diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4f2231..2bca59a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,18 +5,60 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] — PROVING GROUND +## [2.0.0-alpha.2] - 2026-02-11 ### Added -- **`coverage` view** — Code-to-spec gap analysis: identifies `crate:`/`module:`/`pkg:` nodes lacking `implements` edges to `spec:`/`adr:` targets. Returns `meta.linked`, `meta.unlinked`, and `meta.coveragePct` -- **Echo ecosystem seed fixture** — `test/fixtures/echo-seed.yaml` with 55 nodes and 70 edges for integration testing (5 milestones, 5 specs, 5 ADRs, 5 docs, 15 crates, 11 tasks, 9 issues) -- **PROVING GROUND integration tests** — `test/proving-ground.test.js` validates 5 real project management questions against the Echo seed with deterministic ground truth -- **Dogfood session transcript** — `docs/dogfood-session.md` documents CLI walkthrough of all 5 questions with answers and timing +- **`git mind doctor` command** — Graph integrity checking with four composable detectors: dangling edges (error), orphan milestones (warning), orphan nodes (info), low-confidence edges (info). Supports `--fix` to auto-remove dangling edges, `--json` for structured output. Exit code 1 on errors (#193) +- **Doctor API** — `runDoctor(graph)`, `fixIssues(graph, issues)`, and individual detectors (`detectDanglingEdges`, `detectOrphanMilestones`, `detectOrphanNodes`, `detectLowConfidenceEdges`) in `src/doctor.js` (#193) +- **Git context extraction** — `src/context.js` extracts file, commit, and graph context for LLM prompts. Language inference from file extensions. Size-bounded prompt generation (~4000 chars) (#193) +- **`git mind suggest` command** — AI-powered edge suggestions via `GITMIND_AGENT` env var. Shells out to any command (stdin prompt, stdout JSON). Supports `--agent `, `--context `, `--json`. Zero new dependencies (#193) +- **Suggest API** — `callAgent(prompt)`, `parseSuggestions(text)` (handles raw JSON and markdown code fences), `filterRejected(suggestions, graph)`, `generateSuggestions(cwd, graph)` in `src/suggest.js` (#193) +- **`git mind review` command** — Interactive review of pending suggestions with `[a]ccept / [r]eject / [s]kip` prompts via readline. Non-interactive batch mode via `--batch accept|reject`. `--json` output (#193) +- **Review API** — `getPendingSuggestions(graph)`, `acceptSuggestion` (promotes confidence to 1.0), `rejectSuggestion` (removes edge), `adjustSuggestion` (updates edge props), `skipSuggestion` (no-op), `getReviewHistory`, `batchDecision` in `src/review.js` (#193) +- **Decision provenance** — Review decisions stored as `decision:` prefixed nodes with action, source, target, edgeType, confidence, rationale, timestamp, and reviewer properties. Rejected edges excluded from future suggestions (#193) +- **`coverage` view** — Code-to-spec gap analysis: identifies `crate:`/`module:`/`pkg:` nodes lacking `implements` edges to `spec:`/`adr:` targets. Returns `meta.linked`, `meta.unlinked`, and `meta.coveragePct` (#191) +- **Echo ecosystem seed fixture** — `test/fixtures/echo-seed.yaml` with 55 nodes and 70 edges for integration testing (#191) +- **PROVING GROUND integration tests** — `test/proving-ground.test.js` validates 5 real project management questions against the Echo seed with deterministic ground truth (#191) +- **Dogfood session transcript** — `docs/dogfood-session.md` documents CLI walkthrough of all 5 questions (#191) + +### Fixed + +- **`parseFlags` boolean flag handling** — `--json` and `--fix` no longer consume the next argument as a value, fixing `git mind suggest --json --agent ` (#193) +- **Shell injection in `extractCommitContext`** — `opts.range` validated against shell metacharacters; commit SHAs validated as hex before interpolation into `execSync` (#193) +- **ReDoS in `parseSuggestions`** — Replaced polynomial regex for code fence extraction with non-backtracking pattern; replaced greedy array regex with `indexOf`/`lastIndexOf` (#193) +- **Agent subprocess timeout** — `callAgent` now enforces a configurable timeout (default 2 min) via `opts.timeout`, killing hung agent processes (#193) +- **Readline leak in interactive review** — `rl.close()` now called via `try/finally` to prevent terminal state corruption on error (#193) +- **Non-atomic edge type change in `adjustSuggestion`** — New edge created before old edge removed, preventing data loss if `createEdge` throws (#193) +- **Magic confidence default** — `adjustSuggestion` now preserves `original.confidence` instead of silently defaulting to 0.8 (#193) +- **`batchDecision` action validation** — Throws on invalid action instead of silently falling through to reject (#193) +- **Loose file node matching** — `extractGraphContext` uses exact match (`file:${fp}`) or suffix match instead of `includes()` to prevent false positives (#193) +- **`fixResult.details` guard** — `formatDoctorResult` handles undefined `details` array with nullish coalescing (#193) +- **`makeDecisionId` JSDoc** — Updated to say "unique" instead of "deterministic" since it includes `Date.now()` (#193) +- **`fixIssues` named properties** — Uses `issue.source`/`issue.target`/`issue.edgeType` instead of positional destructuring (#193) +- **N+1 query optimization** — `getPendingSuggestions`, `getReviewHistory`, and `filterRejected` use `Promise.all` for concurrent node prop fetches (#193) +- **Consistent flag handling** — `doctor`, `suggest`, `review` CLI commands read `--json`/`--fix` from `parseFlags` instead of mixing `args.includes()` (#193) +- **Sanitize `opts.limit`** — `extractCommitContext` coerces limit to safe integer (1–100) before shell interpolation (#193) +- **Expanded sanitization blocklist** — `sanitizeGitArg` now also rejects `<`, `>`, `\n`, `\r` (#193) +- **Unused import removed** — `extractPrefix` import removed from `src/doctor.js` (#193) +- **Decision nodes excluded from orphan detection** — `detectOrphanNodes` skips `decision:` prefix nodes (#193) +- **Defensive guard on `result.errors`** — `formatSuggestions` uses optional chaining for `result.errors` (#193) +- **ReDoS fence regex eliminated** — Replaced regex-based code fence extraction with `indexOf`-based approach (#193) +- **`skipSuggestion` documented as deferred** — JSDoc clarifies skip is intentional defer, not dismiss (#193) +- **Single-writer assumption documented** — `acceptSuggestion` and `adjustSuggestion` JSDoc notes edge must exist (#193) +- **`formatDecisionSummary` guard** — `result.decisions` now defaults to `[]` via nullish coalescing to prevent TypeError (#193) +- **`DoctorIssue` typedef updated** — Added optional `source`, `target`, `edgeType` properties used by dangling-edge issues (#193) +- **`adjustSuggestion` sets `reviewedAt` on type change** — New edge created during type change now receives a `reviewedAt` timestamp (#193) +- **`generateSuggestions` rejection diagnostic** — Returns `rejectedCount` and logs a diagnostic when all suggestions were previously rejected (#193) +- **`child.stdin` error handler** — `callAgent` attaches a no-op error listener on stdin to prevent uncaught EPIPE exceptions (#193) +- **Doctor test fixture corrected** — Dangling-edge test issue now includes `source`/`target`/`edgeType` matching `fixIssues` expectations (#193) +- **`buildPrompt` defensive guards** — Handles nullish `context.graph`/`commits`/`files` with defaults instead of throwing TypeError (#193) +- **`fetchDecisionProps` shared helper** — Extracted duplicated decision-node fetch logic from `getPendingSuggestions` and `getReviewHistory` into a reusable helper (#193) ### Changed -- **Test count** — 162 tests across 9 files (was 143 across 8) +- **`suggest` and `review` stubs replaced** with full implementations (#193) +- **Test count** — 208 tests across 13 files (was 143 across 8) ## [2.0.0-alpha.1] - 2026-02-11 @@ -92,4 +134,5 @@ Complete rewrite from C23 to Node.js on `@git-stunts/git-warp`. - Docker-based CI/CD - All C-specific documentation +[2.0.0-alpha.2]: https://github.com/neuroglyph/git-mind/releases/tag/v2.0.0-alpha.2 [2.0.0-alpha.0]: https://github.com/neuroglyph/git-mind/releases/tag/v2.0.0-alpha.0 diff --git a/bin/git-mind.js b/bin/git-mind.js index 01f42656..dc5c7f90 100755 --- a/bin/git-mind.js +++ b/bin/git-mind.js @@ -5,7 +5,7 @@ * Usage: git mind [options] */ -import { init, link, view, list, remove, nodes, status, importCmd, installHooks, processCommitCmd, suggest, review } from '../src/cli/commands.js'; +import { init, link, view, list, remove, nodes, status, importCmd, installHooks, processCommitCmd, doctor, suggest, review } from '../src/cli/commands.js'; const args = process.argv.slice(2); const command = args[0]; @@ -36,24 +36,40 @@ Commands: --dry-run, --validate Validate without writing --json Output as JSON install-hooks Install post-commit Git hook - suggest --ai AI suggestions (stub) - review Review edges (stub) + doctor Run graph integrity checks + --fix Auto-fix dangling edges + --json Output as JSON + suggest AI-powered edge suggestions + --agent Override GITMIND_AGENT + --context Git range for context (default: HEAD~10..HEAD) + --json Output as JSON + review Review pending suggestions + --batch accept|reject Non-interactive batch mode + --json Output as JSON Edge types: implements, augments, relates-to, blocks, belongs-to, consumed-by, depends-on, documents`); } +const BOOLEAN_FLAGS = new Set(['json', 'fix']); + /** * Parse --flag value pairs from args. + * Boolean flags (--json, --fix) are set to true; others consume the next arg. * @param {string[]} args - * @returns {Record} + * @returns {Record} */ function parseFlags(args) { const flags = {}; for (let i = 0; i < args.length; i++) { - if (args[i].startsWith('--') && i + 1 < args.length) { - flags[args[i].slice(2)] = args[i + 1]; - i++; + if (args[i].startsWith('--')) { + const name = args[i].slice(2); + if (BOOLEAN_FLAGS.has(name)) { + flags[name] = true; + } else if (i + 1 < args.length) { + flags[name] = args[i + 1]; + i++; + } } } return flags; @@ -150,13 +166,33 @@ switch (command) { await processCommitCmd(cwd, args[1]); break; - case 'suggest': - await suggest(); + case 'doctor': { + const doctorFlags = parseFlags(args.slice(1)); + await doctor(cwd, { + json: doctorFlags.json ?? false, + fix: doctorFlags.fix ?? false, + }); + break; + } + + case 'suggest': { + const suggestFlags = parseFlags(args.slice(1)); + await suggest(cwd, { + agent: suggestFlags.agent, + context: suggestFlags.context, + json: suggestFlags.json ?? false, + }); break; + } - case 'review': - await review(); + case 'review': { + const reviewFlags = parseFlags(args.slice(1)); + await review(cwd, { + batch: reviewFlags.batch, + json: reviewFlags.json ?? false, + }); break; + } case '--help': case '-h': diff --git a/src/cli/commands.js b/src/cli/commands.js index 9647ab6d..31b7991e 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -13,7 +13,10 @@ import { computeStatus } from '../status.js'; import { importFile } from '../import.js'; import { renderView, listViews } from '../views.js'; import { processCommit } from '../hooks.js'; -import { success, error, info, warning, formatEdge, formatView, formatNode, formatNodeList, formatStatus, formatImportResult } from './format.js'; +import { runDoctor, fixIssues } from '../doctor.js'; +import { generateSuggestions } from '../suggest.js'; +import { getPendingSuggestions, acceptSuggestion, rejectSuggestion, skipSuggestion, batchDecision } from '../review.js'; +import { success, error, info, warning, formatEdge, formatView, formatNode, formatNodeList, formatStatus, formatImportResult, formatDoctorResult, formatSuggestions, formatReviewItem, formatDecisionSummary } from './format.js'; /** * Initialize a git-mind graph in the current repo. @@ -277,15 +280,135 @@ export async function importCmd(cwd, filePath, opts = {}) { } /** - * Stub: AI suggestions. + * Run graph integrity checks. + * @param {string} cwd + * @param {{ json?: boolean, fix?: boolean }} opts + */ +export async function doctor(cwd, opts = {}) { + try { + const graph = await loadGraph(cwd); + const result = await runDoctor(graph); + + let fixResult; + if (opts.fix && result.issues.length > 0) { + fixResult = await fixIssues(graph, result.issues); + } + + if (opts.json) { + console.log(JSON.stringify(fixResult ? { ...result, fix: fixResult } : result, null, 2)); + } else { + console.log(formatDoctorResult(result, fixResult)); + } + + if (result.summary.errors > 0) { + process.exitCode = 1; + } + } catch (err) { + console.error(error(err.message)); + process.exitCode = 1; + } +} + +/** + * Generate AI-powered edge suggestions. + * @param {string} cwd + * @param {{ agent?: string, context?: string, json?: boolean }} opts */ -export async function suggest() { - console.log(info('AI suggestions not yet implemented')); +export async function suggest(cwd, opts = {}) { + try { + const graph = await loadGraph(cwd); + const result = await generateSuggestions(cwd, graph, { + agent: opts.agent, + range: opts.context, + }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatSuggestions(result)); + } + } catch (err) { + console.error(error(err.message)); + process.exitCode = 1; + } } /** - * Stub: review edges. + * Review pending suggestions interactively or in batch. + * @param {string} cwd + * @param {{ batch?: string, json?: boolean }} opts */ -export async function review() { - console.log(info('Edge review not yet implemented')); +export async function review(cwd, opts = {}) { + try { + const graph = await loadGraph(cwd); + + // Batch mode + if (opts.batch) { + if (opts.batch !== 'accept' && opts.batch !== 'reject') { + console.error(error('--batch must be "accept" or "reject"')); + process.exitCode = 1; + return; + } + const result = await batchDecision(graph, opts.batch); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatDecisionSummary(result)); + } + return; + } + + // Interactive mode + const pending = await getPendingSuggestions(graph); + + if (pending.length === 0) { + console.log(info('No pending suggestions to review')); + return; + } + + if (opts.json) { + console.log(JSON.stringify(pending, null, 2)); + return; + } + + const { createInterface } = await import('node:readline'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q) => new Promise(resolve => rl.question(q, resolve)); + + const decisions = []; + + try { + for (let i = 0; i < pending.length; i++) { + const item = pending[i]; + console.log(''); + console.log(formatReviewItem(item, i, pending.length)); + + const answer = await ask(' [a]ccept / [r]eject / [s]kip ? '); + const choice = answer.trim().toLowerCase(); + + if (choice === 'a' || choice === 'accept') { + const d = await acceptSuggestion(graph, item); + decisions.push(d); + console.log(success('Accepted')); + } else if (choice === 'r' || choice === 'reject') { + const d = await rejectSuggestion(graph, item); + decisions.push(d); + console.log(success('Rejected')); + } else { + const d = skipSuggestion(item); + decisions.push(d); + console.log(info('Skipped')); + } + } + } finally { + rl.close(); + } + + console.log(''); + console.log(formatDecisionSummary({ processed: decisions.length, decisions })); + } catch (err) { + console.error(error(err.message)); + process.exitCode = 1; + } } diff --git a/src/cli/format.js b/src/cli/format.js index 6e3e2271..db89f550 100644 --- a/src/cli/format.js +++ b/src/cli/format.js @@ -180,6 +180,135 @@ export function formatStatus(status) { return lines.join('\n'); } +/** + * Format a doctor result for terminal display. + * @param {import('../doctor.js').DoctorResult} result + * @param {{ fixed?: number, skipped?: number, details?: string[] }} [fixResult] + * @returns {string} + */ +export function formatDoctorResult(result, fixResult) { + const lines = []; + + lines.push(chalk.bold('Doctor')); + lines.push(chalk.dim('═'.repeat(32))); + lines.push(''); + + if (result.clean) { + lines.push(`${chalk.green(figures.tick)} Graph is healthy — no issues found`); + } else { + for (const issue of result.issues) { + const icon = issue.severity === 'error' + ? chalk.red(figures.cross) + : issue.severity === 'warning' + ? chalk.yellow(figures.warning) + : chalk.blue(figures.info); + lines.push(`${icon} ${issue.message}`); + } + + lines.push(''); + lines.push(chalk.dim( + `${result.summary.errors} error(s), ${result.summary.warnings} warning(s), ${result.summary.info} info` + )); + } + + if (fixResult) { + lines.push(''); + lines.push(chalk.bold('Fix Results')); + lines.push(` ${chalk.green(figures.tick)} Fixed: ${fixResult.fixed}`); + lines.push(` ${chalk.dim('Skipped:')} ${fixResult.skipped}`); + for (const detail of fixResult.details ?? []) { + lines.push(` ${chalk.dim('·')} ${detail}`); + } + } + + return lines.join('\n'); +} + +/** + * Format a list of suggestions for terminal display. + * @param {import('../suggest.js').SuggestResult} result + * @returns {string} + */ +export function formatSuggestions(result) { + const lines = []; + + lines.push(chalk.bold('Suggestions')); + lines.push(chalk.dim('═'.repeat(32))); + lines.push(''); + + if (result.suggestions.length === 0) { + lines.push(chalk.dim(' No suggestions generated')); + } else { + for (const s of result.suggestions) { + const confStr = chalk.dim(`(${(s.confidence * 100).toFixed(0)}%)`); + lines.push(` ${chalk.cyan(s.source)} ${chalk.dim('--[')}${chalk.yellow(s.type)}${chalk.dim(']-->')} ${chalk.cyan(s.target)} ${confStr}`); + if (s.rationale) { + lines.push(` ${chalk.dim(s.rationale)}`); + } + } + } + + if (result.errors?.length > 0) { + lines.push(''); + lines.push(chalk.yellow(`${result.errors.length} parse error(s):`)); + for (const err of result.errors) { + lines.push(` ${chalk.yellow(figures.warning)} ${err}`); + } + } + + return lines.join('\n'); +} + +/** + * Format a single review item for terminal display. + * @param {import('../review.js').PendingSuggestion} item + * @param {number} index + * @param {number} total + * @returns {string} + */ +export function formatReviewItem(item, index, total) { + const lines = []; + lines.push(chalk.bold(`Review [${index + 1}/${total}]`)); + lines.push(chalk.dim('─'.repeat(32))); + const confStr = chalk.dim(`(${(item.confidence * 100).toFixed(0)}%)`); + lines.push(` ${chalk.cyan(item.source)} ${chalk.dim('--[')}${chalk.yellow(item.type)}${chalk.dim(']-->')} ${chalk.cyan(item.target)} ${confStr}`); + if (item.rationale) { + lines.push(` ${chalk.dim('Rationale:')} ${item.rationale}`); + } + return lines.join('\n'); +} + +/** + * Format a decision summary for terminal display. + * @param {{ processed: number, decisions: import('../review.js').ReviewDecision[] }} result + * @returns {string} + */ +export function formatDecisionSummary(result) { + const lines = []; + + lines.push(chalk.bold('Review Summary')); + lines.push(chalk.dim('═'.repeat(32))); + lines.push(''); + + if (result.processed === 0) { + lines.push(chalk.dim(' No pending suggestions to review')); + } else { + const counts = {}; + for (const d of result.decisions ?? []) { + counts[d.action] = (counts[d.action] ?? 0) + 1; + } + lines.push(` ${chalk.green(figures.tick)} Processed ${result.processed} suggestion(s)`); + for (const [action, count] of Object.entries(counts)) { + const icon = action === 'accept' ? chalk.green(figures.tick) + : action === 'reject' ? chalk.red(figures.cross) + : chalk.blue(figures.info); + lines.push(` ${icon} ${action}: ${count}`); + } + } + + return lines.join('\n'); +} + /** * Format an import result for terminal display. * @param {import('../import.js').ImportResult} result diff --git a/src/context.js b/src/context.js new file mode 100644 index 00000000..d3f6b042 --- /dev/null +++ b/src/context.js @@ -0,0 +1,304 @@ +/** + * @module context + * Git context extraction for AI-powered suggestions. + * Builds structured prompts from repository state and graph data. + */ + +import { execSync } from 'node:child_process'; +import { EDGE_TYPES, CANONICAL_PREFIXES } from './validators.js'; + +/** + * @typedef {object} FileContext + * @property {string} path - File path relative to repo root + * @property {string} language - Inferred language from extension + */ + +/** + * @typedef {object} CommitContext + * @property {string} sha - Short SHA + * @property {string} message - First line of commit message + * @property {string[]} files - Changed files in this commit + */ + +/** + * @typedef {object} GraphContext + * @property {string[]} nodes - Existing node IDs + * @property {Array<{from: string, to: string, label: string}>} edges - Existing edges + */ + +/** + * @typedef {object} SuggestContext + * @property {FileContext[]} files + * @property {CommitContext[]} commits + * @property {GraphContext} graph + * @property {string} prompt - Assembled LLM prompt + */ + +const LANGUAGE_MAP = { + '.js': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.jsx': 'javascript', + '.py': 'python', + '.rb': 'ruby', + '.rs': 'rust', + '.go': 'go', + '.java': 'java', + '.c': 'c', + '.cpp': 'cpp', + '.h': 'c', + '.hpp': 'cpp', + '.cs': 'csharp', + '.sh': 'shell', + '.bash': 'shell', + '.zsh': 'shell', + '.md': 'markdown', + '.yaml': 'yaml', + '.yml': 'yaml', + '.json': 'json', + '.toml': 'toml', + '.css': 'css', + '.html': 'html', + '.sql': 'sql', +}; + +/** + * Infer language from file extension. + * @param {string} filePath + * @returns {string} + */ +function inferLanguage(filePath) { + const dot = filePath.lastIndexOf('.'); + if (dot === -1) return 'unknown'; + const ext = filePath.slice(dot).toLowerCase(); + return LANGUAGE_MAP[ext] ?? 'unknown'; +} + +/** + * Extract tracked file paths from git. + * + * @param {string} cwd - Repository root + * @param {{ limit?: number }} [opts={}] + * @returns {FileContext[]} + */ +export function extractFileContext(cwd, opts = {}) { + const limit = opts.limit ?? 200; + try { + const output = execSync('git ls-files', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); + const files = output.trim().split('\n').filter(Boolean).slice(0, limit); + return files.map(path => ({ path, language: inferLanguage(path) })); + } catch { + return []; + } +} + +/** + * Extract recent commit context from git log. + * + * @param {string} cwd - Repository root + * @param {{ range?: string, limit?: number }} [opts={}] + * @returns {CommitContext[]} + */ +/** Validate that a string is safe for use as a git command argument. */ +function sanitizeGitArg(value) { + if (/[;&|`$(){}!#<>\n\r]/.test(value)) { + throw new Error(`Unsafe characters in git argument: ${value}`); + } + return value; +} + +export function extractCommitContext(cwd, opts = {}) { + const limit = Math.max(1, Math.min(Number.parseInt(opts.limit ?? 10, 10) || 10, 100)); + const range = sanitizeGitArg(opts.range ?? `HEAD~${limit}..HEAD`); + + try { + // Get commits with short sha and first line of message + const logOutput = execSync( + `git log --format="%h %s" ${range} 2>/dev/null || git log --format="%h %s" -${limit}`, + { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } + ); + const lines = logOutput.trim().split('\n').filter(Boolean); + + return lines.map(line => { + const spaceIdx = line.indexOf(' '); + const sha = line.slice(0, spaceIdx); + const message = line.slice(spaceIdx + 1); + + // Validate sha is a hex string before interpolation + if (!/^[0-9a-f]+$/.test(sha)) return { sha, message, files: [] }; + + // Get changed files for this commit + let files = []; + try { + const filesOutput = execSync( + `git diff-tree --no-commit-id --name-only -r ${sha}`, + { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } + ); + files = filesOutput.trim().split('\n').filter(Boolean); + } catch { + // Ignore — might be initial commit + } + + return { sha, message, files }; + }); + } catch { + return []; + } +} + +/** + * Extract graph context (existing nodes and edges, optionally filtered by file paths). + * + * @param {import('@git-stunts/git-warp').default} graph + * @param {string[]} [filePaths] + * @returns {Promise} + */ +export async function extractGraphContext(graph, filePaths) { + const nodes = await graph.getNodes(); + const edges = await graph.getEdges(); + + if (!filePaths || filePaths.length === 0) { + return { nodes, edges }; + } + + // Find nodes related to the given file paths + const fileNodeSet = new Set(); + for (const fp of filePaths) { + for (const node of nodes) { + if (node === `file:${fp}` || (node.startsWith('file:') && node.endsWith(`/${fp}`))) { + fileNodeSet.add(node); + } + } + } + + // Include nodes connected to file nodes + const relatedNodes = new Set(fileNodeSet); + for (const e of edges) { + if (fileNodeSet.has(e.from)) relatedNodes.add(e.to); + if (fileNodeSet.has(e.to)) relatedNodes.add(e.from); + } + + const filteredEdges = edges.filter( + e => relatedNodes.has(e.from) || relatedNodes.has(e.to) + ); + + return { + nodes: nodes.filter(n => relatedNodes.has(n)), + edges: filteredEdges, + }; +} + +/** + * Build an LLM prompt from extracted context. + * + * @param {SuggestContext} context + * @param {{ maxLength?: number }} [opts={}] + * @returns {string} + */ +export function buildPrompt(context, opts = {}) { + const maxLength = opts.maxLength ?? 4000; + const parts = []; + const graph = context.graph ?? { nodes: [], edges: [] }; + const commits = context.commits ?? []; + const files = context.files ?? []; + + parts.push('You are a knowledge graph assistant for a software project.'); + parts.push('Suggest new semantic edges for the project knowledge graph.'); + parts.push(''); + parts.push('## Graph Schema'); + parts.push(`Node prefixes: ${CANONICAL_PREFIXES.join(', ')}`); + parts.push(`Edge types: ${EDGE_TYPES.join(', ')}`); + parts.push('Node IDs use the format: prefix:identifier (e.g., file:src/auth.js, spec:auth-flow)'); + parts.push(''); + + // Existing graph + if (graph.nodes.length > 0) { + parts.push('## Existing Nodes'); + const nodeSlice = graph.nodes.slice(0, 50); + parts.push(nodeSlice.join(', ')); + if (graph.nodes.length > 50) { + parts.push(`... and ${graph.nodes.length - 50} more`); + } + parts.push(''); + } + + if (graph.edges.length > 0) { + parts.push('## Existing Edges'); + const edgeSlice = graph.edges.slice(0, 30); + for (const e of edgeSlice) { + parts.push(` ${e.from} --[${e.label}]--> ${e.to}`); + } + if (graph.edges.length > 30) { + parts.push(`... and ${graph.edges.length - 30} more`); + } + parts.push(''); + } + + // Recent commits + if (commits.length > 0) { + parts.push('## Recent Commits'); + for (const c of commits) { + parts.push(` ${c.sha} ${c.message}`); + if (c.files.length > 0) { + parts.push(` files: ${c.files.slice(0, 5).join(', ')}${c.files.length > 5 ? ' ...' : ''}`); + } + } + parts.push(''); + } + + // Files + if (files.length > 0) { + parts.push('## Tracked Files'); + const fileSlice = files.slice(0, 30); + parts.push(fileSlice.map(f => f.path).join(', ')); + if (files.length > 30) { + parts.push(`... and ${files.length - 30} more`); + } + parts.push(''); + } + + parts.push('## Instructions'); + parts.push('Respond with a JSON array of suggested edges. Each suggestion:'); + parts.push('```json'); + parts.push('['); + parts.push(' { "source": "prefix:id", "target": "prefix:id", "type": "edge-type", "confidence": 0.7, "rationale": "why" }'); + parts.push(']'); + parts.push('```'); + parts.push(''); + parts.push('Rules:'); + parts.push('- Use existing node IDs when possible, or suggest new ones with valid prefixes'); + parts.push('- Confidence 0.0-1.0 (higher = more certain)'); + parts.push('- Do NOT duplicate existing edges'); + parts.push('- Focus on edges that capture meaningful project relationships'); + + let prompt = parts.join('\n'); + + // Truncate if too long + if (prompt.length > maxLength) { + prompt = prompt.slice(0, maxLength - 20) + '\n\n[truncated]'; + } + + return prompt; +} + +/** + * Orchestrator: extract all context from git state and graph. + * + * @param {string} cwd - Repository root + * @param {import('@git-stunts/git-warp').default} graph + * @param {{ range?: string, limit?: number }} [opts={}] + * @returns {Promise} + */ +export async function extractContext(cwd, graph, opts = {}) { + const files = extractFileContext(cwd, opts); + const commits = extractCommitContext(cwd, opts); + const filePaths = files.map(f => f.path); + const graphCtx = await extractGraphContext(graph, filePaths); + + const context = { files, commits, graph: graphCtx }; + const prompt = buildPrompt(context, opts); + + return { ...context, prompt }; +} diff --git a/src/doctor.js b/src/doctor.js new file mode 100644 index 00000000..d1f20c14 --- /dev/null +++ b/src/doctor.js @@ -0,0 +1,189 @@ +/** + * @module doctor + * Graph integrity detectors for git-mind. + * Composable checks that identify structural issues in the knowledge graph. + */ + +import { isLowConfidence } from './validators.js'; +import { removeEdge } from './edges.js'; + +/** + * @typedef {object} DoctorIssue + * @property {string} type - Issue type identifier + * @property {'error'|'warning'|'info'} severity + * @property {string} message - Human-readable description + * @property {string[]} affected - IDs of affected nodes/edges + * @property {string} [source] - Source node ID (set by dangling-edge) + * @property {string} [target] - Target node ID (set by dangling-edge) + * @property {string} [edgeType] - Edge type (set by dangling-edge) + */ + +/** + * @typedef {object} DoctorResult + * @property {DoctorIssue[]} issues + * @property {{ errors: number, warnings: number, info: number }} summary + * @property {boolean} clean + */ + +/** + * Detect edges whose source or target node does not exist in the graph. + * + * @param {string[]} nodes + * @param {Array<{from: string, to: string, label: string}>} edges + * @returns {DoctorIssue[]} + */ +export function detectDanglingEdges(nodes, edges) { + const nodeSet = new Set(nodes); + const issues = []; + + for (const edge of edges) { + const missing = []; + if (!nodeSet.has(edge.from)) missing.push(edge.from); + if (!nodeSet.has(edge.to)) missing.push(edge.to); + + if (missing.length > 0) { + issues.push({ + type: 'dangling-edge', + severity: 'error', + message: `Edge ${edge.from} --[${edge.label}]--> ${edge.to} references missing node(s): ${missing.join(', ')}`, + affected: [edge.from, edge.to, edge.label], + source: edge.from, + target: edge.to, + edgeType: edge.label, + }); + } + } + + return issues; +} + +/** + * Detect milestone nodes with no belongs-to edges pointing at them. + * + * @param {string[]} nodes + * @param {Array<{from: string, to: string, label: string}>} edges + * @returns {DoctorIssue[]} + */ +export function detectOrphanMilestones(nodes, edges) { + const milestones = nodes.filter(n => n.startsWith('milestone:')); + if (milestones.length === 0) return []; + + const belongsToTargets = new Set( + edges.filter(e => e.label === 'belongs-to').map(e => e.to) + ); + + return milestones + .filter(m => !belongsToTargets.has(m)) + .map(m => ({ + type: 'orphan-milestone', + severity: 'warning', + message: `Milestone ${m} has no children (no belongs-to edges target it)`, + affected: [m], + })); +} + +/** + * Detect nodes that are not referenced by any edge. + * + * @param {string[]} nodes + * @param {Array<{from: string, to: string, label: string}>} edges + * @returns {DoctorIssue[]} + */ +export function detectOrphanNodes(nodes, edges) { + const connected = new Set(); + for (const e of edges) { + connected.add(e.from); + connected.add(e.to); + } + + return nodes + .filter(n => !connected.has(n) && !n.startsWith('decision:')) + .map(n => ({ + type: 'orphan-node', + severity: 'info', + message: `Node ${n} is not connected to any edge`, + affected: [n], + })); +} + +/** + * Detect edges with confidence below the given threshold. + * + * @param {Array<{from: string, to: string, label: string, props?: object}>} edges + * @param {number} [threshold=0.3] + * @returns {DoctorIssue[]} + */ +export function detectLowConfidenceEdges(edges, threshold = 0.3) { + return edges + .filter(e => { + const c = e.props?.confidence; + return typeof c === 'number' && c < threshold; + }) + .map(e => ({ + type: 'low-confidence', + severity: 'info', + message: `Edge ${e.from} --[${e.label}]--> ${e.to} has low confidence (${e.props.confidence})`, + affected: [e.from, e.to, e.label], + })); +} + +/** + * Run all doctor detectors against the graph. + * + * @param {import('@git-stunts/git-warp').default} graph + * @param {{ threshold?: number }} [opts={}] + * @returns {Promise} + */ +export async function runDoctor(graph, opts = {}) { + const nodes = await graph.getNodes(); + const edges = await graph.getEdges(); + + const issues = [ + ...detectDanglingEdges(nodes, edges), + ...detectOrphanMilestones(nodes, edges), + ...detectOrphanNodes(nodes, edges), + ...detectLowConfidenceEdges(edges, opts.threshold), + ]; + + const summary = { errors: 0, warnings: 0, info: 0 }; + for (const issue of issues) { + if (issue.severity === 'error') summary.errors++; + else if (issue.severity === 'warning') summary.warnings++; + else if (issue.severity === 'info') summary.info++; + } + + return { issues, summary, clean: issues.length === 0 }; +} + +/** + * Fix auto-fixable issues. Currently supports removing dangling edges. + * Note: orphan node removal is not supported by git-warp (nodes cannot be deleted). + * + * @param {import('@git-stunts/git-warp').default} graph + * @param {DoctorIssue[]} issues + * @returns {Promise<{ fixed: number, skipped: number, details: string[] }>} + */ +export async function fixIssues(graph, issues) { + let fixed = 0; + let skipped = 0; + const details = []; + + for (const issue of issues) { + if (issue.type === 'dangling-edge') { + const { source, target, edgeType } = issue; + try { + await removeEdge(graph, source, target, edgeType); + fixed++; + details.push(`Removed dangling edge: ${source} --[${edgeType}]--> ${target}`); + } catch (err) { + skipped++; + details.push(`Failed to remove edge ${source} --[${edgeType}]--> ${target}: ${err.message}`); + } + } else { + skipped++; + details.push(`Cannot auto-fix ${issue.type}: ${issue.message}`); + } + } + + return { fixed, skipped, details }; +} diff --git a/src/index.js b/src/index.js index c1349f9f..9f17065d 100644 --- a/src/index.js +++ b/src/index.js @@ -16,3 +16,16 @@ export { } from './validators.js'; export { defineView, declareView, renderView, listViews, resetViews } from './views.js'; export { parseDirectives, processCommit } from './hooks.js'; +export { + detectDanglingEdges, detectOrphanMilestones, detectOrphanNodes, + detectLowConfidenceEdges, runDoctor, fixIssues, +} from './doctor.js'; +export { + extractFileContext, extractCommitContext, extractGraphContext, + buildPrompt, extractContext, +} from './context.js'; +export { callAgent, parseSuggestions, filterRejected, generateSuggestions } from './suggest.js'; +export { + getPendingSuggestions, acceptSuggestion, rejectSuggestion, + adjustSuggestion, skipSuggestion, getReviewHistory, batchDecision, +} from './review.js'; diff --git a/src/review.js b/src/review.js new file mode 100644 index 00000000..f22d651d --- /dev/null +++ b/src/review.js @@ -0,0 +1,314 @@ +/** + * @module review + * Review decisions and provenance for git-mind. + * Stores decisions as decision: prefixed nodes in the graph. + */ + +import { createHash } from 'node:crypto'; +import { isLowConfidence } from './validators.js'; +import { removeEdge, createEdge } from './edges.js'; + +/** + * @typedef {object} PendingSuggestion + * @property {string} source - Source node ID + * @property {string} target - Target node ID + * @property {string} type - Edge label + * @property {number} confidence + * @property {string} [rationale] + * @property {string} [createdAt] + */ + +/** + * @typedef {object} ReviewDecision + * @property {string} id - Decision node ID + * @property {'accept'|'reject'|'adjust'|'skip'} action + * @property {string} source + * @property {string} target + * @property {string} edgeType + * @property {number} confidence + * @property {string} [rationale] + * @property {number} timestamp + * @property {string} [reviewer] + */ + +/** + * Generate a unique decision node ID from edge components. + * + * @param {string} source + * @param {string} target + * @param {string} type + * @returns {string} + */ +function makeDecisionId(source, target, type) { + const hash = createHash('sha256') + .update(`${source}|${target}|${type}`) + .digest('hex') + .slice(0, 8); + const epoch = Math.floor(Date.now() / 1000); + return `decision:${epoch}-${hash}`; +} + +/** + * Record a decision node in the graph. + * + * @param {import('@git-stunts/git-warp').default} graph + * @param {ReviewDecision} decision + * @returns {Promise} + */ +async function recordDecision(graph, decision) { + const patch = await graph.createPatch(); + patch.addNode(decision.id); + patch.setProperty(decision.id, 'action', decision.action); + patch.setProperty(decision.id, 'source', decision.source); + patch.setProperty(decision.id, 'target', decision.target); + patch.setProperty(decision.id, 'edgeType', decision.edgeType); + patch.setProperty(decision.id, 'confidence', decision.confidence); + patch.setProperty(decision.id, 'timestamp', decision.timestamp); + if (decision.rationale) { + patch.setProperty(decision.id, 'rationale', decision.rationale); + } + if (decision.reviewer) { + patch.setProperty(decision.id, 'reviewer', decision.reviewer); + } + await patch.commit(); +} + +/** + * Fetch all decision-node IDs and their properties from the graph. + * + * @param {import('@git-stunts/git-warp').default} graph + * @returns {Promise | null }>>} + */ +async function fetchDecisionProps(graph) { + const nodes = await graph.getNodes(); + const decisionNodes = nodes.filter(n => n.startsWith('decision:')); + const propsResults = await Promise.all(decisionNodes.map(id => graph.getNodeProps(id))); + return decisionNodes.map((id, i) => ({ id, props: propsResults[i] })); +} + +/** + * Get pending suggestions: low-confidence edges that haven't been reviewed yet. + * + * @param {import('@git-stunts/git-warp').default} graph + * @returns {Promise} + */ +export async function getPendingSuggestions(graph) { + const edges = await graph.getEdges(); + const lowConf = edges.filter(isLowConfidence); + + if (lowConf.length === 0) return []; + + // Find reviewed edge keys from decision nodes + const reviewedKeys = new Set(); + for (const { props: propsMap } of await fetchDecisionProps(graph)) { + if (!propsMap) continue; + const source = propsMap.get('source'); + const target = propsMap.get('target'); + const edgeType = propsMap.get('edgeType'); + if (source && target && edgeType) { + reviewedKeys.add(`${source}|${target}|${edgeType}`); + } + } + + return lowConf + .filter(e => !reviewedKeys.has(`${e.from}|${e.to}|${e.label}`)) + .map(e => ({ + source: e.from, + target: e.to, + type: e.label, + confidence: e.props?.confidence ?? 0, + rationale: e.props?.rationale, + createdAt: e.props?.createdAt, + })); +} + +/** + * Accept a suggestion: promote edge confidence to 1.0, record decision. + * Assumes single-writer: the edge must still exist when called. + * If the edge was concurrently deleted, setEdgeProperty will throw. + * + * @param {import('@git-stunts/git-warp').default} graph + * @param {PendingSuggestion} suggestion + * @param {{ reviewer?: string }} [opts={}] + * @returns {Promise} + */ +export async function acceptSuggestion(graph, suggestion, opts = {}) { + // Update edge confidence to 1.0 and add reviewedAt + const patch = await graph.createPatch(); + patch.setEdgeProperty(suggestion.source, suggestion.target, suggestion.type, 'confidence', 1.0); + patch.setEdgeProperty(suggestion.source, suggestion.target, suggestion.type, 'reviewedAt', new Date().toISOString()); + await patch.commit(); + + const decision = { + id: makeDecisionId(suggestion.source, suggestion.target, suggestion.type), + action: 'accept', + source: suggestion.source, + target: suggestion.target, + edgeType: suggestion.type, + confidence: 1.0, + rationale: suggestion.rationale, + timestamp: Math.floor(Date.now() / 1000), + reviewer: opts.reviewer, + }; + + await recordDecision(graph, decision); + return decision; +} + +/** + * Reject a suggestion: remove the low-confidence edge, record decision. + * + * @param {import('@git-stunts/git-warp').default} graph + * @param {PendingSuggestion} suggestion + * @param {{ reviewer?: string }} [opts={}] + * @returns {Promise} + */ +export async function rejectSuggestion(graph, suggestion, opts = {}) { + await removeEdge(graph, suggestion.source, suggestion.target, suggestion.type); + + const decision = { + id: makeDecisionId(suggestion.source, suggestion.target, suggestion.type), + action: 'reject', + source: suggestion.source, + target: suggestion.target, + edgeType: suggestion.type, + confidence: suggestion.confidence, + rationale: suggestion.rationale, + timestamp: Math.floor(Date.now() / 1000), + reviewer: opts.reviewer, + }; + + await recordDecision(graph, decision); + return decision; +} + +/** + * Adjust a suggestion: update edge props, record decision. + * Assumes single-writer: the edge must still exist when called. + * + * @param {import('@git-stunts/git-warp').default} graph + * @param {PendingSuggestion} original + * @param {{ type?: string, confidence?: number, rationale?: string, reviewer?: string }} adjustments + * @returns {Promise} + */ +export async function adjustSuggestion(graph, original, adjustments = {}) { + const newType = adjustments.type ?? original.type; + const newConf = adjustments.confidence ?? original.confidence; + + // If type changed, create new edge first, then remove old (safer ordering) + if (newType !== original.type) { + await createEdge(graph, { + source: original.source, + target: original.target, + type: newType, + confidence: newConf, + rationale: adjustments.rationale ?? original.rationale, + }); + // Set reviewedAt on the new edge + const patch = await graph.createPatch(); + patch.setEdgeProperty(original.source, original.target, newType, 'reviewedAt', new Date().toISOString()); + await patch.commit(); + await removeEdge(graph, original.source, original.target, original.type); + } else { + // Update existing edge + const patch = await graph.createPatch(); + patch.setEdgeProperty(original.source, original.target, original.type, 'confidence', newConf); + if (adjustments.rationale) { + patch.setEdgeProperty(original.source, original.target, original.type, 'rationale', adjustments.rationale); + } + patch.setEdgeProperty(original.source, original.target, original.type, 'reviewedAt', new Date().toISOString()); + await patch.commit(); + } + + const decision = { + id: makeDecisionId(original.source, original.target, original.type), + action: 'adjust', + source: original.source, + target: original.target, + edgeType: newType, + confidence: newConf, + rationale: adjustments.rationale ?? original.rationale, + timestamp: Math.floor(Date.now() / 1000), + reviewer: adjustments.reviewer, + }; + + await recordDecision(graph, decision); + return decision; +} + +/** + * Skip a suggestion: defers the decision without persisting. + * Skipped items intentionally remain pending and will reappear in future + * review sessions, allowing the reviewer to revisit them later. + * + * @param {PendingSuggestion} suggestion + * @returns {ReviewDecision} + */ +export function skipSuggestion(suggestion) { + return { + id: makeDecisionId(suggestion.source, suggestion.target, suggestion.type), + action: 'skip', + source: suggestion.source, + target: suggestion.target, + edgeType: suggestion.type, + confidence: suggestion.confidence, + rationale: suggestion.rationale, + timestamp: Math.floor(Date.now() / 1000), + }; +} + +/** + * Get review history from decision nodes in the graph. + * + * @param {import('@git-stunts/git-warp').default} graph + * @param {{ action?: string }} [filter={}] + * @returns {Promise} + */ +export async function getReviewHistory(graph, filter = {}) { + const decisions = []; + for (const { id, props: propsMap } of await fetchDecisionProps(graph)) { + if (!propsMap) continue; + + const action = propsMap.get('action'); + if (filter.action && action !== filter.action) continue; + + decisions.push({ + id, + action, + source: propsMap.get('source'), + target: propsMap.get('target'), + edgeType: propsMap.get('edgeType'), + confidence: propsMap.get('confidence'), + rationale: propsMap.get('rationale'), + timestamp: propsMap.get('timestamp'), + reviewer: propsMap.get('reviewer'), + }); + } + + return decisions.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); +} + +/** + * Apply a batch decision to all pending suggestions. + * + * @param {import('@git-stunts/git-warp').default} graph + * @param {'accept'|'reject'} action + * @param {{ reviewer?: string }} [opts={}] + * @returns {Promise<{ processed: number, decisions: ReviewDecision[] }>} + */ +export async function batchDecision(graph, action, opts = {}) { + if (action !== 'accept' && action !== 'reject') { + throw new Error(`Invalid batch action: ${action}. Must be "accept" or "reject".`); + } + const pending = await getPendingSuggestions(graph); + const decisions = []; + + for (const suggestion of pending) { + const decision = action === 'accept' + ? await acceptSuggestion(graph, suggestion, opts) + : await rejectSuggestion(graph, suggestion, opts); + decisions.push(decision); + } + + return { processed: decisions.length, decisions }; +} diff --git a/src/suggest.js b/src/suggest.js new file mode 100644 index 00000000..e9cc22ce --- /dev/null +++ b/src/suggest.js @@ -0,0 +1,237 @@ +/** + * @module suggest + * Agent-powered edge suggestions for git-mind. + * Shells out to a user-configured command via GITMIND_AGENT env var. + */ + +import { spawn } from 'node:child_process'; +import { validateNodeId, validateEdgeType, validateConfidence } from './validators.js'; +import { queryEdges } from './edges.js'; +import { extractContext } from './context.js'; + +/** + * @typedef {object} Suggestion + * @property {string} source - Source node ID + * @property {string} target - Target node ID + * @property {string} type - Edge type + * @property {number} confidence - 0.0–1.0 + * @property {string} [rationale] - Why this edge is suggested + */ + +/** + * @typedef {object} SuggestResult + * @property {Suggestion[]} suggestions + * @property {string[]} errors + * @property {string} prompt - The prompt sent to the agent + * @property {number} [rejectedCount] - Number of suggestions filtered by prior rejections + */ + +/** + * Call an external agent command with a prompt via stdin. + * Returns the raw stdout text. + * + * @param {string} prompt - Prompt to send via stdin + * @param {{ agent?: string }} [opts={}] + * @returns {Promise} + */ +export function callAgent(prompt, opts = {}) { + const cmd = opts.agent ?? process.env.GITMIND_AGENT; + if (!cmd) { + throw new Error( + 'GITMIND_AGENT not set. Configure an AI agent command.\n' + + 'Example: export GITMIND_AGENT="claude -p --output-format json"' + ); + } + + const timeout = opts.timeout ?? 120_000; + + return new Promise((resolve, reject) => { + const child = spawn(cmd, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] }); + const chunks = []; + const errChunks = []; + let settled = false; + + const timer = setTimeout(() => { + if (!settled) { + settled = true; + child.kill('SIGTERM'); + reject(new Error(`Agent command timed out after ${timeout}ms`)); + } + }, timeout); + + child.stdout.on('data', (data) => chunks.push(data)); + child.stderr.on('data', (data) => errChunks.push(data)); + + child.on('error', (err) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(new Error(`Agent command failed to start: ${err.message}`)); + } + }); + + child.on('close', (code) => { + if (!settled) { + settled = true; + clearTimeout(timer); + const stdout = Buffer.concat(chunks).toString('utf-8'); + if (code !== 0) { + const stderr = Buffer.concat(errChunks).toString('utf-8'); + reject(new Error(`Agent exited with code ${code}: ${stderr.slice(0, 500)}`)); + } else { + resolve(stdout); + } + } + }); + + child.stdin.on('error', () => {}); // prevent uncaught EPIPE + child.stdin.write(prompt); + child.stdin.end(); + }); +} + +/** + * Parse and validate suggestions from raw agent response text. + * Handles raw JSON arrays and markdown code fences. + * + * @param {string} responseText + * @returns {{ suggestions: Suggestion[], errors: string[] }} + */ +export function parseSuggestions(responseText) { + const errors = []; + + if (!responseText || !responseText.trim()) { + return { suggestions: [], errors: ['Empty response from agent'] }; + } + + let text = responseText.trim(); + + // Extract JSON from markdown code fences if present (indexOf-based, no regex) + const fenceStart = text.indexOf('```'); + if (fenceStart !== -1) { + const contentStart = text.indexOf('\n', fenceStart); + const fenceEnd = text.indexOf('\n```', contentStart + 1); + if (contentStart !== -1 && fenceEnd !== -1) { + text = text.slice(contentStart + 1, fenceEnd).trim(); + } + } + + // Try to parse as JSON + let parsed; + try { + parsed = JSON.parse(text); + } catch (err) { + // Try to find a JSON array in the text using indexOf (avoids ReDoS) + const start = text.indexOf('['); + const end = text.lastIndexOf(']'); + if (start !== -1 && end > start) { + try { + parsed = JSON.parse(text.slice(start, end + 1)); + } catch { + return { suggestions: [], errors: [`Failed to parse agent response as JSON: ${err.message}`] }; + } + } else { + return { suggestions: [], errors: [`Failed to parse agent response as JSON: ${err.message}`] }; + } + } + + if (!Array.isArray(parsed)) { + return { suggestions: [], errors: ['Agent response is not a JSON array'] }; + } + + const suggestions = []; + for (let i = 0; i < parsed.length; i++) { + const item = parsed[i]; + const itemErrors = []; + + if (!item || typeof item !== 'object') { + errors.push(`Item ${i}: not an object`); + continue; + } + + // Validate source + const srcResult = validateNodeId(item.source); + if (!srcResult.valid) itemErrors.push(`source: ${srcResult.error}`); + + // Validate target + const tgtResult = validateNodeId(item.target); + if (!tgtResult.valid) itemErrors.push(`target: ${tgtResult.error}`); + + // Validate type + const typeResult = validateEdgeType(item.type); + if (!typeResult.valid) itemErrors.push(typeResult.error); + + // Validate confidence + const conf = typeof item.confidence === 'number' ? item.confidence : 0.5; + const confResult = validateConfidence(conf); + if (!confResult.valid) itemErrors.push(confResult.error); + + if (itemErrors.length > 0) { + errors.push(`Item ${i}: ${itemErrors.join('; ')}`); + continue; + } + + suggestions.push({ + source: item.source, + target: item.target, + type: item.type, + confidence: conf, + rationale: item.rationale ?? '', + }); + } + + return { suggestions, errors }; +} + +/** + * Filter out suggestions that have been previously rejected (decision nodes exist). + * + * @param {Suggestion[]} suggestions + * @param {import('@git-stunts/git-warp').default} graph + * @returns {Promise} + */ +export async function filterRejected(suggestions, graph) { + const nodes = await graph.getNodes(); + const decisionNodes = nodes.filter(n => n.startsWith('decision:')); + + if (decisionNodes.length === 0) return suggestions; + + // Build a set of rejected source|target|type combinations + const rejectedKeys = new Set(); + const propsResults = await Promise.all(decisionNodes.map(id => graph.getNodeProps(id))); + for (const propsMap of propsResults) { + if (!propsMap) continue; + const action = propsMap.get('action'); + if (action !== 'reject') continue; + const source = propsMap.get('source'); + const target = propsMap.get('target'); + const edgeType = propsMap.get('edgeType'); + if (source && target && edgeType) { + rejectedKeys.add(`${source}|${target}|${edgeType}`); + } + } + + return suggestions.filter(s => !rejectedKeys.has(`${s.source}|${s.target}|${s.type}`)); +} + +/** + * Generate suggestions by extracting context, calling agent, and parsing results. + * + * @param {string} cwd - Repository root + * @param {import('@git-stunts/git-warp').default} graph + * @param {{ agent?: string, range?: string }} [opts={}] + * @returns {Promise} + */ +export async function generateSuggestions(cwd, graph, opts = {}) { + const context = await extractContext(cwd, graph, opts); + const responseText = await callAgent(context.prompt, opts); + const { suggestions, errors } = parseSuggestions(responseText); + const filtered = await filterRejected(suggestions, graph); + + const rejectedCount = suggestions.length - filtered.length; + if (rejectedCount > 0 && filtered.length === 0 && errors.length === 0) { + errors.push(`All ${rejectedCount} suggestion(s) were previously rejected`); + } + + return { suggestions: filtered, errors, prompt: context.prompt, rejectedCount }; +} diff --git a/test/context.test.js b/test/context.test.js new file mode 100644 index 00000000..7d30e68b --- /dev/null +++ b/test/context.test.js @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execSync } from 'node:child_process'; +import { initGraph } from '../src/graph.js'; +import { createEdge } from '../src/edges.js'; +import { + extractFileContext, + extractCommitContext, + extractGraphContext, + buildPrompt, + extractContext, +} from '../src/context.js'; + +describe('context', () => { + let tempDir; + let graph; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'gitmind-test-')); + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.name "Test"', { cwd: tempDir, stdio: 'ignore' }); + graph = await initGraph(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + // ── extractFileContext ────────────────────────────────────── + + it('extracts tracked files with inferred languages', async () => { + await writeFile(join(tempDir, 'app.js'), 'console.log("hello")'); + await writeFile(join(tempDir, 'README.md'), '# Hello'); + execSync('git add app.js README.md && git commit -m "init"', { cwd: tempDir, stdio: 'ignore' }); + + const files = extractFileContext(tempDir); + + expect(files).toHaveLength(2); + const jsFile = files.find(f => f.path === 'app.js'); + expect(jsFile).toBeDefined(); + expect(jsFile.language).toBe('javascript'); + + const mdFile = files.find(f => f.path === 'README.md'); + expect(mdFile).toBeDefined(); + expect(mdFile.language).toBe('markdown'); + }); + + it('returns empty array for repo with no tracked files', () => { + const files = extractFileContext(tempDir); + expect(files).toEqual([]); + }); + + it('respects the limit option', async () => { + await writeFile(join(tempDir, 'a.js'), ''); + await writeFile(join(tempDir, 'b.js'), ''); + await writeFile(join(tempDir, 'c.js'), ''); + execSync('git add a.js b.js c.js && git commit -m "init"', { cwd: tempDir, stdio: 'ignore' }); + + const files = extractFileContext(tempDir, { limit: 2 }); + expect(files).toHaveLength(2); + }); + + // ── extractCommitContext ──────────────────────────────────── + + it('extracts recent commits with files', async () => { + await writeFile(join(tempDir, 'app.js'), 'v1'); + execSync('git add app.js && git commit -m "feat: initial"', { cwd: tempDir, stdio: 'ignore' }); + + await writeFile(join(tempDir, 'app.js'), 'v2'); + execSync('git add app.js && git commit -m "fix: update app"', { cwd: tempDir, stdio: 'ignore' }); + + const commits = extractCommitContext(tempDir); + + expect(commits.length).toBeGreaterThanOrEqual(1); + expect(commits[0].sha).toBeTruthy(); + expect(commits[0].message).toBeTruthy(); + const hasFiles = commits.some(c => c.files && c.files.includes('app.js')); + expect(hasFiles).toBe(true); + }); + + it('returns empty array for repo with no commits', () => { + const commits = extractCommitContext(tempDir); + expect(commits).toEqual([]); + }); + + // ── extractGraphContext ───────────────────────────────────── + + it('returns all nodes and edges when no filePaths given', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + const ctx = await extractGraphContext(graph); + + expect(ctx.nodes).toContain('task:a'); + expect(ctx.nodes).toContain('spec:b'); + expect(ctx.edges).toHaveLength(1); + }); + + // ── buildPrompt ───────────────────────────────────────────── + + it('produces a structured prompt with schema info', () => { + const context = { + files: [{ path: 'src/app.js', language: 'javascript' }], + commits: [{ sha: 'abc1234', message: 'feat: init', files: ['src/app.js'] }], + graph: { + nodes: ['task:a', 'spec:b'], + edges: [{ from: 'task:a', to: 'spec:b', label: 'implements' }], + }, + }; + const prompt = buildPrompt(context); + + expect(prompt).toContain('Graph Schema'); + expect(prompt).toContain('implements'); + expect(prompt).toContain('task:a'); + expect(prompt).toContain('JSON array'); + }); + + it('truncates prompt to maxLength', () => { + const context = { + files: Array.from({ length: 100 }, (_, i) => ({ path: `file${i}.js`, language: 'javascript' })), + commits: [], + graph: { nodes: [], edges: [] }, + }; + const prompt = buildPrompt(context, { maxLength: 500 }); + expect(prompt.length).toBeLessThanOrEqual(500); + }); + + // ── security: sanitizeGitArg ─────────────────────────────── + + it('rejects range values with shell metacharacters', () => { + expect(() => extractCommitContext(tempDir, { range: 'HEAD; rm -rf /' })).toThrow(/Unsafe characters/); + expect(() => extractCommitContext(tempDir, { range: 'HEAD | cat /etc/passwd' })).toThrow(/Unsafe characters/); + expect(() => extractCommitContext(tempDir, { range: '$(whoami)' })).toThrow(/Unsafe characters/); + expect(() => extractCommitContext(tempDir, { range: 'HEAD > /tmp/output' })).toThrow(/Unsafe characters/); + expect(() => extractCommitContext(tempDir, { range: 'HEAD\necho pwned' })).toThrow(/Unsafe characters/); + }); + + it('uses exact match for file node association', async () => { + await createEdge(graph, { source: 'file:src/app.js', target: 'spec:main', type: 'implements' }); + await createEdge(graph, { source: 'file:src/app.json', target: 'spec:other', type: 'implements' }); + + // "app.js" should match "file:app.js" or "file:src/app.js" but NOT "file:src/app.json" + const ctx = await extractGraphContext(graph, ['app.js']); + const matchedNodes = ctx.nodes.filter(n => n.startsWith('file:')); + expect(matchedNodes).toContain('file:src/app.js'); + expect(matchedNodes).not.toContain('file:src/app.json'); + }); + + it('buildPrompt handles partial context gracefully', () => { + // Missing graph, commits, files should not throw + const prompt = buildPrompt({}); + expect(prompt).toContain('Graph Schema'); + expect(prompt).toContain('JSON array'); + }); + + // ── extractContext orchestrator ───────────────────────────── + + it('assembles full context with prompt', async () => { + await writeFile(join(tempDir, 'app.js'), 'console.log("hi")'); + execSync('git add app.js && git commit -m "init"', { cwd: tempDir, stdio: 'ignore' }); + + await createEdge(graph, { source: 'file:app.js', target: 'spec:main', type: 'implements' }); + const ctx = await extractContext(tempDir, graph); + + expect(ctx.files).toBeDefined(); + expect(ctx.commits).toBeDefined(); + expect(ctx.graph).toBeDefined(); + expect(ctx.prompt).toContain('Graph Schema'); + }); +}); diff --git a/test/doctor.test.js b/test/doctor.test.js new file mode 100644 index 00000000..fd8bbf8f --- /dev/null +++ b/test/doctor.test.js @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execSync } from 'node:child_process'; +import { initGraph } from '../src/graph.js'; +import { createEdge } from '../src/edges.js'; +import { + detectDanglingEdges, + detectOrphanMilestones, + detectOrphanNodes, + detectLowConfidenceEdges, + runDoctor, + fixIssues, +} from '../src/doctor.js'; + +describe('doctor', () => { + let tempDir; + let graph; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'gitmind-test-')); + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + graph = await initGraph(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + // ── detectDanglingEdges ───────────────────────────────────── + + it('returns empty for graph with no dangling edges', () => { + const nodes = ['task:a', 'spec:b']; + const edges = [{ from: 'task:a', to: 'spec:b', label: 'implements' }]; + expect(detectDanglingEdges(nodes, edges)).toEqual([]); + }); + + it('detects edges with missing source or target', () => { + const nodes = ['task:a']; + const edges = [{ from: 'task:a', to: 'spec:gone', label: 'implements' }]; + const issues = detectDanglingEdges(nodes, edges); + + expect(issues).toHaveLength(1); + expect(issues[0].type).toBe('dangling-edge'); + expect(issues[0].severity).toBe('error'); + expect(issues[0].affected).toContain('spec:gone'); + }); + + // ── detectOrphanMilestones ────────────────────────────────── + + it('detects milestones with no belongs-to children', () => { + const nodes = ['milestone:v1', 'task:a']; + const edges = [{ from: 'task:a', to: 'task:a', label: 'relates-to' }]; + const issues = detectOrphanMilestones(nodes, edges); + + expect(issues).toHaveLength(1); + expect(issues[0].type).toBe('orphan-milestone'); + expect(issues[0].severity).toBe('warning'); + }); + + it('does not flag milestones that have children', () => { + const nodes = ['milestone:v1', 'task:a']; + const edges = [{ from: 'task:a', to: 'milestone:v1', label: 'belongs-to' }]; + expect(detectOrphanMilestones(nodes, edges)).toEqual([]); + }); + + // ── detectOrphanNodes ─────────────────────────────────────── + + it('detects nodes not connected to any edge', () => { + const nodes = ['task:a', 'task:b', 'task:c']; + const edges = [{ from: 'task:a', to: 'task:b', label: 'blocks' }]; + const issues = detectOrphanNodes(nodes, edges); + + expect(issues).toHaveLength(1); + expect(issues[0].type).toBe('orphan-node'); + expect(issues[0].severity).toBe('info'); + expect(issues[0].affected).toEqual(['task:c']); + }); + + it('excludes decision: nodes from orphan detection', () => { + const nodes = ['task:a', 'task:b', 'decision:123-abc']; + const edges = [{ from: 'task:a', to: 'task:b', label: 'blocks' }]; + const issues = detectOrphanNodes(nodes, edges); + + expect(issues).toHaveLength(0); + expect(issues.find(i => i.affected[0] === 'decision:123-abc')).toBeUndefined(); + }); + + // ── detectLowConfidenceEdges ──────────────────────────────── + + it('detects edges below the confidence threshold', () => { + const edges = [ + { from: 'task:a', to: 'spec:b', label: 'implements', props: { confidence: 0.2 } }, + { from: 'task:c', to: 'spec:d', label: 'implements', props: { confidence: 0.5 } }, + { from: 'task:e', to: 'spec:f', label: 'implements', props: { confidence: 1.0 } }, + ]; + const issues = detectLowConfidenceEdges(edges, 0.3); + + expect(issues).toHaveLength(1); + expect(issues[0].type).toBe('low-confidence'); + expect(issues[0].affected).toContain('task:a'); + }); + + it('uses default threshold of 0.3', () => { + const edges = [ + { from: 'task:a', to: 'spec:b', label: 'implements', props: { confidence: 0.29 } }, + { from: 'task:c', to: 'spec:d', label: 'implements', props: { confidence: 0.3 } }, + ]; + const issues = detectLowConfidenceEdges(edges); + expect(issues).toHaveLength(1); + }); + + // ── runDoctor ─────────────────────────────────────────────── + + it('returns clean result for a healthy graph', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + const result = await runDoctor(graph); + + expect(result.clean).toBe(true); + expect(result.issues).toHaveLength(0); + expect(result.summary).toEqual({ errors: 0, warnings: 0, info: 0 }); + }); + + it('aggregates issues from all detectors', async () => { + // Create a low-confidence edge + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.1 }); + // Add a milestone with a belongs-to child so it's not orphan-node, + // but then add a second milestone with no children + await createEdge(graph, { source: 'task:a', target: 'milestone:v1', type: 'belongs-to' }); + const patch = await graph.createPatch(); + patch.addNode('milestone:v2'); + await patch.commit(); + + const result = await runDoctor(graph, { threshold: 0.3 }); + + expect(result.clean).toBe(false); + // orphan milestone (v2 has no belongs-to children) + expect(result.summary.warnings).toBeGreaterThanOrEqual(1); + // low-confidence edge + orphan node (v2) + expect(result.summary.info).toBeGreaterThanOrEqual(1); + const types = result.issues.map(i => i.type); + expect(types).toContain('orphan-milestone'); + expect(types).toContain('low-confidence'); + expect(types).toContain('orphan-node'); + }); + + // ── fixIssues ─────────────────────────────────────────────── + + it('removes dangling edges and skips non-fixable issues', async () => { + // Create a valid edge, then simulate a dangling edge scenario + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + + const issues = [ + { + type: 'dangling-edge', + severity: 'error', + message: 'test', + affected: ['task:a', 'spec:b', 'implements'], + source: 'task:a', + target: 'spec:b', + edgeType: 'implements', + }, + { + type: 'orphan-node', + severity: 'info', + message: 'test orphan', + affected: ['task:orphan'], + }, + ]; + + const result = await fixIssues(graph, issues); + + expect(result.fixed).toBe(1); + expect(result.skipped).toBe(1); + expect(result.details).toHaveLength(2); + expect(result.details[0]).toMatch(/Removed dangling edge/); + expect(result.details[1]).toMatch(/Cannot auto-fix/); + }); +}); diff --git a/test/review.test.js b/test/review.test.js new file mode 100644 index 00000000..87e10548 --- /dev/null +++ b/test/review.test.js @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execSync } from 'node:child_process'; +import { initGraph } from '../src/graph.js'; +import { createEdge, queryEdges } from '../src/edges.js'; +import { + getPendingSuggestions, + acceptSuggestion, + rejectSuggestion, + adjustSuggestion, + skipSuggestion, + getReviewHistory, + batchDecision, +} from '../src/review.js'; + +describe('review', () => { + let tempDir; + let graph; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'gitmind-test-')); + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + graph = await initGraph(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + // ── getPendingSuggestions ─────────────────────────────────── + + it('returns low-confidence edges that have not been reviewed', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + await createEdge(graph, { source: 'task:c', target: 'spec:d', type: 'implements', confidence: 1.0 }); + + const pending = await getPendingSuggestions(graph); + + expect(pending).toHaveLength(1); + expect(pending[0].source).toBe('task:a'); + expect(pending[0].confidence).toBe(0.3); + }); + + it('excludes edges that already have a decision', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + + // Record a decision for this edge + const patch = await graph.createPatch(); + patch.addNode('decision:test-1'); + patch.setProperty('decision:test-1', 'action', 'accept'); + patch.setProperty('decision:test-1', 'source', 'task:a'); + patch.setProperty('decision:test-1', 'target', 'spec:b'); + patch.setProperty('decision:test-1', 'edgeType', 'implements'); + await patch.commit(); + + const pending = await getPendingSuggestions(graph); + expect(pending).toHaveLength(0); + }); + + // ── acceptSuggestion ─────────────────────────────────────── + + it('promotes edge confidence to 1.0 and records decision', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + + const suggestion = { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }; + const decision = await acceptSuggestion(graph, suggestion, { reviewer: 'james' }); + + expect(decision.action).toBe('accept'); + expect(decision.confidence).toBe(1.0); + expect(decision.reviewer).toBe('james'); + + // Check edge was updated + const edges = await queryEdges(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + expect(edges[0].props.confidence).toBe(1.0); + expect(edges[0].props.reviewedAt).toBeTruthy(); + + // Check decision node was recorded + const history = await getReviewHistory(graph); + expect(history).toHaveLength(1); + expect(history[0].action).toBe('accept'); + }); + + // ── rejectSuggestion ─────────────────────────────────────── + + it('removes edge and records decision', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + + const suggestion = { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }; + const decision = await rejectSuggestion(graph, suggestion); + + expect(decision.action).toBe('reject'); + + // Edge should be gone + const edges = await queryEdges(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + expect(edges).toHaveLength(0); + + // Decision node persists + const history = await getReviewHistory(graph); + expect(history).toHaveLength(1); + expect(history[0].action).toBe('reject'); + }); + + // ── adjustSuggestion ─────────────────────────────────────── + + it('modifies edge confidence and records decision', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + + const original = { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }; + const decision = await adjustSuggestion(graph, original, { confidence: 0.9, rationale: 'looks good' }); + + expect(decision.action).toBe('adjust'); + expect(decision.confidence).toBe(0.9); + + // Check edge was updated + const edges = await queryEdges(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + expect(edges[0].props.confidence).toBe(0.9); + }); + + it('sets reviewedAt on new edge when type changes', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + + const original = { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }; + await adjustSuggestion(graph, original, { type: 'augments' }); + + const edges = await queryEdges(graph, { source: 'task:a', target: 'spec:b', type: 'augments' }); + expect(edges).toHaveLength(1); + expect(edges[0].props.reviewedAt).toBeTruthy(); + }); + + // ── skipSuggestion ───────────────────────────────────────── + + it('returns decision without modifying graph', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + + const suggestion = { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }; + const decision = skipSuggestion(suggestion); + + expect(decision.action).toBe('skip'); + + // Edge untouched + const edges = await queryEdges(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + expect(edges[0].props.confidence).toBe(0.3); + + // No decision node in graph (skip doesn't write) + const history = await getReviewHistory(graph); + expect(history).toHaveLength(0); + }); + + // ── getReviewHistory ─────────────────────────────────────── + + it('filters history by action', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + await createEdge(graph, { source: 'task:c', target: 'spec:d', type: 'implements', confidence: 0.2 }); + + await acceptSuggestion(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + await rejectSuggestion(graph, { source: 'task:c', target: 'spec:d', type: 'implements', confidence: 0.2 }); + + const accepts = await getReviewHistory(graph, { action: 'accept' }); + expect(accepts).toHaveLength(1); + expect(accepts[0].action).toBe('accept'); + + const rejects = await getReviewHistory(graph, { action: 'reject' }); + expect(rejects).toHaveLength(1); + expect(rejects[0].action).toBe('reject'); + }); + + // ── adjustSuggestion: preserve original confidence ───────── + + it('preserves original confidence when adjustment omits confidence', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + + const original = { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }; + const decision = await adjustSuggestion(graph, original, { rationale: 'updated rationale' }); + + expect(decision.confidence).toBe(0.3); + + const edges = await queryEdges(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + expect(edges[0].props.confidence).toBe(0.3); + }); + + // ── batchDecision ────────────────────────────────────────── + + it('batch reject processes all pending', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + await createEdge(graph, { source: 'task:c', target: 'spec:d', type: 'implements', confidence: 0.2 }); + await createEdge(graph, { source: 'task:e', target: 'spec:f', type: 'implements', confidence: 1.0 }); // not pending + + const result = await batchDecision(graph, 'reject'); + + expect(result.processed).toBe(2); + expect(result.decisions).toHaveLength(2); + expect(result.decisions.every(d => d.action === 'reject')).toBe(true); + }); + + it('throws on invalid batch action', async () => { + await expect(batchDecision(graph, 'invalid')).rejects.toThrow(/Invalid batch action/); + }); + + it('batch accept promotes all pending', async () => { + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.3 }); + + const result = await batchDecision(graph, 'accept'); + + expect(result.processed).toBe(1); + expect(result.decisions[0].action).toBe('accept'); + + // Confirm edge was promoted + const edges = await queryEdges(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + expect(edges[0].props.confidence).toBe(1.0); + }); +}); diff --git a/test/suggest.test.js b/test/suggest.test.js new file mode 100644 index 00000000..634c4c0b --- /dev/null +++ b/test/suggest.test.js @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execSync } from 'node:child_process'; +import { initGraph } from '../src/graph.js'; +import { createEdge } from '../src/edges.js'; +import { callAgent, parseSuggestions, filterRejected } from '../src/suggest.js'; + +describe('suggest', () => { + let tempDir; + let graph; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'gitmind-test-')); + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + graph = await initGraph(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + // ── callAgent ─────────────────────────────────────────────── + + it('throws when GITMIND_AGENT is not set', () => { + const original = process.env.GITMIND_AGENT; + delete process.env.GITMIND_AGENT; + try { + expect(() => callAgent('test prompt')).toThrow(/GITMIND_AGENT not set/); + } finally { + if (original !== undefined) process.env.GITMIND_AGENT = original; + } + }); + + // ── parseSuggestions ──────────────────────────────────────── + + it('parses valid JSON array', () => { + const input = JSON.stringify([ + { source: 'file:app.js', target: 'spec:auth', type: 'implements', confidence: 0.8, rationale: 'direct impl' }, + ]); + const { suggestions, errors } = parseSuggestions(input); + + expect(errors).toHaveLength(0); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].source).toBe('file:app.js'); + expect(suggestions[0].confidence).toBe(0.8); + }); + + it('extracts JSON from markdown code fences', () => { + const input = `Here are my suggestions: + +\`\`\`json +[ + { "source": "task:a", "target": "spec:b", "type": "implements", "confidence": 0.7 } +] +\`\`\` + +That's all!`; + + const { suggestions, errors } = parseSuggestions(input); + + expect(errors).toHaveLength(0); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].type).toBe('implements'); + }); + + it('rejects invalid edge types', () => { + const input = JSON.stringify([ + { source: 'task:a', target: 'spec:b', type: 'foobar', confidence: 0.5 }, + ]); + const { suggestions, errors } = parseSuggestions(input); + + expect(suggestions).toHaveLength(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatch(/foobar/); + }); + + it('rejects invalid node IDs', () => { + const input = JSON.stringify([ + { source: 'bad id!', target: 'spec:b', type: 'implements', confidence: 0.5 }, + ]); + const { suggestions, errors } = parseSuggestions(input); + + expect(suggestions).toHaveLength(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatch(/source/); + }); + + it('handles empty response', () => { + const { suggestions, errors } = parseSuggestions(''); + expect(suggestions).toHaveLength(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatch(/Empty response/); + }); + + it('extracts JSON array using indexOf fallback', () => { + const input = 'Some preamble text [ { "source": "task:a", "target": "spec:b", "type": "implements", "confidence": 0.6 } ] trailing'; + const { suggestions, errors } = parseSuggestions(input); + expect(errors).toHaveLength(0); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].source).toBe('task:a'); + }); + + it('handles malformed JSON', () => { + const { suggestions, errors } = parseSuggestions('not json at all'); + expect(suggestions).toHaveLength(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatch(/Failed to parse/); + }); + + it('defaults confidence to 0.5 when missing', () => { + const input = JSON.stringify([ + { source: 'task:a', target: 'spec:b', type: 'implements' }, + ]); + const { suggestions } = parseSuggestions(input); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].confidence).toBe(0.5); + }); + + // ── filterRejected ───────────────────────────────────────── + + it('filters out previously rejected suggestions', async () => { + // Record a rejection decision + const patch = await graph.createPatch(); + patch.addNode('decision:123-abc'); + patch.setProperty('decision:123-abc', 'action', 'reject'); + patch.setProperty('decision:123-abc', 'source', 'task:a'); + patch.setProperty('decision:123-abc', 'target', 'spec:b'); + patch.setProperty('decision:123-abc', 'edgeType', 'implements'); + await patch.commit(); + + const suggestions = [ + { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.5 }, + { source: 'task:c', target: 'spec:d', type: 'relates-to', confidence: 0.6 }, + ]; + + const filtered = await filterRejected(suggestions, graph); + + expect(filtered).toHaveLength(1); + expect(filtered[0].source).toBe('task:c'); + }); + + it('returns all suggestions when no decision nodes exist', async () => { + const suggestions = [ + { source: 'task:a', target: 'spec:b', type: 'implements', confidence: 0.7 }, + ]; + const filtered = await filterRejected(suggestions, graph); + expect(filtered).toHaveLength(1); + }); +});