@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.
- package/_skeletons/plugin-template/.githooks/pre-push +7 -4
- package/_skeletons/sibling-template/.githooks/pre-push +7 -4
- package/package.json +2 -4
- package/scripts/runner-drop-forensics.mjs +77 -5
- package/scripts/verify.sh +5 -12
- package/scripts/practices-corpus.mjs +0 -202
- package/scripts/practices-metrics.mjs +0 -2768
|
@@ -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,
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
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,
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
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.
|
|
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`
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
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
|
|
464
|
-
#
|
|
465
|
-
#
|
|
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
|
-
}
|