@hublo/sentinel 1.5.0-alpha.9 → 1.5.0

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.
Files changed (41) hide show
  1. package/README.md +9 -7
  2. package/dist/bin/sentinel.js +61 -16
  3. package/dist/{chunk-KLEXJFJ6.js → chunk-43HQ6BVJ.js} +61 -15
  4. package/dist/{chunk-YQRDKWSH.js → chunk-IL5PPLXE.js} +11 -1
  5. package/dist/chunk-IL5PPLXE.js.map +1 -0
  6. package/dist/chunk-MOELAQPC.js +10365 -0
  7. package/dist/chunk-WBDBRGLV.js +214 -0
  8. package/dist/chunk-WBDBRGLV.js.map +1 -0
  9. package/dist/{chunk-AMCGMLPS.js → chunk-Y57KA4QS.js} +2 -2
  10. package/dist/index.d.ts +28 -1
  11. package/dist/index.js +4 -4
  12. package/dist/roles/build/nest/toolchain.js +68 -18
  13. package/dist/roles/build/nest/toolchain.js.map +1 -1
  14. package/dist/roles/static-analysis/config.d.ts +15 -1
  15. package/dist/roles/static-analysis/config.js +1 -1
  16. package/dist/roles/test/setup/jest-parity.js +2 -2
  17. package/dist/roles/test/setup/workspace-entry.js +2 -2
  18. package/dist/{validate-AWWMBSH5.js → validate-FRMENKHM.js} +2 -2
  19. package/docs/bundle-analysis-spec.md +272 -0
  20. package/docs/sql-verification-study.md +401 -0
  21. package/docs/static-analysis-adoption.md +285 -0
  22. package/docs/static-analysis-study.md +824 -18
  23. package/docs/typescript-adoption.md +1 -1
  24. package/docs/typescript-traces.md +27 -12
  25. package/docs/using-sentinel.md +22 -12
  26. package/docs/validating-a-change.md +185 -0
  27. package/lint/nest.json +98 -18
  28. package/lint/node.json +83 -3
  29. package/lint/react-lib.json +121 -31
  30. package/lint/react.json +101 -2
  31. package/package.json +27 -6
  32. package/plugins/hublo/no-palette-common.js +76 -0
  33. package/plugins/hublo/no-regex-lookbehind.js +70 -0
  34. package/plugins/hublo/require-reload-document.js +70 -0
  35. package/plugins/hublo.js +20 -145
  36. package/plugins/sonar-cognitive.js +108 -0
  37. package/dist/chunk-EDFIVLUA.js +0 -100
  38. package/dist/chunk-EDFIVLUA.js.map +0 -1
  39. package/dist/chunk-OZZICSSZ.js +0 -6469
  40. package/dist/chunk-YQRDKWSH.js.map +0 -1
  41. /package/dist/{chunk-AMCGMLPS.js.map → chunk-Y57KA4QS.js.map} +0 -0
@@ -5,6 +5,7 @@
5
5
  "react",
6
6
  "jsx-a11y",
7
7
  "jest",
8
+ "vitest",
8
9
  "unicorn",
9
10
  "oxc",
10
11
  "nextjs"
@@ -53,6 +54,14 @@
53
54
  "typescript/prefer-as-const": "error",
54
55
  "typescript/prefer-namespace-keyword": "error",
55
56
  "typescript/triple-slash-reference": "error",
