@specferret/core 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +2 -1
- package/dist/extractor/frontmatter.d.ts.map +1 -1
- package/dist/extractor/frontmatter.js +12 -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/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 +13 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -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/package.json +4 -1
- 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 +217 -0
- package/src/extractor/frontmatter.ts +77 -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/validator.test.ts +228 -0
- package/src/extractor/validator.ts +199 -0
- package/src/index.ts +16 -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 +272 -0
- package/src/store/sqlite.ts +278 -0
- package/src/store/types.ts +100 -0
- package/src/utils/paths.ts +24 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'bun:test';
|
|
3
|
+
import { extractFromSpecFile } from './frontmatter.js';
|
|
4
|
+
import { CONTRACT_TYPES } from './contract-types.js';
|
|
5
|
+
|
|
6
|
+
const VALID_SPEC = `---
|
|
7
|
+
ferret:
|
|
8
|
+
id: api.GET/users
|
|
9
|
+
type: api
|
|
10
|
+
shape:
|
|
11
|
+
response:
|
|
12
|
+
type: array
|
|
13
|
+
items:
|
|
14
|
+
type: object
|
|
15
|
+
properties:
|
|
16
|
+
id:
|
|
17
|
+
type: string
|
|
18
|
+
format: uuid
|
|
19
|
+
name:
|
|
20
|
+
type: string
|
|
21
|
+
required: [id, name]
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# Users Endpoint
|
|
25
|
+
|
|
26
|
+
Returns all users.
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const SPEC_WITH_IMPORTS = `---
|
|
30
|
+
ferret:
|
|
31
|
+
id: api.GET/search
|
|
32
|
+
type: api
|
|
33
|
+
shape:
|
|
34
|
+
response:
|
|
35
|
+
type: array
|
|
36
|
+
items:
|
|
37
|
+
type: string
|
|
38
|
+
imports:
|
|
39
|
+
- auth.jwt
|
|
40
|
+
- tables.document
|
|
41
|
+
---
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const SPEC_WITH_UNSUPPORTED_KEYWORD = `---
|
|
45
|
+
ferret:
|
|
46
|
+
id: tables.user
|
|
47
|
+
type: table
|
|
48
|
+
shape:
|
|
49
|
+
type: object
|
|
50
|
+
allOf:
|
|
51
|
+
- type: string
|
|
52
|
+
properties:
|
|
53
|
+
id:
|
|
54
|
+
type: string
|
|
55
|
+
---
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
const SPEC_NO_FRONTMATTER = `# Just a markdown file
|
|
59
|
+
|
|
60
|
+
No frontmatter here. Ferret should skip this.
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const SPEC_MISSING_FIELDS = `---
|
|
64
|
+
ferret:
|
|
65
|
+
id: api.GET/broken
|
|
66
|
+
type: api
|
|
67
|
+
---
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const SPEC_INVALID_TYPE = `---
|
|
71
|
+
ferret:
|
|
72
|
+
id: api.GET/broken
|
|
73
|
+
type: service
|
|
74
|
+
shape:
|
|
75
|
+
type: object
|
|
76
|
+
---
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
describe('extractFromSpecFile — Task 3', () => {
|
|
80
|
+
it('extracts valid frontmatter correctly', () => {
|
|
81
|
+
const result = extractFromSpecFile('contracts/users.contract.md', VALID_SPEC);
|
|
82
|
+
assert.equal(result.filePath, 'contracts/users.contract.md');
|
|
83
|
+
assert.equal(result.fileType, 'spec');
|
|
84
|
+
assert.equal(result.extractedBy, 'gray-matter');
|
|
85
|
+
assert.equal(result.warning, undefined);
|
|
86
|
+
assert.equal(result.contracts.length, 1);
|
|
87
|
+
assert.equal(result.contracts[0].id, 'api.GET/users');
|
|
88
|
+
assert.equal(result.contracts[0].type, 'api');
|
|
89
|
+
assert.notEqual(result.contracts[0].shape_hash, undefined);
|
|
90
|
+
assert.equal(result.contracts[0].shape_hash.length, 64);
|
|
91
|
+
assert.deepEqual(result.contracts[0].imports, []);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('extracts imports correctly', () => {
|
|
95
|
+
const result = extractFromSpecFile('contracts/search.contract.md', SPEC_WITH_IMPORTS);
|
|
96
|
+
assert.deepEqual(result.contracts[0].imports, ['auth.jwt', 'tables.document']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('missing frontmatter returns warning, empty contracts, does not throw', () => {
|
|
100
|
+
const result = extractFromSpecFile('contracts/plain.contract.md', SPEC_NO_FRONTMATTER);
|
|
101
|
+
assert.equal(result.warning, 'no-frontmatter');
|
|
102
|
+
assert.equal(result.contracts.length, 0);
|
|
103
|
+
assert.equal(result.filePath, 'contracts/plain.contract.md');
|
|
104
|
+
assert.equal(result.fileType, 'spec');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('missing required field "shape" throws with field name in message', () => {
|
|
108
|
+
assert.throws(() => extractFromSpecFile('contracts/broken.contract.md', SPEC_MISSING_FIELDS), /shape/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('missing multiple required fields throws with all field names in message', () => {
|
|
112
|
+
const specMissingAll = `---\nferret:\n someField: value\n---\n`;
|
|
113
|
+
assert.throws(() => extractFromSpecFile('contracts/broken.contract.md', specMissingAll), /id.*type.*shape|Missing required/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('invalid top-level contract type throws and lists allowed values', () => {
|
|
117
|
+
assert.throws(
|
|
118
|
+
() => extractFromSpecFile('contracts/broken.contract.md', SPEC_INVALID_TYPE),
|
|
119
|
+
/Invalid contract type 'service'.*Allowed types: api, table, type, event, flow, config/,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('unsupported schema keyword produces warning, does not fail', () => {
|
|
124
|
+
const stderrOutput: string[] = [];
|
|
125
|
+
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
126
|
+
// Capture stderr writes
|
|
127
|
+
process.stderr.write = (chunk: any) => {
|
|
128
|
+
stderrOutput.push(String(chunk));
|
|
129
|
+
return true;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
let result: ReturnType<typeof extractFromSpecFile> | undefined;
|
|
133
|
+
try {
|
|
134
|
+
result = extractFromSpecFile('contracts/complex.contract.md', SPEC_WITH_UNSUPPORTED_KEYWORD);
|
|
135
|
+
} finally {
|
|
136
|
+
process.stderr.write = originalWrite;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
assert.notEqual(result, undefined);
|
|
140
|
+
assert.equal(result!.contracts.length, 1);
|
|
141
|
+
assert.equal(result!.warning, undefined);
|
|
142
|
+
assert.equal(
|
|
143
|
+
stderrOutput.some((line) => line.includes('allOf')),
|
|
144
|
+
true,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('all six allowed types are accepted without error', () => {
|
|
149
|
+
for (const contractType of CONTRACT_TYPES) {
|
|
150
|
+
const spec = `---
|
|
151
|
+
ferret:
|
|
152
|
+
id: test.${contractType}
|
|
153
|
+
type: ${contractType}
|
|
154
|
+
shape:
|
|
155
|
+
type: object
|
|
156
|
+
---
|
|
157
|
+
`;
|
|
158
|
+
assert.doesNotThrow(() => extractFromSpecFile(`contracts/${contractType}.contract.md`, spec));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('extraction is synchronous — the function itself has no async/await', () => {
|
|
163
|
+
// If extractFromSpecFile returns a Promise, this would be a thenable object
|
|
164
|
+
const result = extractFromSpecFile('contracts/users.contract.md', VALID_SPEC);
|
|
165
|
+
assert.equal(result instanceof Promise, false);
|
|
166
|
+
assert.notEqual(typeof (result as any).then, 'function');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('identical files produce identical shape_hash', () => {
|
|
170
|
+
const r1 = extractFromSpecFile('contracts/a.contract.md', VALID_SPEC);
|
|
171
|
+
const r2 = extractFromSpecFile('contracts/b.contract.md', VALID_SPEC);
|
|
172
|
+
assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('different shapes produce different shape_hash', () => {
|
|
176
|
+
const specA = VALID_SPEC;
|
|
177
|
+
const specB = specA.replace('format: uuid', 'format: email');
|
|
178
|
+
const r1 = extractFromSpecFile('contracts/a.contract.md', specA);
|
|
179
|
+
const r2 = extractFromSpecFile('contracts/b.contract.md', specB);
|
|
180
|
+
assert.notEqual(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('property order change in shape does NOT change shape_hash', () => {
|
|
184
|
+
const specA = `---
|
|
185
|
+
ferret:
|
|
186
|
+
id: api.GET/test
|
|
187
|
+
type: api
|
|
188
|
+
shape:
|
|
189
|
+
type: object
|
|
190
|
+
properties:
|
|
191
|
+
id:
|
|
192
|
+
type: string
|
|
193
|
+
name:
|
|
194
|
+
type: string
|
|
195
|
+
required: [id, name]
|
|
196
|
+
---
|
|
197
|
+
`;
|
|
198
|
+
const specB = `---
|
|
199
|
+
ferret:
|
|
200
|
+
id: api.GET/test
|
|
201
|
+
type: api
|
|
202
|
+
shape:
|
|
203
|
+
type: object
|
|
204
|
+
properties:
|
|
205
|
+
name:
|
|
206
|
+
type: string
|
|
207
|
+
id:
|
|
208
|
+
type: string
|
|
209
|
+
required: [id, name]
|
|
210
|
+
---
|
|
211
|
+
`;
|
|
212
|
+
const r1 = extractFromSpecFile('contracts/a.contract.md', specA);
|
|
213
|
+
const r2 = extractFromSpecFile('contracts/b.contract.md', specB);
|
|
214
|
+
// Keys are sorted before hashing — order change is a no-change
|
|
215
|
+
assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Synchronous only. No async. No await. Anywhere in this file.
|
|
2
|
+
// Extractor layer — turns a spec file into an ExtractionResult using gray-matter.
|
|
3
|
+
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import { validateContractType, validateFerretSchema } from './validator.js';
|
|
6
|
+
import { hashSchema } from './hash.js';
|
|
7
|
+
import type { ContractType } from './contract-types.js';
|
|
8
|
+
|
|
9
|
+
export interface ExtractionResult {
|
|
10
|
+
filePath: string;
|
|
11
|
+
fileType: 'spec' | 'code';
|
|
12
|
+
contracts: Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
type: ContractType;
|
|
15
|
+
shape: object;
|
|
16
|
+
shape_hash: string;
|
|
17
|
+
imports: string[];
|
|
18
|
+
}>;
|
|
19
|
+
extractedBy: 'gray-matter' | 'tree-sitter';
|
|
20
|
+
extractedAt: number; // unix ms
|
|
21
|
+
warning?: 'no-frontmatter';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extracts a FerretContract from a spec file's YAML frontmatter.
|
|
26
|
+
*
|
|
27
|
+
* Rules:
|
|
28
|
+
* - No frontmatter → return empty contracts + warning: 'no-frontmatter'
|
|
29
|
+
* - Missing required field (id, type, shape) → throw with field name in message
|
|
30
|
+
* - Unsupported schema keyword → warn via validateFerretSchema, continue
|
|
31
|
+
* - Identical files → identical shape_hash (deterministic)
|
|
32
|
+
*/
|
|
33
|
+
export function extractFromSpecFile(filePath: string, fileContent: string): ExtractionResult {
|
|
34
|
+
const { data } = matter(fileContent);
|
|
35
|
+
const ferret = data?.ferret;
|
|
36
|
+
|
|
37
|
+
if (!ferret) {
|
|
38
|
+
return {
|
|
39
|
+
filePath,
|
|
40
|
+
fileType: 'spec',
|
|
41
|
+
contracts: [],
|
|
42
|
+
extractedBy: 'gray-matter',
|
|
43
|
+
extractedAt: Date.now(),
|
|
44
|
+
warning: 'no-frontmatter',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const missingFields = ['id', 'type', 'shape'].filter((f) => !ferret[f]);
|
|
49
|
+
if (missingFields.length > 0) {
|
|
50
|
+
throw new Error(`Missing required frontmatter fields in ${filePath}: ${missingFields.join(', ')}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const typeValidation = validateContractType(ferret.type, filePath);
|
|
54
|
+
if (!typeValidation.valid) {
|
|
55
|
+
throw new Error(typeValidation.error);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// validateFerretSchema never throws — only warns on unsupported keywords
|
|
59
|
+
const validation = validateFerretSchema(ferret.shape, filePath);
|
|
60
|
+
validation.warnings.forEach((w) => process.stderr.write(w + '\n'));
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
filePath,
|
|
64
|
+
fileType: 'spec',
|
|
65
|
+
contracts: [
|
|
66
|
+
{
|
|
67
|
+
id: ferret.id as string,
|
|
68
|
+
type: typeValidation.value,
|
|
69
|
+
shape: ferret.shape as object,
|
|
70
|
+
shape_hash: hashSchema(ferret.shape),
|
|
71
|
+
imports: Array.isArray(ferret.imports) ? (ferret.imports as string[]) : [],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
extractedBy: 'gray-matter',
|
|
75
|
+
extractedAt: Date.now(),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Produces a stable SHA-256 hex fingerprint of a schema object.
|
|
5
|
+
* Used for quick change detection — if the hash changes, the schema changed.
|
|
6
|
+
* JSON.stringify is order-sensitive on keys, so we sort keys for stability.
|
|
7
|
+
*/
|
|
8
|
+
export function hashSchema(schema: unknown): string {
|
|
9
|
+
const sortObject = (obj: any): any => {
|
|
10
|
+
if (obj === null || typeof obj !== 'object') {
|
|
11
|
+
return obj;
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(obj)) {
|
|
14
|
+
return obj.map(sortObject);
|
|
15
|
+
}
|
|
16
|
+
return Object.keys(obj)
|
|
17
|
+
.sort()
|
|
18
|
+
.reduce((acc, key) => {
|
|
19
|
+
acc[key] = sortObject(obj[key]);
|
|
20
|
+
return acc;
|
|
21
|
+
}, {} as any);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const stable = JSON.stringify(sortObject(schema));
|
|
25
|
+
return createHash('sha256').update(stable).digest('hex');
|
|
26
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { describe, it } from 'bun:test';
|
|
5
|
+
import { CONTRACT_TYPES } from './contract-types.js';
|
|
6
|
+
import { extractContractsFromTypeScript } from './typescript.js';
|
|
7
|
+
|
|
8
|
+
describe('extractContractsFromTypeScript — S28 acceptance criteria', () => {
|
|
9
|
+
it('extracts deterministic contract shapes from exported interfaces without annotations', () => {
|
|
10
|
+
const src = `
|
|
11
|
+
export interface GetUsersResponse {
|
|
12
|
+
id: string;
|
|
13
|
+
email: string;
|
|
14
|
+
active: boolean;
|
|
15
|
+
createdAt: Date;
|
|
16
|
+
}
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const first = extractContractsFromTypeScript('src/users.ts', src);
|
|
20
|
+
const second = extractContractsFromTypeScript('src/users.ts', src);
|
|
21
|
+
|
|
22
|
+
assert.equal(first.diagnostics.length, 0);
|
|
23
|
+
assert.equal(JSON.stringify(first), JSON.stringify(second));
|
|
24
|
+
assert.equal(first.contracts.length, 1);
|
|
25
|
+
|
|
26
|
+
const contract = first.contracts[0];
|
|
27
|
+
assert.equal(contract.id, 'type.src/users/getusersresponse');
|
|
28
|
+
assert.equal(contract.type, 'type');
|
|
29
|
+
assert.equal(contract.sourceSymbol, 'GetUsersResponse');
|
|
30
|
+
|
|
31
|
+
const shape = contract.shape as Record<string, unknown>;
|
|
32
|
+
assert.equal(shape.type, 'object');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('extracts enum and function signatures from exported declarations', () => {
|
|
36
|
+
const src = `
|
|
37
|
+
export enum UserRole {
|
|
38
|
+
Admin,
|
|
39
|
+
Viewer,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getUser(id: string): UserRole {
|
|
43
|
+
return UserRole.Admin;
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const result = extractContractsFromTypeScript('src/roles.ts', src);
|
|
48
|
+
|
|
49
|
+
assert.equal(result.contracts.length, 2);
|
|
50
|
+
const enumContract = result.contracts.find((contract) => contract.sourceSymbol === 'UserRole');
|
|
51
|
+
const functionContract = result.contracts.find((contract) => contract.sourceSymbol === 'getUser');
|
|
52
|
+
|
|
53
|
+
assert.ok(enumContract);
|
|
54
|
+
assert.ok(functionContract);
|
|
55
|
+
|
|
56
|
+
const enumShape = enumContract?.shape as Record<string, unknown>;
|
|
57
|
+
assert.equal(enumShape.type, 'string');
|
|
58
|
+
|
|
59
|
+
const functionShape = functionContract?.shape as Record<string, unknown>;
|
|
60
|
+
assert.equal(functionShape.type, 'object');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('records diagnostics for unsupported unions', () => {
|
|
64
|
+
const src = `
|
|
65
|
+
export interface ProfileResponse {
|
|
66
|
+
email: string | null;
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const result = extractContractsFromTypeScript('src/profile.ts', src);
|
|
71
|
+
|
|
72
|
+
assert.equal(result.contracts.length, 1);
|
|
73
|
+
assert.ok(result.diagnostics.some((d) => d.includes('Union types are not supported')));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('applies annotation overrides for id and type when present', () => {
|
|
77
|
+
const src = `
|
|
78
|
+
// @ferret-contract: api.GET/profile api
|
|
79
|
+
export interface ProfileResponse {
|
|
80
|
+
email: string;
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
const result = extractContractsFromTypeScript('src/missing.ts', src);
|
|
84
|
+
|
|
85
|
+
assert.equal(result.contracts.length, 1);
|
|
86
|
+
assert.equal(result.contracts[0].id, 'api.GET/profile');
|
|
87
|
+
assert.equal(result.contracts[0].type, 'api');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('accepts all supported annotation contract types', () => {
|
|
91
|
+
for (const contractType of CONTRACT_TYPES) {
|
|
92
|
+
const src = `
|
|
93
|
+
// @ferret-contract: ${contractType}.GET/profile ${contractType}
|
|
94
|
+
export interface ProfileResponse {
|
|
95
|
+
email: string;
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
const result = extractContractsFromTypeScript('src/annotated.ts', src);
|
|
99
|
+
assert.equal(result.errors.length, 0);
|
|
100
|
+
assert.equal(result.contracts.length, 1);
|
|
101
|
+
assert.equal(result.contracts[0].type, contractType);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('reports unmatched annotations', () => {
|
|
106
|
+
const src = `
|
|
107
|
+
// @ferret-contract: api.GET/missing api
|
|
108
|
+
interface MissingContract {
|
|
109
|
+
id: string;
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
const result = extractContractsFromTypeScript('src/missing.ts', src);
|
|
113
|
+
|
|
114
|
+
assert.equal(result.contracts.length, 0);
|
|
115
|
+
assert.ok(result.errors.some((d) => d.includes('did not match')));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('extracts type alias with primitive value', () => {
|
|
119
|
+
const src = `
|
|
120
|
+
export type UserId = string;
|
|
121
|
+
`;
|
|
122
|
+
const result = extractContractsFromTypeScript('src/ids.ts', src);
|
|
123
|
+
|
|
124
|
+
assert.equal(result.contracts.length, 1);
|
|
125
|
+
const contract = result.contracts[0];
|
|
126
|
+
assert.equal(contract.sourceSymbol, 'UserId');
|
|
127
|
+
assert.deepEqual(contract.shape, { type: 'string' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('extracts enum members with string initialisers', () => {
|
|
131
|
+
const src = `
|
|
132
|
+
export enum Role {
|
|
133
|
+
Admin = "admin",
|
|
134
|
+
Viewer = "viewer",
|
|
135
|
+
}
|
|
136
|
+
`;
|
|
137
|
+
const result = extractContractsFromTypeScript('src/roles.ts', src);
|
|
138
|
+
|
|
139
|
+
assert.equal(result.contracts.length, 1);
|
|
140
|
+
const shape = result.contracts[0].shape as Record<string, unknown>;
|
|
141
|
+
assert.equal(shape.type, 'string');
|
|
142
|
+
assert.deepEqual(shape.enum, ['Admin', 'Viewer']);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('does not mark required methods with optional parameters as optional', () => {
|
|
146
|
+
const src = `
|
|
147
|
+
export interface Service {
|
|
148
|
+
bar: string;
|
|
149
|
+
baz?: string;
|
|
150
|
+
fn(x?: string): void;
|
|
151
|
+
}
|
|
152
|
+
`;
|
|
153
|
+
const result = extractContractsFromTypeScript('src/service.ts', src);
|
|
154
|
+
assert.equal(result.diagnostics.length, 0);
|
|
155
|
+
|
|
156
|
+
const shape = result.contracts[0].shape as {
|
|
157
|
+
required: string[];
|
|
158
|
+
properties: Record<string, unknown>;
|
|
159
|
+
};
|
|
160
|
+
// 'bar' and 'fn' are required; 'baz' is optional
|
|
161
|
+
assert.ok(shape.required.includes('bar'));
|
|
162
|
+
assert.ok(shape.required.includes('fn'));
|
|
163
|
+
assert.ok(!shape.required.includes('baz'));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('does not mark rest parameters as required', () => {
|
|
167
|
+
const src = `
|
|
168
|
+
export function log(message: string, ...args: string[]): void {}
|
|
169
|
+
`;
|
|
170
|
+
const result = extractContractsFromTypeScript('src/log.ts', src);
|
|
171
|
+
assert.equal(result.contracts.length, 1);
|
|
172
|
+
|
|
173
|
+
const shape = result.contracts[0].shape as {
|
|
174
|
+
properties: { params: { required: string[] } };
|
|
175
|
+
};
|
|
176
|
+
const paramsRequired = shape.properties.params.required;
|
|
177
|
+
assert.ok(paramsRequired.includes('message'), 'message should be required');
|
|
178
|
+
assert.ok(!paramsRequired.includes('args'), 'rest param args should not be required');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('extracts function with typed parameters', () => {
|
|
182
|
+
const src = `
|
|
183
|
+
export function getUser(id: string, active: boolean): void {}
|
|
184
|
+
`;
|
|
185
|
+
const result = extractContractsFromTypeScript('src/user.ts', src);
|
|
186
|
+
assert.equal(result.contracts.length, 1);
|
|
187
|
+
|
|
188
|
+
const shape = result.contracts[0].shape as {
|
|
189
|
+
properties: {
|
|
190
|
+
params: { properties: Record<string, unknown>; required: string[] };
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
assert.deepEqual(shape.properties.params.properties['id'], {
|
|
194
|
+
type: 'string',
|
|
195
|
+
});
|
|
196
|
+
assert.deepEqual(shape.properties.params.properties['active'], {
|
|
197
|
+
type: 'boolean',
|
|
198
|
+
});
|
|
199
|
+
assert.deepEqual(shape.properties.params.required, ['id', 'active']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('normalizes absolute paths to deterministic ids', () => {
|
|
203
|
+
const src = `
|
|
204
|
+
export interface ProfileResponse {
|
|
205
|
+
id: string;
|
|
206
|
+
}
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
const windowsPath = 'C:/Users/alice/work/specferret/src/contracts/profile.ts';
|
|
210
|
+
const unixPath = '/home/bob/work/specferret/src/contracts/profile.ts';
|
|
211
|
+
|
|
212
|
+
const windowsResult = extractContractsFromTypeScript(windowsPath, src);
|
|
213
|
+
const unixResult = extractContractsFromTypeScript(unixPath, src);
|
|
214
|
+
|
|
215
|
+
assert.equal(windowsResult.contracts.length, 1);
|
|
216
|
+
assert.equal(unixResult.contracts.length, 1);
|
|
217
|
+
assert.equal(windowsResult.contracts[0].id, 'type.src/contracts/profile/profileresponse');
|
|
218
|
+
assert.equal(unixResult.contracts[0].id, 'type.src/contracts/profile/profileresponse');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('falls back to last 3 path segments for absolute paths without src/', () => {
|
|
222
|
+
const src = `
|
|
223
|
+
export interface Config {
|
|
224
|
+
debug: boolean;
|
|
225
|
+
}
|
|
226
|
+
`;
|
|
227
|
+
|
|
228
|
+
// Without a recognizable src/ segment the normalizer falls back to the
|
|
229
|
+
// last 3 path segments. This limits machine-specific prefix noise but
|
|
230
|
+
// does NOT guarantee cross-machine determinism (usernames may differ).
|
|
231
|
+
const result = extractContractsFromTypeScript('/home/bob/myproject/config.ts', src);
|
|
232
|
+
|
|
233
|
+
assert.equal(result.contracts.length, 1);
|
|
234
|
+
assert.equal(result.contracts[0].id, 'type.bob/myproject/config/config');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('falls back to core contract type for inferred declarations', () => {
|
|
238
|
+
const src = `
|
|
239
|
+
export interface Team {
|
|
240
|
+
id: string;
|
|
241
|
+
}
|
|
242
|
+
`;
|
|
243
|
+
|
|
244
|
+
const result = extractContractsFromTypeScript('src/team.ts', src);
|
|
245
|
+
|
|
246
|
+
assert.equal(result.contracts.length, 1);
|
|
247
|
+
assert.equal(result.contracts[0].type, 'type');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('deterministically suffixes inferred ids when collisions occur', () => {
|
|
251
|
+
const src = `
|
|
252
|
+
export interface UserProfile {
|
|
253
|
+
id: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface userprofile {
|
|
257
|
+
id: string;
|
|
258
|
+
}
|
|
259
|
+
`;
|
|
260
|
+
|
|
261
|
+
const result = extractContractsFromTypeScript('src/collision.ts', src);
|
|
262
|
+
|
|
263
|
+
assert.equal(result.contracts.length, 2);
|
|
264
|
+
const bySymbol = new Map(result.contracts.map((contract) => [contract.sourceSymbol, contract.id]));
|
|
265
|
+
assert.equal(bySymbol.get('UserProfile'), 'type.src/collision/userprofile');
|
|
266
|
+
assert.equal(bySymbol.get('userprofile'), 'type.src/collision/userprofile-2');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('suffixes inferred id when explicit override already uses base id', () => {
|
|
270
|
+
const src = `
|
|
271
|
+
// @ferret-contract: type.src/collision/userprofile type
|
|
272
|
+
export interface Explicit {
|
|
273
|
+
id: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface UserProfile {
|
|
277
|
+
id: string;
|
|
278
|
+
}
|
|
279
|
+
`;
|
|
280
|
+
|
|
281
|
+
const result = extractContractsFromTypeScript('src/collision.ts', src);
|
|
282
|
+
|
|
283
|
+
assert.equal(result.contracts.length, 2);
|
|
284
|
+
const bySymbol = new Map(result.contracts.map((contract) => [contract.sourceSymbol, contract.id]));
|
|
285
|
+
|
|
286
|
+
assert.equal(bySymbol.get('Explicit'), 'type.src/collision/userprofile');
|
|
287
|
+
assert.equal(bySymbol.get('UserProfile'), 'type.src/collision/userprofile-2');
|
|
288
|
+
assert.ok(result.diagnostics.some((d) => d.includes('Inferred id collision') && d.includes('userprofile-2')));
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('matches golden outputs for fixture-driven regression cases', () => {
|
|
292
|
+
const fixtureDir = path.join(import.meta.dir, '__fixtures__', 'typescript');
|
|
293
|
+
const fixtureFiles = fs
|
|
294
|
+
.readdirSync(fixtureDir)
|
|
295
|
+
.filter((name) => name.endsWith('.ts'))
|
|
296
|
+
.sort();
|
|
297
|
+
|
|
298
|
+
assert.ok(fixtureFiles.length > 0, 'expected at least one fixture file');
|
|
299
|
+
|
|
300
|
+
for (const fixtureFile of fixtureFiles) {
|
|
301
|
+
const fixturePath = path.join(fixtureDir, fixtureFile);
|
|
302
|
+
const goldenPath = path.join(fixtureDir, fixtureFile.replace(/\.ts$/, '.golden.json'));
|
|
303
|
+
|
|
304
|
+
assert.ok(fs.existsSync(goldenPath), `missing golden snapshot for ${fixtureFile}`);
|
|
305
|
+
|
|
306
|
+
const source = fs.readFileSync(fixturePath, 'utf-8');
|
|
307
|
+
const logicalPath = `src/fixtures/${fixtureFile}`;
|
|
308
|
+
|
|
309
|
+
const first = extractContractsFromTypeScript(logicalPath, source);
|
|
310
|
+
const second = extractContractsFromTypeScript(logicalPath, source);
|
|
311
|
+
const golden = JSON.parse(fs.readFileSync(goldenPath, 'utf-8'));
|
|
312
|
+
|
|
313
|
+
assert.deepEqual(first, golden, `fixture output mismatch for ${fixtureFile}`);
|
|
314
|
+
assert.deepEqual(second, golden, `fixture output was non-deterministic for ${fixtureFile}`);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('captures unsupported syntax as deterministic diagnostics without crashing', () => {
|
|
319
|
+
const fixtureDir = path.join(import.meta.dir, '__fixtures__', 'typescript');
|
|
320
|
+
const filePath = path.join(fixtureDir, 'unsupported-syntax.ts');
|
|
321
|
+
const source = fs.readFileSync(filePath, 'utf-8');
|
|
322
|
+
|
|
323
|
+
const first = extractContractsFromTypeScript('src/fixtures/unsupported-syntax.ts', source);
|
|
324
|
+
const second = extractContractsFromTypeScript('src/fixtures/unsupported-syntax.ts', source);
|
|
325
|
+
|
|
326
|
+
assert.equal(first.errors.length, 0);
|
|
327
|
+
assert.ok(first.contracts.length > 0);
|
|
328
|
+
assert.ok(
|
|
329
|
+
first.diagnostics.some(
|
|
330
|
+
(d) =>
|
|
331
|
+
d.includes('Unsupported node type') ||
|
|
332
|
+
d.includes('Unsupported object member type') ||
|
|
333
|
+
d.includes('Intersection types are not supported') ||
|
|
334
|
+
d.includes('Union types are not supported'),
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
assert.deepEqual(first.diagnostics, second.diagnostics);
|
|
338
|
+
});
|
|
339
|
+
});
|