@jterrazz/typescript 10.1.10 → 10.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands/check.sh +122 -11
- package/bin/typescript.sh +23 -2
- package/lib/check-baseline.js +75 -9
- package/lib/check-gitignore.js +19 -4
- package/lib/check-suppressions.js +31 -12
- package/lib/doctor.js +168 -6
- package/lib/repository-root.js +24 -0
- package/lib/test-package.js +170 -0
- package/package.json +1 -1
- package/rules/catalog.js +20 -1
- package/rules/catalog.test.ts +10 -0
- package/rules/core/typescript.js +17 -0
package/bin/commands/check.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
|
134
|
-
#
|
|
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,
|
|
573
|
-
# owns, gated by the package that OWNS that root —
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/lib/check-baseline.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
/**
|
|
43
|
-
|
|
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
|
|
46
|
-
const
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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();
|
package/lib/check-gitignore.js
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
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
|
|
|
@@ -59,7 +62,7 @@ const ARTEFACT_DIRECTORIES = new Map([
|
|
|
59
62
|
['.vite', '.artifacts/vite/'],
|
|
60
63
|
['bin', '.artifacts/go/'],
|
|
61
64
|
['build', '.artifacts/<tool>/'],
|
|
62
|
-
['coverage', '.artifacts/coverage/'],
|
|
65
|
+
['coverage', '.artifacts/vitest/coverage/'],
|
|
63
66
|
['out', '.artifacts/next/'],
|
|
64
67
|
['playwright-report', '.artifacts/playwright/'],
|
|
65
68
|
['target', '.artifacts/cargo/'],
|
|
@@ -171,15 +174,27 @@ function isWorkspaceRoot(dir) {
|
|
|
171
174
|
* The nearest ancestor ABOVE `root` that is a workspace root, or null. Never
|
|
172
175
|
* `root` itself — a project reads its OWN `.gitignore` regardless, so only
|
|
173
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.
|
|
174
181
|
*/
|
|
175
182
|
function findWorkspaceRoot(root) {
|
|
176
|
-
|
|
183
|
+
const start = resolve(root);
|
|
184
|
+
if (isRepositoryRoot(start)) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let dir = dirname(start);
|
|
177
189
|
let parent = dirname(dir);
|
|
178
190
|
|
|
179
191
|
while (dir !== parent) {
|
|
180
192
|
if (isWorkspaceRoot(dir)) {
|
|
181
193
|
return dir;
|
|
182
194
|
}
|
|
195
|
+
if (isRepositoryRoot(dir)) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
183
198
|
dir = parent;
|
|
184
199
|
parent = dirname(dir);
|
|
185
200
|
}
|
|
@@ -75,7 +75,8 @@ const hasReason = (tail) => (tail.split(REASON)[1] ?? '').replace(/\*\/\s*$/u, '
|
|
|
75
75
|
*
|
|
76
76
|
* A JS plugin's rules never appear in `--print-config`, so a directive naming
|
|
77
77
|
* one is unverifiable — and the gate says nothing rather than calling a live
|
|
78
|
-
* rule dead.
|
|
78
|
+
* rule dead. The namespaces returned are therefore the ones the printed config
|
|
79
|
+
* can ANSWER for: the plugins it loaded, and never a JS plugin's.
|
|
79
80
|
*/
|
|
80
81
|
function resolveConfig(root, oxlint) {
|
|
81
82
|
let printed;
|
|
@@ -89,21 +90,33 @@ function resolveConfig(root, oxlint) {
|
|
|
89
90
|
return null;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
let
|
|
93
|
+
let resolved;
|
|
93
94
|
try {
|
|
94
|
-
|
|
95
|
-
rules = { ...resolved.rules };
|
|
96
|
-
/* A rule armed only in an override — a test-file rule, a layer's
|
|
97
|
-
* `no-restricted-imports` — is live for the files it names. */
|
|
98
|
-
for (const override of resolved.overrides ?? []) {
|
|
99
|
-
for (const [name, level] of Object.entries(override.rules ?? {})) {
|
|
100
|
-
rules[name] ??= level;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
95
|
+
resolved = JSON.parse(printed);
|
|
103
96
|
} catch {
|
|
104
97
|
return null;
|
|
105
98
|
}
|
|
106
99
|
|
|
100
|
+
const rules = { ...resolved.rules };
|
|
101
|
+
/* A rule armed only in an override — a test-file rule, a layer's
|
|
102
|
+
* `no-restricted-imports` — is live for the files it names. */
|
|
103
|
+
for (const override of resolved.overrides ?? []) {
|
|
104
|
+
for (const [name, level] of Object.entries(override.rules ?? {})) {
|
|
105
|
+
rules[name] ??= level;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { namespaces: namespacesOf(resolved, rules), rules };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The namespaces the printed config can ANSWER for. Where it loads a JS plugin,
|
|
114
|
+
* a namespace outside the plugins it printed is that plugin's, and the map holds
|
|
115
|
+
* at most the few of its rules a `rules` or `allow` entry happened to name —
|
|
116
|
+
* never the live ones. Such a namespace is dropped rather than read as evidence
|
|
117
|
+
* that every OTHER rule of it is off.
|
|
118
|
+
*/
|
|
119
|
+
function namespacesOf(resolved, rules) {
|
|
107
120
|
const namespaces = new Set();
|
|
108
121
|
for (const name of Object.keys(rules)) {
|
|
109
122
|
if (name.includes('/')) {
|
|
@@ -111,7 +124,13 @@ function resolveConfig(root, oxlint) {
|
|
|
111
124
|
}
|
|
112
125
|
}
|
|
113
126
|
|
|
114
|
-
|
|
127
|
+
if ((resolved.jsPlugins?.length ?? 0) === 0) {
|
|
128
|
+
return namespaces;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const loaded = new Set(resolved.plugins);
|
|
132
|
+
|
|
133
|
+
return new Set([...namespaces].filter((namespace) => loaded.has(namespace)));
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
/** Where a named rule stands for this project: `live`, `dead`, or `unverifiable`. */
|
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
|
-
*
|
|
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
|
-
|
|
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
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) :
|
|
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]}`;
|
package/rules/catalog.test.ts
CHANGED
|
@@ -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 }) => {
|
package/rules/core/typescript.js
CHANGED
|
@@ -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(
|