@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Counting rules carry their own version, separate from the package version, and
|
|
4
|
+
`fp:diff` **refuses** to compare counts produced by different rule sets. When a
|
|
5
|
+
release moves the number for unchanged code, the rule set version moves with it —
|
|
6
|
+
otherwise the difference would measure the tool's change rather than the work, and
|
|
7
|
+
that difference becomes an invoice.
|
|
8
|
+
|
|
9
|
+
## 0.3.0
|
|
10
|
+
|
|
11
|
+
**Rule set `afp@1.2.0`.** Three counting fixes move the number for unchanged code,
|
|
12
|
+
so a 0.2.0 baseline has to be recounted.
|
|
13
|
+
|
|
14
|
+
All four were found by installing 0.2.0 in a production application, which is the
|
|
15
|
+
only way any of them could have been found.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **An open input object counted zero.** `vine.object({}).allowUnknownProperties()`
|
|
20
|
+
declares a field whose own fields live in data; the leaf walk descended into the
|
|
21
|
+
empty literal, found nothing, and never pushed the field either. An opaque JSON
|
|
22
|
+
column in the identical position counts 1. It now counts 1 too, and is reported —
|
|
23
|
+
the opaque-column warning names stores, and this side had no warning at all, which
|
|
24
|
+
is why the route saving the application's main document had never looked wrong.
|
|
25
|
+
- **`detFromSchema` was off by one.** It replaced the opaque placeholder by
|
|
26
|
+
subtracting 1 on faith. With no placeholder to replace — the case above — the
|
|
27
|
+
subtraction removed a field the analysis had read correctly. Opaque DETs are now
|
|
28
|
+
marked `(opaque)` in the rationale, and the override replaces a marked one or
|
|
29
|
+
none, warning when it finds nothing to stand in for.
|
|
30
|
+
- **A schema declared in a seeder was not found.** `database/` is excluded from the
|
|
31
|
+
application roots so a test factory's writes never become counted functions, but
|
|
32
|
+
`make:seeder` puts seeders there. Naming such a schema reported "not declared
|
|
33
|
+
anywhere in the code" and left the count at the floor — the exact case the
|
|
34
|
+
override exists for. The schema catalogue now reads `database/` as well; the call
|
|
35
|
+
graph still does not.
|
|
36
|
+
- **`@adonisjs/queue` names the execution method `execute`**, which the list of
|
|
37
|
+
names did not have. It surfaced only once event dispatch started being followed:
|
|
38
|
+
the listener was what enqueued the job, so that path had never been walked. Third
|
|
39
|
+
name this list has learned by measurement — a list written from imagination would
|
|
40
|
+
have missed this one too.
|
|
41
|
+
- **A write through a relation did not maintain the related table.**
|
|
42
|
+
`distribution.related('files').create({…})` is ordinary Lucid and the relation is
|
|
43
|
+
the subject of the write. Every relation access was treated as a read, so a table
|
|
44
|
+
written exclusively that way came out as an EIF. `preload` and `load` still only
|
|
45
|
+
read, because they hand back the parent.
|
|
46
|
+
|
|
47
|
+
### New
|
|
48
|
+
|
|
49
|
+
- `analyze`, `diffCounts`, `measureStructure`, `measureConformance`, `calibrate`,
|
|
50
|
+
`RULESET_VERSION` and the diff types are exported. The two front-ends were the
|
|
51
|
+
only way to reach any of this, so anything built on top had to shell out to the
|
|
52
|
+
CLI and parse its output.
|
|
53
|
+
|
|
54
|
+
## 0.2.0
|
|
55
|
+
|
|
56
|
+
**Rule set `afp@1.1.0`.** A baseline saved with 0.1.0 cannot be compared against
|
|
57
|
+
this release: recount it, or `fp:diff` will refuse. That refusal is the feature.
|
|
58
|
+
|
|
59
|
+
### Counting
|
|
60
|
+
|
|
61
|
+
- **ILF vs EIF is decided across the whole project, not from HTTP routes.** AFP
|
|
62
|
+
§6.5.4 asks who _maintains_ a store; reading that off a walk from routes
|
|
63
|
+
answered a narrower question, so a table written only by a job or a seeder came
|
|
64
|
+
out as somebody else's table. On three production applications this moved 5
|
|
65
|
+
stores out of EIF. A store that is only ever read is still an EIF.
|
|
66
|
+
- **A job is followed into its execution method**, which is `handle`,
|
|
67
|
+
`process`, `run` or `perform` depending on the queue package. Looking only for
|
|
68
|
+
`handle` resolved the file, found no body, reported the dispatch as unknown and
|
|
69
|
+
left every write inside it uncounted.
|
|
70
|
+
- **An event dispatch is followed into its listeners.** Bindings are read from
|
|
71
|
+
`emitter.on(event, [listeners])`; both generated-registry shapes and the direct
|
|
72
|
+
class reference are handled, as is a binding that names the method.
|
|
73
|
+
- **`request.input('x')` and `request.only([…])` count as input DETs.** §7.2 asks
|
|
74
|
+
whether a user-recognisable field crosses the boundary, not how it was declared.
|
|
75
|
+
Deduplicated against validator fields, so nothing is paid for twice.
|
|
76
|
+
|
|
77
|
+
### Billing
|
|
78
|
+
|
|
79
|
+
- `fp:diff` factors are reachable from `config/function_points.ts` under `diff`.
|
|
80
|
+
They were a typed extension point only a test could use.
|
|
81
|
+
- **`diff.reasonFactors`** prices a modified function by _what_ changed about it:
|
|
82
|
+
`type`, `size`, or `implementation` — same type, same DET, same FTR, different
|
|
83
|
+
body. On a real pair of releases, 151 of 378 FP billed as change were
|
|
84
|
+
implementation only. The default does not move: AEP grades the modification
|
|
85
|
+
factor from 0.25 to 1.75 through Effort Complexity variation, which needs
|
|
86
|
+
cyclomatic complexity this package does not measure, so it stays at 1 and every
|
|
87
|
+
diff says so with the amount at stake.
|
|
88
|
+
- The billable total is rounded to cents. `485.00000000000006` is arithmetically
|
|
89
|
+
the same number and not the same document.
|
|
90
|
+
- Warnings print **above** the per-function list. On a real diff that list is over
|
|
91
|
+
a hundred lines, and a caveat that has to be scrolled to is not a caveat.
|
|
92
|
+
|
|
93
|
+
### New
|
|
94
|
+
|
|
95
|
+
- **`fp:metrics`** — density, coupling (Martin's instability) and conformance,
|
|
96
|
+
from the same inventory as the count. If function points pay, the team optimises
|
|
97
|
+
function points; this is the counterweight. Cycles between modules are reported,
|
|
98
|
+
not scored.
|
|
99
|
+
- **`CallResolver.ignores()`** — a third outcome. `resolve` returning `[]` means
|
|
100
|
+
_not recognised_, so a strategy that recognised a call and knew it reached no
|
|
101
|
+
data store had no way to say so. The volume still appears in the confidence
|
|
102
|
+
block: the escape hatch buys coverage, never function points.
|
|
103
|
+
- **`boundary.business`** — restores a store the AFP naming filter (§6.5.2.1.3)
|
|
104
|
+
excluded by accident.
|
|
105
|
+
- `fp:count` reports transactions that read the request without enumerating
|
|
106
|
+
fields (`all()`, `body()`, `except()`), which sit at the floor of their band.
|
|
107
|
+
|
|
108
|
+
### Breaking
|
|
109
|
+
|
|
110
|
+
- `Conformance.writesWithValidator` is now `inputsWithValidator`, and its
|
|
111
|
+
denominator is the transactions that _take_ input rather than every write.
|
|
112
|
+
Measured over every write it read 39% on a healthy application, inviting the
|
|
113
|
+
conclusion that 61% of its writes were unvalidated — they were not: most were
|
|
114
|
+
workflow triggers carrying nothing beyond the route parameter.
|
|
115
|
+
- `HandlerBehavior` gained `requestFields` and `opaqueRequest`.
|
|
116
|
+
- `ResolverContext` gained `exportedAs` and `eventBindings`.
|
|
117
|
+
|
|
118
|
+
### Fixed
|
|
119
|
+
|
|
120
|
+
- An aliased import resolved to the local name, so `import { x as y }` searched
|
|
121
|
+
for `y` in a file that exports `x`.
|
|
122
|
+
- A call on the result of a call was reported as unknown even though the inner
|
|
123
|
+
call — where the unknown is — is reported in the same body.
|
|
124
|
+
- `X.map(callback)` was claimed by `static-service`, which resolved the module and
|
|
125
|
+
reported a missing `map` in it.
|
|
126
|
+
- The package was unusable through ace: `commands/main.ts` exported classes
|
|
127
|
+
instead of the `getMetaData`/`getCommand` contract ace calls, which broke every
|
|
128
|
+
command in the host application.
|
|
129
|
+
- `configure` generated `config/function_points.ts` and no command read it.
|
|
130
|
+
- Generated-schema detection was disabled on Windows by a path-separator mismatch.
|
|
131
|
+
|
|
132
|
+
Coverage across four production applications went from 79.7 / 85.9 / 83.5 / 91.4%
|
|
133
|
+
to 94.8 / 95.1 / 98.4 / 91.4%.
|
|
134
|
+
|
|
135
|
+
## 0.1.0
|
|
136
|
+
|
|
137
|
+
First release. Counts unadjusted function points from AdonisJS 7 + Lucid 22
|
|
138
|
+
source, following OMG Automated Function Points 1.0 (ISO/IEC 19515), with
|
|
139
|
+
`fp:count`, `fp:inventory`, `fp:explain`, `fp:diff` and `fp:calibrate` in both an
|
|
140
|
+
ace and a standalone front-end.
|
|
141
|
+
|
|
142
|
+
Validated against the case study published in Vazquez, Simões & Albert (2011): 46
|
|
143
|
+
FP, exact, with the fixture and the reference count frozen before the counter was
|
|
144
|
+
run against them.
|
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.
|
|
16
|
+
Ruleset: afp@1.2.0
|
|
17
17
|
|
|
18
18
|
type n FP
|
|
19
19
|
ILF 2 14
|
|
@@ -134,6 +134,7 @@ owes you is a number that is still defensible wherever it lands.
|
|
|
134
134
|
| ------------------------------------- | ---------------------------------------------------------- |
|
|
135
135
|
| `node ace fp:count` | counts unadjusted function points |
|
|
136
136
|
| `node ace fp:inventory` | the raw facts: stores, routes, tracing coverage |
|
|
137
|
+
| `node ace fp:metrics` | density, coupling and conformance, from the same run |
|
|
137
138
|
| `node ace fp:explain <name>` | why one function was counted that way |
|
|
138
139
|
| `node ace fp:diff <previous.json>` | additions / modifications / deletions, and the billable FP |
|
|
139
140
|
| `node ace fp:calibrate <samples.csv>` | correction factors against a manual count |
|
|
@@ -143,6 +144,74 @@ saved count against the current state of the application. It deliberately does
|
|
|
143
144
|
**not** take a git ref: booting an older checkout, with possibly different
|
|
144
145
|
dependencies, is a problem not worth solving.
|
|
145
146
|
|
|
147
|
+
### How change is priced
|
|
148
|
+
|
|
149
|
+
AEP §6.5 gives explicit anchors for added (1) and deleted (0.4). For a **modified**
|
|
150
|
+
function it grades the factor from 0.25 to 1.75 through Effort Complexity
|
|
151
|
+
variation, which needs cyclomatic complexity this package does not measure — so it
|
|
152
|
+
defaults to 1, which overestimates, and every diff says so with the amount at
|
|
153
|
+
stake.
|
|
154
|
+
|
|
155
|
+
What the default leaves on the table is a distinction the tool already measures:
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
changed 87 functions 378 FP × 1
|
|
159
|
+
type 4 functions 23 FP
|
|
160
|
+
size 38 functions 204 FP
|
|
161
|
+
implementation 45 functions 151 FP
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`implementation` means same type, same DET, same FTR, different body — a refactor.
|
|
165
|
+
On a real pair of releases that was 151 of 378 FP billed as change. Pricing it at
|
|
166
|
+
full functional value is not defensible, and pricing it at a number this package
|
|
167
|
+
invented would be worse, so the number comes from the contract:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
export default defineConfig({
|
|
171
|
+
diff: {
|
|
172
|
+
reasonFactors: { implementation: 0.25 },
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `fp:metrics` — the counterweight
|
|
178
|
+
|
|
179
|
+
If function points pay, the team optimises function points: more models, more
|
|
180
|
+
endpoints, less reuse. So density and coupling are reported from the **same**
|
|
181
|
+
inventory, and this command exists to put them on the same page as the number.
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
Density
|
|
185
|
+
FP per data store: 27.0
|
|
186
|
+
transactions per data store: 5.4
|
|
187
|
+
|
|
188
|
+
Conformance
|
|
189
|
+
inputs with a validator 100.0% (30/30)
|
|
190
|
+
entry points with a handler 100.0% (163/163)
|
|
191
|
+
data stores reached 96.7% (29/30)
|
|
192
|
+
tracing coverage 95.1% (15 unresolved calls)
|
|
193
|
+
|
|
194
|
+
module FP trans stores I depends on
|
|
195
|
+
pedidos 211 33 8 0.60 inventores, tecnologias, users
|
|
196
|
+
portal 69 15 0 1.00 contato, documentos, inventores, ...
|
|
197
|
+
inpi 70 11 6 0.00
|
|
198
|
+
|
|
199
|
+
Mutual dependencies (cycle candidates)
|
|
200
|
+
inventores <-> tecnologias
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The denominator of the first line is the transactions that **take** input, not
|
|
204
|
+
every write. Measured over every write it read 39% on a healthy application, which
|
|
205
|
+
invites the conclusion that 61% of its writes are unvalidated — and they are not:
|
|
206
|
+
most are workflow triggers (`POST /orders/:id/submit`) that carry nothing beyond
|
|
207
|
+
the route parameter. A metric that makes the reader draw a false conclusion is
|
|
208
|
+
worse than no metric.
|
|
209
|
+
|
|
210
|
+
`I` is Martin's instability, `Ce / (Ca + Ce)`: 0 means everyone depends on it and
|
|
211
|
+
it depends on nobody, 1 means the reverse. A module at 0 that changes often is
|
|
212
|
+
where change hurts. Cycles are **reported, not scored** — what to do about one is
|
|
213
|
+
the team's decision, and a number would hide it.
|
|
214
|
+
|
|
146
215
|
### `fp:explain` — the number has to be defensible
|
|
147
216
|
|
|
148
217
|
```
|
|
@@ -251,6 +320,7 @@ export default defineConfig({
|
|
|
251
320
|
boundary: {
|
|
252
321
|
infrastructure: ['access_tokens', 'audits'], // excluded, with the reason in the report
|
|
253
322
|
externallyMaintained: ['erp_customers'], // counted as EIF instead of ILF
|
|
323
|
+
business: ['chat_sessions'], // the AFP naming filter caught it by accident
|
|
254
324
|
ignoreEntryPoints: ['prometheus.metrics'],
|
|
255
325
|
},
|
|
256
326
|
|
|
@@ -294,7 +364,38 @@ detail: `CreateUserJob.dispatch(p)`, `UserService.create(p)` and `User.find(p)`
|
|
|
294
364
|
are all `Identifier.method(args)`, and only ordering tells them apart.
|
|
295
365
|
|
|
296
366
|
Built-in strategies, most specific first: `same-class-method`, `action-object`,
|
|
297
|
-
`
|
|
367
|
+
`event-dispatch`, `job-dispatch`, `transformer`, `static-service`,
|
|
368
|
+
`property-service`, `module-function`.
|
|
369
|
+
|
|
370
|
+
A job dispatch and an event dispatch are followed as part of the **same**
|
|
371
|
+
transaction: the user clicks and the effect happens, whatever thread runs it.
|
|
372
|
+
Event bindings are read from `emitter.on(event, [listeners])`, so a listener's
|
|
373
|
+
reads and writes count towards the transaction that dispatched the event.
|
|
374
|
+
|
|
375
|
+
### Declaring that a call reaches no data
|
|
376
|
+
|
|
377
|
+
`resolve` has two outcomes — _followed_ and _not mine_ — and sometimes a third
|
|
378
|
+
is the truth: the call is recognised, and it reaches no data store. A wrapper
|
|
379
|
+
over a rate limiter or an attachment variant is a real example. Without a way
|
|
380
|
+
to say so, such a call stays unresolved and drags the coverage gate down.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
const limiterIsDataFree: CallResolver = {
|
|
384
|
+
name: 'login-limiter',
|
|
385
|
+
order: 1,
|
|
386
|
+
resolve: () => [],
|
|
387
|
+
ignores(call) {
|
|
388
|
+
// true means: this is mine, and it touches no data store
|
|
389
|
+
return call.getExpression().getText().startsWith('this.loginLimiter.')
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
`ignores` is asked before `resolve`, in the same order, so a later and more
|
|
395
|
+
generic strategy cannot follow the call into a body it has no business reading.
|
|
396
|
+
It is deliberately more expensive than a list of method names to silence: the
|
|
397
|
+
volume still appears in the confidence block of `fp:count`, because a silent
|
|
398
|
+
drop is the worst defect this package can have — whoever writes it.
|
|
298
399
|
|
|
299
400
|
## Support
|
|
300
401
|
|
|
@@ -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 };
|