@hublo/sentinel 1.4.3 → 1.5.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,17 +53,24 @@ import {
53
53
  selfCommand,
54
54
  splitToolArgs,
55
55
  walk
56
- } from "./chunk-TDNPPNGK.js";
57
- import {
58
- parseJsonc
59
- } from "./chunk-QXFCZON7.js";
56
+ } from "./chunk-KLEXJFJ6.js";
60
57
  import {
61
58
  DEFAULT_MAX_DIAGNOSTICS,
62
59
  findWorkspaceRoot,
63
60
  moduleName,
64
61
  readOwnPackage,
65
62
  readProjectPackageJson
66
- } from "./chunk-KMKQDGI6.js";
63
+ } from "./chunk-YQRDKWSH.js";
64
+ import {
65
+ REPORT_DIR,
66
+ defaults,
67
+ defineStaticAnalysis,
68
+ isPlainObject,
69
+ overridableSettings
70
+ } from "./chunk-KH2DCVC6.js";
71
+ import {
72
+ parseJsonc
73
+ } from "./chunk-QXFCZON7.js";
67
74
 
68
75
  // src/core/registry.ts
69
76
  var adapters = [];
@@ -760,7 +767,7 @@ function invokesVite(segment) {
760
767
  }
761
768
  function buildScripts(cwd) {
762
769
  const own = selfCommand(cwd, "--run --build");
763
- const scripts = {
770
+ const scripts2 = {
764
771
  [BUILD_SCRIPT_NAME]: composeToolScript(existingCommand(cwd, BUILD_SCRIPT_NAME), {
765
772
  command: own ?? SENTINEL_BUILD_COMMAND,
766
773
  replaces: invokesVite,
@@ -772,14 +779,14 @@ function buildScripts(cwd) {
772
779
  const existingDev = existingCommand(cwd, DEV_SCRIPT_NAME);
773
780
  if (existingDev !== void 0) {
774
781
  const own2 = moduleName(cwd);
775
- scripts[DEV_SCRIPT_NAME] = composeDevScript(
782
+ scripts2[DEV_SCRIPT_NAME] = composeDevScript(
776
783
  existingDev,
777
784
  // No trailing `--` here: `composeDevScript` owns the separator, and adding one on both
778
785
  // sides produced `-- --`, handing Vite an argument nobody wrote.
779
786
  `${selfCommand(cwd, "--run --dev") ?? SENTINEL_DEV_COMMAND} --module ${own2}`
780
787
  );
781
788
  }
782
- return scripts;
789
+ return scripts2;
783
790
  }
784
791
  function plan(context) {
785
792
  const manifest = readProjectPackageJson(context.cwd);
@@ -843,8 +850,8 @@ function planReactApp(context, configFile) {
843
850
  `${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.`
844
851
  );
845
852
  }
846
- const scripts = buildScripts(context.cwd);
847
- operations.push(manifestOperation(context.cwd, scripts));
853
+ const scripts2 = buildScripts(context.cwd);
854
+ operations.push(manifestOperation(context.cwd, scripts2));
848
855
  operations.push(
849
856
  ...nxTargetOperations({
850
857
  cwd: context.cwd,
@@ -854,7 +861,7 @@ function planReactApp(context, configFile) {
854
861
  })
855
862
  })
856
863
  );
857
- const dev = devTargetOperations(context.cwd, scripts[DEV_SCRIPT_NAME]);
864
+ const dev = devTargetOperations(context.cwd, scripts2[DEV_SCRIPT_NAME]);
858
865
  operations.push(...dev.operations);
859
866
  if (dev.note !== void 0) notes.push(dev.note);
860
867
  const owned = ownedBuildDependencies(context.cwd);
@@ -1148,8 +1155,8 @@ function insideBuildScript(env = process.env) {
1148
1155
  return env.npm_lifecycle_event === BUILD_SCRIPT_NAME;
1149
1156
  }
1150
1157
  function runPrerequisite(cwd, env = process.env) {
1151
- const scripts = moduleScripts(cwd);
1152
- const script = scripts[PREBUILD_SCRIPT_NAME];
1158
+ const scripts2 = moduleScripts(cwd);
1159
+ const script = scripts2[PREBUILD_SCRIPT_NAME];
1153
1160
  if (script === void 0 || script.trim() === "") return { kind: "none" };
1154
1161
  if (insideBuildScript(env)) return { kind: "already-run" };
1155
1162
  const result = spawnSync2("pnpm", ["run", PREBUILD_SCRIPT_NAME], { cwd, stdio: "inherit" });
@@ -1861,16 +1868,16 @@ var OxfmtAdapter = class extends BaseAdapter {
1861
1868
  * (`svelte-kit sync && <format> && eslint .`).
1862
1869
  */
1863
1870
  formatScripts(cwd) {
1864
- const scripts = moduleScripts(cwd);
1871
+ const scripts2 = moduleScripts(cwd);
1865
1872
  const ownCommand = selfCommand(cwd, "--run --format");
1866
1873
  const compose = (existing, keepFix) => composeFormatScript(existing, { keepFix, command: ownCommand });
1867
1874
  const rewritten = {
1868
1875
  // `existingCommand` for the role's own script: the command can live in an
1869
1876
  // `nx:run-commands` target that adoption deletes, taking anything else it ran with it.
1870
1877
  [FORMAT_SCRIPT_NAME]: compose(existingCommand(cwd, FORMAT_SCRIPT_NAME), false),
1871
- [`${FORMAT_SCRIPT_NAME}:fix`]: compose(scripts[`${FORMAT_SCRIPT_NAME}:fix`], true)
1878
+ [`${FORMAT_SCRIPT_NAME}:fix`]: compose(scripts2[`${FORMAT_SCRIPT_NAME}:fix`], true)
1872
1879
  };
1873
- for (const [name, command] of Object.entries(scripts)) {
1880
+ for (const [name, command] of Object.entries(scripts2)) {
1874
1881
  if (name in rewritten) continue;
1875
1882
  const needsRewrite = /\bprettier\b/.test(command) || // ...and, for a self-adopting package, a script already running sentinel FOR FORMAT.
1876
1883
  // The `--format` check is load-bearing: without it this matched the module's `lint`
@@ -1879,7 +1886,7 @@ var OxfmtAdapter = class extends BaseAdapter {
1879
1886
  if (!needsRewrite) continue;
1880
1887
  rewritten[name] = compose(command, writesWhenRewritten(name, command));
1881
1888
  }
1882
- return this.joinFormatIntoLint(scripts, rewritten, ownCommand);
1889
+ return this.joinFormatIntoLint(scripts2, rewritten, ownCommand);
1883
1890
  }
1884
1891
  /**
1885
1892
  * Make `pnpm run lint` check formatting too, the way it did before the migration.
@@ -4067,7 +4074,15 @@ function findSuppressionRenames(cwd, renames) {
4067
4074
  var DEFAULT_IGNORE_PATTERNS = [
4068
4075
  "**/dist/**",
4069
4076
  "**/node_modules/**",
4070
- "**/coverage/**",
4077
+ // Anchored, unlike the two above: this one names a word a domain can legitimately use.
4078
+ // `**/coverage/**` also swallowed `planning-requirement/src/coverage`, the RequirementResult
4079
+ // coverage feature, whose 48 TypeScript files were then linted by nothing at all — neither
4080
+ // the preset nor the module's own planning layer said a word about them. `dist` and
4081
+ // `node_modules` are tool output wherever they sit, so they stay unanchored; a folder called
4082
+ // `coverage` is only output at the module root. Measured on the monorepo before changing:
4083
+ // of the 55 modules carrying the unanchored entry, planning-requirement is the ONLY one with
4084
+ // a source folder of that name, so this narrows nothing anywhere else.
4085
+ "coverage/**",
4071
4086
  // `**/*.mock.ts` used to sit here and has been removed. It was the only entry in this list
4072
4087
  // with no reason written next to it, and it does not meet the bar the paragraph above sets:
4073
4088
  // a mock is not build output, not vendored and not generated. It is hand-written TypeScript
@@ -4133,12 +4148,12 @@ function plan2(context) {
4133
4148
  const ignores = readEslintIgnores(context.cwd);
4134
4149
  const rootIgnores = readRootEslintIgnores(findWorkspaceRoot(context.cwd));
4135
4150
  const bare = (pattern) => pattern.replace(/^\*\*\//, "").replace(/\/\*\*$/, "");
4136
- const defaults = new Set(DEFAULT_IGNORE_PATTERNS.map(bare));
4151
+ const defaults2 = new Set(DEFAULT_IGNORE_PATTERNS.map(bare));
4137
4152
  const moduleOwn = [
4138
4153
  ...committedIgnorePatterns(context.cwd),
4139
4154
  ...ignores.patterns,
4140
4155
  ...rootIgnores.patterns
4141
- ].filter((pattern) => !defaults.has(bare(pattern)));
4156
+ ].filter((pattern) => !defaults2.has(bare(pattern)));
4142
4157
  const carried = [.../* @__PURE__ */ new Set([...DEFAULT_IGNORE_PATTERNS, ...moduleOwn])];
4143
4158
  const stub = {
4144
4159
  extends: extendsWithPreset(committedExtends(context.cwd), variant),
@@ -4230,7 +4245,7 @@ function plan2(context) {
4230
4245
  return { operations, notes };
4231
4246
  }
4232
4247
  function lintScripts(cwd) {
4233
- const scripts = moduleScripts(cwd);
4248
+ const scripts2 = moduleScripts(cwd);
4234
4249
  const rewritten = {
4235
4250
  // `existingCommand`, not `scripts[...]`: the command can live in an `nx:run-commands`
4236
4251
  // target instead of a script, and adoption deletes that target. Composing against only
@@ -4240,7 +4255,7 @@ function lintScripts(cwd) {
4240
4255
  })
4241
4256
  };
4242
4257
  const ownCommand = selfCommand(cwd, "--run --lint");
4243
- for (const [name, command] of Object.entries(scripts)) {
4258
+ for (const [name, command] of Object.entries(scripts2)) {
4244
4259
  if (name === LINT_SCRIPT_NAME) continue;
4245
4260
  const needsRewrite = /\beslint\b/.test(command) || // ...and, for a self-adopting package, a script already running sentinel FOR LINT.
4246
4261
  // The `--lint` check is load-bearing: without it this matched the module's `format`
@@ -4516,11 +4531,11 @@ ${result.stderr ?? ""}`);
4516
4531
  const { options: passedOptions, paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, {
4517
4532
  valueFlags: OXLINT_VALUE_FLAGS
4518
4533
  });
4519
- const targets = paths.length > 0 ? paths : ["."];
4534
+ const targets2 = paths.length > 0 ? paths : ["."];
4520
4535
  const emptyScope = scopeFlags(paths, passedOptions);
4521
4536
  const lint = (extra) => {
4522
4537
  const passed = [...extra, ...emptyScope, ...passedOptions];
4523
- const result = spawnSync4(oxlint, ["-c", LINT_CONFIG_FILE, ...passed, ...targets], {
4538
+ const result = spawnSync4(oxlint, ["-c", LINT_CONFIG_FILE, ...passed, ...targets2], {
4524
4539
  cwd: ctx.cwd,
4525
4540
  stdio: "inherit",
4526
4541
  env
@@ -4706,8 +4721,8 @@ ${result.stderr ?? ""}`);
4706
4721
  } catch {
4707
4722
  return void 0;
4708
4723
  }
4709
- const targets = Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : typeof parsed.extends === "string" ? [parsed.extends] : [];
4710
- return targets.find((target) => !existsSync12(join16(cwd, target)));
4724
+ const targets2 = Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : typeof parsed.extends === "string" ? [parsed.extends] : [];
4725
+ return targets2.find((target) => !existsSync12(join16(cwd, target)));
4711
4726
  }
4712
4727
  /** Announce what is not enforced, so reduced coverage is never silent. */
4713
4728
  announceDisabled(preset) {
@@ -4737,6 +4752,800 @@ function registerLint() {
4737
4752
  setDefaultRunner("lint", "oxlint");
4738
4753
  }
4739
4754
 
4755
+ // src/roles/static-analysis/adapters/jscpd/jscpd.adapter.ts
4756
+ import { spawnSync as spawnSync5 } from "node:child_process";
4757
+ import { existsSync as existsSync14, mkdirSync, readFileSync as readFileSync14, writeFileSync as writeFileSync4 } from "node:fs";
4758
+ import { join as join19 } from "node:path";
4759
+
4760
+ // src/roles/static-analysis/configs/resolve.ts
4761
+ import { existsSync as existsSync13 } from "node:fs";
4762
+ import { join as join17 } from "node:path";
4763
+ import { pathToFileURL } from "node:url";
4764
+ var STATIC_CONFIG_FILE = "static-analysis.config.mjs";
4765
+ async function loadStaticConfig(cwd) {
4766
+ const path = join17(cwd, STATIC_CONFIG_FILE);
4767
+ if (!existsSync13(path)) {
4768
+ return { config: defineStaticAnalysis(), from: null, unreadable: null };
4769
+ }
4770
+ try {
4771
+ const loaded = await import(pathToFileURL(path).href);
4772
+ return { config: adopt(loaded.default), from: STATIC_CONFIG_FILE, unreadable: null };
4773
+ } catch (error) {
4774
+ return {
4775
+ config: defineStaticAnalysis(),
4776
+ from: null,
4777
+ unreadable: `${STATIC_CONFIG_FILE}: ${error instanceof Error ? error.message : String(error)}`
4778
+ };
4779
+ }
4780
+ }
4781
+ function adopt(exported) {
4782
+ if (exported === null || typeof exported !== "object") return defineStaticAnalysis();
4783
+ if (Array.isArray(exported.moved)) {
4784
+ return exported;
4785
+ }
4786
+ return defineStaticAnalysis(exported);
4787
+ }
4788
+
4789
+ // src/roles/static-analysis/help/metrics.ts
4790
+ var metricsHelp = {
4791
+ duplicatedLinesPercent: {
4792
+ label: "Duplicated lines",
4793
+ what: "The share of lines jscpd found in at least one clone, over the lines of the files it was able to scan.",
4794
+ 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.",
4795
+ 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%.",
4796
+ 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."
4797
+ },
4798
+ entryPoints: {
4799
+ label: "Entry points",
4800
+ what: "How many files the reachability walk started from, against how many it scanned.",
4801
+ 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.",
4802
+ caveat: "This is the figure that tells you the dead-code share is unreadable, and it is measured: on `libs/nest/auth` the walk starts from 138 of 138 files, because the workspace declares `@hublo/auth/*` as a path alias onto its whole `src`, so every file is a public entry and the share is 0.00% by construction rather than by cleanliness. `libs/front/shared` is 26 of 26. An application is the opposite case, 39 of 594 on `apps/front/console`, and its share is worth acting on. A count close to the file count is not a good sign and not a bad one: it means this pass had nothing to decide.",
4803
+ source: "jscpd's dead-code statistics, `entryPoints` against `files`. Entry points come from the module's own manifest, about fifty framework conventions, and the patterns this role declares; the report does not list WHICH files they are, only how many."
4804
+ },
4805
+ deadLinesPercent: {
4806
+ label: "Unreachable lines",
4807
+ what: "The share of scanned lines the reachability walk never reached from an entry point.",
4808
+ read: "Higher is worse, and only once the entry-point count above says the question was decidable. Read it next to the four counts below rather than alone: one number covering files, exports, symbols and imports hides four different kinds of work.",
4809
+ caveat: "Unreachable here means unreachable WITHIN this module, and that is a measured limitation rather than a theoretical one. A library is consumed from outside the scan, so its files look unreachable: on a sample of 12 files reported dead, 9 of `libs/front/components` and 7 of `libs/nest/auth` are cited elsewhere in the repository, one of them in 356 files. Test, fixture and story files are also outside the reachability graph, verified on a fixture, so a file reached only by a story is reported unreachable; it is true of the product and false of the repository, and `libs/front/components` has one such file of 915 lines.",
4810
+ 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`."
4811
+ },
4812
+ unusedFiles: {
4813
+ label: "Unreachable files",
4814
+ what: "Files no entry point reaches, each with a confidence the tool assigns.",
4815
+ read: "The most actionable of the four, and the one to start from: a whole file nothing reaches is a decision, not a cleanup. Open the ones with the highest confidence first.",
4816
+ caveat: "Three classes hide in this count and only the first is a deletion. Genuinely unreachable product code; files reached only by tests, stories or helpers, which are alive and would break if removed; and entry points of a framework this role has not declared, which is why the front bootstrap files are in the shipped `entry` list. Measured on one nest module, 10 of 42 findings were `*.test-helper.ts` or `*.mock.ts` cited only by tests.",
4817
+ source: "jscpd dead-code findings of category `unused-file`. The three classes were separated by hand on `apps/front/console`, `apps/nest/microservices/contract` and `apps/nest/backends-for-frontends/admin`."
4818
+ },
4819
+ unusedExports: {
4820
+ label: "Unused exports",
4821
+ what: "Exported names nothing in this module imports.",
4822
+ 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.",
4823
+ 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.",
4824
+ source: "jscpd dead-code findings of category `unused-export`."
4825
+ },
4826
+ unusedSymbols: {
4827
+ label: "Unused symbols",
4828
+ what: "Declared names, not exported, that nothing in their own file uses.",
4829
+ read: "A local cleanup rather than a design signal, and usually a small count.",
4830
+ 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.",
4831
+ source: "jscpd dead-code findings of category `unused-symbol`, above the confidence floor."
4832
+ },
4833
+ unusedImports: {
4834
+ label: "Unused imports",
4835
+ what: "Imported names the importing file never uses.",
4836
+ 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.",
4837
+ 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.",
4838
+ 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."
4839
+ },
4840
+ duplicatedLinesPercentTests: {
4841
+ label: "Duplicated lines (tests)",
4842
+ what: "The same share, measured over this module's test files alone.",
4843
+ 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.",
4844
+ 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.",
4845
+ 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."
4846
+ },
4847
+ clonesTests: {
4848
+ label: "Clones (tests)",
4849
+ what: "How many duplicated fragments the test pass reported.",
4850
+ read: "A count, with the same reserves as the product one above.",
4851
+ 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.",
4852
+ 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."
4853
+ },
4854
+ clones: {
4855
+ label: "Clones",
4856
+ what: "How many duplicated fragments jscpd reported, each entry linking two places that match.",
4857
+ 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.",
4858
+ 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.",
4859
+ 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."
4860
+ }
4861
+ };
4862
+ function helpFor(key) {
4863
+ return metricsHelp[key];
4864
+ }
4865
+
4866
+ // src/roles/static-analysis/report.ts
4867
+ function shown(row) {
4868
+ return row.unit === "%" && typeof row.value === "number" ? row.value.toFixed(2) : String(row.value);
4869
+ }
4870
+ function renderConsole(report) {
4871
+ const lines = [];
4872
+ const width = (pick) => Math.max(...report.rows.map((row) => pick(row).length));
4873
+ const label = (row) => helpFor(row.metric)?.label ?? row.metric;
4874
+ const value = (row) => `${shown(row)} ${row.unit}`;
4875
+ const labelWidth = width(label);
4876
+ const valueWidth = width(value);
4877
+ lines.push(`sentinel static-analysis: ${report.module}`);
4878
+ for (const row of report.rows) {
4879
+ lines.push(` ${label(row).padEnd(labelWidth)} ${value(row).padStart(valueWidth)}`);
4880
+ }
4881
+ for (const note of report.notes) lines.push(` (${note})`);
4882
+ if (report.settings.length > 0) {
4883
+ const keyWidth = Math.max(...report.settings.map((setting) => setting.key.length));
4884
+ lines.push("", " measured with");
4885
+ for (const setting of report.settings) {
4886
+ lines.push(
4887
+ ` ${setting.key.padEnd(keyWidth)} ${setting.value}${setting.moved ? " (module)" : ""}`
4888
+ );
4889
+ }
4890
+ if (report.settings.some((setting) => setting.moved)) {
4891
+ lines.push(` (module) = overridden in ${report.module}'s static-analysis.config.mjs`);
4892
+ }
4893
+ }
4894
+ if (report.byLanguage && Object.keys(report.byLanguage).length > 0) {
4895
+ lines.push("", " per language, where a clone was found in the product code");
4896
+ for (const [language, stats] of Object.entries(report.byLanguage)) {
4897
+ lines.push(
4898
+ ` ${language.padEnd(12)} ${stats.percentage.toFixed(2)}% (${stats.duplicatedLines} lines over ${stats.files} files)`
4899
+ );
4900
+ }
4901
+ lines.push(" a language is absent when no file of it was long enough to scan");
4902
+ }
4903
+ const caveats = report.rows.map((row) => ({ row, help: helpFor(row.metric) })).filter(
4904
+ (entry) => Boolean(entry.help)
4905
+ );
4906
+ if (caveats.length > 0) {
4907
+ lines.push("", " what these numbers do not say");
4908
+ for (const { row, help } of caveats) {
4909
+ lines.push(` ${help.label}: ${help.caveat}`, ` source: ${help.source}`);
4910
+ void row;
4911
+ }
4912
+ }
4913
+ return `${lines.join("\n")}
4914
+ `;
4915
+ }
4916
+ function renderJson(report) {
4917
+ const rows = report.rows.map((row) => ({ ...row, help: helpFor(row.metric) ?? null }));
4918
+ return `${JSON.stringify({ ...report, rows }, null, 2)}
4919
+ `;
4920
+ }
4921
+ function escape(text) {
4922
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
4923
+ }
4924
+ function renderHtml(report) {
4925
+ const rows = report.rows.map((row) => {
4926
+ const help = helpFor(row.metric);
4927
+ return ` <tr>
4928
+ <th scope="row">${escape(help?.label ?? row.metric)}</th>
4929
+ <td class="num">${escape(shown(row))} <span class="unit">${escape(row.unit)}</span></td>
4930
+ <td class="caveat">${escape(help?.caveat ?? "")}</td>
4931
+ </tr>`;
4932
+ }).join("\n");
4933
+ const settings = report.settings.map(
4934
+ (setting) => ` <tr>
4935
+ <th scope="row"><code>${escape(setting.key)}</code></th>
4936
+ <td class="num">${escape(setting.value)}</td>
4937
+ <td>${setting.moved ? '<span class="moved">overridden in this module</span>' : ""}</td>
4938
+ </tr>`
4939
+ ).join("\n");
4940
+ return `<!doctype html>
4941
+ <html lang="en">
4942
+ <head>
4943
+ <meta charset="utf-8">
4944
+ <title>Static analysis: ${escape(report.module)}</title>
4945
+ <style>
4946
+ :root { color-scheme: light dark; --ink: #1a1a1a; --bg: #fdfdfc; --rule: #e2e0da; --warn: #9a3412; --dim: #6b6b6b; }
4947
+ @media (prefers-color-scheme: dark) {
4948
+ :root { --ink: #e8e6e1; --bg: #1b1b1a; --rule: #35332e; --warn: #fb923c; --dim: #9b9892; }
4949
+ }
4950
+ body { margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--ink);
4951
+ font: 15px/1.55 ui-sans-serif, system-ui, sans-serif; }
4952
+ main { max-width: 60rem; margin: 0 auto; }
4953
+ h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
4954
+ p.sub { color: var(--dim); margin: 0 0 1.5rem; }
4955
+ h2 { font-size: 0.95rem; margin: 2.5rem 0 0.5rem; }
4956
+ table { width: 100%; border-collapse: collapse; }
4957
+ th, td { text-align: left; padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--rule);
4958
+ vertical-align: top; }
4959
+ thead th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--dim); }
4960
+ td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
4961
+ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, monospace; }
4962
+ .unit { color: var(--dim); font-size: 0.85em; }
4963
+ .moved { color: var(--warn); font-size: 0.85em; }
4964
+ .caveat { color: var(--dim); font-size: 0.9em; }
4965
+ </style>
4966
+ </head>
4967
+ <body>
4968
+ <main>
4969
+ <h1>Static analysis: ${escape(report.module)}</h1>
4970
+ <p class="sub">Passes: ${escape(report.passes.join(", "))}</p>
4971
+ <table>
4972
+ <thead>
4973
+ <tr><th scope="col">Metric</th><th scope="col">Value</th><th scope="col">What it does not say</th></tr>
4974
+ </thead>
4975
+ <tbody>
4976
+ ${rows}
4977
+ </tbody>
4978
+ </table>
4979
+ <h2>Measured with</h2>
4980
+ <table>
4981
+ <thead>
4982
+ <tr><th scope="col">Setting</th><th scope="col">Value</th><th scope="col">Origin</th></tr>
4983
+ </thead>
4984
+ <tbody>
4985
+ ${settings}
4986
+ </tbody>
4987
+ </table>
4988
+ </main>
4989
+ </body>
4990
+ </html>
4991
+ `;
4992
+ }
4993
+
4994
+ // src/roles/static-analysis/adapters/jscpd/config.ts
4995
+ import { mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
4996
+ import { tmpdir } from "node:os";
4997
+ import { join as join18 } from "node:path";
4998
+ var OWN_ARTEFACTS = [`**/${STATIC_CONFIG_FILE}`, `**/${REPORT_DIR}/**`];
4999
+ function testGlobs(config) {
5000
+ const relative4 = config.duplication.tests;
5001
+ return {
5002
+ ignore: relative4.map((glob) => `**/${glob}`),
5003
+ // A one-item brace group is avoided rather than trusted: nothing guarantees `{x}` expands.
5004
+ pattern: relative4.length === 1 ? `**/${relative4[0]}` : `**/{${relative4.join(",")}}`
5005
+ };
5006
+ }
5007
+ function exclusions(config, scope = "tests") {
5008
+ const tests = scope === "product" ? testGlobs(config).ignore : [];
5009
+ return [.../* @__PURE__ */ new Set([...config.ignore, ...OWN_ARTEFACTS, ...tests])];
5010
+ }
5011
+ function fileSettings(config, scope) {
5012
+ return {
5013
+ // The detection window. Stated rather than defaulted, because it decides what is invisible.
5014
+ minTokens: config.duplication.minTokens,
5015
+ minLines: config.duplication.minLines,
5016
+ // `format` is jscpd's name for what we call `languages`. Naming `typescript` alone would
5017
+ // leave out `.tsx`, which is a separate language to it: 300 files of 523 on front/console.
5018
+ format: config.languages,
5019
+ ignore: exclusions(config, scope),
5020
+ // A scan that analyses nothing otherwise exits 0 with only a warning, which reads as a pass.
5021
+ failOnEmpty: true,
5022
+ // The dead-code engine keeps its own section, and its own `minLines`: jscpd's documentation is
5023
+ // explicit that the top-level one is about clones and is never carried into a dead-code run.
5024
+ deadCode: {
5025
+ entry: config.entry,
5026
+ minConfidence: config.deadCode.minConfidence,
5027
+ categories: config.deadCode.categories
5028
+ },
5029
+ // The escape hatch, last so it wins, and empty unless a module asked for it.
5030
+ ...config.tools.jscpd
5031
+ };
5032
+ }
5033
+ function duplicationRun(config, paths, scope) {
5034
+ const dir = mkdtempSync(join18(tmpdir(), "sentinel-static-"));
5035
+ const configPath = join18(dir, "jscpd.json");
5036
+ writeFileSync3(configPath, `${JSON.stringify(fileSettings(config, scope), null, 2)}
5037
+ `, "utf8");
5038
+ const args = [
5039
+ "--config",
5040
+ configPath,
5041
+ // The test pass SELECTS its files; the product pass excludes them through `ignore` above. A
5042
+ // flag and not a config key, because `--pattern` is the only documented way in and it takes
5043
+ // exactly one glob, braces expanded. Verified: 478 product files + 87 test files = the 565 of
5044
+ // a blended scan on `apps/front/console`.
5045
+ ...scope === "tests" ? ["--pattern", testGlobs(config).pattern] : [],
5046
+ // Always json, and only json, whatever shape the caller asked for. The three shapes are
5047
+ // rendered by this role from the figures, not by the tool: one report lives under
5048
+ // `static-analysis`, assembled from several passes, so a per-pass console table from the tool
5049
+ // would be a fourth voice saying the same numbers differently. `--output` below is jscpd's
5050
+ // output DIRECTORY, not our `--output` shape.
5051
+ "--reporters",
5052
+ "json",
5053
+ "--output",
5054
+ dir,
5055
+ "--no-tips",
5056
+ ...paths
5057
+ ];
5058
+ return { args, rawReportDir: dir, dispose: () => rmSync3(dir, { force: true, recursive: true }) };
5059
+ }
5060
+ function deadCodeRun(config, paths) {
5061
+ const dir = mkdtempSync(join18(tmpdir(), "sentinel-static-"));
5062
+ const configPath = join18(dir, "jscpd.json");
5063
+ writeFileSync3(
5064
+ configPath,
5065
+ `${JSON.stringify(fileSettings(config, "dead-code"), null, 2)}
5066
+ `,
5067
+ "utf8"
5068
+ );
5069
+ return {
5070
+ args: [
5071
+ "--config",
5072
+ configPath,
5073
+ "--dead-code",
5074
+ "--reporters",
5075
+ "json",
5076
+ "--output",
5077
+ dir,
5078
+ "--no-tips",
5079
+ ...paths
5080
+ ],
5081
+ rawReportDir: dir,
5082
+ dispose: () => rmSync3(dir, { force: true, recursive: true })
5083
+ };
5084
+ }
5085
+ var RAW_REPORT = {
5086
+ duplication: "jscpd-report.json",
5087
+ "dead-code": "basta-report.json"
5088
+ };
5089
+
5090
+ // src/roles/static-analysis/adapters/jscpd/resolve.ts
5091
+ function resolveJscpd(cwd) {
5092
+ return resolveBin(cwd, "jscpd") ?? binFromOwnInstall("jscpd", "jscpd");
5093
+ }
5094
+
5095
+ // src/roles/static-analysis/adapters/jscpd/jscpd.adapter.ts
5096
+ var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css", ".scss"];
5097
+ var SCRIPT = "static-analysis";
5098
+ var SHAPES = ["console", "json", "html"];
5099
+ var scripts = () => ({
5100
+ [SCRIPT]: `sentinel --run --${SCRIPT}`,
5101
+ [`${SCRIPT}:json`]: `sentinel --run --${SCRIPT} --output json`,
5102
+ [`${SCRIPT}:html`]: `sentinel --run --${SCRIPT} --output html`
5103
+ });
5104
+ var targets = () => ({
5105
+ [SCRIPT]: { cache: true, inputs: ["default", `{projectRoot}/${STATIC_CONFIG_FILE}`] }
5106
+ });
5107
+ function configStub() {
5108
+ const rows = overridableSettings();
5109
+ const pad = Math.max(...rows.map((row) => row.key.length));
5110
+ const show = (row) => ` // ${row.key.padEnd(pad)} ${row.value}`;
5111
+ const movable = rows.filter((row) => row.where === "overrides").map(show);
5112
+ const refused = rows.filter((row) => row.where === "refused").map((row) => ` // ${row.key}: ${row.note}`);
5113
+ return `import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
5114
+
5115
+ export default defineStaticAnalysis({
5116
+ root: import.meta.dirname,
5117
+
5118
+ // Files that RUN although nothing imports them: they are where reachability starts. Added to
5119
+ // the patterns sentinel ships, never replacing them.
5120
+ entry: [],
5121
+
5122
+ // Named product journeys, as { 'the name product uses': ['src/some/path/**'] }. Paths are
5123
+ // relative to this module, because one run measures one module.
5124
+ journeys: {},
5125
+
5126
+ // ---------------------------------------------------------------------------------------------
5127
+ // What this module can move, with the role's value today. A setting you leave out keeps that
5128
+ // value; a setting you write REPLACES it, including inside a list. Anything you set is printed
5129
+ // under "measured with" in the report and marked \`(module)\`, because two modules measured with
5130
+ // different settings can no longer be compared.
5131
+ //
5132
+ ${movable.join("\n")}
5133
+ //
5134
+ // A list shown as a count is not copyable, and replacing one means writing back what you keep,
5135
+ // so read the values in full with:
5136
+ //
5137
+ // pnpm exec sentinel --inspect --${SCRIPT} --json
5138
+ //
5139
+ ${refused.join("\n")}
5140
+ //
5141
+ // overrides: {
5142
+ // duplication: { minTokens: 100 },
5143
+ // },
5144
+ })
5145
+ `;
5146
+ }
5147
+ function configValue(config, path) {
5148
+ return path.split(".").reduce(
5149
+ (node, key) => node && typeof node === "object" ? node[key] : void 0,
5150
+ config
5151
+ );
5152
+ }
5153
+ var JscpdAdapter = class extends BaseAdapter {
5154
+ target = "static-analysis";
5155
+ runner = "jscpd";
5156
+ /** Every preset: duplication, dead code and complexity are not stack-specific. */
5157
+ appliesTo(_preset) {
5158
+ return true;
5159
+ }
5160
+ plan(context) {
5161
+ if (!hasSourceFiles(context.cwd, EXTENSIONS)) {
5162
+ return {
5163
+ operations: [],
5164
+ skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS.join(", ")}).`
5165
+ };
5166
+ }
5167
+ return {
5168
+ operations: [
5169
+ // `write` and not `merge-json`: this file is code, and the engine's in-place merge is for
5170
+ // JSON. A re-init therefore RESTORES the stub, so an existing one is left alone below.
5171
+ ...existsSync14(join19(context.cwd, STATIC_CONFIG_FILE)) ? [] : [{ kind: "write", path: STATIC_CONFIG_FILE, contents: configStub() }],
5172
+ manifestOperation(context.cwd, scripts()),
5173
+ ...nxTargetOperations({ cwd: context.cwd, targets: targets() })
5174
+ // The report directory is ignored ONCE at the workspace root, by `--init`'s workspace prep,
5175
+ // and not here: a per-module rule creates a `.gitignore` in modules that had none and
5176
+ // repeats the same line in every adopting module.
5177
+ ],
5178
+ notes: [
5179
+ `added the three \`${SCRIPT}\` scripts and ${STATIC_CONFIG_FILE}; declare this module's entry points and journeys there, everything else comes from the role`
5180
+ ]
5181
+ };
5182
+ }
5183
+ /**
5184
+ * Measure, and emit only when asked to.
5185
+ *
5186
+ * `--run` prints and may write a file; `--report` answers a machine and must do neither, which is
5187
+ * the only difference between the two verbs here. Shared rather than duplicated because the
5188
+ * measurement is where every reserve lives: two copies would drift, and the one that drifted
5189
+ * would be the one nobody reads.
5190
+ */
5191
+ async measure(ctx, emitting) {
5192
+ const shape = ctx.output ?? "console";
5193
+ if (!SHAPES.includes(shape)) {
5194
+ process.stderr.write(
5195
+ `sentinel static-analysis(jscpd): unknown --output "${ctx.output}" (known: ${SHAPES.join(", ")}).
5196
+ `
5197
+ );
5198
+ return { ok: false, code: 1 };
5199
+ }
5200
+ if (!hasSourceFiles(ctx.cwd, EXTENSIONS)) {
5201
+ process.stderr.write(
5202
+ `sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS.join(", ")}).
5203
+ `
5204
+ );
5205
+ return { ok: true, code: 0 };
5206
+ }
5207
+ const { config, unreadable } = await loadStaticConfig(ctx.cwd);
5208
+ if (unreadable) {
5209
+ process.stderr.write(`sentinel static-analysis(jscpd): ${unreadable}
5210
+ `);
5211
+ return { ok: false, code: 1 };
5212
+ }
5213
+ const kinds = config.duplication.kinds;
5214
+ if (kinds.length !== 1 || kinds[0] !== "exact") {
5215
+ process.stderr.write(
5216
+ `sentinel static-analysis(jscpd): duplication.kinds is ${JSON.stringify(kinds)}, and only ["exact"] is supported. Any other kind overlaps the first, and the duplicated-line share then counts the same lines twice.
5217
+ `
5218
+ );
5219
+ return { ok: false, code: 1 };
5220
+ }
5221
+ const jscpd = resolveJscpd(ctx.cwd);
5222
+ if (!jscpd) {
5223
+ process.stderr.write(
5224
+ `sentinel static-analysis(jscpd): could not find the jscpd binary (looked in ${binSearchPath(ctx.cwd)}). Run \`pnpm install\` in the module.
5225
+ `
5226
+ );
5227
+ return { ok: false, code: 1 };
5228
+ }
5229
+ const { paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, { valueFlags: ["-c", "--config"] });
5230
+ const scanned = paths.length > 0 ? paths : ["."];
5231
+ const product = runPass(jscpd, config, scanned, "product", ctx.cwd);
5232
+ if (product.kind === "failed") return { ok: false, code: product.code };
5233
+ if (product.kind === "empty") {
5234
+ process.stderr.write(
5235
+ `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).
5236
+ `
5237
+ );
5238
+ return { ok: true, code: 0 };
5239
+ }
5240
+ const tests = runPass(jscpd, config, scanned, "tests", ctx.cwd);
5241
+ if (tests.kind === "failed") return { ok: false, code: tests.code };
5242
+ const dead = runDeadCode(jscpd, config, scanned, ctx.cwd);
5243
+ if (dead.kind === "failed") return { ok: false, code: dead.code };
5244
+ if (dead.kind === "no-entry-point") {
5245
+ process.stderr.write(
5246
+ `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.
5247
+ `
5248
+ );
5249
+ }
5250
+ const report = assemble(ctx, config, product, tests, dead);
5251
+ if (emitting) emit(report, shape, ctx.cwd);
5252
+ return { ok: true, code: 0, metrics: metricsOf(report) };
5253
+ }
5254
+ /** `--run`: measure and show it. */
5255
+ async run(ctx) {
5256
+ return this.measure(ctx, true);
5257
+ }
5258
+ /**
5259
+ * `--inspect`: the settings that apply here, IN FULL.
5260
+ *
5261
+ * It exists because of the other half of documenting an override. The config stub lists what a
5262
+ * module can move, but a list it summarises as "19 patterns" cannot be copied from, and
5263
+ * `overrides` REPLACES a list rather than extending it: a developer who wants one more exclusion
5264
+ * has to write the current ones back, so they need to read them. This is where.
5265
+ *
5266
+ * ⚠️ `--run` answers a narrower question, on purpose. The report prints the settings its FIGURES
5267
+ * came from, which is what makes two modules comparable; this prints everything that applies,
5268
+ * including what no pass has used yet. The two are not interchangeable.
5269
+ */
5270
+ async inspect(ctx) {
5271
+ const { config, from, unreadable } = await loadStaticConfig(ctx.cwd);
5272
+ const roleValue = (path) => path.split(".").reduce(
5273
+ (node, key) => node && typeof node === "object" ? node[key] : void 0,
5274
+ defaults
5275
+ );
5276
+ return {
5277
+ runner: this.runner,
5278
+ configFile: from,
5279
+ ...unreadable ? { unreadable } : {},
5280
+ reportDir: REPORT_DIR,
5281
+ // The resolved values, not the role's: what this module measures with.
5282
+ settings: {
5283
+ entry: config.entry,
5284
+ ignore: config.ignore,
5285
+ languages: config.languages,
5286
+ journeys: config.journeys,
5287
+ duplication: config.duplication,
5288
+ deadCode: config.deadCode
5289
+ },
5290
+ // What this module moved, with the role's value beside it, so the difference is readable
5291
+ // without opening the package.
5292
+ overrides: config.moved.map((path) => ({
5293
+ setting: path,
5294
+ module: roleValue(path) === void 0 ? "set" : JSON.stringify(configValue(config, path)),
5295
+ role: JSON.stringify(roleValue(path))
5296
+ })),
5297
+ // What the two passes will actually do with the globs, which is where a mistaken override
5298
+ // shows up first: a product pass excluding nothing, or a test pass selecting nothing.
5299
+ passes: {
5300
+ product: { excludes: exclusions(config, "product").length },
5301
+ tests: { excludes: exclusions(config, "tests").length, selects: config.duplication.tests }
5302
+ }
5303
+ };
5304
+ }
5305
+ /**
5306
+ * `--report`: the same figures, for a machine.
5307
+ *
5308
+ * It writes nothing and prints nothing: the caller is the `--json` envelope or the CI comment,
5309
+ * and a role that also printed its table would make the same numbers arrive twice in two shapes.
5310
+ */
5311
+ async report(ctx) {
5312
+ return this.measure(ctx, false);
5313
+ }
5314
+ /**
5315
+ * Adoption, from the COMMITTED manifest only, so a workspace-wide sweep stays cheap and needs no
5316
+ * install. The script is the marker, which is also how the migration page follows this role.
5317
+ */
5318
+ async status(ctx) {
5319
+ const declared = moduleScripts(ctx.cwd);
5320
+ const missing = Object.keys(scripts()).filter((name) => !(name in declared));
5321
+ const adopted = missing.length < Object.keys(scripts()).length;
5322
+ const hasConfig = existsSync14(join19(ctx.cwd, STATIC_CONFIG_FILE));
5323
+ return {
5324
+ adopted,
5325
+ preset: adopted ? ctx.preset : null,
5326
+ conformant: adopted && missing.length === 0 && hasConfig,
5327
+ drift: [
5328
+ ...missing.map((name) => `missing script: ${name}`),
5329
+ ...adopted && !hasConfig ? [`missing ${STATIC_CONFIG_FILE}`] : []
5330
+ ]
5331
+ };
5332
+ }
5333
+ };
5334
+ function analysedNothing(stderr) {
5335
+ return typeof stderr === "string" && stderr.includes("--fail-on-empty");
5336
+ }
5337
+ function duplicationSettings(config) {
5338
+ const moved = (key) => config.moved.some((path) => path === key || path.startsWith(`${key}.`));
5339
+ const row = (key, value) => ({ key, value, moved: moved(key) });
5340
+ return [
5341
+ row("duplication.minTokens", String(config.duplication.minTokens)),
5342
+ row("duplication.minLines", String(config.duplication.minLines)),
5343
+ row("duplication.kinds", config.duplication.kinds.join(", ")),
5344
+ row("languages", `${config.languages.length}: ${config.languages.join(", ")}`),
5345
+ // The count the SCAN received, not the module's list: the role unions its own artefacts in,
5346
+ // and printing 20 while measuring with 22 is the kind of small lie that costs an afternoon.
5347
+ // The product pass, which is the one the headline figure comes from.
5348
+ row("ignore", `${exclusions(config, "product").length} patterns`),
5349
+ row("duplication.tests", config.duplication.tests.join(" ")),
5350
+ // The dead-code pass's own two, and the reason they are in the same block: a reader comparing
5351
+ // two modules' unreachable share needs the confidence floor as much as the detection window.
5352
+ row("deadCode.minConfidence", String(config.deadCode.minConfidence)),
5353
+ row("deadCode.categories", config.deadCode.categories.join(", ")),
5354
+ row("entry", `${config.entry.length} patterns`)
5355
+ ];
5356
+ }
5357
+ function runDeadCode(jscpd, config, paths, cwd) {
5358
+ const { args, rawReportDir, dispose } = deadCodeRun(config, paths);
5359
+ try {
5360
+ const result = spawnSync5(jscpd, args, { cwd, encoding: "utf8" });
5361
+ if (result.error) {
5362
+ process.stderr.write(
5363
+ `sentinel static-analysis(jscpd): could not run the dead-code pass (${result.error.message})
5364
+ `
5365
+ );
5366
+ return { kind: "failed", code: 1 };
5367
+ }
5368
+ if (result.status !== 0) {
5369
+ if (analysedNothing(result.stderr)) return { kind: "empty" };
5370
+ process.stderr.write(
5371
+ `sentinel static-analysis(jscpd): the dead-code pass exited ${result.status}
5372
+ ${result.stderr ?? ""}`
5373
+ );
5374
+ return { kind: "failed", code: result.status ?? 1 };
5375
+ }
5376
+ const path = join19(rawReportDir, RAW_REPORT["dead-code"]);
5377
+ if (!existsSync14(path)) {
5378
+ process.stderr.write(
5379
+ `sentinel static-analysis(jscpd): the dead-code pass wrote no ${RAW_REPORT["dead-code"]}
5380
+ `
5381
+ );
5382
+ return { kind: "failed", code: 1 };
5383
+ }
5384
+ let parsed;
5385
+ try {
5386
+ parsed = JSON.parse(readFileSync14(path, "utf8"));
5387
+ } catch {
5388
+ process.stderr.write(`sentinel static-analysis(jscpd): the dead-code report does not parse
5389
+ `);
5390
+ return { kind: "failed", code: 1 };
5391
+ }
5392
+ const statistics = parsed.statistics;
5393
+ if (!statistics) {
5394
+ process.stderr.write(`sentinel static-analysis(jscpd): the dead-code report has no figures
5395
+ `);
5396
+ return { kind: "failed", code: 1 };
5397
+ }
5398
+ if ((statistics.entryPoints ?? 0) === 0) {
5399
+ return { kind: "no-entry-point", files: statistics.files ?? 0 };
5400
+ }
5401
+ const byCategory = {};
5402
+ for (const finding of parsed.findings ?? []) {
5403
+ const category = finding.category ?? "unknown";
5404
+ byCategory[category] = (byCategory[category] ?? 0) + 1;
5405
+ }
5406
+ return { kind: "figures", statistics, byCategory };
5407
+ } finally {
5408
+ dispose();
5409
+ }
5410
+ }
5411
+ function runPass(jscpd, config, paths, scope, cwd) {
5412
+ const { args, rawReportDir, dispose } = duplicationRun(config, paths, scope);
5413
+ try {
5414
+ const result = spawnSync5(jscpd, args, { cwd, encoding: "utf8" });
5415
+ if (result.error) {
5416
+ process.stderr.write(
5417
+ `sentinel static-analysis(jscpd): could not run the ${scope} pass (${result.error.message})
5418
+ `
5419
+ );
5420
+ return { kind: "failed", code: 1 };
5421
+ }
5422
+ if (result.status !== 0) {
5423
+ if (analysedNothing(result.stderr)) return { kind: "empty" };
5424
+ process.stderr.write(
5425
+ `sentinel static-analysis(jscpd): the ${scope} pass exited ${result.status}
5426
+ ${result.stderr ?? ""}`
5427
+ );
5428
+ return { kind: "failed", code: result.status ?? 1 };
5429
+ }
5430
+ const path = join19(rawReportDir, RAW_REPORT.duplication);
5431
+ const parsed = existsSync14(path) ? safeParse(readFileSync14(path, "utf8")) : void 0;
5432
+ const total = parsed?.statistics?.total;
5433
+ if (!total) {
5434
+ process.stderr.write(
5435
+ `sentinel static-analysis(jscpd): the ${scope} pass wrote no readable report
5436
+ `
5437
+ );
5438
+ return { kind: "failed", code: 1 };
5439
+ }
5440
+ return { kind: "figures", total, formats: parsed?.statistics?.formats ?? {} };
5441
+ } finally {
5442
+ dispose();
5443
+ }
5444
+ }
5445
+ function safeParse(text) {
5446
+ try {
5447
+ return JSON.parse(text);
5448
+ } catch {
5449
+ return void 0;
5450
+ }
5451
+ }
5452
+ function assemble(ctx, config, product, tests, dead) {
5453
+ const share = (total) => Number((total.percentage ?? 0).toFixed(2));
5454
+ const rows = [
5455
+ { metric: "duplicatedLinesPercent", value: share(product.total), unit: "%" },
5456
+ { metric: "clones", value: product.total.clones ?? 0, unit: "entries" }
5457
+ ];
5458
+ const notes = [];
5459
+ if (tests.kind !== "figures") {
5460
+ notes.push("no test file to measure, so no test duplication figure is reported");
5461
+ }
5462
+ if (dead.kind === "no-entry-point") {
5463
+ notes.push(
5464
+ `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\``
5465
+ );
5466
+ }
5467
+ if (dead.kind === "empty") {
5468
+ notes.push("the dead-code pass had nothing left to analyse, so no figure is reported");
5469
+ }
5470
+ if (tests.kind === "figures") {
5471
+ rows.push(
5472
+ { metric: "duplicatedLinesPercentTests", value: share(tests.total), unit: "%" },
5473
+ { metric: "clonesTests", value: tests.total.clones ?? 0, unit: "entries" }
5474
+ );
5475
+ }
5476
+ if (dead.kind === "figures") {
5477
+ const stats = dead.statistics;
5478
+ rows.push(
5479
+ {
5480
+ metric: "entryPoints",
5481
+ value: stats.entryPoints ?? 0,
5482
+ unit: `of ${stats.files ?? 0} files`
5483
+ },
5484
+ { metric: "deadLinesPercent", value: share(stats), unit: "%" },
5485
+ { metric: "unusedFiles", value: dead.byCategory["unused-file"] ?? 0, unit: "files" },
5486
+ { metric: "unusedExports", value: dead.byCategory["unused-export"] ?? 0, unit: "exports" },
5487
+ { metric: "unusedSymbols", value: dead.byCategory["unused-symbol"] ?? 0, unit: "symbols" },
5488
+ { metric: "unusedImports", value: dead.byCategory["unused-import"] ?? 0, unit: "imports" }
5489
+ );
5490
+ }
5491
+ const byLanguage = Object.fromEntries(
5492
+ Object.entries(product.formats).map(([language, stats]) => [
5493
+ language,
5494
+ {
5495
+ files: stats.sources ?? 0,
5496
+ duplicatedLines: stats.duplicatedLines ?? 0,
5497
+ percentage: Number((stats.percentage ?? 0).toFixed(2))
5498
+ }
5499
+ ])
5500
+ );
5501
+ return {
5502
+ module: ctx.module,
5503
+ passes: [
5504
+ "duplication",
5505
+ ...tests.kind === "figures" ? ["duplication (tests)"] : [],
5506
+ ...dead.kind === "figures" ? ["dead code"] : []
5507
+ ],
5508
+ rows,
5509
+ notes,
5510
+ settings: duplicationSettings(config),
5511
+ byLanguage
5512
+ };
5513
+ }
5514
+ function emit(report, shape, cwd) {
5515
+ if (shape === "console") {
5516
+ process.stdout.write(renderConsole(report));
5517
+ return;
5518
+ }
5519
+ const name = shape === "json" ? "static-analysis.json" : "static-analysis.html";
5520
+ const body = shape === "json" ? renderJson(report) : renderHtml(report);
5521
+ mkdirSync(join19(cwd, REPORT_DIR), { recursive: true });
5522
+ writeFileSync4(join19(cwd, REPORT_DIR, name), body, "utf8");
5523
+ process.stdout.write(`${renderConsole(report)}
5524
+ written: ${REPORT_DIR}/${name}
5525
+ `);
5526
+ }
5527
+ function metricsOf(report) {
5528
+ return {
5529
+ passes: report.passes,
5530
+ // Carried for the same reason they are printed: a consumer reading an absent figure needs to
5531
+ // know whether it is clean or unmeasurable.
5532
+ notes: report.notes,
5533
+ ...Object.fromEntries(report.rows.map((row) => [row.metric, row.value])),
5534
+ // Carried into `--json` and the CI comment for the same reason the console prints them: a
5535
+ // figure whose settings are unknown cannot be compared with the next module's.
5536
+ settings: Object.fromEntries(
5537
+ report.settings.map((s) => [s.key, { value: s.value, moved: s.moved }])
5538
+ ),
5539
+ byLanguage: report.byLanguage
5540
+ };
5541
+ }
5542
+
5543
+ // src/roles/static-analysis/register.ts
5544
+ function registerStaticAnalysis() {
5545
+ register(new JscpdAdapter());
5546
+ setDefaultRunner("static-analysis", "jscpd");
5547
+ }
5548
+
4740
5549
  // src/roles/test/register.ts
4741
5550
  function registerTest() {
4742
5551
  register(new VitestAdapter());
@@ -4744,10 +5553,10 @@ function registerTest() {
4744
5553
  }
4745
5554
 
4746
5555
  // src/roles/typescript/adapters/tsc/tsc.adapter.ts
4747
- import { spawnSync as spawnSync5 } from "node:child_process";
4748
- import { existsSync as existsSync13, readFileSync as readFileSync15 } from "node:fs";
5556
+ import { spawnSync as spawnSync6 } from "node:child_process";
5557
+ import { existsSync as existsSync15, readFileSync as readFileSync16 } from "node:fs";
4749
5558
  import { createRequire } from "node:module";
4750
- import { join as join18 } from "node:path";
5559
+ import { join as join21 } from "node:path";
4751
5560
 
4752
5561
  // src/roles/typescript/presets/nest.json
4753
5562
  var nest_default2 = {
@@ -4900,7 +5709,11 @@ function localOnlyCompilerOptions(compilerOptions, preset) {
4900
5709
 
4901
5710
  // src/roles/typescript/resolve-tsconfig-target.ts
4902
5711
  var TSCONFIG_CANDIDATES = ["tsconfig.app.json", "tsconfig.json"];
4903
- var TSCONFIG_MARKERS = ["tsconfig.base.json", "@hublo/sentinel/tsconfig/"];
5712
+ var TSCONFIG_MARKERS = [
5713
+ "tsconfig.base.json",
5714
+ "@hublo/sentinel/typescript/",
5715
+ "@hublo/sentinel/tsconfig/"
5716
+ ];
4904
5717
  var TSCONFIG_FALLBACK = "tsconfig.json";
4905
5718
  function resolveTsconfigTarget(moduleDir) {
4906
5719
  return resolveConfigTarget(moduleDir, {
@@ -4928,8 +5741,8 @@ function readTsconfigAdoption(cwd) {
4928
5741
  }
4929
5742
 
4930
5743
  // src/roles/typescript/adapters/tsc/plan.ts
4931
- import { readFileSync as readFileSync14 } from "node:fs";
4932
- import { join as join17 } from "node:path";
5744
+ import { readFileSync as readFileSync15 } from "node:fs";
5745
+ import { join as join20 } from "node:path";
4933
5746
 
4934
5747
  // src/roles/typescript/typecheck-script.ts
4935
5748
  var SENTINEL_TYPECHECK_COMMAND = "sentinel --run --typescript";
@@ -5022,7 +5835,7 @@ function planAdoption(context) {
5022
5835
  };
5023
5836
  }
5024
5837
  const existing = parseJsonc(
5025
- readFileSync14(join17(context.cwd, target.path), "utf8"),
5838
+ readFileSync15(join20(context.cwd, target.path), "utf8"),
5026
5839
  target.path
5027
5840
  );
5028
5841
  const extendsChain = composeExtends(existing.extends, preset);
@@ -5160,7 +5973,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5160
5973
  let chain;
5161
5974
  try {
5162
5975
  const parsed = parseJsonc(
5163
- readFileSync15(join18(cwd, target.path), "utf8"),
5976
+ readFileSync16(join21(cwd, target.path), "utf8"),
5164
5977
  target.path
5165
5978
  );
5166
5979
  chain = parsed.extends;
@@ -5173,7 +5986,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5173
5986
  );
5174
5987
  if (preset === void 0) return void 0;
5175
5988
  try {
5176
- createRequire(join18(cwd, "noop.js")).resolve(preset);
5989
+ createRequire(join21(cwd, "noop.js")).resolve(preset);
5177
5990
  return void 0;
5178
5991
  } catch {
5179
5992
  return preset;
@@ -5214,7 +6027,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5214
6027
  return { ok: false, code: 1 };
5215
6028
  }
5216
6029
  if (options.length > 0) return this.runWithOptions(ctx, tsc, config);
5217
- const result = spawnSync5(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
6030
+ const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
5218
6031
  if (result.error) {
5219
6032
  process.stderr.write(
5220
6033
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5249,7 +6062,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5249
6062
  let worst = 0;
5250
6063
  for (const project of this.referencedProjects(ctx.cwd, config)) {
5251
6064
  const args = ["-p", project, "--noEmit", "--composite", "false", ...ctx.toolArgs ?? []];
5252
- const result = spawnSync5(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
6065
+ const result = spawnSync6(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
5253
6066
  if (result.error) {
5254
6067
  process.stderr.write(
5255
6068
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5269,7 +6082,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5269
6082
  referencedProjects(cwd, config) {
5270
6083
  try {
5271
6084
  const parsed = parseJsonc(
5272
- readFileSync15(join18(cwd, config), "utf8"),
6085
+ readFileSync16(join21(cwd, config), "utf8"),
5273
6086
  config
5274
6087
  );
5275
6088
  const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path) => typeof path === "string" && path.length > 0);
@@ -5285,7 +6098,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5285
6098
  * check.
5286
6099
  */
5287
6100
  typecheckTarget(cwd) {
5288
- if (existsSync13(join18(cwd, "tsconfig.json"))) return "tsconfig.json";
6101
+ if (existsSync15(join21(cwd, "tsconfig.json"))) return "tsconfig.json";
5289
6102
  const target = resolveTsconfigTarget(cwd);
5290
6103
  return target.reason === "none" ? null : target.path;
5291
6104
  }
@@ -5338,7 +6151,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5338
6151
  return { ok: true, code: 0, metrics: _TscAdapter.NOTHING_TO_REPORT };
5339
6152
  }
5340
6153
  const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
5341
- const result = spawnSync5(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
6154
+ const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
5342
6155
  if (result.error) {
5343
6156
  process.stderr.write(
5344
6157
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5381,11 +6194,11 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5381
6194
  * errors in the output mean the rule must be on.
5382
6195
  */
5383
6196
  noImplicitAnyEnabled(cwd, tsc, config, mainOutput) {
5384
- const shown = spawnSync5(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
5385
- if (shown.status === 0 && shown.stdout) {
6197
+ const shown2 = spawnSync6(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
6198
+ if (shown2.status === 0 && shown2.stdout) {
5386
6199
  try {
5387
6200
  const co = parseJsonc(
5388
- shown.stdout,
6201
+ shown2.stdout,
5389
6202
  "tsconfig(--showConfig)"
5390
6203
  ).compilerOptions;
5391
6204
  if (co) return co.noImplicitAny ?? co.strict === true;
@@ -5409,6 +6222,7 @@ function registerAdapters() {
5409
6222
  registerFormat();
5410
6223
  registerBuild();
5411
6224
  registerTest();
6225
+ registerStaticAnalysis();
5412
6226
  }
5413
6227
 
5414
6228
  // src/core/detect-framework.ts
@@ -5490,16 +6304,9 @@ function replaceLines(current, replacements) {
5490
6304
  }
5491
6305
 
5492
6306
  // src/core/apply-plan.ts
5493
- import { existsSync as existsSync14, readFileSync as readFileSync16, renameSync, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
6307
+ import { existsSync as existsSync16, readFileSync as readFileSync17, renameSync, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
5494
6308
  import { resolve as resolve3, sep } from "node:path";
5495
6309
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
5496
-
5497
- // src/shared/deep-merge.ts
5498
- function isPlainObject(value) {
5499
- return typeof value === "object" && value !== null && !Array.isArray(value);
5500
- }
5501
-
5502
- // src/core/apply-plan.ts
5503
6310
  function resolveWithinRoot(cwd, relativePath) {
5504
6311
  const root = resolve3(cwd);
5505
6312
  const absolutePath = resolve3(root, relativePath);
@@ -5509,7 +6316,7 @@ function resolveWithinRoot(cwd, relativePath) {
5509
6316
  return absolutePath;
5510
6317
  }
5511
6318
  function readIfExists(absolutePath) {
5512
- return existsSync14(absolutePath) ? readFileSync16(absolutePath, "utf8") : void 0;
6319
+ return existsSync16(absolutePath) ? readFileSync17(absolutePath, "utf8") : void 0;
5513
6320
  }
5514
6321
  function* leaves(value, prefix = []) {
5515
6322
  for (const [key, keyValue] of Object.entries(value)) {
@@ -5611,14 +6418,14 @@ function preparePlan(cwd, plan3) {
5611
6418
  }
5612
6419
  function writeFileAtomic(absolutePath, contents) {
5613
6420
  const tempPath = `${absolutePath}.sentinel-${process.pid}.tmp`;
5614
- writeFileSync3(tempPath, contents);
6421
+ writeFileSync5(tempPath, contents);
5615
6422
  renameSync(tempPath, absolutePath);
5616
6423
  }
5617
6424
  function applyPlan(cwd, plan3) {
5618
6425
  const changed = preparePlan(cwd, plan3).filter((file) => file.before !== file.after);
5619
6426
  for (const file of changed) {
5620
6427
  if (file.deleted) {
5621
- rmSync3(file.absolutePath, { force: true });
6428
+ rmSync4(file.absolutePath, { force: true });
5622
6429
  continue;
5623
6430
  }
5624
6431
  writeFileAtomic(file.absolutePath, file.after);
@@ -5627,8 +6434,8 @@ function applyPlan(cwd, plan3) {
5627
6434
  }
5628
6435
 
5629
6436
  // src/core/config/preset-evidence.ts
5630
- import { existsSync as existsSync15, readdirSync as readdirSync3, readFileSync as readFileSync17 } from "node:fs";
5631
- import { join as join19 } from "node:path";
6437
+ import { existsSync as existsSync17, readdirSync as readdirSync3, readFileSync as readFileSync18 } from "node:fs";
6438
+ import { join as join22 } from "node:path";
5632
6439
  var PATH_SIGNALS = [
5633
6440
  {
5634
6441
  preset: "nest",
@@ -5662,12 +6469,12 @@ function sourceImports(cwd, pattern) {
5662
6469
  }
5663
6470
  for (const entry of entries) {
5664
6471
  if (entry.isDirectory()) {
5665
- if (!NOT_SCANNED.has(entry.name)) stack.push(join19(dir, entry.name));
6472
+ if (!NOT_SCANNED.has(entry.name)) stack.push(join22(dir, entry.name));
5666
6473
  continue;
5667
6474
  }
5668
6475
  if (!SOURCE_FILE.test(entry.name)) continue;
5669
6476
  try {
5670
- if (pattern.test(readFileSync17(join19(dir, entry.name), "utf8"))) return true;
6477
+ if (pattern.test(readFileSync18(join22(dir, entry.name), "utf8"))) return true;
5671
6478
  } catch {
5672
6479
  continue;
5673
6480
  }
@@ -5676,11 +6483,11 @@ function sourceImports(cwd, pattern) {
5676
6483
  return false;
5677
6484
  }
5678
6485
  function dependencyNames(cwd) {
5679
- const path = join19(cwd, "package.json");
5680
- if (!existsSync15(path)) return [];
6486
+ const path = join22(cwd, "package.json");
6487
+ if (!existsSync17(path)) return [];
5681
6488
  try {
5682
6489
  const manifest = parseJsonc(
5683
- readFileSync17(path, "utf8"),
6490
+ readFileSync18(path, "utf8"),
5684
6491
  path
5685
6492
  );
5686
6493
  return [
@@ -5703,7 +6510,7 @@ function reactTsconfigEvidence(cwd) {
5703
6510
  }
5704
6511
  for (const name of entries) {
5705
6512
  try {
5706
- const config = parseJsonc(readFileSync17(join19(cwd, name), "utf8"), name);
6513
+ const config = parseJsonc(readFileSync18(join22(cwd, name), "utf8"), name);
5707
6514
  if (config.compilerOptions?.jsx !== void 0) return 'its tsconfig sets "jsx"';
5708
6515
  const extended = Array.isArray(config.extends) ? config.extends : config.extends === void 0 ? [] : [config.extends];
5709
6516
  if (extended.some((entry) => entry === REACT_TYPESCRIPT_PRESET)) {