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

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,10 +15,10 @@ import {
15
15
  palette,
16
16
  registerAdapters,
17
17
  resolve
18
- } from "../chunk-JWOLL5JK.js";
18
+ } from "../chunk-5HM2TYYC.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,
@@ -6018,7 +6019,137 @@ function unreadableNote(coverage) {
6018
6019
  import { readFileSync as readFileSync17 } from "node:fs";
6019
6020
  import { join as join23 } from "node:path";
6020
6021
 
6021
- // src/roles/static-analysis/help/bundle.ts
6022
+ // src/roles/static-analysis/maintainability/size-rules.ts
6023
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
6024
+ import { tmpdir as tmpdir2 } from "node:os";
6025
+ import { join as join22 } from "node:path";
6026
+ var GAUGE_THRESHOLDS = {
6027
+ // McCabe 1976 says 10, our p90 says 11. The only indicator where two independent candidates agree
6028
+ // within one unit, and 94.8% of our functions already sit in the 1-10 band SEI calls low risk.
6029
+ complexity: 10,
6030
+ // IfSQ SP-2, "few people can understand nesting to more than 3 levels". Our p90 agrees at 3.
6031
+ "max-depth": 3,
6032
+ // No research anywhere for a FILE's length: IfSQ's three citations (500, 100-150, 143) are all
6033
+ // about routines. So our p90 of 157, rounded down.
6034
+ "max-lines": 150,
6035
+ // Our p90. IfSQ SP-1 says 150 and is discarded for being more permissive than our own code.
6036
+ "max-lines-per-function": 109,
6037
+ // No research. Our p90 is 15, the rule's default is 10, so the default stays as the stricter one.
6038
+ "max-statements": 10,
6039
+ // No research, and none needed: beyond three parameters the fix is an object or currying, so this
6040
+ // is a design boundary and not a dial. Our p90 of 4 is the looser candidate and loses.
6041
+ "max-params": 3,
6042
+ // Same nesting family as `max-depth`, no citation of its own. Our p90 agrees at 3.
6043
+ "max-nested-callbacks": 3,
6044
+ // 1 is the rule's floor, there is no stricter value to pick.
6045
+ "max-classes-per-file": 1,
6046
+ // Cognitive complexity, Campbell 2018, measured by Sonar's own rule, loaded
6047
+ // through `plugins/sonar-cognitive.js`.
6048
+ // Sonar publishes 15 for S3776, and that is a vendor default rather than a research value: one of
6049
+ // their own engineers has said publicly that no formula supports it. Our p90 is 7, so 7 wins, and
6050
+ // it reports 342 of 8 083 files, 4.2%, which sits inside the band the eight above occupy.
6051
+ "cognitive-complexity": 7
6052
+ };
6053
+ var COGNITIVE = "cognitive-complexity";
6054
+ var cognitivePlugin = () => join22(ownPackageDir(), "plugins", "sonar-cognitive.js");
6055
+ var CATEGORIES = [
6056
+ "correctness",
6057
+ "suspicious",
6058
+ "pedantic",
6059
+ "perf",
6060
+ "style",
6061
+ "restriction",
6062
+ "nursery"
6063
+ ];
6064
+ function sizeRulesConfig() {
6065
+ return {
6066
+ plugins: [],
6067
+ categories: Object.fromEntries(CATEGORIES.map((category) => [category, "off"])),
6068
+ // Sonar's cognitive complexity, in the SAME run as the eight above rather than a second
6069
+ // invocation: the parse is the expensive part and it is already paid here. Measured over the
6070
+ // whole bench, 8 083 files, the rule adds 2.4 s to a pass that was going to read them anyway.
6071
+ jsPlugins: [cognitivePlugin()],
6072
+ // ⛔ `warn` and not `error`, for the reason `tool-config.ts` gives about jscpd's `threshold`:
6073
+ // at `error` oxlint exits 1 as soon as one file breaches, which is indistinguishable from the
6074
+ // binary failing to run. Measured on `libs/nest/auth`, same config otherwise: `error` exits 1,
6075
+ // `warn` exits 0, and both report the same 18 diagnostics. So the tool measures, this role
6076
+ // judges, and a non-zero exit means the tool failed.
6077
+ rules: Object.fromEntries(
6078
+ Object.entries(GAUGE_THRESHOLDS).map(([rule, threshold]) => [
6079
+ rule === COGNITIVE ? `sonar/${rule}` : rule,
6080
+ ["warn", threshold]
6081
+ ])
6082
+ )
6083
+ };
6084
+ }
6085
+ function sizeRulesRun(paths, ignore) {
6086
+ const dir = mkdtempSync2(join22(tmpdir2(), "sentinel-size-"));
6087
+ const configPath = join22(dir, "oxlintrc.json");
6088
+ writeFileSync4(configPath, `${JSON.stringify(sizeRulesConfig(), null, 2)}
6089
+ `, "utf8");
6090
+ return {
6091
+ // `--format json` because the console shape is rendered by this role from the figures: a
6092
+ // per-pass table from the tool would be a second voice saying the same numbers differently.
6093
+ // The exclusions are the SAME list the duplication product pass is given, so "the module's
6094
+ // code" means one thing across the whole report.
6095
+ args: [
6096
+ "-c",
6097
+ configPath,
6098
+ ...ignore.flatMap((pattern) => ["--ignore-pattern", pattern]),
6099
+ "--format",
6100
+ "json",
6101
+ ...paths
6102
+ ],
6103
+ dispose: () => rmSync4(dir, { force: true, recursive: true })
6104
+ };
6105
+ }
6106
+ function ruleOf(code) {
6107
+ return /\(([^)]+)\)\s*$/.exec(code)?.[1];
6108
+ }
6109
+ function valueOf(message) {
6110
+ 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
6111
+ // allowed". It parenthesises nothing, and the `0` after it is the threshold, not the value.
6112
+ /Complexity from (\d+) to/.exec(message);
6113
+ return Number(match?.[1] ?? 0);
6114
+ }
6115
+ function breachesOf(raw) {
6116
+ let parsed;
6117
+ try {
6118
+ parsed = JSON.parse(raw);
6119
+ } catch {
6120
+ return { breaches: [], diagnostics: 0, files: 0 };
6121
+ }
6122
+ const diagnostics = parsed.diagnostics ?? [];
6123
+ const worst = /* @__PURE__ */ new Map();
6124
+ for (const diagnostic of diagnostics) {
6125
+ const rule = ruleOf(diagnostic.code ?? "");
6126
+ const file = diagnostic.filename;
6127
+ if (!rule || !file || !(rule in GAUGE_THRESHOLDS)) continue;
6128
+ const value = valueOf(diagnostic.message ?? "");
6129
+ const line = diagnostic.labels?.[0]?.span?.line ?? 0;
6130
+ const key = `${file}\0${rule}`;
6131
+ const seen = worst.get(key);
6132
+ if (!seen || value > seen.value) worst.set(key, { file, rule, value, line });
6133
+ }
6134
+ return {
6135
+ breaches: [...worst.values()],
6136
+ diagnostics: diagnostics.length,
6137
+ files: parsed.number_of_files ?? 0
6138
+ };
6139
+ }
6140
+ function scanSizeRules(cwd, roots, ignore, binary, spawn) {
6141
+ if (!binary) return void 0;
6142
+ const run = sizeRulesRun(roots, ignore);
6143
+ try {
6144
+ const result = spawn(binary, run.args, cwd);
6145
+ if (result.status !== 0) return void 0;
6146
+ return breachesOf(result.stdout);
6147
+ } finally {
6148
+ run.dispose();
6149
+ }
6150
+ }
6151
+
6152
+ // src/roles/static-analysis/bundle/help.ts
6022
6153
  var WEB_DEV_TREE_SHAKING = "https://web.dev/articles/reduce-javascript-payloads-with-tree-shaking";
