@specferret/core 0.1.2 → 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 (81) hide show
  1. package/dist/context/index.d.ts +5 -1
  2. package/dist/context/index.d.ts.map +1 -1
  3. package/dist/context/index.js +47 -1
  4. package/dist/context/index.js.map +1 -1
  5. package/dist/extractor/__fixtures__/typescript/generics.d.ts +6 -0
  6. package/dist/extractor/__fixtures__/typescript/generics.d.ts.map +1 -0
  7. package/dist/extractor/__fixtures__/typescript/generics.js +2 -0
  8. package/dist/extractor/__fixtures__/typescript/generics.js.map +1 -0
  9. package/dist/extractor/__fixtures__/typescript/intersections.d.ts +6 -0
  10. package/dist/extractor/__fixtures__/typescript/intersections.d.ts.map +1 -0
  11. package/dist/extractor/__fixtures__/typescript/intersections.js +2 -0
  12. package/dist/extractor/__fixtures__/typescript/intersections.js.map +1 -0
  13. package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts +8 -0
  14. package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts.map +1 -0
  15. package/dist/extractor/__fixtures__/typescript/optional-nested.js +2 -0
  16. package/dist/extractor/__fixtures__/typescript/optional-nested.js.map +1 -0
  17. package/dist/extractor/__fixtures__/typescript/unions.d.ts +5 -0
  18. package/dist/extractor/__fixtures__/typescript/unions.d.ts.map +1 -0
  19. package/dist/extractor/__fixtures__/typescript/unions.js +2 -0
  20. package/dist/extractor/__fixtures__/typescript/unions.js.map +1 -0
  21. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts +7 -0
  22. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts.map +1 -0
  23. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js +2 -0
  24. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js.map +1 -0
  25. package/dist/extractor/contract-types.d.ts +5 -0
  26. package/dist/extractor/contract-types.d.ts.map +1 -0
  27. package/dist/extractor/contract-types.js +8 -0
  28. package/dist/extractor/contract-types.js.map +1 -0
  29. package/dist/extractor/frontmatter.d.ts +2 -1
  30. package/dist/extractor/frontmatter.d.ts.map +1 -1
  31. package/dist/extractor/frontmatter.js +12 -6
  32. package/dist/extractor/frontmatter.js.map +1 -1
  33. package/dist/extractor/typescript.d.ts +6 -1
  34. package/dist/extractor/typescript.d.ts.map +1 -1
  35. package/dist/extractor/typescript.js +403 -185
  36. package/dist/extractor/typescript.js.map +1 -1
  37. package/dist/extractor/validator.d.ts +9 -0
  38. package/dist/extractor/validator.d.ts.map +1 -1
  39. package/dist/extractor/validator.js +28 -9
  40. package/dist/extractor/validator.js.map +1 -1
  41. package/dist/index.d.ts +13 -12
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +13 -12
  44. package/dist/index.js.map +1 -1
  45. package/dist/reconciler/index.d.ts +10 -0
  46. package/dist/reconciler/index.d.ts.map +1 -1
  47. package/dist/reconciler/index.js +76 -0
  48. package/dist/reconciler/index.js.map +1 -1
  49. package/package.json +4 -1
  50. package/src/config.ts +48 -0
  51. package/src/context/index.test.ts +274 -0
  52. package/src/context/index.ts +178 -0
  53. package/src/extractor/__fixtures__/typescript/generics.golden.json +36 -0
  54. package/src/extractor/__fixtures__/typescript/generics.ts +5 -0
  55. package/src/extractor/__fixtures__/typescript/intersections.golden.json +18 -0
  56. package/src/extractor/__fixtures__/typescript/intersections.ts +5 -0
  57. package/src/extractor/__fixtures__/typescript/optional-nested.golden.json +41 -0
  58. package/src/extractor/__fixtures__/typescript/optional-nested.ts +7 -0
  59. package/src/extractor/__fixtures__/typescript/unions.golden.json +30 -0
  60. package/src/extractor/__fixtures__/typescript/unions.ts +4 -0
  61. package/src/extractor/__fixtures__/typescript/unsupported-syntax.golden.json +20 -0
  62. package/src/extractor/__fixtures__/typescript/unsupported-syntax.ts +6 -0
  63. package/src/extractor/contract-types.ts +11 -0
  64. package/src/extractor/frontmatter.test.ts +217 -0
  65. package/src/extractor/frontmatter.ts +77 -0
  66. package/src/extractor/hash.ts +26 -0
  67. package/src/extractor/typescript.test.ts +339 -0
  68. package/src/extractor/typescript.ts +597 -0
  69. package/src/extractor/validator.test.ts +228 -0
  70. package/src/extractor/validator.ts +199 -0
  71. package/src/index.ts +16 -0
  72. package/src/reconciler/import-suggestions.test.ts +101 -0
  73. package/src/reconciler/import-suggestions.ts +157 -0
  74. package/src/reconciler/index.test.ts +248 -0
  75. package/src/reconciler/index.ts +445 -0
  76. package/src/store/factory.ts +54 -0
  77. package/src/store/postgres.ts +84 -0
  78. package/src/store/sqlite.test.ts +272 -0
  79. package/src/store/sqlite.ts +278 -0
  80. package/src/store/types.ts +100 -0
  81. package/src/utils/paths.ts +24 -0
