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.
Files changed (47) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +466 -0
  4. package/cli/bin/api-tracer.mjs +266 -0
  5. package/cli/config.mjs +224 -0
  6. package/cli/import.mjs +231 -0
  7. package/cli/index.mjs +10 -0
  8. package/cli/presets.mjs +212 -0
  9. package/cli/report.mjs +346 -0
  10. package/cli/scan.mjs +142 -0
  11. package/cli/server.mjs +1576 -0
  12. package/cli/shape.mjs +90 -0
  13. package/cli/test.mjs +342 -0
  14. package/cli/web/app.css +1424 -0
  15. package/cli/web/app.js +2260 -0
  16. package/cli/web/favicon.svg +5 -0
  17. package/cli/web/index.html +159 -0
  18. package/cli/web/logo.svg +7 -0
  19. package/dist/axios.cjs +856 -0
  20. package/dist/axios.cjs.map +1 -0
  21. package/dist/axios.d.cts +27 -0
  22. package/dist/axios.d.ts +27 -0
  23. package/dist/axios.js +853 -0
  24. package/dist/axios.js.map +1 -0
  25. package/dist/index.cjs +872 -0
  26. package/dist/index.cjs.map +1 -0
  27. package/dist/index.d.cts +74 -0
  28. package/dist/index.d.ts +74 -0
  29. package/dist/index.js +857 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/react.cjs +896 -0
  32. package/dist/react.cjs.map +1 -0
  33. package/dist/react.d.cts +22 -0
  34. package/dist/react.d.ts +22 -0
  35. package/dist/react.js +893 -0
  36. package/dist/react.js.map +1 -0
  37. package/dist/tracer-BUWdU2lG.d.ts +76 -0
  38. package/dist/tracer-DG2YUqK0.d.cts +76 -0
  39. package/dist/types-Bl2-K6_g.d.cts +111 -0
  40. package/dist/types-Bl2-K6_g.d.ts +111 -0
  41. package/dist/ui.cjs +1162 -0
  42. package/dist/ui.cjs.map +1 -0
  43. package/dist/ui.d.cts +16 -0
  44. package/dist/ui.d.ts +16 -0
  45. package/dist/ui.js +1157 -0
  46. package/dist/ui.js.map +1 -0
  47. 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
+ }