@hublo/sentinel 1.5.0-alpha.2 → 1.5.0-alpha.3
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 +2 -2
- package/dist/{chunk-GJGZRXM3.js → chunk-DVKTH3RY.js} +9 -4
- package/dist/{chunk-GJGZRXM3.js.map → chunk-DVKTH3RY.js.map} +1 -1
- package/dist/{chunk-AHA7LGGA.js → chunk-IHRYFU2R.js} +51 -23
- package/dist/index.js +2 -2
- package/dist/roles/static-analysis/config.d.ts +0 -4
- package/dist/roles/static-analysis/config.js +1 -1
- package/docs/static-analysis-study.md +30 -0
- package/docs/typescript-traces.md +138 -4
- package/package.json +1 -1
package/dist/bin/sentinel.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
palette,
|
|
16
16
|
registerAdapters,
|
|
17
17
|
resolve
|
|
18
|
-
} from "../chunk-
|
|
18
|
+
} from "../chunk-IHRYFU2R.js";
|
|
19
19
|
import {
|
|
20
20
|
resolveContext
|
|
21
21
|
} from "../chunk-E3MYVYEZ.js";
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
readOwnVersion,
|
|
27
27
|
readProjectPackageJson
|
|
28
28
|
} from "../chunk-KMKQDGI6.js";
|
|
29
|
-
import "../chunk-
|
|
29
|
+
import "../chunk-DVKTH3RY.js";
|
|
30
30
|
import "../chunk-O7REVMOC.js";
|
|
31
31
|
import "../chunk-QXFCZON7.js";
|
|
32
32
|
|
|
@@ -30,13 +30,18 @@ var shared_default = {
|
|
|
30
30
|
],
|
|
31
31
|
languages: ["javascript", "typescript", "jsx", "tsx", "css", "scss"],
|
|
32
32
|
journeys: {},
|
|
33
|
-
duplication: {
|
|
33
|
+
duplication: {
|
|
34
|
+
minTokens: 50,
|
|
35
|
+
minLines: 5,
|
|
36
|
+
kinds: ["exact"]
|
|
37
|
+
},
|
|
34
38
|
deadCode: {
|
|
35
39
|
minConfidence: 60,
|
|
36
40
|
categories: ["unused-file", "unused-export", "unused-symbol", "unused-import"]
|
|
37
41
|
},
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
tools: {
|
|
43
|
+
jscpd: {}
|
|
44
|
+
}
|
|
40
45
|
};
|
|
41
46
|
|
|
42
47
|
// src/roles/static-analysis/preset-data.ts
|
|
@@ -89,4 +94,4 @@ export {
|
|
|
89
94
|
REPORT_DIR,
|
|
90
95
|
defineStaticAnalysis
|
|
91
96
|
};
|
|
92
|
-
//# sourceMappingURL=chunk-
|
|
97
|
+
//# sourceMappingURL=chunk-DVKTH3RY.js.map
|
|
@@ -1 +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\": [\"**/*.integration-spec.ts\", \"**/*.steps.ts\"],\n \"ignore\": [\n \"**/node_modules/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/coverage/**\",\n \"**/.next/**\",\n \"**/static-analysis-report/**\",\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\": { \"minTokens\": 50, \"minLines\": 5, \"kinds\": [\"exact\"] },\n \"deadCode\": {\n \"minConfidence\": 60,\n \"categories\": [\"unused-file\", \"unused-export\", \"unused-symbol\", \"unused-import\"]\n },\n \"thresholds\": { \"duplicatedLinesPercent\": 5, \"deadCodePercent\": 5 },\n \"tools\": { \"jscpd\": {} }\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 * Both thresholds are a STARTING POINT and will be calibrated from the distribution the first full\n * run produces, the same commitment a preset rule carries in `CONTRIBUTING.md`. For scale,\n * sentinel's own package measures 1.4% duplicated lines and 1.3% dead code.\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 * - `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 * - `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 * - `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\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[] }\n deadCode: { minConfidence: number; categories: string[] }\n thresholds: { duplicatedLinesPercent: number; deadCodePercent: 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 * `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":";AAcO,SAAS,cAAc,OAAkD;AAC9E,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;;;AChBA;AAAA,EACE,OAAS,CAAC,4BAA4B,eAAe;AAAA,EACrD,QAAU;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,WAAa,CAAC,cAAc,cAAc,OAAO,OAAO,OAAO,MAAM;AAAA,EACrE,UAAY,CAAC;AAAA,EACb,aAAe,EAAE,WAAa,IAAI,UAAY,GAAG,OAAS,CAAC,OAAO,EAAE;AAAA,EACpE,UAAY;AAAA,IACV,eAAiB;AAAA,IACjB,YAAc,CAAC,eAAe,iBAAiB,iBAAiB,eAAe;AAAA,EACjF;AAAA,EACA,YAAc,EAAE,wBAA0B,GAAG,iBAAmB,EAAE;AAAA,EAClE,OAAS,EAAE,OAAS,CAAC,EAAE;AACzB;;;ACuDO,IAAM,aAAa;AAcnB,IAAM,WAA2B;;;ACdxC,SAAS,UAAa,MAAS,OAAmB;AAChD,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO;AAClC,MAAI,CAAC,cAAc,IAAI,EAAG,QAAO;AACjC,QAAM,MAA+B,EAAE,GAAG,KAAK;AAC/C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,GAAG,IAAI,cAAc,KAAK,IAAI,UAAU,IAAI,GAAG,GAAG,KAAK,IAAI;AAAA,EACjE;AACA,SAAO;AACT;AAGA,SAAS,UAAU,OAAgB,SAAS,IAAc;AACxD,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO,SAAS,CAAC,MAAM,IAAI,CAAC;AACvD,SAAO,OAAO,QAAQ,KAAK,EAAE;AAAA,IAAQ,CAAC,CAAC,KAAK,KAAK,MAC/C,UAAU,OAAO,SAAS,GAAG,MAAM,IAAI,GAAG,KAAK,GAAG;AAAA,EACpD;AACF;AAGA,SAAS,OAAO,QAA4B;AAC1C,SAAO,CAAC,GAAG,IAAI,IAAI,MAAM,CAAC;AAC5B;AAUA,SAAS,kBAAkB,WAA6B;AACtD,MAAI,CAAC,cAAc,SAAS,KAAK,EAAE,WAAW,WAAY,QAAO;AACjE,QAAM,EAAE,OAAO,SAAS,GAAG,KAAK,IAAI;AACpC,UAAQ,OAAO;AAAA,IACb;AAAA,EAGF;AACA,SAAO;AACT;AAGO,SAAS,qBAAqB,QAA6B,CAAC,GAAyB;AAC1F,QAAM,WAA2B;AAAA,IAC/B,GAAG;AAAA,IACH,OAAO,OAAO,CAAC,GAAG,SAAS,OAAO,GAAI,MAAM,SAAS,CAAC,CAAE,CAAC;AAAA,IACzD,UAAU,EAAE,GAAG,SAAS,UAAU,GAAI,MAAM,YAAY,CAAC,EAAG;AAAA,EAC9D;AACA,QAAM,YAAY,kBAAkB,MAAM,SAAS;AAEnD,SAAO;AAAA,IACL,GAAG,UAAU,UAAU,SAAS;AAAA,IAChC,MAAM,MAAM,QAAQ;AAAA,IACpB,OAAO,UAAU,SAAS,EAAE,KAAK;AAAA,EACnC;AACF;","names":[]}
|
|
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\": [\"**/*.integration-spec.ts\", \"**/*.steps.ts\"],\n \"ignore\": [\n \"**/node_modules/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/coverage/**\",\n \"**/.next/**\",\n \"**/static-analysis-report/**\",\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 },\n \"deadCode\": {\n \"minConfidence\": 60,\n \"categories\": [\"unused-file\", \"unused-export\", \"unused-symbol\", \"unused-import\"]\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 * - `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 * - `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 * - `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\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[] }\n deadCode: { minConfidence: number; categories: string[] }\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 * `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":";AAcO,SAAS,cAAc,OAAkD;AAC9E,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;;;AChBA;AAAA,EACE,OAAS,CAAC,4BAA4B,eAAe;AAAA,EACrD,QAAU;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,WAAa,CAAC,cAAc,cAAc,OAAO,OAAO,OAAO,MAAM;AAAA,EACrE,UAAY,CAAC;AAAA,EACb,aAAe;AAAA,IACb,WAAa;AAAA,IACb,UAAY;AAAA,IACZ,OAAS,CAAC,OAAO;AAAA,EACnB;AAAA,EACA,UAAY;AAAA,IACV,eAAiB;AAAA,IACjB,YAAc,CAAC,eAAe,iBAAiB,iBAAiB,eAAe;AAAA,EACjF;AAAA,EACA,OAAS;AAAA,IACP,OAAS,CAAC;AAAA,EACZ;AACF;;;ACgEO,IAAM,aAAa;AAanB,IAAM,WAA2B;;;AC3BxC,SAAS,UAAa,MAAS,OAAmB;AAChD,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO;AAClC,MAAI,CAAC,cAAc,IAAI,EAAG,QAAO;AACjC,QAAM,MAA+B,EAAE,GAAG,KAAK;AAC/C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,GAAG,IAAI,cAAc,KAAK,IAAI,UAAU,IAAI,GAAG,GAAG,KAAK,IAAI;AAAA,EACjE;AACA,SAAO;AACT;AAGA,SAAS,UAAU,OAAgB,SAAS,IAAc;AACxD,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO,SAAS,CAAC,MAAM,IAAI,CAAC;AACvD,SAAO,OAAO,QAAQ,KAAK,EAAE;AAAA,IAAQ,CAAC,CAAC,KAAK,KAAK,MAC/C,UAAU,OAAO,SAAS,GAAG,MAAM,IAAI,GAAG,KAAK,GAAG;AAAA,EACpD;AACF;AAGA,SAAS,OAAO,QAA4B;AAC1C,SAAO,CAAC,GAAG,IAAI,IAAI,MAAM,CAAC;AAC5B;AAUA,SAAS,kBAAkB,WAA6B;AACtD,MAAI,CAAC,cAAc,SAAS,KAAK,EAAE,WAAW,WAAY,QAAO;AACjE,QAAM,EAAE,OAAO,SAAS,GAAG,KAAK,IAAI;AACpC,UAAQ,OAAO;AAAA,IACb;AAAA,EAGF;AACA,SAAO;AACT;AAGO,SAAS,qBAAqB,QAA6B,CAAC,GAAyB;AAC1F,QAAM,WAA2B;AAAA,IAC/B,GAAG;AAAA,IACH,OAAO,OAAO,CAAC,GAAG,SAAS,OAAO,GAAI,MAAM,SAAS,CAAC,CAAE,CAAC;AAAA,IACzD,UAAU,EAAE,GAAG,SAAS,UAAU,GAAI,MAAM,YAAY,CAAC,EAAG;AAAA,EAC9D;AACA,QAAM,YAAY,kBAAkB,MAAM,SAAS;AAEnD,SAAO;AAAA,IACL,GAAG,UAAU,UAAU,SAAS;AAAA,IAChC,MAAM,MAAM,QAAQ;AAAA,IACpB,OAAO,UAAU,SAAS,EAAE,KAAK;AAAA,EACnC;AACF;","names":[]}
|
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
REPORT_DIR,
|
|
66
66
|
defineStaticAnalysis,
|
|
67
67
|
isPlainObject
|
|
68
|
-
} from "./chunk-
|
|
68
|
+
} from "./chunk-DVKTH3RY.js";
|
|
69
69
|
import {
|
|
70
70
|
parseJsonc
|
|
71
71
|
} from "./chunk-QXFCZON7.js";
|
|
@@ -4932,7 +4932,7 @@ function fileSettings(config) {
|
|
|
4932
4932
|
...config.tools.jscpd
|
|
4933
4933
|
};
|
|
4934
4934
|
}
|
|
4935
|
-
function duplicationRun(config,
|
|
4935
|
+
function duplicationRun(config, paths) {
|
|
4936
4936
|
const dir = mkdtempSync(join18(tmpdir(), "sentinel-static-"));
|
|
4937
4937
|
const configPath = join18(dir, "jscpd.json");
|
|
4938
4938
|
writeFileSync3(configPath, `${JSON.stringify(fileSettings(config), null, 2)}
|
|
@@ -4948,11 +4948,11 @@ function duplicationRun(config, reportDir, paths) {
|
|
|
4948
4948
|
"--reporters",
|
|
4949
4949
|
"json",
|
|
4950
4950
|
"--output",
|
|
4951
|
-
|
|
4951
|
+
dir,
|
|
4952
4952
|
"--no-tips",
|
|
4953
4953
|
...paths
|
|
4954
4954
|
];
|
|
4955
|
-
return { args, dispose: () => rmSync3(dir, { force: true, recursive: true }) };
|
|
4955
|
+
return { args, rawReportDir: dir, dispose: () => rmSync3(dir, { force: true, recursive: true }) };
|
|
4956
4956
|
}
|
|
4957
4957
|
|
|
4958
4958
|
// src/roles/static-analysis/adapters/jscpd/resolve.ts
|
|
@@ -5018,7 +5018,15 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
5018
5018
|
]
|
|
5019
5019
|
};
|
|
5020
5020
|
}
|
|
5021
|
-
|
|
5021
|
+
/**
|
|
5022
|
+
* Measure, and emit only when asked to.
|
|
5023
|
+
*
|
|
5024
|
+
* `--run` prints and may write a file; `--report` answers a machine and must do neither, which is
|
|
5025
|
+
* the only difference between the two verbs here. Shared rather than duplicated because the
|
|
5026
|
+
* measurement is where every reserve lives: two copies would drift, and the one that drifted
|
|
5027
|
+
* would be the one nobody reads.
|
|
5028
|
+
*/
|
|
5029
|
+
async measure(ctx, emitting) {
|
|
5022
5030
|
const shape = ctx.output ?? "console";
|
|
5023
5031
|
if (!SHAPES.includes(shape)) {
|
|
5024
5032
|
process.stderr.write(
|
|
@@ -5057,9 +5065,7 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
5057
5065
|
return { ok: false, code: 1 };
|
|
5058
5066
|
}
|
|
5059
5067
|
const { paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, { valueFlags: ["-c", "--config"] });
|
|
5060
|
-
const
|
|
5061
|
-
mkdirSync(reportDir, { recursive: true });
|
|
5062
|
-
const { args, dispose } = duplicationRun(config, reportDir, paths.length > 0 ? paths : ["."]);
|
|
5068
|
+
const { args, rawReportDir, dispose } = duplicationRun(config, paths.length > 0 ? paths : ["."]);
|
|
5063
5069
|
try {
|
|
5064
5070
|
const result = spawnSync5(jscpd, args, { cwd: ctx.cwd, encoding: "utf8" });
|
|
5065
5071
|
if (result.error) {
|
|
@@ -5070,26 +5076,44 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
5070
5076
|
return { ok: false, code: 1 };
|
|
5071
5077
|
}
|
|
5072
5078
|
if (result.status !== 0) {
|
|
5079
|
+
if (analysedNothing(result.stderr)) {
|
|
5080
|
+
process.stderr.write(
|
|
5081
|
+
`sentinel static-analysis(jscpd): nothing left to measure in this module once the exclusions applied, so no figure is reported (a figure of 0% would be a different claim).
|
|
5082
|
+
`
|
|
5083
|
+
);
|
|
5084
|
+
return { ok: true, code: 0 };
|
|
5085
|
+
}
|
|
5073
5086
|
process.stderr.write(
|
|
5074
5087
|
`sentinel static-analysis(jscpd): jscpd exited ${result.status}
|
|
5075
5088
|
${result.stderr ?? ""}`
|
|
5076
5089
|
);
|
|
5077
5090
|
return { ok: false, code: result.status ?? 1 };
|
|
5078
5091
|
}
|
|
5079
|
-
const report = buildReport(ctx,
|
|
5092
|
+
const report = buildReport(ctx, rawReportDir);
|
|
5080
5093
|
if (!report) {
|
|
5081
|
-
process.stderr.write(
|
|
5082
|
-
|
|
5083
|
-
`
|
|
5084
|
-
);
|
|
5094
|
+
process.stderr.write(`sentinel static-analysis(jscpd): jscpd wrote no report
|
|
5095
|
+
`);
|
|
5085
5096
|
return { ok: false, code: 1 };
|
|
5086
5097
|
}
|
|
5087
|
-
emit(report, shape,
|
|
5098
|
+
if (emitting) emit(report, shape, ctx.cwd);
|
|
5088
5099
|
return { ok: true, code: 0, metrics: metricsOf(report) };
|
|
5089
5100
|
} finally {
|
|
5090
5101
|
dispose();
|
|
5091
5102
|
}
|
|
5092
5103
|
}
|
|
5104
|
+
/** `--run`: measure and show it. */
|
|
5105
|
+
async run(ctx) {
|
|
5106
|
+
return this.measure(ctx, true);
|
|
5107
|
+
}
|
|
5108
|
+
/**
|
|
5109
|
+
* `--report`: the same figures, for a machine.
|
|
5110
|
+
*
|
|
5111
|
+
* It writes nothing and prints nothing: the caller is the `--json` envelope or the CI comment,
|
|
5112
|
+
* and a role that also printed its table would make the same numbers arrive twice in two shapes.
|
|
5113
|
+
*/
|
|
5114
|
+
async report(ctx) {
|
|
5115
|
+
return this.measure(ctx, false);
|
|
5116
|
+
}
|
|
5093
5117
|
/**
|
|
5094
5118
|
* Adoption, from the COMMITTED manifest only, so a workspace-wide sweep stays cheap and needs no
|
|
5095
5119
|
* install. The script is the marker, which is also how the migration page follows this role.
|
|
@@ -5110,7 +5134,10 @@ ${result.stderr ?? ""}`
|
|
|
5110
5134
|
};
|
|
5111
5135
|
}
|
|
5112
5136
|
};
|
|
5113
|
-
function
|
|
5137
|
+
function analysedNothing(stderr) {
|
|
5138
|
+
return typeof stderr === "string" && stderr.includes("--fail-on-empty");
|
|
5139
|
+
}
|
|
5140
|
+
function buildReport(ctx, reportDir) {
|
|
5114
5141
|
const path = join19(reportDir, "jscpd-report.json");
|
|
5115
5142
|
if (!existsSync14(path)) return void 0;
|
|
5116
5143
|
let parsed;
|
|
@@ -5121,17 +5148,18 @@ function buildReport(ctx, reportDir, config) {
|
|
|
5121
5148
|
}
|
|
5122
5149
|
const total = parsed.statistics?.total;
|
|
5123
5150
|
if (!total) return void 0;
|
|
5124
|
-
const limit = config.thresholds.duplicatedLinesPercent;
|
|
5125
|
-
const moved = config.moved.includes("thresholds.duplicatedLinesPercent");
|
|
5126
5151
|
const percent = Number((total.percentage ?? 0).toFixed(2));
|
|
5127
5152
|
const rows = [
|
|
5128
5153
|
{
|
|
5129
5154
|
metric: "duplicatedLinesPercent",
|
|
5130
5155
|
value: percent,
|
|
5131
5156
|
unit: "%",
|
|
5132
|
-
threshold:
|
|
5133
|
-
|
|
5134
|
-
|
|
5157
|
+
// No threshold, and none is coming from here: see `preset-data.ts`. The figure is reported as
|
|
5158
|
+
// measured, and what makes it readable is the distribution it sits in, which one module
|
|
5159
|
+
// cannot know.
|
|
5160
|
+
threshold: null,
|
|
5161
|
+
over: false,
|
|
5162
|
+
moved: false
|
|
5135
5163
|
},
|
|
5136
5164
|
{
|
|
5137
5165
|
metric: "clones",
|
|
@@ -5155,15 +5183,15 @@ function buildReport(ctx, reportDir, config) {
|
|
|
5155
5183
|
);
|
|
5156
5184
|
return { module: ctx.module, passes: ["duplication"], rows, byLanguage };
|
|
5157
5185
|
}
|
|
5158
|
-
function emit(report, shape,
|
|
5186
|
+
function emit(report, shape, cwd) {
|
|
5159
5187
|
if (shape === "console") {
|
|
5160
5188
|
process.stdout.write(renderConsole(report));
|
|
5161
5189
|
return;
|
|
5162
5190
|
}
|
|
5163
5191
|
const name = shape === "json" ? "static-analysis.json" : "static-analysis.html";
|
|
5164
5192
|
const body = shape === "json" ? renderJson(report) : renderHtml(report);
|
|
5165
|
-
|
|
5166
|
-
writeFileSync4(
|
|
5193
|
+
mkdirSync(join19(cwd, REPORT_DIR), { recursive: true });
|
|
5194
|
+
writeFileSync4(join19(cwd, REPORT_DIR, name), body, "utf8");
|
|
5167
5195
|
process.stdout.write(`${renderConsole(report)}
|
|
5168
5196
|
written: ${REPORT_DIR}/${name}
|
|
5169
5197
|
`);
|
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-IHRYFU2R.js";
|
|
10
10
|
import {
|
|
11
11
|
BaseAdapter
|
|
12
12
|
} from "./chunk-E3MYVYEZ.js";
|
|
13
13
|
import "./chunk-KMKQDGI6.js";
|
|
14
|
-
import "./chunk-
|
|
14
|
+
import "./chunk-DVKTH3RY.js";
|
|
15
15
|
import "./chunk-O7REVMOC.js";
|
|
16
16
|
import "./chunk-QXFCZON7.js";
|
|
17
17
|
export {
|
|
@@ -157,6 +157,29 @@ to centrality, ranking all 12 372 files twice:
|
|
|
157
157
|
**Rank correlation DC against LOC: 0.388.** Far from 1, so the two rankings designate different
|
|
158
158
|
files and centrality carries information size does not. The criticism does not bite here.
|
|
159
159
|
|
|
160
|
+
### What centrality is evidence FOR, and where that evidence stops
|
|
161
|
+
|
|
162
|
+
Two measurements above say what centrality is NOT: not file size in disguise, not a proxy for change
|
|
163
|
+
frequency. Neither says what it is good for, and the literature does.
|
|
164
|
+
|
|
165
|
+
| source | what it establishes |
|
|
166
|
+
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
167
|
+
| Zimmermann and Nagappan, ICSE 2008, on Windows Server 2003 | models built from network measures reach a recall **10 percentage points** higher than models built from complexity metrics, and network measures identified **60%** of the binaries the developers themselves called critical, **twice** what complexity metrics found |
|
|
168
|
+
| Tosun, Turhan and Bener, PROMISE 2009, five systems (three small embedded, two Eclipse versions) | network measures are important indicators of defective modules for large and complex systems, **and have no significant effect on small-scale projects** |
|
|
169
|
+
|
|
170
|
+
⛔ The second one is a boundary on everything this role can claim, so it is written here rather than
|
|
171
|
+
left implicit. Centrality's predictive value is demonstrated on LARGE systems. A module of forty
|
|
172
|
+
files gets a `DC` figure that means exactly what it says about its graph and nothing at all about
|
|
173
|
+
where its defects are. `host-admin` at 5 880 files sits on one side of that line; most of
|
|
174
|
+
`libs/nest` sits on the other. It becomes the `caveat` of the centrality help entry, unchanged.
|
|
175
|
+
|
|
176
|
+
And it settles what the number is FOR, which is a question the role has to answer before it prints
|
|
177
|
+
anything. Test impact analysis selects: given this diff, which tests can catch a defect in it.
|
|
178
|
+
Centrality is a property of the code and knows nothing about a diff, so it cannot select. What it can
|
|
179
|
+
do is ORDER: which tests to run first, and where strengthening tests pays. The two compose, and the
|
|
180
|
+
composition has a sharp consequence worth stating: a highly central file is precisely the one where
|
|
181
|
+
impact-based selection saves nothing, because changing it reaches almost everything.
|
|
182
|
+
|
|
160
183
|
### Centrality is not a proxy for change frequency
|
|
161
184
|
|
|
162
185
|
The hypothesis was that a central file, being heavily used, is frequently changed, which would
|
|
@@ -771,6 +794,13 @@ The two are complementary as long as every figure carries its unit.
|
|
|
771
794
|
- Ben Khalfallah, H. _Code Health Meter: A Quantitative and Graph-Theoretic Foundation for
|
|
772
795
|
Automated Code Quality and Architecture Assessment._ ACM TOSEM 35(4), article 114, March 2026.
|
|
773
796
|
<https://doi.org/10.1145/3737670>
|
|
797
|
+
- Zimmermann, T. and Nagappan, N. _Predicting Defects using Network Analysis on Dependency Graphs._
|
|
798
|
+
ICSE 2008.
|
|
799
|
+
<https://www.microsoft.com/en-us/research/publication/predicting-defects-using-network-analysis-on-dependency-graphs/>
|
|
800
|
+
- Tosun, A., Turhan, B. and Bener, A. _Validation of network measures as indicators of defective
|
|
801
|
+
modules in software systems._ PROMISE 2009. <https://doi.org/10.1145/1540438.1540446>
|
|
802
|
+
- Hammant, P. _The Rise of Test Impact Analysis._ martinfowler.com, August 2017.
|
|
803
|
+
<https://martinfowler.com/articles/rise-test-impact-analysis.html>
|
|
774
804
|
- Hassan, A. E. _Predicting Faults Using the Complexity of Code Changes._ ICSE 2009.
|
|
775
805
|
<https://sailresearch.github.io/sail-website/data/pdfs/ICSE2009_PredictingFaultsUsingTheComplexityOfCodeChanges.pdf>
|
|
776
806
|
- Sadowski, C. et al. _Tricorder: Building a Program Analysis Ecosystem._ Google.
|
|
@@ -235,10 +235,10 @@ $ sentinel --report --json 2>/dev/null
|
|
|
235
235
|
"format",
|
|
236
236
|
"typescript",
|
|
237
237
|
"build",
|
|
238
|
-
"test"
|
|
238
|
+
"test",
|
|
239
|
+
"static-analysis"
|
|
239
240
|
],
|
|
240
241
|
"skipped": [
|
|
241
|
-
"static-analysis",
|
|
242
242
|
"runtime-analysis",
|
|
243
243
|
"arch"
|
|
244
244
|
],
|
|
@@ -309,6 +309,16 @@ $ sentinel --report --json 2>/dev/null
|
|
|
309
309
|
"preset": null,
|
|
310
310
|
"conformant": false
|
|
311
311
|
},
|
|
312
|
+
{
|
|
313
|
+
"project": "svelte-app",
|
|
314
|
+
"target": "static-analysis",
|
|
315
|
+
"flavour": "svelte",
|
|
316
|
+
"ok": true,
|
|
317
|
+
"status": "ok",
|
|
318
|
+
"adopted": false,
|
|
319
|
+
"preset": null,
|
|
320
|
+
"conformant": false
|
|
321
|
+
},
|
|
312
322
|
{
|
|
313
323
|
"project": "bff-admin",
|
|
314
324
|
"target": "lint",
|
|
@@ -375,6 +385,16 @@ $ sentinel --report --json 2>/dev/null
|
|
|
375
385
|
"preset": null,
|
|
376
386
|
"conformant": false
|
|
377
387
|
},
|
|
388
|
+
{
|
|
389
|
+
"project": "bff-admin",
|
|
390
|
+
"target": "static-analysis",
|
|
391
|
+
"flavour": "nest",
|
|
392
|
+
"ok": true,
|
|
393
|
+
"status": "ok",
|
|
394
|
+
"adopted": false,
|
|
395
|
+
"preset": null,
|
|
396
|
+
"conformant": false
|
|
397
|
+
},
|
|
378
398
|
{
|
|
379
399
|
"project": "react-app",
|
|
380
400
|
"target": "lint",
|
|
@@ -441,6 +461,16 @@ $ sentinel --report --json 2>/dev/null
|
|
|
441
461
|
"preset": null,
|
|
442
462
|
"conformant": false
|
|
443
463
|
},
|
|
464
|
+
{
|
|
465
|
+
"project": "react-app",
|
|
466
|
+
"target": "static-analysis",
|
|
467
|
+
"flavour": "react",
|
|
468
|
+
"ok": true,
|
|
469
|
+
"status": "ok",
|
|
470
|
+
"adopted": false,
|
|
471
|
+
"preset": null,
|
|
472
|
+
"conformant": false
|
|
473
|
+
},
|
|
444
474
|
{
|
|
445
475
|
"project": "utils",
|
|
446
476
|
"target": "lint",
|
|
@@ -494,6 +524,16 @@ $ sentinel --report --json 2>/dev/null
|
|
|
494
524
|
"adopted": false,
|
|
495
525
|
"preset": null,
|
|
496
526
|
"conformant": false
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
"project": "utils",
|
|
530
|
+
"target": "static-analysis",
|
|
531
|
+
"flavour": "node",
|
|
532
|
+
"ok": true,
|
|
533
|
+
"status": "ok",
|
|
534
|
+
"adopted": false,
|
|
535
|
+
"preset": null,
|
|
536
|
+
"conformant": false
|
|
497
537
|
}
|
|
498
538
|
]
|
|
499
539
|
}
|
|
@@ -525,10 +565,10 @@ $ sentinel --report --all --json 2>/dev/null
|
|
|
525
565
|
"format",
|
|
526
566
|
"typescript",
|
|
527
567
|
"build",
|
|
528
|
-
"test"
|
|
568
|
+
"test",
|
|
569
|
+
"static-analysis"
|
|
529
570
|
],
|
|
530
571
|
"skipped": [
|
|
531
|
-
"static-analysis",
|
|
532
572
|
"runtime-analysis",
|
|
533
573
|
"arch"
|
|
534
574
|
],
|
|
@@ -599,6 +639,16 @@ $ sentinel --report --all --json 2>/dev/null
|
|
|
599
639
|
"preset": null,
|
|
600
640
|
"conformant": false
|
|
601
641
|
},
|
|
642
|
+
{
|
|
643
|
+
"project": "svelte-app",
|
|
644
|
+
"target": "static-analysis",
|
|
645
|
+
"flavour": "svelte",
|
|
646
|
+
"ok": true,
|
|
647
|
+
"status": "ok",
|
|
648
|
+
"adopted": false,
|
|
649
|
+
"preset": null,
|
|
650
|
+
"conformant": false
|
|
651
|
+
},
|
|
602
652
|
{
|
|
603
653
|
"project": "bff-admin",
|
|
604
654
|
"target": "lint",
|
|
@@ -665,6 +715,16 @@ $ sentinel --report --all --json 2>/dev/null
|
|
|
665
715
|
"preset": null,
|
|
666
716
|
"conformant": false
|
|
667
717
|
},
|
|
718
|
+
{
|
|
719
|
+
"project": "bff-admin",
|
|
720
|
+
"target": "static-analysis",
|
|
721
|
+
"flavour": "nest",
|
|
722
|
+
"ok": true,
|
|
723
|
+
"status": "ok",
|
|
724
|
+
"adopted": false,
|
|
725
|
+
"preset": null,
|
|
726
|
+
"conformant": false
|
|
727
|
+
},
|
|
668
728
|
{
|
|
669
729
|
"project": "react-app",
|
|
670
730
|
"target": "lint",
|
|
@@ -731,6 +791,16 @@ $ sentinel --report --all --json 2>/dev/null
|
|
|
731
791
|
"preset": null,
|
|
732
792
|
"conformant": false
|
|
733
793
|
},
|
|
794
|
+
{
|
|
795
|
+
"project": "react-app",
|
|
796
|
+
"target": "static-analysis",
|
|
797
|
+
"flavour": "react",
|
|
798
|
+
"ok": true,
|
|
799
|
+
"status": "ok",
|
|
800
|
+
"adopted": false,
|
|
801
|
+
"preset": null,
|
|
802
|
+
"conformant": false
|
|
803
|
+
},
|
|
734
804
|
{
|
|
735
805
|
"project": "utils",
|
|
736
806
|
"target": "lint",
|
|
@@ -784,6 +854,16 @@ $ sentinel --report --all --json 2>/dev/null
|
|
|
784
854
|
"adopted": false,
|
|
785
855
|
"preset": null,
|
|
786
856
|
"conformant": false
|
|
857
|
+
},
|
|
858
|
+
{
|
|
859
|
+
"project": "utils",
|
|
860
|
+
"target": "static-analysis",
|
|
861
|
+
"flavour": "node",
|
|
862
|
+
"ok": true,
|
|
863
|
+
"status": "ok",
|
|
864
|
+
"adopted": false,
|
|
865
|
+
"preset": null,
|
|
866
|
+
"conformant": false
|
|
787
867
|
}
|
|
788
868
|
]
|
|
789
869
|
}
|
|
@@ -1076,6 +1156,60 @@ $ sentinel --init --dry-run
|
|
|
1076
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.
|
|
1077
1157
|
test: this module has no test and no jest leftovers, so there is nothing to do here.
|
|
1078
1158
|
dry run: no files written
|
|
1159
|
+
added the three `static-analysis` scripts and static-analysis.config.mjs; declare this module's entry points and journeys there, everything else comes from the role
|
|
1160
|
+
|
|
1161
|
+
create static-analysis.config.mjs
|
|
1162
|
+
+ import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
|
|
1163
|
+
+
|
|
1164
|
+
+ export default defineStaticAnalysis({
|
|
1165
|
+
+ root: import.meta.dirname,
|
|
1166
|
+
+
|
|
1167
|
+
+ // Files that RUN although nothing imports them: they are where reachability starts. Added to
|
|
1168
|
+
+ // the patterns sentinel ships, never replacing them.
|
|
1169
|
+
+ entry: [],
|
|
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
|
+
|
|
1176
|
+
update package.json
|
|
1177
|
+
- { "name": "bff-admin", "dependencies": { "@nestjs/core": "^10" } }
|
|
1178
|
+
+ {
|
|
1179
|
+
+ "name": "bff-admin",
|
|
1180
|
+
+ "dependencies": {
|
|
1181
|
+
+ "@nestjs/core": "^10"
|
|
1182
|
+
+ },
|
|
1183
|
+
+ "scripts": {
|
|
1184
|
+
+ "static-analysis": "sentinel --run --static-analysis",
|
|
1185
|
+
+ "static-analysis:json": "sentinel --run --static-analysis --output json",
|
|
1186
|
+
+ "static-analysis:html": "sentinel --run --static-analysis --output html"
|
|
1187
|
+
+ },
|
|
1188
|
+
+ "devDependencies": {
|
|
1189
|
+
+ "@hublo/sentinel": "<version>"
|
|
1190
|
+
+ },
|
|
1191
|
+
+ "nx": {
|
|
1192
|
+
+ "targets": {
|
|
1193
|
+
+ "static-analysis": {
|
|
1194
|
+
+ "cache": true,
|
|
1195
|
+
+ "inputs": [
|
|
1196
|
+
+ "default",
|
|
1197
|
+
+ "{projectRoot}/static-analysis.config.mjs"
|
|
1198
|
+
+ ]
|
|
1199
|
+
+ },
|
|
1200
|
+
+ "static-analysis:json": {
|
|
1201
|
+
+ "cache": false
|
|
1202
|
+
+ },
|
|
1203
|
+
+ "static-analysis:html": {
|
|
1204
|
+
+ "cache": false
|
|
1205
|
+
+ }
|
|
1206
|
+
+ }
|
|
1207
|
+
+ }
|
|
1208
|
+
+ }
|
|
1209
|
+
|
|
1210
|
+
create .gitignore
|
|
1211
|
+
+ /static-analysis-report/
|
|
1212
|
+
dry run: no files written
|
|
1079
1213
|
materialized the nest format preset into .oxfmtrc.json (@hublo/sentinel@<version>). oxfmt has no `extends`, so the values live here; `sentinel --status --format` reports modules left behind by a preset change
|
|
1080
1214
|
|
|
1081
1215
|
create .oxfmtrc.json
|
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.3",
|
|
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",
|