@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 CHANGED
@@ -6,6 +6,111 @@ release moves the number for unchanged code, the rule set version moves with it
6
6
  otherwise the difference would measure the tool's change rather than the work, and
7
7
  that difference becomes an invoice.
8
8
 
9
+ ## 0.4.0
10
+
11
+ **Rule set `afp@1.3.0`.** Conditional validator groups now count, and a nested
12
+ schema is recognised however the formatter wrapped it — so a 0.3.0 baseline has to
13
+ be recounted.
14
+
15
+ Five items reported from real use of 0.3.0, three of them defects.
16
+
17
+ ### Fixed
18
+
19
+ - **A newline decided whether a field counted.** The recogniser for a nested schema
20
+ was a regex over the property's source text (`/vine\.object/`), and Prettier breaks
21
+ a long chain across lines — `data: vine` then `.object({})` — so the regex missed
22
+ and the field was counted as one leaf instead of its nested ones, and never marked
23
+ opaque. Decided by structure now: is this literal the argument of a call named
24
+ `object`? The same applied to `.merge(…)` and `group.if(…)`, which had the same
25
+ kind of check. A count that depends on where the formatter put a newline is not a
26
+ measurement — the reason the implementation-scope hash strips whitespace before
27
+ hashing.
28
+ - **The list of unreadable DETs ignored the overrides that answered it.** It was
29
+ computed before `applyOverrides` ran, so a function whose floor `detFromSchema` had
30
+ already replaced still appeared under "this is a FLOOR", telling the reader to map
31
+ something already mapped. It caused a real misreading of a production report, by
32
+ the author of this code. It now runs after the overrides, is grouped by FUNCTION,
33
+ and states per function how many were replaced by an override, how many reviewed,
34
+ and how many are still unanswered — only the last being a request to do anything.
35
+ - **Emitted artefacts carried absolute paths.** `CountSource.app` is documented as
36
+ never being one, because that says where the machine keeps its files and travels
37
+ with every artefact sent anywhere — and the rule was applied to that one field. A
38
+ production count carried **858** absolute paths in its traces; an inventory
39
+ carried **2036** across ten fields, including the data-store `id`. Every path that
40
+ leaves is now relative to the application root, and the internal absolute form is
41
+ untouched because that is what ts-morph resolves against.
42
+ - **`vine.group` and `.merge()` were not read.**
43
+ `vine.object({}).merge(vine.group([vine.group.if(p, {…})]))` reported the whole
44
+ validator as an open input object: five fields counted as one, and the report said
45
+ they were data when they are in the code. The branches are mutually exclusive at
46
+ runtime and the transaction can carry any of them, so §7.2 counts their union — a
47
+ field two branches share counts once. The group usually lives in an unexported
48
+ constant beside the validator, so the reference is resolved in the validator's own
49
+ file rather than the caller's.
50
+ - **`fp:explain` matched by substring even when the exact name existed.** Asking
51
+ about `POST /orders/:param/submit` returned four functions, because
52
+ `/submit-ready` and `/submit-ready/return` contain it. An exact name now wins
53
+ outright; the substring search is the fallback.
54
+
55
+ ### New
56
+
57
+ - **`detFromSchema` accepts a list**, unioned by leaf path. An ILF's DETs are the
58
+ fields the user recognises in the file, and an application with one schema per
59
+ template recognises all of them. Pointing at the largest and justifying it in
60
+ `reason` gives the same answer only while they land in the same band — reasoning
61
+ the configuration should not have to carry.
62
+ - **`overrides.<fn>.opaqueReviewed`** records that someone looked at an opaque DET
63
+ and decided 1 is right. 1 DET is a floor and `fp:count` says so on every run, but
64
+ some of those columns really are one field, and a warning that cannot be answered
65
+ is one the team learns to scroll past. It moves no number, it is not counted as a
66
+ declared override in the "Declared by override" share, and the volume reviewed is
67
+ still printed.
68
+
69
+ ## 0.3.0
70
+
71
+ **Rule set `afp@1.2.0`.** Three counting fixes move the number for unchanged code,
72
+ so a 0.2.0 baseline has to be recounted.
73
+
74
+ All four were found by installing 0.2.0 in a production application, which is the
75
+ only way any of them could have been found.
76
+
77
+ ### Fixed
78
+
79
+ - **An open input object counted zero.** `vine.object({}).allowUnknownProperties()`
80
+ declares a field whose own fields live in data; the leaf walk descended into the
81
+ empty literal, found nothing, and never pushed the field either. An opaque JSON
82
+ column in the identical position counts 1. It now counts 1 too, and is reported —
83
+ the opaque-column warning names stores, and this side had no warning at all, which
84
+ is why the route saving the application's main document had never looked wrong.
85
+ - **`detFromSchema` was off by one.** It replaced the opaque placeholder by
86
+ subtracting 1 on faith. With no placeholder to replace — the case above — the
87
+ subtraction removed a field the analysis had read correctly. Opaque DETs are now
88
+ marked `(opaque)` in the rationale, and the override replaces a marked one or
89
+ none, warning when it finds nothing to stand in for.
90
+ - **A schema declared in a seeder was not found.** `database/` is excluded from the
91
+ application roots so a test factory's writes never become counted functions, but
92
+ `make:seeder` puts seeders there. Naming such a schema reported "not declared
93
+ anywhere in the code" and left the count at the floor — the exact case the
94
+ override exists for. The schema catalogue now reads `database/` as well; the call
95
+ graph still does not.
96
+ - **`@adonisjs/queue` names the execution method `execute`**, which the list of
97
+ names did not have. It surfaced only once event dispatch started being followed:
98
+ the listener was what enqueued the job, so that path had never been walked. Third
99
+ name this list has learned by measurement — a list written from imagination would
100
+ have missed this one too.
101
+ - **A write through a relation did not maintain the related table.**
102
+ `distribution.related('files').create({…})` is ordinary Lucid and the relation is
103
+ the subject of the write. Every relation access was treated as a read, so a table
104
+ written exclusively that way came out as an EIF. `preload` and `load` still only
105
+ read, because they hand back the parent.
106
+
107
+ ### New
108
+
109
+ - `analyze`, `diffCounts`, `measureStructure`, `measureConformance`, `calibrate`,
110
+ `RULESET_VERSION` and the diff types are exported. The two front-ends were the
111
+ only way to reach any of this, so anything built on top had to shell out to the
112
+ CLI and parse its output.
113
+
9
114
  ## 0.2.0