57
+ "@tanstack/query/exhaustive-deps": "warn",
58
+ "@tanstack/query/infinite-query-property-order": "warn",
59
+ "@tanstack/query/mutation-property-order": "warn",
60
+ "@tanstack/query/no-rest-destructuring": "warn",
61
+ "@tanstack/query/no-unstable-deps": "warn",
62
+ "@tanstack/query/no-void-query-fn": "warn",
63
+ "@tanstack/query/prefer-query-options": "warn",
64
+ "@tanstack/query/stable-query-client": "warn",
56
65
  "curly": [
57
66
  "error",
58
67
  "all"
@@ -70,39 +79,28 @@
70
79
  "jest-dom/prefer-to-have-style": "error",
71
80
  "jest-dom/prefer-to-have-text-content": "error",
72
81
  "jest-dom/prefer-to-have-value": "error",
73
- "jest/expect-expect": [
74
- "error",
75
- {
76
- "assertFunctionNames": [
77
- "expect",
78
- "expect*",
79
- "waitForElementToBeRemoved",
80
- "screen.findBy*",
81
- "result.findBy*"
82
- ]
83
- }
84
- ],
85
- "jest/no-alias-methods": "error",
86
- "jest/no-commented-out-tests": "warn",
87
- "jest/no-conditional-expect": "error",
82
+ "jest/expect-expect": "off",
83
+ "jest/no-alias-methods": "off",
84
+ "jest/no-commented-out-tests": "off",
85
+ "jest/no-conditional-expect": "off",
88
86
  "jest/no-deprecated-functions": "error",
89
- "jest/no-disabled-tests": "warn",
87
+ "jest/no-disabled-tests": "off",
90
88
  "jest/no-done-callback": "error",
91
89
  "jest/no-export": "error",
92
- "jest/no-focused-tests": "error",
93
- "jest/no-identical-title": "error",
94
- "jest/no-interpolation-in-snapshots": "error",
90
+ "jest/no-focused-tests": "off",
91
+ "jest/no-identical-title": "off",
92
+ "jest/no-interpolation-in-snapshots": "off",
95
93
  "jest/no-jasmine-globals": "error",
96
- "jest/no-mocks-import": "error",
97
- "jest/no-standalone-expect": "error",
98
- "jest/no-test-prefixes": "error",
99
- "jest/prefer-to-be": "error",
100
- "jest/prefer-to-contain": "error",
101
- "jest/prefer-to-have-length": "error",
102
- "jest/valid-describe-callback": "error",
103
- "jest/valid-expect": "error",
104
- "jest/valid-expect-in-promise": "error",
105
- "jest/valid-title": "error",
94
+ "jest/no-mocks-import": "off",
95
+ "jest/no-standalone-expect": "off",
96
+ "jest/no-test-prefixes": "off",
97
+ "jest/prefer-to-be": "off",
98
+ "jest/prefer-to-contain": "off",
99
+ "jest/prefer-to-have-length": "off",
100
+ "jest/valid-describe-callback": "off",
101
+ "jest/valid-expect": "off",
102
+ "jest/valid-expect-in-promise": "off",
103
+ "jest/valid-title": "off",
106
104
  "jsx-a11y/anchor-is-valid": [
107
105
  "error",
108
106
  {
@@ -334,7 +332,39 @@
334
332
  {
335
333
  "requireStringLiterals": false
336
334
  }
337
- ]
335
+ ],
336
+ "vitest/expect-expect": [
337
+ "error",
338
+ {
339
+ "assertFunctionNames": [
340
+ "expect",
341
+ "expect*",
342
+ "waitForElementToBeRemoved",
343
+ "screen.findBy*",
344
+ "result.findBy*"
345
+ ]
346
+ }
347
+ ],
348
+ "vitest/no-alias-methods": "error",
349
+ "vitest/no-commented-out-tests": "warn",
350
+ "vitest/no-conditional-expect": "error",
351
+ "vitest/no-disabled-tests": "warn",
352
+ "vitest/no-focused-tests": "error",
353
+ "vitest/no-identical-title": "error",
354
+ "vitest/no-interpolation-in-snapshots": "error",
355
+ "vitest/no-mocks-import": "error",
356
+ "vitest/no-standalone-expect": "error",
357
+ "vitest/no-test-prefixes": "error",
358
+ "vitest/prefer-to-be": "error",
359
+ "vitest/prefer-to-contain": "error",
360
+ "vitest/prefer-to-have-length": "error",
361
+ "vitest/require-mock-type-parameters": "off",
362
+ "vitest/require-to-throw-message": "warn",
363
+ "vitest/valid-describe-callback": "error",
364
+ "vitest/valid-expect": "error",
365
+ "vitest/valid-expect-in-promise": "error",
366
+ "vitest/valid-title": "error",
367
+ "vitest/warn-todo": "warn"
338
368
  },
339
369
  "jsPlugins": [
340
370
  "eslint-plugin-no-barrel-files",
@@ -350,7 +380,11 @@
350
380
  "name": "storybook",
351
381
  "specifier": "eslint-plugin-storybook"
352
382
  },
