@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,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
+ });
@@ -0,0 +1,68 @@
1
+ // Pure function. No I/O. No side effects. Ever.
2
+ // Upward drift classifier — detects when a TypeScript implementation diverges from
3
+ // its declared contract schema (code → spec direction).
4
+
5
+ import { compareSchemas } from './validator.js';
6
+
7
+ export type UpwardDriftClass = 'BREAKING' | 'NON_BREAKING' | 'NOOP';
8
+
9
+ export interface UpwardDriftResult {
10
+ contractId: string;
11
+ driftClass: UpwardDriftClass;
12
+ sourceFile: string;
13
+ sourceSymbol: string;
14
+ reason: string;
15
+ }
16
+
17
+ /**
18
+ * Classifies the drift between a declared contract schema and the live code-derived schema.
19
+ *
20
+ * Input:
21
+ * - declaredSchema: the schema declared in the .contract.md frontmatter (the spec)
22
+ * - codeSchema: the schema extracted from the TypeScript source at lint time
23
+ *
24
+ * Output:
25
+ * - BREAKING: code change breaks the declared contract (required field removed, type changed, etc.)
26
+ * - NON_BREAKING: code change is additive but not declared (optional field added, enum value added)
27
+ * - NOOP: code and declared schema are semantically identical (hash-stable, no action needed)
28
+ *
29
+ * Uses the same classification taxonomy as compareSchemas for consistency.
30
+ * No-op formatting changes (property reorder with stable hash) return NOOP.
31
+ */
32
+ export function classifyUpwardDrift(
33
+ contractId: string,
34
+ declaredSchema: unknown,
35
+ codeSchema: unknown,
36
+ sourceFile: string,
37
+ sourceSymbol: string,
38
+ ): UpwardDriftResult {
39
+ const comparison = compareSchemas(declaredSchema, codeSchema);
40
+
41
+ if (comparison.classification === 'no-change') {
42
+ return {
43
+ contractId,
44
+ driftClass: 'NOOP',
45
+ sourceFile,
46
+ sourceSymbol,
47
+ reason: comparison.reason,
48
+ };
49
+ }
50
+
51
+ if (comparison.classification === 'breaking') {
52
+ return {
53
+ contractId,
54
+ driftClass: 'BREAKING',
55
+ sourceFile,
56
+ sourceSymbol,
57
+ reason: comparison.reason,
58
+ };
59
+ }
60
+
61
+ return {
62
+ contractId,
63
+ driftClass: 'NON_BREAKING',
64
+ sourceFile,
65
+ sourceSymbol,
66
+ reason: comparison.reason,
67
+ };
68
+ }
@@ -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
+ }