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