@hublo/sentinel 1.5.0-alpha.2 → 1.5.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,19 +53,26 @@ import {
53
53
  selfCommand,
54
54
  splitToolArgs,
55
55
  walk
56
- } from "./chunk-E3MYVYEZ.js";
56
+ } from "./chunk-KLEXJFJ6.js";
57
57
  import {
58
58
  DEFAULT_MAX_DIAGNOSTICS,
59
+ WORKSPACE_ROOT_MARKER,
59
60
  findWorkspaceRoot,
60
61
  moduleName,
61
62
  readOwnPackage,
62
63
  readProjectPackageJson
63
- } from "./chunk-KMKQDGI6.js";
64
+ } from "./chunk-YQRDKWSH.js";
64
65
  import {
65
66
  REPORT_DIR,
67
+ SCRIPTS,
68
+ SHAPES,
69
+ TARGET,
70
+ defaults,
66
71
  defineStaticAnalysis,
67
- isPlainObject
68
- } from "./chunk-GJGZRXM3.js";
72
+ extensionsOf,
73
+ isPlainObject,
74
+ overridableSettings
75
+ } from "./chunk-AYEOGJQR.js";
69
76
  import {
70
77
  parseJsonc
71
78
  } from "./chunk-QXFCZON7.js";
@@ -178,10 +185,10 @@ import { join, relative } from "node:path";
178
185
  var DEPRECATED_NX_BUILDER = "@nx/webpack:webpack";
179
186
  var TRANSLATABLE_WEBPACK_CONFIG = "tools/webpack-configs/update.webpack.config.js";
180
187
  var readProjectJson = (cwd) => {
181
- const path = join(cwd, "project.json");
182
- if (!existsSync(path)) return void 0;
188
+ const path2 = join(cwd, "project.json");
189
+ if (!existsSync(path2)) return void 0;
183
190
  try {
184
- return JSON.parse(readFileSync(path, "utf8"));
191
+ return JSON.parse(readFileSync(path2, "utf8"));
185
192
  } catch {
186
193
  return void 0;
187
194
  }
@@ -1124,10 +1131,10 @@ function buildStamp(cwd) {
1124
1131
  return Date.now();
1125
1132
  }
1126
1133
  }