10
115
 
11
116
  **Rule set `afp@1.1.0`.** A baseline saved with 0.1.0 cannot be compared against
package/README.md CHANGED
@@ -13,7 +13,7 @@ node ace fp:count
13
13
 
14
14
  ```
15
15
  Unadjusted count: 46 FP
16
- Ruleset: afp@1.1.0
16
+ Ruleset: afp@1.3.0
17
17
 
18
18
  type n FP
19
19
  ILF 2 14
@@ -0,0 +1,403 @@
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
+ //#region src/albrecht/diff.ts
20
+ /**
21
+ * Added, changed and removed functions between two counts — what becomes an
22
+ * invoice.
23
+ *
24
+ * Normative base: **OMG Automated Enhancement Points 1.0**, the sibling of AFP,
25
+ * written to size maintenance between two revisions.
26
+ *
27
+ * "Each Artifact shall be analyzed in both revisions to determine whether it
28
+ * is: Added — when it exists in revision ToRevision while it didn't exist in
29
+ * FromRevision. […] Modified — when it exists in both revisions but whose
30
+ * source code changed." — AEP §6.3
31
+ *
32
+ * Two decisions make this workable:
33
+ *
34
+ * 1. **It operates on two saved counts**, never on two checkouts. Booting the
35
+ * older revision, with possibly different dependencies, is the kind of
36
+ * problem not worth solving.
37
+ * 2. **It refuses to compare different rule sets.** If the rules changed in
38
+ * between, the difference measures the rule change, not the work — and the
39
+ * result would go into an invoice.
40
+ */
41
+ var IncomparableRulesetsError = class extends Error {
42
+ constructor(from, to) {
43
+ 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.`);
44
+ this.name = "IncomparableRulesetsError";
45
+ }
46
+ };
47
+ const AEP_FACTORS = {
48
+ added: 1,
49
+ changed: 1,
50
+ removed: .4,
51
+ unchanged: 0
52
+ };
53
+ /**
54
+ * Two counts of DIFFERENT applications compare cleanly and mean nothing.
55
+ *
56
+ * The ruleset guard already refuses counts produced by different rules. This
57
+ * refuses counts produced over different subjects, which is the same class of
58
+ * error and the easier one to make in CI, where both files arrive as paths.
59
+ */
60
+ var IncomparableSourcesError = class extends Error {
61
+ constructor(from, to) {
62
+ 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.`);
63
+ this.from = from;
64
+ this.to = to;
65
+ this.name = "IncomparableSourcesError";
66
+ }
67
+ };
68
+ function diffCounts(from, to, options = {}) {
69
+ if (from.rulesetVersion !== to.rulesetVersion || from.ruleset !== to.ruleset) throw new IncomparableRulesetsError(`${from.ruleset}@${from.rulesetVersion}`, `${to.ruleset}@${to.rulesetVersion}`);
70
+ if (from.source && to.source && from.source.app !== to.source.app) throw new IncomparableSourcesError(from.source.app, to.source.app);
71
+ const factors = {
72
+ ...AEP_FACTORS,
73
+ ...options.factors
74
+ };
75
+ const reasonFactors = options.reasonFactors ?? {};
76
+ /** the factor a single entry is billed at, which is the per-reason one when set */
77
+ const factorFor = (entry) => entry.change === "changed" && entry.reason ? reasonFactors[entry.reason] ?? factors.changed : factors[entry.change];
78
+ const before = new Map(from.functions.map((fn) => [fn.id, fn]));
79
+ const after = new Map(to.functions.map((fn) => [fn.id, fn]));
80
+ const entries = [];
81
+ for (const [id, fn] of after) {
82
+ const previous = before.get(id);
83
+ if (!previous) {
84
+ entries.push({
85
+ function: fn,
86
+ change: "added"
87
+ });
88
+ continue;
89
+ }
90
+ const reason = reasonBetween(previous, fn);
91
+ entries.push({
92
+ function: fn,
93
+ change: reason ? "changed" : "unchanged",
94
+ previous,
95
+ ...reason ? { reason } : {}
96
+ });
97
+ }
98
+ for (const [id, fn] of before) if (!after.has(id)) entries.push({
99
+ function: fn,
100
+ change: "removed"
101
+ });
102
+ const warnings = [];
103
+ /**
104
+ * Provenance warnings. None of them stops the comparison — they qualify the
105
+ * number that comes out of it, which is what goes onto an invoice.
106
+ */
107
+ for (const [side, count] of [["from", from], ["to", to]]) {
108
+ if (!count.source) {
109
+ warnings.push(`the "${side}" count records no source: it cannot be tied to a revision, so this difference cannot be reproduced or audited later.`);
110
+ continue;
111
+ }
112
+ 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.`);
113
+ }
114
+ 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.`);
115
+ /**
116
+ * Quantified, because the generic sentence was not actionable.
117
+ *
118
+ * On a real pair of releases this warning sat under 118 lines of per-function
119
+ * output, saying only that the factor was pinned. What a client disputes is
120
+ * the amount, so the amount is what it has to say.
121
+ */
122
+ const changedPoints = entries.filter((entry) => entry.change === "changed").reduce((total, entry) => total + entry.function.points, 0);
123
+ if (changedPoints > 0 && factors.changed === 1 && reasonFactors.implementation === void 0) {
124
+ const byReason = changedByReasonOf(entries);
125
+ const billable = round2(entries.reduce((total, entry) => total + entry.function.points * factorFor(entry), 0));
126
+ const share = billable === 0 ? 0 : Math.round(changedPoints / billable * 100);
127
+ 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.`);
128
+ }
129
+ return {
130
+ from: options.labels?.from ?? "previous",
131
+ to: options.labels?.to ?? "current",
132
+ entries: entries.sort(byChangeThenName),
133
+ totals: totalsOf(entries),
134
+ changedByReason: changedByReasonOf(entries),
135
+ /**
136
+ * Rounded to cents at the source, not at the print.
137
+ *
138
+ * `485.00000000000006` appeared on the first real diff. It is arithmetically
139
+ * the same number and it is not the same document: this value is quoted in
140
+ * an invoice, and a reader who sees that tail stops trusting the rest.
141
+ */
142
+ billable: round2(entries.reduce((total, entry) => total + entry.function.points * factorFor(entry), 0)),
143
+ factors,
144
+ reasonFactors,
145
+ warnings
146
+ };
147
+ }
148
+ /**
149
+ * What counts as a change.
150
+ *
151
+ * A change in the implementation scope (checksum of the normalised AST) **or**
152
+ * in the functional size. Formatting and comments do not count: the hash
153
+ * already ignores them.
154
+ *
155
+ * Renaming a route does not show up here because identity is
156
+ * `(verb, pattern)` — and neither does moving a controller between modules,
157
+ * which is implementation.
158
+ */
159
+ /**
160
+ * Why the function changed, or null when it did not.
161
+ *
162
+ * Reported by the most consequential cause: a reclassification usually moves
163
+ * the size too, and naming the type is the fact that explains the rest. The
164
+ * rendered line carries the DET and FTR movement, so nothing is hidden behind
165
+ * the label.
166
+ */
167
+ function reasonBetween(previous, current) {
168
+ if (previous.type !== current.type) return "type";
169
+ if (previous.det !== current.det || previous.refs !== current.refs) return "size";
170
+ if ((previous.scopeHash ?? "") !== (current.scopeHash ?? "")) return "implementation";
171
+ return null;
172
+ }
173
+ /**
174
+ * Where an invoice actually comes from.
175
+ *
176
+ * `changed` is usually the largest line, and until this split it said nothing
177
+ * about whether it was paying for growth or for refactoring.
178
+ */
179
+ function changedByReasonOf(entries) {
180
+ const byReason = {
181
+ type: {
182
+ count: 0,
183
+ points: 0
184
+ },
185
+ size: {
186
+ count: 0,
187
+ points: 0
188
+ },
189
+ implementation: {
190
+ count: 0,
191
+ points: 0
192
+ }
193
+ };
194
+ for (const entry of entries) {
195
+ if (entry.change !== "changed" || !entry.reason) continue;
196
+ byReason[entry.reason].count++;
197
+ byReason[entry.reason].points += entry.function.points;
198
+ }
199
+ return byReason;
200
+ }
201
+ const ORDER = {
202
+ added: 0,
203
+ changed: 1,
204
+ removed: 2,
205
+ unchanged: 3
206
+ };
207
+ const byChangeThenName = (a, b) => ORDER[a.change] - ORDER[b.change] || a.function.name.localeCompare(b.function.name);
208
+ function totalsOf(entries) {
209
+ const totals = {
210
+ added: {
211
+ count: 0,
212
+ points: 0
213
+ },
214
+ changed: {
215
+ count: 0,
216
+ points: 0
217
+ },
218
+ removed: {
219
+ count: 0,
220
+ points: 0
221
+ },
222
+ unchanged: {
223
+ count: 0,
224
+ points: 0
225
+ }
226
+ };
227
+ for (const entry of entries) {
228
+ totals[entry.change].count++;
229
+ totals[entry.change].points += entry.function.points;
230
+ }
231
+ return totals;
232
+ }
233
+ /** two decimals: this number is quoted in an invoice */
234
+ const round2 = (value) => Math.round(value * 100) / 100;
235
+ //#endregion
236
+ //#region src/metrics/structure.ts
237
+ function measureStructure(inventory, count) {
238
+ /** store -> module that declares it */
239
+ const storeModule = new Map(inventory.dataStores.map((store) => [store.name, store.module]));
240
+ /** entry point -> module */
241
+ const entryModule = new Map(inventory.entryPoints.map((entry) => [entry.id, entry.module]));
242
+ const modules = new Set([...storeModule.values(), ...entryModule.values()]);
243
+ const dependsOn = /* @__PURE__ */ new Map();
244
+ for (const module of modules) dependsOn.set(module, /* @__PURE__ */ new Set());
245
+ /**
246
+ * The dependency that matters is USE, not import: module A depends on B when
247
+ * a transaction of A reaches a store declared in B. A type-only import
248
+ * creates no functional coupling.
249
+ */
250
+ for (const behavior of inventory.behaviors) {
251
+ const from = entryModule.get(behavior.entryPointId);
252
+ if (!from) continue;
253
+ for (const store of behavior.touches) {
254
+ const to = storeModule.get(store);
255
+ if (!to || to === from) continue;
256
+ dependsOn.get(from)?.add(to);
257
+ }
258
+ }
259
+ const dependedOnBy = /* @__PURE__ */ new Map();
260
+ for (const module of modules) dependedOnBy.set(module, /* @__PURE__ */ new Set());
261
+ for (const [from, targets] of dependsOn) for (const to of targets) dependedOnBy.get(to)?.add(from);
262
+ const transactionsPerModule = /* @__PURE__ */ new Map();
263
+ for (const module of entryModule.values()) transactionsPerModule.set(module, (transactionsPerModule.get(module) ?? 0) + 1);
264
+ const storesPerModule = /* @__PURE__ */ new Map();
265
+ for (const module of storeModule.values()) storesPerModule.set(module, (storesPerModule.get(module) ?? 0) + 1);
266
+ const moduleMetrics = [...modules].map((module) => {
267
+ const ce = dependsOn.get(module).size;
268
+ const ca = dependedOnBy.get(module).size;
269
+ return {
270
+ module,
271
+ functionPoints: count.totals.byModule[module] ?? 0,
272
+ transactions: transactionsPerModule.get(module) ?? 0,
273
+ dataStores: storesPerModule.get(module) ?? 0,
274
+ dependsOn: [...dependsOn.get(module)].sort(),
275
+ dependedOnBy: [...dependedOnBy.get(module)].sort(),
276
+ instability: ca + ce === 0 ? 0 : round$1(ce / (ca + ce))
277
+ };
278
+ }).sort((a, b) => b.functionPoints - a.functionPoints);
279
+ const mutual = [];
280
+ for (const [from, targets] of dependsOn) for (const to of targets) if (from < to && dependsOn.get(to)?.has(from)) mutual.push([from, to]);
281
+ const stores = inventory.dataStores.length || 1;
282
+ return {
283
+ modules: moduleMetrics,
284
+ mutualDependencies: mutual.sort(),
285
+ pointsPerDataStore: round$1(count.totals.unadjusted / stores),
286
+ transactionsPerDataStore: round$1(inventory.entryPoints.length / stores)
287
+ };
288
+ }
289
+ function measureConformance(inventory) {
290
+ const behaviors = inventory.behaviors;
291
+ const takesInput = behaviors.filter((behavior) => behavior.inputFields.length > 0 || behavior.requestFields.length > 0 || behavior.opaqueRequest);
292
+ const withValidator = takesInput.filter((behavior) => behavior.inputFields.length > 0);
293
+ const withHandler = inventory.entryPoints.filter((entry) => entry.handler !== null);
294
+ const reached = new Set(behaviors.flatMap((behavior) => behavior.touches));
295
+ return {
296
+ inputsWithValidator: ratio(withValidator.length, takesInput.length),
297
+ entryPointsWithHandler: ratio(withHandler.length, inventory.entryPoints.length),
298
+ dataStoresReached: ratio(reached.size, inventory.dataStores.length)
299
+ };
300
+ }
301
+ const ratio = (ok, total) => ({
302
+ ok,
303
+ total,
304
+ ratio: total === 0 ? 1 : round$1(ok / total)
305
+ });
306
+ const round$1 = (value) => Math.round(value * 1e3) / 1e3;
307
+ //#endregion
308
+ //#region src/albrecht/calibration.ts
309
+ /**
310
+ * Minimum sample size per type for a factor to mean anything.
311
+ *
312
+ * Below this, the "factor" is noise from one or two functions, and using it to
313
+ * correct a count is worse than not correcting at all.
314
+ */
315
+ const MIN_SAMPLES_PER_TYPE = 10;
316
+ function calibrate(result, samples) {
317
+ const byIdentity = new Map(result.functions.map((fn) => [fn.name, fn]));
318
+ const grouped = /* @__PURE__ */ new Map();
319
+ const unmatched = [];
320
+ let manualTotal = 0;
321
+ let automaticTotal = 0;
322
+ let exactTotal = 0;
323
+ for (const sample of samples) {
324
+ const counted = byIdentity.get(sample.function);
325
+ if (!counted) {
326
+ unmatched.push(sample.function);
327
+ continue;
328
+ }
329
+ const bucket = grouped.get(counted.type) ?? {
330
+ manual: 0,
331
+ automatic: 0,
332
+ deviations: [],
333
+ exact: 0
334
+ };
335
+ bucket.manual += sample.manual;
336
+ bucket.automatic += counted.points;
337
+ bucket.deviations.push(Math.abs(counted.points - sample.manual));
338
+ if (counted.points === sample.manual) bucket.exact++;
339
+ grouped.set(counted.type, bucket);
340
+ manualTotal += sample.manual;
341
+ automaticTotal += counted.points;
342
+ if (counted.points === sample.manual) exactTotal++;
343
+ }
344
+ const byType = [...grouped.entries()].map(([type, bucket]) => ({
345
+ type,
346
+ samples: bucket.deviations.length,
347
+ manualPoints: bucket.manual,
348
+ automaticPoints: bucket.automatic,
349
+ factor: bucket.automatic === 0 ? 1 : round(bucket.manual / bucket.automatic),
350
+ meanAbsoluteDeviation: round(bucket.deviations.reduce((total, value) => total + value, 0) / bucket.deviations.length),
351
+ exactMatches: bucket.exact
352
+ })).sort((a, b) => a.type.localeCompare(b.type));
353
+ const warnings = [];
354
+ 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.`);
355
+ 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".`);
356
+ const matched = samples.length - unmatched.length;
357
+ 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.");
358
+ return {
359
+ byType,
360
+ overall: {
361
+ samples: matched,
362
+ manualPoints: manualTotal,
363
+ automaticPoints: automaticTotal,
364
+ deviation: manualTotal === 0 ? 0 : round((automaticTotal - manualTotal) / manualTotal),
365
+ exactMatches: exactTotal
366
+ },
367
+ unmatched,
368
+ warnings
369
+ };
370
+ }
371
+ const round = (value) => Math.round(value * 1e3) / 1e3;
372
+ /**
373
+ * Reads samples from CSV: `function,fp` with a header row.
374
+ *
375
+ * Deliberately plain. A metrics analyst exports from a spreadsheet, and
376
+ * demanding JSON would add friction where none is needed.
377
+ */
378
+ function parseSamples(csv) {
379
+ const samples = [];
380
+ for (const [index, line] of csv.split(/\r?\n/).entries()) {
381
+ const trimmed = line.trim();
382
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
383
+ const separator = trimmed.lastIndexOf(",");
384
+ if (separator === -1) continue;
385
+ const name = trimmed.slice(0, separator).trim().replace(/^"|"$/g, "");
386
+ const manual = Number(trimmed.slice(separator + 1).trim());
387
+ if (!Number.isFinite(manual)) {
388
+ if (index > 0 && ![
389
+ "function",
390
+ "funcao",
391
+ "função"
392
+ ].includes(name)) throw new Error(`line ${index + 1}: unreadable function points in "${trimmed}"`);
393
+ continue;
394
+ }
395
+ samples.push({
396
+ function: name,
397
+ manual
398
+ });
399
+ }
400
+ return samples;
401
+ }
402
+ //#endregion
403
+ export { AEP_FACTORS as a, diffCounts as c, measureStructure as i, DEFAULTS as l, parseSamples as n, IncomparableRulesetsError as o, measureConformance as r, IncomparableSourcesError as s, calibrate as t, defineConfig as u };
@@ -14,27 +14,27 @@
14
14
  const commands = [
15
15
  {
16
16
  commandName: "fp:inventory",
17
- importer: () => import("../fp_inventory-DSrCVhEy.js")
17
+ importer: () => import("../fp_inventory-DHwZzEQf.js")
18
18
  },
19
19
  {
20
20
  commandName: "fp:metrics",
21
- importer: () => import("../fp_metrics-BUWj9dLw.js")
21
+ importer: () => import("../fp_metrics-M84qLYaE.js")
22
22
  },
23
23
  {
24
24
  commandName: "fp:count",
25
- importer: () => import("../fp_count-CfcXPuj5.js")
25
+ importer: () => import("../fp_count-DNSwaLUD.js")
26
26
  },
27
27
  {
28
28
  commandName: "fp:explain",
29
- importer: () => import("../fp_explain-MKyEoi0h.js")
29
+ importer: () => import("../fp_explain-Dpiby5Qx.js")
30
30
  },
31
31
  {
32
32
  commandName: "fp:diff",
33
- importer: () => import("../fp_diff-DE_t3twv.js")
33
+ importer: () => import("../fp_diff-CCKxqGKh.js")
34
34
  },
35
35
  {
36
36
  commandName: "fp:calibrate",
37
- importer: () => import("../fp_calibrate-Cm079xWL.js")
37
+ importer: () => import("../fp_calibrate-DLZP5bUp.js")
38
38
  }
39
39
  ];
