diff --git a/.changeset/1825-graphify-graph-path.md b/.changeset/1825-graphify-graph-path.md new file mode 100644 index 000000000..f260e261d --- /dev/null +++ b/.changeset/1825-graphify-graph-path.md @@ -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 `/.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) diff --git a/CONTEXT.md b/CONTEXT.md index 6796a3da9..5b48787ce 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3d2c467b6..28c6d2b21 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 17c251d79..637776808 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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` --- diff --git a/gsd-core/bin/shared/config-schema.manifest.json b/gsd-core/bin/shared/config-schema.manifest.json index 5933a2159..b22c99635 100644 --- a/gsd-core/bin/shared/config-schema.manifest.json +++ b/gsd-core/bin/shared/config-schema.manifest.json @@ -84,6 +84,7 @@ "context_window", "graphify.build_timeout", "graphify.auto_update", + "graphify.graph_path", "claude_md_path", "claude_md_assembly.mode", "runtime", diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 3bdbe3918..ca863bc9c 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -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" diff --git a/src/graphify.cts b/src/graphify.cts index bd095ecca..ce65d8e96 100644 --- a/src/graphify.cts +++ b/src/graphify.cts @@ -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 `/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 `/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)['graphify']) as Record | 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 `/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, diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index fb67bf0dd..d4c0ab94c 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index c5cfa1494..e68a5edae 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 2bc34e091..cc89fc1d4 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index 8abf17eab..12e67ac45 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 145ca0c83..160dc8aba 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 762f0ea54..71546779d 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 53cb2c767..ee76933b3 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index c73b33e70..c73beb88e 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index ff46c3a89..305d3a9eb 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 0f4eec62b..490b8b7ec 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 73510bb25..64f7812a0 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index f82b52840..afb72a7b4 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 27d5f2ac1..8917f288e 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 519e256b8..a8c7fc401 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 3ecb91e02..bb29666f9 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -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", diff --git a/tests/graphify-graph-path.test.cjs b/tests/graphify-graph-path.test.cjs new file mode 100644 index 000000000..3c792aa06 --- /dev/null +++ b/tests/graphify-graph-path.test.cjs @@ -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')); + }); +});