* 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>
213 lines
7.2 KiB
JavaScript
213 lines
7.2 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* Generates docs/INVENTORY-MANIFEST.json — a structural skeleton of every
|
|
* shipped surface derived entirely from the filesystem. Commit this file;
|
|
* CI re-runs the script and diffs. A non-empty diff means a surface shipped
|
|
* without an INVENTORY.md row.
|
|
*
|
|
* Usage:
|
|
* node scripts/gen-inventory-manifest.cjs # print to stdout
|
|
* node scripts/gen-inventory-manifest.cjs --write # write docs/INVENTORY-MANIFEST.json
|
|
* node scripts/gen-inventory-manifest.cjs --check # exit 1 if committed manifest is stale
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json');
|
|
|
|
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,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* 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) {
|
|
manifest.families[name] = fs
|
|
.readdirSync(dir)
|
|
.filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f))
|
|
.map(toName)
|
|
.sort();
|
|
}
|
|
for (const family of NESTED_FAMILIES) {
|
|
manifest.families[family.name] = collectNested(family);
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
function main() {
|
|
const [, , flag] = process.argv;
|
|
|
|
if (flag === '--check') {
|
|
const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
const live = buildManifest();
|
|
const committedStr = JSON.stringify(committed, null, 2);
|
|
const liveStr = JSON.stringify(live, null, 2);
|
|
if (committedStr !== liveStr) {
|
|
process.stderr.write(
|
|
'docs/INVENTORY-MANIFEST.json is stale. Run:\n' +
|
|
' node scripts/gen-inventory-manifest.cjs --write\n' +
|
|
'then add a matching row in docs/INVENTORY.md for each new entry.\n\n',
|
|
);
|
|
// Show diff-friendly output
|
|
for (const family of Object.keys(live.families)) {
|
|
const liveSet = new Set(live.families[family]);
|
|
const committedSet = new Set((committed.families || {})[family] || []);
|
|
for (const name of liveSet) {
|
|
if (!committedSet.has(name)) process.stderr.write(' + ' + family + '/' + name + '\n');
|
|
}
|
|
for (const name of committedSet) {
|
|
if (!liveSet.has(name)) process.stderr.write(' - ' + family + '/' + name + '\n');
|
|
}
|
|
}
|
|
throw new ExitError(1);
|
|
}
|
|
process.stdout.write('docs/INVENTORY-MANIFEST.json is up to date.\n');
|
|
} else if (flag === '--write') {
|
|
const manifest = buildManifest();
|
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
|
|
process.stdout.write('Wrote ' + MANIFEST_PATH + '\n');
|
|
} else {
|
|
process.stdout.write(JSON.stringify(buildManifest(), null, 2) + '\n');
|
|
}
|
|
}
|
|
|
|
/* 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 };
|