opencode-harness-kit 0.5.4 → 0.5.5

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/dist/profile.d.ts CHANGED
@@ -307,8 +307,21 @@ export declare function pruneThreshold(p: Profile, opts?: {
307
307
  unhealthy?: Set<string>;
308
308
  }): number;
309
309
  /**
310
- * Every model a profile can put behind the user: the base model plus the model
311
- * each lane actually resolves to.
310
+ * Every model a profile can put behind the user: the base model, the model
311
+ * each lane actually resolves to, and the utility route.
312
+ *
313
+ * The utility route belongs in this list because it is WRITTEN into the
314
+ * config as `small_model` and opencode sends real requests down it - a
315
+ * title, a one-line summary, a rewritten question. It used to be a chain
316
+ * rung only, so it never appeared here, and a profile could name a
317
+ * may-train utility route under a zero-retention claim: the file said
318
+ * "zero-retention" while a training provider served the calls.
319
+ *
320
+ * Every caller of this list wants the same thing - the models that can
321
+ * serve a request - so the list is widened rather than a second one
322
+ * invented beside it. The one caller that needs the lanes alone is
323
+ * `validate()`, which cross-checks the profile's DECLARED retention against
324
+ * the base model and the lanes; that is `laneModelsInPlay`.
312
325
  *
313
326
  * @param {Profile} p
314
327
  * @param {{ unhealthy?: Set<string> }} [opts]
@@ -317,6 +330,23 @@ export declare function pruneThreshold(p: Profile, opts?: {
317
330
  export declare function modelsInPlay(p: Profile, opts?: {
318
331
  unhealthy?: Set<string>;
319
332
  }): string[];
333
+ /**
334
+ * The models the profile's own base and lanes put behind the user, without
335
+ * the utility route.
336
+ *
337
+ * This is the set the profile's own `retention` field is about: the base
338
+ * model and the lanes it resolves to. The utility route is an extra route
339
+ * the profile adds on top of them, and it is reported where it belongs - the
340
+ * config header names it, and `oc-harness use` warns about it - so it does
341
+ * not decide what the profile may declare about itself.
342
+ *
343
+ * @param {Profile} p
344
+ * @param {{ unhealthy?: Set<string> }} [opts]
345
+ * @returns {string[]}
346
+ */
347
+ export declare function laneModelsInPlay(p: Profile, opts?: {
348
+ unhealthy?: Set<string>;
349
+ }): string[];
320
350
  /**
321
351
  * The split lanes, each with the limits of the model that lane actually runs
322
352
  * on. A lane on a different model has a different output cap, and the header
@@ -3,6 +3,28 @@ export declare const MAX_BYTES: number;
3
3
  export declare function logPath(dir: string): string;
4
4
  /** The telemetry file and its rotated predecessors, oldest first. */
5
5
  export declare function logFiles(dir: string): string[];
