@specferret/core 0.1.4 → 0.3.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 (103) hide show
  1. package/dist/config.d.ts +3 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js.map +1 -1
  4. package/dist/contract.d.ts +16 -0
  5. package/dist/contract.d.ts.map +1 -0
  6. package/dist/contract.js +17 -0
  7. package/dist/contract.js.map +1 -0
  8. package/dist/extractor/__fixtures__/cross-file-id.fixture.d.ts +15 -0
  9. package/dist/extractor/__fixtures__/cross-file-id.fixture.d.ts.map +1 -0
  10. package/dist/extractor/__fixtures__/cross-file-id.fixture.js +9 -0
  11. package/dist/extractor/__fixtures__/cross-file-id.fixture.js.map +1 -0
  12. package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.d.ts +14 -0
  13. package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.d.ts.map +1 -0
  14. package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.js +9 -0
  15. package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.js.map +1 -0
  16. package/dist/extractor/__fixtures__/empty.fixture.d.ts +6 -0
  17. package/dist/extractor/__fixtures__/empty.fixture.d.ts.map +1 -0
  18. package/dist/extractor/__fixtures__/empty.fixture.js +5 -0
  19. package/dist/extractor/__fixtures__/empty.fixture.js.map +1 -0
  20. package/dist/extractor/__fixtures__/external-no-id.fixture.d.ts +8 -0
  21. package/dist/extractor/__fixtures__/external-no-id.fixture.d.ts.map +1 -0
  22. package/dist/extractor/__fixtures__/external-no-id.fixture.js +7 -0
  23. package/dist/extractor/__fixtures__/external-no-id.fixture.js.map +1 -0
  24. package/dist/extractor/__fixtures__/external-with-id.fixture.d.ts +9 -0
  25. package/dist/extractor/__fixtures__/external-with-id.fixture.d.ts.map +1 -0
  26. package/dist/extractor/__fixtures__/external-with-id.fixture.js +8 -0
  27. package/dist/extractor/__fixtures__/external-with-id.fixture.js.map +1 -0
  28. package/dist/extractor/__fixtures__/mixed.fixture.d.ts +24 -0
  29. package/dist/extractor/__fixtures__/mixed.fixture.d.ts.map +1 -0
  30. package/dist/extractor/__fixtures__/mixed.fixture.js +15 -0
  31. package/dist/extractor/__fixtures__/mixed.fixture.js.map +1 -0
  32. package/dist/extractor/__fixtures__/one-contract.fixture.d.ts +10 -0
  33. package/dist/extractor/__fixtures__/one-contract.fixture.d.ts.map +1 -0
  34. package/dist/extractor/__fixtures__/one-contract.fixture.js +10 -0
  35. package/dist/extractor/__fixtures__/one-contract.fixture.js.map +1 -0
  36. package/dist/extractor/__fixtures__/optional-fields.fixture.d.ts +10 -0
  37. package/dist/extractor/__fixtures__/optional-fields.fixture.d.ts.map +1 -0
  38. package/dist/extractor/__fixtures__/optional-fields.fixture.js +10 -0
  39. package/dist/extractor/__fixtures__/optional-fields.fixture.js.map +1 -0
  40. package/dist/extractor/__fixtures__/same-file-consumes.fixture.d.ts +20 -0
  41. package/dist/extractor/__fixtures__/same-file-consumes.fixture.d.ts.map +1 -0
  42. package/dist/extractor/__fixtures__/same-file-consumes.fixture.js +11 -0
  43. package/dist/extractor/__fixtures__/same-file-consumes.fixture.js.map +1 -0
  44. package/dist/extractor/__fixtures__/three-unresolved.fixture.d.ts +38 -0
  45. package/dist/extractor/__fixtures__/three-unresolved.fixture.d.ts.map +1 -0
  46. package/dist/extractor/__fixtures__/three-unresolved.fixture.js +21 -0
  47. package/dist/extractor/__fixtures__/three-unresolved.fixture.js.map +1 -0
  48. package/dist/extractor/__fixtures__/throws-on-import.fixture.d.ts +2 -0
  49. package/dist/extractor/__fixtures__/throws-on-import.fixture.d.ts.map +1 -0
  50. package/dist/extractor/__fixtures__/throws-on-import.fixture.js +5 -0
  51. package/dist/extractor/__fixtures__/throws-on-import.fixture.js.map +1 -0
  52. package/dist/extractor/frontmatter.d.ts +5 -1
  53. package/dist/extractor/frontmatter.d.ts.map +1 -1
  54. package/dist/extractor/frontmatter.js +5 -0
  55. package/dist/extractor/frontmatter.js.map +1 -1
  56. package/dist/extractor/typescript-contract.d.ts +3 -0
  57. package/dist/extractor/typescript-contract.d.ts.map +1 -0
  58. package/dist/extractor/typescript-contract.js +67 -0
  59. package/dist/extractor/typescript-contract.js.map +1 -0
  60. package/dist/extractor/upward-classifier.d.ts +25 -0
  61. package/dist/extractor/upward-classifier.d.ts.map +1 -0
  62. package/dist/extractor/upward-classifier.js +48 -0
  63. package/dist/extractor/upward-classifier.js.map +1 -0
  64. package/dist/index.d.ts +4 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +4 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/status/index.d.ts +20 -0
  69. package/dist/status/index.d.ts.map +1 -0
  70. package/dist/status/index.js +46 -0
  71. package/dist/status/index.js.map +1 -0
  72. package/dist/store/sqlite.d.ts +1 -1
  73. package/dist/store/sqlite.d.ts.map +1 -1
  74. package/dist/store/sqlite.js +37 -32
  75. package/dist/store/sqlite.js.map +1 -1
  76. package/dist/store/types.d.ts +6 -2
  77. package/dist/store/types.d.ts.map +1 -1
  78. package/package.json +3 -2
  79. package/src/config.ts +3 -0
  80. package/src/contract.test.ts +99 -0
  81. package/src/contract.ts +38 -0
  82. package/src/extractor/__fixtures__/cross-file-id.fixture.ts +9 -0
  83. package/src/extractor/__fixtures__/cross-file-unresolved.fixture.ts +9 -0
  84. package/src/extractor/__fixtures__/empty.fixture.ts +4 -0
  85. package/src/extractor/__fixtures__/external-no-id.fixture.ts +7 -0
  86. package/src/extractor/__fixtures__/external-with-id.fixture.ts +8 -0
  87. package/src/extractor/__fixtures__/mixed.fixture.ts +17 -0
  88. package/src/extractor/__fixtures__/one-contract.fixture.ts +10 -0
  89. package/src/extractor/__fixtures__/optional-fields.fixture.ts +10 -0
  90. package/src/extractor/__fixtures__/same-file-consumes.fixture.ts +12 -0
  91. package/src/extractor/__fixtures__/three-unresolved.fixture.ts +24 -0
  92. package/src/extractor/__fixtures__/throws-on-import.fixture.ts +3 -0
  93. package/src/extractor/frontmatter.test.ts +74 -0
  94. package/src/extractor/frontmatter.ts +11 -1
  95. package/src/extractor/typescript-contract.test.ts +143 -0
  96. package/src/extractor/typescript-contract.ts +76 -0
  97. package/src/extractor/upward-classifier.test.ts +188 -0
  98. package/src/extractor/upward-classifier.ts +68 -0
  99. package/src/index.ts +4 -0
  100. package/src/status/index.ts +68 -0
  101. package/src/store/sqlite.test.ts +135 -79
  102. package/src/store/sqlite.ts +45 -92
  103. package/src/store/types.ts +7 -6
