@filipebraida/adonis-function-points 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/README.md +103 -2
  3. package/build/calibration-8eV8CEix.js +403 -0
  4. package/build/commands/fp_metrics.d.ts +10 -0
  5. package/build/commands/main.d.ts +6 -5
  6. package/build/commands/main.js +48 -114
  7. package/build/decorate-D6enDn9D.js +24 -0
  8. package/build/fp_calibrate-iFAec0tA.js +25 -0
  9. package/build/fp_count-D21tQ_pv.js +22 -0
  10. package/build/fp_diff-D0pHGMgi.js +25 -0
  11. package/build/fp_explain-BwFs-LW-.js +24 -0
  12. package/build/fp_inventory-Bu6O1Nn0.js +18 -0
  13. package/build/fp_metrics-MGDppfSa.js +20 -0
  14. package/build/index.d.ts +30 -1
  15. package/build/index.js +5 -3
  16. package/build/{pipeline-BzP-ITGN.js → pipeline-CIAydCcT.js} +452 -54
  17. package/build/{resolvers-CU9HKYpn.js → resolvers-CRB6lXoo.js} +474 -207
  18. package/build/{runners-Bt8tbISi.js → runners-CmxNHuuq.js} +146 -342
  19. package/build/src/albrecht/counter.d.ts +21 -1
  20. package/build/src/albrecht/data_functions.d.ts +6 -0
  21. package/build/src/albrecht/diff.d.ts +19 -1
  22. package/build/src/cli/runners.d.ts +12 -0
  23. package/build/src/cli.js +11 -2
  24. package/build/src/define_config.d.ts +35 -1
  25. package/build/src/inventory/detectors/lucid.d.ts +8 -0
  26. package/build/src/inventory/graph/call_graph.d.ts +29 -0
  27. package/build/src/inventory/graph/noise.d.ts +13 -0
  28. package/build/src/inventory/resolvers/event_dispatch.d.ts +17 -0
  29. package/build/src/inventory/resolvers/index.js +1 -1
  30. package/build/src/inventory/resolvers/types.d.ts +35 -0
  31. package/build/src/inventory/sources/event_bindings.d.ts +44 -0
  32. package/build/src/metrics/structure.d.ts +16 -2
  33. package/build/src/pipeline.js +1 -1
  34. package/build/src/reporters/table.d.ts +11 -1
  35. package/build/src/types.d.ts +17 -0
  36. package/build/stubs/config.stub +27 -1
  37. package/package.json +2 -1
  38. package/build/define_config-DOqWyPwV.js +0 -19
  39. package/build/scripts/smoke_package.d.ts +0 -1
  40. package/build/tmp/probe.d.ts +0 -1
  41. package/build/tmp/probe_cli.d.ts +0 -1
  42. package/build/tmp/probe_cmp.d.ts +0 -1
  43. package/build/tmp/probe_count.d.ts +0 -1
  44. package/build/tmp/probe_data.d.ts +0 -1
  45. package/build/tmp/probe_diff.d.ts +0 -1
  46. package/build/tmp/probe_gap.d.ts +0 -1
  47. package/build/tmp/probe_graph.d.ts +0 -1
  48. package/build/tmp/probe_metrics.d.ts +0 -1
  49. package/build/tmp/probe_miss.d.ts +0 -1
  50. package/build/tmp/probe_names.d.ts +0 -1
  51. package/build/tmp/probe_nodata.d.ts +0 -1
  52. package/build/tmp/probe_one.d.ts +0 -1
  53. package/build/tmp/probe_perf.d.ts +0 -1
  54. package/build/tmp/probe_routes.d.ts +0 -1
  55. package/build/tmp/probe_unres.d.ts +0 -1
  56. package/build/tmp/probe_vazquez.d.ts +0 -1
  57. package/build/tsdown.config.d.ts +0 -2
