@biffo/cli 0.241.1 → 0.242.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.
@@ -139,6 +139,20 @@ it deliberately and say so in a comment; never steal a fresh one.
139
139
  the deploy cannot fall off the bottom of a short `gh run list` — and when
140
140
  something is red it names the **first** failing commit, not the newest.
141
141
 
142
+ - **When a check dies without a failure, adjudicate it before you re-run:**
143
+
144
+ ```bash
145
+ sh scripts/biffo.sh runner-drop-forensics --repo <owner/repo> --run <run-id>
146
+ ```
147
+
148
+ The self-hosted fleet runs on spot capacity, so a job can be killed mid-step
149
+ and report `failure` with every remaining step at `conclusion: null` — which
150
+ reads exactly like a real defect and sends you diagnosing your own change.
151
+ This matches the run against the fleet's eviction record and tells you which
152
+ it was. Of the 22 runner-killed jobs it was validated against, **17 were in
153
+ satellites** — which is why it ships in the CLI rather than only upstream
154
+ (#1240).
155
+
142
156
  - **Check whether the branch was already failing before diagnosing your own
143
157
  change.** A red deploy has no audience: the author who broke it has moved on,
144
158
  and every later merge fails on damage it did not cause. On 2026-08-02 that
@@ -139,6 +139,20 @@ it deliberately and say so in a comment; never steal a fresh one.
139
139
  the deploy cannot fall off the bottom of a short `gh run list` — and when
140
140
  something is red it names the **first** failing commit, not the newest.
141
141
 
142
+ - **When a check dies without a failure, adjudicate it before you re-run:**
143
+
144
+ ```bash
145
+ sh scripts/biffo.sh runner-drop-forensics --repo <owner/repo> --run <run-id>
146
+ ```
147
+
148
+ The self-hosted fleet runs on spot capacity, so a job can be killed mid-step
149
+ and report `failure` with every remaining step at `conclusion: null` — which
150
+ reads exactly like a real defect and sends you diagnosing your own change.
151
+ This matches the run against the fleet's eviction record and tells you which
152
+ it was. Of the 22 runner-killed jobs it was validated against, **17 were in
153
+ satellites** — which is why it ships in the CLI rather than only upstream
154
+ (#1240).
155
+
142
156
  - **Check whether the branch was already failing before diagnosing your own
143
157
  change.** A red deploy has no audience: the author who broke it has moved on,
144
158
  and every later merge fails on damage it did not cause. On 2026-08-02 that
package/dist/index.js CHANGED
@@ -10795,6 +10795,13 @@ It ships with this package via cli/scripts/packaged-root-assets.mjs; if you are
10795
10795
  }
10796
10796
 
10797
10797
  // src/lib/packaged-script-command.ts
10798
+ function interpreterFor(script) {
10799
+ return script.endsWith(".mjs") ? "node" : "sh";
10800
+ }
10801
+ function runPackagedScript(script, args, cwd) {
10802
+ const result = spawnSync(interpreterFor(script), [script, ...args], { stdio: "inherit", cwd });
10803
+ return result.status === null ? 2 : result.status;
10804
+ }
10798
10805
  function packagedScriptCommand(spec) {
10799
10806
  const command = new Command26(spec.name).description(spec.description).allowExcessArguments(true).allowUnknownOption(true);
10800
10807
  if (spec.argument) command.argument(`<${spec.argument.name}>`, spec.argument.description);
@@ -10809,8 +10816,7 @@ function packagedScriptCommand(spec) {
10809
10816
  const at = process.argv.indexOf(spec.name);
10810
10817
  const args = at === -1 ? [] : process.argv.slice(at + 1);
10811
10818
  const cwd = process.env["BIFFO_ORIGINAL_CWD"] || process.cwd();
10812
- const result = spawnSync("sh", [script, ...args], { stdio: "inherit", cwd });
10813
- process.exit(result.status === null ? 2 : result.status);
10819
+ process.exit(runPackagedScript(script, args, cwd));
10814
10820
  });
10815
10821
  }
10816
10822
 
@@ -10871,6 +10877,13 @@ var waitForChecksCommand = packagedScriptCommand({
10871
10877
  argument: { name: "pr", description: "Pull request number" }
10872
10878
  });
10873
10879
 
10880
+ // src/commands/runner-drop-forensics.ts
10881
+ var runnerDropForensicsCommand = packagedScriptCommand({
10882
+ name: "runner-drop-forensics",
10883
+ script: "scripts/runner-drop-forensics.mjs",
10884
+ description: "Decide whether a red check was a fleet fault or a real failure (0 explained, 1 real failure, 2 cannot tell)"
10885
+ });
10886
+
10874
10887
  // src/index.ts
