autotel-cli 0.19.1 → 0.21.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 +14 -1
- package/dist/index.js +292 -168
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -201,6 +201,9 @@ npx autotel-cli map --min-score 70
|
|
|
201
201
|
|
|
202
202
|
# Fail CI when a check that used to pass now fails
|
|
203
203
|
npx autotel-cli map --baseline git:origin/main
|
|
204
|
+
|
|
205
|
+
# Annotate the pull request diff in GitHub Actions
|
|
206
|
+
npx autotel-cli map --format github --baseline git:origin/main --no-write
|
|
204
207
|
```
|
|
205
208
|
|
|
206
209
|
**Detected frameworks:** `next`, `nitro` (Nuxt), `tanstack-start`, `sveltekit`, `hono`, `express`, `fastify`, `elysia`, `cloudflare`. Override with `--framework <name>`.
|
|
@@ -242,7 +245,17 @@ costs no score and is never counted as coverage;
|
|
|
242
245
|
- `--min-score <n>` - Exit 1 when the global score is below this threshold
|
|
243
246
|
- `--baseline [source]` - Compare against a committed map (path, or `git:<ref>`); exit 1 on regression
|
|
244
247
|
- `--no-write` - Skip writing `autotel.map.json`
|
|
245
|
-
- `--json` / `--output-file <path>` - Machine-readable output
|
|
248
|
+
- `--json` / `--output-file <path>` - Machine-readable output (`--json` is the same as `--format json`)
|
|
249
|
+
- `--format github` - GitHub Actions [workflow annotations](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions) on stdout; the report goes to stderr and the exit code is unchanged
|
|
250
|
+
- `--limit <n>` - Most annotations `--format github` emits (default 10, how many GitHub keeps per step)
|
|
251
|
+
|
|
252
|
+
**Annotating pull requests:** with `--baseline`, `--format github` emits the regressions as errors: the checks this pull request broke, nothing already on the map. Without one, it emits the "Fix these first" list, worst entry point first, as warnings. The first line carries the score, as an error when `--min-score` or the baseline failed the run, and says how many findings `--limit` left out. Paths are rebased on `GITHUB_WORKSPACE`, so a package scanned with `--cwd` inside a monorepo still annotates the right file.
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
::warning file=src/index.ts,line=6,title=autotel map%3A trace::no trace() or span() — this handler is invisible when it breaks
|
|
256
|
+
::warning file=src/index.ts,line=6,title=autotel map%3A audit::no audit trail (money: path says "payment") — withAudit() survives tail sampling
|
|
257
|
+
::notice title=autotel map::score 15/100 (at-risk): 0 instrumented, 0 partial, 3 dark; 6 more findings not shown
|
|
258
|
+
```
|
|
246
259
|
|
|
247
260
|
### `autotel codemod trace <path>`
|
|
248
261
|
|
package/dist/index.js
CHANGED
|
@@ -370,9 +370,6 @@ function createCodeFile() {
|
|
|
370
370
|
backendImports: [],
|
|
371
371
|
pluginImports: [],
|
|
372
372
|
subscriberImports: [],
|
|
373
|
-
loggerImports: [],
|
|
374
|
-
loggerSetup: null,
|
|
375
|
-
loggerExpr: null,
|
|
376
373
|
autoInstrumentations: [],
|
|
377
374
|
backendConfig: null,
|
|
378
375
|
subscribersConfig: [],
|
|
@@ -380,23 +377,11 @@ function createCodeFile() {
|
|
|
380
377
|
};
|
|
381
378
|
}
|
|
382
379
|
/**
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
380
|
+
* Add a name to the autoInstrumentations allowlist. For a logger this is what
|
|
381
|
+
* injects trace context: the app's own logger, created after init(), is
|
|
382
|
+
* patched as it loads, so it is never passed to init().
|
|
386
383
|
*/
|
|
387
|
-
function
|
|
388
|
-
file.loggerImports.push({
|
|
389
|
-
source: "pino",
|
|
390
|
-
default: "pino"
|
|
391
|
-
});
|
|
392
|
-
file.loggerSetup = `const logger = pino({ name: 'app', level: process.env.LOG_LEVEL ?? 'info' });`;
|
|
393
|
-
file.loggerExpr = "logger";
|
|
394
|
-
}
|
|
395
|
-
/**
|
|
396
|
-
* Add a logger to the autoInstrumentations array. For Winston/Bunyan this
|
|
397
|
-
* enables OpenTelemetry contrib's trace-context injection.
|
|
398
|
-
*/
|
|
399
|
-
function addAutoInstrumentationLogger(file, name) {
|
|
384
|
+
function addAutoInstrumentation(file, name) {
|
|
400
385
|
if (!file.autoInstrumentations.includes(name)) file.autoInstrumentations.push(name);
|
|
401
386
|
}
|
|
402
387
|
/**
|
|
@@ -502,17 +487,7 @@ function renderCodeFile(file) {
|
|
|
502
487
|
lines.push(renderImports(file.subscriberImports));
|
|
503
488
|
lines.push("");
|
|
504
489
|
}
|
|
505
|
-
if (file.loggerImports.length > 0) {
|
|
506
|
-
lines.push(SECTION_MARKER("LOGGER"));
|
|
507
|
-
lines.push(renderImports(file.loggerImports));
|
|
508
|
-
lines.push("");
|
|
509
|
-
}
|
|
510
|
-
if (file.loggerSetup !== null) {
|
|
511
|
-
lines.push(file.loggerSetup);
|
|
512
|
-
lines.push("");
|
|
513
|
-
}
|
|
514
490
|
lines.push("init({");
|
|
515
|
-
if (file.loggerExpr !== null) lines.push(` logger: ${file.loggerExpr},`);
|
|
516
491
|
if (file.autoInstrumentations.length > 0) {
|
|
517
492
|
const list = file.autoInstrumentations.map((n) => `'${n}'`).join(", ");
|
|
518
493
|
lines.push(` autoInstrumentations: [${list}],`);
|
|
@@ -2240,8 +2215,7 @@ function buildPlanFromDetection(opts) {
|
|
|
2240
2215
|
for (const pkg of p.packages.optional) prod.add(pkg);
|
|
2241
2216
|
for (const pkg of p.packages.devOnly) dev.add(pkg);
|
|
2242
2217
|
}
|
|
2243
|
-
if (detection.primaryLogger
|
|
2244
|
-
if (detection.autoInstrumentedDeps.length > 0 || detection.autoInstrumentLoggers.length > 0 || detection.primaryLogger === "winston" || detection.primaryLogger === "bunyan") prod.add("@opentelemetry/auto-instrumentations-node");
|
|
2218
|
+
if (detection.autoInstrumentedDeps.length > 0 || detection.autoInstrumentLoggers.length > 0 || detection.primaryLogger !== null) prod.add("@opentelemetry/auto-instrumentations-node");
|
|
2245
2219
|
const envVars = [];
|
|
2246
2220
|
const seenEnv = /* @__PURE__ */ new Set();
|
|
2247
2221
|
for (const p of presets) for (const ev of [...p.env.required, ...p.env.optional]) {
|
|
@@ -2496,7 +2470,7 @@ function renderPlanPreview(plan) {
|
|
|
2496
2470
|
const det = plan.detected;
|
|
2497
2471
|
const pkgList = det.packages.map((p) => `${p.name}@${p.version}`).join(", ");
|
|
2498
2472
|
lines.push(`${chalk.dim("Detected packages:")} ${pkgList || chalk.dim("(none)")}`);
|
|
2499
|
-
if (det.primaryLogger !== null) lines.push(`${chalk.dim("Logger:")} ${chalk.bold(det.primaryLogger)} ${chalk.dim("(
|
|
2473
|
+
if (det.primaryLogger !== null) lines.push(`${chalk.dim("Logger:")} ${chalk.bold(det.primaryLogger)} ${chalk.dim("(auto-instrumented)")}` + (det.autoInstrumentLoggers.length > 0 ? `, ${chalk.dim("+ auto-instrumented:")} ${det.autoInstrumentLoggers.join(", ")}` : ""));
|
|
2500
2474
|
if (det.autoInstrumentedDeps.length > 0) lines.push(`${chalk.dim("Covered by auto-instrumentations-node:")} ${det.autoInstrumentedDeps.join(", ")}`);
|
|
2501
2475
|
lines.push(`${chalk.dim("Backend:")} ${chalk.bold(det.backend.slug)} ${chalk.dim(`(${det.backend.source}${det.backend.detail ? `: ${det.backend.detail}` : ""})`)}`);
|
|
2502
2476
|
if (det.platform !== null) lines.push(`${chalk.dim("Platform:")} ${chalk.bold(det.platform)}`);
|
|
@@ -2759,6 +2733,15 @@ function isCI() {
|
|
|
2759
2733
|
//#endregion
|
|
2760
2734
|
//#region src/commands/init.ts
|
|
2761
2735
|
/**
|
|
2736
|
+
* Detected deps whose auto-instrumentation goes by another name. `next` and
|
|
2737
|
+
* `fastify` have none in auto-instrumentations-node; http covers them.
|
|
2738
|
+
*/
|
|
2739
|
+
const DEP_INSTRUMENTATION = {
|
|
2740
|
+
"@aws-sdk/client-s3": ["aws-sdk"],
|
|
2741
|
+
next: [],
|
|
2742
|
+
fastify: []
|
|
2743
|
+
};
|
|
2744
|
+
/**
|
|
2762
2745
|
* Run the init command.
|
|
2763
2746
|
*
|
|
2764
2747
|
* Order of operations:
|
|
@@ -2973,8 +2956,14 @@ function applyPlan(args) {
|
|
|
2973
2956
|
source: "autotel",
|
|
2974
2957
|
specifiers: ["init"]
|
|
2975
2958
|
});
|
|
2976
|
-
|
|
2977
|
-
|
|
2959
|
+
const detected = plan.detected;
|
|
2960
|
+
const loggers = [...detected?.primaryLogger ? [detected.primaryLogger] : [], ...detected?.autoInstrumentLoggers ?? []];
|
|
2961
|
+
const deps = detected?.autoInstrumentedDeps ?? [];
|
|
2962
|
+
if (loggers.length > 0 || deps.length > 0) for (const name of [
|
|
2963
|
+
"http",
|
|
2964
|
+
...loggers,
|
|
2965
|
+
...deps.flatMap((dep) => DEP_INSTRUMENTATION[dep] ?? [dep])
|
|
2966
|
+
]) addAutoInstrumentation(codeFile, name);
|
|
2978
2967
|
for (const preset of presets) {
|
|
2979
2968
|
for (const imp of preset.imports) addImport(codeFile, imp, preset.type === "backend" || preset.type === "platform" ? "backend" : preset.type === "plugin" ? "plugin" : preset.type === "subscriber" ? "subscriber" : void 0);
|
|
2980
2969
|
if (preset.configBlock.section === "BACKEND_CONFIG") setBackendConfig(codeFile, preset.configBlock.code);
|
|
@@ -2999,8 +2988,7 @@ function applyPlan(args) {
|
|
|
2999
2988
|
...codeFile.imports,
|
|
3000
2989
|
...codeFile.backendImports,
|
|
3001
2990
|
...codeFile.pluginImports,
|
|
3002
|
-
...codeFile.subscriberImports
|
|
3003
|
-
...codeFile.loggerImports
|
|
2991
|
+
...codeFile.subscriberImports
|
|
3004
2992
|
].map((i) => i.source));
|
|
3005
2993
|
const addedAuto = diffAutoInstrumentations(parsed, codeFile.autoInstrumentations);
|
|
3006
2994
|
const contentChanged = existing !== newContent;
|
|
@@ -5671,129 +5659,6 @@ function hasRegressed(comparison) {
|
|
|
5671
5659
|
return comparison.regressions.length > 0 || comparison.delta < 0;
|
|
5672
5660
|
}
|
|
5673
5661
|
//#endregion
|
|
5674
|
-
//#region src/lib/map/project-facts.ts
|
|
5675
|
-
const LLM_PACKAGES = [
|
|
5676
|
-
"openai",
|
|
5677
|
-
"@anthropic-ai/sdk",
|
|
5678
|
-
"ai",
|
|
5679
|
-
"@ai-sdk/openai",
|
|
5680
|
-
"@ai-sdk/anthropic",
|
|
5681
|
-
"@ai-sdk/google",
|
|
5682
|
-
"ai-sdk-ollama",
|
|
5683
|
-
"ollama",
|
|
5684
|
-
"@google/generative-ai",
|
|
5685
|
-
"@mistralai/mistralai",
|
|
5686
|
-
"cohere-ai",
|
|
5687
|
-
"langchain",
|
|
5688
|
-
"@langchain/core"
|
|
5689
|
-
];
|
|
5690
|
-
const RUNTIME_AUTOTEL_PACKAGES = [
|
|
5691
|
-
"autotel",
|
|
5692
|
-
"autotel-edge",
|
|
5693
|
-
"autotel-cloudflare",
|
|
5694
|
-
"autotel-nuxt",
|
|
5695
|
-
"autotel-tanstack",
|
|
5696
|
-
"autotel-hono",
|
|
5697
|
-
"autotel-adapters"
|
|
5698
|
-
];
|
|
5699
|
-
/**
|
|
5700
|
-
* Files where a framework integration is wired up.
|
|
5701
|
-
*
|
|
5702
|
-
* Kept to a fixed list rather than a glob: this answers "did anyone install the
|
|
5703
|
-
* middleware", and the answer is always in the app's entry file or its hooks.
|
|
5704
|
-
*/
|
|
5705
|
-
const WIRING_FILES = [
|
|
5706
|
-
"src/index.ts",
|
|
5707
|
-
"src/app.ts",
|
|
5708
|
-
"src/server.ts",
|
|
5709
|
-
"src/main.ts",
|
|
5710
|
-
"index.ts",
|
|
5711
|
-
"app.ts",
|
|
5712
|
-
"server.ts",
|
|
5713
|
-
"middleware.ts",
|
|
5714
|
-
"src/middleware.ts",
|
|
5715
|
-
"src/hooks.server.ts",
|
|
5716
|
-
"hooks.server.ts",
|
|
5717
|
-
"nuxt.config.ts",
|
|
5718
|
-
"src/router.tsx",
|
|
5719
|
-
"app/router.tsx"
|
|
5720
|
-
];
|
|
5721
|
-
/** Autotel framework integrations that create a request span when called. */
|
|
5722
|
-
const AMBIENT_APIS = ["autotelMiddleware", "autotelHandle"];
|
|
5723
|
-
function hasAmbientTracing(projectRoot, instrumentationPath) {
|
|
5724
|
-
const parse = createParser();
|
|
5725
|
-
const candidates = new Set([instrumentationPath, ...WIRING_FILES.map((file) => path$1.join(projectRoot, file))].filter((file) => file !== null && fileExists(file)));
|
|
5726
|
-
for (const file of candidates) {
|
|
5727
|
-
const source = parse(file);
|
|
5728
|
-
if (source && usesAutotelApi$1(buildFileFacts(source), AMBIENT_APIS)) return true;
|
|
5729
|
-
}
|
|
5730
|
-
return false;
|
|
5731
|
-
}
|
|
5732
|
-
function projectSource(projectRoot) {
|
|
5733
|
-
const files = globSync("{src,app,server,routes,pages}/**/*.{ts,tsx,js,jsx,mts,mjs}", {
|
|
5734
|
-
cwd: projectRoot,
|
|
5735
|
-
absolute: true,
|
|
5736
|
-
ignore: [
|
|
5737
|
-
"**/node_modules/**",
|
|
5738
|
-
"**/dist/**",
|
|
5739
|
-
"**/*.test.*",
|
|
5740
|
-
"**/*.spec.*"
|
|
5741
|
-
]
|
|
5742
|
-
});
|
|
5743
|
-
const occurrences = /* @__PURE__ */ new Map();
|
|
5744
|
-
const chunks = [];
|
|
5745
|
-
const call = /createStructuredError\s*\(\s*\{([\s\S]*?)\}\s*\)/g;
|
|
5746
|
-
for (const file of files) {
|
|
5747
|
-
const source = readFileSafe(file) ?? "";
|
|
5748
|
-
chunks.push(source);
|
|
5749
|
-
for (const match of source.matchAll(call)) {
|
|
5750
|
-
const body = match[1] ?? "";
|
|
5751
|
-
const message = /\bmessage\s*:\s*(['"`])([\s\S]*?)\1/.exec(body)?.[2];
|
|
5752
|
-
const status = /\bstatus\s*:\s*(\d+)/.exec(body)?.[1];
|
|
5753
|
-
if (!message || !status) continue;
|
|
5754
|
-
const signature = `${status}|${message}`;
|
|
5755
|
-
const entry = occurrences.get(signature) ?? {
|
|
5756
|
-
label: message,
|
|
5757
|
-
files: /* @__PURE__ */ new Set()
|
|
5758
|
-
};
|
|
5759
|
-
entry.files.add(path$1.relative(projectRoot, file).split(path$1.sep).join("/"));
|
|
5760
|
-
occurrences.set(signature, entry);
|
|
5761
|
-
}
|
|
5762
|
-
}
|
|
5763
|
-
return {
|
|
5764
|
-
text: chunks.join("\n"),
|
|
5765
|
-
repeatedErrors: new Map([...occurrences].filter(([, entry]) => entry.files.size > 1).map(([signature, entry]) => [signature, {
|
|
5766
|
-
label: entry.label,
|
|
5767
|
-
files: [...entry.files].toSorted()
|
|
5768
|
-
}]))
|
|
5769
|
-
};
|
|
5770
|
-
}
|
|
5771
|
-
function collectDeps(pkg) {
|
|
5772
|
-
return /* @__PURE__ */ new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {})]);
|
|
5773
|
-
}
|
|
5774
|
-
function collectProjectFacts(projectRoot) {
|
|
5775
|
-
const pkg = readJsonSafe(path$1.join(projectRoot, "package.json"));
|
|
5776
|
-
const deps = collectDeps(pkg);
|
|
5777
|
-
const instrumentation = findInstrumentationFile(projectRoot);
|
|
5778
|
-
const instrumentationText = instrumentation ? readFileSafe(instrumentation.path) ?? "" : "";
|
|
5779
|
-
const ambientText = `${instrumentationText}\n${WIRING_FILES.map((candidate) => fileExists(path$1.join(projectRoot, candidate)) ? readFileSafe(path$1.join(projectRoot, candidate)) ?? "" : "").join("\n")}`;
|
|
5780
|
-
const source = projectSource(projectRoot);
|
|
5781
|
-
const projectSignalText = `${ambientText}\n${source.text}`;
|
|
5782
|
-
return {
|
|
5783
|
-
name: pkg?.name ?? path$1.basename(projectRoot),
|
|
5784
|
-
deps,
|
|
5785
|
-
hasAutotel: RUNTIME_AUTOTEL_PACKAGES.some((name) => deps.has(name)),
|
|
5786
|
-
ambientTracing: deps.has("autotel-nuxt") || hasAmbientTracing(projectRoot, instrumentation?.path ?? null),
|
|
5787
|
-
hasRedaction: instrumentationText.includes("attributeRedactor"),
|
|
5788
|
-
instrumentationFile: instrumentation ? path$1.relative(projectRoot, instrumentation.path).split(path$1.sep).join("/") : null,
|
|
5789
|
-
hasLlmDependency: LLM_PACKAGES.some((name) => deps.has(name)),
|
|
5790
|
-
hasZod: deps.has("zod"),
|
|
5791
|
-
hasAuditUsage: deps.has("autotel-audit") && /securityEvent|withSecurity|withAudit|defineAuditCatalog/.test(projectSignalText),
|
|
5792
|
-
hasErrorCatalog: projectSignalText.includes("defineErrorCatalog"),
|
|
5793
|
-
repeatedErrors: source.repeatedErrors
|
|
5794
|
-
};
|
|
5795
|
-
}
|
|
5796
|
-
//#endregion
|
|
5797
5662
|
//#region src/lib/map/sensitivity.ts
|
|
5798
5663
|
const MONEY_PACKAGES = [
|
|
5799
5664
|
"stripe",
|
|
@@ -5932,6 +5797,10 @@ function lostPoints(route) {
|
|
|
5932
5797
|
const weight = route.sensitivity.level === "high" ? 2 : 1;
|
|
5933
5798
|
return (100 - route.score) * weight;
|
|
5934
5799
|
}
|
|
5800
|
+
/** Entry points with gaps, most points lost first: the "Fix these first" order. */
|
|
5801
|
+
function fixFirst(routes) {
|
|
5802
|
+
return routes.filter((route) => classifyRouteObservability(route) !== "exempt" && route.score < 100).toSorted((a, b) => lostPoints(b) - lostPoints(a));
|
|
5803
|
+
}
|
|
5935
5804
|
/**
|
|
5936
5805
|
* The default view: the number, the tally, and the three entry points worth
|
|
5937
5806
|
* fixing first.
|
|
@@ -5948,7 +5817,7 @@ function formatReport(result, options = {}) {
|
|
|
5948
5817
|
lines.push("");
|
|
5949
5818
|
lines.push(` ${colour(chalk.bold(`${map.score}/100`))} ${colour(grade)}`);
|
|
5950
5819
|
lines.push(chalk.dim(` ${map.routes.length} entry point${map.routes.length === 1 ? "" : "s"} · ${summary.instrumented} instrumented · ${summary.partial} partial · ${summary.dark} dark · ${summary.exempt} exempt` + (summary.suppressedChecks > 0 ? ` · ${summary.suppressedChecks} suppressed` : "")));
|
|
5951
|
-
const worst = map.routes
|
|
5820
|
+
const worst = fixFirst(map.routes).slice(0, 3);
|
|
5952
5821
|
if (worst.length > 0) {
|
|
5953
5822
|
lines.push("");
|
|
5954
5823
|
lines.push(chalk.bold("Fix these first"));
|
|
@@ -6057,6 +5926,207 @@ function formatGate(score, threshold) {
|
|
|
6057
5926
|
return score < threshold ? chalk.red(`\nScore ${score} is below --min-score ${threshold}.`) : chalk.green(`\nScore ${score} meets --min-score ${threshold}.`);
|
|
6058
5927
|
}
|
|
6059
5928
|
//#endregion
|
|
5929
|
+
//#region src/lib/map/github.ts
|
|
5930
|
+
function rebase(file, location) {
|
|
5931
|
+
if (!location.workspace) return file;
|
|
5932
|
+
return path$1.relative(location.workspace, path$1.resolve(location.projectRoot, file)).split(path$1.sep).join("/");
|
|
5933
|
+
}
|
|
5934
|
+
/** The failing requirements of one entry point, at the evidence line when a check has one. */
|
|
5935
|
+
function failures(route) {
|
|
5936
|
+
const out = [];
|
|
5937
|
+
for (const [check, result] of Object.entries(route.checks)) {
|
|
5938
|
+
if (result.status !== "fail") continue;
|
|
5939
|
+
out.push({
|
|
5940
|
+
file: result.evidence?.file ?? route.file,
|
|
5941
|
+
line: result.evidence?.line ?? route.handler?.line ?? 1,
|
|
5942
|
+
check,
|
|
5943
|
+
message: result.message ?? getRule(check)?.question ?? check
|
|
5944
|
+
});
|
|
5945
|
+
}
|
|
5946
|
+
return out;
|
|
5947
|
+
}
|
|
5948
|
+
/**
|
|
5949
|
+
* Checks that passed in the baseline and no longer do. These are the pull
|
|
5950
|
+
* request's; everything else on the map predates it and is already in the score.
|
|
5951
|
+
*/
|
|
5952
|
+
function regressions(scan, baseline) {
|
|
5953
|
+
const routes = new Map(scan.map.routes.map((route) => [route.id, route]));
|
|
5954
|
+
return baseline.regressions.flatMap((regression) => {
|
|
5955
|
+
const route = routes.get(regression.routeId);
|
|
5956
|
+
if (!route) return [];
|
|
5957
|
+
const failed = failures(route).find((finding) => finding.check === regression.check);
|
|
5958
|
+
if (failed) return [failed];
|
|
5959
|
+
return [{
|
|
5960
|
+
file: route.file,
|
|
5961
|
+
line: route.handler?.line ?? 1,
|
|
5962
|
+
check: regression.check,
|
|
5963
|
+
message: `${regression.check} passed in ${baseline.source.label} and is now suppressed`
|
|
5964
|
+
}];
|
|
5965
|
+
});
|
|
5966
|
+
}
|
|
5967
|
+
function escapeMessage(text) {
|
|
5968
|
+
return text.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
5969
|
+
}
|
|
5970
|
+
function escapeProperty(text) {
|
|
5971
|
+
return escapeMessage(text).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
5972
|
+
}
|
|
5973
|
+
function annotation(level, properties, message) {
|
|
5974
|
+
const props = Object.entries(properties).map(([key, value]) => `${key}=${escapeProperty(value)}`).join(",");
|
|
5975
|
+
return `::${level}${props ? ` ${props}` : ""}::${escapeMessage(message)}`;
|
|
5976
|
+
}
|
|
5977
|
+
/**
|
|
5978
|
+
* GitHub Actions workflow commands, one line per finding, for stdout.
|
|
5979
|
+
*
|
|
5980
|
+
* With a baseline, the findings are the regressions and they are errors: the
|
|
5981
|
+
* pull request caused them. Without one, they are the "Fix these first" list,
|
|
5982
|
+
* worst entry point first, as warnings. Either list stops at `limit`. The
|
|
5983
|
+
* first line carries the score, as an error when the gate failed, so the run
|
|
5984
|
+
* is readable without the log. It leads because GitHub keeps only the first
|
|
5985
|
+
* ten errors of a step.
|
|
5986
|
+
*/
|
|
5987
|
+
function formatGithubAnnotations(scan, baseline, location, options) {
|
|
5988
|
+
const findings = baseline ? regressions(scan, baseline) : fixFirst(scan.map.routes).flatMap(failures);
|
|
5989
|
+
const level = baseline ? "error" : "warning";
|
|
5990
|
+
const lines = findings.slice(0, options.limit).map((finding) => annotation(level, {
|
|
5991
|
+
file: rebase(finding.file, location),
|
|
5992
|
+
line: String(finding.line),
|
|
5993
|
+
title: `autotel map: ${finding.check}`
|
|
5994
|
+
}, finding.message));
|
|
5995
|
+
const { score } = scan.map;
|
|
5996
|
+
const { dark, instrumented, partial } = scan.summary;
|
|
5997
|
+
const hidden = findings.length - lines.length;
|
|
5998
|
+
const summary = `score ${score}/100 (${scan.grade}): ${instrumented} instrumented, ${partial} partial, ${dark} dark` + (hidden > 0 ? `; ${hidden} more finding${hidden === 1 ? "" : "s"} not shown` : "");
|
|
5999
|
+
const title = { title: "autotel map" };
|
|
6000
|
+
let headline;
|
|
6001
|
+
if (options.minScore !== void 0 && score < options.minScore) headline = annotation("error", title, `${summary}; below --min-score ${options.minScore}`);
|
|
6002
|
+
else if (baseline && hasRegressed(baseline)) headline = annotation("error", title, `${summary}; regressed against ${baseline.source.label}`);
|
|
6003
|
+
else headline = annotation("notice", title, summary);
|
|
6004
|
+
return [headline, ...lines].join("\n");
|
|
6005
|
+
}
|
|
6006
|
+
//#endregion
|
|
6007
|
+
//#region src/lib/map/project-facts.ts
|
|
6008
|
+
const LLM_PACKAGES = [
|
|
6009
|
+
"openai",
|
|
6010
|
+
"@anthropic-ai/sdk",
|
|
6011
|
+
"ai",
|
|
6012
|
+
"@ai-sdk/openai",
|
|
6013
|
+
"@ai-sdk/anthropic",
|
|
6014
|
+
"@ai-sdk/google",
|
|
6015
|
+
"ai-sdk-ollama",
|
|
6016
|
+
"ollama",
|
|
6017
|
+
"@google/generative-ai",
|
|
6018
|
+
"@mistralai/mistralai",
|
|
6019
|
+
"cohere-ai",
|
|
6020
|
+
"langchain",
|
|
6021
|
+
"@langchain/core"
|
|
6022
|
+
];
|
|
6023
|
+
const RUNTIME_AUTOTEL_PACKAGES = [
|
|
6024
|
+
"autotel",
|
|
6025
|
+
"autotel-edge",
|
|
6026
|
+
"autotel-cloudflare",
|
|
6027
|
+
"autotel-nuxt",
|
|
6028
|
+
"autotel-tanstack",
|
|
6029
|
+
"autotel-hono",
|
|
6030
|
+
"autotel-adapters"
|
|
6031
|
+
];
|
|
6032
|
+
/**
|
|
6033
|
+
* Files where a framework integration is wired up.
|
|
6034
|
+
*
|
|
6035
|
+
* Kept to a fixed list rather than a glob: this answers "did anyone install the
|
|
6036
|
+
* middleware", and the answer is always in the app's entry file or its hooks.
|
|
6037
|
+
*/
|
|
6038
|
+
const WIRING_FILES = [
|
|
6039
|
+
"src/index.ts",
|
|
6040
|
+
"src/app.ts",
|
|
6041
|
+
"src/server.ts",
|
|
6042
|
+
"src/main.ts",
|
|
6043
|
+
"index.ts",
|
|
6044
|
+
"app.ts",
|
|
6045
|
+
"server.ts",
|
|
6046
|
+
"middleware.ts",
|
|
6047
|
+
"src/middleware.ts",
|
|
6048
|
+
"src/hooks.server.ts",
|
|
6049
|
+
"hooks.server.ts",
|
|
6050
|
+
"nuxt.config.ts",
|
|
6051
|
+
"src/router.tsx",
|
|
6052
|
+
"app/router.tsx"
|
|
6053
|
+
];
|
|
6054
|
+
/** Autotel framework integrations that create a request span when called. */
|
|
6055
|
+
const AMBIENT_APIS = ["autotelMiddleware", "autotelHandle"];
|
|
6056
|
+
function hasAmbientTracing(projectRoot, instrumentationPath) {
|
|
6057
|
+
const parse = createParser();
|
|
6058
|
+
const candidates = new Set([instrumentationPath, ...WIRING_FILES.map((file) => path$1.join(projectRoot, file))].filter((file) => file !== null && fileExists(file)));
|
|
6059
|
+
for (const file of candidates) {
|
|
6060
|
+
const source = parse(file);
|
|
6061
|
+
if (source && usesAutotelApi$1(buildFileFacts(source), AMBIENT_APIS)) return true;
|
|
6062
|
+
}
|
|
6063
|
+
return false;
|
|
6064
|
+
}
|
|
6065
|
+
function projectSource(projectRoot) {
|
|
6066
|
+
const files = globSync("{src,app,server,routes,pages}/**/*.{ts,tsx,js,jsx,mts,mjs}", {
|
|
6067
|
+
cwd: projectRoot,
|
|
6068
|
+
absolute: true,
|
|
6069
|
+
ignore: [
|
|
6070
|
+
"**/node_modules/**",
|
|
6071
|
+
"**/dist/**",
|
|
6072
|
+
"**/*.test.*",
|
|
6073
|
+
"**/*.spec.*"
|
|
6074
|
+
]
|
|
6075
|
+
});
|
|
6076
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
6077
|
+
const chunks = [];
|
|
6078
|
+
const call = /createStructuredError\s*\(\s*\{([\s\S]*?)\}\s*\)/g;
|
|
6079
|
+
for (const file of files) {
|
|
6080
|
+
const source = readFileSafe(file) ?? "";
|
|
6081
|
+
chunks.push(source);
|
|
6082
|
+
for (const match of source.matchAll(call)) {
|
|
6083
|
+
const body = match[1] ?? "";
|
|
6084
|
+
const message = /\bmessage\s*:\s*(['"`])([\s\S]*?)\1/.exec(body)?.[2];
|
|
6085
|
+
const status = /\bstatus\s*:\s*(\d+)/.exec(body)?.[1];
|
|
6086
|
+
if (!message || !status) continue;
|
|
6087
|
+
const signature = `${status}|${message}`;
|
|
6088
|
+
const entry = occurrences.get(signature) ?? {
|
|
6089
|
+
label: message,
|
|
6090
|
+
files: /* @__PURE__ */ new Set()
|
|
6091
|
+
};
|
|
6092
|
+
entry.files.add(path$1.relative(projectRoot, file).split(path$1.sep).join("/"));
|
|
6093
|
+
occurrences.set(signature, entry);
|
|
6094
|
+
}
|
|
6095
|
+
}
|
|
6096
|
+
return {
|
|
6097
|
+
text: chunks.join("\n"),
|
|
6098
|
+
repeatedErrors: new Map([...occurrences].filter(([, entry]) => entry.files.size > 1).map(([signature, entry]) => [signature, {
|
|
6099
|
+
label: entry.label,
|
|
6100
|
+
files: [...entry.files].toSorted()
|
|
6101
|
+
}]))
|
|
6102
|
+
};
|
|
6103
|
+
}
|
|
6104
|
+
function collectDeps(pkg) {
|
|
6105
|
+
return /* @__PURE__ */ new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {})]);
|
|
6106
|
+
}
|
|
6107
|
+
function collectProjectFacts(projectRoot) {
|
|
6108
|
+
const pkg = readJsonSafe(path$1.join(projectRoot, "package.json"));
|
|
6109
|
+
const deps = collectDeps(pkg);
|
|
6110
|
+
const instrumentation = findInstrumentationFile(projectRoot);
|
|
6111
|
+
const instrumentationText = instrumentation ? readFileSafe(instrumentation.path) ?? "" : "";
|
|
6112
|
+
const ambientText = `${instrumentationText}\n${WIRING_FILES.map((candidate) => fileExists(path$1.join(projectRoot, candidate)) ? readFileSafe(path$1.join(projectRoot, candidate)) ?? "" : "").join("\n")}`;
|
|
6113
|
+
const source = projectSource(projectRoot);
|
|
6114
|
+
const projectSignalText = `${ambientText}\n${source.text}`;
|
|
6115
|
+
return {
|
|
6116
|
+
name: pkg?.name ?? path$1.basename(projectRoot),
|
|
6117
|
+
deps,
|
|
6118
|
+
hasAutotel: RUNTIME_AUTOTEL_PACKAGES.some((name) => deps.has(name)),
|
|
6119
|
+
ambientTracing: deps.has("autotel-nuxt") || hasAmbientTracing(projectRoot, instrumentation?.path ?? null),
|
|
6120
|
+
hasRedaction: instrumentationText.includes("attributeRedactor"),
|
|
6121
|
+
instrumentationFile: instrumentation ? path$1.relative(projectRoot, instrumentation.path).split(path$1.sep).join("/") : null,
|
|
6122
|
+
hasLlmDependency: LLM_PACKAGES.some((name) => deps.has(name)),
|
|
6123
|
+
hasZod: deps.has("zod"),
|
|
6124
|
+
hasAuditUsage: deps.has("autotel-audit") && /securityEvent|withSecurity|withAudit|defineAuditCatalog/.test(projectSignalText),
|
|
6125
|
+
hasErrorCatalog: projectSignalText.includes("defineErrorCatalog"),
|
|
6126
|
+
repeatedErrors: source.repeatedErrors
|
|
6127
|
+
};
|
|
6128
|
+
}
|
|
6129
|
+
//#endregion
|
|
6060
6130
|
//#region src/lib/map/scan.ts
|
|
6061
6131
|
/** Stable identity for an entry point, so a baseline can match it across runs. */
|
|
6062
6132
|
function routeId(route) {
|
|
@@ -6182,6 +6252,46 @@ function parseMinScore(value) {
|
|
|
6182
6252
|
});
|
|
6183
6253
|
return threshold;
|
|
6184
6254
|
}
|
|
6255
|
+
const FORMATS = [
|
|
6256
|
+
"human",
|
|
6257
|
+
"json",
|
|
6258
|
+
"github"
|
|
6259
|
+
];
|
|
6260
|
+
/**
|
|
6261
|
+
* Read `--format`, with `--json` as the long-standing spelling of `--format
|
|
6262
|
+
* json`. Two formats cannot share stdout, so asking for both is refused rather
|
|
6263
|
+
* than letting one silently win.
|
|
6264
|
+
*/
|
|
6265
|
+
function parseFormat(value, json) {
|
|
6266
|
+
if (value === void 0 || value === "") return json ? "json" : "human";
|
|
6267
|
+
const format = FORMATS.find((name) => name === value);
|
|
6268
|
+
if (!format) throw new AutotelError({
|
|
6269
|
+
type: "validation",
|
|
6270
|
+
code: AutotelErrorCodes.E_INVALID_FLAG,
|
|
6271
|
+
message: `Unknown --format "${value}"`,
|
|
6272
|
+
fix: `Pass one of: ${FORMATS.join(", ")}`,
|
|
6273
|
+
expected: { formats: [...FORMATS] }
|
|
6274
|
+
});
|
|
6275
|
+
if (json && format !== "json") throw new AutotelError({
|
|
6276
|
+
type: "validation",
|
|
6277
|
+
code: AutotelErrorCodes.E_INVALID_FLAG,
|
|
6278
|
+
message: `--json and --format ${format} both claim stdout`,
|
|
6279
|
+
fix: "Drop --json, or pass --format json"
|
|
6280
|
+
});
|
|
6281
|
+
return format;
|
|
6282
|
+
}
|
|
6283
|
+
/** Read `--limit`; the whole string has to parse, for the same reason as `--min-score`. */
|
|
6284
|
+
function parseLimit(value) {
|
|
6285
|
+
if (value === void 0 || value === "") return 10;
|
|
6286
|
+
const limit = Number(value);
|
|
6287
|
+
if (!Number.isInteger(limit) || limit < 1) throw new AutotelError({
|
|
6288
|
+
type: "validation",
|
|
6289
|
+
code: AutotelErrorCodes.E_INVALID_FLAG,
|
|
6290
|
+
message: `--limit expects a whole number of 1 or more, got "${value}"`,
|
|
6291
|
+
fix: "Pass e.g. --limit 10"
|
|
6292
|
+
});
|
|
6293
|
+
return limit;
|
|
6294
|
+
}
|
|
6185
6295
|
function resolveFramework(requested, projectRoot, deps) {
|
|
6186
6296
|
if (requested !== void 0 && requested !== "") {
|
|
6187
6297
|
if (!isFramework(requested)) throw new AutotelError({
|
|
@@ -6268,9 +6378,25 @@ function runMap(options) {
|
|
|
6268
6378
|
...options.outputFile !== void 0 ? { outputFile: options.outputFile } : {},
|
|
6269
6379
|
outputRoot: options.cwd
|
|
6270
6380
|
});
|
|
6381
|
+
const format = parseFormat(options.format, options.json);
|
|
6382
|
+
const limit = parseLimit(options.limit);
|
|
6271
6383
|
const minScore = parseMinScore(options.minScore);
|
|
6272
6384
|
const result = runMapScan(options);
|
|
6273
|
-
|
|
6385
|
+
const human = () => formatMapResult(result, {
|
|
6386
|
+
all: options.all,
|
|
6387
|
+
...options.entry !== void 0 ? { entry: options.entry } : {},
|
|
6388
|
+
...minScore !== void 0 ? { minScore } : {}
|
|
6389
|
+
});
|
|
6390
|
+
if (format === "github") {
|
|
6391
|
+
process.stdout.write(`${formatGithubAnnotations(result.scan, result.baseline, {
|
|
6392
|
+
projectRoot: result.projectRoot,
|
|
6393
|
+
...process.env.GITHUB_WORKSPACE ? { workspace: process.env.GITHUB_WORKSPACE } : {}
|
|
6394
|
+
}, {
|
|
6395
|
+
limit,
|
|
6396
|
+
...minScore !== void 0 ? { minScore } : {}
|
|
6397
|
+
})}\n`);
|
|
6398
|
+
process.stderr.write(`${human()}\n`);
|
|
6399
|
+
} else if (format === "json") printJson({
|
|
6274
6400
|
ok: true,
|
|
6275
6401
|
command: "map",
|
|
6276
6402
|
mapFile: MAP_FILE_NAME,
|
|
@@ -6283,11 +6409,7 @@ function runMap(options) {
|
|
|
6283
6409
|
...result.baseline ? { baseline: result.baseline } : {},
|
|
6284
6410
|
map: result.scan.map
|
|
6285
6411
|
});
|
|
6286
|
-
else process.stdout.write(`${
|
|
6287
|
-
all: options.all,
|
|
6288
|
-
...options.entry !== void 0 ? { entry: options.entry } : {},
|
|
6289
|
-
...minScore !== void 0 ? { minScore } : {}
|
|
6290
|
-
})}\n`);
|
|
6412
|
+
else process.stdout.write(`${human()}\n`);
|
|
6291
6413
|
if (minScore !== void 0 && result.scan.map.score < minScore) {
|
|
6292
6414
|
process.exitCode = 1;
|
|
6293
6415
|
return;
|
|
@@ -9631,7 +9753,7 @@ function createProgram() {
|
|
|
9631
9753
|
});
|
|
9632
9754
|
addGlobalOptions(addCmd);
|
|
9633
9755
|
program.addCommand(addCmd);
|
|
9634
|
-
const mapCmd = new Command("map").description("Score the observability of every entry point in your project").argument("[entry]", "Inspect one entry point by route or file path").option("--framework <name>", "Override framework detection (next, nitro, tanstack-start, sveltekit, hono, express, fastify, elysia, cloudflare)").option("--all", "Show every entry point as a check matrix").option("--min-score <n>", "Exit 1 when the global score is below this threshold").option("--baseline [source]", "Compare against a committed autotel.map.json and exit 1 on regression (path, or git:<ref>)").option("--no-write", "Skip writing autotel.map.json").option("--json", "Output machine-readable JSON").option("--output-file <path>", "Persist JSON output to this path").option("--workspace-root", "Scan from the workspace root instead of the package root").action((entry, opts) => {
|
|
9756
|
+
const mapCmd = new Command("map").description("Score the observability of every entry point in your project").argument("[entry]", "Inspect one entry point by route or file path").option("--framework <name>", "Override framework detection (next, nitro, tanstack-start, sveltekit, hono, express, fastify, elysia, cloudflare)").option("--all", "Show every entry point as a check matrix").option("--min-score <n>", "Exit 1 when the global score is below this threshold").option("--baseline [source]", "Compare against a committed autotel.map.json and exit 1 on regression (path, or git:<ref>)").option("--no-write", "Skip writing autotel.map.json").option("--json", "Output machine-readable JSON (same as --format json)").option("--format <name>", "Output: human (default), json, github (workflow annotations on stdout)").option("--limit <n>", "Most annotations --format github emits (default 10, GitHub's per-step cap)").option("--output-file <path>", "Persist JSON output to this path").option("--workspace-root", "Scan from the workspace root instead of the package root").action((entry, opts) => {
|
|
9635
9757
|
runMap({
|
|
9636
9758
|
cwd: opts.cwd ?? process.cwd(),
|
|
9637
9759
|
dryRun: false,
|
|
@@ -9647,6 +9769,8 @@ function createProgram() {
|
|
|
9647
9769
|
baseline: opts.baseline,
|
|
9648
9770
|
write: opts.write !== false,
|
|
9649
9771
|
json: opts.json ?? false,
|
|
9772
|
+
format: opts.format,
|
|
9773
|
+
limit: opts.limit,
|
|
9650
9774
|
outputFile: opts.outputFile
|
|
9651
9775
|
});
|
|
9652
9776
|
});
|
|
@@ -9772,7 +9896,7 @@ async function run() {
|
|
|
9772
9896
|
});
|
|
9773
9897
|
program.exitOverride();
|
|
9774
9898
|
const argvJoined = process.argv.slice(2).join(" ");
|
|
9775
|
-
const isJsonOnly = process.argv.includes("--json") || /^(schema|commands|examples|version|health|capabilities|discover|query|trace|diagnose|topology|correlate|llm|semconv|score|collector)\b/.test(argvJoined);
|
|
9899
|
+
const isJsonOnly = process.argv.includes("--json") || /(^|\s)--format(=|\s+)json\b/.test(argvJoined) || /^(schema|commands|examples|version|health|capabilities|discover|query|trace|diagnose|topology|correlate|llm|semconv|score|collector)\b/.test(argvJoined);
|
|
9776
9900
|
if (isJsonOnly) program.configureOutput({ writeErr: () => {} });
|
|
9777
9901
|
const stack = [...program.commands];
|
|
9778
9902
|
while (stack.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autotel-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "CLI for autotel - setup wizard, diagnostics, and incremental features",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@inquirer/prompts": "^8.7.2",
|
|
22
22
|
"@opentelemetry/otlp-transformer": "^0.222.0",
|
|
23
|
-
"autotel-mcp": "0.
|
|
23
|
+
"autotel-mcp": "0.13.0",
|
|
24
24
|
"chalk": "^6.0.1",
|
|
25
25
|
"commander": "^15.0.0",
|
|
26
26
|
"glob": "^13.0.6",
|