turntax 0.4.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.
package/README.md CHANGED
@@ -8,6 +8,8 @@ npx turntax
8
8
 
9
9
  Try it in the browser, with nothing uploaded: https://torutdev.github.io/turntax/ — drop a folder or enter a public GitHub repository. Links like `https://torutdev.github.io/turntax/?repo=owner/name` open straight to that repository's report.
10
10
 
11
+ [State of agent instruction files, October 2026](https://torutdev.github.io/turntax/state/): what turntax finds in 100 popular repositories, with pinned commits and the script to reproduce it ([`scripts/state-of.mjs`](scripts/state-of.mjs)).
12
+
11
13
  ## Supported agents
12
14
 
13
15
  Each agent's documented loading rules decide what is sent on every turn, what loads only when the agent works in a folder or touches a matching file, and what is ignored.
@@ -49,7 +51,7 @@ Advice (savings are estimates in o200k tokens per turn)
49
51
  3 identical lines are loaded twice every turn by cursor. Keep them in one file.
50
52
  HIGH contradiction AGENTS.md, CLAUDE.md [saves ~11 tokens/turn]
51
53
  Conflicting package manager: "Always use npm ..." (AGENTS.md:3) vs "Always use pnpm ..." (CLAUDE.md:5).
52
- MEDIUM scopable-section CLAUDE.md:11 [saves ~128 tokens/turn]
54
+ MEDIUM scopable-section CLAUDE.md:12 [saves ~128 tokens/turn]
53
55
  Section "API package" is mostly about packages/api/. Move it to packages/api/AGENTS.md ...
54
56
  ```
55
57
 
@@ -100,6 +102,24 @@ Before a fix is kept, the files are classified again with the change applied: ev
100
102
 
101
103
  In preview the diff is on stdout and works with `git apply`, and the summary is on stderr; with `--write` the summary is on stdout. `--write` checks that no file changed since it was read and writes nothing if one did. Symlinked files, their targets and files in symlinked folders are never changed. Contradictions and stale references need a person to decide, so `--fix` leaves them alone.
102
104
 
105
+ The web page shows the same fixes for any repository you load, with a downloadable `fix.patch`.
106
+
107
+ ## Config file
108
+
109
+ Put defaults in `turntax.config.json` in the audited folder (or pass `--config <file>`). Command-line flags override it.
110
+
111
+ ```json
112
+ {
113
+ "tools": ["claude-code", "codex", "cursor"],
114
+ "models": ["claude-sonnet-5.5", "gpt-6.1-sol"],
115
+ "maxTokens": 8000,
116
+ "maxIncrease": 500,
117
+ "ignore": ["docs/agents/archive/", "**/*.draft.md"]
118
+ }
119
+ ```
120
+
121
+ Keys: `tools`, `models`, `turns`, `maxTokens`, `maxIncrease` (applies only with `--compare` or `--baseline`), `sectionTokens`, and `ignore`: globs of instruction files to leave out, relative to the audited folder (`*` and `?` within a folder name, `**` across folders, a trailing `/` for a whole folder). Ignored files are also left out of `--compare` and are never changed by `--fix`. Unknown keys and ids are errors.
122
+
103
123
  ## MCP servers
104
124
 
105
125
  Every MCP server an agent starts adds its tool definitions (names, descriptions, input schemas) to the context. `turntax` reads the agents' MCP configs and lists the servers each agent starts:
@@ -152,7 +172,7 @@ The comparison covers repository instruction files (o200k), per agent and per fi
152
172
 
153
173
  ## GitHub Action
154
174
 
155
- The repository is also an Action. On pull requests it compares with the base branch, posts one comment (updated on every push) and writes the report to the job summary:
175
+ The repository is also an Action. On pull requests it compares with the base branch, posts one comment (updated on every push), writes the report to the job summary and marks findings on the lines they are about in the pull request's changed files (GitHub shows up to 10 warnings and 10 notices per step):
156
176
 
157
177
  ```yaml
158
178
  name: turntax
@@ -165,14 +185,15 @@ jobs:
165
185
  runs-on: ubuntu-latest
166
186
  steps:
167
187
  - uses: actions/checkout@v4
168
- - uses: TorutDev/turntax@v0.4.0
188
+ - uses: TorutDev/turntax@v0.5.0
169
189
  with:
170
190
  max-increase: 500 # optional: fail when an agent grows by more than 500 tokens per turn
171
191
  # max-tokens: 8000 # optional: absolute budget
172
192
  # args: --tools claude-code,cursor
193
+ # annotations: false # optional: no findings on the changed lines
173
194
  ```
174
195
 
175
- The comment opens with a table of the change per agent and per file, and folds the full report below it. Pull requests from forks get a read-only token, so the comment step is skipped there; the job summary still has the report.
196
+ The comment opens with a table of the change per agent and per file. When `--fix` has safe fixes, it says how many and how many tokens they save, with the diff folded; the full report is folded below. A `turntax.config.json` in the repository is read as well. Pull requests from forks get a read-only token, so the comment step is skipped there; the job summary still has the report.
176
197
 
177
198
  ## Options
178
199
 
@@ -182,7 +203,8 @@ turntax [path]
182
203
  --models <ids> see --list-models (default: claude-sonnet-5.5, gpt-6.1-sol, gemini-3.8-flash)
183
204
  --turns <n> cost projection (default 100)
184
205
  --json | --md | --badge output format (default: terminal table)
185
- --pr-comment Markdown for a pull request comment (change first, full report folded)
206
+ --pr-comment Markdown for a pull request comment (change first, fixes and full report folded)
207
+ --annotations GitHub Actions annotations, one per finding (--path-prefix <dir> for a subfolder)
186
208
  --out <file> write the output to a file
187
209
  --max-tokens <n> exit 1 when any tool loads more than n o200k tokens per turn (CI gate)
188
210
  --include-user add user-level files and MCP servers from your home directory
@@ -199,8 +221,11 @@ turntax [path]
199
221
  --section-tokens <n> threshold for oversized sections (default 600)
200
222
  --fix print a diff of the safe fixes (--tools limits the agents whose context is kept)
201
223
  --write with --fix, apply the changes
224
+ --config <file> read defaults from this file (default: turntax.config.json, if any)
202
225
  ```
203
226
 
227
+ When a selected model's price was checked more than 90 days ago, turntax says so on stderr; check the source or pass `--prices`.
228
+
204
229
  ## Privacy
205
230
 
206
231
  Offline by default: no network calls, no telemetry. File contents are sent only when you pass `--claude-api`, `--gemini-api` or `--translate`, and only to that vendor's API. Keys are read from environment variables and sent only in request headers.
package/dist/cli.js CHANGED
@@ -1,18 +1,20 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { basename, relative, resolve } from 'node:path';
3
+ import { basename, isAbsolute, posix, relative, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { hasNonEnglish } from './core/advice.js';
6
6
  import { audit } from './core/audit.js';
7
7
  import { classify } from './core/classify.js';
8
8
  import { asDiffInput, diffReports, maxIncrease } from './core/diff.js';
9
9
  import { planFixes, unifiedDiff } from './core/fix.js';
10
+ import { globMatcher } from './core/glob.js';
10
11
  import { parseMcpConfigs, serverKey, toolDefinitionsText, toolNamesText } from './core/mcp.js';
11
12
  import { DEFAULT_MODEL_IDS, MODELS, validateModels } from './core/prices.js';
12
- import { badgeMarkdown, headlineTokens, renderMarkdown, renderPrComment, renderText } from './core/render.js';
13
+ import { badgeMarkdown, headlineTokens, renderAnnotations, renderMarkdown, renderPrComment, renderText } from './core/render.js';
13
14
  import { countO200k } from './core/tokenize.js';
14
15
  import { TOOL_IDS } from './core/tools.js';
15
16
  import { applyChanges } from './node/apply.js';
17
+ import { CONFIG_FILE, loadConfig } from './node/config.js';
16
18
  import { discover, discoverMcpConfigs, discoverUser, existsIn, linkedPaths, packageScripts } from './node/discover.js';
17
19
  import { filesAtRef } from './node/git.js';
18
20
  import { measureServer } from './node/mcp.js';
@@ -33,6 +35,8 @@ Options:
33
35
  --pr-comment Print a pull request comment: the change first, the full report folded
34
36
  --out <file> Write the report to a file instead of stdout
35
37
  --badge Print a shields.io badge snippet
38
+ --annotations Print GitHub Actions annotations for the advice, shown inline on pull requests
39
+ --path-prefix <dir> With --annotations, the audited folder relative to the repository root
36
40
  --max-tokens <n> Exit with code 1 if any tool loads more than n o200k tokens per turn
37
41
  (repository files plus measured project MCP tools)
38
42
  --include-user Also count user-level files and MCP servers in your home directory
@@ -57,9 +61,17 @@ Options:
57
61
  their folder, misnamed Cursor rules. Every agent keeps reading the moved text;
58
62
  --tools limits the agents whose context is kept
59
63
  --write With --fix, apply the changes to the files
64
+ --config <file> Read defaults from this file (default: ${CONFIG_FILE} in the audited folder, if any)
60
65
  -h, --help Show help
61
66
  -v, --version Show version
62
67
 
68
+ Config file keys (all optional; command-line flags override them):
69
+ tools, models Lists of ids, like --tools and --models
70
+ turns, maxTokens, maxIncrease, sectionTokens
71
+ Integers, like the matching flags (maxIncrease applies only when comparing)
72
+ ignore Globs of instruction files to leave out, relative to the audited folder (* and ? within a
73
+ folder name, ** across folders, a trailing / for a whole folder)
74
+
63
75
  Without --claude-api, --gemini-api or --mcp nothing leaves your machine.
64
76
  `;
65
77
  class UsageError extends Error {
@@ -78,7 +90,7 @@ function positiveInt(name, value, fallback) {
78
90
  throw new UsageError(`--${name} must be a positive integer`);
79
91
  return n;
80
92
  }
81
- export async function main(argv, env = process.env) {
93
+ export async function main(argv, env = process.env, now = new Date()) {
82
94
  let parsed;
83
95
  try {
84
96
  parsed = parseArgs({
@@ -94,6 +106,9 @@ export async function main(argv, env = process.env) {
94
106
  'pr-comment': { type: 'boolean' },
95
107
  out: { type: 'string' },
96
108
  badge: { type: 'boolean' },
109
+ annotations: { type: 'boolean' },
110
+ 'path-prefix': { type: 'string' },
111
+ config: { type: 'string' },
97
112
  'max-tokens': { type: 'string' },
98
113
  'claude-api': { type: 'boolean' },
99
114
  'gemini-api': { type: 'boolean' },
@@ -140,34 +155,56 @@ export async function main(argv, env = process.env) {
140
155
  process.stdout.write(o.json ? JSON.stringify(catalog, null, 2) + '\n' : catalog.map(describeModel).join('\n') + '\n');
141
156
  return 0;
142
157
  }
143
- const modelIds = list(o.models) ?? DEFAULT_MODEL_IDS.filter((id) => catalog.some((m) => m.id === id));
158
+ const root = resolve(positionals[0] ?? '.');
159
+ let config;
160
+ try {
161
+ config = loadConfig(root, o.config, catalog);
162
+ }
163
+ catch (e) {
164
+ throw new UsageError(e.message);
165
+ }
166
+ const modelIds = list(o.models) ?? config.models ?? DEFAULT_MODEL_IDS.filter((id) => catalog.some((m) => m.id === id));
144
167
  const models = modelIds.map((id) => {
145
168
  const m = catalog.find((x) => x.id === id);
146
169
  if (!m)
147
170
  throw new UsageError(`Unknown model "${id}". Run --list-models.`);
148
171
  return m;
149
172
  });
150
- const tools = list(o.tools);
173
+ const tools = list(o.tools) ?? config.tools;
151
174
  for (const t of tools ?? [])
152
175
  if (!TOOL_IDS.includes(t))
153
176
  throw new UsageError(`Unknown tool "${t}". Use: ${TOOL_IDS.join(', ')}`);
154
- const root = resolve(positionals[0] ?? '.');
177
+ const ignored = globMatcher(config.ignore ?? []);
178
+ const repoFiles = (files) => files.filter((f) => !ignored(f.path));
155
179
  if (o.write && !o.fix)
156
180
  throw new UsageError('--write needs --fix.');
181
+ if (o['path-prefix'] !== undefined && !o.annotations)
182
+ throw new UsageError('--path-prefix needs --annotations.');
183
+ if (o.annotations) {
184
+ const other = ['json', 'md', 'pr-comment', 'badge', 'fix'].filter((k) => o[k] !== undefined);
185
+ if (other.length)
186
+ throw new UsageError(`--annotations cannot be combined with --${other.join(', --')}.`);
187
+ }
157
188
  if (o.fix) {
158
189
  const other = ['json', 'md', 'pr-comment', 'badge', 'baseline', 'compare', 'max-tokens', 'max-increase', 'include-user', 'mcp'].filter((k) => o[k] !== undefined);
159
190
  if (o.write && o.out)
160
191
  other.push('out');
161
192
  if (other.length)
162
193
  throw new UsageError(`--fix${o.write ? ' --write' : ''} cannot be combined with --${other.join(', --')}.`);
163
- return await fix(root, tools, Boolean(o.write), o.out);
194
+ return await fix(root, tools, ignored, Boolean(o.write), o.out);
164
195
  }
165
- const files = await discover(root);
196
+ const discovered = await discover(root);
197
+ const files = repoFiles(discovered);
198
+ // Budgets in the config are for report runs; an --annotations run only prints findings.
199
+ const gates = o.annotations ? {} : config;
200
+ const label = (flag, key) => o[flag] !== undefined ? `--${flag}` : `${key} in ${o.config ?? CONFIG_FILE}`;
166
201
  if (o.baseline && o.compare)
167
202
  throw new UsageError('Use --baseline or --compare, not both.');
203
+ // The config's maxIncrease applies only when a comparison runs, so plain local runs keep working.
168
204
  if (o['max-increase'] !== undefined && !o.baseline && !o.compare)
169
205
  throw new UsageError('--max-increase needs --baseline or --compare.');
170
- const maxIncreaseLimit = o['max-increase'] === undefined ? undefined : nonNegativeInt('max-increase', o['max-increase']);
206
+ const maxIncreaseLimit = o['max-increase'] === undefined ? gates.maxIncrease : nonNegativeInt('max-increase', o['max-increase']);
207
+ stalePrices(models, now);
171
208
  let userFiles = [];
172
209
  let mcpFiles = discoverMcpConfigs(root);
173
210
  if (o['include-user']) {
@@ -229,14 +266,14 @@ export async function main(argv, env = process.env) {
229
266
  tools,
230
267
  models,
231
268
  counters,
232
- turns: positiveInt('turns', o.turns, 100),
269
+ turns: positiveInt('turns', o.turns, config.turns ?? 100),
233
270
  userFiles,
234
271
  mcp: { servers, measured },
235
272
  advice: {
236
273
  exists: existsIn(root),
237
274
  packageScripts: packageScripts(root),
238
275
  english,
239
- sectionTokens: positiveInt('section-tokens', o['section-tokens'], 600),
276
+ sectionTokens: positiveInt('section-tokens', o['section-tokens'], config.sectionTokens ?? 600),
240
277
  },
241
278
  });
242
279
  if (o.baseline) {
@@ -253,7 +290,7 @@ export async function main(argv, env = process.env) {
253
290
  const base = await filesAtRef(root, o.compare).catch((e) => {
254
291
  throw new UsageError(e.message);
255
292
  });
256
- const before = await audit(base.files, {
293
+ const before = await audit(repoFiles(base.files), {
257
294
  tools,
258
295
  models: [],
259
296
  mcp: { servers: parseMcpConfigs(base.mcpConfigs, root).servers },
@@ -263,24 +300,26 @@ export async function main(argv, env = process.env) {
263
300
  const output = o.json
264
301
  ? JSON.stringify({ root, ...report }, null, 2) + '\n'
265
302
  : o['pr-comment']
266
- ? renderPrComment(report)
267
- : o.md
268
- ? renderMarkdown(report)
269
- : o.badge
270
- ? badgeMarkdown(report) + '\n'
271
- : renderText(report, root) + '\n';
303
+ ? renderPrComment(report, files.length ? fixPlan(root, discovered, tools, ignored) : undefined, fixCommand(positionals[0], o.config, o.tools))
304
+ : o.annotations
305
+ ? renderAnnotations(report, pathPrefix(o['path-prefix']))
306
+ : o.md
307
+ ? renderMarkdown(report)
308
+ : o.badge
309
+ ? badgeMarkdown(report) + '\n'
310
+ : renderText(report, root) + '\n';
272
311
  if (o.out)
273
312
  writeFileSync(o.out, output);
274
313
  else
275
314
  process.stdout.write(output);
276
315
  let code = 0;
277
- const max = o['max-tokens'] === undefined ? undefined : positiveInt('max-tokens', o['max-tokens'], 0);
316
+ const max = o['max-tokens'] === undefined ? gates.maxTokens : positiveInt('max-tokens', o['max-tokens'], 0);
278
317
  if (max !== undefined && headlineTokens(report) > max) {
279
- process.stderr.write(`turntax: ${headlineTokens(report)} o200k tokens per turn exceeds --max-tokens ${max}\n`);
318
+ process.stderr.write(`turntax: ${headlineTokens(report)} o200k tokens per turn exceeds ${label('max-tokens', 'maxTokens')} ${max}\n`);
280
319
  code = 1;
281
320
  }
282
321
  if (maxIncreaseLimit !== undefined && report.diff && maxIncrease(report.diff) > maxIncreaseLimit) {
283
- process.stderr.write(`turntax: per-turn tokens grew by ${maxIncrease(report.diff)} vs ${report.diff.base}, more than --max-increase ${maxIncreaseLimit}\n`);
322
+ process.stderr.write(`turntax: per-turn tokens grew by ${maxIncrease(report.diff)} vs ${report.diff.base}, more than ${label('max-increase', 'maxIncrease')} ${maxIncreaseLimit}\n`);
284
323
  code = 1;
285
324
  }
286
325
  return code;
@@ -294,9 +333,9 @@ export async function main(argv, env = process.env) {
294
333
  return 1;
295
334
  }
296
335
  }
297
- async function fix(root, tools, write, out) {
298
- const files = await discover(root);
299
- const plan = planFixes(files, { tools, exists: existsIn(root), readOnly: linkedPaths(root, files) });
336
+ async function fix(root, tools, ignored, write, out) {
337
+ const discovered = await discover(root);
338
+ const plan = fixPlan(root, discovered, tools, ignored);
300
339
  const log = (line) => (write ? process.stdout : process.stderr).write(line + '\n');
301
340
  for (const f of plan.fixes)
302
341
  log(`fix: ${f.message}${f.savingTokens ? ` [saves ~${f.savingTokens} tokens/turn]` : ''}`);
@@ -322,6 +361,47 @@ async function fix(root, tools, write, out) {
322
361
  log('Preview only. Run again with --write to apply, or save the diff and use git apply.');
323
362
  return 0;
324
363
  }
364
+ /**
365
+ * Safe fixes. The plan sees every repository file, ignored ones included, because the agents still read
366
+ * them and the safety check must see what they read; symlinks, their targets and ignored files are never
367
+ * written.
368
+ */
369
+ function fixPlan(root, discovered, tools, ignored) {
370
+ const linked = linkedPaths(root, discovered);
371
+ return planFixes(discovered, {
372
+ tools,
373
+ exists: existsIn(root),
374
+ readOnly: (p) => (ignored(p) ? `${p} is in the config's ignore list; it is left alone.` : linked(p)),
375
+ });
376
+ }
377
+ /** The --fix command matching the run that made a pull request comment. */
378
+ function fixCommand(path, config, tools) {
379
+ const arg = (s) => (/^[\w./@:,=-]+$/.test(s) ? s : `"${s}"`);
380
+ const parts = ['npx turntax'];
381
+ if (path && path !== '.' && !isAbsolute(path))
382
+ parts.push(arg(path));
383
+ if (config)
384
+ parts.push('--config', arg(config));
385
+ if (tools)
386
+ parts.push('--tools', arg(tools));
387
+ return [...parts, '--fix'].join(' ');
388
+ }
389
+ /** `--path-prefix` as a POSIX folder ending in a slash, or '' for the repository root. */
390
+ function pathPrefix(dir) {
391
+ if (!dir)
392
+ return '';
393
+ const p = posix.normalize(dir.replace(/\\/g, '/') + '/');
394
+ return p === './' ? '' : p;
395
+ }
396
+ /** One notice when a selected model's price was checked more than 90 days before `now`. */
397
+ function stalePrices(models, now) {
398
+ const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
399
+ const stale = models.filter((m) => today - Date.parse(m.checkedAt) > 90 * 86_400_000);
400
+ if (!stale.length)
401
+ return;
402
+ const dates = [...new Set(stale.map((m) => m.checkedAt))].sort();
403
+ notice(`prices for ${stale.map((m) => m.label).join(', ')} were checked on ${dates.join(', ')}, over 90 days ago; check the sources or pass --prices.`);
404
+ }
325
405
  function nonNegativeInt(name, value) {
326
406
  const n = Number(value);
327
407
  if (!Number.isInteger(n) || n < 0)
@@ -5,18 +5,57 @@ const DEFAULT_SECTION_TOKENS = 600;
5
5
  export function buildAdvice(units, files, opts = {}) {
6
6
  // User-level files take part in checks across files; moving sections or checking paths only makes sense in the repository.
7
7
  const repo = units.filter((u) => !u.scope);
8
+ const raw = rawLines(files);
8
9
  const advice = [
9
10
  ...misconfigured(units),
10
11
  ...sizeLimits(units, files),
11
12
  ...duplicates(units),
12
- ...contradictions(units),
13
- ...sections(repo, opts.sectionTokens ?? DEFAULT_SECTION_TOKENS),
14
- ...nonEnglish(units, opts.english),
15
- ...staleReferences(repo, opts),
13
+ ...contradictions(units, raw),
14
+ ...sections(repo, opts.sectionTokens ?? DEFAULT_SECTION_TOKENS, raw, opts.exists),
15
+ ...nonEnglish(units, raw, opts.english),
16
+ ...staleReferences(repo, opts, raw),
16
17
  ];
17
18
  const order = { high: 0, medium: 1, low: 2 };
18
19
  return advice.sort((a, b) => order[a.severity] - order[b.severity] || (b.savingTokens ?? 0) - (a.savingTokens ?? 0));
19
20
  }
21
+ function rawLines(files) {
22
+ const byPath = new Map(files.map((f) => [f.path, f.text]));
23
+ const cache = new Map();
24
+ return (u, line) => {
25
+ const key = `${u.path}\0${u.text}`;
26
+ let map = cache.get(key);
27
+ if (!map)
28
+ cache.set(key, (map = alignLines(u.text, byPath.get(u.path))));
29
+ return map[line - 1];
30
+ };
31
+ }
32
+ /**
33
+ * For each line of `text` (what an agent loads: frontmatter, HTML comments or a truncated tail removed),
34
+ * the 1-based line of `raw` it comes from. Lines are only ever removed, so line i is at raw line i or later.
35
+ */
36
+ function alignLines(text, raw) {
37
+ if (raw === undefined)
38
+ return [];
39
+ const lines = text.split(/\r?\n/);
40
+ const rawLines = raw.split(/\r?\n/);
41
+ // Frontmatter removed: the text is the end of the file.
42
+ const offset = rawLines.length - lines.length;
43
+ if (offset >= 0 && rawLines.slice(offset).join('\n') === lines.join('\n'))
44
+ return lines.map((_, i) => offset + i + 1);
45
+ const out = [];
46
+ let next = 0;
47
+ lines.forEach((line, i) => {
48
+ const want = line.trim();
49
+ for (let j = Math.max(next, i); j < rawLines.length; j++) {
50
+ if (rawLines[j].trim() !== want)
51
+ continue;
52
+ out[i] = j + 1;
53
+ next = j + 1;
54
+ return;
55
+ }
56
+ });
57
+ return out;
58
+ }
20
59
  /** Units that carry real instruction text (not skill descriptions), grouped by path. */
21
60
  function contentUnits(units, modes = ['always', 'on-demand']) {
22
61
  return units.filter((u) => u.part !== 'description' && modes.includes(u.mode));
@@ -146,6 +185,7 @@ function duplicates(units) {
146
185
  }
147
186
  return out;
148
187
  }
188
+ const at = (d) => (d.line ? `${d.path}:${d.line}` : d.path);
149
189
  const DIRECTIVES = [
150
190
  {
151
191
  topic: 'package manager',
@@ -203,7 +243,7 @@ function conflicts(a, b) {
203
243
  return a === `not ${b}` || b === `not ${a}`;
204
244
  return true;
205
245
  }
206
- function contradictions(units) {
246
+ function contradictions(units, raw) {
207
247
  const found = new Map();
208
248
  const tools = [...new Set(units.map((u) => u.tool))];
209
249
  for (const tool of tools) {
@@ -213,7 +253,7 @@ function contradictions(units) {
213
253
  if (inFence)
214
254
  return;
215
255
  for (const d of directivesIn(line))
216
- directives.push({ ...d, path: u.path, line: i + 1, text: line.trim() });
256
+ directives.push({ ...d, path: u.path, line: raw(u, i + 1), text: line.trim() });
217
257
  });
218
258
  }
219
259
  for (let i = 0; i < directives.length; i++) {
@@ -222,7 +262,7 @@ function contradictions(units) {
222
262
  const b = directives[j];
223
263
  if (a.topic !== b.topic || !conflicts(a.value, b.value))
224
264
  continue;
225
- const key = [a.topic, `${a.path}:${a.line}`, `${b.path}:${b.line}`].join('|');
265
+ const key = [a.topic, at(a), at(b), a.text, b.text].join('|');
226
266
  const prev = found.get(key);
227
267
  if (prev) {
228
268
  prev.tools.push(tool);
@@ -234,7 +274,7 @@ function contradictions(units) {
234
274
  severity: 'high',
235
275
  tools: [tool],
236
276
  paths: [...new Set([a.path, b.path])],
237
- message: `Conflicting ${a.topic}: "${a.text}" (${a.path}:${a.line}) vs "${b.text}" (${b.path}:${b.line}).`,
277
+ message: `Conflicting ${a.topic}: "${a.text}" (${at(a)}) vs "${b.text}" (${at(b)}).`,
238
278
  savingTokens: countO200k(shorter),
239
279
  savingNote: 'Removing the outdated line.',
240
280
  });
@@ -259,20 +299,21 @@ export function pathRefs(text) {
259
299
  .map((r) => r.replace(/^\.\//, '').replace(/[),.;:]+$/, ''))
260
300
  .filter((r) => r.includes('/') && !/^(https?:|~|\/|[A-Za-z]:|@|<)/.test(r) && !/[*?{}<>$]/.test(r));
261
301
  }
262
- function sections(units, threshold) {
302
+ function sections(units, threshold, raw, exists) {
263
303
  const out = [];
264
304
  for (const u of uniqueByPath(contentUnits(units, ['always']))) {
265
305
  const tools = toolsWith(units, u.path, 'always');
266
306
  for (const s of splitSections(u.text)) {
267
307
  const tokens = countO200k(s.text);
308
+ // A folder that does not exist (a URL path, a placeholder) is not a place the section can move to.
268
309
  const scope = sectionScope(s.text);
269
- if (scope) {
310
+ if (scope && (!exists || exists(scope))) {
270
311
  out.push({
271
312
  kind: 'scopable-section',
272
313
  severity: 'medium',
273
314
  tools,
274
315
  paths: [u.path],
275
- line: s.line,
316
+ line: raw(u, s.line),
276
317
  message: `Section "${s.heading}" is mostly about ${scope}/. Move it to ${scope}/AGENTS.md (or CLAUDE.md), a path-scoped rule, or an instructions file with applyTo so it loads only when the agent works there.`,
277
318
  savingTokens: tokens,
278
319
  savingNote: 'Saved on turns that do not touch that directory.',
@@ -284,7 +325,7 @@ function sections(units, threshold) {
284
325
  severity: 'medium',
285
326
  tools,
286
327
  paths: [u.path],
287
- line: s.line,
328
+ line: raw(u, s.line),
288
329
  message: `Section "${s.heading}" is ${tokens} tokens and loads every turn. If it is a procedure (release, deploy, migration), move it into an on-demand skill or rule and keep a one-line pointer.`,
289
330
  savingTokens: Math.max(0, tokens - 30),
290
331
  savingNote: 'Assumes about 30 tokens remain for the skill description or pointer.',
@@ -357,14 +398,16 @@ export function guessLanguage(text) {
357
398
  if (words.length < 15)
358
399
  return { nonEnglish: false, script: 'Latin' };
359
400
  const stop = words.filter((w) => EN_STOPWORDS.has(w)).length / words.length;
360
- const diacritics = letters.filter((c) => /[^\u0000-\u007F]/.test(c)).length / letters.length;
361
- return { nonEnglish: stop < 0.12 || (diacritics > 0.03 && stop < 0.2), script: 'Latin' };
401
+ // Terse English (bullet lists, commands) has few stopwords too, so Latin text also needs words with
402
+ // accented letters: a few percent of words in German, French, Spanish or Turkish, none in plain English.
403
+ const accented = words.filter((w) => /[^\u0000-\u007F]/.test(w)).length / words.length;
404
+ return { nonEnglish: stop < 0.2 && accented >= 0.04, script: 'Latin' };
362
405
  }
363
406
  /** True when any heading section of the text looks non-English. */
364
407
  export function hasNonEnglish(text) {
365
408
  return splitSections(text).some((s) => guessLanguage(s.text).nonEnglish);
366
409
  }
367
- function nonEnglish(units, english) {
410
+ function nonEnglish(units, raw, english) {
368
411
  const out = [];
369
412
  for (const u of uniqueByPath(contentUnits(units, ['always']))) {
370
413
  const parts = splitSections(u.text).filter((s) => guessLanguage(s.text).nonEnglish);
@@ -394,7 +437,7 @@ function nonEnglish(units, english) {
394
437
  severity: 'low',
395
438
  tools,
396
439
  paths: [u.path],
397
- line: parts[0].line,
440
+ line: raw(u, parts[0].line),
398
441
  message: `${parts.length} section(s) (${tokens} tokens) in ${script} script look non-English. Pass --english ${u.path}=<english file> or --translate to measure the multiplier.`,
399
442
  savingTokens: null,
400
443
  savingNote: 'Not measured without an English version.',
@@ -408,7 +451,7 @@ function isNested(path) {
408
451
  const dir = dirname(path);
409
452
  return dir !== '' && !/^\.(claude|github|cursor|windsurf|devin|agents|clinerules|cline|roo|kiro|junie|continue)(\/|$)/.test(dir);
410
453
  }
411
- function staleReferences(units, opts) {
454
+ function staleReferences(units, opts, raw) {
412
455
  const out = [];
413
456
  for (const u of uniqueByPath(contentUnits(units))) {
414
457
  const tools = [...new Set(units.filter((x) => x.path === u.path && x.mode !== 'ignored').map((x) => x.tool))];
@@ -424,18 +467,21 @@ function staleReferences(units, opts) {
424
467
  const first = ref.split('/')[0];
425
468
  if (/^(path|example|foo|your|my)/i.test(first))
426
469
  continue;
470
+ // Paths with .. can point outside the repository, where nothing can be checked.
471
+ if (ref.split('/').includes('..'))
472
+ continue;
427
473
  // Only flag when the top-level folder exists but the file does not: likely moved or deleted.
428
474
  // Paths may be written relative to the repository root or to the instruction file.
429
475
  const local = resolvePath(u.path, ref);
430
476
  if (opts.exists(first) && !opts.exists(ref) && !(local && opts.exists(local)))
431
- missing.push({ ref, line, n: i + 1 });
477
+ missing.push({ ref, line, n: raw(u, i + 1) });
432
478
  }
433
479
  }
434
480
  // Root package.json scripts only apply to root-level instruction files, not nested packages.
435
481
  if (opts.packageScripts && !isNested(u.path)) {
436
482
  for (const m of line.matchAll(/\b(?:npm|pnpm|yarn|bun) run (?!-)([\w:.-]+)/g)) {
437
483
  if (!opts.packageScripts.has(m[1]))
438
- missing.push({ ref: `script "${m[1]}"`, line, n: i + 1 });
484
+ missing.push({ ref: `script "${m[1]}"`, line, n: raw(u, i + 1) });
439
485
  }
440
486
  }
441
487
  });
@@ -447,8 +493,8 @@ function staleReferences(units, opts) {
447
493
  severity: 'medium',
448
494
  tools,
449
495
  paths: [u.path],
450
- line: missing[0].n,
451
- message: `References that no longer exist: ${[...new Set(missing.map((m) => `${m.ref} (line ${m.n})`))].join(', ')}.`,
496
+ line: missing.find((m) => m.n)?.n,
497
+ message: `References that no longer exist: ${[...new Set(missing.map((m) => (m.n ? `${m.ref} (line ${m.n})` : m.ref)))].join(', ')}.`,
452
498
  savingTokens: countO200k(uniqueLines.join('\n')),
453
499
  savingNote: 'If those lines are obsolete; update them instead if they are still relevant.',
454
500
  });
package/dist/core/fix.js CHANGED
@@ -103,11 +103,12 @@ export function planFixes(input, opts = {}) {
103
103
  // Only repository files are ever changed; user-level files (~/...) are not passed in.
104
104
  const ws = new Workspace(input.filter((f) => !f.path.startsWith('~')), opts.tools);
105
105
  // Files reached only through @imports (a README, docs) are for people too; they are never changed.
106
- const readOnly = (path) => !isInstructionFile(path)
107
- ? `${path} is not an agent instruction file (it is @imported); it is left alone.`
108
- : opts.readOnly?.(path)
109
- ? `${path} is a symlink, in a symlinked folder or the target of a symlink; it is left alone.`
110
- : null;
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
+ };
111
112
  const original = new Map(ws.files);
112
113
  const renames = new Map();
113
114
  const fixes = [];
@@ -137,8 +138,9 @@ function renameCursorRules(ws, readOnly, renames, fixes, skipped) {
137
138
  if (!/^(?:.*\/)?\.cursor\/rules\/.+\.md$/.test(f.path))
138
139
  continue;
139
140
  const to = f.path.replace(/\.md$/, '.mdc');
140
- if (readOnly(f.path) || ws.files.has(to)) {
141
- skipped.push({ kind: 'misconfigured', paths: [f.path], reason: readOnly(f.path) ?? `${to} already exists.` });
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 });
142
144
  continue;
143
145
  }
144
146
  const next = ws.trial(new Map([[f.path, null], [to, f.text]]));
@@ -320,12 +322,14 @@ function moveScopableSections(ws, opts, readOnly, fixes, skipped) {
320
322
  const readers = [...new Set(ws.units.filter((u) => u.path === path && u.mode === 'always' && u.part !== 'description').map((u) => u.tool))];
321
323
  const reads = (l, tool) => keysOf(body).some((k) => l.get(tool)?.has(k));
322
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) : [];
323
326
  ws.commit(trial);
324
327
  fixes.push({
325
328
  kind: 'scopable-section',
326
329
  paths: [path, t.path],
327
330
  message: `Moved section "${heading}" from ${path} to ${t.path}${existing ? '' : ' (new file)'}; ${names(readers)} load it only when working in ${scope}/.` +
328
- (added.length ? ` Now also read there by ${names(added)}.` : ''),
331
+ (added.length ? ` Now also read there by ${names(added)}.` : '') +
332
+ (dropped.length ? ` Not kept for ${names(dropped)} (outside --tools).` : ''),
329
333
  savingTokens: countO200k(body),
330
334
  });
331
335
  moved = true;
@@ -340,6 +344,13 @@ function moveScopableSections(ws, opts, readOnly, fixes, skipped) {
340
344
  }
341
345
  }
342
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
+ }
343
354
  function moveBlocker(body, scope, opts) {
344
355
  if (scope.split('/').some((seg) => seg === '' || seg === '.' || seg === '..'))
345
356
  return `unusual folder ${scope}.`;
@@ -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,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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turntax",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Measure the per-turn token cost of AI coding-agent instruction files (AGENTS.md, CLAUDE.md, Cursor, Copilot, Cline, Roo, Kiro, Windsurf and more) for every agent that reads them.",
5
5
  "license": "MIT",
6
6
  "author": "TORUT LTD",