turntax 0.3.0 → 0.5.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,442 @@
1
+ import { sectionScope } from './advice.js';
2
+ import { classify } from './classify.js';
3
+ import { isInstructionFile } from './files.js';
4
+ import { basename, findImports, linesWithFences, parseFrontmatter, splitSections } from './markdown.js';
5
+ import { countO200k } from './tokenize.js';
6
+ import { TOOLS } from './tools.js';
7
+ const RANK = { ignored: 0, manual: 1, 'on-demand': 2, always: 3 };
8
+ const toolName = (id) => TOOLS.find((t) => t.id === id)?.name ?? id;
9
+ const names = (ids) => [...ids].map(toolName).join(', ');
10
+ /** Text with LF line endings, plus the line ending to write back. */
11
+ function lf(text) {
12
+ return { text: text.replace(/\r\n/g, '\n'), eol: text.includes('\r\n') ? '\r\n' : '\n' };
13
+ }
14
+ function contentUnits(units) {
15
+ return units.filter((u) => u.part !== 'description' && u.mode !== 'ignored');
16
+ }
17
+ /**
18
+ * A line as compared by the fixes: only list markers and whitespace are ignored. Case, underscores,
19
+ * asterisks and backticks can carry meaning (`create_user`, globs), so lines that differ there differ.
20
+ */
21
+ function lineKey(line) {
22
+ return line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').replace(/\s+/g, ' ').trim();
23
+ }
24
+ /** Prose lines long enough to be meaningful (outside fenced code, not headings), by key. */
25
+ function proseLines(text) {
26
+ const out = new Map();
27
+ for (const { line, inFence } of linesWithFences(text)) {
28
+ const key = lineKey(line);
29
+ if (!inFence && key.length >= 30 && !key.startsWith('#'))
30
+ out.set(key, line);
31
+ }
32
+ return out;
33
+ }
34
+ function keysOf(text) {
35
+ return text
36
+ .split('\n')
37
+ .map(lineKey)
38
+ .filter((k) => k.replace(/[#\s]/g, '').length > 0);
39
+ }
40
+ function loadedLines(units) {
41
+ const out = new Map();
42
+ for (const u of contentUnits(units)) {
43
+ const lines = out.get(u.tool) ?? new Map();
44
+ out.set(u.tool, lines);
45
+ for (const key of keysOf(u.text))
46
+ lines.set(key, Math.max(lines.get(key) ?? 0, RANK[u.mode]));
47
+ }
48
+ return out;
49
+ }
50
+ /**
51
+ * Agents that would lose something: every line an agent read before must still be read at least as
52
+ * often, except the `edited` lines, which need only `need(before)`.
53
+ */
54
+ function check(before, after, edited, need) {
55
+ const lost = [];
56
+ for (const [tool, lines] of before) {
57
+ const now = after.get(tool);
58
+ for (const [key, rank] of lines) {
59
+ if ((now?.get(key) ?? 0) < (edited.has(key) ? need(rank) : rank)) {
60
+ lost.push(tool);
61
+ break;
62
+ }
63
+ }
64
+ }
65
+ return lost;
66
+ }
67
+ class Workspace {
68
+ tools;
69
+ state;
70
+ constructor(input, tools) {
71
+ this.tools = tools;
72
+ this.state = this.build(new Map(input.map((f) => [f.path, f])));
73
+ }
74
+ build(files) {
75
+ const units = classify([...files.values()], this.tools);
76
+ return { files, units, loaded: loadedLines(units) };
77
+ }
78
+ get files() {
79
+ return this.state.files;
80
+ }
81
+ get units() {
82
+ return this.state.units;
83
+ }
84
+ /** The state with `edits` applied (null deletes a file). */
85
+ trial(edits) {
86
+ const files = new Map(this.state.files);
87
+ for (const [path, text] of edits) {
88
+ if (text === null)
89
+ files.delete(path);
90
+ else
91
+ files.set(path, { path, text });
92
+ }
93
+ return this.build(files);
94
+ }
95
+ check(next, edited, need) {
96
+ return check(this.state.loaded, next.loaded, new Set(edited), need);
97
+ }
98
+ commit(next) {
99
+ this.state = next;
100
+ }
101
+ }
102
+ export function planFixes(input, opts = {}) {
103
+ // Only repository files are ever changed; user-level files (~/...) are not passed in.
104
+ const ws = new Workspace(input.filter((f) => !f.path.startsWith('~')), opts.tools);
105
+ // Files reached only through @imports (a README, docs) are for people too; they are never changed.
106
+ const readOnly = (path) => {
107
+ if (!isInstructionFile(path))
108
+ return `${path} is not an agent instruction file (it is @imported); it is left alone.`;
109
+ const reason = opts.readOnly?.(path);
110
+ return typeof reason === 'string' ? reason : reason ? `${path} is a symlink, in a symlinked folder or the target of a symlink; it is left alone.` : null;
111
+ };
112
+ const original = new Map(ws.files);
113
+ const renames = new Map();
114
+ const fixes = [];
115
+ const skipped = [];
116
+ if (!opts.tools || opts.tools.includes('cursor'))
117
+ renameCursorRules(ws, readOnly, renames, fixes, skipped);
118
+ // Moves run before duplicate removal: a move may relax how often lines load, so a line must not be
119
+ // removed elsewhere on the strength of a copy that is moved later.
120
+ moveScopableSections(ws, opts, readOnly, fixes, skipped);
121
+ removeDuplicates(ws, readOnly, fixes, skipped);
122
+ const renamedTo = new Map([...renames].map(([from, to]) => [to, from]));
123
+ const changes = [];
124
+ for (const [path, file] of ws.files) {
125
+ const from = renamedTo.get(path);
126
+ const before = original.get(from ?? path)?.text ?? null;
127
+ if (from)
128
+ changes.push({ path, before, after: file.text, renamedFrom: from });
129
+ else if (before !== file.text)
130
+ changes.push({ path, before, after: file.text });
131
+ }
132
+ changes.sort((a, b) => a.path.localeCompare(b.path));
133
+ return { fixes, skipped, changes };
134
+ }
135
+ /** `.md` files in `.cursor/rules` are ignored by Cursor; `.mdc` is the documented extension. */
136
+ function renameCursorRules(ws, readOnly, renames, fixes, skipped) {
137
+ for (const f of [...ws.files.values()]) {
138
+ if (!/^(?:.*\/)?\.cursor\/rules\/.+\.md$/.test(f.path))
139
+ continue;
140
+ const to = f.path.replace(/\.md$/, '.mdc');
141
+ const blocked = readOnly(f.path) ?? (ws.files.has(to) ? `${to} already exists.` : readOnly(to));
142
+ if (blocked) {
143
+ skipped.push({ kind: 'misconfigured', paths: [f.path], reason: blocked });
144
+ continue;
145
+ }
146
+ const next = ws.trial(new Map([[f.path, null], [to, f.text]]));
147
+ const lost = ws.check(next, [], (r) => r);
148
+ if (lost.length) {
149
+ skipped.push({ kind: 'misconfigured', paths: [f.path], reason: `${names(lost)} would stop reading it.` });
150
+ continue;
151
+ }
152
+ const mode = next.units.find((u) => u.tool === 'cursor' && u.path === to && u.part !== 'description')?.mode;
153
+ ws.commit(next);
154
+ renames.set(f.path, to);
155
+ fixes.push({
156
+ kind: 'misconfigured',
157
+ paths: [f.path, to],
158
+ message: `Renamed ${f.path} to ${basename(to)} so Cursor reads it` +
159
+ (mode === 'manual' ? '; without frontmatter it applies only when @-mentioned (add alwaysApply, globs or description).' : '.'),
160
+ savingTokens: null,
161
+ });
162
+ }
163
+ }
164
+ /** Indexes of body lines (after any frontmatter) outside fenced code. */
165
+ function bodyLines(text) {
166
+ const start = text.split('\n').length - parseFrontmatter(text).body.split('\n').length;
167
+ return linesWithFences(text)
168
+ .map(({ line, inFence }, index) => ({ line, inFence, index }))
169
+ .filter((l) => !l.inFence && l.index >= start);
170
+ }
171
+ /** Drops the given line indexes; a blank line left next to another blank line where a line was removed goes too. */
172
+ function dropLines(lines, drop) {
173
+ const kept = [];
174
+ let removed = false;
175
+ lines.forEach((line, i) => {
176
+ if (drop.has(i)) {
177
+ removed = true;
178
+ return;
179
+ }
180
+ if (removed && line.trim() === '' && (kept.length === 0 || kept[kept.length - 1].trim() === ''))
181
+ return;
182
+ removed = false;
183
+ kept.push(line);
184
+ });
185
+ return kept;
186
+ }
187
+ /**
188
+ * Removes a line from a file when every agent that reads the file also reads the same line every turn
189
+ * from another file. Files read by fewer agents are trimmed first, so the broadest copy stays.
190
+ */
191
+ function removeDuplicates(ws, readOnly, fixes, skipped) {
192
+ const readers = (path) => new Set(contentUnits(ws.units).filter((u) => u.path === path).map((u) => u.tool));
193
+ const order = [...ws.files.keys()].sort((a, b) => readers(a).size - readers(b).size || b.localeCompare(a));
194
+ for (const path of order) {
195
+ const tools = readers(path);
196
+ // Skill, command and subagent bodies are procedures or separate prompts; they are left whole.
197
+ if (!tools.size || ws.units.some((u) => u.path === path && u.part === 'body'))
198
+ continue;
199
+ const { text, eol } = lf(ws.files.get(path).text);
200
+ const elsewhere = (key) => contentUnits(ws.units).filter((u) => u.path !== path && u.mode === 'always' && proseLines(u.text).has(key));
201
+ // Lines this file shares with another file that some agent reads every turn.
202
+ const shared = new Map();
203
+ const blockers = new Set();
204
+ const removable = new Set();
205
+ for (const [key] of proseLines(text)) {
206
+ const others = elsewhere(key);
207
+ if (!others.length)
208
+ continue;
209
+ shared.set(key, [...new Set(others.map((u) => u.path))]);
210
+ const missing = [...tools].filter((t) => !others.some((u) => u.tool === t));
211
+ missing.forEach((t) => blockers.add(t));
212
+ if (!missing.length)
213
+ removable.add(key);
214
+ }
215
+ if (!shared.size)
216
+ continue;
217
+ const otherPaths = [...new Set([...shared.values()].flat())];
218
+ if (readOnly(path)) {
219
+ skipped.push({ kind: 'duplicate', paths: [path, ...otherPaths], reason: readOnly(path) });
220
+ continue;
221
+ }
222
+ if (removable.size) {
223
+ const lines = text.split('\n');
224
+ const drop = new Set(bodyLines(text).filter((l) => removable.has(lineKey(l.line))).map((l) => l.index));
225
+ const removed = [...drop].map((i) => lines[i]);
226
+ const next = ws.trial(new Map([[path, dropLines(lines, drop).join('\n').replace(/\n/g, eol)]]));
227
+ const lost = ws.check(next, removable, (r) => r);
228
+ if (lost.length) {
229
+ skipped.push({ kind: 'duplicate', paths: [path, ...otherPaths], reason: `Kept ${removable.size} line(s) in ${path}: ${names(lost)} would lose context without them (for example a file they @import).` });
230
+ }
231
+ else {
232
+ const everyTurn = contentUnits(ws.units).some((u) => u.path === path && u.mode === 'always');
233
+ const from = [...new Set([...removable].flatMap((k) => shared.get(k)))];
234
+ ws.commit(next);
235
+ fixes.push({
236
+ kind: 'duplicate',
237
+ paths: [path, ...from],
238
+ message: `Removed ${removed.length} line(s) from ${path} that ${names(tools)} already read every turn from ${from.join(', ')}.`,
239
+ savingTokens: everyTurn ? countO200k(removed.join('\n')) : 0,
240
+ });
241
+ }
242
+ }
243
+ const kept = shared.size - removable.size;
244
+ if (kept && blockers.size)
245
+ skipped.push({
246
+ kind: 'duplicate',
247
+ paths: [path, ...otherPaths],
248
+ reason: `Kept ${kept} line(s) in ${path} that are also in ${otherPaths.join(', ')}: ${names(blockers)} would lose them.`,
249
+ });
250
+ }
251
+ }
252
+ /** Where a section about `scope` can live so it loads only there: the same file name nested, AGENTS.md, or a Claude rule. */
253
+ function targets(source, scope, heading) {
254
+ const name = basename(source);
255
+ const slug = heading.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'section';
256
+ const out = [];
257
+ if (['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'].includes(name))
258
+ out.push({ path: `${scope}/${name}`, header: '' });
259
+ if (name !== 'AGENTS.md')
260
+ out.push({ path: `${scope}/AGENTS.md`, header: '' });
261
+ out.push({ path: `.claude/rules/${slug}.md`, header: `---\npaths:\n - "${scope}/**"\n---\n\n` });
262
+ return out;
263
+ }
264
+ const headingLevel = (line) => /^\s{0,3}(#{1,6})\s/.exec(line)?.[1].length ?? 0;
265
+ /** Level 2 and 3 sections of the LF text, each running to the next heading of the same or a higher level. */
266
+ function movableSections(text) {
267
+ const lines = text.split('\n');
268
+ const all = splitSections(text).filter((s) => s.heading !== '(top)');
269
+ return all
270
+ .map((s) => ({ s, level: headingLevel(lines[s.line - 1]) }))
271
+ .filter((x) => x.level >= 2)
272
+ .map(({ s, level }) => {
273
+ const next = all.find((o) => o.line > s.line && headingLevel(lines[o.line - 1]) <= level);
274
+ const end = next ? next.line - 1 : lines.length;
275
+ return { heading: s.heading, start: s.line - 1, end, body: lines.slice(s.line - 1, end).join('\n').replace(/\n+$/, '') };
276
+ });
277
+ }
278
+ function moveScopableSections(ws, opts, readOnly, fixes, skipped) {
279
+ const sources = [...new Set(ws.units.filter((u) => u.mode === 'always' && u.part === 'full').map((u) => u.path))].sort();
280
+ const tried = new Set();
281
+ for (const path of sources) {
282
+ // Sections are found in the raw file so that comments and formatting move as they are.
283
+ for (;;) {
284
+ const { text, eol } = lf(ws.files.get(path).text);
285
+ const next = movableSections(text)
286
+ .map((x) => ({ ...x, scope: sectionScope(x.body) }))
287
+ .find((x) => x.scope && !tried.has(`${path}\0${x.body}`));
288
+ if (!next)
289
+ break;
290
+ tried.add(`${path}\0${next.body}`);
291
+ const { heading, body, start, end } = next;
292
+ const scope = next.scope;
293
+ const reason = readOnly(path) ?? moveBlocker(body, scope, opts);
294
+ if (reason) {
295
+ skipped.push({ kind: 'scopable-section', paths: [path], reason: `Section "${heading}": ${reason}` });
296
+ continue;
297
+ }
298
+ const lines = text.split('\n');
299
+ const head = lines.slice(0, start);
300
+ const tail = lines.slice(end);
301
+ while (head.length && tail.length && head[head.length - 1].trim() === '' && tail[0].trim() === '')
302
+ tail.shift();
303
+ const rest = [...head, ...tail].join('\n').replace(/\n*$/, '\n');
304
+ let moved = false;
305
+ const refused = [];
306
+ for (const t of targets(path, scope, heading)) {
307
+ const existing = ws.files.get(t.path);
308
+ if (t.path === path || readOnly(t.path) || (existing && t.header))
309
+ continue;
310
+ const target = existing ? lf(existing.text) : { text: '', eol };
311
+ const targetText = existing ? `${target.text.replace(/\n+$/, '')}\n\n${body}\n` : `${t.header}${body}\n`;
312
+ const trial = ws.trial(new Map([
313
+ [path, rest.replace(/\n/g, eol)],
314
+ [t.path, targetText.replace(/\n/g, target.eol)],
315
+ ]));
316
+ // Agents that loaded the section every turn may now load it only when working in that folder.
317
+ const lost = ws.check(trial, keysOf(body), (r) => Math.min(r, RANK['on-demand']));
318
+ if (lost.length) {
319
+ refused.push(`${t.path} (${names(lost)} would lose context)`);
320
+ continue;
321
+ }
322
+ const readers = [...new Set(ws.units.filter((u) => u.path === path && u.mode === 'always' && u.part !== 'description').map((u) => u.tool))];
323
+ const reads = (l, tool) => keysOf(body).some((k) => l.get(tool)?.has(k));
324
+ const added = [...trial.loaded.keys()].filter((tool) => !reads(ws.state.loaded, tool) && reads(trial.loaded, tool));
325
+ const dropped = opts.tools ? leftOut(ws.files, trial.files, body, opts.tools) : [];
326
+ ws.commit(trial);
327
+ fixes.push({
328
+ kind: 'scopable-section',
329
+ paths: [path, t.path],
330
+ message: `Moved section "${heading}" from ${path} to ${t.path}${existing ? '' : ' (new file)'}; ${names(readers)} load it only when working in ${scope}/.` +
331
+ (added.length ? ` Now also read there by ${names(added)}.` : '') +
332
+ (dropped.length ? ` Not kept for ${names(dropped)} (outside --tools).` : ''),
333
+ savingTokens: countO200k(body),
334
+ });
335
+ moved = true;
336
+ break;
337
+ }
338
+ if (!moved)
339
+ skipped.push({
340
+ kind: 'scopable-section',
341
+ paths: [path],
342
+ reason: `Section "${heading}": no target keeps every agent's context: ${refused.join('; ')}. Use --tools to choose the agents to keep it for.`,
343
+ });
344
+ }
345
+ }
346
+ }
347
+ /** Agents outside `tools` that read some of `body` before the move and none of those lines after it. */
348
+ function leftOut(before, after, body, tools) {
349
+ const others = TOOLS.map((t) => t.id).filter((id) => !tools.includes(id));
350
+ const was = loadedLines(classify([...before.values()], others));
351
+ const now = loadedLines(classify([...after.values()], others));
352
+ return others.filter((tool) => keysOf(body).some((k) => was.get(tool)?.has(k) && !now.get(tool)?.has(k)));
353
+ }
354
+ function moveBlocker(body, scope, opts) {
355
+ if (scope.split('/').some((seg) => seg === '' || seg === '.' || seg === '..'))
356
+ return `unusual folder ${scope}.`;
357
+ if (opts.exists && !opts.exists(scope))
358
+ return `folder ${scope}/ does not exist.`;
359
+ if (findImports(body).length)
360
+ return 'it has @imports, which resolve relative to the file they are in.';
361
+ if (/\]\((?!https?:|#|\/|mailto:)[^)]+\)/.test(body))
362
+ return 'it has relative links, which resolve relative to the file they are in.';
363
+ return null;
364
+ }
365
+ /** Unified diff of the planned changes, in the format `git apply` accepts. */
366
+ export function unifiedDiff(changes) {
367
+ let out = '';
368
+ for (const c of changes) {
369
+ const from = c.renamedFrom ?? c.path;
370
+ out += `diff --git a/${from} b/${c.path}\n`;
371
+ if (c.renamedFrom) {
372
+ if (c.before === c.after)
373
+ out += 'similarity index 100%\n';
374
+ out += `rename from ${c.renamedFrom}\nrename to ${c.path}\n`;
375
+ if (c.before === c.after)
376
+ continue;
377
+ }
378
+ if (c.before === null)
379
+ out += 'new file mode 100644\n';
380
+ out += `--- ${c.before === null ? '/dev/null' : `a/${from}`}\n+++ b/${c.path}\n`;
381
+ out += hunks(c.before ?? '', c.after);
382
+ }
383
+ return out;
384
+ }
385
+ function split(text) {
386
+ if (text === '')
387
+ return [];
388
+ const lines = text.split('\n');
389
+ const noEol = lines[lines.length - 1] !== '';
390
+ if (!noEol)
391
+ lines.pop();
392
+ return lines.map((t, i) => ({ text: t, noEol: noEol && i === lines.length - 1 }));
393
+ }
394
+ /** Line diff by longest common subsequence; instruction files are small. */
395
+ function diffLines(a, b) {
396
+ const n = a.length;
397
+ const m = b.length;
398
+ const same = (i, j) => a[i].text === b[j].text && a[i].noEol === b[j].noEol;
399
+ if (n * m > 4_000_000)
400
+ return [...a.map((l) => ({ op: '-', ...l })), ...b.map((l) => ({ op: '+', ...l }))];
401
+ const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
402
+ for (let i = n - 1; i >= 0; i--)
403
+ for (let j = m - 1; j >= 0; j--)
404
+ lcs[i][j] = same(i, j) ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
405
+ const out = [];
406
+ let i = 0;
407
+ let j = 0;
408
+ while (i < n || j < m) {
409
+ if (i < n && j < m && same(i, j)) {
410
+ out.push({ op: ' ', ...a[i++] });
411
+ j++;
412
+ }
413
+ else if (i < n && (j === m || lcs[i + 1][j] >= lcs[i][j + 1]))
414
+ out.push({ op: '-', ...a[i++] });
415
+ else
416
+ out.push({ op: '+', ...b[j++] });
417
+ }
418
+ return out;
419
+ }
420
+ function hunks(before, after, context = 3) {
421
+ const lines = diffLines(split(before), split(after));
422
+ const changed = lines.map((l, i) => (l.op === ' ' ? -1 : i)).filter((i) => i >= 0);
423
+ let out = '';
424
+ let k = 0;
425
+ while (k < changed.length) {
426
+ const start = Math.max(0, changed[k] - context);
427
+ let end = Math.min(lines.length, changed[k] + context + 1);
428
+ while (k + 1 < changed.length && changed[k + 1] - context <= end)
429
+ end = Math.min(lines.length, changed[++k] + context + 1);
430
+ k++;
431
+ const slice = lines.slice(start, end);
432
+ const prefix = lines.slice(0, start);
433
+ const oldStart = prefix.filter((l) => l.op !== '+').length;
434
+ const newStart = prefix.filter((l) => l.op !== '-').length;
435
+ const oldLen = slice.filter((l) => l.op !== '+').length;
436
+ const newLen = slice.filter((l) => l.op !== '-').length;
437
+ out += `@@ -${oldLen ? oldStart + 1 : oldStart},${oldLen} +${newLen ? newStart + 1 : newStart},${newLen} @@\n`;
438
+ for (const l of slice)
439
+ out += `${l.op}${l.text}\n${l.noEol ? '\\n' : ''}`;
440
+ }
441
+ return out;
442
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Repository-relative globs: `*` and `?` match within one path segment, `**` matches any number of
3
+ * segments, and a trailing `/` matches everything in that folder. Patterns match the whole path.
4
+ */
5
+ export function globToRegExp(glob) {
6
+ let g = glob.replace(/\\/g, '/').replace(/^\.?\//, '');
7
+ if (g.endsWith('/'))
8
+ g += '**';
9
+ let re = '';
10
+ for (let i = 0; i < g.length; i++) {
11
+ const c = g[i];
12
+ const segmentStart = i === 0 || g[i - 1] === '/';
13
+ if (c === '*' && g[i + 1] === '*' && segmentStart && (g[i + 2] === '/' || i + 2 === g.length)) {
14
+ // `**/` matches zero or more folders; a final `**` matches the rest of the path.
15
+ re += g[i + 2] === '/' ? '(?:[^/]+/)*' : '.*';
16
+ i += 2;
17
+ }
18
+ else if (c === '*') {
19
+ while (g[i + 1] === '*')
20
+ i++;
21
+ re += '[^/]*';
22
+ }
23
+ else if (c === '?')
24
+ re += '[^/]';
25
+ else
26
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
27
+ }
28
+ return new RegExp(`^${re}$`);
29
+ }
30
+ /** A test for paths that match any of the globs. */
31
+ export function globMatcher(globs) {
32
+ const res = globs.map(globToRegExp);
33
+ return (path) => res.some((re) => re.test(path));
34
+ }
@@ -1,3 +1,4 @@
1
+ import { unifiedDiff } from './fix.js';
1
2
  import { DEFERRED_NOTE } from './mcp.js';
2
3
  export const METHOD_LABEL = {
3
4
  'offline-exact': 'exact (offline tokenizer)',
@@ -330,15 +331,71 @@ export function badgeMarkdown(report) {
330
331
  }
331
332
  export const PR_COMMENT_MARKER = '<!-- turntax-report -->';
332
333
  const COMMENT_LIMIT = 60_000;
334
+ /** Most of the comment the fix diff may take; the full report gets the rest. */
335
+ const FIX_DIFF_LIMIT = 20_000;
333
336
  /**
334
- * A pull request comment: the change against the base branch first, the full report folded below.
335
- * Starts with a marker so the GitHub Action can update its own comment instead of adding new ones.
337
+ * A pull request comment: the change against the base branch first, then the safe fixes (when `fixes`
338
+ * has changes), the full report folded below. Starts with a marker so the GitHub Action can update its
339
+ * own comment instead of adding new ones.
336
340
  */
337
- export function renderPrComment(report) {
341
+ export function renderPrComment(report, fixes, fixCommand = 'npx turntax --fix') {
338
342
  const top = report.diff ? renderDiffMarkdown(report.diff) : '## Agent context tax';
339
343
  const headline = `Largest per-turn cost: **${fmtTokens(headlineTokens(report))}** o200k tokens (repository files and measured project MCP tools).`;
344
+ const fixPart = fixes?.changes.length ? ['', renderFixes(fixes, fixCommand)] : [];
345
+ // GitHub rejects comments over 65,536 characters; everything but the full report counts against the limit.
346
+ const limit = Math.max(0, COMMENT_LIMIT - [top, headline, ...fixPart].join('\n').length);
340
347
  let full = renderMarkdown({ ...report, diff: undefined }).replace(/^# .*\n\n/, '');
341
- if (full.length > COMMENT_LIMIT)
342
- full = full.slice(0, COMMENT_LIMIT) + '\n\n_Report cut to fit in a comment; the job summary has all of it._\n';
343
- return [PR_COMMENT_MARKER, top, '', headline, '', '<details><summary>Full report</summary>', '', full, '</details>', ''].join('\n');
348
+ if (full.length > limit)
349
+ full = full.slice(0, limit) + '\n\n_Report cut to fit in a comment; run turntax locally for all of it._\n';
350
+ return [PR_COMMENT_MARKER, top, '', headline, ...fixPart, '', '<details><summary>Full report</summary>', '', full, '</details>', ''].join('\n');
351
+ }
352
+ function renderFixes(plan, command) {
353
+ const n = plan.fixes.length;
354
+ const saving = plan.fixes.reduce((a, f) => a + (f.savingTokens ?? 0), 0);
355
+ let diff = unifiedDiff(plan.changes);
356
+ let cut = '';
357
+ if (diff.length > FIX_DIFF_LIMIT) {
358
+ const end = diff.lastIndexOf('\n', FIX_DIFF_LIMIT);
359
+ diff = diff.slice(0, end > 0 ? end + 1 : FIX_DIFF_LIMIT);
360
+ cut = `\n_Diff cut to fit in a comment; run \`${command}\` for all of it._\n`;
361
+ }
362
+ // A fence longer than any backtick run in the diff, so moved code blocks cannot close it.
363
+ const fence = '`'.repeat(Math.max(3, ...[...diff.matchAll(/`+/g)].map((m) => m[0].length + 1)));
364
+ return [
365
+ `**${n} safe ${n === 1 ? 'fix' : 'fixes'} available**${saving ? ` (saves ~${fmtTokens(saving)} o200k tokens/turn)` : ''}: run \`${command}\` to preview.`,
366
+ '',
367
+ '<details><summary>Fixes</summary>',
368
+ '',
369
+ // Section headings in the messages may contain @names; keep them from mentioning people.
370
+ ...plan.fixes.map((f) => `- ${f.message.replace(/@(?=[A-Za-z0-9])/g, '@&#8203;')}`),
371
+ '',
372
+ `${fence}diff`,
373
+ diff.replace(/\n$/, ''),
374
+ fence,
375
+ cut,
376
+ '</details>',
377
+ ].join('\n');
378
+ }
379
+ /** GitHub workflow command escaping: message data, and property values (file, title). */
380
+ const escapeData = (s) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
381
+ const escapeProperty = (s) => escapeData(s).replace(/:/g, '%3A').replace(/,/g, '%2C');
382
+ /**
383
+ * GitHub Actions annotations, one per advice item on a repository file, so findings show inline on a
384
+ * pull request. `prefix` makes paths relative to the workspace when the audited folder is not its root.
385
+ */
386
+ export function renderAnnotations(report, prefix = '') {
387
+ let out = '';
388
+ for (const a of report.advice) {
389
+ const path = a.paths.find((p) => !p.startsWith('~'));
390
+ if (!path)
391
+ continue;
392
+ const props = [`file=${escapeProperty(prefix + path)}`];
393
+ // `line` belongs to the first path.
394
+ if (a.line && path === a.paths[0])
395
+ props.push(`line=${a.line}`);
396
+ props.push(`title=${escapeProperty(`turntax ${a.kind}`)}`);
397
+ const saving = a.savingTokens ? ` (saves ~${fmtTokens(a.savingTokens)} tokens/turn)` : '';
398
+ out += `::${a.severity === 'low' ? 'notice' : 'warning'} ${props.join(',')}::${escapeData(a.message + saving)}\n`;
399
+ }
400
+ return out;
344
401
  }
@@ -0,0 +1,43 @@
1
+ import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
+ import { realLocation } from './discover.js';
4
+ const inside = (root, p) => {
5
+ const rel = relative(root, p);
6
+ return !rel.startsWith('..') && !isAbsolute(rel);
7
+ };
8
+ /**
9
+ * Writes planned changes under `root`. Every file is checked first: files to change must still have the
10
+ * content the plan was made from, new files must not exist, and nothing may be written through a symlink
11
+ * (dangling ones included) or outside the repository. Nothing is written when a check fails.
12
+ */
13
+ export function applyChanges(root, changes) {
14
+ const realRoot = realpathSync(root);
15
+ const exists = (p) => lstatSync(p, { throwIfNoEntry: false }) !== undefined;
16
+ const abs = (path) => {
17
+ const p = resolve(root, path);
18
+ if (lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink())
19
+ throw new Error(`${path} is a symlink; nothing was written.`);
20
+ const real = realLocation(p);
21
+ if (!inside(root, p) || real === null || !inside(realRoot, real))
22
+ throw new Error(`${path} is outside ${root}; nothing was written.`);
23
+ return p;
24
+ };
25
+ for (const c of changes) {
26
+ const source = c.renamedFrom ?? c.path;
27
+ if (c.renamedFrom && exists(abs(c.path)))
28
+ throw new Error(`${c.path} already exists; nothing was written.`);
29
+ if (c.before === null ? exists(abs(c.path)) : !exists(abs(source)) || readFileSync(abs(source), 'utf8') !== c.before)
30
+ throw new Error(`${source} ${c.before === null ? 'already exists' : 'changed since it was read'}; nothing was written.`);
31
+ }
32
+ for (const c of changes) {
33
+ const target = abs(c.path);
34
+ mkdirSync(dirname(target), { recursive: true });
35
+ if (c.renamedFrom)
36
+ renameSync(abs(c.renamedFrom), target);
37
+ // New files are created exclusively, so a link placed in the meantime is not followed.
38
+ if (c.before === null)
39
+ writeFileSync(target, c.after, { flag: 'wx' });
40
+ else if (c.after !== c.before || !c.renamedFrom)
41
+ writeFileSync(target, c.after);
42
+ }
43
+ }
@@ -0,0 +1,52 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { TOOL_IDS } from '../core/tools.js';
4
+ export const CONFIG_FILE = 'turntax.config.json';
5
+ const KEYS = {
6
+ tools: 'strings',
7
+ models: 'strings',
8
+ turns: 'positive',
9
+ maxTokens: 'positive',
10
+ maxIncrease: 'non-negative',
11
+ sectionTokens: 'positive',
12
+ ignore: 'strings',
13
+ };
14
+ /**
15
+ * Reads `file`, or turntax.config.json in `root` when no file is given. Returns {} when there is no
16
+ * config and throws, naming the file and key, when it is invalid.
17
+ */
18
+ export function loadConfig(root, file, models) {
19
+ const path = file ?? join(root, CONFIG_FILE);
20
+ if (file === undefined && !existsSync(path))
21
+ return {};
22
+ const name = file ?? CONFIG_FILE;
23
+ let data;
24
+ try {
25
+ // Windows PowerShell 5.1 writes UTF-8 with a byte order mark, which JSON.parse rejects.
26
+ data = JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
27
+ }
28
+ catch (e) {
29
+ throw new Error(`Could not read config ${name}: ${e.message}`);
30
+ }
31
+ if (typeof data !== 'object' || data === null || Array.isArray(data))
32
+ throw new Error(`${name}: expected a JSON object.`);
33
+ for (const [key, value] of Object.entries(data)) {
34
+ if (!Object.hasOwn(KEYS, key))
35
+ throw new Error(`${name}: unknown key "${key}". Use: ${Object.keys(KEYS).join(', ')}.`);
36
+ const kind = KEYS[key];
37
+ if (kind === 'strings' && !(Array.isArray(value) && value.every((v) => typeof v === 'string' && v)))
38
+ throw new Error(`${name}: "${key}" must be an array of non-empty strings.`);
39
+ if ((key === 'tools' || key === 'models') && value.length === 0)
40
+ throw new Error(`${name}: "${key}" must not be empty.`);
41
+ if (kind !== 'strings' && !(Number.isInteger(value) && value >= (kind === 'positive' ? 1 : 0)))
42
+ throw new Error(`${name}: "${key}" must be a ${kind} integer.`);
43
+ }
44
+ const config = data;
45
+ for (const t of config.tools ?? [])
46
+ if (!TOOL_IDS.includes(t))
47
+ throw new Error(`${name}: "tools" has unknown tool "${t}". Use: ${TOOL_IDS.join(', ')}`);
48
+ for (const m of config.models ?? [])
49
+ if (!models.some((x) => x.id === m))
50
+ throw new Error(`${name}: "models" has unknown model "${m}". Run --list-models.`);
51
+ return config;
52
+ }