diff --git a/CHANGELOG.md b/CHANGELOG.md index fa64181e..351d5395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Import API** — `importFile(graph, path, { dryRun })`, `parseImportFile()`, `validateImportData()` in `src/import.js`; exported from public API - **Import CLI flags** — `--dry-run` validates without writing, `--validate` (alias), `--json` for structured output - **Node properties in import** — YAML nodes can declare `properties:` key/value maps, written via `patch.setProperty()` +- **Declarative view engine** — `declareView(name, config)` compiles prefix/type filter configs into views; existing `roadmap`, `architecture`, `backlog` views refactored to declarative configs +- **`milestone` view** — progress tracking per milestone: child task counts, completion percentage, blockers +- **`traceability` view** — spec-to-implementation gap analysis: identifies unimplemented specs/ADRs, reports coverage percentage +- **`blockers` view** — transitive blocking chain resolution with cycle detection, root blocker identification +- **`onboarding` view** — topologically-sorted reading order for doc/spec/ADR nodes with cycle detection - **Schema validators** — `src/validators.js` enforces GRAPH_SCHEMA.md at runtime: node ID grammar (`prefix:identifier`), edge type validation, confidence type safety (rejects NaN/Infinity/strings), self-edge rejection for `blocks` and `depends-on`, prefix classification with warnings for unknown prefixes - **Validator exports** — `validateNodeId`, `validateEdgeType`, `validateConfidence`, `validateEdge`, `extractPrefix`, `classifyPrefix`, plus constants `NODE_ID_REGEX`, `NODE_ID_MAX_LENGTH`, `CANONICAL_PREFIXES`, `SYSTEM_PREFIXES` ### Fixed +- **`buildChain` stack overflow on cyclic graphs** — Root blocker leading into a cycle (e.g., `C → A → B → A`) caused infinite recursion; added visited guard (#189) +- **Duplicate cycle reports in blockers view** — Per-root DFS visited sets caused the same cycle to be reported from multiple entry points; switched to global visited set (#189) +- **O(n*m) lookups in traceability/onboarding views** — Replaced `Array.includes()` with `Set.has()` for spec and sorted membership checks (#189) - **YAML arrays now rejected by `parseImportFile`** — `typeof [] === 'object'` no longer bypasses the guard; arrays produce "not an object" error instead of a confusing "Missing version" (#187) - **Array-typed `node.properties` rejected during validation** — `validateImportData` now rejects arrays in `properties`, preventing `Object.entries` from writing numeric-indexed keys (#187) - **Edge `createdAt` renamed to `importedAt`** — The timestamp on imported edges now honestly reflects import time; avoids misleading "creation" semantics on re-import (#187) @@ -35,7 +43,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`isLowConfidence()` shared helper** — Low-confidence threshold (`< 0.5`) extracted from `status.js` and `views.js` into `validators.js` to keep the threshold in one place (#185) - **`createEdge()` now validates all inputs** — Node IDs must use `prefix:identifier` format, confidence must be a finite number, self-edges rejected for blocking types - **`EDGE_TYPES` canonical source** moved to `validators.js` (re-exported from `edges.js` for backwards compatibility) -- **Test count** — 121 tests across 8 files (was 74) +- **`resetViews()` for test cleanup** — Removes test-registered views from the module-level registry, restoring built-in-only state (#189) +- **`builtInNames` initialized defensively** — Prevents `TypeError` if `resetViews()` is called before module finishes init (#189) +- **Removed dead `|| 0` fallback in onboarding view** — `inDegree` map is pre-initialized for all doc nodes, so the guard was unreachable (#189) +- **Milestone view returns self-contained subgraph** — Edge filter tightened from `||` to `&&` so returned edges only reference nodes in the result; eliminates dangling `implements` references to spec nodes (#189) +- **Onboarding view returns self-contained subgraph** — Same `||` → `&&` fix applied to `docEdges` filter; prevents non-doc nodes (e.g., `file:`) from appearing as dangling edge endpoints (#189) +- **`declareView` validates `config.prefixes`** — Throws on missing or empty prefixes array, surfacing misconfiguration early (#189) +- **Milestone view O(M×E) → O(E+M) edge lookups** — Pre-indexes `belongs-to` and `blocks` edges by target before the milestone loop (#189) +- **Onboarding ordering loop uses pre-filtered `docEdges`** — Eliminates redundant `docSet.has()` checks in dependency graph construction (#189) +- **Test count** — 143 tests across 8 files (was 74) ## [2.0.0-alpha.0] - 2026-02-07 diff --git a/src/index.js b/src/index.js index 63c68ca1..c1349f9f 100644 --- a/src/index.js +++ b/src/index.js @@ -14,5 +14,5 @@ export { NODE_ID_REGEX, NODE_ID_MAX_LENGTH, CANONICAL_PREFIXES, SYSTEM_PREFIXES, ALL_PREFIXES, LOW_CONFIDENCE_THRESHOLD, } from './validators.js'; -export { defineView, renderView, listViews } from './views.js'; +export { defineView, declareView, renderView, listViews, resetViews } from './views.js'; export { parseDirectives, processCommit } from './hooks.js'; diff --git a/src/views.js b/src/views.js index f170b577..f8586421 100644 --- a/src/views.js +++ b/src/views.js @@ -1,10 +1,10 @@ /** * @module views - * Observer view definitions and rendering for git-mind. - * Views are filtered projections of the graph. + * Declarative view engine for git-mind. + * Views are filtered, computed projections of the graph. */ -import { isLowConfidence } from './validators.js'; +import { extractPrefix, isLowConfidence } from './validators.js'; /** @type {Map} */ const registry = new Map(); @@ -12,11 +12,35 @@ const registry = new Map(); /** * @typedef {object} ViewDefinition * @property {string} name - * @property {(nodes: string[], edges: Array<{from: string, to: string, label: string, props: object}>) => {nodes: string[], edges: Array<{from: string, to: string, label: string, props: object}>}} filterFn + * @property {string} [description] + * @property {(nodes: string[], edges: Edge[]) => ViewResult} filterFn */ /** - * Register a named view. + * @typedef {object} Edge + * @property {string} from + * @property {string} to + * @property {string} label + * @property {Record} [props] + */ + +/** + * @typedef {object} ViewResult + * @property {string[]} nodes + * @property {Edge[]} edges + * @property {Record} [meta] - Optional computed metadata + */ + +/** + * @typedef {object} ViewConfig + * @property {string[]} prefixes - Node prefix filter + * @property {string[]} [edgeTypes] - Edge type filter (default: all) + * @property {boolean} [requireBothEndpoints=false] - If true, both edge endpoints must be in filtered nodes + * @property {string} [description] + */ + +/** + * Register a named view with a filter function. * * @param {string} name * @param {ViewDefinition['filterFn']} filterFn @@ -25,12 +49,46 @@ export function defineView(name, filterFn) { registry.set(name, { name, filterFn }); } +/** + * Register a declarative view from a config object. + * Compiles the config into a filter function. + * + * @param {string} name + * @param {ViewConfig} config + */ +export function declareView(name, config) { + if (!Array.isArray(config.prefixes) || config.prefixes.length === 0) { + throw new Error(`declareView("${name}"): config.prefixes must be a non-empty array`); + } + const prefixSet = new Set(config.prefixes); + const typeSet = config.edgeTypes ? new Set(config.edgeTypes) : null; + const bothEndpoints = config.requireBothEndpoints ?? false; + + const filterFn = (nodes, edges) => { + const matchedNodes = nodes.filter(n => { + const prefix = extractPrefix(n); + return prefix && prefixSet.has(prefix); + }); + const nodeSet = new Set(matchedNodes); + + const matchedEdges = edges.filter(e => { + if (typeSet && !typeSet.has(e.label)) return false; + if (bothEndpoints) return nodeSet.has(e.from) && nodeSet.has(e.to); + return nodeSet.has(e.from) || nodeSet.has(e.to); + }); + + return { nodes: matchedNodes, edges: matchedEdges }; + }; + + registry.set(name, { name, description: config.description, filterFn }); +} + /** * Render a named view against the graph. * * @param {import('@git-stunts/git-warp').default} graph * @param {string} viewName - * @returns {Promise<{nodes: string[], edges: Array<{from: string, to: string, label: string, props: object}>}>} + * @returns {Promise} */ export async function renderView(graph, viewName) { const view = registry.get(viewName); @@ -53,37 +111,41 @@ export function listViews() { return [...registry.keys()]; } -// --- Built-in views --- +/** @type {Set} Built-in view names, captured after registration */ +let builtInNames = new Set(); -defineView('roadmap', (nodes, edges) => { - // Nodes that look like phases or tasks (by ID prefix convention) - const phaseOrTask = nodes.filter(n => n.startsWith('phase:') || n.startsWith('task:')); - const relevantEdges = edges.filter( - e => phaseOrTask.includes(e.from) || phaseOrTask.includes(e.to) - ); - return { nodes: phaseOrTask, edges: relevantEdges }; +/** + * Remove all views that were not registered at module load time. + * Intended for test cleanup. + */ +export function resetViews() { + // Safe: ES Map iterators handle deletion of not-yet-visited entries + for (const name of registry.keys()) { + if (!builtInNames.has(name)) registry.delete(name); + } +} + +// ── Built-in declarative views ────────────────────────────────── + +declareView('roadmap', { + description: 'Phase and task nodes with all related edges', + prefixes: ['phase', 'task'], }); -defineView('architecture', (nodes, edges) => { - // Nodes that look like crates/modules and depends-on edges - const modules = nodes.filter(n => - n.startsWith('crate:') || n.startsWith('module:') || n.startsWith('pkg:') - ); - const depEdges = edges.filter( - e => e.label === 'depends-on' && modules.includes(e.from) && modules.includes(e.to) - ); - return { nodes: modules, edges: depEdges }; +declareView('architecture', { + description: 'Module nodes with depends-on edges', + prefixes: ['crate', 'module', 'pkg'], + edgeTypes: ['depends-on'], + requireBothEndpoints: true, }); -defineView('backlog', (nodes, edges) => { - // All task nodes - const tasks = nodes.filter(n => n.startsWith('task:')); - const taskEdges = edges.filter( - e => tasks.includes(e.from) || tasks.includes(e.to) - ); - return { nodes: tasks, edges: taskEdges }; +declareView('backlog', { + description: 'Task nodes with all related edges', + prefixes: ['task'], }); +// ── Built-in imperative views ─────────────────────────────────── + defineView('suggestions', (nodes, edges) => { // Edges with low confidence (AI-suggested, not yet reviewed) const lowConfEdges = edges.filter(isLowConfidence); @@ -97,3 +159,247 @@ defineView('suggestions', (nodes, edges) => { edges: lowConfEdges, }; }); + +// ── PRISM views ───────────────────────────────────────────────── + +defineView('milestone', (nodes, edges) => { + // Milestone progress: find milestones and their children (tasks + features + // linked via belongs-to), then compute completion stats per milestone. + const milestones = nodes.filter(n => n.startsWith('milestone:')); + const tasks = nodes.filter(n => n.startsWith('task:')); + const features = nodes.filter(n => n.startsWith('feature:')); + const relevant = new Set([...milestones, ...tasks, ...features]); + + const relevantEdges = edges.filter( + e => relevant.has(e.from) && relevant.has(e.to) + ); + + // Pre-compute set of nodes that have an outgoing 'implements' edge + const hasImplements = new Set( + edges.filter(e => e.label === 'implements').map(e => e.from) + ); + + // Pre-index belongs-to and blocks edges by target for O(E + M) lookups + const belongsToByTarget = new Map(); + const blocksByTarget = new Map(); + for (const e of edges) { + if (e.label === 'belongs-to') { + if (!belongsToByTarget.has(e.to)) belongsToByTarget.set(e.to, []); + belongsToByTarget.get(e.to).push(e); + } else if (e.label === 'blocks') { + if (!blocksByTarget.has(e.to)) blocksByTarget.set(e.to, []); + blocksByTarget.get(e.to).push(e); + } + } + + // Compute per-milestone stats + const milestoneStats = {}; + for (const m of milestones) { + // Tasks and features that belong-to this milestone + const children = (belongsToByTarget.get(m) || []) + .filter(e => e.from.startsWith('task:') || e.from.startsWith('feature:')) + .map(e => e.from); + + // A child is "done" if it has at least one 'implements' edge pointing from it + const done = children.filter(child => hasImplements.has(child)); + + // Blockers: tasks that block children of this milestone + const blockers = []; + for (const child of children) { + for (const e of (blocksByTarget.get(child) || [])) { + blockers.push(e.from); + } + } + + milestoneStats[m] = { + total: children.length, + done: done.length, + pct: children.length > 0 ? Math.round((done.length / children.length) * 100) : 0, + blockers: [...new Set(blockers)], + }; + } + + return { + nodes: [...relevant], + edges: relevantEdges, + meta: { milestoneStats }, + }; +}); + +defineView('traceability', (nodes, edges) => { + // Spec-to-implementation gap analysis. + // Find specs, then check which have 'implements' edges pointing at them. + const specs = nodes.filter(n => n.startsWith('spec:') || n.startsWith('adr:')); + const specSet = new Set(specs); + const implementsEdges = edges.filter(e => e.label === 'implements'); + + const implemented = new Set(implementsEdges.map(e => e.to)); + const gaps = specs.filter(s => !implemented.has(s)); + const covered = specs.filter(s => implemented.has(s)); + + // Include all implements edges + the spec/impl nodes + const implNodes = new Set(specs); + for (const e of implementsEdges) { + if (specSet.has(e.to)) { + implNodes.add(e.from); + } + } + + const relevantEdges = implementsEdges.filter(e => specSet.has(e.to)); + + return { + nodes: [...implNodes], + edges: relevantEdges, + meta: { + gaps, + covered, + coveragePct: specs.length > 0 ? Math.round((covered.length / specs.length) * 100) : 100, + }, + }; +}); + +defineView('blockers', (nodes, edges) => { + // Transitive blocking chain resolution with cycle detection. + const blockEdges = edges.filter(e => e.label === 'blocks'); + + // Build adjacency list: blocker -> [blocked] + const adj = new Map(); + for (const e of blockEdges) { + if (!adj.has(e.from)) adj.set(e.from, []); + adj.get(e.from).push(e.to); + } + + // Find all transitive chains from each root blocker + const chains = []; + const cycles = []; + const allInvolved = new Set(); + + // DFS from each node that has outgoing blocks edges + const visited = new Set(); + for (const root of adj.keys()) { + const path = []; + const onStack = new Set(); + + const dfs = (node) => { + if (onStack.has(node)) { + // Cycle detected + const cycleStart = path.indexOf(node); + cycles.push([...path.slice(cycleStart), node]); + return; + } + if (visited.has(node)) return; + visited.add(node); + path.push(node); + onStack.add(node); + allInvolved.add(node); + + const targets = adj.get(node) || []; + for (const t of targets) { + allInvolved.add(t); + dfs(t); + } + path.pop(); + onStack.delete(node); + }; + + dfs(root); + } + + // Build chains: root blockers (nodes that block others but aren't blocked themselves) + const blocked = new Set(blockEdges.map(e => e.to)); + const roots = [...adj.keys()].filter(n => !blocked.has(n)); + + for (const root of roots) { + const chain = []; + const seen = new Set(); + const buildChain = (node, depth) => { + if (seen.has(node)) return; + seen.add(node); + chain.push({ node, depth }); + for (const t of (adj.get(node) || [])) { + buildChain(t, depth + 1); + } + }; + buildChain(root, 0); + if (chain.length > 1) chains.push(chain); + } + + return { + nodes: [...allInvolved], + edges: blockEdges, + meta: { chains, cycles, rootBlockers: roots }, + }; +}); + +defineView('onboarding', (nodes, edges) => { + // Topologically-sorted reading order for new engineers. + // Uses doc/spec/adr nodes, ordered by dependency edges. + const docNodes = nodes.filter(n => + n.startsWith('doc:') || n.startsWith('spec:') || n.startsWith('adr:') + ); + const docSet = new Set(docNodes); + + // Relevant edges: depends-on, implements, documents between doc nodes + // or pointing to doc nodes + const relevantTypes = new Set(['depends-on', 'documents', 'implements']); + const docEdges = edges.filter(e => + relevantTypes.has(e.label) && docSet.has(e.from) && docSet.has(e.to) + ); + + // Build dependency graph for topological sort + // An edge A --[depends-on]--> B means read B before A + // An edge A --[documents]--> B means read A alongside B (no strict order) + const inDegree = new Map(); + const adj = new Map(); + for (const n of docNodes) { + inDegree.set(n, 0); + adj.set(n, []); + } + + for (const e of docEdges) { + if (e.label === 'depends-on') { + adj.get(e.to).push(e.from); // B should come before A + inDegree.set(e.from, inDegree.get(e.from) + 1); + } + } + + // Kahn's algorithm + const queue = []; + for (const [node, deg] of inDegree) { + if (deg === 0) queue.push(node); + } + queue.sort(); // deterministic tie-break: alphabetical + + const sorted = []; + const sortedSet = new Set(); + while (queue.length > 0) { + const node = queue.shift(); + sorted.push(node); + sortedSet.add(node); + for (const next of (adj.get(node) || [])) { + const newDeg = inDegree.get(next) - 1; + inDegree.set(next, newDeg); + if (newDeg === 0) { + // Insert sorted to maintain deterministic order + const idx = queue.findIndex(q => q > next); + if (idx === -1) queue.push(next); + else queue.splice(idx, 0, next); + } + } + } + + // Nodes not reached (part of a cycle) go at the end + const remaining = docNodes.filter(n => !sortedSet.has(n)).sort(); + + return { + nodes: [...sorted, ...remaining], + edges: docEdges, + meta: { + readingOrder: [...sorted, ...remaining], + hasCycles: remaining.length > 0, + }, + }; +}); + +// Capture built-in names after all registrations +builtInNames = new Set(registry.keys()); diff --git a/test/views.test.js b/test/views.test.js index e174e5fa..6aa94b70 100644 --- a/test/views.test.js +++ b/test/views.test.js @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { execSync } from 'node:child_process'; import { initGraph } from '../src/graph.js'; import { createEdge } from '../src/edges.js'; -import { renderView, listViews, defineView } from '../src/views.js'; +import { renderView, listViews, defineView, declareView, resetViews } from '../src/views.js'; describe('views', () => { let tempDir; @@ -18,21 +18,65 @@ describe('views', () => { }); afterEach(async () => { + resetViews(); await rm(tempDir, { recursive: true, force: true }); }); - it('listViews returns built-in views', () => { + // ── Core engine ─────────────────────────────────────────────── + + it('listViews returns all built-in views', () => { const views = listViews(); expect(views).toContain('roadmap'); expect(views).toContain('architecture'); expect(views).toContain('backlog'); expect(views).toContain('suggestions'); + expect(views).toContain('milestone'); + expect(views).toContain('traceability'); + expect(views).toContain('blockers'); + expect(views).toContain('onboarding'); }); it('renderView throws for unknown views', async () => { await expect(renderView(graph, 'nonexistent')).rejects.toThrow(/Unknown view/); }); + it('defineView registers a custom imperative view', async () => { + defineView('custom-test', (nodes, edges) => ({ + nodes: nodes.filter(n => n.startsWith('x:')), + edges: [], + })); + + await createEdge(graph, { source: 'x:foo', target: 'y:bar', type: 'relates-to' }); + + const result = await renderView(graph, 'custom-test'); + expect(result.nodes).toEqual(['x:foo']); + expect(result.edges).toEqual([]); + }); + + it('declareView throws on missing or empty prefixes', () => { + expect(() => declareView('bad-view', {})).toThrow('prefixes must be a non-empty array'); + expect(() => declareView('bad-view', { prefixes: [] })).toThrow('prefixes must be a non-empty array'); + }); + + it('declareView registers a config-based view', async () => { + declareView('declared-test', { + prefixes: ['pkg'], + edgeTypes: ['depends-on'], + requireBothEndpoints: true, + }); + + await createEdge(graph, { source: 'pkg:a', target: 'pkg:b', type: 'depends-on' }); + await createEdge(graph, { source: 'pkg:a', target: 'task:c', type: 'implements' }); + + const result = await renderView(graph, 'declared-test'); + expect(result.nodes).toContain('pkg:a'); + expect(result.nodes).toContain('pkg:b'); + expect(result.edges.length).toBe(1); + expect(result.edges[0].label).toBe('depends-on'); + }); + + // ── Existing views (now declarative) ────────────────────────── + it('roadmap view filters for phase/task nodes', async () => { await createEdge(graph, { source: 'phase:alpha', target: 'task:build-cli', type: 'blocks' }); await createEdge(graph, { source: 'file:src/main.js', target: 'doc:readme', type: 'documents' }); @@ -54,6 +98,16 @@ describe('views', () => { expect(result.edges[0].label).toBe('depends-on'); }); + it('backlog view filters for task nodes', async () => { + await createEdge(graph, { source: 'task:a', target: 'task:b', type: 'blocks' }); + await createEdge(graph, { source: 'module:x', target: 'module:y', type: 'depends-on' }); + + const result = await renderView(graph, 'backlog'); + expect(result.nodes).toContain('task:a'); + expect(result.nodes).toContain('task:b'); + expect(result.nodes).not.toContain('module:x'); + }); + it('suggestions view filters for low-confidence edges', async () => { await createEdge(graph, { source: 'task:a', target: 'task:b', type: 'relates-to', confidence: 0.3 }); await createEdge(graph, { source: 'task:c', target: 'task:d', type: 'implements', confidence: 1.0 }); @@ -66,16 +120,213 @@ describe('views', () => { expect(result.nodes).not.toContain('task:c'); }); - it('defineView registers a custom view', async () => { - defineView('custom', (nodes, edges) => ({ - nodes: nodes.filter(n => n.startsWith('x:')), - edges: [], - })); + // ── PRISM: milestone view ───────────────────────────────────── - await createEdge(graph, { source: 'x:foo', target: 'y:bar', type: 'relates-to' }); + describe('milestone view', () => { + it('shows milestones with completion stats', async () => { + await createEdge(graph, { source: 'task:a', target: 'milestone:M1', type: 'belongs-to' }); + await createEdge(graph, { source: 'task:b', target: 'milestone:M1', type: 'belongs-to' }); + // Completion heuristic: a child is "done" if it has an outgoing 'implements' edge + await createEdge(graph, { source: 'task:a', target: 'spec:x', type: 'implements' }); - const result = await renderView(graph, 'custom'); - expect(result.nodes).toEqual(['x:foo']); - expect(result.edges).toEqual([]); + const result = await renderView(graph, 'milestone'); + expect(result.nodes).toContain('milestone:M1'); + expect(result.nodes).toContain('task:a'); + expect(result.nodes).toContain('task:b'); + expect(result.meta.milestoneStats['milestone:M1'].total).toBe(2); + expect(result.meta.milestoneStats['milestone:M1'].done).toBe(1); + expect(result.meta.milestoneStats['milestone:M1'].pct).toBe(50); + }); + + it('reports blockers for a milestone', async () => { + await createEdge(graph, { source: 'task:a', target: 'milestone:M1', type: 'belongs-to' }); + await createEdge(graph, { source: 'task:blocker', target: 'task:a', type: 'blocks' }); + + const result = await renderView(graph, 'milestone'); + expect(result.meta.milestoneStats['milestone:M1'].blockers).toContain('task:blocker'); + }); + + it('includes features in milestone stats', async () => { + await createEdge(graph, { source: 'task:a', target: 'milestone:M1', type: 'belongs-to' }); + await createEdge(graph, { source: 'feature:login', target: 'milestone:M1', type: 'belongs-to' }); + await createEdge(graph, { source: 'feature:login', target: 'spec:auth', type: 'implements' }); + + const result = await renderView(graph, 'milestone'); + expect(result.meta.milestoneStats['milestone:M1'].total).toBe(2); + expect(result.meta.milestoneStats['milestone:M1'].done).toBe(1); + }); + + it('excludes non-task/non-feature children from milestone stats', async () => { + await createEdge(graph, { source: 'task:a', target: 'milestone:M1', type: 'belongs-to' }); + await createEdge(graph, { source: 'spec:loose', target: 'milestone:M1', type: 'belongs-to' }); + + const result = await renderView(graph, 'milestone'); + // spec:loose should not count as a milestone child + expect(result.meta.milestoneStats['milestone:M1'].total).toBe(1); + }); + + it('returns edges as a self-contained subgraph', async () => { + await createEdge(graph, { source: 'task:a', target: 'milestone:M1', type: 'belongs-to' }); + // implements edge targets spec:x which is NOT a milestone/task/feature + await createEdge(graph, { source: 'task:a', target: 'spec:x', type: 'implements' }); + + const result = await renderView(graph, 'milestone'); + const nodeSet = new Set(result.nodes); + for (const e of result.edges) { + expect(nodeSet.has(e.from)).toBe(true); + expect(nodeSet.has(e.to)).toBe(true); + } + }); + + it('handles milestone with no tasks', async () => { + // Create a milestone node by linking it to something + await createEdge(graph, { source: 'milestone:empty', target: 'spec:x', type: 'relates-to' }); + + const result = await renderView(graph, 'milestone'); + expect(result.meta.milestoneStats['milestone:empty'].total).toBe(0); + expect(result.meta.milestoneStats['milestone:empty'].pct).toBe(0); + expect(result.meta.milestoneStats['milestone:empty'].blockers).toEqual([]); + }); + }); + + // ── PRISM: traceability view ────────────────────────────────── + + describe('traceability view', () => { + it('identifies unimplemented specs as gaps', async () => { + await createEdge(graph, { source: 'spec:auth', target: 'doc:readme', type: 'documents' }); + await createEdge(graph, { source: 'spec:session', target: 'doc:readme', type: 'documents' }); + await createEdge(graph, { source: 'file:auth.js', target: 'spec:auth', type: 'implements' }); + + const result = await renderView(graph, 'traceability'); + expect(result.meta.gaps).toContain('spec:session'); + expect(result.meta.gaps).not.toContain('spec:auth'); + expect(result.meta.covered).toContain('spec:auth'); + expect(result.meta.coveragePct).toBe(50); + }); + + it('reports 100% when all specs are implemented', async () => { + await createEdge(graph, { source: 'file:a.js', target: 'spec:auth', type: 'implements' }); + + const result = await renderView(graph, 'traceability'); + expect(result.meta.gaps).toEqual([]); + expect(result.meta.coveragePct).toBe(100); + }); + + it('includes ADRs in traceability', async () => { + await createEdge(graph, { source: 'adr:001', target: 'doc:readme', type: 'documents' }); + + const result = await renderView(graph, 'traceability'); + expect(result.meta.gaps).toContain('adr:001'); + }); + + it('reports 100% coverage when no specs or ADRs exist', async () => { + await createEdge(graph, { source: 'task:a', target: 'task:b', type: 'blocks' }); + + const result = await renderView(graph, 'traceability'); + expect(result.meta.gaps).toEqual([]); + expect(result.meta.coveragePct).toBe(100); + }); + }); + + // ── PRISM: blockers view ────────────────────────────────────── + + describe('blockers view', () => { + it('follows transitive blocking chains', async () => { + await createEdge(graph, { source: 'task:a', target: 'task:b', type: 'blocks' }); + await createEdge(graph, { source: 'task:b', target: 'task:c', type: 'blocks' }); + + const result = await renderView(graph, 'blockers'); + expect(result.nodes).toContain('task:a'); + expect(result.nodes).toContain('task:b'); + expect(result.nodes).toContain('task:c'); + expect(result.meta.rootBlockers).toContain('task:a'); + expect(result.meta.rootBlockers).not.toContain('task:b'); + expect(result.meta.chains.length).toBe(1); + expect(result.meta.chains[0].length).toBe(3); + }); + + it('detects cycles', async () => { + await createEdge(graph, { source: 'task:a', target: 'task:b', type: 'blocks' }); + await createEdge(graph, { source: 'task:b', target: 'task:a', type: 'blocks' }); + + const result = await renderView(graph, 'blockers'); + expect(result.meta.cycles.length).toBeGreaterThan(0); + }); + + it('handles a root blocker leading into a cycle', async () => { + await createEdge(graph, { source: 'task:root', target: 'task:a', type: 'blocks' }); + await createEdge(graph, { source: 'task:a', target: 'task:b', type: 'blocks' }); + await createEdge(graph, { source: 'task:b', target: 'task:a', type: 'blocks' }); + + const result = await renderView(graph, 'blockers'); + expect(result.meta.rootBlockers).toContain('task:root'); + expect(result.meta.cycles.length).toBeGreaterThan(0); + }); + + it('returns empty for graph with no blocks edges', async () => { + await createEdge(graph, { source: 'task:a', target: 'task:b', type: 'relates-to' }); + + const result = await renderView(graph, 'blockers'); + expect(result.nodes).toEqual([]); + expect(result.edges).toEqual([]); + expect(result.meta.chains).toEqual([]); + expect(result.meta.cycles).toEqual([]); + }); + }); + + // ── PRISM: onboarding view ──────────────────────────────────── + + describe('onboarding view', () => { + it('returns topologically sorted reading order for a 3-node chain', async () => { + // spec:a -> spec:b -> spec:c (c depends on b, b depends on a) + await createEdge(graph, { source: 'spec:b', target: 'spec:a', type: 'depends-on' }); + await createEdge(graph, { source: 'spec:c', target: 'spec:b', type: 'depends-on' }); + + const result = await renderView(graph, 'onboarding'); + const order = result.meta.readingOrder; + expect(order.indexOf('spec:a')).toBeLessThan(order.indexOf('spec:b')); + expect(order.indexOf('spec:b')).toBeLessThan(order.indexOf('spec:c')); + }); + + it('includes doc and adr nodes', async () => { + await createEdge(graph, { source: 'doc:guide', target: 'adr:001', type: 'documents' }); + + const result = await renderView(graph, 'onboarding'); + expect(result.nodes).toContain('doc:guide'); + expect(result.nodes).toContain('adr:001'); + }); + + it('handles graphs with no doc nodes', async () => { + await createEdge(graph, { source: 'task:a', target: 'task:b', type: 'blocks' }); + + const result = await renderView(graph, 'onboarding'); + expect(result.nodes).toEqual([]); + expect(result.meta.readingOrder).toEqual([]); + expect(result.meta.hasCycles).toBe(false); + }); + + it('returns edges as a self-contained subgraph', async () => { + await createEdge(graph, { source: 'spec:a', target: 'spec:b', type: 'depends-on' }); + // implements edge from a non-doc node into a doc node + await createEdge(graph, { source: 'file:auth.js', target: 'spec:a', type: 'implements' }); + + const result = await renderView(graph, 'onboarding'); + const nodeSet = new Set(result.nodes); + for (const e of result.edges) { + expect(nodeSet.has(e.from)).toBe(true); + expect(nodeSet.has(e.to)).toBe(true); + } + }); + + it('detects cycles in doc dependencies', async () => { + await createEdge(graph, { source: 'spec:a', target: 'spec:b', type: 'depends-on' }); + await createEdge(graph, { source: 'spec:b', target: 'spec:a', type: 'depends-on' }); + + const result = await renderView(graph, 'onboarding'); + expect(result.meta.hasCycles).toBe(true); + // Both nodes should still appear + expect(result.nodes).toContain('spec:a'); + expect(result.nodes).toContain('spec:b'); + }); }); });