@feigi/fleet-ctl 3.82.28 → 3.83.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/agents/fleet-recipe-deriver.agent.md +46 -20
- package/package.json +1 -1
- package/scripts/claim-ticket.sh +5 -3
- package/scripts/derive-testcmd.sh +89 -29
- package/scripts/fleet-tick.mjs +30 -27
- package/scripts/gh-aliased-read.mjs +95 -0
- package/scripts/recipe-prove.mjs +62 -10
- package/scripts/reclaim-scratch.mjs +11 -59
- package/skills/run-team/references/tick-rows.md +2 -2
|
@@ -58,19 +58,27 @@ derive never changes the answer.
|
|
|
58
58
|
the frozen or locked mode in the command; never rely on the environment to
|
|
59
59
|
imply it.
|
|
60
60
|
- **Test entrypoint** — every test step in the merge-gating CI jobs that can
|
|
61
|
-
run in a fresh worktree without a running service. Which steps qualify is
|
|
62
|
-
decide, by reading CI and the repository.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`
|
|
73
|
-
|
|
61
|
+
run in a fresh worktree without a running service. Which steps qualify is
|
|
62
|
+
yours to decide, by reading CI and the repository. It is ONE simple command:
|
|
63
|
+
the reader refuses an unquoted `&&`, `||`, `;`, `|`, a `&` that is not part
|
|
64
|
+
of a redirection, or a `(`…`)` group. Members run it with their own arguments
|
|
65
|
+
appended (`sh -c '<test> "$@"'`), and a join or group hands those arguments
|
|
66
|
+
to its last command alone or breaks the run. An unquoted newline ends a
|
|
67
|
+
command as `;` does, so it is refused too. `2>&1` and `>&2` stay accepted.
|
|
68
|
+
A chain is accepted only quoted as the script of `sh -c`, and that form
|
|
69
|
+
forwards the appended arguments only through `"$@"`:
|
|
70
|
+
`sh -c 'cd web && npm test -- "$@"' sh`. A suite in a subdirectory uses its
|
|
71
|
+
runner's directory option, not `cd`: `npm --prefix web test`,
|
|
72
|
+
`pnpm -C web test`, `make -C web test`, `go -C web test`,
|
|
73
|
+
`mvn -f web/pom.xml test`, `gradle -p web test`. Prefer a form where an
|
|
74
|
+
appended test file or test name lands as an argument to the test runner. The
|
|
75
|
+
cache reader refuses a command whose first word does not resolve from the
|
|
76
|
+
repository root (a builtin, a `PATH` entry, or an executable path), one
|
|
77
|
+
ending in `;`, `&` or a newline, one containing a word that starts with `#`,
|
|
78
|
+
and one with an unquoted pattern that matches no file tracked from the
|
|
79
|
+
repository root. Quote a pattern that a program resolves under its own
|
|
80
|
+
directory option, e.g. `git -C web ls-files -- '*.test.ts'`: the reader
|
|
81
|
+
accepts a quoted pattern unchecked.
|
|
74
82
|
- **Check step** — every merge-gating CI step that is not a test step, that
|
|
75
83
|
the Test entrypoint does not run, and that needs no running service: the format, lint and type checks the
|
|
76
84
|
repository's CI gates on, whatever they are. One invocation, in the same form
|
|
@@ -92,6 +100,22 @@ derive never changes the answer.
|
|
|
92
100
|
PROVEN` with the cause **needs a running service**, never an entrypoint
|
|
93
101
|
that needs one. That is not the case above of no single invocation covering
|
|
94
102
|
every qualifying step.
|
|
103
|
+
- **Covered steps** — every merge-gating CI step the Test entrypoint or the
|
|
104
|
+
Check step runs, passed as `--covered`: one JSON array of
|
|
105
|
+
`{"step": "<the CI step>", "by": "test" | "check"}`, one entry per step,
|
|
106
|
+
`by` naming which of the two runs it. At least one entry is `by: "test"`,
|
|
107
|
+
and `by: "check"` is refused while the Check step is `true`. A step is in
|
|
108
|
+
`--covered` or in `--uncovered`, never both: the two lists are compared on
|
|
109
|
+
the step name exactly, after trimming and collapsing internal whitespace, so
|
|
110
|
+
write a step the same way in both.
|
|
111
|
+
- **The ledger** — before you propose, classify every test step and every
|
|
112
|
+
non-test step of every merge-gating CI job: run by the Test entrypoint
|
|
113
|
+
(`by: "test"`), run by the Check step (`by: "check"`), or uncovered with a
|
|
114
|
+
reason. Leave none unclassified. A step the Test entrypoint or the Check step
|
|
115
|
+
runs never goes in `uncovered`; a merge-gating step that needs a running
|
|
116
|
+
service, or another OS, always does. Which steps qualify is your judgement:
|
|
117
|
+
no script checks it, so a step left out of both lists (a Docker-gated step
|
|
118
|
+
omitted from `uncovered`, for example) is the one error nothing here catches.
|
|
95
119
|
|
|
96
120
|
## 2. Prove it — this procedure exactly
|
|
97
121
|
|
|
@@ -100,7 +124,7 @@ script creates and removes; `<repo>`'s own tree is never touched.
|
|
|
100
124
|
|
|
101
125
|
**First run, to see the output** — no proof flags:
|
|
102
126
|
|
|
103
|
-
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --uncovered '<uncovered>'
|
|
127
|
+
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --covered '<covered>' --uncovered '<uncovered>'
|
|
104
128
|
|
|
105
129
|
It exits 1 with `NOT PROVEN — no proof given` once every command ran, and
|
|
106
130
|
names the Test entrypoint's output file (`test output: <path>`). Any other
|
|
@@ -113,7 +137,7 @@ and revise.
|
|
|
113
137
|
text off that summary line, as printed, into `--count-line`, and the number
|
|
114
138
|
of tests that ran into `--test-count`:
|
|
115
139
|
|
|
116
|
-
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --uncovered '<uncovered>' \
|
|
140
|
+
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --covered '<covered>' --uncovered '<uncovered>' \
|
|
117
141
|
--count-line '<literal text of the count line>' --test-count <n>
|
|
118
142
|
|
|
119
143
|
The number is the tests that ran — never the skipped, the failed, or a
|
|
@@ -131,7 +155,7 @@ and revise.
|
|
|
131
155
|
must change a tracked file; the script runs it in the throwaway worktree,
|
|
132
156
|
requires the suite to go red, and discards the worktree after:
|
|
133
157
|
|
|
134
|
-
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --uncovered '<uncovered>' \
|
|
158
|
+
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --covered '<covered>' --uncovered '<uncovered>' \
|
|
135
159
|
--mutate '<shell command that breaks one test>' --mutation '<one line: what it breaks>'
|
|
136
160
|
|
|
137
161
|
**The Check step's own proof** — when `--check` is not `true`, the Check step
|
|
@@ -140,15 +164,17 @@ must run green on `origin/main`, and one mutation must turn it red. Give
|
|
|
140
164
|
`--check true`. Both proofs ride in one run: the Test entrypoint's proof flags
|
|
141
165
|
from above, and these:
|
|
142
166
|
|
|
143
|
-
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --uncovered '<uncovered>' \
|
|
167
|
+
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --covered '<covered>' --uncovered '<uncovered>' \
|
|
144
168
|
--check-mutate '<shell command that breaks one file the Check step reads>' --check-mutation '<one line: what it breaks>' \
|
|
145
169
|
<the Test entrypoint's proof flags>
|
|
146
170
|
|
|
147
171
|
Exit 0 is the proof holding: the script wrote the Recipe cache and printed
|
|
148
172
|
`PROVEN` and the cache's contents. Exit 1 is `NOT PROVEN`, with the reason;
|
|
149
|
-
no cache was written.
|
|
150
|
-
|
|
151
|
-
the
|
|
173
|
+
no cache was written. A Test entrypoint, Install step or Check step whose shape
|
|
174
|
+
the reader refuses is `NOT PROVEN` before any command runs. Exit 2 means no
|
|
175
|
+
verdict on the proof: it could not be attempted (no `origin/main`, not a
|
|
176
|
+
repository, a missing or malformed `--covered` or `--uncovered`) or the cache
|
|
177
|
+
could not be written — report it, do not work around it.
|
|
152
178
|
|
|
153
179
|
The script bounds each command it runs itself — the Install step, the Check
|
|
154
180
|
step, both Test runs and the mutations — and refuses one still running at that
|
package/package.json
CHANGED
package/scripts/claim-ticket.sh
CHANGED
|
@@ -199,9 +199,11 @@ testcmd=$("$script_dir/derive-testcmd.sh" . test --at origin/main 2>&1) || die "
|
|
|
199
199
|
echo " test entrypoint → $testcmd" >&2
|
|
200
200
|
|
|
201
201
|
# The Test entrypoint is a shell COMMAND, not an argv: the runner hands it to
|
|
202
|
-
# the suite-slot wrapper, which hands it to `sh -c`, so a compound one
|
|
203
|
-
#
|
|
204
|
-
#
|
|
202
|
+
# the suite-slot wrapper, which hands it to `sh -c`, so a compound one keeps
|
|
203
|
+
# its meaning, with the runner's own arguments appended as "$@". A compound
|
|
204
|
+
# one reaches here only quoted, as the script of an inner `sh -c`
|
|
205
|
+
# (`sh -c 'cd sub && make test -- "$@"' sh`): derive-testcmd.sh refuses an
|
|
206
|
+
# unquoted join. It reaches the runner's source
|
|
205
207
|
# single-quoted — each embedded `'` closed, escaped and reopened — so nothing
|
|
206
208
|
# in it expands at write time. `LC_ALL=C` so sed reads the command as bytes.
|
|
207
209
|
# Derived here, ahead of every mutation, so a failure refuses before
|
|
@@ -61,8 +61,20 @@
|
|
|
61
61
|
# tree that probe reads instead of <repo>'s own index, for a caller that runs
|
|
62
62
|
# the command in a worktree cut from a ref.
|
|
63
63
|
#
|
|
64
|
+
# A Test entrypoint is ONE simple command: a join or a group (an unquoted
|
|
65
|
+
# newline, or an unquoted `;`, `&`, `|`, `(` or `)` outside a redirection) is
|
|
66
|
+
# refused, because the runner appends a run's arguments to its last simple
|
|
67
|
+
# command and a join hands them to the wrong one. A chain stays usable quoted
|
|
68
|
+
# as the script of `sh -c`, for example `sh -c 'cd web && npm test -- "$@"' sh`.
|
|
69
|
+
#
|
|
70
|
+
# `--propose <repo> <install|check|test> <command> [--at <rev>]` runs the same
|
|
71
|
+
# checks on a command that is in no cache: it reads no cache and runs nothing.
|
|
72
|
+
# Its refusals are verdicts on the proposal. On success it prints the command
|
|
73
|
+
# back and exits 0.
|
|
74
|
+
#
|
|
64
75
|
# Exit status: 0 the field is printed, `uncovered` as one line of compact
|
|
65
|
-
# JSON; 1 a refusal about the cache,
|
|
76
|
+
# JSON, or a proposal that passes is printed back; 1 a refusal about the cache,
|
|
77
|
+
# the repository, the arguments or a proposal; 3 a
|
|
66
78
|
# tool this script needs to read the cache
|
|
67
79
|
# (git, node, mktemp, cat) could not be started, mktemp could not create its
|
|
68
80
|
# temp file, git did not run to an answer, node died mid-read, or node exited
|
|
@@ -134,17 +146,28 @@ derive="run the Recipe derivation step (run-team phase 0, before the first claim
|
|
|
134
146
|
# the checkout still lists the old layout until someone updates it. Without
|
|
135
147
|
# `--at` the probe reads <repo>'s own index, as for a caller that runs the
|
|
136
148
|
# command in <repo> itself. Three arguments is neither shape: a refusal.
|
|
137
|
-
usage="usage: derive-testcmd.sh <repo> <install|check|test|uncovered> [--at <rev>]
|
|
149
|
+
usage="usage: derive-testcmd.sh <repo> <install|check|test|uncovered> [--at <rev>]
|
|
150
|
+
derive-testcmd.sh --propose <repo> <install|check|test> <command> [--at <rev>]"
|
|
151
|
+
propose=
|
|
152
|
+
if [ "${1-}" = --propose ]; then
|
|
153
|
+
[ $# -ge 4 ] || die "$usage"
|
|
154
|
+
repo=$2
|
|
155
|
+
field=$3
|
|
156
|
+
propose=$4
|
|
157
|
+
[ -n "$propose" ] || die "--propose needs the command to check, got an empty one — $usage"
|
|
158
|
+
shift 4
|
|
159
|
+
else
|
|
160
|
+
[ $# -ge 2 ] || die "$usage"
|
|
161
|
+
repo=$1
|
|
162
|
+
field=$2
|
|
163
|
+
shift 2
|
|
164
|
+
fi
|
|
138
165
|
rev=
|
|
139
|
-
|
|
140
|
-
2
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
*) die "$usage" ;;
|
|
145
|
-
esac
|
|
146
|
-
repo=$1
|
|
147
|
-
field=$2
|
|
166
|
+
if [ $# -gt 0 ]; then
|
|
167
|
+
[ $# -eq 2 ] && [ "$1" = --at ] || die "$usage"
|
|
168
|
+
rev=$2
|
|
169
|
+
case $rev in ''|-*) die "--at needs a commit, got '$rev' — $usage" ;; esac
|
|
170
|
+
fi
|
|
148
171
|
|
|
149
172
|
# git's own fatal — no repository there, or no such directory — exits 128, the
|
|
150
173
|
# one status that is a verdict on the repository. Any other non-zero status is
|
|
@@ -157,7 +180,8 @@ gitout=$(git -C "$repo" rev-parse --git-dir 2>&1) || {
|
|
|
157
180
|
}
|
|
158
181
|
|
|
159
182
|
case $field in
|
|
160
|
-
install|check|test
|
|
183
|
+
install|check|test) ;;
|
|
184
|
+
uncovered) [ -z "$propose" ] || die "--propose takes an install, check or test command, not '$field' — $usage" ;;
|
|
161
185
|
*) die "unknown Recipe field '$field' — expected install, check, test or uncovered" ;;
|
|
162
186
|
esac
|
|
163
187
|
|
|
@@ -169,6 +193,21 @@ if [ -n "$rev" ]; then
|
|
|
169
193
|
|| die "--at '$rev' does not resolve to a commit in $repo"
|
|
170
194
|
fi
|
|
171
195
|
|
|
196
|
+
# A proposed command is checked, never read: the checks below run on it as
|
|
197
|
+
# they run on a cache's field, but nothing is read from the cache and nothing
|
|
198
|
+
# is run. Its refusals are verdicts on the proposal, and say so.
|
|
199
|
+
if [ -n "$propose" ]; then
|
|
200
|
+
subj="the proposed"
|
|
201
|
+
tail="no command was run — propose a command that passes these checks"
|
|
202
|
+
stale="a command that cannot run is refused before any command runs"
|
|
203
|
+
stale5="so it is refused before any command runs"
|
|
204
|
+
value=$propose
|
|
205
|
+
errf=$(mktemp) || unrunnable "cannot create a temporary file to check the proposed $field command"
|
|
206
|
+
trap 'rc=$?; rm -f "$errf" "$errf.idx" 2>/dev/null || :; exit $rc' EXIT
|
|
207
|
+
else
|
|
208
|
+
# The cache read, for the three fields a Recipe holds. The checks below the
|
|
209
|
+
# `fi` that closes it run on the value it leaves in `value`.
|
|
210
|
+
|
|
172
211
|
# `--path-format=absolute` so the common dir is a real path whatever the
|
|
173
212
|
# caller's cwd. The workspace it names is git-env.mjs's resolveWorkspace() rule,
|
|
174
213
|
# written out below in shell.
|
|
@@ -356,6 +395,10 @@ esac
|
|
|
356
395
|
[ "${#framed}" -eq "$framedlen" ] || die "$carriedmsg"
|
|
357
396
|
value=${framed#"$open"}
|
|
358
397
|
value=${value%"$close"}
|
|
398
|
+
subj="the Recipe cache at $cache is invalid: its"
|
|
399
|
+
tail=$derive
|
|
400
|
+
stale="a Recipe that cannot run is stale, not a finding"
|
|
401
|
+
stale5="so the Recipe is stale, not a finding"
|
|
359
402
|
|
|
360
403
|
# `uncovered` is data, not a command: nothing runs it and nothing appends to
|
|
361
404
|
# it, so none of the command checks below apply. JSON.stringify above made it
|
|
@@ -364,6 +407,7 @@ if [ "$field" = uncovered ]; then
|
|
|
364
407
|
printf '%s\n' "$value"
|
|
365
408
|
exit 0
|
|
366
409
|
fi
|
|
410
|
+
fi
|
|
367
411
|
|
|
368
412
|
# claim-ticket.sh appends the runner's own arguments after this string
|
|
369
413
|
# textually (`exec sh -c '<cmd> "$@"' agent-test "$@"`), so a command ending in
|
|
@@ -383,7 +427,7 @@ fi
|
|
|
383
427
|
# neither hazard is about whether the command resolves.
|
|
384
428
|
case $value in
|
|
385
429
|
*';'|*'&'|*'
|
|
386
|
-
') die "
|
|
430
|
+
') die "$subj $field command '$value' ends in ';', '&', or a newline — a runner-appended argument after it would run as an unrelated command instead of reaching the Test entrypoint; $tail" ;;
|
|
387
431
|
esac
|
|
388
432
|
set -f
|
|
389
433
|
IFS='
|
|
@@ -391,7 +435,7 @@ IFS='
|
|
|
391
435
|
# shellcheck disable=SC2086 # field splitting is the point: scanning every word
|
|
392
436
|
for word in $value; do
|
|
393
437
|
case $word in
|
|
394
|
-
'#'*) set +f; die "
|
|
438
|
+
'#'*) set +f; die "$subj $field command '$value' contains a '#' word — a runner-appended argument would be swallowed as a comment rather than reaching the Test entrypoint; $tail" ;;
|
|
395
439
|
esac
|
|
396
440
|
done
|
|
397
441
|
set +f
|
|
@@ -418,7 +462,7 @@ while [ $# -gt 0 ]; do
|
|
|
418
462
|
*) break ;;
|
|
419
463
|
esac
|
|
420
464
|
done
|
|
421
|
-
[ $# -gt 0 ] || die "
|
|
465
|
+
[ $# -gt 0 ] || die "$subj $field command names no command, only assignments — $tail"
|
|
422
466
|
case $1 in
|
|
423
467
|
*[\'\"\\\$\`\(\)\{\}\<\>\|\&\;\*\?\[\~]*) ;;
|
|
424
468
|
*)
|
|
@@ -431,7 +475,7 @@ case $1 in
|
|
|
431
475
|
# or a PATH match) has no path to test and is left to `command -v`.
|
|
432
476
|
(cd "$repo" && command -v -- "$1" || exit 1
|
|
433
477
|
case $1 in */*) [ -x "$1" ] || exit 1 ;; esac) >/dev/null 2>&1 \
|
|
434
|
-
|| die "
|
|
478
|
+
|| die "$subj $field command '$1' is not found or not executable from $repo — $stale; $tail"
|
|
435
479
|
;;
|
|
436
480
|
esac
|
|
437
481
|
|
|
@@ -457,8 +501,8 @@ esac
|
|
|
457
501
|
# that cannot run. An option, an `=`
|
|
458
502
|
# assignment or value, a path outside <repo>, or the target of a `<` or `>`
|
|
459
503
|
# redirection is the program's own to read, or selects no test, and is
|
|
460
|
-
# accepted unprobed too.
|
|
461
|
-
#
|
|
504
|
+
# accepted unprobed too. A join or a group, an unquoted newline included, is
|
|
505
|
+
# refused as soon as the scan reaches it, so no piece after one is judged.
|
|
462
506
|
# A program's own directory option,
|
|
463
507
|
# such as `git -C`, does not stop it: whether that option moves where the
|
|
464
508
|
# program resolves a pattern is the program's to say, so a Recipe must quote a
|
|
@@ -482,7 +526,9 @@ esac
|
|
|
482
526
|
# private index file, and the symlink and submodule test reads that tree
|
|
483
527
|
# too; the ignore rules are still <repo>'s working copy's, the one place git
|
|
484
528
|
# reads them from. The resolvability probe of the command's leading word
|
|
485
|
-
# above stays <repo>'s own.
|
|
529
|
+
# above stays <repo>'s own. A git call of the probe that fails is git not
|
|
530
|
+
# running to an answer, exit 3: no verdict on the cache or on a proposal, so
|
|
531
|
+
# recipe-prove.mjs never reads it as NOT PROVEN.
|
|
486
532
|
if [ "$field" = test ]; then
|
|
487
533
|
# An ambient index would answer `ls-files` for whatever repository or commit
|
|
488
534
|
# the caller is in the middle of, not for <repo>'s tracked files.
|
|
@@ -493,7 +539,7 @@ if [ "$field" = test ]; then
|
|
|
493
539
|
export GIT_INDEX_FILE="$errf.idx"
|
|
494
540
|
git -C "$repo" read-tree "$revc" 2>"$errf" || {
|
|
495
541
|
listed=$?
|
|
496
|
-
|
|
542
|
+
unrunnable "cannot read the tree of $rev in $repo (git read-tree exit $listed): $(cat "$errf" 2>/dev/null)"
|
|
497
543
|
}
|
|
498
544
|
fi
|
|
499
545
|
# Whether <path> is a directory git lists as ONE entry and never beneath: a
|
|
@@ -518,14 +564,12 @@ if [ "$field" = test ]; then
|
|
|
518
564
|
# written) or `Q` (quoted or expansion-bearing: opaque), $2 the piece. Dies
|
|
519
565
|
# on a stale pattern; otherwise returns 0 having accepted the piece.
|
|
520
566
|
judge() {
|
|
521
|
-
[ -z "$stop" ] || return 0
|
|
522
567
|
piece=$2
|
|
523
568
|
was=$before
|
|
524
569
|
before=$piece
|
|
525
570
|
if [ -n "$redir" ]; then redir=; return 0; fi
|
|
526
571
|
[ "$1" = P ] || return 0
|
|
527
572
|
case $piece in
|
|
528
|
-
cd|pushd) stop=1; return 0 ;;
|
|
529
573
|
-*|*=*|/*|*..*) return 0 ;;
|
|
530
574
|
*'*'*|*'?'*|*'['*']'*) ;;
|
|
531
575
|
*) return 0 ;;
|
|
@@ -539,7 +583,7 @@ if [ "$field" = test ]; then
|
|
|
539
583
|
# the prefix of the files beneath it.
|
|
540
584
|
tracked=$(git -C "$repo" ls-files -- ":(glob)$piece" ":(glob)${piece%/}/**" 2>"$errf") || {
|
|
541
585
|
listed=$?
|
|
542
|
-
|
|
586
|
+
unrunnable "cannot list the files tracked in $where to check its test command's pattern '$piece' (git ls-files exit $listed): $(cat "$errf" 2>/dev/null)"
|
|
543
587
|
}
|
|
544
588
|
[ -z "$tracked" ] || return 0
|
|
545
589
|
lead=${piece%%[*?[]*}
|
|
@@ -550,15 +594,15 @@ if [ "$field" = test ]; then
|
|
|
550
594
|
case $opq in
|
|
551
595
|
0) return 0 ;;
|
|
552
596
|
1) ;;
|
|
553
|
-
*)
|
|
597
|
+
*) unrunnable "cannot ask git whether '$lead' is a symlink or a submodule in $where, to check its test command's pattern '$piece' (git ls-files exit $listed): $(cat "$errf" 2>/dev/null)" ;;
|
|
554
598
|
esac
|
|
555
599
|
done
|
|
556
600
|
ignored=0
|
|
557
601
|
git -C "$repo" check-ignore -q --no-index -- "$piece" 2>"$errf" || ignored=$?
|
|
558
602
|
case $ignored in
|
|
559
603
|
0) ;;
|
|
560
|
-
1) die "
|
|
561
|
-
*)
|
|
604
|
+
1) die "$subj test command's pattern '$piece' matches no file tracked in $where — a Test entrypoint that selects no tests passes having run nothing, $stale5; a pattern that a program resolves under its own directory option, such as git -C, must be quoted; $tail" ;;
|
|
605
|
+
*) unrunnable "cannot ask git whether $repo ignores its test command's pattern '$piece' (git check-ignore exit $ignored): $(cat "$errf" 2>/dev/null)" ;;
|
|
562
606
|
esac
|
|
563
607
|
}
|
|
564
608
|
# End the piece being read, if there is one, and judge it.
|
|
@@ -567,11 +611,14 @@ if [ "$field" = test ]; then
|
|
|
567
611
|
scan_piece=
|
|
568
612
|
scan_kind=P
|
|
569
613
|
}
|
|
614
|
+
# Refuse the Test entrypoint for the unquoted join or group $1 names.
|
|
615
|
+
joined() {
|
|
616
|
+
die "$subj $field command '$value' has an unquoted $1, which joins or groups commands — a runner-appended argument would land after the last one, not reach the Test entrypoint; write one invocation with no unquoted operator or newline (a directory option such as npm --prefix web test, pnpm -C web, make -C web, go -C web, mvn -f web, gradle -p web, or a tracked script; or sh -c 'cd web && npm test -- \"\$@\"' sh so the appended arguments reach the inner command), and list any other step in uncovered; $tail"
|
|
617
|
+
}
|
|
570
618
|
nl='
|
|
571
619
|
'
|
|
572
620
|
before=
|
|
573
621
|
redir=
|
|
574
|
-
stop=
|
|
575
622
|
scan_rest=$value
|
|
576
623
|
scan_piece=
|
|
577
624
|
scan_kind=P
|
|
@@ -650,9 +697,22 @@ if [ "$field" = test ]; then
|
|
|
650
697
|
scan_piece=$scan_piece$scan_c
|
|
651
698
|
scan_dollar ;;
|
|
652
699
|
'{'|'}'|'~') scan_kind=Q; scan_piece=$scan_piece$scan_c ;;
|
|
653
|
-
' '|' '
|
|
700
|
+
' '|' ') flush ;;
|
|
701
|
+
# A join or a group is refused on the Test entrypoint: a runner appends
|
|
702
|
+
# its arguments after the whole string, so they reach only the last
|
|
703
|
+
# command. An unquoted newline ends a command as `;` does. A
|
|
704
|
+
# redirection's own `&`, and the `<` or `>` itself, are not.
|
|
705
|
+
"$nl")
|
|
706
|
+
flush
|
|
707
|
+
joined newline ;;
|
|
654
708
|
';'|'&'|'|'|'('|')'|'<'|'>')
|
|
655
709
|
flush
|
|
710
|
+
if [ -z "$redir" ]; then
|
|
711
|
+
case $scan_c in
|
|
712
|
+
'<'|'>') ;;
|
|
713
|
+
*) joined "'$scan_c'" ;;
|
|
714
|
+
esac
|
|
715
|
+
fi
|
|
656
716
|
before=
|
|
657
717
|
case $scan_c in '<'|'>') redir=1 ;; esac ;;
|
|
658
718
|
*) scan_piece=$scan_piece$scan_c ;;
|
|
@@ -660,7 +720,7 @@ if [ "$field" = test ]; then
|
|
|
660
720
|
done
|
|
661
721
|
flush
|
|
662
722
|
if [ -n "$scan_sq" ] || [ -n "$scan_ctx" ]; then
|
|
663
|
-
die "
|
|
723
|
+
die "$subj test command leaves a quote, a backtick, a \$( or a \${ open, so sh cannot run it — $stale; $tail"
|
|
664
724
|
fi
|
|
665
725
|
fi
|
|
666
726
|
|
package/scripts/fleet-tick.mjs
CHANGED
|
@@ -54,13 +54,13 @@
|
|
|
54
54
|
// whatever its state. Also, for each in-flight `review=` token
|
|
55
55
|
// whose PR the open list does not carry, and for each ticket's
|
|
56
56
|
// latest settled `impl-*=PR#<M>` PR the open list does not carry,
|
|
57
|
-
// that PR's state (`gh
|
|
58
|
-
// be running against a finished PR, so its dangling
|
|
59
|
-
// a review unit, and a PR CLOSED unmerged prints an
|
|
60
|
-
// for its ticket. And, for each ticket holding the
|
|
61
|
-
// row on a tier mismatch, owed a replacement, or whose
|
|
62
|
-
// read CLOSED or unread, whether its issue is CLOSED (`gh
|
|
63
|
-
// view`): a closed ticket's mismatch holds nothing, its owed
|
|
57
|
+
// that PR's state (one `gh api graphql` read per hundred PRs): a
|
|
58
|
+
// review cannot be running against a finished PR, so its dangling
|
|
59
|
+
// token is not a review unit, and a PR CLOSED unmerged prints an
|
|
60
|
+
// ESCALATE line for its ticket. And, for each ticket holding the
|
|
61
|
+
// implementer row on a tier mismatch, owed a replacement, or whose
|
|
62
|
+
// keyed PR read CLOSED or unread, whether its issue is CLOSED (`gh
|
|
63
|
+
// issue view`): a closed ticket's mismatch holds nothing, its owed
|
|
64
64
|
// replacement is not in flight, and its PR prints no line.
|
|
65
65
|
// main The main checkout against the run-start baseline
|
|
66
66
|
// `.fleet/main-checkout.sha`, through main-checkout.mjs.
|
|
@@ -958,7 +958,7 @@ export function foldLedger({ rows, dispatched }, open, { lenient = false } = {})
|
|
|
958
958
|
export const pastPinDue = (st, head) => st !== undefined && st.pastPinHalt && !st.inFlight && st.reviewedHead !== null
|
|
959
959
|
&& !String(head).toLowerCase().startsWith(st.reviewedHead);
|
|
960
960
|
|
|
961
|
-
// The states
|
|
961
|
+
// The states a PR-state probe answers: anything else is no answer.
|
|
962
962
|
const PR_STATES = ["OPEN", "MERGED", "CLOSED"];
|
|
963
963
|
|
|
964
964
|
export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed = new Set(), prStates = new Map()) {
|
|
@@ -1007,7 +1007,7 @@ export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed
|
|
|
1007
1007
|
// A mismatch on the keyed member itself is no replacement of M and still
|
|
1008
1008
|
// prints. A keyed PR the open list carries is normal flow; one it does not
|
|
1009
1009
|
// is named in `prKeyed` for the caller's state probe, `prStates` its
|
|
1010
|
-
// answers (
|
|
1010
|
+
// answers (none for a PR whose probe could not answer, as by default).
|
|
1011
1011
|
// CLOSED escalates, an unanswered probe escalates as unchecked, OPEN and
|
|
1012
1012
|
// MERGED say nothing, and a ticket whose issue the caller found CLOSED
|
|
1013
1013
|
// (`closed`) says nothing either way.
|
|
@@ -1493,6 +1493,7 @@ import { isCLI } from "./is-cli.mjs";
|
|
|
1493
1493
|
import { parseArgs } from "node:util";
|
|
1494
1494
|
import { makeDie, isDigits } from "./arg.mjs";
|
|
1495
1495
|
import { gitEnv } from "./git-env.mjs";
|
|
1496
|
+
import { ghAliasedRead } from "./gh-aliased-read.mjs";
|
|
1496
1497
|
import { fleetFile, FleetDirUnresolvable, scratchRoot } from "./fleet-dir.mjs";
|
|
1497
1498
|
import { statePath, readState, writeState, assessBeat, isStalled, isReported, stallReport, stallOwner } from "./fleet-state.mjs";
|
|
1498
1499
|
import { checkMainCheckout, describe } from "./main-checkout.mjs";
|
|
@@ -1815,34 +1816,36 @@ function closedTickets(mismatched, owed, surfacing) {
|
|
|
1815
1816
|
return closed;
|
|
1816
1817
|
}
|
|
1817
1818
|
|
|
1818
|
-
// The state of each PR the tick asks gh about, one `gh
|
|
1819
|
-
// per
|
|
1820
|
-
// ledger reads as in flight yet the open list does not carry,
|
|
1821
|
-
// each ticket's keyed PR the open list does not carry (deriveRun's
|
|
1822
|
-
// `prKeyed`). The answer is OPEN, MERGED or CLOSED
|
|
1823
|
-
//
|
|
1824
|
-
//
|
|
1819
|
+
// The state of each PR the tick asks gh about, one aliased `gh api graphql`
|
|
1820
|
+
// read per hundred PRs however many readings need them: `reviews`, the PRs
|
|
1821
|
+
// whose review the ledger reads as in flight yet the open list does not carry,
|
|
1822
|
+
// and `keyed`, each ticket's keyed PR the open list does not carry (deriveRun's
|
|
1823
|
+
// `prKeyed`). The answer is OPEN, MERGED or CLOSED. A probe that could not
|
|
1824
|
+
// answer — a failed call, an alias GitHub errored on or answered with nothing,
|
|
1825
|
+
// a node carrying no state, a state that is none of the three — leaves its PR
|
|
1826
|
+
// out of the map and is disclosed, per PR, with what it leaves standing; a
|
|
1827
|
+
// call killed at GH_TIMEOUT_MS is disclosed with that bound.
|
|
1825
1828
|
function prStates(reviews, keyed) {
|
|
1826
1829
|
const states = new Map();
|
|
1827
1830
|
const reviewed = new Set(reviews);
|
|
1828
1831
|
// Two tickets can be keyed on one PR: each is named, on its one probe.
|
|
1829
1832
|
const ticketsOf = new Map();
|
|
1830
1833
|
for (const k of keyed) ticketsOf.set(k.pr, [...(ticketsOf.get(k.pr) ?? []), k.n]);
|
|
1831
|
-
|
|
1834
|
+
const numbers = [...new Set([...reviews, ...ticketsOf.keys()])].sort((a, b) => a - b);
|
|
1835
|
+
const read = ghAliasedRead(numbers, { field: "pullRequest", selection: "{state}", timeoutMs: GH_TIMEOUT_MS });
|
|
1836
|
+
for (const number of numbers) {
|
|
1837
|
+
const r = read.get(number);
|
|
1838
|
+
const st = r.node?.state;
|
|
1839
|
+
if (PR_STATES.includes(st)) {
|
|
1840
|
+
states.set(number, st);
|
|
1841
|
+
continue;
|
|
1842
|
+
}
|
|
1832
1843
|
const unconfirmed = [
|
|
1833
1844
|
...(reviewed.has(number) ? [`review on PR#${number} unconfirmed finished, its unit stands`] : []),
|
|
1834
1845
|
...(ticketsOf.get(number) ?? []).map((n) => `#${n}'s PR#${number} unconfirmed closed, printed as pr-unchecked`),
|
|
1835
1846
|
].join("; ");
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
if (r.error || r.status !== 0) {
|
|
1839
|
-
console.error(`${NAME}: ${failure(r, `gh pr view ${number}`)} — ${unconfirmed}`);
|
|
1840
|
-
continue;
|
|
1841
|
-
}
|
|
1842
|
-
let st = null;
|
|
1843
|
-
try { st = JSON.parse(r.stdout).state; } catch { /* no state: disclosed below */ }
|
|
1844
|
-
if (PR_STATES.includes(st)) states.set(number, st);
|
|
1845
|
-
else console.error(`${NAME}: gh pr view ${number} printed no PR state — ${unconfirmed}`);
|
|
1847
|
+
const why = r.unreachable === "ETIMEDOUT" ? TIMED_OUT : r.unreachable ?? (r.notFound ? "no such pull request" : "no PR state");
|
|
1848
|
+
console.error(`${NAME}: gh api graphql gave no answer for PR#${number} (${why}) — ${unconfirmed}`);
|
|
1846
1849
|
}
|
|
1847
1850
|
return states;
|
|
1848
1851
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// One GraphQL read of the current repository for many numbers at once: each
|
|
2
|
+
// number is an aliased `<field>(number:N){…}` of the one query, a hundred to a
|
|
3
|
+
// call, so a caller that would ask `gh` about every number one `gh … view` at a
|
|
4
|
+
// time asks once per hundred instead. The caller names the field
|
|
5
|
+
// (`pullRequest`, `issueOrPullRequest`) and what to select on it; what an
|
|
6
|
+
// answer means is the caller's.
|
|
7
|
+
//
|
|
8
|
+
// `gh api graphql` exits 1 when any one alias errors yet still prints the full
|
|
9
|
+
// body, `data.repository` holding every alias GitHub answered and `errors[]`
|
|
10
|
+
// naming each failed one by `path: ["repository", "<alias>"]`. So a body that
|
|
11
|
+
// carries an object `data.repository` is read alias by alias at any exit
|
|
12
|
+
// status, never as "the whole call failed".
|
|
13
|
+
//
|
|
14
|
+
// `-F owner={owner} -F name={repo}` is filled in from the environment gh sees:
|
|
15
|
+
// an inherited GH_REPO outranks the repository the cwd is bound to, and a
|
|
16
|
+
// GIT_DIR naming another repository moves it too. gitEnv({ GH_REPO: "" })
|
|
17
|
+
// scrubs both, so the read is of the repository the cwd is in.
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { gitEnv } from "./git-env.mjs";
|
|
21
|
+
|
|
22
|
+
const BATCH = 100;
|
|
23
|
+
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/** A child that did not answer, as one short reason. */
|
|
26
|
+
export function how(r) {
|
|
27
|
+
if (r.error) return r.error.code ?? r.error.message;
|
|
28
|
+
if (r.signal) return `killed by ${r.signal}`;
|
|
29
|
+
const said = String(r.stderr ?? "").trim().split("\n").at(-1);
|
|
30
|
+
return said ? `exit ${r.status}: ${said}` : `exit ${r.status}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What GitHub says of each of `numbers`, as a Map keyed by number:
|
|
35
|
+
*
|
|
36
|
+
* `{ node }` — the alias GitHub answered with an object, as parsed.
|
|
37
|
+
* `{ notFound }` — null with a NOT_FOUND error naming the alias at path
|
|
38
|
+
* length 2: GitHub's own "this number names nothing".
|
|
39
|
+
* `{ unreachable }` — no usable answer, with the reason. Any other error
|
|
40
|
+
* naming an alias, an error naming no alias (it may
|
|
41
|
+
* belong to any of the batch), a null no NOT_FOUND
|
|
42
|
+
* explains, a missing alias, and an unparseable or
|
|
43
|
+
* absent body all land here: a failed lookup is not an
|
|
44
|
+
* answer.
|
|
45
|
+
*
|
|
46
|
+
* `field` and `selection` build each alias, `n<N>:<field>(number:<N>)<selection>`;
|
|
47
|
+
* `timeoutMs` bounds each `gh` call.
|
|
48
|
+
*/
|
|
49
|
+
export function ghAliasedRead(numbers, { field, selection, timeoutMs }) {
|
|
50
|
+
const out = new Map();
|
|
51
|
+
for (let i = 0; i < numbers.length; i += BATCH) {
|
|
52
|
+
const batch = numbers.slice(i, i + BATCH);
|
|
53
|
+
const fields = batch.map((n) => `n${n}:${field}(number:${n})${selection}`).join(" ");
|
|
54
|
+
const r = spawnSync("gh", ["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}",
|
|
55
|
+
"-f", `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${fields}}}`], {
|
|
56
|
+
encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: timeoutMs, env: gitEnv({ GH_REPO: "" }),
|
|
57
|
+
});
|
|
58
|
+
let body;
|
|
59
|
+
let unparseable;
|
|
60
|
+
try {
|
|
61
|
+
body = JSON.parse(r.stdout);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (r.status === 0) unparseable = e.message;
|
|
64
|
+
}
|
|
65
|
+
const repo = body?.data?.repository;
|
|
66
|
+
const errors = body?.errors ?? [];
|
|
67
|
+
const answered = repo !== null && typeof repo === "object" && !Array.isArray(repo) && Array.isArray(errors);
|
|
68
|
+
const nothing = new Set();
|
|
69
|
+
const refused = new Map();
|
|
70
|
+
let batchWide;
|
|
71
|
+
if (answered) {
|
|
72
|
+
for (const e of errors) {
|
|
73
|
+
const path = e?.path;
|
|
74
|
+
const alias = Array.isArray(path) && path[0] === "repository" && typeof path[1] === "string" ? path[1] : null;
|
|
75
|
+
const said = `${e?.type ?? "error"}${e?.message ? `: ${e.message}` : ""}`;
|
|
76
|
+
if (alias === null) batchWide ??= said;
|
|
77
|
+
else if (e.type === "NOT_FOUND" && path.length === 2) nothing.add(alias);
|
|
78
|
+
else refused.set(alias, said);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const n of batch) {
|
|
82
|
+
const alias = `n${n}`;
|
|
83
|
+
const gone = (why) => out.set(n, { unreachable: why });
|
|
84
|
+
if (!answered) gone(unparseable === undefined ? how(r) : `${how(r)}: unparseable body: ${unparseable}`);
|
|
85
|
+
else if (refused.has(alias)) gone(refused.get(alias));
|
|
86
|
+
else if (batchWide !== undefined) gone(batchWide);
|
|
87
|
+
else if (!Object.hasOwn(repo, alias)) gone(how(r));
|
|
88
|
+
else if (repo[alias] === null) {
|
|
89
|
+
if (nothing.has(alias)) out.set(n, { notFound: true });
|
|
90
|
+
else gone("null with no NOT_FOUND error");
|
|
91
|
+
} else out.set(n, { node: repo[alias] });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
package/scripts/recipe-prove.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// reads: the deriving agent chooses all three commands by reading the repository,
|
|
6
6
|
// and this script is what turns that choice into a cache, or refuses to.
|
|
7
7
|
//
|
|
8
|
-
// recipe-prove.mjs <repo> --install <cmd> --check <cmd|true> --test <cmd> --uncovered <json>
|
|
8
|
+
// recipe-prove.mjs <repo> --install <cmd> --check <cmd|true> --test <cmd> --covered <json> --uncovered <json>
|
|
9
9
|
// [--count-line <literal> --test-count <n>]
|
|
10
10
|
// [--mutate <cmd> --mutation <description>]
|
|
11
11
|
// [--check-mutate <cmd> --check-mutation <description>]
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
// required, so a cache never stays silent about a CI step left unrun; it is
|
|
18
18
|
// written as given, as the cache's `uncovered`.
|
|
19
19
|
//
|
|
20
|
+
// `--covered` is one JSON array of `{ "step": <string>, "by": "test" | "check" }`,
|
|
21
|
+
// one per merge-gating CI step that the Test entrypoint or the Check step runs:
|
|
22
|
+
// "test" for a step the Test entrypoint runs, "check" for one the Check step
|
|
23
|
+
// runs. At least one entry is "test"; "check" is never used with `--check true`;
|
|
24
|
+
// and no step is in both `--covered` and `--uncovered`. Its shape is checked
|
|
25
|
+
// before any command runs, and it is never written to the cache: nothing here
|
|
26
|
+
// can check which steps a command runs.
|
|
27
|
+
//
|
|
20
28
|
// Every check runs in a throwaway worktree of `origin/main`, detached and
|
|
21
29
|
// removed afterwards, so neither the caller's checkout nor its uncommitted
|
|
22
30
|
// state takes part — `derivedAt` is the commit the proof ran against:
|
|
@@ -102,7 +110,7 @@ import { isDigits } from "./arg.mjs";
|
|
|
102
110
|
|
|
103
111
|
const NAME = "recipe-prove";
|
|
104
112
|
const USAGE =
|
|
105
|
-
"usage: recipe-prove.mjs <repo> --install <cmd> --check <cmd|true> --test <cmd> --uncovered <json> [--count-line <literal> --test-count <n>] [--mutate <cmd> --mutation <description>] [--check-mutate <cmd> --check-mutation <description>]";
|
|
113
|
+
"usage: recipe-prove.mjs <repo> --install <cmd> --check <cmd|true> --test <cmd> --covered <json> --uncovered <json> [--count-line <literal> --test-count <n>] [--mutate <cmd> --mutation <description>] [--check-mutate <cmd> --check-mutation <description>]";
|
|
106
114
|
const READER = join(dirname(fileURLToPath(import.meta.url)), "derive-testcmd.sh");
|
|
107
115
|
|
|
108
116
|
// Scrubbed once, for every child: an ambient GIT_DIR or GIT_WORK_TREE — set
|
|
@@ -155,7 +163,7 @@ const READER_COULD_NOT_RUN = 3;
|
|
|
155
163
|
function parseArgs(argv) {
|
|
156
164
|
const [repo, ...rest] = argv;
|
|
157
165
|
if (!repo || repo.startsWith("--")) throw cannot(USAGE);
|
|
158
|
-
const flags = new Set(["--install", "--check", "--test", "--uncovered", "--count-line", "--test-count", "--mutate", "--mutation", "--check-mutate", "--check-mutation"]);
|
|
166
|
+
const flags = new Set(["--install", "--check", "--test", "--covered", "--uncovered", "--count-line", "--test-count", "--mutate", "--mutation", "--check-mutate", "--check-mutation"]);
|
|
159
167
|
const a = {};
|
|
160
168
|
for (let i = 0; i < rest.length; i += 2) {
|
|
161
169
|
const [flag, value] = [rest[i], rest[i + 1]];
|
|
@@ -169,6 +177,7 @@ function parseArgs(argv) {
|
|
|
169
177
|
if (!a[f] || !a[f].trim()) throw cannot(`${f} needs a non-empty command — ${USAGE}`);
|
|
170
178
|
}
|
|
171
179
|
if (!("--uncovered" in a)) throw cannot(`--uncovered is required: the merge-gating CI steps neither the Test entrypoint nor the Check step runs, as one JSON array, \`[]\` for none — ${USAGE}`);
|
|
180
|
+
if (!("--covered" in a)) throw cannot(`--covered is required: the merge-gating CI steps the Test entrypoint or the Check step runs, one JSON array of { step, by } with by "test" or "check" — ${USAGE}`);
|
|
172
181
|
const uncovered = parseUncovered(a["--uncovered"]);
|
|
173
182
|
if (("--count-line" in a) !== ("--test-count" in a)) throw cannot("--count-line and --test-count go together");
|
|
174
183
|
if ("--test-count" in a && !isDigits(a["--test-count"])) {
|
|
@@ -189,6 +198,7 @@ function parseArgs(argv) {
|
|
|
189
198
|
if (!("--check-mutate" in a) || !a["--check-mutate"].trim()) throw cannot(`a Check step other than \`true\` needs --check-mutate, the command that turns it red — ${USAGE}`);
|
|
190
199
|
if (!a["--check-mutation"].trim()) throw cannot("--check-mutation needs a non-empty description");
|
|
191
200
|
}
|
|
201
|
+
checkCovered(a["--covered"], uncovered, a["--check"]);
|
|
192
202
|
return {
|
|
193
203
|
repo,
|
|
194
204
|
install: a["--install"],
|
|
@@ -204,18 +214,20 @@ function parseArgs(argv) {
|
|
|
204
214
|
};
|
|
205
215
|
}
|
|
206
216
|
|
|
217
|
+
function parseFlagJson(flag, json) {
|
|
218
|
+
try {
|
|
219
|
+
return JSON.parse(json);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
throw cannot(`${flag} is not JSON: ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
207
225
|
// The same rule derive-testcmd.sh holds the cache to, applied before any
|
|
208
226
|
// command runs, so a malformed list is a usage error rather than a proof that
|
|
209
227
|
// held and a cache the reader then refuses.
|
|
210
228
|
function parseUncovered(json) {
|
|
211
|
-
|
|
212
|
-
try {
|
|
213
|
-
list = JSON.parse(json);
|
|
214
|
-
} catch (e) {
|
|
215
|
-
throw cannot(`--uncovered is not JSON: ${e.message}`);
|
|
216
|
-
}
|
|
229
|
+
const list = parseFlagJson("--uncovered", json);
|
|
217
230
|
if (!Array.isArray(list)) throw cannot("--uncovered is not a JSON array of { step, reason } entries, `[]` for none");
|
|
218
|
-
const filled = (s) => typeof s === "string" && s.trim() !== "";
|
|
219
231
|
list.forEach((u, i) => {
|
|
220
232
|
if (u === null || typeof u !== "object" || Array.isArray(u) || !filled(u.step) || !filled(u.reason)) {
|
|
221
233
|
throw cannot(`--uncovered entry ${i + 1} is not an object with a non-empty \`step\` and \`reason\``);
|
|
@@ -224,6 +236,43 @@ function parseUncovered(json) {
|
|
|
224
236
|
return list;
|
|
225
237
|
}
|
|
226
238
|
|
|
239
|
+
const filled = (s) => typeof s === "string" && s.trim() !== "";
|
|
240
|
+
const normStep = (s) => s.trim().replace(/\s+/g, " ");
|
|
241
|
+
|
|
242
|
+
// The ledger of CI steps the Test entrypoint or the Check step runs: a JSON
|
|
243
|
+
// array of { step, by }, `by` exactly "test" or "check". Checked here, before
|
|
244
|
+
// any command runs, and never written to the cache: nothing can check which
|
|
245
|
+
// steps a command runs, only that the ledger is well-formed and agrees with
|
|
246
|
+
// `uncovered` about what it leaves out.
|
|
247
|
+
function checkCovered(json, uncovered, check) {
|
|
248
|
+
const list = parseFlagJson("--covered", json);
|
|
249
|
+
if (!Array.isArray(list)) throw cannot(`--covered is not a JSON array of { step, by } entries — ${USAGE}`);
|
|
250
|
+
list.forEach((c, i) => {
|
|
251
|
+
if (c === null || typeof c !== "object" || Array.isArray(c) || !filled(c.step) || (c.by !== "test" && c.by !== "check")) {
|
|
252
|
+
throw cannot(`--covered entry ${i + 1} is not an object with a non-empty \`step\` and a \`by\` of exactly "test" or "check"`);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
if (!list.some((c) => c.by === "test")) throw cannot(`--covered names no step with by "test": the Test entrypoint runs at least one merge-gating CI step, or there is nothing to prove`);
|
|
256
|
+
if (check === "true" && list.some((c) => c.by === "check")) throw cannot(`--covered names a step with by "check", but --check is true, so no Check step runs`);
|
|
257
|
+
const listed = new Set(uncovered.map((u) => normStep(u.step)));
|
|
258
|
+
const both = list.find((c) => listed.has(normStep(c.step)));
|
|
259
|
+
if (both) throw cannot(`the step '${both.step}' is in both --covered and --uncovered: a step that runs is never listed as uncovered`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The reader's shape check, before any command runs: `--propose` holds a
|
|
263
|
+
// proposed command to the rules the cache is held to, with no cache read and no
|
|
264
|
+
// command run, so one the cache would refuse is refused before the proof.
|
|
265
|
+
function preflight(field, command, repo, at) {
|
|
266
|
+
const args = [READER, "--propose", repo, field, command, ...(field === "test" ? ["--at", at] : [])];
|
|
267
|
+
const r = spawnSync("sh", args, { env: ENV, encoding: "utf8" });
|
|
268
|
+
if (r.error && !r.pid) throw cannot(`could not start sh to check the ${field} command: ${r.error.message}`);
|
|
269
|
+
if (r.signal) throw cannot(`the reader's sh was killed by ${r.signal}${errorCause(r.error)}, so the ${field} command was not checked`);
|
|
270
|
+
if (r.status === 0) return;
|
|
271
|
+
if (r.status === READER_COULD_NOT_RUN) throw cannot(`${r.stderr.trim()} — the reader could not run a tool it needs, so the ${field} command was not checked`);
|
|
272
|
+
if (r.status === 1) throw notProven(r.stderr.trim() || `the reader refused the ${field} command and said nothing`);
|
|
273
|
+
throw cannot(`the reader exited ${r.status} checking the ${field} command: ${r.stderr.trim()}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
227
276
|
// A git that cannot be started is no verdict, as for `sh` below: read as a
|
|
228
277
|
// failed git command, it would surface as whatever that command's failure
|
|
229
278
|
// means to its caller — a missing repository, an unfetched origin/main, a
|
|
@@ -534,6 +583,9 @@ function main(argv) {
|
|
|
534
583
|
if (!workspace) throw cannot(`cannot resolve the common git dir of ${o.repo}: ${common.err}`);
|
|
535
584
|
const sha = git(["rev-parse", "--verify", "origin/main^{commit}"], o.repo);
|
|
536
585
|
if (!sha.ok) throw cannot(`origin/main does not resolve to a commit (${sha.err}) — fetch it; the proof runs against it`);
|
|
586
|
+
// Each command's shape is checked before any command runs, and before the
|
|
587
|
+
// proof's worktree exists: a command the cache reader would refuse is refused here.
|
|
588
|
+
for (const field of ["install", "check", "test"]) preflight(field, o[field], o.repo, sha.out);
|
|
537
589
|
|
|
538
590
|
const logs = mkdtempSync(join(tmpdir(), `${NAME}-`));
|
|
539
591
|
const cache = join(workspace, ".fleet", "recipe.json");
|
|
@@ -109,6 +109,7 @@ import { fileURLToPath } from "node:url";
|
|
|
109
109
|
import { defineFlags, isDigits, makeDie, writeAll } from "./arg.mjs";
|
|
110
110
|
import { fleetFile, FleetDirUnresolvable, SCRATCH_KEY_SHAPE, scratchRoot } from "./fleet-dir.mjs";
|
|
111
111
|
import { gitEnv } from "./git-env.mjs";
|
|
112
|
+
import { ghAliasedRead, how } from "./gh-aliased-read.mjs";
|
|
112
113
|
import { isCLI } from "./is-cli.mjs";
|
|
113
114
|
import { liveOnLedger, parseMember, REVIEWER } from "./ledger-grammar.mjs";
|
|
114
115
|
|
|
@@ -116,7 +117,6 @@ const NAME = "reclaim-scratch";
|
|
|
116
117
|
const die = makeDie(NAME);
|
|
117
118
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
118
119
|
export const RETENTION_MS = 7 * DAY_MS;
|
|
119
|
-
const BATCH = 100;
|
|
120
120
|
const GH_TIMEOUT_MS = 30_000;
|
|
121
121
|
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
122
122
|
|
|
@@ -151,13 +151,6 @@ const REPORT_PR_ENTRY = /^(?:fix-pr|finisher-pr|review-pr)-([1-9][0-9]*)(?:-[a-z
|
|
|
151
151
|
const REPORT_IMPL_ENTRY = /^impl-([1-9][0-9]*)(?:-[a-z])?(?:\.check)?\.json$/;
|
|
152
152
|
const RUN_ROOT = /^run-/;
|
|
153
153
|
|
|
154
|
-
function how(r) {
|
|
155
|
-
if (r.error) return r.error.code ?? r.error.message;
|
|
156
|
-
if (r.signal) return `killed by ${r.signal}`;
|
|
157
|
-
const said = String(r.stderr ?? "").trim().split("\n").at(-1);
|
|
158
|
-
return said ? `exit ${r.status}: ${said}` : `exit ${r.status}`;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
154
|
/** The path of something under `path` (itself included) modified at or after
|
|
162
155
|
* `cutoff`, or null when everything is older. Symlinks are not followed. A
|
|
163
156
|
* directory that cannot be listed counts its own mtime only, and so does one
|
|
@@ -302,59 +295,18 @@ function claimWorktree(listing, n) {
|
|
|
302
295
|
|
|
303
296
|
// `{type, state}` per number GitHub answered (type "Issue" or "PullRequest",
|
|
304
297
|
// or null for a number that names nothing), or `{unreachable}` for a number
|
|
305
|
-
// that has no usable answer.
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
// answered by a node, or by null with a NOT_FOUND error naming it — GitHub's
|
|
309
|
-
// own "this number names nothing". Any other error naming an alias, a null
|
|
310
|
-
// no NOT_FOUND explains, and an error naming no alias (it may belong to any
|
|
311
|
-
// of the batch) leave the number unreachable: a failed lookup is not an
|
|
312
|
-
// answer.
|
|
298
|
+
// that has no usable answer. What counts as an answer is gh-aliased-read.mjs's:
|
|
299
|
+
// a number GitHub nulls with NOT_FOUND names nothing, and a failed lookup is
|
|
300
|
+
// not an answer.
|
|
313
301
|
function githubStates(numbers) {
|
|
314
302
|
const out = new Map();
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
});
|
|
322
|
-
let body;
|
|
323
|
-
let unparseable;
|
|
324
|
-
try {
|
|
325
|
-
body = JSON.parse(r.stdout);
|
|
326
|
-
} catch (e) {
|
|
327
|
-
body = undefined;
|
|
328
|
-
if (r.status === 0) unparseable = e.message;
|
|
329
|
-
}
|
|
330
|
-
const repo = body?.data?.repository;
|
|
331
|
-
const errors = body?.errors ?? [];
|
|
332
|
-
const answered = repo !== null && typeof repo === "object" && !Array.isArray(repo) && Array.isArray(errors);
|
|
333
|
-
const nothing = new Set();
|
|
334
|
-
const refused = new Map();
|
|
335
|
-
let batchWide;
|
|
336
|
-
if (answered) {
|
|
337
|
-
for (const e of errors) {
|
|
338
|
-
const path = e?.path;
|
|
339
|
-
const alias = Array.isArray(path) && path[0] === "repository" && typeof path[1] === "string" ? path[1] : null;
|
|
340
|
-
const said = `${e?.type ?? "error"}${e?.message ? `: ${e.message}` : ""}`;
|
|
341
|
-
if (alias === null) batchWide ??= said;
|
|
342
|
-
else if (e.type === "NOT_FOUND" && path.length === 2) nothing.add(alias);
|
|
343
|
-
else refused.set(alias, said);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
for (const n of batch) {
|
|
347
|
-
const alias = `n${n}`;
|
|
348
|
-
const gone = (why) => out.set(n, { unreachable: `gh api graphql gave no answer for #${n} (${why})` });
|
|
349
|
-
if (!answered) gone(unparseable === undefined ? how(r) : `${how(r)}: unparseable body: ${unparseable}`);
|
|
350
|
-
else if (refused.has(alias)) gone(refused.get(alias));
|
|
351
|
-
else if (batchWide !== undefined) gone(batchWide);
|
|
352
|
-
else if (!Object.hasOwn(repo, alias)) gone(how(r));
|
|
353
|
-
else if (repo[alias] === null) {
|
|
354
|
-
if (nothing.has(alias)) out.set(n, { type: null });
|
|
355
|
-
else gone("null with no NOT_FOUND error");
|
|
356
|
-
} else out.set(n, { type: repo[alias].__typename, state: String(repo[alias].state).toUpperCase() });
|
|
357
|
-
}
|
|
303
|
+
const read = ghAliasedRead(numbers, {
|
|
304
|
+
field: "issueOrPullRequest", selection: "{__typename ... on Issue{state} ... on PullRequest{state}}", timeoutMs: GH_TIMEOUT_MS,
|
|
305
|
+
});
|
|
306
|
+
for (const [n, r] of read) {
|
|
307
|
+
if (r.unreachable !== undefined) out.set(n, { unreachable: `gh api graphql gave no answer for #${n} (${r.unreachable})` });
|
|
308
|
+
else if (r.notFound) out.set(n, { type: null });
|
|
309
|
+
else out.set(n, { type: r.node.__typename, state: String(r.node.state).toUpperCase() });
|
|
358
310
|
}
|
|
359
311
|
return out;
|
|
360
312
|
}
|
|
@@ -95,8 +95,8 @@ The full text of each row `fleet-tick.mjs` prints and what it asks of the contro
|
|
|
95
95
|
carried on at the right tier — and once that one settles, the line reads it
|
|
96
96
|
the same way. A tier mismatch on the member that settled `PR#<M>` itself
|
|
97
97
|
replaces nothing, so the line stands beside that hold.
|
|
98
|
-
- `ESCALATE pr-unchecked #<N> PR#<M>` — the same ticket, but
|
|
99
|
-
|
|
98
|
+
- `ESCALATE pr-unchecked #<N> PR#<M>` — the same ticket, but gh could not
|
|
99
|
+
answer for PR M, so whether M closed is unknown. The maintainer's on
|
|
100
100
|
the same terms as `pr-closed`; the next tick probes again.
|
|
101
101
|
- `HOLD (…)` — the row is held and says why: draining, a tier mismatch, an
|
|
102
102
|
unchecked tier, the In-flight bound reached (`HOLD (in-flight <n>/<B>)` —
|