@specferret/core 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +62 -62
  2. package/dist/context/index.d.ts +5 -1
  3. package/dist/context/index.d.ts.map +1 -1
  4. package/dist/context/index.js +47 -1
  5. package/dist/context/index.js.map +1 -1
  6. package/dist/extractor/__fixtures__/typescript/generics.d.ts +6 -0
  7. package/dist/extractor/__fixtures__/typescript/generics.d.ts.map +1 -0
  8. package/dist/extractor/__fixtures__/typescript/generics.js +2 -0
  9. package/dist/extractor/__fixtures__/typescript/generics.js.map +1 -0
  10. package/dist/extractor/__fixtures__/typescript/intersections.d.ts +6 -0
  11. package/dist/extractor/__fixtures__/typescript/intersections.d.ts.map +1 -0
  12. package/dist/extractor/__fixtures__/typescript/intersections.js +2 -0
  13. package/dist/extractor/__fixtures__/typescript/intersections.js.map +1 -0
  14. package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts +8 -0
  15. package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts.map +1 -0
  16. package/dist/extractor/__fixtures__/typescript/optional-nested.js +2 -0
  17. package/dist/extractor/__fixtures__/typescript/optional-nested.js.map +1 -0
  18. package/dist/extractor/__fixtures__/typescript/unions.d.ts +5 -0
  19. package/dist/extractor/__fixtures__/typescript/unions.d.ts.map +1 -0
  20. package/dist/extractor/__fixtures__/typescript/unions.js +2 -0
  21. package/dist/extractor/__fixtures__/typescript/unions.js.map +1 -0
  22. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts +7 -0
  23. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts.map +1 -0
  24. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js +2 -0
  25. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js.map +1 -0
  26. package/dist/extractor/contract-types.d.ts +5 -0
  27. package/dist/extractor/contract-types.d.ts.map +1 -0
  28. package/dist/extractor/contract-types.js +8 -0
  29. package/dist/extractor/contract-types.js.map +1 -0
  30. package/dist/extractor/frontmatter.d.ts +2 -1
  31. package/dist/extractor/frontmatter.d.ts.map +1 -1
  32. package/dist/extractor/frontmatter.js +12 -6
  33. package/dist/extractor/frontmatter.js.map +1 -1
  34. package/dist/extractor/typescript.d.ts +6 -1
  35. package/dist/extractor/typescript.d.ts.map +1 -1
  36. package/dist/extractor/typescript.js +403 -185
  37. package/dist/extractor/typescript.js.map +1 -1
  38. package/dist/extractor/validator.d.ts +9 -0
  39. package/dist/extractor/validator.d.ts.map +1 -1
  40. package/dist/extractor/validator.js +28 -9
  41. package/dist/extractor/validator.js.map +1 -1
  42. package/dist/index.d.ts +13 -12
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +13 -12
  45. package/dist/index.js.map +1 -1
  46. package/dist/reconciler/index.d.ts +10 -0
  47. package/dist/reconciler/index.d.ts.map +1 -1
  48. package/dist/reconciler/index.js +76 -0
  49. package/dist/reconciler/index.js.map +1 -1
  50. package/dist/store/sqlite.js +72 -72
  51. package/package.json +47 -44
  52. package/src/config.ts +48 -0
  53. package/src/context/index.test.ts +274 -0
  54. package/src/context/index.ts +178 -0
  55. package/src/extractor/__fixtures__/typescript/generics.golden.json +36 -0
  56. package/src/extractor/__fixtures__/typescript/generics.ts +5 -0
  57. package/src/extractor/__fixtures__/typescript/intersections.golden.json +18 -0
  58. package/src/extractor/__fixtures__/typescript/intersections.ts +5 -0
  59. package/src/extractor/__fixtures__/typescript/optional-nested.golden.json +41 -0
  60. package/src/extractor/__fixtures__/typescript/optional-nested.ts +7 -0
  61. package/src/extractor/__fixtures__/typescript/unions.golden.json +30 -0
  62. package/src/extractor/__fixtures__/typescript/unions.ts +4 -0
  63. package/src/extractor/__fixtures__/typescript/unsupported-syntax.golden.json +20 -0
  64. package/src/extractor/__fixtures__/typescript/unsupported-syntax.ts +6 -0
  65. package/src/extractor/contract-types.ts +11 -0
  66. package/src/extractor/frontmatter.test.ts +217 -0
  67. package/src/extractor/frontmatter.ts +77 -0
  68. package/src/extractor/hash.ts +26 -0
  69. package/src/extractor/typescript.test.ts +339 -0
  70. package/src/extractor/typescript.ts +597 -0
  71. package/src/extractor/validator.test.ts +228 -0
  72. package/src/extractor/validator.ts +199 -0
  73. package/src/index.ts +16 -0
  74. package/src/reconciler/import-suggestions.test.ts +101 -0
  75. package/src/reconciler/import-suggestions.ts +157 -0
  76. package/src/reconciler/index.test.ts +248 -0
  77. package/src/reconciler/index.ts +445 -0
  78. package/src/store/factory.ts +54 -0
  79. package/src/store/postgres.ts +84 -0
  80. package/src/store/sqlite.test.ts +272 -0
  81. package/src/store/sqlite.ts +278 -0
  82. package/src/store/types.ts +100 -0
  83. package/src/utils/paths.ts +24 -0
