@postxl/generator 1.6.3 → 1.7.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.
@@ -93,6 +93,18 @@ export type CustomBlockError = {
93
93
  * @returns ExtractResult containing blocks, non-custom lines, and any errors
94
94
  */
95
95
  export declare function extractCustomBlocks(content: string): ExtractResult;
96
+ /**
97
+ * Finds the line range of a class member (method/property with a body) in the
98
+ * given lines, identified by its name. Returns the half-open range
99
+ * `[start, end)` covering the member's signature through its closing brace, or
100
+ * `null` if the member is not found or has no brace-delimited body.
101
+ *
102
+ * Exported for reuse by the three-way merge's `@custom-override` handling.
103
+ */
104
+ export declare function findMemberRange(targetLines: string[], memberName: string): {
105
+ start: number;
106
+ end: number;
107
+ } | null;
96
108
  /**
97
109
  * Finds the best position to insert a custom block in the target content
98
110
  * based on anchor context matching.
@@ -117,6 +129,26 @@ export declare function insertCustomBlocks(blocks: CustomBlock[], targetContent:
117
129
  * Checks if a string contains any custom block markers
118
130
  */
119
131
  export declare function hasCustomBlockMarkers(content: string): boolean;
132
+ /**
133
+ * Parses a `@custom-override:<member>` marker line **without a regular
134
+ * expression** (plain string scanning — avoids a flagged slow-regex hotspot and
135
+ * is easy to follow). Accepts both the line-comment form (`// @custom-override:member`)
136
+ * and the single-line block-comment form (slash-star ... star-slash), each with
137
+ * an optional leading `@`.
138
+ *
139
+ * Returns the member name, or `undefined` when the line is not an override marker.
140
+ */
141
+ export declare function parseOverrideMarkerMemberName(line: string): string | undefined;
142
+ /**
143
+ * Extracts the member names declared by `// @custom-override:<member>` markers,
144
+ * in document order and de-duplicated. Used by the three-way merge to keep the
145
+ * developer's version of the named generated members.
146
+ */
147
+ export declare function extractOverrideMemberNames(content: string): string[];
148
+ /**
149
+ * Checks if a string contains any `@custom-override` markers.
150
+ */
151
+ export declare function hasCustomOverrideMarkers(content: string): boolean;
120
152
  /**
121
153
  * Reconstructs content from non-custom lines, used when comparing
122
154
  * the "real" content differences (excluding custom blocks)
@@ -37,9 +37,13 @@
37
37
  Object.defineProperty(exports, "__esModule", { value: true });
38
38
  exports.customBlockMarkers = void 0;
39
39
  exports.extractCustomBlocks = extractCustomBlocks;
40
+ exports.findMemberRange = findMemberRange;
40
41
  exports.findInsertionPosition = findInsertionPosition;
41
42
  exports.insertCustomBlocks = insertCustomBlocks;
42
43
  exports.hasCustomBlockMarkers = hasCustomBlockMarkers;
44
+ exports.parseOverrideMarkerMemberName = parseOverrideMarkerMemberName;
45
+ exports.extractOverrideMemberNames = extractOverrideMemberNames;
46
+ exports.hasCustomOverrideMarkers = hasCustomOverrideMarkers;
43
47
  exports.reconstructNonCustomContent = reconstructNonCustomContent;
44
48
  /**
45
49
  * Marker patterns for custom blocks
@@ -340,41 +344,65 @@ function countChar(line, char) {
340
344
  }
341
345
  return count;
342
346
  }
343
- function findMemberRange(targetLines, memberName) {
344
- for (let i = 0; i < targetLines.length; i++) {
345
- const line = targetLines[i];
347
+ /**
348
+ * True if the member signature at `start` opens a `{` body before any `;`. A
349
+ * `;` reached first means a bodyless declaration (overload/abstract), which has
350
+ * no range.
351
+ */
352
+ function memberHasBody(targetLines, start) {
353
+ if (targetLines[start]?.includes('{')) {
354
+ return true;
355
+ }
356
+ for (let j = start + 1; j < targetLines.length; j++) {
357
+ const line = targetLines[j];
358
+ if (line?.includes('{')) {
359
+ return true;
360
+ }
361
+ if (line?.trim().endsWith(';')) {
362
+ return false;
363
+ }
364
+ }
365
+ return false;
366
+ }
367
+ /**
368
+ * Given a member signature at `start` known to open a body, returns the index
369
+ * just past its balanced closing brace, or `null` if braces never balance.
370
+ */
371
+ function findMemberBodyEnd(targetLines, start) {
372
+ let braceDepth = 0;
373
+ for (let k = start; k < targetLines.length; k++) {
374
+ const line = targetLines[k];
346
375
  if (line === undefined) {
347
376
  continue;
348
377
  }
349
- if (findMemberNameInLine(line) !== memberName) {
350
- continue;
378
+ braceDepth += countChar(line, '{');
379
+ braceDepth -= countChar(line, '}');
380
+ if (braceDepth === 0 && k > start) {
381
+ return k + 1;
351
382
  }
352
- let j = i;
353
- let foundOpeningBrace = line.includes('{');
354
- while (!foundOpeningBrace && j + 1 < targetLines.length) {
355
- j++;
356
- const continuationLine = targetLines[j];
357
- if (continuationLine?.includes('{')) {
358
- foundOpeningBrace = true;
359
- }
360
- if (continuationLine?.trim().endsWith(';')) {
361
- break;
362
- }
383
+ }
384
+ return null;
385
+ }
386
+ /**
387
+ * Finds the line range of a class member (method/property with a body) in the
388
+ * given lines, identified by its name. Returns the half-open range
389
+ * `[start, end)` covering the member's signature through its closing brace, or
390
+ * `null` if the member is not found or has no brace-delimited body.
391
+ *
392
+ * Exported for reuse by the three-way merge's `@custom-override` handling.
393
+ */
394
+ function findMemberRange(targetLines, memberName) {
395
+ for (let i = 0; i < targetLines.length; i++) {
396
+ const line = targetLines[i];
397
+ if (line === undefined || findMemberNameInLine(line) !== memberName) {
398
+ continue;
363
399
  }
364
- if (!foundOpeningBrace) {
400
+ if (!memberHasBody(targetLines, i)) {
365
401
  continue;
366
402
  }
367
- let braceDepth = 0;
368
- for (let k = i; k < targetLines.length; k++) {
369
- const memberLine = targetLines[k];
370
- if (memberLine === undefined) {
371
- continue;
372
- }
373
- braceDepth += countChar(memberLine, '{');
374
- braceDepth -= countChar(memberLine, '}');
375
- if (braceDepth === 0 && k > i) {
376
- return { start: i, end: k + 1 };
377
- }
403
+ const end = findMemberBodyEnd(targetLines, i);
404
+ if (end !== null) {
405
+ return { start: i, end };
378
406
  }
379
407
  }
380
408
  return null;
@@ -592,6 +620,78 @@ function hasCustomBlockMarkers(content) {
592
620
  const lines = content.split('\n');
593
621
  return lines.some((line) => exports.customBlockMarkers.startPattern.test(line) || exports.customBlockMarkers.endPattern.test(line));
594
622
  }
623
+ /**
624
+ * Parses a `@custom-override:<member>` marker line **without a regular
625
+ * expression** (plain string scanning — avoids a flagged slow-regex hotspot and
626
+ * is easy to follow). Accepts both the line-comment form (`// @custom-override:member`)
627
+ * and the single-line block-comment form (slash-star ... star-slash), each with
628
+ * an optional leading `@`.
629
+ *
630
+ * Returns the member name, or `undefined` when the line is not an override marker.
631
+ */
632
+ function parseOverrideMarkerMemberName(line) {
633
+ let text = line.trim();
634
+ if (text.startsWith('//')) {
635
+ text = text.slice(2).trim();
636
+ }
637
+ else if (text.startsWith('/*')) {
638
+ text = text.slice(2).trim();
639
+ if (text.endsWith('*/')) {
640
+ text = text.slice(0, -2).trim();
641
+ }
642
+ }
643
+ else {
644
+ return undefined;
645
+ }
646
+ if (text.startsWith('@')) {
647
+ text = text.slice(1);
648
+ }
649
+ const prefix = 'custom-override:';
650
+ if (!text.startsWith(prefix)) {
651
+ return undefined;
652
+ }
653
+ const member = text.slice(prefix.length).trim();
654
+ return isValidMemberName(member) ? member : undefined;
655
+ }
656
+ /** True for a non-empty JS identifier (`[A-Za-z_$][A-Za-z0-9_$]*`), checked char-by-char. */
657
+ function isValidMemberName(name) {
658
+ if (name.length === 0) {
659
+ return false;
660
+ }
661
+ for (let i = 0; i < name.length; i++) {
662
+ const ch = name[i];
663
+ const isLetter = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
664
+ const isSpecial = ch === '_' || ch === '$';
665
+ const isDigit = ch >= '0' && ch <= '9';
666
+ if (i === 0 ? !(isLetter || isSpecial) : !(isLetter || isSpecial || isDigit)) {
667
+ return false;
668
+ }
669
+ }
670
+ return true;
671
+ }
672
+ /**
673
+ * Extracts the member names declared by `// @custom-override:<member>` markers,
674
+ * in document order and de-duplicated. Used by the three-way merge to keep the
675
+ * developer's version of the named generated members.
676
+ */
677
+ function extractOverrideMemberNames(content) {
678
+ const names = [];
679
+ const seen = new Set();
680
+ for (const line of content.split('\n')) {
681
+ const memberName = parseOverrideMarkerMemberName(line);
682
+ if (memberName && !seen.has(memberName)) {
683
+ seen.add(memberName);
684
+ names.push(memberName);
685
+ }
686
+ }
687
+ return names;
688
+ }
689
+ /**
690
+ * Checks if a string contains any `@custom-override` markers.
691
+ */
692
+ function hasCustomOverrideMarkers(content) {
693
+ return content.split('\n').some((line) => parseOverrideMarkerMemberName(line) !== undefined);
694
+ }
595
695
  /**
596
696
  * Reconstructs content from non-custom lines, used when comparing
597
697
  * the "real" content differences (excluding custom blocks)
@@ -0,0 +1,20 @@
1
+ /** A merged region: either resolved lines, or an unresolved 3-way conflict. */
2
+ export type Diff3Chunk = {
3
+ type: 'stable';
4
+ lines: string[];
5
+ } | {
6
+ type: 'conflict';
7
+ local: string[];
8
+ base: string[];
9
+ incoming: string[];
10
+ };
11
+ /**
12
+ * Performs a three-way line merge and returns the resolved/conflicting chunks.
13
+ *
14
+ * @param local - The developer's current version (on disk).
15
+ * @param base - The common ancestor (previous generation).
16
+ * @param incoming - The freshly generated version.
17
+ */
18
+ export declare function diff3Merge(local: string[], base: string[], incoming: string[]): Diff3Chunk[];
19
+ /** Returns true if any chunk is an unresolved conflict. */
20
+ export declare function hasConflictChunk(chunks: Diff3Chunk[]): boolean;
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.diff3Merge = diff3Merge;
37
+ exports.hasConflictChunk = hasConflictChunk;
38
+ /**
39
+ * Self-contained three-way (diff3) line merge.
40
+ *
41
+ * Given a common ancestor (`base`), the developer's on-disk version (`local`),
42
+ * and the freshly generated version (`incoming`), this module reproduces the
43
+ * classic diff3 chunk merge:
44
+ *
45
+ * - Regions changed by only ONE side relative to `base` are auto-applied.
46
+ * - Regions where both sides made the SAME change collapse to that change.
47
+ * - Regions where one side is unchanged from `base` take the other side.
48
+ * - Only regions where both sides diverge from `base` AND from each other are
49
+ * reported as conflicts for a human/agent to arbitrate.
50
+ *
51
+ * Unlike a 2-way diff (disk vs. fresh output), this can attribute a divergence
52
+ * to "the developer changed this" vs. "the generator changed this", so a model
53
+ * change in one region and a developer edit in a different region merge cleanly
54
+ * with no conflict.
55
+ *
56
+ * The matching is computed with jsdiff's `diffArrays` (line arrays), which we
57
+ * already depend on. Regions are grouped on the `base` axis: two changes only
58
+ * collide when their `base` ranges genuinely overlap, so directly-adjacent but
59
+ * disjoint edits from the two sides do not manufacture a false conflict.
60
+ */
61
+ const Diff = __importStar(require("diff"));
62
+ /**
63
+ * Consumes the maximal run of added/removed parts starting at `start`, summing
64
+ * how many base lines it removes and how many side lines it adds. Returns the
65
+ * index of the first non-change part after the run.
66
+ */
67
+ function consumeChangeRun(parts, start) {
68
+ let baseLength = 0;
69
+ let otherLength = 0;
70
+ let i = start;
71
+ while (i < parts.length) {
72
+ const change = parts[i];
73
+ if (change === undefined || (!change.added && !change.removed)) {
74
+ break;
75
+ }
76
+ if (change.removed) {
77
+ baseLength += change.value.length;
78
+ }
79
+ else {
80
+ otherLength += change.value.length;
81
+ }
82
+ i++;
83
+ }
84
+ return { baseLength, otherLength, next: i };
85
+ }
86
+ /**
87
+ * Computes the differing hunks between `base` and `other`, anchored on `base`.
88
+ * Common (matching) regions advance both cursors and are not part of any hunk.
89
+ */
90
+ function diffIndices(base, other) {
91
+ const parts = Diff.diffArrays(base, other);
92
+ const hunks = [];
93
+ let baseOffset = 0;
94
+ let otherOffset = 0;
95
+ let i = 0;
96
+ while (i < parts.length) {
97
+ const part = parts[i];
98
+ if (part === undefined) {
99
+ i++;
100
+ continue;
101
+ }
102
+ if (!part.added && !part.removed) {
103
+ baseOffset += part.value.length;
104
+ otherOffset += part.value.length;
105
+ i++;
106
+ continue;
107
+ }
108
+ // Start of a change region: consume the maximal run of added/removed parts.
109
+ const baseStart = baseOffset;
110
+ const otherStart = otherOffset;
111
+ const run = consumeChangeRun(parts, i);
112
+ i = run.next;
113
+ baseOffset += run.baseLength;
114
+ otherOffset += run.otherLength;
115
+ hunks.push({
116
+ baseOffset: baseStart,
117
+ baseLength: run.baseLength,
118
+ sideOffset: otherStart,
119
+ sideLength: run.otherLength,
120
+ });
121
+ }
122
+ return hunks;
123
+ }
124
+ /**
125
+ * Maps the base range [regionStart, regionEnd) into one side's line coordinates,
126
+ * using that side's hunks. Base lines inside the region but outside a hunk are
127
+ * unchanged on this side and map 1:1.
128
+ *
129
+ * `lo` (where the region starts on the side) is anchored on the FIRST hunk and
130
+ * `hi` (where it ends) on the LAST — not min/max across all hunks. With a single
131
+ * hunk these are equivalent, but when one region groups several same-side hunks
132
+ * whose net length differs from base (deletions/insertions, not pure
133
+ * substitutions), a per-hunk min/max over-extrapolates each hunk to the full
134
+ * region independently and unions the results, pulling in unchanged context lines
135
+ * outside [regionStart, regionEnd). That duplicated context then appears both as a
136
+ * stable chunk and inside the conflict body. `sideHunks` arrive sorted ascending
137
+ * by `baseOffset` (and base hunks never overlap), so the first/last elements carry
138
+ * the region's true start/end deltas.
139
+ */
140
+ function sliceSide(sideHunks, sideArr, regionStart, regionEnd) {
141
+ const first = sideHunks[0];
142
+ const last = sideHunks.at(-1);
143
+ if (first === undefined || last === undefined) {
144
+ return [];
145
+ }
146
+ const lo = first.sideOffset - (first.baseOffset - regionStart);
147
+ const hi = last.sideOffset + last.sideLength + (regionEnd - (last.baseOffset + last.baseLength));
148
+ return sideArr.slice(lo, hi);
149
+ }
150
+ function arraysEqual(a, b) {
151
+ if (a.length !== b.length) {
152
+ return false;
153
+ }
154
+ for (let i = 0; i < a.length; i++) {
155
+ if (a[i] !== b[i]) {
156
+ return false;
157
+ }
158
+ }
159
+ return true;
160
+ }
161
+ /**
162
+ * Starting from `first` at index `start`, groups every later hunk that genuinely
163
+ * overlaps the region on the base axis. Adjacent-but-disjoint edits
164
+ * (`next.baseOffset === regionEnd`) stay separate, so independent single-line
165
+ * edits from the two sides do not collide. Returns the region span, its hunks,
166
+ * and the index of the first hunk past it.
167
+ */
168
+ function collectRegion(first, hunks, start) {
169
+ const regionStart = first.baseOffset;
170
+ let regionEnd = first.baseOffset + first.baseLength;
171
+ const regionHunks = [first];
172
+ let i = start + 1;
173
+ while (i < hunks.length) {
174
+ const next = hunks[i];
175
+ if (next === undefined || next.baseOffset >= regionEnd) {
176
+ break;
177
+ }
178
+ regionEnd = Math.max(regionEnd, next.baseOffset + next.baseLength);
179
+ regionHunks.push(next);
180
+ i++;
181
+ }
182
+ return { regionStart, regionEnd, regionHunks, next: i };
183
+ }
184
+ /**
185
+ * Resolves a single grouped region into one merged chunk, or `null` when a
186
+ * one-sided region contributes no lines (e.g. a pure deletion). A region with
187
+ * changes from both sides is a conflict unless one side is unchanged from base
188
+ * (take the other) or both sides agree (take either).
189
+ */
190
+ function resolveRegion(region, local, base, incoming) {
191
+ const { regionStart, regionEnd, regionHunks } = region;
192
+ const localHunks = regionHunks.filter((hunk) => hunk.side === 'local');
193
+ const incomingHunks = regionHunks.filter((hunk) => hunk.side === 'incoming');
194
+ // Only one side touched this region — apply its content verbatim.
195
+ if (localHunks.length === 0 || incomingHunks.length === 0) {
196
+ const oneSided = localHunks.length > 0 ? localHunks : incomingHunks;
197
+ const sideArr = localHunks.length > 0 ? local : incoming;
198
+ const lines = sliceSide(oneSided, sideArr, regionStart, regionEnd);
199
+ return lines.length > 0 ? { type: 'stable', lines } : null;
200
+ }
201
+ const localLines = sliceSide(localHunks, local, regionStart, regionEnd);
202
+ const incomingLines = sliceSide(incomingHunks, incoming, regionStart, regionEnd);
203
+ const baseLines = base.slice(regionStart, regionEnd);
204
+ // Both agree, or incoming never moved from base -> take local.
205
+ if (arraysEqual(localLines, incomingLines) || arraysEqual(incomingLines, baseLines)) {
206
+ return { type: 'stable', lines: localLines };
207
+ }
208
+ // Local never moved from base -> take the generator's version.
209
+ if (arraysEqual(localLines, baseLines)) {
210
+ return { type: 'stable', lines: incomingLines };
211
+ }
212
+ return { type: 'conflict', local: localLines, base: baseLines, incoming: incomingLines };
213
+ }
214
+ /**
215
+ * Performs a three-way line merge and returns the resolved/conflicting chunks.
216
+ *
217
+ * @param local - The developer's current version (on disk).
218
+ * @param base - The common ancestor (previous generation).
219
+ * @param incoming - The freshly generated version.
220
+ */
221
+ function diff3Merge(local, base, incoming) {
222
+ const hunks = [
223
+ ...diffIndices(base, local).map((hunk) => ({ ...hunk, side: 'local' })),
224
+ ...diffIndices(base, incoming).map((hunk) => ({ ...hunk, side: 'incoming' })),
225
+ ];
226
+ // Order by base position; ties resolve local-before-incoming for stable output.
227
+ hunks.sort((a, b) => a.baseOffset - b.baseOffset || (a.side === b.side ? 0 : a.side === 'local' ? -1 : 1));
228
+ const chunks = [];
229
+ let cursor = 0;
230
+ const emitBase = (end) => {
231
+ if (end > cursor) {
232
+ chunks.push({ type: 'stable', lines: base.slice(cursor, end) });
233
+ cursor = end;
234
+ }
235
+ };
236
+ let i = 0;
237
+ while (i < hunks.length) {
238
+ const first = hunks[i];
239
+ if (first === undefined) {
240
+ i++;
241
+ continue;
242
+ }
243
+ const region = collectRegion(first, hunks, i);
244
+ i = region.next;
245
+ emitBase(region.regionStart);
246
+ const chunk = resolveRegion(region, local, base, incoming);
247
+ if (chunk !== null) {
248
+ chunks.push(chunk);
249
+ }
250
+ cursor = region.regionEnd;
251
+ }
252
+ emitBase(base.length);
253
+ return chunks;
254
+ }
255
+ /** Returns true if any chunk is an unresolved conflict. */
256
+ function hasConflictChunk(chunks) {
257
+ return chunks.some((chunk) => chunk.type === 'conflict');
258
+ }
@@ -35,12 +35,38 @@ export declare function hasMergeConflictMarkers(content: FileContent): boolean;
35
35
  * // your custom code here
36
36
  * // @custom-end:myFeature
37
37
  */
38
- export declare function generateMergeConflict({ contentSource, contentIncoming, labelSource, labelIncoming, }: {
38
+ export declare function generateMergeConflict({ contentSource, contentIncoming, contentBase, labelSource, labelIncoming, onWarning, }: {
39
39
  contentSource: FileContent;
40
40
  contentIncoming: FileContent;
41
+ /**
42
+ * The common ancestor (previous generation, `G(model_old)`). When provided as
43
+ * a string, a true three-way (diff3) merge is performed: developer edits and
44
+ * model-driven changes in disjoint regions merge cleanly, and conflict markers
45
+ * are emitted only where they genuinely overlap. When omitted (legacy projects,
46
+ * first run after upgrade), the function falls back to the 2-way path below.
47
+ */
48
+ contentBase?: FileContent;
41
49
  labelSource?: string;
42
50
  labelIncoming?: string;
51
+ /** Sink for `@custom-override` advisories. Defaults to a yellow `console.warn`. */
52
+ onWarning?: (message: string) => void;
43
53
  }): FileContent;
54
+ /**
55
+ * Applies `@custom-override:<member>` regions: for each overridden member, the
56
+ * developer's version (from `local`) replaces the generator's version inside
57
+ * `incoming`, so diff3 sees both sides agree and auto-resolves to the developer's
58
+ * code. When the generator would itself have changed that member (its body
59
+ * differs between `base` and `incoming`), an advisory is emitted so the developer
60
+ * can deliberately re-sync rather than silently shadow a model-driven change.
61
+ */
62
+ export declare function applyCustomOverrides({ base, local, incoming, onWarning, }: {
63
+ base: string;
64
+ local: string;
65
+ incoming: string;
66
+ onWarning: (message: string) => void;
67
+ }): {
68
+ incoming: string;
69
+ };
44
70
  /**
45
71
  * Generates a summary of the differences between two files: For each conflict,
46
72
  * it will show the first 3 lines of each conflict with line counters, i.e.:
@@ -36,10 +36,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.mergeConflictMarkers = void 0;
37
37
  exports.hasMergeConflictMarkers = hasMergeConflictMarkers;
38
38
  exports.generateMergeConflict = generateMergeConflict;
39
+ exports.applyCustomOverrides = applyCustomOverrides;
39
40
  exports.generateDiffSummary = generateDiffSummary;
40
41
  const Diff = __importStar(require("diff"));
41
42
  const utils_1 = require("@postxl/utils");
42
43
  const custom_blocks_1 = require("./custom-blocks");
44
+ const diff3_1 = require("./diff3");
43
45
  /**
44
46
  * Standard merge conflict markers used by git and our generator
45
47
  */
@@ -136,7 +138,7 @@ function isWhitespaceOnlyDifference(sourceLines, incomingLines) {
136
138
  * // your custom code here
137
139
  * // @custom-end:myFeature
138
140
  */
139
- function generateMergeConflict({ contentSource, contentIncoming, labelSource = 'Manual', labelIncoming = 'Generated', }) {
141
+ function generateMergeConflict({ contentSource, contentIncoming, contentBase, labelSource = 'Manual', labelIncoming = 'Generated', onWarning = (message) => console.warn((0, utils_1.yellow)(message)), }) {
140
142
  // In case the content is binary, we just return the incoming content
141
143
  if (typeof contentSource !== 'string' || typeof contentIncoming !== 'string') {
142
144
  return contentIncoming;
@@ -148,6 +150,17 @@ function generateMergeConflict({ contentSource, contentIncoming, labelSource = '
148
150
  if (sourceNormalized === incomingNormalized) {
149
151
  return contentIncoming;
150
152
  }
153
+ // True three-way merge when a base (previous generation) is available.
154
+ if (typeof contentBase === 'string') {
155
+ return generateThreeWayMerge({
156
+ contentBase,
157
+ contentSource,
158
+ contentIncoming,
159
+ labelSource,
160
+ labelIncoming,
161
+ onWarning,
162
+ });
163
+ }
151
164
  // Handle custom blocks: extract from source, insert into incoming
152
165
  if ((0, custom_blocks_1.hasCustomBlockMarkers)(contentSource)) {
153
166
  return generateMergeConflictWithCustomBlocks({
@@ -165,6 +178,122 @@ function generateMergeConflict({ contentSource, contentIncoming, labelSource = '
165
178
  labelIncoming,
166
179
  });
167
180
  }
181
+ /**
182
+ * Generates a true three-way merge between the previous generation (`base`),
183
+ * the developer's on-disk file (`source`/local), and the freshly generated file
184
+ * (`incoming`).
185
+ *
186
+ * Steps:
187
+ * 1. Apply any `@custom-override:<member>` regions by substituting the developer's
188
+ * version of those members into the incoming content (so they auto-resolve),
189
+ * emitting an advisory when the generator itself would have changed the member.
190
+ * 2. Run diff3 over the line arrays.
191
+ * 3. Suppress whitespace-only conflicts (reformatting must not manufacture them).
192
+ * 4. Emit conflict markers only for genuinely overlapping changes.
193
+ */
194
+ function generateThreeWayMerge({ contentBase, contentSource, contentIncoming, labelSource, labelIncoming, onWarning, }) {
195
+ const { incoming: effectiveIncoming } = applyCustomOverrides({
196
+ base: contentBase,
197
+ local: contentSource,
198
+ incoming: contentIncoming,
199
+ onWarning,
200
+ });
201
+ // Preserve `@custom-start`/`@custom-end` blocks the same way the 2-way path
202
+ // does: extract them from the developer's file, run diff3 on the remaining
203
+ // content, then re-insert the blocks. Without this, a model change in the same
204
+ // base region as a custom block would drag the block into conflict markers
205
+ // (a regression vs. the 2-way behavior). Removing the blocks before diff3
206
+ // guarantees they never appear inside markers; they are re-anchored afterwards.
207
+ if ((0, custom_blocks_1.hasCustomBlockMarkers)(contentSource)) {
208
+ const extractResult = (0, custom_blocks_1.extractCustomBlocks)(contentSource);
209
+ if (extractResult.blocks.length > 0) {
210
+ const sourceWithoutBlocks = (0, custom_blocks_1.reconstructNonCustomContent)(extractResult);
211
+ const chunks = (0, diff3_1.diff3Merge)(splitLines(sourceWithoutBlocks), splitLines(contentBase), splitLines(effectiveIncoming));
212
+ const merged = formatDiff3Chunks(chunks, labelSource, labelIncoming);
213
+ const { content, unplacedBlocks } = (0, custom_blocks_1.insertCustomBlocks)(extractResult.blocks, merged);
214
+ return handleUnplacedBlocks(content, unplacedBlocks);
215
+ }
216
+ }
217
+ const chunks = (0, diff3_1.diff3Merge)(splitLines(contentSource), splitLines(contentBase), splitLines(effectiveIncoming));
218
+ return formatDiff3Chunks(chunks, labelSource, labelIncoming);
219
+ }
220
+ /**
221
+ * Splits content into lines on normalized (Unix) line endings. A trailing
222
+ * newline yields a trailing empty line, which round-trips back to a trailing
223
+ * newline on join — preserving the file's exact terminal newline.
224
+ */
225
+ function splitLines(content) {
226
+ return content.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n');
227
+ }
228
+ /**
229
+ * Renders diff3 chunks into a string, emitting conflict markers for unresolved
230
+ * regions. Whitespace-only conflicts are resolved in favor of the incoming
231
+ * (generated) side so reformatting churn never surfaces as a conflict.
232
+ */
233
+ function formatDiff3Chunks(chunks, labelSource, labelIncoming) {
234
+ const out = [];
235
+ for (const chunk of chunks) {
236
+ if (chunk.type === 'stable') {
237
+ out.push(...chunk.lines);
238
+ continue;
239
+ }
240
+ if (isWhitespaceOnlyDifference(chunk.local, chunk.incoming)) {
241
+ out.push(...chunk.incoming);
242
+ continue;
243
+ }
244
+ out.push(`${exports.mergeConflictMarkers.start} ${labelSource}`, ...chunk.local, exports.mergeConflictMarkers.separator, ...chunk.incoming, `${exports.mergeConflictMarkers.end} ${labelIncoming}`);
245
+ }
246
+ return out.join('\n');
247
+ }
248
+ /**
249
+ * Applies `@custom-override:<member>` regions: for each overridden member, the
250
+ * developer's version (from `local`) replaces the generator's version inside
251
+ * `incoming`, so diff3 sees both sides agree and auto-resolves to the developer's
252
+ * code. When the generator would itself have changed that member (its body
253
+ * differs between `base` and `incoming`), an advisory is emitted so the developer
254
+ * can deliberately re-sync rather than silently shadow a model-driven change.
255
+ */
256
+ function applyCustomOverrides({ base, local, incoming, onWarning, }) {
257
+ if (!(0, custom_blocks_1.hasCustomOverrideMarkers)(local)) {
258
+ return { incoming };
259
+ }
260
+ const localLines = splitLines(local);
261
+ const baseLines = splitLines(base);
262
+ let incomingLines = splitLines(incoming);
263
+ for (const member of (0, custom_blocks_1.extractOverrideMemberNames)(local)) {
264
+ // `findMemberRange` starts at the member's signature line (it skips leading
265
+ // comments), so `localMember` deliberately excludes the `@custom-override`
266
+ // marker line that sits above it. That marker therefore stays in `local` as a
267
+ // local-only line relative to `base`/`incoming`, and diff3 auto-preserves it
268
+ // without it ever landing inside the substituted member body.
269
+ const localRange = (0, custom_blocks_1.findMemberRange)(localLines, member);
270
+ if (!localRange) {
271
+ onWarning(`@custom-override:${member} — member not found in the current file; ignoring this override.`);
272
+ continue;
273
+ }
274
+ const localMember = localLines.slice(localRange.start, localRange.end);
275
+ const incomingRange = (0, custom_blocks_1.findMemberRange)(incomingLines, member);
276
+ if (!incomingRange) {
277
+ onWarning(`@custom-override:${member} — the generator no longer emits this member; your version is preserved.`);
278
+ continue;
279
+ }
280
+ const incomingMember = incomingLines.slice(incomingRange.start, incomingRange.end);
281
+ const baseRange = (0, custom_blocks_1.findMemberRange)(baseLines, member);
282
+ if (baseRange) {
283
+ const baseMember = baseLines.slice(baseRange.start, baseRange.end);
284
+ if (!isWhitespaceOnlyDifference(baseMember, incomingMember)) {
285
+ onWarning(`@custom-override:${member} — the generator changed this member, but your override shadows it. ` +
286
+ `Remove the override and re-sync if you want the new generated version.`);
287
+ }
288
+ }
289
+ incomingLines = [
290
+ ...incomingLines.slice(0, incomingRange.start),
291
+ ...localMember,
292
+ ...incomingLines.slice(incomingRange.end),
293
+ ];
294
+ }
295
+ return { incoming: incomingLines.join('\n') };
296
+ }
168
297
  /**
169
298
  * Generates merge conflict output while preserving custom blocks.
170
299
  *
@@ -83,6 +83,15 @@ type SyncParams = {
83
83
  * `logSyncResult` to summarize the diff.
84
84
  */
85
85
  dryRun?: boolean;
86
+ /**
87
+ * The previous generation (`G(model_old)`) as a virtual file system, keyed by
88
+ * the same POSIX paths as `vfs`. When a file ends up in a `MergeConflict` state
89
+ * and its content is available here, sync performs a true three-way (diff3)
90
+ * merge instead of the 2-way disk-vs-fresh diff: developer edits and
91
+ * model-driven changes in disjoint regions merge cleanly. Omit (legacy projects
92
+ * / first run after upgrade) to fall back to the 2-way path.
93
+ */
94
+ baseVfs?: VirtualFileSystem;
86
95
  };
87
96
  /**
88
97
  * Error returned when files with unresolved merge conflicts are detected
@@ -125,7 +134,7 @@ export type SyncResults = {
125
134
  * merge conflict markers. If found, the sync will abort immediately and return an error with the
126
135
  * list of files that need to be resolved before generation can continue.
127
136
  */
128
- export declare function sync({ vfs, lockFilePath, diskFilePath, force, selectiveGeneration, dryRun, }: SyncParams): Promise<SyncResults>;
137
+ export declare function sync({ vfs, lockFilePath, diskFilePath, force, selectiveGeneration, dryRun, baseVfs, }: SyncParams): Promise<SyncResults>;
129
138
  type FileState = {
130
139
  state: 'empty';
131
140
  } | {
@@ -158,6 +167,7 @@ type Action_MergeConflict = {
158
167
  type: `MergeConflict`;
159
168
  diskContent: FileContent;
160
169
  virtualContent: FileContent;
170
+ baseContent?: FileContent;
161
171
  };
162
172
  export type ActionType = Action['type'];
163
173
  export declare function executeAction(filePath: Path.PosixPath, action: Action): Promise<ActionResult>;
@@ -121,7 +121,7 @@ const Path = __importStar(require("./path"));
121
121
  * merge conflict markers. If found, the sync will abort immediately and return an error with the
122
122
  * list of files that need to be resolved before generation can continue.
123
123
  */
124
- async function sync({ vfs, lockFilePath, diskFilePath, force, selectiveGeneration, dryRun, }) {
124
+ async function sync({ vfs, lockFilePath, diskFilePath, force, selectiveGeneration, dryRun, baseVfs, }) {
125
125
  const diskPathNormalized = Path.normalize(diskFilePath);
126
126
  const files = await getFilesStates({ vfs, lockFilePath, diskFilePath, selectiveGeneration: !!selectiveGeneration });
127
127
  // Check for unresolved merge conflicts before writing any files
@@ -155,7 +155,7 @@ async function sync({ vfs, lockFilePath, diskFilePath, force, selectiveGeneratio
155
155
  };
156
156
  continue;
157
157
  }
158
- const [action, isEjectedFile] = determineActionState({ virtual, lock, disk }, force);
158
+ const [action, isEjectedFile] = determineActionState({ virtual, lock, disk }, force, baseVfs?.get(filePath));
159
159
  if (dryRun) {
160
160
  // Dry-run: record the planned action but do not touch disk.
161
161
  result.files[filePath] = {
@@ -340,7 +340,7 @@ const actionMap = {
340
340
  'V:empty-L:empty-D:Hash0': ['NoAction', false, 'NoAction'], // Indistinguishable from "empty-empty-HashX"
341
341
  'V:empty-L:empty-D:empty': ['NoAction', false, 'NoAction'], // Cannot occur
342
342
  };
343
- function determineActionState({ virtual, lock, disk }, force) {
343
+ function determineActionState({ virtual, lock, disk }, force, baseContent) {
344
344
  const stateKey = getStateKey({ virtual, lock, disk });
345
345
  const [defaultActionType, isEjected, forceActionType] = actionMap[stateKey];
346
346
  const actionType = force ? forceActionType : defaultActionType;
@@ -357,7 +357,15 @@ function determineActionState({ virtual, lock, disk }, force) {
357
357
  if (virtual.state === 'empty') {
358
358
  throw new Error(`This should not happen assuming that the actionMap only has 'Write' actions for entries starting with H1, ie a defined virtual file`);
359
359
  }
360
- return [{ type: 'MergeConflict', diskContent: disk.content, virtualContent: virtual.content }, isEjected];
360
+ return [
361
+ {
362
+ type: 'MergeConflict',
363
+ diskContent: disk.content,
364
+ virtualContent: virtual.content,
365
+ ...(baseContent === undefined ? {} : { baseContent }),
366
+ },
367
+ isEjected,
368
+ ];
361
369
  }
362
370
  return [{ type: actionType }, isEjected];
363
371
  }
@@ -375,7 +383,11 @@ async function executeAction(filePath, action) {
375
383
  throw new utils_1.ExhaustiveSwitchCheck(action);
376
384
  }
377
385
  }
378
- async function writeMergeConflictFile(filePath, { diskContent, virtualContent }) {
379
- const content = (0, merge_conflict_1.generateMergeConflict)({ contentSource: diskContent, contentIncoming: virtualContent });
386
+ async function writeMergeConflictFile(filePath, { diskContent, virtualContent, baseContent }) {
387
+ const content = (0, merge_conflict_1.generateMergeConflict)({
388
+ contentSource: diskContent,
389
+ contentIncoming: virtualContent,
390
+ ...(baseContent === undefined ? {} : { contentBase: baseContent }),
391
+ });
380
392
  return (0, fs_utils_1.writeFile)(filePath, content);
381
393
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postxl/generator",
3
- "version": "1.6.3",
3
+ "version": "1.7.0",
4
4
  "description": "Core package that orchestrates the code generation of a PXL project",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -46,7 +46,7 @@
46
46
  "jszip": "3.10.1",
47
47
  "minimatch": "^10.2.5",
48
48
  "p-limit": "3.1.0",
49
- "@postxl/schema": "^2.0.1",
49
+ "@postxl/schema": "^2.1.0",
50
50
  "@postxl/utils": "^1.4.1"
51
51
  },
52
52
  "devDependencies": {