@filipebraida/adonis-function-points 0.1.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/LICENSE.md +21 -0
- package/README.md +427 -0
- package/bin/cli.js +4 -0
- package/build/commands/fp_calibrate.d.ts +16 -0
- package/build/commands/fp_count.d.ts +11 -0
- package/build/commands/fp_diff.d.ts +16 -0
- package/build/commands/fp_explain.d.ts +15 -0
- package/build/commands/fp_inventory.d.ts +9 -0
- package/build/commands/main.d.ts +5 -0
- package/build/commands/main.js +120 -0
- package/build/commands/printer.d.ts +9 -0
- package/build/configure.d.ts +2 -0
- package/build/configure.js +10 -0
- package/build/define_config-DOqWyPwV.js +19 -0
- package/build/index.d.ts +5 -0
- package/build/index.js +4 -0
- package/build/pipeline-BzP-ITGN.js +2306 -0
- package/build/resolvers-CU9HKYpn.js +555 -0
- package/build/runners-Bt8tbISi.js +630 -0
- package/build/scripts/smoke_package.d.ts +1 -0
- package/build/src/albrecht/calibration.d.ts +62 -0
- package/build/src/albrecht/counter.d.ts +48 -0
- package/build/src/albrecht/data_functions.d.ts +32 -0
- package/build/src/albrecht/diff.d.ts +66 -0
- package/build/src/albrecht/index.d.ts +13 -0
- package/build/src/albrecht/tables.d.ts +19 -0
- package/build/src/albrecht/technical_filter.d.ts +25 -0
- package/build/src/albrecht/transactional_functions.d.ts +35 -0
- package/build/src/cli/load_config.d.ts +28 -0
- package/build/src/cli/print.d.ts +19 -0
- package/build/src/cli/runners.d.ts +52 -0
- package/build/src/cli.d.ts +19 -0
- package/build/src/cli.js +198 -0
- package/build/src/define_config.d.ts +137 -0
- package/build/src/inventory/app_context.d.ts +73 -0
- package/build/src/inventory/detectors/lucid.d.ts +77 -0
- package/build/src/inventory/graph/call_graph.d.ts +80 -0
- package/build/src/inventory/graph/noise.d.ts +9 -0
- package/build/src/inventory/index.d.ts +15 -0
- package/build/src/inventory/paths.d.ts +22 -0
- package/build/src/inventory/resolvers/action_object.d.ts +14 -0
- package/build/src/inventory/resolvers/index.d.ts +23 -0
- package/build/src/inventory/resolvers/index.js +2 -0
- package/build/src/inventory/resolvers/job_dispatch.d.ts +18 -0
- package/build/src/inventory/resolvers/module_function.d.ts +11 -0
- package/build/src/inventory/resolvers/property_service.d.ts +18 -0
- package/build/src/inventory/resolvers/same_class_method.d.ts +17 -0
- package/build/src/inventory/resolvers/static_service.d.ts +13 -0
- package/build/src/inventory/resolvers/transformer.d.ts +25 -0
- package/build/src/inventory/resolvers/types.d.ts +65 -0
- package/build/src/inventory/source.d.ts +39 -0
- package/build/src/inventory/sources/data_stores.d.ts +31 -0
- package/build/src/inventory/sources/json_schemas.d.ts +32 -0
- package/build/src/inventory/sources/routes_ast.d.ts +28 -0
- package/build/src/metrics/structure.d.ts +72 -0
- package/build/src/pipeline.d.ts +44 -0
- package/build/src/pipeline.js +2 -0
- package/build/src/reporters/table.d.ts +6 -0
- package/build/src/types.d.ts +256 -0
- package/build/src/types.js +1 -0
- package/build/stubs/config.stub +37 -0
- package/build/tmp/probe.d.ts +1 -0
- package/build/tmp/probe_cli.d.ts +1 -0
- package/build/tmp/probe_cmp.d.ts +1 -0
- package/build/tmp/probe_count.d.ts +1 -0
- package/build/tmp/probe_data.d.ts +1 -0
- package/build/tmp/probe_diff.d.ts +1 -0
- package/build/tmp/probe_gap.d.ts +1 -0
- package/build/tmp/probe_graph.d.ts +1 -0
- package/build/tmp/probe_metrics.d.ts +1 -0
- package/build/tmp/probe_miss.d.ts +1 -0
- package/build/tmp/probe_names.d.ts +1 -0
- package/build/tmp/probe_nodata.d.ts +1 -0
- package/build/tmp/probe_one.d.ts +1 -0
- package/build/tmp/probe_perf.d.ts +1 -0
- package/build/tmp/probe_routes.d.ts +1 -0
- package/build/tmp/probe_unres.d.ts +1 -0
- package/build/tmp/probe_vazquez.d.ts +1 -0
- package/build/tsdown.config.d.ts +2 -0
- package/package.json +133 -0
|
@@ -0,0 +1,630 @@
|
|
|
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";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
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
|
|
115
|
+
/**
|
|
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:
|
|
128
|
+
*
|
|
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.
|
|
150
|
+
*
|
|
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.
|
|
154
|
+
*/
|
|
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";
|
|
161
|
+
}
|
|
162
|
+
};
|
|
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
|
|
169
|
+
};
|
|
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.`);
|
|
205
|
+
}
|
|
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.");
|
|
208
|
+
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
|
+
}
|
|
264
|
+
};
|
|
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
|
+
}
|
|
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;
|
|
301
|
+
}
|
|
302
|
+
return totals;
|
|
303
|
+
}
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/reporters/table.ts
|
|
306
|
+
/**
|
|
307
|
+
* Text reports.
|
|
308
|
+
*
|
|
309
|
+
* They return a string instead of printing: that is what makes them testable
|
|
310
|
+
* without capturing output, and it keeps each ace command down to
|
|
311
|
+
* `this.logger.log(render(...))`.
|
|
312
|
+
*/
|
|
313
|
+
const pad = (value, width) => String(value).padEnd(width);
|
|
314
|
+
const padStart = (value, width) => String(value).padStart(width);
|
|
315
|
+
function renderCount(result) {
|
|
316
|
+
const lines = [];
|
|
317
|
+
lines.push(`Unadjusted count: ${result.totals.unadjusted} FP`);
|
|
318
|
+
lines.push(`Ruleset: ${result.ruleset}@${result.rulesetVersion}`);
|
|
319
|
+
lines.push("");
|
|
320
|
+
lines.push(`${pad("type", 6)}${padStart("n", 5)}${padStart("FP", 7)}`);
|
|
321
|
+
for (const [type, value] of Object.entries(result.totals.byType)) {
|
|
322
|
+
if (value.count === 0) continue;
|
|
323
|
+
lines.push(`${pad(type, 6)}${padStart(value.count, 5)}${padStart(value.points, 7)}`);
|
|
324
|
+
}
|
|
325
|
+
lines.push("");
|
|
326
|
+
lines.push(`${pad("function", 40)}${pad("type", 6)}${padStart("DET", 5)}${padStart("FTR", 5)}${padStart("FP", 5)}`);
|
|
327
|
+
for (const fn of result.functions) lines.push(`${pad(fn.name.slice(0, 39), 40)}${pad(fn.type, 6)}${padStart(fn.det, 5)}${padStart(fn.refs, 5)}${padStart(fn.points, 5)}`);
|
|
328
|
+
/**
|
|
329
|
+
* How much of the total stopped coming from the code.
|
|
330
|
+
*
|
|
331
|
+
* An override is legitimate where static analysis is blind, and poison as a
|
|
332
|
+
* habit: if it grows, the count comes from a spreadsheet and the tool loses
|
|
333
|
+
* its reason to exist. Printing the share is what keeps that visible.
|
|
334
|
+
*/
|
|
335
|
+
const overridden = result.functions.filter((fn) => fn.rationale.overrides?.length);
|
|
336
|
+
if (overridden.length > 0) {
|
|
337
|
+
const points = overridden.reduce((total, fn) => total + fn.points, 0);
|
|
338
|
+
const share = (points / (result.totals.unadjusted || 1) * 100).toFixed(1);
|
|
339
|
+
lines.push("");
|
|
340
|
+
lines.push(`Declared by override: ${overridden.length} function(s), ${points} FP (${share}% of the total)`);
|
|
341
|
+
for (const fn of overridden) for (const override of fn.rationale.overrides ?? []) lines.push(` ${fn.name} — ${override.reason}`);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Confidence comes right after the number, never hidden. AFP §6.5.3 requires
|
|
345
|
+
* whatever could not be traced to appear in the report.
|
|
346
|
+
*/
|
|
347
|
+
const { unresolvedCalls, entryPointsWithoutHandler, warnings } = result.confidence;
|
|
348
|
+
if (unresolvedCalls > 0 || entryPointsWithoutHandler > 0 || warnings.length > 0) {
|
|
349
|
+
lines.push("");
|
|
350
|
+
lines.push("Confidence:");
|
|
351
|
+
if (unresolvedCalls > 0) lines.push(` ${unresolvedCalls} unresolved calls`);
|
|
352
|
+
if (entryPointsWithoutHandler > 0) lines.push(` ${entryPointsWithoutHandler} entry points without a handler`);
|
|
353
|
+
for (const warning of warnings) lines.push(` ${warning}`);
|
|
354
|
+
}
|
|
355
|
+
return lines.join("\n");
|
|
356
|
+
}
|
|
357
|
+
/** `fp:explain`: a function's provenance, which is what supports a dispute */
|
|
358
|
+
function renderExplain(fn) {
|
|
359
|
+
const lines = [];
|
|
360
|
+
lines.push(`${fn.name} — ${fn.type}, ${fn.complexity} complexity, ${fn.points} FP`);
|
|
361
|
+
lines.push(`module: ${fn.module}`);
|
|
362
|
+
lines.push("");
|
|
363
|
+
lines.push(`Rule applied: ${fn.rationale.rule}`);
|
|
364
|
+
/**
|
|
365
|
+
* A declared count is marked on the line itself. Printing `DET = 60` above a
|
|
366
|
+
* list of five sources reads as an inconsistency, when in fact the number
|
|
367
|
+
* came from a person and the sources are what the analysis could still see.
|
|
368
|
+
*/
|
|
369
|
+
const overridden = (field) => fn.rationale.overrides?.some((o) => o.fields.includes(field)) ? " (declared by override)" : "";
|
|
370
|
+
lines.push("");
|
|
371
|
+
lines.push(`DET = ${fn.det}${overridden("det")}`);
|
|
372
|
+
for (const source of fn.rationale.detSources) lines.push(` ${source}`);
|
|
373
|
+
lines.push("");
|
|
374
|
+
lines.push(`${fn.type === "ILF" || fn.type === "EIF" ? "RET" : "FTR"} = ${fn.refs}${overridden("refs")}`);
|
|
375
|
+
for (const source of fn.rationale.refSources) lines.push(` ${source}`);
|
|
376
|
+
if (fn.rationale.trace?.length) {
|
|
377
|
+
lines.push("");
|
|
378
|
+
lines.push("Path walked:");
|
|
379
|
+
for (const step of fn.rationale.trace) {
|
|
380
|
+
const marca = step.writes ? " [writes]" : "";
|
|
381
|
+
lines.push(` ${" ".repeat(step.depth)}${step.file.split("/").slice(-2).join("/")}#${step.member ?? "handle"} (${step.by})${marca}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (fn.rationale.overrides?.length) {
|
|
385
|
+
lines.push("");
|
|
386
|
+
lines.push("Manual overrides:");
|
|
387
|
+
for (const override of fn.rationale.overrides) lines.push(` ${override.by}: ${override.reason}`);
|
|
388
|
+
}
|
|
389
|
+
return lines.join("\n");
|
|
390
|
+
}
|
|
391
|
+
/** `fp:diff`: added, changed and removed — what becomes an invoice */
|
|
392
|
+
/**
|
|
393
|
+
* What moved, for a size change. Shown beside the label so the reason is never
|
|
394
|
+
* a claim the reader has to take on trust.
|
|
395
|
+
*/
|
|
396
|
+
function movementOf(entry) {
|
|
397
|
+
if (entry.reason === "type" && entry.previous) return ` ${entry.previous.type} -> ${entry.function.type}`;
|
|
398
|
+
if (entry.reason !== "size" || !entry.previous) return "";
|
|
399
|
+
const parts = [];
|
|
400
|
+
if (entry.previous.det !== entry.function.det) parts.push(`DET ${entry.previous.det} -> ${entry.function.det}`);
|
|
401
|
+
if (entry.previous.refs !== entry.function.refs) parts.push(`FTR ${entry.previous.refs} -> ${entry.function.refs}`);
|
|
402
|
+
return parts.length > 0 ? ` ${parts.join(", ")}` : "";
|
|
403
|
+
}
|
|
404
|
+
function renderDiff(diff) {
|
|
405
|
+
const lines = [];
|
|
406
|
+
lines.push(`${diff.from} -> ${diff.to}`);
|
|
407
|
+
lines.push("");
|
|
408
|
+
for (const [change, total] of Object.entries(diff.totals)) {
|
|
409
|
+
if (total.count === 0) continue;
|
|
410
|
+
const factor = diff.factors[change];
|
|
411
|
+
lines.push(`${pad(change, 11)}${padStart(total.count, 4)} functions${padStart(total.points, 6)} FP × ${factor}`);
|
|
412
|
+
/**
|
|
413
|
+
* `changed` is usually the largest line on the invoice, and on its own it
|
|
414
|
+
* does not say whether it is paying for growth or for refactoring.
|
|
415
|
+
*/
|
|
416
|
+
if (change === "changed") for (const [reason, split] of Object.entries(diff.changedByReason)) {
|
|
417
|
+
if (split.count === 0) continue;
|
|
418
|
+
lines.push(` ${pad(reason, 16)}${padStart(split.count, 3)} functions${padStart(split.points, 6)} FP`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
lines.push("");
|
|
422
|
+
lines.push(`Billable FP: ${diff.billable}`);
|
|
423
|
+
const mudou = diff.entries.filter((entry) => entry.change !== "unchanged");
|
|
424
|
+
if (mudou.length > 0) {
|
|
425
|
+
lines.push("");
|
|
426
|
+
for (const entry of mudou) {
|
|
427
|
+
const label = entry.reason ? `${entry.change} (${entry.reason})` : entry.change;
|
|
428
|
+
lines.push(` ${pad(label, 26)} ${pad(entry.function.name.slice(0, 40), 41)}${padStart(entry.function.points, 4)} PF${movementOf(entry)}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
for (const warning of diff.warnings) {
|
|
432
|
+
lines.push("");
|
|
433
|
+
lines.push(`Warning: ${warning}`);
|
|
434
|
+
}
|
|
435
|
+
return lines.join("\n");
|
|
436
|
+
}
|
|
437
|
+
//#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
|
+
//#region src/cli/runners.ts
|
|
483
|
+
/**
|
|
484
|
+
* An empty inventory is never a number.
|
|
485
|
+
*
|
|
486
|
+
* Both front-ends can be pointed at a directory that is not the application —
|
|
487
|
+
* a monorepo root is the common case — and the symptom is not an error, it is
|
|
488
|
+
* a confident zero: 0 FP at 100% coverage, exit 0, CI green. Counting nothing
|
|
489
|
+
* and reporting nothing are indistinguishable from the outside, so the only
|
|
490
|
+
* safe answer is to refuse.
|
|
491
|
+
*/
|
|
492
|
+
function refuseIfEmpty(root, stores, entryPoints) {
|
|
493
|
+
if (stores > 0 || entryPoints > 0) return null;
|
|
494
|
+
return [
|
|
495
|
+
`found no data stores and no entry points in ${root}.`,
|
|
496
|
+
`That is not a count of zero — it is a failure to find the application.`,
|
|
497
|
+
`Check that --root points at the AdonisJS application (apps/<name> in a monorepo).`
|
|
498
|
+
];
|
|
499
|
+
}
|
|
500
|
+
/** every run says which configuration produced it — provenance starts here */
|
|
501
|
+
async function configFor(root) {
|
|
502
|
+
const { config, file } = await loadConfig(root);
|
|
503
|
+
const notes = file ? [`config: ${file}`] : ["config: defaults (no config/function_points.ts found)"];
|
|
504
|
+
return {
|
|
505
|
+
config: {
|
|
506
|
+
...config,
|
|
507
|
+
configFile: file
|
|
508
|
+
},
|
|
509
|
+
notes
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
async function runInventory(options) {
|
|
513
|
+
const { config, notes } = await configFor(options.root);
|
|
514
|
+
const { inventory } = await analyze(options.root, config);
|
|
515
|
+
const empty = refuseIfEmpty(options.root, inventory.dataStores.length, inventory.entryPoints.length);
|
|
516
|
+
if (empty) return {
|
|
517
|
+
output: "",
|
|
518
|
+
notes,
|
|
519
|
+
errors: empty
|
|
520
|
+
};
|
|
521
|
+
if (options.out) {
|
|
522
|
+
await writeFile(options.out, JSON.stringify(inventory, null, 2));
|
|
523
|
+
return {
|
|
524
|
+
output: `inventory written to ${options.out}`,
|
|
525
|
+
notes
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const { coverage } = inventory;
|
|
529
|
+
return {
|
|
530
|
+
notes,
|
|
531
|
+
output: [
|
|
532
|
+
`data stores: ${inventory.dataStores.length}`,
|
|
533
|
+
`entry points: ${coverage.entryPointsTotal}`,
|
|
534
|
+
`coverage: ${(coverage.ratio * 100).toFixed(1)}% (${coverage.unresolvedCalls} unresolved calls)`
|
|
535
|
+
].join("\n")
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
async function runCount(options) {
|
|
539
|
+
const { config, notes } = await configFor(options.root);
|
|
540
|
+
const { inventory, count } = await analyze(options.root, {
|
|
541
|
+
...config,
|
|
542
|
+
minCoverage: options.minCoverage ?? config.minCoverage
|
|
543
|
+
});
|
|
544
|
+
const empty = refuseIfEmpty(options.root, inventory.dataStores.length, inventory.entryPoints.length);
|
|
545
|
+
if (empty) return {
|
|
546
|
+
output: "",
|
|
547
|
+
notes,
|
|
548
|
+
errors: empty
|
|
549
|
+
};
|
|
550
|
+
if (options.out) {
|
|
551
|
+
await writeFile(options.out, JSON.stringify(count, null, 2));
|
|
552
|
+
notes.push(`count written to ${options.out}`);
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
notes,
|
|
556
|
+
output: options.json ? JSON.stringify(count, null, 2) : renderCount(count)
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
async function runExplain(options) {
|
|
560
|
+
const { config, notes } = await configFor(options.root);
|
|
561
|
+
const { count } = await analyze(options.root, config);
|
|
562
|
+
const matched = count.functions.filter((fn) => fn.name.toLowerCase().includes(options.name.toLowerCase()));
|
|
563
|
+
if (matched.length === 0) return {
|
|
564
|
+
output: "",
|
|
565
|
+
notes,
|
|
566
|
+
errors: [`no function matching "${options.name}"`]
|
|
567
|
+
};
|
|
568
|
+
return {
|
|
569
|
+
notes,
|
|
570
|
+
output: matched.map(renderExplain).join("\n\n" + "-".repeat(70) + "\n\n")
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
const readCount = async (file) => JSON.parse(await readFile(file, "utf8"));
|
|
574
|
+
/**
|
|
575
|
+
* Compares a saved count against the current tree, or against a second saved
|
|
576
|
+
* count.
|
|
577
|
+
*
|
|
578
|
+
* The two-file form is the shape CI has: a pipeline counts the base revision
|
|
579
|
+
* and the head revision, and neither of them is "the current working tree" by
|
|
580
|
+
* the time they are compared. Since the analyser needs nothing installed in the
|
|
581
|
+
* application, counting an older revision is a `git worktree` away.
|
|
582
|
+
*/
|
|
583
|
+
async function runDiff(options) {
|
|
584
|
+
const { config, notes } = await configFor(options.root);
|
|
585
|
+
const previous = await readCount(options.previous);
|
|
586
|
+
let current;
|
|
587
|
+
if (options.current) current = await readCount(options.current);
|
|
588
|
+
else current = (await analyze(options.root, config)).count;
|
|
589
|
+
const to = options.current ?? "current";
|
|
590
|
+
for (const [label, count] of [[options.previous, previous], [to, current]]) {
|
|
591
|
+
const source = count.source;
|
|
592
|
+
if (!source) continue;
|
|
593
|
+
notes.push(`${label}: ${source.app}` + (source.revision ? ` @ ${source.revision.slice(0, 8)}` : "") + (source.dirty ? " (dirty)" : ""));
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
return {
|
|
597
|
+
notes,
|
|
598
|
+
output: renderDiff(diffCounts(previous, current, { labels: {
|
|
599
|
+
from: options.previous,
|
|
600
|
+
to
|
|
601
|
+
} }))
|
|
602
|
+
};
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (error instanceof IncomparableRulesetsError || error instanceof IncomparableSourcesError) return {
|
|
605
|
+
output: "",
|
|
606
|
+
notes,
|
|
607
|
+
errors: [error.message]
|
|
608
|
+
};
|
|
609
|
+
throw error;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async function runCalibrate(options) {
|
|
613
|
+
const { config, notes } = await configFor(options.root);
|
|
614
|
+
const samples = parseSamples(await readFile(options.samples, "utf8"));
|
|
615
|
+
const { count } = await analyze(options.root, config);
|
|
616
|
+
const calibration = calibrate(count, samples);
|
|
617
|
+
const { overall } = calibration;
|
|
618
|
+
const lines = [
|
|
619
|
+
`samples: ${overall.samples} · manual ${overall.manualPoints} FP · automatic ${overall.automaticPoints} FP · deviation ${(overall.deviation * 100).toFixed(1)}%`,
|
|
620
|
+
`exact matches: ${overall.exactMatches}/${overall.samples}`,
|
|
621
|
+
""
|
|
622
|
+
];
|
|
623
|
+
for (const item of calibration.byType) lines.push(`${item.type.padEnd(4)} n=${String(item.samples).padStart(3)} factor ${item.factor.toFixed(3)} · mean deviation ${item.meanAbsoluteDeviation} FP`);
|
|
624
|
+
return {
|
|
625
|
+
notes: [...notes, ...calibration.warnings],
|
|
626
|
+
output: lines.join("\n")
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
//#endregion
|
|
630
|
+
export { runInventory as a, runExplain as i, runCount as n, ConfigLoadError as o, runDiff as r, printResult as s, runCalibrate as t };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CountResult, FunctionType } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Calibration: measuring the counter's bias against manual counts.
|
|
4
|
+
*
|
|
5
|
+
* This is what turns a percentage of deviation into a usable number. The
|
|
6
|
+
* premise was never exactness against a certified counter — it was
|
|
7
|
+
* **repeatability**, which is what makes calibration possible: systematic error
|
|
8
|
+
* can be calibrated away, variance between human counters cannot.
|
|
9
|
+
*
|
|
10
|
+
* AFP takes the same position on purpose:
|
|
11
|
+
*
|
|
12
|
+
* "This specification prioritizes repeatability and consistency over
|
|
13
|
+
* consistency with the IFPUG CPM counting guidelines." — AFP §6.1
|
|
14
|
+
*
|
|
15
|
+
* What this module does NOT do: apply the factor. Calibrating is a decision for
|
|
16
|
+
* whoever signs the contract, and a factor applied silently would stop the
|
|
17
|
+
* count from being reproducible from the source.
|
|
18
|
+
*/
|
|
19
|
+
export type CalibrationSample = {
|
|
20
|
+
/** function identity, as it appears in the count */
|
|
21
|
+
function: string;
|
|
22
|
+
/** function points from the manual count */
|
|
23
|
+
manual: number;
|
|
24
|
+
};
|
|
25
|
+
export type TypeCalibration = {
|
|
26
|
+
type: FunctionType;
|
|
27
|
+
samples: number;
|
|
28
|
+
manualPoints: number;
|
|
29
|
+
automaticPoints: number;
|
|
30
|
+
/**
|
|
31
|
+
* Factor bringing the automatic count towards the manual one:
|
|
32
|
+
* `manual / automatic`.
|
|
33
|
+
*
|
|
34
|
+
* Greater than 1 means the counter **underestimates** this type.
|
|
35
|
+
*/
|
|
36
|
+
factor: number;
|
|
37
|
+
/** mean absolute deviation, in function points per function */
|
|
38
|
+
meanAbsoluteDeviation: number;
|
|
39
|
+
exactMatches: number;
|
|
40
|
+
};
|
|
41
|
+
export type Calibration = {
|
|
42
|
+
byType: TypeCalibration[];
|
|
43
|
+
overall: {
|
|
44
|
+
samples: number;
|
|
45
|
+
manualPoints: number;
|
|
46
|
+
automaticPoints: number;
|
|
47
|
+
/** relative deviation of the total, signed: positive means automatic is larger */
|
|
48
|
+
deviation: number;
|
|
49
|
+
exactMatches: number;
|
|
50
|
+
};
|
|
51
|
+
/** samples that matched no counted function */
|
|
52
|
+
unmatched: string[];
|
|
53
|
+
warnings: string[];
|
|
54
|
+
};
|
|
55
|
+
export declare function calibrate(result: CountResult, samples: CalibrationSample[]): Calibration;
|
|
56
|
+
/**
|
|
57
|
+
* Reads samples from CSV: `function,fp` with a header row.
|
|
58
|
+
*
|
|
59
|
+
* Deliberately plain. A metrics analyst exports from a spreadsheet, and
|
|
60
|
+
* demanding JSON would add friction where none is needed.
|
|
61
|
+
*/
|
|
62
|
+
export declare function parseSamples(csv: string): CalibrationSample[];
|