@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
@@ -37,35 +37,67 @@ 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" ;;
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"
44
+
45
+ # oxlint's type-aware rules run in `tsgolint`, a separate binary it looks up on
46
+ # PATH — and a consumer's PATH has no reason to carry this package's bin dir. It
47
+ # is a dependency here, so the lookup is made to succeed by putting the
48
+ # directory that holds it in front, for this process and its children only.
49
+ add_tsgolint_to_path() {
50
+ local tsgolint
51
+ tsgolint=$(find_binary tsgolint)
52
+ case "$tsgolint" in
53
+ */*)
54
+ PATH="$(cd -P "$(dirname "$tsgolint")" && pwd):$PATH"
55
+ export PATH
56
+ ;;
53
57
  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
58
  }
64
59
 
60
+ add_tsgolint_to_path
61
+
62
+ # The config a consumer declares its rules in — the one thing that makes the
63
+ # drift report answerable. Without one there is no profile to have drifted from.
64
+ OXLINT_CONFIG=""
65
+ for candidate in oxlint.config.ts oxlint.config.mjs oxlint.config.js oxlint.config.cjs .oxlintrc.json; do
66
+ [ -f "$candidate" ] && { OXLINT_CONFIG="$candidate"; break; }
67
+ done
68
+
69
+ # The ratchet's file, at the project root. Its presence is what turns the oxlint
70
+ # pass from "no diagnostic at all" into "no diagnostic above what was recorded".
71
+ BASELINE_FILE="oxlint.baseline.json"
72
+
65
73
  TSC=$(find_tsc)
66
74
  OXLINT=$(find_binary oxlint)
67
75
  OXFMT=$(find_binary oxfmt)
68
76
  KNIP=$(find_binary knip)
77
+ DEPCRUISE=$(find_binary depcruise)
78
+ PUBLINT=$(find_binary publint)
79
+ ATTW=$(find_binary attw)
80
+ PRETTIER=$(find_binary prettier)
81
+
82
+ # `astro` is the CONSUMER's dependency, never this package's: the pass runs its
83
+ # checker, it does not ship one. So the lookup starts at the project.
84
+ find_project_binary() {
85
+ local name="$1"
86
+ if [ -x "node_modules/.bin/$name" ]; then
87
+ echo "$PWD/node_modules/.bin/$name"
88
+ else
89
+ find_binary "$name"
90
+ fi
91
+ }
92
+
93
+ # The one file shape oxfmt does not parse. The values are the oxfmt values —
94
+ # 100 / 4 / single / all — so a consumer never declares a formatter of its own.
95
+ #
96
+ # The plugin is passed as a RESOLVED PATH, not as a name in the config: prettier
97
+ # resolves a plugin name from the working directory, which is the consumer's,
98
+ # and the consumer is precisely the project that no longer declares it.
99
+ PRETTIER_ASTRO_CONFIG="$PACKAGE_ROOT/presets/prettier/astro.json"
100
+ PRETTIER_ASTRO_PLUGIN=$(cd "$PACKAGE_ROOT" && node -e 'process.stdout.write(require.resolve("prettier-plugin-astro"))' 2>/dev/null)
69
101
  CHECKER=$(find_binary jterrazz-test-check)
70
102
 
71
103
  # ── The unit is the workspace package, not the repository ────────────────────
@@ -106,15 +138,22 @@ project_uses_jterrazz_test() {
106
138
  node -e 'const {readFileSync}=require("node:fs");const p=JSON.parse(readFileSync(process.argv[1],"utf8"));const d={...p.dependencies,...p.devDependencies,...p.peerDependencies};process.exit(d["@jterrazz/test"]?0:1)' "$dir/package.json" 2>/dev/null
107
139
  }
108
140
 
109
- # In a workspace the dependency may sit on a member alone the warning below
110
- # is about the ROOT oxlint config, but the reason to print it is anywhere.
111
- workspace_uses_jterrazz_test() {
112
- project_uses_jterrazz_test "." && return 0
113
- local member
114
- for member in "${WORKSPACE_MEMBERS[@]}"; do
115
- project_uses_jterrazz_test "$member" && return 0
116
- done
117
- return 1
141
+ # An Astro project, read off its manifest. `.astro` is the one file shape oxfmt
142
+ # does not parse and `astro check` is the only checker that reads a template's
143
+ # frontmatter, so the pass exists exactly where the dependency does.
144
+ project_uses_astro() {
145
+ local dir="${1:-.}"
146
+ [ -f "$dir/package.json" ] || return 1
147
+ node -e 'const {readFileSync}=require("node:fs");const p=JSON.parse(readFileSync(process.argv[1],"utf8"));const d={...p.dependencies,...p.devDependencies,...p.peerDependencies};process.exit(d["astro"]?0:1)' "$dir/package.json" 2>/dev/null
148
+ }
149
+
150
+ # A package the registry would accept: it names an entry (`exports`, `main`) or
151
+ # a publish target, and it never says it is private. A private package, and a
152
+ # workspace root that only holds members, have no tarball to be judged on.
153
+ project_is_publishable() {
154
+ local dir="${1:-.}"
155
+ [ -f "$dir/package.json" ] || return 1
156
+ node -e 'const {readFileSync}=require("node:fs");const p=JSON.parse(readFileSync(process.argv[1],"utf8"));process.exit(p.private!==true&&(p.exports||p.main||p.publishConfig)?0:1)' "$dir/package.json" 2>/dev/null
118
157
  }
119
158
 
120
159
  # A path git has been told to forget is not this workspace's source. Clones,
@@ -174,31 +213,21 @@ discover_docs_roots() {
174
213
  } | LC_ALL=C sort -u
175
214
  }
176
215
 
177
- # The @jterrazz/test oxlint plugin is ESM-only. A CommonJS oxlint config silently drops
178
- # it (oxlint prints a load warning and still exits 0) none of the jterrazz/* rules run.
179
- # Warn loudly when that pitfall is detectable.
180
- warn_cjs_oxlint_config() {
181
- local cfg=""
182
- for c in oxlint.config.ts oxlint.config.mjs oxlint.config.cjs oxlint.config.js; do
183
- [ -f "$c" ] && { cfg="$c"; break; }
184
- done
185
- [ -z "$cfg" ] && return 0
186
-
187
- local is_cjs=false
188
- case "$cfg" in
189
- *.cjs) is_cjs=true ;;
216
+ # The name of the oxlint config, when that config is CommonJS and nothing
217
+ # otherwise. Everything this package ships is ESM, and so is every oxlint JS
218
+ # plugin the estate writes; a CommonJS config cannot load either, and oxlint
219
+ # drops what it cannot load and still exits 0. The rules a config names are the
220
+ # whole claim of a lint run, so the shape of the config is the oxlint pass's
221
+ # business ([Quality checks](../../docs/06-quality-checks.md)).
222
+ commonjs_oxlint_config() {
223
+ case "$OXLINT_CONFIG" in
224
+ *.cjs) printf '%s' "$OXLINT_CONFIG" ;;
190
225
  *.js)
191
226
  if ! node -e 'process.exit(require("./package.json").type==="module"?0:1)' 2>/dev/null; then
192
- is_cjs=true
227
+ printf '%s' "$OXLINT_CONFIG"
193
228
  fi
194
229
  ;;
195
230
  esac
196
-
197
- if [ "$is_cjs" = true ]; then
198
- printf "${RED} WARNING ${NC} @jterrazz/test is installed but %s is CommonJS.\n" "$cfg"
199
- printf " The @jterrazz/test oxlint plugin is ESM-only and will be SILENTLY DROPPED —\n"
200
- printf " none of the jterrazz/* rules will run. Switch to an ESM config (oxlint.config.ts or .mjs).\n\n"
201
- fi
202
231
  }
203
232
 
204
233
  # Parse command and args
@@ -226,6 +255,75 @@ while [[ $# -gt 0 ]]; do
226
255
  esac
227
256
  done
228
257
 
258
+ # ── How a pass speaks ────────────────────────────────────────────────────────
259
+ # Every pass that RAN prints the same three things, in the same order: a `RUN`
260
+ # header carrying its label, whatever it has to say, and one verdict line. A
261
+ # pass that did not apply — no Astro in the project, no declared layer map —
262
+ # prints nothing at all, because it answered no question.
263
+ #
264
+ # What sits between the header and the verdict is the one variable: a failing
265
+ # pass prints its whole captured log, and a passing one stays silent unless it
266
+ # WROTE something. `fix` changed a file the operator owns and silence would hide
267
+ # it, so a writer asks for its log with the fourth argument; a reader's success
268
+ # chatter — oxlint's summary, which its reporter prints on a runner and in a
269
+ # terminal and drops under an AI agent — never reaches the stream, so a green
270
+ # run is byte-identical everywhere.
271
+ report_pass() {
272
+ local label="$1" status="$2" log="$3" writer="${4:-}"
273
+
274
+ printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} %s\n\n" "$label"
275
+ if [ "$status" -ne 0 ] || [ -n "$writer" ]; then
276
+ [ -s "$log" ] && cat "$log"
277
+ fi
278
+ if [ "$status" -eq 0 ]; then
279
+ printf "${GREEN}✓ Passed${NC}\n"
280
+ else
281
+ printf "${RED}✗ Failed with exit code %d${NC}\n" "$status"
282
+ fi
283
+ }
284
+
285
+ # Two ways a lint run says nothing about the rules it was supposed to enforce.
286
+ #
287
+ # A config oxlint cannot parse — a `jsPlugins` naming a module that is not there
288
+ # is the common one — makes it print `Failed to parse oxlint configuration file`
289
+ # and lint nothing; 1.83.0 exits 1 for it, and this names the refusal in the
290
+ # toolchain's own vocabulary rather than leaving a reader with the tool's text.
291
+ # A CommonJS config is the silent one: oxlint drops it whole, prints NOTHING and
292
+ # exits 0, so the run is green having enforced no rule the config named.
293
+ #
294
+ # Both are the oxlint pass refusing, so both are written into its own log and
295
+ # both fail it. After this, `lint_status` is the verdict on what the linter
296
+ # actually ran, not on what it managed to exit with.
297
+ judge_lint_config() {
298
+ local cjs
299
+ cjs=$(commonjs_oxlint_config)
300
+ local refusals=""
301
+
302
+ if grep -q 'Failed to parse oxlint configuration file' "$tmp_dir/lint.log" 2>/dev/null; then
303
+ refusals+="oxlint-config-unparsed ${OXLINT_CONFIG} oxlint refused this config and linted nothing — its own report is above"$'\n'
304
+ fi
305
+ if [ -n "$cjs" ]; then
306
+ refusals+="oxlint-config-commonjs ${cjs} a CommonJS config cannot load an ESM preset or plugin, and oxlint drops what it cannot load — write oxlint.config.ts or .mjs"$'\n'
307
+ fi
308
+
309
+ [ -z "$refusals" ] && return 0
310
+
311
+ printf '%s' "$refusals" >> "$tmp_dir/lint.log"
312
+ lint_status=1
313
+ }
314
+
315
+ # One pass, N runs: the logs of the runs that FAILED, joined into the single log
316
+ # the pass reports under. A green member stays silent — it is the same pass.
317
+ join_logs() {
318
+ local into="$1"
319
+ shift
320
+ : > "$into"
321
+ local log
322
+ for log in "$@"; do
323
+ [ -s "$log" ] && cat "$log" >> "$into"
324
+ done
325
+ }
326
+
229
327
  # Create a temporary directory for log files
230
328
  tmp_dir=$(mktemp -d)
231
329
  cleanup() { rm -rf "$tmp_dir"; }
@@ -243,27 +341,58 @@ run_checks() {
243
341
 
244
342
  printf "${CYAN_BG}${BRIGHT_WHITE} START ${NC} ${LABEL}\n"
245
343
 
246
- if workspace_uses_jterrazz_test; then
247
- warn_cjs_oxlint_config
248
- fi
249
-
250
344
  # Run all tools in parallel
251
345
  "$TSC" --noEmit > "$tmp_dir/type.log" 2>&1 &
252
346
  local type_pid=$!
253
347
 
348
+ # --type-aware is explicit and unconditional: the rules it unlocks are the
349
+ # ones no syntactic linter can express, and a flag that is only sometimes
350
+ # passed is a rule set that is only sometimes enforced. `oxlint-tsgolint` is
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
254
360
  if [ "$FIX_MODE" = true ]; then
255
- "$OXLINT" --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=$?
256
373
  else
257
- "$OXLINT" "${LINT_ARGS[@]}" > "$tmp_dir/lint.log" 2>&1 &
374
+ "$OXLINT" --type-aware "${LINT_ARGS[@]}" > "$tmp_dir/lint.log" 2>&1 &
375
+ lint_pid=$!
258
376
  fi
259
- local lint_pid=$!
260
377
 
261
- if [ "$FIX_MODE" = true ]; then
262
- "$OXFMT" > "$tmp_dir/format.log" 2>&1 &
263
- else
378
+ # The same run, machine-readable, so the ratchet can be judged rule by rule.
379
+ # A second invocation rather than a reformat of the first: the human log is
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.
384
+ local lint_json_pid=""
385
+ if [ "$FIX_MODE" = false ] && [ -f "$BASELINE_FILE" ]; then
386
+ "$OXLINT" --type-aware --format json "${LINT_ARGS[@]}" \
387
+ > "$tmp_dir/lint.json" 2>/dev/null &
388
+ lint_json_pid=$!
389
+ fi
390
+
391
+ local format_pid=""
392
+ if [ "$FIX_MODE" = false ]; then
264
393
  "$OXFMT" --check > "$tmp_dir/format.log" 2>&1 &
394
+ format_pid=$!
265
395
  fi
266
- local format_pid=$!
267
396
 
268
397
  # Knip: only run in check mode (fix mode is destructive)
269
398
  # Merge base config (from this package) with optional project-local knip.json.
@@ -329,6 +458,113 @@ run_checks() {
329
458
  docs_layout_pid=$!
330
459
  fi
331
460
 
461
+ # Suppressions (directives): every place the project told a checker to look
462
+ # away is spelled in this toolchain's vocabulary, carries its reason, and
463
+ # names a rule that is still live. It runs in BOTH modes — `--fix` settles
464
+ # the two spellings a machine can settle, and never invents a reason.
465
+ local suppressions_pid=""
466
+ local suppressions_status=0
467
+ local suppressions_fix=()
468
+ [ "$FIX_MODE" = true ] && suppressions_fix=(--fix)
469
+ node "$PACKAGE_ROOT/lib/check-suppressions.js" . --oxlint "$OXLINT" \
470
+ "${suppressions_fix[@]}" "${LINT_ARGS[@]}" > "$tmp_dir/suppressions.log" 2>&1 &
471
+ suppressions_pid=$!
472
+
473
+ # Markdown (prose): every tracked page's coordinates resolve, and its blocks
474
+ # breathe. Check-only — there is no rewrite that splits a paragraph into the
475
+ # two ideas it was carrying. It reads the same `--ignore-pattern` globs the
476
+ # linter received, so one flag answers for the whole run.
477
+ local markdown_pid=""
478
+ local markdown_status=0
479
+ if [ "$FIX_MODE" = false ]; then
480
+ node "$PACKAGE_ROOT/lib/check-markdown.js" . "${LINT_ARGS[@]}" \
481
+ > "$tmp_dir/markdown.log" 2>&1 &
482
+ markdown_pid=$!
483
+ fi
484
+
485
+ # Names: what the project calls its own parts, under the roots where a
486
+ # project keeps what it wrote. Check-only — renaming a file is a move, and
487
+ # choosing the name it moves to is the work the rule is asking for.
488
+ local names_pid=""
489
+ local names_status=0
490
+ if [ "$FIX_MODE" = false ]; then
491
+ node "$PACKAGE_ROOT/lib/check-names.js" . "${LINT_ARGS[@]}" > "$tmp_dir/names.log" 2>&1 &
492
+ names_pid=$!
493
+ fi
494
+
495
+ # Secrets: no file the project would commit carries a live-looking
496
+ # credential. The gate always applies, so WHICH engine answers — gitleaks
497
+ # where the machine has it, the built-in patterns where it does not — is
498
+ # decided inside the script, not here.
499
+ local secrets_pid=""
500
+ local secrets_status=0
501
+ if [ "$FIX_MODE" = false ]; then
502
+ node "$PACKAGE_ROOT/lib/check-secrets.js" . "${LINT_ARGS[@]}" \
503
+ > "$tmp_dir/secrets.log" 2>&1 &
504
+ secrets_pid=$!
505
+ fi
506
+
507
+ # Astro: the consumer's own checker, plus the formatter for the one file
508
+ # shape oxfmt does not parse. Both halves run in fix mode too — prettier
509
+ # writes there, and `astro check` is read-only wherever it runs.
510
+ local astro_pid=""
511
+ local astro_status=0
512
+ if project_uses_astro "."; then
513
+ ASTRO=$(find_project_binary astro)
514
+ # A subshell, so both halves run and both are reported — a template that
515
+ # does not type-check is not a reason to stay quiet about its shape.
516
+ # `local` has no meaning past the `&`, hence the plain names.
517
+ (
518
+ "$ASTRO" check
519
+ astro_check=$?
520
+ if [ "$FIX_MODE" = true ]; then
521
+ "$PRETTIER" --write --config "$PRETTIER_ASTRO_CONFIG" \
522
+ --plugin "$PRETTIER_ASTRO_PLUGIN" \
523
+ --no-error-on-unmatched-pattern "**/*.astro"
524
+ else
525
+ "$PRETTIER" --check --config "$PRETTIER_ASTRO_CONFIG" \
526
+ --plugin "$PRETTIER_ASTRO_PLUGIN" \
527
+ --no-error-on-unmatched-pattern "**/*.astro"
528
+ fi
529
+ astro_format=$?
530
+ [ $astro_check -eq 0 ] && [ $astro_format -eq 0 ]
531
+ ) > "$tmp_dir/astro.log" 2>&1 &
532
+ astro_pid=$!
533
+ fi
534
+
535
+ # Architecture (layer map): the graph a project declared, resolved. Bash
536
+ # asks the one question that decides whether the gate applies at all — is
537
+ # there a map — and the script decides what it says. Opt-in by the file's
538
+ # existence: a project with no declared architecture is not in breach of one.
539
+ local architecture_pid=""
540
+ local architecture_status=0
541
+ if [ "$FIX_MODE" = false ] &&
542
+ { [ -f ".dependency-cruiser.cjs" ] || [ -f ".dependency-cruiser.js" ] ||
543
+ [ -f ".dependency-cruiser.mjs" ]; }; then
544
+ node "$PACKAGE_ROOT/lib/check-architecture.js" . --depcruise "$DEPCRUISE" \
545
+ > "$tmp_dir/architecture.log" 2>&1 &
546
+ architecture_pid=$!
547
+ fi
548
+
549
+ # Publish (packaging): what a published package promises, held to what the
550
+ # tarball will contain. Once per package the registry would accept — the
551
+ # unit is the workspace package, and a private one has no tarball.
552
+ local publish_pids=()
553
+ local publish_logs=()
554
+ local publish_status=0
555
+ if [ "$FIX_MODE" = false ]; then
556
+ local publish_index=0
557
+ for publish_root in "." "${WORKSPACE_MEMBERS[@]}"; do
558
+ project_is_publishable "$publish_root" || continue
559
+ node "$PACKAGE_ROOT/lib/check-publish.js" "$publish_root" \
560
+ --publint "$PUBLINT" --attw "$ATTW" \
561
+ > "$tmp_dir/publish-$publish_index.log" 2>&1 &
562
+ publish_pids+=($!)
563
+ publish_logs+=("$tmp_dir/publish-$publish_index.log")
564
+ publish_index=$((publish_index + 1))
565
+ done
566
+ fi
567
+
332
568
  # Conventions checker: only in check mode, once per specs root the workspace
