llmnav 0.5.1

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +21 -0
  3. package/README.md +294 -0
  4. package/ROADMAP.md +71 -0
  5. package/bin/llmnav.js +16 -0
  6. package/docs/agent-integration.md +114 -0
  7. package/docs/api.md +290 -0
  8. package/docs/architecture.md +286 -0
  9. package/docs/benchmarking.md +164 -0
  10. package/docs/ci.md +196 -0
  11. package/docs/cli.md +233 -0
  12. package/docs/configuration.md +117 -0
  13. package/docs/editor-integration.md +29 -0
  14. package/docs/faq.md +59 -0
  15. package/docs/graph.md +92 -0
  16. package/docs/language-examples.md +130 -0
  17. package/docs/migration.md +130 -0
  18. package/docs/performance-v0.2.md +42 -0
  19. package/docs/provider-neutral-integration.md +66 -0
  20. package/docs/publishing.md +86 -0
  21. package/docs/quickstart.md +139 -0
  22. package/docs/research.md +31 -0
  23. package/docs/spec.md +424 -0
  24. package/examples/provider-neutral-host.d.mts +17 -0
  25. package/examples/provider-neutral-host.mjs +40 -0
  26. package/package.json +79 -0
  27. package/schema/config.schema.json +296 -0
  28. package/src/agent-protocol.js +117 -0
  29. package/src/agent-tools.js +61 -0
  30. package/src/agents.js +127 -0
  31. package/src/boundaries.js +50 -0
  32. package/src/changes.js +168 -0
  33. package/src/cli.js +459 -0
  34. package/src/config.js +305 -0
  35. package/src/contracts.js +70 -0
  36. package/src/declaration.js +334 -0
  37. package/src/doctor.js +124 -0
  38. package/src/editor.js +107 -0
  39. package/src/evaluation.js +67 -0
  40. package/src/files.js +81 -0
  41. package/src/formatter.js +23 -0
  42. package/src/generator.js +528 -0
  43. package/src/graph-input.js +157 -0
  44. package/src/graph.js +403 -0
  45. package/src/incremental.js +262 -0
  46. package/src/index.d.ts +673 -0
  47. package/src/index.js +115 -0
  48. package/src/initializer.js +137 -0
  49. package/src/inverted-index.js +350 -0
  50. package/src/parser.js +449 -0
  51. package/src/project.js +65 -0
  52. package/src/prompt-bundle.js +108 -0
  53. package/src/registry.js +107 -0
  54. package/src/sarif.js +70 -0
  55. package/src/search-shards.js +75 -0
  56. package/src/search.js +636 -0
  57. package/src/spec.d.ts +27 -0
  58. package/src/spec.js +237 -0
  59. package/src/tokenizer.js +37 -0
  60. package/src/transaction.js +557 -0
  61. package/src/util.js +256 -0
  62. package/src/validator.js +635 -0
  63. package/templates/file-card.txt +8 -0
  64. package/templates/lexicon.json +7 -0
  65. package/templates/line-card.txt +9 -0
  66. package/templates/module-card.txt +9 -0
  67. package/templates/queries.jsonl +1 -0
  68. package/templates/symbol-card.txt +10 -0
