@mnemonik/shared 6.48.0 → 6.51.0

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 (60) hide show
  1. package/dist/ast/astChunker.d.ts +124 -0
  2. package/dist/ast/astChunker.d.ts.map +1 -0
  3. package/dist/ast/astChunker.js +559 -0
  4. package/dist/ast/astChunker.js.map +1 -0
  5. package/dist/ast/grammars.d.ts +170 -0
  6. package/dist/ast/grammars.d.ts.map +1 -0
  7. package/dist/ast/grammars.js +411 -0
  8. package/dist/ast/grammars.js.map +1 -0
  9. package/dist/codeScanner.d.ts +106 -0
  10. package/dist/codeScanner.d.ts.map +1 -1
  11. package/dist/codeScanner.js +552 -62
  12. package/dist/codeScanner.js.map +1 -1
  13. package/dist/index.d.ts +3 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -1
  16. package/dist/index.js.map +1 -1
  17. package/package.json +3 -2
  18. package/queries/bash.tags.scm +7 -0
  19. package/queries/groovy.tags.scm +15 -0
  20. package/queries/powershell.tags.scm +14 -0
  21. package/scripts/vendor-grammars.ts +317 -0
  22. package/src/ast/astChunker.ts +661 -0
  23. package/src/ast/grammars.ts +490 -0
  24. package/src/codeScanner.ts +577 -63
  25. package/src/index.ts +25 -0
  26. package/wasm/bash.tags.scm +7 -0
  27. package/wasm/bash.wasm +0 -0
  28. package/wasm/c-sharp.tags.scm +23 -0
  29. package/wasm/c-sharp.wasm +0 -0
  30. package/wasm/c.tags.scm +9 -0
  31. package/wasm/c.wasm +0 -0
  32. package/wasm/cpp.tags.scm +15 -0
  33. package/wasm/cpp.wasm +0 -0
  34. package/wasm/elixir.tags.scm +54 -0
  35. package/wasm/elixir.wasm +0 -0
  36. package/wasm/go.tags.scm +42 -0
  37. package/wasm/go.wasm +0 -0
  38. package/wasm/groovy.tags.scm +15 -0
  39. package/wasm/groovy.wasm +0 -0
  40. package/wasm/java.tags.scm +20 -0
  41. package/wasm/java.wasm +0 -0
  42. package/wasm/javascript.tags.scm +99 -0
  43. package/wasm/javascript.wasm +0 -0
  44. package/wasm/php.tags.scm +40 -0
  45. package/wasm/php.wasm +0 -0
  46. package/wasm/powershell.tags.scm +14 -0
  47. package/wasm/powershell.wasm +0 -0
  48. package/wasm/python.tags.scm +14 -0
  49. package/wasm/python.wasm +0 -0
  50. package/wasm/ruby.tags.scm +64 -0
  51. package/wasm/ruby.wasm +0 -0
  52. package/wasm/rust.tags.scm +60 -0
  53. package/wasm/rust.wasm +0 -0
  54. package/wasm/scala.tags.scm +66 -0
  55. package/wasm/scala.wasm +0 -0
  56. package/wasm/solidity.tags.scm +43 -0
  57. package/wasm/solidity.wasm +0 -0
  58. package/wasm/tsx.wasm +0 -0
  59. package/wasm/typescript.tags.scm +23 -0
  60. package/wasm/typescript.wasm +0 -0
