Files
openrig/packages/cli/test/workflow-exit-validation.test.ts
mvschwarzandClaude Opus 4.7 2b4772c785 fix(0.3.2 slice 01 BC): HG-6 CLI-side validation — --max-files + --exit (no silent coercion)
Addresses guard BLOCKING-CONCERN verdict on a28f9b71 (guard qitem
qitem-20260516095128-843fbff5; 2 findings).

BLOCK 1 — rig workspace validate --max-files silently coerced garbage.
  `--max-files 12abc` was passed through Number.parseInt which returns
  12, and `--max-files abc` returned NaN that JSON-serialized to null.
  Either way the daemon was called with bogus input.

  Fix: exported parseMaxFilesStrict() in workspace.ts. Pattern
  /^[1-9][0-9]*$/ rejects `12abc` / `abc` / `0` / `-1` / `""` and
  throws an Error carrying fact/consequence/action fields. The
  validate action checks BEFORE withClient runs; on failure emits a
  3-part error (JSON-shape `{ ok: false, error: { fact, consequence,
  action } }` in --json mode; 3-line stderr in human mode), sets
  exitCode = 1, and returns WITHOUT calling the daemon.

  Discriminators (8 new tests in workspace-max-files-validation.test.ts):
    - parseMaxFilesStrict: accepts 1/12/10000; rejects 12abc/abc/""/0/-1;
      the 3-part error carries fact + consequence + action fields
    - end-to-end: --max-files 12abc --json → exit 1, parsed JSON has
      ok=false + 3-part error.fact/consequence/action, client.post()
      never called
    - parameterized check across abc/0/-1
    - human-mode emits 3-part to stderr (Error: ... did not run ... Pass
      a positive integer)

BLOCK 2 — rig workflow project --exit silently forwarded any string.
  TypeScript's union type "handoff" | "waiting" | "done" | "failed"
  is erased at runtime; commander didn't validate. `--exit banana`
  forwarded to /api/workflow/project with body.exit = "banana".

  Fix: exported isProjectExitKind() + PROJECT_EXIT_KINDS in
  workflow.ts. The project action validates opts.exit BEFORE
  withClient runs; on failure emits a 3-part error and returns
  without calling the daemon. Same JSON / human shape as BLOCK 1
  via a shared emit3PartError helper inside workflow.ts (parallel
  to the one in workspace.ts — the two surfaces have separate
  command modules and don't share a deps tree, so duplicating the
  10-line emitter keeps the change minimal).

  Discriminators (6 new tests in workflow-exit-validation.test.ts):
    - isProjectExitKind: accepts every kind in PROJECT_EXIT_KINDS;
      rejects banana/""/HANDOFF (case-sensitive); rejects non-strings
    - end-to-end: --exit banana --json → exit 1, parsed JSON has
      3-part error.fact matching the full enum + got value,
      client.post() never called
    - human-mode emits 3-part to stderr
    - parameterized across ""/HANDOFF/shipped (the latter is a likely
      operator-confusion case from queue closures)

Verification:
- packages/cli npx tsc --noEmit: clean
- 28/28 PASS across workspace-max-files-validation (8) +
  workflow-exit-validation (6) + workflow-ux (14)

Both fixes are minimal additive guards; no existing behavior changed
for valid input. Positive paths (--max-files 12, --exit handoff)
still call the daemon with the parsed value unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-16 02:54:16 -07:00

143 lines
5.0 KiB
TypeScript

// release-0.3.2 slice 01 BC repair — HG-6 discriminators for the
// workflow project --exit enum. Without runtime validation, the
// TypeScript-only enum allowed `--exit banana` to flow through to
// the daemon transactional-scribe surface.
import { describe, expect, it, vi } from "vitest";
import {
workflowCommand,
isProjectExitKind,
PROJECT_EXIT_KINDS,
type WorkflowDeps,
} from "../src/commands/workflow.js";
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
function captureStdout(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = console.log;
console.log = (...args: unknown[]) => {
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
};
return { logs, restore: () => { console.log = original; } };
}
function captureStderr(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = process.stderr.write;
process.stderr.write = ((chunk: unknown) => { logs.push(String(chunk)); return true; }) as typeof process.stderr.write;
return { logs, restore: () => { process.stderr.write = original; } };
}
function fakeRunningDeps(post: ReturnType<typeof vi.fn>): WorkflowDeps {
return {
lifecycleDeps: {} as LifecycleDeps,
clientFactory: () => ({
post,
get: vi.fn(),
postText: vi.fn(),
}) as unknown as ReturnType<WorkflowDeps["clientFactory"]>,
};
}
describe("isProjectExitKind — pure helper", () => {
it("accepts every documented exit kind", () => {
for (const kind of PROJECT_EXIT_KINDS) {
expect(isProjectExitKind(kind)).toBe(true);
}
});
it("rejects garbage strings (BC-2 root cause)", () => {
expect(isProjectExitKind("banana")).toBe(false);
expect(isProjectExitKind("")).toBe(false);
expect(isProjectExitKind("HANDOFF")).toBe(false); // case-sensitive
});
it("rejects non-strings", () => {
expect(isProjectExitKind(undefined)).toBe(false);
expect(isProjectExitKind(null)).toBe(false);
expect(isProjectExitKind(42)).toBe(false);
});
});
describe("rig workflow project --exit — CLI-side discriminator (BLOCK 2)", () => {
it("rejects '--exit banana' with --json: exit=1, body.error 3-part, client.post NOT called", async () => {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const out = captureStdout();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workflowCommand(deps);
await root.parseAsync([
"node", "rig", "project",
"--instance", "WF1",
"--current-packet", "q1",
"--exit", "banana",
"--actor-session", "x@y",
"--json",
]);
out.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode).toBe(1);
expect(post).not.toHaveBeenCalled();
const joined = out.logs.join("\n");
const parsed = JSON.parse(joined);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/--exit must be one of handoff \| waiting \| done \| failed.*banana/);
expect(parsed.error.consequence).toMatch(/did not run/);
expect(parsed.error.action).toMatch(/--exit handoff/);
});
it("emits 3-part error to stderr in human mode", async () => {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const err = captureStderr();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workflowCommand(deps);
await root.parseAsync([
"node", "rig", "project",
"--instance", "WF1",
"--current-packet", "q1",
"--exit", "banana",
"--actor-session", "x@y",
]);
err.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode).toBe(1);
expect(post).not.toHaveBeenCalled();
const joined = err.logs.join("");
expect(joined).toMatch(/Error:.*--exit must be one of/);
expect(joined).toMatch(/banana/);
expect(joined).toMatch(/did not run/);
});
it("rejects each garbage variant ('', 'HANDOFF', 'shipped') without calling the daemon", async () => {
for (const bad of ["", "HANDOFF", "shipped"]) {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const out = captureStdout();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workflowCommand(deps);
await root.parseAsync([
"node", "rig", "project",
"--instance", "WF1",
"--current-packet", "q1",
"--exit", bad,
"--actor-session", "x@y",
"--json",
]);
out.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode, `--exit "${bad}" should fail`).toBe(1);
expect(post, `--exit "${bad}" must not call the daemon`).not.toHaveBeenCalled();
}
});
// Positive-path validation (e.g. --exit handoff forwards correctly)
// is covered by isProjectExitKind pure-helper above; the live action
// path is gated by getDaemonStatus and tested at the integration
// tier, mirroring the slice-01 workflow-ux test pattern.
});