Files
msd-core/tests/feat-3347-graphify-auto-update-config.test.cjs
Tom Boucher dd4f75376a fix(3347): surface auto-build state via graphifyStatus; trim planner edits to free size budget
agents/gsd-planner.md was 49,316 chars after the initial PR; the
planner-decomposition <48K test was passing on main at 49,150 chars (just under
the 49152 limit). My addition pushed it over.

Restructure: instead of teaching the planner agent to read .last-build-status.json
directly, fold the auto-build state into graphifyStatus()'s existing `stale: true`
signal. The planner's existing rule ("if stale: true, treat as approximate") fires
correctly for failed and in-flight auto-builds — no new planner-side prompt content
needed. The full state is exposed under `last_build_auto_update` for callers that
want exit_code / duration_ms / commit-sha context.

- get-shit-done/bin/lib/graphify.cjs: graphifyStatus() reads
  .planning/graphs/.last-build-status.json; OR-folds status in {failed, running}
  into the existing stale signal; exposes last_build_auto_update field
- agents/gsd-planner.md: revert the auto-update awareness paragraph (49,524 → 49,150)
- agents/gsd-phase-researcher.md: revert the parallel paragraph for consistency
- get-shit-done/references/planner-graphify-auto-update.md: rewrite to document
  the graphifyStatus seam instead of planner-side prompt instructions
- tests/feat-3347-graphify-auto-update-config.test.cjs: 4 new graphifyStatus
  tests pinning the failed/running/ok/missing matrix
- tests/feat-3347-graphify-auto-update-hook.test.cjs: bump per-spawn timeout
  5s → 30s and wait-deadline 5s → 15s to absorb cold-start latency under
  parallel-test-file load (full suite runs many *.test.cjs concurrently)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:59:43 -04:00

239 lines
8.4 KiB
JavaScript

