enhance(#3884): failure is a value — strict argv, and --pick that signals absence (#3922)

* test(#3884): failing-first coverage for strict argv and absence-signalling --pick

ADR-3473 §8.4 says failure is a value. Three families currently encode failure as
success, and this commit pins each one RED before the fix lands.

Measured on this tree, 2026-08-26:

  gsd-tools generate-slug "test" --pick nonexistent
    -> empty stdout, exit 0                                     (#3365)

  gsd-tools audit-open --pick nonexistent_field
    -> dumps the entire human-readable audit report, exit 0

  gsd-tools generate-slug "Hello World" --raw --pick bogus
    -> prints "hello-world", another field's value, exit 0

  gsd-tools query state.planned-phase 3        (positional, no --phase)
    -> exit 0; STATE.md's "Phase: 2 of 5 (Widget Support)" is overwritten to
       "Phase: null - READY TO EXECUTE" and the frontmatter gains a corrupted
       current_phase_name                                        (#3358)

tests/pick-flag.test.cjs:27 previously asserted the #3365 defect as the contract
("returns empty string for missing field", success === true). That assertion is
replaced by the required behavior rather than deleted.

The new parseNamedArgs block calls the spec-object signature that does not exist
yet, so it fails today by construction. The 11 existing behavior-lock tests are
left untouched here; they are corrected in the implementation commit.

C1/C4 assert at the consumer's output - STATE.md's bytes - per ADR-3180
Decision 4(b). A unit assertion on the parser would have passed throughout this
defect's life.

Design:      .gsd/phase/feat-3884-failure-is-a-value/40-design.md
Test matrix: .gsd/phase/feat-3884-failure-is-a-value/50-test-matrix.md

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* enhance(#3884): failure is a value — strict argv, and --pick that signals absence

Implements ADR-3473 §8.4. Absence, emptiness and failure stop being interchangeable
ways to say "I could not answer".

parseNamedArgs (src/command-arg-projection.cts)
  Takes a spec object with a REQUIRED `positionals: number | 'rest'` and returns the
  hub's Result shape instead of a bare Record. Declaring the positional arity is what
  makes #3358's call site unrepresentable rather than merely detectable: an unrecognized
  flag or a token past the declared boundary is now InvalidArgs, naming the offending
  token and listing the accepted flags. The legacy positional-array call shape throws
  a TypeError — an internal invariant violation per ADR-3473 Decision 2, so a stale
  hand-written .cjs call site fails loudly instead of destructuring undefined off a
  Result. parseNamedArgsOrExit projects a failure onto the caller's error(); it is a
  projection over the one parser, not a second parser.

  Measured before, against a STATE.md with a populated phase-2 block:
    query state.planned-phase 3        (positional, no --phase)
    -> exit 0; "Phase: 2 of 5 (Widget Support)" overwritten to
       "Phase: null - READY TO EXECUTE", frontmatter gains a corrupted
       current_phase_name
  After: exit 1, `unexpected positional argument "3"`, STATE.md byte-identical.
  The flag form is unchanged and still updates STATE.md.

--pick <field> (gsd-core/bin/gsd-tools.cjs)
  extractField returns {found,value}, and the pick block no longer shares one catch
  between "output was not JSON" and "field was absent". An absent field exits 1 with
  pick_field_absent, naming the field and the keys that do exist; non-JSON output exits 1
  with pick_output_not_json instead of dumping the command's entire output. A field that
  is PRESENT with value null, '', 0 or false still prints at exit 0 — that is an answer,
  not a failure, and it is what keeps `--pick count` printing 0 on a fresh project.

  Measured before: `audit-open --pick nonexistent_field` printed the whole human-readable
  audit report at exit 0, and `generate-slug X --raw --pick bogus` printed "hello-world" —
  a different field's value, confidently, at exit 0.

  ADR-3409 Decision 7 explicitly deferred this contract fix to #3473; this is it. The
  sub-issue's "returns 0 when the count is zero OR absent" wording is superseded by the
  ADR rule it implements: zero prints 0, absence exits non-zero. Defaulting absence to 0
  would demote "could not answer" to "the answer is zero" — the hazard
  docs/how-to/resolve-unreachable-guard-findings.md already warns against.

Guard ledger (ADR-3473 Decision 6)
  scripts/lint-unreachable-guard-drift.cjs Detector A is RETIRED. Its premise — that a
  `--pick ... || echo` arm can never fire — is now false, so the shape it forbade is the
  correct idiom and keeping it would forbid the fix. Detector B (glob-consuming cat/ls,
  a nullglob mechanism this change does not touch) is retained in full, as are the shared
  scanner, the escape-marker parser and the baseline. Net: -1 detector, 0 added. The file
  is not deleted.

Call-site audit
  45 prompt-layer --pick invocations, every one a plain X=$(...) assignment — none in an
  if test, && chain, or a pipeline whose status is consumed, and no shell block in
  workflows/commands/agents/references sets -e. Of the 13 (command, field) pairs the
  prompt layer reads, 10 are always present; the 3 sometimes-absent ones each sit behind
  a prior found/existence check. No ADR-3409-class "field the command never produces"
  remains.

Design:      .gsd/phase/feat-3884-failure-is-a-value/40-design.md
Test matrix: .gsd/phase/feat-3884-failure-is-a-value/50-test-matrix.md

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3884): escape untrusted tokens in diagnostics, and cover five unpinned rows

Two review findings, both fixed here rather than recorded as limits.

1. A newline in an untrusted token forged a second stderr line.

   Before, plain-text mode:
     $ gsd-tools query state.planned-phase $'foo\nError: forged second line'
     Error: unexpected positional argument "foo
     Error: forged second line"

   After:
     Error: unexpected positional argument "foo\nError: forged second line"

   --json-errors mode was never affected — io.error runs that payload through
   JSON.stringify. Plain-text mode writes 'Error: ' + message verbatim, and the
   three new InvalidArgs reasons plus the two new --pick diagnostics all
   interpolate a token that comes straight from argv.

   Fixed with ONE shared helper, formatDiagnosticToken (src/io.cts), applied at
   every interpolation site — not a copy per site. It is deliberately NOT
   applied inside error() itself: several callers in this tree emit intentional
   multi-line diagnostics, and escaping newlines there would mangle them.

   The available-top-level-keys list needed the same treatment for a reason the
   review did not anticipate: `frontmatter get <file>` reads an ARBITRARY user
   document and echoes that document's own keys into the diagnostic. Verified
   reachable — a frontmatter key containing a newline reaches the key list — so
   formatKeyForDiagnosticList is guarding a live path, not a hypothetical one.
   Ordinary keys still render plain and unquoted; a fix that merely dropped the
   key would also have passed a "one line" assertion, so the test pins the
   escaped key's presence too.

2. Five behavior-table rows were implemented but nothing pinned them:
   B7  a dotted path that dies partway
   B9  bracket syntax on a non-array
   B10 a negative array index, in and out of range
   B14 a JSON root that is not an object
   B17 an @file: payload over 50KB

   B17 is the load-bearing one. output() writes @file:<path> instead of inline
   JSON past 50000 characters, and --pick resolves that BEFORE parsing; with no
   test, a future reordering of those two steps turns every large result into a
   false pick_output_not_json. The fixture seeds 1200 phase directories and
   measures the payload at 62474 characters, asserting the spill actually
   happened rather than assuming it.

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3884): correct the strict-argv surface against a full verification run

The first full run came back with 90 failures across 12 files, none in the new
tests. They were the argv surface telling me what it actually is. Ten root
causes; each classified before anything was changed.

I over-implemented, and that is reverted.

  ADR-3473 §8.4 says parseNamedArgs rejects "unrecognized and positional
  tokens". It says nothing about a value flag whose value is missing. Making
  that an error was my design decision, not the rule, and it broke a
  deliberately recorded contract: `--prd` with no value resolving to null
  (tests/init.test.cjs emptyPrdValueIsFalsyAndTreatedAsAbsent, row B5;
  tests/section-manifest-init-facts.test.cjs "flag-shaped value"). The
  "requires a value" branch is deleted outright rather than kept behind an
  option — an unused strictness mode is speculative generality. Unknown-flag
  and unexpected-positional rejection, which is what §8.4 actually mandates,
  is unchanged.

--wave needed a third flag kind the original design did not anticipate.

  `--wave N` is documented (commands/gsd/execute-phase.md:4,48) and the
  shipped workflow reconstructs and passes it (execute-phase.md:84), while
  #2932 records token-PRESENCE semantics: the CLI cares only that the flag
  appeared, and the value belongs to the workflow layer. That is neither a
  boolean flag nor a value flag, so `optionalValueFlags` now exists —
  presence-only in `data`, and the validation cursor consumes a following
  non-flag token so it is not reported as a stray positional. Every other
  declared boolean flag was checked against every argument-hint and prose
  usage in commands/, workflows/, agents/ and docs/; `--wave` is the only one
  of this shape.

Five tests were pinning forms that never worked.

  tests/adr857-core-without-capabilities.test.cjs passed
  `init plan-phase --phase 01-stub`, but the documented form is positional
  (docs/CLI-TOOLS.md:776) and the handler reads args[2] — which for that form
  is the literal string "--phase". Measured on the pre-fix build against a
  real .planning/phases/01-stub/ directory:

    init plan-phase 01-stub          -> phase_found=true
    init plan-phase --phase 01-stub  -> phase_found=false

  The test asserted only exit 0 and key presence, so it had been green while
  proving nothing about phase resolution. Corrected to the documented form and
  strengthened to assert phase_found === true. Same class in state.test.cjs
  (`--plan-count`, a flag that does not exist; the real one is `--plans`),
  milestone-archive.test.cjs (`init new-milestone --json`, silently ignored),
  and concurrency-safety.test.cjs (a bare positional field name whose
  OR-assertion passed because a whole-document dump happens to contain the
  substring it looked for).

Six handlers had no argv validation at all — the same #3358 shape this phase
exists to close, found while fixing the rest: init verify-work / phase-op /
review / todos / remove-workspace read args[2] with nothing checking the rest,
and validate health read --repair/--backfill through a bare args.includes()
scan that bypassed the parser entirely. All now go through the seam, so the
flag has one owner.

tests/init-debug.test.cjs rows C4/C5 asserted that an unrecognized flag must
NOT fail. That is the behavior §8.4 removes, and Decision 8 says a caller's
local expectation does not override §8, so they are inverted and renamed —
a test still called "ignores an unrecognized flag" while asserting rejection
would be its own defect. Row C6's point is its PWNED canary; that assertion is
kept verbatim and only its exit-status expectation changed, because the
hostile token is now rejected rather than absorbed.

The blast-radius estimate in 40-design.md is corrected rather than quietly
left wrong. get_impact reported MEDIUM / 8 symbols upstream, and that was
accurate for what the graph can see — parseNamedArgs's callers. It cannot see
that those callers' handlers accept argv shapes wider than the code reading
args[2] suggests, which is where the real surface was.

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3884): withdraw the validate-health tightening, finish the A2/A3 revert

Second full run: 46 failures, down from 90. Four causes, two of them mine.

Reverted `validate health` entirely — it was scope creep, and it broke a real flag.

  ~30 of the 46 read `unknown flag "--json"; accepted: --repair, --backfill`.
  The previous commit routed `validate health` through the parser on the
  reasoning that a flag should have one owner. That was wrong twice over:
  §8.4 names parseNamedArgs and count queries, and `validate health` was never
  a parseNamedArgs call site — it read its flags, just not through the parser,
  so it had no silent-drop defect to fix. Tightening it omitted `--json`, which
  the health-diagnostic suites use heavily. The handler is now byte-for-behaviour
  back to its pre-branch form. `validate context` stays converted: it genuinely
  was a call site, and its `--json` is now declared rather than read by a second
  `args.includes` scan.

  The five handlers that had NO validation at all — init verify-work / phase-op /
  review / todos / remove-workspace — stay fixed. Those read args[2] with nothing
  checking the rest, which is the #3358 shape this phase owns.

Finished the A2/A3 revert. Three tests still encoded the deleted
"a value flag with a missing value is an error" rule, including one added by the
previous commit for that rule. All three now assert the reverted null contract,
and the ones whose titles said "rejected" are renamed — a test named for a
contract it no longer asserts is its own defect.

`--wave=` and `--wave --weird` are correctly rejected. Neither is documented in
commands/gsd/execute-phase.md, gsd-core/workflows/execute-phase.md or docs/, and
neither is emitted by the shipped prompt layer, so both are unrecognized tokens
that §8.4 mandates rejecting. `doesNotConsumeFollowingFlagAsWaveValue` keeps the
property it exists for — asserted directly now, at the parser, that `--wave` does
not swallow a following flag as its value — and only its exit-status expectation
changed.

A contradiction inside this branch, surfaced by the audit and resolved the safe way.

  Two pre-existing #3573 tests call `state begin-phase '2'` and
  `state planned-phase '2'` with a bare positional, relying on the old permissive
  parser to ignore it. This branch's own #3358 regression test requires that exact
  argv to be REJECTED. The two are mutually exclusive.

  Widening the router to accept a bare positional — mirroring complete-phase —
  would have silently re-opened #3358, and was verified to do exactly that: with
  the widened router, `query state.planned-phase 3` returned exit 0 and wrote
  current_phase_name again. It is reverted. docs/CLI-TOOLS.md:116 and
  docs/COMMANDS.md:2192 document only the `--phase N` form for both verbs, so the
  two #3573 tests move to it. Their assertions were never about the call shape —
  only that total_phases survives the resync — and both still pass.

  complete-phase is untouched: its bare positional IS documented, and it keeps the
  dynamic boundary and the negative-space note that record why.

The audit that produced this is in the PR body: for every handler whose declaration
changed, the flags it reads anywhere in its body, the flags the shipped surface
documents, and the shapes the suite passes, compared. The `--json` miss was a
pattern, not an accident — declaring a handler's flags from its parseNamedArgs call
alone misses whatever it reads elsewhere.

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3884): backfill the changeset PR number

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-27 00:12:13 -04:00
committed by GitHub
parent 878f25025c
commit e20744eacb
30 changed files with 1905 additions and 427 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3922
---
**`--pick <field>` now exits non-zero when a field is absent, and `parseNamedArgs` strictly rejects unrecognized flags and stray positionals** — previously an absent `--pick` field printed an empty string at exit 0 (indistinguishable from a genuinely empty answer, #3365), and a stray or unrecognized argv token was silently dropped rather than rejected, in one case corrupting STATE.md by running a command against the wrong phase (#3358). Both now fail loudly instead of silently: `X=$(gsd_run query V --pick F) || X=default` observes the real failure it was written for, and an unrecognized flag or positional exits non-zero naming what was wrong. (#3884)

View File

@@ -286,7 +286,7 @@ Pure, no-I/O seam owning in-file `<!-- gsd:section id="<id>" when="<when>" -->`
### Section Manifest Module
Pure, no-I/O `when=` evaluator over `InvocationFacts`, mapping a document-order list of parsed `gsd:section` sections (Workflow Fragments Module) to an included/excluded partition for one concrete invocation (ADR-1671 Decision items 3 & 4 + migration step 6; epic #1671 Phase 5, #2932). **The evaluator is a LOOKUP, not a parser** — `WHEN_PREDICATES` is a total map from each frozen `WHEN_VOCABULARY` entry (imported unchanged from `workflow-fragments.cjs`, never redeclared) to exactly one predicate over `InvocationFacts` — `{flags: ReadonlySet<string>, phaseNumber: string|null, hasPriorPhases: boolean}` plus optional already-resolved booleans (`needsCodebaseMap`, `phaseMvpMode`, `worktreesEnabled`, `chunkedMode`, `uiPhaseActive`, `fallowEnabled`, …), every field a plain value the caller computed before `selectSections` runs; `flags` is a `ReadonlySet` rather than a plain object because `.has()` carries no prototype hazard — and note `parseNamedArgs` NEVER returns `undefined` for an absent flag (booleans come back `false`, value keys `null`), so "present in the options record" is not token presence. It MUST NOT tokenize, split on operators, or interpret `when=` structure — the moment it parses, the ad-hoc language Greenspun's Tenth Rule warns against has begun. `selectSections(sections, facts)` returns `{included, excluded}` id arrays that together contain every input id exactly once, in the same relative document order, never mutating the input. An unrecognized `when=` value fails closed via a `TypeError` carrying `.reason = REASON.UNKNOWN_WHEN` — never silently excluded — matching the discipline Phase 3 already established for the same vocabulary at parse time. Every predicate treats an absent fact key as falsy without throwing, since the caller (the init CLI seam) may not always populate every field. A coordinated-change guard runs at module load: every `WHEN_VOCABULARY` entry must have exactly one predicate here, so a 5th vocabulary entry added without a matching predicate fails loudly at load time rather than silently falling through to `REASON.UNKNOWN_WHEN` only at run time. Selection output is generated ahead of time into the committed `gsd-core/workflows/section-manifest.json` (`scripts/gen-section-manifest.cjs`, reusing `parseWorkflowSections` unchanged — a second marker parser here would be the `DEFECT.GENERATIVE-FIX` divergence class) rather than derived from markers at run time, because markers are stripped at emit and the installed parent carries no `gsd:section` metadata. Source of truth: `gsd-core/bin/lib/section-manifest.cjs` (generated from `src/section-manifest.cts`). Test anchors: `tests/section-manifest.test.cjs`, `tests/section-manifest.property.test.cjs`, `tests/gen-section-manifest.test.cjs`.
Pure, no-I/O `when=` evaluator over `InvocationFacts`, mapping a document-order list of parsed `gsd:section` sections (Workflow Fragments Module) to an included/excluded partition for one concrete invocation (ADR-1671 Decision items 3 & 4 + migration step 6; epic #1671 Phase 5, #2932). **The evaluator is a LOOKUP, not a parser** — `WHEN_PREDICATES` is a total map from each frozen `WHEN_VOCABULARY` entry (imported unchanged from `workflow-fragments.cjs`, never redeclared) to exactly one predicate over `InvocationFacts` — `{flags: ReadonlySet<string>, phaseNumber: string|null, hasPriorPhases: boolean}` plus optional already-resolved booleans (`needsCodebaseMap`, `phaseMvpMode`, `worktreesEnabled`, `chunkedMode`, `uiPhaseActive`, `fallowEnabled`, …), every field a plain value the caller computed before `selectSections` runs; `flags` is a `ReadonlySet` rather than a plain object because `.has()` carries no prototype hazard — and note `parseNamedArgs` NEVER returns `undefined` for an absent flag (booleans come back `false`, value keys `null`), so "present in the options record" is not token presence — as of ADR-3473 §8.4 / #3884, `parseNamedArgs(args, spec)` returns a `Result` (`{ok:true,data} | {ok:false,kind:'InvalidArgs',...}`, spec requiring an explicit `positionals: number|'rest'`), so this predicate now describes `.data`'s fields on the `ok:true` branch, not a bare returned object; the null/`false`-never-`undefined` guarantee on those fields is unchanged. It MUST NOT tokenize, split on operators, or interpret `when=` structure — the moment it parses, the ad-hoc language Greenspun's Tenth Rule warns against has begun. `selectSections(sections, facts)` returns `{included, excluded}` id arrays that together contain every input id exactly once, in the same relative document order, never mutating the input. An unrecognized `when=` value fails closed via a `TypeError` carrying `.reason = REASON.UNKNOWN_WHEN` — never silently excluded — matching the discipline Phase 3 already established for the same vocabulary at parse time. Every predicate treats an absent fact key as falsy without throwing, since the caller (the init CLI seam) may not always populate every field. A coordinated-change guard runs at module load: every `WHEN_VOCABULARY` entry must have exactly one predicate here, so a 5th vocabulary entry added without a matching predicate fails loudly at load time rather than silently falling through to `REASON.UNKNOWN_WHEN` only at run time. Selection output is generated ahead of time into the committed `gsd-core/workflows/section-manifest.json` (`scripts/gen-section-manifest.cjs`, reusing `parseWorkflowSections` unchanged — a second marker parser here would be the `DEFECT.GENERATIVE-FIX` divergence class) rather than derived from markers at run time, because markers are stripped at emit and the installed parent carries no `gsd:section` metadata. Source of truth: `gsd-core/bin/lib/section-manifest.cjs` (generated from `src/section-manifest.cts`). Test anchors: `tests/section-manifest.test.cjs`, `tests/section-manifest.property.test.cjs`, `tests/gen-section-manifest.test.cjs`.
### Runtime Artifact Layout Module
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). The `.gsd-source` marker (#1477) is a two-party provisioning contract that lets source resolution succeed on the Claude global skills layout, which ships `gsd-core/{bin,contexts,references,templates,workflows}` but no `commands/gsd` source tree for `findInstallSourceRoot` to walk up to: the writer is `bin/install.js`, which writes `<configDir>/.gsd-source` (content: the absolute path to its own `commands/gsd`, terminated by a newline) when `runtime === 'claude' && isGlobal`, guarded by `fs.existsSync` so a half-published package never writes a dangling marker; the reader is `findInstallSourceRoot(configDir)`, which prefers the marker over its walk-up but falls through to the walk-up if the marker is absent, dangling, or empty/whitespace-only. #2871 Phase 2 widens the Module from placement to placement **+** trigger resolution: `resolveTriggerSurface(runtime, scopes, { stems, routerStems?, childToRouters?, registry? }) -> TriggerSurface[]` answers "what does a user type" rather than "where does a file land" — a new, pure function alongside `resolveRuntimeArtifactLayout` (untouched, still 7 callers), never a widened signature. Only `commands` and `skills` are trigger-bearing; `agents`/`kimi-agents` are excluded entirely — an agent is invoked through the Agent/Task tool's `subagent_type`, a separate dispatch interface point, never a `/gsd-<name>` a user types (ADR-2866 amendment below). Each `TriggerSurface` names its `trigger`, `kind`, `scope`, `destPath` (computed through the SAME `namespacedByDir` branch `_copyStaged` uses), `registration` (`'direct'` | `'via-router'`, the latter naming the owning router's `routerTrigger` for a nested-router runtime's concrete child skill — #69), and `shadowedBy` (the winning sibling entry, or `null`). The winner across scopes and kinds is decided by scope rank first (Install Scope Module's `scopeRank`, consumed not re-derived — global outranks local) then by the runtime's new `runtime.triggerPrecedence` descriptor axis (ordered kind names, highest priority first; default `['skills', 'commands']`, required-with-default so a pre-#2871 `capability.json` keeps validating). `shadowedBy` ships unread this phase — Phase 4 (#2873) is its first consumer. See ADR-3660.

View File

@@ -25,13 +25,49 @@ node gsd-tools.cjs <command> [args] [--raw] [--cwd <path>]
**Global flags (CJS):**
| Flag | Description |
| -------------- | ---------------------------------------------------------------------------- |
| `--raw` | Machine-readable output (JSON or plain text, no formatting) |
| `--cwd <path>` | Override working directory (for sandboxed subagents) |
| `--ws <name>` | Workstream context for `.planning/workstreams/<name>` paths |
| Flag | Description |
| ------------------- | ---------------------------------------------------------------------------- |
| `--raw` | Machine-readable output (JSON or plain text, no formatting) |
| `--cwd <path>` | Override working directory (for sandboxed subagents) |
| `--ws <name>` | Workstream context for `.planning/workstreams/<name>` paths |
| `--pick <field>` | Extract one field from a command's JSON output — see [`--pick <field>` contract](#--pick-field-contract) below |
---
### `--pick <field>` contract
`--pick <field>` runs `<command>` as normal, parses its stdout as JSON, and
extracts one field by name (dotted paths and `[N]` array indices are
supported, e.g. `a.b.c`, `directories[-1]`). As of ADR-3473 §8.4 / #3884, the
three possible outcomes are distinguished **by exit code**, never by an
ambiguous empty string:
| Outcome | stdout | stderr | Exit code |
| --- | --- | --- | --- |
| Field present | The field's value, coerced to a string | (none) | `0` |
| Field absent (missing key, out-of-range index, dotted path partially missing, or a non-object JSON root) | empty | Diagnostic naming the field and the available top-level keys (or the actual JSON root type) | `1` (`pick_field_absent`) |
| Command output is not JSON (including `--raw` output, which is plain text/human-readable, not JSON) | empty | Diagnostic saying the output was not JSON | `1` (`pick_output_not_json`) |
A `null` or empty-string (`''`) field value is a real answer, not an absence
— it still prints (an empty line) at exit **0**. Only the *absence of the
field itself* is a failure. This is why `--raw` and `--pick` are, in
practice, mutually exclusive: `--raw` output is not JSON, so combining them
always hits `pick_output_not_json`.
**This replaces the previous behavior.** Before #3884, an absent field (or
non-JSON output) silently printed an empty string at exit `0` — indistinguishable
from a field that genuinely held `null` or `''`. That coercion is gone. The
common shell idiom
```bash
X=$(gsd_run query some.command --pick some_field 2>/dev/null) || X=default
```
now works as written: the `|| X=default` arm fires exactly when the field
could not be resolved, and never fires merely because the resolved value
happens to be empty.
---
## State Commands

View File

@@ -197,6 +197,7 @@
- [Machine-Readable State Contract (`.planning/state.json`)](#166-machine-readable-state-contract-planningstatejson)
- [Stated Failing Direction](#167-stated-failing-direction)
- [Runtime Identity](#168-runtime-identity)
- ["Failure Is a Value" — Strict Argv Rejection and the `--pick` Absence Contract](#3884-failure-is-a-value--strict-argv-rejection-and-the---pick-absence-contract)
---
@@ -3661,6 +3662,82 @@ _Generated by `scripts/gen-features.cjs` — add a fragment under `docs/features
---
### 3884. "Failure Is a Value" — Strict Argv Rejection and the `--pick` Absence Contract
**Purpose:** ADR-3473 §8.4 states the rule directly: absence, emptiness, and
failure are three different things, and a routine that cannot tell them
apart eventually reports the wrong one. Before this change, `gsd-tools` had
two silent instances of exactly that collapse.
**Half one — a stray positional corrupted state, silently (#3358).**
`parseNamedArgs` read only the flags it recognized and dropped everything
else — an unrecognized `--flag` or an extra positional argument (for
example, a stray phase number appended after `state.planned-phase`) was
silently discarded rather than rejected. The caller's own positional read
(`args[2]`, etc.) still worked, so the command ran anyway, on the wrong
phase, and overwrote the previously-current phase block with no error at
all. The fix makes `parseNamedArgs(args, spec)` return the command-routing
hub's own `Result` shape (`{ok:true,data} | {ok:false,kind:'InvalidArgs',...}`)
and requires every call site to declare `positionals: number | 'rest'` — the
count of leading argv slots the caller itself reads directly. An unknown
flag or an unexpected positional past that boundary is now a loud,
non-zero-exit `InvalidArgs` failure instead of a token quietly falling on
the floor. A duplicate flag, a negative-number value (`--plans -1`), and a
documented free-text tail (`init quick <description>`) are deliberately
left alone — none of them are the defect this closes, and forbidding them
would just break working call sites for no gain.
**Half two — `--pick` on an absent field answered `''` at exit 0, exactly
like a present-but-empty one (#3365).** `--pick <field>` extracted one field
from a command's JSON output, but a missing key, an out-of-range array
index, a partially-missing dotted path, or non-JSON output (including a
`--raw` command's output) all rendered the same way: empty stdout, exit
`0`. That is indistinguishable from a field that genuinely holds `null` or
`''` — a real answer. The shell idiom `X=$(… --pick F) || X=default` could
therefore never observe the failure it was written to react to; only a typo
in the verb name would ever make it exit non-zero. `--pick` now exits `1`
with a diagnostic on stderr (`pick_field_absent` naming the field and the
available top-level keys, or `pick_output_not_json` when the output could
not be parsed as JSON at all) whenever the field cannot be resolved. A
present field's value — including `0`, `false`, `null`, and `''` — is
unchanged: those are answers, not failures, and remain exit `0`. See
[CLI-TOOLS.md's `--pick <field>` contract](CLI-TOOLS.md#--pick-field-contract)
for the full outcome table and [json-errors.md](json-errors.md) for the two
new reason codes.
**Why not just default to zero for an absent count.** The sub-issue's own
Done-when checkbox suggested treating an absent field the same as a
zero-valued one. That is rejected on the merits: it demotes *"I could not
answer"* to *"the answer is zero"*, which would make a count-gated shell
guard fire unconditionally on the very projects that could never resolve
the count in the first place — the opposite of what a gate is for.
**Consequence for `scripts/lint-unreachable-guard-drift.cjs`.** That guard's
Detector A existed specifically because the old `--pick` behavior made a
`--pick … || echo <default>` line's fallback arm permanently unreachable.
Once `--pick` exits non-zero on absence, that premise is false and the
shape the detector forbade becomes the *correct* idiom — so Detector A was
retired rather than kept. Detector B (the unrelated `cat`/`ls`-over-a-glob
nullglob hazard) is untouched. See
[Resolve unreachable-guard findings, Shape A](how-to/resolve-unreachable-guard-findings.md#shape-a---pick-with-an--echo-fallback-resolved-upstream-3884).
**Known limits:**
- A value token beginning with `--` still cannot be passed to a declared
value flag (`--summary "--force is now default"` now fails loudly instead
of silently dropping the value) — strictly better, but no `--flag=value`
escape was added.
- `--pick` still cannot distinguish an absent field from a `null` one *on
stdout alone* — the distinction is carried entirely by exit code.
- The ~10 `gsd-tools.cjs` call sites of `parseNamedArgs` get no
compile-time check (that file is hand-written JavaScript, not `.cts`);
enforcement there is the runtime throw on a stale legacy call shape plus
behavioral tests.
- This phase does not sweep every routine in `gsd-core` for `Result`
conformance — it applies the rule to the argument-projection seam and the
`--pick` extractor it names, not the whole codebase.
---
_Generated by `scripts/gen-features.cjs` — add a fragment under `docs/features/` and run `--write`._
<!-- FEATURES:END -->

View File

@@ -493,7 +493,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `codex-agent-toml.cjs` | Typed IR (genuine leaf) for `~/.codex/agents/<agent>.toml` — `parseCodexAgentToml`/`renderCodexAgentToml` round-trip byte-identically; `stripModel`/`stripReasoningEffort` remove exactly one targeted line; `scanTomlLines`/`stripBOM`/`findDeveloperInstructionsBlockRange`/`unquoteTomlValue` are the lenient reader primitives moved here from `agent-install-check.cjs` (#3242 Phase 2); consumed by the Codex `.toml` sync (`commands.cjs cmdEffortSyncCodex`, ADR-2313 D7, #3243) |
| `command-aliases.cjs` | Alias/subcommand metadata for manifest-backed family routers |
| `commonjs-marker.cjs` | Ownership-guarded `{"type":"commonjs"}` marker used to pin GSD's staged `.js` scripts to CommonJS; exports `classifyMarker` (absent/gsd-owned/foreign, fail-closed), `ensureCommonJsMarker`, and `removeCommonJsMarker` so install and uninstall share one predicate and never touch a user-authored `package.json` (#2544) |
| `command-arg-projection.cjs` | Typed flag and positional argument projection helpers shared across command-family routers |
| `command-arg-projection.cjs` | Strict, `Result`-returning flag and positional argument projection helpers shared across command-family routers — `parseNamedArgs` rejects unrecognized flags and stray positionals instead of silently dropping them (ADR-3473 §8.4, #3884). Generated from `src/command-arg-projection.cts` |
| `command-roster.cjs` | Read-only discovery of canonical `commands/gsd/*.md` command stems for runtime artifact conversion and namespace rewrites |
| `command-routing-hub.cjs` | Pure-result dispatch hub that centralizes mode decision (SDK vs CJS), error taxonomy, and no-throw contract for all command-family routers (#3788) |
| `commands.cjs` | Misc CLI commands (slug, timestamp, todos, scaffolding, stats) |

View File

@@ -0,0 +1,77 @@
---
id: 3884
title: "Failure Is a Value" — Strict Argv Rejection and the `--pick` Absence Contract
group: v1.7.0 Features
---
**Purpose:** ADR-3473 §8.4 states the rule directly: absence, emptiness, and
failure are three different things, and a routine that cannot tell them
apart eventually reports the wrong one. Before this change, `gsd-tools` had
two silent instances of exactly that collapse.
**Half one — a stray positional corrupted state, silently (#3358).**
`parseNamedArgs` read only the flags it recognized and dropped everything
else — an unrecognized `--flag` or an extra positional argument (for
example, a stray phase number appended after `state.planned-phase`) was
silently discarded rather than rejected. The caller's own positional read
(`args[2]`, etc.) still worked, so the command ran anyway, on the wrong
phase, and overwrote the previously-current phase block with no error at
all. The fix makes `parseNamedArgs(args, spec)` return the command-routing
hub's own `Result` shape (`{ok:true,data} | {ok:false,kind:'InvalidArgs',...}`)
and requires every call site to declare `positionals: number | 'rest'` — the
count of leading argv slots the caller itself reads directly. An unknown
flag or an unexpected positional past that boundary is now a loud,
non-zero-exit `InvalidArgs` failure instead of a token quietly falling on
the floor. A duplicate flag, a negative-number value (`--plans -1`), and a
documented free-text tail (`init quick <description>`) are deliberately
left alone — none of them are the defect this closes, and forbidding them
would just break working call sites for no gain.
**Half two — `--pick` on an absent field answered `''` at exit 0, exactly
like a present-but-empty one (#3365).** `--pick <field>` extracted one field
from a command's JSON output, but a missing key, an out-of-range array
index, a partially-missing dotted path, or non-JSON output (including a
`--raw` command's output) all rendered the same way: empty stdout, exit
`0`. That is indistinguishable from a field that genuinely holds `null` or
`''` — a real answer. The shell idiom `X=$(… --pick F) || X=default` could
therefore never observe the failure it was written to react to; only a typo
in the verb name would ever make it exit non-zero. `--pick` now exits `1`
with a diagnostic on stderr (`pick_field_absent` naming the field and the
available top-level keys, or `pick_output_not_json` when the output could
not be parsed as JSON at all) whenever the field cannot be resolved. A
present field's value — including `0`, `false`, `null`, and `''` — is
unchanged: those are answers, not failures, and remain exit `0`. See
[CLI-TOOLS.md's `--pick <field>` contract](CLI-TOOLS.md#--pick-field-contract)
for the full outcome table and [json-errors.md](json-errors.md) for the two
new reason codes.
**Why not just default to zero for an absent count.** The sub-issue's own
Done-when checkbox suggested treating an absent field the same as a
zero-valued one. That is rejected on the merits: it demotes *"I could not
answer"* to *"the answer is zero"*, which would make a count-gated shell
guard fire unconditionally on the very projects that could never resolve
the count in the first place — the opposite of what a gate is for.
**Consequence for `scripts/lint-unreachable-guard-drift.cjs`.** That guard's
Detector A existed specifically because the old `--pick` behavior made a
`--pick … || echo <default>` line's fallback arm permanently unreachable.
Once `--pick` exits non-zero on absence, that premise is false and the
shape the detector forbade becomes the *correct* idiom — so Detector A was
retired rather than kept. Detector B (the unrelated `cat`/`ls`-over-a-glob
nullglob hazard) is untouched. See
[Resolve unreachable-guard findings, Shape A](how-to/resolve-unreachable-guard-findings.md#shape-a---pick-with-an--echo-fallback-resolved-upstream-3884).
**Known limits:**
- A value token beginning with `--` still cannot be passed to a declared
value flag (`--summary "--force is now default"` now fails loudly instead
of silently dropping the value) — strictly better, but no `--flag=value`
escape was added.
- `--pick` still cannot distinguish an absent field from a `null` one *on
stdout alone* — the distinction is carried entirely by exit code.
- The ~10 `gsd-tools.cjs` call sites of `parseNamedArgs` get no
compile-time check (that file is hand-written JavaScript, not `.cts`);
enforcement there is the runtime throw on a stale legacy call shape plus
behavioral tests.
- This phase does not sweep every routine in `gsd-core` for `Result`
conformance — it applies the rule to the argument-projection seam and the
`--pick` extractor it names, not the whole codebase.

View File

@@ -13,7 +13,7 @@ acknowledging is the right answer. For *why* the invariant exists, see
```
unreachable-guard-drift: NEW unreachable shell-guard shape(s) found in the prompt layer.
gsd-core/workflows/ship.md:312 --pick STATUS=$(gsd_run query verification.status "$D" --pick status 2>/dev/null || echo "")
gsd-core/workflows/ship.md:312 cat cat .planning/phases/*-*/*-SUMMARY.md
```
Each line is `file:line`, the token that matched, and the offending source line.
@@ -37,58 +37,65 @@ outcomes and only one of them is good news.
|---|---|---|
| `ok_no_violations` | 0 | Clean. Every scanned file passed. |
| `ok_baseline_updated` | 0 | You ran `--update`; the baseline was rewritten. |
| `fail_fresh_violation` | 1 | A new instance of one of the two shapes. **Fix it** — see below. |
| `fail_fresh_violation` | 1 | A new instance of Shape B (Shape A was retired upstream, #3884 — see below). **Fix it** — see below. |
| `fail_stale_entry` | 1 | A baseline entry matched fewer occurrences than it acknowledges. Either a site was migrated (good — re-record) or only *some* copies were (finish the job). |
| `fail_malformed_marker` | 1 | A `# gsd-scan-ignore:` whose reason names no issue or URL. Not a violation — a broken exemption. |
| `fail_baseline_load` | 1 | The baseline file is missing, empty, not JSON, or structurally wrong. The guard **could not look**; this is not a clean run. |
## Shape A — `--pick` with an `|| echo` fallback
## Shape A — `--pick` with an `|| echo` fallback (resolved upstream, #3884)
**This shape is no longer a finding.** As of #3884 (ADR-3473 §8.4), `--pick
<field>` exits **non-zero** — not `0` — when the field is absent, so the
guard no longer flags a line carrying both `--pick` and `|| echo`; see the
[`--pick <field>` contract](../CLI-TOOLS.md#--pick-field-contract) for the
full three-outcome table. The example below is kept for historical context
(this page previously taught the workaround for the defect), and because the
shape it shows is now the **correct, idiomatic** way to write this:
```bash
# BROKEN — the fallback can never fire
# Previously BROKEN (pre-#3884): the fallback could never fire, because an
# absent field printed '' at exit 0. As of #3884 this now works as written —
# `|| echo "false"` fires exactly when `active` cannot be resolved.
AUTO_MODE=$(gsd_run query check auto-mode --pick active 2>/dev/null || echo "false")
```
`--pick` renders a missing or absent field as the **empty string and exits 0**.
`gsd_run` passes that exit code straight through, so `||` only ever fires on a
typo in the verb name — never on the field absence you wrote it for.
Test it yourself before assuming a field exists:
You can confirm the new behavior directly:
```bash
node gsd-core/bin/gsd-tools.cjs query phases.list --pick a_field_that_does_not_exist; echo "exit=$?"
```
That prints nothing and exits `0`.
That now prints nothing on stdout, a diagnostic on stderr, and exits `1`
(`pick_field_absent`) — not `0`.
**Fix — test the value, not the exit code:**
**The two-line workaround this page used to prescribe is no longer
required, but remains harmless:**
```bash
AUTO_MODE=$(gsd_run query check auto-mode --pick active 2>/dev/null)
AUTO_MODE="${AUTO_MODE:-false}"
```
`${VAR:-default}` is exactly the empty-or-unset test, and unlike
`[ -z "$VAR" ] && VAR=default` it cannot abort a `set -e` shell when the value
is non-empty.
It still works exactly as before — `--pick` still prints `''` at exit `0`
when the field is present but `null` or empty (that is an answer, not a
failure; see the contract's negative space), so `${VAR:-default}` still
resolves those cases the same way it always did. It is simply no longer the
*only* reachable way to supply a default: the single-line `|| echo` idiom at
the top of this section now works too.
**When the safe direction is "do nothing", compare against the literal instead**
and add no default at all:
**A count that returns zero is still not the same as a count that could not
be resolved** — this half of the contract is unchanged by #3884 and remains
the reason a `:-0` default on an absent field would be a bug:
```bash
PRIOR_SUMMARIES=$(gsd_run query phases.list --type summaries --pick count 2>/dev/null)
if [ "$PRIOR_SUMMARIES" = "0" ]; then WALKING_SKELETON=true; fi
```
Here a `:-0` default would be a bug: it turns "the query could not answer" into
"there are zero summaries" and fires the gate unconditionally. Only a literal
`0` should act; anything else correctly does nothing.
**If the field does not exist at all, repoint the query — do not paper over it.**
The Walking Skeleton gate read `--pick summaries_total`, a field `phases.list`
has never produced under any flag combination, so the gate had never fired on
any project. The fix was to ask the owner that *does* answer it
(`--type summaries --pick count`), not to default the empty away.
`phases.list --type summaries --pick count` always returns a real integer
(never absent), so this comparison is safe as written; a query that *can*
return an absent field must still not paper over that with a `:-0` default —
repoint the query to one that actually answers, the same guidance as before.
## Shape B — a glob whose command succeeds on zero matches
@@ -183,6 +190,7 @@ the file most likely to grow the next copy.
## Related
- [ADR-3409](../adr/3409-unreachable-shell-guard-arms.md) — the invariant, the measurements behind both detectors, and the alternatives rejected
- [ADR-3409](../adr/3409-unreachable-shell-guard-arms.md) — the invariant, the measurements behind both detectors (one since retired), and the alternatives rejected
- [ADR-3180](../adr/3180-planning-semantic-model-single-owner.md) — the ratchet and whole-repo-discovery mechanism this guard reuses
- [CLI-TOOLS.md's `--pick <field>` contract](../CLI-TOOLS.md#--pick-field-contract) — the #3884 fix that resolved Shape A upstream and retired its detector
- [Resolve edge-coverage findings](resolve-edge-coverage-findings.md) · [Resolve prohibition findings](resolve-prohibition-findings.md) — sibling "the loop surfaced something, here is what to do with it" pages

View File

@@ -193,6 +193,13 @@ text (unstable).
| `usage` | Version flag (`--version`, `-v`) which gsd-tools never accepts |
| `usage` | Top-level no-args invocation (usage text) |
### `--pick <field>` errors (ADR-3473 §8.4, #3884)
| Code | When emitted |
|------|-------------|
| `pick_field_absent` | `--pick <field>` names a field that does not exist in the command's JSON output (missing key, out-of-range index, a partially-missing dotted path, or a non-object JSON root) — see [CLI-TOOLS.md's `--pick` contract](CLI-TOOLS.md#--pick-field-contract) |
| `pick_output_not_json` | `--pick <field>` is combined with a command whose output is not JSON (including `--raw` output) |
### Config errors (`config-get`, `config-set`, `config-ensure-section`)
| Code | When emitted |

View File

@@ -255,7 +255,7 @@ try {
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const io = require('./lib/io.cjs');
const { error, ERROR_REASON, setJsonErrorMode, output } = io;
const { error, ERROR_REASON, setJsonErrorMode, output, formatDiagnosticToken } = io;
const projectRoot = require('./lib/project-root.cjs');
// Resolve findProjectRoot lazily at call time rather than binding it at module
// load. It is sourced from project-root.cjs; a call-time lookup is robust
@@ -325,7 +325,7 @@ const { routeAgentCommand, AGENT_FAILURE_CLASSES } = require('./lib/agent-comman
const smartEntryMod = require('./lib/smart-entry.cjs');
const { routeCheckCommand } = require('./lib/check-command-router.cjs');
const { routeTaskCommand } = require('./lib/task-command-router.cjs');
const { parseNamedArgs, parseMultiwordArg } = require('./lib/command-arg-projection.cjs');
const { parseNamedArgsOrExit, parseMultiwordArg } = require('./lib/command-arg-projection.cjs');
const { cmdGitBaseBranch } = require('./lib/git-base-branch.cjs');
const { getEffectiveAuthority, classifyDriftSeverity, comparePhaseStatus } = require('./lib/plan-drift-guard.cjs');
@@ -963,7 +963,16 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
function routePrSubrepo({ args, cwd, raw, error }) {
const message = args[1];
const { repo, branch } = parseNamedArgs(args, ['repo', 'branch']);
// #3884: the commit message is an optional leading positional the
// caller owns (args[1]) — but when it is OMITTED, args[1] is itself
// the first flag (e.g. `--repo`), and a static `positionals: 2`
// treats that flag's own value as an unexpected trailing positional
// before cmdPrSubrepo's own "commit message required" guard ever
// runs. Widen the boundary only when args[1] genuinely looks like a
// message (not flag-shaped), mirroring the same fix applied to
// `state complete-phase`.
const messagePresent = message !== undefined && !message.startsWith('--');
const { repo, branch } = parseNamedArgsOrExit(args, { valueFlags: ['repo', 'branch'], positionals: messagePresent ? 2 : 1 }, error);
commands.cmdPrSubrepo(cwd, repo, branch, message, raw);
}
@@ -980,7 +989,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
template.cmdTemplateSelect(cwd, args[2], raw);
} else if (subcommand === 'fill') {
const templateType = args[2];
const { phase, plan, name, type, wave, fields: fieldsRaw } = parseNamedArgs(args, ['phase', 'plan', 'name', 'type', 'wave', 'fields']);
const { phase, plan, name, type, wave, fields: fieldsRaw } = parseNamedArgsOrExit(args, { valueFlags: ['phase', 'plan', 'name', 'type', 'wave', 'fields'], positionals: 3 }, error);
let fields = {};
if (fieldsRaw) {
const { safeJsonParse } = require('./lib/security.cjs');
@@ -1029,14 +1038,14 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
}
// CJS fallback (SDK unavailable or unknown subcommand)
if (subcommand === 'get') {
frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgs(args, ['field']).field, raw);
frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['field'], positionals: 3 }, error).field, raw);
} else if (subcommand === 'set') {
const { field, value } = parseNamedArgs(args, ['field', 'value']);
const { field, value } = parseNamedArgsOrExit(args, { valueFlags: ['field', 'value'], positionals: 3 }, error);
frontmatter.cmdFrontmatterSet(cwd, file, field, value !== null ? value : undefined, raw);
} else if (subcommand === 'merge') {
frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgs(args, ['data']).data, raw);
frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['data'], positionals: 3 }, error).data, raw);
} else if (subcommand === 'validate') {
frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgs(args, ['schema']).schema, raw);
frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['schema'], positionals: 3 }, error).schema, raw);
} else {
error('Unknown frontmatter subcommand. Available: get, set, merge, validate', ERROR_REASON.SDK_UNKNOWN_COMMAND);
}
@@ -1160,7 +1169,15 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
// to the pure appendQuickTaskRow (markdown-table.cjs); this case only
// handles the I/O (read STATE.md, resolve date/commit, write STATE.md).
const qtaArgs = args.slice(1);
const qtaTask = parseNamedArgs(qtaArgs, ['task']).task || args[1];
// Ambiguous boundary (ADR-3473 §8.4 Item 2 note): this command accepts
// EITHER a positional free-text description (qtaArgs[0]) OR --task
// <value> — the same token index is a caller-owned positional in one
// input shape and a flag in the other, which a single fixed
// `positionals` cursor cannot represent. `positionals: 'rest'`
// disables the boundary walk (as with `init quick`) so extraction
// (used for the --task form) and the `|| args[1]` fallback (used for
// the positional form) both keep working unchanged.
const qtaTask = parseNamedArgsOrExit(qtaArgs, { valueFlags: ['task'], positionals: 'rest' }, error).task || args[1];
if (!qtaTask) {
error('quick-tasks-append requires --task <description> (or a positional description)', ERROR_REASON.USAGE);
}
@@ -2429,11 +2446,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
const subcommand = args[1];
if (subcommand === 'render-checkpoint') {
const uat = require('./lib/uat.cjs');
const options = parseNamedArgs(args, ['file']);
const options = parseNamedArgsOrExit(args, { valueFlags: ['file'], positionals: 2 }, error);
uat.cmdRenderCheckpoint(cwd, options, raw);
} else if (subcommand === 'classify-coverage') {
const coverage = require('./lib/coverage.cjs');
const options = parseNamedArgs(args, ['summary', 'file']);
const options = parseNamedArgsOrExit(args, { valueFlags: ['summary', 'file'], positionals: 2 }, error);
coverage.cmdClassify(cwd, options, raw);
} else {
error('Unknown uat subcommand. Available: render-checkpoint, classify-coverage', ERROR_REASON.SDK_UNKNOWN_COMMAND);
@@ -2458,8 +2475,13 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
function routeScaffold({ args, cwd, raw, error }) {
const scaffoldType = args[1];
// `--name` is multi-word (consumed separately by parseMultiwordArg,
// below) — a token count the single-token-per-flag boundary walk
// cannot represent. `positionals: 'rest'` disables that walk for
// this call, matching the existing (unchanged) permissive behavior
// for --name; --phase extraction is unaffected either way.
const scaffoldOptions = {
phase: parseNamedArgs(args, ['phase']).phase,
phase: parseNamedArgsOrExit(args, { valueFlags: ['phase'], positionals: 'rest' }, error).phase,
name: parseMultiwordArg(args, 'name'),
};
commands.cmdScaffold(cwd, scaffoldType, scaffoldOptions, raw);
@@ -4510,19 +4532,37 @@ async function main() {
}
// When --pick is active, capture stdout and extract the requested field.
// ADR-3473 §8.4 (#3365, #3358): an absent field or non-JSON command output
// is a failure ("I could not answer"), never a demotion to an empty answer
// at exit 0. `resolveAtFileOutput` MUST run before JSON.parse — @file:
// payloads (io.cjs output() writes these for JSON > 50KB) are not
// themselves JSON text, so resolving late would make every large result a
// false "output was not JSON" (negative space N8).
if (pickField) {
const captured = await captureStdoutSyncWrites(async () => {
await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext);
});
const resolved = resolveAtFileOutput(captured);
let obj;
try {
const obj = JSON.parse(resolved);
const value = extractField(obj, pickField);
const result = value === null || value === undefined ? '' : String(value);
fs.writeSync(1, result);
obj = JSON.parse(resolved);
} catch {
fs.writeSync(1, captured);
error(`--pick ${formatDiagnosticToken(pickField)}: command output was not JSON`, ERROR_REASON.PICK_OUTPUT_NOT_JSON);
return;
}
const { found, value } = extractField(obj, pickField);
if (!found) {
const rootDescription = isPlainRecord(obj)
? `available top-level keys: ${Object.keys(obj).map(formatKeyForDiagnosticList).join(', ') || '(none)'}`
: `the command's output is a JSON ${describeJsonRootType(obj)}, not an object with that field`;
error(`--pick ${formatDiagnosticToken(pickField)}: field not found; ${rootDescription}`, ERROR_REASON.PICK_FIELD_ABSENT);
return;
}
// N1/N2: `null` and `''` are answers, not failures — an absent field
// above already exited non-zero, so reaching here means the field EXISTS
// and this is its real value (including `0` and `false`, #3365).
const result = value === null || value === undefined ? '' : String(value);
fs.writeSync(1, result);
return;
}
@@ -4584,27 +4624,68 @@ function resolveAtFileOutput(captured) {
return fs.readFileSync(captured.slice(6), 'utf-8');
}
// A plain object root/intermediate value — everything else (null, an array,
// a number, a string, a boolean) is treated as non-object for NAMED-key
// lookup purposes (#3365 / #3358, ADR-3473 §8.4): only bracket notation may
// reach into an array.
function isPlainRecord(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
// Describes the JSON root's shape for a --pick "field not found" message
// when the root is NOT a plain object (so listing "top-level keys" would be
// meaningless).
function describeJsonRootType(v) {
if (Array.isArray(v)) return 'array';
if (v === null) return 'null';
return typeof v;
}
// A command's JSON output can be a USER-authored document (e.g. `frontmatter
// get`), so its top-level keys are untrusted the same way an argv token is.
// `formatDiagnosticToken` (io.cjs) is the shared escape (see its JSDoc for
// why `error()` cannot do this itself); this thin wrapper reuses that exact
// escaping but strips the surrounding quotes JSON.stringify adds, so a key
// list reads as "a, b, c" rather than the noisier "\"a\", \"b\", \"c\"" while
// a key containing \n/\r/\t/other C0 bytes still cannot forge a second
// stderr "Error:" line or span more than one line.
function formatKeyForDiagnosticList(key) {
return formatDiagnosticToken(key).slice(1, -1);
}
/**
* Extract a field from an object using dot-notation and bracket syntax.
* Supports: 'field', 'parent.child', 'arr[-1]', 'arr[0]'
*
* Returns a discriminated `{ found, value }` rather than a bare value so a
* caller can distinguish "the field exists and is null/''/0/false" (an
* ANSWER, exit 0) from "no such field" (an absence, exit non-zero) — #3365.
* Reports NOT-FOUND for: a missing key; a dotted path that dies partway; an
* array index out of range (after negative-index normalization); a bracket
* applied to a non-array; and any key lookup against a non-object (null, a
* number, a string, a boolean, or an array root).
*/
function extractField(obj, fieldPath) {
const parts = fieldPath.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
const bracketMatch = part.match(/^(.+?)\[(-?\d+)]$/);
if (bracketMatch) {
const key = bracketMatch[1];
const index = parseInt(bracketMatch[2], 10);
current = current[key];
if (!Array.isArray(current)) return undefined;
current = index < 0 ? current[current.length + index] : current[index];
if (!isPlainRecord(current)) return { found: false, value: undefined };
const arr = current[key];
if (!Array.isArray(arr)) return { found: false, value: undefined };
const resolvedIndex = index < 0 ? arr.length + index : index;
if (resolvedIndex < 0 || resolvedIndex >= arr.length) return { found: false, value: undefined };
current = arr[resolvedIndex];
} else {
if (!isPlainRecord(current)) return { found: false, value: undefined };
if (!Object.prototype.hasOwnProperty.call(current, part)) return { found: false, value: undefined };
current = current[part];
}
}
return current;
return { found: true, value: current };
}
async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null) {

View File

@@ -45,6 +45,7 @@ const DOCS_GUARD_EXEMPT_BASELINE = [
'commands.test.cjs',
'commit-docs-bypass.test.cjs',
'complexity-trigger.test.cjs',
'concurrency-safety.test.cjs',
'cursor-imperative-reference.test.cjs',
'declarative-reference-antigravity.test.cjs',
'declarative-reference-zcode.test.cjs',
@@ -125,6 +126,10 @@ const DOCS_GUARD_EXEMPT_DOCS_PATHS = {
'docs/40-design.md', 'docs/CONFIGURATION.md', 'docs/readme.md', 'docs/tracked-var-mentioning',
],
'complexity-trigger.test.cjs': ['docs/readme.md'],
// #3884: cites docs/CLI-TOOLS.md:736 in an explanatory comment describing
// the real `frontmatter get <file> [--field key]` CLI shape; the file
// never reads that (or any) docs/ file.
'concurrency-safety.test.cjs': ['docs/CLI-TOOLS.md'],
'cursor-imperative-reference.test.cjs': ['docs/sdk/typescript'],
'declarative-reference-antigravity.test.cjs': ['docs/cli/features'],
'declarative-reference-zcode.test.cjs': ['docs/reference/host-integration-capability-matrix.md'],
@@ -151,7 +156,11 @@ const DOCS_GUARD_EXEMPT_DOCS_PATHS = {
'docs/agents', 'docs/agents/triage-labels.md',
],
'manifest-version-sync.test.cjs': [],
'milestone-archive.test.cjs': ['docs/TESTING-SUITES.md'],
// #3884: re-confirmed — the added docs/CLI-TOOLS.md:458 reference is the
// same class as the existing docs/TESTING-SUITES.md one (a placement-note
// / explanatory comment citing documented CLI behavior for context, never
// a read target); the exemption's premise still holds for both.
'milestone-archive.test.cjs': ['docs/CLI-TOOLS.md', 'docs/TESTING-SUITES.md'],
'model-resolver.test.cjs': ['docs/TESTING-SUITES.md'],
'new-project-mvp-prompt.test.cjs': ['docs/CONFIGURATION.md'],
'onboard-command.test.cjs': ['docs/adr/0001-runtime.md'],

View File

@@ -8,37 +8,43 @@
* Design: .gsd/phase/feat-3409-unreachable-shell-guard-lint/40-design.md
* Test matrix: .gsd/phase/feat-3409-unreachable-shell-guard-lint/50-test-matrix.md
*
* `gsd-tools.cjs`'s `--pick <field>` extractor coerces a missing/absent
* field to the empty string and exits **0** (probe-confirmed in
* 40-design.md). So in `$(gsd_run query V --pick F 2>/dev/null || echo D)`
* the `|| echo D` arm can fire only on a typo in the verb name — never on
* the field absence it was written to handle. Three shipped shell guards
* silently relied on that unreachable arm (#3365's Walking Skeleton gate,
* `PHASE_REQ_IDS`, and `complete-milestone.md`'s bare `cat <glob>`, all
* fixed alongside this guard — see `tests/unreachable-shell-guard.test.cjs`,
* which this file does not touch).
* RETIRED — Detector A (`--pick` + `|| echo` on one line), #3884.
* `gsd-tools.cjs`'s `--pick <field>` extractor used to coerce a missing/
* absent field to the empty string and exit **0**, which made the `|| echo D`
* arm in `$(gsd_run query V --pick F 2>/dev/null || echo D)` unreachable on
* field absence — the exact defect Detector A existed to flag (this file's
* own prior header quoted the premise verbatim: "the `|| echo D` arm can
* fire only on a typo in the verb name, never on the field absence it was
* written to handle"). ADR-3473 §8.4 ("Failure is a value") makes `--pick`
* exit **non-zero** on an absent field (see
* `.gsd/phase/feat-3884-failure-is-a-value/40-design.md` rows B6-B14), so
* that premise is now FALSE: the `|| echo D` arm is reachable, and the shape
* Detector A forbade is the CORRECT idiom going forward. Keeping Detector A
* would forbid the fix, so it is removed rather than updated — see this
* file's Guard ledger entry in 40-design.md ("net: −1 detector, 0 added").
* `docs/how-to/resolve-unreachable-guard-findings.md` Shape A was updated in
* the same change (#3884) to say the same thing. The three shell guards this
* file's Detector A shipped alongside (#3365's Walking Skeleton gate,
* `PHASE_REQ_IDS`, `complete-milestone.md`'s bare `cat <glob>`) were fixed
* under #3409 with remedies that never took the now-retired shape (a bare
* `--pick` with no fallback, a two-line `X=…`/`X="${X:-D}"` split, and an
* array expansion, respectively) — see
* `tests/unreachable-shell-guard.test.cjs`, which this file does not touch
* and which #3884 confirmed still passes unchanged.
*
* TWO detectors, each narrow by design (mirroring the sibling drift guards'
* precedent of a small, specific shape rather than a wide heuristic):
*
* Detector A — a line carrying BOTH a `--pick` token AND a `|| echo`
* fallback. `--pick` is the discriminator: a `|| echo` default WITHOUT
* `--pick` (e.g. `config-get k 2>/dev/null || echo "default"`, ~132 of the
* 141 `$(… || echo …)` lines in the prompt layer) genuinely observes a
* nonzero exit code and is left alone — see 40-design.md's Rejected #2
* ("detect on `gsd_run` + `|| echo`" was tried and reverted for exactly
* this false-positive volume).
* ONE detector remains, unaffected by the above — its mechanism (nullglob
* success-on-empty) has nothing to do with `--pick`'s exit code:
*
* Detector B — `cat` or `ls` invoked in COMMAND POSITION with an operand
* containing an unquoted glob metacharacter (`*` or `?`). Both detectors
* are, at bottom, the SAME shape: a fallback/guard arm that a
* success-on-empty case silently defeats. Detector A is `--pick … ||
* echo`; Detector B-ii below is `ls <glob> … || echo` — the identical
* defect, one level down the stack, with `ls`'s own nullglob-driven
* success-on-empty standing in for `--pick`'s absence-coerced-to-''.
* SCOPED to exactly three fired shapes, per a full measurement across the
* four SCAN_DIRS (measured counts recorded in the PR description; 0 sites
* for B-iii today, by design — see KNOWN LIMITS):
* containing an unquoted glob metacharacter (`*` or `?`). At bottom the
* same class of bug Detector A used to catch one level up the stack: a
* fallback/guard arm that a success-on-empty case silently defeats.
* Detector B-ii below is `ls <glob> … || echo` — with `ls`'s own
* nullglob-driven success-on-empty standing in for what used to be
* `--pick`'s absence-coerced-to-''. SCOPED to exactly three fired shapes,
* per a full measurement across the four SCAN_DIRS (measured counts
* recorded in the PR description; 0 sites for B-iii today, by design — see
* KNOWN LIMITS):
*
* B-i. `cat <glob>` fires UNCONDITIONALLY. This is the stdin-hang
* shape (measured rc=137 at 3s under an unmatched glob +
@@ -96,9 +102,6 @@
* `npm run lint:ci` runs CodeQL js/redos over this repo, the same
* discipline `lint-planning-prompt-drift.cjs` documents in its own header:
*
* - PICK_RE / ECHO_FALLBACK_RE carry only a single bounded `\s*`
* quantifier each, over a fixed literal — no nesting, nothing to
* backtrack.
* - CAT_LS_COMMAND_RE's alternation is a FIXED, non-overlapping set (a
* handful of literal command-position anchors, then a fixed
* `(cat|ls)`), with one `[ \t]*` quantifier between the anchor and the
@@ -171,11 +174,6 @@
* SCAN_EXT: `.md` only.
*
* KNOWN, ACCEPTED limits (same tradeoffs the sibling guards document):
* - A cross-line split defeats Detector A (`--pick` on one line, `|| echo`
* on the next) — left to code review, per-line textual scan only.
* - `|| printf` and other non-`echo` fallbacks are not detected by
* Detector A — narrow by design; widening is a one-line change if a
* site ever appears.
* - Detector B's command-position anchor set (line start; `;`, `&`, `|`,
* `(`; the keywords `if`/`then`/`elif`/`while`/`do`) is what lets
* `$(cat …)` / `$(ls …)` — the dominant real invocation idiom in this
@@ -214,16 +212,6 @@ const path = require('node:path');
const driftScan = require('./lib/drift-scan.cjs');
const { sanitizeForReport, scanTree } = driftScan;
// ─── Detector A — `--pick` + `|| echo` ────────────────────────────────────
//
// `--pick` is the discriminator (see module header); `|| echo` is the
// unreachable fallback arm it silently defeats. Both must be present on the
// SAME line for the shape to be the exact unreachable-arm defect #3409
// fixes — see the module header for why a bare "`gsd_run` + `|| echo`" rule
// was tried and reverted (Rejected #2).
const PICK_RE = /--pick\b/;
const ECHO_FALLBACK_RE = /\|\|\s*echo\b/;
// ─── Detector B — cat <glob> (B-i), ls <glob> … || <real fallback> (B-ii),
// or ls <glob> at the head of if/elif/while (B-iii) ────────────────────────
//
@@ -378,22 +366,28 @@ const BASELINE_REL_PATH = path.join('scripts', 'baselines', 'unreachable-guard-d
// The tracking issue this guard's own baseline entries are owned by, absent
// a more specific site owner named at `--update` time. #3409 is this
// guard's own issue: any site it finds that this PR does not convert is a
// "one careless line from the same class" per 40-design.md's Postel's Law
// section, tracked here until the upstream `--pick` contract fix (#3473)
// or a per-site conversion lands.
// guard's own issue: any Detector B site it finds that this PR does not
// convert is a "one careless line from the same class" per 40-design.md's
// Postel's Law section, tracked here until a per-site conversion lands.
// (Detector A's own entries, if any had ever existed, would have been
// tracked the same way until the upstream `--pick` contract fix landed —
// #3884 — but the baseline shipped with zero Detector A entries; see the
// retirement note at the top of this file.)
const RATCHET_OWNER_ISSUE = '#3409';
/**
* Pure: scan `text` (one file's content) for Detector A / Detector B
* violations and malformed escape-marker declarations. `relPath` is the
* repo-relative path (native separators or POSIX, either accepted) —
* normalized via `toPosixRel` and attached as `file` on every result.
* Pure: scan `text` (one file's content) for Detector B violations and
* malformed escape-marker declarations. `relPath` is the repo-relative path
* (native separators or POSIX, either accepted) — normalized via
* `toPosixRel` and attached as `file` on every result.
*
* Returns `{ violations, malformed }`:
* - `violations`: `[{ file, line, kind: 'A'|'B', found, text }]` — `text`
* is the TRIMMED source line (the baseline key), `found` names the
* discriminating token (`--pick` for A, `cat`/`ls` for B).
* - `violations`: `[{ file, line, kind: 'B', found, text }]` — `text` is
* the TRIMMED source line (the baseline key), `found` names the
* discriminating command (`cat`/`ls`). `kind` is retained as a field
* (rather than dropped now that only one detector remains) so the
* baseline JSON shape and the `--json` report shape are unchanged by
* Detector A's retirement.
* - `malformed`: `[{ file, line, text, reason }]` — an ATTEMPTED
* `# gsd-scan-ignore:` declaration whose reason names no issue and no
* URL. Checked on EVERY line independent of whether that line also
@@ -428,9 +422,6 @@ function findUnreachableGuardDrift(text, relPath) {
}
if (exempt) continue;
if (PICK_RE.test(line) && ECHO_FALLBACK_RE.test(line)) {
violations.push({ file, line: lineNo, kind: 'A', found: '--pick', text: line.trim() });
}
const globInfo = detectGlobOperand(line);
if (globInfo) {
violations.push({ file, line: lineNo, kind: 'B', found: globInfo.command, text: line.trim() });
@@ -774,8 +765,6 @@ function main() {
if (!json) {
if (fresh.length > 0) {
process.stderr.write('unreachable-guard-drift: NEW unreachable shell-guard shape(s) found in the prompt layer.\n');
process.stderr.write('Detector A (--pick + || echo): the fallback can never fire on field absence — replace with an\n');
process.stderr.write('explicit -z/empty-string test on the resolved value.\n');
process.stderr.write('Detector B (cat/ls over a glob operand): under a nullglob set elsewhere in the same shell\n');
process.stderr.write('session, an unmatched glob reads from stdin (cat) or lists the cwd (ls) — use an array\n');
process.stderr.write('expansion or an existence test instead.\n');
@@ -825,8 +814,6 @@ module.exports = {
dedupeViolationsForBaseline,
sortEntries,
writeBaseline,
PICK_RE,
ECHO_FALLBACK_RE,
CAT_LS_COMMAND_RE,
HEREDOC_AFTER_COMMAND_RE,
EXIT_TESTING_KEYWORDS,

View File

@@ -37,7 +37,7 @@ import { platformWriteSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import io = require('./io.cjs');
const { output, error: ioError } = io;
import { parseNamedArgs } from './command-arg-projection.cjs';
import { parseNamedArgsOrExit } from './command-arg-projection.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -1593,19 +1593,28 @@ function resolvePhaseTargetDir(planDir: string, cwd: string, phase: string, arch
* any read or write is attempted.
*/
function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
// args already has the family + subcommand tokens stripped by the caller
// (audit-command-router.cts:147 passes `hubArgs.slice(2)`), so validation
// begins at index 0 — there is no positional this handler owns itself.
const {
category, milestone, at: atFlag,
phase, file, 'archived-milestone': archivedMilestone,
slug, 'seed-id': seedId, dir: quickDir, filename, text,
} = parseNamedArgs(args, [
'category', 'milestone', 'at',
'phase', 'file', 'archived-milestone',
'slug', 'seed-id', 'dir', 'filename', 'text',
]) as Record<string, string | null>;
} = parseNamedArgsOrExit(args, {
valueFlags: [
'category', 'milestone', 'at',
'phase', 'file', 'archived-milestone',
'slug', 'seed-id', 'dir', 'filename', 'text',
],
positionals: 0,
}, ioError);
if (!category) ioError('--category is required');
if (!milestone) ioError('--milestone is required');
const at = atFlag || new Date().toISOString().slice(0, 10);
// All declared flags above are value flags, so each resolves to `string |
// null` at runtime; the cast narrows away the `boolean` arm of
// ParsedNamedArgs's value type that this call site never produces.
const at = (atFlag as string | null) || new Date().toISOString().slice(0, 10);
const planDir = planningDir(cwd);
const markerBase = { milestone: milestone as string, at };
@@ -1630,7 +1639,7 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
if (PHASE_SCOPED.has(category as string)) {
if (!phase) ioError('--phase is required for this --category');
if (!file) ioError('--file is required for this --category');
const targetDir = resolvePhaseTargetDir(planDir, cwd, phase as string, archivedMilestone);
const targetDir = resolvePhaseTargetDir(planDir, cwd, phase as string, archivedMilestone as string | null);
if (!targetDir) {
ioError(`no phase directory found for phase "${phase as string}"${archivedMilestone ? ` (archived-milestone "${archivedMilestone}")` : ''}`);
}

View File

@@ -1,42 +1,241 @@
/**
* Command Argument Projection Module (ADR-457 build-at-publish: the
* hand-written bin/lib/command-arg-projection.cjs collapsed to a TypeScript
* source of truth). Behaviour is preserved byte-for-behaviour from the prior
* hand-written .cjs; only types are added.
* source of truth).
*
* ADR-3473 §8.4 ("failure is a value"): `parseNamedArgs` is now strict and
* returns a `Result` instead of silently accepting unrecognized or stray
* positional tokens. See .gsd/phase/feat-3884-failure-is-a-value/40-design.md
* for the full behavior table (A1-A18) and negative-space section (N1-N8).
*
* Shared helpers for command-family adapters to project argv tokens into
* typed named values and multi-word segments.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
import io = require('./io.cjs');
const { ERROR_REASON, formatDiagnosticToken } = io;
// Structurally identical to io.cts's own (unexported) ErrorReasonValue type —
// both are computed from the SAME ERROR_REASON object, so the two never
// drift. Needed here (rather than a plain `string`) so `parseNamedArgsOrExit`
// can accept a real ERROR_REASON-typed callback (e.g. io.cts's `error`)
// directly: a `fail` parameter typed with a bare `string` second argument
// fails TypeScript's contravariant function-parameter check against a
// callback whose real signature narrows that argument to ErrorReasonValue.
type ErrorReasonValue = (typeof ERROR_REASON)[keyof typeof ERROR_REASON];
// ─── Types ────────────────────────────────────────────────────────────────────
export interface NamedArgSpec {
valueFlags?: string[];
booleanFlags?: string[];
/**
* Optional-value flags (added for `--wave`, ADR-3473 Bucket-A correction —
* `--wave` was misclassified as Bucket C and is in fact a documented,
* shipped, user-facing form: commands/gsd/execute-phase.md:4,48 and
* gsd-core/workflows/execute-phase.md:84 extract the value from
* `$ARGUMENTS` and forward it to the workflow, not to this CLI seam).
* #2932 (commands/gsd/execute-phase.md:53) records the flag's semantics as
* token-PRESENCE: `--wave N` is active only if the literal `--wave` token
* is present. This CLI layer therefore only needs to know the flag was
* *seen* — the value belongs to the workflow, not to `data`.
*
* EXTRACTION: resolves to `true`/`false` exactly like a `booleanFlags`
* entry — presence only. The #2932 guarantee that `parseNamedArgs`
* materializes `false` and never leaks `undefined` for an absent flag
* holds for this kind too.
* VALIDATION: when the token is `--<name>` for an optional-value flag, the
* cursor advances by 2 if a next token exists and is NOT flag-shaped
* (consuming the value so it isn't reported as a stray positional),
* otherwise by 1.
* The value is intentionally NOT surfaced in `data` — callers that need it
* read raw argv themselves (see `execute-phase`'s `args[2]` positional and
* the shell-side `WAVE_PARAM` reconstruction cited above). This is a
* single-purpose escape hatch for `--wave`-shaped flags only; it is not a
* general "value flags are actually optional" mode.
*/
optionalValueFlags?: string[];
/**
* Count of leading argv slots the CALLER owns and reads itself (args[0] =
* family, args[1] = subcommand, plus any documented positional).
* Validation begins at this index. 'rest' means the caller consumes all
* remaining tokens as free text; undeclared-flag rejection is disabled
* entirely for that call (documented exemption, e.g. `init quick`).
*/
positionals: number | 'rest';
}
export type ParsedNamedArgs = Record<string, string | boolean | null>;
export type NamedArgsResult =
| { ok: true; data: ParsedNamedArgs }
| { ok: false; kind: 'InvalidArgs'; arg: string; reason: string; exitReason: ErrorReasonValue };
// ─── Internal helpers ─────────────────────────────────────────────────────────
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/**
* Extract named --flag <value> pairs from an args array.
* Returns an object mapping flag names to their values (null if absent).
* Flags listed in `booleanFlags` are treated as booleans.
* ADR-3473 Decision 2: both ends of this seam are gsd-core's own source, so a
* malformed spec (a stale call site still using the legacy
* `parseNamedArgs(args, valueFlags, booleanFlags)` shape, or a missing spec
* entirely) is an internal invariant violation, not user input — it throws
* loudly instead of destructuring `undefined` off a Result.
*/
export function parseNamedArgs(
args: string[],
valueFlags: string[] = [],
booleanFlags: string[] = [],
): Record<string, string | boolean | null> {
// Index each token's first position once (firstIndex.get(t) ?? -1 === args.indexOf(t),
// firstIndex.has(t) === args.includes(t)) so the flag loops below don't each re-scan
// argv — O(argv + flags) instead of O(flags * argv). Semantics are unchanged. (#312)
function assertValidSpec(spec: unknown): asserts spec is NamedArgSpec {
if (!isPlainObject(spec)) {
throw new TypeError(
'parseNamedArgs: spec must be an object of shape ' +
'{ valueFlags?: string[], booleanFlags?: string[], positionals: number | "rest" }. ' +
'Received a missing, array, or non-object value — this is the legacy ' +
'parseNamedArgs(args, valueFlags, booleanFlags) call shape, retired by ADR-3473 §8.4.',
);
}
const positionals = spec.positionals;
const positionalsValid =
positionals === 'rest' ||
(typeof positionals === 'number' && Number.isInteger(positionals) && positionals >= 0);
if (!positionalsValid) {
throw new TypeError(
'parseNamedArgs: spec.positionals must be a non-negative integer or the literal "rest" ' +
`— received ${JSON.stringify(positionals)}.`,
);
}
}
// Single predicate reused for both extraction (a value beginning with a
// single `-` is a value, not a flag) and validation (negative space N5).
function isFlagToken(tok: string): boolean {
return tok.startsWith('--');
}
// ─── parseNamedArgs ───────────────────────────────────────────────────────────
/**
* Project argv tokens into typed named values, then strictly validate every
* token past the caller's declared positional boundary.
*
* Extraction (unchanged semantics, #312): first occurrence wins; a value
* flag whose next token is absent or starts with `--` yields `null` (this is
* NOT a validation error — see the value-flag branch below); boolean flags
* and optional-value flags (`optionalValueFlags`, #2932's `--wave` shape) are
* presence tests. Kept as a single first-index Map so the flag loops don't
* each re-scan argv — O(argv + flags) instead of O(flags * argv).
*
* Validation (skipped entirely when `positionals === 'rest'`): a single
* left-to-right cursor walk from `spec.positionals`, per the design doc's
* Kernighan's Law note — debuggable over clever, never a set-difference.
*/
export function parseNamedArgs(args: string[], spec: NamedArgSpec): NamedArgsResult {
assertValidSpec(spec);
const valueFlags = spec.valueFlags ?? [];
const booleanFlags = spec.booleanFlags ?? [];
const optionalValueFlags = spec.optionalValueFlags ?? [];
const firstIndex = new Map<string, number>();
for (let i = 0; i < args.length; i++) {
if (!firstIndex.has(args[i])) firstIndex.set(args[i], i);
}
const result: Record<string, string | boolean | null> = {};
const data: ParsedNamedArgs = {};
for (const flag of valueFlags) {
const idx = firstIndex.has(`--${flag}`) ? (firstIndex.get(`--${flag}`) as number) : -1;
result[flag] =
idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--')
data[flag] =
idx !== -1 && args[idx + 1] !== undefined && !isFlagToken(args[idx + 1])
? args[idx + 1]
: null;
}
for (const flag of booleanFlags) {
result[flag] = firstIndex.has(`--${flag}`);
data[flag] = firstIndex.has(`--${flag}`);
}
return result;
// Optional-value flags (#2932: `--wave`-shaped) — presence-only, exactly
// like booleanFlags. The value (if any) is deliberately not surfaced here;
// see the NamedArgSpec.optionalValueFlags JSDoc.
for (const flag of optionalValueFlags) {
data[flag] = firstIndex.has(`--${flag}`);
}
if (spec.positionals === 'rest') {
return { ok: true, data };
}
const valueFlagSet = new Set(valueFlags);
const booleanFlagSet = new Set(booleanFlags);
const optionalValueFlagSet = new Set(optionalValueFlags);
const flagList = [
...valueFlags.map((f) => `--${f} <value>`),
...booleanFlags.map((f) => `--${f}`),
...optionalValueFlags.map((f) => `--${f} [value]`),
];
let i = spec.positionals;
while (i < args.length) {
const tok = args[i];
if (isFlagToken(tok)) {
const name = tok.slice(2);
if (valueFlagSet.has(name)) {
// A value flag whose next token is absent or flag-shaped resolves to
// `null` in `data` (see the extraction loop above) — this is NOT an
// error (#3180/behavior-lock: emptyPrdValueIsFalsyAndTreatedAsAbsent,
// section-manifest-init-facts.test.cjs "flag-shaped value"). Advance
// by 1 so the following flag token is validated on its own merits on
// the next iteration.
const next = args[i + 1];
i += next !== undefined && !isFlagToken(next) ? 2 : 1;
continue;
}
if (booleanFlagSet.has(name)) {
i += 1;
continue;
}
if (optionalValueFlagSet.has(name)) {
const next = args[i + 1];
i += next !== undefined && !isFlagToken(next) ? 2 : 1;
continue;
}
const reason =
flagList.length > 0
? `unknown flag ${formatDiagnosticToken(tok)}; accepted: ${flagList.join(', ')}`
: `unknown flag ${formatDiagnosticToken(tok)}; this command accepts no flags`;
return { ok: false, kind: 'InvalidArgs', arg: tok, reason, exitReason: ERROR_REASON.USAGE };
}
return {
ok: false,
kind: 'InvalidArgs',
arg: tok,
reason: `unexpected positional argument ${formatDiagnosticToken(tok)}`,
exitReason: ERROR_REASON.USAGE,
};
}
return { ok: true, data };
}
/**
* Thin projection over `parseNamedArgs`: on `ok:false` it calls
* `fail(result.reason, result.exitReason)` and then throws.
*
* The trailing throw exists for two reasons: (1) TypeScript's control-flow
* analysis needs a `never`-returning path so callers can destructure the
* return value without a null check; (2) it is a fail-closed backstop — the
* `fail` callbacks in this repo are `never`-returning at runtime (`io.error`
* calls `process.exit(1)`) but are typed `void`, so if a caller ever passes a
* `fail` that actually returns, this still halts instead of falling through
* with a half-built `ParsedNamedArgs`.
*/
export function parseNamedArgsOrExit(
args: string[],
spec: NamedArgSpec,
fail: (message: string, exitReason?: ErrorReasonValue) => void,
): ParsedNamedArgs {
const result = parseNamedArgs(args, spec);
if (!result.ok) {
fail(result.reason, result.exitReason);
throw new Error(`parseNamedArgsOrExit: fail() returned instead of exiting (arg: ${result.arg})`);
}
return result.data;
}
/**

View File

@@ -19,7 +19,7 @@ import { INIT_SUBCOMMANDS } from './command-aliases.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
import { parseNamedArgs } from './command-arg-projection.cjs';
import { parseNamedArgsOrExit } from './command-arg-projection.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -77,7 +77,12 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
// single source of truth for flag ABSENCE and gates on value truthiness,
// so `namedArgs` is passed through here uncoerced.
'execute-phase': () => {
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd', 'wave']);
// `wave` is an optionalValueFlags entry, not a booleanFlags entry:
// `--wave N` is a documented, shipped form (commands/gsd/execute-phase.md:4,48)
// whose value is consumed by the workflow layer
// (gsd-core/workflows/execute-phase.md:84), not by this CLI seam — see
// NamedArgSpec.optionalValueFlags in command-arg-projection.cts.
const namedArgs = parseNamedArgsOrExit(args, { booleanFlags: ['validate', 'tdd'], optionalValueFlags: ['wave'], positionals: 3 }, error);
init.cmdInitExecutePhase(cwd, args[2], raw, {
validate: namedArgs['validate'],
tdd: namedArgs['tdd'],
@@ -85,10 +90,14 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
});
},
'plan-phase': () => {
const namedArgs = parseNamedArgs(
const namedArgs = parseNamedArgsOrExit(
args,
['granularity', 'prd', 'ingest', 'research-phase'],
['validate', 'tdd', 'reviews', 'chunked'],
{
valueFlags: ['granularity', 'prd', 'ingest', 'research-phase'],
booleanFlags: ['validate', 'tdd', 'reviews', 'chunked'],
positionals: 3,
},
error,
);
init.cmdInitPlanPhase(cwd, args[2], raw, {
validate: namedArgs['validate'],
@@ -102,21 +111,25 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
});
},
'new-project': () => {
const namedArgs = parseNamedArgs(args, [], ['auto']);
const namedArgs = parseNamedArgsOrExit(args, { booleanFlags: ['auto'], positionals: 2 }, error);
init.cmdInitNewProject(cwd, raw, { auto: namedArgs['auto'] });
},
'new-milestone': () => {
const namedArgs = parseNamedArgs(args, [], ['reset-phase-numbers']);
const namedArgs = parseNamedArgsOrExit(args, { booleanFlags: ['reset-phase-numbers'], positionals: 2 }, error);
init.cmdInitNewMilestone(cwd, raw, {
'reset-phase-numbers': namedArgs['reset-phase-numbers'],
});
},
onboard: () => {
const namedArgs = parseNamedArgs(args, [], ['fast', 'text']);
const namedArgs = parseNamedArgsOrExit(args, { booleanFlags: ['fast', 'text'], positionals: 2 }, error);
init.cmdInitOnboard(cwd, raw, { fast: namedArgs['fast'], text: namedArgs['text'] });
},
quick: () => {
const namedArgs = parseNamedArgs(args, [], ['discuss', 'research', 'validate', 'full']);
// #3180 Decision 4a / L2 (ADR-3473 §8.4): `positionals: 'rest'` because
// everything after `init quick` is a free-text description — strict
// undeclared-flag rejection would break
// `/gsd-quick add a --dry-run option`, which works today.
const namedArgs = parseNamedArgsOrExit(args, { booleanFlags: ['discuss', 'research', 'validate', 'full'], positionals: 'rest' }, error);
// #2994: `args.slice(2)` is the free-text description, but section-manifest
// gating (buildSectionManifestField, src/init.cts) now requires forwarding
// --discuss/--research/--validate/--full alongside it — a plain `.join(' ')`
@@ -137,22 +150,41 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
},
'ingest-docs': () => init.cmdInitIngestDocs(cwd, raw),
resume: () => init.cmdInitResume(cwd, raw),
'verify-work': () => init.cmdInitVerifyWork(cwd, args[2], raw),
'phase-op': () => init.cmdInitPhaseOp(cwd, args[2], raw),
// ADR-3473 §8.4 / #3358 gap: these handlers read args[2] positionally
// without ever calling parseNamedArgsOrExit, so an unrecognized flag or
// stray positional was silently dropped instead of rejected. No flags
// are declared because none are documented for these subcommands
// (docs/CLI-TOOLS.md); `--ws` seen in shipped workflows targets the
// separate `query init.verify-work` seam and is stripped before
// reaching `init verify-work` (gsd-core/workflows/verify-work.md:42-45).
'verify-work': () => {
parseNamedArgsOrExit(args, { positionals: 3 }, error);
init.cmdInitVerifyWork(cwd, args[2], raw);
},
'phase-op': () => {
parseNamedArgsOrExit(args, { positionals: 3 }, error);
init.cmdInitPhaseOp(cwd, args[2], raw);
},
'code-review': () => {
const namedArgs = parseNamedArgs(args, [], ['fix']);
const namedArgs = parseNamedArgsOrExit(args, { booleanFlags: ['fix'], positionals: 3 }, error);
init.cmdInitCodeReview(cwd, args[2], raw, { fix: namedArgs['fix'] });
},
review: () => init.cmdInitReview(cwd, args[2], raw, {}),
review: () => {
parseNamedArgsOrExit(args, { positionals: 3 }, error);
init.cmdInitReview(cwd, args[2], raw, {});
},
'discuss-phase-assumptions': () => {
const namedArgs = parseNamedArgs(args, [], ['auto']);
const namedArgs = parseNamedArgsOrExit(args, { booleanFlags: ['auto'], positionals: 3 }, error);
init.cmdInitDiscussPhaseAssumptions(cwd, args[2], raw, { auto: namedArgs['auto'] });
},
todos: () => init.cmdInitTodos(cwd, args[2], raw),
todos: () => {
parseNamedArgsOrExit(args, { positionals: 3 }, error);
init.cmdInitTodos(cwd, args[2], raw);
},
'milestone-op': () => init.cmdInitMilestoneOp(cwd, raw),
'map-codebase': () => init.cmdInitMapCodebase(cwd, raw),
progress: () => {
const namedArgs = parseNamedArgs(args, [], ['forensic']);
const namedArgs = parseNamedArgsOrExit(args, { booleanFlags: ['forensic'], positionals: 2 }, error);
init.cmdInitProgress(cwd, raw, { forensic: namedArgs['forensic'] });
},
// Keep manager on CJS for now so runtime-specific command rendering
@@ -160,7 +192,7 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
manager: () => init.cmdInitManager(cwd, raw),
'complete-milestone': () => init.cmdInitCompleteMilestone(cwd, raw),
autonomous: () => {
const namedArgs = parseNamedArgs(args, [], ['converge', 'cross-ai']);
const namedArgs = parseNamedArgsOrExit(args, { booleanFlags: ['converge', 'cross-ai'], positionals: 2 }, error);
init.cmdInitAutonomous(cwd, raw, {
converge: namedArgs['converge'],
'cross-ai': namedArgs['cross-ai'],
@@ -168,17 +200,20 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
},
'docs-update': () => init.cmdInitDocsUpdate(cwd, raw, {}),
update: () => {
const namedArgs = parseNamedArgs(args, [], ['next', 'rc']);
const namedArgs = parseNamedArgsOrExit(args, { booleanFlags: ['next', 'rc'], positionals: 2 }, error);
init.cmdInitUpdate(cwd, raw, { next: namedArgs['next'], rc: namedArgs['rc'] });
},
transition: () => init.cmdInitTransition(cwd, raw, {}),
debug: () => {
const namedArgs = parseNamedArgs(args, [], ['diagnose']);
const namedArgs = parseNamedArgsOrExit(args, { booleanFlags: ['diagnose'], positionals: 2 }, error);
init.cmdInitDebug(cwd, raw, { diagnose: namedArgs['diagnose'] });
},
'new-workspace': () => init.cmdInitNewWorkspace(cwd, raw),
'list-workspaces': () => init.cmdInitListWorkspaces(cwd, raw),
'remove-workspace': () => init.cmdInitRemoveWorkspace(cwd, args[2], raw),
'remove-workspace': () => {
parseNamedArgsOrExit(args, { positionals: 3 }, error);
init.cmdInitRemoveWorkspace(cwd, args[2], raw);
},
},
});
}

View File

@@ -216,6 +216,11 @@ const ERROR_REASON = Object.freeze({
COMMIT_DOCS_GUARD_HOOKS_PATH_SET: 'commit_docs_guard_hooks_path_set',
// security-scan
SECURITY_SCAN_FAILED: 'security_scan_failed',
// --pick (#3365 / #3358, ADR-3473 §8.4): an absent field or non-JSON
// command output is a failure, never a demotion to an empty answer at
// exit 0. See .gsd/phase/feat-3884-failure-is-a-value/40-design.md.
PICK_FIELD_ABSENT: 'pick_field_absent',
PICK_OUTPUT_NOT_JSON: 'pick_output_not_json',
// generic
USAGE: 'usage',
UNKNOWN: 'unknown',
@@ -240,6 +245,35 @@ type ErrorReasonValue = typeof ERROR_REASON[keyof typeof ERROR_REASON];
* human-readable text. Ignored entirely in plain-text mode — the human
* message is the only thing an operator sees there.
*/
/**
* Render an UNTRUSTED string for embedding inside a human-readable,
* plain-text diagnostic (the `'Error: ' + message` line `error()` writes in
* non-JSON mode).
*
* WHY THIS EXISTS AND WHY `error()` DOES NOT DO IT ITSELF: `error()`
* deliberately writes its `message` argument verbatim — several callers in
* this repo intentionally emit multi-line diagnostics (e.g. the phase-gate
* messages), and `error()` has no way to distinguish a legitimate multi-line
* message from a hostile one, so it must not mangle newlines generically.
* That means any UNTRUSTED substring a caller interpolates into `message`
* (an argv token, a JSON key/value read back from a command's own output,
* etc.) can smuggle its own `\n` and forge a second `Error: ` line on
* stderr — a caller that parses stderr line-by-line would then see a second,
* attacker-authored error. Every call site that interpolates untrusted data
* into a diagnostic MUST pass that substring through this function first;
* `error()` itself stays a dumb, faithful writer.
*
* `JSON.stringify` is the primitive: it wraps the value in quotes and
* escapes control characters (`\n`, `\r`, `\t`, and the rest of the C0
* range, plus the quote character itself), so the result can never span
* more than one line or introduce an unescaped `"`. Callers embedding the
* result MUST NOT add their own surrounding quotes — that would
* double-quote it.
*/
function formatDiagnosticToken(value: string): string {
return JSON.stringify(value);
}
function error(message: string, reason: ErrorReasonValue = ERROR_REASON.UNKNOWN, extra?: Record<string, unknown>): never {
if (getJsonErrorMode()) {
const payload = JSON.stringify({ ok: false, reason, message, ...(extra || {}) }) + '\n';
@@ -260,4 +294,5 @@ export = {
setJsonErrorMode,
getJsonErrorMode,
error,
formatDiagnosticToken,
};

View File

@@ -18,7 +18,7 @@ import { STATE_SUBCOMMANDS } from './command-aliases.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
const { routeHubCommandFamily } = cjsCommandRouterAdapter;
import { parseNamedArgs } from './command-arg-projection.cjs';
import { parseNamedArgsOrExit } from './command-arg-projection.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -91,8 +91,19 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
handlers: {
load: () => state.cmdStateLoad(cwd, raw),
json: () => state.cmdStateJson(cwd, raw),
get: () => state.cmdStateGet(cwd, args[2], raw),
update: () => state.cmdStateUpdate(cwd, args[2], args[3]),
// ADR-3473 §8.4 / #3358 gap: these two read args[2]/args[3] positionally
// without ever calling parseNamedArgsOrExit, so an unrecognized flag or
// stray positional was silently dropped instead of rejected. No flags
// are declared — none are documented for these subcommands
// (docs/CLI-TOOLS.md:86,89) and no shipped workflow passes any.
get: () => {
parseNamedArgsOrExit(args, { positionals: 3 }, error);
state.cmdStateGet(cwd, args[2], raw);
},
update: () => {
parseNamedArgsOrExit(args, { positionals: 4 }, error);
state.cmdStateUpdate(cwd, args[2], args[3]);
},
patch: () => {
const patches: Record<string, string> = {};
if (args.length === 3 && typeof args[2] === 'string' && args[2].trim().startsWith('{')) {
@@ -126,7 +137,7 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
},
'advance-plan': () => state.cmdStateAdvancePlan(cwd, raw),
'record-metric': () => {
const a = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']);
const a = parseNamedArgsOrExit(args, { valueFlags: ['phase', 'plan', 'duration', 'tasks', 'files'], positionals: 2 }, error);
state.cmdStateRecordMetric(cwd, {
phase: strArg(a, 'phase'),
plan: strArg(a, 'plan'),
@@ -137,7 +148,7 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
},
'update-progress': () => state.cmdStateUpdateProgress(cwd, raw),
'add-decision': () => {
const a = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']);
const a = parseNamedArgsOrExit(args, { valueFlags: ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file'], positionals: 2 }, error);
state.cmdStateAddDecision(cwd, {
phase: strArg(a, 'phase'),
summary: strArg(a, 'summary'),
@@ -147,11 +158,11 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
}, raw);
},
'add-blocker': () => {
const a = parseNamedArgs(args, ['text', 'text-file']);
const a = parseNamedArgsOrExit(args, { valueFlags: ['text', 'text-file'], positionals: 2 }, error);
state.cmdStateAddBlocker(cwd, { text: strArg(a, 'text'), text_file: strArg(a, 'text-file') }, raw);
},
'add-roadmap-evolution': () => {
const a = parseNamedArgs(args, ['phase', 'action', 'after', 'note', 'note-file'], ['urgent']);
const a = parseNamedArgsOrExit(args, { valueFlags: ['phase', 'action', 'after', 'note', 'note-file'], booleanFlags: ['urgent'], positionals: 2 }, error);
state.cmdStateAddRoadmapEvolution(cwd, {
phase: strArg(a, 'phase'),
action: strArg(a, 'action'),
@@ -161,25 +172,25 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
urgent: a['urgent'] === true,
}, raw);
},
'resolve-blocker': () => state.cmdStateResolveBlocker(cwd, strArg(parseNamedArgs(args, ['text']), 'text'), raw),
'resolve-blocker': () => state.cmdStateResolveBlocker(cwd, strArg(parseNamedArgsOrExit(args, { valueFlags: ['text'], positionals: 2 }, error), 'text'), raw),
'record-session': () => {
const a = parseNamedArgs(args, ['stopped-at', 'resume-file']);
const a = parseNamedArgsOrExit(args, { valueFlags: ['stopped-at', 'resume-file'], positionals: 2 }, error);
// Pass resume_file as-is (undefined when --resume-file was not provided) so
// cmdStateRecordSession can distinguish "caller explicitly passed a value" from
// "option was not supplied" and apply the template-default-only replacement guard.
state.cmdStateRecordSession(cwd, { stopped_at: strArg(a, 'stopped-at'), resume_file: strArg(a, 'resume-file') }, raw);
},
'begin-phase': () => {
const a = parseNamedArgs(args, ['phase', 'name', 'plans']);
const a = parseNamedArgsOrExit(args, { valueFlags: ['phase', 'name', 'plans'], positionals: 2 }, error);
state.cmdStateBeginPhase(cwd, strArg(a, 'phase'), strArg(a, 'name'), parsePlans(strArg(a, 'plans')), raw);
},
'signal-waiting': () => {
const a = parseNamedArgs(args, ['type', 'question', 'options', 'phase']);
const a = parseNamedArgsOrExit(args, { valueFlags: ['type', 'question', 'options', 'phase'], positionals: 2 }, error);
state.cmdSignalWaiting(cwd, strArg(a, 'type'), strArg(a, 'question'), strArg(a, 'options'), strArg(a, 'phase'), raw);
},
'signal-resume': () => state.cmdSignalResume(cwd, raw),
'planned-phase': () => {
const a = parseNamedArgs(args, ['phase', 'name', 'plans']);
const a = parseNamedArgsOrExit(args, { valueFlags: ['phase', 'name', 'plans'], positionals: 2 }, error);
// #3395: --name was parsed here but never forwarded (the StateModule
// signature had no channel for it), so the argument was silently
// dropped. It now persists into the Current Position `Phase:` line and
@@ -190,28 +201,44 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
// #3696: --strict makes the verdict gateable by exit status. The
// default stays exit 0 — the exit code is Tier-2 observable output
// reaching unenumerable downstream consumers (ADR-3180 Decision 3).
const a = parseNamedArgs(args, [], ['strict']);
const a = parseNamedArgsOrExit(args, { booleanFlags: ['strict'], positionals: 2 }, error);
state.cmdStateValidate(cwd, raw, { strict: a['strict'] === true });
},
sync: () => {
const a = parseNamedArgs(args, [], ['verify']);
const a = parseNamedArgsOrExit(args, { booleanFlags: ['verify'], positionals: 2 }, error);
state.cmdStateSync(cwd, { verify: a['verify'] }, raw);
},
prune: () => {
const a = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
const a = parseNamedArgsOrExit(args, { valueFlags: ['keep-recent'], booleanFlags: ['dry-run'], positionals: 2 }, error);
state.cmdStatePrune(cwd, { keepRecent: strArg(a, 'keep-recent') || '3', dryRun: a['dry-run'] === true }, raw);
},
rebuild: () => {
const a = parseNamedArgs(args, [], ['dry-run', 'verbose']);
const a = parseNamedArgsOrExit(args, { booleanFlags: ['dry-run', 'verbose'], positionals: 2 }, error);
state.cmdStateRebuild(cwd, { dryRun: a['dry-run'] === true, verbose: a['verbose'] === true }, raw);
},
// complete-phase: CJS-only — no SDK counterpart.
// complete-phase: CJS-only — no SDK counterpart. Supports two shapes:
// the documented `--phase N` flag (docs/COMMANDS.md:2207) and an
// undocumented-but-preserved bare positional `state complete-phase N`
// (N3). A single static `positionals` count cannot represent both: if
// args[2] is the flag `--phase`, the boundary must be 2 so the generic
// flag/value walk (which starts at the boundary) recognizes `--phase`
// and consumes its value; only when args[2] is itself a bare, non-flag
// token does the boundary widen to 3 to accept it as the positional
// phase. Getting this wrong either breaks the documented flag form
// (boundary 3 treats `--phase`'s value as an unexpected trailing
// positional) or silently re-admits unknown flags (a static boundary
// of 3 with an empty args[2] never validates anything past it).
'complete-phase': () => {
const a = parseNamedArgs(args, ['phase']);
const bareTrailingPositional = args[2] !== undefined && !args[2].startsWith('--');
const a = parseNamedArgsOrExit(
args,
{ valueFlags: ['phase'], positionals: bareTrailingPositional ? 3 : 2 },
error,
);
state.cmdStateCompletePhase(cwd, raw, strArg(a, 'phase') || args[2]);
},
'milestone-switch': () => {
const a = parseNamedArgs(args, ['milestone', 'name']);
const a = parseNamedArgsOrExit(args, { valueFlags: ['milestone', 'name'], positionals: 2 }, error);
state.cmdStateMilestoneSwitch(cwd, strArg(a, 'milestone'), strArg(a, 'name'), raw);
},
},

View File

@@ -24,7 +24,7 @@ import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
import { parseNamedArgs } from './command-arg-projection.cjs';
import { parseNamedArgsOrExit } from './command-arg-projection.cjs';
import { classifyContextUtilization, STATES } from './context-utilization.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -66,7 +66,7 @@ function routeValidateCommand({ verify, args, cwd, raw, output: outputFn, error
// context: CJS-only — complex inline logic using classifyContextUtilization
// with custom output formatting that has no direct SDK counterpart.
context: () => {
const opts = parseNamedArgs(args, ['tokens-used', 'context-window']);
const opts = parseNamedArgsOrExit(args, { valueFlags: ['tokens-used', 'context-window'], booleanFlags: ['json'], positionals: 2 }, error);
if (opts['tokens-used'] === null) {
error('--tokens-used <integer> is required for `validate context`');
return;
@@ -91,7 +91,7 @@ function routeValidateCommand({ verify, args, cwd, raw, output: outputFn, error
return;
}
const result = { ...classified, recommendation: RECOMMENDATIONS[classified.state] };
if (args.includes('--json')) {
if (opts['json'] === true) {
outputFn(result, raw);
} else {
const lines = [`Context utilization: ${result.percent}% (${result.state})`];

View File

@@ -271,20 +271,29 @@ describe('B2 — CLI loop render-hooks: exit 0, activeHooks:[], placeholder for
describe('B3 — init bundles for 5-step loop entry seam: exit 0 and valid JSON with capabilities off', () => {
// Cases: the 5-step loop's main init entry points (those available without git)
const INIT_CASES = [
// #3884 (ADR-3473 §8.4): these three init subcommands take the phase as a
// bare POSITIONAL (docs/CLI-TOOLS.md:775 `init plan-phase <phase>`, :787
// `init execute-phase <phase>`) — there is no `--phase` flag for any of
// them. Under the pre-#3884 permissive parser, `--phase 01-stub` silently
// resolved to phase_found:false (args[2] literally became the string
// "--phase"), which these tests never caught because they only checked
// exit 0 and key PRESENCE. Corrected to the real, documented positional
// form and strengthened to assert phase_found === true so a future
// regression of this kind fails loudly instead of passing vacuously.
{
label: 'init plan-phase',
args: ['init', 'plan-phase', '--phase', '01-stub'],
args: ['init', 'plan-phase', '01-stub'],
// Required fields that prove the bundle is a real JSON object used by the loop
requiredFields: ['tdd_mode', 'phase_found', 'planning_exists'],
},
{
label: 'init execute-phase',
args: ['init', 'execute-phase', '--phase', '01-stub'],
args: ['init', 'execute-phase', '01-stub'],
requiredFields: ['tdd_mode', 'phase_found', 'config_exists'],
},
{
label: 'init verify-work',
args: ['init', 'verify-work', '--phase', '01-stub'],
args: ['init', 'verify-work', '01-stub'],
requiredFields: ['phase_found', 'commit_docs'],
},
];
@@ -296,6 +305,12 @@ describe('B3 — init bundles for 5-step loop entry seam: exit 0 and valid JSON
before(() => {
// Bare project with .planning/ but all caps off in config
tmpDir = makeProject(ALL_FALSE_CONFIG);
// A real phase directory matching the "01-stub" argument used by every
// INIT_CASES entry above — without it, phase_found is trivially false
// regardless of whether the CLI call shape is correct, and the
// strengthened phase_found:true assertion below could not distinguish
// a working positional form from the #3358-class silent-drop bug.
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-stub'), { recursive: true });
});
after(() => {
@@ -330,6 +345,16 @@ describe('B3 — init bundles for 5-step loop entry seam: exit 0 and valid JSON
`${label}: bundle must contain field "${field}", got keys: ${Object.keys(parsed).join(', ')}`,
);
}
// Genuine assertion, not merely key presence (see the fix note on
// INIT_CASES above): the seeded project has a real "01-stub" phase
// directory, so the phase MUST actually resolve. A silently-dropped
// phase argument still has a `phase_found` key (value `false`) — key
// presence alone would pass on that defect.
assert.strictEqual(
parsed.phase_found,
true,
`${label}: phase "01-stub" must actually resolve (phase_found:true), got: ${JSON.stringify(parsed.phase_found)}`,
);
});
test(`${label} returns parseable JSON with bare project (no config at all)`, () => {

View File

@@ -577,7 +577,13 @@ describe('regressions', () => {
assert.strictEqual(seen.getter, 'function');
assert.strictEqual(seen.failFast, 'sdk_fail_fast', 'the literal must survive moving to cli-exit');
assert.strictEqual(seen.frozen, true);
assert.strictEqual(seen.reasonCount, 23, 'ERROR_REASON must keep all 23 members');
// #3884 (ADR-3473 §8.4) legitimately added two new codes —
// PICK_FIELD_ABSENT and PICK_OUTPUT_NOT_JSON — for the `--pick`
// absence contract (see .gsd/phase/feat-3884-failure-is-a-value/40-design.md
// rows B6/B11). 23 -> 25 is an intentional, documented growth of the
// enum, not drift; bump the golden count rather than treat this as a
// Hyrum violation.
assert.strictEqual(seen.reasonCount, 25, 'ERROR_REASON must keep all 25 members (23 + #3884 PICK_FIELD_ABSENT/PICK_OUTPUT_NOT_JSON)');
assert.ok(
seen.keys.includes('SDK_FAIL_FAST'),
`ERROR_REASON must still include SDK_FAIL_FAST, got: ${JSON.stringify(seen.keys)}`,

View File

@@ -1,93 +1,115 @@
'use strict';
const { test } = require('node:test');
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const {
parseNamedArgs,
parseMultiwordArg,
} = require('../gsd-core/bin/lib/command-arg-projection.cjs');
const fc = require('./helpers/fast-check-setup.cjs');
const { createTempProject, cleanup } = require('./helpers.cjs');
const { runCli } = require('./helpers/cli-negative.cjs');
// ---------------------------------------------------------------------------
// parseNamedArgs — behavior-lock tests (green before AND after the #312 fix)
// ---------------------------------------------------------------------------
test('value flag with valid value', () => {
assert.deepStrictEqual(
parseNamedArgs(['--name', 'foo'], ['name']),
{ name: 'foo' }
);
const result = parseNamedArgs(['--name', 'foo'], { valueFlags: ['name'], positionals: 0 });
assert.deepStrictEqual(result, { ok: true, data: { name: 'foo' } });
});
test('value flag followed by another flag (value rejected)', () => {
assert.deepStrictEqual(
parseNamedArgs(['--name', '--other'], ['name']),
{ name: null }
);
// Corrected after the first full verification run: a value flag whose next
// token is another flag is NOT an error (see the "strict argv" describe
// block below, valueFlagFollowedByAnotherFlagResolvesToNullNotAnError) — it
// resolves to `null` and the cursor advances by 1 so the following token is
// validated on its own merits. `--other` is deliberately left undeclared
// here since this row tests extraction only; `positionals: 'rest'` skips
// the unrelated unknown-flag validation.
test('value flag followed by another flag resolves to null, not rejected', () => {
const result = parseNamedArgs(['--name', '--other'], { valueFlags: ['name'], positionals: 'rest' });
assert.deepStrictEqual(result, { ok: true, data: { name: null } });
});
// Corrected after the first full verification run: a value flag with no
// following token at all resolves to `null`, not an error — see the
// "strict argv" describe block below.
test('value flag at end of array (no following token)', () => {
assert.deepStrictEqual(
parseNamedArgs(['--name'], ['name']),
{ name: null }
);
const result = parseNamedArgs(['--name'], { valueFlags: ['name'], positionals: 0 });
assert.deepStrictEqual(result, { ok: true, data: { name: null } });
});
test('value flag absent from args', () => {
assert.deepStrictEqual(
parseNamedArgs(['--x', 'y'], ['name']),
{ name: null }
);
// The original 'value flag absent from args' row passed `['--x', 'y']` with
// only `name` declared. Under strict mode `--x` is itself an unknown flag —
// split into two rows so neither original intent (an undeclared flag is
// null when TRULY absent; `--x` is rejected) is silently dropped.
test('absent declared flag resolves to null (not an error)', () => {
const result = parseNamedArgs([], { valueFlags: ['name'], positionals: 0 });
assert.deepStrictEqual(result, { ok: true, data: { name: null } });
});
test('an undeclared flag token is rejected, not silently ignored', () => {
const result = parseNamedArgs(['--x', 'y'], { valueFlags: ['name'], positionals: 0 });
assert.strictEqual(result.ok, false);
assert.strictEqual(result.kind, 'InvalidArgs');
assert.strictEqual(result.arg, '--x');
});
test('boolean flag present', () => {
assert.deepStrictEqual(
parseNamedArgs(['--write'], [], ['write']),
{ write: true }
);
const result = parseNamedArgs(['--write'], { booleanFlags: ['write'], positionals: 0 });
assert.deepStrictEqual(result, { ok: true, data: { write: true } });
});
test('boolean flag absent', () => {
assert.deepStrictEqual(
parseNamedArgs([], [], ['write']),
{ write: false }
);
const result = parseNamedArgs([], { booleanFlags: ['write'], positionals: 0 });
assert.deepStrictEqual(result, { ok: true, data: { write: false } });
});
// Negative space N4: a repeated flag is not an unknown token — first
// occurrence still wins, and each occurrence is itself a well-formed
// flag+value pair, so strict validation still passes.
test('first-occurrence-wins: duplicate value flag uses first index', () => {
// Locks the indexOf-first semantics that the Map must preserve (#312)
assert.deepStrictEqual(
parseNamedArgs(['--name', 'a', '--name', 'b'], ['name']),
{ name: 'a' }
);
const result = parseNamedArgs(['--name', 'a', '--name', 'b'], { valueFlags: ['name'], positionals: 0 });
assert.deepStrictEqual(result, { ok: true, data: { name: 'a' } });
});
test('mixed multiple flags (the O(flags*argv) case)', () => {
assert.deepStrictEqual(
parseNamedArgs(['--a', '1', '--flag', '--b', '2'], ['a', 'b'], ['flag']),
{ a: '1', b: '2', flag: true }
const result = parseNamedArgs(
['--a', '1', '--flag', '--b', '2'],
{ valueFlags: ['a', 'b'], booleanFlags: ['flag'], positionals: 0 },
);
assert.deepStrictEqual(result, { ok: true, data: { a: '1', b: '2', flag: true } });
});
test('empty args with multiple declared flags', () => {
assert.deepStrictEqual(
parseNamedArgs([], ['name', 'path'], ['verbose', 'dry-run']),
{ name: null, path: null, verbose: false, 'dry-run': false }
const result = parseNamedArgs(
[],
{ valueFlags: ['name', 'path'], booleanFlags: ['verbose', 'dry-run'], positionals: 0 },
);
assert.deepStrictEqual(result, {
ok: true,
data: { name: null, path: null, verbose: false, 'dry-run': false },
});
});
test('value flag value undefined via array boundary', () => {
// --count is last token; args[idx+1] is undefined — must return null
assert.deepStrictEqual(
parseNamedArgs(['--other', 'x', '--count'], ['count']),
{ count: null }
// Corrected after the first full verification run: a value flag exhausted
// by the array boundary resolves to `null`, not InvalidArgs — including
// when it is preceded by another well-formed flag+value pair.
test('value flag at end of argv resolves to null even when preceded by another flag', () => {
const result = parseNamedArgs(
['--other', 'x', '--count'],
{ valueFlags: ['other', 'count'], positionals: 0 },
);
assert.deepStrictEqual(result, { ok: true, data: { other: 'x', count: null } });
});
test('boolean flag does not clobber an already-set value-flag key when names differ', () => {
assert.deepStrictEqual(
parseNamedArgs(['--msg', 'hello', '--verbose'], ['msg'], ['verbose']),
{ msg: 'hello', verbose: true }
const result = parseNamedArgs(
['--msg', 'hello', '--verbose'],
{ valueFlags: ['msg'], booleanFlags: ['verbose'], positionals: 0 },
);
assert.deepStrictEqual(result, { ok: true, data: { msg: 'hello', verbose: true } });
});
// ---------------------------------------------------------------------------
@@ -122,6 +144,282 @@ test('parseMultiwordArg: flag at end of array with no tokens returns null', () =
);
});
// ---------------------------------------------------------------------------
// parseNamedArgs — strict argv (#3358, ADR-3473 §8.4)
//
// New shape: parseNamedArgs(args, spec) where
// spec = { valueFlags?: string[], booleanFlags?: string[], positionals: number | 'rest' }
// returning { ok: true, data } | { ok: false, kind: 'InvalidArgs', arg, reason }.
//
// TODAY (measured on this tree): the function ignores this shape entirely —
// its real signature is still (args, valueFlags = [], booleanFlags = []), so
// passing a spec OBJECT as the second positional argument makes
// `for (const flag of valueFlags)` iterate a non-iterable plain object,
// throwing `TypeError: valueFlags is not iterable` before any of these rows
// can even reach their assertions. Every row below therefore fails against
// the current tree — either via that uncaught throw, or (for the two legacy
// rows) because `assert.throws` finds nothing thrown at all.
// ---------------------------------------------------------------------------
describe('parseNamedArgs — strict argv (#3358, ADR-3473 §8.4)', () => {
test('valueFlagWithValueResolvesOk', () => {
const result = parseNamedArgs(
['state', 'begin-phase', '--phase', '3'],
{ valueFlags: ['phase', 'name', 'plans'], positionals: 2 },
);
assert.deepStrictEqual(result, { ok: true, data: { phase: '3', name: null, plans: null } });
});
// Corrected after the first full verification run: ADR-3473 §8.4 mandates
// rejecting *unrecognized* and *positional* tokens — it says nothing about
// a value flag whose value is missing or flag-shaped. Treating that as an
// error was an over-implementation (not the ADR's rule) and it broke the
// long-standing, deliberately-recorded `null` contract exercised by
// tests/init.test.cjs emptyPrdValueIsFalsyAndTreatedAsAbsent (row B5) and
// tests/section-manifest-init-facts.test.cjs "flag-shaped value (--prd
// --weird)". A value flag whose next token is absent or flag-shaped
// resolves to `null` and is NOT an error; the cursor advances by 1 so the
// following flag token is validated on its own merits on the next
// iteration.
test('valueFlagFollowedByAnotherFlagResolvesToNullNotAnError', () => {
const result = parseNamedArgs(
['state', 'planned-phase', '--phase', '--name', 'x'],
{ valueFlags: ['phase', 'name'], positionals: 2 },
);
assert.deepStrictEqual(result, { ok: true, data: { phase: null, name: 'x' } });
});
test('unknownFlagIsRejectedAndListsAcceptedFlags', () => {
const result = parseNamedArgs(
['state', 'begin-phase', '--bogus', 'x'],
{ valueFlags: ['phase', 'name'], positionals: 2 },
);
assert.strictEqual(result.ok, false);
assert.strictEqual(result.kind, 'InvalidArgs');
assert.strictEqual(result.arg, '--bogus');
assert.match(result.reason, /phase/i, 'reason must name an accepted flag');
assert.match(result.reason, /name/i, 'reason must name an accepted flag');
});
// #3358: the exact call site shape (`state planned-phase 3`, no --phase)
// that let a stray positional silently drop and overwrite STATE.md's
// previously-current phase block. See tests/state.test.cjs
// positionalPlannedPhaseLeavesStateMdUntouched_3358 for the consumer-output
// identity row this defect is actually observed through.
test('unexpectedPositionalIsRejected_3358', () => {
const result = parseNamedArgs(
['state', 'planned-phase', '3'],
{ valueFlags: ['phase', 'name', 'plans'], positionals: 2 },
);
assert.strictEqual(result.ok, false);
assert.strictEqual(result.arg, '3');
assert.match(result.reason, /positional/i);
});
// Negative space N3: a positional the caller declares (and reads itself
// via args[2]) must never be flagged as unexpected.
test('declaredPositionalIsNotFlagged', () => {
const result = parseNamedArgs(
['init', 'execute-phase', '01', '--tdd'],
{ booleanFlags: ['tdd'], positionals: 3 },
);
assert.strictEqual(result.ok, true);
assert.strictEqual(result.data.tdd, true);
});
// Required boundary triple over a fixed argv: positionals one short of the
// token's index rejects it; positionals at or past that index accepts it.
test('positionalBoundaryAtNMinus1_N_NPlus1', () => {
const argv = ['state', 'complete-phase', '3'];
const nMinus1 = parseNamedArgs(argv, { valueFlags: ['phase'], positionals: 2 });
assert.strictEqual(nMinus1.ok, false);
assert.strictEqual(nMinus1.arg, '3');
const atN = parseNamedArgs(argv, { valueFlags: ['phase'], positionals: 3 });
assert.strictEqual(atN.ok, true);
const nPlus1 = parseNamedArgs(argv, { valueFlags: ['phase'], positionals: 4 });
assert.strictEqual(nPlus1.ok, true);
});
// Negative space N5: a value beginning with a single `-` (a negative
// number) is not mistaken for a flag. The strict pass must reuse the same
// `startsWith('--')` predicate the permissive parser already gets right.
test('negativeNumberValueIsNotTreatedAsFlag', () => {
const result = parseNamedArgs(
['state', 'record-metric', '--plans', '-1'],
{ valueFlags: ['plans'], positionals: 2 },
);
assert.strictEqual(result.ok, true);
assert.strictEqual(result.data.plans, '-1');
});
// Negative space N6: `init quick <description>` consumes everything after
// the family/subcommand as free text — undeclared-flag rejection must be
// disabled for this documented shape, not accidentally re-enabled.
test('restPositionalsAcceptFreeTextIncludingUnknownFlags', () => {
const result = parseNamedArgs(
['init', 'quick', 'add', 'a', '--dry-run', 'option'],
{ positionals: 'rest' },
);
assert.strictEqual(result.ok, true);
});
// ADR-3473 Decision 2: both ends of this seam are gsd-core's own source. A
// stale call site using the legacy 3-positional-argument shape must throw
// loudly rather than silently destructuring undefined off a Result.
test('legacyArrayArgumentShapeThrows', () => {
assert.throws(() => parseNamedArgs(['--a', '1'], ['a']));
});
test('missingSpecThrows', () => {
assert.throws(() => parseNamedArgs(['--a', '1']));
});
test('bareDoubleDashIsRejectedNotCrashing', () => {
const result = parseNamedArgs(
['state', 'planned-phase', '--'],
{ valueFlags: ['phase'], positionals: 2 },
);
assert.strictEqual(result.ok, false);
assert.strictEqual(result.arg, '--');
});
// A hostile positional must be rejected as opaque data — never executed,
// interpolated, or otherwise treated as anything but a literal string that
// fails validation and is echoed back verbatim in `arg`.
test('hostileTokensAreRejectedAsOpaqueData', () => {
const hostileTokens = [';', '$(id)', 'line1\nline2', 'a b'];
for (const token of hostileTokens) {
const result = parseNamedArgs(
['state', 'planned-phase', token],
{ valueFlags: ['phase'], positionals: 2 },
);
assert.strictEqual(result.ok, false, `hostile token should be rejected: ${JSON.stringify(token)}`);
assert.strictEqual(result.arg, token);
}
});
// Required fast-check property (parsers get one): for any argv built only
// from declared flags and their well-formed (non-`--`-prefixed) values,
// with positionals:0, the result is ok:true and every declared key
// resolves to exactly the value it was given.
test('fc: wellFormedArgvAlwaysParsesOk', () => {
fc.assert(
fc.property(
fc.uniqueArray(
fc.constantFrom('phase', 'name', 'plans', 'summary', 'text'),
{ minLength: 1, maxLength: 5 },
).chain((flags) => fc.tuple(
fc.constant(flags),
fc.array(
fc.stringMatching(/^[a-zA-Z0-9_]+$/).filter((s) => s.length > 0),
{ minLength: flags.length, maxLength: flags.length },
),
)),
([flags, values]) => {
const argv = [];
for (let i = 0; i < flags.length; i++) {
argv.push(`--${flags[i]}`, values[i]);
}
const result = parseNamedArgs(argv, { valueFlags: flags, positionals: 0 });
assert.strictEqual(result.ok, true);
for (let i = 0; i < flags.length; i++) {
assert.strictEqual(result.data[flags[i]], values[i]);
}
},
),
);
});
// Required fast-check property, negative side: for any argv containing at
// least one token past the boundary that is neither a declared flag nor a
// declared flag's value, the result is ok:false.
test('fc: anyUndeclaredTokenAlwaysRejects', () => {
fc.assert(
fc.property(
fc.constantFrom('phase', 'name', 'plans'),
fc.stringMatching(/^[a-zA-Z0-9_]+$/).filter((s) => s.length > 0),
(declaredFlag, undeclaredToken) => {
const argv = [`--${declaredFlag}`, 'val', undeclaredToken];
const result = parseNamedArgs(argv, { valueFlags: [declaredFlag], positionals: 0 });
assert.strictEqual(result.ok, false);
},
),
);
});
});
// ---------------------------------------------------------------------------
// formatDiagnosticToken (io.cjs) — untrusted-token diagnostic escaping.
//
// Adversarial review finding (isolated review, verified live): a token
// embedded verbatim in a plain-text "Error: <message>" diagnostic can forge
// a second stderr line beginning "Error:" by smuggling its own "\n". Repro
// on this tree BEFORE the fix:
// $ node gsd-tools.cjs query state.planned-phase "foo\nError: forged second line"
// Error: unexpected positional argument "foo
// Error: forged second line"
// These tests spawn the real CLI (the vulnerability is about the literal
// bytes on stderr, not `parseNamedArgs`'s return value) through the exact
// call shape the finding used: `query state.planned-phase <hostile token>`
// hits the "unexpected positional argument" reason string, which embeds the
// token directly. Asserts on the RAW stderr string (not trimmed) — a
// trimmed assertion would hide a leading/trailing forged blank line.
// ---------------------------------------------------------------------------
describe('formatDiagnosticToken escapes untrusted tokens in plain-text diagnostics', () => {
const HOSTILE_TOKENS = [
['embedded newline forging a second Error: line', 'foo\nError: forged second line'],
['embedded double quote', 'foo"bar'],
['embedded C0 control character', 'foo\x07bar'],
];
for (const [label, token] of HOSTILE_TOKENS) {
test(`unexpected-positional diagnostic stays single-line for a token with ${label}`, () => {
const tmpDir = createTempProject();
try {
const result = runCli(['query', 'state.planned-phase', token], { cwd: tmpDir, jsonErrors: false });
assert.notStrictEqual(result.status, 0, 'a rejected positional must exit non-zero');
const rawStderr = result.stderr;
const nonEmptyLines = rawStderr.split('\n').filter((l) => l.length > 0);
assert.strictEqual(
nonEmptyLines.length, 1,
`expected exactly one non-empty stderr line, got raw stderr: ${JSON.stringify(rawStderr)}`,
);
assert.match(nonEmptyLines[0], /^Error: /);
const errorPrefixedLineCount = rawStderr.split('\n').filter((l) => l.startsWith('Error:')).length;
assert.strictEqual(errorPrefixedLineCount, 1, 'the hostile token must not forge a second "Error:" line');
} finally {
cleanup(tmpDir);
}
});
}
// Same repro shape, but through the "unknown flag" reason string (the
// second of the three sites the finding named) — a hostile token that
// itself starts with "--" so it is walked as a flag, not a positional.
test('unknown-flag diagnostic stays single-line for a hostile flag-shaped token', () => {
const tmpDir = createTempProject();
try {
const result = runCli(
['query', 'state.planned-phase', '--bogus\nError: forged', 'x'],
{ cwd: tmpDir, jsonErrors: false },
);
assert.notStrictEqual(result.status, 0);
const rawStderr = result.stderr;
const nonEmptyLines = rawStderr.split('\n').filter((l) => l.length > 0);
assert.strictEqual(
nonEmptyLines.length, 1,
`expected exactly one non-empty stderr line, got raw stderr: ${JSON.stringify(rawStderr)}`,
);
assert.match(nonEmptyLines[0], /^Error: unknown flag/);
} finally {
cleanup(tmpDir);
}
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3431-debug-command-yaml.test.cjs — consolidation epic #1969 (B3 #1972)

View File

@@ -1,5 +1,10 @@
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
//
// docs-guard-exempt: #3884 — the docs/CLI-TOOLS.md:736 citation below is an
// explanatory comment pointing at documented CLI shape, not a read target;
// this file performs unrelated filesystem reads (STATE.md, phase/plan
// fixtures under .planning/) and never reads any docs/ file.
/**
* GSD Tools Tests - Concurrency Safety
@@ -537,16 +542,38 @@ must_haves:
`
);
// #3884 (ADR-3473 §8.4): `frontmatter get <file> <field>` is not a real
// form — the documented shape is `frontmatter get <file> [--field key]`
// (docs/CLI-TOOLS.md:736). Under the pre-#3884 permissive parser the bare
// "must_haves" positional was silently dropped, `field` resolved to
// null, and cmdFrontmatterGet dumped the WHOLE frontmatter object — the
// test's original `result.output.includes('acceptance')` branch passed
// only because the full dump happens to contain that substring, not
// because field selection ever worked. `cmdFrontmatterGet` never calls
// `parseMustHavesBlock` (that WARNING is only emitted by other
// consumers), so the WARNING branch of the old assertion could never
// fire through this command either. Corrected to the real `--field`
// form and strengthened to assert on the actual, field-scoped payload.
const result = runGsdTools(
['frontmatter', 'get', path.join(planDir, '01-01-PLAN.md'), 'must_haves'],
['frontmatter', 'get', path.join(planDir, '01-01-PLAN.md'), '--field', 'must_haves'],
tmpDir
);
const stderr = result.error || '';
assert.ok(
stderr.includes('WARNING') && stderr.includes('must_haves') ||
result.output.includes('acceptance'),
`Expected WARNING about must_haves parse or valid parse result. stderr: ${stderr}, stdout: ${result.output}`
assert.ok(result.success, `frontmatter get --field must_haves failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.deepStrictEqual(
Object.keys(parsed),
['must_haves'],
`--field must_haves must scope the output to only that field, got keys: ${Object.keys(parsed).join(', ')}`,
);
// Dash-less list items under a YAML mapping key fold into a single plain
// scalar string, not an array — this is the actual "0 items" parse
// hazard the test's title names, surfaced directly rather than via a
// WARNING this command path never emits.
assert.strictEqual(
typeof parsed.must_haves.acceptance,
'string',
`bare-content (no dash prefix) must_haves.acceptance must parse as a scalar string, not a list, got: ${JSON.stringify(parsed.must_haves.acceptance)}`,
);
});

View File

@@ -667,12 +667,20 @@ describe('#1778: thread workflow uses the 1.6 named-flag frontmatter.set form',
'named-flag form must write status: resolved into the file',
);
// Pre-1.6 positional form — must fail with the documented message and NOT mutate.
// Pre-1.6 positional form — must fail and NOT mutate.
//
// #3884 (ADR-3473 §8.4): the strict parser now rejects the stray
// positional tokens ("status", "resolved") BEFORE cmdFrontmatterSet's own
// "file, field, and value required" guard ever runs, so the error text
// changed. The behavioral contract this test guards — fails, and does
// NOT mutate the file — is unchanged and, if anything, strengthened (the
// rejection now happens earlier, at argv-parsing time, not deep inside
// the command).
const badFile = writeTempFile('---\nstatus: open\nupdated: "2025-01-01"\n---\n\n# thread body\n');
const bad = runGsdTools(['frontmatter', 'set', badFile, 'status', 'resolved']);
assert.ok(!bad.success, 'positional form must fail (it is the bug being guarded against)');
assert.ok(
(bad.error + bad.output).includes('file, field, and value required'),
(bad.error + bad.output).includes('unexpected positional argument'),
`positional form must error with the documented message; got:\n${bad.error}${bad.output}`,
);
assert.strictEqual(

View File

@@ -311,38 +311,49 @@ describe('init.debug --diagnose forwarding and hostile argv (matrix §C)', () =>
assert.equal(output.diagnose, true);
});
test('ignores an unrecognized flag (row C4)', () => {
test('rejects an unrecognized flag with the flag named (row C4)', () => {
// ADR-3473 §8.4 (Bucket-B correction): "`parseNamedArgs` rejects
// unrecognized ... tokens with a non-zero exit — it is called by agents
// that will drift again." An unrecognized flag is exactly the mandated
// rejection, not a thing to silently absorb.
const result = runGsdTools(['init', 'debug', '--nope'], tmpDir);
assert.ok(result.success, `an unknown flag must not fail the command: ${result.error}`);
const output = JSON.parse(result.output);
assert.equal(output.diagnose, false);
assert.equal(result.success, false, 'an unknown flag must now fail the command');
assert.match(result.error, /--nope/, 'the rejection must name the offending flag');
});
test('survives a flag-shaped trailing token (row C5)', () => {
test('rejects a flag-shaped trailing token, naming it (row C5)', () => {
const result = runGsdTools(['init', 'debug', '--diagnose', '--weird'], tmpDir);
assert.ok(result.success, `must not crash on a flag-shaped token: ${result.error}`);
assert.equal(JSON.parse(result.output).diagnose, true);
assert.equal(result.success, false, 'an unrecognized flag-shaped token must now fail the command');
assert.match(result.error, /--weird/, 'the rejection must name the offending flag');
});
test('does not interpolate shell metacharacters (row C6)', () => {
test('does not interpolate shell metacharacters even though the hostile positional is now rejected (row C6)', () => {
const canary = path.join(tmpDir, 'PWNED');
const hostile = `; touch ${canary}; $(touch ${canary}) \`touch ${canary}\` && touch ${canary}`;
const result = runGsdTools(['init', 'debug', hostile], tmpDir);
assert.ok(result.success, `hostile argv must not fail the command: ${result.error}`);
// §8.4 now rejects this as an unexpected positional argument (exit
// non-zero) instead of silently absorbing it — that is at least as safe
// as the old accept-and-ignore behavior. The canary assertion is the
// actual point of this test and is unchanged: no shell ever touches this
// string, whether the token is accepted or rejected.
assert.equal(result.success, false, 'a stray positional argument must now fail the command');
assert.equal(fs.existsSync(canary), false, 'no shell interpolation of an attacker-controlled argument');
assert.equal(result.output.includes(' at '), false, 'no stack trace in non-debug output');
assert.equal(result.error.includes(' at '), false, 'no stack trace in non-debug output');
});
test('survives a very long argument (row C7/C8)', () => {
test('rejects a very long or unicode positional argument, not just tolerates it (row C7/C8)', () => {
// Classified as the same §8.4 unexpected-positional-argument shape as
// C6: `init debug` declares no positionals, so any bare token here is a
// stray positional and must now be rejected rather than silently
// absorbed.
const long = 'x'.repeat(8192);
const unicode = 'ünïcødé-🐛-测试';
for (const arg of [long, unicode]) {
const result = runGsdTools(['init', 'debug', arg], tmpDir);
assert.ok(result.success, `argument of length ${arg.length} must not crash: ${result.error}`);
assert.equal(JSON.parse(result.output).diagnose, false);
assert.equal(result.success, false, `argument of length ${arg.length} must now fail the command, not crash`);
}
});
});

View File

@@ -11,6 +11,7 @@ const { runGsdTools, cleanup, absPlanningPath, TOOLS_PATH, parseFrontmatter } =
const { createFixture, seedPhase } = require('./fixtures/index.cjs');
const { createTempProject, createTempDir } = require('./helpers.cjs');
const { executionContextRefs } = require('../scripts/command-contract-helpers.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
/**
* #3188: write the canonical flat planning docs so an init-query "present" test
@@ -3648,31 +3649,61 @@ describe('init section manifest', () => {
});
test('handlesMalformedWaveAssignments', (t) => {
// Documented handling (decision made during this dispatch): parseNamedArgs's
// booleanFlags check is an EXACT token match against the literal "--wave" —
// "--wave=" and "--wave==1" are different literal tokens, so neither activates
// the flag. No crash either way; this is the same exact-match discipline that
// keeps "--waves"/"--wave-filter" from false-activating (row 52).
// Corrected after the first full verification run: neither --wave= nor
// --wave==1 is a documented or shipped token (commands/gsd/execute-phase.md,
// gsd-core/workflows/execute-phase.md, and the docs tree all only ever
// emit the space-separated --wave N form) — each is an exact, distinct,
// undeclared flag token, so ADR-3473 §8.4 mandates rejecting it outright
// rather than silently letting it fall through unrecognized. Exit 1, and
// — same exact-match discipline that keeps "--waves"/"--wave-filter"
// from false-activating (row 52) — the rejection must name the
// malformed token itself, proving it was never coerced into activating
// --wave.
const dir = seedSinglePhaseProject(t, 'gsd-e50-');
for (const token of ['--wave=', '--wave==1']) {
const body = parseOkJson(runExecutePhase(['1', token], dir), `malformed-wave:${token}`);
assert.ok(!body.section_manifest.included.includes('partial-wave'), `"${token}" must not activate --wave`);
const result = runExecutePhase(['1', token], dir);
assert.equal(result.status, 1, `malformed-wave:${token}: expected exit 1, got ${result.status}`);
const err = JSON.parse(result.stderr);
assert.match(err.message, new RegExp(escapeRegex(token)), `"${token}" must be named as the unknown flag, proving it did not activate --wave`);
}
});
test('doesNotConsumeFollowingFlagAsWaveValue', (t) => {
// Unit-level: --wave is an optionalValueFlags entry (#2932's `--wave N`
// shape) — its cursor never swallows a following flag-shaped token as
// its value; it advances by 1, not 2, leaving --weird for its own
// validation. Assert the extraction directly rather than through the
// full CLI, since --weird's own (correct) rejection below makes the
// manifest body unreachable.
const { parseNamedArgs } = require('../gsd-core/bin/lib/command-arg-projection.cjs');
const extracted = parseNamedArgs(['--wave', '--weird'], { optionalValueFlags: ['wave'], positionals: 'rest' });
assert.strictEqual(extracted.ok, true);
assert.strictEqual(extracted.data.wave, true, '--wave must resolve to present (true), not be starved by the following token');
// Integration: --weird is a genuinely undeclared flag on execute-phase,
// so ADR-3473 §8.4 mandates rejecting it — exit 1, not the old exit-0
// "ignored" shape. The rejection naming "--weird" (not "--wave") is
// itself proof --wave did not consume it as a value.
const dir = seedSinglePhaseProject(t, 'gsd-e51-');
const body = parseOkJson(runExecutePhase(['1', '--wave', '--weird'], dir), 'wave-then-weird');
// Boolean-flag semantics: --wave never reads a following token as its value,
// so an adjacent flag-shaped token is simply ignored, not eaten or mis-parsed.
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
const result = runExecutePhase(['1', '--wave', '--weird'], dir);
assert.equal(result.status, 1, `wave-then-weird: expected exit 1, got ${result.status}`);
const err = JSON.parse(result.stderr);
assert.match(err.message, /--weird/, 'the unknown-flag rejection must name --weird, proving --wave did not consume it as its value');
});
test('nearMissFlagNamesDoNotActivateWave', (t) => {
// Corrected after the first full verification run: neither "--waves"
// nor "--wave-filter" is documented or shipped for execute-phase, so
// each is a genuinely undeclared flag — ADR-3473 §8.4 mandates
// rejecting it (exit 1), not silently ignoring it. The rejection
// naming the near-miss token itself is what proves it never
// false-activated --wave.
const dir = seedSinglePhaseProject(t, 'gsd-e52-');
for (const flag of ['--waves', '--wave-filter']) {
const body = parseOkJson(runExecutePhase(['1', flag], dir), `near-miss:${flag}`);
assert.ok(!body.section_manifest.included.includes('partial-wave'), `"${flag}" must not activate --wave`);
const result = runExecutePhase(['1', flag], dir);
assert.equal(result.status, 1, `near-miss:${flag}: expected exit 1, got ${result.status}`);
const err = JSON.parse(result.stderr);
assert.match(err.message, new RegExp(escapeRegex(flag)), `"${flag}" must be named as the unknown flag, proving it did not activate --wave`);
}
});
});

View File

@@ -452,6 +452,13 @@ describe('bug #3600: milestone phase filter understands project-code-prefixed di
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true });
}
// #3884 (ADR-3473 §8.4): `--json` was never a real flag for `init
// new-milestone` — `init` subcommands always emit a JSON bundle regardless
// of any flag (the machine-readable-output flag is `--raw`, documented at
// docs/CLI-TOOLS.md:30, not `--json`). Under the pre-#3884 permissive
// parser the unrecognized token was silently dropped and the assertions
// below never actually depended on it; the strict parser now rejects it.
// Removed across this describe block's four call sites.
test('init.new-milestone counts CK-NN-name dirs against numeric `Phase N:` headings', () => {
writeConfig(tmpDir, { project_code: 'CK' });
writeState(tmpDir, 'v1.0.0');
@@ -464,7 +471,7 @@ describe('bug #3600: milestone phase filter understands project-code-prefixed di
ensurePhaseDir(tmpDir, 'CK-01-discovery');
ensurePhaseDir(tmpDir, 'CK-02-build');
const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir);
const r = runGsdTools(['init', 'new-milestone'], tmpDir);
assert.ok(r.success, `init new-milestone failed: ${r.error || r.output}`);
const payload = JSON.parse(r.output);
assert.strictEqual(payload.phase_dir_count, 2,
@@ -480,7 +487,7 @@ describe('bug #3600: milestone phase filter understands project-code-prefixed di
].join('\n'));
ensurePhaseDir(tmpDir, '01-first');
const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir);
const r = runGsdTools(['init', 'new-milestone'], tmpDir);
assert.ok(r.success);
assert.strictEqual(JSON.parse(r.output).phase_dir_count, 1);
});
@@ -495,7 +502,7 @@ describe('bug #3600: milestone phase filter understands project-code-prefixed di
].join('\n'));
ensurePhaseDir(tmpDir, 'PROJ-42');
const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir);
const r = runGsdTools(['init', 'new-milestone'], tmpDir);
assert.ok(r.success);
assert.strictEqual(JSON.parse(r.output).phase_dir_count, 1,
'PROJ-42 directory must still match Phase PROJ-42: via the custom-ID path');
@@ -513,7 +520,7 @@ describe('bug #3600: milestone phase filter understands project-code-prefixed di
ensurePhaseDir(tmpDir, 'CK-99-backlog');
ensurePhaseDir(tmpDir, 'CK-100-future');
const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir);
const r = runGsdTools(['init', 'new-milestone'], tmpDir);
assert.ok(r.success);
assert.strictEqual(JSON.parse(r.output).phase_dir_count, 1,
'only CK-01-first should match Phase 1; CK-99 and CK-100 must be excluded');

View File

@@ -7,7 +7,11 @@
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools } = require('./helpers.cjs');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const { seedPhase } = require('./fixtures/index.cjs');
const { runCli } = require('./helpers/cli-negative.cjs');
// ─── --pick flag ─────────────────────────────────────────────────────────────
@@ -24,10 +28,110 @@ describe('--pick flag', () => {
assert.strictEqual(result.output, 'hello-world');
});
test('returns empty string for missing field', () => {
// #3365 / ADR-3473 §8.4 P6: an ABSENT field is a failure ("I could not
// answer"), never a demotion to the empty answer at exit 0. This inverts
// the old pinned assertion below (kept as a comment for the historical
// record — measured on this tree, 2026-08-26, exit 0 + empty stdout):
// const result = runGsdTools('generate-slug "test" --pick nonexistent');
// assert.strictEqual(result.success, true);
// assert.strictEqual(result.output, '');
test('absentFieldExitsNonZero_3365', () => {
const result = runGsdTools('generate-slug "test" --pick nonexistent');
assert.strictEqual(result.success, true);
assert.strictEqual(result.success, false, 'an absent --pick field must exit non-zero');
assert.strictEqual(result.output, '');
assert.match(result.error, /nonexistent/, 'stderr must name the requested field');
});
// P2 (test matrix): a count of zero is a real value, not absence — this
// must keep PASSING before and after the fix (the non-change half of #3365).
test('zeroCountPrintsZeroAtExitZero', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools('query phases.list --type summaries --pick count', tmpDir);
assert.strictEqual(result.success, true);
assert.strictEqual(result.output, '0');
} finally {
cleanup(tmpDir);
}
});
// P4 (test matrix, negative space N1): a field present with an explicit
// `null` value is an answer, not a failure — must keep PASSING before and
// after the fix. Measured: `phases.list --type plans --pick phase_dir` on
// the enumeration path (no --phase given) returns `phase_dir: null`.
test('presentButNullIsEmptyAtExitZero', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools('query phases.list --type plans --pick phase_dir', tmpDir);
assert.strictEqual(result.success, true);
assert.strictEqual(result.output, '');
} finally {
cleanup(tmpDir);
}
});
// P10 (test matrix): required boundary triple over an array field of
// known length N=3 (three seeded phase directories).
test('arrayIndexBoundaryAtLenMinus1_Len_LenPlus1', () => {
const tmpDir = createTempProject();
try {
seedPhase(tmpDir, '01-alpha');
seedPhase(tmpDir, '02-beta');
seedPhase(tmpDir, '03-gamma');
const atLenMinus1 = runGsdTools('query phases.list --pick directories[2]', tmpDir);
assert.strictEqual(atLenMinus1.success, true);
assert.strictEqual(atLenMinus1.output, '03-gamma');
const atLen = runGsdTools('query phases.list --pick directories[3]', tmpDir);
assert.strictEqual(atLen.success, false, 'index == length is out of range and must exit non-zero');
const atLenPlus1 = runGsdTools('query phases.list --pick directories[4]', tmpDir);
assert.strictEqual(atLenPlus1.success, false, 'index == length + 1 is out of range and must exit non-zero');
} finally {
cleanup(tmpDir);
}
});
// P12 (test matrix): the measured B11 defect — a non-JSON command's
// `--pick` must never dump the whole document as a coincidental "success".
// TODAY (measured on this tree, 2026-08-26), against an empty temp project:
// $ gsd-tools audit-open --pick nonexistent_field
// ### Milestone Close: Open Artifact Audit
//
// All artifact types clear. Safe to proceed.
//
// ---
// exit 0
test('nonJsonOutputDoesNotDumpWholeDocument', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools('audit-open --pick nonexistent_field', tmpDir);
assert.strictEqual(result.success, false);
assert.strictEqual(result.output, '');
} finally {
cleanup(tmpDir);
}
});
// P13 (test matrix): `--raw --pick <known field>` withdraws its
// coincidental "success" — measured today: exit 0, stdout `hello-world`,
// via the same non-JSON dump `--raw` produces.
test('rawPlusPickIsRejectedNotCoincidentallyRight', () => {
const result = runGsdTools(['generate-slug', 'Hello World', '--raw', '--pick', 'slug']);
assert.strictEqual(result.success, false);
});
// P14 (test matrix): the confidently-wrong case — measured today: exit 0,
// stdout `hello-world` (the SLUG field's value, not the bogus field asked
// for), via the same non-JSON dump.
test('rawPlusPickBogusDoesNotEmitAnotherFieldsValue', () => {
const result = runGsdTools(['generate-slug', 'Hello World', '--raw', '--pick', 'bogus']);
assert.strictEqual(result.success, false);
assert.ok(
!result.output.includes('hello-world'),
`must not leak another field's value; got: ${JSON.stringify(result.output)}`,
);
});
test('errors when --pick has no value', () => {
@@ -55,4 +159,282 @@ describe('--pick flag', () => {
assert.ok(result.output.length > 0, 'timestamp should not be empty');
assert.match(result.output, /^\d{4}-\d{2}-\d{2}T/);
});
// B7 (design 40-design.md; test matrix P7): a dotted path that resolves
// partway then dies. `count` is a number on `phases.list --type summaries`
// (a real, always-present field); walking `.missing` off it is not a
// plain object, so the path dies partway through — the same failure class
// as B6 (field absent outright), not a crash.
test('absentDottedPathExitsNonZero', () => {
const tmpDir = createTempProject();
try {
const result = runCli(
['query', 'phases.list', '--type', 'summaries', '--pick', 'count.missing'],
{ cwd: tmpDir },
);
assert.notStrictEqual(result.status, 0);
assert.strictEqual(result.reason, 'pick_field_absent');
} finally {
cleanup(tmpDir);
}
});
// B9 (test matrix P11): bracket syntax applied to a non-array field.
test('bracketOnNonArrayExitsNonZero', () => {
const tmpDir = createTempProject();
try {
const result = runCli(
['query', 'phases.list', '--type', 'summaries', '--pick', 'count[0]'],
{ cwd: tmpDir },
);
assert.notStrictEqual(result.status, 0);
assert.strictEqual(result.reason, 'pick_field_absent');
} finally {
cleanup(tmpDir);
}
});
// B10 (test matrix): the existing boundary test above only exercises
// non-negative indices — negative-index normalization
// (`arr.length + index`) is a separate branch in extractField and was
// otherwise untested. N=3 seeded phase directories.
test('negativeArrayIndexInRangeResolves', () => {
const tmpDir = createTempProject();
try {
seedPhase(tmpDir, '01-alpha');
seedPhase(tmpDir, '02-beta');
seedPhase(tmpDir, '03-gamma');
const last = runGsdTools('query phases.list --pick directories[-1]', tmpDir);
assert.strictEqual(last.success, true);
assert.strictEqual(last.output, '03-gamma');
const first = runGsdTools('query phases.list --pick directories[-3]', tmpDir);
assert.strictEqual(first.success, true);
assert.strictEqual(first.output, '01-alpha');
const outOfRange = runGsdTools('query phases.list --pick directories[-4]', tmpDir);
assert.strictEqual(outOfRange.success, false, 'index == -(N+1) is out of range and must exit non-zero');
} finally {
cleanup(tmpDir);
}
});
// B14 (test matrix P15): the JSON root itself is not an object. VERIFIED
// on this tree: config-get with --default on a missing key emits the bare
// JSON string "fallback" (not an object), so --pick must fail rather than
// walk a string as if it had named fields.
test('nonObjectJsonRootExitsNonZero', () => {
const tmpDir = createTempProject();
try {
const plain = runGsdTools('config-get nonexistent.key --default fallback', tmpDir);
assert.strictEqual(plain.success, true);
assert.strictEqual(plain.output, '"fallback"');
const result = runCli(
['config-get', 'nonexistent.key', '--default', 'fallback', '--pick', 'value'],
{ cwd: tmpDir },
);
assert.notStrictEqual(result.status, 0);
assert.strictEqual(result.reason, 'pick_field_absent');
assert.match(result.message, /JSON string, not an object/);
} finally {
cleanup(tmpDir);
}
});
// B17 (test matrix P19/P20, negative space N8): io.cjs's output() writes
// `@file:<path>` instead of inline JSON once the serialized payload
// exceeds 50000 characters, and `--pick` MUST resolve that redirection
// BEFORE parsing — otherwise every large result becomes a false
// pick_output_not_json. The fixture below seeds exactly PHASE_COUNT real
// phase directories (skipping phase number 999, which phase.cjs's sentinel
// predicate — SENTINEL_RANGES [0,999] — excludes from the list regardless
// of padding width, confirmed empirically; skipping it keeps `count`
// exactly PHASE_COUNT so the assertions below are deterministic) with
// padded names long enough that the serialized JSON provably exceeds the
// threshold. The spill is MEASURED, not assumed: the plain (non --pick)
// path transparently resolves @file: back to inline JSON (#1891), so its
// stdout length IS the real serialized payload size.
test('largeAtFilePayloadStillResolves + largeAtFilePayloadAbsentFieldIsAbsentNotNonJson', () => {
const tmpDir = createTempProject();
try {
const PHASE_COUNT = 1200;
let made = 0;
for (let i = 1; made < PHASE_COUNT; i++) {
if (i === 999) continue; // sentinel phase id — excluded from the list, would skew `count`
seedPhase(tmpDir, `${String(i).padStart(5, '0')}-phase-name-padding-to-make-this-longer`);
made++;
}
const plain = runGsdTools('query phases.list', tmpDir);
assert.strictEqual(plain.success, true);
assert.ok(
plain.output.length > 50000,
`fixture must exceed the 50000-char @file: spill threshold; measured ${plain.output.length}`,
);
// Present field ("largeAtFilePayloadStillResolves"): resolves at exit
// 0 through the @file: payload.
const present = runGsdTools('query phases.list --pick count', tmpDir);
assert.strictEqual(present.success, true);
assert.strictEqual(present.output, String(PHASE_COUNT));
// Absent field ("largeAtFilePayloadAbsentFieldIsAbsentNotNonJson"):
// must be pick_field_absent, NOT pick_output_not_json — proving the
// @file: resolution ran before the JSON.parse/absence check.
const absent = runCli(
['query', 'phases.list', '--pick', 'nonexistent_field'],
{ cwd: tmpDir },
);
assert.notStrictEqual(absent.status, 0);
assert.strictEqual(absent.reason, 'pick_field_absent');
} finally {
cleanup(tmpDir);
}
});
});
// ---------------------------------------------------------------------------
// formatDiagnosticToken (io.cjs) — untrusted --pick token escaping.
//
// Adversarial review finding (isolated review, verified live): `--pick`'s
// field value reaches the "field not found" / "output was not JSON"
// diagnostics verbatim. Repro on this tree BEFORE the fix:
// $ node gsd-tools.cjs generate-slug x --pick $'a\nError: forged'
// Error: --pick a
// Error: forged: field not found; available top-level keys: slug
// Spawns the real CLI (the vulnerability is about the literal bytes on
// stderr) and asserts on the RAW stderr string — a trimmed assertion would
// hide a leading/trailing forged blank line.
// ---------------------------------------------------------------------------
describe('formatDiagnosticToken escapes untrusted --pick field values', () => {
const HOSTILE_TOKENS = [
['embedded newline forging a second Error: line', 'a\nError: forged'],
['embedded double quote', 'a"bogus'],
['embedded C0 control character', 'a\x07bogus'],
];
for (const [label, token] of HOSTILE_TOKENS) {
test(`--pick field-not-found diagnostic stays single-line for a token with ${label}`, () => {
const tmpDir = createTempProject();
try {
const result = runCli(['generate-slug', 'x', '--pick', token], { cwd: tmpDir, jsonErrors: false });
assert.notStrictEqual(result.status, 0);
const rawStderr = result.stderr;
const nonEmptyLines = rawStderr.split('\n').filter((l) => l.length > 0);
assert.strictEqual(
nonEmptyLines.length, 1,
`expected exactly one non-empty stderr line, got raw stderr: ${JSON.stringify(rawStderr)}`,
);
assert.match(nonEmptyLines[0], /^Error: /);
const errorPrefixedLineCount = rawStderr.split('\n').filter((l) => l.startsWith('Error:')).length;
assert.strictEqual(errorPrefixedLineCount, 1, 'the hostile token must not forge a second "Error:" line');
} finally {
cleanup(tmpDir);
}
});
}
});
// ---------------------------------------------------------------------------
// formatKeyForDiagnosticList (gsd-tools.cjs) — untrusted frontmatter KEY
// escaping, as distinct from the untrusted --pick TOKEN escaping above.
//
// `frontmatter get <file>` (no --field) reads an arbitrary user-authored
// markdown file and, on a subsequent --pick miss, echoes that document's own
// top-level frontmatter keys straight into the "field not found; available
// top-level keys: ..." diagnostic. A key is therefore untrusted input from a
// user document in exactly the way a --pick argv token is untrusted input
// from the shell — formatKeyForDiagnosticList (gsd-tools.cjs) exists to
// neutralize it the same way formatDiagnosticToken neutralizes the token.
//
// Reachable + verified live on this tree, e.g. for a frontmatter key
// containing a real embedded newline:
// $ printf '---\n"weird\\nkey": v\nplain: y\n---\n\nbody\n' > f.md
// $ gsd-tools frontmatter get f.md --pick absent_field
// Error: --pick "absent_field": field not found; available top-level keys: weird\nkey, plain
// The `\n` in that stderr is the two-character ESCAPED sequence backslash-n,
// not a real newline — the raw/untrimmed stderr assertions below pin that.
// ---------------------------------------------------------------------------
describe('formatKeyForDiagnosticList escapes untrusted frontmatter keys', () => {
test('hostileFrontmatterKeyCannotForgeASecondErrorLine', () => {
const HOSTILE_KEY_FIXTURES = [
['embedded newline', '---\n"weird\\nkey": v\nplain: y\n---\n\nbody\n', 'weird\\nkey'],
['embedded double quote', '---\n"weird\\"quotekey": v\nplain: y\n---\n\nbody\n', 'weird\\"quotekey'],
['embedded C0 control character', '---\n"weird\\u0007ctrlkey": v\nplain: y\n---\n\nbody\n', 'weird\\u0007ctrlkey'],
];
for (const [label, frontmatterSource, expectedEscapedKey] of HOSTILE_KEY_FIXTURES) {
const tmpDir = createTempProject();
try {
const f = path.join(tmpDir, 'weird.md');
fs.writeFileSync(f, frontmatterSource);
const result = runCli(
['frontmatter', 'get', f, '--pick', 'absent_field'],
{ cwd: tmpDir, jsonErrors: false },
);
assert.notStrictEqual(result.status, 0, `[${label}] must exit non-zero`);
const rawStderr = result.stderr;
// Split on the raw, UNTRIMMED stderr and drop exactly one trailing
// empty element (the newline error() always terminates its message
// with) — a hostile key that forged a second line would leave MORE
// than one element after that single drop.
const lines = rawStderr.split('\n');
assert.strictEqual(
lines[lines.length - 1], '',
`[${label}] expected a single trailing empty element from the terminating newline, got raw stderr: ${JSON.stringify(rawStderr)}`,
);
const linesWithoutTrailingEmpty = lines.slice(0, -1);
assert.strictEqual(
linesWithoutTrailingEmpty.length, 1,
`[${label}] expected exactly one line after dropping the trailing empty element, got raw stderr: ${JSON.stringify(rawStderr)}`,
);
const errorPrefixedLineCount = linesWithoutTrailingEmpty.filter((l) => l.startsWith('Error:')).length;
assert.strictEqual(errorPrefixedLineCount, 1, `[${label}] the hostile key must not forge a second "Error:" line`);
// The diagnostic must stay USEFUL, not merely safe: a "fix" that
// dropped the offending key entirely would also pass the one-line
// assertions above, so pin that the escaped key is still present.
assert.ok(
linesWithoutTrailingEmpty[0].includes(`available top-level keys: ${expectedEscapedKey}, plain`),
`[${label}] expected the escaped key to still name the offending key, got: ${JSON.stringify(linesWithoutTrailingEmpty[0])}`,
);
} finally {
cleanup(tmpDir);
}
}
});
// Negative-space control: an ORDINARY frontmatter document (no hostile
// bytes in any key) must still produce a plain, readable, unquoted key
// list — proving the escape does not turn every normal diagnostic into
// JSON-quoted noise.
test('ordinaryFrontmatterKeysStayPlainAndUnquotedInDiagnostic', () => {
const tmpDir = createTempProject();
try {
const f = path.join(tmpDir, 'plain.md');
fs.writeFileSync(f, '---\nalpha: v\nbeta: y\n---\n\nbody\n');
const result = runCli(
['frontmatter', 'get', f, '--pick', 'absent_field'],
{ cwd: tmpDir, jsonErrors: false },
);
assert.notStrictEqual(result.status, 0);
assert.match(result.stderr, /available top-level keys: alpha, beta\n$/);
// Only the KEY LIST must stay unquoted — `--pick "absent_field"` earlier
// in the same message is legitimately JSON-quoted by formatDiagnosticToken
// (a separate escape, for the untrusted argv token, not the frontmatter
// key), so scope the "no JSON-quoting noise" assertion to the key-list
// segment rather than the whole stderr string.
const keyListSegment = result.stderr.slice(result.stderr.indexOf('available top-level keys:'));
assert.ok(!keyListSegment.includes('"'), `ordinary keys must not be JSON-quoted, got: ${JSON.stringify(keyListSegment)}`);
} finally {
cleanup(tmpDir);
}
});
});

View File

@@ -290,11 +290,21 @@ describe('flag value shapes drive section_manifest by truthiness, not semantic v
assert.equal(occurrences.length, 1, 'must be a single membership, not one entry per duplicate token');
});
test('flag-shaped value (--prd --weird) does not crash and deterministically excludes prd-express-gate (row D6)', () => {
test('flag-shaped value (--prd --reviews) does not crash and deterministically excludes prd-express-gate (row D6)', () => {
// parseNamedArgs treats a token starting with "--" as the NEXT flag, never
// as this flag's value — so --prd here resolves to null (absent), not the
// literal string "--weird".
const result = runGsdTools(['init', 'plan-phase', '1', '--prd', '--weird'], tmpDir);
// literal string "--reviews".
//
// Corrected after the first full verification run: the original choice of
// `--weird` here predates ADR-3473 §8.4's strict unrecognized-flag
// rejection (this file is from #2994/epic #1671, before #3358's strict
// parseNamedArgs) and is itself an unrecognized flag for `plan-phase` —
// it now correctly fails with exit 1 / "unknown flag --weird" instead of
// proving the "flag-shaped value" point this test exists for. Swapped for
// `--reviews`, a real declared plan-phase boolean flag, matching the same
// substitution already used by the sibling row
// tests/init.test.cjs:emptyPrdValueIsFalsyAndTreatedAsAbsent (row B5).
const result = runGsdTools(['init', 'plan-phase', '1', '--prd', '--reviews'], tmpDir);
assert.ok(result.success, `a flag-shaped value must not crash the command: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.section_manifest.excluded.includes('prd-express-gate'), '--prd immediately followed by another --flag token must resolve to absent, per parseNamedArgs');

View File

@@ -1527,8 +1527,16 @@ describe('#3573 total_phases — roadmap absent with an asserted milestone', ()
);
seedPhaseDirs(tmpDir, [1]);
// ADR-3473 §8.4 / #3358: a bare positional phase ("state begin-phase 2")
// is now a rejected, undeclared token — see
// tests/state.test.cjs positionalPlannedPhaseLeavesStateMdUntouched_3358,
// the sibling regression for "planned-phase" that locks in exactly this
// rejection. Use the documented "--phase N" flag form (see
// CLI-TOOLS.md line 116 in the docs directory) instead; this test's own
// assertions were never about the bare-positional shape itself, only
// about total_phases surviving the resync.
const rec = runNode(
[TOOLS_PATH, 'state', 'begin-phase', '2'],
[TOOLS_PATH, 'state', 'begin-phase', '--phase', '2'],
{ cwd: tmpDir, env: { ...process.env, ...TEST_ENV_BASE }, timeoutMs: 60000 },
);
assert.ok(rec.exitCode === 0, `state begin-phase failed: ${rec.stderr}`);
@@ -1587,8 +1595,16 @@ describe('#3573 total_phases — roadmap absent with an asserted milestone', ()
);
seedPhaseDirs(tmpDir, [1]);
// ADR-3473 §8.4 / #3358: a bare positional phase ("state planned-phase 2")
// is now a rejected, undeclared token — see
// tests/state.test.cjs positionalPlannedPhaseLeavesStateMdUntouched_3358,
// the regression test that locks in exactly this rejection for this same
// subcommand. Use the documented "--phase N" flag form (see
// COMMANDS.md line 2192 in the docs directory) instead; this test's own
// assertions were never about the bare-positional shape itself, only
// about total_phases surviving the resync.
const rec = runNode(
[TOOLS_PATH, 'state', 'planned-phase', '2', '--name', 'Core'],
[TOOLS_PATH, 'state', 'planned-phase', '--phase', '2', '--name', 'Core'],
{ cwd: tmpDir, env: { ...process.env, ...TEST_ENV_BASE }, timeoutMs: 60000 },
);
assert.ok(rec.exitCode === 0, `state planned-phase failed: ${rec.stderr}`);

View File

@@ -8566,7 +8566,14 @@ describe('regressions: table-format STATE.md (#1162)', () => {
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '1-01-PLAN.md'), '# Plan 1');
const result = runGsdTools(['state', 'planned-phase', '1', '--plan-count', '1'], tmpDir);
// #3884 (ADR-3473 §8.4): `--plan-count` was never a declared flag (the
// real flag is `--plans`) and the bare '1' was never read as a phase
// positional either — both were silently dropped by the pre-#3884
// permissive parser. The command "worked" only because
// cmdStatePlannedPhase falls back to STATE.md's own current phase (1
// here) when no --phase is given, so the assertion below never actually
// exercised phase/plan-count plumbing. Corrected to the real flags.
const result = runGsdTools(['state', 'planned-phase', '--phase', '1', '--plans', '1'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
@@ -8707,7 +8714,9 @@ describe('regressions: table-format STATE.md (#1162) — updateCurrentPositionFi
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '2-01-PLAN.md'), '# Plan\n');
const result = runGsdTools(['state', 'planned-phase', '2', '--plan-count', '1'], tmpDir);
// #3884: `--plan-count` / bare positional never worked — see the (a)
// Finding-2a-sibling note on the earlier occurrence of this pattern.
const result = runGsdTools(['state', 'planned-phase', '--phase', '2', '--plans', '1'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(statePath, 'utf-8');
@@ -8733,7 +8742,9 @@ describe('regressions: table-format STATE.md (#1162) — updateCurrentPositionFi
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '2-01-PLAN.md'), '# Plan\n');
const result = runGsdTools(['state', 'planned-phase', '2', '--plan-count', '1'], tmpDir);
// #3884: `--plan-count` / bare positional never worked — see the note on
// the first occurrence of this pattern above.
const result = runGsdTools(['state', 'planned-phase', '--phase', '2', '--plans', '1'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(statePath, 'utf-8');
@@ -8754,7 +8765,9 @@ describe('regressions: table-format STATE.md (#1162) — updateCurrentPositionFi
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '2-01-PLAN.md'), '# Plan\n');
const result = runGsdTools(['state', 'planned-phase', '2', '--plan-count', '1'], tmpDir);
// #3884: `--plan-count` / bare positional never worked — see the note on
// the first occurrence of this pattern above.
const result = runGsdTools(['state', 'planned-phase', '--phase', '2', '--plans', '1'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(statePath, 'utf-8');
@@ -18891,3 +18904,122 @@ describe('ADR-3473 §8.7 (#3872): reconcileReportedFields / the transaction diff
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Consumer-output identity (ADR-3180 Decision 4(b)) — #3358 / #3884
//
// For #3358 the consumer is `state planned-phase`'s EFFECT on STATE.md, not
// `parseNamedArgs`'s return value — a unit assertion on the parser alone
// would have passed throughout this defect's entire life. These rows spawn
// the real CLI against a temp project and assert on STATE.md's bytes.
// ────────────────────────────────────────────────────────────────────────
describe('state — consumer-output identity (ADR-3180 Decision 4(b), #3358)', () => {
function stateMdWithPopulatedPhaseTwo() {
return [
'---',
"gsd_state_version: '1.0'",
'status: planning',
'progress:',
' total_phases: 5',
' completed_phases: 1',
' total_plans: 10',
' completed_plans: 4',
' percent: 40',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: 2 of 5 (Widget Support)',
'Plan: 1 of 3 in current phase',
'Status: Ready to execute',
'Last activity: 2026-08-20 — Phase 2 planning complete',
'',
'Progress: [####------] 40%',
'',
].join('\n');
}
// #3358: a stray positional (`3`) past `state planned-phase`'s declared
// boundary is silently dropped by the CURRENT permissive parseNamedArgs —
// every flag resolves to `null` — and the command still RUNS, overwriting
// the previously-current phase block.
//
// Measured on this tree, 2026-08-26, against exactly this fixture:
// $ gsd-tools query state.planned-phase 3 --cwd <tmp>
// {"updated":["Current Position","Current Phase Name"],"phase":null,"plan_count":null}
// exit 0
// STATE.md's `## Current Position` block changed from:
// Phase: 2 of 5 (Widget Support)
// to:
// Phase: null — READY TO EXECUTE
// (and frontmatter gained `current_phase_name: READY TO EXECUTE`, an
// outright corruption of the curated phase name).
test('positionalPlannedPhaseLeavesStateMdUntouched_3358', () => {
const tmpDir = createTempProject();
try {
const statePath = writeState(tmpDir, stateMdWithPopulatedPhaseTwo());
const before = fs.readFileSync(statePath);
const result = runGsdTools('query state.planned-phase 3', tmpDir);
assert.notStrictEqual(result.exitCode, 0, 'a positional argument past the boundary must exit non-zero');
const after = fs.readFileSync(statePath);
assert.ok(before.equals(after), 'STATE.md must be byte-identical to before the rejected call');
} finally {
cleanup(tmpDir);
}
});
// Control: the flag form of the exact same intent must keep succeeding and
// keep updating STATE.md — proves C1 above is not passing merely because
// `state planned-phase` is broken outright.
test('flagFormPlannedPhaseStillUpdatesStateMd', () => {
const tmpDir = createTempProject();
try {
const statePath = writeState(tmpDir, stateMdWithPopulatedPhaseTwo());
const result = runGsdTools('query state.planned-phase --phase 3 --name X --plans 2', tmpDir);
assert.strictEqual(result.success, true, result.error);
const after = fs.readFileSync(statePath, 'utf-8');
assert.match(after, /Phase: 3 \(X\) — READY TO EXECUTE/);
} finally {
cleanup(tmpDir);
}
});
// #3358, second call site: an extra positional token on `add-decision`
// must not be silently absorbed into a successful write.
test('positionalOnAddDecisionAppendsNothing', () => {
const tmpDir = createTempProject();
try {
const statePath = writeState(tmpDir, [
'---',
"gsd_state_version: '1.0'",
'status: planning',
'---',
'',
'# Project State',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
'- none yet',
'',
].join('\n'));
const before = fs.readFileSync(statePath, 'utf-8');
const result = runGsdTools(['query', 'state.add-decision', 'stray-token', '--summary', 'x'], tmpDir);
assert.notStrictEqual(result.exitCode, 0, 'an extra positional argument must exit non-zero');
const after = fs.readFileSync(statePath, 'utf-8');
assert.ok(!after.includes('- [Phase'), 'no decision row should have been appended');
assert.strictEqual(after, before, 'STATE.md must be unchanged when the call is rejected');
} finally {
cleanup(tmpDir);
}
});
});

View File

@@ -43,8 +43,6 @@ const {
dedupeViolationsForBaseline,
writeBaseline,
toPosixRel,
PICK_RE,
ECHO_FALLBACK_RE,
CAT_LS_COMMAND_RE,
HEREDOC_AFTER_COMMAND_RE,
isNoopFallback,
@@ -83,109 +81,56 @@ function markerComment(reason) {
return `# gsd-scan-ignore: ${reason}`;
}
// ─── Detector A — PICK_RE / ECHO_FALLBACK_RE ──────────────────────────────
// ─── Detector A — RETIRED, #3884 ───────────────────────────────────────────
//
// ADR-3473 §8.4 made `--pick` exit non-zero on an absent field (issue
// #3884), which made Detector A's own premise false — the `|| echo D` arm it
// forbade is now the CORRECT idiom, not an unreachable one. Detector A (its
// regexes, its branch in the scan, and its `kind: 'A'` finding shape) was
// removed from scripts/lint-unreachable-guard-drift.cjs; this describe block
// now pins the negative claim instead of the old positive one — the exact
// shapes that used to be flagged (canonical, no-stderr-redirect,
// empty-string-fallback, fenced, duplicated, CRLF) must produce ZERO
// violations, proving the retirement did not leave a partial/half-removed
// detector behind.
describe('Detector A — --pick + || echo fallback', () => {
test('A1: canonical shape with 2>/dev/null is flagged, found names --pick', () => {
const line = pickEchoLine({ stderr: true, fallback: '"d"' });
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].kind, 'A');
assert.strictEqual(violations[0].found, '--pick');
assert.strictEqual(violations[0].text, line.trim());
describe('Detector A (--pick + || echo) — retired, #3884', () => {
test('detectorAShapeIsNoLongerAFinding: the canonical shape, with/without a stderr redirect, and an empty-string fallback are all unreported', () => {
for (const opts of [{ stderr: true, fallback: '"d"' }, { stderr: false, fallback: '"d"' }, { fallback: '""' }]) {
const line = pickEchoLine(opts);
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, [], `expected no violations for ${JSON.stringify(opts)}`);
}
});
test('A2: without a stderr redirect is still flagged', () => {
const line = pickEchoLine({ stderr: false, fallback: '"d"' });
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].kind, 'A');
test('detectorAShapeIsNoLongerAFinding: fenced, duplicated, and CRLF variants of the shape are also unreported', () => {
const line = pickEchoLine();
const fenced = ['```bash', line, '```'].join('\n');
const duplicated = [line, line].join('\n');
const crlf = `${line}\r\n`;
assert.deepStrictEqual(findUnreachableGuardDrift(fenced, FAKE_FILE).violations, []);
assert.deepStrictEqual(findUnreachableGuardDrift(duplicated, FAKE_FILE).violations, []);
assert.deepStrictEqual(findUnreachableGuardDrift(crlf, FAKE_FILE).violations, []);
});
test('A3: an empty-string default is still flagged (unreachable AND a no-op)', () => {
const line = pickEchoLine({ fallback: '""' });
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
});
test('A4: a --pick-less config-get fallback is NOT detected', () => {
const line = ['X=$(gsd_run query config-get k 2>/dev/null ', '|', '|', ' echo "false")'].join('');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A5: a pick with no fallback is NOT detected', () => {
const line = 'X=$(gsd_run query phases.list --pick summaries_total)';
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A6: a git fallback is NOT detected', () => {
const line = ['X=$(git rev-list --count HEAD ', '|', '|', ' echo 0)'].join('');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A7: a grep fallback is NOT detected', () => {
const line = ["Y=$(grep -cE '^' file.md ", '|', '|', ' echo "0")'].join('');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A8: the and-or ternary idiom is NOT detected', () => {
const line = ['$([ -n "$X" ] && echo "a" ', '|', '|', ' echo "")'].join('');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A9 (known limit): a cross-line split is NOT detected', () => {
const text = [
test('detectorAShapeIsNoLongerAFinding: no other --pick + || echo variant (git/grep/config-get/ternary/cross-line/printf fallback) is reported either', () => {
const variants = [
['X=$(gsd_run query config-get k 2>/dev/null ', '|', '|', ' echo "false")'].join(''),
'X=$(gsd_run query phases.list --pick summaries_total)',
['X=$(git rev-list --count HEAD ', '|', '|', ' echo 0)'].join(''),
["Y=$(grep -cE '^' file.md ", '|', '|', ' echo "0")'].join(''),
['$([ -n "$X" ] && echo "a" ', '|', '|', ' echo "")'].join(''),
["X=$(gsd_run query phases.list --pick f ", '|', '|', " printf 'd')"].join(''),
];
for (const line of variants) {
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, [], `expected no violations for ${JSON.stringify(line)}`);
}
const crossLine = [
'X=$(gsd_run query phases.list --pick summaries_total 2>/dev/null)',
['Y=$(echo "$X" ', '|', '|', ' echo "0")'].join(''),
].join('\n');
const { violations } = findUnreachableGuardDrift(text, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A10 (known limit): a printf fallback is NOT detected', () => {
const line = ["X=$(gsd_run query phases.list --pick f ", '|', '|', " printf 'd')"].join('');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A11: a fenced block is not an exemption — the same line inside ```bash still flags', () => {
const line = pickEchoLine();
const text = ['```bash', line, '```'].join('\n');
const { violations } = findUnreachableGuardDrift(text, FAKE_FILE);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].line, 2);
});
test('A12: reports each violating line separately, with correct line numbers', () => {
const l1 = pickEchoLine({ pick: 'a' });
const l2 = pickEchoLine({ pick: 'b' });
const text = ['no-op', l1, 'middle', l2].join('\n');
const { violations } = findUnreachableGuardDrift(text, FAKE_FILE);
assert.strictEqual(violations.length, 2);
assert.strictEqual(violations[0].line, 2);
assert.strictEqual(violations[1].line, 4);
});
test('A13: byte-identical duplicates count as 2 occurrences', () => {
const line = pickEchoLine();
const text = [line, line].join('\n');
const { violations } = findUnreachableGuardDrift(text, FAKE_FILE);
assert.strictEqual(violations.length, 2);
assert.strictEqual(violations[0].text, violations[1].text);
});
test('A15: CRLF line endings yield the identical verdict to LF', () => {
const line = pickEchoLine();
const lf = findUnreachableGuardDrift(line, FAKE_FILE);
const crlf = findUnreachableGuardDrift(`${line}\r\n`, FAKE_FILE);
assert.strictEqual(lf.violations.length, 1);
assert.strictEqual(crlf.violations.length, 1);
assert.strictEqual(crlf.violations[0].text, lf.violations[0].text);
assert.deepStrictEqual(findUnreachableGuardDrift(crossLine, FAKE_FILE).violations, []);
});
});
@@ -360,7 +305,7 @@ describe('Detector B — cat <glob> (B-i), ls <glob> || <real fallback> (B-ii),
describe('Escape marker — # gsd-scan-ignore:', () => {
test('M1: a marker naming an issue exempts the line', () => {
const line = `${pickEchoLine()} ${markerComment('#3409')}`;
const line = `${catLine('dir/*.md')} ${markerComment('#3409')}`;
const { violations, malformed } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
assert.deepStrictEqual(malformed, []);
@@ -374,7 +319,7 @@ describe('Escape marker — # gsd-scan-ignore:', () => {
});
test('M3: a free-text reason reports a malformed declaration, not a plain violation', () => {
const line = `${pickEchoLine()} ${markerComment('because I said so')}`;
const line = `${catLine('dir/*.md')} ${markerComment('because I said so')}`;
const { violations, malformed } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
assert.strictEqual(malformed.length, 1);
@@ -382,7 +327,7 @@ describe('Escape marker — # gsd-scan-ignore:', () => {
});
test('M4: an empty reason is not an audit trail — malformed', () => {
const line = `${pickEchoLine()} # gsd-scan-ignore:`;
const line = `${catLine('dir/*.md')} # gsd-scan-ignore:`;
const { violations, malformed } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
assert.strictEqual(malformed.length, 1);
@@ -390,14 +335,14 @@ describe('Escape marker — # gsd-scan-ignore:', () => {
});
test('M5: a whitespace-only reason is rejected — malformed', () => {
const line = `${pickEchoLine()} # gsd-scan-ignore: `;
const line = `${catLine('dir/*.md')} # gsd-scan-ignore: `;
const { violations, malformed } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
assert.strictEqual(malformed.length, 1);
});
test('M6: the marker binds only to its own line — a marker above a violation does not exempt it', () => {
const text = [markerComment('#3409'), pickEchoLine()].join('\n');
const text = [markerComment('#3409'), catLine('dir/*.md')].join('\n');
const { violations, malformed } = findUnreachableGuardDrift(text, FAKE_FILE);
assert.strictEqual(violations.length, 1, 'the violation on line 2 must still fire');
assert.deepStrictEqual(malformed, []);
@@ -437,7 +382,7 @@ describe('Escape marker — # gsd-scan-ignore:', () => {
const isolatedScript = buildIsolatedGuard(root);
const wfDir = path.join(root, 'gsd-core', 'workflows');
fs.mkdirSync(wfDir, { recursive: true });
fs.writeFileSync(path.join(wfDir, 'fake.md'), `${pickEchoLine()} ${markerComment(reason)}\n`);
fs.writeFileSync(path.join(wfDir, 'fake.md'), `${catLine('dir/*.md')} ${markerComment(reason)}\n`);
const baselinePath = path.join(root, BASELINE_REL_PATH);
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
fs.writeFileSync(baselinePath, JSON.stringify({ entries: [] }), 'utf8');
@@ -662,19 +607,14 @@ describe('Cross-platform & encoding', () => {
const posixRel = 'gsd-core/workflows/fake.md';
assert.strictEqual(toPosixRel(winRel), posixRel);
assert.strictEqual(toPosixRel(posixRel), posixRel);
const { violations } = findUnreachableGuardDrift(pickEchoLine(), winRel);
const { violations } = findUnreachableGuardDrift(catLine('dir/*.md'), winRel);
assert.strictEqual(violations[0].file, posixRel);
assert.ok(!violations[0].file.includes('\\'));
});
test('P2: CRLF input yields the same violations as LF (Detector A)', () => {
const line = pickEchoLine();
const lf = findUnreachableGuardDrift(line, FAKE_FILE).violations;
const crlf = findUnreachableGuardDrift(line.replace(/\n/g, '\r\n') + '\r\n', FAKE_FILE).violations;
assert.strictEqual(lf.length, 1);
assert.strictEqual(crlf.length, 1);
assert.strictEqual(lf[0].text, crlf[0].text);
});
// P2 (formerly "CRLF input yields the same violations as LF (Detector A)")
// was retired alongside Detector A itself, #3884 — its claim is now fully
// subsumed by P3 below, the only detector left whose CRLF parity matters.
test('P3: CRLF does not defeat the glob detector (Detector B)', () => {
const line = catLine('dir/*.md');
@@ -686,7 +626,7 @@ describe('Cross-platform & encoding', () => {
});
test('P4: the baseline key is CR-free under CRLF — matches an LF-recorded baseline entry', () => {
const line = pickEchoLine();
const line = catLine('dir/*.md');
const baseline = [{ file: FAKE_FILE, text: line.trim(), count: 1 }];
const crlfViolations = findUnreachableGuardDrift(`${line}\r\n`, FAKE_FILE).violations;
assert.ok(!crlfViolations[0].text.includes('\r'), 'the baseline-key text must carry no trailing \\r');
@@ -716,7 +656,7 @@ describe('Hostile input', () => {
const wfDir = path.join(root, 'gsd-core', 'workflows');
fs.mkdirSync(wfDir, { recursive: true });
const esc = String.fromCharCode(0x1b);
const line = pickEchoLine() + ` # ${esc}[31mred${esc}[0m`;
const line = catLine('dir/*.md') + ` # ${esc}[31mred${esc}[0m`;
fs.writeFileSync(path.join(wfDir, 'fake.md'), `${line}\n`);
writeBaselineFakeEmpty(root);
@@ -816,8 +756,8 @@ describe('Hostile input', () => {
describe('Property tests', () => {
// DOCUMENT-SHAPED, not writer-seeded (CONTRIBUTING.md's Fixture provenance
// #2371): tokens are drawn from a shell-ish alphabet independent of
// PICK_RE/ECHO_FALLBACK_RE's own literals, not generated from the
// detector's regex source.
// CAT_LS_COMMAND_RE's own literals, not generated from the detector's
// regex source.
const wordArb = fc.constantFrom(
'gsd_run', 'query', 'phases.list', 'config-get', 'k', 'v', 'f', '2>/dev/null',
'echo', 'printf', '"0"', '"d"', '$(', ')', 'X=', '&&', ';', 'if', 'then', 'fi',
@@ -825,17 +765,17 @@ describe('Property tests', () => {
);
const lineArb = fc.array(wordArb, { minLength: 1, maxLength: 12 }).map((ws) => ws.join(' '));
test('F1: detector A never fires on a document-shaped line lacking --pick or lacking || echo', () => {
// Retired-detector regression net (#3884): unlike the pre-retirement F1,
// this deliberately does NOT filter out the `--pick` + `|| echo` combined
// shape — it fuzzes lines that DO carry both tokens (via injectPipe) and
// asserts a `kind: 'A'` violation is unconditionally impossible now that
// Detector A no longer exists, closing off the possibility of a partial
// removal (e.g. a stray branch reachable only through some input shape
// this suite's hand-written fixtures do not happen to hit).
test('F1: no document-shaped line — including one deliberately carrying both --pick and || echo — ever produces a kind:\'A\' violation', () => {
fc.assert(
fc.property(lineArb, fc.boolean(), (line, injectPipe) => {
// Build a line that deliberately lacks at least one of the two
// required tokens, without deriving the construction from
// PICK_RE/ECHO_FALLBACK_RE themselves.
const hasPick = line.includes('--pick');
const rawFallback = injectPipe ? `${line} ${'|'}${'|'} echo done` : line;
const hasEcho = /\|\|\s*echo\b/.test(rawFallback);
fc.pre(!(hasPick && hasEcho));
const { violations } = findUnreachableGuardDrift(rawFallback, FAKE_FILE);
const aViolations = violations.filter((v) => v.kind === 'A');
assert.deepStrictEqual(aViolations, []);
@@ -949,13 +889,13 @@ describe('Integration — CLI end-to-end', () => {
assert.deepStrictEqual(malformed, []);
});
test('C2: a fresh violation exits non-zero and the message names the remedy', (t) => {
test('detectorBStillReportsGlobShapes (formerly C2): a reintroduced Detector-B shape still exits non-zero and names the remedy — proves the guard can FAIL, not just pass, after Detector A\'s retirement', (t) => {
const root = createTempDir('gsd-3409-c2-');
t.after(() => cleanup(root));
const isolatedScript = buildIsolatedGuard(root);
const wfDir = path.join(root, 'gsd-core', 'workflows');
fs.mkdirSync(wfDir, { recursive: true });
fs.writeFileSync(path.join(wfDir, 'fake.md'), `${pickEchoLine()}\n`);
fs.writeFileSync(path.join(wfDir, 'fake.md'), `${catLine('dir/*.md')}\n`);
const baselinePath = path.join(root, BASELINE_REL_PATH);
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
fs.writeFileSync(baselinePath, JSON.stringify({ entries: [] }), 'utf8');
@@ -967,7 +907,8 @@ describe('Integration — CLI end-to-end', () => {
assert.strictEqual(report.reason, REASON.FAIL_FRESH_VIOLATION);
assert.strictEqual(report.violations.length, 1);
assert.strictEqual(report.violations[0].file, FAKE_FILE);
assert.strictEqual(report.violations[0].kind, 'A');
assert.strictEqual(report.violations[0].kind, 'B');
assert.strictEqual(report.violations[0].found, 'cat');
});
test('C3: --update regenerates a baseline that then passes', (t) => {
@@ -994,7 +935,7 @@ describe('Integration — CLI end-to-end', () => {
const wfDir = path.join(root, 'gsd-core', 'workflows');
fs.mkdirSync(wfDir, { recursive: true });
fs.writeFileSync(path.join(wfDir, 'a.md'), `${catLine('dir/*.md')}\n`);
fs.writeFileSync(path.join(wfDir, 'b.md'), `${pickEchoLine()}\n`);
fs.writeFileSync(path.join(wfDir, 'b.md'), 'ls -d other/*.md 2>/dev/null || echo "none"\n');
runNode([isolatedScript, '--update'], { timeoutMs: PROBE_TIMEOUT_MS });
const firstBaseline = fs.readFileSync(path.join(root, BASELINE_REL_PATH), 'utf8');
@@ -1036,20 +977,12 @@ describe('dedupeViolationsForBaseline', () => {
});
});
// ─── Regex-level sanity (documents the two regexes' shapes directly) ─────
// ─── Regex-level sanity (documents Detector B's surviving regexes' shapes) ─
//
// PICK_RE / ECHO_FALLBACK_RE were retired with Detector A (#3884) and are no
// longer exported — there is nothing left to assert on directly.
describe('Regex shape sanity', () => {
test('PICK_RE matches only the literal --pick token', () => {
assert.ok(PICK_RE.test('--pick foo'));
assert.ok(!PICK_RE.test('--picky foo'));
});
test('ECHO_FALLBACK_RE matches || echo with optional interior whitespace', () => {
assert.ok(ECHO_FALLBACK_RE.test(['a ', '|', '|', ' echo b'].join('')));
assert.ok(ECHO_FALLBACK_RE.test(['a ', '|', '|', ' echo b'].join('')));
assert.ok(!ECHO_FALLBACK_RE.test(['a ', '|', '|', ' printf b'].join('')));
});
test('CAT_LS_COMMAND_RE requires cat/ls immediately at a command-position anchor', () => {
assert.ok(CAT_LS_COMMAND_RE.test('cat x'));
assert.ok(CAT_LS_COMMAND_RE.test('$(cat x)'));