353
- "../plugins/hublo.js"
383
+ "../plugins/hublo.js",
384
+ {
385
+ "name": "@tanstack/query",
386
+ "specifier": "@tanstack/eslint-plugin-query"
387
+ }
354
388
  ],
355
389
  "overrides": [
356
390
  {
@@ -373,6 +407,62 @@
373
407
  "storybook/use-storybook-testing-library": "error",
374
408
  "react-hooks/rules-of-hooks": "off"
375
409
  }
410
+ },
411
+ {
412
+ "files": [
413
+ "**/*.test.ts",
414
+ "**/*.test.tsx",
415
+ "**/*.spec.ts",
416
+ "**/*.spec.tsx",
417
+ "**/*.integration-spec.ts",
418
+ "**/*.e2e-spec.ts"
419
+ ],
420
+ "rules": {
421
+ "no-restricted-properties": [
422
+ "warn",
423
+ {
424
+ "object": "Math",
425
+ "property": "random",
426
+ "message": "No Math.random() in a test: use a deterministic fixture, or stub the random source."
427
+ },
428
+ {
429
+ "object": "Date",
430
+ "property": "now",
431
+ "message": "No Date.now() in a test: use vi.useFakeTimers() with vi.setSystemTime()."
432
+ },
433
+ {
434
+ "object": "performance",
435
+ "property": "now",
436
+ "message": "No performance.now() in a test: use vi.useFakeTimers() with vi.setSystemTime()."
437
+ },
438
+ {
439
+ "object": "process",
440
+ "property": "hrtime",
441
+ "message": "No process.hrtime in a test: use vi.useFakeTimers() with vi.setSystemTime()."
442
+ },
443
+ {
444
+ "object": "crypto",
445
+ "property": "randomUUID",
446
+ "message": "No crypto.randomUUID() in a test: use a fixed id, or stub the random source."
447
+ },
448
+ {
449
+ "object": "crypto",
450
+ "property": "getRandomValues",
451
+ "message": "No crypto.getRandomValues() in a test: use a fixed buffer, or stub the random source."
452
+ }
453
+ ],
454
+ "no-restricted-globals": [
455
+ "warn",
456
+ {
457
+ "name": "setTimeout",
458
+ "message": "No real timer in a test: vi.useFakeTimers() then vi.advanceTimersByTime()."
459
+ },
460
+ {
461
+ "name": "setInterval",
462
+ "message": "No real timer in a test: vi.useFakeTimers() then vi.advanceTimersByTime()."
463
+ }
464
+ ]
465
+ }
376
466
  }
377
467
  ]
378
468
  }
package/lint/react.json CHANGED
@@ -5,6 +5,7 @@
5
5
  "react",
6
6
  "jsx-a11y",
7
7
  "jest",
8
+ "vitest",
8
9
  "import",
9
10
  "unicorn",
10
11
  "oxc"
@@ -53,6 +54,16 @@
53
54
  "typescript/prefer-as-const": "error",
54
55
  "typescript/prefer-namespace-keyword": "error",
55
56
  "typescript/triple-slash-reference": "error",
