knodin 0.8.2 → 0.8.4
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 +36 -13
- package/dist/bin/cli.js +248 -62
- package/dist/src/agent-events.js +128 -0
- package/dist/src/agent-hooks.js +156 -0
- package/dist/src/cli-model.js +16 -1
- package/dist/src/docs-sections.js +1 -0
- package/dist/src/engine/index.js +911 -55
- package/dist/src/init-progress-worker.js +4 -40
- package/dist/src/output-telemetry.js +8 -3
- package/dist/src/progress-worker-runtime.js +46 -0
- package/dist/src/repair-progress-worker.js +3 -40
- package/dist/src/resource-reachability.js +456 -0
- package/dist/src/response-budget.js +69 -65
- package/dist/src/session-telemetry.js +163 -0
- package/dist/src/tools/knodin-tools.js +12 -7
- package/docs/BEHAVIORAL-CONTRACT.md +47 -5
- package/docs/CLI.md +27 -0
- package/docs/COMPARISON.md +10 -0
- package/docs/DEMO.md +49 -0
- package/docs/INSTALLATION.md +11 -0
- package/docs/MCP.md +5 -0
- package/docs/TELEMETRY.md +19 -1
- package/docs/releases/0.8.3.md +47 -0
- package/docs/releases/0.8.4.md +22 -0
- package/package.json +14 -1
- package/roadmap/competitive-roadmap.md +115 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
const MAX_DEPTH = 6;
|
|
3
|
+
const MAX_PATHS = 100;
|
|
4
|
+
function language(file) {
|
|
5
|
+
if (/\.(?:ts|tsx)$/.test(file))
|
|
6
|
+
return "typescript";
|
|
7
|
+
if (/\.(?:js|jsx|mjs|cjs)$/.test(file))
|
|
8
|
+
return "javascript";
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
function evidence(file, line, excerpt) {
|
|
12
|
+
return { file, line, excerpt: excerpt.trim().slice(0, 240) };
|
|
13
|
+
}
|
|
14
|
+
export function resourceFingerprint(files) {
|
|
15
|
+
const hash = crypto.createHash("sha256");
|
|
16
|
+
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path)))
|
|
17
|
+
hash.update(file.path).update("\0").update(file.content).update("\0");
|
|
18
|
+
return hash.digest("hex");
|
|
19
|
+
}
|
|
20
|
+
function maskSourceLines(lines) {
|
|
21
|
+
let blockComment = false;
|
|
22
|
+
return lines.map((line) => {
|
|
23
|
+
let quote;
|
|
24
|
+
let escaped = false;
|
|
25
|
+
let output = "";
|
|
26
|
+
for (let index = 0; index < line.length; index++) {
|
|
27
|
+
const char = line[index];
|
|
28
|
+
const next = line[index + 1];
|
|
29
|
+
if (blockComment) {
|
|
30
|
+
output += " ";
|
|
31
|
+
if (char === "*" && next === "/") {
|
|
32
|
+
output += " ";
|
|
33
|
+
index++;
|
|
34
|
+
blockComment = false;
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (quote) {
|
|
39
|
+
output += " ";
|
|
40
|
+
if (!escaped && char === quote)
|
|
41
|
+
quote = undefined;
|
|
42
|
+
escaped = !escaped && char === "\\";
|
|
43
|
+
if (char !== "\\")
|
|
44
|
+
escaped = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (char === "/" && next === "/")
|
|
48
|
+
return output.padEnd(line.length, " ");
|
|
49
|
+
if (char === "/" && next === "*") {
|
|
50
|
+
output += " ";
|
|
51
|
+
index++;
|
|
52
|
+
blockComment = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
56
|
+
quote = char;
|
|
57
|
+
output += " ";
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
output += char;
|
|
61
|
+
}
|
|
62
|
+
return output;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function sourceFromExpression(file, line, text) {
|
|
66
|
+
const code = maskSourceLines([text])[0];
|
|
67
|
+
const env = /^\s*process\.env\.([A-Za-z_$][\w$]*)\s*;?\s*$/.exec(code);
|
|
68
|
+
if (env) {
|
|
69
|
+
const at = evidence(file, line, text);
|
|
70
|
+
return {
|
|
71
|
+
class: "environment",
|
|
72
|
+
resource: env[1],
|
|
73
|
+
evidence: at,
|
|
74
|
+
steps: [{ relation: "read", evidence: at }],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const localCall = /^\s*fs\.readFileSync\s*\(/.exec(code);
|
|
78
|
+
const local = localCall ? /(?<![\w$.])fs\.readFileSync\(\s*["']([^"']+)["']/.exec(text) : null;
|
|
79
|
+
if (local) {
|
|
80
|
+
const at = evidence(file, line, text);
|
|
81
|
+
return {
|
|
82
|
+
class: "local_config",
|
|
83
|
+
resource: local[1],
|
|
84
|
+
evidence: at,
|
|
85
|
+
steps: [{ relation: "read", evidence: at }],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
function functions(lines, codeLines) {
|
|
91
|
+
const result = new Map();
|
|
92
|
+
let index = 0;
|
|
93
|
+
while (index < lines.length) {
|
|
94
|
+
const match = /\bfunction\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)/.exec(codeLines[index]);
|
|
95
|
+
if (!match) {
|
|
96
|
+
index++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
let balance = 0;
|
|
100
|
+
let end = index;
|
|
101
|
+
for (; end < lines.length; end++) {
|
|
102
|
+
balance += (codeLines[end].match(/{/g) ?? []).length;
|
|
103
|
+
balance -= (codeLines[end].match(/}/g) ?? []).length;
|
|
104
|
+
if (balance === 0)
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
result.set(match[1], {
|
|
108
|
+
name: match[1],
|
|
109
|
+
params: match[2]
|
|
110
|
+
.split(",")
|
|
111
|
+
.map((value) => /^([A-Za-z_$][\w$]*)/.exec(value.trim())?.[1])
|
|
112
|
+
.filter((value) => Boolean(value)),
|
|
113
|
+
start: index,
|
|
114
|
+
end,
|
|
115
|
+
lines: lines.slice(index, end + 1),
|
|
116
|
+
code: codeLines.slice(index, end + 1),
|
|
117
|
+
});
|
|
118
|
+
index = end + 1;
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
function sinkOn(line) {
|
|
123
|
+
if (/(?<![\w$.])console\.log\s*\(/.test(line))
|
|
124
|
+
return "logging";
|
|
125
|
+
if (/(?<![\w$.])fetch\s*\(/.test(line))
|
|
126
|
+
return "network";
|
|
127
|
+
if (/(?<![\w$.])db\.query\s*\(/.test(line))
|
|
128
|
+
return "database";
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
function sinkExpression(line, sink) {
|
|
132
|
+
const callee = sink === "logging" ? "console\\.log" : sink === "network" ? "fetch" : "db\\.query";
|
|
133
|
+
const match = new RegExp(`(?<![\\w$.])${callee}\\s*\\(`).exec(line);
|
|
134
|
+
if (!match)
|
|
135
|
+
return "";
|
|
136
|
+
const open = match.index + match[0].lastIndexOf("(");
|
|
137
|
+
let depth = 0;
|
|
138
|
+
for (let index = open; index < line.length; index++) {
|
|
139
|
+
if (line[index] === "(")
|
|
140
|
+
depth++;
|
|
141
|
+
else if (line[index] === ")" && --depth === 0)
|
|
142
|
+
return line.slice(open + 1, index);
|
|
143
|
+
}
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
function callArguments(text, name) {
|
|
147
|
+
// A local summary named `forward` must not be applied to `obj.forward(...)`.
|
|
148
|
+
// Member dispatch is dynamic and outside this deliberately bounded heuristic.
|
|
149
|
+
const match = new RegExp(`(?<![\\w$.])${name}\\s*\\((.*)\\)`).exec(text);
|
|
150
|
+
return match?.[1].split(",").map((part) => part.trim());
|
|
151
|
+
}
|
|
152
|
+
export function analyzeResourceReachability(files, options = {}) {
|
|
153
|
+
const maxItems = Math.min(options.maxItems ?? MAX_PATHS, MAX_PATHS);
|
|
154
|
+
const maxBytes = options.maxBytes ?? 65_536;
|
|
155
|
+
const maxTokens = options.maxTokens ?? 16_384;
|
|
156
|
+
const offset = options.offset ?? 0;
|
|
157
|
+
const fingerprint = resourceFingerprint(files);
|
|
158
|
+
const stale = Boolean(options.expectedFingerprint && options.expectedFingerprint !== fingerprint);
|
|
159
|
+
const coverage = {
|
|
160
|
+
languages: ["typescript", "javascript"],
|
|
161
|
+
registry: {
|
|
162
|
+
environment: ["process.env.LITERAL"],
|
|
163
|
+
local_config: ["fs.readFileSync(LITERAL)"],
|
|
164
|
+
logging: ["console.log"],
|
|
165
|
+
network: ["fetch"],
|
|
166
|
+
database: ["db.query"],
|
|
167
|
+
},
|
|
168
|
+
maxDepth: MAX_DEPTH,
|
|
169
|
+
maxPaths: MAX_PATHS,
|
|
170
|
+
};
|
|
171
|
+
const candidates = [];
|
|
172
|
+
const omissions = [];
|
|
173
|
+
const sourceByPath = new Map(files.map((file) => [file.path, file.content.split(/\r?\n/)]));
|
|
174
|
+
const addPath = (file, taint, sink, line, text) => {
|
|
175
|
+
const sinkEvidence = evidence(file, line, text);
|
|
176
|
+
const normalizedSink = sinkExpression(maskSourceLines([text])[0], sink)
|
|
177
|
+
.replace(/\s+/g, " ")
|
|
178
|
+
.trim();
|
|
179
|
+
const occurrence = (sourceByPath.get(file) ?? []).slice(0, line).filter((candidate) => {
|
|
180
|
+
const masked = maskSourceLines([candidate])[0];
|
|
181
|
+
return (sinkOn(masked) === sink &&
|
|
182
|
+
sinkExpression(masked, sink).replace(/\s+/g, " ").trim() === normalizedSink);
|
|
183
|
+
}).length;
|
|
184
|
+
const sinkKey = crypto.createHash("sha256").update(normalizedSink).digest("hex").slice(0, 16);
|
|
185
|
+
const identity = `${sink}:${file}:${sinkKey}:${occurrence}`;
|
|
186
|
+
const id = crypto
|
|
187
|
+
.createHash("sha256")
|
|
188
|
+
.update(`${taint.class}:${taint.resource}\0${identity}\0${taint.steps.map((s) => s.relation).join("|")}`)
|
|
189
|
+
.digest("hex")
|
|
190
|
+
.slice(0, 24);
|
|
191
|
+
candidates.push({
|
|
192
|
+
id,
|
|
193
|
+
source: {
|
|
194
|
+
identity: `${taint.class}:${taint.resource}`,
|
|
195
|
+
class: taint.class,
|
|
196
|
+
resource: taint.resource,
|
|
197
|
+
evidence: taint.evidence,
|
|
198
|
+
},
|
|
199
|
+
sink: { identity, class: sink, evidence: sinkEvidence },
|
|
200
|
+
relationKind: "static_resource_flow",
|
|
201
|
+
path: [...taint.steps, { relation: "write", evidence: sinkEvidence }],
|
|
202
|
+
provenance: "knodin-resource-reachability-v1",
|
|
203
|
+
confidence: "heuristic",
|
|
204
|
+
staticOnly: true,
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
208
|
+
if (!language(file.path)) {
|
|
209
|
+
omissions.push({
|
|
210
|
+
file: file.path,
|
|
211
|
+
line: 1,
|
|
212
|
+
kind: "unsupported_language",
|
|
213
|
+
reason: "No verified resource registry for this language",
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const lines = file.content.split(/\r?\n/);
|
|
218
|
+
const codeLines = maskSourceLines(lines);
|
|
219
|
+
const summaries = functions(lines, codeLines);
|
|
220
|
+
const scopes = [new Map()];
|
|
221
|
+
const lookup = (name) => {
|
|
222
|
+
for (let index = scopes.length - 1; index >= 0; index--)
|
|
223
|
+
if (scopes[index].has(name))
|
|
224
|
+
return scopes[index].get(name) ?? undefined;
|
|
225
|
+
return undefined;
|
|
226
|
+
};
|
|
227
|
+
const evaluate = (expression, line, bindings = new Map(), depth = 0, stack = new Set()) => {
|
|
228
|
+
for (const summary of summaries.values()) {
|
|
229
|
+
const args = callArguments(expression, summary.name);
|
|
230
|
+
if (!args)
|
|
231
|
+
continue;
|
|
232
|
+
if (depth >= MAX_DEPTH) {
|
|
233
|
+
omissions.push({
|
|
234
|
+
file: file.path,
|
|
235
|
+
line,
|
|
236
|
+
kind: "depth_bound",
|
|
237
|
+
reason: `Call depth exceeds ${MAX_DEPTH}`,
|
|
238
|
+
});
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
if (stack.has(summary.name)) {
|
|
242
|
+
omissions.push({
|
|
243
|
+
file: file.path,
|
|
244
|
+
line,
|
|
245
|
+
kind: "recursion_cycle",
|
|
246
|
+
reason: "Cycle terminated at the bounded call summary",
|
|
247
|
+
});
|
|
248
|
+
// Continue scanning this function's base returns; never recurse again.
|
|
249
|
+
}
|
|
250
|
+
const nextBindings = new Map();
|
|
251
|
+
for (const [index, parameter] of summary.params.entries()) {
|
|
252
|
+
const value = evaluate(args[index] ?? "", line, bindings, depth + 1, stack);
|
|
253
|
+
if (value)
|
|
254
|
+
nextBindings.set(parameter, {
|
|
255
|
+
...value,
|
|
256
|
+
steps: [
|
|
257
|
+
...value.steps,
|
|
258
|
+
{
|
|
259
|
+
relation: "argument",
|
|
260
|
+
evidence: evidence(file.path, line, lines[line - 1] ?? expression),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const nextStack = new Set(stack).add(summary.name);
|
|
266
|
+
for (let inner = 0; inner < summary.lines.length; inner++) {
|
|
267
|
+
const actualLine = summary.start + inner + 1;
|
|
268
|
+
const bodyLine = summary.lines[inner];
|
|
269
|
+
const bodyCode = summary.code[inner];
|
|
270
|
+
const sink = sinkOn(bodyCode);
|
|
271
|
+
if (sink) {
|
|
272
|
+
const written = sinkExpression(bodyCode, sink);
|
|
273
|
+
for (const [parameter, taint] of nextBindings)
|
|
274
|
+
if (new RegExp(`\\b${parameter}\\b`).test(written))
|
|
275
|
+
addPath(file.path, taint, sink, actualLine, bodyLine);
|
|
276
|
+
}
|
|
277
|
+
const returned = /\breturn\s+(.+?);?\s*}/.exec(bodyCode)?.[1] ??
|
|
278
|
+
/\breturn\s+(.+?);?\s*$/.exec(bodyCode)?.[1];
|
|
279
|
+
if (returned) {
|
|
280
|
+
// Conditional recursion is conservatively reduced to its non-recursive source arm.
|
|
281
|
+
const arms = returned.split(":").reverse();
|
|
282
|
+
for (const arm of arms) {
|
|
283
|
+
if (stack.has(summary.name) && new RegExp(`\\b${summary.name}\\s*\\(`).test(arm))
|
|
284
|
+
continue;
|
|
285
|
+
const value = evaluate(arm.replace(/^.*\?/, "").trim(), actualLine, nextBindings, depth + 1, nextStack);
|
|
286
|
+
if (value)
|
|
287
|
+
return {
|
|
288
|
+
...value,
|
|
289
|
+
steps: [
|
|
290
|
+
...value.steps,
|
|
291
|
+
{ relation: "return", evidence: evidence(file.path, actualLine, bodyLine) },
|
|
292
|
+
],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const direct = sourceFromExpression(file.path, line, expression);
|
|
299
|
+
if (direct)
|
|
300
|
+
return direct;
|
|
301
|
+
const identifier = /^([A-Za-z_$][\w$]*)$/.exec(expression.trim())?.[1];
|
|
302
|
+
if (identifier)
|
|
303
|
+
return bindings.get(identifier) ?? lookup(identifier);
|
|
304
|
+
return undefined;
|
|
305
|
+
};
|
|
306
|
+
for (const summary of summaries.values()) {
|
|
307
|
+
if (summary.code.some((body, index) => index > 0 && new RegExp(`\\b${summary.name}\\s*\\(`).test(body)))
|
|
308
|
+
omissions.push({
|
|
309
|
+
file: file.path,
|
|
310
|
+
line: summary.start + 1,
|
|
311
|
+
kind: "recursion_cycle",
|
|
312
|
+
reason: "Recursive summary is cycle-guarded at depth 6",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
for (let index = 0; index < lines.length; index++) {
|
|
316
|
+
const text = lines[index];
|
|
317
|
+
const code = codeLines[index];
|
|
318
|
+
const line = index + 1;
|
|
319
|
+
if (/process\.env\s*\[/.test(code))
|
|
320
|
+
omissions.push({
|
|
321
|
+
file: file.path,
|
|
322
|
+
line,
|
|
323
|
+
kind: "dynamic_resource_name",
|
|
324
|
+
reason: "Computed environment names are intentionally unsupported",
|
|
325
|
+
});
|
|
326
|
+
if (/Reflection|Reflect\.|loadSecretWithReflection/.test(code))
|
|
327
|
+
omissions.push({
|
|
328
|
+
file: file.path,
|
|
329
|
+
line,
|
|
330
|
+
kind: "unsupported_reflection",
|
|
331
|
+
reason: "Reflective calls are intentionally unsupported",
|
|
332
|
+
});
|
|
333
|
+
if (/\b(?:this|globalThis|window)\s*\[/.test(code))
|
|
334
|
+
omissions.push({
|
|
335
|
+
file: file.path,
|
|
336
|
+
line,
|
|
337
|
+
kind: "unsupported_alias",
|
|
338
|
+
reason: "Computed aliases are intentionally unsupported",
|
|
339
|
+
});
|
|
340
|
+
if (/\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:console\.log|fetch|db\.query|fs\.readFileSync|process\.env(?!\.))\b/.test(code) ||
|
|
341
|
+
/\b(?:const|let|var)\s*{[^}]+}\s*=\s*(?:console|db|fs|process\.env)\b/.test(code))
|
|
342
|
+
omissions.push({
|
|
343
|
+
file: file.path,
|
|
344
|
+
line,
|
|
345
|
+
kind: "unsupported_alias",
|
|
346
|
+
reason: "Aliased source or sink registries are intentionally unsupported",
|
|
347
|
+
});
|
|
348
|
+
if (code.trim().startsWith("}") && scopes.length > 1)
|
|
349
|
+
scopes.pop();
|
|
350
|
+
if (code.trim() === "{")
|
|
351
|
+
scopes.push(new Map());
|
|
352
|
+
const assignment = /\b(const|let|var)?\s*([A-Za-z_$][\w$]*)\s*=\s*(.+?);?\s*$/.exec(code);
|
|
353
|
+
if (assignment) {
|
|
354
|
+
const equals = text.indexOf("=", assignment.index);
|
|
355
|
+
const rawExpression = equals >= 0 ? text.slice(equals + 1).replace(/;\s*$/, "") : assignment[3];
|
|
356
|
+
const taint = evaluate(rawExpression, line);
|
|
357
|
+
const value = taint
|
|
358
|
+
? {
|
|
359
|
+
...taint,
|
|
360
|
+
steps: [
|
|
361
|
+
...taint.steps,
|
|
362
|
+
{ relation: "assignment", evidence: evidence(file.path, line, text) },
|
|
363
|
+
],
|
|
364
|
+
}
|
|
365
|
+
: null;
|
|
366
|
+
if (assignment[1])
|
|
367
|
+
scopes.at(-1)?.set(assignment[2], value);
|
|
368
|
+
else {
|
|
369
|
+
for (let scope = scopes.length - 1; scope >= 0; scope--)
|
|
370
|
+
if (scopes[scope].has(assignment[2])) {
|
|
371
|
+
scopes[scope].set(assignment[2], value);
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const sink = sinkOn(code);
|
|
377
|
+
if (sink) {
|
|
378
|
+
const expression = sinkExpression(code, sink);
|
|
379
|
+
const direct = evaluate(expression, line);
|
|
380
|
+
if (direct)
|
|
381
|
+
addPath(file.path, direct, sink, line, text);
|
|
382
|
+
for (const name of new Set([...expression.matchAll(/\b([A-Za-z_$][\w$]*)\b/g)].map((match) => match[1]))) {
|
|
383
|
+
const taint = lookup(name);
|
|
384
|
+
if (taint)
|
|
385
|
+
addPath(file.path, taint, sink, line, text);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Calls whose callee contains a sink produce their paths as a side effect.
|
|
389
|
+
for (const summary of summaries.values())
|
|
390
|
+
if (callArguments(code, summary.name))
|
|
391
|
+
evaluate(code, line);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const uniquePaths = [...new Map(candidates.map((row) => [row.id, row])).values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
395
|
+
const uniqueOmissions = [
|
|
396
|
+
...new Map(omissions.map((row) => [`${row.file}:${row.line}:${row.kind}`, row])).values(),
|
|
397
|
+
].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.kind.localeCompare(b.kind));
|
|
398
|
+
const entries = [
|
|
399
|
+
...uniquePaths.map((value) => ({ kind: "path", value })),
|
|
400
|
+
...uniqueOmissions.map((value) => ({ kind: "omission", value })),
|
|
401
|
+
];
|
|
402
|
+
const selectedPaths = [];
|
|
403
|
+
const selectedOmissions = [];
|
|
404
|
+
let truncated = false;
|
|
405
|
+
const finalize = () => {
|
|
406
|
+
let responseBytes = 0;
|
|
407
|
+
let result;
|
|
408
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
409
|
+
result = {
|
|
410
|
+
paths: stale ? [] : selectedPaths,
|
|
411
|
+
omissions: stale ? [] : selectedOmissions,
|
|
412
|
+
coverage,
|
|
413
|
+
budget: { maxItems, maxBytes, maxTokens, responseBytes },
|
|
414
|
+
truncated: stale ? false : truncated,
|
|
415
|
+
...(!stale && truncated
|
|
416
|
+
? { continuation: { offset: offset + selectedPaths.length + selectedOmissions.length } }
|
|
417
|
+
: {}),
|
|
418
|
+
freshness: { fingerprint, state: stale ? "stale-rejected" : "fresh" },
|
|
419
|
+
};
|
|
420
|
+
const measured = Buffer.byteLength(JSON.stringify(result));
|
|
421
|
+
if (measured === responseBytes)
|
|
422
|
+
break;
|
|
423
|
+
responseBytes = measured;
|
|
424
|
+
}
|
|
425
|
+
return result;
|
|
426
|
+
};
|
|
427
|
+
const empty = finalize();
|
|
428
|
+
if (empty.budget.responseBytes > maxBytes ||
|
|
429
|
+
Math.ceil(empty.budget.responseBytes / 4) > maxTokens)
|
|
430
|
+
throw new Error("resource_reachability budget cannot encode the minimum response envelope");
|
|
431
|
+
if (stale)
|
|
432
|
+
return empty;
|
|
433
|
+
for (const entry of entries.slice(offset)) {
|
|
434
|
+
const itemCount = selectedPaths.length + selectedOmissions.length;
|
|
435
|
+
if (itemCount >= maxItems) {
|
|
436
|
+
truncated = true;
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
if (entry.kind === "path")
|
|
440
|
+
selectedPaths.push(entry.value);
|
|
441
|
+
else
|
|
442
|
+
selectedOmissions.push(entry.value);
|
|
443
|
+
const measured = finalize().budget.responseBytes;
|
|
444
|
+
if (measured > maxBytes || Math.ceil(measured / 4) > maxTokens) {
|
|
445
|
+
if (entry.kind === "path")
|
|
446
|
+
selectedPaths.pop();
|
|
447
|
+
else
|
|
448
|
+
selectedOmissions.pop();
|
|
449
|
+
truncated = true;
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (offset + selectedPaths.length + selectedOmissions.length < entries.length)
|
|
454
|
+
truncated = true;
|
|
455
|
+
return finalize();
|
|
456
|
+
}
|
|
@@ -48,6 +48,71 @@ const PROTECTED_CONTRACT_KEYS = new Set([
|
|
|
48
48
|
function isTruncatableDetail(entry) {
|
|
49
49
|
return typeof entry.key !== "string" || !PROTECTED_CONTRACT_KEYS.has(entry.key);
|
|
50
50
|
}
|
|
51
|
+
function synchronizeNestedCapabilityBudget(root, metadata, operation) {
|
|
52
|
+
const cross = root.crossSubstratePath;
|
|
53
|
+
if (!cross)
|
|
54
|
+
return;
|
|
55
|
+
if (metadata.truncated) {
|
|
56
|
+
cross.truncated = true;
|
|
57
|
+
cross.continuation = `Repeat ${operation} with a larger byte/token budget to recover complete source evidence.`;
|
|
58
|
+
const omission = "Flow or Apex source evidence was shortened by the response budget.";
|
|
59
|
+
cross.omissions ??= [];
|
|
60
|
+
if (!cross.omissions.includes(omission))
|
|
61
|
+
cross.omissions.push(omission);
|
|
62
|
+
}
|
|
63
|
+
const measuredBudget = {
|
|
64
|
+
byteLimit: metadata.byteLimit,
|
|
65
|
+
tokenLimit: metadata.tokenLimit,
|
|
66
|
+
itemLimit: metadata.itemLimit,
|
|
67
|
+
serializedBytes: 0,
|
|
68
|
+
estimatedTokens: 0,
|
|
69
|
+
};
|
|
70
|
+
cross.budget = cross.budget ? { ...cross.budget, ...measuredBudget } : measuredBudget;
|
|
71
|
+
for (let iteration = 0; iteration < 4; iteration++) {
|
|
72
|
+
const serializedBytes = bytes(cross);
|
|
73
|
+
const estimatedTokens = Math.ceil(serializedBytes / 4);
|
|
74
|
+
if (cross.budget.serializedBytes === serializedBytes &&
|
|
75
|
+
cross.budget.estimatedTokens === estimatedTokens)
|
|
76
|
+
break;
|
|
77
|
+
cross.budget.serializedBytes = serializedBytes;
|
|
78
|
+
cross.budget.estimatedTokens = estimatedTokens;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function truncatePayloadToBytes(root, byteLimit) {
|
|
82
|
+
let truncated = false;
|
|
83
|
+
while (bytes(root) > byteLimit) {
|
|
84
|
+
const candidate = collectStrings(root)
|
|
85
|
+
.filter((entry) => !entry.path.startsWith("$/responseBudget") &&
|
|
86
|
+
entry.value.length > 0 &&
|
|
87
|
+
isTruncatableDetail(entry))
|
|
88
|
+
.sort((a, b) => b.value.length - a.value.length)[0];
|
|
89
|
+
if (!candidate)
|
|
90
|
+
break;
|
|
91
|
+
const marker = "\n… [truncated]";
|
|
92
|
+
if (candidate.value.length <= marker.length + 1) {
|
|
93
|
+
if (Array.isArray(candidate.parent))
|
|
94
|
+
candidate.parent.splice(candidate.key, 1);
|
|
95
|
+
else
|
|
96
|
+
delete candidate.parent[candidate.key];
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const keep = Math.max(0, Math.floor((candidate.value.length - marker.length) / 2));
|
|
100
|
+
candidate.parent[candidate.key] =
|
|
101
|
+
`${candidate.value.slice(0, keep)}${marker}`;
|
|
102
|
+
}
|
|
103
|
+
truncated = true;
|
|
104
|
+
}
|
|
105
|
+
while (bytes(root) > byteLimit) {
|
|
106
|
+
const candidate = collectArrays(root)
|
|
107
|
+
.filter((entry) => !entry.path.startsWith("$/responseBudget") && entry.value.length > 0)
|
|
108
|
+
.sort((a, b) => bytes(b.value) - bytes(a.value))[0];
|
|
109
|
+
if (!candidate)
|
|
110
|
+
break;
|
|
111
|
+
candidate.value.pop();
|
|
112
|
+
truncated = true;
|
|
113
|
+
}
|
|
114
|
+
return truncated;
|
|
115
|
+
}
|
|
51
116
|
/**
|
|
52
117
|
* Applies limits to the value that is actually JSON-serialized. Token accounting
|
|
53
118
|
* intentionally uses the deterministic local estimate of four UTF-8 bytes/token;
|
|
@@ -98,38 +163,7 @@ export function applyResponseBudget(value, operation, request, defaults) {
|
|
|
98
163
|
root.responseBudget = metadata;
|
|
99
164
|
// Preserve collection contracts and identifiers where possible: large source,
|
|
100
165
|
// artifact and diagnostic strings are the first expendable detail.
|
|
101
|
-
|
|
102
|
-
const candidate = collectStrings(root)
|
|
103
|
-
.filter((entry) => !entry.path.startsWith("$/responseBudget") &&
|
|
104
|
-
entry.value.length > 0 &&
|
|
105
|
-
isTruncatableDetail(entry))
|
|
106
|
-
.sort((a, b) => b.value.length - a.value.length)[0];
|
|
107
|
-
if (!candidate)
|
|
108
|
-
break;
|
|
109
|
-
const marker = "\n… [truncated]";
|
|
110
|
-
if (candidate.value.length <= marker.length + 1) {
|
|
111
|
-
if (Array.isArray(candidate.parent))
|
|
112
|
-
candidate.parent.splice(candidate.key, 1);
|
|
113
|
-
else
|
|
114
|
-
delete candidate.parent[candidate.key];
|
|
115
|
-
truncated = true;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
const keep = Math.max(0, Math.floor((candidate.value.length - marker.length) / 2));
|
|
119
|
-
candidate.parent[candidate.key] =
|
|
120
|
-
`${candidate.value.slice(0, keep)}${marker}`;
|
|
121
|
-
truncated = true;
|
|
122
|
-
}
|
|
123
|
-
// Only drop tail items after scalar detail has been exhausted.
|
|
124
|
-
while (bytes(root) > byteLimit) {
|
|
125
|
-
const candidate = collectArrays(root)
|
|
126
|
-
.filter((entry) => !entry.path.startsWith("$/responseBudget") && entry.value.length > 0)
|
|
127
|
-
.sort((a, b) => bytes(b.value) - bytes(a.value))[0];
|
|
128
|
-
if (!candidate)
|
|
129
|
-
break;
|
|
130
|
-
candidate.value.pop();
|
|
131
|
-
truncated = true;
|
|
132
|
-
}
|
|
166
|
+
truncated = truncatePayloadToBytes(root, byteLimit) || truncated;
|
|
133
167
|
metadata.truncated = truncated;
|
|
134
168
|
if (truncated) {
|
|
135
169
|
metadata.continuation = {
|
|
@@ -138,6 +172,7 @@ export function applyResponseBudget(value, operation, request, defaults) {
|
|
|
138
172
|
instruction: `Repeat ${operation} with a narrower selector, larger budget, or query drill-down.`,
|
|
139
173
|
};
|
|
140
174
|
}
|
|
175
|
+
synchronizeNestedCapabilityBudget(root, metadata, operation);
|
|
141
176
|
// Metadata changes the byte count, so converge after setting it. The reserve
|
|
142
177
|
// above is normally enough; this final pass handles unusually long paths.
|
|
143
178
|
metadata.serializedBytes = bytes(root);
|
|
@@ -147,42 +182,11 @@ export function applyResponseBudget(value, operation, request, defaults) {
|
|
|
147
182
|
metadata.serializedBytes = bytes(root);
|
|
148
183
|
metadata.estimatedTokens = Math.ceil(metadata.serializedBytes / 4);
|
|
149
184
|
}
|
|
150
|
-
|
|
151
|
-
const candidate = collectStrings(root)
|
|
152
|
-
.filter((entry) => !entry.path.startsWith("$/responseBudget") &&
|
|
153
|
-
entry.value.length > 0 &&
|
|
154
|
-
isTruncatableDetail(entry))
|
|
155
|
-
.sort((a, b) => b.value.length - a.value.length)[0];
|
|
156
|
-
if (!candidate)
|
|
157
|
-
break;
|
|
158
|
-
const marker = "\n… [truncated]";
|
|
159
|
-
if (candidate.value.length <= marker.length + 1) {
|
|
160
|
-
if (Array.isArray(candidate.parent))
|
|
161
|
-
candidate.parent.splice(candidate.key, 1);
|
|
162
|
-
else
|
|
163
|
-
delete candidate.parent[candidate.key];
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
const keep = Math.max(0, Math.floor((candidate.value.length - marker.length) / 2));
|
|
167
|
-
candidate.parent[candidate.key] =
|
|
168
|
-
`${candidate.value.slice(0, keep)}${marker}`;
|
|
169
|
-
}
|
|
170
|
-
metadata.truncated = true;
|
|
171
|
-
}
|
|
185
|
+
metadata.truncated = truncatePayloadToBytes(root, byteLimit) || metadata.truncated;
|
|
172
186
|
metadata.serializedBytes = bytes(root);
|
|
173
187
|
metadata.estimatedTokens = Math.ceil(metadata.serializedBytes / 4);
|
|
174
|
-
while (bytes(root) > byteLimit) {
|
|
175
|
-
const candidate = collectArrays(root)
|
|
176
|
-
.filter((entry) => !entry.path.startsWith("$/responseBudget") && entry.value.length > 0)
|
|
177
|
-
.sort((a, b) => bytes(b.value) - bytes(a.value))[0];
|
|
178
|
-
if (!candidate)
|
|
179
|
-
break;
|
|
180
|
-
candidate.value.pop();
|
|
181
|
-
metadata.truncated = true;
|
|
182
|
-
metadata.serializedBytes = bytes(root);
|
|
183
|
-
metadata.estimatedTokens = Math.ceil(metadata.serializedBytes / 4);
|
|
184
|
-
}
|
|
185
188
|
for (let iteration = 0; iteration < 8; iteration++) {
|
|
189
|
+
synchronizeNestedCapabilityBudget(root, metadata, operation);
|
|
186
190
|
const actual = bytes(root);
|
|
187
191
|
const tokens = Math.ceil(actual / 4);
|
|
188
192
|
if (metadata.serializedBytes === actual && metadata.estimatedTokens === tokens)
|