@jterrazz/typescript 10.0.0 → 10.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @jterrazz/typescript
2
2
 
3
- The complete TypeScript toolchain — build, run, check, and document with zero configuration. Powered by tsdown, Oxlint, Oxfmt, TypeScript 7, and Knip. Six profiles, one rulebook: every rule of every plugin it loads is decided by name.
3
+ The complete TypeScript toolchain — build, run, check, and document with zero configuration. Powered by tsdown, Oxlint, Oxfmt, TypeScript 7, and Knip. Seven profiles, one rulebook: every rule of every plugin it loads is decided by name.
4
4
 
5
5
  ## Installation
6
6
 
@@ -51,7 +51,7 @@ The full corpus lives in [`docs/`](docs/):
51
51
  - [Operating](docs/04-operating.md) — what publishes it, and which number moves.
52
52
  - [Building](docs/05-building.md) — `build`, `bundle`, `start`, `dev`.
53
53
  - [Quality checks](docs/06-quality-checks.md) — `check` / `fix` and their fifteen passes.
54
- - [Lint presets](docs/07-lint-presets.md) — the rulebook, the six profiles, `compose`, architecture, knip.
54
+ - [Lint presets](docs/07-lint-presets.md) — the rulebook, the seven profiles, `compose`, architecture, knip.
55
55
  - [Docs pipeline](docs/08-docs-pipeline.md) — the `typescript docs` compiler.
56
56
  - [Repo structure](docs/09-repo-structure.md) — pointer to the shared doctrine; what's TypeScript-specific here.
57
57
 
@@ -37,30 +37,10 @@ find_binary() {
37
37
  # the per-platform @typescript/typescript-* packages instead of a second
38
38
  # package named "typescript": typedoc and eslint-plugin-perfectionist load
39
39
  # the JS API from the "typescript" name (v6 here), and any typescript@7 in
40
- # the tree can hijack that lookup under pnpm's hoist fallback.
41
- find_tsc() {
42
- local os arch
43
- case "$(uname -s)" in
44
- Darwin) os="darwin" ;;
45
- Linux) os="linux" ;;
46
- MINGW*|MSYS*|CYGWIN*) os="win32" ;;
47
- *) os="linux" ;;
48
- esac
49
- case "$(uname -m)" in
50
- arm64|aarch64) arch="arm64" ;;
51
- armv7l) arch="arm" ;;
52
- *) arch="x64" ;;
53
- esac
54
-
55
- local pkg="@typescript/typescript-$os-$arch"
56
- if [ -x "$PACKAGE_ROOT/node_modules/$pkg/lib/tsc" ]; then
57
- echo "$PACKAGE_ROOT/node_modules/$pkg/lib/tsc"
58
- elif [ -x "$PACKAGE_ROOT/../../$pkg/lib/tsc" ]; then
59
- echo "$PACKAGE_ROOT/../../$pkg/lib/tsc"
60
- else
61
- find_binary tsc
62
- fi
63
- }
40
+ # the tree can hijack that lookup under pnpm's hoist fallback. The lookup is
41
+ # shared with the CLI's `tsc` passthrough, so it lives in one file.
42
+ # shellcheck source=../find-tsc.sh
43
+ . "$SCRIPT_DIR/../find-tsc.sh"
64
44
 
65
45
  # oxlint's type-aware rules run in `tsgolint`, a separate binary it looks up on
66
46
  # PATH — and a consumer's PATH has no reason to carry this package's bin dir. It
