@codyswann/lisa 4.45.1 → 4.45.3

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 (70) hide show
  1. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +49 -0
  2. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +115 -12
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +11 -6
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/opencode/plugin-templates/parity-safety-net.sh +115 -12
  11. package/package.json +4 -4
  12. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa/hooks/parity-safety-net.sh +115 -12
  15. package/plugins/lisa-agy/hooks/parity-safety-net.sh +115 -12
  16. package/plugins/lisa-agy/plugin.json +1 -1
  17. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  20. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +115 -12
  24. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +115 -12
  26. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo-agy/plugin.json +1 -1
  29. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  49. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails-agy/plugin.json +1 -1
  54. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  59. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  64. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/src/base/hooks/parity-safety-net.sh +115 -12
  67. package/scripts/check-typecheck-tests.mjs +229 -72
  68. package/scripts/lib/dependency-tree.mjs +136 -0
  69. package/scripts/lisa-enforcement-fallback.sh +49 -0
  70. package/typescript/copy-contents/.husky/pre-push +45 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.45.1",
3
+ "version": "4.45.3",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -405,7 +405,12 @@ readonly FOLLOW_MAX_TOTAL_BYTES=524288
405
405
  readonly FOLLOW_MAX_FILES=8
406
406
  readonly FOLLOW_MAX_DEPTH=3
407
407
 
408
- follow_bases=""
408
+ # Effective working directory established by a literal `cd` in the command, and
409
+ # the flag that says a `cd` happened whose target could NOT be read literally.
410
+ # Empty + 0 means no `cd` has been seen and the session's own cwd governs.
411
+ follow_cwd=""
412
+ follow_cwd_unknown=0
413
+ follow_normalized=""
409
414
  follow_paths=$'\n'
410
415
  follow_names=""
411
416
  follow_count=0
@@ -420,6 +425,10 @@ see is the thing it exists to stop.
420
425
 