@@ -0,0 +1,334 @@
1
+ import path from "node:path";
2
+ import { lineAtOffset, normalizeNewlines, sha256 } from "./util.js";
3
+
4
+ const DECLARATION_PATTERNS = {
5
+ javascript: [
6
+ { kind: "function", pattern: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\b/u },
7
+ { kind: "class", pattern: /^(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)\b/u },
8
+ { kind: "interface", pattern: /^(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)\b/u },
9
+ { kind: "type", pattern: /^(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\b/u },
10
+ { kind: "variable", pattern: /^(?:export\s+)?(?:declare\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\b/u },
11
+ { kind: "enum", pattern: /^(?:export\s+)?(?:const\s+)?enum\s+([A-Za-z_$][\w$]*)\b/u },
12
+ ],
13
+ go: [
14
+ { kind: "function", pattern: /^func\s+(?:\([^)]*\)\s*)?([A-Za-z_][A-Za-z0-9_]*)\b/u },
15
+ { kind: "type", pattern: /^type\s+([A-Za-z_][A-Za-z0-9_]*)\b/u },
16
+ { kind: "variable", pattern: /^(?:var|const)\s+([A-Za-z_][A-Za-z0-9_]*)\b/u },
17
+ ],
18
+ rust: [
19
+ { kind: "function", pattern: /^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\b/u },
20
+ { kind: "struct", pattern: /^(?:pub(?:\([^)]*\))?\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)\b/u },
21
+ { kind: "enum", pattern: /^(?:pub(?:\([^)]*\))?\s+)?enum\s+([A-Za-z_][A-Za-z0-9_]*)\b/u },
22
+ { kind: "trait", pattern: /^(?:pub(?:\([^)]*\))?\s+)?trait\s+([A-Za-z_][A-Za-z0-9_]*)\b/u },
23
+ { kind: "impl", pattern: /^impl(?:<[^>]+>)?\s+([^\s{]+)\b/u },
24
+ ],
25
+ python: [
26
+ { kind: "function", pattern: /^(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\b/u },
27
+ { kind: "class", pattern: /^class\s+([A-Za-z_][A-Za-z0-9_]*)\b/u },
28
+ ],
29
+ generic: [
30
+ { kind: "class", pattern: /^(?:public\s+|private\s+|protected\s+|internal\s+|static\s+|final\s+|abstract\s+)*(?:class|interface|enum|struct|trait)\s+([A-Za-z_][A-Za-z0-9_]*)\b/u },
31
+ { kind: "function", pattern: /^(?:public\s+|private\s+|protected\s+|internal\s+|static\s+|final\s+|async\s+)*(?:[A-Za-z_][\w<>?\[\],.]*\s+)+([A-Za-z_][A-Za-z0-9_]*)\s*\(/u },
32
+ ],
33
+ };
34
+
35
+ export function findAttachedDeclaration(source, block, filePath) {
36
+ if (block.scope !== "symbol") return null;
37
+ const window = source.slice(block.end, block.end + 3000);
38
+ const skipped = skipTrivia(window);
39
+ const candidate = window.slice(skipped);
40
+ const extension = path.extname(filePath).toLowerCase();
41
+ const family = languageFamily(extension);
42
+ const language = languageName(extension);
43
+ const patterns = DECLARATION_PATTERNS[family] ?? DECLARATION_PATTERNS.generic;
44
+ const firstLines = normalizeNewlines(candidate).split("\n").slice(0, 12).join("\n");
45
+ const collapsed = firstLines.replace(/\s+/gu, " ").trim();
46
+
47
+ for (const definition of patterns) {
48
+ const match = candidate.match(definition.pattern);
49
+ if (!match) continue;
50
+ const declarationOffset = block.end + skipped;
51
+ const signature = extractSignature(collapsed);
52
+ const exported = isExportedDeclaration(language, match[1], signature);
53
+ const endOffset = findDeclarationEnd(source, declarationOffset, family);
54
+ return {
55
+ symbol: match[1],
56
+ kind: definition.kind,
57
+ line: lineAtOffset(source, declarationOffset),
58
+ signature,
59
+ language,
60
+ exported,
61
+ visibility: exported ? "public" : language === "typescript" || language === "javascript" ? "module" : "private",
62
+ receiver: language === "go" ? extractGoReceiver(candidate) : null,
63
+ offset: declarationOffset,
64
+ endOffset,
65
+ bodyHash: sha256(normalizeNewlines(source.slice(declarationOffset, endOffset)).trimEnd()),
66
+ };
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function skipTrivia(value) {
72
+ let remaining = value;
73
+ let consumed = 0;
74
+ for (;;) {
75
+ const before = remaining;
76
+ const whitespace = remaining.match(/^\s+/u)?.[0] ?? "";
77
+ consumed += whitespace.length;
78
+ remaining = remaining.slice(whitespace.length);
79
+
80
+ const blockComment = remaining.match(/^\/\*(?!\s*llmnav\/)[\s\S]*?\*\//u)?.[0];
81
+ if (blockComment) {
82
+ consumed += blockComment.length;
83
+ remaining = remaining.slice(blockComment.length);
84
+ continue;
85
+ }
86
+
87
+ const lineComment = remaining.match(/^(?:\/\/|#|--)\s*(?!llmnav\/).*?(?:\r?\n|$)/u)?.[0];
88
+ if (lineComment) {
89
+ consumed += lineComment.length;
90
+ remaining = remaining.slice(lineComment.length);
91
+ continue;
92
+ }
93
+
94
+ const attribute = remaining.match(/^(?:@[A-Za-z_$][\w$]*(?:\([^\n]*\))?|#\[[^\]]+\])\s*(?:\r?\n)?/u)?.[0];
95
+ if (attribute) {
96
+ consumed += attribute.length;
97
+ remaining = remaining.slice(attribute.length);
98
+ continue;
99
+ }
100
+
101
+ if (remaining === before) break;
102
+ }
103
+ return consumed;
104
+ }
105
+
106
+ function extractSignature(collapsed) {
107
+ if (!collapsed) return "";
108
+ const boundaries = [collapsed.indexOf("{"), collapsed.indexOf("=>")].filter((index) => index >= 0);
109
+ const end = boundaries.length > 0 ? Math.min(...boundaries) : Math.min(collapsed.length, 500);
110
+ return collapsed.slice(0, end).trim().replace(/[;:]$/u, "");
111
+ }
112
+
113
+ function languageFamily(extension) {
114
+ if ([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".svelte", ".astro", ".vue"].includes(extension)) {
115
+ return "javascript";
116
+ }
117
+ if (extension === ".go") return "go";
118
+ if (extension === ".rs") return "rust";
119
+ if (extension === ".py") return "python";
120
+ return "generic";
121
+ }
122
+
123
+ function languageName(extension) {
124
+ if ([".ts", ".tsx", ".mts", ".cts"].includes(extension)) return "typescript";
125
+ if ([".js", ".jsx", ".mjs", ".cjs", ".svelte", ".astro", ".vue"].includes(extension)) return "javascript";
126
+ if (extension === ".go") return "go";
127
+ if (extension === ".rs") return "rust";
128
+ if (extension === ".py") return "python";
129
+ return "generic";
130
+ }
131
+
132
+ function isExportedDeclaration(language, symbol, signature) {
133
+ if (language === "typescript" || language === "javascript") return /^export\s+/u.test(signature);
134
+ if (language === "go") return /^[A-Z]/u.test(symbol);
135
+ if (language === "rust") return /^pub(?:\([^)]*\))?\s+/u.test(signature);
136
+ if (language === "python") return !symbol.startsWith("_");
137
+ return /^(?:public|export)\s+/u.test(signature);
138
+ }
139
+
140
+ function extractGoReceiver(candidate) {
141
+ const match = candidate.match(/^func\s+\(([^)]*)\)\s*/u);
142
+ if (!match) return null;
143
+ const parts = match[1].trim().split(/\s+/u);
144
+ return (parts.at(-1) ?? "").replace(/^\*+/u, "") || null;
145
+ }
146
+
147
+ function findDeclarationEnd(source, start, family) {
148
+ if (family === "python") return findPythonDeclarationEnd(source, start);
149
+ let state = "normal";
150
+ let escaped = false;
151
+ let regexCharacterClass = false;
152
+ let braces = 0;
153
+ let parentheses = 0;
154
+ let brackets = 0;
155
+ let openedBody = false;
156
+
157
+ for (let index = start; index < source.length; index += 1) {
158
+ const character = source[index];
159
+ const next = source[index + 1];
160
+ if (state === "line-comment") {
161
+ if (character === "\n") state = "normal";
162
+ continue;
163
+ }
164
+ if (state === "block-comment") {
165
+ if (character === "*" && next === "/") {
166
+ state = "normal";
167
+ index += 1;
168
+ }
169
+ continue;
170
+ }
171
+ if (state !== "normal") {
172
+ if (state === "regex") {
173
+ if (escaped) escaped = false;
174
+ else if (character === "\\") escaped = true;
175
+ else if (character === "[") regexCharacterClass = true;
176
+ else if (character === "]") regexCharacterClass = false;
177
+ else if (character === "/" && !regexCharacterClass) state = "normal";
178
+ continue;
179
+ }
180
+ if (escaped) {
181
+ escaped = false;
182
+ } else if (character === "\\") {
183
+ escaped = true;
184
+ } else if (character === state) {
185
+ state = "normal";
186
+ }
187
+ continue;
188
+ }
189
+ if (character === "/" && next === "/") {
190
+ state = "line-comment";
191
+ index += 1;
192
+ continue;
193
+ }
194
+ if (character === "/" && next === "*") {
195
+ state = "block-comment";
196
+ index += 1;
197
+ continue;
198
+ }
199
+ if (family === "javascript" && character === "/" && canStartJavaScriptRegex(source, start, index)) {
200
+ state = "regex";
201
+ regexCharacterClass = false;
202
+ continue;
203
+ }
204
+ if (character === '"' || character === "'" || character === "`") {
205
+ if (family === "rust" && character === "'" && !looksLikeRustCharacterLiteral(source, index)) continue;
206
+ state = character;
207
+ continue;
208
+ }
209
+ if (character === "(") parentheses += 1;
210
+ if (character === ")") parentheses = Math.max(0, parentheses - 1);
211
+ if (character === "[") brackets += 1;
212
+ if (character === "]") brackets = Math.max(0, brackets - 1);
213
+ if (character === "{") {
214
+ braces += 1;
215
+ openedBody = true;
216
+ } else if (character === "}" && openedBody) {
217
+ braces -= 1;
218
+ if (braces === 0) return index + 1;
219
+ } else if (character === ";" && !openedBody && parentheses === 0 && brackets === 0) {
220
+ return index + 1;
221
+ } else if (character === "\n" && !openedBody && parentheses === 0 && brackets === 0) {
222
+ const current = source.slice(start, index).trimEnd();
223
+ const nextCharacter = source.slice(index + 1).match(/^\s*(.)/u)?.[1] ?? "";
224
+ if (nextCharacter !== "{" && !/(?:=>|[=|&,([{])$/u.test(current)) return index;
225
+ }
226
+ }
227
+ return source.length;
228
+ }
229
+
230
+ function findPythonDeclarationEnd(source, start) {
231
+ const declarationLineStart = source.lastIndexOf("\n", start - 1) + 1;
232
+ const baseIndent = indentationWidth(source.slice(declarationLineStart, start));
233
+ let cursor = source.indexOf("\n", start);
234
+ if (cursor < 0) return source.length;
235
+ cursor += 1;
236
+ let bodyStarted = false;
237
+ while (cursor < source.length) {
238
+ const lineEnd = source.indexOf("\n", cursor);
239
+ const end = lineEnd < 0 ? source.length : lineEnd + 1;
240
+ const line = source.slice(cursor, lineEnd < 0 ? source.length : lineEnd);
241
+ if (/^\s*(?:#.*)?$/u.test(line)) {
242
+ cursor = end;
243
+ continue;
244
+ }
245
+ const indent = indentationWidth(line.match(/^[ \t]*/u)?.[0] ?? "");
246
+ if (indent > baseIndent) bodyStarted = true;
247
+ else if (bodyStarted) return cursor;
248
+ cursor = end;
249
+ }
250
+ return source.length;
251
+ }
252
+
253
+ function indentationWidth(value) {
254
+ let width = 0;
255
+ for (const character of value) width += character === "\t" ? 8 - (width % 8) : 1;
256
+ return width;
257
+ }
258
+
259
+ function canStartJavaScriptRegex(source, start, offset) {
260
+ const prefix = source.slice(start, offset).trimEnd();
261
+ if (!prefix) return true;
262
+ const previous = prefix.at(-1);
263
+ if (/[=(:,!&|?{};\[]/u.test(previous)) return true;
264
+ return /(?:^|\W)(?:case|delete|do|else|in|instanceof|new|return|throw|typeof|void|yield)\s*$/u.test(prefix);
265
+ }
266
+
267
+ function looksLikeRustCharacterLiteral(source, offset) {
268
+ return /^'(?:\\.|[^'\\\r\n])'/u.test(source.slice(offset));
269
+ }
270
+
271
+ export function extractImports(source, filePath) {
272
+ const extension = path.extname(filePath).toLowerCase();
273
+ const results = new Set();
274
+ const normalized = normalizeNewlines(source);
275
+ if ([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".svelte", ".astro", ".vue"].includes(extension)) {
276
+ const patterns = [
277
+ /^[ \t]*import\s+(?:[^;"']*?\s+from\s+)?["']([^"']+)["']/gmu,
278
+ /^[ \t]*export\s+[^;"']*?\s+from\s+["']([^"']+)["']/gmu,
279
+ /\b(?:require|import)\s*\(\s*["']([^"']+)["']\s*\)/gu,
280
+ ];
281
+ for (const pattern of patterns) {
282
+ for (const match of normalized.matchAll(pattern)) {
283
+ if (match[1] && isJavaScriptCodeOffset(normalized, match.index ?? 0)) results.add(match[1]);
284
+ }
285
+ }
286
+ } else if (extension === ".go") {
287
+ for (const match of normalized.matchAll(/^[ \t]*(?:import\s+)?(?:[A-Za-z_][\w]*\s+)?"([^"]+)"/gmu)) {
288
+ results.add(match[1]);
289
+ }
290
+ } else if (extension === ".rs") {
291
+ for (const match of normalized.matchAll(/^\s*(?:use|mod)\s+([^;]+);/gmu)) results.add(match[1].trim());
292
+ } else if (extension === ".py") {
293
+ for (const match of normalized.matchAll(/^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))/gmu)) {
294
+ results.add(match[1] ?? match[2]);
295
+ }
296
+ }
297
+ return [...results].sort();
298
+ }
299
+
300
+ function isJavaScriptCodeOffset(source, targetOffset) {
301
+ let state = "normal";
302
+ let escaped = false;
303
+ for (let index = 0; index < targetOffset; index += 1) {
304
+ const character = source[index];
305
+ const next = source[index + 1];
306
+ if (state === "line-comment") {
307
+ if (character === "\n") state = "normal";
308
+ continue;
309
+ }
310
+ if (state === "block-comment") {
311
+ if (character === "*" && next === "/") {
312
+ state = "normal";
313
+ index += 1;
314
+ }
315
+ continue;
316
+ }
317
+ if (state !== "normal") {
318
+ if (escaped) escaped = false;
319
+ else if (character === "\\") escaped = true;
320
+ else if (character === state) state = "normal";
321
+ continue;
322
+ }
323
+ if (character === "/" && next === "/") {
324
+ state = "line-comment";
325
+ index += 1;
326
+ } else if (character === "/" && next === "*") {
327
+ state = "block-comment";
328
+ index += 1;
329
+ } else if (character === '"' || character === "'" || character === "`") {
330
+ state = character;
331
+ }
332
+ }
333
+ return state === "normal";
334
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,124 @@
1
+ import path from "node:path";
2
+ import { loadConfig } from "./config.js";
3
+ import { generateProject } from "./generator.js";
4
+ import { usableFileState } from "./incremental.js";
5
+ import { verifySearchIndex } from "./inverted-index.js";
6
+ import { recoverGenerationTransaction } from "./transaction.js";
7
+ import { readJson, readJsonSafe, readText, sha256 } from "./util.js";
8
+
9
+ export async function doctorProject(root) {
10
+ const checks = [];
11
+ let config;
12
+ try {
13
+ ({ config } = await loadConfig(root));
14
+ checks.push(pass("config", ".llmnav/config.json is valid"));
15
+ } catch (error) {
16
+ checks.push(fail("config", error instanceof Error ? error.message : String(error)));
17
+ return { ok: false, checks };
18
+ }
19
+
20
+ try {
21
+ const recovery = await recoverGenerationTransaction(root, {
22
+ cacheDirectory: config.generation.cacheDirectory,
23
+ });
24
+ checks.push(
25
+ recovery.recovered
26
+ ? pass("transaction", `recovered interrupted generation using ${recovery.action}`)
27
+ : pass("transaction", "no interrupted generation is pending"),
28
+ );
29
+ } catch (error) {
30
+ checks.push(fail("transaction", error instanceof Error ? error.message : String(error)));
31
+ }
32
+
33
+ const major = Number.parseInt(process.versions.node.split(".")[0], 10);
34
+ checks.push(major >= 22 ? pass("node", `Node.js ${process.versions.node}`) : fail("node", "Node.js 22 or newer is required"));
35
+
36
+ for (const [name, relativePath] of [
37
+ ["agent", ".llmnav/AGENT_INSTRUCTIONS.md"],
38
+ ["registry", ".llmnav/ids.jsonl"],
39
+ ["order", ".llmnav/order.lock"],
40
+ ["lexicon", ".llmnav/lexicon.json"],
41
+ ["index", `${config.generation.cacheDirectory}/index.json`],
42
+ ["search-index", `${config.generation.cacheDirectory}/search-index.json`],
43
+ ["file-state", `${config.generation.cacheDirectory}/file-state.json`],
44
+ ]) {
45
+ const exists = (await readText(path.join(root, relativePath), null)) !== null;
46
+ checks.push(exists ? pass(name, `${relativePath} exists`) : fail(name, `${relativePath} is missing`));
47
+ }
48
+
49
+ const cacheRoot = path.join(root, config.generation.cacheDirectory);
50
+ const indexRead = await inspectJson(path.join(cacheRoot, "index.json"));
51
+ const searchRead = await inspectJson(path.join(cacheRoot, "search-index.json"));
52
+ const fileStateRead = await inspectJson(path.join(cacheRoot, "file-state.json"));
53
+ const index = indexRead.value;
54
+ const searchIndex = searchRead.value;
55
+ const fileState = fileStateRead.value;
56
+ if (indexRead.error) checks.push(fail("index-integrity", indexRead.error));
57
+ if (searchRead.error) checks.push(fail("search-index-integrity", searchRead.error));
58
+ else if (index && searchIndex) {
59
+ checks.push(
60
+ verifySearchIndex(index, searchIndex)
61
+ ? pass("search-index-integrity", "search-index.json matches index.json")
62
+ : fail("search-index-integrity", "search-index.json does not match index.json"),
63
+ );
64
+ }
65
+ if (fileStateRead.error) checks.push(fail("file-state-integrity", fileStateRead.error));
66
+ else if (fileState) {
67
+ checks.push(
68
+ usableFileState(fileState)
69
+ ? pass("file-state-integrity", "file-state.json uses the supported schema")
70
+ : fail("file-state-integrity", "file-state.json uses an unsupported or malformed schema"),
71
+ );
72
+ }
73
+
74
+ const manifestPath = path.join(cacheRoot, "manifest.json");
75
+ const manifestRead = await inspectJson(manifestPath);
76
+ const manifest = manifestRead.value;
77
+ if (manifestRead.error) checks.push(fail("manifest", manifestRead.error));
78
+ if (!manifest && !manifestRead.error) {
79
+ checks.push(fail("manifest", `${config.generation.cacheDirectory}/manifest.json is missing`));
80
+ } else if (manifest) {
81
+ let valid = true;
82
+ for (const [relativePath, expectedHash] of Object.entries(manifest.files ?? {})) {
83
+ const content = await readText(path.join(root, relativePath), null);
84
+ if (content === null || sha256(content) !== expectedHash) {
85
+ valid = false;
86
+ checks.push(fail("manifest", `${relativePath} does not match its manifest hash`));
87
+ }
88
+ }
89
+ if (valid) checks.push(pass("manifest", "generated cache hashes are valid"));
90
+ }
91
+
92
+ const generated = await generateProject(root, { check: true, incremental: false });
93
+ checks.push(
94
+ generated.ok
95
+ ? pass("generated", "generated files match a full source rebuild")
96
+ : fail("generated", `regenerate ${generated.changedFiles.join(", ") || "after fixing diagnostics"}`),
97
+ );
98
+
99
+ const packageJson = await readJsonSafe(path.join(root, "package.json"), null);
100
+ if (packageJson?.name === "llmnav" && JSON.stringify(packageJson).includes("github.com/OWNER/")) {
101
+ checks.push(fail("release", "replace OWNER in package.json before publishing"));
102
+ }
103
+
104
+ return { ok: checks.every((check) => check.ok), checks };
105
+ }
106
+
107
+ async function inspectJson(filePath) {
108
+ try {
109
+ return { value: await readJson(filePath, null), error: null };
110
+ } catch (error) {
111
+ return {
112
+ value: null,
113
+ error: `${path.basename(filePath)} is malformed: ${error instanceof Error ? error.message : String(error)}`,
114
+ };
115
+ }
116
+ }
117
+
118
+ function pass(name, message) {
119
+ return { name, ok: true, message };
120
+ }
121
+
122
+ function fail(name, message) {
123
+ return { name, ok: false, message };
124
+ }
package/src/editor.js ADDED
@@ -0,0 +1,107 @@
1
+ /* llmnav/1 module
2
+ id=llmnav.diagnostics.editor
3
+ role=Serialize deterministic editor ranges and publish bounded editor task integrations.
4
+ owns=editor diagnostic schema|zero-based ranges|VS Code task configuration
5
+ excludes=editor extension runtime|absolute workspace URIs|source mutation
6
+ search=editor diagnostics|VS Code problem matcher|problems panel|diagnostic range
7
+ rel=workflow>llmnav.rules.validate
8
+ stability=contract
9
+ */
10
+
11
+ import { compareText, stableStringify, toPosix } from "./util.js";
12
+
13
+ export const EDITOR_DIAGNOSTIC_SCHEMA_VERSION = 1;
14
+ export const EDITOR_INTEGRATION_SCHEMA_VERSION = 1;
15
+
16
+ export function diagnosticsToEditor(diagnostics) {
17
+ const byPath = new Map();
18
+ const sorted = [...diagnostics].sort(compareDiagnostics);
19
+ for (const diagnostic of sorted) {
20
+ const documentPath = toPosix(diagnostic.file);
21
+ const items = byPath.get(documentPath) ?? [];
22
+ const line = Math.max(0, Number(diagnostic.line ?? 1) - 1);
23
+ const character = Math.max(0, Number(diagnostic.column ?? 1) - 1);
24
+ items.push({
25
+ range: {
26
+ start: { line, character },
27
+ end: { line, character: character + 1 },
28
+ },
29
+ severity: severityNumber(diagnostic.severity),
30
+ level: diagnostic.severity,
31
+ code: diagnostic.code,
32
+ source: "llmnav",
33
+ message: diagnostic.message,
34
+ });
35
+ byPath.set(documentPath, items);
36
+ }
37
+ return {
38
+ schemaVersion: EDITOR_DIAGNOSTIC_SCHEMA_VERSION,
39
+ source: "llmnav",
40
+ coordinateBase: 0,
41
+ counts: countLevels(sorted),
42
+ documents: [...byPath.entries()]
43
+ .sort(([left], [right]) => compareText(left, right))
44
+ .map(([path, items]) => ({ path, diagnostics: items })),
45
+ };
46
+ }
47
+
48
+ export function renderEditorDiagnostics(diagnostics) {
49
+ return stableStringify(diagnosticsToEditor(diagnostics));
50
+ }
51
+
52
+ export function getEditorIntegration(name) {
53
+ if (name !== "vscode") throw editorUsageError(`Unknown editor integration ${JSON.stringify(name)}.`);
54
+ return {
55
+ schemaVersion: EDITOR_INTEGRATION_SCHEMA_VERSION,
56
+ editor: "vscode",
57
+ target: ".vscode/tasks.json",
58
+ config: {
59
+ version: "2.0.0",
60
+ tasks: [{
61
+ label: "LLMNav: check",
62
+ type: "shell",
63
+ command: "npm",
64
+ args: ["exec", "--", "llmnav", "check"],
65
+ group: { kind: "test", isDefault: false },
66
+ problemMatcher: {
67
+ owner: "llmnav",
68
+ fileLocation: ["relative", "${workspaceFolder}"],
69
+ source: "llmnav",
70
+ pattern: {
71
+ regexp: "^(.+):(\\d+):(\\d+) (error|warning|info) (LNV\\d+) (.+)$",
72
+ file: 1,
73
+ line: 2,
74
+ column: 3,
75
+ severity: 4,
76
+ code: 5,
77
+ message: 6,
78
+ },
79
+ },
80
+ presentation: { reveal: "silent", panel: "dedicated" },
81
+ }],
82
+ },
83
+ };
84
+ }
85
+
86
+ function severityNumber(level) {
87
+ if (level === "error") return 1;
88
+ if (level === "warning") return 2;
89
+ return 3;
90
+ }
91
+
92
+ function countLevels(diagnostics) {
93
+ const counts = { error: 0, warning: 0, info: 0 };
94
+ for (const item of diagnostics) counts[item.severity] = (counts[item.severity] ?? 0) + 1;
95
+ return counts;
96
+ }
97
+
98
+ function compareDiagnostics(left, right) {
99
+ return compareText(left.file, right.file) || left.line - right.line || left.column - right.column ||
100
+ compareText(left.code, right.code) || compareText(left.message, right.message);
101
+ }
102
+
103
+ function editorUsageError(message) {
104
+ const error = new Error(message);
105
+ error.exitCode = 2;
106
+ return error;
107
+ }
@@ -0,0 +1,67 @@
1
+ /* llmnav/1 module
2
+ id=llmnav.eval.measure
3
+ role=Measure whether repository task queries retrieve expected semantic IDs within configured rank gates.
4
+ owns=search regression tests|recall metrics|evaluation gates
5
+ excludes=benchmark task execution|model quality scoring
6
+ search=search recall|navigation benchmark|query regression
7
+ rel=workflow>llmnav.search.query
8
+ stability=architecture
9
+ */
10
+
11
+ import path from "node:path";
12
+ import { loadConfig } from "./config.js";
13
+ import { loadSearchData, queryPreparedIndex } from "./search.js";
14
+ import { assertNoSymlinkTraversal, parseJsonLines, readText } from "./util.js";
15
+
16
+ export async function evaluateProject(root, options = {}) {
17
+ const { config } = await loadConfig(root);
18
+ const { index, lexicon, searchIndex } = await loadSearchData(root);
19
+ const queryPath = path.resolve(root, options.file ?? config.evaluation.queryFile);
20
+ await assertNoSymlinkTraversal(root, queryPath, "evaluation query file");
21
+ const parsed = parseJsonLines(await readText(queryPath, ""), queryPath);
22
+ if (parsed.errors.length > 0) {
23
+ return { ok: false, errors: parsed.errors, cases: [], metrics: emptyMetrics() };
24
+ }
25
+
26
+ const cases = [];
27
+ for (const record of parsed.records) {
28
+ if (!record || typeof record.query !== "string" || !Array.isArray(record.expected) || record.expected.length === 0) {
29
+ return {
30
+ ok: false,
31
+ errors: [`${queryPath}: each record requires query:string and expected:string[]`],
32
+ cases,
33
+ metrics: emptyMetrics(),
34
+ };
35
+ }
36
+ const results = queryPreparedIndex(index, searchIndex, record.query, { top: Math.max(options.top ?? 5, 5), lexicon });
37
+ const ids = results.map((result) => result.id);
38
+ const firstRank = ids.findIndex((id) => record.expected.includes(id));
39
+ cases.push({
40
+ query: record.query,
41
+ expected: record.expected,
42
+ actual: ids,
43
+ rank: firstRank < 0 ? null : firstRank + 1,
44
+ passAt1: firstRank === 0,
45
+ passAt5: firstRank >= 0 && firstRank < 5,
46
+ });
47
+ }
48
+
49
+ const total = cases.length;
50
+ const metrics = total === 0
51
+ ? emptyMetrics()
52
+ : {
53
+ total,
54
+ recallAt1: cases.filter((item) => item.passAt1).length / total,
55
+ recallAt5: cases.filter((item) => item.passAt5).length / total,
56
+ meanReciprocalRank: cases.reduce((sum, item) => sum + (item.rank ? 1 / item.rank : 0), 0) / total,
57
+ };
58
+ const ok =
59
+ total === 0 ||
60
+ (metrics.recallAt1 >= config.evaluation.minimumRecallAt1 &&
61
+ metrics.recallAt5 >= config.evaluation.minimumRecallAt5);
62
+ return { ok, errors: [], cases, metrics, thresholds: config.evaluation };
63
+ }
64
+
65
+ function emptyMetrics() {
66
+ return { total: 0, recallAt1: 0, recallAt5: 0, meanReciprocalRank: 0 };
67
+ }