chore(#2996): inventory the workflow fragment tree as its own manifest families (#3061)

* feat(#2996): inventory the workflow fragment tree as its own families

Epic #1671 Phase 6.5, the epic's last deliverable.

47 step files across 15 workflows and 13 mode files were invisible to
docs/INVENTORY-MANIFEST.json. Not through a missed row — through construction:
buildManifest walks each family with a flat readdirSync + isFile() and never
recurses, so nothing under gsd-core/workflows/<wf>/ could ever appear. modes/
has been invisible that way since #717 without any gate firing, which is the
evidence that this is a generator gap rather than someone forgetting a row.

Two new families, workflow_steps and workflow_modes, keyed by
<workflow>/<subdir>/<file> rather than a bare basename. That is deliberate: two
workflows may each own a regression-gate.md, and a step file may share a name
with a top-level workflow. The manifest is compared by JSON equality, so a
basename collision would silently drop an entry and read as "up to date".
Recursion is bounded at exactly one named subdirectory, and a limit+1 test pins
that bound so it cannot quietly become a general walk.

tests/inventory-manifest-sync.test.cjs carried its OWN duplicate copy of the
FAMILIES table — the DEFECT.GENERATIVE-FIX divergence class. Adding a family to
the generator alone would have left that test verifying six of eight families
while still reporting green. The table now lives once in the generator and is
imported, so the two surfaces cannot drift; runMain is guarded behind
require.main so importing does not execute the CLI.

The per-file roster stays in the generated manifest rather than being copied
into INVENTORY.md: 60 hand-maintained rows in lockstep with a generated artifact
is precisely the drift this file exists to catch.

CONTEXT.md's RULESET.MANIFEST-CANONICAL-KEY and DEFECT.INVENTORY-DRIFT both said
"six families" and now say eight, with the two key shapes and the import rule
recorded. The non-shipping example index was regenerated for the same edits.

Note on scope: this issue also asked for a one-fragment-edit proof. That landed
independently as PR #3046 and is not rebuilt here.

Refs #2996

* fix(#2996): correct a fabricated roster and an inert coverage pragma

Isolated review returned one blocker and three lesser findings. All four were
real; all four are fixed.

BLOCKER — docs/INVENTORY.md claimed the workflow_modes roster was
"discuss-phase, sketch". There is no gsd-core/workflows/sketch/ and never has
been; the second member is `help` (4 mode files), exactly as the manifest
generated by this same diff already listed. A doc contradicting the manifest it
describes, in the PR whose whole purpose is closing doc/reality drift. The
adjacent hand-maintained "15 workflows" count is also removed: an unenforced
number in a table cell is the same staleness class this file exists to catch,
and no test guards table-cell counts.

MAJOR — the CLI entry guard carried `/* istanbul ignore next */`, which excludes
nothing here. This repo measures coverage with c8 (test:coverage:scripts-floor,
55% floor over scripts/**/*.cjs), and c8/v8-to-istanbul honors only
`/* c8 ignore next */`. The pragma looked like it was doing something and was
not — the same failure shape as a marker that looks like working gating.

MINOR — collectNested called statSync/readdirSync unguarded, so a dangling
symlink or an EACCES directory under any workflow's steps/ would throw uncaught
and red the manifest gate for the entire repo. An entry that cannot be statted
is, for inventory purposes, not a countable file — the same disposition as "not
a directory". Row 13c pins the behavior with a real dangling symlink.

Refs #2996

* chore(#2996): backfill changeset pr number to 3061

* test(#2996): guard the dangling-symlink row on Windows

fs.symlinkSync throws EPERM on Windows without elevation or Developer Mode, so
row 13c would red the Windows lane. Guarded with the repo's idiom — a
process.platform check plus a genuine t.skip() carrying its reason, never a bare
return, which node:test counts as a PASS and would hide the gap.

Worth recording why this was not caught here: CI classified this PR's diff as
inert (no bin/, gsd-core/, or src/ changes), so the full test matrix was SKIPPED
entirely — the 'full test (${{ matrix.os }}, ...)' job shows as skipping with
its matrix expression unexpanded. The Windows lane never ran. It would have
fired on the next PR that does touch core code, in someone else's change.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-04 18:51:12 -04:00
committed by GitHub
parent ed360cd99f
commit da062c0e0d
9 changed files with 804 additions and 433 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3061
---
**The extracted workflow fragment tree is now inventoried** — the 47 step files and 13 mode files that live under `gsd-core/workflows/<workflow>/` were invisible to `docs/INVENTORY-MANIFEST.json`, so a new one could ship with no row and no gate firing. They now have their own manifest families. (#2996)

View File

@@ -517,7 +517,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`RULESET.SHARED-HELPERS-LINT-VS-TEST=when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise`
`RULESET.ADR-HEADER=every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title`
`RULESET.MANIFEST-CANONICAL-KEY=docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL SIX families.* arrays (agents/commands/workflows/references/cli_modules/hooks) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all six, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write`
`RULESET.MANIFEST-CANONICAL-KEY=docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by <workflow>/<subdir>/<file> path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib`
`RULESET.PR-SCOPE.one-concern-per-pr=split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit`
@@ -773,7 +773,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`DEFECT.INVENTORY-DRIFT.symptom=new file added under gsd-core/references/ or gsd-core/workflows/ without updating docs/INVENTORY.md row AND docs/INVENTORY-MANIFEST.json`
`DEFECT.INVENTORY-DRIFT.examples=#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)`
`DEFECT.INVENTORY-DRIFT.detect=tests/inventory-manifest-sync.test.cjs fails with "New surfaces not in manifest"; tests/inventory-headings-countfree.test.cjs fails if a (N shipped) count is re-added to a heading`
`DEFECT.INVENTORY-DRIFT.fix-forward=update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all six families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY)`
`DEFECT.INVENTORY-DRIFT.fix-forward=update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all eight families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY); a workflow SUB-file (gsd-core/workflows/<wf>/steps/*.md or modes/*.md) lands in workflow_steps/workflow_modes, not in workflows, which is keyed by bare basename and cannot hold a nested path`
`DEFECT.AGENT-FILE-SIZE-CAP-BREACH.symptom=adding to agents/gsd-planner.md (or other large agent files) exceeds the 45K char extraction-evidence threshold`
`DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state=gsd-planner.md is 49,125 chars on main, just under the test's actual PLANNER_EXTRACTED_LIMIT of 48K (49,152 chars — the test's own title still says "45K" but the enforced constant was raised in #2341); the test currently passes, but any further net-new content risks pushing it over`

View File

@@ -347,7 +347,7 @@
{
"id": "DEFECT.INVENTORY-DRIFT.fix-forward",
"klass": "DEFECT",
"value": "update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all six families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY)"
"value": "update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all eight families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY); a workflow SUB-file (gsd-core/workflows/<wf>/steps/*.md or modes/*.md) lands in workflow_steps/workflow_modes, not in workflows, which is keyed by bare basename and cannot hold a nested path"
},
{
"id": "DEFECT.INVENTORY-DRIFT.symptom",
@@ -1792,7 +1792,7 @@
{
"id": "RULESET.MANIFEST-CANONICAL-KEY",
"klass": "RULESET",
"value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL SIX families.* arrays (agents/commands/workflows/references/cli_modules/hooks) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all six, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write"
"value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by <workflow>/<subdir>/<file> path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib"
},
{
"id": "RULESET.PR-FLOW.docker-before-push",

View File

@@ -508,6 +508,70 @@
"gsd-workflow-guard.js",
"gsd-worktree-path-guard.js",
"gsd-write-guard.js"
],
"workflow_modes": [
"discuss-phase/modes/advisor.md",
"discuss-phase/modes/all.md",
"discuss-phase/modes/analyze.md",
"discuss-phase/modes/auto.md",
"discuss-phase/modes/batch.md",
"discuss-phase/modes/chain.md",
"discuss-phase/modes/default.md",
"discuss-phase/modes/power.md",
"discuss-phase/modes/text.md",
"help/modes/brief.md",
"help/modes/default.md",
"help/modes/full.md",
"help/modes/topic.md"
],
"workflow_steps": [
"autonomous/steps/converge-banner.md",
"autonomous/steps/converge-dispatch-bg.md",
"autonomous/steps/converge-dispatch-inline.md",
"autonomous/steps/converge-fail-fast.md",
"autonomous/steps/converge-loop.md",
"code-review/steps/dispatch-fix.md",
"code-review/steps/structural-pre-pass.md",
"complete-milestone/steps/git-tag.md",
"discuss-phase-assumptions/steps/auto-advance-dispatch.md",
"docs-update/steps/dispatch-monorepo-packages.md",
"execute-phase/steps/codebase-drift-gate.md",
"execute-phase/steps/executor-isolation-dispatch.md",
"execute-phase/steps/gap-closure-artifacts.md",
"execute-phase/steps/partial-wave.md",
"execute-phase/steps/per-plan-worktree-gate.md",
"execute-phase/steps/post-merge-gate.md",
"execute-phase/steps/regression-gate-run.md",
"execute-phase/steps/regression-gate.md",
"execute-phase/steps/worktree-recovery-policy.md",
"new-milestone/steps/project-md-milestone-write.md",
"new-milestone/steps/reset-phase-safety.md",
"new-project/steps/auto-mode-config.md",
"new-project/steps/auto-mode-detection.md",
"new-project/steps/codebase-map-offer.md",
"plan-phase/steps/adr-ingest-express-path.md",
"plan-phase/steps/chunked-planning-mode.md",
"plan-phase/steps/closed-phase-gate.md",
"plan-phase/steps/prd-express-gate.md",
"plan-phase/steps/prd-express-path.md",
"plan-phase/steps/research-only-early-exit.md",
"plan-phase/steps/research-only-modifiers.md",
"plan-phase/steps/reviews-prerequisite.md",
"plan-phase/steps/stall-detection-helpers.md",
"plan-phase/steps/windows-troubleshooting.md",
"progress/steps/forensic-audit.md",
"progress/steps/mvp-display.md",
"quick/steps/discussion-phase.md",
"quick/steps/plan-checker-loop.md",
"quick/steps/quick-verification.md",
"quick/steps/research-phase.md",
"quick/steps/worktree-pre-dispatch-commit.md",
"review/steps/reviewer-instances-note-1.md",
"review/steps/reviewer-instances-note-2.md",
"transition/steps/workstream-collision-check.md",
"update/steps/channel-banner.md",
"verify-work/steps/automated-ui-verification.md",
"verify-work/steps/mvp-uat-framing.md"
]
}
}

View File

@@ -269,6 +269,26 @@ Full roster at `gsd-core/workflows/*.md`. Workflows are thin orchestrators that
> **Note:** Some workflows have no direct user-facing command (e.g. `execute-plan.md`, `verify-phase.md`, `transition.md`, `node-repair.md`, `diagnose-issues.md`) — they are invoked internally by orchestrator workflows. `discovery-phase.md` is an alternate entry for `/gsd-new-project`.
### Workflow Sub-Files
A workflow may own two kinds of sub-file. Both live under `gsd-core/workflows/<workflow>/` and
neither is separately invocable — the parent workflow reaches them.
| Subdirectory | What it holds | Manifest family | Roster |
|---|---|---|---|
| `<workflow>/steps/*.md` | Gated section bodies extracted by the fragment model (ADR-1671, epic #1671 Phases 6.1–6.3). The parent carries a `section_manifest`-gated stub; `gsd-core/workflows/section-manifest.json` names which step a given invocation reads. | `workflow_steps` | See `docs/INVENTORY-MANIFEST.json` for the authoritative per-file list |
| `<workflow>/modes/*.md` | Progressive-disclosure mode files (#717). The parent dispatches to exactly one; `discuss-phase/modes/` is the canonical example. | `workflow_modes` | `discuss-phase`, `help` |
Both families are keyed by `<workflow>/<subdir>/<file>.md` rather than a bare filename, because two
workflows may each own a step of the same name — `families.workflows` uses bare basenames and
cannot represent these without collision.
**Adding a step or mode file requires no hand-written row here.** Run
`node scripts/gen-inventory-manifest.cjs --write` (after `build:lib`) and the manifest picks it up;
`tests/inventory-manifest-sync.test.cjs` fails if you forget. The per-file roster deliberately lives
in `docs/INVENTORY-MANIFEST.json` rather than being duplicated in this table — 60 rows that must be
hand-maintained in lockstep with a generated artifact is the drift this file exists to catch.
---
## References

File diff suppressed because one or more lines are too long

View File

@@ -60,6 +60,93 @@ const FAMILIES = [
},
];
/**
* One-level-nested families (#2996, epic #1671 Phase 6.5).
*
* `buildManifest`'s flat `readdirSync` + `isFile()` walk cannot see a workflow's
* own sub-files, so `gsd-core/workflows/<wf>/steps/*.md` (the fragment tree
* extracted by Phases 6.1-6.3) and `gsd-core/workflows/<wf>/modes/*.md` (the
* #717 progressive-disclosure pattern) shipped invisible to both the manifest
* and `docs/INVENTORY.md` — exactly the `DEFECT.INVENTORY-DRIFT` class.
*
* Keyed by `<parent>/<subdir>/<file>` rather than a bare basename ON PURPOSE:
* two workflows may each own a `regression-gate.md`, and a step file may share a
* name with a top-level workflow. A basename key would let one silently
* overwrite the other, and because the manifest is compared by JSON equality a
* collision would read as "up to date".
*
* Recursion is bounded at exactly one level, by named subdirectory. It is not a
* general recursive walk.
*/
const NESTED_FAMILIES = [
{
name: 'workflow_modes',
root: path.join(ROOT, 'gsd-core', 'workflows'),
subdir: 'modes',
filter: (f) => f.endsWith('.md'),
},
{
name: 'workflow_steps',
root: path.join(ROOT, 'gsd-core', 'workflows'),
subdir: 'steps',
filter: (f) => f.endsWith('.md'),
},
];
/**
* Collect `<root>/<parent>/<subdir>/<file>` entries as POSIX-relative keys.
*
* A parent that has no such subdirectory contributes nothing, and an EMPTY
* subdirectory contributes nothing — never an empty-array key, which would be a
* committed diff that signals nothing. `statSync().isDirectory()` is checked
* before every `readdirSync` so a plain FILE named `steps` cannot throw.
*/
/**
* `fs.statSync` throws on a dangling symlink and on an EACCES-denied path. An
* entry we cannot stat is, for inventory purposes, not a countable file — the
* same disposition as "not a directory" below. Swallowing the throw here keeps
* one unreadable entry from taking down `--check` for the entire repo, which is
* a manifest generator's worst failure mode: it turns a local filesystem oddity
* into a red gate on every PR.
*/
function statOrNull(p) {
try {
return fs.statSync(p);
} catch {
return null;
}
}
function collectNested({ root, subdir, filter }) {
if (!fs.existsSync(root)) return [];
const out = [];
let parents;
try {
parents = fs.readdirSync(root);
} catch {
return [];
}
for (const parent of parents) {
const parentStat = statOrNull(path.join(root, parent));
if (!parentStat || !parentStat.isDirectory()) continue;
const nestedDir = path.join(root, parent, subdir);
const nestedStat = statOrNull(nestedDir);
if (!nestedStat || !nestedStat.isDirectory()) continue;
let files;
try {
files = fs.readdirSync(nestedDir);
} catch {
continue;
}
for (const file of files) {
const fileStat = statOrNull(path.join(nestedDir, file));
if (!fileStat || !fileStat.isFile() || !filter(file)) continue;
out.push([parent, subdir, file].join('/'));
}
}
return out.sort();
}
function buildManifest() {
const manifest = { families: {} };
for (const { name, dir, filter, toName } of FAMILIES) {
@@ -69,6 +156,9 @@ function buildManifest() {
.map(toName)
.sort();
}
for (const family of NESTED_FAMILIES) {
manifest.families[family.name] = collectNested(family);
}
return manifest;
}
@@ -109,4 +199,14 @@ function main() {
}
}
runMain(main);
/* c8 ignore next 3 -- CLI entry guard; this repo measures coverage with c8, which does not honor istanbul pragmas */
if (require.main === module) {
runMain(main);
}
// Single source of truth for the family tables (#2996). `tests/inventory-manifest-sync.test.cjs`
// previously carried its own duplicate copy of FAMILIES, which is the
// `DEFECT.GENERATIVE-FIX` divergence class: adding a family here while the test kept
// its own list meant the test silently verified fewer families than shipped, and still
// passed. The test now imports these, so the two surfaces cannot drift.
module.exports = { FAMILIES, NESTED_FAMILIES, collectNested, buildManifest };

View File

@@ -15,17 +15,12 @@ const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json');
// The `agents` row is NOT swapped to the shared listAgentFiles() helper: it is one
// row in a uniform multi-family table (each with its own filter/toName + an isFile
// guard); folding only agents in would break that uniformity.
const FAMILIES = [
{ name: 'agents', dir: path.join(ROOT, 'agents'), filter: (f) => /^gsd-.*\.md$/.test(f), toName: (f) => f.replace(/\.md$/, '') },
{ name: 'commands', dir: path.join(ROOT, 'commands', 'gsd'), filter: (f) => f.endsWith('.md'), toName: (f) => '/gsd-' + f.replace(/\.md$/, '') },
{ name: 'workflows', dir: path.join(ROOT, 'gsd-core', 'workflows'), filter: (f) => f.endsWith('.md'), toName: (f) => f },
{ name: 'references', dir: path.join(ROOT, 'gsd-core', 'references'), filter: (f) => f.endsWith('.md'), toName: (f) => f },
{ name: 'cli_modules', dir: path.join(ROOT, 'gsd-core', 'bin', 'lib'), filter: (f) => f.endsWith('.cjs'), toName: (f) => f },
{ name: 'hooks', dir: path.join(ROOT, 'hooks'), filter: (f) => /\.(js|sh)$/.test(f), toName: (f) => f },
];
// #2996: FAMILIES and NESTED_FAMILIES are IMPORTED, never redeclared. This file used to
// carry its own copy of the family table — the `DEFECT.GENERATIVE-FIX` divergence class:
// a family added to the generator but not here left this test silently verifying a
// subset while still reporting green. Importing makes divergence impossible rather than
// merely detectable.
const { FAMILIES, NESTED_FAMILIES, collectNested } = require('../scripts/gen-inventory-manifest.cjs');
test('docs/INVENTORY-MANIFEST.json matches the filesystem', () => {
const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
@@ -48,6 +43,17 @@ test('docs/INVENTORY-MANIFEST.json matches the filesystem', () => {
}
}
for (const family of NESTED_FAMILIES) {
const live = new Set(collectNested(family));
const recorded = new Set((committed.families || {})[family.name] || []);
for (const entry of live) {
if (!recorded.has(entry)) additions.push(family.name + '/' + entry);
}
for (const entry of recorded) {
if (!live.has(entry)) removals.push(family.name + '/' + entry);
}
}
const msg = [
additions.length ? 'New surfaces not in manifest (run node scripts/gen-inventory-manifest.cjs --write):\n' + additions.map((e) => ' + ' + e).join('\n') : '',
removals.length ? 'Manifest entries with no matching file:\n' + removals.map((e) => ' - ' + e).join('\n') : '',

View File

@@ -0,0 +1,176 @@
'use strict';
/**
* inventory-nested-families.test.cjs — 50-test-matrix.md rows 4, 5, 8, 9, 10, 11, 12, 13
* (issue #2996, epic #1671 Phase 6.5).
*
* `collectNested` is what makes `gsd-core/workflows/<wf>/steps/*.md` and
* `<wf>/modes/*.md` visible to docs/INVENTORY-MANIFEST.json. The real tree
* currently has no same-named step files in two workflows, so the collision
* safety that motivated PATH keying is unobservable against the live repo —
* these rows build fixture trees that DO collide, so the property is proven
* rather than assumed.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { collectNested } = require('../scripts/gen-inventory-manifest.cjs');
const { cleanup } = require('./helpers.cjs');
const MD_ONLY = (f) => f.endsWith('.md');
/** Build a fixture tree: {parentName: {subdirName: [fileNames]}}. */
function buildTree(spec) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2996-nested-'));
for (const [parent, subdirs] of Object.entries(spec)) {
for (const [subdir, files] of Object.entries(subdirs)) {
const dir = path.join(root, parent, subdir);
fs.mkdirSync(dir, { recursive: true });
for (const f of files) fs.writeFileSync(path.join(dir, f), '# fixture\n');
}
}
return root;
}
// ─── Row 4/5: PATH keying, not basename — collisions must not drop entries ────
test('row 4 — two workflows owning a same-named step file both appear', (t) => {
const root = buildTree({
alpha: { steps: ['regression-gate.md'] },
beta: { steps: ['regression-gate.md'] },
});
t.after(() => cleanup(root));
assert.deepStrictEqual(
collectNested({ root, subdir: 'steps', filter: MD_ONLY }),
['alpha/steps/regression-gate.md', 'beta/steps/regression-gate.md'],
'a basename key would collapse these to one entry and the JSON-equality check would read it as up to date',
);
});
test('row 5 — a step file named after a top-level workflow keeps its own key', (t) => {
const root = buildTree({ alpha: { steps: ['quick.md'] } });
t.after(() => cleanup(root));
assert.deepStrictEqual(
collectNested({ root, subdir: 'steps', filter: MD_ONLY }),
['alpha/steps/quick.md'],
'the nested key is a path, so it cannot be confused with the top-level workflows entry "quick.md"',
);
});
// ─── Rows 8/9/10: emptiness boundaries — limit-1 / limit ─────────────────────
test('row 8 — a parent with no such subdirectory contributes nothing', (t) => {
const root = buildTree({ alpha: { modes: ['default.md'] } });
t.after(() => cleanup(root));
assert.deepStrictEqual(
collectNested({ root, subdir: 'steps', filter: MD_ONLY }),
[],
'a workflow without a steps/ dir must contribute no key at all',
);
});
test('row 9 — an empty subdirectory contributes no entries (limit-1)', (t) => {
const root = buildTree({ alpha: { steps: [] } });
t.after(() => cleanup(root));
assert.deepStrictEqual(
collectNested({ root, subdir: 'steps', filter: MD_ONLY }),
[],
'an empty steps/ dir must not materialize an empty-array key — that is a committed diff signalling nothing',
);
});
test('row 10 — exactly one file is listed (limit)', (t) => {
const root = buildTree({ alpha: { steps: ['only.md'] } });
t.after(() => cleanup(root));
assert.deepStrictEqual(collectNested({ root, subdir: 'steps', filter: MD_ONLY }), ['alpha/steps/only.md']);
});
// ─── Row 11: recursion is bounded at ONE level (limit+1) ─────────────────────
test('row 11 — nesting deeper than one level is not swept', (t) => {
const root = buildTree({ alpha: { steps: ['top.md'] } });
const deep = path.join(root, 'alpha', 'steps', 'sub');
fs.mkdirSync(deep, { recursive: true });
fs.writeFileSync(path.join(deep, 'deeper.md'), '# fixture\n');
t.after(() => cleanup(root));
assert.deepStrictEqual(
collectNested({ root, subdir: 'steps', filter: MD_ONLY }),
['alpha/steps/top.md'],
'recursion is deliberately bounded at one named subdirectory; this row is what stops that bound from silently becoming a general walk',
);
});
// ─── Rows 12/13: filtering and hostile shapes ────────────────────────────────
test('row 12 — non-markdown files in the subdirectory are ignored', (t) => {
const root = buildTree({ alpha: { steps: ['keep.md'] } });
fs.writeFileSync(path.join(root, 'alpha', 'steps', 'notes.txt'), 'x\n');
fs.writeFileSync(path.join(root, 'alpha', 'steps', 'data.json'), '{}\n');
t.after(() => cleanup(root));
assert.deepStrictEqual(collectNested({ root, subdir: 'steps', filter: MD_ONLY }), ['alpha/steps/keep.md']);
});
test('row 13 — a plain FILE named like the subdirectory does not crash the walk', (t) => {
const root = buildTree({ alpha: { steps: ['real.md'] } });
fs.mkdirSync(path.join(root, 'beta'), { recursive: true });
fs.writeFileSync(path.join(root, 'beta', 'steps'), 'i am a file, not a directory\n');
t.after(() => cleanup(root));
assert.deepStrictEqual(
collectNested({ root, subdir: 'steps', filter: MD_ONLY }),
['alpha/steps/real.md'],
'isDirectory() must be checked before readdirSync, or a file named steps throws ENOTDIR',
);
});
test('row 13b — a missing root contributes nothing rather than throwing', () => {
assert.deepStrictEqual(
collectNested({ root: path.join(os.tmpdir(), 'gsd-2996-does-not-exist'), subdir: 'steps', filter: MD_ONLY }),
[],
);
});
// ─── Row 13c: an unstattable entry is skipped, not fatal ─────────────────────
test('row 13c — a dangling symlink under the subdirectory does not crash the walk', (t) => {
// fs.symlinkSync throws EPERM on Windows without elevation or Developer Mode.
// A genuine t.skip(), never a bare `return` — a bare return is a PASS in
// node:test and would hide the gap rather than report it.
if (process.platform === 'win32') {
t.skip('symlink creation requires elevation on Windows; the unstattable-entry path is asserted on macOS + Linux');
return;
}
const root = buildTree({ alpha: { steps: ['real.md'] } });
t.after(() => cleanup(root));
fs.symlinkSync(path.join(root, 'alpha', 'steps', 'nope.md'), path.join(root, 'alpha', 'steps', 'dangling.md'));
assert.deepStrictEqual(
collectNested({ root, subdir: 'steps', filter: MD_ONLY }),
['alpha/steps/real.md'],
'one unreadable entry must not take down manifest generation for the whole repo',
);
});
// ─── Determinism ─────────────────────────────────────────────────────────────
test('collectNested is deterministic and sorted', (t) => {
const root = buildTree({ zeta: { steps: ['b.md', 'a.md'] }, alpha: { steps: ['c.md'] } });
t.after(() => cleanup(root));
const first = collectNested({ root, subdir: 'steps', filter: MD_ONLY });
const second = collectNested({ root, subdir: 'steps', filter: MD_ONLY });
assert.deepStrictEqual(first, second);
assert.deepStrictEqual(first, ['alpha/steps/c.md', 'zeta/steps/a.md', 'zeta/steps/b.md']);
});