40
40
  let cache = null;
@@ -1,4 +1,4 @@
1
- import { s as printResult, t as runCalibrate } from "./runners-DpMd-yZM.js";
1
+ import { s as printResult, t as runCalibrate } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, args } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_calibrate.ts
@@ -1,4 +1,4 @@
1
- import { n as runCount, s as printResult } from "./runners-DpMd-yZM.js";
1
+ import { n as runCount, s as printResult } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, flags } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_count.ts
@@ -1,4 +1,4 @@
1
- import { r as runDiff, s as printResult } from "./runners-DpMd-yZM.js";
1
+ import { r as runDiff, s as printResult } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, args } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_diff.ts
@@ -1,4 +1,4 @@
1
- import { i as runExplain, s as printResult } from "./runners-DpMd-yZM.js";
1
+ import { i as runExplain, s as printResult } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, args } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_explain.ts
@@ -1,4 +1,4 @@
1
- import { a as runInventory, s as printResult } from "./runners-DpMd-yZM.js";
1
+ import { a as runInventory, s as printResult } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, flags } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_inventory.ts
@@ -1,4 +1,4 @@
1
- import { o as runMetrics, s as printResult } from "./runners-DpMd-yZM.js";
1
+ import { o as runMetrics, s as printResult } from "./runners-DetZGfh5.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, flags } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_metrics.ts
package/build/index.d.ts CHANGED
@@ -2,5 +2,33 @@ export { configure } from './configure.js';
2
2
  export { defineConfig } from './src/define_config.js';
