tickmarkr 2.8.0 → 2.8.2

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.
@@ -1,45 +1,59 @@
1
1
  #!/bin/bash
2
- # grade-ci.sh <run-id> <expected-count> [tag] — grade EVERY job of a public CI run's closed inventory from
3
- # its JOB LOG. Grade only at run-end. A missing/in-progress/empty log is UNREADABLE, never evidence of green.
4
- # Exit 0 GREEN, 1 RED, 2 UNREADABLE, 3 INFRA_KILLED: a job the GitHub runner shut down or cancelled with
5
- # no failed or timed-out test (the classifier's class, D-897) — never green, never a pass, only unproven.
2
+ # grade-ci.sh <run-id> <expected-sha> <repo-dir> [tag] — grade EVERY job of a public CI run of <expected-sha>
3
+ # from GitHub's structured run record and each job's uploaded proof group (v2.8.2 T3, ANL-022 §7.2b; queue rows
4
+ # 165 and 148). NO LOG TEXT IS READ: no job log, no step label, no `##[error]` annotation decides anything.
5
+ # Grade only at run-end. Exit 0 GREEN, 1 RED, 2 UNREADABLE, 3 INFRA_KILLED: a cancelled run, job or declared
6
+ # step with no failed declared step — never green, never a pass, only unproven.
6
7
  # UNREADABLE dominates a mixed result, then RED: a failure anywhere dominates a kill.
7
8
  #
9
+ # INPUT: <expected-sha> is 40 lowercase hex naming a commit in <repo-dir> — the mirror clone (RELEASING.md step
10
+ # 4) or the Release workflow's own checkout — else exit 2 before any gh call. No count argument: the manifest of
11
+ # tracked test files is recomputed from that commit.
12
+ # ONE READ: `gh run view <run-id> --json headSha,status,conclusion,jobs`. IDENTITY: UNREADABLE, before any
13
+ # download, unless the run's headSha is <expected-sha> and its status is completed.
14
+ #
8
15
  # Two CLOSED inventories (v2.7.8 T4; the workflow renders these names): serial {test, test-macos}; sharded,
9
16
  # for os in ubuntu-latest and macos-latest, `suite <os> k/4` for k 1..4, `single-fork <os>`, `aggregate <os>`.
10
17
  # A run holding any sharded name is graded against the sharded inventory, else the serial one. The first
11
18
  # line is `INVENTORY form=<serial|sharded> declared=<n> observed=<n>`; each missing, duplicated or unknown
12
19
  # name prints `INVENTORY_DEFECT kind=<missing|duplicate|unknown> job=<json-quoted name>` and makes the run
13
- # UNREADABLE: a job the inventory does not name is never skipped.
20
+ # UNREADABLE: a job the inventory does not name is never skipped. The sharded proof-group selection is ANL-022
21
+ # §7.4's, so until it ships a sharded run reads UNREADABLE after its INVENTORY line.
14
22
  #
15
- # A verdict line is read only from the log lines whose gh step column equals its declared step, exactly
16
- # once, else UNREADABLE — a line a test or any other step prints, before or after the real one, never decides:
17
- # COUNT_ORACLE <GREEN|RED> expected=<n|UNREADABLE> actual=<n|UNREADABLE> step `Assert collected test-file count`
18
- # COVERAGE_FLOORS verdict=… floors=… failing=… (T1's final floors line) step `Recheck coverage floors`
19
- # SET_ORACLE verdict=… expected=… actual=… defects=… (T3's set oracle) step `Assert collected test-file set`
20
- # ROLES — the shared classifier (classify-vitest-log.sh) stays the one log verdict:
21
- # serial the base rule (count oracle GREEN at the expected count, the classifier given that count
22
- # reading CLEAN or RPC_ONLY, passed+skipped equal to it) AND exactly one COVERAGE_FLOORS
23
- # verdict=GREEN, so a dropped floors step reads UNREADABLE.
24
- # shard, single-fork
25
- # GREEN only on conclusion success whose whole log the classifier, given no count, reads CLEAN
26
- # or RPC_ONLY: their wrapper already applied the shard contract in-job.
27
- # aggregate GREEN only on conclusion success with exactly one COVERAGE_FLOORS verdict=GREEN, exactly one
28
- # `SET_ORACLE verdict=GREEN expected=N actual=N defects=0`, and the lines of its step
29
- # `Merge coverage (one threshold check per OS)` classified CLEAN or RPC_ONLY.
30
- # Exit annotations: SERIAL keeps the one exception run-ci-vitest.sh grants (a failure explained ONLY by steps
31
- # that exited exactly 1 on a log of their own the classifier reads RPC_ONLY); any other failing annotation is
32
- # never GREEN. Shard, single-fork and aggregate admit none — their wrapper applied the exception in-job.
33
- # INFRA_KILLED is the base rule for every role.
23
+ # Per serial job, every part from the same read or the job's own artifact:
24
+ # steps each DECLARED step below present exactly once in .jobs[].steps[] (missing or duplicated ->
25
+ # UNREADABLE); GREEN needs every one `success`. A cancelled job or declared step is INFRA_KILLED
26
+ # unless a declared step concluded `failure` (then RED); the steps a kill skipped do not count.
27
+ # conclusions GREEN also needs the job's and the run's conclusion `success`, so a failing undeclared step (an
28
+ # action's post-step) reds the grade; a cancelled run is INFRA_KILLED, and any other run conclusion
29
+ # no job's grade accounts for is RED.
30
+ # proof `gh run download <run-id> -n proof-<job>` into a fresh directory (missing -> UNREADABLE), then
31
+ # the grader's own checkout's `tsx scripts/ci/cli.ts proof --head <expected-sha> --repo <repo-dir>
32
+ # <dir>` (that checkout is the git toplevel holding this script; without its cli.ts, exit 2):
33
+ # the group's original records read CLEAN or RPC_ONLY at <expected-sha>, the tracked set recomputed
34
+ # from the commit holds every file once and finished, and the floors sidecar reads GREEN.
35
+ # Output: the INVENTORY lines, `RUN head=… expected=… status=… conclusion=…`, per job `<job>: steps=[…]
36
+ # proof=[<PROOF line>]` then `<job>: <GRADE>`, and a final `VERDICT rc=<n> …`.
34
37
  set -u
