@hublo/sentinel 1.4.3 → 1.5.0-alpha.10

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.
@@ -0,0 +1,818 @@
1
+ # Static analysis: the study behind the role
2
+
3
+ Research record for the `static-analysis` role (HUB-41506), written 2026-10-06. It is **not a
4
+ specification**: it records what was measured, what was decided, and the reason attached to each
5
+ decision, so that the next person can disagree with a reason rather than with a preference.
6
+
7
+ Every figure in sections 1 to 5 was measured on this monorepo on `origin/main`, with madge 8.0.0,
8
+ nx 24 and oxlint 1.77.0. Section 6 measures the tools themselves and names its own versions, jscpd
9
+ 5.4.0 and `typhonjs-escomplex` 0.1.0. The conditions matter, and the first section says why.
10
+
11
+ ---
12
+
13
+ ## 0. Notation
14
+
15
+ Every symbol used below, in one place. They come from four different traditions, which is why
16
+ the same idea sometimes carries two names.
17
+
18
+ | symbol | name | what it is |
19
+ | ------------------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
20
+ | `Ca` | afferent coupling | how many components depend on this one. Incoming edges |
21
+ | `Ce` | efferent coupling | how many components this one depends on. Outgoing edges |
22
+ | `DC` | degree centrality | `Ca + Ce`. The overall connectedness of one node. The ACM study calls the two halves `IDC` and `ODC` |
23
+ | `I` | instability | `Ce / (Ca + Ce)`, from 0 to 1. 0 is a stable foundation, 1 is a pure consumer |
24
+ | `A` | abstractness | abstract classes over total classes in a component. ArchUnit counts public ones only |
25
+ | `D` | distance from the main sequence | `abs(A + I - 1)`. How far a component sits from the ideal line `A + I = 1` |
26
+ | `SCC` | strongly connected component | a set of nodes that can all reach each other. One SCC of size n contains every cycle among those n nodes |
27
+ | `CC` | cyclomatic complexity | McCabe's count of independent paths through a **function**. Enforced as a lint rule, not measured here |
28
+ | `CX` | file complexity | the same count aggregated per **file**: its decision points plus its number of functions |
29
+ | `SLOC` | logical lines of code | statements rather than physical lines. Only escomplex produces it |
30
+ | `HV` | Halstead volume | a size measure built from the distinct and total operators and operands |
31
+ | `MI` | maintainability index | a composite of `HV`, `CC` and lines of code. Formula in section 5 |
32
+ | `LOC` | lines of code | physical lines, unless stated otherwise |
33
+ | `Q` | modularity | how well a graph splits into communities, maximised by the Louvain method |
34
+ | `H` | relational cohesion | `(R + 1) / N`, with `R` the edges internal to a module and `N` its members |
35
+ | `LCOM4` | lack of cohesion of methods | connected components among the methods that share a field. Above 1 signals several responsibilities |
36
+ | `DependsOn` | Lakos | the transitive closure of a component, itself included |
37
+ | `CCD` | cumulative component dependency | the sum of every `DependsOn` in the system |
38
+ | `ACD`, `RACD` | average and relative average component dependency | `CCD / N` and `ACD / N` |
39
+ | `NCCD` | normalised cumulative component dependency | `CCD` over that of a balanced binary tree of the same size |
40
+ | `RV`, `ARV`, `GRV` | relative visibility, average, global | visible elements over total elements, per component, averaged, and system wide |
41
+ | churn | | how many commits touched a file over a period |
42
+ | entropy | change entropy | how **scattered** those commits were across files, which is a different question |
43
+
44
+ ⚠️ Not every symbol above is produced in the first version. `A`, `D`, `H` and `LCOM4` are not:
45
+ `A` is not graph-derivable (section 8), and `LCOM4` was dropped because the front end is functional,
46
+ which leaves it a nest-only metric to be re-examined. The Lakos family, visibility, churn and
47
+ entropy are the second version. What the first one produces is listed in section 7.
48
+
49
+ Two conventions worth stating because they bite. A **percentile** is written `p95` and means the
50
+ value below which 95% of the measured population sits. A **rank correlation** is Spearman's: 1
51
+ means two rankings are identical, 0 means they are unrelated.
52
+
53
+ ---
54
+
55
+ ## 1. Three traps that make a number wrong without saying so
56
+
57
+ Each was hit while preparing this study. Each produces a plausible figure and no error message,
58
+ which is precisely the failure the ACM study criticises in existing tools: a result nobody can
59
+ trace back to its cause.
60
+
61
+ ### The wrong tsconfig loses 61% of the graph
62
+
63
+ The 403 path aliases live in `tsconfig.base.json` at the **workspace root**. The applications'
64
+ own tsconfigs declare none. 40% of internal import edges go through an alias, so pointing madge
65
+ at an application's tsconfig hands it an empty resolution table.
66
+
67
+ | madge invocation | nodes | edges |
68
+ | ----------------------------------------------- | --------: | --------: |
69
+ | `--ts-config` on the app's tsconfig (0 aliases) | 859 | 1 930 |
70
+ | `--ts-config tsconfig.base.json` (403 aliases) | **1 785** | **4 882** |
71
+
72
+ Measured on one subtree. The witness file went from 1 edge to 3: both of its alias imports
73
+ appeared.
74
+
75
+ ### nx `implicit` edges invent 20% of cyclicality
76
+
77
+ The nx project graph mixes two kinds of edge. A lambda imports its parent project: a `static`
78
+ edge going up. The parent declares that it owns the lambda: an `implicit` edge coming down.
79
+ Tarjan sees a cycle where there is only an ownership relation.
80
+
81
+ | edges kept | SCCs | projects in cycles | cyclicality |
82
+ | --------------------- | ----: | -----------------: | ----------: |
83
+ | `static` + `implicit` | 14 | 90 | **20.45%** |
84
+ | `static` only | **0** | **0** | **0.00%** |
85
+
86
+ Graph-wide: 2 008 `static` edges, 270 `implicit`.
87
+
88
+ ### A partial scan biases every centrality ranking
89
+
90
+ Scanning a subtree and following its outgoing edges makes libraries appear as targets only.
91
+ Their efferent coupling is then zero by construction and the top of the ranking fills with
92
+ artificial sinks. On a first pass, the four most central files all had `Ce = 0`, not because
93
+ they depend on nothing but because they had not been read.
94
+
95
+ > **Consequence.** Every published figure carries a resolution fingerprint: tsconfig used, edge
96
+ > types kept, scope scanned, tool versions. Without it, a configuration change reads as an
97
+ > architectural regression.
98
+
99
+ ---
100
+
101
+ ## 2. What the code actually measures
102
+
103
+ ### File graph, whole front end (11 109 files scanned)
104
+
105
+ | | value |
106
+ | -------------------------------------- | -------------------------------------------------------------------- |
107
+ | nodes, edges | 12 372, 42 472, mean out-degree 3.43 |
108
+ | System Cyclicality (Lattix definition) | **1.48%**, 183 files in 68 strongly connected components, largest 12 |
109
+ | connectedness (reachable pairs) | **4.65%**, against 0.153% edge density |
110
+ | DC median / p95 / p99 / max | 4 / 17 / 43 / 1 244 |
111
+ | DC = 0, isolated in the graph | **160** (1.3%) |
112
+ | DC > 15 | **721** files (5.8%) |
113
+
114
+ ### Project graph, nx, `static` edges only
115
+
116
+ 440 projects, 2 008 dependencies, **0 cycles**, unstable dependencies 1.1% (23 edges).
117
+
118
+ > **The architectural reading.** Module boundaries are sound; the disorder is inside the
119
+ > modules. `nx/enforce-module-boundaries` polices the project graph and nothing polices the file
120
+ > graph, so that is where this role adds something nx does not already provide.
121
+
122
+ ### Timings
123
+
124
+ | | |
125
+ | ------------------------------------------- | -------------------- |
126
+ | madge, whole front end | **46 s** |
127
+ | nx graph | **11 s** |
128
+ | git churn per file, 12 months | **2 s** |
129
+ | the same pass with lines, authors and dates | **5 s** |
130
+ | git churn per function (`git log -L`) | **106 ms per range** |
131
+
132
+ Which settles the "continuous" question: per-file analysis is affordable on every pull request.
133
+ Per-function churn is not, at 3.6 hours for the whole front end, but it is affordable on a short
134
+ list: 106 seconds for the 100 most central files.
135
+
136
+ ### The files the graph points at
137
+
138
+ | file | DC | Ca | Ce | I | reading |
139
+ | ------------------------------------------- | ----: | ----: | --: | ---: | -------------------------------------------------------------------------------------------- |
140
+ | `libs/front/components/.../Typography.tsx` | 1 244 | 1 244 | 0 | 0.00 | Healthy. Martin's rule is that a heavily depended-upon component should be stable, and it is |
141
+ | `libs/api-types/bff-admin/.../api-types.ts` | 962 | 962 | 0 | 0.00 | Generated. Must be excluded before any rule speaks about tests |
142
+ | `libs/front/tests/src/render/index.ts` | 910 | 908 | 2 | 0.00 | Touching this helper, or the test setup this package ships, reaches 908 files |
143
+ | `libs/front/api/src/bff-admin/config.ts` | 655 | 348 | 307 | 0.47 | **The real finding.** 348 files depend on something that itself depends on 307 others |
144
+
145
+ ---
146
+
147
+ ## 3. Three measured results that decided the model
148
+
149
+ Each was run because a plausible assumption deserved a test rather than an argument.
150
+
151
+ ### Centrality is not file size in disguise
152
+
153
+ A known criticism (Herraiz et al., cited in Hassan's related work) is that most code complexity
154
+ metrics correlate highly with lines of code, so they rediscover that big files are big. Applied
155
+ to centrality, ranking all 12 372 files twice:
156
+
157
+ **Rank correlation DC against LOC: 0.388.** Far from 1, so the two rankings designate different
158
+ files and centrality carries information size does not. The criticism does not bite here.
159
+
160
+ ### What centrality is evidence FOR, and where that evidence stops
161
+
162
+ Two measurements above say what centrality is NOT: not file size in disguise, not a proxy for change
163
+ frequency. Neither says what it is good for, and the literature does.
164
+
165
+ | source | what it establishes |
166
+ | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
167
+ | Zimmermann and Nagappan, ICSE 2008, on Windows Server 2003 | models built from network measures reach a recall **10 percentage points** higher than models built from complexity metrics, and network measures identified **60%** of the binaries the developers themselves called critical, **twice** what complexity metrics found |
168
+ | Tosun, Turhan and Bener, PROMISE 2009, five systems (three small embedded, two Eclipse versions) | network measures are important indicators of defective modules for large and complex systems, **and have no significant effect on small-scale projects** |
169
+
170
+ ⛔ The second one is a boundary on everything this role can claim, so it is written here rather than
171
+ left implicit. Centrality's predictive value is demonstrated on LARGE systems. A module of forty
172
+ files gets a `DC` figure that means exactly what it says about its graph and nothing at all about
173
+ where its defects are. `host-admin` at 5 880 files sits on one side of that line; most of
174
+ `libs/nest` sits on the other. It becomes the `caveat` of the centrality help entry, unchanged.
175
+
176
+ And it settles what the number is FOR, which is a question the role has to answer before it prints
177
+ anything. Test impact analysis selects: given this diff, which tests can catch a defect in it.
178
+ Centrality is a property of the code and knows nothing about a diff, so it cannot select. What it can
179
+ do is ORDER: which tests to run first, and where strengthening tests pays. The two compose, and the
180
+ composition has a sharp consequence worth stating: a highly central file is precisely the one where
181
+ impact-based selection saves nothing, because changing it reaches almost everything.
182
+
183
+ ### Centrality is not a proxy for change frequency
184
+
185
+ The hypothesis was that a central file, being heavily used, is frequently changed, which would
186
+ make git history redundant.
187
+
188
+ | | |
189
+ | --------------------------------- | ------------- |
190
+ | rank correlation DC against churn | **0.361** |
191
+ | files common to both top 100 | **13 of 100** |
192
+
193
+ | counter-example | DC | churn |
194
+ | ------------------------------------------------ | --: | ----: |
195
+ | `front-legacy/src/constants/routes/index.ts` | 424 | 2 |
196
+ | `libs/front/translations/.../main.planning.json` | 2 | 309 |
197
+
198
+ So a central file can be perfectly stable, which is often the sign that it is well made. The two
199
+ are **the two factors of a product, not substitutes**: centrality is the consequence term,
200
+ churn is the likelihood term. This is why CodeScene's hotspot is a multiplication.
201
+
202
+ > A corollary that shaped the model: a vector of uncorrelated metrics **combines, it does not
203
+ > stack**. Requiring central AND long AND complex AND poor MI selects almost nothing.
204
+
205
+ ### The graph does not see the product
206
+
207
+ Raised in a tech lead review of the test strategy: none of these metrics says which user journeys
208
+ matter, so a file can be central and uninteresting, or peripheral and critical. The objection
209
+ holds, and it holds twice.
210
+
211
+ **A screen is a structural leaf.** Over the 596 front end files that live under a `routes/` or a
212
+ `pages/` directory, `routeTree.gen.ts` excluded:
213
+
214
+ | | share of the 596 |
215
+ | ----------------------------------------------- | ---------------- |
216
+ | Ca ≤ 1, at most one file depends on them | **83.2%** |
217
+ | Ca = 0, nothing in the codebase depends on them | **46.3%** |
218
+
219
+ This is not a defect in the metric, it is what afferent coupling means: a screen is where the
220
+ product value is and it is the last node of the graph, so centrality ranks it last by
221
+ construction.
222
+
223
+ **And the graph cannot carve a journey out on its own.** If a journey were given by its entry
224
+ point, there are two ways to expand it into a set of files, and both collapse. Measured on the
225
+ 182 route files of `host-admin`:
226
+
227
+ | expansion | result |
228
+ | ------------------------------------------------------ | ------------------------------------------------------------------------------------ |
229
+ | transitive closure of one route file | median **440** files, max 5 733. The 5 largest routes alone reach **70%** of the app |
230
+ | exclusive, reachable from this route and from no other | median **0**. 13 of the 18 route groups own exactly zero files |
231
+
232
+ The two bracket the problem. Expanded transitively a journey is the whole application; reduced to
233
+ what it owns alone it is empty. The useful set in between, the files that actually carry the
234
+ journey, is not in the graph. It is in the team's head.
235
+
236
+ ---
237
+
238
+ ## 4. What was decided, and why
239
+
240
+ ### No single score. Six categories instead.
241
+
242
+ Weighting would need experimentation to fix the formula, and a weight nobody can justify is a
243
+ claim nobody can defend. Six correct numbers beat one indefensible score.
244
+
245
+ This is consistent with three independent positions: the ACM study's own 6D signature per
246
+ module, Lattix's documentation ("it is hard to come up with a single number that represents the
247
+ architectural health of a system... instead, a set of numbers"), and the ACM study's criticism
248
+ of Sonar, which is not that it aggregates but that it aggregates opaquely.
249
+
250
+ The door stays open: the correlation matrix that would justify weights later is the same one
251
+ that validates the categories now. Two of its cells are already filled, and both are weak.
252
+
253
+ **A category is sound when its metrics lead to the same action.** Otherwise it is a filing
254
+ cabinet, not a tool.
255
+
256
+ | category | the question | contains | the action |
257
+ | ---------------- | ------------------------------- | -------------------------------------------- | ------------------- |
258
+ | **Impact** | what does it break | Ca, Ce, DC, I, later A and D | none. It **orders** |
259
+ | **Activity** | how much does it move | churn, entropy, authors, co-change | none. It **orders** |
260
+ | **Cycles** | what bites its own tail | SCCs, System Cyclicality | move one import |
261
+ | **Modularity** | is the split right | Q, density per module | re-cut, move a file |
262
+ | **Complexity** | is it readable | CX per file and per folder, Halstead, MI | read it, then split |
263
+ | **Duplication** | does it already exist elsewhere | clones, duplicated lines, share per language | extract, or accept |
264
+ | **Reachability** | is any of it still run | dead code, with a confidence per finding | delete, or declare |
265
+
266
+ Cycles is separate from Impact although both come from the graph, because the action differs: a
267
+ cycle is fixed, a high centrality is not. **Impact × Activity** are the two terms of risk and
268
+ repair nothing; the other four repair.
269
+
270
+ On naming: a category is named after what it **measures**, never after where the number comes
271
+ from and never as a verdict. "Centrality" was rejected because it is the name of one metric
272
+ inside the category and describes I, A and D badly. "Importance" was rejected because it smuggles
273
+ a judgement, and the counter-example is in our own data: `Typography.tsx` is the most central
274
+ file in the front end and it is perfectly healthy.
275
+
276
+ ### Everything is computed per module, with one measured exception
277
+
278
+ A global top 100 by centrality returns 63 files from `libs/front` and 23 from `host-admin`, and
279
+ **three entire applications never appear**: `console` (526 files), `hr-management` (298) and
280
+ `career` (75), whose most central files sit at DC 43, 27 and 25. Such a list does not say "the
281
+ 100 most critical files", it says "`libs/front` exists", and three teams never receive a line of
282
+ the report.
283
+
284
+ Per module also repairs two metrics: density discriminates again at small N, and `H = (R+1)/N`
285
+ only means something with R counted as edges internal to the module.
286
+
287
+ ⚠️ The exception is reachability, and it is structural rather than a tuning problem. A library's
288
+ consumers live in other modules: `libs/front/components` declares no `main`, `module`, `types`,
289
+ `exports` or `files`, has no barrel, and is imported through a tsconfig alias
290
+ (`@front/components/data-display/badge/Badge`), so **30.3%** of its lines read as dead from inside
291
+ the module, identically from its root and from `src`. No internal declaration repairs that, and
292
+ declaring every file an entry point would empty the metric. Either that one pass runs at workspace
293
+ scope, or the metric is marked not applicable to a library. Open, see section 8.
294
+
295
+ ### The product axis is declared, not observed
296
+
297
+ The objection above has an obvious candidate answer, production telemetry. Real user monitoring
298
+ (RUM), Datadog's in our case, knows which screens are really used. It was not taken, and the
299
+ reason is not the quality of that data.
300
+
301
+ **Static analysis runs before, telemetry reports after.** RUM describes what is in production. The
302
+ decisions this role exists to inform are taken earlier: while the code is being written, when a
303
+ technical choice is made, in CI before a merge. Code that is being written now has no traffic, and
304
+ that is not a marginal case here:
305
+
306
+ | front end `.ts` and `.tsx` files, generated route trees excluded | |
307
+ | ---------------------------------------------------------------- | ----------------- |
308
+ | tracked in git | 10 947 |
309
+ | added in the last 90 days and still present | **3 115 (28.4%)** |
310
+
311
+ The structural axis cannot stand in for the missing signal either, because it does not separate
312
+ that population:
313
+
314
+ | | median DC | DC > 15 | p95 |
315
+ | ------------------------- | --------- | -------- | --- |
316
+ | added in the last 90 days | 4 | **4.3%** | 15 |
317
+ | older | 4 | **6.3%** | 18 |
318
+
319
+ So the input is a **declaration**. The team states which paths carry a journey, the way a lint
320
+ configuration states which rules a project wants. It sits in the repository, it is reviewed in
321
+ a pull request, it is versioned with the code it describes, and it can therefore name a feature
322
+ that does not exist in production yet.
323
+
324
+ ```js
325
+ // static-analysis.config.mjs
326
+ import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
327
+
328
+ export default defineStaticAnalysis({
329
+ root: import.meta.dirname,
330
+ journeys: {
331
+ 'contract signature': ['src/features/contracts/**'],
332
+ },
333
+ entry: ['**/*.steps.ts'],
334
+ })
335
+ ```
336
+
337
+ The journey's name is the **key**, not a field, and its value is a list of globs or explicit files,
338
+ matched the same way. ⚠️ Those paths are module-relative, because the scan is one module at a time:
339
+ a journey cannot name a file in another module, and one that spans two modules is declared once on
340
+ each side.
341
+
342
+ Beside it, `entry` declares the files that RUN although nothing imports them, which is where
343
+ reachability starts. It is the single most consequential line in the file: on one real service the
344
+ dead-code figure is 33.4% with nothing declared and 1.0% with two patterns declared.
345
+
346
+ There is deliberately no grammar for a sequence of steps: the order of a journey is never used by
347
+ anything downstream, so asking for it would be asking teams to maintain a fact we do not read. And
348
+ it is a function call rather than a JSON object because a partial declaration has to be mergeable:
349
+ see section 7.
350
+
351
+ ### A declared set is a named subgraph, not a selector
352
+
353
+ A declared journey is not an input to centrality. It names a subgraph, and **every category is then
354
+ computable on that subgraph** rather than only on the module. That is what the declaration actually
355
+ buys, and for two categories it is the difference between a measurement and no measurement at all.
356
+
357
+ | category | on a declared set | what was impossible without it |
358
+ | --------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
359
+ | **Impact** | the set's own incoming and outgoing coupling, as one unit | a journey's blast radius. Per-file `Ca` never sums to it |
360
+ | **Cohesion** | internal edge share and `H` of the set | there was no candidate unit to test other than the folder tree |
361
+ | **Cycles** | cycles that **cross** two declared sets, reported ahead of cycles inside one | a cycle's product meaning. Two coupled journeys is a different fact |
362
+ | **Duplication** | clones inside one journey against clones **between** two journeys | telling local repetition from an unextracted shared concept |
363
+ | **Complexity** | the same thresholds, with the declared sets ordered first | ordering by anything other than the metric itself |
364
+ | **Activity** | per-function churn, at 106 ms per range, on a short list that means something | the only affordable short list was the top 100 by centrality |
365
+
366
+ The shape was checked rather than assumed. Taking `console/src/features/*`, which is literally the
367
+ shape of a `paths` entry:
368
+
369
+ | feature | files | internal edge share | H |
370
+ | ------------ | ----: | ------------------: | --------: |
371
+ | client | 334 | 78% | 2.76 |
372
+ | audit | 42 | 58% | 2.02 |
373
+ | institutions | 35 | 61% | 1.63 |
374
+ | auth | 7 | **20%** | **0.57** |
375
+ | home | 1 | 0% | 1.00 |
376
+ | **total** | 419 | **74%** | mean 1.60 |
377
+
378
+ 74% of the edges stay inside, and 3 of the 5 land in NDepend's 1.5 to 4.0 band for `H`, so a
379
+ declared set behaves like a unit and not like an arbitrary list. And `auth` is immediately a finding
380
+ rather than noise: 7 files with 3 internal edges against 12 outgoing is a wrapper, not a feature.
381
+
382
+ > ⚠️ **A declared set is a cover, not a partition.** The same file can serve two journeys, which is
383
+ > exactly the exclusive footprint result above. So modularity `Q` does **not** apply to it: `Q` is
384
+ > defined on a partition. What applies is per-set cohesion and conductance, which tolerate overlap.
385
+ > `Q` stays a module-level metric computed on the folder tree.
386
+
387
+ **Within Impact, the report is two blocks, and their order is the whole point.**
388
+
389
+ 1. the files matching a declared `paths` pattern, grouped under their `name`
390
+ 2. the top N, **minus** everything already listed in block 1
391
+
392
+ N defaults to 100 and is configurable. A file appears once, and a declared file that is also
393
+ central is reported as declared. Without the subtraction the declaration would buy nothing: it
394
+ would add names in front of a list that already contained the central files, and still never
395
+ surface the critical file that is not central, which is the entire gap being closed.
396
+
397
+ ### The line: scope and volume are the team's, thresholds are ours
398
+
399
+ | the config carries | the config does not carry |
400
+ | ------------------------------------------------------------------------ | ---------------------------------------- |
401
+ | **scope**: which paths carry a journey, and the name the product uses | the value of any measurement, by default |
402
+ | **volume**: how many files to report, how many months of history to read | what counts as central |
403
+
404
+ `DC > 15`, duplication at 50 tokens over 5 lines, a complex file at `CX >= 50`: those stay on this
405
+ side. A team declares a fact about its domain and chooses how much output it wants, it does not move
406
+ the instrument. ⚠️ "By default" above is exact and not a hedge: an `overrides` block can move any of
407
+ them, nothing advertises it, and every value it sets is printed as `(module)` in the report, because
408
+ two modules whose thresholds differ silently are no longer comparable. See section 7.
409
+
410
+ Per-function limits are deliberately absent from that list: cyclomatic complexity per function is a
411
+ lint rule, already at 30 in the nest preset, while `CX` here is **per file**. Same word, two units,
412
+ and the report prints both. Those values will be calibrated from use and from the
413
+ feedback the role collects, which is the same commitment a preset rule carries in
414
+ `CONTRIBUTING.md`.
415
+
416
+ Two consequences of how it is written, rather than of what it contains:
417
+
418
+ - It is filled **with the product**, not by developers alone, which is why the name is the object's
419
+ key rather than a comment: the name is the part a product person validates, the paths are its
420
+ translation.
421
+ - `--init` generates `static-analysis.config.mjs` like every other config this tool writes, and the
422
+ declared part survives a re-init. The precedent is the format role's `$sentinel.local`, where the keys a module owns are
423
+ **declared rather than inferred**, so that an override survives a preset bump only when someone
424
+ said out loud that it should. Here the same idea is split by nature: `journeys` and `entry` are
425
+ **added** to what the role ships, so no team can lose a shipped pattern by declaring one of its
426
+ own, while `overrides` replaces and is attributed.
427
+
428
+ **Why patterns and not route resolution.** Deriving the journey from the router was considered and
429
+ dropped: only 3 of the 9 front end applications have a `routeTree.gen.ts`, so it would work on a
430
+ third of the code and silently do nothing on the rest. A pattern also reads at review time without
431
+ running anything, and it can name a file that does not exist yet.
432
+
433
+ ### What was deliberately not taken
434
+
435
+ | | reason |
436
+ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
437
+ | Sonar's maintainability rating | `debt / (30 min × LOC)` puts size in the **denominator**, so a longer file gets a better rating at constant debt. The Halstead-based MI subtracts `16.2 × ln(LOC)`, so it gets worse. They move in opposite directions with respect to size and are not two approximations of the same thing |
438
+ | Arcan's dynamic threshold | It is `max(system threshold, benchmark threshold)` against the Qualitas Corpus, which is Java. And it solves a problem we do not have: DC = 15 sits at the p94 in `host-admin` (5 880 files) and the p94.7 in `career` (75) |
439
+ | dependency-cruiser | It implements cycles, orphans and Martin's instability with the same formula, and can fail a build, but an earlier benchmark judged its values unreliable. The ACM study kept madge and graphology |
440
+ | Edge density as a threshold | Measured at 0.00153. It decays as 1/N, so it always reads "very sparse" at our scale. Replaced by Lattix's connectedness |
441
+ | A second duplication diff engine | jscpd 5.4 already has `--baseline`, `--update-baseline`, `--fail-on-new-clones` and `--baseline-from-ref origin/main` |
442
+ | jscpd's `--health` score | A 0-100 grade from duplication, dead code and complexity: `100 x 0.5^(adjusted/half-life)` per dimension, then the geometric mean across them, with half-lives calibrated on 42 open-source projects. The formula is worth borrowing; weighting the quality of our own modules is our governance, not a vendor's |
443
+ | jscpd's `--semantic` pass | Type-4 clones through code embeddings, experimental, and it needs a model download. Parked on purpose: the capability is interesting for SAST and taint analysis later, not for a duplication figure now |
444
+ | RUM traffic as a criticality input | It answers a question this role asks earlier than production can answer it, and 28.4% of the front end is less than 90 days old. Declared paths replace it, see above. Nothing prevents comparing the two later, once there is a report to compare against |
445
+
446
+ ---
447
+
448
+ ## 5. Published thresholds, by source
449
+
450
+ What each tool publishes, and whether it survives contact with our code.
451
+
452
+ ### NDepend
453
+
454
+ | metric | threshold | our state |
455
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
456
+ | cyclomatic complexity | **> 15** hard, **> 30** split | **both, in two units**: `complexity: ["error", 30]` per FUNCTION in the nest lint preset, and `CX` per file measured by this role, ungated. NDepend's own figure is per method |
457
+ | lines per method | > 20 hard, > 40 split | not enforced. The ticket allows 50 in TS and 250 in TSX |
458
+ | nesting depth | > 4 hard, > 8 extreme | not enforced. `max-depth` exists in the planning layer only |
459
+ | efferent coupling, type level | Ce > 50 | 11 files. The ACM study's ODC > 15 designates 228 |
460
+ | relational cohesion H | `(R + 1) / N`, good range **1.5 to 4.0** | not computed. Computable on the graph: R = edges internal to the module |
461
+ | distance from main sequence | ` | A + I − 1 | `, **> 0.7 problematic** | blocked: A is not graph-derivable |
462
+ | maintainability index | `MAX(0, (171 − 5.2·ln(HV) − 0.23·CC − 16.2·ln(LOC)) × 100/171)`, Visual Studio bands 0-9 / 10-19 / 20-100 | computed by escomplex, on the **raw 0 to 171 scale** and not as a percentage. The residual is coverage, not absence: 32.6% of one nest service does not parse, see sections 6 and 8 |
463
+
464
+ NDepend's own warning about its own metric is the most useful line in their documentation: the
465
+ coefficients were calibrated on languages that predate C#, so an absolute score is not reliable.
466
+ Their recommendation is to **rank the worst**, to **combine with complexity, coupling and change
467
+ history**, and to **detect regressions** rather than impose a floor.
468
+
469
+ ### Lattix
470
+
471
+ | metric | formula | threshold |
472
+ | -------------------------- | ---------------------------------------------------- | ------------------------------------- |
473
+ | System Cyclicality | **percentage** of elements in a cycle, not a count | none |
474
+ | Intercomponent Cyclicality | same, across components | **target: zero** |
475
+ | Coupling | `100 × n / (V(V−1)/2)`, n = strongly connected pairs | none |
476
+ | Connectedness | `100 × reachable pairs / V(V−1)` | none |
477
+ | System Stability | `100 − (average impact / atom count) × 100` | **must increase as the system grows** |
478
+
479
+ ### ArchUnit
480
+
481
+ | metric | formula | threshold |
482
+ | ---------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------- |
483
+ | DependsOn (Lakos) | transitive closure of a component, itself included | none |
484
+ | CCD | sum of all DependsOn | lower is better. A cycle inflates it: all members share one value |
485
+ | ACD, RACD | `CCD/N`, `ACD/N` | **RACD bounded by 0.6** for an acyclic graph of at least 5 components |
486
+ | NCCD | CCD over that of a balanced binary tree of the same size | lower is better |
487
+ | A, abstractness | abstract over total classes, **public only** | none |
488
+ | RV, ARV, GRV (Dowalil) | visible over total elements, per component then averaged then global | lower is better |
489
+
490
+ `FreezingArchRule` stores **individual violations** under version control; only new ones are
491
+ reported and the store shrinks by itself as violations are fixed. It ignores line numbers, so a
492
+ violation that moves stays known.
493
+
494
+ Note that `DependsOn` measures the **outgoing** transitive closure, which is what can break this
495
+ component, not what this component breaks. Blast radius is the incoming closure. Measured on our
496
+ project graph, its rank correlation with plain Ce is 0.819, so it adds little to a ranking.
497
+
498
+ ### CodeScene
499
+
500
+ Code Health is a weighted average of 25+ factors, weighted by each file's lines of code, over
501
+ three bands. Factors include LCOM4 for cohesion, Brain Class and Brain Method, Bumpy Road,
502
+ Complex Conditional and Primitive Obsession. Brain Class is a **conjunction**: many lines, many
503
+ functions, and at least one Brain Method, which makes it harder to contest than a single
504
+ threshold. Numeric thresholds are not published on their documentation pages.
505
+
506
+ Hotspot is code health **multiplied by** change frequency, and reframes the question from "is
507
+ this code bad" to "is this bad code important now".
508
+
509
+ ### Arcan
510
+
511
+ | smell | detection | threshold |
512
+ | ------------------- | ------------------------------------------------------------------------------------- | ------------------------ |
513
+ | Cyclic Dependency | Sedgewick-Wayne, Tarjan, or Laval-Falleri for layered cycles | algorithmic |
514
+ | God Component | **too many lines of code** in a container, not too many dependencies | `Max(system, benchmark)` |
515
+ | Hub-Like Dependency | `FanIn + FanOut` | same dynamic threshold |
516
+ | Unstable Dependency | a **relation between two containers**: one depends on a less stable one, with a delta | Martin's instability |
517
+
518
+ Two vocabulary collisions worth knowing: "God Component" means size here and fan-out in the ACM
519
+ study, and "Unstable Dependency" is a property of an edge here, not of a node.
520
+
521
+ ### Google and Meta, on measuring the analyser rather than the code
522
+
523
+ | metric | formula | threshold |
524
+ | --------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
525
+ | not-useful rate (Tricorder) | `NOT USEFUL / (NOT USEFUL + PLEASE FIX + APPLY FIX)`. `PREVIEW FIX` is not in the denominator | **≥ 10% probation, > 25% may be turned off immediately** |
526
+ | fix rate (Infer) | findings that led to a change, over findings emitted | the same analyser with the same defects: nearly nothing fixed in batch, **over 70% fixed at code review** |
527
+
528
+ The Infer result is the one with a design consequence: the integration point weighs as much as
529
+ the algorithm, which separates the developer surface (a pull request comment) from the staff
530
+ surface (a trend) even though both run the same engine.
531
+
532
+ ### Change entropy (Hassan, ICSE 2009)
533
+
534
+ `Hₙ(P) = −Σ pₖ · log₂ pₖ`, where P is, **over a period**, the probability distribution of a
535
+ given file being modified. Maximum entropy when every file changes equally, minimum when one
536
+ file absorbs everything.
537
+
538
+ Entropy is not churn. Churn counts modifications; entropy measures how **scattered** they were.
539
+ Ten changes in one file is zero entropy; ten changes across ten files is maximal. It is the
540
+ observable trace, in the history, of what modularity claims about the structure.
541
+
542
+ Three models: the Basic and Extended Code Change models give one value per period for a
543
+ project, and the **File Code Change model redistributes it onto individual files**, which is the one that would serve here. Validated on six large open source
544
+ projects, where the change complexity metrics were better predictors of fault potential than
545
+ prior modifications and than prior faults.
546
+
547
+ The related work carries two results that challenge the Complexity category: Herraiz et al. show
548
+ that most code complexity metrics correlate highly with LOC, and Moser et al. that process
549
+ metrics outperform code metrics as fault predictors.
550
+
551
+ ---
552
+
553
+ ## 6. The tool, measured rather than read
554
+
555
+ The study above was written against `jscpd` 4.0.5. The published version is **5.4.0** (30 September
556
+ 2026), a Rust rewrite, and it changes what this role has to build. Everything below was measured
557
+ on this repository, not taken from the documentation; where the documentation and the measurement
558
+ disagree, the measurement is kept and the disagreement is stated.
559
+
560
+ ### What it is
561
+
562
+ | | |
563
+ | ---------------- | ----------------------------------------------------------------------------------------- |
564
+ | shape | a 20 KB launcher plus one of eight per-platform binaries, pulled as optional dependencies |
565
+ | npm dependencies | **zero** (4.0.5 carried nine) |
566
+ | engines | `>=18`, so the Node 20 floor holds |
567
+ | speed | 180 files in **28 ms**; the dead-code pass on a 2180-file service in **84 ms** |
568
+ | provenance | same repository `kucherenko/jscpd`, same maintainer, MIT |
569
+
570
+ ### What it already does, that this role no longer has to write
571
+
572
+ | planned here | 5.4.0 |
573
+ | --------------------------------------- | --------------------------------------------------------------------------------------- |
574
+ | warn now, block later | `--baseline`, `newClones`, `--baseline-from-ref`, `--fail-on-new-clones` |
575
+ | cyclomatic complexity | `--complexity`, McCabe exact, calibrated below |
576
+ | console, json, html | native reporters, plus markdown, sarif, codeclimate, openmetrics, badge |
577
+ | git history, deferred to a second phase | `--history`, `--compare`, `--blame` |
578
+ | nothing | `--dead-code` (a separate engine, `basta`), `--health`, `--dashboard`, `--mcp`, `--lsp` |
579
+
580
+ ### How detection works
581
+
582
+ Tokenisation follows each language's own grammar, and for JavaScript, TypeScript, JSX and TSX the
583
+ tokens come from the **oxc** parser. A rolling Rabin-Karp hash over the token stream finds every
584
+ repeated window of at least `minTokens` tokens and `minLines` lines, and the window is therefore
585
+ the floor of visibility: a duplication shorter than it does not exist as far as the figure is
586
+ concerned.
587
+
588
+ Cyclomatic complexity was calibrated against files of known McCabe value, and matches exactly:
589
+
590
+ | fixture | expected | `CX` |
591
+ | ------------------------------- | -------: | -----: |
592
+ | one function, no decision point | 1 | **1** |
593
+ | one function, 4 `if` | 5 | **5** |
594
+ | one function, 9 `if` | 10 | **10** |
595
+ | three functions, 2 `if` each | 9 | **9** |
596
+ | `for` + `while` + `if` + `&&` | 5 | **5** |
597
+
598
+ ### Seven more traps, this time in the tool
599
+
600
+ These are the reason the role's configuration looks the way it does. Each one was reproduced.
601
+
602
+ | trap | measurement |
603
+ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
604
+ | **the entry declaration decides the dead-code figure** | on `apps/nest/microservices/network`: **33.4%** of lines dead with nothing declared (79 657 lines, 13 entry points), **1.0%** with two patterns declared (2 462 lines, 207 entry points) |
605
+ | **the share of duplicated lines double-counts** | two 9-line files report **13 duplicated lines of 18, 72%**, adding an `exact` clone of lines 1-4 to a `similar` clone of lines 1-9 that contains it; the real extent is 9 of 18, 50%. The tool's own answer is to cap the display at 100% |
606
+ | **the language list is not the file extension** | `typescript` alone scans **300** of `apps/front/console`'s 523 files, because `.tsx` is a separate language; all four scan 522, the one left out being `styles.css` |
607
+ | **`maxLines` excludes large files** | passing 1000 explicitly drops **22** files on nest/network, exactly those over 1000 lines |
608
+ | **the duplication window filters the complexity pass** | at `minTokens` 50 the header reads "3 files analyzed" out of five, the two smallest dropped without a word |
609
+ | **a scan that analyses nothing exits 0** | with a warning only, so `--fail-on-empty` has to be set |
610
+ | **the configuration is read from the working directory** | a `.jscpd.json` dropped in a module makes the run report **0 clones**, silently. Measured: same fixture, 1 clone from outside the module, 0 from inside it, 1 again when `--config` is passed |
611
+
612
+ Two further counting facts, both of which change how a figure may be worded:
613
+
614
+ - `n` identical copies produce **`n - 1`** clone entries, neither `n` occurrences nor the
615
+ `n(n-1)/2` pairs. And when a third file shares only part of a block, only the longest match per
616
+ pair is kept, so the same block appears under two fragment identities and the occurrence count
617
+ cannot be recovered. **Fowler's Rule of Three is therefore a reading instruction, never a
618
+ threshold.**
619
+ - `--mode mild|weak|strict` measured **identical** on a comment inserted mid-block and on a pure
620
+ reindentation. The documentation describes the three as dropping whitespace, dropping comments
621
+ and keeping everything; for JS, TS, JSX and TSX the oxc token stream contains neither, so the
622
+ option does nothing here and the `mode: strict` inherited from the ACM study is decorative.
623
+
624
+ ### What stays out
625
+
626
+ | | why |
627
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
628
+ | `--health` | a composite 0-100 score with a letter grade, from duplication, dead code and complexity. Its formula is `100 x 0.5^(adjusted/half-life)` per dimension and the **geometric mean** across them, with half-lives calibrated on 42 open-source projects. The formula is worth borrowing; weighting the quality of our modules is our governance, not a vendor's |
629
+ | `--semantic` | Type-4 clones through code embeddings, experimental, and it needs a model download. Parked deliberately: the capability is interesting for SAST and taint analysis later, not for a duplication figure now |
630
+
631
+ ### The benchmark, with its reservation
632
+
633
+ The project's own benchmark uses a 547-file, 21 645-line corpus on Apple Silicon and reports
634
+ jscpd at **84 ms** against PMD CPD at **35 980 ms**. But jscpd analysed **347** of the 547 files
635
+ and PMD **71**, so "the fastest of the set" holds and the factor of 428 does not.
636
+
637
+ ### What escomplex is still needed for, and what it costs
638
+
639
+ `typhonjs-escomplex` is the only source for logical `SLOC`, the Halstead set and `MI`. It is also
640
+ far faster than previously recorded: **0.8 to 1.6 ms per file** in a single process, not the 23 to
641
+ 29 ms measured when a process was spawned per file. The obstacle is coverage, not speed:
642
+
643
+ | module | files | failures, default | with `decoratorsLegacy` |
644
+ | ------------------------------------- | ----: | ----------------: | ----------------------: |
645
+ | `apps/front/console/src` | 523 | 5 = **1.0%** | 5 = **1.0%** |
646
+ | `apps/nest/microservices/network/src` | 2169 | 1045 = **48.2%** | 708 = **32.6%** |
647
+
648
+ By default 812 of the nest failures are "Decorators must be placed after the `export` keyword":
649
+ its parser forces `decoratorsBeforeExport: false`, which rejects `@Injectable()` above
650
+ `export class`. The option goes through the **fourth** argument of `analyzeModule(source, options,
651
+ parserOptions, parserOverride)`; the third one is a trap, because `BabelParser.parse` does
652
+ `options = typeof options === 'object' ? options : defaultOptions`, so an object passed there
653
+ replaces the whole default plugin set and the TypeScript plugin disappears, taking nest to 97.2%.
654
+ The remaining 708 are `state.generator[node.type] is not a function`, an internal limit.
655
+
656
+ jscpd, on the same service, reports `statistics.unparsed` of **0** out of 2180. So jscpd owns
657
+ every metric it can compute, and escomplex keeps only what nothing else provides.
658
+
659
+ ---
660
+
661
+ ## 7. What the role does
662
+
663
+ ### Perimeter
664
+
665
+ The scan is the **module root**, one module at a time, with `node_modules`, `dist`, `build`,
666
+ `coverage`, the report directory, generated files and fixtures excluded, on top of the
667
+ `.gitignore` the tool already honours. Both halves are measured: without an ignore list the
668
+ sentinel package itself reports 18 186 files and 32 153 clones, **31 969** of them touching
669
+ `node_modules`; with it, 408 files and **zero**. And the root rather than `src` because the
670
+ manifest and the root configuration files are the authoritative entry points:
671
+
672
+ | module | from the root | from `src` |
673
+ | --------------------------------- | ------------: | ---------: |
674
+ | `apps/front/console` | **0.8%** dead | 2.1% dead |
675
+ | `apps/nest/microservices/network` | 1.0% | 0.9% |
676
+ | `libs/front/components` | 30.3% | 30.3% |
677
+
678
+ ### The three outputs, and one naming collision
679
+
680
+ Every target name is a CLI flag, so `--format` already belongs to the format role and cannot mean
681
+ an output shape. `--json` already exists engine-wide, documented for every verb.
682
+
683
+ | script | command |
684
+ | ---------------------- | ---------------------------------- |
685
+ | `static-analysis` | `sentinel --run --static-analysis` |
686
+ | `static-analysis:json` | `... --output json` |
687
+ | `static-analysis:html` | `... --output html` |
688
+
689
+ One report under `static-analysis`, not one per metric, assembled from **two** files:
690
+ `jscpd-report.json` and `basta-report.json`.
691
+
692
+ ### The configuration seam
693
+
694
+ Sentinel exports a function; the module calls it.
695
+
696
+ ```js
697
+ // static-analysis.config.mjs
698
+ import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
699
+
700
+ export default defineStaticAnalysis({
701
+ root: import.meta.dirname,
702
+ entry: ['**/*.steps.ts'],
703
+ journeys: { planning: ['src/planning/**'] },
704
+ })
705
+ ```
706
+
707
+ A function and not `{ ...baseConfig, ... }`, because a spread is shallow: a module writing
708
+ `deadCode: { minConfidence: 80 }` would drop `categories`, and the flattening happens in the
709
+ module's file, before this package sees the object. A function receives the keys the module
710
+ actually set. Second reason: no default is copied into a module, so changing one reaches every
711
+ module at the next release rather than leaving stale copies behind.
712
+
713
+ | | rule |
714
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
715
+ | `entry`, `journeys` | facts a team declares, written in plain sight by `--init`, and **added** to the shipped ones, so no module can lose `**/*.integration-spec.ts` by declaring a third pattern |
716
+ | thresholds, windows, `kinds` | judgments, internal to sentinel, never written into a module |
717
+ | `overrides` | the seam: it **replaces**, including those lists, and every path it sets is recorded so the report can print `(module)` next to the value |
718
+ | no file at all | the role runs on its own defaults; that is not a failure |
719
+ | a file that throws | distinguished from a missing one and reported, because the two call for opposite actions |
720
+
721
+ The file is `.mjs` rather than `.mts` because **sentinel** consumes this config and would
722
+ otherwise need a transpiler at runtime to read six lines. `--status` keeps the cheap path
723
+ regardless: adoption is "the module has the script", read from `package.json`, so a
724
+ workspace-wide sweep still executes nothing. And the resolved settings are written to a
725
+ configuration file that is passed with `--config`, so the tool never discovers a stray
726
+ `.jscpd.json`.
727
+
728
+ ### The passes, and who renders the verdict
729
+
730
+ | pass | settings |
731
+ | ----------- | ------------------------------------------------------------------------------------------------------------------ |
732
+ | duplication | `minTokens` 50, `minLines` 5, **`kinds: exact` only**, `--fail-on-empty`, and **never** `--threshold` |
733
+ | dead code | `minConfidence` 60, four categories without `unused-member`, entry points `*.integration-spec.ts` and `*.steps.ts` |
734
+ | complexity | its **own** window at `minTokens` 10, and `--summary-top` raised, since it defaults to 10 entries |
735
+
736
+ **Sentinel renders the verdict, from the statistics, never from the exit code.** Without
737
+ `--threshold` the tool exits 0 even with 32 clones and still writes its report, including when
738
+ nothing is duplicated; a non-zero exit therefore means the tool failed. The first version
739
+ computes over the whole project every time, with no baseline: refining to what a pull request
740
+ changed comes second, and correctness comes first.
741
+
742
+ `kinds: exact` only is a correctness constraint rather than caution, for the double-counting
743
+ reason above: opening a second kind requires computing the share ourselves from the **union** of
744
+ the line ranges.
745
+
746
+ ### Metrics by source
747
+
748
+ | metric | tool |
749
+ | --------------------------------------------------------- | ------------------ |
750
+ | clones, duplicated lines and tokens, share per language | jscpd |
751
+ | dead code, four categories, with a confidence per finding | jscpd (`basta`) |
752
+ | `CC` per file and per folder, physical lines, tokens | jscpd |
753
+ | logical `SLOC`, Halstead, `MI` | escomplex |
754
+ | cycles, `Q`, density, `Ca` / `Ce` / `DC` / `I` | madge + graphology |
755
+
756
+ ### The unit is part of the number
757
+
758
+ Two tools use the word complexity for two different units, and a report that omits them will look
759
+ like it contradicts itself in public:
760
+
761
+ | | unit | value here |
762
+ | -------------------- | ------------------------------------------------- | -------------------------------------- |
763
+ | oxlint `complexity` | per **function** | 30, in the nest preset, blocking today |
764
+ | jscpd `CX` | per **file**, decision points plus function count | measured, not gated |
765
+ | jscpd "complex file" | per file | `CX >= 50` |
766
+
767
+ So this role does not re-judge a single function: a per-function or per-file limit is a lint rule,
768
+ and oxlint already ships `complexity`, `max-lines`, `max-lines-per-function`, `max-depth`,
769
+ `max-params` and `max-statements`. The role measures the distribution; the linter refuses a unit.
770
+ The two are complementary as long as every figure carries its unit.
771
+
772
+ ---
773
+
774
+ ## 8. Open questions
775
+
776
+ | | what it needs |
777
+ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
778
+ | Abstractness A in TypeScript | A is a property of the code, not of the graph. ArchUnit counts public classes only, which suggests "the share of a module's exported API that is a type or an interface". Until it is settled, A and therefore D are blocked |
779
+ | Centrality against test coverage | a per-file coverage source, which the front end does not have today |
780
+ | A feedback channel on findings | without it no rule can ever prove it deserves to exist, and "it depends" simply moves up one level, to our own tools |
781
+ | Q and betweenness | in the ACM study, absent from the ticket, not computed here |
782
+ | Git history | agreed as a second phase. The cost is not the obstacle: 2 seconds per file over twelve months |
783
+ | Cyclomatic complexity as a measurement | **settled.** jscpd measures it per file and per folder; the lint rule keeps the per-function refusal. The two units are both printed, see section 7 |
784
+ | The config's file name | **settled.** `static-analysis.config.mjs`, calling an exported function, not a JSON file: sentinel consumes these values, and a function is what makes a partial declaration mergeable. See section 7 |
785
+ | Merge and precedence | **settled.** The project wins; declared facts are added, `overrides` replaces and every path it sets is printed as `(module)`. A file matched by two declared journeys is still to be verified in the report |
786
+ | A stricter threshold on a declared path | the lint override analogy invites it, and the recommendation is to refuse it in a first version: a per-path threshold makes two files with the same measurement get different verdicts, and the first thing this role has to be able to say is that a number means the same thing everywhere |
787
+ | Dead code inside a library | `libs/front/components` reports **30.3%** of its lines dead, and no internal declaration fixes it: the package declares no `main`, `module`, `types`, `exports` or `files`, it has no barrel, and consumers import deep paths through a tsconfig alias (`@front/components/data-display/badge/Badge`). Reachability inside a library is therefore meaningless at module scope. Either the pass runs at workspace scope for libraries, or the metric is marked not applicable to them |
788
+ | Halstead and MI on nest | escomplex parses **67.4%** of `network`, and a module-level `MI` computed over two thirds of its files is not that module's `MI`. Three ways out: publish it with its coverage beside it, find whether one node type explains the 708 internal failures, or recompute Halstead on `oxc-parser`, which is already a direct dependency and parses everything the lint role parses |
789
+
790
+ ---
791
+
792
+ ## 9. Sources
793
+
794
+ - Ben Khalfallah, H. _Code Health Meter: A Quantitative and Graph-Theoretic Foundation for
795
+ Automated Code Quality and Architecture Assessment._ ACM TOSEM 35(4), article 114, March 2026.
796
+ <https://doi.org/10.1145/3737670>
797
+ - Zimmermann, T. and Nagappan, N. _Predicting Defects using Network Analysis on Dependency Graphs._
798
+ ICSE 2008.
799
+ <https://www.microsoft.com/en-us/research/publication/predicting-defects-using-network-analysis-on-dependency-graphs/>
800
+ - Tosun, A., Turhan, B. and Bener, A. _Validation of network measures as indicators of defective
801
+ modules in software systems._ PROMISE 2009. <https://doi.org/10.1145/1540438.1540446>
802
+ - Hammant, P. _The Rise of Test Impact Analysis._ martinfowler.com, August 2017.
803
+ <https://martinfowler.com/articles/rise-test-impact-analysis.html>
804
+ - Hassan, A. E. _Predicting Faults Using the Complexity of Code Changes._ ICSE 2009.
805
+ <https://sailresearch.github.io/sail-website/data/pdfs/ICSE2009_PredictingFaultsUsingTheComplexityOfCodeChanges.pdf>
806
+ - Sadowski, C. et al. _Tricorder: Building a Program Analysis Ecosystem._ Google.
807
+ <https://research.google/pubs/tricorder-building-a-program-analysis-ecosystem/>
808
+ - _Scaling Static Analyses at Facebook._ Meta.
809
+ <https://doi.org/10.1145/3338112>
810
+ - NDepend, code metrics: <https://www.ndepend.com/docs/code-metrics>
811
+ - NDepend on the maintainability index: <https://blog.ndepend.com/maintainability-index/>
812
+ - Lattix, metrics: <https://docs.lattix.com/lattix/userGuide/Metrics.html>
813
+ - ArchUnit user guide, metrics and `FreezingArchRule`:
814
+ <https://www.archunit.org/userguide/html/000_Index.html>
815
+ - Arcan, architectural smells: <https://docs.arcan.tech/latest/architectural_smells/>
816
+ - CodeScene, code health and hotspots: <https://codescene.io/docs/guides/technical/code-health.html>
817
+ - Sonar, Clean as You Code: <https://www.sonarsource.com/blog/clean-as-you-code/>
818
+ - Martin's package metrics: <https://en.wikipedia.org/wiki/Software_package_metrics>