ultracost 0.2.0 → 0.3.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/src/guard.js CHANGED
@@ -1,154 +1,248 @@
1
1
  import { existsSync, readFileSync, readdirSync, lstatSync, writeFileSync } from 'node:fs';
2
2
  import { join, extname, sep } from 'node:path';
3
3
  import { classifyModel, tierModel } from './policy.js';
4
+ import { tokenize, TT, lineColAt } from './lexer.js';
5
+ import { semanticFindings } from './classify.js';
4
6
 
5
7
  export const CODES = {
6
8
  NOOPTS: 'UC001', // agent(x) with no options object
7
9
  MISSING: 'UC002', // options object present but no model key
8
10
  BANNED: 'UC003', // model resolves to a neverUse model (e.g. haiku)
9
11
  INHERIT: 'UC004', // model: 'inherit' while allowInherit is false
10
- DYNAMIC: 'UC005' // model/options is a variable; can't verify statically
12
+ DYNAMIC: 'UC005', // model/options is a non-literal expression; can't verify statically
13
+ WRONGTIER: 'UC006', // pinned model disagrees with the work the prompt describes
14
+ OVEREFFORT: 'UC007', // effort exceeds the model's cap (or the work's complexity)
15
+ ALWAYSOPUS: 'UC008' // a policy.alwaysOpus role is pinned to a non-default tier
11
16
  };
12
17
 