6023
6154
  var WEB_DEV_SCRIPT_EVALUATION = "https://web.dev/articles/script-evaluation-and-long-tasks";
6024
6155
  var V8_COST_2019 = "https://v8.dev/blog/cost-of-javascript-2019";
@@ -6121,7 +6252,7 @@ var context = {
6121
6252
  };
6122
6253
  var bundleHelp = { ...actionable, ...context };
6123
6254
 
6124
- // src/roles/static-analysis/help/metrics.ts
6255
+ // src/roles/static-analysis/report-help.ts
6125
6256
  var metricsHelp = {
6126
6257
  duplicatedLinesPercent: {
6127
6258
  label: "Duplicated lines",
@@ -6379,136 +6510,6 @@ function helpFor(key) {
6379
6510
  return metricsHelp[key];
6380
6511
  }
6381
6512
 
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
6513
  // src/roles/static-analysis/report.ts
6513
6514
  var FAMILIES = [
6514
6515
  {
@@ -7774,7 +7775,6 @@ var SCRIPT = `(function(){
7774
7775
 
7775
7776
  // src/roles/static-analysis/graph/imports.ts
7776
7777
  import { dirname, join as join24 } from "node:path";
7777
- var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
7778
7778
  var TS_BEHIND_JS = {
7779
7779
  ".js": [".ts", ".tsx"],
7780
7780
  ".mjs": [".mts"],
@@ -7829,38 +7829,43 @@ function resolveSpecifier(from, specifier, known) {
7829
7829
  if (!specifier.startsWith(".")) return void 0;
7830
7830
  const base = join24(dirname(from), specifier);
7831
7831
  if (known.has(base)) return base;
7832
- for (const extension of EXTENSIONS) {
7832
+ for (const extension of RESOLVABLE_EXTENSIONS) {
7833
7833
  if (known.has(base + extension)) return base + extension;
7834
7834
  }
7835
- const written = EXTENSIONS.find((extension) => base.endsWith(extension));
7835
+ const written = RESOLVABLE_EXTENSIONS.find((extension) => base.endsWith(extension));
7836
7836
  for (const extension of TS_BEHIND_JS[written ?? ""] ?? []) {
7837
7837
  const swapped = base.slice(0, -(written ?? "").length) + extension;
7838
7838
  if (known.has(swapped)) return swapped;
7839
7839
  }
7840
- for (const extension of EXTENSIONS) {
7840
+ for (const extension of RESOLVABLE_EXTENSIONS) {
7841
7841
  const index = join24(base, `index${extension}`);
7842
7842
  if (known.has(index)) return index;
7843
7843
  }
7844
7844
  return void 0;
7845
7845
  }
7846
7846
  function collectImports(known) {
7847
- const all2 = {};
7848
- const value = {};
7847
+ const all2 = /* @__PURE__ */ new Map();
7848
+ const value = /* @__PURE__ */ new Map();
7849
+ const edgesOf = (tree, path2) => {
7850
+ const found = tree.get(path2);
7851
+ if (found !== void 0) return found;
7852
+ const created = /* @__PURE__ */ new Set();
7853
+ tree.set(path2, created);
7854
+ return created;
7855
+ };
7849
7856
  return {
7850
7857
  read: ({ path: path2, program }) => {
7851
7858
  for (const { specifier, typeOnly } of specifiersOf(program)) {
7852
7859
  const target = resolveSpecifier(path2, specifier, known);
7853
7860
  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);
7861
+ edgesOf(all2, path2).add(target);
7862
+ if (!typeOnly) edgesOf(value, path2).add(target);
7858
7863
  }
7859
7864
  },
7860
7865
  finish: (files) => {
7861
7866
  const ordered = (tree) => {
7862
7867
  const out = {};
7863
- for (const file of files) out[file] = tree[file] ?? [];
7868
+ for (const file of files) out[file] = [...tree.get(file) ?? []];
7864
7869
  return out;
7865
7870
  };
7866
7871
  return { all: ordered(all2), value: ordered(value) };
@@ -7951,7 +7956,6 @@ import { dirname as dirname2, join as join25, relative as relative4, resolve as
7951
7956
  var CACHE_DIR = join25("node_modules", ".cache", "sentinel");
7952
7957
  var CACHE_FILE = "consumer-index.json";
7953
7958
  var SHAPE = 4;
7954
- var EXTENSIONS2 = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
7955
7959
  function workspaceRootFrom(cwd) {
7956
7960
  let dir = resolve3(cwd);
7957
7961
  for (; ; ) {
@@ -7991,10 +7995,10 @@ function resolveSpecifier2(specifier, table, workspaceRoot) {
7991
7995
  };
7992
7996
  const exists = (candidate) => {
7993
7997
  if (isFile(candidate)) return candidate;
7994
- for (const extension of EXTENSIONS2) {
7998
+ for (const extension of RESOLVABLE_EXTENSIONS) {
7995
7999
  if (isFile(candidate + extension)) return candidate + extension;
7996
8000
  }
7997
- for (const extension of EXTENSIONS2) {
8001
+ for (const extension of RESOLVABLE_EXTENSIONS) {
7998
8002
  const index = `${candidate}/index${extension}`;
7999
8003
  if (isFile(index)) return index;
8000
8004
  }
@@ -8031,8 +8035,9 @@ function resolveRelative(importer, specifier, workspaceRoot) {
8031
8035
  }
8032
8036
  };
8033
8037
  if (isFile(base)) return base;
8034
- for (const extension of EXTENSIONS2) if (isFile(base + extension)) return base + extension;
8035
- for (const extension of EXTENSIONS2) {
8038
+ for (const extension of RESOLVABLE_EXTENSIONS)
8039
+ if (isFile(base + extension)) return base + extension;
8040
+ for (const extension of RESOLVABLE_EXTENSIONS) {
8036
8041
  const index = `${base}/index${extension}`;
8037
8042
  if (isFile(index)) return index;
8038
8043
  }
@@ -8174,7 +8179,7 @@ function resolveJscpd(cwd) {
8174
8179
  }
8175
8180
 
8176
8181
  // src/roles/static-analysis/adapters/jscpd/jscpd.adapter.ts
8177
- var EXTENSIONS3 = extensionsOf(defaults.languages);
8182
+ var EXTENSIONS = extensionsOf(defaults.languages);
8178
8183
  var scripts = () => ({
8179
8184
  [SCRIPTS.console]: `sentinel --run --${TARGET}`,
8180
8185
  [SCRIPTS.json]: `sentinel --run --${TARGET} --output json`,
@@ -8233,10 +8238,10 @@ var JscpdAdapter = class extends BaseAdapter {
8233
8238
  return true;
8234
8239
  }
8235
8240
  plan(context2) {
8236
- if (!hasSourceFiles(context2.cwd, EXTENSIONS3)) {
8241
+ if (!hasSourceFiles(context2.cwd, EXTENSIONS)) {
8237
8242
  return {
8238
8243
  operations: [],
8239
- skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS3.join(", ")}).`
8244
+ skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS.join(", ")}).`
8240
8245
  };
8241
8246
  }
8242
8247
  return {
@@ -8274,9 +8279,9 @@ var JscpdAdapter = class extends BaseAdapter {
8274
8279
  );
8275
8280
  return { ok: false, code: 1 };
8276
8281
  }
8277
- if (!hasSourceFiles(ctx.cwd, EXTENSIONS3)) {
8282
+ if (!hasSourceFiles(ctx.cwd, EXTENSIONS)) {
8278
8283
  process.stderr.write(
8279
- `sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS3.join(", ")}).
8284
+ `sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS.join(", ")}).
8280
8285
  `
8281
8286
  );
8282
8287
  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-5HM2TYYC.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";
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.36",
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