421
426
  - Name the script by a LITERAL path that exists and is readable — \`bash
422
427
  ./scripts/x.sh\` rather than \`bash \"\$SCRIPT\"\` — and the guard can classify it.
428
+ - A leading \`cd\` the guard cannot read literally — \`cd \"\$DIR\" && bash x.sh\` —
429
+ leaves it unable to say WHICH copy of \`x.sh\` would run, and a same-named file
430
+ in another directory is a different file. Spell the directory out, or give the
431
+ script its full path.
423
432
  - A file past the inspection cap is refused rather than half-scanned, because a
424
433
  truncated scan reports a confident ALLOW about text it never read. Split it,
425
434
  or run it manually outside the agent.
@@ -550,15 +559,77 @@ follow_split() {
550
559
  '
551
560
  }
552
561
 
553
- # Record a literal `cd` target as an additional base for resolving a relative
554
- # executed path, so `cd sub && ./run.sh` resolves the way the shell will.
555
- follow_add_base() {
556
- local d="$1"
562
+ # Collapse `.`, `..` and empty segments so the path a verdict NAMES is the one a
563
+ # reader recognises. Purely lexical, and it cannot move a verdict: every
564
+ # component has already been proven to exist by the `-d` test that follows, and
565
+ # the `-f`/`-r` tests are applied to the collapsed path either way.
566
+ follow_normalize() {
567
+ local p="$1" out="" seg
568
+ local IFS=/
569
+ set -f
570
+ # The word split is the point: `$p` is being addressed one path segment at a
571
+ # time, and the glob guard above keeps a literal `*` from expanding.
572
+ # shellcheck disable=SC2086
573
+ for seg in $p; do
574
+ case "$seg" in
575
+ "" | .) ;;
576
+ ..) out="${out%/*}" ;;
577
+ *) out="$out/$seg" ;;
578
+ esac
579
+ done
580
+ set +f
581
+ follow_normalized="${out:-/}"
582
+ }
583
+
584
+ # Track the working directory a literal `cd` establishes, so a relative executed
585
+ # path resolves against the directory the command will actually have.
586
+ #
587
+ # This REPLACES the session's own cwd rather than adding to it. It used to be an
588
+ # extra base appended AFTER `$PWD`, so `$PWD` always won and
589
+ # `cd <other-checkout> && bash scripts/x.sh` was classified against the session
590
+ # checkout's same-named copy. That is not a resolution failure — it succeeds,
591
+ # against a real and readable file, and the verdict is reported with full
592
+ # confidence about text nobody was about to run (CodySwannGT/lisa#3933). Two
593
+ # checkouts of one repository differ by exactly the work in flight, which is the
594
+ # entire point of the second one, so the copy that would RUN is the only copy
595
+ # whose verdict means anything. Wrong in both directions, and the expensive
596
+ # direction is silent: the dangerous line sits in the copy that would run and
597
+ # the guard reads the pristine one.
598
+ #
599
+ # A target this hook cannot turn into a literal directory marks the cwd UNKNOWN
600
+ # instead of leaving a stale one in place. `follow_locate` then refuses a
601
+ # relative token rather than guessing, which is the honest-refusal doctrine the
602
+ # rest of this block already follows: a confident scan of the wrong file is
603
+ # strictly worse than saying it could not tell.
604
+ follow_cd() {
605
+ local d="$1" home="${HOME:-}"
606
+ # The tilde arms are LITERAL text — the unexpanded spelling the hook was
607
+ # handed — for the reason follow_locate's arms document.
608
+ # shellcheck disable=SC2088
609
+ case "$d" in
610
+ '~') d="$home" ;;
611
+ '~/'*) d="$home/${d#'~/'}" ;;
612
+ esac
613
+ case "$d" in
614
+ "" | -* | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*)
615
+ follow_cwd=""
616
+ follow_cwd_unknown=1
617
+ return 0
618
+ ;;
619
+ esac
557
620
  case "$d" in
558
- "" | -* | *'$'* | *'*'* | *'?'*) return 0 ;;
621
+ /*) ;;
622
+ *) d="${follow_cwd:-$PWD}/$d" ;;
559
623
  esac
560
- [ -d "$d" ] || return 0
561
- follow_bases="$follow_bases"$'\n'"$d"
624
+ follow_normalize "$d"
625
+ d="$follow_normalized"
626
+ if [ -d "$d" ]; then
627
+ follow_cwd="$d"
628
+ follow_cwd_unknown=0
629
+ else
630
+ follow_cwd=""
631
+ follow_cwd_unknown=1
632
+ fi
562
633
  }
563
634
 
564
635
  # Resolve one executed-file operand to a readable regular file, or fail.
@@ -571,6 +642,7 @@ follow_add_base() {
571
642
  # this hook cannot evaluate.
572
643
  follow_locate() {
573
644
  local token="$1" home="${HOME:-}" tmp="${TMPDIR:-/tmp}" proj="${CLAUDE_PROJECT_DIR:-$PWD}" base
645
+ local cwd="${follow_cwd:-$PWD}"
574
646
  follow_located=""
575
647
  tmp="${tmp%/}"
576
648
  [ -n "$tmp" ] || tmp="/tmp"
@@ -584,8 +656,14 @@ follow_locate() {
584
656
  '${TMPDIR}' | '${TMPDIR}'/*) token="$tmp${token#'${TMPDIR}'}" ;;
585
657
  '$HOME' | '$HOME'/*) token="$home${token#'$HOME'}" ;;
586
658
  '${HOME}' | '${HOME}'/*) token="$home${token#'${HOME}'}" ;;
587
- '$PWD' | '$PWD'/*) token="$PWD${token#'$PWD'}" ;;
588
- '${PWD}' | '${PWD}'/*) token="$PWD${token#'${PWD}'}" ;;
659
+ '$PWD' | '$PWD'/*)
660
+ [ "$follow_cwd_unknown" -eq 0 ] || return 1
661
+ token="$cwd${token#'$PWD'}"
662
+ ;;
663
+ '${PWD}' | '${PWD}'/*)
664
+ [ "$follow_cwd_unknown" -eq 0 ] || return 1
665
+ token="$cwd${token#'${PWD}'}"
666
+ ;;
589
667
  '$CLAUDE_PROJECT_DIR' | '$CLAUDE_PROJECT_DIR'/*) token="$proj${token#'$CLAUDE_PROJECT_DIR'}" ;;
590
668
  '${CLAUDE_PROJECT_DIR}' | '${CLAUDE_PROJECT_DIR}'/*) token="$proj${token#'${CLAUDE_PROJECT_DIR}'}" ;;
591
669
  esac
@@ -597,13 +675,26 @@ follow_locate() {
597
675
  return 0
598
676
  ;;
599
677
  esac
678
+ # Past this point the token is RELATIVE, so which directory it is resolved
679
+ # from decides which file the verdict is about.
680
+ if [ "$follow_cwd_unknown" -eq 1 ]; then
681
+ return 1
682
+ fi
683
+ if [ -n "$follow_cwd" ]; then
684
+ # A literal `cd` names the directory the shell will be in. It is the only
685
+ # candidate — falling back to `$PWD` here is what made the guard report
686
+ # confidently about a same-named file in another checkout.
687
+ [ -f "$follow_cwd/$token" ] && [ -r "$follow_cwd/$token" ] || return 1
688
+ follow_located="$follow_cwd/$token"
689
+ return 0
690
+ fi
600
691
  while IFS= read -r base; do
601
692
  [ -n "$base" ] || continue
602
693
  if [ -f "$base/$token" ] && [ -r "$base/$token" ]; then
603
694
  follow_located="$base/$token"
604
695
  return 0
605
696
  fi
606
- done <<<"$PWD"$'\n'"$proj$follow_bases"
697
+ done <<<"$PWD"$'\n'"$proj"
607
698
  return 1
608
699
  }
609
700
 
@@ -636,7 +727,15 @@ follow_take() {
636
727
  follow_names="$follow_names $path"
637
728
  followed_text="$followed_text"$'\n'"$content"
638
729
  if [ "$depth" -lt "$FOLLOW_MAX_DEPTH" ]; then
730
+ # A `cd` inside the followed script governs what THAT script goes on to
731
+ # execute, and nothing after it in the command that ran the script — the
732
+ # child inherits this directory but cannot change the parent's. Saving and
733
+ # restoring is what keeps a nested `cd` from silently re-pointing the rest
734
+ # of the outer walk.
735
+ local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
639
736
  follow_scan "$content" "$((depth + 1))"
737
+ follow_cwd="$saved_cwd"
738
+ follow_cwd_unknown="$saved_unknown"
640
739
  fi
641
740
  }
642
741
 
@@ -861,7 +960,11 @@ follow_scan() {
861
960
  cd)
862
961
  if [ $((i + 1)) -lt "$n" ]; then
863
962
  follow_unwrap "${toks[i + 1]}"
864
- follow_add_base "$follow_unwrapped"
963
+ follow_cd "$follow_unwrapped"
964
+ else
965
+ # A bare `cd` is `cd "$HOME"`, and leaving the previous directory in
966
+ # place would resolve later tokens from a directory the shell left.
967
+ follow_cd "${HOME:-}"
865
968
  fi
866
969
  cmd_pos=0
867
970
  i=$((i + 1))
@@ -27,6 +27,33 @@
27
27
  * threshold-ratchet as an `allow-list` family, so *adding* an entry is flagged
28
28
  * by a gate somebody already reads.
29
29
  *
30
+ * IT REFUSES TO ANSWER FROM AN UNRESOLVABLE TREE (CodySwannGT/lisa#3913). The
31
+ * second of those ratchet directions is the destructive one: it names files and
32
+ * tells a reader to delete their quarantine entries. With an empty
33
+ * `node_modules` the compiler cannot start, nothing is compiled, no diagnostic
34
+ * is parsed — and "no diagnostic for this file" is exactly the shape of "this
35
+ * file was fixed". The gate reported all 370 quarantined files as cleared and
36
+ * instructed their removal, which is a destructive edit ordered on the strength
37
+ * of a comparison whose inputs never resolved.
38
+ *
39
+ * So there are two ways this refuses instead of reporting, and the second is
40
+ * the general one:
41
+ *
42
+ * - the compiler is not present at the path this spawns it from; and
43
+ * - the compiler signalled failure and produced NOT ONE parseable
44
+ * diagnostic, which also covers a broken tsconfig, an OOM and a crash.
45
+ *
46
+ * Both exit non-zero with the denial first and NO file list, because a gate
47
+ * whose only surface is an exit code cannot invent a third one — see
48
+ * `scripts/lib/dependency-tree.mjs` for why the wording is a contract rather
49
+ * than a string.
50
+ *
51
+ * `--root` EXISTS SO THIS CAN BE POINTED AT A FIXTURE. Deriving the root solely
52
+ * from `import.meta.url` made the failure above reproducible only by copying
53
+ * this file somewhere else, and `check-empty-subject-guards.mjs` reads that
54
+ * token out of this header to decide the gate is probeable at all — without it
55
+ * this sits in that sweep's declared blind spot.
56
+ *
30
57
  * WHAT IS DELIBERATELY NOT DONE HERE: the declared `lib` is not widened. tsc
31
58
  * emits "Try changing the 'lib' compiler option to 'es2023' or later" on the
32
59
  * ES2023 array APIs, and taking that suggestion would have erased the
@@ -38,110 +65,240 @@
38
65
  import { spawnSync } from "node:child_process";
39
66
  import { readFileSync } from "node:fs";
40
67
  import * as path from "node:path";
68
+ import process from "node:process";
41
69
  import { fileURLToPath } from "node:url";
42
70
 
43
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
44
- const QUARANTINE = path.join(ROOT, "typecheck-quarantine.json");
45
- const PROJECT = path.join(ROOT, "tsconfig.tests.json");
71
+ import {
72
+ cannotMeasure,
73
+ INSTALL_COMMAND,
74
+ missingDependencies,
75
+ ranVacuously,
76
+ } from "./lib/dependency-tree.mjs";
77
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
78
+
79
+ const DEFAULT_ROOT = path.resolve(
80
+ path.dirname(fileURLToPath(import.meta.url)),
81
+ ".."
82
+ );
83
+
84
+ /** The gate's name, as it prints in both the report and the refusal. */
85
+ const GATE = "type-correctness (tests)";
86
+
87
+ /** Where the compiler is spawned from, relative to the root. */
88
+ const TSC = path.join("node_modules", "typescript", "bin", "tsc");
46
89
 
47
90
  /** Matches `path/to/file.ts(12,34): error TS1234: message`. */
48
91
  const ERROR_LINE = /^(?<file>[^(]+)\((?<line>\d+),\d+\): error (?<code>TS\d+):/;
49
92
 
50
93
  /**
51
94
  * Read the quarantined paths.
52
- * @returns {{files: string[], reason: string}} The parsed quarantine.
95
+ * @param {string} root - The directory holding the quarantine.
96
+ * @returns {{files: string[], reason: string} | null} The parsed quarantine, or
97
+ * `null` when it could not be read — an unreadable list is a refusal, not a
98
+ * pass, because that file is what makes this gate green.
53
99
  */
54
- function readQuarantine() {
100
+ function readQuarantine(root) {
101
+ const quarantine = path.join(root, "typecheck-quarantine.json");
55
102
  try {
56
- const parsed = JSON.parse(readFileSync(QUARANTINE, "utf-8"));
103
+ const parsed = JSON.parse(readFileSync(quarantine, "utf-8"));
57
104
  return { files: parsed.files ?? [], reason: parsed.reason ?? "" };
58
105
  } catch (error) {
59
106
  console.error(
60
- `check-typecheck-tests: cannot read ${path.relative(ROOT, QUARANTINE)} — ${error.message}`
107
+ `check-typecheck-tests: cannot read ${path.relative(root, quarantine)} — ${error.message}`
61
108
  );
62
109
  console.error(
63
110
  "That file is what makes this gate green, so an unreadable one is a refusal, not a pass."
64
111
  );
65
- process.exit(1);
112
+ return null;
66
113
  }
67
114
  }
68
115
 
69
- const { files: quarantined } = readQuarantine();
70
- const exempt = new Set(quarantined);
71
-
72
- const tsc = spawnSync(
73
- process.execPath,
74
- [
75
- path.join(ROOT, "node_modules", "typescript", "bin", "tsc"),
76
- "--noEmit",
77
- "-p",
78
- PROJECT,
79
- ],
80
- { cwd: ROOT, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }
81
- );
82
-
83
- if (tsc.error) {
84
- console.error(
85
- `check-typecheck-tests: could not run tsc — ${tsc.error.message}`
86
- );
87
- process.exit(1);
116
+ /**
117
+ * Parse tsc's output into a per-file error count.
118
+ * @param {string} output - Combined stdout and stderr.
119
+ * @returns {Map<string, number>} Errors keyed by forward-slashed path.
120
+ */
121
+ function parseDiagnostics(output) {
122
+ /** @type {Map<string, number>} */
123
+ const errorsByFile = new Map();
124
+ for (const line of output.split("\n")) {
125
+ const match = ERROR_LINE.exec(line.trim());
126
+ if (!match?.groups) continue;
127
+ const file = match.groups.file.replace(/\\/g, "/");
128
+ errorsByFile.set(file, (errorsByFile.get(file) ?? 0) + 1);
129
+ }
130
+ return errorsByFile;
88
131
  }
89
132
 
90
- const lines = `${tsc.stdout ?? ""}\n${tsc.stderr ?? ""}`.split("\n");
91
- /** @type {Map<string, number>} */
92
- const errorsByFile = new Map();
93
- for (const line of lines) {
94
- const match = ERROR_LINE.exec(line.trim());
95
- if (!match?.groups) continue;
96
- const file = match.groups.file.replace(/\\/g, "/");
97
- errorsByFile.set(file, (errorsByFile.get(file) ?? 0) + 1);
133
+ /**
134
+ * The refusal issued when nothing was compiled.
135
+ *
136
+ * The denial names the destructive remedy explicitly. "Nothing type-checked" is
137
+ * the inference a reader draws from an empty diagnostic set, and the gate's own
138
+ * cleared-entry message is what turns that inference into a deletion — so this
139
+ * has to deny both, not just the first.
140
+ *
141
+ * NO FILE IS NAMED HERE, deliberately. A list of paths is what makes the
142
+ * destructive message actionable, and a refusal that still printed 370 paths
143
+ * would be followed exactly as often as the finding it replaces.
144
+ *
145
+ * @param {string} because - Why the measurement could not happen.
146
+ * @param {number} quarantined - How many entries went unexamined.
147
+ * @param {string} fix - The action that repairs THIS cause. Named per call
148
+ * site rather than fixed, because telling someone to install dependencies
149
+ * they already have is how a refusal gets dismissed as noise.
150
+ * @returns {string} The refusal block.
151
+ */
152
+ function refuseUnmeasurable(because, quarantined, fix) {
153
+ return cannotMeasure({
154
+ gate: GATE,
155
+ denial: `that any of the ${quarantined} quarantined file(s) now type-check`,
156
+ because,
157
+ remedy:
158
+ `${fix} Do NOT edit typecheck-quarantine.json on the strength of ` +
159
+ `this — no file was compiled, so nothing was established about any ` +
160
+ `entry in it.`,
161
+ });
98
162
  }
99
163
 
100
- const offenders = [...errorsByFile.keys()]
101
- .filter(file => !exempt.has(file))
102
- .sort();
103
- const cleared = quarantined.filter(file => !errorsByFile.has(file)).sort();
164
+ /**
165
+ * Run the gate.
166
+ * @param {string} root - Directory to resolve the project and quarantine under.
167
+ * @returns {number} The process exit code.
168
+ */
169
+ export function main(root) {
170
+ const quarantine = readQuarantine(root);
171
+ if (quarantine === null) return 1;
172
+ const quarantined = quarantine.files;
173
+ const exempt = new Set(quarantined);
104
174
 
105
- const totalErrors = [...errorsByFile.values()].reduce((sum, n) => sum + n, 0);
106
- console.log(
107
- `type-correctness (tests): ${errorsByFile.size} file(s) with ${totalErrors} error(s); ` +
108
- `${quarantined.length} quarantined.`
109
- );
175
+ // PRECONDITION. Checked before the spawn rather than inferred from it: the
176
+ // spawn SUCCEEDS with an empty `node_modules` — node starts, and it is node
177
+ // that cannot find the module — so `tsc.error` never fires and the failure
178
+ // arrives as an empty diagnostic set indistinguishable from a clean run.
179
+ const missing = missingDependencies(root, [TSC]);
180
+ if (missing.length > 0) {
181
+ console.error(
182
+ refuseUnmeasurable(
183
+ `the TypeScript compiler is not installed — no file at ${missing[0]}.`,
184
+ quarantined.length,
185
+ `Run \`${INSTALL_COMMAND}\`, then re-run this gate.`
186
+ )
187
+ );
188
+ return 1;
189
+ }
110
190
 
