@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +103 -2
  3. package/build/commands/fp_metrics.d.ts +10 -0
  4. package/build/commands/main.d.ts +6 -5
  5. package/build/commands/main.js +48 -114
  6. package/build/decorate-D6enDn9D.js +24 -0
  7. package/build/fp_calibrate-Cm079xWL.js +25 -0
  8. package/build/fp_count-CfcXPuj5.js +22 -0
  9. package/build/fp_diff-DE_t3twv.js +25 -0
  10. package/build/fp_explain-MKyEoi0h.js +24 -0
  11. package/build/fp_inventory-DSrCVhEy.js +18 -0
  12. package/build/fp_metrics-BUWj9dLw.js +20 -0
  13. package/build/index.d.ts +2 -1
  14. package/build/index.js +3 -2
  15. package/build/{pipeline-BzP-ITGN.js → pipeline-DySlMWcN.js} +298 -40
  16. package/build/{resolvers-CU9HKYpn.js → resolvers-MFjRl2ef.js} +225 -5
  17. package/build/{runners-Bt8tbISi.js → runners-DpMd-yZM.js} +252 -64
  18. package/build/src/albrecht/counter.d.ts +17 -1
  19. package/build/src/albrecht/data_functions.d.ts +6 -0
  20. package/build/src/albrecht/diff.d.ts +19 -1
  21. package/build/src/cli/runners.d.ts +12 -0
  22. package/build/src/cli.js +11 -2
  23. package/build/src/define_config.d.ts +35 -1
  24. package/build/src/inventory/graph/call_graph.d.ts +27 -0
  25. package/build/src/inventory/graph/noise.d.ts +13 -0
  26. package/build/src/inventory/resolvers/event_dispatch.d.ts +17 -0
  27. package/build/src/inventory/resolvers/index.js +1 -1
  28. package/build/src/inventory/resolvers/types.d.ts +35 -0
  29. package/build/src/inventory/sources/event_bindings.d.ts +44 -0
  30. package/build/src/metrics/structure.d.ts +16 -2
  31. package/build/src/pipeline.js +1 -1
  32. package/build/src/reporters/table.d.ts +11 -1
  33. package/build/src/types.d.ts +10 -0
  34. package/build/stubs/config.stub +27 -1
  35. package/package.json +2 -1
  36. package/build/scripts/smoke_package.d.ts +0 -1
  37. package/build/tmp/probe.d.ts +0 -1
  38. package/build/tmp/probe_cli.d.ts +0 -1
  39. package/build/tmp/probe_cmp.d.ts +0 -1
  40. package/build/tmp/probe_count.d.ts +0 -1
  41. package/build/tmp/probe_data.d.ts +0 -1
  42. package/build/tmp/probe_diff.d.ts +0 -1
  43. package/build/tmp/probe_gap.d.ts +0 -1
  44. package/build/tmp/probe_graph.d.ts +0 -1
  45. package/build/tmp/probe_metrics.d.ts +0 -1
  46. package/build/tmp/probe_miss.d.ts +0 -1
  47. package/build/tmp/probe_names.d.ts +0 -1
  48. package/build/tmp/probe_nodata.d.ts +0 -1
  49. package/build/tmp/probe_one.d.ts +0 -1
  50. package/build/tmp/probe_perf.d.ts +0 -1
  51. package/build/tmp/probe_routes.d.ts +0 -1
  52. package/build/tmp/probe_unres.d.ts +0 -1
  53. package/build/tmp/probe_vazquez.d.ts +0 -1
  54. 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 { n as analyze, r as toPosix } from "./pipeline-BzP-ITGN.js";
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
- if (entries.some((entry) => entry.change === "changed") && factors.changed === 1) warnings.push("change factor pinned at 1: AEP grades it from 0.25 to 1.75 through Effort Complexity variation, which requires cyclomatic complexity — not measured yet. Changed functions are being billed at full value.");
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
- billable: entries.reduce((total, entry) => total + entry.function.points * factors[entry.change], 0),
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
- lines.push(` ${pad(reason, 16)}${padStart(split.count, 3)} functions${padStart(split.points, 6)} FP`);
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
- const mudou = diff.entries.filter((entry) => entry.change !== "unchanged");
424
- if (mudou.length > 0) {
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 mudou) {
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, { labels: {
599
- from: options.previous,
600
- to
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, ConfigLoadError as o, runDiff as r, printResult as s, runCalibrate as t };
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.0.0";
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-BzP-ITGN.js";
2
- import { a as runInventory, i as runExplain, n as runCount, o as ConfigLoadError, r as runDiff, s as printResult, t as runCalibrate } from "../runners-Bt8tbISi.js";
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 (`Petition`, `POST /books`).
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,