@@ -0,0 +1,4 @@
1
+ // No ferret contracts here — only primitives and non-contract objects
2
+ export const version = '1.0.0';
3
+ export const count = 42;
4
+ export const config = { debug: true };
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod';
2
+
3
+ // External contract with no id — cross-file refs to this will be unresolvable
4
+ export const externalNoId = {
5
+ value: 'External contract without id',
6
+ output: { data: z.string() },
7
+ };
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+
3
+ // External contract with an explicit id — cross-file refs resolve via c.id
4
+ export const externalWithId = {
5
+ value: 'External contract with explicit id',
6
+ output: { data: z.string() },
7
+ id: 'external.known.id',
8
+ };
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+
3
+ export const validContract = {
4
+ value: 'valid',
5
+ output: { name: z.string() },
6
+ };
7
+
8
+ export const anotherValid = {
9
+ value: 'another valid',
10
+ output: { count: z.number() },
11
+ };
12
+
13
+ // These should be skipped by isContract
14
+ export const notAContract = 42;
15
+ export const alsoNotAContract = 'just a string';
16
+ export const missingOutput = { value: 'oops' };
17
+ export const missingValue = { output: { x: z.string() } };
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+
3
+ export const userContract = {
4
+ value: 'User shape contract',
5
+ output: {
6
+ id: z.string().uuid(),
7
+ name: z.string(),
8
+ email: z.string().email(),
9
+ },
10
+ };
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+
3
+ export const optionalFieldsContract = {
4
+ value: 'Contract with optional fields',
5
+ output: {
6
+ required_field: z.string(),
7
+ optional_field: z.string().optional(),
8
+ optional_number: z.number().optional(),
9
+ },
10
+ };
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+
3
+ export const baseContract = {
4
+ value: 'Base contract',
5
+ output: { token: z.string() },
6
+ };
7
+
8
+ export const dependentContract = {
9
+ value: 'Dependent contract that consumes baseContract',
10
+ output: { result: z.string() },
11
+ consumes: [baseContract],
12
+ };
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+
3
+ // Three separate unresolvable external references (not exported, no id)
4
+ const ext1 = { value: 'ext1', output: { a: z.string() } };
5
+ const ext2 = { value: 'ext2', output: { b: z.string() } };
6
+ const ext3 = { value: 'ext3', output: { c: z.string() } };
7
+
8
+ export const contractA = {
9
+ value: 'Contract A',
10
+ output: { x: z.string() },
11
+ consumes: [ext1],
12
+ };
13
+
14
+ export const contractB = {
15
+ value: 'Contract B',
16
+ output: { y: z.string() },
17
+ consumes: [ext2],
18
+ };
19
+
20
+ export const contractC = {
21
+ value: 'Contract C',
22
+ output: { zField: z.string() },
23
+ consumes: [ext3],
24
+ };
@@ -0,0 +1,3 @@
1
+ // Fixture: throws synchronously at module top-level.
2
+ // Used to verify that extractFromContractFile() does not swallow import errors.
3
+ throw new Error('intentional module-level throw');
@@ -215,3 +215,77 @@ ferret:
215
215
  assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
