@hublo/sentinel 1.5.0-alpha.16 → 1.5.0-alpha.18
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-UUPP5UQS.js → chunk-AYEOGJQR.js} +7 -3
- package/dist/chunk-AYEOGJQR.js.map +1 -0
- package/dist/{chunk-S3LXWTKH.js → chunk-OKHGGXNC.js} +354 -56
- package/dist/index.js +2 -2
- package/dist/roles/static-analysis/config.d.ts +3 -0
- package/dist/roles/static-analysis/config.js +1 -1
- package/docs/typescript-traces.md +2 -1
- package/package.json +9 -1
- package/dist/chunk-UUPP5UQS.js.map +0 -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-OKHGGXNC.js";
|
|
19
19
|
import {
|
|
20
20
|
resolveContext
|
|
21
21
|
} from "../chunk-KLEXJFJ6.js";
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
readOwnVersion,
|
|
27
27
|
readProjectPackageJson
|
|
28
28
|
} from "../chunk-YQRDKWSH.js";
|
|
29
|
-
import "../chunk-
|
|
29
|
+
import "../chunk-AYEOGJQR.js";
|
|
30
30
|
import "../chunk-O7REVMOC.js";
|
|
31
31
|
import "../chunk-QXFCZON7.js";
|
|
32
32
|
|
|
@@ -41,14 +41,18 @@ var shared_default = {
|
|
|
41
41
|
minLines: 5,
|
|
42
42
|
kinds: ["exact"],
|
|
43
43
|
tests: [
|
|
44
|
-
"*.{test,spec,steps,e2e-spec,integration-spec}.{ts,tsx,js,jsx}",
|
|
45
|
-
"{
|
|
44
|
+
"*.{test,spec,steps,e2e-spec,integration-spec,stories}.{ts,tsx,js,jsx}",
|
|
45
|
+
"*.{msw,mock,test-data}.{ts,tsx,js,jsx}",
|
|
46
|
+
"{tests,__tests__,test,testing,stubs}/**/*.{ts,tsx,js,jsx}"
|
|
46
47
|
]
|
|
47
48
|
},
|
|
48
49
|
deadCode: {
|
|
49
50
|
minConfidence: 60,
|
|
50
51
|
categories: ["unused-file", "unused-export", "unused-symbol", "unused-import"]
|
|
51
52
|
},
|
|
53
|
+
graph: {
|
|
54
|
+
resolution: 1
|
|
55
|
+
},
|
|
52
56
|
tools: {
|
|
53
57
|
jscpd: {}
|
|
54
58
|
}
|
|
@@ -162,4 +166,4 @@ export {
|
|
|
162
166
|
overridableSettings,
|
|
163
167
|
defineStaticAnalysis
|
|
164
168
|
};
|
|
165
|
-
//# sourceMappingURL=chunk-
|
|
169
|
+
//# sourceMappingURL=chunk-AYEOGJQR.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'] 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} 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":[]}
|
|
@@ -71,7 +71,7 @@ import {
|
|
|
71
71
|
extensionsOf,
|
|
72
72
|
isPlainObject,
|
|
73
73
|
overridableSettings
|
|
74
|
-
} from "./chunk-
|
|
74
|
+
} from "./chunk-AYEOGJQR.js";
|
|
75
75
|
import {
|
|
76
76
|
parseJsonc
|
|
77
77
|
} from "./chunk-QXFCZON7.js";
|
|
@@ -4758,8 +4758,8 @@ function registerLint() {
|
|
|
4758
4758
|
|
|
4759
4759
|
// src/roles/static-analysis/adapters/jscpd/jscpd.adapter.ts
|
|
4760
4760
|
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
4761
|
-
import { existsSync as existsSync14, mkdirSync, readFileSync as
|
|
4762
|
-
import { join as
|
|
4761
|
+
import { existsSync as existsSync14, mkdirSync, readFileSync as readFileSync16, writeFileSync as writeFileSync4 } from "node:fs";
|
|
4762
|
+
import { join as join20 } from "node:path";
|
|
4763
4763
|
|
|
4764
4764
|
// src/roles/static-analysis/complexity/scan.ts
|
|
4765
4765
|
import { readFileSync as readFileSync14 } from "node:fs";
|
|
@@ -5049,6 +5049,179 @@ function scanComplexity(cwd, config) {
|
|
|
5049
5049
|
};
|
|
5050
5050
|
}
|
|
5051
5051
|
|
|
5052
|
+
// src/roles/static-analysis/graph/picture.ts
|
|
5053
|
+
import forceAtlas2 from "graphology-layout-forceatlas2";
|
|
5054
|
+
import circular from "graphology-layout/circular.js";
|
|
5055
|
+
import renderSvg from "graphology-svg";
|
|
5056
|
+
var ITERATIONS = 300;
|
|
5057
|
+
var LABELLED = 10;
|
|
5058
|
+
var BLANK = " ";
|
|
5059
|
+
var SIZE = { width: 1600, height: 1200, margin: 30 };
|
|
5060
|
+
function hueOf(community) {
|
|
5061
|
+
return `hsl(${community * 137 % 360} 65% 52%)`;
|
|
5062
|
+
}
|
|
5063
|
+
function writeGraphPicture(graph, communities, path) {
|
|
5064
|
+
const drawn = graph.copy();
|
|
5065
|
+
circular.assign(drawn);
|
|
5066
|
+
forceAtlas2.assign(drawn, { iterations: ITERATIONS, settings: forceAtlas2.inferSettings(drawn) });
|
|
5067
|
+
const named = new Set(
|
|
5068
|
+
drawn.nodes().sort((left, right) => drawn.degree(right) - drawn.degree(left) || left.localeCompare(right)).slice(0, LABELLED)
|
|
5069
|
+
);
|
|
5070
|
+
drawn.forEachNode((node) => {
|
|
5071
|
+
drawn.setNodeAttribute(node, "size", 2 + Math.sqrt(drawn.degree(node)));
|
|
5072
|
+
drawn.setNodeAttribute(node, "color", hueOf(communities[node] ?? 0));
|
|
5073
|
+
drawn.setNodeAttribute(node, "label", named.has(node) ? node.split("/").pop() ?? node : BLANK);
|
|
5074
|
+
});
|
|
5075
|
+
return new Promise((resolve4) => {
|
|
5076
|
+
renderSvg(drawn, path, SIZE, () => resolve4());
|
|
5077
|
+
});
|
|
5078
|
+
}
|
|
5079
|
+
|
|
5080
|
+
// src/roles/static-analysis/graph/scan.ts
|
|
5081
|
+
import Graph from "graphology";
|
|
5082
|
+
import louvain from "graphology-communities-louvain";
|
|
5083
|
+
import { stronglyConnectedComponents } from "graphology-components";
|
|
5084
|
+
import { degreeCentrality } from "graphology-metrics/centrality/degree.js";
|
|
5085
|
+
import { density } from "graphology-metrics/graph/density.js";
|
|
5086
|
+
|
|
5087
|
+
// src/roles/static-analysis/graph/imports.ts
|
|
5088
|
+
import { readFileSync as readFileSync15 } from "node:fs";
|
|
5089
|
+
import { dirname, join as join19 } from "node:path";
|
|
5090
|
+
import { parseSync as parseSync3 } from "oxc-parser";
|
|
5091
|
+
import { globSync as globSync2 } from "tinyglobby";
|
|
5092
|
+
var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
5093
|
+
var TS_BEHIND_JS = {
|
|
5094
|
+
".js": [".ts", ".tsx"],
|
|
5095
|
+
".mjs": [".mts"],
|
|
5096
|
+
".cjs": [".cts"]
|
|
5097
|
+
};
|
|
5098
|
+
function specifiersOf(program) {
|
|
5099
|
+
const found = [];
|
|
5100
|
+
const walk3 = (node) => {
|
|
5101
|
+
if (!node || typeof node !== "object") return;
|
|
5102
|
+
if (Array.isArray(node)) {
|
|
5103
|
+
for (const child of node) walk3(child);
|
|
5104
|
+
return;
|
|
5105
|
+
}
|
|
5106
|
+
const current = node;
|
|
5107
|
+
const source = current.source?.value;
|
|
5108
|
+
switch (current.type) {
|
|
5109
|
+
// `import x from './a'`, and the two re-export forms, which are imports that also publish.
|
|
5110
|
+
case "ImportDeclaration":
|
|
5111
|
+
case "ExportNamedDeclaration":
|
|
5112
|
+
case "ExportAllDeclaration":
|
|
5113
|
+
if (typeof source === "string") found.push(source);
|
|
5114
|
+
break;
|
|
5115
|
+
// `import('./a')`, the lazy route and the split bundle.
|
|
5116
|
+
case "ImportExpression":
|
|
5117
|
+
if (typeof source === "string") found.push(source);
|
|
5118
|
+
break;
|
|
5119
|
+
case "CallExpression": {
|
|
5120
|
+
const argument = current.arguments?.[0]?.value;
|
|
5121
|
+
if (current.callee?.name === "require" && typeof argument === "string") found.push(argument);
|
|
5122
|
+
break;
|
|
5123
|
+
}
|
|
5124
|
+
default:
|
|
5125
|
+
break;
|
|
5126
|
+
}
|
|
5127
|
+
for (const [field, value] of Object.entries(current)) {
|
|
5128
|
+
if (field === "type" || field === "start" || field === "end") continue;
|
|
5129
|
+
walk3(value);
|
|
5130
|
+
}
|
|
5131
|
+
};
|
|
5132
|
+
walk3(program);
|
|
5133
|
+
return found;
|
|
5134
|
+
}
|
|
5135
|
+
function resolveSpecifier(from, specifier, known) {
|
|
5136
|
+
if (!specifier.startsWith(".")) return void 0;
|
|
5137
|
+
const base = join19(dirname(from), specifier);
|
|
5138
|
+
if (known.has(base)) return base;
|
|
5139
|
+
for (const extension of EXTENSIONS) {
|
|
5140
|
+
if (known.has(base + extension)) return base + extension;
|
|
5141
|
+
}
|
|
5142
|
+
const written = EXTENSIONS.find((extension) => base.endsWith(extension));
|
|
5143
|
+
for (const extension of TS_BEHIND_JS[written ?? ""] ?? []) {
|
|
5144
|
+
const swapped = base.slice(0, -(written ?? "").length) + extension;
|
|
5145
|
+
if (known.has(swapped)) return swapped;
|
|
5146
|
+
}
|
|
5147
|
+
for (const extension of EXTENSIONS) {
|
|
5148
|
+
const index = join19(base, `index${extension}`);
|
|
5149
|
+
if (known.has(index)) return index;
|
|
5150
|
+
}
|
|
5151
|
+
return void 0;
|
|
5152
|
+
}
|
|
5153
|
+
function buildImportGraph(cwd, config) {
|
|
5154
|
+
const extensions = extensionsOf(config.languages).map((extension) => extension.slice(1)).join(",");
|
|
5155
|
+
const files = globSync2([`**/*.{${extensions}}`], {
|
|
5156
|
+
cwd,
|
|
5157
|
+
ignore: exclusions(config, "product"),
|
|
5158
|
+
dot: false
|
|
5159
|
+
});
|
|
5160
|
+
const known = new Set(files);
|
|
5161
|
+
const tree = {};
|
|
5162
|
+
for (const file of files) {
|
|
5163
|
+
tree[file] = [];
|
|
5164
|
+
let parsed;
|
|
5165
|
+
try {
|
|
5166
|
+
parsed = parseSync3(file, readFileSync15(join19(cwd, file), "utf8"));
|
|
5167
|
+
} catch {
|
|
5168
|
+
continue;
|
|
5169
|
+
}
|
|
5170
|
+
if (parsed.errors.length > 0) continue;
|
|
5171
|
+
for (const specifier of specifiersOf(parsed.program)) {
|
|
5172
|
+
const target = resolveSpecifier(file, specifier, known);
|
|
5173
|
+
if (target && target !== file && !tree[file].includes(target)) tree[file].push(target);
|
|
5174
|
+
}
|
|
5175
|
+
}
|
|
5176
|
+
return tree;
|
|
5177
|
+
}
|
|
5178
|
+
|
|
5179
|
+
// src/roles/static-analysis/graph/scan.ts
|
|
5180
|
+
var MOST_IMPORTED_LISTED = 10;
|
|
5181
|
+
function seeded() {
|
|
5182
|
+
let state = 42;
|
|
5183
|
+
return () => {
|
|
5184
|
+
state = (state * 1664525 + 1013904223) % 4294967296;
|
|
5185
|
+
return state / 4294967296;
|
|
5186
|
+
};
|
|
5187
|
+
}
|
|
5188
|
+
function toGraph(tree) {
|
|
5189
|
+
const graph = new Graph({ type: "directed", allowSelfLoops: false });
|
|
5190
|
+
for (const file of Object.keys(tree)) if (!graph.hasNode(file)) graph.addNode(file);
|
|
5191
|
+
for (const [from, targets2] of Object.entries(tree)) {
|
|
5192
|
+
for (const to of targets2) {
|
|
5193
|
+
if (!graph.hasNode(to)) graph.addNode(to);
|
|
5194
|
+
if (from !== to && !graph.hasDirectedEdge(from, to)) graph.addDirectedEdge(from, to);
|
|
5195
|
+
}
|
|
5196
|
+
}
|
|
5197
|
+
return graph;
|
|
5198
|
+
}
|
|
5199
|
+
function scanGraph(cwd, config) {
|
|
5200
|
+
const graph = toGraph(buildImportGraph(cwd, config));
|
|
5201
|
+
const cycles = stronglyConnectedComponents(graph).filter((component) => component.length > 1).map((files) => ({ files })).sort((left, right) => right.files.length - left.files.length);
|
|
5202
|
+
const partition = graph.size > 0 ? louvain.detailed(graph, { resolution: config.graph.resolution, rng: seeded() }) : { count: graph.order, modularity: void 0, communities: {} };
|
|
5203
|
+
const centrality = degreeCentrality(graph);
|
|
5204
|
+
const mostImported = graph.nodes().map((path) => ({
|
|
5205
|
+
path,
|
|
5206
|
+
importedBy: graph.inDegree(path),
|
|
5207
|
+
centrality: centrality[path] ?? 0
|
|
5208
|
+
})).filter((file) => file.importedBy > 0).sort(
|
|
5209
|
+
(left, right) => right.importedBy - left.importedBy || left.path.localeCompare(right.path)
|
|
5210
|
+
).slice(0, MOST_IMPORTED_LISTED);
|
|
5211
|
+
return {
|
|
5212
|
+
files: graph.order,
|
|
5213
|
+
edges: graph.size,
|
|
5214
|
+
density: graph.order > 1 ? density(graph) : 0,
|
|
5215
|
+
cycles,
|
|
5216
|
+
filesInCycles: cycles.reduce((total, cycle) => total + cycle.files.length, 0),
|
|
5217
|
+
communities: partition.count,
|
|
5218
|
+
modularity: partition.modularity,
|
|
5219
|
+
mostImported,
|
|
5220
|
+
graph,
|
|
5221
|
+
communityOf: partition.communities
|
|
5222
|
+
};
|
|
5223
|
+
}
|
|
5224
|
+
|
|
5052
5225
|
// src/roles/static-analysis/help/metrics.ts
|
|
5053
5226
|
var metricsHelp = {
|
|
5054
5227
|
duplicatedLinesPercent: {
|
|
@@ -5148,6 +5321,48 @@ var metricsHelp = {
|
|
|
5148
5321
|
read: "A count, so it grows with the size of the module. Read it next to the duplicated-line share rather than on its own, and treat it as the number of places to go and look at.",
|
|
5149
5322
|
caveat: "Not a count of duplicated places, and the gap is measured, not theoretical: five identical files produce four entries, all of them naming one file in common, so n copies give n-1 entries, neither n nor the n(n-1)/2 pairs. Worse for reading occurrences, jscpd keeps only the longest match per pair of files, so when a third file shares just part of a block, that block is reported under two different fragments and its real number of occurrences cannot be recovered from the report. Size is invisible too: one clone of sixty lines and one of six both count as one.",
|
|
5150
5323
|
source: "jscpd report statistics. Counting behaviour measured on fixtures with jscpd 5.4.0; the shared file is not the same one across major versions, so only the count is relied on."
|
|
5324
|
+
},
|
|
5325
|
+
cycles: {
|
|
5326
|
+
label: "Import cycles",
|
|
5327
|
+
what: "Groups of files that import each other in a circle, directly or through other files.",
|
|
5328
|
+
read: "Expect zero, and read any value as a count of places to go and break one edge. The figure beside it is how many FILES are caught, which is the size of the problem: one group of forty is a different afternoon from twenty groups of two.",
|
|
5329
|
+
caveat: "A cycle is reported, not judged, and some are harmless in practice: a type-only import is erased at build time and two files that merely reference each other's types will never deadlock. What the measurement cannot tell you is which kind yours is, because it does not read the `type` keyword. It also reports GROUPS and not paths, so a group of six files may contain a dozen distinct circles; breaking one of them does not necessarily break the group, which is exactly why the group is the unit.",
|
|
5330
|
+
source: "Tarjan's strongly connected components, via graphology-components. Held against madge's own `circular()` on five modules of the three families: the set of files caught was identical every time (2, 110, 11, 13 and 2), and only the count differed, because madge enumerates paths where this groups them."
|
|
5331
|
+
},
|
|
5332
|
+
importEdges: {
|
|
5333
|
+
label: "Imports between files",
|
|
5334
|
+
what: "How many times one of the module's own files imports another of them.",
|
|
5335
|
+
read: "Read it against the file count beside it rather than on its own: roughly two to three edges per file is what the modules measured here sit at. It is the size of the graph every other figure on this block is computed over.",
|
|
5336
|
+
caveat: "Module-local on purpose, so it says nothing about what the module pulls from the rest of the workspace: a front app importing three hundred components from `libs/` shows none of that here. Tests are outside the graph too, specs and the support beside them, so a file every spec imports does not look busy because of them: measured on `apps/front/host-admin`, 113 helpers in a `testing/` or `test/` directory or named `*.msw.ts` were counted as product code until the role's glob learnt those conventions, and each was showing up as a community of its own. Generated code is out as well: on `apps/front/console`, excluding `routeTree.gen.ts` removes 20 edges and the module's only cycle, which is TanStack's own and which nobody can fix by hand.",
|
|
5337
|
+
source: "Built from the oxc parse of every file the role scans, resolving relative specifiers only. The resolution was held against madge, which follows the workspace path aliases, over five modules with the same exclusions on both sides: 16 052 edges, no code edge missed and none invented, the 28 differences all imports of files outside the scanned languages."
|
|
5338
|
+
},
|
|
5339
|
+
graphDensity: {
|
|
5340
|
+
label: "Density",
|
|
5341
|
+
what: "The share of all the edges that could exist between these files that actually do.",
|
|
5342
|
+
read: "Between 0 and 1, and near 0 in real code. It is a shape, not a score: it tells you whether the module is a web or a tree.",
|
|
5343
|
+
caveat: "\u26D4 Do NOT compare it between modules of different sizes, and the reason is arithmetic rather than opinion: the denominator grows with the SQUARE of the file count, so a module gets a lower density by growing, not by improving. Measured here: `libs/nest/auth` 0.0216 at 101 files and `apps/front/host-admin` 0.0005 at 4 380, which says almost nothing about either. What it is good for is one module watched over time at a stable size.",
|
|
5344
|
+
source: "graphology-metrics, graph density, the standard definition for a directed graph."
|
|
5345
|
+
},
|
|
5346
|
+
communities: {
|
|
5347
|
+
label: "Communities",
|
|
5348
|
+
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.",
|
|
5349
|
+
read: "Read it against the module's own directories, which is the grouping the team wrote by hand. Expect it to be HIGHER: the partition refines the folders rather than reproducing them, measured at 24 communities for 16 domains on a nest service and 208 for 20 modules on the largest front app. Held against the directories over five modules, each community sits inside one directory 58% to 85% of the time, and each directory splits across several communities, so the agreement is a refinement and not a match.",
|
|
5350
|
+
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.",
|
|
5351
|
+
source: "Louvain, via graphology-communities-louvain, with the role's own random seed so the figure reproduces. Blondel, Guillaume, Lambiotte and Lefebvre, 2008."
|
|
5352
|
+
},
|
|
5353
|
+
modularity: {
|
|
5354
|
+
label: "Modularity (Q)",
|
|
5355
|
+
what: "The quality of the ASSIGNMENT of files to communities: how much more densely connected the files inside a community are than the same files would be in a random network. Both halves of the words a team already uses are in that sentence, which is why the figure is worth having: dense inside is cohesion, the degree to which the elements of a part belong together, and sparse between is what makes a part independent enough to be given an interface.",
|
|
5356
|
+
read: "Between -1 and 1. It judges the partition, not the code, and that is what makes it architectural: Louvain already maximised it, so a low value does not mean a poor partition was picked, it means no good one exists and the module does not decompose along its imports. A high value says the opposite, that a modular model is there to be read, and the drawing is where to read it. The five modules measured here sit between 0.58 and 0.84.",
|
|
5357
|
+
caveat: "\u26A0\uFE0F Do not read the fourth decimal. Louvain is greedy, so Q is the value of ONE local optimum: measured over 20 runs with the library's own randomness, `console` produced 16 different values and `recruitment` spanned 0.0357. The role pins its seed, which makes the figure reproducible but not exact, so a change smaller than a hundredth between two runs of two different versions means nothing. A high Q also does not mean the clusters are the right ones: it says they are separable, not that they match any boundary a team cares about. Read as cohesion it is a practical one: files that interact are a population that lives together, the way Louvain was meant to divide a social network, so it catches technical and functional coupling alike. What it will not give you is a place to go, because it is one average over the whole module. The communities themselves are, and the drawing is there for that.",
|
|
5358
|
+
source: "Newman-Girvan modularity, via graphology-metrics, which states in its own source that it stays true to Newman's original definition. Q is the measure; Louvain is the algorithm that maximises it. The wording of what it quantifies, the quality of an assignment of nodes to communities, is Neo4j's graph data science documentation."
|
|
5359
|
+
},
|
|
5360
|
+
mostImported: {
|
|
5361
|
+
label: "Most imported files",
|
|
5362
|
+
what: "The files the rest of the module leans on, by how many of its own files import them.",
|
|
5363
|
+
read: "Context, not a defect list: every module has a most-imported file and a type definition used everywhere is doing its job. It is worth a look when the file at the top is one that CHANGES often, because then its import count is the number of files a change there can reach.",
|
|
5364
|
+
caveat: "\u26A0\uFE0F If you compare this with another tool, note the unit. The count here is raw, how many files import this one. graphology's degree centrality, which `code-health-meter` reports, is NORMALISED by the file count, and the two can 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 here and the role proposes none.",
|
|
5365
|
+
source: "In-degree of the module import graph, with graphology degree centrality carried beside it for anyone holding this against another tool."
|
|
5151
5366
|
}
|
|
5152
5367
|
};
|
|
5153
5368
|
function helpFor(key) {
|
|
@@ -5167,11 +5382,23 @@ var HTML_GROUP_LIMIT = 50;
|
|
|
5167
5382
|
function figure(row) {
|
|
5168
5383
|
return row.unit === "%" && typeof row.value === "number" ? row.value.toFixed(2) : String(row.value);
|
|
5169
5384
|
}
|
|
5385
|
+
function caveatsOf(report) {
|
|
5386
|
+
const keys = [...report.rows.map((row) => row.metric), ...report.findings.map((f) => f.metric)];
|
|
5387
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5388
|
+
const found = [];
|
|
5389
|
+
for (const key of keys) {
|
|
5390
|
+
if (seen.has(key)) continue;
|
|
5391
|
+
seen.add(key);
|
|
5392
|
+
const help = helpFor(key);
|
|
5393
|
+
if (help) found.push(help);
|
|
5394
|
+
}
|
|
5395
|
+
return found;
|
|
5396
|
+
}
|
|
5170
5397
|
function renderConsole(report) {
|
|
5171
5398
|
const lines = [];
|
|
5172
5399
|
const width = (pick) => Math.max(...report.rows.map((row) => pick(row).length));
|
|
5173
5400
|
const label = (row) => helpFor(row.metric)?.label ?? row.metric;
|
|
5174
|
-
const value = (row) => `${figure(row)} ${row.unit}
|
|
5401
|
+
const value = (row) => `${figure(row)} ${row.unit}`.trim();
|
|
5175
5402
|
const labelWidth = width(label);
|
|
5176
5403
|
const valueWidth = width(value);
|
|
5177
5404
|
lines.push(`sentinel static-analysis: ${report.module}`);
|
|
@@ -5206,21 +5433,21 @@ function renderConsole(report) {
|
|
|
5206
5433
|
` ${report.findings.length} place(s) to look, listed by \`${SCRIPTS.json}\` and \`${SCRIPTS.html}\``
|
|
5207
5434
|
);
|
|
5208
5435
|
}
|
|
5209
|
-
const caveats = report
|
|
5210
|
-
(entry) => Boolean(entry.help)
|
|
5211
|
-
);
|
|
5436
|
+
const caveats = caveatsOf(report);
|
|
5212
5437
|
if (caveats.length > 0) {
|
|
5213
5438
|
lines.push("", " what these numbers do not say");
|
|
5214
|
-
for (const
|
|
5439
|
+
for (const help of caveats) {
|
|
5215
5440
|
lines.push(` ${help.label}: ${help.caveat}`, ` source: ${help.source}`);
|
|
5216
|
-
void row;
|
|
5217
5441
|
}
|
|
5218
5442
|
}
|
|
5219
5443
|
return `${lines.join("\n")}
|
|
5220
5444
|
`;
|
|
5221
5445
|
}
|
|
5222
5446
|
function renderJson(report) {
|
|
5223
|
-
const rows = report.rows.map((row) =>
|
|
5447
|
+
const rows = report.rows.map((row) => {
|
|
5448
|
+
const help = helpFor(row.metric);
|
|
5449
|
+
return { ...row, help: help ? { label: help.label, caveat: help.caveat } : null };
|
|
5450
|
+
});
|
|
5224
5451
|
return `${JSON.stringify({ ...report, rows }, null, 2)}
|
|
5225
5452
|
`;
|
|
5226
5453
|
}
|
|
@@ -5238,12 +5465,12 @@ function renderHtml(report) {
|
|
|
5238
5465
|
<td class="num">${escape(figure(row))} <span class="unit">${escape(row.unit)}</span></td>
|
|
5239
5466
|
</tr>`;
|
|
5240
5467
|
}).join("\n");
|
|
5241
|
-
const reserves = report
|
|
5242
|
-
(
|
|
5243
|
-
<summary>${escape(help
|
|
5244
|
-
<p class="read"><b>How to read it.</b> ${prose(help
|
|
5245
|
-
<p><b>What it does not say.</b> ${prose(help
|
|
5246
|
-
<p class="source">Source: ${prose(help
|
|
5468
|
+
const reserves = caveatsOf(report).map(
|
|
5469
|
+
(help) => ` <details>
|
|
5470
|
+
<summary>${escape(help.label)}<span class="what">${prose(help.what)}</span></summary>
|
|
5471
|
+
<p class="read"><b>How to read it.</b> ${prose(help.read)}</p>
|
|
5472
|
+
<p><b>What it does not say.</b> ${prose(help.caveat)}</p>
|
|
5473
|
+
<p class="source">Source: ${prose(help.source)}</p>
|
|
5247
5474
|
</details>`
|
|
5248
5475
|
).join("\n");
|
|
5249
5476
|
const settings = report.settings.map(
|
|
@@ -5418,6 +5645,10 @@ ${rest > 0 ? ` <p class="sub">The ${HTML_GROUP_LIMIT} longest are listed; ${r
|
|
|
5418
5645
|
color: var(--moved); font-size: 0.78rem; font-weight: 500;
|
|
5419
5646
|
white-space: nowrap;
|
|
5420
5647
|
}
|
|
5648
|
+
/* The graph. The svg carries a viewBox, so dropping its fixed width lets it fill the column. */
|
|
5649
|
+
.picture svg { width: 100%; height: auto; display: block; }
|
|
5650
|
+
.picture p.read { margin: 0 0 0.8rem; padding: 0 0.6rem; color: var(--ink-soft); font-size: 0.88rem; max-width: 52rem; }
|
|
5651
|
+
|
|
5421
5652
|
/* The collapsed reserves. Closed by default: the figures are what the page opens on. */
|
|
5422
5653
|
details { border-bottom: 1px solid var(--rule); }
|
|
5423
5654
|
details:last-of-type { border-bottom: 0; }
|
|
@@ -5494,6 +5725,13 @@ ${report.notes.length > 0 ? `
|
|
|
5494
5725
|
${report.notes.map((note) => ` <tr><td class="read">${escape(note)}</td></tr>`).join("\n")}
|
|
5495
5726
|
</tbody></table>
|
|
5496
5727
|
</section>
|
|
5728
|
+
` : ""}
|
|
5729
|
+
${report.picture ? `
|
|
5730
|
+
<h2>Shape</h2>
|
|
5731
|
+
<section class="picture">
|
|
5732
|
+
<p class="read">One dot per file, sized by how many imports touch it, coloured by the community it landed in. It shows the SHAPE: how many lobes, whether they are joined, where everything converges. The names are in the tables below.</p>
|
|
5733
|
+
${report.picture}
|
|
5734
|
+
</section>
|
|
5497
5735
|
` : ""}
|
|
5498
5736
|
<h2>What these numbers do not say</h2>
|
|
5499
5737
|
<section>
|
|
@@ -5525,7 +5763,7 @@ function resolveJscpd(cwd) {
|
|
|
5525
5763
|
}
|
|
5526
5764
|
|
|
5527
5765
|
// src/roles/static-analysis/adapters/jscpd/jscpd.adapter.ts
|
|
5528
|
-
var
|
|
5766
|
+
var EXTENSIONS2 = extensionsOf(defaults.languages);
|
|
5529
5767
|
var scripts = () => ({
|
|
5530
5768
|
[SCRIPTS.console]: `sentinel --run --${TARGET}`,
|
|
5531
5769
|
[SCRIPTS.json]: `sentinel --run --${TARGET} --output json`,
|
|
@@ -5588,17 +5826,17 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
5588
5826
|
return true;
|
|
5589
5827
|
}
|
|
5590
5828
|
plan(context) {
|
|
5591
|
-
if (!hasSourceFiles(context.cwd,
|
|
5829
|
+
if (!hasSourceFiles(context.cwd, EXTENSIONS2)) {
|
|
5592
5830
|
return {
|
|
5593
5831
|
operations: [],
|
|
5594
|
-
skipped: `no file ${this.runner} can read (looked for ${
|
|
5832
|
+
skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS2.join(", ")}).`
|
|
5595
5833
|
};
|
|
5596
5834
|
}
|
|
5597
5835
|
return {
|
|
5598
5836
|
operations: [
|
|
5599
5837
|
// `write` and not `merge-json`: this file is code, and the engine's in-place merge is for
|
|
5600
5838
|
// JSON. A re-init therefore RESTORES the stub, so an existing one is left alone below.
|
|
5601
|
-
...existsSync14(
|
|
5839
|
+
...existsSync14(join20(context.cwd, STATIC_CONFIG_FILE)) ? [] : [{ kind: "write", path: STATIC_CONFIG_FILE, contents: configStub() }],
|
|
5602
5840
|
manifestOperation(context.cwd, scripts()),
|
|
5603
5841
|
...nxTargetOperations({ cwd: context.cwd, targets: targets() })
|
|
5604
5842
|
// The report directory is ignored ONCE at the workspace root, by `--init`'s workspace prep,
|
|
@@ -5627,9 +5865,9 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
5627
5865
|
);
|
|
5628
5866
|
return { ok: false, code: 1 };
|
|
5629
5867
|
}
|
|
5630
|
-
if (!hasSourceFiles(ctx.cwd,
|
|
5868
|
+
if (!hasSourceFiles(ctx.cwd, EXTENSIONS2)) {
|
|
5631
5869
|
process.stderr.write(
|
|
5632
|
-
`sentinel static-analysis(jscpd): no file to measure in this module (looked for ${
|
|
5870
|
+
`sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS2.join(", ")}).
|
|
5633
5871
|
`
|
|
5634
5872
|
);
|
|
5635
5873
|
return { ok: true, code: 0 };
|
|
@@ -5678,8 +5916,9 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
5678
5916
|
);
|
|
5679
5917
|
}
|
|
5680
5918
|
const complexity = scanComplexity(ctx.cwd, config);
|
|
5681
|
-
const
|
|
5682
|
-
|
|
5919
|
+
const graph = scanGraph(ctx.cwd, config);
|
|
5920
|
+
const report = assembleReport(ctx, config, product, tests, dead, complexity, graph);
|
|
5921
|
+
if (emitting) await emit(report, shape, ctx.cwd, graph);
|
|
5683
5922
|
return { ok: true, code: 0, metrics: metricsOf(report) };
|
|
5684
5923
|
}
|
|
5685
5924
|
/** `--run`: measure and show it. */
|
|
@@ -5750,7 +5989,7 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
5750
5989
|
const declared = moduleScripts(ctx.cwd);
|
|
5751
5990
|
const missing = Object.keys(scripts()).filter((name) => !(name in declared));
|
|
5752
5991
|
const adopted = missing.length < Object.keys(scripts()).length;
|
|
5753
|
-
const hasConfig = existsSync14(
|
|
5992
|
+
const hasConfig = existsSync14(join20(ctx.cwd, STATIC_CONFIG_FILE));
|
|
5754
5993
|
return {
|
|
5755
5994
|
adopted,
|
|
5756
5995
|
preset: adopted ? ctx.preset : null,
|
|
@@ -5783,6 +6022,10 @@ function measuredWith(config) {
|
|
|
5783
6022
|
row("deadCode.minConfidence", String(config.deadCode.minConfidence)),
|
|
5784
6023
|
row("deadCode.categories", config.deadCode.categories.join(", ")),
|
|
5785
6024
|
row("entry", `${config.entry.length} patterns`),
|
|
6025
|
+
// ⚠️ The graph pass's one knob, and it has to be here: it changes both the community count and
|
|
6026
|
+
// `Q`, so the two figures are meaningless without it. The seed is NOT printed beside it,
|
|
6027
|
+
// because it is not a setting: see `graph/scan.ts` for why the role owns it.
|
|
6028
|
+
row("graph.resolution", String(config.graph.resolution)),
|
|
5786
6029
|
// Not a setting a module can move: it is McCabe's own published figure, and the row says so
|
|
5787
6030
|
// rather than letting a reader take it for one of ours.
|
|
5788
6031
|
{
|
|
@@ -5818,7 +6061,7 @@ function runDeadCode(jscpd, config, paths, cwd) {
|
|
|
5818
6061
|
try {
|
|
5819
6062
|
const ran = runTool(jscpd, args, cwd, "dead-code");
|
|
5820
6063
|
if (ran.kind !== "ran") return ran;
|
|
5821
|
-
const path =
|
|
6064
|
+
const path = join20(rawReportDir, RAW_REPORT["dead-code"]);
|
|
5822
6065
|
if (!existsSync14(path)) {
|
|
5823
6066
|
process.stderr.write(
|
|
5824
6067
|
`sentinel static-analysis(jscpd): the dead-code pass wrote no ${RAW_REPORT["dead-code"]}
|
|
@@ -5826,7 +6069,7 @@ function runDeadCode(jscpd, config, paths, cwd) {
|
|
|
5826
6069
|
);
|
|
5827
6070
|
return { kind: "failed", code: 1 };
|
|
5828
6071
|
}
|
|
5829
|
-
const parsed = safeParseDeadCode(
|
|
6072
|
+
const parsed = safeParseDeadCode(readFileSync16(path, "utf8"));
|
|
5830
6073
|
const statistics = parsed?.statistics;
|
|
5831
6074
|
if (!statistics) {
|
|
5832
6075
|
process.stderr.write(
|
|
@@ -5853,8 +6096,8 @@ function runDuplication(jscpd, config, paths, scope, cwd) {
|
|
|
5853
6096
|
try {
|
|
5854
6097
|
const ran = runTool(jscpd, args, cwd, scope);
|
|
5855
6098
|
if (ran.kind !== "ran") return ran;
|
|
5856
|
-
const path =
|
|
5857
|
-
const parsed = existsSync14(path) ? safeParse(
|
|
6099
|
+
const path = join20(rawReportDir, RAW_REPORT.duplication);
|
|
6100
|
+
const parsed = existsSync14(path) ? safeParse(readFileSync16(path, "utf8")) : void 0;
|
|
5858
6101
|
const total = parsed?.statistics?.total;
|
|
5859
6102
|
if (!total) {
|
|
5860
6103
|
process.stderr.write(
|
|
@@ -5887,7 +6130,7 @@ function safeParseDeadCode(text) {
|
|
|
5887
6130
|
return void 0;
|
|
5888
6131
|
}
|
|
5889
6132
|
}
|
|
5890
|
-
function placesToLook(product, tests, dead, complexity) {
|
|
6133
|
+
function placesToLook(product, tests, dead, complexity, graph) {
|
|
5891
6134
|
const at = (side) => `${side?.name ?? "(unknown)"}${side?.start === void 0 ? "" : `:${side.start}`}`;
|
|
5892
6135
|
const clones = (outcome, metric) => outcome.kind === "figures" ? outcome.duplicates.map((entry) => ({
|
|
5893
6136
|
metric,
|
|
@@ -5920,10 +6163,26 @@ function placesToLook(product, tests, dead, complexity) {
|
|
|
5920
6163
|
...finding.confidence === void 0 ? {} : { confidence: finding.confidence },
|
|
5921
6164
|
...finding.reasons?.length ? { reason: finding.reasons.join(", ") } : {},
|
|
5922
6165
|
...finding.message ? { message: finding.message } : {}
|
|
5923
|
-
})) : []
|
|
6166
|
+
})) : [],
|
|
6167
|
+
// One finding per GROUP of mutually importing files, not per cycle path: see `graph/scan.ts`.
|
|
6168
|
+
// The first file names the finding and the rest go in `other`, which is the shape the report
|
|
6169
|
+
// already renders for a clone's two sides.
|
|
6170
|
+
...graph.cycles.map((cycle) => ({
|
|
6171
|
+
metric: "cycles",
|
|
6172
|
+
path: cycle.files[0] ?? "(unknown)",
|
|
6173
|
+
other: cycle.files.slice(1).join(", "),
|
|
6174
|
+
value: cycle.files.length
|
|
6175
|
+
})),
|
|
6176
|
+
// ⛔ Context, not defects. Every module has a most-imported file, and nothing here says the
|
|
6177
|
+
// count is too high: the caveat on the metric says so in the reader's own words.
|
|
6178
|
+
...graph.mostImported.map((file) => ({
|
|
6179
|
+
metric: "mostImported",
|
|
6180
|
+
path: file.path,
|
|
6181
|
+
value: file.importedBy
|
|
6182
|
+
}))
|
|
5924
6183
|
];
|
|
5925
6184
|
}
|
|
5926
|
-
function assembleReport(ctx, config, product, tests, dead, complexity) {
|
|
6185
|
+
function assembleReport(ctx, config, product, tests, dead, complexity, graph) {
|
|
5927
6186
|
const share = (total) => Number((total.percentage ?? 0).toFixed(2));
|
|
5928
6187
|
const rows = [
|
|
5929
6188
|
{ metric: "duplicatedLinesPercent", value: share(product.total), unit: "%" },
|
|
@@ -5976,6 +6235,32 @@ function assembleReport(ctx, config, product, tests, dead, complexity) {
|
|
|
5976
6235
|
} else {
|
|
5977
6236
|
notes.push("no function to measure, so no complexity figure is reported");
|
|
5978
6237
|
}
|
|
6238
|
+
if (graph.files > 1) {
|
|
6239
|
+
rows.push(
|
|
6240
|
+
{ metric: "importEdges", value: graph.edges, unit: `between ${graph.files} files` },
|
|
6241
|
+
{
|
|
6242
|
+
metric: "graphDensity",
|
|
6243
|
+
value: Number(graph.density.toFixed(4)),
|
|
6244
|
+
unit: "of the possible edges"
|
|
6245
|
+
},
|
|
6246
|
+
{
|
|
6247
|
+
metric: "cycles",
|
|
6248
|
+
// ⛔ Zero is the expected value here, so the unit has to read correctly at zero as well as
|
|
6249
|
+
// at one, which `1 groups, 2 files` did not. The file count only means something when
|
|
6250
|
+
// there is a group, so it is absent when there is none.
|
|
6251
|
+
value: graph.cycles.length,
|
|
6252
|
+
unit: graph.cycles.length === 0 ? "none" : `${graph.cycles.length === 1 ? "group" : "groups"}, ${graph.filesInCycles} files`
|
|
6253
|
+
},
|
|
6254
|
+
{ metric: "communities", value: graph.communities, unit: `of ${graph.files} files` }
|
|
6255
|
+
);
|
|
6256
|
+
if (graph.modularity === void 0) {
|
|
6257
|
+
notes.push("no import between the files, so no modularity is reported");
|
|
6258
|
+
} else {
|
|
6259
|
+
rows.push({ metric: "modularity", value: Number(graph.modularity.toFixed(4)), unit: "" });
|
|
6260
|
+
}
|
|
6261
|
+
} else {
|
|
6262
|
+
notes.push("fewer than two files, so no graph figure is reported");
|
|
6263
|
+
}
|
|
5979
6264
|
const byLanguage = Object.fromEntries(
|
|
5980
6265
|
Object.entries(product.formats).map(([language, stats]) => [
|
|
5981
6266
|
language,
|
|
@@ -5992,28 +6277,41 @@ function assembleReport(ctx, config, product, tests, dead, complexity) {
|
|
|
5992
6277
|
"duplication",
|
|
5993
6278
|
...tests.kind === "figures" ? ["duplication (tests)"] : [],
|
|
5994
6279
|
...dead.kind === "figures" ? ["dead code"] : [],
|
|
5995
|
-
...complexity.functions > 0 ? ["complexity"] : []
|
|
6280
|
+
...complexity.functions > 0 ? ["complexity"] : [],
|
|
6281
|
+
...graph.files > 1 ? ["graph"] : []
|
|
5996
6282
|
],
|
|
5997
6283
|
rows,
|
|
5998
6284
|
notes,
|
|
5999
6285
|
settings: measuredWith(config),
|
|
6000
|
-
findings: placesToLook(product, tests, dead, complexity),
|
|
6286
|
+
findings: placesToLook(product, tests, dead, complexity, graph),
|
|
6001
6287
|
byLanguage
|
|
6002
6288
|
};
|
|
6003
6289
|
}
|
|
6004
|
-
function emit(report, shape, cwd) {
|
|
6290
|
+
async function emit(report, shape, cwd, graph) {
|
|
6005
6291
|
if (shape === "console") {
|
|
6006
6292
|
process.stdout.write(renderConsole(report));
|
|
6007
6293
|
return;
|
|
6008
6294
|
}
|
|
6295
|
+
mkdirSync(join20(cwd, REPORT_DIR), { recursive: true });
|
|
6296
|
+
const written = [];
|
|
6297
|
+
if (shape === "html" && graph.files > 1) {
|
|
6298
|
+
const picture = `${TARGET}-graph.svg`;
|
|
6299
|
+
const path = join20(cwd, REPORT_DIR, picture);
|
|
6300
|
+
await writeGraphPicture(graph.graph, graph.communityOf, path);
|
|
6301
|
+
written.push(`${REPORT_DIR}/${picture}`);
|
|
6302
|
+
report.picture = inlineable(readFileSync16(path, "utf8"));
|
|
6303
|
+
}
|
|
6009
6304
|
const name = shape === "json" ? "static-analysis.json" : "static-analysis.html";
|
|
6010
6305
|
const body = shape === "json" ? renderJson(report) : renderHtml(report);
|
|
6011
|
-
|
|
6012
|
-
|
|
6306
|
+
writeFileSync4(join20(cwd, REPORT_DIR, name), body, "utf8");
|
|
6307
|
+
written.unshift(`${REPORT_DIR}/${name}`);
|
|
6013
6308
|
process.stdout.write(`${renderConsole(report)}
|
|
6014
|
-
written: ${
|
|
6309
|
+
written: ${written.join(", ")}
|
|
6015
6310
|
`);
|
|
6016
6311
|
}
|
|
6312
|
+
function inlineable(svg) {
|
|
6313
|
+
return svg.replace(/^<\?xml[^>]*\?>\s*/, "").replace(/^(<svg)\s+width="[^"]*"\s+height="[^"]*"/, "$1");
|
|
6314
|
+
}
|
|
6017
6315
|
function metricsOf(report) {
|
|
6018
6316
|
return {
|
|
6019
6317
|
passes: report.passes,
|
|
@@ -6044,9 +6342,9 @@ function registerTest() {
|
|
|
6044
6342
|
|
|
6045
6343
|
// src/roles/typescript/adapters/tsc/tsc.adapter.ts
|
|
6046
6344
|
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
6047
|
-
import { existsSync as existsSync15, readFileSync as
|
|
6345
|
+
import { existsSync as existsSync15, readFileSync as readFileSync18 } from "node:fs";
|
|
6048
6346
|
import { createRequire } from "node:module";
|
|
6049
|
-
import { join as
|
|
6347
|
+
import { join as join22 } from "node:path";
|
|
6050
6348
|
|
|
6051
6349
|
// src/roles/typescript/presets/nest.json
|
|
6052
6350
|
var nest_default2 = {
|
|
@@ -6231,8 +6529,8 @@ function readTsconfigAdoption(cwd) {
|
|
|
6231
6529
|
}
|
|
6232
6530
|
|
|
6233
6531
|
// src/roles/typescript/adapters/tsc/plan.ts
|
|
6234
|
-
import { readFileSync as
|
|
6235
|
-
import { join as
|
|
6532
|
+
import { readFileSync as readFileSync17 } from "node:fs";
|
|
6533
|
+
import { join as join21 } from "node:path";
|
|
6236
6534
|
|
|
6237
6535
|
// src/roles/typescript/typecheck-script.ts
|
|
6238
6536
|
var SENTINEL_TYPECHECK_COMMAND = "sentinel --run --typescript";
|
|
@@ -6325,7 +6623,7 @@ function planAdoption(context) {
|
|
|
6325
6623
|
};
|
|
6326
6624
|
}
|
|
6327
6625
|
const existing = parseJsonc(
|
|
6328
|
-
|
|
6626
|
+
readFileSync17(join21(context.cwd, target.path), "utf8"),
|
|
6329
6627
|
target.path
|
|
6330
6628
|
);
|
|
6331
6629
|
const extendsChain = composeExtends(existing.extends, preset);
|
|
@@ -6463,7 +6761,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
|
|
|
6463
6761
|
let chain;
|
|
6464
6762
|
try {
|
|
6465
6763
|
const parsed = parseJsonc(
|
|
6466
|
-
|
|
6764
|
+
readFileSync18(join22(cwd, target.path), "utf8"),
|
|
6467
6765
|
target.path
|
|
6468
6766
|
);
|
|
6469
6767
|
chain = parsed.extends;
|
|
@@ -6476,7 +6774,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
|
|
|
6476
6774
|
);
|
|
6477
6775
|
if (preset === void 0) return void 0;
|
|
6478
6776
|
try {
|
|
6479
|
-
createRequire(
|
|
6777
|
+
createRequire(join22(cwd, "noop.js")).resolve(preset);
|
|
6480
6778
|
return void 0;
|
|
6481
6779
|
} catch {
|
|
6482
6780
|
return preset;
|
|
@@ -6572,7 +6870,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
|
|
|
6572
6870
|
referencedProjects(cwd, config) {
|
|
6573
6871
|
try {
|
|
6574
6872
|
const parsed = parseJsonc(
|
|
6575
|
-
|
|
6873
|
+
readFileSync18(join22(cwd, config), "utf8"),
|
|
6576
6874
|
config
|
|
6577
6875
|
);
|
|
6578
6876
|
const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path) => typeof path === "string" && path.length > 0);
|
|
@@ -6588,7 +6886,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
|
|
|
6588
6886
|
* check.
|
|
6589
6887
|
*/
|
|
6590
6888
|
typecheckTarget(cwd) {
|
|
6591
|
-
if (existsSync15(
|
|
6889
|
+
if (existsSync15(join22(cwd, "tsconfig.json"))) return "tsconfig.json";
|
|
6592
6890
|
const target = resolveTsconfigTarget(cwd);
|
|
6593
6891
|
return target.reason === "none" ? null : target.path;
|
|
6594
6892
|
}
|
|
@@ -6794,7 +7092,7 @@ function replaceLines(current, replacements) {
|
|
|
6794
7092
|
}
|
|
6795
7093
|
|
|
6796
7094
|
// src/core/apply-plan.ts
|
|
6797
|
-
import { existsSync as existsSync16, readFileSync as
|
|
7095
|
+
import { existsSync as existsSync16, readFileSync as readFileSync19, renameSync, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
|
|
6798
7096
|
import { resolve as resolve3, sep } from "node:path";
|
|
6799
7097
|
import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
|
|
6800
7098
|
function resolveWithinRoot(cwd, relativePath) {
|
|
@@ -6806,7 +7104,7 @@ function resolveWithinRoot(cwd, relativePath) {
|
|
|
6806
7104
|
return absolutePath;
|
|
6807
7105
|
}
|
|
6808
7106
|
function readIfExists(absolutePath) {
|
|
6809
|
-
return existsSync16(absolutePath) ?
|
|
7107
|
+
return existsSync16(absolutePath) ? readFileSync19(absolutePath, "utf8") : void 0;
|
|
6810
7108
|
}
|
|
6811
7109
|
function* leaves(value, prefix = []) {
|
|
6812
7110
|
for (const [key, keyValue] of Object.entries(value)) {
|
|
@@ -6924,8 +7222,8 @@ function applyPlan(cwd, plan3) {
|
|
|
6924
7222
|
}
|
|
6925
7223
|
|
|
6926
7224
|
// src/core/config/preset-evidence.ts
|
|
6927
|
-
import { existsSync as existsSync17, readdirSync as readdirSync3, readFileSync as
|
|
6928
|
-
import { join as
|
|
7225
|
+
import { existsSync as existsSync17, readdirSync as readdirSync3, readFileSync as readFileSync20 } from "node:fs";
|
|
7226
|
+
import { join as join23 } from "node:path";
|
|
6929
7227
|
var PATH_SIGNALS = [
|
|
6930
7228
|
{
|
|
6931
7229
|
preset: "nest",
|
|
@@ -6959,12 +7257,12 @@ function sourceImports(cwd, pattern) {
|
|
|
6959
7257
|
}
|
|
6960
7258
|
for (const entry of entries) {
|
|
6961
7259
|
if (entry.isDirectory()) {
|
|
6962
|
-
if (!NOT_SCANNED.has(entry.name)) stack.push(
|
|
7260
|
+
if (!NOT_SCANNED.has(entry.name)) stack.push(join23(dir, entry.name));
|
|
6963
7261
|
continue;
|
|
6964
7262
|
}
|
|
6965
7263
|
if (!SOURCE_FILE.test(entry.name)) continue;
|
|
6966
7264
|
try {
|
|
6967
|
-
if (pattern.test(
|
|
7265
|
+
if (pattern.test(readFileSync20(join23(dir, entry.name), "utf8"))) return true;
|
|
6968
7266
|
} catch {
|
|
6969
7267
|
continue;
|
|
6970
7268
|
}
|
|
@@ -6973,11 +7271,11 @@ function sourceImports(cwd, pattern) {
|
|
|
6973
7271
|
return false;
|
|
6974
7272
|
}
|
|
6975
7273
|
function dependencyNames(cwd) {
|
|
6976
|
-
const path =
|
|
7274
|
+
const path = join23(cwd, "package.json");
|
|
6977
7275
|
if (!existsSync17(path)) return [];
|
|
6978
7276
|
try {
|
|
6979
7277
|
const manifest = parseJsonc(
|
|
6980
|
-
|
|
7278
|
+
readFileSync20(path, "utf8"),
|
|
6981
7279
|
path
|
|
6982
7280
|
);
|
|
6983
7281
|
return [
|
|
@@ -7000,7 +7298,7 @@ function reactTsconfigEvidence(cwd) {
|
|
|
7000
7298
|
}
|
|
7001
7299
|
for (const name of entries) {
|
|
7002
7300
|
try {
|
|
7003
|
-
const config = parseJsonc(
|
|
7301
|
+
const config = parseJsonc(readFileSync20(join23(cwd, name), "utf8"), name);
|
|
7004
7302
|
if (config.compilerOptions?.jsx !== void 0) return 'its tsconfig sets "jsx"';
|
|
7005
7303
|
const extended = Array.isArray(config.extends) ? config.extends : config.extends === void 0 ? [] : [config.extends];
|
|
7006
7304
|
if (extended.some((entry) => entry === REACT_TYPESCRIPT_PRESET)) {
|
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-OKHGGXNC.js";
|
|
10
10
|
import {
|
|
11
11
|
BaseAdapter
|
|
12
12
|
} from "./chunk-KLEXJFJ6.js";
|
|
13
13
|
import "./chunk-YQRDKWSH.js";
|
|
14
|
-
import "./chunk-
|
|
14
|
+
import "./chunk-AYEOGJQR.js";
|
|
15
15
|
import "./chunk-O7REVMOC.js";
|
|
16
16
|
import "./chunk-QXFCZON7.js";
|
|
17
17
|
export {
|
|
@@ -1183,9 +1183,10 @@ $ sentinel --init --dry-run
|
|
|
1183
1183
|
+ // duplication.minTokens 50
|
|
1184
1184
|
+ // duplication.minLines 5
|
|
1185
1185
|
+ // duplication.kinds exact
|
|
1186
|
-
+ // duplication.tests
|
|
1186
|
+
+ // duplication.tests 3 patterns
|
|
1187
1187
|
+ // deadCode.minConfidence 60
|
|
1188
1188
|
+ // deadCode.categories unused-file, unused-export, unused-symbol, unused-import
|
|
1189
|
+
+ // graph.resolution 1
|
|
1189
1190
|
+ //
|
|
1190
1191
|
+ // A list shown as a count is not copyable, and replacing one means writing back what you keep,
|
|
1191
1192
|
+ // so read the values in full with:
|
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.18",
|
|
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",
|
|
@@ -120,6 +120,14 @@
|
|
|
120
120
|
"eslint-plugin-react-refresh": "0.4.19",
|
|
121
121
|
"eslint-plugin-storybook": "10.3.5",
|
|
122
122
|
"eslint-plugin-styled-components-a11y": "2.2.0",
|
|
123
|
+
"graphology": "0.26.0",
|
|
124
|
+
"graphology-communities-louvain": "2.0.2",
|
|
125
|
+
"graphology-components": "1.5.4",
|
|
126
|
+
"graphology-layout": "0.6.1",
|
|
127
|
+
"graphology-layout-forceatlas2": "0.10.1",
|
|
128
|
+
"graphology-metrics": "2.4.2",
|
|
129
|
+
"graphology-svg": "0.1.3",
|
|
130
|
+
"graphology-types": "0.24.8",
|
|
123
131
|
"html-validate": "^11.16.0",
|
|
124
132
|
"jscpd": "5.4.0",
|
|
125
133
|
"jsdom": "^24.1.3",
|
|
@@ -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}.{ts,tsx,js,jsx}\",\n \"{tests,__tests__}/**/*.{ts,tsx,js,jsx}\"\n ]\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 * ⭐ 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 * - `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'] 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} 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 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":[]}
|