10875
10888
  var program = new Command27();
10876
10889
  function cliVersion() {
@@ -10890,6 +10903,7 @@ program.addCommand(branchHealthCommand);
10890
10903
  program.addCommand(claimCommand);
10891
10904
  program.addCommand(verifyCommand);
10892
10905
  program.addCommand(gateCoverageCommand);
10906
+ program.addCommand(runnerDropForensicsCommand);
10893
10907
  program.addCommand(hookAuditCommand);
10894
10908
  program.addCommand(pgTestDbCommand);
10895
10909
  program.addCommand(rewriteScopeCheckCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.241.1",
3
+ "version": "0.242.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,10 @@
31
31
  "scripts/pg-test-db.sh",
32
32
  "scripts/rewrite-scope-check.sh",
33
33
  "scripts/gate-coverage.sh",
34
- "scripts/verify.sh"
34
+ "scripts/verify.sh",
35
+ "scripts/runner-drop-forensics.mjs",
36
+ "scripts/practices-metrics.mjs",
37
+ "scripts/practices-corpus.mjs"
35
38
  ],
36
39
  "scripts": {
37
40
  "build": "tsup src/index.ts --format esm --dts --clean",
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Shared read/write helpers for the practices evidence corpus (#1132).
3
+ *
4
+ * ## Why a directory, not one shared file
5
+ *
6
+ * `docs/practices/evidence.jsonl` was a single file every concurrent session
7
+ * appended to. N writers, one path — conflicts **by construction**, the same
8
+ * class already fixed twice in this repo (`core.version` #423, the generated
9
+ * tally block #953). The lever is the same: stop sharing the path. New rows go
10
+ * into their own file under `docs/practices/evidence/`, one per entry, e.g.
11
+ *
12
+ * docs/practices/evidence/2026-08-03-metric-denominator-blindness.json
13
+ *
14
+ * Two sessions writing on the same day still never collide — their filenames
15
+ * differ.
16
+ *
17
+ * ## Migration: read both, split nothing
18
+ *
19
+ * Splitting the ~430 existing rows into ~430 files was rejected: it is more
20
+ * expensive than the alternative for no benefit, and it would re-serialise a
21
+ * file that must never be re-serialised (whole-file rewrites are the exact
22
+ * defect being fixed). Instead `evidence.jsonl` is now a **frozen legacy
23
+ * file** — nothing ever appends to it again — and the read side merges it
24
+ * with the directory. See `practices-monotonic.mjs` for the guard that keeps
25
+ * it frozen rather than shrunk.
26
+ *
27
+ * ## Ordering
28
+ *
29
+ * Filenames carry the date (`YYYY-MM-DD-slug.json`), so the read side sorts
30
+ * the directory listing by filename rather than relying on directory order,
31
+ * which the filesystem does not guarantee. Legacy rows keep their existing
32
+ * file order (untouched) and sort BEFORE every directory row — they predate
33
+ * all of them by construction.
34
+ */
35
+
36
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
37
+ import { join } from 'node:path'
38
+
39
+ export const LEGACY_EVIDENCE = 'docs/practices/evidence.jsonl'
40
+ export const EVIDENCE_DIR = 'docs/practices/evidence'
41
+
42
+ /** The per-entry directory that goes with a legacy `.jsonl` path. */
43
+ export function corpusDirFor(legacyFile) {
44
+ return legacyFile.replace(/\.jsonl$/, '')
45
+ }
46
+
47
+ /**
48
+ * Parse the legacy newline-delimited JSON file, leniently: one malformed line
49
+ * is dropped rather than failing the whole read. Matches the tolerance this
50
+ * file's readers already had before #1132 (a scan for ranking, not a strict
51
+ * audit — `readCorpusStrict` below is the strict counterpart).
52
+ */
53
+ export function readLegacyEvidence(file = LEGACY_EVIDENCE) {
54
+ if (!existsSync(file)) return []
55
+ return readFileSync(file, 'utf8')
56
+ .split('\n')
57
+ .filter((l) => l.trim() !== '')
58
+ .map((line) => {
59
+ try {
60
+ return JSON.parse(line)
61
+ } catch {
62
+ return null
63
+ }
64
+ })
65
+ .filter(Boolean)
66
+ }
67
+
68
+ /** `*.json` filenames directly under the evidence directory, sorted so date-prefixed names order chronologically. */
69
+ export function listEvidenceFiles(dir = EVIDENCE_DIR) {
70
+ if (!existsSync(dir)) return []
71
+ return readdirSync(dir)
72
+ .filter((f) => f.endsWith('.json'))
73
+ .sort()
74
+ }
75
+
76
+ /** Every per-entry file, parsed, alongside the filename it came from — needed to rewrite a specific entry (e.g. `--enrich`). */
77
+ export function readEvidenceDirEntries(dir = EVIDENCE_DIR) {
78
+ return listEvidenceFiles(dir)
79
+ .map((file) => {
80
+ try {
81
+ return { file, row: JSON.parse(readFileSync(join(dir, file), 'utf8')) }
82
+ } catch {
83
+ return null
84
+ }
85
+ })
86
+ .filter(Boolean)
87
+ }
88
+
89
+ /** Every per-entry file's row, sorted by filename. Malformed files are dropped, not fatal. */
90
+ export function readEvidenceDir(dir = EVIDENCE_DIR) {
91
+ return readEvidenceDirEntries(dir).map((e) => e.row)
92
+ }
93
+
94
+ /**
95
+ * The full corpus, lenient: legacy rows (their existing order, untouched)
96
+ * followed by directory rows (sorted by filename). A concatenation, not a
97
+ * merge — the two never name the same entry, so there is nothing to
98
+ * reconcile.
99
+ *
100
+ * @param {string} legacyFile path to the legacy `.jsonl`; its sibling
101
+ * directory is derived from it (`corpusDirFor`)
102
+ */
103
+ export function readCorpus(legacyFile = LEGACY_EVIDENCE) {
104
+ return [...readLegacyEvidence(legacyFile), ...readEvidenceDir(corpusDirFor(legacyFile))]
105
+ }
106
+
107
+ /**
108
+ * The full corpus, strict: throws on the first line or file that fails to
109
+ * parse, and throws if neither the legacy file nor the directory has
110
+ * anything to read. For callers whose whole point is "never report a zero
111
+ * that could actually be 'could not read this'" (`summariseFailOpenBacklog`)
112
+ * — a corpus that half-parses must not silently look like a smaller valid
113
+ * one.
114
+ *
115
+ * @param {string} legacyFile
116
+ */
117
+ export function readCorpusStrict(legacyFile = LEGACY_EVIDENCE) {
118
+ const dir = corpusDirFor(legacyFile)
119
+ const legacyExists = existsSync(legacyFile)
120
+ const dirFiles = listEvidenceFiles(dir)
121
+ if (!legacyExists && dirFiles.length === 0) {
122
+ throw new Error(`no corpus at ${legacyFile} or ${dir}`)
123
+ }
124
+ const legacyRows = legacyExists
125
+ ? readFileSync(legacyFile, 'utf8')
126
+ .split('\n')
127
+ .filter((l) => l.trim() !== '')
128
+ .map((l) => JSON.parse(l))
129
+ : []
130
+ const dirRows = dirFiles.map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')))
131
+ return [...legacyRows, ...dirRows]
132
+ }
133
+
134
+ /** Filename-safe token from a row's summary. */
135
+ export function slugify(text) {
136
+ return String(text ?? '')
137
+ .toLowerCase()
138
+ .replace(/[^a-z0-9]+/g, '-')
139
+ .replace(/^-+|-+$/g, '')
140
+ .slice(0, 60)
141
+ }
142
+
143
+ /**
144
+ * Write ONE new evidence entry as its own file. This is the write path every
145
+ * future session uses — never append to `evidence.jsonl`, which is frozen.
146
+ *
147
+ * Refuses to overwrite an existing file: a collision means the slug needs to
148
+ * be more specific, not that the earlier entry should be silently replaced.
149
+ *
150
+ * @param {Record<string, any>} row
151
+ * @param {{dir?: string, date?: string, slug?: string}} [opts]
152
+ * @returns {string} the path written, relative to `opts.dir`'s base
153
+ */
154
+ export function writeEvidenceEntry(row, opts = {}) {
155
+ const dir = opts.dir ?? EVIDENCE_DIR
156
+ const date = opts.date ?? row.date ?? new Date().toISOString().slice(0, 10)
157
+ const slug = opts.slug ?? slugify(row.summary) ?? 'entry'
158
+ mkdirSync(dir, { recursive: true })
159
+ const file = `${date}-${slug || 'entry'}.json`
160
+ const path = join(dir, file)
161
+ if (existsSync(path)) {
162
+ throw new Error(`${path} already exists — choose a more specific slug or date`)
163
+ }
164
+ writeFileSync(path, `${JSON.stringify({ ...row, date }, null, 2)}\n`)
165
+ return path
166
+ }
167
+
168
+ /** Overwrite one already-existing per-entry file in place (e.g. `--enrich` filling in a date). Never touches the legacy file. */
169
+ export function writeEvidenceFile(dir, file, row) {
170
+ mkdirSync(dir, { recursive: true })
171
+ writeFileSync(join(dir, file), `${JSON.stringify(row, null, 2)}\n`)
172
+ }