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.
- package/CHANGELOG.md +113 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/ROADMAP.md +71 -0
- package/bin/llmnav.js +16 -0
- package/docs/agent-integration.md +114 -0
- package/docs/api.md +290 -0
- package/docs/architecture.md +286 -0
- package/docs/benchmarking.md +164 -0
- package/docs/ci.md +196 -0
- package/docs/cli.md +233 -0
- package/docs/configuration.md +117 -0
- package/docs/editor-integration.md +29 -0
- package/docs/faq.md +59 -0
- package/docs/graph.md +92 -0
- package/docs/language-examples.md +130 -0
- package/docs/migration.md +130 -0
- package/docs/performance-v0.2.md +42 -0
- package/docs/provider-neutral-integration.md +66 -0
- package/docs/publishing.md +86 -0
- package/docs/quickstart.md +139 -0
- package/docs/research.md +31 -0
- package/docs/spec.md +424 -0
- package/examples/provider-neutral-host.d.mts +17 -0
- package/examples/provider-neutral-host.mjs +40 -0
- package/package.json +79 -0
- package/schema/config.schema.json +296 -0
- package/src/agent-protocol.js +117 -0
- package/src/agent-tools.js +61 -0
- package/src/agents.js +127 -0
- package/src/boundaries.js +50 -0
- package/src/changes.js +168 -0
- package/src/cli.js +459 -0
- package/src/config.js +305 -0
- package/src/contracts.js +70 -0
- package/src/declaration.js +334 -0
- package/src/doctor.js +124 -0
- package/src/editor.js +107 -0
- package/src/evaluation.js +67 -0
- package/src/files.js +81 -0
- package/src/formatter.js +23 -0
- package/src/generator.js +528 -0
- package/src/graph-input.js +157 -0
- package/src/graph.js +403 -0
- package/src/incremental.js +262 -0
- package/src/index.d.ts +673 -0
- package/src/index.js +115 -0
- package/src/initializer.js +137 -0
- package/src/inverted-index.js +350 -0
- package/src/parser.js +449 -0
- package/src/project.js +65 -0
- package/src/prompt-bundle.js +108 -0
- package/src/registry.js +107 -0
- package/src/sarif.js +70 -0
- package/src/search-shards.js +75 -0
- package/src/search.js +636 -0
- package/src/spec.d.ts +27 -0
- package/src/spec.js +237 -0
- package/src/tokenizer.js +37 -0
- package/src/transaction.js +557 -0
- package/src/util.js +256 -0
- package/src/validator.js +635 -0
- package/templates/file-card.txt +8 -0
- package/templates/lexicon.json +7 -0
- package/templates/line-card.txt +9 -0
- package/templates/module-card.txt +9 -0
- package/templates/queries.jsonl +1 -0
- package/templates/symbol-card.txt +10 -0
package/src/validator.js
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
/* llmnav/1 module
|
|
2
|
+
id=llmnav.rules.validate
|
|
3
|
+
role=Reject unstable, ambiguous, oversized, or structurally invalid LLMNav metadata before indexing.
|
|
4
|
+
owns=semantic lint rules|coverage rules|registry consistency
|
|
5
|
+
excludes=source rewriting|search ranking
|
|
6
|
+
search=llmnav lint|metadata validation|semantic drift
|
|
7
|
+
rel=workflow>llmnav.syntax.parse
|
|
8
|
+
stability=architecture
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
ALLOWED_KEYS,
|
|
14
|
+
CROSS_REPO_ID_PATTERN,
|
|
15
|
+
EFFECT_KINDS,
|
|
16
|
+
EFFECT_KINDS_WITH_ARGUMENT,
|
|
17
|
+
EFFECT_KINDS_WITHOUT_ARGUMENT,
|
|
18
|
+
FORBIDDEN_STRUCTURE_RELATIONS,
|
|
19
|
+
FORBIDDEN_VOLATILE_KEYS,
|
|
20
|
+
ID_PATTERN,
|
|
21
|
+
KEY_ORDER,
|
|
22
|
+
LIST_KEYS,
|
|
23
|
+
RELATION_KINDS,
|
|
24
|
+
REPEATABLE_KEYS,
|
|
25
|
+
REQUIRED_KEYS,
|
|
26
|
+
RISK_KINDS,
|
|
27
|
+
SCOPES,
|
|
28
|
+
STABILITIES,
|
|
29
|
+
} from "./spec.js";
|
|
30
|
+
import { formatLlmnavBlock } from "./parser.js";
|
|
31
|
+
import { resolveRegistryId } from "./registry.js";
|
|
32
|
+
import { compareText, matchesAnyGlob, unique } from "./util.js";
|
|
33
|
+
|
|
34
|
+
export function validateProject(project) {
|
|
35
|
+
const diagnostics = [];
|
|
36
|
+
const idRecords = new Map();
|
|
37
|
+
|
|
38
|
+
for (const error of project.registry.errors) {
|
|
39
|
+
diagnostics.push(diagnostic("error", "LNV002", error, ".llmnav/ids.jsonl", 1));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const record of project.records) {
|
|
43
|
+
const local = validateRecord(record, project.config);
|
|
44
|
+
diagnostics.push(...local);
|
|
45
|
+
if (record.card.id) {
|
|
46
|
+
const existing = idRecords.get(record.card.id);
|
|
47
|
+
if (existing) {
|
|
48
|
+
diagnostics.push(
|
|
49
|
+
diagnostic(
|
|
50
|
+
"error",
|
|
51
|
+
"LNV002",
|
|
52
|
+
`Duplicate semantic ID ${record.card.id}; first declared in ${existing.relativePath}:${existing.block.startLine}.`,
|
|
53
|
+
record.relativePath,
|
|
54
|
+
record.block.startLine,
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
} else {
|
|
58
|
+
idRecords.set(record.card.id, record);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
validateRelations(project, idRecords, diagnostics);
|
|
64
|
+
validateRegistry(project, idRecords, diagnostics);
|
|
65
|
+
validateCoverage(project, diagnostics);
|
|
66
|
+
validateSearchSaturation(project, diagnostics);
|
|
67
|
+
validateSemanticRatio(project, diagnostics);
|
|
68
|
+
|
|
69
|
+
diagnostics.sort(compareDiagnostics);
|
|
70
|
+
return diagnostics;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateRecord(record, config) {
|
|
74
|
+
const diagnostics = [];
|
|
75
|
+
const { block, card, relativePath, declaration } = record;
|
|
76
|
+
for (const syntaxError of block.syntaxErrors) {
|
|
77
|
+
diagnostics.push(diagnostic("error", "LNV011", syntaxError.message, relativePath, syntaxError.line));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!SCOPES.includes(block.scope)) {
|
|
81
|
+
diagnostics.push(diagnostic("error", "LNV011", `Unknown scope ${block.scope}.`, relativePath, block.startLine));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const unknown of card.unknown) {
|
|
85
|
+
const code = FORBIDDEN_VOLATILE_KEYS.includes(unknown.key) ? "LNV003" : "LNV005";
|
|
86
|
+
const message =
|
|
87
|
+
code === "LNV003"
|
|
88
|
+
? `Volatile or generated field ${unknown.key} is forbidden in source comments.`
|
|
89
|
+
: `Unknown field ${unknown.key}; allowed fields are ${ALLOWED_KEYS.join(", ")}.`;
|
|
90
|
+
diagnostics.push(diagnostic("error", code, message, relativePath, unknown.line));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const presentKeys = new Set(block.entries.map((entry) => entry.key));
|
|
94
|
+
for (const entry of block.entries) {
|
|
95
|
+
if (!entry.value.trim()) {
|
|
96
|
+
diagnostics.push(
|
|
97
|
+
diagnostic("error", "LNV005", `Field ${entry.key} must not be empty.`, relativePath, entry.line),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (LIST_KEYS.includes(entry.key)) {
|
|
101
|
+
const items = entry.value.split("|");
|
|
102
|
+
if (items.some((item) => !item.trim())) {
|
|
103
|
+
diagnostics.push(
|
|
104
|
+
diagnostic("error", "LNV005", `Field ${entry.key} contains an empty list item.`, relativePath, entry.line),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const key of REQUIRED_KEYS) {
|
|
111
|
+
if (!presentKeys.has(key)) {
|
|
112
|
+
diagnostics.push(diagnostic("error", "LNV001", `Missing required field ${key}.`, relativePath, block.startLine));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const counts = new Map();
|
|
117
|
+
for (const entry of block.entries) counts.set(entry.key, (counts.get(entry.key) ?? 0) + 1);
|
|
118
|
+
for (const key of ["id", "role", "owns", "excludes", "search", "effect", "risk", "stability"]) {
|
|
119
|
+
if ((counts.get(key) ?? 0) > 1) {
|
|
120
|
+
diagnostics.push(
|
|
121
|
+
diagnostic("error", "LNV005", `${key} must appear once; combine list values with |.`, relativePath, block.startLine),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (card.id && !ID_PATTERN.test(card.id)) {
|
|
127
|
+
diagnostics.push(
|
|
128
|
+
diagnostic(
|
|
129
|
+
"error",
|
|
130
|
+
"LNV002",
|
|
131
|
+
`Invalid ID ${card.id}; use lower-case dot segments such as auth.session.rotate.`,
|
|
132
|
+
relativePath,
|
|
133
|
+
lineFor(block, "id"),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
validateDuplicateValues(record, diagnostics);
|
|
139
|
+
validateRole(record, config, diagnostics);
|
|
140
|
+
validateSearch(record, config, diagnostics);
|
|
141
|
+
validateInvariants(record, config, diagnostics);
|
|
142
|
+
validateEffects(record, config, diagnostics);
|
|
143
|
+
validateRisks(record, config, diagnostics);
|
|
144
|
+
validateRelationsForRecord(record, config, diagnostics);
|
|
145
|
+
|
|
146
|
+
if (card.stability && !STABILITIES.includes(card.stability)) {
|
|
147
|
+
diagnostics.push(
|
|
148
|
+
diagnostic(
|
|
149
|
+
"error",
|
|
150
|
+
"LNV005",
|
|
151
|
+
`Unknown stability ${card.stability}; expected ${STABILITIES.join(", ")}.`,
|
|
152
|
+
relativePath,
|
|
153
|
+
lineFor(block, "stability"),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const limit = config.lint.maxBlockBytes[block.scope];
|
|
159
|
+
const bytes = Buffer.byteLength(block.raw);
|
|
160
|
+
if (limit && bytes > limit) {
|
|
161
|
+
diagnostics.push(
|
|
162
|
+
diagnostic("error", "LNV013", `${block.scope} block is ${bytes} bytes; limit is ${limit}.`, relativePath, block.startLine),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (block.scope === "symbol" && !declaration) {
|
|
167
|
+
diagnostics.push(
|
|
168
|
+
diagnostic(
|
|
169
|
+
"error",
|
|
170
|
+
"LNV011",
|
|
171
|
+
"Symbol card is not attached to a recognized declaration within the next 3,000 characters.",
|
|
172
|
+
relativePath,
|
|
173
|
+
block.endLine,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (config.lint.requireCanonicalOrder) {
|
|
179
|
+
const positions = block.entries
|
|
180
|
+
.filter((entry) => KEY_ORDER.includes(entry.key))
|
|
181
|
+
.map((entry) => KEY_ORDER.indexOf(entry.key));
|
|
182
|
+
if (positions.some((position, index) => index > 0 && position < positions[index - 1])) {
|
|
183
|
+
diagnostics.push(
|
|
184
|
+
diagnostic("error", "LNV010", "Fields are not in canonical order; run llmnav format.", relativePath, block.startLine),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (config.lint.requireCanonicalFormatting) {
|
|
190
|
+
const expected = formatLlmnavBlock(block).trim();
|
|
191
|
+
if (block.raw.trim() !== expected) {
|
|
192
|
+
diagnostics.push(
|
|
193
|
+
diagnostic("error", "LNV010", "Block is not canonically formatted; run llmnav format.", relativePath, block.startLine),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return diagnostics;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
function validateDuplicateValues(record, diagnostics) {
|
|
203
|
+
const { card, block, relativePath } = record;
|
|
204
|
+
for (const key of [...LIST_KEYS, ...REPEATABLE_KEYS]) {
|
|
205
|
+
const values = card[key] ?? [];
|
|
206
|
+
const normalized = values.map(normalizePhrase);
|
|
207
|
+
if (unique(normalized).length === normalized.length) continue;
|
|
208
|
+
const code = key === "search" ? "LNV007" : "LNV005";
|
|
209
|
+
diagnostics.push(
|
|
210
|
+
diagnostic("error", code, `${key} contains duplicate values.`, relativePath, lineFor(block, key)),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function validateRole(record, config, diagnostics) {
|
|
216
|
+
const { card, block, relativePath } = record;
|
|
217
|
+
if (!card.role) return;
|
|
218
|
+
if (card.role.length > config.lint.maxRoleLength) {
|
|
219
|
+
diagnostics.push(
|
|
220
|
+
diagnostic(
|
|
221
|
+
"error",
|
|
222
|
+
"LNV006",
|
|
223
|
+
`role is ${card.role.length} characters; limit is ${config.lint.maxRoleLength}.`,
|
|
224
|
+
relativePath,
|
|
225
|
+
lineFor(block, "role"),
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const words = tokenize(card.role);
|
|
230
|
+
if (words.length <= 5 && config.lint.vagueRoleWords.some((word) => words.includes(word.toLowerCase()))) {
|
|
231
|
+
diagnostics.push(
|
|
232
|
+
diagnostic(
|
|
233
|
+
"warning",
|
|
234
|
+
"LNV006",
|
|
235
|
+
"role is vague; describe the observable result instead of using handle, manage, process, service, helper, or utility.",
|
|
236
|
+
relativePath,
|
|
237
|
+
lineFor(block, "role"),
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (looksVolatile(card.role)) {
|
|
242
|
+
diagnostics.push(
|
|
243
|
+
diagnostic("error", "LNV003", "role contains path, line, commit, or timestamp-like volatile data.", relativePath, lineFor(block, "role")),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function validateSearch(record, config, diagnostics) {
|
|
249
|
+
const { card, block, relativePath } = record;
|
|
250
|
+
if (card.search.length === 0) return;
|
|
251
|
+
if (card.search.length < config.lint.minSearchTerms || card.search.length > config.lint.maxSearchTerms) {
|
|
252
|
+
diagnostics.push(
|
|
253
|
+
diagnostic(
|
|
254
|
+
"error",
|
|
255
|
+
"LNV007",
|
|
256
|
+
`search requires ${config.lint.minSearchTerms} to ${config.lint.maxSearchTerms} phrases; found ${card.search.length}.`,
|
|
257
|
+
relativePath,
|
|
258
|
+
lineFor(block, "search"),
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const normalized = card.search.map(normalizePhrase);
|
|
263
|
+
const generic = new Set(config.lint.genericSearchTerms.map(normalizePhrase));
|
|
264
|
+
for (const phrase of normalized) {
|
|
265
|
+
if (generic.has(phrase)) {
|
|
266
|
+
diagnostics.push(
|
|
267
|
+
diagnostic("error", "LNV007", `Generic search phrase ${JSON.stringify(phrase)} is forbidden.`, relativePath, lineFor(block, "search")),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (card.search.some(looksVolatile)) {
|
|
272
|
+
diagnostics.push(
|
|
273
|
+
diagnostic("error", "LNV003", "search contains path, line, commit, or timestamp-like volatile data.", relativePath, lineFor(block, "search")),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function validateInvariants(record, config, diagnostics) {
|
|
279
|
+
const { card, block, relativePath } = record;
|
|
280
|
+
if (card.invariant.length > config.lint.maxInvariants) {
|
|
281
|
+
diagnostics.push(
|
|
282
|
+
diagnostic(
|
|
283
|
+
"error",
|
|
284
|
+
"LNV013",
|
|
285
|
+
`Too many invariants; maximum is ${config.lint.maxInvariants}.`,
|
|
286
|
+
relativePath,
|
|
287
|
+
lineFor(block, "invariant"),
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (card.invariant.some(looksVolatile)) {
|
|
292
|
+
diagnostics.push(
|
|
293
|
+
diagnostic("error", "LNV003", "invariant contains volatile location or revision data.", relativePath, lineFor(block, "invariant")),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function validateEffects(record, config, diagnostics) {
|
|
299
|
+
const { card, block, relativePath } = record;
|
|
300
|
+
if (card.effect.length > config.lint.maxEffects) {
|
|
301
|
+
diagnostics.push(
|
|
302
|
+
diagnostic("error", "LNV013", `Too many effects; maximum is ${config.lint.maxEffects}.`, relativePath, lineFor(block, "effect")),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const allowed = new Set([...EFFECT_KINDS, ...config.lint.additionalEffects]);
|
|
306
|
+
const requiresArgument = new Set(EFFECT_KINDS_WITH_ARGUMENT);
|
|
307
|
+
const forbidsArgument = new Set(EFFECT_KINDS_WITHOUT_ARGUMENT);
|
|
308
|
+
for (const effect of card.effect) {
|
|
309
|
+
const match = effect.match(/^([a-z][a-z0-9.-]*)(?:\(([A-Za-z0-9_.:/-]+)\))?$/u);
|
|
310
|
+
if (!match || !allowed.has(match[1])) {
|
|
311
|
+
diagnostics.push(
|
|
312
|
+
diagnostic(
|
|
313
|
+
"error",
|
|
314
|
+
"LNV005",
|
|
315
|
+
`Invalid effect ${effect}; use a controlled effect such as db.write(session_tokens).`,
|
|
316
|
+
relativePath,
|
|
317
|
+
lineFor(block, "effect"),
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const [, kind, argument] = match;
|
|
323
|
+
if (requiresArgument.has(kind) && !argument) {
|
|
324
|
+
diagnostics.push(
|
|
325
|
+
diagnostic(
|
|
326
|
+
"error",
|
|
327
|
+
"LNV005",
|
|
328
|
+
`Effect ${kind} requires one stable target argument, for example ${kind}(resource_name).`,
|
|
329
|
+
relativePath,
|
|
330
|
+
lineFor(block, "effect"),
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
} else if (forbidsArgument.has(kind) && argument) {
|
|
334
|
+
diagnostics.push(
|
|
335
|
+
diagnostic(
|
|
336
|
+
"error",
|
|
337
|
+
"LNV005",
|
|
338
|
+
`Effect ${kind} does not accept an argument.`,
|
|
339
|
+
relativePath,
|
|
340
|
+
lineFor(block, "effect"),
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function validateRisks(record, config, diagnostics) {
|
|
348
|
+
const { card, block, relativePath } = record;
|
|
349
|
+
const allowed = new Set([...RISK_KINDS, ...config.lint.additionalRisks]);
|
|
350
|
+
for (const risk of card.risk) {
|
|
351
|
+
if (!allowed.has(risk)) {
|
|
352
|
+
diagnostics.push(diagnostic("error", "LNV005", `Unknown risk ${risk}.`, relativePath, lineFor(block, "risk")));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const strict = card.risk.some((risk) => config.lint.strictRisks.includes(risk));
|
|
356
|
+
if (strict && card.invariant.length === 0) {
|
|
357
|
+
diagnostics.push(
|
|
358
|
+
diagnostic("error", "LNV001", "auth, money, and privacy cards require at least one invariant.", relativePath, block.startLine),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
if (strict && !card.rel.some((relation) => relation.startsWith("test>"))) {
|
|
362
|
+
diagnostics.push(
|
|
363
|
+
diagnostic("error", "LNV001", "auth, money, and privacy cards require a rel=test>… contract link.", relativePath, block.startLine),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function validateRelationsForRecord(record, config, diagnostics) {
|
|
369
|
+
const { card, block, relativePath } = record;
|
|
370
|
+
if (card.rel.length > config.lint.maxRelations) {
|
|
371
|
+
diagnostics.push(
|
|
372
|
+
diagnostic("error", "LNV013", `Too many relations; maximum is ${config.lint.maxRelations}.`, relativePath, lineFor(block, "rel")),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const allowed = new Set([...RELATION_KINDS, ...config.lint.additionalRelations]);
|
|
376
|
+
for (const relation of card.rel) {
|
|
377
|
+
const separator = relation.indexOf(">");
|
|
378
|
+
if (separator <= 0) {
|
|
379
|
+
diagnostics.push(
|
|
380
|
+
diagnostic("error", "LNV005", `Invalid relation ${relation}; use type>semantic.id.`, relativePath, lineFor(block, "rel")),
|
|
381
|
+
);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const type = relation.slice(0, separator);
|
|
385
|
+
const target = relation.slice(separator + 1);
|
|
386
|
+
if (FORBIDDEN_STRUCTURE_RELATIONS.includes(type)) {
|
|
387
|
+
diagnostics.push(
|
|
388
|
+
diagnostic(
|
|
389
|
+
"error",
|
|
390
|
+
"LNV004",
|
|
391
|
+
`Relation ${type} is generated structure and must not be maintained by hand.`,
|
|
392
|
+
relativePath,
|
|
393
|
+
lineFor(block, "rel"),
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (!allowed.has(type)) {
|
|
399
|
+
diagnostics.push(diagnostic("error", "LNV005", `Unknown relation type ${type}.`, relativePath, lineFor(block, "rel")));
|
|
400
|
+
}
|
|
401
|
+
const validTarget = type === "cross-repo" ? CROSS_REPO_ID_PATTERN.test(target) : ID_PATTERN.test(target);
|
|
402
|
+
if (!validTarget) {
|
|
403
|
+
diagnostics.push(diagnostic("error", "LNV005", `Invalid relation target ${target}.`, relativePath, lineFor(block, "rel")));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function validateRelations(project, idRecords, diagnostics) {
|
|
409
|
+
for (const record of project.records) {
|
|
410
|
+
for (const relation of record.card.rel) {
|
|
411
|
+
const separator = relation.indexOf(">");
|
|
412
|
+
if (separator <= 0) continue;
|
|
413
|
+
const type = relation.slice(0, separator);
|
|
414
|
+
const target = relation.slice(separator + 1);
|
|
415
|
+
if (type === "cross-repo") continue;
|
|
416
|
+
if (!idRecords.has(target) && !project.registry.byId.has(target)) {
|
|
417
|
+
diagnostics.push(
|
|
418
|
+
diagnostic(
|
|
419
|
+
"error",
|
|
420
|
+
"LNV008",
|
|
421
|
+
`Relation target ${target} does not exist in source or .llmnav/ids.jsonl.`,
|
|
422
|
+
record.relativePath,
|
|
423
|
+
lineFor(record.block, "rel"),
|
|
424
|
+
),
|
|
425
|
+
);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const registryTarget = project.registry.byId.get(target);
|
|
429
|
+
if (registryTarget && registryTarget.state !== "active") {
|
|
430
|
+
const resolved = resolveRegistryId(project.registry, target);
|
|
431
|
+
if (resolved.state !== "active") {
|
|
432
|
+
diagnostics.push(
|
|
433
|
+
diagnostic(
|
|
434
|
+
"error",
|
|
435
|
+
"LNV008",
|
|
436
|
+
`Relation target ${target} resolves to ${resolved.state}, not an active semantic ID.`,
|
|
437
|
+
record.relativePath,
|
|
438
|
+
lineFor(record.block, "rel"),
|
|
439
|
+
),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function validateRegistry(project, idRecords, diagnostics) {
|
|
448
|
+
for (const [id, record] of idRecords) {
|
|
449
|
+
const registryRecord = project.registry.byId.get(id);
|
|
450
|
+
if (registryRecord && registryRecord.state !== "active") {
|
|
451
|
+
diagnostics.push(
|
|
452
|
+
diagnostic(
|
|
453
|
+
"error",
|
|
454
|
+
"LNV002",
|
|
455
|
+
`Source uses registry ID ${id}, but its state is ${registryRecord.state}.`,
|
|
456
|
+
record.relativePath,
|
|
457
|
+
record.block.startLine,
|
|
458
|
+
),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
for (const registryRecord of project.registry.records) {
|
|
463
|
+
const targets = registryRecord.state === "redirect"
|
|
464
|
+
? [registryRecord.to]
|
|
465
|
+
: registryRecord.state === "replaced" && Array.isArray(registryRecord.by)
|
|
466
|
+
? registryRecord.by
|
|
467
|
+
: [];
|
|
468
|
+
for (const target of targets.filter(Boolean)) {
|
|
469
|
+
if (!idRecords.has(target) && !project.registry.byId.has(target)) {
|
|
470
|
+
diagnostics.push(
|
|
471
|
+
diagnostic(
|
|
472
|
+
"error",
|
|
473
|
+
"LNV002",
|
|
474
|
+
`Registry ID ${registryRecord.id} points to missing semantic ID ${target}.`,
|
|
475
|
+
".llmnav/ids.jsonl",
|
|
476
|
+
1,
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (registryRecord.state === "redirect" || registryRecord.state === "replaced") {
|
|
482
|
+
const resolved = resolveRegistryId(project.registry, registryRecord.id);
|
|
483
|
+
if (resolved.state === "cycle") {
|
|
484
|
+
diagnostics.push(
|
|
485
|
+
diagnostic(
|
|
486
|
+
"error",
|
|
487
|
+
"LNV002",
|
|
488
|
+
`Registry ID ${registryRecord.id} participates in a redirect or replacement cycle.`,
|
|
489
|
+
".llmnav/ids.jsonl",
|
|
490
|
+
1,
|
|
491
|
+
),
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (registryRecord.state === "active" && !idRecords.has(registryRecord.id)) {
|
|
496
|
+
diagnostics.push(
|
|
497
|
+
diagnostic(
|
|
498
|
+
"warning",
|
|
499
|
+
"LNV012",
|
|
500
|
+
`Registry ID ${registryRecord.id} is active but has no source card; mark it redirect, replaced, or retired if deleted.`,
|
|
501
|
+
".llmnav/ids.jsonl",
|
|
502
|
+
1,
|
|
503
|
+
),
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function validateCoverage(project, diagnostics) {
|
|
510
|
+
for (const rule of project.config.coverageRules) {
|
|
511
|
+
if (!rule || typeof rule !== "object" || !Array.isArray(rule.match)) continue;
|
|
512
|
+
for (const file of project.fileRecords) {
|
|
513
|
+
if (!matchesAnyGlob(file.relativePath, rule.match)) continue;
|
|
514
|
+
const cards = file.blocks.filter((block) => !rule.scope || block.scope === rule.scope);
|
|
515
|
+
if (cards.length === 0) {
|
|
516
|
+
diagnostics.push(
|
|
517
|
+
diagnostic(
|
|
518
|
+
"error",
|
|
519
|
+
"LNV001",
|
|
520
|
+
`Coverage rule ${JSON.stringify(rule.name ?? rule.match.join(", "))} requires a ${rule.scope ?? "LLMNav"} card.`,
|
|
521
|
+
file.relativePath,
|
|
522
|
+
1,
|
|
523
|
+
),
|
|
524
|
+
);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
for (const field of rule.requiredFields ?? []) {
|
|
528
|
+
if (!cards.some((block) => hasValue(block.card[field]))) {
|
|
529
|
+
diagnostics.push(
|
|
530
|
+
diagnostic(
|
|
531
|
+
"error",
|
|
532
|
+
"LNV001",
|
|
533
|
+
`Coverage rule ${JSON.stringify(rule.name ?? "unnamed")} requires field ${field}.`,
|
|
534
|
+
file.relativePath,
|
|
535
|
+
cards[0].startLine,
|
|
536
|
+
),
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function validateSearchSaturation(project, diagnostics) {
|
|
545
|
+
const cards = project.records.filter((record) => record.card.id);
|
|
546
|
+
if (cards.length < project.config.lint.minimumCardsForSaturation) return;
|
|
547
|
+
const counts = new Map();
|
|
548
|
+
for (const record of cards) {
|
|
549
|
+
for (const phrase of new Set(record.card.search.map(normalizePhrase))) {
|
|
550
|
+
counts.set(phrase, (counts.get(phrase) ?? 0) + 1);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (const [phrase, count] of counts) {
|
|
554
|
+
const ratio = count / cards.length;
|
|
555
|
+
if (ratio > project.config.lint.searchTermSaturation) {
|
|
556
|
+
diagnostics.push(
|
|
557
|
+
diagnostic(
|
|
558
|
+
"warning",
|
|
559
|
+
"LNV007",
|
|
560
|
+
`Search phrase ${JSON.stringify(phrase)} appears in ${(ratio * 100).toFixed(1)}% of cards; limit is ${(project.config.lint.searchTermSaturation * 100).toFixed(1)}%.`,
|
|
561
|
+
".llmnav/config.json",
|
|
562
|
+
1,
|
|
563
|
+
),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function validateSemanticRatio(project, diagnostics) {
|
|
570
|
+
if (project.sourceBytes < project.config.lint.minimumSourceBytesForRatio) return;
|
|
571
|
+
const ratio = project.semanticBytes / project.sourceBytes;
|
|
572
|
+
if (ratio > project.config.lint.maxSemanticRatio) {
|
|
573
|
+
diagnostics.push(
|
|
574
|
+
diagnostic(
|
|
575
|
+
"warning",
|
|
576
|
+
"LNV013",
|
|
577
|
+
`LLMNav comments occupy ${(ratio * 100).toFixed(2)}% of scanned source; budget is ${(project.config.lint.maxSemanticRatio * 100).toFixed(2)}%.`,
|
|
578
|
+
".llmnav/config.json",
|
|
579
|
+
1,
|
|
580
|
+
),
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function diagnostic(severity, code, message, file, line = 1, column = 1) {
|
|
586
|
+
return { severity, code, message, file, line, column };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function countDiagnostics(diagnostics) {
|
|
590
|
+
return diagnostics.reduce(
|
|
591
|
+
(counts, item) => {
|
|
592
|
+
counts[item.severity] = (counts[item.severity] ?? 0) + 1;
|
|
593
|
+
return counts;
|
|
594
|
+
},
|
|
595
|
+
{ error: 0, warning: 0, info: 0 },
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function lineFor(block, key) {
|
|
600
|
+
return block.entries.find((entry) => entry.key === key)?.line ?? block.startLine;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function normalizePhrase(value) {
|
|
604
|
+
return value.normalize("NFKC").trim().toLocaleLowerCase("en-US");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function tokenize(value) {
|
|
608
|
+
return value
|
|
609
|
+
.normalize("NFKC")
|
|
610
|
+
.toLocaleLowerCase("en-US")
|
|
611
|
+
.match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function looksVolatile(value) {
|
|
615
|
+
return (
|
|
616
|
+
/(?:^|\s)(?:src|app|packages|services|internal|cmd|lib)\/[\w./-]+/u.test(value) ||
|
|
617
|
+
/\bline\s+\d+\b/iu.test(value) ||
|
|
618
|
+
/:\d{1,6}(?:-\d{1,6})?\b/u.test(value) ||
|
|
619
|
+
/\b[0-9a-f]{12,40}\b/iu.test(value) ||
|
|
620
|
+
/\b20\d{2}-\d{2}-\d{2}\b/u.test(value)
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function hasValue(value) {
|
|
625
|
+
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function compareDiagnostics(left, right) {
|
|
629
|
+
return (
|
|
630
|
+
compareText(left.file, right.file) ||
|
|
631
|
+
left.line - right.line ||
|
|
632
|
+
left.column - right.column ||
|
|
633
|
+
compareText(left.code, right.code)
|
|
634
|
+
);
|
|
635
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# llmnav/1 symbol
|
|
2
|
+
# id=domain.capability.action
|
|
3
|
+
# role=Describe the observable result produced by this declaration.
|
|
4
|
+
# search=user task phrase|domain term|failure symptom
|
|
5
|
+
# invariant=State the condition whose violation is a bug.
|
|
6
|
+
# effect=db.write(resource)
|
|
7
|
+
# risk=concurrency
|
|
8
|
+
# stability=contract
|
|
9
|
+
# /llmnav
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/* llmnav/1 module
|
|
2
|
+
id=domain.capability
|
|
3
|
+
role=Own one durable domain capability and its policy boundary.
|
|
4
|
+
owns=owned responsibility|owned state
|
|
5
|
+
excludes=adjacent responsibility|external state
|
|
6
|
+
search=user task phrase|domain term|historical name
|
|
7
|
+
invariant=State the condition that must remain true.
|
|
8
|
+
stability=architecture
|
|
9
|
+
*/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"query":"real task description copied from an issue","expected":["domain.capability.action"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* llmnav/1 symbol
|
|
2
|
+
id=domain.capability.action
|
|
3
|
+
role=Describe the observable result produced by this declaration.
|
|
4
|
+
search=user task phrase|domain term|failure symptom
|
|
5
|
+
invariant=State the condition whose violation is a bug.
|
|
6
|
+
effect=db.write(resource)
|
|
7
|
+
risk=concurrency
|
|
8
|
+
rel=test>domain.capability.action-contract
|
|
9
|
+
stability=contract
|
|
10
|
+
*/
|