* fix(#622): make graph.html copy optional in /gsd-graphify build chain The Step 3 shell chain in commands/gsd/graphify.md copied graphify-out/graph.html with an unconditional `cp` linked by `&&`. When a graph exceeds graphify's HTML viz node limit (default 5000), `graphify update .` deliberately omits graph.html, so the `cp` failed with "cannot stat" and aborted the chain — skipping the GRAPH_REPORT.md copy, the diff-snapshot write, and the status report, and reporting BUILD FAILED even though the graph data was rebuilt successfully. Guard the graph.html copy with `{ [ -f graphify-out/graph.html ] && cp ... || true; }`, mirroring the already-correct tolerant copy in hooks/lib/gsd-graphify-rebuild.sh. A skipped optional HTML artifact no longer aborts the chain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#622): add changeset for graph.html optional-copy fix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#622): CRLF-tolerant fence regex + allowlist the new graphify test Two CI guards flagged the new regression test: - windows-test-parity (fenceRegexLiteralNewline): the block-extraction regex matched ```bash with a literal \n, which breaks on Windows CRLF checkouts. Use ```bash\r?\n per the guard's sanctioned fix. - lint-test-file-count: the new file is a 5th test in the grapify bucket (cap 2, grandfathered at 4). Add it to the graphify allowlist files array and give the entry a real tracking issue (TBD -> 622). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/rapid-voles-hop.md
Normal file
5
.changeset/rapid-voles-hop.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 634
|
||||
---
|
||||
**`/gsd-graphify build` no longer fails when the graph is too large for an HTML visualization** — when a graph exceeds graphify's HTML viz node limit (default 5000) the `graph.html` artifact is intentionally skipped; the build pipeline now tolerates its absence instead of aborting, so `graph.json`, `GRAPH_REPORT.md`, the diff snapshot, and the status report all still complete.
|
||||
@@ -158,7 +158,7 @@ Run the build, copy artifacts, write the diff snapshot, and report the summary i
|
||||
```bash
|
||||
graphify update . \
|
||||
&& cp graphify-out/graph.json .planning/graphs/graph.json \
|
||||
&& cp graphify-out/graph.html .planning/graphs/graph.html \
|
||||
&& { [ -f graphify-out/graph.html ] && cp graphify-out/graph.html .planning/graphs/graph.html || true; } \
|
||||
&& cp graphify-out/GRAPH_REPORT.md .planning/graphs/GRAPH_REPORT.md \
|
||||
&& node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify build snapshot \
|
||||
&& node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify status
|
||||
|
||||
@@ -25,12 +25,13 @@
|
||||
},
|
||||
"graphify": {
|
||||
"files": [
|
||||
"bug-622-graphify-optional-graph-html.test.cjs",
|
||||
"graphify-auto-update.test.cjs",
|
||||
"graphify-query.test.cjs",
|
||||
"graphify-visualization.test.cjs",
|
||||
"graphify.test.cjs"
|
||||
],
|
||||
"issue": "TBD"
|
||||
"issue": "622"
|
||||
},
|
||||
"intel": {
|
||||
"files": [
|
||||
|
||||
216
tests/bug-622-graphify-optional-graph-html.test.cjs
Normal file
216
tests/bug-622-graphify-optional-graph-html.test.cjs
Normal file
@@ -0,0 +1,216 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// 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 { spawnSync } = require('child_process');
|
||||
|
||||
const { createTempDir, cleanup } = 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).
|
||||
*/
|
||||
function extractStep3Block() {
|
||||
const content = fs.readFileSync(GRAPHIFY_MD, 'utf-8');
|
||||
// Capture from the `graphify update .` line through the next closing ``` fence.
|
||||
const match = content.match(/```bash\r?\n(graphify update \.[^\0]*?)```/);
|
||||
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) {
|
||||
return spawnSync('bash', ['-c', block], {
|
||||
cwd: sandbox,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: fakeBin + ':' + process.env.PATH,
|
||||
HOME: fakeHome,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user