@biffo/cli 0.241.1 → 0.242.1
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/_skeletons/plugin-template/AGENTS.md +14 -0
- package/_skeletons/sibling-template/AGENTS.md +14 -0
- package/dist/index.js +16 -2
- package/package.json +5 -2
- package/scripts/practices-corpus.mjs +172 -0
- package/scripts/practices-metrics.mjs +2682 -0
- package/scripts/runner-drop-forensics.mjs +344 -0
- package/scripts/verify.sh +40 -1
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "0.242.1",
|
|
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
|
+
}
|