@jterrazz/typescript 9.2.1 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -16
- package/bin/commands/check.sh +348 -117
- package/bin/typescript.sh +55 -0
- package/lib/check-architecture.js +89 -0
- package/lib/check-baseline.js +144 -0
- package/lib/check-docs.js +4 -3
- package/lib/check-drift.js +209 -0
- package/lib/check-gitignore.js +4 -4
- package/lib/check-markdown.js +279 -0
- package/lib/check-names.js +125 -0
- package/lib/check-publish.js +150 -0
- package/lib/check-secrets.js +115 -0
- package/lib/check-suppressions.js +355 -0
- package/lib/doctor.js +185 -0
- package/lib/merge-knip-config.js +57 -25
- package/lib/tracked-files.js +165 -0
- package/lib/workspace-members.js +5 -6
- package/package.json +19 -8
- package/presets/oxfmt/index.js +49 -5
- package/presets/oxlint/profiles/astro.js +10 -0
- package/presets/oxlint/profiles/bun.js +7 -0
- package/presets/oxlint/profiles/expo.js +7 -0
- package/presets/oxlint/profiles/library.js +16 -0
- package/presets/oxlint/profiles/next.js +7 -0
- package/presets/oxlint/profiles/node.js +7 -0
- package/presets/prettier/astro.json +6 -0
- package/presets/tsconfig/expo.json +16 -6
- package/presets/tsconfig/library.json +18 -0
- package/presets/tsconfig/next.json +12 -2
- package/presets/tsconfig/node.json +18 -4
- package/rules/README.md +23 -0
- package/rules/_contract.js +191 -0
- package/rules/_contract.test.ts +81 -0
- package/rules/a11y.js +51 -0
- package/rules/architecture/hexagonal.js +56 -0
- package/rules/architecture/layers.js +75 -0
- package/rules/astro.js +49 -0
- package/rules/catalog.js +134 -0
- package/rules/catalog.test.ts +84 -0
- package/rules/compile.js +125 -0
- package/rules/core/eslint.js +234 -0
- package/rules/core/import.js +107 -0
- package/rules/core/jsdoc.js +52 -0
- package/rules/core/node.js +36 -0
- package/rules/core/oxc.js +54 -0
- package/rules/core/promise.js +39 -0
- package/rules/core/typescript.js +204 -0
- package/rules/core/unicorn.js +200 -0
- package/rules/next.js +53 -0
- package/rules/profiles.js +89 -0
- package/rules/react-native.js +48 -0
- package/rules/react.js +148 -0
- package/rules/sorted.js +41 -0
- package/rules/vitest.js +153 -0
- package/src/docs.d.ts +4 -4
- package/src/docs.js +75 -47
- package/src/docs.test.ts +136 -29
- package/src/index.d.ts +13 -9
- package/src/index.js +15 -8
- package/src/oxfmt.d.ts +15 -2
- package/src/oxfmt.test.ts +10 -0
- package/src/oxlint.d.ts +57 -10
- package/src/oxlint.js +35 -50
- package/src/oxlint.test.ts +82 -28
- package/presets/oxlint/architectures/hexagonal-rules.js +0 -39
- package/presets/oxlint/architectures/hexagonal.js +0 -13
- package/presets/oxlint/base.js +0 -145
- package/presets/oxlint/expo.js +0 -36
- package/presets/oxlint/next.js +0 -43
- package/presets/oxlint/node.js +0 -14
- package/presets/oxlint/plugins/codestyle.js +0 -231
package/bin/typescript.sh
CHANGED
|
@@ -41,6 +41,21 @@ find_binary() {
|
|
|
41
41
|
|
|
42
42
|
TSDOWN=$(find_binary tsdown)
|
|
43
43
|
|
|
44
|
+
# oxlint's type-aware rules run in `tsgolint`, a separate binary it looks up on
|
|
45
|
+
# PATH — and a consumer's PATH has no reason to carry this package's bin dir. It
|
|
46
|
+
# is a dependency here, so the lookup is made to succeed by putting the
|
|
47
|
+
# directory that holds it in front, for this process and its children only.
|
|
48
|
+
add_tsgolint_to_path() {
|
|
49
|
+
local tsgolint
|
|
50
|
+
tsgolint=$(find_binary tsgolint)
|
|
51
|
+
case "$tsgolint" in
|
|
52
|
+
*/*)
|
|
53
|
+
PATH="$(cd -P "$(dirname "$tsgolint")" && pwd):$PATH"
|
|
54
|
+
export PATH
|
|
55
|
+
;;
|
|
56
|
+
esac
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
# Parse command
|
|
45
60
|
COMMAND="$1"
|
|
46
61
|
shift 2>/dev/null || true
|
|
@@ -168,6 +183,42 @@ case "$COMMAND" in
|
|
|
168
183
|
exec node "$PACKAGE_ROOT/lib/check-docs.js" "${1:-$PROJECT_ROOT}"
|
|
169
184
|
;;
|
|
170
185
|
|
|
186
|
+
doctor)
|
|
187
|
+
# Cheap, and read-only: what the toolchain is actually running against
|
|
188
|
+
# what it says it needs. No binary is spawned — every version is read
|
|
189
|
+
# off an installed package's own manifest, which is the one place that
|
|
190
|
+
# cannot disagree with what node will load.
|
|
191
|
+
printf "${CYAN_BG}${BRIGHT_WHITE} TYPESCRIPT ${NC} Checking the toolchain...\n\n"
|
|
192
|
+
|
|
193
|
+
exec node "$PACKAGE_ROOT/lib/doctor.js"
|
|
194
|
+
;;
|
|
195
|
+
|
|
196
|
+
baseline)
|
|
197
|
+
# The ratchet, recorded. A command of its own because it neither checks
|
|
198
|
+
# nor repairs: it writes down where the project actually stands, so the
|
|
199
|
+
# oxlint pass can refuse to let that number rise. `fix --baseline` would
|
|
200
|
+
# bury a rewrite of a tracked file inside the everyday gesture.
|
|
201
|
+
cd "$PROJECT_ROOT"
|
|
202
|
+
|
|
203
|
+
add_tsgolint_to_path
|
|
204
|
+
|
|
205
|
+
OXLINT=$(find_binary oxlint)
|
|
206
|
+
BASELINE_REPORT=$(mktemp)
|
|
207
|
+
trap 'rm -f "$BASELINE_REPORT"' EXIT
|
|
208
|
+
|
|
209
|
+
printf "${CYAN_BG}${BRIGHT_WHITE} TYPESCRIPT ${NC} Recording the oxlint baseline...\n\n"
|
|
210
|
+
|
|
211
|
+
# A non-zero exit is the whole point of the recording, not a failure.
|
|
212
|
+
"$OXLINT" --type-aware --format json "$@" > "$BASELINE_REPORT" 2>/dev/null || true
|
|
213
|
+
|
|
214
|
+
node "$PACKAGE_ROOT/lib/check-baseline.js" "$BASELINE_REPORT" . --write
|
|
215
|
+
|
|
216
|
+
# The file is tracked, so it is the formatter's like every other tracked
|
|
217
|
+
# file — written here, shaped by the project's own oxfmt, never both.
|
|
218
|
+
OXFMT=$(find_binary oxfmt)
|
|
219
|
+
"$OXFMT" oxlint.baseline.json > /dev/null 2>&1 || true
|
|
220
|
+
;;
|
|
221
|
+
|
|
171
222
|
check|fix)
|
|
172
223
|
exec bash "$SCRIPT_DIR/commands/check.sh" "$COMMAND" "$@"
|
|
173
224
|
;;
|
|
@@ -182,6 +233,8 @@ case "$COMMAND" in
|
|
|
182
233
|
printf " dev Build, run, and rebuild on changes\n"
|
|
183
234
|
printf " docs Generate the committed docs/reference tree; --check verifies sync\n"
|
|
184
235
|
printf " docs-layout Check a repository's docs/ against the manual spine\n"
|
|
236
|
+
printf " doctor Report the installed tool versions against the declared ranges\n"
|
|
237
|
+
printf " baseline Record the oxlint baseline this project may not exceed\n"
|
|
185
238
|
printf " check Check types, lint, formatting, and unused code\n"
|
|
186
239
|
printf " fix Auto-fix lint and formatting issues\n"
|
|
187
240
|
printf " clean Remove .artifacts/ — dist/ stays, it is the build's product\n\n"
|
|
@@ -193,6 +246,8 @@ case "$COMMAND" in
|
|
|
193
246
|
printf " typescript docs\n"
|
|
194
247
|
printf " typescript docs --check\n"
|
|
195
248
|
printf " typescript docs-layout .\n"
|
|
249
|
+
printf " typescript doctor\n"
|
|
250
|
+
printf " typescript baseline\n"
|
|
196
251
|
printf " typescript check\n"
|
|
197
252
|
printf " typescript fix\n"
|
|
198
253
|
printf " typescript clean\n"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The layer map a project declared, checked against the graph it actually has.
|
|
5
|
+
*
|
|
6
|
+
* oxlint's `no-restricted-imports` reads a path and a pattern; dependency-
|
|
7
|
+
* cruiser resolves the module graph, which is the only way to see a cycle that
|
|
8
|
+
* runs through three files or an edge that hides behind a barrel. Where a
|
|
9
|
+
* project declares a map — `.dependency-cruiser.cjs`, `.js` or `.mjs` at its
|
|
10
|
+
* root — this gate is the one that reads it.
|
|
11
|
+
*
|
|
12
|
+
* The rule ids are the config's own `name`s. That is deliberate and it is the
|
|
13
|
+
* only gate of this toolchain whose vocabulary the consumer writes: a layer map
|
|
14
|
+
* is a project's own architecture, and naming its rules for it is the point.
|
|
15
|
+
*
|
|
16
|
+
* Usage: node check-architecture.js [root] [--depcruise <path>]
|
|
17
|
+
*
|
|
18
|
+
* dependency-cruiser's `err-long` report, verbatim, when the graph breaks the
|
|
19
|
+
* map. Exit code: 0 when it holds, 1 otherwise. No `--fix`: moving a module
|
|
20
|
+
* across a layer is a design decision.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
25
|
+
import { join, resolve } from 'node:path';
|
|
26
|
+
import { argv, exit, stdout } from 'node:process';
|
|
27
|
+
|
|
28
|
+
/** The three spellings dependency-cruiser answers to at a project root. */
|
|
29
|
+
const CONFIGS = ['.dependency-cruiser.cjs', '.dependency-cruiser.js', '.dependency-cruiser.mjs'];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What a cruise starts from. `src/` is the whole answer wherever there is one;
|
|
33
|
+
* a workspace that keeps its code in `apps/` and `packages/` is cruised from
|
|
34
|
+
* those instead, so a map never has to restate the tree's shape.
|
|
35
|
+
*/
|
|
36
|
+
const ROOTS = ['apps', 'lib', 'packages'];
|
|
37
|
+
|
|
38
|
+
const flagged = (name, fallback) => {
|
|
39
|
+
const at = argv.indexOf(name);
|
|
40
|
+
|
|
41
|
+
return at === -1 || argv[at + 1] === undefined ? fallback : argv[at + 1];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const root = resolve(
|
|
45
|
+
argv
|
|
46
|
+
.slice(2)
|
|
47
|
+
.find(
|
|
48
|
+
(argument, index) => !argument.startsWith('--') && argv[index + 1] !== '--depcruise',
|
|
49
|
+
) ?? '.',
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const config = CONFIGS.find((name) => existsSync(join(root, name)));
|
|
53
|
+
if (config === undefined) {
|
|
54
|
+
exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cruised = existsSync(join(root, 'src'))
|
|
58
|
+
? ['src']
|
|
59
|
+
: ROOTS.filter((name) => existsSync(join(root, name)));
|
|
60
|
+
|
|
61
|
+
if (cruised.length === 0) {
|
|
62
|
+
stdout.write(
|
|
63
|
+
`${config} declares a layer map, but there is no src/, apps/, packages/ or lib/ to cruise\n`,
|
|
64
|
+
);
|
|
65
|
+
exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* An empty root is a cruise of nothing, and dependency-cruiser refuses one. */
|
|
69
|
+
const populated = cruised.filter((name) => readdirSync(join(root, name)).length > 0);
|
|
70
|
+
if (populated.length === 0) {
|
|
71
|
+
exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const run = spawnSync(
|
|
75
|
+
flagged('--depcruise', 'depcruise'),
|
|
76
|
+
['--config', config, '--output-type', 'err-long', ...populated],
|
|
77
|
+
{ cwd: root, encoding: 'utf8' },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (run.error) {
|
|
81
|
+
stdout.write(`depcruise could not be run: ${run.error.message}\n`);
|
|
82
|
+
exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (run.status !== 0) {
|
|
86
|
+
stdout.write(`${run.stdout ?? ''}${run.stderr ?? ''}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
exit(run.status === 0 ? 0 : 1);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The migration ratchet: a count per rule that may fall and never rise.
|
|
5
|
+
*
|
|
6
|
+
* A project adopting a stricter rulebook has two honest options — burn every
|
|
7
|
+
* diagnostic down before the first green run, or record where it stands and
|
|
8
|
+
* refuse to go backwards. `oxlint.baseline.json` is the second: `{ "<rule>":
|
|
9
|
+
* <count> }`, tracked, and read by the oxlint pass instead of the raw exit
|
|
10
|
+
* code. Without the file a single diagnostic fails, exactly as before.
|
|
11
|
+
*
|
|
12
|
+
* Three things fail a run that has one:
|
|
13
|
+
*
|
|
14
|
+
* - a rule whose count EXCEEDS its entry — the debt grew;
|
|
15
|
+
* - a rule with diagnostics and NO entry — a rule nobody recorded owing;
|
|
16
|
+
* - an entry whose count is now zero — the ratchet moved, so the entry goes.
|
|
17
|
+
*
|
|
18
|
+
* The third is what makes the file shrink. Without it a baseline records a debt
|
|
19
|
+
* that was paid years ago and nothing ever says so.
|
|
20
|
+
*
|
|
21
|
+
* Usage: node check-baseline.js <oxlint-json> [root] [--write]
|
|
22
|
+
*
|
|
23
|
+
* `--write` rewrites the file from the current counts — that is `typescript
|
|
24
|
+
* baseline`, a command of its own because it RECORDS rather than checks or
|
|
25
|
+
* repairs. Exit code: 0 when the ratchet holds, 1 otherwise.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { join, resolve } from 'node:path';
|
|
30
|
+
import { argv, exit, stdout } from 'node:process';
|
|
31
|
+
|
|
32
|
+
/** The file, at the project root, tracked beside the config it ratchets. */
|
|
33
|
+
export const BASELINE = 'oxlint.baseline.json';
|
|
34
|
+
|
|
35
|
+
/** `eslint(no-debugger)` is how oxlint's JSON spells `eslint/no-debugger`. */
|
|
36
|
+
function ruleOf(code) {
|
|
37
|
+
const match = /^(?<plugin>[\w-]+)\((?<rule>[^)]+)\)$/u.exec(code ?? '');
|
|
38
|
+
|
|
39
|
+
return match === null ? (code ?? 'unknown') : `${match.groups.plugin}/${match.groups.rule}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How many diagnostics each rule accounts for, in oxlint's JSON report. */
|
|
43
|
+
export function countsOf(report) {
|
|
44
|
+
const counts = {};
|
|
45
|
+
for (const diagnostic of report.diagnostics ?? []) {
|
|
46
|
+
const rule = ruleOf(diagnostic.code);
|
|
47
|
+
counts[rule] = (counts[rule] ?? 0) + 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return counts;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The recorded counts, or null when the project does not keep a baseline. */
|
|
54
|
+
export function readBaseline(root) {
|
|
55
|
+
const path = join(root, BASELINE);
|
|
56
|
+
if (!existsSync(path)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
62
|
+
} catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Every way the counts break the ratchet, in the order a reader wants them. */
|
|
68
|
+
export function judge(counts, baseline) {
|
|
69
|
+
const broken = [];
|
|
70
|
+
|
|
71
|
+
for (const [rule, count] of Object.entries(counts).toSorted(([left], [right]) =>
|
|
72
|
+
left < right ? -1 : 1,
|
|
73
|
+
)) {
|
|
74
|
+
const allowed = baseline[rule];
|
|
75
|
+
if (allowed === undefined) {
|
|
76
|
+
broken.push(`${rule} has ${count} diagnostic(s) and no entry — it is new debt`);
|
|
77
|
+
} else if (count > allowed) {
|
|
78
|
+
broken.push(`${rule} is at ${count}, above its baseline of ${allowed}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const rule of Object.keys(baseline).toSorted()) {
|
|
83
|
+
if ((counts[rule] ?? 0) === 0) {
|
|
84
|
+
broken.push(`${rule} is at zero — the ratchet moved, delete its entry`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return broken;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The file's text: sorted, two-space, so a diff reads as a burn-down. */
|
|
92
|
+
function serialise(counts) {
|
|
93
|
+
const sorted = Object.fromEntries(
|
|
94
|
+
Object.entries(counts).toSorted(([left], [right]) => (left < right ? -1 : 1)),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Imported for the ratchet's reading, run for the gate — never both at once. */
|
|
101
|
+
if (import.meta.main) {
|
|
102
|
+
const isWrite = argv.includes('--write');
|
|
103
|
+
const positional = argv.slice(2).filter((argument) => !argument.startsWith('--'));
|
|
104
|
+
const reportPath = positional[0];
|
|
105
|
+
const root = resolve(positional[1] ?? '.');
|
|
106
|
+
|
|
107
|
+
let report;
|
|
108
|
+
try {
|
|
109
|
+
report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
|
110
|
+
} catch {
|
|
111
|
+
stdout.write(
|
|
112
|
+
`oxlint wrote no JSON report at ${reportPath} — the baseline cannot be read\n`,
|
|
113
|
+
);
|
|
114
|
+
exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const counts = countsOf(report);
|
|
118
|
+
|
|
119
|
+
if (isWrite) {
|
|
120
|
+
const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
|
121
|
+
writeFileSync(join(root, BASELINE), serialise(counts));
|
|
122
|
+
stdout.write(
|
|
123
|
+
`${BASELINE} written — ${total} diagnostic(s) across ${Object.keys(counts).length} rule(s)\n`,
|
|
124
|
+
);
|
|
125
|
+
exit(0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const baseline = readBaseline(root);
|
|
129
|
+
|
|
130
|
+
if (baseline === null) {
|
|
131
|
+
exit(Object.keys(counts).length > 0 ? 1 : 0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const broken = judge(counts, baseline);
|
|
135
|
+
|
|
136
|
+
for (const reason of broken) {
|
|
137
|
+
stdout.write(`baseline-ratchet ${BASELINE} ${reason}\n`);
|
|
138
|
+
}
|
|
139
|
+
if (broken.length > 0) {
|
|
140
|
+
stdout.write("Run 'typescript baseline' to record where the project actually stands.\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
exit(broken.length > 0 ? 1 : 0);
|
|
144
|
+
}
|
package/lib/check-docs.js
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* place the workspace shows through is the `04-operating.md` presence test,
|
|
23
23
|
* whose Dockerfile and `.infrastructure/` clauses are read at the root AND at
|
|
24
24
|
* every workspace member's root — a monorepo deploys from a member as readily
|
|
25
|
-
* as from its root (
|
|
25
|
+
* as from its root ([Architecture](../docs/01-architecture.md), "The unit is the
|
|
26
|
+
* workspace package").
|
|
26
27
|
* The publishable clause stays on the ROOT manifest: a private root that holds
|
|
27
28
|
* a publishable member is a question for its owner, not a verdict for a gate.
|
|
28
29
|
*/
|
|
@@ -35,7 +36,7 @@ import { auditDocs, HEAD_LINES } from '../src/docs.js';
|
|
|
35
36
|
import { workspaceMembers } from './workspace-members.js';
|
|
36
37
|
|
|
37
38
|
/** Every markdown link target of a page, in the order the page carries them. */
|
|
38
|
-
const LINK = /!?\[[^\]]
|
|
39
|
+
const LINK = /!?\[[^\]]*\]\(\s*(?<target>[^\s)]+)/gu;
|
|
39
40
|
|
|
40
41
|
/** Where a repository declares a deployment it owns. */
|
|
41
42
|
const INFRASTRUCTURE = '.infrastructure';
|
|
@@ -53,7 +54,7 @@ function listDocs(root) {
|
|
|
53
54
|
paths.push(`${relativePath}/`);
|
|
54
55
|
|
|
55
56
|
const entries = readdirSync(join(root, relativePath), { withFileTypes: true });
|
|
56
|
-
for (const entry of entries.
|
|
57
|
+
for (const entry of entries.toSorted((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
57
58
|
const child = `${relativePath}/${entry.name}`;
|
|
58
59
|
if (entry.isDirectory()) {
|
|
59
60
|
walk(child);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How far this project stands from the profile it says it extends.
|
|
5
|
+
*
|
|
6
|
+
* Four numbers, printed at the end of every `check`, because the alternative is
|
|
7
|
+
* what the estate had: twenty-eight repositories each quietly a little further
|
|
8
|
+
* from the shared rulebook, and nobody able to say by how much without opening
|
|
9
|
+
* twenty-eight config files. A rule turned off, a suppression written, a
|
|
10
|
+
* baseline entry recorded and a tool left behind are the four ways a project
|
|
11
|
+
* drifts, and each of them is a number here.
|
|
12
|
+
*
|
|
13
|
+
* One of the four is also a GATE. A rule the profile has on may be turned off —
|
|
14
|
+
* a project knows things the profile does not — but not silently: the line that
|
|
15
|
+
* turns it off carries a `// reason:` comment, or the run fails on
|
|
16
|
+
* `drift-unreasoned`. The other three only ever report.
|
|
17
|
+
*
|
|
18
|
+
* Usage: node check-drift.js [root] [--oxlint <path>] [--json]
|
|
19
|
+
* [--ignore-pattern <glob>]…
|
|
20
|
+
*
|
|
21
|
+
* Exit code: 0 unless a rule is turned off with no reason beside it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { join, resolve } from 'node:path';
|
|
27
|
+
import { argv, exit, stdout } from 'node:process';
|
|
28
|
+
|
|
29
|
+
import { PROFILES } from '../rules/profiles.js';
|
|
30
|
+
import { readBaseline } from './check-baseline.js';
|
|
31
|
+
import { countSuppressions } from './check-suppressions.js';
|
|
32
|
+
import { toolVersions } from './doctor.js';
|
|
33
|
+
import { ignorePatternsOf } from './tracked-files.js';
|
|
34
|
+
|
|
35
|
+
const PACKAGE_ROOT = resolve(import.meta.dirname, '..');
|
|
36
|
+
|
|
37
|
+
/** Where a consumer declares its rules, in the order oxlint looks. */
|
|
38
|
+
const CONFIGS = [
|
|
39
|
+
'oxlint.config.ts',
|
|
40
|
+
'oxlint.config.mjs',
|
|
41
|
+
'oxlint.config.js',
|
|
42
|
+
'oxlint.config.cjs',
|
|
43
|
+
'.oxlintrc.json',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** The profiles this package ships, read off the manifest that defines them. */
|
|
47
|
+
const PROFILE_NAMES = new Set(Object.keys(PROFILES));
|
|
48
|
+
|
|
49
|
+
/** The comment that makes a rule turned off a decision rather than a drift. */
|
|
50
|
+
const REASON = /\/\/\s*reason:/iu;
|
|
51
|
+
|
|
52
|
+
/** A resolved rule set, as `name -> level`, or null when oxlint refused. */
|
|
53
|
+
function resolvedRules(root, oxlint, configPath) {
|
|
54
|
+
try {
|
|
55
|
+
const printed = execFileSync(
|
|
56
|
+
oxlint,
|
|
57
|
+
configPath === null ? ['--print-config'] : ['-c', configPath, '--print-config'],
|
|
58
|
+
{ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return JSON.parse(printed).rules ?? {};
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The profile a consumer config extends, read off the config's own source, or
|
|
69
|
+
* null when it extends none of ours.
|
|
70
|
+
*
|
|
71
|
+
* The SOURCE is the evidence, and it is the only evidence available: oxlint's
|
|
72
|
+
* config schema is closed — an unknown top-level key makes it print `Failed to
|
|
73
|
+
* parse oxlint configuration file` — so a compiled profile cannot carry its own
|
|
74
|
+
* name through to `--print-config`. A config that imports `node` from this
|
|
75
|
+
* package's oxlint entry is a `node` project, and that import is what this
|
|
76
|
+
* reads.
|
|
77
|
+
*
|
|
78
|
+
* Null matters: a project that extends no profile of ours is not DRIFTING from
|
|
79
|
+
* one, it never joined it, and reporting two hundred "rules off" would say
|
|
80
|
+
* nothing about anything.
|
|
81
|
+
*/
|
|
82
|
+
function profileOf(source) {
|
|
83
|
+
const imported =
|
|
84
|
+
/import\s*\{(?<names>[^}]*)\}\s*from\s*['"]@jterrazz\/typescript\/oxlint['"]/u.exec(
|
|
85
|
+
source ?? '',
|
|
86
|
+
);
|
|
87
|
+
const named = (imported?.groups.names ?? '').split(',').map((name) => name.trim());
|
|
88
|
+
|
|
89
|
+
return named.find((name) => PROFILE_NAMES.has(name)) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A rule the profile runs and this project does not. */
|
|
93
|
+
function turnedOff(profile, consumer) {
|
|
94
|
+
const off = [];
|
|
95
|
+
for (const [rule, level] of Object.entries(profile).toSorted(([left], [right]) =>
|
|
96
|
+
left < right ? -1 : 1,
|
|
97
|
+
)) {
|
|
98
|
+
if (level === 'allow') {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const here = consumer[rule];
|
|
102
|
+
if (here === undefined || here === 'allow') {
|
|
103
|
+
off.push(rule);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return off;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The line of the config that turns a rule off, when the config names it at all. */
|
|
111
|
+
function lineNaming(source, rule) {
|
|
112
|
+
const bare = rule.split('/').at(-1);
|
|
113
|
+
|
|
114
|
+
return (source ?? '')
|
|
115
|
+
.split('\n')
|
|
116
|
+
.find(
|
|
117
|
+
(line) =>
|
|
118
|
+
line.includes(rule) || line.includes(`'${bare}'`) || line.includes(`"${bare}"`),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const isJson = argv.includes('--json');
|
|
123
|
+
const oxlintAt = argv[argv.indexOf('--oxlint') + 1];
|
|
124
|
+
const oxlint = argv.includes('--oxlint') && oxlintAt !== undefined ? oxlintAt : 'oxlint';
|
|
125
|
+
const root = resolve(
|
|
126
|
+
argv.slice(2).find((argument, index) => {
|
|
127
|
+
const previous = argv[index + 1];
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
!argument.startsWith('--') && previous !== '--oxlint' && previous !== '--ignore-pattern'
|
|
131
|
+
);
|
|
132
|
+
}) ?? '.',
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const configName = CONFIGS.find((name) => existsSync(join(root, name)));
|
|
136
|
+
const source = configName === undefined ? null : readFileSync(join(root, configName), 'utf8');
|
|
137
|
+
const profileName = profileOf(source);
|
|
138
|
+
|
|
139
|
+
const consumerRules = resolvedRules(root, oxlint, null) ?? {};
|
|
140
|
+
const profileRules =
|
|
141
|
+
profileName === null
|
|
142
|
+
? {}
|
|
143
|
+
: (resolvedRules(
|
|
144
|
+
root,
|
|
145
|
+
oxlint,
|
|
146
|
+
join(PACKAGE_ROOT, 'presets/oxlint/profiles', `${profileName}.js`),
|
|
147
|
+
) ?? {});
|
|
148
|
+
|
|
149
|
+
const off = turnedOff(profileRules, consumerRules);
|
|
150
|
+
const unreasoned = off.filter((rule) => {
|
|
151
|
+
const line = lineNaming(source, rule);
|
|
152
|
+
|
|
153
|
+
return line === undefined || !REASON.test(line);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const baseline = readBaseline(root);
|
|
157
|
+
const baselineTotal =
|
|
158
|
+
baseline === null ? null : Object.values(baseline).reduce((sum, count) => sum + count, 0);
|
|
159
|
+
const suppressions = countSuppressions(root, ignorePatternsOf(argv));
|
|
160
|
+
const behind = toolVersions().filter(({ verdict }) => verdict !== 'ok');
|
|
161
|
+
|
|
162
|
+
if (isJson) {
|
|
163
|
+
stdout.write(
|
|
164
|
+
`${JSON.stringify(
|
|
165
|
+
{
|
|
166
|
+
baseline: baselineTotal,
|
|
167
|
+
profile: profileName,
|
|
168
|
+
rulesOff: off,
|
|
169
|
+
suppressions,
|
|
170
|
+
tools: behind,
|
|
171
|
+
unreasoned,
|
|
172
|
+
},
|
|
173
|
+
null,
|
|
174
|
+
2,
|
|
175
|
+
)}\n`,
|
|
176
|
+
);
|
|
177
|
+
exit(unreasoned.length > 0 ? 1 : 0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The one line a reader looks for: what this project runs that the profile does not. */
|
|
181
|
+
function rulesOffLine() {
|
|
182
|
+
if (profileName === null) {
|
|
183
|
+
return 'not measured';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return off.length === 0 ? 'none' : `${off.length} (${off.join(', ')})`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
stdout.write(` profile ${profileName ?? "none of this package's"}\n`);
|
|
190
|
+
stdout.write(` rules off vs profile ${rulesOffLine()}\n`);
|
|
191
|
+
stdout.write(` suppressions ${suppressions}\n`);
|
|
192
|
+
stdout.write(` baseline ${baselineTotal === null ? 'absent' : baselineTotal}\n`);
|
|
193
|
+
stdout.write(
|
|
194
|
+
` tool versions ${
|
|
195
|
+
behind.length === 0
|
|
196
|
+
? 'in range'
|
|
197
|
+
: behind
|
|
198
|
+
.map(({ installed, name, verdict }) => `${name} ${installed} ${verdict}`)
|
|
199
|
+
.join(', ')
|
|
200
|
+
}\n`,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
for (const rule of unreasoned) {
|
|
204
|
+
stdout.write(
|
|
205
|
+
`\ndrift-unreasoned ${configName ?? 'oxlint.config'} ${rule} is off and the line that turns it off carries no '// reason:'\n`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
exit(unreasoned.length > 0 ? 1 : 0);
|
package/lib/check-gitignore.js
CHANGED
|
@@ -125,7 +125,7 @@ function nextConfigDeclaresExport(dir) {
|
|
|
125
125
|
continue;
|
|
126
126
|
}
|
|
127
127
|
try {
|
|
128
|
-
if (/output\s*:\s*['"]export['"]
|
|
128
|
+
if (/output\s*:\s*['"]export['"]/u.test(readFileSync(path, 'utf8'))) {
|
|
129
129
|
return true;
|
|
130
130
|
}
|
|
131
131
|
} catch {
|
|
@@ -192,7 +192,7 @@ function findWorkspaceRoot(root) {
|
|
|
192
192
|
* matches at any depth below it.
|
|
193
193
|
*/
|
|
194
194
|
function isAnchored(pattern) {
|
|
195
|
-
return pattern.replace(
|
|
195
|
+
return pattern.replace(/\/+$/u, '').includes('/');
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
/**
|
|
@@ -203,11 +203,11 @@ function isAnchored(pattern) {
|
|
|
203
203
|
*/
|
|
204
204
|
function subject(pattern) {
|
|
205
205
|
const segments = pattern
|
|
206
|
-
.replace(
|
|
206
|
+
.replace(/\/+$/u, '')
|
|
207
207
|
.split('/')
|
|
208
208
|
.filter((segment) => segment !== '' && segment !== '**');
|
|
209
209
|
|
|
210
|
-
return (segments.at(-1) ?? '').replace(
|
|
210
|
+
return (segments.at(-1) ?? '').replace(/\*$/u, '');
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
/** The `.artifacts/` home of the artefact a pattern names, or null. */
|