craftar 0.7.1 → 0.8.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.
@@ -0,0 +1,740 @@
1
+ import { SECTION_NAME } from "../schema/index.js";
2
+ import { splitLines } from "./diff.js";
3
+ import { expandSections, firstMarkerLine, parseSections, SectionMarkerError } from "./sections.js";
4
+ import { stripBom, toLf } from "./text.js";
5
+ import { placeholders, substituteKeys } from "./extract.js";
6
+ /**
7
+ * Compute the opener/closer span for each section of a parsed file (spec 12 §4.2).
8
+ * `closer = opener + s.default.split("\n").length` — the opener line, plus one line per `\n` in the default.
9
+ */
10
+ function sectionSpans(parsed) {
11
+ return parsed.sections.map((s) => ({
12
+ name: s.name,
13
+ opener: s.line,
14
+ closer: s.line + s.default.split("\n").length,
15
+ }));
16
+ }
17
+ /**
18
+ * Does a hunk "touch" a section span? (spec 12 §4.2's definition)
19
+ * - With base lines: at least one line j in [start, start + length - 1] satisfies opener <= j <= closer.
20
+ * - Without base lines: position N = h.a.start - 1 satisfies opener <= N < closer.
21
+ */
22
+ function touches(h, span) {
23
+ const { opener: o, closer: c } = span;
24
+ if (h.a.lines.length > 0) {
25
+ for (let j = h.a.start; j < h.a.start + h.a.lines.length; j++) {
26
+ if (o <= j && j <= c)
27
+ return true;
28
+ }
29
+ return false;
30
+ }
31
+ else {
32
+ const N = h.a.start - 1;
33
+ return o <= N && N < c;
34
+ }
35
+ }
36
+ /** Parse a "from-to" range string into two numbers. */
37
+ function parseLines(s) {
38
+ const [a, b] = s.split("-").map(Number);
39
+ return [a, b];
40
+ }
41
+ /** The position a no-base-line hunk inserts at: h.a.start - 1 (spec 12 §6.1, step 3). */
42
+ function pureAdditionPos(h) {
43
+ return h.a.start - 1;
44
+ }
45
+ /**
46
+ * S5 error message: wording differs based on whether the plan gave explicit `lines`.
47
+ * When the plan gave `lines`, the user meant to override the computed span, so we say
48
+ * "lines <range> of section <name>". Otherwise the span was computed from hunks, so we say
49
+ * "section <name> (lines <range>)".
50
+ */
51
+ function s5CoverageError(name, rangeStr, hunkNum, take, linesSpecified) {
52
+ if (linesSpecified) {
53
+ return new Error(`unify plan: lines ${rangeStr} of section ${name} covers hunk ${hunkNum}, which is take: ${take}`);
54
+ }
55
+ else {
56
+ return new Error(`unify plan: section ${name} (lines ${rangeStr}) covers hunk ${hunkNum}, which is take: ${take}`);
57
+ }
58
+ }
59
+ /**
60
+ * Compute the section runs, their spans, defaults and values from a file's diff and plan entries.
61
+ * Throws with spec 12 §4.6 messages (S1–S10, S12) on any contradiction.
62
+ */
63
+ export function deriveSections(args) {
64
+ const { file, label, ref, baseText, variantText, hunks, entries, declaredElsewhere } = args;
65
+ const A = splitLines(baseText);
66
+ const B = splitLines(variantText);
67
+ // Step 1: Variant markers (S12)
68
+ const variantMarkerLine = firstMarkerLine(toLf(stripBom(variantText)));
69
+ if (variantMarkerLine !== null) {
70
+ throw new Error(`unify: variant holds a section marker on ${file}:${variantMarkerLine} — remove it by hand and save the plan again, or re-import the workspace`);
71
+ }
72
+ // Step 2: Base sections
73
+ const parsed = parseSections(baseText, label, ref);
74
+ const baseSections = sectionSpans(parsed);
75
+ // Step 4: Group section entries
76
+ const sectionEntries = entries.filter((e) => e.take === "section");
77
+ const byName = new Map();
78
+ for (const e of sectionEntries) {
79
+ if (!e.section) {
80
+ throw new Error(`unify plan: "${file}" hunk ${e.hunk} is take: section but names no section`);
81
+ }
82
+ const name = e.section.name;
83
+ let group = byName.get(name);
84
+ if (!group) {
85
+ group = { hunkNums: [], linesRanges: [] };
86
+ byName.set(name, group);
87
+ }
88
+ group.hunkNums.push(e.hunk);
89
+ if (e.section.lines)
90
+ group.linesRanges.push(e.section.lines);
91
+ }
92
+ // Validate groups: consecutive hunks, same lines range
93
+ for (const [name, group] of byName) {
94
+ group.hunkNums.sort((a, b) => a - b);
95
+ // Check consecutive
96
+ for (let i = 1; i < group.hunkNums.length; i++) {
97
+ if (group.hunkNums[i] !== group.hunkNums[i - 1] + 1) {
98
+ throw new Error(`unify plan: section ${name} is not one run of consecutive hunks (hunks ${group.hunkNums[i - 1]}, ${group.hunkNums[i]})`);
99
+ }
100
+ }
101
+ // Check same lines range (S4)
102
+ const uniqueRanges = [...new Set(group.linesRanges)];
103
+ if (uniqueRanges.length > 1) {
104
+ throw new Error(`unify plan: section ${name} has two ranges: ${uniqueRanges[0]}, ${uniqueRanges[1]}`);
105
+ }
106
+ }
107
+ // Step 5: Existing or new
108
+ const runs = [];
109
+ const runByName = new Map();
110
+ for (const [name, group] of byName) {
111
+ const runHunks = group.hunkNums.map((n) => hunks[n - 1]);
112
+ const linesStr = group.linesRanges[0]; // all same or none
113
+ // Find base sections touched by any hunk of this run
114
+ const touchedSections = new Set();
115
+ for (const h of runHunks) {
116
+ for (const sec of baseSections) {
117
+ if (touches(h, sec))
118
+ touchedSections.add(sec);
119
+ }
120
+ }
121
+ // Classify: existing or new
122
+ const touched = [...touchedSections];
123
+ if (touched.length > 1) {
124
+ // More than one section touched
125
+ throw new Error(`unify plan: section ${name} would overlap section ${touched[1].name} (${file}:${touched[1].opener})`);
126
+ }
127
+ if (touched.length === 1) {
128
+ const sec = touched[0];
129
+ if (sec.name !== name) {
130
+ // Touched section has different name
131
+ throw new Error(`unify plan: section ${name} would overlap section ${sec.name} (${file}:${sec.opener})`);
132
+ }
133
+ // Check all run hunks touch this section (S7 for partial touch)
134
+ for (const h of runHunks) {
135
+ const touchesThis = touches(h, sec);
136
+ if (!touchesThis) {
137
+ throw new Error(`unify plan: section ${name} would overlap section ${sec.name} (${file}:${sec.opener})`);
138
+ }
139
+ }
140
+ // EXISTING section
141
+ // S8: Every hunk with base lines must have them all within [opener, closer]
142
+ for (let i = 0; i < runHunks.length; i++) {
143
+ const h = runHunks[i];
144
+ if (h.a.lines.length > 0) {
145
+ const first = h.a.start;
146
+ const last = h.a.start + h.a.lines.length - 1;
147
+ if (first < sec.opener || last > sec.closer) {
148
+ throw new Error(`unify plan: hunk ${group.hunkNums[i]} of "${file}" holds lines inside and outside section ${name} — its variant side cannot be split; resolve it by re-importing the workspace`);
149
+ }
150
+ }
151
+ }
152
+ // S9: If lines given, must be exactly opener-closer
153
+ if (linesStr) {
154
+ const expected = `${sec.opener}-${sec.closer}`;
155
+ if (linesStr !== expected) {
156
+ throw new Error(`unify plan: section ${name} already spans lines ${sec.opener}-${sec.closer}; unify does not move existing markers`);
157
+ }
158
+ }
159
+ const from = sec.opener;
160
+ const to = sec.closer;
161
+ // Compute value from variant segment
162
+ const value = computeValue(A, B, hunks, from, to, name, file, !!linesStr);
163
+ runs.push({
164
+ name,
165
+ file,
166
+ hunks: group.hunkNums,
167
+ existing: true,
168
+ linesSpecified: !!linesStr,
169
+ from,
170
+ to,
171
+ default: null,
172
+ value,
173
+ });
174
+ }
175
+ else {
176
+ // NEW section
177
+ // S6: name must not be declared anywhere
178
+ const declaredInThis = baseSections.find((s) => s.name === name);
179
+ if (declaredInThis) {
180
+ throw new Error(`unify plan: section ${name} is already declared in ${ref} (${file}:${declaredInThis.opener})`);
181
+ }
182
+ const declaredOther = declaredElsewhere.get(name);
183
+ if (declaredOther) {
184
+ throw new Error(`unify plan: section ${name} is already declared in ${ref} (${declaredOther})`);
185
+ }
186
+ // Compute span
187
+ let from;
188
+ let to;
189
+ if (linesStr) {
190
+ // With lines
191
+ const [a, b] = parseLines(linesStr);
192
+ if (a < 1 || b < a || b > A.lines.length) {
193
+ throw new Error(`unify plan: lines ${linesStr} of section ${name} is out of range`);
194
+ }
195
+ // Check all run hunks are within the range
196
+ for (let i = 0; i < runHunks.length; i++) {
197
+ const h = runHunks[i];
198
+ if (h.a.lines.length > 0) {
199
+ const first = h.a.start;
200
+ const last = h.a.start + h.a.lines.length - 1;
201
+ if (first < a || last > b) {
202
+ throw new Error(`unify plan: lines ${linesStr} of section ${name} does not contain hunk ${group.hunkNums[i]}`);
203
+ }
204
+ }
205
+ else {
206
+ const N = pureAdditionPos(h);
207
+ if (N < a - 1 || N > b) {
208
+ throw new Error(`unify plan: lines ${linesStr} of section ${name} does not contain hunk ${group.hunkNums[i]}`);
209
+ }
210
+ }
211
+ }
212
+ from = a;
213
+ to = b;
214
+ }
215
+ else {
216
+ // Without lines: spec 12 §6.2 — a pure insertion at position N contributes N+1 for 'from'
217
+ // and N for 'to'; Ruling 1 includes equal lines between hunks.
218
+ const fromCandidates = [];
219
+ const toCandidates = [];
220
+ for (const h of runHunks) {
221
+ if (h.a.lines.length > 0) {
222
+ fromCandidates.push(h.a.start);
223
+ toCandidates.push(h.a.start + h.a.lines.length - 1);
224
+ }
225
+ else {
226
+ const N = pureAdditionPos(h);
227
+ fromCandidates.push(N + 1);
228
+ toCandidates.push(N);
229
+ }
230
+ }
231
+ // All pure insertions at one position → empty span (from > to)
232
+ const uniqueFrom = new Set(fromCandidates);
233
+ const uniqueTo = new Set(toCandidates);
234
+ if (uniqueFrom.size === 1 && uniqueTo.size === 1 && Math.min(...fromCandidates) > Math.max(...toCandidates)) {
235
+ // Empty span: all hunks are pure insertions at the same position
236
+ from = fromCandidates[0];
237
+ to = toCandidates[0];
238
+ }
239
+ else {
240
+ from = Math.min(...fromCandidates);
241
+ to = Math.max(...toCandidates);
242
+ }
243
+ }
244
+ // Check span doesn't contain any existing section's opener or closer (S7)
245
+ for (const sec of baseSections) {
246
+ if (from <= sec.opener && sec.opener <= to) {
247
+ throw new Error(`unify plan: section ${name} would overlap section ${sec.name} (${file}:${sec.opener})`);
248
+ }
249
+ if (from <= sec.closer && sec.closer <= to) {
250
+ throw new Error(`unify plan: section ${name} would overlap section ${sec.name} (${file}:${sec.closer})`);
251
+ }
252
+ }
253
+ // Compute default and value
254
+ const defaultText = computeDefault(A, from, to, name, file);
255
+ const value = computeValue(A, B, hunks, from, to, name, file, !!linesStr);
256
+ runs.push({
257
+ name,
258
+ file,
259
+ hunks: group.hunkNums,
260
+ existing: false,
261
+ linesSpecified: !!linesStr,
262
+ from,
263
+ to,
264
+ default: defaultText,
265
+ value,
266
+ });
267
+ }
268
+ runByName.set(name, runs[runs.length - 1]);
269
+ }
270
+ // Step 6: Coverage — every hunk not in a run must lie outside all spans
271
+ const hunkInRun = new Set();
272
+ for (const run of runs) {
273
+ for (const n of run.hunks)
274
+ hunkInRun.add(n);
275
+ }
276
+ for (let i = 0; i < hunks.length; i++) {
277
+ const hunkNum = i + 1;
278
+ if (hunkInRun.has(hunkNum))
279
+ continue;
280
+ const h = hunks[i];
281
+ const entry = entries.find((e) => e.hunk === hunkNum);
282
+ const take = entry?.take ?? "keep";
283
+ for (const run of runs) {
284
+ const { from, to, name, linesSpecified } = run;
285
+ const isEmptySpan = from === to + 1;
286
+ const rangeStr = `${from}-${to}`;
287
+ if (h.a.lines.length > 0) {
288
+ // Hunk with base lines: none in [from, to]
289
+ for (let j = h.a.start; j < h.a.start + h.a.lines.length; j++) {
290
+ if (!isEmptySpan && from <= j && j <= to) {
291
+ throw s5CoverageError(name, rangeStr, hunkNum, take, linesSpecified);
292
+ }
293
+ }
294
+ }
295
+ else {
296
+ // No base lines: N must satisfy N < from - 1 or N > to
297
+ const N = pureAdditionPos(h);
298
+ if (!isEmptySpan && !(N < from - 1 || N > to)) {
299
+ throw s5CoverageError(name, rangeStr, hunkNum, take, linesSpecified);
300
+ }
301
+ else if (isEmptySpan && N === to) {
302
+ // Empty span: N cannot equal to (the position)
303
+ throw s5CoverageError(name, rangeStr, hunkNum, take, linesSpecified);
304
+ }
305
+ }
306
+ }
307
+ }
308
+ // Check two runs don't overlap (S7)
309
+ // Sort deterministically: by from, then by to, then by name
310
+ const sortedRuns = [...runs].sort((a, b) => {
311
+ if (a.from !== b.from)
312
+ return a.from - b.from;
313
+ if (a.to !== b.to)
314
+ return a.to - b.to;
315
+ return a.name.localeCompare(b.name);
316
+ });
317
+ for (let i = 1; i < sortedRuns.length; i++) {
318
+ const prev = sortedRuns[i - 1];
319
+ const curr = sortedRuns[i];
320
+ const prevEmpty = prev.from === prev.to + 1;
321
+ const currEmpty = curr.from === curr.to + 1;
322
+ // Non-empty vs non-empty: prev.to must be < curr.from
323
+ // An empty span [N+1, N] conflicts with a non-empty neighbour that touches or contains position N, and with another empty span at the same N.
324
+ let overlaps = false;
325
+ if (!prevEmpty && !currEmpty) {
326
+ // Both non-empty: standard check
327
+ overlaps = prev.to >= curr.from;
328
+ }
329
+ else if (prevEmpty && !currEmpty) {
330
+ // Empty prev: [from=N+1, to=N] touches curr if curr.from === N + 1
331
+ // prev.from is the "after line N" position = N + 1
332
+ overlaps = curr.from === prev.from;
333
+ }
334
+ else if (!prevEmpty && currEmpty) {
335
+ // Empty curr: [from=N+1, to=N] is inside or touching prev if prev.to >= N.
336
+ // The sort guarantees prev.from <= curr.from, so >= covers both touching and containing.
337
+ overlaps = prev.to >= curr.to;
338
+ }
339
+ else {
340
+ // Both empty: [prevFrom=M+1, prevTo=M] and [currFrom=N+1, currTo=N]
341
+ // They overlap only if M === N (same position, i.e. both insert after the same line).
342
+ // Adjacent positions (M+1 === N, i.e. one base line apart) do NOT overlap.
343
+ overlaps = prev.to === curr.to;
344
+ }
345
+ if (overlaps) {
346
+ throw new Error(`unify plan: section ${curr.name} would overlap section ${prev.name} (${file}:${prev.from})`);
347
+ }
348
+ }
349
+ // Step 9: Generate markers for new sections
350
+ // Track section name with each marker for proper sorting
351
+ const markersWithMeta = [];
352
+ for (const run of runs) {
353
+ if (run.existing)
354
+ continue;
355
+ const opener = `<!-- craftar:section ${run.name} -->`;
356
+ const closer = `<!-- /craftar:section -->`;
357
+ // For an empty span (from === to + 1), both markers go at position `to` (which equals from - 1)
358
+ // opener at from - 1 (0-based: from - 1), closer at to (0-based: to)
359
+ // For non-empty: opener before line `from` (at = from - 1), closer after line `to` (at = to)
360
+ const openerAt = run.from - 1;
361
+ const closerAt = run.to;
362
+ markersWithMeta.push({ at: openerAt, line: opener, name: run.name, isOpener: true });
363
+ markersWithMeta.push({ at: closerAt, line: closer, name: run.name, isOpener: false });
364
+ }
365
+ // Sort markers: by `at`, then by type (closer before opener for different sections,
366
+ // opener before closer for same section), then by name for stability.
367
+ //
368
+ // This is a total order because S7 refuses every empty span sharing a position with
369
+ // another span's marker. After S7 passes, at any `at` value we have either:
370
+ // - One section's opener and closer (empty span) → opener before closer
371
+ // - Different sections' openers or closers → closer before opener, then by name
372
+ // No two empty spans share an `at`, and no empty span shares `at` with another marker.
373
+ markersWithMeta.sort((a, b) => {
374
+ if (a.at !== b.at)
375
+ return a.at - b.at;
376
+ if (a.name === b.name) {
377
+ // Same section: opener before closer
378
+ return a.isOpener ? -1 : 1;
379
+ }
380
+ // Different sections at same position: closer before opener
381
+ if (a.isOpener !== b.isOpener) {
382
+ return a.isOpener ? 1 : -1;
383
+ }
384
+ // Both same type (both openers or both closers), different names: sort by name
385
+ return a.name.localeCompare(b.name);
386
+ });
387
+ const markers = markersWithMeta.map(({ at, line }) => ({ at, line }));
388
+ return { runs: sortedRuns, markers };
389
+ }
390
+ /**
391
+ * Compute the default text for a NEW section (base lines from..to).
392
+ * S10: check for missing final newline.
393
+ */
394
+ function computeDefault(A, from, to, name, file) {
395
+ const isEmptySpan = from === to + 1;
396
+ if (isEmptySpan) {
397
+ // Empty span: check S10 for empty span at end of file
398
+ if (to === A.lines.length && !A.eofNewline && A.lines.length > 0) {
399
+ throw new Error(`unify plan: section ${name} would reach a missing final newline in the base, which a section cannot reproduce`);
400
+ }
401
+ return "";
402
+ }
403
+ // Non-empty span
404
+ if (to === A.lines.length && !A.eofNewline) {
405
+ throw new Error(`unify plan: section ${name} would reach a missing final newline in the base, which a section cannot reproduce`);
406
+ }
407
+ const lines = A.lines.slice(from - 1, to);
408
+ const text = lines.map((l) => l + "\n").join("");
409
+ // S12: check for marker in default
410
+ if (firstMarkerLine(text) !== null) {
411
+ throw new Error(`unify plan: section ${name} would hold a section marker`);
412
+ }
413
+ return text;
414
+ }
415
+ /**
416
+ * Compute the value for a section (variant lines between anchors).
417
+ * S10: check for missing final newline.
418
+ * S5: anchors must be equal lines (not inside any hunk).
419
+ * @param linesSpecified true when the plan gave explicit `lines`, false when span was computed from hunks
420
+ */
421
+ function computeValue(A, B, hunks, from, to, name, file, linesSpecified) {
422
+ const isEmptySpan = from === to + 1;
423
+ // Step 7: Anchors and mapping
424
+ // Anchors are base lines from - 1 and to + 1 (absent when < 1 or > A.lines.length)
425
+ const startAnchor = from - 1 >= 1 ? from - 1 : null;
426
+ const endAnchor = to + 1 <= A.lines.length ? to + 1 : null;
427
+ // Step 7 assert: anchors must lie in no hunk (they must be equal lines)
428
+ // If an anchor is inside a hunk's base lines, the value would be read from wrong variant lines
429
+ const lineInHunk = (line) => {
430
+ for (let i = 0; i < hunks.length; i++) {
431
+ const h = hunks[i];
432
+ if (h.a.lines.length > 0) {
433
+ const first = h.a.start;
434
+ const last = h.a.start + h.a.lines.length - 1;
435
+ if (first <= line && line <= last) {
436
+ return i + 1; // 1-based hunk number
437
+ }
438
+ }
439
+ }
440
+ return null;
441
+ };
442
+ if (startAnchor !== null) {
443
+ const hunkNum = lineInHunk(startAnchor);
444
+ if (hunkNum !== null) {
445
+ // S5: wording differs based on whether the plan gave explicit `lines`
446
+ if (linesSpecified) {
447
+ throw new Error(`unify plan: lines ${from}-${to} of section ${name} cuts hunk ${hunkNum}`);
448
+ }
449
+ else {
450
+ throw new Error(`unify plan: section ${name} (lines ${from}-${to}) cuts hunk ${hunkNum}`);
451
+ }
452
+ }
453
+ }
454
+ if (endAnchor !== null) {
455
+ const hunkNum = lineInHunk(endAnchor);
456
+ if (hunkNum !== null) {
457
+ // S5: wording differs based on whether the plan gave explicit `lines`
458
+ if (linesSpecified) {
459
+ throw new Error(`unify plan: lines ${from}-${to} of section ${name} cuts hunk ${hunkNum}`);
460
+ }
461
+ else {
462
+ throw new Error(`unify plan: section ${name} (lines ${from}-${to}) cuts hunk ${hunkNum}`);
463
+ }
464
+ }
465
+ }
466
+ // Map anchor line i to variant: i' = i + Σ(h.b.lines.length - h.a.lines.length) for hunks before i
467
+ const mapToVariant = (i) => {
468
+ let offset = 0;
469
+ for (const h of hunks) {
470
+ // A hunk is "before" line i if its base lines end before i
471
+ // h.a.start + h.a.lines.length <= i covers both cases
472
+ if (h.a.start + h.a.lines.length <= i) {
473
+ offset += h.b.lines.length - h.a.lines.length;
474
+ }
475
+ }
476
+ return i + offset;
477
+ };
478
+ // Variant segment bounds
479
+ let variantStart;
480
+ let variantEnd;
481
+ if (startAnchor !== null) {
482
+ variantStart = mapToVariant(startAnchor) + 1; // line after the anchor's counterpart
483
+ }
484
+ else {
485
+ variantStart = 1;
486
+ }
487
+ if (endAnchor !== null) {
488
+ variantEnd = mapToVariant(endAnchor) - 1; // line before the anchor's counterpart
489
+ }
490
+ else {
491
+ variantEnd = B.lines.length;
492
+ }
493
+ // Empty variant segment
494
+ if (variantStart > variantEnd) {
495
+ return "";
496
+ }
497
+ // S10: check for missing final newline in variant
498
+ if (variantEnd === B.lines.length && !B.eofNewline) {
499
+ throw new Error(`unify plan: section ${name} would reach a missing final newline in the variant, which a section cannot reproduce`);
500
+ }
501
+ const lines = B.lines.slice(variantStart - 1, variantEnd);
502
+ const value = lines.map((l) => l + "\n").join("");
503
+ // S12: check for marker in value
504
+ if (firstMarkerLine(value) !== null) {
505
+ throw new Error(`unify plan: section ${name} would hold a section marker`);
506
+ }
507
+ return value;
508
+ }
509
+ /**
510
+ * The section half of the equivalence proof (spec 12 §6.5), for one file that has at least one
511
+ * section run. It also carries the plan's param keys K (spec 09 §6.2), because a file can hold both:
512
+ * for such a file this replaces `prove`. Throws S17 naming the file.
513
+ */
514
+ export function proveSections(args) {
515
+ const { file, label, ref, template, mBase, mVar, newNames, values, profileValues, extractions } = args;
516
+ const n = (x) => toLf(stripBom(x));
517
+ // Build D and V from extractions
518
+ const D = new Map(extractions.map((e) => [e.key, e.default]));
519
+ const V = new Map(extractions.map((e) => [e.key, e.value]));
520
+ const K = new Set(extractions.map((e) => e.key));
521
+ const names = (p) => p.sections.map((s) => s.name);
522
+ // Step 1: Parse all three
523
+ let pT;
524
+ let pB;
525
+ let pV;
526
+ const runNames = Object.keys(values).join(", ") || newNames.join(", ");
527
+ try {
528
+ pT = parseSections(template, label, ref);
529
+ }
530
+ catch (e) {
531
+ if (e instanceof SectionMarkerError) {
532
+ throw new Error(`unify: section ${runNames} would not reproduce the base side of "${file}" (${e.problem})`);
533
+ }
534
+ throw e;
535
+ }
536
+ try {
537
+ pB = parseSections(mBase, label, ref);
538
+ }
539
+ catch (e) {
540
+ if (e instanceof SectionMarkerError) {
541
+ throw new Error(`unify: section ${runNames} would not reproduce the base side of "${file}" (${e.problem})`);
542
+ }
543
+ throw e;
544
+ }
545
+ try {
546
+ pV = parseSections(mVar, label, ref);
547
+ }
548
+ catch (e) {
549
+ if (e instanceof SectionMarkerError) {
550
+ throw new Error(`unify: section ${runNames} would not reproduce the variant side of "${file}" (${e.problem})`);
551
+ }
552
+ throw e;
553
+ }
554
+ // Step 2: Structure check
555
+ // names(pT) with every name of newNames removed must equal names(pB)
556
+ const tNames = names(pT);
557
+ const bNames = names(pB);
558
+ const newNamesSet = new Set(newNames);
559
+ // Check every newNames entry appears exactly once in tNames
560
+ for (const name of newNames) {
561
+ const count = tNames.filter((n) => n === name).length;
562
+ if (count !== 1) {
563
+ throw new Error(`unify: section ${runNames} would not reproduce the base side of "${file}"`);
564
+ }
565
+ }
566
+ // names(pT) - newNames must equal names(pB)
567
+ const tNamesFiltered = tNames.filter((name) => !newNamesSet.has(name));
568
+ if (tNamesFiltered.length !== bNames.length || !tNamesFiltered.every((name, i) => name === bNames[i])) {
569
+ throw new Error(`unify: section ${runNames} would not reproduce the base side of "${file}"`);
570
+ }
571
+ // Step 3: Base side proof
572
+ const tB = expandSections(pT, {});
573
+ const mB = expandSections(pB, {});
574
+ if (n(substituteKeys(tB, D)) !== n(substituteKeys(mB, D))) {
575
+ throw new Error(`unify: section ${runNames} would not reproduce the base side of "${file}"`);
576
+ }
577
+ // Step 4: Variant side proof
578
+ const combinedValues = { ...profileValues, ...values };
579
+ const tV = expandSections(pT, combinedValues);
580
+ const mV = expandSections(pV, profileValues);
581
+ if (n(substituteKeys(tV, V)) !== n(substituteKeys(mV, V))) {
582
+ throw new Error(`unify: section ${runNames} would not reproduce the variant side of "${file}"`);
583
+ }
584
+ // Step 5: Placeholders check (spec 09 §6.2 (b))
585
+ const others = (t) => placeholders(n(t)).filter((k) => !K.has(k));
586
+ const othersTB = others(tB);
587
+ const othersMB = others(mB);
588
+ if (JSON.stringify(othersTB) !== JSON.stringify(othersMB)) {
589
+ throw new Error(`unify: extracting into "${file}" would change which {{…}} placeholders the text holds`);
590
+ }
591
+ const othersTV = others(tV);
592
+ const othersMV = others(mV);
593
+ if (JSON.stringify(othersTV) !== JSON.stringify(othersMV)) {
594
+ throw new Error(`unify: extracting into "${file}" would change which {{…}} placeholders the text holds`);
595
+ }
596
+ }
597
+ /**
598
+ * A Markdown heading line: `/^#{1,6}[ \t]+(.+?)[ \t#]*$/` at column 0.
599
+ * The capture group is the heading text, trimmed of trailing spaces and `#`.
600
+ */
601
+ const HEADING_RE = /^#{1,6}[ \t]+(.+?)[ \t#]*$/;
602
+ /**
603
+ * Turn a heading text into a slug for a section name (spec 12 §4.2):
604
+ * NFD, strip combining marks (`\p{M}`), lower-case, non-`[a-z0-9]` → `-`, trim `-`, cut at 40 chars, trim `-` again.
605
+ */
606
+ function headingSlug(text) {
607
+ // NFD decomposition, strip combining marks
608
+ const decomposed = text.normalize("NFD").replace(/\p{M}/gu, "");
609
+ // Lower-case
610
+ const lower = decomposed.toLowerCase();
611
+ // Non-[a-z0-9] → -
612
+ const dashed = lower.replace(/[^a-z0-9]+/g, "-");
613
+ // Trim leading/trailing -
614
+ const trimmed = dashed.replace(/^-+|-+$/g, "");
615
+ // Cut at 40 chars
616
+ const cut = trimmed.slice(0, 40);
617
+ // Trim trailing - again (in case cut ended mid-run)
618
+ return cut.replace(/-+$/, "");
619
+ }
620
+ /**
621
+ * The section name `--save-plan` pre-fills for each hunk of one file, or undefined (spec 12 §4.2).
622
+ * Index = hunk index (0-based).
623
+ *
624
+ * Rules (in this order, per hunk):
625
+ * 1. Touches an existing section → that section's exact name.
626
+ * 2. Suggestion class `block` → a slug from the nearest heading above, or `section-<n>`.
627
+ * 3. Otherwise → undefined.
628
+ *
629
+ * Uniqueness (rule 2 names only): a candidate name that is in `declared`, or that an earlier
630
+ * non-consecutive hunk of this file got, gets `-2`, `-3`, … appended until it is free.
631
+ * Consecutive block hunks keep the same name (they form one run if the human sets them all to `take: section`).
632
+ *
633
+ * A parse error in the base → return all undefined (pre-fill never blocks `--save-plan`).
634
+ */
635
+ export function prefillSections(args) {
636
+ const { baseText, label, ref, hunks, declared } = args;
637
+ const result = new Array(hunks.length).fill(undefined);
638
+ // Parse base sections; on error, return all undefined
639
+ let parsed;
640
+ try {
641
+ parsed = parseSections(baseText, label, ref);
642
+ }
643
+ catch (e) {
644
+ if (e instanceof SectionMarkerError)
645
+ return result;
646
+ throw e;
647
+ }
648
+ // Build base section spans using the shared helper
649
+ const baseSections = sectionSpans(parsed);
650
+ // Split base into lines for heading search
651
+ const baseLines = splitLines(baseText).lines;
652
+ // Find the nearest heading above a given position (line number, 1-based)
653
+ const nearestHeadingAbove = (pos) => {
654
+ for (let i = pos - 1; i >= 0; i--) {
655
+ const line = baseLines[i];
656
+ const match = HEADING_RE.exec(line);
657
+ if (match)
658
+ return match[1];
659
+ }
660
+ return null;
661
+ };
662
+ // Track used names for uniqueness
663
+ const usedNames = new Map(); // name → last hunk index that used it
664
+ // Also track what was declared
665
+ const declaredSet = new Set(declared);
666
+ // Process each hunk
667
+ for (let i = 0; i < hunks.length; i++) {
668
+ const h = hunks[i];
669
+ // Rule 1: Touches existing section → that section's exact name
670
+ let touchedSection = null;
671
+ for (const sec of baseSections) {
672
+ if (touches(h, sec)) {
673
+ touchedSection = sec.name;
674
+ break;
675
+ }
676
+ }
677
+ if (touchedSection !== null) {
678
+ result[i] = touchedSection;
679
+ continue;
680
+ }
681
+ // Rule 2: Block suggestion → slug from heading or section-<n>
682
+ if (h.suggestion.class === "block") {
683
+ // Find position for heading search
684
+ let searchPos;
685
+ if (h.a.lines.length > 0) {
686
+ // Above a.start means < a.start, so search starting from a.start - 1 (0-indexed: a.start - 2)
687
+ searchPos = h.a.start - 1; // This will search lines [0, a.start-2] in 0-indexed terms
688
+ }
689
+ else {
690
+ // At or above N where N = a.start - 1, means lines <= N, so search from N (0-indexed: N-1)
691
+ const N = h.a.start - 1;
692
+ searchPos = N; // This will search lines [0, N-1] in 0-indexed, i.e., lines 1..N in 1-indexed
693
+ }
694
+ const heading = nearestHeadingAbove(searchPos);
695
+ let candidate;
696
+ if (heading !== null) {
697
+ const slug = headingSlug(heading);
698
+ // Check if slug is valid (matches SECTION_NAME) and non-empty
699
+ if (slug.length > 0 && SECTION_NAME.test(slug)) {
700
+ candidate = slug;
701
+ }
702
+ else {
703
+ // Fallback: section-<n> with n = 1-based hunk number
704
+ candidate = `section-${i + 1}`;
705
+ }
706
+ }
707
+ else {
708
+ // Fallback: section-<n>
709
+ candidate = `section-${i + 1}`;
710
+ }
711
+ // Uniqueness check: consecutive block hunks with the same name keep it
712
+ const prevIndex = usedNames.get(candidate);
713
+ const isConsecutive = prevIndex !== undefined && prevIndex === i - 1;
714
+ if (isConsecutive) {
715
+ // Same name as directly preceding hunk, keep it
716
+ result[i] = candidate;
717
+ usedNames.set(candidate, i);
718
+ }
719
+ else if (declaredSet.has(candidate) || (prevIndex !== undefined && !isConsecutive)) {
720
+ // Need to find a unique suffix
721
+ let suffix = 2;
722
+ let uniqueName = `${candidate}-${suffix}`;
723
+ while (declaredSet.has(uniqueName) || usedNames.has(uniqueName)) {
724
+ suffix++;
725
+ uniqueName = `${candidate}-${suffix}`;
726
+ }
727
+ result[i] = uniqueName;
728
+ usedNames.set(uniqueName, i);
729
+ }
730
+ else {
731
+ // Name is free
732
+ result[i] = candidate;
733
+ usedNames.set(candidate, i);
734
+ }
735
+ }
736
+ // Rule 3: Otherwise → undefined (already the default)
737
+ }
738
+ return result;
739
+ }
740
+ //# sourceMappingURL=section-extract.js.map