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