@hublo/sentinel 1.5.0-alpha.9 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +9 -7
  2. package/dist/bin/sentinel.js +61 -16
  3. package/dist/{chunk-KLEXJFJ6.js → chunk-43HQ6BVJ.js} +61 -15
  4. package/dist/{chunk-YQRDKWSH.js → chunk-IL5PPLXE.js} +11 -1
  5. package/dist/chunk-IL5PPLXE.js.map +1 -0
  6. package/dist/chunk-MOELAQPC.js +10365 -0
  7. package/dist/chunk-WBDBRGLV.js +214 -0
  8. package/dist/chunk-WBDBRGLV.js.map +1 -0
  9. package/dist/{chunk-AMCGMLPS.js → chunk-Y57KA4QS.js} +2 -2
  10. package/dist/index.d.ts +28 -1
  11. package/dist/index.js +4 -4
  12. package/dist/roles/build/nest/toolchain.js +68 -18
  13. package/dist/roles/build/nest/toolchain.js.map +1 -1
  14. package/dist/roles/static-analysis/config.d.ts +15 -1
  15. package/dist/roles/static-analysis/config.js +1 -1
  16. package/dist/roles/test/setup/jest-parity.js +2 -2
  17. package/dist/roles/test/setup/workspace-entry.js +2 -2
  18. package/dist/{validate-AWWMBSH5.js → validate-FRMENKHM.js} +2 -2
  19. package/docs/bundle-analysis-spec.md +272 -0
  20. package/docs/sql-verification-study.md +401 -0
  21. package/docs/static-analysis-adoption.md +285 -0
  22. package/docs/static-analysis-study.md +824 -18
  23. package/docs/typescript-adoption.md +1 -1
  24. package/docs/typescript-traces.md +27 -12
  25. package/docs/using-sentinel.md +22 -12
  26. package/docs/validating-a-change.md +185 -0
  27. package/lint/nest.json +98 -18
  28. package/lint/node.json +83 -3
  29. package/lint/react-lib.json +121 -31
  30. package/lint/react.json +101 -2
  31. package/package.json +27 -6
  32. package/plugins/hublo/no-palette-common.js +76 -0
  33. package/plugins/hublo/no-regex-lookbehind.js +70 -0
  34. package/plugins/hublo/require-reload-document.js +70 -0
  35. package/plugins/hublo.js +20 -145
  36. package/plugins/sonar-cognitive.js +108 -0
  37. package/dist/chunk-EDFIVLUA.js +0 -100
  38. package/dist/chunk-EDFIVLUA.js.map +0 -1
  39. package/dist/chunk-OZZICSSZ.js +0 -6469
  40. package/dist/chunk-YQRDKWSH.js.map +0 -1
  41. /package/dist/{chunk-AMCGMLPS.js.map → chunk-Y57KA4QS.js.map} +0 -0
@@ -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
@@ -563,7 +570,7 @@ disagree, the measurement is kept and the disagreement is stated.
563
570
  | ---------------- | ----------------------------------------------------------------------------------------- |
564
571
  | shape | a 20 KB launcher plus one of eight per-platform binaries, pulled as optional dependencies |
565
572
  | npm dependencies | **zero** (4.0.5 carried nine) |
566
- | engines | `>=18`, so the Node 20 floor holds |
573
+ | engines | `>=18`, so our own floor holds whatever it is |
567
574
  | speed | 180 files in **28 ms**; the dead-code pass on a 2180-file service in **84 ms** |
568
575
  | provenance | same repository `kucherenko/jscpd`, same maintainer, MIT |
569
576
 
@@ -634,7 +641,7 @@ The project's own benchmark uses a 547-file, 21 645-line corpus on Apple Silicon
634
641
  jscpd at **84 ms** against PMD CPD at **35 980 ms**. But jscpd analysed **347** of the 547 files
635
642
  and PMD **71**, so "the fastest of the set" holds and the factor of 428 does not.
636
643
 
637
- ### What escomplex is still needed for, and what it costs
644
+ ### ⛔ Superseded by 9.1: what escomplex WAS needed for, before it was removed
638
645
 
639
646
  `typhonjs-escomplex` is the only source for logical `SLOC`, the Halstead set and `MI`. It is also
640
647
  far faster than previously recorded: **0.8 to 1.6 ms per file** in a single process, not the 23 to
@@ -666,7 +673,9 @@ The scan is the **module root**, one module at a time, with `node_modules`, `dis
666
673
  `coverage`, the report directory, generated files and fixtures excluded, on top of the
667
674
  `.gitignore` the tool already honours. Both halves are measured: without an ignore list the
668
675
  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
676
+ `node_modules`; with it, 408 files and **zero**. ⛔ The paragraph that follows is superseded by
677
+ 10.1: the scan is **`src`**, or the module itself when it has none. It read "the root rather than
678
+ `src`" because the
670
679
  manifest and the root configuration files are the authoritative entry points:
671
680
 
672
681
  | module | from the root | from `src` |
@@ -675,7 +684,11 @@ manifest and the root configuration files are the authoritative entry points:
675
684
  | `apps/nest/microservices/network` | 1.0% | 0.9% |
676
685
  | `libs/front/components` | 30.3% | 30.3% |
677
686
 
678
- ### The three outputs, and one naming collision
687
+ ### The outputs, and one naming collision
688
+
689
+ ⚠️ There are **four** shapes: `console`, `json`, `html` and `markdown`. The three npm scripts below
690
+ are correct and deliberate: `markdown` is the recap a CI workflow produces, not a command anyone
691
+ types, so it gets no script.
679
692
 
680
693
  Every target name is a CLI flag, so `--format` already belongs to the format role and cannot mean
681
694
  an output shape. `--json` already exists engine-wide, documented for every verb.
@@ -745,24 +758,47 @@ the line ranges.
745
758
 
746
759
  ### Metrics by source
747
760
 
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 |
761
+ ⛔ **Superseded by 9.1 and 10.3.** Three rows below name a tool the role does not use. `escomplex`
762
+ was removed with Halstead, `MI` and logical `SLOC` (9.1): it fails to read 708 of 2 169 files of one
763
+ nest service, and Halstead's target is not defined anywhere. `madge` was never taken: the graph is
764
+ built from the role's own oxc parse, which costs 18 packages and 5.1 MB against madge's 136 and
765
+ 48.4 MB. And jscpd's per-file `CC` is not published: its unit sums a file, so ten trivial functions
766
+ score what one function of 10 does. The table as shipped is below it.
767
+
768
+ | metric | tool |
769
+ | --------------------------------------------------------- | ---------------------- |
770
+ | clones, duplicated lines and tokens, share per language | jscpd |
771
+ | dead code, four categories, with a confidence per finding | jscpd (`basta`) |
772
+ | ~~`CC` per file and per folder, physical lines, tokens~~ | ~~jscpd~~ |
773
+ | ~~logical `SLOC`, Halstead, `MI`~~ | ~~escomplex~~ |
774
+ | ~~cycles, `Q`, density, `Ca` / `Ce` / `DC` / `I`~~ | ~~madge~~ + graphology |
775
+
776
+ **As shipped:**
777
+
778
+ | metric | tool |
779
+ | --------------------------------------------------------- | --------------------------------- |
780
+ | clones, duplicated lines and tokens, share per language | jscpd |
781
+ | dead code, four categories, with a confidence per finding | jscpd (`basta`) |
782
+ | `CC` per **function**, in McCabe's risk bands | the role, over the oxc parse |
783
+ | cycles, `Q`, density, `Ca` / `Ce` / `DC` / `I` | the role's own graph + graphology |
784
+ | nine size indicators | oxlint, at our own thresholds |
785
+ | the bundle, transfer and parsed | the TanStack entry manifest |
755
786
 
756
787
  ### The unit is part of the number
757
788
 
758
789
  Two tools use the word complexity for two different units, and a report that omits them will look
759
790
  like it contradicts itself in public:
760
791
 
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` |
792
+ ⛔ **Superseded by 9.5.** `CX` is NOT a figure this role publishes: jscpd's per-file sum was
793
+ rejected once it was measured, and complexity here is per FUNCTION, computed by the role over the
794
+ oxc parse and reported in McCabe's bands. The row is kept because the unit collision it warns about
795
+ is still real for anyone comparing with another tool.
796
+
797
+ | | unit | value here |
798
+ | ------------------- | ------------------------------------------------- | ------------------------------ |
799
+ | oxlint `complexity` | per **function** | 10, the role's gauge threshold |
800
+ | this role | per **function**, McCabe | bands 11-20, 21-50, 51+ |
801
+ | ~~jscpd `CX`~~ | ~~per file, decision points plus function count~~ | ⛔ not published, see 9.5 |
766
802
 
