@biffo/cli 0.315.5 → 0.315.7

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.
@@ -93,10 +93,13 @@ fi
93
93
  # BIFFO_SKIP_CLAIM_GUARD -- narrow, claim-only escape hatch (#1327). Before
94
94
  # this, the only documented way past a false positive here was
95
95
  # BIFFO_SKIP_VERIFY at the top of this file, which does not target the claim
96
- # guard: it skips the WHOLE gate -- ruff, pyright, bandit, gitleaks,
97
- # rewrite-scope, this guard, everything. An agent reading "skip the claim
98
- # guard" reasonably reaches for the only hatch documented and gets "skip
99
- # everything" instead; one did, in the session #1327 was filed from. This
96
+ # guard: it skips the WHOLE gate -- ruff, pyright, bandit, rewrite-scope,
97
+ # this guard, everything. gitleaks is deliberately not named here: verify.sh
98
+ # only runs it when the binary is installed locally and reports "APPLICABLE
99
+ # BUT NOT RUN" otherwise, so it is not a member of the gate this variable
100
+ # reliably skips (#2038). An agent reading "skip the claim guard" reasonably
101
+ # reaches for the only hatch documented and gets "skip everything" instead;
102
+ # one did, in the session #1327 was filed from. This
100
103
  # variable bypasses exactly the `claim --guard` call below and nothing else --
101
104
  # rewrite-scope-check, the pg-test block gate and `verify` all still run.
102
105
  branch=$(git symbolic-ref --quiet --short HEAD) || branch=""
@@ -93,10 +93,13 @@ fi
93
93
  # BIFFO_SKIP_CLAIM_GUARD -- narrow, claim-only escape hatch (#1327). Before
94
94
  # this, the only documented way past a false positive here was
95
95
  # BIFFO_SKIP_VERIFY at the top of this file, which does not target the claim
96
- # guard: it skips the WHOLE gate -- ruff, pyright, bandit, gitleaks,
97
- # rewrite-scope, this guard, everything. An agent reading "skip the claim
98
- # guard" reasonably reaches for the only hatch documented and gets "skip
99
- # everything" instead; one did, in the session #1327 was filed from. This
96
+ # guard: it skips the WHOLE gate -- ruff, pyright, bandit, rewrite-scope,
97
+ # this guard, everything. gitleaks is deliberately not named here: verify.sh
98
+ # only runs it when the binary is installed locally and reports "APPLICABLE
99
+ # BUT NOT RUN" otherwise, so it is not a member of the gate this variable
100
+ # reliably skips (#2038). An agent reading "skip the claim guard" reasonably
101
+ # reaches for the only hatch documented and gets "skip everything" instead;
102
+ # one did, in the session #1327 was filed from. This
100
103
  # variable bypasses exactly the `claim --guard` call below and nothing else --
101
104
  # rewrite-scope-check, the pg-test block gate and `verify` all still run.
102
105
  branch=$(git symbolic-ref --quiet --short HEAD) || branch=""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.315.5",
3
+ "version": "0.315.7",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,9 +33,7 @@
33
33
  "scripts/pgtest-diff-check.sh",
34
34
  "scripts/gate-coverage.sh",
35
35
  "scripts/verify.sh",
36
- "scripts/runner-drop-forensics.mjs",
37
- "scripts/practices-metrics.mjs",
38
- "scripts/practices-corpus.mjs"
36
+ "scripts/runner-drop-forensics.mjs"
39
37
  ],
