* fix(#2969): deterministic Step 5 verification gate for /gsd-reapply-patches The prior Step 5 "Hunk Verification Gate" was prescribed correctly in the workflow text — but executed laxly by the LLM, which filled in `verified: yes` without actually checking content presence. The reporter observed three distinct files (skills/gsd-discuss-phase/SKILL.md, skills/gsd-autonomous/ SKILL.md, get-shit-done/workflows/new-project.md) where archives contained substantive user-added blocks that did not survive into the merged result, yet the gate reported clean. Move verification from LLM-driven prose into a deterministic Node script the workflow calls. The script can't be shortcut. Changes: - scripts/verify-reapply-patches.cjs (new): pure Node, no external deps. For each file in the patches dir, computes user-added significant lines as the line-set diff between backup and pristine baseline (when available; falls back to "every significant backup line" when no pristine — over-broad but the safe direction for this bug class). Asserts each line appears literally in the merged installed file via String.prototype.includes. Filters trivial lines (length < 12 chars, pure punctuation, decorative comments) so harmless drift doesn't trigger false failures. Exits 0 on pass, 1 on any miss with per-file diagnostic, 2 on usage error. Supports --json for workflow consumption. - get-shit-done/workflows/reapply-patches.md: rewrite Step 5 to call the script and parse its JSON output. The Step 4 Hunk Verification Table remains as advisory Claude-readable summary, but the gate is now the script's exit code. - tests/bug-2969-verify-reapply-patches.test.cjs (new): 6 tests covering (a) pass when every line survives, (b) fail when a line is missing, (c) fail when the merged file is deleted entirely, (d) --json structured report shape, (e) backup-meta.json is correctly skipped as metadata, (f) no-pristine-dir fallback exercises the safe over-broad path. All pass. Out of scope: the manifest-baseline tightening described in #2969 Failure 1 (saveLocalPatches comparing against the wrong baseline so prior silent wipes poison subsequent updates). That's a separate, bigger architectural change involving pristine-content infrastructure; this PR addresses the gate fidelity half so users at least see the diagnostic when content goes missing. Closes #2969 (partial — Failure 2 only) * fix(#2969): preserve #1999 Hunk Verification Table assertions alongside new script gate CI failure on PR #2972 surfaced that tests/reapply-patches.test.cjs (the #1999 contract) asserts Step 5 references: - "Hunk Verification Table" - `verified: no` failure condition - explicit STOP/halt/abort directive - "table absent / missing" halt path My initial Step 5 rewrite for #2969 substituted the deterministic script for the table-based gate entirely, stripping those references. The script is the strictly stronger gate, but the existing #1999 test enforces the table-based safety net as a defense-in-depth contract. Restore both gates as a layered Step 5: - 5a (binding): deterministic verifier script — script gate, exits non-zero on any miss, cannot be shortcut by the LLM - 5b (advisory): Hunk Verification Table review — preserved as redundant safety net for the case where the script has a bug or the pristine baseline is unavailable Both gates must pass. Verified: tests/reapply-patches.test.cjs (5 tests in the #1999 suite) and tests/bug-2969-verify-reapply-patches.test.cjs (6 tests in the #2969 suite) all pass — 21/21 total in this fixture. * fix(#2969): address CodeRabbit findings on workflow + script Five CR findings on PR #2972, all valid; addressed in this commit: 1. (Major) Stderr was merged into VERIFY_OUTPUT via `2>&1`, so any Node warning, deprecation notice, or stack trace would corrupt the JSON parse downstream. Capture stdout only; stderr remains on the controlling terminal for operator visibility. 2. (Major) verifyFile() crashed with EISDIR/EACCES instead of producing a structured diagnostic when the installed path was a directory or unreadable. Wrap statSync/readFileSync in try/catch and emit a per-file fail row; the whole-run gate continues with structured output. Added test case asserting the directory-at-installed-path case fails with `not a regular file` diagnostic instead of crashing. 3. (Minor) PRISTINE_FLAG built as a single string + unquoted expansion would split paths with spaces. Switched to a bash array (VERIFY_ARGS) that preserves whitespace through expansion. 4. (Minor) Fenced code block missing language tag (markdownlint MD040). Added `text` tag to the error message block. 5. (Minor) Usage comment said pristine fallback was "backup-meta lookup" but the actual code path falls back to significant-line checks from backup content. Corrected the comment to match implementation. Verified all 21 tests in tests/reapply-patches.test.cjs (#1999 contract) + tests/bug-2969-verify-reapply-patches.test.cjs (now 7 tests with the new directory case) pass. * test(#2969): structured JSON assertions, no substring matching on script output Replace every assert.match(r.stdout, /pattern/) call with structured assertions on the parsed JSON report from the script's own --json mode. The script's --json contract IS the structured shape we test against — the test author should never depend on the human-readable formatter output, just as no test should depend on substring presence in source. Changes: - All 7 tests now run the verifier with --json (via a runVerifier() helper) and parse the resulting JSON document into { status, report, stderr }. Diagnostic stderr is preserved as a separate channel for debug output but is not used for assertions. - Each previously substring-matched diagnostic ("Failures: 1", "not a regular file", "installed file missing after merge", file path, dropped line) is now a deepEqual / equal / Array.includes against typed report fields: report.failures, report.results[i].status, report.results[i].reason, report.results[i].file, report.results[i].missing[]. - Added an explicit "documented shape" test asserting the JSON output has exactly the keys { file, missing, reason, status } per result — locks the public contract of the --json mode. - DRY'd up fixture reset into a resetFixture() helper since every test starts with a fresh patches/installed/pristine triple. Linter: scripts/lint-no-source-grep.cjs reports 0 violations across 348 test files. Combined run of bug-2969-...test.cjs (7 tests) + reapply-patches.test.cjs (5 tests in the #1999 suite) all pass — 22/22 in the relevant fixture. * fix(#2969): typed REASON enum + raw-text-matching rule shipped repo-wide This commit closes the loop on the no-source-grep discipline: 1. scripts/verify-reapply-patches.cjs: - Frozen REASON enum exposes the diagnostic surface as stable codes: OK_NO_USER_LINES_VS_PRISTINE, OK_NO_SIGNIFICANT_BACKUP_LINES, FAIL_INSTALLED_MISSING, FAIL_INSTALLED_NOT_REGULAR_FILE, FAIL_READ_ERROR, FAIL_USER_LINES_MISSING. - Each result.reason is now a code from this enum, not free text. Tests assert via REASON.X equality, not regex on prose. - REASON exported from module.exports. 2. tests/bug-2969-verify-reapply-patches.test.cjs: - Full rewrite. Every assertion on typed structured fields: report.results[0].status === 'fail', report.results[0].reason === REASON.FAIL_INSTALLED_NOT_REGULAR_FILE, report.results[0].missing.includes(droppedLine) (Array set membership, not String substring). - Locks the REASON enum surface via Object.keys(REASON).sort() deepEqual. - Locks the JSON report shape via Object.keys(report).sort() deepEqual. - Zero regex, zero String#includes, zero startsWith/endsWith on text. 3. CONTRIBUTING.md: - New section "Prohibited: Raw Text Matching on Test Outputs" with concrete BAD/GOOD examples (substring on file content; assert.match on stdout; "structured parser" hiding string ops; regex on free-form reason fields). - The rule statement: "Tests assert on typed structured values. If the code under test produces text, the code under test must also expose a structured intermediate representation, and the test must assert on that IR — never on the rendered text." - Required structured-surface table: file IR, --json mode, frozen enum, fs facts. - "Hiding grep behind a function is still grep" callout — the parser-wrapper anti-pattern. - New `pre-existing-text-matching` exemption category for the 8 grandfathered files. Marked Transitional; new tests cannot use it. 4. scripts/lint-no-source-grep.cjs: - Three new patterns enforced (in addition to the existing .cjs-source readFileSync rule): - assert.match/doesNotMatch on .stdout/.stderr - .stdout/.stderr.<includes|startsWith|endsWith>( - readFileSync(...).<includes|startsWith|endsWith>( - Aggregated violations per file (multiple findings now report together). - Updated diagnostic message references both CONTRIBUTING.md sections. 5. 8 pre-existing tests annotated with `// allow-test-rule: pre-existing-text-matching` so the lint passes on this commit; each carries the prose "Tracked for migration to typed-IR assertions; do not copy this pattern." Files: bug-2649, bug-2687, bug-2796, bug-2838, bug-2943, graphify, hooks-opt-in, security-scan. Verification: lint 0 violations across 348 test files; full suite passes. * fix(#2969): rename exemption category to pending-migration-to-typed-ir + cite tracking issue Per maintainer feedback: 1. "Grandfathered" / "legacy" framing is wrong — both terms imply permanent or condoned exemption. The 8 files are tracked for correction, not exempted. 2. Each annotated file must cite the tracking issue so the migration work is auditable. Changes: - CONTRIBUTING.md: rename exemption category from `pre-existing-text-matching` to `pending-migration-to-typed-ir`. Update prose to "Tracked for correction, not exempted" and require each annotation to cite the open migration issue (e.g. `// allow-test-rule: pending-migration-to-typed-ir [#NNNN]`). - 8 test files: update annotation to cite #2974 (the tracking issue opened for migrating these files to typed-IR assertions).
1132 lines
40 KiB
JavaScript
1132 lines
40 KiB
JavaScript
'use strict';
|
|
|
|
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
|
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
|
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
|
|
|
/**
|
|
* Tests for get-shit-done/bin/lib/graphify.cjs
|
|
*
|
|
* Covers: config gate on/off (TEST-03), graceful degradation (TEST-04),
|
|
* subprocess helper (FOUND-04), presence detection (FOUND-02),
|
|
* version checking (FOUND-03), and disabled response (FOUND-01).
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach, mock } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const childProcess = require('child_process');
|
|
const { createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
const {
|
|
isGraphifyEnabled,
|
|
disabledResponse,
|
|
execGraphify,
|
|
checkGraphifyInstalled,
|
|
checkGraphifyVersion,
|
|
// Phase 2
|
|
graphifyQuery,
|
|
graphifyStatus,
|
|
graphifyDiff,
|
|
safeReadJson,
|
|
buildAdjacencyMap,
|
|
seedAndExpand,
|
|
applyBudget,
|
|
// Build (Phase 3)
|
|
graphifyBuild,
|
|
writeSnapshot,
|
|
} = require('../get-shit-done/bin/lib/graphify.cjs');
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function enableGraphify(planningDir) {
|
|
const configPath = path.join(planningDir, 'config.json');
|
|
const config = fs.existsSync(configPath)
|
|
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
|
: {};
|
|
config.graphify = { enabled: true };
|
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
}
|
|
|
|
function writeGraphJson(planningDir, data) {
|
|
const graphsDir = path.join(planningDir, 'graphs');
|
|
fs.mkdirSync(graphsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(graphsDir, 'graph.json'),
|
|
JSON.stringify(data, null, 2),
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
function writeSnapshotJson(planningDir, data) {
|
|
const graphsDir = path.join(planningDir, 'graphs');
|
|
fs.mkdirSync(graphsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(graphsDir, '.last-build-snapshot.json'),
|
|
JSON.stringify(data, null, 2),
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
const SAMPLE_GRAPH = {
|
|
nodes: [
|
|
{ id: 'n1', label: 'AuthService', description: 'Handles user authentication and token validation', type: 'service' },
|
|
{ id: 'n2', label: 'UserModel', description: 'User database model for storing credentials', type: 'model' },
|
|
{ id: 'n3', label: 'SessionManager', description: 'Manages active user sessions', type: 'service' },
|
|
{ id: 'n4', label: 'EmailService', description: 'Sends notification emails', type: 'service' },
|
|
{ id: 'n5', label: 'Logger', description: 'Centralized logging utility', type: 'utility' },
|
|
],
|
|
edges: [
|
|
{ source: 'n1', target: 'n2', label: 'reads_from', confidence: 'EXTRACTED' },
|
|
{ source: 'n1', target: 'n3', label: 'creates', confidence: 'INFERRED' },
|
|
{ source: 'n2', target: 'n3', label: 'triggers', confidence: 'AMBIGUOUS' },
|
|
{ source: 'n3', target: 'n4', label: 'notifies', confidence: 'INFERRED' },
|
|
{ source: 'n4', target: 'n5', label: 'logs_via', confidence: 'EXTRACTED' },
|
|
],
|
|
hyperedges: [],
|
|
};
|
|
|
|
// ─── isGraphifyEnabled (TEST-03, FOUND-01) ──────────────────────────────────
|
|
|
|
describe('isGraphifyEnabled', () => {
|
|
let tmpDir;
|
|
let planningDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
planningDir = path.join(tmpDir, '.planning');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('returns false when no config.json exists', () => {
|
|
// Remove config.json if createTempProject wrote one
|
|
const configPath = path.join(planningDir, 'config.json');
|
|
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
assert.strictEqual(isGraphifyEnabled(planningDir), false);
|
|
});
|
|
|
|
test('returns false when graphify key is not set', () => {
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'config.json'),
|
|
JSON.stringify({ model_profile: 'balanced' }),
|
|
'utf8'
|
|
);
|
|
assert.strictEqual(isGraphifyEnabled(planningDir), false);
|
|
});
|
|
|
|
test('returns false when graphify.enabled is false', () => {
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'config.json'),
|
|
JSON.stringify({ graphify: { enabled: false } }),
|
|
'utf8'
|
|
);
|
|
assert.strictEqual(isGraphifyEnabled(planningDir), false);
|
|
});
|
|
|
|
test('returns true when graphify.enabled is true', () => {
|
|
enableGraphify(planningDir);
|
|
assert.strictEqual(isGraphifyEnabled(planningDir), true);
|
|
});
|
|
|
|
test('returns false when config.json is malformed', () => {
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'config.json'),
|
|
'not json',
|
|
'utf8'
|
|
);
|
|
assert.strictEqual(isGraphifyEnabled(planningDir), false);
|
|
});
|
|
});
|
|
|
|
// ─── disabledResponse (FOUND-01) ────────────────────────────────────────────
|
|
|
|
describe('disabledResponse', () => {
|
|
test('returns disabled:true with enable instructions', () => {
|
|
const result = disabledResponse();
|
|
assert.strictEqual(result.disabled, true);
|
|
assert.ok(result.message.includes('gsd-tools config-set graphify.enabled true'));
|
|
});
|
|
});
|
|
|
|
// ─── execGraphify (FOUND-04) ────────────────────────────────────────────────
|
|
|
|
describe('execGraphify', () => {
|
|
afterEach(() => {
|
|
mock.restoreAll();
|
|
});
|
|
|
|
test('returns structured output on success', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: 0,
|
|
stdout: '{"nodes": 42}',
|
|
stderr: '',
|
|
error: undefined,
|
|
signal: null,
|
|
}));
|
|
|
|
const result = execGraphify('/tmp', ['build']);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
assert.strictEqual(result.stdout, '{"nodes": 42}');
|
|
assert.strictEqual(result.stderr, '');
|
|
});
|
|
|
|
test('returns exitCode 127 when graphify not on PATH', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: null,
|
|
stdout: '',
|
|
stderr: '',
|
|
error: { code: 'ENOENT' },
|
|
signal: null,
|
|
}));
|
|
|
|
const result = execGraphify('/tmp', ['build']);
|
|
assert.strictEqual(result.exitCode, 127);
|
|
assert.ok(result.stderr.includes('not found'));
|
|
});
|
|
|
|
test('returns exitCode 124 on timeout', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: null,
|
|
stdout: 'partial',
|
|
stderr: '',
|
|
error: undefined,
|
|
signal: 'SIGTERM',
|
|
}));
|
|
|
|
const result = execGraphify('/tmp', ['build']);
|
|
assert.strictEqual(result.exitCode, 124);
|
|
assert.ok(result.stderr.includes('timed out'));
|
|
});
|
|
|
|
test('passes PYTHONUNBUFFERED=1 in env', () => {
|
|
let captured;
|
|
mock.method(childProcess, 'spawnSync', (_cmd, _args, opts) => {
|
|
captured = opts;
|
|
return { status: 0, stdout: '', stderr: '', error: undefined, signal: null };
|
|
});
|
|
|
|
execGraphify('/tmp', ['build']);
|
|
assert.strictEqual(captured.env.PYTHONUNBUFFERED, '1');
|
|
});
|
|
|
|
test('uses 30000ms default timeout', () => {
|
|
let captured;
|
|
mock.method(childProcess, 'spawnSync', (_cmd, _args, opts) => {
|
|
captured = opts;
|
|
return { status: 0, stdout: '', stderr: '', error: undefined, signal: null };
|
|
});
|
|
|
|
execGraphify('/tmp', ['build']);
|
|
assert.strictEqual(captured.timeout, 30000);
|
|
});
|
|
|
|
test('allows timeout override', () => {
|
|
let captured;
|
|
mock.method(childProcess, 'spawnSync', (_cmd, _args, opts) => {
|
|
captured = opts;
|
|
return { status: 0, stdout: '', stderr: '', error: undefined, signal: null };
|
|
});
|
|
|
|
execGraphify('/tmp', ['build'], { timeout: 60000 });
|
|
assert.strictEqual(captured.timeout, 60000);
|
|
});
|
|
|
|
test('trims stdout and stderr whitespace', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: 0,
|
|
stdout: ' hello \n',
|
|
stderr: ' warn \n',
|
|
error: undefined,
|
|
signal: null,
|
|
}));
|
|
|
|
const result = execGraphify('/tmp', ['build']);
|
|
assert.strictEqual(result.stdout, 'hello');
|
|
assert.strictEqual(result.stderr, 'warn');
|
|
});
|
|
});
|
|
|
|
// ─── checkGraphifyInstalled (FOUND-02, TEST-04) ────────────────────────────
|
|
|
|
describe('checkGraphifyInstalled', () => {
|
|
afterEach(() => {
|
|
mock.restoreAll();
|
|
});
|
|
|
|
test('returns installed:true when graphify is on PATH', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: 0,
|
|
stdout: 'Usage: graphify...',
|
|
stderr: '',
|
|
error: undefined,
|
|
signal: null,
|
|
}));
|
|
|
|
const result = checkGraphifyInstalled();
|
|
assert.strictEqual(result.installed, true);
|
|
});
|
|
|
|
test('returns installed:false with install instructions when not on PATH', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: null,
|
|
stdout: '',
|
|
stderr: '',
|
|
error: { code: 'ENOENT' },
|
|
signal: null,
|
|
}));
|
|
|
|
const result = checkGraphifyInstalled();
|
|
assert.strictEqual(result.installed, false);
|
|
assert.ok(result.message.includes('uv pip install graphifyy && graphify install'));
|
|
});
|
|
|
|
test('uses --help not --version for detection', () => {
|
|
let capturedArgs;
|
|
mock.method(childProcess, 'spawnSync', (_cmd, args) => {
|
|
capturedArgs = args;
|
|
return { status: 0, stdout: '', stderr: '', error: undefined, signal: null };
|
|
});
|
|
|
|
checkGraphifyInstalled();
|
|
assert.deepStrictEqual(capturedArgs, ['--help']);
|
|
});
|
|
});
|
|
|
|
// ─── checkGraphifyVersion (FOUND-03, TEST-04) ──────────────────────────────
|
|
|
|
describe('checkGraphifyVersion', () => {
|
|
afterEach(() => {
|
|
mock.restoreAll();
|
|
});
|
|
|
|
test('returns compatible:true for version 0.4.0', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: 0,
|
|
stdout: '0.4.0\n',
|
|
stderr: '',
|
|
error: undefined,
|
|
signal: null,
|
|
}));
|
|
|
|
const result = checkGraphifyVersion();
|
|
assert.strictEqual(result.version, '0.4.0');
|
|
assert.strictEqual(result.compatible, true);
|
|
assert.strictEqual(result.warning, null);
|
|
});
|
|
|
|
test('returns compatible:true for version 0.9.5', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: 0,
|
|
stdout: '0.9.5\n',
|
|
stderr: '',
|
|
error: undefined,
|
|
signal: null,
|
|
}));
|
|
|
|
const result = checkGraphifyVersion();
|
|
assert.strictEqual(result.version, '0.9.5');
|
|
assert.strictEqual(result.compatible, true);
|
|
});
|
|
|
|
test('returns compatible:false for version 0.3.0', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: 0,
|
|
stdout: '0.3.0\n',
|
|
stderr: '',
|
|
error: undefined,
|
|
signal: null,
|
|
}));
|
|
|
|
const result = checkGraphifyVersion();
|
|
assert.strictEqual(result.compatible, false);
|
|
assert.ok(result.warning.includes('outside tested range'));
|
|
});
|
|
|
|
test('returns compatible:false for version 1.0.0', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: 0,
|
|
stdout: '1.0.0\n',
|
|
stderr: '',
|
|
error: undefined,
|
|
signal: null,
|
|
}));
|
|
|
|
const result = checkGraphifyVersion();
|
|
assert.strictEqual(result.compatible, false);
|
|
assert.ok(result.warning.includes('outside tested range'));
|
|
});
|
|
|
|
test('handles python3 not found', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: null,
|
|
stdout: '',
|
|
stderr: '',
|
|
error: { code: 'ENOENT' },
|
|
signal: null,
|
|
}));
|
|
|
|
const result = checkGraphifyVersion();
|
|
assert.strictEqual(result.version, null);
|
|
assert.ok(result.warning.includes('Could not determine'));
|
|
});
|
|
|
|
test('handles unparseable version string', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: 0,
|
|
stdout: 'unknown\n',
|
|
stderr: '',
|
|
error: undefined,
|
|
signal: null,
|
|
}));
|
|
|
|
const result = checkGraphifyVersion();
|
|
assert.strictEqual(result.compatible, null);
|
|
assert.ok(result.warning.includes('Could not parse'));
|
|
});
|
|
|
|
test('tries graphify --version first before python3', () => {
|
|
const calls = [];
|
|
mock.method(childProcess, 'spawnSync', (cmd, args) => {
|
|
calls.push({ cmd, args });
|
|
return { status: 0, stdout: '0.4.3\n', stderr: '', error: undefined, signal: null };
|
|
});
|
|
|
|
checkGraphifyVersion();
|
|
assert.strictEqual(calls.length, 1, 'exactly one spawnSync call — no python3 fallback');
|
|
assert.strictEqual(calls[0].cmd, 'graphify');
|
|
assert.ok(calls[0].args.includes('--version'), 'graphify called with --version');
|
|
const python3Calls = calls.filter(c => c.cmd === 'python3');
|
|
assert.strictEqual(python3Calls.length, 0, 'no python3 fallback when graphify --version succeeds');
|
|
});
|
|
|
|
test('falls back to python3 importlib.metadata when graphify --version fails', () => {
|
|
const calls = [];
|
|
mock.method(childProcess, 'spawnSync', (cmd, args) => {
|
|
calls.push({ cmd, args });
|
|
if (cmd === 'graphify') {
|
|
return { status: 1, stdout: '', stderr: 'unknown option', error: undefined, signal: null };
|
|
}
|
|
// python3 fallback
|
|
return { status: 0, stdout: '0.4.3\n', stderr: '', error: undefined, signal: null };
|
|
});
|
|
|
|
const result = checkGraphifyVersion();
|
|
assert.strictEqual(result.version, '0.4.3');
|
|
assert.strictEqual(result.compatible, true);
|
|
assert.ok(calls.length >= 2, 'at least two spawnSync calls (graphify attempt + python3 fallback)');
|
|
assert.strictEqual(calls[0].cmd, 'graphify', 'graphify call precedes python3 fallback');
|
|
assert.ok(calls[0].args.includes('--version'), 'graphify --version attempted first');
|
|
const lastCall = calls[calls.length - 1];
|
|
assert.strictEqual(lastCall.cmd, 'python3', 'python3 fallback fires last');
|
|
assert.ok(lastCall.args.some(arg => arg.includes('importlib.metadata')));
|
|
});
|
|
});
|
|
|
|
// ─── safeReadJson (TEST-01) ────────────────────────────────────────────────
|
|
|
|
describe('safeReadJson', () => {
|
|
let tmpDir;
|
|
let planningDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
planningDir = path.join(tmpDir, '.planning');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('returns parsed object for valid JSON file', () => {
|
|
const filePath = path.join(planningDir, 'test.json');
|
|
const data = { foo: 'bar', num: 42 };
|
|
fs.writeFileSync(filePath, JSON.stringify(data), 'utf8');
|
|
const result = safeReadJson(filePath);
|
|
assert.deepStrictEqual(result, data);
|
|
});
|
|
|
|
test('returns null for malformed JSON', () => {
|
|
const filePath = path.join(planningDir, 'bad.json');
|
|
fs.writeFileSync(filePath, 'not json', 'utf8');
|
|
const result = safeReadJson(filePath);
|
|
assert.strictEqual(result, null);
|
|
});
|
|
|
|
test('returns null for non-existent file', () => {
|
|
const result = safeReadJson(path.join(planningDir, 'does-not-exist.json'));
|
|
assert.strictEqual(result, null);
|
|
});
|
|
});
|
|
|
|
// ─── buildAdjacencyMap (TEST-01) ───────────────────────────────────────────
|
|
|
|
describe('buildAdjacencyMap', () => {
|
|
test('creates bidirectional adjacency entries', () => {
|
|
const adj = buildAdjacencyMap(SAMPLE_GRAPH);
|
|
// n1 -> n2 edge exists, so adj['n1'] should have target n2 AND adj['n2'] should have target n1
|
|
assert.ok(adj['n1'].some(e => e.target === 'n2'));
|
|
assert.ok(adj['n2'].some(e => e.target === 'n1'));
|
|
});
|
|
|
|
test('initializes empty arrays for nodes without edges', () => {
|
|
const graph = {
|
|
nodes: [
|
|
...SAMPLE_GRAPH.nodes,
|
|
{ id: 'n99', label: 'Orphan', description: 'No edges', type: 'orphan' },
|
|
],
|
|
edges: SAMPLE_GRAPH.edges,
|
|
};
|
|
const adj = buildAdjacencyMap(graph);
|
|
assert.ok(Array.isArray(adj['n99']));
|
|
assert.strictEqual(adj['n99'].length, 0);
|
|
});
|
|
|
|
test('stores full edge object in adjacency entries', () => {
|
|
const adj = buildAdjacencyMap(SAMPLE_GRAPH);
|
|
const entry = adj['n1'].find(e => e.target === 'n2');
|
|
assert.ok(entry);
|
|
assert.strictEqual(entry.edge.label, 'reads_from');
|
|
assert.strictEqual(entry.edge.confidence, 'EXTRACTED');
|
|
});
|
|
|
|
// LINKS-01: graphify emits 'links' key; reader must fall back to it
|
|
test('falls back to graph.links when graph.edges is absent (LINKS-01)', () => {
|
|
const graphWithLinks = {
|
|
nodes: SAMPLE_GRAPH.nodes,
|
|
links: SAMPLE_GRAPH.edges,
|
|
};
|
|
const adj = buildAdjacencyMap(graphWithLinks);
|
|
assert.ok(adj['n1'].some(e => e.target === 'n2'), 'adjacency must traverse links');
|
|
assert.ok(adj['n2'].some(e => e.target === 'n1'), 'reverse adjacency must work');
|
|
});
|
|
});
|
|
|
|
// ─── seedAndExpand (TEST-01) ───────────────────────────────────────────────
|
|
|
|
describe('seedAndExpand', () => {
|
|
test('finds seed nodes by label match (case-insensitive)', () => {
|
|
const result = seedAndExpand(SAMPLE_GRAPH, 'auth');
|
|
assert.ok(result.seeds.has('n1'), 'AuthService should be a seed');
|
|
assert.ok(result.nodes.some(n => n.id === 'n1'));
|
|
});
|
|
|
|
test('finds seed nodes by description match', () => {
|
|
const result = seedAndExpand(SAMPLE_GRAPH, 'credentials');
|
|
assert.ok(result.seeds.has('n2'), 'UserModel description contains credentials');
|
|
assert.ok(result.nodes.some(n => n.id === 'n2'));
|
|
});
|
|
|
|
test('BFS expands 1-2 hops from seeds', () => {
|
|
// 'auth' matches n1 (label: AuthService) and n2 (description: authentication)
|
|
// n1 seeds: 1-hop -> n2, n3; 2-hop -> n4 (via n3->n4)
|
|
// n5 is 3 hops from n1 (n1->n3->n4->n5) so should NOT appear
|
|
const result = seedAndExpand(SAMPLE_GRAPH, 'auth');
|
|
const nodeIds = result.nodes.map(n => n.id);
|
|
assert.ok(nodeIds.includes('n1'), 'seed n1');
|
|
assert.ok(nodeIds.includes('n2'), '1-hop from n1');
|
|
assert.ok(nodeIds.includes('n3'), '1-hop from n1');
|
|
assert.ok(nodeIds.includes('n4'), '2-hop from n3');
|
|
// n5 is reachable only at 3 hops from n1 seeds, but n2 is also a seed
|
|
// (description contains "authentication"), and n2->n3->n4->n5 is also 3 hops
|
|
// So n5 should NOT be in results with maxHops=2
|
|
assert.ok(!nodeIds.includes('n5'), 'n5 should be beyond 2 hops');
|
|
});
|
|
|
|
test('returns empty results for no matches', () => {
|
|
const result = seedAndExpand(SAMPLE_GRAPH, 'nonexistent');
|
|
assert.strictEqual(result.nodes.length, 0);
|
|
assert.strictEqual(result.edges.length, 0);
|
|
assert.strictEqual(result.seeds.size, 0);
|
|
});
|
|
|
|
test('respects maxHops parameter', () => {
|
|
const result = seedAndExpand(SAMPLE_GRAPH, 'auth', 1);
|
|
const nodeIds = result.nodes.map(n => n.id);
|
|
assert.ok(nodeIds.includes('n1'), 'seed');
|
|
assert.ok(nodeIds.includes('n2'), '1-hop');
|
|
assert.ok(nodeIds.includes('n3'), '1-hop');
|
|
assert.ok(!nodeIds.includes('n4'), 'n4 is 2 hops away');
|
|
});
|
|
});
|
|
|
|
// ─── applyBudget (TEST-01) ─────────────────────────────────────────────────
|
|
|
|
describe('applyBudget', () => {
|
|
test('returns result unchanged when no budget', () => {
|
|
const input = { nodes: SAMPLE_GRAPH.nodes, edges: SAMPLE_GRAPH.edges, seeds: new Set(['n1']) };
|
|
const result = applyBudget(input, null);
|
|
assert.strictEqual(result.nodes, input.nodes);
|
|
assert.strictEqual(result.edges, input.edges);
|
|
});
|
|
|
|
test('drops AMBIGUOUS edges first when over budget', () => {
|
|
const input = { nodes: SAMPLE_GRAPH.nodes, edges: SAMPLE_GRAPH.edges, seeds: new Set(['n1']) };
|
|
// Set a budget small enough to trigger trimming but large enough to keep some edges
|
|
// The full graph serialized is ~600+ chars = ~150+ tokens. Use a small budget.
|
|
const result = applyBudget(input, 50);
|
|
const confidences = result.edges.map(e => e.confidence);
|
|
assert.ok(!confidences.includes('AMBIGUOUS'), 'AMBIGUOUS edges should be dropped first');
|
|
});
|
|
|
|
test('drops INFERRED edges after AMBIGUOUS', () => {
|
|
const input = { nodes: SAMPLE_GRAPH.nodes, edges: SAMPLE_GRAPH.edges, seeds: new Set(['n1']) };
|
|
// Very tight budget to force dropping both AMBIGUOUS and INFERRED
|
|
const result = applyBudget(input, 10);
|
|
const confidences = result.edges.map(e => e.confidence);
|
|
assert.ok(!confidences.includes('AMBIGUOUS'), 'AMBIGUOUS removed');
|
|
assert.ok(!confidences.includes('INFERRED'), 'INFERRED removed');
|
|
// Only EXTRACTED should remain (if any)
|
|
for (const c of confidences) {
|
|
assert.strictEqual(c, 'EXTRACTED');
|
|
}
|
|
});
|
|
|
|
test('appends trimmed footer with counts', () => {
|
|
const input = { nodes: SAMPLE_GRAPH.nodes, edges: SAMPLE_GRAPH.edges, seeds: new Set(['n1']) };
|
|
const result = applyBudget(input, 10);
|
|
assert.ok(result.trimmed !== null, 'trimmed should not be null');
|
|
assert.ok(/\d+ edges omitted/.test(result.trimmed), 'trimmed contains edge count');
|
|
assert.ok(/\d+ nodes unreachable/.test(result.trimmed), 'trimmed contains node count');
|
|
});
|
|
});
|
|
|
|
// ─── graphifyQuery (QUERY-01, QUERY-02, QUERY-03) ─────────────────────────
|
|
|
|
describe('graphifyQuery', () => {
|
|
let tmpDir;
|
|
let planningDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
planningDir = path.join(tmpDir, '.planning');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// QUERY-01: returns disabled response when graphify not enabled
|
|
test('returns disabled response when graphify not enabled', () => {
|
|
const result = graphifyQuery(tmpDir, 'auth');
|
|
assert.strictEqual(result.disabled, true);
|
|
});
|
|
|
|
// QUERY-01: returns error when graph.json does not exist
|
|
test('returns error when graph.json does not exist', () => {
|
|
enableGraphify(planningDir);
|
|
const result = graphifyQuery(tmpDir, 'auth');
|
|
assert.ok(result.error);
|
|
assert.ok(result.error.includes('No graph'));
|
|
});
|
|
|
|
// QUERY-01: returns matching nodes and edges for valid query
|
|
test('returns matching nodes and edges for valid query', () => {
|
|
enableGraphify(planningDir);
|
|
writeGraphJson(planningDir, SAMPLE_GRAPH);
|
|
const result = graphifyQuery(tmpDir, 'auth');
|
|
assert.ok(result.nodes.length > 0, 'should have matching nodes');
|
|
assert.ok(result.edges.length > 0, 'should have matching edges');
|
|
assert.strictEqual(result.term, 'auth');
|
|
});
|
|
|
|
// QUERY-03: includes confidence on edges
|
|
test('includes confidence on edges (QUERY-03)', () => {
|
|
enableGraphify(planningDir);
|
|
writeGraphJson(planningDir, SAMPLE_GRAPH);
|
|
const result = graphifyQuery(tmpDir, 'auth');
|
|
const validTiers = ['EXTRACTED', 'INFERRED', 'AMBIGUOUS'];
|
|
for (const edge of result.edges) {
|
|
assert.ok(validTiers.includes(edge.confidence), `edge confidence ${edge.confidence} is valid tier`);
|
|
}
|
|
});
|
|
|
|
// QUERY-02: respects --budget option
|
|
test('respects --budget option (QUERY-02)', () => {
|
|
enableGraphify(planningDir);
|
|
writeGraphJson(planningDir, SAMPLE_GRAPH);
|
|
const result = graphifyQuery(tmpDir, 'auth', { budget: 50 });
|
|
// With a very small budget, trimming should occur
|
|
assert.ok(result.trimmed !== null, 'trimmed should indicate budget was applied');
|
|
});
|
|
|
|
// QUERY-01: returns total_nodes and total_edges counts
|
|
test('returns total_nodes and total_edges counts', () => {
|
|
enableGraphify(planningDir);
|
|
writeGraphJson(planningDir, SAMPLE_GRAPH);
|
|
const result = graphifyQuery(tmpDir, 'auth');
|
|
assert.strictEqual(typeof result.total_nodes, 'number');
|
|
assert.strictEqual(typeof result.total_edges, 'number');
|
|
});
|
|
});
|
|
|
|
// ─── graphifyStatus (STAT-01, STAT-02) ────────────────────────────────────
|
|
|
|
describe('graphifyStatus', () => {
|
|
let tmpDir;
|
|
let planningDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
planningDir = path.join(tmpDir, '.planning');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// STAT-01: returns disabled response when not enabled
|
|
test('returns disabled response when not enabled', () => {
|
|
const result = graphifyStatus(tmpDir);
|
|
assert.strictEqual(result.disabled, true);
|
|
});
|
|
|
|
// STAT-02: returns exists:false when no graph.json
|
|
test('returns exists:false when no graph.json (STAT-02)', () => {
|
|
enableGraphify(planningDir);
|
|
const result = graphifyStatus(tmpDir);
|
|
assert.strictEqual(result.exists, false);
|
|
assert.ok(result.message.includes('No graph built yet'));
|
|
});
|
|
|
|
// STAT-01: returns status with counts when graph exists
|
|
test('returns status with counts when graph exists (STAT-01)', () => {
|
|
enableGraphify(planningDir);
|
|
writeGraphJson(planningDir, SAMPLE_GRAPH);
|
|
const result = graphifyStatus(tmpDir);
|
|
assert.strictEqual(result.exists, true);
|
|
assert.strictEqual(result.node_count, 5);
|
|
assert.strictEqual(result.edge_count, 5);
|
|
assert.strictEqual(typeof result.last_build, 'string');
|
|
assert.strictEqual(typeof result.stale, 'boolean');
|
|
assert.strictEqual(typeof result.age_hours, 'number');
|
|
});
|
|
|
|
// STAT-01: reports hyperedge_count
|
|
test('reports hyperedge_count', () => {
|
|
enableGraphify(planningDir);
|
|
const graphWithHyperedges = {
|
|
...SAMPLE_GRAPH,
|
|
hyperedges: [{ id: 'h1', nodes: ['n1', 'n2', 'n3'], label: 'auth_flow' }],
|
|
};
|
|
writeGraphJson(planningDir, graphWithHyperedges);
|
|
const result = graphifyStatus(tmpDir);
|
|
assert.strictEqual(result.hyperedge_count, 1);
|
|
});
|
|
|
|
// LINKS-02: status edge_count must read graph.links when graph.edges is absent
|
|
test('reports correct edge_count when graph uses links key (LINKS-02)', () => {
|
|
enableGraphify(planningDir);
|
|
const graphWithLinks = {
|
|
nodes: SAMPLE_GRAPH.nodes,
|
|
links: SAMPLE_GRAPH.edges,
|
|
hyperedges: [],
|
|
};
|
|
writeGraphJson(planningDir, graphWithLinks);
|
|
const result = graphifyStatus(tmpDir);
|
|
assert.strictEqual(result.edge_count, 5, 'edge_count must equal links array length');
|
|
});
|
|
});
|
|
|
|
// ─── graphifyDiff (DIFF-01, DIFF-02) ──────────────────────────────────────
|
|
|
|
describe('graphifyDiff', () => {
|
|
let tmpDir;
|
|
let planningDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
planningDir = path.join(tmpDir, '.planning');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// DIFF-01: returns disabled response when not enabled
|
|
test('returns disabled response when not enabled', () => {
|
|
const result = graphifyDiff(tmpDir);
|
|
assert.strictEqual(result.disabled, true);
|
|
});
|
|
|
|
// D-09: returns no_baseline when no snapshot exists
|
|
test('returns no_baseline when no snapshot exists (D-09)', () => {
|
|
enableGraphify(planningDir);
|
|
writeGraphJson(planningDir, SAMPLE_GRAPH);
|
|
const result = graphifyDiff(tmpDir);
|
|
assert.strictEqual(result.no_baseline, true);
|
|
assert.ok(result.message.includes('No previous snapshot'));
|
|
});
|
|
|
|
// DIFF-01: returns error when no current graph but snapshot exists
|
|
test('returns error when no current graph but snapshot exists', () => {
|
|
enableGraphify(planningDir);
|
|
writeSnapshotJson(planningDir, SAMPLE_GRAPH);
|
|
const result = graphifyDiff(tmpDir);
|
|
assert.ok(result.error);
|
|
assert.ok(result.error.includes('No current graph'));
|
|
});
|
|
|
|
// DIFF-02: detects added and removed nodes
|
|
test('detects added and removed nodes (DIFF-02)', () => {
|
|
enableGraphify(planningDir);
|
|
const snapshot = {
|
|
nodes: [
|
|
{ id: 'n1', label: 'AuthService', description: 'Auth', type: 'service' },
|
|
{ id: 'n2', label: 'UserModel', description: 'User', type: 'model' },
|
|
],
|
|
edges: [],
|
|
};
|
|
const current = {
|
|
nodes: [
|
|
{ id: 'n1', label: 'AuthService', description: 'Auth', type: 'service' },
|
|
{ id: 'n3', label: 'SessionManager', description: 'Sessions', type: 'service' },
|
|
],
|
|
edges: [],
|
|
};
|
|
writeSnapshotJson(planningDir, snapshot);
|
|
writeGraphJson(planningDir, current);
|
|
const result = graphifyDiff(tmpDir);
|
|
assert.strictEqual(result.nodes.added, 1, 'n3 added');
|
|
assert.strictEqual(result.nodes.removed, 1, 'n2 removed');
|
|
});
|
|
|
|
// DIFF-02: detects changed nodes and edges
|
|
test('detects changed nodes and edges (DIFF-02)', () => {
|
|
enableGraphify(planningDir);
|
|
const snapshot = {
|
|
nodes: [
|
|
{ id: 'n1', label: 'OldName', description: 'Auth', type: 'service' },
|
|
{ id: 'n2', label: 'UserModel', description: 'User', type: 'model' },
|
|
],
|
|
edges: [
|
|
{ source: 'n1', target: 'n2', label: 'reads_from', confidence: 'INFERRED' },
|
|
],
|
|
};
|
|
const current = {
|
|
nodes: [
|
|
{ id: 'n1', label: 'NewName', description: 'Auth', type: 'service' },
|
|
{ id: 'n2', label: 'UserModel', description: 'User', type: 'model' },
|
|
],
|
|
edges: [
|
|
{ source: 'n1', target: 'n2', label: 'reads_from', confidence: 'EXTRACTED' },
|
|
],
|
|
};
|
|
writeSnapshotJson(planningDir, snapshot);
|
|
writeGraphJson(planningDir, current);
|
|
const result = graphifyDiff(tmpDir);
|
|
assert.strictEqual(result.nodes.changed, 1, 'n1 label changed');
|
|
assert.strictEqual(result.edges.changed, 1, 'edge confidence changed');
|
|
});
|
|
|
|
// LINKS-03: diff must handle links key in both current and snapshot (LINKS-03)
|
|
test('detects edge changes when graphs use links key (LINKS-03)', () => {
|
|
enableGraphify(planningDir);
|
|
const snapshot = {
|
|
nodes: [
|
|
{ id: 'n1', label: 'AuthService', description: 'Auth', type: 'service' },
|
|
{ id: 'n2', label: 'UserModel', description: 'User', type: 'model' },
|
|
],
|
|
links: [
|
|
{ source: 'n1', target: 'n2', label: 'reads_from', confidence: 'INFERRED' },
|
|
],
|
|
};
|
|
const current = {
|
|
nodes: [
|
|
{ id: 'n1', label: 'AuthService', description: 'Auth', type: 'service' },
|
|
{ id: 'n2', label: 'UserModel', description: 'User', type: 'model' },
|
|
],
|
|
links: [
|
|
{ source: 'n1', target: 'n2', label: 'reads_from', confidence: 'EXTRACTED' },
|
|
],
|
|
};
|
|
writeSnapshotJson(planningDir, snapshot);
|
|
writeGraphJson(planningDir, current);
|
|
const result = graphifyDiff(tmpDir);
|
|
assert.strictEqual(result.edges.changed, 1, 'edge confidence change must be detected via links key');
|
|
assert.strictEqual(result.edges.added, 0);
|
|
assert.strictEqual(result.edges.removed, 0);
|
|
});
|
|
});
|
|
|
|
// ─── graphifyBuild (BUILD-01, BUILD-02, TEST-02) ────────────────────────────
|
|
|
|
describe('graphifyBuild', () => {
|
|
let tmpDir;
|
|
let planningDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
planningDir = path.join(tmpDir, '.planning');
|
|
enableGraphify(planningDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
mock.restoreAll();
|
|
});
|
|
|
|
test('returns disabled response when graphify not enabled', () => {
|
|
const tmpDir2 = createTempProject();
|
|
const result = graphifyBuild(tmpDir2);
|
|
assert.strictEqual(result.disabled, true);
|
|
cleanup(tmpDir2);
|
|
});
|
|
|
|
test('returns error when graphify not installed', () => {
|
|
mock.method(childProcess, 'spawnSync', () => ({
|
|
status: null,
|
|
stdout: '',
|
|
stderr: '',
|
|
error: { code: 'ENOENT' },
|
|
signal: null,
|
|
}));
|
|
|
|
const result = graphifyBuild(tmpDir);
|
|
assert.ok(result.error);
|
|
assert.ok(result.error.includes('not installed') || result.error.includes('pip install'));
|
|
});
|
|
|
|
test('returns spawn_agent action on successful pre-flight', () => {
|
|
mock.method(childProcess, 'spawnSync', (_cmd, args) => {
|
|
if (args && args[0] === '--help') {
|
|
return { status: 0, stdout: 'Usage', stderr: '', error: undefined, signal: null };
|
|
}
|
|
// version check via python3
|
|
return { status: 0, stdout: '0.4.3\n', stderr: '', error: undefined, signal: null };
|
|
});
|
|
|
|
const result = graphifyBuild(tmpDir);
|
|
assert.strictEqual(result.action, 'spawn_agent');
|
|
assert.ok(result.graphs_dir);
|
|
assert.ok(result.graphify_out);
|
|
assert.strictEqual(result.timeout_seconds, 300);
|
|
assert.strictEqual(result.version, '0.4.3');
|
|
assert.strictEqual(result.version_warning, null);
|
|
assert.deepStrictEqual(result.artifacts, ['graph.json', 'graph.html', 'GRAPH_REPORT.md']);
|
|
});
|
|
|
|
test('creates .planning/graphs/ directory if missing', () => {
|
|
mock.method(childProcess, 'spawnSync', (_cmd, args) => {
|
|
if (args && args[0] === '--help') {
|
|
return { status: 0, stdout: 'Usage', stderr: '', error: undefined, signal: null };
|
|
}
|
|
return { status: 0, stdout: '0.4.3\n', stderr: '', error: undefined, signal: null };
|
|
});
|
|
|
|
const graphsDir = path.join(planningDir, 'graphs');
|
|
assert.strictEqual(fs.existsSync(graphsDir), false);
|
|
|
|
graphifyBuild(tmpDir);
|
|
assert.strictEqual(fs.existsSync(graphsDir), true);
|
|
});
|
|
|
|
test('reads graphify.build_timeout from config', () => {
|
|
// Write config with custom timeout
|
|
const configPath = path.join(planningDir, 'config.json');
|
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
config.graphify.build_timeout = 600;
|
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
|
|
mock.method(childProcess, 'spawnSync', (_cmd, args) => {
|
|
if (args && args[0] === '--help') {
|
|
return { status: 0, stdout: 'Usage', stderr: '', error: undefined, signal: null };
|
|
}
|
|
return { status: 0, stdout: '0.4.3\n', stderr: '', error: undefined, signal: null };
|
|
});
|
|
|
|
const result = graphifyBuild(tmpDir);
|
|
assert.strictEqual(result.timeout_seconds, 600);
|
|
});
|
|
|
|
test('includes version warning when outside tested range', () => {
|
|
mock.method(childProcess, 'spawnSync', (_cmd, args) => {
|
|
if (args && args[0] === '--help') {
|
|
return { status: 0, stdout: 'Usage', stderr: '', error: undefined, signal: null };
|
|
}
|
|
return { status: 0, stdout: '1.2.0\n', stderr: '', error: undefined, signal: null };
|
|
});
|
|
|
|
const result = graphifyBuild(tmpDir);
|
|
assert.strictEqual(result.action, 'spawn_agent');
|
|
assert.ok(result.version_warning);
|
|
assert.ok(result.version_warning.includes('outside tested range'));
|
|
});
|
|
});
|
|
|
|
// ─── writeSnapshot (BUILD-01, TEST-02) ──────────────────────────────────────
|
|
|
|
describe('writeSnapshot', () => {
|
|
let tmpDir;
|
|
let planningDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
planningDir = path.join(tmpDir, '.planning');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('writes snapshot from existing graph.json', () => {
|
|
const graphData = {
|
|
nodes: [{ id: 'A', label: 'Node A' }, { id: 'B', label: 'Node B' }],
|
|
edges: [{ source: 'A', target: 'B', label: 'relates' }],
|
|
};
|
|
writeGraphJson(planningDir, graphData);
|
|
|
|
const result = writeSnapshot(tmpDir);
|
|
assert.strictEqual(result.saved, true);
|
|
assert.strictEqual(result.node_count, 2);
|
|
assert.strictEqual(result.edge_count, 1);
|
|
assert.ok(result.timestamp);
|
|
|
|
// Verify file was actually written
|
|
const snapshotPath = path.join(planningDir, 'graphs', '.last-build-snapshot.json');
|
|
assert.strictEqual(fs.existsSync(snapshotPath), true);
|
|
|
|
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
|
|
assert.strictEqual(snapshot.version, 1);
|
|
assert.strictEqual(snapshot.nodes.length, 2);
|
|
assert.strictEqual(snapshot.edges.length, 1);
|
|
assert.ok(snapshot.timestamp);
|
|
});
|
|
|
|
test('returns error when graph.json does not exist', () => {
|
|
// graphs directory exists but no graph.json
|
|
fs.mkdirSync(path.join(planningDir, 'graphs'), { recursive: true });
|
|
|
|
const result = writeSnapshot(tmpDir);
|
|
assert.ok(result.error);
|
|
assert.ok(result.error.includes('not parseable'));
|
|
});
|
|
|
|
test('returns error when graph.json is invalid JSON', () => {
|
|
const graphsDir = path.join(planningDir, 'graphs');
|
|
fs.mkdirSync(graphsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(graphsDir, 'graph.json'), 'not valid json{{{', 'utf8');
|
|
|
|
const result = writeSnapshot(tmpDir);
|
|
assert.ok(result.error);
|
|
assert.ok(result.error.includes('not parseable'));
|
|
});
|
|
|
|
test('handles graph.json with empty nodes and edges', () => {
|
|
writeGraphJson(planningDir, { nodes: [], edges: [] });
|
|
|
|
const result = writeSnapshot(tmpDir);
|
|
assert.strictEqual(result.saved, true);
|
|
assert.strictEqual(result.node_count, 0);
|
|
assert.strictEqual(result.edge_count, 0);
|
|
});
|
|
|
|
test('handles graph.json missing nodes/edges keys gracefully', () => {
|
|
writeGraphJson(planningDir, { metadata: { tool: 'graphify' } });
|
|
|
|
const result = writeSnapshot(tmpDir);
|
|
assert.strictEqual(result.saved, true);
|
|
assert.strictEqual(result.node_count, 0);
|
|
assert.strictEqual(result.edge_count, 0);
|
|
});
|
|
|
|
test('overwrites existing snapshot on rebuild', () => {
|
|
// Write initial graph and snapshot
|
|
writeGraphJson(planningDir, {
|
|
nodes: [{ id: 'A' }],
|
|
edges: [],
|
|
});
|
|
writeSnapshot(tmpDir);
|
|
|
|
// Write updated graph with more nodes
|
|
writeGraphJson(planningDir, {
|
|
nodes: [{ id: 'A' }, { id: 'B' }, { id: 'C' }],
|
|
edges: [{ source: 'A', target: 'B' }],
|
|
});
|
|
|
|
const result = writeSnapshot(tmpDir);
|
|
assert.strictEqual(result.saved, true);
|
|
assert.strictEqual(result.node_count, 3);
|
|
assert.strictEqual(result.edge_count, 1);
|
|
|
|
// Verify file reflects latest data
|
|
const snapshotPath = path.join(planningDir, 'graphs', '.last-build-snapshot.json');
|
|
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
|
|
assert.strictEqual(snapshot.nodes.length, 3);
|
|
});
|
|
});
|
|
|
|
// --- AGENT-03: Graceful degradation (graph absent) -------------------------
|
|
|
|
describe('AGENT-03 graceful degradation', () => {
|
|
let tmpDir;
|
|
let planningDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
planningDir = path.join(tmpDir, '.planning');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// AGENT-03: graphifyQuery returns error object when graph.json absent (not exception)
|
|
test('graphifyQuery returns clean error object when graph.json does not exist', () => {
|
|
enableGraphify(planningDir);
|
|
const result = graphifyQuery(tmpDir, 'anything');
|
|
assert.ok(result.error, 'should have error property');
|
|
assert.ok(result.error.includes('No graph'), 'error should mention no graph');
|
|
assert.strictEqual(typeof result.error, 'string', 'error should be a string, not thrown');
|
|
});
|
|
|
|
// AGENT-03: graphifyStatus returns exists:false when graph.json absent (not exception)
|
|
test('graphifyStatus returns exists:false when graph.json does not exist', () => {
|
|
enableGraphify(planningDir);
|
|
const result = graphifyStatus(tmpDir);
|
|
assert.strictEqual(result.exists, false, 'should report exists as false');
|
|
assert.ok(result.message, 'should have a message');
|
|
assert.ok(result.message.includes('No graph'), 'message should mention no graph');
|
|
});
|
|
|
|
// AGENT-03: graphifyQuery with various terms all return clean errors when no graph
|
|
test('graphifyQuery gracefully handles any query term when graph absent', () => {
|
|
enableGraphify(planningDir);
|
|
const terms = ['auth', 'payment', 'nonexistent', ''];
|
|
for (const term of terms) {
|
|
const result = graphifyQuery(tmpDir, term);
|
|
assert.ok(result.error || result.nodes !== undefined,
|
|
`term "${term}" should return error or valid result, not throw`);
|
|
}
|
|
});
|
|
|
|
// D-12: Integration test - query returns expected structure with known graph.json
|
|
test('graphifyQuery returns non-empty results with expected structure for known graph', () => {
|
|
enableGraphify(planningDir);
|
|
writeGraphJson(planningDir, SAMPLE_GRAPH);
|
|
const result = graphifyQuery(tmpDir, 'auth');
|
|
assert.ok(!result.error, 'should not have error when graph exists');
|
|
assert.ok(Array.isArray(result.nodes), 'nodes should be an array');
|
|
assert.ok(Array.isArray(result.edges), 'edges should be an array');
|
|
assert.ok(result.nodes.length > 0, 'should have matching nodes for auth term');
|
|
assert.strictEqual(typeof result.total_nodes, 'number', 'total_nodes should be a number');
|
|
assert.strictEqual(typeof result.total_edges, 'number', 'total_edges should be a number');
|
|
assert.strictEqual(result.term, 'auth', 'term should be echoed back');
|
|
});
|
|
|
|
// D-12: graphifyStatus returns valid structure with known graph.json
|
|
test('graphifyStatus returns valid structure when graph.json exists', () => {
|
|
enableGraphify(planningDir);
|
|
writeGraphJson(planningDir, SAMPLE_GRAPH);
|
|
const result = graphifyStatus(tmpDir);
|
|
assert.strictEqual(result.exists, true, 'should report exists as true');
|
|
assert.strictEqual(typeof result.node_count, 'number', 'node_count should be number');
|
|
assert.strictEqual(typeof result.edge_count, 'number', 'edge_count should be number');
|
|
assert.strictEqual(typeof result.stale, 'boolean', 'stale should be boolean');
|
|
assert.strictEqual(typeof result.age_hours, 'number', 'age_hours should be number');
|
|
});
|
|
});
|