@hublo/sentinel 1.5.0-alpha.3 → 1.5.0-alpha.31

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.
@@ -52,20 +52,29 @@ import {
52
52
  rewriteToolchainImports,
53
53
  selfCommand,
54
54
  splitToolArgs,
55
- walk
56
- } from "./chunk-E3MYVYEZ.js";
55
+ walk,
56
+ writtenFileFormatter
57
+ } from "./chunk-ZFNBW6S3.js";
57
58
  import {
58
59
  DEFAULT_MAX_DIAGNOSTICS,
60
+ WORKSPACE_ROOT_MARKER,
59
61
  findWorkspaceRoot,
60
62
  moduleName,
63
+ ownPackageDir,
61
64
  readOwnPackage,
62
65
  readProjectPackageJson
63
- } from "./chunk-KMKQDGI6.js";
66
+ } from "./chunk-IL5PPLXE.js";
64
67
  import {
65
68
  REPORT_DIR,
69
+ SCRIPTS,
70
+ SHAPES,
71
+ TARGET,
72
+ defaults,
66
73
  defineStaticAnalysis,
67
- isPlainObject
68
- } from "./chunk-DVKTH3RY.js";
74
+ extensionsOf,
75
+ isPlainObject,
76
+ overridableSettings
77
+ } from "./chunk-GCHCLR7P.js";
69
78
  import {
70
79
  parseJsonc
71
80
  } from "./chunk-QXFCZON7.js";
@@ -178,10 +187,10 @@ import { join, relative } from "node:path";
178
187
  var DEPRECATED_NX_BUILDER = "@nx/webpack:webpack";
179
188
  var TRANSLATABLE_WEBPACK_CONFIG = "tools/webpack-configs/update.webpack.config.js";
180
189
  var readProjectJson = (cwd) => {
181
- const path = join(cwd, "project.json");
182
- if (!existsSync(path)) return void 0;
190
+ const path2 = join(cwd, "project.json");
191
+ if (!existsSync(path2)) return void 0;
183
192
  try {
184
- return JSON.parse(readFileSync(path, "utf8"));
193
+ return JSON.parse(readFileSync(path2, "utf8"));
185
194
  } catch {
186
195
  return void 0;
187
196
  }
@@ -786,26 +795,26 @@ function buildScripts(cwd) {
786
795
  }
787
796
  return scripts2;
788
797
  }
