autotel-cli 0.13.1 → 0.15.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/dist/index.js CHANGED
@@ -4,15 +4,16 @@ import * as fs from "node:fs";
4
4
  import { readFileSync } from "node:fs";
5
5
  import * as path$1 from "node:path";
6
6
  import path from "node:path";
7
- import { execSync } from "node:child_process";
7
+ import { execFileSync, execSync } from "node:child_process";
8
8
  import { createHash, randomBytes } from "node:crypto";
9
- import { glob } from "glob";
9
+ import { glob, globSync } from "glob";
10
10
  import chalk from "chalk";
11
11
  import { checkbox, confirm, select } from "@inquirer/prompts";
12
12
  import ora from "ora";
13
13
  import * as semver from "semver";
14
14
  import { Project, SyntaxKind } from "ts-morph";
15
15
  import { fileURLToPath } from "node:url";
16
+ import { JsonTraceSerializer, ProtobufTraceSerializer } from "@opentelemetry/otlp-transformer";
16
17
  import { buildCollectorGuide, buildInstrumentationGuide, clearSemanticConventionCache, collectUsage, detectAnomalies, discoverLogFields, discoverServices, discoverTraceFields, findRootCause, getCollectorComponentReadme, getCollectorComponentSchema, getModelStats, getSemanticConventionNamespace, listCollectorComponents, listCollectorVersions, listModels, listSemanticConventionNamespaces, listToolUsage, pickErrorMessage, rankExpensiveTraces, rankSlowTraces, refreshCollectorCatalog, resolveCollectorVersion, scoreSpan, suggestCollectorConfig, suggestInstrumentationFixes, toLogSearchQuery, toMetricSearchQuery, toSpanSearchQuery, toTraceSearchQuery, validateCollectorComponentConfig, validateOtlpReceiverConfig } from "autotel-mcp";
17
18
  import { AsyncLocalStorage } from "node:async_hooks";
