diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12999a11..fd62323e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,15 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest strategy: matrix: - node-version: [20, 22] + node-version: [22, 24] env: GIT_AUTHOR_NAME: CI diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6e1c1a..7ff6920a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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] + +### Added + +- **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` + +### Changed + +- **`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) + ## [2.0.0-alpha.0] - 2026-02-07 Complete rewrite from C23 to Node.js on `@git-stunts/git-warp`. diff --git a/GUIDE.md b/GUIDE.md index 828ee112..c5769753 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -42,7 +42,7 @@ git-mind captures those relationships explicitly, so you can query them, visuali ### Prerequisites -- [Node.js](https://nodejs.org/) >= 20.0.0 +- [Node.js](https://nodejs.org/) >= 22.0.0 - [Git](https://git-scm.com/) ### From source @@ -87,25 +87,25 @@ Let's say your repo has a spec document and an implementation: ```bash # This file implements that spec -npx git-mind link src/auth.js docs/auth-spec.md --type implements +npx git-mind link file:src/auth.js spec:auth --type implements # These two modules are related -npx git-mind link src/cache.js src/db.js --type depends-on +npx git-mind link module:cache module:db --type depends-on # This test documents the expected behavior -npx git-mind link test/auth.test.js src/auth.js --type documents +npx git-mind link file:test/auth.test.js file:src/auth.js --type documents ``` -Each `link` command creates an edge between two nodes. If the nodes don't exist yet, they're created automatically. +Each `link` command creates an edge between two nodes. If the nodes don't exist yet, they're created automatically. Node IDs must use the `prefix:identifier` format. ### 3. See what you've built ```bash npx git-mind list # ℹ 3 edge(s): -# src/auth.js --[implements]--> docs/auth-spec.md (100%) -# src/cache.js --[depends-on]--> src/db.js (100%) -# test/auth.test.js --[documents]--> src/auth.js (100%) +# file:src/auth.js --[implements]--> spec:auth (100%) +# module:cache --[depends-on]--> module:db (100%) +# file:test/auth.test.js --[documents]--> file:src/auth.js (100%) ``` ### 4. Use views for focused projections @@ -128,16 +128,21 @@ Your knowledge graph is stored in Git. It persists across clones (once pushed), ### Nodes -A node is any string identifier. By convention, use prefixes to categorize: +A node ID follows the `prefix:identifier` format. The prefix is always lowercase, and the identifier can contain letters, digits, dots, slashes, `@`, and hyphens. See [GRAPH_SCHEMA.md](GRAPH_SCHEMA.md) for the full grammar. | Prefix | Meaning | Example | |--------|---------|---------| -| (none) | File path | `src/auth.js` | +| `file:` | File path | `file:src/auth.js` | | `module:` | Software module | `module:authentication` | | `task:` | Work item | `task:implement-oauth` | | `phase:` | Project phase | `phase:beta` | -| `commit:` | Git commit | `commit:abc123` | +| `commit:` | Git commit (system-generated) | `commit:abc123` | | `concept:` | Abstract idea | `concept:zero-trust` | +| `milestone:` | Major project phase | `milestone:BEDROCK` | +| `feature:` | Feature grouping | `feature:BDK-SCHEMA` | +| `spec:` | Specification document | `spec:graph-schema` | + +Unknown prefixes produce a warning but are allowed — this lets the taxonomy grow organically. See the full prefix list in [GRAPH_SCHEMA.md](GRAPH_SCHEMA.md). Nodes are created implicitly when you create an edge referencing them. You don't need to declare nodes separately. @@ -197,7 +202,7 @@ Safe to run multiple times — initialization is idempotent. Create a semantic edge between two nodes. ```bash -git mind link src/auth.js docs/auth-spec.md --type implements +git mind link file:src/auth.js spec:auth --type implements git mind link module:a module:b --type depends-on --confidence 0.9 ``` @@ -306,8 +311,8 @@ feat: add OAuth2 login flow Implements the social login spec with Google and GitHub providers. -IMPLEMENTS: docs/social-login-spec.md -AUGMENTS: src/auth/basic.js +IMPLEMENTS: spec:social-login +AUGMENTS: module:auth-basic RELATES-TO: concept:zero-trust ``` @@ -332,7 +337,7 @@ import { processCommit, loadGraph } from '@neuroglyph/git-mind'; const graph = await loadGraph('.'); await processCommit(graph, { sha: 'abc123def456', - message: 'feat: add login\n\nIMPLEMENTS: docs/auth.md', + message: 'feat: add login\n\nIMPLEMENTS: spec:auth', }); ``` @@ -351,6 +356,16 @@ import { queryEdges, removeEdge, EDGE_TYPES, + validateNodeId, + validateEdgeType, + validateConfidence, + validateEdge, + extractPrefix, + classifyPrefix, + NODE_ID_REGEX, + NODE_ID_MAX_LENGTH, + CANONICAL_PREFIXES, + SYSTEM_PREFIXES, defineView, renderView, listViews, @@ -375,10 +390,10 @@ const sha = await saveGraph(graph); ### Edge operations ```javascript -// Create +// Create — node IDs must use prefix:identifier format await createEdge(graph, { - source: 'src/auth.js', - target: 'docs/auth-spec.md', + source: 'file:src/auth.js', + target: 'spec:auth', type: 'implements', confidence: 1.0, rationale: 'Direct implementation of the spec', @@ -386,11 +401,30 @@ await createEdge(graph, { // Query const allEdges = await queryEdges(graph); -const authEdges = await queryEdges(graph, { source: 'src/auth.js' }); +const authEdges = await queryEdges(graph, { source: 'file:src/auth.js' }); const implEdges = await queryEdges(graph, { type: 'implements' }); // Remove -await removeEdge(graph, 'src/auth.js', 'docs/auth-spec.md', 'implements'); +await removeEdge(graph, 'file:src/auth.js', 'spec:auth', 'implements'); +``` + +### Validation + +Validators return result objects — they don't throw. Callers decide how to handle errors. + +```javascript +// Validate a node ID +const r = validateNodeId('task:BDK-001'); // { valid: true } +const bad = validateNodeId('bad id'); // { valid: false, error: '...' } + +// Validate a full edge (composite — checks everything) +const result = validateEdge('task:X', 'feature:Y', 'implements', 0.8); +// { valid: true, errors: [], warnings: [] } + +// Classify a prefix +classifyPrefix('milestone'); // 'canonical' +classifyPrefix('commit'); // 'system' +classifyPrefix('banana'); // 'unknown' ``` ### Views @@ -414,13 +448,13 @@ defineView('unreviewed', (nodes, edges) => ({ ```javascript // Parse directives from a message -const directives = parseDirectives('IMPLEMENTS: docs/spec.md\nBLOCKS: task:deploy'); -// [{ type: 'implements', target: 'docs/spec.md' }, { type: 'blocks', target: 'task:deploy' }] +const directives = parseDirectives('IMPLEMENTS: spec:auth\nBLOCKS: task:deploy'); +// [{ type: 'implements', target: 'spec:auth' }, { type: 'blocks', target: 'task:deploy' }] // Process a full commit (parse + create edges) const processed = await processCommit(graph, { sha: 'abc123', - message: 'feat: login\n\nIMPLEMENTS: docs/auth.md', + message: 'feat: login\n\nIMPLEMENTS: spec:auth', }); ``` @@ -455,9 +489,9 @@ Internally, a patch is a JSON blob committed to Git: ```json { "ops": [ - { "op": "addNode", "id": "src/auth.js" }, - { "op": "addEdge", "from": "src/auth.js", "to": "docs/spec.md", "label": "implements" }, - { "op": "setProp", "id": "src/auth.js", "key": "type", "value": "file" } + { "op": "addNode", "id": "file:src/auth.js" }, + { "op": "addEdge", "from": "file:src/auth.js", "to": "spec:auth", "label": "implements" }, + { "op": "setProp", "id": "file:src/auth.js", "key": "type", "value": "file" } ], "writerId": "local", "tick": 42, @@ -500,11 +534,11 @@ All of this is under `refs/`, not in your working tree. Your `.gitmind/` directo | Type | Direction | Meaning | Example | |------|-----------|---------|---------| -| `implements` | source implements target | Code fulfills a spec | `src/auth.js` implements `docs/auth-spec.md` | -| `augments` | source extends target | Enhancement or extension | `src/oauth.js` augments `src/auth.js` | -| `relates-to` | source relates to target | General association | `README.md` relates-to `docs/philosophy.md` | -| `blocks` | source blocks target | Dependency/ordering | `task:migrate-db` blocks `task:deploy` | -| `belongs-to` | source is part of target | Membership/containment | `src/auth.js` belongs-to `module:security` | -| `consumed-by` | source is consumed by target | Usage relationship | `config.json` consumed-by `src/loader.js` | -| `depends-on` | source depends on target | Dependency | `module:api` depends-on `module:auth` | -| `documents` | source documents target | Documentation | `docs/api.md` documents `src/api/` | +| `implements` | source implements target | Code fulfills a spec | `file:src/auth.js` implements `spec:auth` | +| `augments` | source extends target | Enhancement or extension | `module:auth-oauth` augments `module:auth` | +| `relates-to` | source relates to target | General association | `doc:README` relates-to `concept:philosophy` | +| `blocks` | source blocks target | Dependency/ordering (no self-edges) | `task:migrate-db` blocks `task:deploy` | +| `belongs-to` | source is part of target | Membership/containment | `file:src/auth.js` belongs-to `module:security` | +| `consumed-by` | source is consumed by target | Usage relationship | `pkg:chalk` consumed-by `module:format` | +| `depends-on` | source depends on target | Dependency (no self-edges) | `module:api` depends-on `module:auth` | +| `documents` | source documents target | Documentation | `doc:api` documents `module:api` | diff --git a/package.json b/package.json index 3b56013e..be6c7a25 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,6 @@ "vitest": "^3.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } } diff --git a/src/edges.js b/src/edges.js index 8d2f31bb..94d7a462 100644 --- a/src/edges.js +++ b/src/edges.js @@ -3,17 +3,9 @@ * Edge creation, querying, and removal for git-mind. */ -/** @type {string[]} */ -export const EDGE_TYPES = [ - 'implements', - 'augments', - 'relates-to', - 'blocks', - 'belongs-to', - 'consumed-by', - 'depends-on', - 'documents', -]; +import { validateEdge } from './validators.js'; + +export { EDGE_TYPES } from './validators.js'; /** * @typedef {object} EdgeInput @@ -33,11 +25,12 @@ export const EDGE_TYPES = [ * @returns {Promise} */ export async function createEdge(graph, { source, target, type, confidence = 1.0, rationale }) { - if (!EDGE_TYPES.includes(type)) { - throw new Error(`Unknown edge type: "${type}". Valid types: ${EDGE_TYPES.join(', ')}`); + const result = validateEdge(source, target, type, confidence); + if (!result.valid) { + throw new Error(result.errors.join('; ')); } - if (confidence < 0 || confidence > 1) { - throw new Error(`Confidence must be between 0.0 and 1.0, got ${confidence}`); + for (const warning of result.warnings) { + console.warn(`[git-mind] ${warning}`); } const patch = await graph.createPatch(); diff --git a/src/hooks.js b/src/hooks.js index ad70b263..66270919 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -15,7 +15,7 @@ import { createEdge } from './edges.js'; * Supported directive patterns in commit messages. * Format: `DIRECTIVE: ` */ -const DIRECTIVE_PATTERN = /^(IMPLEMENTS|AUGMENTS|RELATES-TO|BLOCKS|DEPENDS-ON|DOCUMENTS):\s*(.+)$/gmi; +const DIRECTIVE_PATTERN = /^(IMPLEMENTS|AUGMENTS|RELATES-TO|BLOCKS|DEPENDS-ON|DOCUMENTS):\s*(\S.*)$/gmi; /** * Parse directives from a commit message. diff --git a/src/index.js b/src/index.js index 96227d9a..ac18eafa 100644 --- a/src/index.js +++ b/src/index.js @@ -5,5 +5,10 @@ export { initGraph, loadGraph, saveGraph } from './graph.js'; export { createEdge, queryEdges, removeEdge, EDGE_TYPES } from './edges.js'; +export { + validateNodeId, validateEdgeType, validateConfidence, validateEdge, + extractPrefix, classifyPrefix, + NODE_ID_REGEX, NODE_ID_MAX_LENGTH, CANONICAL_PREFIXES, SYSTEM_PREFIXES, ALL_PREFIXES, +} from './validators.js'; export { defineView, renderView, listViews } from './views.js'; export { parseDirectives, processCommit } from './hooks.js'; diff --git a/src/validators.js b/src/validators.js new file mode 100644 index 00000000..c098670c --- /dev/null +++ b/src/validators.js @@ -0,0 +1,175 @@ +/** + * @module validators + * Runtime schema validators for git-mind's knowledge graph. + * Implements constraints from GRAPH_SCHEMA.md (BDK-001). + */ + +// ── Constants ──────────────────────────────────────────────────────── + +/** @type {RegExp} Canonical regex for node IDs (prefix:identifier) */ +export const NODE_ID_REGEX = /^[a-z][a-z0-9-]*:[A-Za-z0-9._\/@-]+$/; + +/** @type {number} Maximum total length of a node ID */ +export const NODE_ID_MAX_LENGTH = 256; + +/** @type {string[]} User-facing canonical prefixes (excludes system prefixes) */ +export const CANONICAL_PREFIXES = [ + 'milestone', 'feature', 'task', 'issue', 'phase', + 'spec', 'adr', 'doc', 'concept', 'decision', + 'crate', 'module', 'pkg', 'file', + 'person', 'tool', + 'event', 'metric', +]; + +/** @type {string[]} System-generated prefixes (reserved, not user-writable) */ +export const SYSTEM_PREFIXES = ['commit']; + +/** @type {string[]} Valid edge types */ +export const EDGE_TYPES = [ + 'implements', + 'augments', + 'relates-to', + 'blocks', + 'belongs-to', + 'consumed-by', + 'depends-on', + 'documents', +]; + +/** @type {string[]} Edge types that forbid self-edges */ +const SELF_EDGE_FORBIDDEN = ['blocks', 'depends-on']; + +// ── Functions ──────────────────────────────────────────────────────── + +/** + * Extract the prefix portion of a node ID (before the first colon). + * + * @param {string} nodeId + * @returns {string|null} Prefix string, or null if no colon present + */ +export function extractPrefix(nodeId) { + if (typeof nodeId !== 'string') return null; + const idx = nodeId.indexOf(':'); + if (idx === -1) return null; + return nodeId.slice(0, idx); +} + +/** + * Validate a node ID against the schema grammar. + * + * @param {string} nodeId + * @returns {{ valid: boolean, error?: string }} + */ +export function validateNodeId(nodeId) { + if (!nodeId || typeof nodeId !== 'string') { + return { valid: false, error: 'Node ID must be a non-empty string' }; + } + if (nodeId.length > NODE_ID_MAX_LENGTH) { + return { valid: false, error: `Node ID exceeds max length of ${NODE_ID_MAX_LENGTH} characters (got ${nodeId.length})` }; + } + if (!NODE_ID_REGEX.test(nodeId)) { + return { valid: false, error: `Invalid node ID: "${nodeId}". Must match prefix:identifier (lowercase prefix, valid identifier chars)` }; + } + return { valid: true }; +} + +/** + * Classify a prefix string. + * + * @param {string} prefix + * @returns {'canonical'|'system'|'unknown'} + */ +export function classifyPrefix(prefix) { + if (SYSTEM_PREFIXES.includes(prefix)) return 'system'; + if (CANONICAL_PREFIXES.includes(prefix)) return 'canonical'; + return 'unknown'; +} + +/** @type {string[]} All known prefixes (canonical + system) */ +export const ALL_PREFIXES = [...CANONICAL_PREFIXES, ...SYSTEM_PREFIXES]; + +/** + * Validate an edge type against the known set. + * + * @param {string} type + * @returns {{ valid: boolean, error?: string }} + */ +export function validateEdgeType(type) { + if (!EDGE_TYPES.includes(type)) { + return { valid: false, error: `Unknown edge type: "${type}". Valid types: ${EDGE_TYPES.join(', ')}` }; + } + return { valid: true }; +} + +/** + * Validate a confidence value. + * + * @param {*} value + * @returns {{ valid: boolean, error?: string }} + */ +export function validateConfidence(value) { + if (typeof value !== 'number') { + return { valid: false, error: `Confidence must be a number, got ${typeof value}` }; + } + if (!Number.isFinite(value)) { + return { valid: false, error: `Confidence must be a finite number, got ${value}` }; + } + if (value < 0 || value > 1) { + return { valid: false, error: `Confidence must be between 0.0 and 1.0, got ${value}` }; + } + return { valid: true }; +} + +/** + * Composite validator for a full edge. + * Validates source/target IDs, edge type, confidence, and self-edge constraint. + * + * @param {string} source - Source node ID + * @param {string} target - Target node ID + * @param {string} type - Edge type + * @param {number} [confidence] - Optional confidence value + * @returns {{ valid: boolean, errors: string[], warnings: string[] }} + */ +export function validateEdge(source, target, type, confidence) { + const errors = []; + const warnings = []; + + // Validate source node ID + const srcResult = validateNodeId(source); + if (!srcResult.valid) errors.push(`Source: ${srcResult.error}`); + + // Validate target node ID + const tgtResult = validateNodeId(target); + if (!tgtResult.valid) errors.push(`Target: ${tgtResult.error}`); + + // Validate edge type + const typeResult = validateEdgeType(type); + if (!typeResult.valid) errors.push(typeResult.error); + + // Validate confidence if provided + if (confidence !== undefined) { + const confResult = validateConfidence(confidence); + if (!confResult.valid) errors.push(confResult.error); + } + + // Self-edge check (only when both IDs are structurally valid) + if (srcResult.valid && tgtResult.valid && source === target && SELF_EDGE_FORBIDDEN.includes(type)) { + errors.push(`Self-edge forbidden for "${type}": source and target are both "${source}"`); + } + + // Unknown prefix warnings (only if IDs are structurally valid) + if (srcResult.valid) { + const srcPrefix = extractPrefix(source); + if (srcPrefix && classifyPrefix(srcPrefix) === 'unknown') { + warnings.push(`Source prefix "${srcPrefix}" is not a canonical prefix`); + } + } + if (tgtResult.valid) { + const tgtPrefix = extractPrefix(target); + if (tgtPrefix && classifyPrefix(tgtPrefix) === 'unknown') { + warnings.push(`Target prefix "${tgtPrefix}" is not a canonical prefix`); + } + } + + return { valid: errors.length === 0, errors, warnings }; +} diff --git a/test/edges.test.js b/test/edges.test.js index faf3d2b2..308d3e70 100644 --- a/test/edges.test.js +++ b/test/edges.test.js @@ -30,22 +30,22 @@ describe('edges', () => { it('createEdge creates an edge and both nodes', async () => { await createEdge(graph, { - source: 'src/auth.js', - target: 'docs/auth-spec.md', + source: 'file:src/auth.js', + target: 'spec:auth-spec', type: 'implements', }); const edges = await queryEdges(graph); expect(edges.length).toBe(1); - expect(edges[0].from).toBe('src/auth.js'); - expect(edges[0].to).toBe('docs/auth-spec.md'); + expect(edges[0].from).toBe('file:src/auth.js'); + expect(edges[0].to).toBe('spec:auth-spec'); expect(edges[0].label).toBe('implements'); }); it('createEdge sets confidence and rationale', async () => { await createEdge(graph, { - source: 'a', - target: 'b', + source: 'task:a', + target: 'task:b', type: 'relates-to', confidence: 0.7, rationale: 'test rationale', @@ -58,28 +58,46 @@ describe('edges', () => { it('createEdge rejects unknown edge types', async () => { await expect( - createEdge(graph, { source: 'a', target: 'b', type: 'invalid-type' }) + createEdge(graph, { source: 'task:a', target: 'task:b', type: 'invalid-type' }) ).rejects.toThrow(/Unknown edge type/); }); it('createEdge rejects invalid confidence', async () => { await expect( - createEdge(graph, { source: 'a', target: 'b', type: 'relates-to', confidence: 1.5 }) - ).rejects.toThrow(/Confidence must be between/); + createEdge(graph, { source: 'task:a', target: 'task:b', type: 'relates-to', confidence: 1.5 }) + ).rejects.toThrow(/between 0\.0 and 1\.0/); + }); + + it('createEdge rejects invalid node IDs', async () => { + await expect( + createEdge(graph, { source: 'bad id', target: 'task:b', type: 'relates-to' }) + ).rejects.toThrow(/Invalid node ID/); + }); + + it('createEdge rejects self-edge for blocks', async () => { + await expect( + createEdge(graph, { source: 'task:x', target: 'task:x', type: 'blocks' }) + ).rejects.toThrow(/self-edge/i); + }); + + it('createEdge rejects non-number confidence', async () => { + await expect( + createEdge(graph, { source: 'task:a', target: 'task:b', type: 'relates-to', confidence: '0.9' }) + ).rejects.toThrow(/must be a number/); }); it('queryEdges filters by source', async () => { - await createEdge(graph, { source: 'a', target: 'b', type: 'relates-to' }); - await createEdge(graph, { source: 'c', target: 'd', type: 'implements' }); + await createEdge(graph, { source: 'task:a', target: 'task:b', type: 'relates-to' }); + await createEdge(graph, { source: 'task:c', target: 'task:d', type: 'implements' }); - const filtered = await queryEdges(graph, { source: 'a' }); + const filtered = await queryEdges(graph, { source: 'task:a' }); expect(filtered.length).toBe(1); - expect(filtered[0].from).toBe('a'); + expect(filtered[0].from).toBe('task:a'); }); it('queryEdges filters by type', async () => { - await createEdge(graph, { source: 'a', target: 'b', type: 'relates-to' }); - await createEdge(graph, { source: 'c', target: 'd', type: 'implements' }); + await createEdge(graph, { source: 'task:a', target: 'task:b', type: 'relates-to' }); + await createEdge(graph, { source: 'task:c', target: 'task:d', type: 'implements' }); const filtered = await queryEdges(graph, { type: 'implements' }); expect(filtered.length).toBe(1); @@ -87,10 +105,10 @@ describe('edges', () => { }); it('removeEdge removes an edge', async () => { - await createEdge(graph, { source: 'a', target: 'b', type: 'relates-to' }); + await createEdge(graph, { source: 'task:a', target: 'task:b', type: 'relates-to' }); expect((await queryEdges(graph)).length).toBe(1); - await removeEdge(graph, 'a', 'b', 'relates-to'); + await removeEdge(graph, 'task:a', 'task:b', 'relates-to'); expect((await queryEdges(graph)).length).toBe(0); }); }); diff --git a/test/hooks.test.js b/test/hooks.test.js index 1bd98863..9e3a9239 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -10,28 +10,28 @@ import { parseDirectives, processCommit } from '../src/hooks.js'; describe('hooks', () => { describe('parseDirectives', () => { it('parses IMPLEMENTS directive', () => { - const result = parseDirectives('fix auth flow\n\nIMPLEMENTS: docs/auth-spec.md'); + const result = parseDirectives('fix auth flow\n\nIMPLEMENTS: spec:auth'); expect(result).toEqual([ - { type: 'implements', target: 'docs/auth-spec.md' }, + { type: 'implements', target: 'spec:auth' }, ]); }); it('parses multiple directives', () => { const msg = `refactor auth module -IMPLEMENTS: docs/auth-spec.md -AUGMENTS: docs/security.md -RELATES-TO: src/session.js`; +IMPLEMENTS: spec:auth +AUGMENTS: module:security +RELATES-TO: module:session`; const result = parseDirectives(msg); expect(result.length).toBe(3); - expect(result[0]).toEqual({ type: 'implements', target: 'docs/auth-spec.md' }); - expect(result[1]).toEqual({ type: 'augments', target: 'docs/security.md' }); - expect(result[2]).toEqual({ type: 'relates-to', target: 'src/session.js' }); + expect(result[0]).toEqual({ type: 'implements', target: 'spec:auth' }); + expect(result[1]).toEqual({ type: 'augments', target: 'module:security' }); + expect(result[2]).toEqual({ type: 'relates-to', target: 'module:session' }); }); it('is case-insensitive for directives', () => { - const result = parseDirectives('implements: foo.md'); + const result = parseDirectives('implements: spec:foo'); expect(result.length).toBe(1); expect(result[0].type).toBe('implements'); }); @@ -49,9 +49,9 @@ RELATES-TO: src/session.js`; }); it('handles DOCUMENTS directive', () => { - const result = parseDirectives('DOCUMENTS: api/endpoints.md'); + const result = parseDirectives('DOCUMENTS: doc:api-endpoints'); expect(result).toEqual([ - { type: 'documents', target: 'api/endpoints.md' }, + { type: 'documents', target: 'doc:api-endpoints' }, ]); }); }); @@ -73,7 +73,7 @@ RELATES-TO: src/session.js`; it('creates edges from commit directives', async () => { const directives = await processCommit(graph, { sha: 'abc123def456', - message: 'add login\n\nIMPLEMENTS: docs/auth.md', + message: 'add login\n\nIMPLEMENTS: spec:auth', }); expect(directives.length).toBe(1); @@ -81,7 +81,7 @@ RELATES-TO: src/session.js`; const edges = await queryEdges(graph); expect(edges.length).toBe(1); expect(edges[0].from).toBe('commit:abc123def456'); - expect(edges[0].to).toBe('docs/auth.md'); + expect(edges[0].to).toBe('spec:auth'); expect(edges[0].label).toBe('implements'); expect(edges[0].props.confidence).toBe(0.8); }); diff --git a/test/validators.test.js b/test/validators.test.js new file mode 100644 index 00000000..19d1b6ee --- /dev/null +++ b/test/validators.test.js @@ -0,0 +1,285 @@ +import { describe, it, expect } from 'vitest'; +import { + extractPrefix, + validateNodeId, + classifyPrefix, + validateEdgeType, + validateConfidence, + validateEdge, + NODE_ID_REGEX, + NODE_ID_MAX_LENGTH, + CANONICAL_PREFIXES, + SYSTEM_PREFIXES, + EDGE_TYPES, + ALL_PREFIXES, +} from '../src/validators.js'; + +describe('extractPrefix', () => { + it('extracts prefix from a valid node ID', () => { + expect(extractPrefix('milestone:BEDROCK')).toBe('milestone'); + }); + + it('returns null when no colon is present', () => { + expect(extractPrefix('noprefix')).toBeNull(); + }); + + it('extracts prefix from ID with multiple colons', () => { + // Only first colon matters + expect(extractPrefix('task:some:thing')).toBe('task'); + }); + + it('returns null for non-string input', () => { + expect(extractPrefix(null)).toBeNull(); + expect(extractPrefix(undefined)).toBeNull(); + expect(extractPrefix(42)).toBeNull(); + }); +}); + +describe('validateNodeId', () => { + it('accepts a valid node ID', () => { + expect(validateNodeId('task:BDK-001')).toEqual({ valid: true }); + }); + + it('accepts IDs with dots, slashes, and @', () => { + expect(validateNodeId('file:src/auth.js')).toEqual({ valid: true }); + expect(validateNodeId('pkg:@scope/name')).toEqual({ valid: true }); + }); + + it('rejects empty string', () => { + const r = validateNodeId(''); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/non-empty/); + }); + + it('rejects prefix-only (no identifier)', () => { + const r = validateNodeId('milestone:'); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/Invalid node ID/); + }); + + it('rejects colon-only', () => { + const r = validateNodeId(':'); + expect(r.valid).toBe(false); + }); + + it('rejects missing prefix (starts with colon)', () => { + const r = validateNodeId(':foo'); + expect(r.valid).toBe(false); + }); + + it('rejects uppercase prefix', () => { + const r = validateNodeId('Milestone:BEDROCK'); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/Invalid node ID/); + }); + + it('rejects whitespace in ID', () => { + const r = validateNodeId('task:my thing'); + expect(r.valid).toBe(false); + }); + + it('accepts ID at exactly max length', () => { + const prefix = 'task:'; + const id = prefix + 'a'.repeat(NODE_ID_MAX_LENGTH - prefix.length); + expect(id.length).toBe(NODE_ID_MAX_LENGTH); + expect(validateNodeId(id).valid).toBe(true); + }); + + it('rejects ID exceeding max length', () => { + const prefix = 'task:'; + const id = prefix + 'a'.repeat(NODE_ID_MAX_LENGTH - prefix.length + 1); + expect(id.length).toBe(NODE_ID_MAX_LENGTH + 1); + const r = validateNodeId(id); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/max length/); + }); + + it('rejects non-string input', () => { + expect(validateNodeId(null).valid).toBe(false); + expect(validateNodeId(undefined).valid).toBe(false); + expect(validateNodeId(42).valid).toBe(false); + }); + + it('rejects bare identifier without prefix', () => { + const r = validateNodeId('noprefix'); + expect(r.valid).toBe(false); + }); +}); + +describe('classifyPrefix', () => { + it('classifies canonical prefixes', () => { + expect(classifyPrefix('milestone')).toBe('canonical'); + expect(classifyPrefix('task')).toBe('canonical'); + expect(classifyPrefix('file')).toBe('canonical'); + }); + + it('classifies system prefixes', () => { + expect(classifyPrefix('commit')).toBe('system'); + }); + + it('classifies commit as system (not canonical) due to precedence', () => { + // SYSTEM_PREFIXES is checked before CANONICAL_PREFIXES in classifyPrefix, + // and commit is only in SYSTEM_PREFIXES — not in CANONICAL_PREFIXES + expect(classifyPrefix('commit')).toBe('system'); + expect(SYSTEM_PREFIXES).toContain('commit'); + expect(CANONICAL_PREFIXES).not.toContain('commit'); + }); + + it('classifies unknown prefixes', () => { + expect(classifyPrefix('banana')).toBe('unknown'); + expect(classifyPrefix('custom')).toBe('unknown'); + }); +}); + +describe('validateEdgeType', () => { + it('accepts all valid edge types', () => { + for (const type of EDGE_TYPES) { + expect(validateEdgeType(type)).toEqual({ valid: true }); + } + }); + + it('rejects unknown edge types', () => { + const r = validateEdgeType('explodes'); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/Unknown edge type/); + }); +}); + +describe('validateConfidence', () => { + it('accepts 0.0', () => { + expect(validateConfidence(0.0)).toEqual({ valid: true }); + }); + + it('accepts 0.5', () => { + expect(validateConfidence(0.5)).toEqual({ valid: true }); + }); + + it('accepts 1.0', () => { + expect(validateConfidence(1.0)).toEqual({ valid: true }); + }); + + it('rejects 1.5 (out of range)', () => { + const r = validateConfidence(1.5); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/between 0\.0 and 1\.0/); + }); + + it('rejects -0.1 (out of range)', () => { + const r = validateConfidence(-0.1); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/between 0\.0 and 1\.0/); + }); + + it('rejects NaN', () => { + const r = validateConfidence(NaN); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/finite number/); + }); + + it('rejects Infinity', () => { + const r = validateConfidence(Infinity); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/finite number/); + }); + + it('rejects string "0.9"', () => { + const r = validateConfidence('0.9'); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/must be a number/); + }); + + it('rejects null', () => { + const r = validateConfidence(null); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/must be a number/); + }); + + it('rejects undefined', () => { + const r = validateConfidence(undefined); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/must be a number/); + }); +}); + +describe('validateEdge', () => { + it('passes for a valid edge', () => { + const r = validateEdge('task:BDK-001', 'feature:BDK-SCHEMA', 'implements', 1.0); + expect(r.valid).toBe(true); + expect(r.errors).toEqual([]); + expect(r.warnings).toEqual([]); + }); + + it('passes without confidence (optional)', () => { + const r = validateEdge('task:BDK-001', 'feature:BDK-SCHEMA', 'implements'); + expect(r.valid).toBe(true); + }); + + it('rejects self-edge for blocks', () => { + const r = validateEdge('task:X', 'task:X', 'blocks'); + expect(r.valid).toBe(false); + expect(r.errors.some(e => /self-edge/i.test(e))).toBe(true); + }); + + it('rejects self-edge for depends-on', () => { + const r = validateEdge('task:X', 'task:X', 'depends-on'); + expect(r.valid).toBe(false); + expect(r.errors.some(e => /self-edge/i.test(e))).toBe(true); + }); + + it('allows self-edge for relates-to', () => { + const r = validateEdge('task:X', 'task:X', 'relates-to'); + expect(r.valid).toBe(true); + }); + + it('warns on unknown prefix', () => { + const r = validateEdge('banana:X', 'task:Y', 'relates-to'); + expect(r.valid).toBe(true); + expect(r.warnings.length).toBe(1); + expect(r.warnings[0]).toMatch(/banana/); + }); + + it('collects multiple errors', () => { + const r = validateEdge('', '', 'explodes', 'bad'); + expect(r.valid).toBe(false); + // 2 invalid IDs + unknown edge type + bad confidence = 4 errors + // Self-edge check is skipped because IDs are invalid + expect(r.errors.length).toBe(4); + }); + + it('skips self-edge check when IDs are already invalid', () => { + const r = validateEdge('bad', 'bad', 'blocks'); + expect(r.valid).toBe(false); + // Should report invalid IDs but NOT a redundant self-edge error + expect(r.errors.some(e => /self-edge/i.test(e))).toBe(false); + }); +}); + +describe('constants', () => { + it('NODE_ID_REGEX is a RegExp', () => { + expect(NODE_ID_REGEX).toBeInstanceOf(RegExp); + }); + + it('NODE_ID_MAX_LENGTH is 256', () => { + expect(NODE_ID_MAX_LENGTH).toBe(256); + }); + + it('CANONICAL_PREFIXES has 18 user-facing entries', () => { + expect(CANONICAL_PREFIXES.length).toBe(18); + expect(CANONICAL_PREFIXES).toContain('milestone'); + expect(CANONICAL_PREFIXES).not.toContain('commit'); + }); + + it('SYSTEM_PREFIXES contains commit', () => { + expect(SYSTEM_PREFIXES).toEqual(['commit']); + }); + + it('ALL_PREFIXES is the union of canonical and system', () => { + expect(ALL_PREFIXES.length).toBe(CANONICAL_PREFIXES.length + SYSTEM_PREFIXES.length); + expect(ALL_PREFIXES).toContain('milestone'); + expect(ALL_PREFIXES).toContain('commit'); + }); + + it('EDGE_TYPES has 8 entries', () => { + expect(EDGE_TYPES.length).toBe(8); + }); +}); diff --git a/test/views.test.js b/test/views.test.js index f3f41059..e174e5fa 100644 --- a/test/views.test.js +++ b/test/views.test.js @@ -35,12 +35,12 @@ describe('views', () => { 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: 'src/main.js', target: 'docs/readme.md', type: 'documents' }); + await createEdge(graph, { source: 'file:src/main.js', target: 'doc:readme', type: 'documents' }); const result = await renderView(graph, 'roadmap'); expect(result.nodes).toContain('phase:alpha'); expect(result.nodes).toContain('task:build-cli'); - expect(result.nodes).not.toContain('src/main.js'); + expect(result.nodes).not.toContain('file:src/main.js'); }); it('architecture view filters for module nodes and depends-on edges', async () => { @@ -55,15 +55,15 @@ describe('views', () => { }); it('suggestions view filters for low-confidence edges', async () => { - await createEdge(graph, { source: 'a', target: 'b', type: 'relates-to', confidence: 0.3 }); - await createEdge(graph, { source: 'c', target: 'd', type: 'implements', confidence: 1.0 }); + 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 }); const result = await renderView(graph, 'suggestions'); expect(result.edges.length).toBe(1); - expect(result.edges[0].from).toBe('a'); - expect(result.nodes).toContain('a'); - expect(result.nodes).toContain('b'); - expect(result.nodes).not.toContain('c'); + expect(result.edges[0].from).toBe('task:a'); + expect(result.nodes).toContain('task:a'); + expect(result.nodes).toContain('task:b'); + expect(result.nodes).not.toContain('task:c'); }); it('defineView registers a custom view', async () => { diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 00000000..3381f934 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + poolOptions: { + forks: { + execArgv: ['--disable-warning=DEP0169'], + }, + }, + }, +});