@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.
- package/README.md +62 -62
- package/dist/context/index.d.ts +5 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +47 -1
- package/dist/context/index.js.map +1 -1
- package/dist/extractor/__fixtures__/typescript/generics.d.ts +6 -0
- package/dist/extractor/__fixtures__/typescript/generics.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/generics.js +2 -0
- package/dist/extractor/__fixtures__/typescript/generics.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/intersections.d.ts +6 -0
- package/dist/extractor/__fixtures__/typescript/intersections.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/intersections.js +2 -0
- package/dist/extractor/__fixtures__/typescript/intersections.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts +8 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.js +2 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unions.d.ts +5 -0
- package/dist/extractor/__fixtures__/typescript/unions.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unions.js +2 -0
- package/dist/extractor/__fixtures__/typescript/unions.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts +7 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js +2 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js.map +1 -0
- package/dist/extractor/contract-types.d.ts +5 -0
- package/dist/extractor/contract-types.d.ts.map +1 -0
- package/dist/extractor/contract-types.js +8 -0
- package/dist/extractor/contract-types.js.map +1 -0
- package/dist/extractor/frontmatter.d.ts +2 -1
- package/dist/extractor/frontmatter.d.ts.map +1 -1
- package/dist/extractor/frontmatter.js +12 -6
- package/dist/extractor/frontmatter.js.map +1 -1
- package/dist/extractor/typescript.d.ts +6 -1
- package/dist/extractor/typescript.d.ts.map +1 -1
- package/dist/extractor/typescript.js +403 -185
- package/dist/extractor/typescript.js.map +1 -1
- package/dist/extractor/validator.d.ts +9 -0
- package/dist/extractor/validator.d.ts.map +1 -1
- package/dist/extractor/validator.js +28 -9
- package/dist/extractor/validator.js.map +1 -1
- package/dist/index.d.ts +13 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -12
- package/dist/index.js.map +1 -1
- package/dist/reconciler/index.d.ts +10 -0
- package/dist/reconciler/index.d.ts.map +1 -1
- package/dist/reconciler/index.js +76 -0
- package/dist/reconciler/index.js.map +1 -1
- package/dist/store/sqlite.js +72 -72
- package/package.json +47 -44
- package/src/config.ts +48 -0
- package/src/context/index.test.ts +274 -0
- package/src/context/index.ts +178 -0
- package/src/extractor/__fixtures__/typescript/generics.golden.json +36 -0
- package/src/extractor/__fixtures__/typescript/generics.ts +5 -0
- package/src/extractor/__fixtures__/typescript/intersections.golden.json +18 -0
- package/src/extractor/__fixtures__/typescript/intersections.ts +5 -0
- package/src/extractor/__fixtures__/typescript/optional-nested.golden.json +41 -0
- package/src/extractor/__fixtures__/typescript/optional-nested.ts +7 -0
- package/src/extractor/__fixtures__/typescript/unions.golden.json +30 -0
- package/src/extractor/__fixtures__/typescript/unions.ts +4 -0
- package/src/extractor/__fixtures__/typescript/unsupported-syntax.golden.json +20 -0
- package/src/extractor/__fixtures__/typescript/unsupported-syntax.ts +6 -0
- package/src/extractor/contract-types.ts +11 -0
- package/src/extractor/frontmatter.test.ts +217 -0
- package/src/extractor/frontmatter.ts +77 -0
- package/src/extractor/hash.ts +26 -0
- package/src/extractor/typescript.test.ts +339 -0
- package/src/extractor/typescript.ts +597 -0
- package/src/extractor/validator.test.ts +228 -0
- package/src/extractor/validator.ts +199 -0
- package/src/index.ts +16 -0
- package/src/reconciler/import-suggestions.test.ts +101 -0
- package/src/reconciler/import-suggestions.ts +157 -0
- package/src/reconciler/index.test.ts +248 -0
- package/src/reconciler/index.ts +445 -0
- package/src/store/factory.ts +54 -0
- package/src/store/postgres.ts +84 -0
- package/src/store/sqlite.test.ts +272 -0
- package/src/store/sqlite.ts +278 -0
- package/src/store/types.ts +100 -0
- package/src/utils/paths.ts +24 -0
package/dist/store/sqlite.js
CHANGED
|
@@ -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.
|
|
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": "./
|
|
11
|
-
"import": "./dist/index.js",
|
|
12
|
-
"types": "./dist/index.d.ts"
|
|
13
|
-
}
|
|
14
|
-
},
|
|
15
|
-
"files": [
|
|
16
|
-
"dist/",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"ajv
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
},
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
});
|