Files
msd-core/src/graphify.cts
Tom Boucher df04aae5e4 enhancement(#537): migrate all hand-written bin/lib/*.cjs to TypeScript source of truth (ADR-457) (#602)
* enhancement(#537): migrate code-review-flags to TS source of truth

Collapse the hand-written get-shit-done/bin/lib/code-review-flags.cjs to a
TypeScript source of truth (src/code-review-flags.cts), compiled by tsc to a
gitignored .cjs build artifact at the same path, per ADR-457 (build-at-publish).
Second module after the semver-compare pilot (#541).

Behaviour is preserved byte-for-behaviour (characterization test added in
tests/code-review-flags.test.cjs locks the parser quirks). Adds compile-time
type checking: CodeReviewFlags interface + CodeReviewWorkflow literal union.
The require() path is unchanged, so code-review.md and the bug-3727 test keep
working. The emitted .cjs is gitignored and eslint-ignored, mirroring the pilot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 9 leaf bin/lib modules to TS source of truth

ADR-457 build-at-publish, batch 1 (pure leaf modules, 0 sibling-deps):
001-legacy-orphan-files, context-utilization, redaction, artifacts,
command-arg-projection, clock, ui-safety-gate, review-reviewer-selection,
clusters. Each moves to src/*.cts (strict TS, typed), compiled by tsc to a
gitignored .cjs at the same require() path; behaviour preserved byte-for-
behaviour. Adds src/node-globals.d.ts (minimal ambient shim; "types":[]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#537): add @types/node, drop hand-rolled node-globals shim

ADR-457 migration infra: replace the temporary src/node-globals.d.ts ambient
shim with @types/node@22 + "types":["node"] in tsconfig.build.json. Unblocks
migrating the ~49 remaining bin/lib modules that use node:fs/path/os/
child_process. Build + full suite (3030 pass) + lint all green; no .cts type
changes were needed (real Node types matched the shim).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 9 more bin/lib modules to TS (batch 2)

ADR-457 build-at-publish. Clean leaves: installer-migration-report,
prompt-budget. Type-error-prone leaves (were tsconfig.lint-excluded; now
strict-typed and removed from that exclude list): secrets, phase-lifecycle,
workstream-name-policy, decisions, validate, schema-detect. Plus
runtime-name-policy. Strict type fixes narrow unknown->concrete domain types
(no any/ts-ignore); behaviour preserved. Full suite green, lint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate runtime-slash to TS (cross-import proof)

ADR-457. First cross-module TS->TS import: src/runtime-slash.cts imports
./runtime-name-policy.cjs and tsc resolves the sibling .cts types under strict
(no declaration files; NodeNext .cjs->.cts mapping), emitting a correct
require("./runtime-name-policy.cjs"). Confirms the recipe for coupled modules,
which must be migrated in dependency order (leaves-up). Suite green, lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 10 more bin/lib modules to TS (batch 3)

ADR-457 build-at-publish, Wave-1 leaves: event, workstream-inventory-builder,
plan-scan, fallow-runner, project-root, installer-migration-authoring,
update-context, 000-first-time-baseline, runtime-homes, model-catalog. Strict
typing fixed real issues (narrowing unknown, qualified fs/path calls, removed
unnecessary casts); plan-scan/project-root/workstream-inventory-builder dropped
from tsconfig.lint exclude. Behaviour preserved; suite green, lint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 5 large Wave-1 leaves to TS (batch 4)

ADR-457 build-at-publish: configuration, state-document, shell-command-
projection (42 dependents), security, command-aliases. shell-command-
projection keeps a namespace child_process import for mock-intercept
testability. loadConfig/migrateOnDisk emit synchronously (every caller uses
them sync; the one awaited migrateOnDisk caller tolerates a non-Promise) —
full suite (3030 pass) confirms behaviour preserved. configuration/
state-document/command-aliases dropped from tsconfig.lint exclude. Also fixes
the malformed batch-3 changeset frontmatter (type/pr) that failed lint:docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 6 Wave-2 modules to TS (batch 5)

ADR-457 build-at-publish: config-schema, model-profiles,
002-codex-legacy-hooks-json, logger, active-workstream-store, adr-parser.
First batch importing already-migrated siblings (configuration, model-catalog,
shell-command-projection, redaction, security) via ./sibling.cjs specifiers.
Strict type narrowing (typeof guards over String(unknown)); behaviour
preserved; suite 3030 pass, lint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 5 large Wave-2 modules to TS (batch 6)

ADR-457 build-at-publish: graphify, install-profiles, intel,
installer-migrations, worktree-safety. installer-migrations preserves its
dynamic require() loader for numbered migration modules (scoped lint
suppressions). Strict typing (typeof guards over String(unknown)); behaviour
preserved; suite 3030 pass, lint 0 errors. Wave 2 complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate Wave-3 modules to TS (batch 7)

ADR-457 build-at-publish: planning-workspace, runtime-artifact-layout,
command-routing-hub, drift. Uses `import x = require()` for export= siblings;
drift's lazy require of runtime-slash hoisted to a top-level import (verified
non-circular). Behaviour preserved; suite 3030 pass, lint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate small Wave-4 modules to TS (batch 8)

ADR-457 build-at-publish: cjs-command-router-adapter, phase-command-router,
surface, roadmap-upgrade. Typed the hub router handler results as the HubResult
discriminated union; surface drops 4 genuinely-unused imports. Behaviour
preserved; suite 3030 pass, lint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate core hub (2.5k LOC, 68 dependents) to TS (batch 9)

ADR-457 build-at-publish: get-shit-done/bin/lib/core.cjs -> src/core.cts,
preserving all 63 exports via export=. All sibling deps already migrated
(shell-command-projection, model-profiles, model-catalog, worktree-safety,
planning-workspace, project-root, configuration, config-schema). Strict types,
no any/ts-ignore; config-schema lazy require hoisted (non-circular). Behaviour
preserved (independently verified: core's shard 3030 pass / 0 fail).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#537): make ESLint-coverage + test-sprawl checks migration-aware

#551 test hardcoded 12 now-migrated modules as "hand-written, must be linted";
that invariant is obsoleted by the ADR-457 migration. Rewrite it to a
filesystem-driven invariant that holds at every stage: a bin/lib/*.cjs must be
eslint-ignored IFF it has a src/*.cts source (tsc-generated), else linted
(covers package-identity, which has no TS source). Also eslint-ignore
config-types.cjs (has a src counterpart) and drop the redundant
tests/clock.test.cjs (clock already covered by clock-seam + bug-474 tests),
which tripped the lint-test-file-count ratchet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 9 Wave-5 router/inventory modules to TS (batch 10)

ADR-457 build-at-publish: phases/verify/init/agent/task/validate/roadmap/state
command routers + workstream-inventory. Router handler results typed against
core's exported shapes; behaviour preserved (caught+fixed a --verify boolean
flag regression mid-migration). Full suite green across all shards (only the 4
local gpg-env changeset-notes failures remain; CI passes them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 7 Wave-5 modules to TS (batch 11)

ADR-457 build-at-publish: gap-checker, docs, check-command-router, frontmatter,
learnings, gsd2-import, profile-pipeline. Behaviour preserved; full suite green
across all shards (only the 4 local gpg-env failures remain). Also broadens
atomic-write-coverage.test.cjs to accept the tsc-compiled namespace-import form
while still asserting platformWriteSync is called (safety guard intact).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate config + profile-output to TS (batch 12)

ADR-457 build-at-publish: config (729 LOC), profile-output (1142 LOC). All
exports preserved; cmdMigrateConfig de-asynced (migrateOnDisk is sync, awaited
caller tolerates it). Behaviour preserved; suite green across all shards
(only the 4 local gpg-env failures). Wave 5 complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 5 Wave-6 modules to TS (batch 13)

ADR-457 build-at-publish: template, uat, workstream, roadmap, audit. Behaviour
preserved (dead toPosixPath import dropped from audit; inline requires hoisted).
Suite green across all shards (only the 4 local gpg-env failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate commands + state hubs to TS (batch 14)

ADR-457 build-at-publish: commands (1305 LOC), state (2074 LOC, 17 dependents).
All exports preserved; inner requires kept non-hoisted where load-order matters
(install.js, per-call security); acquireStateLock cast inlined to preserve the
err.code source token a structural test inspects. Behaviour preserved; suite
green across all shards (only the 4 local gpg-env failures). Wave 6 complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate milestone to TS (batch 15a, hand-authored)

ADR-457 build-at-publish: milestone -> src/milestone.cts. Authored directly
(subagent capacity was unavailable). Also relaxes core.output()'s 3rd param to
optional, matching its real always-optional call contract (unblocks remaining
2-arg output callers). Behaviour preserved; suite green across all shards
(only the 4 local gpg-env failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate phase, verify, init to TS (batch 15, final modules)

ADR-457 build-at-publish, Wave 7 (the last hubs): phase (1608 LOC), verify
(1615), init (2113). Adds src/package-identity.d.cts so verify can import the
permanently value-baked package-identity.cjs under strict TS.

Fixes two regressions the migration introduced in verify: restore
cmdValidateHealth's `return result` (callers/tests read result.warnings — it is
NOT side-effect-only), and make the bug-3384 source-pattern test tolerant of the
tsc-compiled bracket-notation form of the git_list_failed->W020 branch (behaviour
intact). Full suite green across all shards (only the 4 local gpg-env failures);
lint 0 errors. All 86 migratable bin/lib modules are now TypeScript sources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#537): finalize ADR-457 migration — retire tsconfig.lint.json

All hand-written bin/lib/*.cjs are now src/*.cts sources, so the checkJs
stopgap tsconfig.lint.json (unused; not wired into eslint, scripts, or CI) is
deleted per ADR-457's final step. Also gitignore the tsc-generated
config-types.cjs (was still committed) for consistency with every other
emitted artifact. package-identity.cjs stays value-baked (declared via
src/package-identity.d.cts). Suite green; #551 ESLint-coverage test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): add prepare script so unpacked/git installs build bin/lib artifacts

ADR-457 build-at-publish: bin/lib/*.cjs are now gitignored, built by tsc. The
prepack/prepublishOnly hooks cover `npm pack`/publish, but `npm install -g
<dir>` and git installs run the `prepare` lifecycle — which was missing — so the
unpacked install shipped without the compiled .cjs and failed at startup with
"Cannot find module './lib/core.cjs'" (caught by the smoke-unpacked CI job).
Add `prepare` mirroring prepublishOnly (build:lib + build:hooks). prepare does
NOT run for registry consumers (they get the pre-built tarball), only for
source/local/pack installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): make CI build/lockfile checks work with gitignored bin/lib artifacts

ADR-457 build-at-publish exposed two CI assumptions that bin/lib/*.cjs are
always present on disk:
- check:env's lockfile-sync ran `npm ci --dry-run`, which now triggers the
  `prepare` build (tsc) — but it runs before deps are installed, so tsc is
  absent and it misreported the lockfile as out of sync. Add --ignore-scripts
  (a lockfile check must not build).
- the lint-tests job installs with --ignore-scripts (no prepare build), but
  lint:skill-deps require()s the built install-profiles.cjs. Add an explicit
  `npm run build:lib` step after install.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): narrow prepare to build:lib only (unbreak packed-smoke pack step)

prepare running build:hooks emitted "✓ Copying ..." stdout during `npm pack`,
which the install-smoke "Pack root tarball" step captures into $GITHUB_OUTPUT —
breaking it with "Invalid format". build:lib (tsc) is silent on success and is
all the unpacked/source install needs (the smoke-unpacked assertions exercise
gsd-tools, i.e. bin/lib, and tolerate hook setup with `|| true`). Matches
prepack. build:hooks still runs on prepublishOnly for real publishes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): wire Stryker mutation gate to build-at-publish layout

The gate scored 0.00 because it mutated changed bin/lib/*.cjs that (a) were
generated artifacts and (b) included modules with no coverage in the command's
test set. Rework: mutation.yml now derives changed COVERED modules from
src/*.cts and maps them to their built bin/lib/*.cjs; Stryker mutates those
built artifacts with a no-rebuild command (mutating src/*.cts + per-mutant tsc
was ~3x over the 30-min CI budget).

NOTE: with the gate now correctly measuring the covered modules, their actual
mutation score is 42.94% (< break 50) — a pre-existing test-coverage gap
(adr-parser/prompt-budget/etc.), not introduced by this behaviour-preserving
migration. Reaching 50 needs more tests, a threshold/scope change, or a waiver —
a maintainer decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#537): raise mutation coverage of covered modules above the 50 gate

Adds focused example-based unit tests that kill surviving mutants in the two
lowest-scoring covered modules:
- tests/prompt-budget.unit.test.cjs (112 tests): 17.9% -> 97.9%
- tests/adr-parser.unit.test.cjs (205 tests): 44.7% -> 89.4%
Both wired into stryker.config.mjs's command. Fresh full run over the 6 covered
modules now scores 82.25% (>= break 50); every covered module is >= 68%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537,#609): parallelize mutation gate via dynamic per-module matrix

The serial Stryker run timed out at 30 min once the migration's added tests
made every mutant re-run ~300 tests. Replace it with a dynamic matrix so the
gate completes well under budget — folded into this PR (was tracked as #609)
because it's a prerequisite for this PR's mutation gate to pass.

- scripts/mutation-matrix.cjs: single source of truth (covered-module -> test
  files) computing changed covered modules from git diff -> {has_work, matrix}.
- mutation.yml: detect -> dynamic `matrix: fromJSON(...)` mutate job (one
  parallel shard per changed module, scoped via MUTATION_TEST_CMD to only that
  module's tests, 15-min/shard) -> summary job that KEEPS the legacy check name
  "Stryker mutation score (changed files only)" so branch protection is
  unchanged. Per-shard jobs report as "Stryker (<module>)".
- stryker.config.mjs: commandRunner.command reads MUTATION_TEST_CMD (falls back
  to the full command locally).

Closes #609.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#537,#609): give each mutation shard ≥50% on its own tests; drop blacksmith note

Per-module sharding revealed that active-workstream-store (46.5%) and
frontmatter (7.4%) only cleared 50% in the old serial run via timeout-noise from
the bloated 300-test command; on their own tests they were below the gate. Add
focused unit tests:
- tests/active-workstream-store.unit.test.cjs (115 tests): 46.5% -> 81.9%
- tests/frontmatter.unit.test.cjs (165 tests): 7.4% -> 63.4%
Both wired into scripts/mutation-matrix.cjs (per-module test map) and
stryker.config.mjs DEFAULT_TEST_CMD. All 6 covered modules now clear break:50
with only their own tests (config-schema/context-utilization/prompt-budget/
adr-parser already did). Also removes the leftover blacksmith TODO comment —
GitHub-hosted runners only; speed comes from parallel per-module shards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#537,#609): strengthen prompt-budget tests to clear the gate on its own tests

prompt-budget scored 39.58% when mutation-tested with ONLY its own tests (the
way the per-module CI shard runs it) — an earlier ~98% reading was inflated by
accidentally running the full multi-module command. Add 96 targeted tests to
tests/prompt-budget.unit.test.cjs (exact note-template text, plan-truncation
arithmetic/percentages, drop-block strings, noteInjected/hardFailed booleans):
scoped score 39.58% -> 68.75% (>= break 50). All 6 covered modules now clear
the gate on their own tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:45:01 -04:00

644 lines
22 KiB
TypeScript

/**
* Graphify integration module — config gate, subprocess execution, knowledge-graph
* query, status, diff, build pipeline, and snapshot helpers.
*
* ADR-457 build-at-publish: the hand-written bin/lib/graphify.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execTool, execGit, platformWriteSync } from './shell-command-projection.cjs';
// ─── Config Gate ─────────────────────────────────────────────────────────────
/**
* Check whether graphify is enabled in the project config.
* Reads config.json directly via fs. Returns false by default
* (when no config, no graphify key, or on error).
*/
function isGraphifyEnabled(planningDir: string): boolean {
try {
const configPath = path.join(planningDir, 'config.json');
if (!fs.existsSync(configPath)) return false;
const config: unknown = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (
config &&
typeof config === 'object' &&
'graphify' in config &&
config.graphify &&
typeof config.graphify === 'object' &&
'enabled' in config.graphify &&
(config.graphify as Record<string, unknown>).enabled === true
) return true;
return false;
} catch (_e) {
return false;
}
}
interface DisabledResponse {
disabled: true;
message: string;
}
/**
* Return the standard disabled response object.
*/
function disabledResponse(): DisabledResponse {
return { disabled: true, message: 'graphify is not enabled. Enable with: gsd-tools config-set graphify.enabled true' };
}
// ─── Subprocess Helper ───────────────────────────────────────────────────────
/**
* Frozen enum of typed reason codes for execGraphify failures (#2974).
* Tests assert on result.reason instead of grepping stderr text.
*/
const GRAPHIFY_REASON = Object.freeze({
OK: 'ok',
ENOENT: 'graphify_not_found',
TIMEOUT: 'graphify_timed_out',
EXIT_NONZERO: 'graphify_exit_nonzero',
} as const);
type GraphifyReason = typeof GRAPHIFY_REASON[keyof typeof GRAPHIFY_REASON];
interface GraphifyExecResult {
exitCode: number;
stdout: string;
stderr: string;
reason: GraphifyReason;
timeout_ms?: number;
}
/**
* Execute graphify CLI as a subprocess with proper env and timeout handling.
*/
function execGraphify(cwd: string, args: string[], options: { timeout?: number } = {}): GraphifyExecResult {
const timeout = options.timeout ?? 30000;
const result = execTool('graphify', args, {
cwd,
timeout,
env: { ...process.env, PYTHONUNBUFFERED: '1' },
});
// ENOENT — seam normalizes to exitCode 127. Surface as typed reason.
if (result.error && (result.error as NodeJS.ErrnoException).code === 'ENOENT') {
return {
exitCode: 127,
stdout: '',
stderr: 'graphify not found on PATH',
reason: GRAPHIFY_REASON.ENOENT,
};
}
// Timeout — seam exposes signal; spawnSync sets SIGTERM when killed by timeout.
if (result.signal === 'SIGTERM') {
return {
exitCode: 124,
stdout: result.stdout,
stderr: 'graphify timed out after ' + timeout + 'ms',
reason: GRAPHIFY_REASON.TIMEOUT,
timeout_ms: timeout,
};
}
return {
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
reason: result.exitCode === 0 ? GRAPHIFY_REASON.OK : GRAPHIFY_REASON.EXIT_NONZERO,
};
}
// ─── Presence & Version ──────────────────────────────────────────────────────
interface InstalledResult {
installed: boolean;
message?: string;
}
/**
* Check whether the graphify CLI binary is installed and accessible on PATH.
* Uses --help (NOT --version, which graphify does not support).
*/
function checkGraphifyInstalled(): InstalledResult {
const result = execTool('graphify', ['--help'], { timeout: 5000 });
if (result.error) {
return {
installed: false,
message: 'graphify is not installed.\n\nInstall with:\n uv pip install graphifyy && graphify install',
};
}
return { installed: true };
}
interface VersionResult {
version: string | null;
compatible: boolean | null;
warning: string | null;
}
/**
* Detect graphify version and check compatibility.
* Tested range: >=0.4.0,<1.0
*
* Detection strategy:
* 1. Try `graphify --version` (works for most CLI installations, incl. venv installs)
* 2. Fall back to python3 importlib.metadata (legacy / system Python path)
* 3. Return null version gracefully if both fail
*/
function checkGraphifyVersion(): VersionResult {
// Strategy 1: try `graphify --version` directly (2s timeout -- fast path)
const versionResult = execTool('graphify', ['--version'], { timeout: 2000 });
let versionStr: string | null = null;
if (!versionResult.error && versionResult.exitCode === 0) {
// graphify --version may emit "graphify 0.4.23" or just "0.4.23"
const match = versionResult.stdout.match(/(\d+\.\d+(?:\.\d+)*)/);
if (match) {
versionStr = match[1];
}
}
// Strategy 2: fall back to python3 importlib.metadata
if (!versionStr) {
const pyResult = execTool('python3', [
'-c',
'from importlib.metadata import version; print(version("graphifyy"))',
], { timeout: 5000 });
if (!pyResult.error && pyResult.exitCode === 0 && pyResult.stdout) {
versionStr = pyResult.stdout;
}
}
if (!versionStr) {
return { version: null, compatible: null, warning: 'Could not determine graphify version' };
}
const parts = versionStr.split('.').map(Number);
if (parts.length < 2 || parts.some(isNaN)) {
return { version: versionStr, compatible: null, warning: 'Could not parse version: ' + versionStr };
}
const compatible = parts[0] === 0 && parts[1] >= 4;
const warning = compatible ? null : 'graphify version ' + versionStr + ' is outside tested range >=0.4.0,<1.0';
return { version: versionStr, compatible, warning };
}
// ─── Internal Helpers ────────────────────────────────────────────────────────
interface GraphNode {
id: string;
label?: string;
description?: string;
[key: string]: unknown;
}
interface GraphEdge {
source: string;
target: string;
label?: string;
relation?: string;
confidence?: string;
confidence_score?: string;
[key: string]: unknown;
}
interface Graph {
nodes?: GraphNode[];
edges?: GraphEdge[];
links?: GraphEdge[];
hyperedges?: unknown[];
built_at_commit?: unknown;
[key: string]: unknown;
}
/**
* Safely read and parse a JSON file. Returns null on missing file or parse error.
* Prevents crashes on malformed JSON (T-02-01 mitigation).
*/
function safeReadJson(filePath: string): Graph | null {
try {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as Graph;
} catch (_e) {
return null;
}
}
interface AdjEntry {
target: string;
edge: GraphEdge;
}
/**
* Build a bidirectional adjacency map from graph nodes and edges.
* Each node ID maps to an array of { target, edge } entries.
* Bidirectional: both source->target and target->source are added (Pitfall 3).
*/
function buildAdjacencyMap(graph: Graph): Record<string, AdjEntry[]> {
const adj: Record<string, AdjEntry[]> = {};
for (const node of (graph.nodes || [])) {
adj[node.id] = [];
}
for (const edge of (graph.edges || graph.links || [])) {
if (!adj[edge.source]) adj[edge.source] = [];
if (!adj[edge.target]) adj[edge.target] = [];
adj[edge.source].push({ target: edge.target, edge });
adj[edge.target].push({ target: edge.source, edge });
}
return adj;
}
interface ExpandResult {
nodes: GraphNode[];
edges: GraphEdge[];
seeds: Set<string>;
trimmed?: string | null;
}
/**
* Seed-then-expand query: find nodes matching term, then BFS-expand up to maxHops.
* Matches on node label and description (case-insensitive substring, D-01).
*/
function seedAndExpand(graph: Graph, term: string, maxHops = 2): ExpandResult {
const lowerTerm = term.toLowerCase();
const nodeMap = Object.fromEntries((graph.nodes || []).map(n => [n.id, n]));
const adj = buildAdjacencyMap(graph);
// Seed: match on label and description (case-insensitive substring)
const seeds = (graph.nodes || []).filter(n =>
(n.label || '').toLowerCase().includes(lowerTerm) ||
(n.description || '').toLowerCase().includes(lowerTerm)
);
// BFS expand from seeds
const visitedNodes = new Set(seeds.map(n => n.id));
const collectedEdges: GraphEdge[] = [];
const seenEdgeKeys = new Set<string>();
let frontier = seeds.map(n => n.id);
for (let hop = 0; hop < maxHops && frontier.length > 0; hop++) {
const nextFrontier: string[] = [];
for (const nodeId of frontier) {
for (const entry of (adj[nodeId] || [])) {
// Deduplicate edges by source::target::label key
const edgeKey = `${entry.edge.source}::${entry.edge.target}::${entry.edge.label || ''}`;
if (!seenEdgeKeys.has(edgeKey)) {
seenEdgeKeys.add(edgeKey);
collectedEdges.push(entry.edge);
}
if (!visitedNodes.has(entry.target)) {
visitedNodes.add(entry.target);
nextFrontier.push(entry.target);
}
}
}
frontier = nextFrontier;
}
const resultNodes = [...visitedNodes].map(id => nodeMap[id]).filter((n): n is GraphNode => Boolean(n));
return { nodes: resultNodes, edges: collectedEdges, seeds: new Set(seeds.map(n => n.id)) };
}
interface BudgetResult {
nodes: GraphNode[];
edges: GraphEdge[];
trimmed: string | null;
total_nodes: number;
total_edges: number;
}
/**
* Apply token budget by dropping edges by confidence tier (D-04, D-05, D-06).
* Token estimation: Math.ceil(JSON.stringify(obj).length / 4).
* Drop order: AMBIGUOUS -> INFERRED -> EXTRACTED.
*/
function applyBudget(result: ExpandResult, budgetTokens: number | null): ExpandResult | BudgetResult {
if (!budgetTokens) return result;
const CONFIDENCE_ORDER = ['AMBIGUOUS', 'INFERRED', 'EXTRACTED'];
let edges = [...result.edges];
let omitted = 0;
const estimateTokens = (obj: unknown) => Math.ceil(JSON.stringify(obj).length / 4);
for (const tier of CONFIDENCE_ORDER) {
if (estimateTokens({ nodes: result.nodes, edges }) <= budgetTokens) break;
const before = edges.length;
// Check both confidence and confidence_score field names (Open Question 1)
edges = edges.filter(e => (e.confidence || e.confidence_score) !== tier);
omitted += before - edges.length;
}
// Find unreachable nodes after edge removal
const reachableNodes = new Set<string>();
for (const edge of edges) {
reachableNodes.add(edge.source);
reachableNodes.add(edge.target);
}
// Always keep seed nodes
const nodes = result.nodes.filter(n => reachableNodes.has(n.id) || (result.seeds && result.seeds.has(n.id)));
const unreachable = result.nodes.length - nodes.length;
return {
nodes,
edges,
trimmed: omitted > 0 ? `[${omitted} edges omitted, ${unreachable} nodes unreachable]` : null,
total_nodes: nodes.length,
total_edges: edges.length,
};
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Strict 4-40 hex fence for graph.built_at_commit values (#3170). Anything
* else (dashed, prose, empty) is treated as absent so a hostile graph.json
* cannot smuggle a `--upload-pack=…` option into a `git` argv.
*/
const COMMIT_HASH_RE = /^[0-9a-f]{4,40}$/i;
/**
* Read git HEAD for the project at `cwd`. Returns the full commit hash on
* success, or null when cwd is not a git repo / `git` is not on PATH.
*/
function readGitHead(cwd: string): string | null {
const r = execGit(['rev-parse', 'HEAD'], { cwd });
if (r.exitCode !== 0) return null;
return r.stdout.trim() || null;
}
/**
* Count commits between `from` and `to` (exclusive..inclusive, like
* `git rev-list --count A..B`). Returns null when either ref is unreachable
* or the cwd is not a git repo.
*/
function countCommitsBetween(cwd: string, from: string, to: string): number | null {
const r = execGit(['rev-list', '--count', `${from}..${to}`], { cwd });
if (r.exitCode !== 0) return null;
const n = parseInt(r.stdout.trim(), 10);
return Number.isFinite(n) ? n : null;
}
/**
* Query the knowledge graph for nodes matching a term, with optional budget cap.
* Uses seed-then-expand BFS traversal (D-01).
*/
function graphifyQuery(cwd: string, term: string, options: { budget?: number | null } = {}): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
if (!fs.existsSync(graphPath)) {
return { error: 'No graph built yet. Run graphify build first.' };
}
const graph = safeReadJson(graphPath);
if (!graph) {
return { error: 'Failed to parse graph.json' };
}
let result: ExpandResult | BudgetResult = seedAndExpand(graph, term);
if (options.budget) {
result = applyBudget(result, options.budget);
}
return {
term,
nodes: result.nodes,
edges: result.edges,
total_nodes: result.nodes.length,
total_edges: result.edges.length,
trimmed: 'trimmed' in result ? (result.trimmed || null) : null,
};
}
/**
* Return status information about the knowledge graph (STAT-01, STAT-02).
*
* Surfaces the graphify v0.7+ commit-staleness signal as four optional
* fields when graph.built_at_commit is present and validly formatted
* (#3170). Tri-state on commit_stale: null means "we don't know" (pre-v0.7
* graph, no git, or unreachable commit), distinct from false ("known
* fresh").
*/
function graphifyStatus(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
if (!fs.existsSync(graphPath)) {
return { exists: false, message: 'No graph built yet. Run graphify build to create one.' };
}
const stat = fs.statSync(graphPath);
const graph = safeReadJson(graphPath);
if (!graph) {
return { error: 'Failed to parse graph.json' };
}
const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
const age = Date.now() - stat.mtimeMs;
// Commit-staleness signal (#3170). Validate before passing to git.
const builtAtCommit = graph.built_at_commit;
const rawBuilt = (typeof builtAtCommit === 'string' ? builtAtCommit : '').trim();
const builtAt = COMMIT_HASH_RE.test(rawBuilt) ? rawBuilt : null;
const head = readGitHead(cwd);
let commitsBehind: number | null = null;
let commitStale: boolean | null = null;
if (builtAt && head) {
commitsBehind = countCommitsBetween(cwd, builtAt, head);
if (commitsBehind !== null) commitStale = commitsBehind > 0;
}
// Auto-update status (#3347). Read .last-build-status.json written by the
// hooks/gsd-graphify-update.sh PostToolUse hook (opt-in via graphify.auto_update,
// default false). When the most recent auto-build is "failed" or still "running",
// fold that into the existing `stale: true` signal so consumers (gsd-planner,
// gsd-phase-researcher) surface the standard "treat semantic relationships as
// approximate" annotation without per-consumer prompt changes. The full state
// (running/failed/exit_code/duration_ms/head_at_build) is exposed under
// `last_build` for callers that want richer context.
const statusPath = path.join(planningDir, 'graphs', '.last-build-status.json');
const lastBuildAutoUpdate = fs.existsSync(statusPath) ? safeReadJson(statusPath) : null;
const autoUpdateStale =
lastBuildAutoUpdate &&
(lastBuildAutoUpdate.status === 'failed' || lastBuildAutoUpdate.status === 'running');
return {
exists: true,
last_build: stat.mtime.toISOString(),
node_count: (graph.nodes || []).length,
edge_count: (graph.edges || graph.links || []).length,
hyperedge_count: (graph.hyperedges || []).length,
stale: age > STALE_MS || Boolean(autoUpdateStale),
age_hours: Math.round(age / (60 * 60 * 1000)),
built_at_commit: builtAt ? builtAt.slice(0, 7) : null,
current_commit: head ? head.slice(0, 7) : null,
commits_behind: commitsBehind,
commit_stale: commitStale,
last_build_auto_update: lastBuildAutoUpdate || null,
};
}
/**
* Compute topology-level diff between current graph and last build snapshot (D-07, D-08, D-09).
*/
function graphifyDiff(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
const snapshotPath = path.join(planningDir, 'graphs', '.last-build-snapshot.json');
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
if (!fs.existsSync(snapshotPath)) {
return { no_baseline: true, message: 'No previous snapshot. Run graphify build first, then build again to generate a diff baseline.' };
}
if (!fs.existsSync(graphPath)) {
return { error: 'No current graph. Run graphify build first.' };
}
const current = safeReadJson(graphPath);
const snapshot = safeReadJson(snapshotPath);
if (!current || !snapshot) {
return { error: 'Failed to parse graph or snapshot file' };
}
// Diff nodes
const currentNodeMap = Object.fromEntries((current.nodes || []).map(n => [n.id, n]));
const snapshotNodeMap = Object.fromEntries((snapshot.nodes || []).map(n => [n.id, n]));
const nodesAdded = Object.keys(currentNodeMap).filter(id => !snapshotNodeMap[id]);
const nodesRemoved = Object.keys(snapshotNodeMap).filter(id => !currentNodeMap[id]);
const nodesChanged = Object.keys(currentNodeMap).filter(id =>
snapshotNodeMap[id] && JSON.stringify(currentNodeMap[id]) !== JSON.stringify(snapshotNodeMap[id])
);
// Diff edges (keyed by source+target+relation)
const edgeKey = (e: GraphEdge) => `${e.source}::${e.target}::${e.relation || e.label || ''}`;
const currentEdgeMap = Object.fromEntries((current.edges || current.links || []).map(e => [edgeKey(e), e]));
const snapshotEdgeMap = Object.fromEntries((snapshot.edges || snapshot.links || []).map(e => [edgeKey(e), e]));
const edgesAdded = Object.keys(currentEdgeMap).filter(k => !snapshotEdgeMap[k]);
const edgesRemoved = Object.keys(snapshotEdgeMap).filter(k => !currentEdgeMap[k]);
const edgesChanged = Object.keys(currentEdgeMap).filter(k =>
snapshotEdgeMap[k] && JSON.stringify(currentEdgeMap[k]) !== JSON.stringify(snapshotEdgeMap[k])
);
return {
nodes: { added: nodesAdded.length, removed: nodesRemoved.length, changed: nodesChanged.length },
edges: { added: edgesAdded.length, removed: edgesRemoved.length, changed: edgesChanged.length },
timestamp: snapshot.timestamp || null,
};
}
// ─── Build Pipeline (Phase 3) ───────────────────────────────────────────────
/**
* Pre-flight checks for graphify build (BUILD-01, BUILD-02, D-09).
* Does NOT invoke graphify -- returns structured JSON for the builder agent.
*/
function graphifyBuild(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
const installed = checkGraphifyInstalled();
if (!installed.installed) return { error: installed.message };
const version = checkGraphifyVersion();
// Ensure output directory exists (D-05)
const graphsDir = path.join(planningDir, 'graphs');
fs.mkdirSync(graphsDir, { recursive: true });
// Read build timeout from config -- default 300s per D-02
const config = safeReadJson(path.join(planningDir, 'config.json')) || {};
const graphifyConfig = config.graphify as Record<string, unknown> | undefined;
const timeoutSec = (graphifyConfig && graphifyConfig.build_timeout) || 300;
return {
action: 'spawn_agent',
graphs_dir: graphsDir,
graphify_out: path.join(cwd, 'graphify-out'),
timeout_seconds: timeoutSec,
version: version.version,
version_warning: version.warning,
artifacts: ['graph.json', 'graph.html', 'GRAPH_REPORT.md'],
};
}
interface SnapshotResult {
saved: boolean;
timestamp: string;
node_count: number;
edge_count: number;
}
/**
* Write a diff snapshot after successful build (D-06).
* Reads graph.json from .planning/graphs/ and writes .last-build-snapshot.json
* using platformWriteSync for crash safety.
*/
function writeSnapshot(cwd: string): SnapshotResult | { error: string } {
const graphPath = path.join(cwd, '.planning', 'graphs', 'graph.json');
const graph = safeReadJson(graphPath);
if (!graph) return { error: 'Cannot write snapshot: graph.json not parseable' };
const snapshot = {
version: 1,
timestamp: new Date().toISOString(),
nodes: graph.nodes || [],
edges: graph.edges || graph.links || [],
};
const snapshotPath = path.join(cwd, '.planning', 'graphs', '.last-build-snapshot.json');
platformWriteSync(snapshotPath, JSON.stringify(snapshot, null, 2));
return {
saved: true,
timestamp: snapshot.timestamp,
node_count: snapshot.nodes.length,
edge_count: snapshot.edges.length,
};
}
// ─── Exports ─────────────────────────────────────────────────────────────────
export = {
// Config gate
isGraphifyEnabled,
disabledResponse,
// Subprocess
execGraphify,
GRAPHIFY_REASON,
// Presence and version
checkGraphifyInstalled,
checkGraphifyVersion,
// Query (Phase 2)
graphifyQuery,
safeReadJson,
buildAdjacencyMap,
seedAndExpand,
applyBudget,
// Status (Phase 2)
graphifyStatus,
// Diff (Phase 2)
graphifyDiff,
// Build (Phase 3)
graphifyBuild,
writeSnapshot,
};