api-tracer-kit 1.0.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/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +466 -0
- package/cli/bin/api-tracer.mjs +266 -0
- package/cli/config.mjs +224 -0
- package/cli/import.mjs +231 -0
- package/cli/index.mjs +10 -0
- package/cli/presets.mjs +212 -0
- package/cli/report.mjs +346 -0
- package/cli/scan.mjs +142 -0
- package/cli/server.mjs +1576 -0
- package/cli/shape.mjs +90 -0
- package/cli/test.mjs +342 -0
- package/cli/web/app.css +1424 -0
- package/cli/web/app.js +2260 -0
- package/cli/web/favicon.svg +5 -0
- package/cli/web/index.html +159 -0
- package/cli/web/logo.svg +7 -0
- package/dist/axios.cjs +856 -0
- package/dist/axios.cjs.map +1 -0
- package/dist/axios.d.cts +27 -0
- package/dist/axios.d.ts +27 -0
- package/dist/axios.js +853 -0
- package/dist/axios.js.map +1 -0
- package/dist/index.cjs +872 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +74 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +857 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +896 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +22 -0
- package/dist/react.d.ts +22 -0
- package/dist/react.js +893 -0
- package/dist/react.js.map +1 -0
- package/dist/tracer-BUWdU2lG.d.ts +76 -0
- package/dist/tracer-DG2YUqK0.d.cts +76 -0
- package/dist/types-Bl2-K6_g.d.cts +111 -0
- package/dist/types-Bl2-K6_g.d.ts +111 -0
- package/dist/ui.cjs +1162 -0
- package/dist/ui.cjs.map +1 -0
- package/dist/ui.d.cts +16 -0
- package/dist/ui.d.ts +16 -0
- package/dist/ui.js +1157 -0
- package/dist/ui.js.map +1 -0
- package/package.json +92 -0
package/cli/report.mjs
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the report model from what the tracker already knows. Pure functions:
|
|
3
|
+
* everything comes in as arguments so this can be tested without a server, and
|
|
4
|
+
* so the same model feeds the UI, the Markdown export and the JSON export.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { flatten } from './shape.mjs';
|
|
8
|
+
|
|
9
|
+
const pct = (n, of) => (of ? Math.round((n / of) * 100) : 0);
|
|
10
|
+
|
|
11
|
+
/** failures may be reported in the envelope, so the useful text is in the body */
|
|
12
|
+
function messageOf(failure) {
|
|
13
|
+
if (!failure) return undefined;
|
|
14
|
+
if (failure.error) return failure.error;
|
|
15
|
+
try {
|
|
16
|
+
const body = JSON.parse(failure.body ?? '');
|
|
17
|
+
return body.message ?? body.error ?? undefined;
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function coverage(endpoints, samples) {
|
|
24
|
+
const scanned = endpoints.filter((e) => !e.uncatalogued);
|
|
25
|
+
const byModule = new Map();
|
|
26
|
+
|
|
27
|
+
for (const ep of scanned) {
|
|
28
|
+
const row = byModule.get(ep.module) ?? { module: ep.module, total: 0, seen: 0 };
|
|
29
|
+
row.total++;
|
|
30
|
+
if (samples[ep.id]) row.seen++;
|
|
31
|
+
byModule.set(ep.module, row);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rows = [...byModule.values()]
|
|
35
|
+
.map((r) => ({ ...r, pct: pct(r.seen, r.total) }))
|
|
36
|
+
.sort((a, b) => b.pct - a.pct || a.module.localeCompare(b.module));
|
|
37
|
+
|
|
38
|
+
const seen = scanned.filter((e) => samples[e.id]).length;
|
|
39
|
+
return {
|
|
40
|
+
total: scanned.length,
|
|
41
|
+
seen,
|
|
42
|
+
pct: pct(seen, scanned.length),
|
|
43
|
+
modules: rows,
|
|
44
|
+
untouched: rows.filter((r) => !r.seen).map((r) => r.module),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function health(endpoints, results) {
|
|
49
|
+
const withResult = endpoints.filter((e) => results[e.id]);
|
|
50
|
+
const failing = withResult
|
|
51
|
+
.filter((e) => !results[e.id].ok)
|
|
52
|
+
.map((e) => {
|
|
53
|
+
const r = results[e.id];
|
|
54
|
+
return {
|
|
55
|
+
id: e.id,
|
|
56
|
+
module: e.module,
|
|
57
|
+
method: e.method,
|
|
58
|
+
url: r.url,
|
|
59
|
+
status: r.status,
|
|
60
|
+
innerCode: r.innerCode,
|
|
61
|
+
message: messageOf(r.failure),
|
|
62
|
+
at: r.at,
|
|
63
|
+
failedInReplay: Boolean(r.failedInReplay),
|
|
64
|
+
regression: Boolean(r.wasOk),
|
|
65
|
+
lastOkAt: r.lastOkAt,
|
|
66
|
+
};
|
|
67
|
+
})
|
|
68
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
69
|
+
|
|
70
|
+
const byModule = new Map();
|
|
71
|
+
for (const e of withResult) {
|
|
72
|
+
const row = byModule.get(e.module) ?? { module: e.module, passed: 0, failed: 0 };
|
|
73
|
+
row[results[e.id].ok ? 'passed' : 'failed']++;
|
|
74
|
+
byModule.set(e.module, row);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const byMethod = {};
|
|
78
|
+
for (const e of withResult) {
|
|
79
|
+
const m = (byMethod[e.method] ??= { passed: 0, failed: 0 });
|
|
80
|
+
m[results[e.id].ok ? 'passed' : 'failed']++;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
run: withResult.length,
|
|
85
|
+
passed: withResult.filter((e) => results[e.id].ok).length,
|
|
86
|
+
failed: failing.length,
|
|
87
|
+
failing,
|
|
88
|
+
regressions: failing.filter((f) => f.regression),
|
|
89
|
+
modules: [...byModule.values()].sort((a, b) => b.failed - a.failed),
|
|
90
|
+
methods: byMethod,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function drift(endpoints, results, contracts) {
|
|
95
|
+
return endpoints
|
|
96
|
+
.filter((e) => results[e.id]?.drift)
|
|
97
|
+
.map((e) => ({
|
|
98
|
+
id: e.id,
|
|
99
|
+
module: e.module,
|
|
100
|
+
summary: results[e.id].drift.summary,
|
|
101
|
+
fields: results[e.id].drift.count,
|
|
102
|
+
baselineAt: contracts[e.id]?.at,
|
|
103
|
+
}))
|
|
104
|
+
.sort((a, b) => b.fields - a.fields);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function authSurface(endpoints) {
|
|
108
|
+
const pick = (fn) => endpoints.filter(fn).map((e) => e.id).sort();
|
|
109
|
+
return {
|
|
110
|
+
accessToken: pick((e) => e.customToken === 'ACCESS_TOKEN'),
|
|
111
|
+
secretToken: pick((e) => e.customToken === 'SECRET_TOKEN'),
|
|
112
|
+
csToken: pick((e) => e.customToken === 'CS token'),
|
|
113
|
+
thirdParty: pick((e) => e.absolute),
|
|
114
|
+
standard: endpoints.filter((e) => !e.customToken && !e.absolute).length,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hygiene(endpoints, samples) {
|
|
119
|
+
const seen = new Map();
|
|
120
|
+
for (const ep of endpoints) {
|
|
121
|
+
if (ep.uncatalogued) continue;
|
|
122
|
+
const key = `${ep.method} ${ep.subUrl}`;
|
|
123
|
+
seen.set(key, [...(seen.get(key) ?? []), ep.id]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
unused: endpoints.filter((e) => e.usedIn === 0).map((e) => ({ id: e.id, file: e.file, line: e.line })),
|
|
128
|
+
duplicates: [...seen.entries()]
|
|
129
|
+
.filter(([, ids]) => ids.length > 1)
|
|
130
|
+
.map(([route, ids]) => ({ route, ids })),
|
|
131
|
+
dynamicUrl: endpoints.filter((e) => e.dynamicUrl).map((e) => e.id),
|
|
132
|
+
uncatalogued: endpoints
|
|
133
|
+
.filter((e) => e.uncatalogued)
|
|
134
|
+
.map((e) => ({ id: e.id, from: samples[e.id]?.from })),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const WRITE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
139
|
+
|
|
140
|
+
function risks(endpoints, samples, results) {
|
|
141
|
+
const captured = (e) => Boolean(samples[e.id]);
|
|
142
|
+
return {
|
|
143
|
+
untestedWrites: endpoints
|
|
144
|
+
.filter((e) => WRITE.has(e.method) && !captured(e) && !e.uncatalogued)
|
|
145
|
+
.map((e) => e.id),
|
|
146
|
+
capturedDeletes: endpoints.filter((e) => e.method === 'DELETE' && captured(e)).map((e) => e.id),
|
|
147
|
+
redactedPayloads: endpoints
|
|
148
|
+
.filter((e) => JSON.stringify(samples[e.id]?.data ?? {}).includes('<redacted>'))
|
|
149
|
+
.map((e) => e.id),
|
|
150
|
+
// a 2xx carrying an error code is the failure a status check misses
|
|
151
|
+
envelopeErrors: endpoints
|
|
152
|
+
.map((e) => ({ id: e.id, r: results[e.id] }))
|
|
153
|
+
.filter(({ r }) => r && r.status >= 200 && r.status < 300 && !r.ok)
|
|
154
|
+
.map(({ id, r }) => ({ id, status: r.status, innerCode: r.innerCode })),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function performance(endpoints, samples, results) {
|
|
159
|
+
const rows = endpoints
|
|
160
|
+
.map((e) => ({ id: e.id, module: e.module, ms: results[e.id]?.ms ?? samples[e.id]?.ms }))
|
|
161
|
+
.filter((r) => typeof r.ms === 'number')
|
|
162
|
+
.sort((a, b) => b.ms - a.ms);
|
|
163
|
+
|
|
164
|
+
const values = rows.map((r) => r.ms).sort((a, b) => a - b);
|
|
165
|
+
const at = (p) => (values.length ? values[Math.min(values.length - 1, Math.floor((p / 100) * values.length))] : 0);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
samples: rows.length,
|
|
169
|
+
median: at(50),
|
|
170
|
+
p95: at(95),
|
|
171
|
+
slowest: rows.slice(0, 10),
|
|
172
|
+
// one measurement per endpoint is a hint, not a benchmark
|
|
173
|
+
caveat: 'one measurement per endpoint, from its most recent call',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** request and response field lists, straight from real traffic */
|
|
178
|
+
function inventory(endpoints, samples, contracts) {
|
|
179
|
+
return endpoints
|
|
180
|
+
.filter((e) => samples[e.id])
|
|
181
|
+
.map((e) => {
|
|
182
|
+
const s = samples[e.id];
|
|
183
|
+
return {
|
|
184
|
+
id: e.id,
|
|
185
|
+
module: e.module,
|
|
186
|
+
method: e.method,
|
|
187
|
+
path: e.subUrl,
|
|
188
|
+
bodyType: s.bodyType ?? 'json',
|
|
189
|
+
params: Object.keys(s.params ?? {}),
|
|
190
|
+
body: Object.keys(s.data ?? {}),
|
|
191
|
+
response: contracts[e.id] ? Object.keys(flatten(contracts[e.id].shape)) : [],
|
|
192
|
+
seenAt: s.seenAt,
|
|
193
|
+
};
|
|
194
|
+
})
|
|
195
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** coverage and pass rate over previous runs, for the trend line */
|
|
199
|
+
function trends(runs) {
|
|
200
|
+
return runs.slice(-30).map((r) => ({
|
|
201
|
+
at: r.at,
|
|
202
|
+
ran: r.ran,
|
|
203
|
+
passed: r.passed,
|
|
204
|
+
failed: r.failed,
|
|
205
|
+
passRate: pct(r.passed, r.ran),
|
|
206
|
+
captured: r.captured,
|
|
207
|
+
coverage: r.coveragePct,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildReport({ catalog, endpoints, samples, results, contracts, runs = [], env }) {
|
|
212
|
+
return {
|
|
213
|
+
generatedAt: new Date().toISOString(),
|
|
214
|
+
authHeader: catalog.auth?.header ?? 'Authorization',
|
|
215
|
+
sourceLabel: catalog.sourceLabel ?? 'the source',
|
|
216
|
+
env,
|
|
217
|
+
baseUrl: catalog.baseUrls?.[env],
|
|
218
|
+
scannedAt: catalog.scannedAt,
|
|
219
|
+
coverage: coverage(endpoints, samples),
|
|
220
|
+
health: health(endpoints, results),
|
|
221
|
+
drift: drift(endpoints, results, contracts),
|
|
222
|
+
auth: authSurface(endpoints),
|
|
223
|
+
hygiene: hygiene(endpoints, samples),
|
|
224
|
+
risks: risks(endpoints, samples, results),
|
|
225
|
+
performance: performance(endpoints, samples, results),
|
|
226
|
+
inventory: inventory(endpoints, samples, contracts),
|
|
227
|
+
trends: trends(runs),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* ------------------------------------------------------------------ markdown */
|
|
232
|
+
|
|
233
|
+
const list = (items, fmt = (x) => x) =>
|
|
234
|
+
items.length ? items.map((i) => `- ${fmt(i)}`).join('\n') : '_none_';
|
|
235
|
+
|
|
236
|
+
export function toMarkdown(r) {
|
|
237
|
+
const c = r.coverage;
|
|
238
|
+
const h = r.health;
|
|
239
|
+
|
|
240
|
+
return `# API report
|
|
241
|
+
|
|
242
|
+
**${r.env}** · \`${r.baseUrl ?? ''}\` · generated ${new Date(r.generatedAt).toLocaleString()}
|
|
243
|
+
Catalog scanned ${new Date(r.scannedAt).toLocaleString()}
|
|
244
|
+
|
|
245
|
+
| | |
|
|
246
|
+
| --- | --- |
|
|
247
|
+
| Endpoints | ${c.total} across ${c.modules.length} modules |
|
|
248
|
+
| Exercised | ${c.seen} (${c.pct}%) |
|
|
249
|
+
| Passing | ${h.passed} of ${h.run} run |
|
|
250
|
+
| Failing | ${h.failed}${h.regressions.length ? ` (${h.regressions.length} regressions)` : ''} |
|
|
251
|
+
| Contract drift | ${r.drift.length} |
|
|
252
|
+
| Never exercised | ${c.total - c.seen} |
|
|
253
|
+
|
|
254
|
+
## Coverage by module
|
|
255
|
+
|
|
256
|
+
| Module | Exercised | Total | % |
|
|
257
|
+
| --- | ---: | ---: | ---: |
|
|
258
|
+
${c.modules.map((m) => `| ${m.module} | ${m.seen} | ${m.total} | ${m.pct}% |`).join('\n')}
|
|
259
|
+
|
|
260
|
+
Untouched modules (${c.untouched.length}): ${c.untouched.join(', ') || '_none_'}
|
|
261
|
+
|
|
262
|
+
## Failing endpoints
|
|
263
|
+
|
|
264
|
+
${
|
|
265
|
+
h.failing.length
|
|
266
|
+
? `| Endpoint | Method | Status | Body code | Message |
|
|
267
|
+
| --- | --- | ---: | ---: | --- |
|
|
268
|
+
${h.failing
|
|
269
|
+
.map(
|
|
270
|
+
(f) =>
|
|
271
|
+
`| ${f.id}${f.regression ? ' ⚠️' : ''} | ${f.method} | ${f.status ?? '—'} | ${
|
|
272
|
+
f.innerCode ?? '—'
|
|
273
|
+
} | ${(f.message ?? '').replace(/\|/g, '\\|')} |`,
|
|
274
|
+
)
|
|
275
|
+
.join('\n')}
|
|
276
|
+
|
|
277
|
+
⚠️ = was passing before this run`
|
|
278
|
+
: '_nothing failing_'
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
## Contract drift
|
|
282
|
+
|
|
283
|
+
${
|
|
284
|
+
r.drift.length
|
|
285
|
+
? r.drift.map((d) => `- **${d.id}** — \`${d.summary}\``).join('\n')
|
|
286
|
+
: '_no response shapes have changed_'
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
## Risks
|
|
290
|
+
|
|
291
|
+
- Writes never exercised (${r.risks.untestedWrites.length}): ${r.risks.untestedWrites.slice(0, 15).join(', ') || '_none_'}${r.risks.untestedWrites.length > 15 ? `, and ${r.risks.untestedWrites.length - 15} more` : ''}
|
|
292
|
+
- Captured DELETEs (${r.risks.capturedDeletes.length}): ${r.risks.capturedDeletes.join(', ') || '_none_'}
|
|
293
|
+
- 2xx carrying an error code (${r.risks.envelopeErrors.length}): ${r.risks.envelopeErrors.map((e) => `${e.id} (${e.innerCode})`).join(', ') || '_none_'}
|
|
294
|
+
- Payloads with stripped secrets (${r.risks.redactedPayloads.length}): ${r.risks.redactedPayloads.join(', ') || '_none_'}
|
|
295
|
+
|
|
296
|
+
## Auth surface
|
|
297
|
+
|
|
298
|
+
- Signed \`ACCESS_TOKEN\` (${r.auth.accessToken.length}): ${r.auth.accessToken.join(', ') || '_none_'}
|
|
299
|
+
- \`SECRET_TOKEN\` (${r.auth.secretToken.length}): ${r.auth.secretToken.join(', ') || '_none_'}
|
|
300
|
+
- CS token (${r.auth.csToken.length}): ${r.auth.csToken.join(', ') || '_none_'}
|
|
301
|
+
- Third-party, no token sent (${r.auth.thirdParty.length}): ${r.auth.thirdParty.join(', ') || '_none_'}
|
|
302
|
+
- Standard \`${r.authHeader}\`: ${r.auth.standard}
|
|
303
|
+
|
|
304
|
+
## Hygiene
|
|
305
|
+
|
|
306
|
+
**Referenced nowhere (${r.hygiene.unused.length})**
|
|
307
|
+
${list(r.hygiene.unused, (u) => `\`${u.id}\` — ${u.file}:${u.line}`)}
|
|
308
|
+
|
|
309
|
+
**Duplicate routes (${r.hygiene.duplicates.length})**
|
|
310
|
+
${list(r.hygiene.duplicates, (d) => `\`${d.route}\` — ${d.ids.join(', ')}`)}
|
|
311
|
+
|
|
312
|
+
**Called but not found in ${r.sourceLabel} (${r.hygiene.uncatalogued.length})**
|
|
313
|
+
${list(r.hygiene.uncatalogued, (u) => `\`${u.from ?? u.id}\``)}
|
|
314
|
+
|
|
315
|
+
## Latency
|
|
316
|
+
|
|
317
|
+
Median ${r.performance.median}ms · p95 ${r.performance.p95}ms · ${r.performance.samples} endpoints
|
|
318
|
+
_${r.performance.caveat}_
|
|
319
|
+
|
|
320
|
+
${list(r.performance.slowest, (s) => `${s.ms}ms — ${s.id}`)}
|
|
321
|
+
|
|
322
|
+
## Request and response inventory
|
|
323
|
+
|
|
324
|
+
${r.inventory
|
|
325
|
+
.map(
|
|
326
|
+
(i) =>
|
|
327
|
+
`### ${i.method} ${i.path}\n\`${i.id}\`${i.bodyType !== 'json' ? ` · ${i.bodyType}` : ''}\n\n` +
|
|
328
|
+
`- params: ${i.params.join(', ') || '_none_'}\n` +
|
|
329
|
+
`- body: ${i.body.join(', ') || '_none_'}\n` +
|
|
330
|
+
`- response: ${i.response.slice(0, 25).join(', ') || '_not recorded_'}${
|
|
331
|
+
i.response.length > 25 ? `, and ${i.response.length - 25} more` : ''
|
|
332
|
+
}`,
|
|
333
|
+
)
|
|
334
|
+
.join('\n\n')}
|
|
335
|
+
${
|
|
336
|
+
r.trends.length
|
|
337
|
+
? `\n## Trend\n\n| When | Ran | Passed | Failed | Pass rate | Coverage |\n| --- | ---: | ---: | ---: | ---: | ---: |\n${r.trends
|
|
338
|
+
.map(
|
|
339
|
+
(t) =>
|
|
340
|
+
`| ${new Date(t.at).toLocaleString()} | ${t.ran} | ${t.passed} | ${t.failed} | ${t.passRate}% | ${t.coverage ?? '—'}% |`,
|
|
341
|
+
)
|
|
342
|
+
.join('\n')}`
|
|
343
|
+
: ''
|
|
344
|
+
}
|
|
345
|
+
`;
|
|
346
|
+
}
|
package/cli/scan.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a codebase and writes the endpoint catalog.
|
|
3
|
+
*
|
|
4
|
+
* The catalog is the thing everything else hangs off: the console lists it, the
|
|
5
|
+
* recorder's traffic is matched back against it, and the report measures
|
|
6
|
+
* coverage against it. It is a plain JSON file, so a deployed console needs no
|
|
7
|
+
* source and no build — just the file that shipped with it.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
10
|
+
import { basename, dirname, extname, relative, resolve } from 'node:path';
|
|
11
|
+
import { PRESETS, holesIn, lineOf, stripComments } from './presets.mjs';
|
|
12
|
+
import { collectFiles, defaults, guessAuth, guessBaseUrls, guessPreset } from './config.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Files, other than its own, that mention this name.
|
|
16
|
+
*
|
|
17
|
+
* ponytail: a plain substring count, so `import * as services` or a re-export
|
|
18
|
+
* would hide a real usage. Confirm with a grep before deleting anything; switch
|
|
19
|
+
* to an import graph if that ever bites.
|
|
20
|
+
*/
|
|
21
|
+
function usageCount(name, sources, ownFile) {
|
|
22
|
+
const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
|
23
|
+
let n = 0;
|
|
24
|
+
for (const src of sources) {
|
|
25
|
+
if (src.file === ownFile) continue;
|
|
26
|
+
if (re.test(src.text)) n++;
|
|
27
|
+
}
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** which token a call carries beyond the standard one, when the source says so */
|
|
32
|
+
function customTokenIn(chunk) {
|
|
33
|
+
const hit = chunk.match(/\b(customJWToken|CSToken|secretToken|accessToken|ACCESS_TOKEN|SECRET_TOKEN|CS_TOKEN)\b/);
|
|
34
|
+
if (!hit) return null;
|
|
35
|
+
return { customJWToken: 'ACCESS_TOKEN', accessToken: 'ACCESS_TOKEN', ACCESS_TOKEN: 'ACCESS_TOKEN', CSToken: 'CS token', CS_TOKEN: 'CS token', secretToken: 'SECRET_TOKEN', SECRET_TOKEN: 'SECRET_TOKEN' }[hit[1]] ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Builds the catalog. Everything is passed in, so this runs the same way from
|
|
40
|
+
* the CLI, from a test, or from another tool.
|
|
41
|
+
*/
|
|
42
|
+
export function scanProject(root, config = defaults) {
|
|
43
|
+
const files = collectFiles(root, config);
|
|
44
|
+
const preset = config.preset ?? guessPreset(files, config).preset;
|
|
45
|
+
const parse = config.parse ?? (preset ? PRESETS[preset] : null);
|
|
46
|
+
if (!parse) {
|
|
47
|
+
return { preset: null, endpoints: [], files, baseUrls: {}, auth: guessAuth(files) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sources = files.map((file) => {
|
|
51
|
+
try {
|
|
52
|
+
return { file, text: readFileSync(file, 'utf8') };
|
|
53
|
+
} catch {
|
|
54
|
+
return { file, text: '' };
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const endpoints = [];
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
|
|
61
|
+
for (const { file, text } of sources) {
|
|
62
|
+
const src = stripComments(text);
|
|
63
|
+
const moduleName = config.moduleOf
|
|
64
|
+
? config.moduleOf(file, root)
|
|
65
|
+
: basename(file, extname(file));
|
|
66
|
+
|
|
67
|
+
let found;
|
|
68
|
+
try {
|
|
69
|
+
found = parse(src, config);
|
|
70
|
+
} catch {
|
|
71
|
+
continue; // one unparseable file must not sink the scan
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const hit of found) {
|
|
75
|
+
const id = `${moduleName}.${hit.name}`;
|
|
76
|
+
/*
|
|
77
|
+
* Two service functions can legitimately call the same route with
|
|
78
|
+
* different payloads, so the fingerprint includes the function name --
|
|
79
|
+
* keying on method+path alone silently collapses them into one.
|
|
80
|
+
*/
|
|
81
|
+
const fingerprint = `${hit.method} ${hit.subUrl} ${id}`;
|
|
82
|
+
if (seen.has(fingerprint)) continue;
|
|
83
|
+
seen.add(fingerprint);
|
|
84
|
+
|
|
85
|
+
endpoints.push({
|
|
86
|
+
id,
|
|
87
|
+
module: moduleName,
|
|
88
|
+
name: hit.name,
|
|
89
|
+
method: hit.method,
|
|
90
|
+
subUrl: hit.subUrl,
|
|
91
|
+
dynamicUrl: hit.dynamicUrl || undefined,
|
|
92
|
+
holes: holesIn(hit.subUrl),
|
|
93
|
+
usesParams: Boolean(hit.usesParams),
|
|
94
|
+
usesData: Boolean(hit.usesData),
|
|
95
|
+
absolute: Boolean(hit.absolute),
|
|
96
|
+
customToken: customTokenIn(hit.chunk ?? ''),
|
|
97
|
+
file: relative(root, file),
|
|
98
|
+
line: lineOf(src, hit.index ?? 0),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
endpoints.sort((a, b) => a.id.localeCompare(b.id));
|
|
104
|
+
for (const ep of endpoints) {
|
|
105
|
+
ep.usedIn = usageCount(ep.name, sources, resolve(root, ep.file));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
preset,
|
|
110
|
+
endpoints,
|
|
111
|
+
files,
|
|
112
|
+
baseUrls: Object.keys(config.baseUrls ?? {}).length ? config.baseUrls : guessBaseUrls(files),
|
|
113
|
+
auth: config.auth?.header && config.auth.header !== defaults.auth.header ? config.auth : guessAuth(files),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** writes the catalog, keeping an existing one when there is no source to read */
|
|
118
|
+
export function writeCatalog(out, catalog) {
|
|
119
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
120
|
+
writeFileSync(out, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function scanToFile(root, config, out) {
|
|
124
|
+
const result = scanProject(root, config);
|
|
125
|
+
|
|
126
|
+
// a deployed console ships with a catalog and no app source
|
|
127
|
+
if (!result.endpoints.length && existsSync(out)) {
|
|
128
|
+
const kept = JSON.parse(readFileSync(out, 'utf8'));
|
|
129
|
+
return { ...kept, kept: true, preset: result.preset };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const catalog = {
|
|
133
|
+
scannedAt: new Date().toISOString(),
|
|
134
|
+
preset: result.preset,
|
|
135
|
+
baseUrls: result.baseUrls,
|
|
136
|
+
auth: result.auth,
|
|
137
|
+
envelope: config.envelope ?? defaults.envelope,
|
|
138
|
+
endpoints: result.endpoints,
|
|
139
|
+
};
|
|
140
|
+
writeCatalog(out, catalog);
|
|
141
|
+
return catalog;
|
|
142
|
+
}
|