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.
- package/README.md +9 -3
- package/dist/cli.js +52 -9
- package/dist/cli.js.map +1 -1
- package/dist/core/manifest-edit.js +40 -0
- package/dist/core/manifest-edit.js.map +1 -0
- package/dist/core/param-writes.js +107 -12
- package/dist/core/param-writes.js.map +1 -1
- package/dist/core/section-extract.js +740 -0
- package/dist/core/section-extract.js.map +1 -0
- package/dist/core/unify.js +270 -21
- package/dist/core/unify.js.map +1 -1
- package/dist/importers/claude-code.js +4 -24
- package/dist/importers/claude-code.js.map +1 -1
- package/dist/schema/index.js +12 -2
- package/dist/schema/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|