autotel-cli 0.20.0 → 0.21.1

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 +269 -134
  3. package/package.json +7 -7
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
@@ -2,8 +2,7 @@
2
2
  import { Command } from "commander";
3
3
  import * as fs from "node:fs";
4
4
  import { readFileSync } from "node:fs";
5
- import * as path$1 from "node:path";
6
- import path from "node:path";
5
+ import path, * as path$1 from "node:path";
7
6
  import { execFileSync, execSync } from "node:child_process";
8
7
  import { createHash, randomBytes } from "node:crypto";
9
8
  import { glob, globSync } from "glob";
@@ -5659,129 +5658,6 @@ function hasRegressed(comparison) {
5659
5658
  return comparison.regressions.length > 0 || comparison.delta < 0;
5660
5659
  }
5661
5660
  //#endregion
5662
- //#region src/lib/map/project-facts.ts
5663
- const LLM_PACKAGES = [
5664
- "openai",
5665
- "@anthropic-ai/sdk",
5666
- "ai",
5667
- "@ai-sdk/openai",
5668
- "@ai-sdk/anthropic",
5669
- "@ai-sdk/google",
5670
- "ai-sdk-ollama",
5671
- "ollama",
5672
- "@google/generative-ai",
5673
- "@mistralai/mistralai",
5674
- "cohere-ai",
5675
- "langchain",
5676
- "@langchain/core"
5677
- ];
5678
- const RUNTIME_AUTOTEL_PACKAGES = [
5679
- "autotel",
5680
- "autotel-edge",
5681
- "autotel-cloudflare",
5682
- "autotel-nuxt",
5683
- "autotel-tanstack",
5684
- "autotel-hono",
5685
- "autotel-adapters"
5686
- ];
5687
- /**
5688
- * Files where a framework integration is wired up.
5689
- *
5690
- * Kept to a fixed list rather than a glob: this answers "did anyone install the
5691
- * middleware", and the answer is always in the app's entry file or its hooks.
5692
- */
5693
- const WIRING_FILES = [
5694
- "src/index.ts",
5695
- "src/app.ts",
5696
- "src/server.ts",
5697
- "src/main.ts",
5698
- "index.ts",
5699
- "app.ts",
5700
- "server.ts",
5701
- "middleware.ts",
5702
- "src/middleware.ts",
5703
- "src/hooks.server.ts",
5704
- "hooks.server.ts",
5705
- "nuxt.config.ts",
5706
- "src/router.tsx",
5707
- "app/router.tsx"
5708
- ];
5709
- /** Autotel framework integrations that create a request span when called. */
5710
- const AMBIENT_APIS = ["autotelMiddleware", "autotelHandle"];
5711
- function hasAmbientTracing(projectRoot, instrumentationPath) {
5712
- const parse = createParser();
5713
- const candidates = new Set([instrumentationPath, ...WIRING_FILES.map((file) => path$1.join(projectRoot, file))].filter((file) => file !== null && fileExists(file)));
5714
- for (const file of candidates) {
5715
- const source = parse(file);
5716
- if (source && usesAutotelApi$1(buildFileFacts(source), AMBIENT_APIS)) return true;
5717
- }
5718
- return false;
5719
- }
5720
- function projectSource(projectRoot) {
5721
- const files = globSync("{src,app,server,routes,pages}/**/*.{ts,tsx,js,jsx,mts,mjs}", {
5722
- cwd: projectRoot,
5723
- absolute: true,
5724
- ignore: [
5725
- "**/node_modules/**",
5726
- "**/dist/**",
5727
- "**/*.test.*",
5728
- "**/*.spec.*"
5729
- ]
5730
- });
5731
- const occurrences = /* @__PURE__ */ new Map();
5732
- const chunks = [];
5733
- const call = /createStructuredError\s*\(\s*\{([\s\S]*?)\}\s*\)/g;
5734
- for (const file of files) {
5735
- const source = readFileSafe(file) ?? "";
5736
- chunks.push(source);
5737
- for (const match of source.matchAll(call)) {
5738
- const body = match[1] ?? "";
5739
- const message = /\bmessage\s*:\s*(['"`])([\s\S]*?)\1/.exec(body)?.[2];
5740
- const status = /\bstatus\s*:\s*(\d+)/.exec(body)?.[1];
5741
- if (!message || !status) continue;
5742
- const signature = `${status}|${message}`;
5743
- const entry = occurrences.get(signature) ?? {
5744
- label: message,
5745
- files: /* @__PURE__ */ new Set()
5746
- };
5747
- entry.files.add(path$1.relative(projectRoot, file).split(path$1.sep).join("/"));
5748
- occurrences.set(signature, entry);
5749
- }
5750
- }
5751
- return {
5752
- text: chunks.join("\n"),
5753
- repeatedErrors: new Map([...occurrences].filter(([, entry]) => entry.files.size > 1).map(([signature, entry]) => [signature, {
5754
- label: entry.label,
5755
- files: [...entry.files].toSorted()
5756
- }]))
5757
- };
5758
- }
5759
- function collectDeps(pkg) {
5760
- return /* @__PURE__ */ new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {})]);
5761
- }
5762
- function collectProjectFacts(projectRoot) {
5763
- const pkg = readJsonSafe(path$1.join(projectRoot, "package.json"));
5764
- const deps = collectDeps(pkg);
5765
- const instrumentation = findInstrumentationFile(projectRoot);
5766
- const instrumentationText = instrumentation ? readFileSafe(instrumentation.path) ?? "" : "";
5767
- const ambientText = `${instrumentationText}\n${WIRING_FILES.map((candidate) => fileExists(path$1.join(projectRoot, candidate)) ? readFileSafe(path$1.join(projectRoot, candidate)) ?? "" : "").join("\n")}`;
5768
- const source = projectSource(projectRoot);
5769
- const projectSignalText = `${ambientText}\n${source.text}`;
5770
- return {
5771
- name: pkg?.name ?? path$1.basename(projectRoot),
5772
- deps,
5773
- hasAutotel: RUNTIME_AUTOTEL_PACKAGES.some((name) => deps.has(name)),
5774
- ambientTracing: deps.has("autotel-nuxt") || hasAmbientTracing(projectRoot, instrumentation?.path ?? null),
5775
- hasRedaction: instrumentationText.includes("attributeRedactor"),
5776
- instrumentationFile: instrumentation ? path$1.relative(projectRoot, instrumentation.path).split(path$1.sep).join("/") : null,
5777
- hasLlmDependency: LLM_PACKAGES.some((name) => deps.has(name)),
5778
- hasZod: deps.has("zod"),
5779
- hasAuditUsage: deps.has("autotel-audit") && /securityEvent|withSecurity|withAudit|defineAuditCatalog/.test(projectSignalText),
5780
- hasErrorCatalog: projectSignalText.includes("defineErrorCatalog"),
5781
- repeatedErrors: source.repeatedErrors
5782
- };
5783
- }
5784
- //#endregion
5785
5661
  //#region src/lib/map/sensitivity.ts
