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 +30 -5
- package/dist/cli.js +104 -24
- package/dist/core/advice.js +67 -21
- package/dist/core/fix.js +19 -8
- package/dist/core/glob.js +34 -0
- package/dist/core/render.js +63 -6
- package/dist/node/config.js +52 -0
- package/package.json +1 -1
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:
|
|
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)
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ?
|
|
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.
|
|
268
|
-
?
|
|
269
|
-
: o.
|
|
270
|
-
?
|
|
271
|
-
:
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
299
|
-
const plan =
|
|
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)
|
package/dist/core/advice.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
361
|
-
|
|
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
|
|
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) =>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
+
}
|
package/dist/core/render.js
CHANGED
|
@@ -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
|
|
335
|
-
* Starts with a marker so the GitHub Action can update its
|
|
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 >
|
|
342
|
-
full = full.slice(0,
|
|
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, '@​')}`),
|
|
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.
|
|
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",
|