@hublo/sentinel 1.5.0-alpha.26 → 1.5.0-alpha.28
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 +7 -7
- package/dist/{chunk-WDZRBHMH.js → chunk-BYV4XD5W.js} +105 -48
- package/dist/{chunk-K6MEJRFG.js → chunk-GCHCLR7P.js} +7 -2
- package/dist/chunk-GCHCLR7P.js.map +1 -0
- package/dist/{chunk-ZMVI65FF.js → chunk-OS5TMEKH.js} +1 -0
- package/dist/index.js +3 -3
- package/dist/roles/static-analysis/config.d.ts +12 -1
- package/dist/roles/static-analysis/config.js +1 -1
- package/dist/{validate-AOJGDBHA.js → validate-3KEHAU66.js} +1 -1
- package/docs/bundle-analysis-spec.md +8 -8
- package/docs/static-analysis-adoption.md +44 -12
- package/docs/static-analysis-study.md +68 -24
- package/docs/typescript-traces.md +1 -5
- package/docs/using-sentinel.md +14 -11
- package/package.json +1 -1
- package/dist/chunk-K6MEJRFG.js.map +0 -1
package/dist/bin/sentinel.js
CHANGED
|
@@ -15,10 +15,10 @@ import {
|
|
|
15
15
|
palette,
|
|
16
16
|
registerAdapters,
|
|
17
17
|
resolve
|
|
18
|
-
} from "../chunk-
|
|
18
|
+
} from "../chunk-BYV4XD5W.js";
|
|
19
19
|
import {
|
|
20
20
|
resolveContext
|
|
21
|
-
} from "../chunk-
|
|
21
|
+
} from "../chunk-OS5TMEKH.js";
|
|
22
22
|
import {
|
|
23
23
|
ensureWorkspacePrep,
|
|
24
24
|
findWorkspaceRoot,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
readOwnVersion,
|
|
27
27
|
readProjectPackageJson
|
|
28
28
|
} from "../chunk-JEYGPPSL.js";
|
|
29
|
-
import "../chunk-
|
|
29
|
+
import "../chunk-GCHCLR7P.js";
|
|
30
30
|
import "../chunk-O7REVMOC.js";
|
|
31
31
|
import "../chunk-QXFCZON7.js";
|
|
32
32
|
|
|
@@ -148,7 +148,7 @@ async function runValidate(ctx) {
|
|
|
148
148
|
);
|
|
149
149
|
}
|
|
150
150
|
if (!ctx.targets.includes("test")) return 1;
|
|
151
|
-
const { validateModuleTests } = await import("../validate-
|
|
151
|
+
const { validateModuleTests } = await import("../validate-3KEHAU66.js");
|
|
152
152
|
const outcomes = modules.flatMap(
|
|
153
153
|
(module) => validateModuleTests(module.name, module.root)
|
|
154
154
|
);
|
|
@@ -583,8 +583,8 @@ var TARGET_HELP = {
|
|
|
583
583
|
build: "build",
|
|
584
584
|
dev: "dev server (long-running: never swept by an unqualified --run)",
|
|
585
585
|
test: "tests",
|
|
586
|
-
"static-analysis": "
|
|
587
|
-
"runtime-analysis": "
|
|
586
|
+
"static-analysis": "duplication, dead code, complexity, import graph, file size, bundle weight",
|
|
587
|
+
"runtime-analysis": "Lighthouse, web vitals",
|
|
588
588
|
arch: "architecture boundaries"
|
|
589
589
|
};
|
|
590
590
|
program.name("sentinel").description("One CLI that guards code health: presets, analysis, and arch checks.").version(readOwnVersion()).configureHelp({ sortOptions: false }).showSuggestionAfterError(true).showHelpAfterError('(run "sentinel --help" for usage)').addHelpText(
|
|
@@ -605,7 +605,7 @@ program.name("sentinel").description("One CLI that guards code health: presets,
|
|
|
605
605
|
"from the workspace root: scope to one module (omit = all; inside a module dir, drop this)"
|
|
606
606
|
).option("--preset <name>", `the stack preset to apply (${PRESET_NAMES.join(", ")})`).option("--flavour <name>", "deprecated: use --preset").option("--runner <tool>", "override the default runner (e.g. eslint, biome)").option("--ci", "CI mode: from the root, only the affected modules; non-zero exit on failure").option("--fix", "auto-fix where applicable").option("--dry-run", "preview the changes without writing (--init)").option("--json", "machine-readable output, for every verb").option(
|
|
607
607
|
"--output <shape>",
|
|
608
|
-
"output shape for a role that writes reports (console, json, html); console by default"
|
|
608
|
+
"output shape for a role that writes reports (console, json, html, markdown); console by default"
|
|
609
609
|
).option(
|
|
610
610
|
"--max-diagnostics <n>",
|
|
611
611
|
"cap the diagnostics embedded per module in --report (0 = no cap)",
|
|
@@ -52,8 +52,9 @@ import {
|
|
|
52
52
|
rewriteToolchainImports,
|
|
53
53
|
selfCommand,
|
|
54
54
|
splitToolArgs,
|
|
55
|
-
walk
|
|
56
|
-
|
|
55
|
+
walk,
|
|
56
|
+
writtenFileFormatter
|
|
57
|
+
} from "./chunk-OS5TMEKH.js";
|
|
57
58
|
import {
|
|
58
59
|
DEFAULT_MAX_DIAGNOSTICS,
|
|
59
60
|
WORKSPACE_ROOT_MARKER,
|
|
@@ -73,7 +74,7 @@ import {
|
|
|
73
74
|
extensionsOf,
|
|
74
75
|
isPlainObject,
|
|
75
76
|
overridableSettings
|
|
76
|
-
} from "./chunk-
|
|
77
|
+
} from "./chunk-GCHCLR7P.js";
|
|
77
78
|
import {
|
|
78
79
|
parseJsonc
|
|
79
80
|
} from "./chunk-QXFCZON7.js";
|
|
@@ -5102,7 +5103,7 @@ function duplicationRun(config, paths, scope) {
|
|
|
5102
5103
|
// exactly one glob, braces expanded. Verified: 478 product files + 87 test files = the 565 of
|
|
5103
5104
|
// a blended scan on `apps/front/console`.
|
|
5104
5105
|
...scope === "tests" ? ["--pattern", testGlobs(config).pattern] : [],
|
|
5105
|
-
// Always json, and only json, whatever shape the caller asked for. The
|
|
5106
|
+
// Always json, and only json, whatever shape the caller asked for. The four shapes are
|
|
5106
5107
|
// rendered by this role from the figures, not by the tool: one report lives under
|
|
5107
5108
|
// `static-analysis`, assembled from several passes, so a per-pass console table from the tool
|
|
5108
5109
|
// would be a fourth voice saying the same numbers differently. `--output` below is jscpd's
|
|
@@ -5380,7 +5381,7 @@ var actionable = {
|
|
|
5380
5381
|
},
|
|
5381
5382
|
bundleHeaviestAsset: {
|
|
5382
5383
|
label: "Heaviest single asset",
|
|
5383
|
-
what: "The largest
|
|
5384
|
+
what: "The largest file an ENTRY REFERENCES, of its type, decompressed. \u26D4 Not the largest file emitted: a chunk loaded lazily is referenced by no entry, so it never appears here however heavy it is. Measured on `apps/front/console`, the heaviest unreferenced chunk is 233 KB, 2.3 times the per-script reference; it is inside the dynamic layer figure instead.",
|
|
5384
5385
|
read: "Higher is worse, against 100 KB for one script, which the platform documentation calls a good target, and against the engine team\u2019s own advice to split a bundle once it passes roughly 50 to 100 kB. " + BANDS2,
|
|
5385
5386
|
caveat: "A large chunk is not automatically a defect. A third-party chunk every entry shares is legitimately big, and splitting it further has a cost of its own, which is the file-count row. It says nothing about whether those bytes are used, and nothing about whether this asset is on the critical path: an asset pulled by one rare screen and an asset pulled by all of them have the same weight here and not the same importance.",
|
|
5386
5387
|
source: "The 100 KB per-script target is published by the platform documentation on script evaluation; the 50 to 100 kB splitting advice is the engine team\u2019s, stated with HTTP/2 multiplexing assumed.",
|
|
@@ -5420,7 +5421,7 @@ var actionable = {
|
|
|
5420
5421
|
var context = {
|
|
5421
5422
|
bundleInitialFiles: {
|
|
5422
5423
|
label: "Initial load, files",
|
|
5423
|
-
what: "How many separate files the entry pulls.",
|
|
5424
|
+
what: "How many separate JAVASCRIPT files the entry pulls. \u26A0\uFE0F Stylesheets, fonts and images are not in it, and this page carries no figure for them.",
|
|
5424
5425
|
read: "A direction rather than a target, and it sits between two opposite costs: too few means one large file that cannot be cached or replaced independently, too many means the browser spends longer discovering what the page needs.",
|
|
5425
5426
|
caveat: '\u26D4 There is no threshold here and none is invented. The only published statement is a direction: a front that halved its bundles reported that "too many chunks would result in the browser spending significantly more time in discovering all the modules needed for the page", despite HTTP/2 multiplexing. So read this as context for the two weights, never as a verdict on its own.',
|
|
5426
5427
|
source: 'The direction is reported by a file-sharing front\u2019s own engineering write-up of a bundler migration, which also measured a 33% reduction in bundle sizes against only "modest" improvements in its own perceived-load metric. The count itself comes from the entry manifest.',
|
|
@@ -5434,7 +5435,7 @@ var context = {
|
|
|
5434
5435
|
},
|
|
5435
5436
|
bundleEntrySet: {
|
|
5436
5437
|
label: "Referenced by the entries",
|
|
5437
|
-
what: "The weight of everything some entry references statically, across all entries.",
|
|
5438
|
+
what: "The JAVASCRIPT weight of everything some entry references statically, across all entries. \u26A0\uFE0F Stylesheets, fonts and images are outside it.",
|
|
5438
5439
|
read: "\u26D4 Not a user cost: nobody downloads all of it. Read it as code-splitting health. A large figure beside a small initial load is splitting doing its job; a figure close to the initial load means one entry is carrying the whole application.",
|
|
5439
5440
|
caveat: '\u26A0\uFE0F This is deliberately NOT called "total emitted", because it is not the total: the build writes files no entry references, and on one front measured here that gap was 1326 files against 10 referenced. The dynamic layer above is that gap. Comparing this figure to a page-weight budget is the other trap, because a budget describes one page load and this describes an application. And summing the output directory would be worse than either: the server bundle and the per-brand assets live there, and no page load fetches them.',
|
|
5440
5441
|
source: "This role\u2019s own count over the bundler\u2019s entry manifest, which is the only artefact that states which emitted files an entry references."
|
|
@@ -5455,7 +5456,7 @@ var metricsHelp = {
|
|
|
5455
5456
|
label: "Complexity, worst function",
|
|
5456
5457
|
what: "McCabe's number for the single hardest function of the module's product code.",
|
|
5457
5458
|
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.",
|
|
5458
|
-
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
|
|
5459
|
+
caveat: "It says nothing about how MANY functions are hard, which is what the count beside it is for, and nothing about whether the hardest one is wrong: a parser or a state machine legitimately branches. It is also not what the linter refuses. The `nest` lint preset already fails a build at 30 per function, and the five other presets carry no complexity rule at all, so this figure is the only thing a LINT RUN tells those modules. \u26A0\uFE0F It is not their only visibility here: the size gauge runs in every module whatever its preset, so the same page carries a complexity count per file and a cognitive-complexity one below this row.",
|
|
5459
5460
|
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`."
|
|
5460
5461
|
},
|
|
5461
5462
|
complexityModerate: {
|
|
@@ -5488,9 +5489,9 @@ var metricsHelp = {
|
|
|
5488
5489
|
},
|
|
5489
5490
|
deadLinesPercent: {
|
|
5490
5491
|
label: "Unreachable lines",
|
|
5491
|
-
what: "The share of scanned lines the reachability walk never reached from an entry point, with the
|
|
5492
|
+
what: "The share of scanned lines the reachability walk never reached from an entry point, with the number of files that are unreachable IN FULL beside it. \u26A0\uFE0F The second number does not contain the first: dead lines also come from exports and symbols that are dead inside files the walk does reach, so 0 unreachable files and a non-zero share is a normal reading, not a contradiction.",
|
|
5492
5493
|
read: "\u26D4 Read the two denominators together or the figure misleads, and that is measured rather than feared. On `apps/nest/microservices/agency` the line share is 60.21%, which reads as a rotten module or a broken tool; neither is true. 490 of its 504 files are reachable, and ONE unreachable file, a seed data table, holds 52 210 lines, 59.3% of the module by itself. A large line share over a small file share means a generated or seed file, not code anyone writes. Higher is worse, and only once the entry-point count above says the question was decidable. Read it next to the four counts below rather than alone: one number covering files, exports, symbols and imports hides four different kinds of work.",
|
|
5493
|
-
caveat: "
|
|
5494
|
+
caveat: "\u26D4 This used to warn that a library's files look unreachable because its consumers live outside the scan. That is no longer how the walk starts: it is SEEDED with what other modules import from here, so a library has real entry points. Measured on `libs/front/components`, 473 entry points over 950 files and 8 unreachable files, against the 87 the unseeded walk reported. A finding here now means imported by nobody, anywhere in the workspace, so read it as real rather than as an artefact of the boundary. \u26A0\uFE0F Test, fixture and story files are also outside the reachability graph, verified on a fixture, so a file reached only by a story is reported unreachable; it is true of the product and false of the repository, and `libs/front/components` has one such file of 915 lines.",
|
|
5494
5495
|
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`."
|
|
5495
5496
|
},
|
|
5496
5497
|
unusedFiles: {
|
|
@@ -5566,7 +5567,7 @@ var metricsHelp = {
|
|
|
5566
5567
|
communities: {
|
|
5567
5568
|
label: "Communities",
|
|
5568
5569
|
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.",
|
|
5569
|
-
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
|
|
5570
|
+
read: "Read it against the module's own directories, which is the grouping the team wrote by hand. Expect it to be HIGHER: the partition refines the folders rather than reproducing them, measured at 24 communities for 16 domains on a nest service, and 89 for 10 modules on the largest front app. Held against the directories over five modules, each community sits inside one directory 58% to 85% of the time, and each directory splits across several communities, so the agreement is a refinement and not a match.",
|
|
5570
5571
|
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.",
|
|
5571
5572
|
source: "Louvain, via graphology-communities-louvain, with the role's own random seed so the figure reproduces. Blondel, Guillaume, Lambiotte and Lefebvre, 2008."
|
|
5572
5573
|
},
|
|
@@ -5580,63 +5581,63 @@ var metricsHelp = {
|
|
|
5580
5581
|
mostImported: {
|
|
5581
5582
|
label: "Most imported files",
|
|
5582
5583
|
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)`.",
|
|
5583
|
-
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
|
|
5584
|
+
read: "Context, not a defect list: every module has a most-imported file and a type definition used everywhere is doing its job. Read the three together, because that is what Martin made them for: a file many others depend on should itself depend on little, so a high `Ca` next to an `I` near 1 is the row to open. A change there reaches every dependent, and it is itself exposed to everything it pulls. Measured across the modules benched so far, this repository mostly satisfies that rule already: 23 of 30 most-imported rows sit at `I` of 0.06 or less, and the highest is 0.24. \u26A0\uFE0F Those are the figures of one bench on one day, not a property of the ranking.",
|
|
5584
5585
|
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.",
|
|
5585
5586
|
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.'
|
|
5586
5587
|
},
|
|
5587
5588
|
complexity: {
|
|
5588
5589
|
label: "Files with a complex function",
|
|
5589
5590
|
what: "Files holding at least one function whose cyclomatic complexity passes 10.",
|
|
5590
|
-
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.",
|
|
5591
|
+
read: "499 files of 8 083 across the repository, 6.2 per 100, fourth of the eight. Count of files, so it is comparable between modules only against their file count, which the unit carries. It is the same measure as the band rows above, seen per file rather than per function. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
|
|
5591
5592
|
caveat: "It does not say which function, nor whether branching is wrong there: a parser legitimately branches. \u26D4 This is a GAUGE and not the line your lint refuses at. It runs oxlint with its own config in a temporary directory, at a threshold fixed once for every module, and never reads the module's `.oxlintrc.json`. So it answers the same question the same way in every module, including the 146 of 198 that have not adopted lint, and it WILL disagree with a green build: the `nest` preset refuses `complexity` at 30 while this reports above 10.",
|
|
5592
5593
|
source: "oxlint complexity, above 10. The threshold comes from McCabe 1976, which puts the limit at 10, and from our own p90 at 11: the one indicator where two independent references agree within a unit. 94.8% of our functions already sit in the 1-10 band. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
|
|
5593
5594
|
},
|
|
5594
5595
|
"max-depth": {
|
|
5595
5596
|
label: "Files with deep nesting",
|
|
5596
5597
|
what: "Files holding a block nested more than three levels deep.",
|
|
5597
|
-
read: "The rarest of the
|
|
5598
|
+
read: "The rarest of the eight with `max-nested-callbacks`, 8 files of 8 083 across the repository, 0.1 per 100, so one occurrence is worth opening rather than filtering. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
|
|
5598
5599
|
caveat: "Depth is not complexity: four flat `if`s in a row never trip it, and one `if` inside three loops does.",
|
|
5599
5600
|
source: "oxlint max-depth, above 3. The threshold is IfSQ SP-2, which says few people can understand nesting of conditional statements beyond 3 levels, and our p90 agrees at 3. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
|
|
5600
5601
|
},
|
|
5601
5602
|
"max-lines": {
|
|
5602
5603
|
label: "Long files",
|
|
5603
5604
|
what: "Files longer than 150 lines, blank lines and comments included.",
|
|
5604
|
-
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.",
|
|
5605
|
+
read: "864 files of 8 083 across the repository, 10.7 per 100, the second most frequent of the eight. A size signal and nothing else. Read it beside the duplication figure: a long file that duplicates nothing is a different problem from a long file that repeats itself. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
|
|
5605
5606
|
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.",
|
|
5606
5607
|
source: "oxlint max-lines, above 150. The threshold is our p90 of 157 rounded down, because no research anywhere sets a FILE's length: IfSQ's three citations (500, 100-150, 143) are all about routines. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
|
|
5607
5608
|
},
|
|
5608
5609
|
"max-lines-per-function": {
|
|
5609
5610
|
label: "Files with a long function",
|
|
5610
5611
|
what: "Files holding at least one function longer than 109 lines.",
|
|
5611
|
-
read: "
|
|
5612
|
+
read: "630 files of 8 083 across the repository, 7.8 per 100, the third most frequent of the eight. \u26D4 It used to be held out of the report as a mere descriptor, on a measured break of a factor 4.5; that break was an artefact of a threshold of 50, where this one rule alone flagged 21.3% of all files and carried 77% of what the eight reported together. At 109 there is no break left and every indicator counts. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
|
|
5612
5613
|
caveat: "Length is a signal and not a defect: a long function can be a flat list of fields. Read it beside the statement count, which is the other half of the same question, and which it does NOT duplicate: measured here, 255 long functions breach no statement count and 699 dense ones are not long.",
|
|
5613
5614
|
source: "oxlint max-lines-per-function, above 109. The threshold is our p90. IfSQ SP-1 says 150, citing IBM on routines over 500 lines, and is discarded here for being more permissive than our own code. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
|
|
5614
5615
|
},
|
|
5615
5616
|
"max-statements": {
|
|
5616
5617
|
label: "Files with a long function body",
|
|
5617
5618
|
what: "Files holding at least one function of more than ten statements.",
|
|
5618
|
-
read: "
|
|
5619
|
+
read: "The most frequent of the eight: 891 files of 8 083, 11.0 per 100, against 10.7 for the next. It was held out of the report as a descriptor on a break that no longer exists; see the row above. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
|
|
5619
5620
|
caveat: "It counts statements and not work: ten calls in a row trip it where one dense expression does not.",
|
|
5620
5621
|
source: "oxlint max-statements, above 10. The threshold is the rule's own default, kept because our p90 of 15 is the looser of the two; it is not redundant with the line count, 699 functions breach it without being long and 255 are long without breaching it. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
|
|
5621
5622
|
},
|
|
5622
5623
|
"max-params": {
|
|
5623
5624
|
label: "Files with a wide signature",
|
|
5624
5625
|
what: "Files holding at least one function taking more than three parameters.",
|
|
5625
|
-
read: "
|
|
5626
|
+
read: "373 files of 8 083, 4.6 per 100, fifth of the eight. A wide signature is usually an object waiting to be named. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
|
|
5626
5627
|
caveat: "A destructured object parameter counts as one, so the figure does not see a function taking fifteen fields in one bag.",
|
|
5627
5628
|
source: "oxlint max-params, above 3. The threshold is the rule\u2019s own default, kept because our p90 of 4 is looser; beyond three parameters the fix is an object or currying, so this is a design boundary rather than a dial. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
|
|
5628
5629
|
},
|
|
5629
5630
|
"max-nested-callbacks": {
|
|
5630
5631
|
label: "Files with nested callbacks",
|
|
5631
5632
|
what: "Files holding callbacks nested more than three deep.",
|
|
5632
|
-
read: "
|
|
5633
|
+
read: '9 files of 8 083 across the repository, 0.1 per 100, the joint rarest with `max-depth`. \u26A0\uFE0F It read "zero everywhere measured so far" while the threshold sat at 10 and the deepest chain in the repository is 4, so it reported nothing for as long as it shipped; at 3 it finds those 9. A non-zero value is unambiguous. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.',
|
|
5633
5634
|
caveat: "It counts callbacks and not promise chains or awaits, so modern asynchronous code can be deeply sequential without ever tripping it.",
|
|
5634
5635
|
source: "oxlint max-nested-callbacks, above 3. The threshold is IfSQ SP-2 again, same nesting family, and our p90 agrees at 3. It sat at 10 while the deepest chain in the repository is 4, so it reported 0 files for as long as it shipped. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
|
|
5635
5636
|
},
|
|
5636
5637
|
"max-classes-per-file": {
|
|
5637
5638
|
label: "Files with several classes",
|
|
5638
5639
|
what: "Files declaring more than one class.",
|
|
5639
|
-
read: "
|
|
5640
|
+
read: "161 files of 8 083, 2.0 per 100. On a nest service it usually means a DTO file, which is a convention rather than a defect. A file is counted once per indicator, however many times the rule fires inside it. Measured on `libs/nest/auth`, counting diagnostics instead would have inflated the total by 1.8x, and by 2.8x at lower thresholds, because `max-nested-callbacks` reports once per level.",
|
|
5640
5641
|
caveat: "It says nothing about the size of the classes, and a file with one 800-line class never appears here.",
|
|
5641
5642
|
source: "oxlint max-classes-per-file, above 1. The threshold is the rule's floor, there is no stricter value to pick. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
|
|
5642
5643
|
},
|
|
@@ -5645,7 +5646,7 @@ var metricsHelp = {
|
|
|
5645
5646
|
what: "Files holding at least one function whose cognitive complexity passes 7.",
|
|
5646
5647
|
read: "The other complexity figure, and it answers a different question from McCabe's: not how many paths a test suite must cover, but how much a reader has to hold in their head. The two disagree on purpose. A flat `switch` with twelve cases is cyclomatically 13 and cognitively 1, because nothing is nested and nothing surprises; three `if`s inside each other are cyclomatically 4 and cognitively 6, because each level is charged for the levels above it. When a file is high here and low there, the code is tangled rather than merely branchy, and that is the one this role would act on first.",
|
|
5647
5648
|
caveat: "\u26A0\uFE0F In JavaScript and TypeScript, sequences of `||` and `??` do NOT increment, deliberately: in this language they are overwhelmingly default-value shorthand rather than control flow, and SonarSource made that exclusion universal in SonarQube 10.8. So a long chain of fallbacks scores zero here while `&&` chains are charged. \u26D4 And it is a GAUGE, not the line your lint refuses at: no preset in this repository enforces cognitive complexity at all today.",
|
|
5648
|
-
source: "Sonar's own `cognitive-complexity` rule from `eslint-plugin-sonarjs`, loaded into oxlint through `plugins/sonar-cognitive.js` and run in the same invocation as the size rules. It is USED and never copied: that package ships under the Sonar Source-Available License and sentinel is MIT. The algorithm is Campbell 2018 and its author works at SonarSource, so their implementation is the specification's own house rather than one reading of it; verified against Campbell's published example, which scores 7 in both. The threshold is 7, from `min(research, our p90, the rule's default)`: Sonar publishes 15 for S3776 and that is a vendor default rather than a research value, since one of their own engineers has said publicly that no formula supports it, while our p90 over the files the rule touches is 7. It reports 342 of 8 083 files on the bench, 4.2
|
|
5649
|
+
source: "Sonar's own `cognitive-complexity` rule from `eslint-plugin-sonarjs`, loaded into oxlint through `plugins/sonar-cognitive.js` and run in the same invocation as the size rules. It is USED and never copied: that package ships under the Sonar Source-Available License and sentinel is MIT. The algorithm is Campbell 2018 and its author works at SonarSource, so their implementation is the specification's own house rather than one reading of it; verified against Campbell's published example, which scores 7 in both. The threshold is 7, from `min(research, our p90, the rule's default)`: Sonar publishes 15 for S3776 and that is a vendor default rather than a research value, since one of their own engineers has said publicly that no formula supports it, while our p90 over the files the rule touches is 7. It reports 342 of 8 083 files on the bench, 4.2 per 100."
|
|
5649
5650
|
},
|
|
5650
5651
|
usedOnlyByTests: {
|
|
5651
5652
|
label: "Used only by tests",
|
|
@@ -5657,7 +5658,7 @@ var metricsHelp = {
|
|
|
5657
5658
|
measuredFiles: {
|
|
5658
5659
|
label: "Files the gauge read",
|
|
5659
5660
|
what: "How many product files the size indicators were measured over.",
|
|
5660
|
-
read: "The denominator of every size row
|
|
5661
|
+
read: "The denominator of every size row BELOW it: the gauge prints its population first, then the counts it divides. None of them compares between modules without it: three breaches over 50 files and three over 3 000 are not the same module.",
|
|
5661
5662
|
caveat: "It is the gauge\u2019s population and not the module\u2019s: `src` only, tests, mocks and generated files excluded, which is the same list every other pass is given. So it is smaller than what `ls` would count, deliberately, and it will differ from the duplication pass\u2019s own file count, which drops files shorter than the detection window.",
|
|
5662
5663
|
source: "oxlint's `number_of_files` for the run this role drives, with the role's product exclusions passed as `--ignore-pattern`. \u26A0\uFE0F It is NOT always the graph pass's population, and the earlier version of this line said it was, on the strength of one nest library that happens to have no stylesheet: there the two read the same 99 files. The graph counts a `.css` or `.scss` file as a node; oxlint does not lint one, so the gauge reads fewer. Measured over the four front applications, that accounts for the gap exactly: career 64 against 63, console 504 against 503, front-legacy 1510 against 1509, host-admin 4537 against 4536, one stylesheet each. \u26A0\uFE0F There is a SECOND cause and it is louder: oxlint skips what `.gitignore` covers, and the graph does not. On `apps/cloud/interop` the two read 69 and 100, because a bare unanchored `staffea` on line 225 of the repository\u2019s `.gitignore` shadows 34 files that git still tracks. So a large gap here is worth opening: it means a directory of real source is invisible to every tool that honours `.gitignore`."
|
|
5663
5664
|
},
|
|
@@ -5870,7 +5871,7 @@ function figureBlock(row, places = "") {
|
|
|
5870
5871
|
const label = help?.label ?? row.metric;
|
|
5871
5872
|
const dot = row.status === void 0 ? "" : `<span class="dot s-${row.status}" title="${escape(row.status)} against its reference"></span>`;
|
|
5872
5873
|
const detail = help === void 0 ? "" : helpDetails(help, row.status === void 0);
|
|
5873
|
-
const unit = row.unit === "" ? "" :
|
|
5874
|
+
const unit = row.unit === "" ? "" : `${row.unit.startsWith("%") ? "" : " "}<span class="unit">${escape(row.unit)}</span>`;
|
|
5874
5875
|
return ` <div class="figure">
|
|
5875
5876
|
<div class="head">
|
|
5876
5877
|
<span class="name">${dot}${escape(label)}</span>
|
|
@@ -6264,12 +6265,6 @@ ${panel.body}
|
|
|
6264
6265
|
thead th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--label); font-weight: 600; }
|
|
6265
6266
|
tbody th { font-weight: 500; white-space: nowrap; }
|
|
6266
6267
|
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; font-weight: 500; }
|
|
6267
|
-
/*
|
|
6268
|
-
* Which column absorbs the slack, per table. One global rule on the shared class got this
|
|
6269
|
-
* wrong in all three at once: the figures read well and the settings table clipped its origin
|
|
6270
|
-
* column while the findings table crushed its file paths into three lines.
|
|
6271
|
-
*/
|
|
6272
|
-
table.figures td.num { width: 100%; }
|
|
6273
6268
|
/*
|
|
6274
6269
|
* The settings table does NOT force its value column wide. One of its values is the test glob,
|
|
6275
6270
|
* long enough to push the table past the card, and the origin column then slid out of view and
|
|
@@ -6362,9 +6357,6 @@ ${panel.body}
|
|
|
6362
6357
|
details p.fix { border-left: 2px solid var(--accent, currentColor); padding-left: 0.7rem; }
|
|
6363
6358
|
details p.links { font-size: 0.85rem; }
|
|
6364
6359
|
details p.links a { color: inherit; }
|
|
6365
|
-
/* The family a figure belongs to, so 37 rows read as six groups instead of one wall. */
|
|
6366
|
-
tr.family th { padding-top: 1.4rem; font-size: 0.78rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--label); border-bottom: 1px solid var(--rule, currentColor); }
|
|
6367
|
-
tr.family:first-child th { padding-top: 0; }
|
|
6368
6360
|
/* ---- One place, as a card ----
|
|
6369
6361
|
*
|
|
6370
6362
|
* \u26D4 Cards and not table rows, and the reason is three defects a reader listed in one breath:
|
|
@@ -6458,9 +6450,8 @@ ${panels.map((_, index) => ` #tab-${index}:checked ~ nav.tabs label[for="tab-${
|
|
|
6458
6450
|
.dot.s-exceed { background: var(--over); }
|
|
6459
6451
|
/* \u26D4 Colour is never the only carrier: each dot has a title attribute, and the band is in the help text. */
|
|
6460
6452
|
|
|
6461
|
-
/* ----
|
|
6462
|
-
p.
|
|
6463
|
-
p.attention { font-weight: 600; }
|
|
6453
|
+
/* ---- What was not measured ---- */
|
|
6454
|
+
p.noref { font-size: 0.9rem; }
|
|
6464
6455
|
.notes { margin-top: 1.4rem; }
|
|
6465
6456
|
.notes h3 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--label); }
|
|
6466
6457
|
.notes p { font-size: 0.86rem; color: var(--ink-soft); }
|
|
@@ -7136,10 +7127,6 @@ export default defineStaticAnalysis({
|
|
|
7136
7127
|
// the patterns sentinel ships, never replacing them.
|
|
7137
7128
|
entry: [],
|
|
7138
7129
|
|
|
7139
|
-
// Named product journeys, as { 'the name product uses': ['src/some/path/**'] }. Paths are
|
|
7140
|
-
// relative to this module, because one run measures one module.
|
|
7141
|
-
journeys: {},
|
|
7142
|
-
|
|
7143
7130
|
// ---------------------------------------------------------------------------------------------
|
|
7144
7131
|
// What this module can move, with the role's value today. A setting you leave out keeps that
|
|
7145
7132
|
// value; a setting you write REPLACES it, including inside a list. Anything you set is printed
|
|
@@ -7193,7 +7180,7 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
7193
7180
|
// repeats the same line in every adopting module.
|
|
7194
7181
|
],
|
|
7195
7182
|
notes: [
|
|
7196
|
-
`added the three \`${TARGET}\` scripts and ${STATIC_CONFIG_FILE}; declare this module's entry points
|
|
7183
|
+
`added the three \`${TARGET}\` scripts and ${STATIC_CONFIG_FILE}; declare this module's entry points there, everything else comes from the role`
|
|
7197
7184
|
]
|
|
7198
7185
|
};
|
|
7199
7186
|
}
|
|
@@ -7298,6 +7285,59 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
7298
7285
|
if (emitting) await emit(report, shape, ctx.cwd, graph, coverage);
|
|
7299
7286
|
return { ok: true, code: 0, metrics: metricsOf(report) };
|
|
7300
7287
|
}
|
|
7288
|
+
/**
|
|
7289
|
+
* Format what `--init` wrote, with the MODULE's own formatter.
|
|
7290
|
+
*
|
|
7291
|
+
* ⛔ Found by adopting the role on `libs/front/ui`, where the module's `lint` target is
|
|
7292
|
+
* `prettier --check . && eslint .`. Adoption turned it red on the two files adoption itself had
|
|
7293
|
+
* just written:
|
|
7294
|
+
*
|
|
7295
|
+
* [warn] package.json
|
|
7296
|
+
* [warn] static-analysis.config.mjs
|
|
7297
|
+
*
|
|
7298
|
+
* So a team adopting this role is handed a red lint on a target the adoption never touched, for
|
|
7299
|
+
* something the adoption caused. That is what the test role already refuses in its own words:
|
|
7300
|
+
* "the migration handing its own consequence to whoever adopts, which is the one thing this role
|
|
7301
|
+
* must not do". It had the mechanism and this role did not use it.
|
|
7302
|
+
*
|
|
7303
|
+
* ⚠️ The blast radius is small and measured: **2 modules of 243** run `prettier --check` in their
|
|
7304
|
+
* own lint without having adopted our format role, `apps/front/host-admin` and `libs/front/ui`.
|
|
7305
|
+
* Small is not a reason to leave it: the two are exactly the modules a team would adopt by hand.
|
|
7306
|
+
*
|
|
7307
|
+
* The formatter is borrowed from the test role rather than written again, the same way the size
|
|
7308
|
+
* gauge borrows `resolveOxlint` from the lint role: it already knows how a module declares a
|
|
7309
|
+
* style, including the case where that style IS ours.
|
|
7310
|
+
*
|
|
7311
|
+
* ⛔ Never fatal. Adoption succeeded; only the tidy-up did not. Reverting an adoption over a
|
|
7312
|
+
* formatting pass would be the wrong trade, so a failure here is reported as what it is.
|
|
7313
|
+
*
|
|
7314
|
+
* No batching, unlike the test role: `--init` writes two files, and the OOM that forced batches
|
|
7315
|
+
* there came from handing a formatter 273 at once.
|
|
7316
|
+
*/
|
|
7317
|
+
async afterInit(ctx) {
|
|
7318
|
+
const written = ctx.written ?? [];
|
|
7319
|
+
if (written.length === 0) return Promise.resolve();
|
|
7320
|
+
const formatter = writtenFileFormatter(ctx.cwd);
|
|
7321
|
+
if (formatter === void 0) {
|
|
7322
|
+
this.say(
|
|
7323
|
+
`no style is declared here, by this module or its workspace, so the ${written.length} file(s) this wrote keep the shape they were written in. Run your own formatter over them before committing.`
|
|
7324
|
+
);
|
|
7325
|
+
return Promise.resolve();
|
|
7326
|
+
}
|
|
7327
|
+
const pass = spawnSync6(formatter.bin, [...formatter.args, ...written], {
|
|
7328
|
+
cwd: ctx.cwd,
|
|
7329
|
+
encoding: "utf8"
|
|
7330
|
+
});
|
|
7331
|
+
this.say(
|
|
7332
|
+
pass.status === 0 ? `formatted the ${written.length} file(s) this wrote with ${formatter.origin}, so a format check passes on files it did not write itself.` : `${formatter.origin} could not run over the ${written.length} file(s) this wrote. Adoption itself is applied; run your formatter before committing.`
|
|
7333
|
+
);
|
|
7334
|
+
return Promise.resolve();
|
|
7335
|
+
}
|
|
7336
|
+
/** One line, prefixed like every other message this role writes. */
|
|
7337
|
+
say(message) {
|
|
7338
|
+
process.stderr.write(`sentinel static-analysis(jscpd): ${message}
|
|
7339
|
+
`);
|
|
7340
|
+
}
|
|
7301
7341
|
/** `--run`: measure and show it. */
|
|
7302
7342
|
async run(ctx) {
|
|
7303
7343
|
return this.measure(ctx, true);
|
|
@@ -7332,7 +7372,12 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
7332
7372
|
languages: config.languages,
|
|
7333
7373
|
journeys: config.journeys,
|
|
7334
7374
|
duplication: config.duplication,
|
|
7335
|
-
deadCode: config.deadCode
|
|
7375
|
+
deadCode: config.deadCode,
|
|
7376
|
+
// ⚠️ `graph` was missing from the view this very stub calls "the values in full", while the
|
|
7377
|
+
// `overrides` list two fields below happily reported an override of `graph.resolution`. A
|
|
7378
|
+
// developer reading both at once was told the setting does not exist and that they had
|
|
7379
|
+
// moved it.
|
|
7380
|
+
graph: config.graph
|
|
7336
7381
|
},
|
|
7337
7382
|
// What this module moved, with the role's value beside it, so the difference is readable
|
|
7338
7383
|
// without opening the package.
|
|
@@ -7527,7 +7572,7 @@ function safeParseDeadCode(text) {
|
|
|
7527
7572
|
return void 0;
|
|
7528
7573
|
}
|
|
7529
7574
|
}
|
|
7530
|
-
function placesToLook(product, tests, dead, complexity, graph, size) {
|
|
7575
|
+
function placesToLook(product, tests, dead, complexity, graph, size, unreadable) {
|
|
7531
7576
|
const at = (side) => `${side?.name ?? "(unknown)"}${side?.start === void 0 ? "" : `:${side.start}`}`;
|
|
7532
7577
|
const clones = (outcome, metric) => outcome.kind === "figures" ? outcome.duplicates.map((entry) => ({
|
|
7533
7578
|
metric,
|
|
@@ -7552,7 +7597,7 @@ function placesToLook(product, tests, dead, complexity, graph, size) {
|
|
|
7552
7597
|
name: fn.name,
|
|
7553
7598
|
value: fn.complexity
|
|
7554
7599
|
})),
|
|
7555
|
-
...dead.kind === "figures" ? dead.findings.map((finding) => ({
|
|
7600
|
+
...dead.kind === "figures" && !unreadable ? dead.findings.map((finding) => ({
|
|
7556
7601
|
metric: deadMetric[finding.category ?? ""] ?? "deadLinesPercent",
|
|
7557
7602
|
path: finding.path ?? "(unknown)",
|
|
7558
7603
|
...finding.name ? { name: finding.name } : {},
|
|
@@ -7564,7 +7609,7 @@ function placesToLook(product, tests, dead, complexity, graph, size) {
|
|
|
7564
7609
|
// One finding per GROUP of mutually importing files, not per cycle path: see `graph/scan.ts`.
|
|
7565
7610
|
// The first file names the finding and the rest go in `other`, which is the shape the report
|
|
7566
7611
|
// already renders for a clone's two sides.
|
|
7567
|
-
...graph.cycles.map((cycle) => ({
|
|
7612
|
+
...(unreadable ? [] : graph.cycles).map((cycle) => ({
|
|
7568
7613
|
metric: "cycles",
|
|
7569
7614
|
path: cycle.files[0] ?? "(unknown)",
|
|
7570
7615
|
other: cycle.files.slice(1).join(", "),
|
|
@@ -7602,7 +7647,7 @@ function placesToLook(product, tests, dead, complexity, graph, size) {
|
|
|
7602
7647
|
})),
|
|
7603
7648
|
// ⛔ Context, not defects. Every module has a most-imported file, and nothing here says the
|
|
7604
7649
|
// count is too high: the caveat on the metric says so in the reader's own words.
|
|
7605
|
-
...graph.mostImported.map((file) => ({
|
|
7650
|
+
...(unreadable ? [] : graph.mostImported).map((file) => ({
|
|
7606
7651
|
metric: "mostImported",
|
|
7607
7652
|
path: file.path,
|
|
7608
7653
|
value: file.importedBy,
|
|
@@ -7784,14 +7829,26 @@ function assembleReport(ctx, config, product, tests, dead, complexity, graph, co
|
|
|
7784
7829
|
* `app/database/seed/data/legacy-agencies.ts`, holds **52 210 lines**, 59.3% of the module on
|
|
7785
7830
|
* its own. Twelve files carry 53 233 of the 53 242 dead lines.
|
|
7786
7831
|
*
|
|
7787
|
-
* With both denominators the sentence reads correctly at a glance: 60% of the lines
|
|
7832
|
+
* With both denominators the sentence reads correctly at a glance: 60% of the lines against
|
|
7788
7833
|
* 2% of the files, so the reader looks for a big generated or seed file instead of concluding
|
|
7789
7834
|
* anything about the code they write. Nothing is computed that the pass did not hand over.
|
|
7835
|
+
*
|
|
7836
|
+
* ⚠️ "N files UNREACHABLE IN FULL" and not "in N files", which is what this said first. The
|
|
7837
|
+
* two numbers do NOT contain one another: dead lines also come from exports and symbols that
|
|
7838
|
+
* are dead inside files the walk DOES reach. Measured on `apps/front/console`, 0 files are
|
|
7839
|
+
* unreachable and 0.64% of lines are still dead, which read as a contradiction. On `agency`
|
|
7840
|
+
* the wording looked right only by coincidence, because 13 files carried 53 233 of its
|
|
7841
|
+
* 53 242 dead lines.
|
|
7842
|
+
*
|
|
7843
|
+
* ⛔ And a COMMA, not a semicolon. The markdown recap truncates a unit at its first `;`,
|
|
7844
|
+
* because the bundle units carry a second comparison that pushes a table row past readable
|
|
7845
|
+
* width. A semicolon here meant the recap silently dropped the very denominator this comment
|
|
7846
|
+
* exists for, which is the shape of defect the denominator was added to prevent.
|
|
7790
7847
|
*/
|
|
7791
7848
|
{
|
|
7792
7849
|
metric: "deadLinesPercent",
|
|
7793
7850
|
value: share(stats),
|
|
7794
|
-
unit: `% of lines,
|
|
7851
|
+
unit: `% of lines, ${(stats.files ?? 0) - (stats.reachableFiles ?? 0)} of ${stats.files ?? 0} files unreachable in full`
|
|
7795
7852
|
},
|
|
7796
7853
|
// ⛔ Minus the files the TOOL itself labels `used-only-by-tests`. They were counted as
|
|
7797
7854
|
// unreachable while the tool knew better: measured on `libs/nest/auth`, 5 of the 20. A file a
|
|
@@ -7899,7 +7956,7 @@ function assembleReport(ctx, config, product, tests, dead, complexity, graph, co
|
|
|
7899
7956
|
rows,
|
|
7900
7957
|
notes,
|
|
7901
7958
|
settings,
|
|
7902
|
-
findings: placesToLook(product, tests, dead, complexity, graph, size),
|
|
7959
|
+
findings: placesToLook(product, tests, dead, complexity, graph, size, unreadable),
|
|
7903
7960
|
byLanguage
|
|
7904
7961
|
};
|
|
7905
7962
|
}
|
|
@@ -90,7 +90,12 @@ var DECLARED = {
|
|
|
90
90
|
journeys: "merged by name with the role's"
|
|
91
91
|
};
|
|
92
92
|
var NOT_YOURS = {
|
|
93
|
-
tools: "sentinel's own passthrough to the tool underneath; a module never names the vendor"
|
|
93
|
+
tools: "sentinel's own passthrough to the tool underneath; a module never names the vendor",
|
|
94
|
+
// ⛔ Listed as movable until it was tried: the catalogue said "what this module can move" and
|
|
95
|
+
// moving it exits 1, because any kind beyond `exact` overlaps the first and the duplicated-line
|
|
96
|
+
// share then counts the same lines twice. A setting that is refused at run time belongs here,
|
|
97
|
+
// where the reason is readable, rather than in the list of things to turn.
|
|
98
|
+
"duplication.kinds": "only `exact` is supported: any other kind overlaps it and double-counts"
|
|
94
99
|
};
|
|
95
100
|
function render(value) {
|
|
96
101
|
if (Array.isArray(value)) {
|
|
@@ -171,4 +176,4 @@ export {
|
|
|
171
176
|
overridableSettings,
|
|
172
177
|
defineStaticAnalysis
|
|
173
178
|
};
|
|
174
|
-
//# sourceMappingURL=chunk-
|
|
179
|
+
//# sourceMappingURL=chunk-GCHCLR7P.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shared/deep-merge.ts","../src/roles/static-analysis/presets/shared.json","../src/roles/static-analysis/preset-data.ts","../src/roles/static-analysis/configs/define.ts"],"sourcesContent":["/**\n * Is this a plain JSON object?\n *\n * The file is named for a `deepMerge` that no longer exists. It was written when the engine\n * merged JSON in memory and wrote the result; that approach was replaced by editing the\n * document IN PLACE with jsonc-parser, so a project's comments, key order and formatting\n * survive a `merge-json` op. Nothing has called `deepMerge` since, except its own recursion,\n * so it went.\n *\n * What remains is the predicate the in-place merge still needs, to decide whether a value is\n * a branch to walk into or a leaf to set.\n */\n\n/** True for a mergeable plain object (not null, not an array). */\nexport function isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n","{\n \"entry\": [\n \"**/*.integration-spec.ts\",\n \"**/*.steps.ts\",\n \"src/client.tsx\",\n \"src/start.ts\",\n \"src/router.tsx\",\n \"src/RouterContext.tsx\"\n ],\n \"ignore\": [\n \"**/node_modules/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/coverage/**\",\n \"**/.next/**\",\n \"**/__mocks__/**\",\n \"**/mocks/**\",\n \"**/_mock_/**\",\n \"**/**Mock**/**\",\n \"**/__fixtures__/**\",\n \"**/fixtures/**\",\n \"**/public/**\",\n \"**/.husky/**\",\n \"**/.vscode/**\",\n \"**/.idea/**\",\n \"**/.github/**\",\n \"**/*.d.ts\",\n \"**/*.gen.ts\",\n \"**/*.generated.ts\"\n ],\n \"languages\": [\"javascript\", \"typescript\", \"jsx\", \"tsx\", \"css\", \"scss\"],\n \"journeys\": {},\n \"duplication\": {\n \"minTokens\": 50,\n \"minLines\": 5,\n \"kinds\": [\"exact\"],\n \"tests\": [\n \"*.{test,spec,steps,e2e-spec,integration-spec,stories}.{ts,tsx,js,jsx}\",\n \"*.{msw,mock,test-data}.{ts,tsx,js,jsx}\",\n \"{tests,__tests__,test,testing,stubs}/**/*.{ts,tsx,js,jsx}\"\n ]\n },\n \"deadCode\": {\n \"minConfidence\": 60,\n \"categories\": [\"unused-file\", \"unused-export\", \"unused-symbol\", \"unused-import\"]\n },\n \"graph\": {\n \"resolution\": 1\n },\n \"tools\": {\n \"jscpd\": {}\n }\n}\n","/**\n * The static analysis preset: the role's settings, as data.\n *\n * One shared layer and no per-stack preset yet, because nothing measured so far varies by stack:\n * duplication, dead code and complexity are the same questions in Nest and in React. The split\n * that is already foreseen is `LCOM4`, which is meaningless on a functional front end and real on\n * nest's 16 568 classes; when it arrives it becomes `nest.json` beside this layer, which is why\n * the values live here as JSON rather than inline in the code.\n *\n * ## Why these values are the role's and not the module's\n *\n * A team declares FACTS about its own domain (which journeys exist, which files are entry points);\n * it does not declare the values a number is judged by. Two files with the same measurement\n * getting different verdicts in two modules would make every figure incomparable, and the first\n * thing this role has to be able to say is that a number means the same thing everywhere.\n *\n * That is a default, not a wall: a module CAN move any of them (see `configs/define.ts`), and when\n * it does the report prints `(module)` next to the value. The seam exists from the first commit\n * because retrofitting one later means rewriting every module's file.\n *\n * ⛔ There are no thresholds here, and their absence is a decision rather than an omission.\n *\n * A threshold was written first, at 5% of duplicated lines, and then removed. Measured over all 228\n * nx projects: 5% flags 65 of the 201 measurable modules, a third of the repository, and moving the\n * figure until the count felt manageable is not calibration, it is fitting the instrument to the\n * answer. Calibration is legitimate when a bench is polluted and the noise is removed, or when the\n * reference frame changes; neither applies to a share of duplicated lines.\n *\n * And there is nothing to calibrate AGAINST: of the sources in `docs/static-analysis-study.md`\n * section 5, NDepend, Lattix, ArchUnit, CodeScene and Arcan, none publishes a threshold for\n * duplicated lines. One invented here would travel as if it were published.\n *\n * So the role reports the measurement. What makes a single figure readable is the distribution it\n * sits in, which only a workspace-wide run can know, so that belongs to the sweep and to the\n * migration page, not to a module's own report. The first defensible warning will not be an absolute\n * number either: a module that got WORSE than its own previous measurement is a signal that needs no\n * invented bar, which is what a baseline gives, and that was already deferred for its own reasons.\n *\n * ## Where each value comes from, so nobody has to guess later\n *\n * - `entry` is the single most consequential line in the file. Measured on\n * `apps/nest/microservices/network` with jscpd 5.4.0: with nothing declared the dead-code pass\n * reports **33.4%** of lines dead (79 657 lines, 232 files, 13 entry points); with these two\n * patterns declared it reports **1.0%** (2 462 lines, 207 entry points). The cause is a naming\n * convention and not a bug: our integration tests are `foo.integration-spec.ts` with a HYPHEN,\n * so they do not end in `.spec.ts` and the tool's own test-file detection never sees them, and\n * the 115 `.steps.ts` BDD definitions are tests by no convention it knows. A module ADDS to\n * this list rather than replacing it, precisely so no team can lose these two by declaring a\n * third.\n *\n * ⭐ The four `src/` files are the FRONT bootstrap, and they are the most consequential thing\n * measured on this pass. The tool recognises around fifty frameworks and TanStack Start is not\n * one of them, so nothing in the repository imports these files: the framework finds them by\n * convention at build time. Measured without them declared, `apps/front/console` reports\n * `src/client.tsx`, `src/router.tsx` and `src/start.ts` among its 5 dead files, which are its\n * hydration entry, its router factory and its Start config. `host-admin` reports `client.tsx`\n * too and `career` reports `router.tsx` and `RouterContext.tsx`. Three of console's five\n * findings were its own bootstrap, and the first team to see that stops trusting the tool on\n * the spot. They exist in 2 to 3 front apps each.\n *\n * ⛔ `scripts/**` is deliberately NOT here, although 8 modules have one and\n * `apps/nest/microservices/contract` reports two `scripts/e2e-*.ts` as dead. No `package.json`\n * script invokes them, so whether they run at all is a fact only the team holds. Declaring them\n * would hide a finding that may well be true, and that is the opposite of this role's job.\n *\n * - `languages` is this role's name for what the tool calls `format`, a word that already names\n * another sentinel role and means something else there. Stating it has a measured cost either\n * way: on `apps/front/console`, naming `typescript` alone scans 300 of 523 files because `.tsx`\n * is a SEPARATE language to the tool, naming all four scans 522 (the one left out being\n * `styles.css`), and naming nothing scans every one of its 224 languages, json and markdown\n * included. Stylesheets are in deliberately: measured on `libs/front` they duplicate at 9.64%\n * against 3.50% for the code, a signal that only survives because the report is broken down per\n * language.\n *\n * - `duplication.minTokens` and `minLines` are the detection window, and the window decides what\n * is INVISIBLE: a fragment shorter than it is not reported at all, so a reader comparing two\n * runs has to know both values. They are the tool's own defaults, written out rather than\n * inherited for that reason.\n *\n * - `duplication.kinds` is `exact` alone, and that is a correctness constraint rather than\n * caution. Measured: with a second kind enabled, `duplicatedLines` adds up overlapping clones.\n * Two 9-line files reported 13 duplicated lines out of 18, 72%, because an `exact` clone of\n * lines 1-4 and a `similar` clone of lines 1-9 containing it were both counted; the real extent\n * is 9 of 18, 50%. The tool's own answer is to cap the display at 100%, which does not make a\n * threshold meaningful. Opening another kind therefore requires computing the share ourselves\n * from the UNION of the line ranges.\n *\n * - `duplication.tests` is the glob that splits the pass in two, and the split is the finding that\n * forced it. Measured across 8 modules of the three families, **71% of all duplicated lines are\n * between two test files**: `libs/nest/auth` reports 35.77% and its product code is at 5.96%,\n * `apps/front/host-admin` 7.91% against 3.59%. A single blended figure is therefore mostly a\n * measure of spec duplication wearing the name of the module's duplication, and the number\n * travels while that nuance does not. So the role reports the two side by side, the way it\n * already refuses to blend languages, and neither is hidden: a module whose specs carry 93% of\n * its clones is saying something about its specs that excluding them would erase.\n *\n * ⚠️ The two passes PARTITION the files and therefore lose a clone that spans them: a fragment\n * shared between a test and a product file has no partner in either scan. Measured on the same\n * 8 modules, that is 42 entries of 5117, 0.8%, 650 lines. Stated in the caveat rather than\n * silently accepted, and it is the price of the split.\n *\n * Two patterns and not one, because a suffix convention does not cover a DIRECTORY convention,\n * and the directory one is not a quirk: 635 files across 26 modules live under `tests/` or\n * `__tests__/` without a name this role's suffixes recognise, `apps/nest/microservices/recruitment`\n * alone holding 160. They are step definitions, in-memory doubles and stubs, which is test-side\n * code by any reading. A role default rather than 26 identical overrides.\n *\n * ⚠️ The patterns are RELATIVE here, and the adapter prefixes and composes them, because the\n * tool's `--pattern` takes exactly one glob. Verified on four modules that the two passes\n * partition exactly: console 479 + 87 = 566, contract 399 + 151 = 550, backends-for-frontends/admin\n * 2062 + 773 = 2835, recruitment 936 + 621 = 1557.\n *\n * ⚠️ And a trap that cost a wrong table before it was caught: these globs contain commas, so they\n * survive the config FILE and are destroyed by the `-i` command-line flag, which splits on\n * commas and cuts `{test,spec}` in half. The adapter writes a file, which is one more reason it\n * does.\n *\n * - `deadCode.minConfidence` is the tool's own default of 60. It scores every finding and names\n * what lowered it (a file calling `eval`, an unrecognised decorator, a name appearing in a\n * string literal, a file in the scan that did not parse). Measured on sentinel: 60 yields 20\n * files, 10 exports and 22 symbols; 90 yields 18 files; 100 yields nothing.\n *\n * - `deadCode.categories` leaves out `unused-member` deliberately: without type information\n * `x.render()` could reach any `render` in the project, which the tool itself calls its least\n * certain rule.\n *\n * - `ignore` excludes generated, vendored and fixture code, which duplicates by nature and buries\n * the clones a developer can act on. It is load-bearing rather than tidy: measured on this\n * package, scanning the module root with no ignore list reports 18 186 files and 32 153 clones,\n * **31 969** of them touching `node_modules`; with the list, 408 files and zero.\n *\n * - `graph.resolution` is Louvain's, and it is 1 because that is the value the papers use and\n * graphology's own default. It decides how large a community must be to survive, so it changes\n * both the community count and `Q`, and graphology's documentation warns in its own words that\n * it is finicky to tweak. `code-health-meter` ships 0.74, arrived at by trying values until the\n * partition looked right; this role declines to do that, for the same reason it ships no\n * duplication threshold. A module that has a reason can move it, and the report prints the\n * value every figure was produced with.\n *\n * ⚠️ What is NOT tunable is the random seed, and that is deliberate. Louvain is greedy, so its\n * answer depends on the order it visits nodes, which graphology draws from `Math.random`.\n * Measured over 20 runs per module: `console` produced 16 distinct values of `Q` and between 12\n * and 14 communities, `recruitment` 19 distinct values spanning 0.0357. With the role's own\n * generator all five modules produce one value. A figure that moves on its own between two runs\n * is not a figure a team can act on, so this one is the role's and nobody's to change.\n *\n * - `tools.jscpd` is passed to the tool verbatim, and it is **sentinel's own** escape hatch, not a\n * module's: `defineStaticAnalysis` excludes it from what a module can override. Everything above\n * uses OUR key names, translated on the way out, so that replacing the tool one day does not\n * oblige a single module to edit its config file, and a module only ever knows the ROLE. This\n * block is where that guarantee stops, which is why it stays on this side and why it is empty:\n * the first thing to put in it would be an option a single preset needs, not a workaround.\n */\nimport sharedData from './presets/shared.json'\n\n/**\n * Where the json and html reports are written, relative to the module.\n *\n * Named for the role rather than left on the tool's default `report/`, and added to `.gitignore`\n * by `--init`. Both halves matter: the format role once shipped a `lint/` directory that nothing\n * ignored, and every adopting module grew an untracked directory nobody could explain.\n */\nexport const REPORT_DIR = 'static-analysis-report'\n\n/** The target this role serves, which is also the npm script a module runs and the nx target. */\nexport const TARGET = 'static-analysis'\n\n/** The output shapes `--run` can produce. `console` writes nothing to disk. */\nexport const SHAPES = ['console', 'json', 'html', 'markdown'] as const\n\n/**\n * The npm scripts a module gets: one per shape a developer runs, which is three of the four.\n *\n * Here rather than in the adapter because the REPORT needs them too, to tell a reader of the\n * console summary where the detail is, and two spellings of one script name is drift nobody\n * notices until a developer types the one that does not exist.\n */\nexport const SCRIPTS = {\n console: TARGET,\n json: `${TARGET}:json`,\n html: `${TARGET}:html`,\n // ⛔ No entry for the `markdown` shape, deliberately, and this is NOT a gap: these are the npm\n // scripts a module gets, and markdown is the recap a workflow posts on a pull request, built\n // from one file per affected module. A developer who wants it by hand gets the console shape,\n // which says the same thing in less space and writes nothing. A fourth script here would be a\n // command nobody runs, carried in 198 package.json files.\n} as const\n\n/**\n * What each language the role scans is called on disk.\n *\n * ⚠️ Two lists that MUST agree, and they were maintained separately: the role declares the\n * languages it asks the tool to scan, and the adapter declared the extensions it looks for when\n * deciding whether a module has anything to measure at all. A module that narrowed `languages`\n * would still be probed for the extensions it had removed, and a language added to the preset\n * would never reach the probe. Derived from one source now, with a test that every language maps.\n */\nconst EXTENSIONS_BY_LANGUAGE: Record<string, string[]> = {\n javascript: ['.js', '.mjs', '.cjs'],\n typescript: ['.ts'],\n jsx: ['.jsx'],\n tsx: ['.tsx'],\n css: ['.css'],\n scss: ['.scss'],\n}\n\n/** The file extensions of the languages given, for deciding whether a module has anything to read. */\nexport function extensionsOf(languages: readonly string[]): string[] {\n return [...new Set(languages.flatMap((language) => EXTENSIONS_BY_LANGUAGE[language] ?? []))]\n}\n\n/** Every language the mapping knows, so a test can hold it against the preset. */\nexport const MAPPED_LANGUAGES = Object.keys(EXTENSIONS_BY_LANGUAGE)\n\nexport type StaticDefaults = {\n entry: string[]\n ignore: string[]\n languages: string[]\n journeys: Record<string, string[]>\n duplication: { minTokens: number; minLines: number; kinds: string[]; tests: string[] }\n deadCode: { minConfidence: number; categories: string[] }\n graph: { resolution: number }\n tools: { jscpd: Record<string, unknown> }\n}\n\n/** The role's settings. Read-only here; `defineStaticAnalysis` resolves a module against them. */\nexport const defaults: StaticDefaults = sharedData as StaticDefaults\n\n/**\n * Keys a module declares DIRECTLY rather than through `overrides`, and how each one combines.\n *\n * They are apart because the verb differs: these two are added to and merged, so a team cannot\n * lose what the role ships by declaring its own. Everything else replaces.\n */\nconst DECLARED: Record<string, string> = {\n entry: 'added to the patterns the role ships, never replacing them',\n journeys: \"merged by name with the role's\",\n}\n\n/**\n * ⛔ Not a module's to set, and the catalogue says so rather than leaving it absent: a key that is\n * simply missing from a list reads as an oversight, and someone will try it. Passing it is dropped\n * with a message, see `configs/define.ts`.\n */\nconst NOT_YOURS: Record<string, string> = {\n tools: \"sentinel's own passthrough to the tool underneath; a module never names the vendor\",\n // ⛔ Listed as movable until it was tried: the catalogue said \"what this module can move\" and\n // moving it exits 1, because any kind beyond `exact` overlaps the first and the duplicated-line\n // share then counts the same lines twice. A setting that is refused at run time belongs here,\n // where the reason is readable, rather than in the list of things to turn.\n 'duplication.kinds': 'only `exact` is supported: any other kind overlaps it and double-counts',\n}\n\n/** A value as a reader compares it: short lists in full, long ones as a count. */\nfunction render(value: unknown): string {\n if (Array.isArray(value)) {\n const joined = value.join(', ')\n return value.length <= 6 && joined.length <= 72 ? joined : `${value.length} patterns`\n }\n if (value && typeof value === 'object') return `${Object.keys(value).length} entries`\n return String(value)\n}\n\n/** One documented key: its path, the role's value, and the note a reader needs. */\nexport interface OverridableSetting {\n /** The dotted path a module writes, e.g. `duplication.minTokens`. */\n key: string\n /** The role's current value, rendered. */\n value: string\n /** How it combines, or why it is not available. */\n note: string\n /** Where it goes: directly in the config, inside `overrides`, or nowhere. */\n where: 'declared' | 'overrides' | 'refused'\n}\n\n/**\n * Every setting a module can touch, DERIVED from the values rather than written beside them.\n *\n * Héla's ask, and the reason it is generated: a hand-written list is a second copy of the preset,\n * and the first setting somebody adds makes it a lie that nobody notices, because a stale comment\n * fails no test. Here a new key appears in the next module's config file on its own, with its real\n * value, and `tests/role-layout.test.ts` refuses a key that reaches none of the three groups.\n *\n * It is a snapshot: `--init` leaves an EXISTING config alone, by design, so a module that adopted\n * earlier keeps the catalogue of that day. The report is the live answer, since it prints the value\n * every figure was produced with.\n */\nexport function overridableSettings(from: StaticDefaults = defaults): OverridableSetting[] {\n const rows: OverridableSetting[] = []\n\n for (const [key, value] of Object.entries(from)) {\n if (key in NOT_YOURS) {\n rows.push({ key, value: render(value), note: NOT_YOURS[key] ?? '', where: 'refused' })\n continue\n }\n if (key in DECLARED) {\n rows.push({ key, value: render(value), note: DECLARED[key] ?? '', where: 'declared' })\n continue\n }\n // A group is documented by its leaves, because `duplication` whole is not what anyone writes.\n const leaves =\n value && typeof value === 'object' && !Array.isArray(value)\n ? Object.entries(value).map(([leaf, own]) => ({ key: `${key}.${leaf}`, own }))\n : [{ key, own: value }]\n for (const leaf of leaves) {\n rows.push({ key: leaf.key, value: render(leaf.own), note: 'replaced', where: 'overrides' })\n }\n }\n\n return rows\n}\n","/**\n * `defineStaticAnalysis`: what a module imports to declare its own static analysis settings.\n *\n * ```js\n * // static-analysis.config.mjs\n * import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'\n *\n * export default defineStaticAnalysis({\n * root: import.meta.dirname,\n * entry: ['**\\/*.steps.ts'],\n * journeys: { planning: ['src/planning/**'] },\n * })\n * ```\n *\n * ## Why a function and not `{ ...baseConfig, ... }`\n *\n * A spread is SHALLOW, so a module writing `deadCode: { minConfidence: 80 }` would replace the\n * whole block and silently lose `categories`. And the flattening happens in the module's file,\n * before this package ever sees the object, so nothing here could tell \"set one key, keep the\n * rest\" from \"set one key, drop the rest\". A function receives the module's INTENT, the keys it\n * actually passed, and merges them here. Second reason: the defaults are never copied into a\n * module, so changing one reaches every module at the next release instead of leaving 108 stale\n * copies behind.\n *\n * Same shape as `reactTestConfig` in the test role, for the same reasons.\n *\n * ## Two semantics, one obvious and one deliberate\n *\n * `entry` and `journeys` are FACTS a team declares, and they ADD to what this role ships: no\n * module can lose `**\\/*.integration-spec.ts` by declaring a third pattern, which matters\n * because that one pattern is the difference between 33.4% and 1.0% of dead code on a real\n * service (see `defaults.ts`).\n *\n * `overrides` REPLACES, including those lists. It is the seam for the day a team has a real\n * reason, it is not documented anywhere a developer would meet it, and every path it sets is\n * recorded in `moved` so the report can print `(module)` next to the value. Without that trace\n * two modules stop being comparable and nobody knows. It reaches every setting EXCEPT the vendor\n * passthrough, for the reason stated on the field itself.\n */\nimport { isPlainObject } from '../../../shared/deep-merge.js'\nimport { defaults, type StaticDefaults } from '../preset-data.js'\n\ntype DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }\n\n/** What a module's config file passes. Everything is optional, including the file itself. */\nexport interface StaticAnalysisInput {\n /** The module's own directory, normally `import.meta.dirname`. */\n root?: string\n /** Files that run although nothing imports them. ADDED to the shipped patterns. */\n entry?: string[]\n /**\n * Named journeys, as `{ name: [globs] }`. MERGED by name with any shipped ones.\n *\n * ⛔ **No pass reads this yet, and the generated config stub no longer asks for it.** It is the\n * seam for the product axis, where a figure is reported per journey rather than per module, and\n * that is V2 work tracked in issue #98. It stays accepted so a module that already declared one\n * is not broken, and `--inspect` echoes it back, but nothing measures against it.\n *\n * It was in the stub for a while, under a comment telling every adopting team to fill it in. A\n * team that did would have waited for a breakdown that never came and concluded their globs were\n * wrong. Asking for something nothing consumes is worse than not offering it.\n */\n journeys?: Record<string, string[]>\n /**\n * The seam. Replaces whatever it names, and every path it sets lands in `moved`.\n *\n * ⛔ `tools` is excluded, and the reason is the whole point of translating the vocabulary: a\n * module knows the ROLE, not the tool underneath it. A key named after the vendor would mean\n * nothing to the person reading their own config file, and showing it would invite them to learn\n * a name we are working to keep out of their world. The passthrough still exists, on sentinel's\n * side, for a preset that needs a vendor option; a module that genuinely needs one gets a key in\n * OUR vocabulary, which is the right friction because it goes through a review instead of\n * spreading silently.\n */\n overrides?: DeepPartial<Omit<StaticDefaults, 'tools'>>\n}\n\nexport interface ResolvedStaticConfig extends StaticDefaults {\n /** The module's directory, or null when its config file did not say. */\n root: string | null\n /**\n * Dotted paths the module set through `overrides`, so the report can mark them. Sorted, so\n * two runs on the same config produce the same report.\n */\n moved: string[]\n}\n\n/**\n * Merge `patch` into `base`, recursively, patch winning.\n *\n * Not the `deepMerge` this repo once had: that one existed for WRITING a module's json and was\n * replaced by an in-place jsonc edit, which is why only `isPlainObject` survived. This is the\n * read side, which has no file to preserve and only has to resolve two objects into one.\n *\n * Arrays are replaced, never concatenated. Additive behaviour is a decision about a specific\n * key, which `defineStaticAnalysis` makes explicitly for `entry`, rather than a property of the\n * merge: a merge that silently concatenates cannot express \"replace this list\", and a team\n * reaching for `overrides` is asking for exactly that.\n */\nfunction mergeInto<T>(base: T, patch: unknown): T {\n if (!isPlainObject(patch)) return base\n if (!isPlainObject(base)) return patch as T\n const out: Record<string, unknown> = { ...base }\n for (const [key, value] of Object.entries(patch)) {\n out[key] = isPlainObject(value) ? mergeInto(out[key], value) : value\n }\n return out as T\n}\n\n/** Every leaf path in `patch`, dotted, so a reader can be told what the module decided. */\nfunction leafPaths(patch: unknown, prefix = ''): string[] {\n if (!isPlainObject(patch)) return prefix ? [prefix] : []\n return Object.entries(patch).flatMap(([key, value]) =>\n leafPaths(value, prefix ? `${prefix}.${key}` : key),\n )\n}\n\n/** The same list with duplicates dropped, keeping the first occurrence's order. */\nfunction unique(values: string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * Drop the vendor passthrough from what a module sent.\n *\n * ⚠️ Enforced here and not by the type, because the type cannot: a module's config file is `.mjs`,\n * so nothing type-checks it, and `Omit` on the input would be documentation that compiles away. A\n * `tools` key written there is dropped and NAMED on stderr rather than ignored, so a developer who\n * found the key somewhere learns why it did nothing instead of concluding the role is broken.\n */\nfunction withoutVendorKeys(overrides: unknown): unknown {\n if (!isPlainObject(overrides) || !('tools' in overrides)) return overrides\n const { tools: _vendor, ...rest } = overrides\n process.stderr.write(\n 'sentinel static-analysis: ignoring `overrides.tools` from this module. The tool underneath ' +\n \"the role is not part of a module's surface; if a setting is missing, it needs a key of \" +\n 'its own.\\n',\n )\n return rest\n}\n\n/** Resolve a module's declaration against this role's defaults. */\nexport function defineStaticAnalysis(input: StaticAnalysisInput = {}): ResolvedStaticConfig {\n const declared: StaticDefaults = {\n ...defaults,\n entry: unique([...defaults.entry, ...(input.entry ?? [])]),\n journeys: { ...defaults.journeys, ...(input.journeys ?? {}) },\n }\n const overrides = withoutVendorKeys(input.overrides)\n\n return {\n ...mergeInto(declared, overrides),\n root: input.root ?? null,\n moved: leafPaths(overrides).sort(),\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.js
CHANGED
|
@@ -6,12 +6,12 @@ import {
|
|
|
6
6
|
registerAdapters,
|
|
7
7
|
resolve,
|
|
8
8
|
setDefaultRunner
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-BYV4XD5W.js";
|
|
10
10
|
import {
|
|
11
11
|
BaseAdapter
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-OS5TMEKH.js";
|
|
13
13
|
import "./chunk-JEYGPPSL.js";
|
|
14
|
-
import "./chunk-
|
|
14
|
+
import "./chunk-GCHCLR7P.js";
|
|
15
15
|
import "./chunk-O7REVMOC.js";
|
|
16
16
|
import "./chunk-QXFCZON7.js";
|
|
17
17
|
export {
|
|
@@ -30,7 +30,18 @@ interface StaticAnalysisInput {
|
|
|
30
30
|
root?: string;
|
|
31
31
|
/** Files that run although nothing imports them. ADDED to the shipped patterns. */
|
|
32
32
|
entry?: string[];
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Named journeys, as `{ name: [globs] }`. MERGED by name with any shipped ones.
|
|
35
|
+
*
|
|
36
|
+
* ⛔ **No pass reads this yet, and the generated config stub no longer asks for it.** It is the
|
|
37
|
+
* seam for the product axis, where a figure is reported per journey rather than per module, and
|
|
38
|
+
* that is V2 work tracked in issue #98. It stays accepted so a module that already declared one
|
|
39
|
+
* is not broken, and `--inspect` echoes it back, but nothing measures against it.
|
|
40
|
+
*
|
|
41
|
+
* It was in the stub for a while, under a comment telling every adopting team to fill it in. A
|
|
42
|
+
* team that did would have waited for a breakdown that never came and concluded their globs were
|
|
43
|
+
* wrong. Asking for something nothing consumes is worse than not offering it.
|
|
44
|
+
*/
|
|
34
45
|
journeys?: Record<string, string[]>;
|
|
35
46
|
/**
|
|
36
47
|
* The seam. Replaces whatever it names, and every path it sets lands in `moved`.
|
|
@@ -123,14 +123,14 @@ Below a kilobyte the report shows bytes.
|
|
|
123
123
|
|
|
124
124
|
## 3. What the report says, per application
|
|
125
125
|
|
|
126
|
-
| |
|
|
127
|
-
| ------------------------------- |
|
|
128
|
-
| initial load | per type, both costs, with the file count
|
|
129
|
-
| the heavy assets | the heaviest per type,
|
|
130
|
-
| the heavy and costly JavaScript | both costs side by side, because 842 KB to parse is invisible in 240 KB transferred
|
|
131
|
-
| **the dynamic layer** | the translations: its weight as a bound, its
|
|
132
|
-
| what the entry set references | read as code-splitting health, never as a user cost. ⚠️ Not named "total emitted", because it is not: the build writes more, and §1.1 is why
|
|
133
|
-
| **the guide** | what each figure means, why the decompressed one matters, and **what to do about it**
|
|
126
|
+
| | |
|
|
127
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
128
|
+
| initial load | per type, both costs, with the file count |
|
|
129
|
+
| the heavy assets | the heaviest per type. ⚠️ The heaviest ONE, not a top 5: the bundle family publishes no list of places, because an asset is generated and its name is a content hash |
|
|
130
|
+
| the heavy and costly JavaScript | both costs side by side, because 842 KB to parse is invisible in 240 KB transferred |
|
|
131
|
+
| **the dynamic layer** | the translations: its weight as an upper and a lower bound, and its group count. ⛔ NOT what a user pays: that is a subset the role cannot name, since it depends on navigation and the manifest does not record which screen pulls which chunk |
|
|
132
|
+
| what the entry set references | read as code-splitting health, never as a user cost. ⚠️ Not named "total emitted", because it is not: the build writes more, and §1.1 is why |
|
|
133
|
+
| **the guide** | what each figure means, why the decompressed one matters, and **what to do about it** |
|
|
134
134
|
|
|
135
135
|
The guide is a deliverable, not decoration. A report that says "too heavy" and stops leaves the
|
|
136
136
|
reader where they started. Each breach carries the concrete lever and its reference: route-level
|
|
@@ -17,28 +17,51 @@ npx --yes @hublo/sentinel@<version> --init --static-analysis
|
|
|
17
17
|
pnpm install
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
`--init` writes `static-analysis.config.mjs`, adds the three scripts below to the module's `package.json
|
|
20
|
+
`--init` writes `static-analysis.config.mjs`, adds the three scripts below to the module's `package.json` along with its nx target, pins `@hublo/sentinel` into its own `devDependencies`, and ignores the report directory in the repository's root `.gitignore`. It prints what it did:
|
|
21
21
|
|
|
22
22
|
```
|
|
23
23
|
wrote static-analysis.config.mjs
|
|
24
24
|
wrote package.json
|
|
25
25
|
added the three `static-analysis` scripts and static-analysis.config.mjs; declare
|
|
26
|
-
this module's entry points
|
|
26
|
+
this module's entry points there, everything else comes from the role
|
|
27
|
+
ignored static-analysis-report/ in the root .gitignore
|
|
28
|
+
run `pnpm install` to apply the workspace changes
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
**
|
|
31
|
+
⚠️ **If your module has a `project.json` and no `package.json`, `--init` creates one.** Measured on
|
|
32
|
+
this repository, **49 of 243** nx projects are in that case, and none of them has adopted any
|
|
33
|
+
sentinel role yet, which is the same fact seen from the other side. The consequence is worth
|
|
34
|
+
knowing before you read the diff: the pnpm workspace globs are `apps/**` and `libs/**`, so a new
|
|
35
|
+
`package.json` there makes the directory a workspace package and the lockfile gains an importer for
|
|
36
|
+
it. Verified: `pnpm install` stays green, and nx keeps reading the project, now from two files.
|
|
37
|
+
|
|
38
|
+
This is not specific to this role. `--init --format` on the same module prints `create
|
|
39
|
+
package.json` too: it is the shape of every sentinel adoption, because a role declares its nx
|
|
40
|
+
target in `package.json` and nowhere else.
|
|
41
|
+
|
|
42
|
+
**Nothing else is required.** The config stub is written with an empty `entry` on purpose: for most modules the figures are already right without declaring anything, and the stub exists so you can correct the one case where they are not (see [Declaring entry points](#declaring-entry-points)).
|
|
30
43
|
|
|
31
44
|
## Verbs at a glance
|
|
32
45
|
|
|
33
|
-
| Verb | Who / when | Runs the tools? | What you get
|
|
34
|
-
| ----------- | ------------------ | ----------------- |
|
|
35
|
-
| `--run` | dev, everyday | **yes** | the figures, in one of
|
|
36
|
-
| `--inspect` | dev, everyday | no (reads config) | the resolved settings, and whether this module has adopted
|
|
37
|
-
| `--init` | dev, once to adopt | writes files | the config stub, the three scripts, the pinned dep
|
|
38
|
-
| `--migrate` | planned | not available yet | reserved for changing an already-initialized setup
|
|
46
|
+
| Verb | Who / when | Runs the tools? | What you get |
|
|
47
|
+
| ----------- | ------------------ | ----------------- | --------------------------------------------------------------------- |
|
|
48
|
+
| `--run` | dev, everyday | **yes** | the figures, in one of four shapes. Never exits non-zero on a finding |
|
|
49
|
+
| `--inspect` | dev, everyday | no (reads config) | the resolved settings, and whether this module has adopted |
|
|
50
|
+
| `--init` | dev, once to adopt | writes files | the config stub, the three scripts, the pinned dep |
|
|
51
|
+
| `--migrate` | planned | not available yet | reserved for changing an already-initialized setup |
|
|
39
52
|
|
|
40
53
|
**Location** (any verb): from a module dir → that module; from the root → `--module <name>`, `--ci` (affected), or every module. `--init` writes, so it targets one module only.
|
|
41
54
|
|
|
55
|
+
⚠️ **From the workspace root, `pnpm exec sentinel` does not resolve.** sentinel is a devDependency of each adopting MODULE, not of the root, so the root's `.bin` has no `sentinel` and you get `Command "sentinel" not found`. Three things do work:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pnpm --filter <module> exec sentinel --run --static-analysis # the module becomes the cwd
|
|
59
|
+
pnpm --filter <module> run static-analysis # its own script, same thing
|
|
60
|
+
npx @hublo/sentinel --run --static-analysis --module <module> # when nothing is installed
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`--module` is for the third form. With the first two the cwd is already the module, so it is redundant.
|
|
64
|
+
|
|
42
65
|
## Everyday use
|
|
43
66
|
|
|
44
67
|
```bash
|
|
@@ -106,7 +129,9 @@ Four groups, and they answer different questions:
|
|
|
106
129
|
|
|
107
130
|
**Every row carries its denominator**, because none of them compares between modules without one: three breaches over 50 files and three over 3 000 are not the same module.
|
|
108
131
|
|
|
109
|
-
**
|
|
132
|
+
**An absent row means one of two things, and the notes tell you which.** When a pass could not produce a figure, the report says so in its own line under the figures rather than printing `0.00%`, because "no dead code" and "the walk had no entry point" are different facts. When a COUNT is zero, the row is dropped silently and on purpose: the complexity bands and the nine size indicators would otherwise print a column of zeroes on a healthy module, which makes the normal case the loudest part of the report.
|
|
133
|
+
|
|
134
|
+
So: a row missing **with** a note under the figures was not measured; a row missing **without** one was measured and found nothing.
|
|
110
135
|
|
|
111
136
|
### Three rows people misread
|
|
112
137
|
|
|
@@ -200,7 +225,15 @@ The **import graph** is explorable. Point at a dot for its path, which the brows
|
|
|
200
225
|
|
|
201
226
|
⛔ **It does not compare with `main`.** It says so on itself, because a number in a pull-request comment is read as a delta by default, and that reading is wrong in the dangerous direction: a module already at 6.81% looks exactly like one this branch pushed there. Comparing needs a baseline kept between runs, which is the next version's work.
|
|
202
227
|
|
|
203
|
-
⚠️ `static-analysis-report/` is generated
|
|
228
|
+
⚠️ `static-analysis-report/` is generated, and **`--init` ignores it for you**: it writes one line into your repository's root `.gitignore`, unanchored so it matches at every depth, rather than one line in every adopting module's own file. It checks first, in any spelling, so adopting a second module adds nothing. You have nothing to do and nothing to commit beyond that line.
|
|
229
|
+
|
|
230
|
+
⛔ **The one case where it is not there**: a repository that runs the role WITHOUT ever adopting it, which is what a CI workflow measuring modules with `npx` does. `--init` never ran, so nothing wrote the rule, and every measured module leaves an untracked directory. If that is your situation, add the line yourself:
|
|
231
|
+
|
|
232
|
+
```gitignore
|
|
233
|
+
static-analysis-report/
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
⚠️ This paragraph said the opposite for one commit, on the strength of that very case: the line was missing from a repository where no module had adopted, and the conclusion drawn was that the documentation lied, without checking what writes it. `--init` does, in `src/core/workspace-prep.ts`. Verify yours with `git check-ignore -v <module>/static-analysis-report/`.
|
|
204
237
|
|
|
205
238
|
## Declaring entry points
|
|
206
239
|
|
|
@@ -214,7 +247,6 @@ import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
|
|
|
214
247
|
export default defineStaticAnalysis({
|
|
215
248
|
root: import.meta.dirname,
|
|
216
249
|
entry: ['src/emails/**'],
|
|
217
|
-
journeys: {},
|
|
218
250
|
})
|
|
219
251
|
```
|
|
220
252
|
|
|
@@ -641,7 +641,7 @@ The project's own benchmark uses a 547-file, 21 645-line corpus on Apple Silicon
|
|
|
641
641
|
jscpd at **84 ms** against PMD CPD at **35 980 ms**. But jscpd analysed **347** of the 547 files
|
|
642
642
|
and PMD **71**, so "the fastest of the set" holds and the factor of 428 does not.
|
|
643
643
|
|
|
644
|
-
###
|
|
644
|
+
### ⛔ Superseded by 9.1: what escomplex WAS needed for, before it was removed
|
|
645
645
|
|
|
646
646
|
`typhonjs-escomplex` is the only source for logical `SLOC`, the Halstead set and `MI`. It is also
|
|
647
647
|
far faster than previously recorded: **0.8 to 1.6 ms per file** in a single process, not the 23 to
|
|
@@ -673,7 +673,9 @@ The scan is the **module root**, one module at a time, with `node_modules`, `dis
|
|
|
673
673
|
`coverage`, the report directory, generated files and fixtures excluded, on top of the
|
|
674
674
|
`.gitignore` the tool already honours. Both halves are measured: without an ignore list the
|
|
675
675
|
sentinel package itself reports 18 186 files and 32 153 clones, **31 969** of them touching
|
|
676
|
-
`node_modules`; with it, 408 files and **zero**.
|
|
676
|
+
`node_modules`; with it, 408 files and **zero**. ⛔ The paragraph that follows is superseded by
|
|
677
|
+
10.1: the scan is **`src`**, or the module itself when it has none. It read "the root rather than
|
|
678
|
+
`src`" because the
|
|
677
679
|
manifest and the root configuration files are the authoritative entry points:
|
|
678
680
|
|
|
679
681
|
| module | from the root | from `src` |
|
|
@@ -682,7 +684,11 @@ manifest and the root configuration files are the authoritative entry points:
|
|
|
682
684
|
| `apps/nest/microservices/network` | 1.0% | 0.9% |
|
|
683
685
|
| `libs/front/components` | 30.3% | 30.3% |
|
|
684
686
|
|
|
685
|
-
### The
|
|
687
|
+
### The outputs, and one naming collision
|
|
688
|
+
|
|
689
|
+
⚠️ There are **four** shapes: `console`, `json`, `html` and `markdown`. The three npm scripts below
|
|
690
|
+
are correct and deliberate: `markdown` is the recap a CI workflow produces, not a command anyone
|
|
691
|
+
types, so it gets no script.
|
|
686
692
|
|
|
687
693
|
Every target name is a CLI flag, so `--format` already belongs to the format role and cannot mean
|
|
688
694
|
an output shape. `--json` already exists engine-wide, documented for every verb.
|
|
@@ -752,24 +758,47 @@ the line ranges.
|
|
|
752
758
|
|
|
753
759
|
### Metrics by source
|
|
754
760
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
761
|
+
⛔ **Superseded by 9.1 and 10.3.** Three rows below name a tool the role does not use. `escomplex`
|
|
762
|
+
was removed with Halstead, `MI` and logical `SLOC` (9.1): it fails to read 708 of 2 169 files of one
|
|
763
|
+
nest service, and Halstead's target is not defined anywhere. `madge` was never taken: the graph is
|
|
764
|
+
built from the role's own oxc parse, which costs 18 packages and 5.1 MB against madge's 136 and
|
|
765
|
+
48.4 MB. And jscpd's per-file `CC` is not published: its unit sums a file, so ten trivial functions
|
|
766
|
+
score what one function of 10 does. The table as shipped is below it.
|
|
767
|
+
|
|
768
|
+
| metric | tool |
|
|
769
|
+
| --------------------------------------------------------- | ---------------------- |
|
|
770
|
+
| clones, duplicated lines and tokens, share per language | jscpd |
|
|
771
|
+
| dead code, four categories, with a confidence per finding | jscpd (`basta`) |
|
|
772
|
+
| ~~`CC` per file and per folder, physical lines, tokens~~ | ~~jscpd~~ |
|
|
773
|
+
| ~~logical `SLOC`, Halstead, `MI`~~ | ~~escomplex~~ |
|
|
774
|
+
| ~~cycles, `Q`, density, `Ca` / `Ce` / `DC` / `I`~~ | ~~madge~~ + graphology |
|
|
775
|
+
|
|
776
|
+
**As shipped:**
|
|
777
|
+
|
|
778
|
+
| metric | tool |
|
|
779
|
+
| --------------------------------------------------------- | --------------------------------- |
|
|
780
|
+
| clones, duplicated lines and tokens, share per language | jscpd |
|
|
781
|
+
| dead code, four categories, with a confidence per finding | jscpd (`basta`) |
|
|
782
|
+
| `CC` per **function**, in McCabe's risk bands | the role, over the oxc parse |
|
|
783
|
+
| cycles, `Q`, density, `Ca` / `Ce` / `DC` / `I` | the role's own graph + graphology |
|
|
784
|
+
| nine size indicators | oxlint, at our own thresholds |
|
|
785
|
+
| the bundle, transfer and parsed | the TanStack entry manifest |
|
|
762
786
|
|
|
763
787
|
### The unit is part of the number
|
|
764
788
|
|
|
765
789
|
Two tools use the word complexity for two different units, and a report that omits them will look
|
|
766
790
|
like it contradicts itself in public:
|
|
767
791
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
792
|
+
⛔ **Superseded by 9.5.** `CX` is NOT a figure this role publishes: jscpd's per-file sum was
|
|
793
|
+
rejected once it was measured, and complexity here is per FUNCTION, computed by the role over the
|
|
794
|
+
oxc parse and reported in McCabe's bands. The row is kept because the unit collision it warns about
|
|
795
|
+
is still real for anyone comparing with another tool.
|
|
796
|
+
|
|
797
|
+
| | unit | value here |
|
|
798
|
+
| ------------------- | ------------------------------------------------- | ------------------------------ |
|
|
799
|
+
| oxlint `complexity` | per **function** | 10, the role's gauge threshold |
|
|
800
|
+
| this role | per **function**, McCabe | bands 11-20, 21-50, 51+ |
|
|
801
|
+
| ~~jscpd `CX`~~ | ~~per file, decision points plus function count~~ | ⛔ not published, see 9.5 |
|
|
773
802
|
|
|
774
803
|
So this role does not re-judge a single function: a per-function or per-file limit is a lint rule,
|
|
775
804
|
and oxlint already ships `complexity`, `max-lines`, `max-lines-per-function`, `max-depth`,
|
|
@@ -995,13 +1024,13 @@ touch no production file at all and the median touches **4**.
|
|
|
995
1024
|
|
|
996
1025
|
### 9.7 What is still a decision
|
|
997
1026
|
|
|
998
|
-
| open | what it needs
|
|
999
|
-
| ------------------------------------- |
|
|
1000
|
-
| the `/10` in the `MI
|
|
1001
|
-
| the criticality order of the **nine** | one argument per indicator. **Rarity no longer rules any out:** see 11.6, where the split is removed. Ranking them is still open
|
|
1002
|
-
| the overhead line of the SQL study | the harness rebuilt. #90 corrects the attribution and withdraws the 71%, which was never a ratio
|
|
1003
|
-
| the workspace index for libraries | built and measured in 10.4 and 10.5. Nothing open here any more
|
|
1004
|
-
| a feedback channel on findings | unchanged from section 8, and it is what would let any of this be proven wrong
|
|
1027
|
+
| open | what it needs |
|
|
1028
|
+
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1029
|
+
| ~~the `/10` in the `MI`~~ | ⛔ **Closed by 11.6**: there is no `MI` and no composite figure. The descriptor split it scaled rested on a break that was an artefact of the vendor thresholds |
|
|
1030
|
+
| the criticality order of the **nine** | one argument per indicator. **Rarity no longer rules any out:** see 11.6, where the split is removed. Ranking them is still open |
|
|
1031
|
+
| the overhead line of the SQL study | the harness rebuilt. #90 corrects the attribution and withdraws the 71%, which was never a ratio |
|
|
1032
|
+
| the workspace index for libraries | built and measured in 10.4 and 10.5. Nothing open here any more |
|
|
1033
|
+
| a feedback channel on findings | unchanged from section 8, and it is what would let any of this be proven wrong |
|
|
1005
1034
|
|
|
1006
1035
|
The one measurement that unblocks the first two is the same: **the eight indicators over all 198
|
|
1007
1036
|
modules, once.** That measurement now exists, in 10.7 over 177 modules and in 11 over a five-module
|
|
@@ -1116,6 +1145,17 @@ imports.
|
|
|
1116
1145
|
| `recruitment`, an app | 10 → 10 | → 1,1 % | - |
|
|
1117
1146
|
| `console`, an app | 2 → 2 | → 0,8 % | - |
|
|
1118
1147
|
|
|
1148
|
+
⚠️ **Two of those columns moved afterwards**, and the shipped figures are below. `unused files` now
|
|
1149
|
+
subtracts the files the tool itself labels `used-only-by-tests`, which were counted as unreachable
|
|
1150
|
+
while the tool knew better; and `used only by tests` became the UNION of two disjoint sources, that
|
|
1151
|
+
label and the workspace index. Re-measured on 2026-10-14 with 1.5.0-alpha.26:
|
|
1152
|
+
|
|
1153
|
+
| module | unused files | dead lines | used only by tests |
|
|
1154
|
+
| ----------------------- | -----------: | ---------: | -----------------: |
|
|
1155
|
+
| `libs/nest/auth` | **15** | 4,85 % | **11** |
|
|
1156
|
+
| `libs/front/components` | 8 | 1,42 % | 40 |
|
|
1157
|
+
| `console`, an app | 0 | 0,78 % | 2 |
|
|
1158
|
+
|
|
1119
1159
|
The two libraries move and the two applications do not, which is the shape the measurement
|
|
1120
1160
|
predicted: an application is imported by nothing, so its outside `Ca` is zero by construction.
|
|
1121
1161
|
|
|
@@ -1181,8 +1221,12 @@ moves every mtime without changing a byte.
|
|
|
1181
1221
|
|
|
1182
1222
|
### 10.6 The size indicators, and three silent traps
|
|
1183
1223
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1224
|
+
⚠️ **Two figures here were superseded by section 11**: there are **nine** rules, not seven, and
|
|
1225
|
+
they run at `min(research, our p90, the rule's default)` rather than at the threshold each rule
|
|
1226
|
+
publishes. The three traps below are unchanged and are why this section exists.
|
|
1227
|
+
|
|
1228
|
+
The size rules are measured with oxlint, run from a config in a temporary directory, and the
|
|
1229
|
+
module's own `.oxlintrc.json` is never read. Each trap below
|
|
1186
1230
|
cost a wrong measurement before it was found, and none of them produced an error:
|
|
1187
1231
|
|
|
1188
1232
|
- **oxlint loads 65 rules by default.** Neither `plugins: []` nor `categories: {}` stops it. On
|
|
@@ -1156,7 +1156,7 @@ $ sentinel --init --dry-run
|
|
|
1156
1156
|
build: no Vite config in this module, so there is nothing to point at sentinel. This role moves an existing config's imports; it does not introduce a bundler. If this module builds another way (38 services here build through `@nx/webpack`), the build role does not apply to it.
|
|
1157
1157
|
test: this module has no test and no jest leftovers, so there is nothing to do here.
|
|
1158
1158
|
dry run: no files written
|
|
1159
|
-
added the three `static-analysis` scripts and static-analysis.config.mjs; declare this module's entry points
|
|
1159
|
+
added the three `static-analysis` scripts and static-analysis.config.mjs; declare this module's entry points there, everything else comes from the role
|
|
1160
1160
|
|
|
1161
1161
|
create static-analysis.config.mjs
|
|
1162
1162
|
+ import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
|
|
@@ -1168,10 +1168,6 @@ $ sentinel --init --dry-run
|
|
|
1168
1168
|
+ // the patterns sentinel ships, never replacing them.
|
|
1169
1169
|
+ entry: [],
|
|
1170
1170
|
+
|
|
1171
|
-
+ // Named product journeys, as { 'the name product uses': ['src/some/path/**'] }. Paths are
|
|
1172
|
-
+ // relative to this module, because one run measures one module.
|
|
1173
|
-
+ journeys: {},
|
|
1174
|
-
+
|
|
1175
1171
|
+ // ---------------------------------------------------------------------------------------------
|
|
1176
1172
|
+ // What this module can move, with the role's value today. A setting you leave out keeps that
|
|
1177
1173
|
+ // value; a setting you write REPLACES it, including inside a list. Anything you set is printed
|
package/docs/using-sentinel.md
CHANGED
|
@@ -6,8 +6,8 @@ migrating a module see [`lint-adoption.md`](lint-adoption.md),
|
|
|
6
6
|
[`typescript-adoption.md`](typescript-adoption.md),
|
|
7
7
|
[`build-adoption.md`](build-adoption.md),
|
|
8
8
|
[`test-adoption.md`](test-adoption.md) and
|
|
9
|
-
[`static-analysis-adoption.md`](static-analysis-adoption.md)
|
|
10
|
-
migration against.
|
|
9
|
+
[`static-analysis-adoption.md`](static-analysis-adoption.md). The reference gate `--test` runs a
|
|
10
|
+
migration against is in `test-adoption.md`, not the static-analysis page.
|
|
11
11
|
|
|
12
12
|
## The grid
|
|
13
13
|
|
|
@@ -23,14 +23,15 @@ sentinel --run # no target -> every wired tar
|
|
|
23
23
|
Read it by target, since that is what you pick first. `--report` and `--status` are deprecated
|
|
24
24
|
everywhere (`--run --json` and `--inspect` answer them), and `--migrate` is refused everywhere.
|
|
25
25
|
|
|
26
|
-
| Target
|
|
27
|
-
|
|
|
28
|
-
| `--lint`
|
|
29
|
-
| `--format`
|
|
30
|
-
| `--typescript`
|
|
31
|
-
| `--build`
|
|
32
|
-
| `--dev`
|
|
33
|
-
| `--test`
|
|
26
|
+
| Target | `--init` writes | `--run` does | `--inspect` shows |
|
|
27
|
+
| ------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
28
|
+
| `--lint` | stub + scripts + nx metadata, removes the ESLint config and the module's ESLint deps, then autofixes once | lints; `--fix` autofixes in the same pass | rule count, what is disabled or downgraded and **why**, drift |
|
|
29
|
+
| `--format` | materializes the preset + scripts + nx metadata, removes the Prettier config and deps, then formats once | **checks**; `--fix` writes | effective options, every departure from the standard and why |
|
|
30
|
+
| `--typescript` | writes/extends the tsconfig + `typecheck` script | typechecks | resolved options, what is deferred, drift |
|
|
31
|
+
| `--build` | points the module's Vite config at sentinel's toolchain + scripts + nx metadata | builds | runner, config file, whose Vite, how many overrides, adoption |
|
|
32
|
+
| `--dev` | adopts the BUILD too: one plan writes both `build` and `serve` | serves; long-running, so never swept by an unqualified `--run` | refuses, and sends you to `--inspect --build`: it is that config |
|
|
33
|
+
| `--test` | the Vitest config + scripts + nx metadata, and migrates every spec file | runs Vitest | runner, which configs were found, adoption state, drift |
|
|
34
|
+
| `--static-analysis` | the config stub + three scripts + nx metadata, and ignores the report directory at the repository root | measures and reports; never exits non-zero on a finding | runner, config file, resolved settings, what this module moved |
|
|
34
35
|
|
|
35
36
|
`--json` works for every verb, and stdout carries **only** the envelope, so `| jq` always
|
|
36
37
|
parses. `--dry-run` applies to `--init` and writes nothing.
|
|
@@ -101,7 +102,9 @@ These options can weaken a check as easily as strengthen it, and `--run` is what
|
|
|
101
102
|
|
|
102
103
|
## What a fully adopted module looks like
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
Five files once a module has adopted every role, and none of them holds a rule. The fifth is
|
|
106
|
+
`static-analysis.config.mjs`, which holds the only thing that role asks a team to declare: the files
|
|
107
|
+
that run although nothing imports them.
|
|
105
108
|
|
|
106
109
|
```jsonc
|
|
107
110
|
// .oxlintrc.json — the rules come from the preset; the ignores must be here, because oxlint
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hublo/sentinel",
|
|
3
|
-
"version": "1.5.0-alpha.
|
|
3
|
+
"version": "1.5.0-alpha.28",
|
|
4
4
|
"description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/shared/deep-merge.ts","../src/roles/static-analysis/presets/shared.json","../src/roles/static-analysis/preset-data.ts","../src/roles/static-analysis/configs/define.ts"],"sourcesContent":["/**\n * Is this a plain JSON object?\n *\n * The file is named for a `deepMerge` that no longer exists. It was written when the engine\n * merged JSON in memory and wrote the result; that approach was replaced by editing the\n * document IN PLACE with jsonc-parser, so a project's comments, key order and formatting\n * survive a `merge-json` op. Nothing has called `deepMerge` since, except its own recursion,\n * so it went.\n *\n * What remains is the predicate the in-place merge still needs, to decide whether a value is\n * a branch to walk into or a leaf to set.\n */\n\n/** True for a mergeable plain object (not null, not an array). */\nexport function isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n","{\n \"entry\": [\n \"**/*.integration-spec.ts\",\n \"**/*.steps.ts\",\n \"src/client.tsx\",\n \"src/start.ts\",\n \"src/router.tsx\",\n \"src/RouterContext.tsx\"\n ],\n \"ignore\": [\n \"**/node_modules/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/coverage/**\",\n \"**/.next/**\",\n \"**/__mocks__/**\",\n \"**/mocks/**\",\n \"**/_mock_/**\",\n \"**/**Mock**/**\",\n \"**/__fixtures__/**\",\n \"**/fixtures/**\",\n \"**/public/**\",\n \"**/.husky/**\",\n \"**/.vscode/**\",\n \"**/.idea/**\",\n \"**/.github/**\",\n \"**/*.d.ts\",\n \"**/*.gen.ts\",\n \"**/*.generated.ts\"\n ],\n \"languages\": [\"javascript\", \"typescript\", \"jsx\", \"tsx\", \"css\", \"scss\"],\n \"journeys\": {},\n \"duplication\": {\n \"minTokens\": 50,\n \"minLines\": 5,\n \"kinds\": [\"exact\"],\n \"tests\": [\n \"*.{test,spec,steps,e2e-spec,integration-spec,stories}.{ts,tsx,js,jsx}\",\n \"*.{msw,mock,test-data}.{ts,tsx,js,jsx}\",\n \"{tests,__tests__,test,testing,stubs}/**/*.{ts,tsx,js,jsx}\"\n ]\n },\n \"deadCode\": {\n \"minConfidence\": 60,\n \"categories\": [\"unused-file\", \"unused-export\", \"unused-symbol\", \"unused-import\"]\n },\n \"graph\": {\n \"resolution\": 1\n },\n \"tools\": {\n \"jscpd\": {}\n }\n}\n","/**\n * The static analysis preset: the role's settings, as data.\n *\n * One shared layer and no per-stack preset yet, because nothing measured so far varies by stack:\n * duplication, dead code and complexity are the same questions in Nest and in React. The split\n * that is already foreseen is `LCOM4`, which is meaningless on a functional front end and real on\n * nest's 16 568 classes; when it arrives it becomes `nest.json` beside this layer, which is why\n * the values live here as JSON rather than inline in the code.\n *\n * ## Why these values are the role's and not the module's\n *\n * A team declares FACTS about its own domain (which journeys exist, which files are entry points);\n * it does not declare the values a number is judged by. Two files with the same measurement\n * getting different verdicts in two modules would make every figure incomparable, and the first\n * thing this role has to be able to say is that a number means the same thing everywhere.\n *\n * That is a default, not a wall: a module CAN move any of them (see `configs/define.ts`), and when\n * it does the report prints `(module)` next to the value. The seam exists from the first commit\n * because retrofitting one later means rewriting every module's file.\n *\n * ⛔ There are no thresholds here, and their absence is a decision rather than an omission.\n *\n * A threshold was written first, at 5% of duplicated lines, and then removed. Measured over all 228\n * nx projects: 5% flags 65 of the 201 measurable modules, a third of the repository, and moving the\n * figure until the count felt manageable is not calibration, it is fitting the instrument to the\n * answer. Calibration is legitimate when a bench is polluted and the noise is removed, or when the\n * reference frame changes; neither applies to a share of duplicated lines.\n *\n * And there is nothing to calibrate AGAINST: of the sources in `docs/static-analysis-study.md`\n * section 5, NDepend, Lattix, ArchUnit, CodeScene and Arcan, none publishes a threshold for\n * duplicated lines. One invented here would travel as if it were published.\n *\n * So the role reports the measurement. What makes a single figure readable is the distribution it\n * sits in, which only a workspace-wide run can know, so that belongs to the sweep and to the\n * migration page, not to a module's own report. The first defensible warning will not be an absolute\n * number either: a module that got WORSE than its own previous measurement is a signal that needs no\n * invented bar, which is what a baseline gives, and that was already deferred for its own reasons.\n *\n * ## Where each value comes from, so nobody has to guess later\n *\n * - `entry` is the single most consequential line in the file. Measured on\n * `apps/nest/microservices/network` with jscpd 5.4.0: with nothing declared the dead-code pass\n * reports **33.4%** of lines dead (79 657 lines, 232 files, 13 entry points); with these two\n * patterns declared it reports **1.0%** (2 462 lines, 207 entry points). The cause is a naming\n * convention and not a bug: our integration tests are `foo.integration-spec.ts` with a HYPHEN,\n * so they do not end in `.spec.ts` and the tool's own test-file detection never sees them, and\n * the 115 `.steps.ts` BDD definitions are tests by no convention it knows. A module ADDS to\n * this list rather than replacing it, precisely so no team can lose these two by declaring a\n * third.\n *\n * ⭐ The four `src/` files are the FRONT bootstrap, and they are the most consequential thing\n * measured on this pass. The tool recognises around fifty frameworks and TanStack Start is not\n * one of them, so nothing in the repository imports these files: the framework finds them by\n * convention at build time. Measured without them declared, `apps/front/console` reports\n * `src/client.tsx`, `src/router.tsx` and `src/start.ts` among its 5 dead files, which are its\n * hydration entry, its router factory and its Start config. `host-admin` reports `client.tsx`\n * too and `career` reports `router.tsx` and `RouterContext.tsx`. Three of console's five\n * findings were its own bootstrap, and the first team to see that stops trusting the tool on\n * the spot. They exist in 2 to 3 front apps each.\n *\n * ⛔ `scripts/**` is deliberately NOT here, although 8 modules have one and\n * `apps/nest/microservices/contract` reports two `scripts/e2e-*.ts` as dead. No `package.json`\n * script invokes them, so whether they run at all is a fact only the team holds. Declaring them\n * would hide a finding that may well be true, and that is the opposite of this role's job.\n *\n * - `languages` is this role's name for what the tool calls `format`, a word that already names\n * another sentinel role and means something else there. Stating it has a measured cost either\n * way: on `apps/front/console`, naming `typescript` alone scans 300 of 523 files because `.tsx`\n * is a SEPARATE language to the tool, naming all four scans 522 (the one left out being\n * `styles.css`), and naming nothing scans every one of its 224 languages, json and markdown\n * included. Stylesheets are in deliberately: measured on `libs/front` they duplicate at 9.64%\n * against 3.50% for the code, a signal that only survives because the report is broken down per\n * language.\n *\n * - `duplication.minTokens` and `minLines` are the detection window, and the window decides what\n * is INVISIBLE: a fragment shorter than it is not reported at all, so a reader comparing two\n * runs has to know both values. They are the tool's own defaults, written out rather than\n * inherited for that reason.\n *\n * - `duplication.kinds` is `exact` alone, and that is a correctness constraint rather than\n * caution. Measured: with a second kind enabled, `duplicatedLines` adds up overlapping clones.\n * Two 9-line files reported 13 duplicated lines out of 18, 72%, because an `exact` clone of\n * lines 1-4 and a `similar` clone of lines 1-9 containing it were both counted; the real extent\n * is 9 of 18, 50%. The tool's own answer is to cap the display at 100%, which does not make a\n * threshold meaningful. Opening another kind therefore requires computing the share ourselves\n * from the UNION of the line ranges.\n *\n * - `duplication.tests` is the glob that splits the pass in two, and the split is the finding that\n * forced it. Measured across 8 modules of the three families, **71% of all duplicated lines are\n * between two test files**: `libs/nest/auth` reports 35.77% and its product code is at 5.96%,\n * `apps/front/host-admin` 7.91% against 3.59%. A single blended figure is therefore mostly a\n * measure of spec duplication wearing the name of the module's duplication, and the number\n * travels while that nuance does not. So the role reports the two side by side, the way it\n * already refuses to blend languages, and neither is hidden: a module whose specs carry 93% of\n * its clones is saying something about its specs that excluding them would erase.\n *\n * ⚠️ The two passes PARTITION the files and therefore lose a clone that spans them: a fragment\n * shared between a test and a product file has no partner in either scan. Measured on the same\n * 8 modules, that is 42 entries of 5117, 0.8%, 650 lines. Stated in the caveat rather than\n * silently accepted, and it is the price of the split.\n *\n * Two patterns and not one, because a suffix convention does not cover a DIRECTORY convention,\n * and the directory one is not a quirk: 635 files across 26 modules live under `tests/` or\n * `__tests__/` without a name this role's suffixes recognise, `apps/nest/microservices/recruitment`\n * alone holding 160. They are step definitions, in-memory doubles and stubs, which is test-side\n * code by any reading. A role default rather than 26 identical overrides.\n *\n * ⚠️ The patterns are RELATIVE here, and the adapter prefixes and composes them, because the\n * tool's `--pattern` takes exactly one glob. Verified on four modules that the two passes\n * partition exactly: console 479 + 87 = 566, contract 399 + 151 = 550, backends-for-frontends/admin\n * 2062 + 773 = 2835, recruitment 936 + 621 = 1557.\n *\n * ⚠️ And a trap that cost a wrong table before it was caught: these globs contain commas, so they\n * survive the config FILE and are destroyed by the `-i` command-line flag, which splits on\n * commas and cuts `{test,spec}` in half. The adapter writes a file, which is one more reason it\n * does.\n *\n * - `deadCode.minConfidence` is the tool's own default of 60. It scores every finding and names\n * what lowered it (a file calling `eval`, an unrecognised decorator, a name appearing in a\n * string literal, a file in the scan that did not parse). Measured on sentinel: 60 yields 20\n * files, 10 exports and 22 symbols; 90 yields 18 files; 100 yields nothing.\n *\n * - `deadCode.categories` leaves out `unused-member` deliberately: without type information\n * `x.render()` could reach any `render` in the project, which the tool itself calls its least\n * certain rule.\n *\n * - `ignore` excludes generated, vendored and fixture code, which duplicates by nature and buries\n * the clones a developer can act on. It is load-bearing rather than tidy: measured on this\n * package, scanning the module root with no ignore list reports 18 186 files and 32 153 clones,\n * **31 969** of them touching `node_modules`; with the list, 408 files and zero.\n *\n * - `graph.resolution` is Louvain's, and it is 1 because that is the value the papers use and\n * graphology's own default. It decides how large a community must be to survive, so it changes\n * both the community count and `Q`, and graphology's documentation warns in its own words that\n * it is finicky to tweak. `code-health-meter` ships 0.74, arrived at by trying values until the\n * partition looked right; this role declines to do that, for the same reason it ships no\n * duplication threshold. A module that has a reason can move it, and the report prints the\n * value every figure was produced with.\n *\n * ⚠️ What is NOT tunable is the random seed, and that is deliberate. Louvain is greedy, so its\n * answer depends on the order it visits nodes, which graphology draws from `Math.random`.\n * Measured over 20 runs per module: `console` produced 16 distinct values of `Q` and between 12\n * and 14 communities, `recruitment` 19 distinct values spanning 0.0357. With the role's own\n * generator all five modules produce one value. A figure that moves on its own between two runs\n * is not a figure a team can act on, so this one is the role's and nobody's to change.\n *\n * - `tools.jscpd` is passed to the tool verbatim, and it is **sentinel's own** escape hatch, not a\n * module's: `defineStaticAnalysis` excludes it from what a module can override. Everything above\n * uses OUR key names, translated on the way out, so that replacing the tool one day does not\n * oblige a single module to edit its config file, and a module only ever knows the ROLE. This\n * block is where that guarantee stops, which is why it stays on this side and why it is empty:\n * the first thing to put in it would be an option a single preset needs, not a workaround.\n */\nimport sharedData from './presets/shared.json'\n\n/**\n * Where the json and html reports are written, relative to the module.\n *\n * Named for the role rather than left on the tool's default `report/`, and added to `.gitignore`\n * by `--init`. Both halves matter: the format role once shipped a `lint/` directory that nothing\n * ignored, and every adopting module grew an untracked directory nobody could explain.\n */\nexport const REPORT_DIR = 'static-analysis-report'\n\n/** The target this role serves, which is also the npm script a module runs and the nx target. */\nexport const TARGET = 'static-analysis'\n\n/** The output shapes `--run` can produce. `console` writes nothing to disk. */\nexport const SHAPES = ['console', 'json', 'html', 'markdown'] as const\n\n/**\n * The npm scripts a module gets, one per shape.\n *\n * Here rather than in the adapter because the REPORT needs them too, to tell a reader of the\n * console summary where the detail is, and two spellings of one script name is drift nobody\n * notices until a developer types the one that does not exist.\n */\nexport const SCRIPTS = {\n console: TARGET,\n json: `${TARGET}:json`,\n html: `${TARGET}:html`,\n // ⛔ No entry for the `markdown` shape, deliberately, and this is NOT a gap: these are the npm\n // scripts a module gets, and markdown is the recap a workflow posts on a pull request, built\n // from one file per affected module. A developer who wants it by hand gets the console shape,\n // which says the same thing in less space and writes nothing. A fourth script here would be a\n // command nobody runs, carried in 198 package.json files.\n} as const\n\n/**\n * What each language the role scans is called on disk.\n *\n * ⚠️ Two lists that MUST agree, and they were maintained separately: the role declares the\n * languages it asks the tool to scan, and the adapter declared the extensions it looks for when\n * deciding whether a module has anything to measure at all. A module that narrowed `languages`\n * would still be probed for the extensions it had removed, and a language added to the preset\n * would never reach the probe. Derived from one source now, with a test that every language maps.\n */\nconst EXTENSIONS_BY_LANGUAGE: Record<string, string[]> = {\n javascript: ['.js', '.mjs', '.cjs'],\n typescript: ['.ts'],\n jsx: ['.jsx'],\n tsx: ['.tsx'],\n css: ['.css'],\n scss: ['.scss'],\n}\n\n/** The file extensions of the languages given, for deciding whether a module has anything to read. */\nexport function extensionsOf(languages: readonly string[]): string[] {\n return [...new Set(languages.flatMap((language) => EXTENSIONS_BY_LANGUAGE[language] ?? []))]\n}\n\n/** Every language the mapping knows, so a test can hold it against the preset. */\nexport const MAPPED_LANGUAGES = Object.keys(EXTENSIONS_BY_LANGUAGE)\n\nexport type StaticDefaults = {\n entry: string[]\n ignore: string[]\n languages: string[]\n journeys: Record<string, string[]>\n duplication: { minTokens: number; minLines: number; kinds: string[]; tests: string[] }\n deadCode: { minConfidence: number; categories: string[] }\n graph: { resolution: number }\n tools: { jscpd: Record<string, unknown> }\n}\n\n/** The role's settings. Read-only here; `defineStaticAnalysis` resolves a module against them. */\nexport const defaults: StaticDefaults = sharedData as StaticDefaults\n\n/**\n * Keys a module declares DIRECTLY rather than through `overrides`, and how each one combines.\n *\n * They are apart because the verb differs: these two are added to and merged, so a team cannot\n * lose what the role ships by declaring its own. Everything else replaces.\n */\nconst DECLARED: Record<string, string> = {\n entry: 'added to the patterns the role ships, never replacing them',\n journeys: \"merged by name with the role's\",\n}\n\n/**\n * ⛔ Not a module's to set, and the catalogue says so rather than leaving it absent: a key that is\n * simply missing from a list reads as an oversight, and someone will try it. Passing it is dropped\n * with a message, see `configs/define.ts`.\n */\nconst NOT_YOURS: Record<string, string> = {\n tools: \"sentinel's own passthrough to the tool underneath; a module never names the vendor\",\n}\n\n/** A value as a reader compares it: short lists in full, long ones as a count. */\nfunction render(value: unknown): string {\n if (Array.isArray(value)) {\n const joined = value.join(', ')\n return value.length <= 6 && joined.length <= 72 ? joined : `${value.length} patterns`\n }\n if (value && typeof value === 'object') return `${Object.keys(value).length} entries`\n return String(value)\n}\n\n/** One documented key: its path, the role's value, and the note a reader needs. */\nexport interface OverridableSetting {\n /** The dotted path a module writes, e.g. `duplication.minTokens`. */\n key: string\n /** The role's current value, rendered. */\n value: string\n /** How it combines, or why it is not available. */\n note: string\n /** Where it goes: directly in the config, inside `overrides`, or nowhere. */\n where: 'declared' | 'overrides' | 'refused'\n}\n\n/**\n * Every setting a module can touch, DERIVED from the values rather than written beside them.\n *\n * Héla's ask, and the reason it is generated: a hand-written list is a second copy of the preset,\n * and the first setting somebody adds makes it a lie that nobody notices, because a stale comment\n * fails no test. Here a new key appears in the next module's config file on its own, with its real\n * value, and `tests/role-layout.test.ts` refuses a key that reaches none of the three groups.\n *\n * It is a snapshot: `--init` leaves an EXISTING config alone, by design, so a module that adopted\n * earlier keeps the catalogue of that day. The report is the live answer, since it prints the value\n * every figure was produced with.\n */\nexport function overridableSettings(from: StaticDefaults = defaults): OverridableSetting[] {\n const rows: OverridableSetting[] = []\n\n for (const [key, value] of Object.entries(from)) {\n if (key in NOT_YOURS) {\n rows.push({ key, value: render(value), note: NOT_YOURS[key] ?? '', where: 'refused' })\n continue\n }\n if (key in DECLARED) {\n rows.push({ key, value: render(value), note: DECLARED[key] ?? '', where: 'declared' })\n continue\n }\n // A group is documented by its leaves, because `duplication` whole is not what anyone writes.\n const leaves =\n value && typeof value === 'object' && !Array.isArray(value)\n ? Object.entries(value).map(([leaf, own]) => ({ key: `${key}.${leaf}`, own }))\n : [{ key, own: value }]\n for (const leaf of leaves) {\n rows.push({ key: leaf.key, value: render(leaf.own), note: 'replaced', where: 'overrides' })\n }\n }\n\n return rows\n}\n","/**\n * `defineStaticAnalysis`: what a module imports to declare its own static analysis settings.\n *\n * ```js\n * // static-analysis.config.mjs\n * import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'\n *\n * export default defineStaticAnalysis({\n * root: import.meta.dirname,\n * entry: ['**\\/*.steps.ts'],\n * journeys: { planning: ['src/planning/**'] },\n * })\n * ```\n *\n * ## Why a function and not `{ ...baseConfig, ... }`\n *\n * A spread is SHALLOW, so a module writing `deadCode: { minConfidence: 80 }` would replace the\n * whole block and silently lose `categories`. And the flattening happens in the module's file,\n * before this package ever sees the object, so nothing here could tell \"set one key, keep the\n * rest\" from \"set one key, drop the rest\". A function receives the module's INTENT, the keys it\n * actually passed, and merges them here. Second reason: the defaults are never copied into a\n * module, so changing one reaches every module at the next release instead of leaving 108 stale\n * copies behind.\n *\n * Same shape as `reactTestConfig` in the test role, for the same reasons.\n *\n * ## Two semantics, one obvious and one deliberate\n *\n * `entry` and `journeys` are FACTS a team declares, and they ADD to what this role ships: no\n * module can lose `**\\/*.integration-spec.ts` by declaring a third pattern, which matters\n * because that one pattern is the difference between 33.4% and 1.0% of dead code on a real\n * service (see `defaults.ts`).\n *\n * `overrides` REPLACES, including those lists. It is the seam for the day a team has a real\n * reason, it is not documented anywhere a developer would meet it, and every path it sets is\n * recorded in `moved` so the report can print `(module)` next to the value. Without that trace\n * two modules stop being comparable and nobody knows. It reaches every setting EXCEPT the vendor\n * passthrough, for the reason stated on the field itself.\n */\nimport { isPlainObject } from '../../../shared/deep-merge.js'\nimport { defaults, type StaticDefaults } from '../preset-data.js'\n\ntype DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }\n\n/** What a module's config file passes. Everything is optional, including the file itself. */\nexport interface StaticAnalysisInput {\n /** The module's own directory, normally `import.meta.dirname`. */\n root?: string\n /** Files that run although nothing imports them. ADDED to the shipped patterns. */\n entry?: string[]\n /** Named journeys, as `{ name: [globs] }`. MERGED by name with any shipped ones. */\n journeys?: Record<string, string[]>\n /**\n * The seam. Replaces whatever it names, and every path it sets lands in `moved`.\n *\n * ⛔ `tools` is excluded, and the reason is the whole point of translating the vocabulary: a\n * module knows the ROLE, not the tool underneath it. A key named after the vendor would mean\n * nothing to the person reading their own config file, and showing it would invite them to learn\n * a name we are working to keep out of their world. The passthrough still exists, on sentinel's\n * side, for a preset that needs a vendor option; a module that genuinely needs one gets a key in\n * OUR vocabulary, which is the right friction because it goes through a review instead of\n * spreading silently.\n */\n overrides?: DeepPartial<Omit<StaticDefaults, 'tools'>>\n}\n\nexport interface ResolvedStaticConfig extends StaticDefaults {\n /** The module's directory, or null when its config file did not say. */\n root: string | null\n /**\n * Dotted paths the module set through `overrides`, so the report can mark them. Sorted, so\n * two runs on the same config produce the same report.\n */\n moved: string[]\n}\n\n/**\n * Merge `patch` into `base`, recursively, patch winning.\n *\n * Not the `deepMerge` this repo once had: that one existed for WRITING a module's json and was\n * replaced by an in-place jsonc edit, which is why only `isPlainObject` survived. This is the\n * read side, which has no file to preserve and only has to resolve two objects into one.\n *\n * Arrays are replaced, never concatenated. Additive behaviour is a decision about a specific\n * key, which `defineStaticAnalysis` makes explicitly for `entry`, rather than a property of the\n * merge: a merge that silently concatenates cannot express \"replace this list\", and a team\n * reaching for `overrides` is asking for exactly that.\n */\nfunction mergeInto<T>(base: T, patch: unknown): T {\n if (!isPlainObject(patch)) return base\n if (!isPlainObject(base)) return patch as T\n const out: Record<string, unknown> = { ...base }\n for (const [key, value] of Object.entries(patch)) {\n out[key] = isPlainObject(value) ? mergeInto(out[key], value) : value\n }\n return out as T\n}\n\n/** Every leaf path in `patch`, dotted, so a reader can be told what the module decided. */\nfunction leafPaths(patch: unknown, prefix = ''): string[] {\n if (!isPlainObject(patch)) return prefix ? [prefix] : []\n return Object.entries(patch).flatMap(([key, value]) =>\n leafPaths(value, prefix ? `${prefix}.${key}` : key),\n )\n}\n\n/** The same list with duplicates dropped, keeping the first occurrence's order. */\nfunction unique(values: string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * Drop the vendor passthrough from what a module sent.\n *\n * ⚠️ Enforced here and not by the type, because the type cannot: a module's config file is `.mjs`,\n * so nothing type-checks it, and `Omit` on the input would be documentation that compiles away. A\n * `tools` key written there is dropped and NAMED on stderr rather than ignored, so a developer who\n * found the key somewhere learns why it did nothing instead of concluding the role is broken.\n */\nfunction withoutVendorKeys(overrides: unknown): unknown {\n if (!isPlainObject(overrides) || !('tools' in overrides)) return overrides\n const { tools: _vendor, ...rest } = overrides\n process.stderr.write(\n 'sentinel static-analysis: ignoring `overrides.tools` from this module. The tool underneath ' +\n \"the role is not part of a module's surface; if a setting is missing, it needs a key of \" +\n 'its own.\\n',\n )\n return rest\n}\n\n/** Resolve a module's declaration against this role's defaults. */\nexport function defineStaticAnalysis(input: StaticAnalysisInput = {}): ResolvedStaticConfig {\n const declared: StaticDefaults = {\n ...defaults,\n entry: unique([...defaults.entry, ...(input.entry ?? [])]),\n journeys: { ...defaults.journeys, ...(input.journeys ?? {}) },\n }\n const overrides = withoutVendorKeys(input.overrides)\n\n return {\n ...mergeInto(declared, overrides),\n root: input.root ?? null,\n moved: leafPaths(overrides).sort(),\n }\n}\n"],"mappings":"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","names":[]}
|