5786
5662
  const MONEY_PACKAGES = [
5787
5663
  "stripe",
@@ -5920,6 +5796,10 @@ function lostPoints(route) {
5920
5796
  const weight = route.sensitivity.level === "high" ? 2 : 1;
5921
5797
  return (100 - route.score) * weight;
5922
5798
  }
5799
+ /** Entry points with gaps, most points lost first: the "Fix these first" order. */
5800
+ function fixFirst(routes) {
5801
+ return routes.filter((route) => classifyRouteObservability(route) !== "exempt" && route.score < 100).toSorted((a, b) => lostPoints(b) - lostPoints(a));
5802
+ }
5923
5803
  /**
5924
5804
  * The default view: the number, the tally, and the three entry points worth
5925
5805
  * fixing first.
@@ -5936,7 +5816,7 @@ function formatReport(result, options = {}) {
5936
5816
  lines.push("");
5937
5817
  lines.push(` ${colour(chalk.bold(`${map.score}/100`))} ${colour(grade)}`);
5938
5818
  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` : "")));
5939
- const worst = map.routes.filter((route) => classifyRouteObservability(route) !== "exempt" && route.score < 100).toSorted((a, b) => lostPoints(b) - lostPoints(a)).slice(0, 3);
5819
+ const worst = fixFirst(map.routes).slice(0, 3);
5940
5820
  if (worst.length > 0) {
5941
5821
  lines.push("");
5942
5822
  lines.push(chalk.bold("Fix these first"));
@@ -6045,6 +5925,207 @@ function formatGate(score, threshold) {
6045
5925
  return score < threshold ? chalk.red(`\nScore ${score} is below --min-score ${threshold}.`) : chalk.green(`\nScore ${score} meets --min-score ${threshold}.`);
6046
5926
  }
6047
5927
  //#endregion
5928
+ //#region src/lib/map/github.ts
5929
+ function rebase(file, location) {
5930
+ if (!location.workspace) return file;
5931
+ return path$1.relative(location.workspace, path$1.resolve(location.projectRoot, file)).split(path$1.sep).join("/");
5932
+ }
5933
+ /** The failing requirements of one entry point, at the evidence line when a check has one. */
5934
+ function failures(route) {
5935
+ const out = [];
5936
+ for (const [check, result] of Object.entries(route.checks)) {
5937
+ if (result.status !== "fail") continue;
5938
+ out.push({
5939
+ file: result.evidence?.file ?? route.file,
5940
+ line: result.evidence?.line ?? route.handler?.line ?? 1,
5941
+ check,
5942
+ message: result.message ?? getRule(check)?.question ?? check
5943
+ });
5944
+ }
5945
+ return out;
5946
+ }
5947
+ /**
5948
+ * Checks that passed in the baseline and no longer do. These are the pull
5949
+ * request's; everything else on the map predates it and is already in the score.
5950
+ */
5951
+ function regressions(scan, baseline) {
5952
+ const routes = new Map(scan.map.routes.map((route) => [route.id, route]));
5953
+ return baseline.regressions.flatMap((regression) => {
5954
+ const route = routes.get(regression.routeId);
5955
+ if (!route) return [];
5956
+ const failed = failures(route).find((finding) => finding.check === regression.check);
5957
+ if (failed) return [failed];
5958
+ return [{
5959
+ file: route.file,
5960
+ line: route.handler?.line ?? 1,
5961
+ check: regression.check,
5962
+ message: `${regression.check} passed in ${baseline.source.label} and is now suppressed`
5963
+ }];
5964
+ });
5965
+ }
5966
+ function escapeMessage(text) {
5967
+ return text.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
5968
+ }
5969
+ function escapeProperty(text) {
5970
+ return escapeMessage(text).replaceAll(":", "%3A").replaceAll(",", "%2C");
5971
+ }
5972
+ function annotation(level, properties, message) {
5973
+ const props = Object.entries(properties).map(([key, value]) => `${key}=${escapeProperty(value)}`).join(",");
5974
+ return `::${level}${props ? ` ${props}` : ""}::${escapeMessage(message)}`;
5975
+ }
5976
+ /**
5977
+ * GitHub Actions workflow commands, one line per finding, for stdout.
5978
+ *
5979
+ * With a baseline, the findings are the regressions and they are errors: the
5980
+ * pull request caused them. Without one, they are the "Fix these first" list,
5981
+ * worst entry point first, as warnings. Either list stops at `limit`. The
5982
+ * first line carries the score, as an error when the gate failed, so the run
5983
+ * is readable without the log. It leads because GitHub keeps only the first
5984
+ * ten errors of a step.
5985
+ */
5986
+ function formatGithubAnnotations(scan, baseline, location, options) {
5987
+ const findings = baseline ? regressions(scan, baseline) : fixFirst(scan.map.routes).flatMap(failures);
5988
+ const level = baseline ? "error" : "warning";
5989
+ const lines = findings.slice(0, options.limit).map((finding) => annotation(level, {
5990
+ file: rebase(finding.file, location),
5991
+ line: String(finding.line),
5992
+ title: `autotel map: ${finding.check}`
5993
+ }, finding.message));
5994
+ const { score } = scan.map;
5995
+ const { dark, instrumented, partial } = scan.summary;
5996
+ const hidden = findings.length - lines.length;
5997
+ const summary = `score ${score}/100 (${scan.grade}): ${instrumented} instrumented, ${partial} partial, ${dark} dark` + (hidden > 0 ? `; ${hidden} more finding${hidden === 1 ? "" : "s"} not shown` : "");
5998
+ const title = { title: "autotel map" };
5999
+ let headline;
6000
+ if (options.minScore !== void 0 && score < options.minScore) headline = annotation("error", title, `${summary}; below --min-score ${options.minScore}`);
6001
+ else if (baseline && hasRegressed(baseline)) headline = annotation("error", title, `${summary}; regressed against ${baseline.source.label}`);
6002
+ else headline = annotation("notice", title, summary);
6003
+ return [headline, ...lines].join("\n");
6004
+ }
6005
+ //#endregion
6006
+ //#region src/lib/map/project-facts.ts
6007
+ const LLM_PACKAGES = [
6008
+ "openai",
6009
+ "@anthropic-ai/sdk",
6010
+ "ai",
6011
+ "@ai-sdk/openai",
6012
+ "@ai-sdk/anthropic",
6013
+ "@ai-sdk/google",
6014
+ "ai-sdk-ollama",
6015
+ "ollama",
6016
+ "@google/generative-ai",
6017
+ "@mistralai/mistralai",
6018
+ "cohere-ai",
6019
+ "langchain",
6020
+ "@langchain/core"
6021
+ ];
6022
+ const RUNTIME_AUTOTEL_PACKAGES = [
6023
+ "autotel",
6024
+ "autotel-edge",
6025
+ "autotel-cloudflare",
6026
+ "autotel-nuxt",
6027
+ "autotel-tanstack",
6028
+ "autotel-hono",
6029
+ "autotel-adapters"
6030
+ ];
6031
+ /**
6032
+ * Files where a framework integration is wired up.
6033
+ *
6034
+ * Kept to a fixed list rather than a glob: this answers "did anyone install the
6035
+ * middleware", and the answer is always in the app's entry file or its hooks.
6036
+ */
6037
+ const WIRING_FILES = [
6038
+ "src/index.ts",
6039
+ "src/app.ts",
6040
+ "src/server.ts",
6041
+ "src/main.ts",
6042
+ "index.ts",
6043
+ "app.ts",
6044
+ "server.ts",
6045
+ "middleware.ts",
6046
+ "src/middleware.ts",
6047
+ "src/hooks.server.ts",
6048
+ "hooks.server.ts",
6049
+ "nuxt.config.ts",
6050
+ "src/router.tsx",
6051
+ "app/router.tsx"
6052
+ ];
6053
+ /** Autotel framework integrations that create a request span when called. */
6054
+ const AMBIENT_APIS = ["autotelMiddleware", "autotelHandle"];
6055
+ function hasAmbientTracing(projectRoot, instrumentationPath) {
6056
+ const parse = createParser();
6057
+ const candidates = new Set([instrumentationPath, ...WIRING_FILES.map((file) => path$1.join(projectRoot, file))].filter((file) => file !== null && fileExists(file)));
6058
+ for (const file of candidates) {
6059
+ const source = parse(file);
6060
+ if (source && usesAutotelApi$1(buildFileFacts(source), AMBIENT_APIS)) return true;
6061
+ }
6062
+ return false;
6063
+ }
6064
+ function projectSource(projectRoot) {
6065
+ const files = globSync("{src,app,server,routes,pages}/**/*.{ts,tsx,js,jsx,mts,mjs}", {
6066
+ cwd: projectRoot,
6067
+ absolute: true,
6068
+ ignore: [
6069
+ "**/node_modules/**",
6070
+ "**/dist/**",
6071
+ "**/*.test.*",
6072
+ "**/*.spec.*"
6073
+ ]
6074
+ });
6075
+ const occurrences = /* @__PURE__ */ new Map();
6076
+ const chunks = [];
6077
+ const call = /createStructuredError\s*\(\s*\{([\s\S]*?)\}\s*\)/g;
6078
+ for (const file of files) {
6079
+ const source = readFileSafe(file) ?? "";
6080
+ chunks.push(source);
6081
+ for (const match of source.matchAll(call)) {
6082
+ const body = match[1] ?? "";
6083
+ const message = /\bmessage\s*:\s*(['"`])([\s\S]*?)\1/.exec(body)?.[2];
6084
+ const status = /\bstatus\s*:\s*(\d+)/.exec(body)?.[1];
6085
+ if (!message || !status) continue;
6086
+ const signature = `${status}|${message}`;
6087
+ const entry = occurrences.get(signature) ?? {
6088
+ label: message,
6089
+ files: /* @__PURE__ */ new Set()
6090
+ };
6091
+ entry.files.add(path$1.relative(projectRoot, file).split(path$1.sep).join("/"));
6092
+ occurrences.set(signature, entry);
6093
+ }
6094
+ }
6095
+ return {
6096
+ text: chunks.join("\n"),
6097
+ repeatedErrors: new Map([...occurrences].filter(([, entry]) => entry.files.size > 1).map(([signature, entry]) => [signature, {
6098
+ label: entry.label,
6099
+ files: [...entry.files].toSorted()
6100
+ }]))
6101
+ };
6102
+ }
6103
+ function collectDeps(pkg) {
6104
+ return /* @__PURE__ */ new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {})]);
6105
+ }
6106
+ function collectProjectFacts(projectRoot) {
6107
+ const pkg = readJsonSafe(path$1.join(projectRoot, "package.json"));
6108
+ const deps = collectDeps(pkg);
6109
+ const instrumentation = findInstrumentationFile(projectRoot);
6110
+ const instrumentationText = instrumentation ? readFileSafe(instrumentation.path) ?? "" : "";
6111
+ const ambientText = `${instrumentationText}\n${WIRING_FILES.map((candidate) => fileExists(path$1.join(projectRoot, candidate)) ? readFileSafe(path$1.join(projectRoot, candidate)) ?? "" : "").join("\n")}`;
6112
+ const source = projectSource(projectRoot);
6113
+ const projectSignalText = `${ambientText}\n${source.text}`;
6114
+ return {
6115
+ name: pkg?.name ?? path$1.basename(projectRoot),
6116
+ deps,
6117
+ hasAutotel: RUNTIME_AUTOTEL_PACKAGES.some((name) => deps.has(name)),
6118
+ ambientTracing: deps.has("autotel-nuxt") || hasAmbientTracing(projectRoot, instrumentation?.path ?? null),
6119
+ hasRedaction: instrumentationText.includes("attributeRedactor"),
6120
+ instrumentationFile: instrumentation ? path$1.relative(projectRoot, instrumentation.path).split(path$1.sep).join("/") : null,
6121
+ hasLlmDependency: LLM_PACKAGES.some((name) => deps.has(name)),
6122
+ hasZod: deps.has("zod"),
6123
+ hasAuditUsage: deps.has("autotel-audit") && /securityEvent|withSecurity|withAudit|defineAuditCatalog/.test(projectSignalText),
6124
+ hasErrorCatalog: projectSignalText.includes("defineErrorCatalog"),
6125
+ repeatedErrors: source.repeatedErrors
6126
+ };
6127
+ }
6128
+ //#endregion
6048
6129
  //#region src/lib/map/scan.ts