57
+ "@tanstack/query/exhaustive-deps": "warn",
58
+ "@tanstack/query/infinite-query-property-order": "warn",
59
+ "@tanstack/query/mutation-property-order": "warn",
60
+ "@tanstack/query/no-rest-destructuring": "warn",
61
+ "@tanstack/query/no-unstable-deps": "warn",
62
+ "@tanstack/query/no-void-query-fn": "warn",
63
+ "@tanstack/query/prefer-query-options": "warn",
64
+ "@tanstack/query/stable-query-client": "warn",
65
+ "@tanstack/router/create-route-property-order": "warn",
66
+ "@tanstack/router/route-param-names": "warn",
56
67
  "curly": [
57
68
  "error",
58
69
  "all"
@@ -60,6 +71,7 @@
60
71
  "for-direction": "error",
61
72
  "hublo/no-palette-common": "off",
62
73
  "hublo/no-regex-lookbehind": "error",
74
+ "hublo/require-reload-document": "error",
63
75
  "jest/expect-expect": "off",
64
76
  "jest/no-alias-methods": "off",
65
77
  "jest/no-commented-out-tests": "off",
@@ -456,7 +468,28 @@
456
468
  {
457
469
  "requireStringLiterals": false
458
470
  }
459
- ]
471
+ ],
472
+ "vitest/expect-expect": "warn",
473
+ "vitest/no-alias-methods": "warn",
474
+ "vitest/no-commented-out-tests": "warn",
475
+ "vitest/no-conditional-expect": "warn",
476
+ "vitest/no-disabled-tests": "warn",
477
+ "vitest/no-focused-tests": "warn",
478
+ "vitest/no-identical-title": "warn",
479
+ "vitest/no-interpolation-in-snapshots": "warn",
480
+ "vitest/no-mocks-import": "warn",
481
+ "vitest/no-standalone-expect": "warn",
482
+ "vitest/no-test-prefixes": "warn",
483
+ "vitest/prefer-to-be": "warn",
484
+ "vitest/prefer-to-contain": "warn",
485
+ "vitest/prefer-to-have-length": "warn",
486
+ "vitest/require-mock-type-parameters": "off",
487
+ "vitest/require-to-throw-message": "warn",
488
+ "vitest/valid-describe-callback": "warn",
489
+ "vitest/valid-expect": "warn",
490
+ "vitest/valid-expect-in-promise": "warn",
491
+ "vitest/valid-title": "warn",
492
+ "vitest/warn-todo": "warn"
460
493
  },
461
494
  "jsPlugins": [
462
495
  "eslint-plugin-no-barrel-files",
@@ -464,6 +497,10 @@
464
497
  "name": "nx",
465
498
  "specifier": "@nx/eslint-plugin"
466
499
  },
500
+ {
501
+ "name": "@tanstack/query",
502
+ "specifier": "@tanstack/eslint-plugin-query"
503
+ },
467
504
  {
468
505
  "name": "styled-components-a11y",
469
506
  "specifier": "eslint-plugin-styled-components-a11y"
@@ -472,6 +509,68 @@
472
509
  "name": "react-refresh",
473
510
  "specifier": "eslint-plugin-react-refresh"
474
511
  },
475
- "../plugins/hublo.js"
512
+ "../plugins/hublo.js",
513
+ {
514
+ "name": "@tanstack/router",
515
+ "specifier": "@tanstack/eslint-plugin-router"
516
+ }
517
+ ],
518
+ "overrides": [
519
+ {
520
+ "files": [
521
+ "**/*.test.ts",
522
+ "**/*.test.tsx",
523
+ "**/*.spec.ts",
524
+ "**/*.spec.tsx",
525
+ "**/*.integration-spec.ts",
526
+ "**/*.e2e-spec.ts"
527
+ ],
528
+ "rules": {
529
+ "no-restricted-properties": [
530
+ "warn",
531
+ {
532
+ "object": "Math",
533
+ "property": "random",
534
+ "message": "No Math.random() in a test: use a deterministic fixture, or stub the random source."
535
+ },
536
+ {
537
+ "object": "Date",
538
+ "property": "now",
539
+ "message": "No Date.now() in a test: use vi.useFakeTimers() with vi.setSystemTime()."
540
+ },
541
+ {
542
+ "object": "performance",
543
+ "property": "now",
544
+ "message": "No performance.now() in a test: use vi.useFakeTimers() with vi.setSystemTime()."
545
+ },
546
+ {
547
+ "object": "process",
548
+ "property": "hrtime",
549
+ "message": "No process.hrtime in a test: use vi.useFakeTimers() with vi.setSystemTime()."
550
+ },
551
+ {
552
+ "object": "crypto",
553
+ "property": "randomUUID",
554
+ "message": "No crypto.randomUUID() in a test: use a fixed id, or stub the random source."
555
+ },
556
+ {
557
+ "object": "crypto",
558
+ "property": "getRandomValues",
559
+ "message": "No crypto.getRandomValues() in a test: use a fixed buffer, or stub the random source."
560
+ }
561
+ ],
562
+ "no-restricted-globals": [
563
+ "warn",
564
+ {
565
+ "name": "setTimeout",
566
+ "message": "No real timer in a test: vi.useFakeTimers() then vi.advanceTimersByTime()."
567
+ },
568
+ {
569
+ "name": "setInterval",
570
+ "message": "No real timer in a test: vi.useFakeTimers() then vi.advanceTimersByTime()."
571
+ }
572
+ ]
573
+ }
574
+ }
476
575
  ]
