@jterrazz/typescript 10.1.9 → 10.2.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.
@@ -98,7 +98,15 @@ find_project_binary() {
98
98
  # and the consumer is precisely the project that no longer declares it.
99
99
  PRETTIER_ASTRO_CONFIG="$PACKAGE_ROOT/presets/prettier/astro.json"
100
100
  PRETTIER_ASTRO_PLUGIN=$(cd "$PACKAGE_ROOT" && node -e 'process.stdout.write(require.resolve("prettier-plugin-astro"))' 2>/dev/null)
101
- CHECKER=$(find_binary jterrazz-test-check)
101
+ # The conventions checker is the CONSUMER's binary, like astro's: this package
102
+ # runs it, it does not own it, and the version a project installed is the
103
+ # rulebook it agreed to. So the lookup starts at the project and falls back
104
+ # here only for a project that has no install of its own.
105
+ CHECKER=$(find_project_binary jterrazz-test-check)
106
+
107
+ # The release of @jterrazz/test that answers `--member` and `--format json`.
108
+ # The number is the module's, not this script's: two commands gate on it.
109
+ CHECKER_FLOOR=$(node "$PACKAGE_ROOT/lib/test-package.js" --floor)
102
110
 
103
111
  # ── The unit is the workspace package, not the repository ────────────────────
104
112
  # Every gate measures from the NEAREST package.json. A single-package project
@@ -130,12 +138,50 @@ nearest_package_dir() {
130
138
  done
131
139
  }
132
140
 
133
- # The @jterrazz/test conventions checker (D4 tokens, C8/C9 fixtures) runs only when the
134
- # owning package depends on @jterrazz/test auto-detected from its package.json.
141
+ # The @jterrazz/test conventions checker (D4 tokens, C8/C9 fixtures) runs where
142
+ # the package is a consumer, and a package is one on either evidence: its own
143
+ # manifest names @jterrazz/test, or it RESOLVES one from an ancestor — which is
144
+ # what npm's hoisting makes of a monorepo, where a workspace declares the
145
+ # dependency once at the root and every member loads it.
135
146
  project_uses_jterrazz_test() {
136
147
  local dir="${1:-.}"
137
148
  [ -f "$dir/package.json" ] || return 1
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
149
+ 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 && return 0
150
+ node "$PACKAGE_ROOT/lib/test-package.js" "$dir" > /dev/null 2>&1
151
+ }
152
+
153
+ # A member the checker can be run FOR at all. A member that resolves an older
154
+ # release is counted, so the pass can say once that it is dormant rather than
155
+ # pretend it ran.
156
+ member_resolves_checker() {
157
+ node "$PACKAGE_ROOT/lib/test-package.js" "${1:-.}" > /dev/null 2>&1
158
+ }
159
+
160
+ # The checker a directory RUNS, into CHECKER_COMMAND. Version and binary come
161
+ # from the one install the directory resolves: a member whose @jterrazz/test is
162
+ # nested rather than hoisted owns a different release from the root's, and a
163
+ # published consumer has no `node_modules/.bin` of this package's to fall back
164
+ # on. Returns 1 when the directory resolves nothing that answers the flags.
165
+ member_checker() {
166
+ local dir="${1:-.}" bin
167
+ node "$PACKAGE_ROOT/lib/test-package.js" "$dir" --at-least "$CHECKER_FLOOR" \
168
+ > /dev/null 2>&1 || return 1
169
+ bin=$(node "$PACKAGE_ROOT/lib/test-package.js" "$dir" --bin 2>/dev/null) || return 1
170
+ [ -n "$bin" ] || return 1
171
+ CHECKER_COMMAND=(node "$bin")
172
+ }
173
+
174
+ # The same, for the tree pass, which every release answers: no floor, and the
175
+ # toolchain's own lookup for a project that declares @jterrazz/test without an
176
+ # install this walk can reach.
177
+ tree_checker() {
178
+ local bin
179
+ bin=$(node "$PACKAGE_ROOT/lib/test-package.js" "${1:-.}" --bin 2>/dev/null)
180
+ if [ -n "$bin" ]; then
181
+ CHECKER_COMMAND=(node "$bin")
182
+ else
183
+ CHECKER_COMMAND=("$CHECKER")
184
+ fi
139
185
  }
140
186
 
141
187
  # An Astro project, read off its manifest. `.astro` is the one file shape oxfmt
@@ -569,12 +615,16 @@ run_checks() {
569
615
  done
570
616
  fi
571
617
 
572
- # Conventions checker: only in check mode, once per specs root the workspace
573
- # owns, gated by the package that OWNS that root — a member may depend on
574
- # @jterrazz/test while the root does not, and the reverse.
618
+ # Conventions checker: only in check mode, and in two shapes. One run per
619
+ # specs root the workspace owns, gated by the package that OWNS that root —
620
+ # a member may use @jterrazz/test while the root does not, and the reverse.
621
+ # And one run per workspace member that RESOLVES @jterrazz/test, `--member`,
622
+ # which is the half that reaches a package owning no `specs/` at all.
575
623
  local checker_pids=()
576
624
  local checker_logs=()
577
625
  local checker_status=0
626
+ local checker_dormant=0
627
+ local checker_ratcheted=false
578
628
  if [ "$FIX_MODE" = false ]; then
579
629
  local checker_index=0
580
630
  while IFS= read -r specs_root; do
@@ -582,11 +632,32 @@ run_checks() {
582
632
  local owner
583
633
  owner=$(nearest_package_dir "$(dirname "$specs_root")") || continue
584
634
  project_uses_jterrazz_test "$owner" || continue
585
- "$CHECKER" "$specs_root" > "$tmp_dir/checker-$checker_index.log" 2>&1 &
635
+ tree_checker "$owner"
636
+ "${CHECKER_COMMAND[@]}" "$specs_root" > "$tmp_dir/checker-$checker_index.log" 2>&1 &
586
637
  checker_pids+=($!)
587
638
  checker_logs+=("$tmp_dir/checker-$checker_index.log")
588
639
  checker_index=$((checker_index + 1))
589
640
  done < <(discover_specs_roots)
641
+
642
+ local member
643
+ for member in "." "${WORKSPACE_MEMBERS[@]}"; do
644
+ member_resolves_checker "$member" || continue
645
+ if ! member_checker "$member"; then
646
+ checker_dormant=$((checker_dormant + 1))
647
+ printf 'the member pass needs @jterrazz/test %s; %s resolves %s\n' \
648
+ "$CHECKER_FLOOR" \
649
+ "$([ "$member" = "." ] && printf 'this project' || printf '%s' "$member")" \
650
+ "$(node "$PACKAGE_ROOT/lib/test-package.js" "$member" 2>/dev/null \
651
+ || printf 'none this walk reaches')" \
652
+ >> "$tmp_dir/checker-dormant.log"
653
+ continue
654
+ fi
655
+ "${CHECKER_COMMAND[@]}" --member "$member" \
656
+ > "$tmp_dir/checker-$checker_index.log" 2>&1 &
657
+ checker_pids+=($!)
658
+ checker_logs+=("$tmp_dir/checker-$checker_index.log")
659
+ checker_index=$((checker_index + 1))
660
+ done
590
661
  fi
591
662
 
592
663
  # Docs (sync): only in check mode, and only for a package that has generated
@@ -629,8 +700,19 @@ run_checks() {
629
700
  "$OXLINT" --type-aware --format json "${LINT_ARGS[@]}" \
630
701
  > "$tmp_dir/lint.json" 2>/dev/null
631
702
  fi
703
+ # The other reporter on the same tree. Where the installed
704
+ # @jterrazz/test answers `--format json`, its findings are counted
705
+ # under their own namespace in the same file — one ratchet, two
706
+ # rulebooks, and a rule at zero refused on either side.
707
+ local baseline_checker=()
708
+ if member_checker .; then
709
+ "${CHECKER_COMMAND[@]}" --format json > "$tmp_dir/checker.json" 2>/dev/null || true
710
+ baseline_checker=(--checker "$tmp_dir/checker.json")
711
+ [ -f "$BASELINE_FILE" ] && checker_ratcheted=true
712
+ fi
713
+
632
714
  node "$PACKAGE_ROOT/lib/check-baseline.js" "$tmp_dir/lint.json" . \
633
- >> "$tmp_dir/lint.log" 2>&1
715
+ "${baseline_checker[@]}" >> "$tmp_dir/lint.log" 2>&1
634
716
  lint_status=$?
635
717
  fi
636
718
 
@@ -657,6 +739,15 @@ run_checks() {
657
739
  index=$((index + 1))
658
740
  done
659
741
 
742
+ # The ratchet is the verdict for BOTH reporters or for neither. The
743
+ # checker's findings were counted into `oxlint.baseline.json` under their
744
+ # own namespace, so an id that has not moved is recorded debt here exactly
745
+ # as it is in the oxlint pass, and refusing it here would fail one debt
746
+ # twice. The log still prints: debt a reader cannot see is debt nobody pays.
747
+ if [ "$checker_ratcheted" = true ]; then
748
+ checker_status=0
749
+ fi
750
+
660
751
  local publish_failed_logs=()
661
752
  index=0
662
753
  for pid in "${publish_pids[@]}"; do
@@ -686,6 +777,25 @@ run_checks() {
686
777
  join_logs "$tmp_dir/docs.log" "${docs_failed_logs[@]}"
687
778
  join_logs "$tmp_dir/publish.log" "${publish_failed_logs[@]}"
688
779
 
780
+ # Two things this pass says even when it is green, so it asks for its log
781
+ # the way a writer does: which findings the ratchet is holding, and which
782
+ # member resolves a release too old to answer `--member` — a reader left
783
+ # with silence would believe every member was asked.
784
+ local checker_write=""
785
+ if [ "$checker_ratcheted" = true ] && [ -s "$tmp_dir/checker.log" ]; then
786
+ printf '%s is this pass'"'"'s verdict too — what follows is recorded debt\n\n' \
787
+ "$BASELINE_FILE" | cat - "$tmp_dir/checker.log" > "$tmp_dir/checker-reported.log"
788
+ mv "$tmp_dir/checker-reported.log" "$tmp_dir/checker.log"
789
+ fi
790
+ if [ "$checker_dormant" -gt 0 ]; then
791
+ cat "$tmp_dir/checker-dormant.log" "$tmp_dir/checker.log" \
792
+ > "$tmp_dir/checker-reported.log"
793
+ mv "$tmp_dir/checker-reported.log" "$tmp_dir/checker.log"
794
+ fi
795
+ if [ "$checker_dormant" -gt 0 ] || [ "$checker_ratcheted" = true ]; then
796
+ checker_write="writer"
797
+ fi
798
+
689
799
  local lint_label="Oxlint Check"
690
800
  local format_label="Oxfmt Check"
691
801
  local write=""
@@ -703,8 +813,9 @@ run_checks() {
703
813
  report_pass "Gitignore (artefacts)" $gitignore_status "$tmp_dir/gitignore.log" writer
704
814
  [ "$FIX_MODE" = false ] &&
705
815
  report_pass "Knip (unused code)" $knip_status "$tmp_dir/knip.log"
706
- [ ${#checker_pids[@]} -gt 0 ] &&
707
- report_pass "Test Conventions (@jterrazz/test)" $checker_status "$tmp_dir/checker.log"
816
+ { [ ${#checker_pids[@]} -gt 0 ] || [ "$checker_dormant" -gt 0 ]; } &&
817
+ report_pass "Test Conventions (@jterrazz/test)" $checker_status "$tmp_dir/checker.log" \
818
+ "$checker_write"
708
819
  [ -n "$docs_layout_pid" ] &&
709
820
  report_pass "Docs (layout)" $docs_layout_status "$tmp_dir/docs-layout.log"
710
821
  [ ${#docs_pids[@]} -gt 0 ] &&
package/bin/typescript.sh CHANGED
@@ -204,7 +204,7 @@ case "$COMMAND" in
204
204
  # cannot disagree with what node will load.
205
205
  printf "${CYAN_BG}${BRIGHT_WHITE} TYPESCRIPT ${NC} Checking the toolchain...\n\n"
206
206
 
207
- exec node "$PACKAGE_ROOT/lib/doctor.js"
207
+ exec node "$PACKAGE_ROOT/lib/doctor.js" "$PROJECT_ROOT"
208
208
  ;;
209
209
 
210
210
  baseline)
@@ -225,7 +225,28 @@ case "$COMMAND" in
225
225
  # A non-zero exit is the whole point of the recording, not a failure.
226
226
  "$OXLINT" --type-aware --format json "$@" > "$BASELINE_REPORT" 2>/dev/null || true
227
227
 
228
- node "$PACKAGE_ROOT/lib/check-baseline.js" "$BASELINE_REPORT" . --write
228
+ # The conventions checker judges the same tree from the other side, and
229
+ # a project adopting a stricter version of THAT rulebook needs the same
230
+ # ratchet. Its findings enter the one file under their own namespace,
231
+ # where the installed @jterrazz/test answers `--format json`.
232
+ # Binary and version come from the one install this project resolves —
233
+ # the same answer `check` reads, from the same module, so the file is
234
+ # recorded by the checker that will later be judged against it.
235
+ CHECKER_ARGS=()
236
+ CHECKER_FLOOR=$(node "$PACKAGE_ROOT/lib/test-package.js" --floor)
237
+ if node "$PACKAGE_ROOT/lib/test-package.js" . --at-least "$CHECKER_FLOOR" \
238
+ > /dev/null 2>&1; then
239
+ CHECKER=$(node "$PACKAGE_ROOT/lib/test-package.js" . --bin 2>/dev/null || true)
240
+ if [ -n "$CHECKER" ]; then
241
+ CHECKER_REPORT=$(mktemp)
242
+ trap 'rm -f "$BASELINE_REPORT" "$CHECKER_REPORT"' EXIT
243
+ node "$CHECKER" --format json > "$CHECKER_REPORT" 2>/dev/null || true
244
+ CHECKER_ARGS=(--checker "$CHECKER_REPORT")
245
+ fi
246
+ fi
247
+
248
+ node "$PACKAGE_ROOT/lib/check-baseline.js" "$BASELINE_REPORT" . \
249
+ "${CHECKER_ARGS[@]}" --write
229
250
 
230
251
  # The file is tracked, so it is the formatter's like every other tracked
231
252
  # file — written here, shaped by the project's own oxfmt, never both.
@@ -18,7 +18,22 @@
18
18
  * The third is what makes the file shrink. Without it a baseline records a debt
19
19
  * that was paid years ago and nothing ever says so.
20
20
  *
21
- * Usage: node check-baseline.js <oxlint-json> [root] [--write]
21
+ * One file, two reporters. `@jterrazz/test`'s conventions checker judges the
22
+ * same tree from the other side — how a spec is written rather than what the
23
+ * code does — and a project adopting a stricter version of THAT rulebook needs
24
+ * the same ratchet for the same reason. Its `--format json` diagnostics are
25
+ * merged before the counting, under keys its own `jterrazz-check(<id>)` codes
26
+ * namespace (`jterrazz-check/<id>`), so one flat file records both and neither
27
+ * reporter can be recorded twice. Severity is not read: a finding a release
28
+ * ships at `warn` is exactly the debt a ratchet exists to hold down.
29
+ *
30
+ * A run made with no checker report — the installed `@jterrazz/test` is below
31
+ * the release that answers `--format json`, or there is none — judges the
32
+ * linter's entries alone. The recorded `jterrazz-check/*` entries are set aside
33
+ * rather than read as debts that reached zero: nothing measured them, and the
34
+ * third refusal below would otherwise delete a file the next install needs.
35
+ *
36
+ * Usage: node check-baseline.js <oxlint-json> [root] [--checker <json>] [--write]
22
37
  *
23
38
  * `--write` rewrites the file from the current counts — that is `typescript
24
39
  * baseline`, a command of its own because it RECORDS rather than checks or
@@ -32,6 +47,9 @@ import { argv, exit, stdout } from 'node:process';
32
47
  /** The file, at the project root, tracked beside the config it ratchets. */
33
48
  export const BASELINE = 'oxlint.baseline.json';
34
49
 
50
+ /** The namespace the conventions checker's ids take in the file. */
51
+ const CHECKER_NAMESPACE = 'jterrazz-check/';
52
+
35
53
  /** `eslint(no-debugger)` is how oxlint's JSON spells `eslint/no-debugger`. */
36
54
  function ruleOf(code) {
37
55
  const match = /^(?<plugin>[\w-]+)\((?<rule>[^)]+)\)$/u.exec(code ?? '');
@@ -39,12 +57,28 @@ function ruleOf(code) {
39
57
  return match === null ? (code ?? 'unknown') : `${match.groups.plugin}/${match.groups.rule}`;
40
58
  }
41
59
 
42
- /** How many diagnostics each rule accounts for, in oxlint's JSON report. */
43
- export function countsOf(report) {
60
+ /**
61
+ * The diagnostics a report carries, whichever reporter wrote it. oxlint hands
62
+ * back an object with a `diagnostics` array; the conventions checker's
63
+ * `--format json` is read in both shapes it may take, so the merge does not
64
+ * depend on one of them staying fixed.
65
+ */
66
+ export function diagnosticsOf(report) {
67
+ if (Array.isArray(report)) {
68
+ return report;
69
+ }
70
+
71
+ return Array.isArray(report?.diagnostics) ? report.diagnostics : [];
72
+ }
73
+
74
+ /** How many diagnostics each rule accounts for, across every report given. */
75
+ export function countsOf(...reports) {
44
76
  const counts = {};
45
- for (const diagnostic of report.diagnostics ?? []) {
46
- const rule = ruleOf(diagnostic.code);
47
- counts[rule] = (counts[rule] ?? 0) + 1;
77
+ for (const report of reports) {
78
+ for (const diagnostic of diagnosticsOf(report)) {
79
+ const rule = ruleOf(diagnostic.code);
80
+ counts[rule] = (counts[rule] ?? 0) + 1;
81
+ }
48
82
  }
49
83
 
50
84
  return counts;
@@ -100,7 +134,11 @@ function serialise(counts) {
100
134
  /* Imported for the ratchet's reading, run for the gate — never both at once. */
101
135
  if (import.meta.main) {
102
136
  const isWrite = argv.includes('--write');
103
- const positional = argv.slice(2).filter((argument) => !argument.startsWith('--'));
137
+ const checkerIndex = argv.indexOf('--checker');
138
+ const checkerPath = checkerIndex === -1 ? undefined : argv[checkerIndex + 1];
139
+ const positional = argv
140
+ .slice(2)
141
+ .filter((argument, index) => !argument.startsWith('--') && index + 2 !== checkerIndex + 1);
104
142
  const reportPath = positional[0];
105
143
  const root = resolve(positional[1] ?? '.');
106
144
 
@@ -114,7 +152,24 @@ if (import.meta.main) {
114
152
  exit(1);
115
153
  }
116
154
 
117
- const counts = countsOf(report);
155
+ /*
156
+ * The checker's report is optional and its absence is not a failure: the
157
+ * flag is passed only where the installed @jterrazz/test answers
158
+ * `--format json`, and a project on an older one keeps an oxlint-only file.
159
+ */
160
+ let checkerReport = [];
161
+ if (checkerPath !== undefined) {
162
+ try {
163
+ checkerReport = JSON.parse(readFileSync(checkerPath, 'utf8'));
164
+ } catch {
165
+ stdout.write(
166
+ `the conventions checker wrote no JSON report at ${checkerPath} — the baseline cannot be read\n`,
167
+ );
168
+ exit(1);
169
+ }
170
+ }
171
+
172
+ const counts = countsOf(report, checkerReport);
118
173
 
119
174
  if (isWrite) {
120
175
  const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
@@ -125,7 +180,18 @@ if (import.meta.main) {
125
180
  exit(0);
126
181
  }
127
182
 
128
- const baseline = readBaseline(root);
183
+ const recorded = readBaseline(root);
184
+
185
+ /*
186
+ * Nobody measured the checker's side of the file on this run, so nobody
187
+ * gets to say its debts are paid.
188
+ */
189
+ const baseline =
190
+ recorded === null || checkerPath !== undefined
191
+ ? recorded
192
+ : Object.fromEntries(
193
+ Object.entries(recorded).filter(([rule]) => !rule.startsWith(CHECKER_NAMESPACE)),
194
+ );
129
195
 
130
196
  if (baseline === null) {
131
197
  const rules = Object.keys(counts).toSorted();
@@ -12,8 +12,9 @@
12
12
  * Two files are read, each judged by the same rules: the package's own
13
13
  * `.gitignore`, and the nearest ANCESTOR `.gitignore` above it — the workspace
14
14
  * root's, found by walking up to the nearest directory holding a lockfile or a
15
- * `workspaces` manifest. A workspace whose `lint` delegates to members runs this
16
- * gate once per member, cwd'd there; without the ancestor a root that ignores
15
+ * `workspaces` manifest, and never past the nearest repository boundary. A
16
+ * workspace whose `lint` delegates to members runs this gate once per member,
17
+ * cwd'd there; without the ancestor a root that ignores
17
18
  * `.artifacts/` for everyone would look, from a member with no `.gitignore` of
18
19
  * its own, exactly like a project declaring nothing. An ancestor pattern counts
19
20
  * only when it is NOT anchored to the ancestor's own directory (no `/` besides a
@@ -43,6 +44,8 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
43
44
  import { dirname, join, relative, resolve } from 'node:path';
44
45
  import { argv, exit, stdout } from 'node:process';
45
46
 
47
+ import { isRepositoryRoot } from './repository-root.js';
48
+
46
49
  /** The convention's own directory: the one path that MUST be ignored. */
47
50
  const ARTIFACTS = '.artifacts';
48
51
 
@@ -56,11 +59,10 @@ const PRODUCT = 'dist';
56
59
  const ARTEFACT_DIRECTORIES = new Map([
57
60
  ['.cache', '.artifacts/<tool>/'],
58
61
  ['.next', '.artifacts/next/'],
59
- ['.turbo', '.artifacts/turbo/'],
60
62
  ['.vite', '.artifacts/vite/'],
61
63
  ['bin', '.artifacts/go/'],
62
64
  ['build', '.artifacts/<tool>/'],
63
- ['coverage', '.artifacts/coverage/'],
65
+ ['coverage', '.artifacts/vitest/coverage/'],
64
66
  ['out', '.artifacts/next/'],
65
67
  ['playwright-report', '.artifacts/playwright/'],
66
68
  ['target', '.artifacts/cargo/'],
@@ -83,6 +85,10 @@ const EXCEPTIONS = new Set([
83
85
  '.expo',
84
86
  '.gradle',
85
87
  '.swiftpm',
88
+ // Turborepo writes `<package>/.turbo/turbo-<task>.log` and offers no key,
89
+ // flag or environment variable to move it; only its cache obeys `cacheDir`,
90
+ // whose home stays `.artifacts/turbo/`.
91
+ '.turbo',
86
92
  '.vercel',
87
93
  'DerivedData',
88
94
  'Package.resolved',
@@ -168,15 +174,27 @@ function isWorkspaceRoot(dir) {
168
174
  * The nearest ancestor ABOVE `root` that is a workspace root, or null. Never
169
175
  * `root` itself — a project reads its OWN `.gitignore` regardless, so only
170
176
  * what sits above it is worth a second file.
177
+ *
178
+ * The walk stops at the nearest repository boundary and never passes it
179
+ * ([repository-root.js](repository-root.js)). `root` carrying its own `.git`
180
+ * therefore has no ancestor at all: it IS the repository.
171
181
  */
172
182
  function findWorkspaceRoot(root) {
173
- let dir = dirname(resolve(root));
183
+ const start = resolve(root);
184
+ if (isRepositoryRoot(start)) {
185
+ return null;
186
+ }
187
+
188
+ let dir = dirname(start);
174
189
  let parent = dirname(dir);
175
190
 
176
191
  while (dir !== parent) {
177
192
  if (isWorkspaceRoot(dir)) {
178
193
  return dir;
179
194
  }
195
+ if (isRepositoryRoot(dir)) {
196
+ return null;
197
+ }
180
198
  dir = parent;
181
199
  parent = dirname(dir);
182
200
  }
package/lib/doctor.js CHANGED
@@ -9,20 +9,31 @@
9
9
  * own or not at all. The declared ranges are this package's `package.json`, so
10
10
  * the two halves of every row come from the two places that can disagree.
11
11
  *
12
- * Usage: node doctor.js
12
+ * Usage: node doctor.js [project root]
13
13
  *
14
14
  * One row per tool: what is installed, what is declared, and the verdict. Older
15
15
  * than the range FAILS — a gate running an older linter is enforcing an older
16
16
  * rulebook without saying so. Newer WARNS: a tool ahead of its range may be
17
17
  * fine, and the toolchain is not the thing that gets to decide that.
18
18
  *
19
- * Exit code: 0 when nothing is old or absent, 1 otherwise.
19
+ * Then one section the PROJECT owns rather than the toolchain: a pair of
20
+ * packages that must be pinned to the same version, read off the project's
21
+ * lockfile. Vitest's browser provider is published from vitest's own
22
+ * repository and versioned with it — a provider a patch away from the runner
23
+ * loads a second copy of vitest's internals and fails at run time, in the
24
+ * browser, with a stack nobody reads as a version skew. The lockfile is the
25
+ * right file for this one question: it says what a fresh install resolves,
26
+ * which is what a runner will get, where a range says only what was allowed.
27
+ *
28
+ * Exit code: 0 when nothing is old, absent or skewed, 1 otherwise.
20
29
  */
21
30
 
22
- import { readFileSync } from 'node:fs';
31
+ import { existsSync, readFileSync } from 'node:fs';
23
32
  import { createRequire } from 'node:module';
24
- import { resolve } from 'node:path';
25
- import process, { exit, stdout } from 'node:process';
33
+ import { join, resolve } from 'node:path';
34
+ import process, { argv, cwd, exit, stdout } from 'node:process';
35
+
36
+ import { workspaceMembers } from './workspace-members.js';
26
37
 
27
38
  const require = createRequire(import.meta.url);
28
39
  const PACKAGE_ROOT = resolve(import.meta.dirname, '..');
@@ -155,8 +166,157 @@ export function toolVersions() {
155
166
  }));
156
167
  }
157
168
 
169
+ /**
170
+ * The pair a browser-mode consumer must keep equal, and the reason the check
171
+ * exists at all: `@vitest/browser-playwright` ships from vitest's repository
172
+ * and carries vitest's version, so anything but an exact match is a skew.
173
+ */
174
+ export const PINNED_PAIR = Object.freeze({
175
+ provider: '@vitest/browser-playwright',
176
+ runner: 'vitest',
177
+ });
178
+
179
+ /** Every version an npm lockfile resolves for one name: `node_modules/<name>` keys. */
180
+ function npmVersions(text, name) {
181
+ const suffix = `node_modules/${name}`;
182
+ const versions = new Set();
183
+
184
+ for (const [path, entry] of Object.entries(JSON.parse(text).packages ?? {})) {
185
+ if ((path === suffix || path.endsWith(`/${suffix}`)) && typeof entry.version === 'string') {
186
+ versions.add(entry.version);
187
+ }
188
+ }
189
+
190
+ return versions;
191
+ }
192
+
193
+ /**
194
+ * The same, off a `bun.lock`. The file is JSON with trailing commas, and every
195
+ * entry of its `packages` map opens on the `<name>@<version>` the install
196
+ * resolved — so the one comma rule is dropped and the rest is read as JSON.
197
+ */
198
+ function bunVersions(text, name) {
199
+ const versions = new Set();
200
+ const { packages } = JSON.parse(text.replaceAll(/,(?<trailing>\s*[\]}])/gu, '$<trailing>'));
201
+
202
+ for (const [key, entry] of Object.entries(packages ?? {})) {
203
+ const descriptor = Array.isArray(entry) ? entry[0] : entry;
204
+ if ((key === name || key.endsWith(`/${name}`)) && typeof descriptor === 'string') {
205
+ versions.add(descriptor.slice(descriptor.lastIndexOf('@') + 1));
206
+ }
207
+ }
208
+
209
+ return versions;
210
+ }
211
+
212
+ /** Lockfiles this report can read, in the order a resolver would find them. */
213
+ const READ_LOCKFILES = [
214
+ ['package-lock.json', npmVersions],
215
+ ['npm-shrinkwrap.json', npmVersions],
216
+ ['bun.lock', bunVersions],
217
+ ];
218
+
219
+ /** Lockfiles it cannot: a binary file and two YAML dialects, none of them JSON. */
220
+ const UNREAD_LOCKFILES = ['bun.lockb', 'pnpm-lock.yaml', 'yarn.lock'];
221
+
222
+ /** Every version a readable lockfile resolves for one name, deduplicated and sorted. */
223
+ function lockedVersions(path, read, name) {
224
+ try {
225
+ return [...read(readFileSync(path, 'utf8'), name)].toSorted((left, right) =>
226
+ left < right ? -1 : 1,
227
+ );
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Whether a package is declared under any dependency key of the project's
235
+ * manifest or of any workspace member's — the provider belongs to whichever
236
+ * package runs browser mode, and in a monorepo that is a member, not the root.
237
+ */
238
+ function declaredAnywhere(root, name) {
239
+ return ['.', ...workspaceMembers(root)].some((member) => {
240
+ try {
241
+ const pkg = manifestAt(join(root, member, 'package.json'));
242
+ return (
243
+ { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }[name] !==
244
+ undefined
245
+ );
246
+ } catch {
247
+ return false;
248
+ }
249
+ });
250
+ }
251
+
252
+ /**
253
+ * What the project's lockfile says about the pinned pair. `null` when there is
254
+ * no question to answer — no lockfile this report reads, and no provider
255
+ * declared under one it does not.
256
+ */
257
+ export function pinnedPair(root) {
258
+ const readable = READ_LOCKFILES.map(([name, read]) => [join(root, name), read]).find(([path]) =>
259
+ existsSync(path),
260
+ );
261
+
262
+ if (readable === undefined) {
263
+ const unread = UNREAD_LOCKFILES.find((name) => existsSync(join(root, name)));
264
+ if (unread !== undefined && declaredAnywhere(root, PINNED_PAIR.provider)) {
265
+ return { unread, verdict: 'unread' };
266
+ }
267
+
268
+ return null;
269
+ }
270
+
271
+ const provider = lockedVersions(readable[0], readable[1], PINNED_PAIR.provider);
272
+ if (provider === null || provider.length === 0) {
273
+ return null;
274
+ }
275
+
276
+ const runner = lockedVersions(readable[0], readable[1], PINNED_PAIR.runner) ?? [];
277
+ const matched = runner.length === 1 && provider.length === 1 && runner[0] === provider[0];
278
+
279
+ return { provider, runner, verdict: matched ? 'ok' : 'skewed' };
280
+ }
281
+
282
+ /** Every version a name resolves to, in one column — `—` when the lockfile has none. */
283
+ function versionColumn(versions) {
284
+ return versions.length === 0 ? '—' : versions.join(', ');
285
+ }
286
+
287
+ /** The pinned-pair section, or nothing at all when the project asks no question. */
288
+ function reportPinnedPair(root) {
289
+ const pair = pinnedPair(root);
290
+ if (pair === null) {
291
+ return false;
292
+ }
293
+
294
+ stdout.write('\nPinned together\n\n');
295
+
296
+ if (pair.verdict === 'unread') {
297
+ stdout.write(
298
+ ` ${PINNED_PAIR.provider.padEnd(28)}${pair.unread} is not parsed here — the pin is unchecked\n\n`,
299
+ );
300
+ return false;
301
+ }
302
+
303
+ stdout.write(` ${PINNED_PAIR.runner.padEnd(28)}${versionColumn(pair.runner)}\n`);
304
+ stdout.write(
305
+ ` ${PINNED_PAIR.provider.padEnd(28)}${versionColumn(pair.provider).padEnd(12)}${pair.verdict}\n\n`,
306
+ );
307
+
308
+ if (pair.verdict === 'skewed') {
309
+ stdout.write(
310
+ `${PINNED_PAIR.provider} is versioned with ${PINNED_PAIR.runner} and must equal it exactly — pin both to the same version.\n`,
311
+ );
312
+ }
313
+
314
+ return pair.verdict === 'skewed';
315
+ }
316
+
158
317
  /* Imported for `toolVersions`, run for the report — never both at once. */
159
318
  if (import.meta.main) {
319
+ const root = resolve(argv[2] ?? cwd());
160
320
  const rows = toolVersions();
161
321
  const failed = rows.some(({ verdict }) => verdict === 'old' || verdict === 'absent');
162
322
  const warned = rows.some(({ verdict }) => verdict === 'newer');
@@ -181,5 +341,7 @@ if (import.meta.main) {
181
341
  stdout.write('Every tool is in range.\n');
182
342
  }
183
343
 
184
- exit(failed ? 1 : 0);
344
+ const skewed = reportPinnedPair(root);
345
+
346
+ exit(failed || skewed ? 1 : 0);
185
347
  }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Where one repository ends, for every gate that walks upward.
5
+ *
6
+ * A gate that reads a file above the project it runs in — the artefact gate
7
+ * looking for the workspace `.gitignore`, the dependency walk looking for an
8
+ * installed package — must stop somewhere, and the only honest stop is the
9
+ * repository. A clone checked out INSIDE another tree (a workbench under
10
+ * `home/<brand>/work/`, a vendored dependency) is not a member of the tree it
11
+ * happens to sit in, and what lies above answers for a project whose files are
12
+ * none of this one's business.
13
+ *
14
+ * `.git` is that boundary in both spellings it takes: a DIRECTORY in a clone,
15
+ * a FILE in a worktree.
16
+ */
17
+
18
+ import { existsSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ /** Whether `dir` is a repository root — a `.git` beside it, file or directory. */
22
+ export function isRepositoryRoot(dir) {
23
+ return existsSync(join(dir, '.git'));
24
+ }
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * The `@jterrazz/test` a package would LOAD, and the checker binary that comes
5
+ * with it — found the way node finds them, from the package outward.
6
+ *
7
+ * A package is a consumer of what it can RESOLVE, not of what it names: npm
8
+ * hoists a workspace's shared devDependency to the root, so a member declaring
9
+ * nothing still loads it. Version and binary are answered from the one install
10
+ * the walk lands on, so a workspace whose members hold different releases is
11
+ * never judged by whichever one the root happens to carry.
12
+ *
13
+ * The lookup is the ancestor walk itself rather than `require.resolve`: the
14
+ * package's `exports` map does not publish `./package.json`, so the resolver
15
+ * refuses the one path that carries the version.
16
+ *
17
+ * The walk stops at the nearest repository boundary
18
+ * ([repository-root.js](repository-root.js)): a clone sitting inside another
19
+ * tree loads its own install or none, and the estate's `node_modules` never
20
+ * makes a consumer of a project that declares nothing.
21
+ *
22
+ * Usage: node test-package.js <dir> [--at-least <version>]
23
+ * node test-package.js <dir> --bin
24
+ * node test-package.js --floor
25
+ *
26
+ * Prints the resolved version — or, with `--bin`, the absolute path of the
27
+ * checker entry the install declares. Exit code: 0 when it resolves — and,
28
+ * with `--at-least`, when it is that version or newer — 1 otherwise. `--floor`
29
+ * prints the release the checker's own flags arrive in, so the callers that
30
+ * gate on it read one number rather than each keeping a copy.
31
+ */
32
+
33
+ import { existsSync, readFileSync } from 'node:fs';
34
+ import { dirname, join, resolve } from 'node:path';
35
+ import { argv, exit, stdout } from 'node:process';
36
+
37
+ import { isRepositoryRoot } from './repository-root.js';
38
+
39
+ /** The package every lookup here is about. */
40
+ const PACKAGE = '@jterrazz/test';
41
+
42
+ /** The binary it publishes, and the one the quality checks run. */
43
+ const CHECKER_BIN = 'jterrazz-test-check';
44
+
45
+ /**
46
+ * The release that answers the flags `typescript check` and `typescript
47
+ * baseline` call it with — `--member` and `--format json`. Below it both are
48
+ * skipped, and the gates say so rather than pretending they ran.
49
+ */
50
+ export const CHECKER_FLOOR = '15.3.0';
51
+
52
+ /** `1.83.0` as `[1, 83, 0]`, with anything after a `-` or `+` dropped. */
53
+ function parts(version) {
54
+ return version
55
+ .split(/[+-]/u)[0]
56
+ .split('.')
57
+ .map((piece) => Number.parseInt(piece, 10) || 0);
58
+ }
59
+
60
+ /** Negative when left is older, positive when newer, zero when the same. */
61
+ function compare(left, right) {
62
+ const [a, b] = [parts(left), parts(right)];
63
+ for (let index = 0; index < 3; index += 1) {
64
+ if ((a[index] ?? 0) !== (b[index] ?? 0)) {
65
+ return (a[index] ?? 0) - (b[index] ?? 0);
66
+ }
67
+ }
68
+
69
+ return 0;
70
+ }
71
+
72
+ /**
73
+ * The install of `@jterrazz/test` reachable from `dir` — its directory and its
74
+ * manifest — or null. Every `node_modules` from the directory up to the
75
+ * repository boundary is asked, nearest first, which is the order node itself
76
+ * resolves in.
77
+ */
78
+ export function resolveTestPackage(dir) {
79
+ let current = resolve(dir);
80
+
81
+ while (true) {
82
+ const installed = join(current, 'node_modules', PACKAGE);
83
+ const manifest = join(installed, 'package.json');
84
+ if (existsSync(manifest)) {
85
+ try {
86
+ return { dir: installed, manifest: JSON.parse(readFileSync(manifest, 'utf8')) };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ const parent = dirname(current);
93
+ if (isRepositoryRoot(current) || parent === current) {
94
+ return null;
95
+ }
96
+ current = parent;
97
+ }
98
+ }
99
+
100
+ /** The version of `@jterrazz/test` reachable from `dir`, or null. */
101
+ export function testPackageVersion(dir) {
102
+ const { manifest } = resolveTestPackage(dir) ?? {};
103
+
104
+ return typeof manifest?.version === 'string' ? manifest.version : null;
105
+ }
106
+
107
+ /**
108
+ * The conventions checker `dir` would run, or null. It is the entry the SAME
109
+ * install declares, not a name on PATH: a `node_modules/.bin` shim answers for
110
+ * whichever member npm hoisted to, and PATH answers for nothing at all in a
111
+ * published install.
112
+ */
113
+ export function testPackageBin(dir) {
114
+ const resolved = resolveTestPackage(dir);
115
+ if (resolved === null) {
116
+ return null;
117
+ }
118
+
119
+ const { bin } = resolved.manifest;
120
+ const entry = typeof bin === 'string' ? bin : bin?.[CHECKER_BIN];
121
+ if (typeof entry !== 'string') {
122
+ return null;
123
+ }
124
+
125
+ const path = join(resolved.dir, entry);
126
+
127
+ return existsSync(path) ? path : null;
128
+ }
129
+
130
+ /** Whether what `dir` resolves is at least `floor`. */
131
+ export function testPackageAtLeast(dir, floor) {
132
+ const version = testPackageVersion(dir);
133
+
134
+ return version !== null && compare(version, floor) >= 0;
135
+ }
136
+
137
+ /* Imported for the walk, run for the answer — never both at once. */
138
+ if (import.meta.main) {
139
+ if (argv.includes('--floor')) {
140
+ stdout.write(`${CHECKER_FLOOR}\n`);
141
+ exit(0);
142
+ }
143
+
144
+ const floorIndex = argv.indexOf('--at-least');
145
+ const floor = floorIndex === -1 ? null : (argv[floorIndex + 1] ?? null);
146
+ const dir =
147
+ argv
148
+ .slice(2)
149
+ .find(
150
+ (argument, index) => !argument.startsWith('--') && index + 2 !== floorIndex + 1,
151
+ ) ?? '.';
152
+
153
+ if (argv.includes('--bin')) {
154
+ const bin = testPackageBin(dir);
155
+ if (bin === null) {
156
+ exit(1);
157
+ }
158
+
159
+ stdout.write(`${bin}\n`);
160
+ exit(0);
161
+ }
162
+
163
+ const version = testPackageVersion(dir);
164
+ if (version === null) {
165
+ exit(1);
166
+ }
167
+
168
+ stdout.write(`${version}\n`);
169
+ exit(floor === null || compare(version, floor) >= 0 ? 0 : 1);
170
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/typescript",
3
- "version": "10.1.9",
3
+ "version": "10.2.0",
4
4
  "license": "MIT",
5
5
  "author": "Jean-Baptiste Terrazzoni <contact@jterrazz.com>",
6
6
  "repository": {
package/rules/catalog.js CHANGED
@@ -89,7 +89,7 @@ export function render() {
89
89
  export function renderReference() {
90
90
  const rows = catalog().map((entry) => {
91
91
  const state = entry.level === 'off' ? 'off' : 'on';
92
- const why = entry.level === 'off' ? reasonClause(entry.reason) : scopeOf(entry);
92
+ const why = entry.level === 'off' ? reasonClause(entry.reason) : agentScopeOf(entry);
93
93
  const where = entry.profiles.length === EVERY_PROFILE ? 'all' : entry.profiles.join(', ');
94
94
 
95
95
  return `| \`${entry.rule}\` | ${state} | ${why} | ${where} |`;
@@ -98,6 +98,25 @@ export function renderReference() {
98
98
  return ['| Rule | State | Why | Profiles |', '| --- | --- | --- | --- |', ...rows].join('\n');
99
99
  }
100
100
 
101
+ /** Where the vitest fragment applies, named by its owner rather than spelled out. */
102
+ const TEST_FILE_ROUTE = "scoped to test files — @jterrazz/test's catalogue owns the shapes";
103
+
104
+ /**
105
+ * The scope an AGENT is owed. Seventy-odd vitest rows each restated the same
106
+ * three globs, and one of them — the `__tests__` directory shape — is a layout
107
+ * the test package's own rulebook refuses: a page an agent reads to decide
108
+ * where to put a file would have been advertising it seventy times. So the
109
+ * vitest block names its owner and the agent goes there for the shapes, which
110
+ * is the package that decides them.
111
+ */
112
+ function agentScopeOf(entry) {
113
+ if (entry.fragment === 'vitest' && entry.scoped !== undefined) {
114
+ return TEST_FILE_ROUTE;
115
+ }
116
+
117
+ return scopeOf(entry);
118
+ }
119
+
101
120
  /** An `off` in one clause: its kind, and the first thing its reason names. */
102
121
  function reasonClause({ by, kind }) {
103
122
  return `${kind}: ${by.split(' — ')[0]}`;
@@ -66,6 +66,16 @@ test('every decision carries the version it was taken in', () => {
66
66
  }
67
67
  });
68
68
 
69
+ test('the skill reference routes the vitest scope instead of restating it', () => {
70
+ // Given - the projection an agent reads
71
+ const reference = renderReference();
72
+
73
+ // Then - the vitest block names its owner, and no row spells a glob out
74
+ expect(reference).toContain("@jterrazz/test's catalogue owns the shapes");
75
+ expect(reference).not.toContain('__tests__');
76
+ expect(reference).not.toContain('{test,spec,test-d,spec-d}');
77
+ });
78
+
69
79
  test.each(Object.entries(PROJECTIONS))(
70
80
  'the %s carries the catalogue the manifest renders',
71
81
  (_name, { markers, page, render: project }) => {
@@ -37,6 +37,23 @@ export default fragment({
37
37
  files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
38
38
  rules: Object.fromEntries(UNTYPED_IN_JAVASCRIPT),
39
39
  }),
40
+ /*
41
+ * A declaration file is where module augmentation lives, and only an
42
+ * interface merges into an existing one: a `type` alias REDECLARES the
43
+ * name and every member of the original is lost. A `.ts` file carrying
44
+ * an augmentation is still reported — the scope is the file's purpose,
45
+ * and oxlint scopes by path — and answers with a suppression.
46
+ */
47
+ scoped({
48
+ files: ['**/*.d.ts'],
49
+ rules: {
50
+ 'typescript/consistent-type-definitions': off({
51
+ by: 'TypeScript — an augmentation merges only as an interface, and a declaration file is where augmentations live',
52
+ kind: 'covered',
53
+ }),
54
+ },
55
+ since: '10.2.0',
56
+ }),
40
57
  ],
41
58
  rules: {
42
59
  ...allOn(