@filipebraida/adonis-function-points 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/README.md +103 -2
- package/build/commands/fp_metrics.d.ts +10 -0
- package/build/commands/main.d.ts +6 -5
- package/build/commands/main.js +48 -114
- package/build/decorate-D6enDn9D.js +24 -0
- package/build/fp_calibrate-Cm079xWL.js +25 -0
- package/build/fp_count-CfcXPuj5.js +22 -0
- package/build/fp_diff-DE_t3twv.js +25 -0
- package/build/fp_explain-MKyEoi0h.js +24 -0
- package/build/fp_inventory-DSrCVhEy.js +18 -0
- package/build/fp_metrics-BUWj9dLw.js +20 -0
- package/build/index.d.ts +2 -1
- package/build/index.js +3 -2
- package/build/{pipeline-BzP-ITGN.js → pipeline-DySlMWcN.js} +298 -40
- package/build/{resolvers-CU9HKYpn.js → resolvers-MFjRl2ef.js} +225 -5
- package/build/{runners-Bt8tbISi.js → runners-DpMd-yZM.js} +252 -64
- package/build/src/albrecht/counter.d.ts +17 -1
- package/build/src/albrecht/data_functions.d.ts +6 -0
- package/build/src/albrecht/diff.d.ts +19 -1
- package/build/src/cli/runners.d.ts +12 -0
- package/build/src/cli.js +11 -2
- package/build/src/define_config.d.ts +35 -1
- package/build/src/inventory/graph/call_graph.d.ts +27 -0
- package/build/src/inventory/graph/noise.d.ts +13 -0
- package/build/src/inventory/resolvers/event_dispatch.d.ts +17 -0
- package/build/src/inventory/resolvers/index.js +1 -1
- package/build/src/inventory/resolvers/types.d.ts +35 -0
- package/build/src/inventory/sources/event_bindings.d.ts +44 -0
- package/build/src/metrics/structure.d.ts +16 -2
- package/build/src/pipeline.js +1 -1
- package/build/src/reporters/table.d.ts +11 -1
- package/build/src/types.d.ts +10 -0
- package/build/stubs/config.stub +27 -1
- package/package.json +2 -1
- package/build/scripts/smoke_package.d.ts +0 -1
- package/build/tmp/probe.d.ts +0 -1
- package/build/tmp/probe_cli.d.ts +0 -1
- package/build/tmp/probe_cmp.d.ts +0 -1
- package/build/tmp/probe_count.d.ts +0 -1
- package/build/tmp/probe_data.d.ts +0 -1
- package/build/tmp/probe_diff.d.ts +0 -1
- package/build/tmp/probe_gap.d.ts +0 -1
- package/build/tmp/probe_graph.d.ts +0 -1
- package/build/tmp/probe_metrics.d.ts +0 -1
- package/build/tmp/probe_miss.d.ts +0 -1
- package/build/tmp/probe_names.d.ts +0 -1
- package/build/tmp/probe_nodata.d.ts +0 -1
- package/build/tmp/probe_one.d.ts +0 -1
- package/build/tmp/probe_perf.d.ts +0 -1
- package/build/tmp/probe_routes.d.ts +0 -1
- package/build/tmp/probe_unres.d.ts +0 -1
- package/build/tmp/probe_vazquez.d.ts +0 -1
- package/build/tsdown.config.d.ts +0 -2
|
@@ -1,10 +1,55 @@
|
|
|
1
1
|
import { n as defineConfig, t as DEFAULTS } from "./define_config-DOqWyPwV.js";
|
|
2
|
-
import {
|
|
2
|
+
import { c as toPosix } from "./resolvers-MFjRl2ef.js";
|
|
3
|
+
import { n as analyze } from "./pipeline-DySlMWcN.js";
|
|
3
4
|
import { readFile, writeFile } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { existsSync } from "node:fs";
|
|
6
7
|
import { pathToFileURL } from "node:url";
|
|
7
8
|
import { createJiti } from "jiti";
|
|
9
|
+
//#region src/cli/load_config.ts
|
|
10
|
+
/**
|
|
11
|
+
* Loads `config/function_points.ts` from the application being analysed.
|
|
12
|
+
*
|
|
13
|
+
* Both front-ends need this and neither could do it alone: the ace commands run
|
|
14
|
+
* with `startApp: false`, so there is no booted container to read config from;
|
|
15
|
+
* and the standalone CLI has no container at all. The file is therefore
|
|
16
|
+
* imported directly, through jiti, which transforms the whole module graph —
|
|
17
|
+
* a config may import a resolver from the application, and that resolver may
|
|
18
|
+
* import files using decorators, which Node's type stripping cannot handle.
|
|
19
|
+
*
|
|
20
|
+
* **A config that exists and fails to load is an error, never a fallback.**
|
|
21
|
+
* Falling back to defaults with a warning would silently change the count, and
|
|
22
|
+
* the count becomes an invoice. Absence of a config file is a different thing,
|
|
23
|
+
* and is legitimate: it means the defaults.
|
|
24
|
+
*/
|
|
25
|
+
const CONFIG_PATHS = ["config/function_points.ts", "config/function_points.js"];
|
|
26
|
+
var ConfigLoadError = class extends Error {
|
|
27
|
+
constructor(file, cause) {
|
|
28
|
+
super(`failed to load ${file}: ${cause instanceof Error ? cause.message : String(cause)}\nThe count was NOT produced. Fix the configuration, or remove the file to use the defaults — falling back silently would change the number without telling you.`);
|
|
29
|
+
this.file = file;
|
|
30
|
+
this.cause = cause;
|
|
31
|
+
this.name = "ConfigLoadError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
async function loadConfig(root) {
|
|
35
|
+
const found = CONFIG_PATHS.map((candidate) => path.join(root, candidate)).find((candidate) => existsSync(candidate));
|
|
36
|
+
if (!found) return {
|
|
37
|
+
config: { ...DEFAULTS },
|
|
38
|
+
file: null
|
|
39
|
+
};
|
|
40
|
+
let loaded;
|
|
41
|
+
try {
|
|
42
|
+
loaded = await createJiti(pathToFileURL(path.join(root, "noop.js")).href, { interopDefault: true }).import(found, { default: true });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new ConfigLoadError(found, error);
|
|
45
|
+
}
|
|
46
|
+
if (!loaded || typeof loaded !== "object") throw new ConfigLoadError(found, /* @__PURE__ */ new Error("the default export is not a configuration object"));
|
|
47
|
+
return {
|
|
48
|
+
config: defineConfig(loaded),
|
|
49
|
+
file: toPosix(found)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
8
53
|
//#region src/cli/print.ts
|
|
9
54
|
function printResult(result, printer) {
|
|
10
55
|
for (const note of result.notes ?? []) printer.note(note);
|
|
@@ -57,8 +102,8 @@ function calibrate(result, samples) {
|
|
|
57
102
|
samples: bucket.deviations.length,
|
|
58
103
|
manualPoints: bucket.manual,
|
|
59
104
|
automaticPoints: bucket.automatic,
|
|
60
|
-
factor: bucket.automatic === 0 ? 1 : round(bucket.manual / bucket.automatic),
|
|
61
|
-
meanAbsoluteDeviation: round(bucket.deviations.reduce((total, value) => total + value, 0) / bucket.deviations.length),
|
|
105
|
+
factor: bucket.automatic === 0 ? 1 : round$1(bucket.manual / bucket.automatic),
|
|
106
|
+
meanAbsoluteDeviation: round$1(bucket.deviations.reduce((total, value) => total + value, 0) / bucket.deviations.length),
|
|
62
107
|
exactMatches: bucket.exact
|
|
63
108
|
})).sort((a, b) => a.type.localeCompare(b.type));
|
|
64
109
|
const warnings = [];
|
|
@@ -72,14 +117,14 @@ function calibrate(result, samples) {
|
|
|
72
117
|
samples: matched,
|
|
73
118
|
manualPoints: manualTotal,
|
|
74
119
|
automaticPoints: automaticTotal,
|
|
75
|
-
deviation: manualTotal === 0 ? 0 : round((automaticTotal - manualTotal) / manualTotal),
|
|
120
|
+
deviation: manualTotal === 0 ? 0 : round$1((automaticTotal - manualTotal) / manualTotal),
|
|
76
121
|
exactMatches: exactTotal
|
|
77
122
|
},
|
|
78
123
|
unmatched,
|
|
79
124
|
warnings
|
|
80
125
|
};
|
|
81
126
|
}
|
|
82
|
-
const round = (value) => Math.round(value * 1e3) / 1e3;
|
|
127
|
+
const round$1 = (value) => Math.round(value * 1e3) / 1e3;
|
|
83
128
|
/**
|
|
84
129
|
* Reads samples from CSV: `function,fp` with a header row.
|
|
85
130
|
*
|
|
@@ -167,6 +212,9 @@ function diffCounts(from, to, options = {}) {
|
|
|
167
212
|
...AEP_FACTORS,
|
|
168
213
|
...options.factors
|
|
169
214
|
};
|
|
215
|
+
const reasonFactors = options.reasonFactors ?? {};
|
|
216
|
+
/** the factor a single entry is billed at, which is the per-reason one when set */
|
|
217
|
+
const factorFor = (entry) => entry.change === "changed" && entry.reason ? reasonFactors[entry.reason] ?? factors.changed : factors[entry.change];
|
|
170
218
|
const before = new Map(from.functions.map((fn) => [fn.id, fn]));
|
|
171
219
|
const after = new Map(to.functions.map((fn) => [fn.id, fn]));
|
|
172
220
|
const entries = [];
|
|
@@ -204,15 +252,36 @@ function diffCounts(from, to, options = {}) {
|
|
|
204
252
|
if (count.source.dirty) warnings.push(`the "${side}" count was taken over a tree with uncommitted changes (${count.source.app}${count.source.revision ? ` at ${count.source.revision.slice(0, 8)}` : ""}): no revision reproduces it.`);
|
|
205
253
|
}
|
|
206
254
|
if (from.source?.revision && from.source.revision === to.source?.revision && !from.source.dirty && !to.source.dirty) warnings.push(`both counts are of the same revision (${from.source.revision.slice(0, 8)}): any difference here comes from the tool or its configuration, not from work done.`);
|
|
207
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Quantified, because the generic sentence was not actionable.
|
|
257
|
+
*
|
|
258
|
+
* On a real pair of releases this warning sat under 118 lines of per-function
|
|
259
|
+
* output, saying only that the factor was pinned. What a client disputes is
|
|
260
|
+
* the amount, so the amount is what it has to say.
|
|
261
|
+
*/
|
|
262
|
+
const changedPoints = entries.filter((entry) => entry.change === "changed").reduce((total, entry) => total + entry.function.points, 0);
|
|
263
|
+
if (changedPoints > 0 && factors.changed === 1 && reasonFactors.implementation === void 0) {
|
|
264
|
+
const byReason = changedByReasonOf(entries);
|
|
265
|
+
const billable = round2(entries.reduce((total, entry) => total + entry.function.points * factorFor(entry), 0));
|
|
266
|
+
const share = billable === 0 ? 0 : Math.round(changedPoints / billable * 100);
|
|
267
|
+
warnings.push(`${changedPoints} of ${billable} billable FP (${share}%) are modified functions at a factor pinned to 1. AEP grades it from 0.25 to 1.75 through Effort Complexity variation, which needs cyclomatic complexity — not measured yet. Of those, ${byReason.implementation.points} FP changed implementation only (same type, DET and FTR): set \`reasonFactors\` to price that differently.`);
|
|
268
|
+
}
|
|
208
269
|
return {
|
|
209
270
|
from: options.labels?.from ?? "previous",
|
|
210
271
|
to: options.labels?.to ?? "current",
|
|
211
272
|
entries: entries.sort(byChangeThenName),
|
|
212
273
|
totals: totalsOf(entries),
|
|
213
274
|
changedByReason: changedByReasonOf(entries),
|
|
214
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Rounded to cents at the source, not at the print.
|
|
277
|
+
*
|
|
278
|
+
* `485.00000000000006` appeared on the first real diff. It is arithmetically
|
|
279
|
+
* the same number and it is not the same document: this value is quoted in
|
|
280
|
+
* an invoice, and a reader who sees that tail stops trusting the rest.
|
|
281
|
+
*/
|
|
282
|
+
billable: round2(entries.reduce((total, entry) => total + entry.function.points * factorFor(entry), 0)),
|
|
215
283
|
factors,
|
|
284
|
+
reasonFactors,
|
|
216
285
|
warnings
|
|
217
286
|
};
|
|
218
287
|
}
|
|
@@ -301,6 +370,80 @@ function totalsOf(entries) {
|
|
|
301
370
|
}
|
|
302
371
|
return totals;
|
|
303
372
|
}
|
|
373
|
+
/** two decimals: this number is quoted in an invoice */
|
|
374
|
+
const round2 = (value) => Math.round(value * 100) / 100;
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/metrics/structure.ts
|
|
377
|
+
function measureStructure(inventory, count) {
|
|
378
|
+
/** store -> module that declares it */
|
|
379
|
+
const storeModule = new Map(inventory.dataStores.map((store) => [store.name, store.module]));
|
|
380
|
+
/** entry point -> module */
|
|
381
|
+
const entryModule = new Map(inventory.entryPoints.map((entry) => [entry.id, entry.module]));
|
|
382
|
+
const modules = new Set([...storeModule.values(), ...entryModule.values()]);
|
|
383
|
+
const dependsOn = /* @__PURE__ */ new Map();
|
|
384
|
+
for (const module of modules) dependsOn.set(module, /* @__PURE__ */ new Set());
|
|
385
|
+
/**
|
|
386
|
+
* The dependency that matters is USE, not import: module A depends on B when
|
|
387
|
+
* a transaction of A reaches a store declared in B. A type-only import
|
|
388
|
+
* creates no functional coupling.
|
|
389
|
+
*/
|
|
390
|
+
for (const behavior of inventory.behaviors) {
|
|
391
|
+
const from = entryModule.get(behavior.entryPointId);
|
|
392
|
+
if (!from) continue;
|
|
393
|
+
for (const store of behavior.touches) {
|
|
394
|
+
const to = storeModule.get(store);
|
|
395
|
+
if (!to || to === from) continue;
|
|
396
|
+
dependsOn.get(from)?.add(to);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const dependedOnBy = /* @__PURE__ */ new Map();
|
|
400
|
+
for (const module of modules) dependedOnBy.set(module, /* @__PURE__ */ new Set());
|
|
401
|
+
for (const [from, targets] of dependsOn) for (const to of targets) dependedOnBy.get(to)?.add(from);
|
|
402
|
+
const transactionsPerModule = /* @__PURE__ */ new Map();
|
|
403
|
+
for (const module of entryModule.values()) transactionsPerModule.set(module, (transactionsPerModule.get(module) ?? 0) + 1);
|
|
404
|
+
const storesPerModule = /* @__PURE__ */ new Map();
|
|
405
|
+
for (const module of storeModule.values()) storesPerModule.set(module, (storesPerModule.get(module) ?? 0) + 1);
|
|
406
|
+
const moduleMetrics = [...modules].map((module) => {
|
|
407
|
+
const ce = dependsOn.get(module).size;
|
|
408
|
+
const ca = dependedOnBy.get(module).size;
|
|
409
|
+
return {
|
|
410
|
+
module,
|
|
411
|
+
functionPoints: count.totals.byModule[module] ?? 0,
|
|
412
|
+
transactions: transactionsPerModule.get(module) ?? 0,
|
|
413
|
+
dataStores: storesPerModule.get(module) ?? 0,
|
|
414
|
+
dependsOn: [...dependsOn.get(module)].sort(),
|
|
415
|
+
dependedOnBy: [...dependedOnBy.get(module)].sort(),
|
|
416
|
+
instability: ca + ce === 0 ? 0 : round(ce / (ca + ce))
|
|
417
|
+
};
|
|
418
|
+
}).sort((a, b) => b.functionPoints - a.functionPoints);
|
|
419
|
+
const mutual = [];
|
|
420
|
+
for (const [from, targets] of dependsOn) for (const to of targets) if (from < to && dependsOn.get(to)?.has(from)) mutual.push([from, to]);
|
|
421
|
+
const stores = inventory.dataStores.length || 1;
|
|
422
|
+
return {
|
|
423
|
+
modules: moduleMetrics,
|
|
424
|
+
mutualDependencies: mutual.sort(),
|
|
425
|
+
pointsPerDataStore: round(count.totals.unadjusted / stores),
|
|
426
|
+
transactionsPerDataStore: round(inventory.entryPoints.length / stores)
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function measureConformance(inventory) {
|
|
430
|
+
const behaviors = inventory.behaviors;
|
|
431
|
+
const takesInput = behaviors.filter((behavior) => behavior.inputFields.length > 0 || behavior.requestFields.length > 0 || behavior.opaqueRequest);
|
|
432
|
+
const withValidator = takesInput.filter((behavior) => behavior.inputFields.length > 0);
|
|
433
|
+
const withHandler = inventory.entryPoints.filter((entry) => entry.handler !== null);
|
|
434
|
+
const reached = new Set(behaviors.flatMap((behavior) => behavior.touches));
|
|
435
|
+
return {
|
|
436
|
+
inputsWithValidator: ratio(withValidator.length, takesInput.length),
|
|
437
|
+
entryPointsWithHandler: ratio(withHandler.length, inventory.entryPoints.length),
|
|
438
|
+
dataStoresReached: ratio(reached.size, inventory.dataStores.length)
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
const ratio = (ok, total) => ({
|
|
442
|
+
ok,
|
|
443
|
+
total,
|
|
444
|
+
ratio: total === 0 ? 1 : round(ok / total)
|
|
445
|
+
});
|
|
446
|
+
const round = (value) => Math.round(value * 1e3) / 1e3;
|
|
304
447
|
//#endregion
|
|
305
448
|
//#region src/reporters/table.ts
|
|
306
449
|
/**
|
|
@@ -355,6 +498,41 @@ function renderCount(result) {
|
|
|
355
498
|
return lines.join("\n");
|
|
356
499
|
}
|
|
357
500
|
/** `fp:explain`: a function's provenance, which is what supports a dispute */
|
|
501
|
+
/**
|
|
502
|
+
* Structure and conformance, beside the count and never instead of it.
|
|
503
|
+
*
|
|
504
|
+
* If function points pay, the team optimises function points — more models, more
|
|
505
|
+
* endpoints, less reuse. Density and coupling on the same report are the
|
|
506
|
+
* counterweight, which is why this shares the inventory rather than collecting
|
|
507
|
+
* anything of its own.
|
|
508
|
+
*/
|
|
509
|
+
function renderMetrics(structure, conformance, coverage) {
|
|
510
|
+
const lines = [];
|
|
511
|
+
lines.push("Density");
|
|
512
|
+
lines.push(` FP per data store: ${structure.pointsPerDataStore.toFixed(1)}`);
|
|
513
|
+
lines.push(` transactions per data store: ${structure.transactionsPerDataStore.toFixed(1)}`);
|
|
514
|
+
lines.push("");
|
|
515
|
+
lines.push("Conformance");
|
|
516
|
+
for (const [label, value] of [
|
|
517
|
+
["inputs with a validator", conformance.inputsWithValidator],
|
|
518
|
+
["entry points with a handler", conformance.entryPointsWithHandler],
|
|
519
|
+
["data stores reached", conformance.dataStoresReached]
|
|
520
|
+
]) lines.push(` ${pad(label, 28)}${padStart(`${(value.ratio * 100).toFixed(1)}%`, 7)} (${value.ok}/${value.total})`);
|
|
521
|
+
lines.push(` ${pad("tracing coverage", 28)}${padStart(`${(coverage.ratio * 100).toFixed(1)}%`, 7)} (${coverage.unresolvedCalls} unresolved calls)`);
|
|
522
|
+
lines.push("");
|
|
523
|
+
lines.push(`${pad("module", 20)}${padStart("FP", 6)}${padStart("trans", 7)}${padStart("stores", 7)}${padStart("I", 6)} depends on`);
|
|
524
|
+
for (const module of structure.modules) lines.push(`${pad(module.module.slice(0, 19), 20)}${padStart(module.functionPoints, 6)}${padStart(module.transactions, 7)}${padStart(module.dataStores, 7)}${padStart(module.instability.toFixed(2), 6)} ${module.dependsOn.join(", ")}`);
|
|
525
|
+
/**
|
|
526
|
+
* Reported, not scored. A cycle between two modules is a fact about the code
|
|
527
|
+
* that a number would hide, and the decision about it is the team's.
|
|
528
|
+
*/
|
|
529
|
+
if (structure.mutualDependencies.length > 0) {
|
|
530
|
+
lines.push("");
|
|
531
|
+
lines.push("Mutual dependencies (cycle candidates)");
|
|
532
|
+
for (const [a, b] of structure.mutualDependencies) lines.push(` ${a} <-> ${b}`);
|
|
533
|
+
}
|
|
534
|
+
return lines.join("\n");
|
|
535
|
+
}
|
|
358
536
|
function renderExplain(fn) {
|
|
359
537
|
const lines = [];
|
|
360
538
|
lines.push(`${fn.name} — ${fn.type}, ${fn.complexity} complexity, ${fn.points} FP`);
|
|
@@ -415,70 +593,40 @@ function renderDiff(diff) {
|
|
|
415
593
|
*/
|
|
416
594
|
if (change === "changed") for (const [reason, split] of Object.entries(diff.changedByReason)) {
|
|
417
595
|
if (split.count === 0) continue;
|
|
418
|
-
|
|
596
|
+
/**
|
|
597
|
+
* The effective factor, per reason. Printing only the `changed` factor
|
|
598
|
+
* hid that `implementation` — no change in type, DET or FTR — was being
|
|
599
|
+
* billed at the same rate as a functional one.
|
|
600
|
+
*/
|
|
601
|
+
const effective = diff.reasonFactors[reason];
|
|
602
|
+
lines.push(` ${pad(reason, 16)}${padStart(split.count, 3)} functions${padStart(split.points, 6)} FP` + (effective === void 0 ? "" : ` × ${effective}`));
|
|
419
603
|
}
|
|
420
604
|
}
|
|
421
605
|
lines.push("");
|
|
422
606
|
lines.push(`Billable FP: ${diff.billable}`);
|
|
423
|
-
|
|
424
|
-
|
|
607
|
+
/**
|
|
608
|
+
* Before the per-function list, not after it.
|
|
609
|
+
*
|
|
610
|
+
* On a real pair of releases the list is over a hundred lines, and a caveat
|
|
611
|
+
* about how most of the total was priced sat below all of them. A warning that
|
|
612
|
+
* has to be scrolled to is not a warning — and this particular number becomes
|
|
613
|
+
* an invoice.
|
|
614
|
+
*/
|
|
615
|
+
for (const warning of diff.warnings) {
|
|
616
|
+
lines.push("");
|
|
617
|
+
lines.push(`Warning: ${warning}`);
|
|
618
|
+
}
|
|
619
|
+
const moved = diff.entries.filter((entry) => entry.change !== "unchanged");
|
|
620
|
+
if (moved.length > 0) {
|
|
425
621
|
lines.push("");
|
|
426
|
-
for (const entry of
|
|
622
|
+
for (const entry of moved) {
|
|
427
623
|
const label = entry.reason ? `${entry.change} (${entry.reason})` : entry.change;
|
|
428
624
|
lines.push(` ${pad(label, 26)} ${pad(entry.function.name.slice(0, 40), 41)}${padStart(entry.function.points, 4)} PF${movementOf(entry)}`);
|
|
429
625
|
}
|
|
430
626
|
}
|
|
431
|
-
for (const warning of diff.warnings) {
|
|
432
|
-
lines.push("");
|
|
433
|
-
lines.push(`Warning: ${warning}`);
|
|
434
|
-
}
|
|
435
627
|
return lines.join("\n");
|
|
436
628
|
}
|
|
437
629
|
//#endregion
|
|
438
|
-
//#region src/cli/load_config.ts
|
|
439
|
-
/**
|
|
440
|
-
* Loads `config/function_points.ts` from the application being analysed.
|
|
441
|
-
*
|
|
442
|
-
* Both front-ends need this and neither could do it alone: the ace commands run
|
|
443
|
-
* with `startApp: false`, so there is no booted container to read config from;
|
|
444
|
-
* and the standalone CLI has no container at all. The file is therefore
|
|
445
|
-
* imported directly, through jiti, which transforms the whole module graph —
|
|
446
|
-
* a config may import a resolver from the application, and that resolver may
|
|
447
|
-
* import files using decorators, which Node's type stripping cannot handle.
|
|
448
|
-
*
|
|
449
|
-
* **A config that exists and fails to load is an error, never a fallback.**
|
|
450
|
-
* Falling back to defaults with a warning would silently change the count, and
|
|
451
|
-
* the count becomes an invoice. Absence of a config file is a different thing,
|
|
452
|
-
* and is legitimate: it means the defaults.
|
|
453
|
-
*/
|
|
454
|
-
const CONFIG_PATHS = ["config/function_points.ts", "config/function_points.js"];
|
|
455
|
-
var ConfigLoadError = class extends Error {
|
|
456
|
-
constructor(file, cause) {
|
|
457
|
-
super(`failed to load ${file}: ${cause instanceof Error ? cause.message : String(cause)}\nThe count was NOT produced. Fix the configuration, or remove the file to use the defaults — falling back silently would change the number without telling you.`);
|
|
458
|
-
this.file = file;
|
|
459
|
-
this.cause = cause;
|
|
460
|
-
this.name = "ConfigLoadError";
|
|
461
|
-
}
|
|
462
|
-
};
|
|
463
|
-
async function loadConfig(root) {
|
|
464
|
-
const found = CONFIG_PATHS.map((candidate) => path.join(root, candidate)).find((candidate) => existsSync(candidate));
|
|
465
|
-
if (!found) return {
|
|
466
|
-
config: { ...DEFAULTS },
|
|
467
|
-
file: null
|
|
468
|
-
};
|
|
469
|
-
let loaded;
|
|
470
|
-
try {
|
|
471
|
-
loaded = await createJiti(pathToFileURL(path.join(root, "noop.js")).href, { interopDefault: true }).import(found, { default: true });
|
|
472
|
-
} catch (error) {
|
|
473
|
-
throw new ConfigLoadError(found, error);
|
|
474
|
-
}
|
|
475
|
-
if (!loaded || typeof loaded !== "object") throw new ConfigLoadError(found, /* @__PURE__ */ new Error("the default export is not a configuration object"));
|
|
476
|
-
return {
|
|
477
|
-
config: defineConfig(loaded),
|
|
478
|
-
file: toPosix(found)
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
//#endregion
|
|
482
630
|
//#region src/cli/runners.ts
|
|
483
631
|
/**
|
|
484
632
|
* An empty inventory is never a number.
|
|
@@ -556,6 +704,42 @@ async function runCount(options) {
|
|
|
556
704
|
output: options.json ? JSON.stringify(count, null, 2) : renderCount(count)
|
|
557
705
|
};
|
|
558
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* Structure and conformance, from the same inventory as the count.
|
|
709
|
+
*
|
|
710
|
+
* These were implemented and tested and reachable from no front-end at all,
|
|
711
|
+
* which is the recurring defect of this package: a capability that exists,
|
|
712
|
+
* typed and covered, and that nobody can run. A metric nobody can run is not a
|
|
713
|
+
* metric.
|
|
714
|
+
*/
|
|
715
|
+
async function runMetrics(options) {
|
|
716
|
+
const { config, notes } = await configFor(options.root);
|
|
717
|
+
const { inventory, count } = await analyze(options.root, config);
|
|
718
|
+
const empty = refuseIfEmpty(options.root, inventory.dataStores.length, inventory.entryPoints.length);
|
|
719
|
+
if (empty) return {
|
|
720
|
+
output: "",
|
|
721
|
+
notes,
|
|
722
|
+
errors: empty
|
|
723
|
+
};
|
|
724
|
+
const structure = measureStructure(inventory, count);
|
|
725
|
+
const conformance = measureConformance(inventory);
|
|
726
|
+
if (options.out) {
|
|
727
|
+
await writeFile(options.out, JSON.stringify({
|
|
728
|
+
source: count.source,
|
|
729
|
+
structure,
|
|
730
|
+
conformance
|
|
731
|
+
}, null, 2));
|
|
732
|
+
notes.push(`metrics written to ${options.out}`);
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
notes,
|
|
736
|
+
output: options.json ? JSON.stringify({
|
|
737
|
+
source: count.source,
|
|
738
|
+
structure,
|
|
739
|
+
conformance
|
|
740
|
+
}, null, 2) : renderMetrics(structure, conformance, inventory.coverage)
|
|
741
|
+
};
|
|
742
|
+
}
|
|
559
743
|
async function runExplain(options) {
|
|
560
744
|
const { config, notes } = await configFor(options.root);
|
|
561
745
|
const { count } = await analyze(options.root, config);
|
|
@@ -595,10 +779,14 @@ async function runDiff(options) {
|
|
|
595
779
|
try {
|
|
596
780
|
return {
|
|
597
781
|
notes,
|
|
598
|
-
output: renderDiff(diffCounts(previous, current, {
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
782
|
+
output: renderDiff(diffCounts(previous, current, {
|
|
783
|
+
labels: {
|
|
784
|
+
from: options.previous,
|
|
785
|
+
to
|
|
786
|
+
},
|
|
787
|
+
factors: config.diff?.factors,
|
|
788
|
+
reasonFactors: config.diff?.reasonFactors
|
|
789
|
+
}))
|
|
602
790
|
};
|
|
603
791
|
} catch (error) {
|
|
604
792
|
if (error instanceof IncomparableRulesetsError || error instanceof IncomparableSourcesError) return {
|
|
@@ -627,4 +815,4 @@ async function runCalibrate(options) {
|
|
|
627
815
|
};
|
|
628
816
|
}
|
|
629
817
|
//#endregion
|
|
630
|
-
export { runInventory as a, runExplain as i, runCount as n,
|
|
818
|
+
export { runInventory as a, ConfigLoadError as c, runExplain as i, runCount as n, runMetrics as o, runDiff as r, printResult as s, runCalibrate as t };
|
|
@@ -21,8 +21,16 @@ export declare const RULESET = "afp";
|
|
|
21
21
|
* It appears in every report, and `fp:diff` refuses to compare counts produced
|
|
22
22
|
* by different versions — otherwise the difference would measure the rule
|
|
23
23
|
* change rather than the work.
|
|
24
|
+
*
|
|
25
|
+
* It must be bumped by ANY change that moves the number for unchanged code, and
|
|
26
|
+
* that is easy to forget: four such changes landed in 1.1.0 — maintenance read
|
|
27
|
+
* across the whole project rather than from routes alone, a job followed into
|
|
28
|
+
* `process`, an event followed into its listeners, and `request.input(…)` counted
|
|
29
|
+
* as a DET. Without the bump, a baseline saved by the previous version would have
|
|
30
|
+
* compared cleanly against this one and billed the tool's own improvement as work
|
|
31
|
+
* done. The guard exists for exactly that, and only this constant arms it.
|
|
24
32
|
*/
|
|
25
|
-
export declare const RULESET_VERSION = "1.
|
|
33
|
+
export declare const RULESET_VERSION = "1.1.0";
|
|
26
34
|
export type CountInput = {
|
|
27
35
|
app: AppContext;
|
|
28
36
|
stores: CollectedDataStore[];
|
|
@@ -31,6 +39,12 @@ export type CountInput = {
|
|
|
31
39
|
behaviors: Map<string, Behavior>;
|
|
32
40
|
/** JSON Schema literals found in the code, for `detFromSchema` — §8 */
|
|
33
41
|
jsonSchemas?: Map<string, DiscoveredSchema>;
|
|
42
|
+
/**
|
|
43
|
+
* Stores written anywhere in the application's code, reachable from an entry
|
|
44
|
+
* point or not — AFP §6.5.4 asks who MAINTAINS the store, and a job or a
|
|
45
|
+
* seeder is this application just as much as a route is.
|
|
46
|
+
*/
|
|
47
|
+
writtenAnywhere?: Set<string>;
|
|
34
48
|
};
|
|
35
49
|
export type CountOptions = {
|
|
36
50
|
retStrategy?: 'constant' | 'composition';
|
|
@@ -39,6 +53,8 @@ export type CountOptions = {
|
|
|
39
53
|
boundary?: {
|
|
40
54
|
infrastructure?: string[];
|
|
41
55
|
externallyMaintained?: string[];
|
|
56
|
+
/** restores what the AFP naming filter caught by accident */
|
|
57
|
+
business?: string[];
|
|
42
58
|
ignoreEntryPoints?: string[];
|
|
43
59
|
};
|
|
44
60
|
messageDet?: number;
|
|
@@ -26,6 +26,12 @@ export type DataFunctionOptions = {
|
|
|
26
26
|
retStrategy: 'constant' | 'composition';
|
|
27
27
|
/** stores maintained by another system, by boundary decision */
|
|
28
28
|
externallyMaintained: Set<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Stores the application writes anywhere in its code — a job, a seeder, a
|
|
31
|
+
* command — whether or not a route reaches that write. §6.5.4 asks who
|
|
32
|
+
* maintains the store, not which route does.
|
|
33
|
+
*/
|
|
34
|
+
writtenAnywhere: Set<string>;
|
|
29
35
|
tables: Record<FunctionType, ComplexityTable>;
|
|
30
36
|
weights: Record<FunctionType, Record<Complexity, number>>;
|
|
31
37
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChangeType, CountResult, DiffResult } from '../types.js';
|
|
1
|
+
import type { ChangeReason, ChangeType, CountResult, DiffResult } from '../types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Added, changed and removed functions between two counts — what becomes an
|
|
4
4
|
* invoice.
|
|
@@ -37,8 +37,24 @@ export declare class IncomparableRulesetsError extends Error {
|
|
|
37
37
|
*/
|
|
38
38
|
export type ChangeFactors = Record<ChangeType, number>;
|
|
39
39
|
export declare const AEP_FACTORS: ChangeFactors;
|
|
40
|
+
/**
|
|
41
|
+
* Factors for a modified function, by WHAT changed about it.
|
|
42
|
+
*
|
|
43
|
+
* The distinction is already measured — `type`, `size` and `implementation` come
|
|
44
|
+
* out of the same comparison — and pricing all three at 1 throws that away. It
|
|
45
|
+
* showed up on a real pair of releases: of 378 FP billed as changed, 151 came
|
|
46
|
+
* from functions whose type, DET and FTR were all identical and only the body
|
|
47
|
+
* differed. Charging a refactor at full functional value is not defensible, and
|
|
48
|
+
* charging it at a number this package invented would be worse.
|
|
49
|
+
*
|
|
50
|
+
* So no default changes: each reason falls back to `factors.changed`, and the
|
|
51
|
+
* contract sets what it agreed to price. What the tool owes is the split.
|
|
52
|
+
*/
|
|
53
|
+
export type ChangeReasonFactors = Partial<Record<ChangeReason, number>>;
|
|
40
54
|
export type DiffOptions = {
|
|
41
55
|
factors?: Partial<ChangeFactors>;
|
|
56
|
+
/** per-reason factors for modified functions; each falls back to `factors.changed` */
|
|
57
|
+
reasonFactors?: ChangeReasonFactors;
|
|
42
58
|
/** labels for the two measurements, for the report only */
|
|
43
59
|
labels?: {
|
|
44
60
|
from: string;
|
|
@@ -49,6 +65,8 @@ export type FunctionPointDiff = DiffResult & {
|
|
|
49
65
|
/** function points weighted by the factors — this is what gets billed */
|
|
50
66
|
billable: number;
|
|
51
67
|
factors: ChangeFactors;
|
|
68
|
+
/** what the modified functions were actually billed at, by reason */
|
|
69
|
+
reasonFactors: ChangeReasonFactors;
|
|
52
70
|
warnings: string[];
|
|
53
71
|
};
|
|
54
72
|
/**
|
|
@@ -30,6 +30,18 @@ export declare function runCount(options: Common & {
|
|
|
30
30
|
json?: boolean;
|
|
31
31
|
minCoverage?: number;
|
|
32
32
|
}): Promise<RunResult>;
|
|
33
|
+
/**
|
|
34
|
+
* Structure and conformance, from the same inventory as the count.
|
|
35
|
+
*
|
|
36
|
+
* These were implemented and tested and reachable from no front-end at all,
|
|
37
|
+
* which is the recurring defect of this package: a capability that exists,
|
|
38
|
+
* typed and covered, and that nobody can run. A metric nobody can run is not a
|
|
39
|
+
* metric.
|
|
40
|
+
*/
|
|
41
|
+
export declare function runMetrics(options: Common & {
|
|
42
|
+
out?: string;
|
|
43
|
+
json?: boolean;
|
|
44
|
+
}): Promise<RunResult>;
|
|
33
45
|
export declare function runExplain(options: Common & {
|
|
34
46
|
name: string;
|
|
35
47
|
}): Promise<RunResult>;
|
package/build/src/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as CoverageTooLowError } from "../pipeline-
|
|
2
|
-
import { a as runInventory, i as runExplain, n as runCount, o as
|
|
1
|
+
import { t as CoverageTooLowError } from "../pipeline-DySlMWcN.js";
|
|
2
|
+
import { a as runInventory, c as ConfigLoadError, i as runExplain, n as runCount, o as runMetrics, r as runDiff, s as printResult, t as runCalibrate } from "../runners-DpMd-yZM.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -23,6 +23,7 @@ Usage
|
|
|
23
23
|
Commands
|
|
24
24
|
count count the unadjusted function points
|
|
25
25
|
inventory the raw facts: stores, routes, tracing coverage
|
|
26
|
+
metrics density, coupling and conformance, from the same run
|
|
26
27
|
explain <name> why one function was counted that way
|
|
27
28
|
diff <a.json> [b.json] additions / modifications / deletions, and billable FP
|
|
28
29
|
one file compares against the current tree; two
|
|
@@ -105,6 +106,7 @@ async function run(argv, printer = CONSOLE) {
|
|
|
105
106
|
if (![
|
|
106
107
|
"count",
|
|
107
108
|
"inventory",
|
|
109
|
+
"metrics",
|
|
108
110
|
"explain",
|
|
109
111
|
"diff",
|
|
110
112
|
"calibrate"
|
|
@@ -153,6 +155,13 @@ async function run(argv, printer = CONSOLE) {
|
|
|
153
155
|
out: text(flags.get("out"))
|
|
154
156
|
});
|
|
155
157
|
break;
|
|
158
|
+
case "metrics":
|
|
159
|
+
result = await runMetrics({
|
|
160
|
+
root,
|
|
161
|
+
out: text(flags.get("out")),
|
|
162
|
+
json: flags.get("json") === true
|
|
163
|
+
});
|
|
164
|
+
break;
|
|
156
165
|
case "explain":
|
|
157
166
|
result = await runExplain({
|
|
158
167
|
root,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComplexityTable } from './albrecht/tables.js';
|
|
2
|
+
import type { ChangeFactors, ChangeReasonFactors } from './albrecht/diff.js';
|
|
2
3
|
import type { CallResolver } from './inventory/resolvers/types.js';
|
|
3
4
|
import type { Complexity, FunctionType } from './types.js';
|
|
4
5
|
/**
|
|
@@ -35,6 +36,20 @@ export type FunctionPointsConfig = {
|
|
|
35
36
|
* For example, tables mirrored from an external ERP.
|
|
36
37
|
*/
|
|
37
38
|
externallyMaintained?: string[];
|
|
39
|
+
/**
|
|
40
|
+
* Stores the AFP naming filter excluded, which are business data here.
|
|
41
|
+
*
|
|
42
|
+
* The filter (§6.5.2.1.1, patterns in §6.5.2.1.3) catches names containing
|
|
43
|
+
* `session`, `template`, `error`, `types` and so on, because in most
|
|
44
|
+
* applications those hold infrastructure. When they hold the business —
|
|
45
|
+
* a chat session the user manages, a document template they maintain — the
|
|
46
|
+
* exclusion is wrong and no heuristic can know it. This wins over the
|
|
47
|
+
* filter, and the report says which stores were brought back.
|
|
48
|
+
*
|
|
49
|
+
* It is the counterpart of `infrastructure`: that one excludes what the
|
|
50
|
+
* filter missed, this one restores what it caught by accident.
|
|
51
|
+
*/
|
|
52
|
+
business?: string[];
|
|
38
53
|
/**
|
|
39
54
|
* Entry points with no functional value to the user, by route name or by
|
|
40
55
|
* identity (`GET /health`).
|
|
@@ -72,6 +87,25 @@ export type FunctionPointsConfig = {
|
|
|
72
87
|
complexityTables?: Partial<Record<FunctionType, ComplexityTable>>;
|
|
73
88
|
/** Weights per type and complexity. */
|
|
74
89
|
weights?: Partial<Record<FunctionType, Record<Complexity, number>>>;
|
|
90
|
+
/**
|
|
91
|
+
* How change is priced, for `fp:diff`. A clause of the contract, not a flag.
|
|
92
|
+
*
|
|
93
|
+
* The AEP anchors are explicit for added (1) and deleted (0.4). For a modified
|
|
94
|
+
* function AEP grades the factor from 0.25 to 1.75 through Effort Complexity
|
|
95
|
+
* variation, which needs cyclomatic complexity this package does not measure —
|
|
96
|
+
* so it defaults to 1, which overestimates, and every diff says so.
|
|
97
|
+
*
|
|
98
|
+
* `reasonFactors` is the lever the default leaves on the table. The tool
|
|
99
|
+
* already distinguishes a change of type, a change of size, and a change of
|
|
100
|
+
* implementation only — same type, same DET, same FTR, different body — and on
|
|
101
|
+
* a real pair of releases the last was 151 of 378 FP billed as change. Pricing
|
|
102
|
+
* a refactor at full functional value is not defensible; pricing it at a number
|
|
103
|
+
* this package invented would be worse. So the number comes from the contract.
|
|
104
|
+
*/
|
|
105
|
+
diff?: {
|
|
106
|
+
factors?: Partial<ChangeFactors>;
|
|
107
|
+
reasonFactors?: ChangeReasonFactors;
|
|
108
|
+
};
|
|
75
109
|
/**
|
|
76
110
|
* Custom tracing strategies, added to the built-in ones and running
|
|
77
111
|
* **before** them.
|
|
@@ -93,7 +127,7 @@ export type FunctionPointsConfig = {
|
|
|
93
127
|
minCoverage?: number;
|
|
94
128
|
/**
|
|
95
129
|
* Declared DET or RET/FTR for a function the analysis cannot read, keyed by
|
|
96
|
-
* the name it has in the count (`
|
|
130
|
+
* the name it has in the count (`Invoice`, `POST /books`).
|
|
97
131
|
*
|
|
98
132
|
* The case this exists for is a schema-driven application: when the fields a
|
|
99
133
|
* user fills live in a JSON column whose schema is stored in the database,
|