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.
@@ -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
- const eta = ratePerSecond && total > event.phaseCompleted
33
- ? ` • ~${formatDuration((total - event.phaseCompleted) / ratePerSecond)} remaining`
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
- const preferred = shellQuote(interpreter ?? process.execPath);
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`);
@@ -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");