35
38
 
36
- run=${1:?run id required}
37
- expected=${2:?expected tracked test-file count required}
38
- tag=${3:-$run}
39
+ [ "$#" -ge 3 ] && [ "$#" -le 4 ] \
40
+ || { echo "UNREADABLE: usage: grade-ci.sh <run-id> <expected-sha> <repo-dir> [tag]"; exit 2; }
41
+ run=$1
42
+ sha=$2
43
+ repo_dir=$3
44
+ tag=${4:-$run}
39
45
  here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
40
- repo=${TKR_GRADE_CI_REPO:-alzahrani-khalid/tickmarkr}
46
+ # the grader's own checkout — the same for the canonical copy and the installed .claude/skills one
47
+ checkout=$(git -C "$here" rev-parse --show-toplevel 2>/dev/null)
48
+ gh_repo=${TKR_GRADE_CI_REPO:-alzahrani-khalid/tickmarkr}
41
49
  out_dir=${TKR_GRADE_CI_DIR:-${TKR_STATE_DIR:-.tickmarkr}/overseer/diag}
42
- mkdir -p "$out_dir" || { echo "UNREADABLE: cannot create log directory $out_dir"; exit 2; }
50
+
51
+ [[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo "UNREADABLE: the expected head is not 40 lowercase hex"; exit 2; }
52
+ git -C "$repo_dir" cat-file -e "$sha^{commit}" 2>/dev/null \
53
+ || { echo "UNREADABLE: $sha is not a commit in $repo_dir"; exit 2; }
54
+ [ -n "$checkout" ] && [ -f "$checkout/scripts/ci/cli.ts" ] \
55
+ || { echo "UNREADABLE: the grader's own checkout holds no scripts/ci/cli.ts to recompute the proof"; exit 2; }
56
+ mkdir -p "$out_dir" || { echo "UNREADABLE: cannot create the proof directory $out_dir"; exit 2; }
43
57
 
44
58
  serial_inventory=$'test\ntest-macos'
45
59
  sharded_inventory=$(for os in ubuntu-latest macos-latest; do
@@ -47,21 +61,24 @@ sharded_inventory=$(for os in ubuntu-latest macos-latest; do
47
61
  echo "single-fork $os"
48
62
  echo "aggregate $os"
49
63
  done)
50
- COUNT_STEP="Assert collected test-file count"
51
- FLOORS_STEP="Recheck coverage floors"
52
- SET_STEP="Assert collected test-file set"
53
- MERGE_STEP="Merge coverage (one threshold check per OS)"
54
- COUNT_FORMAT='COUNT_ORACLE (GREEN|RED) expected=([0-9]+|UNREADABLE) actual=([0-9]+|UNREADABLE)'
55
- FLOORS_FORMAT='COVERAGE_FLOORS verdict=(GREEN|RED|UNREADABLE) floors=([0-9]+|UNREADABLE) failing=([0-9]+|UNREADABLE)'
56
- SET_FORMAT='SET_ORACLE verdict=(GREEN|RED|UNREADABLE) expected=([0-9]+|UNREADABLE) actual=([0-9]+|UNREADABLE) defects=[0-9]+'
64
+ # the serial job's declared steps as GitHub names them (an unnamed `run:` step is `Run <command>`)
65
+ serial_steps='Run npm ci
66
+ Run npm run build
67
+ Run npm run lint
68
+ Run coverage proof (parallel suite project)
69
+ Recheck coverage floors
70
+ Run single-fork projects (independent of the step above)
71
+ Assert collected test-file set
72
+ Upload proof group'
73
+ cli=("$checkout/node_modules/.bin/tsx" "$checkout/scripts/ci/cli.ts")
57
74
 
58
75
  verdict=0
59
76
  mark_unreadable() { verdict=2; }
60
77
  mark_red() { [ "$verdict" -eq 2 ] || verdict=1; }
61
78
  mark_killed() { [ "$verdict" -ne 0 ] || verdict=3; }
62
- field() { printf '%s\n' "$classified" | sed -n "s/^VITEST_LOG.* $1=\([^ ]*\).*/\1/p"; }
79
+ finish() { echo "VERDICT rc=$verdict (0=all GREEN 1=RED 2=UNREADABLE 3=INFRA_KILLED)"; exit "$verdict"; }
63
80
  count() { printf '%s' "$1" | grep -c ''; }
64
- # json <name> — one JSON string; awk -v decodes gh's @tsv escapes (\t \n \r \\) first
81
+ # json <name> — one JSON string; awk -v decodes the @tsv escapes (\t \n \r \\) first
65
82
  json() {
66
83
  LC_ALL=C awk -v s="$1" 'BEGIN {
67
84
  for (i = 1; i < 32; i++) esc[sprintf("%c", i)] = sprintf("\\u%04x", i)
@@ -71,28 +88,37 @@ json() {
71
88
  }'
72
89
  }
73
90
  defect() { echo "INVENTORY_DEFECT kind=$1 job=$(json "$2")"; mark_unreadable; }
74
- # step_log <log> <step> — the job-log lines gh's step column attributes to <step>, as gh printed them
75
- step_log() { awk -F'\t' -v s="$2" 'NF >= 3 && $2 == s' "$1"; }
76
- # verdict_line <log> <step> <family> <format> — the ONE <family> line <step> printed, in <format>;
77
- # else MISSING, MULTIPLE(n) or MALFORMED, each UNREADABLE
78
- verdict_line() {
79
- local lines
80
- lines=$(step_log "$1" "$2" | awk -v esc="$(printf '\033')" '{
81
- gsub(/\^\[\[[0-9;]*m/, ""); gsub(esc "\\[[0-9;]*m", "")
82
- sub(/^[^\t]*\t[^\t]*\t[0-9T:.-]+Z ?/, ""); sub(/[ \t\r]+$/, ""); print
83
- }' | grep -E "^$3( |\$)")
84
- case $(count "$lines") in
85
- 0) echo MISSING ;;
86
- 1) if printf '%s\n' "$lines" | grep -qxE "$4"; then printf '%s\n' "$lines"; else echo MALFORMED; fi ;;
87
- *) echo "MULTIPLE($(count "$lines"))" ;;
88
- esac
89
- }
90
- readable() { case $1 in MISSING|MULTIPLE*|MALFORMED) return 1 ;; esac; }
91
- floors_green() { printf '%s\n' "$1" | grep -qxE 'COVERAGE_FLOORS verdict=GREEN floors=[0-9]+ failing=0'; }
92
91
 
