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

@@ -0,0 +1,5 @@
---
type: Added
pr: 2013
---
**`graphify.graph_path` makes the knowledge-graph location configurable so one umbrella graph can serve multiple projects** — a new `.planning/config.json` key (path relative to project root, or absolute) overrides where `/gsd-graphify query|status|diff` read the graph, letting a single curated cross-repo umbrella graph serve every sibling sub-project without N drifting ~5 MB mirror copies. Previously the graph location was hardcoded to `<cwd>/.planning/graphs/` with no override; the only workaround was copying the umbrella `graph.json` into each project (which drifted, wasted disk, and could be silently overwritten by an in-project build). The diff snapshot travels with the configured graph; build stays project-scoped; unset → byte-identical default; a configured-but-missing file yields an actionable error naming the path. (#1825)

View File

@@ -277,7 +277,7 @@ Module owning the explicit per-runtime config-mutation dispatch table for the in
Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). `hooks.json` covers all seven Claude Code lifecycle events: SessionStart, PreToolUse, PostToolUse, SubagentStop, Stop, PreCompact (all wired to context-monitor for context-headroom awareness), and FileChanged (matcher: `config.json` → config-reload, injects `additionalContext` when `.planning/config.json` changes mid-session). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/issue-766-plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module.
### Knowledge Graph Module
Module owning the graphify integration: tri-state capability gate (`isCapabilityActive('graphify', cwd)` from capability-state.cjs — requires installed AND surfaced AND config-enabled; replaces the former config-only `isGraphifyEnabled` gate, cutover in #1306), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Config leg reads `.planning/config.json:graphify.enabled`; all three legs (install, surface, config) must be active; writes to `.planning/graphs/`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`.
Module owning the graphify integration: tri-state capability gate (`isCapabilityActive('graphify', cwd)` from capability-state.cjs — requires installed AND surfaced AND config-enabled; replaces the former config-only `isGraphifyEnabled` gate, cutover in #1306), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Config leg reads `.planning/config.json:graphify.enabled`; all three legs (install, surface, config) must be active; writes to `.planning/graphs/`. Graph location override (#1825): `graphify.graph_path` in `.planning/config.json` (a path relative to the project root, or absolute) redirects where `graphifyQuery`/`graphifyStatus`/`graphifyDiff` read `graph.json` — so one umbrella-level cross-repo graph serves multiple sibling projects without N drifting mirror copies; the diff snapshot (`.last-build-snapshot.json`) travels with the configured graph (same dir); the auto-update status sidecar stays project-local; `writeSnapshot` honors the key (reads the configured graph, writes the snapshot alongside it); build stays project-scoped (`.planning/graphs/`) since the build skill hardcodes that destination — the umbrella graph is built in the umbrella project and sub-projects only READ it. Unset/blank/non-string → byte-identical `.planning/graphs/graph.json` default; a configured-but-missing file yields an actionable error naming the path. The key is registered in `config-schema.manifest.json` `validKeys`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`.
### Intel Module
Module owning the code-intelligence store: tri-state capability gate (`isCapabilityActive('intel', cwd)` from capability-state.cjs — honours installed+surfaced+config-enabled; replaces the former config-only `isIntelEnabled` gate, cutover in #1307; intel has `skills:[]` so installed/surfaced are vacuously true and the effective gate is `intel.enabled` in config), disabled response, query surface (`intelQuery` — full-text search across all intel JSON files), status surface (`intelStatus` — per-file freshness, 24-hour staleness threshold), diff surface (`intelDiff` — added/changed/removed files vs last-refresh snapshot), snapshot management (`saveRefreshSnapshot`/`intelSnapshot`), validation (`intelValidate` — existence, JSON validity, _meta.updated_at recency), api-surface render (`intelApiSurface` — generates `.planning/intel/API-SURFACE.md` from `api-map.json`), plus ungated utilities (`intelPatchMeta` — patches `_meta.updated_at` in any JSON file; `intelExtractExports` — extracts CJS/ESM exports from any JS file). Loop hook rendering gates on `state.active` (not `state.enabled`) so the `activationKey` config gate is honoured even without a per-hook `when` guard (Phase 4 tri-state alignment, #1307). Source: `gsd-core/bin/lib/intel.cjs` (generated from `src/intel.cts`). Router: `gsd-core/bin/lib/intel-command-router.cjs`. See Capability Command Family Module (ADR-959 4d-impl-4) and Loop Extension Point.

View File

@@ -708,6 +708,7 @@ gsd-tools query config-set mempalace.cross_project_tunnels true
| `graphify.enabled` | boolean | `false` | Enable the project knowledge graph. When `true`, `/gsd-graphify` builds and queries a graph in `.planning/graphs/`. Added in v1.36 |
| `graphify.build_timeout` | number (seconds) | `300` | Maximum seconds allowed for a `/gsd-graphify build` run before it aborts. Added in v1.36 |
| `graphify.auto_update` | boolean | `false` | **Opt-in (issue #3347).** When `true` (and `graphify.enabled` is also `true`), the bundled PostToolUse hook `hooks/gsd-graphify-update.sh` auto-rebuilds the project knowledge graph in a detached background process after `git commit/merge/pull/rebase --continue/cherry-pick` on the default branch (`git.base_branch` override, else `main`/`master`/`trunk`). Hook returns instantly; the rebuild updates `.planning/graphs/{graph.json,graph.html,GRAPH_REPORT.md}` and writes `.planning/graphs/.last-build-status.json` (`{ts, status: "running"\|"ok"\|"failed", exit_code, duration_ms, head_at_build}`). PID-locked, CI-aware (`$CI` env suppresses), bails silently if `graphify` is not on `PATH`. Default `false` so existing behaviour is unchanged after upgrade. |
| `graphify.graph_path` | string (path) | _unset_ | **Umbrella/multi-repo support (issue #1825).** Overrides where `/gsd-graphify query\|status\|diff` read the knowledge graph from. Set to a path (relative to the project root, or absolute) pointing at a shared umbrella-level `graph.json` so a single curated cross-repo graph serves every sub-project without N drifting mirror copies. The diff snapshot (`.last-build-snapshot.json`) travels with the configured graph (same directory); the auto-update status sidecar stays project-local. Build stays project-scoped (`.planning/graphs/`) — build the umbrella graph in the umbrella project, then point sub-projects at it. When unset, behaviour is byte-identical to the historical `.planning/graphs/graph.json`. A clear, actionable error is returned when the configured file is missing. |
#### Multi-developer setup

View File

@@ -2626,8 +2626,9 @@ Users who run a memory / knowledge-base MCP server (for example, ExoCortex-style
- REQ-GRAPH-03: Build runs within the configurable `graphify.build_timeout` (seconds); exceeding the timeout aborts cleanly without leaving a partial graph.
- REQ-GRAPH-04: `graphify.cjs` falls back to `graph.links` when `graph.edges` is absent so older graph artifacts keep rendering.
- REQ-GRAPH-05: Graphify is invoked through `gsd-tools.cjs graphify ...` command handlers.
- REQ-GRAPH-06: The knowledge-graph location is configurable via `graphify.graph_path` (issue #1825) so one umbrella-level cross-repo graph can serve multiple sibling projects; `query`/`status`/`diff` read the configured graph (relative to project root), with a byte-identical `.planning/graphs/` default when unset.
**Configuration:** `graphify.enabled`, `graphify.build_timeout`
**Configuration:** `graphify.enabled`, `graphify.build_timeout`, `graphify.graph_path`
**Reference files:** `commands/gsd/graphify.md`, `bin/lib/graphify.cjs`
---

View File

@@ -84,6 +84,7 @@
"context_window",
"graphify.build_timeout",
"graphify.auto_update",
"graphify.graph_path",
"claude_md_path",
"claude_md_assembly.mode",
"runtime",

View File

@@ -14,6 +14,7 @@
"files": [
"graphify-auto-update.slow.test.cjs",
"graphify-command-cutover.test.cjs",
"graphify-graph-path.test.cjs",
"graphify-query.test.cjs",
"graphify-visualization.test.cjs",
"graphify.test.cjs"

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,

View File

@@ -40,7 +40,7 @@
"gsd-core/bin/gsd-tools.cjs": "e839e52071d5baa1",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "8bc541aabc2e143c",

View File

@@ -110,7 +110,7 @@
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -39,7 +39,7 @@
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -43,7 +43,7 @@
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -110,7 +110,7 @@
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -75,7 +75,7 @@
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -41,7 +41,7 @@
"gsd-core/bin/gsd-tools.cjs": "e839e52071d5baa1",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "10226e9512dd44bf",

View File

@@ -110,7 +110,7 @@
"gsd-core/bin/gsd-tools.cjs": "e839e52071d5baa1",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -40,7 +40,7 @@
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -110,7 +110,7 @@
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -76,7 +76,7 @@
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -110,7 +110,7 @@
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -40,7 +40,7 @@
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -40,7 +40,7 @@
"gsd-core/bin/gsd-tools.cjs": "e839e52071d5baa1",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -40,7 +40,7 @@
"gsd-core/bin/gsd-tools.cjs": "e839e52071d5baa1",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
"gsd-core/bin/shared/model-catalog.json": "ca909d55f7966269",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",

View File

@@ -0,0 +1,353 @@
'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('../gsd-core/bin/lib/graphify.cjs');
const { enableGraphify, SAMPLE_GRAPH } = require('./helpers/graphify.cjs');
const { VALID_CONFIG_KEYS } = require('../gsd-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(), 'gsd-graph-path-cfg-'));
fs.writeFileSync(
path.join(dir, '.gsd-surface.json'),
JSON.stringify({ baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n',
'utf8',
);
return dir;
}
function saveSurfacedEnv() {
const saved = {
GSD_RUNTIME: process.env.GSD_RUNTIME,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
GSD_WORKSTREAM: process.env.GSD_WORKSTREAM,
GSD_PROJECT: process.env.GSD_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.GSD_RUNTIME;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_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(), 'gsd-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(), 'gsd-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. '../gsd-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.GSD_RUNTIME;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_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(), 'gsd-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');
});
});
// ─── 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.GSD_RUNTIME;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_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(), 'gsd-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(), 'gsd-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.GSD_RUNTIME;
delete process.env.GSD_WORKSTREAM;
delete process.env.GSD_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(), 'gsd-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'));
});
});