@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
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DBStore,
|
|
3
|
+
FerretNode,
|
|
4
|
+
FerretContract,
|
|
5
|
+
FerretDependency,
|
|
6
|
+
} from "../store/types.js";
|
|
7
|
+
import {
|
|
8
|
+
ImportSuggestion,
|
|
9
|
+
suggestMissingImports,
|
|
10
|
+
} from "./import-suggestions.js";
|
|
11
|
+
|
|
12
|
+
export interface UnresolvedImportViolation {
|
|
13
|
+
contractId: string;
|
|
14
|
+
filePath: string;
|
|
15
|
+
importPath: string;
|
|
16
|
+
expectedTargetId: string;
|
|
17
|
+
transitiveChain?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SelfImportViolation {
|
|
21
|
+
contractId: string;
|
|
22
|
+
filePath: string;
|
|
23
|
+
importPath: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CircularImportViolation {
|
|
27
|
+
contractId: string;
|
|
28
|
+
filePath: string;
|
|
29
|
+
importPath: string;
|
|
30
|
+
cycle: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OrphanedContractViolation {
|
|
34
|
+
contractId: string;
|
|
35
|
+
filePath: string;
|
|
36
|
+
unresolvedImports: string[];
|
|
37
|
+
remediationHint: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ImportIntegrityReport {
|
|
41
|
+
unresolvedImports: UnresolvedImportViolation[];
|
|
42
|
+
selfImports: SelfImportViolation[];
|
|
43
|
+
circularImports: CircularImportViolation[];
|
|
44
|
+
orphanedContracts: OrphanedContractViolation[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ReconcileReport {
|
|
48
|
+
consistent: boolean;
|
|
49
|
+
flagged: Array<{
|
|
50
|
+
nodeId: string;
|
|
51
|
+
filePath: string;
|
|
52
|
+
triggeredByContractId: string;
|
|
53
|
+
impact: "direct" | "transitive";
|
|
54
|
+
depth: number;
|
|
55
|
+
}>;
|
|
56
|
+
integrityViolations: ImportIntegrityReport;
|
|
57
|
+
importSuggestions: ImportSuggestion[];
|
|
58
|
+
timestamp: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The Reconciler engine (Phase 3).
|
|
63
|
+
* It calculates the downstream impact of graph shape changes. Since resolving recursive graphs
|
|
64
|
+
* can be heavily database dependent, we execute an Application-level Breadth-First Search (BFS)
|
|
65
|
+
* to maintain 100% parity across SQLite and PostgreSQL effortlessly and stay extremely fast.
|
|
66
|
+
*/
|
|
67
|
+
export class Reconciler {
|
|
68
|
+
constructor(private store: DBStore) {}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Identifies completely unhandled ripples and propagates them up to 10 hops (S011)
|
|
72
|
+
*/
|
|
73
|
+
async reconcile(): Promise<ReconcileReport> {
|
|
74
|
+
const nodes = await this.store.getNodes();
|
|
75
|
+
const contracts = await this.store.getContracts();
|
|
76
|
+
const dependencies = await this.store.getDependencies();
|
|
77
|
+
|
|
78
|
+
const integrityViolations = this.validateImportIntegrity(
|
|
79
|
+
nodes,
|
|
80
|
+
contracts,
|
|
81
|
+
dependencies,
|
|
82
|
+
);
|
|
83
|
+
const hasIntegrityViolations =
|
|
84
|
+
integrityViolations.unresolvedImports.length > 0 ||
|
|
85
|
+
integrityViolations.selfImports.length > 0 ||
|
|
86
|
+
integrityViolations.circularImports.length > 0;
|
|
87
|
+
|
|
88
|
+
if (hasIntegrityViolations) {
|
|
89
|
+
return {
|
|
90
|
+
consistent: false,
|
|
91
|
+
flagged: [],
|
|
92
|
+
integrityViolations,
|
|
93
|
+
importSuggestions: [],
|
|
94
|
+
timestamp: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const importSuggestions = suggestMissingImports(
|
|
99
|
+
nodes,
|
|
100
|
+
contracts,
|
|
101
|
+
dependencies,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
105
|
+
const contractMap = new Map(contracts.map((c) => [c.id, c]));
|
|
106
|
+
|
|
107
|
+
// 1. Identify "changed/trigger" contracts.
|
|
108
|
+
// In our simplified engine approach, any contract attached to a Node that is "needs-review"
|
|
109
|
+
// acts as a signal for propagation, OR any specifically updated shapes.
|
|
110
|
+
// For this prototype implementation, we'll traverse starting from specifically flagged nodes.
|
|
111
|
+
const triggerContracts = contracts.filter((c) => {
|
|
112
|
+
const parentNode = nodeMap.get(c.node_id);
|
|
113
|
+
return parentNode && parentNode.status === "needs-review";
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const flaggedNodes: ReconcileReport["flagged"] = [];
|
|
117
|
+
|
|
118
|
+
// BFS Queue: [contractId, depth]
|
|
119
|
+
// S011 explicitly mandates capping transitive impact at 10 hops.
|
|
120
|
+
const queue: Array<[string, number]> = triggerContracts.map((c) => [
|
|
121
|
+
c.id,
|
|
122
|
+
1,
|
|
123
|
+
]);
|
|
124
|
+
const visitedContracts = new Set<string>();
|
|
125
|
+
|
|
126
|
+
while (queue.length > 0) {
|
|
127
|
+
const [contractId, depth] = queue.shift()!;
|
|
128
|
+
if (visitedContracts.has(contractId) || depth > 10) continue;
|
|
129
|
+
visitedContracts.add(contractId);
|
|
130
|
+
|
|
131
|
+
// Find nodes that import this contract
|
|
132
|
+
const dependentEdges = dependencies.filter(
|
|
133
|
+
(d) => d.target_contract_id === contractId,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
for (const edge of dependentEdges) {
|
|
137
|
+
const dependentNodeId = edge.source_node_id;
|
|
138
|
+
const dependentNode = nodeMap.get(dependentNodeId);
|
|
139
|
+
|
|
140
|
+
if (!dependentNode) continue;
|
|
141
|
+
|
|
142
|
+
// Skip nodes that are already reviewing or roadmap, per S011 instructions.
|
|
143
|
+
if (
|
|
144
|
+
dependentNode.status === "needs-review" ||
|
|
145
|
+
dependentNode.status === "roadmap"
|
|
146
|
+
) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Flag the node natively
|
|
151
|
+
await this.store.updateNodeStatus(dependentNode.id, "needs-review");
|
|
152
|
+
dependentNode.status = "needs-review"; // Update internal ref mapping
|
|
153
|
+
|
|
154
|
+
flaggedNodes.push({
|
|
155
|
+
nodeId: dependentNode.id,
|
|
156
|
+
filePath: dependentNode.file_path,
|
|
157
|
+
triggeredByContractId: contractId,
|
|
158
|
+
impact: depth === 1 ? "direct" : "transitive",
|
|
159
|
+
depth,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Enqueue cascading contracts exported by the now-flagged dependent node
|
|
163
|
+
const cascadingContracts = contracts.filter(
|
|
164
|
+
(c) => c.node_id === dependentNode.id,
|
|
165
|
+
);
|
|
166
|
+
for (const cContract of cascadingContracts) {
|
|
167
|
+
queue.push([cContract.id, depth + 1]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// S012: graph is consistent when no nodes need review and all nodes are stable or roadmap.
|
|
173
|
+
// Roadmap nodes are planned-but-not-yet-built and are an acceptable stable state.
|
|
174
|
+
return {
|
|
175
|
+
consistent:
|
|
176
|
+
flaggedNodes.length === 0 &&
|
|
177
|
+
nodes.every((n) => n.status === "stable" || n.status === "roadmap"),
|
|
178
|
+
flagged: flaggedNodes,
|
|
179
|
+
integrityViolations,
|
|
180
|
+
importSuggestions,
|
|
181
|
+
timestamp: new Date().toISOString(),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private validateImportIntegrity(
|
|
186
|
+
nodes: FerretNode[],
|
|
187
|
+
contracts: FerretContract[],
|
|
188
|
+
dependencies: FerretDependency[],
|
|
189
|
+
): ImportIntegrityReport {
|
|
190
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
191
|
+
const contractById = new Map(
|
|
192
|
+
contracts.map((contract) => [contract.id, contract]),
|
|
193
|
+
);
|
|
194
|
+
const contractsByNodeId = new Map<string, FerretContract[]>();
|
|
195
|
+
|
|
196
|
+
for (const contract of contracts) {
|
|
197
|
+
const existing = contractsByNodeId.get(contract.node_id) ?? [];
|
|
198
|
+
existing.push(contract);
|
|
199
|
+
contractsByNodeId.set(contract.node_id, existing);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const dependencyKeys = new Set<string>();
|
|
203
|
+
const uniqueDependencies = dependencies.filter((dependency) => {
|
|
204
|
+
const key = `${dependency.source_node_id}->${dependency.target_contract_id}`;
|
|
205
|
+
if (dependencyKeys.has(key)) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
dependencyKeys.add(key);
|
|
209
|
+
return true;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const unresolvedImports: UnresolvedImportViolation[] = [];
|
|
213
|
+
const selfImports: SelfImportViolation[] = [];
|
|
214
|
+
const adjacency = new Map<string, Set<string>>();
|
|
215
|
+
const importsBySourceContractId = new Map<string, string[]>();
|
|
216
|
+
|
|
217
|
+
for (const contract of contracts) {
|
|
218
|
+
adjacency.set(contract.id, new Set<string>());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const dependency of uniqueDependencies) {
|
|
222
|
+
const sourceNode = nodeById.get(dependency.source_node_id);
|
|
223
|
+
const sourceContracts =
|
|
224
|
+
contractsByNodeId.get(dependency.source_node_id) ?? [];
|
|
225
|
+
const targetContract = contractById.get(dependency.target_contract_id);
|
|
226
|
+
|
|
227
|
+
if (!sourceNode || sourceContracts.length === 0) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const sourceContract of sourceContracts) {
|
|
232
|
+
const existing = importsBySourceContractId.get(sourceContract.id) ?? [];
|
|
233
|
+
existing.push(dependency.target_contract_id);
|
|
234
|
+
importsBySourceContractId.set(sourceContract.id, existing);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!targetContract) {
|
|
238
|
+
for (const sourceContract of sourceContracts) {
|
|
239
|
+
unresolvedImports.push({
|
|
240
|
+
contractId: sourceContract.id,
|
|
241
|
+
filePath: sourceNode.file_path,
|
|
242
|
+
importPath: dependency.target_contract_id,
|
|
243
|
+
expectedTargetId: dependency.target_contract_id,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const selfImportingContracts = sourceContracts.filter(
|
|
250
|
+
(sourceContract) => sourceContract.id === dependency.target_contract_id,
|
|
251
|
+
);
|
|
252
|
+
for (const sourceContract of selfImportingContracts) {
|
|
253
|
+
selfImports.push({
|
|
254
|
+
contractId: sourceContract.id,
|
|
255
|
+
filePath: sourceNode.file_path,
|
|
256
|
+
importPath: dependency.target_contract_id,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const sourceContract of sourceContracts) {
|
|
261
|
+
if (sourceContract.id === dependency.target_contract_id) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
adjacency.get(sourceContract.id)?.add(dependency.target_contract_id);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const circularImports = this.findCircularImports(
|
|
269
|
+
adjacency,
|
|
270
|
+
contractById,
|
|
271
|
+
nodeById,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const reverseAdjacency = buildReverseAdjacency(adjacency);
|
|
275
|
+
for (const violation of unresolvedImports) {
|
|
276
|
+
const upstreamChain = this.findTransitiveChainToContract(
|
|
277
|
+
violation.contractId,
|
|
278
|
+
reverseAdjacency,
|
|
279
|
+
);
|
|
280
|
+
if (upstreamChain.length > 1) {
|
|
281
|
+
violation.transitiveChain = [...upstreamChain, violation.expectedTargetId];
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const orphanedContracts: OrphanedContractViolation[] = [];
|
|
286
|
+
for (const contract of contracts) {
|
|
287
|
+
const imports = importsBySourceContractId.get(contract.id) ?? [];
|
|
288
|
+
if (imports.length === 0) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const validResolvedCount = imports.filter((targetId) => {
|
|
293
|
+
if (targetId === contract.id) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
return contractById.has(targetId);
|
|
297
|
+
}).length;
|
|
298
|
+
|
|
299
|
+
const unresolvedForContract = imports.filter(
|
|
300
|
+
(targetId) => !contractById.has(targetId),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
if (validResolvedCount === 0 && unresolvedForContract.length > 0) {
|
|
304
|
+
const sourceNode = nodeById.get(contract.node_id);
|
|
305
|
+
orphanedContracts.push({
|
|
306
|
+
contractId: contract.id,
|
|
307
|
+
filePath: sourceNode?.file_path ?? contract.id,
|
|
308
|
+
unresolvedImports: [...new Set(unresolvedForContract)].sort(),
|
|
309
|
+
remediationHint:
|
|
310
|
+
"Add or restore the expected target contract(s), or remove stale imports.",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
unresolvedImports,
|
|
317
|
+
selfImports,
|
|
318
|
+
circularImports,
|
|
319
|
+
orphanedContracts: orphanedContracts.sort((left, right) =>
|
|
320
|
+
left.contractId.localeCompare(right.contractId),
|
|
321
|
+
),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private findTransitiveChainToContract(
|
|
326
|
+
contractId: string,
|
|
327
|
+
reverseAdjacency: Map<string, Set<string>>,
|
|
328
|
+
): string[] {
|
|
329
|
+
const visit = (currentId: string, seen: Set<string>): string[] => {
|
|
330
|
+
const parents = [...(reverseAdjacency.get(currentId) ?? [])]
|
|
331
|
+
.filter((parentId) => !seen.has(parentId))
|
|
332
|
+
.sort();
|
|
333
|
+
if (parents.length === 0) {
|
|
334
|
+
return [currentId];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let bestPath = [currentId];
|
|
338
|
+
for (const parentId of parents) {
|
|
339
|
+
const nextSeen = new Set(seen);
|
|
340
|
+
nextSeen.add(parentId);
|
|
341
|
+
const parentPath = visit(parentId, nextSeen);
|
|
342
|
+
const candidate = [...parentPath, currentId];
|
|
343
|
+
if (
|
|
344
|
+
candidate.length > bestPath.length ||
|
|
345
|
+
(candidate.length === bestPath.length &&
|
|
346
|
+
candidate.join("->") < bestPath.join("->"))
|
|
347
|
+
) {
|
|
348
|
+
bestPath = candidate;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return bestPath;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
return visit(contractId, new Set([contractId]));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private findCircularImports(
|
|
359
|
+
adjacency: Map<string, Set<string>>,
|
|
360
|
+
contractById: Map<string, FerretContract>,
|
|
361
|
+
nodeById: Map<string, FerretNode>,
|
|
362
|
+
): CircularImportViolation[] {
|
|
363
|
+
const visited = new Set<string>();
|
|
364
|
+
const path: string[] = [];
|
|
365
|
+
const cycleMap = new Map<string, CircularImportViolation>();
|
|
366
|
+
const contractIds = [...adjacency.keys()].sort();
|
|
367
|
+
|
|
368
|
+
const visit = (contractId: string): void => {
|
|
369
|
+
visited.add(contractId);
|
|
370
|
+
path.push(contractId);
|
|
371
|
+
|
|
372
|
+
const neighbors = [...(adjacency.get(contractId) ?? [])].sort();
|
|
373
|
+
for (const neighborId of neighbors) {
|
|
374
|
+
const existingIndex = path.indexOf(neighborId);
|
|
375
|
+
if (existingIndex !== -1) {
|
|
376
|
+
const cycle = [...path.slice(existingIndex), neighborId];
|
|
377
|
+
const key = canonicalizeCycle(cycle);
|
|
378
|
+
if (!cycleMap.has(key)) {
|
|
379
|
+
const sourceContractId = cycle[0];
|
|
380
|
+
const sourceContract = contractById.get(sourceContractId);
|
|
381
|
+
const sourceNode = sourceContract
|
|
382
|
+
? nodeById.get(sourceContract.node_id)
|
|
383
|
+
: undefined;
|
|
384
|
+
cycleMap.set(key, {
|
|
385
|
+
contractId: sourceContractId,
|
|
386
|
+
filePath: sourceNode?.file_path ?? sourceContractId,
|
|
387
|
+
importPath: cycle.join(" -> "),
|
|
388
|
+
cycle,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!visited.has(neighborId)) {
|
|
395
|
+
visit(neighborId);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
path.pop();
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
for (const contractId of contractIds) {
|
|
403
|
+
if (!visited.has(contractId)) {
|
|
404
|
+
visit(contractId);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return [...cycleMap.values()].sort((left, right) =>
|
|
409
|
+
left.importPath.localeCompare(right.importPath),
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function buildReverseAdjacency(
|
|
415
|
+
adjacency: Map<string, Set<string>>,
|
|
416
|
+
): Map<string, Set<string>> {
|
|
417
|
+
const reverse = new Map<string, Set<string>>();
|
|
418
|
+
|
|
419
|
+
for (const [sourceId, targets] of adjacency) {
|
|
420
|
+
if (!reverse.has(sourceId)) {
|
|
421
|
+
reverse.set(sourceId, new Set<string>());
|
|
422
|
+
}
|
|
423
|
+
for (const targetId of targets) {
|
|
424
|
+
const parents = reverse.get(targetId) ?? new Set<string>();
|
|
425
|
+
parents.add(sourceId);
|
|
426
|
+
reverse.set(targetId, parents);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return reverse;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function canonicalizeCycle(cycle: string[]): string {
|
|
434
|
+
const ring = cycle.slice(0, -1);
|
|
435
|
+
if (ring.length === 0) {
|
|
436
|
+
return "";
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const rotations = ring.map((_, index) => {
|
|
440
|
+
const rotated = [...ring.slice(index), ...ring.slice(0, index)];
|
|
441
|
+
return `${rotated.join("->")}->${rotated[0]}`;
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
return rotations.sort()[0];
|
|
445
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { DBStore } from "./types.js";
|
|
4
|
+
import { SqliteStore } from "./sqlite.js";
|
|
5
|
+
import { PostgresStore } from "./postgres.js";
|
|
6
|
+
import { loadConfig } from "../config.js";
|
|
7
|
+
import { findProjectRoot } from "../utils/paths.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Evaluates the current environment and configuration (handling overrides from the CI pipeline, S005)
|
|
11
|
+
* and returns the correct initialized database engine.
|
|
12
|
+
*/
|
|
13
|
+
export async function getStore(): Promise<DBStore> {
|
|
14
|
+
// S005: If FERRET_DATABASE_URL is set in the environment, use PostgreSQL without prompts.
|
|
15
|
+
const envUrl = process.env.FERRET_DATABASE_URL;
|
|
16
|
+
if (envUrl) {
|
|
17
|
+
const store = new PostgresStore(envUrl);
|
|
18
|
+
// In production we should ensure init() was run by `ferret init`, but we do it gracefully here
|
|
19
|
+
// or rely on the user to run init. If this was just the runner, it shouldn't recreate tables.
|
|
20
|
+
// For now we assume the caller handles init if necessary, or we just return the instantiated store.
|
|
21
|
+
return store;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// At this point FERRET_DATABASE_URL is NOT set in the environment (we returned early above if it was).
|
|
25
|
+
// If the config explicitly opts into postgres, that means the user ran `ferret upgrade --postgres`
|
|
26
|
+
// which writes FERRET_DATABASE_URL to .env, but that .env has not been loaded into the environment.
|
|
27
|
+
// Fail fast with a clear error rather than silently falling through to SQLite.
|
|
28
|
+
try {
|
|
29
|
+
const config = await loadConfig();
|
|
30
|
+
if (config.store === "postgres") {
|
|
31
|
+
console.error("FATAL: ferret.config.json sets store to postgres but FERRET_DATABASE_URL is not set. Source your .env file or set the variable in your environment.");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Suppress config missing errors and fallback to logic
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// S005: If FERRET_DATABASE_URL is not set, and config is missing / local, fall back to SQLite
|
|
39
|
+
// Check if .ferret/graph.db exists relative to the discovered project root
|
|
40
|
+
const dbPath = path.join(findProjectRoot(), ".ferret", "graph.db");
|
|
41
|
+
try {
|
|
42
|
+
await fs.access(dbPath);
|
|
43
|
+
return new SqliteStore(); // Implicit fallback to SQLite
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// CI Mode checking:
|
|
46
|
+
if (process.env.FERRET_CI === "true") {
|
|
47
|
+
console.error("FATAL: FERRET_CI is true, but no database configuration found.");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// SQLite by default
|
|
52
|
+
return new SqliteStore();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Sprint 2 roadmap — Postgres support not yet implemented.
|
|
2
|
+
// This stub exists so factory.ts compiles.
|
|
3
|
+
// Do not use this in production until Sprint 2 is complete.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
DBStore,
|
|
7
|
+
FerretNode,
|
|
8
|
+
FerretContract,
|
|
9
|
+
FerretDependency,
|
|
10
|
+
FerretReconciliationLog,
|
|
11
|
+
FerretPlacementDecision,
|
|
12
|
+
NodeStatus,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
export class PostgresStore implements DBStore {
|
|
16
|
+
constructor(private connectionString: string) {}
|
|
17
|
+
|
|
18
|
+
private notImplemented(): never {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"PostgresStore: Postgres support is Sprint 2 roadmap. " +
|
|
21
|
+
'Set FERRET_DATABASE_URL to use Postgres, or remove the store: "postgres" config to use SQLite.',
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async init(): Promise<void> {
|
|
26
|
+
this.notImplemented();
|
|
27
|
+
}
|
|
28
|
+
async close(): Promise<void> {
|
|
29
|
+
this.notImplemented();
|
|
30
|
+
}
|
|
31
|
+
async getNodeByFilePath(_filePath: string): Promise<FerretNode | null> {
|
|
32
|
+
this.notImplemented();
|
|
33
|
+
}
|
|
34
|
+
async upsertNode(_node: FerretNode): Promise<void> {
|
|
35
|
+
this.notImplemented();
|
|
36
|
+
}
|
|
37
|
+
async getAllContractIds(): Promise<string[]> {
|
|
38
|
+
this.notImplemented();
|
|
39
|
+
}
|
|
40
|
+
async upsertContract(_contract: FerretContract): Promise<void> {
|
|
41
|
+
this.notImplemented();
|
|
42
|
+
}
|
|
43
|
+
async upsertDependency(_dependency: FerretDependency): Promise<void> {
|
|
44
|
+
this.notImplemented();
|
|
45
|
+
}
|
|
46
|
+
async replaceDependenciesForSourceNode(
|
|
47
|
+
_sourceNodeId: string,
|
|
48
|
+
_targetNodeIds: string[],
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
this.notImplemented();
|
|
51
|
+
}
|
|
52
|
+
async getNodes(): Promise<FerretNode[]> {
|
|
53
|
+
this.notImplemented();
|
|
54
|
+
}
|
|
55
|
+
async getNodesByStatus(_status: NodeStatus): Promise<FerretNode[]> {
|
|
56
|
+
this.notImplemented();
|
|
57
|
+
}
|
|
58
|
+
async getContracts(): Promise<FerretContract[]> {
|
|
59
|
+
this.notImplemented();
|
|
60
|
+
}
|
|
61
|
+
async getContract(_id: string): Promise<FerretContract | null> {
|
|
62
|
+
this.notImplemented();
|
|
63
|
+
}
|
|
64
|
+
async getDependencies(): Promise<FerretDependency[]> {
|
|
65
|
+
this.notImplemented();
|
|
66
|
+
}
|
|
67
|
+
async updateNodeStatus(_nodeId: string, _status: NodeStatus): Promise<void> {
|
|
68
|
+
this.notImplemented();
|
|
69
|
+
}
|
|
70
|
+
async insertReconciliationLog(_log: FerretReconciliationLog): Promise<void> {
|
|
71
|
+
this.notImplemented();
|
|
72
|
+
}
|
|
73
|
+
async getReconciliationLogs(): Promise<FerretReconciliationLog[]> {
|
|
74
|
+
this.notImplemented();
|
|
75
|
+
}
|
|
76
|
+
async insertPlacementDecision(
|
|
77
|
+
_decision: FerretPlacementDecision,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
this.notImplemented();
|
|
80
|
+
}
|
|
81
|
+
async getPlacementDecisions(): Promise<FerretPlacementDecision[]> {
|
|
82
|
+
this.notImplemented();
|
|
83
|
+
}
|
|
84
|
+
}
|