3
3
  export type { FunctionPointsConfig, FunctionOverride } from './src/define_config.js';
4
4
  export * from './src/types.js';
5
+ /**
6
+ * Counting, programmatically.
7
+ *
8
+ * The two front-ends were the only way to reach any of this, so anything built on
9
+ * top of the package — a ledger, a dashboard, a billing job — had to shell out to
10
+ * the CLI and parse its output. That is the same defect as the configuration
11
+ * nobody read and the metrics nobody could run, one layer up.
12
+ */
13
+ export { analyze, CoverageTooLowError } from './src/pipeline.js';
14
+ export type { Analysis, AnalysisOptions } from './src/pipeline.js';
15
+ /**
16
+ * Which rules produced a number.
17
+ *
18
+ * A ledger row without them cannot be compared to another one: `diffCounts`
19
+ * refuses counts from different rule sets, and a stored count needs to carry what
20
+ * it was measured with.
21
+ */
22
+ export { RULESET, RULESET_VERSION } from './src/albrecht/counter.js';
23
+ /** Change, and what it bills at — AEP §6.3 and §6.5. */
24
+ export { AEP_FACTORS, diffCounts, IncomparableRulesetsError, IncomparableSourcesError, } from './src/albrecht/diff.js';
25
+ export type { ChangeFactors, ChangeReasonFactors, DiffOptions, FunctionPointDiff, } from './src/albrecht/diff.js';
26
+ /** The counterweight: density, coupling and conformance over the same inventory. */
27
+ export { measureConformance, measureStructure } from './src/metrics/structure.js';
28
+ export type { Conformance, ModuleMetrics, StructureMetrics } from './src/metrics/structure.js';
29
+ /** Correction factors against a manual count. */
30
+ export { calibrate, parseSamples } from './src/albrecht/calibration.js';
31
+ export type { Calibration, CalibrationSample, TypeCalibration } from './src/albrecht/calibration.js';
32
+ /** Tracing strategies: the one public extension point. */
5
33
  export { BUILTIN_CALL_RESOLVERS } from './src/inventory/resolvers/index.js';
