autotel-cli 0.20.0 → 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 +268 -132
- 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
|
@@ -5659,129 +5659,6 @@ function hasRegressed(comparison) {
|
|
|
5659
5659
|
return comparison.regressions.length > 0 || comparison.delta < 0;
|
|
5660
5660
|
}
|
|
5661
5661
|
//#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
5662
|
//#region src/lib/map/sensitivity.ts
|
|
5786
5663
|
const MONEY_PACKAGES = [
|
|
5787
5664
|
"stripe",
|
|
@@ -5920,6 +5797,10 @@ function lostPoints(route) {
|
|
|
5920
5797
|
const weight = route.sensitivity.level === "high" ? 2 : 1;
|
|
5921
5798
|
return (100 - route.score) * weight;
|
|
5922
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
|
+
}
|
|
5923
5804
|
/**
|
|
5924
5805
|
* The default view: the number, the tally, and the three entry points worth
|
|
5925
5806
|
* fixing first.
|
|
@@ -5936,7 +5817,7 @@ function formatReport(result, options = {}) {
|
|
|
5936
5817
|
lines.push("");
|
|
5937
5818
|
lines.push(` ${colour(chalk.bold(`${map.score}/100`))} ${colour(grade)}`);
|
|
5938
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` : "")));
|
|
5939
|
-
const worst = map.routes
|
|
5820
|
+
const worst = fixFirst(map.routes).slice(0, 3);
|
|
5940
5821
|
if (worst.length > 0) {
|
|
5941
5822
|
lines.push("");
|
|
5942
5823
|
lines.push(chalk.bold("Fix these first"));
|
|
@@ -6045,6 +5926,207 @@ function formatGate(score, threshold) {
|
|
|
6045
5926
|
return score < threshold ? chalk.red(`\nScore ${score} is below --min-score ${threshold}.`) : chalk.green(`\nScore ${score} meets --min-score ${threshold}.`);
|
|
6046
5927
|
}
|
|
6047
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
|
|
6048
6130
|
//#region src/lib/map/scan.ts
|
|
6049
6131
|
/** Stable identity for an entry point, so a baseline can match it across runs. */
|
|
6050
6132
|
function routeId(route) {
|
|
@@ -6170,6 +6252,46 @@ function parseMinScore(value) {
|
|
|
6170
6252
|
});
|
|
6171
6253
|
return threshold;
|
|
6172
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
|
+
}
|
|
6173
6295
|
function resolveFramework(requested, projectRoot, deps) {
|
|
6174
6296
|
if (requested !== void 0 && requested !== "") {
|
|
6175
6297
|
if (!isFramework(requested)) throw new AutotelError({
|
|
@@ -6256,9 +6378,25 @@ function runMap(options) {
|
|
|
6256
6378
|
...options.outputFile !== void 0 ? { outputFile: options.outputFile } : {},
|
|
6257
6379
|
outputRoot: options.cwd
|
|
6258
6380
|
});
|
|
6381
|
+
const format = parseFormat(options.format, options.json);
|
|
6382
|
+
const limit = parseLimit(options.limit);
|
|
6259
6383
|
const minScore = parseMinScore(options.minScore);
|
|
6260
6384
|
const result = runMapScan(options);
|
|
6261
|
-
|
|
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({
|
|
6262
6400
|
ok: true,
|
|
6263
6401
|
command: "map",
|
|
6264
6402
|
mapFile: MAP_FILE_NAME,
|
|
@@ -6271,11 +6409,7 @@ function runMap(options) {
|
|
|
6271
6409
|
...result.baseline ? { baseline: result.baseline } : {},
|
|
6272
6410
|
map: result.scan.map
|
|
6273
6411
|
});
|
|
6274
|
-
else process.stdout.write(`${
|
|
6275
|
-
all: options.all,
|
|
6276
|
-
...options.entry !== void 0 ? { entry: options.entry } : {},
|
|
6277
|
-
...minScore !== void 0 ? { minScore } : {}
|
|
6278
|
-
})}\n`);
|
|
6412
|
+
else process.stdout.write(`${human()}\n`);
|
|
6279
6413
|
if (minScore !== void 0 && result.scan.map.score < minScore) {
|
|
6280
6414
|
process.exitCode = 1;
|
|
6281
6415
|
return;
|
|
@@ -9619,7 +9753,7 @@ function createProgram() {
|
|
|
9619
9753
|
});
|
|
9620
9754
|
addGlobalOptions(addCmd);
|
|
9621
9755
|
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) => {
|
|
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) => {
|
|
9623
9757
|
runMap({
|
|
9624
9758
|
cwd: opts.cwd ?? process.cwd(),
|
|
9625
9759
|
dryRun: false,
|
|
@@ -9635,6 +9769,8 @@ function createProgram() {
|
|
|
9635
9769
|
baseline: opts.baseline,
|
|
9636
9770
|
write: opts.write !== false,
|
|
9637
9771
|
json: opts.json ?? false,
|
|
9772
|
+
format: opts.format,
|
|
9773
|
+
limit: opts.limit,
|
|
9638
9774
|
outputFile: opts.outputFile
|
|
9639
9775
|
});
|
|
9640
9776
|
});
|
|
@@ -9760,7 +9896,7 @@ async function run() {
|
|
|
9760
9896
|
});
|
|
9761
9897
|
program.exitOverride();
|
|
9762
9898
|
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);
|
|
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);
|
|
9764
9900
|
if (isJsonOnly) program.configureOutput({ writeErr: () => {} });
|
|
9765
9901
|
const stack = [...program.commands];
|
|
9766
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",
|