@hublo/sentinel 1.4.2 → 1.4.4

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,527 @@
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 below was measured on this monorepo on `origin/main`, with madge 8.0.0, nx 24,
8
+ jscpd 5.4.0 and oxlint 1.77.0. The conditions matter, and the first section says why.
9
+
10
+ ---
11
+
12
+ ## 0. Notation
13
+
14
+ Every symbol used below, in one place. They come from four different traditions, which is why
15
+ the same idea sometimes carries two names.
16
+
17
+ | symbol | name | what it is |
18
+ | ------------------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
19
+ | `Ca` | afferent coupling | how many components depend on this one. Incoming edges |
20
+ | `Ce` | efferent coupling | how many components this one depends on. Outgoing edges |
21
+ | `DC` | degree centrality | `Ca + Ce`. The overall connectedness of one node. The ACM study calls the two halves `IDC` and `ODC` |
22
+ | `I` | instability | `Ce / (Ca + Ce)`, from 0 to 1. 0 is a stable foundation, 1 is a pure consumer |
23
+ | `A` | abstractness | abstract classes over total classes in a component. ArchUnit counts public ones only |
24
+ | `D` | distance from the main sequence | `abs(A + I - 1)`. How far a component sits from the ideal line `A + I = 1` |
25
+ | `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 |
26
+ | `CC` | cyclomatic complexity | McCabe's count of independent paths through a function |
27
+ | `HV` | Halstead volume | a size measure built from the distinct and total operators and operands |
28
+ | `MI` | maintainability index | a composite of `HV`, `CC` and lines of code. Formula in section 5 |
29
+ | `LOC` | lines of code | physical lines, unless stated otherwise |
30
+ | `Q` | modularity | how well a graph splits into communities, maximised by the Louvain method |
31
+ | `H` | relational cohesion | `(R + 1) / N`, with `R` the edges internal to a module and `N` its members |
32
+ | `LCOM4` | lack of cohesion of methods | connected components among the methods that share a field. Above 1 signals several responsibilities |
33
+ | `DependsOn` | Lakos | the transitive closure of a component, itself included |
34
+ | `CCD` | cumulative component dependency | the sum of every `DependsOn` in the system |
35
+ | `ACD`, `RACD` | average and relative average component dependency | `CCD / N` and `ACD / N` |
36
+ | `NCCD` | normalised cumulative component dependency | `CCD` over that of a balanced binary tree of the same size |
37
+ | `RV`, `ARV`, `GRV` | relative visibility, average, global | visible elements over total elements, per component, averaged, and system wide |
38
+ | churn | | how many commits touched a file over a period |
39
+ | entropy | change entropy | how **scattered** those commits were across files, which is a different question |
40
+
41
+ Two conventions worth stating because they bite. A **percentile** is written `p95` and means the
42
+ value below which 95% of the measured population sits. A **rank correlation** is Spearman's: 1
43
+ means two rankings are identical, 0 means they are unrelated.
44
+
45
+ ---
46
+
47
+ ## 1. Three traps that make a number wrong without saying so
48
+
49
+ Each was hit while preparing this study. Each produces a plausible figure and no error message,
50
+ which is precisely the failure the ACM study criticises in existing tools: a result nobody can
51
+ trace back to its cause.
52
+
53
+ ### The wrong tsconfig loses 61% of the graph
54
+
55
+ The 403 path aliases live in `tsconfig.base.json` at the **workspace root**. The applications'
56
+ own tsconfigs declare none. 40% of internal import edges go through an alias, so pointing madge
57
+ at an application's tsconfig hands it an empty resolution table.
58
+
59
+ | madge invocation | nodes | edges |
60
+ | ----------------------------------------------- | --------: | --------: |
61
+ | `--ts-config` on the app's tsconfig (0 aliases) | 859 | 1 930 |
62
+ | `--ts-config tsconfig.base.json` (403 aliases) | **1 785** | **4 882** |
63
+
64
+ Measured on one subtree. The witness file went from 1 edge to 3: both of its alias imports
65
+ appeared.
66
+
67
+ ### nx `implicit` edges invent 20% of cyclicality
68
+
69
+ The nx project graph mixes two kinds of edge. A lambda imports its parent project: a `static`
70
+ edge going up. The parent declares that it owns the lambda: an `implicit` edge coming down.
71
+ Tarjan sees a cycle where there is only an ownership relation.
72
+
73
+ | edges kept | SCCs | projects in cycles | cyclicality |
74
+ | --------------------- | ----: | -----------------: | ----------: |
75
+ | `static` + `implicit` | 14 | 90 | **20.45%** |
76
+ | `static` only | **0** | **0** | **0.00%** |
77
+
78
+ Graph-wide: 2 008 `static` edges, 270 `implicit`.
79
+
80
+ ### A partial scan biases every centrality ranking
81
+
82
+ Scanning a subtree and following its outgoing edges makes libraries appear as targets only.
83
+ Their efferent coupling is then zero by construction and the top of the ranking fills with
84
+ artificial sinks. On a first pass, the four most central files all had `Ce = 0`, not because
85
+ they depend on nothing but because they had not been read.
86
+
87
+ > **Consequence.** Every published figure carries a resolution fingerprint: tsconfig used, edge
88
+ > types kept, scope scanned, tool versions. Without it, a configuration change reads as an
89
+ > architectural regression.
90
+
91
+ ---
92
+
93
+ ## 2. What the code actually measures
94
+
95
+ ### File graph, whole front end (11 109 files scanned)
96
+
97
+ | | value |
98
+ | -------------------------------------- | -------------------------------------------------------------------- |
99
+ | nodes, edges | 12 372, 42 472, mean out-degree 3.43 |
100
+ | System Cyclicality (Lattix definition) | **1.48%**, 183 files in 68 strongly connected components, largest 12 |
101
+ | connectedness (reachable pairs) | **4.65%**, against 0.153% edge density |
102
+ | DC median / p95 / p99 / max | 4 / 17 / 43 / 1 244 |
103
+ | DC = 0, dead files | **160** (1.3%) |
104
+ | DC > 15 | **721** files (5.8%) |
105
+
106
+ ### Project graph, nx, `static` edges only
107
+
108
+ 440 projects, 2 008 dependencies, **0 cycles**, unstable dependencies 1.1% (23 edges).
109
+
110
+ > **The architectural reading.** Module boundaries are sound; the disorder is inside the
111
+ > modules. `nx/enforce-module-boundaries` polices the project graph and nothing polices the file
112
+ > graph, so that is where this role adds something nx does not already provide.
113
+
114
+ ### Timings
115
+
116
+ | | |
117
+ | ------------------------------------------- | -------------------- |
118
+ | madge, whole front end | **46 s** |
119
+ | nx graph | **11 s** |
120
+ | git churn per file, 12 months | **2 s** |
121
+ | the same pass with lines, authors and dates | **5 s** |
122
+ | git churn per function (`git log -L`) | **106 ms per range** |
123
+
124
+ Which settles the "continuous" question: per-file analysis is affordable on every pull request.
125
+ Per-function churn is not, at 3.6 hours for the whole front end, but it is affordable on a short
126
+ list: 106 seconds for the 100 most central files.
127
+
128
+ ### The files the graph points at
129
+
130
+ | file | DC | Ca | Ce | I | reading |
131
+ | ------------------------------------------- | ----: | ----: | --: | ---: | -------------------------------------------------------------------------------------------- |
132
+ | `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 |
133
+ | `libs/api-types/bff-admin/.../api-types.ts` | 962 | 962 | 0 | 0.00 | Generated. Must be excluded before any rule speaks about tests |
134
+ | `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 |
135
+ | `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 |
136
+
137
+ ---
138
+
139
+ ## 3. Three measured results that decided the model
140
+
141
+ Each was run because a plausible assumption deserved a test rather than an argument.
142
+
143
+ ### Centrality is not file size in disguise
144
+
145
+ A known criticism (Herraiz et al., cited in Hassan's related work) is that most code complexity
146
+ metrics correlate highly with lines of code, so they rediscover that big files are big. Applied
147
+ to centrality, ranking all 12 372 files twice:
148
+
149
+ **Rank correlation DC against LOC: 0.388.** Far from 1, so the two rankings designate different
150
+ files and centrality carries information size does not. The criticism does not bite here.
151
+
152
+ ### Centrality is not a proxy for change frequency
153
+
154
+ The hypothesis was that a central file, being heavily used, is frequently changed, which would
155
+ make git history redundant.
156
+
157
+ | | |
158
+ | --------------------------------- | ------------- |
159
+ | rank correlation DC against churn | **0.361** |
160
+ | files common to both top 100 | **13 of 100** |
161
+
162
+ | counter-example | DC | churn |
163
+ | ------------------------------------------------ | --: | ----: |
164
+ | `front-legacy/src/constants/routes/index.ts` | 424 | 2 |
165
+ | `libs/front/translations/.../main.planning.json` | 2 | 309 |
166
+
167
+ So a central file can be perfectly stable, which is often the sign that it is well made. The two
168
+ are **the two factors of a product, not substitutes**: centrality is the consequence term,
169
+ churn is the likelihood term. This is why CodeScene's hotspot is a multiplication.
170
+
171
+ > A corollary that shaped the model: a vector of uncorrelated metrics **combines, it does not
172
+ > stack**. Requiring central AND long AND complex AND poor MI selects almost nothing.
173
+
174
+ ### The graph does not see the product
175
+
176
+ Raised in a tech lead review of the test strategy: none of these metrics says which user journeys
177
+ matter, so a file can be central and uninteresting, or peripheral and critical. The objection
178
+ holds, and it holds twice.
179
+
180
+ **A screen is a structural leaf.** Over the 596 front end files that live under a `routes/` or a
181
+ `pages/` directory, `routeTree.gen.ts` excluded:
182
+
183
+ | | share of the 596 |
184
+ | ----------------------------------------------- | ---------------- |
185
+ | Ca ≤ 1, at most one file depends on them | **83.2%** |
186
+ | Ca = 0, nothing in the codebase depends on them | **46.3%** |
187
+
188
+ This is not a defect in the metric, it is what afferent coupling means: a screen is where the
189
+ product value is and it is the last node of the graph, so centrality ranks it last by
190
+ construction.
191
+
192
+ **And the graph cannot carve a journey out on its own.** If a journey were given by its entry
193
+ point, there are two ways to expand it into a set of files, and both collapse. Measured on the
194
+ 182 route files of `host-admin`:
195
+
196
+ | expansion | result |
197
+ | ------------------------------------------------------ | ------------------------------------------------------------------------------------ |
198
+ | transitive closure of one route file | median **440** files, max 5 733. The 5 largest routes alone reach **70%** of the app |
199
+ | exclusive, reachable from this route and from no other | median **0**. 13 of the 18 route groups own exactly zero files |
200
+
201
+ The two bracket the problem. Expanded transitively a journey is the whole application; reduced to
202
+ what it owns alone it is empty. The useful set in between, the files that actually carry the
203
+ journey, is not in the graph. It is in the team's head.
204
+
205
+ ---
206
+
207
+ ## 4. What was decided, and why
208
+
209
+ ### No single score. Six categories instead.
210
+
211
+ Weighting would need experimentation to fix the formula, and a weight nobody can justify is a
212
+ claim nobody can defend. Six correct numbers beat one indefensible score.
213
+
214
+ This is consistent with three independent positions: the ACM study's own 6D signature per
215
+ module, Lattix's documentation ("it is hard to come up with a single number that represents the
216
+ architectural health of a system... instead, a set of numbers"), and the ACM study's criticism
217
+ of Sonar, which is not that it aggregates but that it aggregates opaquely.
218
+
219
+ The door stays open: the correlation matrix that would justify weights later is the same one
220
+ that validates the categories now. Two of its cells are already filled, and both are weak.
221
+
222
+ **A category is sound when its metrics lead to the same action.** Otherwise it is a filing
223
+ cabinet, not a tool.
224
+
225
+ | category | the question | contains | the action |
226
+ | --------------- | ------------------------------- | --------------------------------------------- | ------------------- |
227
+ | **Impact** | what does it break | Ca, Ce, DC, I, later A and D | none. It **orders** |
228
+ | **Activity** | how much does it move | churn, entropy, authors, co-change | none. It **orders** |
229
+ | **Cycles** | what bites its own tail | SCCs, System Cyclicality | move one import |
230
+ | **Cohesion** | is the split right | Q, H, LCOM4, density per module | re-cut, move a file |
231
+ | **Complexity** | is it readable | CC, nesting, lines per function, Halstead, MI | split a function |
232
+ | **Duplication** | does it already exist elsewhere | jscpd | extract, or accept |
233
+
234
+ Cycles is separate from Impact although both come from the graph, because the action differs: a
235
+ cycle is fixed, a high centrality is not. **Impact × Activity** are the two terms of risk and
236
+ repair nothing; the other four repair.
237
+
238
+ On naming: a category is named after what it **measures**, never after where the number comes
239
+ from and never as a verdict. "Centrality" was rejected because it is the name of one metric
240
+ inside the category and describes I, A and D badly. "Importance" was rejected because it smuggles
241
+ a judgement, and the counter-example is in our own data: `Typography.tsx` is the most central
242
+ file in the front end and it is perfectly healthy.
243
+
244
+ ### Everything is computed per module, never globally
245
+
246
+ A global top 100 by centrality returns 63 files from `libs/front` and 23 from `host-admin`, and
247
+ **three entire applications never appear**: `console` (526 files), `hr-management` (298) and
248
+ `career` (75), whose most central files sit at DC 43, 27 and 25. Such a list does not say "the
249
+ 100 most critical files", it says "`libs/front` exists", and three teams never receive a line of
250
+ the report.
251
+
252
+ Per module also repairs two metrics: density discriminates again at small N, and `H = (R+1)/N`
253
+ only means something with R counted as edges internal to the module.
254
+
255
+ ### The product axis is declared, not observed
256
+
257
+ The objection above has an obvious candidate answer, production telemetry. Real user monitoring
258
+ (RUM), Datadog's in our case, knows which screens are really used. It was not taken, and the
259
+ reason is not the quality of that data.
260
+
261
+ **Static analysis runs before, telemetry reports after.** RUM describes what is in production. The
262
+ decisions this role exists to inform are taken earlier: while the code is being written, when a
263
+ technical choice is made, in CI before a merge. Code that is being written now has no traffic, and
264
+ that is not a marginal case here:
265
+
266
+ | front end `.ts` and `.tsx` files, generated route trees excluded | |
267
+ | ---------------------------------------------------------------- | ----------------- |
268
+ | tracked in git | 10 947 |
269
+ | added in the last 90 days and still present | **3 115 (28.4%)** |
270
+
271
+ The structural axis cannot stand in for the missing signal either, because it does not separate
272
+ that population:
273
+
274
+ | | median DC | DC > 15 | p95 |
275
+ | ------------------------- | --------- | -------- | --- |
276
+ | added in the last 90 days | 4 | **4.3%** | 15 |
277
+ | older | 4 | **6.3%** | 18 |
278
+
279
+ So the input is a **declaration**. The team states which paths carry a critical journey, the way a
280
+ lint configuration states which rules a project wants. It sits in the repository, it is reviewed in
281
+ a pull request, it is versioned with the code it describes, and it can therefore name a feature
282
+ that does not exist in production yet.
283
+
284
+ ```jsonc
285
+ {
286
+ "critical": [
287
+ {
288
+ "name": "contract signature",
289
+ "paths": ["features/contracts/**", "libs/front/api/src/bff-admin/contracts.ts"],
290
+ },
291
+ ],
292
+ "report": { "top": 100 },
293
+ "activity": { "months": 12 },
294
+ }
295
+ ```
296
+
297
+ A glob or an explicit file, matched the same way. There is deliberately no grammar for a sequence
298
+ of steps: the order of a journey is never used by anything downstream, so asking for it would be
299
+ asking teams to maintain a fact we do not read.
300
+
301
+ ### A declared set is a named subgraph, not a selector
302
+
303
+ `critical` is not an input to centrality. It names a subgraph, and **every category is then
304
+ computable on that subgraph** rather than only on the module. That is what the declaration actually
305
+ buys, and for two categories it is the difference between a measurement and no measurement at all.
306
+
307
+ | category | on a declared set | what was impossible without it |
308
+ | --------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
309
+ | **Impact** | the set's own incoming and outgoing coupling, as one unit | a journey's blast radius. Per-file `Ca` never sums to it |
310
+ | **Cohesion** | internal edge share and `H` of the set | there was no candidate unit to test other than the folder tree |
311
+ | **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 |
312
+ | **Duplication** | clones inside one journey against clones **between** two journeys | telling local repetition from an unextracted shared concept |
313
+ | **Complexity** | the same thresholds, with the declared sets ordered first | ordering by anything other than the metric itself |
314
+ | **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 |
315
+
316
+ The shape was checked rather than assumed. Taking `console/src/features/*`, which is literally the
317
+ shape of a `paths` entry:
318
+
319
+ | feature | files | internal edge share | H |
320
+ | ------------ | ----: | ------------------: | --------: |
321
+ | client | 334 | 78% | 2.76 |
322
+ | audit | 42 | 58% | 2.02 |
323
+ | institutions | 35 | 61% | 1.63 |
324
+ | auth | 7 | **20%** | **0.57** |
325
+ | home | 1 | 0% | 1.00 |
326
+ | **total** | 419 | **74%** | mean 1.60 |
327
+
328
+ 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
329
+ declared set behaves like a unit and not like an arbitrary list. And `auth` is immediately a finding
330
+ rather than noise: 7 files with 3 internal edges against 12 outgoing is a wrapper, not a feature.
331
+
332
+ > ⚠️ **A declared set is a cover, not a partition.** The same file can serve two journeys, which is
333
+ > exactly the exclusive footprint result above. So modularity `Q` does **not** apply to it: `Q` is
334
+ > defined on a partition. What applies is per-set cohesion and conductance, which tolerate overlap.
335
+ > `Q` stays a module-level metric computed on the folder tree.
336
+
337
+ **Within Impact, the report is two blocks, and their order is the whole point.**
338
+
339
+ 1. the files matching a declared `paths` pattern, grouped under their `name`
340
+ 2. the top N, **minus** everything already listed in block 1
341
+
342
+ N defaults to 100 and is configurable. A file appears once, and a declared file that is also
343
+ central is reported as declared. Without the subtraction the declaration would buy nothing: it
344
+ would add names in front of a list that already contained the central files, and still never
345
+ surface the critical file that is not central, which is the entire gap being closed.
346
+
347
+ ### The line: scope and volume are the team's, thresholds are ours
348
+
349
+ | the config carries | the config does not carry |
350
+ | ------------------------------------------------------------------------ | ---------------------------- |
351
+ | **scope**: which paths carry a journey, and the name the product uses | the value of any measurement |
352
+ | **volume**: how many files to report, how many months of history to read | what counts as central |
353
+
354
+ `DC > 15`, cyclomatic complexity at 30, nesting depth 4, duplication at 50 tokens over 5 lines:
355
+ those stay on this side. A team declares a fact about its domain and chooses how much output it
356
+ wants, it does not move the instrument. Those values will be calibrated from use and from the
357
+ feedback the role collects, which is the same commitment a preset rule carries in
358
+ `CONTRIBUTING.md`.
359
+
360
+ Two consequences of how it is written, rather than of what it contains:
361
+
362
+ - It is filled **with the product**, not by developers alone, which is why `name` is a field and
363
+ not a comment: the name is the part a product person validates, the paths are its translation.
364
+ - `--init` generates it like every other config this tool writes, and the declared part survives a
365
+ re-init. The precedent is the format role's `$sentinel.local`, where the keys a module owns are
366
+ **declared rather than inferred**, so that an override survives a preset bump only when someone
367
+ said out loud that it should.
368
+
369
+ **Why patterns and not route resolution.** Deriving the journey from the router was considered and
370
+ dropped: only 3 of the 9 front end applications have a `routeTree.gen.ts`, so it would work on a
371
+ third of the code and silently do nothing on the rest. A pattern also reads at review time without
372
+ running anything, and it can name a file that does not exist yet.
373
+
374
+ ### What was deliberately not taken
375
+
376
+ | | reason |
377
+ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
378
+ | 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 |
379
+ | 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) |
380
+ | 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 |
381
+ | 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 |
382
+ | A second duplication diff engine | jscpd 5.4 already has `--baseline`, `--update-baseline`, `--fail-on-new-clones` and `--baseline-from-ref origin/main` |
383
+ | 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 |
384
+
385
+ ---
386
+
387
+ ## 5. Published thresholds, by source
388
+
389
+ What each tool publishes, and whether it survives contact with our code.
390
+
391
+ ### NDepend
392
+
393
+ | metric | threshold | our state |
394
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
395
+ | cyclomatic complexity | **> 15** hard, **> 30** split | **already enforced**: `complexity: ["error", 30]` in the nest lint preset, exactly their upper bound |
396
+ | lines per method | > 20 hard, > 40 split | not enforced. The ticket allows 50 in TS and 250 in TSX |
397
+ | nesting depth | > 4 hard, > 8 extreme | not enforced. `max-depth` exists in the planning layer only |
398
+ | efferent coupling, type level | Ce > 50 | 11 files. The ACM study's ODC > 15 designates 228 |
399
+ | 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 |
400
+ | distance from main sequence | ` | A + I − 1 | `, **> 0.7 problematic** | blocked: A is not graph-derivable |
401
+ | 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 | not computed |
402
+
403
+ NDepend's own warning about its own metric is the most useful line in their documentation: the
404
+ coefficients were calibrated on languages that predate C#, so an absolute score is not reliable.
405
+ Their recommendation is to **rank the worst**, to **combine with complexity, coupling and change
406
+ history**, and to **detect regressions** rather than impose a floor.
407
+
408
+ ### Lattix
409
+
410
+ | metric | formula | threshold |
411
+ | -------------------------- | ---------------------------------------------------- | ------------------------------------- |
412
+ | System Cyclicality | **percentage** of elements in a cycle, not a count | none |
413
+ | Intercomponent Cyclicality | same, across components | **target: zero** |
414
+ | Coupling | `100 × n / (V(V−1)/2)`, n = strongly connected pairs | none |
415
+ | Connectedness | `100 × reachable pairs / V(V−1)` | none |
416
+ | System Stability | `100 − (average impact / atom count) × 100` | **must increase as the system grows** |
417
+
418
+ ### ArchUnit
419
+
420
+ | metric | formula | threshold |
421
+ | ---------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------- |
422
+ | DependsOn (Lakos) | transitive closure of a component, itself included | none |
423
+ | CCD | sum of all DependsOn | lower is better. A cycle inflates it: all members share one value |
424
+ | ACD, RACD | `CCD/N`, `ACD/N` | **RACD bounded by 0.6** for an acyclic graph of at least 5 components |
425
+ | NCCD | CCD over that of a balanced binary tree of the same size | lower is better |
426
+ | A, abstractness | abstract over total classes, **public only** | none |
427
+ | RV, ARV, GRV (Dowalil) | visible over total elements, per component then averaged then global | lower is better |
428
+
429
+ `FreezingArchRule` stores **individual violations** under version control; only new ones are
430
+ reported and the store shrinks by itself as violations are fixed. It ignores line numbers, so a
431
+ violation that moves stays known.
432
+
433
+ Note that `DependsOn` measures the **outgoing** transitive closure, which is what can break this
434
+ component, not what this component breaks. Blast radius is the incoming closure. Measured on our
435
+ project graph, its rank correlation with plain Ce is 0.819, so it adds little to a ranking.
436
+
437
+ ### CodeScene
438
+
439
+ Code Health is a weighted average of 25+ factors, weighted by each file's lines of code, over
440
+ three bands. Factors include LCOM4 for cohesion, Brain Class and Brain Method, Bumpy Road,
441
+ Complex Conditional and Primitive Obsession. Brain Class is a **conjunction**: many lines, many
442
+ functions, and at least one Brain Method, which makes it harder to contest than a single
443
+ threshold. Numeric thresholds are not published on their documentation pages.
444
+
445
+ Hotspot is code health **multiplied by** change frequency, and reframes the question from "is
446
+ this code bad" to "is this bad code important now".
447
+
448
+ ### Arcan
449
+
450
+ | smell | detection | threshold |
451
+ | ------------------- | ------------------------------------------------------------------------------------- | ------------------------ |
452
+ | Cyclic Dependency | Sedgewick-Wayne, Tarjan, or Laval-Falleri for layered cycles | algorithmic |
453
+ | God Component | **too many lines of code** in a container, not too many dependencies | `Max(system, benchmark)` |
454
+ | Hub-Like Dependency | `FanIn + FanOut` | same dynamic threshold |
455
+ | Unstable Dependency | a **relation between two containers**: one depends on a less stable one, with a delta | Martin's instability |
456
+
457
+ Two vocabulary collisions worth knowing: "God Component" means size here and fan-out in the ACM
458
+ study, and "Unstable Dependency" is a property of an edge here, not of a node.
459
+
460
+ ### Google and Meta, on measuring the analyser rather than the code
461
+
462
+ | metric | formula | threshold |
463
+ | --------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
464
+ | 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** |
465
+ | 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** |
466
+
467
+ The Infer result is the one with a design consequence: the integration point weighs as much as
468
+ the algorithm, which separates the developer surface (a pull request comment) from the staff
469
+ surface (a trend) even though both run the same engine.
470
+
471
+ ### Change entropy (Hassan, ICSE 2009)
472
+
473
+ `Hₙ(P) = −Σ pₖ · log₂ pₖ`, where P is, **over a period**, the probability distribution of a
474
+ given file being modified. Maximum entropy when every file changes equally, minimum when one
475
+ file absorbs everything.
476
+
477
+ Entropy is not churn. Churn counts modifications; entropy measures how **scattered** they were.
478
+ Ten changes in one file is zero entropy; ten changes across ten files is maximal. It is the
479
+ observable trace, in the history, of what modularity claims about the structure.
480
+
481
+ Three models: the Basic and Extended Code Change models give one value per period for a
482
+ 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
483
+ projects, where the change complexity metrics were better predictors of fault potential than
484
+ prior modifications and than prior faults.
485
+
486
+ The related work carries two results that challenge the Complexity category: Herraiz et al. show
487
+ that most code complexity metrics correlate highly with LOC, and Moser et al. that process
488
+ metrics outperform code metrics as fault predictors.
489
+
490
+ ---
491
+
492
+ ## 6. Open questions
493
+
494
+ | | what it needs |
495
+ | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
496
+ | 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 |
497
+ | Centrality against test coverage | a per-file coverage source, which the front end does not have today |
498
+ | 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 |
499
+ | Q and betweenness | in the ACM study, absent from the ticket, not computed here |
500
+ | Git history | agreed as a second phase. The cost is not the obstacle: 2 seconds per file over twelve months |
501
+ | Cyclomatic complexity as a measurement | enforced as a lint rule at 30, never measured as a distribution |
502
+ | The config's file name | `static-config.json` is the working name. This is the first config sentinel owns rather than inherits from a vendor, so there is no `.oxlintrc.json` precedent to follow, and the name is a free choice to make once |
503
+ | Merge and precedence | two declared journeys can match the same file, and a module config can sit under a workspace one. The rule has to be as explicit as a lint override's, and the report has to be verified for a file counted twice |
504
+ | 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 |
505
+
506
+ ---
507
+
508
+ ## 7. Sources
509
+
510
+ - Ben Khalfallah, H. _Code Health Meter: A Quantitative and Graph-Theoretic Foundation for
511
+ Automated Code Quality and Architecture Assessment._ ACM TOSEM 35(4), article 114, March 2026.
512
+ <https://doi.org/10.1145/3737670>
513
+ - Hassan, A. E. _Predicting Faults Using the Complexity of Code Changes._ ICSE 2009.
514
+ <https://sailresearch.github.io/sail-website/data/pdfs/ICSE2009_PredictingFaultsUsingTheComplexityOfCodeChanges.pdf>
515
+ - Sadowski, C. et al. _Tricorder: Building a Program Analysis Ecosystem._ Google.
516
+ <https://research.google/pubs/tricorder-building-a-program-analysis-ecosystem/>
517
+ - _Scaling Static Analyses at Facebook._ Meta.
518
+ <https://doi.org/10.1145/3338112>
519
+ - NDepend, code metrics: <https://www.ndepend.com/docs/code-metrics>
520
+ - NDepend on the maintainability index: <https://blog.ndepend.com/maintainability-index/>
521
+ - Lattix, metrics: <https://docs.lattix.com/lattix/userGuide/Metrics.html>
522
+ - ArchUnit user guide, metrics and `FreezingArchRule`:
523
+ <https://www.archunit.org/userguide/html/000_Index.html>
524
+ - Arcan, architectural smells: <https://docs.arcan.tech/latest/architectural_smells/>
525
+ - CodeScene, code health and hotspots: <https://codescene.io/docs/guides/technical/code-health.html>
526
+ - Sonar, Clean as You Code: <https://www.sonarsource.com/blog/clean-as-you-code/>
527
+ - Martin's package metrics: <https://en.wikipedia.org/wiki/Software_package_metrics>
@@ -991,7 +991,7 @@ $ sentinel --report --typescript --max-diagnostics 3.9 --module bff-admin
991
991
  ```console
992
992
  $ sentinel --init --dry-run
993
993
  dry run: no files written
994
- wrote .oxlintrc.json extending ./node_modules/@hublo/sentinel/lint/nest.json (@hublo/sentinel@1.4.1)
994
+ wrote .oxlintrc.json extending ./node_modules/@hublo/sentinel/lint/nest.json (@hublo/sentinel@<version>)
995
995
  2 rule(s) not enforced by this preset (1 no-barrel-files, 1 core). Run --inspect --lint for each rule and why.
996
996
  run `pnpm install`, then `pnpm run lint`
997
997
 
@@ -1003,7 +1003,7 @@ $ sentinel --init --dry-run
1003
1003
  + "ignorePatterns": [
1004
1004
  + "**/dist/**",
1005
1005
  + "**/node_modules/**",
1006
- + "**/coverage/**",
1006
+ + "coverage/**",
1007
1007
  + "**/jest.*.{ts,js,cjs,mjs}",
1008
1008
  + "**/eslint.config.{js,cjs,mjs}",
1009
1009
  + "**/*.timestamp*",
@@ -1022,7 +1022,7 @@ $ sentinel --init --dry-run
1022
1022
  + "lint": "sentinel --run --lint"
1023
1023
  + },
1024
1024
  + "devDependencies": {
1025
- + "@hublo/sentinel": "1.4.1"
1025
+ + "@hublo/sentinel": "<version>"
1026
1026
  + },
1027
1027
  + "nx": {
1028
1028
  + "targets": {
@@ -1058,7 +1058,7 @@ $ sentinel --init --dry-run
1058
1058
  + "typecheck": "sentinel --run --typescript"
1059
1059
  + },
1060
1060
  + "devDependencies": {
1061
- + "@hublo/sentinel": "1.4.1"
1061
+ + "@hublo/sentinel": "<version>"
1062
1062
  + },
1063
1063
  + "nx": {
1064
1064
  + "targets": {
@@ -1076,13 +1076,13 @@ $ sentinel --init --dry-run
1076
1076
  build: no Vite config in this module, so there is nothing to point at sentinel. This role moves an existing config's imports; it does not introduce a bundler. If this module builds another way (38 services here build through `@nx/webpack`), the build role does not apply to it.
1077
1077
  test: this module has no test and no jest leftovers, so there is nothing to do here.
1078
1078
  dry run: no files written
1079
- materialized the nest format preset into .oxfmtrc.json (@hublo/sentinel@1.4.1). oxfmt has no `extends`, so the values live here; `sentinel --status --format` reports modules left behind by a preset change
1079
+ materialized the nest format preset into .oxfmtrc.json (@hublo/sentinel@<version>). oxfmt has no `extends`, so the values live here; `sentinel --status --format` reports modules left behind by a preset change
1080
1080
 
1081
1081
  create .oxfmtrc.json
1082
1082
  + {
1083
1083
  + "$sentinel": {
1084
1084
  + "preset": "nest",
1085
- + "version": "1.4.1",
1085
+ + "version": "<version>",
1086
1086
  + "local": []
1087
1087
  + },
1088
1088
  + "printWidth": 80,
@@ -1133,7 +1133,7 @@ $ sentinel --init --dry-run
1133
1133
  + "format:fix": "sentinel --run --format --fix"
1134
1134
  + },
1135
1135
  + "devDependencies": {
1136
- + "@hublo/sentinel": "1.4.1"
1136
+ + "@hublo/sentinel": "<version>"
1137
1137
  + },
1138
1138
  + "nx": {
1139
1139
  + "targets": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hublo/sentinel",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -180,6 +180,7 @@
180
180
  "test": "vitest run",
181
181
  "test:watch": "vitest",
182
182
  "test:e2e": "vitest run tests/e2e",
183
+ "test:coverage": "vitest run --coverage --testTimeout=60000",
183
184
  "cli": "node --import tsx bin/sentinel.ts",
184
185
  "docs:traces": "pnpm build && bash scripts/gen-traces.sh",
185
186
  "docs:traces:check": "pnpm build && bash scripts/gen-traces.sh --check",