ultracost 0.2.1 → 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/CHANGELOG.md +50 -1
- package/NOTICE +16 -3
- package/README.md +77 -12
- package/bin/cli.js +514 -117
- package/docs/ESTIMATES.md +24 -0
- package/docs/PUBLISHING.md +41 -34
- package/docs/architecture.md +19 -1
- package/docs/policy.md +25 -2
- package/package.json +1 -1
- package/src/classify.js +125 -0
- package/src/cost.js +54 -0
- package/src/detect.js +93 -0
- package/src/estimate.js +18 -0
- package/src/guard.js +244 -166
- package/src/index.js +7 -1
- package/src/lexer.js +227 -0
- package/src/log.js +20 -13
- package/src/loop.js +143 -0
- package/src/paths.js +10 -0
- package/src/policy.js +14 -0
- package/src/render.js +211 -0
- package/src/rules.js +17 -5
- package/src/transcript.js +186 -0
- package/templates/hooks/reinject.mjs +21 -18
- package/templates/hooks/workflow-gate.mjs +51 -45
- package/templates/policy.default.json +15 -2
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
|
|
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
|
-
|
|
14
|
-
const
|
|
18
|
+
const CLOSERS = new Set([')', ']', '}']);
|
|
19
|
+
const isPunct = (t, v) => t && t.type === TT.PUNCT && (v === undefined || t.value === v);
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
function
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
let
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
55
|
+
cur.push(t);
|
|
57
56
|
}
|
|
58
57
|
}
|
|
59
|
-
|
|
58
|
+
if (cur.length) args.push(cur);
|
|
59
|
+
return { args, closeIdx: tokens.length - 1 };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
83
|
+
return { kind: 'dynamic' };
|
|
73
84
|
}
|
|
74
85
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 {
|
|
122
|
+
return { isObject: true, hasSpread, model: props.model, effort: props.effort };
|
|
85
123
|
}
|
|
86
124
|
|
|
87
|
-
function
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
198
|
+
const tokens = tokenize(text);
|
|
199
|
+
const loops = loopBodyRanges(tokens);
|
|
121
200
|
const findings = [];
|
|
122
201
|
let stages = 0;
|
|
123
|
-
|
|
124
|
-
|
|
202
|
+
|
|
203
|
+
for (const { nameIdx, openIdx } of agentCalls(tokens)) {
|
|
125
204
|
stages++;
|
|
126
|
-
const
|
|
127
|
-
const {
|
|
128
|
-
const { line, column
|
|
129
|
-
const
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
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
|
|
259
|
+
const tokens = tokenize(text);
|
|
260
|
+
const loops = loopBodyRanges(tokens);
|
|
186
261
|
const out = [];
|
|
187
|
-
for (const
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
//
|
|
212
|
-
//
|
|
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
|
|
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
|
|
350
|
+
const tokens = tokenize(text);
|
|
270
351
|
const sites = [];
|
|
271
|
-
for (const
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
sites.push({ type: 'insert', at:
|
|
279
|
-
} else {
|
|
280
|
-
sites.push({ type: 'append', at:
|
|
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
|
-
|
|
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';
|