6
+ /**
7
+ * Rotate by size so a long-running project cannot fill its disk.
8
+ *
9
+ * THREE generations are kept: the current file, `.1` and `.2`. The reader
10
+ * enumerates all three, so the writer has to produce all three.
11
+ *
12
+ * The rotation used to rename the current file straight to `.1`, and a
13
+ * rename REPLACES the destination on POSIX - so the previous generation was
14
+ * destroyed on every rotation and `.2` never came into existence. Two
15
+ * rotations after the fact, the oldest history was gone and the third
16
+ * generation the reader looks for was missing: the history a project lost
17
+ * was lost on a schedule nobody was watching.
18
+ *
19
+ * So the oldest is shifted out of the way first, and the oldest of the
20
+ * three is dropped - the disk use stays bounded at three files, and the
21
+ * generation that is dropped is the one that was already two rotations
22
+ * old.
23
+ *
24
+ * @param file the file to rotate
25
+ * @param maxBytes the size above which it is rotated
26
+ */
27
+ export declare function rotate(file: string, maxBytes?: number): void;
6
28
  /**
7
29
  * Append one record.
8
30
  *
@@ -75,6 +75,13 @@ attributable measurement carries the gate.
75
75
  | 0 | The install is in place, or the change was confirmed. Something may have been left alone - a file that was already there, a prompt that was not answered - and the reason is printed. |
76
76
  | 1 | The install was refused, or a step failed. |
77
77
 
78
+ A target that was left alone is NOT counted as installed: the install says
79
+ so in place of its tick, and `verify` reports the file that is not the kit's
80
+ - because a file of the user's own at `GLOBAL.md` means the kit's rules are
81
+ not being loaded, and a tick next to that is a claim the installer cannot
82
+ make. A link that could not be created is a step that failed, and the
83
+ install exits 1.
84
+
78
85
  `doctor` also exits 1 when there is no config, or no profile recorded in
79
86
  it: a kit that is installed but points at nothing is a broken install, and
80
87
  a check that passes it is not a check.
@@ -361,13 +361,24 @@ re-reads stop: **-69% input tokens and -60% wall time** on that task, at the
361
361
  cost of a bigger context. It is a setting - `prune.keepDistinctOutputs` - and
362
362
  turning it off brings the old behaviour back.
363
363
 
364
- A `savedChars` of 0 in `prune.jsonl` is not a second, redundant pass. It means
365
- `bytesAfter` did not come out below `bytesBefore` for that turn, which happens
366
- when a stub is no shorter than the output it replaced (the stub's own label
367
- costs more than a short body), or when a `reasoning` part was under the 200
368
- byte floor and so was not shortened at all. These lines are still logged
369
- because a part was stubbed, which is the event worth recording. Savings
370
- aggregate correctly either way, since each line is measured independently.
364
+ `savedChars` in `prune.jsonl` is the size of the request before the pass minus
365
+ the size after it, measured on the parts as they are serialised - not on the
366
+ text inside them. It used to measure the text only, and to ignore the state
367
+ the stub put in a part's place, which carries about fifty characters of its
368
+ own metadata per stubbed part. So a pass whose request GREW was reported as
369
+ a saving of the difference between two texts.
370
+
371
+ A negative `savedChars` therefore means the pass made the request BIGGER: the
372
+ stub was longer than the output it replaced, because the stub carries its own
373
+ label and its own metadata. Such a pass is still logged, because a part was
374
+ stubbed, which is the event worth recording, and the negative number is the
375
+ honest report of it.
376
+
377
+ There is no minimum size under which a part is left alone. This file used to
378
+ claim a 200 byte floor for `reasoning` parts; there was never such a floor in
379
+ the code, and a reasoning part shorter than the stub - about seventy-eight
380
+ characters - is still replaced, so the request grows. The measurement above
381
+ is what makes that visible instead of silent.
371
382
 
372
383
  `baseline.mjs` measures permission pressure with its own glob approximation of
373
384
  OpenCode's matcher, not OpenCode's matcher. It handles the cases that matter
@@ -186,11 +186,20 @@ const hook = await HarnessPlugin(
186
186
  const transform = hook["experimental.chat.messages.transform"]
187
187
 
188
188
  /**
189
- * The size the PRUNER measures.
189
+ * The size this measurement counts.
190
190
  *
191
- * The pruner counts CHARACTERS, not bytes: `part.text.length`. The report
192
- * labelled that "bytes", which is wrong for anything that is not ASCII - and
193
- * an agent's output is not ASCII. Both numbers are reported.
191
+ * CHARACTERS, not bytes: `part.text.length`. The report labelled that
192
+ * "bytes", which is wrong for anything that is not ASCII - and an agent's
193
+ * output is not ASCII. Both numbers are reported.
194
+ *
195
+ * This counts the TEXT of every part, while the pruner's own log counts the
196
+ * PART - and only the parts the pruner touched. So the two numbers answer
197
+ * two different questions and are not the same number twice: this one asks
198
+ * how much TEXT the pruner removed from a stored session, the log asks how
199
+ * much the REQUEST that went out shrank. The stub the pruner leaves behind
200
+ * carries about fifty characters of metadata of its own, so the log's
201
+ * saving is the smaller of the two, and it can even be negative where this
202
+ * one is positive. The gap is stated rather than hidden.
194
203
  */
