@hublo/sentinel 1.5.0-alpha.35 → 1.5.0-alpha.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/dist/bin/sentinel.js +3 -3
- package/dist/{chunk-ZFNBW6S3.js → chunk-43HQ6BVJ.js} +24 -11
- package/dist/{chunk-JWOLL5JK.js → chunk-HLRY2VHZ.js} +169 -155
- package/dist/index.js +2 -2
- package/dist/{validate-7G2ORUZD.js → validate-FRMENKHM.js} +1 -1
- package/docs/using-sentinel.md +6 -0
- 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/README.md
CHANGED
|
@@ -171,20 +171,21 @@ throwaway run wants. Once a module has adopted, nothing is fetched any more: the
|
|
|
171
171
|
- [`docs/build-adoption.md`](docs/build-adoption.md) — moving a React app's Vite toolchain into sentinel: adoption changes the config's IMPORTS and nothing else, what sentinel owns versus what stays the app's, and why reading nothing is what makes losing nothing a guarantee.
|
|
172
172
|
- [`docs/typescript-adoption.md`](docs/typescript-adoption.md) — the adoption cheat sheet: the two adoption steps, the command model (verb x type x location), options, reading a report, and troubleshooting.
|
|
173
173
|
- [`docs/test-adoption.md`](docs/test-adoption.md) — migrating a module from jest to Vitest: the two steps, what the migration rewrites and what it refuses BY NAME, how the baseline gate proves nothing was lost, and the failures worth recognising.
|
|
174
|
-
- [`docs/static-analysis-adoption.md`](docs/static-analysis-adoption.md) — running the static analysis role on a module: the two steps, the
|
|
174
|
+
- [`docs/static-analysis-adoption.md`](docs/static-analysis-adoption.md) — running the static analysis role on a module: the two steps, the four output shapes, how to read each row and where its threshold comes from, declaring an entry point, and the two limits worth knowing before trusting a number.
|
|
175
175
|
- [`docs/typescript-traces.md`](docs/typescript-traces.md) — a **generated, versioned** reference of live command + output traces (every verb, option, config result and edge case) against the mock monorepo. Regenerate after CLI changes with `pnpm docs:traces`.
|
|
176
176
|
|
|
177
177
|
## Architecture: `target → runner → preset`
|
|
178
178
|
|
|
179
179
|
Every check is described by three layers:
|
|
180
180
|
|
|
181
|
-
| Layer | Flag | What it is
|
|
182
|
-
| -------------------- | --------------------------- |
|
|
183
|
-
| **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable
|
|
184
|
-
| **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable
|
|
185
|
-
| **
|
|
181
|
+
| Layer | Flag | What it is | Examples |
|
|
182
|
+
| -------------------- | --------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
183
|
+
| **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `dev` `test` `static-analysis` `runtime-analysis` |
|
|
184
|
+
| **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable | lint: `eslint`/`biome`/`oxlint` · types: `tsc`/`tsgo` · build: `vite` · test: `vitest` |
|
|
185
|
+
| **shape** (output) | `--output=<shape>` | what a reporting role WRITES, not what it measures | `console` (default, writes nothing) `html` `json` `markdown`; roles that do not report ignore it |
|
|
186
|
+
| **preset** (preset) | detected / `--preset` | the _variant_ per stack (strict by default) | `react` `nest` `node` `svelte` (svelte: lint + format; no tsconfig preset yet, so the TypeScript role stands aside on those modules and the other two adopt) |
|
|
186
187
|
|
|
187
|
-
A run is `target × runner × preset`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir.
|
|
188
|
+
A run is `target × runner × preset`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir. The shape is not part of that identity: it decides what the run WRITES, not what it does.
|
|
188
189
|
|
|
189
190
|
- `--runner` is an **optional override on a central default**. `sentinel --lint` uses the configured default runner, so swapping a tool globally is a one-place change; `--runner=biome` overrides for a single run (great for benchmarking eslint vs biome vs oxlint, and for gradual migration).
|
|
190
191
|
- The **preset is detected** from the module's dependencies (deterministic: a framework dep → its preset, else `node`), and **`--preset` overrides it**. Detection can be wrong where deps are hoisted at the repo root (it returns `node`), so pass `--preset` for `--init` (that is where the preset is chosen). `--run`/`--report` don't depend on it, they run the tool on the committed config.
|
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-HLRY2VHZ.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,
|
|
@@ -5691,10 +5692,19 @@ async function loadStaticConfig(cwd) {
|
|
|
5691
5692
|
return {
|
|
5692
5693
|
config: defineStaticAnalysis(),
|
|
5693
5694
|
from: null,
|
|
5694
|
-
unreadable: `${STATIC_CONFIG_FILE}: ${
|
|
5695
|
+
unreadable: `${STATIC_CONFIG_FILE}: ${explainUnreadableConfig(error)}`
|
|
5695
5696
|
};
|
|
5696
5697
|
}
|
|
5697
5698
|
}
|
|
5699
|
+
function explainUnreadableConfig(error) {
|
|
5700
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5701
|
+
const code = error.code;
|
|
5702
|
+
if (code !== "ERR_PACKAGE_PATH_NOT_EXPORTED" || !message.includes("@hublo/sentinel")) {
|
|
5703
|
+
return message;
|
|
5704
|
+
}
|
|
5705
|
+
return `${message}
|
|
5706
|
+
The @hublo/sentinel installed in this module does not ship the static-analysis role yet. Bump it to a release that does, then run this again. Until then the role runs on its defaults and this file is ignored, which is why it is reported rather than silently skipped.`;
|
|
5707
|
+
}
|
|
5698
5708
|
function adopt(exported) {
|
|
5699
5709
|
if (exported === null || typeof exported !== "object") return defineStaticAnalysis();
|
|
5700
5710
|
if (Array.isArray(exported.moved)) {
|
|
@@ -6018,7 +6028,137 @@ function unreadableNote(coverage) {
|
|
|
6018
6028
|
import { readFileSync as readFileSync17 } from "node:fs";
|
|
6019
6029
|
import { join as join23 } from "node:path";
|
|
6020
6030
|
|
|
6021
|
-
// src/roles/static-analysis/
|
|
6031
|
+
// src/roles/static-analysis/maintainability/size-rules.ts
|
|
6032
|
+
import { mkdtempSync as mkdtempSync2, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
6033
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
6034
|
+
import { join as join22 } from "node:path";
|
|
6035
|
+
var GAUGE_THRESHOLDS = {
|
|
6036
|
+
// McCabe 1976 says 10, our p90 says 11. The only indicator where two independent candidates agree
|
|
6037
|
+
// within one unit, and 94.8% of our functions already sit in the 1-10 band SEI calls low risk.
|
|
6038
|
+
complexity: 10,
|
|
6039
|
+
// IfSQ SP-2, "few people can understand nesting to more than 3 levels". Our p90 agrees at 3.
|
|
6040
|
+
"max-depth": 3,
|
|
6041
|
+
// No research anywhere for a FILE's length: IfSQ's three citations (500, 100-150, 143) are all
|
|
6042
|
+
// about routines. So our p90 of 157, rounded down.
|
|
6043
|
+
"max-lines": 150,
|
|
6044
|
+
// Our p90. IfSQ SP-1 says 150 and is discarded for being more permissive than our own code.
|
|
6045
|
+
"max-lines-per-function": 109,
|
|
6046
|
+
// No research. Our p90 is 15, the rule's default is 10, so the default stays as the stricter one.
|
|
6047
|
+
"max-statements": 10,
|
|
6048
|
+
// No research, and none needed: beyond three parameters the fix is an object or currying, so this
|
|
6049
|
+
// is a design boundary and not a dial. Our p90 of 4 is the looser candidate and loses.
|
|
6050
|
+
"max-params": 3,
|
|
6051
|
+
// Same nesting family as `max-depth`, no citation of its own. Our p90 agrees at 3.
|
|
6052
|
+
"max-nested-callbacks": 3,
|
|
6053
|
+
// 1 is the rule's floor, there is no stricter value to pick.
|
|
6054
|
+
"max-classes-per-file": 1,
|
|
6055
|
+
// Cognitive complexity, Campbell 2018, measured by Sonar's own rule, loaded
|
|
6056
|
+
// through `plugins/sonar-cognitive.js`.
|
|
6057
|
+
// Sonar publishes 15 for S3776, and that is a vendor default rather than a research value: one of
|
|
6058
|
+
// their own engineers has said publicly that no formula supports it. Our p90 is 7, so 7 wins, and
|
|
6059
|
+
// it reports 342 of 8 083 files, 4.2%, which sits inside the band the eight above occupy.
|
|
6060
|
+
"cognitive-complexity": 7
|
|
6061
|
+
};
|
|
6062
|
+
var COGNITIVE = "cognitive-complexity";
|
|
6063
|
+
var cognitivePlugin = () => join22(ownPackageDir(), "plugins", "sonar-cognitive.js");
|
|
6064
|
+
var CATEGORIES = [
|
|
6065
|
+
"correctness",
|
|
6066
|
+
"suspicious",
|
|
6067
|
+
"pedantic",
|
|
6068
|
+
"perf",
|
|
6069
|
+
"style",
|
|
6070
|
+
"restriction",
|
|
6071
|
+
"nursery"
|
|
6072
|
+
];
|
|
6073
|
+
function sizeRulesConfig() {
|
|
6074
|
+
return {
|
|
6075
|
+
plugins: [],
|
|
6076
|
+
categories: Object.fromEntries(CATEGORIES.map((category) => [category, "off"])),
|
|
6077
|
+
// Sonar's cognitive complexity, in the SAME run as the eight above rather than a second
|
|
6078
|
+
// invocation: the parse is the expensive part and it is already paid here. Measured over the
|
|
6079
|
+
// whole bench, 8 083 files, the rule adds 2.4 s to a pass that was going to read them anyway.
|
|
6080
|
+
jsPlugins: [cognitivePlugin()],
|
|
6081
|
+
// ⛔ `warn` and not `error`, for the reason `tool-config.ts` gives about jscpd's `threshold`:
|
|
6082
|
+
// at `error` oxlint exits 1 as soon as one file breaches, which is indistinguishable from the
|
|
6083
|
+
// binary failing to run. Measured on `libs/nest/auth`, same config otherwise: `error` exits 1,
|
|
6084
|
+
// `warn` exits 0, and both report the same 18 diagnostics. So the tool measures, this role
|
|
6085
|
+
// judges, and a non-zero exit means the tool failed.
|
|
6086
|
+
rules: Object.fromEntries(
|
|
6087
|
+
Object.entries(GAUGE_THRESHOLDS).map(([rule, threshold]) => [
|
|
6088
|
+
rule === COGNITIVE ? `sonar/${rule}` : rule,
|
|
6089
|
+
["warn", threshold]
|
|
6090
|
+
])
|
|
6091
|
+
)
|
|
6092
|
+
};
|
|
6093
|
+
}
|
|
6094
|
+
function sizeRulesRun(paths, ignore) {
|
|
6095
|
+
const dir = mkdtempSync2(join22(tmpdir2(), "sentinel-size-"));
|
|
6096
|
+
const configPath = join22(dir, "oxlintrc.json");
|
|
6097
|
+
writeFileSync4(configPath, `${JSON.stringify(sizeRulesConfig(), null, 2)}
|
|
6098
|
+
`, "utf8");
|
|
6099
|
+
return {
|
|
6100
|
+
// `--format json` because the console shape is rendered by this role from the figures: a
|
|
6101
|
+
// per-pass table from the tool would be a second voice saying the same numbers differently.
|
|
6102
|
+
// The exclusions are the SAME list the duplication product pass is given, so "the module's
|
|
6103
|
+
// code" means one thing across the whole report.
|
|
6104
|
+
args: [
|
|
6105
|
+
"-c",
|
|
6106
|
+
configPath,
|
|
6107
|
+
...ignore.flatMap((pattern) => ["--ignore-pattern", pattern]),
|
|
6108
|
+
"--format",
|
|
6109
|
+
"json",
|
|
6110
|
+
...paths
|
|
6111
|
+
],
|
|
6112
|
+
dispose: () => rmSync4(dir, { force: true, recursive: true })
|
|
6113
|
+
};
|
|
6114
|
+
}
|
|
6115
|
+
function ruleOf(code) {
|
|
6116
|
+
return /\(([^)]+)\)\s*$/.exec(code)?.[1];
|
|
6117
|
+
}
|
|
6118
|
+
function valueOf(message) {
|
|
6119
|
+
const match = /\((\d+)\)/.exec(message) ?? /complexity of (\d+)/.exec(message) ?? // Sonar's own wording, a third shape again: "reduce its Cognitive Complexity from 7 to the 0
|
|
6120
|
+
// allowed". It parenthesises nothing, and the `0` after it is the threshold, not the value.
|
|
6121
|
+
/Complexity from (\d+) to/.exec(message);
|
|
6122
|
+
return Number(match?.[1] ?? 0);
|
|
6123
|
+
}
|
|
6124
|
+
function breachesOf(raw) {
|
|
6125
|
+
let parsed;
|
|
6126
|
+
try {
|
|
6127
|
+
parsed = JSON.parse(raw);
|
|
6128
|
+
} catch {
|
|
6129
|
+
return { breaches: [], diagnostics: 0, files: 0 };
|
|
6130
|
+
}
|
|
6131
|
+
const diagnostics = parsed.diagnostics ?? [];
|
|
6132
|
+
const worst = /* @__PURE__ */ new Map();
|
|
6133
|
+
for (const diagnostic of diagnostics) {
|
|
6134
|
+
const rule = ruleOf(diagnostic.code ?? "");
|
|
6135
|
+
const file = diagnostic.filename;
|
|
6136
|
+
if (!rule || !file || !(rule in GAUGE_THRESHOLDS)) continue;
|
|
6137
|
+
const value = valueOf(diagnostic.message ?? "");
|
|
6138
|
+
const line = diagnostic.labels?.[0]?.span?.line ?? 0;
|
|
6139
|
+
const key = `${file}\0${rule}`;
|
|
6140
|
+
const seen = worst.get(key);
|
|
6141
|
+
if (!seen || value > seen.value) worst.set(key, { file, rule, value, line });
|
|
6142
|
+
}
|
|
6143
|
+
return {
|
|
6144
|
+
breaches: [...worst.values()],
|
|
6145
|
+
diagnostics: diagnostics.length,
|
|
6146
|
+
files: parsed.number_of_files ?? 0
|
|
6147
|
+
};
|
|
6148
|
+
}
|
|
6149
|
+
function scanSizeRules(cwd, roots, ignore, binary, spawn) {
|
|
6150
|
+
if (!binary) return void 0;
|
|
6151
|
+
const run = sizeRulesRun(roots, ignore);
|
|
6152
|
+
try {
|
|
6153
|
+
const result = spawn(binary, run.args, cwd);
|
|
6154
|
+
if (result.status !== 0) return void 0;
|
|
6155
|
+
return breachesOf(result.stdout);
|
|
6156
|
+
} finally {
|
|
6157
|
+
run.dispose();
|
|
6158
|
+
}
|
|
6159
|
+
}
|
|
6160
|
+
|
|
6161
|
+
// src/roles/static-analysis/bundle/help.ts
|
|
6022
6162
|
var WEB_DEV_TREE_SHAKING = "https://web.dev/articles/reduce-javascript-payloads-with-tree-shaking";
|
|
6023
6163
|
var WEB_DEV_SCRIPT_EVALUATION = "https://web.dev/articles/script-evaluation-and-long-tasks";
|
|
6024
6164
|
var V8_COST_2019 = "https://v8.dev/blog/cost-of-javascript-2019";
|
|
@@ -6121,7 +6261,7 @@ var context = {
|
|
|
6121
6261
|
};
|
|
6122
6262
|
var bundleHelp = { ...actionable, ...context };
|
|
6123
6263
|
|
|
6124
|
-
// src/roles/static-analysis/help
|
|
6264
|
+
// src/roles/static-analysis/report-help.ts
|
|
6125
6265
|
var metricsHelp = {
|
|
6126
6266
|
duplicatedLinesPercent: {
|
|
6127
6267
|
label: "Duplicated lines",
|
|
@@ -6379,136 +6519,6 @@ function helpFor(key) {
|
|
|
6379
6519
|
return metricsHelp[key];
|
|
6380
6520
|
}
|
|
6381
6521
|
|
|
6382
|
-
// src/roles/static-analysis/maintainability/size-rules.ts
|
|
6383
|
-
import { mkdtempSync as mkdtempSync2, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
6384
|
-
import { tmpdir as tmpdir2 } from "node:os";
|
|
6385
|
-
import { join as join22 } from "node:path";
|
|
6386
|
-
var GAUGE_THRESHOLDS = {
|
|
6387
|
-
// McCabe 1976 says 10, our p90 says 11. The only indicator where two independent candidates agree
|
|
6388
|
-
// within one unit, and 94.8% of our functions already sit in the 1-10 band SEI calls low risk.
|
|
6389
|
-
complexity: 10,
|
|
6390
|
-
// IfSQ SP-2, "few people can understand nesting to more than 3 levels". Our p90 agrees at 3.
|
|
6391
|
-
"max-depth": 3,
|
|
6392
|
-
// No research anywhere for a FILE's length: IfSQ's three citations (500, 100-150, 143) are all
|
|
6393
|
-
// about routines. So our p90 of 157, rounded down.
|
|
6394
|
-
"max-lines": 150,
|
|
6395
|
-
// Our p90. IfSQ SP-1 says 150 and is discarded for being more permissive than our own code.
|
|
6396
|
-
"max-lines-per-function": 109,
|
|
6397
|
-
// No research. Our p90 is 15, the rule's default is 10, so the default stays as the stricter one.
|
|
6398
|
-
"max-statements": 10,
|
|
6399
|
-
// No research, and none needed: beyond three parameters the fix is an object or currying, so this
|
|
6400
|
-
// is a design boundary and not a dial. Our p90 of 4 is the looser candidate and loses.
|
|
6401
|
-
"max-params": 3,
|
|
6402
|
-
// Same nesting family as `max-depth`, no citation of its own. Our p90 agrees at 3.
|
|
6403
|
-
"max-nested-callbacks": 3,
|
|
6404
|
-
// 1 is the rule's floor, there is no stricter value to pick.
|
|
6405
|
-
"max-classes-per-file": 1,
|
|
6406
|
-
// Cognitive complexity, Campbell 2018, measured by Sonar's own rule, loaded
|
|
6407
|
-
// through `plugins/sonar-cognitive.js`.
|
|
6408
|
-
// Sonar publishes 15 for S3776, and that is a vendor default rather than a research value: one of
|
|
6409
|
-
// their own engineers has said publicly that no formula supports it. Our p90 is 7, so 7 wins, and
|
|
6410
|
-
// it reports 342 of 8 083 files, 4.2%, which sits inside the band the eight above occupy.
|
|
6411
|
-
"cognitive-complexity": 7
|
|
6412
|
-
};
|
|
6413
|
-
var COGNITIVE = "cognitive-complexity";
|
|
6414
|
-
var cognitivePlugin = () => join22(ownPackageDir(), "plugins", "sonar-cognitive.js");
|
|
6415
|
-
var CATEGORIES = [
|
|
6416
|
-
"correctness",
|
|
6417
|
-
"suspicious",
|
|
6418
|
-
"pedantic",
|
|
6419
|
-
"perf",
|
|
6420
|
-
"style",
|
|
6421
|
-
"restriction",
|
|
6422
|
-
"nursery"
|
|
6423
|
-
];
|
|
6424
|
-
function sizeRulesConfig() {
|
|
6425
|
-
return {
|
|
6426
|
-
plugins: [],
|
|
6427
|
-
categories: Object.fromEntries(CATEGORIES.map((category) => [category, "off"])),
|
|
6428
|
-
// Sonar's cognitive complexity, in the SAME run as the eight above rather than a second
|
|
6429
|
-
// invocation: the parse is the expensive part and it is already paid here. Measured over the
|
|
6430
|
-
// whole bench, 8 083 files, the rule adds 2.4 s to a pass that was going to read them anyway.
|
|
6431
|
-
jsPlugins: [cognitivePlugin()],
|
|
6432
|
-
// ⛔ `warn` and not `error`, for the reason `tool-config.ts` gives about jscpd's `threshold`:
|
|
6433
|
-
// at `error` oxlint exits 1 as soon as one file breaches, which is indistinguishable from the
|
|
6434
|
-
// binary failing to run. Measured on `libs/nest/auth`, same config otherwise: `error` exits 1,
|
|
6435
|
-
// `warn` exits 0, and both report the same 18 diagnostics. So the tool measures, this role
|
|
6436
|
-
// judges, and a non-zero exit means the tool failed.
|
|
6437
|
-
rules: Object.fromEntries(
|
|
6438
|
-
Object.entries(GAUGE_THRESHOLDS).map(([rule, threshold]) => [
|
|
6439
|
-
rule === COGNITIVE ? `sonar/${rule}` : rule,
|
|
6440
|
-
["warn", threshold]
|
|
6441
|
-
])
|
|
6442
|
-
)
|
|
6443
|
-
};
|
|
6444
|
-
}
|
|
6445
|
-
function sizeRulesRun(paths, ignore) {
|
|
6446
|
-
const dir = mkdtempSync2(join22(tmpdir2(), "sentinel-size-"));
|
|
6447
|
-
const configPath = join22(dir, "oxlintrc.json");
|
|
6448
|
-
writeFileSync4(configPath, `${JSON.stringify(sizeRulesConfig(), null, 2)}
|
|
6449
|
-
`, "utf8");
|
|
6450
|
-
return {
|
|
6451
|
-
// `--format json` because the console shape is rendered by this role from the figures: a
|
|
6452
|
-
// per-pass table from the tool would be a second voice saying the same numbers differently.
|
|
6453
|
-
// The exclusions are the SAME list the duplication product pass is given, so "the module's
|
|
6454
|
-
// code" means one thing across the whole report.
|
|
6455
|
-
args: [
|
|
6456
|
-
"-c",
|
|
6457
|
-
configPath,
|
|
6458
|
-
...ignore.flatMap((pattern) => ["--ignore-pattern", pattern]),
|
|
6459
|
-
"--format",
|
|
6460
|
-
"json",
|
|
6461
|
-
...paths
|
|
6462
|
-
],
|
|
6463
|
-
dispose: () => rmSync4(dir, { force: true, recursive: true })
|
|
6464
|
-
};
|
|
6465
|
-
}
|
|
6466
|
-
function ruleOf(code) {
|
|
6467
|
-
return /\(([^)]+)\)\s*$/.exec(code)?.[1];
|
|
6468
|
-
}
|
|
6469
|
-
function valueOf(message) {
|
|
6470
|
-
const match = /\((\d+)\)/.exec(message) ?? /complexity of (\d+)/.exec(message) ?? // Sonar's own wording, a third shape again: "reduce its Cognitive Complexity from 7 to the 0
|
|
6471
|
-
// allowed". It parenthesises nothing, and the `0` after it is the threshold, not the value.
|
|
6472
|
-
/Complexity from (\d+) to/.exec(message);
|
|
6473
|
-
return Number(match?.[1] ?? 0);
|
|
6474
|
-
}
|
|
6475
|
-
function breachesOf(raw) {
|
|
6476
|
-
let parsed;
|
|
6477
|
-
try {
|
|
6478
|
-
parsed = JSON.parse(raw);
|
|
6479
|
-
} catch {
|
|
6480
|
-
return { breaches: [], diagnostics: 0, files: 0 };
|
|
6481
|
-
}
|
|
6482
|
-
const diagnostics = parsed.diagnostics ?? [];
|
|
6483
|
-
const worst = /* @__PURE__ */ new Map();
|
|
6484
|
-
for (const diagnostic of diagnostics) {
|
|
6485
|
-
const rule = ruleOf(diagnostic.code ?? "");
|
|
6486
|
-
const file = diagnostic.filename;
|
|
6487
|
-
if (!rule || !file || !(rule in GAUGE_THRESHOLDS)) continue;
|
|
6488
|
-
const value = valueOf(diagnostic.message ?? "");
|
|
6489
|
-
const line = diagnostic.labels?.[0]?.span?.line ?? 0;
|
|
6490
|
-
const key = `${file}\0${rule}`;
|
|
6491
|
-
const seen = worst.get(key);
|
|
6492
|
-
if (!seen || value > seen.value) worst.set(key, { file, rule, value, line });
|
|
6493
|
-
}
|
|
6494
|
-
return {
|
|
6495
|
-
breaches: [...worst.values()],
|
|
6496
|
-
diagnostics: diagnostics.length,
|
|
6497
|
-
files: parsed.number_of_files ?? 0
|
|
6498
|
-
};
|
|
6499
|
-
}
|
|
6500
|
-
function scanSizeRules(cwd, roots, ignore, binary, spawn) {
|
|
6501
|
-
if (!binary) return void 0;
|
|
6502
|
-
const run = sizeRulesRun(roots, ignore);
|
|
6503
|
-
try {
|
|
6504
|
-
const result = spawn(binary, run.args, cwd);
|
|
6505
|
-
if (result.status !== 0) return void 0;
|
|
6506
|
-
return breachesOf(result.stdout);
|
|
6507
|
-
} finally {
|
|
6508
|
-
run.dispose();
|
|
6509
|
-
}
|
|
6510
|
-
}
|
|
6511
|
-
|
|
6512
6522
|
// src/roles/static-analysis/report.ts
|
|
6513
6523
|
var FAMILIES = [
|
|
6514
6524
|
{
|
|
@@ -7774,7 +7784,6 @@ var SCRIPT = `(function(){
|
|
|
7774
7784
|
|
|
7775
7785
|
// src/roles/static-analysis/graph/imports.ts
|
|
7776
7786
|
import { dirname, join as join24 } from "node:path";
|
|
7777
|
-
var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
7778
7787
|
var TS_BEHIND_JS = {
|
|
7779
7788
|
".js": [".ts", ".tsx"],
|
|
7780
7789
|
".mjs": [".mts"],
|
|
@@ -7829,38 +7838,43 @@ function resolveSpecifier(from, specifier, known) {
|
|
|
7829
7838
|
if (!specifier.startsWith(".")) return void 0;
|
|
7830
7839
|
const base = join24(dirname(from), specifier);
|
|
7831
7840
|
if (known.has(base)) return base;
|
|
7832
|
-
for (const extension of
|
|
7841
|
+
for (const extension of RESOLVABLE_EXTENSIONS) {
|
|
7833
7842
|
if (known.has(base + extension)) return base + extension;
|
|
7834
7843
|
}
|
|
7835
|
-
const written =
|
|
7844
|
+
const written = RESOLVABLE_EXTENSIONS.find((extension) => base.endsWith(extension));
|
|
7836
7845
|
for (const extension of TS_BEHIND_JS[written ?? ""] ?? []) {
|
|
7837
7846
|
const swapped = base.slice(0, -(written ?? "").length) + extension;
|
|
7838
7847
|
if (known.has(swapped)) return swapped;
|
|
7839
7848
|
}
|
|
7840
|
-
for (const extension of
|
|
7849
|
+
for (const extension of RESOLVABLE_EXTENSIONS) {
|
|
7841
7850
|
const index = join24(base, `index${extension}`);
|
|
7842
7851
|
if (known.has(index)) return index;
|
|
7843
7852
|
}
|
|
7844
7853
|
return void 0;
|
|
7845
7854
|
}
|
|
7846
7855
|
function collectImports(known) {
|
|
7847
|
-
const all2 =
|
|
7848
|
-
const value =
|
|
7856
|
+
const all2 = /* @__PURE__ */ new Map();
|
|
7857
|
+
const value = /* @__PURE__ */ new Map();
|
|
7858
|
+
const edgesOf = (tree, path2) => {
|
|
7859
|
+
const found = tree.get(path2);
|
|
7860
|
+
if (found !== void 0) return found;
|
|
7861
|
+
const created = /* @__PURE__ */ new Set();
|
|
7862
|
+
tree.set(path2, created);
|
|
7863
|
+
return created;
|
|
7864
|
+
};
|
|
7849
7865
|
return {
|
|
7850
7866
|
read: ({ path: path2, program }) => {
|
|
7851
7867
|
for (const { specifier, typeOnly } of specifiersOf(program)) {
|
|
7852
7868
|
const target = resolveSpecifier(path2, specifier, known);
|
|
7853
7869
|
if (!target || target === path2) continue;
|
|
7854
|
-
all2
|
|
7855
|
-
|
|
7856
|
-
if (!all2[path2].includes(target)) all2[path2].push(target);
|
|
7857
|
-
if (!typeOnly && !value[path2].includes(target)) value[path2].push(target);
|
|
7870
|
+
edgesOf(all2, path2).add(target);
|
|
7871
|
+
if (!typeOnly) edgesOf(value, path2).add(target);
|
|
7858
7872
|
}
|
|
7859
7873
|
},
|
|
7860
7874
|
finish: (files) => {
|
|
7861
7875
|
const ordered = (tree) => {
|
|
7862
7876
|
const out = {};
|
|
7863
|
-
for (const file of files) out[file] = tree
|
|
7877
|
+
for (const file of files) out[file] = [...tree.get(file) ?? []];
|
|
7864
7878
|
return out;
|
|
7865
7879
|
};
|
|
7866
7880
|
return { all: ordered(all2), value: ordered(value) };
|
|
@@ -7951,7 +7965,6 @@ import { dirname as dirname2, join as join25, relative as relative4, resolve as
|
|
|
7951
7965
|
var CACHE_DIR = join25("node_modules", ".cache", "sentinel");
|
|
7952
7966
|
var CACHE_FILE = "consumer-index.json";
|
|
7953
7967
|
var SHAPE = 4;
|
|
7954
|
-
var EXTENSIONS2 = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
7955
7968
|
function workspaceRootFrom(cwd) {
|
|
7956
7969
|
let dir = resolve3(cwd);
|
|
7957
7970
|
for (; ; ) {
|
|
@@ -7991,10 +8004,10 @@ function resolveSpecifier2(specifier, table, workspaceRoot) {
|
|
|
7991
8004
|
};
|
|
7992
8005
|
const exists = (candidate) => {
|
|
7993
8006
|
if (isFile(candidate)) return candidate;
|
|
7994
|
-
for (const extension of
|
|
8007
|
+
for (const extension of RESOLVABLE_EXTENSIONS) {
|
|
7995
8008
|
if (isFile(candidate + extension)) return candidate + extension;
|
|
7996
8009
|
}
|
|
7997
|
-
for (const extension of
|
|
8010
|
+
for (const extension of RESOLVABLE_EXTENSIONS) {
|
|
7998
8011
|
const index = `${candidate}/index${extension}`;
|
|
7999
8012
|
if (isFile(index)) return index;
|
|
8000
8013
|
}
|
|
@@ -8031,8 +8044,9 @@ function resolveRelative(importer, specifier, workspaceRoot) {
|
|
|
8031
8044
|
}
|
|
8032
8045
|
};
|
|
8033
8046
|
if (isFile(base)) return base;
|
|
8034
|
-
for (const extension of
|
|
8035
|
-
|
|
8047
|
+
for (const extension of RESOLVABLE_EXTENSIONS)
|
|
8048
|
+
if (isFile(base + extension)) return base + extension;
|
|
8049
|
+
for (const extension of RESOLVABLE_EXTENSIONS) {
|
|
8036
8050
|
const index = `${base}/index${extension}`;
|
|
8037
8051
|
if (isFile(index)) return index;
|
|
8038
8052
|
}
|
|
@@ -8174,7 +8188,7 @@ function resolveJscpd(cwd) {
|
|
|
8174
8188
|
}
|
|
8175
8189
|
|
|
8176
8190
|
// src/roles/static-analysis/adapters/jscpd/jscpd.adapter.ts
|
|
8177
|
-
var
|
|
8191
|
+
var EXTENSIONS = extensionsOf(defaults.languages);
|
|
8178
8192
|
var scripts = () => ({
|
|
8179
8193
|
[SCRIPTS.console]: `sentinel --run --${TARGET}`,
|
|
8180
8194
|
[SCRIPTS.json]: `sentinel --run --${TARGET} --output json`,
|
|
@@ -8233,10 +8247,10 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
8233
8247
|
return true;
|
|
8234
8248
|
}
|
|
8235
8249
|
plan(context2) {
|
|
8236
|
-
if (!hasSourceFiles(context2.cwd,
|
|
8250
|
+
if (!hasSourceFiles(context2.cwd, EXTENSIONS)) {
|
|
8237
8251
|
return {
|
|
8238
8252
|
operations: [],
|
|
8239
|
-
skipped: `no file ${this.runner} can read (looked for ${
|
|
8253
|
+
skipped: `no file ${this.runner} can read (looked for ${EXTENSIONS.join(", ")}).`
|
|
8240
8254
|
};
|
|
8241
8255
|
}
|
|
8242
8256
|
return {
|
|
@@ -8274,9 +8288,9 @@ var JscpdAdapter = class extends BaseAdapter {
|
|
|
8274
8288
|
);
|
|
8275
8289
|
return { ok: false, code: 1 };
|
|
8276
8290
|
}
|
|
8277
|
-
if (!hasSourceFiles(ctx.cwd,
|
|
8291
|
+
if (!hasSourceFiles(ctx.cwd, EXTENSIONS)) {
|
|
8278
8292
|
process.stderr.write(
|
|
8279
|
-
`sentinel static-analysis(jscpd): no file to measure in this module (looked for ${
|
|
8293
|
+
`sentinel static-analysis(jscpd): no file to measure in this module (looked for ${EXTENSIONS.join(", ")}).
|
|
8280
8294
|
`
|
|
8281
8295
|
);
|
|
8282
8296
|
return { ok: true, code: 0 };
|
package/dist/index.js
CHANGED
|
@@ -6,10 +6,10 @@ import {
|
|
|
6
6
|
registerAdapters,
|
|
7
7
|
resolve,
|
|
8
8
|
setDefaultRunner
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-HLRY2VHZ.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/docs/using-sentinel.md
CHANGED
|
@@ -36,6 +36,12 @@ everywhere (`--run --json` and `--inspect` answer them), and `--migrate` is refu
|
|
|
36
36
|
`--json` works for every verb, and stdout carries **only** the envelope, so `| jq` always
|
|
37
37
|
parses. `--dry-run` applies to `--init` and writes nothing.
|
|
38
38
|
|
|
39
|
+
`--output <shape>` is different, and only a role that WRITES a report reads it: `console` is the
|
|
40
|
+
default and writes no file, `html` is the page you open and send, `json` is for CI and for a
|
|
41
|
+
script, `markdown` is the six lines a workflow posts on a pull request. Today `--static-analysis`
|
|
42
|
+
is the role that has them. ⚠️ `--output html` pays a graph layout the other three skip, which is
|
|
43
|
+
fine for a page somebody opens and wrong for a commit hook.
|
|
44
|
+
|
|
39
45
|
**`--validate` is a verb of its own**, and the proof of a migration lives there rather than on
|
|
40
46
|
`--init` or `--run`. Run before a migration it records what the module does today; run after, it
|
|
41
47
|
compares by test NAME and refuses a run that lost one or invented one. What separates it from
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hublo/sentinel",
|
|
3
|
-
"version": "1.5.0-alpha.
|
|
3
|
+
"version": "1.5.0-alpha.37",
|
|
4
4
|
"description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ban `palette.common` and `common.*` colour paths in product code.
|
|
3
|
+
*
|
|
4
|
+
* oxlint has no `no-restricted-syntax`, so the eight AST selectors that used to live in
|
|
5
|
+
* `restricted-syntax.cjs` cannot be carried across as configuration. They are expressed as a real
|
|
6
|
+
* rule instead, which is what oxlint's JS plugin API is for. The originals, and where each is
|
|
7
|
+
* handled below:
|
|
8
|
+
*
|
|
9
|
+
* 1-2 MemberExpression[object.property.name='palette'][property.name='common'] theme.palette.common
|
|
10
|
+
* …[computed=true][property.value='common'] theme.palette['common']
|
|
11
|
+
* 3-4 MemberExpression[object.name='palette']… palette.common
|
|
12
|
+
* 5-6 Property[key.name|key.value=/<colour props>/] > Literal[value=/^common\./] { color: 'common.white' }
|
|
13
|
+
* 7-8 JSXAttribute[name.name=/<colour props>/] > Literal | JSXExpressionContainer color="common.white"
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const MESSAGE =
|
|
17
|
+
'Do not use palette.common or common.* color paths in product code. Use semantic palette tokens instead.'
|
|
18
|
+
|
|
19
|
+
/** The MUI props that take a colour path, so `common.*` in them is the same mistake. */
|
|
20
|
+
const COLOUR_PROPS =
|
|
21
|
+
/^(color|bgcolor|background|backgroundColor|borderColor|borderTopColor|borderRightColor|borderBottomColor|borderLeftColor|outlineColor|fill|stroke|textDecorationColor|caretColor|accentColor)$/
|
|
22
|
+
|
|
23
|
+
/** `common.something` as a string value, e.g. `{ color: 'common.white' }`. */
|
|
24
|
+
const isCommonPath = (node) =>
|
|
25
|
+
node?.type === 'Literal' && typeof node.value === 'string' && /^common\./.test(node.value)
|
|
26
|
+
|
|
27
|
+
/** @type {import("oxlint").Rule} */
|
|
28
|
+
export const rule = {
|
|
29
|
+
meta: {
|
|
30
|
+
docs: {
|
|
31
|
+
description: 'Ban palette.common and common.* colour paths in product code.',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
create(context) {
|
|
35
|
+
const report = (node) => context.report({ node, message: MESSAGE })
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
// 1-4: reaching `common` off a palette, by property or by computed key.
|
|
39
|
+
MemberExpression(node) {
|
|
40
|
+
const onPalette =
|
|
41
|
+
node.object?.property?.name === 'palette' || node.object?.name === 'palette'
|
|
42
|
+
if (!onPalette) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const reachesCommon =
|
|
46
|
+
node.property?.name === 'common' || (node.computed && node.property?.value === 'common')
|
|
47
|
+
if (reachesCommon) {
|
|
48
|
+
report(node)
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
// 5-6: `{ color: 'common.white' }` in an object literal, key written either way.
|
|
53
|
+
Property(node) {
|
|
54
|
+
const key = node.key?.name ?? node.key?.value
|
|
55
|
+
if (typeof key === 'string' && COLOUR_PROPS.test(key) && isCommonPath(node.value)) {
|
|
56
|
+
report(node.value)
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// 7-8: `color="common.white"` and `color={'common.white'}` on a JSX element.
|
|
61
|
+
JSXAttribute(node) {
|
|
62
|
+
const name = node.name?.name
|
|
63
|
+
if (typeof name !== 'string' || !COLOUR_PROPS.test(name)) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const value = node.value
|
|
67
|
+
if (isCommonPath(value)) {
|
|
68
|
+
return report(value)
|
|
69
|
+
}
|
|
70
|
+
if (value?.type === 'JSXExpressionContainer' && isCommonPath(value.expression)) {
|
|
71
|
+
report(value.expression)
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ban regex lookbehind, which Safari below 16.4 cannot parse.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ Not a style rule and not a carried-over selector: a browser-support rule. Unlike everything
|
|
5
|
+
* else a preset controls, lookbehind can be neither transpiled nor polyfilled, because no bundler
|
|
6
|
+
* rewrites the contents of a regex literal. Lint is the only layer left that can catch it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Lookbehind, positive or negative. NOT named groups `(?<name>)`, which Safari has had since
|
|
11
|
+
* 11.3: the class is exactly `(?<` followed by `=` or `!`.
|
|
12
|
+
*/
|
|
13
|
+
const LOOKBEHIND = /\(\?<[=!]/
|
|
14
|
+
|
|
15
|
+
const LOOKBEHIND_MESSAGE =
|
|
16
|
+
'Regex lookbehind ((?<=) / (?<!)) is Safari 16.4+ and can be neither transpiled nor polyfilled: on older iOS the literal is a parse-time SyntaxError that takes the whole chunk with it. Rewrite the pattern. Named groups (?<name>) are fine.'
|
|
17
|
+
|
|
18
|
+
/** @type {import("oxlint").Rule} */
|
|
19
|
+
export const rule = {
|
|
20
|
+
meta: {
|
|
21
|
+
docs: {
|
|
22
|
+
description: 'Ban regex lookbehind, unsupported by Safari below 16.4.',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
create(context) {
|
|
26
|
+
const report = (node) => context.report({ node, message: LOOKBEHIND_MESSAGE })
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `new RegExp(...)` and `RegExp(...)`, when the pattern is readable statically: a
|
|
30
|
+
* string, or a template whose fixed parts carry the lookbehind.
|
|
31
|
+
*
|
|
32
|
+
* Each template chunk is tested on its own rather than joined. Joining invents a
|
|
33
|
+
* `(?<=` that the runtime string never has, across an interpolation boundary:
|
|
34
|
+
* `` `(?<${op}=x)` `` would read as one.
|
|
35
|
+
*/
|
|
36
|
+
const isStaticLookbehind = (pattern) => {
|
|
37
|
+
if (pattern?.type === 'Literal') {
|
|
38
|
+
return typeof pattern.value === 'string' && LOOKBEHIND.test(pattern.value)
|
|
39
|
+
}
|
|
40
|
+
if (pattern?.type === 'TemplateLiteral') {
|
|
41
|
+
return (pattern.quasis ?? []).some((quasi) =>
|
|
42
|
+
LOOKBEHIND.test(quasi.value?.cooked ?? quasi.value?.raw ?? ''),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const checkRegExpCall = (node) => {
|
|
49
|
+
if (node.callee?.name !== 'RegExp') {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
const pattern = node.arguments?.[0]
|
|
53
|
+
if (isStaticLookbehind(pattern)) {
|
|
54
|
+
report(pattern)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
// The case that actually breaks a page: a literal is an EARLY error, so the whole
|
|
60
|
+
// chunk fails to parse rather than the one call site failing.
|
|
61
|
+
Literal(node) {
|
|
62
|
+
if (node.regex && LOOKBEHIND.test(node.regex.pattern)) {
|
|
63
|
+
report(node)
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
NewExpression: checkRegExpCall,
|
|
67
|
+
CallExpression: checkRegExpCall,
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ban a TanStack Router `redirect({ href })` that does not say whether it reloads the document.
|
|
3
|
+
*
|
|
4
|
+
* Carried from an ESLint selector in monorepo #22043, for the same reason as its neighbours: a
|
|
5
|
+
* house rule is code, not configuration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** @type {import("oxlint").Rule} */
|
|
9
|
+
export const rule = {
|
|
10
|
+
meta: {
|
|
11
|
+
docs: {
|
|
12
|
+
description:
|
|
13
|
+
'Ban a TanStack Router redirect({ href }) that does not say whether it reloads the document.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
create(context) {
|
|
17
|
+
/**
|
|
18
|
+
* A `redirect({ href })` that declares no `reloadDocument`.
|
|
19
|
+
*
|
|
20
|
+
* ⛔ The failure this exists for ships GREEN and 404s only in production. Since the
|
|
21
|
+
* router-core bump, `redirect({ href })` no longer reloads the document for an absolute
|
|
22
|
+
* URL on the app's own origin: router-core turns it into an in-app navigation. Apps under
|
|
23
|
+
* a basepath share their origin with the app they point at, so the redirect lands on the
|
|
24
|
+
* SOURCE app's 404 instead of loading the target. Tests run on jsdom's localhost, where
|
|
25
|
+
* the same href is cross-origin and does reload, so nothing catches it before production.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ What this rule enforces is "declare the intent", NOT "do not 404". A call that says
|
|
28
|
+
* `reloadDocument: false` passes, and that is the exact in-app navigation which caused the
|
|
29
|
+
* incident; the rule cannot tell an internal path from a cross-app one, because the href is
|
|
30
|
+
* usually computed. It shrinks the target from any `redirect({ href })` to one that opted
|
|
31
|
+
* out explicitly, and that is all it does.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ An `href` arriving through a spread is invisible to it. Measured when the rule was
|
|
34
|
+
* written: zero occurrences of `redirect({ ...opts })` in the whole front end.
|
|
35
|
+
*
|
|
36
|
+
* It is application-agnostic on purpose: every app on TanStack Router under a basepath has
|
|
37
|
+
* the same exposure, so the message names no application.
|
|
38
|
+
*/
|
|
39
|
+
const SPREAD_SAFE_MESSAGE =
|
|
40
|
+
'redirect({ href }) must say whether it reloads the document. Under a basepath the app ' +
|
|
41
|
+
'shares its origin with the app you are pointing at, so the router turns a same-origin ' +
|
|
42
|
+
'href into an in-app navigation and the redirect lands on this app’s 404. Use a ' +
|
|
43
|
+
'helper that sets reloadDocument: true to leave for another app, or set ' +
|
|
44
|
+
'reloadDocument: false to say you mean an in-app navigation.'
|
|
45
|
+
|
|
46
|
+
/** The call is `redirect(...)`, written bare or as a member. */
|
|
47
|
+
const isRedirect = (node) =>
|
|
48
|
+
node.callee?.name === 'redirect' || node.callee?.property?.name === 'redirect'
|
|
49
|
+
|
|
50
|
+
/** A plain `key:` property, which a spread element does not have. */
|
|
51
|
+
const named = (property, name) =>
|
|
52
|
+
property?.type === 'Property' && (property.key?.name === name || property.key?.value === name)
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
CallExpression(node) {
|
|
56
|
+
if (!isRedirect(node)) return
|
|
57
|
+
const options = node.arguments?.[0]
|
|
58
|
+
if (options?.type !== 'ObjectExpression') return
|
|
59
|
+
|
|
60
|
+
const properties = options.properties ?? []
|
|
61
|
+
const hasHref = properties.some((property) => named(property, 'href'))
|
|
62
|
+
if (!hasHref) return
|
|
63
|
+
const declaresReload = properties.some((property) => named(property, 'reloadDocument'))
|
|
64
|
+
if (declaresReload) return
|
|
65
|
+
|
|
66
|
+
context.report({ node: options, message: SPREAD_SAFE_MESSAGE })
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
package/plugins/hublo.js
CHANGED
|
@@ -1,220 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local oxlint plugin: Hublo house rules that have no equivalent anywhere else.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|