@hublo/sentinel 1.4.3 → 1.5.0-alpha.1

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.
@@ -15,12 +15,10 @@ import {
15
15
  palette,
16
16
  registerAdapters,
17
17
  resolve
18
- } from "../chunk-DYK6UEMO.js";
18
+ } from "../chunk-AKAAYAB2.js";
19
19
  import {
20
20
  resolveContext
21
- } from "../chunk-TDNPPNGK.js";
22
- import "../chunk-O7REVMOC.js";
23
- import "../chunk-QXFCZON7.js";
21
+ } from "../chunk-E3MYVYEZ.js";
24
22
  import {
25
23
  ensureWorkspacePrep,
26
24
  findWorkspaceRoot,
@@ -28,6 +26,9 @@ import {
28
26
  readOwnVersion,
29
27
  readProjectPackageJson
30
28
  } from "../chunk-KMKQDGI6.js";
29
+ import "../chunk-3VJGWCAG.js";
30
+ import "../chunk-O7REVMOC.js";
31
+ import "../chunk-QXFCZON7.js";
31
32
 
32
33
  // bin/sentinel.ts
33
34
  import { program } from "commander";
@@ -145,7 +146,7 @@ async function runValidate(ctx) {
145
146
  );
146
147
  }
147
148
  if (!ctx.targets.includes("test")) return 1;
148
- const { validateModuleTests } = await import("../validate-3QMCX2HW.js");
149
+ const { validateModuleTests } = await import("../validate-STUK2AGG.js");
149
150
  const outcomes = modules.flatMap(
150
151
  (module) => validateModuleTests(module.name, module.root)
151
152
  );
@@ -210,7 +211,8 @@ async function analyse(params) {
210
211
  ci: params.ci,
211
212
  fix: params.fix,
212
213
  maxDiagnostics: params.maxDiagnostics,
213
- toolArgs: params.toolArgs
214
+ toolArgs: params.toolArgs,
215
+ output: params.output
214
216
  };
