@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.
- package/dist/bin/sentinel.js +3 -3
- package/dist/{chunk-ZFNBW6S3.js → chunk-43HQ6BVJ.js} +24 -11
- package/dist/{chunk-JWOLL5JK.js → chunk-5HM2TYYC.js} +159 -154
- package/dist/index.js +2 -2
- package/dist/{validate-7G2ORUZD.js → validate-FRMENKHM.js} +1 -1
- package/package.json +1 -1
- package/plugins/hublo/no-palette-common.js +76 -0
- package/plugins/hublo/no-regex-lookbehind.js +70 -0
- package/plugins/hublo/require-reload-document.js +70 -0
- package/plugins/hublo.js +20 -209
package/dist/bin/sentinel.js
CHANGED
|
@@ -15,10 +15,10 @@ import {
|
|
|
15
15
|
palette,
|
|
16
16
|
registerAdapters,
|
|
17
17
|
resolve
|
|
18
|
-
} from "../chunk-
|
|
18
|
+
} from "../chunk-5HM2TYYC.js";
|
|
19
19
|
import {
|
|
20
20
|
resolveContext
|
|
21
|
-
} from "../chunk-
|
|
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-
|
|
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
|
-
|
|
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
|
-
...
|
|
2169
|
-
...
|
|
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
|
|
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 (
|
|
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
|
-
...
|
|
3722
|
-
...
|
|
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
|
|
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
|
|
4341
|
+
for (const extension of EXTENSIONS2) {
|
|
4330
4342
|
if (existsSync14(`${base}${extension}`)) return `${base}${extension}`;
|
|
4331
4343
|
}
|
|
4332
|
-
for (const extension of
|
|
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-
|
|
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/
|
|
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
|
|
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
|
|
7832
|
+
for (const extension of RESOLVABLE_EXTENSIONS) {
|
|
7833
7833
|
if (known.has(base + extension)) return base + extension;
|
|
7834
7834
|
}
|
|
7835
|
-
const written =
|
|
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
|
|
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
|
|
7855
|
-
|
|
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
|
|
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
|
|
7998
|
+
for (const extension of RESOLVABLE_EXTENSIONS) {
|
|
7995
7999
|
if (isFile(candidate + extension)) return candidate + extension;
|
|
7996
8000
|
}
|
|
7997
|
-
for (const extension of
|
|
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
|
|
8035
|
-
|
|
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
|
|
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,
|
|
8241
|
+
if (!hasSourceFiles(context2.cwd, EXTENSIONS)) {
|
|
8237
8242
|
return {
|
|
8238
8243
|
operations: [],
|
|
8239
|
-
skipped: `no file ${this.runner} can read (looked for ${
|
|
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,
|
|
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 ${
|
|
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-
|
|
9
|
+
} from "./chunk-5HM2TYYC.js";
|
|
10
10
|
import {
|
|
11
11
|
BaseAdapter
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-43HQ6BVJ.js";
|
|
13
13
|
import "./chunk-IL5PPLXE.js";
|
|
14
14
|
import "./chunk-V3NHPRLJ.js";
|
|
15
15
|
import "./chunk-O7REVMOC.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hublo/sentinel",
|
|
3
|
-
"version": "1.5.0-alpha.
|
|
3
|
+
"version": "1.5.0-alpha.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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|