@@ -369,16 +349,38 @@ run_checks() {
369
349
  # ones no syntactic linter can express, and a flag that is only sometimes
370
350
  # passed is a rule set that is only sometimes enforced. `oxlint-tsgolint` is
371
351
  # a dependency of this package, so it is there for every consumer.
352
+ #
353
+ # In FIX mode the two rewriters run one after the other, and they are the
354
+ # only pair that does: `oxlint --fix` and `oxfmt` write the same files, and
355
+ # in parallel the second writer lands its copy over the first's — after
356
+ # which `fix` then `check` fails on formatting the fix had just settled.
357
+ local lint_pid=""
358
+ local lint_status=0
359
+ local format_status=0
372
360
  if [ "$FIX_MODE" = true ]; then
373
- "$OXLINT" --type-aware --fix "${LINT_ARGS[@]}" > "$tmp_dir/lint.log" 2>&1 &
361
+ # A fixer that changes MEANING is never applied unattended: the rules
362
+ # marked `unsafe` in the manifest are allowed for THIS run only, so
363
+ # `fix` leaves them alone and `check` still reports them for a human.
364
+ local unsafe_fixers=()
365
+ while IFS= read -r flag; do
366
+ [ -n "$flag" ] && unsafe_fixers+=("$flag")
367
+ done < <(node "$PACKAGE_ROOT/lib/unsafe-fixers.js")
368
+
369
+ "$OXLINT" --type-aware --fix "${unsafe_fixers[@]}" "${LINT_ARGS[@]}" \
370
+ > "$tmp_dir/lint.log" 2>&1 ||
371
+ lint_status=$?
372
+ "$OXFMT" > "$tmp_dir/format.log" 2>&1 || format_status=$?
374
373
  else
375
374
  "$OXLINT" --type-aware "${LINT_ARGS[@]}" > "$tmp_dir/lint.log" 2>&1 &
375
+ lint_pid=$!
376
376
  fi
377
- local lint_pid=$!
378
377
 
379
378
  # The same run, machine-readable, so the ratchet can be judged rule by rule.
380
379
  # A second invocation rather than a reformat of the first: the human log is
381
380
  # what a failing pass prints, and neither form can be derived from the other.
381
+ # In check mode it runs BESIDE the human one, on the same bytes. In fix mode
382
+ # it cannot: the bytes change under the fixer, so the machine-readable run
383
+ # is made after it, below.
382
384
  local lint_json_pid=""
383
385
  if [ "$FIX_MODE" = false ] && [ -f "$BASELINE_FILE" ]; then
384
386
  "$OXLINT" --type-aware --format json "${LINT_ARGS[@]}" \
@@ -386,12 +388,11 @@ run_checks() {
386
388
  lint_json_pid=$!
387
389
  fi
388
390
 
389
- if [ "$FIX_MODE" = true ]; then
390
- "$OXFMT" > "$tmp_dir/format.log" 2>&1 &
391
- else
391
+ local format_pid=""
392
+ if [ "$FIX_MODE" = false ]; then
392
393
  "$OXFMT" --check > "$tmp_dir/format.log" 2>&1 &
394
+ format_pid=$!
393
395
  fi
394
- local format_pid=$!
395
396
 
396
397
  # Knip: only run in check mode (fix mode is destructive)
397
398
  # Merge base config (from this package) with optional project-local knip.json.
@@ -605,19 +606,31 @@ run_checks() {
605
606
 
606
607
  # Wait and collect statuses
607
608
  wait $type_pid; local type_status=$?
608
- wait $lint_pid; local lint_status=$?
609
+ [ -n "$lint_pid" ] && { wait $lint_pid; lint_status=$?; }
609
610
 
610
611
  # The ratchet, where the project keeps one: the pass is judged by what the
611
- # baseline tolerates, not by oxlint's exit code. Bash decides whether the
612
- # file is there; the script decides what it says.
613
- if [ -n "$lint_json_pid" ]; then
614
- wait $lint_json_pid
612
+ # baseline tolerates, not by oxlint's exit code in FIX mode as much as in
613
+ # check mode, or `fix` then `check` reads red then green on the same tree.
614
+ # Bash decides whether the file is there; the script decides what it says.
615
+ #
616
+ # What fix mode judges is what SURVIVED the rewrite, so its machine-readable
617
+ # run is made here, after the fixer, and in the foreground — with the unsafe
618
+ # fixers armed again, so a directive that names one of them is used, not
619
+ # reported unused. The fixer's own exit code is never the verdict: it ran
620
+ # with those rules allowed. Without a baseline the judge wants zero.
621
+ if [ -f "$BASELINE_FILE" ] || [ "$FIX_MODE" = true ]; then
622
+ if [ -n "$lint_json_pid" ]; then
623
+ wait $lint_json_pid
624
+ else
625
+ "$OXLINT" --type-aware --format json "${LINT_ARGS[@]}" \
626
+ > "$tmp_dir/lint.json" 2>/dev/null
627
+ fi
615
628
  node "$PACKAGE_ROOT/lib/check-baseline.js" "$tmp_dir/lint.json" . \
616
629
  >> "$tmp_dir/lint.log" 2>&1
617
630
  lint_status=$?
618
631
  fi
619
632
 
620
- wait $format_pid; local format_status=$?
633
+ [ -n "$format_pid" ] && { wait $format_pid; format_status=$?; }
621
634
  [ -n "$knip_pid" ] && { wait $knip_pid; knip_status=$?; }
622
635
  [ -n "$gitignore_pid" ] && { wait $gitignore_pid; gitignore_status=$?; }
623
636
  [ -n "$docs_layout_pid" ] && { wait $docs_layout_pid; docs_layout_status=$?; }
@@ -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,6 +39,9 @@ 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
 
44
47
  # oxlint's type-aware rules run in `tsgolint`, a separate binary it looks up on
@@ -68,7 +71,15 @@ run_tsdown() {
68
71
 
69
72
  cd "$PROJECT_ROOT"
70
73
 
71
- 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
72
83
  printf "${RED}Error: Build failed${NC}\n"
73
84
  exit 1
74
85
  fi
@@ -219,6 +230,16 @@ case "$COMMAND" in
219
230
  "$OXFMT" oxlint.baseline.json > /dev/null 2>&1 || true
220
231
  ;;
221
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
+
222
243
  check|fix)
223
244
  exec bash "$SCRIPT_DIR/commands/check.sh" "$COMMAND" "$@"
224
245
  ;;
@@ -235,6 +256,7 @@ case "$COMMAND" in
235
256
  printf " docs-layout Check a repository's docs/ against the manual spine\n"
236
257
  printf " doctor Report the installed tool versions against the declared ranges\n"
237
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"
238
260
  printf " check Check types, lint, formatting, and unused code\n"
239
261
  printf " fix Auto-fix lint and formatting issues\n"
240
262
  printf " clean Remove .artifacts/ — dist/ stays, it is the build's product\n\n"
@@ -248,6 +270,7 @@ case "$COMMAND" in
248
270
  printf " typescript docs-layout .\n"
249
271
  printf " typescript doctor\n"
250
272
  printf " typescript baseline\n"
273
+ printf " typescript tsc --build\n"
251
274
  printf " typescript check\n"
252
275
  printf " typescript fix\n"
253
276
  printf " typescript clean\n"
@@ -128,7 +128,18 @@ if (import.meta.main) {
128
128
  const baseline = readBaseline(root);
129
129
 
130
130
  if (baseline === null) {
131
- exit(Object.keys(counts).length > 0 ? 1 : 0);
131
+ const rules = Object.keys(counts).toSorted();
132
+ for (const rule of rules) {
133
+ stdout.write(
134
+ `${rule} ${counts[rule]} diagnostic(s), and the project keeps no baseline\n`,
135
+ );
136
+ }
137
+ if (rules.length > 0) {
138
+ stdout.write(
139
+ "Fix them, or run 'typescript baseline' to record where the project stands.\n",
140
+ );
141
+ }
142
+ exit(rules.length > 0 ? 1 : 0);
132
143
  }
133
144
 
134
145
  const broken = judge(counts, baseline);
@@ -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
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * The rules `fix` must not let oxlint rewrite, as the flags that switch them
5
+ * off for one run: `--allow <rule>` per rule, one per line.
6
+ *
7
+ * A fixer that changes MEANING cannot be applied unattended — it turns working
8
+ * code into code that does not compile, or into code that claims something
9
+ * else. The rules are marked in the manifest (`unsafeFix()`), this prints them
10
+ * for the shell, and the oxlint pass of `fix` passes them through. Check mode
11
+ * keeps every one of them armed: the diagnostic is still owed an answer, from
12
+ * a human ([Quality checks](../docs/06-quality-checks.md)).
13
+ *
14
+ * Usage: node unsafe-fixers.js
15
+ */
16
+
17
+ import { stdout } from 'node:process';
18
+
19
+ import { unsafeFixers } from '../rules/catalog.js';
20
+
21
+ if (import.meta.main) {
22
+ for (const { rule } of unsafeFixers()) {
23
+ stdout.write(`--allow\n${rule}\n`);
24
+ }
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/typescript",
3
- "version": "10.0.0",
3
+ "version": "10.1.1",
4
4
  "license": "MIT",
5
5
  "author": "Jean-Baptiste Terrazzoni <contact@jterrazz.com>",
6
6
  "repository": {
@@ -39,12 +39,24 @@
39
39
  },
40
40
  "./tsconfig/*": "./presets/tsconfig/*.json",
41
41
  "./tsconfig/*.json": "./presets/tsconfig/*.json",
42
- "./tsdown/*": "./presets/tsdown/*.js",
43
- "./tsdown/*.js": "./presets/tsdown/*.js",
42
+ "./tsdown/*": {
43
+ "types": "./presets/tsdown/*.d.ts",
44
+ "default": "./presets/tsdown/*.js"
45
+ },
46
+ "./tsdown/*.js": {
47
+ "types": "./presets/tsdown/*.d.ts",
48
+ "default": "./presets/tsdown/*.js"
49
+ },
44
50
  "./presets/tsconfig/*": "./presets/tsconfig/*.json",
45
51
  "./presets/tsconfig/*.json": "./presets/tsconfig/*.json",
46
- "./presets/tsdown/*": "./presets/tsdown/*.js",
47
- "./presets/tsdown/*.js": "./presets/tsdown/*.js"
52
+ "./presets/tsdown/*": {
53
+ "types": "./presets/tsdown/*.d.ts",
54
+ "default": "./presets/tsdown/*.js"
55
+ },
56
+ "./presets/tsdown/*.js": {
57
+ "types": "./presets/tsdown/*.d.ts",
58
+ "default": "./presets/tsdown/*.js"
59
+ }
48
60
  },
49
61
  "publishConfig": {
50
62
  "registry": "https://registry.npmjs.org/"
@@ -72,7 +84,7 @@
72
84
  "typescript": "^6.0.0"
73
85
  },
74
86
  "devDependencies": {
75
- "@jterrazz/test": "^14.0.0",
87
+ "@jterrazz/test": "^15.0.0",
76
88
  "@types/node": "^26.1.1",
77
89
  "vitest": "^4.1.10"
78
90
  },
@@ -10,7 +10,7 @@ import { PROFILES } from '../../../rules/profiles.js';
10
10
  * The `.js` extension on every relative import is core's (`import/extensions`
11
11
  * at `always`), because Node ESM resolves a specifier literally and a package
12
12
  * published as ESM is read by Node before any bundler reads it. It pairs with
13
- * `presets/tsconfig/library.json`, whose `isolatedDeclarations` is what makes
14
- * the declarations emit without a type-checker.
13
+ * `presets/tsdown/bundle.js`, whose `isolatedDeclarations` is what makes the
14
+ * published declarations emit without a type-checker.
15
15
  */
16
16
  export default defineConfig(profile(PROFILES.library));
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'oxlint';
2
+
3
+ import { profile } from '../../../rules/compile.js';
4
+ import { PROFILES } from '../../../rules/profiles.js';
5
+
6
+ /** React with no framework under it: the rulebook, plus React and accessibility. */
7
+ export default defineConfig(profile(PROFILES.react));
@@ -0,0 +1,25 @@
1
+ {
2
+ "display": "Astro",
3
+ "compilerOptions": {
4
+ "allowJs": true,
5
+ "esModuleInterop": true,
6
+ "exactOptionalPropertyTypes": true,
7
+ "incremental": true,
8
+ "jsx": "react-jsx",
9
+ "jsxImportSource": "react",
10
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
11
+ "module": "ESNext",
12
+ "moduleResolution": "bundler",
13
+ "noEmit": true,
14
+ "noFallthroughCasesInSwitch": true,
15
+ "noImplicitOverride": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "noUncheckedSideEffectImports": true,
18
+ "resolveJsonModule": true,
19
+ "skipLibCheck": true,
20
+ "strict": true,
21
+ "target": "ESNext",
22
+ "tsBuildInfoFile": "${configDir}/.artifacts/tsc/tsconfig.tsbuildinfo",
23
+ "verbatimModuleSyntax": true
24
+ }
25
+ }
@@ -4,8 +4,7 @@
4
4
  "compilerOptions": {
5
5
  "allowJs": false,
6
6
  "declaration": true,
7
- "erasableSyntaxOnly": true,
8
- "isolatedDeclarations": true
7
+ "erasableSyntaxOnly": true
9
8
  },
10
9
  "include": ["${configDir}/**/*.ts"],
11
10
  "exclude": [
@@ -0,0 +1,33 @@
1
+ {
2
+ "display": "React (bundled, no framework)",
3
+ "include": ["${configDir}/**/*.ts", "${configDir}/**/*.tsx"],
4
+ "exclude": [
5
+ "${configDir}/node_modules",
6
+ "${configDir}/dist",
7
+ "${configDir}/.artifacts",
8
+ "${configDir}/**/_fixtures",
9
+ "${configDir}/**/_expected"
10
+ ],
11
+ "compilerOptions": {
12
+ "allowJs": true,
13
+ "esModuleInterop": true,
14
+ "exactOptionalPropertyTypes": true,
15
+ "incremental": true,
16
+ "isolatedModules": true,
17
+ "jsx": "react-jsx",
18
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
19
+ "module": "ESNext",
20
+ "moduleResolution": "bundler",
21
+ "noEmit": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noImplicitOverride": true,
24
+ "noUncheckedIndexedAccess": true,
25
+ "noUncheckedSideEffectImports": true,
26
+ "resolveJsonModule": true,
27
+ "skipLibCheck": true,
28
+ "strict": true,
29
+ "target": "ESNext",
30
+ "tsBuildInfoFile": "${configDir}/.artifacts/tsc/tsconfig.tsbuildinfo",
31
+ "verbatimModuleSyntax": true
32
+ }
33
+ }
@@ -0,0 +1,13 @@
1
+ /*
2
+ * The shape of a bundler config is tsdown's own fact, so this declaration does
3
+ * not restate it: `UserConfig` is re-exported from the tool, for a consumer
4
+ * that annotates its own config ([Developing](../../docs/02-developing.md)).
5
+ */
6
+
7
+ import type { UserConfig } from 'tsdown';
8
+
9
+ /** An application build: one ESM output with declarations and source maps. */
10
+ declare const build: UserConfig;
11
+
12
+ export { type UserConfig } from 'tsdown';
13
+ export default build;
@@ -0,0 +1,13 @@
1
+ /*
2
+ * The shape of a bundler config is tsdown's own fact, so this declaration does
3
+ * not restate it: `UserConfig` is re-exported from the tool, for a consumer
4
+ * that annotates its own config ([Developing](../../docs/02-developing.md)).
5
+ */
6
+
7
+ import type { UserConfig } from 'tsdown';
8
+
9
+ /** A library bundle: ESM and CJS outputs with declarations and source maps. */
10
+ declare const bundle: UserConfig;
11
+
12
+ export { type UserConfig } from 'tsdown';
13
+ export default bundle;
@@ -3,7 +3,16 @@ import { defineConfig } from 'tsdown';
3
3
  export default defineConfig({
4
4
  entry: ['src/index.ts'],
5
5
  format: ['esm', 'cjs'],
6
- dts: true,
6
+ /*
7
+ * `isolatedDeclarations` lives here, not in the `library` tsconfig preset:
8
+ * what it buys is a declaration emitted without a type-checker, which is a
9
+ * property of the PUBLISHED artefact and of nothing else. In the tsconfig
10
+ * it also reached every spec file, where it refused the destructured
11
+ * export a specification hands back ([Developing](../../docs/02-developing.md)).
12
+ * `bundle` is the library command, so the guarantee sits exactly where the
13
+ * tsconfig preset used to put it — and nowhere wider.
14
+ */
15
+ dts: { compilerOptions: { isolatedDeclarations: true } },
7
16
  sourcemap: true,
8
17
  clean: true,
9
18
  hash: false,
package/rules/README.md CHANGED
@@ -2,17 +2,17 @@
2
2
 
3
3
  One nature lives here: a DECISION about a lint rule. Never a config object, never a path, never a tool invocation — those are `presets/`'s and `bin/`'s. What the decisions mean, and the four laws they answer to, is [Lint presets](../docs/07-lint-presets.md); this page only says where each thing is.
4
4
 
5
- | File | Holds |
6
- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
- | `_contract.js` | `fragment()`, `on()`, `typeAware()`, `off()`, `scoped()` — and the load-time refusal of a level that is not `error`/`off` and of an `off` with no reason |
8
- | `compile.js` | fragment → plain oxlint config, and the deterministic merge behind `compose()`. It never emits `categories` |
9
- | `profiles.js` | which fragments each of the six profiles carries, and what none of them lints |
10
- | `catalog.js` | every decision as one list, and the markdown the chapter carries between its `GENERATED` markers |
11
- | `core/` | one file per plugin of the rulebook every profile holds: `eslint`, `typescript`, `unicorn`, `oxc`, `import`, `promise`, `node`, `jsdoc` |
12
- | `react.js` · `a11y.js` · `next.js` · `react-native.js` · `astro.js` | what a framework profile adds to that rulebook |
13
- | `vitest.js` | the test-file rules, as an `overrides` block — they read a test and say nothing about anything else |
14
- | `sorted.js` | perfectionist: only what oxfmt does not sort |
15
- | `architecture/` | `layers.js` turns a declared layer map into `no-restricted-imports` overrides; `hexagonal.js` is the map this package ships |
5
+ | File | Holds |
6
+ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | `_contract.js` | `fragment()`, `on()`, `typeAware()`, `off()`, `unsafeFix()`, `scoped()` — and the load-time refusal of a level that is not `error`/`off`, of an `off` with no reason and of an unsafe fixer with no reason |
8
+ | `compile.js` | fragment → plain oxlint config, and the deterministic merge behind `compose()`. It never emits `categories` |
9
+ | `profiles.js` | which fragments each of the seven profiles carries, and what none of them lints |
10
+ | `catalog.js` | every decision as one list, and the markdown the chapter carries between its `GENERATED` markers |
11
+ | `core/` | one file per plugin of the rulebook every profile holds: `eslint`, `typescript`, `unicorn`, `oxc`, `import`, `promise`, `node`, `jsdoc` |
12
+ | `react.js` · `a11y.js` · `next.js` · `react-native.js` · `astro.js` · `bundler.js` | what a framework profile adds to that rulebook, and what a bundled tree with no framework still owes |
13
+ | `vitest.js` | the test-file rules, as an `overrides` block — they read a test and say nothing about anything else |
14
+ | `sorted.js` | perfectionist: only what oxfmt does not sort |
15
+ | `architecture/` | `layers.js` turns a declared layer map into `no-restricted-imports` overrides; `hexagonal.js` is the map this package ships |
16
16
 
17
17
  A fragment is loaded, not read: importing one runs its contract, so a decision that breaks an invariant fails at import time rather than at review time.
18
18
 
@@ -30,6 +30,8 @@
30
30
  * @property {unknown} [options] The rule's options, at their decided value.
31
31
  * @property {Reason} [reason] Present on every `off`, absent on every `on`.
32
32
  * @property {boolean} [typeAware] Whether the rule needs type information.
33
+ * @property {'unsafe'} [fixer] Present when the rule's own fixer changes meaning.
34
+ * @property {string} [fixerReason] What that rewrite changes — measured, one clause.
33
35
  * @property {string} since The version the decision was taken in.
34
36
  *
35
37
  * @typedef {object} Scoped An `overrides` block, stated in decisions.
@@ -80,6 +82,20 @@ export function off(reason, since) {
80
82
  return Object.freeze({ level: 'off', reason: Object.freeze({ ...reason }), since });
81
83
  }
82
84
 
85
+ /**
86
+ * A decision whose FIXER changes meaning. The rule stays on — `check` reports
87
+ * it and a human answers it — but `fix` never applies the rewrite: the eight
88
+ * marked here have each been measured turning working code into code that
89
+ * does not compile, or into code that claims something else
90
+ * ([Lint presets](../docs/07-lint-presets.md)).
91
+ */
92
+ export function unsafeFix(decision, why) {
93
+ if (typeof why !== 'string' || why.length === 0) {
94
+ throw new TypeError('An unsafe fixer must say what its rewrite changes.');
95
+ }
96
+ return Object.freeze({ ...decision, fixer: 'unsafe', fixerReason: why });
97
+ }
98
+
83
99
  /**
84
100
  * A rule that is on and needs type information — `oxlint --type-aware`, which
85
101
  * every profile of this package turns on. The mark is what the catalogue reads.
package/rules/astro.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { fragment, off, on, scoped } from './_contract.js';
2
- import { EXTENSIONS_NEVER } from './core/import.js';
2
+ import { ASSETS } from './core/import.js';
3
3
 
4
4
  /*
5
5
  * Astro. oxlint parses an `.astro` file's script body but not its frontmatter
@@ -28,7 +28,14 @@ export default fragment({
28
28
  }),
29
29
  ],
30
30
  rules: {
31
- 'import/extensions': EXTENSIONS_NEVER,
31
+ /*
32
+ * `never`, with two extensions that stay: an asset, which Vite resolves
33
+ * BY its extension, and `.astro` itself — Astro's `resolve.extensions`
34
+ * does not carry it, so a layout imported without it fails the build.
35
+ * An override's options REPLACE the base entry, so the whole list is
36
+ * restated here.
37
+ */
38
+ 'import/extensions': on(['never', { ...ASSETS, astro: 'always' }]),
32
39
  'no-restricted-imports': on([
33
40
  {
34
41
  patterns: [
@@ -0,0 +1,19 @@
1
+ import { fragment } from './_contract.js';
2
+ import { EXTENSIONS_NEVER } from './core/import.js';
3
+
4
+ /*
5
+ * A bundled tree with no framework behind it — Vite, Remotion, a browser
6
+ * extension. oxlint has no plugin to load for one, so this fragment carries a
7
+ * single decision: the bundler resolves the specifier, so an import carries no
8
+ * extension.
9
+ *
10
+ * `rules/next.js`, `rules/astro.js` and `rules/react-native.js` each state the
11
+ * same decision inside their own framework fragment, where it sits beside that
12
+ * framework's plugin. A profile with no framework fragment states it here.
13
+ */
14
+ export default fragment({
15
+ id: 'bundler',
16
+ rules: {
17
+ 'import/extensions': EXTENSIONS_NEVER,
18
+ },
19
+ });
package/rules/catalog.js CHANGED
@@ -31,6 +31,12 @@ export const MARKERS = Object.freeze({
31
31
  start: '<!-- GENERATED -->',
32
32
  });
33
33
 
34
+ /** The fence that bounds the list of fixers `fix` refuses to run. */
35
+ export const FIXER_MARKERS = Object.freeze({
36
+ end: '<!-- /GENERATED:fixers -->',
37
+ start: '<!-- GENERATED:fixers -->',
38
+ });
39
+
34
40
  /** Every profile there is, so a decision carried by all of them says `all`. */
35
41
  const EVERY_PROFILE = Object.keys(PROFILES).length;
36
42
 
@@ -132,3 +138,29 @@ function scopeOf(entry) {
132
138
  }
133
139
  return `scoped to ${entry.scoped.map((glob) => `\`${glob}\``).join(', ')}`;
134
140
  }
141
+
142
+ /**
143
+ * Every rule whose own fixer changes meaning, with what the rewrite does.
144
+ * `check` reports them and a human answers them; `fix` runs with each one
145
+ * allowed, so the rewrite is never applied
146
+ * ([Quality checks](../docs/06-quality-checks.md)).
147
+ */
148
+ export function unsafeFixers() {
149
+ const seen = new Map();
150
+ for (const entry of catalog()) {
151
+ if (entry.fixer === 'unsafe' && !seen.has(entry.rule)) {
152
+ seen.set(entry.rule, entry.fixerReason);
153
+ }
154
+ }
155
+
156
+ return [...seen.entries()]
157
+ .map(([rule, why]) => ({ rule, why }))
158
+ .toSorted((left, right) => left.rule.localeCompare(right.rule));
159
+ }
160
+
161
+ /** The same list as the markdown the chapter carries between its fixer markers. */
162
+ export function renderFixers() {
163
+ return unsafeFixers()
164
+ .map(({ rule, why }) => `- \`${rule}\` — ${why}`)
165
+ .join('\n');
166
+ }
@@ -2,7 +2,14 @@ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { expect, test } from 'vitest';
4
4
 
5
- import { catalog, MARKERS, render, renderReference } from './catalog.js';
5
+ import {
6
+ catalog,
7
+ FIXER_MARKERS,
8
+ MARKERS,
9
+ render,
10
+ renderFixers,
11
+ renderReference,
12
+ } from './catalog.js';
6
13
 
7
14
  /*
8
15
  * The catalogue has two readers and one source. `docs/07-lint-presets.md` is
@@ -19,10 +26,17 @@ import { catalog, MARKERS, render, renderReference } from './catalog.js';
19
26
 
20
27
  const PROJECTIONS = {
21
28
  chapter: {
29
+ markers: MARKERS,
22
30
  page: resolve(import.meta.dirname, '../docs/07-lint-presets.md'),
23
31
  render,
24
32
  },
33
+ 'fixer list': {
34
+ markers: FIXER_MARKERS,
35
+ page: resolve(import.meta.dirname, '../docs/07-lint-presets.md'),
36
+ render: renderFixers,
37
+ },
25
38
  'skill reference': {
39
+ markers: MARKERS,
26
40
  page: resolve(import.meta.dirname, '../skills/jterrazz-typescript/references/rules.md'),
27
41
  render: renderReference,
28
42
  },
@@ -54,18 +68,18 @@ test('every decision carries the version it was taken in', () => {
54
68
 
55
69
  test.each(Object.entries(PROJECTIONS))(
56
70
  'the %s carries the catalogue the manifest renders',
57
- (_name, { page, render: project }) => {
71
+ (_name, { markers, page, render: project }) => {
58
72
  // Given - the generated section of the projection
59
73
  const before = readFileSync(page, 'utf8');
60
- const start = before.indexOf(MARKERS.start);
61
- const end = before.indexOf(MARKERS.end);
74
+ const start = before.indexOf(markers.start);
75
+ const end = before.indexOf(markers.end);
62
76
  expect(start, 'the page has lost its GENERATED marker').toBeGreaterThan(-1);
63
77
  expect(end, 'the page has lost its /GENERATED marker').toBeGreaterThan(start);
64
78
 
65
79
  // Then - it is exactly what the manifest renders today
66
80
  const table = project();
67
81
  if (process.env.TEST_UPDATE === '1') {
68
- const head = before.slice(0, start + MARKERS.start.length);
82
+ const head = before.slice(0, start + markers.start.length);
69
83
  writeFileSync(page, `${head}\n\n${table}\n\n${before.slice(end)}`);
70
84
  }
71
85
 
@@ -76,8 +90,8 @@ test.each(Object.entries(PROJECTIONS))(
76
90
  */
77
91
  const fresh = readFileSync(page, 'utf8');
78
92
  const carried = fresh.slice(
79
- fresh.indexOf(MARKERS.start) + MARKERS.start.length,
80
- fresh.indexOf(MARKERS.end),
93
+ fresh.indexOf(markers.start) + markers.start.length,
94
+ fresh.indexOf(markers.end),
81
95
  );
82
96
  expect(cells(carried)).toStrictEqual(cells(table));
83
97
  },
@@ -6,8 +6,9 @@ import { allOn, fragment, off, on } from '../_contract.js';
6
6
  * `import/extensions` is the one rule two platforms answer differently: Node
7
7
  * ESM resolves a specifier literally and needs the `.js`, a bundler resolves it
8
8
  * and refuses one. Core states the Node answer; `rules/next.js`,
9
- * `rules/astro.js` and `rules/react-native.js` re-decide it for their platform,
10
- * at `error` either way — a profile changes the convention, never the level.
9
+ * `rules/astro.js`, `rules/react-native.js` and `rules/bundler.js` re-decide it
10
+ * for their platform, at `error` either way — a profile changes the convention,
11
+ * never the level.
11
12
  *
12
13
  * `import/no-cycle` rides here for +0.05s. Mind what it does NOT see: oxlint's
13
14
  * implementation ignores type-only imports, so a value import one way and an
@@ -15,7 +16,7 @@ import { allOn, fragment, off, on } from '../_contract.js';
15
16
  */
16
17
 
17
18
  /** Asset specifiers keep their extension on every platform — a bundler resolves them by it. */
18
- const ASSETS = {
19
+ export const ASSETS = {
19
20
  avif: 'always',
20
21
  css: 'always',
21
22
  gif: 'always',
@@ -58,12 +59,17 @@ export default fragment({
58
59
  'no-self-import',
59
60
  'no-unassigned-import',
60
61
  'no-webpack-loader-syntax',
61
- 'unambiguous',
62
62
  ].map((rule) => `import/${rule}`),
63
63
  ),
64
64
 
65
65
  // -- On, at the strictest value the option carries ---------------------
66
- 'import/consistent-type-specifier-style': on(['prefer-inline']),
66
+ /*
67
+ * Top level, not inline, and `verbatimModuleSyntax` is why: under it
68
+ * `import { type X } from 'leaflet'` is emitted as
69
+ * `import {} from 'leaflet'` — a runtime side-effect import of a module
70
+ * that may only exist in a browser. signews-web served a 500 from it.
71
+ */
72
+ 'import/consistent-type-specifier-style': on(['prefer-top-level']),
67
73
  'import/extensions': EXTENSIONS_ALWAYS,
68
74
 
69
75
  // -- Off, each with its one reason -------------------------------------
@@ -103,5 +109,9 @@ export default fragment({
103
109
  by: 'docs/07-lint-presets.md — a module exports what it owns by name',
104
110
  kind: 'convention',
105
111
  }),
112
+ 'import/unambiguous': off({
113
+ by: 'TypeScript — `verbatimModuleSyntax` and a package\'s `"type": "module"` already make every file a module, and what the rule reports beyond that is a file with nothing to export: an entry script, an Astro `is:inline` block',
114
+ kind: 'covered',
115
+ }),
106
116
  },
107
117
  });
@@ -1,4 +1,4 @@
1
- import { allOn, fragment, off, on, scoped, typeAware } from '../_contract.js';
1
+ import { allOn, fragment, off, on, scoped, typeAware, unsafeFix } from '../_contract.js';
2
2
 
3
3
  /*
4
4
  * The `typescript` plugin, all 108 non-nursery rules decided by name. Fifty-one
@@ -92,7 +92,6 @@ export default fragment({
92
92
  'dot-notation',
93
93
  'no-array-delete',
94
94
  'no-base-to-string',
95
- 'no-confusing-void-expression',
96
95
  'no-deprecated',
97
96
  'no-duplicate-type-constituents',
98
97
  'no-floating-promises',
@@ -107,7 +106,6 @@ export default fragment({
107
106
  'no-unnecessary-qualifier',
108
107
  'no-unnecessary-template-expression',
109
108
  'no-unnecessary-type-arguments',
110
- 'no-unnecessary-type-assertion',
111
109
  'no-unnecessary-type-conversion',
112
110
  'no-unnecessary-type-parameters',
113
111
  'no-unsafe-argument',
@@ -119,7 +117,6 @@ export default fragment({
119
117
  'no-unsafe-type-assertion',
120
118
  'no-unsafe-unary-minus',
121
119
  'no-useless-default-assignment',
122
- 'non-nullable-type-assertion-style',
123
120
  'only-throw-error',
124
121
  'prefer-find',
125
122
  'prefer-includes',
@@ -153,14 +150,36 @@ export default fragment({
153
150
  'ts-nocheck': true,
154
151
  },
155
152
  ]),
156
- 'typescript/consistent-type-definitions': on(['type']),
153
+ /* Three fixers that change meaning, so `fix` never applies them and
154
+ * `check` still reports them: one rewrites a `declare module`
155
+ * augmentation into an alias that no longer merges, one wraps a
156
+ * returned promise so nothing awaits it, one drops a cast a widened
157
+ * platform type needs. */
158
+ 'typescript/consistent-type-definitions': unsafeFix(
159
+ on(['type']),
160
+ 'rewrites a `declare module` augmentation into an alias, which no longer merges',
161
+ ),
162
+ 'typescript/no-confusing-void-expression': unsafeFix(
163
+ typeAware(),
164
+ 'wraps a returned promise, after which nothing awaits it',
165
+ ),
166
+ 'typescript/no-unnecessary-type-assertion': unsafeFix(
167
+ typeAware(),
168
+ 'drops a cast a widened platform type needs',
169
+ ),
170
+ /* `separate-type-imports`, for `import/consistent-type-specifier-style`'s
171
+ * reason: an inline type specifier survives `verbatimModuleSyntax` as an
172
+ * empty runtime import. */
157
173
  'typescript/consistent-type-imports': on([
158
174
  {
159
175
  disallowTypeAnnotations: true,
160
- fixStyle: 'inline-type-imports',
176
+ fixStyle: 'separate-type-imports',
161
177
  prefer: 'type-imports',
162
178
  },
163
179
  ]),
180
+ /* The guard for the emit above: an all-inline type import is the shape
181
+ * `verbatimModuleSyntax` turns into a side effect, and this names it. */
182
+ 'typescript/no-import-type-side-effects': on(),
164
183
  'typescript/explicit-member-accessibility': on([{ accessibility: 'no-public' }]),
165
184
  /* Booleans excepted: `false ?? x` is `false` and `false || x` is `x`,
166
185
  * so on a boolean the two operators mean different things and the
@@ -177,21 +196,21 @@ export default fragment({
177
196
  kind: 'covered',
178
197
  }),
179
198
  'typescript/explicit-function-return-type': off({
180
- by: 'presets/tsconfig/library.json — isolatedDeclarations requires the annotation exactly where it is load-bearing',
199
+ by: 'presets/tsdown/bundle.js — isolatedDeclarations requires the annotation exactly where it is load-bearing, on a published export',
181
200
  kind: 'covered',
182
201
  }),
183
202
  'typescript/explicit-module-boundary-types': off({
184
- by: 'presets/tsconfig/library.json — isolatedDeclarations requires the annotation exactly where it is load-bearing',
203
+ by: 'presets/tsdown/bundle.js — isolatedDeclarations requires the annotation exactly where it is load-bearing, on a published export',
185
204
  kind: 'covered',
186
205
  }),
187
- 'typescript/no-import-type-side-effects': off({
188
- by: 'import/consistent-type-specifier-style (prefer-inline) — an import whose specifiers are all inline types is exactly the form that rule asks for',
189
- kind: 'exclusive',
190
- }),
191
206
  'typescript/no-empty-interface': off({
192
207
  by: 'typescript/no-empty-object-type — its upstream successor',
193
208
  kind: 'covered',
194
209
  }),
210
+ 'typescript/non-nullable-type-assertion-style': off({
211
+ by: 'typescript/no-non-null-assertion — its fix is the `!` assertion that rule forbids, so no edit closes both',
212
+ kind: 'exclusive',
213
+ }),
195
214
  'typescript/no-var-requires': off({
196
215
  by: 'typescript/no-require-imports — its upstream successor',
197
216
  kind: 'covered',
@@ -1,4 +1,4 @@
1
- import { allOn, fragment, off, on } from '../_contract.js';
1
+ import { allOn, fragment, off, on, unsafeFix } from '../_contract.js';
2
2
 
3
3
  /*
4
4
  * The `unicorn` plugin, all 137 non-nursery rules decided by name. Two of the
@@ -72,7 +72,6 @@ export default fragment({
72
72
  'no-useless-promise-resolve-reject',
73
73
  'no-useless-spread',
74
74
  'no-useless-switch-case',
75
- 'no-useless-undefined',
76
75
  'no-zero-fractions',
77
76
  'numeric-separators-style',
78
77
  'prefer-add-event-listener',
@@ -96,7 +95,6 @@ export default fragment({
96
95
  'prefer-event-target',
97
96
  'prefer-export-from',
98
97
  'prefer-global-this',
99
- 'prefer-import-meta-properties',
100
98
  'prefer-keyboard-event-key',
101
99
  'prefer-logical-operator-over-ternary',
102
100
  'prefer-math-min-max',
@@ -159,6 +157,18 @@ export default fragment({
159
157
  * default preference, and `'utf-8'` is the same encoding spelled longer. */
160
158
  'unicorn/text-encoding-identifier-case': on(),
161
159
 
160
+ /* Both fixers change meaning, so `fix` never applies them and `check`
161
+ * still reports them: one strips a REQUIRED argument, the other drops
162
+ * the trailing slash `new URL('.', import.meta.url)` carries. */
163
+ 'unicorn/no-useless-undefined': unsafeFix(
164
+ on(),
165
+ 'strips a REQUIRED argument, `mockReturnValue(undefined)` included (TS2554)',
166
+ ),
167
+ 'unicorn/prefer-import-meta-properties': unsafeFix(
168
+ on(),
169
+ "rewrites `fileURLToPath(new URL('.', import.meta.url))` into `import.meta.dirname`, which carries no trailing slash",
170
+ ),
171
+
162
172
  // -- Off, each with its one reason -------------------------------------
163
173
  'unicorn/explicit-length-check': off({
164
174
  by: 'typescript/strict-boolean-expressions — it already refuses a length used as a condition',
package/rules/profiles.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import a11y from './a11y.js';
2
2
  import astro from './astro.js';
3
+ import bundler from './bundler.js';
3
4
  import eslint from './core/eslint.js';
4
5
  import importPlugin from './core/import.js';
5
6
  import jsdoc from './core/jsdoc.js';
@@ -86,4 +87,9 @@ export const PROFILES = Object.freeze({
86
87
  fragments: [...CORE],
87
88
  ignorePatterns: [...IGNORED],
88
89
  },
90
+ react: {
91
+ env: { browser: true, builtin: true, node: true },
92
+ fragments: [...CORE, react, a11y, bundler],
93
+ ignorePatterns: [...IGNORED],
94
+ },
89
95
  });
package/rules/react.js CHANGED
@@ -144,5 +144,12 @@ export default fragment({
144
144
  by: 'presets/tsconfig/next.json — the automatic JSX runtime (jsx: react-jsx) imports it',
145
145
  kind: 'covered',
146
146
  }),
147
+
148
+ /*
149
+ * A stylesheet import IS its own assignment — `import './index.css'`
150
+ * is how a React tree carries its styles, whatever bundles it, and
151
+ * there is nothing to bind it to. One owner for every React profile.
152
+ */
153
+ 'import/no-unassigned-import': on([{ allow: ['**/*.css', '**/*.scss'] }]),
147
154
  },
148
155
  });
package/rules/vitest.js CHANGED
@@ -1,4 +1,4 @@
1
- import { allOn, fragment, off, on, scoped } from './_contract.js';
1
+ import { allOn, fragment, off, on, scoped, unsafeFix } from './_contract.js';
2
2
 
3
3
  /*
4
4
  * The `vitest` plugin, all 73 rules decided by name — inside an `overrides`
@@ -63,18 +63,16 @@ const ON_IN_TESTS = [
63
63
  'prefer-mock-return-shorthand',
64
64
  'prefer-snapshot-hint',
65
65
  'prefer-spy-on',
66
+ 'prefer-strict-boolean-matchers',
66
67
  'prefer-strict-equal',
67
68
  'prefer-to-be',
68
- 'prefer-to-be-falsy',
69
69
  'prefer-to-be-object',
70
- 'prefer-to-be-truthy',
71
70
  'prefer-to-contain',
72
71
  'prefer-to-have-been-called-times',
73
72
  'prefer-to-have-length',
74
73
  'prefer-todo',
75
74
  'require-awaited-expect-poll',
76
75
  'require-local-test-context-for-concurrent-snapshots',
77
- 'require-mock-type-parameters',
78
76
  'require-to-throw-message',
79
77
  'valid-describe-callback',
80
78
  'valid-expect',
@@ -83,6 +81,30 @@ const ON_IN_TESTS = [
83
81
  'warn-todo',
84
82
  ].map((rule) => `vitest/${rule}`);
85
83
 
84
+ /*
85
+ * The three vitest fixers that change meaning, so `fix` never applies them and
86
+ * `check` still reports them. They are named here rather than inline because a
87
+ * decision three calls deep inside an override reads as nesting, not as a
88
+ * decision.
89
+ *
90
+ * `prefer-lowercase-title` carries no `allowedPrefixes`: the rule is stricter
91
+ * than the `j5` rule @jterrazz/test retires for it — it also refuses a title
92
+ * opening on an all-caps identifier (`HTTP 404 …`, `DI …`) — and an existing
93
+ * title is a rename, not a case for an estate-specific escape hatch.
94
+ */
95
+ const CONSISTENT_TEST_IT = unsafeFix(
96
+ on([{ fn: 'test' }]),
97
+ 'rewrites `it(` into `test(` and leaves `import { it }` behind',
98
+ );
99
+ const PREFER_LOWERCASE_TITLE = unsafeFix(
100
+ on(),
101
+ 'lower-cases the first character blindly: `CLI …` becomes `cLI …`',
102
+ );
103
+ const REQUIRE_MOCK_TYPE_PARAMETERS = unsafeFix(
104
+ on(),
105
+ "rewrites `vi.mock('x', f)` into `vi.mock(import('x'), f)`, after which the factory owes the module's full type",
106
+ );
107
+
86
108
  export default fragment({
87
109
  id: 'vitest',
88
110
  plugins: ['vitest'],
@@ -92,13 +114,9 @@ export default fragment({
92
114
  rules: {
93
115
  ...allOn(ON_IN_TESTS),
94
116
 
95
- 'vitest/consistent-test-it': on([{ fn: 'test' }]),
96
- /* No `allowedPrefixes`. The rule is stricter than the `j5` rule
97
- * @jterrazz/test retires for it: it also refuses a title opening
98
- * on an all-caps identifier (`HTTP 404 …`, `DI …`). Strictest
99
- * sensible wins, and an existing title is a rename, not a case
100
- * for an estate-specific escape hatch. */
101
- 'vitest/prefer-lowercase-title': on(),
117
+ 'vitest/consistent-test-it': CONSISTENT_TEST_IT,
118
+ 'vitest/prefer-lowercase-title': PREFER_LOWERCASE_TITLE,
119
+ 'vitest/require-mock-type-parameters': REQUIRE_MOCK_TYPE_PARAMETERS,
102
120
 
103
121
  'vitest/no-conditional-in-test': off({
104
122
  by: "vitest/no-conditional-expect — the defect is an assertion that may not run, and that rule names it; this one also refuses a golden suite's TEST_UPDATE branch and every comparator",
@@ -124,8 +142,15 @@ export default fragment({
124
142
  by: 'docs/07-lint-presets.md — a spec states its assertions; counting them is bookkeeping the reader does not need',
125
143
  kind: 'convention',
126
144
  }),
127
- 'vitest/prefer-strict-boolean-matchers': off({
128
- by: 'vitest/prefer-to-be-truthy, vitest/prefer-to-be-falsy',
145
+ /* The pair the other way round. `toBe(true)` is a strict
146
+ * boolean assertion and `toBeTruthy()` is not — the fixer that
147
+ * rewrote one into the other WEAKENED every spec it touched. */
148
+ 'vitest/prefer-to-be-falsy': off({
149
+ by: 'vitest/prefer-strict-boolean-matchers — one asks for the strict matcher, the other for the falsy one',
150
+ kind: 'exclusive',
151
+ }),
152
+ 'vitest/prefer-to-be-truthy': off({
153
+ by: 'vitest/prefer-strict-boolean-matchers — one asks for the strict matcher, the other for the truthy one',
129
154
  kind: 'exclusive',
130
155
  }),
131
156
  'vitest/require-top-level-describe': off({
package/src/docs.test.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { expect, test } from 'vitest';
2
2
 
3
- import { auditDocs, type DocsTree } from './docs.js';
3
+ import { auditDocs } from './docs.js';
4
+ import type { DocsTree } from './docs.js';
4
5
 
5
6
  /**
6
7
  * The manual every repository carries, in its smallest compliant form: a map,
package/src/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { type OxfmtConfig } from './oxfmt.js';
2
- import { type OxlintConfig } from './oxlint.js';
1
+ import type { OxfmtConfig } from './oxfmt.js';
2
+ import type { OxlintConfig } from './oxlint.js';
3
3
 
4
4
  declare const oxfmtConfig: OxfmtConfig;
5
5
 
@@ -11,6 +11,7 @@ declare const oxlintProfiles: {
11
11
  library: OxlintConfig;
12
12
  next: OxlintConfig;
13
13
  node: OxlintConfig;
14
+ react: OxlintConfig;
14
15
  };
15
16
 
16
17
  declare const defaultExport: {
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ import expoProfile from '../presets/oxlint/profiles/expo.js';
5
5
  import libraryProfile from '../presets/oxlint/profiles/library.js';
6
6
  import nextProfile from '../presets/oxlint/profiles/next.js';
7
7
  import nodeProfile from '../presets/oxlint/profiles/node.js';
8
+ import reactProfile from '../presets/oxlint/profiles/react.js';
8
9
  import hexagonalFragment from '../rules/architecture/hexagonal.js';
9
10
  import { compile } from '../rules/compile.js';
10
11
 
@@ -18,6 +19,7 @@ export const oxlint = {
18
19
  library: libraryProfile,
19
20
  next: nextProfile,
20
21
  node: nodeProfile,
22
+ react: reactProfile,
21
23
  };
22
24
 
23
25
  export default { oxfmt, oxlint };
package/src/oxfmt.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * consumer's `defineConfig(base)` stopped type-checking because of it.
7
7
  */
8
8
 
9
- import { type OxfmtConfig } from 'oxfmt';
9
+ import type { OxfmtConfig } from 'oxfmt';
10
10
 
11
11
  declare const base: OxfmtConfig;
12
12
 
package/src/oxfmt.test.ts CHANGED
@@ -12,7 +12,7 @@ test('gives the formatter the three sorters, so no lint rule owns an order', ()
12
12
  // Given - the shared preset
13
13
  // Then - import order, package.json key order and Tailwind class order are oxfmt's
14
14
  expect(base.sortImports).toMatchObject({ ignoreCase: true, order: 'asc' });
15
- expect(base.sortPackageJson).toBeTruthy();
15
+ expect(base.sortPackageJson).toBe(true);
16
16
  expect(base.sortTailwindcss).toStrictEqual({
17
17
  functions: ['clsx', 'cn', 'cva', 'tv', 'twMerge', 'twJoin', 'tw'],
18
18
  });
package/src/oxlint.d.ts CHANGED
@@ -3,10 +3,10 @@
3
3
  * restate it: `OxlintConfig` and `OxlintOverride` are re-exported from the
4
4
  * tool, under the names a consumer already reads here. A hand copy drifts —
5
5
  * this one had `plugins?: string[]` where oxlint takes a closed union, and a
6
- * consumer's `defineConfig({ extends: [node] })` stopped type-checking.
6
+ * consumer's `defineConfig(node)` stopped type-checking.
7
7
  */
8
8
 
9
- import { type OxlintConfig } from 'oxlint';
9
+ import type { OxlintConfig } from 'oxlint';
10
10
 
11
11
  /** A rule entry as oxlint reads it: a level, or a level and its options. */
12
12
  type RuleEntry = NonNullable<OxlintConfig['rules']>[string];
@@ -27,6 +27,7 @@ declare const hexagonal: OxlintConfig;
27
27
  declare const library: OxlintConfig;
28
28
  declare const next: OxlintConfig;
29
29
  declare const node: OxlintConfig;
30
+ declare const react: OxlintConfig;
30
31
 
31
32
  /** The six boundaries the `hexagonal` fragment enforces, as a declared map. */
32
33
  declare const HEXAGONAL_MAP: readonly Layer[];
@@ -59,5 +60,6 @@ export {
59
60
  library,
60
61
  next,
61
62
  node,
63
+ react,
62
64
  type RuleEntry,
63
65
  };
package/src/oxlint.js CHANGED
@@ -1,11 +1,11 @@
1
1
  /*
2
- * The tool-facing oxlint entry (`@jterrazz/typescript/oxlint`): the six
2
+ * The tool-facing oxlint entry (`@jterrazz/typescript/oxlint`): the seven
3
3
  * profiles, the `compose()` merger, the layer-map builder, and oxlint's own
4
4
  * `defineConfig`. A consumer names a PROFILE, not a set of fragments:
5
5
  *
6
6
  * import { defineConfig, node } from '@jterrazz/typescript/oxlint';
7
7
  *
8
- * export default defineConfig({ extends: [node] });
8
+ * export default defineConfig(node);
9
9
  *
10
10
  * What each profile carries, and why every rule of every loaded plugin is
11
11
  * decided by name, is [Lint presets](../docs/07-lint-presets.md).
@@ -33,6 +33,7 @@ export { default as expo } from '../presets/oxlint/profiles/expo.js';
33
33
  export { default as library } from '../presets/oxlint/profiles/library.js';
34
34
  export { default as next } from '../presets/oxlint/profiles/next.js';
35
35
  export { default as node } from '../presets/oxlint/profiles/node.js';
36
+ export { default as react } from '../presets/oxlint/profiles/react.js';
36
37
 
37
38
  export { HEXAGONAL_MAP } from '../rules/architecture/hexagonal.js';
38
39