From 6d63ef1db445825fbf31420f5cd276a5ccd57500 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 3 Apr 2026 11:37:52 -0400 Subject: [PATCH] test: add env matrix coverage and PR verification guardrails --- .codex/skills/hack-cli/SKILL.md | 12 ++ .github/PULL_REQUEST_TEMPLATE.md | 25 +++ AGENTS.md | 15 ++ tests/project-run-command.test.ts | 266 +++++++++++------------------- 4 files changed, 145 insertions(+), 173 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 4eafd263..faa834c8 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -91,6 +91,18 @@ Use `hack` as the primary interface for local development. - Use lifecycle processes for long-running host tasks, not ad-hoc terminals. - Inspect via `hack projects --details` and `hack logs `. +## Verification Loops + +- For `hack run` / `hack exec` / env-resolution changes, verify the effective env transition matrix in + `tests/project-run-command.test.ts`. +- Cover omitted env, explicit overlay, explicit `base`, default-overlay resolution, cached runtime-state env, + and target-service running/not-running. +- For lifecycle or startup-process changes, verify `tests/project-lifecycle-processes.test.ts`. +- Preserve `sh -c` semantics, process-group cleanup, stale pane-metadata reconciliation, and interactive stdin + behavior. +- When semantics change, update `docs/env.md` or `docs/lifecycle.md` in the same patch so future agent work starts + from the current contract. + ## Branch Instances Use branch instances to run parallel environments: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..b9e9a110 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,25 @@ +## Summary + +- + +## Verification + +- + +## Release Signal + +- Commit / squash title: `(): ` +- Release intent: `feat` / `fix` / `docs` / `refactor` / `test` / `chore` / `build` / `ci` / `perf` / `revert` +- Should this PR trigger a release signal? `yes` / `no` +- If `no`, explain why: +- If `yes`, include the required changeset or equivalent release artifact in this PR. + +## Semantic Surfaces + +- Does this change affect `hack run`, `hack exec`, env resolution, runtime-state reconciliation, or lifecycle shell/process behavior? `yes` / `no` +- If `yes`, link the targeted tests you added or updated: +- If `yes`, link the matching docs or skill instructions you updated: + +## Risks / Follow-up + +- diff --git a/AGENTS.md b/AGENTS.md index aae2b66d..25862f58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,21 @@ Never use any types and always default to leveraging generics and smart types to - If a change is breaking, mark it explicitly with `!` in the type or scope position and include a `BREAKING CHANGE:` footer explaining the migration impact. - If work lands through a squash merge, the PR title must also follow Conventional Commits so release automation can classify it correctly. - Before pushing or merging, make sure the final commit history or squash title still preserves the intended release signal. +- Every PR must make the release decision explicit: should this change trigger a release signal, and if not, why not. +- If a changeset or equivalent release artifact is expected for that release signal, include it in the same patch instead of leaving the decision to bot feedback. + +## Verification Guardrails + +- If a change affects `hack run`, `hack exec`, env resolution, runtime-state reconciliation, or lifecycle shell/process semantics, the patch must include both targeted tests and matching docs updates. +- For env-sensitive command changes, verify the requested env, effective env, cached runtime-state env, and target-service-running matrix instead of a single happy path. +- For lifecycle changes, verify `sh -c` semantics, process-group cleanup, stale pane/process metadata reconciliation, and interactive stdin behavior. +- When a semantic contract changes, update the closest durable doc or skill instruction in the same patch so future work starts from the current rules. + +## Command Complexity + +- Treat `src/commands/project.ts` and `src/commands/global.ts` as complexity-sensitive surfaces. +- Before adding new branch-heavy logic there, prefer extracting a small helper with a narrow contract and direct tests. +- Do not grow top-level command handlers when the real change is a decision table, state transition, or reusable readiness check. ## Tickets (git-backed) diff --git a/tests/project-run-command.test.ts b/tests/project-run-command.test.ts index b8e5f483..b64ac0aa 100644 --- a/tests/project-run-command.test.ts +++ b/tests/project-run-command.test.ts @@ -79,9 +79,13 @@ function expectNoEnvScopeWarnings(): void { ).toEqual([]); } -test("run applies modern env overlays to service-specific compose overrides", async () => { - const projectRoot = await createProject(); - +async function runPrintenv({ + projectRoot, + envName, +}: { + readonly projectRoot: string; + readonly envName?: string; +}): Promise { const input = { ctx: { cwd: projectRoot, @@ -91,7 +95,7 @@ test("run applies modern env overlays to service-specific compose overrides", as options: { path: projectRoot, project: undefined, - env: "qa", + env: envName, branch: undefined, workdir: undefined, profile: undefined, @@ -101,13 +105,20 @@ test("run applies modern env overlays to service-specific compose overrides", as cmd: ["printenv"], }, raw: { - argv: ["--path", projectRoot, "--env", "qa", "api", "printenv"], + argv: envName + ? ["--path", projectRoot, "--env", envName, "api", "printenv"] + : ["--path", projectRoot, "api", "printenv"], positionals: ["api", "printenv"], }, }, } as unknown as Parameters[0]; - const exitCode = await runCommand.handler(input); + return await runCommand.handler(input); +} + +test("run applies modern env overlays to service-specific compose overrides", async () => { + const projectRoot = await createProject(); + const exitCode = await runPrintenv({ projectRoot, envName: "qa" }); expect(exitCode).toBe(0); expect(runCalls).toHaveLength(1); @@ -158,189 +169,98 @@ test("run does not warn about sibling service scopes in modern env configs", asy ].join("\n"), }); - const input = { - ctx: { - cwd: projectRoot, - cli: CLI_SPEC, - }, - args: { - options: { - path: projectRoot, - project: undefined, - env: undefined, - branch: undefined, - workdir: undefined, - profile: undefined, - }, - positionals: { - service: "api", - cmd: ["printenv"], - }, - raw: { - argv: ["--path", projectRoot, "api", "printenv"], - positionals: ["api", "printenv"], - }, - }, - } as unknown as Parameters[0]; - - const exitCode = await runCommand.handler(input); + const exitCode = await runPrintenv({ projectRoot }); expect(exitCode).toBe(0); expect(runCalls[0]?.noDeps).toBe(false); expectNoEnvScopeWarnings(); }); -test("run skips dependency startup when the local stack is already up", async () => { - const projectRoot = await createProject({ - runningServices: ["api"], - }); - - const input = { - ctx: { - cwd: projectRoot, - cli: CLI_SPEC, +const runDependencyScenarios = [ + { + name: "skips dependency startup when the local base stack is already up", + createProjectInput: { + runningServices: ["api"], }, - args: { - options: { - path: projectRoot, - project: undefined, - env: undefined, - branch: undefined, - workdir: undefined, - profile: undefined, - }, - positionals: { - service: "api", - cmd: ["printenv"], - }, - raw: { - argv: ["--path", projectRoot, "api", "printenv"], - positionals: ["api", "printenv"], - }, + envName: undefined, + expectedNoDeps: true, + }, + { + name: "keeps dependency startup when runtime state exists but the target service is not running", + createProjectInput: { + runtimeComposeProject: "project-run-env-test", }, - } as unknown as Parameters[0]; - - const exitCode = await runCommand.handler(input); - - expect(exitCode).toBe(0); - expect(runCalls[0]?.noDeps).toBe(true); -}); - -test("run keeps dependency startup when cached runtime state is stale", async () => { - const projectRoot = await createProject({ - runtimeComposeProject: "project-run-env-test", - }); - - const input = { - ctx: { - cwd: projectRoot, - cli: CLI_SPEC, + envName: undefined, + expectedNoDeps: false, + }, + { + name: "keeps dependency startup when the requested overlay differs from the live stack env", + createProjectInput: { + runtimeComposeProject: "project-run-env-test", + runningServices: ["api"], }, - args: { - options: { - path: projectRoot, - project: undefined, - env: undefined, - branch: undefined, - workdir: undefined, - profile: undefined, - }, - positionals: { - service: "api", - cmd: ["printenv"], - }, - raw: { - argv: ["--path", projectRoot, "api", "printenv"], - positionals: ["api", "printenv"], + envName: "qa", + expectedNoDeps: false, + }, + { + name: "skips dependency startup when omitted env resolves to the live default overlay", + createProjectInput: { + config: { + env: { + defaultOverlay: "qa", + }, }, + runtimeComposeProject: "project-run-env-test", + runtimeEnvName: "qa", + runningServices: ["api"], }, - } as unknown as Parameters[0]; - - const exitCode = await runCommand.handler(input); - - expect(exitCode).toBe(0); - expect(runCalls[0]?.noDeps).toBe(false); -}); - -test("run keeps dependency startup when the requested env differs from the live stack env", async () => { - const projectRoot = await createProject({ - runtimeComposeProject: "project-run-env-test", - runningServices: ["api"], - }); - - const input = { - ctx: { - cwd: projectRoot, - cli: CLI_SPEC, - }, - args: { - options: { - path: projectRoot, - project: undefined, - env: "qa", - branch: undefined, - workdir: undefined, - profile: undefined, - }, - positionals: { - service: "api", - cmd: ["printenv"], - }, - raw: { - argv: ["--path", projectRoot, "--env", "qa", "api", "printenv"], - positionals: ["api", "printenv"], + envName: undefined, + expectedNoDeps: true, + }, + { + name: "keeps dependency startup when explicit base bypasses a matching default overlay stack", + createProjectInput: { + config: { + env: { + defaultOverlay: "qa", + }, }, + runtimeComposeProject: "project-run-env-test", + runtimeEnvName: "qa", + runningServices: ["api"], }, - } as unknown as Parameters[0]; - - const exitCode = await runCommand.handler(input); - - expect(exitCode).toBe(0); - expect(runCalls[0]?.noDeps).toBe(false); -}); - -test("run skips dependency startup when omitted env resolves to the live default overlay", async () => { - const projectRoot = await createProject({ - config: { - env: { - defaultOverlay: "qa", + envName: "base", + expectedNoDeps: false, + }, + { + name: "keeps dependency startup when omitted env resolves to qa but the live stack recorded base", + createProjectInput: { + config: { + env: { + defaultOverlay: "qa", + }, }, + runtimeComposeProject: "project-run-env-test", + runtimeEnvName: null, + runningServices: ["api"], }, - runtimeComposeProject: "project-run-env-test", - runtimeEnvName: "qa", - runningServices: ["api"], + envName: undefined, + expectedNoDeps: false, + }, +] as const; + +for (const scenario of runDependencyScenarios) { + test(scenario.name, async () => { + const projectRoot = await createProject(scenario.createProjectInput); + const exitCode = await runPrintenv({ + projectRoot, + envName: scenario.envName, + }); + + expect(exitCode).toBe(0); + expect(runCalls[0]?.noDeps).toBe(scenario.expectedNoDeps); }); - - const input = { - ctx: { - cwd: projectRoot, - cli: CLI_SPEC, - }, - args: { - options: { - path: projectRoot, - project: undefined, - env: undefined, - branch: undefined, - workdir: undefined, - profile: undefined, - }, - positionals: { - service: "api", - cmd: ["printenv"], - }, - raw: { - argv: ["--path", projectRoot, "api", "printenv"], - positionals: ["api", "printenv"], - }, - }, - } as unknown as Parameters[0]; - - const exitCode = await runCommand.handler(input); - - expect(exitCode).toBe(0); - expect(runCalls[0]?.noDeps).toBe(true); -}); +} async function createProject(input?: { readonly services?: readonly string[];