* chore(#2992): widen the when= grammar and key the section manifest per workflow Epic #1671 Phase 6.1. Two blockers stopped the fragment model reaching any file beyond execute-phase.md: the when= vocabulary was frozen at 4 atoms (3 execute-phase-specific), and the section manifest was single-workflow by construction with 'execute-phase' hardcoded into buildSectionManifestField. - widen WHEN_VOCABULARY 4 -> 14 via a coordinated ADR-1671 amendment; the grammar stays CLOSED (one atom, no operators, negation or nesting) and WHEN_PREDICATES stays a hand-written literal map, never deriving a predicate from its atom string - InvocationFacts gains flags: ReadonlySet<string> plus three computed state booleans; add the missing reverse vocabulary/predicate parity guard - key the manifest artifact per workflow; a stale flat {sections:[...]} artifact now fails shape validation instead of being misattributed - wire the field into six init entry points and parse the flags each needs An atom ships only with both a real consuming section and a fact the init seam actually computes. Six surveyed atoms are withheld because their workflows have no dedicated init entry point; an atom without a computed fact evaluates false forever and silently disables its own section. Fixes a defect found while wiring: parseNamedArgs always materializes a boolean flag key, so folding its false into the absent sentinel is required or every flag reads as present and gating is silently always-on. Also resolves ADR-1671:194 by measurement: --mvp stays unmarkable, because its interleaved sites are always-run flag resolution and a ~340 byte block that already delegates lazily. Refs #2992 * fix(#2992): treat any falsy option value as an absent flag and reject unsafe manifest read paths Findings from two orthogonal reviews (Claude /code-review + an isolated adversarial pass); both independently reproduced the first one. - MAJOR: the flags-builder treated only `undefined` as absent, but parseNamedArgs yields `null` for an absent value-flag and `false` for an absent boolean-flag, so `--granularity` read as present on every plan-phase invocation. Fixed at the root: a flag is present iff its option value is truthy. The six per-handler `|| undefined` folds are now redundant and removed, which also closes the duplicate-translation and missed-onboard-handler findings. - MAJOR: state:needs-codebase-map had zero coverage. Added unit, property and real-CLI integration tests. - MINOR: reject absolute, UNC/drive and `..`-traversing `read` paths in the manifest, degrading the whole load to null like every other shape violation. Verified: `/etc/passwd` previously reached section_manifest.read. - MINOR: corrected a stale "4 to 20" doc comment; the vocabulary is 14. Refs #2992 * test(#2992): update the generator suite for the per-workflow manifest shape The remote matrix went red with 5 unique failures, identical on linux-node22 and linux-node24, all in tests/gen-section-manifest.test.cjs. Re-keying the artifact to {workflows:{...}} left this suite asserting the old flat {sections:[...]} shape; nothing else in the tree still does. - three tests read manifest.sections.length, now undefined; retargeted at workflows.<name> with their original intent preserved (a fenced or loop-host marker still asserts NO section is produced, not merely a changed count) - the stale-manifest test wrote its fixture in the OLD shape, so it tripped shape validation and stopped exercising staleness at all. Its fixture is now valid-but-mismatched so FAIL_STALE is genuinely reached again. - added the coverage that exposed: a pre-6.1 flat artifact must report FAIL_MANIFEST_MALFORMED_SHAPE. That is the real upgrade path for an installed tree and nothing covered it. Refs #2992 * chore(#2992): backfill changeset pr number to 3013 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/merry-bears-purr.md
Normal file
5
.changeset/merry-bears-purr.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3013
|
||||
---
|
||||
**Documented the widened `when=` vocabulary and the per-workflow section manifest.** `docs/reference/workflow-fragments.md` now lists all 14 closed `when=` atoms, the two admission gates a new atom must clear, the manifest artifact's per-workflow `{workflows:{<name>:[...]}}` shape (absent key = degraded, empty array = computed-empty), and that boolean-flag membership in `InvocationFacts.flags` is token-presence, not value-truthiness. Also added the missing `--reset-phase-numbers` flag to `/gsd-new-milestone`'s argument-hint. (#2992)
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: gsd:new-milestone
|
||||
description: Start a new milestone cycle — update PROJECT.md and route to requirements
|
||||
argument-hint: "[milestone name, e.g., 'v1.1 Notifications'] [--ws <name>]"
|
||||
argument-hint: "[milestone name, e.g., 'v1.1 Notifications'] [--ws <name>] [--reset-phase-numbers]"
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
|
||||
@@ -381,23 +381,33 @@ grammar, the frozen `when=` vocabulary, and fail-closed authoring rules, and
|
||||
[ADR-1671](adr/1671-dynamic-context-management-platform.md) (open questions 1 and 2) for why
|
||||
in-file markers were chosen over separate fragment files or a sidecar manifest.
|
||||
|
||||
### Section Manifest (`src/section-manifest.cts`, ADR-1671 Phase 5)
|
||||
### Section Manifest (`src/section-manifest.cts`, ADR-1671 Phases 5 and 6.1)
|
||||
|
||||
Two seams turn a workflow's `gsd:section` markers into per-invocation applicability data.
|
||||
`scripts/gen-section-manifest.cjs --write` (wired into `build` after `build:lib`, and into
|
||||
`lint:generated-sync`) scans `gsd-core/workflows/*.md` and writes the committed
|
||||
`gsd-core/workflows/section-manifest.json`: a `{id, when, read}` triple per marked section,
|
||||
where `read` is the path of the step file the section's body was extracted to. It reuses
|
||||
`gsd-core/workflows/section-manifest.json`, keyed **per workflow** —
|
||||
`{workflows: {"<name>": [{id, when, read}]}}` — where `read` is the path of the step file the
|
||||
section's body was extracted to. A workflow with no marked sections contributes **no key at
|
||||
all**: an absent key means degraded/unknown (the caller reads every section, the safe superset),
|
||||
while a key present with an empty array means "computed, nothing applies". The generator reuses
|
||||
`parseWorkflowSections` unchanged rather than re-implementing marker parsing, and fails closed
|
||||
(`--check`) on a marker naming a step file that does not exist or a step file no marker
|
||||
references.
|
||||
(`--check`) on a marker naming a step file that does not exist, a step file no marker
|
||||
references, or a committed artifact still carrying the pre-6.1 flat `{sections: [...]}` shape.
|
||||
|
||||
A separate pure evaluator, `src/section-manifest.cts` (compiled to
|
||||
`gsd-core/bin/lib/section-manifest.cjs` per ADR-457), maps one invocation's facts —
|
||||
`{waveFlag, phaseNumber, hasPriorPhases}` — to an included/excluded partition of section ids
|
||||
via `selectSections`. Per Greenspun's Tenth Rule, this is a total lookup over the frozen
|
||||
`when=` vocabulary, never a parser: it never tokenizes or interprets `when=` structure, and an
|
||||
unrecognized value fails closed rather than being silently excluded.
|
||||
`{flags, phaseNumber, hasPriorPhases}` plus the optional `needsCodebaseMap`, `phaseMvpMode` and
|
||||
`worktreesEnabled` booleans — to an included/excluded partition of section ids via
|
||||
`selectSections`. `flags` is a `ReadonlySet<string>` of flag tokens; because `parseNamedArgs`
|
||||
always materializes a boolean flag key (`false` when the token was absent, never `undefined`),
|
||||
presence is **truthiness**, and the init router folds a boolean flag's own `false` into the
|
||||
absent sentinel before the facts are built. Per Greenspun's Tenth Rule, the evaluator is a total
|
||||
lookup over the frozen 14-atom `when=` vocabulary, never a parser: `WHEN_PREDICATES` is a
|
||||
hand-written literal map that never derives a predicate from its atom string, and an
|
||||
unrecognized value fails closed rather than being silently excluded. An atom is admitted only
|
||||
when it has both a real consuming section and a fact the init seam actually computes — an atom
|
||||
without the latter would evaluate `false` forever and silently disable its own section.
|
||||
|
||||
`execute-phase.md`'s `partial-wave` and `gap-closure-artifacts` sections — previously inlined
|
||||
directly per #2930's pilot — now delegate to dedicated step files under
|
||||
|
||||
@@ -67,6 +67,54 @@ Pure Agent Skills (A alone) and pure MCP (D alone) were rejected as the foundati
|
||||
|
||||
**Amended by #2929 (Phase 2).** This ADR originally specified the contract as "priority + binary-search cutoff to a per-runtime budget". Implementing Phase 2 established that a cutoff alone **cannot express the function this platform generalizes**: `prompt-budget.applyBudget` is not a cutoff but a fixed five-step ladder in which each section carries its own shrink strategy, and only three of its eight sections are ever droppable — `PROJECT.md` is head-shrunk to N lines and plans are proportionally tail-truncated with a per-plan floor, while instructions and roadmap are never trimmed at all. A cutoff composer sorts by priority and discards the tail; it has no way to say "shrink this one", "truncate that one but never below its floor", or "these three are the only droppables, in this order". Building to the literal wording and routing `prompt-budget` through it would have silently changed review-prompt output. Shrink strategies are therefore the core abstraction, and **binary-search cutoff becomes one strategy among them** — the right one for per-runtime emission in Phases 3-4, not for this ladder. Ordering is declaration order rather than a numeric priority field. This is an elaboration of the decision's intent, not a reversal of it.
|
||||
- **Applicability grammar (added by #2930, Phase 3).** The fragment unit's `when=` attribute is deliberately a CLOSED grammar: exactly one atom from a frozen vocabulary — `always`, `flag:--wave`, `state:gap-closure-phase`, `state:has-prior-phases` — with no boolean operators, negation, or nesting, and an unknown `when=` value throws rather than being ignored. This is a Greenspun's-Tenth-Rule guard: left open-ended, `when=` acquires `&&`/`!`/precedence/runtime-capability predicates and becomes an ad-hoc, informally-specified predicate language grown one condition at a time. Widening the vocabulary requires a coordinated ADR amendment, not an organic edit. `when=` is parsed and validated in Phase 3 but not yet acted on; applicability selection is Phase 5.
|
||||
|
||||
**Amended by #2992 (Phase 6.1) — the vocabulary widens 4 → 14, and the guard is restated.**
|
||||
This is the coordinated amendment this bullet requires; it is not an organic edit. Rolling the
|
||||
fragment model past `execute-phase.md` was impossible without it: three of the original four
|
||||
atoms are execute-phase-specific, so every other LARGE/XL workflow branches on conditions the
|
||||
vocabulary could not express.
|
||||
|
||||
*The guard is composition, not cardinality.* This bullet's own rationale names the hazard
|
||||
precisely — `when=` acquiring `&&`/`!`/precedence and becoming an ad-hoc predicate language. A
|
||||
14-entry list with no operators is not a language; a 4-entry list **with** `&&` would be. Adding
|
||||
atoms therefore does not weaken the guard, and the following invariants are unchanged and
|
||||
binding: exactly one atom per marker, no boolean operators, no negation, no nesting, and an
|
||||
unrecognized `when=` still throws rather than being silently excluded. `WHEN_PREDICATES` remains
|
||||
a **hand-written literal map** — deriving a predicate from its atom string (`atom.slice(5)`) is
|
||||
tokenization, and a parser relocated into a build loop is still a parser. The redundancy between
|
||||
an atom's name and its literal token is deliberate; a behavioral test derived from the vocabulary
|
||||
catches a desync, because a desync silently excludes a section rather than failing loudly.
|
||||
|
||||
*Two independent gates govern admission.* An atom ships only when it has **both** (1) a named
|
||||
consuming section of at least 400 bytes, established by survey, and (2) a fact the init seam
|
||||
demonstrably computes at a real entry point. Gate (2) was learned during implementation and is
|
||||
the more important of the two: an atom whose fact is never computed evaluates `false` forever, so
|
||||
a section marked with it is silently never included — strictly worse than not shipping the atom,
|
||||
because the marker looks like working gating. The same failure mode appeared twice more during
|
||||
this phase and is recorded so it is not rediscovered: `parseNamedArgs` always materializes a
|
||||
boolean flag key (`false` when absent, never `undefined`), so "present in the options record" is
|
||||
**not** token presence; and four workflow handlers passed no options at all. Both were fixed by
|
||||
wiring, not by relaxing the gate.
|
||||
|
||||
**Shipped (14).** `always`, `flag:--wave`, `state:gap-closure-phase`, `state:has-prior-phases`
|
||||
(pre-existing), plus `flag:--auto`, `flag:--discuss`, `flag:--forensic`, `flag:--full`,
|
||||
`flag:--research`, `flag:--reset-phase-numbers`, `flag:--validate`, `state:needs-codebase-map`,
|
||||
`state:phase-mvp-mode`, `state:worktrees-enabled`.
|
||||
|
||||
**Withheld (6), surveyed and justified but not yet computable.** `flag:--verify-only` and
|
||||
`state:is-monorepo` (docs-update), `flag:--converge` (autonomous), `flag:--fix` and
|
||||
`state:fallow-enabled` (code-review), `state:git-create-tag` (complete-milestone). Each fails
|
||||
gate (2): `docs-update` initializes through `cmdDocsInit` in `docs.cts`, and the other three run
|
||||
through the shared generic `init.phase-op` / `init.milestone-op` / `init.manager` entry points,
|
||||
each invoked by 20+ workflows — binding a workflow name into those would misattribute one
|
||||
workflow's sections to every other caller. They land with the entry-point work in the LARGE/XL
|
||||
rollout phase. The survey is recorded so it is not repeated.
|
||||
|
||||
**Permanently ineligible condition classes** (found by survey, not admissible as atoms at any
|
||||
future point without a different mechanism): runtime tool/capability availability (Task tool,
|
||||
Playwright-MCP session), live git repository state, Capability-Registry/hook-resolved conditions,
|
||||
interactive answers given mid-run, and UAT/verification runtime results. None is knowable from
|
||||
parsed CLI arguments or `.planning/` state at init time.
|
||||
- **Budget unit:** bytes for emission caps (matches `lfByteCount`, deterministic, offline-safe); a token estimate for run-time selection.
|
||||
|
||||
**Corrected by #2931 (Phase 4) — the Windsurf cap was never load-bearing.** The Context
|
||||
@@ -193,6 +241,8 @@ Prototype scope notes: the parser is intentionally self-contained for the exampl
|
||||
|
||||
**Resolved by #2930 (Phase 3) — fragment unit: in-file `<!-- gsd:section id= when= -->` markers.** Question 1 asked separate files vs in-file section markers. Confirmed with the maintainer: separate files are eliminated by this phase's own acceptance criterion — "emitted output byte-identical-or-smaller" — because splitting a workflow into files changes the emitted tree's *shape*, which is neither identical nor smaller, it is different; it also multiplies INVENTORY rows and `@`-ref contract surface for no Phase-3 benefit. A sidecar fragment manifest keyed on heading anchors was also rejected: zero source growth, but it creates a second surface that drifts from the workflow — the exact multi-surface edit pain the epic exists to remove (`DEFECT.GENERATIVE-FIX`), and directly against the epic's "one fragment, not 4 surfaces" thesis. The shipped answer is in-file markers, stripped at emit so the installed artifact carries no build metadata and shrinks; markers are self-anchoring (no line-number keying — Open question 4 already rejected that for the predicate index, and the same reasoning applies here), and the existing `<!-- gsd:loop-host … -->` block at `plan-phase.md:1` is in-repo precedent for the form. Production landed under `src/workflow-fragments.cts` → `gsd-core/bin/lib/workflow-fragments.cjs` (ADR-457 build-at-publish), piloted on `execute-phase.md`. **The pilot was retargeted from `plan-phase.md` mid-phase, and the reason is itself the most important finding here.** The branches the epic names as motivating (`--prd`, `--ingest`, `--mvp`, `--reviews`) all live in `plan-phase.md` — but `plan-phase.md` sits only 36 B under an independent, pre-existing size gate (`tests/phase6-capstone-conformance.test.cjs`'s `PRE_PHASE6`, an ADR-857 Phase-6 completion property that this ADR's own Blast-radius analysis did not enumerate against, catching only the XL cap). It cannot absorb even the smallest marker overhead, so **it could not be fragmentized at all under this phase's grammar**, independent of any shape limitation. The pilot instead proves the mechanism on state- and flag-gated `<step>` blocks in `execute-phase.md` (`partial-wave`/`flag:--wave`, `gap-closure-artifacts`/`state:gap-closure-phase`, `regression-gate`/`state:has-prior-phases`), which has 728 B of real headroom under its own `PRE_PHASE6` gate. This is direct evidence for the epic's premise that fragmentization pays off, but it also means **Phase 4 (moving size caps from source bytes to emitted bytes) may need to land before `plan-phase.md` itself can be fragmentized.** Separately, and independent of the size-gate finding: the marker grammar addresses SECTION-shaped branches only — a whole-line, non-nesting comment pair around a contiguous block — and `--mvp`'s content in `plan-phase.md` is INTERLEAVED rather than sectioned (`MVP_MODE` resolution shares a bash block with `--tdd`/`--no-tracer`/`--no-reversibility-gates` at `plan-phase.md:125-158`, and is inline `${MVP_MODE === 'true' ? ... }` template interpolation at `:794-803`), so `--mvp` would remain unmarkable by this grammar even if the size gate allowed it. Phase 6 must either accept that gap or introduce a finer-grained (sub-line) mechanism for interleaved branches.
|
||||
|
||||
**Resolved by #2992 (Phase 6.1) — the gap is ACCEPTED, and it is closed by measurement rather than by mechanism.** Phase 6 initially chose to build the sub-line mechanism. Measuring the two sites first falsified the premise that choice rested on. `plan-phase.md:125-158` is not optional content at all: it is `MVP_MODE` **resolution** (alongside `--tdd` / `--no-tracer` / `--no-reversibility-gates`), which must execute on every invocation in order to resolve the flags — gating it would break the workflow rather than trim it. `plan-phase.md:794-803` is genuinely conditional, but it is roughly **340 bytes** and is *already* a lazy pointer: its body instructs the planner to read `references/planner-mvp-mode.md`, so the heavy content is deferred by the existing `@`-reference model, not carried inline. A sub-line grammar would therefore buy about 340 bytes at one site while the other site must never be gated at all — and it would reintroduce exactly the Greenspun's-Tenth-Rule hazard the applicability-grammar bullet above exists to prevent, in exchange for that. The gap this ADR identified is real as a *shape* observation and inconsequential as a *value* one. `--mvp` remains unmarkable by the section grammar, deliberately and permanently; the section-shaped branches of `plan-phase.md` are still fragmentized normally. Should an interleaved branch later carry genuinely large, genuinely skippable content, that measurement — not this precedent — is what should reopen the question.
|
||||
|
||||
**Resolved by #2930 (Phase 3) — build-time emission is the primary surface; per-workflow cutover, no double-write.** Question 2 asked build-time emission vs run-time assembly as the primary surface during migration, and whether that requires a double-write period. Because markers are stripped at emit, an unmarked workflow parses to exactly one implicit fragment and composes back byte-identical by construction — that structural guarantee is what makes a per-workflow cutover safe file-by-file, with no double-write period and no flag day: a workflow can gain markers on its own schedule without touching any other workflow's emission path. Phase 5's run-time selection is planned to consume a build-derived manifest, not markers read at run time, keeping the run-time surface decoupled from the authoring surface.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
> questions 1 and 2).
|
||||
|
||||
Workflow authors can mark one or more sections of a `gsd-core/workflows/*.md` file
|
||||
so that `bin/install.js`'s emission path can compose them per runtime. Today this
|
||||
is an authoring model with no run-time effect yet — see
|
||||
[Not acted on yet](#not-acted-on-yet) below.
|
||||
so that `bin/install.js`'s emission path can compose them per runtime, and so that
|
||||
a separate init-time seam can select which sections apply to one concrete
|
||||
invocation — see [The manifest artifact and per-workflow
|
||||
keying](#the-manifest-artifact-and-per-workflow-keying) below.
|
||||
|
||||
## Marker syntax
|
||||
|
||||
@@ -45,7 +46,8 @@ gap fragment and composes back byte-identical to its source.
|
||||
|
||||
## The frozen `when=` vocabulary
|
||||
|
||||
`when=` takes exactly one of:
|
||||
`when=` takes exactly one of 14 atoms (widened from 4 via the ADR-1671
|
||||
amendment for #2992, epic #1671 Phase 6.1):
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
@@ -53,12 +55,49 @@ gap fragment and composes back byte-identical to its source.
|
||||
| `flag:--wave` | Applicable when the workflow runs with `--wave`. |
|
||||
| `state:gap-closure-phase` | Applicable when the phase number is a gap-closure phase (has a decimal, e.g. `4.1`). |
|
||||
| `state:has-prior-phases` | Applicable when prior phases (and their `VERIFICATION.md` files) exist. |
|
||||
| `flag:--auto` | Applicable when the workflow runs with `--auto`. |
|
||||
| `flag:--discuss` | Applicable when the workflow runs with `--discuss`. |
|
||||
| `flag:--forensic` | Applicable when the workflow runs with `--forensic`. |
|
||||
| `flag:--full` | Applicable when the workflow runs with `--full`. |
|
||||
| `flag:--research` | Applicable when the workflow runs with `--research`. |
|
||||
| `flag:--reset-phase-numbers` | Applicable when the workflow runs with `--reset-phase-numbers`. |
|
||||
| `flag:--validate` | Applicable when the workflow runs with `--validate`. |
|
||||
| `state:needs-codebase-map` | Applicable when a codebase map is needed (init-computed). |
|
||||
| `state:phase-mvp-mode` | Applicable when the current phase's `ROADMAP.md` entry declares `**Mode:** mvp`. |
|
||||
| `state:worktrees-enabled` | Applicable when `.planning/config.json`'s `workflow.use_worktrees` is enabled. |
|
||||
|
||||
This list is **closed by design** (Greenspun's Tenth Rule): left open-ended,
|
||||
`when=` would acquire boolean operators, negation, precedence, and
|
||||
runtime/capability predicates one edit at a time, becoming an ad-hoc,
|
||||
informally-specified applicability language. Widening the vocabulary is a
|
||||
coordinated ADR amendment to ADR-1671, never an organic edit to the parser.
|
||||
coordinated ADR amendment to ADR-1671, never an organic edit to the parser —
|
||||
`when=` remains exactly one atom per marker: no operators, no negation, no
|
||||
nesting, regardless of how many atoms the frozen list holds. An unknown value
|
||||
still throws (see [Fails closed](#fails-closed)).
|
||||
|
||||
An atom only ships once it clears **two independent admission gates**, both
|
||||
required:
|
||||
|
||||
1. **A named consuming section.** Some workflow's marked section actually
|
||||
needs the condition — an atom with no section that uses it is dead
|
||||
vocabulary, and dead vocabulary is how a closed list rots into an open
|
||||
one.
|
||||
2. **A fact the init seam can actually compute.** Only a workflow with a
|
||||
dedicated `cmdInit*` entry point (see [The manifest
|
||||
artifact](#the-manifest-artifact-and-per-workflow-keying) below) can carry
|
||||
a manifest, and only a condition that entry point can resolve at init time
|
||||
— from parsed CLI options or from `.planning/` state — may become an atom.
|
||||
An atom without a computable fact would always evaluate `false`, so a
|
||||
section marked with it would silently never include: the exact
|
||||
silent-wrong-answer class this gate exists to prevent.
|
||||
|
||||
Six further atoms (`flag:--converge`, `flag:--fix`, `flag:--verify-only`,
|
||||
`state:fallow-enabled`, `state:git-create-tag`, `state:is-monorepo`) satisfy
|
||||
gate 1 but not yet gate 2 — their workflows (`autonomous`, `code-review`,
|
||||
`complete-milestone`, `docs-update`) route through shared generic init entry
|
||||
points invoked by 20+ other workflows, so a dedicated `cmdInit*` seam does
|
||||
not yet exist to compute their facts. They are withheld pending that seam,
|
||||
not rejected.
|
||||
|
||||
## Fails closed
|
||||
|
||||
@@ -119,13 +158,61 @@ The pre-existing `<!-- gsd:loop-host ... -->` marker family (consumed by
|
||||
`scripts/gen-loop-host-contract.cjs`) is a different, already-established
|
||||
marker and is never treated as a `gsd:section` marker.
|
||||
|
||||
## Not acted on yet
|
||||
## The manifest artifact and per-workflow keying
|
||||
|
||||
`when=` is parsed and validated today, but applicability selection — actually
|
||||
choosing which sections apply to a given invocation — is not implemented in
|
||||
this phase. Every fragment composes into the output regardless of its `when=`
|
||||
value; only the marker lines are stripped. Run-time selection is planned for
|
||||
a later phase of ADR-1671's epic.
|
||||
`bin/install.js`'s emission path always composes every fragment into the
|
||||
output regardless of its `when=` value — marker lines are stripped, nothing
|
||||
else changes there. Applicability selection is a separate, later seam:
|
||||
`scripts/gen-section-manifest.cjs --write` scans `gsd-core/workflows/*.md` for
|
||||
`gsd:section` markers and generates a committed artifact,
|
||||
`gsd-core/workflows/section-manifest.json`, shaped as
|
||||
`{"workflows": {"<workflow-name>": [{"id", "when", "read"}, ...], ...}}`,
|
||||
where `<workflow-name>` is a source `.md` file's basename without extension
|
||||
and `read` is the POSIX-normalized, repo-root-relative path of the step file
|
||||
the section body was extracted to. This is a per-workflow superset of the
|
||||
pre-#2992 shape, which was a single flat `{"sections": [...]}` array with no
|
||||
workflow key — that shape is now rejected outright rather than mis-parsed, so
|
||||
a stale committed artifact can never be silently attributed to whichever
|
||||
workflow asks first.
|
||||
|
||||
A workflow key's **presence vs. absence is meaningful, not cosmetic**:
|
||||
|
||||
- The key is **absent** when the workflow has zero marked sections. A caller
|
||||
for that workflow must treat this as degraded/unknown (`null`) — safe
|
||||
superset, read everything.
|
||||
- The key is **present with an empty array** when the workflow's sections
|
||||
were evaluated and none applied to this invocation — genuinely nothing to
|
||||
read, not "unknown."
|
||||
|
||||
Collapsing these two states inverts behavior on the degraded path: `null`
|
||||
means "I don't know, so include everything"; `[]` means "I computed this,
|
||||
and the answer is nothing."
|
||||
|
||||
At init time, a separate pure evaluator, `src/section-manifest.cts`
|
||||
(`selectSections`), partitions a workflow's manifest sections into
|
||||
`included`/`excluded` id lists against one invocation's
|
||||
`InvocationFacts` — `{flags, phaseNumber, hasPriorPhases, needsCodebaseMap?,
|
||||
phaseMvpMode?, worktreesEnabled?}`. Only a workflow with a **dedicated
|
||||
`cmdInit*` entry point** in `src/init.cts` can have this evaluation run for
|
||||
it, because only that entry point can assemble `InvocationFacts` from its own
|
||||
parsed CLI options and `.planning/` state reads — this is admission gate 2
|
||||
from [The frozen `when=` vocabulary](#the-frozen-when-vocabulary) above,
|
||||
applied per-workflow rather than per-atom. Six entry points are wired today:
|
||||
`execute-phase`, `plan-phase`, `new-project`, `new-milestone`, `quick`, and
|
||||
`progress`.
|
||||
|
||||
`InvocationFacts.flags` is a `ReadonlySet<string>` of the literal `--<name>`
|
||||
tokens seen on the invocation, and **membership is token-presence, not
|
||||
value-truthiness**. This matters because `parseNamedArgs`'s `booleanFlags`
|
||||
always materializes the key in its result object — `true` when the token was
|
||||
seen, `false` otherwise, never `undefined`. A caller that passed a
|
||||
boolean-flag's own `false` straight through as an "option value" would add it
|
||||
to `flags` anyway (any non-`undefined` value counts as present for a
|
||||
*value* flag), making that `flag:` atom permanently true regardless of the
|
||||
actual command line — the fix is that every boolean-flag call site folds its
|
||||
own `false` into `undefined` (`namedArgs['wave'] || undefined`) before
|
||||
handing options to the facts builder, so `flags` only ever contains tokens
|
||||
that were actually seen.
|
||||
|
||||
## Piloted on one workflow so far
|
||||
|
||||
@@ -173,3 +260,9 @@ for how both limits get addressed.
|
||||
- `src/workflow-fragments.cts` — the compiled parser/composer source.
|
||||
- `src/context-composer.cts` — the shared budget-composition seam consumed by
|
||||
`composeWorkflow`.
|
||||
- `src/section-manifest.cts` — the pure `when=` evaluator (`selectSections`,
|
||||
`InvocationFacts`) consumed by the init seam.
|
||||
- `scripts/gen-section-manifest.cjs` — generates the committed
|
||||
`gsd-core/workflows/section-manifest.json` artifact from markers.
|
||||
- `src/init.cts` — `buildSectionManifestField` and the six wired
|
||||
`cmdInit*` entry points.
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"sections": [
|
||||
{
|
||||
"id": "partial-wave",
|
||||
"when": "flag:--wave",
|
||||
"read": "gsd-core/workflows/execute-phase/steps/partial-wave.md"
|
||||
},
|
||||
{
|
||||
"id": "gap-closure-artifacts",
|
||||
"when": "state:gap-closure-phase",
|
||||
"read": "gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md"
|
||||
},
|
||||
{
|
||||
"id": "regression-gate",
|
||||
"when": "state:has-prior-phases",
|
||||
"read": "gsd-core/workflows/execute-phase/steps/regression-gate.md"
|
||||
}
|
||||
]
|
||||
"workflows": {
|
||||
"execute-phase": [
|
||||
{
|
||||
"id": "partial-wave",
|
||||
"when": "flag:--wave",
|
||||
"read": "gsd-core/workflows/execute-phase/steps/partial-wave.md"
|
||||
},
|
||||
{
|
||||
"id": "gap-closure-artifacts",
|
||||
"when": "state:gap-closure-phase",
|
||||
"read": "gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md"
|
||||
},
|
||||
{
|
||||
"id": "regression-gate",
|
||||
"when": "state:has-prior-phases",
|
||||
"read": "gsd-core/workflows/execute-phase/steps/regression-gate.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
/**
|
||||
* gen-section-manifest.cjs — generates gsd-core/workflows/section-manifest.json
|
||||
* from the `<!-- gsd:section -->` markers in gsd-core/workflows/*.md (ADR-1671
|
||||
* epic #1671, Phase 5 / issue #2932, `.gsd/phase/chore-2932-init-section-manifest/40-design.md`).
|
||||
* epic #1671; Phase 5 / issue #2932 introduced the artifact,
|
||||
* `.gsd/phase/chore-2932-init-section-manifest/40-design.md`; Phase 6.1 /
|
||||
* issue #2992 generalized it from single-workflow to PER-WORKFLOW,
|
||||
* `.gsd/phase/chore-2992-widen-when-vocabulary/40-design.md`).
|
||||
*
|
||||
* Reuses `parseWorkflowSections` from the compiled `workflow-fragments.cjs`
|
||||
* (src/workflow-fragments.cts, Phase 3 / #2930) UNCHANGED — this module never
|
||||
@@ -14,17 +17,29 @@
|
||||
* The committed artifact is placed INSIDE the `gsd-core/` tree (not `docs/`,
|
||||
* unlike `docs/CONTEXT-INDEX.json`/`docs/INVENTORY-MANIFEST.json`) because it
|
||||
* must SHIP: `bin/install.js`'s `copyWithPathReplacement` only copies
|
||||
* `gsd-core/`, and Phase 5's run-time selection (a later commit) reads this
|
||||
* artifact from the INSTALLED tree, not the dev repo. `copyWithPathReplacement`
|
||||
* `gsd-core/`, and the init CLI's run-time selection reads this artifact
|
||||
* from the INSTALLED tree, not the dev repo. `copyWithPathReplacement`
|
||||
* only runs `composeWorkflow`/converters on `*.md` — a `.json` leaf falls
|
||||
* through to a plain `fs.copyFileSync`, so the artifact ships byte-identical.
|
||||
*
|
||||
* Per design's "Rejected" list, the manifest carries NEITHER section content
|
||||
* (would duplicate every section's bytes, fighting Phase 4's emitted-byte
|
||||
* caps) NOR line numbers (re-drifts on any line shift, and per-runtime
|
||||
* converters rewrite text so ranges would differ per runtime). It carries
|
||||
* only `{id, when, read}` triples — `read` is a POSIX-normalized path,
|
||||
* relative to the repo root, of the step file the section body was moved to.
|
||||
* Shape (#2992 Phase 6.1): `{ "workflows": { "<workflow-name>": [{id, when,
|
||||
* read}, ...], ... } }`, where `<workflow-name>` is a source `.md` file's
|
||||
* basename without extension. A workflow with zero explicit sections
|
||||
* contributes NO key at all (absence, not `[]` — an init caller for that
|
||||
* workflow must degrade to `null`, never be attributed some OTHER workflow's
|
||||
* sections). Keys are serialized in sorted (filename) order; each workflow's
|
||||
* own sections stay in document order. The pre-6.1 shape was a single flat
|
||||
* `{ sections: [...] }` array with no workflow key at all — `isValidManifestShape`
|
||||
* REJECTS that shape outright so a stale committed artifact can never be
|
||||
* mis-attributed to whichever workflow asks first (design row C4).
|
||||
*
|
||||
* Per design's "Rejected" list, a workflow's section list carries NEITHER
|
||||
* section content (would duplicate every section's bytes, fighting Phase 4's
|
||||
* emitted-byte caps) NOR line numbers (re-drifts on any line shift, and
|
||||
* per-runtime converters rewrite text so ranges would differ per runtime).
|
||||
* It carries only `{id, when, read}` triples — `read` is a POSIX-normalized
|
||||
* path, relative to the repo root, of the step file the section body was
|
||||
* moved to.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/gen-section-manifest.cjs # print to stdout
|
||||
@@ -40,7 +55,7 @@
|
||||
* Only `.md` files directly inside `--workflows-dir` are scanned (not files
|
||||
* already inside a `<workflow>/steps/` subdirectory — those are MOVED-TO
|
||||
* output, never source-with-markers). A workflow with zero explicit sections
|
||||
* (88 of 89 today) contributes nothing to the manifest and is never
|
||||
* (most workflows today) contributes no key to `workflows` and is never
|
||||
* orphan-checked — orphan-checking is scoped only to a workflow's OWN
|
||||
* `steps/` directory, and only for workflows that declare at least one
|
||||
* `gsd:section` marker.
|
||||
@@ -225,14 +240,18 @@ class ManifestBuildError extends ExitError {
|
||||
|
||||
/**
|
||||
* Scan `workflowsDir` for `.md` files carrying `gsd:section` markers and
|
||||
* build the live (freshly-derived) manifest: `{ sections: [{id, when, read}] }`,
|
||||
* in document order (files sorted by filename, sections in each file's own
|
||||
* document order). Throws `ManifestBuildError` on any fail-closed condition
|
||||
* (missing step file, orphan step file, unparseable source).
|
||||
* build the live (freshly-derived) manifest: `{ workflows: { <name>:
|
||||
* [{id, when, read}], ... } }` (#2992 Phase 6.1). Workflow keys are
|
||||
* serialized in sorted (filename) order; a workflow's own sections stay in
|
||||
* document order. A workflow with zero explicit sections contributes NO key
|
||||
* at all — absence, not `[]` (design row C4/C11: absence must degrade to
|
||||
* `null` at the init seam, never be confused with "computed, no sections").
|
||||
* Throws `ManifestBuildError` on any fail-closed condition (missing step
|
||||
* file, orphan step file, unparseable source).
|
||||
*
|
||||
* @param {string} workflowsDir - defaults to the real repo-root gsd-core/workflows/
|
||||
* @param {string} repoRoot - root `read` paths are computed relative to
|
||||
* @returns {{ sections: Array<{id: string, when: string, read: string}> }}
|
||||
* @returns {{ workflows: Record<string, Array<{id: string, when: string, read: string}>> }}
|
||||
*/
|
||||
function buildFreshManifest(workflowsDir = WORKFLOWS_DIR, repoRoot = ROOT) {
|
||||
const { parseWorkflowSections } = loadWorkflowFragmentsLib();
|
||||
@@ -243,7 +262,7 @@ function buildFreshManifest(workflowsDir = WORKFLOWS_DIR, repoRoot = ROOT) {
|
||||
.map((d) => d.name)
|
||||
.sort();
|
||||
|
||||
const sections = [];
|
||||
const workflows = {};
|
||||
|
||||
for (const fileName of workflowFiles) {
|
||||
const filePath = path.join(workflowsDir, fileName);
|
||||
@@ -267,6 +286,7 @@ function buildFreshManifest(workflowsDir = WORKFLOWS_DIR, repoRoot = ROOT) {
|
||||
|
||||
const workflowName = fileName.replace(/\.md$/, '');
|
||||
const stepsDir = path.join(workflowsDir, workflowName, 'steps');
|
||||
const sections = [];
|
||||
|
||||
for (const section of explicitSections) {
|
||||
const stepFileAbs = path.join(stepsDir, `${section.id}.md`);
|
||||
@@ -294,15 +314,17 @@ function buildFreshManifest(workflowsDir = WORKFLOWS_DIR, repoRoot = ROOT) {
|
||||
`${relOrphanPath} is not referenced by any gsd:section marker or reachable "steps/" reference in ${relSourcePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
workflows[workflowName] = sections;
|
||||
}
|
||||
|
||||
return { sections };
|
||||
return { workflows };
|
||||
}
|
||||
|
||||
// ─── Serialization ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {{ sections: Array<{id: string, when: string, read: string}> }} manifest
|
||||
* @param {{ workflows: Record<string, Array<{id: string, when: string, read: string}>> }} manifest
|
||||
* @returns {string}
|
||||
*/
|
||||
function serializeManifest(manifest) {
|
||||
@@ -310,18 +332,41 @@ function serializeManifest(manifest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `parsed` has the expected committed-manifest shape: a plain
|
||||
* object (not an array, not null) carrying a `sections` array of
|
||||
* `{id, when, read}` string triples. Rejects `0`, `"s"`, `[]`, `null`, `true`.
|
||||
* Workflow-count / section-count pair for a manifest's `workflows` map, used
|
||||
* by the `--write` stdout summary and the `--check` OK message.
|
||||
*
|
||||
* @param {{ workflows: Record<string, Array<{id: string, when: string, read: string}>> }} manifest
|
||||
* @returns {{ workflowCount: number, sectionCount: number }}
|
||||
*/
|
||||
function countManifest(manifest) {
|
||||
const workflowNames = Object.keys(manifest.workflows);
|
||||
const sectionCount = workflowNames.reduce((sum, name) => sum + manifest.workflows[name].length, 0);
|
||||
return { workflowCount: workflowNames.length, sectionCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `parsed` has the expected committed-manifest shape (#2992 Phase
|
||||
* 6.1): a plain object (not an array, not null) carrying a `workflows` plain
|
||||
* object (not an array, not null) whose every own value is an array of
|
||||
* `{id, when, read}` string triples. Rejects `0`, `"s"`, `[]`, `null`, `true`
|
||||
* — AND rejects the pre-6.1 flat `{sections:[...]}` shape (no `workflows`
|
||||
* key ⇒ `parsed.workflows` is `undefined`, which fails the object check),
|
||||
* so a stale committed artifact can never be silently mis-attributed to
|
||||
* whichever workflow asks first (design row C4).
|
||||
*
|
||||
* @param {unknown} parsed
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidManifestShape(parsed) {
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
||||
if (!Array.isArray(parsed.sections)) return false;
|
||||
return parsed.sections.every(
|
||||
(s) => s !== null && typeof s === 'object' && typeof s.id === 'string' && typeof s.when === 'string' && typeof s.read === 'string',
|
||||
const workflows = parsed.workflows;
|
||||
if (workflows === null || typeof workflows !== 'object' || Array.isArray(workflows)) return false;
|
||||
return Object.values(workflows).every(
|
||||
(sections) =>
|
||||
Array.isArray(sections) &&
|
||||
sections.every(
|
||||
(s) => s !== null && typeof s === 'object' && typeof s.id === 'string' && typeof s.when === 'string' && typeof s.read === 'string',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -425,7 +470,7 @@ function checkReport(workflowsDir = WORKFLOWS_DIR, manifestPath = MANIFEST_PATH,
|
||||
ok: false,
|
||||
reason: REASON.FAIL_MANIFEST_MALFORMED_SHAPE,
|
||||
subject: manifestPath,
|
||||
message: `${manifestPath} is valid JSON but does not have the expected {sections:[{id,when,read}]} shape.\n` +
|
||||
message: `${manifestPath} is valid JSON but does not have the expected {workflows:{<name>:[{id,when,read}]}} shape.\n` +
|
||||
'Run:\n node scripts/gen-section-manifest.cjs --write\n',
|
||||
};
|
||||
}
|
||||
@@ -439,11 +484,12 @@ function checkReport(workflowsDir = WORKFLOWS_DIR, manifestPath = MANIFEST_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
const { workflowCount, sectionCount } = countManifest(live);
|
||||
return {
|
||||
ok: true,
|
||||
reason: REASON.OK_UP_TO_DATE,
|
||||
subject: null,
|
||||
message: `${manifestPath} is up to date (${live.sections.length} section${live.sections.length === 1 ? '' : 's'}).\n`,
|
||||
message: `${manifestPath} is up to date (${workflowCount} workflow${workflowCount === 1 ? '' : 's'}, ${sectionCount} section${sectionCount === 1 ? '' : 's'}).\n`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -556,7 +602,8 @@ function main() {
|
||||
: { ok: true, reason: REASON.OK_UP_TO_DATE, subject: null },
|
||||
) + '\n');
|
||||
} else if (!writeErr) {
|
||||
process.stdout.write(`Wrote ${opts.manifestPath}\n ${manifest.sections.length} section${manifest.sections.length === 1 ? '' : 's'}\n`);
|
||||
const { workflowCount, sectionCount } = countManifest(manifest);
|
||||
process.stdout.write(`Wrote ${opts.manifestPath}\n ${workflowCount} workflow${workflowCount === 1 ? '' : 's'}, ${sectionCount} section${sectionCount === 1 ? '' : 's'}\n`);
|
||||
}
|
||||
|
||||
if (writeErr) {
|
||||
@@ -571,6 +618,7 @@ module.exports = {
|
||||
findOrphanStepFiles,
|
||||
buildFreshManifest,
|
||||
serializeManifest,
|
||||
countManifest,
|
||||
isValidManifestShape,
|
||||
writeManifestAtomically,
|
||||
checkReport,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: gsd-new-milestone
|
||||
description: "Start a new milestone cycle — update PROJECT.md and route to requirements"
|
||||
argument-hint: "[milestone name, e.g., 'v1.1 Notifications'] [--ws <name>]"
|
||||
argument-hint: "[milestone name, e.g., 'v1.1 Notifications'] [--ws <name>] [--reset-phase-numbers]"
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
|
||||
@@ -24,12 +24,12 @@ import { parseNamedArgs } from './command-arg-projection.cjs';
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InitModule {
|
||||
cmdInitExecutePhase(cwd: string, phase: string | undefined, raw: boolean, opts: Record<string, string | boolean | null>): void;
|
||||
cmdInitPlanPhase(cwd: string, phase: string | undefined, raw: boolean, opts: Record<string, string | boolean | null>): void;
|
||||
cmdInitNewProject(cwd: string, raw: boolean): void;
|
||||
cmdInitNewMilestone(cwd: string, raw: boolean): void;
|
||||
cmdInitExecutePhase(cwd: string, phase: string | undefined, raw: boolean, opts: Record<string, string | boolean | null | undefined>): void;
|
||||
cmdInitPlanPhase(cwd: string, phase: string | undefined, raw: boolean, opts: Record<string, string | boolean | null | undefined>): void;
|
||||
cmdInitNewProject(cwd: string, raw: boolean, options?: Record<string, string | boolean | null | undefined>): void;
|
||||
cmdInitNewMilestone(cwd: string, raw: boolean, options?: Record<string, string | boolean | null | undefined>): void;
|
||||
cmdInitOnboard(cwd: string, raw: boolean, opts?: Record<string, string | boolean | null>): void;
|
||||
cmdInitQuick(cwd: string, name: string, raw: boolean): void;
|
||||
cmdInitQuick(cwd: string, name: string, raw: boolean, options?: Record<string, string | boolean | null | undefined>): void;
|
||||
cmdInitIngestDocs(cwd: string, raw: boolean): void;
|
||||
cmdInitResume(cwd: string, raw: boolean): void;
|
||||
cmdInitVerifyWork(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||
@@ -37,7 +37,7 @@ interface InitModule {
|
||||
cmdInitTodos(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||
cmdInitMilestoneOp(cwd: string, raw: boolean): void;
|
||||
cmdInitMapCodebase(cwd: string, raw: boolean): void;
|
||||
cmdInitProgress(cwd: string, raw: boolean): void;
|
||||
cmdInitProgress(cwd: string, raw: boolean, options?: Record<string, string | boolean | null | undefined>): void;
|
||||
cmdInitManager(cwd: string, raw: boolean): void;
|
||||
cmdInitNewWorkspace(cwd: string, raw: boolean): void;
|
||||
cmdInitListWorkspaces(cwd: string, raw: boolean): void;
|
||||
@@ -62,25 +62,50 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
|
||||
error,
|
||||
unknownMessage: (_subcommand: string, available: string[]) => `Unknown init workflow: ${_subcommand}\nAvailable: ${available.join(', ')}`,
|
||||
handlers: {
|
||||
// #2932/#2992: `parseNamedArgs` never yields `undefined` for an absent
|
||||
// flag (value-flags default to `null`, booleanFlags default to `false`);
|
||||
// `buildSectionManifestField`'s flags-Set builder (src/init.cts) is the
|
||||
// single source of truth for flag ABSENCE and gates on value truthiness,
|
||||
// so `namedArgs` is passed through here uncoerced.
|
||||
'execute-phase': () => {
|
||||
// #2932: 'wave' is boolean/token-presence (parseNamedArgs's booleanFlags
|
||||
// semantics already match the design's token-presence rule: `--wave` alone,
|
||||
// `--wave 0`, and duplicate `--wave 1 --wave 2` all resolve to `true`;
|
||||
// near-miss tokens `--waves`/`--wave-filter` never match the exact `--wave` token).
|
||||
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd', 'wave']);
|
||||
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'], wave: namedArgs['wave'] });
|
||||
init.cmdInitExecutePhase(cwd, args[2], raw, {
|
||||
validate: namedArgs['validate'],
|
||||
tdd: namedArgs['tdd'],
|
||||
wave: namedArgs['wave'],
|
||||
});
|
||||
},
|
||||
'plan-phase': () => {
|
||||
const namedArgs = parseNamedArgs(args, ['granularity'], ['validate', 'tdd']);
|
||||
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'], granularity: namedArgs['granularity'] });
|
||||
init.cmdInitPlanPhase(cwd, args[2], raw, {
|
||||
validate: namedArgs['validate'],
|
||||
tdd: namedArgs['tdd'],
|
||||
granularity: namedArgs['granularity'],
|
||||
});
|
||||
},
|
||||
'new-project': () => {
|
||||
const namedArgs = parseNamedArgs(args, [], ['auto']);
|
||||
init.cmdInitNewProject(cwd, raw, { auto: namedArgs['auto'] });
|
||||
},
|
||||
'new-milestone': () => {
|
||||
const namedArgs = parseNamedArgs(args, [], ['reset-phase-numbers']);
|
||||
init.cmdInitNewMilestone(cwd, raw, {
|
||||
'reset-phase-numbers': namedArgs['reset-phase-numbers'],
|
||||
});
|
||||
},
|
||||
'new-project': () => init.cmdInitNewProject(cwd, raw),
|
||||
'new-milestone': () => init.cmdInitNewMilestone(cwd, raw),
|
||||
onboard: () => {
|
||||
const namedArgs = parseNamedArgs(args, [], ['fast', 'text']);
|
||||
init.cmdInitOnboard(cwd, raw, { fast: namedArgs['fast'], text: namedArgs['text'] });
|
||||
},
|
||||
quick: () => init.cmdInitQuick(cwd, args.slice(2).join(' '), raw),
|
||||
quick: () => {
|
||||
const namedArgs = parseNamedArgs(args, [], ['discuss', 'research', 'validate', 'full']);
|
||||
init.cmdInitQuick(cwd, args.slice(2).join(' '), raw, {
|
||||
discuss: namedArgs['discuss'],
|
||||
research: namedArgs['research'],
|
||||
validate: namedArgs['validate'],
|
||||
full: namedArgs['full'],
|
||||
});
|
||||
},
|
||||
'ingest-docs': () => init.cmdInitIngestDocs(cwd, raw),
|
||||
resume: () => init.cmdInitResume(cwd, raw),
|
||||
'verify-work': () => init.cmdInitVerifyWork(cwd, args[2], raw),
|
||||
@@ -88,7 +113,10 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
|
||||
todos: () => init.cmdInitTodos(cwd, args[2], raw),
|
||||
'milestone-op': () => init.cmdInitMilestoneOp(cwd, raw),
|
||||
'map-codebase': () => init.cmdInitMapCodebase(cwd, raw),
|
||||
progress: () => init.cmdInitProgress(cwd, raw),
|
||||
progress: () => {
|
||||
const namedArgs = parseNamedArgs(args, [], ['forensic']);
|
||||
init.cmdInitProgress(cwd, raw, { forensic: namedArgs['forensic'] });
|
||||
},
|
||||
// Keep manager on CJS for now so runtime-specific command rendering
|
||||
// (e.g. $gsd-* for codex) stays consistent with runtime-slash helpers.
|
||||
manager: () => init.cmdInitManager(cwd, raw),
|
||||
|
||||
189
src/init.cts
189
src/init.cts
@@ -343,25 +343,58 @@ interface ManifestSection extends sectionManifest.SelectableSection {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and shape-validates the generated section manifest's `sections` array.
|
||||
* Returns `null` — never throws — when the artifact is missing, unreadable,
|
||||
* malformed JSON, or valid JSON of the wrong shape (matrix rows 13/14/57/58:
|
||||
* a missing derived artifact must not break dispatch).
|
||||
* Defense-in-depth shape check for a manifest entry's `read` field, which is
|
||||
* documented as a POSIX-normalized, repo-root-RELATIVE path (never a
|
||||
* filesystem escape). Rejects any absolute path (POSIX leading `/`, a
|
||||
* Windows drive prefix like `C:\`/`C:/`, or a Windows UNC/rooted path
|
||||
* starting with `\`) and any path containing a `..` segment (checked on
|
||||
* BOTH separators — the artifact is generated as POSIX-normalized, but this
|
||||
* validates the raw field defensively rather than trusting that invariant).
|
||||
* `false` here is the only accept path in {@link loadSectionManifestSections};
|
||||
* a `true` degrades the WHOLE load to `null`, same as every other shape
|
||||
* violation — never throws, never partially loads.
|
||||
*/
|
||||
function loadSectionManifestSections(): ManifestSection[] | null {
|
||||
function isUnsafeManifestReadPath(readPath: string): boolean {
|
||||
if (readPath.startsWith('/') || readPath.startsWith('\\')) return true;
|
||||
if (/^[a-zA-Z]:[\\/]/.test(readPath)) return true;
|
||||
return readPath.split(/[\\/]/).includes('..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and shape-validates the generated section manifest, then returns the
|
||||
* document-order section array for exactly one named `workflow` (#2992 Phase
|
||||
* 6.1: the artifact is now `{ workflows: { <name>: [...] } }`, keyed by
|
||||
* `.md` basename — see `scripts/gen-section-manifest.cjs`). Returns `null`
|
||||
* — never throws — when the artifact is missing, unreadable, malformed
|
||||
* JSON, valid JSON of the wrong shape (INCLUDING the pre-6.1 flat
|
||||
* `{sections:[...]}` shape, which must never be mis-attributed to any
|
||||
* workflow — design row C4), or when `workflow` has no key in `workflows`.
|
||||
* `Object.hasOwn` guards the key lookup so a hostile workflow name
|
||||
* (`constructor`, `toString`, `__proto__`) can never resolve via the
|
||||
* prototype chain instead of a genuine own key. Each entry's `read` field is
|
||||
* additionally validated by {@link isUnsafeManifestReadPath} (rejects an
|
||||
* absolute path or a `..` segment) — a single unsafe entry degrades the
|
||||
* WHOLE load to `null`, all-or-nothing like every other shape violation.
|
||||
*/
|
||||
function loadSectionManifestSections(workflow: string): ManifestSection[] | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(_sectionManifestCandidatePath(), 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed === null || typeof parsed !== 'object') return null;
|
||||
const sections = (parsed as Record<string, unknown>)['sections'];
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
const workflows = (parsed as Record<string, unknown>)['workflows'];
|
||||
if (workflows === null || typeof workflows !== 'object' || Array.isArray(workflows)) return null;
|
||||
if (!Object.hasOwn(workflows, workflow)) return null;
|
||||
const sections = (workflows as Record<string, unknown>)[workflow];
|
||||
if (!Array.isArray(sections)) return null;
|
||||
for (const section of sections) {
|
||||
const readValue = (section as Record<string, unknown> | null)?.['read'];
|
||||
if (
|
||||
!section ||
|
||||
typeof section !== 'object' ||
|
||||
typeof (section as Record<string, unknown>)['id'] !== 'string' ||
|
||||
typeof (section as Record<string, unknown>)['when'] !== 'string' ||
|
||||
typeof (section as Record<string, unknown>)['read'] !== 'string'
|
||||
typeof readValue !== 'string' ||
|
||||
isUnsafeManifestReadPath(readValue)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -405,6 +438,70 @@ function detectHasPriorPhases(cwd: string, phaseInfo: Record<string, unknown> |
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict-boolean, bounded, non-throwing read of a dotted key path from
|
||||
* `.planning/config.json` (design rows D7-D10): absent file, unreadable
|
||||
* file (fs error), malformed JSON, a non-object intermediate segment, or a
|
||||
* present-but-non-boolean value (e.g. the string `"true"`) all degrade to
|
||||
* `false` — strict `=== true`, never coerced, mirrors `detectHasPriorPhases`'s
|
||||
* degrade-to-false discipline. `keyPath` is always a fixed literal supplied
|
||||
* by this module, never attacker/user input, so a plain bracket traversal
|
||||
* carries no prototype hazard here.
|
||||
*/
|
||||
function readConfigJsonBoolean(cwd: string, keyPath: readonly string[]): boolean {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(planningDir(cwd), 'config.json'), 'utf8');
|
||||
let cursor: unknown = JSON.parse(raw);
|
||||
for (const segment of keyPath) {
|
||||
if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor)) return false;
|
||||
cursor = (cursor as Record<string, unknown>)[segment];
|
||||
}
|
||||
return cursor === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `state:phase-mvp-mode` ground truth (design doc §Behavior table: ROADMAP.md
|
||||
* `**Mode:** mvp` for the CURRENT phase). Bounded, non-throwing — an absent
|
||||
* `phaseNumber`, an absent ROADMAP.md, an absent phase heading, or a phase
|
||||
* section with no `**Mode:**` line (or a `**Mode:**` value other than the
|
||||
* literal `mvp` token, case-insensitively) all degrade to `false` (D11; "a
|
||||
* phase with no `**Mode:**` line and an absent ROADMAP are both false, but
|
||||
* neither may throw"). Self-contained rather than reusing `phase.cts`'s
|
||||
* private `getRoadmapModeForPhase` (unexported, and importing it here would
|
||||
* be a cross-module surface change outside this task's scope) — but derived
|
||||
* from the SAME extraction primitives (`extractCurrentMilestone`,
|
||||
* `PHASE_NUMBER_TOKEN_SOURCE`-adjacent `escapeRegex`) already used by this
|
||||
* file's own `cmdInitProgress` MVP-heading scan, so it is not a second
|
||||
* ROADMAP-heading parser invented from scratch.
|
||||
*/
|
||||
function detectPhaseMvpMode(cwd: string, phaseNumber: string | null): boolean {
|
||||
if (!phaseNumber) return false;
|
||||
try {
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
if (!fs.existsSync(roadmapPath)) return false;
|
||||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
const escapedPhase = escapeRegex(phaseNumber);
|
||||
const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
|
||||
const headerMatch = content.match(phaseHeader);
|
||||
if (!headerMatch || headerMatch.index === undefined) return false;
|
||||
const sectionStart = headerMatch.index;
|
||||
const rest = content.slice(sectionStart + headerMatch[0].length);
|
||||
const nextHeaderMatch = rest.match(/\n#{2,4}\s+Phase\s+\S/i);
|
||||
const sectionEnd = nextHeaderMatch
|
||||
? sectionStart + headerMatch[0].length + (nextHeaderMatch.index as number)
|
||||
: content.length;
|
||||
const section = content.slice(sectionStart, sectionEnd);
|
||||
const modeMatch = section.match(/\*\*Mode:\*\*\s*([^\n]+)/i);
|
||||
return modeMatch ? modeMatch[1].trim().toLowerCase() === 'mvp' : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `section_manifest` init-bundle field (#2932 Deliverable 2): resolves
|
||||
* {@link sectionManifest.InvocationFacts} from this invocation, loads the generated
|
||||
@@ -413,13 +510,37 @@ function detectHasPriorPhases(cwd: string, phaseInfo: Record<string, unknown> |
|
||||
* or an unexpected throw from the evaluator itself) — this field is additive and
|
||||
* optional, never load-bearing for dispatch (Hyrum's Law: 22 direct init-bundle
|
||||
* dependents must be unaffected by its absence).
|
||||
*
|
||||
* `flags` (D1-D5): built from `options`'s OWN keys, gated on VALUE TRUTHINESS
|
||||
* — not merely `!== undefined`. `parseNamedArgs` (src/command-arg-projection.cts)
|
||||
* never yields `undefined` for an absent flag of either kind: a value-flag's
|
||||
* absence is `null`, a booleanFlag's absence is `false`. An `undefined`-only
|
||||
* absence check therefore lets BOTH kinds of absent flag leak into `flags` as
|
||||
* present. A present value-flag is always a non-empty string, and a present
|
||||
* booleanFlag is always `true` — so skipping any falsy value (`undefined`,
|
||||
* `null`, `false`, `''`, `0`) is a safe, single-rule absence test for both
|
||||
* flag kinds; `--wave 0` still resolves to `true` via `booleanFlags`, so
|
||||
* truthiness never misclassifies a real invocation as absent. `Object.keys`
|
||||
* + a plain `new Set()` so a hostile option key (e.g. `constructor`) can
|
||||
* never leak via the prototype chain.
|
||||
*
|
||||
* `needsCodebaseMap` is not computed in this shared facts-assembly scope —
|
||||
* `isBrownfield && !hasCodebaseMap` is only meaningful for `new-project`
|
||||
* (`cmdInitNewProject` already computes both operands for its own result
|
||||
* object). Rather than recomputing it here (a second, divergence-prone
|
||||
* codebase-map scan) or widening every call site's positional signature,
|
||||
* callers that HAVE the fact pass it via the optional `overrides` param;
|
||||
* every other caller passes nothing and gets `undefined` (falsy per
|
||||
* `WHEN_PREDICATES`, never invented, never throws).
|
||||
*/
|
||||
function buildSectionManifestField(
|
||||
cwd: string,
|
||||
phaseInfo: Record<string, unknown> | null,
|
||||
options: Record<string, unknown>,
|
||||
workflow: string,
|
||||
overrides: { needsCodebaseMap?: boolean } = {},
|
||||
): Record<string, unknown> | null {
|
||||
const sections = loadSectionManifestSections();
|
||||
const sections = loadSectionManifestSections(workflow);
|
||||
if (!sections) return null;
|
||||
|
||||
const rawPhaseNumber = phaseInfo?.['phase_number'];
|
||||
@@ -430,17 +551,26 @@ function buildSectionManifestField(
|
||||
? String(rawPhaseNumber)
|
||||
: null;
|
||||
|
||||
const flags = new Set<string>();
|
||||
for (const key of Object.keys(options)) {
|
||||
if (!options[key]) continue;
|
||||
flags.add(`--${key}`);
|
||||
}
|
||||
|
||||
const facts: sectionManifest.InvocationFacts = {
|
||||
waveFlag: options['wave'] === true,
|
||||
flags,
|
||||
phaseNumber,
|
||||
hasPriorPhases: detectHasPriorPhases(cwd, phaseInfo),
|
||||
worktreesEnabled: readConfigJsonBoolean(cwd, ['workflow', 'use_worktrees']),
|
||||
phaseMvpMode: detectPhaseMvpMode(cwd, phaseNumber),
|
||||
needsCodebaseMap: overrides.needsCodebaseMap,
|
||||
};
|
||||
|
||||
try {
|
||||
const selection = sectionManifest.selectSections(sections, facts);
|
||||
const readById = new Map(sections.map((s) => [s.id, s.read]));
|
||||
return {
|
||||
workflow: 'execute-phase',
|
||||
workflow,
|
||||
included: selection.included,
|
||||
excluded: selection.excluded,
|
||||
read: selection.included
|
||||
@@ -585,8 +715,8 @@ function cmdInitExecutePhase(
|
||||
}
|
||||
}
|
||||
|
||||
// #2932 (Phase 5): additive, optional field — degrades to null, never throws.
|
||||
result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options);
|
||||
// #2932/#2992 (Phase 5/6.1): additive, optional field — degrades to null, never throws.
|
||||
result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options, 'execute-phase');
|
||||
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
@@ -778,10 +908,13 @@ function cmdInitPlanPhase(
|
||||
}
|
||||
}
|
||||
|
||||
// #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
|
||||
result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options, 'plan-phase');
|
||||
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
function cmdInitNewProject(cwd: string, raw: boolean): void {
|
||||
function cmdInitNewProject(cwd: string, raw: boolean, options: Record<string, unknown> = {}): void {
|
||||
const config = loadConfig(cwd);
|
||||
|
||||
const homedir = os.homedir();
|
||||
@@ -832,10 +965,18 @@ function cmdInitNewProject(cwd: string, raw: boolean): void {
|
||||
research_dir: toPosixPath(path.join(planningRoot(cwd), 'research')),
|
||||
};
|
||||
|
||||
// #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
|
||||
// needsCodebaseMap is threaded from this scope's own isBrownfield/hasCodebaseMap
|
||||
// computation (see `needs_codebase_map` above) so `state:needs-codebase-map` is
|
||||
// genuinely computed for this workflow, not left permanently false.
|
||||
result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'new-project', {
|
||||
needsCodebaseMap: isBrownfield && !hasCodebaseMap,
|
||||
});
|
||||
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
function cmdInitNewMilestone(cwd: string, raw: boolean): void {
|
||||
function cmdInitNewMilestone(cwd: string, raw: boolean, options: Record<string, unknown> = {}): void {
|
||||
const config = loadConfig(cwd);
|
||||
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
|
||||
const latestCompleted = getLatestCompletedMilestone(cwd);
|
||||
@@ -891,10 +1032,18 @@ function cmdInitNewMilestone(cwd: string, raw: boolean): void {
|
||||
milestones_path: toPosixPath(path.join(planningDir(cwd), 'MILESTONES.md')),
|
||||
};
|
||||
|
||||
// #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
|
||||
result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'new-milestone');
|
||||
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
function cmdInitQuick(cwd: string, description: string | undefined, raw: boolean): void {
|
||||
function cmdInitQuick(
|
||||
cwd: string,
|
||||
description: string | undefined,
|
||||
raw: boolean,
|
||||
options: Record<string, unknown> = {},
|
||||
): void {
|
||||
const config = loadConfig(cwd);
|
||||
const now = new Date();
|
||||
const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null;
|
||||
@@ -945,6 +1094,9 @@ function cmdInitQuick(cwd: string, description: string | undefined, raw: boolean
|
||||
planning_exists: fs.existsSync(planningRoot(cwd)),
|
||||
};
|
||||
|
||||
// #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
|
||||
result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'quick');
|
||||
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
@@ -1827,7 +1979,7 @@ function cmdInitManager(cwd: string, raw: boolean): void {
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
function cmdInitProgress(cwd: string, raw: boolean): void {
|
||||
function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unknown> = {}): void {
|
||||
try {
|
||||
(pruneOrphanedWorktrees as (cwd: string) => void)(cwd);
|
||||
} catch {
|
||||
@@ -2036,6 +2188,9 @@ function cmdInitProgress(cwd: string, raw: boolean): void {
|
||||
config_path: toPosixPath(path.join(planningDir(cwd), 'config.json')),
|
||||
};
|
||||
|
||||
// #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
|
||||
result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'progress');
|
||||
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,19 @@
|
||||
* ## The evaluator is a LOOKUP, not a parser
|
||||
*
|
||||
* Derived from Greenspun's Tenth Rule (ADR-1671:69 cites it by name) and
|
||||
* binding on this implementation: `when=` is a closed, 4-entry vocabulary.
|
||||
* {@link WHEN_PREDICATES} is a total map from each frozen vocabulary entry
|
||||
* to exactly one predicate over {@link InvocationFacts}. It MUST NOT
|
||||
* tokenize, split on operators, or interpret structure in the `when=`
|
||||
* string — the moment it parses, the ad-hoc language has begun. An
|
||||
* binding on this implementation: `when=` is a closed vocabulary, widened
|
||||
* from 4 to 14 entries via the ADR-1671 amendment for #2992 (epic #1671
|
||||
* Phase 6.1; see `.gsd/phase/chore-2992-widen-when-vocabulary/
|
||||
* 40-design.md`). {@link WHEN_PREDICATES} is a total map from each frozen
|
||||
* vocabulary entry to exactly one predicate over {@link InvocationFacts}.
|
||||
* It MUST NOT tokenize, split on operators, or interpret structure in the
|
||||
* `when=` string — the moment it parses, the ad-hoc language has begun.
|
||||
* Every entry below is therefore a HAND-WRITTEN LITERAL: deriving a
|
||||
* predicate's flag/state name from its atom string (e.g. slicing `--fix`
|
||||
* out of `'flag:--fix'`) is tokenization relocated into this map and is
|
||||
* forbidden even though it would be shorter — the redundancy between each
|
||||
* key and its literal token is deliberate, and the bidirectional parity
|
||||
* test below catches any desync a hand-written entry could introduce. An
|
||||
* unrecognized `when=` value fails closed via {@link selectSections}
|
||||
* throwing a `TypeError` carrying `.reason = REASON.UNKNOWN_WHEN`; it is
|
||||
* never silently excluded (Postel's Law: liberal on FORMAT elsewhere in the
|
||||
@@ -54,12 +62,24 @@ import workflowFragments = require('./workflow-fragments.cjs');
|
||||
* the caller (the init CLI seam) before {@link selectSections} is invoked.
|
||||
*/
|
||||
export interface InvocationFacts {
|
||||
/** Whether the `--wave` flag's literal token was present on the invocation (token-presence, not value-truthiness). */
|
||||
readonly waveFlag: boolean;
|
||||
/**
|
||||
* The literal `--<name>` tokens present on the invocation (token-presence,
|
||||
* not value-truthiness — an option whose value is `false` or a string is
|
||||
* still present; an option whose value is `undefined` is not). A
|
||||
* `ReadonlySet<string>` rather than a plain object: `.has()` carries no
|
||||
* prototype hazard, whereas a plain object's key lookup does.
|
||||
*/
|
||||
readonly flags: ReadonlySet<string>;
|
||||
/** The invocation's phase number, or `null` when absent. A decimal (`X.Y`) phase number is a gap-closure phase. */
|
||||
readonly phaseNumber: string | null;
|
||||
/** Whether prior phases exist for this invocation. */
|
||||
readonly hasPriorPhases: boolean;
|
||||
/** Whether a codebase map is needed (init JSON). Absent/undefined is falsy, never throws. */
|
||||
readonly needsCodebaseMap?: boolean;
|
||||
/** Whether the current phase is in MVP mode (ROADMAP.md `**Mode:** mvp`). Absent/undefined is falsy, never throws. */
|
||||
readonly phaseMvpMode?: boolean;
|
||||
/** Whether worktrees are enabled (`.planning/config.json` `workflow.use_worktrees`). Absent/undefined is falsy, never throws. */
|
||||
readonly worktreesEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** A single input to {@link selectSections}: structurally compatible with {@link workflowFragments.WorkflowSection}. */
|
||||
@@ -108,13 +128,27 @@ function fail(reason: string, message: string): never {
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely tests whether `facts.flags` contains `flag`, tolerating an absent,
|
||||
* `null`, or non-`Set` (e.g. array) `flags` value without throwing —
|
||||
* `.has` is checked to be callable before it is called, rather than
|
||||
* assuming every {@link InvocationFacts.flags} is a real `Set` (totality
|
||||
* over facts; not duck-typed — an array `flags` degrades to "not present",
|
||||
* it is never iterated or `.includes`-checked).
|
||||
*/
|
||||
function hasFlag(facts: InvocationFacts, flag: string): boolean {
|
||||
return typeof facts.flags?.has === 'function' && facts.flags.has(flag) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total map from each frozen {@link workflowFragments.WHEN_VOCABULARY}
|
||||
* entry to exactly one predicate over {@link InvocationFacts}. This is a
|
||||
* LOOKUP, never a parser — see the module doc comment's "The evaluator is a
|
||||
* LOOKUP, not a parser" section. Semantics confirmed against the section
|
||||
* bodies themselves (design doc "Semantics confirmed against the section
|
||||
* bodies themselves, not inferred from the id"):
|
||||
* LOOKUP, not a parser" section. Every entry is a hand-written literal; see
|
||||
* the module doc comment for why deriving a predicate from its atom string
|
||||
* is forbidden. Semantics confirmed against the section bodies themselves
|
||||
* (design doc "Semantics confirmed against the section bodies themselves,
|
||||
* not inferred from the id"):
|
||||
*
|
||||
* - `gap-closure-artifacts` — "For decimal/polish phases only (X.Y
|
||||
* pattern) … Skip if phase number has no decimal" -> `state:gap-closure-phase`.
|
||||
@@ -125,10 +159,20 @@ function fail(reason: string, message: string): never {
|
||||
export const WHEN_PREDICATES: Readonly<Record<string, (facts: InvocationFacts) => boolean>> = Object.freeze(
|
||||
Object.assign(Object.create(null) as Record<string, (facts: InvocationFacts) => boolean>, {
|
||||
always: () => true,
|
||||
'flag:--wave': (facts: InvocationFacts) => facts.waveFlag === true,
|
||||
'flag:--wave': (facts: InvocationFacts) => hasFlag(facts, '--wave'),
|
||||
'state:gap-closure-phase': (facts: InvocationFacts) =>
|
||||
typeof facts.phaseNumber === 'string' && facts.phaseNumber.includes('.'),
|
||||
'state:has-prior-phases': (facts: InvocationFacts) => facts.hasPriorPhases === true,
|
||||
'flag:--auto': (facts: InvocationFacts) => hasFlag(facts, '--auto'),
|
||||
'flag:--discuss': (facts: InvocationFacts) => hasFlag(facts, '--discuss'),
|
||||
'flag:--forensic': (facts: InvocationFacts) => hasFlag(facts, '--forensic'),
|
||||
'flag:--full': (facts: InvocationFacts) => hasFlag(facts, '--full'),
|
||||
'flag:--research': (facts: InvocationFacts) => hasFlag(facts, '--research'),
|
||||
'flag:--reset-phase-numbers': (facts: InvocationFacts) => hasFlag(facts, '--reset-phase-numbers'),
|
||||
'flag:--validate': (facts: InvocationFacts) => hasFlag(facts, '--validate'),
|
||||
'state:needs-codebase-map': (facts: InvocationFacts) => facts.needsCodebaseMap === true,
|
||||
'state:phase-mvp-mode': (facts: InvocationFacts) => facts.phaseMvpMode === true,
|
||||
'state:worktrees-enabled': (facts: InvocationFacts) => facts.worktreesEnabled === true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -146,6 +190,19 @@ for (const when of workflowFragments.WHEN_VOCABULARY) {
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse half of the same coordinated-change guard: every own key of
|
||||
// WHEN_PREDICATES must also appear in WHEN_VOCABULARY. Without this, a
|
||||
// predicate key with no vocabulary entry would let the evaluator accept an
|
||||
// atom that the parser (classifyMarker) rejects — a real divergence between
|
||||
// the two shared-constant halves (DEFECT.GENERATIVE-FIX; B9/B10 in
|
||||
// `.gsd/phase/chore-2992-widen-when-vocabulary/50-test-matrix.md`).
|
||||
const VOCABULARY_SET = new Set(workflowFragments.WHEN_VOCABULARY);
|
||||
for (const predicateKey of Object.keys(WHEN_PREDICATES)) {
|
||||
if (!VOCABULARY_SET.has(predicateKey)) {
|
||||
throw new Error(`section-manifest: WHEN_PREDICATES entry "${predicateKey}" has no matching WHEN_VOCABULARY entry`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition `sections` (document order) into `included`/`excluded` id
|
||||
* arrays for one set of `facts`, per {@link WHEN_PREDICATES}. Exact
|
||||
|
||||
@@ -90,12 +90,38 @@ import contextComposer = require('./context-composer.cjs');
|
||||
* Frozen, CLOSED applicability vocabulary for the `when=` attribute.
|
||||
* Extending this list requires an ADR amendment, not an organic edit
|
||||
* (Greenspun's Tenth Rule — see the module doc comment).
|
||||
*
|
||||
* Widened from 4 to 14 entries via the ADR-1671 amendment for #2992 (epic
|
||||
* #1671 Phase 6.1; see `.gsd/phase/chore-2992-widen-when-vocabulary/
|
||||
* 40-design.md`). The vocabulary remains CLOSED: no operators, no negation,
|
||||
* no nesting. Cardinality is not expressiveness — a 14-entry flat list with
|
||||
* no composition is still not a language.
|
||||
*
|
||||
* Held at 14, not wider: an atom whose fact is never computed always
|
||||
* evaluates FALSE, so a section marked with it would silently never
|
||||
* include — a silent-exclusion bug, not a feature. Six further atoms
|
||||
* (`flag:--converge`, `flag:--fix`, `flag:--verify-only`,
|
||||
* `state:fallow-enabled`, `state:git-create-tag`, `state:is-monorepo`) were
|
||||
* surveyed and are justified in principle, but their workflows
|
||||
* (docs-update, autonomous, code-review, complete-milestone) have no
|
||||
* dedicated `cmdInit*` entry point yet to compute the backing fact, so they
|
||||
* are withheld until that entry point exists (#2992 / ADR-1671 Phase 6.1).
|
||||
*/
|
||||
export const WHEN_VOCABULARY: readonly string[] = Object.freeze([
|
||||
'always',
|
||||
'flag:--wave',
|
||||
'state:gap-closure-phase',
|
||||
'state:has-prior-phases',
|
||||
'flag:--auto',
|
||||
'flag:--discuss',
|
||||
'flag:--forensic',
|
||||
'flag:--full',
|
||||
'flag:--research',
|
||||
'flag:--reset-phase-numbers',
|
||||
'flag:--validate',
|
||||
'state:needs-codebase-map',
|
||||
'state:phase-mvp-mode',
|
||||
'state:worktrees-enabled',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,10 +138,11 @@ describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
|
||||
markerSource('handle-x', 'always', 'handle-x.md'),
|
||||
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
||||
);
|
||||
// Stale: valid shape, but "when" no longer matches the source's marker.
|
||||
// Stale: valid {workflows:{...}} shape, but "when" no longer matches the
|
||||
// source's marker (must reach FAIL_STALE, not trip the shape check).
|
||||
fs.writeFileSync(
|
||||
manifestPath,
|
||||
JSON.stringify({ sections: [{ id: 'handle-x', when: 'flag:--wave', read: 'gsd-core/workflows/sample/steps/handle-x.md' }] }, null, 2) + '\n',
|
||||
JSON.stringify({ workflows: { sample: [{ id: 'handle-x', when: 'flag:--wave', read: 'gsd-core/workflows/sample/steps/handle-x.md' }] } }, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
@@ -248,6 +249,36 @@ describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('rejectsPre6_1FlatSectionsShapeAsMalformed (upgrade-path supplemental: an installed tree still carrying the old flat {sections:[...]} artifact)', (t) => {
|
||||
const tmpRoot = createTempDir('gen-section-manifest-');
|
||||
t.after(() => cleanup(tmpRoot));
|
||||
|
||||
const { workflowsDir, manifestPath } = buildFixture(
|
||||
tmpRoot,
|
||||
'sample',
|
||||
markerSource('handle-x', 'always', 'handle-x.md'),
|
||||
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
||||
);
|
||||
|
||||
// Pre-6.1 committed artifact shape: a flat `{sections:[...]}` array with
|
||||
// no `workflows` key at all. Must be rejected as malformed, never
|
||||
// silently attributed to whichever workflow asks first (design row C4).
|
||||
fs.writeFileSync(
|
||||
manifestPath,
|
||||
JSON.stringify({ sections: [{ id: 'handle-x', when: 'always', read: 'gsd-core/workflows/sample/steps/handle-x.md' }] }, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const r = runGenSectionManifest([
|
||||
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
||||
]);
|
||||
assert.equal(r.code, 1);
|
||||
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'a pre-6.1 flat manifest must not crash the generator');
|
||||
const report = parseJsonReport(r.stdout);
|
||||
assert.equal(report.ok, false);
|
||||
assert.equal(report.reason, REASON.FAIL_MANIFEST_MALFORMED_SHAPE, 'pre-6.1 flat {sections:[...]} shape must be rejected, not mistaken for up-to-date or stale');
|
||||
});
|
||||
|
||||
test('checkFailsWhenMarkerReferencesMissingStepFile (row 35)', (t) => {
|
||||
const tmpRoot = createTempDir('gen-section-manifest-');
|
||||
t.after(() => cleanup(tmpRoot));
|
||||
@@ -324,7 +355,7 @@ describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
|
||||
);
|
||||
|
||||
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
|
||||
assert.equal(fresh.sections.length, 1);
|
||||
assert.equal(fresh.workflows.sample.length, 1);
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
|
||||
|
||||
const r = runGenSectionManifest([
|
||||
@@ -381,8 +412,8 @@ describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
|
||||
});
|
||||
|
||||
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
|
||||
assert.equal(fresh.sections.length, 1, 'the fenced marker-shaped lines must not produce a section');
|
||||
assert.equal(fresh.sections[0].id, 'handle-x');
|
||||
assert.equal(fresh.workflows.sample.length, 1, 'the fenced marker-shaped lines must not produce a section');
|
||||
assert.equal(fresh.workflows.sample[0].id, 'handle-x');
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
|
||||
const r = runGenSectionManifest([
|
||||
@@ -411,8 +442,8 @@ describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
|
||||
});
|
||||
|
||||
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
|
||||
assert.equal(fresh.sections.length, 1, 'gsd:loop-host must never be treated as a gsd:section marker');
|
||||
assert.equal(fresh.sections[0].id, 'handle-x');
|
||||
assert.equal(fresh.workflows.sample.length, 1, 'gsd:loop-host must never be treated as a gsd:section marker');
|
||||
assert.equal(fresh.workflows.sample[0].id, 'handle-x');
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
|
||||
const r = runGenSectionManifest([
|
||||
|
||||
@@ -3275,20 +3275,366 @@ describe('init section manifest', () => {
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null);
|
||||
});
|
||||
|
||||
// ── C4 (#2992): stale FLAT pre-6.1 artifact must degrade to null and
|
||||
// never be mis-attributed to any workflow — the upgrade-path row.
|
||||
|
||||
test('staleFlatPreWideningArtifactDegradesToNull (row C4)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c4-');
|
||||
const flatPath = path.join(dir, 'flat-section-manifest.json');
|
||||
// The pre-#2932-Phase-6.1 shape: a single top-level `sections` array,
|
||||
// no `workflows` key at all. It even NAMES an execute-phase-shaped
|
||||
// section, so a mis-attribution bug would silently "work".
|
||||
fs.writeFileSync(
|
||||
flatPath,
|
||||
JSON.stringify({
|
||||
sections: [{ id: 'partial-wave', when: 'flag:--wave', read: 'gsd-core/workflows/execute-phase/steps/partial-wave.md' }],
|
||||
}),
|
||||
);
|
||||
const result = runExecutePhase(['1', '--wave', '1'], dir, { GSD_SECTION_MANIFEST: flatPath });
|
||||
assert.equal(result.status, 0, `expected exit 0 despite stale flat artifact, got ${result.status} (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, 'stale-flat-artifact');
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, 'a pre-6.1 flat artifact must never be mis-parsed as some workflow\'s sections');
|
||||
});
|
||||
|
||||
// ── C8 (#2992): valid JSON that is not an object — one row each.
|
||||
|
||||
test('nonObjectJsonArtifactDegradesToNull (row C8)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c8-');
|
||||
for (const hostileJson of ['0', '"str"', '[]', 'null', 'true']) {
|
||||
const hostilePath = path.join(dir, `hostile-${Buffer.from(hostileJson).toString('hex')}-section-manifest.json`);
|
||||
fs.writeFileSync(hostilePath, hostileJson);
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: hostilePath });
|
||||
assert.equal(result.status, 0, `hostile JSON ${hostileJson}: expected exit 0 (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, `hostile-json:${hostileJson}`);
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, `hostile JSON ${hostileJson} must degrade to null`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── C9 (#2992): file present but 0 bytes.
|
||||
|
||||
test('emptyZeroByteArtifactFileDegradesToNull (row C9)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c9-');
|
||||
const emptyPath = path.join(dir, 'empty-section-manifest.json');
|
||||
fs.writeFileSync(emptyPath, '');
|
||||
assert.equal(fs.statSync(emptyPath).size, 0, 'sanity: fixture file must be 0 bytes');
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: emptyPath });
|
||||
assert.equal(result.status, 0, `expected exit 0 despite empty artifact file, got ${result.status} (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, 'empty-artifact-file');
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null);
|
||||
});
|
||||
|
||||
// ── #2992 review finding: an unsafe `read` path degrades the WHOLE
|
||||
// load to null, exactly like every other shape violation above — the
|
||||
// field is documented as a POSIX-normalized, repo-root-RELATIVE path,
|
||||
// so an absolute path, a Windows drive/UNC prefix, or a `..` traversal
|
||||
// segment must never be trusted through to a later `fs.readFileSync`.
|
||||
|
||||
test('degradesToNullWhenReadPathIsAbsolute', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-abs-');
|
||||
const manifestPath = path.join(dir, 'unsafe-abs-section-manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: '/etc/passwd' }] },
|
||||
}));
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.equal(result.status, 0, `expected exit 0 despite an absolute read path, got ${result.status} (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, 'unsafe-read-absolute');
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, 'an absolute `read` path must degrade the whole load to null');
|
||||
});
|
||||
|
||||
test('degradesToNullWhenReadPathContainsDotDotTraversal', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-dotdot-');
|
||||
const manifestPath = path.join(dir, 'unsafe-dotdot-section-manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: '../../etc/passwd' }] },
|
||||
}));
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.equal(result.status, 0, `expected exit 0 despite a ../ traversal read path, got ${result.status} (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, 'unsafe-read-dotdot');
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, 'a `..` traversal segment in `read` must degrade the whole load to null');
|
||||
});
|
||||
|
||||
test('degradesToNullWhenReadPathIsWindowsDriveAbsolute', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-windrive-');
|
||||
const manifestPath = path.join(dir, 'unsafe-windrive-section-manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: 'C:\\Windows\\System32\\config' }] },
|
||||
}));
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.equal(result.status, 0, `expected exit 0 despite a Windows drive-absolute read path, got ${result.status} (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, 'unsafe-read-windrive');
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, 'a Windows drive-absolute `read` path must degrade the whole load to null');
|
||||
});
|
||||
});
|
||||
|
||||
// ── C1/C12/C16/C17 (#2992): shape guarantees on the field itself ────────
|
||||
|
||||
describe('init execute-phase: section_manifest field-shape guarantees (#2992)', () => {
|
||||
test('emptySectionsArrayIsComputedNotDegraded (row C12 — present + empty is not null)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c12-');
|
||||
const emptyWorkflowPath = path.join(dir, 'empty-workflow-section-manifest.json');
|
||||
fs.writeFileSync(emptyWorkflowPath, JSON.stringify({ workflows: { 'execute-phase': [] } }));
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: emptyWorkflowPath });
|
||||
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.deepStrictEqual(
|
||||
body.section_manifest,
|
||||
{ workflow: 'execute-phase', included: [], excluded: [], read: [] },
|
||||
'a workflow key present with sections:[] must compute to an empty-but-present selection, never null',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflowAbsentFromArtifactDegradesToNullNotEmptySections (row C16 — absence is not empty)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c16-');
|
||||
const otherWorkflowPath = path.join(dir, 'other-workflow-section-manifest.json');
|
||||
fs.writeFileSync(otherWorkflowPath, JSON.stringify({ workflows: { 'plan-phase': [{ id: 'x', when: 'always', read: 'x.md' }] } }));
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: otherWorkflowPath });
|
||||
assert.equal(result.status, 0);
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, 'execute-phase has no key in this artifact — must degrade to null, never {included:[]}');
|
||||
});
|
||||
|
||||
test('executePhaseSectionManifestFieldIsByteIdenticalToThePreChangeShape (row C1 — Hyrum gate)', (t) => {
|
||||
// Locks the EXACT shape execute-phase's real, shipped manifest produces
|
||||
// for a plain (no --wave, no gap-closure, no prior phases) invocation —
|
||||
// #2992 widened the vocabulary and generalized the artifact to
|
||||
// per-workflow keying, but execute-phase's own 3 real sections (all
|
||||
// pre-existing atoms) must select IDENTICALLY to before this change.
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c1-');
|
||||
const body = parseOkJson(runExecutePhase(['1'], dir), 'c1-baseline');
|
||||
assert.deepStrictEqual(body.section_manifest, {
|
||||
workflow: 'execute-phase',
|
||||
included: [],
|
||||
excluded: ['partial-wave', 'gap-closure-artifacts', 'regression-gate'],
|
||||
read: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('restOfTheInitBundleIsUnaffectedByEverySectionManifestDegradedCondition (row C17 — Hyrum gate)', (t) => {
|
||||
// The `section_manifest` field is additive; every OTHER field of the
|
||||
// init-bundle (22 direct dependents per the design doc's blast-radius
|
||||
// table) must be byte-identical regardless of whether the manifest
|
||||
// artifact resolves, is missing, or is malformed.
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c17-');
|
||||
|
||||
const real = parseOkJson(runExecutePhase(['1'], dir), 'c17-real');
|
||||
const missingPath = path.join(dir, 'does-not-exist-c17.json');
|
||||
const missing = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: missingPath }), 'c17-missing');
|
||||
const badPath = path.join(dir, 'bad-c17.json');
|
||||
fs.writeFileSync(badPath, '{ not valid json');
|
||||
const malformed = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: badPath }), 'c17-malformed');
|
||||
|
||||
delete real.section_manifest;
|
||||
delete missing.section_manifest;
|
||||
delete malformed.section_manifest;
|
||||
assert.deepStrictEqual(missing, real, 'every other init-bundle field must be unaffected by a missing manifest artifact');
|
||||
assert.deepStrictEqual(malformed, real, 'every other init-bundle field must be unaffected by a malformed manifest artifact');
|
||||
});
|
||||
});
|
||||
|
||||
// ── D4 (#2992, prod-shape): an absent CLI flag is absent from `flags` ────
|
||||
//
|
||||
// #2992 review finding (fixed in this change, src/init-command-router.cts):
|
||||
// `parseNamedArgs`'s booleanFlags ALWAYS populate the option key (`true`
|
||||
// when the token was seen, `false` otherwise — never `undefined`).
|
||||
// `buildSectionManifestField`'s flags-Set builder (src/init.cts) treats
|
||||
// ANY non-`undefined` option value as present (matrix D2/D3, for VALUE
|
||||
// flags whose absence is `null`). Passed through unmodified, a
|
||||
// booleanFlag's own `false` ("--wave" never typed) would still have been
|
||||
// added to `flags`, making `flag:--wave` permanently true regardless of
|
||||
// the actual invocation — verified live pre-fix: `partial-wave` was
|
||||
// INCLUDED with no `--wave` on the command line at all, silently
|
||||
// defeating the gating feature this whole seam exists for. The router now
|
||||
// folds a booleanFlag's own `false` into `undefined` before it reaches
|
||||
// `buildSectionManifestField`, so this test is the regression lock for
|
||||
// that fix — it duplicates the assertion `emitsSectionManifestWithoutWaveFlag`
|
||||
// already makes, under an explicit D4 name for matrix traceability.
|
||||
//
|
||||
// D2 ("option present, value false" as a generically-present option) and
|
||||
// D3 ("option present, string value") and D6 (a hostile literal option
|
||||
// key) are NOT independently prod-shape-testable through the real CLI as
|
||||
// currently wired: `execute-phase`/`plan-phase` are the only two handlers
|
||||
// that feed router-derived options into `buildSectionManifestField`, their
|
||||
// option-key lists are FIXED literals (`validate`/`tdd`/`wave`/
|
||||
// `granularity`) never derived from user input (so a hostile key name can
|
||||
// never reach it for real), none of the 14 shipped vocabulary atoms is
|
||||
// backed by a value-taking CLI flag (so a real string-valued option can
|
||||
// never reach a `flag:` atom yet), and this fix means a booleanFlag's own
|
||||
// `false` never reaches the builder at all anymore. Synthesizing an
|
||||
// options object to force these paths would violate the matrix's own
|
||||
// "(prod-shape) rows must drive the options path" constraint, so they are
|
||||
// left unaddressed here rather than faked — surfaced for the orchestrator.
|
||||
describe('init execute-phase: undefined CLI flag is absent from flags (#2992 row D4)', () => {
|
||||
test('waveOptionAbsentWithoutTheFlagNeverActivatesPartialWave', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-d4-');
|
||||
const body = parseOkJson(runExecutePhase(['1'], dir), 'd4-no-wave');
|
||||
assert.ok(!body.section_manifest.included.includes('partial-wave'), 'an unset --wave must never leak into flags as "present"');
|
||||
assert.deepStrictEqual(body.section_manifest.excluded, ['partial-wave', 'gap-closure-artifacts', 'regression-gate']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── D9/D10/D11 (#2992, prod-shape): state:* detector degradation ─────────
|
||||
// Drives the REAL cmdInitExecutePhase -> buildSectionManifestField ->
|
||||
// readConfigJsonBoolean/detectPhaseMvpMode seam through the real CLI with a
|
||||
// fixture manifest naming the two config-backed atoms, never a hand-built
|
||||
// InvocationFacts (matrix note: "(prod-shape)" rows must drive the options
|
||||
// path). `seedSinglePhaseProject` writes no `.planning/config.json` and no
|
||||
// `.planning/ROADMAP.md` at all, so D9 (absent config) and D11 (absent
|
||||
// ROADMAP) are the fixture's natural, un-monkeypatched state.
|
||||
|
||||
describe('init execute-phase: state:* detector degradation (#2992 rows D9-D11)', () => {
|
||||
function writeDetectorManifest(dir) {
|
||||
const manifestPath = path.join(dir, 'detector-section-manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
workflows: {
|
||||
'execute-phase': [
|
||||
{ id: 'worktrees-section', when: 'state:worktrees-enabled', read: 'x.md' },
|
||||
{ id: 'mvp-section', when: 'state:phase-mvp-mode', read: 'y.md' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
test('absentConfigAndAbsentRoadmapDegradeBothStateAtomsToFalse (rows D9/D11)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-d9-');
|
||||
assert.equal(fs.existsSync(path.join(dir, '.planning', 'config.json')), false, 'sanity: no config.json');
|
||||
assert.equal(fs.existsSync(path.join(dir, '.planning', 'ROADMAP.md')), false, 'sanity: no ROADMAP.md');
|
||||
const manifestPath = writeDetectorManifest(dir);
|
||||
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd9-d11');
|
||||
assert.deepStrictEqual(body.section_manifest.excluded, ['worktrees-section', 'mvp-section']);
|
||||
assert.deepStrictEqual(body.section_manifest.included, []);
|
||||
});
|
||||
|
||||
test('nonBooleanConfigValueDegradesToFalse (row D10 — strict boolean, string "true" is not true)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-d10-');
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: 'true' } }));
|
||||
const manifestPath = writeDetectorManifest(dir);
|
||||
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd10');
|
||||
assert.ok(
|
||||
body.section_manifest.excluded.includes('worktrees-section'),
|
||||
'a string "true" config value must never coerce to boolean true',
|
||||
);
|
||||
});
|
||||
|
||||
test('realBooleanTrueConfigValueIncludesTheSection (independence: the strict check still accepts a real boolean)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-d10b-');
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: true } }));
|
||||
const manifestPath = writeDetectorManifest(dir);
|
||||
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd10b');
|
||||
assert.ok(body.section_manifest.included.includes('worktrees-section'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── #2992 review finding: state:needs-codebase-map wiring (real CLI) ─────
|
||||
//
|
||||
// No test anywhere drove `state:needs-codebase-map` (src/section-manifest.cts)
|
||||
// or its `cmdInitNewProject` override wiring (src/init.cts, `overrides.needsCodebaseMap`)
|
||||
// through the real CLI. Drives `init new-project` with a `mkdtempSync`
|
||||
// fixture manifest (via `GSD_SECTION_MANIFEST`) naming a single section
|
||||
// gated on the atom, proving inclusion/exclusion tracks the SAME
|
||||
// isBrownfield/hasCodebaseMap computation `needs_codebase_map` itself uses
|
||||
// — never mutating the shipped gsd-core/workflows/section-manifest.json.
|
||||
|
||||
describe('init new-project: state:needs-codebase-map wiring (#2992 review finding)', () => {
|
||||
function writeNeedsCodebaseMapManifest(dir) {
|
||||
const manifestPath = path.join(dir, 'needs-map-section-manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
workflows: {
|
||||
'new-project': [
|
||||
{ id: 'needs-map-section', when: 'state:needs-codebase-map', read: 'z.md' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
test('brownfieldWithoutCodebaseMapIncludesTheGatedSection', () => {
|
||||
const dir = createTempProject('gsd-needsmap-true-');
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"test"}');
|
||||
const manifestPath = writeNeedsCodebaseMapManifest(dir);
|
||||
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.needs_codebase_map, true, 'sanity: fixture must be brownfield without a codebase map');
|
||||
assert.deepStrictEqual(output.section_manifest.included, ['needs-map-section']);
|
||||
assert.deepStrictEqual(output.section_manifest.excluded, []);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('greenfieldExcludesTheGatedSection', () => {
|
||||
const dir = createTempProject('gsd-needsmap-false-');
|
||||
try {
|
||||
const manifestPath = writeNeedsCodebaseMapManifest(dir);
|
||||
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.needs_codebase_map, false, 'sanity: fixture must be greenfield');
|
||||
assert.deepStrictEqual(output.section_manifest.included, []);
|
||||
assert.deepStrictEqual(output.section_manifest.excluded, ['needs-map-section']);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('brownfieldWithExistingCodebaseMapExcludesTheGatedSection', () => {
|
||||
const dir = createTempProject('gsd-needsmap-hascomap-');
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"test"}');
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
|
||||
for (const name of ['STACK', 'ARCHITECTURE', 'STRUCTURE', 'CONVENTIONS', 'TESTING', 'INTEGRATIONS', 'CONCERNS']) {
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'codebase', `${name}.md`), `# ${name}\n`);
|
||||
}
|
||||
const manifestPath = writeNeedsCodebaseMapManifest(dir);
|
||||
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.needs_codebase_map, false, 'sanity: fixture must already have a complete codebase map');
|
||||
assert.deepStrictEqual(output.section_manifest.included, []);
|
||||
assert.deepStrictEqual(output.section_manifest.excluded, ['needs-map-section']);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── E59: independence — other init subcommands unaffected ───────────────
|
||||
//
|
||||
// #2992 (epic #1671 Phase 6.1) generalized the manifest seam from
|
||||
// execute-phase-only to per-workflow (`buildSectionManifestField` now wires
|
||||
// into `cmdInitPlanPhase`/`cmdInitNewProject`/`cmdInitNewMilestone`/
|
||||
// `cmdInitQuick`/`cmdInitProgress` too — src/init.cts:740/787/839/885/1810).
|
||||
// The OLD assertion here ("section_manifest must be execute-phase-only")
|
||||
// is stale: plan-phase's field is now PRESENT, but `null` — the shipped
|
||||
// gsd-core/workflows/section-manifest.json still keys only `execute-phase`
|
||||
// (design row C3: a workflow absent from the artifact's `workflows` map
|
||||
// degrades to `null`, never an absent field). `resume` has no dedicated
|
||||
// `cmdInit*` manifest wiring at all (design's withheld-atom survey), so its
|
||||
// field truly remains absent — that half of the guard is unchanged.
|
||||
|
||||
describe('init dispatch: other subcommands unaffected (#2932 row 59, CRITICAL radius guard)', () => {
|
||||
test('leavesOtherInitSubcommandsUnchanged', (t) => {
|
||||
describe('init dispatch: other subcommands unaffected (#2932/#2992 row 59, CRITICAL radius guard)', () => {
|
||||
test('planPhaseEmitsANullManifestNotAnAbsentField (row C3/C16 — absence is not empty)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-e59-');
|
||||
|
||||
const planPhase = parseOkJson(runSectionManifestCli(['init.plan-phase', '1'], dir), 'plan-phase');
|
||||
assert.equal(planPhase.phase_found, true);
|
||||
assert.ok(!('section_manifest' in planPhase), 'section_manifest must be execute-phase-only, never leak into plan-phase');
|
||||
assert.ok('section_manifest' in planPhase, 'section_manifest field must be present (computed, degraded to null) for plan-phase');
|
||||
assert.equal(planPhase.section_manifest, null, 'plan-phase has no key in the shipped artifact, so it must degrade to null, never {included:[]}');
|
||||
});
|
||||
|
||||
test('resumeNeverEmitsASectionManifestField', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-e59-resume-');
|
||||
const resume = parseOkJson(runSectionManifestCli(['init.resume'], dir), 'resume');
|
||||
assert.ok(!('section_manifest' in resume), 'section_manifest must never leak into resume');
|
||||
assert.ok(!('section_manifest' in resume), 'section_manifest must never leak into resume — resume has no cmdInit* manifest wiring at all');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -66,10 +66,22 @@ const phaseNumberArb = fc.oneof(
|
||||
fc.stringMatching(/^[0-9]{1,2}\.[0-9]{1,2}$/),
|
||||
);
|
||||
|
||||
// Flag atoms drawn from the module's own frozen WHEN_VOCABULARY re-export
|
||||
// (DEFECT.GENERATIVE-FIX — never a hardcoded local copy of flag names).
|
||||
const FLAG_TOKENS = WHEN_VOCABULARY.filter((w) => w.startsWith('flag:--')).map((w) => w.slice('flag:'.length));
|
||||
|
||||
const flagsArb = fc.subarray(FLAG_TOKENS).map((tokens) => new Set(tokens));
|
||||
|
||||
const factsArb = fc.record({
|
||||
waveFlag: fc.boolean(),
|
||||
flags: flagsArb,
|
||||
phaseNumber: phaseNumberArb,
|
||||
hasPriorPhases: fc.boolean(),
|
||||
// #2992 review finding: the three newer state:* booleans (needsCodebaseMap,
|
||||
// phaseMvpMode, worktreesEnabled) must also participate in the partition
|
||||
// invariant, not just the original three W/D/P facts.
|
||||
needsCodebaseMap: fc.boolean(),
|
||||
phaseMvpMode: fc.boolean(),
|
||||
worktreesEnabled: fc.boolean(),
|
||||
});
|
||||
|
||||
// ─── Row 25: exact partition ────────────────────────────────────────────────
|
||||
@@ -120,8 +132,8 @@ describe('property: never throws for vocabulary-valid when and arbitrary facts',
|
||||
|
||||
test('neverThrowsWhenFactsAreMissingKeysEntirely', () => {
|
||||
// Totality also over PARTIAL facts objects (row 19's property-level
|
||||
// twin): dropping zero or more of the three fact keys must never throw.
|
||||
const factKeys = ['waveFlag', 'phaseNumber', 'hasPriorPhases'];
|
||||
// twin): dropping zero or more of the six fact keys must never throw.
|
||||
const factKeys = ['flags', 'phaseNumber', 'hasPriorPhases', 'needsCodebaseMap', 'phaseMvpMode', 'worktreesEnabled'];
|
||||
fc.assert(
|
||||
fc.property(sectionsArb, factsArb, fc.subarray(factKeys), (sections, facts, keysToKeep) => {
|
||||
const partialFacts = {};
|
||||
@@ -169,6 +181,27 @@ describe('property: prototype-shaped when values always fail closed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B18: flag monotonicity — more flags never yields fewer included ───────
|
||||
|
||||
describe('property: adding more flags never yields fewer included sections (#2992 row B18)', () => {
|
||||
test('flagSupersetNeverShrinksIncluded', () => {
|
||||
fc.assert(
|
||||
fc.property(sectionsArb, flagsArb, fc.subarray(FLAG_TOKENS), factsArb, (sections, baseFlags, extraTokens, restFacts) => {
|
||||
const supersetFlags = new Set([...baseFlags, ...extraTokens]);
|
||||
const baseResult = selectSections(sections, { ...restFacts, flags: baseFlags });
|
||||
const supersetResult = selectSections(sections, { ...restFacts, flags: supersetFlags });
|
||||
|
||||
for (const id of baseResult.included) {
|
||||
assert.ok(
|
||||
supersetResult.included.includes(id),
|
||||
`id "${id}" included under a flags subset must remain included under a flags superset`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 28: order preservation ─────────────────────────────────────────────
|
||||
|
||||
describe('property: included ids preserve document order', () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ const BRANCH_SECTIONS = Object.freeze([
|
||||
]);
|
||||
|
||||
function facts(overrides) {
|
||||
return { waveFlag: false, phaseNumber: null, hasPriorPhases: false, ...overrides };
|
||||
return { flags: new Set(), phaseNumber: null, hasPriorPhases: false, ...overrides };
|
||||
}
|
||||
|
||||
// ─── Rows 1-8: happy path + combinations over W/D/P ─────────────────────────
|
||||
@@ -44,7 +44,7 @@ describe('W/D/P combination matrix (design doc behavior table rows 1-8)', () =>
|
||||
});
|
||||
|
||||
test('includesPartialWaveWhenWaveFlagPresent', () => {
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true }));
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ flags: new Set(['--wave']) }));
|
||||
assert.deepEqual(result.included, ['preamble', 'partial-wave']);
|
||||
assert.deepEqual(result.excluded, ['gap-closure-artifacts', 'regression-gate']);
|
||||
});
|
||||
@@ -62,13 +62,13 @@ describe('W/D/P combination matrix (design doc behavior table rows 1-8)', () =>
|
||||
});
|
||||
|
||||
test('includesBothWaveAndGapClosureWhenBothHold', () => {
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true, phaseNumber: '3.1' }));
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ flags: new Set(['--wave']), phaseNumber: '3.1' }));
|
||||
assert.deepEqual(result.included, ['preamble', 'partial-wave', 'gap-closure-artifacts']);
|
||||
assert.deepEqual(result.excluded, ['regression-gate']);
|
||||
});
|
||||
|
||||
test('includesBothWaveAndRegressionWhenBothHold', () => {
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true, hasPriorPhases: true }));
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ flags: new Set(['--wave']), hasPriorPhases: true }));
|
||||
assert.deepEqual(result.included, ['preamble', 'partial-wave', 'regression-gate']);
|
||||
assert.deepEqual(result.excluded, ['gap-closure-artifacts']);
|
||||
});
|
||||
@@ -80,7 +80,7 @@ describe('W/D/P combination matrix (design doc behavior table rows 1-8)', () =>
|
||||
});
|
||||
|
||||
test('includesEveryBranchSectionWhenAllFactsHold', () => {
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true, phaseNumber: '3.1', hasPriorPhases: true }));
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ flags: new Set(['--wave']), phaseNumber: '3.1', hasPriorPhases: true }));
|
||||
assert.deepEqual(result.included, ['preamble', 'partial-wave', 'gap-closure-artifacts', 'regression-gate']);
|
||||
assert.deepEqual(result.excluded, []);
|
||||
});
|
||||
@@ -146,7 +146,7 @@ describe('boundary section-list sizes (limit-1 / limit / limit+1)', () => {
|
||||
{ id: 's5', when: 'always' },
|
||||
{ id: 's6', when: 'state:has-prior-phases' },
|
||||
];
|
||||
const result = selectSections(sections, facts({ waveFlag: true }));
|
||||
const result = selectSections(sections, facts({ flags: new Set(['--wave']) }));
|
||||
assert.deepEqual(result.included, ['s0', 's1', 's2', 's3', 's5']);
|
||||
assert.deepEqual(result.excluded, ['s4', 's6']);
|
||||
});
|
||||
@@ -183,7 +183,7 @@ describe('determinism and input non-mutation', () => {
|
||||
{ id: 'b', when: 'flag:--wave' },
|
||||
];
|
||||
const snapshotBefore = sections.map((s) => ({ ...s }));
|
||||
const f = facts({ waveFlag: true });
|
||||
const f = facts({ flags: new Set(['--wave']) });
|
||||
|
||||
const first = selectSections(sections, f);
|
||||
const second = selectSections(sections, f);
|
||||
@@ -221,6 +221,63 @@ describe('WHEN_PREDICATES and WHEN_VOCABULARY parity (DEFECT.GENERATIVE-FIX)', (
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B11: atom↔flag-string desync (#2992 — "the key new test") ─────────────
|
||||
// For EVERY 'flag:--X' atom in the frozen WHEN_VOCABULARY, the predicate must
|
||||
// be true iff `flags={--X}` and false for `flags={}`. Derived FROM the
|
||||
// vocabulary export (never a hand-copied local list of flag names), so a
|
||||
// typo in WHEN_PREDICATES' hand-written literal map (e.g. matching the wrong
|
||||
// token) is caught behaviorally instead of only by eyeballing the diff.
|
||||
|
||||
describe('atom<->flag-string desync guard (#2992 row B11)', () => {
|
||||
const flagAtoms = WHEN_VOCABULARY.filter((w) => w.startsWith('flag:--'));
|
||||
|
||||
test('everyFlagAtomHasAtLeastOneEntryToGuard', () => {
|
||||
// Sanity: this guard is vacuous if the vocabulary somehow shipped zero
|
||||
// flag atoms — fail loudly rather than silently passing on an empty loop.
|
||||
assert.ok(flagAtoms.length > 0, 'expected at least one flag: atom in WHEN_VOCABULARY');
|
||||
});
|
||||
|
||||
for (const atom of flagAtoms) {
|
||||
// The atom's own token, derived ONLY for use as the flags-Set member in
|
||||
// this TEST (never fed back into production, which forbids exactly this
|
||||
// derivation in WHEN_PREDICATES itself — see the module doc comment).
|
||||
const token = atom.slice('flag:'.length);
|
||||
|
||||
test(`predicateForAtomMatchesItsOwnToken_${atom}`, () => {
|
||||
const included = selectSections([{ id: 'x', when: atom }], facts({ flags: new Set([token]) }));
|
||||
assert.deepEqual(included, { included: ['x'], excluded: [] }, `expected "${atom}" included when flags={${token}}`);
|
||||
|
||||
const excluded = selectSections([{ id: 'x', when: atom }], facts({ flags: new Set() }));
|
||||
assert.deepEqual(excluded, { included: [], excluded: ['x'] }, `expected "${atom}" excluded when flags={}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── B14: flags set cardinality boundary (0 / 1 / many) ────────────────────
|
||||
|
||||
describe('flags set cardinality (#2992 row B14)', () => {
|
||||
const sections = Object.freeze([
|
||||
{ id: 'a', when: 'flag:--auto' },
|
||||
{ id: 'b', when: 'flag:--discuss' },
|
||||
{ id: 'c', when: 'flag:--full' },
|
||||
]);
|
||||
|
||||
test('zeroFlagsExcludesEveryFlagSection', () => {
|
||||
const result = selectSections(sections, facts({ flags: new Set() }));
|
||||
assert.deepEqual(result, { included: [], excluded: ['a', 'b', 'c'] });
|
||||
});
|
||||
|
||||
test('oneFlagIncludesOnlyItsOwnSection', () => {
|
||||
const result = selectSections(sections, facts({ flags: new Set(['--discuss']) }));
|
||||
assert.deepEqual(result, { included: ['b'], excluded: ['a', 'c'] });
|
||||
});
|
||||
|
||||
test('manyFlagsIncludeEveryMatchingSection', () => {
|
||||
const result = selectSections(sections, facts({ flags: new Set(['--auto', '--discuss', '--full', '--irrelevant']) }));
|
||||
assert.deepEqual(result, { included: ['a', 'b', 'c'], excluded: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 24: REASON enum shape is locked ────────────────────────────────────
|
||||
|
||||
describe('REASON enum is frozen and its shape is locked', () => {
|
||||
@@ -230,6 +287,80 @@ describe('REASON enum is frozen and its shape is locked', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2992 review finding: state:needs-codebase-map / state:phase-mvp-mode /
|
||||
// state:worktrees-enabled predicate coverage ─────────────────────────────
|
||||
//
|
||||
// These three atoms were shipped (src/section-manifest.cts) with zero
|
||||
// direct predicate-level test coverage — `state:phase-mvp-mode` and
|
||||
// `state:worktrees-enabled` DO have real prod-shape integration coverage
|
||||
// (tests/init.test.cjs "init execute-phase: state:* detector degradation
|
||||
// (#2992 rows D9-D11)"), but `state:needs-codebase-map` had none anywhere.
|
||||
// Locking all three here at the evaluator level too, matching every other
|
||||
// shipped predicate's dedicated matrix test.
|
||||
|
||||
describe('state:needs-codebase-map / state:phase-mvp-mode / state:worktrees-enabled predicates', () => {
|
||||
test('needsCodebaseMapTrueWhenFactIsTrue', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:needs-codebase-map'](facts({ needsCodebaseMap: true })), true);
|
||||
});
|
||||
|
||||
test('needsCodebaseMapFalseWhenFactIsFalse', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:needs-codebase-map'](facts({ needsCodebaseMap: false })), false);
|
||||
});
|
||||
|
||||
test('needsCodebaseMapFalseWhenFactIsAbsent', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:needs-codebase-map'](facts({})));
|
||||
assert.equal(WHEN_PREDICATES['state:needs-codebase-map'](facts({})), false);
|
||||
});
|
||||
|
||||
test('needsCodebaseMapFalseWhenFactIsUndefined', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:needs-codebase-map'](facts({ needsCodebaseMap: undefined })));
|
||||
assert.equal(WHEN_PREDICATES['state:needs-codebase-map'](facts({ needsCodebaseMap: undefined })), false);
|
||||
});
|
||||
|
||||
test('phaseMvpModeTrueWhenFactIsTrue', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:phase-mvp-mode'](facts({ phaseMvpMode: true })), true);
|
||||
});
|
||||
|
||||
test('phaseMvpModeFalseWhenFactIsFalse', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:phase-mvp-mode'](facts({ phaseMvpMode: false })), false);
|
||||
});
|
||||
|
||||
test('phaseMvpModeFalseWhenFactIsAbsent', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:phase-mvp-mode'](facts({})));
|
||||
assert.equal(WHEN_PREDICATES['state:phase-mvp-mode'](facts({})), false);
|
||||
});
|
||||
|
||||
test('phaseMvpModeFalseWhenFactIsUndefined', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:phase-mvp-mode'](facts({ phaseMvpMode: undefined })));
|
||||
assert.equal(WHEN_PREDICATES['state:phase-mvp-mode'](facts({ phaseMvpMode: undefined })), false);
|
||||
});
|
||||
|
||||
test('worktreesEnabledTrueWhenFactIsTrue', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:worktrees-enabled'](facts({ worktreesEnabled: true })), true);
|
||||
});
|
||||
|
||||
test('worktreesEnabledFalseWhenFactIsFalse', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:worktrees-enabled'](facts({ worktreesEnabled: false })), false);
|
||||
});
|
||||
|
||||
test('worktreesEnabledFalseWhenFactIsAbsent', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:worktrees-enabled'](facts({})));
|
||||
assert.equal(WHEN_PREDICATES['state:worktrees-enabled'](facts({})), false);
|
||||
});
|
||||
|
||||
test('worktreesEnabledFalseWhenFactIsUndefined', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:worktrees-enabled'](facts({ worktreesEnabled: undefined })));
|
||||
assert.equal(WHEN_PREDICATES['state:worktrees-enabled'](facts({ worktreesEnabled: undefined })), false);
|
||||
});
|
||||
|
||||
test('selectSectionsIncludesNeedsCodebaseMapSectionOnlyWhenFactIsTrue', () => {
|
||||
const sections = [{ id: 'needs-map', when: 'state:needs-codebase-map' }];
|
||||
assert.deepEqual(selectSections(sections, facts({ needsCodebaseMap: true })), { included: ['needs-map'], excluded: [] });
|
||||
assert.deepEqual(selectSections(sections, facts({ needsCodebaseMap: false })), { included: [], excluded: ['needs-map'] });
|
||||
assert.deepEqual(selectSections(sections, facts({})), { included: [], excluded: ['needs-map'] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rows 25-33: Object.prototype-shaped when= values fail closed ──────────
|
||||
// Added during review — prototype-chain fail-open found by isolated
|
||||
// adversarial pass. A bracket lookup on a plain frozen object resolves
|
||||
|
||||
@@ -486,11 +486,136 @@ describe('frozen when= vocabulary', () => {
|
||||
assert.equal(Object.isFrozen(WHEN_VOCABULARY), true);
|
||||
assert.deepEqual(
|
||||
[...WHEN_VOCABULARY].sort(),
|
||||
['always', 'flag:--wave', 'state:gap-closure-phase', 'state:has-prior-phases'],
|
||||
[
|
||||
'always',
|
||||
'flag:--auto',
|
||||
'flag:--discuss',
|
||||
'flag:--forensic',
|
||||
'flag:--full',
|
||||
'flag:--research',
|
||||
'flag:--reset-phase-numbers',
|
||||
'flag:--validate',
|
||||
'flag:--wave',
|
||||
'state:gap-closure-phase',
|
||||
'state:has-prior-phases',
|
||||
'state:needs-codebase-map',
|
||||
'state:phase-mvp-mode',
|
||||
'state:worktrees-enabled',
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2992 (epic #1671 Phase 6.1): widened when= vocabulary (14 atoms) ─────
|
||||
// 50-test-matrix.md rows A2/A5/A6/A14/A19.
|
||||
|
||||
describe('widened when= vocabulary (#2992)', () => {
|
||||
// The 10 net-new atoms shipped by #2992, independent of the pre-existing 4
|
||||
// (Object.keys-style derivation of the diff would be tokenization of the
|
||||
// vocabulary itself, so this list is a deliberate hand-written literal,
|
||||
// mirroring the discipline WHEN_PREDICATES already applies).
|
||||
const NET_NEW_ATOMS = Object.freeze([
|
||||
'flag:--auto',
|
||||
'flag:--discuss',
|
||||
'flag:--forensic',
|
||||
'flag:--full',
|
||||
'flag:--research',
|
||||
'flag:--reset-phase-numbers',
|
||||
'flag:--validate',
|
||||
'state:needs-codebase-map',
|
||||
'state:phase-mvp-mode',
|
||||
'state:worktrees-enabled',
|
||||
]);
|
||||
|
||||
test('everyNetNewAtomIsInWhenVocabulary', () => {
|
||||
// Sanity that the hand-written NET_NEW_ATOMS list above has not drifted
|
||||
// from the module's own frozen export.
|
||||
for (const atom of NET_NEW_ATOMS) {
|
||||
assert.ok(WHEN_VOCABULARY.includes(atom), `expected "${atom}" in WHEN_VOCABULARY`);
|
||||
}
|
||||
});
|
||||
|
||||
test('acceptsEveryWidenedAtom (row A2)', () => {
|
||||
for (const when of NET_NEW_ATOMS) {
|
||||
const source = `<!-- gsd:section id="x" when="${when}" -->\nbody\n<!-- /gsd:section -->`;
|
||||
const sections = parseWorkflowSections(source);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.equal(explicitSections.length, 1, `expected acceptance for when="${when}"`);
|
||||
assert.equal(explicitSections[0].when, when);
|
||||
assert.equal(composeWorkflow(source), 'body\n');
|
||||
}
|
||||
});
|
||||
|
||||
test('sameAtomOnTwoDifferentSectionsIsLegal (row A19)', () => {
|
||||
// Atom reuse is legal; only `id` must be unique.
|
||||
const source = doc(
|
||||
'<!-- gsd:section id="first" when="flag:--auto" -->',
|
||||
'bodyA',
|
||||
'<!-- /gsd:section -->',
|
||||
'<!-- gsd:section id="second" when="flag:--auto" -->',
|
||||
'bodyB',
|
||||
'<!-- /gsd:section -->',
|
||||
);
|
||||
const sections = parseWorkflowSections(source);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.deepEqual(
|
||||
explicitSections.map((s) => ({ id: s.id, when: s.when })),
|
||||
[
|
||||
{ id: 'first', when: 'flag:--auto' },
|
||||
{ id: 'second', when: 'flag:--auto' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('atomMatchIsCaseSensitive (row A5)', () => {
|
||||
// A case variant of a real net-new atom must still throw — exact `===`,
|
||||
// no case folding.
|
||||
for (const when of ['Flag:--auto', 'flag:--Auto', 'FLAG:--AUTO', 'flag:--RESEARCH']) {
|
||||
const source = `<!-- gsd:section id="x" when="${when}" -->\nbody\n<!-- /gsd:section -->`;
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.UNKNOWN_WHEN,
|
||||
`expected throw for when="${when}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('atomValueIsNotTrimmed (row A6)', () => {
|
||||
// A padded value must still throw — the value is not trimmed before the
|
||||
// vocabulary membership check.
|
||||
for (const when of [' flag:--auto', 'flag:--auto ', ' state:worktrees-enabled ']) {
|
||||
const source = `<!-- gsd:section id="x" when="${when}" -->\nbody\n<!-- /gsd:section -->`;
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.UNKNOWN_WHEN,
|
||||
`expected throw for when="${when}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('crlfMarkerLineCarryingAWidenedAtomRoundTripsExactly (row A14)', () => {
|
||||
const source = [
|
||||
'prose one',
|
||||
'<!-- gsd:section id="x" when="flag:--forensic" -->',
|
||||
'crlf body',
|
||||
'<!-- /gsd:section -->',
|
||||
'prose two',
|
||||
].join('\r\n');
|
||||
const sections = parseWorkflowSections(source);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.equal(explicitSections.length, 1);
|
||||
assert.equal(explicitSections[0].when, 'flag:--forensic');
|
||||
assert.equal(explicitSections[0].body, 'crlf body\r\n');
|
||||
|
||||
const rendered = composeWorkflow(source);
|
||||
const expected = source
|
||||
.split('\r\n')
|
||||
.filter((line) => !/^<!--\s*\/?gsd:section.*-->\s*$/.test(line))
|
||||
.join('\r\n');
|
||||
assert.equal(rendered, expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rows 20-22: boundary documents ─────────────────────────────────────────
|
||||
|
||||
describe('boundary documents', () => {
|
||||
|
||||
Reference in New Issue
Block a user