@synmux/claude-commit 1.0.3 → 1.1.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/src/diff.ts DELETED
@@ -1,571 +0,0 @@
1
- /**
2
- * Splitting a unified git diff into chunks that fit a character budget, and
3
- * partitioning it by path priority.
4
- *
5
- * The packer is structure-aware: it prefers to break on file boundaries, then
6
- * on hunk (`@@`) boundaries, and only falls back to raw line splitting for a
7
- * single hunk that is itself larger than the budget. When a file section is
8
- * split, its header (`diff --git ... / --- / +++`) is repeated at the top of
9
- * every piece so each chunk remains a self-contained, interpretable diff.
10
- *
11
- * `applyIgnorePatterns` drops whole file sections outright (see `ignore` in
12
- * the config) and `partitionDiff` sorts what remains into a primary and a
13
- * low-priority diff (see `lowPriorityPaths`), both using the paths
14
- * `sectionPaths` recovers from each section's header lines.
15
- */
16
-
17
- import type { PathMatcher } from "./paths";
18
- import { estimateDiffTokens, isOpaqueLine } from "./tokens";
19
-
20
- const FILE_HEADER = "diff --git ";
21
- const HUNK_HEADER = "@@";
22
- const DEV_NULL = "/dev/null";
23
- const SOURCE_PREFIX = "a/";
24
- const DESTINATION_PREFIX = "b/";
25
-
26
- /**
27
- * Minimum per-piece character budget (after the repeated file header) below
28
- * which we stop trying to subdivide a section. This guards against a
29
- * misconfigured tiny `maxChars`, or a pathologically large file header,
30
- * driving the line-splitter's budget to zero and shattering a hunk into
31
- * one-character pieces - which would otherwise spawn a model request per
32
- * character.
33
- */
34
- const MIN_SPLIT_BUDGET = 64;
35
-
36
- /** Split a full diff into per-file sections. */
37
- function splitFileSections(diff: string): string[] {
38
- const lines = diff.split("\n");
39
- const sections: string[] = [];
40
- let current: string[] = [];
41
- for (const line of lines) {
42
- if (line.startsWith(FILE_HEADER) && current.length > 0) {
43
- sections.push(current.join("\n"));
44
- current = [line];
45
- } else {
46
- current.push(line);
47
- }
48
- }
49
- if (current.length > 0) sections.push(current.join("\n"));
50
- return sections;
51
- }
52
-
53
- /** Group the body of a file section (from the first `@@`) into hunks. */
54
- function groupHunks(bodyLines: string[]): string[] {
55
- const hunks: string[] = [];
56
- let current: string[] = [];
57
- for (const line of bodyLines) {
58
- if (line.startsWith(HUNK_HEADER) && current.length > 0) {
59
- hunks.push(current.join("\n"));
60
- current = [line];
61
- } else {
62
- current.push(line);
63
- }
64
- }
65
- if (current.length > 0) hunks.push(current.join("\n"));
66
- return hunks;
67
- }
68
-
69
- /** Break a string into pieces of at most `maxLen` characters, preferring line boundaries. */
70
- function breakByLines(text: string, maxLen: number): string[] {
71
- const limit = Math.max(1, maxLen);
72
- const lines = text.split("\n");
73
- const pieces: string[] = [];
74
- let current = "";
75
- for (const line of lines) {
76
- const addition = current === "" ? line.length : line.length + 1;
77
- if (current !== "" && current.length + addition > limit) {
78
- pieces.push(current);
79
- current = "";
80
- }
81
- if (line.length > limit) {
82
- // A single line longer than the budget: hard-split it.
83
- if (current !== "") {
84
- pieces.push(current);
85
- current = "";
86
- }
87
- for (let i = 0; i < line.length; i += limit) {
88
- pieces.push(line.slice(i, i + limit));
89
- }
90
- } else {
91
- current = current === "" ? line : current + "\n" + line;
92
- }
93
- }
94
- if (current !== "") pieces.push(current);
95
- return pieces;
96
- }
97
-
98
- /** Break an oversized file section into units that each fit `maxChars`. */
99
- function breakSection(section: string, maxChars: number): string[] {
100
- if (section.length <= maxChars) return [section];
101
-
102
- const lines = section.split("\n");
103
- const firstHunk = lines.findIndex((l) => l.startsWith(HUNK_HEADER));
104
- if (firstHunk === -1) {
105
- // No hunks to split on (binary patch, pure rename, mode change): keep whole.
106
- return [section];
107
- }
108
-
109
- const header = lines.slice(0, firstHunk).join("\n");
110
- const headerLen = header.length + 1; // account for the joining newline
111
-
112
- // If there isn't room for a meaningful piece after repeating the header,
113
- // keep the section whole rather than exploding it into tiny fragments.
114
- if (maxChars - headerLen < MIN_SPLIT_BUDGET) return [section];
115
-
116
- const hunks = groupHunks(lines.slice(firstHunk));
117
- const units: string[] = [];
118
-
119
- for (const hunk of hunks) {
120
- if (headerLen + hunk.length <= maxChars) {
121
- units.push(header + "\n" + hunk);
122
- } else {
123
- for (const piece of breakByLines(hunk, maxChars - headerLen)) {
124
- units.push(header + "\n" + piece);
125
- }
126
- }
127
- }
128
- return units;
129
- }
130
-
131
- /** Greedily pack pre-fitted units into as few chunks as possible. */
132
- function packUnits(units: string[], maxChars: number): string[] {
133
- const chunks: string[] = [];
134
- let current = "";
135
- for (const unit of units) {
136
- if (current === "") {
137
- current = unit;
138
- continue;
139
- }
140
- if (current.length + 1 + unit.length <= maxChars) {
141
- current = current + "\n" + unit;
142
- } else {
143
- chunks.push(current);
144
- current = unit;
145
- }
146
- }
147
- if (current !== "") chunks.push(current);
148
- return chunks;
149
- }
150
-
151
- /**
152
- * Split a unified diff into chunks no larger than `maxChars` characters.
153
- *
154
- * Returns an empty array for an empty diff, and a single-element array when the
155
- * whole diff already fits.
156
- */
157
- export function splitDiff(diff: string, maxChars: number): string[] {
158
- if (diff.trim() === "") return [];
159
- if (diff.length <= maxChars) return [diff];
160
-
161
- const units: string[] = [];
162
- for (const section of splitFileSections(diff)) {
163
- units.push(...breakSection(section, maxChars));
164
- }
165
- return packUnits(units, maxChars);
166
- }
167
-
168
- /** Character budget for `text` such that its classified token estimate fits `maxTokens`. */
169
- function charBudgetFor(
170
- text: string,
171
- maxTokens: number,
172
- charsPerToken: number,
173
- ): number {
174
- const density =
175
- text.length / Math.max(1, estimateDiffTokens(text, charsPerToken));
176
- return Math.max(1, Math.floor(maxTokens * density));
177
- }
178
-
179
- /**
180
- * Minimum consecutive opaque lines before a run is redacted. A lone long
181
- * unbroken line (a URL, a hash pin, a long path) can carry real meaning; an
182
- * armored blob never arrives alone.
183
- */
184
- const MIN_REDACT_RUN = 3;
185
-
186
- /**
187
- * Replace each run of opaque (armored/encoded) lines with a single marker
188
- * line. The marker keeps the surrounding diff structure interpretable and
189
- * tells the summary model what was elided, so it can still report that an
190
- * encrypted file changed - without paying ~1 token per character to send
191
- * ciphertext the model cannot read anyway.
192
- */
193
- export function redactOpaqueRuns(diff: string): string {
194
- const out: string[] = [];
195
- let run: string[] = [];
196
- const flush = () => {
197
- if (run.length >= MIN_REDACT_RUN) {
198
- out.push(`[cco: ${run.length} armored/encoded lines omitted]`);
199
- } else {
200
- out.push(...run);
201
- }
202
- run = [];
203
- };
204
- for (const line of diff.split("\n")) {
205
- if (isOpaqueLine(line)) {
206
- run.push(line);
207
- } else {
208
- flush();
209
- out.push(line);
210
- }
211
- }
212
- flush();
213
- return out.join("\n");
214
- }
215
-
216
- /**
217
- * The staged diff split by path priority. `primary` drives the commit
218
- * message; `lowPriority` holds the sections whose every path matched a
219
- * `lowPriorityPaths` pattern. Either may be `""`. The counts make the
220
- * matcher observable (`--verbose`): a pattern that matches nothing and one
221
- * that matches everything (and is therefore promoted) produce the same
222
- * message otherwise.
223
- */
224
- export interface DiffPartition {
225
- primary: string;
226
- lowPriority: string;
227
- /** File sections whose paths all matched. */
228
- matchedFiles: number;
229
- /** File sections with at least one recognisable path. */
230
- totalFiles: number;
231
- /** Every file matched, so the low-priority sections were promoted to primary. */
232
- promoted: boolean;
233
- }
234
-
235
- const textEncoder = new TextEncoder();
236
- const textDecoder = new TextDecoder();
237
-
238
- /** Single-character C escapes git emits inside a quoted path, mapped to their byte. */
239
- const SIMPLE_ESCAPES: Record<string, number> = {
240
- a: 0x07,
241
- b: 0x08,
242
- t: 0x09,
243
- n: 0x0a,
244
- v: 0x0b,
245
- f: 0x0c,
246
- r: 0x0d,
247
- "\\": 0x5c,
248
- '"': 0x22,
249
- };
250
-
251
- /**
252
- * Undo git's C-style path quoting (`core.quotepath`): a path containing
253
- * quotes, backslashes, control characters or - by default - any non-ASCII
254
- * byte is emitted as `"..."` with `\"`, `\\`, `\t`-style escapes and
255
- * `\NNN` octal escapes for raw bytes. Escapes are decoded to bytes and the
256
- * result is read back as UTF-8, so `"\303\274"` becomes `ü`. Unquoted
257
- * input is returned as-is.
258
- */
259
- function unquoteGitPath(raw: string): string {
260
- if (raw.length < 2 || !raw.startsWith('"') || !raw.endsWith('"')) {
261
- return raw;
262
- }
263
- const inner = raw.slice(1, -1);
264
- const bytes: number[] = [];
265
- let index = 0;
266
- while (index < inner.length) {
267
- if (inner[index] !== "\\") {
268
- // Copy one code point (possibly a surrogate pair) as UTF-8 bytes.
269
- const literal = String.fromCodePoint(inner.codePointAt(index)!);
270
- bytes.push(...textEncoder.encode(literal));
271
- index += literal.length;
272
- continue;
273
- }
274
- const octal = /^[0-7]{1,3}/.exec(inner.slice(index + 1, index + 4));
275
- if (octal) {
276
- bytes.push(parseInt(octal[0], 8) & 0xff);
277
- index += 1 + octal[0].length;
278
- continue;
279
- }
280
- const escaped = inner[index + 1];
281
- if (escaped === undefined) break; // dangling backslash: drop it
282
- const simple = SIMPLE_ESCAPES[escaped];
283
- if (simple !== undefined) {
284
- bytes.push(simple);
285
- } else {
286
- bytes.push(...textEncoder.encode(escaped)); // unknown escape: literal
287
- }
288
- index += 2;
289
- }
290
- return textDecoder.decode(Uint8Array.from(bytes));
291
- }
292
-
293
- /** Drop a `a/` or `b/` prefix when present (diffs made with `--no-prefix` lack it). */
294
- function stripDiffPrefix(path: string, prefix: string): string {
295
- return path.startsWith(prefix) ? path.slice(prefix.length) : path;
296
- }
297
-
298
- /**
299
- * The path named by a `--- `/`+++ ` marker line, or `null` for `/dev/null`.
300
- * Git appends a tab after an unquoted path that contains spaces, so that is
301
- * removed before unquoting.
302
- */
303
- function pathFromMarkerLine(rest: string, prefix: string): string | null {
304
- const unquoted = unquoteGitPath(
305
- rest.endsWith("\t") ? rest.slice(0, -1) : rest,
306
- );
307
- if (unquoted === DEV_NULL) return null;
308
- return stripDiffPrefix(unquoted, prefix);
309
- }
310
-
311
- /**
312
- * Read a quoted token starting at `start` (which must be a `"`), returning
313
- * the token including its quotes and the index just past it, or `null` if
314
- * the closing quote is missing.
315
- */
316
- function readQuotedToken(
317
- text: string,
318
- start: number,
319
- ): { token: string; end: number } | null {
320
- let index = start + 1;
321
- while (index < text.length) {
322
- if (text[index] === "\\") {
323
- index += 2;
324
- continue;
325
- }
326
- if (text[index] === '"') {
327
- return { token: text.slice(start, index + 1), end: index + 1 };
328
- }
329
- index += 1;
330
- }
331
- return null;
332
- }
333
-
334
- /**
335
- * The source and destination paths of a `diff --git a/X b/Y` header line,
336
- * for sections that carry no `---`/`+++`/`rename`/`copy` lines (binary
337
- * patches, mode-only changes). Git separates the two paths with a single
338
- * space and quotes only the ones that need it, so an unquoted path
339
- * containing a space - or even ` b/` - is ambiguous; for such sections git
340
- * always writes the same path twice, so the split is validated by checking
341
- * that both halves agree, falling back to the last ` b/` for a rename-style
342
- * header.
343
- */
344
- function pathsFromHeader(headerLine: string): string[] {
345
- const rest = headerLine.slice(FILE_HEADER.length);
346
- let source: string | undefined;
347
- let destination: string | undefined;
348
-
349
- if (rest.startsWith('"')) {
350
- const first = readQuotedToken(rest, 0);
351
- if (first) {
352
- source = unquoteGitPath(first.token);
353
- const remainder = rest.slice(first.end).replace(/^ /, "");
354
- destination = unquoteGitPath(remainder);
355
- }
356
- } else if (rest.endsWith('"')) {
357
- const quoteStart = rest.indexOf(' "');
358
- if (quoteStart !== -1) {
359
- source = rest.slice(0, quoteStart);
360
- destination = unquoteGitPath(rest.slice(quoteStart + 1));
361
- }
362
- } else if (rest.length % 2 === 1) {
363
- const half = (rest.length - 1) / 2;
364
- const left = rest.slice(0, half);
365
- const right = rest.slice(half + 1);
366
- if (
367
- rest[half] === " " &&
368
- stripDiffPrefix(left, SOURCE_PREFIX) ===
369
- stripDiffPrefix(right, DESTINATION_PREFIX)
370
- ) {
371
- source = left;
372
- destination = right;
373
- }
374
- }
375
-
376
- if (source === undefined || destination === undefined) {
377
- const split = rest.lastIndexOf(` ${DESTINATION_PREFIX}`);
378
- if (split === -1) return [];
379
- source = rest.slice(0, split);
380
- destination = rest.slice(split + 1);
381
- }
382
-
383
- const paths = [
384
- stripDiffPrefix(source, SOURCE_PREFIX),
385
- stripDiffPrefix(destination, DESTINATION_PREFIX),
386
- ].filter((path) => path !== "");
387
- return paths.filter((path, index) => paths.indexOf(path) === index);
388
- }
389
-
390
- /**
391
- * The repository-relative paths a file section touches: one for an ordinary
392
- * change, two for a rename or copy. Paths are read from the `---`/`+++`
393
- * marker lines and `rename`/`copy from`/`to` lines in the section header
394
- * (before the first hunk, so a removed line that happens to start with
395
- * `-- ` is never mistaken for a marker), falling back to the
396
- * `diff --git a/X b/Y` line for sections that have none. Returns `[]` for
397
- * content that is not a file section at all.
398
- */
399
- export function sectionPaths(section: string): string[] {
400
- const lines = section.split("\n");
401
- const firstHunk = lines.findIndex((line) => line.startsWith(HUNK_HEADER));
402
- const headerLines = firstHunk === -1 ? lines : lines.slice(0, firstHunk);
403
-
404
- const paths: string[] = [];
405
- const add = (path: string | null) => {
406
- if (path !== null && path !== "" && !paths.includes(path)) {
407
- paths.push(path);
408
- }
409
- };
410
- for (const line of headerLines) {
411
- if (line.startsWith("--- ")) {
412
- add(pathFromMarkerLine(line.slice(4), SOURCE_PREFIX));
413
- } else if (line.startsWith("+++ ")) {
414
- add(pathFromMarkerLine(line.slice(4), DESTINATION_PREFIX));
415
- } else if (line.startsWith("rename from ")) {
416
- add(unquoteGitPath(line.slice("rename from ".length)));
417
- } else if (line.startsWith("rename to ")) {
418
- add(unquoteGitPath(line.slice("rename to ".length)));
419
- } else if (line.startsWith("copy from ")) {
420
- add(unquoteGitPath(line.slice("copy from ".length)));
421
- } else if (line.startsWith("copy to ")) {
422
- add(unquoteGitPath(line.slice("copy to ".length)));
423
- }
424
- }
425
- if (paths.length > 0) return paths;
426
-
427
- const header = lines[0];
428
- return header !== undefined && header.startsWith(FILE_HEADER)
429
- ? pathsFromHeader(header)
430
- : [];
431
- }
432
-
433
- /**
434
- * Sort a diff's file sections into a primary and a low-priority diff.
435
- *
436
- * A section is low priority only when it names at least one path and every
437
- * path it names matches - so a rename into or out of a low-priority area
438
- * stays primary, as does anything whose path cannot be recognised. Sections
439
- * keep their original order within each partition and are joined back with
440
- * newlines, so each partition is itself a valid unified diff.
441
- *
442
- * When nothing is primary, the low-priority sections are promoted: with no
443
- * other change to yield to, they *are* the change and should be described
444
- * in full, exactly as if no patterns were configured.
445
- */
446
- export function partitionDiff(
447
- diff: string,
448
- isLowPriority: PathMatcher,
449
- ): DiffPartition {
450
- const empty: DiffPartition = {
451
- primary: "",
452
- lowPriority: "",
453
- matchedFiles: 0,
454
- totalFiles: 0,
455
- promoted: false,
456
- };
457
- if (diff === "") return empty;
458
-
459
- const primary: string[] = [];
460
- const lowPriority: string[] = [];
461
- let totalFiles = 0;
462
- for (const section of splitFileSections(diff)) {
463
- const paths = sectionPaths(section);
464
- if (paths.length > 0) totalFiles += 1;
465
- const deprioritised = paths.length > 0 && paths.every(isLowPriority);
466
- (deprioritised ? lowPriority : primary).push(section);
467
- }
468
- const matchedFiles = lowPriority.length;
469
-
470
- if (primary.length === 0) {
471
- return {
472
- ...empty,
473
- primary: lowPriority.join("\n"),
474
- matchedFiles,
475
- totalFiles,
476
- promoted: matchedFiles > 0,
477
- };
478
- }
479
- return {
480
- primary: primary.join("\n"),
481
- lowPriority: lowPriority.join("\n"),
482
- matchedFiles,
483
- totalFiles,
484
- promoted: false,
485
- };
486
- }
487
-
488
- /** What `ignore` removed from a diff. */
489
- export interface IgnoreResult {
490
- /** The diff with every ignored file section removed. May be `""`. */
491
- diff: string;
492
- /** File sections dropped because all of their paths matched. */
493
- ignoredFiles: number;
494
- /** File sections in the original diff with at least one recognisable path. */
495
- totalFiles: number;
496
- }
497
-
498
- /**
499
- * Drop the file sections whose every path matches, returning the rest.
500
- *
501
- * The rule is `partitionDiff`'s: a section is removed only when it names at
502
- * least one path and all of them match, so a rename out of an ignored
503
- * directory - which is news - survives, as does anything whose path cannot
504
- * be read. Surviving sections keep their order and are joined with
505
- * newlines, so the result is itself a valid unified diff.
506
- *
507
- * This runs before everything else in the pipeline, so ignored content is
508
- * never chunked, never sent, and never paid for.
509
- */
510
- export function applyIgnorePatterns(
511
- diff: string,
512
- isIgnored: PathMatcher,
513
- ): IgnoreResult {
514
- if (diff === "") return { diff: "", ignoredFiles: 0, totalFiles: 0 };
515
-
516
- const kept: string[] = [];
517
- let ignoredFiles = 0;
518
- let totalFiles = 0;
519
- for (const section of splitFileSections(diff)) {
520
- const paths = sectionPaths(section);
521
- if (paths.length > 0) totalFiles += 1;
522
- if (paths.length > 0 && paths.every(isIgnored)) {
523
- ignoredFiles += 1;
524
- continue;
525
- }
526
- kept.push(section);
527
- }
528
- return { diff: kept.join("\n"), ignoredFiles, totalFiles };
529
- }
530
-
531
- /**
532
- * Split a diff so that every chunk's *classified token estimate* fits
533
- * `maxTokens`.
534
- *
535
- * `splitDiff` budgets in characters, but token density varies wildly by
536
- * content: prose and code sit near the configured `charsPerToken` (~3.5)
537
- * while base64/armor lines measure near 1 char/token. Sizing every chunk
538
- * with one blended ratio lets an armor-heavy region overflow, so after an
539
- * initial blended-density split, any chunk still over budget is re-split
540
- * using its own (denser) ratio until everything fits or no further split is
541
- * possible. An unsplittable oversized chunk is kept - the overflow retry in
542
- * the generation pipeline is the backstop for that case.
543
- */
544
- export function splitDiffToFit(
545
- diff: string,
546
- maxTokens: number,
547
- charsPerToken: number,
548
- ): string[] {
549
- const queue = splitDiff(diff, charBudgetFor(diff, maxTokens, charsPerToken));
550
- const fitted: string[] = [];
551
- while (queue.length > 0) {
552
- const chunk = queue.shift()!;
553
- if (estimateDiffTokens(chunk, charsPerToken) <= maxTokens) {
554
- fitted.push(chunk);
555
- continue;
556
- }
557
- // Over budget: the chunk's own density is at least as dense as the
558
- // blend it was sized with, so this budget is strictly smaller than the
559
- // chunk - splitDiff will attempt a real split.
560
- const pieces = splitDiff(
561
- chunk,
562
- charBudgetFor(chunk, maxTokens, charsPerToken),
563
- );
564
- if (pieces.length <= 1) {
565
- fitted.push(chunk);
566
- continue;
567
- }
568
- queue.unshift(...pieces);
569
- }
570
- return fitted;
571
- }