'use strict'; // Tests for graphify `graph_path` config override (#1825) — a single umbrella // graph serving multiple sibling projects. // // Boundary matrix (per the issue's acceptance criteria + triage outline): // (a) key unset → byte-identical default `.planning/graphs/graph.json` // (b) key set + file present → reads the CONFIGURED graph (not the default) // (c) key set + file missing → actionable error naming the path (no stack trace) // (d) relative path resolved against the project root (cwd) // + snapshot written alongside the configured graph // + build honors the key (graphs_dir = configured dir) // + VALID_CONFIG_KEYS contains graphify.graph_path const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('node:os'); const path = require('path'); const childProcess = require('child_process'); const { mock } = require('node:test'); const { createTempProject, cleanup } = require('./helpers.cjs'); const { graphifyQuery, graphifyStatus, graphifyDiff, graphifyBuild, writeSnapshot, } = require('../msd-core/bin/lib/graphify.cjs'); const { enableGraphify, SAMPLE_GRAPH } = require('./helpers/graphify.cjs'); const { VALID_CONFIG_KEYS } = require('../msd-core/bin/lib/config-schema.cjs'); // ─── Fixtures (mirrors graphify-query.test.cjs surfaced-config-dir pattern) ──── function makeSurfacedConfigDir() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-graph-path-cfg-')); fs.writeFileSync( path.join(dir, '.msd-surface.json'), JSON.stringify({ baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n', 'utf8', ); return dir; } function saveSurfacedEnv() { const saved = { MSD_RUNTIME: process.env.MSD_RUNTIME, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, MSD_WORKSTREAM: process.env.MSD_WORKSTREAM, MSD_PROJECT: process.env.MSD_PROJECT, }; return { restore() { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } }, }; } function setGraphPath(planningDir, relOrAbs) { const configPath = path.join(planningDir, 'config.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); config.graphify = config.graphify || {}; config.graphify.graph_path = relOrAbs; fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); } // A graph whose label is distinct from SAMPLE_GRAPH so we can prove which file was read. const UMBRELLA_GRAPH = { nodes: [ { id: 'u1', label: 'UmbrellaService', description: 'only present in the configured umbrella graph', type: 'service' }, { id: 'u2', label: 'CrossRepoEdge', description: 'cross repo', type: 'model' }, ], edges: [{ source: 'u1', target: 'u2', label: 'calls', confidence: 'EXTRACTED' }], hyperedges: [], }; // ─── VALID_CONFIG_KEYS registration ─────────────────────────────────────────── describe('graphify.graph_path config key', () => { test('is registered in VALID_CONFIG_KEYS', () => { assert.ok(VALID_CONFIG_KEYS.has('graphify.graph_path'), 'graphify.graph_path must be a recognised config key'); }); }); // ─── query ──────────────────────────────────────────────────────────────────── describe('graphify graph_path override — query', () => { let tmpDir, planningDir, cfgDir, env; beforeEach(() => { tmpDir = createTempProject(); planningDir = path.join(tmpDir, '.planning'); enableGraphify(planningDir); cfgDir = makeSurfacedConfigDir(); env = saveSurfacedEnv(); process.env.CLAUDE_CONFIG_DIR = cfgDir; delete process.env.MSD_RUNTIME; delete process.env.MSD_WORKSTREAM; delete process.env.MSD_PROJECT; }); afterEach(() => { cleanup(tmpDir); cleanup(cfgDir); env.restore(); }); test('(a) unset → reads default .planning/graphs/graph.json', () => { // Write the default graph and query it. fs.mkdirSync(path.join(planningDir, 'graphs'), { recursive: true }); fs.writeFileSync(path.join(planningDir, 'graphs', 'graph.json'), JSON.stringify(SAMPLE_GRAPH), 'utf8'); const result = graphifyQuery(tmpDir, 'AuthService'); assert.ok(!('error' in result), 'default path must succeed'); assert.ok(result.nodes.some((n) => n.label === 'AuthService')); }); test('(b) set + present → reads the CONFIGURED graph, not the default', () => { // Place the default graph AND a distinct configured graph; prove the configured one wins. fs.mkdirSync(path.join(planningDir, 'graphs'), { recursive: true }); fs.writeFileSync(path.join(planningDir, 'graphs', 'graph.json'), JSON.stringify(SAMPLE_GRAPH), 'utf8'); const umbrellaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-umbrella-q-')); try { const abs = path.join(umbrellaDir, 'graph.json'); fs.writeFileSync(abs, JSON.stringify(UMBRELLA_GRAPH), 'utf8'); setGraphPath(planningDir, abs); const result = graphifyQuery(tmpDir, 'UmbrellaService'); assert.ok(!('error' in result), 'configured path must succeed'); assert.ok(result.nodes.some((n) => n.label === 'UmbrellaService'), 'must read the configured graph'); assert.ok(!result.nodes.some((n) => n.label === 'AuthService'), 'must NOT read the default graph'); } finally { cleanup(umbrellaDir); } }); test('(c) set + missing → actionable error naming the configured path (no stack trace)', () => { const abs = path.join(tmpDir, 'does-not-exist.json'); setGraphPath(planningDir, abs); const result = graphifyQuery(tmpDir, 'anything'); assert.ok('error' in result, 'missing configured graph must return an error'); assert.ok(result.error.includes(abs), 'error must name the configured absolute path'); assert.ok(/graphify.graph_path|graphify build/i.test(result.error), 'error must be actionable'); }); test('(d) relative path resolved against the project root', () => { const umbrellaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-umbrella-rel-')); try { const abs = path.join(umbrellaDir, 'graph.json'); fs.writeFileSync(abs, JSON.stringify(UMBRELLA_GRAPH), 'utf8'); const rel = path.relative(tmpDir, abs); // e.g. '../msd-umbrella-rel-XXX/graph.json' setGraphPath(planningDir, rel); const result = graphifyQuery(tmpDir, 'UmbrellaService'); assert.ok(!('error' in result), 'relative configured path must resolve and succeed'); assert.ok(result.nodes.some((n) => n.label === 'UmbrellaService')); } finally { cleanup(umbrellaDir); } }); test('empty-string graph_path is treated as unset (falls back to default)', () => { setGraphPath(planningDir, ' '); fs.mkdirSync(path.join(planningDir, 'graphs'), { recursive: true }); fs.writeFileSync(path.join(planningDir, 'graphs', 'graph.json'), JSON.stringify(SAMPLE_GRAPH), 'utf8'); const result = graphifyQuery(tmpDir, 'AuthService'); assert.ok(!('error' in result)); assert.ok(result.nodes.some((n) => n.label === 'AuthService'), 'blank graph_path falls back to default'); }); }); // ─── status ─────────────────────────────────────────────────────────────────── describe('graphify graph_path override — status', () => { let tmpDir, planningDir, cfgDir, env; beforeEach(() => { tmpDir = createTempProject(); planningDir = path.join(tmpDir, '.planning'); enableGraphify(planningDir); cfgDir = makeSurfacedConfigDir(); env = saveSurfacedEnv(); process.env.CLAUDE_CONFIG_DIR = cfgDir; delete process.env.MSD_RUNTIME; delete process.env.MSD_WORKSTREAM; delete process.env.MSD_PROJECT; }); afterEach(() => { cleanup(tmpDir); cleanup(cfgDir); env.restore(); }); test('set + present → status reads the configured graph counts', () => { const umbrellaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-umbrella-st-')); try { const abs = path.join(umbrellaDir, 'graph.json'); fs.writeFileSync(abs, JSON.stringify(UMBRELLA_GRAPH), 'utf8'); setGraphPath(planningDir, abs); const result = graphifyStatus(tmpDir); assert.strictEqual(result.exists, true); assert.strictEqual(result.node_count, UMBRELLA_GRAPH.nodes.length); assert.strictEqual(result.edge_count, UMBRELLA_GRAPH.edges.length); } finally { cleanup(umbrellaDir); } }); test('set + missing → exists:false with actionable message', () => { const abs = path.join(tmpDir, 'missing.json'); setGraphPath(planningDir, abs); const result = graphifyStatus(tmpDir); assert.strictEqual(result.exists, false); assert.ok(result.message.includes(abs), 'status message must name the configured path'); }); // `graph_path` (#4836). The planner/researcher prompts prefer the `graphify` CLI, // which takes the graph location as `--graph`. They read that value from status // rather than re-deriving `.planning/graphs/graph.json`, which would silently // point the CLI at a non-existent local mirror whenever the umbrella override is // configured — the exact case graph_path exists to serve. So status must emit the // SAME absolute path the built-in reader uses, on both the present and the missing // branch (the missing branch is what makes the error actionable at the call site). test('set + present → status emits graph_path = the configured absolute path', () => { const umbrellaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-umbrella-gp-')); try { const abs = path.join(umbrellaDir, 'graph.json'); fs.writeFileSync(abs, JSON.stringify(UMBRELLA_GRAPH), 'utf8'); setGraphPath(planningDir, abs); const result = graphifyStatus(tmpDir); assert.strictEqual(result.exists, true); assert.strictEqual(result.graph_path, abs); assert.notStrictEqual( result.graph_path, path.join(planningDir, 'graphs', 'graph.json'), 'graph_path must be the configured location, not the default mirror', ); } finally { cleanup(umbrellaDir); } }); test('set + relative → graph_path is resolved against the project root', () => { const umbrellaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-umbrella-rel-')); try { const abs = path.join(umbrellaDir, 'graph.json'); fs.writeFileSync(abs, JSON.stringify(UMBRELLA_GRAPH), 'utf8'); const rel = path.relative(tmpDir, abs); assert.ok(!path.isAbsolute(rel), 'fixture must feed a relative graph_path'); setGraphPath(planningDir, rel); const result = graphifyStatus(tmpDir); assert.strictEqual(result.exists, true); assert.ok(path.isAbsolute(result.graph_path), 'graph_path must be absolute'); assert.strictEqual(result.graph_path, abs); } finally { cleanup(umbrellaDir); } }); test('set + missing → graph_path still names the configured path', () => { const abs = path.join(tmpDir, 'missing.json'); setGraphPath(planningDir, abs); const result = graphifyStatus(tmpDir); assert.strictEqual(result.exists, false); assert.strictEqual(result.graph_path, abs); }); test('unset → graph_path is the default .planning/graphs/graph.json', () => { fs.mkdirSync(path.join(planningDir, 'graphs'), { recursive: true }); const def = path.join(planningDir, 'graphs', 'graph.json'); fs.writeFileSync(def, JSON.stringify(SAMPLE_GRAPH), 'utf8'); const result = graphifyStatus(tmpDir); assert.strictEqual(result.exists, true); assert.strictEqual(result.graph_path, def); }); test('unset + missing → graph_path is the default path', () => { const result = graphifyStatus(tmpDir); assert.strictEqual(result.exists, false); assert.strictEqual(result.graph_path, path.join(planningDir, 'graphs', 'graph.json')); }); // #4836 review Minor 1: a present-but-unparseable graph.json hits neither the // `exists:false` nor the `exists:true` branch, so the CLI-first prompt gate // ("if exists is false, skip") does not catch it and the CLI call would // interpolate the literal `` placeholder without a real path to // substitute. graph_path must be present here too. test('present + unparseable → error response still names graph_path', () => { const def = path.join(planningDir, 'graphs', 'graph.json'); fs.mkdirSync(path.dirname(def), { recursive: true }); fs.writeFileSync(def, '{not valid json', 'utf8'); const result = graphifyStatus(tmpDir); assert.strictEqual(result.exists, undefined); assert.ok(result.error, 'must report the parse failure'); assert.strictEqual(result.graph_path, def); }); }); // ─── diff + writeSnapshot (snapshot travels with the configured graph) ──────── describe('graphify graph_path override — diff & snapshot', () => { let tmpDir, planningDir, cfgDir, env; beforeEach(() => { tmpDir = createTempProject(); planningDir = path.join(tmpDir, '.planning'); enableGraphify(planningDir); cfgDir = makeSurfacedConfigDir(); env = saveSurfacedEnv(); process.env.CLAUDE_CONFIG_DIR = cfgDir; delete process.env.MSD_RUNTIME; delete process.env.MSD_WORKSTREAM; delete process.env.MSD_PROJECT; }); afterEach(() => { cleanup(tmpDir); cleanup(cfgDir); env.restore(); }); test('writeSnapshot reads configured graph and writes the snapshot ALONGSIDE it', () => { const umbrellaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-umbrella-snap-')); try { const abs = path.join(umbrellaDir, 'graph.json'); fs.writeFileSync(abs, JSON.stringify(UMBRELLA_GRAPH), 'utf8'); setGraphPath(planningDir, abs); const result = writeSnapshot(tmpDir); assert.strictEqual(result.saved, true); // Snapshot lands next to the configured graph, NOT under the project default. const snapAlongside = path.join(umbrellaDir, '.last-build-snapshot.json'); const snapDefault = path.join(planningDir, 'graphs', '.last-build-snapshot.json'); assert.ok(fs.existsSync(snapAlongside), 'snapshot must be written alongside the configured graph'); assert.ok(!fs.existsSync(snapDefault), 'snapshot must NOT be written under the project default when graph_path is set'); } finally { cleanup(umbrellaDir); } }); test('diff reads baseline + current from the configured directory', () => { const umbrellaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-umbrella-diff-')); try { const abs = path.join(umbrellaDir, 'graph.json'); fs.writeFileSync(abs, JSON.stringify(UMBRELLA_GRAPH), 'utf8'); setGraphPath(planningDir, abs); // First write a snapshot baseline (alongside configured graph). writeSnapshot(tmpDir); // Mutate the configured graph (add a node) so diff sees an addition. const evolved = JSON.parse(JSON.stringify(UMBRELLA_GRAPH)); evolved.nodes.push({ id: 'u3', label: 'NewNode', description: 'added', type: 'service' }); fs.writeFileSync(abs, JSON.stringify(evolved), 'utf8'); const result = graphifyDiff(tmpDir); assert.ok(!('error' in result) && !result.no_baseline, 'diff must find the baseline alongside the configured graph'); assert.strictEqual(result.nodes.added, 1); } finally { cleanup(umbrellaDir); } }); test('unset → writeSnapshot writes to the default project graphs dir (byte-identical)', () => { fs.mkdirSync(path.join(planningDir, 'graphs'), { recursive: true }); fs.writeFileSync(path.join(planningDir, 'graphs', 'graph.json'), JSON.stringify(SAMPLE_GRAPH), 'utf8'); const result = writeSnapshot(tmpDir); assert.strictEqual(result.saved, true); assert.ok(fs.existsSync(path.join(planningDir, 'graphs', '.last-build-snapshot.json')), 'unset → default snapshot location'); }); }); // ─── build (graphs_dir honors the key) ──────────────────────────────────────── describe('graphify graph_path override — build', () => { let tmpDir, planningDir, cfgDir, env; beforeEach(() => { tmpDir = createTempProject(); planningDir = path.join(tmpDir, '.planning'); enableGraphify(planningDir); cfgDir = makeSurfacedConfigDir(); env = saveSurfacedEnv(); process.env.CLAUDE_CONFIG_DIR = cfgDir; delete process.env.MSD_RUNTIME; delete process.env.MSD_WORKSTREAM; delete process.env.MSD_PROJECT; // Mock the graphify subprocess probes so build's pre-flight passes. 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 }; }); }); afterEach(() => { mock.restoreAll(); cleanup(tmpDir); cleanup(cfgDir); env.restore(); }); test('set → build stays project-scoped (graphs_dir is the default; umbrella graph is built in the umbrella project)', () => { // graphify.graph_path is a READ-path override only (#1825). Build always cp's // into the project's `.planning/graphs/` (the build skill hardcodes that dest), // so graphs_dir must reflect the real destination even when graph_path is set. const umbrellaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-umbrella-build-')); try { const abs = path.join(umbrellaDir, 'graph.json'); setGraphPath(planningDir, abs); const result = graphifyBuild(tmpDir); assert.strictEqual(result.graphs_dir, path.join(planningDir, 'graphs')); } finally { cleanup(umbrellaDir); } }); test('unset → graphs_dir is the default .planning/graphs (byte-identical)', () => { const result = graphifyBuild(tmpDir); assert.strictEqual(result.graphs_dir, path.join(planningDir, 'graphs')); }); });