195
204
  function sizeOf(part) {
196
205
  if (typeof part?.text === "string") return part.text.length
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Harness kit for OpenCode: capability profiles, a fleet of job lanes with one free model each, role-based reasoning effort, context pruning, and measured baselines.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pham-canh/opencode-harness-kit#readme",
package/src/config.js CHANGED
@@ -158,6 +158,11 @@ export function utilityModel(p) {
158
158
  * `instructions` array used to replace it wholesale, which unloaded the
159
159
  * whole behavioural layer while everything still looked correct.
160
160
  *
161
+ * A SINGLE string is a list of one. The parameter has always been typed
162
+ * `string[] | string | undefined`, but a string was dropped on the floor:
163
+ * the file that came back named only the kit's GLOBAL.md, so the user's own
164
+ * instructions were unloaded without a word.
165
+ *
161
166
  * The user's own files keep their order and come first; the kit's is
162
167
  * appended. Duplicates are dropped, so re-running `use` does not grow the
163
168
  * list.
@@ -167,7 +172,12 @@ export function utilityModel(p) {
167
172
  * @returns {string[]}
168
173
  */
169
174
  export function mergeInstructions(base, kitPath) {
170
- const list = Array.isArray(base) ? base.filter((x) => typeof x === "string") : []
175
+ const given = Array.isArray(base)
176
+ ? base
177
+ : typeof base === "string"
178
+ ? [base]
179
+ : []
180
+ const list = given.filter((x) => typeof x === "string")
171
181
  /** @type {string[]} */
172
182
  const out = []
173
183
  for (const entry of [...list, kitPath]) {
@@ -235,6 +245,75 @@ export const BASH_ALLOWLIST = [
235
245
  */
236
246
  export { BASH_DENYLIST } from "./denylist.js"
237
247
 
248
+ /**
249
+ * The user's own bash rules, as a map the kit can merge into its own.
250
+ *
251
+ * The value has to be one of the three verbs opencode's schema defines. A
252
+ * rule the kit cannot express cannot work anyway, and writing it out
253
+ * unchanged would make the whole config invalid - which is a worse outcome
254
+ * than a rule the kit drops.
255
+ *
256
+ * A rule the kit drops is NOT dropped silently: the pattern and the verb it
257
+ * carried are returned in `dropped`, and the header of the generated file
258
+ * names them. The rule was the user's, and a file that comes back looking
259
+ * exactly as it did before is how a lost rule stays lost.
260
+ *
261
+ * `permission.bash` may also be a SINGLE verb, and a single verb is the
262
+ * blanket rule - every command that no other rule names. It used to be
263
+ * dropped on the floor with the rest of the non-object values, so a user
264
+ * who had `bash: "allow"` lost the blanket allow and got the kit's map
265
+ * instead.
266
+ *
267
+ * @param {any} base the config as it is on disk
268
+ * @returns {{ rules: Record<string, string>, dropped: string[] }}
269
+ */
270
+ function userBashRules(base) {
271
+ const own = base?.permission?.bash
272
+ /** @type {Record<string, string>} */
273
+ const rules = {}
274
+ /** @type {string[]} */
275
+ const dropped = []
276
+
277
+ /**
278
+ * Keep a rule the kit can write, or name the one it cannot.
279
+ *
280
+ * @param {string} pattern
281
+ * @param {any} verb
282
+ */
283
+ const keep = (pattern, verb) => {
284
+ if (verb === "allow" || verb === "ask" || verb === "deny") {
285
+ rules[pattern] = verb
286
+ return
287
+ }
288
+ const said = typeof verb === "string" ? JSON.stringify(verb) : String(verb)
289
+ dropped.push(`permission.bash "${pattern}" -> ${said}`)
290
+ }
291
+
292
+ if (own === undefined || own === null) return { rules, dropped }
293
+
294
+ if (typeof own === "string") {
295
+ keep("*", own)
296
+ return { rules, dropped }
297
+ }
298
+
299
+ // Anything else that is not a map of rules cannot be merged, and it is
300
+ // named so the loss is not silent either.
301
+ if (typeof own !== "object" || Array.isArray(own)) {
302
+ dropped.push(
303
+ `permission.bash is ${Array.isArray(own) ? "a list" : String(own)}, which is not a rule the kit can write`,
304
+ )
305
+ return { rules, dropped }
306
+ }
307
+ for (const [pattern, verb] of Object.entries(own)) {
308
+ if (typeof pattern !== "string" || !pattern) {
309
+ dropped.push(`permission.bash has a rule with no pattern, which was dropped`)
310
+ continue
311
+ }
312
+ keep(pattern, verb)
313
+ }
314
+ return { rules, dropped }
315
+ }
316
+
238
317
  /**
239
318
  * @param {Profile} p
240
319
  * @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string> }} [opts]
@@ -294,9 +373,34 @@ export function generateConfig(p, opts = {}) {
294
373
  agent[role] = existing
295
374
  }
296
375
 
376
+ // The user's own bash rules are merged in, not replaced.
377
+ //
378
+ // The map used to be built from the kit's two lists alone, so every rule
379
+ // the user had written was deleted on the next `oc-harness use` - and the
380
+ // file that came back looked exactly as it had before, so nothing said
381
+ // it. The rules that let somebody run their own tools without a prompt
382
+ // were the ones that vanished.
383
+ //
384
+ // The order is the precedence ladder, and it is the kit's denylist that
385
+ // closes it:
386
+ //
387
+ // kit allowlist -> the user's rules -> kit denylist
388
+ //
389
+ // The denylist is last because a deny a user rule could re-allow would
390
+ // not be a deny, and the denylist is the mechanical half of the safety
391
+ // policy. The user's rules sit after the allowlist so a rule of their own
392
+ // is not shadowed by one of the kit's.
393
+ //
394
+ // The kit's own catch-all stays the FIRST key, so a user rule for `*`
395
+ // replaces its value and not its position: the catch-all remains the
396
+ // fallback for the commands no other rule mentions, and the allowlist
397
+ // still decides the ones it names. opencode takes the last matching rule,
398
+ // so this order is the whole rule.
399
+ const own = userBashRules(base)
297
400
  const bashRules = {
298
401
  "*": "ask",
299
402
  ...Object.fromEntries(BASH_ALLOWLIST.map((rule) => [rule, "allow"])),
403
+ ...own.rules,
300
404
  ...Object.fromEntries(BASH_DENYLIST.map((rule) => [rule, "deny"])),
301
405
  }
302
406
 
@@ -423,13 +527,19 @@ export function generateConfig(p, opts = {}) {
423
527
  },
424
528
  }
425
529
 
426
- return render(out, p, { comments: opts.comments ?? true, instructionsPath, unhealthy })
530
+ return render(out, p, {
531
+ comments: opts.comments ?? true,
532
+ instructionsPath,
533
+ unhealthy,
534
+ dropped: own.dropped,
535
+ })
427
536
  }
428
537
 
429
538
  /**
430
539
  * @param {Record<string, any>} cfg
431
540
  * @param {Profile} p
432
- * @param {{ comments: boolean, instructionsPath?: string, unhealthy?: Set<string> }} opts
541
+ * @param {{ comments: boolean, instructionsPath?: string, unhealthy?: Set<string>,
542
+ * dropped?: string[] }} opts
433
543
  * @returns {string}
434
544
  */
435
545
  function render(cfg, p, opts) {
@@ -481,6 +591,19 @@ function render(cfg, p, opts) {
481
591
  `${c}ceiling ${p.maxRetention} - a lane may only resolve to a model at or below this`,
482
592
  ]
483
593
  : []),
594
+ // The rules the kit could not carry over. They are named HERE, in the
595
+ // file the user reads, because a rule that is dropped without a word is
596
+ // a rule that is lost for good: the file comes back looking exactly as
597
+ // it did before, and the prompt it used to allow is back.
598
+ ...(opts.dropped?.length
599
+ ? [
600
+ `${c}`,
601
+ `${c}Dropped from your own permission.bash rules, and NOT in the file below.`,
602
+ `${c}The verb has to be one of allow, ask or deny - a rule the kit cannot`,
603
+ `${c}write would fail the schema for the whole file:`,
604
+ ...opts.dropped.map((d) => `${c} ${d}`),
605
+ ]
606
+ : []),
484
607
  `${c}`,
485
608
  `${c}Derived numbers, do not hand-edit:`,
486
609
  `${c} compaction.reserved ${reservedTokens(p, { unhealthy }).toLocaleString("en-US")} (${Math.round((p.effort?.reserveShare ?? 0.12) * 100)}% of the narrowest input cap in play, held back for reasoning growth)`,
package/src/denylist.js CHANGED
@@ -19,10 +19,50 @@ export const BASH_DENYLIST = [
19
19
  // so opencode prompts for human approval before publishing.
20
20
  "rm -rf *",
21
21
  "rm -fr *",
22
+ // The short forms of the same flags, one at a time.
23
+ //
24
+ // The guard's rule is a TOKEN scanner: it blocks an rm whose short flag
25
+ // cluster contains r or f in any case, and an rm whose long flag is
26
+ // --recursive or --force. So `rm -f file` and `rm -r dir` are blocked
27
+ // there - and the permission map has to deny what the guard blocks, or
28
+ // the two layers contradict each other: the map would ask about a
29
+ // command the guard refuses.
30
+ //
31
+ // `-R` is named too because the guard's rule is case-insensitive and
32
+ // GNU rm reads `-R` as `--recursive`.
33
+ //
34
+ // The two layers are not the same matcher. The guard reads the tokens
35
+ // and can see a destructive flag in any position; this list is a flat
36
+ // glob over the command, so it can only name the spellings.
37
+ //
38
+ // A cluster a glob cannot express (`-rfv`) is caught by the guard and
39
+ // asked about by the config - the config asks about everything it does
40
+ // not name, and asking is the safe direction.
41
+ //
42
+ // A PERMUTED delete (`rm /tmp/x -rf`) is caught by neither layer today:
43
+ // the guard's rule needs the flag before the path, and the glob needs
44
+ // the flag in front. The config asks, so a human decides. Closing that
45
+ // hole is a change to the guard's rule in src/rules.js, which reads the
46
+ // tokens - a glob here could only name one spelling of it, and the
47
+ // spelling would still be the one it missed.
48
+ "rm -f *",
49
+ "rm -r *",
50
+ "rm -R *",
51
+ // The long GNU names of the same two flags. A delete that is spelled
52
+ // `--recursive --force` is the delete the short forms name.
53
+ "rm --recursive *",
54
+ "rm --force *",
22
55
  "curl * | sh",
23
56
  "curl * | bash",
57
+ // The same pipe into a shell that is named by its path. The shell is the
58
+ // same shell either way, and the config can only match what it can see.
59
+ "curl * | */sh",
60
+ "curl * | */bash",
24
61
  "chmod 777 *",
25
62
  "chmod 777*",
63
+ // The same mode written with a leading zero.
64
+ "chmod 0777 *",
65
+ "chmod 0777*",
26
66
  "gh pr create*",
27
67
  "gh pr merge*",
28
68
  "gh pr close*",
@@ -52,6 +92,10 @@ export const BASH_DENYLIST = [
52
92
  *
53
93
  * `*` matches any run of characters, including none.
54
94
  *
95
+ * Every other metacharacter is literal. `?` is a regex quantifier, so an
96
+ * unescaped one made the character before it optional and the pattern
97
+ * matched a command it never named.
98
+ *
55
99
  * @param {string} command
56
100
  * @param {string} pattern
57
101
  * @returns {boolean}
@@ -59,7 +103,7 @@ export const BASH_DENYLIST = [
59
103
  export function matchesPattern(command, pattern) {
60
104
  const re = new RegExp(
61
105
  "^" +
62
- pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") +
106
+ pattern.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") +
63
107
  "$",
64
108
  )
65
109
  return re.test(command)
@@ -78,7 +122,7 @@ export function matchesPattern(command, pattern) {
78
122
  * same list and one of them reads it more carefully; that is the price of
79
123
  * having the list in two places.
80
124
  *
81
- * The two PIPE patterns are for the config's side only. `segments()` splits
125
+ * The PIPE patterns are for the config's side only. `segments()` splits
82
126
  * on `|`, so a segment never contains " | sh" and the guard catches a piped
83
127
  * script through its own rule instead.
84
128
  *
package/src/guard.ts CHANGED
@@ -31,12 +31,50 @@ export const MARKER = "[opencode-harness-kit] blocked"
31
31
  */
32
32
  const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"])
33
33
 
34
+ /**
35
+ * Whether a hunk header follows, before another file's headers do.
36
+ *
37
+ * A `---`/`+++` pair is the header pair of a file only when a hunk header
38
+ * (`@@`) comes after it. That is the difference between the headers of a
39
+ * diff and two body lines that happen to look like one, and it is the same
40
+ * thing a reader of the diff does.
41
+ *
42
+ * @param {string[]} lines
43
+ * @param {number} from the line to start looking at
44
+ * @returns {boolean}
45
+ */
46
+ function hunkFollows(lines: string[], from: number): boolean {
47
+ for (let i = from; i < lines.length; i++) {
48
+ if (/^@@/.test(lines[i])) return true
49
+ // Another pair of headers starts here, so this pair was not followed by
50
+ // a hunk of its own.
51
+ if (/^(?:---|\+\+\+)\s/.test(lines[i])) return false
52
+ }
53
+ return false
54
+ }
55
+
34
56
  /**
35
57
  * The files a patch touches.
36
58
  *
37
59
  * `apply_patch` sends a patch, not a path, so the paths have to be read out
38
60
  * of it. Two formats are in use: opencode's own `*** Update File: path`
39
- * markers, and a plain unified diff with `+++ b/path` headers.
61
+ * markers, and a plain unified diff with `--- a/path` and `+++ b/path`
62
+ * headers.
63
+ *
64
+ * A unified diff names the file TWICE: `--- a/path` is where the change
65
+ * comes FROM, `+++ b/path` is where it goes TO. A deletion is `--- a/path`
66
+ * and `+++ /dev/null`, so the path that has to be scope-checked is the one
67
+ * in the `---` header and nowhere else. Reading only the `+++` header meant
68
+ * a patch that deleted a file outside the project was checked against
69
+ * nothing at all - and a patch that deletes a file is exactly the shape
70
+ * that reaches outside the project.
71
+ *
72
+ * The headers of a diff come BEFORE its first hunk, and a body line can
73
+ * look like a header: a removed line whose content is "-- foo" renders as
74
+ * "--- foo", and an added line "++ bar" renders as "+++ bar". Reading
75
+ * those as the headers of a file collected two words as paths, and a path
76
+ * that is not a path is a write the guard refuses for a reason nobody can
77
+ * find.
40
78
  *
41
79
  * @param {string} text
42
80
  * @returns {string[]}
@@ -44,16 +82,49 @@ const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"])
44
82
  export function pathsInPatch(text: string): string[] {
45
83
  const out: string[] = []
46
84
  const push = (p: string) => {
47
- const v = p.trim()
85
+ // A unified diff header can end in a timestamp: `--- a/file\t2026-01-01`.
86
+ const v = p.split("\t")[0].trim()
48
87
  if (!v) return
49
88
  if (v === "/dev/null") return
50
89
  if (!out.includes(v)) out.push(v)
51
90
  }
52
- for (const m of text.matchAll(/^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/gm)) {
53
- push(m[1])
54
- }
55
- for (const m of text.matchAll(/^\+\+\+\s+(?:b\/)?(.+)$/gm)) {
56
- push(m[1])
91
+ const lines = text.split("\n")
92
+ // Whether a hunk header has been seen. After one, a `+++` line is added
93
+ // content and not the header of another file.
94
+ let seenHunk = false
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const line = lines[i]
97
+ const marker = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/.exec(line)
98
+ if (marker) {
99
+ push(marker[1])
100
+ continue
101
+ }
102
+ if (/^@@/.test(line)) {
103
+ seenHunk = true
104
+ continue
105
+ }
106
+ // The `---` and `+++` headers of a unified diff are one pair: the
107
+ // first names where the change comes FROM, the second where it goes
108
+ // TO. They are read together so a body line that looks like a header
109
+ // is not mistaken for one.
110
+ const minus = /^---\s+(?:a\/)?(.+)$/.exec(line)
111
+ const next = i + 1 < lines.length ? /^\+\+\+\s+(?:b\/)?(.+)$/.exec(lines[i + 1]) : null
112
+ if (minus && next) {
113
+ // A pair is a header pair only when a hunk header follows it.
114
+ if (hunkFollows(lines, i + 2)) {
115
+ push(minus[1])
116
+ push(next[1])
117
+ }
118
+ // The `+++` line is consumed either way: it is the other half of the
119
+ // pair, and not a header of its own.
120
+ i++
121
+ continue
122
+ }
123
+ // A diff that carries only the `+++` header still names its file. It
124
+ // only names one while no hunk has been seen - after a hunk, a `+++`
125
+ // line is added content.
126
+ const plus = /^\+\+\+\s+(?:b\/)?(.+)$/.exec(line)
127
+ if (plus && !seenHunk) push(plus[1])
57
128
  }
58
129
  return out
59
130
  }