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