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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,15 +53,16 @@ import {
53
53
  selfCommand,
54
54
  splitToolArgs,
55
55
  walk
56
- } from "./chunk-KLEXJFJ6.js";
56
+ } from "./chunk-IFK6KGUW.js";
57
57
  import {
58
58
  DEFAULT_MAX_DIAGNOSTICS,
59
59
  WORKSPACE_ROOT_MARKER,
60
60
  findWorkspaceRoot,
61
61
  moduleName,
62
+ ownPackageDir,
62
63
  readOwnPackage,
63
64
  readProjectPackageJson
64
- } from "./chunk-YQRDKWSH.js";
65
+ } from "./chunk-JEYGPPSL.js";
65
66
  import {
66
67
  REPORT_DIR,
67
68
  SCRIPTS,
@@ -5104,7 +5105,7 @@ var SIZE = { width: 1600, height: 1200, margin: 30 };
5104
5105
  function hueOf(community) {
5105
5106
  return `hsl(${community * 137 % 360} 65% 52%)`;
5106
5107
  }
5107
- function writeGraphPicture(graph, communities, path2) {
5108
+ function layOut(graph, communities) {
5108
5109
  const drawn = graph.copy();
5109
5110
  circular.assign(drawn);
5110
5111
  forceAtlas2.assign(drawn, { iterations: ITERATIONS, settings: forceAtlas2.inferSettings(drawn) });
@@ -5116,11 +5117,144 @@ function writeGraphPicture(graph, communities, path2) {
5116
5117
  drawn.setNodeAttribute(node, "color", hueOf(communities[node] ?? 0));
5117
5118
  drawn.setNodeAttribute(node, "label", named.has(node) ? node.split("/").pop() ?? node : BLANK);
5118
5119
  });
5120
+ return drawn;
5121
+ }
5122
+ function writeGraphPicture(drawn, path2) {
5119
5123
  return new Promise((resolve5) => {
5120
5124
  renderSvg(drawn, path2, SIZE, () => resolve5());
5121
5125
  });
5122
5126
  }
5123
5127
 
5128
+ // src/roles/static-analysis/graph/explorer.ts
5129
+ function place(graph) {
5130
+ const nodes = graph.nodes();
5131
+ let minX = Infinity;
5132
+ let maxX = -Infinity;
5133
+ let minY = Infinity;
5134
+ let maxY = -Infinity;
5135
+ for (const node of nodes) {
5136
+ const x = graph.getNodeAttribute(node, "x");
5137
+ const y = graph.getNodeAttribute(node, "y");
5138
+ if (x < minX) minX = x;
5139
+ if (x > maxX) maxX = x;
5140
+ if (y < minY) minY = y;
5141
+ if (y > maxY) maxY = y;
5142
+ }
5143
+ const usableWidth = SIZE.width - 2 * SIZE.margin;
5144
+ const usableHeight = SIZE.height - 2 * SIZE.margin;
5145
+ const spanX = maxX - minX || 1;
5146
+ const spanY = maxY - minY || 1;
5147
+ const scale = Math.min(usableWidth / spanX, usableHeight / spanY);
5148
+ const offsetX = SIZE.margin + (usableWidth - spanX * scale) / 2;
5149
+ const offsetY = SIZE.margin + (usableHeight - spanY * scale) / 2;
5150
+ const placed = /* @__PURE__ */ new Map();
5151
+ for (const node of nodes) {
5152
+ const x = graph.getNodeAttribute(node, "x");
5153
+ const y = graph.getNodeAttribute(node, "y");
5154
+ placed.set(node, {
5155
+ x: offsetX + (x - minX) * scale,
5156
+ y: offsetY + (y - minY) * scale,
5157
+ // `size` is already the sqrt-damped degree `picture.ts` computed, in the same units as the
5158
+ // standalone image, so the two drawings show a file at the same weight.
5159
+ r: graph.getNodeAttribute(node, "size")
5160
+ });
5161
+ }
5162
+ return placed;
5163
+ }
5164
+ function round(value) {
5165
+ return (Math.round(value * 100) / 100).toString();
5166
+ }
5167
+ function hueOf2(community) {
5168
+ return `hsl(${community * 137 % 360} 65% 52%)`;
5169
+ }
5170
+ function jsonInScript(value) {
5171
+ return value.replace(/</g, "\\u003c");
5172
+ }
5173
+ function buildExplorer(drawn, communities) {
5174
+ const placed = place(drawn);
5175
+ const nodes = [...placed.keys()];
5176
+ const index = new Map(nodes.map((node, position) => [node, position]));
5177
+ const edges = [];
5178
+ const segments = [];
5179
+ drawn.forEachEdge((_edge, _attributes, source, target) => {
5180
+ const from = placed.get(source);
5181
+ const to = placed.get(target);
5182
+ const sourceIndex = index.get(source);
5183
+ const targetIndex = index.get(target);
5184
+ if (!from || !to || sourceIndex === void 0 || targetIndex === void 0) return;
5185
+ segments.push(`M${round(from.x)} ${round(from.y)}L${round(to.x)} ${round(to.y)}`);
5186
+ edges.push(sourceIndex, targetIndex);
5187
+ });
5188
+ const used = [...new Set(nodes.map((node) => communities[node] ?? 0))].sort((a, b) => a - b);
5189
+ const palette2 = used.map((community) => `.g svg .k${community}{fill:${hueOf2(community)}}`).join("");
5190
+ const circles = nodes.map((node) => {
5191
+ const spot = placed.get(node);
5192
+ if (!spot) return "";
5193
+ const community = communities[node] ?? 0;
5194
+ return `<circle cx="${round(spot.x)}" cy="${round(spot.y)}" r="${round(spot.r)}" class="k${community}"/>`;
5195
+ }).join("");
5196
+ const payload = JSON.stringify({ f: nodes, e: edges });
5197
+ const markup = `<section class="g">
5198
+ <p class="read">One dot per file, sized by how many imports touch it, coloured by the community it landed in. The shape is the answer to how many lobes there are and whether they are joined. <b>Point at a dot for its path, click it to keep only that file and its neighbours, click the background to come back.</b></p>
5199
+ <div class="gbar"><output id="gname">&nbsp;</output><button type="button" id="greset" hidden>show everything</button></div>
5200
+ <svg viewBox="0 0 ${SIZE.width} ${SIZE.height}" role="img" aria-label="The module's import graph, one dot per file">
5201
+ <path class="ge" d="${segments.join("")}"/>
5202
+ <path class="gh" d=""/>
5203
+ <g class="gn">${circles}</g>
5204
+ </svg>
5205
+ <style>${palette2}</style>
5206
+ <script type="application/json" id="gdata">${jsonInScript(payload)}</script>
5207
+ <script>${SCRIPT}</script>
5208
+ </section>`;
5209
+ return { markup, bytes: Buffer.byteLength(markup, "utf8") };
5210
+ }
5211
+ var SCRIPT = `(function(){
5212
+ var root=document.currentScript.closest('.g');
5213
+ var data=JSON.parse(root.querySelector('#gdata').textContent);
5214
+ var dots=root.querySelectorAll('.gn circle');
5215
+ var name=root.querySelector('#gname');
5216
+ var reset=root.querySelector('#greset');
5217
+ var highlight=root.querySelector('.gh');
5218
+ var out=[],inn=[];
5219
+ for(var i=0;i<data.f.length;i++){out.push([]);inn.push([]);}
5220
+ for(var k=0;k<data.e.length;k+=2){out[data.e[k]].push(data.e[k+1]);inn[data.e[k+1]].push(data.e[k]);}
5221
+ function show(i){
5222
+ name.textContent=data.f[i]+' ('+out[i].length+' imported, '+inn[i].length+' importing)';
5223
+ }
5224
+ function clear(){
5225
+ root.classList.remove('gsel');
5226
+ highlight.setAttribute('d','');
5227
+ for(var i=0;i<dots.length;i++)dots[i].classList.remove('gon','gpick');
5228
+ name.innerHTML='&nbsp;';
5229
+ reset.hidden=true;
5230
+ }
5231
+ function pick(i){
5232
+ root.classList.add('gsel');
5233
+ var near={};near[i]=1;
5234
+ var d='';
5235
+ function seg(a,b){
5236
+ d+='M'+dots[a].getAttribute('cx')+' '+dots[a].getAttribute('cy')+'L'+dots[b].getAttribute('cx')+' '+dots[b].getAttribute('cy');
5237
+ }
5238
+ for(var a=0;a<out[i].length;a++){near[out[i][a]]=1;seg(i,out[i][a]);}
5239
+ for(var b=0;b<inn[i].length;b++){near[inn[i][b]]=1;seg(inn[i][b],i);}
5240
+ highlight.setAttribute('d',d);
5241
+ for(var j=0;j<dots.length;j++){
5242
+ dots[j].classList.toggle('gon',!!near[j]);
5243
+ dots[j].classList.toggle('gpick',j===i);
5244
+ }
5245
+ show(i);
5246
+ reset.hidden=false;
5247
+ }
5248
+ for(var n=0;n<dots.length;n++){
5249
+ (function(i){
5250
+ dots[i].addEventListener('mouseenter',function(){show(i);});
5251
+ dots[i].addEventListener('click',function(event){event.stopPropagation();pick(i);});
5252
+ })(n);
5253
+ }
5254
+ root.querySelector('svg').addEventListener('click',clear);
5255
+ reset.addEventListener('click',clear);
5256
+ })();`;
5257
+
5124
5258
  // src/roles/static-analysis/graph/scan.ts
5125
5259
  import Graph from "graphology";
5126
5260
  import louvain from "graphology-communities-louvain";
@@ -5283,16 +5417,35 @@ function scanGraph(cwd, config) {
5283
5417
  import { mkdtempSync as mkdtempSync2, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
5284
5418
  import { tmpdir as tmpdir2 } from "node:os";
5285
5419
  import { join as join20 } from "node:path";
5286
- var PUBLISHED_THRESHOLDS = {
5287
- complexity: 20,
5288
- "max-depth": 4,
5289
- "max-lines": 300,
5290
- "max-lines-per-function": 50,
5420
+ var GAUGE_THRESHOLDS = {
5421
+ // McCabe 1976 says 10, our p90 says 11. The only indicator where two independent candidates agree
5422
+ // within one unit, and 94.8% of our functions already sit in the 1-10 band SEI calls low risk.
5423
+ complexity: 10,
5424
+ // IfSQ SP-2, "few people can understand nesting to more than 3 levels". Our p90 agrees at 3.
5425
+ "max-depth": 3,
5426
+ // No research anywhere for a FILE's length: IfSQ's three citations (500, 100-150, 143) are all
5427
+ // about routines. So our p90 of 157, rounded down.
5428
+ "max-lines": 150,
5429
+ // Our p90. IfSQ SP-1 says 150 and is discarded for being more permissive than our own code.
5430
+ "max-lines-per-function": 109,
5431
+ // No research. Our p90 is 15, the rule's default is 10, so the default stays as the stricter one.
5291
5432
  "max-statements": 10,
5433
+ // No research, and none needed: beyond three parameters the fix is an object or currying, so this
5434
+ // is a design boundary and not a dial. Our p90 of 4 is the looser candidate and loses.
5292
5435
  "max-params": 3,
5293
- "max-nested-callbacks": 10,
5294
- "max-classes-per-file": 1
5436
+ // Same nesting family as `max-depth`, no citation of its own. Our p90 agrees at 3.
5437
+ "max-nested-callbacks": 3,
5438
+ // 1 is the rule's floor, there is no stricter value to pick.
5439
+ "max-classes-per-file": 1,
5440
+ // Cognitive complexity, Campbell 2018, measured by Sonar's own rule, loaded
5441
+ // through `plugins/sonar-cognitive.js`.
5442
+ // Sonar publishes 15 for S3776, and that is a vendor default rather than a research value: one of
5443
+ // their own engineers has said publicly that no formula supports it. Our p90 is 7, so 7 wins, and
5444
+ // it reports 342 of 8 083 files, 4.2%, which sits inside the band the eight above occupy.
5445
+ "cognitive-complexity": 7
5295
5446
  };
5447
+ var COGNITIVE = "cognitive-complexity";
5448
+ var cognitivePlugin = () => join20(ownPackageDir(), "plugins", "sonar-cognitive.js");
5296
5449
  var CATEGORIES = [
5297
5450
  "correctness",
5298
5451
  "suspicious",
@@ -5306,13 +5459,20 @@ function sizeRulesConfig() {
5306
5459
  return {
5307
5460
  plugins: [],
5308
5461
  categories: Object.fromEntries(CATEGORIES.map((category) => [category, "off"])),
5462
+ // Sonar's cognitive complexity, in the SAME run as the eight above rather than a second
5463
+ // invocation: the parse is the expensive part and it is already paid here. Measured over the
5464
+ // whole bench, 8 083 files, the rule adds 2.4 s to a pass that was going to read them anyway.
5465
+ jsPlugins: [cognitivePlugin()],
5309
5466
  // ⛔ `warn` and not `error`, for the reason `tool-config.ts` gives about jscpd's `threshold`:
5310
5467
  // at `error` oxlint exits 1 as soon as one file breaches, which is indistinguishable from the
5311
5468
  // binary failing to run. Measured on `libs/nest/auth`, same config otherwise: `error` exits 1,
5312
5469
  // `warn` exits 0, and both report the same 18 diagnostics. So the tool measures, this role
5313
5470
  // judges, and a non-zero exit means the tool failed.
5314
5471
  rules: Object.fromEntries(
5315
- Object.entries(PUBLISHED_THRESHOLDS).map(([rule, threshold]) => [rule, ["warn", threshold]])
5472
+ Object.entries(GAUGE_THRESHOLDS).map(([rule, threshold]) => [
5473
+ rule === COGNITIVE ? `sonar/${rule}` : rule,
5474
+ ["warn", threshold]
5475
+ ])
5316
5476
  )
5317
5477
  };
5318
5478
  }
@@ -5341,7 +5501,10 @@ function ruleOf(code) {
5341
5501
  return /\(([^)]+)\)\s*$/.exec(code)?.[1];
5342
5502
  }
5343
5503
  function valueOf(message) {
5344
- return Number(/\((\d+)\)/.exec(message)?.[1] ?? 0);
5504
+ const match = /\((\d+)\)/.exec(message) ?? /complexity of (\d+)/.exec(message) ?? // Sonar's own wording, a third shape again: "reduce its Cognitive Complexity from 7 to the 0
5505
+ // allowed". It parenthesises nothing, and the `0` after it is the threshold, not the value.
5506
+ /Complexity from (\d+) to/.exec(message);
5507
+ return Number(match?.[1] ?? 0);
5345
5508
  }
5346
5509
  function breachesOf(raw) {
5347
5510
  let parsed;
@@ -5355,7 +5518,7 @@ function breachesOf(raw) {
5355
5518
  for (const diagnostic of diagnostics) {
5356
5519
  const rule = ruleOf(diagnostic.code ?? "");
5357
5520
  const file = diagnostic.filename;
5358
- if (!rule || !file || !(rule in PUBLISHED_THRESHOLDS)) continue;
5521
+ if (!rule || !file || !(rule in GAUGE_THRESHOLDS)) continue;
5359
5522
  const value = valueOf(diagnostic.message ?? "");
5360
5523
  const line = diagnostic.labels?.[0]?.span?.line ?? 0;
5361
5524
  const key = `${file}\0${rule}`;
@@ -5524,59 +5687,66 @@ var metricsHelp = {
5524
5687
  },
5525
5688
  complexity: {
5526
5689
  label: "Files with a complex function",
5527
- what: "Files holding at least one function whose cyclomatic complexity passes 20.",
5690
+ what: "Files holding at least one function whose cyclomatic complexity passes 10.",
5528
5691
  read: "Count of files, so it is comparable between modules only against their file count, which the unit carries. It is the same measure as the band rows above, seen per file rather than per function. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5529
- caveat: "It does not say which function, nor whether branching is wrong there: a parser legitimately branches. \u26D4 This is a GAUGE and not the line your lint refuses at. It runs oxlint with its own config in a temporary directory, at the threshold the rule publishes, and never reads the module's `.oxlintrc.json`. So it answers the same question the same way in every module, including the 146 of 198 that have not adopted lint, and it WILL disagree with a green build: the `nest` preset refuses `complexity` at 30 while this reports above 20.",
5530
- source: "oxlint complexity, at ESLint's published default of 20. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5692
+ caveat: "It does not say which function, nor whether branching is wrong there: a parser legitimately branches. \u26D4 This is a GAUGE and not the line your lint refuses at. It runs oxlint with its own config in a temporary directory, at a threshold fixed once for every module, and never reads the module's `.oxlintrc.json`. So it answers the same question the same way in every module, including the 146 of 198 that have not adopted lint, and it WILL disagree with a green build: the `nest` preset refuses `complexity` at 30 while this reports above 10.",
5693
+ source: "oxlint complexity, above 10. The threshold comes from McCabe 1976, which puts the limit at 10, and from our own p90 at 11: the one indicator where two independent references agree within a unit. 94.8% of our functions already sit in the 1-10 band. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5531
5694
  },
5532
5695
  "max-depth": {
5533
5696
  label: "Files with deep nesting",
5534
- what: "Files holding a block nested more than four levels deep.",
5697
+ what: "Files holding a block nested more than three levels deep.",
5535
5698
  read: "The rarest of the indicators, 0.6 firings per 100 files across the repository, so one occurrence is worth opening rather than filtering. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5536
5699
  caveat: "Depth is not complexity: four flat `if`s in a row never trip it, and one `if` inside three loops does.",
5537
- source: "oxlint max-depth, at ESLint's published default of 4. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5700
+ source: "oxlint max-depth, above 3. The threshold is IfSQ SP-2, which says few people can understand nesting of conditional statements beyond 3 levels, and our p90 agrees at 3. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5538
5701
  },
5539
5702
  "max-lines": {
5540
- label: "Files over 300 lines",
5541
- what: "Files longer than 300 lines, blank lines and comments included.",
5703
+ label: "Long files",
5704
+ what: "Files longer than 150 lines, blank lines and comments included.",
5542
5705
  read: "A size signal and nothing else. Read it beside the duplication figure: a long file that duplicates nothing is a different problem from a long file that repeats itself. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5543
5706
  caveat: "Length is not a defect. A generated file, a long constant table or a barrel is long by nature, which is why this is reported and never failed on.",
5544
- source: "oxlint max-lines, at ESLint's published default of 300. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5707
+ source: "oxlint max-lines, above 150. The threshold is our p90 of 157 rounded down, because no research anywhere sets a FILE's length: IfSQ's three citations (500, 100-150, 143) are all about routines. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5545
5708
  },
5546
5709
  "max-lines-per-function": {
5547
5710
  label: "Files with a long function",
5548
- what: "Files holding at least one function longer than 50 lines.",
5711
+ what: "Files holding at least one function longer than 109 lines.",
5549
5712
  read: "\u26A0\uFE0F A DESCRIPTOR, not part of the maintainability figure. It fires on 46.9 files per 100 across the repository, so it would fire on nearly every pull request. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5550
- caveat: "What is excluded from the figure is the SCORE, not the reading: the count stays here precisely because it is frequent, and a module far from 46.9 is saying something.",
5551
- source: "oxlint max-lines-per-function, at ESLint's published default of 50. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5713
+ caveat: "Length is a signal and not a defect: a long function can be a flat list of fields. Read it beside the statement count, which is the other half of the same question, and which it does NOT duplicate: measured here, 255 long functions breach no statement count and 699 dense ones are not long.",
5714
+ source: "oxlint max-lines-per-function, above 109. The threshold is our p90. IfSQ SP-1 says 150, citing IBM on routines over 500 lines, and is discarded here for being more permissive than our own code. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5552
5715
  },
5553
5716
  "max-statements": {
5554
5717
  label: "Files with a long function body",
5555
5718
  what: "Files holding at least one function of more than ten statements.",
5556
5719
  read: "\u26A0\uFE0F A DESCRIPTOR, not part of the maintainability figure, for the same reason as the row above: 42.9 firings per 100 files. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5557
5720
  caveat: "It counts statements and not work: ten calls in a row trip it where one dense expression does not.",
5558
- source: "oxlint max-statements, at ESLint's published default of 10. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5721
+ source: "oxlint max-statements, above 10. The threshold is the rule's own default, kept because our p90 of 15 is the looser of the two; it is not redundant with the line count, 699 functions breach it without being long and 255 are long without breaching it. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5559
5722
  },
5560
5723
  "max-params": {
5561
5724
  label: "Files with a wide signature",
5562
5725
  what: "Files holding at least one function taking more than three parameters.",
5563
5726
  read: "The most frequent of the scored indicators, 9.6 per 100 files. A wide signature is usually an object waiting to be named. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5564
5727
  caveat: "A destructured object parameter counts as one, so the figure does not see a function taking fifteen fields in one bag.",
5565
- source: "oxlint max-params, at ESLint's published default of 3. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5728
+ source: "oxlint max-params, above 3. The threshold is the rule\u2019s own default, kept because our p90 of 4 is looser; beyond three parameters the fix is an object or currying, so this is a design boundary rather than a dial. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5566
5729
  },
5567
5730
  "max-nested-callbacks": {
5568
5731
  label: "Files with nested callbacks",
5569
- what: "Files holding callbacks nested more than ten deep.",
5732
+ what: "Files holding callbacks nested more than three deep.",
5570
5733
  read: "Zero everywhere measured so far. Kept because a non-zero value is then unambiguous. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5571
5734
  caveat: "It counts callbacks and not promise chains or awaits, so modern asynchronous code can be deeply sequential without ever tripping it.",
5572
- source: "oxlint max-nested-callbacks, at ESLint's published default of 10. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5735
+ source: "oxlint max-nested-callbacks, above 3. The threshold is IfSQ SP-2 again, same nesting family, and our p90 agrees at 3. It sat at 10 while the deepest chain in the repository is 4, so it reported 0 files for as long as it shipped. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5573
5736
  },
5574
5737
  "max-classes-per-file": {
5575
5738
  label: "Files with several classes",
5576
5739
  what: "Files declaring more than one class.",
5577
5740
  read: "1.8 per 100 files. On a nest service it usually means a DTO file, which is a convention rather than a defect. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5578
5741
  caveat: "It says nothing about the size of the classes, and a file with one 800-line class never appears here.",
5579
- source: "oxlint max-classes-per-file, at ESLint's published default of 1. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5742
+ source: "oxlint max-classes-per-file, above 1. The threshold is the rule's floor, there is no stricter value to pick. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5743
+ },
5744
+ "cognitive-complexity": {
5745
+ label: "Files hard to follow",
5746
+ what: "Files holding at least one function whose cognitive complexity passes 7.",
5747
+ read: "The other complexity figure, and it answers a different question from McCabe's: not how many paths a test suite must cover, but how much a reader has to hold in their head. The two disagree on purpose. A flat `switch` with twelve cases is cyclomatically 13 and cognitively 1, because nothing is nested and nothing surprises; three `if`s inside each other are cyclomatically 4 and cognitively 6, because each level is charged for the levels above it. When a file is high here and low there, the code is tangled rather than merely branchy, and that is the one this role would act on first.",
5748
+ caveat: "\u26A0\uFE0F In JavaScript and TypeScript, sequences of `||` and `??` do NOT increment, deliberately: in this language they are overwhelmingly default-value shorthand rather than control flow, and SonarSource made that exclusion universal in SonarQube 10.8. So a long chain of fallbacks scores zero here while `&&` chains are charged. \u26D4 And it is a GAUGE, not the line your lint refuses at: no preset in this repository enforces cognitive complexity at all today.",
5749
+ source: "Sonar's own `cognitive-complexity` rule from `eslint-plugin-sonarjs`, loaded into oxlint through `plugins/sonar-cognitive.js` and run in the same invocation as the size rules. It is USED and never copied: that package ships under the Sonar Source-Available License and sentinel is MIT. The algorithm is Campbell 2018 and its author works at SonarSource, so their implementation is the specification's own house rather than one reading of it; verified against Campbell's published example, which scores 7 in both. The threshold is 7, from `min(research, our p90, the rule's default)`: Sonar publishes 15 for S3776 and that is a vendor default rather than a research value, since one of their own engineers has said publicly that no formula supports it, while our p90 over the files the rule touches is 7. It reports 342 of 8 083 files on the bench, 4.2%."
5580
5750
  },
5581
5751
  usedOnlyByTests: {
5582
5752
  label: "Used only by tests",
@@ -5584,6 +5754,13 @@ var metricsHelp = {
5584
5754
  read: "A third state between alive and dead, and the only one of the three that names a design question rather than a defect: a production module shipping something only the suites consume. Zero is the ordinary value.",
5585
5755
  caveat: "It says nothing about whether that is wrong. A test factory deliberately published by a library belongs here and is doing its job. What it refuses to do is call those files dead, which is what a walk that ignores tests does: measured on `libs/nest/auth`, 11 live files are imported only from a test, `auth.module.ts` among them, and 47 projects depend on that module. \u26A0\uFE0F It sees FILES, so an export used only by specs inside a file that production also imports is invisible here.",
5586
5756
  source: "The workspace index in `workspace/consumers.ts`: one scan of the tracked files, specifiers resolved against the tsconfig aliases, an importer classified as a test by the same conventions the duplication pass uses. Measured on this repository: 278 such files in all, 39 of them in `libs/front/components`."
5757
+ },
5758
+ measuredFiles: {
5759
+ label: "Files the gauge read",
5760
+ what: "How many product files the size indicators were measured over.",
5761
+ read: "The denominator of every size row above. None of them compares between modules without it: three breaches over 50 files and three over 3 000 are not the same module.",
5762
+ caveat: "It is the gauge\u2019s population and not the module\u2019s: `src` only, tests, mocks and generated files excluded, which is the same list every other pass is given. So it is smaller than what `ls` would count, deliberately, and it will differ from the duplication pass\u2019s own file count, which drops files shorter than the detection window.",
5763
+ source: "oxlint's `number_of_files` for the run this role drives, with the role's product exclusions passed as `--ignore-pattern`. Verified against the graph pass on `libs/nest/auth`: both read the same 99 files."
5587
5764
  }
5588
5765
  };
5589
5766
  function helpFor(key) {
@@ -5883,8 +6060,35 @@ ${rest > 0 ? ` <p class="sub">The ${HTML_GROUP_LIMIT} longest are listed; ${r
5883
6060
  white-space: nowrap;
5884
6061
  }
5885
6062
  /* The graph. The svg carries a viewBox, so dropping its fixed width lets it fill the column. */
5886
- .picture svg { width: 100%; height: auto; display: block; }
5887
- .picture p.read { margin: 0 0 0.8rem; padding: 0 0.6rem; color: var(--ink-soft); font-size: 0.88rem; max-width: 52rem; }
6063
+ .g svg { width: 100%; height: auto; display: block; }
6064
+ .g p.read { margin: 0 0 0.8rem; padding: 0 0.6rem; color: var(--ink-soft); font-size: 0.88rem; max-width: 52rem; }
6065
+ /* Every edge in one path, faint: it carries the shape, not a readable dependency. */
6066
+ .g .ge { stroke: var(--rule); stroke-width: 0.6; fill: none; }
6067
+ /* The selected file's own edges, drawn on top only once something is picked. */
6068
+ .g .gh { stroke: var(--brand); stroke-width: 1.4; fill: none; }
6069
+ .g .gn circle { cursor: pointer; }
6070
+ /* Dim the module once a file is picked, so its neighbourhood is what is left to read. The
6071
+ opacity sits on the GROUP and the exceptions raise themselves back: one rule instead of one
6072
+ class per dot, which matters at 4 380 of them. */
6073
+ .g.gsel .gn circle { opacity: 0.12; }
6074
+ .g.gsel .gn circle.gon { opacity: 1; }
6075
+ .g.gsel .gn circle.gpick { opacity: 1; stroke: var(--ink); stroke-width: 2; }
6076
+ .g.gsel .ge { opacity: 0.25; }
6077
+ /* The caption. A min-height and not a conditional row: the drawing must not jump by a line
6078
+ when a name appears, which is exactly what pointing at a dot would cause. */
6079
+ .gbar {
6080
+ display: flex; gap: 0.6rem; align-items: center; justify-content: space-between;
6081
+ min-height: 1.9rem; padding: 0 0.6rem; margin-bottom: 0.3rem;
6082
+ }
6083
+ .gbar output {
6084
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.82rem;
6085
+ color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
6086
+ }
6087
+ .gbar button {
6088
+ font: inherit; font-size: 0.8rem; cursor: pointer; white-space: nowrap;
6089
+ padding: 0.2rem 0.6rem; border: 1px solid var(--rule); border-radius: 0.3rem;
6090
+ background: var(--surface); color: var(--ink-soft);
6091
+ }
5888
6092
 
5889
6093
  /* The collapsed reserves. Closed by default: the figures are what the page opens on. */
5890
6094
  details { border-bottom: 1px solid var(--rule); }
@@ -5965,10 +6169,7 @@ ${report.notes.map((note) => ` <tr><td class="read">${escape(note)}</td></t
5965
6169
  ` : ""}
5966
6170
  ${report.picture ? `
5967
6171
  <h2>Shape</h2>
5968
- <section class="picture">
5969
- <p class="read">One dot per file, sized by how many imports touch it, coloured by the community it landed in. It shows the SHAPE: how many lobes, whether they are joined, where everything converges. The names are in the tables below.</p>
5970
6172
  ${report.picture}
5971
- </section>
5972
6173
  ` : ""}
5973
6174
  <h2>What these numbers do not say</h2>
5974
6175
  <section>
@@ -6000,7 +6201,7 @@ import { existsSync as existsSync15, mkdirSync, readFileSync as readFileSync16,
6000
6201
  import { dirname as dirname2, join as join21, relative as relative4, resolve as resolve3, sep } from "node:path";
6001
6202
  var CACHE_DIR = join21("node_modules", ".cache", "sentinel");
6002
6203
  var CACHE_FILE = "consumer-index.json";
6003
- var SHAPE = 3;
6204
+ var SHAPE = 4;
6004
6205
  var EXTENSIONS2 = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
6005
6206
  function workspaceRootFrom(cwd) {
6006
6207
  let dir = resolve3(cwd);
@@ -6070,6 +6271,24 @@ function resolveSpecifier2(specifier, table, workspaceRoot) {
6070
6271
  }
6071
6272
  return void 0;
6072
6273
  }
6274
+ function resolveRelative(importer, specifier, workspaceRoot) {
6275
+ const base = join21(dirname2(importer), specifier).split(sep).join("/");
6276
+ if (base.startsWith("..")) return void 0;
6277
+ const isFile = (candidate) => {
6278
+ try {
6279
+ return statSync4(join21(workspaceRoot, candidate)).isFile();
6280
+ } catch {
6281
+ return false;
6282
+ }
6283
+ };
6284
+ if (isFile(base)) return base;
6285
+ for (const extension of EXTENSIONS2) if (isFile(base + extension)) return base + extension;
6286
+ for (const extension of EXTENSIONS2) {
6287
+ const index = `${base}/index${extension}`;
6288
+ if (isFile(index)) return index;
6289
+ }
6290
+ return void 0;
6291
+ }
6073
6292
  function isTestFile(path2) {
6074
6293
  return /\.(test|spec|steps|e2e-spec|integration-spec|stories)\.[cm]?[jt]sx?$|(^|\/)(tests|__tests__|test|testing|stubs|__mocks__|mocks)\//.test(
6075
6294
  path2
@@ -6091,8 +6310,18 @@ function buildConsumerIndex(workspaceRoot, moduleRoots) {
6091
6310
  if (table.size === 0) return { imported, testOnly, builtFrom: "no alias table" };
6092
6311
  const result = spawnSync5(
6093
6312
  "git",
6094
- ["grep", "-I", "-n", "-o", "-E", "@[A-Za-z0-9_-]+/[A-Za-z0-9_./-]+", "--", "*.ts", "*.tsx"],
6095
- { cwd: workspaceRoot, encoding: "utf8", maxBuffer: 512 * 1024 * 1024 }
6313
+ [
6314
+ "grep",
6315
+ "-I",
6316
+ "-n",
6317
+ "-o",
6318
+ "-E",
6319
+ "@[A-Za-z0-9_-]+/[A-Za-z0-9_./-]+|from '(\\.\\./)+[^']+'",
6320
+ "--",
6321
+ "*.ts",
6322
+ "*.tsx"
6323
+ ],
6324
+ { cwd: workspaceRoot, encoding: "utf8", maxBuffer: 1024 * 1024 * 1024 }
6096
6325
  );
6097
6326
  if (result.error || result.status !== 0 && result.status !== 1) {
6098
6327
  return { imported, testOnly, builtFrom: "git grep failed" };
@@ -6106,8 +6335,16 @@ function buildConsumerIndex(workspaceRoot, moduleRoots) {
6106
6335
  if (first < 0 || second < 0) continue;
6107
6336
  const importer = line.slice(0, first).split(sep).join("/");
6108
6337
  const specifier = line.slice(second + 1);
6109
- if (!seen.has(specifier)) seen.set(specifier, resolveSpecifier2(specifier, table, workspaceRoot));
6110
- const target = seen.get(specifier);
6338
+ let target;
6339
+ const relative5 = /^from '((?:\.\.\/)+[^']+)'$/.exec(specifier)?.[1];
6340
+ if (relative5) {
6341
+ target = resolveRelative(importer, relative5, workspaceRoot);
6342
+ } else {
6343
+ if (!seen.has(specifier)) {
6344
+ seen.set(specifier, resolveSpecifier2(specifier, table, workspaceRoot));
6345
+ }
6346
+ target = seen.get(specifier);
6347
+ }
6111
6348
  if (!target) continue;
6112
6349
  if (moduleOf(importer, moduleRoots) === moduleOf(target, moduleRoots)) continue;
6113
6350
  imported.add(target);
@@ -6478,7 +6715,7 @@ function measuredWith(config) {
6478
6715
  // never appear as `(module)`: a gauge whose scale moves per module compares nothing.
6479
6716
  row(
6480
6717
  "size indicators",
6481
- `${Object.entries(PUBLISHED_THRESHOLDS).map(([rule, threshold]) => `${rule} ${threshold}`).join(", ")} (published defaults, oxlint run outside the module)`
6718
+ `${Object.entries(GAUGE_THRESHOLDS).map(([rule, threshold]) => `${rule} ${threshold}`).join(", ")} (min(research, our p90, the rule's default), oxlint run outside the module)`
6482
6719
  ),
6483
6720
  // ⚠️ The graph pass's one knob, and it has to be here: it changes both the community count and
6484
6721
  // `Q`, so the two figures are meaningless without it. The seed is NOT printed beside it,
@@ -6773,10 +7010,13 @@ function assembleReport(ctx, config, product, tests, dead, complexity, graph, co
6773
7010
  } else if (!unreadable) {
6774
7011
  notes.push("fewer than two files, so no graph figure is reported");
6775
7012
  }
7013
+ if (size && size.files > 0) {
7014
+ rows.push({ metric: "measuredFiles", value: size.files, unit: "files the gauge read" });
7015
+ }
6776
7016
  if (size) {
6777
7017
  const counts = /* @__PURE__ */ new Map();
6778
7018
  for (const breach of size.breaches) counts.set(breach.rule, (counts.get(breach.rule) ?? 0) + 1);
6779
- for (const [rule, threshold] of Object.entries(PUBLISHED_THRESHOLDS)) {
7019
+ for (const [rule, threshold] of Object.entries(GAUGE_THRESHOLDS)) {
6780
7020
  const count = counts.get(rule) ?? 0;
6781
7021
  if (count === 0) continue;
6782
7022
  rows.push({
@@ -6824,11 +7064,12 @@ async function emit(report, shape, cwd, graph, coverage) {
6824
7064
  mkdirSync2(join22(cwd, REPORT_DIR), { recursive: true });
6825
7065
  const written = [];
6826
7066
  if (shape === "html" && graph.files > 1 && !hasUnreadableCode(coverage)) {
7067
+ const drawn = layOut(graph.graph, graph.communityOf);
6827
7068
  const picture = `${TARGET}-graph.svg`;
6828
7069
  const path2 = join22(cwd, REPORT_DIR, picture);
6829
- await writeGraphPicture(graph.graph, graph.communityOf, path2);
7070
+ await writeGraphPicture(drawn, path2);
6830
7071
  written.push(`${REPORT_DIR}/${picture}`);
6831
- report.picture = inlineable(readFileSync17(path2, "utf8"));
7072
+ report.picture = buildExplorer(drawn, graph.communityOf).markup;
6832
7073
  }
6833
7074
  const name = shape === "json" ? "static-analysis.json" : "static-analysis.html";
6834
7075
  const body = shape === "json" ? renderJson(report) : renderHtml(report);
@@ -6838,9 +7079,6 @@ async function emit(report, shape, cwd, graph, coverage) {
6838
7079
  written: ${written.join(", ")}
6839
7080
  `);
6840
7081
  }
6841
- function inlineable(svg) {
6842
- return svg.replace(/^<\?xml[^>]*\?>\s*/, "").replace(/^(<svg)\s+width="[^"]*"\s+height="[^"]*"/, "$1");
6843
- }
6844
7082
  function metricsOf(report) {
6845
7083
  return {
6846
7084
  passes: report.passes,
package/dist/index.js CHANGED
@@ -6,11 +6,11 @@ import {
6
6
  registerAdapters,
7
7
  resolve,
8
8
  setDefaultRunner
9
- } from "./chunk-AFM4AJHA.js";
9
+ } from "./chunk-TL2P2X45.js";
10
10
  import {
11
11
  BaseAdapter
12
- } from "./chunk-KLEXJFJ6.js";
13
- import "./chunk-YQRDKWSH.js";
12
+ } from "./chunk-IFK6KGUW.js";
13
+ import "./chunk-JEYGPPSL.js";
14
14
  import "./chunk-AYEOGJQR.js";
15
15
  import "./chunk-O7REVMOC.js";
16
16
  import "./chunk-QXFCZON7.js";
@@ -3,13 +3,13 @@ import {
3
3
  } from "../../../chunk-NUOQXAYR.js";
4
4
  import {
5
5
  installWorkspaceSetup
6
- } from "../../../chunk-AMCGMLPS.js";
6
+ } from "../../../chunk-SPES5MQT.js";
7
7
  import "../../../chunk-ELZHIN6E.js";
8
8
  import {
9
9
  clearDeepMocks,
10
10
  resetDeepMocks
11
11
  } from "../../../chunk-2XLX6PFR.js";
12
- import "../../../chunk-YQRDKWSH.js";
12
+ import "../../../chunk-JEYGPPSL.js";
13
13
 
14
14
  // src/roles/test/setup/jest-parity.ts
15
15
  import { expect, vi } from "vitest";
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  pinWorkspaceTimezone
3
- } from "../../../chunk-AMCGMLPS.js";
3
+ } from "../../../chunk-SPES5MQT.js";
4
4
  import "../../../chunk-ELZHIN6E.js";
5
- import "../../../chunk-YQRDKWSH.js";
5
+ import "../../../chunk-JEYGPPSL.js";
6
6
 
7
7
  // src/roles/test/setup/workspace-entry.ts
8
8
  await pinWorkspaceTimezone();
@@ -7,8 +7,8 @@ import {
7
7
  readTestAdoption,
8
8
  resolveVitest,
9
9
  suiteOf
10
- } from "./chunk-KLEXJFJ6.js";
11
- import "./chunk-YQRDKWSH.js";
10
+ } from "./chunk-IFK6KGUW.js";
11
+ import "./chunk-JEYGPPSL.js";
12
12
  import "./chunk-O7REVMOC.js";
13
13
  import "./chunk-QXFCZON7.js";
14
14