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.
package/README.md CHANGED
@@ -6,7 +6,9 @@ Coding agents re-send their instruction files with every turn. `turntax` measure
6
6
  npx turntax
7
7
  ```
8
8
 
9
- Try it in the browser, with nothing uploaded: https://torutdev.github.io/turntax/
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
+
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)).
10
12
 
11
13
  ## Supported agents
12
14
 
@@ -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
 
@@ -83,6 +85,41 @@ All checks are deterministic. Each one shows an estimated saving in o200k tokens
83
85
  - **stale-reference**: referenced files or `npm run` scripts that no longer exist.
84
86
  - **non-english**: non-English sections. The token multiplier is measured only against an English version you supply (`--english CLAUDE.md=CLAUDE.en.md`) or, if you opt in, a Claude translation (`--translate`).
85
87
 
88
+ ## Fixing
89
+
90
+ ```bash
91
+ npx turntax --fix # print a diff of the safe fixes; nothing is changed
92
+ npx turntax --fix --write # apply them
93
+ ```
94
+
95
+ `--fix` handles three kinds of advice:
96
+
97
+ - **duplicate**: removes lines from a file when every agent that reads the file also reads the same lines every turn from another file. Skill, command and subagent files are left whole.
98
+ - **scopable-section**: moves a section into the folder it is about: the same file name there (`packages/api/CLAUDE.md`), a nested `AGENTS.md`, or a Claude Code rule with `paths:`. The folder the section is about must already exist, and sections with `@imports` or relative links stay put. A section moves together with its subsections.
99
+ - **misconfigured**: renames `.md` files in `.cursor/rules` to `.mdc`.
100
+
101
+ Before a fix is kept, the files are classified again with the change applied: every agent must still read every line it read before, including files reached through `@imports`, and as often as before. The only exception is a moved section, which may load only when the agent works in that folder. Otherwise the fix is skipped and the reason is printed. A moved section can reach agents that did not read it before (a nested `AGENTS.md` is read by more agents than a `CLAUDE.md`); the summary names them. By default that check covers every supported agent, so a section of `AGENTS.md` usually stays where it is, because some agents read only the root file. `--tools codex,claude-code` limits the check to the agents you use.
102
+
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.
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
+
86
123
  ## MCP servers
87
124
 
88
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:
@@ -135,7 +172,7 @@ The comparison covers repository instruction files (o200k), per agent and per fi
135
172
 
136
173
  ## GitHub Action
137
174
 
138
- 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):
139
176
 
140
177
  ```yaml
141
178
  name: turntax
@@ -148,14 +185,15 @@ jobs:
148
185
  runs-on: ubuntu-latest
149
186
  steps:
150
187
  - uses: actions/checkout@v4
151
- - uses: TorutDev/turntax@v0.3.0
188
+ - uses: TorutDev/turntax@v0.5.0
152
189
  with:
153
190
  max-increase: 500 # optional: fail when an agent grows by more than 500 tokens per turn
154
191
  # max-tokens: 8000 # optional: absolute budget
155
192
  # args: --tools claude-code,cursor
193
+ # annotations: false # optional: no findings on the changed lines
156
194
  ```
157
195
 
158
- 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.
159
197
 
160
198
  ## Options
161
199
 
@@ -165,7 +203,8 @@ turntax [path]
165
203
  --models <ids> see --list-models (default: claude-sonnet-5.5, gpt-6.1-sol, gemini-3.8-flash)
166
204
  --turns <n> cost projection (default 100)
167
205
  --json | --md | --badge output format (default: terminal table)
168
- --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)
169
208
  --out <file> write the output to a file
170
209
  --max-tokens <n> exit 1 when any tool loads more than n o200k tokens per turn (CI gate)
171
210
  --include-user add user-level files and MCP servers from your home directory
@@ -180,18 +219,25 @@ turntax [path]
180
219
  --translate opt-in Claude translation for that measurement (needs --claude-api)
181
220
  --prices <file.json> your own price table
182
221
  --section-tokens <n> threshold for oversized sections (default 600)
222
+ --fix print a diff of the safe fixes (--tools limits the agents whose context is kept)
223
+ --write with --fix, apply the changes
224
+ --config <file> read defaults from this file (default: turntax.config.json, if any)
183
225
  ```
184
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
+
185
229
  ## Privacy
186
230
 
187
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.
188
232
 
