docorbit 0.1.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/LICENSE +21 -0
- package/README.md +660 -0
- package/apps/cli/bin/docorbit.js +8 -0
- package/apps/cli/src/commands/add.ts +44 -0
- package/apps/cli/src/commands/api.ts +38 -0
- package/apps/cli/src/commands/context.ts +47 -0
- package/apps/cli/src/commands/dashboard.ts +55 -0
- package/apps/cli/src/commands/diff.ts +30 -0
- package/apps/cli/src/commands/evaluate.ts +133 -0
- package/apps/cli/src/commands/examples.ts +39 -0
- package/apps/cli/src/commands/export.ts +89 -0
- package/apps/cli/src/commands/impact.ts +31 -0
- package/apps/cli/src/commands/init.ts +69 -0
- package/apps/cli/src/commands/inspect.ts +30 -0
- package/apps/cli/src/commands/mcp.ts +72 -0
- package/apps/cli/src/commands/pitfalls.ts +38 -0
- package/apps/cli/src/commands/recipes.ts +35 -0
- package/apps/cli/src/commands/search.ts +48 -0
- package/apps/cli/src/commands/update.ts +73 -0
- package/apps/cli/src/commands/verify.ts +48 -0
- package/apps/cli/src/formatters/colors.ts +23 -0
- package/apps/cli/src/formatters/inspection.ts +102 -0
- package/apps/cli/src/formatters/knowledge.ts +272 -0
- package/apps/cli/src/formatters/retrieval.ts +74 -0
- package/apps/cli/src/formatters/terminal.ts +6 -0
- package/apps/cli/src/formatters/verification.ts +126 -0
- package/apps/cli/src/index.ts +409 -0
- package/bin/docorbit.js +8 -0
- package/package.json +46 -0
- package/packages/core/src/dashboard/server.ts +314 -0
- package/packages/core/src/dashboard/ui.ts +586 -0
- package/packages/core/src/implementation-service.ts +451 -0
- package/packages/core/src/index.ts +7 -0
- package/packages/core/src/inspector.ts +71 -0
- package/packages/core/src/pipeline.ts +331 -0
- package/packages/crawler/src/config.ts +12 -0
- package/packages/crawler/src/fetcher.ts +185 -0
- package/packages/crawler/src/index.ts +2 -0
- package/packages/discovery/src/index.ts +31 -0
- package/packages/discovery/src/provider.ts +47 -0
- package/packages/discovery/src/providers/generic.ts +98 -0
- package/packages/discovery/src/providers/github.ts +61 -0
- package/packages/discovery/src/providers/llms-txt.ts +73 -0
- package/packages/discovery/src/providers/markdown.ts +48 -0
- package/packages/discovery/src/providers/openapi.ts +91 -0
- package/packages/discovery/src/providers/sitemap.ts +62 -0
- package/packages/discovery/src/providers/skill.ts +54 -0
- package/packages/discovery/src/ranker.ts +123 -0
- package/packages/evaluation/src/dataset.ts +963 -0
- package/packages/evaluation/src/index.ts +8 -0
- package/packages/evaluation/src/runner.ts +241 -0
- package/packages/evaluation/src/strategies/context7-runner.ts +269 -0
- package/packages/evaluation/src/strategies/docorbit-runner.ts +228 -0
- package/packages/evaluation/src/strategies/firecrawl-runner.ts +172 -0
- package/packages/evaluation/src/strategies/web-search-runner.ts +194 -0
- package/packages/evaluation/src/types.ts +34 -0
- package/packages/evaluation/src/version-matcher.ts +73 -0
- package/packages/export/src/agents-md.ts +200 -0
- package/packages/export/src/claude-md.ts +141 -0
- package/packages/export/src/docs-map.ts +150 -0
- package/packages/export/src/index.ts +6 -0
- package/packages/export/src/llms-txt.ts +96 -0
- package/packages/export/src/service.ts +250 -0
- package/packages/export/src/skill-md.ts +128 -0
- package/packages/mcp/src/index.ts +46 -0
- package/packages/mcp/src/resources/index.ts +189 -0
- package/packages/mcp/src/server.ts +278 -0
- package/packages/mcp/src/tools/analyze-impact.ts +74 -0
- package/packages/mcp/src/tools/check-api.ts +86 -0
- package/packages/mcp/src/tools/diff-docs.ts +68 -0
- package/packages/mcp/src/tools/export-context.ts +73 -0
- package/packages/mcp/src/tools/find-api.ts +99 -0
- package/packages/mcp/src/tools/find-example.ts +100 -0
- package/packages/mcp/src/tools/find-pitfall.ts +94 -0
- package/packages/mcp/src/tools/find-recipe.ts +98 -0
- package/packages/mcp/src/tools/get-doc.ts +130 -0
- package/packages/mcp/src/tools/get-docs-map.ts +64 -0
- package/packages/mcp/src/tools/get-version.ts +118 -0
- package/packages/mcp/src/tools/implementation-context.ts +88 -0
- package/packages/mcp/src/tools/index.ts +59 -0
- package/packages/mcp/src/tools/list-sources.ts +85 -0
- package/packages/mcp/src/tools/search-docs.ts +123 -0
- package/packages/mcp/src/tools/types.ts +28 -0
- package/packages/mcp/src/transports/http.ts +256 -0
- package/packages/mcp/src/transports/stdio.ts +105 -0
- package/packages/mcp/src/transports/types.ts +6 -0
- package/packages/mcp/src/types.ts +102 -0
- package/packages/normalizer/src/example-indexer.ts +240 -0
- package/packages/normalizer/src/html.ts +253 -0
- package/packages/normalizer/src/index.ts +8 -0
- package/packages/normalizer/src/llms.ts +83 -0
- package/packages/normalizer/src/openapi/endpoint-parser.ts +406 -0
- package/packages/normalizer/src/openapi/schema-resolver.ts +111 -0
- package/packages/normalizer/src/openapi.ts +2 -0
- package/packages/normalizer/src/page.ts +184 -0
- package/packages/normalizer/src/pitfall-extractor.ts +190 -0
- package/packages/normalizer/src/slicer.ts +455 -0
- package/packages/retrieval/src/engine.ts +120 -0
- package/packages/retrieval/src/index.ts +7 -0
- package/packages/retrieval/src/intent.ts +43 -0
- package/packages/retrieval/src/packer.ts +145 -0
- package/packages/retrieval/src/recipe-engine.ts +313 -0
- package/packages/retrieval/src/scorer.ts +139 -0
- package/packages/retrieval/src/weights.ts +31 -0
- package/packages/security/src/annotations.ts +112 -0
- package/packages/security/src/index.ts +2 -0
- package/packages/security/src/ssrf.ts +153 -0
- package/packages/shared/src/errors.ts +53 -0
- package/packages/shared/src/hashing.ts +23 -0
- package/packages/shared/src/index.ts +3 -0
- package/packages/shared/src/types.ts +881 -0
- package/packages/storage/src/db.ts +72 -0
- package/packages/storage/src/index.ts +11 -0
- package/packages/storage/src/interfaces.ts +115 -0
- package/packages/storage/src/repositories/api-repository.ts +219 -0
- package/packages/storage/src/repositories/chunk-repository.ts +316 -0
- package/packages/storage/src/repositories/example-repository.ts +206 -0
- package/packages/storage/src/repositories/page-repository.ts +205 -0
- package/packages/storage/src/repositories/pitfall-repository.ts +188 -0
- package/packages/storage/src/repositories/source-repository.ts +205 -0
- package/packages/storage/src/repository.ts +256 -0
- package/packages/storage/src/schema.ts +269 -0
- package/packages/storage/src/search-tokens.ts +28 -0
- package/packages/verification/src/diff-engine.ts +258 -0
- package/packages/verification/src/extractor.ts +339 -0
- package/packages/verification/src/impact-scanner.ts +203 -0
- package/packages/verification/src/index.ts +5 -0
- package/packages/verification/src/services.ts +238 -0
- package/packages/verification/src/verifier.ts +375 -0
- package/packages/workspace/src/detector.ts +143 -0
- package/packages/workspace/src/ecosystems/cargo.ts +84 -0
- package/packages/workspace/src/ecosystems/composer.ts +42 -0
- package/packages/workspace/src/ecosystems/go.ts +54 -0
- package/packages/workspace/src/ecosystems/index.ts +34 -0
- package/packages/workspace/src/ecosystems/maven.ts +34 -0
- package/packages/workspace/src/ecosystems/npm.ts +83 -0
- package/packages/workspace/src/ecosystems/pub.ts +40 -0
- package/packages/workspace/src/ecosystems/pypi.ts +100 -0
- package/packages/workspace/src/ecosystems/rubygems.ts +30 -0
- package/packages/workspace/src/ecosystems/types.ts +18 -0
- package/packages/workspace/src/index.ts +5 -0
- package/packages/workspace/src/lockfile.ts +194 -0
- package/packages/workspace/src/resolver.ts +234 -0
- package/packages/workspace/src/semver.ts +259 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiEndpoint,
|
|
3
|
+
DocDiffResult,
|
|
4
|
+
EndpointDiffItem,
|
|
5
|
+
Pitfall,
|
|
6
|
+
} from '../../shared/src/index.ts';
|
|
7
|
+
import type { DocOrbitRepository } from '../../storage/src/index.ts';
|
|
8
|
+
|
|
9
|
+
export interface DiffOptions {
|
|
10
|
+
fromVersion?: string;
|
|
11
|
+
toVersion?: string;
|
|
12
|
+
fromSnapshotId?: string;
|
|
13
|
+
toSnapshotId?: string;
|
|
14
|
+
sourceId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class DocDiffEngine {
|
|
18
|
+
private repo: DocOrbitRepository;
|
|
19
|
+
|
|
20
|
+
constructor(repo: DocOrbitRepository) {
|
|
21
|
+
this.repo = repo;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
diff(options: DiffOptions): DocDiffResult {
|
|
25
|
+
const fromEndpoints = this.getEndpoints(options.fromSnapshotId, options.fromVersion);
|
|
26
|
+
const toEndpoints = this.getEndpoints(options.toSnapshotId, options.toVersion);
|
|
27
|
+
|
|
28
|
+
const fromPitfalls = this.getPitfalls(options.fromSnapshotId, options.fromVersion);
|
|
29
|
+
const toPitfalls = this.getPitfalls(options.toSnapshotId, options.toVersion);
|
|
30
|
+
|
|
31
|
+
// 1. Compute Endpoint Diffs
|
|
32
|
+
const apiChanges: EndpointDiffItem[] = [];
|
|
33
|
+
const fromMap = new Map<string, ApiEndpoint>();
|
|
34
|
+
const toMap = new Map<string, ApiEndpoint>();
|
|
35
|
+
|
|
36
|
+
for (const ep of fromEndpoints) {
|
|
37
|
+
fromMap.set(`${ep.method.toUpperCase()} ${ep.path}`, ep);
|
|
38
|
+
}
|
|
39
|
+
for (const ep of toEndpoints) {
|
|
40
|
+
toMap.set(`${ep.method.toUpperCase()} ${ep.path}`, ep);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let addedCount = 0;
|
|
44
|
+
let removedCount = 0;
|
|
45
|
+
let modifiedCount = 0;
|
|
46
|
+
let deprecatedCount = 0;
|
|
47
|
+
|
|
48
|
+
// Check additions, deprecations, and modifications in 'to'
|
|
49
|
+
for (const [key, toEp] of toMap.entries()) {
|
|
50
|
+
const fromEp = fromMap.get(key);
|
|
51
|
+
|
|
52
|
+
if (!fromEp) {
|
|
53
|
+
// Newly added endpoint
|
|
54
|
+
apiChanges.push({
|
|
55
|
+
path: toEp.path,
|
|
56
|
+
method: toEp.method,
|
|
57
|
+
changeType: 'added',
|
|
58
|
+
endpoint: toEp,
|
|
59
|
+
});
|
|
60
|
+
addedCount++;
|
|
61
|
+
} else {
|
|
62
|
+
// Existed previously: check deprecation status
|
|
63
|
+
if (toEp.deprecated && !fromEp.deprecated) {
|
|
64
|
+
apiChanges.push({
|
|
65
|
+
path: toEp.path,
|
|
66
|
+
method: toEp.method,
|
|
67
|
+
changeType: 'deprecated',
|
|
68
|
+
endpoint: toEp,
|
|
69
|
+
previousEndpoint: fromEp,
|
|
70
|
+
});
|
|
71
|
+
deprecatedCount++;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check semantic parameter and schema modifications
|
|
75
|
+
const changes = this.detectEndpointModifications(fromEp, toEp);
|
|
76
|
+
if (changes.length > 0) {
|
|
77
|
+
apiChanges.push({
|
|
78
|
+
path: toEp.path,
|
|
79
|
+
method: toEp.method,
|
|
80
|
+
changeType: 'modified',
|
|
81
|
+
changes,
|
|
82
|
+
endpoint: toEp,
|
|
83
|
+
previousEndpoint: fromEp,
|
|
84
|
+
});
|
|
85
|
+
modifiedCount++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check removals from 'from'
|
|
91
|
+
for (const [key, fromEp] of fromMap.entries()) {
|
|
92
|
+
if (!toMap.has(key)) {
|
|
93
|
+
apiChanges.push({
|
|
94
|
+
path: fromEp.path,
|
|
95
|
+
method: fromEp.method,
|
|
96
|
+
changeType: 'removed',
|
|
97
|
+
endpoint: fromEp,
|
|
98
|
+
});
|
|
99
|
+
removedCount++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2. Compute Pitfall Diffs
|
|
104
|
+
const pitfallChanges: Array<{ changeType: 'added' | 'removed'; pitfall: Pitfall }> = [];
|
|
105
|
+
const fromPitfallTitles = new Set(fromPitfalls.map(p => `${p.kind}:${p.title}`));
|
|
106
|
+
const toPitfallTitles = new Set(toPitfalls.map(p => `${p.kind}:${p.title}`));
|
|
107
|
+
|
|
108
|
+
for (const toP of toPitfalls) {
|
|
109
|
+
if (!fromPitfallTitles.has(`${toP.kind}:${toP.title}`)) {
|
|
110
|
+
pitfallChanges.push({ changeType: 'added', pitfall: toP });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const fromP of fromPitfalls) {
|
|
114
|
+
if (!toPitfallTitles.has(`${fromP.kind}:${fromP.title}`)) {
|
|
115
|
+
pitfallChanges.push({ changeType: 'removed', pitfall: fromP });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 3. Compute Content/Chunk Diffs (Semantic only, ignoring formatting-only whitespace)
|
|
120
|
+
const contentChanges = this.computeContentDiffs(options);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
fromVersion: options.fromVersion,
|
|
124
|
+
toVersion: options.toVersion,
|
|
125
|
+
fromSnapshotId: options.fromSnapshotId,
|
|
126
|
+
toSnapshotId: options.toSnapshotId,
|
|
127
|
+
sourceId: options.sourceId,
|
|
128
|
+
summary: {
|
|
129
|
+
endpointsAdded: addedCount,
|
|
130
|
+
endpointsRemoved: removedCount,
|
|
131
|
+
endpointsModified: modifiedCount,
|
|
132
|
+
endpointsDeprecated: deprecatedCount,
|
|
133
|
+
pitfallsAdded: pitfallChanges.filter(p => p.changeType === 'added').length,
|
|
134
|
+
pitfallsRemoved: pitfallChanges.filter(p => p.changeType === 'removed').length,
|
|
135
|
+
contentChanged: contentChanges.length,
|
|
136
|
+
},
|
|
137
|
+
apiChanges,
|
|
138
|
+
pitfallChanges,
|
|
139
|
+
contentChanges,
|
|
140
|
+
retrievedAt: new Date().toISOString(),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private getEndpoints(snapshotId?: string, docVersion?: string): ApiEndpoint[] {
|
|
145
|
+
if (snapshotId) {
|
|
146
|
+
return this.repo.getApiEndpointsBySnapshot(snapshotId);
|
|
147
|
+
}
|
|
148
|
+
return this.repo.searchApiEndpoints('', {
|
|
149
|
+
docVersion,
|
|
150
|
+
limit: 500,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private getPitfalls(snapshotId?: string, docVersion?: string): Pitfall[] {
|
|
155
|
+
if (snapshotId) {
|
|
156
|
+
return this.repo.getPitfallsBySnapshot(snapshotId);
|
|
157
|
+
}
|
|
158
|
+
return this.repo.searchPitfalls('', {
|
|
159
|
+
docVersion,
|
|
160
|
+
limit: 200,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private detectEndpointModifications(fromEp: ApiEndpoint, toEp: ApiEndpoint): string[] {
|
|
165
|
+
const changes: string[] = [];
|
|
166
|
+
|
|
167
|
+
// Compare parameters
|
|
168
|
+
const fromParams = new Map(fromEp.parameters.map(p => [p.name, p]));
|
|
169
|
+
const toParams = new Map(toEp.parameters.map(p => [p.name, p]));
|
|
170
|
+
|
|
171
|
+
for (const [name, toP] of toParams.entries()) {
|
|
172
|
+
const fromP = fromParams.get(name);
|
|
173
|
+
if (!fromP) {
|
|
174
|
+
changes.push(`Added parameter "${name}"${toP.required ? ' (required)' : ''}`);
|
|
175
|
+
} else if (!fromP.required && toP.required) {
|
|
176
|
+
changes.push(`Parameter "${name}" is now required (was optional)`);
|
|
177
|
+
} else if (fromP.type !== toP.type && toP.type) {
|
|
178
|
+
changes.push(`Parameter "${name}" type changed from ${fromP.type || 'any'} to ${toP.type}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const name of fromParams.keys()) {
|
|
183
|
+
if (!toParams.has(name)) {
|
|
184
|
+
changes.push(`Removed parameter "${name}"`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Compare Request Body required fields
|
|
189
|
+
const fromReq = new Set(fromEp.requestSchema?.required || []);
|
|
190
|
+
const toReq = new Set(toEp.requestSchema?.required || []);
|
|
191
|
+
|
|
192
|
+
for (const field of toReq) {
|
|
193
|
+
if (!fromReq.has(field)) {
|
|
194
|
+
changes.push(`Request body field "${field}" is now required`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const field of fromReq) {
|
|
198
|
+
if (!toReq.has(field)) {
|
|
199
|
+
changes.push(`Request body field "${field}" is no longer required`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return changes;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private computeContentDiffs(options: DiffOptions): Array<{ title: string; changeType: 'added' | 'removed' | 'modified'; summary: string }> {
|
|
207
|
+
const fromChunks = options.fromSnapshotId
|
|
208
|
+
? this.repo.getChunksBySnapshot(options.fromSnapshotId)
|
|
209
|
+
: options.fromVersion
|
|
210
|
+
? this.repo.getChunksByVersion(options.fromVersion)
|
|
211
|
+
: [];
|
|
212
|
+
const toChunks = options.toSnapshotId
|
|
213
|
+
? this.repo.getChunksBySnapshot(options.toSnapshotId)
|
|
214
|
+
: options.toVersion
|
|
215
|
+
? this.repo.getChunksByVersion(options.toVersion)
|
|
216
|
+
: [];
|
|
217
|
+
|
|
218
|
+
if (fromChunks.length === 0 && toChunks.length === 0) return [];
|
|
219
|
+
|
|
220
|
+
const changes: Array<{ title: string; changeType: 'added' | 'removed' | 'modified'; summary: string }> = [];
|
|
221
|
+
const fromMap = new Map(fromChunks.map(c => [c.title, c]));
|
|
222
|
+
const toMap = new Map(toChunks.map(c => [c.title, c]));
|
|
223
|
+
|
|
224
|
+
for (const [title, toC] of toMap.entries()) {
|
|
225
|
+
const fromC = fromMap.get(title);
|
|
226
|
+
if (!fromC) {
|
|
227
|
+
changes.push({
|
|
228
|
+
title,
|
|
229
|
+
changeType: 'added',
|
|
230
|
+
summary: `New documentation section: "${title}"`,
|
|
231
|
+
});
|
|
232
|
+
} else if (fromC.contentHash !== toC.contentHash) {
|
|
233
|
+
// Compare normalized text to ignore formatting-only whitespace
|
|
234
|
+
const normFrom = fromC.content.replace(/\s+/g, ' ').trim();
|
|
235
|
+
const normTo = toC.content.replace(/\s+/g, ' ').trim();
|
|
236
|
+
if (normFrom !== normTo) {
|
|
237
|
+
changes.push({
|
|
238
|
+
title,
|
|
239
|
+
changeType: 'modified',
|
|
240
|
+
summary: `Content updated for section "${title}"`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const title of fromMap.keys()) {
|
|
247
|
+
if (!toMap.has(title)) {
|
|
248
|
+
changes.push({
|
|
249
|
+
title,
|
|
250
|
+
changeType: 'removed',
|
|
251
|
+
summary: `Documentation section removed: "${title}"`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return changes;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import type { ExtractedApiCall } from '../../shared/src/index.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes an API path for comparison against schema paths.
|
|
5
|
+
* E.g. extracts pathname from full URLs ("https://api.stripe.com/v1/webhook_endpoints" -> "/v1/webhook_endpoints")
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeApiPath(urlOrPath: string): string {
|
|
8
|
+
try {
|
|
9
|
+
if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) {
|
|
10
|
+
const parsed = new URL(urlOrPath);
|
|
11
|
+
return parsed.pathname;
|
|
12
|
+
}
|
|
13
|
+
} catch {
|
|
14
|
+
// Fall back to regex
|
|
15
|
+
}
|
|
16
|
+
const match = urlOrPath.match(/^https?:\/\/[^/]+(\/.*)$/);
|
|
17
|
+
if (match) return match[1].split('?')[0];
|
|
18
|
+
return urlOrPath.split('?')[0].trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Checks if an extracted path matches an OpenAPI schema path template.
|
|
23
|
+
* E.g. candidate "/v1/webhook_endpoints/we_123" matches schema "/v1/webhook_endpoints/{id}"
|
|
24
|
+
*/
|
|
25
|
+
export function matchEndpointPath(candidatePath: string, schemaPath: string): boolean {
|
|
26
|
+
const normCandidate = normalizeApiPath(candidatePath).replace(/\/+$/, '');
|
|
27
|
+
const normSchema = normalizeApiPath(schemaPath).replace(/\/+$/, '');
|
|
28
|
+
|
|
29
|
+
if (normCandidate.toLowerCase() === normSchema.toLowerCase()) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Convert schema {param} or :param to regex wildcard
|
|
34
|
+
const regexPattern = normSchema
|
|
35
|
+
.replace(/{[^}]+}/g, '([^/]+)')
|
|
36
|
+
.replace(/:[a-zA-Z0-9_]+/g, '([^/]+)')
|
|
37
|
+
.replace(/\//g, '\\/');
|
|
38
|
+
|
|
39
|
+
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
|
40
|
+
return regex.test(normCandidate);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extracts top-level key names from a JS/Python object or JSON snippet string.
|
|
45
|
+
*/
|
|
46
|
+
function extractObjectKeys(objSnippet: string): string[] {
|
|
47
|
+
const keys: Set<string> = new Set();
|
|
48
|
+
// Try standard JSON parse first
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(objSnippet);
|
|
51
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
52
|
+
return Object.keys(parsed);
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Fall back to lexical key extraction
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Match identifier keys: { foo: ..., "bar": ..., 'baz': ... }
|
|
59
|
+
const keyRegex = /(?:[{,]\s*)(?:["']?([a-zA-Z0-9_-]+)["']?)\s*:/g;
|
|
60
|
+
let match: RegExpExecArray | null;
|
|
61
|
+
while ((match = keyRegex.exec(objSnippet)) !== null) {
|
|
62
|
+
if (match[1] && !['method', 'headers', 'body'].includes(match[1])) {
|
|
63
|
+
keys.add(match[1]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return Array.from(keys);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Deterministically extracts API calls, parameters, methods, and version syntax from code.
|
|
72
|
+
*/
|
|
73
|
+
export class CodeApiExtractor {
|
|
74
|
+
/**
|
|
75
|
+
* Extracts API calls from code.
|
|
76
|
+
*/
|
|
77
|
+
extract(code: string, language?: string): ExtractedApiCall[] {
|
|
78
|
+
const results: ExtractedApiCall[] = [];
|
|
79
|
+
const lines = code.split('\n');
|
|
80
|
+
|
|
81
|
+
// 1. Check for Next.js Route Params patterns (sync vs async)
|
|
82
|
+
this.extractNextJsParams(code, lines, results);
|
|
83
|
+
|
|
84
|
+
// 2. Extract Fetch calls: fetch('url', { method: 'POST', body: ... })
|
|
85
|
+
this.extractFetchCalls(code, lines, results);
|
|
86
|
+
|
|
87
|
+
// 3. Extract Axios calls: axios.post('url', {...}) / axios.get('url')
|
|
88
|
+
this.extractAxiosCalls(code, lines, results);
|
|
89
|
+
|
|
90
|
+
// 4. Extract Python Requests calls: requests.post('url', json={...})
|
|
91
|
+
this.extractPythonRequestsCalls(code, lines, results);
|
|
92
|
+
|
|
93
|
+
// 5. Extract cURL commands: curl -X POST ...
|
|
94
|
+
this.extractCurlCalls(code, lines, results);
|
|
95
|
+
|
|
96
|
+
// 6. Extract common SDK calls: stripe.webhookEndpoints.create({...}), etc.
|
|
97
|
+
this.extractSdkCalls(code, lines, results);
|
|
98
|
+
|
|
99
|
+
// 7. Check for dynamic / unresolvable URLs: e.g. fetch(apiUrl) or axios.get(endpoint)
|
|
100
|
+
this.extractDynamicCalls(code, lines, results);
|
|
101
|
+
|
|
102
|
+
// If nothing was extracted at all, create an ambiguous marker
|
|
103
|
+
if (results.length === 0) {
|
|
104
|
+
results.push({
|
|
105
|
+
rawSnippet: code.slice(0, 150),
|
|
106
|
+
isDynamicOrAmbiguous: true,
|
|
107
|
+
reasonIfAmbiguous: language && !['javascript', 'typescript', 'python', 'curl', 'bash'].includes(language.toLowerCase())
|
|
108
|
+
? `Language "${language}" is not currently supported for deep AST schema verification.`
|
|
109
|
+
: 'No recognizable HTTP/API client calls, SDK invocations, or endpoint paths found in code.',
|
|
110
|
+
language,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private findLineNumber(lines: string[], snippetPattern: string): number {
|
|
118
|
+
const clean = snippetPattern.trim().split('\n')[0].slice(0, 40);
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
if (lines[i].includes(clean)) return i + 1;
|
|
121
|
+
}
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private extractNextJsParams(code: string, lines: string[], results: ExtractedApiCall[]): void {
|
|
126
|
+
// Next.js 14 sync: params.slug, const { slug } = params
|
|
127
|
+
const syncParamRegex = /(?:const\s+{\s*([^}]+)\s*}\s*=\s*params\b|\bparams\.([a-zA-Z0-9_]+))/;
|
|
128
|
+
// Next.js 15+ async: await params, const { slug } = await params
|
|
129
|
+
const asyncParamRegex = /(?:await\s+params\b|const\s+{\s*([^}]+)\s*}\s*=\s*await\s+params\b)/;
|
|
130
|
+
|
|
131
|
+
const hasAsync = asyncParamRegex.test(code);
|
|
132
|
+
const hasSync = syncParamRegex.test(code) && !hasAsync;
|
|
133
|
+
|
|
134
|
+
if (hasAsync) {
|
|
135
|
+
const match = code.match(asyncParamRegex);
|
|
136
|
+
results.push({
|
|
137
|
+
symbol: 'nextjs_route_params_async',
|
|
138
|
+
endpointPath: 'nextjs/app_router/params',
|
|
139
|
+
method: 'get',
|
|
140
|
+
rawSnippet: match ? match[0] : 'await params',
|
|
141
|
+
line: match ? this.findLineNumber(lines, match[0]) : 1,
|
|
142
|
+
});
|
|
143
|
+
} else if (hasSync) {
|
|
144
|
+
const match = code.match(syncParamRegex);
|
|
145
|
+
results.push({
|
|
146
|
+
symbol: 'nextjs_route_params_sync',
|
|
147
|
+
endpointPath: 'nextjs/app_router/params',
|
|
148
|
+
method: 'get',
|
|
149
|
+
rawSnippet: match ? match[0] : 'params.slug',
|
|
150
|
+
line: match ? this.findLineNumber(lines, match[0]) : 1,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private extractFetchCalls(code: string, lines: string[], results: ExtractedApiCall[]): void {
|
|
156
|
+
// Matches: fetch(['"`]([^'"`]+)['"`](?:\s*,\s*({[\s\S]*?}))?\s*\)
|
|
157
|
+
const fetchRegex = /fetch\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*({[\s\S]*?}))?\s*\)/g;
|
|
158
|
+
let match: RegExpExecArray | null;
|
|
159
|
+
|
|
160
|
+
while ((match = fetchRegex.exec(code)) !== null) {
|
|
161
|
+
const url = match[1];
|
|
162
|
+
const optionsSnippet = match[2] || '';
|
|
163
|
+
const endpointPath = normalizeApiPath(url);
|
|
164
|
+
|
|
165
|
+
let method = 'get';
|
|
166
|
+
const methodMatch = optionsSnippet.match(/method\s*:\s*['"]([a-zA-Z]+)['"]/i);
|
|
167
|
+
if (methodMatch) {
|
|
168
|
+
method = methodMatch[1].toLowerCase();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Extract body fields if body: JSON.stringify({...}) or body: {...}
|
|
172
|
+
let bodyFields: string[] = [];
|
|
173
|
+
const bodyMatch = optionsSnippet.match(/body\s*:\s*(?:JSON\.stringify\s*\(\s*)?({[\s\S]*?})(?:\s*\))?/);
|
|
174
|
+
if (bodyMatch) {
|
|
175
|
+
bodyFields = extractObjectKeys(bodyMatch[1]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Extract response fields accessed: e.g. data.foo, res.json()
|
|
179
|
+
const responseFields = this.extractResponseFields(code, endpointPath);
|
|
180
|
+
|
|
181
|
+
results.push({
|
|
182
|
+
endpointPath,
|
|
183
|
+
method,
|
|
184
|
+
bodyFields,
|
|
185
|
+
responseFieldsAccessed: responseFields,
|
|
186
|
+
rawSnippet: match[0],
|
|
187
|
+
line: this.findLineNumber(lines, match[0]),
|
|
188
|
+
language: 'javascript',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private extractAxiosCalls(code: string, lines: string[], results: ExtractedApiCall[]): void {
|
|
194
|
+
// Matches: axios.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*({[\s\S]*?}))?/g
|
|
195
|
+
const axiosRegex = /axios\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*({[\s\S]*?}))?/g;
|
|
196
|
+
let match: RegExpExecArray | null;
|
|
197
|
+
|
|
198
|
+
while ((match = axiosRegex.exec(code)) !== null) {
|
|
199
|
+
const method = match[1].toLowerCase();
|
|
200
|
+
const url = match[2];
|
|
201
|
+
const payloadSnippet = match[3] || '';
|
|
202
|
+
const endpointPath = normalizeApiPath(url);
|
|
203
|
+
const bodyFields = payloadSnippet ? extractObjectKeys(payloadSnippet) : [];
|
|
204
|
+
const responseFields = this.extractResponseFields(code, endpointPath);
|
|
205
|
+
|
|
206
|
+
results.push({
|
|
207
|
+
endpointPath,
|
|
208
|
+
method,
|
|
209
|
+
bodyFields,
|
|
210
|
+
responseFieldsAccessed: responseFields,
|
|
211
|
+
rawSnippet: match[0],
|
|
212
|
+
line: this.findLineNumber(lines, match[0]),
|
|
213
|
+
language: 'javascript',
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private extractPythonRequestsCalls(code: string, lines: string[], results: ExtractedApiCall[]): void {
|
|
219
|
+
// Matches: requests.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"](?:\s*,\s*(?:json|data)\s*=\s*({[\s\S]*?}))?
|
|
220
|
+
const requestsRegex = /requests\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"](?:\s*,\s*(?:json|data|params)\s*=\s*({[\s\S]*?}))?/g;
|
|
221
|
+
let match: RegExpExecArray | null;
|
|
222
|
+
|
|
223
|
+
while ((match = requestsRegex.exec(code)) !== null) {
|
|
224
|
+
const method = match[1].toLowerCase();
|
|
225
|
+
const url = match[2];
|
|
226
|
+
const payloadSnippet = match[3] || '';
|
|
227
|
+
const endpointPath = normalizeApiPath(url);
|
|
228
|
+
const bodyFields = payloadSnippet ? extractObjectKeys(payloadSnippet) : [];
|
|
229
|
+
|
|
230
|
+
results.push({
|
|
231
|
+
endpointPath,
|
|
232
|
+
method,
|
|
233
|
+
bodyFields,
|
|
234
|
+
rawSnippet: match[0],
|
|
235
|
+
line: this.findLineNumber(lines, match[0]),
|
|
236
|
+
language: 'python',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private extractCurlCalls(code: string, lines: string[], results: ExtractedApiCall[]): void {
|
|
242
|
+
// Matches: curl ... https://... -X POST -d '{...}'
|
|
243
|
+
const curlRegex = /curl\s+([^\n]+)/g;
|
|
244
|
+
let match: RegExpExecArray | null;
|
|
245
|
+
|
|
246
|
+
while ((match = curlRegex.exec(code)) !== null) {
|
|
247
|
+
const fullCmd = match[1];
|
|
248
|
+
const urlMatch = fullCmd.match(/https?:\/\/[^\s'"]+/);
|
|
249
|
+
if (!urlMatch) continue;
|
|
250
|
+
|
|
251
|
+
const endpointPath = normalizeApiPath(urlMatch[0]);
|
|
252
|
+
let method = 'get';
|
|
253
|
+
const methodMatch = fullCmd.match(/-X\s+([A-Z]+)/i);
|
|
254
|
+
if (methodMatch) {
|
|
255
|
+
method = methodMatch[1].toLowerCase();
|
|
256
|
+
} else if (fullCmd.includes('-d ') || fullCmd.includes('--data')) {
|
|
257
|
+
method = 'post';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let bodyFields: string[] = [];
|
|
261
|
+
const dataMatch = fullCmd.match(/(?:-d|--data(?:-raw)?)\s+['"]({[\s\S]*?})['"]/);
|
|
262
|
+
if (dataMatch) {
|
|
263
|
+
bodyFields = extractObjectKeys(dataMatch[1]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
results.push({
|
|
267
|
+
endpointPath,
|
|
268
|
+
method,
|
|
269
|
+
bodyFields,
|
|
270
|
+
rawSnippet: match[0],
|
|
271
|
+
line: this.findLineNumber(lines, match[0]),
|
|
272
|
+
language: 'curl',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private extractSdkCalls(code: string, lines: string[], results: ExtractedApiCall[]): void {
|
|
278
|
+
// Stripe SDK: stripe.webhookEndpoints.create({...})
|
|
279
|
+
const stripeWebhookRegex = /stripe\.webhookEndpoints\.create\s*\(\s*({[\s\S]*?})\s*\)/g;
|
|
280
|
+
let match: RegExpExecArray | null;
|
|
281
|
+
while ((match = stripeWebhookRegex.exec(code)) !== null) {
|
|
282
|
+
const bodyFields = extractObjectKeys(match[1]);
|
|
283
|
+
results.push({
|
|
284
|
+
endpointPath: '/v1/webhook_endpoints',
|
|
285
|
+
method: 'post',
|
|
286
|
+
symbol: 'stripe.webhookEndpoints.create',
|
|
287
|
+
bodyFields,
|
|
288
|
+
rawSnippet: match[0],
|
|
289
|
+
line: this.findLineNumber(lines, match[0]),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Stripe SDK: stripe.charges.create({...})
|
|
294
|
+
const stripeChargesRegex = /stripe\.charges\.create\s*\(\s*({[\s\S]*?})\s*\)/g;
|
|
295
|
+
while ((match = stripeChargesRegex.exec(code)) !== null) {
|
|
296
|
+
const bodyFields = extractObjectKeys(match[1]);
|
|
297
|
+
results.push({
|
|
298
|
+
endpointPath: '/v1/charges',
|
|
299
|
+
method: 'post',
|
|
300
|
+
symbol: 'stripe.charges.create',
|
|
301
|
+
bodyFields,
|
|
302
|
+
rawSnippet: match[0],
|
|
303
|
+
line: this.findLineNumber(lines, match[0]),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private extractDynamicCalls(code: string, lines: string[], results: ExtractedApiCall[]): void {
|
|
309
|
+
// Matches fetch(dynamicVar) or axios.get(dynamicVar) where argument does not start with string quote
|
|
310
|
+
const dynamicFetch = /fetch\s*\(\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?:,|\))/g;
|
|
311
|
+
let match: RegExpExecArray | null;
|
|
312
|
+
while ((match = dynamicFetch.exec(code)) !== null) {
|
|
313
|
+
const varName = match[1];
|
|
314
|
+
// Check if variable is defined as a string literal above
|
|
315
|
+
const defRegex = new RegExp(`(?:const|let|var)\\s+${varName}\\s*=\\s*['"\`]([^'"\`]+)['"\`]`);
|
|
316
|
+
if (!defRegex.test(code)) {
|
|
317
|
+
results.push({
|
|
318
|
+
rawSnippet: match[0],
|
|
319
|
+
isDynamicOrAmbiguous: true,
|
|
320
|
+
reasonIfAmbiguous: `Dynamic variable "${varName}" passed to fetch() cannot be statically resolved to an API path.`,
|
|
321
|
+
line: this.findLineNumber(lines, match[0]),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private extractResponseFields(code: string, _endpoint: string): string[] {
|
|
328
|
+
const fields: Set<string> = new Set();
|
|
329
|
+
// Matches res.data.fieldName or data.fieldName or json.fieldName or event.fieldName
|
|
330
|
+
const fieldRegex = /(?:res(?:ponse)?(?:\.data)?|data|json|body|payload)\.([a-zA-Z0-9_]+)/g;
|
|
331
|
+
let m: RegExpExecArray | null;
|
|
332
|
+
while ((m = fieldRegex.exec(code)) !== null) {
|
|
333
|
+
if (m[1] && !['json', 'status', 'headers', 'data', 'text'].includes(m[1])) {
|
|
334
|
+
fields.add(m[1]);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return Array.from(fields);
|
|
338
|
+
}
|
|
339
|
+
}
|