faberun 0.20.0 → 0.22.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.
@@ -1,21 +1,25 @@
1
1
  /**
2
2
  * Freezing a plan: the boundary between a session's draft and a contract the
3
3
  * engine can execute. `freezePlan` writes the plan's nodes as a validated
4
- * contract.json, plus a plan.json carrying that contract's digest, the plan's
5
- * per-phase requirement declarations (which requirement ids each phase
6
- * satisfies and its one-sentence deliverable), and the full provenance of how
7
- * it was produced — the two files travel together so a later launch and this
8
- * record agree on exactly what was reviewed.
4
+ * contract.json, plus a plan.json carrying that contract's digest, the
5
+ * structured spec's path and content digest, the plan's per-phase requirement
6
+ * declarations (which requirement ids each phase satisfies, which planned
7
+ * nodes it assigns, and its one-sentence deliverable), and the full provenance
8
+ * of how it was produced — the two files travel together so a later launch and
9
+ * this record agree on exactly what was reviewed.
9
10
  * `verifyFrozenPlan` is the one check that the pair still agree.
10
11
  *
11
12
  * Nothing here invokes a model or the engine; it only writes and hashes
12
13
  * bytes, so freezing a plan can never be mistaken for starting a run.
13
14
  */
15
+ import { createHash } from "node:crypto";
14
16
  import { mkdirSync, readFileSync, rmSync } from "node:fs";
15
17
  import { join } from "node:path";
16
18
  import { fileURLToPath } from "node:url";
17
19
  import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, contractDigest, validateContract } from "../contract/index.mjs";