18
19
  import { appendFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
@@ -2250,6 +2251,7 @@ const AutotelErrorCodes = {
2250
2251
  E_INSTALL_FAILED: "AUTOTEL_E_INSTALL_FAILED",
2251
2252
  E_WRITE_FAILED: "AUTOTEL_E_WRITE_FAILED",
2252
2253
  E_READ_FAILED: "AUTOTEL_E_READ_FAILED",
2254
+ E_RATE_LIMITED: "AUTOTEL_E_RATE_LIMITED",
2253
2255
  E_UNKNOWN: "AUTOTEL_E_UNKNOWN"
2254
2256
  };
2255
2257
  /**
@@ -3851,6 +3853,2256 @@ async function runAdd(type, name, options) {
3851
3853
  }
3852
3854
  }
3853
3855
  //#endregion
3856
+ //#region src/lib/map/facts.ts
3857
+ function isAutotelModule(specifier) {
3858
+ return specifier === "autotel" || specifier.startsWith("autotel/") || specifier.startsWith("autotel-");
3859
+ }
3860
+ /**
3861
+ * Whether a canonical Autotel API is actually called in these facts.
3862
+ *
3863
+ * Import provenance matters: a local helper with the same name must not count.
3864
+ * Namespace and aliased imports are resolved here once for every scanner rule
3865
+ * and for project-level integration detection.
3866
+ */
3867
+ function usesAutotelApi$1(facts, names, autoImports = []) {
3868
+ return names.some((name) => {
3869
+ if (autoImports.includes(name) && facts.callsTo(name).length > 0) return true;
3870
+ for (const [local, module] of facts.imports) {
3871
+ if (!isAutotelModule(module)) continue;
3872
+ const imported = facts.importedNames.get(local);
3873
+ if (imported === name && facts.callsTo(local).length > 0) return true;
3874
+ if (imported === "*" && facts.memberCalls(name).some((call) => call.root === local)) return true;
3875
+ }
3876
+ return false;
3877
+ });
3878
+ }
3879
+ /** Names that mean "this error was recorded", not swallowed. */
3880
+ const ERROR_SINKS = /* @__PURE__ */ new Set([
3881
+ "error",
3882
+ "recordStructuredError",
3883
+ "recordException",
3884
+ "captureException",
3885
+ "recordError",
3886
+ "fail"
3887
+ ]);
3888
+ /** Autotel's structured-throw constructors. */
3889
+ const STRUCTURED_ERROR_NAMES = /* @__PURE__ */ new Set([
3890
+ "createStructuredError",
3891
+ "createError",
3892
+ "AutotelError"
3893
+ ]);
3894
+ function createParser() {
3895
+ const project = new Project({
3896
+ skipAddingFilesFromTsConfig: true,
3897
+ skipFileDependencyResolution: true,
3898
+ compilerOptions: {
3899
+ allowJs: true,
3900
+ checkJs: false
3901
+ }
3902
+ });
3903
+ const cache = /* @__PURE__ */ new Map();
3904
+ return (absolutePath) => {
3905
+ const cached = cache.get(absolutePath);
3906
+ if (cached !== void 0) return cached;
3907
+ let file;
3908
+ try {
3909
+ if (absolutePath.endsWith(".vue")) {
3910
+ const text = fs.readFileSync(absolutePath, "utf8");
3911
+ const scripts = [...text.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)];
3912
+ if (scripts.length === 0) file = project.createSourceFile(`${absolutePath}.ts`, "");
3913
+ else {
3914
+ const chars = [...text].map((char) => char === "\n" ? "\n" : " ");
3915
+ for (const match of scripts) {
3916
+ const body = match[1] ?? "";
3917
+ const bodyOffset = (match.index ?? 0) + match[0].indexOf(body);
3918
+ for (let index = 0; index < body.length; index++) chars[bodyOffset + index] = body[index] ?? " ";
3919
+ }
3920
+ file = project.createSourceFile(`${absolutePath}.ts`, chars.join(""));
3921
+ }
3922
+ } else file = project.addSourceFileAtPath(absolutePath);
3923
+ } catch {
3924
+ file = null;
3925
+ }
3926
+ cache.set(absolutePath, file);
3927
+ return file;
3928
+ };
3929
+ }
3930
+ /** Rightmost name and leftmost identifier of a callee expression. */
3931
+ function calleeParts(expr) {
3932
+ const access = expr.asKind(SyntaxKind.PropertyAccessExpression);
3933
+ if (access) {
3934
+ let root = access.getExpression();
3935
+ for (;;) {
3936
+ const inner = root.asKind(SyntaxKind.PropertyAccessExpression);
3937
+ if (!inner) break;
3938
+ root = inner.getExpression();
3939
+ }
3940
+ return {
3941
+ member: access.getName(),
3942
+ root: root.isKind(SyntaxKind.Identifier) ? root.getText() : null
3943
+ };
3944
+ }
3945
+ const text = expr.getText();
3946
+ return {
3947
+ member: text,
3948
+ root: expr.isKind(SyntaxKind.Identifier) ? text : null
3949
+ };
3950
+ }
3951
+ function isEmptyBlock(block) {
3952
+ if (!block) return true;
3953
+ const asBlock = block.asKind(SyntaxKind.Block);
3954
+ return asBlock ? asBlock.getStatements().length === 0 : false;
3955
+ }
3956
+ /** Whether a catch block records the error rather than dropping it. */
3957
+ function catchHandles(block) {
3958
+ let handled = false;
3959
+ block.forEachDescendant((node, traversal) => {
3960
+ if (node.isKind(SyntaxKind.ThrowStatement)) {
3961
+ handled = true;
3962
+ traversal.stop();
3963
+ return;
3964
+ }
3965
+ if (!node.isKind(SyntaxKind.CallExpression)) return;
3966
+ const { member } = calleeParts(node.getExpression());
3967
+ if (ERROR_SINKS.has(member)) {
3968
+ handled = true;
3969
+ traversal.stop();
3970
+ }
3971
+ });
3972
+ return handled;
3973
+ }
3974
+ function classifyThrow(expression) {
3975
+ if (!expression) return "other";
3976
+ const newExpr = expression.asKind(SyntaxKind.NewExpression);
3977
+ if (newExpr) {
3978
+ const name = newExpr.getExpression().getText();
3979
+ if (STRUCTURED_ERROR_NAMES.has(name)) return "structured";
3980
+ return name === "Error" || name.endsWith("Error") ? "plain-error" : "other";
3981
+ }
3982
+ const call = expression.asKind(SyntaxKind.CallExpression);
3983
+ if (call) {
3984
+ const { member } = calleeParts(call.getExpression());
3985
+ if (STRUCTURED_ERROR_NAMES.has(member)) return "structured";
3986
+ return "other";
3987
+ }
3988
+ return "other";
3989
+ }
3990
+ function classifyImportedThrow(expression, imports, importedNames) {
3991
+ const direct = classifyThrow(expression);
3992
+ if (direct !== "other") return direct;
3993
+ const call = expression?.asKind(SyntaxKind.CallExpression) ?? expression?.asKind(SyntaxKind.NewExpression);
3994
+ if (!call) return direct;
3995
+ const { member, root } = calleeParts(call.getExpression());
3996
+ const localModule = imports.get(member);
3997
+ if (STRUCTURED_ERROR_NAMES.has(importedNames.get(member) ?? "") && localModule !== void 0) return "structured";
3998
+ if (root && importedNames.get(root) === "*" && imports.has(root) && STRUCTURED_ERROR_NAMES.has(member)) return "structured";
3999
+ return direct;
4000
+ }
4001
+ function objectProperties(node) {
4002
+ const object = node?.asKind(SyntaxKind.ObjectLiteralExpression);
4003
+ if (!object) return /* @__PURE__ */ new Set();
4004
+ return new Set(object.getProperties().flatMap((property) => {
4005
+ if (property.isKind(SyntaxKind.PropertyAssignment) || property.isKind(SyntaxKind.ShorthandPropertyAssignment) || property.isKind(SyntaxKind.MethodDeclaration) || property.isKind(SyntaxKind.GetAccessor) || property.isKind(SyntaxKind.SetAccessor)) return [property.getName().replaceAll(/^['"]|['"]$/g, "")];
4006
+ return [];
4007
+ }));
4008
+ }
4009
+ function structuredProps(expression) {
4010
+ const call = expression?.asKind(SyntaxKind.CallExpression);
4011
+ if (call) return objectProperties(call.getArguments()[0]);
4012
+ const created = expression?.asKind(SyntaxKind.NewExpression);
4013
+ return created ? objectProperties(created.getArguments()[0]) : /* @__PURE__ */ new Set();
4014
+ }
4015
+ function structuredIdentity(expression) {
4016
+ const call = expression?.asKind(SyntaxKind.CallExpression);
4017
+ const created = expression?.asKind(SyntaxKind.NewExpression);
4018
+ const object = (call?.getArguments()[0] ?? created?.getArguments()[0])?.asKind(SyntaxKind.ObjectLiteralExpression);
4019
+ if (!object) return null;
4020
+ const message = object.getProperty("message");
4021
+ const status = object.getProperty("status");
4022
+ if (!message?.isKind(SyntaxKind.PropertyAssignment) || !status?.isKind(SyntaxKind.PropertyAssignment)) return null;
4023
+ const messageText = message.getInitializer()?.getText();
4024
+ const statusText = status.getInitializer()?.getText();
4025
+ const literal = messageText && /^(['"`])([\s\S]*)\1$/.exec(messageText);
4026
+ if (!literal?.[2] || !statusText || !/^\d+$/.test(statusText)) return null;
4027
+ return {
4028
+ signature: `${statusText}|${literal[2]}`,
4029
+ label: literal[2]
4030
+ };
4031
+ }
4032
+ const NETWORK_CALLS = /* @__PURE__ */ new Set([
4033
+ "fetch",
4034
+ "$fetch",
4035
+ "useFetch",
4036
+ "useAsyncData",
4037
+ "query"
4038
+ ]);
4039
+ function networkCallHandled(call) {
4040
+ for (const ancestor of call.getAncestors()) {
4041
+ if (ancestor.isKind(SyntaxKind.TryStatement)) {
4042
+ const tryBlock = ancestor.getTryBlock();
4043
+ if (call.getStart() >= tryBlock.getStart() && call.getEnd() <= tryBlock.getEnd() && ancestor.getCatchClause()) return true;
4044
+ }
4045
+ if (ancestor.isKind(SyntaxKind.CallExpression)) {
4046
+ const expression = ancestor.getExpression();
4047
+ if (expression.isKind(SyntaxKind.PropertyAccessExpression) && expression.getName() === "catch") return true;
4048
+ }
4049
+ if (ancestor.isKind(SyntaxKind.VariableDeclaration)) {
4050
+ const name = ancestor.getNameNode();
4051
+ if (name.isKind(SyntaxKind.ObjectBindingPattern) && name.getElements().some((element) => element.getNameNode().getText() === "error")) return true;
4052
+ }
4053
+ }
4054
+ return false;
4055
+ }
4056
+ /**
4057
+ * Reduce a parsed file to {@link FileFacts} in a single walk.
4058
+ *
4059
+ * @param scope Narrows the walk to one subtree — the handler function, for a
4060
+ * framework that registers many routes in one file. Imports are always read
4061
+ * from the whole file: that is where they are.
4062
+ */
4063
+ function buildFileFacts(source, scope) {
4064
+ const imports = /* @__PURE__ */ new Map();
4065
+ const importedNames = /* @__PURE__ */ new Map();
4066
+ const modules = /* @__PURE__ */ new Set();
4067
+ for (const declaration of source.getImportDeclarations()) {
4068
+ const specifier = declaration.getModuleSpecifierValue();
4069
+ modules.add(specifier);
4070
+ const bind = (name, importedName = name) => {
4071
+ imports.set(name, specifier);
4072
+ importedNames.set(name, importedName);
4073
+ };
4074
+ const defaultImport = declaration.getDefaultImport();
4075
+ if (defaultImport) bind(defaultImport.getText());
4076
+ for (const named of declaration.getNamedImports()) bind(named.getAliasNode()?.getText() ?? named.getName(), named.getName());
4077
+ const namespaceImport = declaration.getNamespaceImport();
4078
+ if (namespaceImport) bind(namespaceImport.getText(), "*");
4079
+ }
4080
+ const calls = [];
4081
+ const throws = [];
4082
+ const catches = [];
4083
+ const network = [];
4084
+ const factoryBindings = /* @__PURE__ */ new Map();
4085
+ const names = /* @__PURE__ */ new Set();
4086
+ const visit = (node) => {
4087
+ if (node.isKind(SyntaxKind.Identifier)) {
4088
+ names.add(node.getText());
4089
+ return;
4090
+ }
4091
+ if (node.isKind(SyntaxKind.CallExpression)) {
4092
+ const { member, root } = calleeParts(node.getExpression());
4093
+ const fact = {
4094
+ text: node.getExpression().getText(),
4095
+ member,
4096
+ root,
4097
+ line: node.getStartLineNumber(),
4098
+ args: node.getArguments().length
4099
+ };
4100
+ calls.push(fact);
4101
+ if (NETWORK_CALLS.has(member)) network.push({
4102
+ name: member,
4103
+ line: fact.line,
4104
+ handled: networkCallHandled(node)
4105
+ });
4106
+ return;
4107
+ }
4108
+ if (node.isKind(SyntaxKind.ThrowStatement)) {
4109
+ const identity = structuredIdentity(node.getExpression());
4110
+ const fact = {
4111
+ kind: classifyImportedThrow(node.getExpression(), imports, importedNames),
4112
+ line: node.getStartLineNumber(),
4113
+ props: structuredProps(node.getExpression())
4114
+ };
4115
+ if (identity) {
4116
+ fact.signature = identity.signature;
4117
+ fact.label = identity.label;
4118
+ }
4119
+ throws.push(fact);
4120
+ return;
4121
+ }
4122
+ if (node.isKind(SyntaxKind.VariableDeclaration)) {
4123
+ const call = node.getInitializer()?.asKind(SyntaxKind.CallExpression);
4124
+ const name = node.getNameNode();
4125
+ if (call && name.isKind(SyntaxKind.Identifier)) factoryBindings.set(name.getText(), calleeParts(call.getExpression()).member);
4126
+ }
4127
+ if (node.isKind(SyntaxKind.CatchClause)) {
4128
+ const block = node.getBlock();
4129
+ catches.push({
4130
+ line: node.getStartLineNumber(),
4131
+ isEmpty: isEmptyBlock(block),
4132
+ handled: catchHandles(block)
4133
+ });
4134
+ }
4135
+ };
4136
+ const roots = Array.isArray(scope) ? scope : [scope ?? source];
4137
+ for (const root of roots) root.forEachDescendant(visit);
4138
+ const byName = /* @__PURE__ */ new Map();
4139
+ const byMember = /* @__PURE__ */ new Map();
4140
+ for (const call of calls) {
4141
+ if (call.root !== null && call.text === call.root) {
4142
+ const bucket = byName.get(call.text);
4143
+ if (bucket) bucket.push(call);
4144
+ else byName.set(call.text, [call]);
4145
+ }
4146
+ const memberBucket = byMember.get(call.member);
4147
+ if (memberBucket) memberBucket.push(call);
4148
+ else byMember.set(call.member, [call]);
4149
+ }
4150
+ const callsTo = (name) => byName.get(name) ?? [];
4151
+ const memberCalls = (member) => byMember.get(member) ?? [];
4152
+ return {
4153
+ imports,
4154
+ importedNames,
4155
+ modules,
4156
+ calls,
4157
+ throws,
4158
+ catches,
4159
+ network,
4160
+ factoryBindings,
4161
+ names,
4162
+ callsTo,
4163
+ memberCalls
4164
+ };
4165
+ }
4166
+ /** Repo-relative POSIX path — the spelling used everywhere in the map file. */
4167
+ function relativeFile(root, absolutePath) {
4168
+ return path$1.relative(root, absolutePath).split(path$1.sep).join("/");
4169
+ }
4170
+ /** The source line at `line` (1-indexed), trimmed, for evidence snippets. */
4171
+ function lineSnippet(source, line) {
4172
+ return source.getFullText().split("\n")[line - 1]?.trim() || void 0;
4173
+ }
4174
+ //#endregion
4175
+ //#region src/lib/map/adapters.ts
4176
+ const HTTP_METHODS = /* @__PURE__ */ new Set([
4177
+ "GET",
4178
+ "POST",
4179
+ "PUT",
4180
+ "PATCH",
4181
+ "DELETE",
4182
+ "HEAD",
4183
+ "OPTIONS"
4184
+ ]);
4185
+ const IGNORE = [
4186
+ "**/node_modules/**",
4187
+ "**/dist/**",
4188
+ "**/.output/**",
4189
+ "**/*.test.*",
4190
+ "**/*.spec.*"
4191
+ ];
4192
+ function files(root, pattern) {
4193
+ return globSync(pattern, {
4194
+ cwd: root,
4195
+ absolute: true,
4196
+ ignore: IGNORE
4197
+ }).toSorted();
4198
+ }
4199
+ /**
4200
+ * Convert filesystem segments to a route path.
4201
+ *
4202
+ * Covers the three conventions in play at once: Next's `(group)` and
4203
+ * `[...slug]`, Nitro's `[id]`, and SvelteKit's `[id]`. They do not collide, so
4204
+ * one function is less to keep in sync than three.
4205
+ */
4206
+ function segmentsToPath(segments) {
4207
+ return `/${segments.filter((segment) => segment.length > 0).filter((segment) => !/^\(.*\)$/.test(segment)).map((segment) => {
4208
+ if (/^\[?\[\.{3}(.+?)\]\]?$/.exec(segment)) return "*";
4209
+ const dynamic = /^\[(.+?)\]$/.exec(segment);
4210
+ return dynamic ? `:${dynamic[1]}` : segment;
4211
+ }).join("/")}`.replaceAll(/\/+/g, "/").replace(/(.)\/$/, "$1");
4212
+ }
4213
+ function locationOf(node, source) {
4214
+ if (!node || !source) return null;
4215
+ return {
4216
+ line: node.getStartLineNumber(),
4217
+ column: source.getLineAndColumnAtPos(node.getStart()).column
4218
+ };
4219
+ }
4220
+ /** Exported HTTP-method handlers in a route file, with where each is declared. */
4221
+ function methodExports(source) {
4222
+ const found = [];
4223
+ for (const [name, declarations] of source.getExportedDeclarations()) {
4224
+ if (!HTTP_METHODS.has(name)) continue;
4225
+ found.push({
4226
+ method: name,
4227
+ handler: locationOf(declarations[0], source)
4228
+ });
4229
+ }
4230
+ return found;
4231
+ }
4232
+ /** The declaration a file's default export points at. */
4233
+ function defaultExportLocation(source) {
4234
+ return locationOf(source.getExportedDeclarations().get("default")?.[0], source);
4235
+ }
4236
+ /**
4237
+ * Calls to named APIs from one package family, resolved through aliases and
4238
+ * namespace imports. Text mentions and same-named local helpers do not count.
4239
+ */
4240
+ function importedCalls(source, modulePrefix, canonicalNames) {
4241
+ const names = new Set(canonicalNames);
4242
+ const locals = /* @__PURE__ */ new Map();
4243
+ const namespaces = /* @__PURE__ */ new Set();
4244
+ for (const declaration of source.getImportDeclarations()) {
4245
+ if (!declaration.getModuleSpecifierValue().startsWith(modulePrefix)) continue;
4246
+ for (const named of declaration.getNamedImports()) {
4247
+ if (!names.has(named.getName())) continue;
4248
+ locals.set(named.getAliasNode()?.getText() ?? named.getName(), named.getName());
4249
+ }
4250
+ const namespace = declaration.getNamespaceImport()?.getText();
4251
+ if (namespace) namespaces.add(namespace);
4252
+ }
4253
+ const found = /* @__PURE__ */ new Map();
4254
+ for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {
4255
+ const expression = call.getExpression();
4256
+ if (expression.isKind(SyntaxKind.Identifier)) {
4257
+ const canonical = locals.get(expression.getText());
4258
+ if (canonical && !found.has(canonical)) found.set(canonical, call);
4259
+ continue;
4260
+ }
4261
+ if (expression.isKind(SyntaxKind.PropertyAccessExpression) && namespaces.has(expression.getExpression().getText()) && names.has(expression.getName()) && !found.has(expression.getName())) found.set(expression.getName(), call);
4262
+ }
4263
+ return found;
4264
+ }
4265
+ function nextAppDir(root) {
4266
+ for (const candidate of ["app", "src/app"]) if (files(root, `${candidate}/**/route.{ts,tsx,js,jsx}`).length > 0) return candidate;
4267
+ return null;
4268
+ }
4269
+ const nextAdapter = {
4270
+ framework: "next",
4271
+ autoImports: [],
4272
+ extractRoutes({ projectRoot, parse }) {
4273
+ const routes = [];
4274
+ const appDir = nextAppDir(projectRoot);
4275
+ if (appDir) for (const absolute of files(projectRoot, `${appDir}/**/route.{ts,tsx,js,jsx}`)) {
4276
+ const relative = relativeFile(projectRoot, absolute);
4277
+ const routePath = segmentsToPath(relative.slice(appDir.length + 1).replace(/(?:^|\/)route\.[tj]sx?$/, "").split("/"));
4278
+ const source = parse(absolute);
4279
+ const methods = source ? methodExports(source) : [];
4280
+ if (methods.length === 0) {
4281
+ routes.push({
4282
+ framework: "next",
4283
+ kind: "api",
4284
+ method: null,
4285
+ path: routePath,
4286
+ file: relative,
4287
+ handler: null
4288
+ });
4289
+ continue;
4290
+ }
4291
+ for (const { method, handler } of methods) routes.push({
4292
+ framework: "next",
4293
+ kind: "api",
4294
+ method,
4295
+ path: routePath,
4296
+ file: relative,
4297
+ handler
4298
+ });
4299
+ }
4300
+ for (const absolute of [...files(projectRoot, "pages/api/**/*.{ts,tsx,js,jsx}"), ...files(projectRoot, "src/pages/api/**/*.{ts,tsx,js,jsx}")]) {
4301
+ const relative = relativeFile(projectRoot, absolute);
4302
+ const routePath = segmentsToPath(relative.replace(/^(?:src\/)?pages/, "").replace(/\.[tj]sx?$/, "").replace(/\/index$/, "").split("/"));
4303
+ const source = parse(absolute);
4304
+ routes.push({
4305
+ framework: "next",
4306
+ kind: "api",
4307
+ method: null,
4308
+ path: routePath,
4309
+ file: relative,
4310
+ handler: source ? defaultExportLocation(source) : null
4311
+ });
4312
+ }
4313
+ for (const candidate of ["middleware.ts", "src/middleware.ts"]) {
4314
+ const absolute = path$1.join(projectRoot, candidate);
4315
+ if (!fileExists(absolute)) continue;
4316
+ const source = parse(absolute);
4317
+ routes.push({
4318
+ framework: "next",
4319
+ kind: "middleware",
4320
+ method: null,
4321
+ path: "/*",
4322
+ file: candidate,
4323
+ handler: source ? defaultExportLocation(source) : null
4324
+ });
4325
+ }
4326
+ for (const appRoot of ["app", "src/app"]) for (const absolute of files(projectRoot, `${appRoot}/**/page.{ts,tsx,js,jsx}`)) {
4327
+ const relative = relativeFile(projectRoot, absolute);
4328
+ const dir = relative.slice(appRoot.length + 1).replace(/(?:^|\/)page\.[tj]sx?$/, "");
4329
+ const source = parse(absolute);
4330
+ routes.push({
4331
+ framework: "next",
4332
+ kind: "page",
4333
+ method: null,
4334
+ path: segmentsToPath(dir.split("/")),
4335
+ file: relative,
4336
+ handler: source ? defaultExportLocation(source) : null
4337
+ });
4338
+ }
4339
+ for (const pagesRoot of ["pages", "src/pages"]) for (const absolute of files(projectRoot, `${pagesRoot}/**/*.{ts,tsx,js,jsx}`)) {
4340
+ const relative = relativeFile(projectRoot, absolute);
4341
+ const belowRoot = relative.slice(pagesRoot.length + 1);
4342
+ if (belowRoot.startsWith("api/") || /^_(?:app|document|error)\.[tj]sx?$/.test(belowRoot)) continue;
4343
+ const routePath = segmentsToPath(belowRoot.replace(/\.[tj]sx?$/, "").replace(/\/index$/, "").split("/"));
4344
+ const source = parse(absolute);
4345
+ routes.push({
4346
+ framework: "next",
4347
+ kind: "page",
4348
+ method: null,
4349
+ path: routePath,
4350
+ file: relative,
4351
+ handler: source ? defaultExportLocation(source) : null
4352
+ });
4353
+ }
4354
+ return routes;
4355
+ }
4356
+ };
4357
+ /** Nitro encodes the method in the filename: `users.post.ts` is `POST /users`. */
4358
+ function nitroMethodSuffix(fileName) {
4359
+ const match = /^(.*)\.(get|post|put|patch|delete|head|options)$/i.exec(fileName);
4360
+ if (!match?.[1] || !match[2]) return {
4361
+ method: null,
4362
+ base: fileName
4363
+ };
4364
+ return {
4365
+ method: match[2].toUpperCase(),
4366
+ base: match[1]
4367
+ };
4368
+ }
4369
+ const nitroAdapter = {
4370
+ framework: "nitro",
4371
+ autoImports: ["useLogger", "defineEventHandler"],
4372
+ extractRoutes({ projectRoot, parse }) {
4373
+ const routes = [];
4374
+ for (const group of [
4375
+ {
4376
+ glob: "server/api/**/*.{ts,js}",
4377
+ prefix: "server/api",
4378
+ kind: "api"
4379
+ },
4380
+ {
4381
+ glob: "server/routes/**/*.{ts,js}",
4382
+ prefix: "server/routes",
4383
+ kind: "api"
4384
+ },
4385
+ {
4386
+ glob: "server/middleware/*.{ts,js}",
4387
+ prefix: "server/middleware",
4388
+ kind: "middleware"
4389
+ }
4390
+ ]) for (const absolute of files(projectRoot, group.glob)) {
4391
+ const relative = relativeFile(projectRoot, absolute);
4392
+ const segments = relative.slice(group.prefix.length).replace(/\.[tj]s$/, "").split("/").filter(Boolean);
4393
+ const { method, base } = nitroMethodSuffix(segments.pop() ?? "");
4394
+ if (base !== "index") segments.push(base);
4395
+ const routePath = group.kind === "middleware" ? "/*" : segmentsToPath(group.prefix === "server/api" ? ["api", ...segments] : segments);
4396
+ const source = parse(absolute);
4397
+ routes.push({
4398
+ framework: "nitro",
4399
+ kind: group.kind,
4400
+ method,
4401
+ path: routePath,
4402
+ file: relative,
4403
+ handler: source ? defaultExportLocation(source) : null
4404
+ });
4405
+ }
4406
+ for (const pagesRoot of ["pages", "app/pages"]) for (const absolute of files(projectRoot, `${pagesRoot}/**/*.{vue,ts,tsx,js,jsx}`)) {
4407
+ const relative = relativeFile(projectRoot, absolute);
4408
+ const routePath = segmentsToPath(relative.slice(pagesRoot.length + 1).replace(/\.(?:vue|[tj]sx?)$/, "").replace(/\/index$/, "").split("/"));
4409
+ const source = parse(absolute);
4410
+ routes.push({
4411
+ framework: "nitro",
4412
+ kind: "page",
4413
+ method: null,
4414
+ path: routePath,
4415
+ file: relative,
4416
+ handler: source ? defaultExportLocation(source) : null
4417
+ });
4418
+ }
4419
+ return routes;
4420
+ }
4421
+ };
4422
+ const svelteKitAdapter = {
4423
+ framework: "sveltekit",
4424
+ autoImports: [],
4425
+ extractRoutes({ projectRoot, parse }) {
4426
+ const routes = [];
4427
+ for (const absolute of files(projectRoot, "src/routes/**/+server.{ts,js}")) {
4428
+ const relative = relativeFile(projectRoot, absolute);
4429
+ const routePath = segmentsToPath(relative.slice(10).replace(/\/\+server\.[tj]s$/, "").split("/"));
4430
+ const source = parse(absolute);
4431
+ const methods = source ? methodExports(source) : [];
4432
+ if (methods.length === 0) {
4433
+ routes.push({
4434
+ framework: "sveltekit",
4435
+ kind: "api",
4436
+ method: null,
4437
+ path: routePath,
4438
+ file: relative,
4439
+ handler: null
4440
+ });
4441
+ continue;
4442
+ }
4443
+ for (const { method, handler } of methods) routes.push({
4444
+ framework: "sveltekit",
4445
+ kind: "api",
4446
+ method,
4447
+ path: routePath,
4448
+ file: relative,
4449
+ handler
4450
+ });
4451
+ }
4452
+ for (const absolute of files(projectRoot, "src/routes/**/+{page,layout}.server.{ts,js}")) {
4453
+ const relative = relativeFile(projectRoot, absolute);
4454
+ const dir = relative.slice(10).replace(/\/\+(?:page|layout)\.server\.[tj]s$/, "");
4455
+ const source = parse(absolute);
4456
+ routes.push({
4457
+ framework: "sveltekit",
4458
+ kind: "server-fn",
4459
+ method: null,
4460
+ path: segmentsToPath(dir.split("/")),
4461
+ file: relative,
4462
+ handler: source ? locationOf(source.getExportedDeclarations().get("load")?.[0], source) ?? locationOf(source.getExportedDeclarations().get("actions")?.[0], source) : null
4463
+ });
4464
+ }
4465
+ for (const candidate of ["src/hooks.server.ts", "src/hooks.server.js"]) {
4466
+ if (!fileExists(path$1.join(projectRoot, candidate))) continue;
4467
+ const source = parse(path$1.join(projectRoot, candidate));
4468
+ routes.push({
4469
+ framework: "sveltekit",
4470
+ kind: "middleware",
4471
+ method: null,
4472
+ path: "/*",
4473
+ file: candidate,
4474
+ handler: source ? locationOf(source.getExportedDeclarations().get("handle")?.[0], source) : null
4475
+ });
4476
+ }
4477
+ for (const absolute of files(projectRoot, "src/routes/**/+page.{ts,js}")) {
4478
+ const relative = relativeFile(projectRoot, absolute);
4479
+ const dir = relative.slice(10).replace(/\/\+page\.[tj]s$/, "");
4480
+ const source = parse(absolute);
4481
+ routes.push({
4482
+ framework: "sveltekit",
4483
+ kind: "page",
4484
+ method: null,
4485
+ path: segmentsToPath(dir.split("/")),
4486
+ file: relative,
4487
+ handler: source ? locationOf(source.getExportedDeclarations().get("load")?.[0], source) : null
4488
+ });
4489
+ }
4490
+ return routes;
4491
+ }
4492
+ };
4493
+ const tanstackAdapter = {
4494
+ framework: "tanstack-start",
4495
+ autoImports: [],
4496
+ extractRoutes({ projectRoot, parse }) {
4497
+ const routes = [];
4498
+ for (const absolute of files(projectRoot, "src/routes/**/*.{ts,tsx}")) {
4499
+ const text = readFileSafe(absolute) ?? "";
4500
+ if (!/createServerFileRoute|createServerFn|createFileRoute/.test(text)) continue;
4501
+ const relative = relativeFile(projectRoot, absolute);
4502
+ const dir = relative.slice(10).replace(/\.[tj]sx?$/, "").replace(/\/index$/, "");
4503
+ const source = parse(absolute);
4504
+ if (!source) continue;
4505
+ const calls = importedCalls(source, "@tanstack/", [
4506
+ "createServerFileRoute",
4507
+ "createServerFn",
4508
+ "createFileRoute"
4509
+ ]);
4510
+ const serverRoute = calls.get("createServerFileRoute");
4511
+ const serverFn = calls.get("createServerFn");
4512
+ const page = calls.get("createFileRoute");
4513
+ const routePath = segmentsToPath(dir.split("/"));
4514
+ if (serverRoute || serverFn) routes.push({
4515
+ framework: "tanstack-start",
4516
+ kind: serverRoute ? "api" : "server-fn",
4517
+ method: null,
4518
+ path: routePath,
4519
+ file: relative,
4520
+ handler: locationOf(serverRoute ?? serverFn, source)
4521
+ });
4522
+ if (page) routes.push({
4523
+ framework: "tanstack-start",
4524
+ kind: "page",
4525
+ method: null,
4526
+ path: routePath,
4527
+ file: relative,
4528
+ handler: locationOf(page, source)
4529
+ });
4530
+ }
4531
+ return routes;
4532
+ }
4533
+ };
4534
+ const cloudflareAdapter = {
4535
+ framework: "cloudflare",
4536
+ autoImports: [],
4537
+ extractRoutes({ projectRoot, parse }) {
4538
+ for (const candidate of [
4539
+ "src/index.ts",
4540
+ "src/worker.ts",
4541
+ "src/index.js",
4542
+ "index.ts"
4543
+ ]) {
4544
+ const absolute = path$1.join(projectRoot, candidate);
4545
+ if (!fileExists(absolute)) continue;
4546
+ const source = parse(absolute);
4547
+ return [{
4548
+ framework: "cloudflare",
4549
+ kind: "api",
4550
+ method: null,
4551
+ path: "/*",
4552
+ file: candidate,
4553
+ handler: source ? defaultExportLocation(source) : null
4554
+ }];
4555
+ }
4556
+ return [];
4557
+ }
4558
+ };
4559
+ const ROUTER_CALL = /\.\s*(get|post|put|patch|delete|all|use)\s*\(\s*['"`]/;
4560
+ /**
4561
+ * One adapter for every framework that registers routes as `app.get(path, fn)`.
4562
+ *
4563
+ * Hono, Express, Fastify, and Elysia differ in everything except the shape this
4564
+ * scan cares about, so four adapters would be the same file copied four times.
4565
+ * Files are text-filtered before parsing: an app of any size has few route
4566
+ * files, and parsing all of `src/` to find them costs far more than reading it.
4567
+ */
4568
+ function routerAdapter(framework) {
4569
+ return {
4570
+ framework,
4571
+ autoImports: [],
4572
+ extractRoutes({ projectRoot, parse }) {
4573
+ const routes = [];
4574
+ for (const absolute of [...files(projectRoot, "{src,app,server,routes}/**/*.{ts,js,mts,mjs}"), ...files(projectRoot, "*.{ts,js,mts,mjs}")]) {
4575
+ const text = readFileSafe(absolute);
4576
+ if (!text || !ROUTER_CALL.test(text)) continue;
4577
+ const source = parse(absolute);
4578
+ if (!source) continue;
4579
+ const relative = relativeFile(projectRoot, absolute);
4580
+ for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {
4581
+ const callee = call.getExpression();
4582
+ const match = /\.(get|post|put|patch|delete|all|use)$/.exec(callee.getText());
4583
+ if (!match?.[1]) continue;
4584
+ const args = call.getArguments();
4585
+ const first = args[0];
4586
+ if (!first || args.length < 2) continue;
4587
+ const literal = /^['"`](.*)['"`]$/s.exec(first.getText());
4588
+ if (!literal?.[1]) continue;
4589
+ const routePath = literal[1];
4590
+ if (routePath !== "*" && !routePath.startsWith("/")) continue;
4591
+ const verb = match[1];
4592
+ routes.push({
4593
+ framework,
4594
+ kind: verb === "use" ? "middleware" : "api",
4595
+ method: verb === "use" || verb === "all" ? null : verb.toUpperCase(),
4596
+ path: routePath,
4597
+ file: relative,
4598
+ handler: {
4599
+ line: call.getStartLineNumber(),
4600
+ column: source.getLineAndColumnAtPos(call.getStart()).column
4601
+ }
4602
+ });
4603
+ }
4604
+ }
4605
+ return routes;
4606
+ },
4607
+ scopesFor(route, source) {
4608
+ const line = route.handler?.line;
4609
+ if (line === void 0) return;
4610
+ const call = source.getDescendantsOfKind(SyntaxKind.CallExpression).find((candidate) => candidate.getStartLineNumber() === line && /\.(get|post|put|patch|delete|all|use)$/.test(candidate.getExpression().getText()));
4611
+ if (!call) return;
4612
+ const handler = call.getArguments().at(-1);
4613
+ const declarations = handler ? handler.getDescendantsOfKind(SyntaxKind.Identifier).flatMap((identifier) => {
4614
+ const declaration = identifier.getDefinitions()[0]?.getDeclarationNode();
4615
+ return declaration?.isKind(SyntaxKind.FunctionDeclaration) || declaration?.isKind(SyntaxKind.VariableDeclaration) ? [declaration] : [];
4616
+ }) : [];
4617
+ if (handler?.isKind(SyntaxKind.Identifier)) {
4618
+ const declaration = handler.getDefinitions()[0]?.getDeclarationNode();
4619
+ if (declaration?.isKind(SyntaxKind.FunctionDeclaration) || declaration?.isKind(SyntaxKind.VariableDeclaration)) declarations.push(declaration);
4620
+ }
4621
+ return [call, ...new Set(declarations)];
4622
+ }
4623
+ };
4624
+ }
4625
+ function getAdapter(framework) {
4626
+ switch (framework) {
4627
+ case "next": return nextAdapter;
4628
+ case "nitro": return nitroAdapter;
4629
+ case "sveltekit": return svelteKitAdapter;
4630
+ case "tanstack-start": return tanstackAdapter;
4631
+ case "cloudflare": return cloudflareAdapter;
4632
+ case "hono":
4633
+ case "express":
4634
+ case "fastify":
4635
+ case "elysia": return routerAdapter(framework);
4636
+ }
4637
+ }
4638
+ const SUPPORTED_FRAMEWORKS = [
4639
+ "next",
4640
+ "nitro",
4641
+ "tanstack-start",
4642
+ "sveltekit",
4643
+ "hono",
4644
+ "express",
4645
+ "fastify",
4646
+ "elysia",
4647
+ "cloudflare"
4648
+ ];
4649
+ function isFramework(value) {
4650
+ return SUPPORTED_FRAMEWORKS.includes(value);
4651
+ }
4652
+ /**
4653
+ * Pick the framework to scan for.
4654
+ *
4655
+ * Meta-frameworks are checked first: a Nuxt app has `h3` installed and an
4656
+ * Express app does not become a Next app because it renders one page.
4657
+ */
4658
+ function detectFramework(projectRoot, deps) {
4659
+ const warnings = [];
4660
+ const matched = [
4661
+ ["next", deps.has("next")],
4662
+ ["nitro", deps.has("nuxt") || deps.has("nitropack") || deps.has("h3")],
4663
+ ["tanstack-start", deps.has("@tanstack/react-start") || deps.has("@tanstack/start")],
4664
+ ["sveltekit", deps.has("@sveltejs/kit")],
4665
+ ["cloudflare", fileExists(path$1.join(projectRoot, "wrangler.toml")) || fileExists(path$1.join(projectRoot, "wrangler.jsonc")) || fileExists(path$1.join(projectRoot, "wrangler.json"))],
4666
+ ["hono", deps.has("hono")],
4667
+ ["elysia", deps.has("elysia")],
4668
+ ["fastify", deps.has("fastify")],
4669
+ ["express", deps.has("express")]
4670
+ ].filter(([, present]) => present).map(([name]) => name);
4671
+ if (matched.length === 0) return {
4672
+ framework: null,
4673
+ warnings
4674
+ };
4675
+ if (matched.length > 1) warnings.push(`Multiple frameworks detected (${matched.join(", ")}); scanning as ${matched[0]}. Use --framework to override.`);
4676
+ return {
4677
+ framework: matched[0] ?? null,
4678
+ warnings
4679
+ };
4680
+ }
4681
+ //#endregion
4682
+ //#region src/lib/map/exemptions.ts
4683
+ /**
4684
+ * Paths with nothing to instrument, as consecutive path segments.
4685
+ *
4686
+ * Matched segment by segment rather than as a substring: an exemption skips
4687
+ * every rule, so a loose match is the worst bug this tool can have — it drops a
4688
+ * real handler out of the score instead of reporting a gap.
4689
+ */
4690
+ const EXEMPT_SEGMENTS = [
4691
+ {
4692
+ segments: ["health"],
4693
+ reason: "health check — nothing to instrument"
4694
+ },
4695
+ {
4696
+ segments: ["healthz"],
4697
+ reason: "health check — nothing to instrument"
4698
+ },
4699
+ {
4700
+ segments: ["livez"],
4701
+ reason: "liveness probe — nothing to instrument"
4702
+ },
4703
+ {
4704
+ segments: ["readyz"],
4705
+ reason: "readiness probe — nothing to instrument"
4706
+ },
4707
+ {
4708
+ segments: ["ping"],
4709
+ reason: "liveness probe — nothing to instrument"
4710
+ },
4711
+ {
4712
+ segments: ["metrics"],
4713
+ reason: "metrics endpoint — telemetry plumbing"
4714
+ },
4715
+ {
4716
+ segments: ["api", "otel"],
4717
+ reason: "autotel infrastructure — telemetry ingest"
4718
+ },
4719
+ {
4720
+ segments: ["api", "telemetry"],
4721
+ reason: "autotel infrastructure — telemetry ingest"
4722
+ }
4723
+ ];
4724
+ /** Path or file split into lowercase segments, extension and method suffix dropped. */
4725
+ function segmentsOf(value) {
4726
+ return value.toLowerCase().split("/").filter((segment) => segment.length > 0).map((segment) => segment.split(".")[0] ?? segment);
4727
+ }
4728
+ function containsRun(segments, pattern) {
4729
+ return segments.some((_, index) => pattern.every((name, offset) => segments[index + offset] === name));
4730
+ }
4731
+ /** The exemption for this entry point, if any. Depends only on path and file. */
4732
+ function getRouteExemption(route) {
4733
+ const pathSegments = segmentsOf(route.path);
4734
+ const fileSegments = segmentsOf(route.file);
4735
+ for (const candidate of EXEMPT_SEGMENTS) if (containsRun(pathSegments, candidate.segments) || containsRun(fileSegments, candidate.segments)) return {
4736
+ reason: candidate.reason,
4737
+ skip: "all"
4738
+ };
4739
+ return null;
4740
+ }
4741
+ function isSkipped(exemption, id) {
4742
+ return exemption.skip === "all" || exemption.skip.includes(id);
4743
+ }
4744
+ function isExemptRoute(route) {
4745
+ return getRouteExemption(route) !== null;
4746
+ }
4747
+ const DIRECTIVE = /(?:\/\/|\/\*|\*)\s*autotel-map-disable(?<scope>-next-line|-line)?(?:\s+(?<ids>[a-z*-]+(?:\s*,\s*[a-z*-]+)*))?(?:\s*--\s*(?<reason>.*?))?\s*(?:\*\/)?\s*$/;
4748
+ /** Every `autotel-map-disable` comment in a file, indexed for lookup. */
4749
+ function collectSuppressions(sourceText) {
4750
+ const all = [];
4751
+ const lines = sourceText.split("\n");
4752
+ for (const [index, text] of lines.entries()) {
4753
+ const match = DIRECTIVE.exec(text);
4754
+ if (!match) continue;
4755
+ const scope = match.groups?.["scope"] === "-next-line" ? "next-line" : match.groups?.["scope"] === "-line" ? "line" : "file";
4756
+ const reason = match.groups?.["reason"]?.trim() || null;
4757
+ const ids = (match.groups?.["ids"] ?? "*").split(",").map((id) => id.trim()).filter(Boolean);
4758
+ for (const id of ids) all.push({
4759
+ id,
4760
+ declaredAt: index + 1,
4761
+ reason,
4762
+ scope
4763
+ });
4764
+ }
4765
+ const matches = (suppression, id) => suppression.id === "*" || suppression.id === id;
4766
+ return {
4767
+ file: (id) => all.find((suppression) => suppression.scope === "file" && matches(suppression, id)),
4768
+ at: (id, line) => all.find((suppression) => matches(suppression, id) && (suppression.scope === "next-line" && suppression.declaredAt + 1 === line || suppression.scope === "line" && suppression.declaredAt === line)),
4769
+ unknown: (known) => all.filter((suppression) => suppression.id !== "*" && !known.includes(suppression.id))
4770
+ };
4771
+ }
4772
+ function suppressionMessage(suppression) {
4773
+ return suppression.reason ? `disabled: ${suppression.reason}` : "disabled by autotel-map-disable";
4774
+ }
4775
+ /** How many checks on this entry point were waived by a comment. */
4776
+ function countSuppressed(route) {
4777
+ return Object.values(route.checks).filter((check) => check?.suppressed).length;
4778
+ }
4779
+ //#endregion
4780
+ //#region src/lib/map/types.ts
4781
+ /** Entry-point kinds that own a server-side span and request event. */
4782
+ const HANDLER_KINDS = [
4783
+ "api",
4784
+ "middleware",
4785
+ "server-fn"
4786
+ ];
4787
+ //#endregion
4788
+ //#region src/lib/map/rules.ts
4789
+ /**
4790
+ * Everything that puts this handler on a trace.
4791
+ *
4792
+ * The framework wrappers belong here alongside `trace()`: `withAutotel(...)`
4793
+ * from `autotel-adapters/next` creates the request span, and a rule that only
4794
+ * knew about `trace()` would report a fully instrumented route as invisible.
4795
+ */
4796
+ const SPAN_WRAPPERS = [
4797
+ "trace",
4798
+ "span",
4799
+ "instrument",
4800
+ "withTracing",
4801
+ "enterSpan",
4802
+ "traceGenAI",
4803
+ "traceLLM",
4804
+ "withAutotel",
4805
+ "withAutotelFetch",
4806
+ "withAutotelHandler",
4807
+ "autotelHandle",
4808
+ "autotelMiddleware",
4809
+ "defineAutotelEventHandler"
4810
+ ];
4811
+ const REQUEST_LOGGERS = [
4812
+ "getRequestLogger",
4813
+ "useLogger",
4814
+ "getExecutionLogger"
4815
+ ];
4816
+ /** Request-logger and span-context members that attach business context. */
4817
+ const CONTEXT_MEMBERS = [
4818
+ "set",
4819
+ "setAttribute",
4820
+ "setAttributes",
4821
+ "info",
4822
+ "warn",
4823
+ "error"
4824
+ ];
4825
+ const AUDIT_APIS = [
4826
+ "withAudit",
4827
+ "securityEvent",
4828
+ "withSecurity",
4829
+ "setAuditAttributes",
4830
+ "forceKeepAuditEvent"
4831
+ ];
4832
+ const GENAI_TRACERS = [
4833
+ "traceGenAI",
4834
+ "traceLLM",
4835
+ "recordGenAiUsage"
4836
+ ];
4837
+ /** Calls that mean "an LLM was invoked", across the common SDKs. */
4838
+ const LLM_CALLS = [
4839
+ "generateText",
4840
+ "streamText",
4841
+ "generateObject",
4842
+ "streamObject",
4843
+ "completions",
4844
+ "create",
4845
+ "invoke",
4846
+ "chat"
4847
+ ];
4848
+ /**
4849
+ * Whether the file calls one of these autotel APIs.
4850
+ *
4851
+ * The name has to come from autotel — imported from an `autotel*` module, or
4852
+ * injected by the framework integration. Otherwise a local helper called
4853
+ * `trace()` would score as instrumentation.
4854
+ */
4855
+ function usesAutotelApi(context, names) {
4856
+ return usesAutotelApi$1(context.facts, names, context.autoImports);
4857
+ }
4858
+ function autotelLocalNames(context, canonicalNames) {
4859
+ const locals = new Set(context.autoImports.filter((name) => canonicalNames.includes(name)));
4860
+ for (const canonical of canonicalNames) locals.add(canonical);
4861
+ for (const [local, module] of context.facts.imports) {
4862
+ if (!isAutotelModule(module)) continue;
4863
+ const imported = context.facts.importedNames.get(local) ?? "";
4864
+ if (canonicalNames.includes(imported)) locals.add(local);
4865
+ else if (imported === "*") for (const canonical of canonicalNames) locals.add(`${local}.${canonical}`);
4866
+ }
4867
+ return locals;
4868
+ }
4869
+ function loggerMemberCalls(context, member) {
4870
+ const factories = autotelLocalNames(context, REQUEST_LOGGERS);
4871
+ return context.facts.memberCalls(member).filter((call) => {
4872
+ if (call.root && factories.has(context.facts.factoryBindings.get(call.root) ?? "")) return true;
4873
+ return [...factories].some((factory) => call.text.startsWith(`${factory}().`));
4874
+ });
4875
+ }
4876
+ const traceRule = {
4877
+ id: "trace",
4878
+ category: "requirement",
4879
+ title: "span",
4880
+ question: "Does this entry point produce a span?",
4881
+ weight: 40,
4882
+ appliesTo: { kinds: HANDLER_KINDS },
4883
+ fix: ({ target }) => `export const handler = trace('${target.method?.toLowerCase() ?? "handle"} ${target.path}', async () => { /* … */ });`,
4884
+ check(context) {
4885
+ if (usesAutotelApi(context, SPAN_WRAPPERS)) return null;
4886
+ if (context.project.ambientTracing) return null;
4887
+ if (!context.project.hasAutotel) return { message: "autotel not installed — nothing observes this handler" };
4888
+ return { message: "no trace() or span() — this handler is invisible when it breaks" };
4889
+ }
4890
+ };
4891
+ const contextRule = {
4892
+ id: "context",
4893
+ category: "requirement",
4894
+ title: "context",
4895
+ question: "Does the span carry business context, or only method and status?",
4896
+ weight: 25,
4897
+ appliesTo: { kinds: HANDLER_KINDS },
4898
+ fix: ({ target }) => target.framework === "nitro" ? "const log = useLogger(event); log.set({ 'user.id': userId });" : "const log = getRequestLogger(); log.set({ 'user.id': userId });",
4899
+ check(context) {
4900
+ const hasLogger = usesAutotelApi(context, REQUEST_LOGGERS);
4901
+ const setsContext = CONTEXT_MEMBERS.some((member) => loggerMemberCalls(context, member).length > 0);
4902
+ if (hasLogger && setsContext) return null;
4903
+ if (hasLogger) return {
4904
+ message: "request logger acquired but never given attributes",
4905
+ line: context.facts.callsTo("getRequestLogger")[0]?.line
4906
+ };
4907
+ return { message: context.project.ambientTracing ? "handler adds nothing to its request span — only method, path and status are recorded" : "no request-scoped attributes — the span says what failed, not for whom" };
4908
+ }
4909
+ };
4910
+ const structuredErrorsRule = {
4911
+ id: "structured-errors",
4912
+ category: "requirement",
4913
+ title: "errors",
4914
+ question: "Do thrown errors explain why they failed and how to fix them?",
4915
+ weight: 15,
4916
+ appliesTo: {
4917
+ kinds: HANDLER_KINDS,
4918
+ when: ({ facts }) => facts.throws.length > 0
4919
+ },
4920
+ fix: () => "throw createStructuredError({ message: 'Payment declined', why: '…', fix: '…', status: 402 });",
4921
+ check({ facts }) {
4922
+ const plain = facts.throws.find((entry) => entry.kind === "plain-error");
4923
+ if (plain) return {
4924
+ message: "throws a bare Error — no why, fix, or status for the caller",
4925
+ line: plain.line
4926
+ };
4927
+ const incomplete = facts.throws.find((entry) => entry.kind === "structured" && (!entry.props.has("why") || !entry.props.has("fix")));
4928
+ if (!incomplete) return null;
4929
+ return {
4930
+ message: `structured error is missing ${["why", "fix"].filter((name) => !incomplete.props.has(name)).join(" and ")}`,
4931
+ line: incomplete.line
4932
+ };
4933
+ }
4934
+ };
4935
+ const pageErrorHandlingRule = {
4936
+ id: "page-error-handling",
4937
+ category: "requirement",
4938
+ title: "fetch",
4939
+ question: "Does this page handle its data request failing?",
4940
+ weight: 20,
4941
+ appliesTo: {
4942
+ kinds: ["page"],
4943
+ when: ({ facts }) => facts.network.length > 0
4944
+ },
4945
+ fix: () => "try { const data = await fetchData(); } catch (error) { /* render an error state */ }",
4946
+ check({ facts }) {
4947
+ const unhandled = facts.network.find((call) => !call.handled);
4948
+ if (!unhandled) return null;
4949
+ return {
4950
+ message: `${unhandled.name}() has no error path — the page breaks when it fails`,
4951
+ line: unhandled.line
4952
+ };
4953
+ }
4954
+ };
4955
+ const errorHandlingRule = {
4956
+ id: "error-handling",
4957
+ category: "requirement",
4958
+ title: "catch",
4959
+ question: "Does every catch block record the failure?",
4960
+ weight: 10,
4961
+ appliesTo: {
4962
+ kinds: HANDLER_KINDS,
4963
+ when: ({ facts }) => facts.catches.length > 0
4964
+ },
4965
+ fix: () => "catch (error) { log.error(error); throw error; }",
4966
+ check({ facts }) {
4967
+ const swallowed = facts.catches.find((entry) => entry.isEmpty || !entry.handled);
4968
+ if (!swallowed) return null;
4969
+ return {
4970
+ message: swallowed.isEmpty ? "empty catch block — the failure disappears" : "catch block neither records nor rethrows — the failure disappears",
4971
+ line: swallowed.line
4972
+ };
4973
+ }
4974
+ };
4975
+ const auditRule = {
4976
+ id: "audit",
4977
+ category: "requirement",
4978
+ title: "audit",
4979
+ question: "Does this money or auth path leave an audit trail?",
4980
+ weight: 25,
4981
+ appliesTo: {
4982
+ kinds: HANDLER_KINDS,
4983
+ when: ({ target }) => target.sensitivity.level === "high"
4984
+ },
4985
+ fix: ({ target }) => `withAudit({ action: '${target.path.replaceAll("/", ".").replace(/^\./, "")}', resource: '…', actorId }, async (ctx, log) => { /* … */ });`,
4986
+ check(context) {
4987
+ if (usesAutotelApi(context, AUDIT_APIS)) return null;
4988
+ return { message: `no audit trail (${context.target.sensitivity.reasons[0] ?? "sensitive route"}) — withAudit() survives tail sampling` };
4989
+ }
4990
+ };
4991
+ const WRITE_CALLS$1 = [
4992
+ "create",
4993
+ "update",
4994
+ "insert",
4995
+ "upsert",
4996
+ "delete",
4997
+ "destroy"
4998
+ ];
4999
+ /**
5000
+ * Every rule, in report order.
5001
+ *
5002
+ * Adding a rule is one entry here plus one id in {@link CheckId}: weight,
5003
+ * column title, docs link, and applicability all travel with the rule.
5004
+ */
5005
+ const RULES = [
5006
+ traceRule,
5007
+ contextRule,
5008
+ structuredErrorsRule,
5009
+ errorHandlingRule,
5010
+ pageErrorHandlingRule,
5011
+ auditRule,
5012
+ {
5013
+ id: "audit-coverage",
5014
+ category: "opportunity",
5015
+ title: "audit+",
5016
+ question: "Should this state change join the project audit trail?",
5017
+ appliesTo: {
5018
+ kinds: HANDLER_KINDS,
5019
+ when: ({ target, project, facts }) => target.sensitivity.level !== "high" && project.hasAuditUsage && WRITE_CALLS$1.some((name) => facts.memberCalls(name).length > 0)
5020
+ },
5021
+ fix: ({ target }) => `securityEvent({ name: '${target.path.replaceAll("/", ".").replace(/^\./, "")}.changed', category: 'data', outcome: 'success' });`,
5022
+ check(context) {
5023
+ if (usesAutotelApi(context, AUDIT_APIS)) return null;
5024
+ return {
5025
+ message: "changes state with no security event — this project records audit signals elsewhere",
5026
+ line: WRITE_CALLS$1.flatMap((name) => context.facts.memberCalls(name)).toSorted((a, b) => a.line - b.line)[0]?.line
5027
+ };
5028
+ }
5029
+ },
5030
+ {
5031
+ id: "error-catalog",
5032
+ category: "opportunity",
5033
+ title: "catalog",
5034
+ question: "Should duplicated structured errors become catalog entries?",
5035
+ appliesTo: {
5036
+ kinds: HANDLER_KINDS,
5037
+ when: ({ project, facts }) => project.hasErrorCatalog && facts.throws.some((entry) => entry.signature !== void 0 && project.repeatedErrors.has(entry.signature))
5038
+ },
5039
+ fix: () => "const billing = defineErrorCatalog('billing', { PAYMENT_DECLINED: { status: 402, message: 'Card declined', why: '…', fix: '…' } });",
5040
+ check({ facts, project }) {
5041
+ const thrown = facts.throws.find((entry) => entry.signature !== void 0 && project.repeatedErrors.has(entry.signature));
5042
+ if (!thrown?.signature) return null;
5043
+ const repeated = project.repeatedErrors.get(thrown.signature);
5044
+ if (!repeated) return null;
5045
+ return {
5046
+ message: `"${repeated.label}" is repeated in ${repeated.files.length} files — one error catalog entry would cover them`,
5047
+ line: thrown.line
5048
+ };
5049
+ }
5050
+ },
5051
+ {
5052
+ id: "genai",
5053
+ category: "opportunity",
5054
+ title: "genai",
5055
+ question: "Are LLM calls traced with token usage and cost?",
5056
+ appliesTo: {
5057
+ kinds: HANDLER_KINDS,
5058
+ when: ({ project }) => project.hasLlmDependency
5059
+ },
5060
+ fix: () => "traceGenAI({ operation: 'chat', model }, async () => client.chat.completions.create(…));",
5061
+ check(context) {
5062
+ const llmCall = LLM_CALLS.flatMap((name) => context.facts.memberCalls(name)).find((call) => call.root !== null && call.root !== "JSON");
5063
+ if (!llmCall) return null;
5064
+ if (usesAutotelApi(context, GENAI_TRACERS)) return null;
5065
+ return {
5066
+ message: "LLM call with no gen_ai.* span — token usage and cost go unrecorded",
5067
+ line: llmCall.line
5068
+ };
5069
+ }
5070
+ },
5071
+ {
5072
+ id: "validation",
5073
+ category: "opportunity",
5074
+ title: "input",
5075
+ question: "Are rejected inputs visible in telemetry?",
5076
+ appliesTo: {
5077
+ kinds: HANDLER_KINDS,
5078
+ when: ({ project }) => project.hasZod
5079
+ },
5080
+ fix: () => "const parse = defineValidator('createOrder', schema, { boundary: 'http' });",
5081
+ check(context) {
5082
+ const parseCall = context.facts.memberCalls("parse")[0] ?? context.facts.memberCalls("safeParse")[0];
5083
+ if (!parseCall) return null;
5084
+ if (usesAutotelApi(context, ["defineValidator"])) return null;
5085
+ return {
5086
+ message: "input validated without telemetry — defineValidator() records which field failed",
5087
+ line: parseCall.line
5088
+ };
5089
+ }
5090
+ },
5091
+ {
5092
+ id: "redaction",
5093
+ category: "opportunity",
5094
+ scope: "project",
5095
+ title: "pii",
5096
+ question: "Is PII redacted before attributes leave the process?",
5097
+ appliesTo: {
5098
+ kinds: HANDLER_KINDS,
5099
+ when: ({ target, project }) => target.sensitivity.level !== "none" && project.hasAutotel && !project.hasRedaction
5100
+ },
5101
+ fix: () => "init({ service, attributeRedactor: 'default' })",
5102
+ check(context) {
5103
+ return { message: `sensitive routes but init() sets no attributeRedactor${context.project.instrumentationFile ? ` (${context.project.instrumentationFile})` : ""}` };
5104
+ }
5105
+ }
5106
+ ];
5107
+ const RULES_BY_ID = new Map(RULES.map((rule) => [rule.id, rule]));
5108
+ function getRule(id) {
5109
+ return RULES_BY_ID.get(id);
5110
+ }
5111
+ /** Rules that move the score, in report order. */
5112
+ const REQUIREMENTS = RULES.filter((rule) => rule.category === "requirement");
5113
+ /**
5114
+ * Run every applicable rule against one entry point.
5115
+ *
5116
+ * Rules that are irrelevant by kind are left out of the result entirely; rules
5117
+ * that are relevant but gated by `when` are reported as `n/a`, so the map
5118
+ * distinguishes "this question makes no sense here" from "this question does
5119
+ * not apply right now".
5120
+ */
5121
+ function runRules(context) {
5122
+ const results = {
5123
+ checks: {},
5124
+ suggestions: {},
5125
+ warnings: []
5126
+ };
5127
+ const { target } = context;
5128
+ const exemption = getRouteExemption(target);
5129
+ const relevant = RULES.filter((rule) => rule.appliesTo.kinds.includes(target.kind));
5130
+ const suppressions = collectSuppressions(context.source.getFullText());
5131
+ const knownIds = RULES.map((rule) => rule.id);
5132
+ for (const suppression of suppressions.unknown(knownIds)) results.warnings.push(`${target.file}:${suppression.declaredAt} disables "${suppression.id}", which is not a check autotel map runs`);
5133
+ for (const rule of relevant) {
5134
+ const bucket = rule.category === "requirement" ? results.checks : results.suggestions;
5135
+ if (exemption && isSkipped(exemption, rule.id)) {
5136
+ if (rule.category === "requirement") results.checks[rule.id] = {
5137
+ status: "n/a",
5138
+ message: exemption.reason
5139
+ };
5140
+ continue;
5141
+ }
5142
+ if (rule.appliesTo.when && !rule.appliesTo.when(context)) {
5143
+ if (rule.category === "requirement") results.checks[rule.id] = { status: "n/a" };
5144
+ continue;
5145
+ }
5146
+ const report = rule.check(context);
5147
+ if (!report) {
5148
+ if (rule.category === "requirement") results.checks[rule.id] = { status: "pass" };
5149
+ continue;
5150
+ }
5151
+ const line = report.line ?? target.handler?.line ?? 1;
5152
+ const disabled = suppressions.file(rule.id) ?? suppressions.at(rule.id, line);
5153
+ if (disabled) {
5154
+ if (rule.category === "requirement") results.checks[rule.id] = suppressedResult(disabled, target.file);
5155
+ continue;
5156
+ }
5157
+ bucket[rule.id] = {
5158
+ status: "fail",
5159
+ message: report.message,
5160
+ fix: rule.fix(context),
5161
+ evidence: {
5162
+ file: target.file,
5163
+ line,
5164
+ snippet: lineSnippet(context.source, line)
5165
+ }
5166
+ };
5167
+ }
5168
+ return results;
5169
+ }
5170
+ /**
5171
+ * Results for an entry point whose file could not be read or parsed.
5172
+ *
5173
+ * A file that will not parse is a real failure, but only of requirements —
5174
+ * there is no basis to suggest anything about code that could not be read.
5175
+ */
5176
+ function unreadableResults(target) {
5177
+ const results = {
5178
+ checks: {},
5179
+ suggestions: {},
5180
+ warnings: []
5181
+ };
5182
+ const exemption = getRouteExemption(target);
5183
+ for (const rule of RULES) {
5184
+ if (rule.category !== "requirement") continue;
5185
+ if (!rule.appliesTo.kinds.includes(target.kind)) continue;
5186
+ results.checks[rule.id] = exemption && isSkipped(exemption, rule.id) ? {
5187
+ status: "n/a",
5188
+ message: exemption.reason
5189
+ } : {
5190
+ status: "fail",
5191
+ message: "file could not be parsed",
5192
+ evidence: {
5193
+ file: target.file,
5194
+ line: 1
5195
+ }
5196
+ };
5197
+ }
5198
+ return results;
5199
+ }
5200
+ /**
5201
+ * A finding the author waived with a comment.
5202
+ *
5203
+ * `n/a` rather than `pass`: the rule did find something, and calling that a
5204
+ * pass would read as coverage the entry point does not have.
5205
+ */
5206
+ function suppressedResult(suppression, file) {
5207
+ return {
5208
+ status: "n/a",
5209
+ suppressed: true,
5210
+ message: suppressionMessage(suppression),
5211
+ evidence: {
5212
+ file,
5213
+ line: suppression.declaredAt
5214
+ }
5215
+ };
5216
+ }
5217
+ //#endregion
5218
+ //#region src/lib/map/score.ts
5219
+ /** Fallback weight for a rule id that is not in the registry. */
5220
+ const UNKNOWN_WEIGHT = 10;
5221
+ /**
5222
+ * Score one entry point from its requirement results.
5223
+ *
5224
+ * Opportunities are deliberately unreachable from here: they live in
5225
+ * `route.suggestions`, and their type carries no weight to subtract.
5226
+ */
5227
+ function scoreRoute(checks) {
5228
+ let score = 100;
5229
+ for (const [id, result] of Object.entries(checks)) {
5230
+ if (result.status !== "fail") continue;
5231
+ const rule = getRule(id);
5232
+ if (rule && rule.category !== "requirement") continue;
5233
+ score -= rule?.category === "requirement" ? rule.weight : UNKNOWN_WEIGHT;
5234
+ }
5235
+ return Math.max(0, score);
5236
+ }
5237
+ /**
5238
+ * Weighted average of the per-entry scores.
5239
+ *
5240
+ * A sensitive handler counts double: the weights say which entry points the
5241
+ * number should follow.
5242
+ *
5243
+ * Exempt entries are left out entirely. Every rule is `n/a` for them, so they
5244
+ * score a free 100, and averaging those in would let a project of health checks
5245
+ * report a high score while its real handlers are dark.
5246
+ */
5247
+ function scoreGlobal(routes) {
5248
+ const scored = routes.filter((route) => !isExemptRoute(route));
5249
+ if (scored.length === 0) return 100;
5250
+ let totalWeight = 0;
5251
+ let weightedSum = 0;
5252
+ for (const route of scored) {
5253
+ const weight = route.sensitivity.level === "high" ? 2 : 1;
5254
+ totalWeight += weight;
5255
+ weightedSum += route.score * weight;
5256
+ }
5257
+ return Math.round(weightedSum / totalWeight);
5258
+ }
5259
+ /** Grade band a score falls into, at 90 / 70 / 50. */
5260
+ function gradeFromScore(score) {
5261
+ if (score >= 90) return "excellent";
5262
+ if (score >= 70) return "good";
5263
+ if (score >= 50) return "needs-work";
5264
+ return "at-risk";
5265
+ }
5266
+ /**
5267
+ * How much of an entry point the map can actually see.
5268
+ *
5269
+ * `exempt` covers entry points with nothing to instrument; it is not part of
5270
+ * the unobserved tally, because it is not a gap anyone should close.
5271
+ */
5272
+ function classifyRouteObservability(route) {
5273
+ if (isExemptRoute(route)) return "exempt";
5274
+ if (route.kind === "page") {
5275
+ const applicable = Object.values(route.checks).filter((check) => check?.status !== "n/a");
5276
+ if (applicable.length === 0) return "exempt";
5277
+ const passed = applicable.filter((check) => check?.status === "pass").length;
5278
+ if (passed === applicable.length) return "instrumented";
5279
+ if (passed > 0) return "partial";
5280
+ return "dark";
5281
+ }
5282
+ const span = route.checks["trace"]?.status === "pass";
5283
+ const context = route.checks["context"]?.status === "pass";
5284
+ if (span && context) return "instrumented";
5285
+ if (span || context) return "partial";
5286
+ return "dark";
5287
+ }
5288
+ /** The one line to show next to an entry point: its heaviest unmet requirement. */
5289
+ function topIssue(route) {
5290
+ let heaviest;
5291
+ for (const rule of REQUIREMENTS) if (route.checks[rule.id]?.status === "fail" && (!heaviest || rule.weight > heaviest.weight)) heaviest = rule;
5292
+ return heaviest ? route.checks[heaviest.id]?.message ?? heaviest.id : "ok";
5293
+ }
5294
+ //#endregion
5295
+ //#region src/lib/map/baseline.ts
5296
+ /** Committed alongside the code, so the score can be tracked over time. */
5297
+ const MAP_FILE_NAME = "autotel.map.json";
5298
+ function writeMapFile(projectRoot, map) {
5299
+ const target = path$1.join(projectRoot, MAP_FILE_NAME);
5300
+ atomicWrite(target, `${JSON.stringify(map, null, 2)}\n`, { root: projectRoot });
5301
+ return target;
5302
+ }
5303
+ function baselineError(source, reason) {
5304
+ return new AutotelError({
5305
+ type: "validation",
5306
+ code: AutotelErrorCodes.E_INVALID_INPUT,
5307
+ message: `Baseline "${source}" ${reason}`,
5308
+ fix: `Run "autotel map" on the baseline commit and commit ${MAP_FILE_NAME}, or pass --baseline git:<ref>.`
5309
+ });
5310
+ }
5311
+ function readGitBaseline(cwd, ref) {
5312
+ try {
5313
+ return execFileSync("git", [
5314
+ "-C",
5315
+ cwd,
5316
+ "show",
5317
+ `${ref}:${execFileSync("git", [
5318
+ "-C",
5319
+ cwd,
5320
+ "rev-parse",
5321
+ "--show-prefix"
5322
+ ], {
5323
+ encoding: "utf8",
5324
+ stdio: [
5325
+ "ignore",
5326
+ "pipe",
5327
+ "ignore"
5328
+ ]
5329
+ }).trim()}${MAP_FILE_NAME}`
5330
+ ], {
5331
+ encoding: "utf8",
5332
+ stdio: [
5333
+ "ignore",
5334
+ "pipe",
5335
+ "ignore"
5336
+ ],
5337
+ maxBuffer: 64 * 1024 * 1024
5338
+ });
5339
+ } catch {
5340
+ return null;
5341
+ }
5342
+ }
5343
+ function parseMapFile(raw, label) {
5344
+ let parsed;
5345
+ try {
5346
+ parsed = JSON.parse(raw);
5347
+ } catch {
5348
+ throw baselineError(label, "is not valid JSON");
5349
+ }
5350
+ const map = parsed;
5351
+ if (map?.version !== 1 || !Array.isArray(map.routes) || typeof map.score !== "number") throw baselineError(label, `is not an ${MAP_FILE_NAME} (version 1)`);
5352
+ return map;
5353
+ }
5354
+ /**
5355
+ * Read the map to compare against. Local-only: no network, no token, no
5356
+ * repository access, so a private repo gates like a public one.
5357
+ *
5358
+ * @param spec `git:<ref>` to read the committed copy through git, otherwise a
5359
+ * path. Defaults to `autotel.map.json`, falling back to `git:HEAD`.
5360
+ */
5361
+ function loadBaseline(projectRoot, spec) {
5362
+ if (spec?.startsWith("git:")) {
5363
+ const raw = readGitBaseline(projectRoot, spec.slice(4) || "HEAD");
5364
+ if (raw === null) throw baselineError(spec, "could not be read");
5365
+ return {
5366
+ map: parseMapFile(raw, spec),
5367
+ source: {
5368
+ kind: "git",
5369
+ label: spec
5370
+ }
5371
+ };
5372
+ }
5373
+ if (spec) {
5374
+ const target = path$1.isAbsolute(spec) ? spec : path$1.resolve(projectRoot, spec);
5375
+ let raw;
5376
+ try {
5377
+ raw = fs.readFileSync(target, "utf8");
5378
+ } catch {
5379
+ throw baselineError(spec, "could not be read");
5380
+ }
5381
+ return {
5382
+ map: parseMapFile(raw, spec),
5383
+ source: {
5384
+ kind: "file",
5385
+ label: spec
5386
+ }
5387
+ };
5388
+ }
5389
+ let raw;
5390
+ try {
5391
+ raw = fs.readFileSync(path$1.resolve(projectRoot, MAP_FILE_NAME), "utf8");
5392
+ } catch (error) {
5393
+ if (error.code !== "ENOENT") throw baselineError(MAP_FILE_NAME, "could not be read");
5394
+ const gitRaw = readGitBaseline(projectRoot, "HEAD");
5395
+ if (gitRaw === null) throw baselineError(MAP_FILE_NAME, "could not be read");
5396
+ return {
5397
+ map: parseMapFile(gitRaw, "git:HEAD"),
5398
+ source: {
5399
+ kind: "git",
5400
+ label: "git:HEAD"
5401
+ }
5402
+ };
5403
+ }
5404
+ return {
5405
+ map: parseMapFile(raw, MAP_FILE_NAME),
5406
+ source: {
5407
+ kind: "file",
5408
+ label: MAP_FILE_NAME
5409
+ }
5410
+ };
5411
+ }
5412
+ /**
5413
+ * Compare a fresh scan against a baseline, per entry point and per check.
5414
+ *
5415
+ * The unit is the requirement, not the score: a refactor that instruments one
5416
+ * route and breaks another leaves the number untouched. A deleted route is not
5417
+ * a regression, and a new dark one is reported but does not gate — that bar is
5418
+ * `--min-score`'s job.
5419
+ */
5420
+ function compareToBaseline(baseline, current, source) {
5421
+ const currentById = new Map(current.routes.map((route) => [route.id, route]));
5422
+ const baselineById = new Map(baseline.routes.map((route) => [route.id, route]));
5423
+ const regressions = [];
5424
+ const fixed = [];
5425
+ const removed = [];
5426
+ for (const before of baseline.routes) {
5427
+ const after = currentById.get(before.id);
5428
+ if (!after) {
5429
+ removed.push({
5430
+ path: before.path,
5431
+ method: before.method
5432
+ });
5433
+ continue;
5434
+ }
5435
+ for (const id of Object.keys(before.checks)) {
5436
+ const was = before.checks[id];
5437
+ const now = after.checks[id];
5438
+ if (!was || !now) continue;
5439
+ const entry = {
5440
+ routeId: after.id,
5441
+ path: after.path,
5442
+ method: after.method,
5443
+ file: after.file,
5444
+ check: id
5445
+ };
5446
+ if (was.status === "pass" && now.status === "fail") regressions.push({
5447
+ ...entry,
5448
+ to: "fail"
5449
+ });
5450
+ else if (was.status === "pass" && now.status === "n/a" && now.suppressed) regressions.push({
5451
+ ...entry,
5452
+ to: "suppressed"
5453
+ });
5454
+ else if (was.status === "fail" && now.status === "pass") fixed.push(entry);
5455
+ }
5456
+ }
5457
+ const added = current.routes.filter((route) => !baselineById.has(route.id)).map((route) => ({
5458
+ path: route.path,
5459
+ method: route.method,
5460
+ file: route.file,
5461
+ dark: classifyRouteObservability(route) === "dark"
5462
+ }));
5463
+ const carried = current.routes.filter((route) => baselineById.has(route.id));
5464
+ const carriedBefore = baseline.routes.filter((route) => currentById.has(route.id));
5465
+ return {
5466
+ source,
5467
+ baselineScore: baseline.score,
5468
+ score: current.score,
5469
+ delta: scoreGlobal(carried) - scoreGlobal(carriedBefore),
5470
+ totalDelta: current.score - baseline.score,
5471
+ regressions,
5472
+ fixed,
5473
+ added,
5474
+ removed
5475
+ };
5476
+ }
5477
+ /** Whether the comparison should fail the command (exit 1). */
5478
+ function hasRegressed(comparison) {
5479
+ return comparison.regressions.length > 0 || comparison.delta < 0;
5480
+ }
5481
+ //#endregion
5482
+ //#region src/lib/map/project-facts.ts
5483
+ const LLM_PACKAGES = [
5484
+ "openai",
5485
+ "@anthropic-ai/sdk",
5486
+ "ai",
5487
+ "@ai-sdk/openai",
5488
+ "@ai-sdk/anthropic",
5489
+ "@ai-sdk/google",
5490
+ "ai-sdk-ollama",
5491
+ "ollama",
5492
+ "@google/generative-ai",
5493
+ "@mistralai/mistralai",
5494
+ "cohere-ai",
5495
+ "langchain",
5496
+ "@langchain/core"
5497
+ ];
5498
+ const RUNTIME_AUTOTEL_PACKAGES = [
5499
+ "autotel",
5500
+ "autotel-edge",
5501
+ "autotel-cloudflare",
5502
+ "autotel-nuxt",
5503
+ "autotel-tanstack",
5504
+ "autotel-hono",
5505
+ "autotel-adapters"
5506
+ ];
5507
+ /**
5508
+ * Files where a framework integration is wired up.
5509
+ *
5510
+ * Kept to a fixed list rather than a glob: this answers "did anyone install the
5511
+ * middleware", and the answer is always in the app's entry file or its hooks.
5512
+ */
5513
+ const WIRING_FILES = [
5514
+ "src/index.ts",
5515
+ "src/app.ts",
5516
+ "src/server.ts",
5517
+ "src/main.ts",
5518
+ "index.ts",
5519
+ "app.ts",
5520
+ "server.ts",
5521
+ "middleware.ts",
5522
+ "src/middleware.ts",
5523
+ "src/hooks.server.ts",
5524
+ "hooks.server.ts",
5525
+ "nuxt.config.ts",
5526
+ "src/router.tsx",
5527
+ "app/router.tsx"
5528
+ ];
5529
+ /** Autotel framework integrations that create a request span when called. */
5530
+ const AMBIENT_APIS = ["autotelMiddleware", "autotelHandle"];
5531
+ function hasAmbientTracing(projectRoot, instrumentationPath) {
5532
+ const parse = createParser();
5533
+ const candidates = new Set([instrumentationPath, ...WIRING_FILES.map((file) => path$1.join(projectRoot, file))].filter((file) => file !== null && fileExists(file)));
5534
+ for (const file of candidates) {
5535
+ const source = parse(file);
5536
+ if (source && usesAutotelApi$1(buildFileFacts(source), AMBIENT_APIS)) return true;
5537
+ }
5538
+ return false;
5539
+ }
5540
+ function projectSource(projectRoot) {
5541
+ const files = globSync("{src,app,server,routes,pages}/**/*.{ts,tsx,js,jsx,mts,mjs}", {
5542
+ cwd: projectRoot,
5543
+ absolute: true,
5544
+ ignore: [
5545
+ "**/node_modules/**",
5546
+ "**/dist/**",
5547
+ "**/*.test.*",
5548
+ "**/*.spec.*"
5549
+ ]
5550
+ });
5551
+ const occurrences = /* @__PURE__ */ new Map();
5552
+ const chunks = [];
5553
+ const call = /createStructuredError\s*\(\s*\{([\s\S]*?)\}\s*\)/g;
5554
+ for (const file of files) {
5555
+ const source = readFileSafe(file) ?? "";
5556
+ chunks.push(source);
5557
+ for (const match of source.matchAll(call)) {
5558
+ const body = match[1] ?? "";
5559
+ const message = /\bmessage\s*:\s*(['"`])([\s\S]*?)\1/.exec(body)?.[2];
5560
+ const status = /\bstatus\s*:\s*(\d+)/.exec(body)?.[1];
5561
+ if (!message || !status) continue;
5562
+ const signature = `${status}|${message}`;
5563
+ const entry = occurrences.get(signature) ?? {
5564
+ label: message,
5565
+ files: /* @__PURE__ */ new Set()
5566
+ };
5567
+ entry.files.add(path$1.relative(projectRoot, file).split(path$1.sep).join("/"));
5568
+ occurrences.set(signature, entry);
5569
+ }
5570
+ }
5571
+ return {
5572
+ text: chunks.join("\n"),
5573
+ repeatedErrors: new Map([...occurrences].filter(([, entry]) => entry.files.size > 1).map(([signature, entry]) => [signature, {
5574
+ label: entry.label,
5575
+ files: [...entry.files].toSorted()
5576
+ }]))
5577
+ };
5578
+ }
5579
+ function collectDeps(pkg) {
5580
+ return /* @__PURE__ */ new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {})]);
5581
+ }
5582
+ function collectProjectFacts(projectRoot) {
5583
+ const pkg = readJsonSafe(path$1.join(projectRoot, "package.json"));
5584
+ const deps = collectDeps(pkg);
5585
+ const instrumentation = findInstrumentationFile(projectRoot);
5586
+ const instrumentationText = instrumentation ? readFileSafe(instrumentation.path) ?? "" : "";
5587
+ const ambientText = `${instrumentationText}\n${WIRING_FILES.map((candidate) => fileExists(path$1.join(projectRoot, candidate)) ? readFileSafe(path$1.join(projectRoot, candidate)) ?? "" : "").join("\n")}`;
5588
+ const source = projectSource(projectRoot);
5589
+ const projectSignalText = `${ambientText}\n${source.text}`;
5590
+ return {
5591
+ name: pkg?.name ?? path$1.basename(projectRoot),
5592
+ deps,
5593
+ hasAutotel: RUNTIME_AUTOTEL_PACKAGES.some((name) => deps.has(name)),
5594
+ ambientTracing: deps.has("autotel-nuxt") || hasAmbientTracing(projectRoot, instrumentation?.path ?? null),
5595
+ hasRedaction: instrumentationText.includes("attributeRedactor"),
5596
+ instrumentationFile: instrumentation ? path$1.relative(projectRoot, instrumentation.path).split(path$1.sep).join("/") : null,
5597
+ hasLlmDependency: LLM_PACKAGES.some((name) => deps.has(name)),
5598
+ hasZod: deps.has("zod"),
5599
+ hasAuditUsage: deps.has("autotel-audit") && /securityEvent|withSecurity|withAudit|defineAuditCatalog/.test(projectSignalText),
5600
+ hasErrorCatalog: projectSignalText.includes("defineErrorCatalog"),
5601
+ repeatedErrors: source.repeatedErrors
5602
+ };
5603
+ }
5604
+ //#endregion
5605
+ //#region src/lib/map/sensitivity.ts
5606
+ const MONEY_PACKAGES = [
5607
+ "stripe",
5608
+ "@stripe/stripe-js",
5609
+ "@paddle/paddle-node-sdk",
5610
+ "@lemonsqueezy/lemonsqueezy.js",
5611
+ "braintree"
5612
+ ];
5613
+ const AUTH_PACKAGES = [
5614
+ "better-auth",
5615
+ "next-auth",
5616
+ "lucia",
5617
+ "@auth/core",
5618
+ "@clerk/nextjs",
5619
+ "@clerk/backend",
5620
+ "passport"
5621
+ ];
5622
+ const MONEY_TERMS = [
5623
+ "checkout",
5624
+ "payment",
5625
+ "billing",
5626
+ "invoice",
5627
+ "refund",
5628
+ "subscription",
5629
+ "charge",
5630
+ "payout",
5631
+ "order"
5632
+ ];
5633
+ const AUTH_TERMS = [
5634
+ "auth",
5635
+ "oauth",
5636
+ "login",
5637
+ "logout",
5638
+ "signin",
5639
+ "signup",
5640
+ "register",
5641
+ "password",
5642
+ "token",
5643
+ "session",
5644
+ "mfa",
5645
+ "otp",
5646
+ "admin"
5647
+ ];
5648
+ /**
5649
+ * Whole-word matcher per term, allowing a plural.
5650
+ *
5651
+ * Anchoring matters more than it looks: with plain substrings `/api/authors`
5652
+ * reads as authentication. A route wrongly marked sensitive is handed a
5653
+ * 25-point audit requirement it has no reason to satisfy, and counts double in
5654
+ * the global score — the fastest way to make the number untrustworthy.
5655
+ */
5656
+ function compileTerms(terms) {
5657
+ return terms.map((term) => [term, new RegExp(`(?:^|[^a-z0-9])${term}s?(?:[^a-z0-9]|$)`, "i")]);
5658
+ }
5659
+ const MONEY_PATTERNS = compileTerms(MONEY_TERMS);
5660
+ const AUTH_PATTERNS = compileTerms(AUTH_TERMS);
5661
+ function matchTerm(value, patterns) {
5662
+ for (const [term, pattern] of patterns) if (pattern.test(value)) return term;
5663
+ return null;
5664
+ }
5665
+ const PII_FIELDS = /email|phone|address|ssn|iban|dateOfBirth|passport/i;
5666
+ const WRITE_CALLS = /* @__PURE__ */ new Set([
5667
+ "create",
5668
+ "update",
5669
+ "insert",
5670
+ "upsert",
5671
+ "delete"
5672
+ ]);
5673
+ function importsPackage(facts, pkg) {
5674
+ for (const specifier of facts.modules) if (specifier === pkg || specifier.startsWith(`${pkg}/`)) return true;
5675
+ return false;
5676
+ }
5677
+ /**
5678
+ * Classify an entry point as money / auth / PII sensitive.
5679
+ *
5680
+ * Reads resolved imports and the identifiers actually in the AST rather than
5681
+ * searching raw source: substring matching cannot tell an import from a
5682
+ * comment, so a `// TODO: drop stripe` would be enough to mark a route as
5683
+ * handling money.
5684
+ */
5685
+ function classifySensitivity(route, facts) {
5686
+ const reasons = [];
5687
+ for (const pkg of MONEY_PACKAGES) if (importsPackage(facts, pkg)) reasons.push(`money: imports ${pkg}`);
5688
+ const moneyTerm = matchTerm(route.path, MONEY_PATTERNS);
5689
+ if (moneyTerm) reasons.push(`money: path says "${moneyTerm}"`);
5690
+ for (const pkg of AUTH_PACKAGES) if (importsPackage(facts, pkg)) reasons.push(`auth: imports ${pkg}`);
5691
+ const authTerm = matchTerm(route.path, AUTH_PATTERNS);
5692
+ if (authTerm) reasons.push(`auth: path says "${authTerm}"`);
5693
+ const touchesPii = [...facts.names].some((name) => PII_FIELDS.test(name));
5694
+ const writes = facts.calls.some((call) => WRITE_CALLS.has(call.member.toLowerCase()));
5695
+ if (touchesPii && writes) reasons.push("pii: write operation with sensitive fields");
5696
+ const hasMoney = reasons.some((reason) => reason.startsWith("money:"));
5697
+ const hasAuth = reasons.some((reason) => reason.startsWith("auth:"));
5698
+ const hasPii = reasons.some((reason) => reason.startsWith("pii:"));
5699
+ if (hasMoney || hasAuth) return {
5700
+ level: "high",
5701
+ reasons
5702
+ };
5703
+ if (hasPii) return {
5704
+ level: "medium",
5705
+ reasons
5706
+ };
5707
+ return {
5708
+ level: "none",
5709
+ reasons: []
5710
+ };
5711
+ }
5712
+ /** What makes this entry point sensitive — `money`, `auth`, `pii`, or nothing. */
5713
+ function sensitivityLabel(sensitivity) {
5714
+ if (sensitivity.reasons.some((r) => r.startsWith("money:"))) return "money";
5715
+ if (sensitivity.reasons.some((r) => r.startsWith("auth:"))) return "auth";
5716
+ if (sensitivity.level === "medium") return "pii";
5717
+ return "";
5718
+ }
5719
+ //#endregion
5720
+ //#region src/lib/map/report.ts
5721
+ const GRADE_COLOUR = {
5722
+ excellent: chalk.green,
5723
+ good: chalk.green,
5724
+ "needs-work": chalk.yellow,
5725
+ "at-risk": chalk.red
5726
+ };
5727
+ /** Entry-point label without colour — the one width calculations can trust. */
5728
+ function plainLabel(route) {
5729
+ const method = route.method ? `${route.method} ` : "";
5730
+ const badge = sensitivityLabel(route.sensitivity);
5731
+ return `${method}${route.path}${badge ? ` [${badge}]` : ""}`;
5732
+ }
5733
+ function label(route) {
5734
+ const method = route.method ? `${route.method} ` : "";
5735
+ const badge = sensitivityLabel(route.sensitivity);
5736
+ return `${method}${route.path}${badge ? chalk.magenta(` [${badge}]`) : ""}`;
5737
+ }
5738
+ /** Points this entry point is losing, weighted the way the global score weighs it. */
5739
+ function lostPoints(route) {
5740
+ const weight = route.sensitivity.level === "high" ? 2 : 1;
5741
+ return (100 - route.score) * weight;
5742
+ }
5743
+ /**
5744
+ * The default view: the number, the tally, and the three entry points worth
5745
+ * fixing first.
5746
+ *
5747
+ * Three rather than all of them on purpose — a list of forty gaps is a list
5748
+ * nobody starts. `--all` is one keystroke away when the whole picture is wanted.
5749
+ */
5750
+ function formatReport(result, options = {}) {
5751
+ const { map, summary, grade } = result;
5752
+ const lines = [];
5753
+ const colour = GRADE_COLOUR[grade];
5754
+ lines.push("");
5755
+ lines.push(chalk.bold(`Observability map — ${map.projectName} (${map.framework})`));
5756
+ lines.push("");
5757
+ lines.push(` ${colour(chalk.bold(`${map.score}/100`))} ${colour(grade)}`);
5758
+ 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` : "")));
5759
+ const worst = map.routes.filter((route) => classifyRouteObservability(route) !== "exempt" && route.score < 100).toSorted((a, b) => lostPoints(b) - lostPoints(a)).slice(0, 3);
5760
+ if (worst.length > 0) {
5761
+ lines.push("");
5762
+ lines.push(chalk.bold("Fix these first"));
5763
+ for (const [index, route] of worst.entries()) {
5764
+ lines.push(` ${index + 1}. ${label(route)}`);
5765
+ lines.push(` ${chalk.yellow(topIssue(route))}`);
5766
+ lines.push(chalk.dim(` ${route.file}${route.handler ? `:${route.handler.line}` : ""} (${route.score}/100)`));
5767
+ }
5768
+ }
5769
+ const perRoute = map.routes.flatMap((route) => Object.entries(route.suggestions).filter(([, check]) => check?.status === "fail").map(([, check]) => `${label(route)} — ${check?.message ?? ""}`));
5770
+ const suggestions = [...result.suggestions.map((suggestion) => suggestion.message), ...perRoute.slice(0, 3)];
5771
+ if (suggestions.length > 0) {
5772
+ lines.push("");
5773
+ lines.push(chalk.bold("Suggestions"));
5774
+ for (const suggestion of suggestions) lines.push(chalk.dim(` · ${suggestion}`));
5775
+ }
5776
+ lines.push("");
5777
+ if (options.mapPath) lines.push(chalk.dim(`Wrote ${options.mapPath}`));
5778
+ lines.push(chalk.cyan("Next: autotel map --all · autotel map <route|file> · autotel map --json"));
5779
+ return lines.join("\n");
5780
+ }
5781
+ /** Every entry point as a check matrix — one column per requirement. */
5782
+ function formatMatrix(result) {
5783
+ const lines = [""];
5784
+ const columns = REQUIREMENTS.map((rule) => rule.id);
5785
+ const headers = REQUIREMENTS.map((rule) => rule.title);
5786
+ const nameWidth = Math.max(20, ...result.map.routes.map((route) => plainLabel(route).length));
5787
+ lines.push(chalk.bold(`${"entry point".padEnd(nameWidth)} ${headers.map((header) => header.padEnd(8)).join("")}score`));
5788
+ for (const route of result.map.routes) {
5789
+ const padding = " ".repeat(Math.max(0, nameWidth - plainLabel(route).length));
5790
+ const cells = columns.map((id) => mark(route.checks[id]?.status).padEnd(8)).join("");
5791
+ lines.push(`${label(route)}${padding} ${cells}${route.score}`);
5792
+ }
5793
+ lines.push("");
5794
+ lines.push(chalk.dim("✓ pass ✗ fail – not applicable"));
5795
+ return lines.join("\n");
5796
+ }
5797
+ function mark(status) {
5798
+ if (status === "pass") return chalk.green("✓");
5799
+ if (status === "fail") return chalk.red("✗");
5800
+ return chalk.dim("–");
5801
+ }
5802
+ /** Find an entry point by route path or file path. */
5803
+ function findEntry(result, query) {
5804
+ const needle = query.toLowerCase();
5805
+ return result.map.routes.find((route) => route.file.toLowerCase() === needle) ?? result.map.routes.find((route) => route.path.toLowerCase() === needle) ?? result.map.routes.find((route) => route.file.toLowerCase().includes(needle) || route.path.toLowerCase().includes(needle));
5806
+ }
5807
+ /** One entry point in full: every rule, its verdict, and the code that fixes it. */
5808
+ function formatInspect(route) {
5809
+ const lines = [
5810
+ "",
5811
+ chalk.bold(label(route)),
5812
+ chalk.dim(`${route.file}${route.handler ? `:${route.handler.line}` : ""} · ${route.kind} · ${route.score}/100`)
5813
+ ];
5814
+ if (route.sensitivity.reasons.length > 0) lines.push(chalk.magenta(`sensitive: ${route.sensitivity.reasons.join(", ")}`));
5815
+ lines.push("");
5816
+ for (const rule of RULES) {
5817
+ const result = route.checks[rule.id] ?? route.suggestions[rule.id];
5818
+ if (!result) continue;
5819
+ if (result.status === "n/a" && !result.suppressed) continue;
5820
+ const kind = rule.category === "requirement" ? "" : chalk.dim(" (suggestion)");
5821
+ lines.push(`${mark(result.status)} ${chalk.bold(rule.title)}${kind}`);
5822
+ lines.push(` ${chalk.dim(rule.question)}`);
5823
+ if (result.message) lines.push(` ${result.message}`);
5824
+ if (result.evidence?.snippet) lines.push(chalk.dim(` ${result.evidence.line}| ${result.evidence.snippet}`));
5825
+ if (result.status === "fail" && result.fix) lines.push(chalk.cyan(` → ${result.fix}`));
5826
+ lines.push("");
5827
+ }
5828
+ return lines.join("\n");
5829
+ }
5830
+ function formatEntryNotFound(result, query) {
5831
+ const known = result.map.routes.slice(0, 10).map((route) => ` ${label(route)} ${chalk.dim(route.file)}`);
5832
+ return [
5833
+ "",
5834
+ chalk.yellow(`No entry point matches "${query}".`),
5835
+ "",
5836
+ chalk.dim("Known entry points:"),
5837
+ ...known
5838
+ ].join("\n");
5839
+ }
5840
+ function formatWarnings(warnings) {
5841
+ return ["", ...warnings.map((warning) => chalk.yellow(`! ${warning}`))].join("\n");
5842
+ }
5843
+ function formatBaseline(comparison) {
5844
+ const lines = ["", chalk.bold(`Baseline (${comparison.source.label})`)];
5845
+ const sign = comparison.totalDelta >= 0 ? "+" : "";
5846
+ lines.push(` ${comparison.baselineScore} → ${comparison.score} (${sign}${comparison.totalDelta})`);
5847
+ for (const regression of comparison.regressions) {
5848
+ const rule = getRule(regression.check);
5849
+ lines.push(chalk.red(` ✗ ${regression.method ?? ""} ${regression.path} — "${rule?.title ?? regression.check}" now ${regression.to}`));
5850
+ lines.push(chalk.dim(` ${regression.file}`));
5851
+ }
5852
+ for (const fix of comparison.fixed) {
5853
+ const rule = getRule(fix.check);
5854
+ lines.push(chalk.green(` ✓ ${fix.method ?? ""} ${fix.path} — "${rule?.title ?? fix.check}" fixed`));
5855
+ }
5856
+ const darkAdded = comparison.added.filter((route) => route.dark);
5857
+ if (darkAdded.length > 0) {
5858
+ lines.push(chalk.yellow(` ${darkAdded.length} new entry point${darkAdded.length === 1 ? "" : "s"} with no instrumentation`));
5859
+ for (const route of darkAdded) lines.push(chalk.dim(` ${route.method ?? ""} ${route.path} (${route.file})`));
5860
+ }
5861
+ if (comparison.removed.length > 0) lines.push(chalk.dim(` ${comparison.removed.length} entry point(s) removed`));
5862
+ return lines.join("\n");
5863
+ }
5864
+ function formatGate(score, threshold) {
5865
+ return score < threshold ? chalk.red(`\nScore ${score} is below --min-score ${threshold}.`) : chalk.green(`\nScore ${score} meets --min-score ${threshold}.`);
5866
+ }
5867
+ //#endregion
5868
+ //#region src/lib/map/scan.ts
5869
+ /** Stable identity for an entry point, so a baseline can match it across runs. */
5870
+ function routeId(route) {
5871
+ return `${route.method ?? "*"} ${route.path} (${route.file})`;
5872
+ }
5873
+ /** Find entry points for `framework`, run the rules against each, and score them. */
5874
+ function scan(options) {
5875
+ const { projectRoot, framework } = options;
5876
+ const parse = createParser();
5877
+ const project = options.project ?? collectProjectFacts(projectRoot);
5878
+ const adapter = getAdapter(framework);
5879
+ const warnings = [];
5880
+ const routes = [];
5881
+ const factsCache = /* @__PURE__ */ new Map();
5882
+ for (const raw of adapter.extractRoutes({
5883
+ projectRoot,
5884
+ parse
5885
+ })) {
5886
+ const source = parse(path$1.join(projectRoot, raw.file));
5887
+ let facts = null;
5888
+ if (source) {
5889
+ const scope = adapter.scopesFor?.(raw, source);
5890
+ if (scope) facts = buildFileFacts(source, scope);
5891
+ else {
5892
+ const cached = factsCache.get(raw.file);
5893
+ facts = cached ?? buildFileFacts(source);
5894
+ if (!cached) factsCache.set(raw.file, facts);
5895
+ }
5896
+ }
5897
+ const sensitivity = facts !== null ? classifySensitivity(raw, facts) : {
5898
+ level: "none",
5899
+ reasons: []
5900
+ };
5901
+ const target = {
5902
+ ...raw,
5903
+ sensitivity
5904
+ };
5905
+ const results = source !== null && facts !== null ? runRules({
5906
+ target,
5907
+ facts,
5908
+ project,
5909
+ autoImports: adapter.autoImports,
5910
+ source
5911
+ }) : unreadableResults(target);
5912
+ warnings.push(...results.warnings);
5913
+ routes.push({
5914
+ ...raw,
5915
+ id: routeId(raw),
5916
+ checks: results.checks,
5917
+ suggestions: results.suggestions,
5918
+ sensitivity,
5919
+ score: scoreRoute(results.checks)
5920
+ });
5921
+ }
5922
+ const suggestions = hoistProjectSuggestions(routes);
5923
+ const score = scoreGlobal(routes);
5924
+ const summary = {
5925
+ instrumented: 0,
5926
+ partial: 0,
5927
+ dark: 0,
5928
+ exempt: 0,
5929
+ suppressedChecks: 0
5930
+ };
5931
+ for (const route of routes) {
5932
+ summary[classifyRouteObservability(route)]++;
5933
+ summary.suppressedChecks += countSuppressed(route);
5934
+ }
5935
+ return {
5936
+ map: {
5937
+ version: 1,
5938
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5939
+ framework,
5940
+ projectName: project.name,
5941
+ score,
5942
+ routes
5943
+ },
5944
+ grade: gradeFromScore(score),
5945
+ suggestions,
5946
+ warnings: [...new Set(warnings)],
5947
+ summary
5948
+ };
5949
+ }
5950
+ /**
5951
+ * Move project-scoped suggestions off the routes and into one list.
5952
+ *
5953
+ * Setting `attributeRedactor` is a single edit, so leaving a copy on every
5954
+ * entry point where PII is in play would make the report claim there are five
5955
+ * things to do. The first entry point that raised it keeps the evidence, which
5956
+ * is where the reader should look first.
5957
+ */
5958
+ function hoistProjectSuggestions(routes) {
5959
+ const hoisted = /* @__PURE__ */ new Map();
5960
+ for (const route of routes) for (const [id, result] of Object.entries(route.suggestions)) {
5961
+ const rule = getRule(id);
5962
+ if (rule?.category !== "opportunity" || rule.scope !== "project") continue;
5963
+ delete route.suggestions[id];
5964
+ if (hoisted.has(id) || result.status !== "fail") continue;
5965
+ hoisted.set(id, {
5966
+ id,
5967
+ message: result.message ?? rule.question,
5968
+ ...result.evidence ? { evidence: result.evidence } : {}
5969
+ });
5970
+ }
5971
+ return [...hoisted.values()];
5972
+ }
5973
+ //#endregion
5974
+ //#region src/commands/map.ts
5975
+ /**
5976
+ * Read `--min-score`, rejecting anything that is not a whole 0-100.
5977
+ *
5978
+ * The whole string has to parse: `parseInt` reads `80oops` as 80 and `abc` as
5979
+ * nothing at all, and a threshold that quietly becomes `undefined` turns the
5980
+ * gate off — CI then reports success for a bar it never checked.
5981
+ */
5982
+ function parseMinScore(value) {
5983
+ if (value === void 0 || value === "") return void 0;
5984
+ const threshold = Number(value);
5985
+ if (!Number.isInteger(threshold) || threshold < 0 || threshold > 100) throw new AutotelError({
5986
+ type: "validation",
5987
+ code: AutotelErrorCodes.E_INVALID_FLAG,
5988
+ message: `--min-score expects a whole number from 0 to 100, got "${value}"`,
5989
+ fix: "Pass e.g. --min-score 70"
5990
+ });
5991
+ return threshold;
5992
+ }
5993
+ function resolveFramework(requested, projectRoot, deps) {
5994
+ if (requested !== void 0 && requested !== "") {
5995
+ if (!isFramework(requested)) throw new AutotelError({
5996
+ type: "validation",
5997
+ code: AutotelErrorCodes.E_INVALID_FLAG,
5998
+ message: `Unknown framework "${requested}"`,
5999
+ fix: `Use one of: ${SUPPORTED_FRAMEWORKS.join(", ")}`,
6000
+ expected: { frameworks: [...SUPPORTED_FRAMEWORKS] }
6001
+ });
6002
+ return {
6003
+ framework: requested,
6004
+ warnings: []
6005
+ };
6006
+ }
6007
+ const detected = detectFramework(projectRoot, deps);
6008
+ if (!detected.framework) throw new AutotelError({
6009
+ type: "environment",
6010
+ code: AutotelErrorCodes.E_INVALID_INPUT,
6011
+ message: "Could not detect a supported framework in this project",
6012
+ fix: `Pass --framework <name>. Supported: ${SUPPORTED_FRAMEWORKS.join(", ")}`,
6013
+ expected: { frameworks: [...SUPPORTED_FRAMEWORKS] }
6014
+ });
6015
+ return {
6016
+ framework: detected.framework,
6017
+ warnings: detected.warnings
6018
+ };
6019
+ }
6020
+ /**
6021
+ * Scan the project for entry points and score their observability.
6022
+ *
6023
+ * Pure with respect to the filesystem except for the `autotel.map.json` write.
6024
+ */
6025
+ function runMapScan(options) {
6026
+ const project = discoverProject(options.cwd);
6027
+ if (!project) throw new AutotelError({
6028
+ type: "environment",
6029
+ code: AutotelErrorCodes.E_NO_PACKAGE_JSON,
6030
+ message: `No package.json found from ${options.cwd}`,
6031
+ fix: "Run autotel map from inside a package, or pass --cwd <path>."
6032
+ });
6033
+ const projectRoot = options.workspaceRoot ? project.workspace.workspaceRoot ?? project.packageRoot : project.packageRoot;
6034
+ const facts = collectProjectFacts(projectRoot);
6035
+ const { framework, warnings } = resolveFramework(options.framework, projectRoot, facts.deps);
6036
+ const baselineMap = options.baseline ? loadBaseline(projectRoot, typeof options.baseline === "string" ? options.baseline : void 0) : null;
6037
+ const result = scan({
6038
+ projectRoot,
6039
+ framework,
6040
+ project: facts
6041
+ });
6042
+ const baseline = baselineMap ? compareToBaseline(baselineMap.map, result.map, baselineMap.source) : null;
6043
+ const wouldClobberBaseline = baseline !== null && hasRegressed(baseline);
6044
+ return {
6045
+ projectRoot,
6046
+ framework,
6047
+ frameworkWarnings: warnings,
6048
+ scan: result,
6049
+ mapPath: options.write && !wouldClobberBaseline ? writeMapFile(projectRoot, result.map) : null,
6050
+ baseline
6051
+ };
6052
+ }
6053
+ /**
6054
+ * Pick the view for the flags that were passed.
6055
+ *
6056
+ * The three views answer three different questions — "how am I doing", "show
6057
+ * me everything", "explain this one file" — so they are separate renderers
6058
+ * rather than one renderer with three modes.
6059
+ */
6060
+ function formatMapResult(result, options) {
6061
+ const sections = [];
6062
+ const warnings = [...result.frameworkWarnings, ...result.scan.warnings];
6063
+ if (warnings.length > 0) sections.push(formatWarnings(warnings));
6064
+ if (options.entry) {
6065
+ const route = findEntry(result.scan, options.entry);
6066
+ sections.push(route ? formatInspect(route) : formatEntryNotFound(result.scan, options.entry));
6067
+ } else if (options.all) sections.push(formatMatrix(result.scan));
6068
+ else sections.push(formatReport(result.scan, { mapPath: result.mapPath }));
6069
+ if (result.baseline) sections.push(formatBaseline(result.baseline));
6070
+ if (options.minScore !== void 0) sections.push(formatGate(result.scan.map.score, options.minScore));
6071
+ return sections.join("\n");
6072
+ }
6073
+ /** `autotel map` — static observability map of every entry point. */
6074
+ function runMap(options) {
6075
+ configureJsonOutput({
6076
+ ...options.outputFile !== void 0 ? { outputFile: options.outputFile } : {},
6077
+ outputRoot: options.cwd
6078
+ });
6079
+ const minScore = parseMinScore(options.minScore);
6080
+ const result = runMapScan(options);
6081
+ if (options.json) printJson({
6082
+ ok: true,
6083
+ command: "map",
6084
+ mapFile: MAP_FILE_NAME,
6085
+ mapPath: result.mapPath,
6086
+ framework: result.framework,
6087
+ grade: result.scan.grade,
6088
+ summary: result.scan.summary,
6089
+ suggestions: result.scan.suggestions,
6090
+ warnings: [...result.frameworkWarnings, ...result.scan.warnings],
6091
+ ...result.baseline ? { baseline: result.baseline } : {},
6092
+ map: result.scan.map
6093
+ });
6094
+ else process.stdout.write(`${formatMapResult(result, {
6095
+ all: options.all,
6096
+ ...options.entry !== void 0 ? { entry: options.entry } : {},
6097
+ ...minScore !== void 0 ? { minScore } : {}
6098
+ })}\n`);
6099
+ if (minScore !== void 0 && result.scan.map.score < minScore) {
6100
+ process.exitCode = 1;
6101
+ return;
6102
+ }
6103
+ if (result.baseline && hasRegressed(result.baseline)) process.exitCode = 1;
6104
+ }
6105
+ //#endregion
3854
6106
  //#region src/lib/codemod-trace.ts
3855
6107
  const TRACE_IMPORT_MODULE = "autotel";
3856
6108
  function hasTraceImport(sourceFile) {
@@ -4382,6 +6634,75 @@ const COMMANDS = [
4382
6634
  mayReadEnv: false,
4383
6635
  supportsJson: true
4384
6636
  },
6637
+ {
6638
+ name: "map",
6639
+ description: "Score the observability of every entry point in your project (static analysis)",
6640
+ args: [{
6641
+ name: "entry",
6642
+ required: false,
6643
+ description: "Inspect one entry point by route or file path"
6644
+ }],
6645
+ flags: [
6646
+ ...GLOBAL_FLAGS,
6647
+ ...AGENT_FLAGS,
6648
+ {
6649
+ name: "--framework",
6650
+ takesValue: true,
6651
+ description: "Override framework detection (next, nitro, tanstack-start, sveltekit, hono, express, fastify, elysia, cloudflare)"
6652
+ },
6653
+ {
6654
+ name: "--all",
6655
+ description: "Show every entry point as a check matrix"
6656
+ },
6657
+ {
6658
+ name: "--min-score",
6659
+ takesValue: true,
6660
+ description: "Exit 1 when the global score is below this threshold"
6661
+ },
6662
+ {
6663
+ name: "--baseline",
6664
+ takesValue: true,
6665
+ description: "Compare against a committed autotel.map.json and exit 1 on regression (path, or git:<ref>)"
6666
+ },
6667
+ {
6668
+ name: "--no-write",
6669
+ description: "Skip writing autotel.map.json"
6670
+ },
6671
+ {
6672
+ name: "--workspace-root",
6673
+ description: "Scan from the workspace root"
6674
+ }
6675
+ ],
6676
+ mutating: false,
6677
+ network: false,
6678
+ writesFiles: true,
6679
+ supportsDryRun: false,
6680
+ requiresPackageJson: true,
6681
+ mayReadEnv: false,
6682
+ supportsJson: true,
6683
+ examples: [
6684
+ {
6685
+ description: "Score this project and write autotel.map.json",
6686
+ command: "autotel map"
6687
+ },
6688
+ {
6689
+ description: "Every entry point, as a check matrix",
6690
+ command: "autotel map --all"
6691
+ },
6692
+ {
6693
+ description: "Explain one entry point and how to fix it",
6694
+ command: "autotel map app/api/checkout/route.ts"
6695
+ },
6696
+ {
6697
+ description: "Gate CI on a minimum score",
6698
+ command: "autotel map --min-score 70 --json"
6699
+ },
6700
+ {
6701
+ description: "Fail the build when a check regresses against main",
6702
+ command: "autotel map --baseline git:origin/main"
6703
+ }
6704
+ ]
6705
+ },
4385
6706
  {
4386
6707
  name: "codemod trace",
4387
6708
  description: "Wrap functions in trace() with span name from function/variable/method name",
@@ -4892,6 +7213,11 @@ const ERROR_CATALOGUE = [
4892
7213
  type: "io",
4893
7214
  description: "Failed to read a file"
4894
7215
  },
7216
+ {
7217
+ code: AutotelErrorCodes.E_RATE_LIMITED,
7218
+ type: "runtime",
7219
+ description: "Backend rate limit outlasted the retry budget — wait and retry"
7220
+ },
4895
7221
  {
4896
7222
  code: AutotelErrorCodes.E_UNKNOWN,
4897
7223
  type: "runtime",
@@ -5058,6 +7384,9 @@ function applyFlagsToEnv(flags) {
5058
7384
  if (flags.lokiBaseUrl !== void 0) process.env.LOKI_BASE_URL = flags.lokiBaseUrl;
5059
7385
  if (flags.collectorPort !== void 0) process.env.AUTOTEL_COLLECTOR_PORT = String(flags.collectorPort);
5060
7386
  if (flags.fixturePath !== void 0) process.env.AUTOTEL_FIXTURE_PATH = flags.fixturePath;
7387
+ if (flags.logfireBaseUrl !== void 0) process.env.LOGFIRE_BASE_URL = flags.logfireBaseUrl;
7388
+ if (flags.datadogSite !== void 0) process.env.DD_SITE = flags.datadogSite;
7389
+ if (flags.signozBaseUrl !== void 0) process.env.SIGNOZ_BASE_URL = flags.signozBaseUrl;
5061
7390
  }
5062
7391
  /**
5063
7392
  * Build a backend from CLI flags/env. Caller is responsible for calling
@@ -5132,10 +7461,17 @@ function toInvestigateError(command, error) {
5132
7461
  expected: { issues }
5133
7462
  });
5134
7463
  }
7464
+ const message = error instanceof Error ? error.message : String(error);
7465
+ if (/\bHTTP 429\b/.test(message)) return new AutotelError({
7466
+ type: "runtime",
7467
+ code: AutotelErrorCodes.E_RATE_LIMITED,
7468
+ message: `autotel ${command} failed: ${message} — the backend rate limit outlasted the retry budget. Wait for the limit window to reset and run the command again.`,
7469
+ retryable: true
7470
+ });
5135
7471
  return new AutotelError({
5136
7472
  type: "runtime",
5137
7473
  code: "AUTOTEL_E_UNKNOWN",
5138
- message: `autotel ${command} failed: ${error instanceof Error ? error.message : String(error)}`,
7474
+ message: `autotel ${command} failed: ${message}`,
5139
7475
  retryable: false
5140
7476
  });
5141
7477
  }
@@ -5153,7 +7489,7 @@ const floatArg = (v) => Number.parseFloat(v);
5153
7489
  * investigate command. Applied to a Command in place (mutates + returns).
5154
7490
  */
5155
7491
  function addBackendFlags(cmd) {
5156
- return cmd.option("--backend <kind>", "Backend: collector|jaeger|tempo|prometheus|loki|stack|auto|fixture (env: AUTOTEL_BACKEND)").option("--jaeger-base-url <url>", "Jaeger base URL (env: JAEGER_BASE_URL)").option("--tempo-base-url <url>", "Tempo base URL (env: TEMPO_BASE_URL)").option("--prometheus-base-url <url>", "Prometheus base URL (env: PROMETHEUS_BASE_URL)").option("--loki-base-url <url>", "Loki base URL (env: LOKI_BASE_URL)").option("--collector-port <n>", "OTLP receiver port for the collector backend", intArg).option("--fixture-path <path>", "Fixture JSON for the fixture backend").option("--output-file <path>", "Persist JSON output to this file").option("--no-secrets-in-output", "Redact secret-shaped values");
7492
+ return cmd.option("--backend <kind>", "Backend: collector|jaeger|tempo|prometheus|loki|stack|auto|fixture|logfire|datadog|signoz (env: AUTOTEL_BACKEND)").option("--jaeger-base-url <url>", "Jaeger base URL (env: JAEGER_BASE_URL)").option("--tempo-base-url <url>", "Tempo base URL (env: TEMPO_BASE_URL)").option("--prometheus-base-url <url>", "Prometheus base URL (env: PROMETHEUS_BASE_URL)").option("--loki-base-url <url>", "Loki base URL (env: LOKI_BASE_URL)").option("--collector-port <n>", "OTLP receiver port for the collector backend", intArg).option("--fixture-path <path>", "Fixture JSON for the fixture backend").option("--logfire-base-url <url>", "Logfire region URL (env: LOGFIRE_BASE_URL; token via LOGFIRE_READ_TOKEN)").option("--datadog-site <site>", "Datadog site, e.g. datadoghq.eu, or a full API URL (env: DD_SITE; keys via DD_API_KEY + DD_APP_KEY)").option("--signoz-base-url <url>", "SigNoz base URL (env: SIGNOZ_BASE_URL; key via SIGNOZ_API_KEY)").option("--output-file <path>", "Persist JSON output to this file").option("--no-secrets-in-output", "Redact secret-shaped values");
5157
7493
  }
5158
7494
  /**
5159
7495
  * Smaller flag set for commands that don't need a backend (semconv,
@@ -5171,6 +7507,9 @@ function backendFlagsFromOpts(opts) {
5171
7507
  lokiBaseUrl: opts.lokiBaseUrl,
5172
7508
  collectorPort: opts.collectorPort,
5173
7509
  fixturePath: opts.fixturePath,
7510
+ logfireBaseUrl: opts.logfireBaseUrl,
7511
+ datadogSite: opts.datadogSite,
7512
+ signozBaseUrl: opts.signozBaseUrl,
5174
7513
  outputFile: opts.outputFile,
5175
7514
  noSecrets: opts.secretsInOutput === false
5176
7515
  };
@@ -5198,22 +7537,200 @@ function windowFlagsFromOpts(opts) {
5198
7537
  };
5199
7538
  }
5200
7539
  //#endregion
7540
+ //#region src/commands/investigate/freshness.ts
7541
+ const PROBE_SPAN_NAME = "autotel.freshness.probe";
7542
+ const PROBE_SCOPE = "autotel-cli/freshness";
7543
+ /** Nominal probe span duration; a zero-length span reads oddly in a UI. */
7544
+ const PROBE_DURATION_MS = 1;
7545
+ function randomHex(bytes) {
7546
+ const out = [];
7547
+ for (let index = 0; index < bytes; index++) out.push(Math.floor(Math.random() * 256).toString(16).padStart(2, "0"));
7548
+ return out.join("");
7549
+ }
7550
+ /** Epoch ms → OTel's `[seconds, nanoseconds]` pair. */
7551
+ function toHrTime(ms) {
7552
+ const seconds = Math.floor(ms / 1e3);
7553
+ return [seconds, Math.round((ms - seconds * 1e3) * 1e6)];
7554
+ }
7555
+ /** One finished span, ready for either serializer. */
7556
+ function buildProbeSpan(service, startMs) {
7557
+ const traceId = randomHex(16);
7558
+ const spanId = randomHex(8);
7559
+ return {
7560
+ name: PROBE_SPAN_NAME,
7561
+ kind: 0,
7562
+ spanContext: () => ({
7563
+ traceId,
7564
+ spanId,
7565
+ traceFlags: 1
7566
+ }),
7567
+ parentSpanContext: void 0,
7568
+ startTime: toHrTime(startMs),
7569
+ endTime: toHrTime(startMs + PROBE_DURATION_MS),
7570
+ duration: toHrTime(PROBE_DURATION_MS),
7571
+ status: { code: 0 },
7572
+ attributes: {},
7573
+ links: [],
7574
+ events: [],
7575
+ ended: true,
7576
+ resource: { attributes: { "service.name": service } },
7577
+ instrumentationScope: { name: PROBE_SCOPE },
7578
+ droppedAttributesCount: 0,
7579
+ droppedEventsCount: 0,
7580
+ droppedLinksCount: 0
7581
+ };
7582
+ }
7583
+ /** Serialize a probe span into an OTLP request body plus its content type. */
7584
+ function encodeProbe(span, encoding) {
7585
+ const spans = [span];
7586
+ return encoding === "json" ? {
7587
+ body: JsonTraceSerializer.serializeRequest(spans) ?? /* @__PURE__ */ new Uint8Array(),
7588
+ contentType: "application/json"
7589
+ } : {
7590
+ body: ProtobufTraceSerializer.serializeRequest(spans) ?? /* @__PURE__ */ new Uint8Array(),
7591
+ contentType: "application/x-protobuf"
7592
+ };
7593
+ }
7594
+ /**
7595
+ * Parse the standard `OTEL_EXPORTER_OTLP_HEADERS` format (`k=v,k2=v2`).
7596
+ *
7597
+ * Hosted OTLP endpoints all require an auth header, and this is the variable
7598
+ * users already set for their exporters — so the probe reads the same one
7599
+ * rather than inventing a flag that would put a token in argv.
7600
+ */
7601
+ function otlpHeadersFromEnv(raw) {
7602
+ const headers = {};
7603
+ for (const pair of (raw ?? "").split(",")) {
7604
+ const separator = pair.indexOf("=");
7605
+ if (separator <= 0) continue;
7606
+ const key = pair.slice(0, separator).trim();
7607
+ const value = pair.slice(separator + 1).trim();
7608
+ if (key === "") continue;
7609
+ headers[key] = value;
7610
+ }
7611
+ return headers;
7612
+ }
7613
+ /** POST an OTLP/HTTP trace. Kept separate so tests never touch the network. */
7614
+ async function sendOtlpTrace(endpoint, span, headers = {}, encoding = "protobuf") {
7615
+ const url = resolveOtlpTraceUrl(endpoint);
7616
+ const { body, contentType } = encodeProbe(span, encoding);
7617
+ const response = await fetch(url, {
7618
+ method: "POST",
7619
+ headers: {
7620
+ "Content-Type": contentType,
7621
+ ...headers
7622
+ },
7623
+ body
7624
+ });
7625
+ if (!response.ok) {
7626
+ const otherEncoding = encoding === "json" ? "protobuf" : "json";
7627
+ throw new Error(`OTLP endpoint ${url} rejected the probe span: ${response.status} ${response.statusText}. Sent ${encoding}; if the receiver wants the other encoding, pass --otlp-encoding ${otherEncoding}.`);
7628
+ }
7629
+ }
7630
+ /** Resolve an OTLP base endpoint without discarding vendor-specific paths. */
7631
+ function resolveOtlpTraceUrl(endpoint) {
7632
+ const url = new URL(endpoint);
7633
+ const basePath = url.pathname.replace(/\/+$/, "");
7634
+ if (!basePath.endsWith("/v1/traces")) url.pathname = `${basePath}/v1/traces`;
7635
+ return url.toString();
7636
+ }
7637
+ const DEFAULT_TIMEOUT_MS = 12e4;
7638
+ const DEFAULT_POLL_INTERVAL_MS = 2e3;
7639
+ function defaultSleep(ms) {
7640
+ return new Promise((resolve) => setTimeout(resolve, ms));
7641
+ }
7642
+ const DEADLINE_REACHED = Symbol("freshness-deadline-reached");
7643
+ async function beforeDeadline(promise, remainingMs) {
7644
+ if (remainingMs <= 0) return DEADLINE_REACHED;
7645
+ let timer;
7646
+ try {
7647
+ return await Promise.race([promise, new Promise((resolve) => {
7648
+ timer = setTimeout(() => resolve(DEADLINE_REACHED), remainingMs);
7649
+ })]);
7650
+ } finally {
7651
+ if (timer !== void 0) clearTimeout(timer);
7652
+ }
7653
+ }
7654
+ /**
7655
+ * Write one probe span and time how long the backend takes to serve it back.
7656
+ *
7657
+ * An empty successful read means "not yet". Read failures are surfaced because
7658
+ * authentication and configuration errors cannot become fresh by polling.
7659
+ */
7660
+ async function measureFreshness(options) {
7661
+ const { backend, otlpEndpoint, encoding = "protobuf", timeoutMs = DEFAULT_TIMEOUT_MS, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, headers = otlpHeadersFromEnv(process.env.OTEL_EXPORTER_OTLP_HEADERS), now = Date.now, sleep = defaultSleep, send = sendOtlpTrace } = options;
7662
+ const probeService = `autotel-freshness-probe-${randomHex(6)}`;
7663
+ const startedAt = now();
7664
+ const timedOutResult = (attempts) => ({
7665
+ probeService,
7666
+ otlpEndpoint,
7667
+ encoding,
7668
+ timeToQueryableSeconds: null,
7669
+ timedOut: true,
7670
+ attempts,
7671
+ timeoutMs
7672
+ });
7673
+ if (await beforeDeadline(send(otlpEndpoint, buildProbeSpan(probeService, startedAt), headers, encoding), timeoutMs) === DEADLINE_REACHED) return timedOutResult(0);
7674
+ let attempts = 0;
7675
+ for (;;) {
7676
+ attempts++;
7677
+ const remainingMs = timeoutMs - (now() - startedAt);
7678
+ const result = await beforeDeadline(backend.searchTraces({
7679
+ service: probeService,
7680
+ limit: 1
7681
+ }), remainingMs);
7682
+ if (result === DEADLINE_REACHED) return timedOutResult(attempts);
7683
+ if ((result.items?.length ?? 0) > 0) return {
7684
+ probeService,
7685
+ otlpEndpoint,
7686
+ encoding,
7687
+ timeToQueryableSeconds: (now() - startedAt) / 1e3,
7688
+ timedOut: false,
7689
+ attempts,
7690
+ timeoutMs
7691
+ };
7692
+ if (now() - startedAt >= timeoutMs) return timedOutResult(attempts);
7693
+ await sleep(Math.min(pollIntervalMs, timeoutMs - (now() - startedAt)));
7694
+ }
7695
+ }
7696
+ //#endregion
5201
7697
  //#region src/commands/investigate/health.ts
7698
+ function resolvedFreshnessEncoding(requested, backend) {
7699
+ return requested ?? (backend.kind === "collector" ? "json" : "protobuf");
7700
+ }
5202
7701
  async function runHealth(flags) {
5203
7702
  await runInvestigate("health", flags, async (backend) => {
5204
7703
  const [health, capabilities] = await Promise.all([backend.healthCheck(), Promise.resolve(backend.capabilities())]);
5205
- return {
7704
+ const base = {
5206
7705
  ...health,
5207
7706
  signals: capabilities
5208
7707
  };
7708
+ if (!flags.otlpEndpoint) return base;
7709
+ const encoding = resolvedFreshnessEncoding(flags.otlpEncoding, backend);
7710
+ const freshness = await measureFreshness({
7711
+ backend,
7712
+ otlpEndpoint: flags.otlpEndpoint,
7713
+ timeoutMs: flags.freshnessTimeoutMs,
7714
+ encoding
7715
+ });
7716
+ return {
7717
+ ...base,
7718
+ freshness
7719
+ };
5209
7720
  });
5210
7721
  }
5211
7722
  async function runCapabilities(flags) {
5212
7723
  await runInvestigate("capabilities", flags, async (backend) => backend.capabilities());
5213
7724
  }
5214
7725
  function registerHealthCommands(program) {
5215
- const healthCmd = new Command("health").description("Backend health check + signal coverage (JSON)").action(async function() {
5216
- await runHealth(backendFlagsFromOpts(this.opts()));
7726
+ const healthCmd = new Command("health").description("Backend health check + signal coverage (JSON)").option("--otlp-endpoint <url>", "Also measure ingest-to-queryable lag: write a probe span to this OTLP/HTTP endpoint and poll until it reads back").option("--freshness-timeout-ms <ms>", "Give up waiting for the probe span after this long (default 120000)", (value) => Number.parseInt(value, 10)).option("--otlp-encoding <encoding>", "Probe payload encoding: protobuf (default, and the only one some vendors accept) or json (the built-in collector)").action(async function() {
7727
+ const opts = this.opts();
7728
+ await runHealth({
7729
+ ...backendFlagsFromOpts(opts),
7730
+ otlpEndpoint: opts.otlpEndpoint,
7731
+ freshnessTimeoutMs: opts.freshnessTimeoutMs,
7732
+ otlpEncoding: opts.otlpEncoding
7733
+ });
5217
7734
  });
5218
7735
  addBackendFlags(healthCmd);
5219
7736
  program.addCommand(healthCmd);
@@ -6752,6 +9269,27 @@ function createProgram() {
6752
9269
  });
6753
9270
  addGlobalOptions(addCmd);
6754
9271
  program.addCommand(addCmd);
9272
+ 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) => {
9273
+ runMap({
9274
+ cwd: opts.cwd ?? process.cwd(),
9275
+ dryRun: false,
9276
+ noInstall: false,
9277
+ printInstallCmd: false,
9278
+ verbose: opts.verbose ?? false,
9279
+ quiet: opts.quiet ?? false,
9280
+ workspaceRoot: opts.workspaceRoot ?? false,
9281
+ entry,
9282
+ framework: opts.framework,
9283
+ all: opts.all ?? false,
9284
+ minScore: opts.minScore,
9285
+ baseline: opts.baseline,
9286
+ write: opts.write !== false,
9287
+ json: opts.json ?? false,
9288
+ outputFile: opts.outputFile
9289
+ });
9290
+ });
9291
+ addGlobalOptions(mapCmd);
9292
+ program.addCommand(mapCmd);
6755
9293
  const codemodCmd = new Command("codemod").description("Codemod commands for adopting autotel");
6756
9294
  const traceCmd = new Command("trace").description("Wrap functions in trace() with span name from function/variable/method name").argument("<path>", "File path or glob (e.g. src/index.ts, src/**/*.ts)").option("--dry-run", "Print changes without writing files").option("--name-pattern <pattern>", "Span name template: {name}, {file}, {path}").option("--skip <regex>...", "Skip functions whose name matches (repeatable)").option("--print-files", "Print per-file summary (wrapped count, skipped)").action(async (pathArg, opts) => {
6757
9295
  await runCodemodTrace({
@@ -6906,5 +9444,3 @@ run().catch((error) => {
6906
9444
  });
6907
9445
  //#endregion
6908
9446
  export {};
6909
-
6910
- //# sourceMappingURL=index.js.map