@codyswann/lisa 4.9.2 → 4.9.4

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.
Files changed (83) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +2 -2
  2. package/dist/cli/{doctor-learnings-merge-driver.d.ts → doctor-merge-drivers.d.ts} +6 -6
  3. package/dist/cli/doctor-merge-drivers.d.ts.map +1 -0
  4. package/dist/cli/doctor-merge-drivers.js +163 -0
  5. package/dist/cli/doctor-merge-drivers.js.map +1 -0
  6. package/dist/cli/doctor.js +2 -2
  7. package/dist/cli/doctor.js.map +1 -1
  8. package/dist/core/gitattributes-merge-drivers.d.ts +28 -0
  9. package/dist/core/gitattributes-merge-drivers.d.ts.map +1 -0
  10. package/dist/core/gitattributes-merge-drivers.js +62 -0
  11. package/dist/core/gitattributes-merge-drivers.js.map +1 -0
  12. package/dist/core/learnings-merge-driver-install.d.ts +0 -13
  13. package/dist/core/learnings-merge-driver-install.d.ts.map +1 -1
  14. package/dist/core/learnings-merge-driver-install.js +0 -16
  15. package/dist/core/learnings-merge-driver-install.js.map +1 -1
  16. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  17. package/dist/core/lisa-owned-hash-ledger.js +2 -0
  18. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  19. package/dist/core/merge-driver-registration.d.ts +26 -0
  20. package/dist/core/merge-driver-registration.d.ts.map +1 -0
  21. package/dist/core/merge-driver-registration.js +38 -0
  22. package/dist/core/merge-driver-registration.js.map +1 -0
  23. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  24. package/dist/core/upstream-evidence-manifest.js +15 -3
  25. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  26. package/package.json +3 -2
  27. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-agy/plugin.json +1 -1
  30. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  33. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo-agy/plugin.json +1 -1
  40. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  60. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails-agy/plugin.json +1 -1
  65. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  70. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  75. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  77. package/scripts/check-merge-driver-registration.mjs +142 -0
  78. package/scripts/install-generated-artifact-merge-driver.mjs +200 -0
  79. package/scripts/lib/gitattributes-merge-drivers.mjs +52 -0
  80. package/scripts/merge-generated-artifact.mjs +519 -0
  81. package/dist/cli/doctor-learnings-merge-driver.d.ts.map +0 -1
  82. package/dist/cli/doctor-learnings-merge-driver.js +0 -73
  83. package/dist/cli/doctor-learnings-merge-driver.js.map +0 -1