93
- jobs=$(gh run view "$run" --repo "$repo" --json jobs \
94
- --jq '.jobs[] | [.databaseId, .name, .status, (.conclusion // "")] | @tsv') \
95
- || { echo "UNREADABLE: job list"; exit 2; }
92
+ run_json=$(gh run view "$run" --repo "$gh_repo" --json headSha,status,conclusion,jobs) \
93
+ || { echo "UNREADABLE: gh run view $run"; mark_unreadable; finish; }
94
+ # the read as @tsv-escaped rows — RUN <head> <status> <conclusion>, JOB <index> <name> <status> <conclusion>,
95
+ # STEP <job index> <name> <conclusion>; a null or empty value prints `null`; any other shape fails closed
96
+ rows=$(printf '%s' "$run_json" | node -e '
97
+ const field = (v) => v === null || v === undefined || v === "" ? "null"
98
+ : String(v).replace(/\\/g, "\\\\").replace(/\t/g, "\\t").replace(/\n/g, "\\n").replace(/\r/g, "\\r");
99
+ const scalar = (v) => v === null || v === undefined || typeof v === "string" || typeof v === "number";
100
+ let text = "";
101
+ process.stdin.setEncoding("utf8").on("data", (d) => { text += d; }).on("end", () => {
102
+ try {
103
+ const run = JSON.parse(text);
104
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
105
+ if (!object(run) || !Array.isArray(run.jobs) || ![run.headSha, run.status, run.conclusion].every(scalar)) throw 0;
106
+ const out = [["RUN", run.headSha, run.status, run.conclusion]];
107
+ run.jobs.forEach((job, i) => {
108
+ if (!object(job) || ![job.name, job.status, job.conclusion].every(scalar)) throw 0;
109
+ if (job.steps !== undefined && job.steps !== null && !Array.isArray(job.steps)) throw 0;
110
+ out.push(["JOB", i, job.name, job.status, job.conclusion]);
111
+ for (const step of job.steps ?? []) {
112
+ if (!object(step) || ![step.name, step.conclusion].every(scalar)) throw 0;
113
+ out.push(["STEP", i, step.name, step.conclusion]);
114
+ }
115
+ });
116
+ process.stdout.write(out.map((row) => row.map(field).join("\t") + "\n").join(""));
117
+ } catch { process.exit(2); }
118
+ });') || { echo "UNREADABLE: gh run view $run returned no readable run record"; mark_unreadable; finish; }
119
+ IFS=$'\t' read -r _ head status conclusion <<< "$(printf '%s\n' "$rows" | sed -n $'s/^RUN\t/-\t/p')"
120
+ jobs=$(printf '%s\n' "$rows" | sed -n $'s/^JOB\t//p')
121
+ steps=$(printf '%s\n' "$rows" | sed -n $'s/^STEP\t//p')
96
122
  # an empty job list is an inventory like any other: serial, observed=0, every declared job missing
97
123
 
98
124
  names=$(printf '%s\n' "$jobs" | cut -f2)
@@ -113,126 +139,96 @@ while IFS= read -r name; do
113
139
  printf '%s\n' "$declared" | grep -qxF -- "$name" || defect unknown "$name"
114
140
  done < <([ -z "$jobs" ] || printf '%s\n' "$names" | awk '!seen[$0]++')
115
141
 
116
- [ -z "$jobs" ] || while IFS=$'\t' read -r id name status conclusion; do
117
- # an unknown job has no role to grade: its INVENTORY_DEFECT already made the run UNREADABLE
118
- printf '%s\n' "$declared" | grep -qxF -- "$name" || continue
119
- case "$name" in
120
- test|test-macos) role=serial ;;
121
- suite\ *) role=shard ;;
122
- single-fork\ *) role=single-fork ;;
123
- *) role=aggregate ;;
124
- esac
142
+ if [ "$form" = sharded ]; then
143
+ echo "SHARDED: UNREADABLE (the sharded proof-group selection is ANL-022 §7.4's and has not shipped)"
144
+ mark_unreadable
145
+ finish
146
+ fi
125
147
 
