Pay down pre-existing error→warn lint debt. Removes dead imports/vars, unused functions, redundant regex/string escapes, and stale eslint-disable directives; converts unused `catch (_e)` to optional catch binding (src/*.cts). No behavior change. Lint 345→125 warnings (0 errors); deferred categories (n/no-process-exit, test-sleeps, control-regex) tracked in #732 for follow-up. Full test suite green (0 failures); code-review verified all removals unused and all escape fixes semantics-preserving. Closes #732 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
644 lines
22 KiB
TypeScript
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 {
|
|
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 {
|
|
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,
|
|
};
|