@mnemonik/shared 6.50.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 +59 -0
  10. package/dist/codeScanner.d.ts.map +1 -1
  11. package/dist/codeScanner.js +259 -2
  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 +273 -3
  25. package/src/index.ts +22 -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,490 @@
1
+ /**
2
+ * The grammar registry: one module owns the question "can we AST-parse this
3
+ * language on this machine, and if not, why not".
4
+ *
5
+ * Every failure mode is a named value (`wasm_missing`, `wasm_load_failed`,
6
+ * `tags_query_missing`, `query_compile_failed`, `not_covered`) rather than a
7
+ * null or a throw, because the consequence of a degraded install is not a crash
8
+ * — it is quietly worse chunks, forever, with nothing in the logs. A caller that
9
+ * gets `unavailable` can fall back to the heuristic chunker AND say why.
10
+ *
11
+ * Artifacts are vendored, not installed: `packages/shared/wasm/<id>.wasm` plus
12
+ * the `<id>.tags.scm` tag queries, committed and published inside
13
+ * `@mnemonik/shared` (see `scripts/vendor-grammars.ts` for how they get there
14
+ * and why `npm pack` rather than devDependencies). A user's install therefore
15
+ * pulls no grammar package, runs no postinstall, needs no compiler and fetches
16
+ * nothing at runtime.
17
+ *
18
+ * Two reports answer "what can this install do" and they are NOT
19
+ * interchangeable. `astArtifactReport` stats the vendored files and costs
20
+ * nothing; `astCapabilityReport` instantiates all 18 grammars, which costs ~690
21
+ * ms and ~75 MB of RSS that is never returned because web-tree-sitter exposes no
22
+ * `Language.delete`. Startup and per-file paths use the first; the second is for
23
+ * a diagnostic a human asked for. Grammars themselves load lazily, one per
24
+ * language, on the first file that needs one.
25
+ *
26
+ * Paths resolve relative to THIS MODULE, never the CWD: the scanner daemon runs
27
+ * from whatever directory the user happens to be in, so a CWD-relative path is
28
+ * the kind of bug that passes every test and fails in the field. `src/ast/` and
29
+ * `dist/ast/` sit at the same depth below the package root, so one relative
30
+ * expression is correct for both the TS sources and the shipped build.
31
+ */
32
+
33
+ import { existsSync, readFileSync, statSync } from 'node:fs';
34
+ import { dirname, extname, join } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { Language, Parser, Query } from 'web-tree-sitter';
37
+ import { warn } from '../logger.js';
38
+
39
+ const AST_LANGUAGE_ID_LIST = [
40
+ 'typescript',
41
+ 'tsx',
42
+ 'javascript',
43
+ 'python',
44
+ 'go',
45
+ 'java',
46
+ 'c',
47
+ 'cpp',
48
+ 'csharp',
49
+ 'ruby',
50
+ 'php',
51
+ 'rust',
52
+ 'scala',
53
+ 'elixir',
54
+ 'solidity',
55
+ 'bash',
56
+ 'groovy',
57
+ 'powershell',
58
+ ] as const;
59
+
60
+ /** A language this build can AST-parse. Exactly the vendored set, no aspirations. */
61
+ export type AstLanguageId = (typeof AST_LANGUAGE_ID_LIST)[number];
62
+
63
+ /** The vendored set as a runtime value, for capability reporting and tests. */
64
+ export const AST_LANGUAGE_IDS: readonly AstLanguageId[] = AST_LANGUAGE_ID_LIST;
65
+
66
+ /**
67
+ * Ids whose vendored artifact basename differs from the id.
68
+ *
69
+ * `tree-sitter-c-sharp` names its file with a hyphen while every consumer of
70
+ * this registry — `languageForExtension`, chunk metadata, the wire — spells the
71
+ * language `csharp`. Mapping the two explicitly is cheaper than renaming a
72
+ * vendored artifact: the vendoring script, the committed files and the
73
+ * `--check` verifier all agree on `c-sharp`, and a rename would make this
74
+ * module the odd one out for no gain.
75
+ */
76
+ const ARTIFACT_BASENAME: Partial<Record<AstLanguageId, string>> = {
77
+ csharp: 'c-sharp',
78
+ };
79
+
80
+ /** Vendored artifact basename for an id: `csharp` -> `c-sharp`, else identity. */
81
+ export function grammarArtifactBasename(id: AstLanguageId): string {
82
+ return ARTIFACT_BASENAME[id] ?? id;
83
+ }
84
+
85
+ /**
86
+ * Ordered tag-query files composing each grammar's EFFECTIVE query. THE source
87
+ * of truth — `scripts/vendor-grammars.ts` imports this rather than keeping its
88
+ * own copy, because two chains that can drift is precisely the defect this
89
+ * layer exists to remove.
90
+ *
91
+ * Tag queries are DELTAS, not complete definitions. tree-sitter-typescript's
92
+ * query holds only the TypeScript-specific patterns (`function_signature`,
93
+ * `interface_declaration`, `abstract_class_declaration`);
94
+ * `function_declaration`, `class_declaration`, `method_definition` and arrow
95
+ * functions all live in javascript's query and are meant to be inherited.
96
+ * Nothing in the query engine resolves that — composing the chain is the
97
+ * consumer's job.
98
+ *
99
+ * Measured cost of not composing it: 948 definitions across 1,037 TypeScript
100
+ * files in this repo, against 6,471 once javascript's query was prepended.
101
+ * `cpp` inherits `c` for the same reason.
102
+ *
103
+ * Listed explicitly rather than inferred, and typed as a complete record, so
104
+ * adding a language without deciding its chain is a compile error instead of a
105
+ * query that compiles and quietly under-matches.
106
+ */
107
+ export const QUERY_CHAIN: Readonly<Record<AstLanguageId, readonly AstLanguageId[]>> = {
108
+ typescript: ['javascript', 'typescript'],
109
+ tsx: ['javascript', 'typescript'],
110
+ javascript: ['javascript'],
111
+ python: ['python'],
112
+ go: ['go'],
113
+ java: ['java'],
114
+ c: ['c'],
115
+ cpp: ['c', 'cpp'],
116
+ csharp: ['csharp'],
117
+ ruby: ['ruby'],
118
+ php: ['php'],
119
+ rust: ['rust'],
120
+ scala: ['scala'],
121
+ elixir: ['elixir'],
122
+ solidity: ['solidity'],
123
+ bash: ['bash'],
124
+ groovy: ['groovy'],
125
+ powershell: ['powershell'],
126
+ };
127
+
128
+ /**
129
+ * Extensions whose grammar the language string alone gets WRONG.
130
+ *
131
+ * `.ts` and `.tsx` both report language `typescript` and need different
132
+ * grammars out of the same npm package — a `.tsx` file parsed by the typescript
133
+ * grammar errors on the first JSX element. This is the entire reason
134
+ * `resolveAstLanguage` takes an extension at all.
135
+ */
136
+ const EXTENSION_GRAMMARS: Readonly<Record<string, AstLanguageId>> = {
137
+ '.tsx': 'tsx',
138
+ };
139
+
140
+ /**
141
+ * Scanner language string -> grammar. Keyed on what `languageForExtension`
142
+ * actually returns, so this table and the extension table cannot disagree about
143
+ * what a `.zsh` file is.
144
+ *
145
+ * Absences are deliberate answers, not gaps. `markdown` is missing because
146
+ * headers already are a document's semantic unit and 10,483 production chunks
147
+ * depend on `chunkMarkdown` keeping them; `kotlin`, `swift`, `dart` and friends
148
+ * are missing because no loadable grammar is vendored yet. Both resolve to
149
+ * `null` and the heuristic chunker takes over.
150
+ */
151
+ const LANGUAGE_GRAMMARS: Readonly<Record<string, AstLanguageId>> = {
152
+ typescript: 'typescript',
153
+ javascript: 'javascript',
154
+ python: 'python',
155
+ go: 'go',
156
+ java: 'java',
157
+ c: 'c',
158
+ cpp: 'cpp',
159
+ csharp: 'csharp',
160
+ ruby: 'ruby',
161
+ php: 'php',
162
+ rust: 'rust',
163
+ scala: 'scala',
164
+ elixir: 'elixir',
165
+ solidity: 'solidity',
166
+ shell: 'bash',
167
+ groovy: 'groovy',
168
+ powershell: 'powershell',
169
+ };
170
+
171
+ /** A loaded grammar plus its compiled query. Compiled once, reused per file. */
172
+ export interface GrammarBinding {
173
+ id: AstLanguageId;
174
+ language: Language;
175
+ query: Query;
176
+ }
177
+
178
+ /** Why a grammar is not usable, always with a human-readable detail. */
179
+ export type GrammarUnavailableReason =
180
+ | 'not_covered'
181
+ | 'wasm_missing'
182
+ | 'wasm_load_failed'
183
+ | 'tags_query_missing'
184
+ | 'query_compile_failed';
185
+
186
+ export type GrammarLoad =
187
+ { ok: GrammarBinding } | { unavailable: GrammarUnavailableReason; detail: string };
188
+
189
+ const WASM_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'wasm');
190
+
191
+ /**
192
+ * The vendored artifact directory, resolved from THIS module.
193
+ *
194
+ * Exported because a test of a DEGRADED install has to break artifacts, and
195
+ * breaking the tracked ones is not an option: the unit project runs test files
196
+ * in parallel forks, so renaming `ruby.wasm` aside is visible to every other
197
+ * file for as long as it lasts, and any file that cold-loads Ruby inside that
198
+ * window caches a `wasm_missing` this checkout does not have. Copy from here
199
+ * into a temp directory and break the copy — `loadGrammar` takes the directory.
200
+ */
201
+ export const VENDORED_ARTIFACT_DIR = WASM_DIR;
202
+
203
+ /**
204
+ * Which grammar, if any, should parse a file with this extension and language.
205
+ *
206
+ * `null` means "chunk it heuristically" and is a legitimate answer for most of
207
+ * the allowlist. Callers pass the language string `languageForExtension`
208
+ * produced; the extension only decides cases where the language string is
209
+ * ambiguous (`.ts` vs `.tsx`).
210
+ */
211
+ export function resolveAstLanguage(extension: string, language: string): AstLanguageId | null {
212
+ const byExtension = EXTENSION_GRAMMARS[normalizeExtension(extension)];
213
+ if (byExtension) return byExtension;
214
+ return LANGUAGE_GRAMMARS[language.trim().toLowerCase()] ?? null;
215
+ }
216
+
217
+ /**
218
+ * Accepts `.TSX`, `tsx`, or a whole path, and answers the lowercase dotted
219
+ * extension. Mirrors `languageForExtension`'s ordering — `extname` first, so a
220
+ * dotfile carrying a real extension ('.eslintrc.js') is not swallowed whole.
221
+ */
222
+ function normalizeExtension(raw: string): string {
223
+ const lower = raw.trim().toLowerCase();
224
+ if (!lower) return '';
225
+ const fromPath = extname(lower);
226
+ if (fromPath) return fromPath;
227
+ return lower.startsWith('.') ? lower : `.${lower}`;
228
+ }
229
+
230
+ let parserInit: Promise<void> | null = null;
231
+
232
+ /**
233
+ * `Parser.init()` exactly once per process. web-tree-sitter instantiates its
234
+ * WebAssembly runtime here; calling it per file would dominate the scan.
235
+ */
236
+ function initParser(): Promise<void> {
237
+ parserInit ??= Parser.init();
238
+ return parserInit;
239
+ }
240
+
241
+ /**
242
+ * Successes AND failures, keyed by artifact directory + id. Caching the failures
243
+ * is the point: a checkout missing one `.wasm` must not stat-and-fail once per
244
+ * file for the whole repo walk, and must not log the same warning 30,000 times.
245
+ *
246
+ * The cached value is the in-flight promise, so two concurrent callers share
247
+ * one `Language.load` rather than instantiating the grammar twice.
248
+ */
249
+ const loads = new Map<string, Promise<GrammarLoad>>();
250
+
251
+ /**
252
+ * Ids whose `Language` has actually been instantiated, same keying as `loads`.
253
+ *
254
+ * A ledger rather than a cache index, because the entry can never be removed:
255
+ * a loaded Language cannot be freed (see `attemptLoad`), so this set IS the
256
+ * permanent wasm memory this process is holding. `loadedGrammarIds` reads it,
257
+ * and the startup path is asserted against it.
258
+ */
259
+ const instantiated = new Set<string>();
260
+
261
+ // NUL separator, escaped rather than literal: the directory half is an
262
+ // arbitrary path, so the separator has to be a byte a path cannot contain.
263
+ const cacheKey = (artifactDir: string, id: string): string => `${artifactDir}\0${id}`;
264
+
265
+ /**
266
+ * Load (or return the cached) grammar binding for `id`. Never throws.
267
+ *
268
+ * `artifactDir` defaults to the vendored directory and every production caller
269
+ * omits it. It exists so a degraded install can be exercised against a COPY of
270
+ * the artifacts (see `VENDORED_ARTIFACT_DIR`); the cache is keyed by directory,
271
+ * so a broken copy cannot poison the real grammar's cache entry.
272
+ */
273
+ export function loadGrammar(
274
+ id: AstLanguageId,
275
+ artifactDir: string = WASM_DIR
276
+ ): Promise<GrammarLoad> {
277
+ const key = cacheKey(artifactDir, id);
278
+ const cached = loads.get(key);
279
+ if (cached) return cached;
280
+ const pending = loadUncached(id, artifactDir);
281
+ loads.set(key, pending);
282
+ return pending;
283
+ }
284
+
285
+ /**
286
+ * Which grammars this process has instantiated — what is resident in wasm
287
+ * memory right now, not what is vendored or what was asked for.
288
+ *
289
+ * Exists because the cost is permanent and therefore worth being able to state:
290
+ * `astArtifactReport` reports it, and a test asserts the scanner's startup line
291
+ * leaves it empty.
292
+ */
293
+ export function loadedGrammarIds(artifactDir: string = WASM_DIR): AstLanguageId[] {
294
+ return AST_LANGUAGE_IDS.filter((id) => instantiated.has(cacheKey(artifactDir, id)));
295
+ }
296
+
297
+ async function loadUncached(id: AstLanguageId, artifactDir: string): Promise<GrammarLoad> {
298
+ const result = await attemptLoad(id, artifactDir);
299
+ if ('unavailable' in result) {
300
+ // Logged once per id thanks to the cache above. A degradation nobody can
301
+ // see in the logs is indistinguishable from working software.
302
+ warn('ast grammar unavailable', { id, reason: result.unavailable, detail: result.detail });
303
+ }
304
+ return result;
305
+ }
306
+
307
+ /**
308
+ * The on-disk half of a load: artifacts present, query chain composed. No wasm
309
+ * is instantiated, so this is what the cheap report and the real load SHARE —
310
+ * two separate presence checks that could disagree is how a report starts
311
+ * claiming a grammar the loader rejects.
312
+ */
313
+ type ArtifactResolution =
314
+ | { wasmPath: string; source: string }
315
+ | { unavailable: 'wasm_missing' | 'tags_query_missing'; detail: string };
316
+
317
+ function resolveArtifacts(id: AstLanguageId, artifactDir: string): ArtifactResolution {
318
+ const wasmPath = join(artifactDir, `${grammarArtifactBasename(id)}.wasm`);
319
+ if (!isNonEmptyFile(wasmPath)) {
320
+ return { unavailable: 'wasm_missing', detail: `missing or empty grammar artifact ${wasmPath}` };
321
+ }
322
+
323
+ // Composed BEFORE the grammar loads: reading three small files is cheaper
324
+ // than instantiating a megabyte of WebAssembly only to discard it.
325
+ let source = '';
326
+ for (const part of QUERY_CHAIN[id]) {
327
+ const tagsPath = join(artifactDir, `${grammarArtifactBasename(part)}.tags.scm`);
328
+ if (!isNonEmptyFile(tagsPath)) {
329
+ return {
330
+ unavailable: 'tags_query_missing',
331
+ detail: `${id} needs the query chain [${QUERY_CHAIN[id].join(', ')}] but ${tagsPath} is missing or empty`,
332
+ };
333
+ }
334
+ try {
335
+ source += `${readFileSync(tagsPath, 'utf8')}\n`;
336
+ } catch (err) {
337
+ return {
338
+ unavailable: 'tags_query_missing',
339
+ detail: `${id}: could not read ${tagsPath}: ${describe(err)}`,
340
+ };
341
+ }
342
+ }
343
+
344
+ return { wasmPath, source };
345
+ }
346
+
347
+ async function attemptLoad(id: AstLanguageId, artifactDir: string): Promise<GrammarLoad> {
348
+ if (!AST_LANGUAGE_IDS.includes(id)) {
349
+ return {
350
+ unavailable: 'not_covered',
351
+ detail: `${String(id)} is not a vendored grammar; expected one of ${AST_LANGUAGE_IDS.join(', ')}`,
352
+ };
353
+ }
354
+
355
+ const artifacts = resolveArtifacts(id, artifactDir);
356
+ if ('unavailable' in artifacts) return artifacts;
357
+ const { wasmPath, source } = artifacts;
358
+
359
+ let language: Language;
360
+ try {
361
+ await initParser();
362
+ language = await Language.load(wasmPath);
363
+ } catch (err) {
364
+ // Presence is not loadability: tree-sitter-dart ships a 741 KB .wasm that
365
+ // Language.load rejects because the artifact predates the current wasm ABI.
366
+ return {
367
+ unavailable: 'wasm_load_failed',
368
+ detail: `${wasmPath} exists but web-tree-sitter rejected it (ABI mismatch?): ${describe(err)}`,
369
+ };
370
+ }
371
+ instantiated.add(cacheKey(artifactDir, id));
372
+
373
+ // A loaded Language cannot be freed: web-tree-sitter 0.26.11 exposes
374
+ // `delete()` on Tree and Query but NOT on Language. The failure results below
375
+ // are cached, so a grammar whose query is broken leaks its Language exactly
376
+ // once per process rather than once per file — which is why the cache matters
377
+ // for more than latency. Trees are a different story and must be deleted per
378
+ // file by the chunker; the JS heap does not GC wasm memory.
379
+ let query: Query;
380
+ try {
381
+ query = new Query(language, source);
382
+ } catch (err) {
383
+ return {
384
+ unavailable: 'query_compile_failed',
385
+ detail: `${id} tag query [${QUERY_CHAIN[id].join(', ')}] failed to compile: ${describe(err)}`,
386
+ };
387
+ }
388
+
389
+ if (!query.captureNames.some((name) => name.startsWith('definition.'))) {
390
+ // Compiles, matches, names nothing: the chunker would emit only raw chunks
391
+ // and look like it was working. Unavailable is the honest answer.
392
+ query.delete();
393
+ return {
394
+ unavailable: 'query_compile_failed',
395
+ detail: `${id} tag query [${QUERY_CHAIN[id].join(', ')}] compiled but captures no definition.* — it would name nothing`,
396
+ };
397
+ }
398
+
399
+ return { ok: { id, language, query } };
400
+ }
401
+
402
+ const isNonEmptyFile = (path: string): boolean => existsSync(path) && statSync(path).size > 0;
403
+
404
+ const describe = (err: unknown): string =>
405
+ err instanceof Error ? err.message.slice(0, 200) || err.constructor.name : String(err);
406
+
407
+ /**
408
+ * What this install has VENDORED, answered without instantiating anything.
409
+ *
410
+ * Every check is a `statSync` plus a few hundred bytes of `.scm`: the grammar's
411
+ * `.wasm` is present and non-empty, and so is every tag-query file in its chain.
412
+ * Microseconds, and zero permanent memory — which is what makes it the right
413
+ * answer for a startup line (see `logAstCapabilityOnce`).
414
+ *
415
+ * It shares `resolveArtifacts` with the real loader, so it cannot report a
416
+ * grammar as vendored that `loadGrammar` would reject for a missing artifact.
417
+ * What it deliberately cannot see is the two failure modes that require the
418
+ * grammar in memory — `wasm_load_failed` and `query_compile_failed`. Those are
419
+ * reported by `astCapabilityReport` when a human asks, and warned once per
420
+ * language by the scan path on the first file that needs them
421
+ * (`grammar_unavailable` in codeScanner); paying ~75 MB up front to pre-answer
422
+ * the question for 18 languages a repo probably does not contain is the wrong
423
+ * trade.
424
+ *
425
+ * `loaded` is what is resident so far, which for a fresh process is nothing:
426
+ * grammars load lazily, per language, on the first file that needs one.
427
+ */
428
+ export function astArtifactReport(artifactDir: string = WASM_DIR): {
429
+ vendored: AstLanguageId[];
430
+ loaded: AstLanguageId[];
431
+ missing: Array<{
432
+ id: AstLanguageId;
433
+ reason: 'wasm_missing' | 'tags_query_missing';
434
+ detail: string;
435
+ }>;
436
+ } {
437
+ const vendored: AstLanguageId[] = [];
438
+ const missing: Array<{
439
+ id: AstLanguageId;
440
+ reason: 'wasm_missing' | 'tags_query_missing';
441
+ detail: string;
442
+ }> = [];
443
+
444
+ for (const id of AST_LANGUAGE_IDS) {
445
+ const artifacts = resolveArtifacts(id, artifactDir);
446
+ if ('unavailable' in artifacts) {
447
+ missing.push({ id, reason: artifacts.unavailable, detail: artifacts.detail });
448
+ } else {
449
+ vendored.push(id);
450
+ }
451
+ }
452
+
453
+ return { vendored, loaded: loadedGrammarIds(artifactDir), missing };
454
+ }
455
+
456
+ /**
457
+ * What this install can actually AST-PARSE, and why not for the rest. Loads
458
+ * every vendored grammar and compiles every query, so it catches the two things
459
+ * `astArtifactReport` cannot: an artifact that exists and does not load, and a
460
+ * query that does not compile or names no definitions.
461
+ *
462
+ * NOT free, and the cost is memory rather than only latency: measured on this
463
+ * checkout, ~690 ms and ~75 MB of RSS that is never given back. web-tree-sitter
464
+ * 0.26.11 exposes no `Language.delete` (see `attemptLoad`), so all 18 grammars
465
+ * stay resident for the life of the process — including the fifteen a given repo
466
+ * has no files for. Caching makes the second call free; it does not make the
467
+ * first one cheap.
468
+ *
469
+ * So: fine for a diagnostic a human asked for (`doctor`, a `--check` script,
470
+ * this suite). Wrong for a daemon's startup line — that is `astArtifactReport`.
471
+ */
472
+ export async function astCapabilityReport(artifactDir: string = WASM_DIR): Promise<{
473
+ available: AstLanguageId[];
474
+ unavailable: Array<{ id: AstLanguageId; reason: GrammarUnavailableReason; detail: string }>;
475
+ }> {
476
+ const available: AstLanguageId[] = [];
477
+ const unavailable: Array<{
478
+ id: AstLanguageId;
479
+ reason: GrammarUnavailableReason;
480
+ detail: string;
481
+ }> = [];
482
+
483
+ for (const id of AST_LANGUAGE_IDS) {
484
+ const loaded = await loadGrammar(id, artifactDir);
485
+ if ('ok' in loaded) available.push(id);
486
+ else unavailable.push({ id, reason: loaded.unavailable, detail: loaded.detail });
487
+ }
488
+
489
+ return { available, unavailable };
490
+ }