knodin 0.8.2 → 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.
@@ -1,29 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { clearLine as clearTerminalLine, moveCursor as moveTerminalCursor, cursorTo as moveTerminalCursorTo, } from "node:readline";
3
2
  import ora from "ora";
4
- import { formatProgress, normalizeTerminalDimension, } from "./init-progress.js";
3
+ import { formatProgress } from "./init-progress.js";
4
+ import { consumeProgressLines, createProgressStream } from "./progress-worker-runtime.js";
5
5
  const terminalEnabled = process.stderr.isTTY === true || process.env.KNODIN_FORCE_PROGRESS_TTY === "1";
6
6
  // Ora divides rendered text width by `stream.columns`. Some pseudo-terminals
7
7
  // report zero columns, which turns its clear loop into an infinite loop. Keep
8
8
  // the real terminal methods while guaranteeing finite viewport dimensions.
9
- const progressStream = {
10
- isTTY: terminalEnabled,
11
- columns: normalizeTerminalDimension(process.stderr.columns, 80),
12
- rows: normalizeTerminalDimension(process.stderr.rows, 24),
13
- write: process.stderr.write.bind(process.stderr),
14
- cursorTo: (x, y, callback) => moveTerminalCursorTo(process.stderr, x, y, callback),
15
- moveCursor: (dx, dy, callback) => moveTerminalCursor(process.stderr, dx, dy, callback),
16
- clearLine: (direction, callback) => clearTerminalLine(process.stderr, direction, callback),
17
- once: process.stderr.once.bind(process.stderr),
18
- removeListener: process.stderr.removeListener.bind(process.stderr),
19
- };
9
+ const progressStream = createProgressStream(terminalEnabled);
20
10
  const spinner = ora({
21
11
  text: "[init:starting] Opening local graph database (0s elapsed)",
22
12
  stream: progressStream,
23
13
  isEnabled: terminalEnabled,
24
14
  discardStdin: false,
25
15
  });
26
- let buffer = "";
27
16
  let stopped = false;
28
17
  let exiting = false;
29
18
  let startedAt = 0;
@@ -75,32 +64,7 @@ function handle(line) {
75
64
  else
76
65
  stop(true);
77
66
  }
