@massu/core 1.5.8 → 1.6.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.
@@ -0,0 +1,556 @@
1
+ // ../adapter-phoenix/dist/index.js
2
+ import { Parser as Parser2 } from "web-tree-sitter";
3
+
4
+ // src/detect/adapters/query-helpers.ts
5
+ import { Query } from "web-tree-sitter";
6
+ var InvalidQueryError = class extends Error {
7
+ queryName;
8
+ querySource;
9
+ cause;
10
+ constructor(queryName, querySource, cause) {
11
+ const causeMsg = cause instanceof Error ? cause.message : String(cause);
12
+ super(
13
+ `[query-helpers] Invalid Tree-sitter query "${queryName}": ${causeMsg}
14
+ Query source:
15
+ ${querySource}`
16
+ );
17
+ this.name = "InvalidQueryError";
18
+ this.queryName = queryName;
19
+ this.querySource = querySource;
20
+ this.cause = cause;
21
+ }
22
+ };
23
+ var queryCache = /* @__PURE__ */ new WeakMap();
24
+ function compileQuery(language, source, queryName) {
25
+ let perLang = queryCache.get(language);
26
+ if (!perLang) {
27
+ perLang = /* @__PURE__ */ new Map();
28
+ queryCache.set(language, perLang);
29
+ }
30
+ const cached = perLang.get(source);
31
+ if (cached) return cached;
32
+ let q;
33
+ try {
34
+ q = new Query(language, source);
35
+ } catch (e) {
36
+ throw new InvalidQueryError(queryName, source, e);
37
+ }
38
+ perLang.set(source, q);
39
+ return q;
40
+ }
41
+ function runQuery(parser, source, queryText, queryName, filePath) {
42
+ const language = parser.language;
43
+ if (!language) {
44
+ throw new InvalidQueryError(
45
+ queryName,
46
+ queryText,
47
+ new Error("Parser has no language assigned")
48
+ );
49
+ }
50
+ const query = compileQuery(language, queryText, queryName);
51
+ const tree = parser.parse(source);
52
+ if (!tree) return [];
53
+ let matches;
54
+ try {
55
+ matches = query.matches(tree.rootNode);
56
+ } catch (e) {
57
+ throw new InvalidQueryError(queryName, queryText, e);
58
+ }
59
+ const out = [];
60
+ for (const match of matches) {
61
+ if (!match.captures || match.captures.length === 0) continue;
62
+ const captures = {};
63
+ let earliestLine = Number.POSITIVE_INFINITY;
64
+ for (const cap of match.captures) {
65
+ const node = cap.node;
66
+ captures[cap.name] = node.text;
67
+ if (node.startPosition.row + 1 < earliestLine) {
68
+ earliestLine = node.startPosition.row + 1;
69
+ }
70
+ }
71
+ out.push({
72
+ captures,
73
+ file: filePath,
74
+ line: Number.isFinite(earliestLine) ? earliestLine : 1,
75
+ queryName
76
+ });
77
+ }
78
+ try {
79
+ tree.delete();
80
+ } catch {
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // src/detect/adapters/tree-sitter-loader.ts
86
+ import { createHash } from "crypto";
87
+ import {
88
+ mkdirSync,
89
+ readdirSync,
90
+ readFileSync,
91
+ writeFileSync,
92
+ renameSync,
93
+ unlinkSync,
94
+ lstatSync,
95
+ chmodSync,
96
+ utimesSync
97
+ } from "fs";
98
+ import { homedir } from "os";
99
+ import { dirname, join } from "path";
100
+ import { Language, Parser } from "web-tree-sitter";
101
+ var GrammarSHAMismatchError = class extends Error {
102
+ language;
103
+ expected;
104
+ actual;
105
+ constructor(language, expected, actual) {
106
+ super(
107
+ `[tree-sitter-loader] SHA-256 mismatch for grammar "${language}". Expected ${expected}, got ${actual}. REFUSING to load \u2014 see Phase 3.5 audit attack vector #3.`
108
+ );
109
+ this.name = "GrammarSHAMismatchError";
110
+ this.language = language;
111
+ this.expected = expected;
112
+ this.actual = actual;
113
+ }
114
+ };
115
+ var GrammarUnavailableError = class extends Error {
116
+ language;
117
+ cause;
118
+ constructor(language, cause) {
119
+ const causeMsg = cause instanceof Error ? cause.message : cause ? String(cause) : "no cached grammar and download failed";
120
+ super(
121
+ `[tree-sitter-loader] Grammar for "${language}" is unavailable: ${causeMsg}. Falling back to regex introspection for files in ${language}.`
122
+ );
123
+ this.name = "GrammarUnavailableError";
124
+ this.language = language;
125
+ this.cause = cause;
126
+ }
127
+ };
128
+ var GrammarCacheSymlinkError = class extends Error {
129
+ cachePath;
130
+ constructor(cachePath) {
131
+ super(
132
+ `[tree-sitter-loader] Refusing to load grammar \u2014 cache path "${cachePath}" is a symlink or non-regular file. (Phase 3.5 finding #3 \u2014 symlink attack vector.)`
133
+ );
134
+ this.name = "GrammarCacheSymlinkError";
135
+ this.cachePath = cachePath;
136
+ }
137
+ };
138
+ var GrammarUrlNotHttpsError = class extends Error {
139
+ url;
140
+ constructor(url) {
141
+ super(
142
+ `[tree-sitter-loader] Refusing to download grammar from non-HTTPS URL: ${url}. Only https:// URLs are accepted. (Phase 3.5 finding #3.)`
143
+ );
144
+ this.name = "GrammarUrlNotHttpsError";
145
+ this.url = url;
146
+ }
147
+ };
148
+ var GRAMMAR_MANIFEST = {
149
+ python: {
150
+ url: "https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-python.wasm",
151
+ sha256: "9056d0fb0c337810d019fae350e8167786119da98f0f282aceae7ab89ee8253b",
152
+ version: "0.1.13"
153
+ },
154
+ typescript: {
155
+ url: "https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-typescript.wasm",
156
+ sha256: "8515404dceed38e1ed86aa34b09fcf3379fff1b4ff9dd3967bcd6d1eb5ac3d8f",
157
+ version: "0.1.13"
158
+ },
159
+ javascript: {
160
+ url: "https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-javascript.wasm",
161
+ sha256: "63812b9e275d26851264734868d27a1656bd44a2ef6eb3e85e6b03728c595ab5",
162
+ version: "0.1.13"
163
+ },
164
+ swift: {
165
+ url: "https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-swift.wasm",
166
+ sha256: "41c4fdb2249a3aa6d87eed0d383081ff09725c2248b4977043a43825980ffcc7",
167
+ version: "0.1.13"
168
+ },
169
+ // ----------------------------------------------------------------
170
+ // Plan 3c Phase 7 expansion (2026-05-07):
171
+ //
172
+ // Six additional grammars to support the registry-verified framework
173
+ // adapters (go-chi, rails, aspnet, spring, ktor, phoenix) plus the
174
+ // bundled adapters in the same language families (gin/echo/fiber,
175
+ // sinatra, etc.). All entries use the SAME pinned tree-sitter-wasms
176
+ // version (0.1.13) as the v1 four to keep the dependency surface
177
+ // single-source.
178
+ //
179
+ // SHA-256s computed 2026-05-07 via:
180
+ // curl -fsSL <url> | shasum -a 256
181
+ //
182
+ // The unpkg filename for C# uses an underscore (`c_sharp`) while the
183
+ // TreeSitterLanguage identifier uses no separator (`csharp`); the map
184
+ // key is the type identifier, the URL is the storage path — they do
185
+ // NOT need to match, the same as how `python` maps to `tree-sitter-
186
+ // python.wasm`. This is intentional and validated by the manifest
187
+ // shape test in tree-sitter-loader-manifest.test.ts.
188
+ // ----------------------------------------------------------------
189
+ go: {
190
+ url: "https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-go.wasm",
191
+ sha256: "9963ca89b616eaf04b08a43bc1fb0f07b85395bec313330851f1f1ead2f755b6",
192
+ version: "0.1.13"
193
+ },
194
+ ruby: {
195
+ url: "https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-ruby.wasm",
196
+ sha256: "93a5022855314cdb45458c7bb026a24a0ebc3a5ff6439e542e881f14dfa13a39",
197
+ version: "0.1.13"
198
+ },
199
+ csharp: {
200
+ url: "https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-c_sharp.wasm",
201
+ sha256: "6266a7e32d68a3459104d994dc848df15d5672b0ea8e86d327274b694f8e6991",
202
+ version: "0.1.13"
203
+ },
204
+ java: {
205
+ url: "https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-java.wasm",
206
+ sha256: "637aac4415fb39a211a4f4292d63c66b5ce9c32fa2cd35464af4f681d91b9a1f",
207
+ version: "0.1.13"
208
+ },
209
+ kotlin: {
210
+ url: "https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-kotlin.wasm",
211
+ sha256: "b5cb00c8d06ed0f10f1dbe497205b437809d7e87db1f638721a8cfb30e044449",
212
+ version: "0.1.13"
213
+ },
214
+ elixir: {
215
+ url: "https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-elixir.wasm",
216
+ sha256: "82e91b9759ddca30d8978ebbfa8e347b4451b64c931f9ae62112e6db9b8fac20",
217
+ version: "0.1.13"
218
+ }
219
+ };
220
+ function getCacheDir() {
221
+ return process.env.MASSU_WASM_CACHE_DIR ?? join(homedir(), ".massu", "wasm-cache");
222
+ }
223
+ function getCachedPath(language, sha) {
224
+ return join(getCacheDir(), `${language}-${sha}.wasm`);
225
+ }
226
+ var DEFAULT_CACHE_RETAIN_COUNT = 16;
227
+ function getCacheRetainCount() {
228
+ const env = process.env.MASSU_WASM_CACHE_RETAIN;
229
+ if (env) {
230
+ const n = Number(env);
231
+ if (Number.isFinite(n) && n >= 1 && n <= 1024) return Math.floor(n);
232
+ }
233
+ return DEFAULT_CACHE_RETAIN_COUNT;
234
+ }
235
+ function touchCacheFile(path) {
236
+ try {
237
+ const now = /* @__PURE__ */ new Date();
238
+ utimesSync(path, now, now);
239
+ } catch {
240
+ }
241
+ }
242
+ function evictBeyondRetainCount(retain = getCacheRetainCount()) {
243
+ const dir = getCacheDir();
244
+ let entries;
245
+ try {
246
+ entries = readdirSync(dir);
247
+ } catch {
248
+ return;
249
+ }
250
+ const candidates = [];
251
+ for (const name of entries) {
252
+ if (!name.endsWith(".wasm")) continue;
253
+ const path = join(dir, name);
254
+ let stat;
255
+ try {
256
+ stat = lstatSync(path);
257
+ } catch {
258
+ continue;
259
+ }
260
+ if (stat.isSymbolicLink() || !stat.isFile()) {
261
+ console.error(
262
+ `[tree-sitter-loader] cache eviction skipped non-regular file: ${path} (possible symlink attack \u2014 see Phase 3.5 finding F-008).`
263
+ );
264
+ continue;
265
+ }
266
+ candidates.push({ path, mtimeMs: stat.mtimeMs });
267
+ }
268
+ if (candidates.length <= retain) return;
269
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
270
+ for (const victim of candidates.slice(retain)) {
271
+ try {
272
+ unlinkSync(victim.path);
273
+ } catch {
274
+ }
275
+ }
276
+ }
277
+ function sha256(bytes) {
278
+ return createHash("sha256").update(bytes).digest("hex");
279
+ }
280
+ var parserInitPromise = null;
281
+ async function ensureParserInitialized() {
282
+ if (parserInitPromise) return parserInitPromise;
283
+ parserInitPromise = Parser.init();
284
+ return parserInitPromise;
285
+ }
286
+ var loadedGrammars = /* @__PURE__ */ new Map();
287
+ async function loadGrammar(language, options = {}) {
288
+ await ensureParserInitialized();
289
+ const cached = loadedGrammars.get(language);
290
+ if (cached) return cached;
291
+ const manifest = options.manifestOverride?.[language] ?? GRAMMAR_MANIFEST[language];
292
+ if (!manifest) {
293
+ throw new GrammarUnavailableError(
294
+ language,
295
+ new Error(`No manifest entry for language "${language}". v1 supports: ${Object.keys(GRAMMAR_MANIFEST).join(", ")}.`)
296
+ );
297
+ }
298
+ const cachePath = getCachedPath(language, manifest.sha256);
299
+ let cacheLstat;
300
+ try {
301
+ cacheLstat = lstatSync(cachePath);
302
+ } catch {
303
+ cacheLstat = null;
304
+ }
305
+ if (cacheLstat) {
306
+ if (cacheLstat.isSymbolicLink() || !cacheLstat.isFile()) {
307
+ throw new GrammarCacheSymlinkError(cachePath);
308
+ }
309
+ let bytes;
310
+ try {
311
+ bytes = readFileSync(cachePath);
312
+ } catch (e) {
313
+ bytes = new Uint8Array(0);
314
+ }
315
+ if (bytes.byteLength > 0) {
316
+ const actualSha = sha256(bytes);
317
+ if (actualSha !== manifest.sha256) {
318
+ throw new GrammarSHAMismatchError(language, manifest.sha256, actualSha);
319
+ }
320
+ const lang2 = await Language.load(bytes);
321
+ loadedGrammars.set(language, lang2);
322
+ touchCacheFile(cachePath);
323
+ return lang2;
324
+ }
325
+ }
326
+ if (!/^https:\/\//i.test(manifest.url)) {
327
+ throw new GrammarUrlNotHttpsError(manifest.url);
328
+ }
329
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
330
+ if (!fetchImpl) {
331
+ throw new GrammarUnavailableError(
332
+ language,
333
+ new Error("No fetch implementation available (Node < 18?)")
334
+ );
335
+ }
336
+ let body;
337
+ try {
338
+ const res = await fetchImpl(manifest.url);
339
+ if (!res.ok) {
340
+ throw new Error(`HTTP ${res.status ?? "unknown"} from ${manifest.url}`);
341
+ }
342
+ body = new Uint8Array(await res.arrayBuffer());
343
+ } catch (e) {
344
+ throw new GrammarUnavailableError(language, e);
345
+ }
346
+ const downloadedSha = sha256(body);
347
+ if (downloadedSha !== manifest.sha256) {
348
+ throw new GrammarSHAMismatchError(language, manifest.sha256, downloadedSha);
349
+ }
350
+ try {
351
+ mkdirSync(dirname(cachePath), { recursive: true, mode: 448 });
352
+ try {
353
+ chmodSync(dirname(cachePath), 448);
354
+ } catch {
355
+ }
356
+ const tmpPath = `${cachePath}.tmp.${process.pid}`;
357
+ writeFileSync(tmpPath, body, { mode: 384 });
358
+ try {
359
+ chmodSync(tmpPath, 384);
360
+ } catch {
361
+ }
362
+ try {
363
+ renameSync(tmpPath, cachePath);
364
+ try {
365
+ chmodSync(cachePath, 384);
366
+ } catch {
367
+ }
368
+ } catch (e) {
369
+ try {
370
+ unlinkSync(tmpPath);
371
+ } catch {
372
+ }
373
+ throw e;
374
+ }
375
+ evictBeyondRetainCount();
376
+ } catch (e) {
377
+ console.error(
378
+ `[tree-sitter-loader] cache write failed for ${language}: ${e instanceof Error ? e.message : String(e)} \u2014 loading directly from memory.`
379
+ );
380
+ }
381
+ const lang = await Language.load(body);
382
+ loadedGrammars.set(language, lang);
383
+ return lang;
384
+ }
385
+
386
+ // src/detect/adapters/parse-guard.ts
387
+ var MAX_AST_FILE_BYTES = 1 * 1024 * 1024;
388
+ var MAX_AST_PARSE_DEPTH = 5e3;
389
+ function isParsableSource(source, sizeBytes) {
390
+ const bytes = sizeBytes ?? Buffer.byteLength(source, "utf-8");
391
+ if (bytes > MAX_AST_FILE_BYTES) {
392
+ return {
393
+ reason: "size-cap",
394
+ detail: `${bytes} bytes > ${MAX_AST_FILE_BYTES} cap`
395
+ };
396
+ }
397
+ let depth = 0;
398
+ let maxDepth = 0;
399
+ for (let i = 0; i < source.length; i++) {
400
+ const c = source.charCodeAt(i);
401
+ if (c === 0) {
402
+ return { reason: "control-bytes", detail: "NUL byte at offset " + i };
403
+ }
404
+ if (c === 40 || c === 91 || c === 123) {
405
+ depth++;
406
+ if (depth > maxDepth) maxDepth = depth;
407
+ if (depth > MAX_AST_PARSE_DEPTH) {
408
+ return {
409
+ reason: "depth-cap",
410
+ detail: `nesting depth exceeded ${MAX_AST_PARSE_DEPTH}`
411
+ };
412
+ }
413
+ } else if (c === 41 || c === 93 || c === 125) {
414
+ depth = depth > 0 ? depth - 1 : 0;
415
+ }
416
+ }
417
+ return null;
418
+ }
419
+
420
+ // ../adapter-phoenix/dist/index.js
421
+ var ROUTE_METHOD_QUERY = `
422
+ (call
423
+ (identifier) @method (#match? @method "^(get|post|put|patch|delete|options|head)$")
424
+ (arguments
425
+ .
426
+ (string) @route_path))
427
+ `;
428
+ var SCOPE_PATH_QUERY = `
429
+ (call
430
+ (identifier) @_method (#eq? @_method "scope")
431
+ (arguments
432
+ .
433
+ (string) @scope_path)
434
+ (do_block))
435
+ `;
436
+ var ROUTER_MODULE_QUERY = `
437
+ (call
438
+ (identifier) @_method (#eq? @_method "defmodule")
439
+ (arguments
440
+ .
441
+ (alias) @module_name (#match? @module_name "Router$"))
442
+ (do_block))
443
+ `;
444
+ var phoenixAdapter = {
445
+ id: "phoenix",
446
+ languages: ["elixir"],
447
+ matches(signals) {
448
+ if (!signals.mixExs)
449
+ return false;
450
+ return /\{\s*:phoenix\b(?!_)/.test(signals.mixExs);
451
+ },
452
+ async introspect(files, _rootDir) {
453
+ if (files.length === 0) {
454
+ return { conventions: {}, provenance: [], confidence: "none" };
455
+ }
456
+ let language;
457
+ try {
458
+ language = await loadGrammar("elixir");
459
+ } catch (e) {
460
+ return { conventions: {}, provenance: [], confidence: "none" };
461
+ }
462
+ const parser = new Parser2();
463
+ parser.setLanguage(language);
464
+ const routeMethods = /* @__PURE__ */ new Map();
465
+ const scopePaths = /* @__PURE__ */ new Map();
466
+ const routerModules = /* @__PURE__ */ new Map();
467
+ try {
468
+ for (const file of files) {
469
+ const skip = isParsableSource(file.content, file.size);
470
+ if (skip) {
471
+ process.stderr.write(`[massu/ast] WARN: phoenix skipping ${file.path}: ${skip.reason} (${skip.detail}). Cap=${MAX_AST_FILE_BYTES}. (Phase 3.5 mitigation)
472
+ `);
473
+ continue;
474
+ }
475
+ try {
476
+ for (const hit of runQuery(parser, file.content, ROUTE_METHOD_QUERY, "phoenix-route-method", file.path)) {
477
+ const method = hit.captures.method;
478
+ if (method && !routeMethods.has(method)) {
479
+ routeMethods.set(method, { line: hit.line, file: file.path });
480
+ }
481
+ }
482
+ for (const hit of runQuery(parser, file.content, SCOPE_PATH_QUERY, "phoenix-scope-path", file.path)) {
483
+ const raw = hit.captures.scope_path;
484
+ if (!raw)
485
+ continue;
486
+ const literal = raw.replace(/^["']/, "").replace(/["']$/, "");
487
+ const base = extractPrefixBase(literal);
488
+ if (base && !scopePaths.has(base)) {
489
+ scopePaths.set(base, { line: hit.line, file: file.path });
490
+ }
491
+ }
492
+ for (const hit of runQuery(parser, file.content, ROUTER_MODULE_QUERY, "phoenix-router-module", file.path)) {
493
+ const name = hit.captures.module_name;
494
+ if (name && !routerModules.has(name)) {
495
+ routerModules.set(name, { line: hit.line, file: file.path });
496
+ }
497
+ }
498
+ } catch (e) {
499
+ if (e instanceof InvalidQueryError) {
500
+ throw e;
501
+ }
502
+ continue;
503
+ }
504
+ }
505
+ } finally {
506
+ try {
507
+ parser.delete();
508
+ } catch {
509
+ }
510
+ }
511
+ const conventions = {};
512
+ const provenance = [];
513
+ if (routeMethods.size === 1) {
514
+ const [name, { line, file }] = routeMethods.entries().next().value;
515
+ conventions.route_method = name;
516
+ provenance.push({ field: "route_method", sourceFile: file, line, query: "phoenix-route-method" });
517
+ } else if (routeMethods.size >= 2) {
518
+ const [name, { line, file }] = routeMethods.entries().next().value;
519
+ conventions.route_method = name;
520
+ provenance.push({ field: "route_method", sourceFile: file, line, query: "phoenix-route-method" });
521
+ }
522
+ if (scopePaths.size >= 1) {
523
+ const [base, { line, file }] = scopePaths.entries().next().value;
524
+ conventions.scope_prefix_base = base;
525
+ provenance.push({ field: "scope_prefix_base", sourceFile: file, line, query: "phoenix-scope-path" });
526
+ }
527
+ if (routerModules.size >= 1) {
528
+ const [name, { line, file }] = routerModules.entries().next().value;
529
+ conventions.router_module = name;
530
+ provenance.push({ field: "router_module", sourceFile: file, line, query: "phoenix-router-module" });
531
+ }
532
+ let confidence;
533
+ if (Object.keys(conventions).length === 0) {
534
+ confidence = "none";
535
+ } else if (routeMethods.size === 1) {
536
+ confidence = "high";
537
+ } else if (routeMethods.size >= 2) {
538
+ confidence = "low";
539
+ } else {
540
+ confidence = "medium";
541
+ }
542
+ return { conventions, provenance, confidence };
543
+ }
544
+ };
545
+ function extractPrefixBase(prefix) {
546
+ if (!prefix.startsWith("/"))
547
+ return null;
548
+ const stripped = prefix.replace(/^\/+/, "");
549
+ const firstSeg = stripped.split("/")[0];
550
+ if (!firstSeg)
551
+ return null;
552
+ return "/" + firstSeg;
553
+ }
554
+ export {
555
+ phoenixAdapter
556
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Plan 3b — Phase 1: Tree-sitter query wrapper.
3
+ *
4
+ * Adapters consume the helpers in this file — never the raw `web-tree-sitter`
5
+ * API. This keeps the surface area minimal and testable.
6
+ *
7
+ * Design:
8
+ * - `compileQuery` caches compiled `Query` instances per (language, source)
9
+ * tuple. Compiling an S-expression is non-trivial; cache hit-rate is
10
+ * critical when the same query runs across N sampled files.
11
+ * - `runQuery` returns the captures as `{captures, file, line}` records so
12
+ * adapters never need to touch raw `Node` objects.
13
+ * - `InvalidQueryError` is the typed error thrown when an S-expression is
14
+ * malformed; never let a raw `Error` reach the adapter (per audit-iter-5
15
+ * fix HH test (b)).
16
+ */
17
+ import { Query, type Language, type Parser } from 'web-tree-sitter';
18
+ /**
19
+ * Thrown when an S-expression query string fails to compile against the
20
+ * supplied grammar. Carries the original message and the offending source
21
+ * so adapter authors can debug.
22
+ */
23
+ export declare class InvalidQueryError extends Error {
24
+ readonly queryName: string;
25
+ readonly querySource: string;
26
+ readonly cause?: unknown;
27
+ constructor(queryName: string, querySource: string, cause: unknown);
28
+ }
29
+ /**
30
+ * Compile (and cache) an S-expression query against `language`.
31
+ *
32
+ * Throws `InvalidQueryError` (NOT raw Error) on malformed S-expressions —
33
+ * adapters can catch this without losing the typed boundary.
34
+ *
35
+ * Cache lookup is O(1) on the (Language, source) tuple via WeakMap+Map.
36
+ */
37
+ export declare function compileQuery(language: Language, source: string, queryName: string): Query;
38
+ export interface RunQueryHit {
39
+ /**
40
+ * Capture name → captured text. If the same capture name appears multiple
41
+ * times in a single match, the LAST occurrence wins (callers usually want
42
+ * the most-specific one).
43
+ */
44
+ captures: Record<string, string>;
45
+ /** Absolute path to the file being parsed. */
46
+ file: string;
47
+ /** 1-based line number of the FIRST capture in the match. */
48
+ line: number;
49
+ /** Name of the query (used for provenance). */
50
+ queryName: string;
51
+ }
52
+ /**
53
+ * Run a compiled query against a parsed tree. Returns a flat list of hits.
54
+ *
55
+ * Each match becomes one `RunQueryHit`. The `line` is computed from the
56
+ * earliest-starting capture in the match (1-based). Note that this helper is
57
+ * intentionally narrow — it is NOT a general node-walker. Adapters that need
58
+ * tree traversal should compose multiple queries instead.
59
+ */
60
+ export declare function runQuery(parser: Parser, source: string, queryText: string, queryName: string, filePath: string): RunQueryHit[];
@@ -0,0 +1,85 @@
1
+ // src/detect/adapters/query-helpers.ts
2
+ import { Query } from "web-tree-sitter";
3
+ var InvalidQueryError = class extends Error {
4
+ queryName;
5
+ querySource;
6
+ cause;
7
+ constructor(queryName, querySource, cause) {
8
+ const causeMsg = cause instanceof Error ? cause.message : String(cause);
9
+ super(
10
+ `[query-helpers] Invalid Tree-sitter query "${queryName}": ${causeMsg}
11
+ Query source:
12
+ ${querySource}`
13
+ );
14
+ this.name = "InvalidQueryError";
15
+ this.queryName = queryName;
16
+ this.querySource = querySource;
17
+ this.cause = cause;
18
+ }
19
+ };
20
+ var queryCache = /* @__PURE__ */ new WeakMap();
21
+ function compileQuery(language, source, queryName) {
22
+ let perLang = queryCache.get(language);
23
+ if (!perLang) {
24
+ perLang = /* @__PURE__ */ new Map();
25
+ queryCache.set(language, perLang);
26
+ }
27
+ const cached = perLang.get(source);
28
+ if (cached) return cached;
29
+ let q;
30
+ try {
31
+ q = new Query(language, source);
32
+ } catch (e) {
33
+ throw new InvalidQueryError(queryName, source, e);
34
+ }
35
+ perLang.set(source, q);
36
+ return q;
37
+ }
38
+ function runQuery(parser, source, queryText, queryName, filePath) {
39
+ const language = parser.language;
40
+ if (!language) {
41
+ throw new InvalidQueryError(
42
+ queryName,
43
+ queryText,
44
+ new Error("Parser has no language assigned")
45
+ );
46
+ }
47
+ const query = compileQuery(language, queryText, queryName);
48
+ const tree = parser.parse(source);
49
+ if (!tree) return [];
50
+ let matches;
51
+ try {
52
+ matches = query.matches(tree.rootNode);
53
+ } catch (e) {
54
+ throw new InvalidQueryError(queryName, queryText, e);
55
+ }
56
+ const out = [];
57
+ for (const match of matches) {
58
+ if (!match.captures || match.captures.length === 0) continue;
59
+ const captures = {};
60
+ let earliestLine = Number.POSITIVE_INFINITY;
61
+ for (const cap of match.captures) {
62
+ const node = cap.node;
63
+ captures[cap.name] = node.text;
64
+ if (node.startPosition.row + 1 < earliestLine) {
65
+ earliestLine = node.startPosition.row + 1;
66
+ }
67
+ }
68
+ out.push({
69
+ captures,
70
+ file: filePath,
71
+ line: Number.isFinite(earliestLine) ? earliestLine : 1,
72
+ queryName
73
+ });
74
+ }
75
+ try {
76
+ tree.delete();
77
+ } catch {
78
+ }
79
+ return out;
80
+ }
81
+ export {
82
+ InvalidQueryError,
83
+ compileQuery,
84
+ runQuery
85
+ };