knodin 0.10.2 → 0.10.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/dist/bin/cli.js +41 -4
- package/dist/src/cli-args.js +4 -0
- package/dist/src/cli-model.js +2 -0
- package/dist/src/engine/candidate-database.js +53 -0
- package/dist/src/engine/index.js +317 -22
- package/dist/src/engine/text-matches.js +309 -0
- package/dist/src/init-progress.js +36 -5
- package/dist/src/init.js +8 -1
- package/dist/src/lifecycle-health.js +36 -0
- package/dist/src/node-runtime.js +33 -0
- package/dist/src/release-compatibility.js +95 -0
- package/dist/src/release-preflight.js +18 -0
- package/dist/src/tools/knodin-tools.js +36 -7
- package/dist/src/update-policy.js +6 -1
- package/dist/src/wait-for-fresh.js +43 -1
- package/docs/releases/0.10.3.md +93 -0
- package/docs/releases/0.10.4.md +161 -0
- package/package.json +4 -2
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact-text search with per-match classification (EASFDC-8496).
|
|
3
|
+
*
|
|
4
|
+
* The point of this module is the one thing `rg` cannot do: say *what a match
|
|
5
|
+
* is*. A product-name rename spans UI strings, prose and identifiers, and the
|
|
6
|
+
* reported case that motivated this had visible `NOVA` labels needing change
|
|
7
|
+
* while `NOVA-CANARY-…` was a deliberate security token that must not. A flat
|
|
8
|
+
* text search returns both and leaves a human to separate them by eye across
|
|
9
|
+
* however many hits a large repository yields.
|
|
10
|
+
*
|
|
11
|
+
* knodin can do better only because it already parses these files. For a file
|
|
12
|
+
* with a grammar, a match resolves to the smallest syntax node containing it,
|
|
13
|
+
* and the parser's own node type is the evidence. For a file without one, the
|
|
14
|
+
* strongest honest claim is about the file, not the match.
|
|
15
|
+
*
|
|
16
|
+
* That gap is not hidden. Every match carries the `basis` it was classified on,
|
|
17
|
+
* so a reviewer can see which answers came from a parse tree and which are an
|
|
18
|
+
* inference from a file extension. Classification will sometimes be wrong; it
|
|
19
|
+
* is offered as evidence a human is reviewing, never as a decision already
|
|
20
|
+
* taken on their behalf.
|
|
21
|
+
*/
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { walkRepoFiles } from "./file-walker.js";
|
|
25
|
+
const WORD_CHARACTER = /[\p{L}\p{N}_]/u;
|
|
26
|
+
/** Upper bound on `TextMatch.enclosingText`, so one row cannot flood a preview. */
|
|
27
|
+
const ENCLOSING_TEXT_LIMIT = 120;
|
|
28
|
+
function isWordCharacter(value) {
|
|
29
|
+
return value !== undefined && WORD_CHARACTER.test(value);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Map a grammar's node type onto a coarse class.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately substring-based rather than an exhaustive per-language table.
|
|
35
|
+
* Across the grammars this project loads, the same concept appears as `string`,
|
|
36
|
+
* `string_literal`, `template_string`, `interpreted_string_literal`, `comment`,
|
|
37
|
+
* `line_comment`, `identifier`, `property_identifier` and more, and any fixed
|
|
38
|
+
* list would silently degrade to "unclassified" the first time a grammar is
|
|
39
|
+
* updated or added. An unrecognised type keeps `unclassified` while the real
|
|
40
|
+
* node type still travels on the match, so nothing is lost by guessing less.
|
|
41
|
+
*/
|
|
42
|
+
export function classifyNodeType(nodeType) {
|
|
43
|
+
const normalized = nodeType.toLowerCase();
|
|
44
|
+
if (normalized.includes("comment"))
|
|
45
|
+
return "comment";
|
|
46
|
+
if (normalized.includes("string") || normalized.includes("char_literal"))
|
|
47
|
+
return "string-literal";
|
|
48
|
+
if (normalized.includes("identifier") || normalized === "word")
|
|
49
|
+
return "identifier";
|
|
50
|
+
return "unclassified";
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Extensions whose contents are prose rather than code.
|
|
54
|
+
*
|
|
55
|
+
* A file-type claim, and labelled as one: `basis: "file-type"`. Prose is the
|
|
56
|
+
* common case for the renames this feature exists to serve, so saying nothing
|
|
57
|
+
* at all about a markdown file would leave most matches unclassified.
|
|
58
|
+
*/
|
|
59
|
+
const PROSE_EXTENSIONS = new Set([".md", ".mdx", ".markdown", ".txt", ".rst", ".adoc", ".org"]);
|
|
60
|
+
function extensionOf(file) {
|
|
61
|
+
const base = file.slice(file.lastIndexOf("/") + 1);
|
|
62
|
+
const dot = base.lastIndexOf(".");
|
|
63
|
+
return dot <= 0 ? "" : base.slice(dot).toLowerCase();
|
|
64
|
+
}
|
|
65
|
+
export function classifyByFileType(file) {
|
|
66
|
+
return PROSE_EXTENSIONS.has(extensionOf(file)) ? "prose" : "unclassified";
|
|
67
|
+
}
|
|
68
|
+
/** Every occurrence of `term`, non-overlapping, left to right. */
|
|
69
|
+
function findOccurrences(content, term, caseSensitive) {
|
|
70
|
+
if (term.length === 0)
|
|
71
|
+
return [];
|
|
72
|
+
const haystack = caseSensitive ? content : content.toLowerCase();
|
|
73
|
+
const needle = caseSensitive ? term : term.toLowerCase();
|
|
74
|
+
const found = [];
|
|
75
|
+
let from = 0;
|
|
76
|
+
for (;;) {
|
|
77
|
+
const at = haystack.indexOf(needle, from);
|
|
78
|
+
if (at === -1)
|
|
79
|
+
break;
|
|
80
|
+
found.push({
|
|
81
|
+
startIndex: at,
|
|
82
|
+
endIndex: at + term.length,
|
|
83
|
+
// Sliced from the ORIGINAL content, so the reported text carries the
|
|
84
|
+
// case actually on disk rather than the case that was searched for.
|
|
85
|
+
matchedText: content.slice(at, at + term.length),
|
|
86
|
+
});
|
|
87
|
+
from = at + term.length;
|
|
88
|
+
}
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
/** Byte-free line/column lookup, built once per file rather than per match. */
|
|
92
|
+
function lineStarts(content) {
|
|
93
|
+
const starts = [0];
|
|
94
|
+
for (let i = 0; i < content.length; i++)
|
|
95
|
+
if (content[i] === "\n")
|
|
96
|
+
starts.push(i + 1);
|
|
97
|
+
return starts;
|
|
98
|
+
}
|
|
99
|
+
function lineIndexFor(starts, index) {
|
|
100
|
+
let low = 0;
|
|
101
|
+
let high = starts.length - 1;
|
|
102
|
+
while (low < high) {
|
|
103
|
+
const mid = (low + high + 1) >> 1;
|
|
104
|
+
if (starts[mid] <= index)
|
|
105
|
+
low = mid;
|
|
106
|
+
else
|
|
107
|
+
high = mid - 1;
|
|
108
|
+
}
|
|
109
|
+
return low;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A tree-sitter abort is permanent and process-wide, not a per-file failure.
|
|
113
|
+
*
|
|
114
|
+
* Mirrors the engine's own guard (EASFDC-8445): swallowing one of these turns a
|
|
115
|
+
* dead WASM module into "this file has no matches" for every remaining file,
|
|
116
|
+
* which is precisely the silent-undercount this feature must not produce.
|
|
117
|
+
*/
|
|
118
|
+
function isWasmAbort(error) {
|
|
119
|
+
return (typeof error === "object" &&
|
|
120
|
+
error !== null &&
|
|
121
|
+
error.name === "ExitStatus");
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Classify each match in one file against its parse tree.
|
|
125
|
+
*
|
|
126
|
+
* Parses once for the whole file, not once per match. Returns null when the
|
|
127
|
+
* file has no grammar, leaving the caller to fall back to a file-type claim.
|
|
128
|
+
*/
|
|
129
|
+
function classifyAgainstTree(parser, content, raw) {
|
|
130
|
+
const tree = parser.parse(content);
|
|
131
|
+
if (!tree)
|
|
132
|
+
return null;
|
|
133
|
+
try {
|
|
134
|
+
return raw.map((match) => {
|
|
135
|
+
// `endIndex - 1` because the range is inclusive: passing the exclusive
|
|
136
|
+
// end can select the following sibling for a match ending at a
|
|
137
|
+
// boundary, which mislabels exactly the tokens that sit flush against
|
|
138
|
+
// punctuation — quotes, most of all.
|
|
139
|
+
const node = tree.rootNode.descendantForIndex(match.startIndex, Math.max(match.startIndex, match.endIndex - 1));
|
|
140
|
+
const nodeType = node?.type ?? "";
|
|
141
|
+
const text = node?.text ?? "";
|
|
142
|
+
return {
|
|
143
|
+
classification: classifyNodeType(nodeType),
|
|
144
|
+
nodeType,
|
|
145
|
+
// Bounded: a match inside a large node (a whole template literal,
|
|
146
|
+
// a long comment) must not turn one row of a preview into a page.
|
|
147
|
+
enclosingText: text.length > ENCLOSING_TEXT_LIMIT ? `${text.slice(0, ENCLOSING_TEXT_LIMIT)}…` : text,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
tree.delete();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Search already-read files for an exact term and classify every hit.
|
|
157
|
+
*
|
|
158
|
+
* `loadLanguage` is injected rather than imported so this module can be tested
|
|
159
|
+
* without standing up the engine's grammar loader, and so a caller that already
|
|
160
|
+
* knows a file has no grammar can skip the lookup.
|
|
161
|
+
*
|
|
162
|
+
* The parser is created lazily and freed in a `finally`: this walks the same
|
|
163
|
+
* ground as the leak that left 174,205 files silently unparsed, and a search
|
|
164
|
+
* over a large repository touches enough files to reproduce it exactly.
|
|
165
|
+
*/
|
|
166
|
+
export async function searchFiles(files, term, loadLanguage, newParser, options = {}) {
|
|
167
|
+
const caseSensitive = options.caseSensitive ?? true;
|
|
168
|
+
const matches = [];
|
|
169
|
+
const uncovered = [];
|
|
170
|
+
let filesSearched = 0;
|
|
171
|
+
for (const { file, content } of files) {
|
|
172
|
+
const raw = findOccurrences(content, term, caseSensitive);
|
|
173
|
+
filesSearched++;
|
|
174
|
+
if (raw.length === 0)
|
|
175
|
+
continue;
|
|
176
|
+
let classified = null;
|
|
177
|
+
let language = null;
|
|
178
|
+
try {
|
|
179
|
+
language = await loadLanguage(file);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// A grammar that will not load costs classification, not the match.
|
|
183
|
+
language = null;
|
|
184
|
+
}
|
|
185
|
+
if (language) {
|
|
186
|
+
const parser = newParser();
|
|
187
|
+
try {
|
|
188
|
+
parser.setLanguage(language);
|
|
189
|
+
classified = classifyAgainstTree(parser, content, raw);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (isWasmAbort(error))
|
|
193
|
+
throw error;
|
|
194
|
+
// Parsing failed on this file only. The matches are still real and
|
|
195
|
+
// still reported — just without node evidence.
|
|
196
|
+
classified = null;
|
|
197
|
+
uncovered.push({
|
|
198
|
+
file,
|
|
199
|
+
reason: `matched but could not be parsed for classification: ${error instanceof Error ? error.message : String(error)}`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
try {
|
|
204
|
+
parser.delete();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* a dead module has nothing to reclaim */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const starts = lineStarts(content);
|
|
212
|
+
raw.forEach((match, position) => {
|
|
213
|
+
const lineIndex = lineIndexFor(starts, match.startIndex);
|
|
214
|
+
const lineStart = starts[lineIndex];
|
|
215
|
+
const nextStart = starts[lineIndex + 1] ?? content.length + 1;
|
|
216
|
+
const node = classified?.[position] ?? null;
|
|
217
|
+
matches.push({
|
|
218
|
+
file,
|
|
219
|
+
line: lineIndex + 1,
|
|
220
|
+
column: match.startIndex - lineStart + 1,
|
|
221
|
+
startIndex: match.startIndex,
|
|
222
|
+
endIndex: match.endIndex,
|
|
223
|
+
lineText: content.slice(lineStart, Math.max(lineStart, nextStart - 1)).replace(/\r$/, ""),
|
|
224
|
+
matchedText: match.matchedText,
|
|
225
|
+
caseMatchesQuery: match.matchedText === term,
|
|
226
|
+
withinLargerWord: isWordCharacter(content[match.startIndex - 1]) ||
|
|
227
|
+
isWordCharacter(content[match.endIndex]),
|
|
228
|
+
classification: node ? node.classification : classifyByFileType(file),
|
|
229
|
+
basis: node ? "parse-node" : language ? "none" : "file-type",
|
|
230
|
+
nodeType: node ? node.nodeType : null,
|
|
231
|
+
enclosingText: node ? node.enclosingText : null,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return { matches, uncovered, filesSearched, searched: true };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Read a file for searching, or say why it could not be.
|
|
239
|
+
*
|
|
240
|
+
* Binary detection is a NUL-byte sniff over the head of the file rather than an
|
|
241
|
+
* extension list: the extensions that matter here are open-ended, and a
|
|
242
|
+
* misjudged binary read produces garbage matches rather than an honest skip.
|
|
243
|
+
*/
|
|
244
|
+
function readSearchable(absolute) {
|
|
245
|
+
let buffer;
|
|
246
|
+
try {
|
|
247
|
+
buffer = fs.readFileSync(absolute);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
return { reason: `unreadable: ${error instanceof Error ? error.message : String(error)}` };
|
|
251
|
+
}
|
|
252
|
+
if (buffer.subarray(0, 8192).includes(0))
|
|
253
|
+
return { reason: "binary" };
|
|
254
|
+
return { content: buffer.toString("utf8") };
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Exact-text search across a whole repository (R1).
|
|
258
|
+
*
|
|
259
|
+
* Enumerates with the indexer's prune rules but NOT its source-extension
|
|
260
|
+
* filter. That difference is the requirement: a product-name rename lives in
|
|
261
|
+
* markdown, UI strings and changelogs, so restricting the search to files that
|
|
262
|
+
* yield symbols would answer a different question than the one being asked.
|
|
263
|
+
*
|
|
264
|
+
* Every file that could not be searched is returned in `uncovered` (R5). A
|
|
265
|
+
* rename that silently skipped files would leave a half-renamed repository
|
|
266
|
+
* looking finished, which is the worst available outcome for this operation.
|
|
267
|
+
*/
|
|
268
|
+
export async function searchRepoText(repoPath, term, loadLanguage, newParser, options = {}) {
|
|
269
|
+
let candidates;
|
|
270
|
+
try {
|
|
271
|
+
// Checked explicitly because `walkRepoFiles` swallows a missing directory
|
|
272
|
+
// and returns an empty list. Without this, searching a path that does not
|
|
273
|
+
// exist reports a completed search over zero files — "the term appears
|
|
274
|
+
// nowhere", which is the precise failure AC5 exists to make impossible.
|
|
275
|
+
const stats = fs.statSync(repoPath);
|
|
276
|
+
if (!stats.isDirectory())
|
|
277
|
+
throw new Error("not a directory");
|
|
278
|
+
candidates = walkRepoFiles(repoPath);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
// Enumeration failed, so nothing was searched. Reported as `searched:
|
|
282
|
+
// false` rather than as an empty match list, which would read as "the
|
|
283
|
+
// term appears nowhere" (AC5).
|
|
284
|
+
return {
|
|
285
|
+
matches: [],
|
|
286
|
+
uncovered: [
|
|
287
|
+
{
|
|
288
|
+
file: repoPath,
|
|
289
|
+
reason: `could not enumerate: ${error instanceof Error ? error.message : String(error)}`,
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
filesSearched: 0,
|
|
293
|
+
searched: false,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const readable = [];
|
|
297
|
+
const uncovered = [];
|
|
298
|
+
for (const file of candidates) {
|
|
299
|
+
const outcome = readSearchable(path.join(repoPath, file));
|
|
300
|
+
if ("reason" in outcome)
|
|
301
|
+
uncovered.push({ file, reason: outcome.reason });
|
|
302
|
+
else
|
|
303
|
+
readable.push({ file, content: outcome.content });
|
|
304
|
+
}
|
|
305
|
+
const result = await searchFiles(readable, term, loadLanguage, newParser, options);
|
|
306
|
+
// Files skipped at read time and files that failed classification are both
|
|
307
|
+
// gaps in the same claim, so they are reported through one list.
|
|
308
|
+
return { ...result, uncovered: [...uncovered, ...result.uncovered] };
|
|
309
|
+
}
|
|
@@ -20,7 +20,7 @@ function formatDuration(seconds) {
|
|
|
20
20
|
return `${Math.ceil(seconds)}s`;
|
|
21
21
|
return `${Math.floor(seconds / 60)}m ${Math.ceil(seconds % 60)}s`;
|
|
22
22
|
}
|
|
23
|
-
export function formatProgress(event, ratePerSecond, operation = "init") {
|
|
23
|
+
export function formatProgress(event, ratePerSecond, operation = "init", bytesPerSecond) {
|
|
24
24
|
const model = event.modelFile ? ` (${event.modelFile})` : "";
|
|
25
25
|
const total = event.phaseTotal;
|
|
26
26
|
if (total === undefined)
|
|
@@ -29,13 +29,34 @@ export function formatProgress(event, ratePerSecond, operation = "init") {
|
|
|
29
29
|
? `${formatBytes(event.phaseCompleted)} / ${formatBytes(total)}`
|
|
30
30
|
: `${formatCount(event.phaseCompleted)} / ${formatCount(total)}`;
|
|
31
31
|
const percent = total > 0 ? ` (${Math.floor((event.phaseCompleted / total) * 100)}%)` : "";
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// Both counters, because they answer different questions and on a real
|
|
33
|
+
// repository they disagree. Measured on a 902,960-file Salesforce checkout:
|
|
34
|
+
// 435 files over 1 MB hold 78% of the bytes, so "71% of files" was about a
|
|
35
|
+
// quarter of the work. Showing only files is what made a working index look
|
|
36
|
+
// wedged for twenty minutes.
|
|
37
|
+
const bytesTotal = event.phaseBytesTotal;
|
|
38
|
+
const bytesDone = event.phaseBytesCompleted;
|
|
39
|
+
const bytes = bytesTotal !== undefined && bytesDone !== undefined && bytesTotal > 0
|
|
40
|
+
? ` • ${formatBytes(bytesDone)} / ${formatBytes(bytesTotal)} (${Math.floor((bytesDone / bytesTotal) * 100)}%)`
|
|
34
41
|
: "";
|
|
42
|
+
// Prefer the byte rate: it tracks the shape of the work rather than the
|
|
43
|
+
// length of the list. Falls back to the item rate when the phase carries no
|
|
44
|
+
// byte size, which keeps model download and embedding phases as they were.
|
|
45
|
+
const byteEta = bytesPerSecond && bytesTotal !== undefined && bytesDone !== undefined && bytesTotal > bytesDone
|
|
46
|
+
? (bytesTotal - bytesDone) / bytesPerSecond
|
|
47
|
+
: undefined;
|
|
48
|
+
const itemEta = ratePerSecond && total > event.phaseCompleted
|
|
49
|
+
? (total - event.phaseCompleted) / ratePerSecond
|
|
50
|
+
: undefined;
|
|
51
|
+
const remaining = byteEta ?? itemEta;
|
|
52
|
+
// Labelled an estimate because it is one, and because a confidently wrong
|
|
53
|
+
// number is worse than an obviously approximate one — this workload spans
|
|
54
|
+
// three orders of magnitude in cost per file.
|
|
55
|
+
const eta = remaining === undefined ? "" : ` • ~${formatDuration(remaining)} remaining (est.)`;
|
|
35
56
|
const rate = ratePerSecond && event.phase !== "embedding-model"
|
|
36
57
|
? ` • ${ratePerSecond.toFixed(ratePerSecond >= 10 ? 0 : 1)}/s`
|
|
37
58
|
: "";
|
|
38
|
-
return `[${operation}:${event.phase}] ${counter}${percent} ${event.message}${model}${rate}${eta}`;
|
|
59
|
+
return `[${operation}:${event.phase}] ${counter}${percent}${bytes} ${event.message}${model}${rate}${eta}`;
|
|
39
60
|
}
|
|
40
61
|
/**
|
|
41
62
|
* Human-only init feedback. It writes to stderr, leaving stdout stable for the
|
|
@@ -61,6 +82,7 @@ export function createInitProgressRenderer(options) {
|
|
|
61
82
|
let lastPercentBucket = -1;
|
|
62
83
|
let phaseStartedElapsedMs = 0;
|
|
63
84
|
let phaseStartedCompleted = 0;
|
|
85
|
+
let phaseStartedBytes = 0;
|
|
64
86
|
const scheduleHeartbeat = () => {
|
|
65
87
|
if (heartbeat !== undefined || stopped)
|
|
66
88
|
return;
|
|
@@ -126,17 +148,26 @@ export function createInitProgressRenderer(options) {
|
|
|
126
148
|
if (phaseChanged) {
|
|
127
149
|
phaseStartedElapsedMs = event.elapsedMs;
|
|
128
150
|
phaseStartedCompleted = event.phaseCompleted;
|
|
151
|
+
phaseStartedBytes = event.phaseBytesCompleted ?? 0;
|
|
129
152
|
}
|
|
130
153
|
const phaseElapsedSeconds = Math.max(0, event.elapsedMs - phaseStartedElapsedMs) / 1_000;
|
|
131
154
|
const ratePerSecond = phaseElapsedSeconds > 0 && event.phaseCompleted > phaseStartedCompleted
|
|
132
155
|
? (event.phaseCompleted - phaseStartedCompleted) / phaseElapsedSeconds
|
|
133
156
|
: undefined;
|
|
157
|
+
// Measured from the start of the phase, not from process start, so the
|
|
158
|
+
// cold-start window (wasm init, grammar load, embedder setup) does not
|
|
159
|
+
// drag the estimate. Extrapolating from that window overstated a real
|
|
160
|
+
// run by roughly ten times.
|
|
161
|
+
const bytesDone = event.phaseBytesCompleted;
|
|
162
|
+
const bytesPerSecond = phaseElapsedSeconds > 0 && bytesDone !== undefined && bytesDone > phaseStartedBytes
|
|
163
|
+
? (bytesDone - phaseStartedBytes) / phaseElapsedSeconds
|
|
164
|
+
: undefined;
|
|
134
165
|
const completed = event.phaseTotal !== undefined && event.phaseCompleted >= event.phaseTotal;
|
|
135
166
|
if (tty && !phaseChanged && !completed && now() - lastWriteAt < TTY_THROTTLE_MS)
|
|
136
167
|
return;
|
|
137
168
|
if (!tty && !phaseChanged && bucket <= lastPercentBucket && !completed)
|
|
138
169
|
return;
|
|
139
|
-
write(formatProgress(event, ratePerSecond, operation));
|
|
170
|
+
write(formatProgress(event, ratePerSecond, operation, bytesPerSecond));
|
|
140
171
|
lastPhase = event.phase;
|
|
141
172
|
lastPercentBucket = Math.max(lastPercentBucket, bucket);
|
|
142
173
|
},
|
package/dist/src/init.js
CHANGED
|
@@ -7,6 +7,7 @@ import { compareBytes } from "./compare.js";
|
|
|
7
7
|
import { isIndexableSourcePath } from "./engine/source-policy.js";
|
|
8
8
|
import { lookupMirror } from "./engine/state-paths.js";
|
|
9
9
|
import { inspectLefthookIntegration, installHookManagerIntegration, isActiveLefthookHook, } from "./hook-manager-integration.js";
|
|
10
|
+
import { stableInterpreterPath } from "./node-runtime.js";
|
|
10
11
|
import { acquireRepairLease, LIFECYCLE_LEASE_TOKEN_ENV } from "./repair-lease.js";
|
|
11
12
|
import { installKnodinSkills, removeKnodinSkills } from "./skill-management.js";
|
|
12
13
|
import { registerInitializedWorktree } from "./worktree-lifecycle.js";
|
|
@@ -426,7 +427,13 @@ function backgroundScript(command) {
|
|
|
426
427
|
// silently switch which runtime executes, which is its own bug.
|
|
427
428
|
const [interpreter, ...rest] = command;
|
|
428
429
|
const invocation = ['"$KNODIN_NODE"', ...rest.map(shellQuote)].join(" ");
|
|
429
|
-
|
|
430
|
+
// Recorded through `stableInterpreterPath` so a Homebrew interpreter is
|
|
431
|
+
// written as its version-stable `opt` path rather than the versioned Cellar
|
|
432
|
+
// path Node reports. Without it the preferred path is guaranteed to break on
|
|
433
|
+
// the next `brew upgrade` and every hook leans on the fallback below to stay
|
|
434
|
+
// alive — which works, but means the recorded path is wrong from the moment
|
|
435
|
+
// it is written (EASFDC-8497).
|
|
436
|
+
const preferred = shellQuote(stableInterpreterPath(interpreter ?? process.execPath));
|
|
430
437
|
return String.raw `#!/bin/sh
|
|
431
438
|
# knodin packaged background refresh. Generated by knodin init.
|
|
432
439
|
set -u
|
|
@@ -136,6 +136,39 @@ function inspectManagedHooks(repo, hooksDirectory) {
|
|
|
136
136
|
routing: hookRouting(wrappers, managerNative),
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* The interpreter a generated hook records, when that interpreter is gone.
|
|
141
|
+
*
|
|
142
|
+
* Existence and the executable bit say the script is runnable; they say nothing
|
|
143
|
+
* about whether the runtime on its first line still exists. A hook written
|
|
144
|
+
* against a Homebrew Cellar path stops working at the next `brew upgrade` and
|
|
145
|
+
* fails with exit 127 in the background, where the only trace is a log file
|
|
146
|
+
* (EASFDC-8497). Health then reported "degraded" without ever naming the cause,
|
|
147
|
+
* and `repair` could not help because the fault is in the hook's contents, not
|
|
148
|
+
* in graph state.
|
|
149
|
+
*
|
|
150
|
+
* Returns null when the hook is fine, unreadable, or shaped differently than
|
|
151
|
+
* expected — an unparseable hook is not evidence of a missing interpreter, and
|
|
152
|
+
* guessing here would invent a failure rather than report one.
|
|
153
|
+
*/
|
|
154
|
+
function recordedInterpreterIfMissing(backgroundScriptPath) {
|
|
155
|
+
let contents;
|
|
156
|
+
try {
|
|
157
|
+
contents = fs.readFileSync(backgroundScriptPath, "utf8");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const recorded = /^KNODIN_NODE='([^']+)'/m.exec(contents)?.[1];
|
|
163
|
+
if (!recorded)
|
|
164
|
+
return null;
|
|
165
|
+
if (fs.existsSync(recorded))
|
|
166
|
+
return null;
|
|
167
|
+
// A working `node` on PATH means the hook's own fallback will carry it, so
|
|
168
|
+
// this is not currently breaking anything — but the recorded path is still
|
|
169
|
+
// wrong and the next environment without that fallback breaks silently.
|
|
170
|
+
return recorded;
|
|
171
|
+
}
|
|
139
172
|
function readRefreshFailure(repo) {
|
|
140
173
|
const failurePath = path.join(repo, ".knodin", "hooks", HOOK_FAILURE_FILE);
|
|
141
174
|
if (!fs.existsSync(failurePath))
|
|
@@ -218,6 +251,9 @@ export function inspectLifecycleHealth(repoPath) {
|
|
|
218
251
|
const issues = missingHooks.map((hook) => `${hook} no longer routes through knodin's active Git hook path`);
|
|
219
252
|
if (!backgroundReady)
|
|
220
253
|
issues.push("background indexer is missing or not executable");
|
|
254
|
+
const staleInterpreter = backgroundReady ? recordedInterpreterIfMissing(background) : null;
|
|
255
|
+
if (staleInterpreter)
|
|
256
|
+
issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin init\` to rewrite the hook`);
|
|
221
257
|
const lastError = readRefreshFailure(repo);
|
|
222
258
|
if (lastError)
|
|
223
259
|
issues.push(`${lastError} See .knodin/indexer.log for details`);
|
package/dist/src/node-runtime.js
CHANGED
|
@@ -155,3 +155,36 @@ export function handoffCurrentProcessToSupportedNodeRuntime() {
|
|
|
155
155
|
listDirectories,
|
|
156
156
|
});
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Rewrite a Homebrew Cellar path to the version-stable `opt` path it came from.
|
|
160
|
+
*
|
|
161
|
+
* `process.execPath` is symlink-resolved by Node, so invoking a stable entry
|
|
162
|
+
* point does not preserve it:
|
|
163
|
+
*
|
|
164
|
+
* $ /opt/homebrew/opt/node/bin/node -p "process.execPath"
|
|
165
|
+
* /opt/homebrew/Cellar/node/26.7.0/bin/node
|
|
166
|
+
*
|
|
167
|
+
* Both stable Homebrew entry points report the versioned path, which means the
|
|
168
|
+
* caller cannot avoid this by launching differently — by the time knodin can
|
|
169
|
+
* read its own interpreter, the durable path is already gone. Anything that
|
|
170
|
+
* records `execPath` for later therefore records a path Homebrew deletes on the
|
|
171
|
+
* next `brew upgrade`, and a managed hook written against it dies with exit 127
|
|
172
|
+
* (EASFDC-8497, and the 174k-file class of failure before it: a background
|
|
173
|
+
* process failing where nobody is looking).
|
|
174
|
+
*
|
|
175
|
+
* `<prefix>/opt/<formula>` is the symlink Homebrew repoints on upgrade, so the
|
|
176
|
+
* path survives by construction rather than by a runtime fallback. Derived from
|
|
177
|
+
* the matched prefix rather than hardcoding `/opt/homebrew`, so Intel
|
|
178
|
+
* (`/usr/local`) and Linuxbrew layouts work unchanged.
|
|
179
|
+
*
|
|
180
|
+
* Returns the input untouched when it is not a Cellar path, or when the `opt`
|
|
181
|
+
* path does not exist — a rewrite is only safe if the destination is real.
|
|
182
|
+
*/
|
|
183
|
+
export function stableInterpreterPath(executable, exists = (candidate) => fs.existsSync(candidate)) {
|
|
184
|
+
const match = /^(.*)\/Cellar\/([^/]+)\/[^/]+\/(.+)$/.exec(executable);
|
|
185
|
+
if (!match)
|
|
186
|
+
return executable;
|
|
187
|
+
const [, prefix, formula, remainder] = match;
|
|
188
|
+
const candidate = `${prefix}/opt/${formula}/${remainder}`;
|
|
189
|
+
return exists(candidate) ? candidate : executable;
|
|
190
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseVersion } from "./update-policy.js";
|
|
4
|
+
/**
|
|
5
|
+
* Classify a version transition.
|
|
6
|
+
*
|
|
7
|
+
* Returns `null` when the transition is not a forward release — equal versions,
|
|
8
|
+
* a downgrade, or an unparseable input — so callers distinguish "cannot say"
|
|
9
|
+
* from a confident answer. Returning a default here would put a guess into
|
|
10
|
+
* published metadata, which is the failure this whole change removes.
|
|
11
|
+
*
|
|
12
|
+
* ## The 0.x rule, stated rather than inherited
|
|
13
|
+
*
|
|
14
|
+
* Pre-1.0, the MINOR is the breaking axis: `0.10.x` to `0.11.0` breaks, while
|
|
15
|
+
* `0.10.1` to `0.10.2` does not. This is conventional SemVer for major-zero and
|
|
16
|
+
* it is also what this project's own history shows — `0.9.0` and `0.10.0` were
|
|
17
|
+
* genuinely breaking and were labelled so, while `0.10.1` and `0.10.2` were
|
|
18
|
+
* bugfix releases published under the same label because the field was a
|
|
19
|
+
* constant.
|
|
20
|
+
*
|
|
21
|
+
* `policyAllows` in update-policy.ts deliberately does NOT encode this: it
|
|
22
|
+
* treats major-zero like any other major, which is correct for deciding whether
|
|
23
|
+
* an automatic update is safe but would classify every 0.x release as
|
|
24
|
+
* non-breaking here. Reusing it would be the obvious mistake.
|
|
25
|
+
*/
|
|
26
|
+
export function classifyCompatibility(previous, next) {
|
|
27
|
+
const from = parseVersion(previous);
|
|
28
|
+
const to = parseVersion(next);
|
|
29
|
+
if (!from || !to)
|
|
30
|
+
return null;
|
|
31
|
+
const [fromMajor, fromMinor, fromPatch] = from.core;
|
|
32
|
+
const [toMajor, toMinor, toPatch] = to.core;
|
|
33
|
+
// Not a forward release. A caller asking about a downgrade or a no-op has a
|
|
34
|
+
// different problem than a mislabelled release.
|
|
35
|
+
if (toMajor < fromMajor)
|
|
36
|
+
return null;
|
|
37
|
+
if (toMajor === fromMajor && toMinor < fromMinor)
|
|
38
|
+
return null;
|
|
39
|
+
if (toMajor === fromMajor && toMinor === fromMinor && toPatch <= fromPatch)
|
|
40
|
+
return null;
|
|
41
|
+
if (toMajor !== fromMajor)
|
|
42
|
+
return "breaking";
|
|
43
|
+
// Major-zero: the minor carries what the major carries after 1.0.
|
|
44
|
+
if (toMajor === 0)
|
|
45
|
+
return toMinor !== fromMinor ? "breaking" : "compatible";
|
|
46
|
+
return toMinor !== fromMinor ? "compatible-with-additions" : "compatible";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The version of the release immediately before `version`, taken from the
|
|
50
|
+
* release-notes directory.
|
|
51
|
+
*
|
|
52
|
+
* Derived from `docs/releases/` rather than git tags on purpose: the repository
|
|
53
|
+
* has no previous-tag lookup anywhere, this needs no subprocess, and the notes
|
|
54
|
+
* are already required to exist for the release being cut — `docs-integrity`
|
|
55
|
+
* asserts the current version's file is present and packaged. Using a source
|
|
56
|
+
* that is already load-bearing means this cannot silently disagree with what
|
|
57
|
+
* ships.
|
|
58
|
+
*
|
|
59
|
+
* Returns `null` when there is no earlier release, which is a real state for the
|
|
60
|
+
* first one.
|
|
61
|
+
*/
|
|
62
|
+
export function previousReleaseVersion(releasesDir, version) {
|
|
63
|
+
let entries;
|
|
64
|
+
try {
|
|
65
|
+
entries = fs.readdirSync(releasesDir);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const target = parseVersion(version);
|
|
71
|
+
if (!target)
|
|
72
|
+
return null;
|
|
73
|
+
let best = null;
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (path.extname(entry) !== ".md")
|
|
76
|
+
continue;
|
|
77
|
+
const candidateText = path.basename(entry, ".md");
|
|
78
|
+
const candidate = parseVersion(candidateText);
|
|
79
|
+
if (!candidate)
|
|
80
|
+
continue;
|
|
81
|
+
if (compareCore(candidate.core, target.core) >= 0)
|
|
82
|
+
continue;
|
|
83
|
+
if (!best || compareCore(candidate.core, best.core) > 0)
|
|
84
|
+
best = { text: candidateText, core: candidate.core };
|
|
85
|
+
}
|
|
86
|
+
return best?.text ?? null;
|
|
87
|
+
}
|
|
88
|
+
function compareCore(left, right) {
|
|
89
|
+
for (let index = 0; index < 3; index++) {
|
|
90
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
91
|
+
if (difference !== 0)
|
|
92
|
+
return difference;
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
@@ -3,6 +3,7 @@ import { createHash, createPublicKey } from "node:crypto";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { compareBytes } from "./compare.js";
|
|
6
|
+
import { classifyCompatibility, previousReleaseVersion } from "./release-compatibility.js";
|
|
6
7
|
import { validateRootCeremonyManifest } from "./update-ceremony.js";
|
|
7
8
|
import { canonicalizeUpdateMetadata, verifyUpdateRootChain, } from "./update-trust.js";
|
|
8
9
|
export const RELEASE_PREFLIGHT_REPOSITORY_COMMANDS = [
|
|
@@ -486,6 +487,23 @@ export function evaluateReleasePreflight(options) {
|
|
|
486
487
|
const packageManifest = record(parseJson(packageFile.bytes, "package manifest"), "package manifest");
|
|
487
488
|
if (packageManifest.version !== plan.version)
|
|
488
489
|
fail("package version does not equal release-plan version");
|
|
490
|
+
// `knodin.compatibility` ships in the tarball and is read out of it by the
|
|
491
|
+
// Homebrew tap. Nothing inside this repository consumes it, so a wrong value
|
|
492
|
+
// reaches consumers without anything failing — it was a hardcoded "breaking"
|
|
493
|
+
// across four releases, two of which were bugfix patches. This is the last
|
|
494
|
+
// gate that sees the manifest before it is published.
|
|
495
|
+
const declaredCompatibility = record(packageManifest.knodin ?? {}, "package manifest knodin block").compatibility;
|
|
496
|
+
const previousRelease = previousReleaseVersion(path.join(repositoryRoot, "docs", "releases"), plan.version);
|
|
497
|
+
if (previousRelease !== null) {
|
|
498
|
+
const derived = classifyCompatibility(previousRelease, plan.version);
|
|
499
|
+
// A null derivation means the version did not move forward, which the
|
|
500
|
+
// version check above should already have caught; failing here rather than
|
|
501
|
+
// skipping keeps an unexplained state from passing silently.
|
|
502
|
+
if (derived === null)
|
|
503
|
+
fail(`cannot classify compatibility from ${previousRelease} to ${plan.version}`);
|
|
504
|
+
else if (declaredCompatibility !== derived)
|
|
505
|
+
fail(`package knodin.compatibility is ${JSON.stringify(declaredCompatibility)} but ${previousRelease} to ${plan.version} is ${derived}`);
|
|
506
|
+
}
|
|
489
507
|
const workflowFile = readBoundedRegularFile(repositoryRoot, path.join(repositoryRoot, plan.workflow.path), "C64 candidate workflow", MAX_MANIFEST_BYTES);
|
|
490
508
|
if (!git.isTracked(workflowFile.relativePath))
|
|
491
509
|
fail("C64 candidate workflow must be checked in");
|