@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
|
@@ -1,153 +1,374 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
1
|
+
import Parser from 'tree-sitter';
|
|
2
|
+
import tsLanguage from 'tree-sitter-typescript';
|
|
3
|
+
import { CONTRACT_TYPES } from './contract-types.js';
|
|
4
|
+
const parser = new Parser();
|
|
5
|
+
parser.setLanguage(tsLanguage.typescript);
|
|
6
|
+
function normalizeIdSegment(value) {
|
|
7
|
+
return value
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9/._-]+/g, '-')
|
|
10
|
+
.replace(/-+/g, '-')
|
|
11
|
+
.replace(/(^[-./_]+)|([-./_]+$)/g, '');
|
|
12
|
+
}
|
|
13
|
+
function normalizeFilePathForId(filePath) {
|
|
14
|
+
const normalized = filePath.replace(/\\/g, '/').replace(/\.[cm]?tsx?$/i, '');
|
|
15
|
+
// Prefer repository-relative paths for deterministic ids. When absolute
|
|
16
|
+
// paths are passed, keep from the first src/ segment when available.
|
|
17
|
+
if (/^[a-z]:\//i.test(normalized) || normalized.startsWith('/')) {
|
|
18
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
19
|
+
const srcIndex = segments.findIndex((segment) => segment.toLowerCase() === 'src');
|
|
20
|
+
if (srcIndex >= 0) {
|
|
21
|
+
return segments.slice(srcIndex).join('/');
|
|
22
|
+
}
|
|
23
|
+
// Fallback to the last 3 path segments to reduce machine-specific prefixes.
|
|
24
|
+
return segments.slice(-3).join('/');
|
|
25
|
+
}
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
function inferContractType() {
|
|
29
|
+
// Inferred extraction currently emits the strict six-core-type set and
|
|
30
|
+
// defaults to `type` unless an annotation override supplies another type.
|
|
31
|
+
return 'type';
|
|
32
|
+
}
|
|
33
|
+
function inferContractId(filePath, symbol) {
|
|
34
|
+
const normalizedPath = normalizeFilePathForId(filePath);
|
|
35
|
+
const pathPart = normalizeIdSegment(normalizedPath) || 'source';
|
|
36
|
+
const symbolPart = normalizeIdSegment(symbol) || 'contract';
|
|
37
|
+
return `type.${pathPart}/${symbolPart}`;
|
|
38
|
+
}
|
|
39
|
+
function allocateDeterministicInferredId(baseId, seenCounts, allocatedIds, diagnostics, filePath, symbol) {
|
|
40
|
+
let count = (seenCounts.get(baseId) ?? 0) + 1;
|
|
41
|
+
let allocatedId = count === 1 ? baseId : `${baseId}-${count}`;
|
|
42
|
+
while (allocatedIds.has(allocatedId)) {
|
|
43
|
+
count += 1;
|
|
44
|
+
allocatedId = `${baseId}-${count}`;
|
|
45
|
+
}
|
|
46
|
+
seenCounts.set(baseId, count);
|
|
47
|
+
if (allocatedId !== baseId) {
|
|
48
|
+
diagnostics.push(`${filePath} (${symbol}): Inferred id collision for '${baseId}'. Assigned deterministic suffix '${allocatedId}'.`);
|
|
49
|
+
}
|
|
50
|
+
allocatedIds.set(allocatedId, symbol);
|
|
51
|
+
return allocatedId;
|
|
52
|
+
}
|
|
53
|
+
function toSchemaType(text) {
|
|
54
|
+
switch (text) {
|
|
55
|
+
case 'string':
|
|
56
|
+
return { type: 'string' };
|
|
57
|
+
case 'number':
|
|
58
|
+
case 'bigint':
|
|
59
|
+
return { type: 'number' };
|
|
60
|
+
case 'boolean':
|
|
61
|
+
return { type: 'boolean' };
|
|
62
|
+
case 'null':
|
|
63
|
+
return { type: 'null' };
|
|
64
|
+
case 'Date':
|
|
65
|
+
return { type: 'string', format: 'date-time' };
|
|
66
|
+
case 'unknown':
|
|
67
|
+
case 'any':
|
|
68
|
+
return { type: 'object' };
|
|
69
|
+
default:
|
|
70
|
+
return { type: 'object' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function getIdentifierText(node) {
|
|
74
|
+
if (!node)
|
|
75
|
+
return null;
|
|
76
|
+
if (node.type === 'identifier' || node.type === 'property_identifier' || node.type === 'type_identifier') {
|
|
77
|
+
return node.text;
|
|
78
|
+
}
|
|
79
|
+
const idNode = node.namedChildren.find((child) => child.type === 'identifier' || child.type === 'property_identifier' || child.type === 'type_identifier');
|
|
80
|
+
return idNode ? idNode.text : null;
|
|
81
|
+
}
|
|
82
|
+
function extractAnnotationOverrides(filePath, content) {
|
|
83
|
+
const annotations = parseAnnotations(content);
|
|
84
|
+
const overrides = new Map();
|
|
85
|
+
const errors = [];
|
|
86
|
+
for (const annotation of annotations) {
|
|
87
|
+
const declaration = extractDeclaration(content, annotation.index);
|
|
88
|
+
if (declaration.error) {
|
|
89
|
+
errors.push(`${filePath}: ${declaration.error}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
overrides.set(declaration.symbol, annotation);
|
|
93
|
+
}
|
|
94
|
+
return { overrides, errors };
|
|
95
|
+
}
|
|
96
|
+
function collectExportedDeclarations(root) {
|
|
97
|
+
const declarations = [];
|
|
98
|
+
for (const node of root.namedChildren) {
|
|
99
|
+
if (node.type !== 'export_statement') {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const declaration = node.namedChildren.find((child) => ['interface_declaration', 'type_alias_declaration', 'enum_declaration', 'function_declaration'].includes(child.type));
|
|
103
|
+
if (!declaration) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const symbolNode = declaration.childForFieldName('name');
|
|
107
|
+
const symbol = getIdentifierText(symbolNode);
|
|
108
|
+
if (!symbol) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (declaration.type === 'interface_declaration') {
|
|
112
|
+
declarations.push({ kind: 'interface', symbol, node: declaration });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (declaration.type === 'type_alias_declaration') {
|
|
116
|
+
declarations.push({ kind: 'type', symbol, node: declaration });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (declaration.type === 'enum_declaration') {
|
|
120
|
+
declarations.push({ kind: 'enum', symbol, node: declaration });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
declarations.push({ kind: 'function', symbol, node: declaration });
|
|
124
|
+
}
|
|
125
|
+
return declarations;
|
|
126
|
+
}
|
|
127
|
+
function findChildByType(node, types) {
|
|
128
|
+
return node.namedChildren.find((child) => types.includes(child.type)) ?? null;
|
|
129
|
+
}
|
|
130
|
+
function parseTypeNode(filePath, symbol, node, diagnostics) {
|
|
131
|
+
if (node.type === 'type_annotation') {
|
|
132
|
+
const inner = node.namedChildren[0];
|
|
133
|
+
if (!inner) {
|
|
134
|
+
return { type: 'object' };
|
|
135
|
+
}
|
|
136
|
+
return parseTypeNode(filePath, symbol, inner, diagnostics);
|
|
137
|
+
}
|
|
138
|
+
if (node.type === 'predefined_type') {
|
|
139
|
+
return toSchemaType(node.text);
|
|
140
|
+
}
|
|
141
|
+
if (node.type === 'type_identifier') {
|
|
142
|
+
if (node.text === 'Date') {
|
|
143
|
+
return { type: 'string', format: 'date-time' };
|
|
144
|
+
}
|
|
145
|
+
diagnostics.push(`${filePath} (${symbol}): Unsupported referenced type '${node.text}'. Falling back to object.`);
|
|
146
|
+
return { type: 'object' };
|
|
147
|
+
}
|
|
148
|
+
if (node.type === 'array_type') {
|
|
149
|
+
const element = node.namedChildren[0];
|
|
150
|
+
if (!element) {
|
|
151
|
+
diagnostics.push(`${filePath} (${symbol}): Unable to parse array element type. Falling back to object items.`);
|
|
152
|
+
return { type: 'array', items: { type: 'object' } };
|
|
56
153
|
}
|
|
57
154
|
return {
|
|
58
|
-
type:
|
|
59
|
-
|
|
60
|
-
required,
|
|
155
|
+
type: 'array',
|
|
156
|
+
items: parseTypeNode(filePath, symbol, element, diagnostics),
|
|
61
157
|
};
|
|
62
158
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
159
|
+
if (node.type === 'union_type') {
|
|
160
|
+
diagnostics.push(`${filePath} (${symbol}): Union types are not supported in extraction. Falling back to object.`);
|
|
161
|
+
return { type: 'object' };
|
|
162
|
+
}
|
|
163
|
+
if (node.type === 'intersection_type') {
|
|
164
|
+
diagnostics.push(`${filePath} (${symbol}): Intersection types are not supported in extraction. Falling back to object.`);
|
|
165
|
+
return { type: 'object' };
|
|
166
|
+
}
|
|
167
|
+
if (node.type === 'literal_type') {
|
|
168
|
+
const valueNode = node.namedChildren[0];
|
|
169
|
+
if (!valueNode) {
|
|
170
|
+
return { type: 'object' };
|
|
171
|
+
}
|
|
172
|
+
if (valueNode.type === 'string') {
|
|
173
|
+
return { type: 'string', enum: [valueNode.text.slice(1, -1)] };
|
|
174
|
+
}
|
|
175
|
+
if (valueNode.type === 'number') {
|
|
176
|
+
return { type: 'number', enum: [Number(valueNode.text)] };
|
|
177
|
+
}
|
|
178
|
+
if (valueNode.type === 'true' || valueNode.type === 'false') {
|
|
179
|
+
return { type: 'boolean', enum: [valueNode.type === 'true'] };
|
|
180
|
+
}
|
|
181
|
+
return { type: 'object' };
|
|
182
|
+
}
|
|
183
|
+
if (node.type === 'parenthesized_type') {
|
|
184
|
+
const inner = node.namedChildren[0];
|
|
185
|
+
if (!inner) {
|
|
186
|
+
return { type: 'object' };
|
|
187
|
+
}
|
|
188
|
+
return parseTypeNode(filePath, symbol, inner, diagnostics);
|
|
189
|
+
}
|
|
190
|
+
if (node.type === 'object_type' || node.type === 'interface_body') {
|
|
191
|
+
return parseObjectShape(filePath, symbol, node, diagnostics);
|
|
192
|
+
}
|
|
193
|
+
if (node.type === 'function_type') {
|
|
194
|
+
const parameters = node.childForFieldName('parameters');
|
|
195
|
+
const returnType = node.childForFieldName('return_type');
|
|
196
|
+
return {
|
|
197
|
+
type: 'object',
|
|
198
|
+
properties: {
|
|
199
|
+
params: parseParametersShape(filePath, symbol, parameters, diagnostics),
|
|
200
|
+
returns: returnType ? parseTypeNode(filePath, symbol, returnType, diagnostics) : { type: 'object' },
|
|
201
|
+
},
|
|
202
|
+
required: ['params', 'returns'],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
diagnostics.push(`${filePath} (${symbol}): Unsupported node type '${node.type}'. Falling back to object.`);
|
|
206
|
+
return { type: 'object' };
|
|
207
|
+
}
|
|
208
|
+
function parseObjectShape(filePath, symbol, node, diagnostics) {
|
|
209
|
+
const properties = {};
|
|
210
|
+
const required = [];
|
|
211
|
+
const members = node.namedChildren;
|
|
212
|
+
for (const member of members) {
|
|
213
|
+
if (!['property_signature', 'method_signature'].includes(member.type)) {
|
|
214
|
+
diagnostics.push(`${filePath} (${symbol}): Unsupported object member type '${member.type}'. Falling back by skipping this member.`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const nameNode = member.childForFieldName('name') ?? findChildByType(member, ['property_identifier', 'identifier', 'string']);
|
|
218
|
+
const propertyName = getIdentifierText(nameNode) ?? nameNode?.text?.replace(/^['"]|['"]$/g, '');
|
|
219
|
+
if (!propertyName) {
|
|
220
|
+
diagnostics.push(`${filePath} (${symbol}): Unable to parse property name in object type.`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// Check for a '?' token that is a direct (unnamed) child of the member node.
|
|
224
|
+
// This correctly distinguishes optional properties (bar?: string) from required
|
|
225
|
+
// methods that happen to have optional parameters (fn(x?: string): void).
|
|
226
|
+
const optional = member.children.some((child) => !child.isNamed && child.text === '?');
|
|
227
|
+
let schema;
|
|
228
|
+
if (member.type === 'method_signature') {
|
|
229
|
+
const parameters = member.childForFieldName('parameters');
|
|
230
|
+
const returnType = member.childForFieldName('return_type');
|
|
231
|
+
schema = {
|
|
232
|
+
type: 'object',
|
|
233
|
+
properties: {
|
|
234
|
+
params: parseParametersShape(filePath, symbol, parameters, diagnostics),
|
|
235
|
+
returns: returnType ? parseTypeNode(filePath, symbol, returnType, diagnostics) : { type: 'object' },
|
|
236
|
+
},
|
|
237
|
+
required: ['params', 'returns'],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
const typeNode = member.childForFieldName('type') ??
|
|
242
|
+
member.childForFieldName('value') ??
|
|
243
|
+
findChildByType(member, [
|
|
244
|
+
'type_annotation',
|
|
245
|
+
'predefined_type',
|
|
246
|
+
'type_identifier',
|
|
247
|
+
'array_type',
|
|
248
|
+
'union_type',
|
|
249
|
+
'intersection_type',
|
|
250
|
+
'object_type',
|
|
251
|
+
'literal_type',
|
|
252
|
+
'parenthesized_type',
|
|
253
|
+
'function_type',
|
|
254
|
+
]);
|
|
255
|
+
if (!typeNode) {
|
|
256
|
+
diagnostics.push(`${filePath} (${symbol}): Unable to parse type expression for property '${propertyName}'.`);
|
|
257
|
+
schema = { type: 'object' };
|
|
98
258
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
this.skipWs();
|
|
102
|
-
if (this.peek() === "|") {
|
|
103
|
-
diagnostics.push("Union types are not supported in Sprint 3 extraction.");
|
|
104
|
-
while (this.i < this.input.length) {
|
|
105
|
-
const ch = this.peek();
|
|
106
|
-
if (ch === ";" || ch === "," || ch === "}" || ch === "\n") {
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
this.i++;
|
|
259
|
+
else {
|
|
260
|
+
schema = parseTypeNode(filePath, symbol, typeNode, diagnostics);
|
|
110
261
|
}
|
|
111
262
|
}
|
|
112
|
-
|
|
263
|
+
properties[propertyName] = schema;
|
|
264
|
+
if (!optional) {
|
|
265
|
+
required.push(propertyName);
|
|
266
|
+
}
|
|
113
267
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
268
|
+
return {
|
|
269
|
+
type: 'object',
|
|
270
|
+
properties,
|
|
271
|
+
required,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function parseParametersShape(filePath, symbol, parametersNode, diagnostics) {
|
|
275
|
+
if (!parametersNode) {
|
|
276
|
+
return { type: 'object', properties: {}, required: [] };
|
|
277
|
+
}
|
|
278
|
+
const properties = {};
|
|
279
|
+
const required = [];
|
|
280
|
+
for (const parameter of parametersNode.namedChildren) {
|
|
281
|
+
if (!['required_parameter', 'optional_parameter', 'rest_parameter'].includes(parameter.type)) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const nameNode = parameter.childForFieldName('pattern') ??
|
|
285
|
+
parameter.childForFieldName('name') ??
|
|
286
|
+
// rest_parameter exposes its identifier as a direct named child with no field name
|
|
287
|
+
parameter.namedChildren.find((c) => c.type === 'identifier') ??
|
|
288
|
+
null;
|
|
289
|
+
const parameterName = getIdentifierText(nameNode) ?? 'arg';
|
|
290
|
+
const typeNode = parameter.childForFieldName('type') ??
|
|
291
|
+
findChildByType(parameter, [
|
|
292
|
+
'type_annotation',
|
|
293
|
+
'predefined_type',
|
|
294
|
+
'type_identifier',
|
|
295
|
+
'array_type',
|
|
296
|
+
'union_type',
|
|
297
|
+
'intersection_type',
|
|
298
|
+
'object_type',
|
|
299
|
+
]);
|
|
300
|
+
properties[parameterName] = typeNode ? parseTypeNode(filePath, symbol, typeNode, diagnostics) : { type: 'object' };
|
|
301
|
+
// A rest parameter is represented as required_parameter with a rest_pattern child.
|
|
302
|
+
// It can be called with zero arguments, so it must not appear in required[].
|
|
303
|
+
const isRestParameter = parameter.namedChildren.some((c) => c.type === 'rest_pattern');
|
|
304
|
+
if (parameter.type !== 'optional_parameter' && parameter.type !== 'rest_parameter' && !isRestParameter) {
|
|
305
|
+
required.push(parameterName);
|
|
124
306
|
}
|
|
125
|
-
return this.input.slice(start, this.i);
|
|
126
307
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
308
|
+
return {
|
|
309
|
+
type: 'object',
|
|
310
|
+
properties,
|
|
311
|
+
required,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function parseEnumShape(node) {
|
|
315
|
+
const body = node.childForFieldName('body') ?? findChildByType(node, ['enum_body']);
|
|
316
|
+
if (!body) {
|
|
317
|
+
return { type: 'string', enum: [] };
|
|
318
|
+
}
|
|
319
|
+
const values = body.namedChildren
|
|
320
|
+
.map((child) => {
|
|
321
|
+
// Bare member: Admin
|
|
322
|
+
if (child.type === 'property_identifier' || child.type === 'identifier') {
|
|
323
|
+
return child.text;
|
|
134
324
|
}
|
|
325
|
+
// Valued member: Admin = "admin" or Admin = 1
|
|
326
|
+
if (child.type === 'enum_assignment') {
|
|
327
|
+
const nameChild = child.namedChildren.find((c) => c.type === 'property_identifier' || c.type === 'identifier');
|
|
328
|
+
return nameChild?.text ?? null;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
})
|
|
332
|
+
.filter((v) => v !== null);
|
|
333
|
+
return {
|
|
334
|
+
type: 'string',
|
|
335
|
+
enum: values,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function parseDeclarationShape(filePath, declaration, diagnostics) {
|
|
339
|
+
if (declaration.kind === 'interface') {
|
|
340
|
+
const body = declaration.node.childForFieldName('body') ?? findChildByType(declaration.node, ['interface_body']);
|
|
341
|
+
if (!body) {
|
|
342
|
+
diagnostics.push(`${filePath} (${declaration.symbol}): Interface body was not found. Falling back to object.`);
|
|
343
|
+
return { type: 'object', properties: {}, required: [] };
|
|
344
|
+
}
|
|
345
|
+
return parseObjectShape(filePath, declaration.symbol, body, diagnostics);
|
|
135
346
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
this.i++;
|
|
347
|
+
if (declaration.kind === 'type') {
|
|
348
|
+
const valueNode = declaration.node.childForFieldName('value') ?? declaration.node.namedChildren.at(-1);
|
|
349
|
+
if (!valueNode) {
|
|
350
|
+
diagnostics.push(`${filePath} (${declaration.symbol}): Type alias value was not found. Falling back to object.`);
|
|
351
|
+
return { type: 'object' };
|
|
143
352
|
}
|
|
353
|
+
return parseTypeNode(filePath, declaration.symbol, valueNode, diagnostics);
|
|
144
354
|
}
|
|
145
|
-
|
|
146
|
-
return
|
|
355
|
+
if (declaration.kind === 'enum') {
|
|
356
|
+
return parseEnumShape(declaration.node);
|
|
147
357
|
}
|
|
358
|
+
const parameters = declaration.node.childForFieldName('parameters');
|
|
359
|
+
const returnType = declaration.node.childForFieldName('return_type');
|
|
360
|
+
return {
|
|
361
|
+
type: 'object',
|
|
362
|
+
properties: {
|
|
363
|
+
params: parseParametersShape(filePath, declaration.symbol, parameters, diagnostics),
|
|
364
|
+
returns: returnType ? parseTypeNode(filePath, declaration.symbol, returnType, diagnostics) : { type: 'object' },
|
|
365
|
+
},
|
|
366
|
+
required: ['params', 'returns'],
|
|
367
|
+
};
|
|
148
368
|
}
|
|
149
369
|
function parseAnnotations(content) {
|
|
150
|
-
const
|
|
370
|
+
const contractTypePattern = CONTRACT_TYPES.join('|');
|
|
371
|
+
const pattern = new RegExp(`^\\s*//\\s*@ferret-contract:\\s*([^\\s]+)\\s+(${contractTypePattern})\\s*$`, 'gm');
|
|
151
372
|
const out = [];
|
|
152
373
|
let match;
|
|
153
374
|
while ((match = pattern.exec(content)) !== null) {
|
|
@@ -161,72 +382,69 @@ function parseAnnotations(content) {
|
|
|
161
382
|
}
|
|
162
383
|
function extractDeclaration(content, start) {
|
|
163
384
|
const tail = content.slice(start);
|
|
164
|
-
const decl = /(?:export\s+)?(interface|type)\s+([A-Za-z0-9_]+)
|
|
165
|
-
if (!decl
|
|
166
|
-
return {
|
|
167
|
-
symbol: "unknown",
|
|
168
|
-
objectBody: "",
|
|
169
|
-
error: "No interface/type declaration found after annotation.",
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
const symbol = decl[2];
|
|
173
|
-
const offset = start + decl.index + decl[0].length;
|
|
174
|
-
const bodyStart = content.indexOf("{", offset);
|
|
175
|
-
if (bodyStart === -1) {
|
|
176
|
-
return {
|
|
177
|
-
symbol,
|
|
178
|
-
objectBody: "",
|
|
179
|
-
error: `Declaration '${symbol}' is not an object-like type.`,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
let depth = 0;
|
|
183
|
-
let end = -1;
|
|
184
|
-
for (let i = bodyStart; i < content.length; i++) {
|
|
185
|
-
const ch = content[i];
|
|
186
|
-
if (ch === "{")
|
|
187
|
-
depth++;
|
|
188
|
-
if (ch === "}") {
|
|
189
|
-
depth--;
|
|
190
|
-
if (depth === 0) {
|
|
191
|
-
end = i;
|
|
192
|
-
break;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
if (end === -1) {
|
|
385
|
+
const decl = /(?:export\s+)?(interface|type)\s+([A-Za-z0-9_]+)/m.exec(tail);
|
|
386
|
+
if (!decl) {
|
|
197
387
|
return {
|
|
198
|
-
symbol,
|
|
199
|
-
|
|
200
|
-
error: `Unclosed object type for declaration '${symbol}'.`,
|
|
388
|
+
symbol: 'unknown',
|
|
389
|
+
error: 'No interface/type declaration found after annotation.',
|
|
201
390
|
};
|
|
202
391
|
}
|
|
203
|
-
|
|
204
|
-
return { symbol, objectBody };
|
|
392
|
+
return { symbol: decl[2] };
|
|
205
393
|
}
|
|
206
394
|
export function extractContractsFromTypeScript(filePath, content) {
|
|
207
|
-
const
|
|
395
|
+
const annotationResult = extractAnnotationOverrides(filePath, content);
|
|
396
|
+
const annotationOverrides = annotationResult.overrides;
|
|
208
397
|
const contracts = [];
|
|
398
|
+
const inferredIdCounts = new Map();
|
|
399
|
+
const allocatedIds = new Map();
|
|
400
|
+
const errors = [...annotationResult.errors];
|
|
209
401
|
const diagnostics = [];
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
402
|
+
let tree;
|
|
403
|
+
try {
|
|
404
|
+
tree = parser.parse(content);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
const message = error instanceof Error ? error.message : 'Unknown parse error.';
|
|
408
|
+
errors.push(`${filePath}: Tree-sitter parse failed: ${message}`);
|
|
409
|
+
return { contracts, errors, diagnostics };
|
|
410
|
+
}
|
|
411
|
+
if (tree.rootNode.hasError) {
|
|
412
|
+
diagnostics.push(`${filePath}: Tree-sitter detected syntax errors; extraction may be partial.`);
|
|
413
|
+
}
|
|
414
|
+
const declarations = collectExportedDeclarations(tree.rootNode);
|
|
415
|
+
for (const declaration of declarations) {
|
|
416
|
+
const shape = parseDeclarationShape(filePath, declaration, diagnostics);
|
|
417
|
+
const override = annotationOverrides.get(declaration.symbol);
|
|
418
|
+
let allocatedId;
|
|
419
|
+
if (override?.id) {
|
|
420
|
+
const existingOwner = allocatedIds.get(override.id);
|
|
421
|
+
if (existingOwner && existingOwner !== declaration.symbol) {
|
|
422
|
+
errors.push(`${filePath} (${declaration.symbol}): Annotation id '${override.id}' duplicates id already used by '${existingOwner}'.`);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
allocatedId = override.id;
|
|
426
|
+
allocatedIds.set(allocatedId, declaration.symbol);
|
|
215
427
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
diagnostics.push(`${filePath} (${declaration.symbol}): ${d}`);
|
|
428
|
+
else {
|
|
429
|
+
const inferredId = inferContractId(filePath, declaration.symbol);
|
|
430
|
+
allocatedId = allocateDeterministicInferredId(inferredId, inferredIdCounts, allocatedIds, diagnostics, filePath, declaration.symbol);
|
|
220
431
|
}
|
|
221
432
|
contracts.push({
|
|
222
|
-
id:
|
|
223
|
-
type:
|
|
433
|
+
id: allocatedId,
|
|
434
|
+
type: override?.type ?? inferContractType(),
|
|
224
435
|
shape,
|
|
225
436
|
sourceSymbol: declaration.symbol,
|
|
226
437
|
filePath,
|
|
438
|
+
idSource: override?.id ? 'annotated' : 'inferred',
|
|
227
439
|
});
|
|
228
440
|
}
|
|
229
|
-
|
|
230
|
-
|
|
441
|
+
for (const [symbol, annotation] of annotationOverrides.entries()) {
|
|
442
|
+
if (contracts.some((contract) => contract.sourceSymbol === symbol)) {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
errors.push(`${filePath}: Annotation for '${annotation.id}' did not match an exported declaration.`);
|
|
446
|
+
}
|
|
447
|
+
contracts.sort((a, b) => (a.id === b.id ? a.sourceSymbol.localeCompare(b.sourceSymbol) : a.id.localeCompare(b.id)));
|
|
448
|
+
return { contracts, errors, diagnostics };
|
|
231
449
|
}
|
|
232
450
|
//# sourceMappingURL=typescript.js.map
|