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.
Files changed (3) hide show
  1. package/README.md +14 -1
  2. package/dist/index.js +292 -168
  3. 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
- * Wire Pino as the first-class autotel logger. Adds the import, the
384
- * `const logger = pino({...})` construction snippet, and sets the
385
- * `logger:` field on init().
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 setPinoLogger(file) {
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 === "pino") prod.add("pino");
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("(first-class)")}` + (det.autoInstrumentLoggers.length > 0 ? `, ${chalk.dim("+ auto-instrumented:")} ${det.autoInstrumentLoggers.join(", ")}` : ""));
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
- if (plan.detected?.primaryLogger === "pino") setPinoLogger(codeFile);
2977
- for (const l of plan.detected?.autoInstrumentLoggers ?? []) addAutoInstrumentationLogger(codeFile, l);
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.filter((route) => classifyRouteObservability(route) !== "exempt" && route.score < 100).toSorted((a, b) => lostPoints(b) - lostPoints(a)).slice(0, 3);
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
- if (options.json) printJson({
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(`${formatMapResult(result, {
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.19.1",
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.12.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",