@specferret/core 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +62 -62
  2. package/dist/context/index.d.ts +5 -1
  3. package/dist/context/index.d.ts.map +1 -1
  4. package/dist/context/index.js +47 -1
  5. package/dist/context/index.js.map +1 -1
  6. package/dist/extractor/__fixtures__/typescript/generics.d.ts +6 -0
  7. package/dist/extractor/__fixtures__/typescript/generics.d.ts.map +1 -0
  8. package/dist/extractor/__fixtures__/typescript/generics.js +2 -0
  9. package/dist/extractor/__fixtures__/typescript/generics.js.map +1 -0
  10. package/dist/extractor/__fixtures__/typescript/intersections.d.ts +6 -0
  11. package/dist/extractor/__fixtures__/typescript/intersections.d.ts.map +1 -0
  12. package/dist/extractor/__fixtures__/typescript/intersections.js +2 -0
  13. package/dist/extractor/__fixtures__/typescript/intersections.js.map +1 -0
  14. package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts +8 -0
  15. package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts.map +1 -0
  16. package/dist/extractor/__fixtures__/typescript/optional-nested.js +2 -0
  17. package/dist/extractor/__fixtures__/typescript/optional-nested.js.map +1 -0
  18. package/dist/extractor/__fixtures__/typescript/unions.d.ts +5 -0
  19. package/dist/extractor/__fixtures__/typescript/unions.d.ts.map +1 -0
  20. package/dist/extractor/__fixtures__/typescript/unions.js +2 -0
  21. package/dist/extractor/__fixtures__/typescript/unions.js.map +1 -0
  22. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts +7 -0
  23. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts.map +1 -0
  24. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js +2 -0
  25. package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js.map +1 -0
  26. package/dist/extractor/contract-types.d.ts +5 -0
  27. package/dist/extractor/contract-types.d.ts.map +1 -0
  28. package/dist/extractor/contract-types.js +8 -0
  29. package/dist/extractor/contract-types.js.map +1 -0
  30. package/dist/extractor/frontmatter.d.ts +2 -1
  31. package/dist/extractor/frontmatter.d.ts.map +1 -1
  32. package/dist/extractor/frontmatter.js +12 -6
  33. package/dist/extractor/frontmatter.js.map +1 -1
  34. package/dist/extractor/typescript.d.ts +6 -1
  35. package/dist/extractor/typescript.d.ts.map +1 -1
  36. package/dist/extractor/typescript.js +403 -185
  37. package/dist/extractor/typescript.js.map +1 -1
  38. package/dist/extractor/validator.d.ts +9 -0
  39. package/dist/extractor/validator.d.ts.map +1 -1
  40. package/dist/extractor/validator.js +28 -9
  41. package/dist/extractor/validator.js.map +1 -1
  42. package/dist/index.d.ts +13 -12
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +13 -12
  45. package/dist/index.js.map +1 -1
  46. package/dist/reconciler/index.d.ts +10 -0
  47. package/dist/reconciler/index.d.ts.map +1 -1
  48. package/dist/reconciler/index.js +76 -0
  49. package/dist/reconciler/index.js.map +1 -1
  50. package/dist/store/sqlite.js +72 -72
  51. package/package.json +47 -44
  52. package/src/config.ts +48 -0
  53. package/src/context/index.test.ts +274 -0
  54. package/src/context/index.ts +178 -0
  55. package/src/extractor/__fixtures__/typescript/generics.golden.json +36 -0
  56. package/src/extractor/__fixtures__/typescript/generics.ts +5 -0
  57. package/src/extractor/__fixtures__/typescript/intersections.golden.json +18 -0
  58. package/src/extractor/__fixtures__/typescript/intersections.ts +5 -0
  59. package/src/extractor/__fixtures__/typescript/optional-nested.golden.json +41 -0
  60. package/src/extractor/__fixtures__/typescript/optional-nested.ts +7 -0
  61. package/src/extractor/__fixtures__/typescript/unions.golden.json +30 -0
  62. package/src/extractor/__fixtures__/typescript/unions.ts +4 -0
  63. package/src/extractor/__fixtures__/typescript/unsupported-syntax.golden.json +20 -0
  64. package/src/extractor/__fixtures__/typescript/unsupported-syntax.ts +6 -0
  65. package/src/extractor/contract-types.ts +11 -0
  66. package/src/extractor/frontmatter.test.ts +217 -0
  67. package/src/extractor/frontmatter.ts +77 -0
  68. package/src/extractor/hash.ts +26 -0
  69. package/src/extractor/typescript.test.ts +339 -0
  70. package/src/extractor/typescript.ts +597 -0
  71. package/src/extractor/validator.test.ts +228 -0
  72. package/src/extractor/validator.ts +199 -0
  73. package/src/index.ts +16 -0
  74. package/src/reconciler/import-suggestions.test.ts +101 -0
  75. package/src/reconciler/import-suggestions.ts +157 -0
  76. package/src/reconciler/index.test.ts +248 -0
  77. package/src/reconciler/index.ts +445 -0
  78. package/src/store/factory.ts +54 -0
  79. package/src/store/postgres.ts +84 -0
  80. package/src/store/sqlite.test.ts +272 -0
  81. package/src/store/sqlite.ts +278 -0
  82. package/src/store/types.ts +100 -0
  83. package/src/utils/paths.ts +24 -0
