@skitterbyte/skitterspec 16.2.0 → 16.3.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.
package/MIGRATION.md ADDED
@@ -0,0 +1,156 @@
1
+ # Migration guide
2
+
3
+ ## `@skitterbyte/skitterspec-linear` v8 → v9 (a spec is an Issue, phases are sub-issues)
4
+
5
+ **v9 remaps the Linear mirror.** A spec is now a Linear **issue** (not a Project),
6
+ each phase a **sub-issue** (not a Milestone), and **tasks are no longer synced**
7
+ (they stay in the repo phase files). This collapses a large spec from ~1 project +
8
+ N milestones + dozens of task-issues down to **one issue + one sub-issue per
9
+ phase**. The base `@skitterbyte/skitterspec` is unaffected (still v15).
10
+
11
+ ### Breaking changes
12
+
13
+ | Area | v8 | v9 |
14
+ |------|-----|-----|
15
+ | `linear.config.json` → `mapping` | `{specFolder:"project", phases:"milestone", tasks:"issue"}` | `{specFolder:"issue", phases:"subissue", tasks:"none"}` |
16
+ | `linear.config.json` → `linear` | `initiativeId` | `projectId` (the project picker's default) |
17
+ | `linear.config.json` → `states` | Linear **Project** statuses (e.g. `Completed`) | Linear **issue** workflow states (e.g. `Done`) |
18
+ | `linear.config.json` → `sync.fieldOwnership` | `{description, milestones, tasks, workflowState}` | `{description, subIssues, workflowState}` |
19
+ | Phase frontmatter | `linear_milestone_id` | `linear_issue_id` (the sub-issue id) |
20
+ | Overview frontmatter | `linear_project_id` + `linear_identifier` | `linear_identifier` (the spec issue) |
21
+ | Last-pushed snapshot | `{project, milestones, issues}` | `{issue, subIssues}` |
22
+
23
+ ### What to do
24
+
25
+ 1. **Upgrade and re-run `update`:** `npx @skitterbyte/skitterspec-linear update`.
26
+ It refreshes the skills, the `linear.config.md` / `SETUP.md` docs, and the
27
+ config example.
28
+ 2. **Edit `specs/.core/linear.config.json`** to the new keys above (or delete it
29
+ and re-copy `linear.config.json.example`). Point `states` at your workspace's
30
+ **issue** states; set `linear.projectId` if most specs belong to one Project —
31
+ it pre-selects the picker's default rather than fixing every spec there.
32
+ 3. **Optionally add `intake`** to start specs from issues someone else filed:
33
+
34
+ ```jsonc
35
+ "intake": {
36
+ "label": "web-app", // the inbox `/spec --from-issue` browses
37
+ "bugLabels": ["bug"] // issues with these route to /spec-bug
38
+ }
39
+ ```
40
+
41
+ Without it, `/spec SKI-123` still adopts an issue by id; only the browsable
42
+ inbox and the bug routing need the labels.
43
+ 4. **Existing pushed specs:** the snapshot format changed, so the first
44
+ `/spec-push` after upgrading **re-creates** the mirror (a fresh issue +
45
+ sub-issues). Delete any stale `specs/.core/linear-base/*.base.json` and the old
46
+ `linear_project_id` / `linear_milestone_id` frontmatter first. If you were
47
+ pre-first-push, there's nothing to reconcile.
48
+ 5. **Task-level issues** created under v8 are no longer managed by the sync —
49
+ close or repurpose them in Linear by hand.
50
+
51
+ ## `@skitterbyte/skitterspec` v2 → v3 (slimmer surface + local traffic diversion)
52
+
53
+ **v3 shrinks the everyday command surface to five verbs — `spec → go → connect →
54
+ commit → complete` — by folding provisioning, teardown, and grooming into the
55
+ lifecycle skills, and adds `/spec-connect` for testing a worktree at your normal
56
+ `localhost` URL.** (`@skitterbyte/skitterspec-linear` moves to v2.0.0 in lockstep.)
57
+
58
+ ### Removed skills (breaking) → where they went
59
+
60
+ | Removed skill | Replaced by |
61
+ |---------------|-------------|
62
+ | `/spec-env` | **Automatic in `/spec-go`** — it provisions the worktree and (with your OK) starts the spec's dev servers. Escalate Docker later with the CLI: `skitterspec spec-env up <name>`. |
63
+ | `/spec-env-down` | **Folded into `/spec-complete` and `/spec-cancel`** — they tear the environment down (dev servers, worktree, stack, slot) as part of finishing/abandoning a spec. |
64
+ | `/spec-ready` | **Folded into `/spec`** — grilling now writes a `Ready` spec directly (or `Draft` if you deliberately leave open questions). Go straight to `/spec-go`. |
65
+
66
+ The **`skitterspec spec-env` CLI engine stays** (`up`, `down`, `dev`, `connect`,
67
+ `integrate`, `status`, `resolve`) — only the three *skills* were removed. Anything
68
+ that scripted those CLI verbs keeps working.
69
+
70
+ ### New — `/spec-connect` and two config blocks
71
+
72
+ - **`/spec-connect <name>`** points your canonical `localhost` ports at a spec's
73
+ running dev servers (so you can test a worktree's UI/API at the normal URL);
74
+ `/spec-connect main` hands the ports back. It's a small bundled Node reverse
75
+ proxy — no external install. Exclusive: one spec exposed at a time.
76
+ - **`env.config.json` gains `dev` and `proxy` blocks.** `dev` lists the host dev
77
+ servers `/spec-go` starts (`{ name, command, portVar, health?, frontPort? }`);
78
+ `proxy` configures the front-door proxy (`{ enabled, host }`). Both default to
79
+ off/empty, so existing projects are unaffected until you fill `dev` in.
80
+
81
+ ### What to do
82
+
83
+ 1. **Upgrade and re-run `init`** (or `update`): `npx @skitterbyte/skitterspec
84
+ update`. It stops installing the three removed skills, installs `/spec-connect`,
85
+ and refreshes the CLAUDE.md section + `spec-planning` rule. Your specs and
86
+ `env.config.json` are untouched.
87
+ 2. **Remove muscle memory for the old commands** — use `/spec-go` to bring a spec
88
+ up, `/spec-complete`/`/spec-cancel` to tear it down, and `/spec` (no separate
89
+ `/spec-ready`) to reach a Ready spec.
90
+ 3. **To test UI/API worktrees:** add a `dev` block to `env.config.json` (see
91
+ `specs/.core/env.config.md`), then `/spec-go` → `/spec-connect <name>`.
92
+
93
+ ## `@skitterbyte/skitterspec` v1 → v2 (tracker-free base)
94
+
95
+ **v2 of the base package is tracker-free.** The Linear sync feature — the
96
+ `/spec-status`, `/spec-push` skills, the `spec-sync` CLI, the
97
+ Linear-aware steps of `/spec` and `/spec-go`, and the `linear.config.*`
98
+ templates — moved out of `@skitterbyte/skitterspec` into a separate **superset**
99
+ distribution, `@skitterbyte/skitterspec-linear`. You now install exactly one:
100
+
101
+ | If you… | Install |
102
+ |---------|---------|
103
+ | don't sync specs to a tracker | `@skitterbyte/skitterspec` (v2) |
104
+ | use (or want) Linear sync | `@skitterbyte/skitterspec-linear` |
105
+
106
+ Everything else — the spec lifecycle and per-spec isolation — is unchanged and
107
+ present in **both**.
108
+
109
+ ### If you did NOT use Linear sync
110
+
111
+ Nothing to do. Upgrade to v2 and re-run `init` (or `update`) as usual. The base
112
+ never installed the Linear skills for you, so there's nothing to remove.
113
+
114
+ ### If you DID use Linear sync
115
+
116
+ Switching is one install plus a re-`init`:
117
+
118
+ 1. **Install the superset** (in place of the base):
119
+
120
+ ```sh
121
+ npm rm @skitterbyte/skitterspec # if it was a dependency
122
+ npx @skitterbyte/skitterspec-linear init
123
+ ```
124
+
125
+ 2. **Re-run `init`.** It re-installs the shared skills (now composed with the
126
+ Linear steps) and the three sync skills, and re-scaffolds the config
127
+ templates. Your existing files are preserved — `init` never overwrites without
128
+ `--force`.
129
+
130
+ 3. **Your config is unchanged.** The live config path is still
131
+ `specs/.core/linear.config.json`, and the committed base sidecars under
132
+ `specs/.core/linear-base/` are read as-is. No re-linking, no re-sync.
133
+
134
+ That's it — `/spec-status`, `/spec-push`, and `skitterspec-linear
135
+ spec-sync …` work exactly as before.
136
+
137
+ ### One config note — branch naming
138
+
139
+ Embedding the Linear identifier in a worktree branch name is now configured in the
140
+ **isolation** config, not the Linear config. In `specs/.core/env.config.json` set:
141
+
142
+ ```jsonc
143
+ "branch": { "pattern": "{identifier}-{slug}", "identifierField": "linear_identifier" }
144
+ ```
145
+
146
+ If you don't need the id in branch names, leave the default `{type}/{slug}` — the
147
+ old implicit Linear-branch behaviour is off unless you opt in this way. (This is
148
+ the only behavioural change beyond the package split.)
149
+
150
+ ## Why the split
151
+
152
+ The base couldn't ship without a specific tracker's fingerprints baked into shared
153
+ skills and a `src/sync/` engine. Extracting the provider makes the base a clean,
154
+ tracker-free workflow and lets a new provider (e.g. Jira) ship as another superset
155
+ over the same base — without re-patching the base. See
156
+ `specs/complete/feat-extract-ticketing-provider/` for the full rationale.
package/README.md CHANGED
@@ -22,6 +22,25 @@ This installs the skills + rules into `.claude/`, scaffolds `specs/`, and patche
22
22
  `CLAUDE.md`. See `.claude/rules/spec-planning.md` after install to set your
23
23
  project's typecheck/test/lint commands.
24
24
 
25
+ ## Upgrading
26
+
27
+ ```sh
28
+ npx @skitterbyte/skitterspec update
29
+ ```
30
+
31
+ `update` refreshes the files it manages (skills, rules, `specs/.core` docs) and
32
+ **keeps anything you edited**. A file it kept is listed under
33
+ `customized (kept)` with the change it declined summarised as `+added −removed`:
34
+
35
+ ```
36
+ customized (kept):
37
+ .claude/rules/spec-planning.md +34 −13
38
+ ```
39
+
40
+ Add `--diff` to see those changes as a unified diff before deciding whether to
41
+ re-apply your edits on top, or `--force` to take the package version and lose
42
+ them. Your `specs/` content and live `.core` config are never touched.
43
+
25
44
  ## Pick one distribution
26
45
 
27
46
  Ticketing sync is a **separate superset you install instead of this one**:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec",
3
- "version": "16.2.0",
3
+ "version": "16.3.0",
4
4
  "description": "Spec-driven development for Claude Code — a tracker-free filesystem workflow: lifecycle skills and per-spec isolation. For Linear sync, install @skitterbyte/skitterspec-linear instead.",
5
5
  "keywords": [
6
6
  "claude",
@@ -19,7 +19,8 @@
19
19
  "files": [
20
20
  "bin",
21
21
  "src",
22
- "assets"
22
+ "assets",
23
+ "MIGRATION.md"
23
24
  ],
24
25
  "engines": {
25
26
  "node": ">=18"
package/src/cli.js CHANGED
@@ -79,6 +79,8 @@ Options (init / update):
79
79
  --reset (init) Start again: reset managed scaffolding fresh
80
80
  (needs --yes; never touches your specs or config)
81
81
  --force Overwrite skill/rule/script files that already exist
82
+ --diff (update) Show the upstream changes each customized
83
+ file declined, as a unified diff
82
84
  --dir <path> Target project dir (default: positional arg or cwd)
83
85
  --no-claude-md Skip creating/patching CLAUDE.md
84
86
  --yes, -y Accept defaults; skip the interactive setup prompts
@@ -104,6 +106,7 @@ function parse(argv) {
104
106
  removeReleaseTooling: false,
105
107
  resync: false,
106
108
  reset: false,
109
+ diff: false,
107
110
  }
108
111
  const positional = []
109
112
  for (let i = 0; i < argv.length; i++) {
@@ -116,6 +119,7 @@ function parse(argv) {
116
119
  else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
117
120
  else if (a === '--resync') opts.resync = true
118
121
  else if (a === '--reset') opts.reset = true
122
+ else if (a === '--diff') opts.diff = true
119
123
  else if (a === '--dir') opts.dir = argv[++i]
120
124
  else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
121
125
  else positional.push(a)
@@ -1346,7 +1350,7 @@ async function run(argv) {
1346
1350
  break
1347
1351
  }
1348
1352
  if (action === 'resync') {
1349
- resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
1353
+ resync(dir, { claudeMd: opts.claudeMd, force: opts.force, diff: opts.diff })
1350
1354
  break
1351
1355
  }
1352
1356
  // action === 'create-missing' → fall through to a normal (skip-existing) init.
@@ -1365,7 +1369,7 @@ async function run(argv) {
1365
1369
  case 'update':
1366
1370
  // `update` is a resync — refresh managed files, keep customized ones
1367
1371
  // (--force to overwrite). Leaves specs/ and live .core config alone.
1368
- resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
1372
+ resync(dir, { claudeMd: opts.claudeMd, force: opts.force, diff: opts.diff })
1369
1373
  await cleanupReleaseTooling(dir, opts)
1370
1374
  break
1371
1375
  default:
package/src/init.js CHANGED
@@ -53,7 +53,7 @@ const CORE_FILES = listCoreTemplates()
53
53
  const SPEC_MARKER_START = '<!-- skitterspec:start -->'
54
54
  const SPEC_MARKER_END = '<!-- skitterspec:end -->'
55
55
 
56
- const report = { created: [], updated: [], skipped: [], removed: [], customized: [], warnings: [] }
56
+ const report = { created: [], updated: [], skipped: [], removed: [], customized: [], healed: [], warnings: [] }
57
57
 
58
58
  function resetReport() {
59
59
  for (const k of Object.keys(report)) report[k].length = 0
@@ -82,6 +82,8 @@ function ensureDir(p) {
82
82
  // old version we own" (safe to update) from "a file the user edited" (keep). It
83
83
  // lists only managed FILES (skills, rules, .core templates) — never user content.
84
84
 
85
+ const { linesDiff } = require('./lines-diff.js')
86
+
85
87
  const MANIFEST_FILE = path.join('specs', '.core', '.skitterspec-manifest.json')
86
88
  const MANIFEST_VERSION = 1
87
89
 
@@ -132,13 +134,24 @@ function writeManifest(dir, files) {
132
134
 
133
135
  // Classify a managed file against the manifest baseline.
134
136
  // missing — not on disk
135
- // pristine — on disk and matches the hash we recorded (ours to update)
136
- // customized — on disk but differs (or unknown) — a user edit; keep it
137
- function managedState(dir, relPath, manifest) {
137
+ // pristine — ours to update: it matches the package asset, or the hash we recorded
138
+ // customized — on disk but differs from both — a user edit; keep it
139
+ //
140
+ // `bundled` (the current package asset) is optional but decisive: a file whose
141
+ // CONTENT equals what we ship is not customized, whatever the manifest says.
142
+ // Without that check a stale hash pinned the file out of updates permanently —
143
+ // anything that changed it out-of-band (an errant tool, a partial restore, a
144
+ // manifest lost and re-seeded at the wrong version) froze it for good, silently.
145
+ // Comparing content first makes the tool self-healing after any restore.
146
+ // `pruneRetiredManaged` passes no `bundled` on purpose: the package no longer
147
+ // ships that file, so there is nothing to compare it against.
148
+ function managedState(dir, relPath, manifest, bundled) {
138
149
  const abs = path.join(dir, relPath)
139
150
  if (!fs.existsSync(abs)) return 'missing'
151
+ const onDisk = fs.readFileSync(abs, 'utf8')
152
+ if (bundled !== undefined && onDisk === bundled) return 'pristine'
140
153
  const known = manifest.files[relPath]
141
- return known && sha1(fs.readFileSync(abs, 'utf8')) === known ? 'pristine' : 'customized'
154
+ return known && sha1(onDisk) === known ? 'pristine' : 'customized'
142
155
  }
143
156
 
144
157
  // Reconcile and persist the manifest after an install/resync run: keep prior
@@ -405,7 +418,7 @@ function isExistingSetup(dir) {
405
418
  // update; customized (edited) → keep + report, unless `force`.
406
419
  function resyncManagedFile(dir, target, manifest, force) {
407
420
  const { relPath, abs, bundled } = target
408
- const state = managedState(dir, relPath, manifest)
421
+ const state = managedState(dir, relPath, manifest, bundled)
409
422
  const write = (bucket) => {
410
423
  ensureDir(path.dirname(abs))
411
424
  fs.writeFileSync(abs, bundled)
@@ -416,17 +429,25 @@ function resyncManagedFile(dir, target, manifest, force) {
416
429
  if (state === 'customized') {
417
430
  if (force) return write('updated')
418
431
  writtenHashes[relPath] = manifest.files[relPath] || writtenHashes[relPath] // keep baseline
419
- return report.customized.push(relPath)
432
+ // Carry the change the user just DECLINED. A bare filename tells them a
433
+ // decision was made on their behalf but not what it was, which leaves
434
+ // "clobber and re-apply my edits by hand" as the only safe way to upgrade.
435
+ const { added, removed, hunks } = linesDiff(fs.readFileSync(abs, 'utf8'), bundled)
436
+ return report.customized.push({ relPath, added, removed, hunks })
420
437
  }
421
438
  // pristine — update only if the bundled content actually changed
422
439
  if (fs.readFileSync(abs, 'utf8') === bundled) {
440
+ // The file is ours and current, but the manifest disagreed — record the
441
+ // repair rather than healing in silence: a file that quietly starts
442
+ // updating again is as opaque as one that quietly stopped.
443
+ if (manifest.files[relPath] !== sha1(bundled)) report.healed.push(relPath)
423
444
  writtenHashes[relPath] = sha1(bundled)
424
445
  return report.skipped.push(relPath)
425
446
  }
426
447
  write('updated')
427
448
  }
428
449
 
429
- function resync(dir, { force = false, claudeMd = true } = {}) {
450
+ function resync(dir, { force = false, claudeMd = true, diff = false } = {}) {
430
451
  if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
431
452
  resetReport()
432
453
  const manifest = readManifest(dir)
@@ -436,7 +457,7 @@ function resync(dir, { force = false, claudeMd = true } = {}) {
436
457
  pruneRetiredManaged(dir, manifest)
437
458
  if (claudeMd) installClaudeMd(dir, { mode: 'update' })
438
459
  flushManifest(dir)
439
- printReport(dir, 'resync')
460
+ printReport(dir, 'resync', { diff })
440
461
  }
441
462
 
442
463
  // The never-touch set: START AGAIN may only delete a known managed file, and may
@@ -502,7 +523,7 @@ function reset(dir, { claudeMd = true } = {}) {
502
523
  printReport(dir, 'reset')
503
524
  }
504
525
 
505
- function printReport(dir, mode) {
526
+ function printReport(dir, mode, { diff = false } = {}) {
506
527
  const line = (label, items) => {
507
528
  if (!items.length) return
508
529
  process.stdout.write(`\n${label}:\n`)
@@ -512,12 +533,25 @@ function printReport(dir, mode) {
512
533
  line('created', report.created)
513
534
  line('updated', report.updated)
514
535
  line('removed', report.removed)
515
- line('customized (kept)', report.customized)
536
+ line(
537
+ 'customized (kept)',
538
+ report.customized.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
539
+ )
540
+ line('manifest repaired', report.healed)
516
541
  line('unchanged', report.skipped)
517
542
  if (report.warnings.length) {
518
543
  process.stdout.write('\nwarnings:\n')
519
544
  for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
520
545
  }
546
+ if (diff) {
547
+ for (const c of report.customized) {
548
+ if (!c.hunks.length) continue
549
+ process.stdout.write(`\n--- ${c.relPath} (kept — this is what you declined)\n`)
550
+ for (const h of c.hunks) process.stdout.write(`${h}\n`)
551
+ }
552
+ } else if (report.customized.length) {
553
+ process.stdout.write('\nRe-run with --diff to see the changes those files declined.\n')
554
+ }
521
555
  const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
522
556
  const isolationNote = isolationOn
523
557
  ? 'Per-spec isolation is ON: every in-progress spec gets its own git worktree' +
@@ -0,0 +1,114 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * A minimal line diff — just enough for `update` to say what it skipped.
5
+ *
6
+ * `update` reports a file it kept as `customized (kept)` and nothing else, so
7
+ * there is no way to learn WHICH upstream changes you declined without diffing
8
+ * against `node_modules` by hand. That is how a real behavioural change (the
9
+ * lifecycle skills learning to commit their own edits) went unnoticed through an
10
+ * upgrade in the field.
11
+ *
12
+ * Zero dependencies on purpose: this package ships with none, and `diff(1)` is
13
+ * not a portable guarantee. An LCS over lines is a few dozen lines of code and
14
+ * the inputs are markdown files of a few hundred lines.
15
+ */
16
+
17
+ // Longest-common-subsequence walk over two line arrays, as a flat op list.
18
+ // `t` is ' ' (context), '-' (only in `a`) or '+' (only in `b`).
19
+ function diffOps(a, b) {
20
+ const n = a.length
21
+ const m = b.length
22
+ // dp[i][j] = LCS length of a[i..] and b[j..], flattened.
23
+ const dp = new Int32Array((n + 1) * (m + 1))
24
+ const at = (i, j) => i * (m + 1) + j
25
+ for (let i = n - 1; i >= 0; i--) {
26
+ for (let j = m - 1; j >= 0; j--) {
27
+ dp[at(i, j)] = a[i] === b[j] ? dp[at(i + 1, j + 1)] + 1 : Math.max(dp[at(i + 1, j)], dp[at(i, j + 1)])
28
+ }
29
+ }
30
+
31
+ const ops = []
32
+ let i = 0
33
+ let j = 0
34
+ while (i < n && j < m) {
35
+ if (a[i] === b[j]) {
36
+ ops.push({ t: ' ', line: a[i], a: i, b: j })
37
+ i++
38
+ j++
39
+ } else if (dp[at(i + 1, j)] >= dp[at(i, j + 1)]) {
40
+ ops.push({ t: '-', line: a[i], a: i, b: j })
41
+ i++
42
+ } else {
43
+ ops.push({ t: '+', line: b[j], a: i, b: j })
44
+ j++
45
+ }
46
+ }
47
+ while (i < n) {
48
+ ops.push({ t: '-', line: a[i], a: i, b: j })
49
+ i++
50
+ }
51
+ while (j < m) {
52
+ ops.push({ t: '+', line: b[j], a: i, b: j })
53
+ j++
54
+ }
55
+ return ops
56
+ }
57
+
58
+ // Group the ops into unified-diff hunks, each carrying `context` unchanged lines
59
+ // either side of a run of changes. Runs closer together than 2×context merge, as
60
+ // `diff -u` does, so a cluster of edits reads as one hunk.
61
+ function toHunks(ops, context) {
62
+ const changed = ops.map((o) => o.t !== ' ')
63
+ const hunks = []
64
+ let k = 0
65
+ while (k < ops.length) {
66
+ if (!changed[k]) {
67
+ k++
68
+ continue
69
+ }
70
+ let start = Math.max(0, k - context)
71
+ let end = k
72
+ // Extend while the next change is near enough to keep in the same hunk.
73
+ for (let p = k; p < ops.length; p++) {
74
+ if (changed[p]) end = p
75
+ else if (p - end > context * 2) break
76
+ }
77
+ end = Math.min(ops.length - 1, end + context)
78
+
79
+ const body = ops.slice(start, end + 1)
80
+ const aStart = body[0].a + 1
81
+ const bStart = body[0].b + 1
82
+ const aLen = body.filter((o) => o.t !== '+').length
83
+ const bLen = body.filter((o) => o.t !== '-').length
84
+ hunks.push(
85
+ [`@@ -${aStart},${aLen} +${bStart},${bLen} @@`, ...body.map((o) => `${o.t}${o.line}`)].join('\n'),
86
+ )
87
+ k = end + 1
88
+ }
89
+ return hunks
90
+ }
91
+
92
+ /**
93
+ * Diff `a` (what is on disk) against `b` (what the package ships).
94
+ *
95
+ * `added`/`removed` count the lines an update WOULD add and remove — the summary
96
+ * printed beside a kept file. `hunks` are unified-diff blocks for `--diff`.
97
+ *
98
+ * @param {string|string[]} a
99
+ * @param {string|string[]} b
100
+ * @param {{context?:number}} [opts]
101
+ * @returns {{added:number, removed:number, hunks:string[]}}
102
+ */
103
+ function linesDiff(a, b, { context = 3 } = {}) {
104
+ const A = Array.isArray(a) ? a : String(a).split('\n')
105
+ const B = Array.isArray(b) ? b : String(b).split('\n')
106
+ const ops = diffOps(A, B)
107
+ return {
108
+ added: ops.filter((o) => o.t === '+').length,
109
+ removed: ops.filter((o) => o.t === '-').length,
110
+ hunks: toHunks(ops, context),
111
+ }
112
+ }
113
+
114
+ module.exports = { linesDiff }