216
216
  });
217
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
+ });
@@ -15,8 +15,12 @@ export interface ExtractionResult {
15
15
  shape: object;
16
16
  shape_hash: string;
17
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;
18
22
  }>;
19
- extractedBy: 'gray-matter' | 'tree-sitter';
23
+ extractedBy: 'gray-matter' | 'tree-sitter' | 'typescript';
20
24
  extractedAt: number; // unix ms
21
25
  warning?: 'no-frontmatter';
22
26
  }
@@ -59,6 +63,10 @@ export function extractFromSpecFile(filePath: string, fileContent: string): Extr
59
63
  const validation = validateFerretSchema(ferret.shape, filePath);
60
64
  validation.warnings.forEach((w) => process.stderr.write(w + '\n'));
61
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
+
62
70
  return {
63
71
  filePath,
64
72
  fileType: 'spec',
@@ -69,6 +77,8 @@ export function extractFromSpecFile(filePath: string, fileContent: string): Extr
69
77
  shape: ferret.shape as object,
70
78
  shape_hash: hashSchema(ferret.shape),
71
79
  imports: Array.isArray(ferret.imports) ? (ferret.imports as string[]) : [],
80
+ ...(sourceFile !== undefined && { sourceFile }),
81
+ ...(sourceSymbol !== undefined && { sourceSymbol }),
72
82
  },
73
83
  ],
74
84
  extractedBy: 'gray-matter',
