@hublo/sentinel 1.5.0-alpha.15 → 1.5.0-alpha.17

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.
@@ -15,7 +15,7 @@ import {
15
15
  palette,
16
16
  registerAdapters,
17
17
  resolve
18
- } from "../chunk-UBNRRSGV.js";
18
+ } from "../chunk-5GJOBC4M.js";
19
19
  import {
20
20
  resolveContext
21
21
  } from "../chunk-KLEXJFJ6.js";
@@ -26,7 +26,7 @@ import {
26
26
  readOwnVersion,
27
27
  readProjectPackageJson
28
28
  } from "../chunk-YQRDKWSH.js";
29
- import "../chunk-UUPP5UQS.js";
29
+ import "../chunk-XSZKLDE5.js";
30
30
  import "../chunk-O7REVMOC.js";
31
31
  import "../chunk-QXFCZON7.js";
32
32
 
@@ -71,7 +71,7 @@ import {
71
71
  extensionsOf,
72
72
  isPlainObject,
73
73
  overridableSettings
74
- } from "./chunk-UUPP5UQS.js";
74
+ } from "./chunk-XSZKLDE5.js";
75
75
  import {
76
76
  parseJsonc
77
77
  } from "./chunk-QXFCZON7.js";
@@ -4758,8 +4758,8 @@ function registerLint() {
4758
4758
 
4759
4759
  // src/roles/static-analysis/adapters/jscpd/jscpd.adapter.ts
4760
4760
  import { spawnSync as spawnSync5 } from "node:child_process";
4761
- import { existsSync as existsSync14, mkdirSync, readFileSync as readFileSync15, writeFileSync as writeFileSync4 } from "node:fs";
4762
- import { join as join19 } from "node:path";
4761
+ import { existsSync as existsSync14, mkdirSync, readFileSync as readFileSync16, writeFileSync as writeFileSync4 } from "node:fs";
4762
+ import { join as join20 } from "node:path";
4763
4763
 
4764
4764
  // src/roles/static-analysis/complexity/scan.ts
4765
4765
  import { readFileSync as readFileSync14 } from "node:fs";
@@ -5049,6 +5049,179 @@ function scanComplexity(cwd, config) {
5049
5049
  };
5050
5050
  }
5051
5051
 
5052
+ // src/roles/static-analysis/graph/picture.ts
5053
+ import forceAtlas2 from "graphology-layout-forceatlas2";
5054
+ import circular from "graphology-layout/circular.js";
5055
+ import renderSvg from "graphology-svg";
5056
+ var ITERATIONS = 300;
5057
+ var LABELLED = 10;
5058
+ var BLANK = " ";
5059
+ var SIZE = { width: 1600, height: 1200, margin: 30 };
5060
+ function hueOf(community) {
5061
+ return `hsl(${community * 137 % 360} 65% 52%)`;
5062
+ }
5063
+ function writeGraphPicture(graph, communities, path) {
5064
+ const drawn = graph.copy();
5065
+ circular.assign(drawn);
5066
+ forceAtlas2.assign(drawn, { iterations: ITERATIONS, settings: forceAtlas2.inferSettings(drawn) });
5067
+ const named = new Set(
5068
+ drawn.nodes().sort((left, right) => drawn.degree(right) - drawn.degree(left) || left.localeCompare(right)).slice(0, LABELLED)
5069
+ );
5070
+ drawn.forEachNode((node) => {
5071
+ drawn.setNodeAttribute(node, "size", 2 + Math.sqrt(drawn.degree(node)));
5072
+ drawn.setNodeAttribute(node, "color", hueOf(communities[node] ?? 0));
5073
+ drawn.setNodeAttribute(node, "label", named.has(node) ? node.split("/").pop() ?? node : BLANK);
5074
+ });
5075
+ return new Promise((resolve4) => {
5076
+ renderSvg(drawn, path, SIZE, () => resolve4());
5077
+ });
5078
+ }
5079
+
5080
+ // src/roles/static-analysis/graph/scan.ts
5081
+ import Graph from "graphology";
5082
+ import louvain from "graphology-communities-louvain";
5083
+ import { stronglyConnectedComponents } from "graphology-components";
5084
+ import { degreeCentrality } from "graphology-metrics/centrality/degree.js";
5085
+ import { density } from "graphology-metrics/graph/density.js";
5086
+
5087
+ // src/roles/static-analysis/graph/imports.ts
5088
+ import { readFileSync as readFileSync15 } from "node:fs";
5089
+ import { dirname, join as join19 } from "node:path";
5090
+ import { parseSync as parseSync3 } from "oxc-parser";
5091
+ import { globSync as globSync2 } from "tinyglobby";
5092
+ var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
5093
+ var TS_BEHIND_JS = {
5094
+ ".js": [".ts", ".tsx"],
5095
+ ".mjs": [".mts"],
5096
+ ".cjs": [".cts"]
5097
+ };
5098
+ function specifiersOf(program) {
5099
+ const found = [];
5100
+ const walk3 = (node) => {
5101
+ if (!node || typeof node !== "object") return;
5102
+ if (Array.isArray(node)) {
5103
+ for (const child of node) walk3(child);
5104
+ return;
5105
+ }
5106
+ const current = node;
5107
+ const source = current.source?.value;
5108
+ switch (current.type) {
5109
+ // `import x from './a'`, and the two re-export forms, which are imports that also publish.
5110
+ case "ImportDeclaration":
5111
+ case "ExportNamedDeclaration":
5112
+ case "ExportAllDeclaration":
5113
+ if (typeof source === "string") found.push(source);
5114
+ break;
5115
+ // `import('./a')`, the lazy route and the split bundle.
5116
+ case "ImportExpression":
5117
+ if (typeof source === "string") found.push(source);
5118
+ break;
5119
+ case "CallExpression": {
5120
+ const argument = current.arguments?.[0]?.value;
5121
+ if (current.callee?.name === "require" && typeof argument === "string") found.push(argument);
5122
+ break;
5123
+ }
5124
+ default:
5125
+ break;
5126
+ }
5127
+ for (const [field, value] of Object.entries(current)) {
5128
+ if (field === "type" || field === "start" || field === "end") continue;
5129
+ walk3(value);
5130
+ }
5131
+ };
5132
+ walk3(program);
5133
+ return found;
5134
+ }
5135
+ function resolveSpecifier(from, specifier, known) {
5136
+ if (!specifier.startsWith(".")) return void 0;
5137
+ const base = join19(dirname(from), specifier);
5138
+ if (known.has(base)) return base;
5139
+ for (const extension of EXTENSIONS) {
5140
+ if (known.has(base + extension)) return base + extension;
5141
+ }
5142
+ const written = EXTENSIONS.find((extension) => base.endsWith(extension));
5143
+ for (const extension of TS_BEHIND_JS[written ?? ""] ?? []) {
5144
+ const swapped = base.slice(0, -(written ?? "").length) + extension;
5145
+ if (known.has(swapped)) return swapped;
5146
+ }
5147
+ for (const extension of EXTENSIONS) {
5148
+ const index = join19(base, `index${extension}`);
5149
+ if (known.has(index)) return index;
5150
+ }
5151
+ return void 0;
5152
+ }
5153
+ function buildImportGraph(cwd, config) {
5154
+ const extensions = extensionsOf(config.languages).map((extension) => extension.slice(1)).join(",");
5155
+ const files = globSync2([`**/*.{${extensions}}`], {
5156
+ cwd,
5157
+ ignore: exclusions(config, "product"),
5158
+ dot: false
5159
+ });
5160
+ const known = new Set(files);
5161
+ const tree = {};
5162
+ for (const file of files) {
5163
+ tree[file] = [];
5164
+ let parsed;
5165
+ try {
5166
+ parsed = parseSync3(file, readFileSync15(join19(cwd, file), "utf8"));
5167
+ } catch {
5168
+ continue;
5169
+ }
5170
+ if (parsed.errors.length > 0) continue;
5171
+ for (const specifier of specifiersOf(parsed.program)) {
5172
+ const target = resolveSpecifier(file, specifier, known);
5173
+ if (target && target !== file && !tree[file].includes(target)) tree[file].push(target);
5174
+ }
5175
+ }
5176
+ return tree;
5177
+ }
5178
+
5179
+ // src/roles/static-analysis/graph/scan.ts
5180
+ var MOST_IMPORTED_LISTED = 10;
5181
+ function seeded() {
5182
+ let state = 42;
5183
+ return () => {
5184
+ state = (state * 1664525 + 1013904223) % 4294967296;
5185
+ return state / 4294967296;
5186
+ };
5187
+ }
5188
+ function toGraph(tree) {
5189
+ const graph = new Graph({ type: "directed", allowSelfLoops: false });
5190
+ for (const file of Object.keys(tree)) if (!graph.hasNode(file)) graph.addNode(file);
5191
+ for (const [from, targets2] of Object.entries(tree)) {
5192
+ for (const to of targets2) {
5193
+ if (!graph.hasNode(to)) graph.addNode(to);
5194
+ if (from !== to && !graph.hasDirectedEdge(from, to)) graph.addDirectedEdge(from, to);
5195
+ }
5196
+ }
5197
+ return graph;
5198
+ }
5199
+ function scanGraph(cwd, config) {
5200
+ const graph = toGraph(buildImportGraph(cwd, config));
5201
+ const cycles = stronglyConnectedComponents(graph).filter((component) => component.length > 1).map((files) => ({ files })).sort((left, right) => right.files.length - left.files.length);
5202
+ const partition = graph.size > 0 ? louvain.detailed(graph, { resolution: config.graph.resolution, rng: seeded() }) : { count: graph.order, modularity: void 0, communities: {} };
5203
+ const centrality = degreeCentrality(graph);
5204
+ const mostImported = graph.nodes().map((path) => ({
5205
+ path,
5206
+ importedBy: graph.inDegree(path),
5207
+ centrality: centrality[path] ?? 0
5208
+ })).filter((file) => file.importedBy > 0).sort(
5209
+ (left, right) => right.importedBy - left.importedBy || left.path.localeCompare(right.path)
5210
+ ).slice(0, MOST_IMPORTED_LISTED);
5211
+ return {
5212
+ files: graph.order,
5213
+ edges: graph.size,
5214
+ density: graph.order > 1 ? density(graph) : 0,
5215
+ cycles,
5216
+ filesInCycles: cycles.reduce((total, cycle) => total + cycle.files.length, 0),
5217
+ communities: partition.count,
5218
+ modularity: partition.modularity,
5219
+ mostImported,
5220
+ graph,
5221
+ communityOf: partition.communities
5222
+ };
5223
+ }
5224
+
5052
5225
  // src/roles/static-analysis/help/metrics.ts