126
- echo "$name: role=$role status=$status conclusion=${conclusion:-none} job=$id"
127
- if [ "$status" != "completed" ]; then
128
- echo "$name: UNREADABLE (job has not reached run-end)"
129
- mark_unreadable
130
- continue
131
- fi
148
+ echo "RUN head=$head expected=$sha status=$status conclusion=$conclusion"
149
+ if [ "$head" != "$sha" ]; then
150
+ echo "RUN: UNREADABLE (the run's head is not the expected head; nothing downloaded)"
151
+ mark_unreadable
152
+ finish
153
+ fi
154
+ if [ "$status" != completed ]; then
155
+ echo "RUN: UNREADABLE (the run has not reached run-end; nothing downloaded)"
156
+ mark_unreadable
157
+ finish
158
+ fi
132
159
 
133
- log="$out_dir/CI-$tag-$(printf '%s' "$name" | tr -c 'A-Za-z0-9._-' '-').log"
134
- gh run view --repo "$repo" --job "$id" --log > "$log" 2>/dev/null
135
- if [ ! -s "$log" ]; then
136
- echo "$name: UNREADABLE (empty log)"
160
+ [ -z "$jobs" ] || while IFS=$'\t' read -r index name job_status job_conclusion; do
161
+ # an unknown or duplicated job has no role to grade: its INVENTORY_DEFECT already made the run UNREADABLE
162
+ [ "$(printf '%s\n' "$names" | grep -cxF -- "$name")" -eq 1 ] && printf '%s\n' "$declared" | grep -qxF -- "$name" || continue
163
+
164
+ echo "$name: status=$job_status conclusion=$job_conclusion"
165
+ if [ "$job_status" != completed ]; then
166
+ echo "$name: UNREADABLE (job has not reached run-end)"
137
167
  mark_unreadable
138
168
  continue
139
169
  fi
140
170
 