'use strict';
/**
* Regression tests for #3347 — opt-in auto-update of the knowledge graph
* after main HEAD advances.
*
* This file covers the config-key surface: the new `graphify.auto_update`
* key must be a valid config key, default to false, persist via config-set,
* and round-trip via config-get. The runtime hook behavior is covered in
* tests/feat-3347-graphify-auto-update-hook.test.cjs.
*
* Default-off discipline (issue #3347 acceptance criteria):
* - `graphify.auto_update` defaults to `false` so existing users see no
* behavior change after upgrade.
* - Opt-in via /gsd:settings or `gsd-tools config-set graphify.auto_update true`.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const {
VALID_CONFIG_KEYS,
isValidConfigKey,
} = require('../get-shit-done/bin/lib/config-schema.cjs');
const {
CONFIG_DEFAULTS: CANONICAL_CONFIG_DEFAULTS,
} = require('../get-shit-done/bin/lib/configuration.generated.cjs');
const fsx = require('node:fs');
const pathx = require('node:path');
const cpx = require('node:child_process');
const osx = require('node:os');
const { graphifyStatus } = require('../get-shit-done/bin/lib/graphify.cjs');
function makeStatusProject(autoUpdate) {
const tmpDir = fsx.mkdtempSync(pathx.join(osx.tmpdir(), 'gsd-3347-status-'));
cpx.execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: tmpDir });
cpx.execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir });
cpx.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir });
fsx.writeFileSync(pathx.join(tmpDir, 'README.md'), '# t\n');
cpx.execFileSync('git', ['add', '.'], { cwd: tmpDir });
cpx.execFileSync('git', ['commit', '-qm', 'init'], { cwd: tmpDir });
fsx.mkdirSync(pathx.join(tmpDir, '.planning/graphs'), { recursive: true });
fsx.writeFileSync(
pathx.join(tmpDir, '.planning/config.json'),
JSON.stringify({ graphify: { enabled: true } }),
);
// Write a fresh (current-mtime) graph so age-based stale is false; only the
// auto-update status field can set stale: true.
fsx.writeFileSync(
pathx.join(tmpDir, '.planning/graphs/graph.json'),
JSON.stringify({ nodes: [], edges: [] }),
);
if (autoUpdate !== null) {
fsx.writeFileSync(
pathx.join(tmpDir, '.planning/graphs/.last-build-status.json'),
JSON.stringify(autoUpdate),
);
}
return tmpDir;
}
describe('#3347 — graphify.auto_update is a registered config key', () => {
test('VALID_CONFIG_KEYS contains graphify.auto_update', () => {
assert.ok(
VALID_CONFIG_KEYS.has('graphify.auto_update'),
'graphify.auto_update must be in VALID_CONFIG_KEYS so config-set accepts it',
);
});
test('isValidConfigKey accepts graphify.auto_update', () => {
assert.ok(
isValidConfigKey('graphify.auto_update'),
'isValidConfigKey must return true for graphify.auto_update',
);
});
test('isValidConfigKey still accepts the pre-existing graphify.enabled key', () => {
assert.ok(
isValidConfigKey('graphify.enabled'),
'regression guard: graphify.enabled must remain a valid key',
);
});
});
describe('#3347 — graphify.auto_update defaults to false', () => {
test('CANONICAL_CONFIG_DEFAULTS.graphify.auto_update is false', () => {
assert.ok(
CANONICAL_CONFIG_DEFAULTS.graphify !== undefined,
'CANONICAL_CONFIG_DEFAULTS must expose a graphify section',
);
assert.strictEqual(
CANONICAL_CONFIG_DEFAULTS.graphify.auto_update,
false,
'graphify.auto_update default must be false (opt-in per issue #3347 AC)',
);
});
});
describe('#3347 — config-set graphify.auto_update round-trips', () => {
test('config-set graphify.auto_update true succeeds', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'graphify.auto_update', 'true'],
tmpDir,
);
assert.ok(
result.success,
[
'config-set graphify.auto_update true should succeed,',
'got:',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n'),
);
});
test('config-set graphify.auto_update true writes to config.json', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
runGsdTools(['config-set', 'graphify.auto_update', 'true'], tmpDir);
const configPath = path.join(tmpDir, '.planning', 'config.json');
assert.ok(
fs.existsSync(configPath),
'.planning/config.json must exist after config-set',
);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.strictEqual(
config.graphify?.auto_update,
true,
[
'Expected graphify.auto_update: true in config.json,',
'got: ' + JSON.stringify(config.graphify),
].join('\n'),
);
});
test('config-set graphify.auto_update false persists too', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
runGsdTools(['config-set', 'graphify.auto_update', 'true'], tmpDir);
runGsdTools(['config-set', 'graphify.auto_update', 'false'], tmpDir);
const configPath = path.join(tmpDir, '.planning', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.strictEqual(
config.graphify?.auto_update,
false,
'config-set must round-trip true → false',
);
});
test('graphifyStatus folds auto-update status=failed into stale=true', (t) => {
const tmpDir = makeStatusProject({
ts: '2026-05-15T12:00:00Z',
status: 'failed',
exit_code: 1,
duration_ms: 1234,
head_at_build: 'abcdef0',
graphify_version: null,
});
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
const s = graphifyStatus(tmpDir);
assert.strictEqual(s.stale, true, 'auto-build failure must set stale=true');
assert.ok(s.last_build_auto_update, 'last_build_auto_update must be exposed');
assert.strictEqual(s.last_build_auto_update.status, 'failed');
assert.strictEqual(s.last_build_auto_update.exit_code, 1);
});
test('graphifyStatus folds auto-update status=running into stale=true', (t) => {
const tmpDir = makeStatusProject({
ts: '2026-05-15T12:00:00Z',
status: 'running',
exit_code: null,
duration_ms: null,
head_at_build: 'abcdef0',
graphify_version: null,
});
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
const s = graphifyStatus(tmpDir);
assert.strictEqual(s.stale, true, 'auto-build in-flight must set stale=true');
assert.strictEqual(s.last_build_auto_update.status, 'running');
});
test('graphifyStatus leaves stale alone when auto-update status=ok and graph is fresh', (t) => {
const tmpDir = makeStatusProject({
ts: '2026-05-15T12:00:00Z',
status: 'ok',
exit_code: 0,
duration_ms: 1234,
head_at_build: 'abcdef0',
graphify_version: null,
});
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
const s = graphifyStatus(tmpDir);
assert.strictEqual(s.stale, false, 'fresh graph + ok auto-build => not stale');
assert.strictEqual(s.last_build_auto_update.status, 'ok');
});
test('graphifyStatus exposes last_build_auto_update: null when status file absent', (t) => {
const tmpDir = makeStatusProject(null);
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
const s = graphifyStatus(tmpDir);
assert.strictEqual(s.last_build_auto_update, null);
assert.strictEqual(s.stale, false, 'no status file => stale follows mtime only');
});
test('config-set graphify.auto_update does not perturb sibling graphify.enabled', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
runGsdTools(['config-set', 'graphify.enabled', 'true'], tmpDir);
runGsdTools(['config-set', 'graphify.auto_update', 'true'], tmpDir);
const configPath = path.join(tmpDir, '.planning', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.strictEqual(
config.graphify?.enabled,
true,
'graphify.enabled must be preserved when setting graphify.auto_update',
);
assert.strictEqual(
config.graphify?.auto_update,
true,
'graphify.auto_update must coexist with graphify.enabled',
);
});
});