@@ -0,0 +1,143 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, expect, it, spyOn } from 'bun:test';
3
+ import { join } from 'node:path';
4
+ import { extractFromContractFile } from './typescript-contract.js';
5
+
6
+ const fixtures = (name: string) => join(import.meta.dir, '__fixtures__', name);
7
+
8
+ describe('extractFromContractFile', () => {
9
+ it('single valid export → one contract with correct id, shape, shape_hash, imports', async () => {
10
+ const result = await extractFromContractFile(fixtures('one-contract.fixture.ts'));
11
+
12
+ assert.equal(result.contracts.length, 1);
13
+ assert.equal(result.contracts[0].id, 'userContract');
14
+ assert.equal(typeof result.contracts[0].shape, 'object');
15
+ assert.equal(typeof result.contracts[0].shape_hash, 'string');
16
+ assert.equal(result.contracts[0].shape_hash.length, 64);
17
+ assert.deepEqual(result.contracts[0].imports, []);
18
+ assert.equal(result.warning, undefined);
19
+ });
20
+
21
+ it('multiple exports — only valid contracts extracted, non-contracts skipped', async () => {
22
+ const result = await extractFromContractFile(fixtures('mixed.fixture.ts'));
23
+
24
+ const ids = result.contracts.map((c) => c.id);
25
+ assert.equal(result.contracts.length, 2);
26
+ assert.ok(ids.includes('validContract'));
27
+ assert.ok(ids.includes('anotherValid'));
28
+ });
29
+
30
+ it('no ferret contracts → empty contracts array + warning: no-frontmatter', async () => {
31
+ const result = await extractFromContractFile(fixtures('empty.fixture.ts'));
32
+
33
+ assert.equal(result.contracts.length, 0);
34
+ assert.equal(result.warning, 'no-frontmatter');
35
+ });
36
+
37
+ it('extractedBy is always "typescript"', async () => {
38
+ const a = await extractFromContractFile(fixtures('one-contract.fixture.ts'));
39
+ const b = await extractFromContractFile(fixtures('empty.fixture.ts'));
40
+
41
+ assert.equal(a.extractedBy, 'typescript');
42
+ assert.equal(b.extractedBy, 'typescript');
43
+ });
44
+
45
+ it('extractedAt is a unix ms timestamp within 1s of Date.now()', async () => {
46
+ const before = Date.now();
47
+ const result = await extractFromContractFile(fixtures('one-contract.fixture.ts'));
48
+ const after = Date.now();
49
+
50
+ assert.ok(result.extractedAt >= before - 1000);
51
+ assert.ok(result.extractedAt <= after + 1000);
52
+ });
53
+
54
+ it('shape hash is deterministic — two runs on same file produce identical hash', async () => {
55
+ // Clear module cache by using a unique query approach isn't possible cleanly in Bun,
56
+ // but the hash must be stable across calls even with the cached module.
57
+ const r1 = await extractFromContractFile(fixtures('one-contract.fixture.ts'));
58
+ const r2 = await extractFromContractFile(fixtures('one-contract.fixture.ts'));
59
+
60
+ assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
61
+ });
62
+
63
+ it('zod schema with optional fields extracts without throwing', async () => {
64
+ await assert.doesNotReject(() =>
65
+ extractFromContractFile(fixtures('optional-fields.fixture.ts')),
66
+ );
67
+
68
+ const result = await extractFromContractFile(fixtures('optional-fields.fixture.ts'));
69
+ assert.equal(result.contracts.length, 1);
70
+ assert.equal(result.contracts[0].id, 'optionalFieldsContract');
71
+ });
72
+
73
+ it('same-file consumes reference resolves to the correct export name', async () => {
74
+ const result = await extractFromContractFile(fixtures('same-file-consumes.fixture.ts'));
75
+
76
+ const dependent = result.contracts.find((c) => c.id === 'dependentContract');
77
+ assert.ok(dependent, 'dependentContract not found in result');
78
+ assert.deepEqual(dependent.imports, ['baseContract']);
79
+ });
80
+
81
+ it('cross-file reference with explicit id resolves to c.id', async () => {
82
+ const result = await extractFromContractFile(fixtures('cross-file-id.fixture.ts'));
83
+
84
+ assert.equal(result.contracts.length, 1);
85
+ assert.deepEqual(result.contracts[0].imports, ['external.known.id']);
86
+ });
87
+
88
+ it('cross-file reference without id emits exactly one warning and returns [unresolved]', async () => {
89
+ const spy = spyOn(process.stderr, 'write').mockImplementation(() => true);
90
+ try {
91
+ const result = await extractFromContractFile(fixtures('cross-file-unresolved.fixture.ts'));
92
+
93
+ expect(spy.mock.calls.length).toBe(1);
94
+ assert.deepEqual(result.contracts[0].imports, ['[unresolved]']);
95
+ } finally {
96
+ spy.mockRestore();
97
+ }
98
+ });
99
+
100
+ it('three contracts each with unresolvable cross-file ref → three warnings emitted', async () => {
101
+ const spy = spyOn(process.stderr, 'write').mockImplementation(() => true);
102
+ try {
103
+ const result = await extractFromContractFile(fixtures('three-unresolved.fixture.ts'));
104
+
105
+ expect(spy.mock.calls.length).toBe(3);
106
+ assert.equal(result.contracts.length, 3);
107
+ result.contracts.forEach((c) => {
108
+ assert.deepEqual(c.imports, ['[unresolved]']);
109
+ });
110
+ } finally {
111
+ spy.mockRestore();
112
+ }
113
+ });
114
+
115
+ it('filePath and fileType are set correctly in the result', async () => {
116
+ const filePath = fixtures('one-contract.fixture.ts');
117
+ const result = await extractFromContractFile(filePath);
118
+
119
+ assert.equal(result.filePath, filePath);
120
+ assert.equal(result.fileType, 'code');
121
+ });
122
+
123
+ it('fileType is "code" even when no contracts found', async () => {
124
+ const result = await extractFromContractFile(fixtures('empty.fixture.ts'));
125
+
126
+ assert.equal(result.fileType, 'code');
127
+ });
128
+
129
+ it('each contract has sourceFile and sourceSymbol populated', async () => {
130
+ const filePath = fixtures('one-contract.fixture.ts');
131
+ const result = await extractFromContractFile(filePath);
132
+
133
+ assert.equal(result.contracts[0].sourceFile, filePath);
134
+ assert.equal(result.contracts[0].sourceSymbol, 'userContract');
135
+ });
136
+
137
+ it('module that throws at top-level causes extractFromContractFile to reject', async () => {
138
+ await assert.rejects(
139
+ () => extractFromContractFile(fixtures('throws-on-import.fixture.ts')),
140
+ /intentional module-level throw/,
141
+ );
142
+ });
143
+ });
@@ -0,0 +1,76 @@
1
+ // Extractor layer — turns a .contract.ts file into an ExtractionResult.
2
+ // Uses Bun's native import() — no ts-morph, no AST, no compile step.
3
+
4
+ import { z } from 'zod';
5
+ import { zodToJsonSchema } from 'zod-to-json-schema';
6
+ import { isContract } from '../contract.js';
7
+ import { hashSchema } from './hash.js';
8
+ import type { ExtractionResult } from './frontmatter.js';
9
+
10
+ export async function extractFromContractFile(filePath: string): Promise<ExtractionResult> {
11
+ const mod = (await import(filePath)) as Record<string, unknown>;
12
+
13
+ // Pass 1: build Map<contract object reference → export name> for same-file resolution
14
+ const exportNameMap = new Map<object, string>();
15
+ for (const [exportName, exportValue] of Object.entries(mod)) {
16
+ if (isContract(exportValue)) {
17
+ exportNameMap.set(exportValue, exportName);
18
+ }
19
+ }
20
+
21
+ if (exportNameMap.size === 0) {
22
+ return {
23
+ filePath,
24
+ fileType: 'code',
25
+ contracts: [],
26
+ extractedBy: 'typescript',
27
+ extractedAt: Date.now(),
28
+ warning: 'no-frontmatter',
29
+ };
30
+ }
31
+
32
+ const contracts: ExtractionResult['contracts'] = [];
33
+
34
+ for (const [exportName, exportValue] of Object.entries(mod)) {
35
+ if (!isContract(exportValue)) continue;
36
+
37
+ const shape = zodToJsonSchema(z.object(exportValue.output), { $refStrategy: 'none' });
38
+ const shape_hash = hashSchema(shape);
39
+
40
+ // Pass 2: resolve consumes → import IDs
41
+ const imports: string[] = [];
42
+ if (exportValue.consumes) {
43
+ for (const consumed of exportValue.consumes) {
44
+ const samefile = exportNameMap.get(consumed);
45
+ if (samefile !== undefined) {
46
+ imports.push(samefile);
47
+ } else if (consumed.id !== undefined) {
48
+ imports.push(consumed.id);
49
+ } else {
50
+ process.stderr.write(
51
+ `[specferret] warning: unresolvable consumes reference in '${filePath}' — export '${exportName}' references a contract with no id and no matching same-file export\n`,
52
+ );
53
+ imports.push('[unresolved]');
54
+ }
55
+ }
56
+ }
57
+
58
+ contracts.push({
59
+ id: exportName,
60
+ type: 'type',
61
+ shape,
62
+ shape_hash,
63
+ imports,
64
+ sourceFile: filePath,
65
+ sourceSymbol: exportName,
66
+ });
67
+ }
68
+
69
+ return {
70
+ filePath,
71
+ fileType: 'code',
72
+ contracts,
73
+ extractedBy: 'typescript',
74
+ extractedAt: Date.now(),
75
+ };
76
+ }
@@ -0,0 +1,188 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'bun:test';
3
+ import { classifyUpwardDrift } from './upward-classifier.js';
4
+
5
+ const FILE = 'src/auth/jwt.ts';
6
+ const SYMBOL = 'JwtPayload';
7
+ const CONTRACT_ID = 'auth.jwt';
8
+
9
+ // ─── Canonical declared schema ────────────────────────────────────────────────
10
+
11
+ const DECLARED_SCHEMA = {
12
+ type: 'object',
13
+ properties: {
14
+ sub: { type: 'string' },
15
+ role: { type: 'string', enum: ['admin', 'user'] },
16
+ exp: { type: 'number' },
17
+ },
18
+ required: ['sub', 'role'],
19
+ };
20
+
21
+ describe('classifyUpwardDrift — S51: BREAKING cases', () => {
22
+ it('required field removed from code shape → BREAKING', () => {
23
+ const codeSchema = {
24
+ type: 'object',
25
+ properties: {
26
+ role: { type: 'string', enum: ['admin', 'user'] },
27
+ exp: { type: 'number' },
28
+ },
29
+ required: ['role'],
30
+ };
31
+ const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
32
+ assert.equal(result.driftClass, 'BREAKING');
33
+ assert.match(result.reason, /required field\(s\) removed: sub/);
34
+ assert.equal(result.contractId, CONTRACT_ID);
35
+ assert.equal(result.sourceFile, FILE);
36
+ assert.equal(result.sourceSymbol, SYMBOL);
37
+ });
38
+
39
+ it('field type changed in code → BREAKING', () => {
40
+ const codeSchema = {
41
+ type: 'object',
42
+ properties: {
43
+ sub: { type: 'number' }, // was string
44
+ role: { type: 'string', enum: ['admin', 'user'] },
45
+ exp: { type: 'number' },
46
+ },
47
+ required: ['sub', 'role'],
48
+ };
49
+ const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
50
+ assert.equal(result.driftClass, 'BREAKING');
51
+ assert.match(result.reason, /type changed/);
52
+ });
53
+
54
+ it('required field added in code → BREAKING', () => {
55
+ const codeSchema = {
56
+ type: 'object',
57
+ properties: {
58
+ sub: { type: 'string' },
59
+ role: { type: 'string', enum: ['admin', 'user'] },
60
+ exp: { type: 'number' },
61
+ iat: { type: 'number' },
62
+ },
63
+ required: ['sub', 'role', 'iat'], // iat newly required
64
+ };
65
+ const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
66
+ assert.equal(result.driftClass, 'BREAKING');
67
+ assert.match(result.reason, /required field\(s\) added: iat/);
68
+ });
69
+
70
+ it('property removed entirely from code → BREAKING', () => {
71
+ const codeSchema = {
72
+ type: 'object',
73
+ properties: {
74
+ sub: { type: 'string' },
75
+ exp: { type: 'number' },
76
+ // role removed entirely
77
+ },
78
+ required: ['sub'],
79
+ };
80
+ const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
81
+ assert.equal(result.driftClass, 'BREAKING');
82
+ assert.match(result.reason, /property 'role' removed|required field\(s\) removed/);
83
+ });
84
+
85
+ it('enum value removed from code → BREAKING', () => {
86
+ const codeSchema = {
87
+ type: 'object',
88
+ properties: {
89
+ sub: { type: 'string' },
90
+ role: { type: 'string', enum: ['admin'] }, // 'user' removed
91
+ exp: { type: 'number' },
92
+ },
93
+ required: ['sub', 'role'],
94
+ };
95
+ const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
96
+ assert.equal(result.driftClass, 'BREAKING');
97
+ assert.match(result.reason, /enum value\(s\) removed/);
98
+ });
99
+ });
100
+
101
+ describe('classifyUpwardDrift — S51: NON_BREAKING cases', () => {
102
+ it('optional field added in code → NON_BREAKING', () => {
103
+ const codeSchema = {
104
+ type: 'object',
105
+ properties: {
106
+ sub: { type: 'string' },
107
+ role: { type: 'string', enum: ['admin', 'user'] },
108
+ exp: { type: 'number' },
109
+ nbf: { type: 'number' }, // new optional field
110
+ },
111
+ required: ['sub', 'role'],
112
+ };
113
+ const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
114
+ assert.equal(result.driftClass, 'NON_BREAKING');
115
+ assert.match(result.reason, /optional field\(s\) added/);
116
+ });
117
+
118
+ it('enum value added to code → NON_BREAKING', () => {
119
+ const codeSchema = {
120
+ type: 'object',
121
+ properties: {
122
+ sub: { type: 'string' },
123
+ role: { type: 'string', enum: ['admin', 'user', 'superuser'] }, // 'superuser' added
124
+ exp: { type: 'number' },
125
+ },
126
+ required: ['sub', 'role'],
127
+ };
128
+ const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
129
+ assert.equal(result.driftClass, 'NON_BREAKING');
130
+ assert.match(result.reason, /enum value\(s\) added/);
131
+ });
132
+ });
133
+
134
+ describe('classifyUpwardDrift — S51: NOOP cases', () => {
135
+ it('identical schemas → NOOP', () => {
136
+ const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, DECLARED_SCHEMA, FILE, SYMBOL);
137
+ assert.equal(result.driftClass, 'NOOP');
138
+ assert.match(result.reason, /semantically identical/);
139
+ });
140
+
141
+ it('property order change in code does not produce drift (hash-stable)', () => {
142
+ const codeSchema = {
143
+ type: 'object',
144
+ properties: {
145
+ // same properties, different key order
146
+ exp: { type: 'number' },
147
+ role: { type: 'string', enum: ['admin', 'user'] },
148
+ sub: { type: 'string' },
149
+ },
150
+ required: ['role', 'sub'], // reordered required array
151
+ };
152
+ const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
153
+ assert.equal(result.driftClass, 'NOOP');
154
+ });
155
+
156
+ it('empty vs empty schemas → NOOP', () => {
157
+ const result = classifyUpwardDrift(CONTRACT_ID, {}, {}, FILE, SYMBOL);
158
+ assert.equal(result.driftClass, 'NOOP');
159
+ });
160
+ });
161
+
162
+ describe('classifyUpwardDrift — S51: result shape', () => {
163
+ it('result always contains all required fields', () => {
164
+ const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, DECLARED_SCHEMA, FILE, SYMBOL);
165
+ assert.ok('contractId' in result);
166
+ assert.ok('driftClass' in result);
167
+ assert.ok('sourceFile' in result);
168
+ assert.ok('sourceSymbol' in result);
169
+ assert.ok('reason' in result);
170
+ assert.equal(typeof result.reason, 'string');
171
+ });
172
+
173
+ it('is a pure function — same inputs produce same output', () => {
174
+ const r1 = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, DECLARED_SCHEMA, FILE, SYMBOL);
175
+ const r2 = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, DECLARED_SCHEMA, FILE, SYMBOL);
176
+ assert.deepEqual(r1, r2);
177
+ });
178
+
179
+ it('does not mutate inputs', () => {
180
+ const declared = { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] };
181
+ const code = { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] };
182
+ const declaredBefore = JSON.stringify(declared);
183
+ const codeBefore = JSON.stringify(code);
184
+ classifyUpwardDrift(CONTRACT_ID, declared, code, FILE, SYMBOL);
185
+ assert.equal(JSON.stringify(declared), declaredBefore);
186
+ assert.equal(JSON.stringify(code), codeBefore);
187
+ });
188
+ });