141
- files=$(grep -oE 'Test Files .*' "$log" | sed 's/[[:space:]]*$//')
142
- # OBS-1184: the log verdict comes from the ONE classifier the public CI wrapper also uses
143
- # (scripts/run-ci-vitest.sh), so the badge and this grade cannot disagree. Its rules — complete
144
- # summaries, failed/timed-out tests, coverage-threshold misses, and the RPC-timeout-only exception
145
- # to "any unhandled error is RED" (OBS-1058) — live in classify-vitest-log.sh, stated once there.
146
- # A serial job's expected count is its declared file contract: both steps' summaries must collect it all.
147
- # A shard or single-fork job carries no count: its wrapper held it to its plan in-job.
148
- if [ "$role" = serial ]; then
149
- classified=$(bash "$here/classify-vitest-log.sh" "$log" "$expected" 2>&1)
150
- else
151
- classified=$(bash "$here/classify-vitest-log.sh" "$log" 2>&1)
152
- fi
153
- log_verdict=$(field verdict)
154
- passed=$(field passed); skipped=$(field skipped); failed=$(field failed); timedout=$(field timedout)
155
- unhandled=$(field unhandled); rpc=$(field runner_rpc_timeouts); coverage=$(field coverage_misses)
156
- kills=$(field runner_kills)
157
- errors=$(grep -oE '##\[error\].*' "$log" | sort | uniq -c | sed 's/^ *//' | tr '\n' ';')
158
- # The commands' own outcomes stand, as in the wrapper: GREEN needs a success conclusion with no step
159
- # exit annotation, or — SERIAL only — a failure explained ONLY by steps that exited exactly 1 on a log
160
- # of their own (gh's step column) that the classifier calls RPC_ONLY. Any other code (2, a signal's
161
- # 137), any annotation on another role, an unexplained failure, or a cancelled, timed-out or missing
162
- # conclusion is RED.
163
- exits=$(grep -E '##\[error\]Process completed with exit code [0-9]+' "$log")
164
- case "$conclusion" in
165
- success) outcome=ok ;;
166
- failure) if [ -n "$exits" ]; then outcome=ok; else outcome=unexplained-failure; fi ;;
167
- *) outcome="conclusion-${conclusion:-none}" ;;
168
- esac
169
- if [ -n "$exits" ]; then
170
- while IFS= read -r exit_line; do
171
- code=$(printf '%s\n' "$exit_line" | sed -E 's/.*Process completed with exit code ([0-9]+).*/\1/')
172
- if [ "$code" != 1 ] || [ "$role" != serial ]; then outcome="exit-$code"; break; fi
173
- step=$(printf '%s\n' "$exit_line" | awk -F'\t' 'NF >= 3 { print $2 }')
174
- step_log "$log" "$step" > "$log.step"
175
- case $(bash "$here/classify-vitest-log.sh" "$log.step" 2>&1) in
176
- "VITEST_LOG verdict=RPC_ONLY "*) ;;
177
- *) outcome="exit-1-not-rpc-only"; break ;;
178
- esac
179
- done <<< "$exits"
180
- fi
181
-
182
- oracles=
183
- case $role in
184
- serial)
185
- oracle=$(verdict_line "$log" "$COUNT_STEP" COUNT_ORACLE "$COUNT_FORMAT")
186
- floors=$(verdict_line "$log" "$FLOORS_STEP" COVERAGE_FLOORS "$FLOORS_FORMAT")
187
- oracles="oracle=[$oracle] floors=[$floors] " ;;
188
- aggregate)
189
- floors=$(verdict_line "$log" "$FLOORS_STEP" COVERAGE_FLOORS "$FLOORS_FORMAT")
190
- set_oracle=$(verdict_line "$log" "$SET_STEP" SET_ORACLE "$SET_FORMAT")
191
- step_log "$log" "$MERGE_STEP" > "$log.merge"
192
- merge=$(bash "$here/classify-vitest-log.sh" "$log.merge" 2>&1 | sed -n 's/^VITEST_LOG verdict=\([^ ]*\) reason=\([^ ]*\) .*/\1 \2/p')
193
- oracles="floors=[$floors] set=[$set_oracle] merge=[$merge] " ;;
194
- esac
171
+ own=$(printf '%s\n' "$steps" | awk -F'\t' -v i="$index" '$1 == i')
172
+ listed=; unread=; failed=; unproven=; killed=
173
+ [ "$job_conclusion" != cancelled ] || killed=1
174
+ while IFS= read -r step; do
175
+ matches=$(printf '%s\n' "$own" | awk -F'\t' -v s="$step" '$2 == s { print $3 }')
176
+ case $(count "$matches") in
177
+ 0) outcome=MISSING; unread=1 ;;
178
+ 1) outcome=$matches ;;
179
+ *) outcome="MULTIPLE($(count "$matches"))"; unread=1 ;;
180
+ esac
181
+ case $outcome in
182
+ success|MISSING|MULTIPLE*) ;;
183
+ cancelled) killed=1 ;;
184
+ failure) failed=1 ;;
185
+ *) unproven=1 ;; # skipped, or no conclusion: never success
186
+ esac
187
+ listed="$listed${listed:+ | }$step: $outcome"
188
+ done <<< "$serial_steps"
195
189
 
196
- echo "$name: ${oracles}files=[$(printf '%s' "$files" | tr '\n' '|')] passed=$passed skipped=$skipped failed=$failed timedout=$timedout unhandled=$unhandled runner_rpc_timeouts=$rpc coverage_misses=$coverage runner_kills=${kills:-?} log=[$(field reason)] outcome=[$outcome] errors=[$errors]"
197
- # A killed runner skips the oracle steps, so a missing verdict line is the kill's, not an unreadable log:
198
- # INFRA_KILLED unless a step's own failing exit or a failed/timed-out test dominates it (then RED).
199
- if [ "$log_verdict" = INFRA_KILLED ] && [ "${outcome#exit-}" = "$outcome" ]; then
200
- echo "$name: INFRA_KILLED"
201
- mark_killed
202
- continue
203
- elif [ "${kills:-0}" -gt 0 ] 2>/dev/null; then
204
- grade=RED
205
- elif [ -z "$log_verdict" ]; then
206
- grade=UNREADABLE
190
+ proof=
191
+ grade=GREEN
192
+ if [ -n "$unread" ]; then
193
+ grade=UNREADABLE; proof="not downloaded: a declared step is missing or duplicated"
194
+ elif [ -n "$killed" ]; then
195
+ grade=INFRA_KILLED; [ -z "$failed" ] || grade=RED; proof="not downloaded: the job was cancelled"
207
196
  else