189
- `--mcp` runs the commands in the MCP configs it finds and connects to the configured HTTP servers, as the agents themselves would. Run it only on repositories whose configs you trust. `turntax` prints each command before starting it. `--include-user` reads files in your home directory and never sends them anywhere.
233
+ `--mcp` runs the commands in the MCP configs it finds and connects to the configured HTTP servers, as the agents themselves would. Run it only on repositories whose configs you trust. `turntax` prints each command before starting it. `--include-user` reads files in your home directory and never sends them anywhere. `--fix --write` is the only option that changes files, and only repository files.
190
234
 
191
235
  ## Web page
192
236
 
193
237
  [`web/`](web) is a static page that runs the same engine in the browser with the offline tokenizer only. Drop a repository folder or paste files; nothing is uploaded.
194
238
 
239
+ It can also load a public GitHub repository: it lists the files with the GitHub API (`api.github.com`, unauthenticated, 60 requests per hour) and downloads only the instruction files, and files they `@import`, from `raw.githubusercontent.com`. Those are the only network requests the page makes, and only when you load a repository or open a `?repo=` link.
240
+
195
241
  ```bash
196
242
  npm run build:web
197
243
  ```
package/dist/cli.js CHANGED
@@ -1,17 +1,21 @@
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
+ import { planFixes, unifiedDiff } from './core/fix.js';
10
+ import { globMatcher } from './core/glob.js';
9
11
  import { parseMcpConfigs, serverKey, toolDefinitionsText, toolNamesText } from './core/mcp.js';
10
12
  import { DEFAULT_MODEL_IDS, MODELS, validateModels } from './core/prices.js';
11
- import { badgeMarkdown, headlineTokens, renderMarkdown, renderPrComment, renderText } from './core/render.js';
13
+ import { badgeMarkdown, headlineTokens, renderAnnotations, renderMarkdown, renderPrComment, renderText } from './core/render.js';
12
14
  import { countO200k } from './core/tokenize.js';
13
15
  import { TOOL_IDS } from './core/tools.js';
14
- import { discover, discoverMcpConfigs, discoverUser, existsIn, packageScripts } from './node/discover.js';
16
+ import { applyChanges } from './node/apply.js';
17
+ import { CONFIG_FILE, loadConfig } from './node/config.js';
18
+ import { discover, discoverMcpConfigs, discoverUser, existsIn, linkedPaths, packageScripts } from './node/discover.js';
15
19
  import { filesAtRef } from './node/git.js';
16
20
  import { measureServer } from './node/mcp.js';
17
21
  import { anthropicCounter, geminiCounter, translateToEnglish } from './node/remote.js';
@@ -31,6 +35,8 @@ Options:
31
35
  --pr-comment Print a pull request comment: the change first, the full report folded
32
36
  --out <file> Write the report to a file instead of stdout
33
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
34
40
  --max-tokens <n> Exit with code 1 if any tool loads more than n o200k tokens per turn
35
41
  (repository files plus measured project MCP tools)
36
42
  --include-user Also count user-level files and MCP servers in your home directory
@@ -51,9 +57,21 @@ Options:
51
57
  (needs --claude-api; sends those files to api.anthropic.com)
52
58
  --prices <file.json> Use your own price table (same shape as --list-models --json)
53
59
  --section-tokens <n> Flag always-loaded sections at or above n o200k tokens (default: 600)
60
+ --fix Print a diff of the safe fixes: duplicate lines, sections that can load only in
61
+ their folder, misnamed Cursor rules. Every agent keeps reading the moved text;
62
+ --tools limits the agents whose context is kept
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)
54
65
  -h, --help Show help
55
66
  -v, --version Show version
56
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
+
57
75
  Without --claude-api, --gemini-api or --mcp nothing leaves your machine.