18
- import { writeJsonAtomic } from "../run/store.mjs";
20
+ import { assertObject, rejectUnknown, requirePacketHash, requireString } from "../contract/assert.mjs";
21
+ import { writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
22
+ import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
19
23
  import { validatePlanPhases } from "./template.mjs";
20
24
 
21
25
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
@@ -24,12 +28,112 @@ import { validatePlanPhases } from "./template.mjs";
24
28
  /** @typedef {{id: string, severity: "minor"|"major"|"critical", nodeId?: string, text: string}} PlanFinding */
25
29
  /** @typedef {{targetGitHead: string|null, planner: PlanParticipant, reviewer: PlanParticipant, sizing: unknown, findings: PlanFinding[]}} PlanProvenanceInput */
26
30
  /** @typedef {PlanProvenanceInput & {packageVersion: string, schemaVersion: number, contractVersion: string}} PlanProvenance */
27
- /** @typedef {{id: string, requirementIds: string[], deliverable: string}} PlanPhase */
28
- /** @typedef {{formatVersion: number, contractDigest: string, phases?: PlanPhase[], provenance: PlanProvenance}} FrozenPlan */
31
+ /** @typedef {{id: string, requirementIds: string[], nodeIds?: string[], deliverable: string}} PlanPhase */
32
+ /** @typedef {{path: string, digest: string}} PlanSpecIdentity */
33
+ /** @typedef {{formatVersion: number, contractDigest: string, spec?: PlanSpecIdentity, phases?: PlanPhase[], provenance: PlanProvenance}} FrozenPlan */
29
34
  /** @typedef {{ok: boolean, digest: string, expectedDigest: string}} FrozenPlanVerdict */
35
+ /** @typedef {{scripts?: Record<string, string>, verificationCandidates: {argv: string[], measuredMs: number}[]}} MeasuredFacts */
30
36
 
31
37
  const PLAN_FORMAT_VERSION = 1;
32
38
 
39
+ /**
40
+ * The margin a frozen verification timeout keeps over its measured duration.
41
+ * No measurement behind the number itself: it is the spec's (RM-057), and a
42
+ * timeout only bounds a failure, so a passing command never waits for it.
43
+ */
44
+ const MEASURED_TIMEOUT_MARGIN = 1.5;
45
+
46
+ /** `node --test` options that run a subset of the files they name. */
47
+ const FILTER_OPTIONS = ["--test-name-pattern", "--test-skip-pattern", "--test-only", "--test-shard"];
48
+
49
+ /** `node` options whose value is the next argument, so it is not a path. */
50
+ const NODE_VALUE_OPTIONS = new Set(["--import", "--require", "-r", "--loader", "--experimental-loader", "--env-file", "--test-reporter", "--test-reporter-destination", "--test-name-pattern", "--test-skip-pattern", "--test-concurrency", "--test-timeout"]);
51
+
52
+ /**
53
+ * The `node --test <dir>` candidates one path argument includes: a directory
54
+ * includes itself and everything below it, and a glob includes the
55
+ * directories below its literal prefix only when it descends (`test/*` +
56
+ * `/…`); `test/*.test.mjs` names top-level files no candidate measured.
57
+ *
58
+ * @param {string} arg
59
+ * @param {string[]} directories
60
+ * @returns {string[]}
61
+ */
62
+ function includedDirectories(arg, directories) {
63
+ const path = arg.replace(/^\.\//u, "").replace(/\/+$/u, "");
64
+ const wildcard = path.search(/[*?[]/u);
65
+ if (wildcard < 0) return directories.filter((directory) => directory === path || directory.startsWith(`${path}/`));
66
+ const base = path.slice(0, path.lastIndexOf("/", wildcard));
67
+ if (!path.slice(wildcard).includes("/")) return [];
68
+ return directories.filter((directory) => base === "" || directory.startsWith(`${base}/`));
69
+ }
70
+
71
+ /**
72
+ * What repo facts measured for a verification command: its own candidate, or
73
+ * the sum of the `node --test <dir>` candidates it includes, `npm test`
74
+ * resolved through the `test` script. A lower bound when the command also
75
+ * runs files no candidate measured; null when it includes nothing measured.
76
+ *
77
+ * @param {string[]} argv
78
+ * @param {MeasuredFacts} facts
79
+ * @returns {number|null}
80
+ */
81
+ function measuredMsFor(argv, facts) {
82
+ const candidates = facts.verificationCandidates;
83
+ const exact = candidates.find((candidate) => candidate.argv.join(" ") === argv.join(" "));
84
+ if (exact) return exact.measuredMs;
85
+ const npmTest = argv[0] === "npm" && ["test", "run test"].includes(argv.slice(1).join(" "));
86
+ if (npmTest && typeof facts.scripts?.test === "string") return measuredMsFor(facts.scripts.test.trim().split(/\s+/u), facts);
87
+ if (argv[0] !== "node" || argv[1] !== "--test") return null;
88
+ // A filtered run measures nothing a directory candidate measured.
89
+ if (argv.some((arg) => FILTER_OPTIONS.some((option) => arg === option || arg.startsWith(`${option}=`)))) return null;
90
+ const measured = new Map(candidates
91
+ .filter((candidate) => candidate.argv.length === 3 && candidate.argv[0] === "node" && candidate.argv[1] === "--test")
92
+ .map((candidate) => [candidate.argv[2].replace(/\/+$/u, ""), candidate.measuredMs]));
93
+ /** @type {string[]} */
94
+ const paths = [];
95
+ for (let index = 2; index < argv.length; index += 1) {
96
+ if (NODE_VALUE_OPTIONS.has(argv[index])) index += 1;
97
+ else if (!argv[index].startsWith("-")) paths.push(argv[index]);
98
+ }
99
+ const included = new Set((paths.length ? paths : ["test"]).flatMap((path) => includedDirectories(path, [...measured.keys()])));
100
+ if (!included.size) return null;
101
+ return [...included].reduce((sum, directory) => sum + /** @type {number} */ (measured.get(directory)), 0);
102
+ }
103
+
104
+ /**
105
+ * Refuse a contract whose verification timeout sits under
106
+ * `MEASURED_TIMEOUT_MARGIN` times what repo facts measured for the command,
107
+ * and name a command no legal timeout can cover, so the plan is contested
108
+ * with the advice to split it. RM-057, measured on the Campaign Brief run:
109
+ * a gate frozen at 120s against parts measured at 178,904 ms and 246,955 ms.
110
+ *
111
+ * @param {import("../contract/index.mjs").ValidatedContract} contract
112
+ * @param {MeasuredFacts} facts
113
+ * @returns {void}
114
+ */
115
+ export function assertTimeoutsCoverMeasured(contract, facts) {
116
+ const commands = [
117
+ ...contract.nodes.flatMap((node) => node.taskPacket.verification ?? []),
118
+ ...contract.sharedVerification ?? [],
119
+ ...contract.finalVerification ?? [],
120
+ ];
121
+ const problems = new Set();
122
+ for (const command of commands) {
123
+ const measuredMs = measuredMsFor(command.argv, facts);
124
+ if (measuredMs === null) continue;
125
+ const timeoutSec = command.timeoutSec ?? 120;
126
+ const requiredSec = Math.ceil((measuredMs * MEASURED_TIMEOUT_MARGIN) / 1_000);
127
+ const shown = `${command.argv.join(" ")} measured ${(measuredMs / 1_000).toFixed(1)}s`;
128
+ if (requiredSec > VERIFICATION_LIMITS.maxTimeoutSec) {
129
+ problems.add(`${shown}, and ${MEASURED_TIMEOUT_MARGIN} times that passes the ${VERIFICATION_LIMITS.maxTimeoutSec}s maxTimeoutSec: split it into commands that each fit`);
130
+ } else if (timeoutSec < requiredSec) {
131
+ problems.add(`verification ${command.argv.join(" ")} has timeoutSec ${timeoutSec}s, under ${MEASURED_TIMEOUT_MARGIN} times its measured ${(measuredMs / 1_000).toFixed(1)}s: raise it to at least ${requiredSec}s`);
132
+ }
133
+ }
134
+ if (problems.size) throw new TypeError(`verification timeouts do not cover their measured durations: ${[...problems].join("; ")}`);
135
+ }
136
+
33
137
  /** @returns {string} the installed package's own version, read once per call so a freeze always names the toolchain that produced it */
34
138
  function packageVersion() {
35
139
  const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
@@ -37,12 +141,16 @@ function packageVersion() {
37
141
  }
38
142
 
39
143
  /**
40
- * Nodes inherit the requirement ids of the phase they belong to: the frozen
41
- * contract preserves them per node, so the engine can stamp them onto the
42
- * node's accepted result without the worker packet or the worker ever
43
- * declaring one. A node whose phase has no declaration, or declares none,
44
- * carries none. Nodes are re-listed rather than mutated in place, so the
45
- * caller's plan keeps the shape it was reviewed with.
144
+ * Nodes inherit the requirement ids of the phase declaration that names them.
145
+ * The frozen contract preserves them per node, so the engine can stamp them
146
+ * onto the node's accepted result without the worker packet or the worker ever
147
+ * declaring one. The current `{id, requirementIds, nodeIds, deliverable}`
148
+ * declaration names its nodes outright; the legacy
149
+ * `{id, requirementIds, deliverable}` shape still resolves by the node's
150
+ * execution `phase`, so a record frozen before node assignment existed keeps
151
+ * reading. A node no declaration names, or a declaration that declares no ids,
152
+ * leaves it unstamped. Nodes are re-listed rather than mutated in place, so
153
+ * the caller's plan keeps the shape it was reviewed with.
46
154
  *
47
155
  * @param {unknown} nodes
48
156
  * @param {PlanPhase[]} phases
@@ -50,23 +158,108 @@ function packageVersion() {
50
158
  */
51
159
  function stampPhaseRequirementIds(nodes, phases) {
52
160
  if (!Array.isArray(nodes)) return nodes;
53
- const byPhase = new Map(
54
- phases
55
- .filter((phase) => phase.requirementIds.length > 0)
56
- .map((phase) => [phase.id, phase.requirementIds]),
57
- );
161
+ /** @type {Map<string, string[]>} */
162
+ const byNodeId = new Map();
163
+ /** @type {Map<string, string[]>} */
164
+ const byExecutionPhase = new Map();
165
+ for (const phase of phases) {
166
+ if (phase.requirementIds.length === 0) continue;
167
+ if (phase.nodeIds === undefined) {
168
+ byExecutionPhase.set(phase.id, phase.requirementIds);
169
+ continue;
170
+ }
171
+ for (const nodeId of phase.nodeIds) byNodeId.set(nodeId, phase.requirementIds);
172
+ }
58
173
  return nodes.map((node) => {
59
- const phase = typeof node?.phase === "string" ? node.phase : undefined;
60
- const inherited = phase === undefined ? undefined : byPhase.get(phase);
61
- return inherited ? { ...node, requirementIds: [...inherited] } : node;
174
+ const record = /** @type {Record<string, unknown>} */ (node && typeof node === "object" ? node : {});
175
+ const byId = typeof record.id === "string" ? byNodeId.get(record.id) : undefined;
176
+ const inherited = byId ?? (typeof record.phase === "string" ? byExecutionPhase.get(record.phase) : undefined);
177
+ return inherited ? { ...record, requirementIds: [...inherited] } : node;
62
178
  });
63
179
  }
64
180
 
181
+ /**
182
+ * The planned node ids a declaration set is checked against, when `plan.nodes`
183
+ * is a usable list. A non-array nodes field yields undefined, which leaves the
184
+ * assignment checks to `validateContract`'s own refusal.
185
+ *
186
+ * @param {unknown} nodes
187
+ * @returns {string[]|undefined}
188
+ */
189
+ function plannedNodeIdsOf(nodes) {
190
+ if (!Array.isArray(nodes)) return undefined;
191
+ /** @type {string[]} */
192
+ const ids = [];
193
+ for (const node of nodes) {
194
+ const id = /** @type {Record<string, unknown>|undefined} */ (node && typeof node === "object" ? node : undefined)?.id;
195
+ if (typeof id === "string") ids.push(id);
196
+ }
197
+ return ids;
198
+ }
199
+
200
+ /**
201
+ * The spec identity a frozen plan records, shape-checked before anything is
202
+ * written. Absent is legal so a caller that predates spec identity keeps
203
+ * freezing; a plan without it is refused by the Campaign Brief instead.
204
+ *
205
+ * @param {unknown} spec
206
+ * @returns {PlanSpecIdentity|undefined}
207
+ */
208
+ function specIdentityOf(spec) {
209
+ if (spec === undefined) return undefined;
210
+ assertObject(spec, "spec");
211
+ const record = /** @type {Record<string, unknown>} */ (spec);
212
+ rejectUnknown(record, new Set(["path", "digest"]), "spec");
213
+ requireString(record.path, "spec.path");
214
+ requirePacketHash(record.digest, "spec.digest");
215
+ return { path: /** @type {string} */ (record.path), digest: /** @type {string} */ (record.digest) };
216
+ }
217
+
218
+ /**
219
+ * The SHA-256 of a file's exact bytes: the independent digest a frozen plan's
220
+ * `plan.json.sha256` sidecar carries, recomputable by a reader without parsing
221
+ * the JSON.
222
+ *
223
+ * @param {string} path
224
+ * @returns {string}
225
+ */
226
+ export function fileDigest(path) {
227
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
228
+ }
229
+
230
+ /**
231
+ * The SHA-256 of a UTF-8 string, the same digest `fileDigest` computes for the
232
+ * exact bytes a reader sees. Used for the structured spec's content digest.
233
+ *
234
+ * @param {string} text
235
+ * @returns {string}
236
+ */
237
+ export function contentDigest(text) {
238
+ return createHash("sha256").update(text, "utf8").digest("hex");
239
+ }
240
+
241
+ /**
242
+ * Write the final frozen plan record and the `plan.json.sha256` sidecar over
243
+ * those exact bytes. The digest is taken from the file after it is written, so
244
+ * it always covers what a reader will read; the caller must not rewrite
245
+ * plan.json once this returns.
246
+ *
247
+ * @param {string} outDir
248
+ * @param {FrozenPlan} record the final record, status and approval included
249
+ * @returns {FrozenPlan}
250
+ */
251
+ export function writeFrozenPlanRecord(outDir, record) {
252
+ const planPath = join(outDir, "plan.json");
253
+ writeJsonAtomic(planPath, record);
254
+ writeTextAtomic(join(outDir, "plan.json.sha256"), `${fileDigest(planPath)}\n`);
255
+ return record;
256
+ }
257
+
65
258
  /**
66
259
  * Validate `plan` as a contract and, only once it is valid, write it and a
67
- * sibling plan.json naming its digest and provenance. `plan` supplies
68
- * `schemaVersion`/`contractVersion` itself; when it does not, this fills in
69
- * the runner's own current values.
260
+ * sibling plan.json naming its digest, its spec identity and its provenance.
261
+ * `plan` supplies `schemaVersion`/`contractVersion` itself; when it does not,
262
+ * this fills in the runner's own current values.
70
263
  *
71
264
  * contract.json is written before validation runs, because a packet may
72
265
  * declare `readFiles: ["contract.json"]` — an execution packet's own file,
@@ -74,21 +267,29 @@ function stampPhaseRequirementIds(nodes, phases) {
74
267
  * A validation failure removes that file again, so a caller never observes a
75
268
  * contract.json that failed its own check.
76
269
  *
77
- * `options.phases` carries the plan's per-phase requirement declarations —
78
- * each phase's requirementIds|deliverable pair — validated by the same check
270
+ * `options.phases` carries the plan's per-phase declarations — each phase's
271
+ * requirementIds, nodeIds and deliverable — validated by the same check
79
272
  * validatePlanOutput applies, and recorded on plan.json verbatim.
273
+ * `options.spec` carries the structured spec's path and content digest; when
274
+ * given, both are recorded so a reader can pin the plan to the exact spec it
275
+ * was planned from.
80
276
  *
81
277
  * @param {JsonObject} plan
82
- * @param {{outDir: string, provenance: PlanProvenanceInput, phases?: import("./template.mjs").PlanPhase[]}} options
278
+ * `options.facts` carries repo facts' measured durations; given, a
279
+ * verification timeout that does not cover one is refused.
280
+ *
281
+ * @param {{outDir: string, provenance: PlanProvenanceInput, phases?: import("./template.mjs").PlanPhase[], spec?: PlanSpecIdentity, facts?: MeasuredFacts}} options
83
282
  * @returns {FrozenPlan}
84
283
  */
85
- export function freezePlan(plan, { outDir, provenance, phases }) {
284
+ export function freezePlan(plan, { outDir, provenance, phases, spec, facts }) {
86
285
  // Shape-checked before anything is written, so a malformed declaration
87
286
  // leaves the outDir exactly as it was — the same failure discipline as the
88
- // validateContract rollback below. A phase that declares no requirementIds
89
- // passes here: the gap is validatePlanOutput's finding to report, not a
90
- // reason to refuse the freeze.
91
- const phaseDeclarations = validatePlanPhases(phases);
287
+ // validateContract rollback below. A declaration in the nodeIds shape must
288
+ // cover every planned node exactly once; a legacy declaration that names no
289
+ // requirements passes here, its gap being validatePlanOutput's finding to
290
+ // report rather than a reason to refuse the freeze.
291
+ const phaseDeclarations = validatePlanPhases(phases, plannedNodeIdsOf(plan.nodes));
292
+ const specIdentity = specIdentityOf(spec);
92
293
  mkdirSync(outDir, { recursive: true });
93
294
  const contractPath = join(outDir, "contract.json");
94
295
  const raw = /** @type {JsonObject} */ ({
@@ -101,7 +302,8 @@ export function freezePlan(plan, { outDir, provenance, phases }) {
101
302
  });
102
303
  writeJsonAtomic(contractPath, raw);
103
304
  try {
104
- validateContract(raw, contractPath);
305
+ const validated = validateContract(raw, contractPath);
306
+ if (facts) assertTimeoutsCoverMeasured(validated, facts);
105
307
  } catch (error) {
106
308
  rmSync(contractPath, { force: true });
107
309
  throw error;
@@ -109,9 +311,12 @@ export function freezePlan(plan, { outDir, provenance, phases }) {
109
311
  const frozen = /** @type {FrozenPlan} */ ({
110
312
  formatVersion: PLAN_FORMAT_VERSION,
111
313
  contractDigest: contractDigest(raw),
314
+ // The spec's path and digest pin the plan to the exact structured spec it
315
+ // was drafted from, so the brief can verify the pair before reading facts.
316
+ ...(specIdentity === undefined ? {} : { spec: specIdentity }),
112
317
  // The declarations ride on the record rather than the contract (the
113
318
  // contract schema takes no extra field), so the traceability a reviewer
114
- // saw is readable straight off plan.json.
319
+ // saw is readable straight off plan.json, nodeIds included.
115
320
  ...(phaseDeclarations === undefined ? {} : { phases: phaseDeclarations }),
116
321
  provenance: {
117
322
  packageVersion: packageVersion(),
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Planning pipeline shape adapters: runtime availability, sizing-node input,
3
+ * frozen contract-node output and read-volume measurement. Separate from the
4
+ * pipeline loop so orchestration rounds remain about sequencing and findings.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { RISK_TIERS } from "./template.mjs";
8
+
9
+ /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
10
+ /** @typedef {import("./pipeline.mjs").SizedPlanNode} SizedPlanNode */
11
+
12
+ /**
13
+ * @param {Record<string, JsonObject>} runtimes
14
+ * @returns {Record<string, {available: true, exhaustedUntil: null}>}
15
+ */
16
+ export function availabilityOf(runtimes) {
17
+ return Object.fromEntries(Object.keys(runtimes).map((id) => [id, { available: true, exhaustedUntil: null }]));
18
+ }
19
+
20
+ /** @param {Record<string, JsonObject>} runtimes @param {string|undefined} id @returns {string} */
21
+ export function modelOf(runtimes, id) {
22
+ const model = id ? runtimes[id]?.model : undefined;
23
+ return typeof model === "string" ? model : "";
24
+ }
25
+
26
+ /** @param {string[]} riskTiers @returns {string} */
27
+ export function highestOf(riskTiers) {
28
+ return riskTiers.reduce((highest, tier) => (RISK_TIERS.indexOf(tier) > RISK_TIERS.indexOf(highest) ? tier : highest), RISK_TIERS[0]);
29
+ }
30
+
31
+ /**
32
+ * @param {import("./template.mjs").PlanOutputNode} node
33
+ * @returns {SizedPlanNode}
34
+ */
35
+ export function toSizingNode(node) {
36
+ return /** @type {SizedPlanNode} */ ({
37
+ id: node.id,
38
+ dependsOn: node.dependsOn,
39
+ taskKind: node.taskKind,
40
+ riskTier: node.riskTier,
41
+ objective: node.objective,
42
+ expectedTurns: node.expectedTurns,
43
+ definitionOfDone: node.definitionOfDone,
44
+ taskPacket: {
45
+ readFiles: node.readFiles,
46
+ writeFiles: node.writeFiles,
47
+ scopeAcknowledged: node.scopeAcknowledged,
48
+ verification: node.verification,
49
+ },
50
+ });
51
+ }
52
+
53
+ /**
54
+ * @param {SizedPlanNode} node
55
+ * @param {string} phase
56
+ * @param {{worker: string|null, judge: string|null}|undefined} assignment
57
+ * @returns {JsonObject}
58
+ */
59
+ export function toContractNode(node, phase, assignment) {
60
+ const riskTier = /** @type {string} */ (node.riskTier);
61
+ const gate = riskTier === "low"
62
+ ? false
63
+ : {
64
+ review: riskTier === "high" ? "blocking" : "advisory",
65
+ failOn: riskTier === "high" ? ["major", "critical"] : ["critical"],
66
+ ...(assignment?.judge ? { runtime: assignment.judge } : {}),
67
+ };
68
+ return {
69
+ id: node.id,
70
+ type: node.taskKind,
71
+ phase,
72
+ dependsOn: node.dependsOn ?? [],
73
+ ...(assignment?.worker ? { runtime: assignment.worker } : {}),
74
+ taskPacket: {
75
+ mode: "execution",
76
+ objective: node.objective,
77
+ instructions: [node.objective],
78
+ readFiles: node.taskPacket.readFiles ?? [],
79
+ writeFiles: node.taskPacket.writeFiles ?? [],
80
+ scopeAcknowledged: node.taskPacket.scopeAcknowledged ?? [],
81
+ symbols: [],
82
+ decisions: [],
83
+ nonGoals: [],
84
+ verification: node.taskPacket.verification,
85
+ },
86
+ definitionOfDone: node.definitionOfDone ?? [],
87
+ gate,
88
+ };
89
+ }
90
+
91
+ /** @param {string} path @returns {number|null} */
92
+ export function fileLineCount(path) {
93
+ try {
94
+ const text = readFileSync(path, "utf8");
95
+ if (text === "") return 0;
96
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
97
+ } catch {
98
+ return null;
99
+ }
100
+ }