@filipebraida/adonis-function-points 0.1.0 → 0.3.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/CHANGELOG.md +144 -0
- package/README.md +103 -2
- package/build/calibration-8eV8CEix.js +403 -0
- package/build/commands/fp_metrics.d.ts +10 -0
- package/build/commands/main.d.ts +6 -5
- package/build/commands/main.js +48 -114
- package/build/decorate-D6enDn9D.js +24 -0
- package/build/fp_calibrate-iFAec0tA.js +25 -0
- package/build/fp_count-D21tQ_pv.js +22 -0
- package/build/fp_diff-D0pHGMgi.js +25 -0
- package/build/fp_explain-BwFs-LW-.js +24 -0
- package/build/fp_inventory-Bu6O1Nn0.js +18 -0
- package/build/fp_metrics-MGDppfSa.js +20 -0
- package/build/index.d.ts +30 -1
- package/build/index.js +5 -3
- package/build/{pipeline-BzP-ITGN.js → pipeline-CIAydCcT.js} +452 -54
- package/build/{resolvers-CU9HKYpn.js → resolvers-CRB6lXoo.js} +474 -207
- package/build/{runners-Bt8tbISi.js → runners-CmxNHuuq.js} +146 -342
- package/build/src/albrecht/counter.d.ts +21 -1
- package/build/src/albrecht/data_functions.d.ts +6 -0
- package/build/src/albrecht/diff.d.ts +19 -1
- package/build/src/cli/runners.d.ts +12 -0
- package/build/src/cli.js +11 -2
- package/build/src/define_config.d.ts +35 -1
- package/build/src/inventory/detectors/lucid.d.ts +8 -0
- package/build/src/inventory/graph/call_graph.d.ts +29 -0
- package/build/src/inventory/graph/noise.d.ts +13 -0
- package/build/src/inventory/resolvers/event_dispatch.d.ts +17 -0
- package/build/src/inventory/resolvers/index.js +1 -1
- package/build/src/inventory/resolvers/types.d.ts +35 -0
- package/build/src/inventory/sources/event_bindings.d.ts +44 -0
- package/build/src/metrics/structure.d.ts +16 -2
- package/build/src/pipeline.js +1 -1
- package/build/src/reporters/table.d.ts +11 -1
- package/build/src/types.d.ts +17 -0
- package/build/stubs/config.stub +27 -1
- package/package.json +2 -1
- package/build/define_config-DOqWyPwV.js +0 -19
- package/build/scripts/smoke_package.d.ts +0 -1
- package/build/tmp/probe.d.ts +0 -1
- package/build/tmp/probe_cli.d.ts +0 -1
- package/build/tmp/probe_cmp.d.ts +0 -1
- package/build/tmp/probe_count.d.ts +0 -1
- package/build/tmp/probe_data.d.ts +0 -1
- package/build/tmp/probe_diff.d.ts +0 -1
- package/build/tmp/probe_gap.d.ts +0 -1
- package/build/tmp/probe_graph.d.ts +0 -1
- package/build/tmp/probe_metrics.d.ts +0 -1
- package/build/tmp/probe_miss.d.ts +0 -1
- package/build/tmp/probe_names.d.ts +0 -1
- package/build/tmp/probe_nodata.d.ts +0 -1
- package/build/tmp/probe_one.d.ts +0 -1
- package/build/tmp/probe_perf.d.ts +0 -1
- package/build/tmp/probe_routes.d.ts +0 -1
- package/build/tmp/probe_unres.d.ts +0 -1
- package/build/tmp/probe_vazquez.d.ts +0 -1
- package/build/tsdown.config.d.ts +0 -2
|
@@ -1,34 +1,10 @@
|
|
|
1
|
-
import { a as rootSymbolOf, i as hooksFiredBy, n as resolveCall, r as detectAccess, t as BUILTIN_CALL_RESOLVERS } from "./resolvers-
|
|
2
|
-
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
1
|
+
import { a as rootSymbolOf, c as toPosix, i as hooksFiredBy, n as resolveCall, o as collectEventBindings, r as detectAccess, s as samePath, t as BUILTIN_CALL_RESOLVERS } from "./resolvers-CRB6lXoo.js";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
4
3
|
import path from "node:path";
|
|
4
|
+
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
import { readFileSync } from "node:fs";
|
|
8
|
-
//#region src/inventory/paths.ts
|
|
9
|
-
/**
|
|
10
|
-
* One canonical spelling for every path the inventory emits.
|
|
11
|
-
*
|
|
12
|
-
* Two path styles meet in this package. ts-morph always returns forward
|
|
13
|
-
* slashes, including on Windows; node's `path.join` returns backslashes there.
|
|
14
|
-
* Both end up in `HandlerRef.file`, and the call graph uses that string as a
|
|
15
|
-
* cache key:
|
|
16
|
-
*
|
|
17
|
-
* const key = `${ref.file}#${ref.member ?? ref.line ?? '*'}`
|
|
18
|
-
*
|
|
19
|
-
* Two spellings of the same file are two keys, so the same body would be
|
|
20
|
-
* analysed twice and pushed twice into the trace and the implementation scope
|
|
21
|
-
* — and a repeated scope entry changes the hash `fp:diff` compares.
|
|
22
|
-
*
|
|
23
|
-
* Forward slashes win because ts-morph cannot be told otherwise, node's `fs`
|
|
24
|
-
* accepts them on Windows, and `path.relative` normalises mixed input anyway.
|
|
25
|
-
* Normalising at the boundary where a path is created costs one call; leaving
|
|
26
|
-
* it to each comparison costs vigilance forever.
|
|
27
|
-
*/
|
|
28
|
-
const toPosix = (value) => value.split("\\").join("/");
|
|
29
|
-
/** Compares two paths that may have come from different sources. */
|
|
30
|
-
const samePath = (a, b) => a !== void 0 && b !== void 0 && toPosix(a) === toPosix(b);
|
|
31
|
-
//#endregion
|
|
32
8
|
//#region src/inventory/app_context.ts
|
|
33
9
|
/** folders naming an artefact TYPE, in either layout */
|
|
34
10
|
const ARTIFACT_KINDS = new Set([
|
|
@@ -980,7 +956,21 @@ function collectJsonSchemas(app) {
|
|
|
980
956
|
skipFileDependencyResolution: true,
|
|
981
957
|
compilerOptions: { allowJs: false }
|
|
982
958
|
});
|
|
983
|
-
|
|
959
|
+
/**
|
|
960
|
+
* Wider than `scanRoots`, and only here.
|
|
961
|
+
*
|
|
962
|
+
* `database/` is excluded from the application roots on purpose: test factories
|
|
963
|
+
* and migrations contain real persistence calls, and scanning them would turn a
|
|
964
|
+
* test write into a counted function. But `node ace make:seeder` puts seeders in
|
|
965
|
+
* `database/seeders`, which is where seed data — and therefore a form's schema —
|
|
966
|
+
* normally lives. Reading a literal counts nothing, so there is no conflict: the
|
|
967
|
+
* exclusion protects the call graph, not the schema catalogue.
|
|
968
|
+
*
|
|
969
|
+
* Without this, `overrides.detFromSchema` naming a schema declared in a seeder
|
|
970
|
+
* reported "not declared anywhere in the code" and left the DET count at the
|
|
971
|
+
* floor — the exact case the override exists for.
|
|
972
|
+
*/
|
|
973
|
+
for (const root of [...app.scanRoots.map(toPosix), `${toPosix(app.root)}/database`]) project.addSourceFilesAtPaths(`${root}/**/*.ts`);
|
|
984
974
|
const found = /* @__PURE__ */ new Map();
|
|
985
975
|
for (const file of project.getSourceFiles()) for (const declaration of file.getVariableDeclarations()) {
|
|
986
976
|
const literal = unwrap(declaration.getInitializer())?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
@@ -1097,6 +1087,7 @@ const NEVER_DATA_METHODS = new Set([
|
|
|
1097
1087
|
"padEnd",
|
|
1098
1088
|
"toISO",
|
|
1099
1089
|
"toISODate",
|
|
1090
|
+
"toISOString",
|
|
1100
1091
|
"toFormat",
|
|
1101
1092
|
"toUTC",
|
|
1102
1093
|
"toSQL",
|
|
@@ -1116,6 +1107,27 @@ const NEVER_DATA_METHODS = new Set([
|
|
|
1116
1107
|
"primitive"
|
|
1117
1108
|
]);
|
|
1118
1109
|
/**
|
|
1110
|
+
* Array iteration, which is noise ONLY when a callback is passed.
|
|
1111
|
+
*
|
|
1112
|
+
* This is the one place a method name is allowed to matter, and it is guarded:
|
|
1113
|
+
* `repo.find(id)` is a data access while `rows.find((r) => r.id === id)` is a
|
|
1114
|
+
* predicate over a list already in memory. The callback is what separates them,
|
|
1115
|
+
* so the list alone decides nothing — `some`, `every` and `find` stay safe.
|
|
1116
|
+
*/
|
|
1117
|
+
const ITERATION_METHODS = new Set([
|
|
1118
|
+
"map",
|
|
1119
|
+
"filter",
|
|
1120
|
+
"find",
|
|
1121
|
+
"findIndex",
|
|
1122
|
+
"findLast",
|
|
1123
|
+
"some",
|
|
1124
|
+
"every",
|
|
1125
|
+
"forEach",
|
|
1126
|
+
"flatMap",
|
|
1127
|
+
"reduce",
|
|
1128
|
+
"sort"
|
|
1129
|
+
]);
|
|
1130
|
+
/**
|
|
1119
1131
|
* AdonisJS services, which reach the tracer through an application alias.
|
|
1120
1132
|
*
|
|
1121
1133
|
* `env` is imported from `#start/env`, an application module, so the symbol
|
|
@@ -1124,6 +1136,8 @@ const NEVER_DATA_METHODS = new Set([
|
|
|
1124
1136
|
*/
|
|
1125
1137
|
const FRAMEWORK_SERVICES = new Set([
|
|
1126
1138
|
"env",
|
|
1139
|
+
"redis",
|
|
1140
|
+
"limiter",
|
|
1127
1141
|
"logger",
|
|
1128
1142
|
"health",
|
|
1129
1143
|
"hash",
|
|
@@ -1141,6 +1155,7 @@ function isNoise(call, owner) {
|
|
|
1141
1155
|
const expression = call.getExpression();
|
|
1142
1156
|
if (!Node.isPropertyAccessExpression(expression)) return false;
|
|
1143
1157
|
if (NEVER_DATA_METHODS.has(expression.getName())) return true;
|
|
1158
|
+
if (isIteration(expression.getName(), call)) return true;
|
|
1144
1159
|
const receiver = expression.getExpression();
|
|
1145
1160
|
if (Node.isIdentifier(receiver) && FRAMEWORK_SERVICES.has(receiver.getText())) return true;
|
|
1146
1161
|
/**
|
|
@@ -1151,6 +1166,29 @@ function isNoise(call, owner) {
|
|
|
1151
1166
|
if (Node.isPropertyAccessExpression(receiver) && Node.isThisExpression(receiver.getExpression()) && FRAMEWORK_SERVICES.has(receiver.getName())) return true;
|
|
1152
1167
|
return isNativeReceiver(receiver, owner);
|
|
1153
1168
|
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Iteration over a list, checked BEFORE the resolvers run.
|
|
1171
|
+
*
|
|
1172
|
+
* `PAPEIS_CONCEDIVEIS.map((name) => …)` is `Identifier.method(args)`, the shape
|
|
1173
|
+
* `static-service` exists for, so the resolver claimed it, resolved the enum
|
|
1174
|
+
* module, found no `map` in it and reported a gap — noise never got asked,
|
|
1175
|
+
* because it is only consulted once every resolver has declined.
|
|
1176
|
+
*
|
|
1177
|
+
* No resolver's pattern is `X.map(callback)`, so refusing this shape up front
|
|
1178
|
+
* costs nothing and is not the same as silencing an unresolved call: nothing
|
|
1179
|
+
* was ever there to resolve.
|
|
1180
|
+
*/
|
|
1181
|
+
function isIterationCall(call) {
|
|
1182
|
+
const expression = call.getExpression();
|
|
1183
|
+
if (!Node.isPropertyAccessExpression(expression)) return false;
|
|
1184
|
+
return isIteration(expression.getName(), call);
|
|
1185
|
+
}
|
|
1186
|
+
/** An iteration method whose first argument is an inline callback. */
|
|
1187
|
+
function isIteration(method, call) {
|
|
1188
|
+
if (!ITERATION_METHODS.has(method)) return false;
|
|
1189
|
+
const first = call.getArguments()[0];
|
|
1190
|
+
return first !== void 0 && (Node.isArrowFunction(first) || Node.isFunctionExpression(first));
|
|
1191
|
+
}
|
|
1154
1192
|
/** Does the receiver resolve to a built-in, by its declaration? */
|
|
1155
1193
|
function isNativeReceiver(receiver, owner) {
|
|
1156
1194
|
if (Node.isArrayLiteralExpression(receiver) || Node.isStringLiteral(receiver)) return true;
|
|
@@ -1200,6 +1238,7 @@ function isNoiseMember(file, member) {
|
|
|
1200
1238
|
*/
|
|
1201
1239
|
function validatorFieldsIn(body, file, app) {
|
|
1202
1240
|
const fields = [];
|
|
1241
|
+
const opaque = [];
|
|
1203
1242
|
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1204
1243
|
const expression = call.getExpression();
|
|
1205
1244
|
if (!Node.isPropertyAccessExpression(expression)) continue;
|
|
@@ -1209,9 +1248,88 @@ function validatorFieldsIn(body, file, app) {
|
|
|
1209
1248
|
const name = argument.getText();
|
|
1210
1249
|
const declaration = findValidator(name, file, app);
|
|
1211
1250
|
if (!declaration) continue;
|
|
1212
|
-
|
|
1251
|
+
const { leaves, opaque: unreadable } = leavesOf(declaration);
|
|
1252
|
+
const isOpaque = new Set(unreadable);
|
|
1253
|
+
for (const leaf of leaves) {
|
|
1254
|
+
const field = `${name}.${leaf}`;
|
|
1255
|
+
fields.push(field);
|
|
1256
|
+
if (isOpaque.has(leaf)) opaque.push(field);
|
|
1257
|
+
}
|
|
1213
1258
|
}
|
|
1214
|
-
return
|
|
1259
|
+
return {
|
|
1260
|
+
fields,
|
|
1261
|
+
opaque
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Fields read straight off the request, with no validator in between.
|
|
1266
|
+
*
|
|
1267
|
+
* `request.input('title')` is a user-recognisable field crossing the boundary —
|
|
1268
|
+
* §7.2's definition of a DET — and it was worth nothing, because input DETs came
|
|
1269
|
+
* only from VineJS. A transaction that reads six fields this way landed at 1 DET
|
|
1270
|
+
* and therefore at the floor of its complexity band.
|
|
1271
|
+
*
|
|
1272
|
+
* On four production applications about half the submitting transactions have no
|
|
1273
|
+
* validator, so this was not an edge case: it was a systematic undercount, and a
|
|
1274
|
+
* silent one.
|
|
1275
|
+
*
|
|
1276
|
+
* `all()`, `body()`, `except()` and `qs()` enumerate nothing — they read whatever
|
|
1277
|
+
* arrives. Those are the honest blind spot, reported rather than guessed, which
|
|
1278
|
+
* is why they come back as a flag and not as a field.
|
|
1279
|
+
*/
|
|
1280
|
+
const ENUMERATES_FIELDS = new Set(["input", "only"]);
|
|
1281
|
+
const READS_OPAQUELY = new Set([
|
|
1282
|
+
"all",
|
|
1283
|
+
"body",
|
|
1284
|
+
"except",
|
|
1285
|
+
"qs"
|
|
1286
|
+
]);
|
|
1287
|
+
function requestFieldsIn(body) {
|
|
1288
|
+
const fields = /* @__PURE__ */ new Set();
|
|
1289
|
+
let opaque = false;
|
|
1290
|
+
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1291
|
+
const expression = call.getExpression();
|
|
1292
|
+
if (!Node.isPropertyAccessExpression(expression)) continue;
|
|
1293
|
+
if (!isRequest(expression.getExpression())) continue;
|
|
1294
|
+
const method = expression.getName();
|
|
1295
|
+
if (READS_OPAQUELY.has(method)) {
|
|
1296
|
+
opaque = true;
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
if (!ENUMERATES_FIELDS.has(method)) continue;
|
|
1300
|
+
const argument = call.getArguments()[0];
|
|
1301
|
+
if (!argument) continue;
|
|
1302
|
+
/** `request.input('title')` */
|
|
1303
|
+
const single = argument.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
|
1304
|
+
if (single) {
|
|
1305
|
+
fields.add(single);
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
/** `request.only(['title', 'isbn'])` */
|
|
1309
|
+
const list = argument.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
1310
|
+
if (!list) {
|
|
1311
|
+
opaque = true;
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
for (const element of list.getElements()) {
|
|
1315
|
+
const name = element.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
|
1316
|
+
if (name) fields.add(name);
|
|
1317
|
+
else opaque = true;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return {
|
|
1321
|
+
fields: [...fields],
|
|
1322
|
+
opaque
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* `request` as an AdonisJS handler receives it: destructured from the context,
|
|
1327
|
+
* or reached through it. Resolved by shape, not by a name list — `ctx.request`
|
|
1328
|
+
* and `{ request }` are the same object.
|
|
1329
|
+
*/
|
|
1330
|
+
function isRequest(receiver) {
|
|
1331
|
+
if (Node.isIdentifier(receiver)) return receiver.getText() === "request";
|
|
1332
|
+
return Node.isPropertyAccessExpression(receiver) && receiver.getName() === "request";
|
|
1215
1333
|
}
|
|
1216
1334
|
/** validator declaration: in this file, or imported from the application */
|
|
1217
1335
|
function findValidator(name, file, app) {
|
|
@@ -1226,11 +1344,14 @@ function findValidator(name, file, app) {
|
|
|
1226
1344
|
}
|
|
1227
1345
|
return null;
|
|
1228
1346
|
}
|
|
1229
|
-
/** leaves of a VineJS schema, per the table in §7 */
|
|
1230
1347
|
function leavesOf(node) {
|
|
1231
1348
|
const object = node.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
|
|
1232
|
-
if (!object) return
|
|
1349
|
+
if (!object) return {
|
|
1350
|
+
leaves: [],
|
|
1351
|
+
opaque: []
|
|
1352
|
+
};
|
|
1233
1353
|
const leaves = [];
|
|
1354
|
+
const opaque = [];
|
|
1234
1355
|
const walk = (literal, prefix) => {
|
|
1235
1356
|
for (const property of literal.getProperties()) {
|
|
1236
1357
|
if (!Node.isPropertyAssignment(property)) continue;
|
|
@@ -1238,14 +1359,36 @@ function leavesOf(node) {
|
|
|
1238
1359
|
const text = property.getText();
|
|
1239
1360
|
const nested = property.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
|
|
1240
1361
|
if (nested && /vine\.object/.test(text)) {
|
|
1241
|
-
|
|
1362
|
+
const path = prefix ? `${prefix}.${name}` : name;
|
|
1363
|
+
/**
|
|
1364
|
+
* An object declaring no properties enumerates nothing. It is the field
|
|
1365
|
+
* itself that crosses the boundary, so it counts once — never zero,
|
|
1366
|
+
* which would make it cheaper than a plain string.
|
|
1367
|
+
*/
|
|
1368
|
+
if (nested.getProperties().length === 0) {
|
|
1369
|
+
leaves.push(path);
|
|
1370
|
+
opaque.push(path);
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1373
|
+
walk(nested, path);
|
|
1242
1374
|
continue;
|
|
1243
1375
|
}
|
|
1244
1376
|
leaves.push(prefix ? `${prefix}.${name}` : name);
|
|
1245
1377
|
}
|
|
1246
1378
|
};
|
|
1247
1379
|
walk(object, "");
|
|
1248
|
-
|
|
1380
|
+
/**
|
|
1381
|
+
* The whole validator is an open object: nothing is enumerable, and the body
|
|
1382
|
+
* that carries it is measured at the floor. Counted as one, reported as such.
|
|
1383
|
+
*/
|
|
1384
|
+
if (leaves.length === 0 && object.getProperties().length === 0) return {
|
|
1385
|
+
leaves: ["*"],
|
|
1386
|
+
opaque: ["*"]
|
|
1387
|
+
};
|
|
1388
|
+
return {
|
|
1389
|
+
leaves,
|
|
1390
|
+
opaque
|
|
1391
|
+
};
|
|
1249
1392
|
}
|
|
1250
1393
|
/** file name, to identify the unresolved call without dumping the full path */
|
|
1251
1394
|
const pathOf = (file) => file.split("/").pop()?.replace(/\.ts$/, "") ?? file;
|
|
@@ -1259,6 +1402,7 @@ const DEFAULT_MAX_DEPTH = 3;
|
|
|
1259
1402
|
* minutes and seconds on an application of a few hundred routes.
|
|
1260
1403
|
*/
|
|
1261
1404
|
function createAnalyzer(app, stores, options = {}) {
|
|
1405
|
+
const eventBindings = options.eventBindings ?? /* @__PURE__ */ new Map();
|
|
1262
1406
|
const project = new Project({
|
|
1263
1407
|
skipAddingFilesFromTsConfig: true,
|
|
1264
1408
|
skipFileDependencyResolution: true,
|
|
@@ -1287,7 +1431,7 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1287
1431
|
const key = file.getFilePath();
|
|
1288
1432
|
let cached = importCache.get(key);
|
|
1289
1433
|
if (!cached) {
|
|
1290
|
-
cached =
|
|
1434
|
+
cached = importMapsOf(file, app);
|
|
1291
1435
|
importCache.set(key, cached);
|
|
1292
1436
|
}
|
|
1293
1437
|
return cached;
|
|
@@ -1337,7 +1481,7 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1337
1481
|
if (!file) return null;
|
|
1338
1482
|
const body = findBody(file, ref);
|
|
1339
1483
|
if (!body) return null;
|
|
1340
|
-
const imports = importsFor(file);
|
|
1484
|
+
const { imports, exportedAs } = importsFor(file);
|
|
1341
1485
|
/** the class this body belongs to: how `this.something` resolves */
|
|
1342
1486
|
const owner = body.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
|
|
1343
1487
|
const injected = injectedFor(owner, file, app);
|
|
@@ -1345,7 +1489,8 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1345
1489
|
const accesses = [];
|
|
1346
1490
|
const followUps = [];
|
|
1347
1491
|
const unresolved = [];
|
|
1348
|
-
const
|
|
1492
|
+
const validator = validatorFieldsIn(body, file, app);
|
|
1493
|
+
const request = requestFieldsIn(body);
|
|
1349
1494
|
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1350
1495
|
const access = detectAccess(call, symbols, relationsByStore);
|
|
1351
1496
|
if (access) {
|
|
@@ -1353,9 +1498,14 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1353
1498
|
store: access.store,
|
|
1354
1499
|
write: access.mode === "write"
|
|
1355
1500
|
});
|
|
1501
|
+
/**
|
|
1502
|
+
* A relation reached by `preload`/`load` is read; one written through
|
|
1503
|
+
* `related('files').create(…)` is written. Assuming read either way made
|
|
1504
|
+
* a table maintained only through a relation come out as an EIF.
|
|
1505
|
+
*/
|
|
1356
1506
|
if (access.viaRelation) accesses.push({
|
|
1357
1507
|
store: access.viaRelation,
|
|
1358
|
-
write:
|
|
1508
|
+
write: access.relationWritten === true
|
|
1359
1509
|
});
|
|
1360
1510
|
/**
|
|
1361
1511
|
* counting-decisions §3: a hook belongs to the transaction that fired
|
|
@@ -1369,11 +1519,18 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1369
1519
|
});
|
|
1370
1520
|
continue;
|
|
1371
1521
|
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Asked before the resolvers, unlike the rest of the noise filter: this
|
|
1524
|
+
* shape must not be CLAIMED, not merely not reported.
|
|
1525
|
+
*/
|
|
1526
|
+
if (isIterationCall(call)) continue;
|
|
1372
1527
|
const resolved = resolveCall(call, {
|
|
1373
1528
|
file,
|
|
1374
1529
|
depth: 0,
|
|
1375
1530
|
imports,
|
|
1531
|
+
exportedAs,
|
|
1376
1532
|
injected,
|
|
1533
|
+
eventBindings,
|
|
1377
1534
|
dataStoresBySymbol: storesByName,
|
|
1378
1535
|
resolveSpecifier: app.resolveSpecifier,
|
|
1379
1536
|
sourceFile
|
|
@@ -1396,18 +1553,60 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1396
1553
|
accesses,
|
|
1397
1554
|
followUps,
|
|
1398
1555
|
unresolved,
|
|
1399
|
-
validators,
|
|
1556
|
+
validators: validator.fields,
|
|
1557
|
+
opaqueValidators: validator.opaque,
|
|
1558
|
+
requestFields: request.fields,
|
|
1559
|
+
opaqueRequest: request.opaque,
|
|
1400
1560
|
bodyHash: hashOf(body)
|
|
1401
1561
|
};
|
|
1402
1562
|
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Stores written anywhere in the application's own code, reachable from an
|
|
1565
|
+
* entry point or not.
|
|
1566
|
+
*
|
|
1567
|
+
* AFP §6.5.4 decides ILF vs EIF by whether the APPLICATION maintains the
|
|
1568
|
+
* store. The graph only walks from HTTP routes, so a table written solely by a
|
|
1569
|
+
* job or a seeder looked unmaintained and came out as an EIF — data held by
|
|
1570
|
+
* another system. It is not: a job is this application. The misclassification
|
|
1571
|
+
* costs 2 points per store and, worse, says the wrong thing about who owns the
|
|
1572
|
+
* data.
|
|
1573
|
+
*
|
|
1574
|
+
* This is a separate pass because reachability is not the question. Whether a
|
|
1575
|
+
* transaction reaches the store still decides if it is counted at all; this
|
|
1576
|
+
* only decides who maintains it.
|
|
1577
|
+
*/
|
|
1578
|
+
const writtenAnywhere = () => {
|
|
1579
|
+
const written = /* @__PURE__ */ new Set();
|
|
1580
|
+
for (const file of project.getSourceFiles()) {
|
|
1581
|
+
const symbols = storeSymbolsFor(file, file, app, storesByName);
|
|
1582
|
+
if (symbols.size === 0) continue;
|
|
1583
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1584
|
+
const access = detectAccess(call, symbols, relationsByStore);
|
|
1585
|
+
if (access?.mode !== "write") continue;
|
|
1586
|
+
written.add(access.store);
|
|
1587
|
+
/**
|
|
1588
|
+
* `distribution.related('files').create(…)` maintains the related table.
|
|
1589
|
+
* Recording only the parent left a table written exclusively that way
|
|
1590
|
+
* looking like somebody else's — reported by a production application as
|
|
1591
|
+
* an EIF it was sure it maintained.
|
|
1592
|
+
*/
|
|
1593
|
+
if (access.viaRelation && access.relationWritten) written.add(access.viaRelation);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
return written;
|
|
1597
|
+
};
|
|
1403
1598
|
return {
|
|
1404
1599
|
analyze: (handler) => run(handler),
|
|
1600
|
+
writtenAnywhere,
|
|
1405
1601
|
/** how many files the project loaded — used to prove it does not grow */
|
|
1406
1602
|
fileCount: () => project.getSourceFiles().length
|
|
1407
1603
|
};
|
|
1408
1604
|
function run(handler) {
|
|
1409
1605
|
const touches = /* @__PURE__ */ new Set();
|
|
1410
1606
|
const inputFields = /* @__PURE__ */ new Set();
|
|
1607
|
+
const opaqueInputFields = /* @__PURE__ */ new Set();
|
|
1608
|
+
const requestFields = /* @__PURE__ */ new Set();
|
|
1609
|
+
let opaqueRequest = false;
|
|
1411
1610
|
const trace = [];
|
|
1412
1611
|
const scope = [];
|
|
1413
1612
|
const unresolved = [];
|
|
@@ -1445,6 +1644,9 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1445
1644
|
}
|
|
1446
1645
|
unresolved.push(...facts.unresolved);
|
|
1447
1646
|
for (const field of facts.validators) inputFields.add(field);
|
|
1647
|
+
for (const field of facts.opaqueValidators) opaqueInputFields.add(field);
|
|
1648
|
+
for (const field of facts.requestFields) requestFields.add(field);
|
|
1649
|
+
if (facts.opaqueRequest) opaqueRequest = true;
|
|
1448
1650
|
trace.push({
|
|
1449
1651
|
file: ref.file,
|
|
1450
1652
|
member: ref.member,
|
|
@@ -1469,6 +1671,9 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1469
1671
|
writes,
|
|
1470
1672
|
touches: [...touches].sort(),
|
|
1471
1673
|
inputFields: [...inputFields].sort(),
|
|
1674
|
+
opaqueInputFields: [...opaqueInputFields].sort(),
|
|
1675
|
+
requestFields: [...requestFields].sort(),
|
|
1676
|
+
opaqueRequest,
|
|
1472
1677
|
trace,
|
|
1473
1678
|
scope,
|
|
1474
1679
|
unresolved
|
|
@@ -1665,16 +1870,32 @@ function membersOfType(typeNode, file, app) {
|
|
|
1665
1870
|
}
|
|
1666
1871
|
return members;
|
|
1667
1872
|
}
|
|
1668
|
-
|
|
1669
|
-
|
|
1873
|
+
/**
|
|
1874
|
+
* Both maps a file's imports produce: where a local name resolves, and what it
|
|
1875
|
+
* was called where it was exported.
|
|
1876
|
+
*
|
|
1877
|
+
* Exported because the tests need the same answer the pipeline gets: a second
|
|
1878
|
+
* implementation in the helpers drifted from this one and missed aliases.
|
|
1879
|
+
*/
|
|
1880
|
+
function importMapsOf(file, app) {
|
|
1881
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1882
|
+
const exportedAs = /* @__PURE__ */ new Map();
|
|
1670
1883
|
for (const declaration of file.getImportDeclarations()) {
|
|
1671
1884
|
const target = app.resolveSpecifier(declaration.getModuleSpecifierValue());
|
|
1672
1885
|
if (!target) continue;
|
|
1673
1886
|
const defaultImport = declaration.getDefaultImport()?.getText();
|
|
1674
|
-
if (defaultImport)
|
|
1675
|
-
for (const named of declaration.getNamedImports())
|
|
1887
|
+
if (defaultImport) imports.set(defaultImport, target);
|
|
1888
|
+
for (const named of declaration.getNamedImports()) {
|
|
1889
|
+
const alias = named.getAliasNode()?.getText();
|
|
1890
|
+
const local = alias ?? named.getName();
|
|
1891
|
+
imports.set(local, target);
|
|
1892
|
+
if (alias) exportedAs.set(alias, named.getName());
|
|
1893
|
+
}
|
|
1676
1894
|
}
|
|
1677
|
-
return
|
|
1895
|
+
return {
|
|
1896
|
+
imports,
|
|
1897
|
+
exportedAs
|
|
1898
|
+
};
|
|
1678
1899
|
}
|
|
1679
1900
|
/**
|
|
1680
1901
|
* Not every unfollowed call is an unresolved call — but the filter must err on
|
|
@@ -1691,12 +1912,27 @@ function isWorthReporting(call, symbols, imports) {
|
|
|
1691
1912
|
const expression = call.getExpression();
|
|
1692
1913
|
if (Node.isIdentifier(expression)) return imports.has(expression.getText());
|
|
1693
1914
|
if (!Node.isPropertyAccessExpression(expression)) return false;
|
|
1915
|
+
/**
|
|
1916
|
+
* A call ON THE RESULT of another call — `dispatch(job).waitResult()`,
|
|
1917
|
+
* `load(id).unwrap()`. The receiver is a value this body already holds, and
|
|
1918
|
+
* the call that produced it is a call site of this same body: it is visited
|
|
1919
|
+
* too, and reports the gap if there is one. Reporting here as well charges
|
|
1920
|
+
* the same unknown twice, and the second charge reads as a distinct defect.
|
|
1921
|
+
*/
|
|
1922
|
+
const receiver = unwrapAwait(expression.getExpression());
|
|
1923
|
+
if (Node.isCallExpression(receiver)) return false;
|
|
1694
1924
|
const root = rootSymbolOf(expression.getExpression());
|
|
1695
1925
|
if (!root) return false;
|
|
1696
1926
|
if (symbols.has(root)) return false;
|
|
1697
1927
|
if (root === "this") return true;
|
|
1698
1928
|
return imports.has(root);
|
|
1699
1929
|
}
|
|
1930
|
+
/** `(await x())` and `x()` are the same receiver for this purpose. */
|
|
1931
|
+
function unwrapAwait(node) {
|
|
1932
|
+
let current = node;
|
|
1933
|
+
while (Node.isAwaitExpression(current) || Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current)) current = current.getExpression();
|
|
1934
|
+
return current;
|
|
1935
|
+
}
|
|
1700
1936
|
/**
|
|
1701
1937
|
* Hash of the NORMALISED body: comments and whitespace removed.
|
|
1702
1938
|
*
|
|
@@ -1787,6 +2023,12 @@ function pointsOf(type, complexity, weights = DEFAULT_WEIGHTS) {
|
|
|
1787
2023
|
}
|
|
1788
2024
|
//#endregion
|
|
1789
2025
|
//#region src/albrecht/data_functions.ts
|
|
2026
|
+
/**
|
|
2027
|
+
* A column whose shape says nothing about what it holds. Marked in the rationale
|
|
2028
|
+
* because `detFromSchema` replaces exactly this placeholder, and because a reader
|
|
2029
|
+
* deserves to know which of the DETs is a floor rather than a count.
|
|
2030
|
+
*/
|
|
2031
|
+
const OPAQUE_TYPE$1 = /^(object|any|unknown|Record<|Json|JSON)/;
|
|
1790
2032
|
function countDataFunctions(stores, usage, options) {
|
|
1791
2033
|
const counted = [];
|
|
1792
2034
|
for (const store of stores) {
|
|
@@ -1802,7 +2044,15 @@ function countDataFunctions(stores, usage, options) {
|
|
|
1802
2044
|
const detAttributes = store.attributes.filter((attribute) => !attribute.isIdentifier);
|
|
1803
2045
|
const det = detAttributes.length;
|
|
1804
2046
|
const refs = options.retStrategy === "composition" ? 1 + store.subgroups.length : 1;
|
|
1805
|
-
|
|
2047
|
+
/**
|
|
2048
|
+
* Maintained by the application, or by another system?
|
|
2049
|
+
*
|
|
2050
|
+
* A write reachable from an entry point is the common case. A write from a
|
|
2051
|
+
* job or a seeder maintains the store just as much — AFP §6.5.4 asks who
|
|
2052
|
+
* maintains it, not which route does.
|
|
2053
|
+
*/
|
|
2054
|
+
const maintained = use.written || options.writtenAnywhere.has(store.name);
|
|
2055
|
+
const type = options.externallyMaintained.has(store.name) || !maintained ? "EIF" : "ILF";
|
|
1806
2056
|
const complexity = complexityOf(type, refs, det, options.tables);
|
|
1807
2057
|
counted.push({
|
|
1808
2058
|
id: `data:${store.name}`,
|
|
@@ -1814,8 +2064,8 @@ function countDataFunctions(stores, usage, options) {
|
|
|
1814
2064
|
complexity,
|
|
1815
2065
|
points: pointsOf(type, complexity, options.weights),
|
|
1816
2066
|
rationale: {
|
|
1817
|
-
rule: options.externallyMaintained.has(store.name) ? "afp:6.5.4 externally maintained by boundary configuration -> EIF" :
|
|
1818
|
-
detSources: detAttributes.map((attribute) => `${store.columnSource}:${store.table ?? store.name}.${attribute.name}`),
|
|
2067
|
+
rule: options.externallyMaintained.has(store.name) ? "afp:6.5.4 externally maintained by boundary configuration -> EIF" : maintained ? "afp:6.5.4 maintained by an application transaction -> ILF" : "afp:6.5.4 used but not maintained -> EIF",
|
|
2068
|
+
detSources: detAttributes.map((attribute) => `${store.columnSource}:${store.table ?? store.name}.${attribute.name}` + (attribute.type && OPAQUE_TYPE$1.test(attribute.type) ? " (opaque)" : "")),
|
|
1819
2069
|
refSources: options.retStrategy === "composition" ? ["1 (main group)", ...store.subgroups.map((s) => `composition:${s}`)] : ["1 (constant: a logical subgroup is not derivable from code)"]
|
|
1820
2070
|
}
|
|
1821
2071
|
});
|
|
@@ -1899,7 +2149,25 @@ function detsFor(entry, behavior, touched, type, options) {
|
|
|
1899
2149
|
sources.push(source);
|
|
1900
2150
|
};
|
|
1901
2151
|
for (const param of entry.signature.match(/:[A-Za-z_][\w]*/g) ?? []) add(param.slice(1), `param:${param}`);
|
|
1902
|
-
|
|
2152
|
+
/**
|
|
2153
|
+
* `(opaque)` is not decoration: `detFromSchema` replaces the opaque placeholder
|
|
2154
|
+
* with a schema's fields, and it used to assume there was exactly one and that
|
|
2155
|
+
* it was worth 1. An open `vine.object` counted zero, so the subtraction ate a
|
|
2156
|
+
* real field — off by one, in the direction of undercounting.
|
|
2157
|
+
*/
|
|
2158
|
+
const opaqueInputs = new Set(behavior.opaqueInputFields);
|
|
2159
|
+
for (const field of behavior.inputFields) add(field.split(".").pop(), `validator:${field}${opaqueInputs.has(field) ? " (opaque)" : ""}`);
|
|
2160
|
+
/**
|
|
2161
|
+
* §7.2 asks whether a user-recognisable field crosses the boundary, not how it
|
|
2162
|
+
* was declared. `request.input('title')` does, and counted for nothing while
|
|
2163
|
+
* input DETs came only from VineJS — so a transaction reading six fields this
|
|
2164
|
+
* way sat at 1 DET, the floor of its band.
|
|
2165
|
+
*
|
|
2166
|
+
* After the validator, and deduplicated by field name: where both exist the
|
|
2167
|
+
* validator is the better provenance to print, and the same field must not be
|
|
2168
|
+
* paid for twice.
|
|
2169
|
+
*/
|
|
2170
|
+
for (const field of behavior.requestFields) add(field, `request:${field}`);
|
|
1903
2171
|
if (type === "EO" || type === "EQ") for (const store of touched) {
|
|
1904
2172
|
const columns = options.countedStores.get(store).attributes.filter((attribute) => !attribute.isIdentifier);
|
|
1905
2173
|
for (const column of columns) add(`${store}.${column.name}`, `output:${store}.${column.name}`);
|
|
@@ -1952,14 +2220,37 @@ function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
|
|
|
1952
2220
|
for (const { label, pattern } of patterns) if (pattern.test(table)) return `${label} (AFP §6.5.2.1.3: ${pattern.source})`;
|
|
1953
2221
|
return null;
|
|
1954
2222
|
}
|
|
2223
|
+
//#endregion
|
|
2224
|
+
//#region src/albrecht/counter.ts
|
|
2225
|
+
/**
|
|
2226
|
+
* Assembles the count from the inventory.
|
|
2227
|
+
*
|
|
2228
|
+
* The order is not arbitrary: data functions depend on HOW transactions use
|
|
2229
|
+
* each store (AFP §6.5.4), and transactional functions depend on which stores
|
|
2230
|
+
* ended up counted. Hence: usage first, then the technical filter, then data,
|
|
2231
|
+
* then transactions.
|
|
2232
|
+
*/
|
|
2233
|
+
const RULESET = "afp";
|
|
1955
2234
|
/**
|
|
1956
2235
|
* Version of the rule set.
|
|
1957
2236
|
*
|
|
1958
2237
|
* It appears in every report, and `fp:diff` refuses to compare counts produced
|
|
1959
2238
|
* by different versions — otherwise the difference would measure the rule
|
|
1960
2239
|
* change rather than the work.
|
|
2240
|
+
*
|
|
2241
|
+
* It must be bumped by ANY change that moves the number for unchanged code, and
|
|
2242
|
+
* that is easy to forget. Four such changes landed in 1.1.0 — maintenance read
|
|
2243
|
+
* across the whole project rather than from routes alone, a job followed into
|
|
2244
|
+
* `process`, an event followed into its listeners, and `request.input(…)` counted
|
|
2245
|
+
* as a DET — and three more in 1.2.0: an open input object counting 1 instead of 0,
|
|
2246
|
+
* `detFromSchema` no longer subtracting a placeholder that was not there, and a
|
|
2247
|
+
* write through `related(…)` maintaining the related table.
|
|
2248
|
+
*
|
|
2249
|
+
* Without the bump, a baseline saved by the previous version compares cleanly
|
|
2250
|
+
* against this one and bills the tool's own improvement as work done. The guard
|
|
2251
|
+
* exists for exactly that, and only this constant arms it.
|
|
1961
2252
|
*/
|
|
1962
|
-
const RULESET_VERSION = "1.
|
|
2253
|
+
const RULESET_VERSION = "1.2.0";
|
|
1963
2254
|
function count(input, options = {}) {
|
|
1964
2255
|
const warnings = [];
|
|
1965
2256
|
const usage = usageOf(input);
|
|
@@ -1972,16 +2263,30 @@ function count(input, options = {}) {
|
|
|
1972
2263
|
...options.weights
|
|
1973
2264
|
};
|
|
1974
2265
|
const infrastructure = new Set(options.boundary?.infrastructure ?? []);
|
|
2266
|
+
const business = new Set(options.boundary?.business ?? []);
|
|
1975
2267
|
const countable = input.stores.filter((store) => {
|
|
1976
2268
|
if (infrastructure.has(store.name) || infrastructure.has(store.table ?? "")) {
|
|
1977
2269
|
warnings.push(`excluded by boundary configuration: ${store.name}`);
|
|
1978
2270
|
return false;
|
|
1979
2271
|
}
|
|
1980
2272
|
const technical = isTechnical(store);
|
|
1981
|
-
if (technical)
|
|
1982
|
-
|
|
2273
|
+
if (!technical) return true;
|
|
2274
|
+
/**
|
|
2275
|
+
* The naming filter is a heuristic over names, so it catches business data
|
|
2276
|
+
* whose name happens to match — a chat session the user manages, a document
|
|
2277
|
+
* template they maintain. Only a person knows which, so a declaration wins
|
|
2278
|
+
* over the pattern, and the report says it was overruled rather than
|
|
2279
|
+
* quietly counting one more store.
|
|
2280
|
+
*/
|
|
2281
|
+
if (business.has(store.name) || business.has(store.table ?? "")) {
|
|
2282
|
+
warnings.push(`kept by boundary configuration: ${store.name} — the AFP naming filter had excluded it (${technical})`);
|
|
2283
|
+
return true;
|
|
2284
|
+
}
|
|
2285
|
+
warnings.push(`technical, excluded: ${store.name} (${technical})`);
|
|
2286
|
+
return false;
|
|
1983
2287
|
});
|
|
1984
2288
|
const dataFunctions = countDataFunctions(countable, usage, {
|
|
2289
|
+
writtenAnywhere: input.writtenAnywhere ?? /* @__PURE__ */ new Set(),
|
|
1985
2290
|
retStrategy: options.retStrategy ?? "constant",
|
|
1986
2291
|
externallyMaintained: new Set(options.boundary?.externallyMaintained ?? []),
|
|
1987
2292
|
tables,
|
|
@@ -1996,6 +2301,8 @@ function count(input, options = {}) {
|
|
|
1996
2301
|
weights
|
|
1997
2302
|
});
|
|
1998
2303
|
warnings.push(...opaqueColumnWarnings(countable, input));
|
|
2304
|
+
warnings.push(...unreadableInputWarnings(input));
|
|
2305
|
+
warnings.push(...openValidatorWarnings(input));
|
|
1999
2306
|
const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings);
|
|
2000
2307
|
return {
|
|
2001
2308
|
ruleset: "afp",
|
|
@@ -2033,6 +2340,68 @@ function opaqueColumnWarnings(stores, input) {
|
|
|
2033
2340
|
if (found.length === 0) return [];
|
|
2034
2341
|
return [`${found.length} opaque column(s), each counted as 1 DET. If the user recognises fields inside one, declare the count with \`overrides\` — counting-decisions §8:`, ...found];
|
|
2035
2342
|
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Input fields declared as an open object: `vine.object({}).allowUnknownProperties()`.
|
|
2345
|
+
*
|
|
2346
|
+
* The same blind spot as an opaque column and, until now, reported nowhere — the
|
|
2347
|
+
* opaque-column warning names stores, and this one is on the transaction side, so
|
|
2348
|
+
* a route whose whole form arrives through one of these was invisible in the
|
|
2349
|
+
* confidence block. On a production application that was the route that saves the
|
|
2350
|
+
* main document, and it was the reason its EI never looked wrong.
|
|
2351
|
+
*
|
|
2352
|
+
* It counts 1 DET, which is a floor. When the fields are declared in the code
|
|
2353
|
+
* somewhere — a seeder, a schema module — `detFromSchema` replaces the floor with
|
|
2354
|
+
* the real count, and §8 of counting-decisions says how.
|
|
2355
|
+
*/
|
|
2356
|
+
function openValidatorWarnings(input) {
|
|
2357
|
+
const found = [];
|
|
2358
|
+
for (const entry of input.entryPoints) {
|
|
2359
|
+
const fields = input.behaviors.get(entry.id)?.opaqueInputFields ?? [];
|
|
2360
|
+
for (const field of fields) found.push(` ${entry.trigger} ${entry.signature} — ${field}`);
|
|
2361
|
+
}
|
|
2362
|
+
if (found.length === 0) return [];
|
|
2363
|
+
return [
|
|
2364
|
+
`${found.length} open input object(s), each counted as 1 DET. The fields the user fills are data, not code, so this is a FLOOR: if they are declared anywhere in the source, name that schema with \`overrides.detFromSchema\` — counting-decisions §8:`,
|
|
2365
|
+
...found.slice(0, 10),
|
|
2366
|
+
...found.length > 10 ? [` … and ${found.length - 10} more`] : []
|
|
2367
|
+
];
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Transactions that read the request in a way that enumerates nothing.
|
|
2371
|
+
*
|
|
2372
|
+
* `request.all()`, `request.body()`, `request.except([…])` — whatever arrives is
|
|
2373
|
+
* read, and no analysis can say how many fields that is. The transaction is
|
|
2374
|
+
* counted from its route parameters alone, which puts it at the floor of its
|
|
2375
|
+
* complexity band: an undercount, and a silent one until now.
|
|
2376
|
+
*
|
|
2377
|
+
* Two earlier versions of this warning were wrong and are worth recording,
|
|
2378
|
+
* because both looked like rigour. The first flagged every write with no
|
|
2379
|
+
* validator and named `users.destroy`, `DELETE /questions/:id` and
|
|
2380
|
+
* `notifications.markRead` — transactions that legitimately carry nothing beyond
|
|
2381
|
+
* the route parameter, exactly as counting-decisions §7 describes. The second
|
|
2382
|
+
* narrowed to POST, PUT and PATCH, and still named `POST /orders/:id/submit` and
|
|
2383
|
+
* `POST /orders/:id/clear`: in an AdonisJS application POST is how a state
|
|
2384
|
+
* transition is expressed, so the verb does not separate a submission from a
|
|
2385
|
+
* trigger.
|
|
2386
|
+
*
|
|
2387
|
+
* What separates them is whether the handler reads the request at all. A trigger
|
|
2388
|
+
* does not. So the enumerable reads are now COUNTED — `request.input('title')` is
|
|
2389
|
+
* a DET — and only what cannot be enumerated is reported. A warning that names
|
|
2390
|
+
* routes with nothing wrong with them is the noise that teaches people to stop
|
|
2391
|
+
* reading the confidence block.
|
|
2392
|
+
*/
|
|
2393
|
+
function unreadableInputWarnings(input) {
|
|
2394
|
+
const blind = input.entryPoints.map((entry) => ({
|
|
2395
|
+
entry,
|
|
2396
|
+
behavior: input.behaviors.get(entry.id)
|
|
2397
|
+
})).filter(({ behavior }) => behavior?.opaqueRequest && behavior.inputFields.length === 0);
|
|
2398
|
+
if (blind.length === 0) return [];
|
|
2399
|
+
return [
|
|
2400
|
+
`${blind.length} transaction(s) read the request without enumerating fields (\`all()\`, \`body()\`, \`except()\`), so their input DETs could not be counted and each sits at the floor of its band. This UNDERSTATES the total — the fix is a validator, not a configuration:`,
|
|
2401
|
+
...blind.slice(0, 10).map(({ entry }) => ` ${entry.trigger} ${entry.signature}`),
|
|
2402
|
+
...blind.length > 10 ? [` … and ${blind.length - 10} more`] : []
|
|
2403
|
+
];
|
|
2404
|
+
}
|
|
2036
2405
|
/** a column whose shape says nothing about what it holds */
|
|
2037
2406
|
const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
|
|
2038
2407
|
/**
|
|
@@ -2071,8 +2440,20 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
|
|
|
2071
2440
|
if (override.detFromSchema) {
|
|
2072
2441
|
const schema = schemas.get(override.detFromSchema);
|
|
2073
2442
|
if (schema) {
|
|
2074
|
-
|
|
2443
|
+
/**
|
|
2444
|
+
* Replaces the opaque placeholder — the one the rationale marks — rather
|
|
2445
|
+
* than assuming there is one and that it is worth 1.
|
|
2446
|
+
*
|
|
2447
|
+
* That assumption was wrong twice over. An open `vine.object` counted
|
|
2448
|
+
* ZERO, not 1, so subtracting 1 removed a field the analysis had read
|
|
2449
|
+
* correctly: 86 DETs where 87 was right. And a function with no opaque
|
|
2450
|
+
* DET at all was silently charged the subtraction too.
|
|
2451
|
+
*/
|
|
2452
|
+
const placeholders = fn.rationale.detSources.filter((source) => source.endsWith("(opaque)"));
|
|
2453
|
+
det = Math.max(fn.det - Math.min(placeholders.length, 1), 0) + schema.fields;
|
|
2075
2454
|
by = `config:overrides.${fn.name} (from ${schema.name}: ${schema.fields} fields)`;
|
|
2455
|
+
if (placeholders.length === 0) warnings.push(`override for "${fn.name}" names schema "${schema.name}", but this function has no opaque DET for it to stand in for: the ${schema.fields} fields were ADDED to the ${fn.det} already counted. Check the override is on the right function.`);
|
|
2456
|
+
else if (placeholders.length > 1) warnings.push(`override for "${fn.name}" names one schema and the function has ${placeholders.length} opaque DETs (${placeholders.join(", ")}). Only one was replaced; the others still count 1 each.`);
|
|
2076
2457
|
} else warnings.push(`override for "${fn.name}" names schema "${override.detFromSchema}", which is not declared anywhere in the code: the DET count was left as found. A renamed or moved schema breaks the mapping, and this says so rather than counting on silently.`);
|
|
2077
2458
|
}
|
|
2078
2459
|
const refs = override.refs ?? fn.refs;
|
|
@@ -2248,7 +2629,8 @@ async function analyze(root, options = {}) {
|
|
|
2248
2629
|
const jsonSchemas = collectJsonSchemas(app);
|
|
2249
2630
|
const analyzer = createAnalyzer(app, stores, {
|
|
2250
2631
|
maxDepth: options.maxDepth,
|
|
2251
|
-
callResolvers: options.resolvers?.call
|
|
2632
|
+
callResolvers: options.resolvers?.call,
|
|
2633
|
+
eventBindings: collectEventBindings(app)
|
|
2252
2634
|
});
|
|
2253
2635
|
const behaviors = new Map(entryPoints.filter((entry) => entry.handler).map((entry) => [entry.id, analyzer.analyze(entry.handler)]));
|
|
2254
2636
|
const resolved = [...behaviors.values()].filter((behavior) => behavior.unresolved.length === 0).length;
|
|
@@ -2275,6 +2657,21 @@ async function analyze(root, options = {}) {
|
|
|
2275
2657
|
by: "validator"
|
|
2276
2658
|
}
|
|
2277
2659
|
})),
|
|
2660
|
+
opaqueInputFields: behavior.opaqueInputFields.map((name) => ({
|
|
2661
|
+
name,
|
|
2662
|
+
provenance: {
|
|
2663
|
+
file: app.root,
|
|
2664
|
+
by: "validator"
|
|
2665
|
+
}
|
|
2666
|
+
})),
|
|
2667
|
+
requestFields: behavior.requestFields.map((name) => ({
|
|
2668
|
+
name,
|
|
2669
|
+
provenance: {
|
|
2670
|
+
file: app.root,
|
|
2671
|
+
by: "request"
|
|
2672
|
+
}
|
|
2673
|
+
})),
|
|
2674
|
+
opaqueRequest: behavior.opaqueRequest,
|
|
2278
2675
|
outputFields: [],
|
|
2279
2676
|
trace: behavior.trace,
|
|
2280
2677
|
unresolved: behavior.unresolved
|
|
@@ -2296,11 +2693,12 @@ async function analyze(root, options = {}) {
|
|
|
2296
2693
|
stores,
|
|
2297
2694
|
entryPoints,
|
|
2298
2695
|
behaviors,
|
|
2299
|
-
jsonSchemas
|
|
2696
|
+
jsonSchemas,
|
|
2697
|
+
writtenAnywhere: analyzer.writtenAnywhere()
|
|
2300
2698
|
}, options),
|
|
2301
2699
|
source: describeSource(root, options.configFile ?? null)
|
|
2302
2700
|
}
|
|
2303
2701
|
};
|
|
2304
2702
|
}
|
|
2305
2703
|
//#endregion
|
|
2306
|
-
export { analyze as n,
|
|
2704
|
+
export { RULESET_VERSION as i, analyze as n, RULESET as r, CoverageTooLowError as t };
|