13
- // agent( as a call — not subagent(, myagent(, or obj.agent(.
14
- const SPAWN_RE = /(?<![\w.$])agent\s*\(/g;
18
+ const CLOSERS = new Set([')', ']', '}']);
19
+ const isPunct = (t, v) => t && t.type === TT.PUNCT && (v === undefined || t.value === v);
15
20
 
16
- const MODEL_LITERAL = /\bmodel\s*:\s*(['"`])([\w.\-]+)\1/;
17
- const MODEL_DYNAMIC = /\bmodel\s*:/;
18
- const EFFORT_LITERAL = /\beffort\s*:\s*(['"`])(\w+)\1/;
19
- // A fan-out: the agent() sits inside an open .map/.flatMap/Array.from call, or inside a
20
- // pipeline(items, ...stages) call (every stage runs once per item) — so its count is a
21
- // runtime (unknown) multiple of the collection length. parallel([...]) of literal thunks
22
- // is NOT a fan-out (fixed count); parallel(items.map(...)) is caught by the .map rule.
23
- const FANOUT_CALLEE = /(?:^|\.)(?:map|flatMap)$/;
24
- const isFanoutCallee = (name) => FANOUT_CALLEE.test(name) || name === 'Array.from' || name === 'pipeline';
21
+ // Every real `agent` call site: a NAME 'agent' that is not a member access
22
+ // (`obj.agent`) and is immediately called — directly or through optional chaining
23
+ // (`agent?.(`). Yields { nameIdx, openIdx } where openIdx is the '(' token.
24
+ function* agentCalls(tokens) {
25
+ for (let k = 0; k < tokens.length; k++) {
26
+ const t = tokens[k];
27
+ if (t.type !== TT.NAME || t.value !== 'agent') continue;
28
+ const before = tokens[k - 1];
29
+ if (isPunct(before, '.') || isPunct(before, '?.')) continue; // member access
30
+ let p = k + 1;
31
+ if (isPunct(tokens[p], '?.')) p++;
32
+ if (!isPunct(tokens[p], '(')) continue;
33
+ yield { nameIdx: k, openIdx: p };
34
+ }
35
+ }
25
36
 
26
- // Spans inside string/template literals and // or /* */ comments. A literal
27
- // "agent(" there is prose (e.g. a prompt string) and must not count as a call.
28
- function ignorableRanges(text) {
29
- const ranges = [];
30
- const n = text.length;
31
- let i = 0;
32
- while (i < n) {
33
- const ch = text[i];
34
- const next = text[i + 1];
35
- if (ch === '/' && next === '/') {
36
- const start = i;
37
- i += 2;
38
- while (i < n && text[i] !== '\n') i++;
39
- ranges.push([start, i]);
40
- } else if (ch === '/' && next === '*') {
41
- const start = i;
42
- i += 2;
43
- while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i++;
44
- i = Math.min(n, i + 2);
45
- ranges.push([start, i]);
46
- } else if (ch === "'" || ch === '"' || ch === '`') {
47
- const start = i;
48
- i++;
49
- while (i < n) {
50
- if (text[i] === '\\') { i += 2; continue; }
51
- if (text[i] === ch) { i++; break; }
52
- i++;
53
- }
54
- ranges.push([start, i]);
37
+ // From the call's '(' token, split the argument list into per-argument token arrays
38
+ // (top-level commas only) and return the index of the matching ')'.
39
+ function readArgs(tokens, openIdx) {
40
+ const args = [];
41
+ let cur = [];
42
+ let depth = 0;
43
+ for (let j = openIdx; j < tokens.length; j++) {
44
+ const t = tokens[j];
45
+ if (isPunct(t, '(') || isPunct(t, '[') || isPunct(t, '{')) {
46
+ depth++;
47
+ if (depth > 1) cur.push(t);
48
+ } else if (t.type === TT.PUNCT && CLOSERS.has(t.value)) {
49
+ depth--;
50
+ if (depth === 0) { if (cur.length) args.push(cur); return { args, closeIdx: j }; }
51
+ cur.push(t);
52
+ } else if (isPunct(t, ',') && depth === 1) {
53
+ args.push(cur); cur = [];
55
54
  } else {
56
- i++;
55
+ cur.push(t);
57
56
  }
58
57
  }
59
- return ranges;
58
+ if (cur.length) args.push(cur);
59
+ return { args, closeIdx: tokens.length - 1 };
60
60
  }
61
61
 
62
- function inRanges(ranges, idx) {
63
- let lo = 0;
64
- let hi = ranges.length - 1;
65
- while (lo <= hi) {
66
- const mid = (lo + hi) >> 1;
67
- const [s, e] = ranges[mid];
68
- if (idx < s) hi = mid - 1;
69
- else if (idx >= e) lo = mid + 1;
70
- else return true;
62
+ // The literal text of a (possibly concatenated) prompt argument, or null if it has
63
+ // no string/template-literal part (a fully dynamic prompt).
64
+ function literalText(argTokens) {
65
+ if (!argTokens) return null;
66
+ let text = '';
67
+ let found = false;
68
+ for (const t of argTokens) {
69
+ if (t.type === TT.STRING || (t.type === TT.TEMPLATE && t.value !== null)) {
70
+ text += (found ? ' ' : '') + t.value;
71
+ found = true;
72
+ }
73
+ }
74
+ return found ? text : null;
75
+ }
76
+
77
+ function classifyValue(valTokens) {
78
+ if (valTokens.length === 1) {
79
+ const v = valTokens[0];
80
+ if (v.type === TT.STRING) return { kind: 'literal', value: v.value };
81
+ if (v.type === TT.TEMPLATE && v.value !== null) return { kind: 'literal', value: v.value };
71
82
  }
72
- return false;
83
+ return { kind: 'dynamic' };
73
84
  }
74
85
 
75
- function lineCol(text, index) {
76
- let line = 1;
77
- let last = 0;
78
- for (let i = 0; i < index; i++) {
79
- if (text[i] === '\n') {
80
- line++;
81
- last = i + 1;
86
+ // Parse the options argument: is it an object literal, does it spread, and what are
87
+ // the model/effort property values (literal vs dynamic)?
88
+ function parseOptions(argTokens) {
89
+ if (!argTokens || !argTokens.length) return { isObject: false };
90
+ if (!isPunct(argTokens[0], '{')) return { isObject: false, dynamic: true };
91
+
92
+ let depth = 0;
93
+ let hasSpread = false;
94
+ const props = {};
95
+ for (let j = 0; j < argTokens.length; j++) {
96
+ const t = argTokens[j];
97
+ if (isPunct(t, '{') || isPunct(t, '[') || isPunct(t, '(')) { depth++; continue; }
98
+ if (t.type === TT.PUNCT && CLOSERS.has(t.value)) { depth--; continue; }
99
+ if (depth !== 1) continue;
100
+ if (isPunct(t, '...')) { hasSpread = true; continue; }
101
+ if (t.type !== TT.NAME && t.type !== TT.STRING) continue;
102
+ const key = t.value;
103
+ if (key !== 'model' && key !== 'effort') continue;
104
+ const colon = argTokens[j + 1];
105
+ if (!isPunct(colon, ':')) { // shorthand { model } -> a variable
106
+ if (!(key in props)) props[key] = { kind: 'dynamic' };
107
+ continue;
108
+ }
109
+ const valTokens = [];
110
+ let d2 = 1;
111
+ let m = j + 2;
112
+ for (; m < argTokens.length; m++) {
113
+ const v = argTokens[m];
114
+ if (isPunct(v, '{') || isPunct(v, '[') || isPunct(v, '(')) { d2++; valTokens.push(v); continue; }
115
+ if (v.type === TT.PUNCT && CLOSERS.has(v.value)) { d2--; if (d2 === 0) break; valTokens.push(v); continue; }
116
+ if (isPunct(v, ',') && d2 === 1) break;
117
+ valTokens.push(v);
82
118
  }
119
+ if (!(key in props)) props[key] = classifyValue(valTokens);
120
+ j = m - 1;
83
121
  }
84
- return { line, column: index - last + 1 };
122
+ return { isObject: true, hasSpread, model: props.model, effort: props.effort };
85
123
  }
86
124
 
87
- function lineText(text, index) {
125
+ function snippetAt(text, index) {
88
126
  const start = text.lastIndexOf('\n', index) + 1;
89
127
  let end = text.indexOf('\n', index);
90
128
  if (end === -1) end = text.length;
91
129
  return text.slice(start, end).trim();
92
130
  }
93
131
 
94
- // From the '(' index, return the argument source up to the matching ')'.
95
- function captureArgs(text, openIdx) {
96
- let depth = 0;
97
- let str = null;
98
- for (let i = openIdx; i < text.length; i++) {
99
- const ch = text[i];
100
- const prev = text[i - 1];
101
- if (str) {
102
- if (ch === str && prev !== '\\') str = null;
103
- continue;
104
- }
105
- if (ch === '"' || ch === "'" || ch === '`') {
106
- str = ch;
107
- continue;
132
+ const FANOUT_CALLEE = /(?:^|\.)(?:map|flatMap|forEach)$/;
133
+ const isFanoutCallee = (name) =>
134
+ FANOUT_CALLEE.test(name) || name === 'Array.from' || name === 'pipeline' || name === 'Promise.all';
135
+
136
+ // The member chain (e.g. "files.map", "Promise.all") that owns the '(' at openIdx, or null.
137
+ function calleeBefore(tokens, openIdx) {
138
+ let j = openIdx - 1;
139
+ if (j < 0 || tokens[j].type !== TT.NAME) return null;
140
+ const parts = [tokens[j].value];
141
+ j--;
142
+ while (j - 1 >= 0 && isPunct(tokens[j]) && (tokens[j].value === '.' || tokens[j].value === '?.') && tokens[j - 1].type === TT.NAME) {
143
+ parts.unshift(tokens[j - 1].value);
144
+ j -= 2;
145
+ }
146
+ return parts.join('.');
147
+ }
148
+
149
+ // Names of call expressions whose bracket is still open at uptoIdx.
150
+ function enclosingCallees(tokens, uptoIdx) {
151
+ const stack = [];
152
+ for (let i = 0; i < uptoIdx; i++) {
153
+ const t = tokens[i];
154
+ if (isPunct(t, '(')) stack.push(calleeBefore(tokens, i));
155
+ else if (isPunct(t, '[') || isPunct(t, '{')) stack.push(null);
156
+ else if (t.type === TT.PUNCT && CLOSERS.has(t.value)) stack.pop();
157
+ }
158
+ return stack.filter(Boolean);
159
+ }
160
+
161
+ // Token-index ranges that are the body of a for/while loop (each agent() inside one
162
+ // runs once per iteration — a fan-out of unknown size).
163
+ function loopBodyRanges(tokens) {
164
+ const ranges = [];
165
+ for (let i = 0; i < tokens.length; i++) {
166
+ const t = tokens[i];
167
+ if (t.type !== TT.NAME || (t.value !== 'for' && t.value !== 'while')) continue;
168
+ let p = i + 1;
169
+ if (isPunct(tokens[p], '?.')) p++;
170
+ if (tokens[p] && tokens[p].type === TT.NAME && tokens[p].value === 'await') p++; // for await
171
+ if (!isPunct(tokens[p], '(')) continue;
172
+ let depth = 0;
173
+ let q = p;
174
+ for (; q < tokens.length; q++) {
175
+ const v = tokens[q];
176
+ if (isPunct(v, '(') || isPunct(v, '[') || isPunct(v, '{')) depth++;
177
+ else if (v.type === TT.PUNCT && CLOSERS.has(v.value)) { depth--; if (depth === 0) break; }
108
178
  }
109
- if (ch === '(') depth++;
110
- else if (ch === ')') {
111
- depth--;
112
- if (depth === 0) return { args: text.slice(openIdx + 1, i), end: i };
179
+ const b = q + 1;
180
+ if (isPunct(tokens[b], '{')) {
181
+ let d2 = 0;
182
+ for (let r = b; r < tokens.length; r++) {
183
+ const v = tokens[r];
184
+ if (isPunct(v, '(') || isPunct(v, '[') || isPunct(v, '{')) d2++;
185
+ else if (v.type === TT.PUNCT && CLOSERS.has(v.value)) { d2--; if (d2 === 0) { ranges.push([b, r]); break; } }
186
+ }
187
+ } else {
188
+ for (let r = b; r < tokens.length; r++) {
189
+ if (isPunct(tokens[r], ';') || r === tokens.length - 1) { ranges.push([b, r]); break; }
190
+ }
113
191
  }
114
192
  }
115
- return { args: text.slice(openIdx + 1), end: text.length };
193
+ return ranges;
116
194
  }
117
195
 
118
196
  // Count every real agent() stage and collect the subset that are problems.
119
197
  export function analyze(text, policy, file = '<text>') {
120
- const ranges = ignorableRanges(text);
198
+ const tokens = tokenize(text);
199
+ const loops = loopBodyRanges(tokens);
121
200
  const findings = [];
122
201
  let stages = 0;
123
- for (const m of text.matchAll(SPAWN_RE)) {
124
- if (inRanges(ranges, m.index)) continue;
202
+
203
+ for (const { nameIdx, openIdx } of agentCalls(tokens)) {
125
204
  stages++;
126
- const openIdx = m.index + m[0].length - 1;
127
- const { args } = captureArgs(text, openIdx);
128
- const { line, column } = lineCol(text, m.index);
129
- const base = { file, line, column, snippet: lineText(text, m.index) };
205
+ const start = tokens[nameIdx].start;
206
+ const { line, column } = lineColAt(text, start);
207
+ const base = { file, line, column, snippet: snippetAt(text, start) };
208
+ const { args } = readArgs(tokens, openIdx);
209
+ const o = parseOptions(args[1]);
130
210
 
131
- const literal = args.match(MODEL_LITERAL);
132
- if (literal) {
133
- const value = literal[2];
134
- const verdict = classifyModel(value, policy);
135
- if (verdict === 'banned') {
136
- findings.push({ ...base, code: CODES.BANNED, severity: 'error', model: value, message: `stage pins banned model "${value}" (policy.neverUse)` });
137
- } else if (verdict === 'inherit') {
138
- findings.push({ ...base, code: CODES.INHERIT, severity: 'error', model: value, message: `stage uses model: 'inherit' (allowInherit is false)` });
211
+ if (o.isObject) {
212
+ const model = o.model;
213
+ if (model && model.kind === 'literal') {
214
+ const verdict = classifyModel(model.value, policy);
215
+ if (verdict === 'banned') {
216
+ findings.push({ ...base, code: CODES.BANNED, severity: 'error', model: model.value, message: `stage pins banned model "${model.value}" (policy.neverUse)` });
217
+ continue;
218
+ }
219
+ if (verdict === 'inherit') {
220
+ findings.push({ ...base, code: CODES.INHERIT, severity: 'error', model: model.value, message: `stage uses model: 'inherit' (allowInherit is false)` });
221
+ continue;
222
+ }
223
+ } else if (model && model.kind === 'dynamic') {
224
+ findings.push({ ...base, code: CODES.DYNAMIC, severity: 'warn', message: 'stage model is a dynamic expression — cannot statically verify a valid model is pinned' });
225
+ continue;
226
+ } else if (o.hasSpread) {
227
+ findings.push({ ...base, code: CODES.DYNAMIC, severity: 'warn', message: 'stage options spread a variable and pin no literal model — cannot verify a model is pinned' });
228
+ continue;
229
+ } else {
230
+ findings.push({ ...base, code: CODES.MISSING, severity: 'error', message: 'stage options object has no model — will inherit the session model' });
231
+ continue;
139
232
  }
140
- continue;
141
- }
142
- if (MODEL_DYNAMIC.test(args)) continue;
143
-
144
- const hasObjectLiteral = /\{[\s\S]*\}/.test(args);
145
- const secondArgIsIdentifier = /,\s*[A-Za-z_$][\w$]*\s*$/.test(args.trimEnd());
146
- if (hasObjectLiteral) {
147
- findings.push({ ...base, code: CODES.MISSING, severity: 'error', message: 'stage options object has no model — will inherit the session model' });
148
- } else if (secondArgIsIdentifier) {
233
+ } else if (o.dynamic) {
149
234
  findings.push({ ...base, code: CODES.DYNAMIC, severity: 'warn', message: 'stage options passed as a variable — cannot verify a model is pinned' });
235
+ continue;
150
236
  } else {
151
237
  findings.push({ ...base, code: CODES.NOOPTS, severity: 'error', message: 'stage has no options object — add { model: ... } so it does not inherit the session model' });
238
+ continue;
239
+ }
240
+
241
+ // The model is a valid literal pin: run the semantic (advisory) checks.
242
+ const prompt = literalText(args[0]);
243
+ const effort = o.effort && o.effort.kind === 'literal' ? o.effort.value : null;
244
+ for (const f of semanticFindings({ model: o.model.value, effort, prompt }, policy, CODES)) {
245
+ findings.push({ ...base, ...f });
152
246
  }
153
247
  }
154
248
  return { stages, findings };
@@ -158,47 +252,31 @@ export function scanText(text, policy, file = '<text>') {
158
252
  return analyze(text, policy, file).findings;
159
253
  }
160
254
 
161
- // Names of call expressions whose '(' is still open at `idx` (string/comment-aware).
162
- // e.g. inside `files.map(f => agent(...))`, at the agent( the stack holds "files.map".
163
- function enclosingCalls(text, ranges, idx) {
164
- const stack = [];
165
- for (let i = 0; i < idx; i++) {
166
- if (inRanges(ranges, i)) continue;
167
- const ch = text[i];
168
- if (ch === '(') {
169
- let j = i - 1;
170
- while (j >= 0 && /\s/.test(text[j])) j--;
171
- const end = j;
172
- while (j >= 0 && /[\w$.]/.test(text[j])) j--;
173
- stack.push(text.slice(j + 1, end + 1));
174
- } else if (ch === ')') {
175
- stack.pop();
176
- }
177
- }
178
- return stack;
179
- }
180
-
181
- // Per-stage descriptors for cost estimation: each real agent() stage with its
182
- // pinned model (or null = inherits session model), pinned effort (or null), and
183
- // whether it is a fan-out (unknown runtime multiplier).
255
+ // Per-stage descriptors for cost estimation: pinned model (or null = inherits the
256
+ // session model), pinned effort (or null), whether it is a fan-out, and the literal
257
+ // prompt text (for calibration / explain).
184
258
  export function stageList(text) {
185
- const ranges = ignorableRanges(text);
259
+ const tokens = tokenize(text);
260
+ const loops = loopBodyRanges(tokens);
186
261
  const out = [];
187
- for (const m of text.matchAll(SPAWN_RE)) {
188
- if (inRanges(ranges, m.index)) continue;
189
- const openIdx = m.index + m[0].length - 1;
190
- const { args } = captureArgs(text, openIdx);
191
- const { line } = lineCol(text, m.index);
192
- const ml = args.match(MODEL_LITERAL);
193
- const el = args.match(EFFORT_LITERAL);
194
- const fanout = enclosingCalls(text, ranges, m.index).some(isFanoutCallee);
195
- out.push({
196
- line,
197
- model: ml ? ml[2] : null,
198
- dynamicModel: !ml && MODEL_DYNAMIC.test(args),
199
- effort: el ? el[2] : null,
200
- fanout
201
- });
262
+ for (const { nameIdx, openIdx } of agentCalls(tokens)) {
263
+ const { line } = lineColAt(text, tokens[nameIdx].start);
264
+ const { args } = readArgs(tokens, openIdx);
265
+ const o = parseOptions(args[1]);
266
+ let model = null;
267
+ let dynamicModel = false;
268
+ let effort = null;
269
+ if (o.isObject) {
270
+ if (o.model?.kind === 'literal') model = o.model.value;
271
+ else if (o.model?.kind === 'dynamic' || o.hasSpread) dynamicModel = true;
272
+ if (o.effort?.kind === 'literal') effort = o.effort.value;
273
+ } else if (o.dynamic) {
274
+ dynamicModel = true;
275
+ }
276
+ const fanout =
277
+ enclosingCallees(tokens, nameIdx).some(isFanoutCallee) ||
278
+ loops.some(([a, b]) => nameIdx >= a && nameIdx <= b);
279
+ out.push({ line, model, dynamicModel, effort, fanout, prompt: literalText(args[0]) });
202
280
  }
203
281
  return out;
204
282
  }
@@ -207,9 +285,9 @@ const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts']);
207
285
 
208
286
  export function collectFiles(target) {
209
287
  if (!existsSync(target)) return [];
210
- // lstat (not stat) so a symlink is reported as a symlink rather than its
211
- // target — never follow/recurse one (avoids symlink loops blowing the stack,
212
- // and stops --fix writing through a link to a file outside the scanned tree).
288
+ // lstat (not stat) so a symlink is reported as a symlink rather than its target —
289
+ // never follow/recurse one (avoids symlink loops blowing the stack, and stops --fix
290
+ // writing through a link to a file outside the scanned tree).
213
291
  const st = lstatSync(target);
214
292
  if (st.isSymbolicLink()) return [];
215
293
  if (st.isFile()) return [target];
@@ -246,7 +324,7 @@ export function scan(target, policy) {
246
324
 
247
325
  export function auditScripts(base, policy) {
248
326
  const files = collectWorkflowScripts(base);
249
- const totals = { scripts: files.length, stages: 0, pinned: 0, unpinned: 0, banned: 0, inherit: 0, dynamic: 0 };
327
+ const totals = { scripts: files.length, stages: 0, pinned: 0, unpinned: 0, banned: 0, inherit: 0, dynamic: 0, wrongTier: 0, overEffort: 0 };
250
328
  for (const f of files) {
251
329
  const { stages, findings } = analyze(readFileSync(f, 'utf8'), policy, f);
252
330
  totals.stages += stages;
@@ -255,39 +333,39 @@ export function auditScripts(base, policy) {
255
333
  else if (x.code === CODES.BANNED) totals.banned++;
256
334
  else if (x.code === CODES.INHERIT) totals.inherit++;
257
335
  else if (x.code === CODES.DYNAMIC) totals.dynamic++;
336
+ else if (x.code === CODES.WRONGTIER || x.code === CODES.ALWAYSOPUS) totals.wrongTier++;
337
+ else if (x.code === CODES.OVEREFFORT) totals.overEffort++;
258
338
  }
259
339
  }
340
+ // pinned = stages that produced no pin-related finding (semantic warnings don't unpin).
260
341
  totals.pinned = totals.stages - totals.unpinned - totals.banned - totals.inherit - totals.dynamic;
261
342
  totals.unpinnedRatio = totals.stages ? totals.unpinned / totals.stages : 0;
262
343
  return { base, files, totals };
263
344
  }
264
345
 
265
- // Insert the default model on the unambiguous cases, back-to-front so earlier
266
- // edits don't shift later offsets.
346
+ // Insert the default model on the unambiguous cases (UC001 no-options, UC002 object
347
+ // without a model), back-to-front so earlier edits don't shift later offsets.
267
348
  export function fixText(text, policy) {
268
349
  const model = tierModel(policy.default, policy);
269
- const ranges = ignorableRanges(text);
350
+ const tokens = tokenize(text);
270
351
  const sites = [];
271
- for (const m of text.matchAll(SPAWN_RE)) {
272
- if (inRanges(ranges, m.index)) continue;
273
- const openIdx = m.index + m[0].length - 1;
274
- const { args, end } = captureArgs(text, openIdx);
275
- if (MODEL_DYNAMIC.test(args)) continue;
276
- const objStart = text.indexOf('{', openIdx);
277
- if (objStart !== -1 && objStart < end) {
278
- sites.push({ type: 'insert', at: objStart + 1 });
279
- } else {
280
- sites.push({ type: 'append', at: end });
352
+ for (const { openIdx } of agentCalls(tokens)) {
353
+ const { args, closeIdx } = readArgs(tokens, openIdx);
354
+ const o = parseOptions(args[1]);
355
+ if (o.isObject) {
356
+ const fixable = !o.model && !o.hasSpread; // UC002 only; never touch dynamic/spread/banned
357
+ if (!fixable) continue;
358
+ const brace = args[1].find((t) => isPunct(t, '{'));
359
+ sites.push({ type: 'insert', at: brace.end });
360
+ } else if (!o.dynamic) {
361
+ sites.push({ type: 'append', at: tokens[closeIdx].start });
281
362
  }
282
363
  }
283
364
  let out = text;
284
365
  let count = 0;
285
366
  for (const s of sites.sort((a, b) => b.at - a.at)) {
286
- if (s.type === 'insert') {
287
- out = out.slice(0, s.at) + ` model: '${model}',` + out.slice(s.at);
288
- } else {
289
- out = out.slice(0, s.at) + `, { model: '${model}' }` + out.slice(s.at);
290
- }
367
+ if (s.type === 'insert') out = out.slice(0, s.at) + ` model: '${model}',` + out.slice(s.at);
368
+ else out = out.slice(0, s.at) + `, { model: '${model}' }` + out.slice(s.at);
291
369
  count++;
292
370
  }
293
371
  return { text: out, count };
package/src/index.js CHANGED
@@ -1,7 +1,13 @@
1
1
  export { loadPolicy, normalize, classifyModel, tierModel } from './policy.js';
2
2
  export { scan, scanText, analyze, stageList, fixText, fixFile, collectFiles, collectWorkflowScripts, auditScripts, CODES } from './guard.js';
3
+ export { tokenize, TT, lineColAt } from './lexer.js';
4
+ export { classifyPrompt, semanticFindings, tierOfModel } from './classify.js';
3
5
  export { estimateText, estimateFile, priceKey } from './estimate.js';
4
6
  export { refreshPricing, parsePrices, assertPlausible, writePricingToPolicy, DEFAULT_PRICING_URL } from './pricing.js';
5
- export { compileRules, replaceBlock, stripBlock } from './rules.js';
7
+ export { compileRules, routingGuidance, replaceBlock, stripBlock } from './rules.js';
6
8
  export { install, uninstall, readSettings } from './install.js';
9
+ export { detectDelivery } from './detect.js';
10
+ export { readTranscripts, parseUsageLine, dedupe, classifyTranscriptFile, locateWorkflowRuns } from './transcript.js';
11
+ export { costFromUsage, modelPrice, sumUsage } from './cost.js';
12
+ export * as render from './render.js';
7
13
  export * as paths from './paths.js';