Spaces:
Runtime error
Runtime error
Aryan Mishra
feat: initial commit - Multilingual ABSA project setup with 6 phases, 36 requirements
a6b96c2 | ; | |
| /** | |
| * 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. | |
| */ | |
| var __importDefault = (this && this.__importDefault) || function (mod) { | |
| return (mod && mod.__esModule) ? mod : { "default": mod }; | |
| }; | |
| const node_fs_1 = __importDefault(require("node:fs")); | |
| const node_path_1 = __importDefault(require("node:path")); | |
| const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs"); | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const capabilityStateMod = require("./capability-state.cjs"); | |
| const { isCapabilityActive } = capabilityStateMod; | |
| /** | |
| * Return the standard disabled response object. | |
| */ | |
| function 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', | |
| }); | |
| /** | |
| * Execute graphify CLI as a subprocess with proper env and timeout handling. | |
| */ | |
| function execGraphify(cwd, args, options = {}) { | |
| const timeout = options.timeout ?? 30000; | |
| const result = (0, shell_command_projection_cjs_1.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.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, | |
| }; | |
| } | |
| /** | |
| * Check whether the graphify CLI binary is installed and accessible on PATH. | |
| * Uses --help (NOT --version, which graphify does not support). | |
| */ | |
| function checkGraphifyInstalled() { | |
| const result = (0, shell_command_projection_cjs_1.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 }; | |
| } | |
| /** | |
| * 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() { | |
| // Strategy 1: try `graphify --version` directly (2s timeout -- fast path) | |
| const versionResult = (0, shell_command_projection_cjs_1.execTool)('graphify', ['--version'], { timeout: 2000 }); | |
| let versionStr = 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 = (0, shell_command_projection_cjs_1.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 }; | |
| } | |
| /** | |
| * 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) { | |
| try { | |
| if (!node_fs_1.default.existsSync(filePath)) | |
| return null; | |
| return JSON.parse(node_fs_1.default.readFileSync(filePath, 'utf8')); | |
| } | |
| catch { | |
| 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). | |
| */ | |
| function buildAdjacencyMap(graph) { | |
| const adj = {}; | |
| 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; | |
| } | |
| /** | |
| * 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, 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((n) => Boolean(n)); | |
| 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. | |
| */ | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * 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) { | |
| const r = (0, shell_command_projection_cjs_1.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, from, to) { | |
| const r = (0, shell_command_projection_cjs_1.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, term, options = {}) { | |
| const planningDir = node_path_1.default.join(cwd, '.planning'); | |
| if (!isCapabilityActive('graphify', cwd)) | |
| return disabledResponse(); | |
| const graphPath = node_path_1.default.join(planningDir, 'graphs', 'graph.json'); | |
| if (!node_fs_1.default.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: '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) { | |
| const planningDir = node_path_1.default.join(cwd, '.planning'); | |
| if (!isCapabilityActive('graphify', cwd)) | |
| return disabledResponse(); | |
| const graphPath = node_path_1.default.join(planningDir, 'graphs', 'graph.json'); | |
| if (!node_fs_1.default.existsSync(graphPath)) { | |
| return { exists: false, message: 'No graph built yet. Run graphify build to create one.' }; | |
| } | |
| const stat = node_fs_1.default.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 = null; | |
| let commitStale = 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 = node_path_1.default.join(planningDir, 'graphs', '.last-build-status.json'); | |
| const lastBuildAutoUpdate = node_fs_1.default.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) { | |
| const planningDir = node_path_1.default.join(cwd, '.planning'); | |
| if (!isCapabilityActive('graphify', cwd)) | |
| return disabledResponse(); | |
| const snapshotPath = node_path_1.default.join(planningDir, 'graphs', '.last-build-snapshot.json'); | |
| const graphPath = node_path_1.default.join(planningDir, 'graphs', 'graph.json'); | |
| if (!node_fs_1.default.existsSync(snapshotPath)) { | |
| return { no_baseline: true, message: 'No previous snapshot. Run graphify build first, then build again to generate a diff baseline.' }; | |
| } | |
| if (!node_fs_1.default.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 || 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) { | |
| const planningDir = node_path_1.default.join(cwd, '.planning'); | |
| if (!isCapabilityActive('graphify', cwd)) | |
| return disabledResponse(); | |
| const installed = checkGraphifyInstalled(); | |
| if (!installed.installed) | |
| return { error: installed.message }; | |
| const version = checkGraphifyVersion(); | |
| // Ensure output directory exists (D-05) | |
| const graphsDir = node_path_1.default.join(planningDir, 'graphs'); | |
| node_fs_1.default.mkdirSync(graphsDir, { recursive: true }); | |
| // Read build timeout from config -- default 300s per D-02 | |
| const config = safeReadJson(node_path_1.default.join(planningDir, 'config.json')) || {}; | |
| const graphifyConfig = config.graphify; | |
| const timeoutSec = (graphifyConfig && graphifyConfig.build_timeout) || 300; | |
| return { | |
| action: 'spawn_agent', | |
| graphs_dir: graphsDir, | |
| graphify_out: node_path_1.default.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 platformWriteSync for crash safety. | |
| */ | |
| function writeSnapshot(cwd) { | |
| const graphPath = node_path_1.default.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 = node_path_1.default.join(cwd, '.planning', 'graphs', '.last-build-snapshot.json'); | |
| (0, shell_command_projection_cjs_1.platformWriteSync)(snapshotPath, JSON.stringify(snapshot, null, 2)); | |
| return { | |
| saved: true, | |
| timestamp: snapshot.timestamp, | |
| node_count: snapshot.nodes.length, | |
| edge_count: snapshot.edges.length, | |
| }; | |
| } | |
| module.exports = { | |
| // Config gate | |
| 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, | |
| }; | |