@filipebraida/adonis-function-points 0.1.0 → 0.2.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 +99 -0
- package/README.md +103 -2
- 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-Cm079xWL.js +25 -0
- package/build/fp_count-CfcXPuj5.js +22 -0
- package/build/fp_diff-DE_t3twv.js +25 -0
- package/build/fp_explain-MKyEoi0h.js +24 -0
- package/build/fp_inventory-DSrCVhEy.js +18 -0
- package/build/fp_metrics-BUWj9dLw.js +20 -0
- package/build/index.d.ts +2 -1
- package/build/index.js +3 -2
- package/build/{pipeline-BzP-ITGN.js → pipeline-DySlMWcN.js} +298 -40
- package/build/{resolvers-CU9HKYpn.js → resolvers-MFjRl2ef.js} +225 -5
- package/build/{runners-Bt8tbISi.js → runners-DpMd-yZM.js} +252 -64
- package/build/src/albrecht/counter.d.ts +17 -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/graph/call_graph.d.ts +27 -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 +10 -0
- package/build/stubs/config.stub +27 -1
- package/package.json +2 -1
- 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-
|
|
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-MFjRl2ef.js";
|
|
2
2
|
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
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([
|
|
@@ -1097,6 +1073,7 @@ const NEVER_DATA_METHODS = new Set([
|
|
|
1097
1073
|
"padEnd",
|
|
1098
1074
|
"toISO",
|
|
1099
1075
|
"toISODate",
|
|
1076
|
+
"toISOString",
|
|
1100
1077
|
"toFormat",
|
|
1101
1078
|
"toUTC",
|
|
1102
1079
|
"toSQL",
|
|
@@ -1116,6 +1093,27 @@ const NEVER_DATA_METHODS = new Set([
|
|
|
1116
1093
|
"primitive"
|
|
1117
1094
|
]);
|
|
1118
1095
|
/**
|
|
1096
|
+
* Array iteration, which is noise ONLY when a callback is passed.
|
|
1097
|
+
*
|
|
1098
|
+
* This is the one place a method name is allowed to matter, and it is guarded:
|
|
1099
|
+
* `repo.find(id)` is a data access while `rows.find((r) => r.id === id)` is a
|
|
1100
|
+
* predicate over a list already in memory. The callback is what separates them,
|
|
1101
|
+
* so the list alone decides nothing — `some`, `every` and `find` stay safe.
|
|
1102
|
+
*/
|
|
1103
|
+
const ITERATION_METHODS = new Set([
|
|
1104
|
+
"map",
|
|
1105
|
+
"filter",
|
|
1106
|
+
"find",
|
|
1107
|
+
"findIndex",
|
|
1108
|
+
"findLast",
|
|
1109
|
+
"some",
|
|
1110
|
+
"every",
|
|
1111
|
+
"forEach",
|
|
1112
|
+
"flatMap",
|
|
1113
|
+
"reduce",
|
|
1114
|
+
"sort"
|
|
1115
|
+
]);
|
|
1116
|
+
/**
|
|
1119
1117
|
* AdonisJS services, which reach the tracer through an application alias.
|
|
1120
1118
|
*
|
|
1121
1119
|
* `env` is imported from `#start/env`, an application module, so the symbol
|
|
@@ -1124,6 +1122,8 @@ const NEVER_DATA_METHODS = new Set([
|
|
|
1124
1122
|
*/
|
|
1125
1123
|
const FRAMEWORK_SERVICES = new Set([
|
|
1126
1124
|
"env",
|
|
1125
|
+
"redis",
|
|
1126
|
+
"limiter",
|
|
1127
1127
|
"logger",
|
|
1128
1128
|
"health",
|
|
1129
1129
|
"hash",
|
|
@@ -1141,6 +1141,7 @@ function isNoise(call, owner) {
|
|
|
1141
1141
|
const expression = call.getExpression();
|
|
1142
1142
|
if (!Node.isPropertyAccessExpression(expression)) return false;
|
|
1143
1143
|
if (NEVER_DATA_METHODS.has(expression.getName())) return true;
|
|
1144
|
+
if (isIteration(expression.getName(), call)) return true;
|
|
1144
1145
|
const receiver = expression.getExpression();
|
|
1145
1146
|
if (Node.isIdentifier(receiver) && FRAMEWORK_SERVICES.has(receiver.getText())) return true;
|
|
1146
1147
|
/**
|
|
@@ -1151,6 +1152,29 @@ function isNoise(call, owner) {
|
|
|
1151
1152
|
if (Node.isPropertyAccessExpression(receiver) && Node.isThisExpression(receiver.getExpression()) && FRAMEWORK_SERVICES.has(receiver.getName())) return true;
|
|
1152
1153
|
return isNativeReceiver(receiver, owner);
|
|
1153
1154
|
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Iteration over a list, checked BEFORE the resolvers run.
|
|
1157
|
+
*
|
|
1158
|
+
* `PAPEIS_CONCEDIVEIS.map((name) => …)` is `Identifier.method(args)`, the shape
|
|
1159
|
+
* `static-service` exists for, so the resolver claimed it, resolved the enum
|
|
1160
|
+
* module, found no `map` in it and reported a gap — noise never got asked,
|
|
1161
|
+
* because it is only consulted once every resolver has declined.
|
|
1162
|
+
*
|
|
1163
|
+
* No resolver's pattern is `X.map(callback)`, so refusing this shape up front
|
|
1164
|
+
* costs nothing and is not the same as silencing an unresolved call: nothing
|
|
1165
|
+
* was ever there to resolve.
|
|
1166
|
+
*/
|
|
1167
|
+
function isIterationCall(call) {
|
|
1168
|
+
const expression = call.getExpression();
|
|
1169
|
+
if (!Node.isPropertyAccessExpression(expression)) return false;
|
|
1170
|
+
return isIteration(expression.getName(), call);
|
|
1171
|
+
}
|
|
1172
|
+
/** An iteration method whose first argument is an inline callback. */
|
|
1173
|
+
function isIteration(method, call) {
|
|
1174
|
+
if (!ITERATION_METHODS.has(method)) return false;
|
|
1175
|
+
const first = call.getArguments()[0];
|
|
1176
|
+
return first !== void 0 && (Node.isArrowFunction(first) || Node.isFunctionExpression(first));
|
|
1177
|
+
}
|
|
1154
1178
|
/** Does the receiver resolve to a built-in, by its declaration? */
|
|
1155
1179
|
function isNativeReceiver(receiver, owner) {
|
|
1156
1180
|
if (Node.isArrayLiteralExpression(receiver) || Node.isStringLiteral(receiver)) return true;
|
|
@@ -1213,6 +1237,76 @@ function validatorFieldsIn(body, file, app) {
|
|
|
1213
1237
|
}
|
|
1214
1238
|
return fields;
|
|
1215
1239
|
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Fields read straight off the request, with no validator in between.
|
|
1242
|
+
*
|
|
1243
|
+
* `request.input('title')` is a user-recognisable field crossing the boundary —
|
|
1244
|
+
* §7.2's definition of a DET — and it was worth nothing, because input DETs came
|
|
1245
|
+
* only from VineJS. A transaction that reads six fields this way landed at 1 DET
|
|
1246
|
+
* and therefore at the floor of its complexity band.
|
|
1247
|
+
*
|
|
1248
|
+
* On four production applications about half the submitting transactions have no
|
|
1249
|
+
* validator, so this was not an edge case: it was a systematic undercount, and a
|
|
1250
|
+
* silent one.
|
|
1251
|
+
*
|
|
1252
|
+
* `all()`, `body()`, `except()` and `qs()` enumerate nothing — they read whatever
|
|
1253
|
+
* arrives. Those are the honest blind spot, reported rather than guessed, which
|
|
1254
|
+
* is why they come back as a flag and not as a field.
|
|
1255
|
+
*/
|
|
1256
|
+
const ENUMERATES_FIELDS = new Set(["input", "only"]);
|
|
1257
|
+
const READS_OPAQUELY = new Set([
|
|
1258
|
+
"all",
|
|
1259
|
+
"body",
|
|
1260
|
+
"except",
|
|
1261
|
+
"qs"
|
|
1262
|
+
]);
|
|
1263
|
+
function requestFieldsIn(body) {
|
|
1264
|
+
const fields = /* @__PURE__ */ new Set();
|
|
1265
|
+
let opaque = false;
|
|
1266
|
+
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1267
|
+
const expression = call.getExpression();
|
|
1268
|
+
if (!Node.isPropertyAccessExpression(expression)) continue;
|
|
1269
|
+
if (!isRequest(expression.getExpression())) continue;
|
|
1270
|
+
const method = expression.getName();
|
|
1271
|
+
if (READS_OPAQUELY.has(method)) {
|
|
1272
|
+
opaque = true;
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
if (!ENUMERATES_FIELDS.has(method)) continue;
|
|
1276
|
+
const argument = call.getArguments()[0];
|
|
1277
|
+
if (!argument) continue;
|
|
1278
|
+
/** `request.input('title')` */
|
|
1279
|
+
const single = argument.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
|
1280
|
+
if (single) {
|
|
1281
|
+
fields.add(single);
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
/** `request.only(['title', 'isbn'])` */
|
|
1285
|
+
const list = argument.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
1286
|
+
if (!list) {
|
|
1287
|
+
opaque = true;
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
for (const element of list.getElements()) {
|
|
1291
|
+
const name = element.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
|
1292
|
+
if (name) fields.add(name);
|
|
1293
|
+
else opaque = true;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return {
|
|
1297
|
+
fields: [...fields],
|
|
1298
|
+
opaque
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* `request` as an AdonisJS handler receives it: destructured from the context,
|
|
1303
|
+
* or reached through it. Resolved by shape, not by a name list — `ctx.request`
|
|
1304
|
+
* and `{ request }` are the same object.
|
|
1305
|
+
*/
|
|
1306
|
+
function isRequest(receiver) {
|
|
1307
|
+
if (Node.isIdentifier(receiver)) return receiver.getText() === "request";
|
|
1308
|
+
return Node.isPropertyAccessExpression(receiver) && receiver.getName() === "request";
|
|
1309
|
+
}
|
|
1216
1310
|
/** validator declaration: in this file, or imported from the application */
|
|
1217
1311
|
function findValidator(name, file, app) {
|
|
1218
1312
|
const local = file.getVariableDeclaration(name)?.getInitializer();
|
|
@@ -1259,6 +1353,7 @@ const DEFAULT_MAX_DEPTH = 3;
|
|
|
1259
1353
|
* minutes and seconds on an application of a few hundred routes.
|
|
1260
1354
|
*/
|
|
1261
1355
|
function createAnalyzer(app, stores, options = {}) {
|
|
1356
|
+
const eventBindings = options.eventBindings ?? /* @__PURE__ */ new Map();
|
|
1262
1357
|
const project = new Project({
|
|
1263
1358
|
skipAddingFilesFromTsConfig: true,
|
|
1264
1359
|
skipFileDependencyResolution: true,
|
|
@@ -1287,7 +1382,7 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1287
1382
|
const key = file.getFilePath();
|
|
1288
1383
|
let cached = importCache.get(key);
|
|
1289
1384
|
if (!cached) {
|
|
1290
|
-
cached =
|
|
1385
|
+
cached = importMapsOf(file, app);
|
|
1291
1386
|
importCache.set(key, cached);
|
|
1292
1387
|
}
|
|
1293
1388
|
return cached;
|
|
@@ -1337,7 +1432,7 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1337
1432
|
if (!file) return null;
|
|
1338
1433
|
const body = findBody(file, ref);
|
|
1339
1434
|
if (!body) return null;
|
|
1340
|
-
const imports = importsFor(file);
|
|
1435
|
+
const { imports, exportedAs } = importsFor(file);
|
|
1341
1436
|
/** the class this body belongs to: how `this.something` resolves */
|
|
1342
1437
|
const owner = body.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
|
|
1343
1438
|
const injected = injectedFor(owner, file, app);
|
|
@@ -1346,6 +1441,7 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1346
1441
|
const followUps = [];
|
|
1347
1442
|
const unresolved = [];
|
|
1348
1443
|
const validators = validatorFieldsIn(body, file, app);
|
|
1444
|
+
const request = requestFieldsIn(body);
|
|
1349
1445
|
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1350
1446
|
const access = detectAccess(call, symbols, relationsByStore);
|
|
1351
1447
|
if (access) {
|
|
@@ -1369,11 +1465,18 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1369
1465
|
});
|
|
1370
1466
|
continue;
|
|
1371
1467
|
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Asked before the resolvers, unlike the rest of the noise filter: this
|
|
1470
|
+
* shape must not be CLAIMED, not merely not reported.
|
|
1471
|
+
*/
|
|
1472
|
+
if (isIterationCall(call)) continue;
|
|
1372
1473
|
const resolved = resolveCall(call, {
|
|
1373
1474
|
file,
|
|
1374
1475
|
depth: 0,
|
|
1375
1476
|
imports,
|
|
1477
|
+
exportedAs,
|
|
1376
1478
|
injected,
|
|
1479
|
+
eventBindings,
|
|
1377
1480
|
dataStoresBySymbol: storesByName,
|
|
1378
1481
|
resolveSpecifier: app.resolveSpecifier,
|
|
1379
1482
|
sourceFile
|
|
@@ -1397,17 +1500,49 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1397
1500
|
followUps,
|
|
1398
1501
|
unresolved,
|
|
1399
1502
|
validators,
|
|
1503
|
+
requestFields: request.fields,
|
|
1504
|
+
opaqueRequest: request.opaque,
|
|
1400
1505
|
bodyHash: hashOf(body)
|
|
1401
1506
|
};
|
|
1402
1507
|
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Stores written anywhere in the application's own code, reachable from an
|
|
1510
|
+
* entry point or not.
|
|
1511
|
+
*
|
|
1512
|
+
* AFP §6.5.4 decides ILF vs EIF by whether the APPLICATION maintains the
|
|
1513
|
+
* store. The graph only walks from HTTP routes, so a table written solely by a
|
|
1514
|
+
* job or a seeder looked unmaintained and came out as an EIF — data held by
|
|
1515
|
+
* another system. It is not: a job is this application. The misclassification
|
|
1516
|
+
* costs 2 points per store and, worse, says the wrong thing about who owns the
|
|
1517
|
+
* data.
|
|
1518
|
+
*
|
|
1519
|
+
* This is a separate pass because reachability is not the question. Whether a
|
|
1520
|
+
* transaction reaches the store still decides if it is counted at all; this
|
|
1521
|
+
* only decides who maintains it.
|
|
1522
|
+
*/
|
|
1523
|
+
const writtenAnywhere = () => {
|
|
1524
|
+
const written = /* @__PURE__ */ new Set();
|
|
1525
|
+
for (const file of project.getSourceFiles()) {
|
|
1526
|
+
const symbols = storeSymbolsFor(file, file, app, storesByName);
|
|
1527
|
+
if (symbols.size === 0) continue;
|
|
1528
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1529
|
+
const access = detectAccess(call, symbols, relationsByStore);
|
|
1530
|
+
if (access?.mode === "write") written.add(access.store);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return written;
|
|
1534
|
+
};
|
|
1403
1535
|
return {
|
|
1404
1536
|
analyze: (handler) => run(handler),
|
|
1537
|
+
writtenAnywhere,
|
|
1405
1538
|
/** how many files the project loaded — used to prove it does not grow */
|
|
1406
1539
|
fileCount: () => project.getSourceFiles().length
|
|
1407
1540
|
};
|
|
1408
1541
|
function run(handler) {
|
|
1409
1542
|
const touches = /* @__PURE__ */ new Set();
|
|
1410
1543
|
const inputFields = /* @__PURE__ */ new Set();
|
|
1544
|
+
const requestFields = /* @__PURE__ */ new Set();
|
|
1545
|
+
let opaqueRequest = false;
|
|
1411
1546
|
const trace = [];
|
|
1412
1547
|
const scope = [];
|
|
1413
1548
|
const unresolved = [];
|
|
@@ -1445,6 +1580,8 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1445
1580
|
}
|
|
1446
1581
|
unresolved.push(...facts.unresolved);
|
|
1447
1582
|
for (const field of facts.validators) inputFields.add(field);
|
|
1583
|
+
for (const field of facts.requestFields) requestFields.add(field);
|
|
1584
|
+
if (facts.opaqueRequest) opaqueRequest = true;
|
|
1448
1585
|
trace.push({
|
|
1449
1586
|
file: ref.file,
|
|
1450
1587
|
member: ref.member,
|
|
@@ -1469,6 +1606,8 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1469
1606
|
writes,
|
|
1470
1607
|
touches: [...touches].sort(),
|
|
1471
1608
|
inputFields: [...inputFields].sort(),
|
|
1609
|
+
requestFields: [...requestFields].sort(),
|
|
1610
|
+
opaqueRequest,
|
|
1472
1611
|
trace,
|
|
1473
1612
|
scope,
|
|
1474
1613
|
unresolved
|
|
@@ -1665,16 +1804,32 @@ function membersOfType(typeNode, file, app) {
|
|
|
1665
1804
|
}
|
|
1666
1805
|
return members;
|
|
1667
1806
|
}
|
|
1668
|
-
|
|
1669
|
-
|
|
1807
|
+
/**
|
|
1808
|
+
* Both maps a file's imports produce: where a local name resolves, and what it
|
|
1809
|
+
* was called where it was exported.
|
|
1810
|
+
*
|
|
1811
|
+
* Exported because the tests need the same answer the pipeline gets: a second
|
|
1812
|
+
* implementation in the helpers drifted from this one and missed aliases.
|
|
1813
|
+
*/
|
|
1814
|
+
function importMapsOf(file, app) {
|
|
1815
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1816
|
+
const exportedAs = /* @__PURE__ */ new Map();
|
|
1670
1817
|
for (const declaration of file.getImportDeclarations()) {
|
|
1671
1818
|
const target = app.resolveSpecifier(declaration.getModuleSpecifierValue());
|
|
1672
1819
|
if (!target) continue;
|
|
1673
1820
|
const defaultImport = declaration.getDefaultImport()?.getText();
|
|
1674
|
-
if (defaultImport)
|
|
1675
|
-
for (const named of declaration.getNamedImports())
|
|
1821
|
+
if (defaultImport) imports.set(defaultImport, target);
|
|
1822
|
+
for (const named of declaration.getNamedImports()) {
|
|
1823
|
+
const alias = named.getAliasNode()?.getText();
|
|
1824
|
+
const local = alias ?? named.getName();
|
|
1825
|
+
imports.set(local, target);
|
|
1826
|
+
if (alias) exportedAs.set(alias, named.getName());
|
|
1827
|
+
}
|
|
1676
1828
|
}
|
|
1677
|
-
return
|
|
1829
|
+
return {
|
|
1830
|
+
imports,
|
|
1831
|
+
exportedAs
|
|
1832
|
+
};
|
|
1678
1833
|
}
|
|
1679
1834
|
/**
|
|
1680
1835
|
* Not every unfollowed call is an unresolved call — but the filter must err on
|
|
@@ -1691,12 +1846,27 @@ function isWorthReporting(call, symbols, imports) {
|
|
|
1691
1846
|
const expression = call.getExpression();
|
|
1692
1847
|
if (Node.isIdentifier(expression)) return imports.has(expression.getText());
|
|
1693
1848
|
if (!Node.isPropertyAccessExpression(expression)) return false;
|
|
1849
|
+
/**
|
|
1850
|
+
* A call ON THE RESULT of another call — `dispatch(job).waitResult()`,
|
|
1851
|
+
* `load(id).unwrap()`. The receiver is a value this body already holds, and
|
|
1852
|
+
* the call that produced it is a call site of this same body: it is visited
|
|
1853
|
+
* too, and reports the gap if there is one. Reporting here as well charges
|
|
1854
|
+
* the same unknown twice, and the second charge reads as a distinct defect.
|
|
1855
|
+
*/
|
|
1856
|
+
const receiver = unwrapAwait(expression.getExpression());
|
|
1857
|
+
if (Node.isCallExpression(receiver)) return false;
|
|
1694
1858
|
const root = rootSymbolOf(expression.getExpression());
|
|
1695
1859
|
if (!root) return false;
|
|
1696
1860
|
if (symbols.has(root)) return false;
|
|
1697
1861
|
if (root === "this") return true;
|
|
1698
1862
|
return imports.has(root);
|
|
1699
1863
|
}
|
|
1864
|
+
/** `(await x())` and `x()` are the same receiver for this purpose. */
|
|
1865
|
+
function unwrapAwait(node) {
|
|
1866
|
+
let current = node;
|
|
1867
|
+
while (Node.isAwaitExpression(current) || Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current)) current = current.getExpression();
|
|
1868
|
+
return current;
|
|
1869
|
+
}
|
|
1700
1870
|
/**
|
|
1701
1871
|
* Hash of the NORMALISED body: comments and whitespace removed.
|
|
1702
1872
|
*
|
|
@@ -1802,7 +1972,15 @@ function countDataFunctions(stores, usage, options) {
|
|
|
1802
1972
|
const detAttributes = store.attributes.filter((attribute) => !attribute.isIdentifier);
|
|
1803
1973
|
const det = detAttributes.length;
|
|
1804
1974
|
const refs = options.retStrategy === "composition" ? 1 + store.subgroups.length : 1;
|
|
1805
|
-
|
|
1975
|
+
/**
|
|
1976
|
+
* Maintained by the application, or by another system?
|
|
1977
|
+
*
|
|
1978
|
+
* A write reachable from an entry point is the common case. A write from a
|
|
1979
|
+
* job or a seeder maintains the store just as much — AFP §6.5.4 asks who
|
|
1980
|
+
* maintains it, not which route does.
|
|
1981
|
+
*/
|
|
1982
|
+
const maintained = use.written || options.writtenAnywhere.has(store.name);
|
|
1983
|
+
const type = options.externallyMaintained.has(store.name) || !maintained ? "EIF" : "ILF";
|
|
1806
1984
|
const complexity = complexityOf(type, refs, det, options.tables);
|
|
1807
1985
|
counted.push({
|
|
1808
1986
|
id: `data:${store.name}`,
|
|
@@ -1814,7 +1992,7 @@ function countDataFunctions(stores, usage, options) {
|
|
|
1814
1992
|
complexity,
|
|
1815
1993
|
points: pointsOf(type, complexity, options.weights),
|
|
1816
1994
|
rationale: {
|
|
1817
|
-
rule: options.externallyMaintained.has(store.name) ? "afp:6.5.4 externally maintained by boundary configuration -> EIF" :
|
|
1995
|
+
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",
|
|
1818
1996
|
detSources: detAttributes.map((attribute) => `${store.columnSource}:${store.table ?? store.name}.${attribute.name}`),
|
|
1819
1997
|
refSources: options.retStrategy === "composition" ? ["1 (main group)", ...store.subgroups.map((s) => `composition:${s}`)] : ["1 (constant: a logical subgroup is not derivable from code)"]
|
|
1820
1998
|
}
|
|
@@ -1900,6 +2078,17 @@ function detsFor(entry, behavior, touched, type, options) {
|
|
|
1900
2078
|
};
|
|
1901
2079
|
for (const param of entry.signature.match(/:[A-Za-z_][\w]*/g) ?? []) add(param.slice(1), `param:${param}`);
|
|
1902
2080
|
for (const field of behavior.inputFields) add(field.split(".").pop(), `validator:${field}`);
|
|
2081
|
+
/**
|
|
2082
|
+
* §7.2 asks whether a user-recognisable field crosses the boundary, not how it
|
|
2083
|
+
* was declared. `request.input('title')` does, and counted for nothing while
|
|
2084
|
+
* input DETs came only from VineJS — so a transaction reading six fields this
|
|
2085
|
+
* way sat at 1 DET, the floor of its band.
|
|
2086
|
+
*
|
|
2087
|
+
* After the validator, and deduplicated by field name: where both exist the
|
|
2088
|
+
* validator is the better provenance to print, and the same field must not be
|
|
2089
|
+
* paid for twice.
|
|
2090
|
+
*/
|
|
2091
|
+
for (const field of behavior.requestFields) add(field, `request:${field}`);
|
|
1903
2092
|
if (type === "EO" || type === "EQ") for (const store of touched) {
|
|
1904
2093
|
const columns = options.countedStores.get(store).attributes.filter((attribute) => !attribute.isIdentifier);
|
|
1905
2094
|
for (const column of columns) add(`${store}.${column.name}`, `output:${store}.${column.name}`);
|
|
@@ -1958,8 +2147,16 @@ function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
|
|
|
1958
2147
|
* It appears in every report, and `fp:diff` refuses to compare counts produced
|
|
1959
2148
|
* by different versions — otherwise the difference would measure the rule
|
|
1960
2149
|
* change rather than the work.
|
|
2150
|
+
*
|
|
2151
|
+
* It must be bumped by ANY change that moves the number for unchanged code, and
|
|
2152
|
+
* that is easy to forget: four such changes landed in 1.1.0 — maintenance read
|
|
2153
|
+
* across the whole project rather than from routes alone, a job followed into
|
|
2154
|
+
* `process`, an event followed into its listeners, and `request.input(…)` counted
|
|
2155
|
+
* as a DET. Without the bump, a baseline saved by the previous version would have
|
|
2156
|
+
* compared cleanly against this one and billed the tool's own improvement as work
|
|
2157
|
+
* done. The guard exists for exactly that, and only this constant arms it.
|
|
1961
2158
|
*/
|
|
1962
|
-
const RULESET_VERSION = "1.
|
|
2159
|
+
const RULESET_VERSION = "1.1.0";
|
|
1963
2160
|
function count(input, options = {}) {
|
|
1964
2161
|
const warnings = [];
|
|
1965
2162
|
const usage = usageOf(input);
|
|
@@ -1972,16 +2169,30 @@ function count(input, options = {}) {
|
|
|
1972
2169
|
...options.weights
|
|
1973
2170
|
};
|
|
1974
2171
|
const infrastructure = new Set(options.boundary?.infrastructure ?? []);
|
|
2172
|
+
const business = new Set(options.boundary?.business ?? []);
|
|
1975
2173
|
const countable = input.stores.filter((store) => {
|
|
1976
2174
|
if (infrastructure.has(store.name) || infrastructure.has(store.table ?? "")) {
|
|
1977
2175
|
warnings.push(`excluded by boundary configuration: ${store.name}`);
|
|
1978
2176
|
return false;
|
|
1979
2177
|
}
|
|
1980
2178
|
const technical = isTechnical(store);
|
|
1981
|
-
if (technical)
|
|
1982
|
-
|
|
2179
|
+
if (!technical) return true;
|
|
2180
|
+
/**
|
|
2181
|
+
* The naming filter is a heuristic over names, so it catches business data
|
|
2182
|
+
* whose name happens to match — a chat session the user manages, a document
|
|
2183
|
+
* template they maintain. Only a person knows which, so a declaration wins
|
|
2184
|
+
* over the pattern, and the report says it was overruled rather than
|
|
2185
|
+
* quietly counting one more store.
|
|
2186
|
+
*/
|
|
2187
|
+
if (business.has(store.name) || business.has(store.table ?? "")) {
|
|
2188
|
+
warnings.push(`kept by boundary configuration: ${store.name} — the AFP naming filter had excluded it (${technical})`);
|
|
2189
|
+
return true;
|
|
2190
|
+
}
|
|
2191
|
+
warnings.push(`technical, excluded: ${store.name} (${technical})`);
|
|
2192
|
+
return false;
|
|
1983
2193
|
});
|
|
1984
2194
|
const dataFunctions = countDataFunctions(countable, usage, {
|
|
2195
|
+
writtenAnywhere: input.writtenAnywhere ?? /* @__PURE__ */ new Set(),
|
|
1985
2196
|
retStrategy: options.retStrategy ?? "constant",
|
|
1986
2197
|
externallyMaintained: new Set(options.boundary?.externallyMaintained ?? []),
|
|
1987
2198
|
tables,
|
|
@@ -1996,6 +2207,7 @@ function count(input, options = {}) {
|
|
|
1996
2207
|
weights
|
|
1997
2208
|
});
|
|
1998
2209
|
warnings.push(...opaqueColumnWarnings(countable, input));
|
|
2210
|
+
warnings.push(...unreadableInputWarnings(input));
|
|
1999
2211
|
const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings);
|
|
2000
2212
|
return {
|
|
2001
2213
|
ruleset: "afp",
|
|
@@ -2033,6 +2245,42 @@ function opaqueColumnWarnings(stores, input) {
|
|
|
2033
2245
|
if (found.length === 0) return [];
|
|
2034
2246
|
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
2247
|
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Transactions that read the request in a way that enumerates nothing.
|
|
2250
|
+
*
|
|
2251
|
+
* `request.all()`, `request.body()`, `request.except([…])` — whatever arrives is
|
|
2252
|
+
* read, and no analysis can say how many fields that is. The transaction is
|
|
2253
|
+
* counted from its route parameters alone, which puts it at the floor of its
|
|
2254
|
+
* complexity band: an undercount, and a silent one until now.
|
|
2255
|
+
*
|
|
2256
|
+
* Two earlier versions of this warning were wrong and are worth recording,
|
|
2257
|
+
* because both looked like rigour. The first flagged every write with no
|
|
2258
|
+
* validator and named `users.destroy`, `DELETE /questions/:id` and
|
|
2259
|
+
* `notifications.markRead` — transactions that legitimately carry nothing beyond
|
|
2260
|
+
* the route parameter, exactly as counting-decisions §7 describes. The second
|
|
2261
|
+
* narrowed to POST, PUT and PATCH, and still named `POST /orders/:id/submit` and
|
|
2262
|
+
* `POST /orders/:id/clear`: in an AdonisJS application POST is how a state
|
|
2263
|
+
* transition is expressed, so the verb does not separate a submission from a
|
|
2264
|
+
* trigger.
|
|
2265
|
+
*
|
|
2266
|
+
* What separates them is whether the handler reads the request at all. A trigger
|
|
2267
|
+
* does not. So the enumerable reads are now COUNTED — `request.input('title')` is
|
|
2268
|
+
* a DET — and only what cannot be enumerated is reported. A warning that names
|
|
2269
|
+
* routes with nothing wrong with them is the noise that teaches people to stop
|
|
2270
|
+
* reading the confidence block.
|
|
2271
|
+
*/
|
|
2272
|
+
function unreadableInputWarnings(input) {
|
|
2273
|
+
const blind = input.entryPoints.map((entry) => ({
|
|
2274
|
+
entry,
|
|
2275
|
+
behavior: input.behaviors.get(entry.id)
|
|
2276
|
+
})).filter(({ behavior }) => behavior?.opaqueRequest && behavior.inputFields.length === 0);
|
|
2277
|
+
if (blind.length === 0) return [];
|
|
2278
|
+
return [
|
|
2279
|
+
`${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:`,
|
|
2280
|
+
...blind.slice(0, 10).map(({ entry }) => ` ${entry.trigger} ${entry.signature}`),
|
|
2281
|
+
...blind.length > 10 ? [` … and ${blind.length - 10} more`] : []
|
|
2282
|
+
];
|
|
2283
|
+
}
|
|
2036
2284
|
/** a column whose shape says nothing about what it holds */
|
|
2037
2285
|
const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
|
|
2038
2286
|
/**
|
|
@@ -2248,7 +2496,8 @@ async function analyze(root, options = {}) {
|
|
|
2248
2496
|
const jsonSchemas = collectJsonSchemas(app);
|
|
2249
2497
|
const analyzer = createAnalyzer(app, stores, {
|
|
2250
2498
|
maxDepth: options.maxDepth,
|
|
2251
|
-
callResolvers: options.resolvers?.call
|
|
2499
|
+
callResolvers: options.resolvers?.call,
|
|
2500
|
+
eventBindings: collectEventBindings(app)
|
|
2252
2501
|
});
|
|
2253
2502
|
const behaviors = new Map(entryPoints.filter((entry) => entry.handler).map((entry) => [entry.id, analyzer.analyze(entry.handler)]));
|
|
2254
2503
|
const resolved = [...behaviors.values()].filter((behavior) => behavior.unresolved.length === 0).length;
|
|
@@ -2275,6 +2524,14 @@ async function analyze(root, options = {}) {
|
|
|
2275
2524
|
by: "validator"
|
|
2276
2525
|
}
|
|
2277
2526
|
})),
|
|
2527
|
+
requestFields: behavior.requestFields.map((name) => ({
|
|
2528
|
+
name,
|
|
2529
|
+
provenance: {
|
|
2530
|
+
file: app.root,
|
|
2531
|
+
by: "request"
|
|
2532
|
+
}
|
|
2533
|
+
})),
|
|
2534
|
+
opaqueRequest: behavior.opaqueRequest,
|
|
2278
2535
|
outputFields: [],
|
|
2279
2536
|
trace: behavior.trace,
|
|
2280
2537
|
unresolved: behavior.unresolved
|
|
@@ -2296,11 +2553,12 @@ async function analyze(root, options = {}) {
|
|
|
2296
2553
|
stores,
|
|
2297
2554
|
entryPoints,
|
|
2298
2555
|
behaviors,
|
|
2299
|
-
jsonSchemas
|
|
2556
|
+
jsonSchemas,
|
|
2557
|
+
writtenAnywhere: analyzer.writtenAnywhere()
|
|
2300
2558
|
}, options),
|
|
2301
2559
|
source: describeSource(root, options.configFile ?? null)
|
|
2302
2560
|
}
|
|
2303
2561
|
};
|
|
2304
2562
|
}
|
|
2305
2563
|
//#endregion
|
|
2306
|
-
export { analyze as n,
|
|
2564
|
+
export { analyze as n, CoverageTooLowError as t };
|