@hublo/sentinel 1.5.0-alpha.21 → 1.5.0-alpha.23

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,195 @@
1
+ # Static analysis adoption cheat sheet
2
+
3
+ A quick reference for adopting the sentinel static analysis role on a module, and for reading what it prints. The reasoning behind every number, and the measurements that fixed the thresholds, are in [`static-analysis-study.md`](static-analysis-study.md); this page is what you need to run it.
4
+
5
+ **It measures, it never refuses.** Nothing here fails a build or blocks a merge. A figure answers "is this module drifting", which is a question about a trend, and a check that blocks a merge on a trend blocks the wrong thing. Refusing a single unit is the linter's job and oxlint already does it.
6
+
7
+ ## Adopt a module in two steps
8
+
9
+ Run from the **module's own directory**. You do not need to install sentinel first: use `npx --yes` for a one-off.
10
+
11
+ ```bash
12
+ # 1. write the module's config + the pinned devDependency
13
+ cd apps/.../<module>
14
+ npx --yes @hublo/sentinel@<version> --init --static-analysis
15
+
16
+ # 2. fetch what step 1 declared
17
+ pnpm install
18
+ ```
19
+
20
+ `--init` writes `static-analysis.config.mjs`, adds the three scripts below to the module's `package.json`, and pins `@hublo/sentinel` into its own `devDependencies`. It prints what it did:
21
+
22
+ ```
23
+ wrote static-analysis.config.mjs
24
+ wrote package.json
25
+ added the three `static-analysis` scripts and static-analysis.config.mjs; declare
26
+ this module's entry points and journeys there, everything else comes from the role
27
+ ```
28
+
29
+ **Nothing else is required.** The config stub is written with empty `entry` and `journeys` on purpose: for most modules the figures are already right without declaring anything, and the stub exists so you can correct the one case where they are not (see [Declaring entry points](#declaring-entry-points)).
30
+
31
+ ## Verbs at a glance
32
+
33
+ | Verb | Who / when | Runs the tools? | What you get |
34
+ | ----------- | ------------------ | ----------------- | ---------------------------------------------------------------------- |
35
+ | `--run` | dev, everyday | **yes** | the figures, in one of three shapes. Never exits non-zero on a finding |
36
+ | `--inspect` | dev, everyday | no (reads config) | the resolved settings, and whether this module has adopted |
37
+ | `--init` | dev, once to adopt | writes files | the config stub, the three scripts, the pinned dep |
38
+ | `--migrate` | planned | not available yet | reserved for changing an already-initialized setup |
39
+
40
+ **Location** (any verb): from a module dir → that module; from the root → `--module <name>`, `--ci` (affected), or every module. `--init` writes, so it targets one module only.
41
+
42
+ ## Everyday use
43
+
44
+ ```bash
45
+ pnpm run static-analysis # the summary, in your terminal. Writes nothing
46
+ pnpm run static-analysis:json # static-analysis-report/static-analysis.json, for a machine
47
+ pnpm run static-analysis:html # static-analysis-report/static-analysis.html, for a reader
48
+ ```
49
+
50
+ Or without the scripts:
51
+
52
+ ```bash
53
+ sentinel --run --static-analysis # same as the first script
54
+ sentinel --run --static-analysis --output html # same as the third
55
+ sentinel --inspect --static-analysis # the settings, without running anything
56
+ ```
57
+
58
+ **Which shape to use.** `console` is the one you run in a loop: it writes nothing and skips the expensive drawing. `json` is for CI and for a script. `html` is the one you open and send: it carries the findings, the places to look, and an explorable import graph.
59
+
60
+ ⚠️ The `html` shape pays for a graph layout that the other two skip, 5 seconds on the largest module measured against 14 ms for the rest of the graph pass. That is fine for a page somebody opens and wrong for a commit hook.
61
+
62
+ ## Reading the report
63
+
64
+ The console summary of a nest microservice, in full:
65
+
66
+ ```
67
+ sentinel static-analysis: contract
68
+ Duplicated lines 3.95 %
69
+ Clones 61 entries
70
+ Duplicated lines (tests) 10.93 %
71
+ Clones (tests) 196 entries
72
+ Entry points 46 of 656 files
73
+ Unreachable lines 1.07 %
74
+ Unreachable files 5 files
75
+ Unused exports 1 exports
76
+ Unused symbols 1 symbols
77
+ Unused imports 0 imports
78
+ Complexity, worst function 27 max per function
79
+ Complexity 11-20, moderate risk 18 of 1386 functions
80
+ Complexity 21-50, high risk 1 of 1386 functions
81
+ Imports between files 1135 between 495 files
82
+ Density 0.0046 of the possible edges
83
+ Import cycles 3 groups, 8 files
84
+ Communities 12 of 495 files
85
+ Modularity (Q) 0.753
86
+ Files the gauge read 495 files the gauge read
87
+ Files with a complex function 18 files over 10, of 495
88
+ Files with deep nesting 4 files over 3, of 495
89
+ Long files 43 files over 150, of 495
90
+ Files with a long function 5 files over 109, of 495
91
+ Files with a long function body 47 files over 10, of 495
92
+ Files with a wide signature 33 files over 3, of 495
93
+ Files with several classes 16 files over 1, of 495
94
+ Files hard to follow 23 files over 7, of 495
95
+ ```
96
+
97
+ Four groups, and they answer different questions:
98
+
99
+ | | what it answers |
100
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
101
+ | duplication | how much of this module repeats itself, product and tests counted apart |
102
+ | dead code | what nothing reaches: files, exports, symbols, imports |
103
+ | complexity | two measures, on purpose: how hard the hardest functions are to TEST (McCabe) and how hard they are to FOLLOW (cognitive). They disagree on 60% of the files either one flags |
104
+ | structure and size | how the files depend on each other, and which ones are outliers |
105
+
106
+ **Every row carries its denominator**, because none of them compares between modules without one: three breaches over 50 files and three over 3 000 are not the same module.
107
+
108
+ **A row that is absent was not measured.** It is never a silent zero. When a pass cannot produce a figure, the report says so in its own line under the figures rather than printing `0.00%`, because "no dead code" and "the walk had no entry point" are different facts.
109
+
110
+ ### Three rows people misread
111
+
112
+ **`Unreachable files` is not a delete list.** It means nothing in this module imports them. A file another module's spec imports is alive, and it has its own row, `Used only by tests`. For a library the question cannot even be answered inside the module, so the role leaves it (see below).
113
+
114
+ **`Import cycles` reports GROUPS, not paths.** A group of six files can hold a dozen distinct circles, and breaking one does not necessarily break the group. The group is the unit precisely for that reason.
115
+
116
+ **`Density` must not be compared between modules of different sizes.** Its denominator grows with the square of the file count, so a module gets a lower density by growing rather than by improving. Measured: `libs/nest/auth` 0.0216 at 101 files, `apps/front/host-admin` 0.0005 at 4 380. It is good for one module watched over time.
117
+
118
+ ### Where the size thresholds come from
119
+
120
+ The nine size rows are measured with oxlint at thresholds this role fixes once for every module, so the same question gets the same answer everywhere. Each one is the **strictest of three candidates**: published research, our own p90, and the rule's own default.
121
+
122
+ | row | threshold | what decided it |
123
+ | --------------------------------- | ------------: | ----------------------------------------------------------------------------------------- |
124
+ | `Files with a complex function` | 10 | McCabe 1976 says 10, our p90 says 11 |
125
+ | `Files with a long function` | 109 | our p90. IfSQ SP-1 says 150 and loses for being more permissive than our code |
126
+ | `Long files` | 150 | our p90 of 157, rounded down. No research sets a file's length |
127
+ | `Files with deep nesting` | 3 | IfSQ SP-2, and our p90 agrees |
128
+ | `Files with nested callbacks` | 3 | same nesting family |
129
+ | `Files with a long function body` | 10 statements | the rule's default, already the strictest candidate |
130
+ | `Files with a wide signature` | 3 parameters | beyond three the fix is an object or currying, so this is a design boundary |
131
+ | `Files with several classes` | 1 | the rule's floor |
132
+ | `Files hard to follow` | 7 | Sonar publishes 15 for cognitive complexity and says no formula supports it; our p90 is 7 |
133
+
134
+ ⚠️ **These will disagree with your green lint, out loud.** The `nest` lint preset refuses `complexity` at 30 while this reports above 10, so a nest developer reads breaches beside a passing build. That is correct: the lint is the line your build refuses at, this is a gauge that answers the same question the same way in every module, including the ones that have not adopted lint at all. The report prints its thresholds under `measured with` so the two are never confused.
135
+
136
+ ## The `html` report
137
+
138
+ Written to `static-analysis-report/`, two files:
139
+
140
+ - `static-analysis.html`, self-contained: no CDN, no library, no font, nothing fetched. Open it from disk, attach it to a ticket, serve it from a CI artifact.
141
+ - `static-analysis-graph.svg`, the import graph as a plain image, for when you want to paste a picture.
142
+
143
+ The page carries what the console summary leaves out: every finding with its path, the code of each clone, and an **explorable import graph**. Point at a dot for its path and its two degrees, click it to keep only that file and its neighbours, click the background to come back. The drawing renders with scripting off; the script only adds the naming and the filtering.
144
+
145
+ ⚠️ `static-analysis-report/` is generated. The rule lives in the **root** `.gitignore`, unanchored so it matches at any depth, rather than one line added to every adopting module's own file. You have nothing to do and nothing to commit.
146
+
147
+ ## Declaring entry points
148
+
149
+ `entry` names the files that **run although nothing imports them**. They are where reachability starts, and they are added to the patterns the role already ships rather than replacing them.
150
+
151
+ Most modules need nothing. Declare an entry point when the framework loads a file by convention the role has not met, or when code is loaded by name at run time:
152
+
153
+ ```js
154
+ import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
155
+
156
+ export default defineStaticAnalysis({
157
+ root: import.meta.dirname,
158
+ entry: ['src/emails/**'],
159
+ journeys: {},
160
+ })
161
+ ```
162
+
163
+ **The symptom that tells you one is missing**: a large, tightly-grouped block of unreachable files under one directory. Measured on `libs/cloud/hermes`, 228 of its 231 findings sit under `src/emails`, which holds templates loaded by name rather than imported. That is one line of config, not 228 deletions.
164
+
165
+ ## Two limits worth knowing before you read a number
166
+
167
+ **A single-file component format cannot be parsed.** `.svelte`, `.vue` and `.astro` files are not read, so on a module holding them the role reports the duplication, complexity and size figures and **omits the graph and the dead-code figures entirely**, with the share printed in their place:
168
+
169
+ ```
170
+ (252 product file(s) in .svelte cannot be parsed, 53% of this module: no graph
171
+ and no dead-code figure is reported, because a file the role cannot open still
172
+ imports the files it imports)
173
+ ```
174
+
175
+ Absent with its reason, rather than a figure that is wrong by half.
176
+
177
+ **A library's dead code is decided outside the module.** An application has an entry point; a library has none, so a module-local walk would report "the module does not import itself", which is true of every library. The role builds a workspace-wide index of who imports what, once, and caches it under `node_modules/.cache/sentinel/`. Measured on `libs/nest/auth`: **15** unreachable files with the index against **87** without it.
178
+
179
+ ## Troubleshooting
180
+
181
+ | | |
182
+ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
183
+ | a wall of unreachable files under one directory | an undeclared entry point, see above |
184
+ | the graph and dead-code rows are missing | the module holds `.svelte`, `.vue` or `.astro`; the note above the figures says the share |
185
+ | `Entry points 0 of N` | nothing matched, so the walk had no start. Declare `entry` |
186
+ | the numbers differ from another module's | check `measured with`: a setting this module moved is marked `(module)`, and two modules measured differently cannot be compared |
187
+ | the run is slow | you are on `--output html`, which pays the graph layout. Use the plain script in a loop |
188
+ | `oxlint could not be run, so no size indicator is reported` | the binary did not resolve; the size rows are absent rather than zero |
189
+
190
+ ## What the role will not do
191
+
192
+ - **It will not fail your build.** No threshold here refuses anything, by design, for as long as adoption is incomplete. The only non-zero exits are an unknown `--output` value and a tool that could not be run at all, never a finding.
193
+ - **It will not read your module's `.oxlintrc.json`.** The size gauge writes its own config to a temporary directory, so its scale is the same in every module. A gauge whose scale moves per module compares nothing.
194
+ - **It will not look outside the module**, except for the one measurement that cannot be answered inside it: a library's reachability.
195
+ - **It will not measure your tests.** Specs, fixtures, mocks, stories and generated files are out of every product figure. Duplication reports the test suite separately, on its own two rows, because that one is worth seeing and must not be blended into the product number.
@@ -548,6 +548,13 @@ The related work carries two results that challenge the Complexity category: Her
548
548
  that most code complexity metrics correlate highly with LOC, and Moser et al. that process
549
549
  metrics outperform code metrics as fault predictors.
550
550
 
551
+ ### IfSQ
552
+
553
+ The one standard found that attaches a citation to a numeric limit, and the only external source
554
+ used to fix this role’s own thresholds. It is a human-inspection standard rather than a tool, so its
555
+ table sits with the decision it serves, in **11.4**: three of its fifteen indicators carry a number,
556
+ and none of them is about a file’s length.
557
+
551
558
  ---
552
559
 
553
560
  ## 6. The tool, measured rather than read
@@ -849,7 +856,14 @@ function is reported: sentinel 752 functions and 41 above 15, network 1 701 and
849
856
  1 050 and 6, console 510 and 0. Zero fatal errors on 4 572 files, where escomplex fails on 37.9%
850
857
  of `network` and 39.0% of `recruitment`. The worst module is this package.
851
858
 
852
- It is absent from oxlint 1.77, so it is written once here and used twice. See 9.5.
859
+ ⛔ **Corrected on 2026-10-13: it is not written here, it is LOADED.** oxlint 1.77 accepts an ESLint
860
+ rule as a JS plugin, so Sonar's own rule runs inside our own pass with none of their code in this
861
+ repository. The reasoning, the measurements and the licence that decides it are in **11.8**.
862
+
863
+ ⚠️ **The four figures above were produced with a version this document does not name**, and the
864
+ plugin's behaviour moved between majors: sequences of `||` and `??` stopped incrementing. So treat
865
+ them as an order of magnitude, not as a baseline. The figures measured on the current version, and
866
+ what the metric actually adds beside McCabe, are in 11.8.
853
867
 
854
868
  ### 9.3 The maintainability figure
855
869
 
@@ -866,6 +880,8 @@ each one fires per 100 files, which is measured rather than judged:
866
880
  | `max-params` | 3 | 9.6 |
867
881
  | `max-nested-callbacks` | 10 | 0 |
868
882
 
883
+ ⛔ **Superseded by 10.7, and closed by 11.6.** The two figures below, and the break they rest on, were measured on four modules before two defects in the gauge were fixed: diagnostics counted as breaches, and a population that included the test suite. Over 177 modules the same two rules measure 10.4 and 6.9 per 100 files against the next at 4.6, a ratio of 1.5 rather than 4.5. Section 11 then changed the thresholds themselves, and at those the most frequent rule reaches 11.0% of files against 10.7% for the next: there is no break left to split on, so `DESCRIPTORS`, `scored` and `described` are removed and every indicator counts.
884
+
869
885
  **Which are out, and why.** There is a break in the data, a factor of 4.5, not a line we drew:
870
886
 
871
887
  | kept as a descriptor | per 100 files |
@@ -968,17 +984,18 @@ touch no production file at all and the median touches **4**.
968
984
 
969
985
  ### 9.7 What is still a decision
970
986
 
971
- | open | what it needs |
972
- | ---------------------------------- | ------------------------------------------------------------------------------------------------ |
973
- | the `/10` in the `MI` | the distribution over the 198 modules. It changes the spread, never the order |
974
- | the criticality order of the seven | one argument per indicator. Rarity rules two out, it does not rank the rest |
975
- | the overhead line of the SQL study | the harness rebuilt. #90 corrects the attribution and withdraws the 71%, which was never a ratio |
976
- | the workspace index for libraries | built and measured in 10.4 and 10.5. Nothing open here any more |
977
- | a feedback channel on findings | unchanged from section 8, and it is what would let any of this be proven wrong |
987
+ | open | what it needs |
988
+ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
989
+ | the `/10` in the `MI` | the distribution over the 198 modules. It changes the spread, never the order |
990
+ | the criticality order of the **nine** | one argument per indicator. **Rarity no longer rules any out:** see 11.6, where the split is removed. Ranking them is still open |
991
+ | the overhead line of the SQL study | the harness rebuilt. #90 corrects the attribution and withdraws the 71%, which was never a ratio |
992
+ | the workspace index for libraries | built and measured in 10.4 and 10.5. Nothing open here any more |
993
+ | a feedback channel on findings | unchanged from section 8, and it is what would let any of this be proven wrong |
978
994
 
979
995
  The one measurement that unblocks the first two is the same: **the eight indicators over all 198
980
- modules, once.** Until then the figures in this section come from four modules and are quoted as
981
- such.
996
+ modules, once.** That measurement now exists, in 10.7 over 177 modules and in 11 over a five-module
997
+ bench, and it is what fixed the thresholds. The figures in THIS section still come from four modules
998
+ and are quoted as such.
982
999
 
983
1000
  ---
984
1001
 
@@ -1173,7 +1190,357 @@ the binary failing. Measured: same 18 diagnostics either way, exit 1 against exi
1173
1190
 
1174
1191
  ---
1175
1192
 
1176
- ## 11. Sources
1193
+ ### 10.7 The whole repository, measured, and what it does to 9.3
1194
+
1195
+ Section 9.3 chose which indicators enter the maintainability figure by how often each fires per 100
1196
+ files, and excluded two of them on a **break of a factor 4.5** in that data. Those figures came from
1197
+ four modules, measured before two defects found while building the gauge were fixed. Here is the
1198
+ same question over **177 modules and 29 770 files**, with the gauge reading `src` only and counting
1199
+ a file once per indicator.
1200
+
1201
+ | indicator | per 100 files | modules above zero |
1202
+ | ------------------------ | ------------: | -----------------: |
1203
+ | `max-lines-per-function` | **10.4** | 75 |
1204
+ | `max-statements` | **6.9** | 82 |
1205
+ | `max-params` | 4.6 | 69 |
1206
+ | `max-classes-per-file` | 3.5 | 53 |
1207
+ | `max-lines` | 2.1 | 59 |
1208
+ | `complexity` | 0.8 | 33 |
1209
+ | `max-depth` | 0.0 | 12 |
1210
+ | `max-nested-callbacks` | 0.0 | 0 |
1211
+
1212
+ ⛔ **The 4.5 break is gone, and it was an artefact of the measurement rather than a fact about the
1213
+ code.** Section 9.3 reported 46.9 and 42.9 for the two descriptors; the **same three modules**
1214
+ measured now give **14.4 and 7.7**. Two defects fixed tonight explain the direction and the order of
1215
+ magnitude:
1216
+
1217
+ - diagnostics were counted where a breach is a (file, rule) pair. `max-nested-callbacks` fires once
1218
+ per nesting level; measured, counting diagnostics inflates by **1.8x at the published thresholds
1219
+ and 2.8x at lower ones**.
1220
+ - the population included the test suite, because `ignorePatterns` in oxlint's config is accepted
1221
+ and silently ignored. Measured on `libs/front/components`: `max-lines-per-function` falls from
1222
+ **33.6 to 23.9** per 100 files once the specs leave.
1223
+
1224
+ What the clean data shows instead is a **smooth gradient**, consecutive ratios of 1.5, 1.5, 1.3,
1225
+ 1.7, then 2.6 and finally 17.1 between `complexity` and `max-depth`:
1226
+
1227
+ max-lines-per-function 10.4 -> max-statements 6.9 x1.5
1228
+ max-statements 6.9 -> max-params 4.6 x1.5
1229
+ max-params 4.6 -> max-classes 3.5 x1.3
1230
+ max-classes 3.5 -> max-lines 2.1 x1.7
1231
+ max-lines 2.1 -> complexity 0.8 x2.6
1232
+ complexity 0.8 -> max-depth 0.0 x17.1
1233
+
1234
+ ⚠️ **So the descriptor split now rests on nothing measured.** The two rules excluded from the figure
1235
+ are still the two most frequent, which is a reason to treat them with care, but 1.5x is not the 4.5x
1236
+ the decision was taken on, and inventing a cut here would be exactly the kind of threshold this
1237
+ document refuses elsewhere. The relative ORDER did survive: `complexity` below `max-lines` below
1238
+ `max-params` as before, with only `max-classes-per-file` moving up, from below `complexity` to above
1239
+ it.
1240
+
1241
+ **This is left open rather than re-decided.** What the data supports today is one clean statement:
1242
+ `max-nested-callbacks` fires **nowhere** in the repository, and `max-depth` in 12 modules for 14
1243
+ files in all, so those two carry almost no information here whatever the figure does with them.
1244
+
1245
+ ---
1246
+
1247
+ ## 11. How each threshold was chosen
1248
+
1249
+ Sections 9.3 and 10.7 left one question open: the gauge ran at the thresholds ESLint ships, which is
1250
+ not a choice, and 10.7 showed the one decision taken on those figures rested on a measurement
1251
+ defect. This section closes it. Every number in `GAUGE_THRESHOLDS` comes from one rule applied eight
1252
+ times, and the rule is stated before any figure so it cannot be fitted to a result afterwards.
1253
+
1254
+ ### 11.1 The rule: `min(research, our p90, the rule's default)`
1255
+
1256
+ Three candidates per indicator, the strictest wins, and there is no fourth. The minimum is what
1257
+ makes it safe in **both** directions of failure, and both directions were observed here:
1258
+
1259
+ - ⛔ **A threshold above what the code reaches detects nothing.** `max-nested-callbacks` shipped at
1260
+ 10 while the deepest chain in the repository is 4. It reported 0 files in 177 modules, for as long
1261
+ as it existed. 10.7 recorded that as "carries almost no information"; the cause is simpler, the
1262
+ scale was wrong.
1263
+ - ⛔ **Research can be more permissive than our own code, and then our code wins.** IfSQ's SP-1 puts
1264
+ a routine at 150 lines. Our p90 is 109. Adopting 150 because a published standard says so would
1265
+ relax a control to match a document, which is the opposite of measuring.
1266
+
1267
+ A vendor default is a candidate, never a reference: it is shipped to fit every codebase, so it was
1268
+ calibrated on none. What it cost here is in 11.6.
1269
+
1270
+ ### 11.2 Three traps in deriving a threshold from our own distribution
1271
+
1272
+ ⚠️ **The p90 over the whole population collapses to 0 for four of the eight.** Ninety per cent of
1273
+ files never nest a block, never nest a callback, declare at most one class and take at most one
1274
+ parameter, so the percentile sits at the floor and a threshold of 0 flags everything. So "our p90"
1275
+ means **the p90 over the files the rule touches**, which is the only convention that does not
1276
+ collapse:
1277
+
1278
+ | indicator | p90 over all 8 083 files | p90 over the files it touches |
1279
+ | ---------------------- | -----------------------: | ----------------------------: |
1280
+ | `max-depth` | 0 | 3 |
1281
+ | `max-nested-callbacks` | 0 | 3 |
1282
+ | `max-classes-per-file` | 0 | 5 |
1283
+ | `max-params` | 3 | 4 |
1284
+
1285
+ ⚠️ **Rounding goes downward only.** `max-lines` derives a p90 of 157 and publishes 150. Rounding up
1286
+ would move a threshold away from the evidence that produced it, in the one direction the rule above
1287
+ exists to forbid.
1288
+
1289
+ ⛔ **The harness read seven of the eight message shapes.** oxlint parenthesises the measured value,
1290
+ `too many statements (20)`, except for `complexity`, which writes `has a complexity of 15`. A reader
1291
+ of the parenthesised form alone returns 0 for every complexity breach and says nothing about it: the
1292
+ first run of this bench reported **0 files above complexity 10, 15 and 20 alike**, which reads as
1293
+ "nothing to see" rather than as a broken instrument. The same defect sat in the role's own
1294
+ `valueOf`, where it was latent because no report row consumed the value yet. Both are fixed, and the
1295
+ regression test now uses the messages oxlint really emits rather than a paraphrase.
1296
+
1297
+ ### 11.3 The bench
1298
+
1299
+ **8 083 product files over five modules**: `console` and `host-admin` (react), `front-components`
1300
+ (react library), `recruitment` and `network` (nest). The exclusion list is the role's own, so no
1301
+ spec, fixture, mock, story, generated or declaration file is in any figure below. Distributions come
1302
+ from one oxlint run per rule set with every maximum at 1, so each occurrence reports its real value
1303
+ instead of only its breach.
1304
+
1305
+ ### 11.4 What IfSQ gives, and what it does not
1306
+
1307
+ IfSQ (ifsq.org) is the one standard found that states numeric limits with citations attached. It is
1308
+ a standard for **human inspection**: the level pages say what to look for, and `assessment.html`
1309
+ says who inspects, from self-assessment to third-party certification. There is no defect-density
1310
+ rule and no grade boundary anywhere on the site, checked page by page.
1311
+
1312
+ **Three of its fifteen indicators carry a number:**
1313
+
1314
+ | code | indicator | threshold | citations given |
1315
+ | -------- | ------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1316
+ | **SP-1** | Routine Too Long | > 150 lines, comments and blank lines excluded | IBM, "the most error-prone routines were those larger than 500 lines"; "when routines averaged 100 to 150 lines each, code was more stable"; "routines with 143 lines or less are 2.4 times less expensive to fix" |
1317
+ | **SP-2** | Nesting Too Deep | > 3 levels of conditional nesting | "few people can understand more than 3 levels of nested if statements". ⚠️ One finding page says "more than four" instead |
1318
+ | **SP-3** | Routine Too Complex | > 10 binary terms in conditionals | McCabe 1976 |
1319
+
1320
+ ⛔ **IfSQ sources nothing about a FILE's length.** All three citations above are about routines. So
1321
+ `max-lines` has no research candidate at all, and its threshold is our p90 and nothing else.
1322
+
1323
+ The remaining twelve state a defect to look for with no measurement: WIP-1 Vague "To Do", WIP-2
1324
+ Disabled Code, WIP-3 Empty Statement Block, SPM-1 Magic Numbers, SPM-2 Magic Strings, SPM-3
1325
+ Copy/Paste Programming, DP-1 Parameter Not Checked, DP-2 Status Ignored After Call, DP-3 Unexpected
1326
+ State Not Trapped, and the three Causes for Concern, which are an inspector's judgement. Of those,
1327
+ SPM-3 is already covered by the duplication pass and WIP-2 partly by the dead-code pass; WIP-1,
1328
+ WIP-3, SPM-1, SPM-2, DP-1 and DP-2 are candidates for indicators this role does not have yet.
1329
+
1330
+ ### 11.5 The eight thresholds, with their origin
1331
+
1332
+ | indicator | was | **is** | research | our p90 | what decided it |
1333
+ | ------------------------ | --: | ------: | -------- | ------: | --------------------------------------------------------------------------------------------------------- |
1334
+ | `complexity` | 20 | **10** | 10 | 11 | the only indicator where two independent references agree within a unit |
1335
+ | `max-lines-per-function` | 50 | **109** | 150 | 109 | research is more permissive than our code, so our code wins |
1336
+ | `max-lines` | 300 | **150** | none | 157 | our p90, rounded down. No research sets a file's length |
1337
+ | `max-depth` | 4 | **3** | 3 | 3 | research and our code agree |
1338
+ | `max-nested-callbacks` | 10 | **3** | 3 (SP-2) | 3 | same nesting family as SP-2, no citation of its own. Was above the repository's maximum of 4 |
1339
+ | `max-statements` | 10 | **10** | none | 15 | the default is the stricter candidate and stays |
1340
+ | `max-params` | 3 | **3** | none | 4 | the default stays. Beyond three parameters the fix is an object or currying, so this is a design boundary |
1341
+ | `max-classes-per-file` | 1 | **1** | none | 5 | 1 is the rule's floor, there is no stricter value |
1342
+
1343
+ Five move, and `complexity` is worth one line on its own: 94.8% of our 11 050 measured functions
1344
+ already sit in the 1-10 band the SEI calls low risk, so 10 is a ratchet rather than a demand.
1345
+
1346
+ | SEI band | functions | share |
1347
+ | ------------------- | --------: | ---------: |
1348
+ | 1-10, low risk | 10 475 | **94.8 %** |
1349
+ | 11-15 | 319 | 2.9 % |
1350
+ | 16-20 | 120 | 1.1 % |
1351
+ | 21-50, high risk | 128 | 1.2 % |
1352
+ | over 50, untestable | 8 | 0.1 % |
1353
+
1354
+ ### 11.6 What the eight size rules say together, and the end of the descriptor split
1355
+
1356
+ A threshold is only half a decision: what matters is what the **set** reports.
1357
+
1358
+ | | at the vendor defaults | at the thresholds above |
1359
+ | ----------------------------- | ------------------------------: | ----------------------: |
1360
+ | union of the eight size rules | 2 236 files, 27.7 % | **1 708 files, 21.1 %** |
1361
+ | largest single rule | `max-lines-per-function`, 1 724 | `max-statements`, 891 |
1362
+ | its share of the union | **77 %** | **52 %** |
1363
+
1364
+ ⛔ **At the defaults one rule was the report.** `max-lines-per-function` at 50 flagged 21.3% of all
1365
+ product files and carried 77% of the union, so the other seven indicators were inaudible: the
1366
+ report said "your functions are longer than 50 lines" and nothing else could be heard beside it.
1367
+
1368
+ And this closes what 10.7 left open. The descriptor split held `max-statements` and
1369
+ `max-lines-per-function` out of the figure on a **break of a factor 4.5** between 42.9/46.9 and 9.6
1370
+ per 100 files. 10.7 already showed those numbers were an artefact of two defects. At the thresholds
1371
+ above the two most frequent rules are 11.0% and 10.7%, and the next is 7.8%:
1372
+
1373
+ | indicator | files | per 100 files | ratio to the next |
1374
+ | ------------------------ | ----: | ------------: | ----------------: |
1375
+ | `max-statements` | 891 | 11.0 | 1.03 |
1376
+ | `max-lines` | 864 | 10.7 | 1.37 |
1377
+ | `max-lines-per-function` | 630 | 7.8 | 1.26 |
1378
+ | `complexity` | 499 | 6.2 | 1.34 |
1379
+ | `max-params` | 373 | 4.6 | 2.32 |
1380
+ | `max-classes-per-file` | 161 | 2.0 | 17.89 |
1381
+ | `max-nested-callbacks` | 9 | 0.1 | 1.13 |
1382
+ | `max-depth` | 8 | 0.1 | - |
1383
+
1384
+ **The top five are a smooth gradient, 1.03 to 1.37, so there is no population to exclude.** The one
1385
+ large break, 17.9, sits at the BOTTOM, between `max-classes-per-file` and the two nesting rules: it
1386
+ says those two are rare here, not that the frequent ones are noise. `DESCRIPTORS`, `scored` and
1387
+ `described` are removed: every indicator counts, and the asymmetry that needed explaining in two
1388
+ places no longer exists. Note also that the two nesting rules moved from reporting **nothing at all**
1389
+ to reporting 9 and 8 files: lowering them from 10 and 4 to 3 is what gave them any information.
1390
+
1391
+ ### 11.7 Why `max-statements` is not a second reading of `max-lines-per-function`
1392
+
1393
+ The obvious objection to keeping both is that a long function has many statements. Measured by
1394
+ joining the two diagnostics on the same function span, over 25 658 functions of which 10 441 carry
1395
+ both values:
1396
+
1397
+ | | |
1398
+ | --------------------------------------- | -------------------: |
1399
+ | Pearson, statements against lines | 0.726 |
1400
+ | Spearman | 0.600 |
1401
+ | lines per statement, p25 / median / p90 | 2.5 / **4.4** / 14.0 |
1402
+
1403
+ Correlated, so not independent. But the density varies by a factor of **5.6** across functions, and
1404
+ that is what forbids giving them one value. At the chosen thresholds:
1405
+
1406
+ | | functions | what it is |
1407
+ | ----------------------------- | --------: | ------------------------ |
1408
+ | over both | 399 | long and dense |
1409
+ | `max-statements` only | **699** | dense logic in few lines |
1410
+ | `max-lines-per-function` only | **255** | long with little logic |
1411
+ | overlap | **29 %** | |
1412
+
1413
+ Both shapes are real, and in this repository they are two different kinds of file:
1414
+
1415
+ | file | lines | statements | shape |
1416
+ | --------------------------------------- | ----: | ---------: | ---------------------- |
1417
+ | `add-to-network/JobsEvaluationList.tsx` | 259 | 2 | one large JSX return |
1418
+ | `entities/job-offer.entity.ts` | 48 | 46 | one statement per line |
1419
+
1420
+ So `max-lines-per-function` measures volume including markup, and `max-statements` measures
1421
+ imperative density, and neither sees what the other sees. Giving `max-statements` the same 109 would
1422
+ kill it: at the median density that is a function of roughly 480 lines, so the line count would
1423
+ always fire first and the statement count would never say anything again.
1424
+
1425
+ ---
1426
+
1427
+ ### 11.8 The ninth indicator: cognitive complexity, loaded rather than written
1428
+
1429
+ Section 9.2 chose this metric and said it would be "written once here". It is not written. oxlint
1430
+ 1.77 accepts an ESLint rule as a JS plugin, so Sonar's own `cognitive-complexity` runs inside the
1431
+ same invocation as the eight rules above, through a bridge that re-exposes it and contains none of
1432
+ their code.
1433
+
1434
+ **Why loaded and not reimplemented**, in order of weight:
1435
+
1436
+ ⛔ **The licence decides it.** `eslint-plugin-sonarjs` ships under the Sonar Source-Available
1437
+ License, whose grant is limited to a "Non-competitive Purpose", and sentinel is published under MIT.
1438
+ Depending on a package is not preparing a derivative work of it; transcribing its code into this
1439
+ repository would be, in any language, and would mean granting rights we never received. ⚠️ Its
1440
+ `package.json` declares `LGPL-3.0-only` while the LICENSE file and every source header say SSAL.
1441
+ That contradiction is Sonar's; it is recorded here, not resolved.
1442
+
1443
+ **The metric's author works there.** Campbell 2018 is theirs, so their implementation is the
1444
+ specification's own house rather than one reading of it among several.
1445
+
1446
+ **It costs almost nothing.** Measured: 0 advisories at every level, 94 packages, and `eslint` is
1447
+ **not** pulled, because pnpm leaves the peer unmet and oxlint provides the context itself. It does
1448
+ pull `typescript`, which nothing of ours imports. The rule adds 2.4 s over 8 083 files to a pass
1449
+ that was already reading them.
1450
+
1451
+ #### The behaviour is not the paper's, deliberately
1452
+
1453
+ Measured on the same file with the same parser, before any of it was believed:
1454
+
1455
+ | | sonarjs 0.25.1 | sonarjs 4.2.2 |
1456
+ | --------------------------- | -------------: | ------------: |
1457
+ | `if (a \|\| b \|\| c)` | 2 | **1** |
1458
+ | `if (a && b && c)` | 2 | 2 |
1459
+ | `const x = a \|\| b \|\| c` | 1 | **0** |
1460
+
1461
+ ⛔ **This is not a regression, which is what it was first called here.** Sequences of `||` and `??`
1462
+ are excluded on purpose: SonarSource state it themselves, "we tried to adjust the cognitive
1463
+ complexity rule to accommodate common idioms", and made the exclusion universal in SonarQube 10.8
1464
+ after it had been inconsistent between contexts. In JavaScript those two operators are
1465
+ overwhelmingly default-value shorthand rather than control flow. The lesson is the general one: an
1466
+ implementation that diverges from a published algorithm is a question to ask, not a defect to
1467
+ assume.
1468
+
1469
+ #### The host is validated, not assumed
1470
+
1471
+ Running somebody else's rule inside another linter removes the question "is our counting right" and
1472
+ replaces it with a narrower one: **does oxlint's tree feed the rule the same thing ESLint's does.**
1473
+ A fixture cannot answer that, because the constructs at risk are the ones real code has and
1474
+ fixtures do not: decorators, enums, `satisfies`, generics, and the JSX short-circuit the rule
1475
+ treats specially.
1476
+
1477
+ So the same rule was run through both hosts over two real modules and compared **function by
1478
+ function**, matched on file and line:
1479
+
1480
+ | | functions | identical | disagreements | seen by one host only |
1481
+ | ------------------------ | --------: | --------: | ------------: | --------------------: |
1482
+ | `contract`, nest | 539 | **539** | 0 | 0 |
1483
+ | `console`, react and tsx | 581 | **581** | 0 | 0 |
1484
+
1485
+ ⚠️ This was missing when the figure first shipped, and the gap was not obvious: the earlier check
1486
+ compared the two hosts on about twenty fixture functions, which proves the plumbing and not the
1487
+ parse. It is the same bar `complexity` was held to against oxlint, 839 functions and 839 identical
1488
+ values, and it is the bar any rule borrowed from another ecosystem has to clear.
1489
+
1490
+ #### The threshold
1491
+
1492
+ `min(research, our p90, the rule's default)`, the same rule as the eight above. Sonar publishes
1493
+ **15** for S3776, and that is a vendor default rather than a research value: one of their own
1494
+ engineers has said publicly that no formula supports it. Our p90 over the files the rule touches is
1495
+ **7**, so 7 wins. It reports **342 of 8 083 files, 4.2%**, inside the band the other eight occupy.
1496
+
1497
+ #### What it adds, and the honest version of that
1498
+
1499
+ ⚠️ **Almost nothing to the union.** Of its 342 files, **330 were already flagged** by one of the
1500
+ eight: the union goes from 1 708 files to 1 720, 21.1% to 21.3%. Anyone judging the indicator by
1501
+ coverage should stop here and conclude it is redundant.
1502
+
1503
+ That reading is wrong, and the measurement that shows why is the comparison with McCabe, which is
1504
+ the thing it was brought in to complement:
1505
+
1506
+ | on 8 083 product files | |
1507
+ | ------------------------------------------------- | ------: |
1508
+ | flagged by both complexities | 242 |
1509
+ | **cyclomatic only**, branchy but readable | **257** |
1510
+ | **cognitive only**, tangled without being branchy | **100** |
1511
+ | agreement between the two | **40%** |
1512
+ | Spearman over the 3 823 files carrying both | 0.764 |
1513
+
1514
+ The two disagree on **60% of the files either one flags**, which is the whole point. A flat `switch`
1515
+ with twelve cases is cyclomatically 13 and cognitively 1, because nothing is nested and nothing
1516
+ surprises; three `if`s inside each other are cyclomatically 4 and cognitively 6, because each level
1517
+ is charged for the levels above it. The 257 are the first shape and the 100 are the second. A figure
1518
+ that agreed with McCabe would have been the redundant one.
1519
+
1520
+ #### Three defects on the way, all with one symptom
1521
+
1522
+ Each produced exactly `oxlint could not be run, so no size indicator is reported`, which names
1523
+ neither the plugin nor the path, and each silently removed **all nine** figures rather than one:
1524
+
1525
+ - the bridge written straight into `plugins/`, which is git-ignored and wiped by every build. It
1526
+ survived until the next `pnpm build`.
1527
+ - its path computed by counting `..` from `import.meta.url`. `tsup` bundles into chunks whose depth
1528
+ is not the source tree's, so it resolved from `src/` and not from `dist/`. ⚠️
1529
+ `roles/test/shared-test-config.ts` already records this lesson at length, after it cost a module
1530
+ 83 passing tests; it was repeated anyway. The path is now walked up to.
1531
+ - the rule is namespaced by its plugin in the config, `sonar/cognitive-complexity`, and bare in the
1532
+ diagnostic. One name in both places gives a config oxlint accepts and a breach map that never
1533
+ matches.
1534
+
1535
+ All three are now tests.
1536
+
1537
+ ---
1538
+
1539
+ ## 12. Sources
1540
+
1541
+ - IfSQ. _Level 1, Level 2 and Level 3 Standards for Code Quality._ <https://www.ifsq.org/level-1.html>,
1542
+ <https://www.ifsq.org/level-2.html>, <https://www.ifsq.org/structured-programming.html>,
1543
+ <https://www.ifsq.org/research.html>, <https://www.ifsq.org/assessment.html>
1177
1544
 
1178
1545
  - Ben Khalfallah, H. _Code Health Meter: A Quantitative and Graph-Theoretic Foundation for
1179
1546
  Automated Code Quality and Architecture Assessment._ ACM TOSEM 35(4), article 114, March 2026.
@@ -4,8 +4,9 @@ The commands, what an adopted module ends up looking like, and how versions move
4
4
  migrating a module see [`lint-adoption.md`](lint-adoption.md),
5
5
  [`format-adoption.md`](format-adoption.md),
6
6
  [`typescript-adoption.md`](typescript-adoption.md),
7
- [`build-adoption.md`](build-adoption.md) and
8
- [`test-adoption.md`](test-adoption.md), which also carries the reference gate `--test` runs a
7
+ [`build-adoption.md`](build-adoption.md),
8
+ [`test-adoption.md`](test-adoption.md) and
9
+ [`static-analysis-adoption.md`](static-analysis-adoption.md), which also carries the reference gate `--test` runs a
9
10
  migration against.
10
11
 
11
12
  ## The grid