@feigi/fleet-ctl 3.82.27 → 3.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/recipe-prove.mjs +62 -10
|
@@ -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/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");
|