@@ -1,305 +1,64 @@
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";
1
+ import { c as diffCounts, i as measureStructure, l as DEFAULTS, n as parseSamples, o as IncomparableRulesetsError, r as measureConformance, s as IncomparableSourcesError, t as calibrate, u as defineConfig } from "./calibration-8eV8CEix.js";
2
+ import { c as toPosix } from "./resolvers-CRB6lXoo.js";
3
+ import { n as analyze } from "./pipeline-CIAydCcT.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";
8
- //#region src/cli/print.ts
9
- function printResult(result, printer) {
10
- for (const note of result.notes ?? []) printer.note(note);
11
- if (result.errors?.length) {
12
- for (const message of result.errors) printer.error(message);
13
- return 1;
14
- }
15
- if (result.output) printer.log(result.output);
16
- return 0;
17
- }
18
- //#endregion
19
- //#region src/albrecht/calibration.ts
20
- /**
21
- * Minimum sample size per type for a factor to mean anything.
22
- *
23
- * Below this, the "factor" is noise from one or two functions, and using it to
24
- * correct a count is worse than not correcting at all.
25
- */
26
- const MIN_SAMPLES_PER_TYPE = 10;
27
- function calibrate(result, samples) {
28
- const byIdentity = new Map(result.functions.map((fn) => [fn.name, fn]));
29
- const grouped = /* @__PURE__ */ new Map();
30
- const unmatched = [];
31
- let manualTotal = 0;
32
- let automaticTotal = 0;
33
- let exactTotal = 0;
34
- for (const sample of samples) {
35
- const counted = byIdentity.get(sample.function);
36
- if (!counted) {
37
- unmatched.push(sample.function);
38
- continue;
39
- }
40
- const bucket = grouped.get(counted.type) ?? {
41
- manual: 0,
42
- automatic: 0,
43
- deviations: [],
44
- exact: 0
45
- };
46
- bucket.manual += sample.manual;
47
- bucket.automatic += counted.points;
48
- bucket.deviations.push(Math.abs(counted.points - sample.manual));
49
- if (counted.points === sample.manual) bucket.exact++;
50
- grouped.set(counted.type, bucket);
51
- manualTotal += sample.manual;
52
- automaticTotal += counted.points;
53
- if (counted.points === sample.manual) exactTotal++;
54
- }
55
- const byType = [...grouped.entries()].map(([type, bucket]) => ({
56
- type,
57
- samples: bucket.deviations.length,
58
- manualPoints: bucket.manual,
59
- 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),
62
- exactMatches: bucket.exact
63
- })).sort((a, b) => a.type.localeCompare(b.type));
64
- const warnings = [];
65
- for (const calibration of byType) if (calibration.samples < MIN_SAMPLES_PER_TYPE) warnings.push(`${calibration.type}: ${calibration.samples} samples, below the minimum of ${MIN_SAMPLES_PER_TYPE}. The factor ${calibration.factor} is noise from a handful of functions — do not use it to correct a count.`);
66
- if (unmatched.length > 0) warnings.push(`${unmatched.length} samples matched no counted function. Check the identity: it is "VERB /pattern" with parameters written as ":param".`);
67
- const matched = samples.length - unmatched.length;
68
- if (matched > 0 && exactTotal === matched) warnings.push("every sample matched exactly. Check that the manual count was not derived from the automatic one — calibrating against itself measures nothing.");
69
- return {
70
- byType,
71
- overall: {
72
- samples: matched,
73
- manualPoints: manualTotal,
74
- automaticPoints: automaticTotal,
75
- deviation: manualTotal === 0 ? 0 : round((automaticTotal - manualTotal) / manualTotal),
76
- exactMatches: exactTotal
77
- },
78
- unmatched,
79
- warnings
80
- };
81
- }
82
- const round = (value) => Math.round(value * 1e3) / 1e3;
83
- /**
84
- * Reads samples from CSV: `function,fp` with a header row.
85
- *
86
- * Deliberately plain. A metrics analyst exports from a spreadsheet, and
87
- * demanding JSON would add friction where none is needed.
88
- */
89
- function parseSamples(csv) {
90
- const samples = [];
91
- for (const [index, line] of csv.split(/\r?\n/).entries()) {
92
- const trimmed = line.trim();
93
- if (trimmed === "" || trimmed.startsWith("#")) continue;
94
- const separator = trimmed.lastIndexOf(",");
95
- if (separator === -1) continue;
96
- const name = trimmed.slice(0, separator).trim().replace(/^"|"$/g, "");
97
- const manual = Number(trimmed.slice(separator + 1).trim());
98
- if (!Number.isFinite(manual)) {
99
- if (index > 0 && ![
100
- "function",
101
- "funcao",
102
- "função"
103
- ].includes(name)) throw new Error(`line ${index + 1}: unreadable function points in "${trimmed}"`);
104
- continue;
105
- }
106
- samples.push({
107
- function: name,
108
- manual
109
- });
110
- }
111
- return samples;
112
- }
113
- //#endregion
114
- //#region src/albrecht/diff.ts
9
+ //#region src/cli/load_config.ts
115
10
  /**
116
- * Added, changed and removed functions between two counts — what becomes an
117
- * invoice.
118
- *
119
- * Normative base: **OMG Automated Enhancement Points 1.0**, the sibling of AFP,
120
- * written to size maintenance between two revisions.
121
- *
122
- * "Each Artifact shall be analyzed in both revisions to determine whether it
123
- * is: Added — when it exists in revision ToRevision while it didn't exist in
124
- * FromRevision. […] Modified — when it exists in both revisions but whose
125
- * source code changed." — AEP §6.3
126
- *
127
- * Two decisions make this workable:
11
+ * Loads `config/function_points.ts` from the application being analysed.
128
12
  *
129
- * 1. **It operates on two saved counts**, never on two checkouts. Booting the
130
- * older revision, with possibly different dependencies, is the kind of
131
- * problem not worth solving.
132
- * 2. **It refuses to compare different rule sets.** If the rules changed in
133
- * between, the difference measures the rule change, not the work — and the
134
- * result would go into an invoice.
135
- */
136
- var IncomparableRulesetsError = class extends Error {
137
- constructor(from, to) {
138
- super(`counts from different rule sets are not comparable: ${from} vs ${to}. The rules changed between the two measurements, so the difference does not measure work — it measures the rule change.`);
139
- this.name = "IncomparableRulesetsError";
140
- }
141
- };
142
- const AEP_FACTORS = {
143
- added: 1,
144
- changed: 1,
145
- removed: .4,
146
- unchanged: 0
147
- };
148
- /**
149
- * Two counts of DIFFERENT applications compare cleanly and mean nothing.
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.
150
19
  *
151
- * The ruleset guard already refuses counts produced by different rules. This
152
- * refuses counts produced over different subjects, which is the same class of
153
- * error and the easier one to make in CI, where both files arrive as paths.
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.
154
24
  */
