knodin 0.5.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/LICENSE +21 -0
- package/README.md +590 -0
- package/dist/bin/cli.js +1704 -0
- package/dist/src/agent-integration.js +250 -0
- package/dist/src/artifact-refresh.js +81 -0
- package/dist/src/cli-args.js +267 -0
- package/dist/src/cli-model.js +324 -0
- package/dist/src/compact-structural.js +96 -0
- package/dist/src/competitive-constraints.js +20 -0
- package/dist/src/competitive-manifest.js +330 -0
- package/dist/src/competitive-measurement.js +183 -0
- package/dist/src/competitive-runner.js +453 -0
- package/dist/src/competitive-sandbox.js +108 -0
- package/dist/src/context-export.js +422 -0
- package/dist/src/context.js +102 -0
- package/dist/src/docs-sections.js +141 -0
- package/dist/src/doctor.js +380 -0
- package/dist/src/engine/ann-hnsw.js +271 -0
- package/dist/src/engine/embeddings.js +193 -0
- package/dist/src/engine/file-walker.js +43 -0
- package/dist/src/engine/index.js +13030 -0
- package/dist/src/engine/perf.js +115 -0
- package/dist/src/engine/prune.js +112 -0
- package/dist/src/engine/source-policy.js +69 -0
- package/dist/src/engine/sqlite.js +71 -0
- package/dist/src/engine/symbol-delete.js +58 -0
- package/dist/src/failure-diagnosis.js +590 -0
- package/dist/src/fleet.js +7 -0
- package/dist/src/git-executable.js +31 -0
- package/dist/src/graph-query-health.js +115 -0
- package/dist/src/index-activity.js +125 -0
- package/dist/src/init-progress-worker.js +107 -0
- package/dist/src/init-progress.js +155 -0
- package/dist/src/init.js +985 -0
- package/dist/src/lifecycle-health.js +213 -0
- package/dist/src/lsp-readonly.js +217 -0
- package/dist/src/output-compression.js +629 -0
- package/dist/src/output-telemetry.js +359 -0
- package/dist/src/pr-triage.js +638 -0
- package/dist/src/relationship-adapters.js +370 -0
- package/dist/src/release-attestation.js +533 -0
- package/dist/src/repair-progress-worker.js +121 -0
- package/dist/src/repair-progress.js +262 -0
- package/dist/src/repository-init-process.js +173 -0
- package/dist/src/repository-management.js +1089 -0
- package/dist/src/response-budget.js +184 -0
- package/dist/src/server.js +53 -0
- package/dist/src/system-config.js +615 -0
- package/dist/src/terminal-help.js +83 -0
- package/dist/src/tools/knodin-tools.js +1438 -0
- package/dist/src/tools/reckon-tools.js +5 -0
- package/dist/src/update-policy.js +944 -0
- package/dist/src/update-trust.js +503 -0
- package/dist/src/version.js +13 -0
- package/dist/src/visualization.js +162 -0
- package/dist/src/wait-for-fresh.js +98 -0
- package/dist/src/worktree-lifecycle.js +231 -0
- package/docs/CLI.md +39 -0
- package/docs/COMMAND-OUTPUT-COMPRESSION.md +194 -0
- package/docs/DEAD-CODE-AND-IMPACT.md +27 -0
- package/docs/DOCTOR-AND-UPDATES.md +84 -0
- package/docs/INDEXING-POLICY-AND-PROVENANCE.md +37 -0
- package/docs/INSTALLATION.md +208 -0
- package/docs/MCP.md +100 -0
- package/docs/PT-ACCESS-RECOMMENDATION.md +91 -0
- package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +81 -0
- package/docs/SIGNED-UPDATES.md +146 -0
- package/docs/SYSTEMS-AND-RELATIONSHIPS.md +45 -0
- package/docs/TELEMETRY.md +42 -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/package.json +110 -0
- package/schemas/release-attestation-v1.schema.json +210 -0
- package/tree-sitter-prisma.wasm +0 -0
- package/tree-sitter-sql.wasm +0 -0
- package/tree-sitter-xml.wasm +0 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const DEFAULT_LINE_BUDGET = 200;
|
|
5
|
+
const DEFAULT_BYTE_BUDGET = 16_384;
|
|
6
|
+
const DEFAULT_INPUT_LIMIT = 16 * 1024 * 1024;
|
|
7
|
+
const MAX_INPUT_LIMIT = 64 * 1024 * 1024;
|
|
8
|
+
// JSON escaping can expand a one-byte control character to six bytes.
|
|
9
|
+
const MAX_STORED_ARTIFACT_BYTES = MAX_INPUT_LIMIT * 6 + 1024 * 1024;
|
|
10
|
+
const MAX_LINE_BUDGET = 10_000;
|
|
11
|
+
const MAX_BYTE_BUDGET = 4 * 1024 * 1024;
|
|
12
|
+
const ARTIFACT_ID = /^[a-f0-9]{64}$/;
|
|
13
|
+
const ESCAPE_CODE_POINT = 0x1b;
|
|
14
|
+
const BELL_CODE_POINT = 0x07;
|
|
15
|
+
function validateInteger(name, value, minimum, maximum, fallback) {
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
return fallback;
|
|
18
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum)
|
|
19
|
+
throw new Error(`knodin compress: ${name} must be an integer from ${minimum} to ${maximum}`);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function normalizeEvents(request) {
|
|
23
|
+
const hasText = request.text !== undefined;
|
|
24
|
+
const hasEvents = request.events !== undefined;
|
|
25
|
+
if (hasText === hasEvents)
|
|
26
|
+
throw new Error("knodin compress: provide exactly one of text or events");
|
|
27
|
+
if (hasText)
|
|
28
|
+
return [{ stream: "combined", text: request.text ?? "" }];
|
|
29
|
+
if (!Array.isArray(request.events) ||
|
|
30
|
+
request.events.some((event) => !event ||
|
|
31
|
+
!["combined", "stdout", "stderr"].includes(event.stream) ||
|
|
32
|
+
typeof event.text !== "string"))
|
|
33
|
+
throw new Error("knodin compress: events must contain valid stream/text records");
|
|
34
|
+
return request.events.map(({ stream, text }) => ({ stream, text }));
|
|
35
|
+
}
|
|
36
|
+
function inputBytes(events) {
|
|
37
|
+
return events.reduce((total, event) => total + Buffer.byteLength(event.text), 0);
|
|
38
|
+
}
|
|
39
|
+
function boundedInputLimit(value) {
|
|
40
|
+
return validateInteger("maxInputBytes", value, 1, MAX_INPUT_LIMIT, DEFAULT_INPUT_LIMIT);
|
|
41
|
+
}
|
|
42
|
+
function resolveInputFile(repo, relativePath) {
|
|
43
|
+
if (!relativePath || path.isAbsolute(relativePath))
|
|
44
|
+
throw new Error("knodin compress: input file must be a repo-relative path");
|
|
45
|
+
const resolvedRepo = fs.realpathSync(repo);
|
|
46
|
+
const lexical = path.resolve(resolvedRepo, relativePath);
|
|
47
|
+
const lexicalRelative = path.relative(resolvedRepo, lexical);
|
|
48
|
+
if (lexicalRelative === ".." ||
|
|
49
|
+
lexicalRelative.startsWith(`..${path.sep}`) ||
|
|
50
|
+
path.isAbsolute(lexicalRelative))
|
|
51
|
+
throw new Error("knodin compress: input file escapes the repository");
|
|
52
|
+
if (fs.lstatSync(lexical).isSymbolicLink())
|
|
53
|
+
throw new Error("knodin compress: refusing symlinked input file");
|
|
54
|
+
const resolvedFile = fs.realpathSync(lexical);
|
|
55
|
+
const resolvedRelative = path.relative(resolvedRepo, resolvedFile);
|
|
56
|
+
if (resolvedRelative === ".." ||
|
|
57
|
+
resolvedRelative.startsWith(`..${path.sep}`) ||
|
|
58
|
+
path.isAbsolute(resolvedRelative))
|
|
59
|
+
throw new Error("knodin compress: input file resolves outside the repository");
|
|
60
|
+
if (!fs.statSync(resolvedFile).isFile())
|
|
61
|
+
throw new Error("knodin compress: input path is not a regular file");
|
|
62
|
+
return resolvedFile;
|
|
63
|
+
}
|
|
64
|
+
function splitEvent(event) {
|
|
65
|
+
const lines = [];
|
|
66
|
+
let start = 0;
|
|
67
|
+
let index = 0;
|
|
68
|
+
while (index < event.text.length) {
|
|
69
|
+
const character = event.text[index];
|
|
70
|
+
if (character !== "\r" && character !== "\n") {
|
|
71
|
+
index++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const separatorLength = character === "\r" && event.text[index + 1] === "\n" ? 2 : 1;
|
|
75
|
+
const end = index + separatorLength;
|
|
76
|
+
lines.push({
|
|
77
|
+
raw: event.text.slice(start, index),
|
|
78
|
+
sourceBytes: Buffer.byteLength(event.text.slice(start, end)),
|
|
79
|
+
});
|
|
80
|
+
start = end;
|
|
81
|
+
index = end;
|
|
82
|
+
}
|
|
83
|
+
if (start < event.text.length)
|
|
84
|
+
lines.push({
|
|
85
|
+
raw: event.text.slice(start),
|
|
86
|
+
sourceBytes: Buffer.byteLength(event.text.slice(start)),
|
|
87
|
+
});
|
|
88
|
+
if (lines.length === 0)
|
|
89
|
+
lines.push({ raw: "", sourceBytes: 0 });
|
|
90
|
+
return lines;
|
|
91
|
+
}
|
|
92
|
+
function redact(text) {
|
|
93
|
+
let count = 0;
|
|
94
|
+
const replace = (pattern, replacement) => {
|
|
95
|
+
text = text.replace(pattern, (...values) => {
|
|
96
|
+
count++;
|
|
97
|
+
return typeof replacement === "string" ? replacement : replacement(...values);
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
replace(/\b(authorization|password|passwd|secret|token|api[_-]?key)\s*([:=])\s*([^\s,;]+)/gi, (_match, name, separator) => `${name}${separator}[REDACTED:credential]`);
|
|
101
|
+
replace(/\bAKIA[A-Z0-9]{16}\b/g, "[REDACTED:aws-access-key]");
|
|
102
|
+
replace(/\bgh[pousr]_\w{20,}\b/g, "[REDACTED:github-token]");
|
|
103
|
+
replace(/\bBearer\s+[-\w.~+/]{12,}=*\b/gi, "Bearer [REDACTED:bearer-token]");
|
|
104
|
+
replace(/\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b/g, "[REDACTED:jwt]");
|
|
105
|
+
return { text, count };
|
|
106
|
+
}
|
|
107
|
+
function csiSequenceEnd(text, escapeIndex) {
|
|
108
|
+
for (let index = escapeIndex + 2; index < text.length; index++) {
|
|
109
|
+
const codePoint = text.codePointAt(index) ?? 0;
|
|
110
|
+
if (codePoint >= 0x40 && codePoint <= 0x7e)
|
|
111
|
+
return index + 1;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
function oscSequenceEnd(text, escapeIndex) {
|
|
116
|
+
for (let index = escapeIndex + 2; index < text.length; index++) {
|
|
117
|
+
const codePoint = text.codePointAt(index);
|
|
118
|
+
if (codePoint === BELL_CODE_POINT)
|
|
119
|
+
return index + 1;
|
|
120
|
+
if (codePoint === ESCAPE_CODE_POINT && text[index + 1] === "\\")
|
|
121
|
+
return index + 2;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
function ansiSequenceEnd(text, escapeIndex) {
|
|
126
|
+
const sequenceType = text.codePointAt(escapeIndex + 1);
|
|
127
|
+
let detectedEnd = null;
|
|
128
|
+
if (sequenceType === 0x5b)
|
|
129
|
+
detectedEnd = csiSequenceEnd(text, escapeIndex);
|
|
130
|
+
else if (sequenceType === 0x5d)
|
|
131
|
+
detectedEnd = oscSequenceEnd(text, escapeIndex);
|
|
132
|
+
// Incomplete or unknown escape: drop only ESC so printable diagnostic text
|
|
133
|
+
// after it is not accidentally discarded.
|
|
134
|
+
return detectedEnd ?? escapeIndex + 1;
|
|
135
|
+
}
|
|
136
|
+
function sanitizeTerminalText(raw) {
|
|
137
|
+
let sanitized = "";
|
|
138
|
+
for (let index = 0; index < raw.length;) {
|
|
139
|
+
const codePoint = raw.codePointAt(index) ?? 0;
|
|
140
|
+
if (codePoint === ESCAPE_CODE_POINT) {
|
|
141
|
+
index = ansiSequenceEnd(raw, index);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const width = codePoint > 0xffff ? 2 : 1;
|
|
145
|
+
const isControl = codePoint <= 0x08 ||
|
|
146
|
+
codePoint === 0x0b ||
|
|
147
|
+
codePoint === 0x0c ||
|
|
148
|
+
(codePoint >= 0x0e && codePoint <= 0x1f) ||
|
|
149
|
+
codePoint === 0x7f;
|
|
150
|
+
sanitized += isControl ? `<0x${codePoint.toString(16)}>` : raw.slice(index, index + width);
|
|
151
|
+
index += width;
|
|
152
|
+
}
|
|
153
|
+
return sanitized;
|
|
154
|
+
}
|
|
155
|
+
function displayText(raw, redactSecrets) {
|
|
156
|
+
const sanitized = sanitizeTerminalText(raw);
|
|
157
|
+
return redactSecrets ? redact(sanitized) : { text: sanitized, count: 0 };
|
|
158
|
+
}
|
|
159
|
+
function harmlessErrorPhrase(text) {
|
|
160
|
+
return (/\b(?:0|zero|no)\s+errors?\b/i.test(text) ||
|
|
161
|
+
/\berror\s+(?:budget|handling|rate|page|message|type)\b/i.test(text));
|
|
162
|
+
}
|
|
163
|
+
const classifyVitestOrJest = (trimmed) => ["test files", "tests", "snapshots", "time"].some((label) => trimmed.toLowerCase().startsWith(label))
|
|
164
|
+
? "summary"
|
|
165
|
+
: null;
|
|
166
|
+
const classifyPytest = (trimmed) => trimmed.startsWith("=") && trimmed.endsWith("=") && /\b(?:failed|passed|errors?)\b/i.test(trimmed)
|
|
167
|
+
? "summary"
|
|
168
|
+
: null;
|
|
169
|
+
const classifyGoTest = (trimmed) => trimmed.startsWith("--- FAIL:") || trimmed === "FAIL" || trimmed.startsWith("FAIL ")
|
|
170
|
+
? "error"
|
|
171
|
+
: null;
|
|
172
|
+
const classifyMaven = (trimmed) => {
|
|
173
|
+
if (trimmed.startsWith("[ERROR]"))
|
|
174
|
+
return "error";
|
|
175
|
+
if (trimmed.startsWith("[WARNING]"))
|
|
176
|
+
return "warning";
|
|
177
|
+
return null;
|
|
178
|
+
};
|
|
179
|
+
const classifyGradle = (trimmed) => {
|
|
180
|
+
if (((trimmed.startsWith("> Task ") || trimmed.startsWith("BUILD FAILED")) &&
|
|
181
|
+
trimmed.includes("FAILED")) ||
|
|
182
|
+
trimmed.startsWith("FAILURE:"))
|
|
183
|
+
return "error";
|
|
184
|
+
return null;
|
|
185
|
+
};
|
|
186
|
+
const classifyDotnet = (trimmed) => /\berror [A-Z]{1,4}\d+\b/i.test(trimmed) || trimmed.startsWith("Failed! -") ? "error" : null;
|
|
187
|
+
const classifyCargo = (trimmed) => {
|
|
188
|
+
if (trimmed.startsWith("test result:"))
|
|
189
|
+
return "summary";
|
|
190
|
+
if (/^error(?:\[E\d+\])?:/i.test(trimmed))
|
|
191
|
+
return "error";
|
|
192
|
+
return null;
|
|
193
|
+
};
|
|
194
|
+
const ADAPTER_CLASSIFIERS = {
|
|
195
|
+
generic: () => null,
|
|
196
|
+
vitest: classifyVitestOrJest,
|
|
197
|
+
jest: classifyVitestOrJest,
|
|
198
|
+
pytest: classifyPytest,
|
|
199
|
+
"go-test": classifyGoTest,
|
|
200
|
+
maven: classifyMaven,
|
|
201
|
+
gradle: classifyGradle,
|
|
202
|
+
dotnet: classifyDotnet,
|
|
203
|
+
cargo: classifyCargo,
|
|
204
|
+
};
|
|
205
|
+
function classifyAdapterLine(text, adapter) {
|
|
206
|
+
return ADAPTER_CLASSIFIERS[adapter](text.trimStart());
|
|
207
|
+
}
|
|
208
|
+
function isStackLine(text, previousKind) {
|
|
209
|
+
const trimmed = text.trimStart();
|
|
210
|
+
return (/^at\s+\S/i.test(trimmed) ||
|
|
211
|
+
(/^File\s+"/i.test(trimmed) && /",\s+line\s+\d+/i.test(trimmed)) ||
|
|
212
|
+
trimmed.startsWith("Caused by:") ||
|
|
213
|
+
trimmed.startsWith("Suppressed:") ||
|
|
214
|
+
/^\.\.\. \d+ more/i.test(trimmed) ||
|
|
215
|
+
(previousKind === "error" && trimmed !== text && trimmed.length > 0));
|
|
216
|
+
}
|
|
217
|
+
function isErrorLine(text) {
|
|
218
|
+
if (harmlessErrorPhrase(text))
|
|
219
|
+
return false;
|
|
220
|
+
if (/(?:^|\W)(?:error|exception|fatal|panic|failed|failure|assertion|segmentation fault|caused by|erro|fehler|erreur|エラー)(?:\W|$)/i.test(text))
|
|
221
|
+
return true;
|
|
222
|
+
return /(?:^(?:[ \t]*(?:FAIL\b|✗|✕|×))|(?:\b(?:test|tests|suite|suites)\s+failed\b))/i.test(text);
|
|
223
|
+
}
|
|
224
|
+
function isWarningLine(text) {
|
|
225
|
+
return /(?:^|\W)(?:warn(?:ing)?|deprecated|avertissement|advertencia|警告)(?:\W|$)/i.test(text);
|
|
226
|
+
}
|
|
227
|
+
function isSummaryLine(text) {
|
|
228
|
+
return (/\b(?:tests?|suites?|passed|skipped)\s*[:=]/i.test(text) ||
|
|
229
|
+
/\b(?:duration|time|total|summary|failures?|warnings?|errors?)\s*[:=]/i.test(text) ||
|
|
230
|
+
/\b\d+\s+(?:passed|failed|skipped)\b/i.test(text) ||
|
|
231
|
+
/\b(?:0|zero|no)\s+errors?\b/i.test(text));
|
|
232
|
+
}
|
|
233
|
+
function classify(text, previousKind, adapter) {
|
|
234
|
+
const adapterKind = classifyAdapterLine(text, adapter);
|
|
235
|
+
if (adapterKind)
|
|
236
|
+
return adapterKind;
|
|
237
|
+
if (isStackLine(text, previousKind))
|
|
238
|
+
return "stack";
|
|
239
|
+
if (isErrorLine(text))
|
|
240
|
+
return "error";
|
|
241
|
+
if (isWarningLine(text))
|
|
242
|
+
return "warning";
|
|
243
|
+
if (isSummaryLine(text))
|
|
244
|
+
return "summary";
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
function detectAdapter(events, requested) {
|
|
248
|
+
if (requested !== "auto")
|
|
249
|
+
return requested;
|
|
250
|
+
const sample = events
|
|
251
|
+
.map(({ text }) => text)
|
|
252
|
+
.join("\n")
|
|
253
|
+
.slice(0, 256_000);
|
|
254
|
+
const lowerSample = sample.toLowerCase();
|
|
255
|
+
const sampleLines = sample.split(/\r\n|\r|\n/).map((line) => line.trimStart());
|
|
256
|
+
if (lowerSample.includes("vitest") || sampleLines.some((line) => line.startsWith("Test Files ")))
|
|
257
|
+
return "vitest";
|
|
258
|
+
if (lowerSample.includes("jest") || sampleLines.some((line) => line.startsWith("Snapshots:")))
|
|
259
|
+
return "jest";
|
|
260
|
+
if (lowerSample.includes("pytest") ||
|
|
261
|
+
sampleLines.some((line) => line.startsWith("=") && line.endsWith("=") && /\sin\s\d+(?:\.\d+)?s\s/.test(line)))
|
|
262
|
+
return "pytest";
|
|
263
|
+
if (sampleLines.some((line) => line.startsWith("ok ") ||
|
|
264
|
+
line.startsWith("FAIL ") ||
|
|
265
|
+
line.startsWith("--- PASS:") ||
|
|
266
|
+
line.startsWith("--- FAIL:")))
|
|
267
|
+
return "go-test";
|
|
268
|
+
if (sampleLines.some((line) => (line.startsWith("[INFO]") || line.startsWith("[ERROR]") || line.startsWith("[WARNING]")) &&
|
|
269
|
+
(line.toLowerCase().includes("maven") || line.includes("BUILD"))))
|
|
270
|
+
return "maven";
|
|
271
|
+
if (sampleLines.some((line) => line.startsWith("> Task ") || line.startsWith("* What went wrong:")))
|
|
272
|
+
return "gradle";
|
|
273
|
+
if (sampleLines.some((line) => /\berror [A-Z]{1,4}\d+\b/i.test(line) || line.startsWith("Failed! -")))
|
|
274
|
+
return "dotnet";
|
|
275
|
+
if (sampleLines.some((line) => /^error\[E\d+\]:/.test(line) || line.startsWith("test result:")))
|
|
276
|
+
return "cargo";
|
|
277
|
+
return "generic";
|
|
278
|
+
}
|
|
279
|
+
function flatten(events, redactSecrets, adapter) {
|
|
280
|
+
const lines = [];
|
|
281
|
+
let previousKind = null;
|
|
282
|
+
for (const event of events) {
|
|
283
|
+
for (const { raw, sourceBytes } of splitEvent(event)) {
|
|
284
|
+
const displayed = displayText(raw, redactSecrets);
|
|
285
|
+
const kind = classify(displayed.text, previousKind, adapter);
|
|
286
|
+
lines.push({
|
|
287
|
+
line: lines.length + 1,
|
|
288
|
+
stream: event.stream,
|
|
289
|
+
raw,
|
|
290
|
+
sourceBytes,
|
|
291
|
+
display: displayed.text,
|
|
292
|
+
kind,
|
|
293
|
+
secretRedactions: displayed.count,
|
|
294
|
+
});
|
|
295
|
+
previousKind = kind;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return lines;
|
|
299
|
+
}
|
|
300
|
+
function renderLine(line) {
|
|
301
|
+
if (line.stream === "combined")
|
|
302
|
+
return line.display;
|
|
303
|
+
return `[${line.stream} L${line.line}] ${line.display}`;
|
|
304
|
+
}
|
|
305
|
+
function renderedBytes(lines) {
|
|
306
|
+
return Buffer.byteLength(lines.map(renderLine).join("\n"));
|
|
307
|
+
}
|
|
308
|
+
function rangesFor(lines, selected, includeSelected) {
|
|
309
|
+
const ranges = [];
|
|
310
|
+
let active = [];
|
|
311
|
+
const flush = () => {
|
|
312
|
+
if (active.length === 0)
|
|
313
|
+
return;
|
|
314
|
+
ranges.push({
|
|
315
|
+
startLine: active[0].line,
|
|
316
|
+
endLine: active.at(-1)?.line ?? active[0].line,
|
|
317
|
+
lineCount: active.length,
|
|
318
|
+
byteCount: active.reduce((total, line) => total + line.sourceBytes, 0),
|
|
319
|
+
});
|
|
320
|
+
active = [];
|
|
321
|
+
};
|
|
322
|
+
for (const line of lines) {
|
|
323
|
+
if (selected.has(line.line) === includeSelected)
|
|
324
|
+
active.push(line);
|
|
325
|
+
else
|
|
326
|
+
flush();
|
|
327
|
+
}
|
|
328
|
+
flush();
|
|
329
|
+
return ranges;
|
|
330
|
+
}
|
|
331
|
+
function percentReduction(input, output) {
|
|
332
|
+
if (input === 0)
|
|
333
|
+
return 0;
|
|
334
|
+
return Math.round((1 - output / input) * 10_000) / 100;
|
|
335
|
+
}
|
|
336
|
+
function artifactDirectory(repo) {
|
|
337
|
+
const resolvedRepo = fs.realpathSync(repo);
|
|
338
|
+
const knodinDirectory = path.join(resolvedRepo, ".reckon");
|
|
339
|
+
const outputDirectory = path.join(knodinDirectory, "output");
|
|
340
|
+
for (const candidate of [knodinDirectory, outputDirectory]) {
|
|
341
|
+
if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink())
|
|
342
|
+
throw new Error(`knodin compress: refusing symlinked artifact directory ${candidate}`);
|
|
343
|
+
}
|
|
344
|
+
fs.mkdirSync(outputDirectory, { recursive: true, mode: 0o700 });
|
|
345
|
+
fs.chmodSync(outputDirectory, 0o700);
|
|
346
|
+
return outputDirectory;
|
|
347
|
+
}
|
|
348
|
+
function artifactPath(repo, id) {
|
|
349
|
+
if (!ARTIFACT_ID.test(id))
|
|
350
|
+
throw new Error("knodin compress: invalid artifact id");
|
|
351
|
+
return path.join(artifactDirectory(repo), `${id}.json`);
|
|
352
|
+
}
|
|
353
|
+
function artifactIdentity(events, exit) {
|
|
354
|
+
return crypto.createHash("sha256").update(JSON.stringify({ events, exit })).digest("hex");
|
|
355
|
+
}
|
|
356
|
+
function persistArtifact(repo, events, exit) {
|
|
357
|
+
const id = artifactIdentity(events, exit);
|
|
358
|
+
const target = artifactPath(repo, id);
|
|
359
|
+
if (!fs.existsSync(target)) {
|
|
360
|
+
const artifact = {
|
|
361
|
+
schemaVersion: 1,
|
|
362
|
+
id,
|
|
363
|
+
createdAt: new Date().toISOString(),
|
|
364
|
+
exit,
|
|
365
|
+
events,
|
|
366
|
+
};
|
|
367
|
+
const descriptor = fs.openSync(target, fs.constants.O_WRONLY |
|
|
368
|
+
fs.constants.O_CREAT |
|
|
369
|
+
fs.constants.O_EXCL |
|
|
370
|
+
(fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
371
|
+
try {
|
|
372
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(artifact)}\n`);
|
|
373
|
+
fs.fsyncSync(descriptor);
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
fs.closeSync(descriptor);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!fs.lstatSync(target).isFile())
|
|
380
|
+
throw new Error("knodin compress: artifact target is not a regular file");
|
|
381
|
+
// Existing content-addressed artifacts must still match their name. This
|
|
382
|
+
// catches local tampering and partial writes instead of silently reusing them.
|
|
383
|
+
loadArtifact(repo, id);
|
|
384
|
+
fs.chmodSync(target, 0o600);
|
|
385
|
+
return {
|
|
386
|
+
id,
|
|
387
|
+
relativePath: path.relative(fs.realpathSync(repo), target).split(path.sep).join("/"),
|
|
388
|
+
bytes: fs.statSync(target).size,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
function loadArtifact(repo, id) {
|
|
392
|
+
const target = artifactPath(repo, id);
|
|
393
|
+
const stats = fs.lstatSync(target);
|
|
394
|
+
if (!stats.isFile())
|
|
395
|
+
throw new Error("knodin compress: artifact target is not a regular file");
|
|
396
|
+
if (stats.size > MAX_STORED_ARTIFACT_BYTES)
|
|
397
|
+
throw new Error("knodin compress: retained artifact exceeds the bounded storage limit");
|
|
398
|
+
const descriptor = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
399
|
+
try {
|
|
400
|
+
const parsed = JSON.parse(fs.readFileSync(descriptor, "utf-8"));
|
|
401
|
+
if (parsed.schemaVersion !== 1 ||
|
|
402
|
+
parsed.id !== id ||
|
|
403
|
+
!parsed.exit ||
|
|
404
|
+
!(parsed.exit.code === null ||
|
|
405
|
+
(typeof parsed.exit.code === "number" && Number.isInteger(parsed.exit.code))) ||
|
|
406
|
+
!(parsed.exit.signal === null || typeof parsed.exit.signal === "string") ||
|
|
407
|
+
!Array.isArray(parsed.events) ||
|
|
408
|
+
parsed.events.some((event) => !event ||
|
|
409
|
+
!["combined", "stdout", "stderr"].includes(event.stream) ||
|
|
410
|
+
typeof event.text !== "string"))
|
|
411
|
+
throw new Error("knodin compress: invalid retained artifact");
|
|
412
|
+
if (artifactIdentity(parsed.events, parsed.exit) !== id)
|
|
413
|
+
throw new Error("knodin compress: retained artifact identity mismatch");
|
|
414
|
+
return parsed;
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
fs.closeSync(descriptor);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function setCandidateScore(scores, line, value) {
|
|
421
|
+
scores.set(line, Math.max(value, scores.get(line) ?? 0));
|
|
422
|
+
}
|
|
423
|
+
function scoreMandatoryContext(scores, lines, mandatory, contextLines) {
|
|
424
|
+
for (const line of mandatory) {
|
|
425
|
+
setCandidateScore(scores, line, 1_000);
|
|
426
|
+
for (let offset = 1; offset <= contextLines; offset++) {
|
|
427
|
+
if (line - offset >= 1)
|
|
428
|
+
setCandidateScore(scores, line - offset, 600 - offset);
|
|
429
|
+
if (line + offset <= lines.length)
|
|
430
|
+
setCandidateScore(scores, line + offset, 600 - offset);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function scoreEdges(scores, lineCount, edgeLines) {
|
|
435
|
+
for (let index = 1; index <= Math.min(edgeLines, lineCount); index++)
|
|
436
|
+
setCandidateScore(scores, index, 300 - index);
|
|
437
|
+
for (let index = Math.max(1, lineCount - edgeLines + 1); index <= lineCount; index++)
|
|
438
|
+
setCandidateScore(scores, index, 300 - (lineCount - index));
|
|
439
|
+
}
|
|
440
|
+
function scoreHeadTail(scores, lineCount) {
|
|
441
|
+
const window = Math.min(10, Math.ceil(lineCount / 2));
|
|
442
|
+
for (let index = 1; index <= window; index++)
|
|
443
|
+
setCandidateScore(scores, index, 400 - index);
|
|
444
|
+
for (let index = Math.max(1, lineCount - window + 1); index <= lineCount; index++)
|
|
445
|
+
setCandidateScore(scores, index, 400 - (lineCount - index));
|
|
446
|
+
}
|
|
447
|
+
function candidateScores(lines, mandatory, strategy, contextLines) {
|
|
448
|
+
const scores = new Map();
|
|
449
|
+
scoreMandatoryContext(scores, lines, mandatory, contextLines);
|
|
450
|
+
if (strategy !== "errors-only" || mandatory.size === 0)
|
|
451
|
+
scoreEdges(scores, lines.length, mandatory.size > 0 ? 1 : 3);
|
|
452
|
+
if (strategy === "head-tail")
|
|
453
|
+
scoreHeadTail(scores, lines.length);
|
|
454
|
+
return scores;
|
|
455
|
+
}
|
|
456
|
+
function selectLines(lines, mandatory, strategy, lineBudget, byteBudget, contextLines) {
|
|
457
|
+
const selected = new Set();
|
|
458
|
+
const fits = (candidate) => {
|
|
459
|
+
const next = [...selected, candidate.line]
|
|
460
|
+
.sort((left, right) => left - right)
|
|
461
|
+
.map((line) => lines[line - 1]);
|
|
462
|
+
return next.length <= lineBudget && renderedBytes(next) <= byteBudget;
|
|
463
|
+
};
|
|
464
|
+
for (const lineNumber of [...mandatory].sort((left, right) => left - right)) {
|
|
465
|
+
const candidate = lines[lineNumber - 1];
|
|
466
|
+
if (candidate && fits(candidate))
|
|
467
|
+
selected.add(lineNumber);
|
|
468
|
+
}
|
|
469
|
+
const scores = candidateScores(lines, mandatory, strategy, contextLines);
|
|
470
|
+
const candidates = [...scores]
|
|
471
|
+
.filter(([line]) => !selected.has(line))
|
|
472
|
+
.sort(([leftLine, leftScore], [rightLine, rightScore]) => {
|
|
473
|
+
if (leftScore !== rightScore)
|
|
474
|
+
return rightScore - leftScore;
|
|
475
|
+
return leftLine - rightLine;
|
|
476
|
+
});
|
|
477
|
+
for (const [lineNumber] of candidates) {
|
|
478
|
+
const candidate = lines[lineNumber - 1];
|
|
479
|
+
if (candidate && fits(candidate))
|
|
480
|
+
selected.add(lineNumber);
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
selected,
|
|
484
|
+
unpreservedSignals: [...mandatory].filter((line) => !selected.has(line)).sort((a, b) => a - b),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
export function compressOutput(repo, request) {
|
|
488
|
+
const events = normalizeEvents(request);
|
|
489
|
+
const lineBudget = validateInteger("lineBudget", request.lineBudget, 1, MAX_LINE_BUDGET, DEFAULT_LINE_BUDGET);
|
|
490
|
+
const byteBudget = validateInteger("byteBudget", request.byteBudget, 256, MAX_BYTE_BUDGET, DEFAULT_BYTE_BUDGET);
|
|
491
|
+
const contextLines = validateInteger("contextLines", request.contextLines, 0, 10, 1);
|
|
492
|
+
const maxInputBytes = boundedInputLimit(request.maxInputBytes);
|
|
493
|
+
const bytes = inputBytes(events);
|
|
494
|
+
if (bytes > maxInputBytes)
|
|
495
|
+
throw new Error(`knodin compress: input is ${bytes} bytes; bounded retention limit is ${maxInputBytes}`);
|
|
496
|
+
const strategy = request.strategy ?? "smart";
|
|
497
|
+
if (!["smart", "head-tail", "errors-only"].includes(strategy))
|
|
498
|
+
throw new Error("knodin compress: invalid strategy");
|
|
499
|
+
const requestedAdapter = request.adapter ?? "auto";
|
|
500
|
+
if (![
|
|
501
|
+
"auto",
|
|
502
|
+
"generic",
|
|
503
|
+
"vitest",
|
|
504
|
+
"jest",
|
|
505
|
+
"pytest",
|
|
506
|
+
"go-test",
|
|
507
|
+
"maven",
|
|
508
|
+
"gradle",
|
|
509
|
+
"dotnet",
|
|
510
|
+
"cargo",
|
|
511
|
+
].includes(requestedAdapter))
|
|
512
|
+
throw new Error("knodin compress: invalid adapter");
|
|
513
|
+
const adapter = detectAdapter(events, requestedAdapter);
|
|
514
|
+
const exit = { code: request.exitCode ?? null, signal: request.signal ?? null };
|
|
515
|
+
const retained = request.retain === false ? null : persistArtifact(repo, events, { ...exit });
|
|
516
|
+
const lines = flatten(events, request.redactSecrets !== false, adapter);
|
|
517
|
+
const mandatory = new Set(lines.filter(({ kind }) => kind !== null).map(({ line }) => line));
|
|
518
|
+
const allFit = lines.length <= lineBudget && renderedBytes(lines) <= byteBudget;
|
|
519
|
+
const selection = allFit
|
|
520
|
+
? { selected: new Set(lines.map(({ line }) => line)), unpreservedSignals: [] }
|
|
521
|
+
: selectLines(lines, mandatory, strategy, lineBudget, byteBudget, contextLines);
|
|
522
|
+
const selectedLines = lines.filter(({ line }) => selection.selected.has(line));
|
|
523
|
+
const content = selectedLines.map(renderLine).join("\n");
|
|
524
|
+
const contentBytes = Buffer.byteLength(content);
|
|
525
|
+
const complete = selection.selected.size === lines.length;
|
|
526
|
+
const insufficient = selection.unpreservedSignals.length > 0;
|
|
527
|
+
let status = "compressed";
|
|
528
|
+
if (complete)
|
|
529
|
+
status = "complete";
|
|
530
|
+
else if (insufficient)
|
|
531
|
+
status = "insufficient-budget";
|
|
532
|
+
return {
|
|
533
|
+
schemaVersion: 1,
|
|
534
|
+
status,
|
|
535
|
+
complete,
|
|
536
|
+
strategy,
|
|
537
|
+
adapter,
|
|
538
|
+
content,
|
|
539
|
+
exit,
|
|
540
|
+
artifact: {
|
|
541
|
+
id: retained?.id ?? artifactIdentity(events, exit),
|
|
542
|
+
retained: retained !== null,
|
|
543
|
+
path: retained?.relativePath ?? null,
|
|
544
|
+
bytes: retained?.bytes ?? 0,
|
|
545
|
+
},
|
|
546
|
+
input: {
|
|
547
|
+
lines: lines.length,
|
|
548
|
+
bytes,
|
|
549
|
+
streams: [...new Set(events.map(({ stream }) => stream))],
|
|
550
|
+
},
|
|
551
|
+
output: {
|
|
552
|
+
lines: selectedLines.length,
|
|
553
|
+
bytes: contentBytes,
|
|
554
|
+
lineBudget,
|
|
555
|
+
byteBudget,
|
|
556
|
+
budgetAppliesTo: "content-utf8",
|
|
557
|
+
reductionPercent: percentReduction(bytes, contentBytes),
|
|
558
|
+
},
|
|
559
|
+
fidelity: {
|
|
560
|
+
detectedSignals: mandatory.size,
|
|
561
|
+
preservedSignals: mandatory.size - selection.unpreservedSignals.length,
|
|
562
|
+
unpreservedSignals: selection.unpreservedSignals,
|
|
563
|
+
secretRedactions: lines.reduce((total, line) => total + line.secretRedactions, 0),
|
|
564
|
+
unknownFormat: mandatory.size === 0,
|
|
565
|
+
},
|
|
566
|
+
includedRanges: rangesFor(lines, selection.selected, true),
|
|
567
|
+
omittedRanges: rangesFor(lines, selection.selected, false),
|
|
568
|
+
continuation: retained
|
|
569
|
+
? {
|
|
570
|
+
operation: "compress",
|
|
571
|
+
compressionAction: "read",
|
|
572
|
+
artifactId: retained.id,
|
|
573
|
+
}
|
|
574
|
+
: null,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
export function compressOutputFile(repo, relativePath, request = {}) {
|
|
578
|
+
const input = resolveInputFile(repo, relativePath);
|
|
579
|
+
const limit = boundedInputLimit(request.maxInputBytes);
|
|
580
|
+
const size = fs.statSync(input).size;
|
|
581
|
+
if (size > limit)
|
|
582
|
+
throw new Error(`knodin compress: input is ${size} bytes; bounded retention limit is ${limit}`);
|
|
583
|
+
return compressOutput(repo, {
|
|
584
|
+
...request,
|
|
585
|
+
text: fs.readFileSync(input, "utf-8"),
|
|
586
|
+
maxInputBytes: limit,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
export function readOutputArtifact(repo, id, request = {}) {
|
|
590
|
+
const artifact = loadArtifact(repo, id);
|
|
591
|
+
const byteBudget = validateInteger("byteBudget", request.byteBudget, 256, MAX_BYTE_BUDGET, DEFAULT_BYTE_BUDGET);
|
|
592
|
+
const lines = flatten(artifact.events, request.raw !== true, detectAdapter(artifact.events, "auto"));
|
|
593
|
+
const startLine = validateInteger("startLine", request.startLine, 1, Math.max(1, lines.length), 1);
|
|
594
|
+
const endLine = validateInteger("endLine", request.endLine, startLine, Math.max(startLine, lines.length), Math.min(lines.length, startLine + 199));
|
|
595
|
+
const selected = [];
|
|
596
|
+
for (const line of lines.slice(startLine - 1, endLine)) {
|
|
597
|
+
const candidate = [...selected, line];
|
|
598
|
+
if (renderedBytes(candidate) > byteBudget)
|
|
599
|
+
break;
|
|
600
|
+
selected.push(line);
|
|
601
|
+
}
|
|
602
|
+
const content = selected.map(renderLine).join("\n");
|
|
603
|
+
const actualEnd = selected.at(-1)?.line ?? startLine - 1;
|
|
604
|
+
return {
|
|
605
|
+
schemaVersion: 1,
|
|
606
|
+
artifactId: id,
|
|
607
|
+
content,
|
|
608
|
+
raw: request.raw === true,
|
|
609
|
+
range: { startLine, endLine: actualEnd, totalLines: lines.length },
|
|
610
|
+
bytes: Buffer.byteLength(content),
|
|
611
|
+
byteBudget,
|
|
612
|
+
complete: startLine === 1 && actualEnd === lines.length,
|
|
613
|
+
secretRedactions: lines.reduce((total, line) => total + line.secretRedactions, 0),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
export function deleteOutputArtifact(repo, id) {
|
|
617
|
+
const target = artifactPath(repo, id);
|
|
618
|
+
try {
|
|
619
|
+
const descriptor = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
620
|
+
fs.closeSync(descriptor);
|
|
621
|
+
fs.unlinkSync(target);
|
|
622
|
+
return { deleted: true, artifactId: id };
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
if (error.code === "ENOENT")
|
|
626
|
+
return { deleted: false, artifactId: id };
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
}
|