@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.
Files changed (94) 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 +6 -1
  31. package/dist/extractor/frontmatter.d.ts.map +1 -1
  32. package/dist/extractor/frontmatter.js +17 -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/upward-classifier.d.ts +25 -0
  39. package/dist/extractor/upward-classifier.d.ts.map +1 -0
  40. package/dist/extractor/upward-classifier.js +48 -0
  41. package/dist/extractor/upward-classifier.js.map +1 -0
  42. package/dist/extractor/validator.d.ts +9 -0
  43. package/dist/extractor/validator.d.ts.map +1 -1
  44. package/dist/extractor/validator.js +28 -9
  45. package/dist/extractor/validator.js.map +1 -1
  46. package/dist/index.d.ts +14 -12
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +14 -12
  49. package/dist/index.js.map +1 -1
  50. package/dist/reconciler/index.d.ts +10 -0
  51. package/dist/reconciler/index.d.ts.map +1 -1
  52. package/dist/reconciler/index.js +76 -0
  53. package/dist/reconciler/index.js.map +1 -1
  54. package/dist/store/sqlite.d.ts +1 -1
  55. package/dist/store/sqlite.d.ts.map +1 -1
  56. package/dist/store/sqlite.js +106 -101
  57. package/dist/store/sqlite.js.map +1 -1
  58. package/dist/store/types.d.ts +6 -2
  59. package/dist/store/types.d.ts.map +1 -1
  60. package/package.json +47 -44
  61. package/src/config.ts +48 -0
  62. package/src/context/index.test.ts +274 -0
  63. package/src/context/index.ts +178 -0
  64. package/src/extractor/__fixtures__/typescript/generics.golden.json +36 -0
  65. package/src/extractor/__fixtures__/typescript/generics.ts +5 -0
  66. package/src/extractor/__fixtures__/typescript/intersections.golden.json +18 -0
  67. package/src/extractor/__fixtures__/typescript/intersections.ts +5 -0
  68. package/src/extractor/__fixtures__/typescript/optional-nested.golden.json +41 -0
  69. package/src/extractor/__fixtures__/typescript/optional-nested.ts +7 -0
  70. package/src/extractor/__fixtures__/typescript/unions.golden.json +30 -0
  71. package/src/extractor/__fixtures__/typescript/unions.ts +4 -0
  72. package/src/extractor/__fixtures__/typescript/unsupported-syntax.golden.json +20 -0
  73. package/src/extractor/__fixtures__/typescript/unsupported-syntax.ts +6 -0
  74. package/src/extractor/contract-types.ts +11 -0
  75. package/src/extractor/frontmatter.test.ts +291 -0
  76. package/src/extractor/frontmatter.ts +87 -0
  77. package/src/extractor/hash.ts +26 -0
  78. package/src/extractor/typescript.test.ts +339 -0
  79. package/src/extractor/typescript.ts +597 -0
  80. package/src/extractor/upward-classifier.test.ts +188 -0
  81. package/src/extractor/upward-classifier.ts +68 -0
  82. package/src/extractor/validator.test.ts +228 -0
  83. package/src/extractor/validator.ts +199 -0
  84. package/src/index.ts +17 -0
  85. package/src/reconciler/import-suggestions.test.ts +101 -0
  86. package/src/reconciler/import-suggestions.ts +157 -0
  87. package/src/reconciler/index.test.ts +248 -0
  88. package/src/reconciler/index.ts +445 -0
  89. package/src/store/factory.ts +54 -0
  90. package/src/store/postgres.ts +84 -0
  91. package/src/store/sqlite.test.ts +328 -0
  92. package/src/store/sqlite.ts +231 -0
  93. package/src/store/types.ts +101 -0
  94. package/src/utils/paths.ts +24 -0
