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/README.md +66 -0
- package/dist/index.js +2342 -2
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/skills/autotel-cli/SKILL.md +0 -265
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({
|