@feigi/fleet-ctl 3.23.7 → 3.23.9
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/package.json +1 -1
- package/scripts/cell-readout.mjs +9 -1
- package/scripts/cell-readout.test.mjs +28 -0
- package/scripts/reap-cherry-check-prose.test.mjs +80 -0
- package/scripts/reap.sh +21 -18
- package/scripts/ticket-router.test.mjs +1 -1
- package/scripts/tier-outcomes.mjs +23 -3
- package/scripts/tier-outcomes.test.mjs +80 -3
package/package.json
CHANGED
package/scripts/cell-readout.mjs
CHANGED
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
// `fleet-implementer-<cell>` definition was most recently added, ruled by its
|
|
44
44
|
// last tier-outcomes.tsv row (a `+`-joined `ticket` field rules each ticket
|
|
45
45
|
// it names); a ticket counts once however many Pulls it took. A verdict FAILS
|
|
46
|
-
// the quality floor on `minted_false_claim=yes` or `closed_own_ticket=no
|
|
46
|
+
// the quality floor on `minted_false_claim=yes` or `closed_own_ticket=no`; a
|
|
47
|
+
// counted ruling holding anything but `yes` or `no` in either column, blank
|
|
48
|
+
// included, is refused rather than read as a pass.
|
|
47
49
|
// X is to be withdrawn once it has at least STOP.verdicts verdicts and
|
|
48
50
|
// floor failures ÷ verdicts is at least STOP.failRate.
|
|
49
51
|
//
|
|
@@ -56,6 +58,7 @@ import { isCLI } from "./is-cli.mjs";
|
|
|
56
58
|
import { CELL, POLICY_CELL } from "./ledger-grammar.mjs";
|
|
57
59
|
import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
|
|
58
60
|
import { parseFeatures } from "./pr-cost.mjs";
|
|
61
|
+
import { VERDICT, VERDICT_COLUMNS } from "./tier-outcomes.mjs";
|
|
59
62
|
|
|
60
63
|
const NAME = "cell-readout";
|
|
61
64
|
export const GATE = Object.freeze({ comparisons: 10, runDates: 5 });
|
|
@@ -122,6 +125,8 @@ export function readout({ features, members }) {
|
|
|
122
125
|
* `[{ ticket, pr, run_date, closed_own_ticket, minted_false_claim, failed }]`
|
|
123
126
|
* sorted by ticket, `run_date` the ruling's; `stop` is whether the stopping
|
|
124
127
|
* rule withdraws the cell. `verdicts` are parsed tier-outcomes.tsv rows.
|
|
128
|
+
* Throws on a counted ruling whose `closed_own_ticket` or `minted_false_claim`
|
|
129
|
+
* is not `yes` or `no`.
|
|
125
130
|
*/
|
|
126
131
|
export function stoppingRule({ features, members, verdicts, added }) {
|
|
127
132
|
const byKey = new Map(members.map((m) => [key(m.session, m.agent), m]));
|
|
@@ -132,6 +137,9 @@ export function stoppingRule({ features, members, verdicts, added }) {
|
|
|
132
137
|
const tickets = judged.get(p.chosen_cell);
|
|
133
138
|
if (!tickets || p.run_date < added[p.chosen_cell] || !ruling.has(p.ticket) || !admissibleMember(p, byKey)) continue;
|
|
134
139
|
const v = ruling.get(p.ticket);
|
|
140
|
+
for (const c of VERDICT_COLUMNS) {
|
|
141
|
+
if (!VERDICT.includes(v[c])) throw new Error(`ticket #${p.ticket} (PR #${v.pr}): ${c} is '${v[c]}', expected yes or no`);
|
|
142
|
+
}
|
|
135
143
|
const failed = v.minted_false_claim === "yes" || v.closed_own_ticket === "no";
|
|
136
144
|
tickets.set(p.ticket, {
|
|
137
145
|
ticket: p.ticket, pr: v.pr, run_date: v.run_date,
|
|
@@ -441,3 +441,31 @@ test("stopping rule: a ticket counts once, judged by its last tier-outcomes row,
|
|
|
441
441
|
assert.equal(at.verdicts.find((v) => v.ticket === second.ticket).failed, true);
|
|
442
442
|
assert.equal(at.stop, true);
|
|
443
443
|
});
|
|
444
|
+
|
|
445
|
+
// A verdict that is neither `yes` nor `no` matches neither floor half, so read
|
|
446
|
+
// as a pass it would lower the failure rate and keep the cell alive unreported.
|
|
447
|
+
test("stopping rule: a counted ruling whose closed_own_ticket or minted_false_claim is not exactly yes or no is refused, naming the ticket", () => {
|
|
448
|
+
for (const [col, value] of [["closed_own_ticket", "No"], ["minted_false_claim", "YES"], ["closed_own_ticket", ""], ["minted_false_claim", ""], ["closed_own_ticket", "yes "]]) {
|
|
449
|
+
const w = world();
|
|
450
|
+
addVerdicts(w, "smol-high", 10, 0);
|
|
451
|
+
const bad = w.verdicts[3];
|
|
452
|
+
bad[col] = value;
|
|
453
|
+
assert.throws(() => judged(w, { "smol-high": "2026-09-01" }, "smol-high"),
|
|
454
|
+
new RegExp(`ticket #${bad.ticket} \\(PR #${bad.pr}\\): ${col} is '${value}', expected yes or no`), `${col}=${JSON.stringify(value)}`);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// What the refusal must ACCEPT: a malformed ruling on a ticket the rule does not
|
|
459
|
+
// count, and every yes/no combination on one it does.
|
|
460
|
+
test("stopping rule: a malformed ruling on a ticket with no counted Pull is not read, and all four yes/no pairs are", () => {
|
|
461
|
+
const w = world();
|
|
462
|
+
addVerdicts(w, "smol-high", 4, 0);
|
|
463
|
+
[["yes", "no"], ["yes", "yes"], ["no", "no"], ["no", "yes"]].forEach(([closed, minted], i) => {
|
|
464
|
+
Object.assign(w.verdicts[i], { closed_own_ticket: closed, minted_false_claim: minted });
|
|
465
|
+
});
|
|
466
|
+
w.verdicts.push(verdict({ ticket: "1381", pr: "1392", closed_own_ticket: "", minted_false_claim: "" }));
|
|
467
|
+
w.verdicts.push(verdict({ ticket: "7", pr: "8", closed_own_ticket: "No", minted_false_claim: "YES" }));
|
|
468
|
+
const at = judged(w, { "smol-high": "2026-09-01" }, "smol-high");
|
|
469
|
+
assert.equal(at.verdicts.length, 4);
|
|
470
|
+
assert.deepEqual(at.verdicts.map((v) => v.failed), [false, true, true, true]);
|
|
471
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// reap.sh's branch sweep runs more checks than `git cherry` before its
|
|
2
|
+
// `git update-ref --no-deref -d`: the worktree registry and listing checks,
|
|
3
|
+
// the dirty check, the `worktree remove` refusal and the `wt_holding` re-read
|
|
4
|
+
// can each keep the branch. The cherry check is the only one of them that
|
|
5
|
+
// asks whether the tip is merged, so a comment saying the delete is
|
|
6
|
+
// "authorized by this check and by nothing else" is false as written. The
|
|
7
|
+
// component doc said reap deletes with `-D`; it deletes with `update-ref -d`.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { bullet, paragraph, phrase, runAbove, stripHashGutter } from "./prose-pin.mjs";
|
|
13
|
+
|
|
14
|
+
const REPO = join(import.meta.dirname, "..", "..");
|
|
15
|
+
const SCRIPT = readFileSync(join(REPO, "plugin", "scripts", "reap.sh"), "utf8");
|
|
16
|
+
const DOC = readFileSync(join(REPO, "docs", "components", "reaping-and-liveness.md"), "utf8");
|
|
17
|
+
|
|
18
|
+
// The comment block above the branch sweep's cherry probe, and the one above
|
|
19
|
+
// its cherry scan: the two blocks that justify why the cherry check fails
|
|
20
|
+
// closed.
|
|
21
|
+
const probeComment = () =>
|
|
22
|
+
stripHashGutter(runAbove(SCRIPT, 'if ! cherry=$(git cherry "$base_rev" "$tip"', "reap.sh cherry probe", "#"));
|
|
23
|
+
const scanComment = () =>
|
|
24
|
+
stripHashGutter(
|
|
25
|
+
runAbove(SCRIPT, `if ! grep_probe "$cherry" '^+'; then keep "$b"`, "reap.sh cherry scan", "#"),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const NOTHING_ELSE = phrase("authorized by this check and by nothing else");
|
|
29
|
+
|
|
30
|
+
for (const [name, comment] of [
|
|
31
|
+
["cherry-probe", probeComment],
|
|
32
|
+
["cherry-scan", scanComment],
|
|
33
|
+
]) {
|
|
34
|
+
test(`reap.sh: the ${name} comment does not call the cherry check the delete's sole authorizer`, () => {
|
|
35
|
+
assert.doesNotMatch(
|
|
36
|
+
comment(),
|
|
37
|
+
NOTHING_ELSE,
|
|
38
|
+
`reap.sh's ${name} comment says the delete is authorized by the cherry check and by nothing else — the worktree checks and \`wt_holding\` can stop the delete too`,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("reap.sh: the cherry-probe comment does not quote the retired \"By nothing else\" sentence", () => {
|
|
44
|
+
assert.doesNotMatch(
|
|
45
|
+
probeComment(),
|
|
46
|
+
phrase('"By nothing else" bounds'),
|
|
47
|
+
"reap.sh's cherry-probe comment says \"By nothing else\" bounds what ELSE authorizes the delete — the worktree checks and `wt_holding` can stop the delete too, and the sentence restates the sole-authorizer claim",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("reap.sh: the cherry comments still say why an unanswerable probe must keep", () => {
|
|
52
|
+
// Input the pins above must ACCEPT: the fail-closed argument the old
|
|
53
|
+
// sentence carried survives, restated as what makes the cherry check
|
|
54
|
+
// unique — it is the only check that asks whether the tip is merged.
|
|
55
|
+
assert.match(probeComment(), phrase("this is the only one that asks whether `$tip` is merged"));
|
|
56
|
+
assert.match(probeComment(), phrase("never a `$tip` that is unmerged — so an unanswerable probe must KEEP"));
|
|
57
|
+
assert.match(scanComment(), phrase("no check between here and the delete below asks whether the branch is merged"));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const reapStep = () =>
|
|
61
|
+
bullet(DOC, "1. **Reap after every merge pass**", "2. **Release a claim", "reaping-and-liveness.md reap step");
|
|
62
|
+
// The doc's last list item, so the blank-line bound is the end of the file.
|
|
63
|
+
const reapAndRelease = () =>
|
|
64
|
+
paragraph(
|
|
65
|
+
DOC,
|
|
66
|
+
"- **Reap and release are two different scripts on purpose.**",
|
|
67
|
+
"reaping-and-liveness.md reap-and-release choice",
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
test("reaping-and-liveness.md: the reap step names the delete as a compare-and-swap `update-ref -d`, not `-D`", () => {
|
|
71
|
+
const step = reapStep();
|
|
72
|
+
assert.match(step, phrase("compare-and-swap `git update-ref -d`"));
|
|
73
|
+
assert.doesNotMatch(step, /`-D`/, "reaping-and-liveness.md's reap step says the delete is `-D` — reap.sh deletes with `git update-ref -d`");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("reaping-and-liveness.md: the reap-and-release choice names reap's delete as `update-ref -d`, not `-D`", () => {
|
|
77
|
+
const choice = reapAndRelease();
|
|
78
|
+
assert.match(choice, phrase("Reap authorizes a destructive `git update-ref -d`"));
|
|
79
|
+
assert.doesNotMatch(choice, /`-D`/, "reaping-and-liveness.md says reap authorizes a `-D` — reap.sh deletes with `git update-ref -d`");
|
|
80
|
+
});
|
package/scripts/reap.sh
CHANGED
|
@@ -938,23 +938,26 @@ for b in $gone_branches; do
|
|
|
938
938
|
# enough) prints nothing, grep sees empty input and exits 1 — the identical
|
|
939
939
|
# verdict a genuinely clean cherry produces, so "merged" is indistinguishable
|
|
940
940
|
# from "the probe could not answer". An unmerged branch is never the ambiguous
|
|
941
|
-
# one: its `+` line makes grep exit 0 and always keeps.
|
|
942
|
-
#
|
|
943
|
-
#
|
|
944
|
-
#
|
|
945
|
-
#
|
|
941
|
+
# one: its `+` line makes grep exit 0 and always keeps. Of the checks that
|
|
942
|
+
# must pass before the delete, this is the only one that asks whether `$tip`
|
|
943
|
+
# is merged — the worktree checks below and the `worktree remove` refusal
|
|
944
|
+
# can each keep the branch, but none of them looks at its commits, and the
|
|
945
|
+
# compare-and-swap refuses a ref that moved off `$tip`, never a `$tip` that
|
|
946
|
+
# is unmerged — so an unanswerable probe must KEEP, the same fail-closed
|
|
947
|
+
# shape the worktree `status` check below already uses.
|
|
946
948
|
# `refs/heads/$b`, never a bare `$b`, where the tip is read above. Restoring
|
|
947
949
|
# the bare name makes a branch that shares its name with a tag ambiguous AS A
|
|
948
950
|
# REV again, and git resolves an ambiguous one by preferring refs/tags/ over
|
|
949
951
|
# refs/heads/ (measured, git 2.50.1 Apple Git-155). This probe would then
|
|
950
952
|
# answer about the TAG's commit while the delete below removes the BRANCH —
|
|
951
953
|
# and a tag sitting on a merged commit reports clean for a branch whose
|
|
952
|
-
# commits exist nowhere else.
|
|
953
|
-
#
|
|
954
|
-
# on a fixture, the enumeration fix alone turned a branch
|
|
955
|
-
# currently KEEPS into `REAPED`, at exit 0, with an empty
|
|
956
|
-
# changes nothing for an ordinary branch — both spellings
|
|
957
|
-
# commit — and it is the same key the worktree lookup below
|
|
954
|
+
# commits exist nowhere else. No other check before the delete asks whether
|
|
955
|
+
# those commits are merged, so that reads straight through to destroying
|
|
956
|
+
# them: measured on a fixture, the enumeration fix alone turned a branch
|
|
957
|
+
# this script currently KEEPS into `REAPED`, at exit 0, with an empty
|
|
958
|
+
# kept[]. Qualifying changes nothing for an ordinary branch — both spellings
|
|
959
|
+
# name the same commit — and it is the same key the worktree lookup below
|
|
960
|
+
# already builds.
|
|
958
961
|
#
|
|
959
962
|
# `$base_rev` is the other side of the same rule, and it was missing until
|
|
960
963
|
# #924: `$base` reached this `git cherry` exactly as BASE_REF spelled it, so a
|
|
@@ -963,10 +966,10 @@ for b in $gone_branches; do
|
|
|
963
966
|
# deleted the BRANCH — measured, an unmerged [gone] branch whose commit
|
|
964
967
|
# existed nowhere else REAPED at exit 0 with an empty kept[]. The
|
|
965
968
|
# qualification is built at the top of the file, where its own comment
|
|
966
|
-
# records why the accept-list beside it is what makes prefixing sound.
|
|
967
|
-
#
|
|
968
|
-
# check itself can be wrong — which is why BOTH revs it
|
|
969
|
-
# qualified. #634
|
|
969
|
+
# records why the accept-list beside it is what makes prefixing sound. Being
|
|
970
|
+
# the only merge check bounds what ELSE could catch an unmerged branch, not
|
|
971
|
+
# whether this check itself can be wrong — which is why BOTH revs it
|
|
972
|
+
# consumes are qualified. #634
|
|
970
973
|
if ! cherry=$(git cherry "$base_rev" "$tip" 2>&1); then
|
|
971
974
|
keep "$b" "cherry probe failed — cannot tell if merged: $(printf '%s' "$cherry" | tr '\n' ' ')"
|
|
972
975
|
continue
|
|
@@ -981,9 +984,9 @@ for b in $gone_branches; do
|
|
|
981
984
|
# What "grep's is the only status left to take" missed, and what this shape
|
|
982
985
|
# exists for: taking it is not the same as READING it. grep answers three
|
|
983
986
|
# ways, and until #1419 the `if` had two arms — an rc 2 scan that never
|
|
984
|
-
# examined `$cherry` fell into the merged arm, and
|
|
985
|
-
#
|
|
986
|
-
# a merge status no tool had established. `grep_probe` splits the two
|
|
987
|
+
# examined `$cherry` fell into the merged arm, and no check between here and
|
|
988
|
+
# the delete below asks whether the branch is merged. So the deletion went
|
|
989
|
+
# ahead on a merge status no tool had established. `grep_probe` splits the two
|
|
987
990
|
# questions; the answers are ruled on here, in the order that makes the
|
|
988
991
|
# unanswerable case fail CLOSED like every other could-not-check in this
|
|
989
992
|
# file: scan first, verdict second.
|
|
@@ -660,7 +660,7 @@ test("--check stays green when a verdict or member row lands after the fit, for
|
|
|
660
660
|
const fitted = readFileSync(p.table, "utf8");
|
|
661
661
|
assert.equal(JSON.parse(fitted).fitted_through, "2026-10-01");
|
|
662
662
|
// The ruling lands later and flips the ticket to a failure, and a fix round books against it.
|
|
663
|
-
writeFileSync(p.verdicts, tsv(VERDICT_COLUMNS, [verdictRow({ pr: "11", ticket: "1" }), verdictRow({ run_date: "2026-10-09", pr: "11", ticket: "1", minted_false_claim: "yes" })], true));
|
|
663
|
+
writeFileSync(p.verdicts, `# ${tsv(VERDICT_COLUMNS, [verdictRow({ pr: "11", ticket: "1" }), verdictRow({ run_date: "2026-10-09", pr: "11", ticket: "1", minted_false_claim: "yes" })], true)}`);
|
|
664
664
|
writeFileSync(p.members, tsv(MEMBER_COLUMNS, [...members, { run_date: "2026-10-09", agent: "fix-pr-11", member: "fix-pr-11", ticket: "1", pr: "11" }].map(memberRow), true));
|
|
665
665
|
const r = cli(["--check", ...p.fitArgs]);
|
|
666
666
|
assert.equal(r.status, 0, r.stderr);
|
|
@@ -63,7 +63,8 @@ const LEDGER_SCRIPT = join(SCRIPT_DIR, "ledger.mjs");
|
|
|
63
63
|
|
|
64
64
|
// ---------------------------------------------------------------------------
|
|
65
65
|
// pure core — exercised through the CLI by tier-outcomes.test.mjs; only
|
|
66
|
-
// COLUMNS and TIER_SWITCH_DATE are imported directly, the rest
|
|
66
|
+
// COLUMNS, LEGACY_WIDTH and TIER_SWITCH_DATE are imported directly, the rest
|
|
67
|
+
// through argv
|
|
67
68
|
// ---------------------------------------------------------------------------
|
|
68
69
|
|
|
69
70
|
// APPENDED TO, never inserted into: every awk read-out in the file's header
|
|
@@ -81,6 +82,11 @@ export const TIER_SWITCH_DATE = "2026-09-29";
|
|
|
81
82
|
|
|
82
83
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
83
84
|
const BASE_DEFINITION = "fleet-implementer";
|
|
85
|
+
// The two quality-floor columns `append` writes. Every reader fails the floor
|
|
86
|
+
// on exactly `minted_false_claim=yes` or `closed_own_ticket=no`, so any other
|
|
87
|
+
// spelling would read as a pass.
|
|
88
|
+
export const VERDICT = Object.freeze(["yes", "no"]);
|
|
89
|
+
export const VERDICT_COLUMNS = Object.freeze(["closed_own_ticket", "minted_false_claim"]);
|
|
84
90
|
|
|
85
91
|
// `fleet-implementer` -> `default`, `fleet-implementer-<x>` -> `<x>`, anything
|
|
86
92
|
// else (a generic `task`, a blank pre-#1066 subagent_type) -> null.
|
|
@@ -94,6 +100,11 @@ export function shortName(definition) {
|
|
|
94
100
|
// Data rows as objects keyed by COLUMNS, plus the raw `line`. A row of any
|
|
95
101
|
// width but the legacy or the full one is refused: a tab typed into `note`
|
|
96
102
|
// shifts every field after it, and a reader keyed by position cannot tell.
|
|
103
|
+
// So is a verdict column holding anything but `yes`, `no` or blank — blank
|
|
104
|
+
// because a row backfilled without a ruling leaves both empty, so a row with
|
|
105
|
+
// exactly one of the two blank is refused too: it was never written whole, and
|
|
106
|
+
// a blanked `closed_own_ticket=no` or `minted_false_claim=yes` would read as a
|
|
107
|
+
// pass.
|
|
97
108
|
export function parseTierOutcomes(text) {
|
|
98
109
|
return String(text ?? "").split("\n")
|
|
99
110
|
.filter((l) => l.trim() && !l.startsWith("#"))
|
|
@@ -104,6 +115,15 @@ export function parseTierOutcomes(text) {
|
|
|
104
115
|
}
|
|
105
116
|
const row = { line };
|
|
106
117
|
COLUMNS.forEach((c, i) => { row[c] = cells[i] ?? ""; });
|
|
118
|
+
for (const c of VERDICT_COLUMNS) {
|
|
119
|
+
if (row[c] !== "" && !VERDICT.includes(row[c])) {
|
|
120
|
+
throw new Error(`malformed row: ${c} is '${row[c]}', expected yes, no or blank — ${line.slice(0, 60)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const [first, second] = VERDICT_COLUMNS;
|
|
124
|
+
if ((row[first] === "") !== (row[second] === "")) {
|
|
125
|
+
throw new Error(`malformed row: ${first} is '${row[first]}' but ${second} is '${row[second]}', expected both blank or both yes or no — ${line.slice(0, 60)}`);
|
|
126
|
+
}
|
|
107
127
|
return row;
|
|
108
128
|
});
|
|
109
129
|
}
|
|
@@ -388,8 +408,8 @@ function append(pr, paths) {
|
|
|
388
408
|
for (const [col, value] of Object.entries(fields)) {
|
|
389
409
|
if (/[\t\r\n]/.test(value)) die(`--${col.replaceAll("_", "-")} holds a tab or newline — it would shift every field after it`);
|
|
390
410
|
}
|
|
391
|
-
for (const col of
|
|
392
|
-
if (!
|
|
411
|
+
for (const col of VERDICT_COLUMNS) {
|
|
412
|
+
if (!VERDICT.includes(fields[col])) die(`--${col.replaceAll("_", "-")} must be yes or no, got '${fields[col]}'`);
|
|
393
413
|
}
|
|
394
414
|
if (fields.sizing && !["light", "heavy"].includes(fields.sizing)) die(`--sizing must be light or heavy, got '${fields.sizing}'`);
|
|
395
415
|
for (const col of ["loc", "files"]) {
|
|
@@ -10,7 +10,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync
|
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { COLUMNS, TIER_SWITCH_DATE } from "./tier-outcomes.mjs";
|
|
13
|
+
import { COLUMNS, LEGACY_WIDTH, TIER_SWITCH_DATE } from "./tier-outcomes.mjs";
|
|
14
14
|
import { COLUMNS as MEMBER_COLUMNS } from "./member-outcomes.mjs";
|
|
15
15
|
import { writeExecStub } from "./exec-stub.mjs";
|
|
16
16
|
|
|
@@ -29,8 +29,8 @@ function memberRow({ member, ticket, type, role = "implementer" }) {
|
|
|
29
29
|
return MEMBER_COLUMNS.map((c) => r[c]).join("\t");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function tierRow({ date = TIER_SWITCH_DATE, pr, ticket, tier }) {
|
|
33
|
-
return [date, pr, ticket, "routine", tier,
|
|
32
|
+
function tierRow({ date = TIER_SWITCH_DATE, pr, ticket, tier, closed = "yes", minted = "no" }) {
|
|
33
|
+
return [date, pr, ticket, "routine", tier, closed, minted, "a note with spaces", "light", "production", "40", "2"].join("\t");
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function ledgerText(rows = [], dispatched = []) {
|
|
@@ -275,6 +275,24 @@ test("append: --class is refused as an unknown flag before anything is written",
|
|
|
275
275
|
assert.deepEqual(f.ghCalls(), [], "refused before gh was asked");
|
|
276
276
|
});
|
|
277
277
|
|
|
278
|
+
test("append: a verdict that is not exactly yes or no is refused naming the flag, before gh is asked or anything is written", (t) => {
|
|
279
|
+
for (const [flag, closed, minted] of [
|
|
280
|
+
["closed-own-ticket", "YES", "no"],
|
|
281
|
+
["closed-own-ticket", "yes ", "no"],
|
|
282
|
+
["minted-false-claim", "yes", "No"],
|
|
283
|
+
["minted-false-claim", "yes", "maybe"],
|
|
284
|
+
]) {
|
|
285
|
+
const f = fixture(t, { members: [{ member: "impl-10", ticket: 10, type: "fleet-implementer" }] });
|
|
286
|
+
const before = readFileSync(f.tier, "utf8");
|
|
287
|
+
const r = f.run("append", "20", "--closed-own-ticket", closed, "--minted-false-claim", minted, "--note", "n");
|
|
288
|
+
const got = flag === "closed-own-ticket" ? closed : minted;
|
|
289
|
+
assert.equal(r.code, 2, `${flag}=${JSON.stringify(got)}\n${r.stdout}${r.stderr}`);
|
|
290
|
+
assert.ok(r.stderr.includes(`--${flag} must be yes or no, got '${got}'`), r.stderr);
|
|
291
|
+
assert.equal(readFileSync(f.tier, "utf8"), before);
|
|
292
|
+
assert.deepEqual(f.ghCalls(), [], "refused before gh was asked");
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
278
296
|
// ---------------------------------------------------------------------------
|
|
279
297
|
// check
|
|
280
298
|
// ---------------------------------------------------------------------------
|
|
@@ -467,6 +485,65 @@ test("check: a missing tier-outcomes.tsv file fails loudly instead of passing 0
|
|
|
467
485
|
assert.match(r.stderr, /no .*tier-outcomes\.tsv — no rows to check/);
|
|
468
486
|
});
|
|
469
487
|
|
|
488
|
+
// Every reader of the file judges the floor as `minted_false_claim === "yes" ||
|
|
489
|
+
// closed_own_ticket === "no"`, so any other spelling would read as a pass.
|
|
490
|
+
test("check: a verdict column that is not yes, no or blank is refused as malformed, on full and legacy rows", (t) => {
|
|
491
|
+
for (const [col, row] of [
|
|
492
|
+
["closed_own_ticket", tierRow({ pr: 21, ticket: 11, tier: "default", closed: "No" })],
|
|
493
|
+
["minted_false_claim", tierRow({ pr: 21, ticket: 11, tier: "default", minted: "YES" })],
|
|
494
|
+
["minted_false_claim", tierRow({ pr: 21, ticket: 11, tier: "default", minted: "no " })],
|
|
495
|
+
["closed_own_ticket", tierRow({ date: PRE_SWITCH, pr: 21, ticket: 11, tier: "opus", closed: "y" }).split("\t").slice(0, LEGACY_WIDTH).join("\t")],
|
|
496
|
+
]) {
|
|
497
|
+
const f = fixture(t, {
|
|
498
|
+
members: [{ member: "impl-10", ticket: 10, type: "fleet-implementer" }],
|
|
499
|
+
tierRows: [tierRow({ pr: 20, ticket: 10, tier: "default" }), row],
|
|
500
|
+
});
|
|
501
|
+
const r = f.run("check");
|
|
502
|
+
assert.equal(r.code, 2, `${row}\n${r.stdout}${r.stderr}`);
|
|
503
|
+
const value = row.split("\t")[COLUMNS.indexOf(col)];
|
|
504
|
+
assert.ok(r.stderr.includes(`malformed row: ${col} is '${value}', expected yes, no or blank`), r.stderr);
|
|
505
|
+
assert.equal(r.stdout, "");
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// What the refusal must ACCEPT: the committed file carries never-ruled rows
|
|
510
|
+
// with both verdict columns blank, and every reader parses the whole file.
|
|
511
|
+
test("check: blank verdict columns and every yes/no pair parse, on full and legacy rows", (t) => {
|
|
512
|
+
const f = fixture(t, {
|
|
513
|
+
members: [{ member: "impl-10", ticket: 10, type: "fleet-implementer" }],
|
|
514
|
+
tierRows: [
|
|
515
|
+
tierRow({ date: PRE_SWITCH, pr: 19, ticket: 9, tier: "", closed: "", minted: "" }).split("\t").slice(0, LEGACY_WIDTH).join("\t"),
|
|
516
|
+
tierRow({ pr: 20, ticket: 10, tier: "default", closed: "", minted: "" }),
|
|
517
|
+
tierRow({ pr: 21, ticket: 10, tier: "default", closed: "yes", minted: "yes" }),
|
|
518
|
+
tierRow({ pr: 22, ticket: 10, tier: "default", closed: "no", minted: "no" }),
|
|
519
|
+
tierRow({ pr: 23, ticket: 10, tier: "default", closed: "no", minted: "yes" }),
|
|
520
|
+
],
|
|
521
|
+
});
|
|
522
|
+
const r = f.run("check");
|
|
523
|
+
assert.equal(r.code, 0, r.stdout + r.stderr);
|
|
524
|
+
assert.match(r.stdout, /4 checked, 0 failed/);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// A row with ONE verdict column blank is what a hand-edit leaves when it blanks
|
|
528
|
+
// a failing value; `append` never writes it and a no-ruling backfill blanks both.
|
|
529
|
+
test("check: a row with exactly one verdict column blank is refused as malformed, on full and legacy rows", (t) => {
|
|
530
|
+
for (const [closed, minted] of [["", "no"], ["", "yes"], ["yes", ""], ["no", ""]]) {
|
|
531
|
+
for (const legacy of [false, true]) {
|
|
532
|
+
const full = tierRow({ date: legacy ? PRE_SWITCH : TIER_SWITCH_DATE, pr: 21, ticket: 11, tier: "default", closed, minted });
|
|
533
|
+
const row = legacy ? full.split("\t").slice(0, LEGACY_WIDTH).join("\t") : full;
|
|
534
|
+
const f = fixture(t, {
|
|
535
|
+
members: [{ member: "impl-10", ticket: 10, type: "fleet-implementer" }],
|
|
536
|
+
tierRows: [tierRow({ pr: 20, ticket: 10, tier: "default" }), row],
|
|
537
|
+
});
|
|
538
|
+
const r = f.run("check");
|
|
539
|
+
const label = `${JSON.stringify({ closed, minted, legacy })}\n${r.stdout}${r.stderr}`;
|
|
540
|
+
assert.equal(r.code, 2, label);
|
|
541
|
+
assert.ok(r.stderr.includes(`malformed row: closed_own_ticket is '${closed}' but minted_false_claim is '${minted}', expected both blank or both yes or no`), label);
|
|
542
|
+
assert.equal(r.stdout, "");
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
470
547
|
test("check --live: warns on each reviewed PR with no row, and the warning alone never fails", (t) => {
|
|
471
548
|
const f = fixture(t, {
|
|
472
549
|
members: [{ member: "impl-10", ticket: 10, type: "fleet-implementer" }],
|