* 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>
177 lines
7.3 KiB
JavaScript
177 lines
7.3 KiB
JavaScript
'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']);
|
|
});
|