turntax 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -3
- package/dist/bin.js +5 -0
- package/dist/cli.js +210 -0
- package/dist/core/advice.js +447 -0
- package/dist/core/audit.js +98 -0
- package/dist/core/classify.js +211 -0
- package/dist/core/files.js +74 -0
- package/dist/core/markdown.js +170 -0
- package/dist/core/prices.js +163 -0
- package/dist/core/render.js +143 -0
- package/dist/core/tokenize.js +21 -0
- package/dist/core/tools.js +301 -0
- package/dist/core/types.js +1 -0
- package/dist/node/discover.js +63 -0
- package/dist/node/remote.js +100 -0
- package/package.json +53 -6
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { dirname, linesWithFences, resolvePath, splitSections } from './markdown.js';
|
|
2
|
+
import { countO200k } from './tokenize.js';
|
|
3
|
+
import { TOOLS } from './tools.js';
|
|
4
|
+
const DEFAULT_SECTION_TOKENS = 600;
|
|
5
|
+
export function buildAdvice(units, files, opts = {}) {
|
|
6
|
+
const advice = [
|
|
7
|
+
...misconfigured(units),
|
|
8
|
+
...sizeLimits(units, files),
|
|
9
|
+
...duplicates(units),
|
|
10
|
+
...contradictions(units),
|
|
11
|
+
...sections(units, opts.sectionTokens ?? DEFAULT_SECTION_TOKENS),
|
|
12
|
+
...nonEnglish(units, opts.english),
|
|
13
|
+
...staleReferences(units, opts),
|
|
14
|
+
];
|
|
15
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
16
|
+
return advice.sort((a, b) => order[a.severity] - order[b.severity] || (b.savingTokens ?? 0) - (a.savingTokens ?? 0));
|
|
17
|
+
}
|
|
18
|
+
/** Units that carry real instruction text (not skill descriptions), grouped by path. */
|
|
19
|
+
function contentUnits(units, modes = ['always', 'on-demand']) {
|
|
20
|
+
return units.filter((u) => u.part !== 'description' && modes.includes(u.mode));
|
|
21
|
+
}
|
|
22
|
+
function uniqueByPath(units) {
|
|
23
|
+
const seen = new Map();
|
|
24
|
+
for (const u of units)
|
|
25
|
+
if (!seen.has(u.path))
|
|
26
|
+
seen.set(u.path, u);
|
|
27
|
+
return [...seen.values()];
|
|
28
|
+
}
|
|
29
|
+
function toolsWith(units, path, mode) {
|
|
30
|
+
return [...new Set(units.filter((u) => u.path === path && u.mode === mode && u.part !== 'description').map((u) => u.tool))];
|
|
31
|
+
}
|
|
32
|
+
function misconfigured(units) {
|
|
33
|
+
const byKey = new Map();
|
|
34
|
+
for (const u of units) {
|
|
35
|
+
if (!u.problem)
|
|
36
|
+
continue;
|
|
37
|
+
const key = `${u.path}\0${u.problem}`;
|
|
38
|
+
const prev = byKey.get(key);
|
|
39
|
+
if (prev)
|
|
40
|
+
prev.tools.push(u.tool);
|
|
41
|
+
else
|
|
42
|
+
byKey.set(key, {
|
|
43
|
+
kind: 'misconfigured',
|
|
44
|
+
severity: 'high',
|
|
45
|
+
tools: [u.tool],
|
|
46
|
+
paths: [u.path],
|
|
47
|
+
message: u.problem,
|
|
48
|
+
savingTokens: null,
|
|
49
|
+
savingNote: 'Not loaded today; fixing it adds context.',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return [...byKey.values()];
|
|
53
|
+
}
|
|
54
|
+
function sizeLimits(units, files) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
57
|
+
for (const tool of TOOLS) {
|
|
58
|
+
for (const limit of tool.limits) {
|
|
59
|
+
for (const u of uniqueByPath(contentUnits(units.filter((x) => x.tool === tool.id)))) {
|
|
60
|
+
const f = byPath.get(u.path);
|
|
61
|
+
if (!f || !limit.pattern.test(u.path))
|
|
62
|
+
continue;
|
|
63
|
+
let size;
|
|
64
|
+
let overflow;
|
|
65
|
+
if (limit.unit === 'lines') {
|
|
66
|
+
const lines = f.text.split(/\r?\n/);
|
|
67
|
+
size = lines.length;
|
|
68
|
+
overflow = lines.slice(limit.max).join('\n');
|
|
69
|
+
}
|
|
70
|
+
else if (limit.unit === 'chars') {
|
|
71
|
+
size = f.text.length;
|
|
72
|
+
overflow = f.text.slice(limit.max);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const bytes = new TextEncoder().encode(f.text);
|
|
76
|
+
size = bytes.length;
|
|
77
|
+
overflow = new TextDecoder().decode(bytes.slice(limit.max));
|
|
78
|
+
}
|
|
79
|
+
if (size <= limit.max)
|
|
80
|
+
continue;
|
|
81
|
+
const notes = {
|
|
82
|
+
truncate: `About ${countO200k(overflow)} tokens past the limit never reach the agent; move them somewhere it reads.`,
|
|
83
|
+
limit: `About ${countO200k(overflow)} tokens are over the tool's limit; shorten the file.`,
|
|
84
|
+
advice: 'Tokens beyond the recommended size.',
|
|
85
|
+
};
|
|
86
|
+
out.push({
|
|
87
|
+
kind: 'size-limit',
|
|
88
|
+
severity: limit.severity === 'advice' ? 'medium' : 'high',
|
|
89
|
+
tools: [tool.id],
|
|
90
|
+
paths: [u.path],
|
|
91
|
+
message: `${size} ${limit.unit} (limit ${limit.max}). ${limit.text} Source: ${limit.source}`,
|
|
92
|
+
savingTokens: limit.severity === 'advice' ? countO200k(overflow) : null,
|
|
93
|
+
savingNote: notes[limit.severity],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/** Lowercased, whitespace-collapsed instruction lines long enough to be meaningful. */
|
|
101
|
+
function normalizedLines(text) {
|
|
102
|
+
const out = new Map();
|
|
103
|
+
for (const { line, inFence } of linesWithFences(text)) {
|
|
104
|
+
if (inFence)
|
|
105
|
+
continue;
|
|
106
|
+
const norm = line
|
|
107
|
+
.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '')
|
|
108
|
+
.replace(/[*_`]/g, '')
|
|
109
|
+
.replace(/\s+/g, ' ')
|
|
110
|
+
.trim()
|
|
111
|
+
.toLowerCase();
|
|
112
|
+
if (norm.length >= 30 && !norm.startsWith('#'))
|
|
113
|
+
out.set(norm, line);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
function duplicates(units) {
|
|
118
|
+
const files = uniqueByPath(contentUnits(units, ['always', 'on-demand', 'ignored']));
|
|
119
|
+
const lines = files.map((u) => ({ path: u.path, lines: normalizedLines(u.text) }));
|
|
120
|
+
const out = [];
|
|
121
|
+
for (let i = 0; i < lines.length; i++) {
|
|
122
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
123
|
+
const a = lines[i];
|
|
124
|
+
const b = lines[j];
|
|
125
|
+
const shared = [...a.lines.keys()].filter((k) => b.lines.has(k));
|
|
126
|
+
if (!shared.length)
|
|
127
|
+
continue;
|
|
128
|
+
const tokens = countO200k(shared.map((k) => a.lines.get(k)).join('\n'));
|
|
129
|
+
if (shared.length < 3 && tokens < 50)
|
|
130
|
+
continue;
|
|
131
|
+
const bothAlways = toolsWith(units, a.path, 'always').filter((t) => toolsWith(units, b.path, 'always').includes(t));
|
|
132
|
+
out.push({
|
|
133
|
+
kind: 'duplicate',
|
|
134
|
+
severity: bothAlways.length ? 'high' : 'low',
|
|
135
|
+
tools: bothAlways,
|
|
136
|
+
paths: [a.path, b.path],
|
|
137
|
+
message: bothAlways.length
|
|
138
|
+
? `${shared.length} identical lines are loaded twice every turn by ${bothAlways.join(', ')}. Keep them in one file.`
|
|
139
|
+
: `${shared.length} identical lines are maintained in two files that different tools read. No tool pays twice, but the copies can drift; consider one source (for example a CLAUDE.md that imports @AGENTS.md).`,
|
|
140
|
+
savingTokens: bothAlways.length ? tokens : 0,
|
|
141
|
+
savingNote: bothAlways.length ? undefined : 'Maintenance only; no per-turn saving.',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
const DIRECTIVES = [
|
|
148
|
+
{
|
|
149
|
+
topic: 'package manager',
|
|
150
|
+
re: /\b(?:use|prefer)\s+(npm|pnpm|yarn|bun)\b(?!\s+(?:run|scripts?)\b)/gi,
|
|
151
|
+
value: (m) => m[1].toLowerCase(),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
topic: 'indentation',
|
|
155
|
+
re: /\b(?:indent(?:ation)?\s+(?:with|using|of)\s+|use\s+)(tabs|\d(?=[- ]?spaces?\b))/gi,
|
|
156
|
+
value: (m) => (m[1].toLowerCase() === 'tabs' ? 'tabs' : `${m[1]} spaces`),
|
|
157
|
+
scoped: true,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
topic: 'indentation',
|
|
161
|
+
re: /\b(\d)[- ]space indent(?:ation|s)?\b/gi,
|
|
162
|
+
value: (m) => `${m[1]} spaces`,
|
|
163
|
+
scoped: true,
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
topic: 'quotes',
|
|
167
|
+
re: /\b(?:use|prefer)\s+(single|double)\s+quotes\b/gi,
|
|
168
|
+
value: (m) => m[1].toLowerCase(),
|
|
169
|
+
scoped: true,
|
|
170
|
+
},
|
|
171
|
+
{ topic: 'semicolons', re: /\b(?:use|add|require)\s+semicolons\b/gi, value: () => 'semicolons' },
|
|
172
|
+
// Already negative: "no semicolons" means the same as "do not use semicolons".
|
|
173
|
+
{ topic: 'semicolons', re: /\b(?:no|omit|without)\s+semicolons\b/gi, value: () => 'not semicolons' },
|
|
174
|
+
];
|
|
175
|
+
/** Matches a negation right before a directive: "never use", "do not use", "don't prefer". */
|
|
176
|
+
const NEGATED = /\b(?:never|not|don['’]?t|avoid|no)\s+(?:\w+\s+)?$/i;
|
|
177
|
+
/** Words after "for"/"in" that do not name a language or file type, so they do not scope a style rule. */
|
|
178
|
+
const NOT_A_SCOPE = new Set('indentation indent this the all every each any code files file everything general new our your my repo repository project here it them'.split(' '));
|
|
179
|
+
function directivesIn(line) {
|
|
180
|
+
const found = [];
|
|
181
|
+
for (const d of DIRECTIVES) {
|
|
182
|
+
for (const m of line.matchAll(d.re)) {
|
|
183
|
+
let value = d.value(m);
|
|
184
|
+
if (!value.startsWith('not ') && NEGATED.test(line.slice(0, m.index)))
|
|
185
|
+
value = `not ${value}`;
|
|
186
|
+
const rest = line.slice((m.index ?? 0) + m[0].length);
|
|
187
|
+
const word = d.scoped
|
|
188
|
+
? /^[^.;]{0,25}?\b(?:for|in)\s+([A-Za-z][\w.+#-]*)/.exec(rest)?.[1]?.replace(/[.-]+$/, '')
|
|
189
|
+
: undefined;
|
|
190
|
+
const scope = word && !NOT_A_SCOPE.has(word.toLowerCase()) ? word : undefined;
|
|
191
|
+
found.push({ topic: scope ? `${d.topic} (${scope.toLowerCase()})` : d.topic, value });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return found;
|
|
195
|
+
}
|
|
196
|
+
/** "not x" conflicts only with "x"; two positive values conflict when they differ. */
|
|
197
|
+
function conflicts(a, b) {
|
|
198
|
+
if (a === b)
|
|
199
|
+
return false;
|
|
200
|
+
if (a.startsWith('not ') || b.startsWith('not '))
|
|
201
|
+
return a === `not ${b}` || b === `not ${a}`;
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
function contradictions(units) {
|
|
205
|
+
const found = new Map();
|
|
206
|
+
const tools = [...new Set(units.map((u) => u.tool))];
|
|
207
|
+
for (const tool of tools) {
|
|
208
|
+
const directives = [];
|
|
209
|
+
for (const u of uniqueByPath(contentUnits(units.filter((x) => x.tool === tool), ['always']))) {
|
|
210
|
+
linesWithFences(u.text).forEach(({ line, inFence }, i) => {
|
|
211
|
+
if (inFence)
|
|
212
|
+
return;
|
|
213
|
+
for (const d of directivesIn(line))
|
|
214
|
+
directives.push({ ...d, path: u.path, line: i + 1, text: line.trim() });
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
for (let i = 0; i < directives.length; i++) {
|
|
218
|
+
for (let j = i + 1; j < directives.length; j++) {
|
|
219
|
+
const a = directives[i];
|
|
220
|
+
const b = directives[j];
|
|
221
|
+
if (a.topic !== b.topic || !conflicts(a.value, b.value))
|
|
222
|
+
continue;
|
|
223
|
+
const key = [a.topic, `${a.path}:${a.line}`, `${b.path}:${b.line}`].join('|');
|
|
224
|
+
const prev = found.get(key);
|
|
225
|
+
if (prev) {
|
|
226
|
+
prev.tools.push(tool);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const shorter = a.text.length <= b.text.length ? a.text : b.text;
|
|
230
|
+
found.set(key, {
|
|
231
|
+
kind: 'contradiction',
|
|
232
|
+
severity: 'high',
|
|
233
|
+
tools: [tool],
|
|
234
|
+
paths: [...new Set([a.path, b.path])],
|
|
235
|
+
message: `Conflicting ${a.topic}: "${a.text}" (${a.path}:${a.line}) vs "${b.text}" (${b.path}:${b.line}).`,
|
|
236
|
+
savingTokens: countO200k(shorter),
|
|
237
|
+
savingNote: 'Removing the outdated line.',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return [...found.values()];
|
|
243
|
+
}
|
|
244
|
+
/** Path-like references: backticked paths, or bare tokens with a slash and a file extension / trailing slash. */
|
|
245
|
+
export function pathRefs(text) {
|
|
246
|
+
const refs = [];
|
|
247
|
+
for (const { line, inFence } of linesWithFences(text)) {
|
|
248
|
+
if (inFence)
|
|
249
|
+
continue;
|
|
250
|
+
for (const m of line.matchAll(/`([^`\s]+)`/g))
|
|
251
|
+
refs.push(m[1]);
|
|
252
|
+
const bare = line.replace(/`[^`]*`/g, ' ').replace(/https?:\/\/\S+/g, ' ');
|
|
253
|
+
for (const m of bare.matchAll(/(?:^|[\s(])((?:\.{0,2}[\w-][\w.-]*\/)+(?:[\w-][\w.-]*\.[a-z0-9]+|))(?=[\s),.;:]|$)/gi))
|
|
254
|
+
refs.push(m[1]);
|
|
255
|
+
}
|
|
256
|
+
return refs
|
|
257
|
+
.map((r) => r.replace(/^\.\//, '').replace(/[),.;:]+$/, ''))
|
|
258
|
+
.filter((r) => r.includes('/') && !/^(https?:|~|\/|[A-Za-z]:|@|<)/.test(r) && !/[*?{}<>$]/.test(r));
|
|
259
|
+
}
|
|
260
|
+
function sections(units, threshold) {
|
|
261
|
+
const out = [];
|
|
262
|
+
for (const u of uniqueByPath(contentUnits(units, ['always']))) {
|
|
263
|
+
const tools = toolsWith(units, u.path, 'always');
|
|
264
|
+
for (const s of splitSections(u.text)) {
|
|
265
|
+
const tokens = countO200k(s.text);
|
|
266
|
+
const refs = pathRefs(s.text).filter((r) => !r.startsWith('.'));
|
|
267
|
+
const dirs = refs.map((r) => r.split('/')[0]);
|
|
268
|
+
const top = mostCommon(dirs);
|
|
269
|
+
if (tokens >= 100 && top && top.count >= 2 && top.count / dirs.length >= 0.6) {
|
|
270
|
+
const scope = commonDir(refs.filter((r) => r.startsWith(top.value + '/')));
|
|
271
|
+
out.push({
|
|
272
|
+
kind: 'scopable-section',
|
|
273
|
+
severity: 'medium',
|
|
274
|
+
tools,
|
|
275
|
+
paths: [u.path],
|
|
276
|
+
line: s.line,
|
|
277
|
+
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.`,
|
|
278
|
+
savingTokens: tokens,
|
|
279
|
+
savingNote: 'Saved on turns that do not touch that directory.',
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
else if (tokens >= threshold) {
|
|
283
|
+
out.push({
|
|
284
|
+
kind: 'oversized-section',
|
|
285
|
+
severity: 'medium',
|
|
286
|
+
tools,
|
|
287
|
+
paths: [u.path],
|
|
288
|
+
line: s.line,
|
|
289
|
+
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.`,
|
|
290
|
+
savingTokens: Math.max(0, tokens - 30),
|
|
291
|
+
savingNote: 'Assumes about 30 tokens remain for the skill description or pointer.',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
/** Longest directory prefix shared by all refs, e.g. `packages/api` for `packages/api/db.ts` and `packages/api/handlers/`. */
|
|
299
|
+
function commonDir(refs) {
|
|
300
|
+
const dirs = refs.map((r) => (r.endsWith('/') ? r.slice(0, -1) : r.slice(0, r.lastIndexOf('/'))).split('/'));
|
|
301
|
+
const first = dirs[0] ?? [];
|
|
302
|
+
let n = first.length;
|
|
303
|
+
for (const d of dirs) {
|
|
304
|
+
let i = 0;
|
|
305
|
+
while (i < n && d[i] === first[i])
|
|
306
|
+
i++;
|
|
307
|
+
n = i;
|
|
308
|
+
}
|
|
309
|
+
return first.slice(0, Math.max(1, n)).join('/');
|
|
310
|
+
}
|
|
311
|
+
function mostCommon(values) {
|
|
312
|
+
const counts = new Map();
|
|
313
|
+
for (const v of values)
|
|
314
|
+
counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
315
|
+
let best = null;
|
|
316
|
+
for (const [value, count] of counts)
|
|
317
|
+
if (!best || count > best.count)
|
|
318
|
+
best = { value, count };
|
|
319
|
+
return best;
|
|
320
|
+
}
|
|
321
|
+
const EN_STOPWORDS = new Set('the a an and or of to in on for with is are be it this that as by at from not do does use when if you your we our all any must should can will before after into only than then'.split(' '));
|
|
322
|
+
const SCRIPTS = [
|
|
323
|
+
['Cyrillic', /\p{Script=Cyrillic}/u],
|
|
324
|
+
['Greek', /\p{Script=Greek}/u],
|
|
325
|
+
['Arabic', /\p{Script=Arabic}/u],
|
|
326
|
+
['Hebrew', /\p{Script=Hebrew}/u],
|
|
327
|
+
['Devanagari', /\p{Script=Devanagari}/u],
|
|
328
|
+
['Thai', /\p{Script=Thai}/u],
|
|
329
|
+
['Hangul', /\p{Script=Hangul}/u],
|
|
330
|
+
['CJK', /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u],
|
|
331
|
+
];
|
|
332
|
+
/** Script- and stopword-based guess. Deterministic; it does not name the language. */
|
|
333
|
+
export function guessLanguage(text) {
|
|
334
|
+
const prose = text
|
|
335
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
336
|
+
.replace(/`[^`]*`/g, ' ')
|
|
337
|
+
.replace(/https?:\/\/\S+/g, ' ');
|
|
338
|
+
const letters = [...prose].filter((c) => /\p{L}/u.test(c));
|
|
339
|
+
if (letters.length < 40)
|
|
340
|
+
return { nonEnglish: false, script: 'Latin' };
|
|
341
|
+
for (const [name, re] of SCRIPTS) {
|
|
342
|
+
const n = letters.filter((c) => re.test(c)).length;
|
|
343
|
+
if (n / letters.length > 0.2)
|
|
344
|
+
return { nonEnglish: true, script: name };
|
|
345
|
+
}
|
|
346
|
+
const words = prose.toLowerCase().match(/\p{L}+/gu) ?? [];
|
|
347
|
+
if (words.length < 15)
|
|
348
|
+
return { nonEnglish: false, script: 'Latin' };
|
|
349
|
+
const stop = words.filter((w) => EN_STOPWORDS.has(w)).length / words.length;
|
|
350
|
+
const diacritics = letters.filter((c) => /[^\u0000-\u007F]/.test(c)).length / letters.length;
|
|
351
|
+
return { nonEnglish: stop < 0.12 || (diacritics > 0.03 && stop < 0.2), script: 'Latin' };
|
|
352
|
+
}
|
|
353
|
+
/** True when any heading section of the text looks non-English. */
|
|
354
|
+
export function hasNonEnglish(text) {
|
|
355
|
+
return splitSections(text).some((s) => guessLanguage(s.text).nonEnglish);
|
|
356
|
+
}
|
|
357
|
+
function nonEnglish(units, english) {
|
|
358
|
+
const out = [];
|
|
359
|
+
for (const u of uniqueByPath(contentUnits(units, ['always']))) {
|
|
360
|
+
const parts = splitSections(u.text).filter((s) => guessLanguage(s.text).nonEnglish);
|
|
361
|
+
if (!parts.length)
|
|
362
|
+
continue;
|
|
363
|
+
const script = guessLanguage(parts.map((p) => p.text).join('\n')).script;
|
|
364
|
+
const tools = toolsWith(units, u.path, 'always');
|
|
365
|
+
const tokens = countO200k(parts.map((p) => p.text).join('\n'));
|
|
366
|
+
const en = english?.get(u.path);
|
|
367
|
+
if (en !== undefined) {
|
|
368
|
+
const fileTokens = countO200k(u.text);
|
|
369
|
+
const enTokens = countO200k(en);
|
|
370
|
+
const multiplier = enTokens ? fileTokens / enTokens : 0;
|
|
371
|
+
out.push({
|
|
372
|
+
kind: 'non-english',
|
|
373
|
+
severity: multiplier > 1.1 ? 'medium' : 'low',
|
|
374
|
+
tools,
|
|
375
|
+
paths: [u.path],
|
|
376
|
+
message: `${parts.length} section(s) in ${script} script look non-English. Measured: ${fileTokens} tokens vs ${enTokens} for the English version (x${multiplier.toFixed(2)}).`,
|
|
377
|
+
savingTokens: Math.max(0, fileTokens - enTokens),
|
|
378
|
+
savingNote: 'Measured against the English version you supplied or opted to translate.',
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
out.push({
|
|
383
|
+
kind: 'non-english',
|
|
384
|
+
severity: 'low',
|
|
385
|
+
tools,
|
|
386
|
+
paths: [u.path],
|
|
387
|
+
line: parts[0].line,
|
|
388
|
+
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.`,
|
|
389
|
+
savingTokens: null,
|
|
390
|
+
savingNote: 'Not measured without an English version.',
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
/** True for instruction files that live in a package subdirectory rather than at the root or in a root tool folder. */
|
|
397
|
+
function isNested(path) {
|
|
398
|
+
const dir = dirname(path);
|
|
399
|
+
return dir !== '' && !/^\.(claude|github|cursor|windsurf|devin|agents)(\/|$)/.test(dir);
|
|
400
|
+
}
|
|
401
|
+
function staleReferences(units, opts) {
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const u of uniqueByPath(contentUnits(units))) {
|
|
404
|
+
const tools = [...new Set(units.filter((x) => x.path === u.path && x.mode !== 'ignored').map((x) => x.tool))];
|
|
405
|
+
const missing = [];
|
|
406
|
+
linesWithFences(u.text).forEach(({ line, inFence }, i) => {
|
|
407
|
+
if (inFence)
|
|
408
|
+
return;
|
|
409
|
+
if (opts.exists) {
|
|
410
|
+
for (const ref of pathRefs(line)) {
|
|
411
|
+
// Directory mentions are often conventions ("put guides under docs/guides/"), so only files are checked.
|
|
412
|
+
if (!/\/[^/]+\.[A-Za-z0-9]+$/.test(ref))
|
|
413
|
+
continue;
|
|
414
|
+
const first = ref.split('/')[0];
|
|
415
|
+
if (/^(path|example|foo|your|my)/i.test(first))
|
|
416
|
+
continue;
|
|
417
|
+
// Only flag when the top-level folder exists but the file does not: likely moved or deleted.
|
|
418
|
+
// Paths may be written relative to the repository root or to the instruction file.
|
|
419
|
+
const local = resolvePath(u.path, ref);
|
|
420
|
+
if (opts.exists(first) && !opts.exists(ref) && !(local && opts.exists(local)))
|
|
421
|
+
missing.push({ ref, line, n: i + 1 });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// Root package.json scripts only apply to root-level instruction files, not nested packages.
|
|
425
|
+
if (opts.packageScripts && !isNested(u.path)) {
|
|
426
|
+
for (const m of line.matchAll(/\b(?:npm|pnpm|yarn|bun) run (?!-)([\w:.-]+)/g)) {
|
|
427
|
+
if (!opts.packageScripts.has(m[1]))
|
|
428
|
+
missing.push({ ref: `script "${m[1]}"`, line, n: i + 1 });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
if (!missing.length)
|
|
433
|
+
continue;
|
|
434
|
+
const uniqueLines = [...new Set(missing.map((m) => m.line))];
|
|
435
|
+
out.push({
|
|
436
|
+
kind: 'stale-reference',
|
|
437
|
+
severity: 'medium',
|
|
438
|
+
tools,
|
|
439
|
+
paths: [u.path],
|
|
440
|
+
line: missing[0].n,
|
|
441
|
+
message: `References that no longer exist: ${[...new Set(missing.map((m) => `${m.ref} (line ${m.n})`))].join(', ')}.`,
|
|
442
|
+
savingTokens: countO200k(uniqueLines.join('\n')),
|
|
443
|
+
savingNote: 'If those lines are obsolete; update them instead if they are still relevant.',
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { buildAdvice } from './advice.js';
|
|
2
|
+
import { classify, detectTools } from './classify.js';
|
|
3
|
+
import { countO200k, offlineCounter } from './tokenize.js';
|
|
4
|
+
import { TOOLS } from './tools.js';
|
|
5
|
+
export function modelsForTool(tool, models) {
|
|
6
|
+
const spec = TOOLS.find((t) => t.id === tool);
|
|
7
|
+
return spec.families.length ? models.filter((m) => spec.families.includes(m.family)) : models;
|
|
8
|
+
}
|
|
9
|
+
function counterFor(model, opts) {
|
|
10
|
+
if (model.family === 'claude' && opts.counters?.claude)
|
|
11
|
+
return opts.counters.claude;
|
|
12
|
+
if (model.family === 'gemini' && opts.counters?.gemini)
|
|
13
|
+
return opts.counters.gemini;
|
|
14
|
+
return offlineCounter;
|
|
15
|
+
}
|
|
16
|
+
export async function audit(files, opts) {
|
|
17
|
+
const turns = opts.turns ?? 100;
|
|
18
|
+
const allUnits = classify(files);
|
|
19
|
+
const toolIds = opts.tools ?? detectTools(allUnits);
|
|
20
|
+
const units = allUnits.filter((u) => toolIds.includes(u.tool));
|
|
21
|
+
// Count each distinct text once per model, only for the tools that run that model.
|
|
22
|
+
const counts = new Map();
|
|
23
|
+
for (const model of opts.models) {
|
|
24
|
+
const relevant = units.filter((u) => u.mode !== 'ignored' && modelsForTool(u.tool, opts.models).some((m) => m.id === model.id));
|
|
25
|
+
const texts = [...new Set(relevant.map((u) => u.text))];
|
|
26
|
+
const results = await counterFor(model, opts).count(model, texts);
|
|
27
|
+
counts.set(model.id, new Map(texts.map((t, i) => [t, results[i]])));
|
|
28
|
+
}
|
|
29
|
+
const files_ = units.map((u) => {
|
|
30
|
+
const row = {
|
|
31
|
+
tool: u.tool,
|
|
32
|
+
path: u.path,
|
|
33
|
+
part: u.part,
|
|
34
|
+
mode: u.mode,
|
|
35
|
+
trigger: u.trigger,
|
|
36
|
+
notes: u.notes,
|
|
37
|
+
o200k: countO200k(u.text),
|
|
38
|
+
counts: {},
|
|
39
|
+
};
|
|
40
|
+
if (u.importedFrom)
|
|
41
|
+
row.importedFrom = u.importedFrom;
|
|
42
|
+
if (u.mode !== 'ignored')
|
|
43
|
+
for (const m of modelsForTool(u.tool, opts.models)) {
|
|
44
|
+
const c = counts.get(m.id)?.get(u.text);
|
|
45
|
+
if (c)
|
|
46
|
+
row.counts[m.id] = c;
|
|
47
|
+
}
|
|
48
|
+
return row;
|
|
49
|
+
});
|
|
50
|
+
const tools = toolIds.map((id) => {
|
|
51
|
+
const spec = TOOLS.find((t) => t.id === id);
|
|
52
|
+
const rows = files_.filter((r) => r.tool === id);
|
|
53
|
+
const always = rows.filter((r) => r.mode === 'always');
|
|
54
|
+
const onDemand = rows.filter((r) => r.mode === 'on-demand');
|
|
55
|
+
const models = modelsForTool(id, opts.models).map((m) => modelCost(m, always, turns));
|
|
56
|
+
return {
|
|
57
|
+
tool: id,
|
|
58
|
+
name: spec.name,
|
|
59
|
+
alwaysFiles: new Set(always.map((r) => r.path)).size,
|
|
60
|
+
onDemandFiles: new Set(onDemand.map((r) => r.path)).size,
|
|
61
|
+
perTurnO200k: sum(always.map((r) => r.o200k)),
|
|
62
|
+
onDemandO200k: sum(onDemand.map((r) => r.o200k)),
|
|
63
|
+
models,
|
|
64
|
+
notes: spec.notes,
|
|
65
|
+
sources: spec.sources,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
return { turns, tools, files: files_, advice: buildAdvice(units, files, opts.advice), models: opts.models };
|
|
69
|
+
}
|
|
70
|
+
function modelCost(model, always, turns) {
|
|
71
|
+
const counts = always.map((r) => r.counts[model.id]);
|
|
72
|
+
const missing = counts.find((c) => c === undefined || c.tokens === null);
|
|
73
|
+
if (counts.length && missing !== undefined) {
|
|
74
|
+
return {
|
|
75
|
+
model: model.id,
|
|
76
|
+
label: model.label,
|
|
77
|
+
perTurn: null,
|
|
78
|
+
method: 'unavailable',
|
|
79
|
+
reason: missing?.reason ?? 'not counted',
|
|
80
|
+
uncached: null,
|
|
81
|
+
cached: null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const perTurn = sum(counts.map((c) => c.tokens));
|
|
85
|
+
const method = counts[0]?.method ?? 'none-loaded';
|
|
86
|
+
const price = (p) => (p === null ? null : (perTurn * turns * p) / 1_000_000);
|
|
87
|
+
return {
|
|
88
|
+
model: model.id,
|
|
89
|
+
label: model.label,
|
|
90
|
+
perTurn,
|
|
91
|
+
method,
|
|
92
|
+
uncached: price(model.inputPerMTok),
|
|
93
|
+
cached: price(model.cachedInputPerMTok),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function sum(values) {
|
|
97
|
+
return values.reduce((a, b) => a + b, 0);
|
|
98
|
+
}
|