@hublo/sentinel 1.5.0-alpha.35 → 1.5.0-alpha.37

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 CHANGED
@@ -171,20 +171,21 @@ 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
+ - [`docs/static-analysis-adoption.md`](docs/static-analysis-adoption.md) — running the static analysis role on a module: the two steps, the four 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.
175
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`.
176
176
 
177
177
  ## Architecture: `target → runner → preset`
178
178
 
179
179
  Every check is described by three layers:
180
180
 
181
- | Layer | Flag | What it is | Examples |
182
- | -------------------- | --------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
183
- | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `dev` `test` `static-analysis` `runtime-analysis` |
184
- | **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable | lint: `eslint`/`biome`/`oxlint` · types: `tsc`/`tsgo` · build: `vite` · test: `vitest` |
185
- | **preset** (preset) | detected / `--preset` | the _variant_ per stack (strict by default) | `react` `nest` `node` `svelte` (svelte: lint + format; no tsconfig preset yet, so the TypeScript role stands aside on those modules and the other two adopt) |
181
+ | Layer | Flag | What it is | Examples |
182
+ | -------------------- | --------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
183
+ | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `dev` `test` `static-analysis` `runtime-analysis` |
184
+ | **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable | lint: `eslint`/`biome`/`oxlint` · types: `tsc`/`tsgo` · build: `vite` · test: `vitest` |
185
+ | **shape** (output) | `--output=<shape>` | what a reporting role WRITES, not what it measures | `console` (default, writes nothing) `html` `json` `markdown`; roles that do not report ignore it |
186
+ | **preset** (preset) | detected / `--preset` | the _variant_ per stack (strict by default) | `react` `nest` `node` `svelte` (svelte: lint + format; no tsconfig preset yet, so the TypeScript role stands aside on those modules and the other two adopt) |
186
187
 
187
- A run is `target × runner × preset`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir.
188
+ A run is `target × runner × preset`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir. The shape is not part of that identity: it decides what the run WRITES, not what it does.
188
189
 
189
190
  - `--runner` is an **optional override on a central default**. `sentinel --lint` uses the configured default runner, so swapping a tool globally is a one-place change; `--runner=biome` overrides for a single run (great for benchmarking eslint vs biome vs oxlint, and for gradual migration).
190
191
  - The **preset is detected** from the module's dependencies (deterministic: a framework dep → its preset, else `node`), and **`--preset` overrides it**. Detection can be wrong where deps are hoisted at the repo root (it returns `node`), so pass `--preset` for `--init` (that is where the preset is chosen). `--run`/`--report` don't depend on it, they run the tool on the committed config.
@@ -15,10 +15,10 @@ import {
15
15
  palette,
16
16
  registerAdapters,
17
17
  resolve
18
- } from "../chunk-JWOLL5JK.js";
18
+ } from "../chunk-HLRY2VHZ.js";
19
19
  import {
20
20
  resolveContext
21
- } from "../chunk-ZFNBW6S3.js";
21
+ } from "../chunk-43HQ6BVJ.js";
22
22
  import {
23
23
  ensureWorkspacePrep,
24
24
  findWorkspaceRoot,
@@ -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-7G2ORUZD.js");
151
+ const { validateModuleTests } = await import("../validate-FRMENKHM.js");
152
152
  const outcomes = modules.flatMap(
153
153
  (module) => validateModuleTests(module.name, module.root)
154
154
  );
@@ -2160,13 +2160,26 @@ function namespaceBindings(program) {
2160
2160
  import { readFileSync as readFileSync9, statSync } from "node:fs";
2161
2161
  import { dirname as dirname2, join as join10, resolve as resolve4 } from "node:path";
2162
2162
  import { parseSync as parseSync4 } from "oxc-parser";
2163
- var EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
2163
+
2164
+ // src/shared/module-resolution.ts
2165
+ var RESOLVABLE_EXTENSIONS = [
2166
+ ".ts",
2167
+ ".tsx",
2168
+ ".mts",
2169
+ ".cts",
2170
+ ".js",
2171
+ ".jsx",
2172
+ ".mjs",
2173
+ ".cjs"
2174
+ ];
2175
+
2176
+ // src/roles/test/codemod/static-import-for-require.ts
2164
2177
  function resolveRelative(fromFile, specifier) {
2165
2178
  const base = resolve4(dirname2(fromFile), specifier);
2166
2179
  const candidates = [
2167
2180
  base,
2168
- ...EXTENSIONS.map((extension) => `${base}${extension}`),
2169
- ...EXTENSIONS.map((extension) => join10(base, `index${extension}`))
2181
+ ...RESOLVABLE_EXTENSIONS.map((extension) => `${base}${extension}`),
2182
+ ...RESOLVABLE_EXTENSIONS.map((extension) => join10(base, `index${extension}`))
2170
2183
  ];
2171
2184
  for (const candidate of candidates) {
2172
2185
  try {
@@ -3537,7 +3550,7 @@ function resolveContext(cwd, opts) {
3537
3550
  // src/roles/test/codemod/workspace-specifier.ts
3538
3551
  import { existsSync as existsSync13, readFileSync as readFileSync13 } from "node:fs";
3539
3552
  import { join as join15, relative as relative2, resolve as resolve6 } from "node:path";
3540
- var EXTENSIONS2 = ["", ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", "/index.ts", "/index.js"];
3553
+ var EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", "/index.ts", "/index.js"];
3541
3554
  function pathEntries(workspaceRoot) {
3542
3555
  const file = join15(workspaceRoot, "tsconfig.base.json");
3543
3556
  if (!existsSync13(file)) return [];
@@ -3567,7 +3580,7 @@ function workspaceSpecifier(workspaceRoot, file) {
3567
3580
  const absolute = resolve6(file);
3568
3581
  for (const entry of pathEntries(workspaceRoot)) {
3569
3582
  if (!entry.wildcard) {
3570
- if (EXTENSIONS2.some((extension) => `${entry.target}${extension}` === absolute)) {
3583
+ if (EXTENSIONS.some((extension) => `${entry.target}${extension}` === absolute)) {
3571
3584
  return entry.key;
3572
3585
  }
3573
3586
  continue;
@@ -3712,14 +3725,13 @@ function rewriteMockFactoryGenerics(source, program) {
3712
3725
  import { readFileSync as readFileSync14, statSync as statSync3 } from "node:fs";
3713
3726
  import { dirname as dirname7, join as join16, relative as relative3, resolve as resolve8 } from "node:path";
3714
3727
  import { parseSync as parseSync7 } from "oxc-parser";
3715
- var EXTENSIONS3 = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
3716
3728
  function resolveRelative2(fromFile, specifier) {
3717
3729
  if (!specifier.startsWith(".")) return void 0;
3718
3730
  const base = resolve8(dirname7(fromFile), specifier);
3719
3731
  const candidates = [
3720
3732
  base,
3721
- ...EXTENSIONS3.map((extension) => `${base}${extension}`),
3722
- ...EXTENSIONS3.map((extension) => join16(base, `index${extension}`))
3733
+ ...RESOLVABLE_EXTENSIONS.map((extension) => `${base}${extension}`),
3734
+ ...RESOLVABLE_EXTENSIONS.map((extension) => join16(base, `index${extension}`))
3723
3735
  ];
3724
3736
  for (const candidate of candidates) {
3725
3737
  try {
@@ -4309,7 +4321,7 @@ var THE_SHIM_CANNOT_ANSWER = [/\brequire\s*\(/, /\bjest\.(?:fn|Mock|SpyInstance)
4309
4321
  var SKIPPED = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage", ".git", ".vite"]);
4310
4322
  var TEST_FILE2 = /[.-](spec|test)\.[cm]?[jt]sx?$/;
4311
4323
  var SOURCE_FILE = /\.[cm]?[jt]sx?$/;
4312
- var EXTENSIONS4 = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"];
4324
+ var EXTENSIONS2 = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"];
4313
4325
  function relativeSpecifiers2(source) {
4314
4326
  const found = [];
4315
4327
  const patterns = [
@@ -4326,10 +4338,10 @@ function relativeSpecifiers2(source) {
4326
4338
  function resolveSpecifier(fromFile, specifier) {
4327
4339
  const base = resolve9(dirname9(fromFile), specifier);
4328
4340
  if (existsSync14(base) && statSync4(base).isFile()) return base;
4329
- for (const extension of EXTENSIONS4) {
4341
+ for (const extension of EXTENSIONS2) {
4330
4342
  if (existsSync14(`${base}${extension}`)) return `${base}${extension}`;
4331
4343
  }
4332
- for (const extension of EXTENSIONS4) {
4344
+ for (const extension of EXTENSIONS2) {
4333
4345
  const index = join17(base, `index${extension}`);
4334
4346
  if (existsSync14(index)) return index;
4335
4347
  }
@@ -6574,6 +6586,7 @@ export {
6574
6586
  oxlintSearchPath,
6575
6587
  walk,
6576
6588
  writtenFileFormatter,
6589
+ RESOLVABLE_EXTENSIONS,
6577
6590
  readTestAdoption,
6578
6591
  resolveContext,
6579
6592
  moduleEnvironment,
@@ -13,6 +13,7 @@ import {
13
13
  PERMITTED_LOCAL_KEYS,
14
14
  PRETTIER_CONFIG_FILES,
15
15
  PROVENANCE_KEY,
16
+ RESOLVABLE_EXTENSIONS,
16
17
  VitestAdapter,
17
18
  adoptedPackageName,
18
19
  binFromOwnInstall,
@@ -54,7 +55,7 @@ import {
54
55
  splitToolArgs,
55
56
  walk,
56
57
  writtenFileFormatter
57
- } from "./chunk-ZFNBW6S3.js";
58
+ } from "./chunk-43HQ6BVJ.js";
58
59
  import {
59
60
  DEFAULT_MAX_DIAGNOSTICS,
60
61
  WORKSPACE_ROOT_MARKER,
@@ -5691,10 +5692,19 @@ async function loadStaticConfig(cwd) {
5691
5692
  return {
5692
5693
  config: defineStaticAnalysis(),
5693
5694
  from: null,
5694
- unreadable: `${STATIC_CONFIG_FILE}: ${error instanceof Error ? error.message : String(error)}`
5695
+ unreadable: `${STATIC_CONFIG_FILE}: ${explainUnreadableConfig(error)}`
5695
5696
  };
5696
5697
  }
5697
5698
  }
5699
+ function explainUnreadableConfig(error) {
5700
+ const message = error instanceof Error ? error.message : String(error);
5701
+ const code = error.code;
5702
+ if (code !== "ERR_PACKAGE_PATH_NOT_EXPORTED" || !message.includes("@hublo/sentinel")) {
5703
+ return message;
5704
+ }
5705
+ return `${message}
5706
+ The @hublo/sentinel installed in this module does not ship the static-analysis role yet. Bump it to a release that does, then run this again. Until then the role runs on its defaults and this file is ignored, which is why it is reported rather than silently skipped.`;
5707
+ }
5698
5708
  function adopt(exported) {
5699
5709
  if (exported === null || typeof exported !== "object") return defineStaticAnalysis();
5700
5710
  if (Array.isArray(exported.moved)) {
@@ -6018,7 +6028,137 @@ function unreadableNote(coverage) {
6018
6028
  import { readFileSync as readFileSync17 } from "node:fs";
6019
6029
  import { join as join23 } from "node:path";
6020
6030
 
6021
- // src/roles/static-analysis/help/bundle.ts
6031
+ // src/roles/static-analysis/maintainability/size-rules.ts
6032
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
6033
+ import { tmpdir as tmpdir2 } from "node:os";
6034
+ import { join as join22 } from "node:path";
6035
+ var GAUGE_THRESHOLDS = {
6036
+ // McCabe 1976 says 10, our p90 says 11. The only indicator where two independent candidates agree
6037
+ // within one unit, and 94.8% of our functions already sit in the 1-10 band SEI calls low risk.
6038
+ complexity: 10,
6039
+ // IfSQ SP-2, "few people can understand nesting to more than 3 levels". Our p90 agrees at 3.
6040
+ "max-depth": 3,
6041
+ // No research anywhere for a FILE's length: IfSQ's three citations (500, 100-150, 143) are all
6042
+ // about routines. So our p90 of 157, rounded down.
6043
+ "max-lines": 150,
6044
+ // Our p90. IfSQ SP-1 says 150 and is discarded for being more permissive than our own code.
6045
+ "max-lines-per-function": 109,
6046
+ // No research. Our p90 is 15, the rule's default is 10, so the default stays as the stricter one.
6047
+ "max-statements": 10,
6048
+ // No research, and none needed: beyond three parameters the fix is an object or currying, so this
6049
+ // is a design boundary and not a dial. Our p90 of 4 is the looser candidate and loses.
6050
+ "max-params": 3,
6051
+ // Same nesting family as `max-depth`, no citation of its own. Our p90 agrees at 3.
6052
+ "max-nested-callbacks": 3,
6053
+ // 1 is the rule's floor, there is no stricter value to pick.
6054
+ "max-classes-per-file": 1,
6055
+ // Cognitive complexity, Campbell 2018, measured by Sonar's own rule, loaded
6056
+ // through `plugins/sonar-cognitive.js`.
6057
+ // Sonar publishes 15 for S3776, and that is a vendor default rather than a research value: one of
6058
+ // their own engineers has said publicly that no formula supports it. Our p90 is 7, so 7 wins, and
6059
+ // it reports 342 of 8 083 files, 4.2%, which sits inside the band the eight above occupy.
6060
+ "cognitive-complexity": 7
6061
+ };
6062
+ var COGNITIVE = "cognitive-complexity";
6063
+ var cognitivePlugin = () => join22(ownPackageDir(), "plugins", "sonar-cognitive.js");
6064
+ var CATEGORIES = [
6065
+ "correctness",
6066
+ "suspicious",
6067
+ "pedantic",
6068
+ "perf",
6069
+ "style",
6070
+ "restriction",
6071
+ "nursery"
6072
+ ];
6073
+ function sizeRulesConfig() {
6074
+ return {
6075
+ plugins: [],
6076
+ categories: Object.fromEntries(CATEGORIES.map((category) => [category, "off"])),
6077
+ // Sonar's cognitive complexity, in the SAME run as the eight above rather than a second
6078
+ // invocation: the parse is the expensive part and it is already paid here. Measured over the
6079
+ // whole bench, 8 083 files, the rule adds 2.4 s to a pass that was going to read them anyway.
6080
+ jsPlugins: [cognitivePlugin()],
6081
+ // ⛔ `warn` and not `error`, for the reason `tool-config.ts` gives about jscpd's `threshold`:
6082
+ // at `error` oxlint exits 1 as soon as one file breaches, which is indistinguishable from the
6083
+ // binary failing to run. Measured on `libs/nest/auth`, same config otherwise: `error` exits 1,
6084
+ // `warn` exits 0, and both report the same 18 diagnostics. So the tool measures, this role
6085
+ // judges, and a non-zero exit means the tool failed.
6086
+ rules: Object.fromEntries(
6087
+ Object.entries(GAUGE_THRESHOLDS).map(([rule, threshold]) => [
6088
+ rule === COGNITIVE ? `sonar/${rule}` : rule,
6089
+ ["warn", threshold]
6090
+ ])
6091
+ )
6092
+ };
6093
+ }
6094
+ function sizeRulesRun(paths, ignore) {
6095
+ const dir = mkdtempSync2(join22(tmpdir2(), "sentinel-size-"));
6096
+ const configPath = join22(dir, "oxlintrc.json");
6097
+ writeFileSync4(configPath, `${JSON.stringify(sizeRulesConfig(), null, 2)}
6098
+ `, "utf8");
6099
+ return {
6100
+ // `--format json` because the console shape is rendered by this role from the figures: a
6101
+ // per-pass table from the tool would be a second voice saying the same numbers differently.
6102
+ // The exclusions are the SAME list the duplication product pass is given, so "the module's
6103
+ // code" means one thing across the whole report.
6104
+ args: [
6105
+ "-c",
6106
+ configPath,
6107
+ ...ignore.flatMap((pattern) => ["--ignore-pattern", pattern]),
6108
+ "--format",
6109
+ "json",
6110
+ ...paths
6111
+ ],
6112
+ dispose: () => rmSync4(dir, { force: true, recursive: true })
6113
+ };
6114
+ }
6115
+ function ruleOf(code) {
6116
+ return /\(([^)]+)\)\s*$/.exec(code)?.[1];
6117
+ }
6118
+ function valueOf(message) {
6119
+ 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
6120
+ // allowed". It parenthesises nothing, and the `0` after it is the threshold, not the value.
6121
+ /Complexity from (\d+) to/.exec(message);
6122
+ return Number(match?.[1] ?? 0);
6123
+ }
6124
+ function breachesOf(raw) {
6125
+ let parsed;
6126
+ try {
6127
+ parsed = JSON.parse(raw);
6128
+ } catch {
6129
+ return { breaches: [], diagnostics: 0, files: 0 };
6130
+ }
6131
+ const diagnostics = parsed.diagnostics ?? [];
6132
+ const worst = /* @__PURE__ */ new Map();
6133
+ for (const diagnostic of diagnostics) {
6134
+ const rule = ruleOf(diagnostic.code ?? "");
6135
+ const file = diagnostic.filename;
6136
+ if (!rule || !file || !(rule in GAUGE_THRESHOLDS)) continue;
6137
+ const value = valueOf(diagnostic.message ?? "");
6138
+ const line = diagnostic.labels?.[0]?.span?.line ?? 0;
6139
+ const key = `${file}\0${rule}`;
6140
+ const seen = worst.get(key);
6141
+ if (!seen || value > seen.value) worst.set(key, { file, rule, value, line });
6142
+ }
6143
+ return {
6144
+ breaches: [...worst.values()],
6145
+ diagnostics: diagnostics.length,
6146
+ files: parsed.number_of_files ?? 0
6147
+ };
6148
+ }
6149
+ function scanSizeRules(cwd, roots, ignore, binary, spawn) {
6150
+ if (!binary) return void 0;
6151
+ const run = sizeRulesRun(roots, ignore);
6152
+ try {
6153
+ const result = spawn(binary, run.args, cwd);
6154
+ if (result.status !== 0) return void 0;
6155
+ return breachesOf(result.stdout);
6156
+ } finally {
6157
+ run.dispose();
6158
+ }
6159
+ }
6160
+
6161
+ // src/roles/static-analysis/bundle/help.ts
6022
6162
  var WEB_DEV_TREE_SHAKING = "https://web.dev/articles/reduce-javascript-payloads-with-tree-shaking";
6023
6163
  var WEB_DEV_SCRIPT_EVALUATION = "https://web.dev/articles/script-evaluation-and-long-tasks";
6024
6164
  var V8_COST_2019 = "https://v8.dev/blog/cost-of-javascript-2019";
@@ -6121,7 +6261,7 @@ var context = {
6121
6261
  };
6122
6262
  var bundleHelp = { ...actionable, ...context };
6123
6263
 
6124
- // src/roles/static-analysis/help/metrics.ts
6264
+ // src/roles/static-analysis/report-help.ts
6125
6265
  var metricsHelp = {
6126
6266
  duplicatedLinesPercent: {
6127
6267
  label: "Duplicated lines",
@@ -6379,136 +6519,6 @@ function helpFor(key) {
6379
6519
  return metricsHelp[key];
6380
6520
  }
6381
6521
 
6382
- // src/roles/static-analysis/maintainability/size-rules.ts
6383
- import { mkdtempSync as mkdtempSync2, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
6384
- import { tmpdir as tmpdir2 } from "node:os";
6385
- import { join as join22 } from "node:path";
6386
- var GAUGE_THRESHOLDS = {
6387
- // McCabe 1976 says 10, our p90 says 11. The only indicator where two independent candidates agree
6388
- // within one unit, and 94.8% of our functions already sit in the 1-10 band SEI calls low risk.
6389
- complexity: 10,
6390
- // IfSQ SP-2, "few people can understand nesting to more than 3 levels". Our p90 agrees at 3.
6391
- "max-depth": 3,
6392
- // No research anywhere for a FILE's length: IfSQ's three citations (500, 100-150, 143) are all
6393
- // about routines. So our p90 of 157, rounded down.
6394
- "max-lines": 150,
6395
- // Our p90. IfSQ SP-1 says 150 and is discarded for being more permissive than our own code.
6396
- "max-lines-per-function": 109,
6397
- // No research. Our p90 is 15, the rule's default is 10, so the default stays as the stricter one.
6398
- "max-statements": 10,
6399
- // No research, and none needed: beyond three parameters the fix is an object or currying, so this
6400
- // is a design boundary and not a dial. Our p90 of 4 is the looser candidate and loses.
6401
- "max-params": 3,
6402
- // Same nesting family as `max-depth`, no citation of its own. Our p90 agrees at 3.
6403
- "max-nested-callbacks": 3,
6404
- // 1 is the rule's floor, there is no stricter value to pick.
6405
- "max-classes-per-file": 1,
6406
- // Cognitive complexity, Campbell 2018, measured by Sonar's own rule, loaded
6407
- // through `plugins/sonar-cognitive.js`.
6408
- // Sonar publishes 15 for S3776, and that is a vendor default rather than a research value: one of
6409
- // their own engineers has said publicly that no formula supports it. Our p90 is 7, so 7 wins, and
6410
- // it reports 342 of 8 083 files, 4.2%, which sits inside the band the eight above occupy.
6411
- "cognitive-complexity": 7
6412
- };
6413
- var COGNITIVE = "cognitive-complexity";
6414
- var cognitivePlugin = () => join22(ownPackageDir(), "plugins", "sonar-cognitive.js");
6415
- var CATEGORIES = [
6416
- "correctness",
6417
- "suspicious",
6418
- "pedantic",
6419
- "perf",
6420
- "style",
6421
- "restriction",
6422
- "nursery"
6423
- ];
6424
- function sizeRulesConfig() {
6425
- return {
6426
- plugins: [],
6427
- categories: Object.fromEntries(CATEGORIES.map((category) => [category, "off"])),
6428
- // Sonar's cognitive complexity, in the SAME run as the eight above rather than a second
6429
- // invocation: the parse is the expensive part and it is already paid here. Measured over the
6430
- // whole bench, 8 083 files, the rule adds 2.4 s to a pass that was going to read them anyway.
6431
- jsPlugins: [cognitivePlugin()],
6432
- // ⛔ `warn` and not `error`, for the reason `tool-config.ts` gives about jscpd's `threshold`:
6433
- // at `error` oxlint exits 1 as soon as one file breaches, which is indistinguishable from the
6434
- // binary failing to run. Measured on `libs/nest/auth`, same config otherwise: `error` exits 1,
6435
- // `warn` exits 0, and both report the same 18 diagnostics. So the tool measures, this role
6436
- // judges, and a non-zero exit means the tool failed.
6437
- rules: Object.fromEntries(
6438
- Object.entries(GAUGE_THRESHOLDS).map(([rule, threshold]) => [
6439
- rule === COGNITIVE ? `sonar/${rule}` : rule,
6440
- ["warn", threshold]
6441
- ])
6442
- )
6443
- };
6444
- }
6445
- function sizeRulesRun(paths, ignore) {
6446
- const dir = mkdtempSync2(join22(tmpdir2(), "sentinel-size-"));
6447
- const configPath = join22(dir, "oxlintrc.json");
6448
- writeFileSync4(configPath, `${JSON.stringify(sizeRulesConfig(), null, 2)}
6449
- `, "utf8");
6450
- return {
6451
- // `--format json` because the console shape is rendered by this role from the figures: a
6452
- // per-pass table from the tool would be a second voice saying the same numbers differently.
6453
- // The exclusions are the SAME list the duplication product pass is given, so "the module's
6454
- // code" means one thing across the whole report.
6455
- args: [
6456
- "-c",
6457
- configPath,
6458
- ...ignore.flatMap((pattern) => ["--ignore-pattern", pattern]),
6459
- "--format",
6460
- "json",
6461
- ...paths
6462
- ],
6463
- dispose: () => rmSync4(dir, { force: true, recursive: true })
6464
- };
6465
- }
6466
- function ruleOf(code) {
6467
- return /\(([^)]+)\)\s*$/.exec(code)?.[1];
6468
- }
6469
- function valueOf(message) {
6470
- 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
6471
- // allowed". It parenthesises nothing, and the `0` after it is the threshold, not the value.
6472
- /Complexity from (\d+) to/.exec(message);
6473
- return Number(match?.[1] ?? 0);
6474
- }
6475
- function breachesOf(raw) {
6476
- let parsed;
6477
- try {
6478
- parsed = JSON.parse(raw);
6479
- } catch {
6480
- return { breaches: [], diagnostics: 0, files: 0 };
6481
- }
6482
- const diagnostics = parsed.diagnostics ?? [];
6483
- const worst = /* @__PURE__ */ new Map();
6484
- for (const diagnostic of diagnostics) {
6485
- const rule = ruleOf(diagnostic.code ?? "");
6486
- const file = diagnostic.filename;
6487
- if (!rule || !file || !(rule in GAUGE_THRESHOLDS)) continue;
6488
- const value = valueOf(diagnostic.message ?? "");
6489
- const line = diagnostic.labels?.[0]?.span?.line ?? 0;
6490
- const key = `${file}\0${rule}`;
6491
- const seen = worst.get(key);
6492
- if (!seen || value > seen.value) worst.set(key, { file, rule, value, line });
6493
- }
6494
- return {
6495
- breaches: [...worst.values()],
6496
- diagnostics: diagnostics.length,
6497
- files: parsed.number_of_files ?? 0
6498
- };
6499
- }
6500
- function scanSizeRules(cwd, roots, ignore, binary, spawn) {
6501
- if (!binary) return void 0;
6502
- const run = sizeRulesRun(roots, ignore);
6503
- try {
6504
- const result = spawn(binary, run.args, cwd);
6505
- if (result.status !== 0) return void 0;
6506
- return breachesOf(result.stdout);
6507
- } finally {
6508
- run.dispose();
6509
- }
6510
- }
6511
-
6512
6522
  // src/roles/static-analysis/report.ts
6513
6523
  var FAMILIES = [
6514
6524
  {
@@ -7774,7 +7784,6 @@ var SCRIPT = `(function(){
7774
7784
 
7775
7785
  // src/roles/static-analysis/graph/imports.ts
7776
7786
  import { dirname, join as join24 } from "node:path";
7777
- var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
7778
7787
  var TS_BEHIND_JS = {
7779
7788
  ".js": [".ts", ".tsx"],
7780
7789
  ".mjs": [".mts"],
@@ -7829,38 +7838,43 @@ function resolveSpecifier(from, specifier, known) {
7829
7838
  if (!specifier.startsWith(".")) return void 0;
7830
7839
  const base = join24(dirname(from), specifier);
7831
7840
  if (known.has(base)) return base;
7832
- for (const extension of EXTENSIONS) {
7841
+ for (const extension of RESOLVABLE_EXTENSIONS) {
7833
7842
  if (known.has(base + extension)) return base + extension;
7834
7843
  }
7835
- const written = EXTENSIONS.find((extension) => base.endsWith(extension));
7844
+ const written = RESOLVABLE_EXTENSIONS.find((extension) => base.endsWith(extension));
7836
7845
  for (const extension of TS_BEHIND_JS[written ?? ""] ?? []) {
7837
7846
  const swapped = base.slice(0, -(written ?? "").length) + extension;
7838
7847
  if (known.has(swapped)) return swapped;
7839
7848
  }
7840
- for (const extension of EXTENSIONS) {
7849
+ for (const extension of RESOLVABLE_EXTENSIONS) {
7841
7850
  const index = join24(base, `index${extension}`);
7842
7851
  if (known.has(index)) return index;
7843
7852
  }
7844
7853
  return void 0;
7845
7854
  }
7846
7855
  function collectImports(known) {
7847
- const all2 = {};
7848
- const value = {};
7856
+ const all2 = /* @__PURE__ */ new Map();
7857
+ const value = /* @__PURE__ */ new Map();
7858
+ const edgesOf = (tree, path2) => {
7859
+ const found = tree.get(path2);
7860
+ if (found !== void 0) return found;
7861
+ const created = /* @__PURE__ */ new Set();
7862
+ tree.set(path2, created);
7863
+ return created;
7864
+ };
7849
7865
  return {
7850
7866
  read: ({ path: path2, program }) => {
7851
7867
  for (const { specifier, typeOnly } of specifiersOf(program)) {
7852
7868
  const target = resolveSpecifier(path2, specifier, known);
7853
7869
  if (!target || target === path2) continue;
7854
- all2[path2] = all2[path2] ?? [];
7855
- value[path2] = value[path2] ?? [];
7856
- if (!all2[path2].includes(target)) all2[path2].push(target);
7857
- if (!typeOnly && !value[path2].includes(target)) value[path2].push(target);
7870
+ edgesOf(all2, path2).add(target);
7871
+ if (!typeOnly) edgesOf(value, path2).add(target);
7858
7872
  }
7859
7873
  },
7860
7874
  finish: (files) => {
7861
7875
  const ordered = (tree) => {
7862
7876
  const out = {};
7863
- for (const file of files) out[file] = tree[file] ?? [];
7877
+ for (const file of files) out[file] = [...tree.get(file) ?? []];
7864
7878
  return out;
7865
7879
  };
7866
7880
  return { all: ordered(all2), value: ordered(value) };
@@ -7951,7 +7965,6 @@ import { dirname as dirname2, join as join25, relative as relative4, resolve as
7951
7965
  var CACHE_DIR = join25("node_modules", ".cache", "sentinel");
7952
7966
  var CACHE_FILE = "consumer-index.json";
7953
7967
  var SHAPE = 4;
7954
- var EXTENSIONS2 = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
7955
7968
  function workspaceRootFrom(cwd) {
7956
7969
  let dir = resolve3(cwd);
7957
7970
  for (; ; ) {
@@ -7991,10 +8004,10 @@ function resolveSpecifier2(specifier, table, workspaceRoot) {
7991
8004
  };
7992
8005
  const exists = (candidate) => {
7993
8006
  if (isFile(candidate)) return candidate;
7994
- for (const extension of EXTENSIONS2) {
8007
+ for (const extension of RESOLVABLE_EXTENSIONS) {
7995
8008
  if (isFile(candidate + extension)) return candidate + extension;
7996
8009
  }
7997
- for (const extension of EXTENSIONS2) {
8010
+ for (const extension of RESOLVABLE_EXTENSIONS) {
7998
8011
  const index = `${candidate}/index${extension}`;
7999
8012
  if (isFile(index)) return index;
8000
8013
  }
@@ -8031,8 +8044,9 @@ function resolveRelative(importer, specifier, workspaceRoot) {
8031
8044
  }
8032
8045
  };
8033
8046
  if (isFile(base)) return base;
8034
- for (const extension of EXTENSIONS2) if (isFile(base + extension)) return base + extension;
8035
- for (const extension of EXTENSIONS2) {
8047
+ for (const extension of RESOLVABLE_EXTENSIONS)
8048
+ if (isFile(base + extension)) return base + extension;
8049
+ for (const extension of RESOLVABLE_EXTENSIONS) {
8036
8050
  const index = `${base}/index${extension}`;
8037
8051
  if (isFile(index)) return index;
8038
8052
  }
@@ -8174,7 +8188,7 @@ function resolveJscpd(cwd) {
8174
8188
  }
8175
8189
 
8176
8190
  // src/roles/static-analysis/adapters/jscpd/jscpd.adapter.ts
8177
- var EXTENSIONS3 = extensionsOf(defaults.languages);
8191
+ var EXTENSIONS = extensionsOf(defaults.languages);
8178
8192
  var scripts = () => ({
8179
8193
  [SCRIPTS.console]: `sentinel --run --${TARGET}`,
8180
8194
  [SCRIPTS.json]: `sentinel --run --${TARGET} --output json`,
@@ -8233,10 +8247,10 @@ var JscpdAdapter = class extends BaseAdapter {
8233
8247
  return true;
8234
8248
  }
8235
8249
  plan(context2) {
8236
- if (!hasSourceFiles(context2.cwd, EXTENSIONS3)) {
8250
+ if (!hasSourceFiles(context2.cwd, EXTENSIONS)) {
8237
8251
  return {
8238
8252
  operations: [],
8239
- skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS3.join(", ")}).`
8253
+ skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS.join(", ")}).`
8240
8254
  };
8241
8255
  }
8242
8256
  return {
@@ -8274,9 +8288,9 @@ var JscpdAdapter = class extends BaseAdapter {
8274
8288
  );
8275
8289
  return { ok: false, code: 1 };
8276
8290
  }
8277
- if (!hasSourceFiles(ctx.cwd, EXTENSIONS3)) {
8291
+ if (!hasSourceFiles(ctx.cwd, EXTENSIONS)) {
8278
8292
  process.stderr.write(
8279
- `sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS3.join(", ")}).
8293
+ `sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS.join(", ")}).
8280
8294
  `
8281
8295
  );
8282
8296
  return { ok: true, code: 0 };
package/dist/index.js CHANGED
@@ -6,10 +6,10 @@ import {
6
6
  registerAdapters,
7
7
  resolve,
8
8
  setDefaultRunner
9
- } from "./chunk-JWOLL5JK.js";
9
+ } from "./chunk-HLRY2VHZ.js";
10
10
  import {
11
11
  BaseAdapter
12
- } from "./chunk-ZFNBW6S3.js";
12
+ } from "./chunk-43HQ6BVJ.js";
13
13
  import "./chunk-IL5PPLXE.js";
14
14
  import "./chunk-V3NHPRLJ.js";
15
15
  import "./chunk-O7REVMOC.js";
@@ -7,7 +7,7 @@ import {
7
7
  readTestAdoption,
8
8
  resolveVitest,
9
9
  suiteOf
10
- } from "./chunk-ZFNBW6S3.js";
10
+ } from "./chunk-43HQ6BVJ.js";
11
11
  import "./chunk-IL5PPLXE.js";
12
12
  import "./chunk-O7REVMOC.js";
13
13
  import "./chunk-QXFCZON7.js";
@@ -36,6 +36,12 @@ everywhere (`--run --json` and `--inspect` answer them), and `--migrate` is refu
36
36
  `--json` works for every verb, and stdout carries **only** the envelope, so `| jq` always
37
37
  parses. `--dry-run` applies to `--init` and writes nothing.
38
38
 
39
+ `--output <shape>` is different, and only a role that WRITES a report reads it: `console` is the
40
+ default and writes no file, `html` is the page you open and send, `json` is for CI and for a
41
+ script, `markdown` is the six lines a workflow posts on a pull request. Today `--static-analysis`
42
+ is the role that has them. ⚠️ `--output html` pays a graph layout the other three skip, which is
43
+ fine for a page somebody opens and wrong for a commit hook.
44
+
39
45
  **`--validate` is a verb of its own**, and the proof of a migration lives there rather than on
40
46
  `--init` or `--run`. Run before a migration it records what the module does today; run after, it
41
47
  compares by test NAME and refuses a run that lost one or invented one. What separates it from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hublo/sentinel",
3
- "version": "1.5.0-alpha.35",
3
+ "version": "1.5.0-alpha.37",
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",
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Ban `palette.common` and `common.*` colour paths in product code.
3
+ *
4
+ * oxlint has no `no-restricted-syntax`, so the eight AST selectors that used to live in
5
+ * `restricted-syntax.cjs` cannot be carried across as configuration. They are expressed as a real
6
+ * rule instead, which is what oxlint's JS plugin API is for. The originals, and where each is
7
+ * handled below:
8
+ *
9
+ * 1-2 MemberExpression[object.property.name='palette'][property.name='common'] theme.palette.common
10
+ * …[computed=true][property.value='common'] theme.palette['common']
11
+ * 3-4 MemberExpression[object.name='palette']… palette.common
12
+ * 5-6 Property[key.name|key.value=/<colour props>/] > Literal[value=/^common\./] { color: 'common.white' }
13
+ * 7-8 JSXAttribute[name.name=/<colour props>/] > Literal | JSXExpressionContainer color="common.white"
14
+ */
15
+
16
+ const MESSAGE =
17
+ 'Do not use palette.common or common.* color paths in product code. Use semantic palette tokens instead.'
18
+
19
+ /** The MUI props that take a colour path, so `common.*` in them is the same mistake. */
20
+ const COLOUR_PROPS =
21
+ /^(color|bgcolor|background|backgroundColor|borderColor|borderTopColor|borderRightColor|borderBottomColor|borderLeftColor|outlineColor|fill|stroke|textDecorationColor|caretColor|accentColor)$/
22
+
23
+ /** `common.something` as a string value, e.g. `{ color: 'common.white' }`. */
24
+ const isCommonPath = (node) =>
25
+ node?.type === 'Literal' && typeof node.value === 'string' && /^common\./.test(node.value)
26
+
27
+ /** @type {import("oxlint").Rule} */
28
+ export const rule = {
29
+ meta: {
30
+ docs: {
31
+ description: 'Ban palette.common and common.* colour paths in product code.',
32
+ },
33
+ },
34
+ create(context) {
35
+ const report = (node) => context.report({ node, message: MESSAGE })
36
+
37
+ return {
38
+ // 1-4: reaching `common` off a palette, by property or by computed key.
39
+ MemberExpression(node) {
40
+ const onPalette =
41
+ node.object?.property?.name === 'palette' || node.object?.name === 'palette'
42
+ if (!onPalette) {
43
+ return
44
+ }
45
+ const reachesCommon =
46
+ node.property?.name === 'common' || (node.computed && node.property?.value === 'common')
47
+ if (reachesCommon) {
48
+ report(node)
49
+ }
50
+ },
51
+
52
+ // 5-6: `{ color: 'common.white' }` in an object literal, key written either way.
53
+ Property(node) {
54
+ const key = node.key?.name ?? node.key?.value
55
+ if (typeof key === 'string' && COLOUR_PROPS.test(key) && isCommonPath(node.value)) {
56
+ report(node.value)
57
+ }
58
+ },
59
+
60
+ // 7-8: `color="common.white"` and `color={'common.white'}` on a JSX element.
61
+ JSXAttribute(node) {
62
+ const name = node.name?.name
63
+ if (typeof name !== 'string' || !COLOUR_PROPS.test(name)) {
64
+ return
65
+ }
66
+ const value = node.value
67
+ if (isCommonPath(value)) {
68
+ return report(value)
69
+ }
70
+ if (value?.type === 'JSXExpressionContainer' && isCommonPath(value.expression)) {
71
+ report(value.expression)
72
+ }
73
+ },
74
+ }
75
+ },
76
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Ban regex lookbehind, which Safari below 16.4 cannot parse.
3
+ *
4
+ * ⛔ Not a style rule and not a carried-over selector: a browser-support rule. Unlike everything
5
+ * else a preset controls, lookbehind can be neither transpiled nor polyfilled, because no bundler
6
+ * rewrites the contents of a regex literal. Lint is the only layer left that can catch it.
7
+ */
8
+
9
+ /**
10
+ * Lookbehind, positive or negative. NOT named groups `(?<name>)`, which Safari has had since
11
+ * 11.3: the class is exactly `(?<` followed by `=` or `!`.
12
+ */
13
+ const LOOKBEHIND = /\(\?<[=!]/
14
+
15
+ const LOOKBEHIND_MESSAGE =
16
+ 'Regex lookbehind ((?<=) / (?<!)) is Safari 16.4+ and can be neither transpiled nor polyfilled: on older iOS the literal is a parse-time SyntaxError that takes the whole chunk with it. Rewrite the pattern. Named groups (?<name>) are fine.'
17
+
18
+ /** @type {import("oxlint").Rule} */
19
+ export const rule = {
20
+ meta: {
21
+ docs: {
22
+ description: 'Ban regex lookbehind, unsupported by Safari below 16.4.',
23
+ },
24
+ },
25
+ create(context) {
26
+ const report = (node) => context.report({ node, message: LOOKBEHIND_MESSAGE })
27
+
28
+ /**
29
+ * `new RegExp(...)` and `RegExp(...)`, when the pattern is readable statically: a
30
+ * string, or a template whose fixed parts carry the lookbehind.
31
+ *
32
+ * Each template chunk is tested on its own rather than joined. Joining invents a
33
+ * `(?<=` that the runtime string never has, across an interpolation boundary:
34
+ * `` `(?<${op}=x)` `` would read as one.
35
+ */
36
+ const isStaticLookbehind = (pattern) => {
37
+ if (pattern?.type === 'Literal') {
38
+ return typeof pattern.value === 'string' && LOOKBEHIND.test(pattern.value)
39
+ }
40
+ if (pattern?.type === 'TemplateLiteral') {
41
+ return (pattern.quasis ?? []).some((quasi) =>
42
+ LOOKBEHIND.test(quasi.value?.cooked ?? quasi.value?.raw ?? ''),
43
+ )
44
+ }
45
+ return false
46
+ }
47
+
48
+ const checkRegExpCall = (node) => {
49
+ if (node.callee?.name !== 'RegExp') {
50
+ return
51
+ }
52
+ const pattern = node.arguments?.[0]
53
+ if (isStaticLookbehind(pattern)) {
54
+ report(pattern)
55
+ }
56
+ }
57
+
58
+ return {
59
+ // The case that actually breaks a page: a literal is an EARLY error, so the whole
60
+ // chunk fails to parse rather than the one call site failing.
61
+ Literal(node) {
62
+ if (node.regex && LOOKBEHIND.test(node.regex.pattern)) {
63
+ report(node)
64
+ }
65
+ },
66
+ NewExpression: checkRegExpCall,
67
+ CallExpression: checkRegExpCall,
68
+ }
69
+ },
70
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Ban a TanStack Router `redirect({ href })` that does not say whether it reloads the document.
3
+ *
4
+ * Carried from an ESLint selector in monorepo #22043, for the same reason as its neighbours: a
5
+ * house rule is code, not configuration.
6
+ */
7
+
8
+ /** @type {import("oxlint").Rule} */
9
+ export const rule = {
10
+ meta: {
11
+ docs: {
12
+ description:
13
+ 'Ban a TanStack Router redirect({ href }) that does not say whether it reloads the document.',
14
+ },
15
+ },
16
+ create(context) {
17
+ /**
18
+ * A `redirect({ href })` that declares no `reloadDocument`.
19
+ *
20
+ * ⛔ The failure this exists for ships GREEN and 404s only in production. Since the
21
+ * router-core bump, `redirect({ href })` no longer reloads the document for an absolute
22
+ * URL on the app's own origin: router-core turns it into an in-app navigation. Apps under
23
+ * a basepath share their origin with the app they point at, so the redirect lands on the
24
+ * SOURCE app's 404 instead of loading the target. Tests run on jsdom's localhost, where
25
+ * the same href is cross-origin and does reload, so nothing catches it before production.
26
+ *
27
+ * ⚠️ What this rule enforces is "declare the intent", NOT "do not 404". A call that says
28
+ * `reloadDocument: false` passes, and that is the exact in-app navigation which caused the
29
+ * incident; the rule cannot tell an internal path from a cross-app one, because the href is
30
+ * usually computed. It shrinks the target from any `redirect({ href })` to one that opted
31
+ * out explicitly, and that is all it does.
32
+ *
33
+ * ⚠️ An `href` arriving through a spread is invisible to it. Measured when the rule was
34
+ * written: zero occurrences of `redirect({ ...opts })` in the whole front end.
35
+ *
36
+ * It is application-agnostic on purpose: every app on TanStack Router under a basepath has
37
+ * the same exposure, so the message names no application.
38
+ */
39
+ const SPREAD_SAFE_MESSAGE =
40
+ 'redirect({ href }) must say whether it reloads the document. Under a basepath the app ' +
41
+ 'shares its origin with the app you are pointing at, so the router turns a same-origin ' +
42
+ 'href into an in-app navigation and the redirect lands on this app’s 404. Use a ' +
43
+ 'helper that sets reloadDocument: true to leave for another app, or set ' +
44
+ 'reloadDocument: false to say you mean an in-app navigation.'
45
+
46
+ /** The call is `redirect(...)`, written bare or as a member. */
47
+ const isRedirect = (node) =>
48
+ node.callee?.name === 'redirect' || node.callee?.property?.name === 'redirect'
49
+
50
+ /** A plain `key:` property, which a spread element does not have. */
51
+ const named = (property, name) =>
52
+ property?.type === 'Property' && (property.key?.name === name || property.key?.value === name)
53
+
54
+ return {
55
+ CallExpression(node) {
56
+ if (!isRedirect(node)) return
57
+ const options = node.arguments?.[0]
58
+ if (options?.type !== 'ObjectExpression') return
59
+
60
+ const properties = options.properties ?? []
61
+ const hasHref = properties.some((property) => named(property, 'href'))
62
+ if (!hasHref) return
63
+ const declaresReload = properties.some((property) => named(property, 'reloadDocument'))
64
+ if (declaresReload) return
65
+
66
+ context.report({ node: options, message: SPREAD_SAFE_MESSAGE })
67
+ },
68
+ }
69
+ },
70
+ }
package/plugins/hublo.js CHANGED
@@ -1,220 +1,31 @@
1
1
  /**
2
2
  * Local oxlint plugin: Hublo house rules that have no equivalent anywhere else.
3
3
  *
4
- * oxlint has no `no-restricted-syntax`, so the eight AST selectors that used to live in
5
- * `restricted-syntax.cjs` cannot be carried across as configuration. They are expressed
6
- * here as a real rule instead, which is what oxlint's JS plugin API is for.
4
+ * It ships INSIDE sentinel rather than being copied into each module. A house rule is code, not
5
+ * configuration: a per-module copy drifts, and drifting house rules are the problem sentinel
6
+ * exists to remove. The cost is that `@hublo/sentinel` carries a rule about Hublo's colour
7
+ * palette, which is honest for a package named after the company.
7
8
  *
8
- * It ships INSIDE sentinel rather than being copied into each module. A house rule is code,
9
- * not configuration: a per-module copy drifts, and drifting house rules are the problem
10
- * sentinel exists to remove. The cost is that `@hublo/sentinel` carries a rule about Hublo's
11
- * colour palette, which is honest for a package named after the company.
9
+ * ⛔ One file per rule, assembled here, and this file holds NO rule logic. It was one 220-line
10
+ * object with three rules inside it, which is the shape that invites a fourth: the rules share
11
+ * nothing, each carries its own constants and its own reasoning, and a reader looking for one of
12
+ * them had to scroll past the other two. Composition is the point here rather than speed: this
13
+ * plugin has no dependencies and loads in about 1 ms, so there is no sonarjs-style cost to cut.
12
14
  *
13
- * It now hosts a SECOND, unrelated rule. `no-regex-lookbehind` is not a carried-over selector
14
- * but a browser-support rule: regex lookbehind is Safari 16.4+, and unlike everything else a
15
- * preset controls it can be neither transpiled nor polyfilled, because no bundler rewrites the
16
- * contents of a regex literal. It is here for the same reason as the palette rule — a house
17
- * rule is code, not configuration — and because lint is the only layer left that can catch it.
18
- *
19
- * The original selectors, and where each is handled below:
20
- * 1-2 MemberExpression[object.property.name='palette'][property.name='common'] theme.palette.common
21
- * …[computed=true][property.value='common'] theme.palette['common']
22
- * 3-4 MemberExpression[object.name='palette']… palette.common
23
- * 5-6 Property[key.name|key.value=/<colour props>/] > Literal[value=/^common\./] { color: 'common.white' }
24
- * 7-8 JSXAttribute[name.name=/<colour props>/] > Literal | JSXExpressionContainer color="common.white"
25
- */
26
-
27
- const MESSAGE =
28
- 'Do not use palette.common or common.* color paths in product code. Use semantic palette tokens instead.'
29
-
30
- /** The MUI props that take a colour path, so `common.*` in them is the same mistake. */
31
- const COLOUR_PROPS =
32
- /^(color|bgcolor|background|backgroundColor|borderColor|borderTopColor|borderRightColor|borderBottomColor|borderLeftColor|outlineColor|fill|stroke|textDecorationColor|caretColor|accentColor)$/
33
-
34
- /**
35
- * Lookbehind, positive or negative. NOT named groups `(?<name>)`, which Safari has had since
36
- * 11.3: the class is exactly `(?<` followed by `=` or `!`.
15
+ * ⚠️ The build copies a role's whole `plugins/` DIRECTORY, so `plugins/hublo/` ships beside
16
+ * `plugins/hublo.js` and the relative imports below resolve the same way in this repository and
17
+ * in the published package. The generated preset still names `../plugins/hublo.js`, so nothing an
18
+ * adopted module extends has moved.
37
19
  */
38
- const LOOKBEHIND = /\(\?<[=!]/
20
+ import { rule as noPaletteCommon } from './hublo/no-palette-common.js'
21
+ import { rule as noRegexLookbehind } from './hublo/no-regex-lookbehind.js'
22
+ import { rule as requireReloadDocument } from './hublo/require-reload-document.js'
39
23
 
40
- const LOOKBEHIND_MESSAGE =
41
- 'Regex lookbehind ((?<=) / (?<!)) is Safari 16.4+ and can be neither transpiled nor polyfilled: on older iOS the literal is a parse-time SyntaxError that takes the whole chunk with it. Rewrite the pattern. Named groups (?<name>) are fine.'
42
-
43
- /** `common.something` as a string value, e.g. `{ color: 'common.white' }`. */
44
- const isCommonPath = (node) =>
45
- node?.type === 'Literal' && typeof node.value === 'string' && /^common\./.test(node.value)
46
-
47
- const plugin = {
24
+ export default {
48
25
  meta: { name: 'hublo' },
49
26
  rules: {
50
- 'no-palette-common': {
51
- meta: {
52
- docs: {
53
- description: 'Ban palette.common and common.* colour paths in product code.',
54
- },
55
- },
56
- create(context) {
57
- const report = (node) => context.report({ node, message: MESSAGE })
58
-
59
- return {
60
- // 1-4: reaching `common` off a palette, by property or by computed key.
61
- MemberExpression(node) {
62
- const onPalette =
63
- node.object?.property?.name === 'palette' || node.object?.name === 'palette'
64
- if (!onPalette) {
65
- return
66
- }
67
- const reachesCommon =
68
- node.property?.name === 'common' ||
69
- (node.computed && node.property?.value === 'common')
70
- if (reachesCommon) {
71
- report(node)
72
- }
73
- },
74
-
75
- // 5-6: `{ color: 'common.white' }` in an object literal, key written either way.
76
- Property(node) {
77
- const key = node.key?.name ?? node.key?.value
78
- if (typeof key === 'string' && COLOUR_PROPS.test(key) && isCommonPath(node.value)) {
79
- report(node.value)
80
- }
81
- },
82
-
83
- // 7-8: `color="common.white"` and `color={'common.white'}` on a JSX element.
84
- JSXAttribute(node) {
85
- const name = node.name?.name
86
- if (typeof name !== 'string' || !COLOUR_PROPS.test(name)) {
87
- return
88
- }
89
- const value = node.value
90
- if (isCommonPath(value)) {
91
- return report(value)
92
- }
93
- if (value?.type === 'JSXExpressionContainer' && isCommonPath(value.expression)) {
94
- report(value.expression)
95
- }
96
- },
97
- }
98
- },
99
- },
100
-
101
- 'no-regex-lookbehind': {
102
- meta: {
103
- docs: {
104
- description: 'Ban regex lookbehind, unsupported by Safari below 16.4.',
105
- },
106
- },
107
- create(context) {
108
- const report = (node) => context.report({ node, message: LOOKBEHIND_MESSAGE })
109
-
110
- /**
111
- * `new RegExp(...)` and `RegExp(...)`, when the pattern is readable statically: a
112
- * string, or a template whose fixed parts carry the lookbehind.
113
- *
114
- * Each template chunk is tested on its own rather than joined. Joining invents a
115
- * `(?<=` that the runtime string never has, across an interpolation boundary:
116
- * `` `(?<${op}=x)` `` would read as one.
117
- */
118
- const isStaticLookbehind = (pattern) => {
119
- if (pattern?.type === 'Literal') {
120
- return typeof pattern.value === 'string' && LOOKBEHIND.test(pattern.value)
121
- }
122
- if (pattern?.type === 'TemplateLiteral') {
123
- return (pattern.quasis ?? []).some((quasi) =>
124
- LOOKBEHIND.test(quasi.value?.cooked ?? quasi.value?.raw ?? ''),
125
- )
126
- }
127
- return false
128
- }
129
-
130
- const checkRegExpCall = (node) => {
131
- if (node.callee?.name !== 'RegExp') {
132
- return
133
- }
134
- const pattern = node.arguments?.[0]
135
- if (isStaticLookbehind(pattern)) {
136
- report(pattern)
137
- }
138
- }
139
-
140
- return {
141
- // The case that actually breaks a page: a literal is an EARLY error, so the whole
142
- // chunk fails to parse rather than the one call site failing.
143
- Literal(node) {
144
- if (node.regex && LOOKBEHIND.test(node.regex.pattern)) {
145
- report(node)
146
- }
147
- },
148
- NewExpression: checkRegExpCall,
149
- CallExpression: checkRegExpCall,
150
- }
151
- },
152
- },
153
-
154
- 'require-reload-document': {
155
- meta: {
156
- docs: {
157
- description:
158
- 'Ban a TanStack Router redirect({ href }) that does not say whether it reloads the document.',
159
- },
160
- },
161
- create(context) {
162
- /**
163
- * A `redirect({ href })` that declares no `reloadDocument`.
164
- *
165
- * ⛔ The failure this exists for ships GREEN and 404s only in production. Since the
166
- * router-core bump, `redirect({ href })` no longer reloads the document for an absolute
167
- * URL on the app's own origin: router-core turns it into an in-app navigation. Apps under
168
- * a basepath share their origin with the app they point at, so the redirect lands on the
169
- * SOURCE app's 404 instead of loading the target. Tests run on jsdom's localhost, where
170
- * the same href is cross-origin and does reload, so nothing catches it before production.
171
- *
172
- * ⚠️ What this rule enforces is "declare the intent", NOT "do not 404". A call that says
173
- * `reloadDocument: false` passes, and that is the exact in-app navigation which caused the
174
- * incident; the rule cannot tell an internal path from a cross-app one, because the href is
175
- * usually computed. It shrinks the target from any `redirect({ href })` to one that opted
176
- * out explicitly, and that is all it does.
177
- *
178
- * ⚠️ An `href` arriving through a spread is invisible to it. Measured when the rule was
179
- * written: zero occurrences of `redirect({ ...opts })` in the whole front end.
180
- *
181
- * It is application-agnostic on purpose: every app on TanStack Router under a basepath has
182
- * the same exposure, so the message names no application.
183
- */
184
- const SPREAD_SAFE_MESSAGE =
185
- 'redirect({ href }) must say whether it reloads the document. Under a basepath the app ' +
186
- 'shares its origin with the app you are pointing at, so the router turns a same-origin ' +
187
- 'href into an in-app navigation and the redirect lands on this app’s 404. Use a ' +
188
- 'helper that sets reloadDocument: true to leave for another app, or set ' +
189
- 'reloadDocument: false to say you mean an in-app navigation.'
190
-
191
- /** The call is `redirect(...)`, written bare or as a member. */
192
- const isRedirect = (node) =>
193
- node.callee?.name === 'redirect' || node.callee?.property?.name === 'redirect'
194
-
195
- /** A plain `key:` property, which a spread element does not have. */
196
- const named = (property, name) =>
197
- property?.type === 'Property' &&
198
- (property.key?.name === name || property.key?.value === name)
199
-
200
- return {
201
- CallExpression(node) {
202
- if (!isRedirect(node)) return
203
- const options = node.arguments?.[0]
204
- if (options?.type !== 'ObjectExpression') return
205
-
206
- const properties = options.properties ?? []
207
- const hasHref = properties.some((property) => named(property, 'href'))
208
- if (!hasHref) return
209
- const declaresReload = properties.some((property) => named(property, 'reloadDocument'))
210
- if (declaresReload) return
211
-
212
- context.report({ node: options, message: SPREAD_SAFE_MESSAGE })
213
- },
214
- }
215
- },
216
- },
27
+ 'no-palette-common': noPaletteCommon,
28
+ 'no-regex-lookbehind': noRegexLookbehind,
29
+ 'require-reload-document': requireReloadDocument,
217
30
  },
218
31
  }
219
-
220
- export default plugin