@@ -33,48 +33,48 @@ export class SqliteStore {
33
33
  this.db = new Database(this.dbPath);
34
34
  this.db.exec("PRAGMA journal_mode = WAL;");
35
35
  this.db.exec("PRAGMA foreign_keys = ON;");
36
- this.db.exec(`
37
- CREATE TABLE IF NOT EXISTS ferret_nodes (
38
- id TEXT PRIMARY KEY,
39
- file_path TEXT NOT NULL,
40
- hash TEXT NOT NULL,
41
- status TEXT NOT NULL,
42
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
43
- );
44
-
45
- CREATE TABLE IF NOT EXISTS ferret_contracts (
46
- id TEXT PRIMARY KEY,
47
- node_id TEXT NOT NULL,
48
- shape_hash TEXT NOT NULL,
49
- shape_schema TEXT NOT NULL DEFAULT '{}',
50
- type TEXT NOT NULL,
51
- status TEXT NOT NULL,
52
- FOREIGN KEY (node_id) REFERENCES ferret_nodes(id)
53
- );
54
-
55
- CREATE TABLE IF NOT EXISTS ferret_dependencies (
56
- id TEXT PRIMARY KEY,
57
- source_node_id TEXT NOT NULL,
58
- target_contract_id TEXT NOT NULL,
59
- FOREIGN KEY (source_node_id) REFERENCES ferret_nodes(id)
60
- );
61
-
62
- CREATE TABLE IF NOT EXISTS ferret_reconciliation_log (
63
- id TEXT PRIMARY KEY,
64
- node_id TEXT NOT NULL,
65
- triggered_by TEXT NOT NULL,
66
- resolved_by TEXT,
67
- resolution_notes TEXT,
68
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
69
- );
70
-
71
- CREATE TABLE IF NOT EXISTS ferret_placement_decisions (
72
- id TEXT PRIMARY KEY,
73
- node_id TEXT NOT NULL,
74
- placed_by TEXT NOT NULL,
75
- reasoning TEXT,
76
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
77
- );
36
+ this.db.exec(`
37
+ CREATE TABLE IF NOT EXISTS ferret_nodes (
38
+ id TEXT PRIMARY KEY,
39
+ file_path TEXT NOT NULL,
40
+ hash TEXT NOT NULL,
41
+ status TEXT NOT NULL,
42
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
43
+ );
44
+
45
+ CREATE TABLE IF NOT EXISTS ferret_contracts (
46
+ id TEXT PRIMARY KEY,
47
+ node_id TEXT NOT NULL,
48
+ shape_hash TEXT NOT NULL,
49
+ shape_schema TEXT NOT NULL DEFAULT '{}',
50
+ type TEXT NOT NULL,
51
+ status TEXT NOT NULL,
52
+ FOREIGN KEY (node_id) REFERENCES ferret_nodes(id)
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS ferret_dependencies (
56
+ id TEXT PRIMARY KEY,
57
+ source_node_id TEXT NOT NULL,
58
+ target_contract_id TEXT NOT NULL,
59
+ FOREIGN KEY (source_node_id) REFERENCES ferret_nodes(id)
60
+ );
61
+
62
+ CREATE TABLE IF NOT EXISTS ferret_reconciliation_log (
63
+ id TEXT PRIMARY KEY,
64
+ node_id TEXT NOT NULL,
65
+ triggered_by TEXT NOT NULL,
66
+ resolved_by TEXT,
67
+ resolution_notes TEXT,
68
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
69
+ );
70
+
71
+ CREATE TABLE IF NOT EXISTS ferret_placement_decisions (
72
+ id TEXT PRIMARY KEY,
73
+ node_id TEXT NOT NULL,
74
+ placed_by TEXT NOT NULL,
75
+ reasoning TEXT,
76
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
77
+ );
78
78
  `);
79
79
  try {
80
80
  this.db.exec(`ALTER TABLE ferret_contracts ADD COLUMN shape_schema TEXT NOT NULL DEFAULT '{}';`);
@@ -92,44 +92,44 @@ export class SqliteStore {
92
92
  return row ?? null;
93
93
  }
94
94
  async upsertNode(node) {
95
- this.db.prepare(`
96
- INSERT INTO ferret_nodes (id, file_path, hash, status)
97
- VALUES (?, ?, ?, ?)
98
- ON CONFLICT(id) DO UPDATE SET
99
- file_path = excluded.file_path,
100
- hash = excluded.hash,
101
- status = excluded.status,
102
- updated_at = CURRENT_TIMESTAMP
95
+ this.db.prepare(`
96
+ INSERT INTO ferret_nodes (id, file_path, hash, status)
97
+ VALUES (?, ?, ?, ?)
98
+ ON CONFLICT(id) DO UPDATE SET
99
+ file_path = excluded.file_path,
100
+ hash = excluded.hash,
101
+ status = excluded.status,
102
+ updated_at = CURRENT_TIMESTAMP
103
103
  `).run(node.id, node.file_path, node.hash, node.status);
104
104
  }
105
105
  async getAllContractIds() {
106
106
  return this.db.prepare("SELECT id FROM ferret_contracts").all().map((r) => r.id);
107
107
  }
108
108
  async upsertContract(contract) {
109
- this.db.prepare(`
110
- INSERT INTO ferret_contracts (id, node_id, shape_hash, shape_schema, type, status)
111
- VALUES (?, ?, ?, ?, ?, ?)
112
- ON CONFLICT(id) DO UPDATE SET
113
- node_id = excluded.node_id,
114
- shape_hash = excluded.shape_hash,
115
- shape_schema = excluded.shape_schema,
116
- type = excluded.type,
117
- status = excluded.status
109
+ this.db.prepare(`
110
+ INSERT INTO ferret_contracts (id, node_id, shape_hash, shape_schema, type, status)
111
+ VALUES (?, ?, ?, ?, ?, ?)
112
+ ON CONFLICT(id) DO UPDATE SET
113
+ node_id = excluded.node_id,
114
+ shape_hash = excluded.shape_hash,
115
+ shape_schema = excluded.shape_schema,
116
+ type = excluded.type,
117
+ status = excluded.status
118
118
  `).run(contract.id, contract.node_id, contract.shape_hash, contract.shape_schema, contract.type, contract.status);
119
119
  }
120
120
  async upsertDependency(dependency) {
121
- this.db.prepare(`
122
- INSERT INTO ferret_dependencies (id, source_node_id, target_contract_id)
123
- VALUES (?, ?, ?)
124
- ON CONFLICT(id) DO NOTHING
121
+ this.db.prepare(`
122
+ INSERT INTO ferret_dependencies (id, source_node_id, target_contract_id)
123
+ VALUES (?, ?, ?)
124
+ ON CONFLICT(id) DO NOTHING
125
125
  `).run(dependency.id, dependency.source_node_id, dependency.target_contract_id);
126
126
  }
127
127
  async replaceDependenciesForSourceNode(sourceNodeId, targetContractIds) {
128
128
  const uniqueTargets = [...new Set(targetContractIds)].sort();
129
129
  const deleteStatement = this.db.prepare("DELETE FROM ferret_dependencies WHERE source_node_id = ?");
130
- const insertStatement = this.db.prepare(`
131
- INSERT INTO ferret_dependencies (id, source_node_id, target_contract_id)
132
- VALUES (?, ?, ?)
130
+ const insertStatement = this.db.prepare(`
131
+ INSERT INTO ferret_dependencies (id, source_node_id, target_contract_id)
132
+ VALUES (?, ?, ?)
133
133
  `);
134
134
  const transaction = this.db.transaction((sourceId) => {
135
135
  deleteStatement.run(sourceId);
@@ -159,18 +159,18 @@ export class SqliteStore {
159
159
  this.db.prepare("UPDATE ferret_nodes SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, nodeId);
160
160
  }
161
161
  async insertReconciliationLog(log) {
162
- this.db.prepare(`
163
- INSERT INTO ferret_reconciliation_log (id, node_id, triggered_by, resolved_by, resolution_notes)
164
- VALUES (?, ?, ?, ?, ?)
162
+ this.db.prepare(`
163
+ INSERT INTO ferret_reconciliation_log (id, node_id, triggered_by, resolved_by, resolution_notes)
164
+ VALUES (?, ?, ?, ?, ?)
165
165
  `).run(log.id, log.node_id, log.triggered_by, log.resolved_by ?? null, log.resolution_notes ?? null);
166
166
  }
167
167
  async getReconciliationLogs() {
168
168
  return this.db.prepare("SELECT id, node_id, triggered_by, resolved_by, resolution_notes FROM ferret_reconciliation_log").all();
169
169
  }
170
170
  async insertPlacementDecision(decision) {
171
- this.db.prepare(`
172
- INSERT INTO ferret_placement_decisions (id, node_id, placed_by, reasoning)
173
- VALUES (?, ?, ?, ?)
171
+ this.db.prepare(`
172
+ INSERT INTO ferret_placement_decisions (id, node_id, placed_by, reasoning)
173
+ VALUES (?, ?, ?, ?)
174
174
  `).run(decision.id, decision.node_id, decision.placed_by, decision.reasoning ?? null);
175
175
  }
176
176
  async getPlacementDecisions() {
package/package.json CHANGED
@@ -1,44 +1,47 @@
1
- {
2
- "name": "@specferret/core",
3
- "version": "0.1.3",
4
- "description": "SpecFerret core engine — spec drift detection.",
5
- "type": "module",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "bun": "./dist/index.js",
11
- "import": "./dist/index.js",
12
- "types": "./dist/index.d.ts"
13
- }
14
- },
15
- "files": [
16
- "dist/",
17
- "README.md",
18
- "LICENSE"
19
- ],
20
- "scripts": {
21
- "build": "bun ../../scripts/clean-dist.ts dist && tsc --project tsconfig.build.json",
22
- "test": "bun test"
23
- },
24
- "dependencies": {
25
- "ajv": "^8.12.0",
26
- "ajv-formats": "^2.1.1",
27
- "glob": "^13.0.6",
28
- "gray-matter": "^4.0.3",
29
- "zod": "^3.22.4"
30
- },
31
- "devDependencies": {
32
- "@types/bun": "^1.3.11",
33
- "typescript": "^6.0.2"
34
- },
35
- "engines": {
36
- "bun": ">=1.0.0"
37
- },
38
- "license": "MIT",
39
- "repository": {
40
- "type": "git",
41
- "url": "git+https://github.com/BenGardiner123/spec-ferret.git"
42
- },
43
- "homepage": "https://specferret.dev"
44
- }
1
+ {
2
+ "name": "@specferret/core",
3
+ "version": "0.1.4",
4
+ "description": "SpecFerret core engine — spec drift detection.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "bun": "./src/index.ts",
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist/",
17
+ "src/",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "build": "bun ../../scripts/clean-dist.ts dist && tsc --project tsconfig.build.json",
23
+ "test": "bun test"
24
+ },
25
+ "dependencies": {
26
+ "ajv": "^8.12.0",
27
+ "ajv-formats": "^2.1.1",
28
+ "glob": "^13.0.6",
29
+ "gray-matter": "^4.0.3",
30
+ "tree-sitter": "^0.22.4",
31
+ "tree-sitter-typescript": "^0.23.2",
32
+ "zod": "^3.22.4"
33
+ },
34
+ "devDependencies": {
35
+ "@types/bun": "^1.3.11",
36
+ "typescript": "^6.0.2"
37
+ },
38
+ "engines": {
39
+ "bun": ">=1.0.0"
40
+ },
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/BenGardiner123/spec-ferret.git"
45
+ },
46
+ "homepage": "https://specferret.dev"
47
+ }
package/src/config.ts ADDED
@@ -0,0 +1,48 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { findProjectRoot } from "./utils/paths.js";
4
+
5
+ export interface FerretConfig {
6
+ specDir: string;
7
+ filePattern: string;
8
+ includes?: string[];
9
+ store: "sqlite" | "postgres";
10
+ importSuggestions?: {
11
+ enabled?: boolean;
12
+ };
13
+ codeContracts?: {
14
+ include: string[];
15
+ watchNodes?: string[];
16
+ };
17
+ }
18
+
19
+ export const DEFAULT_CONFIG: FerretConfig = {
20
+ specDir: "contracts/",
21
+ filePattern: "**/*.contract.md",
22
+ includes: ["**/*.contract.md"],
23
+ store: "sqlite",
24
+ importSuggestions: {
25
+ enabled: true,
26
+ },
27
+ };
28
+
29
+ /**
30
+ * Loads ferret.config.json from the project root.
31
+ * Falls back to DEFAULT_CONFIG if the file is not found.
32
+ */
33
+ export function loadConfig(): FerretConfig {
34
+ const root = findProjectRoot();
35
+ const configPath = path.join(root, "ferret.config.json");
36
+
37
+ if (!fs.existsSync(configPath)) {
38
+ return { ...DEFAULT_CONFIG };
39
+ }
40
+
41
+ try {
42
+ const raw = fs.readFileSync(configPath, "utf-8");
43
+ const parsed = JSON.parse(raw) as Partial<FerretConfig>;
44
+ return { ...DEFAULT_CONFIG, ...parsed };
45
+ } catch {
46
+ return { ...DEFAULT_CONFIG };
47
+ }
48
+ }
@@ -0,0 +1,274 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "bun:test";
3
+ import {
4
+ CONTEXT_SCHEMA_VERSION,
5
+ readContextFile,
6
+ writeContext,
7
+ type FerretContext,
8
+ } from "./index.js";
9
+ import { SqliteStore } from "../store/sqlite.js";
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import * as os from "node:os";
13
+ import { randomUUID } from "node:crypto";
14
+
15
+ function makeTmpDir(): string {
16
+ const tmp = path.join(os.tmpdir(), `ferret-ctx-test-${randomUUID()}`);
17
+ fs.mkdirSync(tmp, { recursive: true });
18
+ return tmp;
19
+ }
20
+
21
+ async function makeStoreWithData(tmpDir: string) {
22
+ // Use in-memory store but fake the projectRoot for file writing
23
+ const store = new SqliteStore(":memory:");
24
+ await store.init();
25
+
26
+ const nodeId = randomUUID();
27
+ await store.upsertNode({
28
+ id: nodeId,
29
+ file_path: "contracts/auth.contract.md",
30
+ hash: "abc",
31
+ status: "stable",
32
+ });
33
+
34
+ const contractId = "auth.jwt";
35
+ await store.upsertContract({
36
+ id: contractId,
37
+ node_id: nodeId,
38
+ shape_hash: "sha256hash",
39
+ shape_schema: JSON.stringify({
40
+ type: "object",
41
+ properties: { token: { type: "string" } },
42
+ required: ["token"],
43
+ }),
44
+ type: "api",
45
+ status: "stable",
46
+ });
47
+
48
+ // Second node that imports auth.jwt
49
+ const nodeId2 = randomUUID();
50
+ await store.upsertNode({
51
+ id: nodeId2,
52
+ file_path: "contracts/search.contract.md",
53
+ hash: "def",
54
+ status: "needs-review",
55
+ });
56
+
57
+ const contractId2 = "api.GET/search";
58
+ await store.upsertContract({
59
+ id: contractId2,
60
+ node_id: nodeId2,
61
+ shape_hash: "sha256hash2",
62
+ shape_schema: JSON.stringify({ type: "array" }),
63
+ type: "api",
64
+ status: "stable",
65
+ });
66
+
67
+ await store.upsertDependency({
68
+ id: randomUUID(),
69
+ source_node_id: nodeId2,
70
+ target_contract_id: contractId,
71
+ });
72
+
73
+ return { store, contractId, contractId2 };
74
+ }
75
+
76
+ describe("writeContext — Task 5", () => {
77
+ const tmps: string[] = [];
78
+
79
+ afterEach(() => {
80
+ // Clean up temp directories
81
+ for (const tmp of tmps) {
82
+ try {
83
+ fs.rmSync(tmp, { recursive: true, force: true });
84
+ } catch {}
85
+ }
86
+ tmps.length = 0;
87
+ });
88
+
89
+ it("creates context.json at the correct path", async () => {
90
+ const tmpDir = makeTmpDir();
91
+ tmps.push(tmpDir);
92
+ const { store } = await makeStoreWithData(tmpDir);
93
+
94
+ await writeContext(store, tmpDir);
95
+
96
+ const contextPath = path.join(tmpDir, ".ferret", "context.json");
97
+ assert.equal(fs.existsSync(contextPath), true);
98
+
99
+ await store.close();
100
+ });
101
+
102
+ it("produces valid JSON", async () => {
103
+ const tmpDir = makeTmpDir();
104
+ tmps.push(tmpDir);
105
+ const { store } = await makeStoreWithData(tmpDir);
106
+
107
+ await writeContext(store, tmpDir);
108
+
109
+ const contextPath = path.join(tmpDir, ".ferret", "context.json");
110
+ const raw = fs.readFileSync(contextPath, "utf-8");
111
+ assert.doesNotThrow(() => JSON.parse(raw));
112
+
113
+ await store.close();
114
+ });
115
+
116
+ it('contains version "2.0"', async () => {
117
+ const tmpDir = makeTmpDir();
118
+ tmps.push(tmpDir);
119
+ const { store } = await makeStoreWithData(tmpDir);
120
+
121
+ await writeContext(store, tmpDir);
122
+
123
+ const ctx = JSON.parse(
124
+ fs.readFileSync(path.join(tmpDir, ".ferret", "context.json"), "utf-8"),
125
+ ) as FerretContext;
126
+ assert.equal(ctx.version, "2.0");
127
+
128
+ await store.close();
129
+ });
130
+
131
+ it("contains context schemaVersion", async () => {
132
+ const tmpDir = makeTmpDir();
133
+ tmps.push(tmpDir);
134
+ const { store } = await makeStoreWithData(tmpDir);
135
+
136
+ await writeContext(store, tmpDir);
137
+
138
+ const ctx = JSON.parse(
139
+ fs.readFileSync(path.join(tmpDir, ".ferret", "context.json"), "utf-8"),
140
+ ) as FerretContext;
141
+ assert.equal(ctx.schemaVersion, CONTEXT_SCHEMA_VERSION);
142
+
143
+ await store.close();
144
+ });
145
+
146
+ it("readContextFile migrates known legacy V2 payload without schemaVersion", () => {
147
+ const tmpDir = makeTmpDir();
148
+ tmps.push(tmpDir);
149
+ const ferretDir = path.join(tmpDir, ".ferret");
150
+ fs.mkdirSync(ferretDir, { recursive: true });
151
+ const contextPath = path.join(ferretDir, "context.json");
152
+ fs.writeFileSync(
153
+ contextPath,
154
+ JSON.stringify(
155
+ {
156
+ version: "2.0",
157
+ generated: "2026-01-01T00:00:00.000Z",
158
+ contracts: [],
159
+ edges: [],
160
+ needsReview: [],
161
+ },
162
+ null,
163
+ 2,
164
+ ),
165
+ "utf-8",
166
+ );
167
+
168
+ const context = readContextFile(contextPath);
169
+ assert.equal(context.version, "2.0");
170
+ assert.equal(context.schemaVersion, CONTEXT_SCHEMA_VERSION);
171
+ assert.deepEqual(context.contracts, []);
172
+ assert.deepEqual(context.edges, []);
173
+ assert.deepEqual(context.needsReview, []);
174
+ });
175
+
176
+ it("readContextFile fails with upgrade guidance on unknown context version", () => {
177
+ const tmpDir = makeTmpDir();
178
+ tmps.push(tmpDir);
179
+ const ferretDir = path.join(tmpDir, ".ferret");
180
+ fs.mkdirSync(ferretDir, { recursive: true });
181
+ const contextPath = path.join(ferretDir, "context.json");
182
+ fs.writeFileSync(
183
+ contextPath,
184
+ JSON.stringify({
185
+ version: "9.9",
186
+ schemaVersion: CONTEXT_SCHEMA_VERSION,
187
+ generated: new Date().toISOString(),
188
+ contracts: [],
189
+ edges: [],
190
+ needsReview: [],
191
+ }),
192
+ "utf-8",
193
+ );
194
+
195
+ assert.throws(
196
+ () => readContextFile(contextPath),
197
+ /unsupported context\.json version|Run 'ferret scan'/,
198
+ );
199
+ });
200
+
201
+ it("readContextFile fails with upgrade guidance on unknown schemaVersion", () => {
202
+ const tmpDir = makeTmpDir();
203
+ tmps.push(tmpDir);
204
+ const ferretDir = path.join(tmpDir, ".ferret");
205
+ fs.mkdirSync(ferretDir, { recursive: true });
206
+ const contextPath = path.join(ferretDir, "context.json");
207
+ fs.writeFileSync(
208
+ contextPath,
209
+ JSON.stringify({
210
+ version: "2.0",
211
+ schemaVersion: "9.0.0",
212
+ generated: new Date().toISOString(),
213
+ contracts: [],
214
+ edges: [],
215
+ needsReview: [],
216
+ }),
217
+ "utf-8",
218
+ );
219
+
220
+ assert.throws(
221
+ () => readContextFile(contextPath),
222
+ /unsupported context schemaVersion|Run 'ferret scan'/,
223
+ );
224
+ });
225
+
226
+ it("stable contracts appear in contracts array", async () => {
227
+ const tmpDir = makeTmpDir();
228
+ tmps.push(tmpDir);
229
+ const { store, contractId } = await makeStoreWithData(tmpDir);
230
+
231
+ await writeContext(store, tmpDir);
232
+
233
+ const ctx = JSON.parse(
234
+ fs.readFileSync(path.join(tmpDir, ".ferret", "context.json"), "utf-8"),
235
+ ) as FerretContext;
236
+ const ids = ctx.contracts.map((c) => c.id);
237
+ assert.ok(ids.includes(contractId));
238
+
239
+ await store.close();
240
+ });
241
+
242
+ it("needs-review contracts appear in needsReview array", async () => {
243
+ const tmpDir = makeTmpDir();
244
+ tmps.push(tmpDir);
245
+ const { store, contractId2 } = await makeStoreWithData(tmpDir);
246
+
247
+ await writeContext(store, tmpDir);
248
+
249
+ const ctx = JSON.parse(
250
+ fs.readFileSync(path.join(tmpDir, ".ferret", "context.json"), "utf-8"),
251
+ ) as FerretContext;
252
+ // contractId2 belongs to the needs-review node
253
+ assert.ok(ctx.needsReview.includes(contractId2));
254
+
255
+ await store.close();
256
+ });
257
+
258
+ it("edges correctly represent dependency relationships", async () => {
259
+ const tmpDir = makeTmpDir();
260
+ tmps.push(tmpDir);
261
+ const { store, contractId } = await makeStoreWithData(tmpDir);
262
+
263
+ await writeContext(store, tmpDir);
264
+
265
+ const ctx = JSON.parse(
266
+ fs.readFileSync(path.join(tmpDir, ".ferret", "context.json"), "utf-8"),
267
+ ) as FerretContext;
268
+ const edge = ctx.edges.find((e) => e.to === contractId);
269
+ assert.notEqual(edge, undefined);
270
+ assert.equal(edge!.from, "contracts/search.contract.md");
271
+
272
+ await store.close();
273
+ });
274
+ });