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