6049
6130
  /** Stable identity for an entry point, so a baseline can match it across runs. */
6050
6131
  function routeId(route) {
@@ -6170,6 +6251,46 @@ function parseMinScore(value) {
6170
6251
  });
6171
6252
  return threshold;
6172
6253
  }
6254
+ const FORMATS = [
6255
+ "human",
6256
+ "json",
6257
+ "github"
6258
+ ];
6259
+ /**
6260
+ * Read `--format`, with `--json` as the long-standing spelling of `--format
6261
+ * json`. Two formats cannot share stdout, so asking for both is refused rather
6262
+ * than letting one silently win.
6263
+ */
6264
+ function parseFormat(value, json) {
6265
+ if (value === void 0 || value === "") return json ? "json" : "human";
6266
+ const format = FORMATS.find((name) => name === value);
6267
+ if (!format) throw new AutotelError({
6268
+ type: "validation",
6269
+ code: AutotelErrorCodes.E_INVALID_FLAG,
6270
+ message: `Unknown --format "${value}"`,
6271
+ fix: `Pass one of: ${FORMATS.join(", ")}`,
6272
+ expected: { formats: [...FORMATS] }
6273
+ });
6274
+ if (json && format !== "json") throw new AutotelError({
6275
+ type: "validation",
6276
+ code: AutotelErrorCodes.E_INVALID_FLAG,
6277
+ message: `--json and --format ${format} both claim stdout`,
6278
+ fix: "Drop --json, or pass --format json"
6279
+ });
6280
+ return format;
6281
+ }
6282
+ /** Read `--limit`; the whole string has to parse, for the same reason as `--min-score`. */
6283
+ function parseLimit(value) {
6284
+ if (value === void 0 || value === "") return 10;
6285
+ const limit = Number(value);
6286
+ if (!Number.isInteger(limit) || limit < 1) throw new AutotelError({
6287
+ type: "validation",
6288
+ code: AutotelErrorCodes.E_INVALID_FLAG,
6289
+ message: `--limit expects a whole number of 1 or more, got "${value}"`,
6290
+ fix: "Pass e.g. --limit 10"
6291
+ });
6292
+ return limit;
6293
+ }
6173
6294
  function resolveFramework(requested, projectRoot, deps) {
6174
6295
  if (requested !== void 0 && requested !== "") {
6175
6296
  if (!isFramework(requested)) throw new AutotelError({
@@ -6256,9 +6377,25 @@ function runMap(options) {
6256
6377
  ...options.outputFile !== void 0 ? { outputFile: options.outputFile } : {},
6257
6378
  outputRoot: options.cwd
6258
6379
  });
6380
+ const format = parseFormat(options.format, options.json);
6381
+ const limit = parseLimit(options.limit);
6259
6382
  const minScore = parseMinScore(options.minScore);
6260
6383
  const result = runMapScan(options);
6261
- if (options.json) printJson({
6384
+ const human = () => formatMapResult(result, {
6385
+ all: options.all,
6386
+ ...options.entry !== void 0 ? { entry: options.entry } : {},
6387
+ ...minScore !== void 0 ? { minScore } : {}
6388
+ });
6389
+ if (format === "github") {
6390
+ process.stdout.write(`${formatGithubAnnotations(result.scan, result.baseline, {
6391
+ projectRoot: result.projectRoot,
6392
+ ...process.env.GITHUB_WORKSPACE ? { workspace: process.env.GITHUB_WORKSPACE } : {}
6393
+ }, {
6394
+ limit,
6395
+ ...minScore !== void 0 ? { minScore } : {}
6396
+ })}\n`);
6397
+ process.stderr.write(`${human()}\n`);
6398
+ } else if (format === "json") printJson({
6262
6399
  ok: true,
6263
6400
  command: "map",
6264
6401
  mapFile: MAP_FILE_NAME,
@@ -6271,11 +6408,7 @@ function runMap(options) {
6271
6408
  ...result.baseline ? { baseline: result.baseline } : {},
6272
6409
  map: result.scan.map
6273
6410
  });
6274
- else process.stdout.write(`${formatMapResult(result, {
6275
- all: options.all,
6276
- ...options.entry !== void 0 ? { entry: options.entry } : {},
6277
- ...minScore !== void 0 ? { minScore } : {}
6278
- })}\n`);
6411
+ else process.stdout.write(`${human()}\n`);
6279
6412
  if (minScore !== void 0 && result.scan.map.score < minScore) {
6280
6413
  process.exitCode = 1;
6281
6414
  return;
@@ -9619,7 +9752,7 @@ function createProgram() {
9619
9752
  });
9620
9753
  addGlobalOptions(addCmd);
9621
9754
  program.addCommand(addCmd);
9622
- 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) => {
9755
+ 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) => {
9623
9756
  runMap({
9624
9757
  cwd: opts.cwd ?? process.cwd(),
9625
9758
  dryRun: false,
@@ -9635,6 +9768,8 @@ function createProgram() {
9635
9768
  baseline: opts.baseline,
9636
9769
  write: opts.write !== false,
9637
9770
  json: opts.json ?? false,
9771
+ format: opts.format,
9772
+ limit: opts.limit,
9638
9773
  outputFile: opts.outputFile
9639
9774
  });
9640
9775
  });
@@ -9760,7 +9895,7 @@ async function run() {
9760
9895
  });
9761
9896
  program.exitOverride();
9762
9897
  const argvJoined = process.argv.slice(2).join(" ");
9763
- 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);
9898
+ 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);
9764
9899
  if (isJsonOnly) program.configureOutput({ writeErr: () => {} });
9765
9900
  const stack = [...program.commands];
9766
9901
  while (stack.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autotel-cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "CLI for autotel - setup wizard, diagnostics, and incremental features",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,9 +18,9 @@
18
18
  "NOTICE"
19
19
  ],
20
20
  "dependencies": {
21
- "@inquirer/prompts": "^8.7.2",
22
- "@opentelemetry/otlp-transformer": "^0.222.0",
23
- "autotel-mcp": "0.12.0",
21
+ "@inquirer/prompts": "^8.7.3",
22
+ "@opentelemetry/otlp-transformer": "^0.223.0",
23
+ "autotel-mcp": "0.13.1",
24
24
  "chalk": "^6.0.1",
25
25
  "commander": "^15.0.0",
26
26
  "glob": "^13.0.6",
@@ -30,13 +30,13 @@
30
30
  "yaml": "^2.9.1"
31
31
  },
32
32
  "devDependencies": {
33
- "@types/node": "^26.6.3",
33
+ "@types/node": "^26.6.5",
34
34
  "@types/semver": "^7.8.0",
35
35
  "autotel-telemetry": "0.2.0",
36
- "tsdown": "^0.23.0",
36
+ "tsdown": "^0.23.1",
37
37
  "tsx": "^4.23.15",
38
38
  "typescript": "^7.0.2",
39
- "vitest": "^5.0.2"
39
+ "vitest": "^5.0.4"
40
40
  },
41
41
  "engines": {
42
42
  "node": ">=22.0.0"