From c11ec0555451338c3ef666753ec2b24731d7a2c7 Mon Sep 17 00:00:00 2001 From: pingchesu <33800609+pingchesu@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:17:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20/gsd-graphify=20integration=20=E2=80=94?= =?UTF-8?q?=20knowledge=20graph=20for=20planning=20agents=20(#2164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(01-01): create graphify.cjs library module with config gate, subprocess helper, presence detection, and version check - isGraphifyEnabled() gates on config.graphify.enabled in .planning/config.json - disabledResponse() returns structured disabled message with enable instructions - execGraphify() wraps spawnSync with PYTHONUNBUFFERED=1, 30s timeout, ENOENT/SIGTERM handling - checkGraphifyInstalled() detects missing binary via --help probe - checkGraphifyVersion() uses python3 importlib.metadata, validates >=0.4.0,<1.0 range * feat(01-01): register graphify.enabled in VALID_CONFIG_KEYS - Added graphify.enabled after intel.enabled in config.cjs VALID_CONFIG_KEYS Set - Enables gsd-tools config-set graphify.enabled true without key rejection * test(01-02): add comprehensive unit tests for graphify.cjs module - 23 tests covering all 5 exported functions across 5 describe blocks - Config gate tests: enabled/disabled/missing/malformed scenarios (TEST-03, FOUND-01) - Subprocess tests: success, ENOENT, timeout, env vars, timeout override (FOUND-04) - Presence tests: --help detection, install instructions (FOUND-02, TEST-04) - Version tests: compatible/incompatible/unparseable/missing (FOUND-03, TEST-04) - Fix graphify.cjs to use childProcess.spawnSync (not destructured) for testability * feat(02-01): add graphifyQuery, graphifyStatus, graphifyDiff to graphify.cjs - safeReadJson wraps JSON.parse in try/catch, returns null on failure - buildAdjacencyMap creates bidirectional adjacency map from graph nodes/edges - seedAndExpand matches on label+description (case-insensitive), BFS-expands up to maxHops - applyBudget uses chars/4 token estimation, drops AMBIGUOUS then INFERRED edges - graphifyQuery gates on config, reads graph.json, supports --budget option - graphifyStatus returns exists/last_build/counts/staleness or no-graph message - graphifyDiff compares current graph.json against .last-build-snapshot.json * feat(02-01): add case 'graphify' routing block to gsd-tools.cjs - Routes query/status/diff/build subcommands to graphify.cjs handlers - Query supports --budget flag via args.indexOf parsing - Build returns Phase 3 placeholder error message - Unknown subcommand lists all 4 available options * feat(02-01): create commands/gsd/graphify.md command definition - YAML frontmatter with name, description, argument-hint, allowed-tools - Config gate reads .planning/config.json directly (not gsd-tools config get-value) - Inline CLI calls for query/status/diff subcommands - Agent spawn placeholder for build subcommand - Anti-read warning and anti-patterns section * test(02-02): add Phase 2 test scaffolding with fixture helpers and describe blocks - Import 7 Phase 2 exports (graphifyQuery, graphifyStatus, graphifyDiff, safeReadJson, buildAdjacencyMap, seedAndExpand, applyBudget) - Add writeGraphJson and writeSnapshotJson fixture helpers - Add SAMPLE_GRAPH constant with 5 nodes, 5 edges across all confidence tiers - Scaffold 7 new describe blocks for Phase 2 functions * test(02-02): add comprehensive unit tests for all Phase 2 graphify.cjs functions - safeReadJson: valid JSON, malformed JSON, missing file (3 tests) - buildAdjacencyMap: bidirectional entries, orphan nodes, edge objects (3 tests) - seedAndExpand: label match, description match, BFS depth, empty results, maxHops (5 tests) - applyBudget: no budget passthrough, AMBIGUOUS drop, INFERRED drop, trimmed footer (4 tests) - graphifyQuery: disabled gate, no graph, valid query, confidence tiers, budget, counts (6 tests) - graphifyStatus: disabled gate, no graph, counts with graph, hyperedge count (4 tests) - graphifyDiff: disabled gate, no baseline, no graph, added/removed, changed (5 tests) - Requirements: TEST-01, QUERY-01..03, STAT-01..02, DIFF-01..02 - Full suite: 53 graphify tests pass, 3666 total tests pass (0 regressions) * feat(03-01): add graphifyBuild() pre-flight, writeSnapshot(), and build_timeout config key - Add graphifyBuild(cwd) returning spawn_agent JSON with graphs_dir, timeout, version - Add writeSnapshot(cwd) reading graph.json and writing atomic .last-build-snapshot.json - Register graphify.build_timeout in VALID_CONFIG_KEYS - Import atomicWriteFileSync from core.cjs for crash-safe snapshot writes * feat(03-01): wire build routing in gsd-tools and flesh out builder agent prompt - Replace Phase 3 placeholder with graphifyBuild() and writeSnapshot() dispatch - Route 'graphify build snapshot' to writeSnapshot(), 'graphify build' to graphifyBuild() - Expand Step 3 builder agent prompt with 5-step workflow: invoke, validate, copy, snapshot, summary - Include error handling guidance: non-zero exit preserves prior .planning/graphs/ * test(03-02): add graphifyBuild test suite with 6 tests - Disabled config returns disabled response - Missing CLI returns error with install instructions - Successful pre-flight returns spawn_agent action with correct shape - Creates .planning/graphs/ directory if missing - Reads graphify.build_timeout from config (custom 600s) - Version warning included when outside tested range * test(03-02): add writeSnapshot test suite with 6 tests - Writes snapshot from existing graph.json with correct structure - Returns error when graph.json does not exist - Returns error when graph.json is invalid JSON - Handles empty nodes and edges arrays - Handles missing nodes/edges keys gracefully - Overwrites existing snapshot on incremental rebuild * feat(04-01): add load_graph_context step to gsd-planner agent - Detects .planning/graphs/graph.json via ls check - Checks graph staleness via graphify status CLI call - Queries phase-relevant context with single --budget 2000 query - Silent no-op when graph.json absent (AGENT-01) * feat(04-01): add Step 1.3 Load Graph Context to gsd-phase-researcher agent - Detects .planning/graphs/graph.json via ls check - Checks graph staleness via graphify status CLI call - Queries 2-3 capability keywords with --budget 1500 each - Silent no-op when graph.json absent (AGENT-02) * test(04-01): add AGENT-03 graceful degradation tests - 3 AGENT-03 tests: absent-graph query, status, multi-term handling - 2 D-12 integration tests: known-graph query and status structure - All 5 tests pass with existing helpers and imports --- agents/gsd-phase-researcher.md | 35 + agents/gsd-planner.md | 34 + commands/gsd/graphify.md | 199 ++++++ get-shit-done/bin/gsd-tools.cjs | 27 + get-shit-done/bin/lib/config.cjs | 2 + get-shit-done/bin/lib/graphify.cjs | 494 +++++++++++++ tests/graphify.test.cjs | 1051 ++++++++++++++++++++++++++++ 7 files changed, 1842 insertions(+) create mode 100644 commands/gsd/graphify.md create mode 100644 get-shit-done/bin/lib/graphify.cjs create mode 100644 tests/graphify.test.cjs diff --git a/agents/gsd-phase-researcher.md b/agents/gsd-phase-researcher.md index 66c7dc7d0..284e27b1f 100644 --- a/agents/gsd-phase-researcher.md +++ b/agents/gsd-phase-researcher.md @@ -540,6 +540,41 @@ cat "$phase_dir"/*-CONTEXT.md 2>/dev/null - User decided "simple UI, no animations" → don't research animation libraries - Marked as Claude's discretion → research options and recommend +## Step 1.3: Load Graph Context + +Check for knowledge graph: + +```bash +ls .planning/graphs/graph.json 2>/dev/null +``` + +If graph.json exists, check freshness: + +```bash +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" graphify status +``` + +If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. + +Query the graph for each major capability in the phase scope (2-3 queries per D-05, discovery-focused): + +```bash +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" graphify query "" --budget 1500 +``` + +Derive query terms from the phase goal and requirement descriptions. Examples: +- Phase "user authentication and session management" -> query "authentication", "session", "token" +- Phase "payment integration" -> query "payment", "billing" +- Phase "build pipeline" -> query "build", "compile" + +Use graph results to: +- Discover non-obvious cross-document relationships (e.g., a config file related to an API module) +- Identify architectural boundaries that affect the phase +- Surface dependencies the phase description does not explicitly mention +- Inform which subsystems to investigate more deeply in subsequent research steps + +If no results or graph.json absent, continue to Step 1.5 without graph context. + ## Step 1.5: Architectural Responsibility Mapping Before diving into framework-specific research, map each capability in this phase to its standard architectural tier owner. This is a pure reasoning step — no tool calls needed. diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index fa7cdf30e..bcd26ed84 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -875,6 +875,40 @@ If exists, load relevant documents by phase type: | (default) | STACK.md, ARCHITECTURE.md | + +Check for knowledge graph: + +```bash +ls .planning/graphs/graph.json 2>/dev/null +``` + +If graph.json exists, check freshness: + +```bash +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" graphify status +``` + +If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. + +Query the graph for phase-relevant dependency context (single query per D-06): + +```bash +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" graphify query "" --budget 2000 +``` + +Use the keyword that best captures the phase goal. Examples: +- Phase "User Authentication" -> query term "auth" +- Phase "Payment Integration" -> query term "payment" +- Phase "Database Migration" -> query term "migration" + +If the query returns nodes and edges, incorporate as dependency context for planning: +- Which modules/files are semantically related to this phase's domain +- Which subsystems may be affected by changes in this phase +- Cross-document relationships that inform task ordering and wave structure + +If no results or graph.json absent, continue without graph context. + + ```bash cat .planning/ROADMAP.md diff --git a/commands/gsd/graphify.md b/commands/gsd/graphify.md new file mode 100644 index 000000000..1debb0f2e --- /dev/null +++ b/commands/gsd/graphify.md @@ -0,0 +1,199 @@ +--- +name: gsd:graphify +description: "Build, query, and inspect the project knowledge graph in .planning/graphs/" +argument-hint: "[build|query |status|diff]" +allowed-tools: + - Read + - Bash + - Task +--- + +**STOP -- DO NOT READ THIS FILE. You are already reading it. This prompt was injected into your context by Claude Code's command system. Using the Read tool on this file wastes tokens. Begin executing Step 0 immediately.** + +## Step 0 -- Banner + +**Before ANY tool calls**, display this banner: + +``` +GSD > GRAPHIFY +``` + +Then proceed to Step 1. + +## Step 1 -- Config Gate + +Check if graphify is enabled by reading `.planning/config.json` directly using the Read tool. + +**DO NOT use the gsd-tools config get-value command** -- it hard-exits on missing keys. + +1. Read `.planning/config.json` using the Read tool +2. If the file does not exist: display the disabled message below and **STOP** +3. Parse the JSON content. Check if `config.graphify && config.graphify.enabled === true` +4. If `graphify.enabled` is NOT explicitly `true`: display the disabled message below and **STOP** +5. If `graphify.enabled` is `true`: proceed to Step 2 + +**Disabled message:** + +``` +GSD > GRAPHIFY + +Knowledge graph is disabled. To activate: + + node $HOME/.claude/get-shit-done/bin/gsd-tools.cjs config-set graphify.enabled true + +Then run /gsd-graphify build to create the initial graph. +``` + +--- + +## Step 2 -- Parse Argument + +Parse `$ARGUMENTS` to determine the operation mode: + +| Argument | Action | +|----------|--------| +| `build` | Spawn graphify-builder agent (Step 3) | +| `query ` | Run inline query (Step 2a) | +| `status` | Run inline status check (Step 2b) | +| `diff` | Run inline diff check (Step 2c) | +| No argument or unknown | Show usage message | + +**Usage message** (shown when no argument or unrecognized argument): + +``` +GSD > GRAPHIFY + +Usage: /gsd-graphify + +Modes: + build Build or rebuild the knowledge graph + query Search the graph for a term + status Show graph freshness and statistics + diff Show changes since last build +``` + +### Step 2a -- Query + +Run: + +```bash +node $HOME/.claude/get-shit-done/bin/gsd-tools.cjs graphify query +``` + +Parse the JSON output and display results: +- If the output contains `"disabled": true`, display the disabled message from Step 1 and **STOP** +- If the output contains `"error"` field, display the error message and **STOP** +- If no nodes found, display: `No graph matches for ''. Try /gsd-graphify build to create or rebuild the graph.` +- Otherwise, display matched nodes grouped by type, with edge relationships and confidence tiers (EXTRACTED/INFERRED/AMBIGUOUS) + +**STOP** after displaying results. Do not spawn an agent. + +### Step 2b -- Status + +Run: + +```bash +node $HOME/.claude/get-shit-done/bin/gsd-tools.cjs graphify status +``` + +Parse the JSON output and display: +- If `exists: false`, display the message field +- Otherwise show last build time, node/edge/hyperedge counts, and STALE or FRESH indicator + +**STOP** after displaying status. Do not spawn an agent. + +### Step 2c -- Diff + +Run: + +```bash +node $HOME/.claude/get-shit-done/bin/gsd-tools.cjs graphify diff +``` + +Parse the JSON output and display: +- If `no_baseline: true`, display the message field +- Otherwise show node and edge change counts (added/removed/changed) + +If no snapshot exists, suggest running `build` twice (first to create, second to generate a diff baseline). + +**STOP** after displaying diff. Do not spawn an agent. + +--- + +## Step 3 -- Build (Agent Spawn) + +Run pre-flight check first: + +``` +PREFLIGHT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" graphify build) +``` + +If pre-flight returns `disabled: true` or `error`, display the message and **STOP**. + +If pre-flight returns `action: "spawn_agent"`, display: + +``` +GSD > Spawning graphify-builder agent... +``` + +Spawn a Task: + +``` +Task( + description="Build or rebuild the project knowledge graph", + prompt="You are the graphify-builder agent. Your job is to build or rebuild the project knowledge graph using the graphify CLI. + +Project root: ${CWD} +gsd-tools path: $HOME/.claude/get-shit-done/bin/gsd-tools.cjs + +## Instructions + +1. **Invoke graphify:** + Run from the project root: + ``` + graphify . --update + ``` + This builds the knowledge graph with SHA256 incremental caching. + Timeout: up to 5 minutes (or as configured via graphify.build_timeout). + +2. **Validate output:** + Check that graphify-out/graph.json exists and is valid JSON with nodes[] and edges[] arrays. + If graphify exited non-zero or graph.json is not parseable, output: + ## GRAPHIFY BUILD FAILED + Include the stderr output for debugging. Do NOT delete .planning/graphs/ -- prior valid graph remains available. + +3. **Copy artifacts to .planning/graphs/:** + ``` + cp graphify-out/graph.json .planning/graphs/graph.json + cp graphify-out/graph.html .planning/graphs/graph.html + cp graphify-out/GRAPH_REPORT.md .planning/graphs/GRAPH_REPORT.md + ``` + These three files are the build output consumed by query, status, and diff commands. + +4. **Write diff snapshot:** + ``` + node \"$HOME/.claude/get-shit-done/bin/gsd-tools.cjs\" graphify build snapshot + ``` + This creates .planning/graphs/.last-build-snapshot.json for future diff comparisons. + +5. **Report build summary:** + ``` + node \"$HOME/.claude/get-shit-done/bin/gsd-tools.cjs\" graphify status + ``` + Display the node count, edge count, and hyperedge count from the status output. + +When complete, output: ## GRAPHIFY BUILD COMPLETE with the summary counts. +If something fails at any step, output: ## GRAPHIFY BUILD FAILED with details." +) +``` + +Wait for the agent to complete. + +--- + +## Anti-Patterns + +1. DO NOT spawn an agent for query/status/diff operations -- these are inline CLI calls +2. DO NOT modify graph files directly -- the build agent handles writes +3. DO NOT skip the config gate check +4. DO NOT use gsd-tools config get-value for the config gate -- it exits on missing keys diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 555d52b2e..b09013753 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -1080,6 +1080,33 @@ async function runCommand(command, args, cwd, raw, defaultValue) { break; } + // ─── Graphify ────────────────────────────────────────────────────────── + + case 'graphify': { + const graphify = require('./lib/graphify.cjs'); + const subcommand = args[1]; + if (subcommand === 'query') { + const term = args[2]; + if (!term) error('Usage: gsd-tools graphify query '); + const budgetIdx = args.indexOf('--budget'); + const budget = budgetIdx !== -1 ? parseInt(args[budgetIdx + 1], 10) : null; + core.output(graphify.graphifyQuery(cwd, term, { budget }), raw); + } else if (subcommand === 'status') { + core.output(graphify.graphifyStatus(cwd), raw); + } else if (subcommand === 'diff') { + core.output(graphify.graphifyDiff(cwd), raw); + } else if (subcommand === 'build') { + if (args[2] === 'snapshot') { + core.output(graphify.writeSnapshot(cwd), raw); + } else { + core.output(graphify.graphifyBuild(cwd), raw); + } + } else { + error('Unknown graphify subcommand. Available: build, query, status, diff'); + } + break; + } + // ─── Documentation ──────────────────────────────────────────────────── case 'docs-init': { diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 646ed137a..3d532a7bb 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -46,6 +46,8 @@ const VALID_CONFIG_KEYS = new Set([ 'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute', 'response_language', 'intel.enabled', + 'graphify.enabled', + 'graphify.build_timeout', 'claude_md_path', ]); diff --git a/get-shit-done/bin/lib/graphify.cjs b/get-shit-done/bin/lib/graphify.cjs new file mode 100644 index 000000000..8dc640839 --- /dev/null +++ b/get-shit-done/bin/lib/graphify.cjs @@ -0,0 +1,494 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const childProcess = require('child_process'); +const { atomicWriteFileSync } = require('./core.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). + * + * @param {string} planningDir - Path to .planning directory + * @returns {boolean} + */ +function isGraphifyEnabled(planningDir) { + try { + const configPath = path.join(planningDir, 'config.json'); + if (!fs.existsSync(configPath)) return false; + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + if (config && config.graphify && config.graphify.enabled === true) return true; + return false; + } catch (_e) { + return false; + } +} + +/** + * Return the standard disabled response object. + * @returns {{ disabled: true, message: string }} + */ +function disabledResponse() { + return { disabled: true, message: 'graphify is not enabled. Enable with: gsd-tools config-set graphify.enabled true' }; +} + +// ─── Subprocess Helper ─────────────────────────────────────────────────────── + +/** + * Execute graphify CLI as a subprocess with proper env and timeout handling. + * + * @param {string} cwd - Working directory for the subprocess + * @param {string[]} args - Arguments to pass to graphify + * @param {{ timeout?: number }} [options={}] - Options (timeout in ms, default 30000) + * @returns {{ exitCode: number, stdout: string, stderr: string }} + */ +function execGraphify(cwd, args, options = {}) { + const timeout = options.timeout ?? 30000; + const result = childProcess.spawnSync('graphify', args, { + cwd, + stdio: 'pipe', + encoding: 'utf-8', + timeout, + env: { ...process.env, PYTHONUNBUFFERED: '1' }, + }); + + // ENOENT -- graphify binary not found on PATH + if (result.error && result.error.code === 'ENOENT') { + return { exitCode: 127, stdout: '', stderr: 'graphify not found on PATH' }; + } + + // Timeout -- subprocess killed via SIGTERM + if (result.signal === 'SIGTERM') { + return { + exitCode: 124, + stdout: (result.stdout ?? '').toString().trim(), + stderr: 'graphify timed out after ' + timeout + 'ms', + }; + } + + return { + exitCode: result.status ?? 1, + stdout: (result.stdout ?? '').toString().trim(), + stderr: (result.stderr ?? '').toString().trim(), + }; +} + +// ─── Presence & Version ────────────────────────────────────────────────────── + +/** + * Check whether the graphify CLI binary is installed and accessible on PATH. + * Uses --help (NOT --version, which graphify does not support). + * + * @returns {{ installed: boolean, message?: string }} + */ +function checkGraphifyInstalled() { + const result = childProcess.spawnSync('graphify', ['--help'], { + stdio: 'pipe', + encoding: 'utf-8', + 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 }; +} + +/** + * Detect graphify version via python3 importlib.metadata and check compatibility. + * Tested range: >=0.4.0,<1.0 + * + * @returns {{ version: string|null, compatible: boolean|null, warning: string|null }} + */ +function checkGraphifyVersion() { + const result = childProcess.spawnSync('python3', [ + '-c', + 'from importlib.metadata import version; print(version("graphifyy"))', + ], { + stdio: 'pipe', + encoding: 'utf-8', + timeout: 5000, + }); + + if (result.status !== 0 || !result.stdout || !result.stdout.trim()) { + return { version: null, compatible: null, warning: 'Could not determine graphify version' }; + } + + const versionStr = result.stdout.trim(); + 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 ──────────────────────────────────────────────────────── + +/** + * Safely read and parse a JSON file. Returns null on missing file or parse error. + * Prevents crashes on malformed JSON (T-02-01 mitigation). + * + * @param {string} filePath - Absolute path to JSON file + * @returns {object|null} + */ +function safeReadJson(filePath) { + try { + if (!fs.existsSync(filePath)) return null; + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (_e) { + return null; + } +} + +/** + * 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). + * + * @param {{ nodes: object[], edges: object[] }} graph + * @returns {Object.>} + */ +function buildAdjacencyMap(graph) { + const adj = {}; + for (const node of (graph.nodes || [])) { + adj[node.id] = []; + } + for (const edge of (graph.edges || [])) { + 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; +} + +/** + * 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). + * + * @param {{ nodes: object[], edges: object[] }} graph + * @param {string} term - Search term + * @param {number} [maxHops=2] - Maximum BFS hops from seed nodes + * @returns {{ nodes: object[], edges: object[], seeds: Set }} + */ +function seedAndExpand(graph, term, maxHops = 2) { + 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 = []; + const seenEdgeKeys = new Set(); + let frontier = seeds.map(n => n.id); + + for (let hop = 0; hop < maxHops && frontier.length > 0; hop++) { + const nextFrontier = []; + 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(Boolean); + return { nodes: resultNodes, edges: collectedEdges, seeds: new Set(seeds.map(n => n.id)) }; +} + +/** + * 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. + * + * @param {{ nodes: object[], edges: object[], seeds: Set }} result + * @param {number|null} budgetTokens - Max tokens, or null/falsy for unlimited + * @returns {{ nodes: object[], edges: object[], trimmed: string|null, total_nodes: number, total_edges: number, term?: string }} + */ +function applyBudget(result, budgetTokens) { + if (!budgetTokens) return result; + + const CONFIDENCE_ORDER = ['AMBIGUOUS', 'INFERRED', 'EXTRACTED']; + let edges = [...result.edges]; + let omitted = 0; + + const estimateTokens = (obj) => 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(); + 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 ────────────────────────────────────────────────────────────── + +/** + * Query the knowledge graph for nodes matching a term, with optional budget cap. + * Uses seed-then-expand BFS traversal (D-01). + * + * @param {string} cwd - Working directory + * @param {string} term - Search term + * @param {{ budget?: number|null }} [options={}] + * @returns {object} + */ +function graphifyQuery(cwd, term, options = {}) { + 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 = 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: result.trimmed || null, + }; +} + +/** + * Return status information about the knowledge graph (STAT-01, STAT-02). + * + * @param {string} cwd - Working directory + * @returns {object} + */ +function graphifyStatus(cwd) { + 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; + + return { + exists: true, + last_build: stat.mtime.toISOString(), + node_count: (graph.nodes || []).length, + edge_count: (graph.edges || []).length, + hyperedge_count: (graph.hyperedges || []).length, + stale: age > STALE_MS, + age_hours: Math.round(age / (60 * 60 * 1000)), + }; +} + +/** + * Compute topology-level diff between current graph and last build snapshot (D-07, D-08, D-09). + * + * @param {string} cwd - Working directory + * @returns {object} + */ +function graphifyDiff(cwd) { + 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) => `${e.source}::${e.target}::${e.relation || e.label || ''}`; + const currentEdgeMap = Object.fromEntries((current.edges || []).map(e => [edgeKey(e), e])); + const snapshotEdgeMap = Object.fromEntries((snapshot.edges || []).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. + * + * @param {string} cwd - Working directory + * @returns {object} + */ +function graphifyBuild(cwd) { + 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 timeoutSec = (config.graphify && config.graphify.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'], + }; +} + +/** + * Write a diff snapshot after successful build (D-06). + * Reads graph.json from .planning/graphs/ and writes .last-build-snapshot.json + * using atomicWriteFileSync for crash safety. + * + * @param {string} cwd - Working directory + * @returns {object} + */ +function writeSnapshot(cwd) { + 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 || [], + }; + + const snapshotPath = path.join(cwd, '.planning', 'graphs', '.last-build-snapshot.json'); + atomicWriteFileSync(snapshotPath, JSON.stringify(snapshot, null, 2)); + return { + saved: true, + timestamp: snapshot.timestamp, + node_count: snapshot.nodes.length, + edge_count: snapshot.edges.length, + }; +} + +// ─── Exports ───────────────────────────────────────────────────────────────── + +module.exports = { + // Config gate + isGraphifyEnabled, + disabledResponse, + // Subprocess + execGraphify, + // 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, +}; diff --git a/tests/graphify.test.cjs b/tests/graphify.test.cjs new file mode 100644 index 000000000..7c5d6ac99 --- /dev/null +++ b/tests/graphify.test.cjs @@ -0,0 +1,1051 @@ +'use strict'; + +/** + * Tests for get-shit-done/bin/lib/graphify.cjs + * + * Covers: config gate on/off (TEST-03), graceful degradation (TEST-04), + * subprocess helper (FOUND-04), presence detection (FOUND-02), + * version checking (FOUND-03), and disabled response (FOUND-01). + */ + +const { describe, test, beforeEach, afterEach, mock } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const childProcess = require('child_process'); +const { createTempProject, cleanup } = require('./helpers.cjs'); + +const { + isGraphifyEnabled, + disabledResponse, + execGraphify, + checkGraphifyInstalled, + checkGraphifyVersion, + // Phase 2 + graphifyQuery, + graphifyStatus, + graphifyDiff, + safeReadJson, + buildAdjacencyMap, + seedAndExpand, + applyBudget, + // Build (Phase 3) + graphifyBuild, + writeSnapshot, +} = require('../get-shit-done/bin/lib/graphify.cjs'); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function enableGraphify(planningDir) { + const configPath = path.join(planningDir, 'config.json'); + const config = fs.existsSync(configPath) + ? JSON.parse(fs.readFileSync(configPath, 'utf8')) + : {}; + config.graphify = { enabled: true }; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); +} + +function writeGraphJson(planningDir, data) { + const graphsDir = path.join(planningDir, 'graphs'); + fs.mkdirSync(graphsDir, { recursive: true }); + fs.writeFileSync( + path.join(graphsDir, 'graph.json'), + JSON.stringify(data, null, 2), + 'utf8' + ); +} + +function writeSnapshotJson(planningDir, data) { + const graphsDir = path.join(planningDir, 'graphs'); + fs.mkdirSync(graphsDir, { recursive: true }); + fs.writeFileSync( + path.join(graphsDir, '.last-build-snapshot.json'), + JSON.stringify(data, null, 2), + 'utf8' + ); +} + +const SAMPLE_GRAPH = { + nodes: [ + { id: 'n1', label: 'AuthService', description: 'Handles user authentication and token validation', type: 'service' }, + { id: 'n2', label: 'UserModel', description: 'User database model for storing credentials', type: 'model' }, + { id: 'n3', label: 'SessionManager', description: 'Manages active user sessions', type: 'service' }, + { id: 'n4', label: 'EmailService', description: 'Sends notification emails', type: 'service' }, + { id: 'n5', label: 'Logger', description: 'Centralized logging utility', type: 'utility' }, + ], + edges: [ + { source: 'n1', target: 'n2', label: 'reads_from', confidence: 'EXTRACTED' }, + { source: 'n1', target: 'n3', label: 'creates', confidence: 'INFERRED' }, + { source: 'n2', target: 'n3', label: 'triggers', confidence: 'AMBIGUOUS' }, + { source: 'n3', target: 'n4', label: 'notifies', confidence: 'INFERRED' }, + { source: 'n4', target: 'n5', label: 'logs_via', confidence: 'EXTRACTED' }, + ], + hyperedges: [], +}; + +// ─── isGraphifyEnabled (TEST-03, FOUND-01) ────────────────────────────────── + +describe('isGraphifyEnabled', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = createTempProject(); + planningDir = path.join(tmpDir, '.planning'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('returns false when no config.json exists', () => { + // Remove config.json if createTempProject wrote one + const configPath = path.join(planningDir, 'config.json'); + if (fs.existsSync(configPath)) fs.unlinkSync(configPath); + assert.strictEqual(isGraphifyEnabled(planningDir), false); + }); + + test('returns false when graphify key is not set', () => { + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ model_profile: 'balanced' }), + 'utf8' + ); + assert.strictEqual(isGraphifyEnabled(planningDir), false); + }); + + test('returns false when graphify.enabled is false', () => { + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ graphify: { enabled: false } }), + 'utf8' + ); + assert.strictEqual(isGraphifyEnabled(planningDir), false); + }); + + test('returns true when graphify.enabled is true', () => { + enableGraphify(planningDir); + assert.strictEqual(isGraphifyEnabled(planningDir), true); + }); + + test('returns false when config.json is malformed', () => { + fs.writeFileSync( + path.join(planningDir, 'config.json'), + 'not json', + 'utf8' + ); + assert.strictEqual(isGraphifyEnabled(planningDir), false); + }); +}); + +// ─── disabledResponse (FOUND-01) ──────────────────────────────────────────── + +describe('disabledResponse', () => { + test('returns disabled:true with enable instructions', () => { + const result = disabledResponse(); + assert.strictEqual(result.disabled, true); + assert.ok(result.message.includes('gsd-tools config-set graphify.enabled true')); + }); +}); + +// ─── execGraphify (FOUND-04) ──────────────────────────────────────────────── + +describe('execGraphify', () => { + afterEach(() => { + mock.restoreAll(); + }); + + test('returns structured output on success', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: 0, + stdout: '{"nodes": 42}', + stderr: '', + error: undefined, + signal: null, + })); + + const result = execGraphify('/tmp', ['build']); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stdout, '{"nodes": 42}'); + assert.strictEqual(result.stderr, ''); + }); + + test('returns exitCode 127 when graphify not on PATH', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: null, + stdout: '', + stderr: '', + error: { code: 'ENOENT' }, + signal: null, + })); + + const result = execGraphify('/tmp', ['build']); + assert.strictEqual(result.exitCode, 127); + assert.ok(result.stderr.includes('not found')); + }); + + test('returns exitCode 124 on timeout', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: null, + stdout: 'partial', + stderr: '', + error: undefined, + signal: 'SIGTERM', + })); + + const result = execGraphify('/tmp', ['build']); + assert.strictEqual(result.exitCode, 124); + assert.ok(result.stderr.includes('timed out')); + }); + + test('passes PYTHONUNBUFFERED=1 in env', () => { + let captured; + mock.method(childProcess, 'spawnSync', (_cmd, _args, opts) => { + captured = opts; + return { status: 0, stdout: '', stderr: '', error: undefined, signal: null }; + }); + + execGraphify('/tmp', ['build']); + assert.strictEqual(captured.env.PYTHONUNBUFFERED, '1'); + }); + + test('uses 30000ms default timeout', () => { + let captured; + mock.method(childProcess, 'spawnSync', (_cmd, _args, opts) => { + captured = opts; + return { status: 0, stdout: '', stderr: '', error: undefined, signal: null }; + }); + + execGraphify('/tmp', ['build']); + assert.strictEqual(captured.timeout, 30000); + }); + + test('allows timeout override', () => { + let captured; + mock.method(childProcess, 'spawnSync', (_cmd, _args, opts) => { + captured = opts; + return { status: 0, stdout: '', stderr: '', error: undefined, signal: null }; + }); + + execGraphify('/tmp', ['build'], { timeout: 60000 }); + assert.strictEqual(captured.timeout, 60000); + }); + + test('trims stdout and stderr whitespace', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: 0, + stdout: ' hello \n', + stderr: ' warn \n', + error: undefined, + signal: null, + })); + + const result = execGraphify('/tmp', ['build']); + assert.strictEqual(result.stdout, 'hello'); + assert.strictEqual(result.stderr, 'warn'); + }); +}); + +// ─── checkGraphifyInstalled (FOUND-02, TEST-04) ──────────────────────────── + +describe('checkGraphifyInstalled', () => { + afterEach(() => { + mock.restoreAll(); + }); + + test('returns installed:true when graphify is on PATH', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: 0, + stdout: 'Usage: graphify...', + stderr: '', + error: undefined, + signal: null, + })); + + const result = checkGraphifyInstalled(); + assert.strictEqual(result.installed, true); + }); + + test('returns installed:false with install instructions when not on PATH', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: null, + stdout: '', + stderr: '', + error: { code: 'ENOENT' }, + signal: null, + })); + + const result = checkGraphifyInstalled(); + assert.strictEqual(result.installed, false); + assert.ok(result.message.includes('uv pip install graphifyy && graphify install')); + }); + + test('uses --help not --version for detection', () => { + let capturedArgs; + mock.method(childProcess, 'spawnSync', (_cmd, args) => { + capturedArgs = args; + return { status: 0, stdout: '', stderr: '', error: undefined, signal: null }; + }); + + checkGraphifyInstalled(); + assert.deepStrictEqual(capturedArgs, ['--help']); + }); +}); + +// ─── checkGraphifyVersion (FOUND-03, TEST-04) ────────────────────────────── + +describe('checkGraphifyVersion', () => { + afterEach(() => { + mock.restoreAll(); + }); + + test('returns compatible:true for version 0.4.0', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: 0, + stdout: '0.4.0\n', + stderr: '', + error: undefined, + signal: null, + })); + + const result = checkGraphifyVersion(); + assert.strictEqual(result.version, '0.4.0'); + assert.strictEqual(result.compatible, true); + assert.strictEqual(result.warning, null); + }); + + test('returns compatible:true for version 0.9.5', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: 0, + stdout: '0.9.5\n', + stderr: '', + error: undefined, + signal: null, + })); + + const result = checkGraphifyVersion(); + assert.strictEqual(result.version, '0.9.5'); + assert.strictEqual(result.compatible, true); + }); + + test('returns compatible:false for version 0.3.0', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: 0, + stdout: '0.3.0\n', + stderr: '', + error: undefined, + signal: null, + })); + + const result = checkGraphifyVersion(); + assert.strictEqual(result.compatible, false); + assert.ok(result.warning.includes('outside tested range')); + }); + + test('returns compatible:false for version 1.0.0', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: 0, + stdout: '1.0.0\n', + stderr: '', + error: undefined, + signal: null, + })); + + const result = checkGraphifyVersion(); + assert.strictEqual(result.compatible, false); + assert.ok(result.warning.includes('outside tested range')); + }); + + test('handles python3 not found', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: null, + stdout: '', + stderr: '', + error: { code: 'ENOENT' }, + signal: null, + })); + + const result = checkGraphifyVersion(); + assert.strictEqual(result.version, null); + assert.ok(result.warning.includes('Could not determine')); + }); + + test('handles unparseable version string', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: 0, + stdout: 'unknown\n', + stderr: '', + error: undefined, + signal: null, + })); + + const result = checkGraphifyVersion(); + assert.strictEqual(result.compatible, null); + assert.ok(result.warning.includes('Could not parse')); + }); + + test('calls python3 with importlib.metadata', () => { + let capturedCmd; + let capturedArgs; + mock.method(childProcess, 'spawnSync', (cmd, args) => { + capturedCmd = cmd; + capturedArgs = args; + return { status: 0, stdout: '0.4.3\n', stderr: '', error: undefined, signal: null }; + }); + + checkGraphifyVersion(); + assert.strictEqual(capturedCmd, 'python3'); + assert.ok(capturedArgs.some(arg => arg.includes('importlib.metadata'))); + }); +}); + +// ─── safeReadJson (TEST-01) ──────────────────────────────────────────────── + +describe('safeReadJson', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = createTempProject(); + planningDir = path.join(tmpDir, '.planning'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('returns parsed object for valid JSON file', () => { + const filePath = path.join(planningDir, 'test.json'); + const data = { foo: 'bar', num: 42 }; + fs.writeFileSync(filePath, JSON.stringify(data), 'utf8'); + const result = safeReadJson(filePath); + assert.deepStrictEqual(result, data); + }); + + test('returns null for malformed JSON', () => { + const filePath = path.join(planningDir, 'bad.json'); + fs.writeFileSync(filePath, 'not json', 'utf8'); + const result = safeReadJson(filePath); + assert.strictEqual(result, null); + }); + + test('returns null for non-existent file', () => { + const result = safeReadJson(path.join(planningDir, 'does-not-exist.json')); + assert.strictEqual(result, null); + }); +}); + +// ─── buildAdjacencyMap (TEST-01) ─────────────────────────────────────────── + +describe('buildAdjacencyMap', () => { + test('creates bidirectional adjacency entries', () => { + const adj = buildAdjacencyMap(SAMPLE_GRAPH); + // n1 -> n2 edge exists, so adj['n1'] should have target n2 AND adj['n2'] should have target n1 + assert.ok(adj['n1'].some(e => e.target === 'n2')); + assert.ok(adj['n2'].some(e => e.target === 'n1')); + }); + + test('initializes empty arrays for nodes without edges', () => { + const graph = { + nodes: [ + ...SAMPLE_GRAPH.nodes, + { id: 'n99', label: 'Orphan', description: 'No edges', type: 'orphan' }, + ], + edges: SAMPLE_GRAPH.edges, + }; + const adj = buildAdjacencyMap(graph); + assert.ok(Array.isArray(adj['n99'])); + assert.strictEqual(adj['n99'].length, 0); + }); + + test('stores full edge object in adjacency entries', () => { + const adj = buildAdjacencyMap(SAMPLE_GRAPH); + const entry = adj['n1'].find(e => e.target === 'n2'); + assert.ok(entry); + assert.strictEqual(entry.edge.label, 'reads_from'); + assert.strictEqual(entry.edge.confidence, 'EXTRACTED'); + }); +}); + +// ─── seedAndExpand (TEST-01) ─────────────────────────────────────────────── + +describe('seedAndExpand', () => { + test('finds seed nodes by label match (case-insensitive)', () => { + const result = seedAndExpand(SAMPLE_GRAPH, 'auth'); + assert.ok(result.seeds.has('n1'), 'AuthService should be a seed'); + assert.ok(result.nodes.some(n => n.id === 'n1')); + }); + + test('finds seed nodes by description match', () => { + const result = seedAndExpand(SAMPLE_GRAPH, 'credentials'); + assert.ok(result.seeds.has('n2'), 'UserModel description contains credentials'); + assert.ok(result.nodes.some(n => n.id === 'n2')); + }); + + test('BFS expands 1-2 hops from seeds', () => { + // 'auth' matches n1 (label: AuthService) and n2 (description: authentication) + // n1 seeds: 1-hop -> n2, n3; 2-hop -> n4 (via n3->n4) + // n5 is 3 hops from n1 (n1->n3->n4->n5) so should NOT appear + const result = seedAndExpand(SAMPLE_GRAPH, 'auth'); + const nodeIds = result.nodes.map(n => n.id); + assert.ok(nodeIds.includes('n1'), 'seed n1'); + assert.ok(nodeIds.includes('n2'), '1-hop from n1'); + assert.ok(nodeIds.includes('n3'), '1-hop from n1'); + assert.ok(nodeIds.includes('n4'), '2-hop from n3'); + // n5 is reachable only at 3 hops from n1 seeds, but n2 is also a seed + // (description contains "authentication"), and n2->n3->n4->n5 is also 3 hops + // So n5 should NOT be in results with maxHops=2 + assert.ok(!nodeIds.includes('n5'), 'n5 should be beyond 2 hops'); + }); + + test('returns empty results for no matches', () => { + const result = seedAndExpand(SAMPLE_GRAPH, 'nonexistent'); + assert.strictEqual(result.nodes.length, 0); + assert.strictEqual(result.edges.length, 0); + assert.strictEqual(result.seeds.size, 0); + }); + + test('respects maxHops parameter', () => { + const result = seedAndExpand(SAMPLE_GRAPH, 'auth', 1); + const nodeIds = result.nodes.map(n => n.id); + assert.ok(nodeIds.includes('n1'), 'seed'); + assert.ok(nodeIds.includes('n2'), '1-hop'); + assert.ok(nodeIds.includes('n3'), '1-hop'); + assert.ok(!nodeIds.includes('n4'), 'n4 is 2 hops away'); + }); +}); + +// ─── applyBudget (TEST-01) ───────────────────────────────────────────────── + +describe('applyBudget', () => { + test('returns result unchanged when no budget', () => { + const input = { nodes: SAMPLE_GRAPH.nodes, edges: SAMPLE_GRAPH.edges, seeds: new Set(['n1']) }; + const result = applyBudget(input, null); + assert.strictEqual(result.nodes, input.nodes); + assert.strictEqual(result.edges, input.edges); + }); + + test('drops AMBIGUOUS edges first when over budget', () => { + const input = { nodes: SAMPLE_GRAPH.nodes, edges: SAMPLE_GRAPH.edges, seeds: new Set(['n1']) }; + // Set a budget small enough to trigger trimming but large enough to keep some edges + // The full graph serialized is ~600+ chars = ~150+ tokens. Use a small budget. + const result = applyBudget(input, 50); + const confidences = result.edges.map(e => e.confidence); + assert.ok(!confidences.includes('AMBIGUOUS'), 'AMBIGUOUS edges should be dropped first'); + }); + + test('drops INFERRED edges after AMBIGUOUS', () => { + const input = { nodes: SAMPLE_GRAPH.nodes, edges: SAMPLE_GRAPH.edges, seeds: new Set(['n1']) }; + // Very tight budget to force dropping both AMBIGUOUS and INFERRED + const result = applyBudget(input, 10); + const confidences = result.edges.map(e => e.confidence); + assert.ok(!confidences.includes('AMBIGUOUS'), 'AMBIGUOUS removed'); + assert.ok(!confidences.includes('INFERRED'), 'INFERRED removed'); + // Only EXTRACTED should remain (if any) + for (const c of confidences) { + assert.strictEqual(c, 'EXTRACTED'); + } + }); + + test('appends trimmed footer with counts', () => { + const input = { nodes: SAMPLE_GRAPH.nodes, edges: SAMPLE_GRAPH.edges, seeds: new Set(['n1']) }; + const result = applyBudget(input, 10); + assert.ok(result.trimmed !== null, 'trimmed should not be null'); + assert.ok(/\d+ edges omitted/.test(result.trimmed), 'trimmed contains edge count'); + assert.ok(/\d+ nodes unreachable/.test(result.trimmed), 'trimmed contains node count'); + }); +}); + +// ─── graphifyQuery (QUERY-01, QUERY-02, QUERY-03) ───────────────────────── + +describe('graphifyQuery', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = createTempProject(); + planningDir = path.join(tmpDir, '.planning'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // QUERY-01: returns disabled response when graphify not enabled + test('returns disabled response when graphify not enabled', () => { + const result = graphifyQuery(tmpDir, 'auth'); + assert.strictEqual(result.disabled, true); + }); + + // QUERY-01: returns error when graph.json does not exist + test('returns error when graph.json does not exist', () => { + enableGraphify(planningDir); + const result = graphifyQuery(tmpDir, 'auth'); + assert.ok(result.error); + assert.ok(result.error.includes('No graph')); + }); + + // QUERY-01: returns matching nodes and edges for valid query + test('returns matching nodes and edges for valid query', () => { + enableGraphify(planningDir); + writeGraphJson(planningDir, SAMPLE_GRAPH); + const result = graphifyQuery(tmpDir, 'auth'); + assert.ok(result.nodes.length > 0, 'should have matching nodes'); + assert.ok(result.edges.length > 0, 'should have matching edges'); + assert.strictEqual(result.term, 'auth'); + }); + + // QUERY-03: includes confidence on edges + test('includes confidence on edges (QUERY-03)', () => { + enableGraphify(planningDir); + writeGraphJson(planningDir, SAMPLE_GRAPH); + const result = graphifyQuery(tmpDir, 'auth'); + const validTiers = ['EXTRACTED', 'INFERRED', 'AMBIGUOUS']; + for (const edge of result.edges) { + assert.ok(validTiers.includes(edge.confidence), `edge confidence ${edge.confidence} is valid tier`); + } + }); + + // QUERY-02: respects --budget option + test('respects --budget option (QUERY-02)', () => { + enableGraphify(planningDir); + writeGraphJson(planningDir, SAMPLE_GRAPH); + const result = graphifyQuery(tmpDir, 'auth', { budget: 50 }); + // With a very small budget, trimming should occur + assert.ok(result.trimmed !== null, 'trimmed should indicate budget was applied'); + }); + + // QUERY-01: returns total_nodes and total_edges counts + test('returns total_nodes and total_edges counts', () => { + enableGraphify(planningDir); + writeGraphJson(planningDir, SAMPLE_GRAPH); + const result = graphifyQuery(tmpDir, 'auth'); + assert.strictEqual(typeof result.total_nodes, 'number'); + assert.strictEqual(typeof result.total_edges, 'number'); + }); +}); + +// ─── graphifyStatus (STAT-01, STAT-02) ──────────────────────────────────── + +describe('graphifyStatus', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = createTempProject(); + planningDir = path.join(tmpDir, '.planning'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // STAT-01: returns disabled response when not enabled + test('returns disabled response when not enabled', () => { + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.disabled, true); + }); + + // STAT-02: returns exists:false when no graph.json + test('returns exists:false when no graph.json (STAT-02)', () => { + enableGraphify(planningDir); + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.exists, false); + assert.ok(result.message.includes('No graph built yet')); + }); + + // STAT-01: returns status with counts when graph exists + test('returns status with counts when graph exists (STAT-01)', () => { + enableGraphify(planningDir); + writeGraphJson(planningDir, SAMPLE_GRAPH); + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.exists, true); + assert.strictEqual(result.node_count, 5); + assert.strictEqual(result.edge_count, 5); + assert.strictEqual(typeof result.last_build, 'string'); + assert.strictEqual(typeof result.stale, 'boolean'); + assert.strictEqual(typeof result.age_hours, 'number'); + }); + + // STAT-01: reports hyperedge_count + test('reports hyperedge_count', () => { + enableGraphify(planningDir); + const graphWithHyperedges = { + ...SAMPLE_GRAPH, + hyperedges: [{ id: 'h1', nodes: ['n1', 'n2', 'n3'], label: 'auth_flow' }], + }; + writeGraphJson(planningDir, graphWithHyperedges); + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.hyperedge_count, 1); + }); +}); + +// ─── graphifyDiff (DIFF-01, DIFF-02) ────────────────────────────────────── + +describe('graphifyDiff', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = createTempProject(); + planningDir = path.join(tmpDir, '.planning'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // DIFF-01: returns disabled response when not enabled + test('returns disabled response when not enabled', () => { + const result = graphifyDiff(tmpDir); + assert.strictEqual(result.disabled, true); + }); + + // D-09: returns no_baseline when no snapshot exists + test('returns no_baseline when no snapshot exists (D-09)', () => { + enableGraphify(planningDir); + writeGraphJson(planningDir, SAMPLE_GRAPH); + const result = graphifyDiff(tmpDir); + assert.strictEqual(result.no_baseline, true); + assert.ok(result.message.includes('No previous snapshot')); + }); + + // DIFF-01: returns error when no current graph but snapshot exists + test('returns error when no current graph but snapshot exists', () => { + enableGraphify(planningDir); + writeSnapshotJson(planningDir, SAMPLE_GRAPH); + const result = graphifyDiff(tmpDir); + assert.ok(result.error); + assert.ok(result.error.includes('No current graph')); + }); + + // DIFF-02: detects added and removed nodes + test('detects added and removed nodes (DIFF-02)', () => { + enableGraphify(planningDir); + const snapshot = { + nodes: [ + { id: 'n1', label: 'AuthService', description: 'Auth', type: 'service' }, + { id: 'n2', label: 'UserModel', description: 'User', type: 'model' }, + ], + edges: [], + }; + const current = { + nodes: [ + { id: 'n1', label: 'AuthService', description: 'Auth', type: 'service' }, + { id: 'n3', label: 'SessionManager', description: 'Sessions', type: 'service' }, + ], + edges: [], + }; + writeSnapshotJson(planningDir, snapshot); + writeGraphJson(planningDir, current); + const result = graphifyDiff(tmpDir); + assert.strictEqual(result.nodes.added, 1, 'n3 added'); + assert.strictEqual(result.nodes.removed, 1, 'n2 removed'); + }); + + // DIFF-02: detects changed nodes and edges + test('detects changed nodes and edges (DIFF-02)', () => { + enableGraphify(planningDir); + const snapshot = { + nodes: [ + { id: 'n1', label: 'OldName', description: 'Auth', type: 'service' }, + { id: 'n2', label: 'UserModel', description: 'User', type: 'model' }, + ], + edges: [ + { source: 'n1', target: 'n2', label: 'reads_from', confidence: 'INFERRED' }, + ], + }; + const current = { + nodes: [ + { id: 'n1', label: 'NewName', description: 'Auth', type: 'service' }, + { id: 'n2', label: 'UserModel', description: 'User', type: 'model' }, + ], + edges: [ + { source: 'n1', target: 'n2', label: 'reads_from', confidence: 'EXTRACTED' }, + ], + }; + writeSnapshotJson(planningDir, snapshot); + writeGraphJson(planningDir, current); + const result = graphifyDiff(tmpDir); + assert.strictEqual(result.nodes.changed, 1, 'n1 label changed'); + assert.strictEqual(result.edges.changed, 1, 'edge confidence changed'); + }); +}); + +// ─── graphifyBuild (BUILD-01, BUILD-02, TEST-02) ──────────────────────────── + +describe('graphifyBuild', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = createTempProject(); + planningDir = path.join(tmpDir, '.planning'); + enableGraphify(planningDir); + }); + + afterEach(() => { + cleanup(tmpDir); + mock.restoreAll(); + }); + + test('returns disabled response when graphify not enabled', () => { + const tmpDir2 = createTempProject(); + const result = graphifyBuild(tmpDir2); + assert.strictEqual(result.disabled, true); + cleanup(tmpDir2); + }); + + test('returns error when graphify not installed', () => { + mock.method(childProcess, 'spawnSync', () => ({ + status: null, + stdout: '', + stderr: '', + error: { code: 'ENOENT' }, + signal: null, + })); + + const result = graphifyBuild(tmpDir); + assert.ok(result.error); + assert.ok(result.error.includes('not installed') || result.error.includes('pip install')); + }); + + test('returns spawn_agent action on successful pre-flight', () => { + mock.method(childProcess, 'spawnSync', (_cmd, args) => { + if (args && args[0] === '--help') { + return { status: 0, stdout: 'Usage', stderr: '', error: undefined, signal: null }; + } + // version check via python3 + return { status: 0, stdout: '0.4.3\n', stderr: '', error: undefined, signal: null }; + }); + + const result = graphifyBuild(tmpDir); + assert.strictEqual(result.action, 'spawn_agent'); + assert.ok(result.graphs_dir); + assert.ok(result.graphify_out); + assert.strictEqual(result.timeout_seconds, 300); + assert.strictEqual(result.version, '0.4.3'); + assert.strictEqual(result.version_warning, null); + assert.deepStrictEqual(result.artifacts, ['graph.json', 'graph.html', 'GRAPH_REPORT.md']); + }); + + test('creates .planning/graphs/ directory if missing', () => { + 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 }; + }); + + const graphsDir = path.join(planningDir, 'graphs'); + assert.strictEqual(fs.existsSync(graphsDir), false); + + graphifyBuild(tmpDir); + assert.strictEqual(fs.existsSync(graphsDir), true); + }); + + test('reads graphify.build_timeout from config', () => { + // Write config with custom timeout + const configPath = path.join(planningDir, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.graphify.build_timeout = 600; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); + + 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 }; + }); + + const result = graphifyBuild(tmpDir); + assert.strictEqual(result.timeout_seconds, 600); + }); + + test('includes version warning when outside tested range', () => { + 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: '1.2.0\n', stderr: '', error: undefined, signal: null }; + }); + + const result = graphifyBuild(tmpDir); + assert.strictEqual(result.action, 'spawn_agent'); + assert.ok(result.version_warning); + assert.ok(result.version_warning.includes('outside tested range')); + }); +}); + +// ─── writeSnapshot (BUILD-01, TEST-02) ────────────────────────────────────── + +describe('writeSnapshot', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = createTempProject(); + planningDir = path.join(tmpDir, '.planning'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('writes snapshot from existing graph.json', () => { + const graphData = { + nodes: [{ id: 'A', label: 'Node A' }, { id: 'B', label: 'Node B' }], + edges: [{ source: 'A', target: 'B', label: 'relates' }], + }; + writeGraphJson(planningDir, graphData); + + const result = writeSnapshot(tmpDir); + assert.strictEqual(result.saved, true); + assert.strictEqual(result.node_count, 2); + assert.strictEqual(result.edge_count, 1); + assert.ok(result.timestamp); + + // Verify file was actually written + const snapshotPath = path.join(planningDir, 'graphs', '.last-build-snapshot.json'); + assert.strictEqual(fs.existsSync(snapshotPath), true); + + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8')); + assert.strictEqual(snapshot.version, 1); + assert.strictEqual(snapshot.nodes.length, 2); + assert.strictEqual(snapshot.edges.length, 1); + assert.ok(snapshot.timestamp); + }); + + test('returns error when graph.json does not exist', () => { + // graphs directory exists but no graph.json + fs.mkdirSync(path.join(planningDir, 'graphs'), { recursive: true }); + + const result = writeSnapshot(tmpDir); + assert.ok(result.error); + assert.ok(result.error.includes('not parseable')); + }); + + test('returns error when graph.json is invalid JSON', () => { + const graphsDir = path.join(planningDir, 'graphs'); + fs.mkdirSync(graphsDir, { recursive: true }); + fs.writeFileSync(path.join(graphsDir, 'graph.json'), 'not valid json{{{', 'utf8'); + + const result = writeSnapshot(tmpDir); + assert.ok(result.error); + assert.ok(result.error.includes('not parseable')); + }); + + test('handles graph.json with empty nodes and edges', () => { + writeGraphJson(planningDir, { nodes: [], edges: [] }); + + const result = writeSnapshot(tmpDir); + assert.strictEqual(result.saved, true); + assert.strictEqual(result.node_count, 0); + assert.strictEqual(result.edge_count, 0); + }); + + test('handles graph.json missing nodes/edges keys gracefully', () => { + writeGraphJson(planningDir, { metadata: { tool: 'graphify' } }); + + const result = writeSnapshot(tmpDir); + assert.strictEqual(result.saved, true); + assert.strictEqual(result.node_count, 0); + assert.strictEqual(result.edge_count, 0); + }); + + test('overwrites existing snapshot on rebuild', () => { + // Write initial graph and snapshot + writeGraphJson(planningDir, { + nodes: [{ id: 'A' }], + edges: [], + }); + writeSnapshot(tmpDir); + + // Write updated graph with more nodes + writeGraphJson(planningDir, { + nodes: [{ id: 'A' }, { id: 'B' }, { id: 'C' }], + edges: [{ source: 'A', target: 'B' }], + }); + + const result = writeSnapshot(tmpDir); + assert.strictEqual(result.saved, true); + assert.strictEqual(result.node_count, 3); + assert.strictEqual(result.edge_count, 1); + + // Verify file reflects latest data + const snapshotPath = path.join(planningDir, 'graphs', '.last-build-snapshot.json'); + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8')); + assert.strictEqual(snapshot.nodes.length, 3); + }); +}); + +// --- AGENT-03: Graceful degradation (graph absent) ------------------------- + +describe('AGENT-03 graceful degradation', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = createTempProject(); + planningDir = path.join(tmpDir, '.planning'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // AGENT-03: graphifyQuery returns error object when graph.json absent (not exception) + test('graphifyQuery returns clean error object when graph.json does not exist', () => { + enableGraphify(planningDir); + const result = graphifyQuery(tmpDir, 'anything'); + assert.ok(result.error, 'should have error property'); + assert.ok(result.error.includes('No graph'), 'error should mention no graph'); + assert.strictEqual(typeof result.error, 'string', 'error should be a string, not thrown'); + }); + + // AGENT-03: graphifyStatus returns exists:false when graph.json absent (not exception) + test('graphifyStatus returns exists:false when graph.json does not exist', () => { + enableGraphify(planningDir); + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.exists, false, 'should report exists as false'); + assert.ok(result.message, 'should have a message'); + assert.ok(result.message.includes('No graph'), 'message should mention no graph'); + }); + + // AGENT-03: graphifyQuery with various terms all return clean errors when no graph + test('graphifyQuery gracefully handles any query term when graph absent', () => { + enableGraphify(planningDir); + const terms = ['auth', 'payment', 'nonexistent', '']; + for (const term of terms) { + const result = graphifyQuery(tmpDir, term); + assert.ok(result.error || result.nodes !== undefined, + `term "${term}" should return error or valid result, not throw`); + } + }); + + // D-12: Integration test - query returns expected structure with known graph.json + test('graphifyQuery returns non-empty results with expected structure for known graph', () => { + enableGraphify(planningDir); + writeGraphJson(planningDir, SAMPLE_GRAPH); + const result = graphifyQuery(tmpDir, 'auth'); + assert.ok(!result.error, 'should not have error when graph exists'); + assert.ok(Array.isArray(result.nodes), 'nodes should be an array'); + assert.ok(Array.isArray(result.edges), 'edges should be an array'); + assert.ok(result.nodes.length > 0, 'should have matching nodes for auth term'); + assert.strictEqual(typeof result.total_nodes, 'number', 'total_nodes should be a number'); + assert.strictEqual(typeof result.total_edges, 'number', 'total_edges should be a number'); + assert.strictEqual(result.term, 'auth', 'term should be echoed back'); + }); + + // D-12: graphifyStatus returns valid structure with known graph.json + test('graphifyStatus returns valid structure when graph.json exists', () => { + enableGraphify(planningDir); + writeGraphJson(planningDir, SAMPLE_GRAPH); + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.exists, true, 'should report exists as true'); + assert.strictEqual(typeof result.node_count, 'number', 'node_count should be number'); + assert.strictEqual(typeof result.edge_count, 'number', 'edge_count should be number'); + assert.strictEqual(typeof result.stale, 'boolean', 'stale should be boolean'); + assert.strictEqual(typeof result.age_hours, 'number', 'age_hours should be number'); + }); +});