789
- function plan(context) {
790
- const manifest = readProjectPackageJson(context.cwd);
791
- const unmet = unmetRequirements(context.preset, {
798
+ function plan(context2) {
799
+ const manifest = readProjectPackageJson(context2.cwd);
800
+ const unmet = unmetRequirements(context2.preset, {
792
801
  ...manifest.dependencies,
793
802
  ...manifest.devDependencies
794
803
  });
795
804
  if (unmet.length > 0) {
796
- return { operations: [], skipped: describeUnmet(context.preset, unmet) };
805
+ return { operations: [], skipped: describeUnmet(context2.preset, unmet) };
797
806
  }
798
- const declaredPreset2 = declaredBuildPreset(context.cwd);
799
- const configFile = buildConfigFile(context.cwd);
807
+ const declaredPreset2 = declaredBuildPreset(context2.cwd);
808
+ const configFile = buildConfigFile(context2.cwd);
800
809
  if (declaredPreset2 === void 0 && configFile !== void 0) {
801
810
  return {
802
811
  operations: [],
803
812
  skipped: `this module has a Vite config, but it builds neither a React app (no @vitejs/plugin-react or @tanstack/react-start) nor a Nest service (no ${BUILD_PRESET_SPECIFIERS.nest}). Those are the two families this role serves.`
804
813
  };
805
814
  }
806
- const webpackTarget = webpackBuildTarget(context.cwd);
807
- if (declaredBuildPreset(context.cwd) === "nest" || webpackTarget !== void 0) {
808
- return planNestService(context, webpackTarget);
815
+ const webpackTarget = webpackBuildTarget(context2.cwd);
816
+ if (declaredBuildPreset(context2.cwd) === "nest" || webpackTarget !== void 0) {
817
+ return planNestService(context2, webpackTarget);
809
818
  }
810
819
  if (configFile === void 0) {
811
820
  return {
@@ -813,14 +822,14 @@ function plan(context) {
813
822
  skipped: `no Vite config in this module, so there is nothing to point at sentinel. This role moves an existing config's imports; it does not introduce a bundler. If this module builds another way (38 services here build through \`@nx/webpack\`), the build role does not apply to it.`
814
823
  };
815
824
  }
816
- return planReactApp(context, configFile);
825
+ return planReactApp(context2, configFile);
817
826
  }
818
- function planReactApp(context, configFile) {
827
+ function planReactApp(context2, configFile) {
819
828
  const notes = [];
820
829
  const operations = [];
821
- const esm = esmReadiness(configFile, readProjectPackageJson(context.cwd).type);
830
+ const esm = esmReadiness(configFile, readProjectPackageJson(context2.cwd).type);
822
831
  if (esm.kind === "blocked") return { operations: [], skipped: esm.why };
823
- const rewrite = rewriteBuildImports(readConfigSource(context.cwd, configFile), configFile);
832
+ const rewrite = rewriteBuildImports(readConfigSource(context2.cwd, configFile), configFile);
824
833
  if (rewrite.kind === "blocked") {
825
834
  return {
826
835
  operations: [],
@@ -831,7 +840,7 @@ function planReactApp(context, configFile) {
831
840
  operations.push({ kind: "write", path: configFile, contents: rewrite.source });
832
841
  }
833
842
  for (const name of COMPANION_CONFIG_FILES) {
834
- const source = readConfigSource(context.cwd, name);
843
+ const source = readConfigSource(context2.cwd, name);
835
844
  if (source === "") continue;
836
845
  const companion = rewriteBuildImports(source, name);
837
846
  if (companion.kind === "rewritten") {
@@ -848,28 +857,28 @@ function planReactApp(context, configFile) {
848
857
  `${configFile} now imports ${rewrite.moved.join(", ")} from \`${BUILD_PRESET_SPECIFIER}\`. Only the import lines changed: every attribute, alias and path is untouched, so this build produces what it produced before.`
849
858
  );
850
859
  }
851
- const scripts2 = buildScripts(context.cwd);
852
- operations.push(manifestOperation(context.cwd, scripts2));
860
+ const scripts2 = buildScripts(context2.cwd);
861
+ operations.push(manifestOperation(context2.cwd, scripts2));
853
862
  operations.push(
854
863
  ...nxTargetOperations({
855
- cwd: context.cwd,
864
+ cwd: context2.cwd,
856
865
  targets: buildTarget({
857
- moduleName: adoptedPackageName(context.cwd),
866
+ moduleName: adoptedPackageName(context2.cwd),
858
867
  outputs: candidateOutputs()
859
868
  })
860
869
  })
861
870
  );
862
- const dev = devTargetOperations(context.cwd, scripts2[DEV_SCRIPT_NAME]);
871
+ const dev = devTargetOperations(context2.cwd, scripts2[DEV_SCRIPT_NAME]);
863
872
  operations.push(...dev.operations);
864
873
  if (dev.note !== void 0) notes.push(dev.note);
865
- const owned = ownedBuildDependencies(context.cwd);
874
+ const owned = ownedBuildDependencies(context2.cwd);
866
875
  if (owned.length > 0) {
867
876
  operations.push({ kind: "remove-json-keys", path: "package.json", keys: owned });
868
877
  notes.push(
869
878
  `removed ${owned.map(([, name]) => name).join(", ")} from this module: sentinel owns them now, and two copies of Vite in one build give the plugins a different Vite than the one running them.`
870
879
  );
871
880
  }
872
- const formatted = readFormatAdoption(context.cwd).adopted;
881
+ const formatted = readFormatAdoption(context2.cwd).adopted;
873
882
  notes.push(
874
883
  formatted ? `run \`sentinel --run --format --fix\` on this module: adoption rewrote ${configFile}, and a rewritten import can be longer than your formatter wraps at.` : `this module is formatted by the workspace, not by sentinel, so format ${configFile} with whatever owns it before committing: a rewritten import can be longer than that formatter wraps at, and the check would fail on a file nobody typed.`
875
884
  );
@@ -878,8 +887,8 @@ function planReactApp(context, configFile) {
878
887
  );
879
888
  return { operations, notes };
880
889
  }
881
- function planNestService(context, webpackTarget) {
882
- const outputs = webpackTarget === void 0 ? nestOutputsFromConfig(context.cwd) : nestBuildOutputs(webpackTarget);
890
+ function planNestService(context2, webpackTarget) {
891
+ const outputs = webpackTarget === void 0 ? nestOutputsFromConfig(context2.cwd) : nestBuildOutputs(webpackTarget);
883
892
  if (outputs === void 0) {
884
893
  return {
885
894
  operations: [],
@@ -887,22 +896,22 @@ function planNestService(context, webpackTarget) {
887
896
  };
888
897
  }
889
898
  const metadata = [
890
- manifestOperation(context.cwd, { [BUILD_SCRIPT_NAME]: SENTINEL_BUILD_COMMAND }),
899
+ manifestOperation(context2.cwd, { [BUILD_SCRIPT_NAME]: SENTINEL_BUILD_COMMAND }),
891
900
  ...nxTargetOperations({
892
- cwd: context.cwd,
901
+ cwd: context2.cwd,
893
902
  targets: buildTarget({
894
- moduleName: adoptedPackageName(context.cwd),
903
+ moduleName: adoptedPackageName(context2.cwd),
895
904
  configFiles: [NEST_CONFIG_FILE],
896
905
  outputs
897
906
  })
898
907
  })
899
908
  ];
900
909
  if (webpackTarget === void 0) return { operations: metadata };
901
- const refusal = refusalFor(context.cwd, webpackTarget);
910
+ const refusal = refusalFor(context2.cwd, webpackTarget);
902
911
  if (refusal !== void 0) return { operations: [], blocked: refusal };
903
912
  const dropped = Object.keys(webpackTarget.configurations ?? {});
904
913
  return {
905
- operations: [...nestAdoptionOperations(context.cwd, webpackTarget), ...metadata],
914
+ operations: [...nestAdoptionOperations(context2.cwd, webpackTarget), ...metadata],
906
915
  notes: [
907
916
  `wrote ${NEST_CONFIG_FILE} and pointed the nx build target at sentinel. The target stays \`nx:run-commands\` with \`forwardAllArgs: false\`, so the image's \`--generatePackageJson\` still works and the Dockerfile needs no change.`,
908
917
  ...dropped.length > 0 ? [
@@ -984,8 +993,8 @@ var ViteRoleAdapter = class extends BaseAdapter {
984
993
  * other leaves the module half-migrated in a way nothing reports. The operations are
985
994
  * idempotent, so `--init --build` and `--init --dev` are interchangeable rather than additive.
986
995
  */
987
- plan(context) {
988
- return plan(context);
996
+ plan(context2) {
997
+ return plan(context2);
989
998
  }
990
999
  async status(ctx) {
991
1000
  const { adopted, preset, conformant, drift, unreadable } = readBuildAdoption(ctx.cwd);
@@ -1064,14 +1073,14 @@ var ViteDevAdapter = class extends ViteRoleAdapter {
1064
1073
  * so a developer could believe the dev server had been adopted on its own. Refusing was the
1065
1074
  * wrong correction, it added friction to a command that already produced the right result.
1066
1075
  */
1067
- plan(context) {
1068
- if (declaredBuildPreset(context.cwd) === "nest" || webpackBuildTarget(context.cwd)) {
1076
+ plan(context2) {
1077
+ if (declaredBuildPreset(context2.cwd) === "nest" || webpackBuildTarget(context2.cwd)) {
1069
1078
  return {
1070
1079
  operations: [],
1071
1080
  skipped: `a Nest service has no dev server, so --dev has nothing to adopt here. Its build is \`sentinel --init --build\`, and it runs as a Node process afterwards.`
1072
1081
  };
1073
1082
  }
1074
- const planned = super.plan(context);
1083
+ const planned = super.plan(context2);
1075
1084
  if (planned.operations.length === 0) return planned;
1076
1085
  return {
1077
1086
  ...planned,
@@ -1117,17 +1126,17 @@ function buildStamp(cwd) {
1117
1126
  const marker = join6(cwd, `.sentinel-build-stamp-${process.pid}`);
1118
1127
  try {
1119
1128
  writeFileSync(marker, "");
1120
- const stamp = statSync(marker).mtimeMs;
1129
+ const stamp2 = statSync(marker).mtimeMs;
1121
1130
  rmSync(marker, { force: true });
1122
- return stamp;
1131
+ return stamp2;
1123
1132
  } catch {
1124
1133
  return Date.now();
1125
1134
  }
1126
1135
  }
1127
- function writtenSince(path, startedAt) {
1128
- if (!existsSync5(path)) return false;
1136
+ function writtenSince(path2, startedAt) {
1137
+ if (!existsSync5(path2)) return false;
1129
1138
  try {
1130
- return statSync(path).mtimeMs >= startedAt;
1139
+ return statSync(path2).mtimeMs >= startedAt;
1131
1140
  } catch {
1132
1141
  return true;
1133
1142
  }
@@ -1414,11 +1423,11 @@ function toModulePattern(pattern) {
1414
1423
  }
1415
1424
  function inheritedIgnorePatterns(workspaceRoot) {
1416
1425
  if (!workspaceRoot) return [];
1417
- const path = join8(workspaceRoot, ROOT_IGNORE_FILE);
1418
- if (!existsSync6(path)) return [];
1426
+ const path2 = join8(workspaceRoot, ROOT_IGNORE_FILE);
1427
+ if (!existsSync6(path2)) return [];
1419
1428
  let contents;
1420
1429
  try {
1421
- contents = readFileSync6(path, "utf8");
1430
+ contents = readFileSync6(path2, "utf8");
1422
1431
  } catch {
1423
1432
  return [];
1424
1433
  }
@@ -1565,25 +1574,25 @@ var OxfmtAdapter = class extends BaseAdapter {
1565
1574
  const declared = readFormatAdoption(cwd).preset;
1566
1575
  return declared !== null && PRESET_NAMES.includes(declared) ? declared : void 0;
1567
1576
  }
1568
- plan(context) {
1569
- if (!hasSourceFiles(context.cwd, FORMATTABLE_EXTENSIONS)) {
1577
+ plan(context2) {
1578
+ if (!hasSourceFiles(context2.cwd, FORMATTABLE_EXTENSIONS)) {
1570
1579
  return {
1571
1580
  operations: [],
1572
1581
  skipped: `no file oxfmt can read here, so there is nothing to format.`
1573
1582
  };
1574
1583
  }
1575
1584
  const own = readOwnPackage();
1576
- const presetName = formatPresetName(context.preset);
1585
+ const presetName = formatPresetName(context2.preset);
1577
1586
  const preset = formatPresetFor(presetName);
1578
- const existing = readFormatAdoption(context.cwd);
1579
- const prettier = readPrettierSettings(context.cwd);
1587
+ const existing = readFormatAdoption(context2.cwd);
1588
+ const prettier = readPrettierSettings(context2.cwd);
1580
1589
  const local = existing.adopted ? { ...existing.local } : { ...prettier.options };
1581
1590
  for (const [key, value] of Object.entries(local)) {
1582
1591
  if (ALWAYS_LOCAL_KEYS.includes(key)) continue;
1583
1592
  if (key in preset && JSON.stringify(preset[key]) === JSON.stringify(value)) delete local[key];
1584
1593
  }
1585
1594
  const localKeys = Object.keys(local).filter((key) => !ALWAYS_LOCAL_KEYS.includes(key)).sort();
1586
- const inherited = inheritedIgnorePatterns(findWorkspaceRoot(context.cwd));
1595
+ const inherited = inheritedIgnorePatterns(findWorkspaceRoot(context2.cwd));
1587
1596
  const declaredIgnores = Array.isArray(local.ignorePatterns) ? local.ignorePatterns.filter((v) => typeof v === "string") : [];
1588
1597
  const ignorePatterns = [
1589
1598
  .../* @__PURE__ */ new Set([...declaredIgnores, ...inherited, ...FORMAT_DEFAULT_IGNORES])
@@ -1604,17 +1613,17 @@ var OxfmtAdapter = class extends BaseAdapter {
1604
1613
  // Written pre-formatted (2-space, trailing newline): oxfmt formats JSON too, and until
1605
1614
  // the format pass runs this file is the one config nothing has formatted yet.
1606
1615
  { kind: "write", path: FORMAT_CONFIG_FILE, contents: JSON.stringify(config, null, 2) + "\n" },
1607
- manifestOperation(context.cwd, this.formatScripts(context.cwd))
1616
+ manifestOperation(context2.cwd, this.formatScripts(context2.cwd))
1608
1617
  ];
1609
1618
  const prettierConfigs = PRETTIER_CONFIG_FILES.filter(
1610
- (name) => existsSync8(join10(context.cwd, name))
1619
+ (name) => existsSync8(join10(context2.cwd, name))
1611
1620
  );
1612
1621
  for (const name of prettierConfigs) operations.push({ kind: "delete", path: name });
1613
- const removableDeps = this.modulePrettierDependencies(context.cwd);
1622
+ const removableDeps = this.modulePrettierDependencies(context2.cwd);
1614
1623
  if (removableDeps.length > 0) {
1615
1624
  operations.push({ kind: "remove-json-keys", path: "package.json", keys: removableDeps });
1616
1625
  }
1617
- operations.push(...nxTargetOperations({ cwd: context.cwd, targets: formatTargets() }));
1626
+ operations.push(...nxTargetOperations({ cwd: context2.cwd, targets: formatTargets() }));
1618
1627
  const notes = [
1619
1628
  `materialized the ${presetName} format preset into ${FORMAT_CONFIG_FILE} (${own.name}@${own.version}). oxfmt has no \`extends\`, so the values live here; \`sentinel --status --format\` reports modules left behind by a preset change`
1620
1629
  ];
@@ -1646,8 +1655,8 @@ var OxfmtAdapter = class extends BaseAdapter {
1646
1655
  `removed the prettier devDependenc(ies) this module declared (${removableDeps.map(([, name]) => name).join(", ")}); the ROOT is untouched`
1647
1656
  );
1648
1657
  }
1649
- const script = this.formatScripts(context.cwd)[FORMAT_SCRIPT_NAME] ?? "";
1650
- if (keepsOtherCommands2(script, selfCommand(context.cwd, "--run --format"))) {
1658
+ const script = this.formatScripts(context2.cwd)[FORMAT_SCRIPT_NAME] ?? "";
1659
+ if (keepsOtherCommands2(script, selfCommand(context2.cwd, "--run --format"))) {
1651
1660
  notes.push(
1652
1661
  `kept the rest of the format script (\`${script}\`): only the prettier part is sentinel's`
1653
1662
  );
@@ -1948,9 +1957,9 @@ function readFailure(output) {
1948
1957
  const line = (detail ?? lines.find((entry) => entry !== "") ?? "").replace(/^x\s*/, "");
1949
1958
  return line.split(/:\s*Failed to parse config/i)[0] ?? line;
1950
1959
  }
1951
- function fingerprint(path) {
1960
+ function fingerprint(path2) {
1952
1961
  try {
1953
- const stat = statSync2(path);
1962
+ const stat = statSync2(path2);
1954
1963
  return `${stat.size}:${stat.mtimeMs}`;
1955
1964
  } catch {
1956
1965
  return void 0;
@@ -2799,6 +2808,7 @@ var react_default = {
2799
2808
  reason: "the house rule finds 3 real violations; they are fixed as their own change rather than inside a linter swap, so the rule ships visible-but-off and can be switched on once they are"
2800
2809
  },
2801
2810
  "hublo/no-regex-lookbehind": "error",
2811
+ "hublo/require-reload-document": "error",
2802
2812
  "jest/expect-expect": {
2803
2813
  severity: "off",
2804
2814
  reason: "pre-existing test-suite debt; enabling it on adoption would surface a wall of findings and make a lateral move look like a regression"
@@ -3285,7 +3295,7 @@ var react_default = {
3285
3295
  uncovered: {
3286
3296
  "import/order": "import ordering is FORMATTING, and the format role owns it: oxfmt's `sortImports` reproduces exactly these groups (builtin, external, internal, parent/index, sibling, alphabetical, blank line between) and sorts a file completely in ONE pass, because it rewrites the whole import block. A lint fixer cannot: it emits text edits over overlapping ranges, so this rule needed several runs to finish (five imports took four passes, measured) and sentinel deliberately runs autofix once. Enforced by `pnpm run format`, not by the linter, which is also why `eslint-plugin-import` is no longer hosted at all: its only other rule, no-duplicates, is native in oxlint.",
3287
3297
  "no-octal": "no oxlint equivalent",
3288
- "no-restricted-syntax": "no oxlint equivalent as configuration, so the AST selectors this repo used are shipped as real rules in the local `hublo` plugin instead, which this tier hosts (`../plugins/hublo.js`). Two are hosted today: the eight palette selectors as `hublo/no-palette-common`, and the lookbehind selector as `hublo/no-regex-lookbehind`. Any selector NOT in that plugin is not enforced here; propose it and it joins the plugin rather than a per-module copy.",
3298
+ "no-restricted-syntax": "no oxlint equivalent as configuration, so the AST selectors this repo used are shipped as real rules in the local `hublo` plugin instead, which this tier hosts (`../plugins/hublo.js`). Three are hosted today: the eight palette selectors as `hublo/no-palette-common`, the lookbehind selector as `hublo/no-regex-lookbehind`, and the TanStack Router one as `hublo/require-reload-document`, carried here from an ESLint selector in the monorepo (PR #22043) because ADR 0042 makes this the layer that outlives it. App tier only: measured on main, `libs/front` calls `redirect(` nowhere, while host-admin does in 93 files and console in 2. Any selector NOT in that plugin is not enforced here; propose it and it joins the plugin rather than a per-module copy.",
3289
3299
  semi: "formatting, and the formatter owns it. It moves to oxfmt, which is deliberately the LAST step of the migration, so nothing enforces it in between."
3290
3300
  }
3291
3301
  };
@@ -3770,11 +3780,11 @@ function downgradedRulesFor(preset) {
3770
3780
  import { existsSync as existsSync9, readFileSync as readFileSync9 } from "node:fs";
3771
3781
  import { isAbsolute, join as join12, resolve as resolve2 } from "node:path";
3772
3782
  function ruleCount(cwd, specifier) {
3773
- const path = isAbsolute(specifier) ? specifier : resolve2(cwd, specifier);
3774
- if (!existsSync9(path)) return void 0;
3783
+ const path2 = isAbsolute(specifier) ? specifier : resolve2(cwd, specifier);
3784
+ if (!existsSync9(path2)) return void 0;
3775
3785
  try {
3776
3786
  const parsed = parseJsonc(
3777
- readFileSync9(path, "utf8"),
3787
+ readFileSync9(path2, "utf8"),
3778
3788
  specifier
3779
3789
  );
3780
3790
  return Object.keys(parsed.rules ?? {}).length;
@@ -3870,7 +3880,7 @@ function readRootEslintIgnores(root) {
3870
3880
  return { patterns: patterns.filter((pattern) => pattern.startsWith("**/")), unresolved };
3871
3881
  }
3872
3882
  function readEslintIgnores(cwd) {
3873
- const config = ESLINT_CONFIG_FILES.map((name) => join13(cwd, name)).find((path) => existsSync10(path));
3883
+ const config = ESLINT_CONFIG_FILES.map((name) => join13(cwd, name)).find((path2) => existsSync10(path2));
3874
3884
  if (!config) return { patterns: [], unresolved: [] };
3875
3885
  let source;
3876
3886
  try {
@@ -4120,48 +4130,48 @@ function summariseByPlugin(rules) {
4120
4130
  }
4121
4131
  return [...counts.entries()].sort((left, right) => right[1] - left[1]).map(([plugin, count]) => `${count} ${plugin}`).join(", ");
4122
4132
  }
4123
- function plan2(context) {
4124
- if (!hasLintPreset(context.preset)) {
4133
+ function plan2(context2) {
4134
+ if (!hasLintPreset(context2.preset)) {
4125
4135
  return {
4126
4136
  operations: [],
4127
- skipped: `sentinel does not ship a lint preset named "${context.preset}" yet (shipped: ${LINT_PRESETS.join(", ")}). Nothing was written; the module keeps its current linter and the other roles adopt normally.`
4137
+ skipped: `sentinel does not ship a lint preset named "${context2.preset}" yet (shipped: ${LINT_PRESETS.join(", ")}). Nothing was written; the module keeps its current linter and the other roles adopt normally.`
4128
4138
  };
4129
4139
  }
4130
- const current = presetOfVariant(presetNameFromPath(readLintAdoption(context.cwd).preset));
4131
- if (current !== void 0 && current !== context.preset) {
4140
+ const current = presetOfVariant(presetNameFromPath(readLintAdoption(context2.cwd).preset));
4141
+ if (current !== void 0 && current !== context2.preset) {
4132
4142
  return {
4133
4143
  operations: [],
4134
- blocked: `this module is already adopted as "${current}", and --preset says "${context.preset}". Nothing was written. If the change is deliberate, delete ${LINT_CONFIG_FILE} and re-run; otherwise re-run with --preset ${current}.`
4144
+ blocked: `this module is already adopted as "${current}", and --preset says "${context2.preset}". Nothing was written. If the change is deliberate, delete ${LINT_CONFIG_FILE} and re-run; otherwise re-run with --preset ${current}.`
4135
4145
  };
4136
4146
  }
4137
- if (!hasSourceFiles(context.cwd, LINTABLE_EXTENSIONS)) {
4147
+ if (!hasSourceFiles(context2.cwd, LINTABLE_EXTENSIONS)) {
4138
4148
  return {
4139
4149
  operations: [],
4140
4150
  skipped: `no file oxlint can read (looked for ${LINTABLE_EXTENSIONS.join(", ")}), so there is nothing to lint here. Other roles may still apply: the formatter reads markdown, YAML, HTML and CSS too.`
4141
4151
  };
4142
4152
  }
4143
- const variant = presetVariant(context.preset, context.cwd);
4153
+ const variant = presetVariant(context2.preset, context2.cwd);
4144
4154
  const disabled = disabledRulesFor(variant);
4145
4155
  const own = readOwnPackage();
4146
- const ignores = readEslintIgnores(context.cwd);
4147
- const rootIgnores = readRootEslintIgnores(findWorkspaceRoot(context.cwd));
4156
+ const ignores = readEslintIgnores(context2.cwd);
4157
+ const rootIgnores = readRootEslintIgnores(findWorkspaceRoot(context2.cwd));
4148
4158
  const bare = (pattern) => pattern.replace(/^\*\*\//, "").replace(/\/\*\*$/, "");
4149
- const defaults = new Set(DEFAULT_IGNORE_PATTERNS.map(bare));
4159
+ const defaults2 = new Set(DEFAULT_IGNORE_PATTERNS.map(bare));
4150
4160
  const moduleOwn = [
4151
- ...committedIgnorePatterns(context.cwd),
4161
+ ...committedIgnorePatterns(context2.cwd),
4152
4162
  ...ignores.patterns,
4153
4163
  ...rootIgnores.patterns
4154
- ].filter((pattern) => !defaults.has(bare(pattern)));
4164
+ ].filter((pattern) => !defaults2.has(bare(pattern)));
4155
4165
  const carried = [.../* @__PURE__ */ new Set([...DEFAULT_IGNORE_PATTERNS, ...moduleOwn])];
4156
4166
  const stub = {
4157
- extends: extendsWithPreset(committedExtends(context.cwd), variant),
4167
+ extends: extendsWithPreset(committedExtends(context2.cwd), variant),
4158
4168
  ...carried.length > 0 ? { ignorePatterns: carried } : {}
4159
4169
  };
4160
4170
  const operations = [
4161
4171
  { kind: "write", path: LINT_CONFIG_FILE, contents: JSON.stringify(stub, null, 2) + "\n" },
4162
- manifestOperation(context.cwd, lintScripts(context.cwd))
4172
+ manifestOperation(context2.cwd, lintScripts(context2.cwd))
4163
4173
  ];
4164
- const removableDeps = moduleEslintDependencies(context.cwd);
4174
+ const removableDeps = moduleEslintDependencies(context2.cwd);
4165
4175
  if (removableDeps.length > 0) {
4166
4176
  operations.push({
4167
4177
  kind: "remove-json-keys",
@@ -4169,16 +4179,16 @@ function plan2(context) {
4169
4179
  keys: removableDeps
4170
4180
  });
4171
4181
  }
4172
- const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync11(join15(context.cwd, name)));
4182
+ const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync11(join15(context2.cwd, name)));
4173
4183
  for (const name of eslintConfigs) operations.push({ kind: "delete", path: name });
4174
4184
  operations.push(
4175
4185
  ...nxTargetOperations({
4176
- cwd: context.cwd,
4186
+ cwd: context2.cwd,
4177
4187
  targets: { [LINT_SCRIPT_NAME]: lintTarget() }
4178
4188
  })
4179
4189
  );
4180
4190
  const renames = findSuppressionRenames(
4181
- context.cwd,
4191
+ context2.cwd,
4182
4192
  renamedPrefixes(lintPresetFor(variant).jsPlugins, lintPresetFor(variant).renames)
4183
4193
  );
4184
4194
  const deleting = new Set(eslintConfigs);
@@ -4228,7 +4238,7 @@ function plan2(context) {
4228
4238
  `renamed ${rewritten.length} suppression comment(s) whose plugin oxlint registers under a different name (${[...new Set(rewritten.map((entry) => entry.rule))].join(", ")}); left alone they would stop suppressing anything, silently`
4229
4239
  );
4230
4240
  }
4231
- const script = lintScripts(context.cwd)[LINT_SCRIPT_NAME] ?? "";
4241
+ const script = lintScripts(context2.cwd)[LINT_SCRIPT_NAME] ?? "";
4232
4242
  if (keepsOtherCommands3(script)) {
4233
4243
  const stillPrettier = /(^|[\s/])prettier(\s|$)/.test(script);
4234
4244
  notes.push(
@@ -4348,8 +4358,8 @@ var OxlintAdapter = class extends BaseAdapter {
4348
4358
  * linter are two different jobs, and keeping them in one file is what grew it past 800
4349
4359
  * lines. The adapter stays the contract with the engine.
4350
4360
  */
4351
- plan(context) {
4352
- return plan2(context);
4361
+ plan(context2) {
4362
+ return plan2(context2);
4353
4363
  }
4354
4364
  declaredPreset(cwd) {
4355
4365
  return presetOfVariant(presetNameFromPath(readLintAdoption(cwd).preset));
@@ -4751,22 +4761,277 @@ function registerLint() {
4751
4761
  }
4752
4762
 
4753
4763
  // 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";
4764
+ import { spawnSync as spawnSync6 } from "node:child_process";
4765
+ import { existsSync as existsSync18, mkdirSync as mkdirSync2, readFileSync as readFileSync20, writeFileSync as writeFileSync6 } from "node:fs";
4766
+ import { join as join25 } from "node:path";
4767
+ import { globSync as globSync6 } from "tinyglobby";
4757
4768
 
4758
- // src/roles/static-analysis/configs/resolve.ts
4759
- import { existsSync as existsSync13 } from "node:fs";
4760
- import { join as join17 } from "node:path";
4769
+ // src/roles/static-analysis/bundle/scan.ts
4770
+ import { existsSync as existsSync14, readFileSync as readFileSync15 } from "node:fs";
4771
+ import { basename, join as join18 } from "node:path";
4761
4772
  import { pathToFileURL } from "node:url";
4773
+ import { gzipSync } from "node:zlib";
4774
+ import { globSync as globSync2 } from "tinyglobby";
4775
+
4776
+ // src/roles/static-analysis/bundle/budgets.ts
4777
+ var TRANSFER_REFERENCES = {
4778
+ /**
4779
+ * The engineering budget, with its conditions: "aim for a JS budget of < 170KB
4780
+ * minified/compressed", for "getting interactive in under five seconds on a slow 3G connection
4781
+ * on a median mobile device". It answers "are we good".
4782
+ */
4783
+ budgetKb: 170,
4784
+ /**
4785
+ * HTTP Archive's `bytesJs`, "the sum of transfer size kilobytes of all external scripts
4786
+ * requested by the page". It answers "are we normal", which is a different question, and the two
4787
+ * routinely disagree: all three of our fronts are below this and above the budget.
4788
+ */
4789
+ webMedianKb: 747.5
4790
+ };
4791
+ var PARSED_BUDGET_KB = 717;
4792
+ var PER_SCRIPT_KB = 100;
4793
+ var BANDS = { near: 0.7, exceed: 1 };
4794
+ function bandFor(valueKb, referenceKb) {
4795
+ const ratio = valueKb / referenceKb;
4796
+ if (ratio > BANDS.exceed) return "exceed";
4797
+ if (ratio >= BANDS.near) return "near";
4798
+ return "ok";
4799
+ }
4800
+
4801
+ // src/roles/static-analysis/bundle/find-build.ts
4802
+ import { existsSync as existsSync13, readFileSync as readFileSync14, statSync as statSync4 } from "node:fs";
4803
+ import { join as join17 } from "node:path";
4804
+ import { globSync } from "tinyglobby";
4805
+ var MANIFEST_GLOB = "server/_tanstack-start-manifest*.mjs";
4806
+ function candidateDirs(cwd) {
4807
+ const declared = readProjectPackageJson(cwd).nx?.targets?.build?.["outputs"];
4808
+ const fromDeclaration = Array.isArray(declared) ? declared.filter((token) => typeof token === "string").map((token) => join17(cwd, token.replace(/^\{projectRoot\}\//, ""))) : [];
4809
+ return [...fromDeclaration, join17(cwd, ".output"), join17(cwd, "dist"), join17(cwd, "build")];
4810
+ }
4811
+ function findBuildOutput(cwd) {
4812
+ const lookedIn = [];
4813
+ for (const dir of candidateDirs(cwd)) {
4814
+ lookedIn.push(dir);
4815
+ if (!existsSync13(dir)) continue;
4816
+ const found = globSync(MANIFEST_GLOB, { cwd: dir, absolute: true, dot: false });
4817
+ if (found.length === 0) continue;
4818
+ const manifestPath = found[0];
4819
+ const publicDir = join17(dir, "public");
4820
+ if (!existsSync13(publicDir)) continue;
4821
+ return { manifestPath, publicDir, ...dateOf(dir, manifestPath) };
4822
+ }
4823
+ const pkg = readProjectPackageJson(cwd);
4824
+ const builds = typeof pkg.scripts?.["build"] === "string" || pkg.nx?.targets?.["build"] !== void 0;
4825
+ const declaresOutputs = Array.isArray(pkg.nx?.targets?.["build"]?.["outputs"]);
4826
+ return {
4827
+ reason: builds ? `no bundle figure: this module declares a build and wrote no entry manifest, so ${declaresOutputs ? "build it before measuring" : "declare that build's outputs on its nx target the way apps/front/career does"}. A build target with no declared \`outputs\` is still cached, and on a cache hit nx reports the task successful, restores no file and leaves the directory empty, so every job that reads the build sees nothing. Measured on this monorepo: 5 of the 48 projects with a build target are in that state.` : "no bundle figure: this module declares no build, so it writes no entry manifest and there is nothing to measure here. A component library and a back-end service are in that case by construction, and no action follows.",
4828
+ lookedIn
4829
+ };
4830
+ }
4831
+ function dateOf(dir, manifestPath) {
4832
+ const record = join17(dir, "nitro.json");
4833
+ if (existsSync13(record)) {
4834
+ try {
4835
+ const parsed = JSON.parse(readFileSync14(record, "utf8"));
4836
+ if (typeof parsed.date === "string") {
4837
+ const date = new Date(parsed.date);
4838
+ if (!Number.isNaN(date.getTime())) return { builtAt: date, builtAtFrom: "build record" };
4839
+ }
4840
+ } catch {
4841
+ }
4842
+ }
4843
+ return { builtAt: statSync4(manifestPath).mtime, builtAtFrom: "file timestamp" };
4844
+ }
4845
+ function isBuildOutput(found) {
4846
+ return "manifestPath" in found;
4847
+ }
4848
+
4849
+ // src/roles/static-analysis/bundle/scan.ts
4850
+ var KIND_BY_EXTENSION = [
4851
+ [/\.(?:js|mjs|cjs)$/, "js"],
4852
+ [/\.css$/, "css"],
4853
+ [/\.(?:png|jpe?g|gif|svg|webp|avif)$/, "img"],
4854
+ [/\.(?:woff2?|ttf|otf|eot)$/, "font"]
4855
+ ];
4856
+ function kindOf(path2) {
4857
+ for (const [pattern, kind] of KIND_BY_EXTENSION) if (pattern.test(path2)) return kind;
4858
+ return "other";
4859
+ }
4860
+ function resolveAnnounced(publicDir, announced) {
4861
+ const direct = join18(publicDir, announced);
4862
+ if (existsSync14(direct)) return direct;
4863
+ const stripped = join18(publicDir, announced.replace(/^\/[^/]+\//, "/"));
4864
+ return existsSync14(stripped) ? stripped : void 0;
4865
+ }
4866
+ function emptyWeight() {
4867
+ return { transfer: 0, parsed: 0, files: 0 };
4868
+ }
4869
+ function add(into, bytes) {
4870
+ into.transfer += bytes.transfer;
4871
+ into.parsed += bytes.parsed;
4872
+ into.files += 1;
4873
+ }
4874
+ function groupOf(fileName) {
4875
+ return fileName.replace(/-[A-Za-z0-9_-]+\.(?:js|mjs|cjs)$/, "");
4876
+ }
4877
+ async function scanBundle(cwd) {
4878
+ const found = findBuildOutput(cwd);
4879
+ if (!isBuildOutput(found)) return { notMeasured: found, unresolved: [] };
4880
+ const manifest = await loadManifest(found.manifestPath);
4881
+ if (manifest === void 0) {
4882
+ return {
4883
+ notMeasured: {
4884
+ reason: "the entry manifest was found but could not be read, so no figure is produced rather than a partial one.",
4885
+ lookedIn: [found.manifestPath]
4886
+ },
4887
+ unresolved: []
4888
+ };
4889
+ }
4890
+ const routes = manifest.routes ?? {};
4891
+ const unresolved = [];
4892
+ const measured = /* @__PURE__ */ new Map();
4893
+ const sizeOf = (announced) => {
4894
+ if (!measured.has(announced)) {
4895
+ const path2 = resolveAnnounced(found.publicDir, announced);
4896
+ if (path2 === void 0) {
4897
+ unresolved.push(announced);
4898
+ measured.set(announced, void 0);
4899
+ } else {
4900
+ const buffer = readFileSync15(path2);
4901
+ measured.set(announced, {
4902
+ parsed: buffer.length,
4903
+ transfer: gzipSync(buffer, { level: 9 }).length
4904
+ });
4905
+ }
4906
+ }
4907
+ return measured.get(announced);
4908
+ };
4909
+ const referencesOf = (ids) => {
4910
+ const out = /* @__PURE__ */ new Set();
4911
+ for (const id of ids) {
4912
+ for (const preload of routes[id]?.preloads ?? []) out.add(preload);
4913
+ for (const asset of routes[id]?.assets ?? []) {
4914
+ const href = asset?.attrs?.href;
4915
+ if (href !== void 0) out.add(href);
4916
+ }
4917
+ }
4918
+ return out;
4919
+ };
4920
+ const weigh = (set) => {
4921
+ const by = {};
4922
+ for (const announced of set) {
4923
+ const size = sizeOf(announced);
4924
+ if (size === void 0) continue;
4925
+ const kind = kindOf(announced);
4926
+ add(by[kind] ??= emptyWeight(), size);
4927
+ }
4928
+ return by;
4929
+ };
4930
+ const initialLoad = weigh(referencesOf(["__root__"]));
4931
+ const everyReference = referencesOf(Object.keys(routes));
4932
+ const entrySet = weigh(everyReference);
4933
+ const js = initialLoad.js ?? emptyWeight();
4934
+ const verdict = {
4935
+ transferVsBudget: bandFor(js.transfer / 1024, TRANSFER_REFERENCES.budgetKb),
4936
+ transferVsWebMedian: bandFor(js.transfer / 1024, TRANSFER_REFERENCES.webMedianKb),
4937
+ parsedVsBudget: bandFor(js.parsed / 1024, PARSED_BUDGET_KB)
4938
+ };
4939
+ return {
4940
+ initialLoad,
4941
+ entrySet,
4942
+ dynamic: scanDynamicLayer(found.publicDir, everyReference),
4943
+ heaviest: heaviestPerKind(everyReference, sizeOf),
4944
+ verdict,
4945
+ conditions: { builtAt: found.builtAt, builtAtFrom: found.builtAtFrom, brandKnown: false },
4946
+ unresolved
4947
+ };
4948
+ }
4949
+ async function loadManifest(path2) {
4950
+ try {
4951
+ const loaded = await import(pathToFileURL(path2).href);
4952
+ if (typeof loaded.tsrStartManifest !== "function") return void 0;
4953
+ const produced = loaded.tsrStartManifest();
4954
+ return produced !== null && typeof produced === "object" ? produced : void 0;
4955
+ } catch {
4956
+ return void 0;
4957
+ }
4958
+ }
4959
+ function heaviestPerKind(references, sizeOf) {
4960
+ const best = /* @__PURE__ */ new Map();
4961
+ for (const announced of references) {
4962
+ const size = sizeOf(announced);
4963
+ if (size === void 0) continue;
4964
+ const kind = kindOf(announced);
4965
+ const current = best.get(kind);
4966
+ if (current !== void 0 && current.weight.parsed >= size.parsed) continue;
4967
+ best.set(kind, {
4968
+ name: basename(announced),
4969
+ kind,
4970
+ weight: { ...size, files: 1 },
4971
+ band: bandFor(size.parsed / 1024, PER_SCRIPT_KB)
4972
+ });
4973
+ }
4974
+ return [...best.values()].sort((a, b) => b.weight.parsed - a.weight.parsed);
4975
+ }
4976
+ function scanDynamicLayer(publicDir, references) {
4977
+ const referenced = new Set([...references].map((announced) => basename(announced)));
4978
+ const groups = /* @__PURE__ */ new Map();
4979
+ const all2 = emptyWeight();
4980
+ for (const path2 of globSync2("assets/*.{js,mjs,cjs}", {
4981
+ cwd: publicDir,
4982
+ absolute: true,
4983
+ dot: false
4984
+ })) {
4985
+ const name = basename(path2);
4986
+ if (referenced.has(name)) continue;
4987
+ const buffer = readFileSync15(path2);
4988
+ const size = { parsed: buffer.length, transfer: gzipSync(buffer, { level: 9 }).length };
4989
+ add(all2, size);
4990
+ const group = groupOf(name);
4991
+ groups.set(group, [...groups.get(group) ?? [], size]);
4992
+ }
4993
+ const upperBound = emptyWeight();
4994
+ const lowerBound = emptyWeight();
4995
+ for (const variants of groups.values()) {
4996
+ add(upperBound, {
4997
+ transfer: Math.max(...variants.map((v) => v.transfer)),
4998
+ parsed: Math.max(...variants.map((v) => v.parsed))
4999
+ });
5000
+ add(lowerBound, {
5001
+ transfer: Math.min(...variants.map((v) => v.transfer)),
5002
+ parsed: Math.min(...variants.map((v) => v.parsed))
5003
+ });
5004
+ }
5005
+ return {
5006
+ all: all2,
5007
+ upperBound,
5008
+ lowerBound,
5009
+ groups: groups.size,
5010
+ groupsWithVariants: [...groups.values()].filter((v) => v.length > 1).length
5011
+ };
5012
+ }
5013
+
5014
+ // src/roles/static-analysis/complexity/scan.ts
5015
+ import { readFileSync as readFileSync16 } from "node:fs";
5016
+ import { globSync as globSync3 } from "tinyglobby";
5017
+
5018
+ // src/roles/static-analysis/adapters/jscpd/tool-config.ts
5019
+ import { existsSync as existsSync16, mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
5020
+ import { tmpdir } from "node:os";
5021
+ import { join as join20 } from "node:path";
5022
+
5023
+ // src/roles/static-analysis/configs/resolve.ts
5024
+ import { existsSync as existsSync15 } from "node:fs";
5025
+ import { join as join19 } from "node:path";
5026
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
4762
5027
  var STATIC_CONFIG_FILE = "static-analysis.config.mjs";
4763
5028
  async function loadStaticConfig(cwd) {
4764
- const path = join17(cwd, STATIC_CONFIG_FILE);
4765
- if (!existsSync13(path)) {
5029
+ const path2 = join19(cwd, STATIC_CONFIG_FILE);
5030
+ if (!existsSync15(path2)) {
4766
5031
  return { config: defineStaticAnalysis(), from: null, unreadable: null };
4767
5032
  }
4768
5033
  try {
4769
- const loaded = await import(pathToFileURL(path).href);
5034
+ const loaded = await import(pathToFileURL2(path2).href);
4770
5035
  return { config: adopt(loaded.default), from: STATIC_CONFIG_FILE, unreadable: null };
4771
5036
  } catch (error) {
4772
5037
  return {
@@ -4784,197 +5049,2463 @@ function adopt(exported) {
4784
5049
  return defineStaticAnalysis(exported);
4785
5050
  }
4786
5051
 
5052
+ // src/roles/static-analysis/adapters/jscpd/tool-config.ts
5053
+ var OWN_ARTEFACTS = [`**/${STATIC_CONFIG_FILE}`, `**/${REPORT_DIR}/**`];
5054
+ function testGlobs(config) {
5055
+ const relative5 = config.duplication.tests;
5056
+ return {
5057
+ ignore: relative5.map((glob) => `**/${glob}`),
5058
+ // A one-item brace group is avoided rather than trusted: nothing guarantees `{x}` expands.
5059
+ pattern: relative5.length === 1 ? `**/${relative5[0]}` : `**/{${relative5.join(",")}}`
5060
+ };
5061
+ }
5062
+ function exclusions(config, scope = "tests") {
5063
+ const tests = scope === "product" ? testGlobs(config).ignore : [];
5064
+ return [.../* @__PURE__ */ new Set([...config.ignore, ...OWN_ARTEFACTS, ...tests])];
5065
+ }
5066
+ function measuredRoots(cwd) {
5067
+ return existsSync16(join20(cwd, "src")) ? ["src"] : ["."];
5068
+ }
5069
+ function fileSettings(config, scope, extraEntries = []) {
5070
+ return {
5071
+ // The detection window. Stated rather than defaulted, because it decides what is invisible.
5072
+ minTokens: config.duplication.minTokens,
5073
+ minLines: config.duplication.minLines,
5074
+ // `format` is jscpd's name for what we call `languages`. Naming `typescript` alone would
5075
+ // leave out `.tsx`, which is a separate language to it: 300 files of 523 on front/console.
5076
+ format: config.languages,
5077
+ ignore: exclusions(config, scope),
5078
+ // A scan that analyses nothing otherwise exits 0 with only a warning, which reads as a pass.
5079
+ failOnEmpty: true,
5080
+ // The dead-code engine keeps its own section, and its own `minLines`: jscpd's documentation is
5081
+ // explicit that the top-level one is about clones and is never carried into a dead-code run.
5082
+ deadCode: {
5083
+ // ⛔ The module's declared patterns PLUS the files another module imports. Seeding the walk
5084
+ // rather than replacing it keeps what it does well, transitivity: a file reachable only
5085
+ // through a dead one stays dead. Without these seeds a library has no entry point at all, so
5086
+ // the walk reports "the module does not import itself", true of every library: 87 files on
5087
+ // `libs/nest/auth` against 12 that nothing anywhere imports. See `workspace/consumers.ts`.
5088
+ entry: [...config.entry, ...extraEntries],
5089
+ minConfidence: config.deadCode.minConfidence,
5090
+ categories: config.deadCode.categories
5091
+ },
5092
+ // The escape hatch, last so it wins, and empty unless a module asked for it.
5093
+ ...config.tools.jscpd
5094
+ };
5095
+ }
5096
+ function duplicationRun(config, paths, scope) {
5097
+ const dir = mkdtempSync(join20(tmpdir(), "sentinel-static-"));
5098
+ const configPath = join20(dir, "jscpd.json");
5099
+ writeFileSync3(configPath, `${JSON.stringify(fileSettings(config, scope), null, 2)}
5100
+ `, "utf8");
5101
+ const args = [
5102
+ "--config",
5103
+ configPath,
5104
+ // The test pass SELECTS its files; the product pass excludes them through `ignore` above. A
5105
+ // flag and not a config key, because `--pattern` is the only documented way in and it takes
5106
+ // exactly one glob, braces expanded. Verified: 478 product files + 87 test files = the 565 of
5107
+ // a blended scan on `apps/front/console`.
5108
+ ...scope === "tests" ? ["--pattern", testGlobs(config).pattern] : [],
5109
+ // Always json, and only json, whatever shape the caller asked for. The four shapes are
5110
+ // rendered by this role from the figures, not by the tool: one report lives under
5111
+ // `static-analysis`, assembled from several passes, so a per-pass console table from the tool
5112
+ // would be a fourth voice saying the same numbers differently. `--output` below is jscpd's
5113
+ // output DIRECTORY, not our `--output` shape.
5114
+ "--reporters",
5115
+ "json",
5116
+ "--output",
5117
+ dir,
5118
+ "--no-tips",
5119
+ ...paths
5120
+ ];
5121
+ return { args, rawReportDir: dir, dispose: () => rmSync3(dir, { force: true, recursive: true }) };
5122
+ }
5123
+ function deadCodeRun(config, paths, extraEntries = []) {
5124
+ const dir = mkdtempSync(join20(tmpdir(), "sentinel-static-"));
5125
+ const configPath = join20(dir, "jscpd.json");
5126
+ writeFileSync3(
5127
+ configPath,
5128
+ `${JSON.stringify(fileSettings(config, "dead-code", extraEntries), null, 2)}
5129
+ `,
5130
+ "utf8"
5131
+ );
5132
+ return {
5133
+ args: [
5134
+ "--config",
5135
+ configPath,
5136
+ "--dead-code",
5137
+ "--reporters",
5138
+ "json",
5139
+ "--output",
5140
+ dir,
5141
+ "--no-tips",
5142
+ ...paths
5143
+ ],
5144
+ rawReportDir: dir,
5145
+ dispose: () => rmSync3(dir, { force: true, recursive: true })
5146
+ };
5147
+ }
5148
+ var RAW_REPORT = {
5149
+ duplication: "jscpd-report.json",
5150
+ "dead-code": "basta-report.json"
5151
+ };
5152
+
5153
+ // src/roles/static-analysis/complexity/cyclomatic.ts
5154
+ import { parseSync as parseSync2 } from "oxc-parser";
5155
+ var FUNCTION_NODES = /* @__PURE__ */ new Set([
5156
+ "FunctionDeclaration",
5157
+ "FunctionExpression",
5158
+ "ArrowFunctionExpression"
5159
+ ]);
5160
+ var DECISION_NODES = /* @__PURE__ */ new Set([
5161
+ "IfStatement",
5162
+ "ForStatement",
5163
+ "ForInStatement",
5164
+ "ForOfStatement",
5165
+ "WhileStatement",
5166
+ "DoWhileStatement",
5167
+ "CatchClause",
5168
+ "ConditionalExpression"
5169
+ ]);
5170
+ var DECISION_OPERATORS = /* @__PURE__ */ new Set(["&&", "||", "??"]);
5171
+ var DECISION_ASSIGNMENTS = /* @__PURE__ */ new Set(["&&=", "||=", "??="]);
5172
+ function walk2(node, visit, stopAt) {
5173
+ if (!node || typeof node !== "object") return;
5174
+ if (Array.isArray(node)) {
5175
+ for (const child of node) walk2(child, visit, stopAt);
5176
+ return;
5177
+ }
5178
+ const current = node;
5179
+ if (typeof current.type === "string") {
5180
+ if (stopAt(current)) return;
5181
+ visit(current);
5182
+ }
5183
+ for (const [field, value] of Object.entries(current)) {
5184
+ if (field === "type" || field === "start" || field === "end") continue;
5185
+ walk2(value, visit, stopAt);
5186
+ }
5187
+ }
5188
+ function complexityOf(fn) {
5189
+ let count = 1;
5190
+ for (const parameter of Array.isArray(fn.params) ? fn.params : []) {
5191
+ walk2(
5192
+ parameter,
5193
+ (node) => {
5194
+ if (node.type === "AssignmentPattern") count += 1;
5195
+ },
5196
+ (node) => FUNCTION_NODES.has(node.type ?? "")
5197
+ );
5198
+ }
5199
+ const body = fn.body ?? fn;
5200
+ let root = true;
5201
+ walk2(
5202
+ body,
5203
+ (node) => {
5204
+ if (DECISION_NODES.has(node.type ?? "")) count += 1;
5205
+ if (node.type === "AssignmentPattern") count += 1;
5206
+ if (node.type === "SwitchCase" && node.test !== null && node.test !== void 0) count += 1;
5207
+ if (node.type === "LogicalExpression" && DECISION_OPERATORS.has(String(node.operator ?? ""))) {
5208
+ count += 1;
5209
+ }
5210
+ if (node.type === "AssignmentExpression" && DECISION_ASSIGNMENTS.has(String(node.operator ?? ""))) {
5211
+ count += 1;
5212
+ }
5213
+ if ((node.type === "MemberExpression" || node.type === "CallExpression") && node.optional === true) {
5214
+ count += 1;
5215
+ }
5216
+ },
5217
+ (node) => {
5218
+ if (root) {
5219
+ root = false;
5220
+ return false;
5221
+ }
5222
+ return FUNCTION_NODES.has(node.type ?? "");
5223
+ }
5224
+ );
5225
+ return count;
5226
+ }
5227
+ function nameOf(fn, parent) {
5228
+ const own = fn.id?.name;
5229
+ if (own) return own;
5230
+ const key = parent?.id?.name ?? parent?.key?.name;
5231
+ if (key) return key;
5232
+ return "(anonymous)";
5233
+ }
5234
+ function lineAt(source, offset) {
5235
+ if (offset === void 0) return 1;
5236
+ let line = 1;
5237
+ for (let index = 0; index < offset && index < source.length; index += 1) {
5238
+ if (source[index] === "\n") line += 1;
5239
+ }
5240
+ return line;
5241
+ }
5242
+ function complexityOfFile(path2, source) {
5243
+ const parsed = parseSync2(path2, source);
5244
+ if (parsed.errors.length > 0) return [];
5245
+ const found = [];
5246
+ const parents = /* @__PURE__ */ new Map();
5247
+ walk2(
5248
+ parsed.program,
5249
+ (node) => {
5250
+ for (const value of Object.values(node)) {
5251
+ if (value && typeof value === "object" && !Array.isArray(value)) {
5252
+ parents.set(value, node);
5253
+ }
5254
+ }
5255
+ if (!FUNCTION_NODES.has(node.type ?? "")) return;
5256
+ found.push({
5257
+ name: nameOf(node, parents.get(node)),
5258
+ line: lineAt(source, node.start),
5259
+ complexity: complexityOf(node)
5260
+ });
5261
+ },
5262
+ () => false
5263
+ );
5264
+ return found;
5265
+ }
5266
+
5267
+ // src/roles/static-analysis/complexity/scan.ts
5268
+ var RISK_BANDS = [
5269
+ { metric: "complexityModerate", from: 11, to: 20, label: "moderate risk" },
5270
+ { metric: "complexityHigh", from: 21, to: 50, label: "high risk" },
5271
+ { metric: "complexityUntestable", from: 51, to: Infinity, label: "untestable" }
5272
+ ];
5273
+ var MCCABE_REFERENCE = RISK_BANDS[0].from;
5274
+ function productFiles(cwd, config) {
5275
+ const extensions = extensionsOf(config.languages).map((extension) => extension.slice(1)).join(",");
5276
+ return globSync3(
5277
+ measuredRoots(cwd).map(
5278
+ (root) => root === "." ? `**/*.{${extensions}}` : `${root}/**/*.{${extensions}}`
5279
+ ),
5280
+ {
5281
+ cwd,
5282
+ ignore: exclusions(config, "product"),
5283
+ dot: false
5284
+ }
5285
+ );
5286
+ }
5287
+ function scanComplexity(cwd, config) {
5288
+ const files = productFiles(cwd, config);
5289
+ const all2 = [];
5290
+ for (const path2 of files) {
5291
+ let source;
5292
+ try {
5293
+ source = readFileSync16(`${cwd}/${path2}`, "utf8");
5294
+ } catch {
5295
+ continue;
5296
+ }
5297
+ for (const fn of complexityOfFile(path2, source)) all2.push({ ...fn, path: path2 });
5298
+ }
5299
+ all2.sort((left, right) => right.complexity - left.complexity);
5300
+ const bands = {};
5301
+ for (const band of RISK_BANDS) {
5302
+ bands[band.metric] = all2.filter(
5303
+ (fn) => fn.complexity >= band.from && fn.complexity <= band.to
5304
+ ).length;
5305
+ }
5306
+ return {
5307
+ files: files.length,
5308
+ functions: all2.length,
5309
+ max: all2[0]?.complexity ?? 0,
5310
+ complex: all2.filter((fn) => fn.complexity >= MCCABE_REFERENCE),
5311
+ bands
5312
+ };
5313
+ }
5314
+
5315
+ // src/roles/static-analysis/coverage.ts
5316
+ import path from "node:path";
5317
+ import { globSync as globSync4 } from "tinyglobby";
5318
+ var UNREADABLE_EXTENSIONS = [".svelte", ".vue", ".astro"];
5319
+ function hasUnreadableCode(coverage) {
5320
+ return coverage.unread > 0;
5321
+ }
5322
+ function coverageOf(cwd, config) {
5323
+ const ignore = exclusions(config, "product");
5324
+ const readable = extensionsOf(config.languages).map((extension) => extension.slice(1)).join(",");
5325
+ const roots = measuredRoots(cwd);
5326
+ const under = (extensions) => roots.map((root) => root === "." ? `**/*.{${extensions}}` : `${root}/**/*.{${extensions}}`);
5327
+ const read = readable ? globSync4(under(readable), { cwd, ignore, dot: false }).length : 0;
5328
+ const unreadable = UNREADABLE_EXTENSIONS.map((extension) => extension.slice(1)).join(",");
5329
+ const found = globSync4(under(unreadable), { cwd, ignore, dot: false });
5330
+ const total = read + found.length;
5331
+ return {
5332
+ read,
5333
+ unread: found.length,
5334
+ share: total === 0 ? 0 : found.length / total,
5335
+ extensions: [...new Set(found.map((file) => path.extname(file)))].sort()
5336
+ };
5337
+ }
5338
+ function unreadableNote(coverage) {
5339
+ const percent = Math.round(coverage.share * 100);
5340
+ const kinds = coverage.extensions.join(", ");
5341
+ 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`;
5342
+ }
5343
+
5344
+ // src/roles/static-analysis/excerpt.ts
5345
+ import { readFileSync as readFileSync17 } from "node:fs";
5346
+ import { join as join22 } from "node:path";
5347
+
5348
+ // src/roles/static-analysis/help/bundle.ts
5349
+ var WEB_DEV_TREE_SHAKING = "https://web.dev/articles/reduce-javascript-payloads-with-tree-shaking";
5350
+ var WEB_DEV_SCRIPT_EVALUATION = "https://web.dev/articles/script-evaluation-and-long-tasks";
5351
+ var V8_COST_2019 = "https://v8.dev/blog/cost-of-javascript-2019";
5352
+ var PATTERNS_SPLITTING = "https://www.patterns.dev/vanilla/bundle-splitting/";
5353
+ var TWO_COSTS = 'Compression changes what crosses the network and nothing about what the engine reads: "900 KB of decompressed JavaScript is still 900 KB to the parser and compiler, even though it may be roughly 300 KB when compressed."';
5354
+ var BANDS2 = "The word beside the figure is its band: `ok` below 70% of the reference, `near` from 70% to 100%, `exceed` above it. `near` exists because a figure that crosses a line without warning crosses it in a release nobody was watching.";
5355
+ var actionable = {
5356
+ bundleInitialTransfer: {
5357
+ label: "Initial load, transfer",
5358
+ what: "The gzipped weight of the JavaScript the entry pulls, which is what crosses the network on a first visit.",
5359
+ read: "Higher is worse, against two references that both measure transfer: 170 KB, the budget for getting interactive in five seconds on a slow 3G connection on a median mobile device, and 747.5 KB, the median page on the web. Sitting below the median and above the budget at the same time is an ordinary position, and both readings are true: the first says we are normal, the second says we are not yet good. " + BANDS2,
5360
+ caveat: `It says nothing about the cost of RUNNING the code. ${TWO_COSTS} The row below is that other cost, and the ratio between the two is not a constant: it varies with the content of each chunk, so neither figure can be derived from the other. This is also not what a returning visitor pays, because the HTTP cache and the engine's own code cache change that picture entirely.`,
5361
+ source: 'The 170 KB budget and its conditions are published together, as is its decompressed equivalent. The median is HTTP Archive\u2019s `bytesJs`, defined there as "the sum of transfer size kilobytes of all external scripts requested by the page", which is why it may only be compared to a compressed figure. gzip rather than brotli deliberately: the build precompresses nothing, so this role compresses for itself, and gzip is the conservative bound because it is always larger than brotli. If the edge serves brotli we are lighter than reported, which is the acceptable direction to be wrong in.',
5362
+ links: [
5363
+ { label: "Why compression does not help the parser", url: WEB_DEV_TREE_SHAKING },
5364
+ { label: "HTTP Archive, page weight", url: "https://httparchive.org/reports/page-weight" },
5365
+ { label: "Bundle splitting", url: PATTERNS_SPLITTING }
5366
+ ],
5367
+ fix: "Split by route, so one screen stops shipping what the others need. Then defer whatever the first interaction does not use."
5368
+ },
5369
+ bundleInitialParsed: {
5370
+ label: "Initial load, to parse",
5371
+ what: "The same files, decompressed: the size the engine actually reads, parses and compiles.",
5372
+ read: "Higher is worse, against 717 KB, published as the decompressed equivalent of the same mobile budget. Read it beside the transfer row rather than instead of it: a chunk of mostly repetitive code compresses well and still costs the engine every byte. " + BANDS2,
5373
+ caveat: "\u26D4 A byte count is not a duration, and this is deliberately not converted into one: no primary source publishes an engine parse throughput, so any millisecond figure derived from kilobytes would be invented. What IS published is per-device, for 1 MB decompressed: 280 ms on a high-end phone, 1781 ms on a median one, 6010 ms on a sub-$100 one. It also says nothing about which of these bytes are USED. A function that is never called is preparsed rather than fully parsed, so dead code costs less than live code, not nothing, and no static measurement can tell which is which.",
5374
+ source: `${TWO_COSTS} Stated by the platform documentation rather than inferred. \u26A0\uFE0F One correction worth carrying: the engine team retired "parse is the dominant cost" in 2019, stating that "the dominant costs of processing scripts are now download and CPU execution time". The widely quoted per-device parse figures predate that, which is why this row is presented as weight rather than as time.`,
5375
+ links: [
5376
+ { label: "The decompressed figure is the one that matters", url: WEB_DEV_TREE_SHAKING },
5377
+ {
5378
+ label: "What the engine does with code it may never run",
5379
+ url: "https://v8.dev/blog/preparser"
5380
+ },
5381
+ { label: "Where the cost actually sits, measured", url: V8_COST_2019 }
5382
+ ],
5383
+ fix: "Ship less code. Drop a polyfill the supported browsers no longer need, or replace a big library with the platform. Splitting moves these bytes, it does not remove them."
5384
+ },
5385
+ bundleHeaviestAsset: {
5386
+ label: "Heaviest single asset",
5387
+ what: "The largest file an ENTRY REFERENCES, of its type, decompressed. \u26D4 Not the largest file emitted: a chunk loaded lazily is referenced by no entry, so it never appears here however heavy it is. Measured on `apps/front/console`, the heaviest unreferenced chunk is 233 KB, 2.3 times the per-script reference; it is inside the dynamic layer figure instead.",
5388
+ read: "Higher is worse, against 100 KB for one script, which the platform documentation calls a good target, and against the engine team\u2019s own advice to split a bundle once it passes roughly 50 to 100 kB. " + BANDS2,
5389
+ caveat: "A large chunk is not automatically a defect. A third-party chunk every entry shares is legitimately big, and splitting it further has a cost of its own, which is the file-count row. It says nothing about whether those bytes are used, and nothing about whether this asset is on the critical path: an asset pulled by one rare screen and an asset pulled by all of them have the same weight here and not the same importance.",
5390
+ source: "The 100 KB per-script target is published by the platform documentation on script evaluation; the 50 to 100 kB splitting advice is the engine team\u2019s, stated with HTTP/2 multiplexing assumed.",
5391
+ links: [
5392
+ { label: "Script evaluation and long tasks", url: WEB_DEV_SCRIPT_EVALUATION },
5393
+ { label: "Bundle splitting", url: PATTERNS_SPLITTING },
5394
+ {
5395
+ label: "Vite build options, including manualChunks",
5396
+ url: "https://vite.dev/config/build-options"
5397
+ }
5398
+ ],
5399
+ fix: "Separate library code from your own with `manualChunks`, then split by route. \u26A0\uFE0F Neither is configured on any front here today."
5400
+ },
5401
+ bundleDynamicLayer: {
5402
+ label: "Dynamic layer, one variant per group",
5403
+ what: "The weight of the files the build emits that no entry references statically, counted once per group rather than once per variant.",
5404
+ read: "A **ceiling**, not an average, and the pair of bounds is the point: the upper figure takes the heaviest variant of every group, the lower one the lightest, so the gap between them is the spread between variants. Read the upper bound as what reaching every group in one variant would cost, which no single session does.",
5405
+ caveat: "\u26D4 It is not additive with the initial load and it is not a total either. A reader pays a SUBSET: one variant, and only the groups the screens they visit actually need. The role cannot say which subset, because that depends on navigation, and attributing a dynamically imported file to the screen that pulls it is not something the entry manifest records. \u26A0\uFE0F Counting every variant instead, which is what reading the output directory gives, overstates this by the number of variants, and ignoring the layer understates the application by more than half on two of the fronts measured here.",
5406
+ source: "The role\u2019s own pass: every emitted script the entry manifest does not reference, grouped by filename stem so variants of one group collapse, then bounded. Confirmed to be translation chunks by reading them, one group per namespace and one variant per locale. \u26A0\uFE0F The grouping is a filename convention, so it is evidence rather than proof: a future bundler naming scheme would need the grouping re-checked, and the file count beside the figure is what makes a wrong grouping visible.",
5407
+ links: [
5408
+ {
5409
+ label: "Import on visibility",
5410
+ url: "https://www.patterns.dev/vanilla/import-on-visibility/"
5411
+ },
5412
+ {
5413
+ label: "Import on interaction",
5414
+ url: "https://www.patterns.dev/vanilla/import-on-interaction/"
5415
+ },
5416
+ {
5417
+ label: "Why the decompressed figure is the one the engine pays",
5418
+ url: WEB_DEV_TREE_SHAKING
5419
+ }
5420
+ ],
5421
+ fix: "Load a group only on the screen that needs it. Many small groups cost a reader far less than the same bytes in a few large ones."
5422
+ }
5423
+ };
5424
+ var context = {
5425
+ bundleInitialFiles: {
5426
+ label: "Initial load, files",
5427
+ what: "How many separate JAVASCRIPT files the entry pulls. \u26A0\uFE0F Stylesheets, fonts and images are not in it, and this page carries no figure for them.",
5428
+ read: "A direction rather than a target, and it sits between two opposite costs: too few means one large file that cannot be cached or replaced independently, too many means the browser spends longer discovering what the page needs.",
5429
+ caveat: '\u26D4 There is no threshold here and none is invented. The only published statement is a direction: a front that halved its bundles reported that "too many chunks would result in the browser spending significantly more time in discovering all the modules needed for the page", despite HTTP/2 multiplexing. So read this as context for the two weights, never as a verdict on its own.',
5430
+ fix: "Nothing to do. There is no target here: too few files means one big one that cannot be cached alone, too many means a slower start.",
5431
+ source: 'The direction is reported by a file-sharing front\u2019s own engineering write-up of a bundler migration, which also measured a 33% reduction in bundle sizes against only "modest" improvements in its own perceived-load metric. The count itself comes from the entry manifest.',
5432
+ links: [
5433
+ {
5434
+ label: "A bundler migration, measured and reported honestly",
5435
+ url: "https://dropbox.tech/frontend/how-we-reduced-the-size-of-our-javascript-bundles-by-33-percent"
5436
+ },
5437
+ { label: "Bundle splitting", url: PATTERNS_SPLITTING }
5438
+ ]
5439
+ },
5440
+ bundleEntrySet: {
5441
+ label: "Referenced by the entries",
5442
+ what: "The JAVASCRIPT weight of everything some entry references statically, across all entries. \u26A0\uFE0F Stylesheets, fonts and images are outside it.",
5443
+ read: "\u26D4 Not a user cost: nobody downloads all of it. Read it as code-splitting health. A large figure beside a small initial load is splitting doing its job; a figure close to the initial load means one entry is carrying the whole application.",
5444
+ caveat: '\u26A0\uFE0F This is deliberately NOT called "total emitted", because it is not the total: the build writes files no entry references, and on one front measured here that gap was 1326 files against 10 referenced. The dynamic layer above is that gap. Comparing this figure to a page-weight budget is the other trap, because a budget describes one page load and this describes an application. And summing the output directory would be worse than either: the server bundle and the per-brand assets live there, and no page load fetches them.',
5445
+ fix: "Nothing to do. It is everything the entries can reach, which nobody downloads at once. It sets the ceiling for the figures above it.",
5446
+ source: "This role\u2019s own count over the bundler\u2019s entry manifest, which is the only artefact that states which emitted files an entry references."
5447
+ }
5448
+ };
5449
+ var bundleHelp = { ...actionable, ...context };
5450
+
4787
5451
  // src/roles/static-analysis/help/metrics.ts
4788
5452
  var metricsHelp = {
4789
5453
  duplicatedLinesPercent: {
4790
5454
  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.",
5455
+ what: "The share of lines jscpd found in at least one clone, over the lines of the files it was able to scan.",
5456
+ 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.",
5457
+ 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%.",
5458
+ fix: "Extract the shared code once, where both copies can see it. \u26A0\uFE0F Open the list below by length first: one long pair costs more than ten short ones.",
4794
5459
  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
5460
  },
5461
+ complexityMax: {
5462
+ label: "Complexity, worst function",
5463
+ what: "McCabe's number for the single hardest function of the module's product code.",
5464
+ 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.",
5465
+ 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 thing a LINT RUN tells those modules. \u26A0\uFE0F It is not their only visibility here: the size gauge runs in every module whatever its preset, so the same page carries a complexity count per file and a cognitive-complexity one below this row.",
5466
+ fix: "Split this function. Each early return and each block you could name is a place to cut. It is one function, so the work is bounded.",
5467
+ 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`."
5468
+ },
5469
+ complexityModerate: {
5470
+ label: "Complexity 11-20, moderate risk",
5471
+ what: "How many functions sit in McCabe's second band, out of how many were measured.",
5472
+ 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.",
5473
+ 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.',
5474
+ fix: "Nothing to do. This is the healthy middle. It is here to give the two bands below a denominator.",
5475
+ 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."
5476
+ },
5477
+ complexityHigh: {
5478
+ label: "Complexity 21-50, high risk",
5479
+ what: "How many functions sit in McCabe's third band.",
5480
+ 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.",
5481
+ 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.",
5482
+ fix: "Plan a split rather than waiting for the next change. At this size nobody tidies a function while passing through, so it only gets worse.",
5483
+ 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."
5484
+ },
5485
+ complexityUntestable: {
5486
+ label: "Complexity over 50, untestable",
5487
+ what: "How many functions sit in McCabe's top band, the one he called untestable.",
5488
+ 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.",
5489
+ 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.",
5490
+ fix: "Treat this file as blocked for changes until the function is split. A test suite cannot cover this many branches, so a change here cannot be verified.",
5491
+ 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."
5492
+ },
5493
+ entryPoints: {
5494
+ label: "Entry points",
5495
+ what: "How many files the reachability walk started from, against how many it scanned.",
5496
+ 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.",
5497
+ 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.",
5498
+ fix: "Nothing to do. It tells you whether the dead-code figures below can mean anything: the further this sits from the file count, the more the walk proved.",
5499
+ 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."
5500
+ },
5501
+ deadLinesPercent: {
5502
+ label: "Unreachable lines",
5503
+ what: "The share of scanned lines the reachability walk never reached from an entry point, with the number of files that are unreachable IN FULL beside it. \u26A0\uFE0F The second number does not contain the first: dead lines also come from exports and symbols that are dead inside files the walk does reach, so 0 unreachable files and a non-zero share is a normal reading, not a contradiction.",
5504
+ read: "\u26D4 Read the two denominators together or the figure misleads, and that is measured rather than feared. On `apps/nest/microservices/agency` the line share is 60.21%, which reads as a rotten module or a broken tool; neither is true. 490 of its 504 files are reachable, and ONE unreachable file, a seed data table, holds 52 210 lines, 59.3% of the module by itself. A large line share over a small file share means a generated or seed file, not code anyone writes. 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.",
5505
+ caveat: "\u26D4 This used to warn that a library's files look unreachable because its consumers live outside the scan. That is no longer how the walk starts: it is SEEDED with what other modules import from here, so a library has real entry points. Measured on `libs/front/components`, 473 entry points over 950 files and 8 unreachable files, against the 87 the unseeded walk reported. A finding here now means imported by nobody, anywhere in the workspace, so read it as real rather than as an artefact of the boundary. \u26A0\uFE0F 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.",
5506
+ fix: "Nothing from this number alone. It says how much; the rows under it say where. Start there.",
5507
+ 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`."
5508
+ },
5509
+ unusedFiles: {
5510
+ label: "Unreachable files",
5511
+ what: "Files of this module that nothing reaches: not its own code, not another module, not a spec.",
5512
+ read: "A candidate for deletion, and the only one of the dead-code rows that names whole files. Zero is the ordinary value for an application whose entry points are declared.",
5513
+ caveat: "\u26A0\uFE0F Two classes remain in it and only one is a deletion: a file nothing reaches at all, and an entry point of a framework this role has not been told about, which is a missing declaration rather than dead code. A THIRD class used to sit here and no longer does: a file reached only by a spec, which the tool labels `used-only-by-tests` itself, is now counted on its own row instead. Measured on `libs/nest/auth`, that moved 5 files out of 20, and the figure now agrees with the reason printed beside each finding instead of contradicting it.",
5514
+ fix: "Delete the file, or import it where it belongs. \u26A0\uFE0F Read the reason beside each one first: some are kept alive by a framework that loads them by name.",
5515
+ source: "jscpd's dead-code walk, category `unused-file`, minus the findings it labels `used-only-by-tests`. The walk starts from the module's declared entry points PLUS the files another module imports, which come from the workspace index: without those seeds a library has no starting point at all and reports 87 unreachable files where 12 are, measured on `libs/nest/auth`."
5516
+ },
5517
+ unusedExports: {
5518
+ label: "Unused exports",
5519
+ what: "Exported names nothing in this module imports.",
5520
+ 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.",
5521
+ 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.",
5522
+ fix: "Remove the `export` keyword, not the code. Most of these are internal helpers that were exported only so a test could reach them.",
5523
+ source: "jscpd dead-code findings of category `unused-export`."
5524
+ },
5525
+ unusedSymbols: {
5526
+ label: "Unused symbols",
5527
+ what: "Declared names, not exported, that nothing in their own file uses.",
5528
+ read: "A local cleanup rather than a design signal, and usually a small count.",
5529
+ 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.",
5530
+ fix: "Delete it. Nothing names this symbol, and unlike a file it cannot be reached by a string at runtime.",
5531
+ source: "jscpd dead-code findings of category `unused-symbol`, above the confidence floor."
5532
+ },
5533
+ unusedImports: {
5534
+ label: "Unused imports",
5535
+ what: "Imported names the importing file never uses.",
5536
+ 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.",
5537
+ 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.",
5538
+ fix: "Delete the import. The lint role does this for you once the module adopts it, so a number here on an adopted module is a bug worth reporting.",
5539
+ 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."
5540
+ },
5541
+ duplicatedLinesPercentTests: {
5542
+ label: "Duplicated lines (tests)",
5543
+ what: "The same share, measured over this module's test files alone.",
5544
+ 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.",
5545
+ 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.",
5546
+ fix: "Probably nothing. A test that reads top to bottom is worth more than one sharing a builder with thirty others. Act only when the same setup drifts between suites.",
5547
+ 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."
5548
+ },
5549
+ clonesTests: {
5550
+ label: "Clones (tests)",
5551
+ what: "How many duplicated fragments the test pass reported.",
5552
+ read: "A count, with the same reserves as the product one above.",
5553
+ 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.",
5554
+ fix: "Start with pairs whose two sides are in different suites. Those drift apart without anyone noticing, because nobody runs both at once.",
5555
+ 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."
5556
+ },
4796
5557
  clones: {
4797
5558
  label: "Clones",
4798
5559
  what: "How many duplicated fragments jscpd reported, each entry linking two places that match.",
4799
5560
  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
5561
  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.",
5562
+ fix: "Start with the longest pairs. A pair inside one folder is usually one extraction. A pair across two features is a shared idea nobody has named yet.",
4801
5563
  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."
4802
- }
5564
+ },
5565
+ cycles: {
5566
+ label: "Import cycles",
5567
+ what: "Groups of files that import each other in a circle, directly or through other files.",
5568
+ 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.",
5569
+ caveat: "A group here survives the build, which is a narrowing of what this figure used to report. The edges a build erases are left out: `import type`, `export type`, and an import whose every binding is an inline `type` one. That matters more than it sounds, because over this monorepo they are **100 of 120 groups, 83.3%**, and the files caught in one fall from 327 to 55, so the old figure pointed at five phantom tangles for every real one. A phantom is not nothing, it is a circle in the TYPES, and it still means two files that cannot be understood apart; it just cannot deadlock, because `tsc` and every bundler delete the import that closes it. The erased count is printed beside the figure so a bigger number from madge or from an older report is explained rather than mysterious. It also reports GROUPS and not paths, so a group of six files may contain a dozen distinct circles, and breaking one of them does not necessarily break the group, which is exactly why the group is the unit.",
5570
+ fix: "Break one import per group. \u26A0\uFE0F Pick the one pointing the wrong way, not the easiest one: cutting any other edge usually leaves the tangle in place.",
5571
+ source: "Tarjan's strongly connected components, via graphology-components, run over the value edges. Held against madge's own `circular()` on five modules of the three families BEFORE the type edges were dropped: 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. madge does not read the `type` keyword either, so it now reports the larger figure, and the difference is the count printed beside ours. The kind of an edge is read at two levels of the oxc tree, the declaration's `importKind` and each binding's own, because they disagree: in `import type { A }` the declaration is a type one while the binding inside it says value."
5572
+ },
5573
+ importEdges: {
5574
+ label: "Imports between files",
5575
+ what: "How many times one of the module's own files imports another of them.",
5576
+ 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.",
5577
+ 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.",
5578
+ fix: "Nothing to do. It is the size of the graph every other figure on this panel is computed over.",
5579
+ 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."
5580
+ },
5581
+ graphDensity: {
5582
+ label: "Density",
5583
+ what: "The share of all the edges that could exist between these files that actually do.",
5584
+ 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.",
5585
+ 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.",
5586
+ fix: "Nothing to do. It is a shape, not a score: it says whether the module is a web or a tree. \u26A0\uFE0F Only compare it to itself over time, never between modules.",
5587
+ source: "graphology-metrics, graph density, the standard definition for a directed graph."
5588
+ },
5589
+ communities: {
5590
+ label: "Communities",
5591
+ 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.",
5592
+ 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 89 for 10 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.",
5593
+ 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.",
5594
+ fix: "Nothing to do. It is a grouping to compare with your own folders. \u26A0\uFE0F A community of one file is worth a look: nothing in the module imports it, and nothing it imports.",
5595
+ source: "Louvain, via graphology-communities-louvain, with the role's own random seed so the figure reproduces. Blondel, Guillaume, Lambiotte and Lefebvre, 2008."
5596
+ },
5597
+ modularity: {
5598
+ label: "Modularity (Q)",
5599
+ 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.",
5600
+ 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.",
5601
+ 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.",
5602
+ fix: "Nothing to do. It judges the grouping, not the code. A high value means a clear structure is there to read, and the drawing is where to read it.",
5603
+ 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."
5604
+ },
5605
+ mostImported: {
5606
+ label: "Most imported files",
5607
+ 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)`.",
5608
+ 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 the modules benched so far, this repository mostly satisfies that rule already: 23 of 30 most-imported rows sit at `I` of 0.06 or less, and the highest is 0.24. \u26A0\uFE0F Those are the figures of one bench on one day, not a property of the ranking.",
5609
+ 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.",
5610
+ fix: "Nothing to do. Every module has a most-imported file, and a type used everywhere is doing its job. Read it as context for the figures around it.",
5611
+ 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.'
5612
+ },
5613
+ complexity: {
5614
+ label: "Files with a complex function",
5615
+ what: "Files holding at least one function whose cyclomatic complexity passes 10.",
5616
+ read: "499 files of 8 083 across the repository, 6.2 per 100, fourth of the eight. 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.",
5617
+ caveat: "It does not say which function, nor whether branching is wrong there: a parser legitimately branches. \u26D4 This is a GAUGE and not the line your lint refuses at. It runs oxlint with its own config in a temporary directory, at a threshold fixed once for every module, and never reads the module's `.oxlintrc.json`. So it answers the same question the same way in every module, including the 146 of 198 that have not adopted lint, and it WILL disagree with a green build: the `nest` preset refuses `complexity` at 30 while this reports above 10.",
5618
+ fix: "Split the functions this points at. This row counts FILES, so it tells you how much work there is, not how bad any single case is.",
5619
+ source: "oxlint complexity, above 10. The threshold comes from McCabe 1976, which puts the limit at 10, and from our own p90 at 11: the one indicator where two independent references agree within a unit. 94.8% of our functions already sit in the 1-10 band. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5620
+ },
5621
+ "max-depth": {
5622
+ label: "Files with deep nesting",
5623
+ what: "Files holding a block nested more than three levels deep.",
5624
+ read: "The rarest of the eight with `max-nested-callbacks`, 8 files of 8 083 across the repository, 0.1 per 100, 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.",
5625
+ caveat: "Depth is not complexity: four flat `if`s in a row never trip it, and one `if` inside three loops does.",
5626
+ fix: "Move the guard conditions to the top and return early. Depth is not complexity: four `if`s in a row are fine, one inside three loops is not.",
5627
+ source: "oxlint max-depth, above 3. The threshold is IfSQ SP-2, which says few people can understand nesting of conditional statements beyond 3 levels, and our p90 agrees at 3. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5628
+ },
5629
+ "max-lines": {
5630
+ label: "Long files",
5631
+ what: "Files longer than 150 lines, blank lines and comments included.",
5632
+ read: "864 files of 8 083 across the repository, 10.7 per 100, the second most frequent of the eight. 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.",
5633
+ 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.",
5634
+ fix: "Nothing from this row alone. Long is not bad: a generated file or a constants table is long by nature. Read it beside the other size rows.",
5635
+ source: "oxlint max-lines, above 150. The threshold is our p90 of 157 rounded down, because no research anywhere sets a FILE's length: IfSQ's three citations (500, 100-150, 143) are all about routines. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5636
+ },
5637
+ "max-lines-per-function": {
5638
+ label: "Files with a long function",
5639
+ what: "Files holding at least one function longer than 109 lines.",
5640
+ read: "630 files of 8 083 across the repository, 7.8 per 100, the third most frequent of the eight. \u26D4 It used to be held out of the report as a mere descriptor, on a measured break of a factor 4.5; that break was an artefact of a threshold of 50, where this one rule alone flagged 21.3% of all files and carried 77% of what the eight reported together. At 109 there is no break left and every indicator counts. 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.",
5641
+ caveat: "Length is a signal and not a defect: a long function can be a flat list of fields. Read it beside the statement count, which is the other half of the same question, and which it does NOT duplicate: measured here, 255 long functions breach no statement count and 699 dense ones are not long.",
5642
+ fix: "Give the blocks names. A long function is usually several short ones waiting for a signature, and each comment announcing a section marks one.",
5643
+ source: "oxlint max-lines-per-function, above 109. The threshold is our p90. IfSQ SP-1 says 150, citing IBM on routines over 500 lines, and is discarded here for being more permissive than our own code. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5644
+ },
5645
+ "max-statements": {
5646
+ label: "Files with a long function body",
5647
+ what: "Files holding at least one function of more than ten statements.",
5648
+ read: "The most frequent of the eight: 891 files of 8 083, 11.0 per 100, against 10.7 for the next. It was held out of the report as a descriptor on a break that no longer exists; see the row above. 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.",
5649
+ caveat: "It counts statements and not work: ten calls in a row trip it where one dense expression does not.",
5650
+ fix: "Same answer as the row above: name the blocks. This counts statements where that one counts lines, so a file high here is dense rather than long.",
5651
+ source: "oxlint max-statements, above 10. The threshold is the rule's own default, kept because our p90 of 15 is the looser of the two; it is not redundant with the line count, 699 functions breach it without being long and 255 are long without breaching it. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5652
+ },
5653
+ "max-params": {
5654
+ label: "Files with a wide signature",
5655
+ what: "Files holding at least one function taking more than three parameters.",
5656
+ read: "373 files of 8 083, 4.6 per 100, fifth of the eight. 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.",
5657
+ caveat: "A destructured object parameter counts as one, so the figure does not see a function taking fifteen fields in one bag.",
5658
+ fix: "Group the arguments into the object the call site already builds. A wide signature forces every caller to remember an order, and that order breaks quietly.",
5659
+ source: "oxlint max-params, above 3. The threshold is the rule\u2019s own default, kept because our p90 of 4 is looser; beyond three parameters the fix is an object or currying, so this is a design boundary rather than a dial. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5660
+ },
5661
+ "max-nested-callbacks": {
5662
+ label: "Files with nested callbacks",
5663
+ what: "Files holding callbacks nested more than three deep.",
5664
+ read: '9 files of 8 083 across the repository, 0.1 per 100, the joint rarest with `max-depth`. \u26A0\uFE0F It read "zero everywhere measured so far" while the threshold sat at 10 and the deepest chain in the repository is 4, so it reported nothing for as long as it shipped; at 3 it finds those 9. A non-zero value is 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.',
5665
+ caveat: "It counts callbacks and not promise chains or awaits, so modern asynchronous code can be deeply sequential without ever tripping it.",
5666
+ fix: "Flatten it with `async`/`await`, or give the inner callback a name. Nesting like this is rarely a choice; it is what a chain looks like before anyone revisits it.",
5667
+ source: "oxlint max-nested-callbacks, above 3. The threshold is IfSQ SP-2 again, same nesting family, and our p90 agrees at 3. It sat at 10 while the deepest chain in the repository is 4, so it reported 0 files for as long as it shipped. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5668
+ },
5669
+ "max-classes-per-file": {
5670
+ label: "Files with several classes",
5671
+ what: "Files declaring more than one class.",
5672
+ read: "161 files of 8 083, 2.0 per 100. 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.",
5673
+ caveat: "It says nothing about the size of the classes, and a file with one 800-line class never appears here.",
5674
+ fix: "Split the file, one class each. \u26A0\uFE0F Unless the second class exists only to serve the first, which this rule cannot tell apart.",
5675
+ source: "oxlint max-classes-per-file, above 1. The threshold is the rule's floor, there is no stricter value to pick. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
5676
+ },
5677
+ "cognitive-complexity": {
5678
+ label: "Files hard to follow",
5679
+ what: "Files holding at least one function whose cognitive complexity passes 7.",
5680
+ read: "The other complexity figure, and it answers a different question from McCabe's: not how many paths a test suite must cover, but how much a reader has to hold in their head. The two disagree on purpose. A flat `switch` with twelve cases is cyclomatically 13 and cognitively 1, because nothing is nested and nothing surprises; three `if`s inside each other are cyclomatically 4 and cognitively 6, because each level is charged for the levels above it. When a file is high here and low there, the code is tangled rather than merely branchy, and that is the one this role would act on first.",
5681
+ caveat: "\u26A0\uFE0F In JavaScript and TypeScript, sequences of `||` and `??` do NOT increment, deliberately: in this language they are overwhelmingly default-value shorthand rather than control flow, and SonarSource made that exclusion universal in SonarQube 10.8. So a long chain of fallbacks scores zero here while `&&` chains are charged. \u26D4 And it is a GAUGE, not the line your lint refuses at: no preset in this repository enforces cognitive complexity at all today.",
5682
+ fix: "Split what this points at, and trust it over the complexity rows when they disagree. It counts what a reader has to hold in their head.",
5683
+ source: "Sonar's own `cognitive-complexity` rule from `eslint-plugin-sonarjs`, loaded into oxlint through `plugins/sonar-cognitive.js` and run in the same invocation as the size rules. It is USED and never copied: that package ships under the Sonar Source-Available License and sentinel is MIT. The algorithm is Campbell 2018 and its author works at SonarSource, so their implementation is the specification's own house rather than one reading of it; verified against Campbell's published example, which scores 7 in both. The threshold is 7, from `min(research, our p90, the rule's default)`: Sonar publishes 15 for S3776 and that is a vendor default rather than a research value, since one of their own engineers has said publicly that no formula supports it, while our p90 over the files the rule touches is 7. It reports 342 of 8 083 files on the bench, 4.2 per 100."
5684
+ },
5685
+ usedOnlyByTests: {
5686
+ label: "Used only by tests",
5687
+ what: "Files of this module that no production code imports, but another module's test files do.",
5688
+ 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.",
5689
+ 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.",
5690
+ fix: "Decide file by file. Test helpers move to a test folder. Product code that only a test calls is a design question: ask why it was written.",
5691
+ 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`."
5692
+ },
5693
+ measuredFiles: {
5694
+ label: "Files the gauge read",
5695
+ what: "How many product files the size indicators were measured over.",
5696
+ read: "The denominator of every size row BELOW it: the gauge prints its population first, then the counts it divides. None of them compares between modules without it: three breaches over 50 files and three over 3 000 are not the same module.",
5697
+ caveat: "It is the gauge\u2019s population and not the module\u2019s: `src` only, tests, mocks and generated files excluded, which is the same list every other pass is given. So it is smaller than what `ls` would count, deliberately, and it will differ from the duplication pass\u2019s own file count, which drops files shorter than the detection window.",
5698
+ fix: "Nothing to do. It is the denominator of every size row below. Three breaches in 50 files and three in 3 000 are not the same module.",
5699
+ source: "oxlint's `number_of_files` for the run this role drives, with the role's product exclusions passed as `--ignore-pattern`. \u26A0\uFE0F It is NOT always the graph pass's population, and the earlier version of this line said it was, on the strength of one nest library that happens to have no stylesheet: there the two read the same 99 files. The graph counts a `.css` or `.scss` file as a node; oxlint does not lint one, so the gauge reads fewer. Measured over the four front applications, that accounts for the gap exactly: career 64 against 63, console 504 against 503, front-legacy 1510 against 1509, host-admin 4537 against 4536, one stylesheet each. \u26A0\uFE0F There is a SECOND cause and it is louder: oxlint skips what `.gitignore` covers, and the graph does not. On `apps/cloud/interop` the two read 69 and 100, because a bare unanchored `staffea` on line 225 of the repository\u2019s `.gitignore` shadows 34 files that git still tracks. So a large gap here is worth opening: it means a directory of real source is invisible to every tool that honours `.gitignore`."
5700
+ },
5701
+ // The bundle family lives in its own file: it is the only one whose entries carry a way to act,
5702
+ // and keeping it here would have pushed this file past the point where anyone reads it whole.
5703
+ ...bundleHelp
4803
5704
  };
4804
5705
  function helpFor(key) {
4805
5706
  return metricsHelp[key];
4806
5707
  }
4807
5708
 
5709
+ // src/roles/static-analysis/maintainability/size-rules.ts
5710
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
5711
+ import { tmpdir as tmpdir2 } from "node:os";
5712
+ import { join as join21 } from "node:path";
5713
+ var GAUGE_THRESHOLDS = {
5714
+ // McCabe 1976 says 10, our p90 says 11. The only indicator where two independent candidates agree
5715
+ // within one unit, and 94.8% of our functions already sit in the 1-10 band SEI calls low risk.
5716
+ complexity: 10,
5717
+ // IfSQ SP-2, "few people can understand nesting to more than 3 levels". Our p90 agrees at 3.
5718
+ "max-depth": 3,
5719
+ // No research anywhere for a FILE's length: IfSQ's three citations (500, 100-150, 143) are all
5720
+ // about routines. So our p90 of 157, rounded down.
5721
+ "max-lines": 150,
5722
+ // Our p90. IfSQ SP-1 says 150 and is discarded for being more permissive than our own code.
5723
+ "max-lines-per-function": 109,
5724
+ // No research. Our p90 is 15, the rule's default is 10, so the default stays as the stricter one.
5725
+ "max-statements": 10,
5726
+ // No research, and none needed: beyond three parameters the fix is an object or currying, so this
5727
+ // is a design boundary and not a dial. Our p90 of 4 is the looser candidate and loses.
5728
+ "max-params": 3,
5729
+ // Same nesting family as `max-depth`, no citation of its own. Our p90 agrees at 3.
5730
+ "max-nested-callbacks": 3,
5731
+ // 1 is the rule's floor, there is no stricter value to pick.
5732
+ "max-classes-per-file": 1,
5733
+ // Cognitive complexity, Campbell 2018, measured by Sonar's own rule, loaded
5734
+ // through `plugins/sonar-cognitive.js`.
5735
+ // Sonar publishes 15 for S3776, and that is a vendor default rather than a research value: one of
5736
+ // their own engineers has said publicly that no formula supports it. Our p90 is 7, so 7 wins, and
5737
+ // it reports 342 of 8 083 files, 4.2%, which sits inside the band the eight above occupy.
5738
+ "cognitive-complexity": 7
5739
+ };
5740
+ var COGNITIVE = "cognitive-complexity";
5741
+ var cognitivePlugin = () => join21(ownPackageDir(), "plugins", "sonar-cognitive.js");
5742
+ var CATEGORIES = [
5743
+ "correctness",
5744
+ "suspicious",
5745
+ "pedantic",
5746
+ "perf",
5747
+ "style",
5748
+ "restriction",
5749
+ "nursery"
5750
+ ];
5751
+ function sizeRulesConfig() {
5752
+ return {
5753
+ plugins: [],
5754
+ categories: Object.fromEntries(CATEGORIES.map((category) => [category, "off"])),
5755
+ // Sonar's cognitive complexity, in the SAME run as the eight above rather than a second
5756
+ // invocation: the parse is the expensive part and it is already paid here. Measured over the
5757
+ // whole bench, 8 083 files, the rule adds 2.4 s to a pass that was going to read them anyway.
5758
+ jsPlugins: [cognitivePlugin()],
5759
+ // ⛔ `warn` and not `error`, for the reason `tool-config.ts` gives about jscpd's `threshold`:
5760
+ // at `error` oxlint exits 1 as soon as one file breaches, which is indistinguishable from the
5761
+ // binary failing to run. Measured on `libs/nest/auth`, same config otherwise: `error` exits 1,
5762
+ // `warn` exits 0, and both report the same 18 diagnostics. So the tool measures, this role
5763
+ // judges, and a non-zero exit means the tool failed.
5764
+ rules: Object.fromEntries(
5765
+ Object.entries(GAUGE_THRESHOLDS).map(([rule, threshold]) => [
5766
+ rule === COGNITIVE ? `sonar/${rule}` : rule,
5767
+ ["warn", threshold]
5768
+ ])
5769
+ )
5770
+ };
5771
+ }
5772
+ function sizeRulesRun(paths, ignore) {
5773
+ const dir = mkdtempSync2(join21(tmpdir2(), "sentinel-size-"));
5774
+ const configPath = join21(dir, "oxlintrc.json");
5775
+ writeFileSync4(configPath, `${JSON.stringify(sizeRulesConfig(), null, 2)}
5776
+ `, "utf8");
5777
+ return {
5778
+ // `--format json` because the console shape is rendered by this role from the figures: a
5779
+ // per-pass table from the tool would be a second voice saying the same numbers differently.
5780
+ // The exclusions are the SAME list the duplication product pass is given, so "the module's
5781
+ // code" means one thing across the whole report.
5782
+ args: [
5783
+ "-c",
5784
+ configPath,
5785
+ ...ignore.flatMap((pattern) => ["--ignore-pattern", pattern]),
5786
+ "--format",
5787
+ "json",
5788
+ ...paths
5789
+ ],
5790
+ dispose: () => rmSync4(dir, { force: true, recursive: true })
5791
+ };
5792
+ }
5793
+ function ruleOf(code) {
5794
+ return /\(([^)]+)\)\s*$/.exec(code)?.[1];
5795
+ }
5796
+ function valueOf(message) {
5797
+ const match = /\((\d+)\)/.exec(message) ?? /complexity of (\d+)/.exec(message) ?? // Sonar's own wording, a third shape again: "reduce its Cognitive Complexity from 7 to the 0
5798
+ // allowed". It parenthesises nothing, and the `0` after it is the threshold, not the value.
5799
+ /Complexity from (\d+) to/.exec(message);
5800
+ return Number(match?.[1] ?? 0);
5801
+ }
5802
+ function breachesOf(raw) {
5803
+ let parsed;
5804
+ try {
5805
+ parsed = JSON.parse(raw);
5806
+ } catch {
5807
+ return { breaches: [], diagnostics: 0, files: 0 };
5808
+ }
5809
+ const diagnostics = parsed.diagnostics ?? [];
5810
+ const worst = /* @__PURE__ */ new Map();
5811
+ for (const diagnostic of diagnostics) {
5812
+ const rule = ruleOf(diagnostic.code ?? "");
5813
+ const file = diagnostic.filename;
5814
+ if (!rule || !file || !(rule in GAUGE_THRESHOLDS)) continue;
5815
+ const value = valueOf(diagnostic.message ?? "");
5816
+ const line = diagnostic.labels?.[0]?.span?.line ?? 0;
5817
+ const key = `${file}\0${rule}`;
5818
+ const seen = worst.get(key);
5819
+ if (!seen || value > seen.value) worst.set(key, { file, rule, value, line });
5820
+ }
5821
+ return {
5822
+ breaches: [...worst.values()],
5823
+ diagnostics: diagnostics.length,
5824
+ files: parsed.number_of_files ?? 0
5825
+ };
5826
+ }
5827
+ function scanSizeRules(cwd, roots, ignore, binary, spawn) {
5828
+ if (!binary) return void 0;
5829
+ const run = sizeRulesRun(roots, ignore);
5830
+ try {
5831
+ const result = spawn(binary, run.args, cwd);
5832
+ if (result.status !== 0) return void 0;
5833
+ return breachesOf(result.stdout);
5834
+ } finally {
5835
+ run.dispose();
5836
+ }
5837
+ }
5838
+
4808
5839
  // src/roles/static-analysis/report.ts
4809
- function hasFinding(report) {
4810
- return report.rows.some((row) => row.over);
5840
+ var FAMILIES = [
5841
+ {
5842
+ label: "Duplication",
5843
+ headline: "duplicatedLinesPercent",
5844
+ metrics: ["duplicatedLinesPercent", "clones", "duplicatedLinesPercentTests", "clonesTests"]
5845
+ },
5846
+ {
5847
+ label: "Dead code",
5848
+ // Not `entryPoints`, which is the walk's starting condition rather than a result.
5849
+ headline: "deadLinesPercent",
5850
+ metrics: [
5851
+ "entryPoints",
5852
+ "deadLinesPercent",
5853
+ "unusedFiles",
5854
+ "unusedExports",
5855
+ "unusedSymbols",
5856
+ "unusedImports",
5857
+ "usedOnlyByTests"
5858
+ ]
5859
+ },
5860
+ {
5861
+ label: "Complexity",
5862
+ headline: "complexityMax",
5863
+ metrics: ["complexityMax", "complexityModerate", "complexityHigh", "complexityUntestable"]
5864
+ },
5865
+ {
5866
+ label: "Structure",
5867
+ // ⚠️ Not `importEdges`, which is a size and not a signal: a big module has many edges and that
5868
+ // says nothing. A cycle is the one figure in this family a reviewer acts on.
5869
+ headline: "cycles",
5870
+ metrics: ["importEdges", "graphDensity", "cycles", "communities", "modularity", "mostImported"]
5871
+ },
5872
+ {
5873
+ label: "Size",
5874
+ // ⛔ The denominator, and that is the honest choice rather than a weakness. The family is nine
5875
+ // rules with nine counts and no one of them leads: picking one would rank them, which is a
5876
+ // judgement this role does not make. The count of places beside it says the size of the job.
5877
+ headline: "measuredFiles",
5878
+ metrics: ["measuredFiles", ...Object.keys(GAUGE_THRESHOLDS)]
5879
+ },
5880
+ {
5881
+ label: "Bundle",
5882
+ headline: "bundleInitialTransfer",
5883
+ metrics: [
5884
+ "bundleInitialTransfer",
5885
+ "bundleInitialParsed",
5886
+ "bundleInitialFiles",
5887
+ "bundleHeaviestAsset",
5888
+ "bundleDynamicLayer",
5889
+ "bundleEntrySet"
5890
+ ]
5891
+ }
5892
+ ];
5893
+ function familyOf(metric) {
5894
+ return FAMILIES.find((family) => family.metrics.includes(metric))?.label;
5895
+ }
5896
+ var CLAIMED_METRICS = FAMILIES.flatMap((family) => family.metrics);
5897
+ function byFamily(rows) {
5898
+ const grouped2 = FAMILIES.map((family) => ({
5899
+ label: family.label,
5900
+ rows: rows.filter((row) => family.metrics.includes(row.metric))
5901
+ })).filter((family) => family.rows.length > 0);
5902
+ const orphans = rows.filter((row) => familyOf(row.metric) === void 0);
5903
+ return orphans.length > 0 ? [...grouped2, { label: "Other", rows: orphans }] : grouped2;
5904
+ }
5905
+ function figureBlock(row, places = "") {
5906
+ const help = helpFor(row.metric);
5907
+ const label = help?.label ?? row.metric;
5908
+ const VERDICT = { ok: "ok", near: "near", exceed: "exceeds" };
5909
+ const mark = row.status === void 0 ? "" : `<span class="mark s-${row.status}">${escape(VERDICT[row.status] ?? row.status)}</span>`;
5910
+ const dot = "";
5911
+ const detail = help === void 0 ? "" : helpDetails(help, row.status === void 0);
5912
+ const unit = row.unit === "" ? "" : `${row.unit.startsWith("%") ? "" : " "}<span class="unit">${escape(row.unit)}</span>`;
5913
+ const action = help?.fix === void 0 ? "" : `
5914
+ <p class="fix"><b>What to do.</b> ${prose(help.fix)}</p>`;
5915
+ return ` <div class="figure">
5916
+ <div class="head">
5917
+ <span class="name">${dot}${escape(label)}</span>
5918
+ <span class="val">${escape(figure(row))}${mark}${unit}</span>
5919
+ </div>${action}
5920
+ ${detail}
5921
+ ${places}
5922
+ </div>`;
5923
+ }
5924
+ function helpDetails(help, withoutReference) {
5925
+ const links = (help.links ?? []).length === 0 ? "" : `<p class="links">${(help.links ?? []).map(
5926
+ (link) => `<a href="${escape(link.url)}" target="_blank" rel="noreferrer">${escape(link.label)}</a>`
5927
+ ).join(" \xB7 ")}</p>`;
5928
+ const noReference = withoutReference ? `<p class="noref">No published reference exists for this figure, so it carries no verdict. It is reported as measured.</p>` : "";
5929
+ return ` <details>
5930
+ <summary>how this is measured, and what it does not say</summary>
5931
+ <p class="what">${prose(help.what)}</p>
5932
+ <p class="read"><b>How to read it.</b> ${prose(help.read)}</p>
5933
+ ${noReference}
5934
+ <p><b>What it does not say.</b> ${prose(help.caveat)}</p>
5935
+ ${links}
5936
+ <p class="source">Source: ${prose(help.source)}</p>
5937
+ </details>`;
5938
+ }
5939
+ function secondSide(finding) {
5940
+ if (!finding.other) return "";
5941
+ const file = (at) => at.split(":")[0] ?? at;
5942
+ const sameFile = file(finding.other) === file(finding.path);
5943
+ const label = sameFile ? "and line" : "and";
5944
+ const shownSide = sameFile ? finding.other.split(":")[1] ?? finding.other : finding.other;
5945
+ return ` <span class="unit">${label}</span> <code>${escape(shownSide)}</code>`;
5946
+ }
5947
+ var HTML_GROUP_LIMIT = 50;
5948
+ function listedByMetric(findings) {
5949
+ const groups = /* @__PURE__ */ new Map();
5950
+ for (const finding of findings) {
5951
+ const bucket = groups.get(finding.metric) ?? [];
5952
+ bucket.push(finding);
5953
+ groups.set(finding.metric, bucket);
5954
+ }
5955
+ const weight = (finding) => finding.value ?? finding.lines ?? 0;
5956
+ return new Map(
5957
+ [...groups.entries()].map(([metric, all2]) => {
5958
+ const sorted = [...all2].sort((left, right) => weight(right) - weight(left));
5959
+ return [
5960
+ metric,
5961
+ {
5962
+ listed: sorted.slice(0, HTML_GROUP_LIMIT),
5963
+ rest: Math.max(0, sorted.length - HTML_GROUP_LIMIT)
5964
+ }
5965
+ ];
5966
+ })
5967
+ );
5968
+ }
5969
+ function figure(row) {
5970
+ if (row.unit === "%" && typeof row.value === "number") return row.value.toFixed(2);
5971
+ return typeof row.value === "number" ? grouped(row.value) : String(row.value);
5972
+ }
5973
+ function caveatsOf(report) {
5974
+ const keys = [...report.rows.map((row) => row.metric), ...report.findings.map((f) => f.metric)];
5975
+ const seen = /* @__PURE__ */ new Set();
5976
+ const found = [];
5977
+ for (const key of keys) {
5978
+ if (seen.has(key)) continue;
5979
+ seen.add(key);
5980
+ const help = helpFor(key);
5981
+ if (help) found.push(help);
5982
+ }
5983
+ return found;
4811
5984
  }
4812
5985
  function renderConsole(report) {
4813
5986
  const lines = [];
4814
5987
  const width = (pick) => Math.max(...report.rows.map((row) => pick(row).length));
4815
5988
  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)" : ""}`;
5989
+ const value = (row) => `${figure(row)} ${row.unit}`.trim();
4818
5990
  const labelWidth = width(label);
4819
5991
  const valueWidth = width(value);
4820
5992
  lines.push(`sentinel static-analysis: ${report.module}`);
4821
- for (const row of report.rows) {
4822
- lines.push(
4823
- ` ${row.over ? "!" : " "} ${label(row).padEnd(labelWidth)} ${value(row).padStart(valueWidth)} ${limit(row)}`
4824
- );
5993
+ for (const family of byFamily(report.rows)) {
5994
+ lines.push("", ` ${family.label.toLowerCase()}`);
5995
+ for (const row of family.rows) {
5996
+ lines.push(` ${label(row).padEnd(labelWidth)} ${value(row).padStart(valueWidth)}`);
5997
+ }
5998
+ }
5999
+ for (const note of report.notes) lines.push(` (${note})`);
6000
+ if (report.settings.length > 0) {
6001
+ const keyWidth = Math.max(...report.settings.map((setting) => setting.key.length));
6002
+ lines.push("", " measured with");
6003
+ for (const setting of report.settings) {
6004
+ lines.push(
6005
+ ` ${setting.key.padEnd(keyWidth)} ${setting.value}${setting.moved ? " (module)" : ""}`
6006
+ );
6007
+ }
6008
+ if (report.settings.some((setting) => setting.moved)) {
6009
+ lines.push(` (module) = overridden in ${report.module}'s static-analysis.config.mjs`);
6010
+ }
4825
6011
  }
4826
6012
  if (report.byLanguage && Object.keys(report.byLanguage).length > 0) {
4827
- lines.push("", " per language, where a clone was found");
6013
+ lines.push("", " per language, where a clone was found in the product code");
4828
6014
  for (const [language, stats] of Object.entries(report.byLanguage)) {
4829
6015
  lines.push(
4830
6016
  ` ${language.padEnd(12)} ${stats.percentage.toFixed(2)}% (${stats.duplicatedLines} lines over ${stats.files} files)`
4831
6017
  );
4832
6018
  }
4833
- lines.push(" a language with no clone is not listed");
6019
+ lines.push(" a language is absent when no file of it was long enough to scan");
4834
6020
  }
4835
- const caveats = report.rows.map((row) => ({ row, help: helpFor(row.metric) })).filter(
4836
- (entry) => Boolean(entry.help)
4837
- );
6021
+ if (report.findings.length > 0) {
6022
+ lines.push(
6023
+ "",
6024
+ ` ${report.findings.length} place(s) to look, listed by \`${SCRIPTS.json}\` and \`${SCRIPTS.html}\``
6025
+ );
6026
+ }
6027
+ const caveats = caveatsOf(report);
4838
6028
  if (caveats.length > 0) {
4839
6029
  lines.push("", " what these numbers do not say");
4840
- for (const { row, help } of caveats) {
6030
+ for (const help of caveats) {
4841
6031
  lines.push(` ${help.label}: ${help.caveat}`, ` source: ${help.source}`);
4842
- void row;
6032
+ if (help.fix !== void 0) lines.push(` what to do: ${help.fix}`);
6033
+ for (const link of help.links ?? []) lines.push(` ${link.label}: ${link.url}`);
6034
+ }
6035
+ }
6036
+ return `${lines.join("\n")}
6037
+ `;
6038
+ }
6039
+ var JSON_SCHEMA_VERSION = 1;
6040
+ function renderJson(report) {
6041
+ const rows = report.rows.map((row) => {
6042
+ const help = helpFor(row.metric);
6043
+ return { ...row, help: help ? { label: help.label, caveat: help.caveat } : null };
6044
+ });
6045
+ const findings = (report.findings ?? []).map(({ excerpt: _excerpt, ...rest }) => rest);
6046
+ const envelope = {
6047
+ schemaVersion: JSON_SCHEMA_VERSION,
6048
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
6049
+ commit: report.commit ?? null,
6050
+ ...report,
6051
+ rows,
6052
+ findings
6053
+ };
6054
+ return `${JSON.stringify(envelope, null, 2)}
6055
+ `;
6056
+ }
6057
+ function renderMarkdown(report) {
6058
+ const version = report.settings.find((setting) => setting.key === "sentinel")?.value;
6059
+ const places = /* @__PURE__ */ new Map();
6060
+ for (const finding of report.findings) {
6061
+ places.set(finding.metric, (places.get(finding.metric) ?? 0) + 1);
6062
+ }
6063
+ const figureLine = (row) => {
6064
+ const help = helpFor(row.metric);
6065
+ const label = help?.label ?? row.metric;
6066
+ const VERDICT = { ok: "ok", near: "near", exceed: "exceeds" };
6067
+ const mark = row.status === void 0 ? "" : ` **${VERDICT[row.status] ?? row.status}**`;
6068
+ const unit = row.unit === "" ? "" : `${row.unit.startsWith("%") ? "" : " "}${row.unit}`;
6069
+ const count = places.get(row.metric) ?? 0;
6070
+ const found = count === 0 ? "" : ` \xB7 ${count} ${count === 1 ? "place" : "places"}`;
6071
+ const line = `**${label}** \xB7 ${figure(row)}${mark}${unit}${found}`;
6072
+ return help?.fix === void 0 ? [line, ""] : [line, `> **What to do.** ${help.fix}`, ""];
6073
+ };
6074
+ const byLabel = byFamily(report.rows);
6075
+ const families = [
6076
+ ...byLabel.filter((family) => family.label === "Bundle"),
6077
+ ...byLabel.filter((family) => family.label !== "Bundle")
6078
+ ].flatMap((family) => [`##### ${family.label}`, "", ...family.rows.flatMap(figureLine)]);
6079
+ const notes = report.notes.length === 0 ? [] : ["", ...report.notes.map((note) => `- ${note.split(". ")[0] ?? note}`)];
6080
+ return [
6081
+ `#### \`${report.module}\``,
6082
+ "",
6083
+ summaryText(report),
6084
+ ...notes,
6085
+ "",
6086
+ ...families,
6087
+ "> Measured on this branch and **not compared with `main`**. No figure here refuses anything.",
6088
+ "> **ok**, **near** or **exceeds** is where a figure sits against a PUBLISHED reference; a",
6089
+ "> figure with no verdict has none, which is a decision and not a gap. A file breaching several",
6090
+ "> rules counts once per rule. The code behind every place, and the drawing of the import",
6091
+ "> graph, are in the `static-analysis.html` the artifact carries.",
6092
+ // ⭐ The version that produced these numbers, because the thresholds a module cannot override
6093
+ // live in the tool: nine gauge limits, the complexity bands, the bundle budgets. Two recaps
6094
+ // from two versions are not comparable, and without this nothing on the comment would say so.
6095
+ ...version === void 0 ? [] : [`> Measured by sentinel ${version}.`],
6096
+ ""
6097
+ ].join("\n");
6098
+ }
6099
+ function prose(text) {
6100
+ return escape(text).replace(/`([^`]+)`/g, "<code>$1</code>");
6101
+ }
6102
+ function escape(text) {
6103
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
6104
+ }
6105
+ function ruleChip(metric) {
6106
+ const threshold = GAUGE_THRESHOLDS[metric];
6107
+ return threshold === void 0 ? "" : ` <span class="chip"><code>${escape(metric)}</code> &gt; ${threshold}</span>`;
6108
+ }
6109
+ function measureOf(finding) {
6110
+ const metric = finding.metric;
6111
+ const number = (value, unit = "") => `<span class="pmeas">${grouped(value)}${unit === "" ? "" : `<span class="unit">${escape(unit)}</span>`}</span>`;
6112
+ if (finding.value !== void 0) {
6113
+ if (GAUGE_THRESHOLDS[metric] !== void 0) return number(finding.value);
6114
+ if (metric.startsWith("complexity")) return number(finding.value, "complexity");
6115
+ if (finding.dependsOn !== void 0) return number(finding.value, "imported by");
6116
+ if (metric === "cycles") return number(finding.value, "files in the cycle");
6117
+ return number(finding.value);
6118
+ }
6119
+ return finding.lines === void 0 ? "" : number(finding.lines, "lines");
6120
+ }
6121
+ function metaOf(finding) {
6122
+ const pairs = [];
6123
+ const add2 = (label, value) => {
6124
+ pairs.push(`<span class="pair"><span class="k">${escape(label)}</span>${value}</span>`);
6125
+ };
6126
+ if (finding.name) {
6127
+ add2(
6128
+ finding.metric.startsWith("complexity") ? "function" : "name",
6129
+ `<code>${escape(finding.name)}</code>`
6130
+ );
6131
+ }
6132
+ if (finding.dependsOn !== void 0) add2("depends on", String(finding.dependsOn));
6133
+ if (finding.instability !== void 0) add2("instability", finding.instability.toFixed(2));
6134
+ if (finding.centrality !== void 0) add2("centrality", finding.centrality.toFixed(4));
6135
+ if (finding.lines !== void 0 && finding.value !== void 0)
6136
+ add2("length", `${finding.lines} lines`);
6137
+ if (finding.confidence !== void 0) add2("confidence", `${finding.confidence}%`);
6138
+ if (finding.reason) add2("unsure", `<span class="reason">${escape(finding.reason)}</span>`);
6139
+ return pairs.length === 0 ? "" : ` <div class="pmeta">${pairs.join("")}</div>
6140
+ `;
6141
+ }
6142
+ function codeSummary(finding) {
6143
+ const count = finding.excerpt === void 0 ? 0 : finding.excerpt.split("\n").length;
6144
+ if (finding.metric === "clones" || finding.metric === "clonesTests") {
6145
+ return `${finding.lines ?? count} duplicated lines`;
6146
+ }
6147
+ if (finding.excerptFirstLine === 1) return `the first ${count} lines`;
6148
+ return `${count} lines from ${finding.excerptFirstLine ?? "?"}`;
6149
+ }
6150
+ function codeBlock(finding) {
6151
+ if (finding.excerpt === void 0) return "";
6152
+ const first = finding.excerptFirstLine;
6153
+ const body = first === void 0 ? escape(finding.excerpt) : finding.excerpt.split("\n").map((line, offset) => `<span class="n">${first + offset}</span>${escape(line)}`).join("\n");
6154
+ return ` <details class="pcode">
6155
+ <summary>${escape(codeSummary(finding))}</summary>
6156
+ <pre class="code">${body}</pre>
6157
+ </details>
6158
+ `;
6159
+ }
6160
+ function placeCard(finding) {
6161
+ return ` <div class="pcard">
6162
+ <div class="phead">
6163
+ <code class="path">${escape(finding.path)}</code>${secondSide(finding)}
6164
+ <span class="pright">${measureOf(finding)}${ruleChip(finding.metric)}</span>
6165
+ </div>
6166
+ ${metaOf(finding)}${codeBlock(finding)} </div>`;
6167
+ }
6168
+ function placesFor(group) {
6169
+ const total = group.listed.length + group.rest;
6170
+ const capped = group.rest > 0 ? ` <span class="capped">${HTML_GROUP_LIMIT} largest listed</span>` : "";
6171
+ return ` <details class="place">
6172
+ <summary>where to look <span class="count">${grouped(total)} ${total === 1 ? "place" : "places"}</span>${capped}</summary>
6173
+ <div class="cards">
6174
+ ${group.listed.map((finding) => placeCard(finding)).join("\n")}
6175
+ </div>
6176
+ ${group.rest > 0 ? ` <p class="sub">The other ${group.rest} are not on this page. <code>${SCRIPTS.json}</code> writes all of them, from the same run as this one.</p>` : ""}
6177
+ </details>`;
6178
+ }
6179
+ function tabRules(count) {
6180
+ return Array.from(
6181
+ { length: count },
6182
+ (_, index) => ` #tab-${index}:checked ~ nav.tabs label[for="tab-${index}"] { color: var(--ink); background: var(--bg); border-color: var(--rule); font-weight: 600; }
6183
+ #tab-${index}:checked ~ section.panel[data-tab="${index}"] { display: block; }`
6184
+ ).join("\n");
6185
+ }
6186
+ function kindOf2(metric) {
6187
+ return KIND_LABELS[metric] ?? helpFor(metric)?.label ?? metric;
6188
+ }
6189
+ var KIND_LABELS = {
6190
+ complexityUntestable: "Complexity 51+",
6191
+ complexityHigh: "Complexity 21-50",
6192
+ complexityModerate: "Complexity 11-20",
6193
+ complexityMax: "Worst function",
6194
+ cycles: "Import cycle",
6195
+ unusedFiles: "Unreachable file",
6196
+ unusedExports: "Unused export",
6197
+ unusedSymbols: "Unused symbol",
6198
+ usedOnlyByTests: "Used only by tests",
6199
+ clones: "Clone",
6200
+ clonesTests: "Clone in tests",
6201
+ mostImported: "Most imported",
6202
+ complexity: "Complex function",
6203
+ "cognitive-complexity": "Hard to follow",
6204
+ "max-lines": "Long file",
6205
+ "max-lines-per-function": "Long function",
6206
+ "max-statements": "Long body",
6207
+ "max-params": "Wide signature",
6208
+ "max-nested-callbacks": "Nested callbacks",
6209
+ "max-depth": "Deep nesting",
6210
+ "max-classes-per-file": "Several classes"
6211
+ };
6212
+ var KIND_UNITS = {
6213
+ complexityUntestable: "McCabe",
6214
+ complexityHigh: "McCabe",
6215
+ complexityModerate: "McCabe",
6216
+ complexityMax: "McCabe",
6217
+ complexity: "McCabe",
6218
+ "cognitive-complexity": "cognitive",
6219
+ "max-lines": "lines",
6220
+ "max-lines-per-function": "lines",
6221
+ "max-statements": "statements",
6222
+ "max-params": "params",
6223
+ "max-nested-callbacks": "deep",
6224
+ "max-depth": "deep",
6225
+ "max-classes-per-file": "classes",
6226
+ clones: "lines",
6227
+ clonesTests: "lines",
6228
+ cycles: "files",
6229
+ mostImported: "imported by"
6230
+ };
6231
+ function findingRow(finding) {
6232
+ const path2 = finding.path;
6233
+ const cut = path2.lastIndexOf("/");
6234
+ const dir = cut === -1 ? "" : path2.slice(0, cut + 1);
6235
+ const file = cut === -1 ? path2 : path2.slice(cut + 1);
6236
+ const measure = finding.value ?? finding.lines;
6237
+ const unit = KIND_UNITS[finding.metric];
6238
+ const haystack = [path2, finding.name, finding.other].filter(Boolean).join(" ").toLowerCase();
6239
+ return ` <tr data-kind="${escape(kindOf2(finding.metric))}" data-find="${escape(haystack)}">
6240
+ <td><span class="kind">${escape(kindOf2(finding.metric))}</span></td>
6241
+ <td class="where"><span class="dir">${escape(dir)}</span><b>${escape(file)}</b>${finding.name === void 0 ? "" : `<span class="sym">${escape(finding.name)}</span>`}${finding.other === void 0 ? "" : `<span class="sym">&#8596; ${escape(finding.other)}</span>`}</td>
6242
+ <td class="meas">${measure === void 0 ? "" : `<b>${escape(typeof measure === "number" ? grouped(measure) : String(measure))}</b>${unit === void 0 ? "" : `<span>${escape(unit)}</span>`}`}</td>
6243
+ </tr>`;
6244
+ }
6245
+ function findingsPanel(report) {
6246
+ const findings = report.findings ?? [];
6247
+ if (findings.length === 0) {
6248
+ return ` <p class="read">This run produced no finding to list.</p>`;
6249
+ }
6250
+ const counts = /* @__PURE__ */ new Map();
6251
+ for (const finding of findings) counts.set(finding.metric, (counts.get(finding.metric) ?? 0) + 1);
6252
+ const chips = [...counts.entries()].map(
6253
+ ([metric, count]) => `<button type="button" class="fchip" data-kind="${escape(kindOf2(metric))}">${escape(kindOf2(metric))} <span>${grouped(count)}</span></button>`
6254
+ ).join("\n ");
6255
+ return ` <div class="ftools">
6256
+ <input type="search" id="ffilter" placeholder="Filter by path, function or symbol&hellip;"
6257
+ aria-label="Filter the findings" autocomplete="off">
6258
+ <kbd aria-hidden="true">/</kbd>
6259
+ </div>
6260
+ <div class="fchips">
6261
+ <button type="button" class="fchip on" data-kind="">All <span>${grouped(findings.length)}</span></button>
6262
+ ${chips}
6263
+ </div>
6264
+ <p class="fcount" id="fcount" hidden>${grouped(findings.length)} of ${grouped(findings.length)} shown</p>
6265
+ <p class="panel-note"><b>This list is complete.</b> Every one of the ${grouped(findings.length)} findings
6266
+ this run produced is in the page, worst first within each kind, while a figure&rsquo;s own card on
6267
+ Overview shows only its largest. The code of a clone or a long function stays on that card, where
6268
+ the context is; this list is here to find a file.</p>
6269
+ <table class="findings" id="ftable">
6270
+ <thead>
6271
+ <tr><th scope="col">Kind</th><th scope="col">Where</th><th scope="col" class="meas">Measure</th></tr>
6272
+ </thead>
6273
+ <tbody>
6274
+ ${findings.map((finding) => findingRow(finding)).join("\n")}
6275
+ </tbody>
6276
+ </table>`;
6277
+ }
6278
+ function grouped(count) {
6279
+ if (!Number.isInteger(count)) return String(count);
6280
+ return String(count).replace(/\B(?=(\d{3})+(?!\d))/g, "\u202F");
6281
+ }
6282
+ function summaryParts(report, bold) {
6283
+ const withReference = report.rows.filter((row) => row.status !== void 0);
6284
+ const above = withReference.filter((row) => row.status === "exceed").length;
6285
+ const findings = report.findings ?? [];
6286
+ const inTests = findings.filter((finding) => finding.metric === "clonesTests").length;
6287
+ const inProduct = findings.length - inTests;
6288
+ return [
6289
+ withReference.length === 0 ? "No figure here has a published reference" : `${bold(`${above} of ${withReference.length}`)} figures with a published reference are above it`,
6290
+ `${bold(grouped(inProduct))} places in product code`,
6291
+ ...inTests === 0 ? [] : [`${bold(grouped(inTests))} clones in tests`]
6292
+ ];
6293
+ }
6294
+ function summaryLine(report) {
6295
+ return summaryParts(report, (text) => `<b>${text}</b>`).join(" &middot; ");
6296
+ }
6297
+ function summaryText(report) {
6298
+ return summaryParts(report, (text) => `**${text}**`).join(" \xB7 ");
6299
+ }
6300
+ function populationsLine(report) {
6301
+ const of = (metric) => {
6302
+ const unit = report.rows.find((row) => row.metric === metric)?.unit;
6303
+ const found = /(\d[\d\u202f ]*) files/.exec(unit ?? "")?.[1];
6304
+ return found === void 0 ? void 0 : grouped(Number(found.replace(/[\u202f ]/g, "")));
6305
+ };
6306
+ const gauge = report.rows.find((row) => row.metric === "measuredFiles")?.value;
6307
+ const graph = of("importEdges") ?? of("communities");
6308
+ const walk3 = of("entryPoints") ?? of("deadLinesPercent");
6309
+ const parts = [
6310
+ gauge === void 0 ? void 0 : `the size gauge ${grouped(Number(gauge))} files`,
6311
+ graph === void 0 ? void 0 : `the import graph ${graph}`,
6312
+ walk3 === void 0 ? void 0 : `the dead-code walk ${walk3}, which follows the specs too`
6313
+ ].filter(Boolean);
6314
+ return parts.length < 2 ? "" : ` <p class="pops">Three passes read three populations: ${parts.join(", ")}.</p>`;
6315
+ }
6316
+ var FINDINGS_SCRIPT = `
6317
+ (function () {
6318
+ var table = document.getElementById('ftable')
6319
+ var box = document.getElementById('ffilter')
6320
+ var count = document.getElementById('fcount')
6321
+ if (!table || !box || !count) return
6322
+ var rows = [].slice.call(table.tBodies[0].rows)
6323
+ var chips = [].slice.call(document.querySelectorAll('.fchip'))
6324
+ var kind = ''
6325
+ function apply() {
6326
+ var text = box.value.trim().toLowerCase()
6327
+ var shown = 0
6328
+ for (var i = 0; i < rows.length; i++) {
6329
+ var row = rows[i]
6330
+ var ok =
6331
+ (kind === '' || row.getAttribute('data-kind') === kind) &&
6332
+ (text === '' || row.getAttribute('data-find').indexOf(text) !== -1)
6333
+ row.classList.toggle('hidden', !ok)
6334
+ if (ok) shown++
6335
+ }
6336
+ /*
6337
+ * The same grouping the server applies, so the counter does not change shape as soon as
6338
+ * someone types in the box.
6339
+ *
6340
+ * \u26D4 Every backslash here is DOUBLED because this script is written inside a template
6341
+ * literal, which eats one layer: the single-backslash spelling compiled to /B(?=(d{3})...)/
6342
+ * and looked for the letter B. It never threw, it simply never grouped, and the page said
6343
+ * 1 of 244 shown either way. Our own lint caught it as no-useless-escape.
6344
+ */
6345
+ var group = function (n) { return String(n).replace(/\\B(?=(\\d{3})+(?!\\d))/g, '\\u202f') }
6346
+ count.textContent = group(shown) + ' of ' + group(rows.length) + ' shown'
6347
+ // \u26A0\uFE0F Silent at rest: a counter reading 244 of 244 beside an empty box is noise, and the
6348
+ // reader needs it only once something is hidden.
6349
+ count.hidden = shown === rows.length
6350
+ }
6351
+ box.addEventListener('input', apply)
6352
+ chips.forEach(function (chip) {
6353
+ chip.addEventListener('click', function () {
6354
+ kind = chip.getAttribute('data-kind')
6355
+ chips.forEach(function (other) { other.classList.toggle('on', other === chip) })
6356
+ apply()
6357
+ })
6358
+ })
6359
+ // The shortcut the placeholder advertises. Ignored while typing somewhere else.
6360
+ document.addEventListener('keydown', function (event) {
6361
+ if (event.key === '/' && event.target === document.body) {
6362
+ event.preventDefault()
6363
+ box.focus()
6364
+ }
6365
+ })
6366
+ })()
6367
+ `;
6368
+ var STYLES = `
6369
+ /*
6370
+ * The Hublo palette, token by token, read from "libs/front/theme/src/branding/definitions/hublo"
6371
+ * rather than approximated: "commonColors.ts" holds the values and "colorTokens.ts" the semantic
6372
+ * names used in the comments below.
6373
+ *
6374
+ * \u26A0\uFE0F This block is the LIGHT palette only. It used to say the page had one theme on purpose,
6375
+ * because the component package has no dark variant and inventing one would have been charter
6376
+ * this page made up; the dark block below borrows the status page's values instead, so that
6377
+ * sentence became false and is replaced rather than left to be quoted.
6378
+ */
6379
+ :root {
6380
+ color-scheme: light dark;
6381
+ --page: #faf7f1; /* --bg */
6382
+ --surface: #ffffff; /* --panel */
6383
+ --ink: #23091c; /* --ink */
6384
+ --ink-soft: #4a3344; /* between --ink and --muted, for body prose */
6385
+ --label: #6f5a67; /* --muted */
6386
+ --rule: #e1dcd0; /* --line */
6387
+ --brand: #491b3c; /* --accent */
6388
+ --brand-bg: #f3e9ee; /* a tint of --accent, for the one filled surface */
6389
+ --moved: #a3341f; /* --warn */
6390
+ --ok: #2f6b4f; /* --done */
6391
+ --near: #99513a; /* --todo */
6392
+ --over: #a3341f; /* --warn */
6393
+ /*
6394
+ * \u26A0\uFE0F A third surface, under the page and the card. Measured off the validated screen rather
6395
+ * than picked: the note reads as a statement ABOUT the table under it, and on the card colour
6396
+ * it became a first row of that table. No dark value is invented for it below: in the dark
6397
+ * palette it falls back to the card surface, which is a value this page borrowed and did not
6398
+ * make up.
6399
+ */
6400
+ --note: #fdf6ee;
6401
+ --radius: 10px;
6402
+ }
6403
+ /*
6404
+ * \u2B50 A dark palette, which this page refused to have until now for a reason that was true at the
6405
+ * time: the component theme package has no dark variant, so inventing one here would have been
6406
+ * charter this page made up. The status page HAS one, and these are its values, so the dark
6407
+ * theme is now borrowed rather than invented.
6408
+ */
6409
+ @media (prefers-color-scheme: dark) {
6410
+ :root {
6411
+ --page: #23091c;
6412
+ --surface: #2f1128;
6413
+ --ink: #faf7f1;
6414
+ --ink-soft: #d8c6d1;
6415
+ --label: #b298ab;
6416
+ --rule: #45253d;
6417
+ --brand: #ffab93;
6418
+ --brand-bg: #3a1830;
6419
+ --moved: #ef8f78;
6420
+ --ok: #7ed3a5;
6421
+ --near: #ffab93;
6422
+ --over: #ef8f78;
6423
+ --note: #2f1128; /* = --surface: borrowed, not invented */
6424
+ }
6425
+ }
6426
+ /*
6427
+ * The brand faces first, then Inter, then the system. They are loaded at runtime from a branding
6428
+ * artefact in the applications, so they cannot be embedded here without an external request, and
6429
+ * an external request is the one thing this page must not make. A viewer who has them sees them.
6430
+ */
6431
+ body {
6432
+ margin: 0;
6433
+ padding: 2.5rem 1rem 4rem;
6434
+ background: var(--page);
6435
+ color: var(--ink);
6436
+ font: 15px/1.6 'Brand Sans', Inter, ui-sans-serif, system-ui, sans-serif;
6437
+ }
6438
+ main { max-width: 64rem; margin: 0 auto; }
6439
+ header { border-left: 3px solid var(--brand); padding-left: 0.9rem; margin-bottom: 2rem; }
6440
+ h1 { font-size: 1.3rem; font-weight: 600; margin: 0 0 0.2rem; letter-spacing: -0.01em; }
6441
+ h2 {
6442
+ font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.08em;
6443
+ color: var(--label); margin: 2.5rem 0 0.6rem; font-weight: 600;
6444
+ }
6445
+ p.sub { color: var(--ink-soft); margin: 0; font-size: 0.9rem; }
6446
+ section {
6447
+ background: var(--surface); border: 1px solid var(--rule); border-radius: 10px;
6448
+ padding: 0.25rem 1.1rem; overflow-x: auto;
6449
+ }
6450
+ table { width: 100%; border-collapse: collapse; }
6451
+ th, td { text-align: left; padding: 0.7rem 0.6rem; border-bottom: 1px solid var(--rule); vertical-align: top; }
6452
+ tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
6453
+ thead th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--label); font-weight: 600; }
6454
+ tbody th { font-weight: 500; white-space: nowrap; }
6455
+ td.num { font-variant-numeric: tabular-nums; white-space: nowrap; font-weight: 500; }
6456
+ /*
6457
+ * The settings table does NOT force its value column wide. One of its values is the test glob,
6458
+ * long enough to push the table past the card, and the origin column then slid out of view and
6459
+ * was clipped: "overridden in this mo". Long values wrap instead, and the origin stays readable.
6460
+ */
6461
+ table.settings td.num { white-space: normal; overflow-wrap: anywhere; font-weight: 400; }
6462
+ /*
6463
+ * The settings table is a bordered GRID, as the validated screen draws it: an outer card, a
6464
+ * header row, and a rule between each of the three columns.
6465
+ *
6466
+ * \u26D4 Those vertical rules are not decoration. A setting is read as a triple, key then value then
6467
+ * where the value came from, and without them a long value ran visually into the origin beside
6468
+ * it: the glob row is one line of 110 characters and the words "the role's value" sat at its end
6469
+ * looking like part of it.
6470
+ */
6471
+ table.settings {
6472
+ border: 1px solid var(--rule); border-radius: var(--radius); background: var(--surface);
6473
+ border-collapse: separate; border-spacing: 0; overflow: hidden; margin-top: 1.1rem;
6474
+ }
6475
+ table.settings th, table.settings td { border-right: 1px solid var(--rule); }
6476
+ table.settings th:last-child, table.settings td:last-child { border-right: 0; }
6477
+ /*
6478
+ * \u26A0\uFE0F The uppercase is CSS, never the text. text-transform paints the key as the screen draws
6479
+ * it while the document still holds duplication.minTokens, so a reader who copies the cell or
6480
+ * searches the page gets the real key, with its camel case, and not a spelling no config file
6481
+ * would accept.
6482
+ */
6483
+ table.settings tbody th code {
6484
+ text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.72rem; color: var(--label);
6485
+ }
6486
+ table.settings tbody th { width: 13rem; vertical-align: middle; padding-block: 1rem; }
6487
+ /* Narrow on purpose, so the origin cannot take the width the glob value needs. It wraps to two
6488
+ lines when the column gets tighter, which is what the validated screen shows. */
6489
+ table.settings td:last-child {
6490
+ width: 7rem; color: var(--label); font-size: 0.82rem;
6491
+ }
6492
+ /* The header row is a label strip, not a row of data: it takes the smaller padding. */
6493
+ table.settings thead th { padding-block: 0.6rem; }
6494
+ .num { font-variant-numeric: tabular-nums; }
6495
+ td.read { color: var(--ink-soft); font-size: 0.88rem; }
6496
+ tbody th { color: var(--ink); }
6497
+ code { font: 0.86em ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ink-soft); }
6498
+ .unit { color: var(--label); font-size: 0.82em; font-weight: 400; }
6499
+ .reason {
6500
+ font: 0.74rem ui-monospace, SFMono-Regular, Menlo, monospace;
6501
+ background: var(--brand-bg); color: var(--brand);
6502
+ padding: 0.1rem 0.4rem; border-radius: 4px; white-space: nowrap;
6503
+ }
6504
+ .moved {
6505
+ color: var(--moved); font-size: 0.78rem; font-weight: 500;
6506
+ white-space: nowrap;
6507
+ }
6508
+ /* The graph. The svg carries a viewBox, so dropping its fixed width lets it fill the column. */
6509
+ .g svg { width: 100%; height: auto; display: block; }
6510
+ /* The intro sat flush against the tab strip, which reads as a caption glued to the border
6511
+ rather than as the paragraph that explains the drawing. It needs room above AND below. */
6512
+ .g p.read { margin: 0 0 1.6rem; padding: 1.4rem 0.9rem 0; color: var(--ink-soft); font-size: 0.88rem; max-width: 52rem; }
6513
+ section.panel > section.g { margin-top: 0.8rem; }
6514
+ /* Every edge in one path, faint: it carries the shape, not a readable dependency. */
6515
+ .g .ge { stroke: var(--rule); stroke-width: 0.6; fill: none; }
6516
+ /* The selected file's own edges, drawn on top only once something is picked. */
6517
+ .g .gh { stroke: var(--brand); stroke-width: 1.4; fill: none; }
6518
+ .g .gn circle { cursor: pointer; }
6519
+ /* Dim the module once a file is picked, so its neighbourhood is what is left to read. The
6520
+ opacity sits on the GROUP and the exceptions raise themselves back: one rule instead of one
6521
+ class per dot, which matters at 4 380 of them. */
6522
+ .g.gsel .gn circle { opacity: 0.12; }
6523
+ .g.gsel .gn circle.gon { opacity: 1; }
6524
+ .g.gsel .gn circle.gpick { opacity: 1; stroke: var(--ink); stroke-width: 2; }
6525
+ .g.gsel .ge { opacity: 0.25; }
6526
+ /* The caption. A min-height and not a conditional row: the drawing must not jump by a line
6527
+ when a name appears, which is exactly what pointing at a dot would cause. */
6528
+ .gbar {
6529
+ display: flex; gap: 0.6rem; align-items: center; justify-content: space-between;
6530
+ min-height: 1.9rem; padding: 0 0.6rem; margin-bottom: 0.3rem;
6531
+ }
6532
+ .gbar output {
6533
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.82rem;
6534
+ color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
6535
+ }
6536
+ .gbar button {
6537
+ font: inherit; font-size: 0.8rem; cursor: pointer; white-space: nowrap;
6538
+ padding: 0.2rem 0.6rem; border: 1px solid var(--rule); border-radius: 0.3rem;
6539
+ background: var(--surface); color: var(--ink-soft);
6540
+ }
6541
+
6542
+ /* The collapsed reserves. Closed by default: the figures are what the page opens on. */
6543
+ details { border-bottom: 1px solid var(--rule); }
6544
+ details:last-of-type { border-bottom: 0; }
6545
+ summary {
6546
+ cursor: pointer; padding: 0.7rem 0.6rem; font-size: 0.9rem; font-weight: 500;
6547
+ display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline;
6548
+ }
6549
+ /*
6550
+ * The chevron is drawn, not inherited: "display: flex" on a summary removes the native marker,
6551
+ * and a collapsed block with no affordance is a block nobody opens.
6552
+ */
6553
+ summary { list-style: none; }
6554
+ summary::-webkit-details-marker { display: none; }
6555
+ summary::before {
6556
+ content: ''; flex: none; width: 0.38rem; height: 0.38rem; margin: 0.3rem 0.1rem 0 0;
6557
+ border-right: 1.5px solid var(--label); border-bottom: 1.5px solid var(--label);
6558
+ transform: rotate(-45deg); transition: transform 0.15s ease;
6559
+ }
6560
+ details[open] summary::before { transform: rotate(45deg); border-color: var(--brand); }
6561
+ @media (prefers-reduced-motion: reduce) { summary::before { transition: none; } }
6562
+ summary:hover { color: var(--brand); }
6563
+ /* One line, truncated: the summary is a hint, the body is the text. Opening it shows all. */
6564
+ summary .what {
6565
+ color: var(--label); font-size: 0.82rem; font-weight: 400;
6566
+ flex: 1 1 18rem; min-width: 0;
6567
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
6568
+ }
6569
+ details[open] summary .what { white-space: normal; overflow: visible; }
6570
+ details[open] summary { color: var(--brand); }
6571
+ details p.read { color: var(--ink-soft); }
6572
+ details p b { color: var(--ink); font-weight: 600; }
6573
+ details p { margin: 0 0 0.7rem; padding: 0 0.6rem; color: var(--ink-soft); font-size: 0.88rem; max-width: 52rem; }
6574
+ details p.source { color: var(--label); font-size: 0.8rem; }
6575
+ /* The lever reads as the actionable line, so it is the one that carries weight on the page. */
6576
+ details p.fix { border-left: 2px solid var(--accent, currentColor); padding-left: 0.7rem; }
6577
+ details p.links { font-size: 0.85rem; }
6578
+ details p.links a { color: inherit; }
6579
+ /* ---- One place, as a card ----
6580
+ *
6581
+ * \u26D4 Cards and not table rows, and the reason is three defects a reader listed in one breath:
6582
+ * "les separateurs dans la liste parfois on a deux separateurs, le dernier separateur dans la
6583
+ * list a supp car il n'est pas bien avec la bordure de la liste, la meme remarque le separateur
6584
+ * top a supp". All three are the same mistake, a divider meeting another edge, and none of them
6585
+ * can happen between bordered boxes with a gap. There is not one divider left on this page's
6586
+ * place lists.
6587
+ *
6588
+ * Three grounds alternate down the nesting so each level reads as inset rather than flat:
6589
+ * --page for the panel, --surface for the figure card, --page again for a place, and
6590
+ * --surface for the code. Both tokens exist in both themes, so nothing is invented for dark.
6591
+ */
6592
+ .cards { display: flex; flex-direction: column; gap: 0.55rem; margin-top: 0.85rem; }
6593
+ .pcard {
6594
+ background: var(--page); border: 1px solid var(--rule); border-radius: 8px;
6595
+ padding: 0.6rem 0.75rem;
6596
+ }
6597
+ .phead {
6598
+ display: flex; flex-wrap: wrap; gap: 0.3rem 0.8rem;
6599
+ align-items: baseline; justify-content: space-between;
6600
+ }
6601
+ /* The path is the point of the card, so it gets the ink and breaks anywhere rather than
6602
+ pushing the measure off the card: a 90-character path under src/modules/.../view-models
6603
+ is the ordinary case here, not the exception. */
6604
+ .path { color: var(--ink); overflow-wrap: anywhere; }
6605
+ .pright { display: flex; align-items: baseline; gap: 0.45rem; white-space: nowrap; }
6606
+ .pmeas { font-variant-numeric: tabular-nums; font-weight: 600; }
6607
+ .pmeas .unit { margin-left: 0.3rem; font-weight: 400; }
6608
+ /* Which gauge fired and at what value. Both halves come from GAUGE_THRESHOLDS. */
6609
+ .chip {
6610
+ font-size: 0.72rem; color: var(--label); background: var(--surface);
6611
+ border: 1px solid var(--rule); border-radius: 999px; padding: 0.08rem 0.5rem;
6612
+ }
6613
+ .chip code { color: inherit; font-size: 0.95em; }
6614
+ .pmeta {
6615
+ display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem;
6616
+ margin-top: 0.35rem; font-size: 0.8rem; color: var(--ink-soft);
6617
+ }
6618
+ .pair .k { color: var(--label); margin-right: 0.3rem; }
6619
+ details.pcode { margin-top: 0.45rem; }
6620
+ details.pcode > summary { cursor: pointer; font-size: 0.78rem; color: var(--label); font-weight: 400; }
6621
+ details.pcode > summary:hover { color: var(--brand); }
6622
+ pre.code {
6623
+ margin: 0.45rem 0 0; padding: 0.65rem 0.85rem 0.65rem 0; background: var(--surface);
6624
+ border: 1px solid var(--rule); border-radius: 6px; overflow-x: auto;
6625
+ font: 0.8rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
6626
+ color: var(--ink); white-space: pre;
6627
+ }
6628
+ /* The line number. An inline-block with a fixed width aligns the gutter without a second
6629
+ element, and the number stays in the text flow, so the block is still readable as numbered
6630
+ lines if the stylesheet never arrives. */
6631
+ pre.code .n {
6632
+ display: inline-block; width: 4em; padding-right: 0.9rem; text-align: right;
6633
+ color: var(--label); user-select: none;
6634
+ }
6635
+ /* ---- Tabs, with no script. The radios are hidden but still focusable by keyboard. ---- */
6636
+ input.tab { position: absolute; opacity: 0; pointer-events: none; }
6637
+ nav.tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 1.6rem 0 0; border-bottom: 1px solid var(--rule); }
6638
+ nav.tabs label {
6639
+ padding: 0.5rem 0.9rem; cursor: pointer; font-size: 0.86rem; color: var(--label);
6640
+ border: 1px solid transparent; border-bottom: none; border-radius: 6px 6px 0 0; margin-bottom: -1px;
6641
+ }
6642
+ nav.tabs label:hover { color: var(--ink); }
6643
+ nav.tabs .tabn { font-size: 0.76rem; color: var(--label); }
6644
+ /* \u26D4 The panel gives up its own ground and border so the figures can be the cards. Two bordered
6645
+ surfaces of the same colour nested in each other read as one flat block, which is what the
6646
+ page looked like, and the reference a reader named is Sonar: white cards on a plain ground. */
6647
+ section.panel { display: none; padding: 1.4rem 0 0; background: none; border: 0; border-radius: 0; }
6648
+ /* One rule per tab index, from tabRules: the selector has to name the id to stay script-free. */
6649
+ __TAB_RULES__
6650
+ /* Focus has to be visible although the control is: the label is what a keyboard user lands on. */
6651
+ input.tab:focus-visible ~ nav.tabs label { outline: 2px solid var(--accent, currentColor); outline-offset: 2px; }
6652
+ /* \u26A0\uFE0F No style at all means every panel shows, which is the readable fallback rather than a blank page. */
6653
+
6654
+ /* ---- One figure, its status and its help: a card ---- */
6655
+ .figure {
6656
+ background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius);
6657
+ padding: 0.85rem 1.05rem; margin-bottom: 0.7rem;
6658
+ }
6659
+ .figure .head { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; align-items: baseline; justify-content: space-between; }
6660
+ .figure .name { font-weight: 500; }
6661
+ .figure .val { font-variant-numeric: tabular-nums; }
6662
+ .figure details { margin-top: 0.5rem; }
6663
+ .figure summary { cursor: pointer; font-size: 0.8rem; color: var(--label); }
6664
+ .figure details p { max-width: 52rem; font-size: 0.86rem; }
6665
+ .figure p.noref { color: var(--label); font-style: italic; }
6666
+ /* ---- The header: what this report is, before any figure ---- */
6667
+ .brand { display: flex; align-items: baseline; gap: 0.5rem; font-size: 0.8rem; margin-bottom: 1.1rem; }
6668
+ .brand b { font-weight: 700; }
6669
+ .brand span { color: var(--label); }
6670
+ .brand .stamp { margin-left: auto; text-align: right; }
6671
+ header h1 { font-size: 1.6rem; margin: 0 0 0.35rem; letter-spacing: -0.01em; }
6672
+ .lede { font-size: 0.95rem; color: var(--ink-soft); margin: 0; }
6673
+ .lede b { color: var(--ink); font-weight: 600; }
6674
+ .pops { font-size: 0.8rem; color: var(--label); margin: 0.45rem 0 0; }
6675
+
6676
+ /* ---- A family, as a panel of the overview ---- */
6677
+ .fam { margin-top: 1.9rem; }
6678
+ .fam > h2 { font-size: 0.72rem; letter-spacing: 0.09em; text-transform: uppercase; color: var(--label); font-weight: 600; margin: 0 0 0.7rem; }
6679
+
6680
+ /* \u26D4 The one line a reader acts on, outside the fold. It used to be the fifth of six items
6681
+ inside a disclosure labelled "what this means", so 37 sentences were written and none read. */
6682
+ .figure p.fix { background: var(--brand-bg, #f3e9ee); border-radius: 0.5rem; padding: 0.55rem 0.8rem; margin: 0.6rem 0 0; font-size: 0.84rem; line-height: 1.5; }
6683
+ .figure p.fix b { color: var(--accent, #491b3c); }
6684
+
6685
+ /* \u26D4 A view's opening note sits in a box rather than running as prose into the first row: it is
6686
+ a statement ABOUT the table under it, and prose at the same weight reads as the table's first
6687
+ line. Both the findings and the settings views open with one. */
6688
+ .panel-note { background: var(--note); border: 1px solid var(--rule); border-radius: var(--radius); padding: 0.75rem 1rem; margin: 1.1rem 0 0; font-size: 0.86rem; line-height: 1.55; color: var(--ink-soft); }
6689
+
6690
+ /* ---- The findings filter: the one place the page has a script ---- */
6691
+ .ftools { position: relative; margin: 1.6rem 0 0; }
6692
+ .ftools input { width: 100%; font: inherit; font-size: 0.88rem; padding: 0.6rem 2.4rem 0.6rem 0.9rem; border: 1px solid var(--rule); border-radius: 0.5rem; background: var(--surface); color: var(--ink); }
6693
+ .ftools input:focus-visible { outline: 2px solid var(--accent, currentColor); outline-offset: 1px; }
6694
+ .ftools kbd { position: absolute; right: 0.85rem; top: 50%; transform: translateY(-50%);
6695
+ font-family: var(--mono, ui-monospace, monospace); font-size: 0.78rem; color: var(--label); }
6696
+ button.fchip { font: inherit; cursor: pointer; background: var(--surface); }
6697
+ button.fchip.on { background: var(--accent, #491b3c); border-color: var(--accent, #491b3c); color: #fff; }
6698
+ button.fchip.on span { color: #fff; }
6699
+ .fcount { font-size: 0.76rem; color: var(--label); margin: 0 0 0.9rem; }
6700
+ table.findings tr.hidden { display: none; }
6701
+
6702
+ /* ---- The findings list: a row per finding, never a card ---- */
6703
+ .fchips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.9rem 0 0.7rem; }
6704
+ .fchip { font-size: 0.72rem; padding: 0.18rem 0.6rem; border-radius: 0.8rem; border: 1px solid var(--rule); color: var(--ink-soft); }
6705
+ .fchip span { color: var(--label); }
6706
+ table.findings { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius); overflow: hidden; }
6707
+ table.findings th { text-align: left; font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--label); font-weight: 600; padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--rule); }
6708
+ table.findings td { padding: 0.5rem 0.9rem; border-bottom: 1px solid var(--rule); vertical-align: top; }
6709
+ table.findings tr:last-child td { border-bottom: 0; }
6710
+ table.findings .kind { display: inline-block; font-size: 0.68rem; padding: 0.12rem 0.5rem; border-radius: 0.7rem; background: var(--accent-bg, #f3e9ee); color: var(--accent, #491b3c); white-space: nowrap; }
6711
+ /* \u26A0\uFE0F The directory is dimmed and the file name is not: the eye scans the name, and a path where
6712
+ every character carries the same weight is a path nobody reads. */
6713
+ table.findings .where { font-family: var(--mono, ui-monospace, monospace); font-size: 0.76rem; line-height: 1.5; word-break: break-all; }
6714
+ table.findings .where .dir { color: var(--label); }
6715
+ table.findings .where .sym { display: block; color: var(--label); font-size: 0.72rem; }
6716
+ table.findings .meas { text-align: right; white-space: nowrap; font-family: var(--mono, ui-monospace, monospace); }
6717
+ table.findings .meas b { font-size: 0.9rem; font-weight: 500; }
6718
+ table.findings .meas span { display: block; font-size: 0.66rem; color: var(--label); }
6719
+ .capped { font-size: 0.72rem; color: var(--label); font-weight: 400; margin-left: 0.5rem; }
6720
+ .mark { display: inline-block; font-size: 0.68rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; padding: 0.1rem 0.42rem; border-radius: 0.7rem; vertical-align: 0.14rem; margin: 0 0.4rem; }
6721
+ .mark.s-ok { background: #e4efe9; color: var(--ok); }
6722
+ .mark.s-near { background: #f6ebe3; color: var(--near); }
6723
+ .mark.s-exceed { background: #f7e4e0; color: var(--over); }
6724
+ /* \u26D4 Colour is never the only carrier: each dot has a title attribute, and the band is in the help text. */
6725
+
6726
+ /* ---- What was not measured ---- */
6727
+ p.noref { font-size: 0.9rem; }
6728
+ .notes { margin-top: 1.4rem; }
6729
+ .notes h2 { font-size: 0.8rem; margin: 0 0 0.3rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--label); }
6730
+ .notes p { font-size: 0.86rem; color: var(--ink-soft); }
6731
+ p.census { color: var(--label); margin-top: 1.6rem; }
6732
+
6733
+ /* The places of one figure, inside its card: counted on the summary, closed. No filet, the
6734
+ card's own border is the only edge at this level. */
6735
+ details.place { margin-top: 0.6rem; }
6736
+ details.place > summary { cursor: pointer; font-weight: 500; font-size: 0.86rem; }
6737
+ details.place > summary .count { color: var(--label); font-weight: 400; font-size: 0.85rem; margin-left: 0.5rem; }
6738
+ footer { color: var(--label); font-size: 0.78rem; margin-top: 2.5rem; }
6739
+ @media (max-width: 40rem) {
6740
+ section { padding: 0.25rem 0.6rem; }
6741
+ summary .what { display: none; }
6742
+ }
6743
+ `;
6744
+ function renderHtml(report) {
6745
+ const placesByMetric = new Map(
6746
+ [...listedByMetric(report.findings).entries()].map(([metric, group]) => [
6747
+ metric,
6748
+ placesFor(group)
6749
+ ])
6750
+ );
6751
+ const settings = report.settings.map(
6752
+ (setting) => ` <tr>
6753
+ <th scope="row"><code>${escape(setting.key)}</code></th>
6754
+ <td class="num">${escape(setting.value)}</td>
6755
+ <td>${setting.moved ? '<span class="moved">overridden by this module</span>' : '<span class="u">the role&rsquo;s value</span>'}</td>
6756
+ </tr>`
6757
+ ).join("\n");
6758
+ const shown = new Set(report.rows.map((row) => row.metric));
6759
+ const orphansByFamily = /* @__PURE__ */ new Map();
6760
+ for (const [metric, places] of placesByMetric) {
6761
+ if (shown.has(metric)) continue;
6762
+ const orphanHelp = helpFor(metric);
6763
+ const orphanAction = orphanHelp?.fix === void 0 ? "" : `
6764
+ <p class="fix"><b>What to do.</b> ${prose(orphanHelp.fix)}</p>`;
6765
+ const block = ` <div class="figure">
6766
+ <div class="head"><span class="name">${escape(orphanHelp?.label ?? metric)}</span></div>${orphanAction}
6767
+ ${orphanHelp === void 0 ? "" : helpDetails(orphanHelp, true)}
6768
+ ${places}
6769
+ </div>`;
6770
+ const family = familyOf(metric) ?? "Other";
6771
+ orphansByFamily.set(family, [...orphansByFamily.get(family) ?? [], block]);
6772
+ }
6773
+ const orphansFor = (label) => (orphansByFamily.get(label) ?? []).join("\n");
6774
+ const families = byFamily(report.rows);
6775
+ const withOrphansOnly = FAMILIES.filter(
6776
+ (family) => orphansByFamily.has(family.label) && !families.some((f) => f.label === family.label)
6777
+ ).map((family) => ({ label: family.label, rows: [] }));
6778
+ const complete = [...families, ...withOrphansOnly];
6779
+ const ordered = [
6780
+ ...complete.filter((family) => family.label === "Bundle"),
6781
+ ...complete.filter((family) => family.label !== "Bundle")
6782
+ ];
6783
+ const absences = report.notes.length === 0 ? "" : ` <div class="notes"><h2>Not measured</h2>${report.notes.map((note) => `<p>${escape(note)}</p>`).join("")}</div>`;
6784
+ const overview = `${ordered.map(
6785
+ (family, index) => ` <section class="fam">
6786
+ <h2>${escape(family.label)}</h2>
6787
+ ${family.rows.map((row) => figureBlock(row, placesByMetric.get(row.metric) ?? "")).join("\n")}${orphansFor(family.label)}${index === 0 ? absences : ""}
6788
+ </section>`
6789
+ ).join(
6790
+ "\n"
6791
+ )}${report.picture === void 0 ? "" : `
6792
+ <section class="fam">
6793
+ <h2>Shape</h2>
6794
+ ${report.picture}
6795
+ </section>`}`;
6796
+ const panels = [
6797
+ { label: "Overview", body: overview },
6798
+ { label: "Findings", body: findingsPanel(report), count: report.findings?.length ?? 0 },
6799
+ {
6800
+ label: "Settings",
6801
+ count: report.settings.length,
6802
+ body: ` <p class="panel-note">The settings these figures were produced with. Two modules measured with
6803
+ different settings cannot be compared, which is why they travel with the numbers.</p>
6804
+ <table class="settings">
6805
+ <thead>
6806
+ <tr><th scope="col">Setting</th><th scope="col">Value</th><th scope="col">Origin</th></tr>
6807
+ </thead>
6808
+ <tbody>
6809
+ ${settings}
6810
+ </tbody>
6811
+ </table>`
6812
+ }
6813
+ ];
6814
+ const tabInputs = panels.map(
6815
+ (panel, index) => ` <input class="tab" type="radio" name="tab" id="tab-${index}"${index === 0 ? " checked" : ""}>`
6816
+ ).join("\n");
6817
+ const tabLabels = panels.map(
6818
+ (panel, index) => ` <label for="tab-${index}" id="tablabel-${index}" aria-controls="panel-${index}">${escape(panel.label)}${panel.count === void 0 ? "" : ` <span class="tabn">${grouped(panel.count)}</span>`}</label>`
6819
+ ).join("\n");
6820
+ const tabPanels = panels.map(
6821
+ (panel, index) => ` <section class="panel" data-tab="${index}" id="panel-${index}" aria-labelledby="tablabel-${index}">
6822
+ ${panel.body}
6823
+ </section>`
6824
+ ).join("\n");
6825
+ const page = `<!DOCTYPE html>
6826
+ <html lang="en">
6827
+ <head>
6828
+ <meta charset="utf-8">
6829
+ <title>Static analysis: ${escape(report.module)}</title>
6830
+ <style>${STYLES.replace("__TAB_RULES__", tabRules(panels.length))}</style>
6831
+ </head>
6832
+ <body>
6833
+ <main>
6834
+ <header>
6835
+ <div class="brand"><b>sentinel</b> <span>static analysis</span><span class="stamp">${report.commit == null ? "" : `commit <code>${escape(report.commit)}</code> &middot; `}${escape(report.passes.join(" / "))}</span></div>
6836
+ <h1>${escape(report.module)}</h1>
6837
+ <p class="lede">${summaryLine(report)}</p>
6838
+ ${populationsLine(report)}
6839
+ </header>
6840
+
6841
+ ${tabInputs}
6842
+ <nav class="tabs" aria-label="Figure families">
6843
+ ${tabLabels}
6844
+ </nav>
6845
+ ${tabPanels}
6846
+ <footer>sentinel static-analysis</footer>
6847
+ </main>
6848
+ <script>${FINDINGS_SCRIPT}</script>
6849
+ </body>
6850
+ </html>
6851
+ `;
6852
+ return page.split("\n").map((line) => line.replace(/[ \t]+$/, "")).join("\n");
6853
+ }
6854
+
6855
+ // src/roles/static-analysis/excerpt.ts
6856
+ var WINDOW = { lines: 10, nesting: { before: 3, after: 6 } };
6857
+ var NESTS_UPWARD = /* @__PURE__ */ new Set(["max-depth", "max-nested-callbacks"]);
6858
+ var SHOWS_THE_HEAD = /* @__PURE__ */ new Set(["max-lines"]);
6859
+ var WHOLE_FILE = /* @__PURE__ */ new Set(["unusedFiles"]);
6860
+ function linesOf(cache, cwd, relative5) {
6861
+ if (cache.has(relative5)) return cache.get(relative5);
6862
+ let lines;
6863
+ try {
6864
+ lines = readFileSync17(join22(cwd, relative5), "utf8").split("\n");
6865
+ } catch {
6866
+ lines = void 0;
6867
+ }
6868
+ cache.set(relative5, lines);
6869
+ return lines;
6870
+ }
6871
+ function pointOf(path2) {
6872
+ const cut = path2.lastIndexOf(":");
6873
+ if (cut <= 0) return { file: path2 };
6874
+ const tail = path2.slice(cut + 1);
6875
+ if (!/^\d+$/.test(tail)) return { file: path2 };
6876
+ return { file: path2.slice(0, cut), line: Number(tail) };
6877
+ }
6878
+ function declarationOf(lines, name) {
6879
+ const named = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
6880
+ const declares = /\b(export|const|function|class|type|interface|enum|let|var)\b/;
6881
+ const hits = [];
6882
+ for (const [index, line] of lines.entries()) {
6883
+ if (named.test(line) && declares.test(line)) hits.push(index + 1);
6884
+ }
6885
+ return hits.length === 1 ? hits[0] : void 0;
6886
+ }
6887
+ function windowFor(finding, lines) {
6888
+ const { line } = pointOf(finding.path);
6889
+ const head = () => ({
6890
+ firstLine: 1,
6891
+ text: lines.slice(0, WINDOW.lines).join("\n")
6892
+ });
6893
+ if (WHOLE_FILE.has(finding.metric) || SHOWS_THE_HEAD.has(finding.metric)) return head();
6894
+ const at = line ?? (finding.name ? declarationOf(lines, finding.name) : void 0);
6895
+ if (at === void 0) return void 0;
6896
+ const nests = NESTS_UPWARD.has(finding.metric);
6897
+ const from = nests ? Math.max(1, at - WINDOW.nesting.before) : at;
6898
+ const to = Math.min(lines.length, nests ? at + WINDOW.nesting.after : at + WINDOW.lines - 1);
6899
+ if (from > lines.length) return void 0;
6900
+ return { firstLine: from, text: lines.slice(from - 1, to).join("\n") };
6901
+ }
6902
+ function withExcerpts(report, cwd) {
6903
+ const wanted = /* @__PURE__ */ new Set();
6904
+ for (const group of listedByMetric(report.findings).values()) {
6905
+ for (const finding of group.listed) {
6906
+ if (finding.excerpt === void 0) wanted.add(finding);
6907
+ }
6908
+ }
6909
+ if (wanted.size === 0) return report;
6910
+ const cache = /* @__PURE__ */ new Map();
6911
+ const findings = report.findings.map((finding) => {
6912
+ if (!wanted.has(finding)) return finding;
6913
+ const lines = linesOf(cache, cwd, pointOf(finding.path).file);
6914
+ if (lines === void 0) return finding;
6915
+ const found = windowFor(finding, lines);
6916
+ return found === void 0 ? finding : { ...finding, excerpt: found.text, excerptFirstLine: found.firstLine };
6917
+ });
6918
+ return { ...report, findings };
6919
+ }
6920
+
6921
+ // src/roles/static-analysis/graph/picture.ts
6922
+ import forceAtlas2 from "graphology-layout-forceatlas2";
6923
+ import circular from "graphology-layout/circular.js";
6924
+ import renderSvg from "graphology-svg";
6925
+ var ITERATIONS = 300;
6926
+ var LABELLED = 10;
6927
+ var BLANK = " ";
6928
+ var SIZE = { width: 1600, height: 1200, margin: 30 };
6929
+ function hueOf(community) {
6930
+ return `hsl(${community * 137 % 360} 65% 52%)`;
6931
+ }
6932
+ function layOut(graph, communities) {
6933
+ const drawn = graph.copy();
6934
+ circular.assign(drawn);
6935
+ forceAtlas2.assign(drawn, { iterations: ITERATIONS, settings: forceAtlas2.inferSettings(drawn) });
6936
+ const named = new Set(
6937
+ drawn.nodes().sort((left, right) => drawn.degree(right) - drawn.degree(left) || left.localeCompare(right)).slice(0, LABELLED)
6938
+ );
6939
+ drawn.forEachNode((node) => {
6940
+ drawn.setNodeAttribute(node, "size", 3.5 + Math.sqrt(drawn.degree(node)));
6941
+ drawn.setNodeAttribute(node, "color", hueOf(communities[node] ?? 0));
6942
+ drawn.setNodeAttribute(node, "label", named.has(node) ? node.split("/").pop() ?? node : BLANK);
6943
+ });
6944
+ return drawn;
6945
+ }
6946
+ function writeGraphPicture(drawn, path2) {
6947
+ return new Promise((resolve5) => {
6948
+ renderSvg(drawn, path2, SIZE, () => resolve5());
6949
+ });
6950
+ }
6951
+
6952
+ // src/roles/static-analysis/graph/explorer.ts
6953
+ function place(graph) {
6954
+ const nodes = graph.nodes();
6955
+ let minX = Infinity;
6956
+ let maxX = -Infinity;
6957
+ let minY = Infinity;
6958
+ let maxY = -Infinity;
6959
+ for (const node of nodes) {
6960
+ const x = graph.getNodeAttribute(node, "x");
6961
+ const y = graph.getNodeAttribute(node, "y");
6962
+ if (x < minX) minX = x;
6963
+ if (x > maxX) maxX = x;
6964
+ if (y < minY) minY = y;
6965
+ if (y > maxY) maxY = y;
6966
+ }
6967
+ const usableWidth = SIZE.width - 2 * SIZE.margin;
6968
+ const usableHeight = SIZE.height - 2 * SIZE.margin;
6969
+ const spanX = maxX - minX || 1;
6970
+ const spanY = maxY - minY || 1;
6971
+ const scale = Math.min(usableWidth / spanX, usableHeight / spanY);
6972
+ const offsetX = SIZE.margin + (usableWidth - spanX * scale) / 2;
6973
+ const offsetY = SIZE.margin + (usableHeight - spanY * scale) / 2;
6974
+ const placed = /* @__PURE__ */ new Map();
6975
+ for (const node of nodes) {
6976
+ const x = graph.getNodeAttribute(node, "x");
6977
+ const y = graph.getNodeAttribute(node, "y");
6978
+ placed.set(node, {
6979
+ x: offsetX + (x - minX) * scale,
6980
+ y: offsetY + (y - minY) * scale,
6981
+ // `size` is already the sqrt-damped degree `picture.ts` computed, in the same units as the
6982
+ // standalone image, so the two drawings show a file at the same weight.
6983
+ r: graph.getNodeAttribute(node, "size")
6984
+ });
6985
+ }
6986
+ return placed;
6987
+ }
6988
+ function round(value) {
6989
+ return (Math.round(value * 100) / 100).toString();
6990
+ }
6991
+ function hueOf2(community) {
6992
+ return `hsl(${community * 137 % 360} 65% 52%)`;
6993
+ }
6994
+ function jsonInScript(value) {
6995
+ return value.replace(/</g, "\\u003c");
6996
+ }
6997
+ function buildExplorer(drawn, communities) {
6998
+ const placed = place(drawn);
6999
+ const nodes = [...placed.keys()];
7000
+ const index = new Map(nodes.map((node, position) => [node, position]));
7001
+ const edges = [];
7002
+ const segments = [];
7003
+ drawn.forEachEdge((_edge, _attributes, source, target) => {
7004
+ const from = placed.get(source);
7005
+ const to = placed.get(target);
7006
+ const sourceIndex = index.get(source);
7007
+ const targetIndex = index.get(target);
7008
+ if (!from || !to || sourceIndex === void 0 || targetIndex === void 0) return;
7009
+ segments.push(`M${round(from.x)} ${round(from.y)}L${round(to.x)} ${round(to.y)}`);
7010
+ edges.push(sourceIndex, targetIndex);
7011
+ });
7012
+ const used = [...new Set(nodes.map((node) => communities[node] ?? 0))].sort((a, b) => a - b);
7013
+ const palette2 = used.map((community) => `.g svg .k${community}{fill:${hueOf2(community)}}`).join("");
7014
+ const circles = nodes.map((node) => {
7015
+ const spot = placed.get(node);
7016
+ if (!spot) return "";
7017
+ const community = communities[node] ?? 0;
7018
+ return `<circle cx="${round(spot.x)}" cy="${round(spot.y)}" r="${round(spot.r)}" class="k${community}"><title>${escape(node)}</title></circle>`;
7019
+ }).join("");
7020
+ const payload = JSON.stringify({ f: nodes, e: edges });
7021
+ const markup = `<section class="g">
7022
+ <p class="read">One dot per file, sized by how many imports touch it, coloured by the community it landed in. The shape is the answer to how many lobes there are and whether they are joined. <b>Point at a dot for its path, click it to keep only that file and its neighbours, click the background to come back.</b></p>
7023
+ <div class="gbar"><output id="gname">&nbsp;</output><button type="button" id="greset" hidden>show everything</button></div>
7024
+ <svg viewBox="0 0 ${SIZE.width} ${SIZE.height}" role="img" aria-label="The module's import graph, one dot per file">
7025
+ <path class="ge" d="${segments.join("")}"/>
7026
+ <path class="gh" d=""/>
7027
+ <g class="gn">${circles}</g>
7028
+ ${/* ⛔ INSIDE the svg, where `<style>` is permitted content. It sat under the `<section>`,
7029
+ which html-validate refuses as `element-permitted-content`, and that is our own matcher:
7030
+ `toBeValidHtml` would fail this page. The svg is also the right scope, since every rule
7031
+ in here colours an svg element. */
7032
+ ""}
7033
+ <style>${palette2}</style>
7034
+ </svg>
7035
+ <script type="application/json" id="gdata">${jsonInScript(payload)}</script>
7036
+ <script>${SCRIPT}</script>
7037
+ </section>`;
7038
+ return { markup, bytes: Buffer.byteLength(markup, "utf8") };
7039
+ }
7040
+ var SCRIPT = `(function(){
7041
+ var root=document.currentScript.closest('.g');
7042
+ var data=JSON.parse(root.querySelector('#gdata').textContent);
7043
+ var dots=root.querySelectorAll('.gn circle');
7044
+ var name=root.querySelector('#gname');
7045
+ var reset=root.querySelector('#greset');
7046
+ var highlight=root.querySelector('.gh');
7047
+ var out=[],inn=[];
7048
+ for(var i=0;i<data.f.length;i++){out.push([]);inn.push([]);}
7049
+ for(var k=0;k<data.e.length;k+=2){out[data.e[k]].push(data.e[k+1]);inn[data.e[k+1]].push(data.e[k]);}
7050
+ function show(i){
7051
+ name.textContent=data.f[i]+' ('+out[i].length+' imported, '+inn[i].length+' importing)';
7052
+ }
7053
+ function clear(){
7054
+ root.classList.remove('gsel');
7055
+ highlight.setAttribute('d','');
7056
+ for(var i=0;i<dots.length;i++)dots[i].classList.remove('gon','gpick');
7057
+ name.innerHTML='&nbsp;';
7058
+ reset.hidden=true;
7059
+ }
7060
+ function pick(i){
7061
+ root.classList.add('gsel');
7062
+ var near={};near[i]=1;
7063
+ var d='';
7064
+ function seg(a,b){
7065
+ d+='M'+dots[a].getAttribute('cx')+' '+dots[a].getAttribute('cy')+'L'+dots[b].getAttribute('cx')+' '+dots[b].getAttribute('cy');
7066
+ }
7067
+ for(var a=0;a<out[i].length;a++){near[out[i][a]]=1;seg(i,out[i][a]);}
7068
+ for(var b=0;b<inn[i].length;b++){near[inn[i][b]]=1;seg(inn[i][b],i);}
7069
+ highlight.setAttribute('d',d);
7070
+ for(var j=0;j<dots.length;j++){
7071
+ dots[j].classList.toggle('gon',!!near[j]);
7072
+ dots[j].classList.toggle('gpick',j===i);
7073
+ }
7074
+ show(i);
7075
+ reset.hidden=false;
7076
+ }
7077
+ for(var n=0;n<dots.length;n++){
7078
+ (function(i){
7079
+ dots[i].addEventListener('mouseenter',function(){show(i);});
7080
+ dots[i].addEventListener('click',function(event){event.stopPropagation();pick(i);});
7081
+ })(n);
7082
+ }
7083
+ root.querySelector('svg').addEventListener('click',clear);
7084
+ reset.addEventListener('click',clear);
7085
+ })();`;
7086
+
7087
+ // src/roles/static-analysis/graph/scan.ts
7088
+ import Graph from "graphology";
7089
+ import louvain from "graphology-communities-louvain";
7090
+ import { stronglyConnectedComponents } from "graphology-components";
7091
+ import { degreeCentrality } from "graphology-metrics/centrality/degree.js";
7092
+ import { density } from "graphology-metrics/graph/density.js";
7093
+
7094
+ // src/roles/static-analysis/graph/imports.ts
7095
+ import { readFileSync as readFileSync18 } from "node:fs";
7096
+ import { dirname, join as join23 } from "node:path";
7097
+ import { parseSync as parseSync3 } from "oxc-parser";
7098
+ import { globSync as globSync5 } from "tinyglobby";
7099
+ var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
7100
+ var TS_BEHIND_JS = {
7101
+ ".js": [".ts", ".tsx"],
7102
+ ".mjs": [".mts"],
7103
+ ".cjs": [".cts"]
7104
+ };
7105
+ function isTypeOnly(node) {
7106
+ if (node.importKind === "type" || node.exportKind === "type") return true;
7107
+ const specifiers = node.specifiers;
7108
+ if (!Array.isArray(specifiers) || specifiers.length === 0) return false;
7109
+ return specifiers.every((specifier) => specifier?.importKind === "type");
7110
+ }
7111
+ function specifiersOf(program) {
7112
+ const found = [];
7113
+ const walk3 = (node) => {
7114
+ if (!node || typeof node !== "object") return;
7115
+ if (Array.isArray(node)) {
7116
+ for (const child of node) walk3(child);
7117
+ return;
7118
+ }
7119
+ const current = node;
7120
+ const source = current.source?.value;
7121
+ switch (current.type) {
7122
+ // `import x from './a'`, and the two re-export forms, which are imports that also publish.
7123
+ case "ImportDeclaration":
7124
+ case "ExportNamedDeclaration":
7125
+ case "ExportAllDeclaration":
7126
+ if (typeof source === "string")
7127
+ found.push({ specifier: source, typeOnly: isTypeOnly(current) });
7128
+ break;
7129
+ // `import('./a')`, the lazy route and the split bundle. A real runtime edge, always.
7130
+ case "ImportExpression":
7131
+ if (typeof source === "string") found.push({ specifier: source, typeOnly: false });
7132
+ break;
7133
+ case "CallExpression": {
7134
+ const argument = current.arguments?.[0]?.value;
7135
+ if (current.callee?.name === "require" && typeof argument === "string")
7136
+ found.push({ specifier: argument, typeOnly: false });
7137
+ break;
7138
+ }
7139
+ default:
7140
+ break;
7141
+ }
7142
+ for (const [field, value] of Object.entries(current)) {
7143
+ if (field === "type" || field === "start" || field === "end") continue;
7144
+ walk3(value);
7145
+ }
7146
+ };
7147
+ walk3(program);
7148
+ return found;
7149
+ }
7150
+ function resolveSpecifier(from, specifier, known) {
7151
+ if (!specifier.startsWith(".")) return void 0;
7152
+ const base = join23(dirname(from), specifier);
7153
+ if (known.has(base)) return base;
7154
+ for (const extension of EXTENSIONS) {
7155
+ if (known.has(base + extension)) return base + extension;
7156
+ }
7157
+ const written = EXTENSIONS.find((extension) => base.endsWith(extension));
7158
+ for (const extension of TS_BEHIND_JS[written ?? ""] ?? []) {
7159
+ const swapped = base.slice(0, -(written ?? "").length) + extension;
7160
+ if (known.has(swapped)) return swapped;
7161
+ }
7162
+ for (const extension of EXTENSIONS) {
7163
+ const index = join23(base, `index${extension}`);
7164
+ if (known.has(index)) return index;
7165
+ }
7166
+ return void 0;
7167
+ }
7168
+ function buildImportGraph(cwd, config) {
7169
+ const extensions = extensionsOf(config.languages).map((extension) => extension.slice(1)).join(",");
7170
+ const files = globSync5(
7171
+ measuredRoots(cwd).map(
7172
+ (root) => root === "." ? `**/*.{${extensions}}` : `${root}/**/*.{${extensions}}`
7173
+ ),
7174
+ {
7175
+ cwd,
7176
+ ignore: exclusions(config, "product"),
7177
+ dot: false
7178
+ }
7179
+ );
7180
+ const known = new Set(files);
7181
+ const all2 = {};
7182
+ const value = {};
7183
+ for (const file of files) {
7184
+ all2[file] = [];
7185
+ value[file] = [];
7186
+ let parsed;
7187
+ try {
7188
+ parsed = parseSync3(file, readFileSync18(join23(cwd, file), "utf8"));
7189
+ } catch {
7190
+ continue;
7191
+ }
7192
+ if (parsed.errors.length > 0) continue;
7193
+ for (const { specifier, typeOnly } of specifiersOf(parsed.program)) {
7194
+ const target = resolveSpecifier(file, specifier, known);
7195
+ if (!target || target === file) continue;
7196
+ if (!all2[file].includes(target)) all2[file].push(target);
7197
+ if (!typeOnly && !value[file].includes(target)) value[file].push(target);
7198
+ }
7199
+ }
7200
+ return { all: all2, value };
7201
+ }
7202
+
7203
+ // src/roles/static-analysis/graph/scan.ts
7204
+ var MOST_IMPORTED_LISTED = 10;
7205
+ function seeded() {
7206
+ let state = 42;
7207
+ return () => {
7208
+ state = (state * 1664525 + 1013904223) % 4294967296;
7209
+ return state / 4294967296;
7210
+ };
7211
+ }
7212
+ function toGraph(tree) {
7213
+ const graph = new Graph({ type: "directed", allowSelfLoops: false });
7214
+ for (const file of Object.keys(tree)) if (!graph.hasNode(file)) graph.addNode(file);
7215
+ for (const [from, targets2] of Object.entries(tree)) {
7216
+ for (const to of targets2) {
7217
+ if (!graph.hasNode(to)) graph.addNode(to);
7218
+ if (from !== to && !graph.hasDirectedEdge(from, to)) graph.addDirectedEdge(from, to);
7219
+ }
7220
+ }
7221
+ return graph;
7222
+ }
7223
+ function scanGraph(cwd, config) {
7224
+ const trees = buildImportGraph(cwd, config);
7225
+ const graph = toGraph(trees.all);
7226
+ const groupsOf = (candidate) => stronglyConnectedComponents(candidate).filter((component) => component.length > 1).map((files) => ({ files })).sort((left, right) => right.files.length - left.files.length);
7227
+ const cycles = groupsOf(toGraph(trees.value));
7228
+ const erased = groupsOf(graph).length - cycles.length;
7229
+ const partition = graph.size > 0 ? louvain.detailed(graph, { resolution: config.graph.resolution, rng: seeded() }) : { count: graph.order, modularity: void 0, communities: {} };
7230
+ const centrality = degreeCentrality(graph);
7231
+ const mostImported = graph.nodes().map((path2) => {
7232
+ const afferent = graph.inDegree(path2);
7233
+ const efferent = graph.outDegree(path2);
7234
+ return {
7235
+ path: path2,
7236
+ importedBy: afferent,
7237
+ dependsOn: efferent,
7238
+ // Both zero cannot reach here, the filter below drops them, but the guard stays: the
7239
+ // formula is the only place in this pass that divides.
7240
+ instability: afferent + efferent === 0 ? 1 : efferent / (afferent + efferent),
7241
+ centrality: centrality[path2] ?? 0
7242
+ };
7243
+ }).filter((file) => file.importedBy > 0).sort(
7244
+ (left, right) => right.importedBy - left.importedBy || left.path.localeCompare(right.path)
7245
+ ).slice(0, MOST_IMPORTED_LISTED);
7246
+ return {
7247
+ files: graph.order,
7248
+ edges: graph.size,
7249
+ density: graph.order > 1 ? density(graph) : 0,
7250
+ cycles,
7251
+ filesInCycles: cycles.reduce((total, cycle) => total + cycle.files.length, 0),
7252
+ // ⚠️ Clamped at 0: the two runs are independent, and a group that SHRANK rather than died
7253
+ // leaves both counts equal while fewer files are caught. Never a negative in the report.
7254
+ cyclesErasedAtBuild: Math.max(erased, 0),
7255
+ communities: partition.count,
7256
+ modularity: partition.modularity,
7257
+ mostImported,
7258
+ graph,
7259
+ communityOf: partition.communities
7260
+ };
7261
+ }
7262
+
7263
+ // src/roles/static-analysis/workspace/consumers.ts
7264
+ import { spawnSync as spawnSync5 } from "node:child_process";
7265
+ import { existsSync as existsSync17, mkdirSync, readFileSync as readFileSync19, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
7266
+ import { dirname as dirname2, join as join24, relative as relative4, resolve as resolve3, sep } from "node:path";
7267
+ var CACHE_DIR = join24("node_modules", ".cache", "sentinel");
7268
+ var CACHE_FILE = "consumer-index.json";
7269
+ var SHAPE = 4;
7270
+ var EXTENSIONS2 = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
7271
+ function workspaceRootFrom(cwd) {
7272
+ let dir = resolve3(cwd);
7273
+ for (; ; ) {
7274
+ if (existsSync17(join24(dir, WORKSPACE_ROOT_MARKER))) return dir;
7275
+ const parent = dirname2(dir);
7276
+ if (parent === dir) return void 0;
7277
+ dir = parent;
7278
+ }
7279
+ }
7280
+ function aliasTable(workspaceRoot) {
7281
+ const file = join24(workspaceRoot, "tsconfig.base.json");
7282
+ if (!existsSync17(file)) return /* @__PURE__ */ new Map();
7283
+ let paths;
7284
+ try {
7285
+ const parsed = JSON.parse(readFileSync19(file, "utf8"));
7286
+ paths = parsed.compilerOptions?.paths ?? {};
7287
+ } catch {
7288
+ return /* @__PURE__ */ new Map();
7289
+ }
7290
+ const table = /* @__PURE__ */ new Map();
7291
+ for (const [alias, targets2] of Object.entries(paths)) {
7292
+ if (alias === "apps/*" || alias === "libs/*") continue;
7293
+ table.set(
7294
+ alias,
7295
+ targets2.map((target) => target.replace(/^\.\//, ""))
7296
+ );
7297
+ }
7298
+ return table;
7299
+ }
7300
+ function resolveSpecifier2(specifier, table, workspaceRoot) {
7301
+ const isFile = (candidate) => {
7302
+ try {
7303
+ return statSync5(join24(workspaceRoot, candidate)).isFile();
7304
+ } catch {
7305
+ return false;
7306
+ }
7307
+ };
7308
+ const exists = (candidate) => {
7309
+ if (isFile(candidate)) return candidate;
7310
+ for (const extension of EXTENSIONS2) {
7311
+ if (isFile(candidate + extension)) return candidate + extension;
7312
+ }
7313
+ for (const extension of EXTENSIONS2) {
7314
+ const index = `${candidate}/index${extension}`;
7315
+ if (isFile(index)) return index;
7316
+ }
7317
+ return void 0;
7318
+ };
7319
+ const exact = table.get(specifier);
7320
+ if (exact) {
7321
+ for (const target of exact) {
7322
+ const hit = exists(target);
7323
+ if (hit) return hit;
7324
+ }
7325
+ }
7326
+ for (const [alias, targets2] of table) {
7327
+ if (!alias.endsWith("/*")) continue;
7328
+ const prefix = alias.slice(0, -1);
7329
+ if (!specifier.startsWith(prefix)) continue;
7330
+ const rest = specifier.slice(prefix.length);
7331
+ for (const target of targets2) {
7332
+ if (!target.endsWith("/*")) continue;
7333
+ const hit = exists(target.slice(0, -1) + rest);
7334
+ if (hit) return hit;
7335
+ }
7336
+ }
7337
+ return void 0;
7338
+ }
7339
+ function resolveRelative(importer, specifier, workspaceRoot) {
7340
+ const base = join24(dirname2(importer), specifier).split(sep).join("/");
7341
+ if (base.startsWith("..")) return void 0;
7342
+ const isFile = (candidate) => {
7343
+ try {
7344
+ return statSync5(join24(workspaceRoot, candidate)).isFile();
7345
+ } catch {
7346
+ return false;
7347
+ }
7348
+ };
7349
+ if (isFile(base)) return base;
7350
+ for (const extension of EXTENSIONS2) if (isFile(base + extension)) return base + extension;
7351
+ for (const extension of EXTENSIONS2) {
7352
+ const index = `${base}/index${extension}`;
7353
+ if (isFile(index)) return index;
7354
+ }
7355
+ return void 0;
7356
+ }
7357
+ function isTestFile(path2) {
7358
+ return /\.(test|spec|steps|e2e-spec|integration-spec|stories)\.[cm]?[jt]sx?$|(^|\/)(tests|__tests__|test|testing|stubs|__mocks__|mocks)\//.test(
7359
+ path2
7360
+ );
7361
+ }
7362
+ function moduleOf(path2, roots) {
7363
+ let best;
7364
+ for (const root of roots) {
7365
+ if ((path2 === root || path2.startsWith(`${root}/`)) && (!best || root.length > best.length)) {
7366
+ best = root;
7367
+ }
7368
+ }
7369
+ return best;
7370
+ }
7371
+ function buildConsumerIndex(workspaceRoot, moduleRoots) {
7372
+ const table = aliasTable(workspaceRoot);
7373
+ const imported = /* @__PURE__ */ new Set();
7374
+ const testOnly = /* @__PURE__ */ new Set();
7375
+ if (table.size === 0) return { imported, testOnly, builtFrom: "no alias table" };
7376
+ const result = spawnSync5(
7377
+ "git",
7378
+ [
7379
+ "grep",
7380
+ "-I",
7381
+ "-n",
7382
+ "-o",
7383
+ "-E",
7384
+ "@[A-Za-z0-9_-]+/[A-Za-z0-9_./-]+|from '(\\.\\./)+[^']+'",
7385
+ "--",
7386
+ "*.ts",
7387
+ "*.tsx"
7388
+ ],
7389
+ { cwd: workspaceRoot, encoding: "utf8", maxBuffer: 1024 * 1024 * 1024 }
7390
+ );
7391
+ if (result.error || result.status !== 0 && result.status !== 1) {
7392
+ return { imported, testOnly, builtFrom: "git grep failed" };
7393
+ }
7394
+ const fromProduct = /* @__PURE__ */ new Map();
7395
+ const seen = /* @__PURE__ */ new Map();
7396
+ for (const line of (result.stdout ?? "").split("\n")) {
7397
+ if (!line) continue;
7398
+ const first = line.indexOf(":");
7399
+ const second = line.indexOf(":", first + 1);
7400
+ if (first < 0 || second < 0) continue;
7401
+ const importer = line.slice(0, first).split(sep).join("/");
7402
+ const specifier = line.slice(second + 1);
7403
+ let target;
7404
+ const relative5 = /^from '((?:\.\.\/)+[^']+)'$/.exec(specifier)?.[1];
7405
+ if (relative5) {
7406
+ target = resolveRelative(importer, relative5, workspaceRoot);
7407
+ } else {
7408
+ if (!seen.has(specifier)) {
7409
+ seen.set(specifier, resolveSpecifier2(specifier, table, workspaceRoot));
7410
+ }
7411
+ target = seen.get(specifier);
7412
+ }
7413
+ if (!target) continue;
7414
+ if (moduleOf(importer, moduleRoots) === moduleOf(target, moduleRoots)) continue;
7415
+ imported.add(target);
7416
+ const product = !isTestFile(importer);
7417
+ fromProduct.set(target, (fromProduct.get(target) ?? false) || product);
7418
+ }
7419
+ for (const [target, product] of fromProduct) if (!product) testOnly.add(target);
7420
+ return { imported, testOnly, builtFrom: `git grep over ${seen.size} distinct specifiers` };
7421
+ }
7422
+ function cacheKey(workspaceRoot) {
7423
+ const run = (args) => {
7424
+ const out = spawnSync5("git", args, { cwd: workspaceRoot, encoding: "utf8" });
7425
+ return out.error || out.status !== 0 ? void 0 : out.stdout ?? "";
7426
+ };
7427
+ const head = run(["rev-parse", "HEAD"]);
7428
+ const dirty = run(["status", "--porcelain=v1", "--untracked-files=no"]);
7429
+ if (head === void 0 || dirty === void 0) return void 0;
7430
+ return `${SHAPE}:${head.trim()}:${hash(dirty)}`;
7431
+ }
7432
+ function hash(text) {
7433
+ let value = 5381;
7434
+ for (let index = 0; index < text.length; index += 1) {
7435
+ value = (value << 5) + value + text.charCodeAt(index) >>> 0;
7436
+ }
7437
+ return value.toString(36);
7438
+ }
7439
+ function consumerIndex(workspaceRoot, moduleRoots) {
7440
+ const key = cacheKey(workspaceRoot);
7441
+ const file = join24(workspaceRoot, CACHE_DIR, CACHE_FILE);
7442
+ if (key && existsSync17(file)) {
7443
+ try {
7444
+ const cached = JSON.parse(readFileSync19(file, "utf8"));
7445
+ if (cached.key === key) {
7446
+ return {
7447
+ imported: new Set(cached.imported),
7448
+ testOnly: new Set(cached.testOnly),
7449
+ builtFrom: `${cached.builtFrom}, from cache`
7450
+ };
7451
+ }
7452
+ } catch {
7453
+ }
7454
+ }
7455
+ const index = buildConsumerIndex(workspaceRoot, moduleRoots());
7456
+ if (key) {
7457
+ try {
7458
+ mkdirSync(join24(workspaceRoot, CACHE_DIR), { recursive: true });
7459
+ const payload = {
7460
+ key,
7461
+ imported: [...index.imported],
7462
+ testOnly: [...index.testOnly],
7463
+ builtFrom: index.builtFrom
7464
+ };
7465
+ writeFileSync5(file, JSON.stringify(payload), "utf8");
7466
+ } catch {
4843
7467
  }
4844
7468
  }
4845
- return `${lines.join("\n")}
4846
- `;
4847
- }
4848
- function renderJson(report) {
4849
- const rows = report.rows.map((row) => ({ ...row, help: helpFor(row.metric) ?? null }));
4850
- return `${JSON.stringify({ ...report, rows }, null, 2)}
4851
- `;
4852
- }
4853
- function escape(text) {
4854
- return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
7469
+ return index;
4855
7470
  }
4856
- function renderHtml(report) {
4857
- const rows = report.rows.map((row) => {
4858
- const help = helpFor(row.metric);
4859
- return ` <tr class="${row.over ? "over" : ""}">
4860
- <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>
4864
- </tr>`;
4865
- }).join("\n");
4866
- return `<!doctype html>
4867
- <html lang="en">
4868
- <head>
4869
- <meta charset="utf-8">
4870
- <title>Static analysis: ${escape(report.module)}</title>
4871
- <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; }
7471
+ function outsideView(index, workspaceRoot, moduleRoot) {
7472
+ const prefix = relative4(workspaceRoot, moduleRoot).split(sep).join("/");
7473
+ const within = (path2) => prefix === "" ? path2 : path2.startsWith(`${prefix}/`) ? path2.slice(prefix.length + 1) : void 0;
7474
+ const imported = /* @__PURE__ */ new Set();
7475
+ const testOnly = /* @__PURE__ */ new Set();
7476
+ for (const path2 of index.imported) {
7477
+ const local = within(path2);
7478
+ if (local) imported.add(local);
4875
7479
  }
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; }
4881
- 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; }
4889
- </style>
4890
- </head>
4891
- <body>
4892
- <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>
4900
- ${rows}
4901
- </tbody>
4902
- </table>
4903
- </main>
4904
- </body>
4905
- </html>
4906
- `;
4907
- }
4908
-
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
4933
- };
4934
- }
4935
- function duplicationRun(config, 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
- dir,
4952
- "--no-tips",
4953
- ...paths
4954
- ];
4955
- return { args, rawReportDir: dir, dispose: () => rmSync3(dir, { force: true, recursive: true }) };
7480
+ for (const path2 of index.testOnly) {
7481
+ const local = within(path2);
7482
+ if (local) testOnly.add(local);
7483
+ }
7484
+ return { imported, testOnly };
4956
7485
  }
4957
7486
 
4958
- // src/roles/static-analysis/adapters/jscpd/resolve.ts
7487
+ // src/roles/static-analysis/adapters/jscpd/resolve-binary.ts
4959
7488
  function resolveJscpd(cwd) {
4960
7489
  return resolveBin(cwd, "jscpd") ?? binFromOwnInstall("jscpd", "jscpd");
4961
7490
  }
4962
7491
 
4963
7492
  // 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"];
7493
+ var EXTENSIONS3 = extensionsOf(defaults.languages);
4967
7494
  var scripts = () => ({
4968
- [SCRIPT]: `sentinel --run --${SCRIPT}`,
4969
- [`${SCRIPT}:json`]: `sentinel --run --${SCRIPT} --output json`,
4970
- [`${SCRIPT}:html`]: `sentinel --run --${SCRIPT} --output html`
7495
+ [SCRIPTS.console]: `sentinel --run --${TARGET}`,
7496
+ [SCRIPTS.json]: `sentinel --run --${TARGET} --output json`,
7497
+ [SCRIPTS.html]: `sentinel --run --${TARGET} --output html`
4971
7498
  });
4972
7499
  var targets = () => ({
4973
- [SCRIPT]: { cache: true, inputs: ["default", `{projectRoot}/${STATIC_CONFIG_FILE}`] },
4974
- [`${SCRIPT}:json`]: { cache: false },
4975
- [`${SCRIPT}:html`]: { cache: false }
7500
+ [TARGET]: { cache: true, inputs: ["default", `{projectRoot}/${STATIC_CONFIG_FILE}`] }
4976
7501
  });
4977
- var CONFIG_STUB = `import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
7502
+ function configStub() {
7503
+ const rows = overridableSettings();
7504
+ const pad = Math.max(...rows.map((row) => row.key.length));
7505
+ const show = (row) => ` // ${row.key.padEnd(pad)} ${row.value}`;
7506
+ const movable = rows.filter((row) => row.where === "overrides").map(show);
7507
+ const refused = rows.filter((row) => row.where === "refused").map((row) => ` // ${row.key}: ${row.note}`);
7508
+ return `import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
4978
7509
 
4979
7510
  export default defineStaticAnalysis({
4980
7511
  root: import.meta.dirname,
@@ -4983,38 +7514,60 @@ export default defineStaticAnalysis({
4983
7514
  // the patterns sentinel ships, never replacing them.
4984
7515
  entry: [],
4985
7516
 
4986
- // Named product journeys, as { 'the name product uses': ['src/some/path/**'] }. Paths are
4987
- // relative to this module, because one run measures one module.
4988
- journeys: {},
7517
+ // ---------------------------------------------------------------------------------------------
7518
+ // What this module can move, with the role's value today. A setting you leave out keeps that
7519
+ // value; a setting you write REPLACES it, including inside a list. Anything you set is printed
7520
+ // under "measured with" in the report and marked \`(module)\`, because two modules measured with
7521
+ // different settings can no longer be compared.
7522
+ //
7523
+ ${movable.join("\n")}
7524
+ //
7525
+ // A list shown as a count is not copyable, and replacing one means writing back what you keep,
7526
+ // so read the values in full with:
7527
+ //
7528
+ // pnpm exec sentinel --inspect --${TARGET} --json
7529
+ //
7530
+ ${refused.join("\n")}
7531
+ //
7532
+ // overrides: {
7533
+ // duplication: { minTokens: 100 },
7534
+ // },
4989
7535
  })
4990
7536
  `;
7537
+ }
7538
+ function configValue(config, path2) {
7539
+ return path2.split(".").reduce(
7540
+ (node, key) => node && typeof node === "object" ? node[key] : void 0,
7541
+ config
7542
+ );
7543
+ }
4991
7544
  var JscpdAdapter = class extends BaseAdapter {
4992
- target = "static-analysis";
7545
+ target = TARGET;
4993
7546
  runner = "jscpd";
4994
7547
  /** Every preset: duplication, dead code and complexity are not stack-specific. */
4995
7548
  appliesTo(_preset) {
4996
7549
  return true;
4997
7550
  }
4998
- plan(context) {
4999
- if (!hasSourceFiles(context.cwd, EXTENSIONS)) {
7551
+ plan(context2) {
7552
+ if (!hasSourceFiles(context2.cwd, EXTENSIONS3)) {
5000
7553
  return {
5001
7554
  operations: [],
5002
- skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS.join(", ")}).`
7555
+ skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS3.join(", ")}).`
5003
7556
  };
5004
7557
  }
5005
7558
  return {
5006
7559
  operations: [
5007
7560
  // `write` and not `merge-json`: this file is code, and the engine's in-place merge is for
5008
7561
  // 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 }],
5010
- 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}/`] }
7562
+ ...existsSync18(join25(context2.cwd, STATIC_CONFIG_FILE)) ? [] : [{ kind: "write", path: STATIC_CONFIG_FILE, contents: configStub() }],
7563
+ manifestOperation(context2.cwd, scripts()),
7564
+ ...nxTargetOperations({ cwd: context2.cwd, targets: targets() })
7565
+ // The report directory is ignored ONCE at the workspace root, by `--init`'s workspace prep,
7566
+ // and not here: a per-module rule creates a `.gitignore` in modules that had none and
7567
+ // repeats the same line in every adopting module.
5015
7568
  ],
5016
7569
  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`
7570
+ `added the three \`${TARGET}\` scripts and ${STATIC_CONFIG_FILE}; declare this module's entry points there, everything else comes from the role`
5018
7571
  ]
5019
7572
  };
5020
7573
  }
@@ -5027,17 +7580,19 @@ var JscpdAdapter = class extends BaseAdapter {
5027
7580
  * would be the one nobody reads.
5028
7581
  */
5029
7582
  async measure(ctx, emitting) {
5030
- const shape = ctx.output ?? "console";
5031
- if (!SHAPES.includes(shape)) {
7583
+ const asked = (ctx.output ?? "console").split(",").map((one) => one.trim()).filter((one) => one !== "");
7584
+ const shapes = [...new Set(asked)];
7585
+ const unknown = shapes.filter((one) => !SHAPES.includes(one));
7586
+ if (shapes.length === 0 || unknown.length > 0) {
5032
7587
  process.stderr.write(
5033
- `sentinel static-analysis(jscpd): unknown --output "${ctx.output}" (known: ${SHAPES.join(", ")}).
7588
+ `sentinel static-analysis(jscpd): unknown --output "${ctx.output}" (known: ${SHAPES.join(", ")}; several at once with a comma).
5034
7589
  `
5035
7590
  );
5036
7591
  return { ok: false, code: 1 };
5037
7592
  }
5038
- if (!hasSourceFiles(ctx.cwd, EXTENSIONS)) {
7593
+ if (!hasSourceFiles(ctx.cwd, EXTENSIONS3)) {
5039
7594
  process.stderr.write(
5040
- `sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS.join(", ")}).
7595
+ `sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS3.join(", ")}).
5041
7596
  `
5042
7597
  );
5043
7598
  return { ok: true, code: 0 };
@@ -5065,46 +7620,171 @@ var JscpdAdapter = class extends BaseAdapter {
5065
7620
  return { ok: false, code: 1 };
5066
7621
  }
5067
7622
  const { paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, { valueFlags: ["-c", "--config"] });
5068
- const { args, rawReportDir, dispose } = duplicationRun(config, paths.length > 0 ? paths : ["."]);
5069
- try {
5070
- const result = spawnSync5(jscpd, args, { cwd: ctx.cwd, encoding: "utf8" });
5071
- if (result.error) {
5072
- process.stderr.write(
5073
- `sentinel static-analysis(jscpd): could not run jscpd (${result.error.message})
7623
+ const scanned = paths.length > 0 ? paths : measuredRoots(ctx.cwd);
7624
+ const coverage = coverageOf(ctx.cwd, config);
7625
+ const unreadableCode = hasUnreadableCode(coverage);
7626
+ const product = runDuplication(jscpd, config, scanned, "product", ctx.cwd);
7627
+ if (product.kind === "failed") return { ok: false, code: product.code };
7628
+ if (product.kind === "empty") {
7629
+ process.stderr.write(
7630
+ `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).
5074
7631
  `
5075
- );
5076
- return { ok: false, code: 1 };
5077
- }
5078
- if (result.status !== 0) {
5079
- if (analysedNothing(result.stderr)) {
5080
- process.stderr.write(
5081
- `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).
7632
+ );
7633
+ return { ok: true, code: 0 };
7634
+ }
7635
+ const tests = runDuplication(jscpd, config, scanned, "tests", ctx.cwd);
7636
+ if (tests.kind === "failed") return { ok: false, code: tests.code };
7637
+ const outside = outsideOf(ctx.cwd);
7638
+ const dead = runDeadCode(jscpd, config, paths.length > 0 ? paths : ["."], ctx.cwd, [
7639
+ ...outside.imported
7640
+ ]);
7641
+ if (dead.kind === "failed") return { ok: false, code: dead.code };
7642
+ if (dead.kind === "no-entry-point" && !unreadableCode) {
7643
+ process.stderr.write(
7644
+ `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.
5082
7645
  `
5083
- );
5084
- return { ok: true, code: 0 };
5085
- }
5086
- process.stderr.write(
5087
- `sentinel static-analysis(jscpd): jscpd exited ${result.status}
5088
- ${result.stderr ?? ""}`
5089
- );
5090
- return { ok: false, code: result.status ?? 1 };
5091
- }
5092
- const report = buildReport(ctx, rawReportDir);
5093
- if (!report) {
5094
- process.stderr.write(`sentinel static-analysis(jscpd): jscpd wrote no report
5095
- `);
5096
- return { ok: false, code: 1 };
7646
+ );
7647
+ }
7648
+ const complexity = scanComplexity(ctx.cwd, config);
7649
+ const graph = scanGraph(ctx.cwd, config);
7650
+ const size = scanSizeRules(
7651
+ ctx.cwd,
7652
+ scanned,
7653
+ exclusions(config, "product"),
7654
+ resolveOxlint(ctx.cwd),
7655
+ (bin, args, cwd) => {
7656
+ const result = spawnSync6(bin, args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
7657
+ return { status: result.error ? 1 : result.status, stdout: result.stdout ?? "" };
5097
7658
  }
5098
- if (emitting) emit(report, shape, ctx.cwd);
5099
- return { ok: true, code: 0, metrics: metricsOf(report) };
5100
- } finally {
5101
- dispose();
7659
+ );
7660
+ const bundle = await scanBundle(ctx.cwd);
7661
+ const report = assembleReport(
7662
+ ctx,
7663
+ config,
7664
+ product,
7665
+ tests,
7666
+ dead,
7667
+ complexity,
7668
+ graph,
7669
+ coverage,
7670
+ size,
7671
+ outside,
7672
+ bundle
7673
+ );
7674
+ if (emitting) {
7675
+ for (const shape of shapes) await emit(report, shape, ctx.cwd, graph, coverage);
7676
+ }
7677
+ return { ok: true, code: 0, metrics: metricsOf(report) };
7678
+ }
7679
+ /**
7680
+ * Format what `--init` wrote, with the MODULE's own formatter.
7681
+ *
7682
+ * ⛔ Found by adopting the role on `libs/front/ui`, where the module's `lint` target is
7683
+ * `prettier --check . && eslint .`. Adoption turned it red on the two files adoption itself had
7684
+ * just written:
7685
+ *
7686
+ * [warn] package.json
7687
+ * [warn] static-analysis.config.mjs
7688
+ *
7689
+ * So a team adopting this role is handed a red lint on a target the adoption never touched, for
7690
+ * something the adoption caused. That is what the test role already refuses in its own words:
7691
+ * "the migration handing its own consequence to whoever adopts, which is the one thing this role
7692
+ * must not do". It had the mechanism and this role did not use it.
7693
+ *
7694
+ * ⚠️ The blast radius is small and measured: **2 modules of 243** run `prettier --check` in their
7695
+ * own lint without having adopted our format role, `apps/front/host-admin` and `libs/front/ui`.
7696
+ * Small is not a reason to leave it: the two are exactly the modules a team would adopt by hand.
7697
+ *
7698
+ * The formatter is borrowed from the test role rather than written again, the same way the size
7699
+ * gauge borrows `resolveOxlint` from the lint role: it already knows how a module declares a
7700
+ * style, including the case where that style IS ours.
7701
+ *
7702
+ * ⛔ Never fatal. Adoption succeeded; only the tidy-up did not. Reverting an adoption over a
7703
+ * formatting pass would be the wrong trade, so a failure here is reported as what it is.
7704
+ *
7705
+ * No batching, unlike the test role: `--init` writes two files, and the OOM that forced batches
7706
+ * there came from handing a formatter 273 at once.
7707
+ */
7708
+ async afterInit(ctx) {
7709
+ const written = ctx.written ?? [];
7710
+ if (written.length === 0) return Promise.resolve();
7711
+ const formatter = writtenFileFormatter(ctx.cwd);
7712
+ if (formatter === void 0) {
7713
+ this.say(
7714
+ `no style is declared here, by this module or its workspace, so the ${written.length} file(s) this wrote keep the shape they were written in. Run your own formatter over them before committing.`
7715
+ );
7716
+ return Promise.resolve();
5102
7717
  }
7718
+ const pass = spawnSync6(formatter.bin, [...formatter.args, ...written], {
7719
+ cwd: ctx.cwd,
7720
+ encoding: "utf8"
7721
+ });
7722
+ this.say(
7723
+ pass.status === 0 ? `formatted the ${written.length} file(s) this wrote with ${formatter.origin}, so a format check passes on files it did not write itself.` : `${formatter.origin} could not run over the ${written.length} file(s) this wrote. Adoption itself is applied; run your formatter before committing.`
7724
+ );
7725
+ return Promise.resolve();
7726
+ }
7727
+ /** One line, prefixed like every other message this role writes. */
7728
+ say(message) {
7729
+ process.stderr.write(`sentinel static-analysis(jscpd): ${message}
7730
+ `);
5103
7731
  }
5104
7732
  /** `--run`: measure and show it. */
5105
7733
  async run(ctx) {
5106
7734
  return this.measure(ctx, true);
5107
7735
  }
7736
+ /**
7737
+ * `--inspect`: the settings that apply here, IN FULL.
7738
+ *
7739
+ * It exists because of the other half of documenting an override. The config stub lists what a
7740
+ * module can move, but a list it summarises as "19 patterns" cannot be copied from, and
7741
+ * `overrides` REPLACES a list rather than extending it: a developer who wants one more exclusion
7742
+ * has to write the current ones back, so they need to read them. This is where.
7743
+ *
7744
+ * ⚠️ `--run` answers a narrower question, on purpose. The report prints the settings its FIGURES
7745
+ * came from, which is what makes two modules comparable; this prints everything that applies,
7746
+ * including what no pass has used yet. The two are not interchangeable.
7747
+ */
7748
+ async inspect(ctx) {
7749
+ const { config, from, unreadable } = await loadStaticConfig(ctx.cwd);
7750
+ const roleValue = (path2) => path2.split(".").reduce(
7751
+ (node, key) => node && typeof node === "object" ? node[key] : void 0,
7752
+ defaults
7753
+ );
7754
+ return {
7755
+ runner: this.runner,
7756
+ configFile: from,
7757
+ ...unreadable ? { unreadable } : {},
7758
+ reportDir: REPORT_DIR,
7759
+ // The resolved values, not the role's: what this module measures with.
7760
+ settings: {
7761
+ entry: config.entry,
7762
+ ignore: config.ignore,
7763
+ languages: config.languages,
7764
+ journeys: config.journeys,
7765
+ duplication: config.duplication,
7766
+ deadCode: config.deadCode,
7767
+ // ⚠️ `graph` was missing from the view this very stub calls "the values in full", while the
7768
+ // `overrides` list two fields below happily reported an override of `graph.resolution`. A
7769
+ // developer reading both at once was told the setting does not exist and that they had
7770
+ // moved it.
7771
+ graph: config.graph
7772
+ },
7773
+ // What this module moved, with the role's value beside it, so the difference is readable
7774
+ // without opening the package.
7775
+ overrides: config.moved.map((path2) => ({
7776
+ setting: path2,
7777
+ module: roleValue(path2) === void 0 ? "set" : JSON.stringify(configValue(config, path2)),
7778
+ role: JSON.stringify(roleValue(path2))
7779
+ })),
7780
+ // What the two passes will actually do with the globs, which is where a mistaken override
7781
+ // shows up first: a product pass excluding nothing, or a test pass selecting nothing.
7782
+ passes: {
7783
+ product: { excludes: exclusions(config, "product").length },
7784
+ tests: { excludes: exclusions(config, "tests").length, selects: config.duplication.tests }
7785
+ }
7786
+ };
7787
+ }
5108
7788
  /**
5109
7789
  * `--report`: the same figures, for a machine.
5110
7790
  *
@@ -5122,7 +7802,7 @@ ${result.stderr ?? ""}`
5122
7802
  const declared = moduleScripts(ctx.cwd);
5123
7803
  const missing = Object.keys(scripts()).filter((name) => !(name in declared));
5124
7804
  const adopted = missing.length < Object.keys(scripts()).length;
5125
- const hasConfig = existsSync14(join19(ctx.cwd, STATIC_CONFIG_FILE));
7805
+ const hasConfig = existsSync18(join25(ctx.cwd, STATIC_CONFIG_FILE));
5126
7806
  return {
5127
7807
  adopted,
5128
7808
  preset: adopted ? ctx.preset : null,
@@ -5137,42 +7817,578 @@ ${result.stderr ?? ""}`
5137
7817
  function analysedNothing(stderr) {
5138
7818
  return typeof stderr === "string" && stderr.includes("--fail-on-empty");
5139
7819
  }
5140
- function buildReport(ctx, reportDir) {
5141
- const path = join19(reportDir, "jscpd-report.json");
5142
- if (!existsSync14(path)) return void 0;
5143
- let parsed;
7820
+ function measuredWith(config) {
7821
+ const moved = (key) => config.moved.some((path2) => path2 === key || path2.startsWith(`${key}.`));
7822
+ const row = (key, value) => ({ key, value, moved: moved(key) });
7823
+ return [
7824
+ /**
7825
+ * ⭐ The tool's own version, first, and it is the same argument this list already makes about
7826
+ * every other line: "6.81% found with `minTokens` at 50 and 6.81% found with it at 100 are not
7827
+ * the same measurement". The version is the stronger case, because the numbers a module cannot
7828
+ * override live HERE: the nine gauge thresholds, the complexity bands, the bundle budgets, and
7829
+ * what each figure's unit means. `max-nested-callbacks` sat at 10 and now sits at 3;
7830
+ * `complexityMax` carried a unit and no longer does. A report that does not name the version
7831
+ * that produced it cannot be held against a report from last month, and nothing on the page
7832
+ * would say why the two disagree.
7833
+ *
7834
+ * Never `moved`: it is not a setting a module can turn, which is exactly why it has to be said.
7835
+ */
7836
+ { key: "sentinel", value: readOwnPackage().version, moved: false },
7837
+ row("duplication.minTokens", String(config.duplication.minTokens)),
7838
+ row("duplication.minLines", String(config.duplication.minLines)),
7839
+ row("duplication.kinds", config.duplication.kinds.join(", ")),
7840
+ row("languages", `${config.languages.length}: ${config.languages.join(", ")}`),
7841
+ // The count the SCAN received, not the module's list: the role unions its own artefacts in,
7842
+ // and printing 20 while measuring with 22 is the kind of small lie that costs an afternoon.
7843
+ // The product pass, which is the one the headline figure comes from.
7844
+ row("ignore", `${exclusions(config, "product").length} patterns`),
7845
+ row("duplication.tests", config.duplication.tests.join(" ")),
7846
+ // The dead-code pass's own two, and the reason they are in the same block: a reader comparing
7847
+ // two modules' unreachable share needs the confidence floor as much as the detection window.
7848
+ row("deadCode.minConfidence", String(config.deadCode.minConfidence)),
7849
+ row("deadCode.categories", config.deadCode.categories.join(", ")),
7850
+ row("entry", `${config.entry.length} patterns`),
7851
+ // ⛔ Always printed, even when no size row appears, because this is the line that tells a
7852
+ // reader why a breach can sit beside a green lint. The thresholds are not a module setting and
7853
+ // never appear as `(module)`: a gauge whose scale moves per module compares nothing.
7854
+ row(
7855
+ "size indicators",
7856
+ `${Object.entries(GAUGE_THRESHOLDS).map(([rule, threshold]) => `${rule} ${threshold}`).join(", ")} (min(research, our p90, the rule's default), oxlint run outside the module)`
7857
+ ),
7858
+ // ⚠️ The graph pass's one knob, and it has to be here: it changes both the community count and
7859
+ // `Q`, so the two figures are meaningless without it. The seed is NOT printed beside it,
7860
+ // because it is not a setting: see `graph/scan.ts` for why the role owns it.
7861
+ row("graph.resolution", String(config.graph.resolution)),
7862
+ // Not a setting a module can move: it is McCabe's own published figure, and the row says so
7863
+ // rather than letting a reader take it for one of ours.
7864
+ {
7865
+ key: "complexity bands",
7866
+ value: RISK_BANDS.map(
7867
+ (band) => `${band.from}${band.to === Infinity ? "+" : `-${band.to}`}`
7868
+ ).join(" / "),
7869
+ moved: false
7870
+ }
7871
+ ];
7872
+ }
7873
+ function runTool(jscpd, args, cwd, pass) {
7874
+ const result = spawnSync6(jscpd, args, { cwd, encoding: "utf8" });
7875
+ if (result.error) {
7876
+ process.stderr.write(
7877
+ `sentinel static-analysis(jscpd): could not run the ${pass} pass (${result.error.message})
7878
+ `
7879
+ );
7880
+ return { kind: "failed", code: 1 };
7881
+ }
7882
+ if (result.status !== 0) {
7883
+ if (analysedNothing(result.stderr)) return { kind: "empty" };
7884
+ process.stderr.write(
7885
+ `sentinel static-analysis(jscpd): the ${pass} pass exited ${result.status}
7886
+ ${result.stderr ?? ""}`
7887
+ );
7888
+ return { kind: "failed", code: result.status ?? 1 };
7889
+ }
7890
+ return { kind: "ran" };
7891
+ }
7892
+ function runDeadCode(jscpd, config, paths, cwd, extraEntries = []) {
7893
+ const { args, rawReportDir, dispose } = deadCodeRun(config, paths, extraEntries);
7894
+ try {
7895
+ const ran = runTool(jscpd, args, cwd, "dead-code");
7896
+ if (ran.kind !== "ran") return ran;
7897
+ const path2 = join25(rawReportDir, RAW_REPORT["dead-code"]);
7898
+ if (!existsSync18(path2)) {
7899
+ process.stderr.write(
7900
+ `sentinel static-analysis(jscpd): the dead-code pass wrote no ${RAW_REPORT["dead-code"]}
7901
+ `
7902
+ );
7903
+ return { kind: "failed", code: 1 };
7904
+ }
7905
+ const parsed = safeParseDeadCode(readFileSync20(path2, "utf8"));
7906
+ const statistics = parsed?.statistics;
7907
+ if (!statistics) {
7908
+ process.stderr.write(
7909
+ `sentinel static-analysis(jscpd): the dead-code report has no readable figures
7910
+ `
7911
+ );
7912
+ return { kind: "failed", code: 1 };
7913
+ }
7914
+ if ((statistics.entryPoints ?? 0) === 0) {
7915
+ return { kind: "no-entry-point", files: statistics.files ?? 0 };
7916
+ }
7917
+ const byCategory = {};
7918
+ for (const finding of parsed?.findings ?? []) {
7919
+ const category = finding.category ?? "unknown";
7920
+ byCategory[category] = (byCategory[category] ?? 0) + 1;
7921
+ }
7922
+ return { kind: "figures", statistics, byCategory, findings: parsed?.findings ?? [] };
7923
+ } finally {
7924
+ dispose();
7925
+ }
7926
+ }
7927
+ function moduleRelative(name, roots, cwd) {
7928
+ if (existsSync18(join25(cwd, name))) return name;
7929
+ for (const root of roots) {
7930
+ if (root === ".") continue;
7931
+ const candidate = `${root}/${name}`;
7932
+ if (existsSync18(join25(cwd, candidate))) return candidate;
7933
+ }
7934
+ return name;
7935
+ }
7936
+ function runDuplication(jscpd, config, paths, scope, cwd) {
7937
+ const { args, rawReportDir, dispose } = duplicationRun(config, paths, scope);
5144
7938
  try {
5145
- parsed = JSON.parse(readFileSync14(path, "utf8"));
7939
+ const ran = runTool(jscpd, args, cwd, scope);
7940
+ if (ran.kind !== "ran") return ran;
7941
+ const path2 = join25(rawReportDir, RAW_REPORT.duplication);
7942
+ const parsed = existsSync18(path2) ? safeParse(readFileSync20(path2, "utf8")) : void 0;
7943
+ const total = parsed?.statistics?.total;
7944
+ if (!total) {
7945
+ process.stderr.write(
7946
+ `sentinel static-analysis(jscpd): the ${scope} pass wrote no readable report
7947
+ `
7948
+ );
7949
+ return { kind: "failed", code: 1 };
7950
+ }
7951
+ const seen = /* @__PURE__ */ new Map();
7952
+ const relative5 = (name) => {
7953
+ if (name === void 0) return void 0;
7954
+ const hit = seen.get(name);
7955
+ if (hit !== void 0) return hit;
7956
+ const resolved = moduleRelative(name, paths, cwd);
7957
+ seen.set(name, resolved);
7958
+ return resolved;
7959
+ };
7960
+ return {
7961
+ kind: "figures",
7962
+ total,
7963
+ formats: parsed?.statistics?.formats ?? {},
7964
+ duplicates: (parsed?.duplicates ?? []).map((entry) => ({
7965
+ ...entry,
7966
+ ...entry.firstFile ? { firstFile: { ...entry.firstFile, name: relative5(entry.firstFile.name) } } : {},
7967
+ ...entry.secondFile ? { secondFile: { ...entry.secondFile, name: relative5(entry.secondFile.name) } } : {}
7968
+ }))
7969
+ };
7970
+ } finally {
7971
+ dispose();
7972
+ }
7973
+ }
7974
+ function safeParse(text) {
7975
+ try {
7976
+ return JSON.parse(text);
5146
7977
  } catch {
5147
7978
  return void 0;
5148
7979
  }
5149
- const total = parsed.statistics?.total;
5150
- if (!total) return void 0;
5151
- const percent = Number((total.percentage ?? 0).toFixed(2));
5152
- const rows = [
7980
+ }
7981
+ function safeParseDeadCode(text) {
7982
+ try {
7983
+ return JSON.parse(text);
7984
+ } catch {
7985
+ return void 0;
7986
+ }
7987
+ }
7988
+ function placesToLook(product, tests, dead, complexity, graph, size, outsideTestOnly, unreadable) {
7989
+ const at = (side) => `${side?.name ?? "(unknown)"}${side?.start === void 0 ? "" : `:${side.start}`}`;
7990
+ const clones = (outcome, metric) => outcome.kind === "figures" ? outcome.duplicates.map((entry) => ({
7991
+ metric,
7992
+ path: at(entry.firstFile),
7993
+ other: at(entry.secondFile),
7994
+ ...entry.lines === void 0 ? {} : { lines: entry.lines },
7995
+ ...entry.fragment ? { excerpt: entry.fragment } : {}
7996
+ })) : [];
7997
+ const deadMetric = {
7998
+ "unused-file": "unusedFiles",
7999
+ "unused-export": "unusedExports",
8000
+ "unused-symbol": "unusedSymbols",
8001
+ "unused-import": "unusedImports"
8002
+ };
8003
+ const toolTestOnlyPaths = new Set(
8004
+ dead.kind === "figures" ? dead.findings.filter((f) => f.category === "unused-file" && f.reasons?.includes("used-only-by-tests")).map((f) => f.path ?? "").filter(Boolean) : []
8005
+ );
8006
+ const metricOf = (finding) => finding.category === "unused-file" && finding.reasons?.includes("used-only-by-tests") === true ? "usedOnlyByTests" : deadMetric[finding.category ?? ""] ?? "deadLinesPercent";
8007
+ const weight = (f) => f.value ?? f.lines ?? 0;
8008
+ const inOrder = (all2) => [...all2].sort(
8009
+ (left, right) => left.metric.localeCompare(right.metric) || weight(right) - weight(left) || left.path.localeCompare(right.path) || (left.other ?? "").localeCompare(right.other ?? "")
8010
+ );
8011
+ return inOrder([
8012
+ ...clones(product, "clones"),
8013
+ ...clones(tests, "clonesTests"),
8014
+ // Each one under the band it falls in, so it sits beside the caveat that applies to it.
8015
+ ...complexity.complex.map((fn) => ({
8016
+ metric: RISK_BANDS.find((band) => fn.complexity >= band.from && fn.complexity <= band.to)?.metric ?? "complexityMax",
8017
+ path: `${fn.path}:${fn.line}`,
8018
+ name: fn.name,
8019
+ value: fn.complexity
8020
+ })),
8021
+ ...dead.kind === "figures" && !unreadable ? dead.findings.map((finding) => ({
8022
+ metric: metricOf(finding),
8023
+ path: finding.path ?? "(unknown)",
8024
+ ...finding.name ? { name: finding.name } : {},
8025
+ ...finding.lines === void 0 ? {} : { lines: finding.lines },
8026
+ ...finding.confidence === void 0 ? {} : { confidence: finding.confidence },
8027
+ ...finding.reasons?.length ? { reason: finding.reasons.join(", ") } : {},
8028
+ ...finding.message ? { message: finding.message } : {}
8029
+ })) : [],
8030
+ /*
8031
+ * The files the workspace index found, which the tool cannot see.
8032
+ *
8033
+ * ⚠️ A DIFFERENT fact from the tool's own `used-only-by-tests`, and the reason says which:
8034
+ * the tool sees a file only this module's specs reach, the index sees one only another
8035
+ * MODULE's specs import. Measured on `libs/nest/auth` the two sets are disjoint, 5 and 6.
8036
+ * Deduplicated against the tool's anyway, because a file could in principle be both.
8037
+ */
8038
+ ...unreadable ? [] : [...outsideTestOnly].filter((path2) => !toolTestOnlyPaths.has(path2)).sort().map((path2) => ({
8039
+ metric: "usedOnlyByTests",
8040
+ path: path2,
8041
+ reason: "used only by another module's specs"
8042
+ })),
8043
+ // One finding per GROUP of mutually importing files, not per cycle path: see `graph/scan.ts`.
8044
+ // The first file names the finding and the rest go in `other`, which is the shape the report
8045
+ // already renders for a clone's two sides.
8046
+ ...(unreadable ? [] : graph.cycles).map((cycle) => ({
8047
+ metric: "cycles",
8048
+ path: cycle.files[0] ?? "(unknown)",
8049
+ other: cycle.files.slice(1).join(", "),
8050
+ value: cycle.files.length
8051
+ })),
8052
+ /**
8053
+ * The size breaches, one place per file that broke a rule.
8054
+ *
8055
+ * ⭐ This data was computed and thrown away. The gauge already produces the file, the rule, the
8056
+ * worst value and the LINE for every breach, and the report printed only a count: "43 files
8057
+ * over 150, of 495", with no way to reach one of the 43. A reader's question about every count
8058
+ * in this report is the same one, and it is the only one that gets them into an editor.
8059
+ *
8060
+ * The metric key is the rule name, which is exactly the key the size rows already use, so each
8061
+ * place lands under the figure it belongs to with no mapping table to drift.
8062
+ */
8063
+ ...(size?.breaches ?? []).map((breach) => ({
8064
+ metric: breach.rule,
8065
+ path: `${breach.file}:${breach.line}`,
8066
+ value: breach.value
8067
+ })),
8068
+ /**
8069
+ * The single worst function, under the figure that reports it.
8070
+ *
8071
+ * ⚠️ It is also in one of the risk bands above, deliberately: `complexityMax` answers "how bad
8072
+ * is the worst one" and the bands answer "how many are there", so a reader following the max
8073
+ * must not have to guess which band it fell into. `complex` is sorted worst first, which
8074
+ * is what makes the first element the right one.
8075
+ */
8076
+ ...complexity.complex.slice(0, 1).map((worst) => ({
8077
+ metric: "complexityMax",
8078
+ path: `${worst.path}:${worst.line}`,
8079
+ name: worst.name,
8080
+ value: worst.complexity
8081
+ })),
8082
+ // ⛔ Context, not defects. Every module has a most-imported file, and nothing here says the
8083
+ // count is too high: the caveat on the metric says so in the reader's own words.
8084
+ ...(unreadable ? [] : graph.mostImported).map((file) => ({
8085
+ metric: "mostImported",
8086
+ path: file.path,
8087
+ value: file.importedBy,
8088
+ dependsOn: file.dependsOn,
8089
+ instability: Number(file.instability.toFixed(2)),
8090
+ centrality: Number(file.centrality.toFixed(4))
8091
+ }))
8092
+ ]);
8093
+ }
8094
+ function outsideOf(cwd) {
8095
+ const empty = { imported: /* @__PURE__ */ new Set(), testOnly: /* @__PURE__ */ new Set() };
8096
+ const root = workspaceRootFrom(cwd);
8097
+ if (!root) return empty;
8098
+ try {
8099
+ return outsideView(
8100
+ consumerIndex(root, () => moduleRootsOf(root)),
8101
+ root,
8102
+ cwd
8103
+ );
8104
+ } catch {
8105
+ return empty;
8106
+ }
8107
+ }
8108
+ function moduleRootsOf(workspaceRoot) {
8109
+ const roots = [];
8110
+ for (const area of ["apps", "libs"]) {
8111
+ const base = join25(workspaceRoot, area);
8112
+ if (!existsSync18(base)) continue;
8113
+ for (const entry of globSync6([`${area}/**/package.json`, `${area}/**/project.json`], {
8114
+ cwd: workspaceRoot,
8115
+ ignore: ["**/node_modules/**"],
8116
+ dot: false
8117
+ })) {
8118
+ const root = entry.split("/").slice(0, -1).join("/");
8119
+ if (root) roots.push(root);
8120
+ }
8121
+ }
8122
+ return [...new Set(roots)];
8123
+ }
8124
+ function weightOf(bytes) {
8125
+ return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`;
8126
+ }
8127
+ function stamp(at) {
8128
+ return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
8129
+ }
8130
+ function commitOf(cwd) {
8131
+ const result = spawnSync6("git", ["rev-parse", "--short", "HEAD"], { cwd, encoding: "utf8" });
8132
+ const sha = result.status === 0 ? result.stdout.trim() : "";
8133
+ return sha === "" ? void 0 : sha;
8134
+ }
8135
+ function ageOf(at) {
8136
+ const minutes = Math.round((Date.now() - at.getTime()) / 6e4);
8137
+ if (minutes < 2) return "measured a moment later";
8138
+ if (minutes < 90) return `measured ${minutes} minutes later`;
8139
+ const hours = Math.round(minutes / 60);
8140
+ if (hours < 36) return `measured ${hours} hours later`;
8141
+ return `measured ${Math.round(hours / 24)} days later`;
8142
+ }
8143
+ function addBundleConditions(settings, bundle) {
8144
+ const conditions = bundle.conditions;
8145
+ if (conditions === void 0) return;
8146
+ settings.push(
5153
8147
  {
5154
- metric: "duplicatedLinesPercent",
5155
- value: percent,
5156
- unit: "%",
5157
- // No threshold, and none is coming from here: see `preset-data.ts`. The figure is reported as
5158
- // measured, and what makes it readable is the distribution it sits in, which one module
5159
- // cannot know.
5160
- threshold: null,
5161
- over: false,
8148
+ key: "bundle.build",
8149
+ value: `${stamp(conditions.builtAt)}, ${ageOf(conditions.builtAt)}, from the ${conditions.builtAtFrom}`,
5162
8150
  moved: false
5163
8151
  },
5164
8152
  {
5165
- metric: "clones",
5166
- value: total.clones ?? 0,
5167
- unit: "entries",
5168
- threshold: null,
5169
- over: false,
8153
+ key: "bundle.brand",
8154
+ // ⚠️ Always unknown today, and said rather than left blank: two of the fronts refuse to build
8155
+ // without a brand selected, so these figures are one brand's, and nothing in a build output
8156
+ // records which. A blank here would read as "no brand involved".
8157
+ value: conditions.brandKnown ? "recorded in the output" : "not recorded in the output",
5170
8158
  moved: false
5171
8159
  }
8160
+ );
8161
+ }
8162
+ function addBundleRows(rows, notes, bundle) {
8163
+ if (bundle.notMeasured !== void 0) {
8164
+ notes.push(`no bundle figure: ${bundle.notMeasured.reason}`);
8165
+ return;
8166
+ }
8167
+ const js = bundle.initialLoad?.js;
8168
+ if (js !== void 0 && bundle.verdict !== void 0) {
8169
+ rows.push(
8170
+ {
8171
+ metric: "bundleInitialTransfer",
8172
+ value: weightOf(js.transfer),
8173
+ unit: `gzip, against the 170 KB budget; ${bundle.verdict.transferVsWebMedian} against the 747.5 KB web median`,
8174
+ // The dot carries the budget, which is the reference that asks "are we good". The median
8175
+ // stays in the text: two dots on one row would read as two verdicts.
8176
+ status: bundle.verdict.transferVsBudget
8177
+ },
8178
+ {
8179
+ metric: "bundleInitialParsed",
8180
+ value: weightOf(js.parsed),
8181
+ unit: "decompressed, against the 717 KB budget",
8182
+ status: bundle.verdict.parsedVsBudget
8183
+ },
8184
+ { metric: "bundleInitialFiles", value: js.files, unit: "files the entry pulls" }
8185
+ );
8186
+ }
8187
+ const heaviest = bundle.heaviest?.find((asset) => asset.kind === "js");
8188
+ if (heaviest !== void 0) {
8189
+ rows.push({
8190
+ metric: "bundleHeaviestAsset",
8191
+ value: weightOf(heaviest.weight.parsed),
8192
+ unit: `decompressed, against 100 KB per script, in ${heaviest.name}`,
8193
+ status: heaviest.band
8194
+ });
8195
+ }
8196
+ const dynamic = bundle.dynamic;
8197
+ if (dynamic !== void 0 && dynamic.groups > 0) {
8198
+ rows.push({
8199
+ metric: "bundleDynamicLayer",
8200
+ value: weightOf(dynamic.upperBound.parsed),
8201
+ // ⚠️ The bound is named IN the unit, because the number alone reads as a total and it is a
8202
+ // ceiling: it takes the heaviest variant of every group, and no session reaches every group.
8203
+ unit: `decompressed, at most, over ${dynamic.groups} groups; ${weightOf(dynamic.lowerBound.parsed)} at least`
8204
+ });
8205
+ }
8206
+ const entrySet = bundle.entrySet?.js;
8207
+ if (entrySet !== void 0) {
8208
+ rows.push({
8209
+ metric: "bundleEntrySet",
8210
+ value: weightOf(entrySet.parsed),
8211
+ unit: `decompressed, over ${entrySet.files} files, which no reader downloads whole`
8212
+ });
8213
+ }
8214
+ if (bundle.unresolved.length > 0) {
8215
+ notes.push(
8216
+ `${bundle.unresolved.length} path(s) the manifest announces resolved to no file, so they are excluded from every bundle figure rather than counted as zero: ${bundle.unresolved.slice(0, 3).join(", ")}`
8217
+ );
8218
+ }
8219
+ }
8220
+ function assembleReport(ctx, config, product, tests, dead, complexity, graph, coverage, size, outside, bundle) {
8221
+ const unreadable = hasUnreadableCode(coverage);
8222
+ const share = (total) => Number((total.percentage ?? 0).toFixed(2));
8223
+ const rows = [
8224
+ { metric: "duplicatedLinesPercent", value: share(product.total), unit: "%" },
8225
+ { metric: "clones", value: product.total.clones ?? 0, unit: "entries" }
5172
8226
  ];
5173
- const formats = parsed.statistics?.formats ?? {};
8227
+ const notes = [];
8228
+ if (tests.kind !== "figures") {
8229
+ notes.push("no test file to measure, so no test duplication figure is reported");
8230
+ }
8231
+ if (dead.kind === "no-entry-point" && !unreadable) {
8232
+ notes.push(
8233
+ `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\``
8234
+ );
8235
+ }
8236
+ if (dead.kind === "empty" && !unreadable) {
8237
+ notes.push("the dead-code pass had nothing left to analyse, so no figure is reported");
8238
+ }
8239
+ if (tests.kind === "figures") {
8240
+ rows.push(
8241
+ { metric: "duplicatedLinesPercentTests", value: share(tests.total), unit: "%" },
8242
+ { metric: "clonesTests", value: tests.total.clones ?? 0, unit: "entries" }
8243
+ );
8244
+ }
8245
+ if (unreadable) {
8246
+ notes.push(unreadableNote(coverage));
8247
+ }
8248
+ const toolTestOnly = new Set(
8249
+ dead.kind === "figures" ? dead.findings.filter(
8250
+ (finding) => finding.category === "unused-file" && finding.reasons?.includes("used-only-by-tests") === true
8251
+ ).map((finding) => finding.path ?? "").filter(Boolean) : []
8252
+ );
8253
+ if (dead.kind === "figures" && !unreadable) {
8254
+ const stats = dead.statistics;
8255
+ rows.push(
8256
+ {
8257
+ metric: "entryPoints",
8258
+ value: stats.entryPoints ?? 0,
8259
+ unit: `of ${stats.files ?? 0} files`
8260
+ },
8261
+ /**
8262
+ * ⛔ The share of LINES, with the share of FILES beside it, because one without the other
8263
+ * misleads and this was measured rather than imagined.
8264
+ *
8265
+ * On `apps/nest/microservices/agency` the line share reads **60.21%**, which in a
8266
+ * pull-request comment says the module is rotten or the tool is broken. Neither is true:
8267
+ * 490 of its 504 files are reachable, and a SINGLE unreachable file,
8268
+ * `app/database/seed/data/legacy-agencies.ts`, holds **52 210 lines**, 59.3% of the module on
8269
+ * its own. Twelve files carry 53 233 of the 53 242 dead lines.
8270
+ *
8271
+ * With both denominators the sentence reads correctly at a glance: 60% of the lines against
8272
+ * 2% of the files, so the reader looks for a big generated or seed file instead of concluding
8273
+ * anything about the code they write. Nothing is computed that the pass did not hand over.
8274
+ *
8275
+ * ⚠️ "N files UNREACHABLE IN FULL" and not "in N files", which is what this said first. The
8276
+ * two numbers do NOT contain one another: dead lines also come from exports and symbols that
8277
+ * are dead inside files the walk DOES reach. Measured on `apps/front/console`, 0 files are
8278
+ * unreachable and 0.64% of lines are still dead, which read as a contradiction. On `agency`
8279
+ * the wording looked right only by coincidence, because 13 files carried 53 233 of its
8280
+ * 53 242 dead lines.
8281
+ *
8282
+ * ⛔ And a COMMA, not a semicolon. The markdown recap truncates a unit at its first `;`,
8283
+ * because the bundle units carry a second comparison that pushes a table row past readable
8284
+ * width. A semicolon here meant the recap silently dropped the very denominator this comment
8285
+ * exists for, which is the shape of defect the denominator was added to prevent.
8286
+ */
8287
+ {
8288
+ metric: "deadLinesPercent",
8289
+ value: share(stats),
8290
+ unit: `% of lines, ${(stats.files ?? 0) - (stats.reachableFiles ?? 0)} of ${stats.files ?? 0} files unreachable in full`
8291
+ },
8292
+ // ⛔ Minus the files the TOOL itself labels `used-only-by-tests`. They were counted as
8293
+ // unreachable while the tool knew better: measured on `libs/nest/auth`, 5 of the 20. A file a
8294
+ // spec reaches is not unreachable, and leaving it in the headline count meant the figure
8295
+ // disagreed with the reason printed beside it.
8296
+ {
8297
+ metric: "unusedFiles",
8298
+ value: Math.max((dead.byCategory["unused-file"] ?? 0) - toolTestOnly.size, 0),
8299
+ unit: "files"
8300
+ },
8301
+ { metric: "unusedExports", value: dead.byCategory["unused-export"] ?? 0, unit: "exports" },
8302
+ { metric: "unusedSymbols", value: dead.byCategory["unused-symbol"] ?? 0, unit: "symbols" },
8303
+ { metric: "unusedImports", value: dead.byCategory["unused-import"] ?? 0, unit: "imports" }
8304
+ );
8305
+ }
8306
+ const testOnly = /* @__PURE__ */ new Set([...toolTestOnly, ...outside.testOnly]);
8307
+ if (testOnly.size > 0 && !unreadable) {
8308
+ rows.push({ metric: "usedOnlyByTests", value: testOnly.size, unit: "files" });
8309
+ }
8310
+ if (complexity.functions > 0) {
8311
+ rows.push({ metric: "complexityMax", value: complexity.max, unit: "" });
8312
+ for (const band of RISK_BANDS) {
8313
+ const count = complexity.bands[band.metric] ?? 0;
8314
+ rows.push({
8315
+ metric: band.metric,
8316
+ value: count,
8317
+ unit: `of ${complexity.functions} functions`
8318
+ });
8319
+ }
8320
+ } else {
8321
+ notes.push("no function to measure, so no complexity figure is reported");
8322
+ }
8323
+ if (graph.files > 1 && !unreadable) {
8324
+ rows.push(
8325
+ { metric: "importEdges", value: graph.edges, unit: `between ${graph.files} files` },
8326
+ {
8327
+ metric: "graphDensity",
8328
+ value: Number(graph.density.toFixed(4)),
8329
+ unit: "of the possible edges"
8330
+ },
8331
+ {
8332
+ metric: "cycles",
8333
+ // ⛔ Zero is the expected value here, so the unit has to read correctly at zero as well as
8334
+ // at one, which `1 groups, 2 files` did not. The file count only means something when
8335
+ // there is a group, so it is absent when there is none.
8336
+ //
8337
+ // ⚠️ And NO unit at all at zero, which `none` was: "Import cycles 0 none" reads as a
8338
+ // defect in the tool. The label already carries the noun, so the bare 0 is the sentence.
8339
+ //
8340
+ // ⛔ The erased count is SAID, and the reason is that the figure got smaller. A reader who
8341
+ // ran madge, or an earlier version of this role, sees a bigger number from it: on
8342
+ // host-admin 36 groups against 5. Without this clause they are left to decide which tool
8343
+ // is broken. With it, the sentence is "5 groups, 15 files, 31 more erased at build", which
8344
+ // is the whole finding in one line.
8345
+ //
8346
+ // ⚠️ Commas, never a semicolon: the markdown recap cuts a unit at its first `;`.
8347
+ value: graph.cycles.length,
8348
+ unit: graph.cycles.length === 0 ? (
8349
+ // ⚠️ At zero the unit has to carry a NOUN, or the line reads "Import cycles 0 2
8350
+ // erased at build", which is two numbers and no sentence. Caught by reading the real
8351
+ // output on this package, whose own two groups are both erased.
8352
+ graph.cyclesErasedAtBuild === 0 ? "" : `that survive the build, ${graph.cyclesErasedAtBuild} erased`
8353
+ ) : [
8354
+ `${graph.cycles.length === 1 ? "group" : "groups"}, ${graph.filesInCycles} files`,
8355
+ graph.cyclesErasedAtBuild === 0 ? "" : `${graph.cyclesErasedAtBuild} more erased at build`
8356
+ ].filter(Boolean).join(", ")
8357
+ },
8358
+ { metric: "communities", value: graph.communities, unit: `of ${graph.files} files` }
8359
+ );
8360
+ if (graph.modularity === void 0) {
8361
+ notes.push("no import between the files, so no modularity is reported");
8362
+ } else {
8363
+ rows.push({ metric: "modularity", value: Number(graph.modularity.toFixed(4)), unit: "" });
8364
+ }
8365
+ } else if (!unreadable) {
8366
+ notes.push("fewer than two files, so no graph figure is reported");
8367
+ }
8368
+ if (size && size.files > 0) {
8369
+ rows.push({ metric: "measuredFiles", value: size.files, unit: "files" });
8370
+ }
8371
+ if (size) {
8372
+ const counts = /* @__PURE__ */ new Map();
8373
+ for (const breach of size.breaches) counts.set(breach.rule, (counts.get(breach.rule) ?? 0) + 1);
8374
+ for (const [rule, threshold] of Object.entries(GAUGE_THRESHOLDS)) {
8375
+ const count = counts.get(rule) ?? 0;
8376
+ rows.push({
8377
+ metric: rule,
8378
+ value: count,
8379
+ // The threshold travels WITH the figure: the same count means two different things at two
8380
+ // thresholds, and this gauge's thresholds are not the ones a module's lint enforces.
8381
+ unit: `files over ${threshold}, of ${size.files}`
8382
+ });
8383
+ }
8384
+ } else {
8385
+ notes.push("oxlint could not be run, so no size indicator is reported");
8386
+ }
8387
+ addBundleRows(rows, notes, bundle);
8388
+ const settings = measuredWith(config);
8389
+ addBundleConditions(settings, bundle);
5174
8390
  const byLanguage = Object.fromEntries(
5175
- Object.entries(formats).map(([language, stats]) => [
8391
+ Object.entries(product.formats).map(([language, stats]) => [
5176
8392
  language,
5177
8393
  {
5178
8394
  files: stats.sources ?? 0,
@@ -5181,26 +8397,74 @@ function buildReport(ctx, reportDir) {
5181
8397
  }
5182
8398
  ])
5183
8399
  );
5184
- return { module: ctx.module, passes: ["duplication"], rows, byLanguage };
8400
+ return {
8401
+ module: ctx.module,
8402
+ commit: commitOf(ctx.cwd) ?? null,
8403
+ passes: [
8404
+ "duplication",
8405
+ ...tests.kind === "figures" ? ["duplication (tests)"] : [],
8406
+ ...dead.kind === "figures" ? ["dead code"] : [],
8407
+ ...complexity.functions > 0 ? ["complexity"] : [],
8408
+ ...graph.files > 1 ? ["graph"] : []
8409
+ ],
8410
+ rows,
8411
+ notes,
8412
+ settings,
8413
+ findings: placesToLook(
8414
+ product,
8415
+ tests,
8416
+ dead,
8417
+ complexity,
8418
+ graph,
8419
+ size,
8420
+ outside.testOnly,
8421
+ unreadable
8422
+ ),
8423
+ byLanguage
8424
+ };
5185
8425
  }
5186
- function emit(report, shape, cwd) {
8426
+ async function emit(report, shape, cwd, graph, coverage) {
5187
8427
  if (shape === "console") {
5188
8428
  process.stdout.write(renderConsole(report));
5189
8429
  return;
5190
8430
  }
5191
- const name = shape === "json" ? "static-analysis.json" : "static-analysis.html";
5192
- const body = shape === "json" ? renderJson(report) : renderHtml(report);
5193
- mkdirSync(join19(cwd, REPORT_DIR), { recursive: true });
5194
- writeFileSync4(join19(cwd, REPORT_DIR, name), body, "utf8");
5195
- process.stdout.write(`${renderConsole(report)}
5196
- written: ${REPORT_DIR}/${name}
8431
+ const detailed = shape === "markdown" ? report : withExcerpts(report, cwd);
8432
+ mkdirSync2(join25(cwd, REPORT_DIR), { recursive: true });
8433
+ const written = [];
8434
+ if (shape === "html" && graph.files > 1 && !hasUnreadableCode(coverage)) {
8435
+ const drawn = layOut(graph.graph, graph.communityOf);
8436
+ const picture = `${TARGET}-graph.svg`;
8437
+ const path2 = join25(cwd, REPORT_DIR, picture);
8438
+ await writeGraphPicture(drawn, path2);
8439
+ written.push(`${REPORT_DIR}/${picture}`);
8440
+ detailed.picture = buildExplorer(drawn, graph.communityOf).markup;
8441
+ }
8442
+ const FILES = {
8443
+ json: "static-analysis.json",
8444
+ html: "static-analysis.html",
8445
+ markdown: "static-analysis.md"
8446
+ };
8447
+ const RENDER = { json: renderJson, html: renderHtml, markdown: renderMarkdown };
8448
+ const name = FILES[shape];
8449
+ const body = RENDER[shape](detailed);
8450
+ writeFileSync6(join25(cwd, REPORT_DIR, name), body, "utf8");
8451
+ written.unshift(`${REPORT_DIR}/${name}`);
8452
+ process.stdout.write(`${renderConsole(detailed)}
8453
+ written: ${written.join(", ")}
5197
8454
  `);
5198
8455
  }
5199
8456
  function metricsOf(report) {
5200
8457
  return {
5201
8458
  passes: report.passes,
5202
- finding: hasFinding(report),
8459
+ // Carried for the same reason they are printed: a consumer reading an absent figure needs to
8460
+ // know whether it is clean or unmeasurable.
8461
+ notes: report.notes,
5203
8462
  ...Object.fromEntries(report.rows.map((row) => [row.metric, row.value])),
8463
+ // Carried into `--json` and the CI comment for the same reason the console prints them: a
8464
+ // figure whose settings are unknown cannot be compared with the next module's.
8465
+ settings: Object.fromEntries(
8466
+ report.settings.map((s) => [s.key, { value: s.value, moved: s.moved }])
8467
+ ),
5204
8468
  byLanguage: report.byLanguage
5205
8469
  };
5206
8470
  }
@@ -5218,10 +8482,10 @@ function registerTest() {
5218
8482
  }
5219
8483
 
5220
8484
  // src/roles/typescript/adapters/tsc/tsc.adapter.ts
5221
- import { spawnSync as spawnSync6 } from "node:child_process";
5222
- import { existsSync as existsSync15, readFileSync as readFileSync16 } from "node:fs";
8485
+ import { spawnSync as spawnSync7 } from "node:child_process";
8486
+ import { existsSync as existsSync19, readFileSync as readFileSync22 } from "node:fs";
5223
8487
  import { createRequire } from "node:module";
5224
- import { join as join21 } from "node:path";
8488
+ import { join as join27 } from "node:path";
5225
8489
 
5226
8490
  // src/roles/typescript/presets/nest.json
5227
8491
  var nest_default2 = {
@@ -5401,13 +8665,13 @@ function readTsconfigAdoption(cwd) {
5401
8665
  // Drift is measured against what the preset SETS, not against the allowlist alone: see
5402
8666
  // `presetOwnedKeys`. Without this a module overriding the workspace base reads as
5403
8667
  // non-conformant forever, since re-running `--init` would not (and should not) fix it.
5404
- presetOwns: (path) => presetCompilerOptionKeys(path.slice(path.lastIndexOf("/") + 1)) ?? []
8668
+ presetOwns: (path2) => presetCompilerOptionKeys(path2.slice(path2.lastIndexOf("/") + 1)) ?? []
5405
8669
  });
5406
8670
  }
5407
8671
 
5408
8672
  // src/roles/typescript/adapters/tsc/plan.ts
5409
- import { readFileSync as readFileSync15 } from "node:fs";
5410
- import { join as join20 } from "node:path";
8673
+ import { readFileSync as readFileSync21 } from "node:fs";
8674
+ import { join as join26 } from "node:path";
5411
8675
 
5412
8676
  // src/roles/typescript/typecheck-script.ts
5413
8677
  var SENTINEL_TYPECHECK_COMMAND = "sentinel --run --typescript";
@@ -5453,31 +8717,31 @@ function declaredPreset(cwd) {
5453
8717
  const name = preset.slice(preset.lastIndexOf("/") + 1);
5454
8718
  return PRESET_NAMES.includes(name) ? name : void 0;
5455
8719
  }
5456
- function planAdoption(context) {
5457
- if (!hasShippedPreset(context.preset)) {
8720
+ function planAdoption(context2) {
8721
+ if (!hasShippedPreset(context2.preset)) {
5458
8722
  return {
5459
8723
  operations: [],
5460
- skipped: `sentinel does not ship a TypeScript preset named "${context.preset}" yet (shipped: ${SHIPPED_PRESETS.join(", ")}). Nothing was written, and the module keeps its own tsconfig; the other roles adopt normally.`
8724
+ skipped: `sentinel does not ship a TypeScript preset named "${context2.preset}" yet (shipped: ${SHIPPED_PRESETS.join(", ")}). Nothing was written, and the module keeps its own tsconfig; the other roles adopt normally.`
5461
8725
  };
5462
8726
  }
5463
- const current = declaredPreset(context.cwd);
5464
- if (current !== void 0 && current !== context.preset) {
8727
+ const current = declaredPreset(context2.cwd);
8728
+ if (current !== void 0 && current !== context2.preset) {
5465
8729
  return {
5466
8730
  operations: [],
5467
- blocked: `this module is already adopted as "${current}", and --preset says "${context.preset}". Nothing was written. If the change is deliberate, remove the sentinel preset from the tsconfig \`extends\` chain and re-run; otherwise re-run with --preset ${current}.`
8731
+ blocked: `this module is already adopted as "${current}", and --preset says "${context2.preset}". Nothing was written. If the change is deliberate, remove the sentinel preset from the tsconfig \`extends\` chain and re-run; otherwise re-run with --preset ${current}.`
5468
8732
  };
5469
8733
  }
5470
- if (!hasSourceFiles(context.cwd, TYPECHECKABLE_EXTENSIONS)) {
8734
+ if (!hasSourceFiles(context2.cwd, TYPECHECKABLE_EXTENSIONS)) {
5471
8735
  return {
5472
8736
  operations: [],
5473
8737
  skipped: `no TypeScript here (looked for ${TYPECHECKABLE_EXTENSIONS.join(", ")}), so there is nothing for tsc to check.`
5474
8738
  };
5475
8739
  }
5476
- const target = resolveTsconfigTarget(context.cwd);
5477
- const preset = `@hublo/sentinel/typescript/${context.preset}`;
5478
- const addScript = manifestOperation(context.cwd, typecheckScripts(context.cwd));
8740
+ const target = resolveTsconfigTarget(context2.cwd);
8741
+ const preset = `@hublo/sentinel/typescript/${context2.preset}`;
8742
+ const addScript = manifestOperation(context2.cwd, typecheckScripts(context2.cwd));
5479
8743
  const nxTargets = nxTargetOperations({
5480
- cwd: context.cwd,
8744
+ cwd: context2.cwd,
5481
8745
  targets: { typecheck: TYPECHECK_TARGET }
5482
8746
  });
5483
8747
  if (target.reason === "other-chain") {
@@ -5500,12 +8764,12 @@ function planAdoption(context) {
5500
8764
  };
5501
8765
  }
5502
8766
  const existing = parseJsonc(
5503
- readFileSync15(join20(context.cwd, target.path), "utf8"),
8767
+ readFileSync21(join26(context2.cwd, target.path), "utf8"),
5504
8768
  target.path
5505
8769
  );
5506
8770
  const extendsChain = composeExtends(existing.extends, preset);
5507
- const drift = presetOwnedKeys2(existing.compilerOptions, context.preset);
5508
- const keptLocal = localOnlyCompilerOptions(existing.compilerOptions, context.preset);
8771
+ const drift = presetOwnedKeys2(existing.compilerOptions, context2.preset);
8772
+ const keptLocal = localOnlyCompilerOptions(existing.compilerOptions, context2.preset);
5509
8773
  const operations = [
5510
8774
  { kind: "merge-json", path: target.path, value: { extends: extendsChain } }
5511
8775
  ];
@@ -5526,7 +8790,7 @@ function planAdoption(context) {
5526
8790
  );
5527
8791
  }
5528
8792
  operations.push(addScript, ...nxTargets);
5529
- const existingCheck = moduleScripts(context.cwd)[TYPECHECK_SCRIPT_NAME];
8793
+ const existingCheck = moduleScripts(context2.cwd)[TYPECHECK_SCRIPT_NAME];
5530
8794
  if (keepsForeignChecker(existingCheck)) {
5531
8795
  notes.push(
5532
8796
  `left the \`${TYPECHECK_SCRIPT_NAME}\` script alone (\`${existingCheck}\`): it does not run tsc, and sentinel's check would not cover what that one does. The tsconfig is adopted either way, so \`sentinel --run --typescript\` works when you want it`
@@ -5582,8 +8846,8 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5582
8846
  * What `--init` writes. The planning lives in `plan.ts`: adopting a module and running the
5583
8847
  * checker are two different jobs, and the tsconfig composition is the bulk of the first.
5584
8848
  */
5585
- plan(context) {
5586
- return planAdoption(context);
8849
+ plan(context2) {
8850
+ return planAdoption(context2);
5587
8851
  }
5588
8852
  declaredPreset(cwd) {
5589
8853
  return declaredPreset(cwd);
@@ -5638,7 +8902,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5638
8902
  let chain;
5639
8903
  try {
5640
8904
  const parsed = parseJsonc(
5641
- readFileSync16(join21(cwd, target.path), "utf8"),
8905
+ readFileSync22(join27(cwd, target.path), "utf8"),
5642
8906
  target.path
5643
8907
  );
5644
8908
  chain = parsed.extends;
@@ -5651,7 +8915,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5651
8915
  );
5652
8916
  if (preset === void 0) return void 0;
5653
8917
  try {
5654
- createRequire(join21(cwd, "noop.js")).resolve(preset);
8918
+ createRequire(join27(cwd, "noop.js")).resolve(preset);
5655
8919
  return void 0;
5656
8920
  } catch {
5657
8921
  return preset;
@@ -5692,7 +8956,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5692
8956
  return { ok: false, code: 1 };
5693
8957
  }
5694
8958
  if (options.length > 0) return this.runWithOptions(ctx, tsc, config);
5695
- const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
8959
+ const result = spawnSync7(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
5696
8960
  if (result.error) {
5697
8961
  process.stderr.write(
5698
8962
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5727,7 +8991,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5727
8991
  let worst = 0;
5728
8992
  for (const project of this.referencedProjects(ctx.cwd, config)) {
5729
8993
  const args = ["-p", project, "--noEmit", "--composite", "false", ...ctx.toolArgs ?? []];
5730
- const result = spawnSync6(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
8994
+ const result = spawnSync7(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
5731
8995
  if (result.error) {
5732
8996
  process.stderr.write(
5733
8997
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5747,10 +9011,10 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5747
9011
  referencedProjects(cwd, config) {
5748
9012
  try {
5749
9013
  const parsed = parseJsonc(
5750
- readFileSync16(join21(cwd, config), "utf8"),
9014
+ readFileSync22(join27(cwd, config), "utf8"),
5751
9015
  config
5752
9016
  );
5753
- const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path) => typeof path === "string" && path.length > 0);
9017
+ const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path2) => typeof path2 === "string" && path2.length > 0);
5754
9018
  return referenced.length > 0 ? referenced : [config];
5755
9019
  } catch {
5756
9020
  return [config];
@@ -5763,7 +9027,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5763
9027
  * check.
5764
9028
  */
5765
9029
  typecheckTarget(cwd) {
5766
- if (existsSync15(join21(cwd, "tsconfig.json"))) return "tsconfig.json";
9030
+ if (existsSync19(join27(cwd, "tsconfig.json"))) return "tsconfig.json";
5767
9031
  const target = resolveTsconfigTarget(cwd);
5768
9032
  return target.reason === "none" ? null : target.path;
5769
9033
  }
@@ -5816,7 +9080,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5816
9080
  return { ok: true, code: 0, metrics: _TscAdapter.NOTHING_TO_REPORT };
5817
9081
  }
5818
9082
  const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
5819
- const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
9083
+ const result = spawnSync7(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
5820
9084
  if (result.error) {
5821
9085
  process.stderr.write(
5822
9086
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5859,7 +9123,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5859
9123
  * errors in the output mean the rule must be on.
5860
9124
  */
5861
9125
  noImplicitAnyEnabled(cwd, tsc, config, mainOutput) {
5862
- const shown = spawnSync6(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
9126
+ const shown = spawnSync7(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
5863
9127
  if (shown.status === 0 && shown.stdout) {
5864
9128
  try {
5865
9129
  const co = parseJsonc(
@@ -5913,7 +9177,7 @@ function detectFramework(packageJson) {
5913
9177
  }
5914
9178
 
5915
9179
  // src/core/dispatch.ts
5916
- import { basename } from "node:path";
9180
+ import { basename as basename2 } from "node:path";
5917
9181
 
5918
9182
  // src/shared/text.ts
5919
9183
  function ensureLines(current, lines) {
@@ -5969,31 +9233,31 @@ function replaceLines(current, replacements) {
5969
9233
  }
5970
9234
 
5971
9235
  // src/core/apply-plan.ts
5972
- import { existsSync as existsSync16, readFileSync as readFileSync17, renameSync, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
5973
- import { resolve as resolve3, sep } from "node:path";
9236
+ import { existsSync as existsSync20, readFileSync as readFileSync23, renameSync, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "node:fs";
9237
+ import { resolve as resolve4, sep as sep2 } from "node:path";
5974
9238
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
5975
9239
  function resolveWithinRoot(cwd, relativePath) {
5976
- const root = resolve3(cwd);
5977
- const absolutePath = resolve3(root, relativePath);
5978
- if (absolutePath !== root && !absolutePath.startsWith(root + sep)) {
9240
+ const root = resolve4(cwd);
9241
+ const absolutePath = resolve4(root, relativePath);
9242
+ if (absolutePath !== root && !absolutePath.startsWith(root + sep2)) {
5979
9243
  throw new Error(`Refusing to write outside the module root: "${relativePath}".`);
5980
9244
  }
5981
9245
  return absolutePath;
5982
9246
  }
5983
9247
  function readIfExists(absolutePath) {
5984
- return existsSync16(absolutePath) ? readFileSync17(absolutePath, "utf8") : void 0;
9248
+ return existsSync20(absolutePath) ? readFileSync23(absolutePath, "utf8") : void 0;
5985
9249
  }
5986
9250
  function* leaves(value, prefix = []) {
5987
9251
  for (const [key, keyValue] of Object.entries(value)) {
5988
- const path = [...prefix, key];
5989
- if (isPlainObject(keyValue)) yield* leaves(keyValue, path);
5990
- else yield [path, keyValue];
9252
+ const path2 = [...prefix, key];
9253
+ if (isPlainObject(keyValue)) yield* leaves(keyValue, path2);
9254
+ else yield [path2, keyValue];
5991
9255
  }
5992
9256
  }
5993
9257
  function mergeJsonc(current, value) {
5994
9258
  let text = current.trim().length > 0 ? current : "{}\n";
5995
- for (const [path, leaf] of leaves(value)) {
5996
- const edits = modify(text, path, leaf, {
9259
+ for (const [path2, leaf] of leaves(value)) {
9260
+ const edits = modify(text, path2, leaf, {
5997
9261
  formattingOptions: { insertSpaces: true, tabSize: 2 }
5998
9262
  });
5999
9263
  text = applyEdits(text, edits);
@@ -6002,10 +9266,10 @@ function mergeJsonc(current, value) {
6002
9266
  }
6003
9267
  function removeJsoncKeys(current, keys) {
6004
9268
  let text = current.trim().length > 0 ? current : "{}\n";
6005
- for (const path of keys) {
9269
+ for (const path2 of keys) {
6006
9270
  const root = parseTree(text);
6007
- if (!root || !findNodeAtLocation(root, path)) continue;
6008
- const edits = modify(text, path, void 0, {
9271
+ if (!root || !findNodeAtLocation(root, path2)) continue;
9272
+ const edits = modify(text, path2, void 0, {
6009
9273
  formattingOptions: { insertSpaces: true, tabSize: 2 }
6010
9274
  });
6011
9275
  text = applyEdits(text, edits);
@@ -6019,14 +9283,14 @@ function sameJson(left, right) {
6019
9283
  return false;
6020
9284
  }
6021
9285
  }
6022
- function assertParseable(path, current) {
9286
+ function assertParseable(path2, current) {
6023
9287
  if (current.trim().length === 0) return;
6024
9288
  try {
6025
- parseJsonc(current, path);
9289
+ parseJsonc(current, path2);
6026
9290
  } catch (error) {
6027
9291
  const reason = error instanceof Error ? error.message : String(error);
6028
9292
  throw new Error(
6029
- `${path} does not parse, so sentinel will not edit it: ${reason}. Nothing was written. Fix the file and re-run.`,
9293
+ `${path2} does not parse, so sentinel will not edit it: ${reason}. Nothing was written. Fix the file and re-run.`,
6030
9294
  { cause: error }
6031
9295
  );
6032
9296
  }
@@ -6083,14 +9347,14 @@ function preparePlan(cwd, plan3) {
6083
9347
  }
6084
9348
  function writeFileAtomic(absolutePath, contents) {
6085
9349
  const tempPath = `${absolutePath}.sentinel-${process.pid}.tmp`;
6086
- writeFileSync5(tempPath, contents);
9350
+ writeFileSync7(tempPath, contents);
6087
9351
  renameSync(tempPath, absolutePath);
6088
9352
  }
6089
9353
  function applyPlan(cwd, plan3) {
6090
9354
  const changed = preparePlan(cwd, plan3).filter((file) => file.before !== file.after);
6091
9355
  for (const file of changed) {
6092
9356
  if (file.deleted) {
6093
- rmSync4(file.absolutePath, { force: true });
9357
+ rmSync5(file.absolutePath, { force: true });
6094
9358
  continue;
6095
9359
  }
6096
9360
  writeFileAtomic(file.absolutePath, file.after);
@@ -6099,8 +9363,8 @@ function applyPlan(cwd, plan3) {
6099
9363
  }
6100
9364
 
6101
9365
  // src/core/config/preset-evidence.ts
6102
- import { existsSync as existsSync17, readdirSync as readdirSync3, readFileSync as readFileSync18 } from "node:fs";
6103
- import { join as join22 } from "node:path";
9366
+ import { existsSync as existsSync21, readdirSync as readdirSync3, readFileSync as readFileSync24 } from "node:fs";
9367
+ import { join as join28 } from "node:path";
6104
9368
  var PATH_SIGNALS = [
6105
9369
  {
6106
9370
  preset: "nest",
@@ -6134,12 +9398,12 @@ function sourceImports(cwd, pattern) {
6134
9398
  }
6135
9399
  for (const entry of entries) {
6136
9400
  if (entry.isDirectory()) {
6137
- if (!NOT_SCANNED.has(entry.name)) stack.push(join22(dir, entry.name));
9401
+ if (!NOT_SCANNED.has(entry.name)) stack.push(join28(dir, entry.name));
6138
9402
  continue;
6139
9403
  }
6140
9404
  if (!SOURCE_FILE.test(entry.name)) continue;
6141
9405
  try {
6142
- if (pattern.test(readFileSync18(join22(dir, entry.name), "utf8"))) return true;
9406
+ if (pattern.test(readFileSync24(join28(dir, entry.name), "utf8"))) return true;
6143
9407
  } catch {
6144
9408
  continue;
6145
9409
  }
@@ -6148,12 +9412,12 @@ function sourceImports(cwd, pattern) {
6148
9412
  return false;
6149
9413
  }
6150
9414
  function dependencyNames(cwd) {
6151
- const path = join22(cwd, "package.json");
6152
- if (!existsSync17(path)) return [];
9415
+ const path2 = join28(cwd, "package.json");
9416
+ if (!existsSync21(path2)) return [];
6153
9417
  try {
6154
9418
  const manifest = parseJsonc(
6155
- readFileSync18(path, "utf8"),
6156
- path
9419
+ readFileSync24(path2, "utf8"),
9420
+ path2
6157
9421
  );
6158
9422
  return [
6159
9423
  ...Object.keys(manifest.dependencies ?? {}),
@@ -6175,7 +9439,7 @@ function reactTsconfigEvidence(cwd) {
6175
9439
  }
6176
9440
  for (const name of entries) {
6177
9441
  try {
6178
- const config = parseJsonc(readFileSync18(join22(cwd, name), "utf8"), name);
9442
+ const config = parseJsonc(readFileSync24(join28(cwd, name), "utf8"), name);
6179
9443
  if (config.compilerOptions?.jsx !== void 0) return 'its tsconfig sets "jsx"';
6180
9444
  const extended = Array.isArray(config.extends) ? config.extends : config.extends === void 0 ? [] : [config.extends];
6181
9445
  if (extended.some((entry) => entry === REACT_TYPESCRIPT_PRESET)) {
@@ -6245,8 +9509,8 @@ function previewPlan(opts, plan3) {
6245
9509
  {
6246
9510
  dryRun: true,
6247
9511
  notes: plan3.notes ?? [],
6248
- files: changed.map(({ path, before, after, deleted }) => ({
6249
- path,
9512
+ files: changed.map(({ path: path2, before, after, deleted }) => ({
9513
+ path: path2,
6250
9514
  action: deleted ? "delete" : before.length === 0 ? "create" : "update",
6251
9515
  before,
6252
9516
  after
@@ -6265,10 +9529,10 @@ function previewPlan(opts, plan3) {
6265
9529
  process.stderr.write(" nothing to change\n");
6266
9530
  return 0;
6267
9531
  }
6268
- for (const { path, before, after, deleted } of changed) {
9532
+ for (const { path: path2, before, after, deleted } of changed) {
6269
9533
  const action = deleted ? "delete" : before.length === 0 ? "create" : "update";
6270
9534
  process.stdout.write(`
6271
- ${action} ${path}
9535
+ ${action} ${path2}
6272
9536
  `);
6273
9537
  for (const line of diffLines(before, after)) process.stdout.write(` ${line}
6274
9538
  `);
@@ -6308,8 +9572,8 @@ async function dispatch(opts) {
6308
9572
  );
6309
9573
  }
6310
9574
  }
6311
- const context = { cwd: opts.cwd, preset: effective };
6312
- const plan3 = await adapter.plan(context);
9575
+ const context2 = { cwd: opts.cwd, preset: effective };
9576
+ const plan3 = await adapter.plan(context2);
6313
9577
  if (plan3.blocked) {
6314
9578
  process.stderr.write(`sentinel (${opts.target}): ${plan3.blocked}
6315
9579
  `);
@@ -6325,7 +9589,7 @@ async function dispatch(opts) {
6325
9589
  }
6326
9590
  if (adapter.beforeInit) {
6327
9591
  await adapter.beforeInit({
6328
- module: basename(opts.cwd),
9592
+ module: basename2(opts.cwd),
6329
9593
  cwd: opts.cwd,
6330
9594
  preset,
6331
9595
  ci: false,
@@ -6344,7 +9608,7 @@ async function dispatch(opts) {
6344
9608
  process.stderr.write(` fixing what ${opts.target} can fix automatically...
6345
9609
  `);
6346
9610
  await adapter.afterInit({
6347
- module: basename(opts.cwd),
9611
+ module: basename2(opts.cwd),
6348
9612
  cwd: opts.cwd,
6349
9613
  preset,
6350
9614
  ci: false,