215
217
  try {
216
218
  let adoption;
@@ -485,6 +487,7 @@ async function runVerb(ctx) {
485
487
  ci: ctx.ci,
486
488
  fix: ctx.fix,
487
489
  toolArgs: ctx.toolArgs,
490
+ output: ctx.output,
488
491
  // Per-module progress is a HUMAN affordance: it tells someone staring at a slow sweep
489
492
  // that it is alive. On this monorepo's 441 projects it is 441 lines that bury the four
490
493
  // that matter, and it is pure noise in a captured log. So emit it only for an
@@ -599,6 +602,9 @@ program.name("sentinel").description("One CLI that guards code health: presets,
599
602
  "--module <name>",
600
603
  "from the workspace root: scope to one module (omit = all; inside a module dir, drop this)"
601
604
  ).option("--preset <name>", `the stack preset to apply (${PRESET_NAMES.join(", ")})`).option("--flavour <name>", "deprecated: use --preset").option("--runner <tool>", "override the default runner (e.g. eslint, biome)").option("--ci", "CI mode: from the root, only the affected modules; non-zero exit on failure").option("--fix", "auto-fix where applicable").option("--dry-run", "preview the changes without writing (--init)").option("--json", "machine-readable output, for every verb").option(
605
+ "--output <shape>",
606
+ "output shape for a role that writes reports (console, json, html); console by default"
607
+ ).option(
602
608
  "--max-diagnostics <n>",
603
609
  "cap the diagnostics embedded per module in --report (0 = no cap)",
604
610
  "100"
@@ -724,6 +730,9 @@ async function main() {
724
730
  ci: Boolean(opts.ci),
725
731
  fix: Boolean(opts.fix),
726
732
  json: Boolean(opts.json),
733
+ // `--json` stays the engine-wide machine-readable flag, so it also selects the json shape for
734
+ // a role that writes reports: two ways to ask for the same thing would be a trap.
735
+ output: typeof opts.output === "string" ? opts.output : opts.json ? "json" : void 0,
727
736
  dryRun: Boolean(opts.dryRun),
728
737
  fail: (message) => program.error(message)
729
738
  };
@@ -0,0 +1,83 @@
1
+ // src/shared/deep-merge.ts
2
+ function isPlainObject(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+
6
+ // src/roles/static-analysis/presets/shared.json
7
+ var shared_default = {
8
+ entry: ["**/*.integration-spec.ts", "**/*.steps.ts"],
9
+ ignore: [
10
+ "**/node_modules/**",
11
+ "**/dist/**",
12
+ "**/build/**",
13
+ "**/coverage/**",
14
+ "**/.next/**",
15
+ "**/static-analysis-report/**",
16
+ "**/__mocks__/**",
17
+ "**/mocks/**",
18
+ "**/_mock_/**",
19
+ "**/**Mock**/**",
20
+ "**/__fixtures__/**",
21
+ "**/fixtures/**",
22
+ "**/public/**",
23
+ "**/.husky/**",
24
+ "**/.vscode/**",
25
+ "**/.idea/**",
26
+ "**/.github/**",
27
+ "**/*.d.ts",
28
+ "**/*.gen.ts",
29
+ "**/*.generated.ts"
30
+ ],
31
+ languages: ["javascript", "typescript", "jsx", "tsx", "css", "scss"],
32
+ journeys: {},
33
+ duplication: { minTokens: 50, minLines: 5, kinds: ["exact"] },
34
+ deadCode: {
35
+ minConfidence: 60,
36
+ categories: ["unused-file", "unused-export", "unused-symbol", "unused-import"]
37
+ },
38
+ thresholds: { duplicatedLinesPercent: 5, deadCodePercent: 5 },
39
+ tools: { jscpd: {} }
40
+ };
41
+
42
+ // src/roles/static-analysis/preset-data.ts
43
+ var REPORT_DIR = "static-analysis-report";
44
+ var defaults = shared_default;
45
+
46
+ // src/roles/static-analysis/configs/define.ts
47
+ function mergeInto(base, patch) {
48
+ if (!isPlainObject(patch)) return base;
49
+ if (!isPlainObject(base)) return patch;
50
+ const out = { ...base };
51
+ for (const [key, value] of Object.entries(patch)) {
52
+ out[key] = isPlainObject(value) ? mergeInto(out[key], value) : value;
53
+ }
54
+ return out;
55
+ }
56
+ function leafPaths(patch, prefix = "") {
57
+ if (!isPlainObject(patch)) return prefix ? [prefix] : [];
58
+ return Object.entries(patch).flatMap(
59
+ ([key, value]) => leafPaths(value, prefix ? `${prefix}.${key}` : key)
60
+ );
61
+ }
62
+ function unique(values) {
63
+ return [...new Set(values)];
64
+ }
65
+ function defineStaticAnalysis(input = {}) {
66
+ const declared = {
67
+ ...defaults,
68
+ entry: unique([...defaults.entry, ...input.entry ?? []]),
69
+ journeys: { ...defaults.journeys, ...input.journeys ?? {} }
70
+ };
71
+ return {
72
+ ...mergeInto(declared, input.overrides),
73
+ root: input.root ?? null,
74
+ moved: leafPaths(input.overrides).sort()
75
+ };
76
+ }
77
+
78
+ export {
79
+ isPlainObject,
80
+ REPORT_DIR,
81
+ defineStaticAnalysis
82
+ };
83
+ //# sourceMappingURL=chunk-3VJGWCAG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/shared/deep-merge.ts","../src/roles/static-analysis/presets/shared.json","../src/roles/static-analysis/preset-data.ts","../src/roles/static-analysis/configs/define.ts"],"sourcesContent":["/**\n * Is this a plain JSON object?\n *\n * The file is named for a `deepMerge` that no longer exists. It was written when the engine\n * merged JSON in memory and wrote the result; that approach was replaced by editing the\n * document IN PLACE with jsonc-parser, so a project's comments, key order and formatting\n * survive a `merge-json` op. Nothing has called `deepMerge` since, except its own recursion,\n * so it went.\n *\n * What remains is the predicate the in-place merge still needs, to decide whether a value is\n * a branch to walk into or a leaf to set.\n */\n\n/** True for a mergeable plain object (not null, not an array). */\nexport function isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n","{\n \"entry\": [\"**/*.integration-spec.ts\", \"**/*.steps.ts\"],\n \"ignore\": [\n \"**/node_modules/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/coverage/**\",\n \"**/.next/**\",\n \"**/static-analysis-report/**\",\n \"**/__mocks__/**\",\n \"**/mocks/**\",\n \"**/_mock_/**\",\n \"**/**Mock**/**\",\n \"**/__fixtures__/**\",\n \"**/fixtures/**\",\n \"**/public/**\",\n \"**/.husky/**\",\n \"**/.vscode/**\",\n \"**/.idea/**\",\n \"**/.github/**\",\n \"**/*.d.ts\",\n \"**/*.gen.ts\",\n \"**/*.generated.ts\"\n ],\n \"languages\": [\"javascript\", \"typescript\", \"jsx\", \"tsx\", \"css\", \"scss\"],\n \"journeys\": {},\n \"duplication\": { \"minTokens\": 50, \"minLines\": 5, \"kinds\": [\"exact\"] },\n \"deadCode\": {\n \"minConfidence\": 60,\n \"categories\": [\"unused-file\", \"unused-export\", \"unused-symbol\", \"unused-import\"]\n },\n \"thresholds\": { \"duplicatedLinesPercent\": 5, \"deadCodePercent\": 5 },\n \"tools\": { \"jscpd\": {} }\n}\n","/**\n * The static analysis preset: the role's settings, as data.\n *\n * One shared layer and no per-stack preset yet, because nothing measured so far varies by stack:\n * duplication, dead code and complexity are the same questions in Nest and in React. The split\n * that is already foreseen is `LCOM4`, which is meaningless on a functional front end and real on\n * nest's 16 568 classes; when it arrives it becomes `nest.json` beside this layer, which is why\n * the values live here as JSON rather than inline in the code.\n *\n * ## Why these values are the role's and not the module's\n *\n * A team declares FACTS about its own domain (which journeys exist, which files are entry points);\n * it does not declare the values a number is judged by. Two files with the same measurement\n * getting different verdicts in two modules would make every figure incomparable, and the first\n * thing this role has to be able to say is that a number means the same thing everywhere.\n *\n * That is a default, not a wall: a module CAN move any of them (see `configs/define.ts`), and when\n * it does the report prints `(module)` next to the value. The seam exists from the first commit\n * because retrofitting one later means rewriting every module's file.\n *\n * Both thresholds are a STARTING POINT and will be calibrated from the distribution the first full\n * run produces, the same commitment a preset rule carries in `CONTRIBUTING.md`. For scale,\n * sentinel's own package measures 1.4% duplicated lines and 1.3% dead code.\n *\n * ## Where each value comes from, so nobody has to guess later\n *\n * - `entry` is the single most consequential line in the file. Measured on\n * `apps/nest/microservices/network` with jscpd 5.4.0: with nothing declared the dead-code pass\n * reports **33.4%** of lines dead (79 657 lines, 232 files, 13 entry points); with these two\n * patterns declared it reports **1.0%** (2 462 lines, 207 entry points). The cause is a naming\n * convention and not a bug: our integration tests are `foo.integration-spec.ts` with a HYPHEN,\n * so they do not end in `.spec.ts` and the tool's own test-file detection never sees them, and\n * the 115 `.steps.ts` BDD definitions are tests by no convention it knows. A module ADDS to\n * this list rather than replacing it, precisely so no team can lose these two by declaring a\n * third.\n *\n * - `languages` is this role's name for what the tool calls `format`, a word that already names\n * another sentinel role and means something else there. Stating it has a measured cost either\n * way: on `apps/front/console`, naming `typescript` alone scans 300 of 523 files because `.tsx`\n * is a SEPARATE language to the tool, naming all four scans 522 (the one left out being\n * `styles.css`), and naming nothing scans every one of its 224 languages, json and markdown\n * included. Stylesheets are in deliberately: measured on `libs/front` they duplicate at 9.64%\n * against 3.50% for the code, a signal that only survives because the report is broken down per\n * language.\n *\n * - `duplication.minTokens` and `minLines` are the detection window, and the window decides what\n * is INVISIBLE: a fragment shorter than it is not reported at all, so a reader comparing two\n * runs has to know both values. They are the tool's own defaults, written out rather than\n * inherited for that reason.\n *\n * - `duplication.kinds` is `exact` alone, and that is a correctness constraint rather than\n * caution. Measured: with a second kind enabled, `duplicatedLines` adds up overlapping clones.\n * Two 9-line files reported 13 duplicated lines out of 18, 72%, because an `exact` clone of\n * lines 1-4 and a `similar` clone of lines 1-9 containing it were both counted; the real extent\n * is 9 of 18, 50%. The tool's own answer is to cap the display at 100%, which does not make a\n * threshold meaningful. Opening another kind therefore requires computing the share ourselves\n * from the UNION of the line ranges.\n *\n * - `deadCode.minConfidence` is the tool's own default of 60. It scores every finding and names\n * what lowered it (a file calling `eval`, an unrecognised decorator, a name appearing in a\n * string literal, a file in the scan that did not parse). Measured on sentinel: 60 yields 20\n * files, 10 exports and 22 symbols; 90 yields 18 files; 100 yields nothing.\n *\n * - `deadCode.categories` leaves out `unused-member` deliberately: without type information\n * `x.render()` could reach any `render` in the project, which the tool itself calls its least\n * certain rule.\n *\n * - `ignore` excludes generated, vendored and fixture code, which duplicates by nature and buries\n * the clones a developer can act on. It is load-bearing rather than tidy: measured on this\n * package, scanning the module root with no ignore list reports 18 186 files and 32 153 clones,\n * **31 969** of them touching `node_modules`; with the list, 408 files and zero.\n *\n * - `tools.jscpd` is passed to the tool verbatim, as an escape hatch for a flag this role never\n * modelled. Everything above uses OUR key names, translated on the way out, so that replacing\n * the tool one day does not oblige a single module to edit its config file; this block is where\n * that guarantee stops, and it is empty by design.\n */\nimport sharedData from './presets/shared.json'\n\n/**\n * Where the json and html reports are written, relative to the module.\n *\n * Named for the role rather than left on the tool's default `report/`, and added to `.gitignore`\n * by `--init`. Both halves matter: the format role once shipped a `lint/` directory that nothing\n * ignored, and every adopting module grew an untracked directory nobody could explain.\n */\nexport const REPORT_DIR = 'static-analysis-report'\n\nexport type StaticDefaults = {\n entry: string[]\n ignore: string[]\n languages: string[]\n journeys: Record<string, string[]>\n duplication: { minTokens: number; minLines: number; kinds: string[] }\n deadCode: { minConfidence: number; categories: string[] }\n thresholds: { duplicatedLinesPercent: number; deadCodePercent: number }\n tools: { jscpd: Record<string, unknown> }\n}\n\n/** The role's settings. Read-only here; `defineStaticAnalysis` resolves a module against them. */\nexport const defaults: StaticDefaults = sharedData as StaticDefaults\n","/**\n * `defineStaticAnalysis`: what a module imports to declare its own static analysis settings.\n *\n * ```js\n * // static-analysis.config.mjs\n * import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'\n *\n * export default defineStaticAnalysis({\n * root: import.meta.dirname,\n * entry: ['**\\/*.steps.ts'],\n * journeys: { planning: ['src/planning/**'] },\n * })\n * ```\n *\n * ## Why a function and not `{ ...baseConfig, ... }`\n *\n * A spread is SHALLOW, so a module writing `deadCode: { minConfidence: 80 }` would replace the\n * whole block and silently lose `categories`. And the flattening happens in the module's file,\n * before this package ever sees the object, so nothing here could tell \"set one key, keep the\n * rest\" from \"set one key, drop the rest\". A function receives the module's INTENT, the keys it\n * actually passed, and merges them here. Second reason: the defaults are never copied into a\n * module, so changing one reaches every module at the next release instead of leaving 108 stale\n * copies behind.\n *\n * Same shape as `reactTestConfig` in the test role, for the same reasons.\n *\n * ## Two semantics, one obvious and one deliberate\n *\n * `entry` and `journeys` are FACTS a team declares, and they ADD to what this role ships: no\n * module can lose `**\\/*.integration-spec.ts` by declaring a third pattern, which matters\n * because that one pattern is the difference between 33.4% and 1.0% of dead code on a real\n * service (see `defaults.ts`).\n *\n * `overrides` REPLACES, including those lists. It is the seam for the day a team has a real\n * reason, it is not documented anywhere a developer would meet it, and every path it sets is\n * recorded in `moved` so the report can print `(module)` next to the value. Without that trace\n * two modules stop being comparable and nobody knows.\n */\nimport { isPlainObject } from '../../../shared/deep-merge.js'\nimport { defaults, type StaticDefaults } from '../preset-data.js'\n\ntype DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }\n\n/** What a module's config file passes. Everything is optional, including the file itself. */\nexport interface StaticAnalysisInput {\n /** The module's own directory, normally `import.meta.dirname`. */\n root?: string\n /** Files that run although nothing imports them. ADDED to the shipped patterns. */\n entry?: string[]\n /** Named journeys, as `{ name: [globs] }`. MERGED by name with any shipped ones. */\n journeys?: Record<string, string[]>\n /** The seam. Replaces whatever it names, and every path it sets lands in `moved`. */\n overrides?: DeepPartial<StaticDefaults>\n}\n\nexport interface ResolvedStaticConfig extends StaticDefaults {\n /** The module's directory, or null when its config file did not say. */\n root: string | null\n /**\n * Dotted paths the module set through `overrides`, so the report can mark them. Sorted, so\n * two runs on the same config produce the same report.\n */\n moved: string[]\n}\n\n/**\n * Merge `patch` into `base`, recursively, patch winning.\n *\n * Not the `deepMerge` this repo once had: that one existed for WRITING a module's json and was\n * replaced by an in-place jsonc edit, which is why only `isPlainObject` survived. This is the\n * read side, which has no file to preserve and only has to resolve two objects into one.\n *\n * Arrays are replaced, never concatenated. Additive behaviour is a decision about a specific\n * key, which `defineStaticAnalysis` makes explicitly for `entry`, rather than a property of the\n * merge: a merge that silently concatenates cannot express \"replace this list\", and a team\n * reaching for `overrides` is asking for exactly that.\n */\nfunction mergeInto<T>(base: T, patch: unknown): T {\n if (!isPlainObject(patch)) return base\n if (!isPlainObject(base)) return patch as T\n const out: Record<string, unknown> = { ...base }\n for (const [key, value] of Object.entries(patch)) {\n out[key] = isPlainObject(value) ? mergeInto(out[key], value) : value\n }\n return out as T\n}\n\n/** Every leaf path in `patch`, dotted, so a reader can be told what the module decided. */\nfunction leafPaths(patch: unknown, prefix = ''): string[] {\n if (!isPlainObject(patch)) return prefix ? [prefix] : []\n return Object.entries(patch).flatMap(([key, value]) =>\n leafPaths(value, prefix ? `${prefix}.${key}` : key),\n )\n}\n\n/** The same list with duplicates dropped, keeping the first occurrence's order. */\nfunction unique(values: string[]): string[] {\n return [...new Set(values)]\n}\n\n/** Resolve a module's declaration against this role's defaults. */\nexport function defineStaticAnalysis(input: StaticAnalysisInput = {}): ResolvedStaticConfig {\n const declared: StaticDefaults = {\n ...defaults,\n entry: unique([...defaults.entry, ...(input.entry ?? [])]),\n journeys: { ...defaults.journeys, ...(input.journeys ?? {}) },\n }\n\n return {\n ...mergeInto(declared, input.overrides),\n root: input.root ?? null,\n moved: leafPaths(input.overrides).sort(),\n }\n}\n"],"mappings":";AAcO,SAAS,cAAc,OAAkD;AAC9E,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;;;AChBA;AAAA,EACE,OAAS,CAAC,4BAA4B,eAAe;AAAA,EACrD,QAAU;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,WAAa,CAAC,cAAc,cAAc,OAAO,OAAO,OAAO,MAAM;AAAA,EACrE,UAAY,CAAC;AAAA,EACb,aAAe,EAAE,WAAa,IAAI,UAAY,GAAG,OAAS,CAAC,OAAO,EAAE;AAAA,EACpE,UAAY;AAAA,IACV,eAAiB;AAAA,IACjB,YAAc,CAAC,eAAe,iBAAiB,iBAAiB,eAAe;AAAA,EACjF;AAAA,EACA,YAAc,EAAE,wBAA0B,GAAG,iBAAmB,EAAE;AAAA,EAClE,OAAS,EAAE,OAAS,CAAC,EAAE;AACzB;;;ACqDO,IAAM,aAAa;AAcnB,IAAM,WAA2B;;;ACvBxC,SAAS,UAAa,MAAS,OAAmB;AAChD,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO;AAClC,MAAI,CAAC,cAAc,IAAI,EAAG,QAAO;AACjC,QAAM,MAA+B,EAAE,GAAG,KAAK;AAC/C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,GAAG,IAAI,cAAc,KAAK,IAAI,UAAU,IAAI,GAAG,GAAG,KAAK,IAAI;AAAA,EACjE;AACA,SAAO;AACT;AAGA,SAAS,UAAU,OAAgB,SAAS,IAAc;AACxD,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO,SAAS,CAAC,MAAM,IAAI,CAAC;AACvD,SAAO,OAAO,QAAQ,KAAK,EAAE;AAAA,IAAQ,CAAC,CAAC,KAAK,KAAK,MAC/C,UAAU,OAAO,SAAS,GAAG,MAAM,IAAI,GAAG,KAAK,GAAG;AAAA,EACpD;AACF;AAGA,SAAS,OAAO,QAA4B;AAC1C,SAAO,CAAC,GAAG,IAAI,IAAI,MAAM,CAAC;AAC5B;AAGO,SAAS,qBAAqB,QAA6B,CAAC,GAAyB;AAC1F,QAAM,WAA2B;AAAA,IAC/B,GAAG;AAAA,IACH,OAAO,OAAO,CAAC,GAAG,SAAS,OAAO,GAAI,MAAM,SAAS,CAAC,CAAE,CAAC;AAAA,IACzD,UAAU,EAAE,GAAG,SAAS,UAAU,GAAI,MAAM,YAAY,CAAC,EAAG;AAAA,EAC9D;AAEA,SAAO;AAAA,IACL,GAAG,UAAU,UAAU,MAAM,SAAS;AAAA,IACtC,MAAM,MAAM,QAAQ;AAAA,IACpB,OAAO,UAAU,MAAM,SAAS,EAAE,KAAK;AAAA,EACzC;AACF;","names":[]}