@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.
Files changed (81) hide show
  1. package/README.md +20 -16
  2. package/bin/commands/check.sh +387 -146
  3. package/bin/find-tsc.sh +30 -0
  4. package/bin/typescript.sh +79 -1
  5. package/lib/check-architecture.js +89 -0
  6. package/lib/check-baseline.js +144 -0
  7. package/lib/check-docs.js +4 -3
  8. package/lib/check-drift.js +209 -0
  9. package/lib/check-gitignore.js +4 -4
  10. package/lib/check-markdown.js +279 -0
  11. package/lib/check-names.js +125 -0
  12. package/lib/check-publish.js +150 -0
  13. package/lib/check-secrets.js +115 -0
  14. package/lib/check-suppressions.js +355 -0
  15. package/lib/doctor.js +185 -0
  16. package/lib/entry-points.js +91 -0
  17. package/lib/merge-knip-config.js +57 -25
  18. package/lib/tracked-files.js +165 -0
  19. package/lib/unsafe-fixers.js +25 -0
  20. package/lib/workspace-members.js +5 -6
  21. package/package.json +36 -13
  22. package/presets/oxfmt/index.js +49 -5
  23. package/presets/oxlint/profiles/astro.js +10 -0
  24. package/presets/oxlint/profiles/bun.js +7 -0
  25. package/presets/oxlint/profiles/expo.js +7 -0
  26. package/presets/oxlint/profiles/library.js +16 -0
  27. package/presets/oxlint/profiles/next.js +7 -0
  28. package/presets/oxlint/profiles/node.js +7 -0
  29. package/presets/oxlint/profiles/react.js +7 -0
  30. package/presets/prettier/astro.json +6 -0
  31. package/presets/tsconfig/astro.json +25 -0
  32. package/presets/tsconfig/expo.json +16 -6
  33. package/presets/tsconfig/library.json +17 -0
  34. package/presets/tsconfig/next.json +12 -2
  35. package/presets/tsconfig/node.json +18 -4
  36. package/presets/tsconfig/react.json +33 -0
  37. package/presets/tsdown/build.d.ts +13 -0
  38. package/presets/tsdown/bundle.d.ts +13 -0
  39. package/presets/tsdown/bundle.js +10 -1
  40. package/rules/README.md +23 -0
  41. package/rules/_contract.js +207 -0
  42. package/rules/_contract.test.ts +81 -0
  43. package/rules/a11y.js +51 -0
  44. package/rules/architecture/hexagonal.js +56 -0
  45. package/rules/architecture/layers.js +75 -0
  46. package/rules/astro.js +56 -0
  47. package/rules/bundler.js +19 -0
  48. package/rules/catalog.js +166 -0
  49. package/rules/catalog.test.ts +98 -0
  50. package/rules/compile.js +125 -0
  51. package/rules/core/eslint.js +234 -0
  52. package/rules/core/import.js +117 -0
  53. package/rules/core/jsdoc.js +52 -0
  54. package/rules/core/node.js +36 -0
  55. package/rules/core/oxc.js +54 -0
  56. package/rules/core/promise.js +39 -0
  57. package/rules/core/typescript.js +223 -0
  58. package/rules/core/unicorn.js +210 -0
  59. package/rules/next.js +53 -0
  60. package/rules/profiles.js +95 -0
  61. package/rules/react-native.js +48 -0
  62. package/rules/react.js +155 -0
  63. package/rules/sorted.js +41 -0
  64. package/rules/vitest.js +178 -0
  65. package/src/docs.d.ts +4 -4
  66. package/src/docs.js +75 -57
  67. package/src/docs.test.ts +43 -31
  68. package/src/index.d.ts +14 -9
  69. package/src/index.js +17 -8
  70. package/src/oxfmt.d.ts +15 -2
  71. package/src/oxfmt.test.ts +10 -0
  72. package/src/oxlint.d.ts +59 -10
  73. package/src/oxlint.js +36 -50
  74. package/src/oxlint.test.ts +82 -28
  75. package/presets/oxlint/architectures/hexagonal-rules.js +0 -39
  76. package/presets/oxlint/architectures/hexagonal.js +0 -13
  77. package/presets/oxlint/base.js +0 -145
  78. package/presets/oxlint/expo.js +0 -36
  79. package/presets/oxlint/next.js +0 -43
  80. package/presets/oxlint/node.js +0 -14
  81. 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
+ }