@@ -0,0 +1,157 @@
1
+ import {
2
+ FerretContract,
3
+ FerretDependency,
4
+ FerretNode,
5
+ } from "../store/types.js";
6
+
7
+ export interface ImportSuggestion {
8
+ sourceContractId: string;
9
+ sourceFilePath: string;
10
+ suggestedImportId: string;
11
+ confidence: "medium" | "high";
12
+ evidence: string;
13
+ }
14
+
15
+ export function suggestMissingImports(
16
+ nodes: FerretNode[],
17
+ contracts: FerretContract[],
18
+ dependencies: FerretDependency[],
19
+ ): ImportSuggestion[] {
20
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
21
+ const nodeImportMap = new Map<string, Set<string>>();
22
+
23
+ for (const dependency of dependencies) {
24
+ const imports =
25
+ nodeImportMap.get(dependency.source_node_id) ?? new Set<string>();
26
+ imports.add(dependency.target_contract_id);
27
+ nodeImportMap.set(dependency.source_node_id, imports);
28
+ }
29
+
30
+ const schemaKeysByContract = new Map<string, Set<string>>();
31
+ for (const contract of contracts) {
32
+ schemaKeysByContract.set(
33
+ contract.id,
34
+ extractSchemaKeys(parseSchema(contract.shape_schema)),
35
+ );
36
+ }
37
+
38
+ const suggestions: ImportSuggestion[] = [];
39
+ const seen = new Set<string>();
40
+
41
+ for (const source of contracts) {
42
+ const sourceKeys = schemaKeysByContract.get(source.id) ?? new Set<string>();
43
+ if (sourceKeys.size === 0) {
44
+ continue;
45
+ }
46
+
47
+ const importedTargets =
48
+ nodeImportMap.get(source.node_id) ?? new Set<string>();
49
+ const sourceNode = nodeById.get(source.node_id);
50
+ if (!sourceNode) {
51
+ continue;
52
+ }
53
+
54
+ const rankedCandidates = contracts
55
+ .filter(
56
+ (target) => target.id !== source.id && !importedTargets.has(target.id),
57
+ )
58
+ .map((target) => {
59
+ const targetKeys =
60
+ schemaKeysByContract.get(target.id) ?? new Set<string>();
61
+ const sharedKeys = intersectSets(sourceKeys, targetKeys);
62
+ const overlap =
63
+ sourceKeys.size === 0 ? 0 : sharedKeys.length / sourceKeys.size;
64
+ return {
65
+ target,
66
+ targetKeys,
67
+ sharedKeys,
68
+ overlap,
69
+ };
70
+ })
71
+ .filter(
72
+ (candidate) =>
73
+ candidate.sharedKeys.length >= 2 &&
74
+ candidate.overlap >= 0.67 &&
75
+ candidate.targetKeys.size >= sourceKeys.size,
76
+ )
77
+ .sort((left, right) => {
78
+ if (right.sharedKeys.length !== left.sharedKeys.length) {
79
+ return right.sharedKeys.length - left.sharedKeys.length;
80
+ }
81
+ if (right.overlap !== left.overlap) {
82
+ return right.overlap - left.overlap;
83
+ }
84
+ return left.target.id.localeCompare(right.target.id);
85
+ })
86
+ .slice(0, 3);
87
+
88
+ for (const candidate of rankedCandidates) {
89
+ const confidence: "medium" | "high" =
90
+ candidate.sharedKeys.length >= 3 || candidate.overlap >= 0.75
91
+ ? "high"
92
+ : "medium";
93
+ const key = `${source.id}->${candidate.target.id}`;
94
+ if (seen.has(key)) {
95
+ continue;
96
+ }
97
+ seen.add(key);
98
+
99
+ suggestions.push({
100
+ sourceContractId: source.id,
101
+ sourceFilePath: sourceNode.file_path,
102
+ suggestedImportId: candidate.target.id,
103
+ confidence,
104
+ evidence: `shared shape keys: ${candidate.sharedKeys.slice(0, 3).join(", ")}`,
105
+ });
106
+ }
107
+ }
108
+
109
+ return suggestions.sort((left, right) => {
110
+ if (left.sourceContractId !== right.sourceContractId) {
111
+ return left.sourceContractId.localeCompare(right.sourceContractId);
112
+ }
113
+ return left.suggestedImportId.localeCompare(right.suggestedImportId);
114
+ });
115
+ }
116
+
117
+ function parseSchema(shapeSchema: string): unknown {
118
+ try {
119
+ return JSON.parse(shapeSchema);
120
+ } catch {
121
+ return {};
122
+ }
123
+ }
124
+
125
+ function extractSchemaKeys(schema: unknown): Set<string> {
126
+ const keys = new Set<string>();
127
+
128
+ const walk = (value: unknown): void => {
129
+ if (!value || typeof value !== "object") {
130
+ return;
131
+ }
132
+
133
+ const record = value as Record<string, unknown>;
134
+ const properties = record.properties;
135
+ if (properties && typeof properties === "object") {
136
+ for (const [key, child] of Object.entries(
137
+ properties as Record<string, unknown>,
138
+ )) {
139
+ keys.add(key);
140
+ walk(child);
141
+ }
142
+ }
143
+
144
+ const items = record.items;
145
+ if (items) {
146
+ walk(items);
147
+ }
148
+ };
149
+
150
+ walk(schema);
151
+ return keys;
152
+ }
153
+
154
+ function intersectSets(left: Set<string>, right: Set<string>): string[] {
155
+ const shared = [...left].filter((item) => right.has(item));
156
+ return shared.sort((a, b) => a.localeCompare(b));
157
+ }
@@ -0,0 +1,248 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import { describe, it } from "bun:test";
4
+ import { Reconciler } from "./index.js";
5
+ import { SqliteStore } from "../store/sqlite.js";
6
+ import type { FerretContract, FerretNode } from "../store/types.js";
7
+
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: randomUUID(),
17
+ status: "stable",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ function makeContract(nodeId: string, id: string): FerretContract {
23
+ return {
24
+ id,
25
+ node_id: nodeId,
26
+ shape_hash: randomUUID(),
27
+ shape_schema: JSON.stringify({ type: "object" }),
28
+ type: "api",
29
+ status: "stable",
30
+ };
31
+ }
32
+
33
+ describe("Reconciler — S30 import integrity", () => {
34
+ it("detects unresolved imports", async () => {
35
+ const store = makeStore();
36
+ await store.init();
37
+
38
+ const node = makeNode({
39
+ id: "node-a",
40
+ file_path: "contracts/a.contract.md",
41
+ });
42
+ await store.upsertNode(node);
43
+ await store.upsertContract(makeContract(node.id, "api.GET/a"));
44
+ await store.replaceDependenciesForSourceNode(node.id, ["api.GET/missing"]);
45
+
46
+ const report = await new Reconciler(store).reconcile();
47
+ assert.equal(report.consistent, false);
48
+ assert.deepEqual(report.integrityViolations.unresolvedImports, [
49
+ {
50
+ contractId: "api.GET/a",
51
+ filePath: "contracts/a.contract.md",
52
+ importPath: "api.GET/missing",
53
+ expectedTargetId: "api.GET/missing",
54
+ },
55
+ ]);
56
+ assert.equal(report.integrityViolations.selfImports.length, 0);
57
+ assert.equal(report.integrityViolations.circularImports.length, 0);
58
+ assert.equal(report.integrityViolations.orphanedContracts.length, 1);
59
+ assert.equal(
60
+ report.integrityViolations.orphanedContracts[0].contractId,
61
+ "api.GET/a",
62
+ );
63
+
64
+ await store.close();
65
+ });
66
+
67
+ it("detects self-imports", async () => {
68
+ const store = makeStore();
69
+ await store.init();
70
+
71
+ const node = makeNode({
72
+ id: "node-a",
73
+ file_path: "contracts/a.contract.md",
74
+ });
75
+ await store.upsertNode(node);
76
+ await store.upsertContract(makeContract(node.id, "api.GET/a"));
77
+ await store.replaceDependenciesForSourceNode(node.id, ["api.GET/a"]);
78
+
79
+ const report = await new Reconciler(store).reconcile();
80
+ assert.equal(report.consistent, false);
81
+ assert.deepEqual(report.integrityViolations.selfImports, [
82
+ {
83
+ contractId: "api.GET/a",
84
+ filePath: "contracts/a.contract.md",
85
+ importPath: "api.GET/a",
86
+ },
87
+ ]);
88
+ assert.equal(report.integrityViolations.unresolvedImports.length, 0);
89
+ assert.equal(report.integrityViolations.circularImports.length, 0);
90
+
91
+ await store.close();
92
+ });
93
+
94
+ it("detects direct circular imports", async () => {
95
+ const store = makeStore();
96
+ await store.init();
97
+
98
+ const nodeA = makeNode({
99
+ id: "node-a",
100
+ file_path: "contracts/a.contract.md",
101
+ });
102
+ const nodeB = makeNode({
103
+ id: "node-b",
104
+ file_path: "contracts/b.contract.md",
105
+ });
106
+ await store.upsertNode(nodeA);
107
+ await store.upsertNode(nodeB);
108
+ await store.upsertContract(makeContract(nodeA.id, "api.GET/a"));
109
+ await store.upsertContract(makeContract(nodeB.id, "api.GET/b"));
110
+ await store.replaceDependenciesForSourceNode(nodeA.id, ["api.GET/b"]);
111
+ await store.replaceDependenciesForSourceNode(nodeB.id, ["api.GET/a"]);
112
+
113
+ const report = await new Reconciler(store).reconcile();
114
+ assert.equal(report.consistent, false);
115
+ assert.equal(report.integrityViolations.circularImports.length, 1);
116
+ assert.equal(
117
+ report.integrityViolations.circularImports[0].importPath,
118
+ "api.GET/a -> api.GET/b -> api.GET/a",
119
+ );
120
+ assert.equal(report.integrityViolations.unresolvedImports.length, 0);
121
+ assert.equal(report.integrityViolations.selfImports.length, 0);
122
+
123
+ await store.close();
124
+ });
125
+
126
+ it("detects multi-node circular imports", async () => {
127
+ const store = makeStore();
128
+ await store.init();
129
+
130
+ const nodeA = makeNode({
131
+ id: "node-a",
132
+ file_path: "contracts/a.contract.md",
133
+ });
134
+ const nodeB = makeNode({
135
+ id: "node-b",
136
+ file_path: "contracts/b.contract.md",
137
+ });
138
+ const nodeC = makeNode({
139
+ id: "node-c",
140
+ file_path: "contracts/c.contract.md",
141
+ });
142
+ await store.upsertNode(nodeA);
143
+ await store.upsertNode(nodeB);
144
+ await store.upsertNode(nodeC);
145
+ await store.upsertContract(makeContract(nodeA.id, "api.GET/a"));
146
+ await store.upsertContract(makeContract(nodeB.id, "api.GET/b"));
147
+ await store.upsertContract(makeContract(nodeC.id, "api.GET/c"));
148
+ await store.replaceDependenciesForSourceNode(nodeA.id, ["api.GET/b"]);
149
+ await store.replaceDependenciesForSourceNode(nodeB.id, ["api.GET/c"]);
150
+ await store.replaceDependenciesForSourceNode(nodeC.id, ["api.GET/a"]);
151
+
152
+ const report = await new Reconciler(store).reconcile();
153
+ assert.equal(report.consistent, false);
154
+ assert.equal(report.integrityViolations.circularImports.length, 1);
155
+ assert.equal(
156
+ report.integrityViolations.circularImports[0].importPath,
157
+ "api.GET/a -> api.GET/b -> api.GET/c -> api.GET/a",
158
+ );
159
+
160
+ await store.close();
161
+ });
162
+
163
+ it("adds transitive chain context to unresolved import violations", async () => {
164
+ const store = makeStore();
165
+ await store.init();
166
+
167
+ const nodeRoot = makeNode({
168
+ id: "node-root",
169
+ file_path: "contracts/root.contract.md",
170
+ });
171
+ const nodeMid = makeNode({
172
+ id: "node-mid",
173
+ file_path: "contracts/mid.contract.md",
174
+ });
175
+ const nodeLeaf = makeNode({
176
+ id: "node-leaf",
177
+ file_path: "contracts/leaf.contract.md",
178
+ });
179
+
180
+ await store.upsertNode(nodeRoot);
181
+ await store.upsertNode(nodeMid);
182
+ await store.upsertNode(nodeLeaf);
183
+ await store.upsertContract(makeContract(nodeRoot.id, "api.GET/root"));
184
+ await store.upsertContract(makeContract(nodeMid.id, "api.GET/mid"));
185
+ await store.upsertContract(makeContract(nodeLeaf.id, "api.GET/leaf"));
186
+
187
+ await store.replaceDependenciesForSourceNode(nodeRoot.id, ["api.GET/mid"]);
188
+ await store.replaceDependenciesForSourceNode(nodeMid.id, ["api.GET/leaf"]);
189
+ await store.replaceDependenciesForSourceNode(nodeLeaf.id, [
190
+ "api.GET/missing",
191
+ ]);
192
+
193
+ const report = await new Reconciler(store).reconcile();
194
+ assert.equal(report.integrityViolations.unresolvedImports.length, 1);
195
+ assert.deepEqual(
196
+ report.integrityViolations.unresolvedImports[0].transitiveChain,
197
+ [
198
+ "api.GET/root",
199
+ "api.GET/mid",
200
+ "api.GET/leaf",
201
+ "api.GET/missing",
202
+ ],
203
+ );
204
+ assert.equal(report.integrityViolations.orphanedContracts.length, 1);
205
+ assert.equal(
206
+ report.integrityViolations.orphanedContracts[0].contractId,
207
+ "api.GET/leaf",
208
+ );
209
+
210
+ await store.close();
211
+ });
212
+
213
+ it("deduplicates repeated dependency edges during integrity checks", async () => {
214
+ const store = makeStore();
215
+ await store.init();
216
+
217
+ const nodeA = makeNode({
218
+ id: "node-a",
219
+ file_path: "contracts/a.contract.md",
220
+ });
221
+ const nodeB = makeNode({
222
+ id: "node-b",
223
+ file_path: "contracts/b.contract.md",
224
+ });
225
+ await store.upsertNode(nodeA);
226
+ await store.upsertNode(nodeB);
227
+ await store.upsertContract(makeContract(nodeA.id, "api.GET/a"));
228
+ await store.upsertContract(makeContract(nodeB.id, "api.GET/b"));
229
+ await store.upsertDependency({
230
+ id: randomUUID(),
231
+ source_node_id: nodeA.id,
232
+ target_contract_id: "api.GET/b",
233
+ });
234
+ await store.upsertDependency({
235
+ id: randomUUID(),
236
+ source_node_id: nodeA.id,
237
+ target_contract_id: "api.GET/b",
238
+ });
239
+
240
+ const report = await new Reconciler(store).reconcile();
241
+ assert.equal(report.integrityViolations.unresolvedImports.length, 0);
242
+ assert.equal(report.integrityViolations.selfImports.length, 0);
243
+ assert.equal(report.integrityViolations.circularImports.length, 0);
244
+ assert.equal(report.consistent, true);
245
+
246
+ await store.close();
247
+ });
248
+ });