78
- process.stdin.setEncoding("utf8");
79
- process.stdin.on("data", (chunk) => {
80
- buffer += chunk;
81
- for (;;) {
82
- const newline = buffer.indexOf("\n");
83
- if (newline < 0)
84
- break;
85
- const line = buffer.slice(0, newline);
86
- buffer = buffer.slice(newline + 1);
87
- try {
88
- handle(line);
89
- }
90
- catch {
91
- // Progress transport is observational; malformed input cannot affect init.
92
- }
93
- }
94
- });
95
- process.stdin.on("end", () => {
96
- if (buffer) {
97
- try {
98
- handle(buffer);
99
- }
100
- catch { }
101
- }
102
- stop(true);
103
- });
67
+ consumeProgressLines(handle, () => stop(true));
104
68
  process.on("SIGTERM", () => {
105
69
  stop();
106
70
  process.exit(0);
@@ -0,0 +1,46 @@
1
+ import { clearLine as clearTerminalLine, moveCursor as moveTerminalCursor, cursorTo as moveTerminalCursorTo, } from "node:readline";
2
+ import { normalizeTerminalDimension } from "./init-progress.js";
3
+ export function createProgressStream(terminalEnabled) {
4
+ return {
5
+ isTTY: terminalEnabled,
6
+ columns: normalizeTerminalDimension(process.stderr.columns, 80),
7
+ rows: normalizeTerminalDimension(process.stderr.rows, 24),
8
+ write: process.stderr.write.bind(process.stderr),
9
+ cursorTo: (x, y, callback) => moveTerminalCursorTo(process.stderr, x, y, callback),
10
+ moveCursor: (dx, dy, callback) => moveTerminalCursor(process.stderr, dx, dy, callback),
11
+ clearLine: (direction, callback) => clearTerminalLine(process.stderr, direction, callback),
12
+ once: process.stderr.once.bind(process.stderr),
13
+ removeListener: process.stderr.removeListener.bind(process.stderr),
14
+ };
15
+ }
16
+ export function consumeProgressLines(handle, finish) {
17
+ let buffer = "";
18
+ process.stdin.setEncoding("utf8");
19
+ process.stdin.on("data", (chunk) => {
20
+ buffer += chunk;
21
+ for (;;) {
22
+ const newline = buffer.indexOf("\n");
23
+ if (newline < 0)
24
+ break;
25
+ const line = buffer.slice(0, newline);
26
+ buffer = buffer.slice(newline + 1);
27
+ try {
28
+ handle(line);
29
+ }
30
+ catch {
31
+ // Progress transport is observational and cannot affect lifecycle work.
32
+ }
33
+ }
34
+ });
35
+ process.stdin.on("end", () => {
36
+ if (buffer) {
37
+ try {
38
+ handle(buffer);
39
+ }
40
+ catch {
41
+ // Progress transport is observational and cannot affect lifecycle work.
42
+ }
43
+ }
44
+ finish();
45
+ });
46
+ }
@@ -1,27 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { clearLine as clearTerminalLine, moveCursor as moveTerminalCursor, cursorTo as moveTerminalCursorTo, } from "node:readline";
3
2
  import ora from "ora";
4
- import { normalizeTerminalDimension } from "./init-progress.js";
3
+ import { consumeProgressLines, createProgressStream } from "./progress-worker-runtime.js";
5
4
  import { formatRepairProgress } from "./repair-progress.js";
6
5
  const terminalEnabled = process.stderr.isTTY === true || process.env.KNODIN_FORCE_PROGRESS_TTY === "1";
7
- const progressStream = {
8
- isTTY: terminalEnabled,
9
- columns: normalizeTerminalDimension(process.stderr.columns, 80),
10
- rows: normalizeTerminalDimension(process.stderr.rows, 24),
11
- write: process.stderr.write.bind(process.stderr),
12
- cursorTo: (x, y, callback) => moveTerminalCursorTo(process.stderr, x, y, callback),
13
- moveCursor: (dx, dy, callback) => moveTerminalCursor(process.stderr, dx, dy, callback),
14
- clearLine: (direction, callback) => clearTerminalLine(process.stderr, direction, callback),
15
- once: process.stderr.once.bind(process.stderr),
16
- removeListener: process.stderr.removeListener.bind(process.stderr),
17
- };
6
+ const progressStream = createProgressStream(terminalEnabled);
18
7
  const spinner = ora({
19
8
  text: "[repair:starting] Opening local graph database (0s elapsed)",
20
9
  stream: progressStream,
21
10
  isEnabled: terminalEnabled,
22
11
  discardStdin: false,
23
12
  });
24
- let buffer = "";
25
13
  let stopped = false;
26
14
  let exiting = false;
27
15
  let startedAt = 0;
@@ -89,32 +77,7 @@ function handle(line) {
89
77
  else
90
78
  stop(true);
91
79
  }
92
- process.stdin.setEncoding("utf8");
93
- process.stdin.on("data", (chunk) => {
94
- buffer += chunk;
95
- for (;;) {
96
- const newline = buffer.indexOf("\n");
97
- if (newline < 0)
98
- break;
99
- const line = buffer.slice(0, newline);
100
- buffer = buffer.slice(newline + 1);
101
- try {
102
- handle(line);
103
- }
104
- catch {
105
- // Progress transport is observational; malformed input cannot affect repair.
106
- }
107
- }
108
- });
109
- process.stdin.on("end", () => {
110
- if (buffer) {
111
- try {
112
- handle(buffer);
113
- }
114
- catch { }
115
- }
116
- stop(true);
117
- });
80
+ consumeProgressLines(handle, () => stop(true));
118
81
  process.on("SIGTERM", () => {
119
82
  stop();
120
83
  process.exit(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
+ }