5053
5226
  var metricsHelp = {
5054
5227
  duplicatedLinesPercent: {
@@ -5148,6 +5321,48 @@ var metricsHelp = {
5148
5321
  read: "A count, so it grows with the size of the module. Read it next to the duplicated-line share rather than on its own, and treat it as the number of places to go and look at.",
5149
5322
  caveat: "Not a count of duplicated places, and the gap is measured, not theoretical: five identical files produce four entries, all of them naming one file in common, so n copies give n-1 entries, neither n nor the n(n-1)/2 pairs. Worse for reading occurrences, jscpd keeps only the longest match per pair of files, so when a third file shares just part of a block, that block is reported under two different fragments and its real number of occurrences cannot be recovered from the report. Size is invisible too: one clone of sixty lines and one of six both count as one.",
5150
5323
  source: "jscpd report statistics. Counting behaviour measured on fixtures with jscpd 5.4.0; the shared file is not the same one across major versions, so only the count is relied on."
5324
+ },
5325
+ cycles: {
5326
+ label: "Import cycles",
5327
+ what: "Groups of files that import each other in a circle, directly or through other files.",
5328
+ read: "Expect zero, and read any value as a count of places to go and break one edge. The figure beside it is how many FILES are caught, which is the size of the problem: one group of forty is a different afternoon from twenty groups of two.",
5329
+ caveat: "A cycle is reported, not judged, and some are harmless in practice: a type-only import is erased at build time and two files that merely reference each other's types will never deadlock. What the measurement cannot tell you is which kind yours is, because it does not read the `type` keyword. It also reports GROUPS and not paths, so a group of six files may contain a dozen distinct circles; breaking one of them does not necessarily break the group, which is exactly why the group is the unit.",
5330
+ source: "Tarjan's strongly connected components, via graphology-components. Held against madge's own `circular()` on five modules of the three families: the set of files caught was identical every time (2, 110, 11, 13 and 2), and only the count differed, because madge enumerates paths where this groups them."
5331
+ },
5332
+ importEdges: {
5333
+ label: "Imports between files",
5334
+ what: "How many times one of the module's own files imports another of them.",
5335
+ read: "Read it against the file count beside it rather than on its own: roughly two to three edges per file is what the modules measured here sit at. It is the size of the graph every other figure on this block is computed over.",
5336
+ caveat: "Module-local on purpose, so it says nothing about what the module pulls from the rest of the workspace: a front app importing three hundred components from `libs/` shows none of that here. Tests are outside the graph too, so a file every spec imports does not look busy because of them, and so is generated code: measured on `apps/front/console`, excluding `routeTree.gen.ts` removes 20 edges and the module's only cycle, which is TanStack's own and which nobody can fix by hand.",
5337
+ source: "Built from the oxc parse of every file the role scans, resolving relative specifiers only. The resolution was held against madge, which follows the workspace path aliases, over five modules with the same exclusions on both sides: 16 052 edges, no code edge missed and none invented, the 28 differences all imports of files outside the scanned languages."
5338
+ },
5339
+ graphDensity: {
5340
+ label: "Density",
5341
+ what: "The share of all the edges that could exist between these files that actually do.",
5342
+ read: "Between 0 and 1, and near 0 in real code. It is a shape, not a score: it tells you whether the module is a web or a tree.",
5343
+ caveat: "\u26D4 Do NOT compare it between modules of different sizes, and the reason is arithmetic rather than opinion: the denominator grows with the SQUARE of the file count, so a module gets a lower density by growing, not by improving. Measured here: `libs/nest/auth` 0.0216 at 101 files and `apps/front/host-admin` 0.0005 at 4 380, which says almost nothing about either. What it is good for is one module watched over time at a stable size.",
5344
+ source: "graphology-metrics, graph density, the standard definition for a directed graph."
5345
+ },
5346
+ communities: {
5347
+ label: "Communities",
5348
+ what: "How many clusters of files the partition found, where a cluster is a set of files much more connected to each other than to the rest.",
5349
+ read: "Read it against the module's own directories, which is the grouping the team wrote by hand. Expect it to be HIGHER: the partition refines the folders rather than reproducing them, measured at 24 communities for 16 domains on a nest service and 208 for 20 modules on the largest front app. Held against the directories over five modules, each community sits inside one directory 58% to 85% of the time, and each directory splits across several communities, so the agreement is a refinement and not a match.",
5350
+ caveat: "The count depends on a setting, `graph.resolution`, which the report prints: changing it changes the answer, and graphology warns in its own documentation that it is finicky to tweak. The role ships the published value of 1 rather than one chosen to make the partition look right. The algorithm is also greedy, so this is one good partition, not the best one. \u26A0\uFE0F And a community holding two directories is NOT a statement that they depend on each other: a file both of them import pulls them together on its own. Measured on `apps/nest/microservices/recruitment`, one community holds 79 files of `candidate-management` and 64 of `application-management` with 4 edges between the two halves, because 12 shared enums sit between them, one of which is imported 58 times.",
5351
+ source: "Louvain, via graphology-communities-louvain, with the role's own random seed so the figure reproduces. Blondel, Guillaume, Lambiotte and Lefebvre, 2008."
5352
+ },
5353
+ modularity: {
5354
+ label: "Modularity (Q)",
5355
+ what: "How much more connected the communities are internally than they would be if the same files were wired at random.",
5356
+ read: "Between -1 and 1, higher meaning the clusters are real. The five modules measured here sit between 0.58 and 0.84.",
5357
+ caveat: "\u26A0\uFE0F Do not read the fourth decimal. Louvain is greedy, so Q is the value of ONE local optimum: measured over 20 runs with the library's own randomness, `console` produced 16 different values and `recruitment` spanned 0.0357. The role pins its seed, which makes the figure reproducible but not exact, so a change smaller than a hundredth between two runs of two different versions means nothing. A high Q also does not mean the clusters are the right ones: it says they are separable, not that they match any boundary a team cares about.",
5358
+ source: "Newman-Girvan modularity, via graphology-metrics, which states in its own source that it stays true to Newman's original definition. Q is the measure; Louvain is the algorithm that maximises it."
5359
+ },
5360
+ mostImported: {
5361
+ label: "Most imported files",
5362
+ what: "The files the rest of the module leans on, by how many of its own files import them.",
5363
+ read: "Context, not a defect list: every module has a most-imported file and a type definition used everywhere is doing its job. It is worth a look when the file at the top is one that CHANGES often, because then its import count is the number of files a change there can reach.",
5364
+ caveat: "\u26A0\uFE0F If you compare this with another tool, note the unit. The count here is raw, how many files import this one. graphology's degree centrality, which `code-health-meter` reports, is NORMALISED by the file count, and the two can disagree in direction: `libs/nest/auth`'s busiest file is imported 18 times and scores 0.18, `apps/front/host-admin`'s is imported 122 times and scores 0.028. There is no threshold here and the role proposes none.",
5365
+ source: "In-degree of the module import graph, with graphology degree centrality carried beside it for anyone holding this against another tool."
5151
5366
  }
5152
5367
  };
5153
5368
  function helpFor(key) {
@@ -5167,11 +5382,23 @@ var HTML_GROUP_LIMIT = 50;
5167
5382
  function figure(row) {
5168
5383
  return row.unit === "%" && typeof row.value === "number" ? row.value.toFixed(2) : String(row.value);
5169
5384
  }
5385
+ function caveatsOf(report) {
5386
+ const keys = [...report.rows.map((row) => row.metric), ...report.findings.map((f) => f.metric)];
5387
+ const seen = /* @__PURE__ */ new Set();
5388
+ const found = [];
5389
+ for (const key of keys) {
5390
+ if (seen.has(key)) continue;
5391
+ seen.add(key);
5392
+ const help = helpFor(key);
5393
+ if (help) found.push(help);
5394
+ }
5395
+ return found;
5396
+ }
5170
5397
  function renderConsole(report) {
5171
5398
  const lines = [];
5172
5399
  const width = (pick) => Math.max(...report.rows.map((row) => pick(row).length));
5173
5400
  const label = (row) => helpFor(row.metric)?.label ?? row.metric;
5174
- const value = (row) => `${figure(row)} ${row.unit}`;
5401
+ const value = (row) => `${figure(row)} ${row.unit}`.trim();
5175
5402
  const labelWidth = width(label);
5176
5403
  const valueWidth = width(value);
5177
5404
  lines.push(`sentinel static-analysis: ${report.module}`);
@@ -5206,21 +5433,21 @@ function renderConsole(report) {
5206
5433
  ` ${report.findings.length} place(s) to look, listed by \`${SCRIPTS.json}\` and \`${SCRIPTS.html}\``
5207
5434
  );
5208
5435
  }
5209
- const caveats = report.rows.map((row) => ({ row, help: helpFor(row.metric) })).filter(
5210
- (entry) => Boolean(entry.help)
5211
- );
5436
+ const caveats = caveatsOf(report);
5212
5437
  if (caveats.length > 0) {
5213
5438
  lines.push("", " what these numbers do not say");
5214
- for (const { row, help } of caveats) {
5439
+ for (const help of caveats) {
5215
5440
  lines.push(` ${help.label}: ${help.caveat}`, ` source: ${help.source}`);
5216
- void row;
5217
5441
  }
5218
5442
  }
5219
5443
  return `${lines.join("\n")}
5220
5444
  `;
5221
5445
  }
5222
5446
  function renderJson(report) {
5223
- const rows = report.rows.map((row) => ({ ...row, help: helpFor(row.metric) ?? null }));
5447
+ const rows = report.rows.map((row) => {
5448
+ const help = helpFor(row.metric);
5449
+ return { ...row, help: help ? { label: help.label, caveat: help.caveat } : null };
5450
+ });
5224
5451
  return `${JSON.stringify({ ...report, rows }, null, 2)}
5225
5452
  `;
5226
5453
  }
@@ -5238,12 +5465,12 @@ function renderHtml(report) {
5238
5465
  <td class="num">${escape(figure(row))} <span class="unit">${escape(row.unit)}</span></td>
5239
5466
  </tr>`;
5240
5467
  }).join("\n");
5241
- const reserves = report.rows.map((row) => ({ row, help: helpFor(row.metric) })).filter((entry) => entry.help).map(
5242
- ({ row, help }) => ` <details>
5243
- <summary>${escape(help?.label ?? row.metric)}<span class="what">${prose(help?.what ?? "")}</span></summary>
5244
- <p class="read"><b>How to read it.</b> ${prose(help?.read ?? "")}</p>
5245
- <p><b>What it does not say.</b> ${prose(help?.caveat ?? "")}</p>
5246
- <p class="source">Source: ${prose(help?.source ?? "")}</p>
5468
+ const reserves = caveatsOf(report).map(
5469
+ (help) => ` <details>
5470
+ <summary>${escape(help.label)}<span class="what">${prose(help.what)}</span></summary>
5471
+ <p class="read"><b>How to read it.</b> ${prose(help.read)}</p>
5472
+ <p><b>What it does not say.</b> ${prose(help.caveat)}</p>
5473
+ <p class="source">Source: ${prose(help.source)}</p>
5247
5474
  </details>`
5248
5475
  ).join("\n");
5249
5476
  const settings = report.settings.map(
@@ -5301,11 +5528,21 @@ function renderHtml(report) {
5301
5528
  const rest = sorted.length - listed.length;
5302
5529
  const columns = columnsFor(metric, all2);
5303
5530
  const head = columns.map((column) => `<th scope="col">${column.header}</th>`).join("");
5304
- const rows2 = listed.map(
5305
- (finding) => ` <tr>
5306
- ${columns.map((column) => ` <td>${column.cell(finding)}</td>`).join("\n")}
5307
- </tr>`
5308
- ).join("\n");
5531
+ const rows2 = listed.map((finding) => {
5532
+ const cells = columns.map((column) => ` <td>${column.cell(finding)}</td>`).join("\n");
5533
+ const code = finding.excerpt ? `
5534
+ <tr class="excerpt">
5535
+ <td colspan="${columns.length}">
5536
+ <details>
5537
+ <summary>${finding.lines ?? ""} duplicated lines</summary>
5538
+ <pre><code>${escape(finding.excerpt)}</code></pre>
5539
+ </details>
5540
+ </td>
5541
+ </tr>` : "";
5542
+ return ` <tr>
5543
+ ${cells}
5544
+ </tr>${code}`;
5545
+ }).join("\n");
5309
5546
  return `
5310
5547
  <h2>${escape(label)} &middot; where to look</h2>
5311
5548
  <section>
@@ -5408,6 +5645,10 @@ ${rest > 0 ? ` <p class="sub">The ${HTML_GROUP_LIMIT} longest are listed; ${r
5408
5645
  color: var(--moved); font-size: 0.78rem; font-weight: 500;
5409
5646
  white-space: nowrap;
5410
5647
  }
5648
+ /* The graph. The svg carries a viewBox, so dropping its fixed width lets it fill the column. */
5649
+ .picture svg { width: 100%; height: auto; display: block; }
5650
+ .picture p.read { margin: 0 0 0.8rem; padding: 0 0.6rem; color: var(--ink-soft); font-size: 0.88rem; max-width: 52rem; }
5651
+
5411
5652
  /* The collapsed reserves. Closed by default: the figures are what the page opens on. */
5412
5653
  details { border-bottom: 1px solid var(--rule); }
5413
5654
  details:last-of-type { border-bottom: 0; }
@@ -5441,6 +5682,17 @@ ${rest > 0 ? ` <p class="sub">The ${HTML_GROUP_LIMIT} longest are listed; ${r
5441
5682
  details p b { color: var(--ink); font-weight: 600; }
5442
5683
  details p { margin: 0 0 0.7rem; padding: 0 0.6rem; color: var(--ink-soft); font-size: 0.88rem; max-width: 52rem; }
5443
5684
  details p.source { color: var(--label); font-size: 0.8rem; }
5685
+ /* The duplicated code, under the row that located it. */
5686
+ tr.excerpt td { padding: 0 0.6rem 0.4rem; border-bottom: 1px solid var(--rule); }
5687
+ tr.excerpt details { border: 0; }
5688
+ tr.excerpt summary { padding: 0.35rem 0; font-size: 0.8rem; color: var(--label); font-weight: 400; }
5689
+ tr.excerpt summary:hover { color: var(--brand); }
5690
+ tr.excerpt pre {
5691
+ margin: 0 0 0.5rem; padding: 0.7rem 0.9rem; background: var(--page);
5692
+ border: 1px solid var(--rule); border-radius: 6px; overflow-x: auto;
5693
+ font: 0.8rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre;
5694
+ }
5695
+ tr.excerpt pre code { color: var(--ink); font-size: 1em; }
5444
5696
  footer { color: var(--label); font-size: 0.78rem; margin-top: 2.5rem; }
5445
5697
  @media (max-width: 40rem) {
5446
5698
  section { padding: 0.25rem 0.6rem; }
@@ -5473,6 +5725,13 @@ ${report.notes.length > 0 ? `
5473
5725
  ${report.notes.map((note) => ` <tr><td class="read">${escape(note)}</td></tr>`).join("\n")}
5474
5726
  </tbody></table>
5475
5727
  </section>
5728
+ ` : ""}
5729
+ ${report.picture ? `
5730
+ <h2>Shape</h2>
5731
+ <section class="picture">
5732
+ <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>
5733
+ ${report.picture}
5734
+ </section>
5476
5735
  ` : ""}
5477
5736
  <h2>What these numbers do not say</h2>
5478
5737
  <section>
@@ -5504,7 +5763,7 @@ function resolveJscpd(cwd) {
5504
5763
  }
5505
5764
 
5506
5765
  // src/roles/static-analysis/adapters/jscpd/jscpd.adapter.ts
5507
- var EXTENSIONS = extensionsOf(defaults.languages);
5766
+ var EXTENSIONS2 = extensionsOf(defaults.languages);
5508
5767
  var scripts = () => ({
5509
5768
  [SCRIPTS.console]: `sentinel --run --${TARGET}`,
5510
5769
  [SCRIPTS.json]: `sentinel --run --${TARGET} --output json`,
@@ -5567,17 +5826,17 @@ var JscpdAdapter = class extends BaseAdapter {
5567
5826
  return true;
5568
5827
  }
5569
5828
  plan(context) {
5570
- if (!hasSourceFiles(context.cwd, EXTENSIONS)) {
5829
+ if (!hasSourceFiles(context.cwd, EXTENSIONS2)) {
5571
5830
  return {
5572
5831
  operations: [],
5573
- skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS.join(", ")}).`
5832
+ skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS2.join(", ")}).`
5574
5833
  };
5575
5834
  }
5576
5835
  return {
5577
5836
  operations: [
5578
5837
  // `write` and not `merge-json`: this file is code, and the engine's in-place merge is for
5579
5838
  // JSON. A re-init therefore RESTORES the stub, so an existing one is left alone below.
5580
- ...existsSync14(join19(context.cwd, STATIC_CONFIG_FILE)) ? [] : [{ kind: "write", path: STATIC_CONFIG_FILE, contents: configStub() }],
5839
+ ...existsSync14(join20(context.cwd, STATIC_CONFIG_FILE)) ? [] : [{ kind: "write", path: STATIC_CONFIG_FILE, contents: configStub() }],
5581
5840
  manifestOperation(context.cwd, scripts()),
5582
5841
  ...nxTargetOperations({ cwd: context.cwd, targets: targets() })
5583
5842
  // The report directory is ignored ONCE at the workspace root, by `--init`'s workspace prep,
@@ -5606,9 +5865,9 @@ var JscpdAdapter = class extends BaseAdapter {
5606
5865
  );
5607
5866
  return { ok: false, code: 1 };
5608
5867
  }
5609
- if (!hasSourceFiles(ctx.cwd, EXTENSIONS)) {
5868
+ if (!hasSourceFiles(ctx.cwd, EXTENSIONS2)) {
5610
5869
  process.stderr.write(
5611
- `sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS.join(", ")}).
5870
+ `sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS2.join(", ")}).
5612
5871
  `
5613
5872
  );
5614
5873
  return { ok: true, code: 0 };
@@ -5657,8 +5916,9 @@ var JscpdAdapter = class extends BaseAdapter {
5657
5916
  );
5658
5917
  }
