Files
msd-core/tests/graphify-visualization.test.cjs
Tom Boucher 107eb8c1d9 feat(#3753): run docs guards on the PR that changes the docs they read (#3787)
A PR whose diff is entirely under docs/ runs zero tests, so a guard whose INPUT
is shipped prose cannot protect the PR lane of the diffs it exists to check. Its
only firing opportunity is after merge, on the shared branch -- which is how next
went red on dacae9273 while the PR that caused it (#3746) was green on every
check.

The docs-lint job in .github/workflows/docs-required.yml -- an ALREADY-REQUIRED
context -- now selects and runs the docs guards that read the specific docs files
the PR changed.

  scripts/docs-guard-registry.cjs    test file -> the docs paths it reads (63)
  scripts/select-docs-guards.cjs     pure (changedPaths, registry) -> test files
  scripts/lint-docs-guard-registration.cjs   drift guard, wired into lint:ci

scripts/ci-test-scope.cjs is NOT touched -- `git diff origin/next --` on it is
empty -- so #764's saving stands and its 21 pinning tests are untouched.

Selection: exact path; trailing-slash directory prefix (boundary-checked --
docs/adrenaline.md does NOT match docs/adr/, which a naive startsWith gets
wrong); and '*' for the 6 entries that walk docs/ generally or read a computed
path. Unknown maps to '*' -- guessing narrow is how a guard silently stops
running. Measured: a typo fix selects 6 of 63; docs/AGENTS.md selects 12;
docs/COMMANDS.md selects 18.

Four things this got wrong first, each found by an independent reviewer or by
probe, and each having been asserted safe in a comment:

1. The registry started as a RULE in ci-test-scope.cjs's RULES, on the theory
   that classify()'s !codeChanged normalization made it inert. True for
   docs-ONLY diffs; false for MIXED docs+code diffs, where codeChanged is true
   and the normalization never runs:

     node scripts/ci-test-scope.cjs --files "docs/a.md src/semver.cts"
       with the RULE:  25 targeted_tests
       origin/next:     3 targeted_tests

   Category error: RULES is the scoped lane's input; a docs-guard registry is a
   lane manifest for a consumer that never calls classify(). Extracted; pinned
   by value.

2. The second attempt was a dedicated workflow with paths: [docs/**]. Such a
   workflow never reports on a non-docs PR, so it can never be a required
   context without hanging every non-docs PR -- and a non-required check does not
   block a merge, so the guard would have been advisory and #3753 unfixed.
   docs-required.yml already has no paths: filter, already supplies the required
   docs-lint context, already computes docs_changed, and already ran one docs
   guard gated on it. Generalizing that step needs no ruleset edit at all.

3. The registry and the drift lint were built from ONE path-segment heuristic, so
   both were blind identically -- and blind at the guard that motivated the issue.
   The reader-call regex required a character BEFORE its keyword, so a callee
   named exactly read( / load( / parse( / doc( / file( / content( could never
   match; and only an INLINE path.join(ROOT,'docs','X.md') argument was caught,
   missing the two-step-via-variable form -- the MAJORITY spelling -- plus
   template literals and concatenation. Detector 1 fired on 14 of ~450 files, so
   35 genuine guards sat unregistered while the lint reported 0 violations,
   including cursor-reviewer (reads docs/COMMANDS.md, asserts
   .includes('--cursor')) and inventory-headings-countfree. The "accepted blind
   spot" this shipped with was the common case, not a fringe.

4. With detection fixed the true population is 115 files: 63 genuine guards, 52
   incidental. Running all 63 in a REQUIRED check on a one-line typo fix is the
   cost #764 exists to avoid -- install.test.cjs is 7840 lines and reads exactly
   one docs file, docs/AGENTS.md, for its frontmatter. Dropping it reproduces the
   bug; running it for a typo elsewhere is waste. Hence the map.

Then a second review round found six more, all fixed here:

- fragment-single-edit-propagation.install.test.cjs was EXEMPTED as
  "overlay fixture only". False: it reads the real docs/registries/eos.json and
  asserts on a registry entry name, and reads the real ADR-0001 and asserts its
  H1. A docs-only PR touching either would have gone green and red next -- #3753
  shipping again, from inside the fix for it. Now registered against both paths,
  and all 52 remaining exemptions were re-audited one by one.
- The SUITES-collision guard compared RAW registry keys, but run-tests.cjs strips
  a leading `tests/` BEFORE its suite check. So it caught 'all' and missed
  'tests/all' -- the only spelling that can actually occur, since every key
  carries the prefix. One typo would have run all 824 test files inside the
  required job. Now normalized the same way run-tests.cjs normalizes.
- The lint failed OPEN on an unreadable tests dir or candidate file: 0 violations,
  ok:true. A guard that cannot read its input must never report success.
- The exemption ratchet gated identity only, so a baselined file that later
  STARTED asserting on shipped docs stayed exempt silently -- 52 permanently blind
  files. The baseline now fingerprints the docs paths each exempted file
  references and fails when that set changes, naming what changed.
- The exemption marker was still honored inside a multi-line template literal in
  the header window. The scanner now tracks template-literal and block-comment
  state.
- `git diff --name-only | grep '^docs/'` silently dropped C-quoted non-ASCII docs
  paths, making docs_changed=false a green zero-guard check. Both call sites now
  pass -c core.quotepath=false.
- The run step was gated on hashFiles(), which a force-committed
  .docs-guard-tests.txt would satisfy. The step now rm -f's both scratch files
  first and gates on an output it sets itself.

Three empty states, deliberately distinct, because conflating them rebuilds
#3753: an empty or malformed registry HARD-FAILS; docs changed with no guard
covering them logs and skips; no docs change is already gated. The middle state
must never be expressed as an empty --files-from, which prints `no tests in suite
"all"` and exits 0 -- a green check that guarded nothing. With the current
registry that state is unreachable, because the six '*' entries always match;
the branch is kept as defensive handling for a future registry and says so.

timeout-minutes: 15 bounds the required job against a hanging fork-supplied test;
it had none. npm ci was added because the job never installed dependencies -- the
previous single-file step got away without it, the registry does not.

docs/contributing/docs-guard-registration.md documents the rule, following its
sibling cross-platform-portability-rules.md, and CONTRIBUTING.md's CI Test
Quality Checks table links to it. It is also load-bearing: without a docs/ file
in the diff this PR would not have triggered its own lane, shipping an
unexercised change to a required check.

One unrelated fix, included because this PR surfaced it and CLAUDE.md forbids
deferring a defect found while working. On this branch's first CI run,
`full test (windows-latest, 24, shard 3/3)` was CANCELLED at exactly 30 minutes;
tests were still passing 0.8s before the cancel, so it is a wall-clock timeout,
not a hang, and a cancelled job reddens `Required tests`.

The cause is not this PR's test file, which costs ~60ms. Shard composition is
unstable: adding ONE file to the unit suite reshuffled 115 of 268 files between
shards, and shard 3 drew a heavier mix. Underneath that is a real pre-existing
defect. tests/ci-test-job-timeout-budget.test.cjs requires every lane's budget to
be >= 1.5x its MEASURED cost -- "a lane that got slower must be re-budgeted, not
excused" -- and its test-full entry recorded 19m from a windows-22 shard. That is
stale. Measured on `next` with none of this PR's changes present: 26m18s (run
32614439702, windows-latest/24 shard 3/3), 23m36s and 23m17s on shard 2/3. So the
lane costs ~26m and the 30-minute cap carried 1.14x headroom, not 1.5x. The gate
had been out of compliance with its own rule; this PR was merely the file
addition that reshuffled shard 3 past the cliff.

Fixed as that file prescribes: measuredMinutes 19 -> 27 with fresh evidence, and
test-full timeout-minutes 30 -> 45. The rule's minimum for 27m is 41; 45 is
deliberately above it because the reshuffle means per-shard worst case moves run
to run, and a budget pinned to the exact minimum would be re-breached by the next
test file anyone adds. Only that one job's timeout changed; test.yml's scope,
matrix and steps are untouched, so #764's saving is unaffected.

Raising that cap let the Windows shard finish (28m45s, inside 45) and uncovered
a real failure the 30-minute cancel had been masking:
`new quick-task branch branches off origin/main (#2916)` died with
`outcome=timed_out exitCode=null`, SIGTERM, at the 15000ms bound.

tests/quick-branching.test.cjs:149 `runStep` runs a `#!/usr/bin/env bash` script
executing MULTIPLE git commands, but was bound to GIT_TIMEOUT_MS (15000) -- the
norm for a SINGLE git plumbing call. tests/helpers/timeouts.cjs already documents
this exact failure and exists to fix it: HOOK_FANOUT_TIMEOUT_MS was created after
PR #3285 recorded "outcome=timed_out exitCode=null at exactly the 15000ms probe
bound while every other lane passed the same commit", and calls that "a bound
sized for the wrong class, not a slow machine". Our failure is that case
verbatim, so both sites move to the class norm rather than to a bigger number.

The same class also failed on `next` itself 21 hours earlier -- run 32608945654,
windows-latest/24 shard 1/3, `plan touching only src/ in a submodule project
keeps worktree isolation ENABLED` -- where tests/worktree-safety.test.cjs:5845
`runGate` fans out to `git config --file .gitmodules` under a hardcoded 30000.
Fixed too, since it is a defect in the tree regardless of which branch surfaced
it.

A survey of the whole tests/ tree found the same class-mismatch at further
bash fan-out sites bound under 60000ms, and the maintainer approved sweeping
them rather than leaving them latent to surface the same way one at a time. 16
fan-out sites across 16 files now use the class norm.

The sweep is class-correctness, not raising numbers until things pass. Sites
were moved ONLY where the bash body demonstrably spawns something (git, node,
npm, a CLI); self-contained shell snippets were left where they are, and are
listed as deliberately unchanged: pure if/printf bodies (copilot-install), pure
array/case builtins (code-review-pipeline-regression:638), a documented
pure-shell gsd_run stub (host-integration), single-process hook calls
(workflow-guard:222/271/302), and a deliberately tight 5000ms fast-check hook
(gsd-write-guard.property). Nothing was lowered. process-seam.test.cjs:513
(literal 300) is untouched on purpose -- it tests timeout BEHAVIOR, so raising
it would destroy what it asserts.

Shared file-level constants were the trap here, and were handled per file rather
than by redefinition: GIT_TIMEOUT_MS has ~15 users in git-base-branch and only 1
is a fan-out; WORKTREE_TIMEOUT_MS has 16 users in worktree.test.cjs and 3 are;
PROBE_TIMEOUT_MS has several in three more files. In each the CALL SITE was
changed and the constant left alone, so no single-plumbing-call site silently
inherited a 60s bound. The one exception is hooks-opt-in.test.cjs, where
HOOK_TIMEOUT_MS has exactly one consumer -- spawnHook, the fan-out itself -- so
redefining it is identical in effect and reads better.

Only two of these sites have actually been observed failing. The rest cite that
shared class and those two run ids rather than inventing evidence of their own.

Co-authored-by: sim <sim@local>
2026-08-23 21:21:21 -04:00

826 lines
32 KiB
JavaScript

'use strict';
// Tests for graphify.cjs — staleness, mvp-viz, and regressions describe blocks.
// Split from the consolidated 2336-LOC file. Refs #3761.
const { describe, test, beforeEach, afterEach, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('node:os');
const { runNode } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { BUILD_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
const {
graphifyStatus,
} = require('../gsd-core/bin/lib/graphify.cjs');
const {
enableGraphify,
writeGraphJson,
gitHead,
commitEmpty,
SAMPLE_NODES_MINIMAL,
} = require('./helpers/graphify.cjs');
// ─── staleness describe ──────────────────────────────────────────────────────
describe('staleness', () => {
// Regression for #3170: graphifyStatus surfaces built_at_commit staleness.
// graphify v0.7+ embeds `built_at_commit` into graph.json at write time.
// Tri-state on commit_stale: null means "we don't know" (pre-v0.7 graph or
// no git), which is semantically distinct from false ("known fresh").
describe('git-aware', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = createTempGitProject();
planningDir = path.join(tmpDir, '.planning');
enableGraphify(planningDir);
});
afterEach(() => cleanup(tmpDir));
test('graph rebuilt at HEAD: commits_behind=0, commit_stale=false', () => {
const head = gitHead(tmpDir);
writeGraphJson(planningDir, { nodes: SAMPLE_NODES_MINIMAL, edges: [], built_at_commit: head });
const result = graphifyStatus(tmpDir);
assert.equal(result.built_at_commit, head.slice(0, 7),
'short hash from graph.built_at_commit');
assert.equal(result.current_commit, head.slice(0, 7),
'short hash of git HEAD');
assert.equal(result.commits_behind, 0,
'zero commits between HEAD and itself');
assert.equal(result.commit_stale, false,
'commit_stale is explicitly false when commits_behind === 0');
});
test('graph 5 commits behind HEAD: commits_behind=5, commit_stale=true', () => {
const built = gitHead(tmpDir);
for (let i = 0; i < 5; i += 1) commitEmpty(tmpDir, `c${i}`);
writeGraphJson(planningDir, { nodes: SAMPLE_NODES_MINIMAL, edges: [], built_at_commit: built });
const result = graphifyStatus(tmpDir);
assert.equal(result.commits_behind, 5);
assert.equal(result.commit_stale, true);
assert.equal(result.built_at_commit, built.slice(0, 7));
assert.notEqual(result.current_commit, built.slice(0, 7),
'current_commit reflects HEAD, not graph build commit');
});
test('built_at_commit absent (pre-v0.7 graph): all four new fields null', () => {
// No built_at_commit on the graph -- GSD must not fabricate one.
writeGraphJson(planningDir, { nodes: SAMPLE_NODES_MINIMAL, edges: [] });
const result = graphifyStatus(tmpDir);
assert.equal(result.built_at_commit, null);
assert.equal(result.commits_behind, null);
assert.equal(result.commit_stale, null,
'tri-state: null means "we do not know", not "fresh"');
// current_commit may still be non-null since we are in a git repo,
// but without a baseline it cannot drive staleness.
assert.notEqual(result.current_commit, undefined,
'current_commit field is always present even when null');
});
test('rebased-away built_at_commit: commits_behind=null, commit_stale=null', () => {
// built_at_commit references a commit that never existed in this repo.
const ghostHash = '0000000000000000000000000000000000000001';
writeGraphJson(planningDir, { nodes: SAMPLE_NODES_MINIMAL, edges: [], built_at_commit: ghostHash });
const result = graphifyStatus(tmpDir);
assert.equal(result.built_at_commit, ghostHash.slice(0, 7),
'echoes the field even if unreachable -- caller can decide what to do');
assert.equal(result.commits_behind, null,
'cannot count commits to an unreachable commit');
assert.equal(result.commit_stale, null,
'unknown distance means unknown staleness');
});
test('malformed built_at_commit (dashed argv): rejected before git invocation', () => {
// Argument-injection fence: a graph.json with a hostile built_at_commit
// must never reach `git` as an argv element. The implementation should
// validate /^[0-9a-f]{4,40}$/i and treat anything else as absent.
const malicious = '--upload-pack=evil';
writeGraphJson(planningDir, { nodes: SAMPLE_NODES_MINIMAL, edges: [], built_at_commit: malicious });
const result = graphifyStatus(tmpDir);
assert.equal(result.built_at_commit, null,
'malformed value is rejected, not echoed');
assert.equal(result.commits_behind, null);
assert.equal(result.commit_stale, null);
});
});
describe('non-git cwd', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
enableGraphify(planningDir);
});
afterEach(() => cleanup(tmpDir));
test('cwd has no .git: current_commit=null, derived fields=null', () => {
const built = 'abcdef1234567890abcdef1234567890abcdef12';
writeGraphJson(planningDir, { nodes: SAMPLE_NODES_MINIMAL, edges: [], built_at_commit: built });
const result = graphifyStatus(tmpDir);
assert.equal(result.built_at_commit, built.slice(0, 7),
'graph field is echoed even without a local repo');
assert.equal(result.current_commit, null,
'no HEAD without git');
assert.equal(result.commits_behind, null);
assert.equal(result.commit_stale, null);
});
});
describe('back-compat', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = createTempGitProject();
planningDir = path.join(tmpDir, '.planning');
enableGraphify(planningDir);
writeGraphJson(planningDir, {
nodes: SAMPLE_NODES_MINIMAL,
edges: [{ source: 'n1', target: 'n2', label: 'x', confidence: 'EXTRACTED' }],
hyperedges: [],
built_at_commit: gitHead(tmpDir),
});
});
afterEach(() => cleanup(tmpDir));
test('existing fields are unchanged when commit-staleness fields are added', () => {
const result = graphifyStatus(tmpDir);
// Existing contract — must not regress.
assert.equal(result.exists, true);
assert.equal(result.node_count, 2);
assert.equal(result.edge_count, 1);
assert.equal(result.hyperedge_count, 0);
assert.equal(typeof result.last_build, 'string');
assert.equal(typeof result.stale, 'boolean',
'mtime-based stale flag stays as-is for back-compat');
assert.equal(typeof result.age_hours, 'number');
});
test('disabled response is unchanged (commit-staleness fields not added)', () => {
const tmp2 = createTempProject();
try {
const result = graphifyStatus(tmp2);
assert.equal(result.disabled, true,
'disabled path returns the existing shape, no commit fields');
assert.equal(result.built_at_commit, undefined,
'commit-staleness fields are only added on the success path');
} finally {
cleanup(tmp2);
}
});
});
});
// ─── mvp-viz describe ─────────────────────────────────────────────────────────
describe('mvp-viz', () => {
// Contract: commands/gsd/graphify.md documents MVP visual differentiation.
// Per PRD Q5: distinct node color + 'MVP' label suffix.
// Tests parse the markdown skill into structured IR (YAML frontmatter +
// fenced code blocks) and assert on the parsed structures, not raw text.
const CMD = path.join(__dirname, '..', 'commands', 'gsd', 'graphify.md');
/**
* Parse the narrow YAML subset used in this skill's frontmatter:
* key: scalar
* key:
* - item
* - item
*/
function parseSkillFrontmatter(text) {
const lines = text.split(/\r?\n/);
const out = {};
let _activeKey = null;
let activeList = null;
for (const raw of lines) {
const listItem = raw.match(/^\s+-\s+(.+?)\s*$/);
if (listItem && activeList) {
activeList.push(listItem[1]);
continue;
}
const kv = raw.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (!kv) continue;
const [, key, rawValue] = kv;
const value = rawValue.trim();
if (value === '') {
_activeKey = key;
activeList = [];
out[key] = activeList;
} else {
_activeKey = null;
activeList = null;
out[key] = value;
}
}
return out;
}
/**
* Walk markdown body line-by-line and return every fenced code block as
* { lang, content } records. Tracks fence state explicitly.
*/
function extractFencedBlocks(body) {
const lines = body.split(/\r?\n/);
const blocks = [];
let active = null;
for (const line of lines) {
const open = line.match(/^```(\S*)\s*$/);
if (active === null) {
if (open) active = { lang: open[1] || '', lines: [] };
continue;
}
if (line.trim() === '```') {
blocks.push({ lang: active.lang, content: active.lines.join('\n') });
active = null;
continue;
}
active.lines.push(line);
}
return blocks;
}
function loadSkill() {
// Local rename (`markdown` not `content`) so the no-source-grep lint
// doesn't conflate this readFileSync-bound variable with the
// `b.content.includes(...)` calls below — those operate on parsed
// fenced-block records, not raw file text.
const markdown = fs.readFileSync(CMD, 'utf8');
const lines = markdown.split(/\r?\n/);
const delims = [];
for (let i = 0; i < lines.length; i += 1) {
if (lines[i].trim() === '---') delims.push(i);
if (delims.length === 2) break;
}
assert.equal(delims.length, 2, 'graphify.md must have a closed frontmatter block');
const frontmatterText = lines.slice(delims[0] + 1, delims[1]).join('\n');
const body = lines.slice(delims[1] + 1).join('\n');
return {
frontmatter: parseSkillFrontmatter(frontmatterText),
body,
fencedBlocks: extractFencedBlocks(body),
};
}
// Parse MVP section from graphify.md body as structured IR (not raw grep).
// Extracts: mentionsMvp, colorRuleLine, labelRuleLine, fallbackLine.
function parseMvpVizContract(body) {
const lines = body.split(/\r?\n/);
const lowerLines = lines.map(line => line.toLowerCase());
const mvpLines = lines.filter(line => line.toLowerCase().includes('mvp'));
return {
mentionsMvp: mvpLines.length > 0,
colorRuleLine: mvpLines.find(line => {
const lower = line.toLowerCase();
return lower.includes('color') || lower.includes('fill') || line.includes('#');
}) || '',
labelRuleLine: mvpLines.find(line => {
const lower = line.toLowerCase();
return lower.includes('label') || lower.includes('suffix');
}) || '',
fallbackLine: lowerLines.find(line =>
(line.includes('mode') && (line.includes('null') || line.includes('absent') || line.includes('not mvp'))) ||
(line.includes('standard') && (line.includes('render') || line.includes('fallback')))
) || '',
};
}
test('graphify.md documents distinct color for MVP-mode phases', () => {
const { body } = loadSkill();
const contract = parseMvpVizContract(body);
assert.ok(contract.mentionsMvp, 'must mention MVP in color rule');
assert.ok(contract.colorRuleLine.length > 0, 'must reference a color/fill rule for MVP nodes');
});
test('graphify.md documents MVP label suffix on node text', () => {
const { body } = loadSkill();
const contract = parseMvpVizContract(body);
assert.ok(contract.labelRuleLine.length > 0, 'must add an MVP label/suffix to node text');
});
test('graphify.md specifies fallback when phase mode is null/absent', () => {
const { body } = loadSkill();
const contract = parseMvpVizContract(body);
assert.ok(contract.fallbackLine.length > 0, 'must specify fallback when mode is not mvp');
});
// Counter-test: a non-mvp phase must NOT carry mode:'mvp' in the contract.
// The fallbackLine ensures standard rendering is documented for the non-mvp case.
test('non-mvp phase render path is documented (counter-test)', () => {
const { body } = loadSkill();
const contract = parseMvpVizContract(body);
// The fallback line is required precisely because non-mvp phases exist;
// its presence is the counter-assertion that mvp rendering is NOT applied globally.
assert.ok(
contract.fallbackLine.length > 0,
'fallback documentation confirms mvp rendering is not applied to non-mvp phases',
);
// Additionally: the MVP label should only be a suffix, not a full replacement;
// so the standard label path (no MVP suffix) must be documented.
assert.ok(
contract.mentionsMvp,
'mvp mention is present, meaning mvp is treated as a special case, not the default',
);
});
});
// ─── regressions describe ─────────────────────────────────────────────────────
describe('regressions', () => {
// ── Regression for #3166 ────────────────────────────────────────────────────
// /gsd-graphify build lost artifacts because the skill spawned a Task
// sub-agent that backgrounded `graphify update .`. Sub-agent isolation
// SIGTERM'd the post-extraction phase before graph.json / graph.html /
// GRAPH_REPORT.md were written.
// Fix: skill runs the build inline in a single foreground Bash call.
// Structural fence: skill is parsed into (a) a YAML frontmatter map and
// (b) a list of fenced code blocks. Assertions run against parsed structures,
// never against raw markdown text.
const SKILL_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'graphify.md');
function parseBug3166SkillFrontmatter(text) {
const lines = text.split(/\r?\n/);
const out = {};
let _activeKey = null;
let activeList = null;
for (const raw of lines) {
const listItem = raw.match(/^\s+-\s+(.+?)\s*$/);
if (listItem && activeList) {
activeList.push(listItem[1]);
continue;
}
const kv = raw.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (!kv) continue;
const [, key, rawValue] = kv;
const value = rawValue.trim();
if (value === '') {
_activeKey = key;
activeList = [];
out[key] = activeList;
} else {
_activeKey = null;
activeList = null;
out[key] = value;
}
}
return out;
}
function extractBug3166FencedBlocks(body) {
const lines = body.split(/\r?\n/);
const blocks = [];
let active = null;
for (const line of lines) {
const open = line.match(/^```(\S*)\s*$/);
if (active === null) {
if (open) active = { lang: open[1] || '', lines: [] };
continue;
}
if (line.trim() === '```') {
blocks.push({ lang: active.lang, content: active.lines.join('\n') });
active = null;
continue;
}
active.lines.push(line);
}
return blocks;
}
function loadBug3166Skill() {
const markdown = fs.readFileSync(SKILL_PATH, 'utf8');
const lines = markdown.split(/\r?\n/);
const delims = [];
for (let i = 0; i < lines.length; i += 1) {
if (lines[i].trim() === '---') delims.push(i);
if (delims.length === 2) break;
}
assert.equal(delims.length, 2, 'graphify.md must have a closed frontmatter block');
const frontmatterText = lines.slice(delims[0] + 1, delims[1]).join('\n');
const body = lines.slice(delims[1] + 1).join('\n');
return {
frontmatter: parseBug3166SkillFrontmatter(frontmatterText),
body,
fencedBlocks: extractBug3166FencedBlocks(body),
};
}
// Regression for #3166
test('graphify.md allowed-tools does not include Task (inline build fence)', () => {
const { frontmatter } = loadBug3166Skill();
assert.ok(Array.isArray(frontmatter['allowed-tools']),
'allowed-tools must be a YAML block list');
assert.ok(frontmatter['allowed-tools'].length > 0,
'allowed-tools must declare at least one tool');
assert.ok(!frontmatter['allowed-tools'].includes('Task'),
'Task must NOT be in allowed-tools — sub-agent isolation truncates ' +
'graphify v0.7+ post-extraction phase (#3166). Build runs inline.');
});
// Regression for #3166
test('graphify.md frontmatter retains Read and Bash (inline build prerequisites)', () => {
const { frontmatter } = loadBug3166Skill();
const tools = frontmatter['allowed-tools'];
assert.ok(tools.includes('Read'), 'Read required for config gate');
assert.ok(tools.includes('Bash'), 'Bash required for inline build chain');
});
// Regression for #3166
test('no fenced code block in graphify.md invokes Task() agent spawn syntax', () => {
const { fencedBlocks } = loadBug3166Skill();
const offending = fencedBlocks.filter(b => b.content.includes('Task('));
assert.deepEqual(offending, [],
'no fenced code block in graphify.md may contain `Task(` invocation ' +
'syntax — sub-agent spawning truncates graphify v0.7+ post-extraction ' +
'phase (#3166). Prose mentioning the word "Task" is fine; only the ' +
'call expression inside a code block is forbidden.');
});
// Regression for #3166
test('a bash code block invokes the inline graphify update . pipeline', () => {
const { fencedBlocks } = loadBug3166Skill();
const bashBlocks = fencedBlocks.filter(b => b.lang === 'bash');
assert.ok(bashBlocks.length > 0, 'skill must contain at least one bash block');
assert.ok(
bashBlocks.some(b => b.content.includes('graphify update .')),
'a bash code block must invoke `graphify update .`'
);
assert.ok(
bashBlocks.some(b => /gsd_run\s+graphify build snapshot/.test(b.content)),
'a bash code block must invoke `gsd_run graphify build snapshot`'
);
});
// ── Regression for #3579 ────────────────────────────────────────────────────
// graphify auto-update hook was dead-on-arrival in 1.50.0-canary.x because:
// Gap 1: scripts/build-hooks.js HOOKS_TO_COPY did not include
// gsd-graphify-update.sh
// Gap 2: hooks/lib/gsd-graphify-rebuild.sh not copied by installer
// Test strategy: run the actual build and assert filesystem outcomes.
const REPO_ROOT_3579 = path.resolve(__dirname, '..');
const HOOKS_DIR_3579 = path.join(REPO_ROOT_3579, 'hooks');
const DIST_DIR_3579 = path.join(HOOKS_DIR_3579, 'dist');
const BUILD_SCRIPT_3579 = path.join(REPO_ROOT_3579, 'scripts', 'build-hooks.js');
const INSTALL_SCRIPT_3579 = path.join(REPO_ROOT_3579, 'bin', 'install.js');
// Regression for #3579: Gap 1 — build-hooks.js packages every top-level hooks/*.sh
describe('#3579 Gap 1: build-hooks.js packages every top-level hooks/*.sh into dist', () => {
before(() => {
const r = runNode([BUILD_SCRIPT_3579], { timeoutMs: BUILD_TIMEOUT_MS });
throwIfFailed(r, `node ${BUILD_SCRIPT_3579}`);
});
test('every top-level hooks/*.sh is emitted to hooks/dist/ by the build', () => {
const topLevelSh = fs
.readdirSync(HOOKS_DIR_3579, { withFileTypes: true })
.filter((e) => e.isFile() && e.name.endsWith('.sh'))
.map((e) => e.name);
assert.ok(topLevelSh.length > 0, 'expected at least one top-level hooks/*.sh in source');
const missing = topLevelSh.filter(
(sh) => !fs.existsSync(path.join(DIST_DIR_3579, sh))
);
assert.deepStrictEqual(
missing,
[],
`every top-level hooks/*.sh must be emitted to hooks/dist/ by scripts/build-hooks.js; missing from dist: ${JSON.stringify(missing)}`
);
});
test('hooks/dist/gsd-graphify-update.sh exists after build', () => {
assert.ok(
fs.existsSync(path.join(DIST_DIR_3579, 'gsd-graphify-update.sh')),
'expected hooks/dist/gsd-graphify-update.sh to exist after build (Gap 1)'
);
});
test('hooks/dist/lib/gsd-graphify-rebuild.sh exists after build', () => {
assert.ok(
fs.existsSync(path.join(DIST_DIR_3579, 'lib', 'gsd-graphify-rebuild.sh')),
'expected hooks/dist/lib/gsd-graphify-rebuild.sh to exist after build (Gap 2)'
);
});
});
// Regression for #3579: installer deploys graphify hook + lib helper to target
describe('#3579: installer deploys graphify hook + lib helper to target', () => {
let tmpDir;
let installStdout;
before(() => {
const r1 = runNode([BUILD_SCRIPT_3579], { timeoutMs: BUILD_TIMEOUT_MS });
throwIfFailed(r1, `node ${BUILD_SCRIPT_3579}`);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3579-install-'));
const r2 = runNode(
[INSTALL_SCRIPT_3579, '--claude', '--global', '--yes', '--no-sdk'],
{
env: { ...process.env, CLAUDE_CONFIG_DIR: tmpDir },
timeoutMs: INSTALL_TIMEOUT_MS,
}
);
throwIfFailed(r2, `node ${INSTALL_SCRIPT_3579}`);
installStdout = r2.stdout;
});
after(() => {
cleanup(tmpDir);
});
test('hooks/gsd-graphify-update.sh present at install target', () => {
const dest = path.join(tmpDir, 'hooks', 'gsd-graphify-update.sh');
assert.ok(fs.existsSync(dest), `expected ${dest} to exist after install`);
});
test('hooks/lib/gsd-graphify-rebuild.sh present at install target', () => {
const dest = path.join(tmpDir, 'hooks', 'lib', 'gsd-graphify-rebuild.sh');
assert.ok(fs.existsSync(dest), `expected ${dest} to exist after install`);
});
test('installer does not warn about missing gsd-graphify-update.sh', () => {
assert.ok(
!installStdout.includes('Missing expected hook: gsd-graphify-update.sh'),
`installer output must not warn about missing graphify hook; got:\n${installStdout}`
);
assert.ok(
!installStdout.includes(
'Skipped graphify auto-update hook — gsd-graphify-update.sh not found'
),
`installer must not skip graphify hook configuration; got:\n${installStdout}`
);
});
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-622-graphify-optional-graph-html.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-622-graphify-optional-graph-html (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #622)
// This test extracts the deployed Step 3 shell block from commands/gsd/graphify.md
// and executes it to prove that a skipped graph.html (due to the graphify HTML viz
// node limit) does not abort the chain (#622). The deployed markdown text IS the
// product surface — the block the runtime executes — so asserting on its execution
// behavior requires reading the source text.
'use strict';
/**
* Regression test for bug #622.
*
* The `/gsd-graphify build` Step 3 shell chain in commands/gsd/graphify.md
* aborted when `graph.html` was intentionally skipped (graph exceeds the HTML
* viz node limit, default 5000). The unconditional `cp graphify-out/graph.html`
* failed with "cannot stat", and the `&&` chain aborted before the
* GRAPH_REPORT.md copy, snapshot, and status steps ran.
*
* Fix: guard the graph.html copy with
* `{ [ -f graphify-out/graph.html ] && cp … || true; }`
* so the chain continues when the file is absent.
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runHook } = require('./helpers/process-seam.cjs');
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
const { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { createTempDir, cleanup, readFileNormalized } = require('./helpers.cjs');
// Path to the command doc (relative to repo root)
const GRAPHIFY_MD = path.join(__dirname, '..', 'commands', 'gsd', 'graphify.md');
/**
* Extract the Step 3 fenced bash block from graphify.md.
* The block starts with the line `graphify update .` and ends at the next
* closing ``` fence.
*
* Returns the bash source text (without the fence lines themselves).
*
* readFileNormalized() strips \r\n -> \n before the match below runs — the
* extracted block is later spawned via runHook('-c', ..., {interpreter:
* 'bash'}) in runBlock(), so an un-normalized read on a Windows checkout
* would break bash mid-script
* (DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE, #2650).
*/
function extractStep3Block() {
const content = readFileNormalized(GRAPHIFY_MD);
// Capture the full body of the ```bash fence that CONTAINS `graphify update .`
// (including any leading preamble line), without crossing into other fences.
const match = content.match(/```bash\r?\n((?:(?!```)[\s\S])*?graphify update \.(?:(?!```)[\s\S])*?)\r?\n```/);
return match ? match[1].trim() : null;
}
// ─── shared sandbox dirs ──────────────────────────────────────────────────────
let sandbox;
let fakeBin;
let fakeHome;
before(() => {
sandbox = createTempDir('gsd-622-sandbox-');
fakeBin = createTempDir('gsd-622-fakebin-');
fakeHome = createTempDir('gsd-622-fakehome-');
});
after(() => {
cleanup(sandbox);
cleanup(fakeBin);
cleanup(fakeHome);
});
// ─── helpers ─────────────────────────────────────────────────────────────────
/**
* Write a minimal fake `graphify` executable into fakeBin.
* It just exits 0 so the `graphify update .` step succeeds.
*/
function writeFakeGraphify() {
const exe = path.join(fakeBin, 'graphify');
fs.writeFileSync(exe, ['#!/bin/sh', 'exit 0'].join('\n'), { mode: 0o755 });
}
/**
* Write a minimal gsd-tools.cjs stub into fakeHome that exits 0 for any
* invocation (covers the `graphify build snapshot` and `graphify status` steps).
*/
function writeFakeGsdTools() {
const binDir = path.join(fakeHome, '.claude', 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(
path.join(binDir, 'gsd-tools.cjs'),
['#!/usr/bin/env node', 'process.exit(0);'].join('\n'),
{ mode: 0o755 },
);
}
/**
* Populate the sandbox with the minimal directory structure and output files
* that a real `graphify update .` would produce. `includeHtml` controls
* whether graphify-out/graph.html is created (simulating the node-limit skip
* when false).
*/
function populateSandbox(includeHtml) {
// graphify-out/ — simulates graphify CLI output directory
const outDir = path.join(sandbox, 'graphify-out');
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'graph.json'), '{}');
fs.writeFileSync(path.join(outDir, 'GRAPH_REPORT.md'), '# report');
if (includeHtml) {
fs.writeFileSync(path.join(outDir, 'graph.html'), '<html/>');
}
// .planning/graphs/ — destination directory
const graphsDir = path.join(sandbox, '.planning', 'graphs');
fs.mkdirSync(graphsDir, { recursive: true });
}
/**
* Execute the extracted Step 3 block in the sandbox.
*/
function runBlock(block) {
// The extracted block is a shell chain (&&, [ -f ] guards, ||) — it stays
// a `bash -c` invocation rather than being decomposed into argv. Bash
// FAN-OUT: the block spawns `graphify update .` (and further commands
// chained via && / ||) under one `bash` interpreter, not a single CLI
// probe — the wrong class for `PROBE_TIMEOUT_MS`. Same class as the
// observed CI failures in tests/quick-branching.test.cjs (PR #3787 run
// 32668773524) and tests/worktree-safety.test.cjs (`next` run
// 32608945654). See HOOK_FANOUT_TIMEOUT_MS in ./helpers/timeouts.cjs for
// the class rationale.
const r = runHook('-c', [block], {
interpreter: 'bash',
cwd: sandbox,
env: {
...process.env,
PATH: fakeBin + ':' + process.env.PATH,
HOME: fakeHome,
},
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
});
return toLegacyResult(r);
}
// ─── tests ───────────────────────────────────────────────────────────────────
describe('bug #622: graph.html absence must not abort the Step 3 shell chain', () => {
let block;
before(() => {
block = extractStep3Block();
});
test('Step 3 bash block is present in graphify.md (sanity gate)', () => {
assert.ok(block !== null, 'Step 3 bash block starting with "graphify update ." was not found in commands/gsd/graphify.md');
assert.ok(block.length > 0, 'Extracted bash block must not be empty');
});
test('graph.html absent: chain exits 0 and all other artifacts are copied (#622 regression)', (t) => {
// Use t.after for per-test cleanup so sandbox is fresh for each test
t.after(() => {
// Remove and recreate sandbox so the next test starts with an empty dir
cleanup(sandbox);
fs.mkdirSync(sandbox, { recursive: true });
});
writeFakeGraphify();
writeFakeGsdTools();
populateSandbox(false); // no graph.html — simulates node-limit skip
const result = runBlock(block);
// Chain must not abort
assert.equal(result.status, 0, [
'Expected exit 0 but got ' + result.status,
'stderr: ' + result.stderr,
'stdout: ' + result.stdout,
].join('\n'));
// graph.json was copied (step before the guarded line)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
'.planning/graphs/graph.json must be copied even when graph.html is absent',
);
// GRAPH_REPORT.md was copied (step AFTER the guarded line — key regression assertion)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
'.planning/graphs/GRAPH_REPORT.md must be copied (the chain must not abort at graph.html)',
);
// graph.html must NOT exist in the destination (correctly skipped)
assert.ok(
!fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
'.planning/graphs/graph.html must NOT be created when source is absent',
);
});
test('graph.html present: chain exits 0 and graph.html is copied (happy path)', (t) => {
t.after(() => {
cleanup(sandbox);
fs.mkdirSync(sandbox, { recursive: true });
});
writeFakeGraphify();
writeFakeGsdTools();
populateSandbox(true); // include graph.html
const result = runBlock(block);
assert.equal(result.status, 0, [
'Expected exit 0 but got ' + result.status,
'stderr: ' + result.stderr,
'stdout: ' + result.stdout,
].join('\n'));
// graph.html must exist in the destination (normal copy)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
'.planning/graphs/graph.html must be copied when the source file is present',
);
// Other artifacts also copied
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
'.planning/graphs/graph.json must be copied',
);
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
'.planning/graphs/GRAPH_REPORT.md must be copied',
);
});
});
});
}