@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.
- package/all/copy-overwrite/scripts/lisa-gates.mjs +2 -2
- package/dist/cli/{doctor-learnings-merge-driver.d.ts → doctor-merge-drivers.d.ts} +6 -6
- package/dist/cli/doctor-merge-drivers.d.ts.map +1 -0
- package/dist/cli/doctor-merge-drivers.js +163 -0
- package/dist/cli/doctor-merge-drivers.js.map +1 -0
- package/dist/cli/doctor.js +2 -2
- package/dist/cli/doctor.js.map +1 -1
- package/dist/core/gitattributes-merge-drivers.d.ts +28 -0
- package/dist/core/gitattributes-merge-drivers.d.ts.map +1 -0
- package/dist/core/gitattributes-merge-drivers.js +62 -0
- package/dist/core/gitattributes-merge-drivers.js.map +1 -0
- package/dist/core/learnings-merge-driver-install.d.ts +0 -13
- package/dist/core/learnings-merge-driver-install.d.ts.map +1 -1
- package/dist/core/learnings-merge-driver-install.js +0 -16
- package/dist/core/learnings-merge-driver-install.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +2 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/merge-driver-registration.d.ts +26 -0
- package/dist/core/merge-driver-registration.d.ts.map +1 -0
- package/dist/core/merge-driver-registration.js +38 -0
- package/dist/core/merge-driver-registration.js.map +1 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +15 -3
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +3 -2
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/scripts/check-merge-driver-registration.mjs +142 -0
- package/scripts/install-generated-artifact-merge-driver.mjs +200 -0
- package/scripts/lib/gitattributes-merge-drivers.mjs +52 -0
- package/scripts/merge-generated-artifact.mjs +519 -0
- package/dist/cli/doctor-learnings-merge-driver.d.ts.map +0 -1
- package/dist/cli/doctor-learnings-merge-driver.js +0 -73
- 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"}
|