knodin 0.10.3 → 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 +11 -2
- package/dist/src/engine/index.js +17 -0
- package/dist/src/engine/text-matches.js +309 -0
- 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/tools/knodin-tools.js +36 -7
- package/dist/src/wait-for-fresh.js +43 -1
- package/docs/releases/0.10.4.md +161 -0
- package/package.json +2 -1
package/dist/bin/cli.js
CHANGED
|
@@ -77,8 +77,17 @@ function integrationAgents(repo) {
|
|
|
77
77
|
const repositoryDetected = inspectRepositoryIntegrationStatus(repo)?.agents ?? [];
|
|
78
78
|
return [...new Set([...detectSupportedAgents(), ...previous, ...repositoryDetected])];
|
|
79
79
|
}
|
|
80
|
-
function formatInitHuman(result) {
|
|
81
|
-
|
|
80
|
+
export function formatInitHuman(result) {
|
|
81
|
+
// `configured` is what THIS run wrote, not what exists on the machine. The
|
|
82
|
+
// old wording called an empty list "no supported coding agents detected",
|
|
83
|
+
// asserting a detection result init never computed — and it read as a flat
|
|
84
|
+
// contradiction of the `status` line seconds later, which lists the agents
|
|
85
|
+
// that genuinely are configured (EASFDC-8499).
|
|
86
|
+
//
|
|
87
|
+
// Deliberately not calling the detector here: a formatter that reads the
|
|
88
|
+
// machine cannot be tested deterministically, and the honest fix is to stop
|
|
89
|
+
// claiming more than this value supports rather than to go find more.
|
|
90
|
+
let agents = `${result.paths.scope} — no agents configured by this run`;
|
|
82
91
|
if (result.paths.scope === "cli-only")
|
|
83
92
|
agents = "CLI-only — AI agents are not configured to discover knodin";
|
|
84
93
|
else if (result.paths.agentIntegration.configured.length > 0)
|
package/dist/src/engine/index.js
CHANGED
|
@@ -46,6 +46,7 @@ import { isIndexableSourcePath } from "./source-policy.js";
|
|
|
46
46
|
import { Database } from "./sqlite.js";
|
|
47
47
|
import { lookupMirror, mayWriteToRepository, resolveDbPath, resolveStateDir, } from "./state-paths.js";
|
|
48
48
|
import { deleteAllSymbols, deleteSymbolsForFile, deleteSymbolsMatchingPath, ORPHANED_EMBEDDING_PREDICATE, purgeOrphanEmbeddings, } from "./symbol-delete.js";
|
|
49
|
+
import { searchRepoText } from "./text-matches.js";
|
|
49
50
|
// ES Module resolution
|
|
50
51
|
const __filename = fileURLToPath(import.meta.url);
|
|
51
52
|
const __dirname = path.dirname(__filename);
|
|
@@ -11555,6 +11556,13 @@ function withStaleness(engine, claimRepository, openDb, openPolicy) {
|
|
|
11555
11556
|
row.staleness = staleness;
|
|
11556
11557
|
return page;
|
|
11557
11558
|
},
|
|
11559
|
+
searchText(term, repoPath, options) {
|
|
11560
|
+
claimRepository(repoPath);
|
|
11561
|
+
// No staleness annotation: this reads the working tree directly rather
|
|
11562
|
+
// than the graph, so its answers are current by construction and
|
|
11563
|
+
// stamping them with the index's freshness would misreport them.
|
|
11564
|
+
return engine.searchText(term, repoPath, options);
|
|
11565
|
+
},
|
|
11558
11566
|
async query(pattern, target, repoPath, to, limit, depth, detailLevel, selector, impactOptions, options) {
|
|
11559
11567
|
claimRepository(repoPath);
|
|
11560
11568
|
const result = await engine.query(pattern, target, repoPath, to, limit, depth, detailLevel, selector, impactOptions, options);
|
|
@@ -14073,6 +14081,15 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14073
14081
|
indexCoordination.release();
|
|
14074
14082
|
}
|
|
14075
14083
|
},
|
|
14084
|
+
async searchText(term, repoPath, options = {}) {
|
|
14085
|
+
if (term.length === 0)
|
|
14086
|
+
throw new Error("knodin searchText: term must not be empty");
|
|
14087
|
+
// Reads the working tree, not the graph, so it needs no open database
|
|
14088
|
+
// and no freshness check. `getLanguageForFile` is the same grammar
|
|
14089
|
+
// loader indexing uses, which is what makes the classification
|
|
14090
|
+
// evidence rather than a heuristic over file extensions.
|
|
14091
|
+
return searchRepoText(path.resolve(repoPath), term, getLanguageForFile, () => new Parser(), options);
|
|
14092
|
+
},
|
|
14076
14093
|
async search(query, repoPath, limit, options = {}) {
|
|
14077
14094
|
const resolvedRepoPath = path.resolve(repoPath);
|
|
14078
14095
|
const offset = options.offset ?? 0;
|
|
@@ -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
|
+
}
|
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
|
+
}
|
|
@@ -183,6 +183,7 @@ function buildDocumentedKnodinTools() {
|
|
|
183
183
|
"review",
|
|
184
184
|
"map",
|
|
185
185
|
"search",
|
|
186
|
+
"textSearch",
|
|
186
187
|
"query",
|
|
187
188
|
"prs",
|
|
188
189
|
"context",
|
|
@@ -204,7 +205,7 @@ function buildDocumentedKnodinTools() {
|
|
|
204
205
|
"diagnostics",
|
|
205
206
|
"sealed",
|
|
206
207
|
],
|
|
207
|
-
description: "Which knodin capability to run. context: call this FIRST when starting any investigation and unsure which operation to reach for — one ultra-compact orientation (repo stats + top subsystems/hubs/flows + a risk score if there's a diff + a heuristic next-operation suggestion); the suggestion is only a hint and never blocks calling any operation directly. explain: use when orienting on a symbol/file or before editing it — returns edit-ready source + call paths + blast radius. review: use before writing a PR description or approving a diff — risk-scored context (changed symbols, affected flows, test gaps). map: use before a cross-cutting refactor or to understand subsystem boundaries — communities + hub/bridge nodes + confidence-tagged edges. search: use when you don't know the exact symbol name — hybrid semantic + keyword lookup over code symbols. query: use for a structured question about a known symbol — callers_of, tests_for, shortest_path, dead_code, rename_preview, flows, and more (see `pattern`). pack: create deterministic Markdown/JSON/XML source context under hard budgets, or bounded-read/exact-regex-grep a saved artifact. compress: reduce already-produced diagnostic text under exact line/content-byte budgets, preserve exit metadata and detected signals, retain private local drill-down data by default, and never label insufficient-fidelity output complete. execute: run one immutable repository-defined profile only when independently enabled globally and locally; no executable or argv is accepted from the caller, unsupported containment fails closed, and output is compressed then diagnosed. prs: use to triage open GitHub PRs (via your local authenticated `gh`) — per-PR status, CI, and blast radius sorted ready-small-impact first; pass `prNumber` for one PR's impacted files + community names. wiki: write a static markdown documentation site for the repository's logical subsystems to `.knodin/wiki/`. Generates an index plus one page per mapped community. Reuses the `map` output. Idempotent: unchanged pages are untouched on disk unless `force` is true. docs: call this to retrieve curated, focused markdown usage guidance directly over MCP. remote: list read-only mirrors of repositories that are reachable but not checked out locally, so you can run explain/query/map/search against one by passing its `path` as `repoPath`. Each mirror is a snapshot pinned at a commit; the remote is not watched. Acquiring, refreshing, and removing mirrors is CLI-only (`knodin remote add|refresh|remove`) because it clones another repository's source onto this machine and consumes disk nothing reclaims automatically — the user's decision to make, not an agent's. sealed: query a sealed artifact (`artifactPath`) with NO checkout — source is embedded in it. Returns attested identity, commit, ref, age, coverage, and a `degraded` list of what it cannot do; pass `symbol` to explain. Describes the attested commit and no later one, so staleness is `unknown`, never `fresh`. Creating one is CLI-only (`knodin seal`).",
|
|
208
|
+
description: "Which knodin capability to run. context: call this FIRST when starting any investigation and unsure which operation to reach for — one ultra-compact orientation (repo stats + top subsystems/hubs/flows + a risk score if there's a diff + a heuristic next-operation suggestion); the suggestion is only a hint and never blocks calling any operation directly. explain: use when orienting on a symbol/file or before editing it — returns edit-ready source + call paths + blast radius. review: use before writing a PR description or approving a diff — risk-scored context (changed symbols, affected flows, test gaps). map: use before a cross-cutting refactor or to understand subsystem boundaries — communities + hub/bridge nodes + confidence-tagged edges. search: use when you don't know the exact symbol name — hybrid semantic + keyword lookup over code symbols. textSearch: use when what you are renaming is NOT a code symbol — a product name, a UI label, a literal — so `explain` does not apply. Read-only exact-text match repo-wide, including files yielding no symbols (markdown, config); each hit is classified string/comment/identifier/prose with `basis` stating whether that came from the file's parse tree or only its extension. Names files it could not read, and separates no-matches from could-not-search. `caseSensitive: false` also matches other casings, each labelled. query: use for a structured question about a known symbol — callers_of, tests_for, shortest_path, dead_code, rename_preview, flows, and more (see `pattern`). pack: create deterministic Markdown/JSON/XML source context under hard budgets, or bounded-read/exact-regex-grep a saved artifact. compress: reduce already-produced diagnostic text under exact line/content-byte budgets, preserve exit metadata and detected signals, retain private local drill-down data by default, and never label insufficient-fidelity output complete. execute: run one immutable repository-defined profile only when independently enabled globally and locally; no executable or argv is accepted from the caller, unsupported containment fails closed, and output is compressed then diagnosed. prs: use to triage open GitHub PRs (via your local authenticated `gh`) — per-PR status, CI, and blast radius sorted ready-small-impact first; pass `prNumber` for one PR's impacted files + community names. wiki: write a static markdown documentation site for the repository's logical subsystems to `.knodin/wiki/`. Generates an index plus one page per mapped community. Reuses the `map` output. Idempotent: unchanged pages are untouched on disk unless `force` is true. docs: call this to retrieve curated, focused markdown usage guidance directly over MCP. remote: list read-only mirrors of repositories that are reachable but not checked out locally, so you can run explain/query/map/search against one by passing its `path` as `repoPath`. Each mirror is a snapshot pinned at a commit; the remote is not watched. Acquiring, refreshing, and removing mirrors is CLI-only (`knodin remote add|refresh|remove`) because it clones another repository's source onto this machine and consumes disk nothing reclaims automatically — the user's decision to make, not an agent's. sealed: query a sealed artifact (`artifactPath`) with NO checkout — source is embedded in it. Returns attested identity, commit, ref, age, coverage, and a `degraded` list of what it cannot do; pass `symbol` to explain. Describes the attested commit and no later one, so staleness is `unknown`, never `fresh`. Creating one is CLI-only (`knodin seal`).",
|
|
208
209
|
},
|
|
209
210
|
profile: {
|
|
210
211
|
type: "string",
|
|
@@ -221,7 +222,7 @@ function buildDocumentedKnodinTools() {
|
|
|
221
222
|
},
|
|
222
223
|
file: {
|
|
223
224
|
type: "string",
|
|
224
|
-
description: "Repo-relative definition file
|
|
225
|
+
description: "Repo-relative file. Disambiguates which definition is meant when a symbol resolves in several files; with query impact and impactMode=file it names the target file instead.",
|
|
225
226
|
},
|
|
226
227
|
evidenceLevel: {
|
|
227
228
|
type: "string",
|
|
@@ -272,7 +273,7 @@ function buildDocumentedKnodinTools() {
|
|
|
272
273
|
impactMode: {
|
|
273
274
|
type: "string",
|
|
274
275
|
enum: ["symbol", "file"],
|
|
275
|
-
description: "query impact only: stable symbol reach (default) or explicitly labeled changed-file blast radius.",
|
|
276
|
+
description: "query impact only: stable symbol reach (default) or explicitly labeled changed-file blast radius. With `file`, name the target in the `file` parameter, or pass comma-separated paths in `symbol` for a multi-file change set.",
|
|
276
277
|
},
|
|
277
278
|
direction: {
|
|
278
279
|
type: "string",
|
|
@@ -399,6 +400,10 @@ function buildDocumentedKnodinTools() {
|
|
|
399
400
|
enum: ["all", "test", "production"],
|
|
400
401
|
description: "search/file_metrics: include all, test-only, or production-only files.",
|
|
401
402
|
},
|
|
403
|
+
caseSensitive: {
|
|
404
|
+
type: "boolean",
|
|
405
|
+
description: "textSearch only; default true. False also matches other casings; each hit reports the case on disk and whether it matched exactly, so a case-sensitive token is not folded into the same decision as a label.",
|
|
406
|
+
},
|
|
402
407
|
offset: {
|
|
403
408
|
type: "number",
|
|
404
409
|
minimum: 0,
|
|
@@ -1398,7 +1403,7 @@ async function handleDiagnosticsOperation(repo, args, bounded) {
|
|
|
1398
1403
|
async function dispatchKnodinTool(args) {
|
|
1399
1404
|
const startedAt = performance.now();
|
|
1400
1405
|
const parsedArgs = (args ?? {});
|
|
1401
|
-
const { operation, symbol, base, diffScope, from, toRevision, reviewFiles, query, pattern, queries, to, limit, depth, impactMode, direction, relationKinds, minConfidence, includeTests, includeDataFlow, flowVariable, apply, force, detailLevel, prNumber, prState, branches, auditRange, auditBase, auditHead, expectedLogin, worktreeAction, worktreePath, telemetryAction, telemetryRetentionDays, task, changedFiles, repoPath, section, systemAction, repositories, scope, distributed, repositoryIds, components, evidence, relationshipType, requireComplete, repositoryAction, roots, linkedWorktrees, cursor, allowPartial, dryRun, manifestPath, planDigest, identity, file, kind, toIdentity, toFile, toKind, byteBudget, tokenBudget, itemBudget, includeSource, languages, extensions, kinds, path, architectureFacets, testScope, offset, minLines, minComplexity, topN, sort, packAction, format, include, exclude, filePolicies, alreadyPresent, chatFiles, lineNumbers, includeTree, outputPath, artifactPath, startLine, endLine, regex, regexFlags, gitDiffScope, gitLog, statusAudit, timeoutMs, client, repairPlan, persistTelemetry, } = parsedArgs;
|
|
1406
|
+
const { operation, symbol, base, diffScope, from, toRevision, reviewFiles, query, pattern, queries, to, limit, depth, impactMode, direction, relationKinds, minConfidence, includeTests, includeDataFlow, flowVariable, apply, force, detailLevel, prNumber, prState, branches, auditRange, auditBase, auditHead, expectedLogin, worktreeAction, worktreePath, telemetryAction, telemetryRetentionDays, task, changedFiles, repoPath, section, systemAction, repositories, scope, distributed, repositoryIds, components, evidence, relationshipType, requireComplete, repositoryAction, roots, linkedWorktrees, cursor, allowPartial, dryRun, manifestPath, planDigest, identity, file, kind, toIdentity, toFile, toKind, byteBudget, tokenBudget, itemBudget, includeSource, languages, extensions, kinds, path, architectureFacets, testScope, caseSensitive, offset, minLines, minComplexity, topN, sort, packAction, format, include, exclude, filePolicies, alreadyPresent, chatFiles, lineNumbers, includeTree, outputPath, artifactPath, startLine, endLine, regex, regexFlags, gitDiffScope, gitLog, statusAudit, timeoutMs, client, repairPlan, persistTelemetry, } = parsedArgs;
|
|
1402
1407
|
const repo = repoPath ?? process.cwd();
|
|
1403
1408
|
const selector = {
|
|
1404
1409
|
identity: expandCompactIdentity(identity),
|
|
@@ -1744,6 +1749,15 @@ async function dispatchKnodinTool(args) {
|
|
|
1744
1749
|
includeSource,
|
|
1745
1750
|
offset,
|
|
1746
1751
|
}));
|
|
1752
|
+
case "textSearch": {
|
|
1753
|
+
if (!query)
|
|
1754
|
+
throw new Error("knodin textSearch requires `query` (the exact text to find)");
|
|
1755
|
+
const found = await engine.searchText(query, repo, { caseSensitive });
|
|
1756
|
+
// Not routed through `graphRead`: this reads the working tree rather
|
|
1757
|
+
// than the graph, so it is answerable — and correct — on a repository
|
|
1758
|
+
// whose index is stale, absent, or still building.
|
|
1759
|
+
return bounded(found, "textSearch");
|
|
1760
|
+
}
|
|
1747
1761
|
case "prs": {
|
|
1748
1762
|
return bounded(await handlePullRequestsOperation({
|
|
1749
1763
|
repo,
|
|
@@ -1799,8 +1813,23 @@ async function dispatchKnodinTool(args) {
|
|
|
1799
1813
|
if (limit !== undefined && (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1))
|
|
1800
1814
|
throw new Error("knodin query: limit must be a positive integer");
|
|
1801
1815
|
const repoWide = REPO_WIDE_QUERY_PATTERNS.includes(pattern);
|
|
1802
|
-
|
|
1803
|
-
|
|
1816
|
+
// File-mode impact takes its target as comma-separated paths in
|
|
1817
|
+
// `symbol`. That is not the call anyone forms from this schema: a `file`
|
|
1818
|
+
// parameter is published right beside `impactMode`, and the obvious
|
|
1819
|
+
// reading — impactMode "file" plus file: <path> — was rejected with
|
|
1820
|
+
// "requires `symbol`", naming a field the caller had deliberately not
|
|
1821
|
+
// used. A user reported the capability as unavailable because of it.
|
|
1822
|
+
//
|
|
1823
|
+
// `file` is now accepted as the target here, and only here: in symbol
|
|
1824
|
+
// mode it keeps its existing meaning as a definition disambiguator.
|
|
1825
|
+
const fileModeImpact = pattern === "impact" && impactMode === "file";
|
|
1826
|
+
if (fileModeImpact && symbol && file && symbol !== file)
|
|
1827
|
+
throw new Error(`knodin query impact with impactMode=file received different targets in \`symbol\` (${symbol}) and \`file\` (${file}); pass one`);
|
|
1828
|
+
const target = fileModeImpact ? (symbol ?? file) : symbol;
|
|
1829
|
+
if (!target && !repoWide)
|
|
1830
|
+
throw new Error(fileModeImpact
|
|
1831
|
+
? "knodin query impact with impactMode=file requires a target path in `file`, or comma-separated paths in `symbol`"
|
|
1832
|
+
: `knodin query ${pattern} requires \`symbol\``);
|
|
1804
1833
|
if ((pattern === "shortest_path" || pattern === "cross_substrate_path") && !to)
|
|
1805
1834
|
throw new Error(`knodin query ${pattern} requires \`to\``);
|
|
1806
1835
|
if (pattern === "rename_preview" && !to)
|
|
@@ -1817,7 +1846,7 @@ async function dispatchKnodinTool(args) {
|
|
|
1817
1846
|
.rename(symbol ?? "", to ?? "", repo, apply === true, true, selector)
|
|
1818
1847
|
.then((result) => bounded(decorateGraphQueryResult(result, queryHealth?.available ? queryHealth.state : "healthy", queryHealth?.available ? queryHealth.graph.freshness : undefined), "query:rename_preview"));
|
|
1819
1848
|
}
|
|
1820
|
-
const queryResult = await engine.query(pattern,
|
|
1849
|
+
const queryResult = await engine.query(pattern, target ?? "", repo, to, Math.min(limit ?? itemBudget ?? 100, itemBudget ?? 1000), depth, detailLevel === "source" ? undefined : detailLevel, selector, pattern === "impact"
|
|
1821
1850
|
? {
|
|
1822
1851
|
mode: impactMode ?? "symbol",
|
|
1823
1852
|
direction,
|
|
@@ -62,6 +62,30 @@ function completedStatus(graph) {
|
|
|
62
62
|
return "unknown";
|
|
63
63
|
return null;
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Waiting here can only end at the timeout (EASFDC-8498).
|
|
67
|
+
*
|
|
68
|
+
* `queued` freshness means progress depends on the queued-event drainer, and
|
|
69
|
+
* `reconcileOrdinaryDrift` deliberately declines that state, so the in-process
|
|
70
|
+
* path cannot advance it either. If the drainer also cannot run, every
|
|
71
|
+
* remaining poll is spent relaunching a processor that will not start.
|
|
72
|
+
*
|
|
73
|
+
* This is the exact shape reported on 0.10.2: a hook exiting 127 on every
|
|
74
|
+
* invocation, freshness stuck at `queued`, and `status` recommending
|
|
75
|
+
* `wait --fresh` — which held the reason for its own futility on every poll and
|
|
76
|
+
* reported none of it.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately narrow. A degraded lifecycle with freshness that is NOT queued
|
|
79
|
+
* can still be reconciled in process, and failing fast there would refuse work
|
|
80
|
+
* the command can actually do.
|
|
81
|
+
*/
|
|
82
|
+
function blockedOnDeadDrainer(graph) {
|
|
83
|
+
return graph.freshness?.state === "queued" && !graph.lifecycle.refreshCapable;
|
|
84
|
+
}
|
|
85
|
+
function describeDeadDrainer(graph) {
|
|
86
|
+
const detail = graph.lifecycle.lastError ?? graph.lifecycle.issues[0] ?? "cause not recorded";
|
|
87
|
+
return `background refresh cannot run, so queued work will not drain: ${detail}`;
|
|
88
|
+
}
|
|
65
89
|
async function reconcileOrdinaryDrift(engine, repo, graph) {
|
|
66
90
|
const shouldReconcile = (graph.status === "stale" || graph.status === "repair-needed") &&
|
|
67
91
|
graph.freshness.state !== "queued";
|
|
@@ -90,9 +114,27 @@ export async function waitForFresh(engine, repo, timeoutMs = 30_000, processQueu
|
|
|
90
114
|
status = completedStatus(graph);
|
|
91
115
|
if (status)
|
|
92
116
|
return completedResult(status, startedAt, polls, graph);
|
|
117
|
+
// Checked after the completion checks, never before: a repository that is
|
|
118
|
+
// already fresh must still report `fresh`, even with a broken hook.
|
|
119
|
+
if (blockedOnDeadDrainer(graph))
|
|
120
|
+
return {
|
|
121
|
+
status: "lifecycle-degraded",
|
|
122
|
+
waitedMs: Date.now() - startedAt,
|
|
123
|
+
polls,
|
|
124
|
+
graph,
|
|
125
|
+
reason: describeDeadDrainer(graph),
|
|
126
|
+
};
|
|
93
127
|
const elapsed = Date.now() - startedAt;
|
|
94
128
|
if (elapsed >= timeoutMs)
|
|
95
|
-
return {
|
|
129
|
+
return {
|
|
130
|
+
status: "timeout",
|
|
131
|
+
waitedMs: elapsed,
|
|
132
|
+
polls,
|
|
133
|
+
graph,
|
|
134
|
+
// A timeout that names nothing is what sent the reporter looking in
|
|
135
|
+
// the wrong place. Say what was still outstanding.
|
|
136
|
+
reason: `still waiting on ${graph.lifecycle.queuedEvents} queued event(s)${graph.lifecycle.lastError ? `; last refresh error: ${graph.lifecycle.lastError}` : ""}`,
|
|
137
|
+
};
|
|
96
138
|
await delay(Math.min(100, timeoutMs - elapsed));
|
|
97
139
|
}
|
|
98
140
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# knodin 0.10.4
|
|
2
|
+
|
|
3
|
+
Five changes from two pieces of field feedback. The first came from a
|
|
4
|
+
product-name rename in the Nova repository; the second from a checkout whose
|
|
5
|
+
background refresh had been failing silently since a Homebrew upgrade.
|
|
6
|
+
|
|
7
|
+
The theme is the same one 0.10.2 and 0.10.3 were about: a tool that reports
|
|
8
|
+
things it has not verified, and a failure that leaves no trace anywhere a person
|
|
9
|
+
looks.
|
|
10
|
+
|
|
11
|
+
## `textSearch`: exact text, with every match classified
|
|
12
|
+
|
|
13
|
+
The reported blocker: the refactor workflow assumes a rename targets a code
|
|
14
|
+
symbol. This one was a product-name literal spanning UI strings and documents,
|
|
15
|
+
where `explain` and symbol resolution do not apply. The reporter fell back to
|
|
16
|
+
`rg` and hand-applied patches for the whole task.
|
|
17
|
+
|
|
18
|
+
Searching is the easy half. The half worth building is classification, and their
|
|
19
|
+
case shows why: visible `NOVA` labels had to change, while `NOVA-CANARY-…` was a
|
|
20
|
+
deliberate case-sensitive security token that had to survive. A flat text search
|
|
21
|
+
returns both and leaves a human to separate them by eye across however many hits
|
|
22
|
+
a large repository produces — exactly the position knodin exists to remove
|
|
23
|
+
people from.
|
|
24
|
+
|
|
25
|
+
knodin can classify only because it already parses these files. A match in a
|
|
26
|
+
file with a grammar resolves to its smallest containing syntax node, and the
|
|
27
|
+
parser's own node type is the evidence. A file without a grammar supports a
|
|
28
|
+
claim about the file, not about the match.
|
|
29
|
+
|
|
30
|
+
That difference is reported rather than smoothed over. Every match carries the
|
|
31
|
+
`basis` its classification rests on — `parse-node`, `file-type`, or `none` — and
|
|
32
|
+
the grammar's raw node type travels alongside the coarse label so a bad mapping
|
|
33
|
+
is catchable. Classification will sometimes be wrong; it is offered as evidence
|
|
34
|
+
a human is reviewing, never as a decision already taken.
|
|
35
|
+
|
|
36
|
+
Three details that matter in practice:
|
|
37
|
+
|
|
38
|
+
- **Documents are searched.** Enumeration uses the indexer's prune rules but not
|
|
39
|
+
its source-extension filter, because markdown and config are the point.
|
|
40
|
+
- **Files that could not be read are named**, not omitted. A rename that
|
|
41
|
+
silently skipped files would leave a half-renamed repository looking finished.
|
|
42
|
+
- **"No matches" is distinguishable from "could not search."** An empty list
|
|
43
|
+
that reads as reassurance is the defect this whole operation exists to avoid.
|
|
44
|
+
|
|
45
|
+
`textSearch` reads the working tree rather than the graph, so it stays
|
|
46
|
+
answerable when the index is stale, absent, or still building — often exactly
|
|
47
|
+
when a rename is underway.
|
|
48
|
+
|
|
49
|
+
It previews only. Applying edits is deliberately not in this release.
|
|
50
|
+
|
|
51
|
+
## `impact` accepts the file you gave it
|
|
52
|
+
|
|
53
|
+
Reported verbatim against 0.10.2:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
operation: query, pattern: impact, impactMode: file,
|
|
57
|
+
file: documents/nova-architecture-deck.js
|
|
58
|
+
-> knodin query impact requires `symbol`
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
File-mode impact was never missing. It read its target from `symbol`, documented
|
|
62
|
+
in one clause buried inside that parameter's prose, while a `file` parameter sat
|
|
63
|
+
published right beside `impactMode` meaning something else entirely. The schema
|
|
64
|
+
offered both, and the combination anyone would form from them was the one that
|
|
65
|
+
failed.
|
|
66
|
+
|
|
67
|
+
That is worse than a missing feature: the reporter concluded the capability was
|
|
68
|
+
broken and moved on. A working feature was recorded as unavailable because of
|
|
69
|
+
parameter naming.
|
|
70
|
+
|
|
71
|
+
`file` is now accepted as the target under file mode, and keeps its original
|
|
72
|
+
meaning in symbol mode. Conflicting `symbol` and `file` values are refused with
|
|
73
|
+
both named rather than resolved by preference — silently picking one would
|
|
74
|
+
answer a question nobody asked. The error now states the supported call.
|
|
75
|
+
|
|
76
|
+
## Managed hooks survive a Node upgrade
|
|
77
|
+
|
|
78
|
+
A checkout's background refresh had exited 127 seventeen consecutive times:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
background-index.sh: /opt/homebrew/Cellar/node/26.5.0_1/bin/node:
|
|
82
|
+
No such file or directory
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`brew upgrade node` deletes the versioned Cellar directory. The hook had
|
|
86
|
+
recorded the interpreter's absolute path at generation time, and that path
|
|
87
|
+
contains the version, so it was guaranteed to stop existing.
|
|
88
|
+
|
|
89
|
+
The caller cannot avoid this: Node symlink-resolves `process.execPath`, so
|
|
90
|
+
invoking `/opt/homebrew/opt/node/bin/node` still reports the Cellar path. By the
|
|
91
|
+
time knodin can read its own interpreter, the durable path is gone.
|
|
92
|
+
|
|
93
|
+
The recorded path is now mapped back to `<prefix>/opt/<formula>/…`, the symlink
|
|
94
|
+
Homebrew repoints on upgrade, so it survives by construction rather than by
|
|
95
|
+
falling back at run time. The prefix is derived from the match, so Intel
|
|
96
|
+
`/usr/local` and Linuxbrew work through the same path. A non-Homebrew
|
|
97
|
+
interpreter is left untouched.
|
|
98
|
+
|
|
99
|
+
**Existing repositories need one `knodin init`** to pick up a corrected hook.
|
|
100
|
+
`repair` cannot do it: the fault lives in the hook's contents, not in graph
|
|
101
|
+
state — which is why a `repair` run in that checkout returned "healthy" while
|
|
102
|
+
the lifecycle stayed broken.
|
|
103
|
+
|
|
104
|
+
Lifecycle health now notices this class of failure. It previously checked that
|
|
105
|
+
the background script existed and was executable, neither of which says anything
|
|
106
|
+
about the runtime on its first line, so the repository reported a degraded
|
|
107
|
+
lifecycle while naming no cause. The reported issue now names the missing
|
|
108
|
+
interpreter and the command that rewrites the hook.
|
|
109
|
+
|
|
110
|
+
## `wait --fresh` stops instead of waiting for something that cannot happen
|
|
111
|
+
|
|
112
|
+
`wait --fresh` has always had a bounded timeout, and it was reached honestly.
|
|
113
|
+
But when the background refresh is failing, waiting cannot succeed, and the
|
|
114
|
+
command already knew that: it recomputes lifecycle health on every poll, so it
|
|
115
|
+
was holding the exit-127 error the entire time it spun, then reported the single
|
|
116
|
+
word `timeout`.
|
|
117
|
+
|
|
118
|
+
It now returns immediately when freshness is queued and lifecycle refresh cannot
|
|
119
|
+
run — the pairing that is genuinely futile — and names the recorded failure.
|
|
120
|
+
Timeouts carry a reason as well: what remained queued, and the last refresh
|
|
121
|
+
error.
|
|
122
|
+
|
|
123
|
+
The guard is narrow on purpose. A degraded lifecycle whose freshness is not
|
|
124
|
+
queued can still be reconciled in process, and a repository that is already
|
|
125
|
+
fresh still reports `fresh` even with a broken hook.
|
|
126
|
+
|
|
127
|
+
## `init` no longer denies agents that are configured
|
|
128
|
+
|
|
129
|
+
Six seconds apart, with nothing installed in between:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
$ knodin init
|
|
133
|
+
Agent integration: personal - no supported coding agents detected
|
|
134
|
+
$ knodin status
|
|
135
|
+
Agent integration: personal (claude, codex, gemini, antigravity)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Both commands were computing something correct — `init` reports what that run
|
|
139
|
+
configured, `status` reports what is configured — but `init` described its empty
|
|
140
|
+
set as a *detection* result it had never run, which reads as a denial of the
|
|
141
|
+
four agents the next command lists. It now says what the value supports: no
|
|
142
|
+
agents configured by this run.
|
|
143
|
+
|
|
144
|
+
## Known gaps
|
|
145
|
+
|
|
146
|
+
Two items from the same report are deliberately not fixed here rather than
|
|
147
|
+
guessed at:
|
|
148
|
+
|
|
149
|
+
- `status` and `repair` can disagree about indexed-file and symbol counts. In
|
|
150
|
+
the reported session `status` showed 0 indexed files and 95 issues while
|
|
151
|
+
`repair`, seconds later, reported 94 indexed files and 53 with symbols and
|
|
152
|
+
called itself "verified". Whether `repair` inspected or silently rebuilt
|
|
153
|
+
changes what the fix is, and the evidence does not settle it.
|
|
154
|
+
- `init --scope personal` leaves `.agents/`, `.codex/` and `.gemini/` untracked
|
|
155
|
+
and unexcluded, and migrates a tracked `.gitignore` entry without saying so.
|
|
156
|
+
|
|
157
|
+
## Compatibility
|
|
158
|
+
|
|
159
|
+
Compatible. Nothing is removed or renamed; `textSearch` is a new operation
|
|
160
|
+
alongside the existing ones, and `file` gains a meaning under `impactMode: file`
|
|
161
|
+
while keeping its previous one everywhere else.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knodin",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
4
4
|
"knodin": {
|
|
5
5
|
"compatibility": "compatible"
|
|
6
6
|
},
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"docs/releases/0.10.1.md",
|
|
76
76
|
"docs/releases/0.10.2.md",
|
|
77
77
|
"docs/releases/0.10.3.md",
|
|
78
|
+
"docs/releases/0.10.4.md",
|
|
78
79
|
"docs/assets/knodin-favicon.svg",
|
|
79
80
|
"docs/SYSTEMS-AND-RELATIONSHIPS.md",
|
|
80
81
|
"docs/TELEMETRY.md",
|