knodin 0.7.6 → 0.8.3
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 +41 -12
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +2181 -108
- package/dist/bin/launcher.js +25 -3
- package/dist/src/agent-integration.js +304 -0
- package/dist/src/artifact-refresh.js +82 -0
- package/dist/src/cli-args.js +292 -0
- package/dist/src/cli-model.js +384 -0
- package/dist/src/codeflow-replay.js +81 -0
- package/dist/src/compact-structural.js +96 -0
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-cold-mcp.js +40 -0
- package/dist/src/competitive-constraints.js +21 -0
- package/dist/src/competitive-manifest.js +411 -0
- package/dist/src/competitive-measurement.js +183 -0
- package/dist/src/competitive-runner.js +487 -0
- package/dist/src/competitive-sandbox.js +108 -0
- package/dist/src/context-export.js +423 -0
- package/dist/src/context.js +102 -0
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1476 -0
- package/dist/src/docs-sections.js +142 -0
- package/dist/src/doctor.js +382 -0
- package/dist/src/engine/ann-hnsw.js +261 -0
- package/dist/src/engine/embeddings.js +193 -0
- package/dist/src/engine/file-walker.js +49 -0
- package/dist/src/engine/git-history.js +289 -0
- package/dist/src/engine/index.js +15094 -0
- package/dist/src/engine/perf.js +115 -0
- package/dist/src/engine/prune.js +112 -0
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +423 -0
- package/dist/src/engine/source-policy.js +85 -0
- package/dist/src/engine/sqlite.js +71 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/engine/symbol-delete.js +58 -0
- package/dist/src/execution-profile.js +208 -0
- package/dist/src/failure-diagnosis.js +655 -0
- package/dist/src/fleet.js +7 -0
- package/dist/src/git-executable.js +31 -0
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/graph-query-health.js +115 -0
- package/dist/src/hook-manager-integration.js +156 -0
- package/dist/src/index-activity.js +126 -0
- package/dist/src/init-progress-worker.js +70 -2
- package/dist/src/init-progress.js +155 -0
- package/dist/src/init.js +1295 -0
- package/dist/src/lifecycle-health.js +282 -0
- package/dist/src/lsp-readonly.js +217 -0
- package/dist/src/mcp-graph-worker.js +69 -0
- package/dist/src/mcp-reliability.js +154 -0
- package/dist/src/mcp-worker-supervisor.js +350 -0
- package/dist/src/mirror.js +290 -0
- package/dist/src/node-runtime.js +157 -0
- package/dist/src/output-compression.js +630 -0
- package/dist/src/output-telemetry.js +368 -0
- package/dist/src/pr-triage.js +638 -0
- package/dist/src/progress-worker-runtime.js +46 -0
- package/dist/src/progressive-evidence.js +477 -0
- package/dist/src/pure-compression-cli.js +102 -0
- package/dist/src/relationship-adapters.js +377 -0
- package/dist/src/release-attestation.js +533 -0
- package/dist/src/release-preflight.js +513 -0
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repair-progress-worker.js +83 -2
- package/dist/src/repair-progress.js +262 -0
- package/dist/src/repository-init-process.js +177 -0
- package/dist/src/repository-management.js +1261 -0
- package/dist/src/resource-reachability.js +456 -0
- package/dist/src/response-budget.js +200 -0
- package/dist/src/server.js +217 -0
- package/dist/src/structural-fast-path.js +344 -0
- package/dist/src/structural-snapshot.js +37 -0
- package/dist/src/system-config.js +638 -0
- package/dist/src/terminal-help.js +83 -0
- package/dist/src/tools/knodin-tools.js +1645 -0
- package/dist/src/update-ceremony.js +162 -0
- package/dist/src/update-policy.js +944 -0
- package/dist/src/update-trust.js +504 -0
- package/dist/src/version.js +13 -0
- package/dist/src/visualization.js +515 -0
- package/dist/src/wait-for-fresh.js +98 -0
- package/dist/src/worktree-lifecycle.js +234 -0
- package/docs/BEHAVIORAL-CONTRACT.md +114 -0
- package/docs/CLI.md +30 -1
- package/docs/COMPARISON.md +413 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/CONTAINED-EXECUTION.md +77 -0
- package/docs/DEMO.md +49 -0
- package/docs/DIAGNOSTICS.md +80 -0
- package/docs/GIT-HISTORY-REVIEW.md +39 -0
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -18
- package/docs/MCP.md +64 -8
- package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
- package/docs/PT-ACCESS-RECOMMENDATION.md +89 -0
- package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +62 -0
- package/docs/SIGNED-UPDATES.md +151 -0
- package/docs/TELEMETRY.md +46 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/assets/knodin-favicon.svg +4 -0
- package/docs/releases/0.3.0.md +46 -0
- package/docs/releases/0.4.0.md +68 -0
- package/docs/releases/0.4.1.md +28 -0
- package/docs/releases/0.4.2.md +27 -0
- package/docs/releases/0.4.3.md +23 -0
- package/docs/releases/0.5.0.md +29 -0
- package/docs/releases/0.5.1.md +17 -0
- package/docs/releases/0.6.0.md +18 -0
- package/docs/releases/0.7.0.md +24 -0
- package/docs/releases/0.7.1.md +21 -0
- package/docs/releases/0.7.2.md +21 -0
- package/docs/releases/0.7.3.md +23 -0
- package/docs/releases/0.7.4.md +17 -0
- package/docs/releases/0.7.5.md +20 -0
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/docs/releases/0.8.3.md +47 -0
- package/package.json +139 -4
- package/roadmap/competitive-roadmap.md +3896 -0
- package/schemas/release-attestation-v1.schema.json +210 -0
- package/schemas/support-bundle-v2.schema.json +212 -0
- package/dist/chunks/chunk-DMQAGX77.js +0 -654
- package/dist/chunks/chunk-F4Z3Z766.js +0 -4
- package/dist/chunks/chunk-SIJAQVSX.js +0 -3
- package/dist/chunks/chunk-X6M4HUUE.js +0 -2
- package/dist/chunks/chunk-YPRMY2LP.js +0 -8
- package/dist/chunks/pure-compression-cli-4TA2TQD5.js +0 -5
- package/dist/chunks/server-7EDF4CBY.js +0 -14
- package/dist/chunks/structural-fast-path-KD5KQSPX.js +0 -4
- package/docs/releases/0.7.6.md +0 -25
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { compareBytes } from "./compare.js";
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
export const MIN_RESPONSE_BUDGET_BYTES = 256;
|
|
4
|
+
const MAX_REPORTED_TOTAL_PATHS = 128;
|
|
5
|
+
function bytes(value) {
|
|
6
|
+
return encoder.encode(JSON.stringify(value)).byteLength;
|
|
7
|
+
}
|
|
8
|
+
function positiveInt(value, fallback) {
|
|
9
|
+
return value == null || !Number.isFinite(value) ? fallback : Math.max(1, Math.trunc(value));
|
|
10
|
+
}
|
|
11
|
+
function collectArrays(value, path = "$") {
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
return [{ path, value }, ...value.flatMap((item, i) => collectArrays(item, `${path}/${i}`))];
|
|
14
|
+
}
|
|
15
|
+
if (!value || typeof value !== "object")
|
|
16
|
+
return [];
|
|
17
|
+
return Object.entries(value).flatMap(([key, child]) => collectArrays(child, `${path}/${key}`));
|
|
18
|
+
}
|
|
19
|
+
function collectStrings(value, path = "$") {
|
|
20
|
+
if (!value || typeof value !== "object")
|
|
21
|
+
return [];
|
|
22
|
+
const entries = Array.isArray(value) ? value.entries() : Object.entries(value);
|
|
23
|
+
const found = [];
|
|
24
|
+
for (const [key, child] of entries) {
|
|
25
|
+
const childPath = `${path}/${String(key)}`;
|
|
26
|
+
if (typeof child === "string") {
|
|
27
|
+
found.push({ parent: value, key, path: childPath, value: child });
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
found.push(...collectStrings(child, childPath));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
const PROTECTED_CONTRACT_KEYS = new Set([
|
|
36
|
+
"identity",
|
|
37
|
+
"symbol",
|
|
38
|
+
"file",
|
|
39
|
+
"filePath",
|
|
40
|
+
"error",
|
|
41
|
+
"status",
|
|
42
|
+
"operation",
|
|
43
|
+
"kind",
|
|
44
|
+
"name",
|
|
45
|
+
"target",
|
|
46
|
+
"to",
|
|
47
|
+
]);
|
|
48
|
+
function isTruncatableDetail(entry) {
|
|
49
|
+
return typeof entry.key !== "string" || !PROTECTED_CONTRACT_KEYS.has(entry.key);
|
|
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
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Applies limits to the value that is actually JSON-serialized. Token accounting
|
|
118
|
+
* intentionally uses the deterministic local estimate of four UTF-8 bytes/token;
|
|
119
|
+
* callers can therefore reproduce the ceiling without installing a tokenizer.
|
|
120
|
+
*/
|
|
121
|
+
export function applyResponseBudget(value, operation, request, defaults) {
|
|
122
|
+
const byteRequest = positiveInt(request.bytes, defaults.bytes);
|
|
123
|
+
const tokenLimit = positiveInt(request.tokens, defaults.tokens);
|
|
124
|
+
const byteLimit = Math.min(byteRequest, tokenLimit * 4);
|
|
125
|
+
if (byteLimit < MIN_RESPONSE_BUDGET_BYTES) {
|
|
126
|
+
throw new RangeError(`response budget must allow at least ${MIN_RESPONSE_BUDGET_BYTES} serialized bytes (64 estimated tokens)`);
|
|
127
|
+
}
|
|
128
|
+
const itemLimit = positiveInt(request.items, defaults.items);
|
|
129
|
+
const payload = structuredClone(value);
|
|
130
|
+
const objectRoot = payload && typeof payload === "object"
|
|
131
|
+
? payload
|
|
132
|
+
: { result: payload };
|
|
133
|
+
const root = Array.isArray(payload) ? { results: payload } : objectRoot;
|
|
134
|
+
const totals = {};
|
|
135
|
+
let truncated = false;
|
|
136
|
+
const initialArrays = collectArrays(root);
|
|
137
|
+
const reportedTotals = [...initialArrays]
|
|
138
|
+
// Byte order: this tie-break decides which paths survive the slice below,
|
|
139
|
+
// and the reported totals are serialized into the response.
|
|
140
|
+
.sort((left, right) => left.path.split("/").length - right.path.split("/").length ||
|
|
141
|
+
compareBytes(left.path, right.path))
|
|
142
|
+
.slice(0, MAX_REPORTED_TOTAL_PATHS);
|
|
143
|
+
for (const entry of reportedTotals)
|
|
144
|
+
totals[entry.path] = entry.value.length;
|
|
145
|
+
for (const entry of initialArrays) {
|
|
146
|
+
if (entry.value.length > itemLimit) {
|
|
147
|
+
entry.value.splice(itemLimit);
|
|
148
|
+
truncated = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const metadata = {
|
|
152
|
+
byteLimit,
|
|
153
|
+
tokenLimit,
|
|
154
|
+
itemLimit,
|
|
155
|
+
serializedBytes: 0,
|
|
156
|
+
estimatedTokens: 0,
|
|
157
|
+
truncated: false,
|
|
158
|
+
totals,
|
|
159
|
+
...(initialArrays.length > reportedTotals.length
|
|
160
|
+
? { omittedTotalPaths: initialArrays.length - reportedTotals.length }
|
|
161
|
+
: {}),
|
|
162
|
+
};
|
|
163
|
+
root.responseBudget = metadata;
|
|
164
|
+
// Preserve collection contracts and identifiers where possible: large source,
|
|
165
|
+
// artifact and diagnostic strings are the first expendable detail.
|
|
166
|
+
truncated = truncatePayloadToBytes(root, byteLimit) || truncated;
|
|
167
|
+
metadata.truncated = truncated;
|
|
168
|
+
if (truncated) {
|
|
169
|
+
metadata.continuation = {
|
|
170
|
+
operation,
|
|
171
|
+
after: "returned-items",
|
|
172
|
+
instruction: `Repeat ${operation} with a narrower selector, larger budget, or query drill-down.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
synchronizeNestedCapabilityBudget(root, metadata, operation);
|
|
176
|
+
// Metadata changes the byte count, so converge after setting it. The reserve
|
|
177
|
+
// above is normally enough; this final pass handles unusually long paths.
|
|
178
|
+
metadata.serializedBytes = bytes(root);
|
|
179
|
+
metadata.estimatedTokens = Math.ceil(metadata.serializedBytes / 4);
|
|
180
|
+
if (bytes(root) > byteLimit && metadata.continuation) {
|
|
181
|
+
metadata.continuation.instruction = "Narrow or increase budget.";
|
|
182
|
+
metadata.serializedBytes = bytes(root);
|
|
183
|
+
metadata.estimatedTokens = Math.ceil(metadata.serializedBytes / 4);
|
|
184
|
+
}
|
|
185
|
+
metadata.truncated = truncatePayloadToBytes(root, byteLimit) || metadata.truncated;
|
|
186
|
+
metadata.serializedBytes = bytes(root);
|
|
187
|
+
metadata.estimatedTokens = Math.ceil(metadata.serializedBytes / 4);
|
|
188
|
+
for (let iteration = 0; iteration < 8; iteration++) {
|
|
189
|
+
synchronizeNestedCapabilityBudget(root, metadata, operation);
|
|
190
|
+
const actual = bytes(root);
|
|
191
|
+
const tokens = Math.ceil(actual / 4);
|
|
192
|
+
if (metadata.serializedBytes === actual && metadata.estimatedTokens === tokens)
|
|
193
|
+
break;
|
|
194
|
+
metadata.serializedBytes = actual;
|
|
195
|
+
metadata.estimatedTokens = tokens;
|
|
196
|
+
}
|
|
197
|
+
if (bytes(root) > byteLimit)
|
|
198
|
+
throw new RangeError(`response budget ${byteLimit} cannot preserve the ${operation} response contract; narrow the request or increase the budget`);
|
|
199
|
+
return root;
|
|
200
|
+
}
|