@@ -0,0 +1,661 @@
1
+ /**
2
+ * The AST chunker: source text in, named line-ranges out. No filesystem, no
3
+ * logging of file contents, one pure entry point (`chunkWithAst`).
4
+ *
5
+ * WHY THIS EXISTS. The heuristic chunker matches a regex whose name pattern
6
+ * requires a leading `function`/`class`/`const` keyword — a keyword a method's
7
+ * first line never has. So it produces method-shaped chunks with no
8
+ * `symbolName`: 1,512 methods in this repo, unnameable and therefore unusable
9
+ * as citation anchors. Measured over the same 1,037 TypeScript files, the
10
+ * heuristic named 16.9% of its 16,604 chunks; the tag queries name 100% of
11
+ * 6,471 definitions.
12
+ *
13
+ * THE NESTING RULE. Emitting both a whole class and each of its methods would
14
+ * duplicate every method body and double the embedding bill, so:
15
+ *
16
+ * - a STRUCTURAL definition — one whose body is a container of other definitions
17
+ * (class, interface, enum, struct, trait, object, module, namespace) — emits a
18
+ * HEADER chunk from its own first line to the line before its first nested
19
+ * definition: the declaration plus the fields that precede the first method.
20
+ * Real contiguous source, never an elision;
21
+ * - every other definition is a LEAF and becomes one chunk over its FULL line
22
+ * range, whatever it happens to contain. A local arrow function is part of
23
+ * what the enclosing function IS, not a sibling of it, so definitions nested
24
+ * inside a leaf are not emitted separately — the leaf's chunk already carries
25
+ * their source verbatim. See `STRUCTURAL_KINDS` for why that distinction is
26
+ * load-bearing rather than cosmetic;
27
+ * - every remaining uncovered line span becomes a `raw` chunk.
28
+ *
29
+ * Coverage is therefore total: every non-blank line of a file belongs to some
30
+ * chunk, replacing the ~50% coverage of the old heuristic and giving citation
31
+ * anchoring a line -> chunk map with no holes.
32
+ *
33
+ * A NEWLINE IS A TERMINATOR, NOT A LINE. `'...}\n'.split('\n')` yields a phantom
34
+ * trailing `''` that is not a line of the file. It is dropped before any length
35
+ * arithmetic, because `memory_file_index.end_line` feeds citation anchoring and
36
+ * the golden eval's containment scoring, and a one-line drift misaligns both
37
+ * silently.
38
+ *
39
+ * CHUNKS ARE LINE-GRANULAR, NOT BYTE-GRANULAR. A chunk's content is always
40
+ * `lines.slice(startLine - 1, endLine).join('\n')`. That is deliberate: the tag
41
+ * queries capture `class_declaration`, which starts at `class` and excludes the
42
+ * `export ` in front of it, and a citation anchor that omitted `export` would
43
+ * not match what a reader sees. Line granularity also keeps content verbatim
44
+ * and contiguous by construction.
45
+ *
46
+ * SYMBOL NAMES ARE BARE. `verifyForFile`, not `CitationManager.verifyForFile`.
47
+ * `memory_file_index.symbol_name` is one column with a partial index on
48
+ * `(project_id, symbol_name)` and citations reference bare names; storing
49
+ * qualified names would break lookup. Qualified names are a deliberate
50
+ * deferral — see the plan's Task 11 gate.
51
+ *
52
+ * WASM MEMORY IS NOT GC'd BY THE JS HEAP. Every `Tree` and `TreeCursor` created
53
+ * here is explicitly deleted in a `finally`. Skipping that leaks across a
54
+ * whole-repo walk in a long-lived daemon (the scanner is one).
55
+ */
56
+
57
+ import type { Node, Query, Tree } from 'web-tree-sitter';
58
+ import { Parser } from 'web-tree-sitter';
59
+ import { loadGrammar, type AstLanguageId, type GrammarBinding } from './grammars.js';
60
+
61
+ export interface AstChunk {
62
+ /** Verbatim contiguous source: exactly the lines `startLine..endLine`. */
63
+ content: string;
64
+ /** 1-based, inclusive. */
65
+ startLine: number;
66
+ /** 1-based, inclusive. */
67
+ endLine: number;
68
+ /** The existing wire enum. The finer classification lives in `symbolKind`. */
69
+ chunkType: 'function' | 'class' | 'module' | 'raw';
70
+ /**
71
+ * Bare symbol name, never qualified. Present on every chunk that came from a
72
+ * `definition.*` capture — INCLUDING the value-shaped kinds that map to
73
+ * `chunkType: 'raw'` (`variable`, `constant`, `field`, `property`), because the
74
+ * wire enum has no value-shaped member but the identity is real and
75
+ * `memory_file_index.symbol_name` indexes it either way. Absent only on the
76
+ * uncovered-span chunks, which have no capture behind them — those are exactly
77
+ * the chunks with no `symbolKind` either.
78
+ */
79
+ symbolName?: string;
80
+ /** The tag capture verbatim: 'function' | 'class' | 'method' | 'module' | ... */
81
+ symbolKind?: string;
82
+ /** The definition's first line, trimmed, capped at 500 chars. */
83
+ signature?: string;
84
+ }
85
+
86
+ export type AstChunkResult =
87
+ | {
88
+ chunks: AstChunk[];
89
+ errorNodes: number;
90
+ parseMs: number;
91
+ /**
92
+ * Definitions that reached no chunk. Two chunks over one line range would
93
+ * collide on `filePath:startLine-endLine`, the staleness key ProjectManager
94
+ * compares, so a second definition sharing a range — `function a() {}
95
+ * function b() {}` on one line — cannot be emitted, and neither can a
96
+ * container whose first nested definition opens on the container's own
97
+ * first line (`class A { m() {} }`). Two captures can also COLLAPSE onto one
98
+ * range while widening: `int f(int), g(int);` binds two declarators to one
99
+ * declaration (see `CollectedDefinitions.collapsedDefinitions`). All three
100
+ * are counted here. The name is then absent from
101
+ * `memory_file_index` and a citation to it resolves as
102
+ * `unresolved_symbol`; counting it is what stops that being invisible.
103
+ */
104
+ droppedDefinitions: number;
105
+ }
106
+ | { unsupported: 'grammar_unavailable' | 'parse_failed' | 'file_too_large'; detail: string };
107
+
108
+ /**
109
+ * Parse ceiling, derived from Task 4's measurement rather than guessed: the
110
+ * largest source file in this repo is 415 KB (`src/agent/ContextAgent.ts`) and
111
+ * the slowest per-file parse across 1,061 files was 108.6 ms. 2 MiB is ~5x that
112
+ * largest real file, which bounds the worst case near half a second — while
113
+ * still refusing the multi-megabyte generated blobs that `MAX_SCANNED_FILE_BYTES`
114
+ * (10 MB) otherwise lets through. Above the ceiling the caller degrades to the
115
+ * heuristic chunker, which is line-bounded and cheap.
116
+ */
117
+ export const MAX_AST_PARSE_BYTES = 2 * 1024 * 1024;
118
+
119
+ /**
120
+ * Line budget for a single `raw` span chunk, mirroring `ScanOptions.maxChunkSize`
121
+ * (8000 chars, ~2000 tokens) so AST raw spans and heuristic raw chunks land in
122
+ * the same size class.
123
+ */
124
+ const MAX_RAW_SPAN_CHARS = 8000;
125
+
126
+ const MAX_SIGNATURE_CHARS = 500;
127
+
128
+ /**
129
+ * `definition.<kind>` -> the wire enum. `chunkType` is constrained to
130
+ * `function|class|module|raw` by `CodeChunk` and by `scanChunkSchema`;
131
+ * `symbolKind` is not, and keeps the capture name verbatim.
132
+ *
133
+ * Kinds absent here (`constant`, `field`, `property`, `variable`) fall to
134
+ * `'raw'`: the enum has no value-shaped member, and claiming a constant is a
135
+ * `class` or a `module` would be false where `'raw'` only says "no coarse class
136
+ * for this" — the identity is still carried by `symbolName` + `symbolKind`.
137
+ */
138
+ const CHUNK_TYPE_BY_KIND: Readonly<Record<string, AstChunk['chunkType']>> = {
139
+ function: 'function',
140
+ method: 'function',
141
+ macro: 'function',
142
+ class: 'class',
143
+ interface: 'class',
144
+ enum: 'class',
145
+ object: 'class',
146
+ struct: 'class',
147
+ trait: 'class',
148
+ type: 'class',
149
+ module: 'module',
150
+ namespace: 'module',
151
+ };
152
+
153
+ /**
154
+ * Kinds whose BODY IS A CONTAINER of other definitions. Only these split into a
155
+ * header chunk; every other kind is a LEAF and gets its full line range.
156
+ *
157
+ * The distinction is load-bearing, not cosmetic. When every `definition.*`
158
+ * capture counted as a nested child, a function declaring one local arrow
159
+ * function collapsed to its first two lines: `ContextAgent.registerDocsTools`
160
+ * spans 513 lines and was recorded as 2. A wrong extent is worse than no extent —
161
+ * it anchors citations confidently at the wrong place instead of failing to
162
+ * anchor them.
163
+ *
164
+ * A closure, a variable, a constant, a field and a property are VALUE-SHAPED:
165
+ * they are part of what the enclosing definition IS. So is a nested helper
166
+ * function. Definitions inside a leaf are therefore not emitted at all; the
167
+ * leaf's own chunk carries their source verbatim, and emitting both would
168
+ * duplicate it and double the embedding bill.
169
+ *
170
+ * `type` stays out deliberately. C's `(type_definition declarator:
171
+ * (type_identifier) @name) @definition.type` matches `typedef struct {...} Foo;`,
172
+ * whose nested `struct_specifier` opens on the typedef's own first line — as a
173
+ * container it would split to an empty header and be dropped entirely, so the
174
+ * typedef is the leaf and keeps the whole range.
175
+ */
176
+ const STRUCTURAL_KINDS: ReadonlySet<string> = new Set([
177
+ 'class',
178
+ 'interface',
179
+ 'enum',
180
+ 'struct',
181
+ 'trait',
182
+ 'object',
183
+ 'module',
184
+ 'namespace',
185
+ ]);
186
+
187
+ /**
188
+ * One `Parser` per language for the life of the process. Constructing a parser
189
+ * per file allocates and frees a wasm parser object 30,000 times on a repo walk
190
+ * for no benefit; the parse itself is synchronous and stateless once the
191
+ * language is set.
192
+ */
193
+ const parsers = new Map<AstLanguageId, Parser>();
194
+
195
+ function parserFor(binding: GrammarBinding): Parser {
196
+ const cached = parsers.get(binding.id);
197
+ if (cached) return cached;
198
+ // `Parser.init()` has already run: `loadGrammar` awaits it before
199
+ // `Language.load`, and we only get a binding after that succeeded.
200
+ const parser = new Parser();
201
+ parser.setLanguage(binding.language);
202
+ parsers.set(binding.id, parser);
203
+ return parser;
204
+ }
205
+
206
+ /** A definition capture: the node's extent plus its paired `@name`. */
207
+ interface Definition {
208
+ startIndex: number;
209
+ endIndex: number;
210
+ /** 0-based tree-sitter rows, converted to 1-based lines exactly once, at emit. */
211
+ startRow: number;
212
+ endRow: number;
213
+ kind: string;
214
+ name?: string;
215
+ }
216
+
217
+ /**
218
+ * Chunk `content` by syntax for a language with a vendored grammar.
219
+ *
220
+ * Async only because `loadGrammar` is; the grammar is cached per process, so
221
+ * after the first file of a given language there is no per-file async cost.
222
+ * Never throws — every failure is a named `unsupported` reason with a detail the
223
+ * caller can log before degrading.
224
+ */
225
+ export async function chunkWithAst(
226
+ content: string,
227
+ languageId: AstLanguageId,
228
+ opts?: { maxParseBytes?: number }
229
+ ): Promise<AstChunkResult> {
230
+ // Size first: refusing a 6 MB generated file must not first instantiate a
231
+ // 5 MB grammar we then throw away.
232
+ const ceiling = opts?.maxParseBytes ?? MAX_AST_PARSE_BYTES;
233
+ const bytes = Buffer.byteLength(content, 'utf8');
234
+ if (bytes > ceiling) {
235
+ return {
236
+ unsupported: 'file_too_large',
237
+ detail: `${bytes} bytes exceeds the AST parse ceiling of ${ceiling} bytes`,
238
+ };
239
+ }
240
+
241
+ const loaded = await loadGrammar(languageId);
242
+ if ('unavailable' in loaded) {
243
+ return {
244
+ unsupported: 'grammar_unavailable',
245
+ detail: `${String(languageId)}: ${loaded.unavailable}: ${loaded.detail}`,
246
+ };
247
+ }
248
+
249
+ const startedAt = performance.now();
250
+ let tree: Tree | null;
251
+ try {
252
+ tree = parserFor(loaded.ok).parse(content);
253
+ } catch (err) {
254
+ return {
255
+ unsupported: 'parse_failed',
256
+ detail: `${languageId}: web-tree-sitter threw while parsing ${bytes} bytes: ${describe(err)}`,
257
+ };
258
+ }
259
+ if (!tree) {
260
+ // Documented return value of `Parser.parse`, not a theoretical branch: it
261
+ // is null when the parse is cancelled or the language is unset.
262
+ return {
263
+ unsupported: 'parse_failed',
264
+ detail: `${languageId}: web-tree-sitter returned no tree for ${bytes} bytes`,
265
+ };
266
+ }
267
+ const parseMs = performance.now() - startedAt;
268
+
269
+ try {
270
+ const lines = content.split('\n');
271
+ // A trailing newline TERMINATES the last line, it does not open a new one,
272
+ // but `split` yields a phantom '' for it. Dropping it here — before
273
+ // `lines.length` is read by the coverage array, `uncoveredSpans` or
274
+ // `sliceLines` — is what keeps `endLine` equal to the file's last line
275
+ // number. Left in, the final raw chunk of every newline-terminated file (109
276
+ // of this repo's 268 `src/` TypeScript files) ran one line past the end, and
277
+ // `memory_file_index.end_line` feeds citation anchoring and the golden eval's
278
+ // containment scoring.
279
+ if (content.endsWith('\n')) lines.pop();
280
+ const collected = collectDefinitions(loaded.ok.query, tree.rootNode);
281
+ const built = buildChunks(collected.definitions, lines);
282
+ return {
283
+ chunks: built.chunks,
284
+ errorNodes: countErrorNodes(tree),
285
+ parseMs,
286
+ // A definition can be lost at either stage — collapsed onto another
287
+ // definition's byte range here, or reaching no chunk in `buildChunks` — and
288
+ // the caller logs one number, so both have to be in it.
289
+ droppedDefinitions: built.droppedDefinitions + collected.collapsedDefinitions,
290
+ };
291
+ } finally {
292
+ tree.delete();
293
+ }
294
+ }
295
+
296
+ interface CollectedDefinitions {
297
+ definitions: Definition[];
298
+ /**
299
+ * Definitions lost to the byte-range dedup below, counted only when the two
300
+ * captures carried DIFFERENT names. Widening COLLAPSES: `int f(int), g(int);`
301
+ * binds two `function_declarator` siblings to one `declaration`, so both widen
302
+ * onto the same node and the second is discarded here — upstream of
303
+ * `buildChunks`, where every other drop is counted. Left uncounted, widening
304
+ * turned a COUNTED drop into a silent one, which is the exact defect class
305
+ * `droppedDefinitions` exists to eliminate.
306
+ *
307
+ * The name comparison is what keeps ordinary dedup silent: one node matched by
308
+ * two patterns of the composed chain (`cpp` composes `c.tags.scm` too, so every
309
+ * C-shaped function matches twice) yields the same name and loses nothing.
310
+ */
311
+ collapsedDefinitions: number;
312
+ }
313
+
314
+ /**
315
+ * Every `definition.*` capture with its paired `@name`, outermost first.
316
+ *
317
+ * `matches()` rather than `captures()`: a match is one pattern's captures, so
318
+ * the `@name` belonging to a definition is unambiguous. `captures()` returns a
319
+ * flat stream in which pairing is guesswork — and the composed QUERY_CHAIN
320
+ * concatenates several files' patterns, so guessing would mispair across
321
+ * grammars.
322
+ */
323
+ function collectDefinitions(query: Query, root: Node): CollectedDefinitions {
324
+ // Keyed by byte range: the composed chain can match one node from two
325
+ // patterns (javascript's `class` and `class_declaration` alternates, for
326
+ // instance), and two chunks over one range would collide on
327
+ // `filePath:startLine-endLine`, the key ProjectManager uses for staleness.
328
+ const byRange = new Map<string, Definition>();
329
+ let collapsedDefinitions = 0;
330
+
331
+ for (const match of query.matches(root)) {
332
+ let definition: Node | undefined;
333
+ let kind = '';
334
+ let name: Node | undefined;
335
+ for (const capture of match.captures) {
336
+ if (!definition && capture.name.startsWith('definition.')) {
337
+ definition = capture.node;
338
+ kind = capture.name.slice('definition.'.length);
339
+ } else if (!name && capture.name === 'name') {
340
+ name = capture.node;
341
+ }
342
+ }
343
+ if (!definition) continue;
344
+
345
+ const extent = widenToDefinition(definition);
346
+ const key = `${extent.startIndex}:${extent.endIndex}`;
347
+
348
+ // A `@name` from outside the definition's extent would be a query bug;
349
+ // dropping it costs a name, keeping it would attach a wrong one.
350
+ const named =
351
+ name && name.startIndex >= extent.startIndex && name.endIndex <= extent.endIndex
352
+ ? name.text
353
+ : undefined;
354
+
355
+ const existing = byRange.get(key);
356
+ if (existing) {
357
+ // Two names over one range means a real symbol is gone from
358
+ // `memory_file_index`, and a citation to it will resolve as
359
+ // `unresolved_symbol`; the same name twice means the same symbol twice.
360
+ if (named !== undefined && existing.name !== undefined && existing.name !== named) {
361
+ collapsedDefinitions++;
362
+ }
363
+ continue;
364
+ }
365
+
366
+ byRange.set(key, {
367
+ startIndex: extent.startIndex,
368
+ endIndex: extent.endIndex,
369
+ startRow: extent.startPosition.row,
370
+ endRow: extent.endPosition.row,
371
+ kind,
372
+ ...(named ? { name: named } : {}),
373
+ });
374
+ }
375
+
376
+ // Start ascending, end descending: a parent therefore always precedes the
377
+ // children it contains, which is what makes the single-pass stack below
378
+ // correct.
379
+ const definitions = [...byRange.values()].sort((a, b) =>
380
+ a.startIndex === b.startIndex ? b.endIndex - a.endIndex : a.startIndex - b.startIndex
381
+ );
382
+ return { definitions, collapsedDefinitions };
383
+ }
384
+
385
+ /**
386
+ * Ascend from a `@definition.*` capture to the node that actually SPANS the
387
+ * definition.
388
+ *
389
+ * Some tag queries bind the capture to a node that names the definition without
390
+ * containing its body. `c.tags.scm` and `cpp.tags.scm` are the loud case:
391
+ *
392
+ * (function_declarator declarator: (identifier) @name) @definition.function
393
+ *
394
+ * `function_declarator` ends at the closing paren of the parameter list, so
395
+ * naming it alone reproduces — for two whole languages — the exact defect this
396
+ * module exists to fix: a named signature line plus an anonymous raw chunk
397
+ * holding the body.
398
+ *
399
+ * The rule is general rather than a C special case, and it takes TWO forms
400
+ * because C and C++ nest declarators inside other declarators:
401
+ *
402
+ * 1. THE `declarator` FIELD. Ascend while the node is bound to its parent's
403
+ * `declarator` field. That field is the grammar's own statement of "this node
404
+ * is the declarator OF something larger", so the ascent lands on the node that
405
+ * owns the body (`function_definition`, `declaration`, `field_declaration`,
406
+ * through any `pointer_declarator` or `array_declarator` in between).
407
+ * 2. A DECLARATOR WRAPPER. `int &f()` parses as
408
+ * `(function_definition declarator: (reference_declarator (function_declarator …)))`
409
+ * and `int (*f(void))[4]` as `… (parenthesized_declarator (pointer_declarator …))`.
410
+ * Neither `reference_declarator` nor `parenthesized_declarator` NAMES the
411
+ * declarator it wraps — the child sits in an unnamed field — so rule 1 alone
412
+ * stopped dead at `function_declarator` and reproduced the very defect above
413
+ * for `T &operator[]`, `std::string &name()`, and every other
414
+ * reference-returning accessor in real C++. So also ascend when the parent is
415
+ * itself a declarator that holds this declarator in an UNNAMED field.
416
+ *
417
+ * Both forms keep the property that made rule 1 safe: neither can reach an
418
+ * unrelated ancestor. Rule 1 follows a field the grammar defines only within a
419
+ * declaration; rule 2 requires BOTH ends to be declarator nodes, and a
420
+ * `field_declaration_list` is not one — a method inside a class body therefore
421
+ * still stops at its own `function_definition` and a class is never swallowed.
422
+ * Grammars that already bind the whole definition (every JS/TS, Python, Go, Rust,
423
+ * Ruby pattern) fail both conditions on the first iteration, which makes this a
424
+ * no-op for them: TypeScript's `variable_declarator` sits unnamed in a
425
+ * `lexical_declaration`, which is not a declarator, so rule 2 does not fire and
426
+ * `const a = 1; const b = () => 2;` stays two statements.
427
+ */
428
+ function widenToDefinition(node: Node): Node {
429
+ let current = node;
430
+ for (;;) {
431
+ const parent = current.parent;
432
+ if (!parent) return current;
433
+ // `childrenForFieldName`, not `childForFieldName`: `int a, f(int x);` binds
434
+ // two declarators to one declaration, and the singular accessor answers only
435
+ // the first — which would refuse to widen every declarator but one.
436
+ const boundToDeclaratorField = parent
437
+ .childrenForFieldName('declarator')
438
+ .some((child) => child.id === current.id);
439
+ if (!boundToDeclaratorField && !wrapsDeclarator(parent, current)) return current;
440
+ current = parent;
441
+ }
442
+ }
443
+
444
+ /** A declarator node type, by the grammars' own naming convention. */
445
+ function isDeclarator(node: Node): boolean {
446
+ return node.type.endsWith('_declarator');
447
+ }
448
+
449
+ /**
450
+ * True when `parent` is a declarator that wraps `child` without naming the field
451
+ * — `reference_declarator` (`&` / `&&`) and `parenthesized_declarator` (`( … )`),
452
+ * whose grammar rules are a bare token sequence around the inner declarator.
453
+ *
454
+ * Both ends must be declarators, and the field must be UNNAMED: a declarator's
455
+ * named fields hold things that are NOT the wrapped declarator (`parameters`,
456
+ * `size`, `value`), and ascending out of one of those would leave the
457
+ * declaration the capture belongs to.
458
+ */
459
+ function wrapsDeclarator(parent: Node, child: Node): boolean {
460
+ if (!isDeclarator(parent) || !isDeclarator(child)) return false;
461
+ return fieldNameForChild(parent, child) === null;
462
+ }
463
+
464
+ /**
465
+ * The field name binding `child` to `parent`, or null when the child sits in an
466
+ * unnamed field. `Node` exposes no direct accessor, so the index has to be found
467
+ * first; declarator nodes have a handful of children, so the scan is trivial.
468
+ */
469
+ function fieldNameForChild(parent: Node, child: Node): string | null {
470
+ for (let i = 0; i < parent.childCount; i++) {
471
+ if (parent.child(i)?.id === child.id) return parent.fieldNameForChild(i);
472
+ }
473
+ return null;
474
+ }
475
+
476
+ interface BuiltChunks {
477
+ chunks: AstChunk[];
478
+ droppedDefinitions: number;
479
+ }
480
+
481
+ function buildChunks(definitions: Definition[], lines: string[]): BuiltChunks {
482
+ // `headerEndRow[i]` is the 0-based row of definition `i`'s first nested
483
+ // definition — set only for STRUCTURAL definitions, since only they split.
484
+ // `suppressed[i]` marks a definition that lies inside a leaf and is therefore
485
+ // already carried, verbatim, by that leaf's own chunk.
486
+ const headerEndRow = new Map<number, number>();
487
+ const suppressed = new Uint8Array(definitions.length);
488
+ const open: number[] = [];
489
+ for (let i = 0; i < definitions.length; i++) {
490
+ const current = definitions[i];
491
+ if (!current) continue;
492
+ while (open.length > 0) {
493
+ const top = definitions[open[open.length - 1] ?? -1];
494
+ if (top && top.endIndex > current.startIndex) break;
495
+ open.pop();
496
+ }
497
+ const parentIndex = open[open.length - 1];
498
+ const parent = parentIndex === undefined ? undefined : definitions[parentIndex];
499
+ if (parentIndex !== undefined && parent) {
500
+ if (suppressed[parentIndex] === 1 || !STRUCTURAL_KINDS.has(parent.kind)) {
501
+ // Inside a leaf (or inside something already inside one). Suppression
502
+ // propagates: a class declared inside a function belongs to that
503
+ // function's chunk, and so do the class's own methods.
504
+ suppressed[i] = 1;
505
+ } else if (!headerEndRow.has(parentIndex)) {
506
+ headerEndRow.set(parentIndex, current.startRow);
507
+ }
508
+ }
509
+ open.push(i);
510
+ }
511
+
512
+ const chunks: AstChunk[] = [];
513
+ const emittedRanges = new Set<string>();
514
+ const covered = new Uint8Array(lines.length + 2);
515
+ let droppedDefinitions = 0;
516
+
517
+ /** True when the chunk was emitted; false when its range cannot carry one. */
518
+ const emit = (chunk: AstChunk): boolean => {
519
+ if (chunk.endLine < chunk.startLine) return false;
520
+ const key = `${chunk.startLine}:${chunk.endLine}`;
521
+ if (emittedRanges.has(key)) return false;
522
+ if (chunk.content.trim() === '') return false;
523
+ emittedRanges.add(key);
524
+ chunks.push(chunk);
525
+ for (let line = chunk.startLine; line <= chunk.endLine; line++) covered[line] = 1;
526
+ return true;
527
+ };
528
+
529
+ for (let i = 0; i < definitions.length; i++) {
530
+ const definition = definitions[i];
531
+ if (!definition || suppressed[i] === 1) continue;
532
+
533
+ // THE ONE 0-based -> 1-based conversion. `Point.row` is 0-based; startLine
534
+ // and endLine are 1-based inclusive. A structural definition's first child
535
+ // starts on `headerEnd + 1`, so its header ends on the line before that:
536
+ // `headerEnd`. When the child opens on the container's own first line
537
+ // (`class A { m() {} }`) that range is empty, `emit` refuses it and the
538
+ // refusal is counted — emitting the line anyway would duplicate the child's
539
+ // source. Clamped to the last real line so a definition whose node runs to
540
+ // end-of-file cannot name a line that does not exist.
541
+ const headerEnd = headerEndRow.get(i);
542
+ const startLine = definition.startRow + 1;
543
+ const endLine = Math.min(headerEnd ?? definition.endRow + 1, lines.length);
544
+ const content = sliceLines(lines, startLine, endLine);
545
+ const signature = (content.split('\n')[0] ?? '').trim().slice(0, MAX_SIGNATURE_CHARS);
546
+
547
+ const emitted = emit({
548
+ content,
549
+ startLine,
550
+ endLine,
551
+ chunkType: CHUNK_TYPE_BY_KIND[definition.kind] ?? 'raw',
552
+ ...(definition.name ? { symbolName: definition.name } : {}),
553
+ symbolKind: definition.kind,
554
+ ...(signature ? { signature } : {}),
555
+ });
556
+ // A definition that reaches no chunk reaches no index either, and a citation
557
+ // to it resolves as `unresolved_symbol`. It cannot be emitted without
558
+ // colliding on the staleness key, so it is counted and reported instead of
559
+ // vanishing — a silent no-op is the defect class, not the collision.
560
+ if (!emitted) droppedDefinitions++;
561
+ }
562
+
563
+ for (const span of uncoveredSpans(covered, lines.length)) {
564
+ for (const [startLine, endLine] of splitSpan(lines, span)) {
565
+ // Uncovered spans are not counted when refused: a whitespace-only span is
566
+ // owed no chunk, which is the documented contract of `splitSpan`.
567
+ emit({
568
+ content: sliceLines(lines, startLine, endLine),
569
+ startLine,
570
+ endLine,
571
+ chunkType: 'raw',
572
+ });
573
+ }
574
+ }
575
+
576
+ return {
577
+ chunks: chunks.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine),
578
+ droppedDefinitions,
579
+ };
580
+ }
581
+
582
+ const sliceLines = (lines: string[], startLine: number, endLine: number): string =>
583
+ lines.slice(startLine - 1, endLine).join('\n');
584
+
585
+ /** Maximal runs of lines no definition chunk claimed, as 1-based inclusive pairs. */
586
+ function uncoveredSpans(covered: Uint8Array, lineCount: number): Array<[number, number]> {
587
+ const spans: Array<[number, number]> = [];
588
+ let start: number | null = null;
589
+ for (let line = 1; line <= lineCount; line++) {
590
+ if (covered[line] === 1) {
591
+ if (start !== null) spans.push([start, line - 1]);
592
+ start = null;
593
+ } else if (start === null) {
594
+ start = line;
595
+ }
596
+ }
597
+ if (start !== null) spans.push([start, lineCount]);
598
+ return spans;
599
+ }
600
+
601
+ /**
602
+ * Break an uncovered span on line boundaries so no chunk exceeds
603
+ * `MAX_RAW_SPAN_CHARS`. No overlap — overlap would re-embed the same lines — and
604
+ * no mid-line splitting: two chunks over one line range would collide on the
605
+ * `filePath:startLine-endLine` staleness key, so an over-long single line is
606
+ * left whole and the embedding layer truncates it with a warning
607
+ * (`EmbeddingService` caps each input at the 8191-token per-input limit).
608
+ *
609
+ * Whitespace-only spans yield nothing — `emit` drops them, and the blank lines
610
+ * between definitions are the one thing total coverage does not owe a chunk.
611
+ */
612
+ function splitSpan(
613
+ lines: string[],
614
+ [spanStart, spanEnd]: [number, number]
615
+ ): Array<[number, number]> {
616
+ const pieces: Array<[number, number]> = [];
617
+ let start = spanStart;
618
+ let length = 0;
619
+ for (let line = spanStart; line <= spanEnd; line++) {
620
+ const lineLength = (lines[line - 1] ?? '').length + 1;
621
+ if (line > start && length + lineLength > MAX_RAW_SPAN_CHARS) {
622
+ pieces.push([start, line - 1]);
623
+ start = line;
624
+ length = 0;
625
+ }
626
+ length += lineLength;
627
+ }
628
+ pieces.push([start, spanEnd]);
629
+ return pieces;
630
+ }
631
+
632
+ /**
633
+ * ERROR and MISSING nodes, so a caller can see that a file parsed badly instead
634
+ * of inferring it from thin chunks.
635
+ *
636
+ * Short-circuits on a clean tree, and descends only into subtrees whose own
637
+ * `hasError` is true — 97.6% of this repo's TypeScript files have no error at
638
+ * all, and they must not pay for a full tree walk.
639
+ */
640
+ function countErrorNodes(tree: Tree): number {
641
+ if (!tree.rootNode.hasError) return 0;
642
+
643
+ const cursor = tree.walk();
644
+ let count = 0;
645
+ try {
646
+ for (;;) {
647
+ const node = cursor.currentNode;
648
+ if (node.isError || node.isMissing) count++;
649
+ if (node.hasError && cursor.gotoFirstChild()) continue;
650
+ for (;;) {
651
+ if (cursor.gotoNextSibling()) break;
652
+ if (!cursor.gotoParent()) return count;
653
+ }
654
+ }
655
+ } finally {
656
+ cursor.delete();
657
+ }
658
+ }
659
+
660
+ const describe = (err: unknown): string =>
661
+ err instanceof Error ? err.message.slice(0, 200) || err.constructor.name : String(err);