477
576
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hublo/sentinel",
3
- "version": "1.5.0-alpha.9",
3
+ "version": "1.5.0",
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",
@@ -10,7 +10,7 @@
10
10
  "url": "git+https://github.com/hublo/sentinel.git"
11
11
  },
12
12
  "engines": {
13
- "node": ">=20.12.0"
13
+ "node": ">=24.15.0"
14
14
  },
15
15
  "bin": {
16
16
  "sentinel": "./dist/bin/sentinel.js"
@@ -110,6 +110,8 @@
110
110
  "dependencies": {
111
111
  "@nx/eslint-plugin": "23.0.1",
112
112
  "@tailwindcss/vite": "4.1.18",
113
+ "@tanstack/eslint-plugin-query": "5.104.0",
114
+ "@tanstack/eslint-plugin-router": "1.162.0",
113
115
  "@vitejs/plugin-react": "6.0.1",
114
116
  "@vitest/coverage-v8": "4.1.4",
115
117
  "axe-core": "^4.13.0",
@@ -118,8 +120,17 @@
118
120
  "eslint-plugin-jest-dom": "5.5.0",
119
121
  "eslint-plugin-no-barrel-files": "1.2.1",
120
122
  "eslint-plugin-react-refresh": "0.4.19",
123
+ "eslint-plugin-sonarjs": "4.2.2",
121
124
  "eslint-plugin-storybook": "10.3.5",
122
125
  "eslint-plugin-styled-components-a11y": "2.2.0",
126
+ "graphology": "0.26.0",
127
+ "graphology-communities-louvain": "2.0.2",
128
+ "graphology-components": "1.5.4",
129
+ "graphology-layout": "0.6.1",
130
+ "graphology-layout-forceatlas2": "0.10.1",
131
+ "graphology-metrics": "2.4.2",
132
+ "graphology-svg": "0.1.3",
133
+ "graphology-types": "0.24.8",
123
134
  "html-validate": "^11.16.0",
124
135
  "jscpd": "5.4.0",
125
136
  "jsdom": "^24.1.3",
@@ -139,11 +150,10 @@
139
150
  },
140
151
  "devDependencies": {
141
152
  "@hublo/sentinel": "link:.",
142
- "@types/node": "^22.0.0",
153
+ "@types/node": "^24.19.2",
143
154
  "nx": "^23.1.0",
144
155
  "tsup": "^8.3.0",
145
- "tsx": "^4.19.0",
146
- "typescript": "^5.9.0"
156
+ "tsx": "^4.19.0"
147
157
  },
148
158
  "publishConfig": {
149
159
  "registry": "https://registry.npmjs.org",
@@ -160,6 +170,13 @@
160
170
  },
161
171
  "format:fix": {
162
172
  "cache": false
173
+ },
174
+ "static-analysis": {
175
+ "cache": true,
176
+ "inputs": [
177
+ "default",
178
+ "{projectRoot}/static-analysis.config.mjs"
179
+ ]
163
180
  }
164
181
  }
165
182
  },
@@ -190,6 +207,10 @@
190
207
  "docs:traces": "pnpm build && bash scripts/gen-traces.sh",
191
208
  "docs:traces:check": "pnpm build && bash scripts/gen-traces.sh --check",
192
209
  "format:fix": "node dist/bin/sentinel.js --run --format --fix",
193
- "bench": "node scripts/bench.mjs"
210
+ "bench": "node scripts/bench.mjs",
211
+ "deps:trust": "tsx scripts/check-trust-policy.ts",
212
+ "static-analysis": "sentinel --run --static-analysis",
213
+ "static-analysis:json": "sentinel --run --static-analysis --output json",
214
+ "static-analysis:html": "sentinel --run --static-analysis --output html"
194
215
  }
195
216
  }
@@ -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
+ }