@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
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every place the project told a checker to look away.
|
|
5
|
+
*
|
|
6
|
+
* A suppression is a decision, and a decision the next reader cannot re-derive
|
|
7
|
+
* is a defect waiting to be re-introduced. Three rules hold the whole surface:
|
|
8
|
+
* it is spelled in the vocabulary of the tools this project actually runs, it
|
|
9
|
+
* carries the reason it was taken, and it names a rule that is still live.
|
|
10
|
+
*
|
|
11
|
+
* Usage: node check-suppressions.js [root] [--fix] [--oxlint <path>]
|
|
12
|
+
* [--ignore-pattern <glob>]…
|
|
13
|
+
*
|
|
14
|
+
* `--fix` rewrites the two spellings a machine can settle: an
|
|
15
|
+
* `eslint-disable*` whose every named rule resolves becomes `oxlint-disable*`,
|
|
16
|
+
* and `@ts-ignore` becomes `@ts-expect-error`. It never invents a reason and it
|
|
17
|
+
* never deletes a directive — both of those are the author's judgement.
|
|
18
|
+
*
|
|
19
|
+
* One line per violation, `<rule> <path> <message>`. Exit code: 0 when every
|
|
20
|
+
* suppression is spelled, reasoned and live, 1 otherwise.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { writeFileSync } from 'node:fs';
|
|
25
|
+
import { join, resolve } from 'node:path';
|
|
26
|
+
import { argv, exit, stdout } from 'node:process';
|
|
27
|
+
|
|
28
|
+
import { ignorePatternsOf, readText, trackedFiles } from './tracked-files.js';
|
|
29
|
+
|
|
30
|
+
/** What a linter and a type-checker read — the files a directive can live in. */
|
|
31
|
+
export const SOURCE = /\.(?:astro|[cm]?[jt]sx?|svelte|vue)$/u;
|
|
32
|
+
|
|
33
|
+
/** The separator oxlint reads a directive's reason after. */
|
|
34
|
+
const REASON = ' -- ';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A directive is only a directive where a checker reads one: at the OPENING of
|
|
38
|
+
* a comment. Prose about a suppression, and a string carrying its spelling —
|
|
39
|
+
* this file is made of both — is not a suppression, and neither oxlint nor tsc
|
|
40
|
+
* would treat it as one.
|
|
41
|
+
*/
|
|
42
|
+
const COMMENT = String.raw`(?:\/\/|\/\*+|^\s*\*(?!\/))\s*`;
|
|
43
|
+
|
|
44
|
+
/** A disable directive of either vocabulary, with its scope and its tail. */
|
|
45
|
+
const DISABLE = new RegExp(
|
|
46
|
+
`${COMMENT}(?<tool>es|ox)lint-disable(?<scope>-next-line|-line)?(?<tail>[^\n*]*)`,
|
|
47
|
+
'gu',
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** The two TypeScript directives, with whatever description follows. */
|
|
51
|
+
const TS_DIRECTIVE = new RegExp(`${COMMENT}@ts-(?<kind>expect-error|ignore)(?<tail>[^\n*]*)`, 'gu');
|
|
52
|
+
|
|
53
|
+
/** Biome's, which no project of this toolchain runs. */
|
|
54
|
+
const BIOME = new RegExp(`${COMMENT}biome-ignore\\b`, 'u');
|
|
55
|
+
|
|
56
|
+
/** The rules a directive names: everything before the reason, comma-separated. */
|
|
57
|
+
function rulesOf(tail) {
|
|
58
|
+
const named = tail.split(REASON)[0] ?? '';
|
|
59
|
+
|
|
60
|
+
return named
|
|
61
|
+
.replace(/\*\/\s*$/u, '')
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((name) => name.trim())
|
|
64
|
+
.filter((name) => /^[\w-]+(?:\/[\w-]+)*$/u.test(name) && name !== '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether the directive carries a reason after the separator oxlint reads. */
|
|
68
|
+
const hasReason = (tail) => (tail.split(REASON)[1] ?? '').replace(/\*\/\s*$/u, '').trim() !== '';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The rules oxlint resolved for this project, as `name -> level`, plus the
|
|
72
|
+
* plugin namespaces that map knows about. The map holds every rule the config
|
|
73
|
+
* DECIDED, which is not oxlint's whole catalogue: a rule missing from it is a
|
|
74
|
+
* rule this project does not run, which is the only question asked of it.
|
|
75
|
+
*
|
|
76
|
+
* A JS plugin's rules never appear in `--print-config`, so a directive naming
|
|
77
|
+
* one is unverifiable — and the gate says nothing rather than calling a live
|
|
78
|
+
* rule dead.
|
|
79
|
+
*/
|
|
80
|
+
function resolveConfig(root, oxlint) {
|
|
81
|
+
let printed;
|
|
82
|
+
try {
|
|
83
|
+
printed = execFileSync(oxlint, ['--print-config'], {
|
|
84
|
+
cwd: root,
|
|
85
|
+
encoding: 'utf8',
|
|
86
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let rules;
|
|
93
|
+
try {
|
|
94
|
+
rules = JSON.parse(printed).rules ?? {};
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const namespaces = new Set();
|
|
100
|
+
for (const name of Object.keys(rules)) {
|
|
101
|
+
if (name.includes('/')) {
|
|
102
|
+
namespaces.add(name.split('/')[0]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { namespaces, rules };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Where a named rule stands for this project: `live`, `dead`, or `unverifiable`. */
|
|
110
|
+
function standingOf(name, config) {
|
|
111
|
+
if (config === null) {
|
|
112
|
+
return 'unverifiable';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const namespace = name.includes('/') ? name.split('/')[0] : null;
|
|
116
|
+
if (namespace !== null && !config.namespaces.has(namespace)) {
|
|
117
|
+
/* A JS plugin's namespace: its rules are absent from --print-config. */
|
|
118
|
+
return 'unverifiable';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const bare = name.split('/').at(-1);
|
|
122
|
+
const level =
|
|
123
|
+
config.rules[name] ??
|
|
124
|
+
Object.entries(config.rules).find(([key]) => key.split('/').at(-1) === bare)?.[1];
|
|
125
|
+
|
|
126
|
+
return level === undefined || level === 'allow' ? 'dead' : 'live';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Every violation one line carries, and the line a `--fix` would leave behind. */
|
|
130
|
+
function auditLine(line, number, config) {
|
|
131
|
+
const found = [];
|
|
132
|
+
let fixed = line;
|
|
133
|
+
|
|
134
|
+
for (const audit of [auditBiome, auditDisables, auditTypeScript]) {
|
|
135
|
+
const { fixed: rewritten, found: reported } = audit(fixed, number, config);
|
|
136
|
+
found.push(...reported);
|
|
137
|
+
fixed = rewritten;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { fixed, found };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Biome's spelling, which no project of this toolchain runs and no fix rewrites. */
|
|
144
|
+
function auditBiome(line, number) {
|
|
145
|
+
if (!BIOME.test(line)) {
|
|
146
|
+
return { fixed: line, found: [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
fixed: line,
|
|
151
|
+
found: [
|
|
152
|
+
{
|
|
153
|
+
message: `line ${number} spells a suppression for biome, which this toolchain never runs`,
|
|
154
|
+
rule: 'suppressions-spelling',
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The two linter vocabularies: the spelling, the reason, and the rules named. */
|
|
161
|
+
function auditDisables(line, number, config) {
|
|
162
|
+
const found = [];
|
|
163
|
+
let fixed = line;
|
|
164
|
+
|
|
165
|
+
for (const match of line.matchAll(DISABLE)) {
|
|
166
|
+
const { scope = '', tail = '', tool } = match.groups;
|
|
167
|
+
const named = rulesOf(tail);
|
|
168
|
+
|
|
169
|
+
if (tool === 'es') {
|
|
170
|
+
/* A rewrite is only safe where the name it carries means something here. */
|
|
171
|
+
const settled = isRewritable(named, config);
|
|
172
|
+
if (settled) {
|
|
173
|
+
fixed = fixed.replace(`eslint-disable${scope}`, `oxlint-disable${scope}`);
|
|
174
|
+
}
|
|
175
|
+
found.push(eslintSpelling(number, named, settled));
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
found.push(...auditOxlintDirective(number, tail, named, config));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { fixed, found };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Whether every rule an eslint directive names is one this project actually runs. */
|
|
186
|
+
function isRewritable(named, config) {
|
|
187
|
+
return named.length > 0 && named.every((name) => standingOf(name, config) !== 'dead');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What an eslint spelling is told — which depends on whether a fix can settle it. */
|
|
191
|
+
function eslintSpelling(number, named, settled) {
|
|
192
|
+
return {
|
|
193
|
+
message: settled
|
|
194
|
+
? `line ${number} spells an eslint directive; this toolchain runs oxlint — 'typescript fix' rewrites it`
|
|
195
|
+
: `line ${number} spells an eslint directive naming ${named.join(', ') || 'no rule'}, which this project does not run — name an oxlint rule`,
|
|
196
|
+
rule: 'suppressions-spelling',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** An oxlint directive: whether it carries its reason, and whether its rules are live. */
|
|
201
|
+
function auditOxlintDirective(number, tail, named, config) {
|
|
202
|
+
const found = [];
|
|
203
|
+
|
|
204
|
+
if (!hasReason(tail)) {
|
|
205
|
+
found.push({
|
|
206
|
+
message: `line ${number} disables ${named.join(', ') || 'every rule'} with no '${REASON.trim()} reason' after it`,
|
|
207
|
+
rule: 'suppressions-reason',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const name of named) {
|
|
212
|
+
if (standingOf(name, config) === 'dead') {
|
|
213
|
+
found.push({
|
|
214
|
+
message: `line ${number} disables ${name}, which the resolved config does not have on`,
|
|
215
|
+
rule: 'suppressions-dead',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return found;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The two TypeScript directives: the spelling that stays silent, and the missing description. */
|
|
224
|
+
function auditTypeScript(line, number) {
|
|
225
|
+
const found = [];
|
|
226
|
+
let fixed = line;
|
|
227
|
+
|
|
228
|
+
for (const match of line.matchAll(TS_DIRECTIVE)) {
|
|
229
|
+
const { kind, tail = '' } = match.groups;
|
|
230
|
+
|
|
231
|
+
if (kind === 'ignore') {
|
|
232
|
+
fixed = fixed.replace('@ts-ignore', '@ts-expect-error');
|
|
233
|
+
found.push({
|
|
234
|
+
message: `line ${number} spells @ts-ignore, which stays silent once the error is gone — @ts-expect-error does not`,
|
|
235
|
+
rule: 'suppressions-spelling',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (tail.replace(/\*\/\s*$/u, '').trim() === '') {
|
|
240
|
+
found.push({
|
|
241
|
+
message: `line ${number} carries a TypeScript suppression with no description of what it is for`,
|
|
242
|
+
rule: 'suppressions-reason',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { fixed, found };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* One file read and audited: every violation it carries, each marked with
|
|
252
|
+
* whether a `--fix` settles that very line, and the text the rewrite would
|
|
253
|
+
* leave behind — null where nothing changed. A file with no directive spelling
|
|
254
|
+
* anywhere in it is skipped before a single line is parsed.
|
|
255
|
+
*/
|
|
256
|
+
function auditSource(root, path, configOf) {
|
|
257
|
+
const text = readText(root, path);
|
|
258
|
+
if (text === null || !/(?:es|ox)lint-disable|@ts-|biome-ignore/u.test(text)) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const kept = [];
|
|
263
|
+
const violations = [];
|
|
264
|
+
|
|
265
|
+
for (const [index, line] of text.split('\n').entries()) {
|
|
266
|
+
const { fixed, found } = auditLine(line, index + 1, configOf());
|
|
267
|
+
kept.push(fixed);
|
|
268
|
+
for (const violation of found) {
|
|
269
|
+
violations.push({ ...violation, rewritable: fixed !== line });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const rewritten = kept.join('\n');
|
|
274
|
+
|
|
275
|
+
return { rewritten: rewritten === text ? null : rewritten, violations };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* How many directives the tracked source carries — the number the drift report
|
|
280
|
+
* prints. It lives here because the shapes that count as a directive are this
|
|
281
|
+
* file's, and a second copy of them is a second definition of "suppression".
|
|
282
|
+
*/
|
|
283
|
+
export function countSuppressions(root, ignorePatterns = []) {
|
|
284
|
+
let found = 0;
|
|
285
|
+
|
|
286
|
+
for (const path of trackedFiles(root, { ignorePatterns }).filter((file) => SOURCE.test(file))) {
|
|
287
|
+
const text = readText(root, path);
|
|
288
|
+
if (text === null) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
for (const line of text.split('\n')) {
|
|
292
|
+
found +=
|
|
293
|
+
[...line.matchAll(DISABLE)].length +
|
|
294
|
+
[...line.matchAll(TS_DIRECTIVE)].length +
|
|
295
|
+
(BIOME.test(line) ? 1 : 0);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return found;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* Imported for `countSuppressions`, run for the gate — never both at once. */
|
|
303
|
+
if (import.meta.main) {
|
|
304
|
+
const isFix = argv.includes('--fix');
|
|
305
|
+
const oxlintAt = argv[argv.indexOf('--oxlint') + 1];
|
|
306
|
+
const oxlint = argv.includes('--oxlint') && oxlintAt !== undefined ? oxlintAt : 'oxlint';
|
|
307
|
+
const root = resolve(
|
|
308
|
+
argv.slice(2).find((argument, index) => {
|
|
309
|
+
const previous = argv[index + 1];
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
!argument.startsWith('--') &&
|
|
313
|
+
previous !== '--oxlint' &&
|
|
314
|
+
previous !== '--ignore-pattern'
|
|
315
|
+
);
|
|
316
|
+
}) ?? '.',
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const sources = trackedFiles(root, { ignorePatterns: ignorePatternsOf(argv) }).filter((path) =>
|
|
320
|
+
SOURCE.test(path),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
/* The resolved config costs an oxlint run, so it is read only once a directive asks for it. */
|
|
324
|
+
let config;
|
|
325
|
+
const configOf = () => (config === undefined ? (config = resolveConfig(root, oxlint)) : config);
|
|
326
|
+
|
|
327
|
+
let failed = false;
|
|
328
|
+
const rewritten = [];
|
|
329
|
+
|
|
330
|
+
for (const path of sources) {
|
|
331
|
+
const audited = auditSource(root, path, configOf);
|
|
332
|
+
if (audited === null) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const violation of audited.violations) {
|
|
337
|
+
if (isFix && violation.rewritable && violation.rule === 'suppressions-spelling') {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
stdout.write(`${violation.rule} ${path} ${violation.message}\n`);
|
|
341
|
+
failed = true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (isFix && audited.rewritten !== null) {
|
|
345
|
+
writeFileSync(join(root, path), audited.rewritten);
|
|
346
|
+
rewritten.push(path);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (const path of rewritten) {
|
|
351
|
+
stdout.write(`${path} rewritten — the directive now names the checker this project runs\n`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
exit(failed ? 1 : 0);
|
|
355
|
+
}
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the toolchain is actually running, against what it says it needs.
|
|
5
|
+
*
|
|
6
|
+
* Every version here is read from an INSTALLED package's own manifest rather
|
|
7
|
+
* than from a lockfile or a `--version` flag: a lockfile says what should have
|
|
8
|
+
* been installed, and two of these tools answer `--version` in a shape of their
|
|
9
|
+
* own or not at all. The declared ranges are this package's `package.json`, so
|
|
10
|
+
* the two halves of every row come from the two places that can disagree.
|
|
11
|
+
*
|
|
12
|
+
* Usage: node doctor.js
|
|
13
|
+
*
|
|
14
|
+
* One row per tool: what is installed, what is declared, and the verdict. Older
|
|
15
|
+
* than the range FAILS — a gate running an older linter is enforcing an older
|
|
16
|
+
* rulebook without saying so. Newer WARNS: a tool ahead of its range may be
|
|
17
|
+
* fine, and the toolchain is not the thing that gets to decide that.
|
|
18
|
+
*
|
|
19
|
+
* Exit code: 0 when nothing is old or absent, 1 otherwise.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
|
+
import process, { exit, stdout } from 'node:process';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const PACKAGE_ROOT = resolve(import.meta.dirname, '..');
|
|
29
|
+
|
|
30
|
+
/** A manifest off the disk. Every version this file reports is read this way. */
|
|
31
|
+
function manifestAt(path) {
|
|
32
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const manifest = manifestAt(resolve(PACKAGE_ROOT, 'package.json'));
|
|
36
|
+
|
|
37
|
+
/** The per-platform package that carries the TypeScript 7 Go compiler here. */
|
|
38
|
+
function goCompilerPackage() {
|
|
39
|
+
const os = { darwin: 'darwin', linux: 'linux', win32: 'win32' }[process.platform] ?? 'linux';
|
|
40
|
+
const arch = { arm: 'arm', arm64: 'arm64', x64: 'x64' }[process.arch] ?? 'x64';
|
|
41
|
+
|
|
42
|
+
return `@typescript/typescript-${os}-${arch}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* An installed package's own version, or null when it is not there. A manifest
|
|
47
|
+
* is read through the resolver first and off the disk second: a package whose
|
|
48
|
+
* `exports` map does not publish `./package.json` — knip is one — is invisible
|
|
49
|
+
* to `require`, and the two directories below are the same two `find_binary`
|
|
50
|
+
* walks in `check.sh`.
|
|
51
|
+
*/
|
|
52
|
+
function installedVersion(name) {
|
|
53
|
+
for (const at of [
|
|
54
|
+
() => require.resolve(`${name}/package.json`),
|
|
55
|
+
() => resolve(PACKAGE_ROOT, 'node_modules', name, 'package.json'),
|
|
56
|
+
() => resolve(PACKAGE_ROOT, '../..', name, 'package.json'),
|
|
57
|
+
]) {
|
|
58
|
+
try {
|
|
59
|
+
return manifestAt(at()).version;
|
|
60
|
+
} catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `1.83.0` as `[1, 83, 0]`, with anything after a `-` or `+` dropped. */
|
|
69
|
+
function parts(version) {
|
|
70
|
+
return version
|
|
71
|
+
.split(/[+-]/u)[0]
|
|
72
|
+
.split('.')
|
|
73
|
+
.map((piece) => Number.parseInt(piece, 10) || 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Negative when left is older, positive when newer, zero when the same. */
|
|
77
|
+
function compare(left, right) {
|
|
78
|
+
const [a, b] = [parts(left), parts(right)];
|
|
79
|
+
for (let index = 0; index < 3; index += 1) {
|
|
80
|
+
if ((a[index] ?? 0) !== (b[index] ?? 0)) {
|
|
81
|
+
return (a[index] ?? 0) - (b[index] ?? 0);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The floor a range asks for, and the first version it refuses. `^` follows
|
|
90
|
+
* npm: below 1.0.0 a caret allows the patch line only, which is exactly why
|
|
91
|
+
* oxfmt's range moves on every minor.
|
|
92
|
+
*/
|
|
93
|
+
function boundsOf(range) {
|
|
94
|
+
const floor = range.replace(/^[\^>=~ ]+/u, '');
|
|
95
|
+
const [major = 0, minor = 0] = parts(floor);
|
|
96
|
+
|
|
97
|
+
if (range.startsWith('^')) {
|
|
98
|
+
return { ceiling: major === 0 ? `0.${minor + 1}.0` : `${major + 1}.0.0`, floor };
|
|
99
|
+
}
|
|
100
|
+
if (range.startsWith('>=') || range.startsWith('>')) {
|
|
101
|
+
return { ceiling: null, floor };
|
|
102
|
+
}
|
|
103
|
+
if (range.startsWith('~')) {
|
|
104
|
+
return { ceiling: `${major}.${minor + 1}.0`, floor };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { ceiling: floor, exact: true, floor };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Where an installed version stands against a declared range. */
|
|
111
|
+
function verdictOf(installed, range) {
|
|
112
|
+
if (installed === null) {
|
|
113
|
+
return 'absent';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const { ceiling, exact, floor } = boundsOf(range);
|
|
117
|
+
if (compare(installed, floor) < 0) {
|
|
118
|
+
return 'old';
|
|
119
|
+
}
|
|
120
|
+
if (exact === true) {
|
|
121
|
+
return compare(installed, floor) === 0 ? 'ok' : 'newer';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return ceiling !== null && compare(installed, ceiling) >= 0 ? 'newer' : 'ok';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const declared = {
|
|
128
|
+
...manifest.dependencies,
|
|
129
|
+
...manifest.optionalDependencies,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Every tool the toolchain runs, with what is installed, what is declared and
|
|
134
|
+
* where the two stand. The drift report reads the same rows and prints only the
|
|
135
|
+
* ones that deviate, so a version is answered for in one place.
|
|
136
|
+
*/
|
|
137
|
+
export function toolVersions() {
|
|
138
|
+
return [
|
|
139
|
+
['node', process.version.replace(/^v/u, ''), manifest.engines?.node ?? '*'],
|
|
140
|
+
['tsc (Go)', installedVersion(goCompilerPackage()), declared[goCompilerPackage()] ?? '*'],
|
|
141
|
+
['typescript', installedVersion('typescript'), declared.typescript ?? '*'],
|
|
142
|
+
['oxlint', installedVersion('oxlint'), declared.oxlint ?? '*'],
|
|
143
|
+
['oxfmt', installedVersion('oxfmt'), declared.oxfmt ?? '*'],
|
|
144
|
+
[
|
|
145
|
+
'oxlint-tsgolint',
|
|
146
|
+
installedVersion('oxlint-tsgolint'),
|
|
147
|
+
declared['oxlint-tsgolint'] ?? '*',
|
|
148
|
+
],
|
|
149
|
+
['knip', installedVersion('knip'), declared.knip ?? '*'],
|
|
150
|
+
].map(([name, installed, range]) => ({
|
|
151
|
+
installed,
|
|
152
|
+
name,
|
|
153
|
+
range,
|
|
154
|
+
verdict: verdictOf(installed, range),
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* Imported for `toolVersions`, run for the report — never both at once. */
|
|
159
|
+
if (import.meta.main) {
|
|
160
|
+
const rows = toolVersions();
|
|
161
|
+
const failed = rows.some(({ verdict }) => verdict === 'old' || verdict === 'absent');
|
|
162
|
+
const warned = rows.some(({ verdict }) => verdict === 'newer');
|
|
163
|
+
|
|
164
|
+
stdout.write('Toolchain versions\n\n');
|
|
165
|
+
|
|
166
|
+
for (const { installed, name, range, verdict } of rows) {
|
|
167
|
+
stdout.write(
|
|
168
|
+
` ${name.padEnd(16)}${(installed ?? '—').padEnd(12)}${range.padEnd(12)}${verdict}\n`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
stdout.write('\n');
|
|
173
|
+
|
|
174
|
+
if (failed) {
|
|
175
|
+
stdout.write('A tool older than its range enforces an older rulebook without saying so.\n');
|
|
176
|
+
} else if (warned) {
|
|
177
|
+
stdout.write(
|
|
178
|
+
'A tool ahead of its range may be fine; the toolchain does not get to decide.\n',
|
|
179
|
+
);
|
|
180
|
+
} else {
|
|
181
|
+
stdout.write('Every tool is in range.\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
exit(failed ? 1 : 0);
|
|
185
|
+
}
|
package/lib/merge-knip-config.js
CHANGED
|
@@ -39,29 +39,26 @@ import { workspaceMembers } from './workspace-members.js';
|
|
|
39
39
|
* alone — a comment marker in a glob (`ignore: ["**\/*.js"]`) is data.
|
|
40
40
|
*/
|
|
41
41
|
function withoutComments(text) {
|
|
42
|
-
let output = ''
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
let output = '';
|
|
43
|
+
let index = 0;
|
|
44
|
+
let inString = false;
|
|
45
45
|
|
|
46
46
|
while (index < text.length) {
|
|
47
47
|
const char = text[index];
|
|
48
48
|
|
|
49
49
|
if (inString) {
|
|
50
|
-
const
|
|
51
|
-
output +=
|
|
52
|
-
inString =
|
|
53
|
-
index
|
|
50
|
+
const step = inStringStep(text, index);
|
|
51
|
+
output += step.kept;
|
|
52
|
+
inString = step.open;
|
|
53
|
+
index = step.next;
|
|
54
54
|
} else if (char === '"') {
|
|
55
55
|
inString = true;
|
|
56
56
|
output += char;
|
|
57
57
|
index += 1;
|
|
58
|
-
} else if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
} else if (char === '/' && text[index + 1] === '*') {
|
|
63
|
-
const end = text.indexOf('*/', index + 2);
|
|
64
|
-
index = end === -1 ? text.length : end + 2;
|
|
58
|
+
} else if (opens(text, index, '//')) {
|
|
59
|
+
index = afterLine(text, index);
|
|
60
|
+
} else if (opens(text, index, '/*')) {
|
|
61
|
+
index = afterBlock(text, index);
|
|
65
62
|
} else {
|
|
66
63
|
output += char;
|
|
67
64
|
index += 1;
|
|
@@ -71,6 +68,41 @@ function withoutComments(text) {
|
|
|
71
68
|
return output;
|
|
72
69
|
}
|
|
73
70
|
|
|
71
|
+
/**
|
|
72
|
+
* One step of a scan already inside a string literal: what it keeps, whether
|
|
73
|
+
* the literal is still `open` after it, and where the scan resumes. A backslash
|
|
74
|
+
* carries the character behind it, so an escaped quote never closes anything.
|
|
75
|
+
*/
|
|
76
|
+
function inStringStep(text, index) {
|
|
77
|
+
const char = text[index];
|
|
78
|
+
const escaped = char === '\\';
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
kept: escaped ? char + (text[index + 1] ?? '') : char,
|
|
82
|
+
next: index + (escaped ? 2 : 1),
|
|
83
|
+
open: escaped || char !== '"',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether a two-character marker opens at `index`. */
|
|
88
|
+
function opens(text, index, marker) {
|
|
89
|
+
return text.startsWith(marker, index);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Where the scan resumes past a `//` comment: the newline that ends it. */
|
|
93
|
+
function afterLine(text, index) {
|
|
94
|
+
const end = text.indexOf('\n', index);
|
|
95
|
+
|
|
96
|
+
return end === -1 ? text.length : end;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Where the scan resumes past a block comment: after its closing marker. */
|
|
100
|
+
function afterBlock(text, index) {
|
|
101
|
+
const end = text.indexOf('*/', index + 2);
|
|
102
|
+
|
|
103
|
+
return end === -1 ? text.length : end + 2;
|
|
104
|
+
}
|
|
105
|
+
|
|
74
106
|
/** Whether the comma at `index` is the last one of its collection — `["a",]`. */
|
|
75
107
|
function closesCollection(text, index) {
|
|
76
108
|
if (text[index] !== ',') {
|
|
@@ -87,19 +119,19 @@ function closesCollection(text, index) {
|
|
|
87
119
|
|
|
88
120
|
/** Drops a comma that closes its collection, outside string literals. */
|
|
89
121
|
function withoutTrailingCommas(text) {
|
|
90
|
-
let output = ''
|
|
91
|
-
|
|
92
|
-
|
|
122
|
+
let output = '';
|
|
123
|
+
let index = 0;
|
|
124
|
+
let inString = false;
|
|
93
125
|
|
|
94
126
|
while (index < text.length) {
|
|
95
127
|
const char = text[index];
|
|
96
128
|
const closes = closesCollection(text, index);
|
|
97
129
|
|
|
98
130
|
if (inString) {
|
|
99
|
-
const
|
|
100
|
-
output +=
|
|
101
|
-
inString =
|
|
102
|
-
index
|
|
131
|
+
const step = inStringStep(text, index);
|
|
132
|
+
output += step.kept;
|
|
133
|
+
inString = step.open;
|
|
134
|
+
index = step.next;
|
|
103
135
|
} else {
|
|
104
136
|
inString = char === '"';
|
|
105
137
|
output += closes ? '' : char;
|
|
@@ -115,8 +147,8 @@ function withoutTrailingCommas(text) {
|
|
|
115
147
|
* package's tree, and two token classes do not earn a dependency.
|
|
116
148
|
*/
|
|
117
149
|
function readJsonc(path) {
|
|
118
|
-
const document = readFileSync(path, 'utf8')
|
|
119
|
-
|
|
150
|
+
const document = readFileSync(path, 'utf8');
|
|
151
|
+
const json = withoutTrailingCommas(withoutComments(document));
|
|
120
152
|
|
|
121
153
|
return JSON.parse(json);
|
|
122
154
|
}
|
|
@@ -302,7 +334,7 @@ for (const dep of devDeps) {
|
|
|
302
334
|
for (const dep of [...prodDeps, ...devDeps]) {
|
|
303
335
|
// @scope/sub-package when the unscoped root is a peer or dependency
|
|
304
336
|
// E.g. @hono/node-server → hono
|
|
305
|
-
const scopeMatch =
|
|
337
|
+
const scopeMatch = /^@(?<scope>[^/]+)\//u.exec(dep);
|
|
306
338
|
if (
|
|
307
339
|
scopeMatch &&
|
|
308
340
|
(peerDeps.includes(scopeMatch.groups.scope) ||
|
|
@@ -314,7 +346,7 @@ for (const dep of [...prodDeps, ...devDeps]) {
|
|
|
314
346
|
|
|
315
347
|
// <parent>-plugin-* or <parent>-preset-* when <parent> is installed
|
|
316
348
|
// E.g. vitepress-plugin-llms → vitepress
|
|
317
|
-
const pluginMatch =
|
|
349
|
+
const pluginMatch = /^(?<parent>.+?)-(?:plugin|preset|transformer|loader)-/u.exec(dep);
|
|
318
350
|
if (pluginMatch && allDepNames.includes(pluginMatch.groups.parent)) {
|
|
319
351
|
autoIgnoreDeps.push(dep);
|
|
320
352
|
continue;
|