@@ -0,0 +1,291 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'bun:test';
3
+ import { extractFromSpecFile } from './frontmatter.js';
4
+ import { CONTRACT_TYPES } from './contract-types.js';
5
+
6
+ const VALID_SPEC = `---
7
+ ferret:
8
+ id: api.GET/users
9
+ type: api
10
+ shape:
11
+ response:
12
+ type: array
13
+ items:
14
+ type: object
15
+ properties:
16
+ id:
17
+ type: string
18
+ format: uuid
19
+ name:
20
+ type: string
21
+ required: [id, name]
22
+ ---
23
+
24
+ # Users Endpoint
25
+
26
+ Returns all users.
27
+ `;
28
+
29
+ const SPEC_WITH_IMPORTS = `---
30
+ ferret:
31
+ id: api.GET/search
32
+ type: api
33
+ shape:
34
+ response:
35
+ type: array
36
+ items:
37
+ type: string
38
+ imports:
39
+ - auth.jwt
40
+ - tables.document
41
+ ---
42
+ `;
43
+
44
+ const SPEC_WITH_UNSUPPORTED_KEYWORD = `---
45
+ ferret:
46
+ id: tables.user
47
+ type: table
48
+ shape:
49
+ type: object
50
+ allOf:
51
+ - type: string
52
+ properties:
53
+ id:
54
+ type: string
55
+ ---
56
+ `;
57
+
58
+ const SPEC_NO_FRONTMATTER = `# Just a markdown file
59
+
60
+ No frontmatter here. Ferret should skip this.
61
+ `;
62
+
63
+ const SPEC_MISSING_FIELDS = `---
64
+ ferret:
65
+ id: api.GET/broken
66
+ type: api
67
+ ---
68
+ `;
69
+
70
+ const SPEC_INVALID_TYPE = `---
71
+ ferret:
72
+ id: api.GET/broken
73
+ type: service
74
+ shape:
75
+ type: object
76
+ ---
77
+ `;
78
+
79
+ describe('extractFromSpecFile — Task 3', () => {
80
+ it('extracts valid frontmatter correctly', () => {
81
+ const result = extractFromSpecFile('contracts/users.contract.md', VALID_SPEC);
82
+ assert.equal(result.filePath, 'contracts/users.contract.md');
83
+ assert.equal(result.fileType, 'spec');
84
+ assert.equal(result.extractedBy, 'gray-matter');
85
+ assert.equal(result.warning, undefined);
86
+ assert.equal(result.contracts.length, 1);
87
+ assert.equal(result.contracts[0].id, 'api.GET/users');
88
+ assert.equal(result.contracts[0].type, 'api');
89
+ assert.notEqual(result.contracts[0].shape_hash, undefined);
90
+ assert.equal(result.contracts[0].shape_hash.length, 64);
91
+ assert.deepEqual(result.contracts[0].imports, []);
92
+ });
93
+
94
+ it('extracts imports correctly', () => {
95
+ const result = extractFromSpecFile('contracts/search.contract.md', SPEC_WITH_IMPORTS);
96
+ assert.deepEqual(result.contracts[0].imports, ['auth.jwt', 'tables.document']);
97
+ });
98
+
99
+ it('missing frontmatter returns warning, empty contracts, does not throw', () => {
100
+ const result = extractFromSpecFile('contracts/plain.contract.md', SPEC_NO_FRONTMATTER);
101
+ assert.equal(result.warning, 'no-frontmatter');
102
+ assert.equal(result.contracts.length, 0);
103
+ assert.equal(result.filePath, 'contracts/plain.contract.md');
104
+ assert.equal(result.fileType, 'spec');
105
+ });
106
+
107
+ it('missing required field "shape" throws with field name in message', () => {
108
+ assert.throws(() => extractFromSpecFile('contracts/broken.contract.md', SPEC_MISSING_FIELDS), /shape/);
109
+ });
110
+
111
+ it('missing multiple required fields throws with all field names in message', () => {
112
+ const specMissingAll = `---\nferret:\n someField: value\n---\n`;
113
+ assert.throws(() => extractFromSpecFile('contracts/broken.contract.md', specMissingAll), /id.*type.*shape|Missing required/);
114
+ });
115
+
116
+ it('invalid top-level contract type throws and lists allowed values', () => {
117
+ assert.throws(
118
+ () => extractFromSpecFile('contracts/broken.contract.md', SPEC_INVALID_TYPE),
119
+ /Invalid contract type 'service'.*Allowed types: api, table, type, event, flow, config/,
120
+ );
121
+ });
122
+
123
+ it('unsupported schema keyword produces warning, does not fail', () => {
124
+ const stderrOutput: string[] = [];
125
+ const originalWrite = process.stderr.write.bind(process.stderr);
126
+ // Capture stderr writes
127
+ process.stderr.write = (chunk: any) => {
128
+ stderrOutput.push(String(chunk));
129
+ return true;
130
+ };
131
+
132
+ let result: ReturnType<typeof extractFromSpecFile> | undefined;
133
+ try {
134
+ result = extractFromSpecFile('contracts/complex.contract.md', SPEC_WITH_UNSUPPORTED_KEYWORD);
135
+ } finally {
136
+ process.stderr.write = originalWrite;
137
+ }
138
+
139
+ assert.notEqual(result, undefined);
140
+ assert.equal(result!.contracts.length, 1);
141
+ assert.equal(result!.warning, undefined);
142
+ assert.equal(
143
+ stderrOutput.some((line) => line.includes('allOf')),
144
+ true,
145
+ );
146
+ });
147
+
148
+ it('all six allowed types are accepted without error', () => {
149
+ for (const contractType of CONTRACT_TYPES) {
150
+ const spec = `---
151
+ ferret:
152
+ id: test.${contractType}
153
+ type: ${contractType}
154
+ shape:
155
+ type: object
156
+ ---
157
+ `;
158
+ assert.doesNotThrow(() => extractFromSpecFile(`contracts/${contractType}.contract.md`, spec));
159
+ }
160
+ });
161
+
162
+ it('extraction is synchronous — the function itself has no async/await', () => {
163
+ // If extractFromSpecFile returns a Promise, this would be a thenable object
164
+ const result = extractFromSpecFile('contracts/users.contract.md', VALID_SPEC);
165
+ assert.equal(result instanceof Promise, false);
166
+ assert.notEqual(typeof (result as any).then, 'function');
167
+ });
168
+
169
+ it('identical files produce identical shape_hash', () => {
170
+ const r1 = extractFromSpecFile('contracts/a.contract.md', VALID_SPEC);
171
+ const r2 = extractFromSpecFile('contracts/b.contract.md', VALID_SPEC);
172
+ assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
173
+ });
174
+
175
+ it('different shapes produce different shape_hash', () => {
176
+ const specA = VALID_SPEC;
177
+ const specB = specA.replace('format: uuid', 'format: email');
178
+ const r1 = extractFromSpecFile('contracts/a.contract.md', specA);
179
+ const r2 = extractFromSpecFile('contracts/b.contract.md', specB);
180
+ assert.notEqual(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
181
+ });
182
+
183
+ it('property order change in shape does NOT change shape_hash', () => {
184
+ const specA = `---
185
+ ferret:
186
+ id: api.GET/test
187
+ type: api
188
+ shape:
189
+ type: object
190
+ properties:
191
+ id:
192
+ type: string
193
+ name:
194
+ type: string
195
+ required: [id, name]
196
+ ---
197
+ `;
198
+ const specB = `---
199
+ ferret:
200
+ id: api.GET/test
201
+ type: api
202
+ shape:
203
+ type: object
204
+ properties:
205
+ name:
206
+ type: string
207
+ id:
208
+ type: string
209
+ required: [id, name]
210
+ ---
211
+ `;
212
+ const r1 = extractFromSpecFile('contracts/a.contract.md', specA);
213
+ const r2 = extractFromSpecFile('contracts/b.contract.md', specB);
214
+ // Keys are sorted before hashing — order change is a no-change
215
+ assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
216
+ });
217
+ });
218
+
219
+ describe('extractFromSpecFile — S50 source block', () => {
220
+ const SPEC_WITH_SOURCE = `---
221
+ ferret:
222
+ id: auth.jwt
223
+ type: type
224
+ source:
225
+ file: src/auth/jwt.ts
226
+ symbol: JwtPayload
227
+ shape:
228
+ type: object
229
+ properties:
230
+ sub:
231
+ type: string
232
+ required: [sub]
233
+ ---
234
+ `;
235
+
236
+ const SPEC_WITHOUT_SOURCE = `---
237
+ ferret:
238
+ id: auth.jwt
239
+ type: type
240
+ shape:
241
+ type: object
242
+ properties:
243
+ sub:
244
+ type: string
245
+ required: [sub]
246
+ ---
247
+ `;
248
+
249
+ it('parses source.file and source.symbol when present', () => {
250
+ const result = extractFromSpecFile('contracts/auth/jwt.contract.md', SPEC_WITH_SOURCE);
251
+ assert.equal(result.contracts.length, 1);
252
+ assert.equal(result.contracts[0].sourceFile, 'src/auth/jwt.ts');
253
+ assert.equal(result.contracts[0].sourceSymbol, 'JwtPayload');
254
+ });
255
+
256
+ it('has undefined sourceFile and sourceSymbol when source block absent', () => {
257
+ const result = extractFromSpecFile('contracts/auth/jwt.contract.md', SPEC_WITHOUT_SOURCE);
258
+ assert.equal(result.contracts.length, 1);
259
+ assert.equal(result.contracts[0].sourceFile, undefined);
260
+ assert.equal(result.contracts[0].sourceSymbol, undefined);
261
+ });
262
+
263
+ it('ignores source block with non-string fields gracefully', () => {
264
+ const specBadSource = `---
265
+ ferret:
266
+ id: auth.jwt
267
+ type: type
268
+ source:
269
+ file: 42
270
+ symbol: [not, a, string]
271
+ shape:
272
+ type: object
273
+ ---
274
+ `;
275
+ const result = extractFromSpecFile('contracts/auth/jwt.contract.md', specBadSource);
276
+ assert.equal(result.contracts[0].sourceFile, undefined);
277
+ assert.equal(result.contracts[0].sourceSymbol, undefined);
278
+ });
279
+
280
+ it('source block does not affect shape_hash', () => {
281
+ const r1 = extractFromSpecFile('contracts/a.contract.md', SPEC_WITH_SOURCE);
282
+ const r2 = extractFromSpecFile('contracts/b.contract.md', SPEC_WITHOUT_SOURCE);
283
+ assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
284
+ });
285
+
286
+ it('identical source blocks produce identical shape_hash (deterministic snapshot)', () => {
287
+ const r1 = extractFromSpecFile('contracts/a.contract.md', SPEC_WITH_SOURCE);
288
+ const r2 = extractFromSpecFile('contracts/b.contract.md', SPEC_WITH_SOURCE);
289
+ assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
290
+ });
291
+ });
@@ -0,0 +1,87 @@
1
+ // Synchronous only. No async. No await. Anywhere in this file.
2
+ // Extractor layer — turns a spec file into an ExtractionResult using gray-matter.
3
+
4
+ import matter from 'gray-matter';
5
+ import { validateContractType, validateFerretSchema } from './validator.js';
6
+ import { hashSchema } from './hash.js';
7
+ import type { ContractType } from './contract-types.js';
8
+
9
+ export interface ExtractionResult {
10
+ filePath: string;
11
+ fileType: 'spec' | 'code';
12
+ contracts: Array<{
13
+ id: string;
14
+ type: ContractType;
15
+ shape: object;
16
+ shape_hash: string;
17
+ imports: string[];
18
+ /** Path to the TypeScript source file (code-first contracts only). */
19
+ sourceFile?: string;
20
+ /** TypeScript symbol name (code-first contracts only). */
21
+ sourceSymbol?: string;
22
+ }>;
23
+ extractedBy: 'gray-matter' | 'tree-sitter';
24
+ extractedAt: number; // unix ms
25
+ warning?: 'no-frontmatter';
26
+ }
27
+
28
+ /**
29
+ * Extracts a FerretContract from a spec file's YAML frontmatter.
30
+ *
31
+ * Rules:
32
+ * - No frontmatter → return empty contracts + warning: 'no-frontmatter'
33
+ * - Missing required field (id, type, shape) → throw with field name in message
34
+ * - Unsupported schema keyword → warn via validateFerretSchema, continue
35
+ * - Identical files → identical shape_hash (deterministic)
36
+ */
37
+ export function extractFromSpecFile(filePath: string, fileContent: string): ExtractionResult {
38
+ const { data } = matter(fileContent);
39
+ const ferret = data?.ferret;
40
+
41
+ if (!ferret) {
42
+ return {
43
+ filePath,
44
+ fileType: 'spec',
45
+ contracts: [],
46
+ extractedBy: 'gray-matter',
47
+ extractedAt: Date.now(),
48
+ warning: 'no-frontmatter',
49
+ };
50
+ }
51
+
52
+ const missingFields = ['id', 'type', 'shape'].filter((f) => !ferret[f]);
53
+ if (missingFields.length > 0) {
54
+ throw new Error(`Missing required frontmatter fields in ${filePath}: ${missingFields.join(', ')}`);
55
+ }
56
+
57
+ const typeValidation = validateContractType(ferret.type, filePath);
58
+ if (!typeValidation.valid) {
59
+ throw new Error(typeValidation.error);
60
+ }
61
+
62
+ // validateFerretSchema never throws — only warns on unsupported keywords
63
+ const validation = validateFerretSchema(ferret.shape, filePath);
64
+ validation.warnings.forEach((w) => process.stderr.write(w + '\n'));
65
+
66
+ const source = ferret.source as { file?: unknown; symbol?: unknown } | undefined;
67
+ const sourceFile = typeof source?.file === 'string' ? source.file : undefined;
68
+ const sourceSymbol = typeof source?.symbol === 'string' ? source.symbol : undefined;
69
+
70
+ return {
71
+ filePath,
72
+ fileType: 'spec',
73
+ contracts: [
74
+ {
75
+ id: ferret.id as string,
76
+ type: typeValidation.value,
77
+ shape: ferret.shape as object,
78
+ shape_hash: hashSchema(ferret.shape),
79
+ imports: Array.isArray(ferret.imports) ? (ferret.imports as string[]) : [],
80
+ ...(sourceFile !== undefined && { sourceFile }),
81
+ ...(sourceSymbol !== undefined && { sourceSymbol }),
82
+ },
83
+ ],
84
+ extractedBy: 'gray-matter',
85
+ extractedAt: Date.now(),
86
+ };
87
+ }
@@ -0,0 +1,26 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /**
4
+ * Produces a stable SHA-256 hex fingerprint of a schema object.
5
+ * Used for quick change detection — if the hash changes, the schema changed.
6
+ * JSON.stringify is order-sensitive on keys, so we sort keys for stability.
7
+ */
8
+ export function hashSchema(schema: unknown): string {
9
+ const sortObject = (obj: any): any => {
10
+ if (obj === null || typeof obj !== 'object') {
11
+ return obj;
12
+ }
13
+ if (Array.isArray(obj)) {
14
+ return obj.map(sortObject);
15
+ }
16
+ return Object.keys(obj)
17
+ .sort()
18
+ .reduce((acc, key) => {
19
+ acc[key] = sortObject(obj[key]);
20
+ return acc;
21
+ }, {} as any);
22
+ };
23
+
24
+ const stable = JSON.stringify(sortObject(schema));
25
+ return createHash('sha256').update(stable).digest('hex');
26
+ }