767
803
  So this role does not re-judge a single function: a per-function or per-file limit is a lint rule,
768
804
  and oxlint already ships `complexity`, `max-lines`, `max-lines-per-function`, `max-depth`,
@@ -785,11 +821,781 @@ The two are complementary as long as every figure carries its unit.
785
821
  | 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
822
  | 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
823
  | 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 |
824
+ | Halstead and MI on nest | **settled: both leave, and cognitive complexity takes their place.** The third way out was written and measured; see section 9 |
789
825
 
790
826
  ---
791
827
 
792
- ## 9. Sources
828
+ ## 9. The two scores: what is settled, what is still ours to choose
829
+
830
+ Section 8 left `MI` open on escomplex's coverage. Working that question through replaced it. This
831
+ section records the model that came out, every step of it measured, and marks plainly what is
832
+ still a decision rather than a finding.
833
+
834
+ ### 9.1 Halstead and `MI` leave, and the reason is not the code
835
+
836
+ The third way out of section 8, recomputing Halstead on `oxc-parser`, was written and run against
837
+ escomplex over the 443 files of `apps/front/console` that it reads:
838
+
839
+ | count | identical |
840
+ | ---------------------- | --------: |
841
+ | n1, distinct operators | 93.9% |
842
+ | N1, total operators | 82.8% |
843
+ | n2, distinct operands | 74.3% |
844
+ | N2, total operands | 26.0% |
845
+ | logical SLOC | 42.7% |
846
+
847
+ Five causes were found by measuring and closed, each worth tens of points: `ignoreKeys`, walking
848
+ INTO `TS*` nodes rather than skipping their subtree, suppressing annotations only inside a
849
+ parameter list, one statement per function or class scope, and the implicit return of an
850
+ expression-bodied arrow. The rest is a long tail of single-rule differences in both directions.
851
+
852
+ ⛔ **The gap cannot be closed, because the target is not defined.**
853
+
854
+ - **IBM publishes the seven formulas and not the classification.** Its documentation says the
855
+ measures come directly from the operators and operands, and defines neither.
856
+ - **The ecosystem is one implementation.** `code-complexity`, `complexity-report` and `plato` all
857
+ depend on escomplex; `typhonjs-escomplex` is a fork of it.
858
+ - **The one independent implementation disagrees.** `ts-complex`, built on the TypeScript
859
+ compiler, agrees with escomplex on operands 8 fixtures out of 12 and on operators **0 out of
860
+ 12**: it counts neither `[]` nor `{}` nor `()`. On `return [1, 2, 3]` one says `n1 = 4`, the
861
+ other `n1 = 1`. On the 8 agreeing fixtures ours matches both.
862
+ - ⚠️ **And `ts-complex` cannot be the reference anyway.** It reads 3 683 files here with zero
863
+ failures, which looks better than escomplex until the instrument is checked: it runs on
864
+ TypeScript 2.9.2 and, on `satisfies`, `??` and `?.`, raises nothing and returns operand lists
865
+ containing an empty string. Silent degradation is worse than a throw.
866
+ - **A check needing no tool was tried and failed.** Halstead's length equation does not
867
+ discriminate: swapping operators and operands entirely leaves the correlation at 0.9222,
868
+ unchanged, because it is symmetric in `n1` and `n2`. It detects only total randomness.
869
+
870
+ ⛔ **Deleted on 2026-10-13**, with `src/roles/static-analysis/complexity/halstead.ts`, 412 lines
871
+ imported by nothing. Keeping dead code as a record is a habit this role exists to break, and the
872
+ record is this section.
873
+
874
+ ⛔ **Logical `SLOC` goes with it, and so do `LOC` and `PLOC` as metrics of ours.** Their only
875
+ source was escomplex, which this section rules out, so a V1 entry was left with no producer and no
876
+ decision written against it: exactly the gap that surfaces in a demo as "and where is SLOC?". The
877
+ decision is that we do not look for another source. Size is already answered by three figures that
878
+ do exist, `max-lines`, `max-lines-per-function` and `max-statements`, each with a threshold
879
+ derived in section 11, and a fourth count of lines would add a number without adding a question.
880
+
881
+ ⚠️ `LOC` still appears below inside OTHER tools' formulas, Sonar's maintainability rating and the
882
+ `MI` equation. Those are descriptions of what someone else computes, not metrics this role emits.
883
+
884
+ ### 9.2 Cognitive complexity takes the place
885
+
886
+ | | Halstead and `MI` | cognitive complexity |
887
+ | -------------------------- | ------------------------------------ | --------------------------------- |
888
+ | specification of the count | **none** published for TypeScript | Campbell 2018, an exact algorithm |
889
+ | reference implementation | two, disagreeing 12 times out of 12 | `eslint-plugin-sonarjs`, by Sonar |
890
+ | reads our code | 61% to 99% depending on the family | **100%**, 0 fatal errors |
891
+ | published threshold | none transferable on the 0-171 scale | **15**, rule S3776 |
892
+ | how ours gets validated | hand-counted fixtures only | function by function, as `CC` was |
893
+
894
+ Sonar's own implementation, run over four modules with its threshold lowered to 0 so that every
895
+ function is reported: sentinel 752 functions and 41 above 15, network 1 701 and 19, recruitment
896
+ 1 050 and 6, console 510 and 0. Zero fatal errors on 4 572 files, where escomplex fails on 37.9%
897
+ of `network` and 39.0% of `recruitment`. The worst module is this package.
898
+
899
+ ⛔ **Corrected on 2026-10-13: it is not written here, it is LOADED.** oxlint 1.77 accepts an ESLint
900
+ rule as a JS plugin, so Sonar's own rule runs inside our own pass with none of their code in this
901
+ repository. The reasoning, the measurements and the licence that decides it are in **11.8**.
902
+
903
+ ⚠️ **The four figures above were produced with a version this document does not name**, and the
904
+ plugin's behaviour moved between majors: sequences of `||` and `??` stopped incrementing. So treat
905
+ them as an order of magnitude, not as a baseline. The figures measured on the current version, and
906
+ what the metric actually adds beside McCabe, are in 11.8.
907
+
908
+ ### 9.3 The maintainability figure
909
+
910
+ **Which indicators are in it.** Each at its published threshold, and the ranking is by how often
911
+ each one fires per 100 files, which is measured rather than judged:
912
+
913
+ | indicator | published threshold | per 100 files |
914
+ | ---------------------- | ------------------: | ------------: |
915
+ | `max-depth` | 4 | 0.6 |
916
+ | `max-classes-per-file` | 1 | 1.8 |
917
+ | `complexity` | 20 | 3.7 |
918
+ | cognitive complexity | 15, S3776 | 6.5 |
919
+ | `max-lines` | 300 | 7.5 |
920
+ | `max-params` | 3 | 9.6 |
921
+ | `max-nested-callbacks` | 10 | 0 |
922
+
923
+ ⛔ **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.
924
+
925
+ **Which are out, and why.** There is a break in the data, a factor of 4.5, not a line we drew:
926
+
927
+ | kept as a descriptor | per 100 files |
928
+ | ------------------------ | ------------: |
929
+ | `max-statements` | 42.9 |
930
+ | `max-lines-per-function` | 46.9 |
931
+
932
+ They fire on nearly one file in two, so they would fire on nearly every pull request. A gate that
933
+ always fires is noise. They stay in the report as descriptors and out of the figure.
934
+
935
+ ⚠️ **Rarity is not severity.** It says what _can_ be a gate, not what is serious. Ordering the
936
+ seven that remain still needs one argument per indicator, and that is open; see 9.6.
937
+
938
+ **The computation.**
939
+
940
+ ```
941
+ per file breaches_f = indicators above their published threshold, in that file
942
+ → names the worst file, and counts the clean ones
943
+ the figure rate = Σ breaches / files × 100
944
+ MI = 1 / (1 + rate/10) 0 to 1, higher is better
945
+ beside it the share of files with no breach at all
946
+ ```
947
+
948
+ Measured: console 0.651, components 0.390, recruitment 0.237, sentinel 0.234. Clean files: 95%,
949
+ 87%, 74%, 82%.
950
+
951
+ ⛔ **Three things the measurement forced, each against an idea that looked better:**
952
+
953
+ - **Equal weights.** A coefficient fitted to today's distribution expires when the distribution
954
+ moves, which is an argument for no weights rather than for smarter ones.
955
+ - **A denominator is unavoidable.** `1/Σ` ranks by module size, and all four modules land between
956
+ 0.0005 and 0.0042, unreadable. `1/log(1+Σ)` does not fix it, it compresses it: a factor 1.4
957
+ between best and worst. No monotone transform of a raw count escapes size; only dividing by one.
958
+ - **Never the mean of a per-file score.** 74% to 95% of files are clean, so the mean of `1/(1+n)`
959
+ saturates between 0.86 and 0.97, a factor 1.13, and the median is 1.000 everywhere. Jensen. The
960
+ per-file computation is kept for what it alone gives: the worst file, named, and the share that
961
+ is clean.
962
+
963
+ ⚠️ The `/10` is a display scale, chosen here and arbitrary. It changes the spread, never the
964
+ order, and it is to be replaced; see 9.6. And sentinel at 0.234 and recruitment at 0.240 are
965
+ indistinguishable, not ranked.
966
+
967
+ ### 9.4 Structure is not a score
968
+
969
+ Each of these says its own thing, and they are reported side by side. Summing them would mean
970
+ inventing both a scale and a direction, which is exactly what 9.3 eliminated.
971
+
972
+ | metric | what it says | why it cannot be summed |
973
+ | --------------------- | ---------------------------------------- | --------------------------------------------------------- |
974
+ | cycles | files importing each other in a circle | 0 expected, **size-independent**: the only summable one |
975
+ | `Q` + community count | the quality of the assignment | high is not good: a module split into islands scores high |
976
+ | `Ca` / `Ce` / `I` | coupling, per file, the 10 most imported | Martin defined them for a component, not a file |
977
+ | `DC` | centrality, beside the raw count | normalised by `n − 1`, so incomparable between modules |
978
+ | density | a web or a tree | 0.0216 at 101 files, 0.0005 at 4 380: size, not structure |
979
+
980
+ ### 9.5 Where a rule lives: gate or gauge
981
+
982
+ The same algorithm is written once and consumed twice, and the split is forced by adoption rather
983
+ than preferred.
984
+
985
+ | | the lint plugin | the static-analysis role |
986
+ | ------------ | --------------------------------------- | ------------------------------------------- |
987
+ | what it is | a **gate**: it refuses | a **gauge**: it measures |
988
+ | configurable | yes, a team may derogate | **no**, or a number stops meaning one thing |
989
+ | threshold | whatever the preset sets | the published one, identical everywhere |
990
+ | reaches | **52 modules of 198** that adopted lint | **198**, it reads the oxc tree |
991
+
992
+ A score sourced from the plugin would exist on a quarter of the repository and be incomparable
993
+ between two modules that configured the rules differently. Hence the gauge, always present.
994
+
995
+ **The role never runs the module's lint.** It writes its own config to a tmpdir and passes it to
996
+ oxlint, exactly as `tool-config.ts` already does for jscpd. Verified: a config outside the module,
997
+ with `jsPlugins` naming a plugin by path, lints 523 files and leaves `.oxlintrc.json` untouched.
998
+ So no new setting reaches a module's configuration, and the gauge's thresholds live in a file the
999
+ module never sees, which is what makes them non-negotiable.
1000
+
1001
+ > ⚠️ Gate and gauge will disagree out loud, and the report must say so. `complexity` is published
1002
+ > at 20 and the `nest` preset refuses at 30, so a nest developer will read breaches beside a green
1003
+ > lint. That is correct, not a defect: the figure compares modules, the rule refuses code. Each
1004
+ > number is printed with the line that produced it, under "measured with".
1005
+
1006
+ ### 9.6 The gate is on the diff, because on a module it saturates
1007
+
1008
+ A worst-breach rating needs no weights and no denominator, which is why it was tried. Measured
1009
+ both ways:
1010
+
1011
+ | | the whole module | one commit |
1012
+ | --- | --------------------- | ---------: |
1013
+ | A | — | 39 (85%) |
1014
+ | C | — | 6 (13%) |
1015
+ | D | console | 0 |
1016
+ | E | sentinel, recruitment | 1 (2%) |
1017
+
1018
+ On a module it saturates: at 1 500 to 3 700 functions, something crosses any per-function line.
1019
+ On a diff it discriminates, and the shape suits it: over 300 first-parent commits of 90 days, 18%
1020
+ touch no production file at all and the median touches **4**.
1021
+
1022
+ > The 46-commit figure reads the files' **current** content, not their content at the commit, so
1023
+ > it answers "would this change be flagged today" rather than "did it introduce the defect".
1024
+
1025
+ ### 9.7 What is still a decision
1026
+
1027
+ | open | what it needs |
1028
+ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1029
+ | ~~the `/10` in the `MI`~~ | ⛔ **Closed by 11.6**: there is no `MI` and no composite figure. The descriptor split it scaled rested on a break that was an artefact of the vendor thresholds |
1030
+ | 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 |
1031
+ | the overhead line of the SQL study | the harness rebuilt. #90 corrects the attribution and withdraws the 71%, which was never a ratio |
1032
+ | the workspace index for libraries | built and measured in 10.4 and 10.5. Nothing open here any more |
1033
+ | a feedback channel on findings | unchanged from section 8, and it is what would let any of this be proven wrong |
1034
+
1035
+ The one measurement that unblocks the first two is the same: **the eight indicators over all 198
1036
+ modules, once.** That measurement now exists, in 10.7 over 177 modules and in 11 over a five-module
1037
+ bench, and it is what fixed the thresholds. The figures in THIS section still come from four modules
1038
+ and are quoted as such.
1039
+
1040
+ ---
1041
+
1042
+ ## 10. What the perimeter is, and what a figure refuses to answer
1043
+
1044
+ Section 9 settled the two scores. Building them raised a different question four times in one
1045
+ evening, always the same one under a new coat: **on which files does a figure speak, and what is
1046
+ it entitled to conclude about them?** Every answer below is measured, and two of them reverse a
1047
+ decision taken earlier in this document.
1048
+
1049
+ ### 10.1 The measured perimeter is a PLACE, not a list of patterns
1050
+
1051
+ What is measured is `src`, or the module itself when it has none.
1052
+
1053
+ | over the 208 modules with a `src` | files |
1054
+ | --------------------------------- | ------: |
1055
+ | product files under `src` | 30 008 |
1056
+ | outside it | 4 004 |
1057
+ | of those, tooling configuration | **205** |
1058
+
1059
+ The rest outside `src` is mostly `tests/doubles/`. One rule answers, identically for every pass,
1060
+ the three questions a reader actually asks: no, your `jest.config.ts` is not measured; no, your
1061
+ tests are not; no, nothing outside `src` is. A list of 24 exclusion patterns answered the same
1062
+ questions one argument at a time, and left the configs in.
1063
+
1064
+ ⚠️ The fallback is not a detail: **232 of the 440 nx projects have no `src` at all**, mostly cloud
1065
+ lambdas. Measuring nothing there would be a silence that reads as a pass.
1066
+
1067
+ ### 10.2 Being measured and being a USER are two different roles
1068
+
1069
+ ⭐ **A file outside the perimeter is never counted in a figure, and still proves that what it
1070
+ imports is alive.** A test is not measured; a test that imports a file shows the file is used.
1071
+
1072
+ The measurement that forced it, on `libs/nest/auth`: **11 production files are imported only from a
1073
+ test file**, `auth.module.ts` among them, and **47 projects depend on that module**. Treating "not
1074
+ measured" as "does not exist" turns all 11 into dead code.
1075
+
1076
+ So reachability reads imports from everywhere, and only the population reported on is bounded.
1077
+ The same holds for a configuration file: it is never measured, and an import from it still counts.
1078
+
1079
+ ⚠️ And a file that **only** tests import is not dead either, it is _used only by tests_: a third
1080
+ category with its own line. Measured: 6 such entry points on `auth`, **39 on `libs/front/components`**
1081
+ (`CalendarHeader`, `MonthCalendar`, `Cell`).
1082
+
1083
+ ### 10.3 A figure nothing can reach is not published
1084
+
1085
+ Two of the four original passes answer a question of the form "does anything reach this?", and both
1086
+ become WRONG rather than partial when a file cannot be parsed. Measured on `apps/front/hr-management`,
1087
+ 232 product `.ts`/`.js` beside 252 `.svelte`:
1088
+
1089
+ | what was reported | what it was worth |
1090
+ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
1091
+ | 161 edges, 124 communities, 163 entry points over 232 files | dust, not an application |
1092
+ | 33 files reported unused | **41 of the 232 are imported only from a `.svelte` file**: `debounce.ts`, `clickOutside.ts`, `isMobile.ts`, `modals.ts` |
1093
+
1094
+ So the graph and the dead-code figures are suppressed for such a module, and the drawing with them:
1095
+ figures carry their reserve in the report, an image carries none. Duplication and complexity stay,
1096
+ because each value they produce is true of the file it was measured on.
1097
+
1098
+ ⛔ **By language, not by a share.** A cut on the unreadable proportion would be a threshold nobody
1099
+ can source, and the defect does not arrive gradually: one unreadable component disconnects the
1100
+ helpers only it imports. Across the repository this touches **2 modules of 175**: `libs/front/ui`
1101
+ (73% unreadable) and `apps/front/hr-management` (52%).
1102
+
1103
+ ### 10.4 For a library, reachability is decided outside the module
1104
+
1105
+ This reverses section 7's assumption that one module's graph can answer its own dead code.
1106
+
1107
+ An application has an entry point: something runs, and what it cannot reach is dead. A library has
1108
+ none. Its consumers are outside, and the role's graph stops at the module edge on purpose. So the
1109
+ figure does not measure dead code there, it measures "the module does not import itself", which is
1110
+ true of every library.
1111
+
1112
+ | `libs/nest/auth`, 100 production files | |
1113
+ | -------------------------------------- | -----: |
1114
+ | reported dead by the entry-point walk | **87** |
1115
+ | `Ca = 0` inside the module graph | 29 |
1116
+ | of those, imported from another module | 17 |
1117
+ | **imported by nobody, anywhere** | **12** |
1118
+
1119
+ ⛔ **An index, real or synthetic, does not rescue it.** On `libs/front/components`, 432 distinct
1120
+ specifiers are used across the repository and **4 go through a barrel, 428 enter by a deep path**.
1121
+ The barrel exists and is bypassed 99 times out of 100, so a synthetic index would have to re-export
1122
+ everything and the figure would read **0% by construction**. Shape of the 164 libraries with a
1123
+ `src`: wildcard alias only **75 (46%)**, barrel and wildcard **71 (43%)**, barrel only **3 (2%)**,
1124
+ no alias of their own 15 (9%).
1125
+
1126
+ So the decision is to extend `Ca` past the module edge for a library, and only for a library: an
1127
+ application is imported by nothing, so its outside `Ca` is zero by construction, and 39 of its 44
1128
+ already declare what runs.
1129
+
1130
+ **The sentence of principle, written rather than slipped in.** For this one figure, and only this
1131
+ one, the role reads outside the module. Everywhere else the module edge is the boundary and that is
1132
+ deliberate: a figure comparable between modules must be computed the same way in each. Here the
1133
+ question is _who imports this_, the answer is by definition elsewhere, and refusing to look does not
1134
+ make the figure local, it makes it wrong. Measured on `libs/nest/auth`: 87 against 12.
1135
+
1136
+ **Built, and what changed.** The walk is kept and **seeded** rather than replaced, so it keeps the
1137
+ one thing it does that a degree count cannot: transitivity, a file reachable only through a dead one
1138
+ stays dead. Its entry points become the module's declared patterns plus the files another module
1139
+ imports.
1140
+
1141
+ | module | unused files | dead lines | used only by tests |
1142
+ | ----------------------- | -----------: | -------------: | -----------------: |
1143
+ | `libs/nest/auth` | **87 → 20** | 40,1 % → 4,9 % | 6 |
1144
+ | `libs/front/components` | **362 → 10** | → 1,4 % | **39** |
1145
+ | `recruitment`, an app | 10 → 10 | → 1,1 % | - |
1146
+ | `console`, an app | 2 → 2 | → 0,8 % | - |
1147
+
1148
+ ⚠️ **Two of those columns moved afterwards**, and the shipped figures are below. `unused files` now
1149
+ subtracts the files the tool itself labels `used-only-by-tests`, which were counted as unreachable
1150
+ while the tool knew better; and `used only by tests` became the UNION of two disjoint sources, that
1151
+ label and the workspace index. Re-measured on 2026-10-14 with 1.5.0-alpha.26:
1152
+
1153
+ | module | unused files | dead lines | used only by tests |
1154
+ | ----------------------- | -----------: | ---------: | -----------------: |
1155
+ | `libs/nest/auth` | **15** | 4,85 % | **11** |
1156
+ | `libs/front/components` | 8 | 1,42 % | 40 |
1157
+ | `console`, an app | 0 | 0,78 % | 2 |
1158
+
1159
+ The two libraries move and the two applications do not, which is the shape the measurement
1160
+ predicted: an application is imported by nothing, so its outside `Ca` is zero by construction.
1161
+
1162
+ ⭐ **And a third state appeared, which neither of the two could hold.** A file no production code
1163
+ imports but another module's **specs** do is not dead, and not plainly alive either: it is alive for
1164
+ the tests alone. `usedOnlyByTests` is its own row, 6 files on `auth` and **39** on
1165
+ `libs/front/components`, among them `CalendarHeader`, `MonthCalendar` and `Cell`.
1166
+
1167
+ ⚠️ The role got **faster**, not slower: `auth` runs in 529 ms against 873 ms before the index
1168
+ existed, because the walk has far less to report.
1169
+
1170
+ ### 10.5 The index: the cost is I/O, so the answer is amortisation and not a data structure
1171
+
1172
+ Finding a library's consumers costs 2 to 5 seconds with `git grep`, against 873 ms for the whole
1173
+ role on `auth`. The reflex is to reach for a better structure. Measured, that is the wrong end:
1174
+
1175
+ | | time |
1176
+ | ---------------------------------------------------------- | -----------: |
1177
+ | enumerate the 39 308 files (`git ls-files`) | **56 ms** |
1178
+ | `git grep` for a pattern that matches **nothing** | **8 994 ms** |
1179
+ | `git grep` for the real pattern, 2 708 lines found | 9 343 ms |
1180
+ | `git grep` for **every** alias at once, 22 303 lines found | 9 349 ms |
1181
+ | build the in-memory index from that output, 5 089 entries | **13 ms** |
1182
+
1183
+ A pattern that finds nothing costs what a pattern finding 22 303 lines costs: the price is reading
1184
+ the disk. A balanced tree would accelerate an insertion that already takes 13 ms, and a lookup that
1185
+ is free.
1186
+
1187
+ ⭐ **The lever is in the last row: one scan serves every library.** The index is built once, not per
1188
+ module.
1189
+
1190
+ | | |
1191
+ | -------------------------------- | --------: |
1192
+ | today, per library | ~4 900 ms |
1193
+ | one scan, the whole index | 9 024 ms |
1194
+ | amortised over the 164 libraries | **55 ms** |
1195
+
1196
+ Thirteen minutes become nine seconds. Restricting the scan to the consumers' `src` saves nothing
1197
+ (4 884 against 4 911 ms) and loses 5 real consumers, so it is not done.
1198
+
1199
+ **Where the cache lives: `node_modules/.cache/sentinel/`.** Not per module, because the index
1200
+ describes the workspace and 164 copies would be 164 different staleness states. Not inside the
1201
+ installed package, because that is the tool's directory rather than the workspace's, and an install
1202
+ replaces it.
1203
+
1204
+ The convention already exists in this repository: `node_modules/.cache` holds 208 MB and six tools
1205
+ use it, `prettier` and `storybook` among them. Three consequences come free: nothing to add to
1206
+ `.gitignore` since `node_modules` is already ignored, one cache per workspace which is the scope the
1207
+ data has, and a fresh install clearing it.
1208
+
1209
+ ⚠️ **That it survives an install is measured, not assumed**: `node_modules/.cache` was last written
1210
+ on 2026-09-17 and `.modules.yaml`, which pnpm rewrites on every install, carries 2026-09-27. An
1211
+ install ran ten days later and the cache is still there. pnpm removes only what it tracks.
1212
+
1213
+ And if the workspace ever switches to a hoisted linker: no collision is possible, since an npm
1214
+ package name cannot begin with a dot; the index itself is unaffected, since it maps specifiers to
1215
+ this repository's own sources and never reads `node_modules`; and the switch rebuilds `node_modules`
1216
+ wholesale, so the cache is lost once and rebuilt in nine seconds, which is the correct invalidation
1217
+ for a change that can change the installed code.
1218
+
1219
+ Keyed on the git state, `HEAD` plus the uncommitted files, rather than on mtimes: a `git checkout`
1220
+ moves every mtime without changing a byte.
1221
+
1222
+ ### 10.6 The size indicators, and three silent traps
1223
+
1224
+ ⚠️ **Two figures here were superseded by section 11**: there are **nine** rules, not seven, and
1225
+ they run at `min(research, our p90, the rule's default)` rather than at the threshold each rule
1226
+ publishes. The three traps below are unchanged and are why this section exists.
1227
+
1228
+ The size rules are measured with oxlint, run from a config in a temporary directory, and the
1229
+ module's own `.oxlintrc.json` is never read. Each trap below
1230
+ cost a wrong measurement before it was found, and none of them produced an error:
1231
+
1232
+ - **oxlint loads 65 rules by default.** Neither `plugins: []` nor `categories: {}` stops it. On
1233
+ `libs/nest/auth` it reported `no-unused-vars` and `no-useless-rename`, which are not indicators of
1234
+ this gauge. Every category has to be named and turned off; then exactly 8 load.
1235
+ - **`ignorePatterns` in the config is accepted and ignored.** Same patterns on the same module: 137
1236
+ files read through the config key against **111** through `--ignore-pattern`, and the worst file
1237
+ it produced was a 1 436-line `permissions.guard.spec.ts`. The gauge was measuring the test suite.
1238
+ - **A diagnostic is not a breach.** `max-nested-callbacks` fires once per nesting level, so the unit
1239
+ is the (file, rule) pair: counting diagnostics inflates the rate by **1.8x** at the published
1240
+ thresholds and **2.8x** at lower ones.
1241
+
1242
+ And `warn` rather than `error`, for the reason `tool-config.ts` already gives about jscpd's
1243
+ `threshold`: at `error`, oxlint exits 1 on any breach and a finding becomes indistinguishable from
1244
+ the binary failing. Measured: same 18 diagnostics either way, exit 1 against exit 0.
1245
+
1246
+ ---
1247
+
1248
+ ### 10.7 The whole repository, measured, and what it does to 9.3
1249
+
1250
+ Section 9.3 chose which indicators enter the maintainability figure by how often each fires per 100
1251
+ files, and excluded two of them on a **break of a factor 4.5** in that data. Those figures came from
1252
+ four modules, measured before two defects found while building the gauge were fixed. Here is the
1253
+ same question over **177 modules and 29 770 files**, with the gauge reading `src` only and counting
1254
+ a file once per indicator.
1255
+
1256
+ | indicator | per 100 files | modules above zero |
1257
+ | ------------------------ | ------------: | -----------------: |
1258
+ | `max-lines-per-function` | **10.4** | 75 |
1259
+ | `max-statements` | **6.9** | 82 |
1260
+ | `max-params` | 4.6 | 69 |
1261
+ | `max-classes-per-file` | 3.5 | 53 |
1262
+ | `max-lines` | 2.1 | 59 |
1263
+ | `complexity` | 0.8 | 33 |
1264
+ | `max-depth` | 0.0 | 12 |
1265
+ | `max-nested-callbacks` | 0.0 | 0 |
1266
+
1267
+ ⛔ **The 4.5 break is gone, and it was an artefact of the measurement rather than a fact about the
1268
+ code.** Section 9.3 reported 46.9 and 42.9 for the two descriptors; the **same three modules**
1269
+ measured now give **14.4 and 7.7**. Two defects fixed tonight explain the direction and the order of
1270
+ magnitude:
1271
+
1272
+ - diagnostics were counted where a breach is a (file, rule) pair. `max-nested-callbacks` fires once
1273
+ per nesting level; measured, counting diagnostics inflates by **1.8x at the published thresholds
1274
+ and 2.8x at lower ones**.
1275
+ - the population included the test suite, because `ignorePatterns` in oxlint's config is accepted
1276
+ and silently ignored. Measured on `libs/front/components`: `max-lines-per-function` falls from
1277
+ **33.6 to 23.9** per 100 files once the specs leave.
1278
+
1279
+ What the clean data shows instead is a **smooth gradient**, consecutive ratios of 1.5, 1.5, 1.3,
1280
+ 1.7, then 2.6 and finally 17.1 between `complexity` and `max-depth`:
1281
+
1282
+ max-lines-per-function 10.4 -> max-statements 6.9 x1.5
1283
+ max-statements 6.9 -> max-params 4.6 x1.5
1284
+ max-params 4.6 -> max-classes 3.5 x1.3
1285
+ max-classes 3.5 -> max-lines 2.1 x1.7
1286
+ max-lines 2.1 -> complexity 0.8 x2.6
1287
+ complexity 0.8 -> max-depth 0.0 x17.1
1288
+
1289
+ ⚠️ **So the descriptor split now rests on nothing measured.** The two rules excluded from the figure
1290
+ are still the two most frequent, which is a reason to treat them with care, but 1.5x is not the 4.5x
1291
+ the decision was taken on, and inventing a cut here would be exactly the kind of threshold this
1292
+ document refuses elsewhere. The relative ORDER did survive: `complexity` below `max-lines` below
1293
+ `max-params` as before, with only `max-classes-per-file` moving up, from below `complexity` to above
1294
+ it.
1295
+
1296
+ **This is left open rather than re-decided.** What the data supports today is one clean statement:
1297
+ `max-nested-callbacks` fires **nowhere** in the repository, and `max-depth` in 12 modules for 14
1298
+ files in all, so those two carry almost no information here whatever the figure does with them.
1299
+
1300
+ ---
1301
+
1302
+ ## 11. How each threshold was chosen
1303
+
1304
+ Sections 9.3 and 10.7 left one question open: the gauge ran at the thresholds ESLint ships, which is
1305
+ not a choice, and 10.7 showed the one decision taken on those figures rested on a measurement
1306
+ defect. This section closes it. Every number in `GAUGE_THRESHOLDS` comes from one rule applied eight
1307
+ times, and the rule is stated before any figure so it cannot be fitted to a result afterwards.
1308
+
1309
+ ### 11.1 The rule: `min(research, our p90, the rule's default)`
1310
+
1311
+ Three candidates per indicator, the strictest wins, and there is no fourth. The minimum is what
1312
+ makes it safe in **both** directions of failure, and both directions were observed here:
1313
+
1314
+ - ⛔ **A threshold above what the code reaches detects nothing.** `max-nested-callbacks` shipped at
1315
+ 10 while the deepest chain in the repository is 4. It reported 0 files in 177 modules, for as long
1316
+ as it existed. 10.7 recorded that as "carries almost no information"; the cause is simpler, the
1317
+ scale was wrong.
1318
+ - ⛔ **Research can be more permissive than our own code, and then our code wins.** IfSQ's SP-1 puts
1319
+ a routine at 150 lines. Our p90 is 109. Adopting 150 because a published standard says so would
1320
+ relax a control to match a document, which is the opposite of measuring.
1321
+
1322
+ A vendor default is a candidate, never a reference: it is shipped to fit every codebase, so it was
1323
+ calibrated on none. What it cost here is in 11.6.
1324
+
1325
+ ### 11.2 Three traps in deriving a threshold from our own distribution
1326
+
1327
+ ⚠️ **The p90 over the whole population collapses to 0 for four of the eight.** Ninety per cent of
1328
+ files never nest a block, never nest a callback, declare at most one class and take at most one
1329
+ parameter, so the percentile sits at the floor and a threshold of 0 flags everything. So "our p90"
1330
+ means **the p90 over the files the rule touches**, which is the only convention that does not
1331
+ collapse:
1332
+
1333
+ | indicator | p90 over all 8 083 files | p90 over the files it touches |
1334
+ | ---------------------- | -----------------------: | ----------------------------: |
1335
+ | `max-depth` | 0 | 3 |
1336
+ | `max-nested-callbacks` | 0 | 3 |
1337
+ | `max-classes-per-file` | 0 | 5 |
1338
+ | `max-params` | 3 | 4 |
1339
+
1340
+ ⚠️ **Rounding goes downward only.** `max-lines` derives a p90 of 157 and publishes 150. Rounding up
1341
+ would move a threshold away from the evidence that produced it, in the one direction the rule above
1342
+ exists to forbid.
1343
+
1344
+ ⛔ **The harness read seven of the eight message shapes.** oxlint parenthesises the measured value,
1345
+ `too many statements (20)`, except for `complexity`, which writes `has a complexity of 15`. A reader
1346
+ of the parenthesised form alone returns 0 for every complexity breach and says nothing about it: the
1347
+ first run of this bench reported **0 files above complexity 10, 15 and 20 alike**, which reads as
1348
+ "nothing to see" rather than as a broken instrument. The same defect sat in the role's own
1349
+ `valueOf`, where it was latent because no report row consumed the value yet. Both are fixed, and the
1350
+ regression test now uses the messages oxlint really emits rather than a paraphrase.
1351
+
1352
+ ### 11.3 The bench
1353
+
1354
+ **8 083 product files over five modules**: `console` and `host-admin` (react), `front-components`
1355
+ (react library), `recruitment` and `network` (nest). The exclusion list is the role's own, so no
1356
+ spec, fixture, mock, story, generated or declaration file is in any figure below. Distributions come
1357
+ from one oxlint run per rule set with every maximum at 1, so each occurrence reports its real value
1358
+ instead of only its breach.
1359
+
1360
+ ### 11.4 What IfSQ gives, and what it does not
1361
+
1362
+ IfSQ (ifsq.org) is the one standard found that states numeric limits with citations attached. It is
1363
+ a standard for **human inspection**: the level pages say what to look for, and `assessment.html`
1364
+ says who inspects, from self-assessment to third-party certification. There is no defect-density
1365
+ rule and no grade boundary anywhere on the site, checked page by page.
1366
+
1367
+ **Three of its fifteen indicators carry a number:**
1368
+
1369
+ | code | indicator | threshold | citations given |
1370
+ | -------- | ------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1371
+ | **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" |
1372
+ | **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 |
1373
+ | **SP-3** | Routine Too Complex | > 10 binary terms in conditionals | McCabe 1976 |
1374
+
1375
+ ⛔ **IfSQ sources nothing about a FILE's length.** All three citations above are about routines. So
1376
+ `max-lines` has no research candidate at all, and its threshold is our p90 and nothing else.
1377
+
1378
+ The remaining twelve state a defect to look for with no measurement: WIP-1 Vague "To Do", WIP-2
1379
+ Disabled Code, WIP-3 Empty Statement Block, SPM-1 Magic Numbers, SPM-2 Magic Strings, SPM-3
1380
+ Copy/Paste Programming, DP-1 Parameter Not Checked, DP-2 Status Ignored After Call, DP-3 Unexpected
1381
+ State Not Trapped, and the three Causes for Concern, which are an inspector's judgement. Of those,
1382
+ SPM-3 is already covered by the duplication pass and WIP-2 partly by the dead-code pass; WIP-1,
1383
+ WIP-3, SPM-1, SPM-2, DP-1 and DP-2 are candidates for indicators this role does not have yet.
1384
+
1385
+ ### 11.5 The eight thresholds, with their origin
1386
+
1387
+ | indicator | was | **is** | research | our p90 | what decided it |
1388
+ | ------------------------ | --: | ------: | -------- | ------: | --------------------------------------------------------------------------------------------------------- |
1389
+ | `complexity` | 20 | **10** | 10 | 11 | the only indicator where two independent references agree within a unit |
1390
+ | `max-lines-per-function` | 50 | **109** | 150 | 109 | research is more permissive than our code, so our code wins |
1391
+ | `max-lines` | 300 | **150** | none | 157 | our p90, rounded down. No research sets a file's length |
1392
+ | `max-depth` | 4 | **3** | 3 | 3 | research and our code agree |
1393
+ | `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 |
1394
+ | `max-statements` | 10 | **10** | none | 15 | the default is the stricter candidate and stays |
1395
+ | `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 |
1396
+ | `max-classes-per-file` | 1 | **1** | none | 5 | 1 is the rule's floor, there is no stricter value |
1397
+
1398
+ Five move, and `complexity` is worth one line on its own: 94.8% of our 11 050 measured functions
1399
+ already sit in the 1-10 band the SEI calls low risk, so 10 is a ratchet rather than a demand.
1400
+
1401
+ | SEI band | functions | share |
1402
+ | ------------------- | --------: | ---------: |
1403
+ | 1-10, low risk | 10 475 | **94.8 %** |
1404
+ | 11-15 | 319 | 2.9 % |
1405
+ | 16-20 | 120 | 1.1 % |
1406
+ | 21-50, high risk | 128 | 1.2 % |
1407
+ | over 50, untestable | 8 | 0.1 % |
1408
+
1409
+ ### 11.6 What the eight size rules say together, and the end of the descriptor split
1410
+
1411
+ A threshold is only half a decision: what matters is what the **set** reports.
1412
+
1413
+ | | at the vendor defaults | at the thresholds above |
1414
+ | ----------------------------- | ------------------------------: | ----------------------: |
1415
+ | union of the eight size rules | 2 236 files, 27.7 % | **1 708 files, 21.1 %** |
1416
+ | largest single rule | `max-lines-per-function`, 1 724 | `max-statements`, 891 |
1417
+ | its share of the union | **77 %** | **52 %** |
1418
+
1419
+ ⛔ **At the defaults one rule was the report.** `max-lines-per-function` at 50 flagged 21.3% of all
1420
+ product files and carried 77% of the union, so the other seven indicators were inaudible: the
1421
+ report said "your functions are longer than 50 lines" and nothing else could be heard beside it.
1422
+
1423
+ And this closes what 10.7 left open. The descriptor split held `max-statements` and
1424
+ `max-lines-per-function` out of the figure on a **break of a factor 4.5** between 42.9/46.9 and 9.6
1425
+ per 100 files. 10.7 already showed those numbers were an artefact of two defects. At the thresholds
1426
+ above the two most frequent rules are 11.0% and 10.7%, and the next is 7.8%:
1427
+
1428
+ | indicator | files | per 100 files | ratio to the next |
1429
+ | ------------------------ | ----: | ------------: | ----------------: |
1430
+ | `max-statements` | 891 | 11.0 | 1.03 |
1431
+ | `max-lines` | 864 | 10.7 | 1.37 |
1432
+ | `max-lines-per-function` | 630 | 7.8 | 1.26 |
1433
+ | `complexity` | 499 | 6.2 | 1.34 |
1434
+ | `max-params` | 373 | 4.6 | 2.32 |
1435
+ | `max-classes-per-file` | 161 | 2.0 | 17.89 |
1436
+ | `max-nested-callbacks` | 9 | 0.1 | 1.13 |
1437
+ | `max-depth` | 8 | 0.1 | - |
1438
+
1439
+ **The top five are a smooth gradient, 1.03 to 1.37, so there is no population to exclude.** The one
1440
+ large break, 17.9, sits at the BOTTOM, between `max-classes-per-file` and the two nesting rules: it
1441
+ says those two are rare here, not that the frequent ones are noise. `DESCRIPTORS`, `scored` and
1442
+ `described` are removed: every indicator counts, and the asymmetry that needed explaining in two
1443
+ places no longer exists. Note also that the two nesting rules moved from reporting **nothing at all**
1444
+ to reporting 9 and 8 files: lowering them from 10 and 4 to 3 is what gave them any information.
1445
+
1446
+ ### 11.7 Why `max-statements` is not a second reading of `max-lines-per-function`
1447
+
1448
+ The obvious objection to keeping both is that a long function has many statements. Measured by
1449
+ joining the two diagnostics on the same function span, over 25 658 functions of which 10 441 carry
1450
+ both values:
1451
+
1452
+ | | |
1453
+ | --------------------------------------- | -------------------: |
1454
+ | Pearson, statements against lines | 0.726 |
1455
+ | Spearman | 0.600 |
1456
+ | lines per statement, p25 / median / p90 | 2.5 / **4.4** / 14.0 |
1457
+
1458
+ Correlated, so not independent. But the density varies by a factor of **5.6** across functions, and
1459
+ that is what forbids giving them one value. At the chosen thresholds:
1460
+
1461
+ | | functions | what it is |
1462
+ | ----------------------------- | --------: | ------------------------ |
1463
+ | over both | 399 | long and dense |
1464
+ | `max-statements` only | **699** | dense logic in few lines |
1465
+ | `max-lines-per-function` only | **255** | long with little logic |
1466
+ | overlap | **29 %** | |
1467
+
1468
+ Both shapes are real, and in this repository they are two different kinds of file:
1469
+
1470
+ | file | lines | statements | shape |
1471
+ | --------------------------------------- | ----: | ---------: | ---------------------- |
1472
+ | `add-to-network/JobsEvaluationList.tsx` | 259 | 2 | one large JSX return |
1473
+ | `entities/job-offer.entity.ts` | 48 | 46 | one statement per line |
1474
+
1475
+ So `max-lines-per-function` measures volume including markup, and `max-statements` measures
1476
+ imperative density, and neither sees what the other sees. Giving `max-statements` the same 109 would
1477
+ kill it: at the median density that is a function of roughly 480 lines, so the line count would
1478
+ always fire first and the statement count would never say anything again.
1479
+
1480
+ ---
1481
+
1482
+ ### 11.8 The ninth indicator: cognitive complexity, loaded rather than written
1483
+
1484
+ Section 9.2 chose this metric and said it would be "written once here". It is not written. oxlint
1485
+ 1.77 accepts an ESLint rule as a JS plugin, so Sonar's own `cognitive-complexity` runs inside the
1486
+ same invocation as the eight rules above, through a bridge that re-exposes it and contains none of
1487
+ their code.
1488
+
1489
+ **Why loaded and not reimplemented**, in order of weight:
1490
+
1491
+ ⛔ **The licence decides it.** `eslint-plugin-sonarjs` ships under the Sonar Source-Available
1492
+ License, whose grant is limited to a "Non-competitive Purpose", and sentinel is published under MIT.
1493
+ Depending on a package is not preparing a derivative work of it; transcribing its code into this
1494
+ repository would be, in any language, and would mean granting rights we never received. ⚠️ Its
1495
+ `package.json` declares `LGPL-3.0-only` while the LICENSE file and every source header say SSAL.
1496
+ That contradiction is Sonar's; it is recorded here, not resolved.
1497
+
1498
+ **The metric's author works there.** Campbell 2018 is theirs, so their implementation is the
1499
+ specification's own house rather than one reading of it among several.
1500
+
1501
+ **It costs almost nothing.** Measured: 0 advisories at every level, 94 packages, and `eslint` is
1502
+ **not** pulled, because pnpm leaves the peer unmet and oxlint provides the context itself. It does
1503
+ pull `typescript`, which nothing of ours imports. The rule adds 2.4 s over 8 083 files to a pass
1504
+ that was already reading them.
1505
+
1506
+ #### The behaviour is not the paper's, deliberately
1507
+
1508
+ Measured on the same file with the same parser, before any of it was believed:
1509
+
1510
+ | | sonarjs 0.25.1 | sonarjs 4.2.2 |
1511
+ | --------------------------- | -------------: | ------------: |
1512
+ | `if (a \|\| b \|\| c)` | 2 | **1** |
1513
+ | `if (a && b && c)` | 2 | 2 |
1514
+ | `const x = a \|\| b \|\| c` | 1 | **0** |
1515
+
1516
+ ⛔ **This is not a regression, which is what it was first called here.** Sequences of `||` and `??`
1517
+ are excluded on purpose: SonarSource state it themselves, "we tried to adjust the cognitive
1518
+ complexity rule to accommodate common idioms", and made the exclusion universal in SonarQube 10.8
1519
+ after it had been inconsistent between contexts. In JavaScript those two operators are
1520
+ overwhelmingly default-value shorthand rather than control flow. The lesson is the general one: an
1521
+ implementation that diverges from a published algorithm is a question to ask, not a defect to
1522
+ assume.
1523
+
1524
+ #### The host is validated, not assumed
1525
+
1526
+ Running somebody else's rule inside another linter removes the question "is our counting right" and
1527
+ replaces it with a narrower one: **does oxlint's tree feed the rule the same thing ESLint's does.**
1528
+ A fixture cannot answer that, because the constructs at risk are the ones real code has and
1529
+ fixtures do not: decorators, enums, `satisfies`, generics, and the JSX short-circuit the rule
1530
+ treats specially.
1531
+
1532
+ So the same rule was run through both hosts over two real modules and compared **function by
1533
+ function**, matched on file and line:
1534
+
1535
+ | | functions | identical | disagreements | seen by one host only |
1536
+ | ------------------------ | --------: | --------: | ------------: | --------------------: |
1537
+ | `contract`, nest | 539 | **539** | 0 | 0 |
1538
+ | `console`, react and tsx | 581 | **581** | 0 | 0 |
1539
+
1540
+ ⚠️ This was missing when the figure first shipped, and the gap was not obvious: the earlier check
1541
+ compared the two hosts on about twenty fixture functions, which proves the plumbing and not the
1542
+ parse. It is the same bar `complexity` was held to against oxlint, 839 functions and 839 identical
1543
+ values, and it is the bar any rule borrowed from another ecosystem has to clear.
1544
+
1545
+ #### The threshold
1546
+
1547
+ `min(research, our p90, the rule's default)`, the same rule as the eight above. Sonar publishes
1548
+ **15** for S3776, and that is a vendor default rather than a research value: one of their own
1549
+ engineers has said publicly that no formula supports it. Our p90 over the files the rule touches is
1550
+ **7**, so 7 wins. It reports **342 of 8 083 files, 4.2%**, inside the band the other eight occupy.
1551
+
1552
+ #### What it adds, and the honest version of that
1553
+
1554
+ ⚠️ **Almost nothing to the union.** Of its 342 files, **330 were already flagged** by one of the
1555
+ eight: the union goes from 1 708 files to 1 720, 21.1% to 21.3%. Anyone judging the indicator by
1556
+ coverage should stop here and conclude it is redundant.
1557
+
1558
+ That reading is wrong, and the measurement that shows why is the comparison with McCabe, which is
1559
+ the thing it was brought in to complement:
1560
+
1561
+ | on 8 083 product files | |
1562
+ | ------------------------------------------------- | ------: |
1563
+ | flagged by both complexities | 242 |
1564
+ | **cyclomatic only**, branchy but readable | **257** |
1565
+ | **cognitive only**, tangled without being branchy | **100** |
1566
+ | agreement between the two | **40%** |
1567
+ | Spearman over the 3 823 files carrying both | 0.764 |
1568
+
1569
+ The two disagree on **60% of the files either one flags**, which is the whole point. A flat `switch`
1570
+ with twelve cases is cyclomatically 13 and cognitively 1, because nothing is nested and nothing
1571
+ surprises; three `if`s inside each other are cyclomatically 4 and cognitively 6, because each level
1572
+ is charged for the levels above it. The 257 are the first shape and the 100 are the second. A figure
1573
+ that agreed with McCabe would have been the redundant one.
1574
+
1575
+ #### Three defects on the way, all with one symptom
1576
+
1577
+ Each produced exactly `oxlint could not be run, so no size indicator is reported`, which names
1578
+ neither the plugin nor the path, and each silently removed **all nine** figures rather than one:
1579
+
1580
+ - the bridge written straight into `plugins/`, which is git-ignored and wiped by every build. It
1581
+ survived until the next `pnpm build`.
1582
+ - its path computed by counting `..` from `import.meta.url`. `tsup` bundles into chunks whose depth
1583
+ is not the source tree's, so it resolved from `src/` and not from `dist/`. ⚠️
1584
+ `roles/test/shared-test-config.ts` already records this lesson at length, after it cost a module
1585
+ 83 passing tests; it was repeated anyway. The path is now walked up to.
1586
+ - the rule is namespaced by its plugin in the config, `sonar/cognitive-complexity`, and bare in the
1587
+ diagnostic. One name in both places gives a config oxlint accepts and a breach map that never
1588
+ matches.
1589
+
1590
+ All three are now tests.
1591
+
1592
+ ---
1593
+
1594
+ ## 12. Sources
1595
+
1596
+ - IfSQ. _Level 1, Level 2 and Level 3 Standards for Code Quality._ <https://www.ifsq.org/level-1.html>,
1597
+ <https://www.ifsq.org/level-2.html>, <https://www.ifsq.org/structured-programming.html>,
1598
+ <https://www.ifsq.org/research.html>, <https://www.ifsq.org/assessment.html>
793
1599
 
794
1600
  - Ben Khalfallah, H. _Code Health Meter: A Quantitative and Graph-Theoretic Foundation for
795
1601
  Automated Code Quality and Architecture Assessment._ ACM TOSEM 35(4), article 114, March 2026.