208
- case $log_verdict in CLEAN|RPC_ONLY) log_ok=1 ;; *) log_ok= ;; esac
209
- grade=RED
210
- case $role in
211
- serial)
212
- if ! readable "$oracle" || ! readable "$floors" || [ -z "$files" ]; then
213
- grade=UNREADABLE
214
- elif [ "$oracle" = "COUNT_ORACLE GREEN expected=$expected actual=$expected" ] && floors_green "$floors" \
215
- && [ -n "$log_ok" ] && [ "$outcome" = ok ] && [ $((passed + skipped)) -eq "$expected" ]; then
216
- grade=GREEN
217
- fi ;;
218
- shard|single-fork)
219
- if [ "$outcome" = ok ] && [ "$conclusion" = success ] && [ -n "$log_ok" ]; then grade=GREEN; fi ;;
220
- aggregate)
221
- if ! readable "$floors" || ! readable "$set_oracle"; then
222
- grade=UNREADABLE
223
- elif [ "$outcome" = ok ] && [ "$conclusion" = success ] && floors_green "$floors" \
224
- && [ "$set_oracle" = "SET_ORACLE verdict=GREEN expected=$expected actual=$expected defects=0" ] \
225
- && { [ "${merge%% *}" = CLEAN ] || [ "${merge%% *}" = RPC_ONLY ]; }; then
226
- grade=GREEN
227
- fi ;;
228
- esac
197
+ dir=$(mktemp -d "$out_dir/CI-$(printf '%s' "$tag" | tr -c 'A-Za-z0-9._-' '-')-proof-$name.XXXXXX") \
198
+ && gh run download "$run" --repo "$gh_repo" -n "proof-$name" -D "$dir" > /dev/null 2>&1
199
+ if [ $? -ne 0 ]; then
200
+ grade=UNREADABLE; proof="MISSING: artifact proof-$name not downloaded"
201
+ else
202
+ proof_out=$("${cli[@]}" proof --head "$sha" --repo "$repo_dir" "$dir" 2> "$dir.stderr")
203
+ proof_status=$?
204
+ printf '%s\n' "$proof_out" | sed -n "s/^PROOF_DEFECT /$name: PROOF_DEFECT /p"
205
+ proof=$(printf '%s\n' "$proof_out" | grep '^PROOF ')
206
+ word=$(printf '%s\n' "$proof" | sed -n 's/^PROOF dir=.* verdict=\([A-Z]*\) head=.*/\1/p')
207
+ # fail closed: one PROOF line, and its word believed only beside its own exit status
208
+ if [ "$(count "$proof")" -ne 1 ]; then
209
+ grade=UNREADABLE; proof="UNREADABLE: cli.ts proof exited $proof_status with $(count "$proof") PROOF lines"
210
+ elif [ "$word:$proof_status" = GREEN:0 ] || [ "$word:$proof_status" = RED:1 ]; then
211
+ grade=$word
212
+ else
213
+ grade=UNREADABLE
214
+ fi
215
+ fi
216
+ if [ "$grade" != UNREADABLE ] && { [ -n "$failed$unproven" ] || [ "$job_conclusion" != success ]; }; then grade=RED; fi
229
217
  fi
218
+ echo "$name: steps=[$listed] proof=[$proof]"
230
219
  echo "$name: $grade"
231
220
  case $grade in
232
221
  RED) mark_red ;;
233
222
  UNREADABLE) mark_unreadable ;;
223
+ INFRA_KILLED) mark_killed ;;
234
224
  esac
235
225
  done <<< "$jobs"
236
226
 
