@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.
@@ -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 yours to
62
- decide, by reading CI and the repository. Members run it with their own
63
- arguments appended (`sh -c '<test> "$@"'`), so it is one invocation: an
64
- `&&` chain hands those arguments to its last command alone. Prefer a form
65
- where an appended test file or test name lands as an argument to the test
66
- runner. The cache reader refuses a command whose first word does not
67
- resolve from the repository root (a builtin, a `PATH` entry, or an
68
- executable path), one ending in `;`, `&` or a newline, one containing a
69
- word that starts with `#`, and one with an unquoted pattern that matches no
70
- file tracked from the repository root. Quote a pattern that a program
71
- resolves under its own directory option, e.g.
72
- `git -C web ls-files -- '*.test.ts'`: the reader accepts a quoted pattern
73
- unchecked.
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. Exit 2 means no verdict on the proof: it could not be
150
- attempted (no `origin/main`, not a repository, a malformed `--uncovered`) or
151
- the cache could not be written — report it, do not work around it.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.82.27",
3
+ "version": "3.83.0",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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
- # (`cd sub && make test`) keeps its meaning, with the
204
- # runner's own arguments appended as "$@". It reaches the runner's source
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, the repository or the arguments; 3 a
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
- case $# in
140
- 2) ;;
141
- 4) [ "$3" = --at ] || die "$usage"
142
- rev=$4
143
- case $rev in ''|-*) die "--at needs a commit, got '$rev' — $usage" ;; esac ;;
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|uncovered) ;;
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 "the Recipe cache at $cache is invalid: its $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; $derive" ;;
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 "the Recipe cache at $cache is invalid: its $field command '$value' contains a '#' word — a runner-appended argument would be swallowed as a comment rather than reaching the Test entrypoint; $derive" ;;
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 "the Recipe cache at $cache is invalid: its $field command names no command, only assignments — $derive"
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 "the Recipe cache at $cache is invalid: its $field command '$1' is not found or not executable from $repo — a Recipe that cannot run is stale, not a finding; $derive"
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. The scan stops judging at a `cd` or `pushd` piece,
461
- # past which a pattern no longer resolves from <repo>.
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
- die "cannot read the tree of $rev in $repo (git read-tree exit $listed): $(cat "$errf" 2>/dev/null)"
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
- die "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)"
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
- *) die "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)" ;;
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 "the Recipe cache at $cache is invalid: its test command's pattern '$piece' matches no file tracked in $where — a Test entrypoint that selects no tests passes having run nothing, so the Recipe is stale, not a finding; a pattern that a program resolves under its own directory option, such as git -C, must be quoted; $derive" ;;
561
- *) die "cannot ask git whether $repo ignores its test command's pattern '$piece' (git check-ignore exit $ignored): $(cat "$errf" 2>/dev/null)" ;;
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
- ' '|' '|"$nl") flush ;;
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 "the Recipe cache at $cache is invalid: its test command leaves a quote, a backtick, a \$( or a \${ open, so sh cannot run it — a Recipe that cannot run is stale, not a finding; $derive"
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
 
@@ -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
- let list;
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");