@hublo/sentinel 1.5.0-alpha.22 → 1.5.0-alpha.23
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/README.md +1 -0
- package/dist/bin/sentinel.js +4 -4
- package/dist/{chunk-KLEXJFJ6.js → chunk-IFK6KGUW.js} +1 -1
- package/dist/{chunk-YQRDKWSH.js → chunk-JEYGPPSL.js} +11 -1
- package/dist/chunk-JEYGPPSL.js.map +1 -0
- package/dist/{chunk-AMCGMLPS.js → chunk-SPES5MQT.js} +2 -2
- package/dist/{chunk-BN6SCBFW.js → chunk-TL2P2X45.js} +196 -16
- package/dist/index.js +3 -3
- package/dist/roles/test/setup/jest-parity.js +2 -2
- package/dist/roles/test/setup/workspace-entry.js +2 -2
- package/dist/{validate-AWWMBSH5.js → validate-7VBODB4M.js} +2 -2
- package/docs/static-analysis-adoption.md +195 -0
- package/docs/static-analysis-study.md +133 -14
- package/docs/using-sentinel.md +3 -2
- package/package.json +2 -1
- package/plugins/sonar-cognitive.js +71 -0
- package/dist/chunk-YQRDKWSH.js.map +0 -1
- /package/dist/{chunk-AMCGMLPS.js.map → chunk-SPES5MQT.js.map} +0 -0
package/README.md
CHANGED
|
@@ -171,6 +171,7 @@ throwaway run wants. Once a module has adopted, nothing is fetched any more: the
|
|
|
171
171
|
- [`docs/build-adoption.md`](docs/build-adoption.md) — moving a React app's Vite toolchain into sentinel: adoption changes the config's IMPORTS and nothing else, what sentinel owns versus what stays the app's, and why reading nothing is what makes losing nothing a guarantee.
|
|
172
172
|
- [`docs/typescript-adoption.md`](docs/typescript-adoption.md) — the adoption cheat sheet: the two adoption steps, the command model (verb x type x location), options, reading a report, and troubleshooting.
|
|
173
173
|
- [`docs/test-adoption.md`](docs/test-adoption.md) — migrating a module from jest to Vitest: the two steps, what the migration rewrites and what it refuses BY NAME, how the baseline gate proves nothing was lost, and the failures worth recognising.
|
|
174
|
+
- [`docs/static-analysis-adoption.md`](docs/static-analysis-adoption.md) — running the static analysis role on a module: the two steps, the three output shapes, how to read each row and where its threshold comes from, declaring an entry point, and the two limits worth knowing before trusting a number.
|
|
174
175
|
- [`docs/typescript-traces.md`](docs/typescript-traces.md) — a **generated, versioned** reference of live command + output traces (every verb, option, config result and edge case) against the mock monorepo. Regenerate after CLI changes with `pnpm docs:traces`.
|
|
175
176
|
|
|
176
177
|
## Architecture: `target → runner → preset`
|
package/dist/bin/sentinel.js
CHANGED
|
@@ -15,17 +15,17 @@ import {
|
|
|
15
15
|
palette,
|
|
16
16
|
registerAdapters,
|
|
17
17
|
resolve
|
|
18
|
-
} from "../chunk-
|
|
18
|
+
} from "../chunk-TL2P2X45.js";
|
|
19
19
|
import {
|
|
20
20
|
resolveContext
|
|
21
|
-
} from "../chunk-
|
|
21
|
+
} from "../chunk-IFK6KGUW.js";
|
|
22
22
|
import {
|
|
23
23
|
ensureWorkspacePrep,
|
|
24
24
|
findWorkspaceRoot,
|
|
25
25
|
inspectWorkspacePrep,
|
|
26
26
|
readOwnVersion,
|
|
27
27
|
readProjectPackageJson
|
|
28
|
-
} from "../chunk-
|
|
28
|
+
} from "../chunk-JEYGPPSL.js";
|
|
29
29
|
import "../chunk-AYEOGJQR.js";
|
|
30
30
|
import "../chunk-O7REVMOC.js";
|
|
31
31
|
import "../chunk-QXFCZON7.js";
|
|
@@ -148,7 +148,7 @@ async function runValidate(ctx) {
|
|
|
148
148
|
);
|
|
149
149
|
}
|
|
150
150
|
if (!ctx.targets.includes("test")) return 1;
|
|
151
|
-
const { validateModuleTests } = await import("../validate-
|
|
151
|
+
const { validateModuleTests } = await import("../validate-7VBODB4M.js");
|
|
152
152
|
const outcomes = modules.flatMap(
|
|
153
153
|
(module) => validateModuleTests(module.name, module.root)
|
|
154
154
|
);
|
|
@@ -24,6 +24,15 @@ function readOwnPackage() {
|
|
|
24
24
|
function readOwnVersion() {
|
|
25
25
|
return readOwnPackage().version;
|
|
26
26
|
}
|
|
27
|
+
function ownPackageDir() {
|
|
28
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
for (; ; ) {
|
|
30
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
31
|
+
const parent = dirname(dir);
|
|
32
|
+
if (parent === dir) return dir;
|
|
33
|
+
dir = parent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
27
36
|
function readProjectPackageJson(dir) {
|
|
28
37
|
const path = join(dir, "package.json");
|
|
29
38
|
if (!existsSync(path)) return {};
|
|
@@ -279,6 +288,7 @@ function inspectWorkspacePrep(root) {
|
|
|
279
288
|
export {
|
|
280
289
|
readOwnPackage,
|
|
281
290
|
readOwnVersion,
|
|
291
|
+
ownPackageDir,
|
|
282
292
|
readProjectPackageJson,
|
|
283
293
|
readNxProjectName,
|
|
284
294
|
moduleName,
|
|
@@ -288,4 +298,4 @@ export {
|
|
|
288
298
|
ensureWorkspacePrep,
|
|
289
299
|
inspectWorkspacePrep
|
|
290
300
|
};
|
|
291
|
-
//# sourceMappingURL=chunk-
|
|
301
|
+
//# sourceMappingURL=chunk-JEYGPPSL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shared/package-json.ts","../src/core/settings.ts","../src/core/workspace-prep.ts"],"sourcesContent":["/**\n * package.json helpers, shared by the CLI and the engine.\n */\nimport { existsSync, readFileSync } from 'node:fs'\nimport { basename, dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\n/**\n * sentinel's OWN version, for `--version`. We must NOT use\n * `process.env.npm_package_version`, that is the version of whatever script\n * invoked us (an app's package.json, or nothing). Instead walk up from this\n * module to the nearest package.json, which is always sentinel's own, whether\n * running from source or the bundled dist.\n */\n/**\n * sentinel's OWN name + version, read by walking up from this module to the nearest\n * package.json. Used both for `--version` and to pin the exact dependency `--init`\n * writes into an adopted module, so a module always references the version that adopted it.\n */\nexport function readOwnPackage(): { name: string; version: string; bin?: string } {\n let dir = dirname(fileURLToPath(import.meta.url))\n for (;;) {\n const pkgPath = join(dir, 'package.json')\n if (existsSync(pkgPath)) {\n try {\n const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {\n name?: string\n version?: string\n bin?: string | Record<string, string>\n }\n if (typeof pkg.version === 'string') {\n // The bin PATH, not the command name: a package that adopts itself has to invoke\n // its own build output, because the `.bin` shim does not exist yet (see below).\n const bin = typeof pkg.bin === 'string' ? pkg.bin : Object.values(pkg.bin ?? {})[0]\n return { name: pkg.name ?? '@hublo/sentinel', version: pkg.version, bin }\n }\n } catch {\n // Malformed package.json; keep walking up.\n }\n }\n const parent = dirname(dir)\n if (parent === dir) return { name: '@hublo/sentinel', version: '0.0.0' } // filesystem root\n dir = parent\n }\n}\n\nexport function readOwnVersion(): string {\n return readOwnPackage().version\n}\n\n/**\n * The directory sentinel's own `package.json` sits in, for the files it SHIPS beside its code.\n *\n * ⛔ Walked up to, never computed from a count of `..`. The emitted layout is not ours to predict:\n * `tsup` bundles the source into chunks whose depth has nothing to do with the source tree's, and\n * it moves code between them from one build to the next. A relative path from `import.meta.url` is\n * therefore right in `src/` and wrong in `dist/`, which is exactly the failure\n * `roles/test/shared-test-config.ts` records at length after it cost a module 83 passing tests.\n *\n * Measured here: `plugins/sonar-cognitive.js` resolved through a four-segment relative path existed\n * when run from source and did not after the build, and the only symptom was the whole size pass\n * reporting \"oxlint could not be run\".\n */\nexport function ownPackageDir(): string {\n let dir = dirname(fileURLToPath(import.meta.url))\n for (;;) {\n if (existsSync(join(dir, 'package.json'))) return dir\n const parent = dirname(dir)\n if (parent === dir) return dir\n dir = parent\n }\n}\n\n/**\n * Read a module's package.json for framework detection; tolerant of a missing or\n * malformed file (returns `{}`, so detection falls through to the `node` default).\n */\nexport function readProjectPackageJson(dir: string): {\n /** The package name, which is also what nx calls the project when there is no `project.json`. */\n name?: string\n dependencies?: Record<string, string>\n devDependencies?: Record<string, string>\n /**\n * How the module's `.js`/`.ts` files are loaded. It decides whether a Vite config can import\n * an ESM-only package at all, which is why the build role reads it before rewriting one.\n */\n type?: string\n /**\n * What nx alone needs per target, which every role writes here and none could read back.\n *\n * Typed loosely on purpose: this reader is shared by every role and each one knows the shape of\n * its own target. Declared at all because a role that WRITES a block and then has to cast to\n * read it is one refactor away from reading a key it never wrote.\n */\n nx?: { targets?: Record<string, Record<string, unknown>> }\n} {\n const path = join(dir, 'package.json')\n if (!existsSync(path)) return {}\n try {\n return JSON.parse(readFileSync(path, 'utf8')) as Record<string, never>\n } catch {\n process.stderr.write(`sentinel: could not parse ${path}; ignoring for detection.\\n`)\n return {}\n }\n}\n\n/**\n * The nx project name for a module, read from its `project.json`, or undefined if\n * there is none/unreadable. Used when `--init` has to scaffold a `package.json`\n * for a module that only has a `project.json` (common for nx apps/services): the\n * scaffolded file borrows this name so it is a valid, uniquely-named workspace\n * package. Reusable by any tool that scaffolds, not just TypeScript.\n */\nexport function readNxProjectName(dir: string): string | undefined {\n const path = join(dir, 'project.json')\n if (!existsSync(path)) return undefined\n try {\n const parsed = JSON.parse(readFileSync(path, 'utf8')) as { name?: unknown }\n return typeof parsed.name === 'string' ? parsed.name : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * What nx calls this module, resolved the way nx itself resolves it.\n *\n * The precedence is `project.json`, then `package.json`, then the directory, and only the\n * first and last of those were being consulted. A module with no `project.json` therefore\n * answered with its DIRECTORY while nx answered with its package name, and the two disagree\n * in the real repo: `apps/front/hr-management` is `front-hr-management` to nx, as is\n * `apps/front/storybook-app`.\n *\n * That gap is not cosmetic, because this name is what `--module` is matched against. The\n * `serve` script `--init` writes carries `--module <name>`, so a module in that shape was\n * adopted with a script naming a project the workspace does not contain, and `pnpm run serve`\n * failed the day it was adopted with nothing having reported it.\n *\n * One function so the two ends cannot drift again: whoever WRITES the name and whoever\n * RESOLVES it now read the same source.\n */\nexport function moduleName(dir: string): string {\n const declared = readProjectPackageJson(dir).name\n return readNxProjectName(dir) ?? (declared === undefined ? basename(dir) : declared)\n}\n","/**\n * Tunable settings: the knobs you would actually change. The fixed vocabulary and\n * its types live in `domain.ts`.\n *\n * Note: the preset is DETECTED from a module's dependencies (see\n * `detect-framework.ts`), deterministically, `node` when no framework dep is present.\n * `--preset` overrides the detection (recommended for `--init`, since hoisted deps\n * can make detection return `node`). The detection table lives in `detect-framework.ts`.\n */\n\n/** A file that marks a workspace root; sentinel refuses to operate there. */\nexport const WORKSPACE_ROOT_MARKER = 'nx.json'\n\n/**\n * How many structured diagnostics a report embeds per module, when the caller does not say.\n *\n * A cap, not a limit on what is counted: the totals stay exact and `diagnosticsTruncated`\n * says when rows were dropped, so a bounded envelope never reads as a clean module. Shared\n * by every role, because a consumer reading `--json` across roles should not have to learn\n * a different cap for each one. `--max-diagnostics 0` means no cap.\n */\nexport const DEFAULT_MAX_DIAGNOSTICS = 100\n","/**\n * Workspace-level preparation that `--init` applies once, at the workspace root, so an\n * adopted module actually installs and resolves cleanly. `--init` writes a module's own\n * files; this is the small, deliberate exception where it also touches the root, because\n * \"set this module up\" is not honest if the workspace it lives in cannot then install or\n * resolve it.\n *\n * Kept intentionally narrow (YAGNI): it applies the specific fixes a workspace's OWN\n * config shows it needs, and is a no-op everywhere else. It is NOT a speculative dedupe\n * engine, when a second, different case appears we generalize from real cases instead of\n * predicting them. The two fixes today:\n *\n * - i18next singleton override. If the root declares a second TypeScript via the\n * `@typescript/native` alias (a repo mid-migration running two TypeScripts), adopting a\n * module re-resolves pnpm and i18next, which carries an optional type-only `typescript`\n * peer, forks into two peer-keyed variants. That forks react-i18next into two React\n * contexts and breaks translation-singleton tests. Pinning i18next's `typescript` peer\n * to the declared TS collapses it to one. Read from the root's own declaration, so it\n * tracks the version and disappears (no-op) once the migration removes the second TS.\n * Written to `pnpm-workspace.yaml`, the only place current pnpm reads overrides from.\n *\n * - release-age allow-list. If the root uses pnpm's `minimumReleaseAgeExclude` gate, a\n * freshly published sentinel is younger than the gate and will not install; allow-list\n * it. Only touched when the gate is actually configured.\n *\n * - root formatter exclusion. A module that has adopted the FORMAT role is formatted by\n * oxfmt, from its own config. The root Prettier does not know that and still owns every\n * file in the repo, so `nx format:write` or `prettier --write .` from the root reformats\n * the module back to the ROOT's style, and the next `pnpm run format` in the module\n * reverses it. Two formatters fighting over the same files, with whichever ran last\n * winning, is the exact state adoption removes INSIDE a module, and it has to be removed\n * at the root too. So the module is excluded in the root `.prettierignore` as it adopts,\n * and the root's own config is otherwise untouched: it still serves every module that has\n * not migrated.\n *\n * All three edits are idempotent and defensive: absent the trigger (no second TypeScript, no\n * release-age gate, no root Prettier) each is skipped, so the step is safe on any workspace.\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs'\nimport { dirname, join, relative } from 'node:path'\n\nimport { readOwnPackage } from '../shared/package-json.js'\n\nimport { WORKSPACE_ROOT_MARKER } from './settings.js'\n\nconst OVERRIDE_KEY = 'i18next>typescript'\nconst NATIVE_TS_ALIAS = '@typescript/native' // `npm:typescript@^7.x`: a workspace's second, real TS\nconst WORKSPACE_YAML = 'pnpm-workspace.yaml'\nconst RELEASE_AGE_KEY = 'minimumReleaseAgeExclude'\nconst LIST_ITEM = /^(\\s*)-\\s+(.*?)\\s*$/\nconst MAP_ENTRY = /^(\\s+)(.*?):\\s+(.*?)\\s*$/\n\ninterface RootPackageJson {\n dependencies?: Record<string, string>\n devDependencies?: Record<string, string>\n pnpm?: { overrides?: Record<string, string> }\n}\n\n/** Walk up from a module directory to the workspace root (the dir holding the marker). */\nexport function findWorkspaceRoot(startDir: string): string | undefined {\n let dir = startDir\n for (;;) {\n if (existsSync(join(dir, WORKSPACE_ROOT_MARKER))) return dir\n const parent = dirname(dir)\n if (parent === dir) return undefined\n dir = parent\n }\n}\n\n/**\n * The TypeScript version to pin i18next's peer to, read from the root's own\n * `@typescript/native` alias (`npm:typescript@^7.0.2` -> `7.0.2`). Returns undefined when\n * the alias is absent (no second TypeScript, so no fork to fix).\n */\nfunction declaredNativeTs(pkg: RootPackageJson): string | undefined {\n const spec = pkg.dependencies?.[NATIVE_TS_ALIAS] ?? pkg.devDependencies?.[NATIVE_TS_ALIAS]\n if (!spec) return undefined\n const version = spec.slice(spec.lastIndexOf('@') + 1).replace(/^[\\^~>=<\\s]+/, '')\n return version || undefined\n}\n\n/**\n * Ensure the i18next singleton override matches the declared second TypeScript. No-op when\n * the workspace runs a single TypeScript (no `@typescript/native`). Returns a message when\n * it changed (or would change, under dryRun), else undefined.\n *\n * The override is written to `pnpm-workspace.yaml`, never to `package.json#pnpm`: pnpm 11\n * stopped reading the `pnpm` field and does so SILENTLY, with no warning and no deprecation\n * (pnpm/pnpm#11536), so an override left in `package.json` is simply not applied. The yaml is\n * read by pnpm 10 and 11 alike (https://pnpm.io/settings), which is why there is one target\n * here rather than a version-dependent fallback.\n */\nfunction ensureI18nextSingleton(root: string, dryRun: boolean): string | undefined {\n const pkgPath = join(root, 'package.json')\n if (!existsSync(pkgPath)) return undefined\n const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as RootPackageJson\n const want = declaredNativeTs(pkg)\n if (!want) return undefined // single TypeScript: nothing forks\n\n const yamlPath = join(root, WORKSPACE_YAML)\n if (!existsSync(yamlPath)) return undefined // no workspace file: nowhere pnpm would read it\n const lines = readFileSync(yamlPath, 'utf8').split('\\n')\n const entry = findOverrideEntry(lines)\n if (entry.valueIdx !== undefined && entry.value === want) return undefined // already pinned\n if (dryRun) return `would pin ${OVERRIDE_KEY} to ${want} in ${WORKSPACE_YAML} (i18next singleton)`\n\n applyOverride(lines, entry, want)\n writeFileSync(yamlPath, lines.join('\\n'))\n return `pinned ${OVERRIDE_KEY} to ${want} in ${WORKSPACE_YAML} (i18next singleton)`\n}\n\n/** Where the override sits in the yaml, if at all, plus where a new one would be inserted. */\ninterface OverrideEntry {\n /** Index of the `OVERRIDE_KEY` line, when the key is already declared. */\n valueIdx?: number\n /** Its current value, unquoted. */\n value?: string\n /** Index of the `overrides:` key line, when the block exists. */\n blockIdx?: number\n /** Index of the block's last entry, so an insertion keeps the block contiguous. */\n lastEntryIdx?: number\n /** Indentation the block's entries use. */\n indent: string\n}\n\n/**\n * Locate the `overrides:` block and the i18next key inside it. A targeted text read rather\n * than a yaml parse, for the same reason `ensureReleaseAgeAllowList` does it: this file is\n * hand-maintained and round-tripping it through a parser would drop its comments and ordering.\n */\nfunction findOverrideEntry(lines: string[]): OverrideEntry {\n const blockIdx = lines.findIndex((line) => line.replace(/\\s+$/, '') === 'overrides:')\n if (blockIdx === -1) return { indent: ' ' }\n\n let lastEntryIdx = blockIdx\n let indent = ' '\n for (let i = blockIdx + 1; i < lines.length; i++) {\n const match = lines[i]?.match(MAP_ENTRY)\n if (!match) break // dedent or blank line: the block ended\n indent = match[1] ?? indent\n lastEntryIdx = i\n if ((match[2] ?? '').replace(/^['\"]|['\"]$/g, '') === OVERRIDE_KEY) {\n return {\n valueIdx: i,\n value: (match[3] ?? '').replace(/^['\"]|['\"]$/g, ''),\n blockIdx,\n lastEntryIdx,\n indent,\n }\n }\n }\n return { blockIdx, lastEntryIdx, indent }\n}\n\n/** Update the key in place, append it to an existing block, or open a block at the end. */\nfunction applyOverride(lines: string[], entry: OverrideEntry, want: string): void {\n const line = `${entry.indent}'${OVERRIDE_KEY}': ${want}`\n if (entry.valueIdx !== undefined) {\n lines[entry.valueIdx] = line\n return\n }\n if (entry.lastEntryIdx !== undefined) {\n lines.splice(entry.lastEntryIdx + 1, 0, line)\n return\n }\n // No `overrides:` block at all: open one, keeping a single trailing newline.\n while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()\n lines.push('overrides:', line, '')\n}\n\n/**\n * Ensure sentinel is allow-listed under `minimumReleaseAgeExclude` so a fresh publish\n * installs. No-op when the workspace has no such gate. Targeted text edit so comments and\n * formatting are preserved. Returns a message when it changed (or would), else undefined.\n */\nfunction ensureReleaseAgeAllowList(root: string, dryRun: boolean): string | undefined {\n const yamlPath = join(root, WORKSPACE_YAML)\n if (!existsSync(yamlPath)) return undefined\n const own = readOwnPackage().name\n const lines = readFileSync(yamlPath, 'utf8').split('\\n')\n const keyIdx = lines.findIndex((line) => line.replace(/\\s+$/, '') === `${RELEASE_AGE_KEY}:`)\n if (keyIdx === -1) return undefined // no release-age gate in use\n\n let lastItemIdx = keyIdx\n let indent = ' '\n for (let i = keyIdx + 1; i < lines.length; i++) {\n const match = lines[i]?.match(LIST_ITEM)\n if (!match) break\n indent = match[1] ?? indent\n lastItemIdx = i\n if ((match[2] ?? '').replace(/^['\"]|['\"]$/g, '') === own) return undefined // already listed\n }\n if (dryRun) return `would allow-list ${own} under ${RELEASE_AGE_KEY}`\n\n lines.splice(lastItemIdx + 1, 0, `${indent}- '${own}'`)\n writeFileSync(yamlPath, lines.join('\\n'))\n return `allow-listed ${own} under ${RELEASE_AGE_KEY} in ${WORKSPACE_YAML}`\n}\n\n/** The root ignore file Prettier reads, and the section sentinel owns inside it. */\nconst GIT_IGNORE = '.gitignore'\nconst REPORT_DIR_NAME = 'static-analysis-report'\nconst REPORT_PATTERN = `${REPORT_DIR_NAME}/`\nconst REPORT_HEADER = '# @hublo/sentinel: static analysis reports, written inside each module'\nconst PRETTIER_IGNORE = '.prettierignore'\nconst EXCLUSION_HEADER = '# @hublo/sentinel: modules formatted by oxfmt, not by the root Prettier'\n\n/** Root Prettier configs. Their presence is what makes an exclusion meaningful. */\nconst ROOT_PRETTIER_CONFIGS = [\n '.prettierrc',\n '.prettierrc.json',\n '.prettierrc.json5',\n '.prettierrc.yml',\n '.prettierrc.yaml',\n '.prettierrc.js',\n '.prettierrc.cjs',\n '.prettierrc.mjs',\n 'prettier.config.js',\n 'prettier.config.cjs',\n 'prettier.config.mjs',\n]\n\n/**\n * Exclude a module that now formats itself from the ROOT Prettier.\n *\n * Written as a directory pattern anchored at the root (`/apps/front/x/`), which is what\n * gitignore syntax means by \"this exact directory\", so a module cannot accidentally exclude a\n * same-named directory somewhere else.\n *\n * Skipped entirely when the root has no Prettier at all: there is then nothing to conflict\n * with, and creating an ignore file for a tool the workspace does not use would be noise.\n */\nfunction ensureFormatterExclusion(\n root: string,\n moduleDir: string,\n dryRun: boolean,\n): string | undefined {\n const rel = relative(root, moduleDir).replaceAll('\\\\', '/')\n if (rel === '' || rel.startsWith('..')) return undefined // the root itself, or outside it\n\n const ignorePath = join(root, PRETTIER_IGNORE)\n const hasPrettier =\n existsSync(ignorePath) || ROOT_PRETTIER_CONFIGS.some((name) => existsSync(join(root, name)))\n if (!hasPrettier) return undefined\n\n const pattern = `/${rel}/`\n const existing = existsSync(ignorePath) ? readFileSync(ignorePath, 'utf8') : ''\n const lines = existing.split('\\n')\n // Already excluded, in whichever spelling a human may have used.\n if (lines.some((line) => line.trim().replace(/\\/$/, '') === pattern.replace(/\\/$/, ''))) {\n return undefined\n }\n if (dryRun) return `would exclude ${rel} from the root Prettier (${PRETTIER_IGNORE})`\n\n const headerIdx = lines.findIndex((line) => line.trim() === EXCLUSION_HEADER)\n if (headerIdx === -1) {\n const prefix = existing.trim() === '' ? '' : `${existing.replace(/\\n*$/, '')}\\n\\n`\n writeFileSync(ignorePath, `${prefix}${EXCLUSION_HEADER}\\n${pattern}\\n`)\n } else {\n // Keep the managed section together and sorted, so the list stays readable as it grows\n // to 150 entries and a diff shows one added line rather than a reshuffle.\n let end = headerIdx + 1\n while (end < lines.length && lines[end]?.trim().startsWith('/')) end += 1\n const managed = [...lines.slice(headerIdx + 1, end), pattern].sort()\n writeFileSync(\n ignorePath,\n [...lines.slice(0, headerIdx + 1), ...managed, ...lines.slice(end)].join('\\n'),\n )\n }\n return `excluded ${rel} from the root Prettier (${PRETTIER_IGNORE}): it formats itself now`\n}\n\n/**\n * Apply every workspace-level fix the given root needs. Returns the human-readable\n * messages for what changed (empty when nothing did). Under dryRun nothing is written and\n * the messages describe what would change.\n *\n * `formattedModule` is the module that just adopted the FORMAT role, when one did. Absent\n * for a lint-only or TypeScript-only `--init`, because a module that has not adopted the\n * formatter must keep being formatted by the root.\n */\n/**\n * Ignore the static analysis report directory, once, at the ROOT.\n *\n * ## Why the root and not each module\n *\n * The role writes its json and html into `<module>/static-analysis-report/`, so the obvious place\n * for the rule is the module. It was there first, and it was wrong twice over: it CREATES a\n * `.gitignore` in a module that had none, which is a structural change to someone else's module for\n * one line, and it repeats that line once per adopting module. The root already ignores output this\n * way, with `/dist` and `/coverage`.\n *\n * ⚠️ The pattern is deliberately NOT anchored. `/dist` matches only the root's `dist`; the report\n * directory lives inside a module, so `static-analysis-report/` has to match at any depth. Getting\n * that backwards would leave every adopting module with an untracked directory, which is the exact\n * defect this guards against.\n *\n * ## Why this root write is not the dangerous kind\n *\n * The comment on `inspectWorkspacePrep` is right that root writes are what nobody sees afterwards,\n * and the i18next override earned that warning: it re-resolved a module from a pull request that\n * touched none of its files. An ignore line changes no resolution, no build and no install. If it\n * ever goes stale the cost is one unused line, which is why it is reported rather than feared.\n */\nfunction ensureReportIgnored(root: string, dryRun: boolean): string | undefined {\n const path = join(root, GIT_IGNORE)\n const existing = existsSync(path) ? readFileSync(path, 'utf8') : ''\n // Whatever spelling a human may have used: with or without a leading or trailing slash.\n const already = existing\n .split('\\n')\n .some((line) => line.trim().replace(/^\\//, '').replace(/\\/$/, '') === REPORT_DIR_NAME)\n if (already) return undefined\n if (dryRun) return `would ignore ${REPORT_PATTERN} in the root ${GIT_IGNORE}`\n\n const prefix = existing.trim() === '' ? '' : `${existing.replace(/\\n*$/, '')}\\n\\n`\n writeFileSync(path, `${prefix}${REPORT_HEADER}\\n${REPORT_PATTERN}\\n`)\n return `ignored ${REPORT_PATTERN} in the root ${GIT_IGNORE}`\n}\n\nexport function ensureWorkspacePrep(opts: {\n root: string\n dryRun?: boolean\n formattedModule?: string\n /** True when the STATIC ANALYSIS role adopted: it is what earns the ignore line. */\n analysedModule?: boolean\n}): string[] {\n const dryRun = Boolean(opts.dryRun)\n return [\n ensureI18nextSingleton(opts.root, dryRun),\n ensureReleaseAgeAllowList(opts.root, dryRun),\n opts.formattedModule === undefined\n ? undefined\n : ensureFormatterExclusion(opts.root, opts.formattedModule, dryRun),\n opts.analysedModule === true ? ensureReportIgnored(opts.root, dryRun) : undefined,\n ].filter((message): message is string => message !== undefined)\n}\n\n/**\n * One standing change sentinel has made to the workspace ROOT, and whether it is still earned.\n *\n * This exists because the three root writes are the only thing sentinel does that nobody can\n * see afterwards. `--init` announces each one on the run that makes it, and then it is a line\n * in someone else's file forever. Two consequences already bit us.\n *\n * The `i18next>typescript` override re-resolved `apps/front/console` from a PR that touched no\n * file under it, so CODEOWNERS pulled nobody and its owners learned about it afterwards.\n *\n * And every one of them is TEMPORARY with its removal condition written in prose. That failed\n * three times in one week: `SENTINEL_TAG=alpha` in the migration-page workflow kept building\n * with a pre-release long after 1.1.0 went stable and reported `Format 0/439` for 15 adopted\n * modules; ADR 0042 and its RFC both sat mid-flight past their deadline; and this release-age\n * exclusion is still waiting on \"once sentinel's release cadence settles\". Nothing failed when\n * any of them expired, because a comment cannot fail.\n *\n * So each entry carries the condition that JUSTIFIES it, evaluated now rather than described.\n * A trigger that has gone away makes the entry dead config, and dead config is something a\n * command can say out loud instead of something a reader has to notice.\n *\n * Read-only, and offline: it inspects the same files `--init` writes and nothing else. What it\n * deliberately does NOT claim is anything needing the npm registry — whether a published\n * version is old enough to clear the release-age gate is not knowable from the repo, so it is\n * stated as the standing exemption it is rather than guessed at.\n */\nexport interface WorkspacePrepEntry {\n /** `rule` to match the shape the shared renderer already prints as a labelled block. */\n rule: string\n /** What it does, whether its trigger still holds, and what would retire it. */\n reason: string\n}\n\n/** The root-level changes sentinel maintains here, each with its justification checked now. */\nexport function inspectWorkspacePrep(root: string): WorkspacePrepEntry[] {\n const entries: WorkspacePrepEntry[] = []\n // Tolerant, unlike the write path above, and the difference is deliberate. `--init` should\n // fail loudly on a root it cannot parse, because it is about to edit it. `--inspect` is a\n // read verb that sweeps 441 modules, and one malformed root file must not take the whole\n // sweep down: the modules still have honest answers to give. Caught by its own test.\n let pkg: RootPackageJson = {}\n const pkgPath = join(root, 'package.json')\n if (existsSync(pkgPath)) {\n try {\n pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as RootPackageJson\n } catch {\n pkg = {}\n }\n }\n\n const yamlPath = join(root, WORKSPACE_YAML)\n const yaml = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf8') : undefined\n\n const pinned = yaml === undefined ? undefined : findOverrideEntry(yaml.split('\\n')).value\n if (pinned !== undefined) {\n const stillForks = declaredNativeTs(pkg)\n entries.push({\n rule: `${WORKSPACE_YAML} → overrides.${OVERRIDE_KEY}`,\n reason:\n stillForks === undefined\n ? `pinned to ${pinned}, but the root no longer declares ${NATIVE_TS_ALIAS}, so nothing ` +\n `forks any more. This override is now dead config and can be removed.`\n : `pinned to ${pinned}, matching the second TypeScript the root declares ` +\n `(${NATIVE_TS_ALIAS}). It re-resolves i18next for EVERY module that depends on it, ` +\n `not only adopted ones. It retires with the second TypeScript.`,\n })\n }\n\n // A copy left in `package.json#pnpm` by an older sentinel, or by hand. Reported because it\n // is INERT rather than redundant: pnpm 11 dropped the `pnpm` field with no warning\n // (pnpm/pnpm#11536), so it reads as protection that is not applied.\n const legacy = pkg.pnpm?.overrides?.[OVERRIDE_KEY]\n if (legacy !== undefined) {\n entries.push({\n rule: `package.json → pnpm.overrides.${OVERRIDE_KEY}`,\n reason:\n `pinned to ${legacy}, but pnpm 11 no longer reads the \\`pnpm\\` field of package.json ` +\n `and says nothing about it, so this override is not applied. Move it to ` +\n `${WORKSPACE_YAML} under \\`overrides:\\`, which pnpm 10 and 11 both read.`,\n })\n }\n\n const ignorePath = join(root, GIT_IGNORE)\n if (existsSync(ignorePath)) {\n const ignored = readFileSync(ignorePath, 'utf8')\n .split('\\n')\n .some((line) => line.trim().replace(/^\\//, '').replace(/\\/$/, '') === REPORT_DIR_NAME)\n if (ignored) {\n entries.push({\n rule: `${GIT_IGNORE} → ${REPORT_PATTERN}`,\n reason:\n `the static analysis role writes its json and html into ` +\n `\\`<module>/${REPORT_PATTERN}\\`, and one unanchored line at the root covers every module ` +\n `instead of creating a \\`${GIT_IGNORE}\\` in each one. Unlike the override above it changes ` +\n `no resolution and no build, so a stale one costs a single unused line. It retires when no ` +\n `module declares a \\`static-analysis\\` script any more, which the adoption report counts ` +\n `and this read deliberately does not: it would have to sweep every module.`,\n })\n }\n }\n\n const own = readOwnPackage().name\n if (yaml !== undefined) {\n const listed = new RegExp(`^\\\\s*-\\\\s*['\"]?${own.replace('/', '\\\\/')}['\"]?\\\\s*$`, 'm').test(yaml)\n if (listed) {\n const gated = new RegExp(`^\\\\s*minimumReleaseAge\\\\s*:`, 'm').test(yaml)\n entries.push({\n rule: `${WORKSPACE_YAML} → ${RELEASE_AGE_KEY}: ${own}`,\n reason: gated\n ? `${own} is exempt from this workspace's release-age gate, so a freshly published ` +\n `version installs instead of being held. That is a standing exemption from a ` +\n `supply-chain control: the exposure is one bump wide, since the version is pinned ` +\n `and the lockfile carries its integrity, so a re-publish of the same version does ` +\n `not pass. Retire it once a bump can simply wait out the window.`\n : `${own} is allow-listed, but this workspace no longer sets minimumReleaseAge, so ` +\n `there is no gate to be exempt from. This entry is now dead config and can be removed.`,\n })\n }\n }\n\n return entries\n}\n"],"mappings":";AAGA,SAAS,YAAY,oBAAoB;AACzC,SAAS,UAAU,SAAS,YAAY;AACxC,SAAS,qBAAqB;AAcvB,SAAS,iBAAkE;AAChF,MAAI,MAAM,QAAQ,cAAc,YAAY,GAAG,CAAC;AAChD,aAAS;AACP,UAAM,UAAU,KAAK,KAAK,cAAc;AACxC,QAAI,WAAW,OAAO,GAAG;AACvB,UAAI;AACF,cAAM,MAAM,KAAK,MAAM,aAAa,SAAS,MAAM,CAAC;AAKpD,YAAI,OAAO,IAAI,YAAY,UAAU;AAGnC,gBAAM,MAAM,OAAO,IAAI,QAAQ,WAAW,IAAI,MAAM,OAAO,OAAO,IAAI,OAAO,CAAC,CAAC,EAAE,CAAC;AAClF,iBAAO,EAAE,MAAM,IAAI,QAAQ,mBAAmB,SAAS,IAAI,SAAS,IAAI;AAAA,QAC1E;AAAA,MACF,QAAQ;AAAA,MAER;AAAA,IACF;AACA,UAAM,SAAS,QAAQ,GAAG;AAC1B,QAAI,WAAW,IAAK,QAAO,EAAE,MAAM,mBAAmB,SAAS,QAAQ;AACvE,UAAM;AAAA,EACR;AACF;AAEO,SAAS,iBAAyB;AACvC,SAAO,eAAe,EAAE;AAC1B;AAeO,SAAS,gBAAwB;AACtC,MAAI,MAAM,QAAQ,cAAc,YAAY,GAAG,CAAC;AAChD,aAAS;AACP,QAAI,WAAW,KAAK,KAAK,cAAc,CAAC,EAAG,QAAO;AAClD,UAAM,SAAS,QAAQ,GAAG;AAC1B,QAAI,WAAW,IAAK,QAAO;AAC3B,UAAM;AAAA,EACR;AACF;AAMO,SAAS,uBAAuB,KAkBrC;AACA,QAAM,OAAO,KAAK,KAAK,cAAc;AACrC,MAAI,CAAC,WAAW,IAAI,EAAG,QAAO,CAAC;AAC/B,MAAI;AACF,WAAO,KAAK,MAAM,aAAa,MAAM,MAAM,CAAC;AAAA,EAC9C,QAAQ;AACN,YAAQ,OAAO,MAAM,6BAA6B,IAAI;AAAA,CAA6B;AACnF,WAAO,CAAC;AAAA,EACV;AACF;AASO,SAAS,kBAAkB,KAAiC;AACjE,QAAM,OAAO,KAAK,KAAK,cAAc;AACrC,MAAI,CAAC,WAAW,IAAI,EAAG,QAAO;AAC9B,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,aAAa,MAAM,MAAM,CAAC;AACpD,WAAO,OAAO,OAAO,SAAS,WAAW,OAAO,OAAO;AAAA,EACzD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAmBO,SAAS,WAAW,KAAqB;AAC9C,QAAM,WAAW,uBAAuB,GAAG,EAAE;AAC7C,SAAO,kBAAkB,GAAG,MAAM,aAAa,SAAY,SAAS,GAAG,IAAI;AAC7E;;;ACrIO,IAAM,wBAAwB;AAU9B,IAAM,0BAA0B;;;ACiBvC,SAAS,cAAAA,aAAY,gBAAAC,eAAc,qBAAqB;AACxD,SAAS,WAAAC,UAAS,QAAAC,OAAM,gBAAgB;AAMxC,IAAM,eAAe;AACrB,IAAM,kBAAkB;AACxB,IAAM,iBAAiB;AACvB,IAAM,kBAAkB;AACxB,IAAM,YAAY;AAClB,IAAM,YAAY;AASX,SAAS,kBAAkB,UAAsC;AACtE,MAAI,MAAM;AACV,aAAS;AACP,QAAIC,YAAWC,MAAK,KAAK,qBAAqB,CAAC,EAAG,QAAO;AACzD,UAAM,SAASC,SAAQ,GAAG;AAC1B,QAAI,WAAW,IAAK,QAAO;AAC3B,UAAM;AAAA,EACR;AACF;AAOA,SAAS,iBAAiB,KAA0C;AAClE,QAAM,OAAO,IAAI,eAAe,eAAe,KAAK,IAAI,kBAAkB,eAAe;AACzF,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,UAAU,KAAK,MAAM,KAAK,YAAY,GAAG,IAAI,CAAC,EAAE,QAAQ,gBAAgB,EAAE;AAChF,SAAO,WAAW;AACpB;AAaA,SAAS,uBAAuB,MAAc,QAAqC;AACjF,QAAM,UAAUD,MAAK,MAAM,cAAc;AACzC,MAAI,CAACD,YAAW,OAAO,EAAG,QAAO;AACjC,QAAM,MAAM,KAAK,MAAMG,cAAa,SAAS,MAAM,CAAC;AACpD,QAAM,OAAO,iBAAiB,GAAG;AACjC,MAAI,CAAC,KAAM,QAAO;AAElB,QAAM,WAAWF,MAAK,MAAM,cAAc;AAC1C,MAAI,CAACD,YAAW,QAAQ,EAAG,QAAO;AAClC,QAAM,QAAQG,cAAa,UAAU,MAAM,EAAE,MAAM,IAAI;AACvD,QAAM,QAAQ,kBAAkB,KAAK;AACrC,MAAI,MAAM,aAAa,UAAa,MAAM,UAAU,KAAM,QAAO;AACjE,MAAI,OAAQ,QAAO,aAAa,YAAY,OAAO,IAAI,OAAO,cAAc;AAE5E,gBAAc,OAAO,OAAO,IAAI;AAChC,gBAAc,UAAU,MAAM,KAAK,IAAI,CAAC;AACxC,SAAO,UAAU,YAAY,OAAO,IAAI,OAAO,cAAc;AAC/D;AAqBA,SAAS,kBAAkB,OAAgC;AACzD,QAAM,WAAW,MAAM,UAAU,CAAC,SAAS,KAAK,QAAQ,QAAQ,EAAE,MAAM,YAAY;AACpF,MAAI,aAAa,GAAI,QAAO,EAAE,QAAQ,KAAK;AAE3C,MAAI,eAAe;AACnB,MAAI,SAAS;AACb,WAAS,IAAI,WAAW,GAAG,IAAI,MAAM,QAAQ,KAAK;AAChD,UAAM,QAAQ,MAAM,CAAC,GAAG,MAAM,SAAS;AACvC,QAAI,CAAC,MAAO;AACZ,aAAS,MAAM,CAAC,KAAK;AACrB,mBAAe;AACf,SAAK,MAAM,CAAC,KAAK,IAAI,QAAQ,gBAAgB,EAAE,MAAM,cAAc;AACjE,aAAO;AAAA,QACL,UAAU;AAAA,QACV,QAAQ,MAAM,CAAC,KAAK,IAAI,QAAQ,gBAAgB,EAAE;AAAA,QAClD;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO,EAAE,UAAU,cAAc,OAAO;AAC1C;AAGA,SAAS,cAAc,OAAiB,OAAsB,MAAoB;AAChF,QAAM,OAAO,GAAG,MAAM,MAAM,IAAI,YAAY,MAAM,IAAI;AACtD,MAAI,MAAM,aAAa,QAAW;AAChC,UAAM,MAAM,QAAQ,IAAI;AACxB;AAAA,EACF;AACA,MAAI,MAAM,iBAAiB,QAAW;AACpC,UAAM,OAAO,MAAM,eAAe,GAAG,GAAG,IAAI;AAC5C;AAAA,EACF;AAEA,SAAO,MAAM,SAAS,KAAK,MAAM,MAAM,SAAS,CAAC,MAAM,GAAI,OAAM,IAAI;AACrE,QAAM,KAAK,cAAc,MAAM,EAAE;AACnC;AAOA,SAAS,0BAA0B,MAAc,QAAqC;AACpF,QAAM,WAAWF,MAAK,MAAM,cAAc;AAC1C,MAAI,CAACD,YAAW,QAAQ,EAAG,QAAO;AAClC,QAAM,MAAM,eAAe,EAAE;AAC7B,QAAM,QAAQG,cAAa,UAAU,MAAM,EAAE,MAAM,IAAI;AACvD,QAAM,SAAS,MAAM,UAAU,CAAC,SAAS,KAAK,QAAQ,QAAQ,EAAE,MAAM,GAAG,eAAe,GAAG;AAC3F,MAAI,WAAW,GAAI,QAAO;AAE1B,MAAI,cAAc;AAClB,MAAI,SAAS;AACb,WAAS,IAAI,SAAS,GAAG,IAAI,MAAM,QAAQ,KAAK;AAC9C,UAAM,QAAQ,MAAM,CAAC,GAAG,MAAM,SAAS;AACvC,QAAI,CAAC,MAAO;AACZ,aAAS,MAAM,CAAC,KAAK;AACrB,kBAAc;AACd,SAAK,MAAM,CAAC,KAAK,IAAI,QAAQ,gBAAgB,EAAE,MAAM,IAAK,QAAO;AAAA,EACnE;AACA,MAAI,OAAQ,QAAO,oBAAoB,GAAG,UAAU,eAAe;AAEnE,QAAM,OAAO,cAAc,GAAG,GAAG,GAAG,MAAM,MAAM,GAAG,GAAG;AACtD,gBAAc,UAAU,MAAM,KAAK,IAAI,CAAC;AACxC,SAAO,gBAAgB,GAAG,UAAU,eAAe,OAAO,cAAc;AAC1E;AAGA,IAAM,aAAa;AACnB,IAAM,kBAAkB;AACxB,IAAM,iBAAiB,GAAG,eAAe;AACzC,IAAM,gBAAgB;AACtB,IAAM,kBAAkB;AACxB,IAAM,mBAAmB;AAGzB,IAAM,wBAAwB;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAYA,SAAS,yBACP,MACA,WACA,QACoB;AACpB,QAAM,MAAM,SAAS,MAAM,SAAS,EAAE,WAAW,MAAM,GAAG;AAC1D,MAAI,QAAQ,MAAM,IAAI,WAAW,IAAI,EAAG,QAAO;AAE/C,QAAM,aAAaF,MAAK,MAAM,eAAe;AAC7C,QAAM,cACJD,YAAW,UAAU,KAAK,sBAAsB,KAAK,CAAC,SAASA,YAAWC,MAAK,MAAM,IAAI,CAAC,CAAC;AAC7F,MAAI,CAAC,YAAa,QAAO;AAEzB,QAAM,UAAU,IAAI,GAAG;AACvB,QAAM,WAAWD,YAAW,UAAU,IAAIG,cAAa,YAAY,MAAM,IAAI;AAC7E,QAAM,QAAQ,SAAS,MAAM,IAAI;AAEjC,MAAI,MAAM,KAAK,CAAC,SAAS,KAAK,KAAK,EAAE,QAAQ,OAAO,EAAE,MAAM,QAAQ,QAAQ,OAAO,EAAE,CAAC,GAAG;AACvF,WAAO;AAAA,EACT;AACA,MAAI,OAAQ,QAAO,iBAAiB,GAAG,4BAA4B,eAAe;AAElF,QAAM,YAAY,MAAM,UAAU,CAAC,SAAS,KAAK,KAAK,MAAM,gBAAgB;AAC5E,MAAI,cAAc,IAAI;AACpB,UAAM,SAAS,SAAS,KAAK,MAAM,KAAK,KAAK,GAAG,SAAS,QAAQ,QAAQ,EAAE,CAAC;AAAA;AAAA;AAC5E,kBAAc,YAAY,GAAG,MAAM,GAAG,gBAAgB;AAAA,EAAK,OAAO;AAAA,CAAI;AAAA,EACxE,OAAO;AAGL,QAAI,MAAM,YAAY;AACtB,WAAO,MAAM,MAAM,UAAU,MAAM,GAAG,GAAG,KAAK,EAAE,WAAW,GAAG,EAAG,QAAO;AACxE,UAAM,UAAU,CAAC,GAAG,MAAM,MAAM,YAAY,GAAG,GAAG,GAAG,OAAO,EAAE,KAAK;AACnE;AAAA,MACE;AAAA,MACA,CAAC,GAAG,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,GAAG,SAAS,GAAG,MAAM,MAAM,GAAG,CAAC,EAAE,KAAK,IAAI;AAAA,IAC/E;AAAA,EACF;AACA,SAAO,YAAY,GAAG,4BAA4B,eAAe;AACnE;AAkCA,SAAS,oBAAoB,MAAc,QAAqC;AAC9E,QAAM,OAAOF,MAAK,MAAM,UAAU;AAClC,QAAM,WAAWD,YAAW,IAAI,IAAIG,cAAa,MAAM,MAAM,IAAI;AAEjE,QAAM,UAAU,SACb,MAAM,IAAI,EACV,KAAK,CAAC,SAAS,KAAK,KAAK,EAAE,QAAQ,OAAO,EAAE,EAAE,QAAQ,OAAO,EAAE,MAAM,eAAe;AACvF,MAAI,QAAS,QAAO;AACpB,MAAI,OAAQ,QAAO,gBAAgB,cAAc,gBAAgB,UAAU;AAE3E,QAAM,SAAS,SAAS,KAAK,MAAM,KAAK,KAAK,GAAG,SAAS,QAAQ,QAAQ,EAAE,CAAC;AAAA;AAAA;AAC5E,gBAAc,MAAM,GAAG,MAAM,GAAG,aAAa;AAAA,EAAK,cAAc;AAAA,CAAI;AACpE,SAAO,WAAW,cAAc,gBAAgB,UAAU;AAC5D;AAEO,SAAS,oBAAoB,MAMvB;AACX,QAAM,SAAS,QAAQ,KAAK,MAAM;AAClC,SAAO;AAAA,IACL,uBAAuB,KAAK,MAAM,MAAM;AAAA,IACxC,0BAA0B,KAAK,MAAM,MAAM;AAAA,IAC3C,KAAK,oBAAoB,SACrB,SACA,yBAAyB,KAAK,MAAM,KAAK,iBAAiB,MAAM;AAAA,IACpE,KAAK,mBAAmB,OAAO,oBAAoB,KAAK,MAAM,MAAM,IAAI;AAAA,EAC1E,EAAE,OAAO,CAAC,YAA+B,YAAY,MAAS;AAChE;AAoCO,SAAS,qBAAqB,MAAoC;AACvE,QAAM,UAAgC,CAAC;AAKvC,MAAI,MAAuB,CAAC;AAC5B,QAAM,UAAUF,MAAK,MAAM,cAAc;AACzC,MAAID,YAAW,OAAO,GAAG;AACvB,QAAI;AACF,YAAM,KAAK,MAAMG,cAAa,SAAS,MAAM,CAAC;AAAA,IAChD,QAAQ;AACN,YAAM,CAAC;AAAA,IACT;AAAA,EACF;AAEA,QAAM,WAAWF,MAAK,MAAM,cAAc;AAC1C,QAAM,OAAOD,YAAW,QAAQ,IAAIG,cAAa,UAAU,MAAM,IAAI;AAErE,QAAM,SAAS,SAAS,SAAY,SAAY,kBAAkB,KAAK,MAAM,IAAI,CAAC,EAAE;AACpF,MAAI,WAAW,QAAW;AACxB,UAAM,aAAa,iBAAiB,GAAG;AACvC,YAAQ,KAAK;AAAA,MACX,MAAM,GAAG,cAAc,qBAAgB,YAAY;AAAA,MACnD,QACE,eAAe,SACX,aAAa,MAAM,qCAAqC,eAAe,sFAEvE,aAAa,MAAM,uDACf,eAAe;AAAA,IAE3B,CAAC;AAAA,EACH;AAKA,QAAM,SAAS,IAAI,MAAM,YAAY,YAAY;AACjD,MAAI,WAAW,QAAW;AACxB,YAAQ,KAAK;AAAA,MACX,MAAM,sCAAiC,YAAY;AAAA,MACnD,QACE,aAAa,MAAM,2IAEhB,cAAc;AAAA,IACrB,CAAC;AAAA,EACH;AAEA,QAAM,aAAaF,MAAK,MAAM,UAAU;AACxC,MAAID,YAAW,UAAU,GAAG;AAC1B,UAAM,UAAUG,cAAa,YAAY,MAAM,EAC5C,MAAM,IAAI,EACV,KAAK,CAAC,SAAS,KAAK,KAAK,EAAE,QAAQ,OAAO,EAAE,EAAE,QAAQ,OAAO,EAAE,MAAM,eAAe;AACvF,QAAI,SAAS;AACX,cAAQ,KAAK;AAAA,QACX,MAAM,GAAG,UAAU,WAAM,cAAc;AAAA,QACvC,QACE,qEACc,cAAc,uFACD,UAAU;AAAA,MAIzC,CAAC;AAAA,IACH;AAAA,EACF;AAEA,QAAM,MAAM,eAAe,EAAE;AAC7B,MAAI,SAAS,QAAW;AACtB,UAAM,SAAS,IAAI,OAAO,kBAAkB,IAAI,QAAQ,KAAK,KAAK,CAAC,cAAc,GAAG,EAAE,KAAK,IAAI;AAC/F,QAAI,QAAQ;AACV,YAAM,QAAQ,IAAI,OAAO,+BAA+B,GAAG,EAAE,KAAK,IAAI;AACtE,cAAQ,KAAK;AAAA,QACX,MAAM,GAAG,cAAc,WAAM,eAAe,KAAK,GAAG;AAAA,QACpD,QAAQ,QACJ,GAAG,GAAG,4XAKN,GAAG,GAAG;AAAA,MAEZ,CAAC;AAAA,IACH;AAAA,EACF;AAEA,SAAO;AACT;","names":["existsSync","readFileSync","dirname","join","existsSync","join","dirname","readFileSync"]}
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-ELZHIN6E.js";
|
|
4
4
|
import {
|
|
5
5
|
findWorkspaceRoot
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-JEYGPPSL.js";
|
|
7
7
|
|
|
8
8
|
// src/roles/test/setup/workspace.ts
|
|
9
9
|
function isMissingModule(error) {
|
|
@@ -60,4 +60,4 @@ export {
|
|
|
60
60
|
installWorkspaceSetup,
|
|
61
61
|
pinWorkspaceTimezone
|
|
62
62
|
};
|
|
63
|
-
//# sourceMappingURL=chunk-
|
|
63
|
+
//# sourceMappingURL=chunk-SPES5MQT.js.map
|
|
@@ -53,15 +53,16 @@ import {
|
|
|
53
53
|
selfCommand,
|
|
54
54
|
splitToolArgs,
|
|
55
55
|
walk
|
|
56
|
-
} from "./chunk-
|
|
56
|
+
} from "./chunk-IFK6KGUW.js";
|
|
57
57
|
import {
|
|
58
58
|
DEFAULT_MAX_DIAGNOSTICS,
|
|
59
59
|
WORKSPACE_ROOT_MARKER,
|
|
60
60
|
findWorkspaceRoot,
|
|
61
61
|
moduleName,
|
|
62
|
+
ownPackageDir,
|
|
62
63
|
readOwnPackage,
|
|
63
64
|
readProjectPackageJson
|
|
64
|
-
} from "./chunk-
|
|
65
|
+
} from "./chunk-JEYGPPSL.js";
|
|
65
66
|
import {
|
|
66
67
|
REPORT_DIR,
|
|
67
68
|
SCRIPTS,
|
|
@@ -5104,7 +5105,7 @@ var SIZE = { width: 1600, height: 1200, margin: 30 };
|
|
|
5104
5105
|
function hueOf(community) {
|
|
5105
5106
|
return `hsl(${community * 137 % 360} 65% 52%)`;
|
|
5106
5107
|
}
|
|
5107
|
-
function
|
|
5108
|
+
function layOut(graph, communities) {
|
|
5108
5109
|
const drawn = graph.copy();
|
|
5109
5110
|
circular.assign(drawn);
|
|
5110
5111
|
forceAtlas2.assign(drawn, { iterations: ITERATIONS, settings: forceAtlas2.inferSettings(drawn) });
|
|
@@ -5116,11 +5117,144 @@ function writeGraphPicture(graph, communities, path2) {
|
|
|
5116
5117
|
drawn.setNodeAttribute(node, "color", hueOf(communities[node] ?? 0));
|
|
5117
5118
|
drawn.setNodeAttribute(node, "label", named.has(node) ? node.split("/").pop() ?? node : BLANK);
|
|
5118
5119
|
});
|
|
5120
|
+
return drawn;
|
|
5121
|
+
}
|
|
5122
|
+
function writeGraphPicture(drawn, path2) {
|
|
5119
5123
|
return new Promise((resolve5) => {
|
|
5120
5124
|
renderSvg(drawn, path2, SIZE, () => resolve5());
|
|
5121
5125
|
});
|
|
5122
5126
|
}
|
|
5123
5127
|
|
|
5128
|
+
// src/roles/static-analysis/graph/explorer.ts
|
|
5129
|
+
function place(graph) {
|
|
5130
|
+
const nodes = graph.nodes();
|
|
5131
|
+
let minX = Infinity;
|
|
5132
|
+
let maxX = -Infinity;
|
|
5133
|
+
let minY = Infinity;
|
|
5134
|
+
let maxY = -Infinity;
|
|
5135
|
+
for (const node of nodes) {
|
|
5136
|
+
const x = graph.getNodeAttribute(node, "x");
|
|
5137
|
+
const y = graph.getNodeAttribute(node, "y");
|
|
5138
|
+
if (x < minX) minX = x;
|
|
5139
|
+
if (x > maxX) maxX = x;
|
|
5140
|
+
if (y < minY) minY = y;
|
|
5141
|
+
if (y > maxY) maxY = y;
|
|
5142
|
+
}
|
|
5143
|
+
const usableWidth = SIZE.width - 2 * SIZE.margin;
|
|
5144
|
+
const usableHeight = SIZE.height - 2 * SIZE.margin;
|
|
5145
|
+
const spanX = maxX - minX || 1;
|
|
5146
|
+
const spanY = maxY - minY || 1;
|
|
5147
|
+
const scale = Math.min(usableWidth / spanX, usableHeight / spanY);
|
|
5148
|
+
const offsetX = SIZE.margin + (usableWidth - spanX * scale) / 2;
|
|
5149
|
+
const offsetY = SIZE.margin + (usableHeight - spanY * scale) / 2;
|
|
5150
|
+
const placed = /* @__PURE__ */ new Map();
|
|
5151
|
+
for (const node of nodes) {
|
|
5152
|
+
const x = graph.getNodeAttribute(node, "x");
|
|
5153
|
+
const y = graph.getNodeAttribute(node, "y");
|
|
5154
|
+
placed.set(node, {
|
|
5155
|
+
x: offsetX + (x - minX) * scale,
|
|
5156
|
+
y: offsetY + (y - minY) * scale,
|
|
5157
|
+
// `size` is already the sqrt-damped degree `picture.ts` computed, in the same units as the
|
|
5158
|
+
// standalone image, so the two drawings show a file at the same weight.
|
|
5159
|
+
r: graph.getNodeAttribute(node, "size")
|
|
5160
|
+
});
|
|
5161
|
+
}
|
|
5162
|
+
return placed;
|
|
5163
|
+
}
|
|
5164
|
+
function round(value) {
|
|
5165
|
+
return (Math.round(value * 100) / 100).toString();
|
|
5166
|
+
}
|
|
5167
|
+
function hueOf2(community) {
|
|
5168
|
+
return `hsl(${community * 137 % 360} 65% 52%)`;
|
|
5169
|
+
}
|
|
5170
|
+
function jsonInScript(value) {
|
|
5171
|
+
return value.replace(/</g, "\\u003c");
|
|
5172
|
+
}
|
|
5173
|
+
function buildExplorer(drawn, communities) {
|
|
5174
|
+
const placed = place(drawn);
|
|
5175
|
+
const nodes = [...placed.keys()];
|
|
5176
|
+
const index = new Map(nodes.map((node, position) => [node, position]));
|
|
5177
|
+
const edges = [];
|
|
5178
|
+
const segments = [];
|
|
5179
|
+
drawn.forEachEdge((_edge, _attributes, source, target) => {
|
|
5180
|
+
const from = placed.get(source);
|
|
5181
|
+
const to = placed.get(target);
|
|
5182
|
+
const sourceIndex = index.get(source);
|
|
5183
|
+
const targetIndex = index.get(target);
|
|
5184
|
+
if (!from || !to || sourceIndex === void 0 || targetIndex === void 0) return;
|
|
5185
|
+
segments.push(`M${round(from.x)} ${round(from.y)}L${round(to.x)} ${round(to.y)}`);
|
|
5186
|
+
edges.push(sourceIndex, targetIndex);
|
|
5187
|
+
});
|
|
5188
|
+
const used = [...new Set(nodes.map((node) => communities[node] ?? 0))].sort((a, b) => a - b);
|
|
5189
|
+
const palette2 = used.map((community) => `.g svg .k${community}{fill:${hueOf2(community)}}`).join("");
|
|
5190
|
+
const circles = nodes.map((node) => {
|
|
5191
|
+
const spot = placed.get(node);
|
|
5192
|
+
if (!spot) return "";
|
|
5193
|
+
const community = communities[node] ?? 0;
|
|
5194
|
+
return `<circle cx="${round(spot.x)}" cy="${round(spot.y)}" r="${round(spot.r)}" class="k${community}"/>`;
|
|
5195
|
+
}).join("");
|
|
5196
|
+
const payload = JSON.stringify({ f: nodes, e: edges });
|
|
5197
|
+
const markup = `<section class="g">
|
|
5198
|
+
<p class="read">One dot per file, sized by how many imports touch it, coloured by the community it landed in. The shape is the answer to how many lobes there are and whether they are joined. <b>Point at a dot for its path, click it to keep only that file and its neighbours, click the background to come back.</b></p>
|
|
5199
|
+
<div class="gbar"><output id="gname"> </output><button type="button" id="greset" hidden>show everything</button></div>
|
|
5200
|
+
<svg viewBox="0 0 ${SIZE.width} ${SIZE.height}" role="img" aria-label="The module's import graph, one dot per file">
|
|
5201
|
+
<path class="ge" d="${segments.join("")}"/>
|
|
5202
|
+
<path class="gh" d=""/>
|
|
5203
|
+
<g class="gn">${circles}</g>
|
|
5204
|
+
</svg>
|
|
5205
|
+
<style>${palette2}</style>
|
|
5206
|
+
<script type="application/json" id="gdata">${jsonInScript(payload)}</script>
|
|
5207
|
+
<script>${SCRIPT}</script>
|
|
5208
|
+
</section>`;
|
|
5209
|
+
return { markup, bytes: Buffer.byteLength(markup, "utf8") };
|
|
5210
|
+
}
|
|
5211
|
+
var SCRIPT = `(function(){
|
|
5212
|
+
var root=document.currentScript.closest('.g');
|
|
5213
|
+
var data=JSON.parse(root.querySelector('#gdata').textContent);
|
|
5214
|
+
var dots=root.querySelectorAll('.gn circle');
|
|
5215
|
+
var name=root.querySelector('#gname');
|
|
5216
|
+
var reset=root.querySelector('#greset');
|
|
5217
|
+
var highlight=root.querySelector('.gh');
|
|
5218
|
+
var out=[],inn=[];
|
|
5219
|
+
for(var i=0;i<data.f.length;i++){out.push([]);inn.push([]);}
|
|
5220
|
+
for(var k=0;k<data.e.length;k+=2){out[data.e[k]].push(data.e[k+1]);inn[data.e[k+1]].push(data.e[k]);}
|
|
5221
|
+
function show(i){
|
|
5222
|
+
name.textContent=data.f[i]+' ('+out[i].length+' imported, '+inn[i].length+' importing)';
|
|
5223
|
+
}
|
|
5224
|
+
function clear(){
|
|
5225
|
+
root.classList.remove('gsel');
|
|
5226
|
+
highlight.setAttribute('d','');
|
|
5227
|
+
for(var i=0;i<dots.length;i++)dots[i].classList.remove('gon','gpick');
|
|
5228
|
+
name.innerHTML=' ';
|
|
5229
|
+
reset.hidden=true;
|
|
5230
|
+
}
|
|
5231
|
+
function pick(i){
|
|
5232
|
+
root.classList.add('gsel');
|
|
5233
|
+
var near={};near[i]=1;
|
|
5234
|
+
var d='';
|
|
5235
|
+
function seg(a,b){
|
|
5236
|
+
d+='M'+dots[a].getAttribute('cx')+' '+dots[a].getAttribute('cy')+'L'+dots[b].getAttribute('cx')+' '+dots[b].getAttribute('cy');
|
|
5237
|
+
}
|
|
5238
|
+
for(var a=0;a<out[i].length;a++){near[out[i][a]]=1;seg(i,out[i][a]);}
|
|
5239
|
+
for(var b=0;b<inn[i].length;b++){near[inn[i][b]]=1;seg(inn[i][b],i);}
|
|
5240
|
+
highlight.setAttribute('d',d);
|
|
5241
|
+
for(var j=0;j<dots.length;j++){
|
|
5242
|
+
dots[j].classList.toggle('gon',!!near[j]);
|
|
5243
|
+
dots[j].classList.toggle('gpick',j===i);
|
|
5244
|
+
}
|
|
5245
|
+
show(i);
|
|
5246
|
+
reset.hidden=false;
|
|
5247
|
+
}
|
|
5248
|
+
for(var n=0;n<dots.length;n++){
|
|
5249
|
+
(function(i){
|
|
5250
|
+
dots[i].addEventListener('mouseenter',function(){show(i);});
|
|
5251
|
+
dots[i].addEventListener('click',function(event){event.stopPropagation();pick(i);});
|
|
5252
|
+
})(n);
|
|
5253
|
+
}
|
|
5254
|
+
root.querySelector('svg').addEventListener('click',clear);
|
|
5255
|
+
reset.addEventListener('click',clear);
|
|
5256
|
+
})();`;
|
|
5257
|
+
|
|
5124
5258
|
// src/roles/static-analysis/graph/scan.ts
|
|
5125
5259
|
import Graph from "graphology";
|
|
5126
5260
|
import louvain from "graphology-communities-louvain";
|
|
@@ -5302,8 +5436,16 @@ var GAUGE_THRESHOLDS = {
|
|
|
5302
5436
|
// Same nesting family as `max-depth`, no citation of its own. Our p90 agrees at 3.
|
|
5303
5437
|
"max-nested-callbacks": 3,
|
|
5304
5438
|
// 1 is the rule's floor, there is no stricter value to pick.
|
|
5305
|
-
"max-classes-per-file": 1
|
|
5439
|
+
"max-classes-per-file": 1,
|
|
5440
|
+
// Cognitive complexity, Campbell 2018, measured by Sonar's own rule, loaded
|
|
5441
|
+
// through `plugins/sonar-cognitive.js`.
|
|
5442
|
+
// Sonar publishes 15 for S3776, and that is a vendor default rather than a research value: one of
|
|
5443
|
+
// their own engineers has said publicly that no formula supports it. Our p90 is 7, so 7 wins, and
|
|
5444
|
+
// it reports 342 of 8 083 files, 4.2%, which sits inside the band the eight above occupy.
|
|
5445
|
+
"cognitive-complexity": 7
|
|
5306
5446
|
};
|
|
5447
|
+
var COGNITIVE = "cognitive-complexity";
|
|
5448
|
+
var cognitivePlugin = () => join20(ownPackageDir(), "plugins", "sonar-cognitive.js");
|
|
5307
5449
|
var CATEGORIES = [
|
|
5308
5450
|
"correctness",
|
|
5309
5451
|
"suspicious",
|
|
@@ -5317,13 +5459,20 @@ function sizeRulesConfig() {
|
|
|
5317
5459
|
return {
|
|
5318
5460
|
plugins: [],
|
|
5319
5461
|
categories: Object.fromEntries(CATEGORIES.map((category) => [category, "off"])),
|
|
5462
|
+
// Sonar's cognitive complexity, in the SAME run as the eight above rather than a second
|
|
5463
|
+
// invocation: the parse is the expensive part and it is already paid here. Measured over the
|
|
5464
|
+
// whole bench, 8 083 files, the rule adds 2.4 s to a pass that was going to read them anyway.
|
|
5465
|
+
jsPlugins: [cognitivePlugin()],
|
|
5320
5466
|
// ⛔ `warn` and not `error`, for the reason `tool-config.ts` gives about jscpd's `threshold`:
|
|
5321
5467
|
// at `error` oxlint exits 1 as soon as one file breaches, which is indistinguishable from the
|
|
5322
5468
|
// binary failing to run. Measured on `libs/nest/auth`, same config otherwise: `error` exits 1,
|
|
5323
5469
|
// `warn` exits 0, and both report the same 18 diagnostics. So the tool measures, this role
|
|
5324
5470
|
// judges, and a non-zero exit means the tool failed.
|
|
5325
5471
|
rules: Object.fromEntries(
|
|
5326
|
-
Object.entries(GAUGE_THRESHOLDS).map(([rule, threshold]) => [
|
|
5472
|
+
Object.entries(GAUGE_THRESHOLDS).map(([rule, threshold]) => [
|
|
5473
|
+
rule === COGNITIVE ? `sonar/${rule}` : rule,
|
|
5474
|
+
["warn", threshold]
|
|
5475
|
+
])
|
|
5327
5476
|
)
|
|
5328
5477
|
};
|
|
5329
5478
|
}
|
|
@@ -5352,7 +5501,9 @@ function ruleOf(code) {
|
|
|
5352
5501
|
return /\(([^)]+)\)\s*$/.exec(code)?.[1];
|
|
5353
5502
|
}
|
|
5354
5503
|
function valueOf(message) {
|
|
5355
|
-
const match = /\((\d+)\)/.exec(message) ?? /complexity of (\d+)/.exec(message)
|
|
5504
|
+
const match = /\((\d+)\)/.exec(message) ?? /complexity of (\d+)/.exec(message) ?? // Sonar's own wording, a third shape again: "reduce its Cognitive Complexity from 7 to the 0
|
|
5505
|
+
// allowed". It parenthesises nothing, and the `0` after it is the threshold, not the value.
|
|
5506
|
+
/Complexity from (\d+) to/.exec(message);
|
|
5356
5507
|
return Number(match?.[1] ?? 0);
|
|
5357
5508
|
}
|
|
5358
5509
|
function breachesOf(raw) {
|
|
@@ -5590,6 +5741,13 @@ var metricsHelp = {
|
|
|
5590
5741
|
caveat: "It says nothing about the size of the classes, and a file with one 800-line class never appears here.",
|
|
5591
5742
|
source: "oxlint max-classes-per-file, above 1. The threshold is the rule's floor, there is no stricter value to pick. The rule for every one of the eight is `min(research, our p90, the rule's default)`, and a vendor default alone is not a reference: at 50, max-lines-per-function flagged 21.3% of our files and carried 77% of the union of all eight. The exclusions are the same list the duplication product pass is given, passed as `--ignore-pattern`: the `ignorePatterns` key in oxlint's config is accepted and silently ignored, which had this gauge measuring the test suite until the file counts were compared, 137 against 111 on `libs/nest/auth`."
|
|
5592
5743
|
},
|
|
5744
|
+
"cognitive-complexity": {
|
|
5745
|
+
label: "Files hard to follow",
|
|
5746
|
+
what: "Files holding at least one function whose cognitive complexity passes 7.",
|
|
5747
|
+
read: "The other complexity figure, and it answers a different question from McCabe's: not how many paths a test suite must cover, but how much a reader has to hold in their head. The two disagree on purpose. A flat `switch` with twelve cases is cyclomatically 13 and cognitively 1, because nothing is nested and nothing surprises; three `if`s inside each other are cyclomatically 4 and cognitively 6, because each level is charged for the levels above it. When a file is high here and low there, the code is tangled rather than merely branchy, and that is the one this role would act on first.",
|
|
5748
|
+
caveat: "\u26A0\uFE0F In JavaScript and TypeScript, sequences of `||` and `??` do NOT increment, deliberately: in this language they are overwhelmingly default-value shorthand rather than control flow, and SonarSource made that exclusion universal in SonarQube 10.8. So a long chain of fallbacks scores zero here while `&&` chains are charged. \u26D4 And it is a GAUGE, not the line your lint refuses at: no preset in this repository enforces cognitive complexity at all today.",
|
|
5749
|
+
source: "Sonar's own `cognitive-complexity` rule from `eslint-plugin-sonarjs`, loaded into oxlint through `plugins/sonar-cognitive.js` and run in the same invocation as the size rules. It is USED and never copied: that package ships under the Sonar Source-Available License and sentinel is MIT. The algorithm is Campbell 2018 and its author works at SonarSource, so their implementation is the specification's own house rather than one reading of it; verified against Campbell's published example, which scores 7 in both. The threshold is 7, from `min(research, our p90, the rule's default)`: Sonar publishes 15 for S3776 and that is a vendor default rather than a research value, since one of their own engineers has said publicly that no formula supports it, while our p90 over the files the rule touches is 7. It reports 342 of 8 083 files on the bench, 4.2%."
|
|
5750
|
+
},
|
|
5593
5751
|
usedOnlyByTests: {
|
|
5594
5752
|
label: "Used only by tests",
|
|
5595
5753
|
what: "Files of this module that no production code imports, but another module's test files do.",
|
|
@@ -5902,8 +6060,35 @@ ${rest > 0 ? ` <p class="sub">The ${HTML_GROUP_LIMIT} longest are listed; ${r
|
|
|
5902
6060
|
white-space: nowrap;
|
|
5903
6061
|
}
|
|
5904
6062
|
/* The graph. The svg carries a viewBox, so dropping its fixed width lets it fill the column. */
|
|
5905
|
-
.
|
|
5906
|
-
.
|
|
6063
|
+
.g svg { width: 100%; height: auto; display: block; }
|
|
6064
|
+
.g p.read { margin: 0 0 0.8rem; padding: 0 0.6rem; color: var(--ink-soft); font-size: 0.88rem; max-width: 52rem; }
|
|
6065
|
+
/* Every edge in one path, faint: it carries the shape, not a readable dependency. */
|
|
6066
|
+
.g .ge { stroke: var(--rule); stroke-width: 0.6; fill: none; }
|
|
6067
|
+
/* The selected file's own edges, drawn on top only once something is picked. */
|
|
6068
|
+
.g .gh { stroke: var(--brand); stroke-width: 1.4; fill: none; }
|
|
6069
|
+
.g .gn circle { cursor: pointer; }
|
|
6070
|
+
/* Dim the module once a file is picked, so its neighbourhood is what is left to read. The
|
|
6071
|
+
opacity sits on the GROUP and the exceptions raise themselves back: one rule instead of one
|
|
6072
|
+
class per dot, which matters at 4 380 of them. */
|
|
6073
|
+
.g.gsel .gn circle { opacity: 0.12; }
|
|
6074
|
+
.g.gsel .gn circle.gon { opacity: 1; }
|
|
6075
|
+
.g.gsel .gn circle.gpick { opacity: 1; stroke: var(--ink); stroke-width: 2; }
|
|
6076
|
+
.g.gsel .ge { opacity: 0.25; }
|
|
6077
|
+
/* The caption. A min-height and not a conditional row: the drawing must not jump by a line
|
|
6078
|
+
when a name appears, which is exactly what pointing at a dot would cause. */
|
|
6079
|
+
.gbar {
|
|
6080
|
+
display: flex; gap: 0.6rem; align-items: center; justify-content: space-between;
|
|
6081
|
+
min-height: 1.9rem; padding: 0 0.6rem; margin-bottom: 0.3rem;
|
|
6082
|
+
}
|
|
6083
|
+
.gbar output {
|
|
6084
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.82rem;
|
|
6085
|
+
color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
6086
|
+
}
|
|
6087
|
+
.gbar button {
|
|
6088
|
+
font: inherit; font-size: 0.8rem; cursor: pointer; white-space: nowrap;
|
|
6089
|
+
padding: 0.2rem 0.6rem; border: 1px solid var(--rule); border-radius: 0.3rem;
|
|
6090
|
+
background: var(--surface); color: var(--ink-soft);
|
|
6091
|
+
}
|
|
5907
6092
|
|
|
5908
6093
|
/* The collapsed reserves. Closed by default: the figures are what the page opens on. */
|
|
5909
6094
|
details { border-bottom: 1px solid var(--rule); }
|
|
@@ -5984,10 +6169,7 @@ ${report.notes.map((note) => ` <tr><td class="read">${escape(note)}</td></t
|
|
|
5984
6169
|
` : ""}
|
|
5985
6170
|
${report.picture ? `
|
|
5986
6171
|
<h2>Shape</h2>
|
|
5987
|
-
<section class="picture">
|
|
5988
|
-
<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>
|
|
5989
6172
|
${report.picture}
|
|
5990
|
-
</section>
|
|
5991
6173
|
` : ""}
|
|
5992
6174
|
<h2>What these numbers do not say</h2>
|
|
5993
6175
|
<section>
|
|
@@ -6882,11 +7064,12 @@ async function emit(report, shape, cwd, graph, coverage) {
|
|
|
6882
7064
|
mkdirSync2(join22(cwd, REPORT_DIR), { recursive: true });
|
|
6883
7065
|
const written = [];
|
|
6884
7066
|
if (shape === "html" && graph.files > 1 && !hasUnreadableCode(coverage)) {
|
|
7067
|
+
const drawn = layOut(graph.graph, graph.communityOf);
|
|
6885
7068
|
const picture = `${TARGET}-graph.svg`;
|
|
6886
7069
|
const path2 = join22(cwd, REPORT_DIR, picture);
|
|
6887
|
-
await writeGraphPicture(
|
|
7070
|
+
await writeGraphPicture(drawn, path2);
|
|
6888
7071
|
written.push(`${REPORT_DIR}/${picture}`);
|
|
6889
|
-
report.picture =
|
|
7072
|
+
report.picture = buildExplorer(drawn, graph.communityOf).markup;
|
|
6890
7073
|
}
|
|
6891
7074
|
const name = shape === "json" ? "static-analysis.json" : "static-analysis.html";
|
|
6892
7075
|
const body = shape === "json" ? renderJson(report) : renderHtml(report);
|
|
@@ -6896,9 +7079,6 @@ async function emit(report, shape, cwd, graph, coverage) {
|
|
|
6896
7079
|
written: ${written.join(", ")}
|
|
6897
7080
|
`);
|
|
6898
7081
|
}
|
|
6899
|
-
function inlineable(svg) {
|
|
6900
|
-
return svg.replace(/^<\?xml[^>]*\?>\s*/, "").replace(/^(<svg)\s+width="[^"]*"\s+height="[^"]*"/, "$1");
|
|
6901
|
-
}
|
|
6902
7082
|
function metricsOf(report) {
|
|
6903
7083
|
return {
|
|
6904
7084
|
passes: report.passes,
|
package/dist/index.js
CHANGED
|
@@ -6,11 +6,11 @@ import {
|
|
|
6
6
|
registerAdapters,
|
|
7
7
|
resolve,
|
|
8
8
|
setDefaultRunner
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-TL2P2X45.js";
|
|
10
10
|
import {
|
|
11
11
|
BaseAdapter
|
|
12
|
-
} from "./chunk-
|
|
13
|
-
import "./chunk-
|
|
12
|
+
} from "./chunk-IFK6KGUW.js";
|
|
13
|
+
import "./chunk-JEYGPPSL.js";
|
|
14
14
|
import "./chunk-AYEOGJQR.js";
|
|
15
15
|
import "./chunk-O7REVMOC.js";
|
|
16
16
|
import "./chunk-QXFCZON7.js";
|
|
@@ -3,13 +3,13 @@ import {
|
|
|
3
3
|
} from "../../../chunk-NUOQXAYR.js";
|
|
4
4
|
import {
|
|
5
5
|
installWorkspaceSetup
|
|
6
|
-
} from "../../../chunk-
|
|
6
|
+
} from "../../../chunk-SPES5MQT.js";
|
|
7
7
|
import "../../../chunk-ELZHIN6E.js";
|
|
8
8
|
import {
|
|
9
9
|
clearDeepMocks,
|
|
10
10
|
resetDeepMocks
|
|
11
11
|
} from "../../../chunk-2XLX6PFR.js";
|
|
12
|
-
import "../../../chunk-
|
|
12
|
+
import "../../../chunk-JEYGPPSL.js";
|
|
13
13
|
|
|
14
14
|
// src/roles/test/setup/jest-parity.ts
|
|
15
15
|
import { expect, vi } from "vitest";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
pinWorkspaceTimezone
|
|
3
|
-
} from "../../../chunk-
|
|
3
|
+
} from "../../../chunk-SPES5MQT.js";
|
|
4
4
|
import "../../../chunk-ELZHIN6E.js";
|
|
5
|
-
import "../../../chunk-
|
|
5
|
+
import "../../../chunk-JEYGPPSL.js";
|
|
6
6
|
|
|
7
7
|
// src/roles/test/setup/workspace-entry.ts
|
|
8
8
|
await pinWorkspaceTimezone();
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Static analysis adoption cheat sheet
|
|
2
|
+
|
|
3
|
+
A quick reference for adopting the sentinel static analysis role on a module, and for reading what it prints. The reasoning behind every number, and the measurements that fixed the thresholds, are in [`static-analysis-study.md`](static-analysis-study.md); this page is what you need to run it.
|
|
4
|
+
|
|
5
|
+
**It measures, it never refuses.** Nothing here fails a build or blocks a merge. A figure answers "is this module drifting", which is a question about a trend, and a check that blocks a merge on a trend blocks the wrong thing. Refusing a single unit is the linter's job and oxlint already does it.
|
|
6
|
+
|
|
7
|
+
## Adopt a module in two steps
|
|
8
|
+
|
|
9
|
+
Run from the **module's own directory**. You do not need to install sentinel first: use `npx --yes` for a one-off.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# 1. write the module's config + the pinned devDependency
|
|
13
|
+
cd apps/.../<module>
|
|
14
|
+
npx --yes @hublo/sentinel@<version> --init --static-analysis
|
|
15
|
+
|
|
16
|
+
# 2. fetch what step 1 declared
|
|
17
|
+
pnpm install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`--init` writes `static-analysis.config.mjs`, adds the three scripts below to the module's `package.json`, and pins `@hublo/sentinel` into its own `devDependencies`. It prints what it did:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
wrote static-analysis.config.mjs
|
|
24
|
+
wrote package.json
|
|
25
|
+
added the three `static-analysis` scripts and static-analysis.config.mjs; declare
|
|
26
|
+
this module's entry points and journeys there, everything else comes from the role
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Nothing else is required.** The config stub is written with empty `entry` and `journeys` on purpose: for most modules the figures are already right without declaring anything, and the stub exists so you can correct the one case where they are not (see [Declaring entry points](#declaring-entry-points)).
|
|
30
|
+
|
|
31
|
+
## Verbs at a glance
|
|
32
|
+
|
|
33
|
+
| Verb | Who / when | Runs the tools? | What you get |
|
|
34
|
+
| ----------- | ------------------ | ----------------- | ---------------------------------------------------------------------- |
|
|
35
|
+
| `--run` | dev, everyday | **yes** | the figures, in one of three shapes. Never exits non-zero on a finding |
|
|
36
|
+
| `--inspect` | dev, everyday | no (reads config) | the resolved settings, and whether this module has adopted |
|
|
37
|
+
| `--init` | dev, once to adopt | writes files | the config stub, the three scripts, the pinned dep |
|
|
38
|
+
| `--migrate` | planned | not available yet | reserved for changing an already-initialized setup |
|
|
39
|
+
|
|
40
|
+
**Location** (any verb): from a module dir → that module; from the root → `--module <name>`, `--ci` (affected), or every module. `--init` writes, so it targets one module only.
|
|
41
|
+
|
|
42
|
+
## Everyday use
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pnpm run static-analysis # the summary, in your terminal. Writes nothing
|
|
46
|
+
pnpm run static-analysis:json # static-analysis-report/static-analysis.json, for a machine
|
|
47
|
+
pnpm run static-analysis:html # static-analysis-report/static-analysis.html, for a reader
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or without the scripts:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
sentinel --run --static-analysis # same as the first script
|
|
54
|
+
sentinel --run --static-analysis --output html # same as the third
|
|
55
|
+
sentinel --inspect --static-analysis # the settings, without running anything
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Which shape to use.** `console` is the one you run in a loop: it writes nothing and skips the expensive drawing. `json` is for CI and for a script. `html` is the one you open and send: it carries the findings, the places to look, and an explorable import graph.
|
|
59
|
+
|
|
60
|
+
⚠️ The `html` shape pays for a graph layout that the other two skip, 5 seconds on the largest module measured against 14 ms for the rest of the graph pass. That is fine for a page somebody opens and wrong for a commit hook.
|
|
61
|
+
|
|
62
|
+
## Reading the report
|
|
63
|
+
|
|
64
|
+
The console summary of a nest microservice, in full:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
sentinel static-analysis: contract
|
|
68
|
+
Duplicated lines 3.95 %
|
|
69
|
+
Clones 61 entries
|
|
70
|
+
Duplicated lines (tests) 10.93 %
|
|
71
|
+
Clones (tests) 196 entries
|
|
72
|
+
Entry points 46 of 656 files
|
|
73
|
+
Unreachable lines 1.07 %
|
|
74
|
+
Unreachable files 5 files
|
|
75
|
+
Unused exports 1 exports
|
|
76
|
+
Unused symbols 1 symbols
|
|
77
|
+
Unused imports 0 imports
|
|
78
|
+
Complexity, worst function 27 max per function
|
|
79
|
+
Complexity 11-20, moderate risk 18 of 1386 functions
|
|
80
|
+
Complexity 21-50, high risk 1 of 1386 functions
|
|
81
|
+
Imports between files 1135 between 495 files
|
|
82
|
+
Density 0.0046 of the possible edges
|
|
83
|
+
Import cycles 3 groups, 8 files
|
|
84
|
+
Communities 12 of 495 files
|
|
85
|
+
Modularity (Q) 0.753
|
|
86
|
+
Files the gauge read 495 files the gauge read
|
|
87
|
+
Files with a complex function 18 files over 10, of 495
|
|
88
|
+
Files with deep nesting 4 files over 3, of 495
|
|
89
|
+
Long files 43 files over 150, of 495
|
|
90
|
+
Files with a long function 5 files over 109, of 495
|
|
91
|
+
Files with a long function body 47 files over 10, of 495
|
|
92
|
+
Files with a wide signature 33 files over 3, of 495
|
|
93
|
+
Files with several classes 16 files over 1, of 495
|
|
94
|
+
Files hard to follow 23 files over 7, of 495
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Four groups, and they answer different questions:
|
|
98
|
+
|
|
99
|
+
| | what it answers |
|
|
100
|
+
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
101
|
+
| duplication | how much of this module repeats itself, product and tests counted apart |
|
|
102
|
+
| dead code | what nothing reaches: files, exports, symbols, imports |
|
|
103
|
+
| complexity | two measures, on purpose: how hard the hardest functions are to TEST (McCabe) and how hard they are to FOLLOW (cognitive). They disagree on 60% of the files either one flags |
|
|
104
|
+
| structure and size | how the files depend on each other, and which ones are outliers |
|
|
105
|
+
|
|
106
|
+
**Every row carries its denominator**, because none of them compares between modules without one: three breaches over 50 files and three over 3 000 are not the same module.
|
|
107
|
+
|
|
108
|
+
**A row that is absent was not measured.** It is never a silent zero. When a pass cannot produce a figure, the report says so in its own line under the figures rather than printing `0.00%`, because "no dead code" and "the walk had no entry point" are different facts.
|
|
109
|
+
|
|
110
|
+
### Three rows people misread
|
|
111
|
+
|
|
112
|
+
**`Unreachable files` is not a delete list.** It means nothing in this module imports them. A file another module's spec imports is alive, and it has its own row, `Used only by tests`. For a library the question cannot even be answered inside the module, so the role leaves it (see below).
|
|
113
|
+
|
|
114
|
+
**`Import cycles` reports GROUPS, not paths.** A group of six files can hold a dozen distinct circles, and breaking one does not necessarily break the group. The group is the unit precisely for that reason.
|
|
115
|
+
|
|
116
|
+
**`Density` must not be compared between modules of different sizes.** Its denominator grows with the square of the file count, so a module gets a lower density by growing rather than by improving. Measured: `libs/nest/auth` 0.0216 at 101 files, `apps/front/host-admin` 0.0005 at 4 380. It is good for one module watched over time.
|
|
117
|
+
|
|
118
|
+
### Where the size thresholds come from
|
|
119
|
+
|
|
120
|
+
The nine size rows are measured with oxlint at thresholds this role fixes once for every module, so the same question gets the same answer everywhere. Each one is the **strictest of three candidates**: published research, our own p90, and the rule's own default.
|
|
121
|
+
|
|
122
|
+
| row | threshold | what decided it |
|
|
123
|
+
| --------------------------------- | ------------: | ----------------------------------------------------------------------------------------- |
|
|
124
|
+
| `Files with a complex function` | 10 | McCabe 1976 says 10, our p90 says 11 |
|
|
125
|
+
| `Files with a long function` | 109 | our p90. IfSQ SP-1 says 150 and loses for being more permissive than our code |
|
|
126
|
+
| `Long files` | 150 | our p90 of 157, rounded down. No research sets a file's length |
|
|
127
|
+
| `Files with deep nesting` | 3 | IfSQ SP-2, and our p90 agrees |
|
|
128
|
+
| `Files with nested callbacks` | 3 | same nesting family |
|
|
129
|
+
| `Files with a long function body` | 10 statements | the rule's default, already the strictest candidate |
|
|
130
|
+
| `Files with a wide signature` | 3 parameters | beyond three the fix is an object or currying, so this is a design boundary |
|
|
131
|
+
| `Files with several classes` | 1 | the rule's floor |
|
|
132
|
+
| `Files hard to follow` | 7 | Sonar publishes 15 for cognitive complexity and says no formula supports it; our p90 is 7 |
|
|
133
|
+
|
|
134
|
+
⚠️ **These will disagree with your green lint, out loud.** The `nest` lint preset refuses `complexity` at 30 while this reports above 10, so a nest developer reads breaches beside a passing build. That is correct: the lint is the line your build refuses at, this is a gauge that answers the same question the same way in every module, including the ones that have not adopted lint at all. The report prints its thresholds under `measured with` so the two are never confused.
|
|
135
|
+
|
|
136
|
+
## The `html` report
|
|
137
|
+
|
|
138
|
+
Written to `static-analysis-report/`, two files:
|
|
139
|
+
|
|
140
|
+
- `static-analysis.html`, self-contained: no CDN, no library, no font, nothing fetched. Open it from disk, attach it to a ticket, serve it from a CI artifact.
|
|
141
|
+
- `static-analysis-graph.svg`, the import graph as a plain image, for when you want to paste a picture.
|
|
142
|
+
|
|
143
|
+
The page carries what the console summary leaves out: every finding with its path, the code of each clone, and an **explorable import graph**. Point at a dot for its path and its two degrees, click it to keep only that file and its neighbours, click the background to come back. The drawing renders with scripting off; the script only adds the naming and the filtering.
|
|
144
|
+
|
|
145
|
+
⚠️ `static-analysis-report/` is generated. The rule lives in the **root** `.gitignore`, unanchored so it matches at any depth, rather than one line added to every adopting module's own file. You have nothing to do and nothing to commit.
|
|
146
|
+
|
|
147
|
+
## Declaring entry points
|
|
148
|
+
|
|
149
|
+
`entry` names the files that **run although nothing imports them**. They are where reachability starts, and they are added to the patterns the role already ships rather than replacing them.
|
|
150
|
+
|
|
151
|
+
Most modules need nothing. Declare an entry point when the framework loads a file by convention the role has not met, or when code is loaded by name at run time:
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'
|
|
155
|
+
|
|
156
|
+
export default defineStaticAnalysis({
|
|
157
|
+
root: import.meta.dirname,
|
|
158
|
+
entry: ['src/emails/**'],
|
|
159
|
+
journeys: {},
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**The symptom that tells you one is missing**: a large, tightly-grouped block of unreachable files under one directory. Measured on `libs/cloud/hermes`, 228 of its 231 findings sit under `src/emails`, which holds templates loaded by name rather than imported. That is one line of config, not 228 deletions.
|
|
164
|
+
|
|
165
|
+
## Two limits worth knowing before you read a number
|
|
166
|
+
|
|
167
|
+
**A single-file component format cannot be parsed.** `.svelte`, `.vue` and `.astro` files are not read, so on a module holding them the role reports the duplication, complexity and size figures and **omits the graph and the dead-code figures entirely**, with the share printed in their place:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
(252 product file(s) in .svelte cannot be parsed, 53% of this module: no graph
|
|
171
|
+
and no dead-code figure is reported, because a file the role cannot open still
|
|
172
|
+
imports the files it imports)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Absent with its reason, rather than a figure that is wrong by half.
|
|
176
|
+
|
|
177
|
+
**A library's dead code is decided outside the module.** An application has an entry point; a library has none, so a module-local walk would report "the module does not import itself", which is true of every library. The role builds a workspace-wide index of who imports what, once, and caches it under `node_modules/.cache/sentinel/`. Measured on `libs/nest/auth`: **15** unreachable files with the index against **87** without it.
|
|
178
|
+
|
|
179
|
+
## Troubleshooting
|
|
180
|
+
|
|
181
|
+
| | |
|
|
182
|
+
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
183
|
+
| a wall of unreachable files under one directory | an undeclared entry point, see above |
|
|
184
|
+
| the graph and dead-code rows are missing | the module holds `.svelte`, `.vue` or `.astro`; the note above the figures says the share |
|
|
185
|
+
| `Entry points 0 of N` | nothing matched, so the walk had no start. Declare `entry` |
|
|
186
|
+
| the numbers differ from another module's | check `measured with`: a setting this module moved is marked `(module)`, and two modules measured differently cannot be compared |
|
|
187
|
+
| the run is slow | you are on `--output html`, which pays the graph layout. Use the plain script in a loop |
|
|
188
|
+
| `oxlint could not be run, so no size indicator is reported` | the binary did not resolve; the size rows are absent rather than zero |
|
|
189
|
+
|
|
190
|
+
## What the role will not do
|
|
191
|
+
|
|
192
|
+
- **It will not fail your build.** No threshold here refuses anything, by design, for as long as adoption is incomplete. The only non-zero exits are an unknown `--output` value and a tool that could not be run at all, never a finding.
|
|
193
|
+
- **It will not read your module's `.oxlintrc.json`.** The size gauge writes its own config to a temporary directory, so its scale is the same in every module. A gauge whose scale moves per module compares nothing.
|
|
194
|
+
- **It will not look outside the module**, except for the one measurement that cannot be answered inside it: a library's reachability.
|
|
195
|
+
- **It will not measure your tests.** Specs, fixtures, mocks, stories and generated files are out of every product figure. Duplication reports the test suite separately, on its own two rows, because that one is worth seeing and must not be blended into the product number.
|
|
@@ -856,7 +856,14 @@ function is reported: sentinel 752 functions and 41 above 15, network 1 701 and
|
|
|
856
856
|
1 050 and 6, console 510 and 0. Zero fatal errors on 4 572 files, where escomplex fails on 37.9%
|
|
857
857
|
of `network` and 39.0% of `recruitment`. The worst module is this package.
|
|
858
858
|
|
|
859
|
-
|
|
859
|
+
⛔ **Corrected on 2026-10-13: it is not written here, it is LOADED.** oxlint 1.77 accepts an ESLint
|
|
860
|
+
rule as a JS plugin, so Sonar's own rule runs inside our own pass with none of their code in this
|
|
861
|
+
repository. The reasoning, the measurements and the licence that decides it are in **11.8**.
|
|
862
|
+
|
|
863
|
+
⚠️ **The four figures above were produced with a version this document does not name**, and the
|
|
864
|
+
plugin's behaviour moved between majors: sequences of `||` and `??` stopped incrementing. So treat
|
|
865
|
+
them as an order of magnitude, not as a baseline. The figures measured on the current version, and
|
|
866
|
+
what the metric actually adds beside McCabe, are in 11.8.
|
|
860
867
|
|
|
861
868
|
### 9.3 The maintainability figure
|
|
862
869
|
|
|
@@ -977,13 +984,13 @@ touch no production file at all and the median touches **4**.
|
|
|
977
984
|
|
|
978
985
|
### 9.7 What is still a decision
|
|
979
986
|
|
|
980
|
-
| open
|
|
981
|
-
|
|
|
982
|
-
| the `/10` in the `MI`
|
|
983
|
-
| the criticality order of the **
|
|
984
|
-
| the overhead line of the SQL study
|
|
985
|
-
| the workspace index for libraries
|
|
986
|
-
| a feedback channel on findings
|
|
987
|
+
| open | what it needs |
|
|
988
|
+
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
989
|
+
| the `/10` in the `MI` | the distribution over the 198 modules. It changes the spread, never the order |
|
|
990
|
+
| the criticality order of the **nine** | one argument per indicator. **Rarity no longer rules any out:** see 11.6, where the split is removed. Ranking them is still open |
|
|
991
|
+
| the overhead line of the SQL study | the harness rebuilt. #90 corrects the attribution and withdraws the 71%, which was never a ratio |
|
|
992
|
+
| the workspace index for libraries | built and measured in 10.4 and 10.5. Nothing open here any more |
|
|
993
|
+
| a feedback channel on findings | unchanged from section 8, and it is what would let any of this be proven wrong |
|
|
987
994
|
|
|
988
995
|
The one measurement that unblocks the first two is the same: **the eight indicators over all 198
|
|
989
996
|
modules, once.** That measurement now exists, in 10.7 over 177 modules and in 11 over a five-module
|
|
@@ -1344,15 +1351,15 @@ already sit in the 1-10 band the SEI calls low risk, so 10 is a ratchet rather t
|
|
|
1344
1351
|
| 21-50, high risk | 128 | 1.2 % |
|
|
1345
1352
|
| over 50, untestable | 8 | 0.1 % |
|
|
1346
1353
|
|
|
1347
|
-
### 11.6 What the eight say together, and the end of the descriptor split
|
|
1354
|
+
### 11.6 What the eight size rules say together, and the end of the descriptor split
|
|
1348
1355
|
|
|
1349
1356
|
A threshold is only half a decision: what matters is what the **set** reports.
|
|
1350
1357
|
|
|
1351
|
-
|
|
|
1352
|
-
|
|
|
1353
|
-
| union of the eight
|
|
1354
|
-
| largest single rule
|
|
1355
|
-
| its share of the union
|
|
1358
|
+
| | at the vendor defaults | at the thresholds above |
|
|
1359
|
+
| ----------------------------- | ------------------------------: | ----------------------: |
|
|
1360
|
+
| union of the eight size rules | 2 236 files, 27.7 % | **1 708 files, 21.1 %** |
|
|
1361
|
+
| largest single rule | `max-lines-per-function`, 1 724 | `max-statements`, 891 |
|
|
1362
|
+
| its share of the union | **77 %** | **52 %** |
|
|
1356
1363
|
|
|
1357
1364
|
⛔ **At the defaults one rule was the report.** `max-lines-per-function` at 50 flagged 21.3% of all
|
|
1358
1365
|
product files and carried 77% of the union, so the other seven indicators were inaudible: the
|
|
@@ -1417,6 +1424,118 @@ always fire first and the statement count would never say anything again.
|
|
|
1417
1424
|
|
|
1418
1425
|
---
|
|
1419
1426
|
|
|
1427
|
+
### 11.8 The ninth indicator: cognitive complexity, loaded rather than written
|
|
1428
|
+
|
|
1429
|
+
Section 9.2 chose this metric and said it would be "written once here". It is not written. oxlint
|
|
1430
|
+
1.77 accepts an ESLint rule as a JS plugin, so Sonar's own `cognitive-complexity` runs inside the
|
|
1431
|
+
same invocation as the eight rules above, through a bridge that re-exposes it and contains none of
|
|
1432
|
+
their code.
|
|
1433
|
+
|
|
1434
|
+
**Why loaded and not reimplemented**, in order of weight:
|
|
1435
|
+
|
|
1436
|
+
⛔ **The licence decides it.** `eslint-plugin-sonarjs` ships under the Sonar Source-Available
|
|
1437
|
+
License, whose grant is limited to a "Non-competitive Purpose", and sentinel is published under MIT.
|
|
1438
|
+
Depending on a package is not preparing a derivative work of it; transcribing its code into this
|
|
1439
|
+
repository would be, in any language, and would mean granting rights we never received. ⚠️ Its
|
|
1440
|
+
`package.json` declares `LGPL-3.0-only` while the LICENSE file and every source header say SSAL.
|
|
1441
|
+
That contradiction is Sonar's; it is recorded here, not resolved.
|
|
1442
|
+
|
|
1443
|
+
**The metric's author works there.** Campbell 2018 is theirs, so their implementation is the
|
|
1444
|
+
specification's own house rather than one reading of it among several.
|
|
1445
|
+
|
|
1446
|
+
**It costs almost nothing.** Measured: 0 advisories at every level, 94 packages, and `eslint` is
|
|
1447
|
+
**not** pulled, because pnpm leaves the peer unmet and oxlint provides the context itself. It does
|
|
1448
|
+
pull `typescript`, which nothing of ours imports. The rule adds 2.4 s over 8 083 files to a pass
|
|
1449
|
+
that was already reading them.
|
|
1450
|
+
|
|
1451
|
+
#### The behaviour is not the paper's, deliberately
|
|
1452
|
+
|
|
1453
|
+
Measured on the same file with the same parser, before any of it was believed:
|
|
1454
|
+
|
|
1455
|
+
| | sonarjs 0.25.1 | sonarjs 4.2.2 |
|
|
1456
|
+
| --------------------------- | -------------: | ------------: |
|
|
1457
|
+
| `if (a \|\| b \|\| c)` | 2 | **1** |
|
|
1458
|
+
| `if (a && b && c)` | 2 | 2 |
|
|
1459
|
+
| `const x = a \|\| b \|\| c` | 1 | **0** |
|
|
1460
|
+
|
|
1461
|
+
⛔ **This is not a regression, which is what it was first called here.** Sequences of `||` and `??`
|
|
1462
|
+
are excluded on purpose: SonarSource state it themselves, "we tried to adjust the cognitive
|
|
1463
|
+
complexity rule to accommodate common idioms", and made the exclusion universal in SonarQube 10.8
|
|
1464
|
+
after it had been inconsistent between contexts. In JavaScript those two operators are
|
|
1465
|
+
overwhelmingly default-value shorthand rather than control flow. The lesson is the general one: an
|
|
1466
|
+
implementation that diverges from a published algorithm is a question to ask, not a defect to
|
|
1467
|
+
assume.
|
|
1468
|
+
|
|
1469
|
+
#### The host is validated, not assumed
|
|
1470
|
+
|
|
1471
|
+
Running somebody else's rule inside another linter removes the question "is our counting right" and
|
|
1472
|
+
replaces it with a narrower one: **does oxlint's tree feed the rule the same thing ESLint's does.**
|
|
1473
|
+
A fixture cannot answer that, because the constructs at risk are the ones real code has and
|
|
1474
|
+
fixtures do not: decorators, enums, `satisfies`, generics, and the JSX short-circuit the rule
|
|
1475
|
+
treats specially.
|
|
1476
|
+
|
|
1477
|
+
So the same rule was run through both hosts over two real modules and compared **function by
|
|
1478
|
+
function**, matched on file and line:
|
|
1479
|
+
|
|
1480
|
+
| | functions | identical | disagreements | seen by one host only |
|
|
1481
|
+
| ------------------------ | --------: | --------: | ------------: | --------------------: |
|
|
1482
|
+
| `contract`, nest | 539 | **539** | 0 | 0 |
|
|
1483
|
+
| `console`, react and tsx | 581 | **581** | 0 | 0 |
|
|
1484
|
+
|
|
1485
|
+
⚠️ This was missing when the figure first shipped, and the gap was not obvious: the earlier check
|
|
1486
|
+
compared the two hosts on about twenty fixture functions, which proves the plumbing and not the
|
|
1487
|
+
parse. It is the same bar `complexity` was held to against oxlint, 839 functions and 839 identical
|
|
1488
|
+
values, and it is the bar any rule borrowed from another ecosystem has to clear.
|
|
1489
|
+
|
|
1490
|
+
#### The threshold
|
|
1491
|
+
|
|
1492
|
+
`min(research, our p90, the rule's default)`, the same rule as the eight above. Sonar publishes
|
|
1493
|
+
**15** for S3776, and that is a vendor default rather than a research value: one of their own
|
|
1494
|
+
engineers has said publicly that no formula supports it. Our p90 over the files the rule touches is
|
|
1495
|
+
**7**, so 7 wins. It reports **342 of 8 083 files, 4.2%**, inside the band the other eight occupy.
|
|
1496
|
+
|
|
1497
|
+
#### What it adds, and the honest version of that
|
|
1498
|
+
|
|
1499
|
+
⚠️ **Almost nothing to the union.** Of its 342 files, **330 were already flagged** by one of the
|
|
1500
|
+
eight: the union goes from 1 708 files to 1 720, 21.1% to 21.3%. Anyone judging the indicator by
|
|
1501
|
+
coverage should stop here and conclude it is redundant.
|
|
1502
|
+
|
|
1503
|
+
That reading is wrong, and the measurement that shows why is the comparison with McCabe, which is
|
|
1504
|
+
the thing it was brought in to complement:
|
|
1505
|
+
|
|
1506
|
+
| on 8 083 product files | |
|
|
1507
|
+
| ------------------------------------------------- | ------: |
|
|
1508
|
+
| flagged by both complexities | 242 |
|
|
1509
|
+
| **cyclomatic only**, branchy but readable | **257** |
|
|
1510
|
+
| **cognitive only**, tangled without being branchy | **100** |
|
|
1511
|
+
| agreement between the two | **40%** |
|
|
1512
|
+
| Spearman over the 3 823 files carrying both | 0.764 |
|
|
1513
|
+
|
|
1514
|
+
The two disagree on **60% of the files either one flags**, which is the whole point. A flat `switch`
|
|
1515
|
+
with twelve cases is cyclomatically 13 and cognitively 1, because nothing is nested and nothing
|
|
1516
|
+
surprises; three `if`s inside each other are cyclomatically 4 and cognitively 6, because each level
|
|
1517
|
+
is charged for the levels above it. The 257 are the first shape and the 100 are the second. A figure
|
|
1518
|
+
that agreed with McCabe would have been the redundant one.
|
|
1519
|
+
|
|
1520
|
+
#### Three defects on the way, all with one symptom
|
|
1521
|
+
|
|
1522
|
+
Each produced exactly `oxlint could not be run, so no size indicator is reported`, which names
|
|
1523
|
+
neither the plugin nor the path, and each silently removed **all nine** figures rather than one:
|
|
1524
|
+
|
|
1525
|
+
- the bridge written straight into `plugins/`, which is git-ignored and wiped by every build. It
|
|
1526
|
+
survived until the next `pnpm build`.
|
|
1527
|
+
- its path computed by counting `..` from `import.meta.url`. `tsup` bundles into chunks whose depth
|
|
1528
|
+
is not the source tree's, so it resolved from `src/` and not from `dist/`. ⚠️
|
|
1529
|
+
`roles/test/shared-test-config.ts` already records this lesson at length, after it cost a module
|
|
1530
|
+
83 passing tests; it was repeated anyway. The path is now walked up to.
|
|
1531
|
+
- the rule is namespaced by its plugin in the config, `sonar/cognitive-complexity`, and bare in the
|
|
1532
|
+
diagnostic. One name in both places gives a config oxlint accepts and a breach map that never
|
|
1533
|
+
matches.
|
|
1534
|
+
|
|
1535
|
+
All three are now tests.
|
|
1536
|
+
|
|
1537
|
+
---
|
|
1538
|
+
|
|
1420
1539
|
## 12. Sources
|
|
1421
1540
|
|
|
1422
1541
|
- IfSQ. _Level 1, Level 2 and Level 3 Standards for Code Quality._ <https://www.ifsq.org/level-1.html>,
|
package/docs/using-sentinel.md
CHANGED
|
@@ -4,8 +4,9 @@ The commands, what an adopted module ends up looking like, and how versions move
|
|
|
4
4
|
migrating a module see [`lint-adoption.md`](lint-adoption.md),
|
|
5
5
|
[`format-adoption.md`](format-adoption.md),
|
|
6
6
|
[`typescript-adoption.md`](typescript-adoption.md),
|
|
7
|
-
[`build-adoption.md`](build-adoption.md)
|
|
8
|
-
[`test-adoption.md`](test-adoption.md)
|
|
7
|
+
[`build-adoption.md`](build-adoption.md),
|
|
8
|
+
[`test-adoption.md`](test-adoption.md) and
|
|
9
|
+
[`static-analysis-adoption.md`](static-analysis-adoption.md), which also carries the reference gate `--test` runs a
|
|
9
10
|
migration against.
|
|
10
11
|
|
|
11
12
|
## The grid
|
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.23",
|
|
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",
|
|
@@ -118,6 +118,7 @@
|
|
|
118
118
|
"eslint-plugin-jest-dom": "5.5.0",
|
|
119
119
|
"eslint-plugin-no-barrel-files": "1.2.1",
|
|
120
120
|
"eslint-plugin-react-refresh": "0.4.19",
|
|
121
|
+
"eslint-plugin-sonarjs": "4.2.2",
|
|
121
122
|
"eslint-plugin-storybook": "10.3.5",
|
|
122
123
|
"eslint-plugin-styled-components-a11y": "2.2.0",
|
|
123
124
|
"graphology": "0.26.0",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cognitive complexity, measured by Sonar's own rule, loaded into oxlint.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ Source file. It is COPIED to `plugins/` at build time by `scripts/build-presets.ts`, which
|
|
5
|
+
* wipes that directory first: `plugins/` is git-ignored, so a file written straight into it lasts
|
|
6
|
+
* until the next build. Edit this one.
|
|
7
|
+
*
|
|
8
|
+
* ## Why a bridge and not an implementation
|
|
9
|
+
*
|
|
10
|
+
* Cognitive complexity is Campbell 2018, and its author works at SonarSource, so their
|
|
11
|
+
* implementation is not one reading of the specification among several: it is the specification's
|
|
12
|
+
* own house, with the language-specific decisions that come with it. The clearest of those is that
|
|
13
|
+
* `||` and `??` sequences do NOT increment in JavaScript and TypeScript, which looks like a defect
|
|
14
|
+
* against the generic paper and is deliberate: in this language those two operators are
|
|
15
|
+
* overwhelmingly default-value shorthand rather than control flow. SonarSource say so themselves,
|
|
16
|
+
* "we tried to adjust the cognitive complexity rule to accommodate common idioms", and made the
|
|
17
|
+
* exclusion universal in SonarQube 10.8 after it had been inconsistent between contexts.
|
|
18
|
+
*
|
|
19
|
+
* Measured here before it was believed, same file and same parser on both:
|
|
20
|
+
*
|
|
21
|
+
* | | sonarjs 0.25.1 | sonarjs 4.2.2 |
|
|
22
|
+
* | `if (a \|\| b \|\| c)` | 2 | 1 |
|
|
23
|
+
* | `if (a && b && c)` | 2 | 2 |
|
|
24
|
+
* | `const x = a \|\| b \|\| c`| 1 | 0 |
|
|
25
|
+
*
|
|
26
|
+
* ⛔ **The rule is USED, never copied.** `eslint-plugin-sonarjs` ships under the Sonar
|
|
27
|
+
* Source-Available License, whose grant is limited to a "Non-competitive Purpose", and sentinel is
|
|
28
|
+
* published under MIT. Depending on a package is not preparing a derivative work of it; copying its
|
|
29
|
+
* code into this repository would be, and would also mean granting rights we never received. So
|
|
30
|
+
* this file re-exposes their rule and contains none of their code.
|
|
31
|
+
*
|
|
32
|
+
* ## Why it works at all, which is not obvious
|
|
33
|
+
*
|
|
34
|
+
* This is an ESLint rule running inside oxlint. Measured before it was adopted:
|
|
35
|
+
*
|
|
36
|
+
* | | |
|
|
37
|
+
* | the same values as ESLint, on `.js` and `.ts`| identical on every fixture, including the 7 of Campbell's own published example |
|
|
38
|
+
* | on a real module, `contract`, 516 files | 765 ms, exit 0, no error |
|
|
39
|
+
* | `eslint` itself | NOT installed: pnpm leaves the peer unmet and oxlint provides the context |
|
|
40
|
+
* | advisories the dependency adds | 0 at every level, 94 packages |
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ It does pull `typescript` as a hard dependency of the plugin, which nothing of ours imports.
|
|
43
|
+
*/
|
|
44
|
+
import { createRequire } from 'node:module'
|
|
45
|
+
|
|
46
|
+
const require = createRequire(import.meta.url)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ⛔ The deep path, and not the package name.
|
|
50
|
+
*
|
|
51
|
+
* `eslint-plugin-sonarjs` declares `main: ./cjs/plugin.js` and no `exports` map, so a bare ESM
|
|
52
|
+
* `import` of the package name fails to resolve under Node's ESM rules. Measured: it throws
|
|
53
|
+
* `ERR_MODULE_NOT_FOUND` before oxlint has read a single file, and oxlint reports it as
|
|
54
|
+
* "Failed to parse oxlint configuration file", which points at the wrong thing entirely.
|
|
55
|
+
*/
|
|
56
|
+
const sonarjs = require('eslint-plugin-sonarjs/cjs/plugin.js')
|
|
57
|
+
|
|
58
|
+
/** CJS interop: the plugin is reached either directly or behind `default`, depending on the build. */
|
|
59
|
+
const rules = sonarjs.rules ?? sonarjs.default?.rules
|
|
60
|
+
|
|
61
|
+
if (!rules?.['cognitive-complexity']) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'sentinel: eslint-plugin-sonarjs no longer exposes `cognitive-complexity` from cjs/plugin.js. ' +
|
|
64
|
+
'The cognitive complexity figure cannot be produced; see this file.',
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default {
|
|
69
|
+
meta: { name: 'sonar' },
|
|
70
|
+
rules: { 'cognitive-complexity': rules['cognitive-complexity'] },
|
|
71
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/shared/package-json.ts","../src/core/settings.ts","../src/core/workspace-prep.ts"],"sourcesContent":["/**\n * package.json helpers, shared by the CLI and the engine.\n */\nimport { existsSync, readFileSync } from 'node:fs'\nimport { basename, dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\n/**\n * sentinel's OWN version, for `--version`. We must NOT use\n * `process.env.npm_package_version`, that is the version of whatever script\n * invoked us (an app's package.json, or nothing). Instead walk up from this\n * module to the nearest package.json, which is always sentinel's own, whether\n * running from source or the bundled dist.\n */\n/**\n * sentinel's OWN name + version, read by walking up from this module to the nearest\n * package.json. Used both for `--version` and to pin the exact dependency `--init`\n * writes into an adopted module, so a module always references the version that adopted it.\n */\nexport function readOwnPackage(): { name: string; version: string; bin?: string } {\n let dir = dirname(fileURLToPath(import.meta.url))\n for (;;) {\n const pkgPath = join(dir, 'package.json')\n if (existsSync(pkgPath)) {\n try {\n const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {\n name?: string\n version?: string\n bin?: string | Record<string, string>\n }\n if (typeof pkg.version === 'string') {\n // The bin PATH, not the command name: a package that adopts itself has to invoke\n // its own build output, because the `.bin` shim does not exist yet (see below).\n const bin = typeof pkg.bin === 'string' ? pkg.bin : Object.values(pkg.bin ?? {})[0]\n return { name: pkg.name ?? '@hublo/sentinel', version: pkg.version, bin }\n }\n } catch {\n // Malformed package.json; keep walking up.\n }\n }\n const parent = dirname(dir)\n if (parent === dir) return { name: '@hublo/sentinel', version: '0.0.0' } // filesystem root\n dir = parent\n }\n}\n\nexport function readOwnVersion(): string {\n return readOwnPackage().version\n}\n\n/**\n * Read a module's package.json for framework detection; tolerant of a missing or\n * malformed file (returns `{}`, so detection falls through to the `node` default).\n */\nexport function readProjectPackageJson(dir: string): {\n /** The package name, which is also what nx calls the project when there is no `project.json`. */\n name?: string\n dependencies?: Record<string, string>\n devDependencies?: Record<string, string>\n /**\n * How the module's `.js`/`.ts` files are loaded. It decides whether a Vite config can import\n * an ESM-only package at all, which is why the build role reads it before rewriting one.\n */\n type?: string\n /**\n * What nx alone needs per target, which every role writes here and none could read back.\n *\n * Typed loosely on purpose: this reader is shared by every role and each one knows the shape of\n * its own target. Declared at all because a role that WRITES a block and then has to cast to\n * read it is one refactor away from reading a key it never wrote.\n */\n nx?: { targets?: Record<string, Record<string, unknown>> }\n} {\n const path = join(dir, 'package.json')\n if (!existsSync(path)) return {}\n try {\n return JSON.parse(readFileSync(path, 'utf8')) as Record<string, never>\n } catch {\n process.stderr.write(`sentinel: could not parse ${path}; ignoring for detection.\\n`)\n return {}\n }\n}\n\n/**\n * The nx project name for a module, read from its `project.json`, or undefined if\n * there is none/unreadable. Used when `--init` has to scaffold a `package.json`\n * for a module that only has a `project.json` (common for nx apps/services): the\n * scaffolded file borrows this name so it is a valid, uniquely-named workspace\n * package. Reusable by any tool that scaffolds, not just TypeScript.\n */\nexport function readNxProjectName(dir: string): string | undefined {\n const path = join(dir, 'project.json')\n if (!existsSync(path)) return undefined\n try {\n const parsed = JSON.parse(readFileSync(path, 'utf8')) as { name?: unknown }\n return typeof parsed.name === 'string' ? parsed.name : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * What nx calls this module, resolved the way nx itself resolves it.\n *\n * The precedence is `project.json`, then `package.json`, then the directory, and only the\n * first and last of those were being consulted. A module with no `project.json` therefore\n * answered with its DIRECTORY while nx answered with its package name, and the two disagree\n * in the real repo: `apps/front/hr-management` is `front-hr-management` to nx, as is\n * `apps/front/storybook-app`.\n *\n * That gap is not cosmetic, because this name is what `--module` is matched against. The\n * `serve` script `--init` writes carries `--module <name>`, so a module in that shape was\n * adopted with a script naming a project the workspace does not contain, and `pnpm run serve`\n * failed the day it was adopted with nothing having reported it.\n *\n * One function so the two ends cannot drift again: whoever WRITES the name and whoever\n * RESOLVES it now read the same source.\n */\nexport function moduleName(dir: string): string {\n const declared = readProjectPackageJson(dir).name\n return readNxProjectName(dir) ?? (declared === undefined ? basename(dir) : declared)\n}\n","/**\n * Tunable settings: the knobs you would actually change. The fixed vocabulary and\n * its types live in `domain.ts`.\n *\n * Note: the preset is DETECTED from a module's dependencies (see\n * `detect-framework.ts`), deterministically, `node` when no framework dep is present.\n * `--preset` overrides the detection (recommended for `--init`, since hoisted deps\n * can make detection return `node`). The detection table lives in `detect-framework.ts`.\n */\n\n/** A file that marks a workspace root; sentinel refuses to operate there. */\nexport const WORKSPACE_ROOT_MARKER = 'nx.json'\n\n/**\n * How many structured diagnostics a report embeds per module, when the caller does not say.\n *\n * A cap, not a limit on what is counted: the totals stay exact and `diagnosticsTruncated`\n * says when rows were dropped, so a bounded envelope never reads as a clean module. Shared\n * by every role, because a consumer reading `--json` across roles should not have to learn\n * a different cap for each one. `--max-diagnostics 0` means no cap.\n */\nexport const DEFAULT_MAX_DIAGNOSTICS = 100\n","/**\n * Workspace-level preparation that `--init` applies once, at the workspace root, so an\n * adopted module actually installs and resolves cleanly. `--init` writes a module's own\n * files; this is the small, deliberate exception where it also touches the root, because\n * \"set this module up\" is not honest if the workspace it lives in cannot then install or\n * resolve it.\n *\n * Kept intentionally narrow (YAGNI): it applies the specific fixes a workspace's OWN\n * config shows it needs, and is a no-op everywhere else. It is NOT a speculative dedupe\n * engine, when a second, different case appears we generalize from real cases instead of\n * predicting them. The two fixes today:\n *\n * - i18next singleton override. If the root declares a second TypeScript via the\n * `@typescript/native` alias (a repo mid-migration running two TypeScripts), adopting a\n * module re-resolves pnpm and i18next, which carries an optional type-only `typescript`\n * peer, forks into two peer-keyed variants. That forks react-i18next into two React\n * contexts and breaks translation-singleton tests. Pinning i18next's `typescript` peer\n * to the declared TS collapses it to one. Read from the root's own declaration, so it\n * tracks the version and disappears (no-op) once the migration removes the second TS.\n * Written to `pnpm-workspace.yaml`, the only place current pnpm reads overrides from.\n *\n * - release-age allow-list. If the root uses pnpm's `minimumReleaseAgeExclude` gate, a\n * freshly published sentinel is younger than the gate and will not install; allow-list\n * it. Only touched when the gate is actually configured.\n *\n * - root formatter exclusion. A module that has adopted the FORMAT role is formatted by\n * oxfmt, from its own config. The root Prettier does not know that and still owns every\n * file in the repo, so `nx format:write` or `prettier --write .` from the root reformats\n * the module back to the ROOT's style, and the next `pnpm run format` in the module\n * reverses it. Two formatters fighting over the same files, with whichever ran last\n * winning, is the exact state adoption removes INSIDE a module, and it has to be removed\n * at the root too. So the module is excluded in the root `.prettierignore` as it adopts,\n * and the root's own config is otherwise untouched: it still serves every module that has\n * not migrated.\n *\n * All three edits are idempotent and defensive: absent the trigger (no second TypeScript, no\n * release-age gate, no root Prettier) each is skipped, so the step is safe on any workspace.\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs'\nimport { dirname, join, relative } from 'node:path'\n\nimport { readOwnPackage } from '../shared/package-json.js'\n\nimport { WORKSPACE_ROOT_MARKER } from './settings.js'\n\nconst OVERRIDE_KEY = 'i18next>typescript'\nconst NATIVE_TS_ALIAS = '@typescript/native' // `npm:typescript@^7.x`: a workspace's second, real TS\nconst WORKSPACE_YAML = 'pnpm-workspace.yaml'\nconst RELEASE_AGE_KEY = 'minimumReleaseAgeExclude'\nconst LIST_ITEM = /^(\\s*)-\\s+(.*?)\\s*$/\nconst MAP_ENTRY = /^(\\s+)(.*?):\\s+(.*?)\\s*$/\n\ninterface RootPackageJson {\n dependencies?: Record<string, string>\n devDependencies?: Record<string, string>\n pnpm?: { overrides?: Record<string, string> }\n}\n\n/** Walk up from a module directory to the workspace root (the dir holding the marker). */\nexport function findWorkspaceRoot(startDir: string): string | undefined {\n let dir = startDir\n for (;;) {\n if (existsSync(join(dir, WORKSPACE_ROOT_MARKER))) return dir\n const parent = dirname(dir)\n if (parent === dir) return undefined\n dir = parent\n }\n}\n\n/**\n * The TypeScript version to pin i18next's peer to, read from the root's own\n * `@typescript/native` alias (`npm:typescript@^7.0.2` -> `7.0.2`). Returns undefined when\n * the alias is absent (no second TypeScript, so no fork to fix).\n */\nfunction declaredNativeTs(pkg: RootPackageJson): string | undefined {\n const spec = pkg.dependencies?.[NATIVE_TS_ALIAS] ?? pkg.devDependencies?.[NATIVE_TS_ALIAS]\n if (!spec) return undefined\n const version = spec.slice(spec.lastIndexOf('@') + 1).replace(/^[\\^~>=<\\s]+/, '')\n return version || undefined\n}\n\n/**\n * Ensure the i18next singleton override matches the declared second TypeScript. No-op when\n * the workspace runs a single TypeScript (no `@typescript/native`). Returns a message when\n * it changed (or would change, under dryRun), else undefined.\n *\n * The override is written to `pnpm-workspace.yaml`, never to `package.json#pnpm`: pnpm 11\n * stopped reading the `pnpm` field and does so SILENTLY, with no warning and no deprecation\n * (pnpm/pnpm#11536), so an override left in `package.json` is simply not applied. The yaml is\n * read by pnpm 10 and 11 alike (https://pnpm.io/settings), which is why there is one target\n * here rather than a version-dependent fallback.\n */\nfunction ensureI18nextSingleton(root: string, dryRun: boolean): string | undefined {\n const pkgPath = join(root, 'package.json')\n if (!existsSync(pkgPath)) return undefined\n const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as RootPackageJson\n const want = declaredNativeTs(pkg)\n if (!want) return undefined // single TypeScript: nothing forks\n\n const yamlPath = join(root, WORKSPACE_YAML)\n if (!existsSync(yamlPath)) return undefined // no workspace file: nowhere pnpm would read it\n const lines = readFileSync(yamlPath, 'utf8').split('\\n')\n const entry = findOverrideEntry(lines)\n if (entry.valueIdx !== undefined && entry.value === want) return undefined // already pinned\n if (dryRun) return `would pin ${OVERRIDE_KEY} to ${want} in ${WORKSPACE_YAML} (i18next singleton)`\n\n applyOverride(lines, entry, want)\n writeFileSync(yamlPath, lines.join('\\n'))\n return `pinned ${OVERRIDE_KEY} to ${want} in ${WORKSPACE_YAML} (i18next singleton)`\n}\n\n/** Where the override sits in the yaml, if at all, plus where a new one would be inserted. */\ninterface OverrideEntry {\n /** Index of the `OVERRIDE_KEY` line, when the key is already declared. */\n valueIdx?: number\n /** Its current value, unquoted. */\n value?: string\n /** Index of the `overrides:` key line, when the block exists. */\n blockIdx?: number\n /** Index of the block's last entry, so an insertion keeps the block contiguous. */\n lastEntryIdx?: number\n /** Indentation the block's entries use. */\n indent: string\n}\n\n/**\n * Locate the `overrides:` block and the i18next key inside it. A targeted text read rather\n * than a yaml parse, for the same reason `ensureReleaseAgeAllowList` does it: this file is\n * hand-maintained and round-tripping it through a parser would drop its comments and ordering.\n */\nfunction findOverrideEntry(lines: string[]): OverrideEntry {\n const blockIdx = lines.findIndex((line) => line.replace(/\\s+$/, '') === 'overrides:')\n if (blockIdx === -1) return { indent: ' ' }\n\n let lastEntryIdx = blockIdx\n let indent = ' '\n for (let i = blockIdx + 1; i < lines.length; i++) {\n const match = lines[i]?.match(MAP_ENTRY)\n if (!match) break // dedent or blank line: the block ended\n indent = match[1] ?? indent\n lastEntryIdx = i\n if ((match[2] ?? '').replace(/^['\"]|['\"]$/g, '') === OVERRIDE_KEY) {\n return {\n valueIdx: i,\n value: (match[3] ?? '').replace(/^['\"]|['\"]$/g, ''),\n blockIdx,\n lastEntryIdx,\n indent,\n }\n }\n }\n return { blockIdx, lastEntryIdx, indent }\n}\n\n/** Update the key in place, append it to an existing block, or open a block at the end. */\nfunction applyOverride(lines: string[], entry: OverrideEntry, want: string): void {\n const line = `${entry.indent}'${OVERRIDE_KEY}': ${want}`\n if (entry.valueIdx !== undefined) {\n lines[entry.valueIdx] = line\n return\n }\n if (entry.lastEntryIdx !== undefined) {\n lines.splice(entry.lastEntryIdx + 1, 0, line)\n return\n }\n // No `overrides:` block at all: open one, keeping a single trailing newline.\n while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()\n lines.push('overrides:', line, '')\n}\n\n/**\n * Ensure sentinel is allow-listed under `minimumReleaseAgeExclude` so a fresh publish\n * installs. No-op when the workspace has no such gate. Targeted text edit so comments and\n * formatting are preserved. Returns a message when it changed (or would), else undefined.\n */\nfunction ensureReleaseAgeAllowList(root: string, dryRun: boolean): string | undefined {\n const yamlPath = join(root, WORKSPACE_YAML)\n if (!existsSync(yamlPath)) return undefined\n const own = readOwnPackage().name\n const lines = readFileSync(yamlPath, 'utf8').split('\\n')\n const keyIdx = lines.findIndex((line) => line.replace(/\\s+$/, '') === `${RELEASE_AGE_KEY}:`)\n if (keyIdx === -1) return undefined // no release-age gate in use\n\n let lastItemIdx = keyIdx\n let indent = ' '\n for (let i = keyIdx + 1; i < lines.length; i++) {\n const match = lines[i]?.match(LIST_ITEM)\n if (!match) break\n indent = match[1] ?? indent\n lastItemIdx = i\n if ((match[2] ?? '').replace(/^['\"]|['\"]$/g, '') === own) return undefined // already listed\n }\n if (dryRun) return `would allow-list ${own} under ${RELEASE_AGE_KEY}`\n\n lines.splice(lastItemIdx + 1, 0, `${indent}- '${own}'`)\n writeFileSync(yamlPath, lines.join('\\n'))\n return `allow-listed ${own} under ${RELEASE_AGE_KEY} in ${WORKSPACE_YAML}`\n}\n\n/** The root ignore file Prettier reads, and the section sentinel owns inside it. */\nconst GIT_IGNORE = '.gitignore'\nconst REPORT_DIR_NAME = 'static-analysis-report'\nconst REPORT_PATTERN = `${REPORT_DIR_NAME}/`\nconst REPORT_HEADER = '# @hublo/sentinel: static analysis reports, written inside each module'\nconst PRETTIER_IGNORE = '.prettierignore'\nconst EXCLUSION_HEADER = '# @hublo/sentinel: modules formatted by oxfmt, not by the root Prettier'\n\n/** Root Prettier configs. Their presence is what makes an exclusion meaningful. */\nconst ROOT_PRETTIER_CONFIGS = [\n '.prettierrc',\n '.prettierrc.json',\n '.prettierrc.json5',\n '.prettierrc.yml',\n '.prettierrc.yaml',\n '.prettierrc.js',\n '.prettierrc.cjs',\n '.prettierrc.mjs',\n 'prettier.config.js',\n 'prettier.config.cjs',\n 'prettier.config.mjs',\n]\n\n/**\n * Exclude a module that now formats itself from the ROOT Prettier.\n *\n * Written as a directory pattern anchored at the root (`/apps/front/x/`), which is what\n * gitignore syntax means by \"this exact directory\", so a module cannot accidentally exclude a\n * same-named directory somewhere else.\n *\n * Skipped entirely when the root has no Prettier at all: there is then nothing to conflict\n * with, and creating an ignore file for a tool the workspace does not use would be noise.\n */\nfunction ensureFormatterExclusion(\n root: string,\n moduleDir: string,\n dryRun: boolean,\n): string | undefined {\n const rel = relative(root, moduleDir).replaceAll('\\\\', '/')\n if (rel === '' || rel.startsWith('..')) return undefined // the root itself, or outside it\n\n const ignorePath = join(root, PRETTIER_IGNORE)\n const hasPrettier =\n existsSync(ignorePath) || ROOT_PRETTIER_CONFIGS.some((name) => existsSync(join(root, name)))\n if (!hasPrettier) return undefined\n\n const pattern = `/${rel}/`\n const existing = existsSync(ignorePath) ? readFileSync(ignorePath, 'utf8') : ''\n const lines = existing.split('\\n')\n // Already excluded, in whichever spelling a human may have used.\n if (lines.some((line) => line.trim().replace(/\\/$/, '') === pattern.replace(/\\/$/, ''))) {\n return undefined\n }\n if (dryRun) return `would exclude ${rel} from the root Prettier (${PRETTIER_IGNORE})`\n\n const headerIdx = lines.findIndex((line) => line.trim() === EXCLUSION_HEADER)\n if (headerIdx === -1) {\n const prefix = existing.trim() === '' ? '' : `${existing.replace(/\\n*$/, '')}\\n\\n`\n writeFileSync(ignorePath, `${prefix}${EXCLUSION_HEADER}\\n${pattern}\\n`)\n } else {\n // Keep the managed section together and sorted, so the list stays readable as it grows\n // to 150 entries and a diff shows one added line rather than a reshuffle.\n let end = headerIdx + 1\n while (end < lines.length && lines[end]?.trim().startsWith('/')) end += 1\n const managed = [...lines.slice(headerIdx + 1, end), pattern].sort()\n writeFileSync(\n ignorePath,\n [...lines.slice(0, headerIdx + 1), ...managed, ...lines.slice(end)].join('\\n'),\n )\n }\n return `excluded ${rel} from the root Prettier (${PRETTIER_IGNORE}): it formats itself now`\n}\n\n/**\n * Apply every workspace-level fix the given root needs. Returns the human-readable\n * messages for what changed (empty when nothing did). Under dryRun nothing is written and\n * the messages describe what would change.\n *\n * `formattedModule` is the module that just adopted the FORMAT role, when one did. Absent\n * for a lint-only or TypeScript-only `--init`, because a module that has not adopted the\n * formatter must keep being formatted by the root.\n */\n/**\n * Ignore the static analysis report directory, once, at the ROOT.\n *\n * ## Why the root and not each module\n *\n * The role writes its json and html into `<module>/static-analysis-report/`, so the obvious place\n * for the rule is the module. It was there first, and it was wrong twice over: it CREATES a\n * `.gitignore` in a module that had none, which is a structural change to someone else's module for\n * one line, and it repeats that line once per adopting module. The root already ignores output this\n * way, with `/dist` and `/coverage`.\n *\n * ⚠️ The pattern is deliberately NOT anchored. `/dist` matches only the root's `dist`; the report\n * directory lives inside a module, so `static-analysis-report/` has to match at any depth. Getting\n * that backwards would leave every adopting module with an untracked directory, which is the exact\n * defect this guards against.\n *\n * ## Why this root write is not the dangerous kind\n *\n * The comment on `inspectWorkspacePrep` is right that root writes are what nobody sees afterwards,\n * and the i18next override earned that warning: it re-resolved a module from a pull request that\n * touched none of its files. An ignore line changes no resolution, no build and no install. If it\n * ever goes stale the cost is one unused line, which is why it is reported rather than feared.\n */\nfunction ensureReportIgnored(root: string, dryRun: boolean): string | undefined {\n const path = join(root, GIT_IGNORE)\n const existing = existsSync(path) ? readFileSync(path, 'utf8') : ''\n // Whatever spelling a human may have used: with or without a leading or trailing slash.\n const already = existing\n .split('\\n')\n .some((line) => line.trim().replace(/^\\//, '').replace(/\\/$/, '') === REPORT_DIR_NAME)\n if (already) return undefined\n if (dryRun) return `would ignore ${REPORT_PATTERN} in the root ${GIT_IGNORE}`\n\n const prefix = existing.trim() === '' ? '' : `${existing.replace(/\\n*$/, '')}\\n\\n`\n writeFileSync(path, `${prefix}${REPORT_HEADER}\\n${REPORT_PATTERN}\\n`)\n return `ignored ${REPORT_PATTERN} in the root ${GIT_IGNORE}`\n}\n\nexport function ensureWorkspacePrep(opts: {\n root: string\n dryRun?: boolean\n formattedModule?: string\n /** True when the STATIC ANALYSIS role adopted: it is what earns the ignore line. */\n analysedModule?: boolean\n}): string[] {\n const dryRun = Boolean(opts.dryRun)\n return [\n ensureI18nextSingleton(opts.root, dryRun),\n ensureReleaseAgeAllowList(opts.root, dryRun),\n opts.formattedModule === undefined\n ? undefined\n : ensureFormatterExclusion(opts.root, opts.formattedModule, dryRun),\n opts.analysedModule === true ? ensureReportIgnored(opts.root, dryRun) : undefined,\n ].filter((message): message is string => message !== undefined)\n}\n\n/**\n * One standing change sentinel has made to the workspace ROOT, and whether it is still earned.\n *\n * This exists because the three root writes are the only thing sentinel does that nobody can\n * see afterwards. `--init` announces each one on the run that makes it, and then it is a line\n * in someone else's file forever. Two consequences already bit us.\n *\n * The `i18next>typescript` override re-resolved `apps/front/console` from a PR that touched no\n * file under it, so CODEOWNERS pulled nobody and its owners learned about it afterwards.\n *\n * And every one of them is TEMPORARY with its removal condition written in prose. That failed\n * three times in one week: `SENTINEL_TAG=alpha` in the migration-page workflow kept building\n * with a pre-release long after 1.1.0 went stable and reported `Format 0/439` for 15 adopted\n * modules; ADR 0042 and its RFC both sat mid-flight past their deadline; and this release-age\n * exclusion is still waiting on \"once sentinel's release cadence settles\". Nothing failed when\n * any of them expired, because a comment cannot fail.\n *\n * So each entry carries the condition that JUSTIFIES it, evaluated now rather than described.\n * A trigger that has gone away makes the entry dead config, and dead config is something a\n * command can say out loud instead of something a reader has to notice.\n *\n * Read-only, and offline: it inspects the same files `--init` writes and nothing else. What it\n * deliberately does NOT claim is anything needing the npm registry — whether a published\n * version is old enough to clear the release-age gate is not knowable from the repo, so it is\n * stated as the standing exemption it is rather than guessed at.\n */\nexport interface WorkspacePrepEntry {\n /** `rule` to match the shape the shared renderer already prints as a labelled block. */\n rule: string\n /** What it does, whether its trigger still holds, and what would retire it. */\n reason: string\n}\n\n/** The root-level changes sentinel maintains here, each with its justification checked now. */\nexport function inspectWorkspacePrep(root: string): WorkspacePrepEntry[] {\n const entries: WorkspacePrepEntry[] = []\n // Tolerant, unlike the write path above, and the difference is deliberate. `--init` should\n // fail loudly on a root it cannot parse, because it is about to edit it. `--inspect` is a\n // read verb that sweeps 441 modules, and one malformed root file must not take the whole\n // sweep down: the modules still have honest answers to give. Caught by its own test.\n let pkg: RootPackageJson = {}\n const pkgPath = join(root, 'package.json')\n if (existsSync(pkgPath)) {\n try {\n pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as RootPackageJson\n } catch {\n pkg = {}\n }\n }\n\n const yamlPath = join(root, WORKSPACE_YAML)\n const yaml = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf8') : undefined\n\n const pinned = yaml === undefined ? undefined : findOverrideEntry(yaml.split('\\n')).value\n if (pinned !== undefined) {\n const stillForks = declaredNativeTs(pkg)\n entries.push({\n rule: `${WORKSPACE_YAML} → overrides.${OVERRIDE_KEY}`,\n reason:\n stillForks === undefined\n ? `pinned to ${pinned}, but the root no longer declares ${NATIVE_TS_ALIAS}, so nothing ` +\n `forks any more. This override is now dead config and can be removed.`\n : `pinned to ${pinned}, matching the second TypeScript the root declares ` +\n `(${NATIVE_TS_ALIAS}). It re-resolves i18next for EVERY module that depends on it, ` +\n `not only adopted ones. It retires with the second TypeScript.`,\n })\n }\n\n // A copy left in `package.json#pnpm` by an older sentinel, or by hand. Reported because it\n // is INERT rather than redundant: pnpm 11 dropped the `pnpm` field with no warning\n // (pnpm/pnpm#11536), so it reads as protection that is not applied.\n const legacy = pkg.pnpm?.overrides?.[OVERRIDE_KEY]\n if (legacy !== undefined) {\n entries.push({\n rule: `package.json → pnpm.overrides.${OVERRIDE_KEY}`,\n reason:\n `pinned to ${legacy}, but pnpm 11 no longer reads the \\`pnpm\\` field of package.json ` +\n `and says nothing about it, so this override is not applied. Move it to ` +\n `${WORKSPACE_YAML} under \\`overrides:\\`, which pnpm 10 and 11 both read.`,\n })\n }\n\n const ignorePath = join(root, GIT_IGNORE)\n if (existsSync(ignorePath)) {\n const ignored = readFileSync(ignorePath, 'utf8')\n .split('\\n')\n .some((line) => line.trim().replace(/^\\//, '').replace(/\\/$/, '') === REPORT_DIR_NAME)\n if (ignored) {\n entries.push({\n rule: `${GIT_IGNORE} → ${REPORT_PATTERN}`,\n reason:\n `the static analysis role writes its json and html into ` +\n `\\`<module>/${REPORT_PATTERN}\\`, and one unanchored line at the root covers every module ` +\n `instead of creating a \\`${GIT_IGNORE}\\` in each one. Unlike the override above it changes ` +\n `no resolution and no build, so a stale one costs a single unused line. It retires when no ` +\n `module declares a \\`static-analysis\\` script any more, which the adoption report counts ` +\n `and this read deliberately does not: it would have to sweep every module.`,\n })\n }\n }\n\n const own = readOwnPackage().name\n if (yaml !== undefined) {\n const listed = new RegExp(`^\\\\s*-\\\\s*['\"]?${own.replace('/', '\\\\/')}['\"]?\\\\s*$`, 'm').test(yaml)\n if (listed) {\n const gated = new RegExp(`^\\\\s*minimumReleaseAge\\\\s*:`, 'm').test(yaml)\n entries.push({\n rule: `${WORKSPACE_YAML} → ${RELEASE_AGE_KEY}: ${own}`,\n reason: gated\n ? `${own} is exempt from this workspace's release-age gate, so a freshly published ` +\n `version installs instead of being held. That is a standing exemption from a ` +\n `supply-chain control: the exposure is one bump wide, since the version is pinned ` +\n `and the lockfile carries its integrity, so a re-publish of the same version does ` +\n `not pass. Retire it once a bump can simply wait out the window.`\n : `${own} is allow-listed, but this workspace no longer sets minimumReleaseAge, so ` +\n `there is no gate to be exempt from. This entry is now dead config and can be removed.`,\n })\n }\n }\n\n return entries\n}\n"],"mappings":";AAGA,SAAS,YAAY,oBAAoB;AACzC,SAAS,UAAU,SAAS,YAAY;AACxC,SAAS,qBAAqB;AAcvB,SAAS,iBAAkE;AAChF,MAAI,MAAM,QAAQ,cAAc,YAAY,GAAG,CAAC;AAChD,aAAS;AACP,UAAM,UAAU,KAAK,KAAK,cAAc;AACxC,QAAI,WAAW,OAAO,GAAG;AACvB,UAAI;AACF,cAAM,MAAM,KAAK,MAAM,aAAa,SAAS,MAAM,CAAC;AAKpD,YAAI,OAAO,IAAI,YAAY,UAAU;AAGnC,gBAAM,MAAM,OAAO,IAAI,QAAQ,WAAW,IAAI,MAAM,OAAO,OAAO,IAAI,OAAO,CAAC,CAAC,EAAE,CAAC;AAClF,iBAAO,EAAE,MAAM,IAAI,QAAQ,mBAAmB,SAAS,IAAI,SAAS,IAAI;AAAA,QAC1E;AAAA,MACF,QAAQ;AAAA,MAER;AAAA,IACF;AACA,UAAM,SAAS,QAAQ,GAAG;AAC1B,QAAI,WAAW,IAAK,QAAO,EAAE,MAAM,mBAAmB,SAAS,QAAQ;AACvE,UAAM;AAAA,EACR;AACF;AAEO,SAAS,iBAAyB;AACvC,SAAO,eAAe,EAAE;AAC1B;AAMO,SAAS,uBAAuB,KAkBrC;AACA,QAAM,OAAO,KAAK,KAAK,cAAc;AACrC,MAAI,CAAC,WAAW,IAAI,EAAG,QAAO,CAAC;AAC/B,MAAI;AACF,WAAO,KAAK,MAAM,aAAa,MAAM,MAAM,CAAC;AAAA,EAC9C,QAAQ;AACN,YAAQ,OAAO,MAAM,6BAA6B,IAAI;AAAA,CAA6B;AACnF,WAAO,CAAC;AAAA,EACV;AACF;AASO,SAAS,kBAAkB,KAAiC;AACjE,QAAM,OAAO,KAAK,KAAK,cAAc;AACrC,MAAI,CAAC,WAAW,IAAI,EAAG,QAAO;AAC9B,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,aAAa,MAAM,MAAM,CAAC;AACpD,WAAO,OAAO,OAAO,SAAS,WAAW,OAAO,OAAO;AAAA,EACzD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAmBO,SAAS,WAAW,KAAqB;AAC9C,QAAM,WAAW,uBAAuB,GAAG,EAAE;AAC7C,SAAO,kBAAkB,GAAG,MAAM,aAAa,SAAY,SAAS,GAAG,IAAI;AAC7E;;;AC9GO,IAAM,wBAAwB;AAU9B,IAAM,0BAA0B;;;ACiBvC,SAAS,cAAAA,aAAY,gBAAAC,eAAc,qBAAqB;AACxD,SAAS,WAAAC,UAAS,QAAAC,OAAM,gBAAgB;AAMxC,IAAM,eAAe;AACrB,IAAM,kBAAkB;AACxB,IAAM,iBAAiB;AACvB,IAAM,kBAAkB;AACxB,IAAM,YAAY;AAClB,IAAM,YAAY;AASX,SAAS,kBAAkB,UAAsC;AACtE,MAAI,MAAM;AACV,aAAS;AACP,QAAIC,YAAWC,MAAK,KAAK,qBAAqB,CAAC,EAAG,QAAO;AACzD,UAAM,SAASC,SAAQ,GAAG;AAC1B,QAAI,WAAW,IAAK,QAAO;AAC3B,UAAM;AAAA,EACR;AACF;AAOA,SAAS,iBAAiB,KAA0C;AAClE,QAAM,OAAO,IAAI,eAAe,eAAe,KAAK,IAAI,kBAAkB,eAAe;AACzF,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,UAAU,KAAK,MAAM,KAAK,YAAY,GAAG,IAAI,CAAC,EAAE,QAAQ,gBAAgB,EAAE;AAChF,SAAO,WAAW;AACpB;AAaA,SAAS,uBAAuB,MAAc,QAAqC;AACjF,QAAM,UAAUD,MAAK,MAAM,cAAc;AACzC,MAAI,CAACD,YAAW,OAAO,EAAG,QAAO;AACjC,QAAM,MAAM,KAAK,MAAMG,cAAa,SAAS,MAAM,CAAC;AACpD,QAAM,OAAO,iBAAiB,GAAG;AACjC,MAAI,CAAC,KAAM,QAAO;AAElB,QAAM,WAAWF,MAAK,MAAM,cAAc;AAC1C,MAAI,CAACD,YAAW,QAAQ,EAAG,QAAO;AAClC,QAAM,QAAQG,cAAa,UAAU,MAAM,EAAE,MAAM,IAAI;AACvD,QAAM,QAAQ,kBAAkB,KAAK;AACrC,MAAI,MAAM,aAAa,UAAa,MAAM,UAAU,KAAM,QAAO;AACjE,MAAI,OAAQ,QAAO,aAAa,YAAY,OAAO,IAAI,OAAO,cAAc;AAE5E,gBAAc,OAAO,OAAO,IAAI;AAChC,gBAAc,UAAU,MAAM,KAAK,IAAI,CAAC;AACxC,SAAO,UAAU,YAAY,OAAO,IAAI,OAAO,cAAc;AAC/D;AAqBA,SAAS,kBAAkB,OAAgC;AACzD,QAAM,WAAW,MAAM,UAAU,CAAC,SAAS,KAAK,QAAQ,QAAQ,EAAE,MAAM,YAAY;AACpF,MAAI,aAAa,GAAI,QAAO,EAAE,QAAQ,KAAK;AAE3C,MAAI,eAAe;AACnB,MAAI,SAAS;AACb,WAAS,IAAI,WAAW,GAAG,IAAI,MAAM,QAAQ,KAAK;AAChD,UAAM,QAAQ,MAAM,CAAC,GAAG,MAAM,SAAS;AACvC,QAAI,CAAC,MAAO;AACZ,aAAS,MAAM,CAAC,KAAK;AACrB,mBAAe;AACf,SAAK,MAAM,CAAC,KAAK,IAAI,QAAQ,gBAAgB,EAAE,MAAM,cAAc;AACjE,aAAO;AAAA,QACL,UAAU;AAAA,QACV,QAAQ,MAAM,CAAC,KAAK,IAAI,QAAQ,gBAAgB,EAAE;AAAA,QAClD;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO,EAAE,UAAU,cAAc,OAAO;AAC1C;AAGA,SAAS,cAAc,OAAiB,OAAsB,MAAoB;AAChF,QAAM,OAAO,GAAG,MAAM,MAAM,IAAI,YAAY,MAAM,IAAI;AACtD,MAAI,MAAM,aAAa,QAAW;AAChC,UAAM,MAAM,QAAQ,IAAI;AACxB;AAAA,EACF;AACA,MAAI,MAAM,iBAAiB,QAAW;AACpC,UAAM,OAAO,MAAM,eAAe,GAAG,GAAG,IAAI;AAC5C;AAAA,EACF;AAEA,SAAO,MAAM,SAAS,KAAK,MAAM,MAAM,SAAS,CAAC,MAAM,GAAI,OAAM,IAAI;AACrE,QAAM,KAAK,cAAc,MAAM,EAAE;AACnC;AAOA,SAAS,0BAA0B,MAAc,QAAqC;AACpF,QAAM,WAAWF,MAAK,MAAM,cAAc;AAC1C,MAAI,CAACD,YAAW,QAAQ,EAAG,QAAO;AAClC,QAAM,MAAM,eAAe,EAAE;AAC7B,QAAM,QAAQG,cAAa,UAAU,MAAM,EAAE,MAAM,IAAI;AACvD,QAAM,SAAS,MAAM,UAAU,CAAC,SAAS,KAAK,QAAQ,QAAQ,EAAE,MAAM,GAAG,eAAe,GAAG;AAC3F,MAAI,WAAW,GAAI,QAAO;AAE1B,MAAI,cAAc;AAClB,MAAI,SAAS;AACb,WAAS,IAAI,SAAS,GAAG,IAAI,MAAM,QAAQ,KAAK;AAC9C,UAAM,QAAQ,MAAM,CAAC,GAAG,MAAM,SAAS;AACvC,QAAI,CAAC,MAAO;AACZ,aAAS,MAAM,CAAC,KAAK;AACrB,kBAAc;AACd,SAAK,MAAM,CAAC,KAAK,IAAI,QAAQ,gBAAgB,EAAE,MAAM,IAAK,QAAO;AAAA,EACnE;AACA,MAAI,OAAQ,QAAO,oBAAoB,GAAG,UAAU,eAAe;AAEnE,QAAM,OAAO,cAAc,GAAG,GAAG,GAAG,MAAM,MAAM,GAAG,GAAG;AACtD,gBAAc,UAAU,MAAM,KAAK,IAAI,CAAC;AACxC,SAAO,gBAAgB,GAAG,UAAU,eAAe,OAAO,cAAc;AAC1E;AAGA,IAAM,aAAa;AACnB,IAAM,kBAAkB;AACxB,IAAM,iBAAiB,GAAG,eAAe;AACzC,IAAM,gBAAgB;AACtB,IAAM,kBAAkB;AACxB,IAAM,mBAAmB;AAGzB,IAAM,wBAAwB;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAYA,SAAS,yBACP,MACA,WACA,QACoB;AACpB,QAAM,MAAM,SAAS,MAAM,SAAS,EAAE,WAAW,MAAM,GAAG;AAC1D,MAAI,QAAQ,MAAM,IAAI,WAAW,IAAI,EAAG,QAAO;AAE/C,QAAM,aAAaF,MAAK,MAAM,eAAe;AAC7C,QAAM,cACJD,YAAW,UAAU,KAAK,sBAAsB,KAAK,CAAC,SAASA,YAAWC,MAAK,MAAM,IAAI,CAAC,CAAC;AAC7F,MAAI,CAAC,YAAa,QAAO;AAEzB,QAAM,UAAU,IAAI,GAAG;AACvB,QAAM,WAAWD,YAAW,UAAU,IAAIG,cAAa,YAAY,MAAM,IAAI;AAC7E,QAAM,QAAQ,SAAS,MAAM,IAAI;AAEjC,MAAI,MAAM,KAAK,CAAC,SAAS,KAAK,KAAK,EAAE,QAAQ,OAAO,EAAE,MAAM,QAAQ,QAAQ,OAAO,EAAE,CAAC,GAAG;AACvF,WAAO;AAAA,EACT;AACA,MAAI,OAAQ,QAAO,iBAAiB,GAAG,4BAA4B,eAAe;AAElF,QAAM,YAAY,MAAM,UAAU,CAAC,SAAS,KAAK,KAAK,MAAM,gBAAgB;AAC5E,MAAI,cAAc,IAAI;AACpB,UAAM,SAAS,SAAS,KAAK,MAAM,KAAK,KAAK,GAAG,SAAS,QAAQ,QAAQ,EAAE,CAAC;AAAA;AAAA;AAC5E,kBAAc,YAAY,GAAG,MAAM,GAAG,gBAAgB;AAAA,EAAK,OAAO;AAAA,CAAI;AAAA,EACxE,OAAO;AAGL,QAAI,MAAM,YAAY;AACtB,WAAO,MAAM,MAAM,UAAU,MAAM,GAAG,GAAG,KAAK,EAAE,WAAW,GAAG,EAAG,QAAO;AACxE,UAAM,UAAU,CAAC,GAAG,MAAM,MAAM,YAAY,GAAG,GAAG,GAAG,OAAO,EAAE,KAAK;AACnE;AAAA,MACE;AAAA,MACA,CAAC,GAAG,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,GAAG,SAAS,GAAG,MAAM,MAAM,GAAG,CAAC,EAAE,KAAK,IAAI;AAAA,IAC/E;AAAA,EACF;AACA,SAAO,YAAY,GAAG,4BAA4B,eAAe;AACnE;AAkCA,SAAS,oBAAoB,MAAc,QAAqC;AAC9E,QAAM,OAAOF,MAAK,MAAM,UAAU;AAClC,QAAM,WAAWD,YAAW,IAAI,IAAIG,cAAa,MAAM,MAAM,IAAI;AAEjE,QAAM,UAAU,SACb,MAAM,IAAI,EACV,KAAK,CAAC,SAAS,KAAK,KAAK,EAAE,QAAQ,OAAO,EAAE,EAAE,QAAQ,OAAO,EAAE,MAAM,eAAe;AACvF,MAAI,QAAS,QAAO;AACpB,MAAI,OAAQ,QAAO,gBAAgB,cAAc,gBAAgB,UAAU;AAE3E,QAAM,SAAS,SAAS,KAAK,MAAM,KAAK,KAAK,GAAG,SAAS,QAAQ,QAAQ,EAAE,CAAC;AAAA;AAAA;AAC5E,gBAAc,MAAM,GAAG,MAAM,GAAG,aAAa;AAAA,EAAK,cAAc;AAAA,CAAI;AACpE,SAAO,WAAW,cAAc,gBAAgB,UAAU;AAC5D;AAEO,SAAS,oBAAoB,MAMvB;AACX,QAAM,SAAS,QAAQ,KAAK,MAAM;AAClC,SAAO;AAAA,IACL,uBAAuB,KAAK,MAAM,MAAM;AAAA,IACxC,0BAA0B,KAAK,MAAM,MAAM;AAAA,IAC3C,KAAK,oBAAoB,SACrB,SACA,yBAAyB,KAAK,MAAM,KAAK,iBAAiB,MAAM;AAAA,IACpE,KAAK,mBAAmB,OAAO,oBAAoB,KAAK,MAAM,MAAM,IAAI;AAAA,EAC1E,EAAE,OAAO,CAAC,YAA+B,YAAY,MAAS;AAChE;AAoCO,SAAS,qBAAqB,MAAoC;AACvE,QAAM,UAAgC,CAAC;AAKvC,MAAI,MAAuB,CAAC;AAC5B,QAAM,UAAUF,MAAK,MAAM,cAAc;AACzC,MAAID,YAAW,OAAO,GAAG;AACvB,QAAI;AACF,YAAM,KAAK,MAAMG,cAAa,SAAS,MAAM,CAAC;AAAA,IAChD,QAAQ;AACN,YAAM,CAAC;AAAA,IACT;AAAA,EACF;AAEA,QAAM,WAAWF,MAAK,MAAM,cAAc;AAC1C,QAAM,OAAOD,YAAW,QAAQ,IAAIG,cAAa,UAAU,MAAM,IAAI;AAErE,QAAM,SAAS,SAAS,SAAY,SAAY,kBAAkB,KAAK,MAAM,IAAI,CAAC,EAAE;AACpF,MAAI,WAAW,QAAW;AACxB,UAAM,aAAa,iBAAiB,GAAG;AACvC,YAAQ,KAAK;AAAA,MACX,MAAM,GAAG,cAAc,qBAAgB,YAAY;AAAA,MACnD,QACE,eAAe,SACX,aAAa,MAAM,qCAAqC,eAAe,sFAEvE,aAAa,MAAM,uDACf,eAAe;AAAA,IAE3B,CAAC;AAAA,EACH;AAKA,QAAM,SAAS,IAAI,MAAM,YAAY,YAAY;AACjD,MAAI,WAAW,QAAW;AACxB,YAAQ,KAAK;AAAA,MACX,MAAM,sCAAiC,YAAY;AAAA,MACnD,QACE,aAAa,MAAM,2IAEhB,cAAc;AAAA,IACrB,CAAC;AAAA,EACH;AAEA,QAAM,aAAaF,MAAK,MAAM,UAAU;AACxC,MAAID,YAAW,UAAU,GAAG;AAC1B,UAAM,UAAUG,cAAa,YAAY,MAAM,EAC5C,MAAM,IAAI,EACV,KAAK,CAAC,SAAS,KAAK,KAAK,EAAE,QAAQ,OAAO,EAAE,EAAE,QAAQ,OAAO,EAAE,MAAM,eAAe;AACvF,QAAI,SAAS;AACX,cAAQ,KAAK;AAAA,QACX,MAAM,GAAG,UAAU,WAAM,cAAc;AAAA,QACvC,QACE,qEACc,cAAc,uFACD,UAAU;AAAA,MAIzC,CAAC;AAAA,IACH;AAAA,EACF;AAEA,QAAM,MAAM,eAAe,EAAE;AAC7B,MAAI,SAAS,QAAW;AACtB,UAAM,SAAS,IAAI,OAAO,kBAAkB,IAAI,QAAQ,KAAK,KAAK,CAAC,cAAc,GAAG,EAAE,KAAK,IAAI;AAC/F,QAAI,QAAQ;AACV,YAAM,QAAQ,IAAI,OAAO,+BAA+B,GAAG,EAAE,KAAK,IAAI;AACtE,cAAQ,KAAK;AAAA,QACX,MAAM,GAAG,cAAc,WAAM,eAAe,KAAK,GAAG;AAAA,QACpD,QAAQ,QACJ,GAAG,GAAG,4XAKN,GAAG,GAAG;AAAA,MAEZ,CAAC;AAAA,IACH;AAAA,EACF;AAEA,SAAO;AACT;","names":["existsSync","readFileSync","dirname","join","existsSync","join","dirname","readFileSync"]}
|
|
File without changes
|