@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.
- package/CHANGELOG.md +144 -0
- package/README.md +103 -2
- package/build/calibration-8eV8CEix.js +403 -0
- package/build/commands/fp_metrics.d.ts +10 -0
- package/build/commands/main.d.ts +6 -5
- package/build/commands/main.js +48 -114
- package/build/decorate-D6enDn9D.js +24 -0
- package/build/fp_calibrate-iFAec0tA.js +25 -0
- package/build/fp_count-D21tQ_pv.js +22 -0
- package/build/fp_diff-D0pHGMgi.js +25 -0
- package/build/fp_explain-BwFs-LW-.js +24 -0
- package/build/fp_inventory-Bu6O1Nn0.js +18 -0
- package/build/fp_metrics-MGDppfSa.js +20 -0
- package/build/index.d.ts +30 -1
- package/build/index.js +5 -3
- package/build/{pipeline-BzP-ITGN.js → pipeline-CIAydCcT.js} +452 -54
- package/build/{resolvers-CU9HKYpn.js → resolvers-CRB6lXoo.js} +474 -207
- package/build/{runners-Bt8tbISi.js → runners-CmxNHuuq.js} +146 -342
- package/build/src/albrecht/counter.d.ts +21 -1
- package/build/src/albrecht/data_functions.d.ts +6 -0
- package/build/src/albrecht/diff.d.ts +19 -1
- package/build/src/cli/runners.d.ts +12 -0
- package/build/src/cli.js +11 -2
- package/build/src/define_config.d.ts +35 -1
- package/build/src/inventory/detectors/lucid.d.ts +8 -0
- package/build/src/inventory/graph/call_graph.d.ts +29 -0
- package/build/src/inventory/graph/noise.d.ts +13 -0
- package/build/src/inventory/resolvers/event_dispatch.d.ts +17 -0
- package/build/src/inventory/resolvers/index.js +1 -1
- package/build/src/inventory/resolvers/types.d.ts +35 -0
- package/build/src/inventory/sources/event_bindings.d.ts +44 -0
- package/build/src/metrics/structure.d.ts +16 -2
- package/build/src/pipeline.js +1 -1
- package/build/src/reporters/table.d.ts +11 -1
- package/build/src/types.d.ts +17 -0
- package/build/stubs/config.stub +27 -1
- package/package.json +2 -1
- package/build/define_config-DOqWyPwV.js +0 -19
- package/build/scripts/smoke_package.d.ts +0 -1
- package/build/tmp/probe.d.ts +0 -1
- package/build/tmp/probe_cli.d.ts +0 -1
- package/build/tmp/probe_cmp.d.ts +0 -1
- package/build/tmp/probe_count.d.ts +0 -1
- package/build/tmp/probe_data.d.ts +0 -1
- package/build/tmp/probe_diff.d.ts +0 -1
- package/build/tmp/probe_gap.d.ts +0 -1
- package/build/tmp/probe_graph.d.ts +0 -1
- package/build/tmp/probe_metrics.d.ts +0 -1
- package/build/tmp/probe_miss.d.ts +0 -1
- package/build/tmp/probe_names.d.ts +0 -1
- package/build/tmp/probe_nodata.d.ts +0 -1
- package/build/tmp/probe_one.d.ts +0 -1
- package/build/tmp/probe_perf.d.ts +0 -1
- package/build/tmp/probe_routes.d.ts +0 -1
- package/build/tmp/probe_unres.d.ts +0 -1
- package/build/tmp/probe_vazquez.d.ts +0 -1
- package/build/tsdown.config.d.ts +0 -2
|
@@ -1,305 +1,64 @@
|
|
|
1
|
-
import { n as
|
|
2
|
-
import {
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
this.
|
|
160
|
-
this.
|
|
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
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 (
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
|
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, {
|
|
599
|
-
|
|
600
|
-
|
|
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,
|
|
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.
|
|
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>;
|