* 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:
@@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user