From 05c051d982893a4abf092ec3d545dbc9c719f6e1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 10 Feb 2026 21:09:52 -0800 Subject: [PATCH 1/5] feat(import): add YAML import pipeline with schema validation (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements INTAKE milestone — schema-validated YAML import with version checking, reference validation (no dangling edges), idempotent merge, atomic writes, node properties, and dry-run mode. Adds js-yaml as direct dependency. 22 new tests (117 total). --- CHANGELOG.md | 6 +- bin/git-mind.js | 19 ++- package-lock.json | 7 +- package.json | 3 +- src/cli/commands.js | 29 +++- src/cli/format.js | 35 ++++ src/import.js | 239 +++++++++++++++++++++++++++ src/index.js | 1 + test/import.test.js | 388 ++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 719 insertions(+), 8 deletions(-) create mode 100644 src/import.js create mode 100644 test/import.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e29477..c9575fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Node formatting** — `formatNode()` and `formatNodeList()` in `src/cli/format.js` for terminal display - **`git mind status` command** — Graph health dashboard showing node counts by prefix, edge counts by type, blocked items, low-confidence edges, and orphan nodes. Supports `--json` for CI pipelines - **Status computation API** — `computeStatus(graph)` in `src/status.js` returns structured summary of graph state +- **YAML import pipeline** — `git mind import ` with schema-validated ingestion (`version: 1` required), idempotent merge semantics, reference validation (no dangling edges), and atomic writes (all-or-nothing) +- **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()` - **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` @@ -22,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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** — 95 tests across 7 files (was 74) +- **Test count** — 117 tests across 8 files (was 74) ## [2.0.0-alpha.0] - 2026-02-07 diff --git a/bin/git-mind.js b/bin/git-mind.js index 2ca41d50..93871ff7 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, installHooks, processCommitCmd, suggest, review } from '../src/cli/commands.js'; +import { init, link, view, list, remove, nodes, status, importCmd, installHooks, processCommitCmd, suggest, review } from '../src/cli/commands.js'; const args = process.argv.slice(2); const command = args[0]; @@ -32,6 +32,9 @@ Commands: --json Output as JSON status Show graph health dashboard --json Output as JSON + import Import a YAML graph file + --dry-run Validate without writing + --json Output as JSON install-hooks Install post-commit Git hook suggest --ai AI suggestions (stub) review Review edges (stub) @@ -120,6 +123,20 @@ switch (command) { await status(cwd, { json: args.includes('--json') }); break; + case 'import': { + const importPath = args[1]; + if (!importPath) { + console.error('Usage: git mind import [--dry-run] [--json]'); + process.exitCode = 1; + break; + } + await importCmd(cwd, importPath, { + dryRun: args.includes('--dry-run') || args.includes('--validate'), + json: args.includes('--json'), + }); + break; + } + case 'install-hooks': await installHooks(cwd); break; diff --git a/package-lock.json b/package-lock.json index 5a8ca440..3b239f15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@git-stunts/git-warp": "^10.3.2", "@git-stunts/plumbing": "^2.8.0", "chalk": "^5.3.0", - "figures": "^6.0.1" + "figures": "^6.0.1", + "js-yaml": "^4.1.1" }, "bin": { "git-mind": "bin/git-mind.js" @@ -23,7 +24,7 @@ "vitest": "^3.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { @@ -1550,7 +1551,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/assertion-error": { @@ -2638,7 +2638,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index be6c7a25..4a42d22d 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "@git-stunts/git-warp": "^10.3.2", "@git-stunts/plumbing": "^2.8.0", "chalk": "^5.3.0", - "figures": "^6.0.1" + "figures": "^6.0.1", + "js-yaml": "^4.1.1" }, "devDependencies": { "eslint": "^9.0.0", diff --git a/src/cli/commands.js b/src/cli/commands.js index 717bd713..9647ab6d 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -10,9 +10,10 @@ import { initGraph, loadGraph } from '../graph.js'; import { createEdge, queryEdges, removeEdge, EDGE_TYPES } from '../edges.js'; import { getNodes, hasNode, getNode, getNodesByPrefix } from '../nodes.js'; 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, formatEdge, formatView, formatNode, formatNodeList, formatStatus } from './format.js'; +import { success, error, info, warning, formatEdge, formatView, formatNode, formatNodeList, formatStatus, formatImportResult } from './format.js'; /** * Initialize a git-mind graph in the current repo. @@ -249,6 +250,32 @@ export async function status(cwd, opts = {}) { } } +/** + * Import a YAML file into the graph. + * @param {string} cwd + * @param {string} filePath + * @param {{ dryRun?: boolean, json?: boolean }} opts + */ +export async function importCmd(cwd, filePath, opts = {}) { + try { + const graph = await loadGraph(cwd); + const result = await importFile(graph, filePath, { dryRun: opts.dryRun }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatImportResult(result)); + } + + if (!result.valid) { + process.exitCode = 1; + } + } catch (err) { + console.error(error(err.message)); + process.exitCode = 1; + } +} + /** * Stub: AI suggestions. */ diff --git a/src/cli/format.js b/src/cli/format.js index 2909aa86..6e3e2271 100644 --- a/src/cli/format.js +++ b/src/cli/format.js @@ -179,3 +179,38 @@ export function formatStatus(status) { return lines.join('\n'); } + +/** + * Format an import result for terminal display. + * @param {import('../import.js').ImportResult} result + * @returns {string} + */ +export function formatImportResult(result) { + const lines = []; + + if (result.dryRun) { + lines.push(chalk.bold('Import dry run')); + } else { + lines.push(chalk.bold('Import')); + } + + if (!result.valid) { + lines.push(`${chalk.red(figures.cross)} Validation failed`); + for (const err of result.errors) { + lines.push(` ${chalk.red(figures.cross)} ${err}`); + } + } else { + if (result.dryRun) { + lines.push(`${chalk.green(figures.tick)} Validation passed`); + lines.push(` Would import: ${result.stats.nodes} node(s), ${result.stats.edges} edge(s)`); + } else { + lines.push(`${chalk.green(figures.tick)} Imported ${result.stats.nodes} node(s), ${result.stats.edges} edge(s)`); + } + } + + for (const w of result.warnings) { + lines.push(` ${chalk.yellow(figures.warning)} ${w}`); + } + + return lines.join('\n'); +} diff --git a/src/import.js b/src/import.js new file mode 100644 index 00000000..590de73c --- /dev/null +++ b/src/import.js @@ -0,0 +1,239 @@ +/** + * @module import + * YAML import pipeline for git-mind. + * Schema-validated, idempotent, atomic graph ingestion. + */ + +import { readFile } from 'node:fs/promises'; +import yaml from 'js-yaml'; +import { validateNodeId, validateEdgeType, validateConfidence, extractPrefix, classifyPrefix } from './validators.js'; + +const SUPPORTED_VERSIONS = [1]; + +/** + * @typedef {object} ImportResult + * @property {boolean} valid - Whether the import data passed all validation + * @property {string[]} errors - Validation errors (import aborted if non-empty) + * @property {string[]} warnings - Non-fatal warnings + * @property {{ nodes: number, edges: number }} stats - Count of items that were (or would be) written + * @property {boolean} dryRun - Whether this was a dry-run (no writes) + */ + +/** + * Parse and validate a YAML import file. + * + * @param {string} filePath - Path to the YAML file + * @returns {Promise<{ data: object|null, parseError: string|null }>} + */ +export async function parseImportFile(filePath) { + try { + const raw = await readFile(filePath, 'utf-8'); + const data = yaml.load(raw); + if (data === null || data === undefined || typeof data !== 'object') { + return { data: null, parseError: 'YAML file is empty or not an object' }; + } + return { data, parseError: null }; + } catch (err) { + if (err.code === 'ENOENT') { + return { data: null, parseError: `File not found: ${filePath}` }; + } + return { data: null, parseError: `YAML parse error: ${err.message}` }; + } +} + +/** + * Validate import data against the schema. + * + * @param {object} data - Parsed YAML data + * @param {import('@git-stunts/git-warp').default} graph - Graph for reference checks + * @returns {Promise<{ valid: boolean, errors: string[], warnings: string[], nodeIds: Set }>} + */ +export async function validateImportData(data, graph) { + const errors = []; + const warnings = []; + + // Version check + if (data.version === undefined || data.version === null) { + errors.push('Missing required field: "version"'); + return { valid: false, errors, warnings, nodeIds: new Set() }; + } + if (!SUPPORTED_VERSIONS.includes(data.version)) { + errors.push(`Unsupported version: ${data.version}. Supported: ${SUPPORTED_VERSIONS.join(', ')}`); + return { valid: false, errors, warnings, nodeIds: new Set() }; + } + + // Collect all declared node IDs (from nodes array + edge endpoints) + const declaredNodeIds = new Set(); + + // Validate nodes + const nodeEntries = data.nodes ?? []; + if (!Array.isArray(nodeEntries)) { + errors.push('"nodes" must be an array'); + } else { + for (let i = 0; i < nodeEntries.length; i++) { + const node = nodeEntries[i]; + if (!node || typeof node !== 'object') { + errors.push(`nodes[${i}]: must be an object`); + continue; + } + if (!node.id) { + errors.push(`nodes[${i}]: missing required field "id"`); + continue; + } + const v = validateNodeId(node.id); + if (!v.valid) { + errors.push(`nodes[${i}]: ${v.error}`); + continue; + } + // Prefix warning + const prefix = extractPrefix(node.id); + if (prefix && classifyPrefix(prefix) === 'unknown') { + warnings.push(`nodes[${i}]: prefix "${prefix}" is not a canonical prefix`); + } + declaredNodeIds.add(node.id); + } + } + + // Validate edges + const edgeEntries = data.edges ?? []; + if (!Array.isArray(edgeEntries)) { + errors.push('"edges" must be an array'); + } else { + // Get existing nodes for reference validation + const existingNodes = new Set(await graph.getNodes()); + + for (let i = 0; i < edgeEntries.length; i++) { + const edge = edgeEntries[i]; + if (!edge || typeof edge !== 'object') { + errors.push(`edges[${i}]: must be an object`); + continue; + } + + // Required fields + if (!edge.source) errors.push(`edges[${i}]: missing required field "source"`); + if (!edge.target) errors.push(`edges[${i}]: missing required field "target"`); + if (!edge.type) errors.push(`edges[${i}]: missing required field "type"`); + if (!edge.source || !edge.target || !edge.type) continue; + + // Validate source/target node IDs + const sv = validateNodeId(edge.source); + if (!sv.valid) errors.push(`edges[${i}].source: ${sv.error}`); + + const tv = validateNodeId(edge.target); + if (!tv.valid) errors.push(`edges[${i}].target: ${tv.error}`); + + // Validate edge type + const et = validateEdgeType(edge.type); + if (!et.valid) errors.push(`edges[${i}].type: ${et.error}`); + + // Validate confidence if provided + if (edge.confidence !== undefined) { + const cv = validateConfidence(edge.confidence); + if (!cv.valid) errors.push(`edges[${i}].confidence: ${cv.error}`); + } + + // Self-edge check for blocks/depends-on + if (edge.source === edge.target && ['blocks', 'depends-on'].includes(edge.type)) { + errors.push(`edges[${i}]: self-edge forbidden for "${edge.type}"`); + } + + // Reference validation: source and target must be declared or pre-existing + if (sv.valid && !declaredNodeIds.has(edge.source) && !existingNodes.has(edge.source)) { + errors.push(`edges[${i}].source: "${edge.source}" is not declared in nodes[] and does not exist in the graph`); + } + if (tv.valid && !declaredNodeIds.has(edge.target) && !existingNodes.has(edge.target)) { + errors.push(`edges[${i}].target: "${edge.target}" is not declared in nodes[] and does not exist in the graph`); + } + } + } + + return { valid: errors.length === 0, errors, warnings, nodeIds: declaredNodeIds }; +} + +/** + * Import a validated data object into the graph atomically. + * + * @param {import('@git-stunts/git-warp').default} graph + * @param {object} data - Validated import data + * @returns {Promise<{ nodes: number, edges: number }>} + */ +async function writeImport(graph, data) { + const patch = await graph.createPatch(); + let nodeCount = 0; + let edgeCount = 0; + + // Add nodes + for (const node of (data.nodes ?? [])) { + patch.addNode(node.id); + nodeCount++; + + // Set node properties if provided + if (node.properties && typeof node.properties === 'object') { + for (const [key, value] of Object.entries(node.properties)) { + patch.setProperty(node.id, key, value); + } + } + } + + // Add edges + for (const edge of (data.edges ?? [])) { + // Ensure edge endpoint nodes exist (idempotent) + patch.addNode(edge.source); + patch.addNode(edge.target); + + patch.addEdge(edge.source, edge.target, edge.type); + + const confidence = edge.confidence ?? 1.0; + patch.setEdgeProperty(edge.source, edge.target, edge.type, 'confidence', confidence); + patch.setEdgeProperty(edge.source, edge.target, edge.type, 'createdAt', new Date().toISOString()); + + if (edge.rationale) { + patch.setEdgeProperty(edge.source, edge.target, edge.type, 'rationale', edge.rationale); + } + + edgeCount++; + } + + // Only commit if there are operations to write + if (nodeCount > 0 || edgeCount > 0) { + await patch.commit(); + } + return { nodes: nodeCount, edges: edgeCount }; +} + +/** + * Import a YAML file into the graph. + * + * @param {import('@git-stunts/git-warp').default} graph + * @param {string} filePath - Path to the YAML file + * @param {{ dryRun?: boolean }} [opts] + * @returns {Promise} + */ +export async function importFile(graph, filePath, opts = {}) { + const dryRun = opts.dryRun ?? false; + + // Parse + const { data, parseError } = await parseImportFile(filePath); + if (parseError) { + return { valid: false, errors: [parseError], warnings: [], stats: { nodes: 0, edges: 0 }, dryRun }; + } + + // Validate + const { valid, errors, warnings } = await validateImportData(data, graph); + if (!valid) { + return { valid: false, errors, warnings, stats: { nodes: 0, edges: 0 }, dryRun }; + } + + // Dry-run: report stats without writing + if (dryRun) { + const stats = { + nodes: (data.nodes ?? []).length, + edges: (data.edges ?? []).length, + }; + return { valid: true, errors: [], warnings, stats, dryRun: true }; + } + + // Write atomically + const stats = await writeImport(graph, data); + return { valid: true, errors: [], warnings, stats, dryRun: false }; +} diff --git a/src/index.js b/src/index.js index 06078a36..6389e227 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ export { initGraph, loadGraph, saveGraph } from './graph.js'; export { createEdge, queryEdges, removeEdge, EDGE_TYPES } from './edges.js'; export { getNodes, hasNode, getNode, getNodesByPrefix } from './nodes.js'; export { computeStatus } from './status.js'; +export { importFile, parseImportFile, validateImportData } from './import.js'; export { validateNodeId, validateEdgeType, validateConfidence, validateEdge, extractPrefix, classifyPrefix, diff --git a/test/import.test.js b/test/import.test.js new file mode 100644 index 00000000..01a4bcd4 --- /dev/null +++ b/test/import.test.js @@ -0,0 +1,388 @@ +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 { importFile, parseImportFile, validateImportData } from '../src/import.js'; + +describe('import', () => { + 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 }); + }); + + /** + * Helper: write a YAML file and return its path. + */ + async function writeYaml(filename, content) { + const path = join(tempDir, filename); + await writeFile(path, content, 'utf-8'); + return path; + } + + // ── Schema validation ───────────────────────────────────────── + + describe('schema validation', () => { + it('rejects missing version field', async () => { + const path = await writeYaml('bad.yaml', ` +nodes: + - id: "task:a" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/Missing required field.*version/); + }); + + it('rejects unsupported version', async () => { + const path = await writeYaml('bad.yaml', ` +version: 99 +nodes: + - id: "task:a" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/Unsupported version: 99/); + }); + + it('rejects non-object YAML', async () => { + const path = await writeYaml('bad.yaml', `just a string`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/not an object/); + }); + + it('reports file not found', async () => { + const result = await importFile(graph, '/nonexistent/file.yaml'); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/File not found/); + }); + + it('rejects non-array nodes field', async () => { + const path = await writeYaml('bad.yaml', ` +version: 1 +nodes: "not-an-array" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual(expect.stringMatching(/"nodes" must be an array/)); + }); + + it('rejects non-array edges field', async () => { + const path = await writeYaml('bad.yaml', ` +version: 1 +edges: "not-an-array" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual(expect.stringMatching(/"edges" must be an array/)); + }); + }); + + // ── Node validation ─────────────────────────────────────────── + + describe('node validation', () => { + it('rejects invalid node IDs', async () => { + const path = await writeYaml('bad.yaml', ` +version: 1 +nodes: + - id: "bad id" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/nodes\[0\].*Invalid node ID/); + }); + + it('rejects nodes without id field', async () => { + const path = await writeYaml('bad.yaml', ` +version: 1 +nodes: + - name: "oops" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/nodes\[0\].*missing required field "id"/); + }); + + it('warns on unknown prefix', async () => { + const path = await writeYaml('warn.yaml', ` +version: 1 +nodes: + - id: "custom:thing" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(true); + expect(result.warnings[0]).toMatch(/prefix "custom" is not a canonical prefix/); + }); + }); + + // ── Edge validation ─────────────────────────────────────────── + + describe('edge validation', () => { + it('rejects invalid edge type', async () => { + const path = await writeYaml('bad.yaml', ` +version: 1 +nodes: + - id: "task:a" + - id: "task:b" +edges: + - source: "task:a" + target: "task:b" + type: "invalid-type" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual(expect.stringMatching(/Unknown edge type/)); + }); + + it('rejects invalid confidence', async () => { + const path = await writeYaml('bad.yaml', ` +version: 1 +nodes: + - id: "task:a" + - id: "task:b" +edges: + - source: "task:a" + target: "task:b" + type: "relates-to" + confidence: 5.0 +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual(expect.stringMatching(/between 0\.0 and 1\.0/)); + }); + + it('rejects self-edge for blocks', async () => { + const path = await writeYaml('bad.yaml', ` +version: 1 +nodes: + - id: "task:a" +edges: + - source: "task:a" + target: "task:a" + type: "blocks" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual(expect.stringMatching(/self-edge forbidden/)); + }); + + it('rejects edges with missing required fields', async () => { + const path = await writeYaml('bad.yaml', ` +version: 1 +edges: + - source: "task:a" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual(expect.stringMatching(/missing required field "target"/)); + expect(result.errors).toContainEqual(expect.stringMatching(/missing required field "type"/)); + }); + }); + + // ── Reference validation ────────────────────────────────────── + + describe('reference validation', () => { + it('rejects dangling edge references', async () => { + const path = await writeYaml('bad.yaml', ` +version: 1 +nodes: + - id: "task:a" +edges: + - source: "task:a" + target: "spec:missing" + type: "implements" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual(expect.stringMatching(/"spec:missing" is not declared/)); + }); + + it('allows edges to pre-existing graph nodes', async () => { + // Pre-populate the graph + await createEdge(graph, { source: 'spec:auth', target: 'doc:readme', type: 'documents' }); + + const path = await writeYaml('ok.yaml', ` +version: 1 +nodes: + - id: "file:auth.js" +edges: + - source: "file:auth.js" + target: "spec:auth" + type: "implements" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(true); + expect(result.stats.edges).toBe(1); + }); + }); + + // ── Successful imports ──────────────────────────────────────── + + describe('successful import', () => { + it('imports nodes and edges', async () => { + const path = await writeYaml('graph.yaml', ` +version: 1 +nodes: + - id: "spec:auth" + - id: "file:src/auth.js" +edges: + - source: "file:src/auth.js" + target: "spec:auth" + type: "implements" + confidence: 0.9 + rationale: "Main auth module" +`); + const result = await importFile(graph, path); + + expect(result.valid).toBe(true); + expect(result.dryRun).toBe(false); + expect(result.stats.nodes).toBe(2); + expect(result.stats.edges).toBe(1); + + // Verify graph state + const nodes = await graph.getNodes(); + expect(nodes).toContain('spec:auth'); + expect(nodes).toContain('file:src/auth.js'); + + const edges = await graph.getEdges(); + expect(edges.length).toBe(1); + expect(edges[0].from).toBe('file:src/auth.js'); + expect(edges[0].to).toBe('spec:auth'); + expect(edges[0].label).toBe('implements'); + expect(edges[0].props.confidence).toBe(0.9); + expect(edges[0].props.rationale).toBe('Main auth module'); + }); + + it('imports nodes with properties', async () => { + const path = await writeYaml('graph.yaml', ` +version: 1 +nodes: + - id: "task:auth" + properties: + status: "active" + priority: "high" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(true); + + const props = await graph.getNodeProps('task:auth'); + expect(props.get('status')).toBe('active'); + expect(props.get('priority')).toBe('high'); + }); + + it('handles version-only file with no nodes or edges', async () => { + const path = await writeYaml('empty.yaml', `version: 1`); + const result = await importFile(graph, path); + + expect(result.valid).toBe(true); + expect(result.stats.nodes).toBe(0); + expect(result.stats.edges).toBe(0); + }); + }); + + // ── Dry run ─────────────────────────────────────────────────── + + describe('dry run', () => { + it('validates without writing', async () => { + const path = await writeYaml('graph.yaml', ` +version: 1 +nodes: + - id: "task:a" + - id: "task:b" +edges: + - source: "task:a" + target: "task:b" + type: "blocks" +`); + const result = await importFile(graph, path, { dryRun: true }); + + expect(result.valid).toBe(true); + expect(result.dryRun).toBe(true); + expect(result.stats.nodes).toBe(2); + expect(result.stats.edges).toBe(1); + + // Graph should be unchanged + const nodes = await graph.getNodes(); + expect(nodes.length).toBe(0); + }); + }); + + // ── Idempotent re-import ────────────────────────────────────── + + describe('idempotency', () => { + it('re-import produces same graph state', async () => { + const path = await writeYaml('graph.yaml', ` +version: 1 +nodes: + - id: "spec:auth" + - id: "file:auth.js" +edges: + - source: "file:auth.js" + target: "spec:auth" + type: "implements" +`); + // Import twice + await importFile(graph, path); + await importFile(graph, path); + + const nodes = await graph.getNodes(); + const edges = await graph.getEdges(); + + expect(nodes.length).toBe(2); + expect(edges.length).toBe(1); + }); + }); + + // ── Atomicity ───────────────────────────────────────────────── + + describe('atomicity', () => { + it('writes nothing if validation fails', async () => { + const path = await writeYaml('bad.yaml', ` +version: 1 +nodes: + - id: "task:a" +edges: + - source: "task:a" + target: "spec:missing" + type: "implements" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + + // Graph should be empty + const nodes = await graph.getNodes(); + expect(nodes.length).toBe(0); + }); + }); + + // ── JSON structure ──────────────────────────────────────────── + + describe('result structure', () => { + it('returns correct structure for JSON serialization', async () => { + const path = await writeYaml('graph.yaml', ` +version: 1 +nodes: + - id: "task:a" +`); + const result = await importFile(graph, path); + const json = JSON.parse(JSON.stringify(result)); + + expect(json).toHaveProperty('valid'); + expect(json).toHaveProperty('errors'); + expect(json).toHaveProperty('warnings'); + expect(json).toHaveProperty('stats.nodes'); + expect(json).toHaveProperty('stats.edges'); + expect(json).toHaveProperty('dryRun'); + }); + }); +}); From 9046bb807e5d59920f252a185e1c3b376b95cf46 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 10 Feb 2026 21:12:10 -0800 Subject: [PATCH 2/5] docs: update README and GUIDE for nodes, status, and import commands (#185, #187) Adds CLI reference for git mind nodes, status, and import. Adds YAML import format section, node query and status library examples, and updated public API export listing. --- GUIDE.md | 204 ++++++++++++++++++++++++++++++++++++++++++++++-------- README.md | 14 +++- 2 files changed, 189 insertions(+), 29 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index c5769753..e8cfc3e1 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -12,10 +12,11 @@ Everything you need to know — from zero to power user. 4. [Core concepts](#core-concepts) 5. [CLI reference](#cli-reference) 6. [Views](#views) -7. [Commit directives](#commit-directives) -8. [Using git-mind as a library](#using-git-mind-as-a-library) -9. [Appendix A: How it works under the hood](#appendix-a-how-it-works-under-the-hood) -10. [Appendix B: Edge types reference](#appendix-b-edge-types-reference) +7. [Importing graphs from YAML](#importing-graphs-from-yaml) +8. [Commit directives](#commit-directives) +9. [Using git-mind as a library](#using-git-mind-as-a-library) +10. [Appendix A: How it works under the hood](#appendix-a-how-it-works-under-the-hood) +11. [Appendix B: Edge types reference](#appendix-b-edge-types-reference) --- @@ -215,12 +216,78 @@ git mind link module:a module:b --type depends-on --confidence 0.9 ### `git mind list` -Show all edges in the graph. +Show all edges in the graph, optionally filtered. ```bash git mind list +git mind list --type implements +git mind list --source file:src/auth.js +git mind list --target spec:auth ``` +**Flags:** + +| Flag | Description | +|------|-------------| +| `--type ` | Filter by edge type | +| `--source ` | Filter by source node | +| `--target ` | Filter by target node | + +### `git mind nodes` + +List and inspect nodes in the graph. + +```bash +git mind nodes # list all nodes +git mind nodes --prefix task # list only task:* nodes +git mind nodes --id task:auth # show details for one node +git mind nodes --json # JSON output +``` + +**Flags:** + +| Flag | Description | +|------|-------------| +| `--prefix ` | Filter by prefix (e.g. `task`, `spec`, `module`) | +| `--id ` | Show details for a single node (prefix classification, properties) | +| `--json` | Output as JSON | + +### `git mind status` + +Show a health dashboard for the graph. + +```bash +git mind status +git mind status --json +``` + +Displays: +- **Node counts** by prefix (with percentages) +- **Edge counts** by type +- **Health indicators** — blocked items, low-confidence edges (< 0.5), orphan nodes (0 edges) + +The `--json` flag outputs a structured object suitable for CI pipelines. + +### `git mind import ` + +Import a YAML graph file. + +```bash +git mind import graph.yaml # import +git mind import graph.yaml --dry-run # validate without writing +git mind import graph.yaml --json # structured output +``` + +**Flags:** + +| Flag | Description | +|------|-------------| +| `--dry-run` | Validate the file and report what would be imported, without writing | +| `--validate` | Alias for `--dry-run` | +| `--json` | Output as JSON | + +See [Importing graphs from YAML](#importing-graphs-from-yaml) for the file format. + ### `git mind view [name]` Render a named view, or list available views. @@ -302,6 +369,64 @@ console.log(result); --- +## Importing graphs from YAML + +For bulk ingestion, git-mind supports a YAML import format. This is useful for bootstrapping a graph from existing documentation, seeding a project template, or sharing graph snapshots. + +### File format + +```yaml +version: 1 + +nodes: + - id: "spec:auth" + - id: "file:src/auth.js" + properties: + status: active + owner: alice + +edges: + - source: "file:src/auth.js" + target: "spec:auth" + type: implements + confidence: 1.0 + rationale: "Main auth implementation" +``` + +**Rules:** + +- `version: 1` is required. Unknown versions produce a hard error. +- `nodes` is optional. Each node must have an `id` field. Nodes can optionally include `properties` (key/value map). +- `edges` is optional. Each edge requires `source`, `target`, and `type`. `confidence` defaults to 1.0. `rationale` is optional. +- **Reference validation** — every edge endpoint must be declared in the `nodes` array or already exist in the graph. Dangling references are rejected. +- **Atomic writes** — if any validation fails, nothing is written. It's all-or-nothing. +- **Idempotent** — importing the same file twice is safe. Nodes merge, edges update. + +### Dry-run mode + +Validate without writing: + +```bash +git mind import graph.yaml --dry-run +# ✔ Validation passed +# Would import: 2 node(s), 1 edge(s) +``` + +### Programmatic import + +```javascript +import { importFile, loadGraph } from '@neuroglyph/git-mind'; + +const graph = await loadGraph('.'); +const result = await importFile(graph, 'graph.yaml', { dryRun: false }); + +console.log(result.valid); // true +console.log(result.stats.nodes); // 2 +console.log(result.stats.edges); // 1 +``` + +--- + ## Commit directives git-mind can automatically create edges from commit messages. Include directives in your commit body: @@ -349,28 +474,24 @@ git-mind exports its core modules for use in scripts and integrations. ```javascript import { - initGraph, - loadGraph, - saveGraph, - createEdge, - queryEdges, - removeEdge, - EDGE_TYPES, - validateNodeId, - validateEdgeType, - validateConfidence, - validateEdge, - extractPrefix, - classifyPrefix, - NODE_ID_REGEX, - NODE_ID_MAX_LENGTH, - CANONICAL_PREFIXES, - SYSTEM_PREFIXES, - defineView, - renderView, - listViews, - parseDirectives, - processCommit, + // Graph lifecycle + initGraph, loadGraph, saveGraph, + // Edge CRUD + createEdge, queryEdges, removeEdge, EDGE_TYPES, + // Node queries + getNodes, hasNode, getNode, getNodesByPrefix, + // Status + computeStatus, + // Import + importFile, parseImportFile, validateImportData, + // Validation + validateNodeId, validateEdgeType, validateConfidence, validateEdge, + extractPrefix, classifyPrefix, + NODE_ID_REGEX, NODE_ID_MAX_LENGTH, CANONICAL_PREFIXES, SYSTEM_PREFIXES, + // Views + defineView, renderView, listViews, + // Hooks + parseDirectives, processCommit, } from '@neuroglyph/git-mind'; ``` @@ -408,6 +529,35 @@ const implEdges = await queryEdges(graph, { type: 'implements' }); await removeEdge(graph, 'file:src/auth.js', 'spec:auth', 'implements'); ``` +### Node queries + +```javascript +// Get all node IDs +const allNodes = await getNodes(graph); + +// Check existence +const exists = await hasNode(graph, 'task:auth'); + +// Get full node info (prefix classification, properties) +const node = await getNode(graph, 'file:src/auth.js'); +// { id: 'file:src/auth.js', prefix: 'file', prefixClass: 'canonical', properties: {} } + +// Filter by prefix +const tasks = await getNodesByPrefix(graph, 'task'); +// ['task:auth', 'task:login', ...] +``` + +### Status + +```javascript +const status = await computeStatus(graph); +// { +// nodes: { total: 12, byPrefix: { task: 5, spec: 3, ... } }, +// edges: { total: 8, byType: { implements: 4, ... } }, +// health: { blockedItems: 1, lowConfidence: 2, orphanNodes: 0 } +// } +``` + ### Validation Validators return result objects — they don't throw. Callers decide how to handle errors. diff --git a/README.md b/README.md index 21b15e90..f81420ba 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,21 @@ Try an idea in a branch. If it works, merge it — graph and all. If it doesn't, git mind init # Link files with semantic relationships -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:cache module:db --type depends-on +# Import a graph from YAML +git mind import graph.yaml + # See all connections git mind list +# Query nodes +git mind nodes --prefix task + +# Check graph health +git mind status + # View filtered projections git mind view architecture git mind view roadmap @@ -54,8 +63,9 @@ cd git-mind && npm install # Use in any Git repo cd /path/to/your/repo npx git-mind init -npx git-mind link README.md docs/spec.md --type documents +npx git-mind link file:README.md doc:spec --type documents npx git-mind list +npx git-mind status ``` See [GUIDE.md](GUIDE.md) for a complete walkthrough. From 582b0bad3affc82ede2a5068b87ac930f8c53dbc Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 10 Feb 2026 21:12:22 -0800 Subject: [PATCH 3/5] docs: add TECH-PLAN.md technical deep dive (#185, #187) Reference artifact documenting architecture, data model, runtime behavior, system invariants, failure modes, and roadmap trajectory. --- TECH-PLAN.md | 625 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 625 insertions(+) create mode 100644 TECH-PLAN.md diff --git a/TECH-PLAN.md b/TECH-PLAN.md new file mode 100644 index 00000000..c175669e --- /dev/null +++ b/TECH-PLAN.md @@ -0,0 +1,625 @@ +# Technical Deep Dive: git-mind + +> **"A knowledge graph that thinks alongside you."** + +A technical analysis of git-mind's architecture, data model, runtime behavior, invariants, and roadmap trajectory. This is a reference artifact — not a changelog, not a tutorial. + +--- + +## 1. The Big Idea + +git-mind turns any Git repository into a **semantic knowledge graph**. Instead of only tracking *what* files exist and *when* they changed (which Git already does), git-mind captures *why* things are connected — which spec a file implements, what a module depends on, which task a commit addresses. + +The key insight: **Git is already a graph database.** It has an immutable object store, a reflog, branches, merges, and distributed replication. git-mind exploits this by storing its knowledge graph *inside* Git's ref system using CRDTs, making the graph invisible to normal workflows but always present, versioned, and mergeable. + +--- + +## 2. Storage Layer: git-warp + +git-mind delegates persistence to **@git-stunts/git-warp** (v10.3.2), a CRDT graph database that uses Git's object store as its backend. + +### How data gets stored + +``` +refs/warp/gitmind/writers/local ← your patch chain (Git commits) +refs/warp/gitmind/writers/alice ← another writer's patches +refs/warp/gitmind/checkpoints/latest ← snapshot for fast reload +``` + +Each graph mutation is a **patch** — a JSON blob committed to Git's object store. These commits point to Git's empty tree (`4b825dc...`), creating **zero files** in the working directory. They're invisible to `git status`, `git diff`, and all normal Git operations. + +A patch looks like: + +```json +{ + "ops": [ + { "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, + "vv": { "local": 42 } +} +``` + +Each patch carries a **version vector** (`vv`) for causal ordering and a **tick** (Lamport logical clock) for property conflict resolution. + +### CRDT Semantics + +Two CRDT types make concurrent writes conflict-free: + +| CRDT | Used for | Conflict resolution | +|------|----------|-------------------| +| **OR-Set** (Observed-Remove) | Nodes and edges | Add wins over concurrent remove | +| **LWW Register** (Last-Writer-Wins) | Properties | See tie-break rules below | + +### LWW Tie-Break Hierarchy + +When two writers concurrently set the same property, the following deterministic tie-break applies: + +1. **Compare Lamport tick** (logical clock). Higher tick wins. +2. **If equal, compare writerId lexicographically.** `"bob"` > `"alice"` — deterministic across all replicas. +3. **If equal (same writer, same tick — shouldn't happen), compare patch hash.** The patch with the lexicographically greater hash wins. + +This ensures **all replicas converge to identical state** regardless of delivery order. No hidden nondeterminism. No "it depends on who syncs first." + +### Convergence vs. Correctness + +**State convergence is guaranteed** — all replicas always reach the same materialized graph. The CRDTs ensure this mechanically. + +**Domain correctness is not guaranteed by convergence alone.** If writer A sets `confidence: 0.9` and writer B concurrently sets `confidence: 0.2` on the same edge, LWW picks one deterministically — but neither writer's *intent* is preserved. Similarly, if two writers independently create `spec:auth` and `specs:auth`, the graph converges to having both nodes — even though that's semantic noise. + +Domain correctness is governed by: +- The **validation layer** (prefix taxonomy, edge type constraints, self-edge prohibition) +- The **review workflow** (humans curate, machines suggest) +- **Prefix governance** (see §3) + +Do not confuse "no merge conflicts" with "no data quality problems." + +### Materialization + +Reading the graph requires **materialization** — replaying all patches in causal order to build an in-memory graph. git-mind sets `autoMaterialize: true`, so this happens transparently. + +| Operation | Complexity | Notes | +|-----------|-----------|-------| +| Add node/edge | O(1) | Append-only | +| Materialize | O(P) | P = total patches across all writers | +| Query (post-materialize) | O(N) | N = matching nodes | +| Checkpoint | O(S) | S = state size; creates snapshot | + +### Scalability Escape Hatches + +Materialization is O(P) where P is total patches. At small scale this is fine. At large scale: + +- **Checkpoints** (`saveGraph()`) — Creates a snapshot. Future materializations replay only patches since the last checkpoint. This is the primary mitigation and is available today. +- **Checkpoint policy** — Not yet automated. Currently manual via `saveGraph()`. Planned: automatic checkpoint after N patches or on `git mind status`. +- **Patch compaction** — Not yet implemented. Planned: merge sequential patches from the same writer into a single consolidated patch. +- **Incremental materialization** — Not yet implemented. Planned: cache frontier hash + tick, only replay new patches since last materialization. +- **State cache keying** — Planned: key cache by `(writer frontier vector hash)` so identical state can skip re-materialization entirely. + +Until these are implemented, the practical limit is ~10K patches before materialization latency becomes noticeable. Checkpoints should be created after bulk operations. + +### Initialization Flow (`src/graph.js`) + +``` +initGraph(repoPath) + → ShellRunnerFactory.create() // from @git-stunts/plumbing + → GitPlumbing({ runner }) // low-level Git operations + → GitGraphAdapter({ plumbing }) // persistence adapter + → WarpGraph.open({ + graphName: 'gitmind', + persistence: adapter, + writerId: 'local', + autoMaterialize: true + }) +``` + +`WarpGraph.open()` is idempotent — init and load are the same call. `@git-stunts/plumbing` must be installed as a **direct** dependency (not hoisted from git-warp). + +--- + +## 3. Data Model + +### Node ID Grammar + +Node IDs follow the `prefix:identifier` format. The prefix is always lowercase; the identifier is case-preserving. + +**Regex:** `/^[a-z][a-z0-9-]*:[A-Za-z0-9._\/@-]+$/` +**Max length:** 256 characters + +Nodes are **implicitly created** when an edge references them. There's no separate "create node" operation — if you link `file:a.js` to `spec:auth`, both nodes spring into existence. + +### Prefix Taxonomy + +**18 canonical prefixes:** + +| Category | Prefixes | +|----------|----------| +| Project mgmt | `milestone`, `feature`, `task`, `issue`, `phase` | +| Documentation | `spec`, `adr`, `doc` | +| Architecture | `crate`, `module`, `pkg`, `file` | +| Abstract | `concept`, `decision` | +| People/tools | `person`, `tool` | +| Observability | `event`, `metric` | + +**1 system prefix:** `commit` (auto-generated from commit hooks, reserved) + +### Prefix Governance + +Unknown prefixes produce a **warning** but are allowed. This lets the taxonomy grow organically. However, unchecked growth creates semantic noise (`spec:auth` vs `specs:auth` vs `requirement:auth` vs `req:auth`). + +**Two-level governance model:** + +1. **Runtime: soft allowlist (warn-on-unknown)** + Current behavior in `validators.js`. Unknown prefixes pass validation but emit warnings. This prevents rejecting legitimate new prefixes while signaling drift. + +2. **Project: canonical prefix registry (lint-in-CI)** + Each project should declare its authorized prefixes. Planned: `.gitmind.yml` config with `allowedPrefixes` field. CI can treat unknown-prefix warnings as errors, preventing organic sprawl in team projects while allowing solo users full freedom. + +This is "freedom with rails" — the runtime never blocks, but the CI can. + +### Node ID Grammar vs. Federation Format + +**Current grammar:** `prefix:identifier` +**Planned federation grammar (NEXUS milestone):** `repo:owner/name:prefix:identifier` + +**Collision risk:** The current regex allows `/` in identifiers, so `repo:owner/name` would parse as prefix=`repo`, identifier=`owner/name` — a valid node ID under today's grammar. When federation arrives, `repo:owner/name:prefix:identifier` would be ambiguous: is the prefix `repo` or `repo:owner/name`? + +**Resolution strategy:** Federation IDs use a **different syntax boundary** — not bare `:` but a qualified escape. Options under consideration: + +1. **Double-colon delimiter:** `repo::owner/name::prefix:identifier` — federation layer uses `::`, node IDs use single `:` +2. **Bracket syntax:** `[owner/name]prefix:identifier` — repo qualifier is syntactically distinct +3. **URI scheme:** `gitmind://owner/name/prefix:identifier` — full URI for remote, bare `prefix:id` for local + +The final choice will be made during NEXUS implementation. **The key invariant is:** local node IDs (`prefix:identifier`) are never syntactically ambiguous with federation-qualified IDs. The parser must be able to distinguish them without context. + +Until federation ships, `repo:` is not in the canonical prefix list and will trigger an unknown-prefix warning if used manually. + +### Edge Types + +An edge is a directed, typed, scored connection: `source --[type]--> target` + +**Edge uniqueness:** `(source, target, type)` tuple. Re-adding the same edge updates its properties rather than creating a duplicate. + +**8 edge types:** + +| Type | Direction | Meaning | Self-edge? | +|------|-----------|---------|-----------| +| `implements` | source implements target | Code fulfills a spec | Allowed | +| `augments` | source extends target | Enhancement or extension | Allowed | +| `relates-to` | source relates to target | General association | Allowed | +| `blocks` | source blocks target | Dependency ordering | **Forbidden** | +| `belongs-to` | source is part of target | Membership/containment | Allowed | +| `consumed-by` | source is consumed by target | Usage relationship | Allowed | +| `depends-on` | source depends on target | Dependency | **Forbidden** | +| `documents` | source documents target | Documentation link | Allowed | + +### Edge Properties + +| Property | Type | Default | Mutable? | Owner | +|----------|------|---------|----------|-------| +| `confidence` | float [0.0, 1.0] | 1.0 | Yes (user) | User/system | +| `createdAt` | ISO 8601 string | `new Date().toISOString()` | **No** (set once) | System | +| `rationale` | string or undefined | undefined | Yes (user) | User | + +**Confidence semantics:** + +| Score | Source | Meaning | +|-------|--------|---------| +| 1.0 | `git mind link` | Human-verified | +| 0.8 | Commit directive | Auto-created, high confidence | +| 0.3–0.5 | AI suggestion (future) | Needs review | +| 0.0 | Unknown | Should be reviewed or removed | + +**Note:** `createdAt` is set on first creation and should be treated as immutable. LWW means a concurrent write *could* overwrite it, but this is considered a bug in the writer, not a feature of the system. Future: enforce immutability of `createdAt` at the validation layer. + +--- + +## 4. System Invariants + +These are the rules that must never be violated. If any of these break, the system is in a corrupt state. + +| Invariant | Enforced by | Consequence of violation | +|-----------|------------|------------------------| +| Node IDs match `/^[a-z][a-z0-9-]*:[A-Za-z0-9._\/@-]+$/` | `validateNodeId()` | Edge creation rejected | +| Node IDs ≤ 256 characters | `validateNodeId()` | Edge creation rejected | +| Edge types are one of the 8 defined types | `validateEdgeType()` | Edge creation rejected | +| Confidence is a finite number in [0.0, 1.0] | `validateConfidence()` | Edge creation rejected | +| Self-edges forbidden for `blocks` and `depends-on` | `validateEdge()` | Edge creation rejected | +| System prefix `commit:` is reserved for hooks | Convention (not enforced) | Semantic confusion | +| Edge uniqueness by `(source, target, type)` | git-warp addEdge semantics | Update, not duplicate | +| Patches are append-only | git-warp CRDT | History is immutable | + +--- + +## 5. Runtime Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ CLI Layer │ +│ bin/git-mind.js → src/cli/commands.js │ +│ src/cli/format.js │ +├──────────────────────────────────────────────────────┤ +│ Domain Layer │ +│ src/edges.js — Edge CRUD with validation │ +│ src/views.js — Observer views (filtered reads) │ +│ src/hooks.js — Commit directive parser │ +│ src/validators.js — Runtime schema enforcement │ +├──────────────────────────────────────────────────────┤ +│ Infrastructure Layer │ +│ src/graph.js — WarpGraph wrapper │ +├──────────────────────────────────────────────────────┤ +│ External Layer │ +│ @git-stunts/git-warp — CRDT graph on Git │ +│ @git-stunts/plumbing — Low-level Git ops │ +├──────────────────────────────────────────────────────┤ +│ Public API │ +│ src/index.js — Re-exports everything │ +└──────────────────────────────────────────────────────┘ +``` + +### CLI Entry Point (`bin/git-mind.js`) + +Manual argv parsing — no CLI framework. Parses command name and flags (`--type`, `--confidence`, `--source`, `--target`), then delegates to command functions in `src/cli/commands.js`. + +**Current commands:** `init`, `link`, `list`, `remove`, `view`, `install-hooks`, `process-commit`, `suggest` (stub), `review` (stub) + +### Edge CRUD (`src/edges.js`) + +`createEdge(graph, { source, target, type, confidence, rationale })` +1. Validates all inputs via `validators.js` +2. Emits warnings for unknown prefixes (to stderr) +3. Auto-creates source/target nodes if they don't exist (idempotent `addNode`) +4. Creates edge via `graph.createPatch()` → `.addEdge()` → `.commit()` +5. Sets edge properties: confidence, createdAt, rationale + +`queryEdges(graph, { source?, target?, type? })` +- Fetches all edges via `graph.getEdges()`, then filters client-side +- Returns edge objects with full properties (`from`, `to`, `label`, `props`) + +`removeEdge(graph, source, target, type)` +- Removes matching edge via patch commit + +### Views (`src/views.js`) + +Registry pattern: `Map`. A view is a filter function `(nodes, edges) → { nodes, edges }`. + +**4 built-in views:** + +| View | Node filter | Edge filter | +|------|------------|-------------| +| `roadmap` | `phase:*`, `task:*` | All related edges | +| `architecture` | `crate:*`, `module:*`, `pkg:*` | `depends-on` only | +| `backlog` | `task:*` | All related edges | +| `suggestions` | Nodes in low-conf edges | Confidence < 0.5 | + +Custom views can be registered via `defineView(name, filterFn)`. + +### Commit Directives (`src/hooks.js`) + +Parses commit messages for directives that auto-create edges. + +**Directive regex:** `/^(IMPLEMENTS|AUGMENTS|RELATES-TO|BLOCKS|DEPENDS-ON|DOCUMENTS):\s*(\S.*)$/gmi` + +**Current directive grammar:** + +``` +directive := KEYWORD ":" WHITESPACE+ target +KEYWORD := "IMPLEMENTS" | "AUGMENTS" | "RELATES-TO" | "BLOCKS" + | "DEPENDS-ON" | "DOCUMENTS" +target := non-whitespace-run trailing-trimmed +``` + +**Behavior:** +- Case-insensitive for keyword (`implements:` = `IMPLEMENTS:`) +- One directive per line — the regex is anchored to `^...$` with multiline flag +- Target is everything after the colon and whitespace, trimmed +- Source node is always `commit:` +- Confidence: 0.8 (high but not human-reviewed) +- Rationale: `"Auto-created from commit "` + +**Defined edge cases:** + +| Input | Behavior | +|-------|----------| +| `IMPLEMENTS: spec:auth` | Creates one edge | +| `implements: spec:auth` | Creates one edge (case-insensitive) | +| `IMPLEMENTS: spec:auth, spec:session` | Creates one edge to target `"spec:auth, spec:session"` (comma is **not** a delimiter) | +| `IMPLEMENTS: spec:auth spec:session` | Creates one edge to target `"spec:auth spec:session"` (space is **not** a delimiter within target) | +| Two `IMPLEMENTS:` lines in one message | Creates two edges (each line matched independently) | +| `AUGMENTS:` with no target | No match (`\S` requires at least one non-space char after colon) | +| Directive in commit subject line | Matches (regex is multiline, not body-only) | + +**Current limitation:** The regex captures `(\S.*)$` as the target, which means multi-word targets like `spec:auth, spec:session` are treated as a single target string. This will fail node ID validation (commas and spaces are not in the allowed charset), causing `createEdge` to throw. The directive effectively requires exactly one valid node ID per line. + +**Planned improvements (ORACLE milestone):** +- Explicit multi-target delimiter (comma-separated, validated individually) +- Body-only directive matching (skip subject line) +- Duplicate directive deduplication within a single commit + +### Validation (`src/validators.js`) + +Pure functions that return result objects — they never throw. Callers decide error handling. + +- `validateNodeId(id)` — format, length, regex +- `validateEdgeType(type)` — against EDGE_TYPES enum +- `validateConfidence(value)` — [0.0, 1.0], rejects NaN/Infinity/non-number +- `validateEdge(source, target, type, confidence)` — composite; collects all errors and warnings + +Self-edges are forbidden for `blocks` and `depends-on` (would create semantic paradoxes). + +### Formatting (`src/cli/format.js`) + +Terminal output using chalk + figures: +``` +✔ Created edge +ℹ 3 edge(s): + file:src/auth.js --[implements]--> spec:auth (100%) +``` + +Views render with header, stats, edge list, and orphan nodes section. + +--- + +## 6. Data Flow: End-to-End Scenarios + +### Scenario: `git mind link file:a.js spec:auth --type implements` + +``` +CLI parses argv + → commands.link(graph, 'file:a.js', 'spec:auth', { type: 'implements', confidence: 1.0 }) + → edges.createEdge(graph, { source, target, type, confidence }) + → validators.validateEdge(source, target, type, confidence) + → validateNodeId('file:a.js') → { valid: true } + → validateNodeId('spec:auth') → { valid: true } + → validateEdgeType('implements') → { valid: true } + → validateConfidence(1.0) → { valid: true } + → self-edge check → pass (different nodes) + → prefix check → both canonical, 0 warnings + → graph.createPatch() + → patch.addNode('file:a.js') // idempotent if exists + → patch.addNode('spec:auth') // idempotent if exists + → patch.addEdge('file:a.js', 'spec:auth', 'implements') + → patch.setEdgeProperty(..., 'confidence', 1.0) + → patch.setEdgeProperty(..., 'createdAt', ISO timestamp) + → patch.commit() // writes to Git ref + → format.success('Created edge...') +``` + +### Scenario: Post-commit hook fires + +``` +.git/hooks/post-commit runs: + npx git-mind process-commit "$SHA" + +CLI parses argv + → commands.processCommitCmd(graph, sha) + → git log -1 --format=%B $SHA // get commit message + → hooks.processCommit(graph, { sha, message }) + → hooks.parseDirectives(message) + → regex matches: IMPLEMENTS: spec:auth + → returns [{ type: 'implements', target: 'spec:auth' }] + → for each directive: + → edges.createEdge(graph, { + source: 'commit:', + target: 'spec:auth', + type: 'implements', + confidence: 0.8, + rationale: 'Auto-created from commit ' + }) +``` + +### Scenario: `git mind view architecture` + +``` +CLI parses argv + → commands.view(graph, 'architecture') + → views.renderView(graph, 'architecture') + → get all nodes from graph + → filter: keep only crate:*, module:*, pkg:* nodes + → get all edges from graph + → filter: keep only 'depends-on' edges between matching nodes + → return { nodes, edges } + → format.formatView('architecture', result) + → print header, node/edge counts + → print each edge: source --[depends-on]--> target (confidence%) + → print orphan nodes (nodes with 0 edges in this view) +``` + +--- + +## 7. Test Coverage + +**74 tests across 5 files** (as of v2.0.0-alpha.0, verified by `vitest run`). + +| File | Tests | Focus | +|------|-------|-------| +| `test/graph.test.js` | 3 | Init, load (idempotent), round-trip persistence | +| `test/edges.test.js` | 14 | CRUD, 8 edge types, confidence, validation rejection, self-edge | +| `test/hooks.test.js` | 8 | Directive parsing (6 types), case insensitivity, processCommit integration | +| `test/validators.test.js` | 42 | Exhaustive boundary testing for all validators + constants | +| `test/views.test.js` | 7 | 4 built-in views, custom view registration, filtering | + +**Note:** The README and ROADMAP reference "25 tests" — this is stale. The actual count is 74. + +**Testing pattern:** Every test creates a temp Git repo (`mkdtemp` + `git init`), initializes the graph, runs the test, then `rm -rf`s the temp dir. No mocking — real Git operations. Full isolation. + +**Tested:** +- Graph lifecycle (init, load, checkpoint round-trip) +- Edge CRUD with all 8 relationship types +- Confidence scoring boundaries (0.0, 0.5, 1.0, -0.1, 1.1, NaN, Infinity, null) +- Node ID validation (regex, length, prefix classification) +- Self-edge prohibition for `blocks` and `depends-on` +- Commit directive parsing (all 6 directive types, case insensitivity) +- View filtering (all 4 built-in views + custom) + +**Not tested (known gaps):** +- CLI commands directly (`bin/git-mind.js` argv parsing) +- Terminal formatting (`src/cli/format.js`) +- Concurrent CRDT merge behavior (multiple writers) +- Large graph performance (>1K nodes) +- Error recovery (corrupt refs, partial writes) +- Hook installation and execution (`install-hooks` command) + +--- + +## 8. Failure Modes + +| Failure | Current behavior | Severity | +|---------|-----------------|----------| +| Corrupt Git ref | git-warp throws on materialization | Fatal — manual ref repair | +| Missing writer chain | Materialization skips that writer's patches | Silent data loss | +| Partial hook execution | Some directives create edges, others fail | Partial state — no rollback | +| Invalid node ID in directive target | `createEdge` throws, remaining directives skipped | Partial — first N succeed | +| Disk full during patch commit | Git object write fails, patch lost | Fatal — retry after space freed | +| Concurrent checkpoint + write | git-warp handles via ref CAS | Safe — retry on conflict | +| `plumbing` not installed as direct dep | `ShellRunnerFactory.create()` throws | Fatal — npm install fix | + +--- + +## 9. Roadmap: The Seven Milestones + +The roadmap is a 7-milestone, 46-task, ~202 hour plan organized as a dependency DAG. The roadmap is itself tracked as a DAG inside git-mind's own graph. + +### Milestone 1: BEDROCK (28h) — Schema & Node Foundations + +**Goal:** Establish the schema contract, runtime validators, and node query layer. + +**Status:** Partially complete. `validators.js` (BDK-002) and its tests (BDK-007) are done. What remains: +- `GRAPH_SCHEMA.md` spec doc (BDK-001) +- Node query/inspection API: `getNodes()`, `getNode()`, `hasNode()`, `getNodesByPrefix()` (BDK-003/004) +- `git mind nodes` command with `--prefix`, `--id`, `--json` flags (BDK-005/006) +- Node query test suite (BDK-008) + +### Milestone 2: INTAKE (34h) — Data Ingestion Pipeline + +**Depends on:** BEDROCK + +A YAML import pipeline with: +- Schema-validated ingestion (`version: 1` field required, unknown version = hard error) +- Idempotent merge semantics (re-import = safe; nodes merge props, edges update confidence) +- Reference validation (no dangling edges — all source/target must exist) +- Atomic writes (all-or-nothing via `createPatch()`) +- CLI: `git mind import graph.yaml --dry-run --validate --json` + +### Milestone 3: PRISM (30h) — Views & Value Surfaces + +**Depends on:** BEDROCK + +Refactors hardcoded views into a **declarative view engine** (config objects instead of filter functions). Then adds 4 new views: +- **milestone** — progress tracking (completion %, blockers) +- **traceability** — spec-to-implementation gap analysis +- **blockers** — transitive blocking chain resolution with cycle detection +- **onboarding** — topologically-sorted reading order for new engineers + +### Milestone 4: WATCHTOWER (18h) — Dashboard & Observability + +**Depends on:** BEDROCK, PRISM + +`git mind status` — a single command showing graph health: +- Total nodes and edges +- Nodes by prefix (count + percentage) +- Edges by type (count) +- Blocked items count +- Low-confidence edges (< 0.5) count +- Orphan nodes (0 edges) count +- `--json` output for CI pipelines + +### Milestone 5: PROVING GROUND (16h) — Dogfood Validation + +**Depends on:** All above + +Seeds the [Echo](https://github.com/neuroglyph/echo) ecosystem into git-mind and validates 5 specific project management questions can be answered via CLI in <60 seconds total: + +1. What blocks milestone M2? +2. Which ADRs lack implementation? +3. Which crates are unlinked to specs? +4. What should a new engineer read first? +5. What's low-confidence and needs review? + +### Milestone 6: ORACLE (40h) — AI Intelligence & Curation + +**Depends on:** PROVING GROUND + +- `git mind suggest --ai` — LLM-powered edge suggestions from code context +- `git mind review` — interactive accept/reject/adjust/skip workflow +- Review provenance stored in git-warp (decisions improve future suggestions) +- `git mind doctor` — integrity checks (dangling edges, orphan milestones, duplicates) +- Design principle: **humans curate, machines suggest.** AI never auto-commits. + +### Milestone 7: NEXUS (36h) — Integration & Federation + +**Depends on:** ORACLE + +- GitHub Action that posts edge suggestions on PRs +- Markdown frontmatter import (`git mind import --from-markdown`) +- Cross-repo edges with federation-qualified node IDs +- Multi-repo graph merge (additive, namespaced) +- Round-trip YAML/JSON export (`git mind export --format yaml|json`) + +### Dependency DAG + +``` +BEDROCK → INTAKE ──→ PROVING GROUND → ORACLE → NEXUS + ↘ PRISM ──↗ + ↘ WATCHTOWER ↗ +``` + +INTAKE, PRISM, and WATCHTOWER can proceed in parallel once BEDROCK is done. Everything converges at PROVING GROUND. + +### Backlog (Unscheduled) + +- Confidence decay over time (edges rot if not refreshed) +- View composition (combine multiple views) +- Graph diff between commits (`git mind diff HEAD~5..HEAD`) +- Mermaid diagram export +- GraphQL API for web frontends +- Real-time file watcher for automatic edge updates +- Git blame integration (who created this edge?) +- Edge provenance visualization + +--- + +## 10. Key Design Decisions + +| Decision | Choice | Why | +|----------|--------|-----| +| Storage backend | Git refs via git-warp CRDTs | No external DB, versioned with code, conflict-free | +| Node ID format | `prefix:identifier` | Enables prefix-based filtering and view matching | +| Prefix casing | Lowercase prefix, case-preserving identifier | `milestone:BEDROCK` not `Milestone:bedrock` | +| Edge uniqueness | `(source, target, type)` tuple | Re-adding updates, doesn't duplicate | +| Implicit nodes | Nodes created by edge references | Reduces ceremony; edges are the primary data | +| Import failure | All-or-nothing (atomic via `createPatch`) | No partial writes, validates everything first | +| AI suggestions | Never auto-commit | Humans maintain authority over the graph | +| Schema version | Required `version: 1`, unknown = hard error | Fail closed, not best-effort | +| CLI framework | None (manual argv) | Zero deps, full control | +| Validation style | Pure functions returning result objects | No throws; callers decide error handling | +| Unknown prefixes | Warning, not rejection | Allows organic growth; CI can enforce strictness | +| Convergence model | CRDTs guarantee state convergence; validation governs correctness | Explicit separation of concerns | + +--- + +## 11. What's Built vs. What's Planned + +**Built (v2.0.0-alpha.0):** +- Graph lifecycle (init, load, checkpoint) +- Edge CRUD with 8 types + confidence scoring +- 4 hardcoded observer views +- Commit directive parser (6 directive types) +- CLI with 7 working commands + 2 stubs +- Comprehensive validation layer +- 74 tests, CI green (GitHub Actions, Node 22 + 24) + +**Not yet built:** +- Node query layer (nodes are still implicit-only) +- YAML import pipeline +- Declarative view engine +- Status/dashboard command +- AI suggestions + interactive review +- GitHub Action integration +- Cross-repo federation +- Export command +- Prefix governance config (`.gitmind.yml`) +- Directive payload grammar (multi-target support) +- Checkpoint automation / patch compaction From e46a41dd958a48738944c6bee598b677dbb203b6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 11 Feb 2026 00:29:00 -0800 Subject: [PATCH 4/5] fix(import): address PR #188 review feedback (#187) - Reject YAML arrays in parseImportFile (typeof [] === 'object' pitfall) - Validate node.properties is a plain object, not an array - Rename edge createdAt to importedAt (honest re-import semantics) - Refactor edge required-field checks for cleaner control flow - Document --validate alias in CLI usage text --- CHANGELOG.md | 9 ++++++++- bin/git-mind.js | 2 +- src/import.js | 27 ++++++++++++++++++++------- test/import.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee60927c..fa64181e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,13 +22,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 + +- **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) +- **`--validate` alias documented in CLI help** — Usage text now shows `--dry-run, --validate` (#187) + ### Changed - **`blockedItems` now counts distinct blocked targets** — Previously counted `blocks` edges; now uses a `Set` on edge targets so two edges blocking the same node count as one blocked item (#185) - **`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** — 117 tests across 8 files (was 74) +- **Test count** — 121 tests across 8 files (was 74) ## [2.0.0-alpha.0] - 2026-02-07 diff --git a/bin/git-mind.js b/bin/git-mind.js index 93871ff7..01f42656 100755 --- a/bin/git-mind.js +++ b/bin/git-mind.js @@ -33,7 +33,7 @@ Commands: status Show graph health dashboard --json Output as JSON import Import a YAML graph file - --dry-run Validate without writing + --dry-run, --validate Validate without writing --json Output as JSON install-hooks Install post-commit Git hook suggest --ai AI suggestions (stub) diff --git a/src/import.js b/src/import.js index 590de73c..060bc71f 100644 --- a/src/import.js +++ b/src/import.js @@ -29,7 +29,7 @@ export async function parseImportFile(filePath) { try { const raw = await readFile(filePath, 'utf-8'); const data = yaml.load(raw); - if (data === null || data === undefined || typeof data !== 'object') { + if (data === null || data === undefined || typeof data !== 'object' || Array.isArray(data)) { return { data: null, parseError: 'YAML file is empty or not an object' }; } return { data, parseError: null }; @@ -85,6 +85,13 @@ export async function validateImportData(data, graph) { errors.push(`nodes[${i}]: ${v.error}`); continue; } + // Validate properties if provided + if (node.properties !== undefined && node.properties !== null) { + if (typeof node.properties !== 'object' || Array.isArray(node.properties)) { + errors.push(`nodes[${i}].properties: must be a plain object, not ${Array.isArray(node.properties) ? 'an array' : typeof node.properties}`); + } + } + // Prefix warning const prefix = extractPrefix(node.id); if (prefix && classifyPrefix(prefix) === 'unknown') { @@ -109,11 +116,17 @@ export async function validateImportData(data, graph) { continue; } - // Required fields - if (!edge.source) errors.push(`edges[${i}]: missing required field "source"`); - if (!edge.target) errors.push(`edges[${i}]: missing required field "target"`); - if (!edge.type) errors.push(`edges[${i}]: missing required field "type"`); - if (!edge.source || !edge.target || !edge.type) continue; + // Required fields — collect missing, skip further checks if any absent + const missing = []; + if (!edge.source) missing.push('source'); + if (!edge.target) missing.push('target'); + if (!edge.type) missing.push('type'); + if (missing.length > 0) { + for (const field of missing) { + errors.push(`edges[${i}]: missing required field "${field}"`); + } + continue; + } // Validate source/target node IDs const sv = validateNodeId(edge.source); @@ -185,7 +198,7 @@ async function writeImport(graph, data) { const confidence = edge.confidence ?? 1.0; patch.setEdgeProperty(edge.source, edge.target, edge.type, 'confidence', confidence); - patch.setEdgeProperty(edge.source, edge.target, edge.type, 'createdAt', new Date().toISOString()); + patch.setEdgeProperty(edge.source, edge.target, edge.type, 'importedAt', new Date().toISOString()); if (edge.rationale) { patch.setEdgeProperty(edge.source, edge.target, edge.type, 'rationale', edge.rationale); diff --git a/test/import.test.js b/test/import.test.js index 01a4bcd4..54a7ba35 100644 --- a/test/import.test.js +++ b/test/import.test.js @@ -61,6 +61,13 @@ nodes: expect(result.errors[0]).toMatch(/not an object/); }); + it('rejects YAML arrays', async () => { + const path = await writeYaml('bad.yaml', `- item1\n- item2`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/not an object/); + }); + it('reports file not found', async () => { const result = await importFile(graph, '/nonexistent/file.yaml'); expect(result.valid).toBe(false); @@ -280,6 +287,20 @@ nodes: expect(props.get('priority')).toBe('high'); }); + it('rejects array-typed node properties', async () => { + const path = await writeYaml('bad.yaml', ` +version: 1 +nodes: + - id: "task:auth" + properties: + - "not" + - "a map" +`); + const result = await importFile(graph, path); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual(expect.stringMatching(/properties.*must be a plain object/)); + }); + it('handles version-only file with no nodes or edges', async () => { const path = await writeYaml('empty.yaml', `version: 1`); const result = await importFile(graph, path); @@ -341,6 +362,25 @@ edges: expect(nodes.length).toBe(2); expect(edges.length).toBe(1); }); + + it('uses importedAt instead of createdAt for edge timestamps', async () => { + const path = await writeYaml('graph.yaml', ` +version: 1 +nodes: + - id: "spec:auth" + - id: "file:auth.js" +edges: + - source: "file:auth.js" + target: "spec:auth" + type: "implements" +`); + await importFile(graph, path); + const edges1 = await graph.getEdges(); + const ts1 = edges1[0].props.importedAt; + + expect(ts1).toBeDefined(); + expect(edges1[0].props.createdAt).toBeUndefined(); + }); }); // ── Atomicity ───────────────────────────────────────────────── From 31bef6f73cf3076190a2e3af44383f1734b50266 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 11 Feb 2026 00:42:48 -0800 Subject: [PATCH 5/5] chore(test): remove unused imports and strengthen idempotency test (#187) - Remove unused parseImportFile/validateImportData imports from test - Verify importedAt is present after re-import in idempotency test --- test/import.test.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/import.test.js b/test/import.test.js index 54a7ba35..0ee76570 100644 --- a/test/import.test.js +++ b/test/import.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 { importFile, parseImportFile, validateImportData } from '../src/import.js'; +import { importFile } from '../src/import.js'; describe('import', () => { let tempDir; @@ -354,6 +354,9 @@ edges: `); // Import twice await importFile(graph, path); + const edges1 = await graph.getEdges(); + const ts1 = edges1[0].props.importedAt; + await importFile(graph, path); const nodes = await graph.getNodes(); @@ -361,6 +364,9 @@ edges: expect(nodes.length).toBe(2); expect(edges.length).toBe(1); + // importedAt should be refreshed on re-import + expect(edges[0].props.importedAt).toBeDefined(); + expect(ts1).toBeDefined(); }); it('uses importedAt instead of createdAt for edge timestamps', async () => {