@jterrazz/typescript 9.3.0 → 10.1.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 +387 -146
- package/bin/find-tsc.sh +30 -0
- package/bin/typescript.sh +79 -1
- 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/entry-points.js +91 -0
- package/lib/merge-knip-config.js +57 -25
- package/lib/tracked-files.js +165 -0
- package/lib/unsafe-fixers.js +25 -0
- package/lib/workspace-members.js +5 -6
- package/package.json +36 -13
- 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/oxlint/profiles/react.js +7 -0
- package/presets/prettier/astro.json +6 -0
- package/presets/tsconfig/astro.json +25 -0
- package/presets/tsconfig/expo.json +16 -6
- package/presets/tsconfig/library.json +17 -0
- package/presets/tsconfig/next.json +12 -2
- package/presets/tsconfig/node.json +18 -4
- package/presets/tsconfig/react.json +33 -0
- package/presets/tsdown/build.d.ts +13 -0
- package/presets/tsdown/bundle.d.ts +13 -0
- package/presets/tsdown/bundle.js +10 -1
- package/rules/README.md +23 -0
- package/rules/_contract.js +207 -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 +56 -0
- package/rules/bundler.js +19 -0
- package/rules/catalog.js +166 -0
- package/rules/catalog.test.ts +98 -0
- package/rules/compile.js +125 -0
- package/rules/core/eslint.js +234 -0
- package/rules/core/import.js +117 -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 +223 -0
- package/rules/core/unicorn.js +210 -0
- package/rules/next.js +53 -0
- package/rules/profiles.js +95 -0
- package/rules/react-native.js +48 -0
- package/rules/react.js +155 -0
- package/rules/sorted.js +41 -0
- package/rules/vitest.js +178 -0
- package/src/docs.d.ts +4 -4
- package/src/docs.js +75 -57
- package/src/docs.test.ts +43 -31
- package/src/index.d.ts +14 -9
- package/src/index.js +17 -8
- package/src/oxfmt.d.ts +15 -2
- package/src/oxfmt.test.ts +10 -0
- package/src/oxlint.d.ts +59 -10
- package/src/oxlint.js +36 -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
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The entries a build compiles, read off the consumer's own `exports` map.
|
|
5
|
+
*
|
|
6
|
+
* A package with several public subpaths — `./register`, `./testing`, `./oxlint`
|
|
7
|
+
* — used to need a `tsdown.config.ts` of its own to name them, which means
|
|
8
|
+
* declaring tsdown as a dependency, which is the one-devDependency contract
|
|
9
|
+
* broken ([Developing](../docs/02-developing.md)). The map already says what
|
|
10
|
+
* the package publishes, so the build reads it there.
|
|
11
|
+
*
|
|
12
|
+
* The rule: every subpath whose target is a file under `dist/` is compiled from
|
|
13
|
+
* the same path under `src/`, with a `.ts` extension. A subpath carrying a `*`
|
|
14
|
+
* is skipped — a pattern names a set the map does not enumerate — and so is a
|
|
15
|
+
* target the source tree has no file for, because a build cannot compile what
|
|
16
|
+
* nobody wrote.
|
|
17
|
+
*
|
|
18
|
+
* Prints one entry per line, sorted; prints NOTHING when the package has no
|
|
19
|
+
* exports map or no subpath resolves, and the caller then keeps the preset's
|
|
20
|
+
* single `src/index.ts`.
|
|
21
|
+
*
|
|
22
|
+
* Usage: node entry-points.js [project-root]
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { join, resolve } from 'node:path';
|
|
27
|
+
import { argv, stdout } from 'node:process';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The file a subpath's condition tree points at, in the order Node resolves
|
|
31
|
+
* them. The empty string is "no target": a condition tree is data, and every
|
|
32
|
+
* answer here is a path.
|
|
33
|
+
*/
|
|
34
|
+
function targetOf(entry) {
|
|
35
|
+
if (typeof entry === 'string') {
|
|
36
|
+
return entry;
|
|
37
|
+
}
|
|
38
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
for (const condition of ['import', 'default', 'require']) {
|
|
42
|
+
const nested = targetOf(entry[condition]);
|
|
43
|
+
if (nested !== '') {
|
|
44
|
+
return nested;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `./dist/register.js` -> `src/register.ts`, and the empty string otherwise. */
|
|
51
|
+
function sourceOf(target) {
|
|
52
|
+
const match = /^\.\/dist\/(?<path>.+)\.(?:js|mjs|cjs)$/u.exec(target);
|
|
53
|
+
return match === null ? '' : `src/${match.groups.path}.ts`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every entry the map earns, in one order, each one a file that exists. */
|
|
57
|
+
export function entryPoints(root) {
|
|
58
|
+
const manifest = join(root, 'package.json');
|
|
59
|
+
if (!existsSync(manifest)) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let exported;
|
|
64
|
+
try {
|
|
65
|
+
exported = JSON.parse(readFileSync(manifest, 'utf8')).exports;
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
if (typeof exported !== 'object' || exported === null) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const entries = new Set();
|
|
74
|
+
for (const [subpath, entry] of Object.entries(exported)) {
|
|
75
|
+
if (subpath.includes('*')) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const source = sourceOf(targetOf(entry));
|
|
79
|
+
if (source !== '' && existsSync(join(root, source))) {
|
|
80
|
+
entries.add(source);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return [...entries].toSorted((left, right) => left.localeCompare(right));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (import.meta.main) {
|
|
88
|
+
for (const entry of entryPoints(resolve(argv[2] ?? '.'))) {
|
|
89
|
+
stdout.write(`${entry}\n`);
|
|
90
|
+
}
|
|
91
|
+
}
|