* chore(#2932): emit a per-invocation section manifest from init Extends the init bundle with a typed per-invocation section manifest so an invocation loads only the branch guidance it will actually take. The three flag/state-gated branches in execute-phase.md move into their own step files; the parent keeps its gsd:section markers wrapping a one-line on-demand reference, so each section's prose lives in exactly one file and the parent shrinks 93369 -> 89507 bytes. A new drift-guarded generator derives the shipped section manifest from those markers, and a new pure evaluator maps invocation facts to applicable section ids. The evaluator is a lookup over the frozen WHEN_VOCABULARY, never a parser (Greenspun's Tenth Rule, ADR-1671:69); a parity test asserts the vocabulary and the predicate map stay exhaustively in sync. Closes #2932 * fix(#2932): fail closed on prototype-chain when values An isolated adversarial review found WHEN_PREDICATES[section.when] was a bracket lookup on a plain-prototype object, so inherited Object.prototype members resolved as predicates: "constructor"/"toString"/"valueOf"/ "hasOwnProperty" returned truthy and SILENTLY INCLUDED the section, and "__proto__" threw an untyped TypeError carrying no .reason. Both violate the module's documented fail-closed contract, and the manifest is read from disk at run time so it cannot be assumed trustworthy. Builds the predicate map on a null prototype and guards the lookup with an explicit Object.hasOwn check. Adds table-driven coverage for nine Object.prototype-shaped keys asserting the TYPED reason (asserting only that it throws would still pass while broken) plus a fast-check property injecting a hostile value at an arbitrary document position. * test(#2932): retarget execute-phase step assertions at extracted step files * fix(#2932): emit typed reasons for generator lib-load and write failures * fix(#2932): restore launcher preamble in extracted steps and refresh derived fixtures * chore(#2932): backfill changeset pr number to 2987 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -63,8 +63,12 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
|
||||
unknownMessage: (_subcommand: string, available: string[]) => `Unknown init workflow: ${_subcommand}\nAvailable: ${available.join(', ')}`,
|
||||
handlers: {
|
||||
'execute-phase': () => {
|
||||
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd']);
|
||||
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
|
||||
// #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'] });
|
||||
},
|
||||
'plan-phase': () => {
|
||||
const namedArgs = parseNamedArgs(args, ['granularity'], ['validate', 'tdd']);
|
||||
|
||||
133
src/init.cts
133
src/init.cts
@@ -47,6 +47,8 @@ import verificationMod = require('./verification.cjs');
|
||||
import uatPredicateMod = require('./uat-predicate.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
|
||||
import agentInstallCheck = require('./agent-install-check.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- section-manifest.cjs is compiled from section-manifest.cts's named exports; imported as a namespace to read selectSections/SelectableSection/InvocationFacts off module.exports directly (#2932).
|
||||
import sectionManifest = require('./section-manifest.cjs');
|
||||
const { checkAgentsInstalled } = agentInstallCheck;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- git-base-branch.cjs is an export= CommonJS module
|
||||
import gitBaseBranch = require('./git-base-branch.cjs');
|
||||
@@ -322,6 +324,134 @@ function getInitGitState(cwd: string): GitState {
|
||||
};
|
||||
}
|
||||
|
||||
// #2932 (Phase 5, ADR-1671): shipped, generated artifact — see
|
||||
// scripts/gen-section-manifest.cjs and gsd-core/workflows/section-manifest.json.
|
||||
// Resolved the same way model-catalog.cts resolves model-catalog.json: relative
|
||||
// to the compiled module's own directory (gsd-core/bin/lib -> gsd-core/workflows),
|
||||
// with a GSD_SECTION_MANIFEST env override so tests can point at a temp fixture
|
||||
// (missing/malformed-JSON degraded-path coverage) without mutating the shipped
|
||||
// artifact — the shipped file is a shared, concurrently-read resource across
|
||||
// parallel test runs and must never be moved/corrupted in place.
|
||||
const _sectionManifestCandidatePath = (): string =>
|
||||
process.env['GSD_SECTION_MANIFEST']
|
||||
? path.resolve(process.env['GSD_SECTION_MANIFEST'])
|
||||
: path.resolve(__dirname, '..', '..', 'workflows', 'section-manifest.json');
|
||||
|
||||
/** A manifest entry as shipped on disk: {@link sectionManifest.SelectableSection} plus the `read` step-file path. */
|
||||
interface ManifestSection extends sectionManifest.SelectableSection {
|
||||
readonly read: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
function loadSectionManifestSections(): 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 (!Array.isArray(sections)) return null;
|
||||
for (const section of sections) {
|
||||
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'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return sections as ManifestSection[];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `state:has-prior-phases` ground truth (design doc §Behavior table, regression-gate
|
||||
* body: "Skip if: this is the first phase (no prior phases)"): TRUE when at least
|
||||
* one OTHER phase directory under `.planning/phases/` contains a `*-VERIFICATION.md`
|
||||
* file. Bounded, non-throwing — an unreadable phases directory degrades to `false`
|
||||
* rather than surfacing an error from an init query.
|
||||
*/
|
||||
function detectHasPriorPhases(cwd: string, phaseInfo: Record<string, unknown> | null): boolean {
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const currentDirName = phaseInfo?.['directory']
|
||||
? path.basename(phaseInfo['directory'] as string)
|
||||
: null;
|
||||
try {
|
||||
if (!fs.existsSync(phasesDir)) return false;
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === currentDirName) continue;
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(path.join(phasesDir, entry.name));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (files.some((f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `section_manifest` init-bundle field (#2932 Deliverable 2): resolves
|
||||
* {@link sectionManifest.InvocationFacts} from this invocation, loads the generated
|
||||
* manifest, and partitions it via the pure {@link sectionManifest.selectSections}
|
||||
* evaluator. Returns `null` on any degraded condition (missing/malformed artifact,
|
||||
* 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).
|
||||
*/
|
||||
function buildSectionManifestField(
|
||||
cwd: string,
|
||||
phaseInfo: Record<string, unknown> | null,
|
||||
options: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const sections = loadSectionManifestSections();
|
||||
if (!sections) return null;
|
||||
|
||||
const rawPhaseNumber = phaseInfo?.['phase_number'];
|
||||
const phaseNumber =
|
||||
typeof rawPhaseNumber === 'string'
|
||||
? rawPhaseNumber
|
||||
: typeof rawPhaseNumber === 'number'
|
||||
? String(rawPhaseNumber)
|
||||
: null;
|
||||
|
||||
const facts: sectionManifest.InvocationFacts = {
|
||||
waveFlag: options['wave'] === true,
|
||||
phaseNumber,
|
||||
hasPriorPhases: detectHasPriorPhases(cwd, phaseInfo),
|
||||
};
|
||||
|
||||
try {
|
||||
const selection = sectionManifest.selectSections(sections, facts);
|
||||
const readById = new Map(sections.map((s) => [s.id, s.read]));
|
||||
return {
|
||||
workflow: 'execute-phase',
|
||||
included: selection.included,
|
||||
excluded: selection.excluded,
|
||||
read: selection.included
|
||||
.map((id) => readById.get(id))
|
||||
.filter((p): p is string => typeof p === 'string'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cmdInitExecutePhase(
|
||||
cwd: string,
|
||||
phase: string,
|
||||
@@ -455,6 +585,9 @@ function cmdInitExecutePhase(
|
||||
}
|
||||
}
|
||||
|
||||
// #2932 (Phase 5): additive, optional field — degrades to null, never throws.
|
||||
result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options);
|
||||
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
|
||||
182
src/section-manifest.cts
Normal file
182
src/section-manifest.cts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Section Manifest — pure `when=` evaluator over `InvocationFacts`, mapping
|
||||
* a document-order list of parsed `<!-- gsd:section -->` sections (Phase 3,
|
||||
* `src/workflow-fragments.cts`) to an included/excluded partition for one
|
||||
* concrete invocation (ADR-1671 epic #1671, Phase 5 / issue #2932,
|
||||
* `.gsd/phase/chore-2932-init-section-manifest/40-design.md`).
|
||||
*
|
||||
* Pure module: no I/O, no dependency beyond node built-ins and the sibling
|
||||
* compiled module `workflow-fragments.cjs`, whose {@link
|
||||
* workflowFragments.WHEN_VOCABULARY} is imported and never redeclared here
|
||||
* (DEFECT.GENERATIVE-FIX — a second frozen copy of the same 4 strings would
|
||||
* silently desync from the source of truth the moment either side is edited
|
||||
* without the other).
|
||||
*
|
||||
* ## 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
|
||||
* 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
|
||||
* pipeline, strict on this SEMANTIC boundary — matching the discipline
|
||||
* Phase 3 already established for the same vocabulary at parse time).
|
||||
*
|
||||
* ## Totality over facts
|
||||
*
|
||||
* Every predicate treats an absent/missing fact key as falsy WITHOUT
|
||||
* throwing — {@link InvocationFacts} is a plain data object handed in by a
|
||||
* caller (the init CLI seam) that may not always populate every field, and
|
||||
* this module must never surprise that caller with an exception for an
|
||||
* omission rather than a malformed `when=` value.
|
||||
*
|
||||
* ## Partition invariant
|
||||
*
|
||||
* {@link selectSections} returns `included` and `excluded` id arrays that
|
||||
* together contain every input section's `id` exactly once, in the SAME
|
||||
* relative document order they appeared in the input — never mutating the
|
||||
* input array or its elements.
|
||||
*
|
||||
* ADR-457 build-at-publish: compiled by tsc to
|
||||
* gsd-core/bin/lib/section-manifest.cjs (gitignored).
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- workflow-fragments.cjs is a CommonJS module compiled from a sibling .cts source; `import x = require()` reads its module.exports namespace directly.
|
||||
import workflowFragments = require('./workflow-fragments.cjs');
|
||||
|
||||
/**
|
||||
* The facts a concrete invocation supplies to the evaluator. Every field is
|
||||
* a plain, already-resolved value — no parsing, no derivation — computed by
|
||||
* 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 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;
|
||||
}
|
||||
|
||||
/** A single input to {@link selectSections}: structurally compatible with {@link workflowFragments.WorkflowSection}. */
|
||||
export interface SelectableSection {
|
||||
readonly id: string;
|
||||
readonly when: string;
|
||||
}
|
||||
|
||||
/** The result of partitioning a document-order section list against one set of {@link InvocationFacts}. */
|
||||
export interface SectionSelection {
|
||||
/** Ids of sections whose `when=` predicate held, in document order. */
|
||||
readonly included: string[];
|
||||
/** Ids of sections whose `when=` predicate did not hold, in document order. */
|
||||
readonly excluded: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen, stable reason codes for every `fail()` throw site in this module.
|
||||
* Tests assert via `assert.equal(err.reason, REASON.X)` rather than
|
||||
* regex-/substring-matching the human-readable message (CONTRIBUTING.md
|
||||
* "Prohibited: Raw Text Matching on Test Outputs"; shape copied from
|
||||
* `src/workflow-fragments.cts`'s own `REASON` export) — a message reword
|
||||
* must never silently pass a test that exists to catch a behavior
|
||||
* regression.
|
||||
*
|
||||
* Adding a new reason requires updating this map AND the test that locks
|
||||
* `Object.keys(REASON).sort()` as a coordinated change.
|
||||
*/
|
||||
export const REASON = Object.freeze({
|
||||
UNKNOWN_WHEN: 'unknown_when',
|
||||
});
|
||||
|
||||
/** A `TypeError` carrying a stable {@link REASON} code alongside the human-readable message. */
|
||||
export interface SectionManifestError extends TypeError {
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a `TypeError` naming the offending `when` value, carrying `reason`
|
||||
* (one of {@link REASON}) as a typed property so callers/tests never need
|
||||
* to pattern-match the message prose.
|
||||
*/
|
||||
function fail(reason: string, message: string): never {
|
||||
const err = new TypeError(`section-manifest: ${message}`) as TypeError & { reason: string };
|
||||
err.reason = reason;
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"):
|
||||
*
|
||||
* - `gap-closure-artifacts` — "For decimal/polish phases only (X.Y
|
||||
* pattern) … Skip if phase number has no decimal" -> `state:gap-closure-phase`.
|
||||
* - `regression-gate` — "Skip if: this is the first phase (no prior
|
||||
* phases)" -> `state:has-prior-phases`.
|
||||
* - `partial-wave` — "If `WAVE_FILTER` was used" -> `flag:--wave`.
|
||||
*/
|
||||
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,
|
||||
'state:gap-closure-phase': (facts: InvocationFacts) =>
|
||||
typeof facts.phaseNumber === 'string' && facts.phaseNumber.includes('.'),
|
||||
'state:has-prior-phases': (facts: InvocationFacts) => facts.hasPriorPhases === true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Coordinated-change guard, checked at module load: every entry of the
|
||||
// frozen WHEN_VOCABULARY (imported, never redeclared — see module doc
|
||||
// comment) must have exactly one predicate here, and vice versa. This is
|
||||
// the load-bearing half of the DEFECT.GENERATIVE-FIX parity contract; the
|
||||
// test-level half (50-test-matrix.md rows 21-23) additionally asserts it
|
||||
// from the vocabulary's own export so a 5th vocabulary entry added without
|
||||
// a predicate fails loudly rather than silently falling through to
|
||||
// REASON.UNKNOWN_WHEN only at run time.
|
||||
for (const when of workflowFragments.WHEN_VOCABULARY) {
|
||||
if (!Object.hasOwn(WHEN_PREDICATES, when)) {
|
||||
throw new Error(`section-manifest: WHEN_VOCABULARY entry "${when}" has no predicate in WHEN_PREDICATES`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition `sections` (document order) into `included`/`excluded` id
|
||||
* arrays for one set of `facts`, per {@link WHEN_PREDICATES}. Exact
|
||||
* partition: every input id appears in exactly one of the two output
|
||||
* arrays, in the same relative order it appeared in `sections`. Never
|
||||
* mutates `sections` or its elements.
|
||||
*
|
||||
* @param sections - document-order sections carrying at least `{id, when}`
|
||||
* @param facts - the concrete invocation's resolved facts
|
||||
* @throws {SectionManifestError} with `.reason = REASON.UNKNOWN_WHEN` when a
|
||||
* section's `when` value has no entry in {@link WHEN_PREDICATES} (fail
|
||||
* closed — never silently excluded).
|
||||
*/
|
||||
export function selectSections(
|
||||
sections: readonly SelectableSection[],
|
||||
facts: InvocationFacts,
|
||||
): SectionSelection {
|
||||
const included: string[] = [];
|
||||
const excluded: string[] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
if (!Object.hasOwn(WHEN_PREDICATES, section.when)) {
|
||||
fail(REASON.UNKNOWN_WHEN, `section "${section.id}" has unrecognized when= value "${section.when}"`);
|
||||
}
|
||||
const predicate = WHEN_PREDICATES[section.when];
|
||||
if (predicate(facts)) {
|
||||
included.push(section.id);
|
||||
} else {
|
||||
excluded.push(section.id);
|
||||
}
|
||||
}
|
||||
|
||||
return { included, excluded };
|
||||
}
|
||||
Reference in New Issue
Block a user