@jterrazz/typescript 9.3.0 → 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.
Files changed (71) hide show
  1. package/README.md +20 -16
  2. package/bin/commands/check.sh +348 -117
  3. package/bin/typescript.sh +55 -0
  4. package/lib/check-architecture.js +89 -0
  5. package/lib/check-baseline.js +144 -0
  6. package/lib/check-docs.js +4 -3
  7. package/lib/check-drift.js +209 -0
  8. package/lib/check-gitignore.js +4 -4
  9. package/lib/check-markdown.js +279 -0
  10. package/lib/check-names.js +125 -0
  11. package/lib/check-publish.js +150 -0
  12. package/lib/check-secrets.js +115 -0
  13. package/lib/check-suppressions.js +355 -0
  14. package/lib/doctor.js +185 -0
  15. package/lib/merge-knip-config.js +57 -25
  16. package/lib/tracked-files.js +165 -0
  17. package/lib/workspace-members.js +5 -6
  18. package/package.json +19 -8
  19. package/presets/oxfmt/index.js +49 -5
  20. package/presets/oxlint/profiles/astro.js +10 -0
  21. package/presets/oxlint/profiles/bun.js +7 -0
  22. package/presets/oxlint/profiles/expo.js +7 -0
  23. package/presets/oxlint/profiles/library.js +16 -0
  24. package/presets/oxlint/profiles/next.js +7 -0
  25. package/presets/oxlint/profiles/node.js +7 -0
  26. package/presets/prettier/astro.json +6 -0
  27. package/presets/tsconfig/expo.json +16 -6
  28. package/presets/tsconfig/library.json +18 -0
  29. package/presets/tsconfig/next.json +12 -2
  30. package/presets/tsconfig/node.json +18 -4
  31. package/rules/README.md +23 -0
  32. package/rules/_contract.js +191 -0
  33. package/rules/_contract.test.ts +81 -0
  34. package/rules/a11y.js +51 -0
  35. package/rules/architecture/hexagonal.js +56 -0
  36. package/rules/architecture/layers.js +75 -0
  37. package/rules/astro.js +49 -0
  38. package/rules/catalog.js +134 -0
  39. package/rules/catalog.test.ts +84 -0
  40. package/rules/compile.js +125 -0
  41. package/rules/core/eslint.js +234 -0
  42. package/rules/core/import.js +107 -0
  43. package/rules/core/jsdoc.js +52 -0
  44. package/rules/core/node.js +36 -0
  45. package/rules/core/oxc.js +54 -0
  46. package/rules/core/promise.js +39 -0
  47. package/rules/core/typescript.js +204 -0
  48. package/rules/core/unicorn.js +200 -0
  49. package/rules/next.js +53 -0
  50. package/rules/profiles.js +89 -0
  51. package/rules/react-native.js +48 -0
  52. package/rules/react.js +148 -0
  53. package/rules/sorted.js +41 -0
  54. package/rules/vitest.js +153 -0
  55. package/src/docs.d.ts +4 -4
  56. package/src/docs.js +75 -57
  57. package/src/docs.test.ts +43 -32
  58. package/src/index.d.ts +13 -9
  59. package/src/index.js +15 -8
  60. package/src/oxfmt.d.ts +15 -2
  61. package/src/oxfmt.test.ts +10 -0
  62. package/src/oxlint.d.ts +57 -10
  63. package/src/oxlint.js +35 -50
  64. package/src/oxlint.test.ts +82 -28
  65. package/presets/oxlint/architectures/hexagonal-rules.js +0 -39
  66. package/presets/oxlint/architectures/hexagonal.js +0 -13
  67. package/presets/oxlint/base.js +0 -145
  68. package/presets/oxlint/expo.js +0 -36
  69. package/presets/oxlint/next.js +0 -43
  70. package/presets/oxlint/node.js +0 -14
  71. 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
+ }
@@ -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
- index = 0,
44
- inString = false;
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 escaped = char === '\\';
51
- output += escaped ? char + (text[index + 1] ?? '') : char;
52
- inString = escaped || char !== '"';
53
- index += escaped ? 2 : 1;
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 (char === '/' && text[index + 1] === '/') {
59
- while (index < text.length && text[index] !== '\n') {
60
- index += 1;
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
- index = 0,
92
- inString = false;
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 escaped = char === '\\';
100
- output += escaped ? char + (text[index + 1] ?? '') : char;
101
- inString = escaped || char !== '"';
102
- index += escaped ? 2 : 1;
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
- json = withoutTrailingCommas(withoutComments(document));
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 = dep.match(/^@(?<scope>[^/]+)\//);
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 = dep.match(/^(?<parent>.+?)-(?:plugin|preset|transformer|loader)-/);
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;