5659
5918
  const complexity = scanComplexity(ctx.cwd, config);
5660
- const report = assembleReport(ctx, config, product, tests, dead, complexity);
5661
- if (emitting) emit(report, shape, ctx.cwd);
5919
+ const graph = scanGraph(ctx.cwd, config);
5920
+ const report = assembleReport(ctx, config, product, tests, dead, complexity, graph);
5921
+ if (emitting) await emit(report, shape, ctx.cwd, graph);
5662
5922
  return { ok: true, code: 0, metrics: metricsOf(report) };
5663
5923
  }
5664
5924
  /** `--run`: measure and show it. */
@@ -5729,7 +5989,7 @@ var JscpdAdapter = class extends BaseAdapter {
5729
5989
  const declared = moduleScripts(ctx.cwd);
5730
5990
  const missing = Object.keys(scripts()).filter((name) => !(name in declared));
5731
5991
  const adopted = missing.length < Object.keys(scripts()).length;
5732
- const hasConfig = existsSync14(join19(ctx.cwd, STATIC_CONFIG_FILE));
5992
+ const hasConfig = existsSync14(join20(ctx.cwd, STATIC_CONFIG_FILE));
5733
5993
  return {
5734
5994
  adopted,
5735
5995
  preset: adopted ? ctx.preset : null,
@@ -5762,6 +6022,10 @@ function measuredWith(config) {
5762
6022
  row("deadCode.minConfidence", String(config.deadCode.minConfidence)),
5763
6023
  row("deadCode.categories", config.deadCode.categories.join(", ")),
5764
6024
  row("entry", `${config.entry.length} patterns`),
6025
+ // ⚠️ The graph pass's one knob, and it has to be here: it changes both the community count and
6026
+ // `Q`, so the two figures are meaningless without it. The seed is NOT printed beside it,
6027
+ // because it is not a setting: see `graph/scan.ts` for why the role owns it.
6028
+ row("graph.resolution", String(config.graph.resolution)),
5765
6029
  // Not a setting a module can move: it is McCabe's own published figure, and the row says so
5766
6030
  // rather than letting a reader take it for one of ours.
5767
6031
  {
@@ -5797,7 +6061,7 @@ function runDeadCode(jscpd, config, paths, cwd) {
5797
6061
  try {
5798
6062
  const ran = runTool(jscpd, args, cwd, "dead-code");
5799
6063
  if (ran.kind !== "ran") return ran;
5800
- const path = join19(rawReportDir, RAW_REPORT["dead-code"]);
6064
+ const path = join20(rawReportDir, RAW_REPORT["dead-code"]);
5801
6065
  if (!existsSync14(path)) {
5802
6066
  process.stderr.write(
5803
6067
  `sentinel static-analysis(jscpd): the dead-code pass wrote no ${RAW_REPORT["dead-code"]}
@@ -5805,7 +6069,7 @@ function runDeadCode(jscpd, config, paths, cwd) {
5805
6069
  );
5806
6070
  return { kind: "failed", code: 1 };
5807
6071
  }
5808
- const parsed = safeParseDeadCode(readFileSync15(path, "utf8"));
6072
+ const parsed = safeParseDeadCode(readFileSync16(path, "utf8"));
5809
6073
  const statistics = parsed?.statistics;
5810
6074
  if (!statistics) {
5811
6075
  process.stderr.write(
@@ -5832,8 +6096,8 @@ function runDuplication(jscpd, config, paths, scope, cwd) {
5832
6096
  try {
5833
6097
  const ran = runTool(jscpd, args, cwd, scope);
5834
6098
  if (ran.kind !== "ran") return ran;
5835
- const path = join19(rawReportDir, RAW_REPORT.duplication);
5836
- const parsed = existsSync14(path) ? safeParse(readFileSync15(path, "utf8")) : void 0;
6099
+ const path = join20(rawReportDir, RAW_REPORT.duplication);
6100
+ const parsed = existsSync14(path) ? safeParse(readFileSync16(path, "utf8")) : void 0;
5837
6101
  const total = parsed?.statistics?.total;
5838
6102
  if (!total) {
5839
6103
  process.stderr.write(
@@ -5866,13 +6130,14 @@ function safeParseDeadCode(text) {
5866
6130
  return void 0;
5867
6131
  }
5868
6132
  }
5869
- function placesToLook(product, tests, dead, complexity) {
6133
+ function placesToLook(product, tests, dead, complexity, graph) {
5870
6134
  const at = (side) => `${side?.name ?? "(unknown)"}${side?.start === void 0 ? "" : `:${side.start}`}`;
5871
6135
  const clones = (outcome, metric) => outcome.kind === "figures" ? outcome.duplicates.map((entry) => ({
5872
6136
  metric,
5873
6137
  path: at(entry.firstFile),
5874
6138
  other: at(entry.secondFile),
5875
- ...entry.lines === void 0 ? {} : { lines: entry.lines }
6139
+ ...entry.lines === void 0 ? {} : { lines: entry.lines },
6140
+ ...entry.fragment ? { excerpt: entry.fragment } : {}
5876
6141
  })) : [];
5877
6142
  const deadMetric = {
5878
6143
  "unused-file": "unusedFiles",
@@ -5898,10 +6163,26 @@ function placesToLook(product, tests, dead, complexity) {
5898
6163
  ...finding.confidence === void 0 ? {} : { confidence: finding.confidence },
5899
6164
  ...finding.reasons?.length ? { reason: finding.reasons.join(", ") } : {},
5900
6165
  ...finding.message ? { message: finding.message } : {}
5901
- })) : []
6166
+ })) : [],
6167
+ // One finding per GROUP of mutually importing files, not per cycle path: see `graph/scan.ts`.
6168
+ // The first file names the finding and the rest go in `other`, which is the shape the report
6169
+ // already renders for a clone's two sides.
6170
+ ...graph.cycles.map((cycle) => ({
6171
+ metric: "cycles",
6172
+ path: cycle.files[0] ?? "(unknown)",
6173
+ other: cycle.files.slice(1).join(", "),
6174
+ value: cycle.files.length
6175
+ })),
6176
+ // ⛔ Context, not defects. Every module has a most-imported file, and nothing here says the
6177
+ // count is too high: the caveat on the metric says so in the reader's own words.
6178
+ ...graph.mostImported.map((file) => ({
6179
+ metric: "mostImported",
6180
+ path: file.path,
6181
+ value: file.importedBy
6182
+ }))
5902
6183
  ];
5903
6184
  }
5904
- function assembleReport(ctx, config, product, tests, dead, complexity) {
6185
+ function assembleReport(ctx, config, product, tests, dead, complexity, graph) {
5905
6186
  const share = (total) => Number((total.percentage ?? 0).toFixed(2));
5906
6187
  const rows = [
5907
6188
  { metric: "duplicatedLinesPercent", value: share(product.total), unit: "%" },
@@ -5954,6 +6235,32 @@ function assembleReport(ctx, config, product, tests, dead, complexity) {
5954
6235
  } else {
5955
6236
  notes.push("no function to measure, so no complexity figure is reported");
5956
6237
  }
6238
+ if (graph.files > 1) {
6239
+ rows.push(
6240
+ { metric: "importEdges", value: graph.edges, unit: `between ${graph.files} files` },
6241
+ {
6242
+ metric: "graphDensity",
6243
+ value: Number(graph.density.toFixed(4)),
6244
+ unit: "of the possible edges"
6245
+ },
6246
+ {
6247
+ metric: "cycles",
6248
+ // ⛔ Zero is the expected value here, so the unit has to read correctly at zero as well as
6249
+ // at one, which `1 groups, 2 files` did not. The file count only means something when
6250
+ // there is a group, so it is absent when there is none.
6251
+ value: graph.cycles.length,
6252
+ unit: graph.cycles.length === 0 ? "none" : `${graph.cycles.length === 1 ? "group" : "groups"}, ${graph.filesInCycles} files`
6253
+ },
6254
+ { metric: "communities", value: graph.communities, unit: `of ${graph.files} files` }
6255
+ );
6256
+ if (graph.modularity === void 0) {
6257
+ notes.push("no import between the files, so no modularity is reported");
6258
+ } else {
6259
+ rows.push({ metric: "modularity", value: Number(graph.modularity.toFixed(4)), unit: "" });
6260
+ }
6261
+ } else {
6262
+ notes.push("fewer than two files, so no graph figure is reported");
6263
+ }
5957
6264
  const byLanguage = Object.fromEntries(
5958
6265
  Object.entries(product.formats).map(([language, stats]) => [
5959
6266
  language,
@@ -5970,28 +6277,41 @@ function assembleReport(ctx, config, product, tests, dead, complexity) {
5970
6277
  "duplication",
5971
6278
  ...tests.kind === "figures" ? ["duplication (tests)"] : [],
5972
6279
  ...dead.kind === "figures" ? ["dead code"] : [],
5973
- ...complexity.functions > 0 ? ["complexity"] : []
6280
+ ...complexity.functions > 0 ? ["complexity"] : [],
6281
+ ...graph.files > 1 ? ["graph"] : []
5974
6282
  ],
5975
6283
  rows,
5976
6284
  notes,
5977
6285
  settings: measuredWith(config),
5978
- findings: placesToLook(product, tests, dead, complexity),
6286
+ findings: placesToLook(product, tests, dead, complexity, graph),
5979
6287
  byLanguage
5980
6288
  };
5981
6289
  }
5982
- function emit(report, shape, cwd) {
6290
+ async function emit(report, shape, cwd, graph) {
5983
6291
  if (shape === "console") {
5984
6292
  process.stdout.write(renderConsole(report));
5985
6293
  return;
5986
6294
  }
6295
+ mkdirSync(join20(cwd, REPORT_DIR), { recursive: true });
6296
+ const written = [];
6297
+ if (shape === "html" && graph.files > 1) {
6298
+ const picture = `${TARGET}-graph.svg`;
6299
+ const path = join20(cwd, REPORT_DIR, picture);
6300
+ await writeGraphPicture(graph.graph, graph.communityOf, path);
6301
+ written.push(`${REPORT_DIR}/${picture}`);
6302
+ report.picture = inlineable(readFileSync16(path, "utf8"));
6303
+ }
5987
6304
  const name = shape === "json" ? "static-analysis.json" : "static-analysis.html";
5988
6305
  const body = shape === "json" ? renderJson(report) : renderHtml(report);
5989
- mkdirSync(join19(cwd, REPORT_DIR), { recursive: true });
5990
- writeFileSync4(join19(cwd, REPORT_DIR, name), body, "utf8");
6306
+ writeFileSync4(join20(cwd, REPORT_DIR, name), body, "utf8");
6307
+ written.unshift(`${REPORT_DIR}/${name}`);
5991
6308
  process.stdout.write(`${renderConsole(report)}
5992
- written: ${REPORT_DIR}/${name}
6309
+ written: ${written.join(", ")}
5993
6310
  `);
5994
6311
  }
6312
+ function inlineable(svg) {
6313
+ return svg.replace(/^<\?xml[^>]*\?>\s*/, "").replace(/^(<svg)\s+width="[^"]*"\s+height="[^"]*"/, "$1");
6314
+ }
5995
6315
  function metricsOf(report) {
5996
6316
  return {
5997
6317
  passes: report.passes,
@@ -6022,9 +6342,9 @@ function registerTest() {
6022
6342
 
6023
6343
  // src/roles/typescript/adapters/tsc/tsc.adapter.ts
6024
6344
  import { spawnSync as spawnSync6 } from "node:child_process";
6025
- import { existsSync as existsSync15, readFileSync as readFileSync17 } from "node:fs";
6345
+ import { existsSync as existsSync15, readFileSync as readFileSync18 } from "node:fs";
6026
6346
  import { createRequire } from "node:module";
6027
- import { join as join21 } from "node:path";
6347
+ import { join as join22 } from "node:path";
6028
6348
 
6029
6349
  // src/roles/typescript/presets/nest.json
6030
6350
  var nest_default2 = {
@@ -6209,8 +6529,8 @@ function readTsconfigAdoption(cwd) {
6209
6529
  }
6210
6530
 
6211
6531
  // src/roles/typescript/adapters/tsc/plan.ts
6212
- import { readFileSync as readFileSync16 } from "node:fs";
6213
- import { join as join20 } from "node:path";
6532
+ import { readFileSync as readFileSync17 } from "node:fs";
6533
+ import { join as join21 } from "node:path";
6214
6534
 
6215
6535
  // src/roles/typescript/typecheck-script.ts
6216
6536
  var SENTINEL_TYPECHECK_COMMAND = "sentinel --run --typescript";
@@ -6303,7 +6623,7 @@ function planAdoption(context) {
6303
6623
  };
6304
6624
  }
6305
6625
  const existing = parseJsonc(
6306
- readFileSync16(join20(context.cwd, target.path), "utf8"),
6626
+ readFileSync17(join21(context.cwd, target.path), "utf8"),
6307
6627
  target.path
6308
6628
  );
6309
6629
  const extendsChain = composeExtends(existing.extends, preset);
@@ -6441,7 +6761,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
6441
6761
  let chain;
6442
6762
  try {
6443
6763
  const parsed = parseJsonc(
6444
- readFileSync17(join21(cwd, target.path), "utf8"),
6764
+ readFileSync18(join22(cwd, target.path), "utf8"),
6445
6765
  target.path
6446
6766
  );
6447
6767
  chain = parsed.extends;
@@ -6454,7 +6774,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
6454
6774
  );
6455
6775
  if (preset === void 0) return void 0;
6456
6776
  try {
6457
- createRequire(join21(cwd, "noop.js")).resolve(preset);
6777
+ createRequire(join22(cwd, "noop.js")).resolve(preset);
6458
6778
  return void 0;
6459
6779
  } catch {
6460
6780
  return preset;
@@ -6550,7 +6870,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
6550
6870
  referencedProjects(cwd, config) {
6551
6871
  try {
6552
6872
  const parsed = parseJsonc(
6553
- readFileSync17(join21(cwd, config), "utf8"),
6873
+ readFileSync18(join22(cwd, config), "utf8"),
6554
6874
  config
6555
6875
  );
6556
6876
  const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path) => typeof path === "string" && path.length > 0);
@@ -6566,7 +6886,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
6566
6886
  * check.
6567
6887
  */
6568
6888
  typecheckTarget(cwd) {
6569
- if (existsSync15(join21(cwd, "tsconfig.json"))) return "tsconfig.json";
6889
+ if (existsSync15(join22(cwd, "tsconfig.json"))) return "tsconfig.json";
6570
6890
  const target = resolveTsconfigTarget(cwd);
6571
6891
  return target.reason === "none" ? null : target.path;
6572
6892
  }
@@ -6772,7 +7092,7 @@ function replaceLines(current, replacements) {
6772
7092
  }
6773
7093
 
6774
7094
  // src/core/apply-plan.ts
6775
- import { existsSync as existsSync16, readFileSync as readFileSync18, renameSync, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
7095
+ import { existsSync as existsSync16, readFileSync as readFileSync19, renameSync, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
6776
7096
  import { resolve as resolve3, sep } from "node:path";
6777
7097
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
6778
7098
  function resolveWithinRoot(cwd, relativePath) {
@@ -6784,7 +7104,7 @@ function resolveWithinRoot(cwd, relativePath) {
6784
7104
  return absolutePath;
6785
7105
  }
6786
7106
  function readIfExists(absolutePath) {
6787
- return existsSync16(absolutePath) ? readFileSync18(absolutePath, "utf8") : void 0;
7107
+ return existsSync16(absolutePath) ? readFileSync19(absolutePath, "utf8") : void 0;
6788
7108
  }
6789
7109
  function* leaves(value, prefix = []) {
6790
7110
  for (const [key, keyValue] of Object.entries(value)) {
@@ -6902,8 +7222,8 @@ function applyPlan(cwd, plan3) {
6902
7222
  }
6903
7223
 
6904
7224
  // src/core/config/preset-evidence.ts
6905
- import { existsSync as existsSync17, readdirSync as readdirSync3, readFileSync as readFileSync19 } from "node:fs";
6906
- import { join as join22 } from "node:path";
7225
+ import { existsSync as existsSync17, readdirSync as readdirSync3, readFileSync as readFileSync20 } from "node:fs";
7226
+ import { join as join23 } from "node:path";
6907
7227
  var PATH_SIGNALS = [
6908
7228
  {
6909
7229
  preset: "nest",
@@ -6937,12 +7257,12 @@ function sourceImports(cwd, pattern) {
6937
7257
  }
6938
7258
  for (const entry of entries) {
6939
7259
  if (entry.isDirectory()) {
6940
- if (!NOT_SCANNED.has(entry.name)) stack.push(join22(dir, entry.name));
7260
+ if (!NOT_SCANNED.has(entry.name)) stack.push(join23(dir, entry.name));
6941
7261
  continue;
6942
7262
  }
6943
7263
  if (!SOURCE_FILE.test(entry.name)) continue;
6944
7264
  try {
6945
- if (pattern.test(readFileSync19(join22(dir, entry.name), "utf8"))) return true;
7265
+ if (pattern.test(readFileSync20(join23(dir, entry.name), "utf8"))) return true;
6946
7266
  } catch {
6947
7267
  continue;
6948
7268
  }
@@ -6951,11 +7271,11 @@ function sourceImports(cwd, pattern) {
6951
7271
  return false;
6952
7272
  }
6953
7273
  function dependencyNames(cwd) {
6954
- const path = join22(cwd, "package.json");
7274
+ const path = join23(cwd, "package.json");
6955
7275
  if (!existsSync17(path)) return [];
6956
7276
  try {
6957
7277
  const manifest = parseJsonc(
6958
- readFileSync19(path, "utf8"),
7278
+ readFileSync20(path, "utf8"),
6959
7279
  path
6960
7280
  );
6961
7281
  return [
@@ -6978,7 +7298,7 @@ function reactTsconfigEvidence(cwd) {
6978
7298
  }
6979
7299
  for (const name of entries) {
6980
7300
  try {
6981
- const config = parseJsonc(readFileSync19(join22(cwd, name), "utf8"), name);
7301
+ const config = parseJsonc(readFileSync20(join23(cwd, name), "utf8"), name);
6982
7302
  if (config.compilerOptions?.jsx !== void 0) return 'its tsconfig sets "jsx"';
6983
7303
  const extended = Array.isArray(config.extends) ? config.extends : config.extends === void 0 ? [] : [config.extends];
6984
7304
  if (extended.some((entry) => entry === REACT_TYPESCRIPT_PRESET)) {
@@ -49,6 +49,9 @@ var shared_default = {
49
49
  minConfidence: 60,
50
50
  categories: ["unused-file", "unused-export", "unused-symbol", "unused-import"]
51
51
  },
52
+ graph: {
53
+ resolution: 1
54
+ },
52
55
  tools: {
53
56
  jscpd: {}
54
57
  }
@@ -162,4 +165,4 @@ export {
162
165
  overridableSettings,
163
166
  defineStaticAnalysis
164
167
  };
165
- //# sourceMappingURL=chunk-UUPP5UQS.js.map
168
+ //# sourceMappingURL=chunk-XSZKLDE5.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/shared/deep-merge.ts","../src/roles/static-analysis/presets/shared.json","../src/roles/static-analysis/preset-data.ts","../src/roles/static-analysis/configs/define.ts"],"sourcesContent":["/**\n * Is this a plain JSON object?\n *\n * The file is named for a `deepMerge` that no longer exists. It was written when the engine\n * merged JSON in memory and wrote the result; that approach was replaced by editing the\n * document IN PLACE with jsonc-parser, so a project's comments, key order and formatting\n * survive a `merge-json` op. Nothing has called `deepMerge` since, except its own recursion,\n * so it went.\n *\n * What remains is the predicate the in-place merge still needs, to decide whether a value is\n * a branch to walk into or a leaf to set.\n */\n\n/** True for a mergeable plain object (not null, not an array). */\nexport function isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n","{\n \"entry\": [\n \"**/*.integration-spec.ts\",\n \"**/*.steps.ts\",\n \"src/client.tsx\",\n \"src/start.ts\",\n \"src/router.tsx\",\n \"src/RouterContext.tsx\"\n ],\n \"ignore\": [\n \"**/node_modules/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/coverage/**\",\n \"**/.next/**\",\n \"**/__mocks__/**\",\n \"**/mocks/**\",\n \"**/_mock_/**\",\n \"**/**Mock**/**\",\n \"**/__fixtures__/**\",\n \"**/fixtures/**\",\n \"**/public/**\",\n \"**/.husky/**\",\n \"**/.vscode/**\",\n \"**/.idea/**\",\n \"**/.github/**\",\n \"**/*.d.ts\",\n \"**/*.gen.ts\",\n \"**/*.generated.ts\"\n ],\n \"languages\": [\"javascript\", \"typescript\", \"jsx\", \"tsx\", \"css\", \"scss\"],\n \"journeys\": {},\n \"duplication\": {\n \"minTokens\": 50,\n \"minLines\": 5,\n \"kinds\": [\"exact\"],\n \"tests\": [\n \"*.{test,spec,steps,e2e-spec,integration-spec}.{ts,tsx,js,jsx}\",\n \"{tests,__tests__}/**/*.{ts,tsx,js,jsx}\"\n ]\n },\n \"deadCode\": {\n \"minConfidence\": 60,\n \"categories\": [\"unused-file\", \"unused-export\", \"unused-symbol\", \"unused-import\"]\n },\n \"graph\": {\n \"resolution\": 1\n },\n \"tools\": {\n \"jscpd\": {}\n }\n}\n","/**\n * The static analysis preset: the role's settings, as data.\n *\n * One shared layer and no per-stack preset yet, because nothing measured so far varies by stack:\n * duplication, dead code and complexity are the same questions in Nest and in React. The split\n * that is already foreseen is `LCOM4`, which is meaningless on a functional front end and real on\n * nest's 16 568 classes; when it arrives it becomes `nest.json` beside this layer, which is why\n * the values live here as JSON rather than inline in the code.\n *\n * ## Why these values are the role's and not the module's\n *\n * A team declares FACTS about its own domain (which journeys exist, which files are entry points);\n * it does not declare the values a number is judged by. Two files with the same measurement\n * getting different verdicts in two modules would make every figure incomparable, and the first\n * thing this role has to be able to say is that a number means the same thing everywhere.\n *\n * That is a default, not a wall: a module CAN move any of them (see `configs/define.ts`), and when\n * it does the report prints `(module)` next to the value. The seam exists from the first commit\n * because retrofitting one later means rewriting every module's file.\n *\n * ⛔ There are no thresholds here, and their absence is a decision rather than an omission.\n *\n * A threshold was written first, at 5% of duplicated lines, and then removed. Measured over all 228\n * nx projects: 5% flags 65 of the 201 measurable modules, a third of the repository, and moving the\n * figure until the count felt manageable is not calibration, it is fitting the instrument to the\n * answer. Calibration is legitimate when a bench is polluted and the noise is removed, or when the\n * reference frame changes; neither applies to a share of duplicated lines.\n *\n * And there is nothing to calibrate AGAINST: of the sources in `docs/static-analysis-study.md`\n * section 5, NDepend, Lattix, ArchUnit, CodeScene and Arcan, none publishes a threshold for\n * duplicated lines. One invented here would travel as if it were published.\n *\n * So the role reports the measurement. What makes a single figure readable is the distribution it\n * sits in, which only a workspace-wide run can know, so that belongs to the sweep and to the\n * migration page, not to a module's own report. The first defensible warning will not be an absolute\n * number either: a module that got WORSE than its own previous measurement is a signal that needs no\n * invented bar, which is what a baseline gives, and that was already deferred for its own reasons.\n *\n * ## Where each value comes from, so nobody has to guess later\n *\n * - `entry` is the single most consequential line in the file. Measured on\n * `apps/nest/microservices/network` with jscpd 5.4.0: with nothing declared the dead-code pass\n * reports **33.4%** of lines dead (79 657 lines, 232 files, 13 entry points); with these two\n * patterns declared it reports **1.0%** (2 462 lines, 207 entry points). The cause is a naming\n * convention and not a bug: our integration tests are `foo.integration-spec.ts` with a HYPHEN,\n * so they do not end in `.spec.ts` and the tool's own test-file detection never sees them, and\n * the 115 `.steps.ts` BDD definitions are tests by no convention it knows. A module ADDS to\n * this list rather than replacing it, precisely so no team can lose these two by declaring a\n * third.\n *\n * ⭐ The four `src/` files are the FRONT bootstrap, and they are the most consequential thing\n * measured on this pass. The tool recognises around fifty frameworks and TanStack Start is not\n * one of them, so nothing in the repository imports these files: the framework finds them by\n * convention at build time. Measured without them declared, `apps/front/console` reports\n * `src/client.tsx`, `src/router.tsx` and `src/start.ts` among its 5 dead files, which are its\n * hydration entry, its router factory and its Start config. `host-admin` reports `client.tsx`\n * too and `career` reports `router.tsx` and `RouterContext.tsx`. Three of console's five\n * findings were its own bootstrap, and the first team to see that stops trusting the tool on\n * the spot. They exist in 2 to 3 front apps each.\n *\n * ⛔ `scripts/**` is deliberately NOT here, although 8 modules have one and\n * `apps/nest/microservices/contract` reports two `scripts/e2e-*.ts` as dead. No `package.json`\n * script invokes them, so whether they run at all is a fact only the team holds. Declaring them\n * would hide a finding that may well be true, and that is the opposite of this role's job.\n *\n * - `languages` is this role's name for what the tool calls `format`, a word that already names\n * another sentinel role and means something else there. Stating it has a measured cost either\n * way: on `apps/front/console`, naming `typescript` alone scans 300 of 523 files because `.tsx`\n * is a SEPARATE language to the tool, naming all four scans 522 (the one left out being\n * `styles.css`), and naming nothing scans every one of its 224 languages, json and markdown\n * included. Stylesheets are in deliberately: measured on `libs/front` they duplicate at 9.64%\n * against 3.50% for the code, a signal that only survives because the report is broken down per\n * language.\n *\n * - `duplication.minTokens` and `minLines` are the detection window, and the window decides what\n * is INVISIBLE: a fragment shorter than it is not reported at all, so a reader comparing two\n * runs has to know both values. They are the tool's own defaults, written out rather than\n * inherited for that reason.\n *\n * - `duplication.kinds` is `exact` alone, and that is a correctness constraint rather than\n * caution. Measured: with a second kind enabled, `duplicatedLines` adds up overlapping clones.\n * Two 9-line files reported 13 duplicated lines out of 18, 72%, because an `exact` clone of\n * lines 1-4 and a `similar` clone of lines 1-9 containing it were both counted; the real extent\n * is 9 of 18, 50%. The tool's own answer is to cap the display at 100%, which does not make a\n * threshold meaningful. Opening another kind therefore requires computing the share ourselves\n * from the UNION of the line ranges.\n *\n * - `duplication.tests` is the glob that splits the pass in two, and the split is the finding that\n * forced it. Measured across 8 modules of the three families, **71% of all duplicated lines are\n * between two test files**: `libs/nest/auth` reports 35.77% and its product code is at 5.96%,\n * `apps/front/host-admin` 7.91% against 3.59%. A single blended figure is therefore mostly a\n * measure of spec duplication wearing the name of the module's duplication, and the number\n * travels while that nuance does not. So the role reports the two side by side, the way it\n * already refuses to blend languages, and neither is hidden: a module whose specs carry 93% of\n * its clones is saying something about its specs that excluding them would erase.\n *\n * ⚠️ The two passes PARTITION the files and therefore lose a clone that spans them: a fragment\n * shared between a test and a product file has no partner in either scan. Measured on the same\n * 8 modules, that is 42 entries of 5117, 0.8%, 650 lines. Stated in the caveat rather than\n * silently accepted, and it is the price of the split.\n *\n * Two patterns and not one, because a suffix convention does not cover a DIRECTORY convention,\n * and the directory one is not a quirk: 635 files across 26 modules live under `tests/` or\n * `__tests__/` without a name this role's suffixes recognise, `apps/nest/microservices/recruitment`\n * alone holding 160. They are step definitions, in-memory doubles and stubs, which is test-side\n * code by any reading. A role default rather than 26 identical overrides.\n *\n * ⚠️ The patterns are RELATIVE here, and the adapter prefixes and composes them, because the\n * tool's `--pattern` takes exactly one glob. Verified on four modules that the two passes\n * partition exactly: console 479 + 87 = 566, contract 399 + 151 = 550, backends-for-frontends/admin\n * 2062 + 773 = 2835, recruitment 936 + 621 = 1557.\n *\n * ⚠️ And a trap that cost a wrong table before it was caught: these globs contain commas, so they\n * survive the config FILE and are destroyed by the `-i` command-line flag, which splits on\n * commas and cuts `{test,spec}` in half. The adapter writes a file, which is one more reason it\n * does.\n *\n * - `deadCode.minConfidence` is the tool's own default of 60. It scores every finding and names\n * what lowered it (a file calling `eval`, an unrecognised decorator, a name appearing in a\n * string literal, a file in the scan that did not parse). Measured on sentinel: 60 yields 20\n * files, 10 exports and 22 symbols; 90 yields 18 files; 100 yields nothing.\n *\n * - `deadCode.categories` leaves out `unused-member` deliberately: without type information\n * `x.render()` could reach any `render` in the project, which the tool itself calls its least\n * certain rule.\n *\n * - `ignore` excludes generated, vendored and fixture code, which duplicates by nature and buries\n * the clones a developer can act on. It is load-bearing rather than tidy: measured on this\n * package, scanning the module root with no ignore list reports 18 186 files and 32 153 clones,\n * **31 969** of them touching `node_modules`; with the list, 408 files and zero.\n *\n * - `graph.resolution` is Louvain's, and it is 1 because that is the value the papers use and\n * graphology's own default. It decides how large a community must be to survive, so it changes\n * both the community count and `Q`, and graphology's documentation warns in its own words that\n * it is finicky to tweak. `code-health-meter` ships 0.74, arrived at by trying values until the\n * partition looked right; this role declines to do that, for the same reason it ships no\n * duplication threshold. A module that has a reason can move it, and the report prints the\n * value every figure was produced with.\n *\n * ⚠️ What is NOT tunable is the random seed, and that is deliberate. Louvain is greedy, so its\n * answer depends on the order it visits nodes, which graphology draws from `Math.random`.\n * Measured over 20 runs per module: `console` produced 16 distinct values of `Q` and between 12\n * and 14 communities, `recruitment` 19 distinct values spanning 0.0357. With the role's own\n * generator all five modules produce one value. A figure that moves on its own between two runs\n * is not a figure a team can act on, so this one is the role's and nobody's to change.\n *\n * - `tools.jscpd` is passed to the tool verbatim, and it is **sentinel's own** escape hatch, not a\n * module's: `defineStaticAnalysis` excludes it from what a module can override. Everything above\n * uses OUR key names, translated on the way out, so that replacing the tool one day does not\n * oblige a single module to edit its config file, and a module only ever knows the ROLE. This\n * block is where that guarantee stops, which is why it stays on this side and why it is empty:\n * the first thing to put in it would be an option a single preset needs, not a workaround.\n */\nimport sharedData from './presets/shared.json'\n\n/**\n * Where the json and html reports are written, relative to the module.\n *\n * Named for the role rather than left on the tool's default `report/`, and added to `.gitignore`\n * by `--init`. Both halves matter: the format role once shipped a `lint/` directory that nothing\n * ignored, and every adopting module grew an untracked directory nobody could explain.\n */\nexport const REPORT_DIR = 'static-analysis-report'\n\n/** The target this role serves, which is also the npm script a module runs and the nx target. */\nexport const TARGET = 'static-analysis'\n\n/** The output shapes `--run` can produce. `console` writes nothing to disk. */\nexport const SHAPES = ['console', 'json', 'html'] as const\n\n/**\n * The npm scripts a module gets, one per shape.\n *\n * Here rather than in the adapter because the REPORT needs them too, to tell a reader of the\n * console summary where the detail is, and two spellings of one script name is drift nobody\n * notices until a developer types the one that does not exist.\n */\nexport const SCRIPTS = {\n console: TARGET,\n json: `${TARGET}:json`,\n html: `${TARGET}:html`,\n} as const\n\n/**\n * What each language the role scans is called on disk.\n *\n * ⚠️ Two lists that MUST agree, and they were maintained separately: the role declares the\n * languages it asks the tool to scan, and the adapter declared the extensions it looks for when\n * deciding whether a module has anything to measure at all. A module that narrowed `languages`\n * would still be probed for the extensions it had removed, and a language added to the preset\n * would never reach the probe. Derived from one source now, with a test that every language maps.\n */\nconst EXTENSIONS_BY_LANGUAGE: Record<string, string[]> = {\n javascript: ['.js', '.mjs', '.cjs'],\n typescript: ['.ts'],\n jsx: ['.jsx'],\n tsx: ['.tsx'],\n css: ['.css'],\n scss: ['.scss'],\n}\n\n/** The file extensions of the languages given, for deciding whether a module has anything to read. */\nexport function extensionsOf(languages: readonly string[]): string[] {\n return [...new Set(languages.flatMap((language) => EXTENSIONS_BY_LANGUAGE[language] ?? []))]\n}\n\n/** Every language the mapping knows, so a test can hold it against the preset. */\nexport const MAPPED_LANGUAGES = Object.keys(EXTENSIONS_BY_LANGUAGE)\n\nexport type StaticDefaults = {\n entry: string[]\n ignore: string[]\n languages: string[]\n journeys: Record<string, string[]>\n duplication: { minTokens: number; minLines: number; kinds: string[]; tests: string[] }\n deadCode: { minConfidence: number; categories: string[] }\n graph: { resolution: number }\n tools: { jscpd: Record<string, unknown> }\n}\n\n/** The role's settings. Read-only here; `defineStaticAnalysis` resolves a module against them. */\nexport const defaults: StaticDefaults = sharedData as StaticDefaults\n\n/**\n * Keys a module declares DIRECTLY rather than through `overrides`, and how each one combines.\n *\n * They are apart because the verb differs: these two are added to and merged, so a team cannot\n * lose what the role ships by declaring its own. Everything else replaces.\n */\nconst DECLARED: Record<string, string> = {\n entry: 'added to the patterns the role ships, never replacing them',\n journeys: \"merged by name with the role's\",\n}\n\n/**\n * ⛔ Not a module's to set, and the catalogue says so rather than leaving it absent: a key that is\n * simply missing from a list reads as an oversight, and someone will try it. Passing it is dropped\n * with a message, see `configs/define.ts`.\n */\nconst NOT_YOURS: Record<string, string> = {\n tools: \"sentinel's own passthrough to the tool underneath; a module never names the vendor\",\n}\n\n/** A value as a reader compares it: short lists in full, long ones as a count. */\nfunction render(value: unknown): string {\n if (Array.isArray(value)) {\n const joined = value.join(', ')\n return value.length <= 6 && joined.length <= 72 ? joined : `${value.length} patterns`\n }\n if (value && typeof value === 'object') return `${Object.keys(value).length} entries`\n return String(value)\n}\n\n/** One documented key: its path, the role's value, and the note a reader needs. */\nexport interface OverridableSetting {\n /** The dotted path a module writes, e.g. `duplication.minTokens`. */\n key: string\n /** The role's current value, rendered. */\n value: string\n /** How it combines, or why it is not available. */\n note: string\n /** Where it goes: directly in the config, inside `overrides`, or nowhere. */\n where: 'declared' | 'overrides' | 'refused'\n}\n\n/**\n * Every setting a module can touch, DERIVED from the values rather than written beside them.\n *\n * Héla's ask, and the reason it is generated: a hand-written list is a second copy of the preset,\n * and the first setting somebody adds makes it a lie that nobody notices, because a stale comment\n * fails no test. Here a new key appears in the next module's config file on its own, with its real\n * value, and `tests/role-layout.test.ts` refuses a key that reaches none of the three groups.\n *\n * It is a snapshot: `--init` leaves an EXISTING config alone, by design, so a module that adopted\n * earlier keeps the catalogue of that day. The report is the live answer, since it prints the value\n * every figure was produced with.\n */\nexport function overridableSettings(from: StaticDefaults = defaults): OverridableSetting[] {\n const rows: OverridableSetting[] = []\n\n for (const [key, value] of Object.entries(from)) {\n if (key in NOT_YOURS) {\n rows.push({ key, value: render(value), note: NOT_YOURS[key] ?? '', where: 'refused' })\n continue\n }\n if (key in DECLARED) {\n rows.push({ key, value: render(value), note: DECLARED[key] ?? '', where: 'declared' })\n continue\n }\n // A group is documented by its leaves, because `duplication` whole is not what anyone writes.\n const leaves =\n value && typeof value === 'object' && !Array.isArray(value)\n ? Object.entries(value).map(([leaf, own]) => ({ key: `${key}.${leaf}`, own }))\n : [{ key, own: value }]\n for (const leaf of leaves) {\n rows.push({ key: leaf.key, value: render(leaf.own), note: 'replaced', where: 'overrides' })\n }\n }\n\n return rows\n}\n","/**\n * `defineStaticAnalysis`: what a module imports to declare its own static analysis settings.\n *\n * ```js\n * // static-analysis.config.mjs\n * import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'\n *\n * export default defineStaticAnalysis({\n * root: import.meta.dirname,\n * entry: ['**\\/*.steps.ts'],\n * journeys: { planning: ['src/planning/**'] },\n * })\n * ```\n *\n * ## Why a function and not `{ ...baseConfig, ... }`\n *\n * A spread is SHALLOW, so a module writing `deadCode: { minConfidence: 80 }` would replace the\n * whole block and silently lose `categories`. And the flattening happens in the module's file,\n * before this package ever sees the object, so nothing here could tell \"set one key, keep the\n * rest\" from \"set one key, drop the rest\". A function receives the module's INTENT, the keys it\n * actually passed, and merges them here. Second reason: the defaults are never copied into a\n * module, so changing one reaches every module at the next release instead of leaving 108 stale\n * copies behind.\n *\n * Same shape as `reactTestConfig` in the test role, for the same reasons.\n *\n * ## Two semantics, one obvious and one deliberate\n *\n * `entry` and `journeys` are FACTS a team declares, and they ADD to what this role ships: no\n * module can lose `**\\/*.integration-spec.ts` by declaring a third pattern, which matters\n * because that one pattern is the difference between 33.4% and 1.0% of dead code on a real\n * service (see `defaults.ts`).\n *\n * `overrides` REPLACES, including those lists. It is the seam for the day a team has a real\n * reason, it is not documented anywhere a developer would meet it, and every path it sets is\n * recorded in `moved` so the report can print `(module)` next to the value. Without that trace\n * two modules stop being comparable and nobody knows. It reaches every setting EXCEPT the vendor\n * passthrough, for the reason stated on the field itself.\n */\nimport { isPlainObject } from '../../../shared/deep-merge.js'\nimport { defaults, type StaticDefaults } from '../preset-data.js'\n\ntype DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }\n\n/** What a module's config file passes. Everything is optional, including the file itself. */\nexport interface StaticAnalysisInput {\n /** The module's own directory, normally `import.meta.dirname`. */\n root?: string\n /** Files that run although nothing imports them. ADDED to the shipped patterns. */\n entry?: string[]\n /** Named journeys, as `{ name: [globs] }`. MERGED by name with any shipped ones. */\n journeys?: Record<string, string[]>\n /**\n * The seam. Replaces whatever it names, and every path it sets lands in `moved`.\n *\n * ⛔ `tools` is excluded, and the reason is the whole point of translating the vocabulary: a\n * module knows the ROLE, not the tool underneath it. A key named after the vendor would mean\n * nothing to the person reading their own config file, and showing it would invite them to learn\n * a name we are working to keep out of their world. The passthrough still exists, on sentinel's\n * side, for a preset that needs a vendor option; a module that genuinely needs one gets a key in\n * OUR vocabulary, which is the right friction because it goes through a review instead of\n * spreading silently.\n */\n overrides?: DeepPartial<Omit<StaticDefaults, 'tools'>>\n}\n\nexport interface ResolvedStaticConfig extends StaticDefaults {\n /** The module's directory, or null when its config file did not say. */\n root: string | null\n /**\n * Dotted paths the module set through `overrides`, so the report can mark them. Sorted, so\n * two runs on the same config produce the same report.\n */\n moved: string[]\n}\n\n/**\n * Merge `patch` into `base`, recursively, patch winning.\n *\n * Not the `deepMerge` this repo once had: that one existed for WRITING a module's json and was\n * replaced by an in-place jsonc edit, which is why only `isPlainObject` survived. This is the\n * read side, which has no file to preserve and only has to resolve two objects into one.\n *\n * Arrays are replaced, never concatenated. Additive behaviour is a decision about a specific\n * key, which `defineStaticAnalysis` makes explicitly for `entry`, rather than a property of the\n * merge: a merge that silently concatenates cannot express \"replace this list\", and a team\n * reaching for `overrides` is asking for exactly that.\n */\nfunction mergeInto<T>(base: T, patch: unknown): T {\n if (!isPlainObject(patch)) return base\n if (!isPlainObject(base)) return patch as T\n const out: Record<string, unknown> = { ...base }\n for (const [key, value] of Object.entries(patch)) {\n out[key] = isPlainObject(value) ? mergeInto(out[key], value) : value\n }\n return out as T\n}\n\n/** Every leaf path in `patch`, dotted, so a reader can be told what the module decided. */\nfunction leafPaths(patch: unknown, prefix = ''): string[] {\n if (!isPlainObject(patch)) return prefix ? [prefix] : []\n return Object.entries(patch).flatMap(([key, value]) =>\n leafPaths(value, prefix ? `${prefix}.${key}` : key),\n )\n}\n\n/** The same list with duplicates dropped, keeping the first occurrence's order. */\nfunction unique(values: string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * Drop the vendor passthrough from what a module sent.\n *\n * ⚠️ Enforced here and not by the type, because the type cannot: a module's config file is `.mjs`,\n * so nothing type-checks it, and `Omit` on the input would be documentation that compiles away. A\n * `tools` key written there is dropped and NAMED on stderr rather than ignored, so a developer who\n * found the key somewhere learns why it did nothing instead of concluding the role is broken.\n */\nfunction withoutVendorKeys(overrides: unknown): unknown {\n if (!isPlainObject(overrides) || !('tools' in overrides)) return overrides\n const { tools: _vendor, ...rest } = overrides\n process.stderr.write(\n 'sentinel static-analysis: ignoring `overrides.tools` from this module. The tool underneath ' +\n \"the role is not part of a module's surface; if a setting is missing, it needs a key of \" +\n 'its own.\\n',\n )\n return rest\n}\n\n/** Resolve a module's declaration against this role's defaults. */\nexport function defineStaticAnalysis(input: StaticAnalysisInput = {}): ResolvedStaticConfig {\n const declared: StaticDefaults = {\n ...defaults,\n entry: unique([...defaults.entry, ...(input.entry ?? [])]),\n journeys: { ...defaults.journeys, ...(input.journeys ?? {}) },\n }\n const overrides = withoutVendorKeys(input.overrides)\n\n return {\n ...mergeInto(declared, overrides),\n root: input.root ?? null,\n moved: leafPaths(overrides).sort(),\n }\n}\n"],"mappings":"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","names":[]}
package/dist/index.js CHANGED
@@ -6,12 +6,12 @@ import {
6
6
  registerAdapters,
7
7
  resolve,
8
8
  setDefaultRunner
9
- } from "./chunk-UBNRRSGV.js";
9
+ } from "./chunk-5GJOBC4M.js";
10
10
  import {
11
11
  BaseAdapter
12
12
  } from "./chunk-KLEXJFJ6.js";
13
13
  import "./chunk-YQRDKWSH.js";
14
- import "./chunk-UUPP5UQS.js";
14
+ import "./chunk-XSZKLDE5.js";
15
15
  import "./chunk-O7REVMOC.js";
16
16
  import "./chunk-QXFCZON7.js";
17
17
  export {
@@ -13,6 +13,9 @@ type StaticDefaults = {
13
13
  minConfidence: number;
14
14
  categories: string[];
15
15
  };
16
+ graph: {
17
+ resolution: number;
18
+ };
16
19
  tools: {
17
20
  jscpd: Record<string, unknown>;
18
21
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  defineStaticAnalysis
3
- } from "../../chunk-UUPP5UQS.js";
3
+ } from "../../chunk-XSZKLDE5.js";
4
4
  export {
5
5
  defineStaticAnalysis
6
6
  };
@@ -1186,6 +1186,7 @@ $ sentinel --init --dry-run
1186
1186
  + // duplication.tests 2 patterns
1187
1187
  + // deadCode.minConfidence 60
1188
1188
  + // deadCode.categories unused-file, unused-export, unused-symbol, unused-import
1189
+ + // graph.resolution 1
1189
1190
  + //
1190
1191
  + // A list shown as a count is not copyable, and replacing one means writing back what you keep,
1191
1192
  + // so read the values in full with:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hublo/sentinel",
3
- "version": "1.5.0-alpha.15",
3
+ "version": "1.5.0-alpha.17",
4
4
  "description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -120,6 +120,14 @@
120
120
  "eslint-plugin-react-refresh": "0.4.19",
121
121
  "eslint-plugin-storybook": "10.3.5",
122
122
  "eslint-plugin-styled-components-a11y": "2.2.0",
123
+ "graphology": "0.26.0",
124
+ "graphology-communities-louvain": "2.0.2",
125
+ "graphology-components": "1.5.4",
126
+ "graphology-layout": "0.6.1",
127
+ "graphology-layout-forceatlas2": "0.10.1",
128
+ "graphology-metrics": "2.4.2",
129
+ "graphology-svg": "0.1.3",
130
+ "graphology-types": "0.24.8",
123
131
  "html-validate": "^11.16.0",
124
132
  "jscpd": "5.4.0",
125
133
  "jsdom": "^24.1.3",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/shared/deep-merge.ts","../src/roles/static-analysis/presets/shared.json","../src/roles/static-analysis/preset-data.ts","../src/roles/static-analysis/configs/define.ts"],"sourcesContent":["/**\n * Is this a plain JSON object?\n *\n * The file is named for a `deepMerge` that no longer exists. It was written when the engine\n * merged JSON in memory and wrote the result; that approach was replaced by editing the\n * document IN PLACE with jsonc-parser, so a project's comments, key order and formatting\n * survive a `merge-json` op. Nothing has called `deepMerge` since, except its own recursion,\n * so it went.\n *\n * What remains is the predicate the in-place merge still needs, to decide whether a value is\n * a branch to walk into or a leaf to set.\n */\n\n/** True for a mergeable plain object (not null, not an array). */\nexport function isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n","{\n \"entry\": [\n \"**/*.integration-spec.ts\",\n \"**/*.steps.ts\",\n \"src/client.tsx\",\n \"src/start.ts\",\n \"src/router.tsx\",\n \"src/RouterContext.tsx\"\n ],\n \"ignore\": [\n \"**/node_modules/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/coverage/**\",\n \"**/.next/**\",\n \"**/__mocks__/**\",\n \"**/mocks/**\",\n \"**/_mock_/**\",\n \"**/**Mock**/**\",\n \"**/__fixtures__/**\",\n \"**/fixtures/**\",\n \"**/public/**\",\n \"**/.husky/**\",\n \"**/.vscode/**\",\n \"**/.idea/**\",\n \"**/.github/**\",\n \"**/*.d.ts\",\n \"**/*.gen.ts\",\n \"**/*.generated.ts\"\n ],\n \"languages\": [\"javascript\", \"typescript\", \"jsx\", \"tsx\", \"css\", \"scss\"],\n \"journeys\": {},\n \"duplication\": {\n \"minTokens\": 50,\n \"minLines\": 5,\n \"kinds\": [\"exact\"],\n \"tests\": [\n \"*.{test,spec,steps,e2e-spec,integration-spec}.{ts,tsx,js,jsx}\",\n \"{tests,__tests__}/**/*.{ts,tsx,js,jsx}\"\n ]\n },\n \"deadCode\": {\n \"minConfidence\": 60,\n \"categories\": [\"unused-file\", \"unused-export\", \"unused-symbol\", \"unused-import\"]\n },\n \"tools\": {\n \"jscpd\": {}\n }\n}\n","/**\n * The static analysis preset: the role's settings, as data.\n *\n * One shared layer and no per-stack preset yet, because nothing measured so far varies by stack:\n * duplication, dead code and complexity are the same questions in Nest and in React. The split\n * that is already foreseen is `LCOM4`, which is meaningless on a functional front end and real on\n * nest's 16 568 classes; when it arrives it becomes `nest.json` beside this layer, which is why\n * the values live here as JSON rather than inline in the code.\n *\n * ## Why these values are the role's and not the module's\n *\n * A team declares FACTS about its own domain (which journeys exist, which files are entry points);\n * it does not declare the values a number is judged by. Two files with the same measurement\n * getting different verdicts in two modules would make every figure incomparable, and the first\n * thing this role has to be able to say is that a number means the same thing everywhere.\n *\n * That is a default, not a wall: a module CAN move any of them (see `configs/define.ts`), and when\n * it does the report prints `(module)` next to the value. The seam exists from the first commit\n * because retrofitting one later means rewriting every module's file.\n *\n * ⛔ There are no thresholds here, and their absence is a decision rather than an omission.\n *\n * A threshold was written first, at 5% of duplicated lines, and then removed. Measured over all 228\n * nx projects: 5% flags 65 of the 201 measurable modules, a third of the repository, and moving the\n * figure until the count felt manageable is not calibration, it is fitting the instrument to the\n * answer. Calibration is legitimate when a bench is polluted and the noise is removed, or when the\n * reference frame changes; neither applies to a share of duplicated lines.\n *\n * And there is nothing to calibrate AGAINST: of the sources in `docs/static-analysis-study.md`\n * section 5, NDepend, Lattix, ArchUnit, CodeScene and Arcan, none publishes a threshold for\n * duplicated lines. One invented here would travel as if it were published.\n *\n * So the role reports the measurement. What makes a single figure readable is the distribution it\n * sits in, which only a workspace-wide run can know, so that belongs to the sweep and to the\n * migration page, not to a module's own report. The first defensible warning will not be an absolute\n * number either: a module that got WORSE than its own previous measurement is a signal that needs no\n * invented bar, which is what a baseline gives, and that was already deferred for its own reasons.\n *\n * ## Where each value comes from, so nobody has to guess later\n *\n * - `entry` is the single most consequential line in the file. Measured on\n * `apps/nest/microservices/network` with jscpd 5.4.0: with nothing declared the dead-code pass\n * reports **33.4%** of lines dead (79 657 lines, 232 files, 13 entry points); with these two\n * patterns declared it reports **1.0%** (2 462 lines, 207 entry points). The cause is a naming\n * convention and not a bug: our integration tests are `foo.integration-spec.ts` with a HYPHEN,\n * so they do not end in `.spec.ts` and the tool's own test-file detection never sees them, and\n * the 115 `.steps.ts` BDD definitions are tests by no convention it knows. A module ADDS to\n * this list rather than replacing it, precisely so no team can lose these two by declaring a\n * third.\n *\n * ⭐ The four `src/` files are the FRONT bootstrap, and they are the most consequential thing\n * measured on this pass. The tool recognises around fifty frameworks and TanStack Start is not\n * one of them, so nothing in the repository imports these files: the framework finds them by\n * convention at build time. Measured without them declared, `apps/front/console` reports\n * `src/client.tsx`, `src/router.tsx` and `src/start.ts` among its 5 dead files, which are its\n * hydration entry, its router factory and its Start config. `host-admin` reports `client.tsx`\n * too and `career` reports `router.tsx` and `RouterContext.tsx`. Three of console's five\n * findings were its own bootstrap, and the first team to see that stops trusting the tool on\n * the spot. They exist in 2 to 3 front apps each.\n *\n * ⛔ `scripts/**` is deliberately NOT here, although 8 modules have one and\n * `apps/nest/microservices/contract` reports two `scripts/e2e-*.ts` as dead. No `package.json`\n * script invokes them, so whether they run at all is a fact only the team holds. Declaring them\n * would hide a finding that may well be true, and that is the opposite of this role's job.\n *\n * - `languages` is this role's name for what the tool calls `format`, a word that already names\n * another sentinel role and means something else there. Stating it has a measured cost either\n * way: on `apps/front/console`, naming `typescript` alone scans 300 of 523 files because `.tsx`\n * is a SEPARATE language to the tool, naming all four scans 522 (the one left out being\n * `styles.css`), and naming nothing scans every one of its 224 languages, json and markdown\n * included. Stylesheets are in deliberately: measured on `libs/front` they duplicate at 9.64%\n * against 3.50% for the code, a signal that only survives because the report is broken down per\n * language.\n *\n * - `duplication.minTokens` and `minLines` are the detection window, and the window decides what\n * is INVISIBLE: a fragment shorter than it is not reported at all, so a reader comparing two\n * runs has to know both values. They are the tool's own defaults, written out rather than\n * inherited for that reason.\n *\n * - `duplication.kinds` is `exact` alone, and that is a correctness constraint rather than\n * caution. Measured: with a second kind enabled, `duplicatedLines` adds up overlapping clones.\n * Two 9-line files reported 13 duplicated lines out of 18, 72%, because an `exact` clone of\n * lines 1-4 and a `similar` clone of lines 1-9 containing it were both counted; the real extent\n * is 9 of 18, 50%. The tool's own answer is to cap the display at 100%, which does not make a\n * threshold meaningful. Opening another kind therefore requires computing the share ourselves\n * from the UNION of the line ranges.\n *\n * - `duplication.tests` is the glob that splits the pass in two, and the split is the finding that\n * forced it. Measured across 8 modules of the three families, **71% of all duplicated lines are\n * between two test files**: `libs/nest/auth` reports 35.77% and its product code is at 5.96%,\n * `apps/front/host-admin` 7.91% against 3.59%. A single blended figure is therefore mostly a\n * measure of spec duplication wearing the name of the module's duplication, and the number\n * travels while that nuance does not. So the role reports the two side by side, the way it\n * already refuses to blend languages, and neither is hidden: a module whose specs carry 93% of\n * its clones is saying something about its specs that excluding them would erase.\n *\n * ⚠️ The two passes PARTITION the files and therefore lose a clone that spans them: a fragment\n * shared between a test and a product file has no partner in either scan. Measured on the same\n * 8 modules, that is 42 entries of 5117, 0.8%, 650 lines. Stated in the caveat rather than\n * silently accepted, and it is the price of the split.\n *\n * Two patterns and not one, because a suffix convention does not cover a DIRECTORY convention,\n * and the directory one is not a quirk: 635 files across 26 modules live under `tests/` or\n * `__tests__/` without a name this role's suffixes recognise, `apps/nest/microservices/recruitment`\n * alone holding 160. They are step definitions, in-memory doubles and stubs, which is test-side\n * code by any reading. A role default rather than 26 identical overrides.\n *\n * ⚠️ The patterns are RELATIVE here, and the adapter prefixes and composes them, because the\n * tool's `--pattern` takes exactly one glob. Verified on four modules that the two passes\n * partition exactly: console 479 + 87 = 566, contract 399 + 151 = 550, backends-for-frontends/admin\n * 2062 + 773 = 2835, recruitment 936 + 621 = 1557.\n *\n * ⚠️ And a trap that cost a wrong table before it was caught: these globs contain commas, so they\n * survive the config FILE and are destroyed by the `-i` command-line flag, which splits on\n * commas and cuts `{test,spec}` in half. The adapter writes a file, which is one more reason it\n * does.\n *\n * - `deadCode.minConfidence` is the tool's own default of 60. It scores every finding and names\n * what lowered it (a file calling `eval`, an unrecognised decorator, a name appearing in a\n * string literal, a file in the scan that did not parse). Measured on sentinel: 60 yields 20\n * files, 10 exports and 22 symbols; 90 yields 18 files; 100 yields nothing.\n *\n * - `deadCode.categories` leaves out `unused-member` deliberately: without type information\n * `x.render()` could reach any `render` in the project, which the tool itself calls its least\n * certain rule.\n *\n * - `ignore` excludes generated, vendored and fixture code, which duplicates by nature and buries\n * the clones a developer can act on. It is load-bearing rather than tidy: measured on this\n * package, scanning the module root with no ignore list reports 18 186 files and 32 153 clones,\n * **31 969** of them touching `node_modules`; with the list, 408 files and zero.\n *\n * - `tools.jscpd` is passed to the tool verbatim, and it is **sentinel's own** escape hatch, not a\n * module's: `defineStaticAnalysis` excludes it from what a module can override. Everything above\n * uses OUR key names, translated on the way out, so that replacing the tool one day does not\n * oblige a single module to edit its config file, and a module only ever knows the ROLE. This\n * block is where that guarantee stops, which is why it stays on this side and why it is empty:\n * the first thing to put in it would be an option a single preset needs, not a workaround.\n */\nimport sharedData from './presets/shared.json'\n\n/**\n * Where the json and html reports are written, relative to the module.\n *\n * Named for the role rather than left on the tool's default `report/`, and added to `.gitignore`\n * by `--init`. Both halves matter: the format role once shipped a `lint/` directory that nothing\n * ignored, and every adopting module grew an untracked directory nobody could explain.\n */\nexport const REPORT_DIR = 'static-analysis-report'\n\n/** The target this role serves, which is also the npm script a module runs and the nx target. */\nexport const TARGET = 'static-analysis'\n\n/** The output shapes `--run` can produce. `console` writes nothing to disk. */\nexport const SHAPES = ['console', 'json', 'html'] as const\n\n/**\n * The npm scripts a module gets, one per shape.\n *\n * Here rather than in the adapter because the REPORT needs them too, to tell a reader of the\n * console summary where the detail is, and two spellings of one script name is drift nobody\n * notices until a developer types the one that does not exist.\n */\nexport const SCRIPTS = {\n console: TARGET,\n json: `${TARGET}:json`,\n html: `${TARGET}:html`,\n} as const\n\n/**\n * What each language the role scans is called on disk.\n *\n * ⚠️ Two lists that MUST agree, and they were maintained separately: the role declares the\n * languages it asks the tool to scan, and the adapter declared the extensions it looks for when\n * deciding whether a module has anything to measure at all. A module that narrowed `languages`\n * would still be probed for the extensions it had removed, and a language added to the preset\n * would never reach the probe. Derived from one source now, with a test that every language maps.\n */\nconst EXTENSIONS_BY_LANGUAGE: Record<string, string[]> = {\n javascript: ['.js', '.mjs', '.cjs'],\n typescript: ['.ts'],\n jsx: ['.jsx'],\n tsx: ['.tsx'],\n css: ['.css'],\n scss: ['.scss'],\n}\n\n/** The file extensions of the languages given, for deciding whether a module has anything to read. */\nexport function extensionsOf(languages: readonly string[]): string[] {\n return [...new Set(languages.flatMap((language) => EXTENSIONS_BY_LANGUAGE[language] ?? []))]\n}\n\n/** Every language the mapping knows, so a test can hold it against the preset. */\nexport const MAPPED_LANGUAGES = Object.keys(EXTENSIONS_BY_LANGUAGE)\n\nexport type StaticDefaults = {\n entry: string[]\n ignore: string[]\n languages: string[]\n journeys: Record<string, string[]>\n duplication: { minTokens: number; minLines: number; kinds: string[]; tests: string[] }\n deadCode: { minConfidence: number; categories: string[] }\n tools: { jscpd: Record<string, unknown> }\n}\n\n/** The role's settings. Read-only here; `defineStaticAnalysis` resolves a module against them. */\nexport const defaults: StaticDefaults = sharedData as StaticDefaults\n\n/**\n * Keys a module declares DIRECTLY rather than through `overrides`, and how each one combines.\n *\n * They are apart because the verb differs: these two are added to and merged, so a team cannot\n * lose what the role ships by declaring its own. Everything else replaces.\n */\nconst DECLARED: Record<string, string> = {\n entry: 'added to the patterns the role ships, never replacing them',\n journeys: \"merged by name with the role's\",\n}\n\n/**\n * ⛔ Not a module's to set, and the catalogue says so rather than leaving it absent: a key that is\n * simply missing from a list reads as an oversight, and someone will try it. Passing it is dropped\n * with a message, see `configs/define.ts`.\n */\nconst NOT_YOURS: Record<string, string> = {\n tools: \"sentinel's own passthrough to the tool underneath; a module never names the vendor\",\n}\n\n/** A value as a reader compares it: short lists in full, long ones as a count. */\nfunction render(value: unknown): string {\n if (Array.isArray(value)) {\n const joined = value.join(', ')\n return value.length <= 6 && joined.length <= 72 ? joined : `${value.length} patterns`\n }\n if (value && typeof value === 'object') return `${Object.keys(value).length} entries`\n return String(value)\n}\n\n/** One documented key: its path, the role's value, and the note a reader needs. */\nexport interface OverridableSetting {\n /** The dotted path a module writes, e.g. `duplication.minTokens`. */\n key: string\n /** The role's current value, rendered. */\n value: string\n /** How it combines, or why it is not available. */\n note: string\n /** Where it goes: directly in the config, inside `overrides`, or nowhere. */\n where: 'declared' | 'overrides' | 'refused'\n}\n\n/**\n * Every setting a module can touch, DERIVED from the values rather than written beside them.\n *\n * Héla's ask, and the reason it is generated: a hand-written list is a second copy of the preset,\n * and the first setting somebody adds makes it a lie that nobody notices, because a stale comment\n * fails no test. Here a new key appears in the next module's config file on its own, with its real\n * value, and `tests/role-layout.test.ts` refuses a key that reaches none of the three groups.\n *\n * It is a snapshot: `--init` leaves an EXISTING config alone, by design, so a module that adopted\n * earlier keeps the catalogue of that day. The report is the live answer, since it prints the value\n * every figure was produced with.\n */\nexport function overridableSettings(from: StaticDefaults = defaults): OverridableSetting[] {\n const rows: OverridableSetting[] = []\n\n for (const [key, value] of Object.entries(from)) {\n if (key in NOT_YOURS) {\n rows.push({ key, value: render(value), note: NOT_YOURS[key] ?? '', where: 'refused' })\n continue\n }\n if (key in DECLARED) {\n rows.push({ key, value: render(value), note: DECLARED[key] ?? '', where: 'declared' })\n continue\n }\n // A group is documented by its leaves, because `duplication` whole is not what anyone writes.\n const leaves =\n value && typeof value === 'object' && !Array.isArray(value)\n ? Object.entries(value).map(([leaf, own]) => ({ key: `${key}.${leaf}`, own }))\n : [{ key, own: value }]\n for (const leaf of leaves) {\n rows.push({ key: leaf.key, value: render(leaf.own), note: 'replaced', where: 'overrides' })\n }\n }\n\n return rows\n}\n","/**\n * `defineStaticAnalysis`: what a module imports to declare its own static analysis settings.\n *\n * ```js\n * // static-analysis.config.mjs\n * import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'\n *\n * export default defineStaticAnalysis({\n * root: import.meta.dirname,\n * entry: ['**\\/*.steps.ts'],\n * journeys: { planning: ['src/planning/**'] },\n * })\n * ```\n *\n * ## Why a function and not `{ ...baseConfig, ... }`\n *\n * A spread is SHALLOW, so a module writing `deadCode: { minConfidence: 80 }` would replace the\n * whole block and silently lose `categories`. And the flattening happens in the module's file,\n * before this package ever sees the object, so nothing here could tell \"set one key, keep the\n * rest\" from \"set one key, drop the rest\". A function receives the module's INTENT, the keys it\n * actually passed, and merges them here. Second reason: the defaults are never copied into a\n * module, so changing one reaches every module at the next release instead of leaving 108 stale\n * copies behind.\n *\n * Same shape as `reactTestConfig` in the test role, for the same reasons.\n *\n * ## Two semantics, one obvious and one deliberate\n *\n * `entry` and `journeys` are FACTS a team declares, and they ADD to what this role ships: no\n * module can lose `**\\/*.integration-spec.ts` by declaring a third pattern, which matters\n * because that one pattern is the difference between 33.4% and 1.0% of dead code on a real\n * service (see `defaults.ts`).\n *\n * `overrides` REPLACES, including those lists. It is the seam for the day a team has a real\n * reason, it is not documented anywhere a developer would meet it, and every path it sets is\n * recorded in `moved` so the report can print `(module)` next to the value. Without that trace\n * two modules stop being comparable and nobody knows. It reaches every setting EXCEPT the vendor\n * passthrough, for the reason stated on the field itself.\n */\nimport { isPlainObject } from '../../../shared/deep-merge.js'\nimport { defaults, type StaticDefaults } from '../preset-data.js'\n\ntype DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }\n\n/** What a module's config file passes. Everything is optional, including the file itself. */\nexport interface StaticAnalysisInput {\n /** The module's own directory, normally `import.meta.dirname`. */\n root?: string\n /** Files that run although nothing imports them. ADDED to the shipped patterns. */\n entry?: string[]\n /** Named journeys, as `{ name: [globs] }`. MERGED by name with any shipped ones. */\n journeys?: Record<string, string[]>\n /**\n * The seam. Replaces whatever it names, and every path it sets lands in `moved`.\n *\n * ⛔ `tools` is excluded, and the reason is the whole point of translating the vocabulary: a\n * module knows the ROLE, not the tool underneath it. A key named after the vendor would mean\n * nothing to the person reading their own config file, and showing it would invite them to learn\n * a name we are working to keep out of their world. The passthrough still exists, on sentinel's\n * side, for a preset that needs a vendor option; a module that genuinely needs one gets a key in\n * OUR vocabulary, which is the right friction because it goes through a review instead of\n * spreading silently.\n */\n overrides?: DeepPartial<Omit<StaticDefaults, 'tools'>>\n}\n\nexport interface ResolvedStaticConfig extends StaticDefaults {\n /** The module's directory, or null when its config file did not say. */\n root: string | null\n /**\n * Dotted paths the module set through `overrides`, so the report can mark them. Sorted, so\n * two runs on the same config produce the same report.\n */\n moved: string[]\n}\n\n/**\n * Merge `patch` into `base`, recursively, patch winning.\n *\n * Not the `deepMerge` this repo once had: that one existed for WRITING a module's json and was\n * replaced by an in-place jsonc edit, which is why only `isPlainObject` survived. This is the\n * read side, which has no file to preserve and only has to resolve two objects into one.\n *\n * Arrays are replaced, never concatenated. Additive behaviour is a decision about a specific\n * key, which `defineStaticAnalysis` makes explicitly for `entry`, rather than a property of the\n * merge: a merge that silently concatenates cannot express \"replace this list\", and a team\n * reaching for `overrides` is asking for exactly that.\n */\nfunction mergeInto<T>(base: T, patch: unknown): T {\n if (!isPlainObject(patch)) return base\n if (!isPlainObject(base)) return patch as T\n const out: Record<string, unknown> = { ...base }\n for (const [key, value] of Object.entries(patch)) {\n out[key] = isPlainObject(value) ? mergeInto(out[key], value) : value\n }\n return out as T\n}\n\n/** Every leaf path in `patch`, dotted, so a reader can be told what the module decided. */\nfunction leafPaths(patch: unknown, prefix = ''): string[] {\n if (!isPlainObject(patch)) return prefix ? [prefix] : []\n return Object.entries(patch).flatMap(([key, value]) =>\n leafPaths(value, prefix ? `${prefix}.${key}` : key),\n )\n}\n\n/** The same list with duplicates dropped, keeping the first occurrence's order. */\nfunction unique(values: string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * Drop the vendor passthrough from what a module sent.\n *\n * ⚠️ Enforced here and not by the type, because the type cannot: a module's config file is `.mjs`,\n * so nothing type-checks it, and `Omit` on the input would be documentation that compiles away. A\n * `tools` key written there is dropped and NAMED on stderr rather than ignored, so a developer who\n * found the key somewhere learns why it did nothing instead of concluding the role is broken.\n */\nfunction withoutVendorKeys(overrides: unknown): unknown {\n if (!isPlainObject(overrides) || !('tools' in overrides)) return overrides\n const { tools: _vendor, ...rest } = overrides\n process.stderr.write(\n 'sentinel static-analysis: ignoring `overrides.tools` from this module. The tool underneath ' +\n \"the role is not part of a module's surface; if a setting is missing, it needs a key of \" +\n 'its own.\\n',\n )\n return rest\n}\n\n/** Resolve a module's declaration against this role's defaults. */\nexport function defineStaticAnalysis(input: StaticAnalysisInput = {}): ResolvedStaticConfig {\n const declared: StaticDefaults = {\n ...defaults,\n entry: unique([...defaults.entry, ...(input.entry ?? [])]),\n journeys: { ...defaults.journeys, ...(input.journeys ?? {}) },\n }\n const overrides = withoutVendorKeys(input.overrides)\n\n return {\n ...mergeInto(declared, overrides),\n root: input.root ?? null,\n moved: leafPaths(overrides).sort(),\n }\n}\n"],"mappings":";AAcO,SAAS,cAAc,OAAkD;AAC9E,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;;;AChBA;AAAA,EACE,OAAS;AAAA,IACP;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,QAAU;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,WAAa,CAAC,cAAc,cAAc,OAAO,OAAO,OAAO,MAAM;AAAA,EACrE,UAAY,CAAC;AAAA,EACb,aAAe;AAAA,IACb,WAAa;AAAA,IACb,UAAY;AAAA,IACZ,OAAS,CAAC,OAAO;AAAA,IACjB,OAAS;AAAA,MACP;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAAA,EACA,UAAY;AAAA,IACV,eAAiB;AAAA,IACjB,YAAc,CAAC,eAAe,iBAAiB,iBAAiB,eAAe;AAAA,EACjF;AAAA,EACA,OAAS;AAAA,IACP,OAAS,CAAC;AAAA,EACZ;AACF;;;ACmGO,IAAM,aAAa;AAGnB,IAAM,SAAS;AAGf,IAAM,SAAS,CAAC,WAAW,QAAQ,MAAM;AASzC,IAAM,UAAU;AAAA,EACrB,SAAS;AAAA,EACT,MAAM,GAAG,MAAM;AAAA,EACf,MAAM,GAAG,MAAM;AACjB;AAWA,IAAM,yBAAmD;AAAA,EACvD,YAAY,CAAC,OAAO,QAAQ,MAAM;AAAA,EAClC,YAAY,CAAC,KAAK;AAAA,EAClB,KAAK,CAAC,MAAM;AAAA,EACZ,KAAK,CAAC,MAAM;AAAA,EACZ,KAAK,CAAC,MAAM;AAAA,EACZ,MAAM,CAAC,OAAO;AAChB;AAGO,SAAS,aAAa,WAAwC;AACnE,SAAO,CAAC,GAAG,IAAI,IAAI,UAAU,QAAQ,CAAC,aAAa,uBAAuB,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC;AAC7F;AAGO,IAAM,mBAAmB,OAAO,KAAK,sBAAsB;AAa3D,IAAM,WAA2B;AAQxC,IAAM,WAAmC;AAAA,EACvC,OAAO;AAAA,EACP,UAAU;AACZ;AAOA,IAAM,YAAoC;AAAA,EACxC,OAAO;AACT;AAGA,SAAS,OAAO,OAAwB;AACtC,MAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,UAAM,SAAS,MAAM,KAAK,IAAI;AAC9B,WAAO,MAAM,UAAU,KAAK,OAAO,UAAU,KAAK,SAAS,GAAG,MAAM,MAAM;AAAA,EAC5E;AACA,MAAI,SAAS,OAAO,UAAU,SAAU,QAAO,GAAG,OAAO,KAAK,KAAK,EAAE,MAAM;AAC3E,SAAO,OAAO,KAAK;AACrB;AA0BO,SAAS,oBAAoB,OAAuB,UAAgC;AACzF,QAAM,OAA6B,CAAC;AAEpC,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,IAAI,GAAG;AAC/C,QAAI,OAAO,WAAW;AACpB,WAAK,KAAK,EAAE,KAAK,OAAO,OAAO,KAAK,GAAG,MAAM,UAAU,GAAG,KAAK,IAAI,OAAO,UAAU,CAAC;AACrF;AAAA,IACF;AACA,QAAI,OAAO,UAAU;AACnB,WAAK,KAAK,EAAE,KAAK,OAAO,OAAO,KAAK,GAAG,MAAM,SAAS,GAAG,KAAK,IAAI,OAAO,WAAW,CAAC;AACrF;AAAA,IACF;AAEA,UAAM,SACJ,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,IACtD,OAAO,QAAQ,KAAK,EAAE,IAAI,CAAC,CAAC,MAAM,GAAG,OAAO,EAAE,KAAK,GAAG,GAAG,IAAI,IAAI,IAAI,IAAI,EAAE,IAC3E,CAAC,EAAE,KAAK,KAAK,MAAM,CAAC;AAC1B,eAAW,QAAQ,QAAQ;AACzB,WAAK,KAAK,EAAE,KAAK,KAAK,KAAK,OAAO,OAAO,KAAK,GAAG,GAAG,MAAM,YAAY,OAAO,YAAY,CAAC;AAAA,IAC5F;AAAA,EACF;AAEA,SAAO;AACT;;;ACpMA,SAAS,UAAa,MAAS,OAAmB;AAChD,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO;AAClC,MAAI,CAAC,cAAc,IAAI,EAAG,QAAO;AACjC,QAAM,MAA+B,EAAE,GAAG,KAAK;AAC/C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,GAAG,IAAI,cAAc,KAAK,IAAI,UAAU,IAAI,GAAG,GAAG,KAAK,IAAI;AAAA,EACjE;AACA,SAAO;AACT;AAGA,SAAS,UAAU,OAAgB,SAAS,IAAc;AACxD,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO,SAAS,CAAC,MAAM,IAAI,CAAC;AACvD,SAAO,OAAO,QAAQ,KAAK,EAAE;AAAA,IAAQ,CAAC,CAAC,KAAK,KAAK,MAC/C,UAAU,OAAO,SAAS,GAAG,MAAM,IAAI,GAAG,KAAK,GAAG;AAAA,EACpD;AACF;AAGA,SAAS,OAAO,QAA4B;AAC1C,SAAO,CAAC,GAAG,IAAI,IAAI,MAAM,CAAC;AAC5B;AAUA,SAAS,kBAAkB,WAA6B;AACtD,MAAI,CAAC,cAAc,SAAS,KAAK,EAAE,WAAW,WAAY,QAAO;AACjE,QAAM,EAAE,OAAO,SAAS,GAAG,KAAK,IAAI;AACpC,UAAQ,OAAO;AAAA,IACb;AAAA,EAGF;AACA,SAAO;AACT;AAGO,SAAS,qBAAqB,QAA6B,CAAC,GAAyB;AAC1F,QAAM,WAA2B;AAAA,IAC/B,GAAG;AAAA,IACH,OAAO,OAAO,CAAC,GAAG,SAAS,OAAO,GAAI,MAAM,SAAS,CAAC,CAAE,CAAC;AAAA,IACzD,UAAU,EAAE,GAAG,SAAS,UAAU,GAAI,MAAM,YAAY,CAAC,EAAG;AAAA,EAC9D;AACA,QAAM,YAAY,kBAAkB,MAAM,SAAS;AAEnD,SAAO;AAAA,IACL,GAAG,UAAU,UAAU,SAAS;AAAA,IAChC,MAAM,MAAM,QAAQ;AAAA,IACpB,OAAO,UAAU,SAAS,EAAE,KAAK;AAAA,EACnC;AACF;","names":[]}