feat(#1825): configurable graphify graph location (graphify.graph_path) (#2013)

* feat(#1825): configurable graphify graph location (graphify.graph_path)

Add a graphify.graph_path config key (.planning/config.json) that overrides
where /gsd-graphify query|status|diff read the knowledge graph, so one curated
umbrella-level cross-repo graph can serve multiple sibling projects without N
drifting ~5 MB mirror copies. Previously the graph location was hardcoded to
<cwd>/.planning/graphs/.

- src/graphify.cts: resolveGraphLocation(cwd, planningDir) honors the key
  (resolved relative to project root; absolute paths honored via path.resolve);
  falls back to the historical .planning/graphs/graph.json when unset/blank/
  non-string (byte-identical). Wired into graphifyQuery, graphifyStatus,
  graphifyDiff (snapshot travels with the configured graph via dirname), and
  writeSnapshot. Configured-but-missing -> actionable error naming the path.
  Build stays project-scoped (skill hardcodes the cp dest); umbrella graph is
  built in the umbrella project, sub-projects only READ it.
- config-schema.manifest.json: register graphify.graph_path in validKeys.
- tests/graphify-graph-path.test.cjs: boundary matrix (unset byte-identical,
  set+present reads configured graph not default, set+missing actionable error,
  relative resolved vs project root, blank treated as unset, snapshot alongside
  configured graph, diff from configured dir, build project-scoped) +
  VALID_CONFIG_KEYS registration.
- docs: CONFIGURATION.md row, FEATURES.md REQ-GRAPH-06, CONTEXT.md module note,
  .changeset (Added).

Closes #1825

* docs(#1825): backfill changeset pr number 2013
This commit is contained in:
Tom Boucher
2026-07-05 14:50:01 -04:00
committed by GitHub
parent ef8a3e27d4
commit bd77b40107
23 changed files with 430 additions and 26 deletions

View File

@@ -369,6 +369,39 @@ function countCommitsBetween(cwd: string, from: string, to: string): number | nu
return Number.isFinite(n) ? n : null;
}
// ─── Graph location resolution (#1825) ───────────────────────────────────────
//
// `graphify.graph_path` (in .planning/config.json) lets one umbrella-level graph
// serve multiple sibling projects without a per-project mirror. When set, query /
// status / diff read the configured graph, and the diff snapshot travels with it
// (alongside graph.json). When unset/blank/non-string, behaviour is byte-identical
// to the historical `<planningDir>/graphs/graph.json`.
const GRAPH_FILENAME = 'graph.json';
const SNAPSHOT_FILENAME = '.last-build-snapshot.json';
interface GraphLocation {
/** Absolute path to graph.json. */
graphPath: string;
/** True iff graphify.graph_path was explicitly configured (non-empty string). */
configured: boolean;
}
/**
* Resolve the absolute graph.json location. Honors `graphify.graph_path` in
* config.json (resolved relative to the project root, `cwd`); falls back to the
* default `<planningDir>/graphs/graph.json` when unset/blank/non-string.
*/
function resolveGraphLocation(cwd: string, planningDir: string): GraphLocation {
const config = safeReadJson(path.join(planningDir, 'config.json'));
const graphify = (config && (config as Record<string, unknown>)['graphify']) as Record<string, unknown> | undefined;
const configuredValue = graphify && graphify['graph_path'];
if (typeof configuredValue === 'string' && configuredValue.trim().length > 0) {
return { graphPath: path.resolve(cwd, configuredValue), configured: true };
}
return { graphPath: path.join(planningDir, 'graphs', GRAPH_FILENAME), configured: false };
}
/**
* Query the knowledge graph for nodes matching a term, with optional budget cap.
* Uses seed-then-expand BFS traversal (D-01).
@@ -377,9 +410,11 @@ function graphifyQuery(cwd: string, term: string, options: { budget?: number | n
const planningDir = path.join(cwd, '.planning');
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
const { graphPath, configured } = resolveGraphLocation(cwd, planningDir);
if (!fs.existsSync(graphPath)) {
return { error: 'No graph built yet. Run graphify build first.' };
return { error: configured
? `Configured graph not found at ${graphPath}. Set graphify.graph_path or run /gsd:graphify build.`
: 'No graph built yet. Run graphify build first.' };
}
const graph = safeReadJson(graphPath);
@@ -416,9 +451,11 @@ function graphifyStatus(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
const { graphPath, configured } = resolveGraphLocation(cwd, planningDir);
if (!fs.existsSync(graphPath)) {
return { exists: false, message: 'No graph built yet. Run graphify build to create one.' };
return { exists: false, message: configured
? `Configured graph not found at ${graphPath}. Set graphify.graph_path or run /gsd:graphify build.`
: 'No graph built yet. Run graphify build to create one.' };
}
const stat = fs.statSync(graphPath);
@@ -479,8 +516,8 @@ function graphifyDiff(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
const snapshotPath = path.join(planningDir, 'graphs', '.last-build-snapshot.json');
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
const { graphPath } = resolveGraphLocation(cwd, planningDir);
const snapshotPath = path.join(path.dirname(graphPath), SNAPSHOT_FILENAME);
if (!fs.existsSync(snapshotPath)) {
return { no_baseline: true, message: 'No previous snapshot. Run graphify build first, then build again to generate a diff baseline.' };
@@ -540,7 +577,11 @@ function graphifyBuild(cwd: string): unknown {
const version = checkGraphifyVersion();
// Ensure output directory exists (D-05)
// Ensure output directory exists (D-05). Build stays project-scoped: the build
// skill cp's artifacts into `<planningDir>/graphs/` regardless of graph_path, so
// graphs_dir reflects that real destination (not the configured read location).
// A shared umbrella graph is built in the umbrella project; sub-projects only
// READ it via graphify.graph_path (#1825).
const graphsDir = path.join(planningDir, 'graphs');
fs.mkdirSync(graphsDir, { recursive: true });
@@ -573,7 +614,8 @@ interface SnapshotResult {
* using platformWriteSync for crash safety.
*/
function writeSnapshot(cwd: string): SnapshotResult | { error: string } {
const graphPath = path.join(cwd, '.planning', 'graphs', 'graph.json');
const planningDir = path.join(cwd, '.planning');
const { graphPath } = resolveGraphLocation(cwd, planningDir);
const graph = safeReadJson(graphPath);
if (!graph) return { error: 'Cannot write snapshot: graph.json not parseable' };
@@ -584,7 +626,7 @@ function writeSnapshot(cwd: string): SnapshotResult | { error: string } {
edges: graph.edges || graph.links || [],
};
const snapshotPath = path.join(cwd, '.planning', 'graphs', '.last-build-snapshot.json');
const snapshotPath = path.join(path.dirname(graphPath), SNAPSHOT_FILENAME);
platformWriteSync(snapshotPath, JSON.stringify(snapshot, null, 2));
return {
saved: true,