58
76
  `;
59
77
  class UsageError extends Error {
@@ -72,7 +90,7 @@ function positiveInt(name, value, fallback) {
72
90
  throw new UsageError(`--${name} must be a positive integer`);
73
91
  return n;
74
92
  }
75
- export async function main(argv, env = process.env) {
93
+ export async function main(argv, env = process.env, now = new Date()) {
76
94
  let parsed;
77
95
  try {
78
96
  parsed = parseArgs({
@@ -88,6 +106,9 @@ export async function main(argv, env = process.env) {
88
106
  'pr-comment': { type: 'boolean' },
89
107
  out: { type: 'string' },
90
108
  badge: { type: 'boolean' },
109
+ annotations: { type: 'boolean' },
110
+ 'path-prefix': { type: 'string' },
111
+ config: { type: 'string' },
91
112
  'max-tokens': { type: 'string' },
92
113
  'claude-api': { type: 'boolean' },
93
114
  'gemini-api': { type: 'boolean' },
@@ -101,6 +122,8 @@ export async function main(argv, env = process.env) {
101
122
  baseline: { type: 'string' },
102
123
  compare: { type: 'string' },
103
124
  'max-increase': { type: 'string' },
125
+ fix: { type: 'boolean' },
126
+ write: { type: 'boolean' },
104
127
  help: { type: 'boolean', short: 'h' },
105
128
  version: { type: 'boolean', short: 'v' },
106
129
  },
@@ -132,24 +155,56 @@ export async function main(argv, env = process.env) {
132
155
  process.stdout.write(o.json ? JSON.stringify(catalog, null, 2) + '\n' : catalog.map(describeModel).join('\n') + '\n');
133
156
  return 0;
134
157
  }
135
- 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));
136
167
  const models = modelIds.map((id) => {
137
168
  const m = catalog.find((x) => x.id === id);
138
169
  if (!m)
139
170
  throw new UsageError(`Unknown model "${id}". Run --list-models.`);
140
171
  return m;
141
172
  });
142
- const tools = list(o.tools);
173
+ const tools = list(o.tools) ?? config.tools;
143
174
  for (const t of tools ?? [])
144
175
  if (!TOOL_IDS.includes(t))
145
176
  throw new UsageError(`Unknown tool "${t}". Use: ${TOOL_IDS.join(', ')}`);
146
- const root = resolve(positionals[0] ?? '.');
147
- const files = await discover(root);
177
+ const ignored = globMatcher(config.ignore ?? []);
178
+ const repoFiles = (files) => files.filter((f) => !ignored(f.path));
179
+ if (o.write && !o.fix)
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
+ }
188
+ if (o.fix) {
189
+ const other = ['json', 'md', 'pr-comment', 'badge', 'baseline', 'compare', 'max-tokens', 'max-increase', 'include-user', 'mcp'].filter((k) => o[k] !== undefined);
190
+ if (o.write && o.out)
191
+ other.push('out');
192
+ if (other.length)
193
+ throw new UsageError(`--fix${o.write ? ' --write' : ''} cannot be combined with --${other.join(', --')}.`);
194
+ return await fix(root, tools, ignored, Boolean(o.write), o.out);
195
+ }
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}`;
148
201
  if (o.baseline && o.compare)
149
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.
150
204
  if (o['max-increase'] !== undefined && !o.baseline && !o.compare)
151
205
  throw new UsageError('--max-increase needs --baseline or --compare.');
152
- 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);
153
208
  let userFiles = [];
154
209
  let mcpFiles = discoverMcpConfigs(root);
155
210
  if (o['include-user']) {
@@ -211,14 +266,14 @@ export async function main(argv, env = process.env) {
211
266
  tools,
212
267
  models,
213
268
  counters,
214
- turns: positiveInt('turns', o.turns, 100),
269
+ turns: positiveInt('turns', o.turns, config.turns ?? 100),
215
270
  userFiles,
216
271
  mcp: { servers, measured },
217
272
  advice: {
218
273
  exists: existsIn(root),
219
274
  packageScripts: packageScripts(root),
220
275
  english,
221
- sectionTokens: positiveInt('section-tokens', o['section-tokens'], 600),
276
+ sectionTokens: positiveInt('section-tokens', o['section-tokens'], config.sectionTokens ?? 600),
222
277
  },
223
278
  });