@@ -0,0 +1,519 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Git merge driver for Lisa's two checked-in generated artifacts —
4
+ * `src/core/upstream-evidence-manifest.ts` and
5
+ * `src/core/lisa-owned-hash-ledger.ts` (issue CodySwannGT/lisa#3084).
6
+ *
7
+ * ## The problem this exists for
8
+ *
9
+ * Every branch that changes a shipped artifact must regenerate both files, and
10
+ * both are tracked, so **the conflict recurs by construction**: two branches
11
+ * that touch nothing in common still rewrite the same sorted regions and git
12
+ * refuses. Measured on `main` at filing, 27 of the last 40 commits touched
13
+ * them. With a 36–53 minute pre-push gate the branch loses the race it is in,
14
+ * indefinitely, while remaining correct throughout.
15
+ *
16
+ * ## Why this is NOT "a driver picks a side"
17
+ *
18
+ * CodySwannGT/lisa#3046 rejected a merge driver for `readiness.json` on the
19
+ * grounds that a driver can only pick a side, which suppresses a conflict
20
+ * without making either answer right. That objection is sound and it does not
21
+ * reach these two files, for a reason worth stating precisely.
22
+ *
23
+ * Both artifacts are **accumulators over a pointwise function of the tree**:
24
+ *
25
+ * - every entry is keyed by one path (or one commit sha) and depends on that
26
+ * key alone — no entry is a function of any other entry;
27
+ * - entries are only ever added, and the array-valued entries in the hash
28
+ * ledger are append-only sets that the generator itself unions forward from
29
+ * whatever is already checked in (`existingLedger()`).
30
+ *
31
+ * For a pointwise function, **a pointwise 3-way merge of the OUTPUT equals the
32
+ * function applied to the merged INPUT**. If a key changed on our side only,
33
+ * the merged tree has our version of the file it describes, so our entry is the
34
+ * right one; symmetrically for theirs; if both sides changed the same key
35
+ * differently, the merged tree has a file that neither side described, and this
36
+ * driver conflicts rather than inventing an answer. So the result is not one
37
+ * side's blob — it is a reconstruction of what regeneration would produce.
38
+ *
39
+ * ## Why the driver cannot simply run the generators
40
+ *
41
+ * Measured (git 2.53.0, `ort` strategy): **inside a merge driver the working
42
+ * tree and the index are still pre-merge.** A probe driver that read a sibling
43
+ * file saw HEAD's bytes, `git ls-files -s` listed stage-0 HEAD entries, and
44
+ * `.git/MERGE_HEAD` did not exist yet — merge-ort computes the whole merge in
45
+ * memory and writes it out only after every content merge has returned. A
46
+ * driver that shelled out to `generate-*.mjs` would therefore regenerate
47
+ * against HEAD and produce, byte for byte, **ours** — picking a side while
48
+ * looking like it regenerated. That is the failure mode #3046 warned about,
49
+ * reached by the route that looks most like the fix.
50
+ *
51
+ * ## Why the artifacts cannot simply be untracked
52
+ *
53
+ * The ticket's own preferred direction — generate at build time, stop tracking
54
+ * the bytes — does not survive contact with either generator. Both carry state
55
+ * that is NOT derivable from the tree: the manifest's `UPSTREAM_PUBLIC_COMMITS`
56
+ * is seeded from the checked-in file and only ever extended (the generator
57
+ * refuses to emit an empty one), and the hash ledger is explicitly append-only
58
+ * across history so that a shallow clone yields fewer new hashes rather than
59
+ * losing known-good ones. They are accumulators, not pure derivations. That is
60
+ * also precisely why union-forward is their correct merge semantics.
61
+ *
62
+ * ## How it works
63
+ *
64
+ * The file is segmented into literal TEXT runs and ENTRIES blocks (a run of
65
+ * `<key>: <value>` lines at one indent, each absorbing its own continuation
66
+ * lines). Entry bodies are carried as **raw source text**, never re-rendered
67
+ * from a parsed model, so this driver has no opinion about either generator's
68
+ * output format and cannot drift from it. Merged entries are re-emitted sorted
69
+ * by unquoted key, which is exactly the order both generators emit.
70
+ *
71
+ * Anything the driver does not fully understand — a differing block shape, a
72
+ * key both sides changed, an array entry that lost elements on one side —
73
+ * exits non-zero and lets git record a conflict. Silence is never chosen over a
74
+ * conflict.
75
+ *
76
+ * ## Registration
77
+ *
78
+ * The `.gitattributes` mapping is committed; the driver COMMAND is machine-
79
+ * local, because git refuses to run a command a repository supplies. An
80
+ * unregistered checkout falls back to git's built-in text merge — today's
81
+ * behaviour exactly, conflict markers and all — so it is degraded, never
82
+ * broken. `scripts/install-generated-artifact-merge-driver.mjs` registers it
83
+ * from Lisa's own `postinstall`, and `lisa doctor` reports any mapping whose
84
+ * driver is unregistered.
85
+ *
86
+ * CLI (git substitutes the placeholders):
87
+ * node scripts/merge-generated-artifact.mjs --base %O --ours %A --theirs %B --path %P
88
+ *
89
+ * Exit codes:
90
+ * 0 — merged cleanly; the result was written over the `--ours` file.
91
+ * 1 — conflict. `--ours` is left untouched (a parseable artifact plus a
92
+ * precise stderr reason beats a corrupted one; both sides remain in the
93
+ * index at stages 2 and 3), and git records the path as unmerged.
94
+ * 2 — usage error.
95
+ *
96
+ * @module scripts/merge-generated-artifact
97
+ */
98
+ import { readFileSync, writeFileSync } from "node:fs";
99
+ import process from "node:process";
100
+
101
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
102
+
103
+ /**
104
+ * Identity standing for "this side does not have this thing at all".
105
+ *
106
+ * Written as the `\u0000` escape, never as a raw NUL byte: a raw NUL makes the
107
+ * whole file invisible to recursive `grep`/`rg`, and a guard in
108
+ * `tests/integration/tracked-source-nul-bytes.test.ts` refuses one. The runtime
109
+ * string is identical. NUL is the right VALUE because no entry body or text run
110
+ * can ever contain it, so absence can never collide with real content.
111
+ */
112
+ const ABSENT = "\u0000absent";
113
+
114
+ /** Matches an entry line: indented `"quoted key":` or `bareKey:`. */
115
+ const ENTRY_LINE = /^(\s+)("(?:[^"\\]|\\.)*"|[A-Za-z_$][\w$]*)\s*:/u;
116
+
117
+ /** Header of an append-only array entry, e.g. `"dest": Object.freeze([`. */
118
+ const ARRAY_OPEN = /Object\.freeze\(\[\s*$/u;
119
+
120
+ /**
121
+ * Compare two keys the way both generators sort theirs.
122
+ *
123
+ * Both call `.sort()` (or `left < right`) on the RAW path or sha, before the
124
+ * key is quoted for emission. Sorting on the rendered token instead would
125
+ * reorder the commit block, where `formatObjectKey` leaves a sha starting with
126
+ * a letter bare and quotes one starting with a digit.
127
+ * @param {string} left - First unquoted key
128
+ * @param {string} right - Second unquoted key
129
+ * @returns {number} Negative, zero, or positive per the usual comparator contract
130
+ */
131
+ function compareKeys(left, right) {
132
+ if (left === right) return 0;
133
+ return left < right ? -1 : 1;
134
+ }
135
+
136
+ /**
137
+ * Strip the quoting `formatObjectKey` applied, so keys sort as the generator
138
+ * sorted them.
139
+ * @param {string} token - Key token exactly as it appears in the source
140
+ * @returns {string} The underlying path or sha
141
+ */
142
+ function unquoteKey(token) {
143
+ if (!token.startsWith('"')) return token;
144
+ try {
145
+ return JSON.parse(token);
146
+ } catch {
147
+ return token;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Whether a non-entry line continues the entry above it.
153
+ * @param {string} line - Source line
154
+ * @param {string} indent - Indentation shared by the block's entry lines
155
+ * @returns {boolean} True when the line belongs to the preceding entry
156
+ */
157
+ function isContinuation(line, indent) {
158
+ if (line.trim() === "" || !line.startsWith(indent)) return false;
159
+ return (
160
+ /^\s/u.test(line.slice(indent.length)) || line.trimStart().startsWith("])")
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Segment one artifact into literal text runs and entry blocks.
166
+ *
167
+ * Entry bodies keep their exact source lines. A block whose entries are not all
168
+ * at one indent is refused rather than guessed at: a mis-segmented block would
169
+ * merge plausible-looking garbage.
170
+ * @param {string} text - Whole file contents
171
+ * @returns {{ok: true, chunks: object[]} | {ok: false, reason: string}} Parse result
172
+ */
173
+ export function parseArtifact(text) {
174
+ const lines = text.split("\n");
175
+ const chunks = [];
176
+ let pending = [];
177
+ let block = null;
178
+ const flushText = () => {
179
+ if (pending.length > 0) {
180
+ chunks.push({ kind: "text", text: pending.join("\n") });
181
+ pending = [];
182
+ }
183
+ };
184
+ const flushBlock = () => {
185
+ if (block !== null) {
186
+ chunks.push({
187
+ kind: "entries",
188
+ indent: block.indent,
189
+ entries: block.map,
190
+ });
191
+ block = null;
192
+ }
193
+ };
194
+ for (const line of lines) {
195
+ const match = ENTRY_LINE.exec(line);
196
+ if (match === null) {
197
+ // A continuation line belongs to the entry above it; anything else after
198
+ // a block has ended is ordinary text. "More indented, or the `])` that
199
+ // closes an array-valued entry" is the whole rule — and the second half
200
+ // is load-bearing in the other direction too: the line that CLOSES the
201
+ // block (` });`) sits at the entry indent, so a rule of merely
202
+ // "starts with the indent" would swallow it into the last entry and then
203
+ // re-emit it wherever that entry happened to sort.
204
+ if (block !== null && isContinuation(line, block.indent)) {
205
+ block.map.get(block.last).push(line);
206
+ continue;
207
+ }
208
+ flushBlock();
209
+ pending.push(line);
210
+ continue;
211
+ }
212
+ const indent = match[1];
213
+ const key = unquoteKey(match[2]);
214
+ if (block !== null && block.indent !== indent) {
215
+ return {
216
+ ok: false,
217
+ reason: `mixed indentation in an entry block near ${key}`,
218
+ };
219
+ }
220
+ if (block === null) {
221
+ flushText();
222
+ block = { indent, map: new Map(), last: "" };
223
+ }
224
+ if (block.map.has(key)) {
225
+ return { ok: false, reason: `duplicate key ${key}` };
226
+ }
227
+ block.map.set(key, [line]);
228
+ block.last = key;
229
+ }
230
+ flushBlock();
231
+ flushText();
232
+ return { ok: true, chunks };
233
+ }
234
+
235
+ /**
236
+ * Re-emit a parsed artifact.
237
+ *
238
+ * Entry text is replayed verbatim, so this is the identity on any file that
239
+ * parsed — asserted by a round-trip test against the real checked-in artifacts,
240
+ * which is what stops this driver drifting from the generators' formatting.
241
+ * @param {object[]} chunks - Parsed segments
242
+ * @returns {string} File contents
243
+ */
244
+ export function renderArtifact(chunks) {
245
+ return chunks
246
+ .map(chunk =>
247
+ chunk.kind === "text"
248
+ ? chunk.text
249
+ : [...chunk.entries.keys()]
250
+ .sort(compareKeys)
251
+ .flatMap(key => chunk.entries.get(key))
252
+ .join("\n")
253
+ )
254
+ .join("\n");
255
+ }
256
+
257
+ /**
258
+ * Split an append-only array entry into its header, element lines, and footer.
259
+ * @param {string[]} lines - Raw entry lines
260
+ * @returns {{head: string, items: string[], tail: string[]} | undefined} Parts, when the entry is an array
261
+ */
262
+ function splitArrayEntry(lines) {
263
+ if (lines.length < 3 || !ARRAY_OPEN.test(lines[0])) return undefined;
264
+ const closeAt = lines.findIndex(line => /^\s*\]\)/u.test(line));
265
+ if (closeAt < 1) return undefined;
266
+ return {
267
+ head: lines[0],
268
+ items: lines.slice(1, closeAt),
269
+ tail: lines.slice(closeAt),
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Union two append-only array entries that both grew from the same base.
275
+ *
276
+ * Only applies when neither side REMOVED an element. Append-only is the ledger
277
+ * generator's own invariant — losing a hash permanently reclassifies a stale
278
+ * host copy as host-modified — so a side that dropped one is not an append and
279
+ * must not be merged silently.
280
+ * @param {string[] | undefined} base - Merge-base entry lines
281
+ * @param {string[]} ours - Our entry lines
282
+ * @param {string[]} theirs - Their entry lines
283
+ * @returns {string[] | undefined} Unioned entry lines, when the union is sound
284
+ */
285
+ function unionArrayEntry(base, ours, theirs) {
286
+ const left = splitArrayEntry(ours);
287
+ const right = splitArrayEntry(theirs);
288
+ if (left === undefined || right === undefined) return undefined;
289
+ if (
290
+ left.head !== right.head ||
291
+ left.tail.join("\n") !== right.tail.join("\n")
292
+ ) {
293
+ return undefined;
294
+ }
295
+ const ourItems = new Set(left.items);
296
+ const theirItems = new Set(right.items);
297
+ if (base !== undefined) {
298
+ const parts = splitArrayEntry(base);
299
+ if (parts === undefined) return undefined;
300
+ for (const item of parts.items) {
301
+ if (!ourItems.has(item) || !theirItems.has(item)) return undefined;
302
+ }
303
+ }
304
+ const merged = [...new Set([...left.items, ...right.items])].sort(
305
+ compareKeys
306
+ );
307
+ return [left.head, ...merged, ...left.tail];
308
+ }
309
+
310
+ /**
311
+ * Three-way merge of one value that may be absent on any side.
312
+ *
313
+ * `hasBase` is separate from `base === undefined` on purpose, and conflating
314
+ * the two is a bug this driver shipped for exactly one test run: a key that one
315
+ * branch ADDED is absent from the base, which is a perfectly good base value
316
+ * ("this key did not exist") and the single most common case here. Reading that
317
+ * absence as "there is no merge base" turned every ordinary one-sided addition
318
+ * into a conflict — the precise thing the driver exists to stop.
319
+ * @param {boolean} hasBase - Whether merge-base information exists at all
320
+ * @param {T | undefined} base - Merge-base value, undefined when genuinely absent there
321
+ * @param {T | undefined} ours - Our value
322
+ * @param {T | undefined} theirs - Their value
323
+ * @param {(value: T | undefined) => string} key - Stable identity for comparison
324
+ * @returns {{ok: true, value: T | undefined} | {ok: false}} Merged value, or a conflict
325
+ * @template T
326
+ */
327
+ function mergeSide(hasBase, base, ours, theirs, key) {
328
+ if (key(ours) === key(theirs)) return { ok: true, value: ours };
329
+ if (hasBase && key(ours) === key(base)) return { ok: true, value: theirs };
330
+ if (hasBase && key(theirs) === key(base)) return { ok: true, value: ours };
331
+ return { ok: false };
332
+ }
333
+
334
+ /** Identity of an entry body (or its absence) for three-way comparison. */
335
+ const entryKey = lines => (lines === undefined ? ABSENT : lines.join("\n"));
336
+
337
+ /**
338
+ * Merge one entries block.
339
+ * @param {Map<string, string[]> | undefined} base - Merge-base entries
340
+ * @param {Map<string, string[]>} ours - Our entries
341
+ * @param {Map<string, string[]>} theirs - Their entries
342
+ * @returns {{ok: true, entries: Map<string, string[]>} | {ok: false, reason: string}} Merged block
343
+ */
344
+ function mergeEntries(base, ours, theirs) {
345
+ const merged = new Map();
346
+ const keys = [
347
+ ...new Set([...(base?.keys() ?? []), ...ours.keys(), ...theirs.keys()]),
348
+ ];
349
+ for (const name of keys.sort(compareKeys)) {
350
+ const b = base?.get(name);
351
+ const o = ours.get(name);
352
+ const t = theirs.get(name);
353
+ const straight = mergeSide(base !== undefined, b, o, t, entryKey);
354
+ if (straight.ok) {
355
+ if (straight.value !== undefined) merged.set(name, straight.value);
356
+ continue;
357
+ }
358
+ const unioned =
359
+ o !== undefined && t !== undefined ? unionArrayEntry(b, o, t) : undefined;
360
+ if (unioned === undefined) {
361
+ return {
362
+ ok: false,
363
+ reason: `both sides changed the entry for ${name} in incompatible ways`,
364
+ };
365
+ }
366
+ merged.set(name, unioned);
367
+ }
368
+ return { ok: true, entries: merged };
369
+ }
370
+
371
+ /**
372
+ * Merge three versions of one generated artifact.
373
+ *
374
+ * An empty merge base means the file was created independently on both sides;
375
+ * the merge then degrades to add/add, where only identical or disjoint content
376
+ * can merge.
377
+ * @param {string} base - `%O` contents ("" when the file is new on both sides)
378
+ * @param {string} ours - `%A` contents
379
+ * @param {string} theirs - `%B` contents
380
+ * @returns {{ok: true, text: string} | {ok: false, reason: string}} Merge result
381
+ */
382
+ export function mergeGeneratedArtifact(base, ours, theirs) {
383
+ const parsedOurs = parseArtifact(ours);
384
+ const parsedTheirs = parseArtifact(theirs);
385
+ if (!parsedOurs.ok)
386
+ return { ok: false, reason: `ours: ${parsedOurs.reason}` };
387
+ if (!parsedTheirs.ok)
388
+ return { ok: false, reason: `theirs: ${parsedTheirs.reason}` };
389
+ let parsedBase;
390
+ if (base !== "") {
391
+ const attempt = parseArtifact(base);
392
+ if (!attempt.ok) return { ok: false, reason: `base: ${attempt.reason}` };
393
+ parsedBase = attempt.chunks;
394
+ }
395
+ const shape = chunks => chunks.map(chunk => chunk.kind).join(",");
396
+ if (shape(parsedOurs.chunks) !== shape(parsedTheirs.chunks)) {
397
+ return {
398
+ ok: false,
399
+ reason: "the two sides have different block structures",
400
+ };
401
+ }
402
+ if (
403
+ parsedBase !== undefined &&
404
+ shape(parsedBase) !== shape(parsedOurs.chunks)
405
+ ) {
406
+ parsedBase = undefined;
407
+ }
408
+ const merged = [];
409
+ for (const [index, ourChunk] of parsedOurs.chunks.entries()) {
410
+ const theirChunk = parsedTheirs.chunks[index];
411
+ const baseChunk = parsedBase?.[index];
412
+ if (ourChunk.kind === "text") {
413
+ const result = mergeSide(
414
+ baseChunk !== undefined,
415
+ baseChunk?.text,
416
+ ourChunk.text,
417
+ theirChunk.text,
418
+ value => value ?? ABSENT
419
+ );
420
+ if (!result.ok) {
421
+ return {
422
+ ok: false,
423
+ reason: "both sides edited the same non-generated text",
424
+ };
425
+ }
426
+ merged.push({ kind: "text", text: result.value });
427
+ continue;
428
+ }
429
+ const result = mergeEntries(
430
+ baseChunk?.entries,
431
+ ourChunk.entries,
432
+ theirChunk.entries
433
+ );
434
+ if (!result.ok) return result;
435
+ merged.push({
436
+ kind: "entries",
437
+ indent: ourChunk.indent,
438
+ entries: result.entries,
439
+ });
440
+ }
441
+ return { ok: true, text: renderArtifact(merged) };
442
+ }
443
+
444
+ /**
445
+ * Read one merge side, never conflating "unreadable" with "empty".
446
+ *
447
+ * The learnings driver failed OPEN exactly once by collapsing the two: an
448
+ * unreadable base silently became "no base", the merge degraded to two-way, and
449
+ * a superseded entry was resurrected into a CLEAN committed merge. Here the
450
+ * same collapse would drop one side's whole accumulated block.
451
+ * @param {string} file - Path git supplied
452
+ * @returns {{ok: true, content: string} | {ok: false}} Contents, or a read failure
453
+ */
454
+ function readSide(file) {
455
+ try {
456
+ return { ok: true, content: readFileSync(file, "utf8") };
457
+ } catch {
458
+ return { ok: false };
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Parse `--flag value` pairs.
464
+ * @param {readonly string[]} argv - Arguments after the script name
465
+ * @returns {Record<string, string>} Flag values
466
+ */
467
+ export function parseArgs(argv) {
468
+ const options = {};
469
+ for (let index = 0; index < argv.length; index += 2) {
470
+ const flag = argv[index];
471
+ if (!flag.startsWith("--")) continue;
472
+ options[flag.slice(2)] = argv[index + 1] ?? "";
473
+ }
474
+ return options;
475
+ }
476
+
477
+ /**
478
+ * Run the driver against git-supplied side files.
479
+ * @param {readonly string[]} argv - Arguments after the script name
480
+ * @param {(message: string) => void} [error] - Sink for the diagnostic
481
+ * @returns {number} Process exit code
482
+ */
483
+ export function runMergeGeneratedArtifact(
484
+ argv,
485
+ error = message => process.stderr.write(message)
486
+ ) {
487
+ const options = parseArgs(argv);
488
+ const { base, ours, theirs } = options;
489
+ const label = options.path ?? "the generated artifact";
490
+ if (base === undefined || ours === undefined || theirs === undefined) {
491
+ error(
492
+ "merge-generated-artifact: --base, --ours and --theirs are all required\n"
493
+ );
494
+ return 2;
495
+ }
496
+ const sides = [readSide(base), readSide(ours), readSide(theirs)];
497
+ if (sides.some(side => !side.ok)) {
498
+ error(
499
+ `merge-generated-artifact: could not read every side of the merge for ${label}; refusing to merge\n`
500
+ );
501
+ return 1;
502
+ }
503
+ const [baseText, oursText, theirsText] = sides.map(side => side.content);
504
+ const result = mergeGeneratedArtifact(baseText, oursText, theirsText);
505
+ if (!result.ok) {
506
+ error(
507
+ `merge-generated-artifact: ${label} could not be merged mechanically — ${result.reason}.\n` +
508
+ `Both sides are preserved in the index (stages 2 and 3). Resolve by regenerating against the merged tree:\n` +
509
+ ` git checkout --theirs -- ${label} && bun run build:upstream-evidence-manifest && bun run build:lisa-owned-hash-ledger && git add ${label}\n`
510
+ );
511
+ return 1;
512
+ }
513
+ writeFileSync(ours, result.text);
514
+ return 0;
515
+ }
516
+
517
+ if (invokedAsScript(import.meta.url)) {
518
+ process.exit(runMergeGeneratedArtifact(process.argv.slice(2)));
519
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"doctor-learnings-merge-driver.d.ts","sourceRoot":"","sources":["../../src/cli/doctor-learnings-merge-driver.ts"],"names":[],"mappings":"AA6BA,2DAA2D;AAC3D,eAAO,MAAM,iCAAiC,uCACR,CAAC;AAKvC,6EAA6E;AAC7E,UAAU,sBAAsB;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;IAC/B,MAAM,EAAE,MAAM,CAAC;CAChB;AAYD;;;;;;;;;;;GAWG;AACH,wBAAsB,yBAAyB,CAC7C,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,sBAAsB,CAAC,CA8CjC"}
@@ -1,73 +0,0 @@
1
- /**
2
- * `lisa doctor` check: is the learnings union merge driver actually registered?
3
- *
4
- * A merge driver has two halves in two places. The `merge=lisa-learnings`
5
- * mapping is committed in `.gitattributes`; the driver COMMAND is machine-local,
6
- * because git refuses to run a command a repository supplied. So any clone that
7
- * never ran `lisa apply` — a fresh CI checkout, a teammate who cloned without
8
- * installing, a worktree created before the migration ran — silently falls back
9
- * to git's built-in text merge for the ledger.
10
- *
11
- * That degradation is far less catastrophic than it sounds: the text merge
12
- * leaves conflict markers, which the budget gate diagnoses, rather than
13
- * resolving the ledger to empty. What it is not is visible. Nothing tells an
14
- * operator that the union protecting the ledger is not registered here — and
15
- * invisibility is precisely what let one fleet project lose 19 captured
16
- * learnings. This check is the missing sentence.
17
- * @module cli/doctor-learnings-merge-driver
18
- */
19
- import { isLearningsMergeDriverEnabled } from "../core/learnings-merge-driver-config.js";
20
- import { isLearningsPathMappedToDriver, probeLearningsMergeDriverRegistration, } from "../core/learnings-merge-driver-install.js";
21
- import { LEARNINGS_MERGE_DRIVER_NAME } from "../core/learnings-merge-driver.js";
22
- import { readProjectConfig, resolveProjectLearningsFile, } from "../core/project-config.js";
23
- /** Name of the merge-driver check as doctor reports it. */
24
- export const LEARNINGS_MERGE_DRIVER_CHECK_NAME = "Learnings merge driver registered?";
25
- /** Git config key holding the driver command. */
26
- const DRIVER_KEY = `merge.${LEARNINGS_MERGE_DRIVER_NAME}.driver`;
27
- /**
28
- * Build one check result under this check's name.
29
- * @param status - Reported status
30
- * @param detail - Operator-readable explanation
31
- * @returns Doctor check result
32
- */
33
- function result(status, detail) {
34
- return { name: LEARNINGS_MERGE_DRIVER_CHECK_NAME, status, detail };
35
- }
36
- /**
37
- * Report whether this checkout can actually run the ledger's union merge.
38
- *
39
- * Warn, never fail: an unregistered driver degrades to a merge that conflicts
40
- * loudly instead of one that loses content, so reddening a CI checkout over it
41
- * would trade a real signal for noise. The states that are genuinely fine — a
42
- * host that opted out, a directory that is not a git repository, a repository
43
- * with nothing mapped to the driver — report ok and say which one they are,
44
- * rather than sharing one indistinguishable "skipped".
45
- * @param targetPath - Project path to inspect
46
- * @returns Doctor check result
47
- */
48
- export async function checkLearningsMergeDriver(targetPath) {
49
- try {
50
- if (!(await isLearningsMergeDriverEnabled(targetPath))) {
51
- return result("ok", "Host opted out via learnings.mergeDriver: false; no driver expected");
52
- }
53
- const registration = await probeLearningsMergeDriverRegistration(targetPath);
54
- if (registration === "not-a-repository") {
55
- return result("ok", "Not a git repository, so merge-driver registration does not apply");
56
- }
57
- if (registration === "git-unavailable") {
58
- return result("warn", `git executable not found, so ${DRIVER_KEY} could not be verified — install git, then re-run \`lisa doctor\``);
59
- }
60
- const ledger = resolveProjectLearningsFile(await readProjectConfig(targetPath));
61
- if (!(await isLearningsPathMappedToDriver(targetPath, ledger))) {
62
- return result("ok", `Nothing maps ${ledger} to the ${LEARNINGS_MERGE_DRIVER_NAME} driver, so there is nothing to register (run \`lisa apply\` to ship the .gitattributes mapping)`);
63
- }
64
- if (registration === "registered") {
65
- return result("ok", `${DRIVER_KEY} is registered for ${ledger}`);
66
- }
67
- return result("warn", `.gitattributes maps ${ledger} to the ${LEARNINGS_MERGE_DRIVER_NAME} merge driver, but ${DRIVER_KEY} is not registered in this checkout — git silently falls back to its built-in text merge, which leaves conflict markers in the ledger instead of unioning entries by id. Fix it with \`lisa install-merge-driver\` (or any \`lisa apply\`) run here. Hosts that do not want the driver set learnings.mergeDriver: false in .lisa.config.json.`);
68
- }
69
- catch (error) {
70
- return result("warn", `Could not inspect the ${LEARNINGS_MERGE_DRIVER_NAME} merge driver: ${error instanceof Error ? error.message : String(error)}`);
71
- }
72
- }
73
- //# sourceMappingURL=doctor-learnings-merge-driver.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"doctor-learnings-merge-driver.js","sourceRoot":"","sources":["../../src/cli/doctor-learnings-merge-driver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,6BAA6B,EAAE,MAAM,0CAA0C,CAAC;AACzF,OAAO,EACL,6BAA6B,EAC7B,qCAAqC,GACtC,MAAM,2CAA2C,CAAC;AACnD,OAAO,EAAE,2BAA2B,EAAE,MAAM,mCAAmC,CAAC;AAChF,OAAO,EACL,iBAAiB,EACjB,2BAA2B,GAC5B,MAAM,2BAA2B,CAAC;AAEnC,2DAA2D;AAC3D,MAAM,CAAC,MAAM,iCAAiC,GAC5C,oCAAoC,CAAC;AAEvC,iDAAiD;AACjD,MAAM,UAAU,GAAG,SAAS,2BAA2B,SAAS,CAAC;AASjE;;;;;GAKG;AACH,SAAS,MAAM,CAAC,MAAqB,EAAE,MAAc;IACnD,OAAO,EAAE,IAAI,EAAE,iCAAiC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;AACrE,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,UAAkB;IAElB,IAAI,CAAC;QACH,IAAI,CAAC,CAAC,MAAM,6BAA6B,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC;YACvD,OAAO,MAAM,CACX,IAAI,EACJ,qEAAqE,CACtE,CAAC;QACJ,CAAC;QACD,MAAM,YAAY,GAChB,MAAM,qCAAqC,CAAC,UAAU,CAAC,CAAC;QAC1D,IAAI,YAAY,KAAK,kBAAkB,EAAE,CAAC;YACxC,OAAO,MAAM,CACX,IAAI,EACJ,mEAAmE,CACpE,CAAC;QACJ,CAAC;QACD,IAAI,YAAY,KAAK,iBAAiB,EAAE,CAAC;YACvC,OAAO,MAAM,CACX,MAAM,EACN,gCAAgC,UAAU,mEAAmE,CAC9G,CAAC;QACJ,CAAC;QACD,MAAM,MAAM,GAAG,2BAA2B,CACxC,MAAM,iBAAiB,CAAC,UAAU,CAAC,CACpC,CAAC;QACF,IAAI,CAAC,CAAC,MAAM,6BAA6B,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;YAC/D,OAAO,MAAM,CACX,IAAI,EACJ,gBAAgB,MAAM,WAAW,2BAA2B,kGAAkG,CAC/J,CAAC;QACJ,CAAC;QACD,IAAI,YAAY,KAAK,YAAY,EAAE,CAAC;YAClC,OAAO,MAAM,CAAC,IAAI,EAAE,GAAG,UAAU,sBAAsB,MAAM,EAAE,CAAC,CAAC;QACnE,CAAC;QACD,OAAO,MAAM,CACX,MAAM,EACN,uBAAuB,MAAM,WAAW,2BAA2B,sBAAsB,UAAU,+UAA+U,CACnb,CAAC;IACJ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,MAAM,CACX,MAAM,EACN,yBAAyB,2BAA2B,kBAClD,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CACvD,EAAE,CACH,CAAC;IACJ,CAAC;AACH,CAAC"}