1127
- function writtenSince(path, startedAt) {
1128
- if (!existsSync5(path)) return false;
1134
+ function writtenSince(path2, startedAt) {
1135
+ if (!existsSync5(path2)) return false;
1129
1136
  try {
1130
- return statSync(path).mtimeMs >= startedAt;
1137
+ return statSync(path2).mtimeMs >= startedAt;
1131
1138
  } catch {
1132
1139
  return true;
1133
1140
  }
@@ -1414,11 +1421,11 @@ function toModulePattern(pattern) {
1414
1421
  }
1415
1422
  function inheritedIgnorePatterns(workspaceRoot) {
1416
1423
  if (!workspaceRoot) return [];
1417
- const path = join8(workspaceRoot, ROOT_IGNORE_FILE);
1418
- if (!existsSync6(path)) return [];
1424
+ const path2 = join8(workspaceRoot, ROOT_IGNORE_FILE);
1425
+ if (!existsSync6(path2)) return [];
1419
1426
  let contents;
1420
1427
  try {
1421
- contents = readFileSync6(path, "utf8");
1428
+ contents = readFileSync6(path2, "utf8");
1422
1429
  } catch {
1423
1430
  return [];
1424
1431
  }
@@ -1948,9 +1955,9 @@ function readFailure(output) {
1948
1955
  const line = (detail ?? lines.find((entry) => entry !== "") ?? "").replace(/^x\s*/, "");
1949
1956
  return line.split(/:\s*Failed to parse config/i)[0] ?? line;
1950
1957
  }
1951
- function fingerprint(path) {
1958
+ function fingerprint(path2) {
1952
1959
  try {
1953
- const stat = statSync2(path);
1960
+ const stat = statSync2(path2);
1954
1961
  return `${stat.size}:${stat.mtimeMs}`;
1955
1962
  } catch {
1956
1963
  return void 0;
@@ -3770,11 +3777,11 @@ function downgradedRulesFor(preset) {
3770
3777
  import { existsSync as existsSync9, readFileSync as readFileSync9 } from "node:fs";
3771
3778
  import { isAbsolute, join as join12, resolve as resolve2 } from "node:path";
3772
3779
  function ruleCount(cwd, specifier) {
3773
- const path = isAbsolute(specifier) ? specifier : resolve2(cwd, specifier);
3774
- if (!existsSync9(path)) return void 0;
3780
+ const path2 = isAbsolute(specifier) ? specifier : resolve2(cwd, specifier);
3781
+ if (!existsSync9(path2)) return void 0;
3775
3782
  try {
3776
3783
  const parsed = parseJsonc(
3777
- readFileSync9(path, "utf8"),
3784
+ readFileSync9(path2, "utf8"),
3778
3785
  specifier
3779
3786
  );
3780
3787
  return Object.keys(parsed.rules ?? {}).length;
@@ -3870,7 +3877,7 @@ function readRootEslintIgnores(root) {
3870
3877
  return { patterns: patterns.filter((pattern) => pattern.startsWith("**/")), unresolved };
3871
3878
  }
3872
3879
  function readEslintIgnores(cwd) {
3873
- const config = ESLINT_CONFIG_FILES.map((name) => join13(cwd, name)).find((path) => existsSync10(path));
3880
+ const config = ESLINT_CONFIG_FILES.map((name) => join13(cwd, name)).find((path2) => existsSync10(path2));
3874
3881
  if (!config) return { patterns: [], unresolved: [] };
3875
3882
  let source;
3876
3883
  try {
@@ -4146,12 +4153,12 @@ function plan2(context) {
4146
4153
  const ignores = readEslintIgnores(context.cwd);
4147
4154
  const rootIgnores = readRootEslintIgnores(findWorkspaceRoot(context.cwd));
4148
4155
  const bare = (pattern) => pattern.replace(/^\*\*\//, "").replace(/\/\*\*$/, "");
4149
- const defaults = new Set(DEFAULT_IGNORE_PATTERNS.map(bare));
4156
+ const defaults2 = new Set(DEFAULT_IGNORE_PATTERNS.map(bare));
4150
4157
  const moduleOwn = [
4151
4158
  ...committedIgnorePatterns(context.cwd),
4152
4159
  ...ignores.patterns,
4153
4160
  ...rootIgnores.patterns
4154
- ].filter((pattern) => !defaults.has(bare(pattern)));
4161
+ ].filter((pattern) => !defaults2.has(bare(pattern)));
4155
4162
  const carried = [.../* @__PURE__ */ new Set([...DEFAULT_IGNORE_PATTERNS, ...moduleOwn])];
4156
4163
  const stub = {
4157
4164
  extends: extendsWithPreset(committedExtends(context.cwd), variant),
@@ -4751,9 +4758,19 @@ function registerLint() {
4751
4758
  }
4752
4759
 
4753
4760
  // src/roles/static-analysis/adapters/jscpd/jscpd.adapter.ts
4754
- import { spawnSync as spawnSync5 } from "node:child_process";
4755
- import { existsSync as existsSync14, mkdirSync, readFileSync as readFileSync14, writeFileSync as writeFileSync4 } from "node:fs";
4756
- import { join as join19 } from "node:path";
4761
+ import { spawnSync as spawnSync6 } from "node:child_process";
4762
+ import { existsSync as existsSync16, mkdirSync as mkdirSync2, readFileSync as readFileSync17, writeFileSync as writeFileSync6 } from "node:fs";
4763
+ import { join as join22 } from "node:path";
4764
+ import { globSync as globSync4 } from "tinyglobby";
4765
+
4766
+ // src/roles/static-analysis/complexity/scan.ts
4767
+ import { readFileSync as readFileSync14 } from "node:fs";
4768
+ import { globSync } from "tinyglobby";
4769
+
4770
+ // src/roles/static-analysis/adapters/jscpd/tool-config.ts
4771
+ import { existsSync as existsSync14, mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
4772
+ import { tmpdir } from "node:os";
4773
+ import { join as join18 } from "node:path";
4757
4774
 
4758
4775
  // src/roles/static-analysis/configs/resolve.ts
4759
4776
  import { existsSync as existsSync13 } from "node:fs";
@@ -4761,12 +4778,12 @@ import { join as join17 } from "node:path";
4761
4778
  import { pathToFileURL } from "node:url";
4762
4779
  var STATIC_CONFIG_FILE = "static-analysis.config.mjs";
4763
4780
  async function loadStaticConfig(cwd) {
4764
- const path = join17(cwd, STATIC_CONFIG_FILE);
4765
- if (!existsSync13(path)) {
4781
+ const path2 = join17(cwd, STATIC_CONFIG_FILE);
4782
+ if (!existsSync13(path2)) {
4766
4783
  return { config: defineStaticAnalysis(), from: null, unreadable: null };
4767
4784
  }
4768
4785
  try {
4769
- const loaded = await import(pathToFileURL(path).href);
4786
+ const loaded = await import(pathToFileURL(path2).href);
4770
4787
  return { config: adopt(loaded.default), from: STATIC_CONFIG_FILE, unreadable: null };
4771
4788
  } catch (error) {
4772
4789
  return {
@@ -4784,21 +4801,789 @@ function adopt(exported) {
4784
4801
  return defineStaticAnalysis(exported);
4785
4802
  }
4786
4803
 
4804
+ // src/roles/static-analysis/adapters/jscpd/tool-config.ts
4805
+ var OWN_ARTEFACTS = [`**/${STATIC_CONFIG_FILE}`, `**/${REPORT_DIR}/**`];
4806
+ function testGlobs(config) {
4807
+ const relative5 = config.duplication.tests;
4808
+ return {
4809
+ ignore: relative5.map((glob) => `**/${glob}`),
4810
+ // A one-item brace group is avoided rather than trusted: nothing guarantees `{x}` expands.
4811
+ pattern: relative5.length === 1 ? `**/${relative5[0]}` : `**/{${relative5.join(",")}}`
4812
+ };
4813
+ }
4814
+ function exclusions(config, scope = "tests") {
4815
+ const tests = scope === "product" ? testGlobs(config).ignore : [];
4816
+ return [.../* @__PURE__ */ new Set([...config.ignore, ...OWN_ARTEFACTS, ...tests])];
4817
+ }
4818
+ function measuredRoots(cwd) {
4819
+ return existsSync14(join18(cwd, "src")) ? ["src"] : ["."];
4820
+ }
4821
+ function fileSettings(config, scope, extraEntries = []) {
4822
+ return {
4823
+ // The detection window. Stated rather than defaulted, because it decides what is invisible.
4824
+ minTokens: config.duplication.minTokens,
4825
+ minLines: config.duplication.minLines,
4826
+ // `format` is jscpd's name for what we call `languages`. Naming `typescript` alone would
4827
+ // leave out `.tsx`, which is a separate language to it: 300 files of 523 on front/console.
4828
+ format: config.languages,
4829
+ ignore: exclusions(config, scope),
4830
+ // A scan that analyses nothing otherwise exits 0 with only a warning, which reads as a pass.
4831
+ failOnEmpty: true,
4832
+ // The dead-code engine keeps its own section, and its own `minLines`: jscpd's documentation is
4833
+ // explicit that the top-level one is about clones and is never carried into a dead-code run.
4834
+ deadCode: {
4835
+ // ⛔ The module's declared patterns PLUS the files another module imports. Seeding the walk
4836
+ // rather than replacing it keeps what it does well, transitivity: a file reachable only
4837
+ // through a dead one stays dead. Without these seeds a library has no entry point at all, so
4838
+ // the walk reports "the module does not import itself", true of every library: 87 files on
4839
+ // `libs/nest/auth` against 12 that nothing anywhere imports. See `workspace/consumers.ts`.
4840
+ entry: [...config.entry, ...extraEntries],
4841
+ minConfidence: config.deadCode.minConfidence,
4842
+ categories: config.deadCode.categories
4843
+ },
4844
+ // The escape hatch, last so it wins, and empty unless a module asked for it.
4845
+ ...config.tools.jscpd
4846
+ };
4847
+ }
4848
+ function duplicationRun(config, paths, scope) {
4849
+ const dir = mkdtempSync(join18(tmpdir(), "sentinel-static-"));
4850
+ const configPath = join18(dir, "jscpd.json");
4851
+ writeFileSync3(configPath, `${JSON.stringify(fileSettings(config, scope), null, 2)}
4852
+ `, "utf8");
4853
+ const args = [
4854
+ "--config",
4855
+ configPath,
4856
+ // The test pass SELECTS its files; the product pass excludes them through `ignore` above. A
4857
+ // flag and not a config key, because `--pattern` is the only documented way in and it takes
4858
+ // exactly one glob, braces expanded. Verified: 478 product files + 87 test files = the 565 of
4859
+ // a blended scan on `apps/front/console`.
4860
+ ...scope === "tests" ? ["--pattern", testGlobs(config).pattern] : [],
4861
+ // Always json, and only json, whatever shape the caller asked for. The three shapes are
4862
+ // rendered by this role from the figures, not by the tool: one report lives under
4863
+ // `static-analysis`, assembled from several passes, so a per-pass console table from the tool
4864
+ // would be a fourth voice saying the same numbers differently. `--output` below is jscpd's
4865
+ // output DIRECTORY, not our `--output` shape.
4866
+ "--reporters",
4867
+ "json",
4868
+ "--output",
4869
+ dir,
4870
+ "--no-tips",
4871
+ ...paths
4872
+ ];
4873
+ return { args, rawReportDir: dir, dispose: () => rmSync3(dir, { force: true, recursive: true }) };
4874
+ }
4875
+ function deadCodeRun(config, paths, extraEntries = []) {
4876
+ const dir = mkdtempSync(join18(tmpdir(), "sentinel-static-"));
4877
+ const configPath = join18(dir, "jscpd.json");
4878
+ writeFileSync3(
4879
+ configPath,
4880
+ `${JSON.stringify(fileSettings(config, "dead-code", extraEntries), null, 2)}
4881
+ `,
4882
+ "utf8"
4883
+ );
4884
+ return {
4885
+ args: [
4886
+ "--config",
4887
+ configPath,
4888
+ "--dead-code",
4889
+ "--reporters",
4890
+ "json",
4891
+ "--output",
4892
+ dir,
4893
+ "--no-tips",
4894
+ ...paths
4895
+ ],
4896
+ rawReportDir: dir,
4897
+ dispose: () => rmSync3(dir, { force: true, recursive: true })
4898
+ };
4899
+ }
4900
+ var RAW_REPORT = {
4901
+ duplication: "jscpd-report.json",
4902
+ "dead-code": "basta-report.json"
4903
+ };
4904
+
4905
+ // src/roles/static-analysis/complexity/cyclomatic.ts
4906
+ import { parseSync as parseSync2 } from "oxc-parser";
4907
+ var FUNCTION_NODES = /* @__PURE__ */ new Set([
4908
+ "FunctionDeclaration",
4909
+ "FunctionExpression",
4910
+ "ArrowFunctionExpression"
4911
+ ]);
4912
+ var DECISION_NODES = /* @__PURE__ */ new Set([
4913
+ "IfStatement",
4914
+ "ForStatement",
4915
+ "ForInStatement",
4916
+ "ForOfStatement",
4917
+ "WhileStatement",
4918
+ "DoWhileStatement",
4919
+ "CatchClause",
4920
+ "ConditionalExpression"
4921
+ ]);
4922
+ var DECISION_OPERATORS = /* @__PURE__ */ new Set(["&&", "||", "??"]);
4923
+ var DECISION_ASSIGNMENTS = /* @__PURE__ */ new Set(["&&=", "||=", "??="]);
4924
+ function walk2(node, visit, stopAt) {
4925
+ if (!node || typeof node !== "object") return;
4926
+ if (Array.isArray(node)) {
4927
+ for (const child of node) walk2(child, visit, stopAt);
4928
+ return;
4929
+ }
4930
+ const current = node;
4931
+ if (typeof current.type === "string") {
4932
+ if (stopAt(current)) return;
4933
+ visit(current);
4934
+ }
4935
+ for (const [field, value] of Object.entries(current)) {
4936
+ if (field === "type" || field === "start" || field === "end") continue;
4937
+ walk2(value, visit, stopAt);
4938
+ }
4939
+ }
4940
+ function complexityOf(fn) {
4941
+ let count = 1;
4942
+ for (const parameter of Array.isArray(fn.params) ? fn.params : []) {
4943
+ walk2(
4944
+ parameter,
4945
+ (node) => {
4946
+ if (node.type === "AssignmentPattern") count += 1;
4947
+ },
4948
+ (node) => FUNCTION_NODES.has(node.type ?? "")
4949
+ );
4950
+ }
4951
+ const body = fn.body ?? fn;
4952
+ let root = true;
4953
+ walk2(
4954
+ body,
4955
+ (node) => {
4956
+ if (DECISION_NODES.has(node.type ?? "")) count += 1;
4957
+ if (node.type === "AssignmentPattern") count += 1;
4958
+ if (node.type === "SwitchCase" && node.test !== null && node.test !== void 0) count += 1;
4959
+ if (node.type === "LogicalExpression" && DECISION_OPERATORS.has(String(node.operator ?? ""))) {
4960
+ count += 1;
4961
+ }
4962
+ if (node.type === "AssignmentExpression" && DECISION_ASSIGNMENTS.has(String(node.operator ?? ""))) {
4963
+ count += 1;
4964
+ }
4965
+ if ((node.type === "MemberExpression" || node.type === "CallExpression") && node.optional === true) {
4966
+ count += 1;
4967
+ }
4968
+ },
4969
+ (node) => {
4970
+ if (root) {
4971
+ root = false;
4972
+ return false;
4973
+ }
4974
+ return FUNCTION_NODES.has(node.type ?? "");
4975
+ }
4976
+ );
4977
+ return count;
4978
+ }
4979
+ function nameOf(fn, parent) {
4980
+ const own = fn.id?.name;
4981
+ if (own) return own;
4982
+ const key = parent?.id?.name ?? parent?.key?.name;
4983
+ if (key) return key;
4984
+ return "(anonymous)";
4985
+ }
4986
+ function lineAt(source, offset) {
4987
+ if (offset === void 0) return 1;
4988
+ let line = 1;
4989
+ for (let index = 0; index < offset && index < source.length; index += 1) {
4990
+ if (source[index] === "\n") line += 1;
4991
+ }
4992
+ return line;
4993
+ }
4994
+ function complexityOfFile(path2, source) {
4995
+ const parsed = parseSync2(path2, source);
4996
+ if (parsed.errors.length > 0) return [];
4997
+ const found = [];
4998
+ const parents = /* @__PURE__ */ new Map();
4999
+ walk2(
5000
+ parsed.program,
5001
+ (node) => {
5002
+ for (const value of Object.values(node)) {
5003
+ if (value && typeof value === "object" && !Array.isArray(value)) {
5004
+ parents.set(value, node);
5005
+ }
5006
+ }
5007
+ if (!FUNCTION_NODES.has(node.type ?? "")) return;
5008
+ found.push({
5009
+ name: nameOf(node, parents.get(node)),
5010
+ line: lineAt(source, node.start),
5011
+ complexity: complexityOf(node)
5012
+ });
5013
+ },
5014
+ () => false
5015
+ );
5016
+ return found;
5017
+ }
5018
+
5019
+ // src/roles/static-analysis/complexity/scan.ts
5020
+ var RISK_BANDS = [
5021
+ { metric: "complexityModerate", from: 11, to: 20, label: "moderate risk" },
5022
+ { metric: "complexityHigh", from: 21, to: 50, label: "high risk" },
5023
+ { metric: "complexityUntestable", from: 51, to: Infinity, label: "untestable" }
5024
+ ];
5025
+ var MCCABE_REFERENCE = RISK_BANDS[0].from;
5026
+ function productFiles(cwd, config) {
5027
+ const extensions = extensionsOf(config.languages).map((extension) => extension.slice(1)).join(",");
5028
+ return globSync(
5029
+ measuredRoots(cwd).map(
5030
+ (root) => root === "." ? `**/*.{${extensions}}` : `${root}/**/*.{${extensions}}`
5031
+ ),
5032
+ {
5033
+ cwd,
5034
+ ignore: exclusions(config, "product"),
5035
+ dot: false
5036
+ }
5037
+ );
5038
+ }
5039
+ function scanComplexity(cwd, config) {
5040
+ const files = productFiles(cwd, config);
5041
+ const all2 = [];
5042
+ for (const path2 of files) {
5043
+ let source;
5044
+ try {
5045
+ source = readFileSync14(`${cwd}/${path2}`, "utf8");
5046
+ } catch {
5047
+ continue;
5048
+ }
5049
+ for (const fn of complexityOfFile(path2, source)) all2.push({ ...fn, path: path2 });
5050
+ }
5051
+ all2.sort((left, right) => right.complexity - left.complexity);
5052
+ const bands = {};
5053
+ for (const band of RISK_BANDS) {
5054
+ bands[band.metric] = all2.filter(
5055
+ (fn) => fn.complexity >= band.from && fn.complexity <= band.to
5056
+ ).length;
5057
+ }
5058
+ return {
5059
+ files: files.length,
5060
+ functions: all2.length,
5061
+ max: all2[0]?.complexity ?? 0,
5062
+ complex: all2.filter((fn) => fn.complexity >= MCCABE_REFERENCE),
5063
+ bands
5064
+ };
5065
+ }
5066
+
5067
+ // src/roles/static-analysis/coverage.ts
5068
+ import path from "node:path";
5069
+ import { globSync as globSync2 } from "tinyglobby";
5070
+ var UNREADABLE_EXTENSIONS = [".svelte", ".vue", ".astro"];
5071
+ function hasUnreadableCode(coverage) {
5072
+ return coverage.unread > 0;
5073
+ }
5074
+ function coverageOf(cwd, config) {
5075
+ const ignore = exclusions(config, "product");
5076
+ const readable = extensionsOf(config.languages).map((extension) => extension.slice(1)).join(",");
5077
+ const roots = measuredRoots(cwd);
5078
+ const under = (extensions) => roots.map((root) => root === "." ? `**/*.{${extensions}}` : `${root}/**/*.{${extensions}}`);
5079
+ const read = readable ? globSync2(under(readable), { cwd, ignore, dot: false }).length : 0;
5080
+ const unreadable = UNREADABLE_EXTENSIONS.map((extension) => extension.slice(1)).join(",");
5081
+ const found = globSync2(under(unreadable), { cwd, ignore, dot: false });
5082
+ const total = read + found.length;
5083
+ return {
5084
+ read,
5085
+ unread: found.length,
5086
+ share: total === 0 ? 0 : found.length / total,
5087
+ extensions: [...new Set(found.map((file) => path.extname(file)))].sort()
5088
+ };
5089
+ }
5090
+ function unreadableNote(coverage) {
5091
+ const percent = Math.round(coverage.share * 100);
5092
+ const kinds = coverage.extensions.join(", ");
5093
+ return `${coverage.unread} product file(s) in ${kinds} cannot be parsed, ${percent}% of this module: no graph and no dead-code figure is reported, because a file the role cannot open still imports the files it imports`;
5094
+ }
5095
+
5096
+ // src/roles/static-analysis/graph/picture.ts
5097
+ import forceAtlas2 from "graphology-layout-forceatlas2";
5098
+ import circular from "graphology-layout/circular.js";
5099
+ import renderSvg from "graphology-svg";
5100
+ var ITERATIONS = 300;
5101
+ var LABELLED = 10;
5102
+ var BLANK = " ";
5103
+ var SIZE = { width: 1600, height: 1200, margin: 30 };
5104
+ function hueOf(community) {
5105
+ return `hsl(${community * 137 % 360} 65% 52%)`;
5106
+ }
5107
+ function writeGraphPicture(graph, communities, path2) {
5108
+ const drawn = graph.copy();
5109
+ circular.assign(drawn);
5110
+ forceAtlas2.assign(drawn, { iterations: ITERATIONS, settings: forceAtlas2.inferSettings(drawn) });
5111
+ const named = new Set(
5112
+ drawn.nodes().sort((left, right) => drawn.degree(right) - drawn.degree(left) || left.localeCompare(right)).slice(0, LABELLED)
5113
+ );
5114
+ drawn.forEachNode((node) => {
5115
+ drawn.setNodeAttribute(node, "size", 2 + Math.sqrt(drawn.degree(node)));
5116
+ drawn.setNodeAttribute(node, "color", hueOf(communities[node] ?? 0));
5117
+ drawn.setNodeAttribute(node, "label", named.has(node) ? node.split("/").pop() ?? node : BLANK);
5118
+ });
5119
+ return new Promise((resolve5) => {
5120
+ renderSvg(drawn, path2, SIZE, () => resolve5());
5121
+ });
5122
+ }
5123
+
5124
+ // src/roles/static-analysis/graph/scan.ts
5125
+ import Graph from "graphology";
5126
+ import louvain from "graphology-communities-louvain";
5127
+ import { stronglyConnectedComponents } from "graphology-components";
5128
+ import { degreeCentrality } from "graphology-metrics/centrality/degree.js";
5129
+ import { density } from "graphology-metrics/graph/density.js";
5130
+
5131
+ // src/roles/static-analysis/graph/imports.ts
5132
+ import { readFileSync as readFileSync15 } from "node:fs";
5133
+ import { dirname, join as join19 } from "node:path";
5134
+ import { parseSync as parseSync3 } from "oxc-parser";
5135
+ import { globSync as globSync3 } from "tinyglobby";
5136
+ var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
5137
+ var TS_BEHIND_JS = {
5138
+ ".js": [".ts", ".tsx"],
5139
+ ".mjs": [".mts"],
5140
+ ".cjs": [".cts"]
5141
+ };
5142
+ function specifiersOf(program) {
5143
+ const found = [];
5144
+ const walk3 = (node) => {
5145
+ if (!node || typeof node !== "object") return;
5146
+ if (Array.isArray(node)) {
5147
+ for (const child of node) walk3(child);
5148
+ return;
5149
+ }
5150
+ const current = node;
5151
+ const source = current.source?.value;
5152
+ switch (current.type) {
5153
+ // `import x from './a'`, and the two re-export forms, which are imports that also publish.
5154
+ case "ImportDeclaration":
5155
+ case "ExportNamedDeclaration":
5156
+ case "ExportAllDeclaration":
5157
+ if (typeof source === "string") found.push(source);
5158
+ break;
5159
+ // `import('./a')`, the lazy route and the split bundle.
5160
+ case "ImportExpression":
5161
+ if (typeof source === "string") found.push(source);
5162
+ break;
5163
+ case "CallExpression": {
5164
+ const argument = current.arguments?.[0]?.value;
5165
+ if (current.callee?.name === "require" && typeof argument === "string") found.push(argument);
5166
+ break;
5167
+ }
5168
+ default:
5169
+ break;
5170
+ }
5171
+ for (const [field, value] of Object.entries(current)) {
5172
+ if (field === "type" || field === "start" || field === "end") continue;
5173
+ walk3(value);
5174
+ }
5175
+ };
5176
+ walk3(program);
5177
+ return found;
5178
+ }
5179
+ function resolveSpecifier(from, specifier, known) {
5180
+ if (!specifier.startsWith(".")) return void 0;
5181
+ const base = join19(dirname(from), specifier);
5182
+ if (known.has(base)) return base;
5183
+ for (const extension of EXTENSIONS) {
5184
+ if (known.has(base + extension)) return base + extension;
5185
+ }
5186
+ const written = EXTENSIONS.find((extension) => base.endsWith(extension));
5187
+ for (const extension of TS_BEHIND_JS[written ?? ""] ?? []) {
5188
+ const swapped = base.slice(0, -(written ?? "").length) + extension;
5189
+ if (known.has(swapped)) return swapped;
5190
+ }
5191
+ for (const extension of EXTENSIONS) {
5192
+ const index = join19(base, `index${extension}`);
5193
+ if (known.has(index)) return index;
5194
+ }
5195
+ return void 0;
5196
+ }
5197
+ function buildImportGraph(cwd, config) {
5198
+ const extensions = extensionsOf(config.languages).map((extension) => extension.slice(1)).join(",");
5199
+ const files = globSync3(
5200
+ measuredRoots(cwd).map(
5201
+ (root) => root === "." ? `**/*.{${extensions}}` : `${root}/**/*.{${extensions}}`
5202
+ ),
5203
+ {
5204
+ cwd,
5205
+ ignore: exclusions(config, "product"),
5206
+ dot: false
5207
+ }
5208
+ );
5209
+ const known = new Set(files);
5210
+ const tree = {};
5211
+ for (const file of files) {
5212
+ tree[file] = [];
5213
+ let parsed;
5214
+ try {
5215
+ parsed = parseSync3(file, readFileSync15(join19(cwd, file), "utf8"));
5216
+ } catch {
5217
+ continue;
5218
+ }
5219
+ if (parsed.errors.length > 0) continue;
5220
+ for (const specifier of specifiersOf(parsed.program)) {
5221
+ const target = resolveSpecifier(file, specifier, known);
5222
+ if (target && target !== file && !tree[file].includes(target)) tree[file].push(target);
5223
+ }
5224
+ }
5225
+ return tree;
5226
+ }
5227
+
5228
+ // src/roles/static-analysis/graph/scan.ts
5229
+ var MOST_IMPORTED_LISTED = 10;
5230
+ function seeded() {
5231
+ let state = 42;
5232
+ return () => {
5233
+ state = (state * 1664525 + 1013904223) % 4294967296;
5234
+ return state / 4294967296;
5235
+ };
5236
+ }
5237
+ function toGraph(tree) {
5238
+ const graph = new Graph({ type: "directed", allowSelfLoops: false });
5239
+ for (const file of Object.keys(tree)) if (!graph.hasNode(file)) graph.addNode(file);
5240
+ for (const [from, targets2] of Object.entries(tree)) {
5241
+ for (const to of targets2) {
5242
+ if (!graph.hasNode(to)) graph.addNode(to);
5243
+ if (from !== to && !graph.hasDirectedEdge(from, to)) graph.addDirectedEdge(from, to);
5244
+ }
5245
+ }
5246
+ return graph;
5247
+ }
5248
+ function scanGraph(cwd, config) {
5249
+ const graph = toGraph(buildImportGraph(cwd, config));
5250
+ const cycles = stronglyConnectedComponents(graph).filter((component) => component.length > 1).map((files) => ({ files })).sort((left, right) => right.files.length - left.files.length);
5251
+ const partition = graph.size > 0 ? louvain.detailed(graph, { resolution: config.graph.resolution, rng: seeded() }) : { count: graph.order, modularity: void 0, communities: {} };
5252
+ const centrality = degreeCentrality(graph);
5253
+ const mostImported = graph.nodes().map((path2) => {
5254
+ const afferent = graph.inDegree(path2);
5255
+ const efferent = graph.outDegree(path2);
5256
+ return {
5257
+ path: path2,
5258
+ importedBy: afferent,
5259
+ dependsOn: efferent,
5260
+ // Both zero cannot reach here, the filter below drops them, but the guard stays: the
5261
+ // formula is the only place in this pass that divides.
5262
+ instability: afferent + efferent === 0 ? 1 : efferent / (afferent + efferent),
5263
+ centrality: centrality[path2] ?? 0
5264
+ };
5265
+ }).filter((file) => file.importedBy > 0).sort(
5266
+ (left, right) => right.importedBy - left.importedBy || left.path.localeCompare(right.path)
5267
+ ).slice(0, MOST_IMPORTED_LISTED);
5268
+ return {
5269
+ files: graph.order,
5270
+ edges: graph.size,
5271
+ density: graph.order > 1 ? density(graph) : 0,
5272
+ cycles,
5273
+ filesInCycles: cycles.reduce((total, cycle) => total + cycle.files.length, 0),
5274
+ communities: partition.count,
5275
+ modularity: partition.modularity,
5276
+ mostImported,
5277
+ graph,
5278
+ communityOf: partition.communities
5279
+ };
5280
+ }
5281
+
5282
+ // src/roles/static-analysis/maintainability/size-rules.ts
5283
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
5284
+ import { tmpdir as tmpdir2 } from "node:os";
5285
+ import { join as join20 } from "node:path";
5286
+ var PUBLISHED_THRESHOLDS = {
5287
+ complexity: 20,
5288
+ "max-depth": 4,
5289
+ "max-lines": 300,
5290
+ "max-lines-per-function": 50,
5291
+ "max-statements": 10,
5292
+ "max-params": 3,
5293
+ "max-nested-callbacks": 10,
5294
+ "max-classes-per-file": 1
5295
+ };
5296
+ var CATEGORIES = [
5297
+ "correctness",
5298
+ "suspicious",
5299
+ "pedantic",
5300
+ "perf",
5301
+ "style",
5302
+ "restriction",
5303
+ "nursery"
5304
+ ];
5305
+ function sizeRulesConfig() {
5306
+ return {
5307
+ plugins: [],
5308
+ categories: Object.fromEntries(CATEGORIES.map((category) => [category, "off"])),
5309
+ // ⛔ `warn` and not `error`, for the reason `tool-config.ts` gives about jscpd's `threshold`:
5310
+ // at `error` oxlint exits 1 as soon as one file breaches, which is indistinguishable from the
5311
+ // binary failing to run. Measured on `libs/nest/auth`, same config otherwise: `error` exits 1,
5312
+ // `warn` exits 0, and both report the same 18 diagnostics. So the tool measures, this role
5313
+ // judges, and a non-zero exit means the tool failed.
5314
+ rules: Object.fromEntries(
5315
+ Object.entries(PUBLISHED_THRESHOLDS).map(([rule, threshold]) => [rule, ["warn", threshold]])
5316
+ )
5317
+ };
5318
+ }
5319
+ function sizeRulesRun(paths, ignore) {
5320
+ const dir = mkdtempSync2(join20(tmpdir2(), "sentinel-size-"));
5321
+ const configPath = join20(dir, "oxlintrc.json");
5322
+ writeFileSync4(configPath, `${JSON.stringify(sizeRulesConfig(), null, 2)}
5323
+ `, "utf8");
5324
+ return {
5325
+ // `--format json` because the console shape is rendered by this role from the figures: a
5326
+ // per-pass table from the tool would be a second voice saying the same numbers differently.
5327
+ // The exclusions are the SAME list the duplication product pass is given, so "the module's
5328
+ // code" means one thing across the whole report.
5329
+ args: [
5330
+ "-c",
5331
+ configPath,
5332
+ ...ignore.flatMap((pattern) => ["--ignore-pattern", pattern]),
5333
+ "--format",
5334
+ "json",
5335
+ ...paths
5336
+ ],
5337
+ dispose: () => rmSync4(dir, { force: true, recursive: true })
5338
+ };
5339
+ }
5340
+ function ruleOf(code) {
5341
+ return /\(([^)]+)\)\s*$/.exec(code)?.[1];
5342
+ }
5343
+ function valueOf(message) {
5344
+ return Number(/\((\d+)\)/.exec(message)?.[1] ?? 0);
5345
+ }
5346
+ function breachesOf(raw) {
5347
+ let parsed;
5348
+ try {
5349
+ parsed = JSON.parse(raw);
5350
+ } catch {
5351
+ return { breaches: [], diagnostics: 0, files: 0 };
5352
+ }
5353
+ const diagnostics = parsed.diagnostics ?? [];
5354
+ const worst = /* @__PURE__ */ new Map();
5355
+ for (const diagnostic of diagnostics) {
5356
+ const rule = ruleOf(diagnostic.code ?? "");
5357
+ const file = diagnostic.filename;
5358
+ if (!rule || !file || !(rule in PUBLISHED_THRESHOLDS)) continue;
5359
+ const value = valueOf(diagnostic.message ?? "");
5360
+ const line = diagnostic.labels?.[0]?.span?.line ?? 0;
5361
+ const key = `${file}\0${rule}`;
5362
+ const seen = worst.get(key);
5363
+ if (!seen || value > seen.value) worst.set(key, { file, rule, value, line });
5364
+ }
5365
+ return {
5366
+ breaches: [...worst.values()],
5367
+ diagnostics: diagnostics.length,
5368
+ files: parsed.number_of_files ?? 0
5369
+ };
5370
+ }
5371
+ function scanSizeRules(cwd, roots, ignore, binary, spawn) {
5372
+ if (!binary) return void 0;
5373
+ const run = sizeRulesRun(roots, ignore);
5374
+ try {
5375
+ const result = spawn(binary, run.args, cwd);
5376
+ if (result.status !== 0) return void 0;
5377
+ return breachesOf(result.stdout);
5378
+ } finally {
5379
+ run.dispose();
5380
+ }
5381
+ }
5382
+
4787
5383
  // src/roles/static-analysis/help/metrics.ts
4788
5384
  var metricsHelp = {
4789
5385
  duplicatedLinesPercent: {
4790
5386
  label: "Duplicated lines",
4791
- what: "The share of lines jscpd found in at least one clone, over the lines it scanned.",
4792
- read: "Higher is worse. It is computed per module, so two modules compare directly; the repository-wide figure is not the average of theirs, because modules differ in size.",
4793
- caveat: "A clone is not automatically a defect. Two screens that genuinely do the same thing are supposed to look alike, and extracting them can couple two features that have no reason to change together. The usual reading is the Rule of Three: a second occurrence is tolerable, a third is the signal to refactor. This share cannot apply that rule for you, because it counts lines, not how many times one fragment occurs.",
5387
+ what: "The share of lines jscpd found in at least one clone, over the lines of the files it was able to scan.",
5388
+ read: "Higher is worse. It is computed per module, so two modules compare directly as long as they measured with the same settings, which is what the settings printed under the figures are for; the repository-wide figure is not the average of theirs, because modules differ in size.",
5389
+ caveat: "A clone is not automatically a defect. Two screens that genuinely do the same thing are supposed to look alike, and extracting them can couple two features that have no reason to change together. The usual reading is the Rule of Three: a second occurrence is tolerable, a third is the signal to refactor. This share cannot apply that rule for you, because it counts lines, not how many times one fragment occurs. The denominator is also narrower than the module: a file shorter than the detection window cannot contain a clone, so the tool leaves it out of both counts, which on one front app is 31 files of 596, or 5%.",
4794
5390
  source: "jscpd, over the token stream rather than the text: for JavaScript and TypeScript the tokens come from the oxc parser, which emits neither whitespace nor comments, so reindenting a block is not a clone and `--mode` measured identical on all three values. Rule of Three: Fowler, Refactoring, crediting Don Roberts."
4795
5391
  },
5392
+ complexityMax: {
5393
+ label: "Complexity, worst function",
5394
+ what: "McCabe's number for the single hardest function of the module's product code.",
5395
+ read: "One number for the module, and the only one of the pair that names a place to go: the function itself is in the list below. Read it against the two published references, 10 and 30, rather than against another module, because a module of 200 functions and one of 2 000 are not comparable on a maximum.",
5396
+ caveat: "It says nothing about how MANY functions are hard, which is what the count beside it is for, and nothing about whether the hardest one is wrong: a parser or a state machine legitimately branches. It is also not what the linter refuses. The `nest` lint preset already fails a build at 30 per function, and the five other presets carry no complexity rule at all, so this figure is the only visibility a React or a tooling module has today.",
5397
+ source: "Computed over the oxc tree by this role, because no tool we have gives it per function: jscpd sums it per FILE, verified on fixtures where ten trivial functions score the same as one function of 10, and escomplex fails to parse 32.6% of one nest service. The counting rule is the common one, ESLint's set of decision points, held against values computed by hand in `tests/static-analysis-cyclomatic.test.ts`."
5398
+ },
5399
+ complexityModerate: {
5400
+ label: "Complexity 11-20, moderate risk",
5401
+ what: "How many functions sit in McCabe's second band, out of how many were measured.",
5402
+ read: "The ordinary band to find something in. A function here is readable and worth a second look when it changes often, not a defect.",
5403
+ caveat: 'A band is a statement about testing effort, not about correctness: McCabe proposed them as the number of independent paths a test suite would have to cover. A parser or a state machine legitimately sits here. \u26D4 Nothing in this role fails on a band, and the line this repository actually refuses is elsewhere: `complexity: ["error", 30]` per function in the `nest` lint preset, which sits inside the NEXT band, and the five other presets carry no complexity rule at all.',
5404
+ source: "McCabe, \"A Complexity Measure\", IEEE TSE SE-2(4), 1976, for the count and for the bands, as published by SonarSource and summarised by Sourcegraph. The counting rule is this role's own, over the oxc tree, and it was held against oxlint's own `complexity` rule on this package, its threshold lowered to 1 so that it reports every function it can: 839 functions over 181 files, 839 identical values, not one disagreement. Compared per FILE as a multiset of values rather than by function name, because oxlint writes six shapes of message and matching on the name silently drops four of them. The 182nd file is `plugins/hublo.js`, which oxlint declines to lint because it is git-ignored, so its 8 functions are unverified rather than contested."
5405
+ },
5406
+ complexityHigh: {
5407
+ label: "Complexity 21-50, high risk",
5408
+ what: "How many functions sit in McCabe's third band.",
5409
+ read: "The band worth acting on, and the one the `nest` lint preset cuts through at 30. A function here needs either a split or a very good reason, and the list below names each one with its line.",
5410
+ caveat: "Same reserve as the band below: it measures paths, not defects. What makes this band different is that it brackets the only complexity line this repository enforces, so a function at 25 is high risk by the published scale and perfectly acceptable to the build, while one at 35 fails a nest build and passes everywhere else.",
5411
+ source: "McCabe, \"A Complexity Measure\", IEEE TSE SE-2(4), 1976, for the count and for the bands, as published by SonarSource and summarised by Sourcegraph. The counting rule is this role's own, over the oxc tree, and it was held against oxlint's own `complexity` rule on this package, its threshold lowered to 1 so that it reports every function it can: 839 functions over 181 files, 839 identical values, not one disagreement. Compared per FILE as a multiset of values rather than by function name, because oxlint writes six shapes of message and matching on the name silently drops four of them. The 182nd file is `plugins/hublo.js`, which oxlint declines to lint because it is git-ignored, so its 8 functions are unverified rather than contested."
5412
+ },
5413
+ complexityUntestable: {
5414
+ label: "Complexity over 50, untestable",
5415
+ what: "How many functions sit in McCabe's top band, the one he called untestable.",
5416
+ read: "Read it as a count of places no test suite can cover exhaustively, and expect it to be zero. Any value here is the first thing to look at in the module.",
5417
+ caveat: "The word is McCabe's and it is about path coverage, not about whether the function works: a 60-path function can be correct and well tested in practice. What it does say is that nobody can enumerate its behaviour, which is what makes a change to it expensive.",
5418
+ source: "McCabe, \"A Complexity Measure\", IEEE TSE SE-2(4), 1976, for the count and for the bands, as published by SonarSource and summarised by Sourcegraph. The counting rule is this role's own, over the oxc tree, and it was held against oxlint's own `complexity` rule on this package, its threshold lowered to 1 so that it reports every function it can: 839 functions over 181 files, 839 identical values, not one disagreement. Compared per FILE as a multiset of values rather than by function name, because oxlint writes six shapes of message and matching on the name silently drops four of them. The 182nd file is `plugins/hublo.js`, which oxlint declines to lint because it is git-ignored, so its 8 functions are unverified rather than contested."
5419
+ },
5420
+ entryPoints: {
5421
+ label: "Entry points",
5422
+ what: "How many files the reachability walk started from, against how many it scanned.",
5423
+ read: "Read it FIRST, before the dead-code share below, because it says whether that share can mean anything. The further it is from the file count, the more the walk had to prove; when the two are equal, nothing could have been found unreachable.",
5424
+ caveat: "\u26A0\uFE0F Two sources feed it, and they answer different questions. The module DECLARES what runs: a framework bootstrap, a lambda handler, the patterns this role ships. And the workspace says what other modules IMPORT from here, which is the only answer a library has, since its consumers live outside and nothing inside it runs. Measured on `libs/nest/auth`, 57 of 137: a handful declared, the rest imported from the 47 projects that depend on it. A count close to the file count is neither good nor bad, it means this pass had little to decide; a count of zero on a library used to mean the walk had nothing to start from, and the share below was then unreadable rather than clean.",
5425
+ source: "jscpd's dead-code statistics, `entryPoints` against `files`. The seeds are the module's own manifest, about fifty framework conventions, the patterns this role declares, and the files another module imports, which come from the workspace index in `workspace/consumers.ts`. The report does not list WHICH files they are, only how many."
5426
+ },
5427
+ deadLinesPercent: {
5428
+ label: "Unreachable lines",
5429
+ what: "The share of scanned lines the reachability walk never reached from an entry point.",
5430
+ read: "Higher is worse, and only once the entry-point count above says the question was decidable. Read it next to the four counts below rather than alone: one number covering files, exports, symbols and imports hides four different kinds of work.",
5431
+ caveat: "Unreachable here means unreachable WITHIN this module, and that is a measured limitation rather than a theoretical one. A library is consumed from outside the scan, so its files look unreachable: on a sample of 12 files reported dead, 9 of `libs/front/components` and 7 of `libs/nest/auth` are cited elsewhere in the repository, one of them in 356 files. Test, fixture and story files are also outside the reachability graph, verified on a fixture, so a file reached only by a story is reported unreachable; it is true of the product and false of the repository, and `libs/front/components` has one such file of 915 lines.",
5432
+ source: "jscpd's dead-code engine, `deadLines` over `totalLines`. The library figures were measured per module with the workspace's own path aliases, and the story case was isolated on a four-file fixture where the only importer was a `.stories.tsx`."
5433
+ },
5434
+ unusedFiles: {
5435
+ label: "Unreachable files",
5436
+ what: "Files no entry point reaches, each with a confidence the tool assigns.",
5437
+ read: "The most actionable of the four, and the one to start from: a whole file nothing reaches is a decision, not a cleanup. Open the ones with the highest confidence first.",
5438
+ caveat: "Three classes hide in this count and only one of them is a deletion, so read the reason the tool gives before acting: a file nothing reaches at all, a file only a spec or a story reaches, which is alive and breaks if removed, and an entry point of a framework this role has not declared. The tool labels the second itself as `used-only-by-tests`, and that is not a corner: measured over 1 231 findings on this repository, 659 of them carry it, 53.5%. A finding with no reason given is the one to open first.",
5439
+ source: "jscpd dead-code findings of category `unused-file`, with the engine's own `reasons` and `message` carried through. The 53.5% was counted over every dead-code report produced on this repository; the third class was separated by hand on `apps/front/console`, where three of five findings were its TanStack bootstrap."
5440
+ },
5441
+ unusedExports: {
5442
+ label: "Unused exports",
5443
+ what: "Exported names nothing in this module imports.",
5444
+ read: "Low on an application, where an export with no local consumer is usually a leftover. On a library it says almost nothing, for the reason the share above gives.",
5445
+ caveat: "Exports of a file the tool treats as an entry point are NOT counted, deliberately: the tool's own words are that they are usually a public API. So this number is about internal exports, and a module whose every file is an entry point reports zero of them whatever its state.",
5446
+ source: "jscpd dead-code findings of category `unused-export`."
5447
+ },
5448
+ unusedSymbols: {
5449
+ label: "Unused symbols",
5450
+ what: "Declared names, not exported, that nothing in their own file uses.",
5451
+ read: "A local cleanup rather than a design signal, and usually a small count.",
5452
+ caveat: "The tool scores every finding and names what lowered the score, a file calling `eval`, an unrecognised decorator, a name that appears inside a string literal. Measured on sentinel itself, a confidence floor of 60 yields 22 symbols, 90 yields none, so this count moves with that floor and is printed with it.",
5453
+ source: "jscpd dead-code findings of category `unused-symbol`, above the confidence floor."
5454
+ },
5455
+ unusedImports: {
5456
+ label: "Unused imports",
5457
+ what: "Imported names the importing file never uses.",
5458
+ read: "Mostly a lint concern, and it is here because for front applications the linter is not asking. Treat a high count as a lint gap rather than as dead code.",
5459
+ caveat: "\u26A0\uFE0F 85% of these findings, measured over every module scanned on this pass, are `import React from 'react'`, which the modern JSX transform makes unnecessary and the classic one requires, so whether it is removable depends on the module's `jsx` setting and not on this count. Worse for acting on it line by line: `import React, { useState } from 'react'` is reported with the name `React`, and deleting the line takes `useState` with it. It overlaps `typescript/no-unused-vars`, which this repository's `nest`, `react-lib`, `svelte` and `tools` lint presets enable and its `react` preset turns off, which is the only reason the figure is not redundant for front applications.",
5460
+ source: "jscpd dead-code findings of category `unused-import`; the 85% and the preset states were measured on this repository's lint presets and the day's scans."
5461
+ },
5462
+ duplicatedLinesPercentTests: {
5463
+ label: "Duplicated lines (tests)",
5464
+ what: "The same share, measured over this module's test files alone.",
5465
+ read: "Read it DIFFERENTLY from the figure above rather than as a worse version of it. Repetition in a spec is often deliberate: a test that spells out its own setup can be read on its own, and factoring that setup into a helper is what makes a failing test hard to diagnose. The figure is here because its size is a fact about the specs, not because it is a debt to pay down.",
5466
+ caveat: "It says nothing about whether the tests are good, and a high value is not automatically a problem: the two common causes are a deliberately explicit setup, which is a defensible style, and a suite copied file by file as the module grew, which is not, and this number cannot tell them apart. The split is also why it exists at all: measured across 8 modules of the three families, 71% of all duplicated lines are between two test files, so a single blended figure was mostly this one wearing the name of the module.",
5467
+ source: "Same tool and same detection window as the figure above, over the files matching this role's test glob, which is printed under the figures. The 71% was measured on apps/front/console, host-admin, hr-management, libs/front/components, libs/front/shared, apps/nest/microservices/contract, apps/nest/microservices/agency and libs/nest/auth."
5468
+ },
5469
+ clonesTests: {
5470
+ label: "Clones (tests)",
5471
+ what: "How many duplicated fragments the test pass reported.",
5472
+ read: "A count, with the same reserves as the product one above.",
5473
+ caveat: "Everything the product clone count does not say applies here unchanged. One more thing is specific to the split: the two passes partition the files, so a fragment shared between a test and a product file has a partner in NEITHER pass and is counted in neither number. Measured on the same 8 modules, that is 42 entries of 5117, 0.8%, and 650 lines.",
5474
+ source: "jscpd report statistics for the test pass. The partition and its loss were verified on apps/front/console: 478 product files plus 87 test files are the 565 of a blended scan, and 32 plus 40 are its 72 entries."
5475
+ },
4796
5476
  clones: {
4797
5477
  label: "Clones",
4798
5478
  what: "How many duplicated fragments jscpd reported, each entry linking two places that match.",
4799
5479
  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.",
4800
5480
  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.",
4801
5481
  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."
5482
+ },
5483
+ cycles: {
5484
+ label: "Import cycles",
5485
+ what: "Groups of files that import each other in a circle, directly or through other files.",
5486
+ 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.",
5487
+ 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.",
5488
+ 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."
5489
+ },
5490
+ importEdges: {
5491
+ label: "Imports between files",
5492
+ what: "How many times one of the module's own files imports another of them.",
5493
+ 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.",
5494
+ 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, specs and the support beside them, so a file every spec imports does not look busy because of them: measured on `apps/front/host-admin`, 113 helpers in a `testing/` or `test/` directory or named `*.msw.ts` were counted as product code until the role's glob learnt those conventions, and each was showing up as a community of its own. Generated code is out as well: 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.",
5495
+ 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."
5496
+ },
5497
+ graphDensity: {
5498
+ label: "Density",
5499
+ what: "The share of all the edges that could exist between these files that actually do.",
5500
+ 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.",
5501
+ 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.",
5502
+ source: "graphology-metrics, graph density, the standard definition for a directed graph."
5503
+ },
5504
+ communities: {
5505
+ label: "Communities",
5506
+ 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.",
5507
+ 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.",
5508
+ 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. \u26A0\uFE0F A community of ONE file means the module's own code neither imports it nor is imported by it, which is a strong signal and not a verdict. Measured on `apps/front/host-admin`: 53 such files, of which 37 have no reference anywhere and are dead, 10 are routes the framework loads by convention, and 5 are test helpers whose naming this role does not recognise. \u26D4 Read it WITH the dead-code figures rather than against them: this pass sees a file as a node, so it can only say that nothing reaches it, while the dead-code pass looks inside the files and reports an unused export or an unused symbol in a file that is otherwise very much alive. Two scales, not two answers to one question.",
5509
+ source: "Louvain, via graphology-communities-louvain, with the role's own random seed so the figure reproduces. Blondel, Guillaume, Lambiotte and Lefebvre, 2008."
5510
+ },
5511
+ modularity: {
5512
+ label: "Modularity (Q)",
5513
+ what: "The quality of the ASSIGNMENT of files to communities: how much more densely connected the files inside a community are than the same files would be in a random network. Both halves of the words a team already uses are in that sentence, which is why the figure is worth having: dense inside is cohesion, the degree to which the elements of a part belong together, and sparse between is what makes a part independent enough to be given an interface.",
5514
+ read: "Between -1 and 1. It judges the partition, not the code, and that is what makes it architectural: Louvain already maximised it, so a low value does not mean a poor partition was picked, it means no good one exists and the module does not decompose along its imports. A high value says the opposite, that a modular model is there to be read, and the drawing is where to read it. The five modules measured here sit between 0.58 and 0.84.",
5515
+ 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. Read as cohesion it is a practical one: files that interact are a population that lives together, the way Louvain was meant to divide a social network, so it catches technical and functional coupling alike. What it will not give you is a place to go, because it is one average over the whole module. The communities themselves are, and the drawing is there for that.",
5516
+ 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. The wording of what it quantifies, the quality of an assignment of nodes to communities, is Neo4j's graph data science documentation."
5517
+ },
5518
+ mostImported: {
5519
+ label: "Most imported files",
5520
+ what: "The files the rest of the module leans on, with the three coupling figures for each: how many files import it (`Ca`), how many it imports (`Ce`), and Martin's instability `I = Ce / (Ca + Ce)`.",
5521
+ read: "Context, not a defect list: every module has a most-imported file and a type definition used everywhere is doing its job. Read the three together, because that is what Martin made them for: a file many others depend on should itself depend on little, so a high `Ca` next to an `I` near 1 is the row to open. A change there reaches every dependent, and it is itself exposed to everything it pulls. Measured across five modules of the three families, this repository mostly satisfies that rule already: of the 50 most-imported files, 46 sit at `I` of 0.06 or less, and the two highest are 0.23 and 0.29.",
5522
+ caveat: "\u26D4 Martin defined `Ca`, `Ce` and `I` for a COMPONENT, something released and versioned as a unit, not for a file. Reading them per file is a transposition, the same one `code-health-meter` makes, and what it costs is that a file is not independently releasable: splitting one in two changes every figure without changing anything about the design. The module's OWN instability is not here either, and cannot be: this graph stops at the module's edge on purpose, so the imports that would give the module its `Ce` are outside it. \u26A0\uFE0F And if you compare the counts with another tool, note the unit. `Ca` here is raw. graphology's degree centrality, in the last column, is NORMALISED by the file count, and the two 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 on any of the four and the role proposes none. \u26A0\uFE0F The list is ranked by `Ca` alone, so in principle a file with a middling `Ca` and an `I` near 1, which is the shape Martin warns about, could sit below the cut. Measured on 6 214 imported files across five modules: not one has `Ca` of 10 or more together with `I` of 0.5 or more, so the ranking hides nothing today. That is a fact about this repository on the day it was measured, not a property of the ranking.",
5523
+ source: 'In- and out-degree of the module import graph, with graphology\'s degree centrality beside them. `Ca`, `Ce` and `I`: Robert C. Martin, "OO Design Quality Metrics", 1994. \u26D4 `A` and `D` from the same paper are deliberately absent: abstractness measured 0.013 on the front and 0.010 on nest, so `D = |A + I - 1|` collapses to `|I - 1|`, which is instability said twice.'
5524
+ },
5525
+ complexity: {
5526
+ label: "Files with a complex function",
5527
+ what: "Files holding at least one function whose cyclomatic complexity passes 20.",
5528
+ read: "Count of files, so it is comparable between modules only against their file count, which the unit carries. It is the same measure as the band rows above, seen per file rather than per function. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5529
+ caveat: "It does not say which function, nor whether branching is wrong there: a parser legitimately branches. \u26D4 This is a GAUGE and not the line your lint refuses at. It runs oxlint with its own config in a temporary directory, at the threshold the rule publishes, and never reads the module's `.oxlintrc.json`. So it answers the same question the same way in every module, including the 146 of 198 that have not adopted lint, and it WILL disagree with a green build: the `nest` preset refuses `complexity` at 30 while this reports above 20.",
5530
+ source: "oxlint complexity, at ESLint's published default of 20. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5531
+ },
5532
+ "max-depth": {
5533
+ label: "Files with deep nesting",
5534
+ what: "Files holding a block nested more than four levels deep.",
5535
+ read: "The rarest of the indicators, 0.6 firings per 100 files across the repository, so one occurrence is worth opening rather than filtering. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5536
+ caveat: "Depth is not complexity: four flat `if`s in a row never trip it, and one `if` inside three loops does.",
5537
+ source: "oxlint max-depth, at ESLint's published default of 4. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5538
+ },
5539
+ "max-lines": {
5540
+ label: "Files over 300 lines",
5541
+ what: "Files longer than 300 lines, blank lines and comments included.",
5542
+ read: "A size signal and nothing else. Read it beside the duplication figure: a long file that duplicates nothing is a different problem from a long file that repeats itself. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5543
+ caveat: "Length is not a defect. A generated file, a long constant table or a barrel is long by nature, which is why this is reported and never failed on.",
5544
+ source: "oxlint max-lines, at ESLint's published default of 300. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5545
+ },
5546
+ "max-lines-per-function": {
5547
+ label: "Files with a long function",
5548
+ what: "Files holding at least one function longer than 50 lines.",
5549
+ read: "\u26A0\uFE0F A DESCRIPTOR, not part of the maintainability figure. It fires on 46.9 files per 100 across the repository, so it would fire on nearly every pull request. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5550
+ caveat: "What is excluded from the figure is the SCORE, not the reading: the count stays here precisely because it is frequent, and a module far from 46.9 is saying something.",
5551
+ source: "oxlint max-lines-per-function, at ESLint's published default of 50. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5552
+ },
5553
+ "max-statements": {
5554
+ label: "Files with a long function body",
5555
+ what: "Files holding at least one function of more than ten statements.",
5556
+ read: "\u26A0\uFE0F A DESCRIPTOR, not part of the maintainability figure, for the same reason as the row above: 42.9 firings per 100 files. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5557
+ caveat: "It counts statements and not work: ten calls in a row trip it where one dense expression does not.",
5558
+ source: "oxlint max-statements, at ESLint's published default of 10. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5559
+ },
5560
+ "max-params": {
5561
+ label: "Files with a wide signature",
5562
+ what: "Files holding at least one function taking more than three parameters.",
5563
+ read: "The most frequent of the scored indicators, 9.6 per 100 files. A wide signature is usually an object waiting to be named. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5564
+ caveat: "A destructured object parameter counts as one, so the figure does not see a function taking fifteen fields in one bag.",
5565
+ source: "oxlint max-params, at ESLint's published default of 3. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5566
+ },
5567
+ "max-nested-callbacks": {
5568
+ label: "Files with nested callbacks",
5569
+ what: "Files holding callbacks nested more than ten deep.",
5570
+ read: "Zero everywhere measured so far. Kept because a non-zero value is then unambiguous. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5571
+ caveat: "It counts callbacks and not promise chains or awaits, so modern asynchronous code can be deeply sequential without ever tripping it.",
5572
+ source: "oxlint max-nested-callbacks, at ESLint's published default of 10. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5573
+ },
5574
+ "max-classes-per-file": {
5575
+ label: "Files with several classes",
5576
+ what: "Files declaring more than one class.",
5577
+ read: "1.8 per 100 files. On a nest service it usually means a DTO file, which is a convention rather than a defect. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
5578
+ caveat: "It says nothing about the size of the classes, and a file with one 800-line class never appears here.",
5579
+ source: "oxlint max-classes-per-file, at ESLint's published default of 1. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5580
+ },
5581
+ usedOnlyByTests: {
5582
+ label: "Used only by tests",
5583
+ what: "Files of this module that no production code imports, but another module's test files do.",
5584
+ read: "A third state between alive and dead, and the only one of the three that names a design question rather than a defect: a production module shipping something only the suites consume. Zero is the ordinary value.",
5585
+ caveat: "It says nothing about whether that is wrong. A test factory deliberately published by a library belongs here and is doing its job. What it refuses to do is call those files dead, which is what a walk that ignores tests does: measured on `libs/nest/auth`, 11 live files are imported only from a test, `auth.module.ts` among them, and 47 projects depend on that module. \u26A0\uFE0F It sees FILES, so an export used only by specs inside a file that production also imports is invisible here.",
5586
+ source: "The workspace index in `workspace/consumers.ts`: one scan of the tracked files, specifiers resolved against the tsconfig aliases, an importer classified as a test by the same conventions the duplication pass uses. Measured on this repository: 278 such files in all, 39 of them in `libs/front/components`."
4802
5587
  }
4803
5588
  };
4804
5589
  function helpFor(key) {
@@ -4806,175 +5591,619 @@ function helpFor(key) {
4806
5591
  }
4807
5592
 
4808
5593
  // src/roles/static-analysis/report.ts
4809
- function hasFinding(report) {
4810
- return report.rows.some((row) => row.over);
5594
+ function secondSide(finding) {
5595
+ if (!finding.other) return "";
5596
+ const file = (at) => at.split(":")[0] ?? at;
5597
+ const sameFile = file(finding.other) === file(finding.path);
5598
+ const label = sameFile ? "and line" : "and";
5599
+ const shownSide = sameFile ? finding.other.split(":")[1] ?? finding.other : finding.other;
5600
+ return ` <span class="unit">${label}</span> <code>${escape(shownSide)}</code>`;
5601
+ }
5602
+ var HTML_GROUP_LIMIT = 50;
5603
+ function figure(row) {
5604
+ return row.unit === "%" && typeof row.value === "number" ? row.value.toFixed(2) : String(row.value);
5605
+ }
5606
+ function caveatsOf(report) {
5607
+ const keys = [...report.rows.map((row) => row.metric), ...report.findings.map((f) => f.metric)];
5608
+ const seen = /* @__PURE__ */ new Set();
5609
+ const found = [];
5610
+ for (const key of keys) {
5611
+ if (seen.has(key)) continue;
5612
+ seen.add(key);
5613
+ const help = helpFor(key);
5614
+ if (help) found.push(help);
5615
+ }
5616
+ return found;
4811
5617
  }
4812
5618
  function renderConsole(report) {
4813
5619
  const lines = [];
4814
5620
  const width = (pick) => Math.max(...report.rows.map((row) => pick(row).length));
4815
5621
  const label = (row) => helpFor(row.metric)?.label ?? row.metric;
4816
- const value = (row) => `${row.value} ${row.unit}`;
4817
- const limit = (row) => row.threshold === null ? "no threshold" : `max ${row.threshold}${row.moved ? " (module)" : ""}`;
5622
+ const value = (row) => `${figure(row)} ${row.unit}`.trim();
4818
5623
  const labelWidth = width(label);
4819
5624
  const valueWidth = width(value);
4820
5625
  lines.push(`sentinel static-analysis: ${report.module}`);
4821
5626
  for (const row of report.rows) {
4822
- lines.push(
4823
- ` ${row.over ? "!" : " "} ${label(row).padEnd(labelWidth)} ${value(row).padStart(valueWidth)} ${limit(row)}`
4824
- );
5627
+ lines.push(` ${label(row).padEnd(labelWidth)} ${value(row).padStart(valueWidth)}`);
5628
+ }
5629
+ for (const note of report.notes) lines.push(` (${note})`);
5630
+ if (report.settings.length > 0) {
5631
+ const keyWidth = Math.max(...report.settings.map((setting) => setting.key.length));
5632
+ lines.push("", " measured with");
5633
+ for (const setting of report.settings) {
5634
+ lines.push(
5635
+ ` ${setting.key.padEnd(keyWidth)} ${setting.value}${setting.moved ? " (module)" : ""}`
5636
+ );
5637
+ }
5638
+ if (report.settings.some((setting) => setting.moved)) {
5639
+ lines.push(` (module) = overridden in ${report.module}'s static-analysis.config.mjs`);
5640
+ }
4825
5641
  }
4826
5642
  if (report.byLanguage && Object.keys(report.byLanguage).length > 0) {
4827
- lines.push("", " per language, where a clone was found");
5643
+ lines.push("", " per language, where a clone was found in the product code");
4828
5644
  for (const [language, stats] of Object.entries(report.byLanguage)) {
4829
5645
  lines.push(
4830
5646
  ` ${language.padEnd(12)} ${stats.percentage.toFixed(2)}% (${stats.duplicatedLines} lines over ${stats.files} files)`
4831
5647
  );
4832
5648
  }
4833
- lines.push(" a language with no clone is not listed");
5649
+ lines.push(" a language is absent when no file of it was long enough to scan");
4834
5650
  }
4835
- const caveats = report.rows.map((row) => ({ row, help: helpFor(row.metric) })).filter(
4836
- (entry) => Boolean(entry.help)
4837
- );
5651
+ if (report.findings.length > 0) {
5652
+ lines.push(
5653
+ "",
5654
+ ` ${report.findings.length} place(s) to look, listed by \`${SCRIPTS.json}\` and \`${SCRIPTS.html}\``
5655
+ );
5656
+ }
5657
+ const caveats = caveatsOf(report);
4838
5658
  if (caveats.length > 0) {
4839
5659
  lines.push("", " what these numbers do not say");
4840
- for (const { row, help } of caveats) {
5660
+ for (const help of caveats) {
4841
5661
  lines.push(` ${help.label}: ${help.caveat}`, ` source: ${help.source}`);
4842
- void row;
4843
5662
  }
4844
5663
  }
4845
5664
  return `${lines.join("\n")}
4846
5665
  `;
4847
5666
  }
4848
5667
  function renderJson(report) {
4849
- const rows = report.rows.map((row) => ({ ...row, help: helpFor(row.metric) ?? null }));
5668
+ const rows = report.rows.map((row) => {
5669
+ const help = helpFor(row.metric);
5670
+ return { ...row, help: help ? { label: help.label, caveat: help.caveat } : null };
5671
+ });
4850
5672
  return `${JSON.stringify({ ...report, rows }, null, 2)}
4851
5673
  `;
4852
5674
  }
5675
+ function prose(text) {
5676
+ return escape(text).replace(/`([^`]+)`/g, "<code>$1</code>");
5677
+ }
4853
5678
  function escape(text) {
4854
5679
  return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
4855
5680
  }
4856
5681
  function renderHtml(report) {
4857
5682
  const rows = report.rows.map((row) => {
4858
5683
  const help = helpFor(row.metric);
4859
- return ` <tr class="${row.over ? "over" : ""}">
5684
+ return ` <tr>
4860
5685
  <th scope="row">${escape(help?.label ?? row.metric)}</th>
4861
- <td class="num">${escape(String(row.value))} <span class="unit">${escape(row.unit)}</span></td>
4862
- <td class="num">${row.threshold === null ? '<span class="unit">none</span>' : escape(String(row.threshold))}${row.moved ? ' <span class="moved">module</span>' : ""}</td>
4863
- <td class="caveat">${escape(help?.caveat ?? "")}</td>
5686
+ <td class="num">${escape(figure(row))} <span class="unit">${escape(row.unit)}</span></td>
4864
5687
  </tr>`;
4865
5688
  }).join("\n");
5689
+ const reserves = caveatsOf(report).map(
5690
+ (help) => ` <details>
5691
+ <summary>${escape(help.label)}<span class="what">${prose(help.what)}</span></summary>
5692
+ <p class="read"><b>How to read it.</b> ${prose(help.read)}</p>
5693
+ <p><b>What it does not say.</b> ${prose(help.caveat)}</p>
5694
+ <p class="source">Source: ${prose(help.source)}</p>
5695
+ </details>`
5696
+ ).join("\n");
5697
+ const settings = report.settings.map(
5698
+ (setting) => ` <tr>
5699
+ <th scope="row"><code>${escape(setting.key)}</code></th>
5700
+ <td class="num">${escape(setting.value)}</td>
5701
+ <td>${setting.moved ? '<span class="moved">overridden here</span>' : ""}</td>
5702
+ </tr>`
5703
+ ).join("\n");
5704
+ const groups = /* @__PURE__ */ new Map();
5705
+ for (const finding of report.findings) {
5706
+ const bucket = groups.get(finding.metric) ?? [];
5707
+ bucket.push(finding);
5708
+ groups.set(finding.metric, bucket);
5709
+ }
5710
+ const columnsFor = (metric, all2) => {
5711
+ const has = (pick) => all2.some((f) => pick(f) !== void 0);
5712
+ const isComplexity = metric.startsWith("complexity");
5713
+ return [
5714
+ { header: "File", cell: (f) => `<code>${escape(f.path)}</code>${secondSide(f)}` },
5715
+ ...has((f) => f.name) ? [
5716
+ {
5717
+ header: isComplexity ? "Function" : "Name",
5718
+ cell: (f) => f.name ? `<code>${escape(f.name)}</code>` : ""
5719
+ }
5720
+ ] : [],
5721
+ ...has((f) => f.value) ? [
5722
+ {
5723
+ // Martin's own names, because a reader holding this against another tool needs the
5724
+ // word, and `Ca` alone would be unreadable to one who does not.
5725
+ header: isComplexity ? "Complexity" : has((f) => f.dependsOn) ? "Imported by (Ca)" : "Value",
5726
+ cell: (f) => `<span class="num">${f.value ?? ""}</span>`
5727
+ }
5728
+ ] : [],
5729
+ ...has((f) => f.dependsOn) ? [
5730
+ {
5731
+ header: "Depends on (Ce)",
5732
+ cell: (f) => `<span class="num">${f.dependsOn ?? ""}</span>`
5733
+ },
5734
+ {
5735
+ header: "Instability (I)",
5736
+ cell: (f) => `<span class="num">${f.instability === void 0 ? "" : f.instability.toFixed(2)}</span>`
5737
+ },
5738
+ {
5739
+ header: "Centrality",
5740
+ cell: (f) => `<span class="num">${f.centrality === void 0 ? "" : f.centrality.toFixed(4)}</span>`
5741
+ }
5742
+ ] : [],
5743
+ // "Length" and not "Lines": the number is how many lines the finding COVERS, and the cell
5744
+ // beside it already carries line numbers.
5745
+ ...has((f) => f.lines) ? [{ header: "Length", cell: (f) => `<span class="num">${f.lines ?? ""}</span>` }] : [],
5746
+ ...has((f) => f.confidence) ? [
5747
+ {
5748
+ header: "Confidence",
5749
+ cell: (f) => `<span class="num">${f.confidence ?? ""}</span>`
5750
+ }
5751
+ ] : [],
5752
+ ...has((f) => f.reason) ? [
5753
+ {
5754
+ header: "Why the tool is unsure",
5755
+ cell: (f) => f.reason ? `<span class="reason">${escape(f.reason)}</span>` : ""
5756
+ }
5757
+ ] : []
5758
+ ];
5759
+ };
5760
+ const findings = [...groups.entries()].map(([metric, all2]) => {
5761
+ const label = helpFor(metric)?.label ?? metric;
5762
+ const weight = (finding) => finding.value ?? finding.lines ?? 0;
5763
+ const sorted = [...all2].sort((left, right) => weight(right) - weight(left));
5764
+ const listed = sorted.slice(0, HTML_GROUP_LIMIT);
5765
+ const rest = sorted.length - listed.length;
5766
+ const columns = columnsFor(metric, all2);
5767
+ const head = columns.map((column) => `<th scope="col">${column.header}</th>`).join("");
5768
+ const rows2 = listed.map((finding) => {
5769
+ const cells = columns.map((column) => ` <td>${column.cell(finding)}</td>`).join("\n");
5770
+ const code = finding.excerpt ? `
5771
+ <tr class="excerpt">
5772
+ <td colspan="${columns.length}">
5773
+ <details>
5774
+ <summary>${finding.lines ?? ""} duplicated lines</summary>
5775
+ <pre><code>${escape(finding.excerpt)}</code></pre>
5776
+ </details>
5777
+ </td>
5778
+ </tr>` : "";
5779
+ return ` <tr>
5780
+ ${cells}
5781
+ </tr>${code}`;
5782
+ }).join("\n");
5783
+ return `
5784
+ <h2>${escape(label)} &middot; where to look</h2>
5785
+ <section>
5786
+ <table class="places">
5787
+ <thead>
5788
+ <tr>${head}</tr>
5789
+ </thead>
5790
+ <tbody>
5791
+ ${rows2}
5792
+ </tbody>
5793
+ </table>
5794
+ ${rest > 0 ? ` <p class="sub">The ${HTML_GROUP_LIMIT} longest are listed; ${rest} more are in <code>${SCRIPTS.json}</code>.</p>` : ""}
5795
+ </section>`;
5796
+ }).join("\n");
4866
5797
  return `<!doctype html>
4867
5798
  <html lang="en">
4868
5799
  <head>
4869
5800
  <meta charset="utf-8">
4870
5801
  <title>Static analysis: ${escape(report.module)}</title>
4871
5802
  <style>
4872
- :root { color-scheme: light dark; --ink: #1a1a1a; --bg: #fdfdfc; --rule: #e2e0da; --warn: #9a3412; --dim: #6b6b6b; }
4873
- @media (prefers-color-scheme: dark) {
4874
- :root { --ink: #e8e6e1; --bg: #1b1b1a; --rule: #35332e; --warn: #fb923c; --dim: #9b9892; }
4875
- }
4876
- body { margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--ink);
4877
- font: 15px/1.55 ui-sans-serif, system-ui, sans-serif; }
4878
- main { max-width: 60rem; margin: 0 auto; }
4879
- h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
4880
- p.sub { color: var(--dim); margin: 0 0 1.5rem; }
5803
+ /*
5804
+ * The Hublo palette, token by token, read from "libs/front/theme/src/branding/definitions/hublo"
5805
+ * rather than approximated: "commonColors.ts" holds the values and "colorTokens.ts" the semantic
5806
+ * names used in the comments below.
5807
+ *
5808
+ * \u26D4 One theme, and that is a decision rather than an omission: the charter has no dark palette,
5809
+ * zero occurrences of "prefers-color-scheme" in the whole theme package, so a dark variant here
5810
+ * would be charter this page invented. Every colour is painted explicitly instead, so the page
5811
+ * holds on whatever ground opens it.
5812
+ */
5813
+ :root {
5814
+ color-scheme: light;
5815
+ --page: #f5f8fd; /* neutral5, background.page-header */
5816
+ --surface: #ffffff; /* neutral0, background.surface */
5817
+ --ink: #131415; /* neutral35, text.primary */
5818
+ --ink-soft: #5e6972; /* neutral30, text.secondary */
5819
+ --label: #72808e; /* neutral25, text.label-default */
5820
+ --rule: #ced7e0; /* neutral15, border.page-header */
5821
+ --brand: #3a4392; /* blue15, text.brand */
5822
+ --brand-bg: #e5ecff; /* blue0, background.brand */
5823
+ --moved: #c83c3c; /* red15, text.destructive */
5824
+ }
5825
+ /*
5826
+ * The brand faces first, then Inter, then the system. They are loaded at runtime from a branding
5827
+ * artefact in the applications, so they cannot be embedded here without an external request, and
5828
+ * an external request is the one thing this page must not make. A viewer who has them sees them.
5829
+ */
5830
+ body {
5831
+ margin: 0;
5832
+ padding: 2.5rem 1rem 4rem;
5833
+ background: var(--page);
5834
+ color: var(--ink);
5835
+ font: 15px/1.6 'Brand Sans', Inter, ui-sans-serif, system-ui, sans-serif;
5836
+ }
5837
+ main { max-width: 64rem; margin: 0 auto; }
5838
+ header { border-left: 3px solid var(--brand); padding-left: 0.9rem; margin-bottom: 2rem; }
5839
+ h1 { font-size: 1.3rem; font-weight: 600; margin: 0 0 0.2rem; letter-spacing: -0.01em; }
5840
+ h2 {
5841
+ font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.08em;
5842
+ color: var(--label); margin: 2.5rem 0 0.6rem; font-weight: 600;
5843
+ }
5844
+ p.sub { color: var(--ink-soft); margin: 0; font-size: 0.9rem; }
5845
+ section {
5846
+ background: var(--surface); border: 1px solid var(--rule); border-radius: 10px;
5847
+ padding: 0.25rem 1.1rem; overflow-x: auto;
5848
+ }
4881
5849
  table { width: 100%; border-collapse: collapse; }
4882
- th, td { text-align: left; padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--rule);
4883
- vertical-align: top; }
4884
- thead th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--dim); }
4885
- td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
4886
- .unit, .moved { color: var(--dim); font-size: 0.85em; }
4887
- .caveat { color: var(--dim); font-size: 0.9em; }
4888
- tr.over th, tr.over td.num { color: var(--warn); font-weight: 600; }
5850
+ th, td { text-align: left; padding: 0.7rem 0.6rem; border-bottom: 1px solid var(--rule); vertical-align: top; }
5851
+ tr:last-child th, tr:last-child td { border-bottom: 0; }
5852
+ thead th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--label); font-weight: 600; }
5853
+ tbody th { font-weight: 500; white-space: nowrap; }
5854
+ td.num { font-variant-numeric: tabular-nums; white-space: nowrap; font-weight: 500; }
5855
+ /*
5856
+ * Which column absorbs the slack, per table. One global rule on the shared class got this
5857
+ * wrong in all three at once: the figures read well and the settings table clipped its origin
5858
+ * column while the findings table crushed its file paths into three lines.
5859
+ */
5860
+ table.figures td.num { width: 100%; }
5861
+ /*
5862
+ * The settings table does NOT force its value column wide. One of its values is the test glob,
5863
+ * long enough to push the table past the card, and the origin column then slid out of view and
5864
+ * was clipped: "overridden in this mo". Long values wrap instead, and the origin stays readable.
5865
+ */
5866
+ table.settings td.num { white-space: normal; overflow-wrap: anywhere; }
5867
+ table.settings td:last-child { text-align: right; white-space: nowrap; }
5868
+ table.places td:first-child { width: 100%; }
5869
+ table.places td { white-space: nowrap; }
5870
+ table.places td:first-child { white-space: normal; }
5871
+ .num { font-variant-numeric: tabular-nums; }
5872
+ td.read { color: var(--ink-soft); font-size: 0.88rem; }
5873
+ tbody th { color: var(--ink); }
5874
+ code { font: 0.86em ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ink-soft); }
5875
+ .unit { color: var(--label); font-size: 0.82em; font-weight: 400; }
5876
+ .reason {
5877
+ font: 0.74rem ui-monospace, SFMono-Regular, Menlo, monospace;
5878
+ background: var(--brand-bg); color: var(--brand);
5879
+ padding: 0.1rem 0.4rem; border-radius: 4px; white-space: nowrap;
5880
+ }
5881
+ .moved {
5882
+ color: var(--moved); font-size: 0.78rem; font-weight: 500;
5883
+ white-space: nowrap;
5884
+ }
5885
+ /* The graph. The svg carries a viewBox, so dropping its fixed width lets it fill the column. */
5886
+ .picture svg { width: 100%; height: auto; display: block; }
5887
+ .picture p.read { margin: 0 0 0.8rem; padding: 0 0.6rem; color: var(--ink-soft); font-size: 0.88rem; max-width: 52rem; }
5888
+
5889
+ /* The collapsed reserves. Closed by default: the figures are what the page opens on. */
5890
+ details { border-bottom: 1px solid var(--rule); }
5891
+ details:last-of-type { border-bottom: 0; }
5892
+ summary {
5893
+ cursor: pointer; padding: 0.7rem 0.6rem; font-size: 0.9rem; font-weight: 500;
5894
+ display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline;
5895
+ }
5896
+ /*
5897
+ * The chevron is drawn, not inherited: "display: flex" on a summary removes the native marker,
5898
+ * and a collapsed block with no affordance is a block nobody opens.
5899
+ */
5900
+ summary { list-style: none; }
5901
+ summary::-webkit-details-marker { display: none; }
5902
+ summary::before {
5903
+ content: ''; flex: none; width: 0.38rem; height: 0.38rem; margin: 0.3rem 0.1rem 0 0;
5904
+ border-right: 1.5px solid var(--label); border-bottom: 1.5px solid var(--label);
5905
+ transform: rotate(-45deg); transition: transform 0.15s ease;
5906
+ }
5907
+ details[open] summary::before { transform: rotate(45deg); border-color: var(--brand); }
5908
+ @media (prefers-reduced-motion: reduce) { summary::before { transition: none; } }
5909
+ summary:hover { color: var(--brand); }
5910
+ /* One line, truncated: the summary is a hint, the body is the text. Opening it shows all. */
5911
+ summary .what {
5912
+ color: var(--label); font-size: 0.82rem; font-weight: 400;
5913
+ flex: 1 1 18rem; min-width: 0;
5914
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
5915
+ }
5916
+ details[open] summary .what { white-space: normal; overflow: visible; }
5917
+ details[open] summary { color: var(--brand); }
5918
+ details p.read { color: var(--ink-soft); }
5919
+ details p b { color: var(--ink); font-weight: 600; }
5920
+ details p { margin: 0 0 0.7rem; padding: 0 0.6rem; color: var(--ink-soft); font-size: 0.88rem; max-width: 52rem; }
5921
+ details p.source { color: var(--label); font-size: 0.8rem; }
5922
+ /* The duplicated code, under the row that located it. */
5923
+ tr.excerpt td { padding: 0 0.6rem 0.4rem; border-bottom: 1px solid var(--rule); }
5924
+ tr.excerpt details { border: 0; }
5925
+ tr.excerpt summary { padding: 0.35rem 0; font-size: 0.8rem; color: var(--label); font-weight: 400; }
5926
+ tr.excerpt summary:hover { color: var(--brand); }
5927
+ tr.excerpt pre {
5928
+ margin: 0 0 0.5rem; padding: 0.7rem 0.9rem; background: var(--page);
5929
+ border: 1px solid var(--rule); border-radius: 6px; overflow-x: auto;
5930
+ font: 0.8rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre;
5931
+ }
5932
+ tr.excerpt pre code { color: var(--ink); font-size: 1em; }
5933
+ footer { color: var(--label); font-size: 0.78rem; margin-top: 2.5rem; }
5934
+ @media (max-width: 40rem) {
5935
+ section { padding: 0.25rem 0.6rem; }
5936
+ summary .what { display: none; }
5937
+ }
4889
5938
  </style>
4890
5939
  </head>
4891
5940
  <body>
4892
5941
  <main>
4893
- <h1>Static analysis: ${escape(report.module)}</h1>
4894
- <p class="sub">Passes: ${escape(report.passes.join(", "))}</p>
4895
- <table>
4896
- <thead>
4897
- <tr><th scope="col">Metric</th><th scope="col">Value</th><th scope="col">Threshold</th><th scope="col">What it does not say</th></tr>
4898
- </thead>
4899
- <tbody>
5942
+ <header>
5943
+ <h1>${escape(report.module)}</h1>
5944
+ <p class="sub">Static analysis, ${escape(report.passes.join(" / "))}</p>
5945
+ </header>
5946
+
5947
+ <h2>Measured</h2>
5948
+ <section>
5949
+ <table class="figures">
5950
+ <thead>
5951
+ <tr><th scope="col">Metric</th><th scope="col">Value</th></tr>
5952
+ </thead>
5953
+ <tbody>
4900
5954
  ${rows}
4901
- </tbody>
4902
- </table>
5955
+ </tbody>
5956
+ </table>
5957
+ </section>
5958
+ ${report.notes.length > 0 ? `
5959
+ <h2>Not measured</h2>
5960
+ <section>
5961
+ <table><tbody>
5962
+ ${report.notes.map((note) => ` <tr><td class="read">${escape(note)}</td></tr>`).join("\n")}
5963
+ </tbody></table>
5964
+ </section>
5965
+ ` : ""}
5966
+ ${report.picture ? `
5967
+ <h2>Shape</h2>
5968
+ <section class="picture">
5969
+ <p class="read">One dot per file, sized by how many imports touch it, coloured by the community it landed in. It shows the SHAPE: how many lobes, whether they are joined, where everything converges. The names are in the tables below.</p>
5970
+ ${report.picture}
5971
+ </section>
5972
+ ` : ""}
5973
+ <h2>What these numbers do not say</h2>
5974
+ <section>
5975
+ ${reserves}
5976
+ </section>
5977
+
5978
+ <h2>Measured with</h2>
5979
+ <section>
5980
+ <table class="settings">
5981
+ <thead>
5982
+ <tr><th scope="col">Setting</th><th scope="col">Value</th><th scope="col">Origin</th></tr>
5983
+ </thead>
5984
+ <tbody>
5985
+ ${settings}
5986
+ </tbody>
5987
+ </table>
5988
+ </section>
5989
+ ${findings}
5990
+ <footer>sentinel static-analysis</footer>
4903
5991
  </main>
4904
5992
  </body>
4905
5993
  </html>
4906
5994
  `;
4907
5995
  }
4908
5996
 
4909
- // src/roles/static-analysis/adapters/jscpd/config.ts
4910
- import { mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
4911
- import { tmpdir } from "node:os";
4912
- import { join as join18 } from "node:path";
4913
- function fileSettings(config) {
4914
- return {
4915
- // The detection window. Stated rather than defaulted, because it decides what is invisible.
4916
- minTokens: config.duplication.minTokens,
4917
- minLines: config.duplication.minLines,
4918
- // `format` is jscpd's name for what we call `languages`. Naming `typescript` alone would
4919
- // leave out `.tsx`, which is a separate language to it: 300 files of 523 on front/console.
4920
- format: config.languages,
4921
- ignore: config.ignore,
4922
- // A scan that analyses nothing otherwise exits 0 with only a warning, which reads as a pass.
4923
- failOnEmpty: true,
4924
- // The dead-code engine keeps its own section, and its own `minLines`: jscpd's documentation is
4925
- // explicit that the top-level one is about clones and is never carried into a dead-code run.
4926
- deadCode: {
4927
- entry: config.entry,
4928
- minConfidence: config.deadCode.minConfidence,
4929
- categories: config.deadCode.categories
4930
- },
4931
- // The escape hatch, last so it wins, and empty unless a module asked for it.
4932
- ...config.tools.jscpd
5997
+ // src/roles/static-analysis/workspace/consumers.ts
5998
+ import { spawnSync as spawnSync5 } from "node:child_process";
5999
+ import { existsSync as existsSync15, mkdirSync, readFileSync as readFileSync16, statSync as statSync4, writeFileSync as writeFileSync5 } from "node:fs";
6000
+ import { dirname as dirname2, join as join21, relative as relative4, resolve as resolve3, sep } from "node:path";
6001
+ var CACHE_DIR = join21("node_modules", ".cache", "sentinel");
6002
+ var CACHE_FILE = "consumer-index.json";
6003
+ var SHAPE = 3;
6004
+ var EXTENSIONS2 = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
6005
+ function workspaceRootFrom(cwd) {
6006
+ let dir = resolve3(cwd);
6007
+ for (; ; ) {
6008
+ if (existsSync15(join21(dir, WORKSPACE_ROOT_MARKER))) return dir;
6009
+ const parent = dirname2(dir);
6010
+ if (parent === dir) return void 0;
6011
+ dir = parent;
6012
+ }
6013
+ }
6014
+ function aliasTable(workspaceRoot) {
6015
+ const file = join21(workspaceRoot, "tsconfig.base.json");
6016
+ if (!existsSync15(file)) return /* @__PURE__ */ new Map();
6017
+ let paths;
6018
+ try {
6019
+ const parsed = JSON.parse(readFileSync16(file, "utf8"));
6020
+ paths = parsed.compilerOptions?.paths ?? {};
6021
+ } catch {
6022
+ return /* @__PURE__ */ new Map();
6023
+ }
6024
+ const table = /* @__PURE__ */ new Map();
6025
+ for (const [alias, targets2] of Object.entries(paths)) {
6026
+ if (alias === "apps/*" || alias === "libs/*") continue;
6027
+ table.set(
6028
+ alias,
6029
+ targets2.map((target) => target.replace(/^\.\//, ""))
6030
+ );
6031
+ }
6032
+ return table;
6033
+ }
6034
+ function resolveSpecifier2(specifier, table, workspaceRoot) {
6035
+ const isFile = (candidate) => {
6036
+ try {
6037
+ return statSync4(join21(workspaceRoot, candidate)).isFile();
6038
+ } catch {
6039
+ return false;
6040
+ }
4933
6041
  };
6042
+ const exists = (candidate) => {
6043
+ if (isFile(candidate)) return candidate;
6044
+ for (const extension of EXTENSIONS2) {
6045
+ if (isFile(candidate + extension)) return candidate + extension;
6046
+ }
6047
+ for (const extension of EXTENSIONS2) {
6048
+ const index = `${candidate}/index${extension}`;
6049
+ if (isFile(index)) return index;
6050
+ }
6051
+ return void 0;
6052
+ };
6053
+ const exact = table.get(specifier);
6054
+ if (exact) {
6055
+ for (const target of exact) {
6056
+ const hit = exists(target);
6057
+ if (hit) return hit;
6058
+ }
6059
+ }
6060
+ for (const [alias, targets2] of table) {
6061
+ if (!alias.endsWith("/*")) continue;
6062
+ const prefix = alias.slice(0, -1);
6063
+ if (!specifier.startsWith(prefix)) continue;
6064
+ const rest = specifier.slice(prefix.length);
6065
+ for (const target of targets2) {
6066
+ if (!target.endsWith("/*")) continue;
6067
+ const hit = exists(target.slice(0, -1) + rest);
6068
+ if (hit) return hit;
6069
+ }
6070
+ }
6071
+ return void 0;
4934
6072
  }
4935
- function duplicationRun(config, reportDir, paths) {
4936
- const dir = mkdtempSync(join18(tmpdir(), "sentinel-static-"));
4937
- const configPath = join18(dir, "jscpd.json");
4938
- writeFileSync3(configPath, `${JSON.stringify(fileSettings(config), null, 2)}
4939
- `, "utf8");
4940
- const args = [
4941
- "--config",
4942
- configPath,
4943
- // Always json, and only json, whatever shape the caller asked for. The three shapes are
4944
- // rendered by this role from the figures, not by the tool: one report lives under
4945
- // `static-analysis`, assembled from several passes, so a per-pass console table from the tool
4946
- // would be a fourth voice saying the same numbers differently. `--output` below is jscpd's
4947
- // output DIRECTORY, not our `--output` shape.
4948
- "--reporters",
4949
- "json",
4950
- "--output",
4951
- reportDir,
4952
- "--no-tips",
4953
- ...paths
4954
- ];
4955
- return { args, dispose: () => rmSync3(dir, { force: true, recursive: true }) };
6073
+ function isTestFile(path2) {
6074
+ return /\.(test|spec|steps|e2e-spec|integration-spec|stories)\.[cm]?[jt]sx?$|(^|\/)(tests|__tests__|test|testing|stubs|__mocks__|mocks)\//.test(
6075
+ path2
6076
+ );
6077
+ }
6078
+ function moduleOf(path2, roots) {
6079
+ let best;
6080
+ for (const root of roots) {
6081
+ if ((path2 === root || path2.startsWith(`${root}/`)) && (!best || root.length > best.length)) {
6082
+ best = root;
6083
+ }
6084
+ }
6085
+ return best;
6086
+ }
6087
+ function buildConsumerIndex(workspaceRoot, moduleRoots) {
6088
+ const table = aliasTable(workspaceRoot);
6089
+ const imported = /* @__PURE__ */ new Set();
6090
+ const testOnly = /* @__PURE__ */ new Set();
6091
+ if (table.size === 0) return { imported, testOnly, builtFrom: "no alias table" };
6092
+ const result = spawnSync5(
6093
+ "git",
6094
+ ["grep", "-I", "-n", "-o", "-E", "@[A-Za-z0-9_-]+/[A-Za-z0-9_./-]+", "--", "*.ts", "*.tsx"],
6095
+ { cwd: workspaceRoot, encoding: "utf8", maxBuffer: 512 * 1024 * 1024 }
6096
+ );
6097
+ if (result.error || result.status !== 0 && result.status !== 1) {
6098
+ return { imported, testOnly, builtFrom: "git grep failed" };
6099
+ }
6100
+ const fromProduct = /* @__PURE__ */ new Map();
6101
+ const seen = /* @__PURE__ */ new Map();
6102
+ for (const line of (result.stdout ?? "").split("\n")) {
6103
+ if (!line) continue;
6104
+ const first = line.indexOf(":");
6105
+ const second = line.indexOf(":", first + 1);
6106
+ if (first < 0 || second < 0) continue;
6107
+ const importer = line.slice(0, first).split(sep).join("/");
6108
+ const specifier = line.slice(second + 1);
6109
+ if (!seen.has(specifier)) seen.set(specifier, resolveSpecifier2(specifier, table, workspaceRoot));
6110
+ const target = seen.get(specifier);
6111
+ if (!target) continue;
6112
+ if (moduleOf(importer, moduleRoots) === moduleOf(target, moduleRoots)) continue;
6113
+ imported.add(target);
6114
+ const product = !isTestFile(importer);
6115
+ fromProduct.set(target, (fromProduct.get(target) ?? false) || product);
6116
+ }
6117
+ for (const [target, product] of fromProduct) if (!product) testOnly.add(target);
6118
+ return { imported, testOnly, builtFrom: `git grep over ${seen.size} distinct specifiers` };
6119
+ }
6120
+ function cacheKey(workspaceRoot) {
6121
+ const run = (args) => {
6122
+ const out = spawnSync5("git", args, { cwd: workspaceRoot, encoding: "utf8" });
6123
+ return out.error || out.status !== 0 ? void 0 : out.stdout ?? "";
6124
+ };
6125
+ const head = run(["rev-parse", "HEAD"]);
6126
+ const dirty = run(["status", "--porcelain=v1", "--untracked-files=no"]);
6127
+ if (head === void 0 || dirty === void 0) return void 0;
6128
+ return `${SHAPE}:${head.trim()}:${hash(dirty)}`;
6129
+ }
6130
+ function hash(text) {
6131
+ let value = 5381;
6132
+ for (let index = 0; index < text.length; index += 1) {
6133
+ value = (value << 5) + value + text.charCodeAt(index) >>> 0;
6134
+ }
6135
+ return value.toString(36);
6136
+ }
6137
+ function consumerIndex(workspaceRoot, moduleRoots) {
6138
+ const key = cacheKey(workspaceRoot);
6139
+ const file = join21(workspaceRoot, CACHE_DIR, CACHE_FILE);
6140
+ if (key && existsSync15(file)) {
6141
+ try {
6142
+ const cached = JSON.parse(readFileSync16(file, "utf8"));
6143
+ if (cached.key === key) {
6144
+ return {
6145
+ imported: new Set(cached.imported),
6146
+ testOnly: new Set(cached.testOnly),
6147
+ builtFrom: `${cached.builtFrom}, from cache`
6148
+ };
6149
+ }
6150
+ } catch {
6151
+ }
6152
+ }
6153
+ const index = buildConsumerIndex(workspaceRoot, moduleRoots());
6154
+ if (key) {
6155
+ try {
6156
+ mkdirSync(join21(workspaceRoot, CACHE_DIR), { recursive: true });
6157
+ const payload = {
6158
+ key,
6159
+ imported: [...index.imported],
6160
+ testOnly: [...index.testOnly],
6161
+ builtFrom: index.builtFrom
6162
+ };
6163
+ writeFileSync5(file, JSON.stringify(payload), "utf8");
6164
+ } catch {
6165
+ }
6166
+ }
6167
+ return index;
6168
+ }
6169
+ function outsideView(index, workspaceRoot, moduleRoot) {
6170
+ const prefix = relative4(workspaceRoot, moduleRoot).split(sep).join("/");
6171
+ const within = (path2) => prefix === "" ? path2 : path2.startsWith(`${prefix}/`) ? path2.slice(prefix.length + 1) : void 0;
6172
+ const imported = /* @__PURE__ */ new Set();
6173
+ const testOnly = /* @__PURE__ */ new Set();
6174
+ for (const path2 of index.imported) {
6175
+ const local = within(path2);
6176
+ if (local) imported.add(local);
6177
+ }
6178
+ for (const path2 of index.testOnly) {
6179
+ const local = within(path2);
6180
+ if (local) testOnly.add(local);
6181
+ }
6182
+ return { imported, testOnly };
4956
6183
  }
4957
6184
 
4958
- // src/roles/static-analysis/adapters/jscpd/resolve.ts
6185
+ // src/roles/static-analysis/adapters/jscpd/resolve-binary.ts
4959
6186
  function resolveJscpd(cwd) {
4960
6187
  return resolveBin(cwd, "jscpd") ?? binFromOwnInstall("jscpd", "jscpd");
4961
6188
  }
4962
6189
 
4963
6190
  // src/roles/static-analysis/adapters/jscpd/jscpd.adapter.ts
4964
- var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css", ".scss"];
4965
- var SCRIPT = "static-analysis";
4966
- var SHAPES = ["console", "json", "html"];
6191
+ var EXTENSIONS3 = extensionsOf(defaults.languages);
4967
6192
  var scripts = () => ({
4968
- [SCRIPT]: `sentinel --run --${SCRIPT}`,
4969
- [`${SCRIPT}:json`]: `sentinel --run --${SCRIPT} --output json`,
4970
- [`${SCRIPT}:html`]: `sentinel --run --${SCRIPT} --output html`
6193
+ [SCRIPTS.console]: `sentinel --run --${TARGET}`,
6194
+ [SCRIPTS.json]: `sentinel --run --${TARGET} --output json`,
6195
+ [SCRIPTS.html]: `sentinel --run --${TARGET} --output html`
4971
6196
  });
4972
6197
  var targets = () => ({
4973
- [SCRIPT]: { cache: true, inputs: ["default", `{projectRoot}/${STATIC_CONFIG_FILE}`] },
4974
- [`${SCRIPT}:json`]: { cache: false },
4975
- [`${SCRIPT}:html`]: { cache: false }
6198
+ [TARGET]: { cache: true, inputs: ["default", `{projectRoot}/${STATIC_CONFIG_FILE}`] }
4976
6199
  });
4977
- var CONFIG_STUB = `import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
6200
+ function configStub() {
6201
+ const rows = overridableSettings();
6202
+ const pad = Math.max(...rows.map((row) => row.key.length));
6203
+ const show = (row) => ` // ${row.key.padEnd(pad)} ${row.value}`;
6204
+ const movable = rows.filter((row) => row.where === "overrides").map(show);
6205
+ const refused = rows.filter((row) => row.where === "refused").map((row) => ` // ${row.key}: ${row.note}`);
6206
+ return `import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
4978
6207
 
4979
6208
  export default defineStaticAnalysis({
4980
6209
  root: import.meta.dirname,
@@ -4986,39 +6215,73 @@ export default defineStaticAnalysis({
4986
6215
  // Named product journeys, as { 'the name product uses': ['src/some/path/**'] }. Paths are
4987
6216
  // relative to this module, because one run measures one module.
4988
6217
  journeys: {},
6218
+
6219
+ // ---------------------------------------------------------------------------------------------
6220
+ // What this module can move, with the role's value today. A setting you leave out keeps that
6221
+ // value; a setting you write REPLACES it, including inside a list. Anything you set is printed
6222
+ // under "measured with" in the report and marked \`(module)\`, because two modules measured with
6223
+ // different settings can no longer be compared.
6224
+ //
6225
+ ${movable.join("\n")}
6226
+ //
6227
+ // A list shown as a count is not copyable, and replacing one means writing back what you keep,
6228
+ // so read the values in full with:
6229
+ //
6230
+ // pnpm exec sentinel --inspect --${TARGET} --json
6231
+ //
6232
+ ${refused.join("\n")}
6233
+ //
6234
+ // overrides: {
6235
+ // duplication: { minTokens: 100 },
6236
+ // },
4989
6237
  })
4990
6238
  `;
6239
+ }
6240
+ function configValue(config, path2) {
6241
+ return path2.split(".").reduce(
6242
+ (node, key) => node && typeof node === "object" ? node[key] : void 0,
6243
+ config
6244
+ );
6245
+ }
4991
6246
  var JscpdAdapter = class extends BaseAdapter {
4992
- target = "static-analysis";
6247
+ target = TARGET;
4993
6248
  runner = "jscpd";
4994
6249
  /** Every preset: duplication, dead code and complexity are not stack-specific. */
4995
6250
  appliesTo(_preset) {
4996
6251
  return true;
4997
6252
  }
4998
6253
  plan(context) {
4999
- if (!hasSourceFiles(context.cwd, EXTENSIONS)) {
6254
+ if (!hasSourceFiles(context.cwd, EXTENSIONS3)) {
5000
6255
  return {
5001
6256
  operations: [],
5002
- skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS.join(", ")}).`
6257
+ skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS3.join(", ")}).`
5003
6258
  };
5004
6259
  }
5005
6260
  return {
5006
6261
  operations: [
5007
6262
  // `write` and not `merge-json`: this file is code, and the engine's in-place merge is for
5008
6263
  // JSON. A re-init therefore RESTORES the stub, so an existing one is left alone below.
5009
- ...existsSync14(join19(context.cwd, STATIC_CONFIG_FILE)) ? [] : [{ kind: "write", path: STATIC_CONFIG_FILE, contents: CONFIG_STUB }],
6264
+ ...existsSync16(join22(context.cwd, STATIC_CONFIG_FILE)) ? [] : [{ kind: "write", path: STATIC_CONFIG_FILE, contents: configStub() }],
5010
6265
  manifestOperation(context.cwd, scripts()),
5011
- ...nxTargetOperations({ cwd: context.cwd, targets: targets() }),
5012
- // The reports are build output. Without this every adopting module grows an untracked
5013
- // directory nobody can explain, which is exactly what the format role's `lint/` did.
5014
- { kind: "ensure-lines", path: ".gitignore", lines: [`/${REPORT_DIR}/`] }
6266
+ ...nxTargetOperations({ cwd: context.cwd, targets: targets() })
6267
+ // The report directory is ignored ONCE at the workspace root, by `--init`'s workspace prep,
6268
+ // and not here: a per-module rule creates a `.gitignore` in modules that had none and
6269
+ // repeats the same line in every adopting module.
5015
6270
  ],
5016
6271
  notes: [
5017
- `added the three \`${SCRIPT}\` scripts and ${STATIC_CONFIG_FILE}; declare this module's entry points and journeys there, everything else comes from the role`
6272
+ `added the three \`${TARGET}\` scripts and ${STATIC_CONFIG_FILE}; declare this module's entry points and journeys there, everything else comes from the role`
5018
6273
  ]
5019
6274
  };
5020
6275
  }
5021
- async run(ctx) {
6276
+ /**
6277
+ * Measure, and emit only when asked to.
6278
+ *
6279
+ * `--run` prints and may write a file; `--report` answers a machine and must do neither, which is
6280
+ * the only difference between the two verbs here. Shared rather than duplicated because the
6281
+ * measurement is where every reserve lives: two copies would drift, and the one that drifted
6282
+ * would be the one nobody reads.
6283
+ */
6284
+ async measure(ctx, emitting) {
5022
6285
  const shape = ctx.output ?? "console";
5023
6286
  if (!SHAPES.includes(shape)) {
5024
6287
  process.stderr.write(
@@ -5027,9 +6290,9 @@ var JscpdAdapter = class extends BaseAdapter {
5027
6290
  );
5028
6291
  return { ok: false, code: 1 };
5029
6292
  }
5030
- if (!hasSourceFiles(ctx.cwd, EXTENSIONS)) {
6293
+ if (!hasSourceFiles(ctx.cwd, EXTENSIONS3)) {
5031
6294
  process.stderr.write(
5032
- `sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS.join(", ")}).
6295
+ `sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS3.join(", ")}).
5033
6296
  `
5034
6297
  );
5035
6298
  return { ok: true, code: 0 };
@@ -5057,38 +6320,117 @@ var JscpdAdapter = class extends BaseAdapter {
5057
6320
  return { ok: false, code: 1 };
5058
6321
  }
5059
6322
  const { paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, { valueFlags: ["-c", "--config"] });
5060
- const reportDir = join19(ctx.cwd, REPORT_DIR);
5061
- mkdirSync(reportDir, { recursive: true });
5062
- const { args, dispose } = duplicationRun(config, reportDir, paths.length > 0 ? paths : ["."]);
5063
- try {
5064
- const result = spawnSync5(jscpd, args, { cwd: ctx.cwd, encoding: "utf8" });
5065
- if (result.error) {
5066
- process.stderr.write(
5067
- `sentinel static-analysis(jscpd): could not run jscpd (${result.error.message})
6323
+ const scanned = paths.length > 0 ? paths : measuredRoots(ctx.cwd);
6324
+ const coverage = coverageOf(ctx.cwd, config);
6325
+ const unreadableCode = hasUnreadableCode(coverage);
6326
+ const product = runDuplication(jscpd, config, scanned, "product", ctx.cwd);
6327
+ if (product.kind === "failed") return { ok: false, code: product.code };
6328
+ if (product.kind === "empty") {
6329
+ process.stderr.write(
6330
+ `sentinel static-analysis(jscpd): nothing left to measure in this module once the exclusions applied, so no figure is reported (a figure of 0% would be a different claim).
5068
6331
  `
5069
- );
5070
- return { ok: false, code: 1 };
5071
- }
5072
- if (result.status !== 0) {
5073
- process.stderr.write(
5074
- `sentinel static-analysis(jscpd): jscpd exited ${result.status}
5075
- ${result.stderr ?? ""}`
5076
- );
5077
- return { ok: false, code: result.status ?? 1 };
5078
- }
5079
- const report = buildReport(ctx, reportDir, config);
5080
- if (!report) {
5081
- process.stderr.write(
5082
- `sentinel static-analysis(jscpd): jscpd wrote no report in ${REPORT_DIR}
6332
+ );
6333
+ return { ok: true, code: 0 };
6334
+ }
6335
+ const tests = runDuplication(jscpd, config, scanned, "tests", ctx.cwd);
6336
+ if (tests.kind === "failed") return { ok: false, code: tests.code };
6337
+ const outside = outsideOf(ctx.cwd);
6338
+ const dead = runDeadCode(jscpd, config, paths.length > 0 ? paths : ["."], ctx.cwd, [
6339
+ ...outside.imported
6340
+ ]);
6341
+ if (dead.kind === "failed") return { ok: false, code: dead.code };
6342
+ if (dead.kind === "no-entry-point" && !unreadableCode) {
6343
+ process.stderr.write(
6344
+ `sentinel static-analysis(jscpd): no entry point found in this module, so nothing could be reached and no dead-code figure is reported (${dead.files} file(s) scanned). Declare what runs here in \`entry\` inside ${STATIC_CONFIG_FILE}: a lambda handler, a framework bootstrap, a script. A figure of 100% would describe the walk, not the code.
5083
6345
  `
5084
- );
5085
- return { ok: false, code: 1 };
5086
- }
5087
- emit(report, shape, reportDir);
5088
- return { ok: true, code: 0, metrics: metricsOf(report) };
5089
- } finally {
5090
- dispose();
6346
+ );
5091
6347
  }
6348
+ const complexity = scanComplexity(ctx.cwd, config);
6349
+ const graph = scanGraph(ctx.cwd, config);
6350
+ const size = scanSizeRules(
6351
+ ctx.cwd,
6352
+ scanned,
6353
+ exclusions(config, "product"),
6354
+ resolveOxlint(ctx.cwd),
6355
+ (bin, args, cwd) => {
6356
+ const result = spawnSync6(bin, args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
6357
+ return { status: result.error ? 1 : result.status, stdout: result.stdout ?? "" };
6358
+ }
6359
+ );
6360
+ const report = assembleReport(
6361
+ ctx,
6362
+ config,
6363
+ product,
6364
+ tests,
6365
+ dead,
6366
+ complexity,
6367
+ graph,
6368
+ coverage,
6369
+ size,
6370
+ outside
6371
+ );
6372
+ if (emitting) await emit(report, shape, ctx.cwd, graph, coverage);
6373
+ return { ok: true, code: 0, metrics: metricsOf(report) };
6374
+ }
6375
+ /** `--run`: measure and show it. */
6376
+ async run(ctx) {
6377
+ return this.measure(ctx, true);
6378
+ }
6379
+ /**
6380
+ * `--inspect`: the settings that apply here, IN FULL.
6381
+ *
6382
+ * It exists because of the other half of documenting an override. The config stub lists what a
6383
+ * module can move, but a list it summarises as "19 patterns" cannot be copied from, and
6384
+ * `overrides` REPLACES a list rather than extending it: a developer who wants one more exclusion
6385
+ * has to write the current ones back, so they need to read them. This is where.
6386
+ *
6387
+ * ⚠️ `--run` answers a narrower question, on purpose. The report prints the settings its FIGURES
6388
+ * came from, which is what makes two modules comparable; this prints everything that applies,
6389
+ * including what no pass has used yet. The two are not interchangeable.
6390
+ */
6391
+ async inspect(ctx) {
6392
+ const { config, from, unreadable } = await loadStaticConfig(ctx.cwd);
6393
+ const roleValue = (path2) => path2.split(".").reduce(
6394
+ (node, key) => node && typeof node === "object" ? node[key] : void 0,
6395
+ defaults
6396
+ );
6397
+ return {
6398
+ runner: this.runner,
6399
+ configFile: from,
6400
+ ...unreadable ? { unreadable } : {},
6401
+ reportDir: REPORT_DIR,
6402
+ // The resolved values, not the role's: what this module measures with.
6403
+ settings: {
6404
+ entry: config.entry,
6405
+ ignore: config.ignore,
6406
+ languages: config.languages,
6407
+ journeys: config.journeys,
6408
+ duplication: config.duplication,
6409
+ deadCode: config.deadCode
6410
+ },
6411
+ // What this module moved, with the role's value beside it, so the difference is readable
6412
+ // without opening the package.
6413
+ overrides: config.moved.map((path2) => ({
6414
+ setting: path2,
6415
+ module: roleValue(path2) === void 0 ? "set" : JSON.stringify(configValue(config, path2)),
6416
+ role: JSON.stringify(roleValue(path2))
6417
+ })),
6418
+ // What the two passes will actually do with the globs, which is where a mistaken override
6419
+ // shows up first: a product pass excluding nothing, or a test pass selecting nothing.
6420
+ passes: {
6421
+ product: { excludes: exclusions(config, "product").length },
6422
+ tests: { excludes: exclusions(config, "tests").length, selects: config.duplication.tests }
6423
+ }
6424
+ };
6425
+ }
6426
+ /**
6427
+ * `--report`: the same figures, for a machine.
6428
+ *
6429
+ * It writes nothing and prints nothing: the caller is the `--json` envelope or the CI comment,
6430
+ * and a role that also printed its table would make the same numbers arrive twice in two shapes.
6431
+ */
6432
+ async report(ctx) {
6433
+ return this.measure(ctx, false);
5092
6434
  }
5093
6435
  /**
5094
6436
  * Adoption, from the COMMITTED manifest only, so a workspace-wide sweep stays cheap and needs no
@@ -5098,7 +6440,7 @@ ${result.stderr ?? ""}`
5098
6440
  const declared = moduleScripts(ctx.cwd);
5099
6441
  const missing = Object.keys(scripts()).filter((name) => !(name in declared));
5100
6442
  const adopted = missing.length < Object.keys(scripts()).length;
5101
- const hasConfig = existsSync14(join19(ctx.cwd, STATIC_CONFIG_FILE));
6443
+ const hasConfig = existsSync16(join22(ctx.cwd, STATIC_CONFIG_FILE));
5102
6444
  return {
5103
6445
  adopted,
5104
6446
  preset: adopted ? ctx.preset : null,
@@ -5110,41 +6452,332 @@ ${result.stderr ?? ""}`
5110
6452
  };
5111
6453
  }
5112
6454
  };
5113
- function buildReport(ctx, reportDir, config) {
5114
- const path = join19(reportDir, "jscpd-report.json");
5115
- if (!existsSync14(path)) return void 0;
5116
- let parsed;
6455
+ function analysedNothing(stderr) {
6456
+ return typeof stderr === "string" && stderr.includes("--fail-on-empty");
6457
+ }
6458
+ function measuredWith(config) {
6459
+ const moved = (key) => config.moved.some((path2) => path2 === key || path2.startsWith(`${key}.`));
6460
+ const row = (key, value) => ({ key, value, moved: moved(key) });
6461
+ return [
6462
+ row("duplication.minTokens", String(config.duplication.minTokens)),
6463
+ row("duplication.minLines", String(config.duplication.minLines)),
6464
+ row("duplication.kinds", config.duplication.kinds.join(", ")),
6465
+ row("languages", `${config.languages.length}: ${config.languages.join(", ")}`),
6466
+ // The count the SCAN received, not the module's list: the role unions its own artefacts in,
6467
+ // and printing 20 while measuring with 22 is the kind of small lie that costs an afternoon.
6468
+ // The product pass, which is the one the headline figure comes from.
6469
+ row("ignore", `${exclusions(config, "product").length} patterns`),
6470
+ row("duplication.tests", config.duplication.tests.join(" ")),
6471
+ // The dead-code pass's own two, and the reason they are in the same block: a reader comparing
6472
+ // two modules' unreachable share needs the confidence floor as much as the detection window.
6473
+ row("deadCode.minConfidence", String(config.deadCode.minConfidence)),
6474
+ row("deadCode.categories", config.deadCode.categories.join(", ")),
6475
+ row("entry", `${config.entry.length} patterns`),
6476
+ // ⛔ Always printed, even when no size row appears, because this is the line that tells a
6477
+ // reader why a breach can sit beside a green lint. The thresholds are not a module setting and
6478
+ // never appear as `(module)`: a gauge whose scale moves per module compares nothing.
6479
+ row(
6480
+ "size indicators",
6481
+ `${Object.entries(PUBLISHED_THRESHOLDS).map(([rule, threshold]) => `${rule} ${threshold}`).join(", ")} (published defaults, oxlint run outside the module)`
6482
+ ),
6483
+ // ⚠️ The graph pass's one knob, and it has to be here: it changes both the community count and
6484
+ // `Q`, so the two figures are meaningless without it. The seed is NOT printed beside it,
6485
+ // because it is not a setting: see `graph/scan.ts` for why the role owns it.
6486
+ row("graph.resolution", String(config.graph.resolution)),
6487
+ // Not a setting a module can move: it is McCabe's own published figure, and the row says so
6488
+ // rather than letting a reader take it for one of ours.
6489
+ {
6490
+ key: "complexity bands",
6491
+ value: RISK_BANDS.map(
6492
+ (band) => `${band.from}${band.to === Infinity ? "+" : `-${band.to}`}`
6493
+ ).join(" / "),
6494
+ moved: false
6495
+ }
6496
+ ];
6497
+ }
6498
+ function runTool(jscpd, args, cwd, pass) {
6499
+ const result = spawnSync6(jscpd, args, { cwd, encoding: "utf8" });
6500
+ if (result.error) {
6501
+ process.stderr.write(
6502
+ `sentinel static-analysis(jscpd): could not run the ${pass} pass (${result.error.message})
6503
+ `
6504
+ );
6505
+ return { kind: "failed", code: 1 };
6506
+ }
6507
+ if (result.status !== 0) {
6508
+ if (analysedNothing(result.stderr)) return { kind: "empty" };
6509
+ process.stderr.write(
6510
+ `sentinel static-analysis(jscpd): the ${pass} pass exited ${result.status}
6511
+ ${result.stderr ?? ""}`
6512
+ );
6513
+ return { kind: "failed", code: result.status ?? 1 };
6514
+ }
6515
+ return { kind: "ran" };
6516
+ }
6517
+ function runDeadCode(jscpd, config, paths, cwd, extraEntries = []) {
6518
+ const { args, rawReportDir, dispose } = deadCodeRun(config, paths, extraEntries);
6519
+ try {
6520
+ const ran = runTool(jscpd, args, cwd, "dead-code");
6521
+ if (ran.kind !== "ran") return ran;
6522
+ const path2 = join22(rawReportDir, RAW_REPORT["dead-code"]);
6523
+ if (!existsSync16(path2)) {
6524
+ process.stderr.write(
6525
+ `sentinel static-analysis(jscpd): the dead-code pass wrote no ${RAW_REPORT["dead-code"]}
6526
+ `
6527
+ );
6528
+ return { kind: "failed", code: 1 };
6529
+ }
6530
+ const parsed = safeParseDeadCode(readFileSync17(path2, "utf8"));
6531
+ const statistics = parsed?.statistics;
6532
+ if (!statistics) {
6533
+ process.stderr.write(
6534
+ `sentinel static-analysis(jscpd): the dead-code report has no readable figures
6535
+ `
6536
+ );
6537
+ return { kind: "failed", code: 1 };
6538
+ }
6539
+ if ((statistics.entryPoints ?? 0) === 0) {
6540
+ return { kind: "no-entry-point", files: statistics.files ?? 0 };
6541
+ }
6542
+ const byCategory = {};
6543
+ for (const finding of parsed?.findings ?? []) {
6544
+ const category = finding.category ?? "unknown";
6545
+ byCategory[category] = (byCategory[category] ?? 0) + 1;
6546
+ }
6547
+ return { kind: "figures", statistics, byCategory, findings: parsed?.findings ?? [] };
6548
+ } finally {
6549
+ dispose();
6550
+ }
6551
+ }
6552
+ function runDuplication(jscpd, config, paths, scope, cwd) {
6553
+ const { args, rawReportDir, dispose } = duplicationRun(config, paths, scope);
6554
+ try {
6555
+ const ran = runTool(jscpd, args, cwd, scope);
6556
+ if (ran.kind !== "ran") return ran;
6557
+ const path2 = join22(rawReportDir, RAW_REPORT.duplication);
6558
+ const parsed = existsSync16(path2) ? safeParse(readFileSync17(path2, "utf8")) : void 0;
6559
+ const total = parsed?.statistics?.total;
6560
+ if (!total) {
6561
+ process.stderr.write(
6562
+ `sentinel static-analysis(jscpd): the ${scope} pass wrote no readable report
6563
+ `
6564
+ );
6565
+ return { kind: "failed", code: 1 };
6566
+ }
6567
+ return {
6568
+ kind: "figures",
6569
+ total,
6570
+ formats: parsed?.statistics?.formats ?? {},
6571
+ duplicates: parsed?.duplicates ?? []
6572
+ };
6573
+ } finally {
6574
+ dispose();
6575
+ }
6576
+ }
6577
+ function safeParse(text) {
5117
6578
  try {
5118
- parsed = JSON.parse(readFileSync14(path, "utf8"));
6579
+ return JSON.parse(text);
5119
6580
  } catch {
5120
6581
  return void 0;
5121
6582
  }
5122
- const total = parsed.statistics?.total;
5123
- if (!total) return void 0;
5124
- const limit = config.thresholds.duplicatedLinesPercent;
5125
- const moved = config.moved.includes("thresholds.duplicatedLinesPercent");
5126
- const percent = Number((total.percentage ?? 0).toFixed(2));
5127
- const rows = [
5128
- {
5129
- metric: "duplicatedLinesPercent",
5130
- value: percent,
5131
- unit: "%",
5132
- threshold: limit,
5133
- over: percent > limit,
5134
- moved
5135
- },
5136
- {
5137
- metric: "clones",
5138
- value: total.clones ?? 0,
5139
- unit: "entries",
5140
- threshold: null,
5141
- over: false,
5142
- moved: false
6583
+ }
6584
+ function safeParseDeadCode(text) {
6585
+ try {
6586
+ return JSON.parse(text);
6587
+ } catch {
6588
+ return void 0;
6589
+ }
6590
+ }
6591
+ function placesToLook(product, tests, dead, complexity, graph) {
6592
+ const at = (side) => `${side?.name ?? "(unknown)"}${side?.start === void 0 ? "" : `:${side.start}`}`;
6593
+ const clones = (outcome, metric) => outcome.kind === "figures" ? outcome.duplicates.map((entry) => ({
6594
+ metric,
6595
+ path: at(entry.firstFile),
6596
+ other: at(entry.secondFile),
6597
+ ...entry.lines === void 0 ? {} : { lines: entry.lines },
6598
+ ...entry.fragment ? { excerpt: entry.fragment } : {}
6599
+ })) : [];
6600
+ const deadMetric = {
6601
+ "unused-file": "unusedFiles",
6602
+ "unused-export": "unusedExports",
6603
+ "unused-symbol": "unusedSymbols",
6604
+ "unused-import": "unusedImports"
6605
+ };
6606
+ return [
6607
+ ...clones(product, "clones"),
6608
+ ...clones(tests, "clonesTests"),
6609
+ // Each one under the band it falls in, so it sits beside the caveat that applies to it.
6610
+ ...complexity.complex.map((fn) => ({
6611
+ metric: RISK_BANDS.find((band) => fn.complexity >= band.from && fn.complexity <= band.to)?.metric ?? "complexityMax",
6612
+ path: `${fn.path}:${fn.line}`,
6613
+ name: fn.name,
6614
+ value: fn.complexity
6615
+ })),
6616
+ ...dead.kind === "figures" ? dead.findings.map((finding) => ({
6617
+ metric: deadMetric[finding.category ?? ""] ?? "deadLinesPercent",
6618
+ path: finding.path ?? "(unknown)",
6619
+ ...finding.name ? { name: finding.name } : {},
6620
+ ...finding.lines === void 0 ? {} : { lines: finding.lines },
6621
+ ...finding.confidence === void 0 ? {} : { confidence: finding.confidence },
6622
+ ...finding.reasons?.length ? { reason: finding.reasons.join(", ") } : {},
6623
+ ...finding.message ? { message: finding.message } : {}
6624
+ })) : [],
6625
+ // One finding per GROUP of mutually importing files, not per cycle path: see `graph/scan.ts`.
6626
+ // The first file names the finding and the rest go in `other`, which is the shape the report
6627
+ // already renders for a clone's two sides.
6628
+ ...graph.cycles.map((cycle) => ({
6629
+ metric: "cycles",
6630
+ path: cycle.files[0] ?? "(unknown)",
6631
+ other: cycle.files.slice(1).join(", "),
6632
+ value: cycle.files.length
6633
+ })),
6634
+ // ⛔ Context, not defects. Every module has a most-imported file, and nothing here says the
6635
+ // count is too high: the caveat on the metric says so in the reader's own words.
6636
+ ...graph.mostImported.map((file) => ({
6637
+ metric: "mostImported",
6638
+ path: file.path,
6639
+ value: file.importedBy,
6640
+ dependsOn: file.dependsOn,
6641
+ instability: Number(file.instability.toFixed(2)),
6642
+ centrality: Number(file.centrality.toFixed(4))
6643
+ }))
6644
+ ];
6645
+ }
6646
+ function outsideOf(cwd) {
6647
+ const empty = { imported: /* @__PURE__ */ new Set(), testOnly: /* @__PURE__ */ new Set() };
6648
+ const root = workspaceRootFrom(cwd);
6649
+ if (!root) return empty;
6650
+ try {
6651
+ return outsideView(
6652
+ consumerIndex(root, () => moduleRootsOf(root)),
6653
+ root,
6654
+ cwd
6655
+ );
6656
+ } catch {
6657
+ return empty;
6658
+ }
6659
+ }
6660
+ function moduleRootsOf(workspaceRoot) {
6661
+ const roots = [];
6662
+ for (const area of ["apps", "libs"]) {
6663
+ const base = join22(workspaceRoot, area);
6664
+ if (!existsSync16(base)) continue;
6665
+ for (const entry of globSync4([`${area}/**/package.json`, `${area}/**/project.json`], {
6666
+ cwd: workspaceRoot,
6667
+ ignore: ["**/node_modules/**"],
6668
+ dot: false
6669
+ })) {
6670
+ const root = entry.split("/").slice(0, -1).join("/");
6671
+ if (root) roots.push(root);
5143
6672
  }
6673
+ }
6674
+ return [...new Set(roots)];
6675
+ }
6676
+ function assembleReport(ctx, config, product, tests, dead, complexity, graph, coverage, size, outside) {
6677
+ const unreadable = hasUnreadableCode(coverage);
6678
+ const share = (total) => Number((total.percentage ?? 0).toFixed(2));
6679
+ const rows = [
6680
+ { metric: "duplicatedLinesPercent", value: share(product.total), unit: "%" },
6681
+ { metric: "clones", value: product.total.clones ?? 0, unit: "entries" }
5144
6682
  ];
5145
- const formats = parsed.statistics?.formats ?? {};
6683
+ const notes = [];
6684
+ if (tests.kind !== "figures") {
6685
+ notes.push("no test file to measure, so no test duplication figure is reported");
6686
+ }
6687
+ if (dead.kind === "no-entry-point" && !unreadable) {
6688
+ notes.push(
6689
+ `no entry point found, so nothing could be reached and no dead-code figure is reported (${dead.files} file(s) scanned): declare what RUNS here in \`entry\``
6690
+ );
6691
+ }
6692
+ if (dead.kind === "empty" && !unreadable) {
6693
+ notes.push("the dead-code pass had nothing left to analyse, so no figure is reported");
6694
+ }
6695
+ if (tests.kind === "figures") {
6696
+ rows.push(
6697
+ { metric: "duplicatedLinesPercentTests", value: share(tests.total), unit: "%" },
6698
+ { metric: "clonesTests", value: tests.total.clones ?? 0, unit: "entries" }
6699
+ );
6700
+ }
6701
+ if (unreadable) {
6702
+ notes.push(unreadableNote(coverage));
6703
+ }
6704
+ if (dead.kind === "figures" && !unreadable) {
6705
+ const stats = dead.statistics;
6706
+ rows.push(
6707
+ {
6708
+ metric: "entryPoints",
6709
+ value: stats.entryPoints ?? 0,
6710
+ unit: `of ${stats.files ?? 0} files`
6711
+ },
6712
+ { metric: "deadLinesPercent", value: share(stats), unit: "%" },
6713
+ { metric: "unusedFiles", value: dead.byCategory["unused-file"] ?? 0, unit: "files" },
6714
+ { metric: "unusedExports", value: dead.byCategory["unused-export"] ?? 0, unit: "exports" },
6715
+ { metric: "unusedSymbols", value: dead.byCategory["unused-symbol"] ?? 0, unit: "symbols" },
6716
+ { metric: "unusedImports", value: dead.byCategory["unused-import"] ?? 0, unit: "imports" }
6717
+ );
6718
+ }
6719
+ if (outside.testOnly.size > 0 && !unreadable) {
6720
+ rows.push({ metric: "usedOnlyByTests", value: outside.testOnly.size, unit: "files" });
6721
+ }
6722
+ if (complexity.functions > 0) {
6723
+ rows.push({ metric: "complexityMax", value: complexity.max, unit: "max per function" });
6724
+ for (const band of RISK_BANDS) {
6725
+ const count = complexity.bands[band.metric] ?? 0;
6726
+ if (count === 0) continue;
6727
+ rows.push({
6728
+ metric: band.metric,
6729
+ value: count,
6730
+ unit: `of ${complexity.functions} functions`
6731
+ });
6732
+ }
6733
+ } else {
6734
+ notes.push("no function to measure, so no complexity figure is reported");
6735
+ }
6736
+ if (graph.files > 1 && !unreadable) {
6737
+ rows.push(
6738
+ { metric: "importEdges", value: graph.edges, unit: `between ${graph.files} files` },
6739
+ {
6740
+ metric: "graphDensity",
6741
+ value: Number(graph.density.toFixed(4)),
6742
+ unit: "of the possible edges"
6743
+ },
6744
+ {
6745
+ metric: "cycles",
6746
+ // ⛔ Zero is the expected value here, so the unit has to read correctly at zero as well as
6747
+ // at one, which `1 groups, 2 files` did not. The file count only means something when
6748
+ // there is a group, so it is absent when there is none.
6749
+ value: graph.cycles.length,
6750
+ unit: graph.cycles.length === 0 ? "none" : `${graph.cycles.length === 1 ? "group" : "groups"}, ${graph.filesInCycles} files`
6751
+ },
6752
+ { metric: "communities", value: graph.communities, unit: `of ${graph.files} files` }
6753
+ );
6754
+ if (graph.modularity === void 0) {
6755
+ notes.push("no import between the files, so no modularity is reported");
6756
+ } else {
6757
+ rows.push({ metric: "modularity", value: Number(graph.modularity.toFixed(4)), unit: "" });
6758
+ }
6759
+ } else if (!unreadable) {
6760
+ notes.push("fewer than two files, so no graph figure is reported");
6761
+ }
6762
+ if (size) {
6763
+ const counts = /* @__PURE__ */ new Map();
6764
+ for (const breach of size.breaches) counts.set(breach.rule, (counts.get(breach.rule) ?? 0) + 1);
6765
+ for (const [rule, threshold] of Object.entries(PUBLISHED_THRESHOLDS)) {
6766
+ const count = counts.get(rule) ?? 0;
6767
+ if (count === 0) continue;
6768
+ rows.push({
6769
+ metric: rule,
6770
+ value: count,
6771
+ // The threshold travels WITH the figure: the same count means two different things at two
6772
+ // thresholds, and this gauge's thresholds are not the ones a module's lint enforces.
6773
+ unit: `files over ${threshold}, of ${size.files}`
6774
+ });
6775
+ }
6776
+ } else {
6777
+ notes.push("oxlint could not be run, so no size indicator is reported");
6778
+ }
5146
6779
  const byLanguage = Object.fromEntries(
5147
- Object.entries(formats).map(([language, stats]) => [
6780
+ Object.entries(product.formats).map(([language, stats]) => [
5148
6781
  language,
5149
6782
  {
5150
6783
  files: stats.sources ?? 0,
@@ -5153,26 +6786,59 @@ function buildReport(ctx, reportDir, config) {
5153
6786
  }
5154
6787
  ])
5155
6788
  );
5156
- return { module: ctx.module, passes: ["duplication"], rows, byLanguage };
6789
+ return {
6790
+ module: ctx.module,
6791
+ passes: [
6792
+ "duplication",
6793
+ ...tests.kind === "figures" ? ["duplication (tests)"] : [],
6794
+ ...dead.kind === "figures" ? ["dead code"] : [],
6795
+ ...complexity.functions > 0 ? ["complexity"] : [],
6796
+ ...graph.files > 1 ? ["graph"] : []
6797
+ ],
6798
+ rows,
6799
+ notes,
6800
+ settings: measuredWith(config),
6801
+ findings: placesToLook(product, tests, dead, complexity, graph),
6802
+ byLanguage
6803
+ };
5157
6804
  }
5158
- function emit(report, shape, reportDir) {
6805
+ async function emit(report, shape, cwd, graph, coverage) {
5159
6806
  if (shape === "console") {
5160
6807
  process.stdout.write(renderConsole(report));
5161
6808
  return;
5162
6809
  }
6810
+ mkdirSync2(join22(cwd, REPORT_DIR), { recursive: true });
6811
+ const written = [];
6812
+ if (shape === "html" && graph.files > 1 && !hasUnreadableCode(coverage)) {
6813
+ const picture = `${TARGET}-graph.svg`;
6814
+ const path2 = join22(cwd, REPORT_DIR, picture);
6815
+ await writeGraphPicture(graph.graph, graph.communityOf, path2);
6816
+ written.push(`${REPORT_DIR}/${picture}`);
6817
+ report.picture = inlineable(readFileSync17(path2, "utf8"));
6818
+ }
5163
6819
  const name = shape === "json" ? "static-analysis.json" : "static-analysis.html";
5164
6820
  const body = shape === "json" ? renderJson(report) : renderHtml(report);
5165
- const path = join19(reportDir, name);
5166
- writeFileSync4(path, body, "utf8");
6821
+ writeFileSync6(join22(cwd, REPORT_DIR, name), body, "utf8");
6822
+ written.unshift(`${REPORT_DIR}/${name}`);
5167
6823
  process.stdout.write(`${renderConsole(report)}
5168
- written: ${REPORT_DIR}/${name}
6824
+ written: ${written.join(", ")}
5169
6825
  `);
5170
6826
  }
6827
+ function inlineable(svg) {
6828
+ return svg.replace(/^<\?xml[^>]*\?>\s*/, "").replace(/^(<svg)\s+width="[^"]*"\s+height="[^"]*"/, "$1");
6829
+ }
5171
6830
  function metricsOf(report) {
5172
6831
  return {
5173
6832
  passes: report.passes,
5174
- finding: hasFinding(report),
6833
+ // Carried for the same reason they are printed: a consumer reading an absent figure needs to
6834
+ // know whether it is clean or unmeasurable.
6835
+ notes: report.notes,
5175
6836
  ...Object.fromEntries(report.rows.map((row) => [row.metric, row.value])),
6837
+ // Carried into `--json` and the CI comment for the same reason the console prints them: a
6838
+ // figure whose settings are unknown cannot be compared with the next module's.
6839
+ settings: Object.fromEntries(
6840
+ report.settings.map((s) => [s.key, { value: s.value, moved: s.moved }])
6841
+ ),
5176
6842
  byLanguage: report.byLanguage
5177
6843
  };
5178
6844
  }
@@ -5190,10 +6856,10 @@ function registerTest() {
5190
6856
  }
5191
6857
 
5192
6858
  // src/roles/typescript/adapters/tsc/tsc.adapter.ts
5193
- import { spawnSync as spawnSync6 } from "node:child_process";
5194
- import { existsSync as existsSync15, readFileSync as readFileSync16 } from "node:fs";
6859
+ import { spawnSync as spawnSync7 } from "node:child_process";
6860
+ import { existsSync as existsSync17, readFileSync as readFileSync19 } from "node:fs";
5195
6861
  import { createRequire } from "node:module";
5196
- import { join as join21 } from "node:path";
6862
+ import { join as join24 } from "node:path";
5197
6863
 
5198
6864
  // src/roles/typescript/presets/nest.json
5199
6865
  var nest_default2 = {
@@ -5373,13 +7039,13 @@ function readTsconfigAdoption(cwd) {
5373
7039
  // Drift is measured against what the preset SETS, not against the allowlist alone: see
5374
7040
  // `presetOwnedKeys`. Without this a module overriding the workspace base reads as
5375
7041
  // non-conformant forever, since re-running `--init` would not (and should not) fix it.
5376
- presetOwns: (path) => presetCompilerOptionKeys(path.slice(path.lastIndexOf("/") + 1)) ?? []
7042
+ presetOwns: (path2) => presetCompilerOptionKeys(path2.slice(path2.lastIndexOf("/") + 1)) ?? []
5377
7043
  });
5378
7044
  }
5379
7045
 
5380
7046
  // src/roles/typescript/adapters/tsc/plan.ts
5381
- import { readFileSync as readFileSync15 } from "node:fs";
5382
- import { join as join20 } from "node:path";
7047
+ import { readFileSync as readFileSync18 } from "node:fs";
7048
+ import { join as join23 } from "node:path";
5383
7049
 
5384
7050
  // src/roles/typescript/typecheck-script.ts
5385
7051
  var SENTINEL_TYPECHECK_COMMAND = "sentinel --run --typescript";
@@ -5472,7 +7138,7 @@ function planAdoption(context) {
5472
7138
  };
5473
7139
  }
5474
7140
  const existing = parseJsonc(
5475
- readFileSync15(join20(context.cwd, target.path), "utf8"),
7141
+ readFileSync18(join23(context.cwd, target.path), "utf8"),
5476
7142
  target.path
5477
7143
  );
5478
7144
  const extendsChain = composeExtends(existing.extends, preset);
@@ -5610,7 +7276,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5610
7276
  let chain;
5611
7277
  try {
5612
7278
  const parsed = parseJsonc(
5613
- readFileSync16(join21(cwd, target.path), "utf8"),
7279
+ readFileSync19(join24(cwd, target.path), "utf8"),
5614
7280
  target.path
5615
7281
  );
5616
7282
  chain = parsed.extends;
@@ -5623,7 +7289,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5623
7289
  );
5624
7290
  if (preset === void 0) return void 0;
5625
7291
  try {
5626
- createRequire(join21(cwd, "noop.js")).resolve(preset);
7292
+ createRequire(join24(cwd, "noop.js")).resolve(preset);
5627
7293
  return void 0;
5628
7294
  } catch {
5629
7295
  return preset;
@@ -5664,7 +7330,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5664
7330
  return { ok: false, code: 1 };
5665
7331
  }
5666
7332
  if (options.length > 0) return this.runWithOptions(ctx, tsc, config);
5667
- const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
7333
+ const result = spawnSync7(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
5668
7334
  if (result.error) {
5669
7335
  process.stderr.write(
5670
7336
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5699,7 +7365,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5699
7365
  let worst = 0;
5700
7366
  for (const project of this.referencedProjects(ctx.cwd, config)) {
5701
7367
  const args = ["-p", project, "--noEmit", "--composite", "false", ...ctx.toolArgs ?? []];
5702
- const result = spawnSync6(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
7368
+ const result = spawnSync7(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
5703
7369
  if (result.error) {
5704
7370
  process.stderr.write(
5705
7371
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5719,10 +7385,10 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5719
7385
  referencedProjects(cwd, config) {
5720
7386
  try {
5721
7387
  const parsed = parseJsonc(
5722
- readFileSync16(join21(cwd, config), "utf8"),
7388
+ readFileSync19(join24(cwd, config), "utf8"),
5723
7389
  config
5724
7390
  );
5725
- const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path) => typeof path === "string" && path.length > 0);
7391
+ const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path2) => typeof path2 === "string" && path2.length > 0);
5726
7392
  return referenced.length > 0 ? referenced : [config];
5727
7393
  } catch {
5728
7394
  return [config];
@@ -5735,7 +7401,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5735
7401
  * check.
5736
7402
  */
5737
7403
  typecheckTarget(cwd) {
5738
- if (existsSync15(join21(cwd, "tsconfig.json"))) return "tsconfig.json";
7404
+ if (existsSync17(join24(cwd, "tsconfig.json"))) return "tsconfig.json";
5739
7405
  const target = resolveTsconfigTarget(cwd);
5740
7406
  return target.reason === "none" ? null : target.path;
5741
7407
  }
@@ -5788,7 +7454,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5788
7454
  return { ok: true, code: 0, metrics: _TscAdapter.NOTHING_TO_REPORT };
5789
7455
  }
5790
7456
  const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
5791
- const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
7457
+ const result = spawnSync7(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
5792
7458
  if (result.error) {
5793
7459
  process.stderr.write(
5794
7460
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5831,7 +7497,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5831
7497
  * errors in the output mean the rule must be on.
5832
7498
  */
5833
7499
  noImplicitAnyEnabled(cwd, tsc, config, mainOutput) {
5834
- const shown = spawnSync6(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
7500
+ const shown = spawnSync7(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
5835
7501
  if (shown.status === 0 && shown.stdout) {
5836
7502
  try {
5837
7503
  const co = parseJsonc(
@@ -5941,31 +7607,31 @@ function replaceLines(current, replacements) {
5941
7607
  }
5942
7608
 
5943
7609
  // src/core/apply-plan.ts
5944
- import { existsSync as existsSync16, readFileSync as readFileSync17, renameSync, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
5945
- import { resolve as resolve3, sep } from "node:path";
7610
+ import { existsSync as existsSync18, readFileSync as readFileSync20, renameSync, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "node:fs";
7611
+ import { resolve as resolve4, sep as sep2 } from "node:path";
5946
7612
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
5947
7613
  function resolveWithinRoot(cwd, relativePath) {
5948
- const root = resolve3(cwd);
5949
- const absolutePath = resolve3(root, relativePath);
5950
- if (absolutePath !== root && !absolutePath.startsWith(root + sep)) {
7614
+ const root = resolve4(cwd);
7615
+ const absolutePath = resolve4(root, relativePath);
7616
+ if (absolutePath !== root && !absolutePath.startsWith(root + sep2)) {
5951
7617
  throw new Error(`Refusing to write outside the module root: "${relativePath}".`);
5952
7618
  }
5953
7619
  return absolutePath;
5954
7620
  }
5955
7621
  function readIfExists(absolutePath) {
5956
- return existsSync16(absolutePath) ? readFileSync17(absolutePath, "utf8") : void 0;
7622
+ return existsSync18(absolutePath) ? readFileSync20(absolutePath, "utf8") : void 0;
5957
7623
  }
5958
7624
  function* leaves(value, prefix = []) {
5959
7625
  for (const [key, keyValue] of Object.entries(value)) {
5960
- const path = [...prefix, key];
5961
- if (isPlainObject(keyValue)) yield* leaves(keyValue, path);
5962
- else yield [path, keyValue];
7626
+ const path2 = [...prefix, key];
7627
+ if (isPlainObject(keyValue)) yield* leaves(keyValue, path2);
7628
+ else yield [path2, keyValue];
5963
7629
  }
5964
7630
  }
5965
7631
  function mergeJsonc(current, value) {
5966
7632
  let text = current.trim().length > 0 ? current : "{}\n";
5967
- for (const [path, leaf] of leaves(value)) {
5968
- const edits = modify(text, path, leaf, {
7633
+ for (const [path2, leaf] of leaves(value)) {
7634
+ const edits = modify(text, path2, leaf, {
5969
7635
  formattingOptions: { insertSpaces: true, tabSize: 2 }
5970
7636
  });
5971
7637
  text = applyEdits(text, edits);
@@ -5974,10 +7640,10 @@ function mergeJsonc(current, value) {
5974
7640
  }
5975
7641
  function removeJsoncKeys(current, keys) {
5976
7642
  let text = current.trim().length > 0 ? current : "{}\n";
5977
- for (const path of keys) {
7643
+ for (const path2 of keys) {
5978
7644
  const root = parseTree(text);
5979
- if (!root || !findNodeAtLocation(root, path)) continue;
5980
- const edits = modify(text, path, void 0, {
7645
+ if (!root || !findNodeAtLocation(root, path2)) continue;
7646
+ const edits = modify(text, path2, void 0, {
5981
7647
  formattingOptions: { insertSpaces: true, tabSize: 2 }
5982
7648
  });
5983
7649
  text = applyEdits(text, edits);
@@ -5991,14 +7657,14 @@ function sameJson(left, right) {
5991
7657
  return false;
5992
7658
  }
5993
7659
  }
5994
- function assertParseable(path, current) {
7660
+ function assertParseable(path2, current) {
5995
7661
  if (current.trim().length === 0) return;
5996
7662
  try {
5997
- parseJsonc(current, path);
7663
+ parseJsonc(current, path2);
5998
7664
  } catch (error) {
5999
7665
  const reason = error instanceof Error ? error.message : String(error);
6000
7666
  throw new Error(
6001
- `${path} does not parse, so sentinel will not edit it: ${reason}. Nothing was written. Fix the file and re-run.`,
7667
+ `${path2} does not parse, so sentinel will not edit it: ${reason}. Nothing was written. Fix the file and re-run.`,
6002
7668
  { cause: error }
6003
7669
  );
6004
7670
  }
@@ -6055,14 +7721,14 @@ function preparePlan(cwd, plan3) {
6055
7721
  }
6056
7722
  function writeFileAtomic(absolutePath, contents) {
6057
7723
  const tempPath = `${absolutePath}.sentinel-${process.pid}.tmp`;
6058
- writeFileSync5(tempPath, contents);
7724
+ writeFileSync7(tempPath, contents);
6059
7725
  renameSync(tempPath, absolutePath);
6060
7726
  }
6061
7727
  function applyPlan(cwd, plan3) {
6062
7728
  const changed = preparePlan(cwd, plan3).filter((file) => file.before !== file.after);
6063
7729
  for (const file of changed) {
6064
7730
  if (file.deleted) {
6065
- rmSync4(file.absolutePath, { force: true });
7731
+ rmSync5(file.absolutePath, { force: true });
6066
7732
  continue;
6067
7733
  }
6068
7734
  writeFileAtomic(file.absolutePath, file.after);
@@ -6071,8 +7737,8 @@ function applyPlan(cwd, plan3) {
6071
7737
  }
6072
7738
 
6073
7739
  // src/core/config/preset-evidence.ts
6074
- import { existsSync as existsSync17, readdirSync as readdirSync3, readFileSync as readFileSync18 } from "node:fs";
6075
- import { join as join22 } from "node:path";
7740
+ import { existsSync as existsSync19, readdirSync as readdirSync3, readFileSync as readFileSync21 } from "node:fs";
7741
+ import { join as join25 } from "node:path";
6076
7742
  var PATH_SIGNALS = [
6077
7743
  {
6078
7744
  preset: "nest",
@@ -6106,12 +7772,12 @@ function sourceImports(cwd, pattern) {
6106
7772
  }
6107
7773
  for (const entry of entries) {
6108
7774
  if (entry.isDirectory()) {
6109
- if (!NOT_SCANNED.has(entry.name)) stack.push(join22(dir, entry.name));
7775
+ if (!NOT_SCANNED.has(entry.name)) stack.push(join25(dir, entry.name));
6110
7776
  continue;
6111
7777
  }
6112
7778
  if (!SOURCE_FILE.test(entry.name)) continue;
6113
7779
  try {
6114
- if (pattern.test(readFileSync18(join22(dir, entry.name), "utf8"))) return true;
7780
+ if (pattern.test(readFileSync21(join25(dir, entry.name), "utf8"))) return true;
6115
7781
  } catch {
6116
7782
  continue;
6117
7783
  }
@@ -6120,12 +7786,12 @@ function sourceImports(cwd, pattern) {
6120
7786
  return false;
6121
7787
  }
6122
7788
  function dependencyNames(cwd) {
6123
- const path = join22(cwd, "package.json");
6124
- if (!existsSync17(path)) return [];
7789
+ const path2 = join25(cwd, "package.json");
7790
+ if (!existsSync19(path2)) return [];
6125
7791
  try {
6126
7792
  const manifest = parseJsonc(
6127
- readFileSync18(path, "utf8"),
6128
- path
7793
+ readFileSync21(path2, "utf8"),
7794
+ path2
6129
7795
  );
6130
7796
  return [
6131
7797
  ...Object.keys(manifest.dependencies ?? {}),
@@ -6147,7 +7813,7 @@ function reactTsconfigEvidence(cwd) {
6147
7813
  }
6148
7814
  for (const name of entries) {
6149
7815
  try {
6150
- const config = parseJsonc(readFileSync18(join22(cwd, name), "utf8"), name);
7816
+ const config = parseJsonc(readFileSync21(join25(cwd, name), "utf8"), name);
6151
7817
  if (config.compilerOptions?.jsx !== void 0) return 'its tsconfig sets "jsx"';
6152
7818
  const extended = Array.isArray(config.extends) ? config.extends : config.extends === void 0 ? [] : [config.extends];
6153
7819
  if (extended.some((entry) => entry === REACT_TYPESCRIPT_PRESET)) {
@@ -6217,8 +7883,8 @@ function previewPlan(opts, plan3) {
6217
7883
  {
6218
7884
  dryRun: true,
6219
7885
  notes: plan3.notes ?? [],
6220
- files: changed.map(({ path, before, after, deleted }) => ({
6221
- path,
7886
+ files: changed.map(({ path: path2, before, after, deleted }) => ({
7887
+ path: path2,
6222
7888
  action: deleted ? "delete" : before.length === 0 ? "create" : "update",
6223
7889
  before,
6224
7890
  after
@@ -6237,10 +7903,10 @@ function previewPlan(opts, plan3) {
6237
7903
  process.stderr.write(" nothing to change\n");
6238
7904
  return 0;
6239
7905
  }
6240
- for (const { path, before, after, deleted } of changed) {
7906
+ for (const { path: path2, before, after, deleted } of changed) {
6241
7907
  const action = deleted ? "delete" : before.length === 0 ? "create" : "update";
6242
7908
  process.stdout.write(`
6243
- ${action} ${path}
7909
+ ${action} ${path2}
6244
7910
  `);
6245
7911
  for (const line of diffLines(before, after)) process.stdout.write(` ${line}
6246
7912
  `);