40
38
  "scripts": {
41
39
  "build": "tsup src/index.ts --format esm --dts --clean --external typescript",
@@ -14,10 +14,9 @@
14
14
  * reading of a red branch and the lazy one are indistinguishable, and #982
15
15
  * showed the estate had been counting these as broken code for months.
16
16
  *
17
- * `isRunnerKill` (in `practices-metrics.mjs`, imported below rather than
18
- * re-implemented) already answers *"did a runner die?"* from the run's own step
19
- * conclusions. It cannot answer *"why?"* — and "why" is what decides whether
20
- * anyone should be looking at the code at all.
17
+ * `isRunnerKill` below already answers *"did a runner die?"* from the run's own
18
+ * step conclusions. It cannot answer *"why?"* — and "why" is what decides
19
+ * whether anyone should be looking at the code at all.
21
20
  *
22
21
  * ## The join nobody had written
23
22
  *
@@ -61,7 +60,80 @@
61
60
 
62
61
  // @ts-check
63
62
  import { execFileSync } from 'node:child_process'
64
- import { isRunnerKill } from './practices-metrics.mjs'
63
+
64
+ /**
65
+ * Step conclusions that mean the step **stopped without a verdict**.
66
+ *
67
+ * A dying runner produces two different signatures and #982 caught only the
68
+ * first, so `biffo-platform` kept two failures it had not earned:
69
+ *
70
+ * - `null` — the step was still executing when the lights went out. A deploy
71
+ * frozen on "Package and deploy Lambda", six steps left `pending`.
72
+ * - `cancelled` — the step was stopped, and every later step reads `skipped`.
73
+ * Two `biffo-platform` CI runs died 64 seconds in this way, on "Type check"
74
+ * and "Lint".
75
+ *
76
+ * ## Why `cancelled` here is not an ordinary cancellation
77
+ *
78
+ * The obvious objection is that this launders someone hitting cancel, or a
79
+ * `cancel-in-progress` supersession. It does not, and the reason is structural:
80
+ * **those conclude the run `cancelled`**, which `isRunnerKill` is only ever
81
+ * reached for a run that concluded `failure`. A run that concluded `failure`
82
+ * while no step ever returned a verdict was therefore stopped by something
83
+ * that is not a cancellation.
84
+ *
85
+ * A step that hits `timeout-minutes` (20 since #980) is expected to be marked
86
+ * `failure` and so stays a real failure.
87
+ */
88
+ const STOPPED_SHORT = new Set([null, undefined, 'cancelled'])
89
+
90
+ /**
91
+ * Did this run fail because a **runner died**, rather than because a gate
92
+ * rejected the change? (#982)
93
+ *
94
+ * ## The hole this closes
95
+ *
96
+ * A killed or superseded run naturally concludes `cancelled`, which is not a
97
+ * defect. That reasoning is right and its coverage is only partial: **a runner
98
+ * killed mid-job reports `cancelled` only sometimes.** The rest of the time
99
+ * GitHub concludes the run `failure` with *no failing step* — the same
100
+ * physical event, a different label, and the second label was counted as if
101
+ * code had broken.
102
+ *
103
+ * Measured on `tabsii-com/tabsii-platform`, 2026-07-31: **all six** `dev`
104
+ * failures inspected had zero failing steps and 3–21 steps left incomplete. One
105
+ * deploy succeeded through thirteen steps and froze on "Package and deploy
106
+ * Lambda". Not one gate rejected a change.
107
+ *
108
+ * ## The rule, and why it errs the way it does
109
+ *
110
+ * A failed run is a runner kill when **no job reports a failing step** and **at
111
+ * least one failed job has a step that stopped without a verdict** — see
112
+ * {@link STOPPED_SHORT} for the two signatures that means, and why `cancelled`
113
+ * among them is not an ordinary cancellation. Both halves matter: the first
114
+ * says nothing rejected the change, the second says work was still outstanding
115
+ * when the lights went out.
116
+ *
117
+ * A failed run with no steps recorded at all is deliberately **not** classified
118
+ * as a kill. It stays a failure. That is the conservative direction for a
119
+ * counter-metric — it can still refute an experiment the author would prefer to
120
+ * confirm — and this module's whole purpose is to make that the default.
121
+ *
122
+ * A job that hits its `timeout-minutes` (20 since #980) marks the offending step
123
+ * `failure`, so a genuine hang stays a genuine failure and is not laundered
124
+ * through here.
125
+ *
126
+ * @param {Array<Record<string, any>>} jobs the `jobs` array of one run
127
+ * @returns {boolean}
128
+ */
129
+ export function isRunnerKill(jobs) {
130
+ const failed = (jobs ?? []).filter((job) => job.conclusion === 'failure')
131
+ if (failed.length === 0) return false
132
+ const steps = failed.flatMap((job) => job.steps ?? [])
133
+ if (steps.length === 0) return false
134
+ if (steps.some((step) => step.conclusion === 'failure')) return false
135
+ return steps.some((step) => STOPPED_SHORT.has(step.conclusion))
136
+ }
65
137
 
66
138
  /**
67
139
  * How far outside a job's own start/finish window an eviction may fall and
package/scripts/verify.sh CHANGED
@@ -460,9 +460,11 @@ fi
460
460
  # and NO_CI must not fire just because a repo has not adopted the split (a
461
461
  # sibling never will; an instance not yet upgraded past #1319 has not yet).
462
462
  # But if it EXISTS and cannot be READ, that is the identical #1218 shape as
463
- # ci.yml itself, and ci_has() must search it too or "practices-monotonic"
464
- # (moved into it) would silently stop being locally mirrored the moment it
465
- # left ci.yml -- covered less than verify.sh claims, with nothing saying so.
463
+ # ci.yml itself, and ci_has() must search it too -- a check that lives ONLY
464
+ # in release-guards.yml (any of the ones this repo's own header explains
465
+ # moved here to avoid re-running the whole ci.yml matrix on a PR edit) would
466
+ # otherwise silently stop being locally mirrored, covered less than verify.sh
467
+ # claims, with nothing saying so.
466
468
  RELEASE_GUARDS_YML_UNREADABLE=""
467
469
  if [ -f .github/workflows/release-guards.yml ] && [ ! -r .github/workflows/release-guards.yml ]; then
468
470
  RELEASE_GUARDS_YML_UNREADABLE=1
@@ -1544,15 +1546,6 @@ fi
1544
1546
  [ -f scripts/check-orphan-ratchet-instance.test.sh ] &&
1545
1547
  run_check orphan-ratchet-instance-selftest sh scripts/check-orphan-ratchet-instance.test.sh
1546
1548
 
1547
- # The append-only corpus guard (#778). CI runs it in Release Guards, and it was
1548
- # invisible to the parity test until #897 widened the harvester -- it is neither
1549
- # `pnpm`, `uv`, `terraform` nor `sh scripts/`, so the guard whose property is
1550
- # "every CI check is in the gate or explicitly excluded" could not see it at all.
1551
- # Measured 0.06s here, which is cheaper than every other check in this file.
1552
- if [ -f scripts/practices-monotonic.mjs ]; then
1553
- ci_has "practices-monotonic" && run_check corpus-append-only node scripts/practices-monotonic.mjs
1554
- fi
1555
-
1556
1549
  # Terraform plan artefacts, refused by CONTENT (biffo-runners#1).
1557
1550
  #
1558
1551
  # A saved plan is a zip. `strings`/`grep` over it is a false-negative machine —
@@ -1,202 +0,0 @@
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
- * The refusal is an atomic `wx` create, not an `existsSync` check followed by
151
- * a write (#1222). This corpus has concurrent writers BY DESIGN — several
152
- * agent sessions run against this estate at once — so the window between a
153
- * check and a write is not theoretical here: two sessions writing the same
154
- * `date-slug` is the exact case the guard exists for, and the check-then-write
155
- * form lost the earlier entry rather than refusing. `EEXIST` is translated
156
- * back into the same message, so nothing else changes.
157
- *
158
- * @param {Record<string, any>} row
159
- * @param {{dir?: string, date?: string, slug?: string}} [opts]
160
- * @returns {string} the path written, relative to `opts.dir`'s base
161
- */
162
- export function writeEvidenceEntry(row, opts = {}) {
163
- const dir = opts.dir ?? EVIDENCE_DIR
164
- // `undefined` means "nobody said"; `null` means "known to be unknown". Both
165
- // must reach the stored field as null rather than today's date.
166
- //
167
- // This used to read `opts.date ?? row.date ?? new Date()…` and write that
168
- // single value to BOTH the filename and the row. The module docstring says
169
- // the opposite in as many words — "Rows citing nothing keep `date: null` —
170
- // never a guess, because a fabricated date would corrupt exactly the ranking
171
- // this exists to enable" — and `--extract` even passes `date: row.date ??
172
- // null` to say so explicitly. `null ?? today` discarded that.
173
- //
174
- // It was invisible while rows were extracted the day they were written, and
175
- // surfaced on 2026-08-09 when extracting five rows also swept up eighteen
176
- // older ones and stamped every one with that day. `--enrich` recovers real
177
- // dates from the cited issues afterwards, and it can only do that for rows
178
- // whose date is *absent*; a fabricated one looks recovered and is skipped.
179
- const date = opts.date ?? row.date ?? null
180
- const slug = opts.slug ?? slugify(row.summary) ?? 'entry'
181
- mkdirSync(dir, { recursive: true })
182
- // The filename is used only for sorting and uniqueness — every reader parses
183
- // the JSON body — so an undated row says so here too, rather than carrying a
184
- // date prefix that the data it contains denies.
185
- const file = `${date ?? 'undated'}-${slug || 'entry'}.json`
186
- const path = join(dir, file)
187
- try {
188
- writeFileSync(path, `${JSON.stringify({ ...row, date }, null, 2)}\n`, { flag: 'wx' })
189
- } catch (err) {
190
- if (err && err.code === 'EEXIST') {
191
- throw new Error(`${path} already exists — choose a more specific slug or date`)
192
- }
193
- throw err
194
- }
195
- return path
196
- }
197
-
198
- /** Overwrite one already-existing per-entry file in place (e.g. `--enrich` filling in a date). Never touches the legacy file. */
199
- export function writeEvidenceFile(dir, file, row) {
200
- mkdirSync(dir, { recursive: true })
201
- writeFileSync(join(dir, file), `${JSON.stringify(row, null, 2)}\n`)
202
- }