237
- echo "VERDICT rc=$verdict (0=all GREEN 1=RED 2=UNREADABLE 3=INFRA_KILLED)"
238
- exit "$verdict"
227
+ # the run's own conclusion: GREEN needs success; a cancelled run is INFRA_KILLED; any other conclusion is RED
228
+ # unless a job's grade above already accounts for it (a killed job concludes its run `failure` too)
229
+ case $conclusion in
230
+ success) ;;
231
+ cancelled) mark_killed ;;
232
+ *) if [ "$verdict" -eq 0 ]; then echo "RUN: RED (run conclusion $conclusion with every job GREEN)"; mark_red; fi ;;
233
+ esac
234
+ finish
@@ -1,133 +0,0 @@
1
- #!/bin/bash
2
- # classify-vitest-log.sh <log> [expected-files] — the ONE strict Vitest log classifier
3
- # (OBS-1184). The public CI wrapper (scripts/run-ci-vitest.sh) and grade-ci.sh both call it, so the badge
4
- # and the grade cannot disagree. Reads a raw Vitest log or a gh job log (gh's "job<TAB>step<TAB>timestamp "
5
- # prefix and ANSI styling — the raw ESC byte and gh's `^[` rendering of it — are stripped) and prints ONE line:
6
- # VITEST_LOG verdict=<CLEAN|RPC_ONLY|RED|INFRA_KILLED> reason=<why> summaries=S passed=P skipped=K failed=F
7
- # timedout=T unhandled=U runner_rpc_timeouts=R coverage_misses=C signals=G terminal_errors=E runner_kills=X
8
- # unhandled counts the unhandled errors that are NOT runner RPC timeouts. Exit 0 CLEAN or RPC_ONLY, 1 RED,
9
- # 3 INFRA_KILLED.
10
- #
11
- # INFRA_KILLED (D-897): the GitHub runner's own shutdown or cancellation annotation ("##[error]The runner
12
- # has received a shutdown signal…" / "##[error]The operation was canceled.") with no failed or timed-out
13
- # test and no coverage-threshold miss. It is never green and never passes a gate — the proof was not
14
- # finished — but it is not a test red either. Those failures dominate a kill (RED); a kill excuses only
15
- # the cut-off or short log it leaves behind (runner_kills counts the annotations either way). A failure
16
- # the reporter already printed before the kill cut its summary off counts too: a dot-reporter "x" (its
17
- # stream has no newline, so a test's "stdout | …"/"stderr | …" header may finish the same line), a
18
- # default-reporter "×" or failed module record "❯ file (N tests…)" (❯ marks a failed module even with
19
- # no failed test — a collect or hook error), a " FAIL " badge or the "Failed Tests/Suites N" banner (only
20
- # beside a kill — without one, the complete summaries' own counts decide). Only one printed after the last
21
- # complete summary counts: a complete summary's own counts settle every line before it. Test output never
22
- # counts: a console block runs from its header until the reporter speaks again — a dot run, a ⎯ banner or
23
- # a default-reporter module record (✓/❯/↓ file (N tests…)) right after a blank line, or the next summary —
24
- # so blank lines inside a test's output do not end it.
25
- # ponytail: a test that prints, after a blank line of its own, a line the reporter could have printed is
26
- # indistinguishable from the reporter; only that deliberate forgery escapes the block.
27
- #
28
- # The declared contract (D-893), when a caller supplies one: expected-files is the number of test files
29
- # the run must collect — the summaries' "(N)" totals must sum to it exactly. The caller derives it from
30
- # the declared projects' own candidate-tree discovery, so a never-started declared project is a short
31
- # count: project presence comes from that discovery, never from log text a test can print. A short count
32
- # is RED reason=partial: a complete summary of the projects that happened to start proves nothing about
33
- # the one that did not. With or without a contract, a summary whose finished files (failed + passed +
34
- # skipped + todo) fall short of its own "(N)" is RED reason=partial too: Vitest's total counts the queued
35
- # and running files an abort left unfinished.
36
- #
37
- # RED unless every Vitest summary is complete (Test Files, Tests and Duration lines, and every
38
- # "Unhandled Errors" block closed by the summary that follows it — a cut-off log is RED), with no
39
- # failed file or test, no timed-out test or hook, no coverage-threshold ERROR, no npm signal line,
40
- # no unhandled error other than a vitest-worker RPC timeout, and no terminal error: a structural
41
- # "Unhandled Error", "Startup Error" or "Collect Error" banner outside Vitest's counted block — how
42
- # Vitest reports an exception thrown after the summary, such as a coverage-reporting failure, before it
43
- # exits 1 (terminal_errors counts them; an RPC timeout never offsets one). RPC_ONLY means unhandled
44
- # errors were present and EVERY one is such a timeout (OBS-1058: the starved 2-core runner talking,
45
- # not a test).
46
- #
47
- # Which timeouts count: only one that is ITSELF an unhandled entry — the first payload line under a
48
- # per-error header, nothing later. The header is Vitest's own structural line — a run of ⎯ on each side
49
- # of "Unhandled Error", nothing else on the line — so prose that merely contains the words never opens
50
- # a window, and the payload must be Vitest's whole line (`Error: [vitest-worker]: Timeout calling
51
- # "<method>"`), so an assertion quoting it stays RED. Per-error headers are honoured only inside
52
- # Vitest's own block — after its structural "Unhandled Errors" line and "Vitest caught N" tally, until
53
- # the file summary — and each block's count never exceeds its N, so a test that prints a header and a
54
- # timeout to stdout forges nothing. The tallies must also sum to the summaries' own "Errors N" lines
55
- # (Vitest prints both from one list), so a forged or a missing block is RED as well.
56
- set -u
57
-
58
- log=${1:?log path required}
59
- fields='summaries=0 passed=0 skipped=0 failed=0 timedout=0 unhandled=0 runner_rpc_timeouts=0 coverage_misses=0 signals=0 terminal_errors=0 runner_kills=0'
60
- if [ ! -s "$log" ] || [ ! -r "$log" ]; then
61
- echo "VITEST_LOG verdict=RED reason=unreadable $fields"
62
- exit 1
63
- fi
64
-
65
- esc=$(printf '\033')
66
- awk -v esc="$esc" -v expected="${2:-}" '
67
- function num(s, word) { return match(s, "[0-9]+ " word) ? substr(s, RSTART, RLENGTH) + 0 : 0 }
68
- function close_block() { rpc += (found < cap ? found : cap); armed = 0; pending = 0 }
69
- {
70
- line = $0
71
- gsub(/\^\[\[[0-9;]*m/, "", line); gsub(esc "\\[[0-9;]*m", "", line)
72
- sub(/^[^\t]*\t[^\t]*\t[0-9T:.-]+Z[ ]?/, "", line)
73
- if (line ~ /^[ \t]*(·|x|\*|-)*(stdout|stderr) \| /) {
74
- if (line ~ /^[ \t]*(·|\*|-)*x/) reported++
75
- console = 1
76
- } else if (after_blank && line ~ /^[ \t]*((·|x|\*|-)*(·|x)(·|x|\*|-)*|(⎯)+( .* (⎯)+)?|(✓|❯|↓) .* [(][0-9]+ tests?( [|] [0-9]+ (failed|skipped))*[)].*)[ \t]*$/) console = 0
77
- if (!console && line ~ /^[ \t]*((·|\*|-)*x(·|x|\*|-)*|× .*|❯ .* [(][0-9]+ tests?( [|] [0-9]+ (failed|skipped))*[)].*|FAIL[ \t].*|(⎯)+ Failed (Tests|Suites) [0-9]+ (⎯)+)[ \t]*$/) reported++
78
- after_blank = line !~ /[^ \t]/
79
- }
80
- line ~ /^##\[error\](The runner has received a shutdown signal|The operation was cancell?ed)/ { kills++ }
81
- # The Vitest timeout message carries its millisecond count; a test that PRINTS a fingerprint normalized
82
- # to #ms (the daemon OBS-1106 notifications) is output, not a timed-out test. A real timeout also fails
83
- # its test, so the summary failed count still reds it.
84
- line ~ /(Test|Hook) timed out in [0-9]+ ?ms/ { timedout++ }
85
- line ~ /ERROR: Coverage for .* does not meet .*threshold/ { coverage++ }
86
- line ~ /^npm (error|ERR!) signal / { signals++ }
87
- line ~ /^(⎯)+ Unhandled Errors (⎯)+[ \t]*$/ { if (armed) close_block(); opening = 1; next }
88
- opening && line !~ /[^ \t]/ { next }
89
- opening {
90
- opening = 0
91
- if (line ~ /^Vitest caught [0-9]+ unhandled error/) { armed = 1; found = 0; cap = num(line, "unhandled"); tally += cap }
92
- else untallied++
93
- }
94
- line ~ /^[ \t]*Test Files[ \t]/ {
95
- if (armed) close_block()
96
- summaries++; in_summary = 1; saw_tests = 0; console = 0
97
- failed += num(line, "failed"); passed += num(line, "passed"); skipped += num(line, "skipped")
98
- finished = num(line, "failed") + num(line, "passed") + num(line, "skipped") + num(line, "todo")
99
- total = match(line, /\([0-9]+\)[ \t]*$/) ? substr(line, RSTART + 1) + 0 : -1
100
- if (total >= 0) collected += total
101
- if (finished != total) unfinished++
102
- next
103
- }
104
- in_summary && line ~ /^[ \t]*Tests[ \t]/ { saw_tests = 1; failed_tests += num(line, "failed"); next }
105
- in_summary && line ~ /^[ \t]*Errors[ \t]+[0-9]+ error/ { summary_errors += num(line, "error"); next }
106
- in_summary && line ~ /^[ \t]*Duration[ \t]/ { if (saw_tests) { complete++; reported = 0 }; in_summary = 0; next }
107
- armed && line ~ /^(⎯)+ Unhandled Error (⎯)+[ \t]*$/ { pending = 1; next }
108
- line ~ /^(⎯)+ (Unhandled|Startup|Collect) Error (⎯)+[ \t]*$/ { terminal++; next }
109
- pending && line ~ /[^ \t]/ { pending = 0; if (line ~ /^Error: \[vitest-worker\]: Timeout calling "[A-Za-z]+"[ \t]*$/) found++ }
110
- END {
111
- unhandled = tally + untallied
112
- if (summary_errors > unhandled) unhandled = summary_errors
113
- other = unhandled - rpc
114
- partial = unfinished || (expected != "" && collected != expected + 0)
115
- # a kill always names a class: the failure that dominates it, or the kill itself
116
- if (kills) reason = (failed || failed_tests || reported) ? "failed" : timedout ? "timed-out" : coverage ? "coverage-threshold" : "runner-shutdown"
117
- else if (summaries == 0) reason = "no-summary"
118
- else if (complete != summaries || opening || armed) reason = "truncated"
119
- else if (failed || failed_tests) reason = "failed"
120
- else if (timedout) reason = "timed-out"
121
- else if (partial) reason = "partial"
122
- else if (coverage) reason = "coverage-threshold"
123
- else if (signals) reason = "signal"
124
- else if (terminal) reason = "terminal-error"
125
- else if (other > 0) reason = "unhandled-error"
126
- else if (tally + untallied != summary_errors) reason = "unhandled-mismatch"
127
- verdict = reason == "runner-shutdown" ? "INFRA_KILLED" : (reason != "" ? "RED" : (unhandled > 0 ? "RPC_ONLY" : "CLEAN"))
128
- if (reason == "") reason = "none"
129
- printf "VITEST_LOG verdict=%s reason=%s summaries=%d passed=%d skipped=%d failed=%d timedout=%d unhandled=%d runner_rpc_timeouts=%d coverage_misses=%d signals=%d terminal_errors=%d runner_kills=%d\n", \
130
- verdict, reason, summaries, passed, skipped, failed, timedout, other, rpc, coverage, signals, terminal, kills
131
- exit (verdict == "INFRA_KILLED" ? 3 : verdict == "RED")
132
- }
133
- ' "$log"