155
- var IncomparableSourcesError = class extends Error {
156
- constructor(from, to) {
157
- super(`refusing to compare counts of different applications: "${from}" and "${to}". The difference would not measure work, it would measure that the two files are about different things.`);
158
- this.from = from;
159
- this.to = to;
160
- this.name = "IncomparableSourcesError";
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";
161
32
  }
162
33
  };
163
- function diffCounts(from, to, options = {}) {
164
- if (from.rulesetVersion !== to.rulesetVersion || from.ruleset !== to.ruleset) throw new IncomparableRulesetsError(`${from.ruleset}@${from.rulesetVersion}`, `${to.ruleset}@${to.rulesetVersion}`);
165
- if (from.source && to.source && from.source.app !== to.source.app) throw new IncomparableSourcesError(from.source.app, to.source.app);
166
- const factors = {
167
- ...AEP_FACTORS,
168
- ...options.factors
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
169
39
  };
170
- const before = new Map(from.functions.map((fn) => [fn.id, fn]));
171
- const after = new Map(to.functions.map((fn) => [fn.id, fn]));
172
- const entries = [];
173
- for (const [id, fn] of after) {
174
- const previous = before.get(id);
175
- if (!previous) {
176
- entries.push({
177
- function: fn,
178
- change: "added"
179
- });
180
- continue;
181
- }
182
- const reason = reasonBetween(previous, fn);
183
- entries.push({
184
- function: fn,
185
- change: reason ? "changed" : "unchanged",
186
- previous,
187
- ...reason ? { reason } : {}
188
- });
189
- }
190
- for (const [id, fn] of before) if (!after.has(id)) entries.push({
191
- function: fn,
192
- change: "removed"
193
- });
194
- const warnings = [];
195
- /**
196
- * Provenance warnings. None of them stops the comparison — they qualify the
197
- * number that comes out of it, which is what goes onto an invoice.
198
- */
199
- for (const [side, count] of [["from", from], ["to", to]]) {
200
- if (!count.source) {
201
- warnings.push(`the "${side}" count records no source: it cannot be tied to a revision, so this difference cannot be reproduced or audited later.`);
202
- continue;
203
- }
204
- 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.`);
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);
205
45
  }
206
- 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.");
46
+ if (!loaded || typeof loaded !== "object") throw new ConfigLoadError(found, /* @__PURE__ */ new Error("the default export is not a configuration object"));
208
47
  return {
209
- from: options.labels?.from ?? "previous",
210
- to: options.labels?.to ?? "current",
211
- entries: entries.sort(byChangeThenName),
212
- totals: totalsOf(entries),
213
- changedByReason: changedByReasonOf(entries),
214
- billable: entries.reduce((total, entry) => total + entry.function.points * factors[entry.change], 0),
215
- factors,
216
- warnings
217
- };
218
- }
219
- /**
220
- * What counts as a change.
221
- *
222
- * A change in the implementation scope (checksum of the normalised AST) **or**
223
- * in the functional size. Formatting and comments do not count: the hash
224
- * already ignores them.
225
- *
226
- * Renaming a route does not show up here because identity is
227
- * `(verb, pattern)` — and neither does moving a controller between modules,
228
- * which is implementation.
229
- */
230
- /**
231
- * Why the function changed, or null when it did not.
232
- *
233
- * Reported by the most consequential cause: a reclassification usually moves
234
- * the size too, and naming the type is the fact that explains the rest. The
235
- * rendered line carries the DET and FTR movement, so nothing is hidden behind
236
- * the label.
237
- */
238
- function reasonBetween(previous, current) {
239
- if (previous.type !== current.type) return "type";
240
- if (previous.det !== current.det || previous.refs !== current.refs) return "size";
241
- if ((previous.scopeHash ?? "") !== (current.scopeHash ?? "")) return "implementation";
242
- return null;
243
- }
244
- /**
245
- * Where an invoice actually comes from.
246
- *
247
- * `changed` is usually the largest line, and until this split it said nothing
248
- * about whether it was paying for growth or for refactoring.
249
- */
250
- function changedByReasonOf(entries) {
251
- const byReason = {
252
- type: {
253
- count: 0,
254
- points: 0
255
- },
256
- size: {
257
- count: 0,
258
- points: 0
259
- },
260
- implementation: {
261
- count: 0,
262
- points: 0
263
- }
48
+ config: defineConfig(loaded),
49
+ file: toPosix(found)
264
50
  };
265
- for (const entry of entries) {
266
- if (entry.change !== "changed" || !entry.reason) continue;
267
- byReason[entry.reason].count++;
268
- byReason[entry.reason].points += entry.function.points;
269
- }
270
- return byReason;
271
51
  }
272
- const ORDER = {
273
- added: 0,
274
- changed: 1,
275
- removed: 2,
276
- unchanged: 3
277
- };
278
- const byChangeThenName = (a, b) => ORDER[a.change] - ORDER[b.change] || a.function.name.localeCompare(b.function.name);
279
- function totalsOf(entries) {
280
- const totals = {
281
- added: {
282
- count: 0,
283
- points: 0
284
- },
285
- changed: {
286
- count: 0,
287
- points: 0
288
- },
289
- removed: {
290
- count: 0,
291
- points: 0
292
- },
293
- unchanged: {
294
- count: 0,
295
- points: 0
296
- }
297
- };
298
- for (const entry of entries) {
299
- totals[entry.change].count++;
300
- totals[entry.change].points += entry.function.points;
52
+ //#endregion
53
+ //#region src/cli/print.ts
54
+ function printResult(result, printer) {
55
+ for (const note of result.notes ?? []) printer.note(note);
56
+ if (result.errors?.length) {
57
+ for (const message of result.errors) printer.error(message);
58
+ return 1;
301
59
  }
302
- return totals;
60
+ if (result.output) printer.log(result.output);
61
+ return 0;
303
62
  }
304
63
  //#endregion
305
64
  //#region src/reporters/table.ts
@@ -355,6 +114,41 @@ function renderCount(result) {
355
114
  return lines.join("\n");
356
115
  }
357
116
  /** `fp:explain`: a function's provenance, which is what supports a dispute */
117
+ /**
118
+ * Structure and conformance, beside the count and never instead of it.
119
+ *
120
+ * If function points pay, the team optimises function points — more models, more
121
+ * endpoints, less reuse. Density and coupling on the same report are the
122
+ * counterweight, which is why this shares the inventory rather than collecting
123
+ * anything of its own.
124
+ */
125
+ function renderMetrics(structure, conformance, coverage) {
126
+ const lines = [];
127
+ lines.push("Density");
128
+ lines.push(` FP per data store: ${structure.pointsPerDataStore.toFixed(1)}`);
129
+ lines.push(` transactions per data store: ${structure.transactionsPerDataStore.toFixed(1)}`);
130
+ lines.push("");
131
+ lines.push("Conformance");
132
+ for (const [label, value] of [
133
+ ["inputs with a validator", conformance.inputsWithValidator],
134
+ ["entry points with a handler", conformance.entryPointsWithHandler],
135
+ ["data stores reached", conformance.dataStoresReached]
136
+ ]) lines.push(` ${pad(label, 28)}${padStart(`${(value.ratio * 100).toFixed(1)}%`, 7)} (${value.ok}/${value.total})`);
137
+ lines.push(` ${pad("tracing coverage", 28)}${padStart(`${(coverage.ratio * 100).toFixed(1)}%`, 7)} (${coverage.unresolvedCalls} unresolved calls)`);
138
+ lines.push("");
139
+ lines.push(`${pad("module", 20)}${padStart("FP", 6)}${padStart("trans", 7)}${padStart("stores", 7)}${padStart("I", 6)} depends on`);
140
+ 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(", ")}`);
141
+ /**
142
+ * Reported, not scored. A cycle between two modules is a fact about the code
143
+ * that a number would hide, and the decision about it is the team's.
144
+ */
145
+ if (structure.mutualDependencies.length > 0) {
146
+ lines.push("");
147
+ lines.push("Mutual dependencies (cycle candidates)");
148
+ for (const [a, b] of structure.mutualDependencies) lines.push(` ${a} <-> ${b}`);
149
+ }
150
+ return lines.join("\n");
151
+ }
358
152
  function renderExplain(fn) {
359
153
  const lines = [];
360
154
  lines.push(`${fn.name} — ${fn.type}, ${fn.complexity} complexity, ${fn.points} FP`);
@@ -415,70 +209,40 @@ function renderDiff(diff) {
415
209
  */
416
210
  if (change === "changed") for (const [reason, split] of Object.entries(diff.changedByReason)) {
417
211
  if (split.count === 0) continue;
418
- lines.push(` ${pad(reason, 16)}${padStart(split.count, 3)} functions${padStart(split.points, 6)} FP`);
212
+ /**
213
+ * The effective factor, per reason. Printing only the `changed` factor
214
+ * hid that `implementation` — no change in type, DET or FTR — was being
215
+ * billed at the same rate as a functional one.
216
+ */
217
+ const effective = diff.reasonFactors[reason];
218
+ lines.push(` ${pad(reason, 16)}${padStart(split.count, 3)} functions${padStart(split.points, 6)} FP` + (effective === void 0 ? "" : ` × ${effective}`));
419
219
  }
420
220
  }
421
221
  lines.push("");
422
222
  lines.push(`Billable FP: ${diff.billable}`);
423
- const mudou = diff.entries.filter((entry) => entry.change !== "unchanged");
424
- if (mudou.length > 0) {
223
+ /**
224
+ * Before the per-function list, not after it.
225
+ *
226
+ * On a real pair of releases the list is over a hundred lines, and a caveat
227
+ * about how most of the total was priced sat below all of them. A warning that
228
+ * has to be scrolled to is not a warning — and this particular number becomes
229
+ * an invoice.
230
+ */
231
+ for (const warning of diff.warnings) {
232
+ lines.push("");
233
+ lines.push(`Warning: ${warning}`);
234
+ }
235
+ const moved = diff.entries.filter((entry) => entry.change !== "unchanged");
236
+ if (moved.length > 0) {
425
237
  lines.push("");
426
- for (const entry of mudou) {
238
+ for (const entry of moved) {
427
239
  const label = entry.reason ? `${entry.change} (${entry.reason})` : entry.change;
428
240
  lines.push(` ${pad(label, 26)} ${pad(entry.function.name.slice(0, 40), 41)}${padStart(entry.function.points, 4)} PF${movementOf(entry)}`);
429
241
  }
430
242
  }
431
- for (const warning of diff.warnings) {
432
- lines.push("");
433
- lines.push(`Warning: ${warning}`);
434
- }
435
243
  return lines.join("\n");
436
244
  }
437
245
  //#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
246
  //#region src/cli/runners.ts
483
247
  /**
484
248
  * An empty inventory is never a number.
@@ -556,6 +320,42 @@ async function runCount(options) {
556
320
  output: options.json ? JSON.stringify(count, null, 2) : renderCount(count)
557
321
  };
558
322
  }
323
+ /**
324
+ * Structure and conformance, from the same inventory as the count.
325
+ *
326
+ * These were implemented and tested and reachable from no front-end at all,
327
+ * which is the recurring defect of this package: a capability that exists,
328
+ * typed and covered, and that nobody can run. A metric nobody can run is not a
329
+ * metric.
330
+ */
331
+ async function runMetrics(options) {
332
+ const { config, notes } = await configFor(options.root);
333
+ const { inventory, count } = await analyze(options.root, config);
334
+ const empty = refuseIfEmpty(options.root, inventory.dataStores.length, inventory.entryPoints.length);
335
+ if (empty) return {
336
+ output: "",
337
+ notes,
338
+ errors: empty
339
+ };
340
+ const structure = measureStructure(inventory, count);
341
+ const conformance = measureConformance(inventory);
342
+ if (options.out) {
343
+ await writeFile(options.out, JSON.stringify({
344
+ source: count.source,
345
+ structure,
346
+ conformance
347
+ }, null, 2));
348
+ notes.push(`metrics written to ${options.out}`);
349
+ }
350
+ return {
351
+ notes,
352
+ output: options.json ? JSON.stringify({
353
+ source: count.source,
354
+ structure,
355
+ conformance
356
+ }, null, 2) : renderMetrics(structure, conformance, inventory.coverage)
357
+ };
358
+ }
559
359
  async function runExplain(options) {
560
360
  const { config, notes } = await configFor(options.root);
561
361
  const { count } = await analyze(options.root, config);
@@ -595,10 +395,14 @@ async function runDiff(options) {
595
395
  try {
596
396
  return {
597
397
  notes,
598
- output: renderDiff(diffCounts(previous, current, { labels: {
599
- from: options.previous,
600
- to
601
- } }))
398
+ output: renderDiff(diffCounts(previous, current, {
399
+ labels: {
400
+ from: options.previous,
401
+ to
402
+ },
403
+ factors: config.diff?.factors,
404
+ reasonFactors: config.diff?.reasonFactors
405
+ }))
602
406
  };
603
407
  } catch (error) {
604
408
  if (error instanceof IncomparableRulesetsError || error instanceof IncomparableSourcesError) return {
@@ -627,4 +431,4 @@ async function runCalibrate(options) {
627
431
  };
628
432
  }
629
433
  //#endregion
630
- export { runInventory as a, runExplain as i, runCount as n, ConfigLoadError as o, runDiff as r, printResult as s, runCalibrate as t };
434
+ 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,20 @@ 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 — and three more in 1.2.0: an open input object counting 1 instead of 0,
30
+ * `detFromSchema` no longer subtracting a placeholder that was not there, and a
31
+ * write through `related(…)` maintaining the related table.
32
+ *
33
+ * Without the bump, a baseline saved by the previous version compares cleanly
34
+ * against this one and bills the tool's own improvement as work done. The guard
35
+ * exists for exactly that, and only this constant arms it.
24
36
  */
25
- export declare const RULESET_VERSION = "1.0.0";
37
+ export declare const RULESET_VERSION = "1.2.0";
26
38
  export type CountInput = {
27
39
  app: AppContext;
28
40
  stores: CollectedDataStore[];
@@ -31,6 +43,12 @@ export type CountInput = {
31
43
  behaviors: Map<string, Behavior>;
32
44
  /** JSON Schema literals found in the code, for `detFromSchema` — §8 */
33
45
  jsonSchemas?: Map<string, DiscoveredSchema>;
46
+ /**
47
+ * Stores written anywhere in the application's code, reachable from an entry
48
+ * point or not — AFP §6.5.4 asks who MAINTAINS the store, and a job or a
49
+ * seeder is this application just as much as a route is.
50
+ */
51
+ writtenAnywhere?: Set<string>;
34
52
  };
35
53
  export type CountOptions = {
36
54
  retStrategy?: 'constant' | 'composition';
@@ -39,6 +57,8 @@ export type CountOptions = {
39
57
  boundary?: {
40
58
  infrastructure?: string[];
41
59
  externallyMaintained?: string[];
60
+ /** restores what the AFP naming filter caught by accident */
61
+ business?: string[];
42
62
  ignoreEntryPoints?: string[];
43
63
  };
44
64
  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>;