@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.
- package/README.md +62 -62
- package/dist/context/index.d.ts +5 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +47 -1
- package/dist/context/index.js.map +1 -1
- package/dist/extractor/__fixtures__/typescript/generics.d.ts +6 -0
- package/dist/extractor/__fixtures__/typescript/generics.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/generics.js +2 -0
- package/dist/extractor/__fixtures__/typescript/generics.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/intersections.d.ts +6 -0
- package/dist/extractor/__fixtures__/typescript/intersections.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/intersections.js +2 -0
- package/dist/extractor/__fixtures__/typescript/intersections.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts +8 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.js +2 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unions.d.ts +5 -0
- package/dist/extractor/__fixtures__/typescript/unions.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unions.js +2 -0
- package/dist/extractor/__fixtures__/typescript/unions.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts +7 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js +2 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js.map +1 -0
- package/dist/extractor/contract-types.d.ts +5 -0
- package/dist/extractor/contract-types.d.ts.map +1 -0
- package/dist/extractor/contract-types.js +8 -0
- package/dist/extractor/contract-types.js.map +1 -0
- package/dist/extractor/frontmatter.d.ts +6 -1
- package/dist/extractor/frontmatter.d.ts.map +1 -1
- package/dist/extractor/frontmatter.js +17 -6
- package/dist/extractor/frontmatter.js.map +1 -1
- package/dist/extractor/typescript.d.ts +6 -1
- package/dist/extractor/typescript.d.ts.map +1 -1
- package/dist/extractor/typescript.js +403 -185
- package/dist/extractor/typescript.js.map +1 -1
- package/dist/extractor/upward-classifier.d.ts +25 -0
- package/dist/extractor/upward-classifier.d.ts.map +1 -0
- package/dist/extractor/upward-classifier.js +48 -0
- package/dist/extractor/upward-classifier.js.map +1 -0
- package/dist/extractor/validator.d.ts +9 -0
- package/dist/extractor/validator.d.ts.map +1 -1
- package/dist/extractor/validator.js +28 -9
- package/dist/extractor/validator.js.map +1 -1
- package/dist/index.d.ts +14 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -12
- package/dist/index.js.map +1 -1
- package/dist/reconciler/index.d.ts +10 -0
- package/dist/reconciler/index.d.ts.map +1 -1
- package/dist/reconciler/index.js +76 -0
- package/dist/reconciler/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +106 -101
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/types.d.ts +6 -2
- package/dist/store/types.d.ts.map +1 -1
- package/package.json +47 -44
- package/src/config.ts +48 -0
- package/src/context/index.test.ts +274 -0
- package/src/context/index.ts +178 -0
- package/src/extractor/__fixtures__/typescript/generics.golden.json +36 -0
- package/src/extractor/__fixtures__/typescript/generics.ts +5 -0
- package/src/extractor/__fixtures__/typescript/intersections.golden.json +18 -0
- package/src/extractor/__fixtures__/typescript/intersections.ts +5 -0
- package/src/extractor/__fixtures__/typescript/optional-nested.golden.json +41 -0
- package/src/extractor/__fixtures__/typescript/optional-nested.ts +7 -0
- package/src/extractor/__fixtures__/typescript/unions.golden.json +30 -0
- package/src/extractor/__fixtures__/typescript/unions.ts +4 -0
- package/src/extractor/__fixtures__/typescript/unsupported-syntax.golden.json +20 -0
- package/src/extractor/__fixtures__/typescript/unsupported-syntax.ts +6 -0
- package/src/extractor/contract-types.ts +11 -0
- package/src/extractor/frontmatter.test.ts +291 -0
- package/src/extractor/frontmatter.ts +87 -0
- package/src/extractor/hash.ts +26 -0
- package/src/extractor/typescript.test.ts +339 -0
- package/src/extractor/typescript.ts +597 -0
- package/src/extractor/upward-classifier.test.ts +188 -0
- package/src/extractor/upward-classifier.ts +68 -0
- package/src/extractor/validator.test.ts +228 -0
- package/src/extractor/validator.ts +199 -0
- package/src/index.ts +17 -0
- package/src/reconciler/import-suggestions.test.ts +101 -0
- package/src/reconciler/import-suggestions.ts +157 -0
- package/src/reconciler/index.test.ts +248 -0
- package/src/reconciler/index.ts +445 -0
- package/src/store/factory.ts +54 -0
- package/src/store/postgres.ts +84 -0
- package/src/store/sqlite.test.ts +328 -0
- package/src/store/sqlite.ts +231 -0
- package/src/store/types.ts +101 -0
- 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
|
+
}
|