224
279
  if (o.baseline) {
@@ -235,7 +290,7 @@ export async function main(argv, env = process.env) {
235
290
  const base = await filesAtRef(root, o.compare).catch((e) => {
236
291
  throw new UsageError(e.message);
237
292
  });
238
- const before = await audit(base.files, {
293
+ const before = await audit(repoFiles(base.files), {
239
294
  tools,
240
295
  models: [],
241
296
  mcp: { servers: parseMcpConfigs(base.mcpConfigs, root).servers },
@@ -245,24 +300,26 @@ export async function main(argv, env = process.env) {
245
300
  const output = o.json
246
301
  ? JSON.stringify({ root, ...report }, null, 2) + '\n'
247
302
  : o['pr-comment']
248
- ? renderPrComment(report)
249
- : o.md
250
- ? renderMarkdown(report)
251
- : o.badge
252
- ? badgeMarkdown(report) + '\n'
253
- : 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';
254
311
  if (o.out)
255
312
  writeFileSync(o.out, output);
256
313
  else
257
314
  process.stdout.write(output);
258
315
  let code = 0;
259
- 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);
260
317
  if (max !== undefined && headlineTokens(report) > max) {
261
- 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`);
262
319
  code = 1;
263
320
  }
264
321
  if (maxIncreaseLimit !== undefined && report.diff && maxIncrease(report.diff) > maxIncreaseLimit) {
265
- 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`);
266
323
  code = 1;
267
324
  }
268
325
  return code;
@@ -276,6 +333,75 @@ export async function main(argv, env = process.env) {
276
333
  return 1;
277
334
  }
278
335
  }
336
+ async function fix(root, tools, ignored, write, out) {
337
+ const discovered = await discover(root);
338
+ const plan = fixPlan(root, discovered, tools, ignored);
339
+ const log = (line) => (write ? process.stdout : process.stderr).write(line + '\n');
340
+ for (const f of plan.fixes)
341
+ log(`fix: ${f.message}${f.savingTokens ? ` [saves ~${f.savingTokens} tokens/turn]` : ''}`);
342
+ for (const s of plan.skipped)
343
+ log(`skipped ${s.kind}: ${s.reason}`);
344
+ if (!plan.changes.length) {
345
+ // An empty patch, so an earlier patch in the same file is not applied again.
346
+ if (out && !write)
347
+ writeFileSync(out, '');
348
+ log('Nothing to fix automatically.');
349
+ return 0;
350
+ }
351
+ if (write) {
352
+ applyChanges(root, plan.changes);
353
+ log(`Changed ${plan.changes.length} file(s): ${plan.changes.map((c) => c.path).join(', ')}`);
354
+ return 0;
355
+ }
356
+ const diff = unifiedDiff(plan.changes);
357
+ if (out)
358
+ writeFileSync(out, diff);
359
+ else
360
+ process.stdout.write(diff);
361
+ log('Preview only. Run again with --write to apply, or save the diff and use git apply.');
362
+ return 0;
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
+ }
279
405
  function nonNegativeInt(name, value) {
280
406
  const n = Number(value);
281
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,23 +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);
268
- const refs = pathRefs(s.text).filter((r) => !r.startsWith('.'));
269
- const dirs = refs.map((r) => r.split('/')[0]);
270
- const top = mostCommon(dirs);
271
- if (tokens >= 100 && top && top.count >= 2 && top.count / dirs.length >= 0.6) {
272
- const scope = commonDir(refs.filter((r) => r.startsWith(top.value + '/')));
308
+ // A folder that does not exist (a URL path, a placeholder) is not a place the section can move to.
309
+ const scope = sectionScope(s.text);
310
+ if (scope && (!exists || exists(scope))) {
273
311
  out.push({
274
312
  kind: 'scopable-section',
275
313
  severity: 'medium',
276
314
  tools,
277
315
  paths: [u.path],
278
- line: s.line,
316
+ line: raw(u, s.line),
279
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.`,
280
318
  savingTokens: tokens,
281
319
  savingNote: 'Saved on turns that do not touch that directory.',
@@ -287,7 +325,7 @@ function sections(units, threshold) {
287
325
  severity: 'medium',
288
326
  tools,
289
327
  paths: [u.path],
290
- line: s.line,
328
+ line: raw(u, s.line),
291
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.`,
292
330
  savingTokens: Math.max(0, tokens - 30),
293
331
  savingNote: 'Assumes about 30 tokens remain for the skill description or pointer.',
@@ -297,6 +335,17 @@ function sections(units, threshold) {
297
335
  }
298
336
  return out;
299
337
  }
338
+ /** The directory a section is mostly about (for example `packages/api`), or null when it is not scopable. */
339
+ export function sectionScope(text) {
340
+ if (countO200k(text) < 100)
341
+ return null;
342
+ const refs = pathRefs(text).filter((r) => !r.startsWith('.'));
343
+ const dirs = refs.map((r) => r.split('/')[0]);
344
+ const top = mostCommon(dirs);
345
+ if (!top || top.count < 2 || top.count / dirs.length < 0.6)
346
+ return null;
347
+ return commonDir(refs.filter((r) => r.startsWith(top.value + '/')));
348
+ }
300
349
  /** Longest directory prefix shared by all refs, e.g. `packages/api` for `packages/api/db.ts` and `packages/api/handlers/`. */
301
350
  function commonDir(refs) {
302
351
  const dirs = refs.map((r) => (r.endsWith('/') ? r.slice(0, -1) : r.slice(0, r.lastIndexOf('/'))).split('/'));
@@ -349,14 +398,16 @@ export function guessLanguage(text) {
349
398
  if (words.length < 15)
350
399
  return { nonEnglish: false, script: 'Latin' };
351
400
  const stop = words.filter((w) => EN_STOPWORDS.has(w)).length / words.length;
352
- const diacritics = letters.filter((c) => /[^\u0000-\u007F]/.test(c)).length / letters.length;
353
- 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' };
354
405
  }
355
406
  /** True when any heading section of the text looks non-English. */
356
407
  export function hasNonEnglish(text) {
357
408
  return splitSections(text).some((s) => guessLanguage(s.text).nonEnglish);
358
409
  }
359
- function nonEnglish(units, english) {
410
+ function nonEnglish(units, raw, english) {
360
411
  const out = [];
361
412
  for (const u of uniqueByPath(contentUnits(units, ['always']))) {
362
413
  const parts = splitSections(u.text).filter((s) => guessLanguage(s.text).nonEnglish);
@@ -386,7 +437,7 @@ function nonEnglish(units, english) {
386
437
  severity: 'low',
387
438
  tools,
388
439
  paths: [u.path],
389
- line: parts[0].line,
440
+ line: raw(u, parts[0].line),
390
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.`,
391
442
  savingTokens: null,
392
443
  savingNote: 'Not measured without an English version.',
@@ -400,7 +451,7 @@ function isNested(path) {
400
451
  const dir = dirname(path);
401
452
  return dir !== '' && !/^\.(claude|github|cursor|windsurf|devin|agents|clinerules|cline|roo|kiro|junie|continue)(\/|$)/.test(dir);
402
453
  }
403
- function staleReferences(units, opts) {
454
+ function staleReferences(units, opts, raw) {
404
455
  const out = [];
405
456
  for (const u of uniqueByPath(contentUnits(units))) {
406
457
  const tools = [...new Set(units.filter((x) => x.path === u.path && x.mode !== 'ignored').map((x) => x.tool))];
@@ -416,18 +467,21 @@ function staleReferences(units, opts) {
416
467
  const first = ref.split('/')[0];
417
468
  if (/^(path|example|foo|your|my)/i.test(first))
418
469
  continue;
470
+ // Paths with .. can point outside the repository, where nothing can be checked.
471
+ if (ref.split('/').includes('..'))
472
+ continue;
419
473
  // Only flag when the top-level folder exists but the file does not: likely moved or deleted.
420
474
  // Paths may be written relative to the repository root or to the instruction file.
421
475
  const local = resolvePath(u.path, ref);
422
476
  if (opts.exists(first) && !opts.exists(ref) && !(local && opts.exists(local)))
423
- missing.push({ ref, line, n: i + 1 });
477
+ missing.push({ ref, line, n: raw(u, i + 1) });
424
478
  }
425
479
  }
426
480
  // Root package.json scripts only apply to root-level instruction files, not nested packages.
427
481
  if (opts.packageScripts && !isNested(u.path)) {
428
482
  for (const m of line.matchAll(/\b(?:npm|pnpm|yarn|bun) run (?!-)([\w:.-]+)/g)) {
429
483
  if (!opts.packageScripts.has(m[1]))
430
- missing.push({ ref: `script "${m[1]}"`, line, n: i + 1 });
484
+ missing.push({ ref: `script "${m[1]}"`, line, n: raw(u, i + 1) });
431
485
  }
432
486
  }
433
487
  });
@@ -439,8 +493,8 @@ function staleReferences(units, opts) {
439
493
  severity: 'medium',
440
494
  tools,
441
495
  paths: [u.path],
442
- line: missing[0].n,
443
- 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(', ')}.`,
444
498
  savingTokens: countO200k(uniqueLines.join('\n')),
445
499
  savingNote: 'If those lines are obsolete; update them instead if they are still relevant.',
446
500
  });