@filipebraida/adonis-function-points 0.5.0 → 0.7.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 +170 -0
- package/README.md +101 -292
- package/build/{calibration-8eV8CEix.js → calibration-DVIf8hcE.js} +42 -3
- package/build/commands/main.js +6 -6
- package/build/{fp_calibrate-DUbHiifm.js → fp_calibrate-3TxGdS1b.js} +1 -1
- package/build/{fp_count-ChtblhZV.js → fp_count-arGLnVlY.js} +1 -1
- package/build/{fp_diff-Dt7J4IWu.js → fp_diff-DBBvzq5x.js} +1 -1
- package/build/{fp_explain-DZJ--0-S.js → fp_explain-aNFApwiT.js} +1 -1
- package/build/{fp_inventory-CPtmuuke.js → fp_inventory-DIjKIC9t.js} +1 -1
- package/build/{fp_metrics-et8F1Wvt.js → fp_metrics-BpU61waG.js} +1 -1
- package/build/index.d.ts +8 -4
- package/build/index.js +4 -4
- package/build/{pipeline-CNTBhs6o.js → pipeline-DO2301fV.js} +2131 -389
- package/build/{resolvers-PJwo2Z8R.js → resolvers-DaU4uAqT.js} +603 -165
- package/build/{runners-DIt1G85i.js → runners-Dm7cWGa-.js} +6 -3
- package/build/src/albrecht/counter.d.ts +38 -5
- package/build/src/albrecht/data_functions.d.ts +49 -3
- package/build/src/albrecht/diff.d.ts +27 -0
- package/build/src/albrecht/index.d.ts +1 -0
- package/build/src/albrecht/opaque.d.ts +90 -0
- package/build/src/albrecht/technical_filter.d.ts +18 -11
- package/build/src/albrecht/transactional_functions.d.ts +7 -0
- package/build/src/cli.js +2 -2
- package/build/src/define_config.d.ts +55 -57
- package/build/src/inventory/graph/call_graph.d.ts +44 -0
- package/build/src/inventory/graph/deliveries.d.ts +88 -0
- package/build/src/inventory/graph/output_fields.d.ts +143 -0
- package/build/src/inventory/paths.d.ts +2 -0
- package/build/src/inventory/resolvers/index.d.ts +21 -0
- package/build/src/inventory/resolvers/index.js +2 -2
- package/build/src/inventory/resolvers/job_dispatch.d.ts +20 -0
- package/build/src/inventory/resolvers/local_function.d.ts +24 -0
- package/build/src/inventory/resolvers/transformer.d.ts +0 -23
- package/build/src/inventory/sources/commands.d.ts +14 -0
- package/build/src/inventory/sources/jobs.d.ts +27 -0
- package/build/src/pipeline.js +1 -1
- package/build/src/types.d.ts +49 -1
- package/build/stubs/config.stub +29 -16
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { _ as relativeTo, a as chainShapeOf, c as unwrap$1, d as collectEventBindings, f as detectAccess, g as isSeeder, h as isApplicationCode, i as resolveCall, l as DISPATCH_METHODS, m as rootSymbolOf, o as mappedLiteralOf, p as hooksFiredBy, r as isTechnicalWrite, s as outputFieldsIn, t as BUILTIN_CALL_RESOLVERS, u as EXECUTION_METHODS, v as samePath, y as toPosix } from "./resolvers-DaU4uAqT.js";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
@@ -465,7 +465,7 @@ function walkChain(start, app, project) {
|
|
|
465
465
|
if (samePath(cls.getSourceFile().getFilePath(), app.generated.dataSchema)) columnSource = "generated-schema";
|
|
466
466
|
for (const attribute of columnsOf(cls)) if (!attributes.has(attribute.name)) attributes.set(attribute.name, attribute);
|
|
467
467
|
for (const parent of parentsOf(cls)) {
|
|
468
|
-
const origin = originOf(parent.getText(), cls.getSourceFile());
|
|
468
|
+
const origin = originOf$1(parent.getText(), cls.getSourceFile());
|
|
469
469
|
if (origin?.specifier === LUCID_ORM && origin.exportedName === BASE_MODEL) {
|
|
470
470
|
reachesLucid = true;
|
|
471
471
|
continue;
|
|
@@ -501,7 +501,7 @@ function walkChain(start, app, project) {
|
|
|
501
501
|
*/
|
|
502
502
|
function reasonFor(parent, file, app) {
|
|
503
503
|
if (Node.isCallExpression(parent)) return "mixin factory: the column only exists on the class the function returns, and evaluating that return is beyond the current static analysis";
|
|
504
|
-
const origin = originOf(parent.getText(), file);
|
|
504
|
+
const origin = originOf$1(parent.getText(), file);
|
|
505
505
|
if (origin && !app.resolveSpecifier(origin.specifier)) return `base class outside the application (${origin.specifier}): the package cannot know which columns it adds`;
|
|
506
506
|
return "base class not found in the application";
|
|
507
507
|
}
|
|
@@ -519,7 +519,7 @@ function parentsOf(cls) {
|
|
|
519
519
|
function resolveClass(name, from, app, project) {
|
|
520
520
|
const local = from.getClass(name);
|
|
521
521
|
if (local) return local;
|
|
522
|
-
const origin = originOf(name, from);
|
|
522
|
+
const origin = originOf$1(name, from);
|
|
523
523
|
if (!origin) return null;
|
|
524
524
|
const target = app.resolveSpecifier(origin.specifier);
|
|
525
525
|
if (!target) return null;
|
|
@@ -528,7 +528,7 @@ function resolveClass(name, from, app, project) {
|
|
|
528
528
|
if (origin.exportedName === "default") return file.getClasses().find((candidate) => candidate.isDefaultExport()) ?? null;
|
|
529
529
|
return file.getClass(origin.exportedName) ?? null;
|
|
530
530
|
}
|
|
531
|
-
function originOf(local, file) {
|
|
531
|
+
function originOf$1(local, file) {
|
|
532
532
|
for (const declaration of file.getImportDeclarations()) {
|
|
533
533
|
const specifier = declaration.getModuleSpecifierValue();
|
|
534
534
|
if (declaration.getDefaultImport()?.getText() === local) return {
|
|
@@ -556,11 +556,27 @@ function columnsOf(cls) {
|
|
|
556
556
|
for (const property of cls.getProperties()) for (const decorator of property.getDecorators()) {
|
|
557
557
|
const full = decorator.getFullName();
|
|
558
558
|
if (full !== "column" && !full.startsWith("column.")) continue;
|
|
559
|
-
const
|
|
559
|
+
const options = decorator.getExpression().getText();
|
|
560
|
+
const isIdentifier = /isPrimary\s*:\s*true/.test(options);
|
|
561
|
+
/**
|
|
562
|
+
* `autoCreate` / `autoUpdate`: the framework stamps it on insert or update.
|
|
563
|
+
* The user neither supplies nor maintains the value, so it is not a DET —
|
|
564
|
+
* counting-decisions §6. Recorded here as a fact about the column; the
|
|
565
|
+
* counting side decides what to do with it.
|
|
566
|
+
*/
|
|
567
|
+
const system = /auto(Create|Update)\s*:\s*true/.test(options);
|
|
568
|
+
/**
|
|
569
|
+
* `serializeAs: null`: Lucid never serialises the column, so it cannot leave
|
|
570
|
+
* the boundary on an output. It is still a DET of the data function — the
|
|
571
|
+
* user supplies a password — counting-decisions §6.
|
|
572
|
+
*/
|
|
573
|
+
const hidden = /serializeAs\s*:\s*null/.test(options);
|
|
560
574
|
attributes.push({
|
|
561
575
|
name: property.getName(),
|
|
562
576
|
type: property.getTypeNode()?.getText(),
|
|
563
577
|
isIdentifier,
|
|
578
|
+
...system ? { system } : {},
|
|
579
|
+
...hidden ? { hidden } : {},
|
|
564
580
|
provenance: {
|
|
565
581
|
file,
|
|
566
582
|
line: property.getStartLineNumber(),
|
|
@@ -949,93 +965,116 @@ const problemAt = (call, expression, reason) => ({
|
|
|
949
965
|
reason
|
|
950
966
|
});
|
|
951
967
|
//#endregion
|
|
952
|
-
//#region src/inventory/sources/
|
|
953
|
-
|
|
968
|
+
//#region src/inventory/sources/commands.ts
|
|
969
|
+
/**
|
|
970
|
+
* Ace commands as entry points — counting-decisions §5, plan 0.7 §C.
|
|
971
|
+
*
|
|
972
|
+
* IFPUG counts batch processes an operator starts. `node ace noticias:importar`
|
|
973
|
+
* reads a feed and writes news: an EI exactly like a `POST`, and until this the
|
|
974
|
+
* collector emitted only `kind: 'http'`, although the type had `command` and §5
|
|
975
|
+
* had decided its identity since 0.1.0.
|
|
976
|
+
*
|
|
977
|
+
* AdonisJS scans `./commands` for the application's own commands; so does this.
|
|
978
|
+
* A command is a class extending `BaseCommand` from `@adonisjs/core/ace` with a
|
|
979
|
+
* literal `static commandName`; its body is `run()`, followed by the same
|
|
980
|
+
* strategies as any handler. What it reaches decides whether it is a transaction
|
|
981
|
+
* at all — a scaffolder that writes files reaches no store and falls out, as a
|
|
982
|
+
* static route does.
|
|
983
|
+
*/
|
|
984
|
+
const ACE_MODULE = "@adonisjs/core/ace";
|
|
985
|
+
const BASE_COMMAND = "BaseCommand";
|
|
986
|
+
/** a generator of test data, by the package everyone uses for it */
|
|
987
|
+
const FAKER = /^@faker-js\/faker/;
|
|
988
|
+
const GENERATES_DATA_HINT = "imports @faker-js/faker: generates data, probably a development tool";
|
|
989
|
+
function collectCommands(app) {
|
|
954
990
|
const project = new Project({
|
|
955
991
|
skipAddingFilesFromTsConfig: true,
|
|
956
992
|
skipFileDependencyResolution: true,
|
|
957
993
|
compilerOptions: { allowJs: false }
|
|
958
994
|
});
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
995
|
+
project.addSourceFilesAtPaths(`${toPosix(app.root)}/commands/**/*.ts`);
|
|
996
|
+
const entryPoints = [];
|
|
997
|
+
for (const file of project.getSourceFiles()) {
|
|
998
|
+
if (/\.(spec|test)\.ts$/.test(file.getBaseName())) continue;
|
|
999
|
+
const baseCommand = baseCommandNameIn(file);
|
|
1000
|
+
if (!baseCommand) continue;
|
|
1001
|
+
for (const cls of file.getClasses()) {
|
|
1002
|
+
if (cls.getExtends()?.getExpression().getText() !== baseCommand) continue;
|
|
1003
|
+
const name = commandNameOf(cls);
|
|
1004
|
+
if (!name) continue;
|
|
1005
|
+
const filePath = file.getFilePath();
|
|
1006
|
+
const hints = importsFaker(file) ? [GENERATES_DATA_HINT] : [];
|
|
1007
|
+
entryPoints.push({
|
|
1008
|
+
id: `ace ${name}`,
|
|
1009
|
+
kind: "command",
|
|
1010
|
+
module: app.moduleOf(filePath),
|
|
1011
|
+
trigger: "ace",
|
|
1012
|
+
signature: name,
|
|
1013
|
+
name,
|
|
1014
|
+
handler: {
|
|
1015
|
+
file: filePath,
|
|
1016
|
+
member: "run"
|
|
1017
|
+
},
|
|
1018
|
+
identity: `ace ${name}`,
|
|
1019
|
+
provenance: {
|
|
1020
|
+
file: filePath,
|
|
1021
|
+
line: cls.getStartLineNumber(),
|
|
1022
|
+
by: "ace-commands"
|
|
1023
|
+
},
|
|
1024
|
+
...hints.length ? { hints } : {}
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
990
1027
|
}
|
|
991
|
-
return
|
|
1028
|
+
return entryPoints.sort((a, b) => a.identity.localeCompare(b.identity));
|
|
992
1029
|
}
|
|
993
|
-
/**
|
|
994
|
-
function
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1030
|
+
/** the local name `BaseCommand` was imported under from `@adonisjs/core/ace`, if it was */
|
|
1031
|
+
function baseCommandNameIn(file) {
|
|
1032
|
+
for (const declaration of file.getImportDeclarations()) {
|
|
1033
|
+
if (declaration.getModuleSpecifierValue() !== ACE_MODULE) continue;
|
|
1034
|
+
const named = declaration.getNamedImports().find((n) => n.getName() === BASE_COMMAND);
|
|
1035
|
+
if (named) return named.getAliasNode()?.getText() ?? BASE_COMMAND;
|
|
1036
|
+
}
|
|
1037
|
+
return null;
|
|
998
1038
|
}
|
|
999
|
-
/**
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
return
|
|
1039
|
+
/** `static commandName = 'noticias:importar'` — a literal; a computed name is nobody's identity */
|
|
1040
|
+
function commandNameOf(cls) {
|
|
1041
|
+
const property = cls.getStaticProperty("commandName");
|
|
1042
|
+
if (!property || !Node.isPropertyDeclaration(property)) return null;
|
|
1043
|
+
const initializer = property.getInitializer();
|
|
1044
|
+
if (!initializer) return null;
|
|
1045
|
+
if (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer)) return initializer.getLiteralValue();
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
function importsFaker(file) {
|
|
1049
|
+
return file.getImportDeclarations().some((d) => FAKER.test(d.getModuleSpecifierValue()));
|
|
1007
1050
|
}
|
|
1051
|
+
/** the ace decorators that declare what the operator types: `@flags.string()`, `@args.string()` */
|
|
1052
|
+
const INPUT_DECORATORS = new Set(["flags", "args"]);
|
|
1008
1053
|
/**
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
* array of scalar 1 (repeating group)
|
|
1014
|
-
* array of object the object's leaves, once
|
|
1015
|
-
* enum / const 1
|
|
1054
|
+
* The input DETs of a command: its `@flags.*` and `@args.*`, named as the
|
|
1055
|
+
* operator types them — `flagName` / `argumentName` when given, the property
|
|
1056
|
+
* otherwise. Returned as `flags.<name>` / `args.<name>` so the counter can say
|
|
1057
|
+
* which is which.
|
|
1016
1058
|
*/
|
|
1017
|
-
function
|
|
1018
|
-
const
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
const
|
|
1022
|
-
if (!
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const inner = nested ? leavesOf$1(nested, path) : [];
|
|
1030
|
-
return inner.length > 0 ? inner : [path];
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
1033
|
-
if (items) {
|
|
1034
|
-
const element = unwrap(items.getInitializer())?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
1035
|
-
const inner = element ? leavesOf$1(element, prefix) : [];
|
|
1036
|
-
return inner.length > 0 ? inner : [prefix];
|
|
1059
|
+
function commandFieldsOf(cls) {
|
|
1060
|
+
const fields = [];
|
|
1061
|
+
for (const property of cls.getProperties()) for (const decorator of property.getDecorators()) {
|
|
1062
|
+
const call = decorator.getCallExpression();
|
|
1063
|
+
const callee = call?.getExpression();
|
|
1064
|
+
if (!call || !callee || !Node.isPropertyAccessExpression(callee)) continue;
|
|
1065
|
+
const kind = callee.getExpression().getText();
|
|
1066
|
+
if (!INPUT_DECORATORS.has(kind)) continue;
|
|
1067
|
+
const options = call.getArguments()[0];
|
|
1068
|
+
const literal = (options && Node.isObjectLiteralExpression(options) ? ["flagName", "argumentName"].map((key) => options.getProperty(key)).find((p) => p && Node.isPropertyAssignment(p)) : void 0)?.getInitializer();
|
|
1069
|
+
const name = literal && Node.isStringLiteral(literal) ? literal.getLiteralValue() : property.getName();
|
|
1070
|
+
fields.push(`${kind}.${name}`);
|
|
1037
1071
|
}
|
|
1038
|
-
return
|
|
1072
|
+
return fields;
|
|
1073
|
+
}
|
|
1074
|
+
/** is this class an ace command? — the graph asks, to read its flags as input */
|
|
1075
|
+
function isCommandClass(cls) {
|
|
1076
|
+
const baseCommand = baseCommandNameIn(cls.getSourceFile());
|
|
1077
|
+
return !!baseCommand && cls.getExtends()?.getExpression().getText() === baseCommand;
|
|
1039
1078
|
}
|
|
1040
1079
|
//#endregion
|
|
1041
1080
|
//#region src/inventory/graph/noise.ts
|
|
@@ -1148,7 +1187,10 @@ const FRAMEWORK_SERVICES = new Set([
|
|
|
1148
1187
|
"encryption",
|
|
1149
1188
|
"i18n",
|
|
1150
1189
|
"ally",
|
|
1151
|
-
"bouncer"
|
|
1190
|
+
"bouncer",
|
|
1191
|
+
"ui",
|
|
1192
|
+
"colors",
|
|
1193
|
+
"app"
|
|
1152
1194
|
]);
|
|
1153
1195
|
/** Is this call one that cannot reach a data store? */
|
|
1154
1196
|
function isNoise(call, owner) {
|
|
@@ -1223,6 +1265,814 @@ function isNoiseMember(file, member) {
|
|
|
1223
1265
|
return base !== void 0 && FRAMEWORK_SERVICES.has(base);
|
|
1224
1266
|
}
|
|
1225
1267
|
//#endregion
|
|
1268
|
+
//#region src/inventory/graph/deliveries.ts
|
|
1269
|
+
/** `inertia.render(page, props)`, `inertia.modal(page, props)`, `view.render(view, props)` */
|
|
1270
|
+
const RENDERERS = new Set(["inertia", "view"]);
|
|
1271
|
+
const RENDER_METHODS = new Set(["render", "modal"]);
|
|
1272
|
+
/** `response.json(x)`, `.ok(x)`, `.created(x)`, `.send(x)`, `.accepted(x)` */
|
|
1273
|
+
const RESPONSE_METHODS = new Set([
|
|
1274
|
+
"json",
|
|
1275
|
+
"ok",
|
|
1276
|
+
"created",
|
|
1277
|
+
"accepted",
|
|
1278
|
+
"send"
|
|
1279
|
+
]);
|
|
1280
|
+
/**
|
|
1281
|
+
* An ace command prints: `this.ui.table().row([…])`, `this.logger.info(…)`,
|
|
1282
|
+
* `console.log(…)`. What a report hands to the terminal is what leaves — every
|
|
1283
|
+
* argument is delivered, read like a prop (plan 0.7 §C).
|
|
1284
|
+
*/
|
|
1285
|
+
const PRINTERS = new Set([
|
|
1286
|
+
"ui",
|
|
1287
|
+
"logger",
|
|
1288
|
+
"console"
|
|
1289
|
+
]);
|
|
1290
|
+
/** `x.data`, `x.rows` on a paginated / wrapped result hand the collection on */
|
|
1291
|
+
const PASSES_THROUGH = new Set([
|
|
1292
|
+
"data",
|
|
1293
|
+
"rows",
|
|
1294
|
+
"all",
|
|
1295
|
+
"toJSON",
|
|
1296
|
+
"serialize"
|
|
1297
|
+
]);
|
|
1298
|
+
/** keys of a result that carry its rows: picking one of these is not picking one value */
|
|
1299
|
+
const PASSES_ROWS = new Set([
|
|
1300
|
+
"data",
|
|
1301
|
+
"rows",
|
|
1302
|
+
"items",
|
|
1303
|
+
"results",
|
|
1304
|
+
"list",
|
|
1305
|
+
"linhas",
|
|
1306
|
+
"itens"
|
|
1307
|
+
]);
|
|
1308
|
+
/** properties of a result that are one value, not its rows */
|
|
1309
|
+
const SCALAR_PROPS = new Set([
|
|
1310
|
+
"length",
|
|
1311
|
+
"size",
|
|
1312
|
+
"total",
|
|
1313
|
+
"count"
|
|
1314
|
+
]);
|
|
1315
|
+
/**
|
|
1316
|
+
* What Lucid's `paginator.getMeta()` says that a page can show. The URLs are
|
|
1317
|
+
* navigation and `firstPage` a constant: neither is a user-recognisable attribute.
|
|
1318
|
+
*/
|
|
1319
|
+
const PAGINATOR_META = [
|
|
1320
|
+
"total",
|
|
1321
|
+
"perPage",
|
|
1322
|
+
"currentPage",
|
|
1323
|
+
"lastPage"
|
|
1324
|
+
];
|
|
1325
|
+
/** methods that return the same collection, or one of its rows: what leaves is the receiver */
|
|
1326
|
+
const SAME_COLLECTION = new Set([
|
|
1327
|
+
"slice",
|
|
1328
|
+
"filter",
|
|
1329
|
+
"sort",
|
|
1330
|
+
"sortBy",
|
|
1331
|
+
"toSorted",
|
|
1332
|
+
"reverse",
|
|
1333
|
+
"toReversed",
|
|
1334
|
+
"concat",
|
|
1335
|
+
"flat",
|
|
1336
|
+
"find",
|
|
1337
|
+
"findLast",
|
|
1338
|
+
"at",
|
|
1339
|
+
"first",
|
|
1340
|
+
"last"
|
|
1341
|
+
]);
|
|
1342
|
+
/** reads of the request whose result echoes input already counted on entry */
|
|
1343
|
+
const ECHOES_INPUT = new Set([
|
|
1344
|
+
"validateUsing",
|
|
1345
|
+
"input",
|
|
1346
|
+
"only",
|
|
1347
|
+
"all",
|
|
1348
|
+
"body",
|
|
1349
|
+
"qs",
|
|
1350
|
+
"params"
|
|
1351
|
+
]);
|
|
1352
|
+
/** Inertia's lazy props: `inertia.defer(() => q.handle())` — the callback's value is what leaves */
|
|
1353
|
+
const INERTIA_LAZY = new Set([
|
|
1354
|
+
"defer",
|
|
1355
|
+
"lazy",
|
|
1356
|
+
"optional",
|
|
1357
|
+
"always",
|
|
1358
|
+
"merge",
|
|
1359
|
+
"scroll",
|
|
1360
|
+
"once"
|
|
1361
|
+
]);
|
|
1362
|
+
/** a yes/no: an authorisation check, a membership test */
|
|
1363
|
+
const BOOLEAN_METHODS = new Set([
|
|
1364
|
+
"allows",
|
|
1365
|
+
"denies",
|
|
1366
|
+
"can",
|
|
1367
|
+
"cannot",
|
|
1368
|
+
"includes",
|
|
1369
|
+
"has",
|
|
1370
|
+
"startsWith",
|
|
1371
|
+
"endsWith",
|
|
1372
|
+
"test"
|
|
1373
|
+
]);
|
|
1374
|
+
/** a value formatted: still one value */
|
|
1375
|
+
const FORMAT_METHODS = new Set([
|
|
1376
|
+
"join",
|
|
1377
|
+
"toString",
|
|
1378
|
+
"toISO",
|
|
1379
|
+
"toISODate",
|
|
1380
|
+
"toISOTime",
|
|
1381
|
+
"toISOString",
|
|
1382
|
+
"toFormat",
|
|
1383
|
+
"toRFC2822",
|
|
1384
|
+
"toHTTP",
|
|
1385
|
+
"toSQL",
|
|
1386
|
+
"toLocaleString",
|
|
1387
|
+
"toLocaleDateString",
|
|
1388
|
+
"toFixed",
|
|
1389
|
+
"toUnixInteger",
|
|
1390
|
+
"toMillis",
|
|
1391
|
+
"trim",
|
|
1392
|
+
"toUpperCase",
|
|
1393
|
+
"toLowerCase",
|
|
1394
|
+
"padStart",
|
|
1395
|
+
"padEnd",
|
|
1396
|
+
"replace",
|
|
1397
|
+
"slice",
|
|
1398
|
+
"substring"
|
|
1399
|
+
]);
|
|
1400
|
+
/** framework services whose calls hand back one value: a translation, a session key, a URL */
|
|
1401
|
+
const SCALAR_SERVICES = new Set([
|
|
1402
|
+
"i18n",
|
|
1403
|
+
"session",
|
|
1404
|
+
"env",
|
|
1405
|
+
"router",
|
|
1406
|
+
"encryption",
|
|
1407
|
+
"hash",
|
|
1408
|
+
"config",
|
|
1409
|
+
"app"
|
|
1410
|
+
]);
|
|
1411
|
+
/** built-ins whose static calls are one value or a constant list: `Object.values(Enum)`, `JSON.stringify(x)` */
|
|
1412
|
+
const NATIVE_GLOBALS = new Set([
|
|
1413
|
+
"Object",
|
|
1414
|
+
"Array",
|
|
1415
|
+
"JSON",
|
|
1416
|
+
"Math",
|
|
1417
|
+
"Number",
|
|
1418
|
+
"String",
|
|
1419
|
+
"Boolean",
|
|
1420
|
+
"Date",
|
|
1421
|
+
"Intl"
|
|
1422
|
+
]);
|
|
1423
|
+
/**
|
|
1424
|
+
* The deliveries of a body: every props argument handed to a renderer or a
|
|
1425
|
+
* response method, and what a `return` hands back that is not one of those calls.
|
|
1426
|
+
*/
|
|
1427
|
+
function deliveriesIn(ctx) {
|
|
1428
|
+
const deliveries = [];
|
|
1429
|
+
const returns = [];
|
|
1430
|
+
let any = false;
|
|
1431
|
+
let anyReturn = false;
|
|
1432
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1433
|
+
for (const call of ctx.body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1434
|
+
const callee = call.getExpression();
|
|
1435
|
+
if (!Node.isPropertyAccessExpression(callee)) continue;
|
|
1436
|
+
const method = callee.getName();
|
|
1437
|
+
const receiver = lastSegmentOf(callee.getExpression());
|
|
1438
|
+
let payload;
|
|
1439
|
+
if (RENDERERS.has(receiver) && RENDER_METHODS.has(method)) payload = call.getArguments()[1];
|
|
1440
|
+
else if (receiver === "response" && RESPONSE_METHODS.has(method)) payload = call.getArguments()[0];
|
|
1441
|
+
else if (isPrinter(callee.getExpression(), ctx.body)) {
|
|
1442
|
+
any = true;
|
|
1443
|
+
seen.add(call);
|
|
1444
|
+
for (const argument of call.getArguments()) classify(unwrap$1(argument), "", ctx, deliveries, 0);
|
|
1445
|
+
continue;
|
|
1446
|
+
} else continue;
|
|
1447
|
+
any = true;
|
|
1448
|
+
if (!payload) continue;
|
|
1449
|
+
seen.add(call);
|
|
1450
|
+
classify(unwrap$1(payload), "", ctx, deliveries, 0);
|
|
1451
|
+
}
|
|
1452
|
+
for (const statement of ctx.body.getDescendantsOfKind(SyntaxKind.ReturnStatement)) {
|
|
1453
|
+
if (statement.getFirstAncestor((node) => Node.isArrowFunction(node) || Node.isFunctionExpression(node) || Node.isMethodDeclaration(node) || Node.isFunctionDeclaration(node)) !== ctx.body) continue;
|
|
1454
|
+
const value = unwrap$1(statement.getExpression());
|
|
1455
|
+
if (!value) continue;
|
|
1456
|
+
if (Node.isCallExpression(value)) {
|
|
1457
|
+
if (seen.has(value)) continue;
|
|
1458
|
+
/**
|
|
1459
|
+
* `return response.redirect().toRoute(…)`, `return response.noContent()`,
|
|
1460
|
+
* `return inertia.render(…)` (seen above): a call whose chain is rooted at
|
|
1461
|
+
* the response or a renderer is the response itself, not a value handed
|
|
1462
|
+
* back — nothing to classify.
|
|
1463
|
+
*/
|
|
1464
|
+
const root = chainRootOf(value);
|
|
1465
|
+
if (root && (RENDERERS.has(root) || root === "response")) continue;
|
|
1466
|
+
}
|
|
1467
|
+
anyReturn = true;
|
|
1468
|
+
classify(value, "", ctx, returns, 0);
|
|
1469
|
+
}
|
|
1470
|
+
return {
|
|
1471
|
+
calls: deliveries,
|
|
1472
|
+
anyCall: any,
|
|
1473
|
+
returns,
|
|
1474
|
+
anyReturn
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
/** the identifier a call chain is rooted at: `response.redirect().toRoute(x)` -> 'response' */
|
|
1478
|
+
function chainRootOf(node) {
|
|
1479
|
+
let current = node;
|
|
1480
|
+
for (let depth = 0; current && depth < 40; depth++) {
|
|
1481
|
+
if (Node.isCallExpression(current) || Node.isPropertyAccessExpression(current)) {
|
|
1482
|
+
current = current.getExpression();
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
if (Node.isAwaitExpression(current) || Node.isParenthesizedExpression(current)) {
|
|
1486
|
+
current = current.getExpression();
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
return Node.isIdentifier(current) ? current.getText() : Node.isThisExpression(current) ? "this" : null;
|
|
1490
|
+
}
|
|
1491
|
+
return null;
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* `this.ui.table().row(x)`, `this.logger.info(x)`, `console.log(x)`: a chain rooted
|
|
1495
|
+
* at a printer — through a variable too (`const table = this.ui.table(); table.row(x)`).
|
|
1496
|
+
*/
|
|
1497
|
+
function isPrinter(receiver, body, depth = 0) {
|
|
1498
|
+
let current = receiver;
|
|
1499
|
+
for (let steps = 0; current && steps < 20; steps++) {
|
|
1500
|
+
if (Node.isCallExpression(current)) {
|
|
1501
|
+
current = current.getExpression();
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
if (Node.isPropertyAccessExpression(current)) {
|
|
1505
|
+
const inner = current.getExpression();
|
|
1506
|
+
if (Node.isThisExpression(inner)) return PRINTERS.has(current.getName());
|
|
1507
|
+
current = inner;
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
if (!Node.isIdentifier(current)) return false;
|
|
1511
|
+
if (current.getText() === "console") return true;
|
|
1512
|
+
const bound = depth < 3 ? bindingOf(current.getText(), body) : null;
|
|
1513
|
+
return !!bound && isPrinter(bound.initializer, body, depth + 1);
|
|
1514
|
+
}
|
|
1515
|
+
return false;
|
|
1516
|
+
}
|
|
1517
|
+
/** `ctx.inertia` -> 'inertia', `inertia` -> 'inertia', `this.response` -> 'response' */
|
|
1518
|
+
function lastSegmentOf(node) {
|
|
1519
|
+
if (Node.isPropertyAccessExpression(node)) return node.getName();
|
|
1520
|
+
return node.getText();
|
|
1521
|
+
}
|
|
1522
|
+
function classify(value, path, ctx, out, depth) {
|
|
1523
|
+
if (!value || depth > 6) return;
|
|
1524
|
+
if (value.getKind() === SyntaxKind.NullKeyword) return;
|
|
1525
|
+
if (Node.isIdentifier(value) && value.getText() === "undefined") return;
|
|
1526
|
+
/**
|
|
1527
|
+
* A constant with no key — `this.logger.info('done')`, a table's `head(['Nome'])`
|
|
1528
|
+
* — is a label or a message, not a field: AFP counts no message DET (§6). Keyed,
|
|
1529
|
+
* `{ titulo: 'X' }` is a value the page receives, and stays one.
|
|
1530
|
+
*/
|
|
1531
|
+
if (!path && (Node.isStringLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value) || Node.isNumericLiteral(value))) return;
|
|
1532
|
+
if (Node.isObjectLiteralExpression(value)) {
|
|
1533
|
+
for (const property of value.getProperties()) if (Node.isShorthandPropertyAssignment(property)) classify(property.getNameNode(), join(path, property.getName()), ctx, out, depth + 1);
|
|
1534
|
+
else if (Node.isPropertyAssignment(property)) {
|
|
1535
|
+
const name = Node.isComputedPropertyName(property.getNameNode()) ? "*" : property.getName().replace(/^['"]|['"]$/g, "");
|
|
1536
|
+
classify(unwrap$1(property.getInitializer()), join(path, name), ctx, out, depth + 1);
|
|
1537
|
+
} else if (Node.isSpreadAssignment(property)) classify(unwrap$1(property.getExpression()), path, ctx, out, depth + 1);
|
|
1538
|
+
else out.push({
|
|
1539
|
+
kind: "scalar",
|
|
1540
|
+
path: join(path, property.getName?.() ?? "*")
|
|
1541
|
+
});
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
if (Node.isConditionalExpression(value)) {
|
|
1545
|
+
classify(unwrap$1(value.getWhenTrue()), path, ctx, out, depth + 1);
|
|
1546
|
+
classify(unwrap$1(value.getWhenFalse()), path, ctx, out, depth + 1);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
if (Node.isArrayLiteralExpression(value)) {
|
|
1550
|
+
for (const element of value.getElements()) classify(unwrap$1(Node.isSpreadElement(element) ? element.getExpression() : element), path, ctx, out, depth + 1);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
if (Node.isElementAccessExpression(value)) {
|
|
1554
|
+
classify(unwrap$1(value.getExpression()), path, ctx, out, depth + 1);
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (Node.isIdentifier(value)) {
|
|
1558
|
+
const name = value.getText();
|
|
1559
|
+
if (ctx.symbols.has(name)) {
|
|
1560
|
+
out.push({
|
|
1561
|
+
kind: "store",
|
|
1562
|
+
store: ctx.symbols.get(name),
|
|
1563
|
+
path
|
|
1564
|
+
});
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
const bound = bindingOf(name, ctx.body);
|
|
1568
|
+
if (bound) {
|
|
1569
|
+
if (bound.pick && Node.isCallExpression(bound.initializer)) {
|
|
1570
|
+
classifyCall(bound.initializer, path, ctx, out, depth + 1, bound.pick);
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
if (bound.pick && Node.isObjectLiteralExpression(bound.initializer)) {
|
|
1574
|
+
const picked = bound.initializer.getProperty(bound.pick);
|
|
1575
|
+
if (picked && Node.isPropertyAssignment(picked)) {
|
|
1576
|
+
classify(unwrap$1(picked.getInitializer()), path, ctx, out, depth + 1);
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (picked && Node.isShorthandPropertyAssignment(picked)) {
|
|
1580
|
+
classify(picked.getNameNode(), path, ctx, out, depth + 1);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
classify(bound.initializer, path, ctx, out, depth + 1);
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
if (isEchoBinding(name, ctx.body)) {
|
|
1588
|
+
out.push({
|
|
1589
|
+
kind: "echo",
|
|
1590
|
+
path
|
|
1591
|
+
});
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
out.push({
|
|
1595
|
+
kind: "scalar",
|
|
1596
|
+
path
|
|
1597
|
+
});
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
if (Node.isCallExpression(value)) {
|
|
1601
|
+
classifyCall(value, path, ctx, out, depth);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
if (Node.isPropertyAccessExpression(value) || Node.isElementAccessExpression(value)) {
|
|
1605
|
+
classifyAccess(value, path, ctx, out, depth);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
if (Node.isAwaitExpression(value)) {
|
|
1609
|
+
classify(unwrap$1(value.getExpression()), path, ctx, out, depth + 1);
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (Node.isBinaryExpression(value)) {
|
|
1613
|
+
const operator = value.getOperatorToken().getKind();
|
|
1614
|
+
if (operator === SyntaxKind.QuestionQuestionToken || operator === SyntaxKind.BarBarToken) {
|
|
1615
|
+
classify(unwrap$1(value.getLeft()), path, ctx, out, depth + 1);
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
if (operator === SyntaxKind.AmpersandAmpersandToken) {
|
|
1619
|
+
classify(unwrap$1(value.getRight()), path, ctx, out, depth + 1);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
if (Node.isTemplateExpression(value)) {
|
|
1624
|
+
for (const span of value.getTemplateSpans()) classify(unwrap$1(span.getExpression()), path, ctx, out, depth + 1);
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
out.push({
|
|
1628
|
+
kind: "scalar",
|
|
1629
|
+
path
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
function classifyCall(value, path, ctx, out, depth, pick) {
|
|
1633
|
+
{
|
|
1634
|
+
const callee = value.getExpression();
|
|
1635
|
+
const method = Node.isPropertyAccessExpression(callee) ? callee.getName() : "";
|
|
1636
|
+
if (Node.isPropertyAccessExpression(callee) && ECHOES_INPUT.has(method) && isRequestLike(callee.getExpression())) {
|
|
1637
|
+
out.push({
|
|
1638
|
+
kind: "echo",
|
|
1639
|
+
path
|
|
1640
|
+
});
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const mapped = mappedLiteralOf(value);
|
|
1644
|
+
if (mapped) {
|
|
1645
|
+
classify(mapped, path, ctx, out, depth + 1);
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
if (method === "map" && Node.isPropertyAccessExpression(callee)) {
|
|
1649
|
+
classifyMapped(value, callee.getExpression(), path, ctx, out, depth);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
if (Node.isPropertyAccessExpression(callee) && PASSES_THROUGH.has(method) && value.getArguments().length === 0) {
|
|
1653
|
+
classify(unwrap$1(callee.getExpression()), path, ctx, out, depth + 1);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
if (method === "getMeta" && value.getArguments().length === 0) {
|
|
1657
|
+
if (pick) out.push({
|
|
1658
|
+
kind: "scalar",
|
|
1659
|
+
path
|
|
1660
|
+
});
|
|
1661
|
+
else for (const key of PAGINATOR_META) out.push({
|
|
1662
|
+
kind: "scalar",
|
|
1663
|
+
path: join(path, key)
|
|
1664
|
+
});
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
if (Node.isPropertyAccessExpression(callee) && SAME_COLLECTION.has(method)) {
|
|
1668
|
+
classify(unwrap$1(callee.getExpression()), path, ctx, out, depth + 1);
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
if (Node.isPropertyAccessExpression(callee) && lastSegmentOf(callee.getExpression()) === "colors") {
|
|
1672
|
+
const coloured = unwrap$1(value.getArguments()[0]);
|
|
1673
|
+
if (coloured) classify(coloured, path, ctx, out, depth + 1);
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
if (Node.isPropertyAccessExpression(callee) && INERTIA_LAZY.has(method) && RENDERERS.has(lastSegmentOf(callee.getExpression()))) {
|
|
1677
|
+
const body = callbackValueOf(unwrap$1(value.getArguments()[0]));
|
|
1678
|
+
if (body) classify(body, path, ctx, out, depth + 1);
|
|
1679
|
+
else out.push({
|
|
1680
|
+
kind: "scalar",
|
|
1681
|
+
path
|
|
1682
|
+
});
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
const args = [];
|
|
1686
|
+
for (const argument of value.getArguments()) classify(unwrap$1(argument), path, ctx, args, depth + 1);
|
|
1687
|
+
/** only what carries rows matters for a fallback: a scalar argument is a parameter, not an output */
|
|
1688
|
+
const carried = args.filter((item) => item.kind === "store" || item.kind === "call");
|
|
1689
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
1690
|
+
const root = chainRootOf(callee.getExpression());
|
|
1691
|
+
if (root && SCALAR_SERVICES.has(root)) {
|
|
1692
|
+
out.push({
|
|
1693
|
+
kind: "scalar",
|
|
1694
|
+
path
|
|
1695
|
+
});
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
const refs = ctx.followed.get(value);
|
|
1700
|
+
if (refs && refs.length > 0) {
|
|
1701
|
+
out.push({
|
|
1702
|
+
kind: "call",
|
|
1703
|
+
refs,
|
|
1704
|
+
path,
|
|
1705
|
+
expression: value.getText().replace(/\s+/g, "").slice(0, 60),
|
|
1706
|
+
args: carried,
|
|
1707
|
+
...pick ? { pick } : {}
|
|
1708
|
+
});
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const access = detectAccess(value, ctx.symbols, ctx.relations);
|
|
1712
|
+
if (access) {
|
|
1713
|
+
out.push({
|
|
1714
|
+
kind: "store",
|
|
1715
|
+
store: access.store,
|
|
1716
|
+
path
|
|
1717
|
+
});
|
|
1718
|
+
if (access.viaRelation) out.push({
|
|
1719
|
+
kind: "store",
|
|
1720
|
+
store: access.viaRelation,
|
|
1721
|
+
path
|
|
1722
|
+
});
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
if (Node.isIdentifier(callee) && NATIVE_GLOBALS.has(callee.getText())) {
|
|
1726
|
+
out.push({
|
|
1727
|
+
kind: "scalar",
|
|
1728
|
+
path
|
|
1729
|
+
});
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
if (Node.isPropertyAccessExpression(callee) && Node.isIdentifier(callee.getExpression()) && NATIVE_GLOBALS.has(callee.getExpression().getText())) {
|
|
1733
|
+
out.push({
|
|
1734
|
+
kind: "scalar",
|
|
1735
|
+
path
|
|
1736
|
+
});
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* A call nobody followed, over rows: a package's CSV builder, a formatter. The
|
|
1741
|
+
* document it builds carries what was handed into it, so the rows' stores leave.
|
|
1742
|
+
* With nothing flowing in there is nothing to say: one DET, opaque, reported.
|
|
1743
|
+
*/
|
|
1744
|
+
if (carried.length > 0) {
|
|
1745
|
+
out.push(...carried);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* `startDate?.toISOString()`, `categoria.trim()`: a call ON a value the body
|
|
1750
|
+
* holds is that value formatted — an echo stays an echo, a store's field one
|
|
1751
|
+
* field. Only when the root itself resolves to nothing else is the call read
|
|
1752
|
+
* on its own.
|
|
1753
|
+
*/
|
|
1754
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
1755
|
+
const root = chainRootOf(callee.getExpression());
|
|
1756
|
+
if (root && ctx.symbols.has(root)) {
|
|
1757
|
+
out.push({
|
|
1758
|
+
kind: "scalar",
|
|
1759
|
+
path
|
|
1760
|
+
});
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
if (root && root !== "this") {
|
|
1764
|
+
const rootNode = rootIdentifierOf(callee.getExpression());
|
|
1765
|
+
const held = [];
|
|
1766
|
+
if (rootNode) classify(rootNode, path, ctx, held, depth + 1);
|
|
1767
|
+
if (held.length > 0 && held.every((item) => item.kind === "echo")) {
|
|
1768
|
+
out.push({
|
|
1769
|
+
kind: "echo",
|
|
1770
|
+
path
|
|
1771
|
+
});
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* `rows.map(…).join('\n')` in a body whose parameter is `rows`: the whole input
|
|
1778
|
+
* transformed, so the input says what leaves — the caller's arguments decide,
|
|
1779
|
+
* and the body itself says nothing. `noticia.publicadaEm?.toISO()` on that
|
|
1780
|
+
* parameter is one FIELD of it, and stays one value below.
|
|
1781
|
+
*/
|
|
1782
|
+
if (Node.isPropertyAccessExpression(callee) && transformsParameter(callee, ctx.body)) {
|
|
1783
|
+
out.push({
|
|
1784
|
+
kind: "opaque",
|
|
1785
|
+
path,
|
|
1786
|
+
expression: value.getText().replace(/\s+/g, "").slice(0, 60)
|
|
1787
|
+
});
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
1791
|
+
/**
|
|
1792
|
+
* `comunicado.enviadoEm!.toISODate()`, `(a ?? b).toRFC2822()`: a FIELD read off
|
|
1793
|
+
* the chain, or an expression, then a method — one value. A chain rooted at
|
|
1794
|
+
* `this` (`this.service.find()`) is a call into a service, and not this.
|
|
1795
|
+
*/
|
|
1796
|
+
if (readsField(callee.getExpression())) {
|
|
1797
|
+
out.push({
|
|
1798
|
+
kind: "scalar",
|
|
1799
|
+
path
|
|
1800
|
+
});
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
if (BOOLEAN_METHODS.has(method) || FORMAT_METHODS.has(method)) {
|
|
1804
|
+
out.push({
|
|
1805
|
+
kind: "scalar",
|
|
1806
|
+
path
|
|
1807
|
+
});
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
if (returnsPrimitive(value)) {
|
|
1812
|
+
out.push({
|
|
1813
|
+
kind: "scalar",
|
|
1814
|
+
path
|
|
1815
|
+
});
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
out.push({
|
|
1819
|
+
kind: "opaque",
|
|
1820
|
+
path,
|
|
1821
|
+
expression: value.getText().replace(/\s+/g, "").slice(0, 60)
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* `xs.map(cb)` with no literal in the callback: what the callback RETURNS,
|
|
1827
|
+
* once — a function passed by reference (`rows.map(paraLinha)`) is a call to
|
|
1828
|
+
* that function over the rows; an expression body (`(m) => new T(m).toObject()`)
|
|
1829
|
+
* is classified as if it were the value; anything else is one repeating attribute.
|
|
1830
|
+
*/
|
|
1831
|
+
function classifyMapped(value, receiver, path, ctx, out, depth) {
|
|
1832
|
+
const callback = unwrap$1(value.getArguments()[0]);
|
|
1833
|
+
const refs = ctx.followed.get(value);
|
|
1834
|
+
if (callback && Node.isIdentifier(callback) && refs && refs.length > 0) {
|
|
1835
|
+
const rows = [];
|
|
1836
|
+
classify(unwrap$1(receiver), path, ctx, rows, depth + 1);
|
|
1837
|
+
out.push({
|
|
1838
|
+
kind: "call",
|
|
1839
|
+
refs,
|
|
1840
|
+
path,
|
|
1841
|
+
expression: value.getText().replace(/\s+/g, "").slice(0, 60),
|
|
1842
|
+
args: rows.filter((item) => item.kind === "store" || item.kind === "call")
|
|
1843
|
+
});
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
const body = callbackValueOf(callback);
|
|
1847
|
+
if (body) {
|
|
1848
|
+
classify(body, path, ctx, out, depth + 1);
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
out.push({
|
|
1852
|
+
kind: "scalar",
|
|
1853
|
+
path
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
/** the value a callback hands back: an expression body, or the one `return` of a block */
|
|
1857
|
+
function callbackValueOf(callback) {
|
|
1858
|
+
if (!callback || !(Node.isArrowFunction(callback) || Node.isFunctionExpression(callback))) return null;
|
|
1859
|
+
const body = callback.getBody();
|
|
1860
|
+
if (!Node.isBlock(body)) return unwrap$1(body);
|
|
1861
|
+
const returns = body.getStatements().filter(Node.isReturnStatement);
|
|
1862
|
+
return returns.length === 1 ? unwrap$1(returns[0].getExpression()) : null;
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Does this chain read a property (not a method) or hold an expression before
|
|
1866
|
+
* the method is applied? `comunicado.enviadoEm.toISO()` does; `rows.map(f).join()`
|
|
1867
|
+
* does not; a chain rooted at `this` is a service, and does not.
|
|
1868
|
+
*/
|
|
1869
|
+
function readsField(node) {
|
|
1870
|
+
let current = node;
|
|
1871
|
+
for (let depth = 0; current && depth < 40; depth++) {
|
|
1872
|
+
if (Node.isCallExpression(current)) {
|
|
1873
|
+
const inner = current.getExpression();
|
|
1874
|
+
current = Node.isPropertyAccessExpression(inner) ? inner.getExpression() : inner;
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
if (Node.isAwaitExpression(current) || Node.isNonNullExpression(current)) {
|
|
1878
|
+
current = current.getExpression();
|
|
1879
|
+
continue;
|
|
1880
|
+
}
|
|
1881
|
+
if (Node.isParenthesizedExpression(current)) {
|
|
1882
|
+
const inner = unwrap$1(current.getExpression());
|
|
1883
|
+
if (!inner) return false;
|
|
1884
|
+
if (Node.isBinaryExpression(inner) || Node.isConditionalExpression(inner)) return true;
|
|
1885
|
+
current = inner;
|
|
1886
|
+
continue;
|
|
1887
|
+
}
|
|
1888
|
+
if (Node.isPropertyAccessExpression(current) || Node.isElementAccessExpression(current)) return chainRootOf(current) !== "this";
|
|
1889
|
+
return false;
|
|
1890
|
+
}
|
|
1891
|
+
return false;
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Is this chain a method (or methods) applied to a plain parameter of the body,
|
|
1895
|
+
* with no field read in between? `rows.map(f).join(s)` is; `noticia.capa?.toISO()`
|
|
1896
|
+
* reads a field first and is not.
|
|
1897
|
+
*/
|
|
1898
|
+
function transformsParameter(callee, body) {
|
|
1899
|
+
let current = callee.getExpression();
|
|
1900
|
+
for (let depth = 0; current && depth < 40; depth++) {
|
|
1901
|
+
if (Node.isCallExpression(current)) {
|
|
1902
|
+
const inner = current.getExpression();
|
|
1903
|
+
current = Node.isPropertyAccessExpression(inner) ? inner.getExpression() : inner;
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
if (Node.isAwaitExpression(current) || Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current)) {
|
|
1907
|
+
current = current.getExpression();
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
if (Node.isPropertyAccessExpression(current) || Node.isElementAccessExpression(current)) return false;
|
|
1911
|
+
if (!Node.isIdentifier(current)) return false;
|
|
1912
|
+
const name = current.getText();
|
|
1913
|
+
return Node.isParametered(body) && body.getParameters().some((p) => Node.isIdentifier(p.getNameNode()) && p.getNameNode().getText() === name);
|
|
1914
|
+
}
|
|
1915
|
+
return false;
|
|
1916
|
+
}
|
|
1917
|
+
/** the identifier node a chain is rooted at: `startDate?.toISOString` -> `startDate` */
|
|
1918
|
+
function rootIdentifierOf(node) {
|
|
1919
|
+
let current = node;
|
|
1920
|
+
for (let depth = 0; current && depth < 40; depth++) {
|
|
1921
|
+
if (Node.isCallExpression(current) || Node.isPropertyAccessExpression(current) || Node.isElementAccessExpression(current) || Node.isAwaitExpression(current) || Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current)) {
|
|
1922
|
+
current = current.getExpression();
|
|
1923
|
+
continue;
|
|
1924
|
+
}
|
|
1925
|
+
return Node.isIdentifier(current) ? current : null;
|
|
1926
|
+
}
|
|
1927
|
+
return null;
|
|
1928
|
+
}
|
|
1929
|
+
/** the call's declared return type, `Promise<…>` unwrapped, is a boolean, string, number or a union of those */
|
|
1930
|
+
function returnsPrimitive(call) {
|
|
1931
|
+
try {
|
|
1932
|
+
let type = call.getReturnType();
|
|
1933
|
+
if (type.getSymbol()?.getName() === "Promise") type = type.getTypeArguments()[0] ?? type;
|
|
1934
|
+
const primitive = (t) => t.isBoolean() || t.isBooleanLiteral() || t.isString() || t.isStringLiteral() || t.isNumber() || t.isNumberLiteral() || t.isEnumLiteral() || t.isNull() || t.isUndefined();
|
|
1935
|
+
if (type.isUnion()) {
|
|
1936
|
+
const members = type.getUnionTypes();
|
|
1937
|
+
return members.length > 0 && members.every(primitive) && !members.every((t) => t.isNull() || t.isUndefined());
|
|
1938
|
+
}
|
|
1939
|
+
return primitive(type);
|
|
1940
|
+
} catch {
|
|
1941
|
+
return false;
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
/**
|
|
1945
|
+
* `x.data`, `resultado.linhas`, `rows[0]`: a part of a value the body holds.
|
|
1946
|
+
*
|
|
1947
|
+
* on a variable bound to a followed call that key of what the call returns
|
|
1948
|
+
* on a store-bound variable the store (`rows[0]`, `.data`) or a field (scalar)
|
|
1949
|
+
* on the validated payload or the request an echo of input — counts on entry
|
|
1950
|
+
* anything else one value
|
|
1951
|
+
*/
|
|
1952
|
+
function classifyAccess(value, path, ctx, out, depth) {
|
|
1953
|
+
const root = rootSymbolOf(value);
|
|
1954
|
+
/**
|
|
1955
|
+
* `x.nome`, `x['nome']`: the key named; `x[papel]`: a key the body computes — one
|
|
1956
|
+
* value, unnamed (`*`); `rows[0]`: one row of the collection, the collection.
|
|
1957
|
+
*/
|
|
1958
|
+
const property = Node.isPropertyAccessExpression(value) ? value.getName() : keyOfElementAccess(value);
|
|
1959
|
+
if (root && ctx.symbols.has(root)) {
|
|
1960
|
+
if (!property || PASSES_THROUGH.has(property)) out.push({
|
|
1961
|
+
kind: "store",
|
|
1962
|
+
store: ctx.symbols.get(root),
|
|
1963
|
+
path
|
|
1964
|
+
});
|
|
1965
|
+
else out.push({
|
|
1966
|
+
kind: "scalar",
|
|
1967
|
+
path: path || property.replace(/^\*$/, "")
|
|
1968
|
+
});
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
if (root) {
|
|
1972
|
+
const bound = bindingOf(root, ctx.body);
|
|
1973
|
+
if (bound && Node.isCallExpression(bound.initializer)) {
|
|
1974
|
+
if (SCALAR_PROPS.has(property)) {
|
|
1975
|
+
out.push({
|
|
1976
|
+
kind: "scalar",
|
|
1977
|
+
path
|
|
1978
|
+
});
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* `resultado.linhas`: one key of what the call returns; `meta.pagina` on a
|
|
1983
|
+
* destructured `meta`: the key under the key; `rows[0]`, `.data`: the whole.
|
|
1984
|
+
*/
|
|
1985
|
+
const own = property && !PASSES_THROUGH.has(property) ? property : void 0;
|
|
1986
|
+
const pick = [bound.pick, own].filter(Boolean).join(".") || void 0;
|
|
1987
|
+
if (own && !ctx.followed.has(bound.initializer)) {
|
|
1988
|
+
out.push({
|
|
1989
|
+
kind: "scalar",
|
|
1990
|
+
path
|
|
1991
|
+
});
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
classifyCall(bound.initializer, path, ctx, out, depth + 1, pick);
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
if (bound && Node.isObjectLiteralExpression(bound.initializer) && property) {
|
|
1998
|
+
const picked = bound.initializer.getProperty(property);
|
|
1999
|
+
if (picked && Node.isPropertyAssignment(picked)) {
|
|
2000
|
+
classify(unwrap$1(picked.getInitializer()), path, ctx, out, depth + 1);
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
if (isEchoBinding(root, ctx.body) || root === "request" || root === "params") {
|
|
2005
|
+
out.push({
|
|
2006
|
+
kind: "echo",
|
|
2007
|
+
path
|
|
2008
|
+
});
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
out.push({
|
|
2013
|
+
kind: "scalar",
|
|
2014
|
+
path: path || property.replace(/^\*$/, "")
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
function keyOfElementAccess(value) {
|
|
2018
|
+
const argument = unwrap$1(value.getArgumentExpression());
|
|
2019
|
+
if (!argument || Node.isNumericLiteral(argument)) return "";
|
|
2020
|
+
if (Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument)) return argument.getLiteralValue();
|
|
2021
|
+
return "*";
|
|
2022
|
+
}
|
|
2023
|
+
const join = (prefix, name) => prefix ? `${prefix}.${name}` : name;
|
|
2024
|
+
/** `await x` binds x: the value, not the promise */
|
|
2025
|
+
function unwrapAwait$1(node) {
|
|
2026
|
+
let current = unwrap$1(node);
|
|
2027
|
+
while (current && Node.isAwaitExpression(current)) current = unwrap$1(current.getExpression());
|
|
2028
|
+
return current;
|
|
2029
|
+
}
|
|
2030
|
+
/**
|
|
2031
|
+
* How a local name was bound in this body: `const x = …` gives the initializer;
|
|
2032
|
+
* `const { x, y } = …` gives the initializer and the key picked out of it.
|
|
2033
|
+
*/
|
|
2034
|
+
function bindingOf(name, body) {
|
|
2035
|
+
for (const declaration of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
2036
|
+
const nameNode = declaration.getNameNode();
|
|
2037
|
+
if (Node.isIdentifier(nameNode)) {
|
|
2038
|
+
if (nameNode.getText() !== name) continue;
|
|
2039
|
+
const initializer = unwrapAwait$1(declaration.getInitializer());
|
|
2040
|
+
return initializer ? { initializer } : null;
|
|
2041
|
+
}
|
|
2042
|
+
if (Node.isObjectBindingPattern(nameNode)) {
|
|
2043
|
+
const element = nameNode.getElements().find((e) => e.getName() === name);
|
|
2044
|
+
if (!element) continue;
|
|
2045
|
+
const initializer = unwrapAwait$1(declaration.getInitializer());
|
|
2046
|
+
if (!initializer) return null;
|
|
2047
|
+
return {
|
|
2048
|
+
initializer,
|
|
2049
|
+
pick: element.getPropertyNameNode()?.getText() ?? element.getName()
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
return null;
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* A name bound by destructuring the validated payload or the request:
|
|
2057
|
+
* `const { busca } = await request.validateUsing(x)`, `const { id } = params`.
|
|
2058
|
+
*/
|
|
2059
|
+
function isEchoBinding(name, body) {
|
|
2060
|
+
for (const declaration of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
2061
|
+
const binding = declaration.getNameNode();
|
|
2062
|
+
if (!Node.isObjectBindingPattern(binding)) continue;
|
|
2063
|
+
if (!binding.getElements().some((element) => element.getName() === name)) continue;
|
|
2064
|
+
const initializer = unwrap$1(declaration.getInitializer());
|
|
2065
|
+
if (!initializer) continue;
|
|
2066
|
+
if (Node.isIdentifier(initializer) && /^(params|request)$/.test(initializer.getText())) return true;
|
|
2067
|
+
if (Node.isCallExpression(initializer)) {
|
|
2068
|
+
const callee = initializer.getExpression();
|
|
2069
|
+
if (Node.isPropertyAccessExpression(callee) && ECHOES_INPUT.has(callee.getName())) return true;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
return false;
|
|
2073
|
+
}
|
|
2074
|
+
const isRequestLike = (node) => lastSegmentOf(node) === "request";
|
|
2075
|
+
//#endregion
|
|
1226
2076
|
//#region src/inventory/graph/call_graph.ts
|
|
1227
2077
|
/**
|
|
1228
2078
|
* Fields declared by the validators used in this body.
|
|
@@ -1255,7 +2105,7 @@ function validatorFieldsIn(body, file, app) {
|
|
|
1255
2105
|
* validator and is usually not exported, so looking for it where the call site
|
|
1256
2106
|
* is finds nothing — which is how five fields stayed invisible.
|
|
1257
2107
|
*/
|
|
1258
|
-
const { leaves, opaque: unreadable } = leavesOf(declaration, (ref) => findValidator(ref, declaration.getSourceFile(), app));
|
|
2108
|
+
const { leaves, opaque: unreadable } = leavesOf$1(declaration, (ref) => findValidator(ref, declaration.getSourceFile(), app));
|
|
1259
2109
|
const isOpaque = new Set(unreadable);
|
|
1260
2110
|
for (const leaf of leaves) {
|
|
1261
2111
|
const field = `${name}.${leaf}`;
|
|
@@ -1398,7 +2248,7 @@ function calleeOwner(call) {
|
|
|
1398
2248
|
const owner = callee.getExpression();
|
|
1399
2249
|
return Node.isPropertyAccessExpression(owner) ? owner.getName() : void 0;
|
|
1400
2250
|
}
|
|
1401
|
-
function leavesOf(node, resolveRef) {
|
|
2251
|
+
function leavesOf$1(node, resolveRef) {
|
|
1402
2252
|
const object = node.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
|
|
1403
2253
|
if (!object) return {
|
|
1404
2254
|
leaves: [],
|
|
@@ -1511,6 +2361,12 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1511
2361
|
* per route, uniformly. Loading everything first trades N rebuilds for one.
|
|
1512
2362
|
*/
|
|
1513
2363
|
for (const root of app.scanRoots) project.addSourceFilesAtPaths(`${root}/**/*.ts`);
|
|
2364
|
+
/**
|
|
2365
|
+
* Seeders under `database/` as well — not application code, and never followed
|
|
2366
|
+
* from a handler, but an EIF only a seed populates is a fact the report needs
|
|
2367
|
+
* (counting-decisions §11), and `make:seeder` puts them exactly there.
|
|
2368
|
+
*/
|
|
2369
|
+
project.addSourceFilesAtPaths(`${toPosix(app.root)}/database/**/seeders/**/*.ts`);
|
|
1514
2370
|
const storesByName = new Map(stores.map((store) => [store.name, store]));
|
|
1515
2371
|
const relationsByStore = new Map(stores.map((store) => [store.name, store.relations]));
|
|
1516
2372
|
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
@@ -1584,7 +2440,11 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1584
2440
|
const accesses = [];
|
|
1585
2441
|
const followUps = [];
|
|
1586
2442
|
const unresolved = [];
|
|
2443
|
+
const reads = [];
|
|
2444
|
+
/** calls a strategy claimed, and where they lead: a nested transformer's keys arrive through its body */
|
|
2445
|
+
const followedCalls = /* @__PURE__ */ new Map();
|
|
1587
2446
|
const validator = validatorFieldsIn(body, file, app);
|
|
2447
|
+
const commandFields = owner && isCommandClass(owner) ? commandFieldsOf(owner) : [];
|
|
1588
2448
|
const request = requestFieldsIn(body);
|
|
1589
2449
|
const context = {
|
|
1590
2450
|
file,
|
|
@@ -1616,6 +2476,41 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1616
2476
|
technical
|
|
1617
2477
|
});
|
|
1618
2478
|
/**
|
|
2479
|
+
* How the chain reads the store decides what leaves when nothing transforms
|
|
2480
|
+
* it — §6: rows whole, `.select()` columns, or one scalar from `.count()`.
|
|
2481
|
+
* A select list that is not literal is reported, and the store falls back
|
|
2482
|
+
* to every column, which overestimates in the open.
|
|
2483
|
+
*
|
|
2484
|
+
* `related('itens').query().count()` reads the RELATION target, and the
|
|
2485
|
+
* parent only as a receiver; `preload('itens')` reads the target whole.
|
|
2486
|
+
*/
|
|
2487
|
+
if (access.mode === "read") {
|
|
2488
|
+
const chain = chainShapeOf(call);
|
|
2489
|
+
const shape = chain.aggregate ? "aggregate" : chain.selected.length > 0 ? "select" : "whole";
|
|
2490
|
+
/**
|
|
2491
|
+
* The select list survives an aggregate shape: `select('categoria').count()`
|
|
2492
|
+
* with a GROUP BY leaves the grouped column as well as the count. Dropping
|
|
2493
|
+
* it left a summary at 1 DET.
|
|
2494
|
+
*/
|
|
2495
|
+
const read = (store, how, via) => reads.push({
|
|
2496
|
+
store,
|
|
2497
|
+
shape: how,
|
|
2498
|
+
columns: how === "whole" ? [] : chain.selected,
|
|
2499
|
+
...via ? { via } : {}
|
|
2500
|
+
});
|
|
2501
|
+
if (access.method === "related" && access.viaRelation) read(access.viaRelation, shape);
|
|
2502
|
+
else {
|
|
2503
|
+
read(access.store, shape);
|
|
2504
|
+
if (access.viaRelation) read(access.viaRelation, "whole", access.store);
|
|
2505
|
+
}
|
|
2506
|
+
for (const problem of chain.unreadable) unresolved.push({
|
|
2507
|
+
file: ref.file,
|
|
2508
|
+
line: problem.line,
|
|
2509
|
+
expression: problem.expression,
|
|
2510
|
+
reason: `select with a column list that is not literal: ${access.store} counts every column`
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
/**
|
|
1619
2514
|
* A relation reached by `preload`/`load` is read; one written through
|
|
1620
2515
|
* `related('files').create(…)` is written. Assuming read either way made
|
|
1621
2516
|
* a table maintained only through a relation come out as an EIF.
|
|
@@ -1654,6 +2549,7 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1654
2549
|
by: resolved.by,
|
|
1655
2550
|
technical
|
|
1656
2551
|
});
|
|
2552
|
+
followedCalls.set(call, resolved.refs);
|
|
1657
2553
|
continue;
|
|
1658
2554
|
}
|
|
1659
2555
|
if (isWorthReporting(call, symbols, imports) && !isNoise(call, owner)) unresolved.push({
|
|
@@ -1663,14 +2559,33 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1663
2559
|
reason: "call that no strategy knew how to follow"
|
|
1664
2560
|
});
|
|
1665
2561
|
}
|
|
2562
|
+
/**
|
|
2563
|
+
* Read after the loop: whether a key holds a nested transformer is known only
|
|
2564
|
+
* once the strategies have said which calls they follow.
|
|
2565
|
+
*/
|
|
2566
|
+
const followed = (c) => followedCalls.has(c);
|
|
2567
|
+
const output = outputFieldsIn(body, owner, storesByName, followed);
|
|
2568
|
+
const deliveries = deliveriesIn({
|
|
2569
|
+
body,
|
|
2570
|
+
file,
|
|
2571
|
+
symbols,
|
|
2572
|
+
relations: relationsByStore,
|
|
2573
|
+
followed: followedCalls
|
|
2574
|
+
});
|
|
1666
2575
|
return {
|
|
1667
2576
|
accesses,
|
|
1668
2577
|
followUps,
|
|
1669
2578
|
unresolved,
|
|
1670
2579
|
validators: validator.fields,
|
|
2580
|
+
commandFields,
|
|
1671
2581
|
opaqueValidators: validator.opaque,
|
|
1672
2582
|
requestFields: request.fields,
|
|
1673
2583
|
opaqueRequest: request.opaque,
|
|
2584
|
+
outputs: output.outputs,
|
|
2585
|
+
opaqueOutputs: output.opaqueOutputs,
|
|
2586
|
+
transformed: output.resource,
|
|
2587
|
+
reads,
|
|
2588
|
+
deliveries,
|
|
1674
2589
|
bodyHash: hashOf(body)
|
|
1675
2590
|
};
|
|
1676
2591
|
}
|
|
@@ -1689,8 +2604,20 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1689
2604
|
* transaction reaches the store still decides if it is counted at all; this
|
|
1690
2605
|
* only decides who maintains it.
|
|
1691
2606
|
*/
|
|
1692
|
-
|
|
2607
|
+
/**
|
|
2608
|
+
* Both project-wide facts come from one pass, computed once: which stores the
|
|
2609
|
+
* application WRITES (maintenance, §6.5.4) and which it ADDRESSES directly
|
|
2610
|
+
* (grouping, counting-decisions §10). A store reached only through a relation
|
|
2611
|
+
* — `preload('itens')`, `related('itens').create()` — is read or written, but
|
|
2612
|
+
* not addressed: the user never sees it outside its parent.
|
|
2613
|
+
*/
|
|
2614
|
+
let projectWide;
|
|
2615
|
+
const scanProject = () => {
|
|
2616
|
+
if (projectWide) return projectWide;
|
|
1693
2617
|
const written = /* @__PURE__ */ new Set();
|
|
2618
|
+
const addressed = /* @__PURE__ */ new Set();
|
|
2619
|
+
/** written by a seeder: not maintenance, but a fact the report needs (an EIF only a seed populates) */
|
|
2620
|
+
const seeded = /* @__PURE__ */ new Set();
|
|
1694
2621
|
for (const file of project.getSourceFiles()) {
|
|
1695
2622
|
/**
|
|
1696
2623
|
* A seeder's inserts are not the application maintaining a table, and a test
|
|
@@ -1702,12 +2629,25 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1702
2629
|
* depth — because a domain-module layout puts `tests/` and `seeders/` inside
|
|
1703
2630
|
* `app/`, where the root filter never looks.
|
|
1704
2631
|
*/
|
|
1705
|
-
if (!isApplicationCode(app.root, file.getFilePath()))
|
|
2632
|
+
if (!isApplicationCode(app.root, file.getFilePath())) {
|
|
2633
|
+
if (isSeeder(app.root, file.getFilePath())) {
|
|
2634
|
+
const symbols = storeSymbolsFor(file, file, app, storesByName);
|
|
2635
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
2636
|
+
const access = symbols.size > 0 ? detectAccess(call, symbols, relationsByStore) : null;
|
|
2637
|
+
if (access?.mode !== "write") continue;
|
|
2638
|
+
seeded.add(access.store);
|
|
2639
|
+
if (access.viaRelation && access.relationWritten) seeded.add(access.viaRelation);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
continue;
|
|
2643
|
+
}
|
|
1706
2644
|
const symbols = storeSymbolsFor(file, file, app, storesByName);
|
|
1707
2645
|
if (symbols.size === 0) continue;
|
|
1708
2646
|
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1709
2647
|
const access = detectAccess(call, symbols, relationsByStore);
|
|
1710
|
-
if (access
|
|
2648
|
+
if (!access) continue;
|
|
2649
|
+
addressed.add(access.store);
|
|
2650
|
+
if (access.mode !== "write") continue;
|
|
1711
2651
|
written.add(access.store);
|
|
1712
2652
|
/**
|
|
1713
2653
|
* `distribution.related('files').create(…)` maintains the related table.
|
|
@@ -1717,12 +2657,27 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1717
2657
|
*/
|
|
1718
2658
|
if (access.viaRelation && access.relationWritten) written.add(access.viaRelation);
|
|
1719
2659
|
}
|
|
2660
|
+
for (const construction of file.getDescendantsOfKind(SyntaxKind.NewExpression)) {
|
|
2661
|
+
const target = construction.getExpression();
|
|
2662
|
+
const store = Node.isIdentifier(target) ? symbols.get(target.getText()) : void 0;
|
|
2663
|
+
if (store) addressed.add(store);
|
|
2664
|
+
}
|
|
1720
2665
|
}
|
|
1721
|
-
|
|
2666
|
+
projectWide = {
|
|
2667
|
+
written,
|
|
2668
|
+
addressed,
|
|
2669
|
+
seeded
|
|
2670
|
+
};
|
|
2671
|
+
return projectWide;
|
|
1722
2672
|
};
|
|
2673
|
+
const writtenAnywhere = () => scanProject().written;
|
|
2674
|
+
const addressedAnywhere = () => scanProject().addressed;
|
|
2675
|
+
const seededAnywhere = () => scanProject().seeded;
|
|
1723
2676
|
return {
|
|
1724
2677
|
analyze: (handler) => run(handler),
|
|
1725
2678
|
writtenAnywhere,
|
|
2679
|
+
addressedAnywhere,
|
|
2680
|
+
seededAnywhere,
|
|
1726
2681
|
/** how many files the project loaded — used to prove it does not grow */
|
|
1727
2682
|
fileCount: () => project.getSourceFiles().length
|
|
1728
2683
|
};
|
|
@@ -1730,14 +2685,137 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1730
2685
|
const touches = /* @__PURE__ */ new Set();
|
|
1731
2686
|
const writtenStores = /* @__PURE__ */ new Set();
|
|
1732
2687
|
const inputFields = /* @__PURE__ */ new Set();
|
|
2688
|
+
const commandFields = /* @__PURE__ */ new Set();
|
|
1733
2689
|
const opaqueInputFields = /* @__PURE__ */ new Set();
|
|
1734
2690
|
const requestFields = /* @__PURE__ */ new Set();
|
|
1735
2691
|
let opaqueRequest = false;
|
|
2692
|
+
const outputFields = /* @__PURE__ */ new Set();
|
|
2693
|
+
const opaqueOutputFields = /* @__PURE__ */ new Set();
|
|
2694
|
+
const transformedStores = /* @__PURE__ */ new Set();
|
|
2695
|
+
const deliveredFields = /* @__PURE__ */ new Set();
|
|
2696
|
+
const deliveredOpaque = /* @__PURE__ */ new Set();
|
|
2697
|
+
const deliveredStores = /* @__PURE__ */ new Set();
|
|
2698
|
+
let anyDelivery = false;
|
|
2699
|
+
const outputReads = /* @__PURE__ */ new Map();
|
|
1736
2700
|
const trace = [];
|
|
1737
2701
|
const scope = [];
|
|
1738
2702
|
const unresolved = [];
|
|
1739
2703
|
const visited = /* @__PURE__ */ new Set();
|
|
1740
2704
|
let writes = false;
|
|
2705
|
+
/** what a followed body reads, itself and through what it follows — bounded like the walk */
|
|
2706
|
+
const storesReadBy = (ref, depth, seen = /* @__PURE__ */ new Set()) => {
|
|
2707
|
+
const found = /* @__PURE__ */ new Set();
|
|
2708
|
+
const key = `${ref.file}#${ref.member ?? ref.line ?? "*"}`;
|
|
2709
|
+
if (seen.has(key) || depth > maxDepth) return found;
|
|
2710
|
+
seen.add(key);
|
|
2711
|
+
const facts = factsFor(ref);
|
|
2712
|
+
if (!facts) return found;
|
|
2713
|
+
for (const access of facts.accesses) found.add(access.store);
|
|
2714
|
+
for (const followUp of facts.followUps) for (const store of storesReadBy(followUp.ref, depth + 1, seen)) found.add(store);
|
|
2715
|
+
return found;
|
|
2716
|
+
};
|
|
2717
|
+
/**
|
|
2718
|
+
* A delivered call hands on what its body RETURNS — classified, so a returned
|
|
2719
|
+
* `{ data: rows, meta }` delivers the rows' store and the meta's leaves, not
|
|
2720
|
+
* one DET per key. `pick` keeps one key of it: `const { data } = …`,
|
|
2721
|
+
* `resultado.linhas`. Bounded by depth like the walk, and by a seen set so a
|
|
2722
|
+
* body returning itself cannot loop.
|
|
2723
|
+
*/
|
|
2724
|
+
const deliver = (item, depth, seen = /* @__PURE__ */ new Set()) => {
|
|
2725
|
+
switch (item.kind) {
|
|
2726
|
+
case "store":
|
|
2727
|
+
deliveredStores.add(item.store);
|
|
2728
|
+
return;
|
|
2729
|
+
case "scalar":
|
|
2730
|
+
deliveredFields.add(item.path || "<value>");
|
|
2731
|
+
return;
|
|
2732
|
+
case "echo": return;
|
|
2733
|
+
case "opaque":
|
|
2734
|
+
deliveredOpaque.add(`${item.path ? `${item.path}.` : ""}<${item.expression}>`);
|
|
2735
|
+
return;
|
|
2736
|
+
case "call": {
|
|
2737
|
+
if (depth > maxDepth + 2) return;
|
|
2738
|
+
let resolved = false;
|
|
2739
|
+
for (const ref of item.refs) {
|
|
2740
|
+
const key = `${ref.file}#${ref.member ?? ref.line ?? "*"}#${item.pick ?? ""}#${item.path}`;
|
|
2741
|
+
if (seen.has(key)) continue;
|
|
2742
|
+
seen.add(key);
|
|
2743
|
+
const facts = factsFor(ref);
|
|
2744
|
+
if (!facts) continue;
|
|
2745
|
+
if (facts.outputs.length > 0 || facts.transformed) {
|
|
2746
|
+
resolved = true;
|
|
2747
|
+
continue;
|
|
2748
|
+
}
|
|
2749
|
+
const returned = facts.deliveries.returns.filter((r) => !item.pick || r.path === item.pick || r.path.startsWith(`${item.pick}.`));
|
|
2750
|
+
/**
|
|
2751
|
+
* `egresso.curso` where the body returns the row itself (`return
|
|
2752
|
+
* Egresso.query()…first()`, path ''): the pick lands INSIDE a returned
|
|
2753
|
+
* value — one field of a store is one value; one key of a returned call
|
|
2754
|
+
* is that call picked deeper.
|
|
2755
|
+
*/
|
|
2756
|
+
if (item.pick && returned.length === 0) {
|
|
2757
|
+
const above = facts.deliveries.returns.filter((r) => r.path === "" || item.pick.startsWith(`${r.path}.`));
|
|
2758
|
+
for (const r of above) {
|
|
2759
|
+
const rest = r.path ? item.pick.slice(r.path.length + 1) : item.pick;
|
|
2760
|
+
if (r.kind === "call") deliver({
|
|
2761
|
+
...r,
|
|
2762
|
+
path: item.path,
|
|
2763
|
+
pick: rest
|
|
2764
|
+
}, depth + 1, seen);
|
|
2765
|
+
else if (r.kind === "store" || r.kind === "scalar") deliveredFields.add(item.path || "<value>");
|
|
2766
|
+
}
|
|
2767
|
+
if (above.length > 0) {
|
|
2768
|
+
resolved = true;
|
|
2769
|
+
continue;
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* A return the classifier could not read at all (`rows.map(…).join(…)`)
|
|
2774
|
+
* says nothing about what leaves; what the body read, or what was handed
|
|
2775
|
+
* into it, says more — so it does not count as resolved.
|
|
2776
|
+
*/
|
|
2777
|
+
if (returned.length > 0 && returned.some((r) => r.kind !== "opaque")) {
|
|
2778
|
+
for (const r of returned) {
|
|
2779
|
+
const rest = item.pick ? r.path.slice(item.pick.length).replace(/^\./, "") : r.path;
|
|
2780
|
+
const path = [item.path, rest].filter(Boolean).join(".");
|
|
2781
|
+
deliver({
|
|
2782
|
+
...r,
|
|
2783
|
+
path
|
|
2784
|
+
}, depth + 1, seen);
|
|
2785
|
+
}
|
|
2786
|
+
resolved = true;
|
|
2787
|
+
continue;
|
|
2788
|
+
}
|
|
2789
|
+
if (item.pick) continue;
|
|
2790
|
+
const read = storesReadBy(ref, depth + 1);
|
|
2791
|
+
if (read.size > 0) {
|
|
2792
|
+
for (const store of read) deliveredStores.add(store);
|
|
2793
|
+
resolved = true;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
if (resolved) return;
|
|
2797
|
+
/**
|
|
2798
|
+
* `const { confidenciais } = await this.contar()` where the body's return
|
|
2799
|
+
* is unreadable: one KEY of it, named — one value, not an opaque floor.
|
|
2800
|
+
* Only a key that carries rows on (`data`, `rows`) stays unreadable.
|
|
2801
|
+
*/
|
|
2802
|
+
if (item.pick && !PASSES_ROWS.has(item.pick.split(".")[0])) {
|
|
2803
|
+
deliveredFields.add(item.path || "<value>");
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
/**
|
|
2807
|
+
* The body returned no literal and read no store — a CSV builder, a
|
|
2808
|
+
* formatter over rows handed in. The document it built carries what it
|
|
2809
|
+
* received, so the rows' stores leave. Nothing handed in: opaque.
|
|
2810
|
+
*/
|
|
2811
|
+
if (item.args.length > 0) {
|
|
2812
|
+
for (const argument of item.args) deliver(argument, depth, seen);
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
deliveredOpaque.add(`${item.path ? `${item.path}.` : ""}<${item.expression}>`);
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
};
|
|
1741
2819
|
const visit = (ref, depth, technical = false) => {
|
|
1742
2820
|
const key = `${ref.file}#${ref.member ?? ref.line ?? "*"}`;
|
|
1743
2821
|
if (visited.has(key) || depth > maxDepth) return;
|
|
@@ -1776,9 +2854,35 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1776
2854
|
}
|
|
1777
2855
|
unresolved.push(...facts.unresolved);
|
|
1778
2856
|
for (const field of facts.validators) inputFields.add(field);
|
|
2857
|
+
for (const field of facts.commandFields) commandFields.add(field);
|
|
1779
2858
|
for (const field of facts.opaqueValidators) opaqueInputFields.add(field);
|
|
1780
2859
|
for (const field of facts.requestFields) requestFields.add(field);
|
|
1781
2860
|
if (facts.opaqueRequest) opaqueRequest = true;
|
|
2861
|
+
for (const field of facts.outputs) outputFields.add(field);
|
|
2862
|
+
for (const field of facts.opaqueOutputs) opaqueOutputFields.add(field);
|
|
2863
|
+
if (facts.transformed) transformedStores.add(facts.transformed);
|
|
2864
|
+
/**
|
|
2865
|
+
* Deliveries, resolved here because a delivered CALL leads to a body only
|
|
2866
|
+
* the graph knows: what that body returns is what the value hands on.
|
|
2867
|
+
*/
|
|
2868
|
+
const items = [...facts.deliveries.calls, ...depth === 0 ? facts.deliveries.returns : []];
|
|
2869
|
+
if (facts.deliveries.anyCall || depth === 0 && facts.deliveries.anyReturn) anyDelivery = true;
|
|
2870
|
+
for (const item of items) deliver(item, depth);
|
|
2871
|
+
for (const { store, shape, columns, via } of facts.reads) {
|
|
2872
|
+
const known = outputReads.get(store) ?? {
|
|
2873
|
+
whole: false,
|
|
2874
|
+
selected: /* @__PURE__ */ new Set(),
|
|
2875
|
+
aggregate: false,
|
|
2876
|
+
direct: false,
|
|
2877
|
+
via: /* @__PURE__ */ new Set()
|
|
2878
|
+
};
|
|
2879
|
+
if (shape === "whole") known.whole = true;
|
|
2880
|
+
if (shape === "aggregate") known.aggregate = true;
|
|
2881
|
+
for (const column of columns) known.selected.add(column);
|
|
2882
|
+
if (via) known.via.add(via);
|
|
2883
|
+
else known.direct = true;
|
|
2884
|
+
outputReads.set(store, known);
|
|
2885
|
+
}
|
|
1782
2886
|
trace.push({
|
|
1783
2887
|
file: ref.file,
|
|
1784
2888
|
member: ref.member,
|
|
@@ -1804,9 +2908,26 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1804
2908
|
touches: [...touches].sort(),
|
|
1805
2909
|
writtenStores: [...writtenStores].sort(),
|
|
1806
2910
|
inputFields: [...inputFields].sort(),
|
|
2911
|
+
commandFields: [...commandFields].sort(),
|
|
1807
2912
|
opaqueInputFields: [...opaqueInputFields].sort(),
|
|
1808
2913
|
requestFields: [...requestFields].sort(),
|
|
1809
2914
|
opaqueRequest,
|
|
2915
|
+
outputFields: [...outputFields].sort(),
|
|
2916
|
+
opaqueOutputFields: [...opaqueOutputFields].sort(),
|
|
2917
|
+
transformedStores: [...transformedStores].sort(),
|
|
2918
|
+
delivered: {
|
|
2919
|
+
any: anyDelivery,
|
|
2920
|
+
fields: [...deliveredFields].sort(),
|
|
2921
|
+
opaqueFields: [...deliveredOpaque].sort(),
|
|
2922
|
+
stores: [...deliveredStores].sort()
|
|
2923
|
+
},
|
|
2924
|
+
outputReads: Object.fromEntries([...outputReads.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([store, read]) => [store, {
|
|
2925
|
+
whole: read.whole,
|
|
2926
|
+
selected: [...read.selected].sort(),
|
|
2927
|
+
aggregate: read.aggregate,
|
|
2928
|
+
direct: read.direct,
|
|
2929
|
+
via: [...read.via].sort()
|
|
2930
|
+
}])),
|
|
1810
2931
|
trace,
|
|
1811
2932
|
scope,
|
|
1812
2933
|
unresolved
|
|
@@ -1832,6 +2953,8 @@ function findBody(file, ref) {
|
|
|
1832
2953
|
}
|
|
1833
2954
|
const fn = file.getFunction(ref.member);
|
|
1834
2955
|
if (fn) return fn;
|
|
2956
|
+
const initializer = file.getVariableDeclaration(ref.member)?.getInitializer();
|
|
2957
|
+
if (initializer && (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer))) return initializer;
|
|
1835
2958
|
return null;
|
|
1836
2959
|
}
|
|
1837
2960
|
for (const cls of file.getClasses()) {
|
|
@@ -1860,6 +2983,27 @@ function storeSymbolsFor(body, file, app, stores) {
|
|
|
1860
2983
|
if (stores.has(named.getName())) symbols.set(binding, named.getName());
|
|
1861
2984
|
}
|
|
1862
2985
|
}
|
|
2986
|
+
/**
|
|
2987
|
+
* `const { default: Noticia } = await import('#noticias/models/noticia')`: a
|
|
2988
|
+
* model imported INSIDE the body — an ace command does this to keep the app
|
|
2989
|
+
* from booting for `--help`. The store is the same; only the binding moved.
|
|
2990
|
+
*/
|
|
2991
|
+
const storeByFile = /* @__PURE__ */ new Map();
|
|
2992
|
+
for (const [name, store] of stores) storeByFile.set(toPosix(store.provenance.file), name);
|
|
2993
|
+
for (const declaration of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
2994
|
+
const binding = declaration.getNameNode();
|
|
2995
|
+
if (!Node.isObjectBindingPattern(binding)) continue;
|
|
2996
|
+
let initializer = declaration.getInitializer();
|
|
2997
|
+
if (initializer && Node.isAwaitExpression(initializer)) initializer = initializer.getExpression();
|
|
2998
|
+
if (!initializer || !Node.isCallExpression(initializer)) continue;
|
|
2999
|
+
if (initializer.getExpression().getKind() !== SyntaxKind.ImportKeyword) continue;
|
|
3000
|
+
const specifier = initializer.getArguments()[0];
|
|
3001
|
+
if (!specifier || !Node.isStringLiteral(specifier)) continue;
|
|
3002
|
+
const target = app.resolveSpecifier(specifier.getLiteralValue());
|
|
3003
|
+
const store = target ? storeByFile.get(toPosix(target)) : void 0;
|
|
3004
|
+
if (!store) continue;
|
|
3005
|
+
for (const element of binding.getElements()) if ((element.getPropertyNameNode()?.getText() ?? element.getName()) === "default") symbols.set(element.getName(), store);
|
|
3006
|
+
}
|
|
1863
3007
|
for (const declaration of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
1864
3008
|
const initializer = declaration.getInitializer();
|
|
1865
3009
|
const name = declaration.getNameNode();
|
|
@@ -2025,6 +3169,32 @@ function importMapsOf(file, app) {
|
|
|
2025
3169
|
if (alias) exportedAs.set(alias, named.getName());
|
|
2026
3170
|
}
|
|
2027
3171
|
}
|
|
3172
|
+
/**
|
|
3173
|
+
* `const { default: SincronizarBulk } = await import('#inpi/actions/sincronizar_bulk')`
|
|
3174
|
+
* `const { execucaoEmAndamento } = await import('#inpi/services/execucao')`
|
|
3175
|
+
*
|
|
3176
|
+
* A module imported INSIDE a body — the shape ace commands use so `--help` does
|
|
3177
|
+
* not boot the application. The binding moved; the body it names did not, and
|
|
3178
|
+
* a command importing its whole action layer this way reached nothing.
|
|
3179
|
+
*/
|
|
3180
|
+
for (const declaration of file.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
3181
|
+
const binding = declaration.getNameNode();
|
|
3182
|
+
if (!Node.isObjectBindingPattern(binding)) continue;
|
|
3183
|
+
let initializer = declaration.getInitializer();
|
|
3184
|
+
if (initializer && Node.isAwaitExpression(initializer)) initializer = initializer.getExpression();
|
|
3185
|
+
if (!initializer || !Node.isCallExpression(initializer)) continue;
|
|
3186
|
+
if (initializer.getExpression().getKind() !== SyntaxKind.ImportKeyword) continue;
|
|
3187
|
+
const specifier = initializer.getArguments()[0];
|
|
3188
|
+
if (!specifier || !Node.isStringLiteral(specifier)) continue;
|
|
3189
|
+
const target = app.resolveSpecifier(specifier.getLiteralValue());
|
|
3190
|
+
if (!target) continue;
|
|
3191
|
+
for (const element of binding.getElements()) {
|
|
3192
|
+
const exported = element.getPropertyNameNode()?.getText() ?? element.getName();
|
|
3193
|
+
const local = element.getName();
|
|
3194
|
+
imports.set(local, target);
|
|
3195
|
+
if (exported !== "default" && exported !== local) exportedAs.set(local, exported);
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
2028
3198
|
return {
|
|
2029
3199
|
imports,
|
|
2030
3200
|
exportedAs
|
|
@@ -2067,15 +3237,163 @@ function unwrapAwait(node) {
|
|
|
2067
3237
|
return current;
|
|
2068
3238
|
}
|
|
2069
3239
|
/**
|
|
2070
|
-
* Hash of the NORMALISED body: comments and whitespace removed.
|
|
3240
|
+
* Hash of the NORMALISED body: comments and whitespace removed.
|
|
3241
|
+
*
|
|
3242
|
+
* counting-decisions §5 measures modification by a checksum of the
|
|
3243
|
+
* implementation scope. If the hash were over the raw bytes, running Prettier
|
|
3244
|
+
* would turn into an invoice.
|
|
3245
|
+
*/
|
|
3246
|
+
function hashOf(body) {
|
|
3247
|
+
const normalized = body.getText().replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "").replace(/\s+/g, "");
|
|
3248
|
+
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
3249
|
+
}
|
|
3250
|
+
//#endregion
|
|
3251
|
+
//#region src/inventory/sources/jobs.ts
|
|
3252
|
+
/** `X.schedule({}).cron(…)`: a scheduler's way of dispatching */
|
|
3253
|
+
const SCHEDULE_METHODS = new Set(["schedule"]);
|
|
3254
|
+
function collectJobs(app) {
|
|
3255
|
+
const project = new Project({
|
|
3256
|
+
skipAddingFilesFromTsConfig: true,
|
|
3257
|
+
skipFileDependencyResolution: true,
|
|
3258
|
+
compilerOptions: { allowJs: false }
|
|
3259
|
+
});
|
|
3260
|
+
const root = toPosix(app.root);
|
|
3261
|
+
for (const dir of app.scanRoots) project.addSourceFilesAtPaths(`${toPosix(dir)}/**/*.ts`);
|
|
3262
|
+
project.addSourceFilesAtPaths(`${root}/start/**/*.ts`);
|
|
3263
|
+
project.addSourceFilesAtPaths(`${root}/commands/**/*.ts`);
|
|
3264
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
3265
|
+
for (const file of project.getSourceFiles()) {
|
|
3266
|
+
const filePath = toPosix(file.getFilePath());
|
|
3267
|
+
if (!isApplicationCode(app.root, filePath) || !inJobsDirectory(filePath)) continue;
|
|
3268
|
+
for (const cls of file.getClasses()) {
|
|
3269
|
+
const name = cls.getName();
|
|
3270
|
+
if (!name || !looksLikeJob(cls)) continue;
|
|
3271
|
+
jobs.set(filePath, {
|
|
3272
|
+
name,
|
|
3273
|
+
file: filePath,
|
|
3274
|
+
dispatchedFrom: [],
|
|
3275
|
+
scheduledFrom: []
|
|
3276
|
+
});
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
if (jobs.size === 0) return [];
|
|
3280
|
+
for (const file of project.getSourceFiles()) {
|
|
3281
|
+
const filePath = toPosix(file.getFilePath());
|
|
3282
|
+
if (/\.(spec|test)\.ts$/.test(file.getBaseName())) continue;
|
|
3283
|
+
const { imports } = importMapsOf(file, app);
|
|
3284
|
+
if (imports.size === 0) continue;
|
|
3285
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
3286
|
+
const callee = call.getExpression();
|
|
3287
|
+
if (!Node.isPropertyAccessExpression(callee)) continue;
|
|
3288
|
+
const method = callee.getName();
|
|
3289
|
+
const scheduled = SCHEDULE_METHODS.has(method);
|
|
3290
|
+
if (!scheduled && !DISPATCH_METHODS.has(method)) continue;
|
|
3291
|
+
const receiver = callee.getExpression();
|
|
3292
|
+
if (!Node.isIdentifier(receiver)) continue;
|
|
3293
|
+
const target = imports.get(receiver.getText());
|
|
3294
|
+
const job = target ? jobs.get(toPosix(target)) : void 0;
|
|
3295
|
+
if (!job) continue;
|
|
3296
|
+
const sites = scheduled ? job.scheduledFrom : job.dispatchedFrom;
|
|
3297
|
+
if (!sites.includes(filePath)) sites.push(filePath);
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
return [...jobs.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
3301
|
+
}
|
|
3302
|
+
const inJobsDirectory = (filePath) => filePath.split("/").slice(0, -1).some((segment) => segment === "jobs" || segment === "job");
|
|
3303
|
+
/** extends something called `…Job`, or declares the method a queue would run */
|
|
3304
|
+
function looksLikeJob(cls) {
|
|
3305
|
+
const parent = cls.getExtends()?.getExpression().getText() ?? "";
|
|
3306
|
+
if (/Job$/.test(parent)) return true;
|
|
3307
|
+
return EXECUTION_METHODS.some((method) => cls.getMethod(method) !== void 0);
|
|
3308
|
+
}
|
|
3309
|
+
//#endregion
|
|
3310
|
+
//#region src/inventory/sources/json_schemas.ts
|
|
3311
|
+
function collectJsonSchemas(app) {
|
|
3312
|
+
const project = new Project({
|
|
3313
|
+
skipAddingFilesFromTsConfig: true,
|
|
3314
|
+
skipFileDependencyResolution: true,
|
|
3315
|
+
compilerOptions: { allowJs: false }
|
|
3316
|
+
});
|
|
3317
|
+
/**
|
|
3318
|
+
* Wider than `scanRoots`, and only here.
|
|
3319
|
+
*
|
|
3320
|
+
* `database/` is excluded from the application roots on purpose: test factories
|
|
3321
|
+
* and migrations contain real persistence calls, and scanning them would turn a
|
|
3322
|
+
* test write into a counted function. But `node ace make:seeder` puts seeders in
|
|
3323
|
+
* `database/seeders`, which is where seed data — and therefore a form's schema —
|
|
3324
|
+
* normally lives. Reading a literal counts nothing, so there is no conflict: the
|
|
3325
|
+
* exclusion protects the call graph, not the schema catalogue.
|
|
3326
|
+
*
|
|
3327
|
+
* Without this, `overrides.detFromSchema` naming a schema declared in a seeder
|
|
3328
|
+
* reported "not declared anywhere in the code" and left the DET count at the
|
|
3329
|
+
* floor — the exact case the override exists for.
|
|
3330
|
+
*/
|
|
3331
|
+
for (const root of [...app.scanRoots.map(toPosix), `${toPosix(app.root)}/database`]) project.addSourceFilesAtPaths(`${root}/**/*.ts`);
|
|
3332
|
+
const found = /* @__PURE__ */ new Map();
|
|
3333
|
+
for (const file of project.getSourceFiles()) for (const declaration of file.getVariableDeclarations()) {
|
|
3334
|
+
const literal = unwrap(declaration.getInitializer())?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
3335
|
+
if (!literal || !isJsonSchema(literal)) continue;
|
|
3336
|
+
const leaves = leavesOf(literal);
|
|
3337
|
+
if (leaves.length === 0) continue;
|
|
3338
|
+
found.set(declaration.getName(), {
|
|
3339
|
+
name: declaration.getName(),
|
|
3340
|
+
fields: leaves.length,
|
|
3341
|
+
leaves,
|
|
3342
|
+
provenance: {
|
|
3343
|
+
file: toPosix(file.getFilePath()),
|
|
3344
|
+
line: declaration.getStartLineNumber(),
|
|
3345
|
+
by: "json-schema"
|
|
3346
|
+
}
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
return found;
|
|
3350
|
+
}
|
|
3351
|
+
/** `{ … } as const` and `{ … } satisfies X` still hold the literal */
|
|
3352
|
+
function unwrap(node) {
|
|
3353
|
+
if (!node) return void 0;
|
|
3354
|
+
if (Node.isAsExpression(node) || Node.isSatisfiesExpression(node)) return unwrap(node.getExpression());
|
|
3355
|
+
return node;
|
|
3356
|
+
}
|
|
3357
|
+
/**
|
|
3358
|
+
* Recognised by shape, never by name.
|
|
3359
|
+
*
|
|
3360
|
+
* A const called `schema` may be anything; an object declaring `type: 'object'`
|
|
3361
|
+
* with a `properties` map is a JSON Schema whatever it is called.
|
|
3362
|
+
*/
|
|
3363
|
+
function isJsonSchema(literal) {
|
|
3364
|
+
return (literal.getProperty("type")?.asKind(SyntaxKind.PropertyAssignment))?.getInitializer()?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue() === "object" && literal.getProperty("properties") !== void 0;
|
|
3365
|
+
}
|
|
3366
|
+
/**
|
|
3367
|
+
* Leaves of a JSON Schema, by the table in counting-decisions §7.
|
|
2071
3368
|
*
|
|
2072
|
-
*
|
|
2073
|
-
*
|
|
2074
|
-
*
|
|
3369
|
+
* scalar 1
|
|
3370
|
+
* nested object leaves counted individually
|
|
3371
|
+
* array of scalar 1 (repeating group)
|
|
3372
|
+
* array of object the object's leaves, once
|
|
3373
|
+
* enum / const 1
|
|
2075
3374
|
*/
|
|
2076
|
-
function
|
|
2077
|
-
const
|
|
2078
|
-
|
|
3375
|
+
function leavesOf(literal, prefix = "") {
|
|
3376
|
+
const properties = literal.getProperty("properties")?.asKind(SyntaxKind.PropertyAssignment);
|
|
3377
|
+
const items = literal.getProperty("items")?.asKind(SyntaxKind.PropertyAssignment);
|
|
3378
|
+
if (properties) {
|
|
3379
|
+
const map = properties.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
3380
|
+
if (!map) return [];
|
|
3381
|
+
return map.getProperties().flatMap((property) => {
|
|
3382
|
+
const assignment = property.asKind(SyntaxKind.PropertyAssignment);
|
|
3383
|
+
if (!assignment) return [];
|
|
3384
|
+
const name = assignment.getName().replace(/['"]/g, "");
|
|
3385
|
+
const path = prefix ? `${prefix}.${name}` : name;
|
|
3386
|
+
const nested = unwrap(assignment.getInitializer())?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
3387
|
+
const inner = nested ? leavesOf(nested, path) : [];
|
|
3388
|
+
return inner.length > 0 ? inner : [path];
|
|
3389
|
+
});
|
|
3390
|
+
}
|
|
3391
|
+
if (items) {
|
|
3392
|
+
const element = unwrap(items.getInitializer())?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
3393
|
+
const inner = element ? leavesOf(element, prefix) : [];
|
|
3394
|
+
return inner.length > 0 ? inner : [prefix];
|
|
3395
|
+
}
|
|
3396
|
+
return [];
|
|
2079
3397
|
}
|
|
2080
3398
|
//#endregion
|
|
2081
3399
|
//#region src/albrecht/tables.ts
|
|
@@ -2155,40 +3473,360 @@ function pointsOf(type, complexity, weights = DEFAULT_WEIGHTS) {
|
|
|
2155
3473
|
return weights[type][complexity];
|
|
2156
3474
|
}
|
|
2157
3475
|
//#endregion
|
|
3476
|
+
//#region src/albrecht/opaque.ts
|
|
3477
|
+
/**
|
|
3478
|
+
* DETs the analysis cannot read, and what a person declared about them —
|
|
3479
|
+
* counting-decisions §8 and §9.
|
|
3480
|
+
*
|
|
3481
|
+
* Three shapes are opaque: a JSON column (`ast:surveys.answers`), an open
|
|
3482
|
+
* input object (`validator:answerSurveyValidator.answers`), and a spread a
|
|
3483
|
+
* transformer emits (`transformer:X.<this.resource.serialize()>`). Each counts
|
|
3484
|
+
* 1 DET — a floor, never a zero — and is marked `(opaque)` in the rationale.
|
|
3485
|
+
*
|
|
3486
|
+
* A declaration is about the ORIGIN of the placeholder, not about a function,
|
|
3487
|
+
* and it applies to every function that carries the DET: the data function and
|
|
3488
|
+
* each transaction that takes or shows the column. Keyed by function it had to
|
|
3489
|
+
* be written twice and still missed the third place, so the same column was
|
|
3490
|
+
* worth two numbers in one count — and matching by bare name meant reviewing
|
|
3491
|
+
* `Attachment.metadata` reviewed every `metadata` column of every table.
|
|
3492
|
+
*/
|
|
3493
|
+
const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
|
|
3494
|
+
const isOpaqueType = (type) => type !== void 0 && OPAQUE_TYPE.test(type);
|
|
3495
|
+
const OPAQUE = " (opaque)";
|
|
3496
|
+
const REVIEWED = " (opaque, reviewed)";
|
|
3497
|
+
/** the origin of a placeholder, spelled the way the configuration keys it */
|
|
3498
|
+
function originOf(source, storeOfTable) {
|
|
3499
|
+
if (!source.endsWith(OPAQUE)) return null;
|
|
3500
|
+
const body = source.slice(0, -9);
|
|
3501
|
+
const colon = body.indexOf(":");
|
|
3502
|
+
const prefix = body.slice(0, colon);
|
|
3503
|
+
const rest = body.slice(colon + 1);
|
|
3504
|
+
switch (prefix) {
|
|
3505
|
+
case "ast":
|
|
3506
|
+
case "generated-schema": {
|
|
3507
|
+
const dot = rest.indexOf(".");
|
|
3508
|
+
const table = rest.slice(0, dot);
|
|
3509
|
+
return `${storeOfTable.get(table) ?? table}.${rest.slice(dot + 1)}`;
|
|
3510
|
+
}
|
|
3511
|
+
case "output":
|
|
3512
|
+
case "validator":
|
|
3513
|
+
case "transformer": return rest;
|
|
3514
|
+
default: return null;
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
/**
|
|
3518
|
+
* Applies the declarations to every function carrying the origin they name.
|
|
3519
|
+
*
|
|
3520
|
+
* schemas the placeholder's 1 DET becomes the schema's leaves, and the line
|
|
3521
|
+
* says which schema stood in
|
|
3522
|
+
* reviewed the placeholder stays 1, marked reviewed; the warning stops
|
|
3523
|
+
*
|
|
3524
|
+
* A declaration keyed by the physical table (`surveys.answers`) is accepted
|
|
3525
|
+
* as well as one keyed by the model (`Survey.answers`): the count prints
|
|
3526
|
+
* the model, `fp:explain` prints the table, and a person copies from either.
|
|
3527
|
+
*/
|
|
3528
|
+
function applyOpaque(functions, options) {
|
|
3529
|
+
const storeOfTable = new Map(options.stores.map((store) => [store.table ?? store.name, store.name]));
|
|
3530
|
+
/** declarations by the origin the rationale will produce */
|
|
3531
|
+
const declared = /* @__PURE__ */ new Map();
|
|
3532
|
+
for (const [key, declaration] of Object.entries(options.declarations)) {
|
|
3533
|
+
const dot = key.indexOf(".");
|
|
3534
|
+
const head = dot === -1 ? key : key.slice(0, dot);
|
|
3535
|
+
const normalised = dot === -1 ? key : `${storeOfTable.get(head) ?? head}.${key.slice(dot + 1)}`;
|
|
3536
|
+
declared.set(normalised, {
|
|
3537
|
+
key,
|
|
3538
|
+
declaration
|
|
3539
|
+
});
|
|
3540
|
+
}
|
|
3541
|
+
const answered = /* @__PURE__ */ new Map();
|
|
3542
|
+
const used = /* @__PURE__ */ new Set();
|
|
3543
|
+
const warnings = [];
|
|
3544
|
+
const warned = /* @__PURE__ */ new Set();
|
|
3545
|
+
const resolveSchemas = (key, names) => {
|
|
3546
|
+
const resolved = names.map((name) => options.schemas.get(name)).filter((schema) => schema !== void 0);
|
|
3547
|
+
for (const name of names) {
|
|
3548
|
+
if (options.schemas.has(name) || warned.has(`${key}:${name}`)) continue;
|
|
3549
|
+
warned.add(`${key}:${name}`);
|
|
3550
|
+
warnings.push(`opaque declaration "${key}" names schema "${name}", which is not declared anywhere in the code: it contributed nothing. A renamed or moved schema breaks the mapping, and this says so rather than counting on silently.`);
|
|
3551
|
+
}
|
|
3552
|
+
if (resolved.length === 0) return null;
|
|
3553
|
+
/** unioned by leaf path: a field two templates share is one DET */
|
|
3554
|
+
const leaves = new Set(resolved.flatMap((schema) => schema.leaves));
|
|
3555
|
+
return {
|
|
3556
|
+
name: resolved.map((schema) => schema.name).join(" + "),
|
|
3557
|
+
fields: leaves.size
|
|
3558
|
+
};
|
|
3559
|
+
};
|
|
3560
|
+
const applied = functions.map((fn) => {
|
|
3561
|
+
let det = fn.det;
|
|
3562
|
+
const sources = [];
|
|
3563
|
+
const overrides = [...fn.rationale.overrides ?? []];
|
|
3564
|
+
const recorded = /* @__PURE__ */ new Set();
|
|
3565
|
+
for (const source of fn.rationale.detSources) {
|
|
3566
|
+
const origin = originOf(source, storeOfTable);
|
|
3567
|
+
const found = origin ? declared.get(origin) : void 0;
|
|
3568
|
+
if (!origin || !found) {
|
|
3569
|
+
sources.push(source);
|
|
3570
|
+
continue;
|
|
3571
|
+
}
|
|
3572
|
+
const { key, declaration } = found;
|
|
3573
|
+
used.add(origin);
|
|
3574
|
+
const body = source.slice(0, -9);
|
|
3575
|
+
if (declaration.schemas) {
|
|
3576
|
+
const schema = resolveSchemas(key, [declaration.schemas].flat());
|
|
3577
|
+
if (!schema) {
|
|
3578
|
+
sources.push(source);
|
|
3579
|
+
continue;
|
|
3580
|
+
}
|
|
3581
|
+
det += schema.fields - 1;
|
|
3582
|
+
sources.push(`${body} → ${schema.name} (${schema.fields} fields)`);
|
|
3583
|
+
answered.set(origin, "replaced");
|
|
3584
|
+
if (!recorded.has(key)) {
|
|
3585
|
+
recorded.add(key);
|
|
3586
|
+
overrides.push({
|
|
3587
|
+
by: `config:opaque.${key} (from ${schema.name}: ${schema.fields} fields)`,
|
|
3588
|
+
reason: declaration.reason,
|
|
3589
|
+
fields: ["det"]
|
|
3590
|
+
});
|
|
3591
|
+
}
|
|
3592
|
+
continue;
|
|
3593
|
+
}
|
|
3594
|
+
if (declaration.reviewed) {
|
|
3595
|
+
sources.push(body + REVIEWED);
|
|
3596
|
+
answered.set(origin, "reviewed");
|
|
3597
|
+
if (!recorded.has(key)) {
|
|
3598
|
+
recorded.add(key);
|
|
3599
|
+
overrides.push({
|
|
3600
|
+
by: `config:opaque.${key}`,
|
|
3601
|
+
reason: declaration.reason,
|
|
3602
|
+
fields: []
|
|
3603
|
+
});
|
|
3604
|
+
}
|
|
3605
|
+
continue;
|
|
3606
|
+
}
|
|
3607
|
+
sources.push(source);
|
|
3608
|
+
}
|
|
3609
|
+
if (det === fn.det && overrides.length === (fn.rationale.overrides?.length ?? 0)) return fn;
|
|
3610
|
+
const complexity = complexityOf(fn.type, fn.refs, det, options.tables);
|
|
3611
|
+
return {
|
|
3612
|
+
...fn,
|
|
3613
|
+
det,
|
|
3614
|
+
complexity,
|
|
3615
|
+
points: pointsOf(fn.type, complexity, options.weights),
|
|
3616
|
+
rationale: {
|
|
3617
|
+
...fn.rationale,
|
|
3618
|
+
detSources: sources,
|
|
3619
|
+
overrides
|
|
3620
|
+
}
|
|
3621
|
+
};
|
|
3622
|
+
});
|
|
3623
|
+
for (const [origin, { key }] of declared) {
|
|
3624
|
+
if (used.has(origin)) continue;
|
|
3625
|
+
warnings.push(`opaque declaration "${key}" matches no DET the analysis found opaque: it had no effect. The origins it can answer are the ones \`fp:count\` lists — \`Store.column\` or \`validator.field\`.`);
|
|
3626
|
+
}
|
|
3627
|
+
return {
|
|
3628
|
+
functions: applied,
|
|
3629
|
+
answered,
|
|
3630
|
+
warnings
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
/**
|
|
3634
|
+
* The floors still standing, by origin — the one blind spot this package used
|
|
3635
|
+
* to keep to itself.
|
|
3636
|
+
*
|
|
3637
|
+
* Grouped by origin because that is what a declaration answers: one line for
|
|
3638
|
+
* `Form.definition` however many transactions show it. A transformer's spread has
|
|
3639
|
+
* its own warning and is not repeated here. Only what is unanswered is a request
|
|
3640
|
+
* to do something; what was answered is counted at the end so the fact is
|
|
3641
|
+
* recorded rather than erased.
|
|
3642
|
+
*/
|
|
3643
|
+
function opaqueWarnings(input) {
|
|
3644
|
+
const storeOfTable = new Map(input.stores.map((store) => [store.table ?? store.name, store.name]));
|
|
3645
|
+
const typeOf = /* @__PURE__ */ new Map();
|
|
3646
|
+
for (const store of input.stores) for (const attribute of store.attributes) if (attribute.type) typeOf.set(`${store.name}.${attribute.name}`, attribute.type);
|
|
3647
|
+
/** how many transactions reach each store, so the reader can judge a column's weight */
|
|
3648
|
+
const reached = /* @__PURE__ */ new Map();
|
|
3649
|
+
for (const entry of input.entryPoints) for (const store of input.behaviors.get(entry.id)?.touches ?? []) reached.set(store, (reached.get(store) ?? 0) + 1);
|
|
3650
|
+
const floors = /* @__PURE__ */ new Map();
|
|
3651
|
+
for (const fn of input.functions) for (const source of fn.rationale.detSources) {
|
|
3652
|
+
const origin = originOf(source, storeOfTable);
|
|
3653
|
+
if (!origin || source.startsWith("transformer:")) continue;
|
|
3654
|
+
const kind = source.startsWith("validator:") ? "input object" : "column";
|
|
3655
|
+
const floor = floors.get(origin) ?? {
|
|
3656
|
+
kind,
|
|
3657
|
+
carriers: []
|
|
3658
|
+
};
|
|
3659
|
+
if (!floor.carriers.includes(fn.name)) floor.carriers.push(fn.name);
|
|
3660
|
+
floors.set(origin, floor);
|
|
3661
|
+
}
|
|
3662
|
+
const lines = [...floors.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([origin, floor]) => {
|
|
3663
|
+
if (floor.kind === "input object") return ` ${origin} — input object on ${floor.carriers.join(", ")}`;
|
|
3664
|
+
const store = origin.slice(0, origin.indexOf("."));
|
|
3665
|
+
const type = typeOf.get(origin);
|
|
3666
|
+
return ` ${origin}${type ? ` (${type})` : ""} — column on ${store}, reached by ${reached.get(store) ?? 0} transaction(s)`;
|
|
3667
|
+
});
|
|
3668
|
+
const replaced = [...input.answered.values()].filter((how) => how === "replaced").length;
|
|
3669
|
+
const reviewed = [...input.answered.values()].filter((how) => how === "reviewed").length;
|
|
3670
|
+
const settled = replaced + reviewed === 0 ? [] : [` (already answered: ` + [...replaced > 0 ? [`${replaced} replaced by a schema`] : [], ...reviewed > 0 ? [`${reviewed} reviewed`] : []].join(", ") + `)`];
|
|
3671
|
+
if (lines.length === 0) return settled;
|
|
3672
|
+
return [
|
|
3673
|
+
`${lines.length} DET(s) the analysis cannot read, counted as 1 each — a FLOOR, not a measurement. Where the fields are declared in the source, name that schema with \`opaque.<origin>.schemas\`; where 1 is the right answer, record it with \`opaque.<origin>.reviewed\` — counting-decisions §8:`,
|
|
3674
|
+
...lines,
|
|
3675
|
+
...settled
|
|
3676
|
+
];
|
|
3677
|
+
}
|
|
3678
|
+
//#endregion
|
|
2158
3679
|
//#region src/albrecht/data_functions.ts
|
|
2159
3680
|
/**
|
|
2160
|
-
* A
|
|
2161
|
-
*
|
|
2162
|
-
*
|
|
3681
|
+
* A store `C` is a RET of `P` when, and only when:
|
|
3682
|
+
*
|
|
3683
|
+
* 1. `P` declares `hasMany` / `hasOne` -> `C` (collected as `subgroups`);
|
|
3684
|
+
* 2. no application code addresses `C` directly — the user only ever reaches
|
|
3685
|
+
* it through `P`, so under the CPM it is not a logical file of its own;
|
|
3686
|
+
* 3. exactly one `P` satisfies (1). More than one: `C` stays apart, reported.
|
|
3687
|
+
*
|
|
3688
|
+
* Cascade delete was measured and rejected as the signal: on a real application
|
|
3689
|
+
* 11 of 13 cascades pointed at the tenant table. Usage is the rule the rest of
|
|
3690
|
+
* the count already runs on.
|
|
3691
|
+
*/
|
|
3692
|
+
function groupStores(stores, options) {
|
|
3693
|
+
const rootOf = /* @__PURE__ */ new Map();
|
|
3694
|
+
const members = /* @__PURE__ */ new Map();
|
|
3695
|
+
const linkColumns = /* @__PURE__ */ new Map();
|
|
3696
|
+
const warnings = [];
|
|
3697
|
+
const byName = new Map(stores.map((store) => [store.name, store]));
|
|
3698
|
+
for (const store of stores) {
|
|
3699
|
+
rootOf.set(store.name, store.name);
|
|
3700
|
+
members.set(store.name, [store.name]);
|
|
3701
|
+
}
|
|
3702
|
+
const strategy = options.grouping;
|
|
3703
|
+
if (strategy === "none") return {
|
|
3704
|
+
strategy,
|
|
3705
|
+
rootOf,
|
|
3706
|
+
members,
|
|
3707
|
+
linkColumns,
|
|
3708
|
+
warnings
|
|
3709
|
+
};
|
|
3710
|
+
/** child -> the parents declaring a composition relation to it */
|
|
3711
|
+
const parentsOf = /* @__PURE__ */ new Map();
|
|
3712
|
+
for (const store of stores) for (const child of store.subgroups) {
|
|
3713
|
+
if (!byName.has(child)) continue;
|
|
3714
|
+
parentsOf.set(child, [...parentsOf.get(child) ?? [], store.name]);
|
|
3715
|
+
}
|
|
3716
|
+
const parentChosen = /* @__PURE__ */ new Map();
|
|
3717
|
+
for (const [child, parents] of [...parentsOf.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
3718
|
+
if (options.addressedAnywhere.has(child)) continue;
|
|
3719
|
+
if (parents.length > 1) {
|
|
3720
|
+
warnings.push(`not grouped: ${child} is a composition child of ${parents.sort().join(" and ")} and no application code addresses it directly. Which parent it belongs to is not derivable from the code, so it stays its own data function.`);
|
|
3721
|
+
continue;
|
|
3722
|
+
}
|
|
3723
|
+
parentChosen.set(child, parents[0]);
|
|
3724
|
+
}
|
|
3725
|
+
/** follow parent -> parent up to a store that is its own root; a cycle stops at the child */
|
|
3726
|
+
const rootFor = (child) => {
|
|
3727
|
+
let current = child;
|
|
3728
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3729
|
+
while (parentChosen.has(current) && !seen.has(current)) {
|
|
3730
|
+
seen.add(current);
|
|
3731
|
+
current = parentChosen.get(current);
|
|
3732
|
+
}
|
|
3733
|
+
return seen.has(current) ? child : current;
|
|
3734
|
+
};
|
|
3735
|
+
for (const [child, parent] of parentChosen) {
|
|
3736
|
+
const root = rootFor(child);
|
|
3737
|
+
if (root === child) continue;
|
|
3738
|
+
rootOf.set(child, root);
|
|
3739
|
+
members.get(root).push(child);
|
|
3740
|
+
members.delete(child);
|
|
3741
|
+
linkColumns.set(child, foreignKeysTo(byName.get(child), parent));
|
|
3742
|
+
warnings.push(`grouped: ${child} is a RET of ${root} — ${parent} declares hasMany/hasOne to it, and no application code addresses it directly (counting-decisions §10)`);
|
|
3743
|
+
}
|
|
3744
|
+
for (const [root, list] of members) members.set(root, [root, ...list.filter((member) => member !== root).sort()]);
|
|
3745
|
+
return {
|
|
3746
|
+
strategy,
|
|
3747
|
+
rootOf,
|
|
3748
|
+
members,
|
|
3749
|
+
linkColumns,
|
|
3750
|
+
warnings
|
|
3751
|
+
};
|
|
3752
|
+
}
|
|
3753
|
+
/**
|
|
3754
|
+
* The child's foreign keys to its parent, by Lucid's convention: the
|
|
3755
|
+
* `belongsTo` property plus `Id`. Inside one logical file that key is the
|
|
3756
|
+
* subgroup's link, not an attribute the user recognises. A key to a DIFFERENT
|
|
3757
|
+
* data function still counts, as IFPUG requires.
|
|
2163
3758
|
*/
|
|
2164
|
-
|
|
3759
|
+
function foreignKeysTo(child, parent) {
|
|
3760
|
+
const names = new Set(child.attributes.map((attribute) => attribute.name));
|
|
3761
|
+
const keys = /* @__PURE__ */ new Set();
|
|
3762
|
+
for (const [property, target] of Object.entries(child.relations)) {
|
|
3763
|
+
if (target !== parent) continue;
|
|
3764
|
+
const key = `${property}Id`;
|
|
3765
|
+
if (names.has(key)) keys.add(key);
|
|
3766
|
+
}
|
|
3767
|
+
return keys;
|
|
3768
|
+
}
|
|
3769
|
+
/** the DET attributes of one store: not the key, not a system stamp, not a link to its parent */
|
|
3770
|
+
function detAttributesOf(store, links = /* @__PURE__ */ new Set()) {
|
|
3771
|
+
return store.attributes.filter((attribute) => !attribute.isIdentifier && !attribute.system && !links.has(attribute.name));
|
|
3772
|
+
}
|
|
2165
3773
|
function countDataFunctions(stores, usage, options) {
|
|
2166
3774
|
const counted = [];
|
|
3775
|
+
const byName = new Map(stores.map((store) => [store.name, store]));
|
|
3776
|
+
const { rootOf, members, linkColumns } = options.grouping;
|
|
2167
3777
|
for (const store of stores) {
|
|
2168
|
-
|
|
2169
|
-
|
|
3778
|
+
if (rootOf.get(store.name) !== store.name) continue;
|
|
3779
|
+
const group = (members.get(store.name) ?? [store.name]).map((name) => byName.get(name)).filter((member) => member !== void 0);
|
|
3780
|
+
/**
|
|
3781
|
+
* Usage and maintenance are properties of the GROUP: a transaction that
|
|
3782
|
+
* reaches or writes the detail reaches or writes the logical file.
|
|
3783
|
+
*/
|
|
3784
|
+
const use = group.reduce((total, member) => {
|
|
3785
|
+
const each = usage.get(member.name);
|
|
3786
|
+
return {
|
|
3787
|
+
used: total.used || each?.used === true,
|
|
3788
|
+
written: total.written || each?.written === true
|
|
3789
|
+
};
|
|
3790
|
+
}, {
|
|
3791
|
+
used: false,
|
|
3792
|
+
written: false
|
|
3793
|
+
});
|
|
3794
|
+
if (!use.used) continue;
|
|
2170
3795
|
/**
|
|
2171
|
-
* DETs exclude the technical identifier
|
|
3796
|
+
* DETs exclude the technical identifier, the system timestamps, and — for a
|
|
3797
|
+
* child folded in — its link to the parent.
|
|
2172
3798
|
*
|
|
2173
3799
|
* IFPUG defines a DET as a "user recognizable" attribute, and an
|
|
2174
3800
|
* auto-increment surrogate key is not something the user recognises.
|
|
2175
|
-
* Counting it would inflate every data function by one.
|
|
3801
|
+
* Counting it would inflate every data function by one. A column the
|
|
3802
|
+
* framework stamps (`autoCreate` / `autoUpdate`) is the same kind of field —
|
|
3803
|
+
* counting-decisions §6.
|
|
2176
3804
|
*/
|
|
2177
|
-
const
|
|
2178
|
-
|
|
2179
|
-
const
|
|
3805
|
+
const detSources = [];
|
|
3806
|
+
let det = 0;
|
|
3807
|
+
for (const member of group) for (const attribute of detAttributesOf(member, linkColumns.get(member.name))) {
|
|
3808
|
+
det++;
|
|
3809
|
+
detSources.push(`${member.columnSource}:${member.table ?? member.name}.${attribute.name}` + (isOpaqueType(attribute.type) ? " (opaque)" : ""));
|
|
3810
|
+
}
|
|
3811
|
+
const refs = group.length;
|
|
2180
3812
|
/**
|
|
2181
3813
|
* Maintained by the application, or by another system?
|
|
2182
3814
|
*
|
|
2183
3815
|
* A write reachable from an entry point is the common case. A write from a
|
|
2184
|
-
* job
|
|
2185
|
-
*
|
|
3816
|
+
* job maintains the store just as much — AFP §6.5.4 asks who maintains it,
|
|
3817
|
+
* not which route does.
|
|
2186
3818
|
*/
|
|
2187
|
-
const maintained = use.written || options.writtenAnywhere.has(
|
|
2188
|
-
const
|
|
3819
|
+
const maintained = use.written || group.some((member) => options.writtenAnywhere.has(member.name));
|
|
3820
|
+
const declaredExternal = group.some((member) => options.externallyMaintained.has(member.name));
|
|
3821
|
+
const type = declaredExternal || !maintained ? "EIF" : "ILF";
|
|
2189
3822
|
const complexity = complexityOf(type, refs, det, options.tables);
|
|
2190
3823
|
counted.push({
|
|
2191
|
-
|
|
3824
|
+
/**
|
|
3825
|
+
* Identity is the physical table — counting-decisions §5 — never the class.
|
|
3826
|
+
* Renaming a model is implementation; keyed by the class it billed as a
|
|
3827
|
+
* deletion plus an addition for zero functional change.
|
|
3828
|
+
*/
|
|
3829
|
+
id: `data:${store.table ?? store.name}`,
|
|
2192
3830
|
name: store.name,
|
|
2193
3831
|
module: store.module,
|
|
2194
3832
|
type,
|
|
@@ -2197,9 +3835,12 @@ function countDataFunctions(stores, usage, options) {
|
|
|
2197
3835
|
complexity,
|
|
2198
3836
|
points: pointsOf(type, complexity, options.weights),
|
|
2199
3837
|
rationale: {
|
|
2200
|
-
rule:
|
|
2201
|
-
detSources
|
|
2202
|
-
refSources: options.
|
|
3838
|
+
rule: declaredExternal ? "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",
|
|
3839
|
+
detSources,
|
|
3840
|
+
refSources: [options.grouping.strategy === "none" ? `1 (grouping disabled: every table is its own data function)` : `1 (main group: ${store.table ?? store.name})`, ...group.slice(1).map((member) => {
|
|
3841
|
+
const links = [...linkColumns.get(member.name) ?? []].join(", ") || "none found";
|
|
3842
|
+
return `subgroup:${member.name} — a composition child no application code addresses directly; its link to the parent (${links}) is not a DET`;
|
|
3843
|
+
})]
|
|
2203
3844
|
}
|
|
2204
3845
|
});
|
|
2205
3846
|
}
|
|
@@ -2220,7 +3861,19 @@ function countTransactionalFunctions(entryPoints, behaviors, options) {
|
|
|
2220
3861
|
*/
|
|
2221
3862
|
if (touched.length === 0) continue;
|
|
2222
3863
|
const type = behavior.writes ? "EI" : "EO";
|
|
2223
|
-
|
|
3864
|
+
/**
|
|
3865
|
+
* FTR counts logical files, not tables: the master and the detail folded
|
|
3866
|
+
* into it are one. `reaches:Pedido (via ItemPedido)` keeps the path visible.
|
|
3867
|
+
*/
|
|
3868
|
+
const { rootOf } = options.grouping;
|
|
3869
|
+
const viaOf = /* @__PURE__ */ new Map();
|
|
3870
|
+
for (const store of touched) {
|
|
3871
|
+
const root = rootOf.get(store) ?? store;
|
|
3872
|
+
const via = viaOf.get(root) ?? [];
|
|
3873
|
+
if (root !== store) via.push(store);
|
|
3874
|
+
viaOf.set(root, via);
|
|
3875
|
+
}
|
|
3876
|
+
const refs = viaOf.size;
|
|
2224
3877
|
const { det, sources } = detsFor(entry, behavior, touched, type, options);
|
|
2225
3878
|
const complexity = complexityOf(type, refs, det, options.tables);
|
|
2226
3879
|
counted.push({
|
|
@@ -2236,7 +3889,7 @@ function countTransactionalFunctions(entryPoints, behaviors, options) {
|
|
|
2236
3889
|
rationale: {
|
|
2237
3890
|
rule: behavior.writes ? "afp:6.5.3 modifies a data store -> EI" : "afp:6.5.3 uses without modifying -> EO (EQ collapsed per 6.5.3)",
|
|
2238
3891
|
detSources: sources,
|
|
2239
|
-
refSources:
|
|
3892
|
+
refSources: [...viaOf.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([root, via]) => `reaches:${root}${via.length ? ` (via ${via.join(", ")})` : ""}`),
|
|
2240
3893
|
trace: behavior.trace.map((step) => ({
|
|
2241
3894
|
...step,
|
|
2242
3895
|
file: relativeTo(options.root, step.file)
|
|
@@ -2274,7 +3927,8 @@ function scopeHashOf(behavior) {
|
|
|
2274
3927
|
* With no `.select()` and no visible transformer, the output fields are the
|
|
2275
3928
|
* whole table, which **overestimates**. That is the trade AFP makes on purpose,
|
|
2276
3929
|
* favouring repeatability over fidelity; the origin is recorded in `Rationale`
|
|
2277
|
-
* so `fp:calibrate` can measure the
|
|
3930
|
+
* (`transformer:` / `select:` / `output:`) so `fp:calibrate` can measure the
|
|
3931
|
+
* bias per origin.
|
|
2278
3932
|
*/
|
|
2279
3933
|
function detsFor(entry, behavior, touched, type, options) {
|
|
2280
3934
|
const sources = [];
|
|
@@ -2284,7 +3938,7 @@ function detsFor(entry, behavior, touched, type, options) {
|
|
|
2284
3938
|
counted.add(field);
|
|
2285
3939
|
sources.push(source);
|
|
2286
3940
|
};
|
|
2287
|
-
for (const param of entry.signature.match(/:[A-Za-z_][\w]*/g) ?? []) add(param.slice(1), `param:${param}`);
|
|
3941
|
+
if (entry.kind === "http") for (const param of entry.signature.match(/:[A-Za-z_][\w]*/g) ?? []) add(param.slice(1), `param:${param}`);
|
|
2288
3942
|
/**
|
|
2289
3943
|
* `(opaque)` is not decoration: `detFromSchema` replaces the opaque placeholder
|
|
2290
3944
|
* with a schema's fields, and it used to assume there was exactly one and that
|
|
@@ -2304,9 +3958,104 @@ function detsFor(entry, behavior, touched, type, options) {
|
|
|
2304
3958
|
* paid for twice.
|
|
2305
3959
|
*/
|
|
2306
3960
|
for (const field of behavior.requestFields) add(field, `request:${field}`);
|
|
2307
|
-
|
|
2308
|
-
const
|
|
2309
|
-
|
|
3961
|
+
for (const field of behavior.commandFields ?? []) {
|
|
3962
|
+
const [kind, ...rest] = field.split(".");
|
|
3963
|
+
const name = rest.join(".");
|
|
3964
|
+
add(name, `${kind === "args" ? "arg" : "flag"}:${name}`);
|
|
3965
|
+
}
|
|
3966
|
+
if (type === "EO" || type === "EQ") {
|
|
3967
|
+
/**
|
|
3968
|
+
* counting-decisions §6, per store, in order of what is visible:
|
|
3969
|
+
*
|
|
3970
|
+
* transformer covers ITS resource: the keys leave, the columns do not.
|
|
3971
|
+
* A store read beside it and passed raw is not covered.
|
|
3972
|
+
* aggregate `.count()` / `.exists()`: one derived scalar leaves — 1 DET,
|
|
3973
|
+
* whatever else is known about the store
|
|
3974
|
+
* whole rows leave: every column
|
|
3975
|
+
* select only the columns named
|
|
3976
|
+
* unknown reached some other way (a hook, a relation): every column
|
|
3977
|
+
*
|
|
3978
|
+
* An unreadable spread in a transformer is a placeholder at 1 DET, marked
|
|
3979
|
+
* `(opaque)` like an open input object, and the counter reports it.
|
|
3980
|
+
*/
|
|
3981
|
+
const opaqueOutputs = new Set(behavior.opaqueOutputFields);
|
|
3982
|
+
/**
|
|
3983
|
+
* Covered: the stores a transformer is for, and the stores preloaded ONLY
|
|
3984
|
+
* through a covered one — `Livro.query().preload('autor')` handed to
|
|
3985
|
+
* `RecenteTransformer<Livro>` loads the author for the transformer, which
|
|
3986
|
+
* emits whatever of it leaves. A store read by a chain of its own is shown
|
|
3987
|
+
* for itself and is never covered this way. Iterated to a fixpoint: a
|
|
3988
|
+
* relation of a relation.
|
|
3989
|
+
*/
|
|
3990
|
+
const covered = new Set(behavior.transformedStores);
|
|
3991
|
+
for (let changed = true; changed;) {
|
|
3992
|
+
changed = false;
|
|
3993
|
+
for (const [store, read] of Object.entries(behavior.outputReads)) {
|
|
3994
|
+
if (covered.has(store) || read.direct || read.via.length === 0) continue;
|
|
3995
|
+
if (!read.via.every((parent) => covered.has(parent))) continue;
|
|
3996
|
+
covered.add(store);
|
|
3997
|
+
changed = true;
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
4000
|
+
for (const field of behavior.outputFields) add(field, `transformer:${field}${opaqueOutputs.has(field) ? " (opaque)" : ""}`);
|
|
4001
|
+
/**
|
|
4002
|
+
* Plan 0.7 §A′ — the delivery is the boundary. When the transaction hands
|
|
4003
|
+
* something to a renderer or a response, THAT is what leaves: derived values
|
|
4004
|
+
* and the leaves of literals a followed body returned count 1 each; a store
|
|
4005
|
+
* leaves only if its rows were handed on, raw or through a query object; a
|
|
4006
|
+
* store read to authorise or to decide contributes nothing to the output. A
|
|
4007
|
+
* field that entered and is echoed back counts once, on entry (§7.3) — the
|
|
4008
|
+
* classifier marks it as an echo. Without a delivery point the rule cannot
|
|
4009
|
+
* apply, and every store read leaves, as before.
|
|
4010
|
+
*/
|
|
4011
|
+
const delivered = behavior.delivered;
|
|
4012
|
+
if (delivered.any) {
|
|
4013
|
+
for (const field of delivered.fields) add(`render.${field}`, `render:${field}`);
|
|
4014
|
+
for (const field of delivered.opaqueFields) add(`render.${field}`, `render:${field} (opaque)`);
|
|
4015
|
+
}
|
|
4016
|
+
const deliveredStores = new Set(delivered.stores);
|
|
4017
|
+
for (let changed = true; changed;) {
|
|
4018
|
+
changed = false;
|
|
4019
|
+
for (const [store, read] of Object.entries(behavior.outputReads)) {
|
|
4020
|
+
if (deliveredStores.has(store) || read.direct) continue;
|
|
4021
|
+
if (!read.via.some((parent) => deliveredStores.has(parent))) continue;
|
|
4022
|
+
deliveredStores.add(store);
|
|
4023
|
+
changed = true;
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
for (const store of touched) {
|
|
4027
|
+
const read = behavior.outputReads[store];
|
|
4028
|
+
if (delivered.any && !deliveredStores.has(store) && !covered.has(store)) continue;
|
|
4029
|
+
if (read?.aggregate) add(`${store}.<aggregate>`, `aggregate:${store} (a count or an existence check: one scalar)`);
|
|
4030
|
+
if (covered.has(store)) continue;
|
|
4031
|
+
if (read && read.aggregate && !read.whole && read.selected.length === 0) continue;
|
|
4032
|
+
/**
|
|
4033
|
+
* The key and the system timestamps are not DETs however they leave —
|
|
4034
|
+
* selected by name or as part of the whole table. Same ground as on the
|
|
4035
|
+
* data function: the user neither supplies nor recognises them (§6). A
|
|
4036
|
+
* hidden column is a DET of the file and never of an output.
|
|
4037
|
+
*/
|
|
4038
|
+
const attributes = options.countedStores.get(store).attributes;
|
|
4039
|
+
const excluded = new Set([...attributes.filter((a) => a.isIdentifier || a.system || a.hidden).map((a) => a.name), ...options.grouping.linkColumns.get(store) ?? []]);
|
|
4040
|
+
/**
|
|
4041
|
+
* A JSON column leaving the boundary is as unreadable here as on the data
|
|
4042
|
+
* function: 1 DET, marked, so a declaration about the column (§8) reaches the
|
|
4043
|
+
* transactions that show it and not only the store.
|
|
4044
|
+
*/
|
|
4045
|
+
const opaqueOf = new Map(attributes.map((a) => [a.name, isOpaqueType(a.type)]));
|
|
4046
|
+
const mark = (column) => opaqueOf.get(column) ? " (opaque)" : "";
|
|
4047
|
+
if (read && !read.whole && read.selected.length > 0) {
|
|
4048
|
+
for (const column of read.selected) {
|
|
4049
|
+
if (excluded.has(column)) continue;
|
|
4050
|
+
add(`${store}.${column}`, `select:${store}.${column}${mark(column)}`);
|
|
4051
|
+
}
|
|
4052
|
+
continue;
|
|
4053
|
+
}
|
|
4054
|
+
for (const column of attributes) {
|
|
4055
|
+
if (excluded.has(column.name)) continue;
|
|
4056
|
+
add(`${store}.${column.name}`, `output:${store}.${column.name}${mark(column.name)}`);
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
2310
4059
|
}
|
|
2311
4060
|
let det = counted.size + options.messageDet;
|
|
2312
4061
|
if (options.messageDet > 0) sources.push("message:1");
|
|
@@ -2318,20 +4067,17 @@ function detsFor(entry, behavior, touched, type, options) {
|
|
|
2318
4067
|
//#endregion
|
|
2319
4068
|
//#region src/albrecht/technical_filter.ts
|
|
2320
4069
|
/**
|
|
2321
|
-
*
|
|
4070
|
+
* Naming conventions, with the defaults given by the spec itself (§6.5.2.1.3)
|
|
4071
|
+
* plus the one AdonisJS asks for.
|
|
2322
4072
|
*
|
|
2323
|
-
*
|
|
2324
|
-
*
|
|
2325
|
-
*
|
|
2326
|
-
*
|
|
2327
|
-
* Returns the reason when a table is technical, `null` otherwise: the report
|
|
2328
|
-
* must say WHY something was excluded, not merely that it was.
|
|
2329
|
-
*/
|
|
2330
|
-
/**
|
|
2331
|
-
* Naming conventions, with the defaults given by the spec itself (§6.5.2.1.3).
|
|
4073
|
+
* The standard treats these as user-provided inputs, so `boundary.technicalPatterns`
|
|
4074
|
+
* REPLACES this list when set — a team that finds `.+types?` catching its
|
|
4075
|
+
* business data drops it there — and `boundary.business` restores one table.
|
|
2332
4076
|
*
|
|
2333
|
-
*
|
|
2334
|
-
*
|
|
4077
|
+
* `token` is not in the spec's list and is here because the framework's own
|
|
4078
|
+
* tables are: `auth_access_tokens`, `remember_me_tokens`, `password_reset_tokens`.
|
|
4079
|
+
* A token is the machinery of authentication, not data the user maintains, and
|
|
4080
|
+
* on two applications it came out as an ILF at 7 PF each.
|
|
2335
4081
|
*/
|
|
2336
4082
|
const DEFAULT_TECHNICAL_PATTERNS = [
|
|
2337
4083
|
{
|
|
@@ -2349,11 +4095,18 @@ const DEFAULT_TECHNICAL_PATTERNS = [
|
|
|
2349
4095
|
{
|
|
2350
4096
|
label: "template entity",
|
|
2351
4097
|
pattern: /^(.*template.*)$/i
|
|
4098
|
+
},
|
|
4099
|
+
{
|
|
4100
|
+
label: "token entity",
|
|
4101
|
+
pattern: /^(.*tokens?.*)$/i
|
|
2352
4102
|
}
|
|
2353
4103
|
];
|
|
2354
4104
|
function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
|
|
2355
4105
|
const table = store.table ?? store.name;
|
|
2356
|
-
for (const { label, pattern } of patterns)
|
|
4106
|
+
for (const { label, pattern } of patterns) {
|
|
4107
|
+
const regex = typeof pattern === "string" ? new RegExp(pattern, "i") : pattern;
|
|
4108
|
+
if (regex.test(table)) return `${label} (AFP §6.5.2.1.3: ${regex.source})`;
|
|
4109
|
+
}
|
|
2357
4110
|
return null;
|
|
2358
4111
|
}
|
|
2359
4112
|
//#endregion
|
|
@@ -2378,15 +4131,23 @@ const RULESET = "afp";
|
|
|
2378
4131
|
* that is easy to forget. Four such changes landed in 1.1.0 — maintenance read
|
|
2379
4132
|
* across the whole project rather than from routes alone, a job followed into
|
|
2380
4133
|
* `process`, an event followed into its listeners, and `request.input(…)` counted
|
|
2381
|
-
* as a DET —
|
|
4134
|
+
* as a DET — three more in 1.2.0: an open input object counting 1 instead of 0,
|
|
2382
4135
|
* `detFromSchema` no longer subtracting a placeholder that was not there, and a
|
|
2383
|
-
* write through `related(…)` maintaining the related table.
|
|
4136
|
+
* write through `related(…)` maintaining the related table — and six in 1.5.0:
|
|
4137
|
+
* output DETs read from transformers, selects and aggregates instead of every
|
|
4138
|
+
* column; system timestamps and `serializeAs: null` columns leaving the DETs;
|
|
4139
|
+
* master-detail folded into one data function; identity by table; token tables
|
|
4140
|
+
* technical; and opaque declarations reaching every function carrying the origin.
|
|
4141
|
+
* Three in 1.6.0: an output's DETs are what the transaction DELIVERS (the render
|
|
4142
|
+
* props, the response payload, what a command prints) read back to their origin;
|
|
4143
|
+
* a function of the same file and a `.map(fn)` by reference are followed, so
|
|
4144
|
+
* FTRs move; and ace commands are transactions, with flags as input.
|
|
2384
4145
|
*
|
|
2385
4146
|
* Without the bump, a baseline saved by the previous version compares cleanly
|
|
2386
4147
|
* against this one and bills the tool's own improvement as work done. The guard
|
|
2387
4148
|
* exists for exactly that, and only this constant arms it.
|
|
2388
4149
|
*/
|
|
2389
|
-
const RULESET_VERSION = "1.
|
|
4150
|
+
const RULESET_VERSION = "1.6.0";
|
|
2390
4151
|
function count(input, options = {}) {
|
|
2391
4152
|
const warnings = [];
|
|
2392
4153
|
const usage = usageOf(input);
|
|
@@ -2405,7 +4166,7 @@ function count(input, options = {}) {
|
|
|
2405
4166
|
warnings.push(`excluded by boundary configuration: ${store.name}`);
|
|
2406
4167
|
return false;
|
|
2407
4168
|
}
|
|
2408
|
-
const technical = isTechnical(store);
|
|
4169
|
+
const technical = isTechnical(store, options.boundary?.technicalPatterns);
|
|
2409
4170
|
if (!technical) return true;
|
|
2410
4171
|
/**
|
|
2411
4172
|
* The naming filter is a heuristic over names, so it catches business data
|
|
@@ -2431,50 +4192,83 @@ function count(input, options = {}) {
|
|
|
2431
4192
|
warnings.push(`technical, excluded: ${store.name} (${technical})`);
|
|
2432
4193
|
return false;
|
|
2433
4194
|
});
|
|
4195
|
+
/**
|
|
4196
|
+
* A configuration the code does not honour is worse than none: whoever set it
|
|
4197
|
+
* believes something changed. `retStrategy` left the type in 0.6.0 — but a
|
|
4198
|
+
* configuration file is loaded without types, so an old one still arrives here
|
|
4199
|
+
* and has to be told.
|
|
4200
|
+
*/
|
|
4201
|
+
if (options.retStrategy !== void 0) warnings.push("`retStrategy` is no longer read: RET comes from how the application uses each table (counting-decisions §10), configurable as `dataFunctions.grouping: 'usage' | 'none'`. Remove the key.");
|
|
4202
|
+
/**
|
|
4203
|
+
* 3. how the stores fold into data functions — §10 — then the data functions.
|
|
4204
|
+
*
|
|
4205
|
+
* Grouping by usage needs the project-wide pass. Without it an empty set would
|
|
4206
|
+
* read as "nobody addresses this table" and fold every composition child into
|
|
4207
|
+
* its parent — the absence of a fact is not the fact. The pipeline always
|
|
4208
|
+
* provides it; a direct caller that does not is told, and gets no grouping.
|
|
4209
|
+
*/
|
|
4210
|
+
const strategy = options.dataFunctions?.grouping ?? "usage";
|
|
4211
|
+
if (strategy === "usage" && input.addressedAnywhere === void 0) warnings.push("grouping by usage needs the project-wide pass (`addressedAnywhere`) and none was provided: every table is its own data function in this count.");
|
|
4212
|
+
const grouping = groupStores(countable, {
|
|
4213
|
+
grouping: input.addressedAnywhere === void 0 ? "none" : strategy,
|
|
4214
|
+
addressedAnywhere: input.addressedAnywhere ?? /* @__PURE__ */ new Set()
|
|
4215
|
+
});
|
|
4216
|
+
warnings.push(...grouping.warnings);
|
|
2434
4217
|
const dataFunctions = countDataFunctions(countable, usage, {
|
|
4218
|
+
grouping,
|
|
2435
4219
|
writtenAnywhere: input.writtenAnywhere ?? /* @__PURE__ */ new Set(),
|
|
2436
|
-
retStrategy: options.retStrategy ?? "constant",
|
|
2437
4220
|
externallyMaintained: new Set(options.boundary?.externallyMaintained ?? []),
|
|
2438
4221
|
tables,
|
|
2439
4222
|
weights
|
|
2440
4223
|
});
|
|
2441
|
-
|
|
4224
|
+
/**
|
|
4225
|
+
* 4. transactional functions, over the stores that actually count — a child
|
|
4226
|
+
* folded into a counted root counts too: a transaction reaching it reaches the
|
|
4227
|
+
* group, and its columns are the group's output.
|
|
4228
|
+
*/
|
|
4229
|
+
const countedRoots = new Set(dataFunctions.map((fn) => fn.name));
|
|
4230
|
+
const countedStores = new Map(countable.filter((store) => countedRoots.has(grouping.rootOf.get(store.name) ?? store.name)).map((store) => [store.name, store]));
|
|
2442
4231
|
const ignored = new Set(options.boundary?.ignoreEntryPoints ?? []);
|
|
2443
|
-
const
|
|
4232
|
+
const entryPoints = input.entryPoints.filter((entry) => !ignored.has(entry.identity) && !ignored.has(entry.name ?? ""));
|
|
4233
|
+
const transactionalFunctions = countTransactionalFunctions(entryPoints, input.behaviors, {
|
|
2444
4234
|
countedStores,
|
|
4235
|
+
grouping,
|
|
2445
4236
|
root: input.app.root,
|
|
2446
4237
|
messageDet: options.messageDet ?? 0,
|
|
2447
4238
|
tables,
|
|
2448
4239
|
weights
|
|
2449
4240
|
});
|
|
2450
4241
|
/**
|
|
2451
|
-
*
|
|
4242
|
+
* What the analysis could not read, answered by ORIGIN — counting-decisions §8.
|
|
2452
4243
|
*
|
|
2453
|
-
*
|
|
2454
|
-
*
|
|
2455
|
-
*
|
|
2456
|
-
*
|
|
2457
|
-
* reports. It silences nothing else: the count does not move, and how many were
|
|
2458
|
-
* reviewed is still printed.
|
|
4244
|
+
* A declaration is about a column or a validator field, and it applies to every
|
|
4245
|
+
* function that carries the DET: the data function and each transaction that
|
|
4246
|
+
* takes or shows it. Keyed by function it was declared twice and still missed
|
|
4247
|
+
* the third place, so the same column was worth two numbers in one count.
|
|
2459
4248
|
*/
|
|
2460
|
-
const
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
overrides: options.overrides ?? {}
|
|
4249
|
+
const opaque = applyOpaque([...dataFunctions, ...transactionalFunctions], {
|
|
4250
|
+
declarations: options.opaque ?? {},
|
|
4251
|
+
stores: countable,
|
|
4252
|
+
schemas: input.jsonSchemas ?? /* @__PURE__ */ new Map(),
|
|
4253
|
+
tables,
|
|
4254
|
+
weights
|
|
4255
|
+
});
|
|
4256
|
+
warnings.push(...opaque.warnings);
|
|
4257
|
+
const functions = applyOverrides(opaque.functions, options.overrides ?? {}, tables, weights, warnings);
|
|
4258
|
+
warnings.push(...opaqueWarnings({
|
|
4259
|
+
functions,
|
|
4260
|
+
stores: countable,
|
|
4261
|
+
entryPoints: input.entryPoints,
|
|
4262
|
+
behaviors: input.behaviors,
|
|
4263
|
+
answered: opaque.answered
|
|
2476
4264
|
}));
|
|
2477
4265
|
warnings.push(...unreadableInputWarnings(input));
|
|
4266
|
+
warnings.push(...unreadableOutputWarnings(input));
|
|
4267
|
+
warnings.push(...unreadableDeliveryWarnings(input));
|
|
4268
|
+
warnings.push(...commandWarnings(entryPoints, transactionalFunctions));
|
|
4269
|
+
warnings.push(...undispatchedJobWarnings(input));
|
|
4270
|
+
warnings.push(...lookAlikeWarnings(functions));
|
|
4271
|
+
warnings.push(...seededOnlyWarnings(functions, grouping.members, input.seededAnywhere));
|
|
2478
4272
|
return {
|
|
2479
4273
|
ruleset: "afp",
|
|
2480
4274
|
rulesetVersion: RULESET_VERSION,
|
|
@@ -2484,160 +4278,118 @@ function count(input, options = {}) {
|
|
|
2484
4278
|
};
|
|
2485
4279
|
}
|
|
2486
4280
|
/**
|
|
2487
|
-
*
|
|
2488
|
-
*
|
|
2489
|
-
* A JSON column holding a form the user fills counts as 1 DET, because the
|
|
2490
|
-
* schema is runtime data. That is the documented trade, and until now it was
|
|
2491
|
-
* documented ONLY: the count said nothing, which is the one known blind spot
|
|
2492
|
-
* this package reported nowhere. It reports an unresolved call, a technical
|
|
2493
|
-
* table, an unresolved mixin and a handler-less route — and stayed silent here.
|
|
4281
|
+
* Transactions that deliver something the analysis cannot read.
|
|
2494
4282
|
*
|
|
2495
|
-
*
|
|
2496
|
-
*
|
|
2497
|
-
*
|
|
4283
|
+
* A generated document (`response.send(gerarCsv(rows))`), a value built by a
|
|
4284
|
+
* call nobody followed. Each counts 1 DET — a floor — and the transaction's
|
|
4285
|
+
* output is understated by whatever the value carries. Named with the
|
|
4286
|
+
* expression, because the fix is usually in the code: return a literal, or a
|
|
4287
|
+
* transformer, and the fields become readable.
|
|
2498
4288
|
*/
|
|
4289
|
+
function unreadableDeliveryWarnings(input) {
|
|
4290
|
+
const blind = input.entryPoints.map((entry) => ({
|
|
4291
|
+
entry,
|
|
4292
|
+
behavior: input.behaviors.get(entry.id)
|
|
4293
|
+
})).filter(({ behavior }) => behavior && !behavior.writes && behavior.delivered.opaqueFields.length > 0);
|
|
4294
|
+
if (blind.length === 0) return [];
|
|
4295
|
+
return [
|
|
4296
|
+
`${blind.length} transaction(s) deliver a value the analysis cannot read — a generated document, a call nobody followed — counted as 1 DET each, a FLOOR. This UNDERSTATES the output:`,
|
|
4297
|
+
...blind.slice(0, 10).map(({ entry, behavior }) => ` ${entry.trigger} ${entry.signature}: ${behavior.delivered.opaqueFields.join(", ")}`),
|
|
4298
|
+
...blind.length > 10 ? [` … and ${blind.length - 10} more`] : []
|
|
4299
|
+
];
|
|
4300
|
+
}
|
|
2499
4301
|
/**
|
|
2500
|
-
*
|
|
4302
|
+
* Jobs no transaction reaches — plan 0.7 §D.
|
|
2501
4303
|
*
|
|
2502
|
-
*
|
|
2503
|
-
*
|
|
4304
|
+
* A job dispatched by a handler is part of that handler's transaction (§9). One
|
|
4305
|
+
* that nothing reachable dispatches is either a scheduled process — an elementary
|
|
4306
|
+
* process nobody is counting — or dead code, and the code cannot say which. It is
|
|
4307
|
+
* reported, never counted: inventing an elementary process is the error this
|
|
4308
|
+
* package exists to avoid.
|
|
2504
4309
|
*/
|
|
2505
|
-
function
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
*/
|
|
2519
|
-
reviewed.add(entry.split(".").pop() ?? entry);
|
|
2520
|
-
}
|
|
2521
|
-
return reviewed;
|
|
4310
|
+
function undispatchedJobWarnings(input) {
|
|
4311
|
+
if (!input.jobs?.length) return [];
|
|
4312
|
+
const reached = /* @__PURE__ */ new Set();
|
|
4313
|
+
for (const behavior of input.behaviors.values()) for (const step of behavior.trace) reached.add(toPosix(step.file));
|
|
4314
|
+
const orphans = input.jobs.filter((job) => !reached.has(job.file));
|
|
4315
|
+
if (orphans.length === 0) return [];
|
|
4316
|
+
const where = (file) => relativeTo(input.app.root, file);
|
|
4317
|
+
return [`${orphans.length} job(s) reached by no transaction — a scheduled process nobody counts, or dead code. Reported, not counted: a scheduler is an entry point of its own once one is read (counting-decisions §9):`, ...orphans.map(({ name, file, dispatchedFrom, scheduledFrom }) => {
|
|
4318
|
+
const at = ` ${name} (${where(file)}): `;
|
|
4319
|
+
if (scheduledFrom.length > 0) return `${at}scheduled from ${scheduledFrom.map(where).join(", ")}, outside every transaction`;
|
|
4320
|
+
if (dispatchedFrom.length > 0) return `${at}dispatched from ${dispatchedFrom.map(where).join(", ")}, which no transaction reaches`;
|
|
4321
|
+
return `${at}dispatched by nothing in the application — dead code, or a scheduler this analysis does not read`;
|
|
4322
|
+
})];
|
|
2522
4323
|
}
|
|
2523
4324
|
/**
|
|
2524
|
-
*
|
|
4325
|
+
* Ace commands counted as transactions — plan 0.7 §C.
|
|
2525
4326
|
*
|
|
2526
|
-
*
|
|
2527
|
-
*
|
|
2528
|
-
*
|
|
4327
|
+
* A batch process an operator starts is an elementary process, and it is counted.
|
|
4328
|
+
* Half the commands on the validated applications are development tools — a data
|
|
4329
|
+
* generator, a scaffolder — which the CPM does not count and the code cannot tell
|
|
4330
|
+
* from an importer: both write the same table. So each one is listed with the FP
|
|
4331
|
+
* at stake, the collector's hint beside it, and the way out is a declaration.
|
|
2529
4332
|
*/
|
|
2530
|
-
|
|
2531
|
-
const
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
});
|
|
4333
|
+
function commandWarnings(entryPoints, functions) {
|
|
4334
|
+
const counted = entryPoints.filter((entry) => entry.kind === "command").map((entry) => ({
|
|
4335
|
+
entry,
|
|
4336
|
+
fn: functions.find((f) => f.id === `tx:${entry.identity}`)
|
|
4337
|
+
})).filter(({ fn }) => fn !== void 0);
|
|
4338
|
+
if (counted.length === 0) return [];
|
|
4339
|
+
return [`${counted.length} ace command(s) counted as elementary processes — a batch process an operator starts is a transaction. A development tool (a data generator, a scaffolder) is not the user's: exclude it with \`boundary.ignoreEntryPoints: ['<commandName>']\`:`, ...counted.map(({ entry, fn }) => ` ${entry.identity}: ${fn.type} ${fn.points} FP` + (entry.hints?.length ? ` — ${entry.hints.join("; ")}` : ""))];
|
|
2538
4340
|
}
|
|
2539
|
-
/** a column whose shape says nothing about what it holds */
|
|
2540
|
-
const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
|
|
2541
4341
|
/**
|
|
2542
|
-
*
|
|
2543
|
-
*
|
|
2544
|
-
* Both count 1, which is a FLOOR rather than a measurement, and counting-decisions §8
|
|
2545
|
-
* is the trade. Reporting it is the point — this was the one known blind spot the
|
|
2546
|
-
* package reported nowhere.
|
|
2547
|
-
*
|
|
2548
|
-
* Grouped by FUNCTION and stating what has already been answered, because a flat list
|
|
2549
|
-
* of columns could not say that. Computed before the overrides ran, it named functions
|
|
2550
|
-
* whose floor `detFromSchema` had already replaced, which reads as "go and map this"
|
|
2551
|
-
* about something already mapped. That misreading actually happened, to the author of
|
|
2552
|
-
* this code, reading someone else's report.
|
|
4342
|
+
* Transactions that look like the same elementary process.
|
|
2553
4343
|
*
|
|
2554
|
-
*
|
|
4344
|
+
* The CPM counts identical processing logic once. `GET /perfil` and
|
|
4345
|
+
* `GET /perfil/editar` on a real application walk the same queries and the same
|
|
4346
|
+
* transformers, touch the same stores and emit the same DETs, and were 7 FP each.
|
|
4347
|
+
* Whether the second is a screen the user needs or a second URL for the same one
|
|
4348
|
+
* is not derivable from code, so both stay counted and the pair is named with
|
|
4349
|
+
* the FP at stake — a request to decide, answered with `boundary.ignoreEntryPoints`.
|
|
2555
4350
|
*
|
|
2556
|
-
*
|
|
2557
|
-
*
|
|
2558
|
-
*
|
|
4351
|
+
* The key is deliberately narrow: same type, same stores, same DET sources AND
|
|
4352
|
+
* the same bodies below the entry point. Two fat controllers that merely read the
|
|
4353
|
+
* same table are not flagged — with nothing followed, nothing says the logic is
|
|
4354
|
+
* the same.
|
|
2559
4355
|
*/
|
|
2560
|
-
function
|
|
2561
|
-
const
|
|
2562
|
-
for (const
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
return found;
|
|
2574
|
-
};
|
|
2575
|
-
for (const store of stores) {
|
|
2576
|
-
if (!reached.get(store.name)) continue;
|
|
2577
|
-
for (const attribute of store.attributes) {
|
|
2578
|
-
if (!attribute.type || !OPAQUE_TYPE.test(attribute.type)) continue;
|
|
2579
|
-
const entry = tally(store.name, "column", reached.get(store.name) ?? 0);
|
|
2580
|
-
if (state.reviewed.has(`${store.name}.${attribute.name}`) || state.reviewed.has(attribute.name)) {
|
|
2581
|
-
entry.reviewed += 1;
|
|
2582
|
-
entry.reviewedNames.push(attribute.name);
|
|
2583
|
-
} else entry.floor.push(`${attribute.name} (${attribute.type})`);
|
|
2584
|
-
}
|
|
2585
|
-
}
|
|
2586
|
-
for (const point of input.entryPoints) for (const field of input.behaviors.get(point.id)?.opaqueInputFields ?? []) {
|
|
2587
|
-
const entry = tally(point.identity, "input object", 1);
|
|
2588
|
-
if (state.reviewed.has(field) || state.reviewed.has(`${point.identity}.${field}`)) {
|
|
2589
|
-
entry.reviewed += 1;
|
|
2590
|
-
entry.reviewedNames.push(field);
|
|
2591
|
-
} else entry.floor.push(field);
|
|
2592
|
-
}
|
|
2593
|
-
/**
|
|
2594
|
-
* A review that matches nothing is a review that does nothing.
|
|
2595
|
-
*
|
|
2596
|
-
* `detFromSchema` already warns when it names a schema that is not declared, and
|
|
2597
|
-
* `opaqueReviewed` did not — so `['messages.schema']` against a field actually named
|
|
2598
|
-
* `createMessageValidator.messages.schema` reviewed nothing in silence while the
|
|
2599
|
-
* warning kept firing, which reads as the tool ignoring the configuration.
|
|
2600
|
-
*/
|
|
2601
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2602
|
-
for (const [name, entry] of byFunction) for (const field of [...entry.floor, ...entry.reviewedNames]) {
|
|
2603
|
-
const bare = field.replace(/ \(.*\)$/, "");
|
|
2604
|
-
seen.add(bare);
|
|
2605
|
-
seen.add(`${name}.${bare}`);
|
|
2606
|
-
seen.add(bare.split(".").pop() ?? bare);
|
|
2607
|
-
}
|
|
2608
|
-
const unmatched = [];
|
|
2609
|
-
for (const [name, override] of Object.entries(state.overrides)) for (const declaredName of override.opaqueReviewed ?? []) if (![
|
|
2610
|
-
declaredName,
|
|
2611
|
-
`${name}.${declaredName}`,
|
|
2612
|
-
declaredName.split(".").pop() ?? ""
|
|
2613
|
-
].some((spelling) => seen.has(spelling))) unmatched.push(`${name}.opaqueReviewed: ${declaredName}`);
|
|
2614
|
-
const lines = [];
|
|
2615
|
-
let answered = 0;
|
|
2616
|
-
for (const [name, entry] of byFunction) {
|
|
2617
|
-
/** a declared schema stands in for exactly one placeholder — §8, and the override warns when there are more */
|
|
2618
|
-
const replaced = state.declared.has(name) && entry.floor.length > 0 ? 1 : 0;
|
|
2619
|
-
const remaining = entry.floor.slice(replaced);
|
|
2620
|
-
if (remaining.length === 0) {
|
|
2621
|
-
answered += 1;
|
|
2622
|
-
continue;
|
|
2623
|
-
}
|
|
2624
|
-
const answeredHere = [...replaced > 0 ? [`${replaced} replaced by override`] : [], ...entry.reviewed > 0 ? [`${entry.reviewed} reviewed`] : []];
|
|
2625
|
-
/**
|
|
2626
|
-
* How many transactions reach the store, so the reader can judge whether the
|
|
2627
|
-
* floor is worth answering. A blob nothing touches changes no number.
|
|
2628
|
-
*/
|
|
2629
|
-
const reach = entry.kind === "column" ? `, reached by ${entry.transactions} transaction(s)` : "";
|
|
2630
|
-
lines.push(` ${name} — ${remaining.length} ${entry.kind}(s) at 1 DET${reach}` + (answeredHere.length > 0 ? ` (${answeredHere.join(", ")} already)` : "") + `: ${remaining.map((f) => entry.kind === "column" ? `${name}.${f}` : f).join(", ")}`);
|
|
4356
|
+
function lookAlikeWarnings(functions) {
|
|
4357
|
+
const groups = /* @__PURE__ */ new Map();
|
|
4358
|
+
for (const fn of functions) {
|
|
4359
|
+
if (!fn.id.startsWith("tx:")) continue;
|
|
4360
|
+
const below = (fn.rationale.trace ?? []).filter((step) => step.depth > 0).map((step) => `${step.file}#${step.member ?? "*"}`).sort();
|
|
4361
|
+
if (below.length === 0) continue;
|
|
4362
|
+
const key = [
|
|
4363
|
+
fn.type,
|
|
4364
|
+
[...fn.rationale.refSources].sort().join(","),
|
|
4365
|
+
[...fn.rationale.detSources].sort().join(","),
|
|
4366
|
+
below.join(",")
|
|
4367
|
+
].join("|");
|
|
4368
|
+
groups.set(key, [...groups.get(key) ?? [], fn]);
|
|
2631
4369
|
}
|
|
2632
|
-
const
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
4370
|
+
const alike = [...groups.values()].filter((group) => group.length > 1);
|
|
4371
|
+
if (alike.length === 0) return [];
|
|
4372
|
+
return [`${alike.length} group(s) of transactions share the same stores, the same DETs and the same bodies below the controller — the CPM counts identical processing logic once. Whether the second is a screen of its own is not derivable from the code: decide, and record it with \`boundary.ignoreEntryPoints\`:`, ...alike.map((group) => {
|
|
4373
|
+
const names = group.map((fn) => fn.name).sort();
|
|
4374
|
+
const atStake = group.slice(1).reduce((total, fn) => total + fn.points, 0);
|
|
4375
|
+
return ` ${names.join(" ≡ ")} (${atStake} FP at stake)`;
|
|
4376
|
+
})];
|
|
4377
|
+
}
|
|
4378
|
+
/**
|
|
4379
|
+
* EIFs that only a seeder writes.
|
|
4380
|
+
*
|
|
4381
|
+
* After 0.5.0 a seeder's inserts are not maintenance, so a table only the seed
|
|
4382
|
+
* populates is "used but not maintained" — an EIF. That is right for a table
|
|
4383
|
+
* that mirrors data another system maintains in production, and wrong for a
|
|
4384
|
+
* `roles` table: reference data the team maintains is code data under the CPM,
|
|
4385
|
+
* and is not counted at all. The code cannot tell the two apart, and should not
|
|
4386
|
+
* try; it names them and says what each answer costs.
|
|
4387
|
+
*/
|
|
4388
|
+
function seededOnlyWarnings(functions, members, seeded) {
|
|
4389
|
+
if (!seeded || seeded.size === 0) return [];
|
|
4390
|
+
const named = functions.filter((fn) => fn.type === "EIF" && fn.rationale.rule.includes("used but not maintained") && (members.get(fn.name) ?? [fn.name]).some((member) => seeded.has(member)));
|
|
4391
|
+
if (named.length === 0) return [];
|
|
4392
|
+
return [`${named.length} EIF(s) are written by a seeder and by nothing else in the application. Reference data the team maintains is code data (CPM) and is not counted — exclude it with \`boundary.infrastructure\`; data another system maintains in production is a legitimate EIF — keep it. The code cannot tell which:`, ...named.map((fn) => ` ${fn.name} (${fn.points} FP)`)];
|
|
2641
4393
|
}
|
|
2642
4394
|
/**
|
|
2643
4395
|
* Transactions that read the request in a way that enumerates nothing.
|
|
@@ -2676,100 +4428,63 @@ function unreadableInputWarnings(input) {
|
|
|
2676
4428
|
];
|
|
2677
4429
|
}
|
|
2678
4430
|
/**
|
|
2679
|
-
*
|
|
4431
|
+
* Transformers that emit something the analysis cannot read.
|
|
2680
4432
|
*
|
|
2681
|
-
*
|
|
2682
|
-
*
|
|
2683
|
-
*
|
|
2684
|
-
*
|
|
4433
|
+
* `...this.resource.serialize()`, `...this.extras`: whatever the model has, or
|
|
4434
|
+
* whatever was handed in. Each counts 1 DET — a floor, the same as an open input
|
|
4435
|
+
* object — and the transaction's output is understated by however many fields
|
|
4436
|
+
* the spread carries. Reported with the expression, because the fix is in the
|
|
4437
|
+
* transformer: name the fields, or `pick` them.
|
|
4438
|
+
*/
|
|
4439
|
+
function unreadableOutputWarnings(input) {
|
|
4440
|
+
const blind = input.entryPoints.map((entry) => ({
|
|
4441
|
+
entry,
|
|
4442
|
+
behavior: input.behaviors.get(entry.id)
|
|
4443
|
+
})).filter(({ behavior }) => (behavior?.opaqueOutputFields.length ?? 0) > 0);
|
|
4444
|
+
if (blind.length === 0) return [];
|
|
4445
|
+
return [
|
|
4446
|
+
`${blind.length} transaction(s) pass through a transformer that spreads something the analysis cannot read, counted as 1 DET each — a FLOOR. This UNDERSTATES the output; the fix is in the transformer (\`this.pick(...)\` or named keys), not a configuration:`,
|
|
4447
|
+
...blind.slice(0, 10).map(({ entry, behavior }) => ` ${entry.trigger} ${entry.signature}: ${behavior.opaqueOutputFields.join(", ")}`),
|
|
4448
|
+
...blind.length > 10 ? [` … and ${blind.length - 10} more`] : []
|
|
4449
|
+
];
|
|
4450
|
+
}
|
|
4451
|
+
/**
|
|
4452
|
+
* Replaces what the analysis found with a NUMBER a person declared — DET or RET
|
|
4453
|
+
* of one function.
|
|
2685
4454
|
*
|
|
2686
|
-
*
|
|
2687
|
-
*
|
|
4455
|
+
* Only for facts static analysis cannot reach, and only when naming the origin
|
|
4456
|
+
* (`opaque`) is not possible: a declared number is reproducible because it comes
|
|
4457
|
+
* from a versioned file, and auditable because it travels with its justification
|
|
4458
|
+
* into the rationale, which `fp:explain` prints — but it freezes the moment the
|
|
4459
|
+
* form grows. An override naming no function is a warning, never silence.
|
|
2688
4460
|
*/
|
|
2689
|
-
function applyOverrides(functions, overrides,
|
|
4461
|
+
function applyOverrides(functions, overrides, tables, weights, warnings) {
|
|
2690
4462
|
const keys = Object.keys(overrides);
|
|
2691
4463
|
if (keys.length === 0) return functions;
|
|
4464
|
+
/**
|
|
4465
|
+
* A configuration the code does not honour is worse than none. The two keys
|
|
4466
|
+
* that used to live here moved to `opaque`, by origin, in 0.6.0 and left the
|
|
4467
|
+
* type — but a configuration file is loaded without types, so an old one still
|
|
4468
|
+
* arrives here believing something happened.
|
|
4469
|
+
*/
|
|
4470
|
+
for (const [name, override] of Object.entries(overrides)) {
|
|
4471
|
+
const legacy = override;
|
|
4472
|
+
const moved = [...legacy.detFromSchema ? ["detFromSchema"] : [], ...legacy.opaqueReviewed ? ["opaqueReviewed"] : []];
|
|
4473
|
+
if (moved.length === 0) continue;
|
|
4474
|
+
warnings.push(`override "${name}" uses \`${moved.join("` and `")}\`, which moved to \`opaque.<Store.column | validator.field>\` in 0.6.0 and is no longer read here: it had no effect. Declare the origin, and it applies to every function carrying it.`);
|
|
4475
|
+
}
|
|
2692
4476
|
const used = /* @__PURE__ */ new Set();
|
|
2693
4477
|
const applied = functions.map((fn) => {
|
|
2694
4478
|
const override = overrides[fn.name];
|
|
2695
4479
|
if (!override) return fn;
|
|
2696
4480
|
used.add(fn.name);
|
|
2697
4481
|
const fields = [];
|
|
2698
|
-
if (override.det !== void 0
|
|
4482
|
+
if (override.det !== void 0) fields.push("det");
|
|
2699
4483
|
if (override.refs !== void 0) fields.push("refs");
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
/**
|
|
2703
|
-
* Read from the schema rather than declared as a number.
|
|
2704
|
-
*
|
|
2705
|
-
* A frozen number goes stale the moment someone adds a field: the count
|
|
2706
|
-
* would not move and `fp:diff` would report no change for real functional
|
|
2707
|
-
* growth. Naming the schema keeps the number coming from the code, and the
|
|
2708
|
-
* only thing maintained by hand is the mapping — which changes when a form
|
|
2709
|
-
* is born, not when a field is.
|
|
2710
|
-
*/
|
|
2711
|
-
if (override.detFromSchema) {
|
|
2712
|
-
/**
|
|
2713
|
-
* One name or several, unioned by leaf path.
|
|
2714
|
-
*
|
|
2715
|
-
* An ILF's DETs are the fields the user recognises in the file, and an
|
|
2716
|
-
* application with one schema per template recognises all of them. A field
|
|
2717
|
-
* two templates share is one DET, so the union is over paths rather than a
|
|
2718
|
-
* sum of counts.
|
|
2719
|
-
*/
|
|
2720
|
-
const named = [override.detFromSchema].flat();
|
|
2721
|
-
const resolved = named.map((name) => schemas.get(name)).filter((s) => s !== void 0);
|
|
2722
|
-
const missing = named.filter((name) => !schemas.has(name));
|
|
2723
|
-
const union = new Set(resolved.flatMap((s) => s.leaves));
|
|
2724
|
-
const schema = resolved.length === 0 ? void 0 : {
|
|
2725
|
-
/** only what resolved: naming a schema that contributed nothing would mislead */
|
|
2726
|
-
name: resolved.map((s) => s.name).join(" + "),
|
|
2727
|
-
fields: union.size,
|
|
2728
|
-
leaves: [...union]
|
|
2729
|
-
};
|
|
2730
|
-
for (const name of missing) warnings.push(`override for "${fn.name}" names schema "${name}", which is not declared anywhere in the code: it contributed nothing. A renamed or moved schema breaks the mapping, and this says so rather than counting on silently.`);
|
|
2731
|
-
if (schema) {
|
|
2732
|
-
/**
|
|
2733
|
-
* Replaces the opaque placeholder — the one the rationale marks — rather
|
|
2734
|
-
* than assuming there is one and that it is worth 1.
|
|
2735
|
-
*
|
|
2736
|
-
* That assumption was wrong twice over. An open `vine.object` counted
|
|
2737
|
-
* ZERO, not 1, so subtracting 1 removed a field the analysis had read
|
|
2738
|
-
* correctly: 86 DETs where 87 was right. And a function with no opaque
|
|
2739
|
-
* DET at all was silently charged the subtraction too.
|
|
2740
|
-
*/
|
|
2741
|
-
const placeholders = fn.rationale.detSources.filter((source) => source.endsWith("(opaque)"));
|
|
2742
|
-
det = Math.max(fn.det - Math.min(placeholders.length, 1), 0) + schema.fields;
|
|
2743
|
-
by = `config:overrides.${fn.name} (from ${schema.name}: ${schema.fields} fields)`;
|
|
2744
|
-
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.`);
|
|
2745
|
-
else {
|
|
2746
|
-
/**
|
|
2747
|
-
* Only the placeholders nobody has answered are worth reporting.
|
|
2748
|
-
*
|
|
2749
|
-
* The message used to count every opaque DET of the function and say "names
|
|
2750
|
-
* one schema" whatever it was given. With four of five columns in
|
|
2751
|
-
* `opaqueReviewed` and a LIST of two schemas, it still fired, still said
|
|
2752
|
-
* "one schema", and still counted the four already answered — a warning
|
|
2753
|
-
* wrong on all three counts, about a configuration that was complete.
|
|
2754
|
-
*/
|
|
2755
|
-
const unanswered = placeholders.filter((source) => !reviewed.has(opaqueNameOf(source)) && !reviewed.has(shortOpaqueNameOf(source)));
|
|
2756
|
-
if (unanswered.length > 1) warnings.push(`override for "${fn.name}" names ${named.length === 1 ? "one schema" : `${named.length} schemas`} and the function has ${unanswered.length} unanswered opaque DETs (${unanswered.join(", ")}). One was replaced; the others still count 1 each — declare them or record them with \`opaqueReviewed\`.`);
|
|
2757
|
-
}
|
|
2758
|
-
}
|
|
2759
|
-
}
|
|
4484
|
+
if (fields.length === 0) return fn;
|
|
4485
|
+
const det = override.det ?? fn.det;
|
|
2760
4486
|
const refs = override.refs ?? fn.refs;
|
|
2761
4487
|
const complexity = complexityOf(fn.type, refs, det, tables);
|
|
2762
|
-
/**
|
|
2763
|
-
* A review is recorded with NO fields, and the reporter's "declared by override"
|
|
2764
|
-
* share counts only entries that declared one.
|
|
2765
|
-
*
|
|
2766
|
-
* Dropping it entirely lost the `reason`, so an `opaqueReviewed`-only decision
|
|
2767
|
-
* appeared nowhere — not in `fp:explain`, not anywhere — which defeats the point
|
|
2768
|
-
* of requiring a reason. Counting it in the share was the opposite error: it read
|
|
2769
|
-
* as "1 function, 7 FP, 35% of the total declared by override" when no number had
|
|
2770
|
-
* been declared at all.
|
|
2771
|
-
*/
|
|
2772
|
-
const marked = markReviewed(fn.rationale.detSources, reviewed);
|
|
2773
4488
|
return {
|
|
2774
4489
|
...fn,
|
|
2775
4490
|
det,
|
|
@@ -2778,9 +4493,8 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
|
|
|
2778
4493
|
points: pointsOf(fn.type, complexity, weights),
|
|
2779
4494
|
rationale: {
|
|
2780
4495
|
...fn.rationale,
|
|
2781
|
-
detSources: marked,
|
|
2782
4496
|
overrides: [...fn.rationale.overrides ?? [], {
|
|
2783
|
-
by
|
|
4497
|
+
by: `config:overrides.${fn.name}`,
|
|
2784
4498
|
reason: override.reason,
|
|
2785
4499
|
fields
|
|
2786
4500
|
}]
|
|
@@ -2946,7 +4660,9 @@ var CoverageTooLowError = class extends Error {
|
|
|
2946
4660
|
async function analyze(root, options = {}) {
|
|
2947
4661
|
const app = await discoverApp(root);
|
|
2948
4662
|
const { stores, unresolved: storeProblems } = await collectDataStores(app);
|
|
2949
|
-
const
|
|
4663
|
+
const routes = await collectEntryPoints(app);
|
|
4664
|
+
const routeProblems = routes.unresolved;
|
|
4665
|
+
const entryPoints = [...routes.entryPoints, ...collectCommands(app)];
|
|
2950
4666
|
/** only read when an override names one — but collected once, like everything else */
|
|
2951
4667
|
const jsonSchemas = collectJsonSchemas(app);
|
|
2952
4668
|
const analyzer = createAnalyzer(app, stores, {
|
|
@@ -3028,8 +4744,31 @@ async function analyze(root, options = {}) {
|
|
|
3028
4744
|
by: "request"
|
|
3029
4745
|
}
|
|
3030
4746
|
})),
|
|
4747
|
+
...behavior.commandFields.length ? { commandFields: behavior.commandFields.map((name) => ({
|
|
4748
|
+
name,
|
|
4749
|
+
provenance: {
|
|
4750
|
+
file: emit(app.root),
|
|
4751
|
+
by: "ace-commands"
|
|
4752
|
+
}
|
|
4753
|
+
})) } : {},
|
|
3031
4754
|
opaqueRequest: behavior.opaqueRequest,
|
|
3032
|
-
outputFields:
|
|
4755
|
+
outputFields: behavior.outputFields.map((name) => ({
|
|
4756
|
+
name,
|
|
4757
|
+
provenance: {
|
|
4758
|
+
file: emit(app.root),
|
|
4759
|
+
by: "transformer"
|
|
4760
|
+
}
|
|
4761
|
+
})),
|
|
4762
|
+
opaqueOutputFields: behavior.opaqueOutputFields.map((name) => ({
|
|
4763
|
+
name,
|
|
4764
|
+
provenance: {
|
|
4765
|
+
file: emit(app.root),
|
|
4766
|
+
by: "transformer"
|
|
4767
|
+
}
|
|
4768
|
+
})),
|
|
4769
|
+
transformedStores: behavior.transformedStores,
|
|
4770
|
+
delivered: behavior.delivered,
|
|
4771
|
+
outputReads: behavior.outputReads,
|
|
3033
4772
|
trace: behavior.trace.map((step) => ({
|
|
3034
4773
|
...step,
|
|
3035
4774
|
file: emit(step.file)
|
|
@@ -3057,11 +4796,14 @@ async function analyze(root, options = {}) {
|
|
|
3057
4796
|
entryPoints,
|
|
3058
4797
|
behaviors,
|
|
3059
4798
|
jsonSchemas,
|
|
3060
|
-
|
|
4799
|
+
jobs: collectJobs(app),
|
|
4800
|
+
writtenAnywhere: analyzer.writtenAnywhere(),
|
|
4801
|
+
addressedAnywhere: analyzer.addressedAnywhere(),
|
|
4802
|
+
seededAnywhere: analyzer.seededAnywhere()
|
|
3061
4803
|
}, options),
|
|
3062
4804
|
source
|
|
3063
4805
|
}
|
|
3064
4806
|
};
|
|
3065
4807
|
}
|
|
3066
4808
|
//#endregion
|
|
3067
|
-
export { RULESET_VERSION as i, analyze as n, RULESET as r, CoverageTooLowError as t };
|
|
4809
|
+
export { DEFAULT_TECHNICAL_PATTERNS as a, RULESET_VERSION as i, analyze as n, RULESET as r, CoverageTooLowError as t };
|