111
- if (offenders.length > 0) {
112
- console.error("");
113
- console.error(
114
- `❌ ${offenders.length} file(s) outside the quarantine have type errors:`
191
+ const tsc = spawnSync(
192
+ process.execPath,
193
+ [
194
+ path.join(root, TSC),
195
+ "--noEmit",
196
+ "-p",
197
+ path.join(root, "tsconfig.tests.json"),
198
+ ],
199
+ { cwd: root, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }
115
200
  );
116
- for (const file of offenders) {
117
- console.error(` ${file} (${errorsByFile.get(file)})`);
201
+
202
+ if (tsc.error) {
203
+ console.error(
204
+ `check-typecheck-tests: could not run tsc — ${tsc.error.message}`
205
+ );
206
+ return 1;
118
207
  }
119
- console.error("");
120
- console.error(
121
- "Fix them. Do NOT add them to typecheck-quarantine.json — that list"
122
- );
123
- console.error(
124
- "only shrinks, and the threshold-ratchet flags additions to it."
125
- );
126
- }
127
208
 
128
- if (cleared.length > 0) {
129
- console.error("");
130
- console.error(
131
- `❌ ${cleared.length} quarantined file(s) now type-check and must leave the list:`
209
+ const errorsByFile = parseDiagnostics(
210
+ `${tsc.stdout ?? ""}\n${tsc.stderr ?? ""}`
132
211
  );
133
- for (const file of cleared) console.error(` ${file}`);
134
- console.error("");
135
- console.error(
136
- "Remove them from typecheck-quarantine.json. A list that keeps entries"
212
+
213
+ // POSTCONDITION, and the general form of the same defect. The compiler
214
+ // signalled failure and produced nothing this gate can read, so the
215
+ // comparison below would run against an empty set — the exact shape of "every
216
+ // quarantined file is fixed". A ZERO exit with no diagnostics is not this: it
217
+ // is a clean run, and it reports normally.
218
+ if (ranVacuously(tsc.status, errorsByFile.size)) {
219
+ const firstLine = `${tsc.stderr ?? ""}${tsc.stdout ?? ""}`
220
+ .split("\n")
221
+ .map(line => line.trim())
222
+ .find(Boolean);
223
+ console.error(
224
+ refuseUnmeasurable(
225
+ `tsc exited ${tsc.status} without emitting one diagnostic this gate ` +
226
+ `could parse, so the comparison had no subject.` +
227
+ `${firstLine ? ` First output line: ${firstLine}` : ""}`,
228
+ quarantined.length,
229
+ "Fix the cause named above, then re-run this gate."
230
+ )
231
+ );
232
+ return 1;
233
+ }
234
+
235
+ const offenders = [...errorsByFile.keys()]
236
+ .filter(file => !exempt.has(file))
237
+ .sort();
238
+ const cleared = quarantined.filter(file => !errorsByFile.has(file)).sort();
239
+
240
+ const totalErrors = [...errorsByFile.values()].reduce((sum, n) => sum + n, 0);
241
+ console.log(
242
+ `${GATE}: ${errorsByFile.size} file(s) with ${totalErrors} error(s); ` +
243
+ `${quarantined.length} quarantined.`
137
244
  );
138
- console.error(
139
- "after they are fixed would re-authorise them if they ever regressed."
245
+
246
+ if (offenders.length > 0) {
247
+ console.error("");
248
+ console.error(
249
+ `❌ ${offenders.length} file(s) outside the quarantine have type errors:`
250
+ );
251
+ for (const file of offenders) {
252
+ console.error(` ${file} (${errorsByFile.get(file)})`);
253
+ }
254
+ console.error("");
255
+ console.error(
256
+ "Fix them. Do NOT add them to typecheck-quarantine.json — that list"
257
+ );
258
+ console.error(
259
+ "only shrinks, and the threshold-ratchet flags additions to it."
260
+ );
261
+ }
262
+
263
+ if (cleared.length > 0) {
264
+ console.error("");
265
+ console.error(
266
+ `❌ ${cleared.length} quarantined file(s) now type-check and must leave the list:`
267
+ );
268
+ for (const file of cleared) console.error(` ${file}`);
269
+ console.error("");
270
+ console.error(
271
+ "Remove them from typecheck-quarantine.json. A list that keeps entries"
272
+ );
273
+ console.error(
274
+ "after they are fixed would re-authorise them if they ever regressed."
275
+ );
276
+ }
277
+
278
+ if (offenders.length > 0 || cleared.length > 0) return 1;
279
+
280
+ console.log(
281
+ "✅ No type errors outside the quarantine, and no stale quarantine entries."
140
282
  );
283
+ return 0;
141
284
  }
142
285
 
143
- if (offenders.length > 0 || cleared.length > 0) process.exit(1);
286
+ /**
287
+ * Resolve the root from `--root <dir>`, defaulting to the repository.
288
+ * @param {ReadonlyArray<string>} argv - Arguments after the script name.
289
+ * @returns {string} The absolute root.
290
+ */
291
+ export function rootFrom(argv) {
292
+ const flag = argv.indexOf("--root");
293
+ if (flag === -1) return DEFAULT_ROOT;
294
+ const value = argv[flag + 1];
295
+ if (value === undefined) {
296
+ console.error("check-typecheck-tests: --root requires a value");
297
+ process.exit(2);
298
+ }
299
+ return path.resolve(value);
300
+ }
144
301
 
145
- console.log(
146
- "✅ No type errors outside the quarantine, and no stale quarantine entries."
147
- );
302
+ if (invokedAsScript(import.meta.url)) {
303
+ process.exit(main(rootFrom(process.argv.slice(2))));
304
+ }