@@ -0,0 +1,228 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'bun:test';
3
+ import { validateFerretSchema, compareSchemas, validateContractType } from './validator.js';
4
+ import { CONTRACT_TYPES } from './contract-types.js';
5
+
6
+ // ─── validateFerretSchema ────────────────────────────────────────────────────
7
+
8
+ describe('validateFerretSchema', () => {
9
+ it('warns on every unsupported keyword when present', () => {
10
+ const UNSUPPORTED = ['$ref', 'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', '$defs', 'definitions', 'patternProperties', 'dependencies'];
11
+
12
+ for (const keyword of UNSUPPORTED) {
13
+ const shape = { type: 'object', [keyword]: {} };
14
+ const result = validateFerretSchema(shape, 'contracts/test.contract.md');
15
+ assert.equal(result.warnings.length, 1);
16
+ assert.match(result.warnings[0], new RegExp(keyword.replace('$', '\\$')));
17
+ assert.match(result.warnings[0], /contracts\/test\.contract\.md/);
18
+ assert.equal(result.valid, true);
19
+ }
20
+ });
21
+
22
+ it('does not warn on valid supported keywords', () => {
23
+ const shape = {
24
+ type: 'object',
25
+ properties: {
26
+ id: { type: 'string', format: 'uuid' },
27
+ name: { type: 'string' },
28
+ score: { type: 'number' },
29
+ active: { type: 'boolean' },
30
+ tags: { type: 'array', items: { type: 'string' } },
31
+ status: { type: 'string', enum: ['pending', 'active', 'cancelled'] },
32
+ },
33
+ required: ['id', 'name'],
34
+ additionalProperties: false,
35
+ };
36
+ const result = validateFerretSchema(shape, 'contracts/valid.contract.md');
37
+ assert.equal(result.warnings.length, 0);
38
+ assert.equal(result.valid, true);
39
+ });
40
+
41
+ it('warns on multiple unsupported keywords in a single schema', () => {
42
+ const shape = {
43
+ type: 'object',
44
+ allOf: [{}],
45
+ anyOf: [{}],
46
+ $ref: '#/definitions/Foo',
47
+ };
48
+ const result = validateFerretSchema(shape, 'contracts/complex.contract.md');
49
+ assert.ok(result.warnings.length >= 3);
50
+ assert.equal(result.valid, true);
51
+ });
52
+
53
+ it('does not warn on null or primitive shape', () => {
54
+ assert.equal(validateFerretSchema(null, 'f.md').warnings.length, 0);
55
+ assert.equal(validateFerretSchema('string shape', 'f.md').warnings.length, 0);
56
+ assert.equal(validateFerretSchema(42, 'f.md').warnings.length, 0);
57
+ });
58
+ });
59
+
60
+ describe('validateContractType', () => {
61
+ it('accepts all six supported contract types', () => {
62
+ for (const contractType of CONTRACT_TYPES) {
63
+ const result = validateContractType(contractType, 'contracts/test.contract.md');
64
+ assert.equal(result.valid, true);
65
+ if (result.valid) {
66
+ assert.equal(result.value, contractType);
67
+ }
68
+ }
69
+ });
70
+
71
+ it('rejects unknown contract types and lists allowed values', () => {
72
+ const result = validateContractType('service', 'contracts/test.contract.md');
73
+ assert.equal(result.valid, false);
74
+ assert.match(result.error ?? '', /Invalid contract type 'service'/);
75
+ assert.match(result.error ?? '', /Allowed types: api, table, type, event, flow, config/);
76
+ });
77
+
78
+ it('keeps shared constants aligned with expected six core types', () => {
79
+ assert.deepEqual(CONTRACT_TYPES, ['api', 'table', 'type', 'event', 'flow', 'config']);
80
+ });
81
+ });
82
+
83
+ // ─── compareSchemas ──────────────────────────────────────────────────────────
84
+
85
+ describe('compareSchemas — breaking changes', () => {
86
+ it('required field removed → breaking', () => {
87
+ const prev = {
88
+ type: 'object',
89
+ properties: { id: { type: 'string' }, email: { type: 'string' } },
90
+ required: ['id', 'email'],
91
+ };
92
+ const curr = {
93
+ type: 'object',
94
+ properties: { id: { type: 'string' }, email: { type: 'string' } },
95
+ required: ['id'],
96
+ };
97
+ const result = compareSchemas(prev, curr);
98
+ assert.equal(result.classification, 'breaking');
99
+ assert.match(result.reason, /email/);
100
+ });
101
+
102
+ it('field type changed → breaking', () => {
103
+ const prev = { type: 'object', properties: { count: { type: 'string' } } };
104
+ const curr = { type: 'object', properties: { count: { type: 'integer' } } };
105
+ const result = compareSchemas(prev, curr);
106
+ assert.equal(result.classification, 'breaking');
107
+ });
108
+
109
+ it('required field added → breaking', () => {
110
+ const prev = {
111
+ type: 'object',
112
+ properties: { id: { type: 'string' }, name: { type: 'string' } },
113
+ required: ['id'],
114
+ };
115
+ const curr = {
116
+ type: 'object',
117
+ properties: { id: { type: 'string' }, name: { type: 'string' } },
118
+ required: ['id', 'name'],
119
+ };
120
+ const result = compareSchemas(prev, curr);
121
+ assert.equal(result.classification, 'breaking');
122
+ assert.match(result.reason, /name/);
123
+ });
124
+
125
+ it('enum value removed → breaking', () => {
126
+ const prev = { type: 'string', enum: ['pending', 'active', 'cancelled'] };
127
+ const curr = { type: 'string', enum: ['pending', 'active'] };
128
+ const result = compareSchemas(prev, curr);
129
+ assert.equal(result.classification, 'breaking');
130
+ assert.match(result.reason, /cancelled/);
131
+ });
132
+
133
+ it('response type changed → breaking', () => {
134
+ const prev = { response: { type: 'array', items: { type: 'string' } } };
135
+ const curr = { response: { type: 'object', properties: {} } };
136
+ const result = compareSchemas(prev, curr);
137
+ assert.equal(result.classification, 'breaking');
138
+ });
139
+
140
+ it('property removed → breaking', () => {
141
+ const prev = {
142
+ type: 'object',
143
+ properties: { id: { type: 'string' }, secret: { type: 'string' } },
144
+ required: ['id'],
145
+ };
146
+ const curr = {
147
+ type: 'object',
148
+ properties: { id: { type: 'string' } },
149
+ required: ['id'],
150
+ };
151
+ const result = compareSchemas(prev, curr);
152
+ assert.equal(result.classification, 'breaking');
153
+ assert.match(result.reason, /secret/);
154
+ });
155
+ });
156
+
157
+ describe('compareSchemas — non-breaking changes', () => {
158
+ it('optional field added → non-breaking', () => {
159
+ const prev = {
160
+ type: 'object',
161
+ properties: { id: { type: 'string' } },
162
+ required: ['id'],
163
+ };
164
+ const curr = {
165
+ type: 'object',
166
+ properties: { id: { type: 'string' }, description: { type: 'string' } },
167
+ required: ['id'],
168
+ };
169
+ const result = compareSchemas(prev, curr);
170
+ assert.equal(result.classification, 'non-breaking');
171
+ assert.match(result.reason, /description/);
172
+ });
173
+
174
+ it('enum value added → non-breaking', () => {
175
+ const prev = { type: 'string', enum: ['pending', 'active'] };
176
+ const curr = { type: 'string', enum: ['pending', 'active', 'cancelled'] };
177
+ const result = compareSchemas(prev, curr);
178
+ assert.equal(result.classification, 'non-breaking');
179
+ assert.match(result.reason, /cancelled/);
180
+ });
181
+ });
182
+
183
+ describe('compareSchemas — no-change', () => {
184
+ it('property order changed → no-change', () => {
185
+ const prev = {
186
+ type: 'object',
187
+ properties: { id: { type: 'string' }, name: { type: 'string' } },
188
+ required: ['id', 'name'],
189
+ };
190
+ const curr = {
191
+ type: 'object',
192
+ properties: { name: { type: 'string' }, id: { type: 'string' } }, // order swapped
193
+ required: ['id', 'name'],
194
+ };
195
+ const result = compareSchemas(prev, curr);
196
+ assert.equal(result.classification, 'no-change');
197
+ });
198
+
199
+ it('required array reordered → no-change', () => {
200
+ const prev = {
201
+ type: 'object',
202
+ properties: { id: { type: 'string' }, email: { type: 'string' } },
203
+ required: ['id', 'email'],
204
+ };
205
+ const curr = {
206
+ type: 'object',
207
+ properties: { id: { type: 'string' }, email: { type: 'string' } },
208
+ required: ['email', 'id'], // same fields, different order
209
+ };
210
+ const result = compareSchemas(prev, curr);
211
+ assert.equal(result.classification, 'no-change');
212
+ });
213
+
214
+ it('identical schemas → no-change', () => {
215
+ const schema = {
216
+ type: 'object',
217
+ properties: { id: { type: 'string', format: 'uuid' } },
218
+ required: ['id'],
219
+ };
220
+ const result = compareSchemas(schema, { ...schema });
221
+ assert.equal(result.classification, 'no-change');
222
+ });
223
+
224
+ it('empty schemas compared → no-change', () => {
225
+ const result = compareSchemas({}, {});
226
+ assert.equal(result.classification, 'no-change');
227
+ });
228
+ });
@@ -0,0 +1,199 @@
1
+ // Pure function. No I/O. No side effects. Ever.
2
+ // Validator layer — validates JSON Schema subset, classifies breaking/non-breaking/no-change.
3
+
4
+ import { CONTRACT_TYPES, formatAllowedContractTypes, isContractType, type ContractType } from './contract-types.js';
5
+
6
+ export type ChangeClassification = 'breaking' | 'non-breaking' | 'no-change';
7
+
8
+ export interface SchemaValidationResult {
9
+ valid: boolean;
10
+ warnings: string[];
11
+ }
12
+
13
+ export interface SchemaComparisonResult {
14
+ classification: ChangeClassification;
15
+ reason: string;
16
+ }
17
+
18
+ export type ContractTypeValidationResult = { valid: true; value: ContractType } | { valid: false; error: string };
19
+
20
+ export function validateContractType(contractType: unknown, filePath: string): ContractTypeValidationResult {
21
+ if (isContractType(contractType)) {
22
+ return { valid: true, value: contractType };
23
+ }
24
+
25
+ return {
26
+ valid: false,
27
+ error: `Invalid contract type '${String(contractType)}' in ${filePath}. Allowed types: ${formatAllowedContractTypes()}.`,
28
+ };
29
+ }
30
+
31
+ /**
32
+ * JSON Schema keywords that are explicitly NOT supported by Ferret.
33
+ * If any appear in a schema, a warning is emitted but the schema is still accepted.
34
+ * See: spec/CONTRACT-SCHEMA.md — Part 3
35
+ */
36
+ const UNSUPPORTED_KEYWORDS = [
37
+ '$ref',
38
+ 'allOf',
39
+ 'anyOf',
40
+ 'oneOf',
41
+ 'not',
42
+ 'if',
43
+ 'then',
44
+ 'else',
45
+ '$defs',
46
+ 'definitions',
47
+ 'patternProperties',
48
+ 'dependencies',
49
+ ];
50
+
51
+ /**
52
+ * Validates a schema object against the Ferret JSON Schema subset.
53
+ * Always returns valid: true — unsupported keywords produce warnings, not errors.
54
+ */
55
+ export function validateFerretSchema(shape: unknown, filePath: string): SchemaValidationResult {
56
+ const warnings: string[] = [];
57
+
58
+ if (shape === null || typeof shape !== 'object') {
59
+ return { valid: true, warnings };
60
+ }
61
+
62
+ const serialised = JSON.stringify(shape);
63
+
64
+ for (const keyword of UNSUPPORTED_KEYWORDS) {
65
+ // Match the keyword as a JSON object key (surrounded by quotes)
66
+ if (serialised.includes(`"${keyword}"`)) {
67
+ warnings.push(
68
+ `⚠ Unsupported JSON Schema keyword: ${keyword} in ${filePath}\n` +
69
+ ` Ferret supports a subset of JSON Schema.\n` +
70
+ ` See: spec/CONTRACT-SCHEMA.md — Part 3`,
71
+ );
72
+ }
73
+ }
74
+
75
+ return { valid: true, warnings };
76
+ }
77
+
78
+ /**
79
+ * Compares two schema objects and classifies the change.
80
+ *
81
+ * Breaking: required field removed, field type changed, enum value removed,
82
+ * response/array type changed, required field added
83
+ * Non-breaking: optional field added, enum value added
84
+ * No-change: property order changed, whitespace, required array reordered
85
+ */
86
+ export function compareSchemas(previous: unknown, current: unknown): SchemaComparisonResult {
87
+ // Normalise: work with plain objects only
88
+ const prev = (typeof previous === 'object' && previous !== null ? previous : {}) as Record<string, unknown>;
89
+ const curr = (typeof current === 'object' && current !== null ? current : {}) as Record<string, unknown>;
90
+
91
+ // 1. Check type change at this level
92
+ if (prev.type !== undefined && curr.type !== undefined && prev.type !== curr.type) {
93
+ return { classification: 'breaking', reason: `type changed from '${prev.type}' to '${curr.type}'` };
94
+ }
95
+
96
+ // 2. Check required fields
97
+ const prevRequired = normaliseRequired(prev.required);
98
+ const currRequired = normaliseRequired(curr.required);
99
+
100
+ const removedRequired = prevRequired.filter((f) => !currRequired.includes(f));
101
+ if (removedRequired.length > 0) {
102
+ return { classification: 'breaking', reason: `required field(s) removed: ${removedRequired.join(', ')}` };
103
+ }
104
+
105
+ const addedRequired = currRequired.filter((f) => !prevRequired.includes(f));
106
+ if (addedRequired.length > 0) {
107
+ return { classification: 'breaking', reason: `required field(s) added: ${addedRequired.join(', ')}` };
108
+ }
109
+
110
+ // 3. Check enum changes
111
+ const prevEnum = normaliseEnum(prev.enum);
112
+ const currEnum = normaliseEnum(curr.enum);
113
+
114
+ if (prevEnum !== null && currEnum !== null) {
115
+ const removedEnum = prevEnum.filter((v) => !currEnum.includes(v));
116
+ if (removedEnum.length > 0) {
117
+ return { classification: 'breaking', reason: `enum value(s) removed: ${removedEnum.join(', ')}` };
118
+ }
119
+ const addedEnum = currEnum.filter((v) => !prevEnum.includes(v));
120
+ if (addedEnum.length > 0) {
121
+ return { classification: 'non-breaking', reason: `enum value(s) added: ${addedEnum.join(', ')}` };
122
+ }
123
+ }
124
+
125
+ // 4. Check properties — look for type changes in existing properties, or optional additions
126
+ const prevProps = (prev.properties ?? {}) as Record<string, unknown>;
127
+ const currProps = (curr.properties ?? {}) as Record<string, unknown>;
128
+
129
+ for (const key of Object.keys(currProps)) {
130
+ if (!(key in prevProps)) {
131
+ // New property — only breaking if it's also in required (already caught above)
132
+ if (currRequired.includes(key)) {
133
+ return { classification: 'breaking', reason: `required field added: ${key}` };
134
+ }
135
+ // Not required — non-breaking addition
136
+ continue;
137
+ }
138
+ // Property exists in both — check for type change recursively
139
+ const nested = compareSchemas(prevProps[key], currProps[key]);
140
+ if (nested.classification === 'breaking') {
141
+ return { classification: 'breaking', reason: `property '${key}': ${nested.reason}` };
142
+ }
143
+ if (nested.classification === 'non-breaking') {
144
+ return { classification: 'non-breaking', reason: `property '${key}': ${nested.reason}` };
145
+ }
146
+ }
147
+
148
+ // 5. Check for property removals (any removal is breaking — even "optional" fields
149
+ // that consumers may depend on)
150
+ for (const key of Object.keys(prevProps)) {
151
+ if (!(key in currProps)) {
152
+ return { classification: 'breaking', reason: `property '${key}' removed` };
153
+ }
154
+ }
155
+
156
+ // 6. Check request/response top-level wrappers (Ferret API extension)
157
+ for (const wrapper of ['request', 'response'] as const) {
158
+ if (prev[wrapper] !== undefined || curr[wrapper] !== undefined) {
159
+ const nested = compareSchemas(prev[wrapper] ?? {}, curr[wrapper] ?? {});
160
+ if (nested.classification === 'breaking') {
161
+ return { classification: 'breaking', reason: `${wrapper}: ${nested.reason}` };
162
+ }
163
+ if (nested.classification === 'non-breaking') {
164
+ return { classification: 'non-breaking', reason: `${wrapper}: ${nested.reason}` };
165
+ }
166
+ }
167
+ }
168
+
169
+ // 7. Check array items type change
170
+ if (prev.items !== undefined && curr.items !== undefined) {
171
+ const nested = compareSchemas(prev.items, curr.items);
172
+ if (nested.classification === 'breaking') {
173
+ return { classification: 'breaking', reason: `array items: ${nested.reason}` };
174
+ }
175
+ if (nested.classification === 'non-breaking') {
176
+ return { classification: 'non-breaking', reason: `array items: ${nested.reason}` };
177
+ }
178
+ }
179
+
180
+ // 8. Check if a new optional property was added (non-breaking)
181
+ const newOptionalKeys = Object.keys(currProps).filter((k) => !(k in prevProps) && !currRequired.includes(k));
182
+ if (newOptionalKeys.length > 0) {
183
+ return { classification: 'non-breaking', reason: `optional field(s) added: ${newOptionalKeys.join(', ')}` };
184
+ }
185
+
186
+ return { classification: 'no-change', reason: 'schemas are semantically identical' };
187
+ }
188
+
189
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
190
+
191
+ function normaliseRequired(required: unknown): string[] {
192
+ if (!Array.isArray(required)) return [];
193
+ return required.filter((v): v is string => typeof v === 'string');
194
+ }
195
+
196
+ function normaliseEnum(enumVal: unknown): string[] | null {
197
+ if (!Array.isArray(enumVal)) return null;
198
+ return enumVal.map((v) => String(v));
199
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ // @specferret/core public API
2
+ // Do not export llm-fallback — dynamic import only, never a default export.
3
+
4
+ export * from './extractor/frontmatter.js';
5
+ export * from './extractor/typescript.js';
6
+ export * from './extractor/validator.js';
7
+ export * from './extractor/contract-types.js';
8
+ export * from './extractor/hash.js';
9
+ export * from './context/index.js';
10
+ export * from './store/types.js';
11
+ export * from './store/sqlite.js';
12
+ export * from './store/factory.js';
13
+ export * from './reconciler/index.js';
14
+ export * from './reconciler/import-suggestions.js';
15
+ export * from './config.js';
16
+ export * from './utils/paths.js';
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "bun:test";
3
+ import { suggestMissingImports } from "./import-suggestions.js";
4
+ import type {
5
+ FerretContract,
6
+ FerretDependency,
7
+ FerretNode,
8
+ } from "../store/types.js";
9
+
10
+ function makeNode(id: string, filePath: string): FerretNode {
11
+ return {
12
+ id,
13
+ file_path: filePath,
14
+ hash: id,
15
+ status: "stable",
16
+ };
17
+ }
18
+
19
+ function makeContract(
20
+ nodeId: string,
21
+ id: string,
22
+ shapeSchema: unknown,
23
+ ): FerretContract {
24
+ return {
25
+ id,
26
+ node_id: nodeId,
27
+ shape_hash: id,
28
+ shape_schema: JSON.stringify(shapeSchema),
29
+ type: "api",
30
+ status: "stable",
31
+ };
32
+ }
33
+
34
+ describe("suggestMissingImports — S31 acceptance criteria", () => {
35
+ it("suggests likely missing imports using shape overlap evidence", () => {
36
+ const nodes: FerretNode[] = [
37
+ makeNode("node-a", "contracts/a.contract.md"),
38
+ makeNode("node-b", "contracts/b.contract.md"),
39
+ ];
40
+
41
+ const contracts: FerretContract[] = [
42
+ makeContract("node-a", "api.GET/a", {
43
+ type: "object",
44
+ properties: {
45
+ token: { type: "string" },
46
+ userId: { type: "string" },
47
+ },
48
+ }),
49
+ makeContract("node-b", "auth.jwt", {
50
+ type: "object",
51
+ properties: {
52
+ token: { type: "string" },
53
+ userId: { type: "string" },
54
+ expiresAt: { type: "string" },
55
+ },
56
+ }),
57
+ ];
58
+
59
+ const suggestions = suggestMissingImports(nodes, contracts, []);
60
+ assert.equal(suggestions.length, 1);
61
+ assert.equal(suggestions[0].sourceContractId, "api.GET/a");
62
+ assert.equal(suggestions[0].suggestedImportId, "auth.jwt");
63
+ assert.match(suggestions[0].evidence, /shared shape keys/);
64
+ });
65
+
66
+ it("does not suggest imports that already exist in dependencies", () => {
67
+ const nodes: FerretNode[] = [
68
+ makeNode("node-a", "contracts/a.contract.md"),
69
+ makeNode("node-b", "contracts/b.contract.md"),
70
+ ];
71
+
72
+ const contracts: FerretContract[] = [
73
+ makeContract("node-a", "api.GET/a", {
74
+ type: "object",
75
+ properties: {
76
+ token: { type: "string" },
77
+ userId: { type: "string" },
78
+ },
79
+ }),
80
+ makeContract("node-b", "auth.jwt", {
81
+ type: "object",
82
+ properties: {
83
+ token: { type: "string" },
84
+ userId: { type: "string" },
85
+ expiresAt: { type: "string" },
86
+ },
87
+ }),
88
+ ];
89
+
90
+ const dependencies: FerretDependency[] = [
91
+ {
92
+ id: "dep-a",
93
+ source_node_id: "node-a",
94
+ target_contract_id: "auth.jwt",
95
+ },
96
+ ];
97
+
98
+ const suggestions = suggestMissingImports(nodes, contracts, dependencies);
99
+ assert.equal(suggestions.length, 0);
100
+ });
101
+ });