333
569
  # owns, gated by the package that OWNS that root — a member may depend on
334
570
  # @jterrazz/test while the root does not, and the reverse.
@@ -370,11 +606,37 @@ run_checks() {
370
606
 
371
607
  # Wait and collect statuses
372
608
  wait $type_pid; local type_status=$?
373
- wait $lint_pid; local lint_status=$?
374
- wait $format_pid; local format_status=$?
609
+ [ -n "$lint_pid" ] && { wait $lint_pid; lint_status=$?; }
610
+
611
+ # The ratchet, where the project keeps one: the pass is judged by what the
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.
618
+ if [ -f "$BASELINE_FILE" ]; then
619
+ if [ -n "$lint_json_pid" ]; then
620
+ wait $lint_json_pid
621
+ else
622
+ "$OXLINT" --type-aware --format json "${LINT_ARGS[@]}" \
623
+ > "$tmp_dir/lint.json" 2>/dev/null
624
+ fi
625
+ node "$PACKAGE_ROOT/lib/check-baseline.js" "$tmp_dir/lint.json" . \
626
+ >> "$tmp_dir/lint.log" 2>&1
627
+ lint_status=$?
628
+ fi
629
+
630
+ [ -n "$format_pid" ] && { wait $format_pid; format_status=$?; }
375
631
  [ -n "$knip_pid" ] && { wait $knip_pid; knip_status=$?; }
376
632
  [ -n "$gitignore_pid" ] && { wait $gitignore_pid; gitignore_status=$?; }
377
633
  [ -n "$docs_layout_pid" ] && { wait $docs_layout_pid; docs_layout_status=$?; }
634
+ wait $suppressions_pid; suppressions_status=$?
635
+ [ -n "$markdown_pid" ] && { wait $markdown_pid; markdown_status=$?; }
636
+ [ -n "$architecture_pid" ] && { wait $architecture_pid; architecture_status=$?; }
637
+ [ -n "$astro_pid" ] && { wait $astro_pid; astro_status=$?; }
638
+ [ -n "$names_pid" ] && { wait $names_pid; names_status=$?; }
639
+ [ -n "$secrets_pid" ] && { wait $secrets_pid; secrets_status=$?; }
378
640
 
379
641
  # One pass, N runs: the pass fails if any run failed, and only the logs of
380
642
  # the runs that FAILED are printed — a green member stays silent.
@@ -388,6 +650,16 @@ run_checks() {
388
650
  index=$((index + 1))
389
651
  done
390
652
 
653
+ local publish_failed_logs=()
654
+ index=0
655
+ for pid in "${publish_pids[@]}"; do
656
+ if ! wait "$pid"; then
657
+ publish_status=1
658
+ publish_failed_logs+=("${publish_logs[$index]}")
659
+ fi
660
+ index=$((index + 1))
661
+ done
662
+
391
663
  local docs_failed_logs=()
392
664
  index=0
393
665
  for pid in "${docs_pids[@]}"; do
@@ -398,92 +670,61 @@ run_checks() {
398
670
  index=$((index + 1))
399
671
  done
400
672
 
401
- # Print results quiet on success, verbose on failure: a tool's captured log
402
- # is shown only when it failed, so green output stays byte-identical across
403
- # platforms (some tool builds print success chatter on Linux but not macOS).
404
- printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} TypeScript Check\n\n"
405
- if [ $type_status -ne 0 ]; then
406
- [ -s "$tmp_dir/type.log" ] && cat "$tmp_dir/type.log"
407
- printf "${RED}✗ Failed with exit code %d${NC}\n" $type_status
408
- else
409
- printf "${GREEN}✓ Passed${NC}\n"
410
- fi
673
+ # ── The report ───────────────────────────────────────────────────────────
674
+ # One order, and it is the chapter's: the three tools, the artefact gate,
675
+ # knip, the conventions checker, the two Docs passes, then the gates a
676
+ # project opts into and the four that read its tree on every run. A pass
677
+ # that did not apply is absent; every pass that ran prints the same block.
678
+ join_logs "$tmp_dir/checker.log" "${checker_failed_logs[@]}"
679
+ join_logs "$tmp_dir/docs.log" "${docs_failed_logs[@]}"
680
+ join_logs "$tmp_dir/publish.log" "${publish_failed_logs[@]}"
411
681
 
412
682
  local lint_label="Oxlint Check"
413
- [ "$FIX_MODE" = true ] && lint_label="Oxlint Fix"
414
- printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} ${lint_label}\n\n"
415
- if [ $lint_status -ne 0 ]; then
416
- [ -s "$tmp_dir/lint.log" ] && cat "$tmp_dir/lint.log"
417
- printf "${RED}✗ Failed with exit code %d${NC}\n" $lint_status
418
- else
419
- printf "${GREEN}✓ Passed${NC}\n"
420
- fi
421
-
422
683
  local format_label="Oxfmt Check"
423
- [ "$FIX_MODE" = true ] && format_label="Oxfmt Format"
424
- printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} ${format_label}\n\n"
425
- if [ $format_status -ne 0 ]; then
426
- [ -s "$tmp_dir/format.log" ] && cat "$tmp_dir/format.log"
427
- printf "${RED}✗ Failed with exit code %d${NC}\n" $format_status
428
- else
429
- printf "${GREEN}✓ Passed${NC}\n"
430
- fi
431
-
432
- # The one pass that speaks on success: a rewrite changed a file the operator
433
- # owns, and silence would hide it. In check mode a green gate writes nothing,
434
- # so the green output stays byte-identical with the others.
435
- if [ -n "$gitignore_pid" ]; then
436
- printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Gitignore (artefacts)\n\n"
437
- [ -s "$tmp_dir/gitignore.log" ] && cat "$tmp_dir/gitignore.log"
438
- if [ $gitignore_status -ne 0 ]; then
439
- printf "${RED}✗ Failed with exit code %d${NC}\n" $gitignore_status
440
- else
441
- printf "${GREEN}✓ Passed${NC}\n"
442
- fi
684
+ local write=""
685
+ if [ "$FIX_MODE" = true ]; then
686
+ lint_label="Oxlint Fix"
687
+ format_label="Oxfmt Format"
688
+ write="writer"
443
689
  fi