6
34
  export type { CallResolver, ResolverContext } from './src/inventory/resolvers/types.js';
package/build/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { configure } from "./configure.js";
2
- import { n as defineConfig } from "./define_config-DOqWyPwV.js";
2
+ import { a as AEP_FACTORS, c as diffCounts, i as measureStructure, n as parseSamples, o as IncomparableRulesetsError, r as measureConformance, s as IncomparableSourcesError, t as calibrate, u as defineConfig } from "./calibration-8eV8CEix.js";
3
3
  import "./src/types.js";
4
- import { t as BUILTIN_CALL_RESOLVERS } from "./resolvers-MFjRl2ef.js";
5
- export { BUILTIN_CALL_RESOLVERS, configure, defineConfig };
4
+ import { t as BUILTIN_CALL_RESOLVERS } from "./resolvers-vMahHkAd.js";
5
+ import { i as RULESET_VERSION, n as analyze, r as RULESET, t as CoverageTooLowError } from "./pipeline-Dm9KvUvF.js";
6
+ export { AEP_FACTORS, BUILTIN_CALL_RESOLVERS, CoverageTooLowError, IncomparableRulesetsError, IncomparableSourcesError, RULESET, RULESET_VERSION, analyze, calibrate, configure, defineConfig, diffCounts, measureConformance, measureStructure, parseSamples };