@filipebraida/adonis-function-points 0.2.0 → 0.4.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 +105 -0
- package/README.md +1 -1
- package/build/calibration-8eV8CEix.js +403 -0
- package/build/commands/main.js +6 -6
- package/build/{fp_calibrate-Cm079xWL.js → fp_calibrate-DLZP5bUp.js} +1 -1
- package/build/{fp_count-CfcXPuj5.js → fp_count-DNSwaLUD.js} +1 -1
- package/build/{fp_diff-DE_t3twv.js → fp_diff-CCKxqGKh.js} +1 -1
- package/build/{fp_explain-MKyEoi0h.js → fp_explain-Dpiby5Qx.js} +1 -1
- package/build/{fp_inventory-DSrCVhEy.js → fp_inventory-DHwZzEQf.js} +1 -1
- package/build/{fp_metrics-BUWj9dLw.js → fp_metrics-M84qLYaE.js} +1 -1
- package/build/index.d.ts +28 -0
- package/build/index.js +4 -3
- package/build/{pipeline-DySlMWcN.js → pipeline-Dm9KvUvF.js} +424 -48
- package/build/{resolvers-MFjRl2ef.js → resolvers-vMahHkAd.js} +356 -291
- package/build/{runners-DpMd-yZM.js → runners-DetZGfh5.js} +14 -388
- package/build/src/albrecht/counter.d.ts +9 -5
- package/build/src/albrecht/transactional_functions.d.ts +9 -0
- package/build/src/cli.js +2 -2
- package/build/src/define_config.d.ts +26 -1
- package/build/src/inventory/detectors/lucid.d.ts +8 -0
- package/build/src/inventory/graph/call_graph.d.ts +2 -0
- package/build/src/inventory/paths.d.ts +17 -0
- package/build/src/inventory/resolvers/index.js +1 -1
- package/build/src/inventory/source.d.ts +8 -1
- package/build/src/pipeline.js +1 -1
- package/build/src/types.d.ts +7 -0
- package/build/stubs/config.stub +7 -1
- package/package.json +1 -1
- package/build/define_config-DOqWyPwV.js +0 -19
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { n as
|
|
2
|
-
import {
|
|
3
|
-
import { n as analyze } from "./pipeline-
|
|
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 { l as toPosix } from "./resolvers-vMahHkAd.js";
|
|
3
|
+
import { n as analyze } from "./pipeline-Dm9KvUvF.js";
|
|
4
4
|
import { readFile, writeFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
@@ -61,390 +61,6 @@ function printResult(result, printer) {
|
|
|
61
61
|
return 0;
|
|
62
62
|
}
|
|
63
63
|
//#endregion
|
|
64
|
-
//#region src/albrecht/calibration.ts
|
|
65
|
-
/**
|
|
66
|
-
* Minimum sample size per type for a factor to mean anything.
|
|
67
|
-
*
|
|
68
|
-
* Below this, the "factor" is noise from one or two functions, and using it to
|
|
69
|
-
* correct a count is worse than not correcting at all.
|
|
70
|
-
*/
|
|
71
|
-
const MIN_SAMPLES_PER_TYPE = 10;
|
|
72
|
-
function calibrate(result, samples) {
|
|
73
|
-
const byIdentity = new Map(result.functions.map((fn) => [fn.name, fn]));
|
|
74
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
75
|
-
const unmatched = [];
|
|
76
|
-
let manualTotal = 0;
|
|
77
|
-
let automaticTotal = 0;
|
|
78
|
-
let exactTotal = 0;
|
|
79
|
-
for (const sample of samples) {
|
|
80
|
-
const counted = byIdentity.get(sample.function);
|
|
81
|
-
if (!counted) {
|
|
82
|
-
unmatched.push(sample.function);
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
const bucket = grouped.get(counted.type) ?? {
|
|
86
|
-
manual: 0,
|
|
87
|
-
automatic: 0,
|
|
88
|
-
deviations: [],
|
|
89
|
-
exact: 0
|
|
90
|
-
};
|
|
91
|
-
bucket.manual += sample.manual;
|
|
92
|
-
bucket.automatic += counted.points;
|
|
93
|
-
bucket.deviations.push(Math.abs(counted.points - sample.manual));
|
|
94
|
-
if (counted.points === sample.manual) bucket.exact++;
|
|
95
|
-
grouped.set(counted.type, bucket);
|
|
96
|
-
manualTotal += sample.manual;
|
|
97
|
-
automaticTotal += counted.points;
|
|
98
|
-
if (counted.points === sample.manual) exactTotal++;
|
|
99
|
-
}
|
|
100
|
-
const byType = [...grouped.entries()].map(([type, bucket]) => ({
|
|
101
|
-
type,
|
|
102
|
-
samples: bucket.deviations.length,
|
|
103
|
-
manualPoints: bucket.manual,
|
|
104
|
-
automaticPoints: bucket.automatic,
|
|
105
|
-
factor: bucket.automatic === 0 ? 1 : round$1(bucket.manual / bucket.automatic),
|
|
106
|
-
meanAbsoluteDeviation: round$1(bucket.deviations.reduce((total, value) => total + value, 0) / bucket.deviations.length),
|
|
107
|
-
exactMatches: bucket.exact
|
|
108
|
-
})).sort((a, b) => a.type.localeCompare(b.type));
|
|
109
|
-
const warnings = [];
|
|
110
|
-
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.`);
|
|
111
|
-
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".`);
|
|
112
|
-
const matched = samples.length - unmatched.length;
|
|
113
|
-
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.");
|
|
114
|
-
return {
|
|
115
|
-
byType,
|
|
116
|
-
overall: {
|
|
117
|
-
samples: matched,
|
|
118
|
-
manualPoints: manualTotal,
|
|
119
|
-
automaticPoints: automaticTotal,
|
|
120
|
-
deviation: manualTotal === 0 ? 0 : round$1((automaticTotal - manualTotal) / manualTotal),
|
|
121
|
-
exactMatches: exactTotal
|
|
122
|
-
},
|
|
123
|
-
unmatched,
|
|
124
|
-
warnings
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
const round$1 = (value) => Math.round(value * 1e3) / 1e3;
|
|
128
|
-
/**
|
|
129
|
-
* Reads samples from CSV: `function,fp` with a header row.
|
|
130
|
-
*
|
|
131
|
-
* Deliberately plain. A metrics analyst exports from a spreadsheet, and
|
|
132
|
-
* demanding JSON would add friction where none is needed.
|
|
133
|
-
*/
|
|
134
|
-
function parseSamples(csv) {
|
|
135
|
-
const samples = [];
|
|
136
|
-
for (const [index, line] of csv.split(/\r?\n/).entries()) {
|
|
137
|
-
const trimmed = line.trim();
|
|
138
|
-
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
139
|
-
const separator = trimmed.lastIndexOf(",");
|
|
140
|
-
if (separator === -1) continue;
|
|
141
|
-
const name = trimmed.slice(0, separator).trim().replace(/^"|"$/g, "");
|
|
142
|
-
const manual = Number(trimmed.slice(separator + 1).trim());
|
|
143
|
-
if (!Number.isFinite(manual)) {
|
|
144
|
-
if (index > 0 && ![
|
|
145
|
-
"function",
|
|
146
|
-
"funcao",
|
|
147
|
-
"função"
|
|
148
|
-
].includes(name)) throw new Error(`line ${index + 1}: unreadable function points in "${trimmed}"`);
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
samples.push({
|
|
152
|
-
function: name,
|
|
153
|
-
manual
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
return samples;
|
|
157
|
-
}
|
|
158
|
-
//#endregion
|
|
159
|
-
//#region src/albrecht/diff.ts
|
|
160
|
-
/**
|
|
161
|
-
* Added, changed and removed functions between two counts — what becomes an
|
|
162
|
-
* invoice.
|
|
163
|
-
*
|
|
164
|
-
* Normative base: **OMG Automated Enhancement Points 1.0**, the sibling of AFP,
|
|
165
|
-
* written to size maintenance between two revisions.
|
|
166
|
-
*
|
|
167
|
-
* "Each Artifact shall be analyzed in both revisions to determine whether it
|
|
168
|
-
* is: Added — when it exists in revision ToRevision while it didn't exist in
|
|
169
|
-
* FromRevision. […] Modified — when it exists in both revisions but whose
|
|
170
|
-
* source code changed." — AEP §6.3
|
|
171
|
-
*
|
|
172
|
-
* Two decisions make this workable:
|
|
173
|
-
*
|
|
174
|
-
* 1. **It operates on two saved counts**, never on two checkouts. Booting the
|
|
175
|
-
* older revision, with possibly different dependencies, is the kind of
|
|
176
|
-
* problem not worth solving.
|
|
177
|
-
* 2. **It refuses to compare different rule sets.** If the rules changed in
|
|
178
|
-
* between, the difference measures the rule change, not the work — and the
|
|
179
|
-
* result would go into an invoice.
|
|
180
|
-
*/
|
|
181
|
-
var IncomparableRulesetsError = class extends Error {
|
|
182
|
-
constructor(from, to) {
|
|
183
|
-
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.`);
|
|
184
|
-
this.name = "IncomparableRulesetsError";
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
const AEP_FACTORS = {
|
|
188
|
-
added: 1,
|
|
189
|
-
changed: 1,
|
|
190
|
-
removed: .4,
|
|
191
|
-
unchanged: 0
|
|
192
|
-
};
|
|
193
|
-
/**
|
|
194
|
-
* Two counts of DIFFERENT applications compare cleanly and mean nothing.
|
|
195
|
-
*
|
|
196
|
-
* The ruleset guard already refuses counts produced by different rules. This
|
|
197
|
-
* refuses counts produced over different subjects, which is the same class of
|
|
198
|
-
* error and the easier one to make in CI, where both files arrive as paths.
|
|
199
|
-
*/
|
|
200
|
-
var IncomparableSourcesError = class extends Error {
|
|
201
|
-
constructor(from, to) {
|
|
202
|
-
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.`);
|
|
203
|
-
this.from = from;
|
|
204
|
-
this.to = to;
|
|
205
|
-
this.name = "IncomparableSourcesError";
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
function diffCounts(from, to, options = {}) {
|
|
209
|
-
if (from.rulesetVersion !== to.rulesetVersion || from.ruleset !== to.ruleset) throw new IncomparableRulesetsError(`${from.ruleset}@${from.rulesetVersion}`, `${to.ruleset}@${to.rulesetVersion}`);
|
|
210
|
-
if (from.source && to.source && from.source.app !== to.source.app) throw new IncomparableSourcesError(from.source.app, to.source.app);
|
|
211
|
-
const factors = {
|
|
212
|
-
...AEP_FACTORS,
|
|
213
|
-
...options.factors
|
|
214
|
-
};
|
|
215
|
-
const reasonFactors = options.reasonFactors ?? {};
|
|
216
|
-
/** the factor a single entry is billed at, which is the per-reason one when set */
|
|
217
|
-
const factorFor = (entry) => entry.change === "changed" && entry.reason ? reasonFactors[entry.reason] ?? factors.changed : factors[entry.change];
|
|
218
|
-
const before = new Map(from.functions.map((fn) => [fn.id, fn]));
|
|
219
|
-
const after = new Map(to.functions.map((fn) => [fn.id, fn]));
|
|
220
|
-
const entries = [];
|
|
221
|
-
for (const [id, fn] of after) {
|
|
222
|
-
const previous = before.get(id);
|
|
223
|
-
if (!previous) {
|
|
224
|
-
entries.push({
|
|
225
|
-
function: fn,
|
|
226
|
-
change: "added"
|
|
227
|
-
});
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
const reason = reasonBetween(previous, fn);
|
|
231
|
-
entries.push({
|
|
232
|
-
function: fn,
|
|
233
|
-
change: reason ? "changed" : "unchanged",
|
|
234
|
-
previous,
|
|
235
|
-
...reason ? { reason } : {}
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
for (const [id, fn] of before) if (!after.has(id)) entries.push({
|
|
239
|
-
function: fn,
|
|
240
|
-
change: "removed"
|
|
241
|
-
});
|
|
242
|
-
const warnings = [];
|
|
243
|
-
/**
|
|
244
|
-
* Provenance warnings. None of them stops the comparison — they qualify the
|
|
245
|
-
* number that comes out of it, which is what goes onto an invoice.
|
|
246
|
-
*/
|
|
247
|
-
for (const [side, count] of [["from", from], ["to", to]]) {
|
|
248
|
-
if (!count.source) {
|
|
249
|
-
warnings.push(`the "${side}" count records no source: it cannot be tied to a revision, so this difference cannot be reproduced or audited later.`);
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
if (count.source.dirty) warnings.push(`the "${side}" count was taken over a tree with uncommitted changes (${count.source.app}${count.source.revision ? ` at ${count.source.revision.slice(0, 8)}` : ""}): no revision reproduces it.`);
|
|
253
|
-
}
|
|
254
|
-
if (from.source?.revision && from.source.revision === to.source?.revision && !from.source.dirty && !to.source.dirty) warnings.push(`both counts are of the same revision (${from.source.revision.slice(0, 8)}): any difference here comes from the tool or its configuration, not from work done.`);
|
|
255
|
-
/**
|
|
256
|
-
* Quantified, because the generic sentence was not actionable.
|
|
257
|
-
*
|
|
258
|
-
* On a real pair of releases this warning sat under 118 lines of per-function
|
|
259
|
-
* output, saying only that the factor was pinned. What a client disputes is
|
|
260
|
-
* the amount, so the amount is what it has to say.
|
|
261
|
-
*/
|
|
262
|
-
const changedPoints = entries.filter((entry) => entry.change === "changed").reduce((total, entry) => total + entry.function.points, 0);
|
|
263
|
-
if (changedPoints > 0 && factors.changed === 1 && reasonFactors.implementation === void 0) {
|
|
264
|
-
const byReason = changedByReasonOf(entries);
|
|
265
|
-
const billable = round2(entries.reduce((total, entry) => total + entry.function.points * factorFor(entry), 0));
|
|
266
|
-
const share = billable === 0 ? 0 : Math.round(changedPoints / billable * 100);
|
|
267
|
-
warnings.push(`${changedPoints} of ${billable} billable FP (${share}%) are modified functions at a factor pinned to 1. AEP grades it from 0.25 to 1.75 through Effort Complexity variation, which needs cyclomatic complexity — not measured yet. Of those, ${byReason.implementation.points} FP changed implementation only (same type, DET and FTR): set \`reasonFactors\` to price that differently.`);
|
|
268
|
-
}
|
|
269
|
-
return {
|
|
270
|
-
from: options.labels?.from ?? "previous",
|
|
271
|
-
to: options.labels?.to ?? "current",
|
|
272
|
-
entries: entries.sort(byChangeThenName),
|
|
273
|
-
totals: totalsOf(entries),
|
|
274
|
-
changedByReason: changedByReasonOf(entries),
|
|
275
|
-
/**
|
|
276
|
-
* Rounded to cents at the source, not at the print.
|
|
277
|
-
*
|
|
278
|
-
* `485.00000000000006` appeared on the first real diff. It is arithmetically
|
|
279
|
-
* the same number and it is not the same document: this value is quoted in
|
|
280
|
-
* an invoice, and a reader who sees that tail stops trusting the rest.
|
|
281
|
-
*/
|
|
282
|
-
billable: round2(entries.reduce((total, entry) => total + entry.function.points * factorFor(entry), 0)),
|
|
283
|
-
factors,
|
|
284
|
-
reasonFactors,
|
|
285
|
-
warnings
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
/**
|
|
289
|
-
* What counts as a change.
|
|
290
|
-
*
|
|
291
|
-
* A change in the implementation scope (checksum of the normalised AST) **or**
|
|
292
|
-
* in the functional size. Formatting and comments do not count: the hash
|
|
293
|
-
* already ignores them.
|
|
294
|
-
*
|
|
295
|
-
* Renaming a route does not show up here because identity is
|
|
296
|
-
* `(verb, pattern)` — and neither does moving a controller between modules,
|
|
297
|
-
* which is implementation.
|
|
298
|
-
*/
|
|
299
|
-
/**
|
|
300
|
-
* Why the function changed, or null when it did not.
|
|
301
|
-
*
|
|
302
|
-
* Reported by the most consequential cause: a reclassification usually moves
|
|
303
|
-
* the size too, and naming the type is the fact that explains the rest. The
|
|
304
|
-
* rendered line carries the DET and FTR movement, so nothing is hidden behind
|
|
305
|
-
* the label.
|
|
306
|
-
*/
|
|
307
|
-
function reasonBetween(previous, current) {
|
|
308
|
-
if (previous.type !== current.type) return "type";
|
|
309
|
-
if (previous.det !== current.det || previous.refs !== current.refs) return "size";
|
|
310
|
-
if ((previous.scopeHash ?? "") !== (current.scopeHash ?? "")) return "implementation";
|
|
311
|
-
return null;
|
|
312
|
-
}
|
|
313
|
-
/**
|
|
314
|
-
* Where an invoice actually comes from.
|
|
315
|
-
*
|
|
316
|
-
* `changed` is usually the largest line, and until this split it said nothing
|
|
317
|
-
* about whether it was paying for growth or for refactoring.
|
|
318
|
-
*/
|
|
319
|
-
function changedByReasonOf(entries) {
|
|
320
|
-
const byReason = {
|
|
321
|
-
type: {
|
|
322
|
-
count: 0,
|
|
323
|
-
points: 0
|
|
324
|
-
},
|
|
325
|
-
size: {
|
|
326
|
-
count: 0,
|
|
327
|
-
points: 0
|
|
328
|
-
},
|
|
329
|
-
implementation: {
|
|
330
|
-
count: 0,
|
|
331
|
-
points: 0
|
|
332
|
-
}
|
|
333
|
-
};
|
|
334
|
-
for (const entry of entries) {
|
|
335
|
-
if (entry.change !== "changed" || !entry.reason) continue;
|
|
336
|
-
byReason[entry.reason].count++;
|
|
337
|
-
byReason[entry.reason].points += entry.function.points;
|
|
338
|
-
}
|
|
339
|
-
return byReason;
|
|
340
|
-
}
|
|
341
|
-
const ORDER = {
|
|
342
|
-
added: 0,
|
|
343
|
-
changed: 1,
|
|
344
|
-
removed: 2,
|
|
345
|
-
unchanged: 3
|
|
346
|
-
};
|
|
347
|
-
const byChangeThenName = (a, b) => ORDER[a.change] - ORDER[b.change] || a.function.name.localeCompare(b.function.name);
|
|
348
|
-
function totalsOf(entries) {
|
|
349
|
-
const totals = {
|
|
350
|
-
added: {
|
|
351
|
-
count: 0,
|
|
352
|
-
points: 0
|
|
353
|
-
},
|
|
354
|
-
changed: {
|
|
355
|
-
count: 0,
|
|
356
|
-
points: 0
|
|
357
|
-
},
|
|
358
|
-
removed: {
|
|
359
|
-
count: 0,
|
|
360
|
-
points: 0
|
|
361
|
-
},
|
|
362
|
-
unchanged: {
|
|
363
|
-
count: 0,
|
|
364
|
-
points: 0
|
|
365
|
-
}
|
|
366
|
-
};
|
|
367
|
-
for (const entry of entries) {
|
|
368
|
-
totals[entry.change].count++;
|
|
369
|
-
totals[entry.change].points += entry.function.points;
|
|
370
|
-
}
|
|
371
|
-
return totals;
|
|
372
|
-
}
|
|
373
|
-
/** two decimals: this number is quoted in an invoice */
|
|
374
|
-
const round2 = (value) => Math.round(value * 100) / 100;
|
|
375
|
-
//#endregion
|
|
376
|
-
//#region src/metrics/structure.ts
|
|
377
|
-
function measureStructure(inventory, count) {
|
|
378
|
-
/** store -> module that declares it */
|
|
379
|
-
const storeModule = new Map(inventory.dataStores.map((store) => [store.name, store.module]));
|
|
380
|
-
/** entry point -> module */
|
|
381
|
-
const entryModule = new Map(inventory.entryPoints.map((entry) => [entry.id, entry.module]));
|
|
382
|
-
const modules = new Set([...storeModule.values(), ...entryModule.values()]);
|
|
383
|
-
const dependsOn = /* @__PURE__ */ new Map();
|
|
384
|
-
for (const module of modules) dependsOn.set(module, /* @__PURE__ */ new Set());
|
|
385
|
-
/**
|
|
386
|
-
* The dependency that matters is USE, not import: module A depends on B when
|
|
387
|
-
* a transaction of A reaches a store declared in B. A type-only import
|
|
388
|
-
* creates no functional coupling.
|
|
389
|
-
*/
|
|
390
|
-
for (const behavior of inventory.behaviors) {
|
|
391
|
-
const from = entryModule.get(behavior.entryPointId);
|
|
392
|
-
if (!from) continue;
|
|
393
|
-
for (const store of behavior.touches) {
|
|
394
|
-
const to = storeModule.get(store);
|
|
395
|
-
if (!to || to === from) continue;
|
|
396
|
-
dependsOn.get(from)?.add(to);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
const dependedOnBy = /* @__PURE__ */ new Map();
|
|
400
|
-
for (const module of modules) dependedOnBy.set(module, /* @__PURE__ */ new Set());
|
|
401
|
-
for (const [from, targets] of dependsOn) for (const to of targets) dependedOnBy.get(to)?.add(from);
|
|
402
|
-
const transactionsPerModule = /* @__PURE__ */ new Map();
|
|
403
|
-
for (const module of entryModule.values()) transactionsPerModule.set(module, (transactionsPerModule.get(module) ?? 0) + 1);
|
|
404
|
-
const storesPerModule = /* @__PURE__ */ new Map();
|
|
405
|
-
for (const module of storeModule.values()) storesPerModule.set(module, (storesPerModule.get(module) ?? 0) + 1);
|
|
406
|
-
const moduleMetrics = [...modules].map((module) => {
|
|
407
|
-
const ce = dependsOn.get(module).size;
|
|
408
|
-
const ca = dependedOnBy.get(module).size;
|
|
409
|
-
return {
|
|
410
|
-
module,
|
|
411
|
-
functionPoints: count.totals.byModule[module] ?? 0,
|
|
412
|
-
transactions: transactionsPerModule.get(module) ?? 0,
|
|
413
|
-
dataStores: storesPerModule.get(module) ?? 0,
|
|
414
|
-
dependsOn: [...dependsOn.get(module)].sort(),
|
|
415
|
-
dependedOnBy: [...dependedOnBy.get(module)].sort(),
|
|
416
|
-
instability: ca + ce === 0 ? 0 : round(ce / (ca + ce))
|
|
417
|
-
};
|
|
418
|
-
}).sort((a, b) => b.functionPoints - a.functionPoints);
|
|
419
|
-
const mutual = [];
|
|
420
|
-
for (const [from, targets] of dependsOn) for (const to of targets) if (from < to && dependsOn.get(to)?.has(from)) mutual.push([from, to]);
|
|
421
|
-
const stores = inventory.dataStores.length || 1;
|
|
422
|
-
return {
|
|
423
|
-
modules: moduleMetrics,
|
|
424
|
-
mutualDependencies: mutual.sort(),
|
|
425
|
-
pointsPerDataStore: round(count.totals.unadjusted / stores),
|
|
426
|
-
transactionsPerDataStore: round(inventory.entryPoints.length / stores)
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
function measureConformance(inventory) {
|
|
430
|
-
const behaviors = inventory.behaviors;
|
|
431
|
-
const takesInput = behaviors.filter((behavior) => behavior.inputFields.length > 0 || behavior.requestFields.length > 0 || behavior.opaqueRequest);
|
|
432
|
-
const withValidator = takesInput.filter((behavior) => behavior.inputFields.length > 0);
|
|
433
|
-
const withHandler = inventory.entryPoints.filter((entry) => entry.handler !== null);
|
|
434
|
-
const reached = new Set(behaviors.flatMap((behavior) => behavior.touches));
|
|
435
|
-
return {
|
|
436
|
-
inputsWithValidator: ratio(withValidator.length, takesInput.length),
|
|
437
|
-
entryPointsWithHandler: ratio(withHandler.length, inventory.entryPoints.length),
|
|
438
|
-
dataStoresReached: ratio(reached.size, inventory.dataStores.length)
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
const ratio = (ok, total) => ({
|
|
442
|
-
ok,
|
|
443
|
-
total,
|
|
444
|
-
ratio: total === 0 ? 1 : round(ok / total)
|
|
445
|
-
});
|
|
446
|
-
const round = (value) => Math.round(value * 1e3) / 1e3;
|
|
447
|
-
//#endregion
|
|
448
64
|
//#region src/reporters/table.ts
|
|
449
65
|
/**
|
|
450
66
|
* Text reports.
|
|
@@ -743,7 +359,17 @@ async function runMetrics(options) {
|
|
|
743
359
|
async function runExplain(options) {
|
|
744
360
|
const { config, notes } = await configFor(options.root);
|
|
745
361
|
const { count } = await analyze(options.root, config);
|
|
746
|
-
|
|
362
|
+
/**
|
|
363
|
+
* An exact name wins outright; the substring search is the fallback.
|
|
364
|
+
*
|
|
365
|
+
* `fp:explain "POST /orders/:param/submit"` returned four functions, because
|
|
366
|
+
* `/submit`, `/submit-ready` and `/submit-ready/return` all contain it. Asking
|
|
367
|
+
* about a function by its exact name and being handed its neighbours makes the
|
|
368
|
+
* command useless for the thing it exists for — defending one number.
|
|
369
|
+
*/
|
|
370
|
+
const wanted = options.name.toLowerCase();
|
|
371
|
+
const exact = count.functions.filter((fn) => fn.name.toLowerCase() === wanted);
|
|
372
|
+
const matched = exact.length > 0 ? exact : count.functions.filter((fn) => fn.name.toLowerCase().includes(wanted));
|
|
747
373
|
if (matched.length === 0) return {
|
|
748
374
|
output: "",
|
|
749
375
|
notes,
|
|
@@ -23,14 +23,18 @@ export declare const RULESET = "afp";
|
|
|
23
23
|
* change rather than the work.
|
|
24
24
|
*
|
|
25
25
|
* It must be bumped by ANY change that moves the number for unchanged code, and
|
|
26
|
-
* that is easy to forget
|
|
26
|
+
* that is easy to forget. Four such changes landed in 1.1.0 — maintenance read
|
|
27
27
|
* across the whole project rather than from routes alone, a job followed into
|
|
28
28
|
* `process`, an event followed into its listeners, and `request.input(…)` counted
|
|
29
|
-
* as a DET
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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.
|
|
32
36
|
*/
|
|
33
|
-
export declare const RULESET_VERSION = "1.
|
|
37
|
+
export declare const RULESET_VERSION = "1.3.0";
|
|
34
38
|
export type CountInput = {
|
|
35
39
|
app: AppContext;
|
|
36
40
|
stores: CollectedDataStore[];
|
|
@@ -31,5 +31,14 @@ export type TransactionOptions = {
|
|
|
31
31
|
messageDet: number;
|
|
32
32
|
tables: Record<FunctionType, ComplexityTable>;
|
|
33
33
|
weights: Record<FunctionType, Record<Complexity, number>>;
|
|
34
|
+
/**
|
|
35
|
+
* Application root, used only to relativise the paths that LEAVE in the trace.
|
|
36
|
+
*
|
|
37
|
+
* `CountSource.app` is documented as never being the absolute path, because it
|
|
38
|
+
* says where the machine keeps its files and travels with every count sent
|
|
39
|
+
* anywhere. The trace shipped the absolute path regardless — 858 times in a
|
|
40
|
+
* single production count, which is most of the artefact a ledger would store.
|
|
41
|
+
*/
|
|
42
|
+
root: string;
|
|
34
43
|
};
|
|
35
44
|
export declare function countTransactionalFunctions(entryPoints: CollectedEntryPoint[], behaviors: Map<string, Behavior>, options: TransactionOptions): CountedFunction[];
|
package/build/src/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as CoverageTooLowError } from "../pipeline-
|
|
2
|
-
import { a as runInventory, c as ConfigLoadError, i as runExplain, n as runCount, o as runMetrics, r as runDiff, s as printResult, t as runCalibrate } from "../runners-
|
|
1
|
+
import { t as CoverageTooLowError } from "../pipeline-Dm9KvUvF.js";
|
|
2
|
+
import { a as runInventory, c as ConfigLoadError, i as runExplain, n as runCount, o as runMetrics, r as runDiff, s as printResult, t as runCalibrate } from "../runners-DetZGfh5.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -161,9 +161,34 @@ export type FunctionOverride = {
|
|
|
161
161
|
*
|
|
162
162
|
* A name that matches no schema is a warning, never a silent fallback.
|
|
163
163
|
*/
|
|
164
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Name of a declared schema, or several whose fields are UNIONED.
|
|
166
|
+
*
|
|
167
|
+
* An ILF's DETs are the fields the user recognises in the file, and an
|
|
168
|
+
* application with one schema per template recognises the fields of all of them.
|
|
169
|
+
* Pointing at the largest and justifying it in `reason` gives the same answer
|
|
170
|
+
* only while they land in the same complexity band — which is a piece of
|
|
171
|
+
* reasoning the configuration should not have to carry.
|
|
172
|
+
*
|
|
173
|
+
* Unioned by leaf path, so a field two templates share counts once.
|
|
174
|
+
*/
|
|
175
|
+
detFromSchema?: string | string[];
|
|
165
176
|
/** declared RET (data function) or FTR (transaction) */
|
|
166
177
|
refs?: number;
|
|
178
|
+
/**
|
|
179
|
+
* Opaque DETs someone has looked at and decided are correct at 1.
|
|
180
|
+
*
|
|
181
|
+
* `fp:count` reports every opaque column and open input object, because 1 DET is
|
|
182
|
+
* a floor rather than a measurement. But some of them ARE one field — a copy, a
|
|
183
|
+
* checksum, a bag of metadata — and there was no way to say so, so the warning
|
|
184
|
+
* fired on every run forever. A warning that cannot be answered is a warning the
|
|
185
|
+
* team learns to scroll past, which costs more than the one it reports.
|
|
186
|
+
*
|
|
187
|
+
* It silences nothing else: the count does not move, and `fp:count` still says
|
|
188
|
+
* how many were reviewed. Names are matched bare (`schema`) or qualified
|
|
189
|
+
* (`Petition.schema`).
|
|
190
|
+
*/
|
|
191
|
+
opaqueReviewed?: string[];
|
|
167
192
|
/** why — required, and printed by `fp:explain` beside the number */
|
|
168
193
|
reason: string;
|
|
169
194
|
};
|
|
@@ -31,6 +31,14 @@ export type PersistenceAccess = {
|
|
|
31
31
|
* relation out of the count (§6.5.4) when it is a legitimate EIF.
|
|
32
32
|
*/
|
|
33
33
|
viaRelation?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Whether the access WRITES the related table.
|
|
36
|
+
*
|
|
37
|
+
* `preload('author')` reads it; `related('files').create(…)` writes it. Treating
|
|
38
|
+
* every relation access as a read made a table written only through a relation
|
|
39
|
+
* look externally maintained.
|
|
40
|
+
*/
|
|
41
|
+
relationWritten?: boolean;
|
|
34
42
|
/**
|
|
35
43
|
* Does this access fire the model's hooks?
|
|
36
44
|
*
|
|
@@ -35,6 +35,8 @@ export type Behavior = {
|
|
|
35
35
|
* fields of the VineJS schema — counting-decisions §7.
|
|
36
36
|
*/
|
|
37
37
|
inputFields: string[];
|
|
38
|
+
/** input fields that enumerate nothing: an open `vine.object` */
|
|
39
|
+
opaqueInputFields: string[];
|
|
38
40
|
/**
|
|
39
41
|
* Fields read straight off the request. Kept apart from `inputFields` so the
|
|
40
42
|
* conformance metric keeps meaning what it says: these are DETs, and they are
|
|
@@ -18,5 +18,22 @@
|
|
|
18
18
|
* it to each comparison costs vigilance forever.
|
|
19
19
|
*/
|
|
20
20
|
export declare const toPosix: (value: string) => string;
|
|
21
|
+
/**
|
|
22
|
+
* A path as it should appear in an EMITTED artefact: relative to the application.
|
|
23
|
+
*
|
|
24
|
+
* `CountSource.app` is documented as never being the absolute path, because that
|
|
25
|
+
* says where the machine keeps its files and travels with every count sent
|
|
26
|
+
* anywhere. One field below it, `config` shipped the absolute path — and so did
|
|
27
|
+
* every `trace[].file`, 858 times in a single production count. The rule was
|
|
28
|
+
* stated and then applied to one field.
|
|
29
|
+
*
|
|
30
|
+
* Internally the absolute path is the right thing: it is what ts-morph resolves
|
|
31
|
+
* and what the call graph keys its caches on. So this converts at the boundary
|
|
32
|
+
* where a path LEAVES, and nowhere else.
|
|
33
|
+
*
|
|
34
|
+
* A path outside the root keeps its `../` prefix, which describes where it is
|
|
35
|
+
* without naming the home directory.
|
|
36
|
+
*/
|
|
37
|
+
export declare const relativeTo: (root: string, value: string) => string;
|
|
21
38
|
/** Compares two paths that may have come from different sources. */
|
|
22
39
|
export declare const samePath: (a: string | undefined, b: string | undefined) => boolean;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as resolveCall, t as BUILTIN_CALL_RESOLVERS } from "../../../resolvers-
|
|
1
|
+
import { n as resolveCall, t as BUILTIN_CALL_RESOLVERS } from "../../../resolvers-vMahHkAd.js";
|
|
2
2
|
export { BUILTIN_CALL_RESOLVERS, resolveCall };
|
|
@@ -33,7 +33,14 @@ export type CountSource = {
|
|
|
33
33
|
*/
|
|
34
34
|
dirty?: boolean;
|
|
35
35
|
countedAt: string;
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Configuration file that shaped the count, RELATIVE to the application root,
|
|
38
|
+
* or null for the defaults.
|
|
39
|
+
*
|
|
40
|
+
* Relative for the same reason `app` is a name: an absolute path says where the
|
|
41
|
+
* machine keeps its files, and this artefact is what goes into a ledger and to
|
|
42
|
+
* whoever receives the invoice.
|
|
43
|
+
*/
|
|
37
44
|
config: string | null;
|
|
38
45
|
};
|
|
39
46
|
export declare function describeSource(root: string, config: string | null): CountSource;
|
package/build/src/pipeline.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as analyze, t as CoverageTooLowError } from "../pipeline-
|
|
1
|
+
import { n as analyze, t as CoverageTooLowError } from "../pipeline-Dm9KvUvF.js";
|
|
2
2
|
export { CoverageTooLowError, analyze };
|
package/build/src/types.d.ts
CHANGED
|
@@ -86,6 +86,13 @@ export type HandlerBehavior = {
|
|
|
86
86
|
touches: string[];
|
|
87
87
|
/** declared input fields (validators) */
|
|
88
88
|
inputFields: Field[];
|
|
89
|
+
/**
|
|
90
|
+
* Input fields that enumerate nothing — an open `vine.object`.
|
|
91
|
+
*
|
|
92
|
+
* They count 1 DET each, like an opaque column, and are reported: the number
|
|
93
|
+
* is a floor, not a measurement.
|
|
94
|
+
*/
|
|
95
|
+
opaqueInputFields: Field[];
|
|
89
96
|
/**
|
|
90
97
|
* Input fields read straight off the request, with no validator.
|
|
91
98
|
*
|
package/build/stubs/config.stub
CHANGED
|
@@ -42,7 +42,13 @@ export default defineConfig({
|
|
|
42
42
|
* here.
|
|
43
43
|
*/
|
|
44
44
|
// overrides: {
|
|
45
|
-
//
|
|
45
|
+
// // one schema, or several whose fields are unioned by leaf path
|
|
46
|
+
// Form: { detFromSchema: ['intakeSchema', 'reviewSchema'], reason: 'one per template' },
|
|
47
|
+
//
|
|
48
|
+
// // 1 DET is a floor, and `fp:count` says so on every run. When 1 IS the right
|
|
49
|
+
// // answer, record that someone checked — otherwise the warning becomes noise
|
|
50
|
+
// // the team learns to scroll past. It moves no number.
|
|
51
|
+
// Petition: { opaqueReviewed: ['schema', 'uiSchema'], reason: 'metadata; one field each' },
|
|
46
52
|
// },
|
|
47
53
|
|
|
48
54
|
/**
|
package/package.json
CHANGED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
//#region src/define_config.ts
|
|
2
|
-
const DEFAULTS = {
|
|
3
|
-
boundary: {},
|
|
4
|
-
retStrategy: "constant",
|
|
5
|
-
maxDepth: 3,
|
|
6
|
-
messageDet: 0
|
|
7
|
-
};
|
|
8
|
-
function defineConfig(config) {
|
|
9
|
-
return {
|
|
10
|
-
...DEFAULTS,
|
|
11
|
-
...config,
|
|
12
|
-
boundary: {
|
|
13
|
-
...DEFAULTS.boundary,
|
|
14
|
-
...config.boundary
|
|
15
|
-
}
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
//#endregion
|
|
19
|
-
export { defineConfig as n, DEFAULTS as t };
|