444
690
 
445
- if [ "$FIX_MODE" = false ]; then
446
- printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Knip (unused code)\n\n"
447
- if [ $knip_status -ne 0 ]; then
448
- [ -s "$tmp_dir/knip.log" ] && cat "$tmp_dir/knip.log"
449
- printf "${RED}✗ Failed with exit code %d${NC}\n" $knip_status
450
- else
451
- printf "${GREEN}✓ Passed${NC}\n"
452
- fi
453
-
454
- if [ ${#checker_pids[@]} -gt 0 ]; then
455
- printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Test Conventions (@jterrazz/test)\n\n"
456
- if [ $checker_status -ne 0 ]; then
457
- for log in "${checker_failed_logs[@]}"; do
458
- [ -s "$log" ] && cat "$log"
459
- done
460
- printf "${RED}✗ Failed with exit code %d${NC}\n" $checker_status
461
- else
462
- printf "${GREEN}✓ Passed${NC}\n"
463
- fi
464
- fi
465
-
466
- if [ -n "$docs_layout_pid" ]; then
467
- printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Docs (layout)\n\n"
468
- if [ $docs_layout_status -ne 0 ]; then
469
- [ -s "$tmp_dir/docs-layout.log" ] && cat "$tmp_dir/docs-layout.log"
470
- printf "${RED}✗ Failed with exit code %d${NC}\n" $docs_layout_status
471
- else
472
- printf "${GREEN}✓ Passed${NC}\n"
473
- fi
474
- fi
475
-
476
- if [ ${#docs_pids[@]} -gt 0 ]; then
477
- printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Docs (sync)\n\n"
478
- if [ $docs_status -ne 0 ]; then
479
- for log in "${docs_failed_logs[@]}"; do
480
- [ -s "$log" ] && cat "$log"
481
- done
482
- printf "${RED}✗ Failed with exit code %d${NC}\n" $docs_status
483
- else
484
- printf "${GREEN}✓ Passed${NC}\n"
485
- fi
486
- fi
691
+ judge_lint_config
692
+ report_pass "TypeScript Check" $type_status "$tmp_dir/type.log"
693
+ report_pass "$lint_label" $lint_status "$tmp_dir/lint.log"
694
+ report_pass "$format_label" $format_status "$tmp_dir/format.log"
695
+ [ -n "$gitignore_pid" ] &&
696
+ report_pass "Gitignore (artefacts)" $gitignore_status "$tmp_dir/gitignore.log" writer
697
+ [ "$FIX_MODE" = false ] &&
698
+ report_pass "Knip (unused code)" $knip_status "$tmp_dir/knip.log"
699
+ [ ${#checker_pids[@]} -gt 0 ] &&
700
+ report_pass "Test Conventions (@jterrazz/test)" $checker_status "$tmp_dir/checker.log"
701
+ [ -n "$docs_layout_pid" ] &&
702
+ report_pass "Docs (layout)" $docs_layout_status "$tmp_dir/docs-layout.log"
703
+ [ ${#docs_pids[@]} -gt 0 ] &&
704
+ report_pass "Docs (sync)" $docs_status "$tmp_dir/docs.log"
705
+ [ ${#publish_pids[@]} -gt 0 ] &&
706
+ report_pass "Publish (packaging)" $publish_status "$tmp_dir/publish.log"
707
+ [ -n "$architecture_pid" ] &&
708
+ report_pass "Architecture (layer map)" $architecture_status "$tmp_dir/architecture.log"
709
+ [ -n "$astro_pid" ] &&
710
+ report_pass "Astro (check + format)" $astro_status "$tmp_dir/astro.log" "$write"
711
+ report_pass "Suppressions (directives)" $suppressions_status "$tmp_dir/suppressions.log" "$write"
712
+ [ -n "$markdown_pid" ] &&
713
+ report_pass "Markdown (prose)" $markdown_status "$tmp_dir/markdown.log"
714
+ [ -n "$names_pid" ] && report_pass "Names (tree)" $names_status "$tmp_dir/names.log"
715
+ [ -n "$secrets_pid" ] && report_pass "Secrets (credentials)" $secrets_status "$tmp_dir/secrets.log"
716
+
717
+ # Drift: the report, not a gate — how far this project stands from the
718
+ # profile it says it extends, in four numbers. It runs last and it speaks on
719
+ # every check, because the alternative is what the estate had: every
720
+ # repository quietly a little further from the shared rulebook, and nobody
721
+ # able to say by how much without opening every config. Only a rule turned
722
+ # off with no reason beside it actually fails the run.
723
+ local drift_status=0
724
+ if [ "$FIX_MODE" = false ] && [ -n "$OXLINT_CONFIG" ]; then
725
+ printf "\n${CYAN_BG}${BRIGHT_WHITE} DRIFT ${NC} Deviations from the profile\n\n"
726
+ node "$PACKAGE_ROOT/lib/check-drift.js" . --oxlint "$OXLINT" "${LINT_ARGS[@]}"
727
+ drift_status=$?
487
728
  fi
488
729
 
489
730
  # Summary
@@ -493,7 +734,7 @@ run_checks() {
493
734
  printf "\n${CYAN_BG}${BRIGHT_WHITE} END ${NC} Finalizing quality checks\n\n"
494
735
  fi
495
736
 
496
- if [ $type_status -eq 0 ] && [ $lint_status -eq 0 ] && [ $format_status -eq 0 ] && [ $knip_status -eq 0 ] && [ $gitignore_status -eq 0 ] && [ $checker_status -eq 0 ] && [ $docs_layout_status -eq 0 ] && [ $docs_status -eq 0 ]; then
737
+ if [ $type_status -eq 0 ] && [ $lint_status -eq 0 ] && [ $format_status -eq 0 ] && [ $knip_status -eq 0 ] && [ $gitignore_status -eq 0 ] && [ $checker_status -eq 0 ] && [ $docs_layout_status -eq 0 ] && [ $docs_status -eq 0 ] && [ $markdown_status -eq 0 ] && [ $names_status -eq 0 ] && [ $secrets_status -eq 0 ] && [ $suppressions_status -eq 0 ] && [ $publish_status -eq 0 ] && [ $architecture_status -eq 0 ] && [ $astro_status -eq 0 ] && [ $drift_status -eq 0 ]; then
497
738
  printf "${GREEN}✓ All checks passed${NC}\n"
498
739
  exit 0
499
740
  else