@specferret/core 0.1.3 → 0.2.0
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 +6 -1
- package/dist/extractor/frontmatter.d.ts.map +1 -1
- package/dist/extractor/frontmatter.js +17 -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/upward-classifier.d.ts +25 -0
- package/dist/extractor/upward-classifier.d.ts.map +1 -0
- package/dist/extractor/upward-classifier.js +48 -0
- package/dist/extractor/upward-classifier.js.map +1 -0
- 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 +14 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -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.d.ts +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +106 -101
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/types.d.ts +6 -2
- package/dist/store/types.d.ts.map +1 -1
- 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 +291 -0
- package/src/extractor/frontmatter.ts +87 -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/upward-classifier.test.ts +188 -0
- package/src/extractor/upward-classifier.ts +68 -0
- package/src/extractor/validator.test.ts +228 -0
- package/src/extractor/validator.ts +199 -0
- package/src/index.ts +17 -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 +328 -0
- package/src/store/sqlite.ts +231 -0
- package/src/store/types.ts +101 -0
- package/src/utils/paths.ts +24 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'bun:test';
|
|
3
|
+
import { SqliteStore } from './sqlite.js';
|
|
4
|
+
import type { FerretNode, FerretContract } from './types.js';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
// Use in-memory SQLite for all tests — fast, isolated, no disk cleanup needed
|
|
8
|
+
function makeStore() {
|
|
9
|
+
return new SqliteStore(':memory:');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeNode(overrides: Partial<FerretNode> = {}): FerretNode {
|
|
13
|
+
return {
|
|
14
|
+
id: randomUUID(),
|
|
15
|
+
file_path: 'contracts/test.contract.md',
|
|
16
|
+
hash: 'abc123',
|
|
17
|
+
status: 'stable',
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeContract(nodeId: string, overrides: Partial<FerretContract> = {}): FerretContract {
|
|
23
|
+
return {
|
|
24
|
+
id: `api.GET/test-${randomUUID()}`,
|
|
25
|
+
node_id: nodeId,
|
|
26
|
+
shape_hash: 'sha256hashvalue',
|
|
27
|
+
shape_schema: JSON.stringify({
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: { id: { type: 'string' } },
|
|
30
|
+
required: ['id'],
|
|
31
|
+
}),
|
|
32
|
+
type: 'api',
|
|
33
|
+
status: 'stable',
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('SqliteStore — Task 1: shape_schema field', () => {
|
|
39
|
+
it('upserts a contract with shape_schema field', async () => {
|
|
40
|
+
const store = makeStore();
|
|
41
|
+
await store.init();
|
|
42
|
+
|
|
43
|
+
const node = makeNode();
|
|
44
|
+
await store.upsertNode(node);
|
|
45
|
+
|
|
46
|
+
const contract = makeContract(node.id, {
|
|
47
|
+
shape_schema: JSON.stringify({
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: { name: { type: 'string' } },
|
|
50
|
+
required: ['name'],
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
await store.upsertContract(contract);
|
|
54
|
+
|
|
55
|
+
const all = await store.getContracts();
|
|
56
|
+
assert.equal(all.length, 1);
|
|
57
|
+
assert.equal(all[0].shape_schema, contract.shape_schema);
|
|
58
|
+
|
|
59
|
+
await store.close();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('retrieves a contract with shape_schema field intact', async () => {
|
|
63
|
+
const store = makeStore();
|
|
64
|
+
await store.init();
|
|
65
|
+
|
|
66
|
+
const node = makeNode();
|
|
67
|
+
await store.upsertNode(node);
|
|
68
|
+
|
|
69
|
+
const schema = {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: { email: { type: 'string', format: 'email' } },
|
|
72
|
+
required: ['email'],
|
|
73
|
+
};
|
|
74
|
+
const contract = makeContract(node.id, {
|
|
75
|
+
shape_schema: JSON.stringify(schema),
|
|
76
|
+
});
|
|
77
|
+
await store.upsertContract(contract);
|
|
78
|
+
|
|
79
|
+
const retrieved = await store.getContract(contract.id);
|
|
80
|
+
assert.notEqual(retrieved, null);
|
|
81
|
+
assert.equal(retrieved!.shape_schema, JSON.stringify(schema));
|
|
82
|
+
assert.deepEqual(JSON.parse(retrieved!.shape_schema), schema);
|
|
83
|
+
|
|
84
|
+
await store.close();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('upserts (updates) a contract — shape_schema is overwritten correctly', async () => {
|
|
88
|
+
const store = makeStore();
|
|
89
|
+
await store.init();
|
|
90
|
+
|
|
91
|
+
const node = makeNode();
|
|
92
|
+
await store.upsertNode(node);
|
|
93
|
+
|
|
94
|
+
const contract = makeContract(node.id, {
|
|
95
|
+
shape_schema: JSON.stringify({ type: 'string' }),
|
|
96
|
+
});
|
|
97
|
+
await store.upsertContract(contract);
|
|
98
|
+
|
|
99
|
+
const updatedSchema = JSON.stringify({
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: { id: { type: 'string' } },
|
|
102
|
+
});
|
|
103
|
+
await store.upsertContract({ ...contract, shape_schema: updatedSchema });
|
|
104
|
+
|
|
105
|
+
const retrieved = await store.getContract(contract.id);
|
|
106
|
+
assert.equal(retrieved!.shape_schema, updatedSchema);
|
|
107
|
+
|
|
108
|
+
await store.close();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('migration: ALTER TABLE runs cleanly on an existing database that lacks shape_schema', async () => {
|
|
112
|
+
// Simulate a pre-migration DB by manually creating the table without shape_schema
|
|
113
|
+
// then calling init() which should run the migration without throwing
|
|
114
|
+
const store = makeStore();
|
|
115
|
+
// init() contains the migration; calling it twice should be idempotent
|
|
116
|
+
await store.init();
|
|
117
|
+
await store.init(); // second call must not throw
|
|
118
|
+
await store.close();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('shape_schema defaults to empty JSON object string when not set explicitly', async () => {
|
|
122
|
+
const store = makeStore();
|
|
123
|
+
await store.init();
|
|
124
|
+
|
|
125
|
+
const node = makeNode();
|
|
126
|
+
await store.upsertNode(node);
|
|
127
|
+
|
|
128
|
+
// shape_schema has DEFAULT '{}' in the schema; pass it explicitly to match interface
|
|
129
|
+
const contract = makeContract(node.id, { shape_schema: '{}' });
|
|
130
|
+
await store.upsertContract(contract);
|
|
131
|
+
|
|
132
|
+
const retrieved = await store.getContract(contract.id);
|
|
133
|
+
assert.equal(retrieved!.shape_schema, '{}');
|
|
134
|
+
|
|
135
|
+
await store.close();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('SqliteStore — existing store functionality still passes', () => {
|
|
140
|
+
it('upserts and retrieves a node by file path', async () => {
|
|
141
|
+
const store = makeStore();
|
|
142
|
+
await store.init();
|
|
143
|
+
|
|
144
|
+
const node = makeNode({ file_path: 'contracts/auth.contract.md' });
|
|
145
|
+
await store.upsertNode(node);
|
|
146
|
+
|
|
147
|
+
const retrieved = await store.getNodeByFilePath('contracts/auth.contract.md');
|
|
148
|
+
assert.notEqual(retrieved, null);
|
|
149
|
+
assert.equal(retrieved!.id, node.id);
|
|
150
|
+
assert.equal(retrieved!.hash, node.hash);
|
|
151
|
+
|
|
152
|
+
await store.close();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns null for unknown file path', async () => {
|
|
156
|
+
const store = makeStore();
|
|
157
|
+
await store.init();
|
|
158
|
+
const result = await store.getNodeByFilePath('contracts/nonexistent.contract.md');
|
|
159
|
+
assert.equal(result, null);
|
|
160
|
+
await store.close();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('getAllContractIds returns all contract IDs', async () => {
|
|
164
|
+
const store = makeStore();
|
|
165
|
+
await store.init();
|
|
166
|
+
|
|
167
|
+
const node = makeNode();
|
|
168
|
+
await store.upsertNode(node);
|
|
169
|
+
const c1 = makeContract(node.id, { id: 'api.GET/one' });
|
|
170
|
+
const c2 = makeContract(node.id, { id: 'api.GET/two' });
|
|
171
|
+
await store.upsertContract(c1);
|
|
172
|
+
await store.upsertContract(c2);
|
|
173
|
+
|
|
174
|
+
const ids = await store.getAllContractIds();
|
|
175
|
+
assert.ok(ids.includes('api.GET/one'));
|
|
176
|
+
assert.ok(ids.includes('api.GET/two'));
|
|
177
|
+
|
|
178
|
+
await store.close();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('updateNodeStatus changes node status', async () => {
|
|
182
|
+
const store = makeStore();
|
|
183
|
+
await store.init();
|
|
184
|
+
|
|
185
|
+
const node = makeNode({ status: 'stable' });
|
|
186
|
+
await store.upsertNode(node);
|
|
187
|
+
await store.updateNodeStatus(node.id, 'needs-review');
|
|
188
|
+
|
|
189
|
+
const nodes = await store.getNodesByStatus('needs-review');
|
|
190
|
+
assert.equal(
|
|
191
|
+
nodes.some((n) => n.id === node.id),
|
|
192
|
+
true,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
await store.close();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('upsertDependency and getDependencies work correctly', async () => {
|
|
199
|
+
const store = makeStore();
|
|
200
|
+
await store.init();
|
|
201
|
+
|
|
202
|
+
const nodeA = makeNode({
|
|
203
|
+
id: 'node-a',
|
|
204
|
+
file_path: 'contracts/a.contract.md',
|
|
205
|
+
});
|
|
206
|
+
const nodeB = makeNode({
|
|
207
|
+
id: 'node-b',
|
|
208
|
+
file_path: 'contracts/b.contract.md',
|
|
209
|
+
});
|
|
210
|
+
await store.upsertNode(nodeA);
|
|
211
|
+
await store.upsertNode(nodeB);
|
|
212
|
+
|
|
213
|
+
const contract = makeContract(nodeA.id, { id: 'api.GET/shared' });
|
|
214
|
+
await store.upsertContract(contract);
|
|
215
|
+
|
|
216
|
+
await store.upsertDependency({
|
|
217
|
+
id: randomUUID(),
|
|
218
|
+
source_node_id: nodeB.id,
|
|
219
|
+
target_contract_id: contract.id,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const deps = await store.getDependencies();
|
|
223
|
+
assert.equal(
|
|
224
|
+
deps.some((d) => d.source_node_id === nodeB.id && d.target_contract_id === contract.id),
|
|
225
|
+
true,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
await store.close();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('replaceDependenciesForSourceNode replaces stale edges and deduplicates targets', async () => {
|
|
232
|
+
const store = makeStore();
|
|
233
|
+
await store.init();
|
|
234
|
+
|
|
235
|
+
const nodeA = makeNode({
|
|
236
|
+
id: 'node-a',
|
|
237
|
+
file_path: 'contracts/a.contract.md',
|
|
238
|
+
});
|
|
239
|
+
await store.upsertNode(nodeA);
|
|
240
|
+
|
|
241
|
+
await store.replaceDependenciesForSourceNode(nodeA.id, ['api.GET/one', 'api.GET/one', 'api.GET/two']);
|
|
242
|
+
|
|
243
|
+
let deps = await store.getDependencies();
|
|
244
|
+
assert.equal(deps.length, 2);
|
|
245
|
+
assert.deepEqual(deps.map((dependency) => dependency.target_contract_id).sort(), ['api.GET/one', 'api.GET/two']);
|
|
246
|
+
|
|
247
|
+
await store.replaceDependenciesForSourceNode(nodeA.id, ['api.GET/two']);
|
|
248
|
+
|
|
249
|
+
deps = await store.getDependencies();
|
|
250
|
+
assert.equal(deps.length, 1);
|
|
251
|
+
assert.equal(deps[0].target_contract_id, 'api.GET/two');
|
|
252
|
+
|
|
253
|
+
await store.close();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('SqliteStore — S50: code_source_file and code_source_symbol', () => {
|
|
258
|
+
it('persists code_source_file and code_source_symbol when provided', async () => {
|
|
259
|
+
const store = makeStore();
|
|
260
|
+
await store.init();
|
|
261
|
+
|
|
262
|
+
const node = makeNode();
|
|
263
|
+
await store.upsertNode(node);
|
|
264
|
+
|
|
265
|
+
const contract = makeContract(node.id, {
|
|
266
|
+
code_source_file: 'src/auth/jwt.ts',
|
|
267
|
+
code_source_symbol: 'JwtPayload',
|
|
268
|
+
});
|
|
269
|
+
await store.upsertContract(contract);
|
|
270
|
+
|
|
271
|
+
const retrieved = await store.getContract(contract.id);
|
|
272
|
+
assert.equal(retrieved!.code_source_file, 'src/auth/jwt.ts');
|
|
273
|
+
assert.equal(retrieved!.code_source_symbol, 'JwtPayload');
|
|
274
|
+
|
|
275
|
+
await store.close();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('stores null when code_source_file and code_source_symbol are absent', async () => {
|
|
279
|
+
const store = makeStore();
|
|
280
|
+
await store.init();
|
|
281
|
+
|
|
282
|
+
const node = makeNode();
|
|
283
|
+
await store.upsertNode(node);
|
|
284
|
+
|
|
285
|
+
const contract = makeContract(node.id);
|
|
286
|
+
await store.upsertContract(contract);
|
|
287
|
+
|
|
288
|
+
const retrieved = await store.getContract(contract.id);
|
|
289
|
+
// SQLite returns null for missing TEXT columns
|
|
290
|
+
assert.ok(retrieved!.code_source_file == null);
|
|
291
|
+
assert.ok(retrieved!.code_source_symbol == null);
|
|
292
|
+
|
|
293
|
+
await store.close();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('overwrites code_source_file on upsert', async () => {
|
|
297
|
+
const store = makeStore();
|
|
298
|
+
await store.init();
|
|
299
|
+
|
|
300
|
+
const node = makeNode();
|
|
301
|
+
await store.upsertNode(node);
|
|
302
|
+
|
|
303
|
+
const contract = makeContract(node.id, {
|
|
304
|
+
code_source_file: 'src/old.ts',
|
|
305
|
+
code_source_symbol: 'OldSymbol',
|
|
306
|
+
});
|
|
307
|
+
await store.upsertContract(contract);
|
|
308
|
+
|
|
309
|
+
await store.upsertContract({
|
|
310
|
+
...contract,
|
|
311
|
+
code_source_file: 'src/new.ts',
|
|
312
|
+
code_source_symbol: 'NewSymbol',
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const retrieved = await store.getContract(contract.id);
|
|
316
|
+
assert.equal(retrieved!.code_source_file, 'src/new.ts');
|
|
317
|
+
assert.equal(retrieved!.code_source_symbol, 'NewSymbol');
|
|
318
|
+
|
|
319
|
+
await store.close();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('migration for code_source columns is idempotent (double init does not throw)', async () => {
|
|
323
|
+
const store = makeStore();
|
|
324
|
+
await store.init();
|
|
325
|
+
await store.init();
|
|
326
|
+
await store.close();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { findProjectRoot } from '../utils/paths.js';
|
|
6
|
+
import type { DBStore, FerretNode, FerretContract, FerretDependency, FerretReconciliationLog, FerretPlacementDecision, NodeStatus } from './types.js';
|
|
7
|
+
|
|
8
|
+
export class SqliteStore implements DBStore {
|
|
9
|
+
private db: Database | null = null;
|
|
10
|
+
private dbPath: string;
|
|
11
|
+
|
|
12
|
+
constructor(customPath?: string) {
|
|
13
|
+
const root = findProjectRoot();
|
|
14
|
+
const defaultPath = path.join(root, '.ferret', 'graph.db');
|
|
15
|
+
this.dbPath = customPath === ':memory:' ? ':memory:' : customPath ? path.resolve(root, customPath) : defaultPath;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async init(): Promise<void> {
|
|
19
|
+
if (this.db) return;
|
|
20
|
+
|
|
21
|
+
if (this.dbPath !== ':memory:') {
|
|
22
|
+
const ferretDir = path.dirname(this.dbPath);
|
|
23
|
+
if (!fs.existsSync(ferretDir)) {
|
|
24
|
+
fs.mkdirSync(ferretDir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
const gitignorePath = path.join(ferretDir, '.gitignore');
|
|
27
|
+
try {
|
|
28
|
+
fs.writeFileSync(gitignorePath, '*\n!.gitignore\n!context.json\n', 'utf-8');
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.db = new Database(this.dbPath);
|
|
33
|
+
this.db.exec('PRAGMA journal_mode = WAL;');
|
|
34
|
+
this.db.exec('PRAGMA foreign_keys = ON;');
|
|
35
|
+
|
|
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
|
+
`);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
this.db.exec(`ALTER TABLE ferret_contracts ADD COLUMN shape_schema TEXT NOT NULL DEFAULT '{}';`);
|
|
82
|
+
} catch {}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
this.db.exec(`ALTER TABLE ferret_contracts ADD COLUMN code_source_file TEXT;`);
|
|
86
|
+
} catch {}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
this.db.exec(`ALTER TABLE ferret_contracts ADD COLUMN code_source_symbol TEXT;`);
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async close(): Promise<void> {
|
|
94
|
+
if (this.db) {
|
|
95
|
+
this.db.close();
|
|
96
|
+
this.db = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async getNodeByFilePath(filePath: string): Promise<FerretNode | null> {
|
|
101
|
+
const row = this.db!.prepare('SELECT * FROM ferret_nodes WHERE file_path = ?').get(filePath) as any;
|
|
102
|
+
return row ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async upsertNode(node: FerretNode): Promise<void> {
|
|
106
|
+
this.db!.prepare(
|
|
107
|
+
`
|
|
108
|
+
INSERT INTO ferret_nodes (id, file_path, hash, status)
|
|
109
|
+
VALUES (?, ?, ?, ?)
|
|
110
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
111
|
+
file_path = excluded.file_path,
|
|
112
|
+
hash = excluded.hash,
|
|
113
|
+
status = excluded.status,
|
|
114
|
+
updated_at = CURRENT_TIMESTAMP
|
|
115
|
+
`,
|
|
116
|
+
).run(node.id, node.file_path, node.hash, node.status);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async getAllContractIds(): Promise<string[]> {
|
|
120
|
+
return (this.db!.prepare('SELECT id FROM ferret_contracts').all() as any[]).map((r) => r.id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async upsertContract(contract: FerretContract): Promise<void> {
|
|
124
|
+
this.db!.prepare(
|
|
125
|
+
`
|
|
126
|
+
INSERT INTO ferret_contracts (id, node_id, shape_hash, shape_schema, type, status, code_source_file, code_source_symbol)
|
|
127
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
128
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
129
|
+
node_id = excluded.node_id,
|
|
130
|
+
shape_hash = excluded.shape_hash,
|
|
131
|
+
shape_schema = excluded.shape_schema,
|
|
132
|
+
type = excluded.type,
|
|
133
|
+
status = excluded.status,
|
|
134
|
+
code_source_file = excluded.code_source_file,
|
|
135
|
+
code_source_symbol = excluded.code_source_symbol
|
|
136
|
+
`,
|
|
137
|
+
).run(
|
|
138
|
+
contract.id,
|
|
139
|
+
contract.node_id,
|
|
140
|
+
contract.shape_hash,
|
|
141
|
+
contract.shape_schema,
|
|
142
|
+
contract.type,
|
|
143
|
+
contract.status,
|
|
144
|
+
contract.code_source_file ?? null,
|
|
145
|
+
contract.code_source_symbol ?? null,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async upsertDependency(dependency: FerretDependency): Promise<void> {
|
|
150
|
+
this.db!.prepare(
|
|
151
|
+
`
|
|
152
|
+
INSERT INTO ferret_dependencies (id, source_node_id, target_contract_id)
|
|
153
|
+
VALUES (?, ?, ?)
|
|
154
|
+
ON CONFLICT(id) DO NOTHING
|
|
155
|
+
`,
|
|
156
|
+
).run(dependency.id, dependency.source_node_id, dependency.target_contract_id);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async replaceDependenciesForSourceNode(sourceNodeId: string, targetContractIds: string[]): Promise<void> {
|
|
160
|
+
const uniqueTargets = [...new Set(targetContractIds)].sort();
|
|
161
|
+
const deleteStatement = this.db!.prepare('DELETE FROM ferret_dependencies WHERE source_node_id = ?');
|
|
162
|
+
const insertStatement = this.db!.prepare(
|
|
163
|
+
`
|
|
164
|
+
INSERT INTO ferret_dependencies (id, source_node_id, target_contract_id)
|
|
165
|
+
VALUES (?, ?, ?)
|
|
166
|
+
`,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const transaction = this.db!.transaction((sourceId: string) => {
|
|
170
|
+
deleteStatement.run(sourceId);
|
|
171
|
+
for (const targetId of uniqueTargets) {
|
|
172
|
+
insertStatement.run(randomUUID(), sourceId, targetId);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
transaction(sourceNodeId);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async getNodes(): Promise<FerretNode[]> {
|
|
180
|
+
return this.db!.prepare('SELECT * FROM ferret_nodes').all() as unknown as FerretNode[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async getNodesByStatus(status: NodeStatus): Promise<FerretNode[]> {
|
|
184
|
+
return this.db!.prepare('SELECT * FROM ferret_nodes WHERE status = ?').all(status) as unknown as FerretNode[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async getContracts(): Promise<FerretContract[]> {
|
|
188
|
+
return this.db!.prepare('SELECT * FROM ferret_contracts').all() as unknown as FerretContract[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async getContract(id: string): Promise<FerretContract | null> {
|
|
192
|
+
const row = this.db!.prepare('SELECT * FROM ferret_contracts WHERE id = ?').get(id) as any;
|
|
193
|
+
return row ?? null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async getDependencies(): Promise<FerretDependency[]> {
|
|
197
|
+
return this.db!.prepare('SELECT * FROM ferret_dependencies').all() as unknown as FerretDependency[];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async updateNodeStatus(nodeId: string, status: NodeStatus): Promise<void> {
|
|
201
|
+
this.db!.prepare('UPDATE ferret_nodes SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, nodeId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async insertReconciliationLog(log: FerretReconciliationLog): Promise<void> {
|
|
205
|
+
this.db!.prepare(
|
|
206
|
+
`
|
|
207
|
+
INSERT INTO ferret_reconciliation_log (id, node_id, triggered_by, resolved_by, resolution_notes)
|
|
208
|
+
VALUES (?, ?, ?, ?, ?)
|
|
209
|
+
`,
|
|
210
|
+
).run(log.id, log.node_id, log.triggered_by, log.resolved_by ?? null, log.resolution_notes ?? null);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async getReconciliationLogs(): Promise<FerretReconciliationLog[]> {
|
|
214
|
+
return this.db!.prepare(
|
|
215
|
+
'SELECT id, node_id, triggered_by, resolved_by, resolution_notes FROM ferret_reconciliation_log',
|
|
216
|
+
).all() as unknown as FerretReconciliationLog[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async insertPlacementDecision(decision: FerretPlacementDecision): Promise<void> {
|
|
220
|
+
this.db!.prepare(
|
|
221
|
+
`
|
|
222
|
+
INSERT INTO ferret_placement_decisions (id, node_id, placed_by, reasoning)
|
|
223
|
+
VALUES (?, ?, ?, ?)
|
|
224
|
+
`,
|
|
225
|
+
).run(decision.id, decision.node_id, decision.placed_by, decision.reasoning ?? null);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async getPlacementDecisions(): Promise<FerretPlacementDecision[]> {
|
|
229
|
+
return this.db!.prepare('SELECT id, node_id, placed_by, reasoning FROM ferret_placement_decisions').all() as unknown as FerretPlacementDecision[];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export type NodeStatus = 'stable' | 'needs-review' | 'roadmap' | 'blocked';
|
|
2
|
+
export type ContractStatus = 'stable' | 'roadmap' | 'needs-review';
|
|
3
|
+
|
|
4
|
+
export interface FerretNode {
|
|
5
|
+
id: string;
|
|
6
|
+
file_path: string;
|
|
7
|
+
hash: string;
|
|
8
|
+
status: NodeStatus;
|
|
9
|
+
updated_at?: string | Date; // Dependent on SQLite (string) vs Postgres (Date) nuances, keep flexible for mapping.
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FerretContract {
|
|
13
|
+
id: string;
|
|
14
|
+
node_id: string;
|
|
15
|
+
shape_hash: string;
|
|
16
|
+
shape_schema: string;
|
|
17
|
+
type: string;
|
|
18
|
+
status: ContractStatus;
|
|
19
|
+
/** Path to the TypeScript file this contract was generated from (code-first contracts only). */
|
|
20
|
+
code_source_file?: string;
|
|
21
|
+
/** TypeScript symbol name this contract was generated from (code-first contracts only). */
|
|
22
|
+
code_source_symbol?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FerretDependency {
|
|
26
|
+
id: string;
|
|
27
|
+
source_node_id: string;
|
|
28
|
+
target_contract_id: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FerretReconciliationLog {
|
|
32
|
+
id: string;
|
|
33
|
+
node_id: string;
|
|
34
|
+
triggered_by: string;
|
|
35
|
+
resolved_by?: string;
|
|
36
|
+
resolution_notes?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FerretPlacementDecision {
|
|
40
|
+
id: string;
|
|
41
|
+
node_id: string;
|
|
42
|
+
placed_by: string;
|
|
43
|
+
reasoning?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FerretMigrationSnapshot {
|
|
47
|
+
nodes: FerretNode[];
|
|
48
|
+
contracts: FerretContract[];
|
|
49
|
+
dependencies: FerretDependency[];
|
|
50
|
+
reconciliationLogs: FerretReconciliationLog[];
|
|
51
|
+
placementDecisions: FerretPlacementDecision[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The standard contract for our Data Access Layer (DAL).
|
|
56
|
+
* All implementers (SQLite, Postgres) MUST satisfy this completely so the CLI
|
|
57
|
+
* can remain ignorant to the underlying storage type.
|
|
58
|
+
*/
|
|
59
|
+
export interface DBStore {
|
|
60
|
+
/** Core initialisation & teardown */
|
|
61
|
+
init(): Promise<void>;
|
|
62
|
+
close(): Promise<void>;
|
|
63
|
+
|
|
64
|
+
/** Retrieves a node based on its unique file path relative to specDir */
|
|
65
|
+
getNodeByFilePath(filePath: string): Promise<FerretNode | null>;
|
|
66
|
+
|
|
67
|
+
/** Upserts a node based on its ID */
|
|
68
|
+
upsertNode(node: FerretNode): Promise<void>;
|
|
69
|
+
|
|
70
|
+
/** Retrieves all existing contract IDs for LLM context injection */
|
|
71
|
+
getAllContractIds(): Promise<string[]>;
|
|
72
|
+
|
|
73
|
+
/** Upserts an extracted contract */
|
|
74
|
+
upsertContract(contract: FerretContract): Promise<void>;
|
|
75
|
+
|
|
76
|
+
/** Creates a dependency edge between a Node and a Contract */
|
|
77
|
+
upsertDependency(dependency: FerretDependency): Promise<void>;
|
|
78
|
+
|
|
79
|
+
/** Replaces all dependency edges for a source node with the provided targets */
|
|
80
|
+
replaceDependenciesForSourceNode(sourceNodeId: string, targetContractIds: string[]): Promise<void>;
|
|
81
|
+
|
|
82
|
+
/** Full graph retrieval methods for the Reconciler engine */
|
|
83
|
+
getNodes(): Promise<FerretNode[]>;
|
|
84
|
+
getNodesByStatus(status: NodeStatus): Promise<FerretNode[]>;
|
|
85
|
+
getContracts(): Promise<FerretContract[]>;
|
|
86
|
+
getContract(id: string): Promise<FerretContract | null>;
|
|
87
|
+
getDependencies(): Promise<FerretDependency[]>;
|
|
88
|
+
|
|
89
|
+
/** Update helper for the Reconciler to safely flag nodes */
|
|
90
|
+
updateNodeStatus(nodeId: string, status: NodeStatus): Promise<void>;
|
|
91
|
+
|
|
92
|
+
/** Records review actions against flagged nodes S013 */
|
|
93
|
+
insertReconciliationLog(log: FerretReconciliationLog): Promise<void>;
|
|
94
|
+
|
|
95
|
+
getReconciliationLogs(): Promise<FerretReconciliationLog[]>;
|
|
96
|
+
|
|
97
|
+
/** Tracks feature placement decisions S016 */
|
|
98
|
+
insertPlacementDecision(decision: FerretPlacementDecision): Promise<void>;
|
|
99
|
+
|
|
100
|
+
getPlacementDecisions(): Promise<FerretPlacementDecision[]>;
|
|
101
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Walks up from cwd looking for .ferret/ or ferret.config.json.
|
|
6
|
+
* Falls back to cwd if neither is found.
|
|
7
|
+
*/
|
|
8
|
+
export function findProjectRoot(startDir: string = process.cwd()): string {
|
|
9
|
+
let current = path.resolve(startDir);
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < 20; i++) {
|
|
12
|
+
if (
|
|
13
|
+
fs.existsSync(path.join(current, '.ferret')) ||
|
|
14
|
+
fs.existsSync(path.join(current, 'ferret.config.json'))
|
|
15
|
+
) {
|
|
16
|
+
return current;
|
|
17
|
+
}
|
|
18
|
+
const parent = path.dirname(current);
|
|
19
|
+
if (parent === current) break; // filesystem root
|
|
20
|
+
current = parent;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return path.resolve(startDir);
|
|
24
|
+
}
|