pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
1,669 lines • 74.8 kB
JavaScript
/**
* Tree-sitter Structural Search Client for pi-lens
*
* Inspired by pi-lsp-extension's search-engine.ts and pattern-compiler.ts
* Provides AST-aware structural search with metavariable capture.
*
* Uses web-tree-sitter (WASM) for parsing - no native compilation needed.
*
* Pattern syntax:
* $NAME - Matches any single AST node, captures as NAME
* $$$NAME - Matches zero or more sibling nodes (variadic)
*
* Example:
* "console.log($MSG)" matches any console.log call, captures argument as MSG
* "function $NAME($$$PARAMS) { $BODY }" matches function declarations
*/
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { loadWebTreeSitter } from "./deps/web-tree-sitter.js";
import { getProjectIgnoreMatcher, isExcludedDirName } from "./file-utils.js";
import { downloadGrammar, grammarBlockReason, LANGUAGE_TO_GRAMMAR, } from "./grammar-source.js";
import { resolvePackagePath } from "./package-root.js";
const _require = createRequire(import.meta.url);
import { TreeCache } from "./tree-sitter-cache.js";
import { TreeSitterNavigator } from "./tree-sitter-navigator.js";
import { TreeSitterQueryLoader, } from "./tree-sitter-query-loader.js";
// Hard cap on a single structural-search file walk. Bounds a misrooted scan so
// it can't enumerate an unbounded tree synchronously before result collection
// short-circuits (#262).
const TREE_SITTER_MAX_SCAN_FILES = 20_000;
// --- Parser Manager ---
export class TreeSitterClient {
initialized = false;
initPromise = null;
languages = new Map();
parsers = new Map();
treeCache;
navigator = new TreeSitterNavigator();
grammarsDir;
/** In-flight/settled lazy grammar fetches, keyed by wasm filename. */
grammarEnsurePromises = new Map();
// biome-ignore lint/suspicious/noExplicitAny: Optional dependency loaded dynamically
ParserClass = null;
// biome-ignore lint/suspicious/noExplicitAny: Language loader from module
LanguageLoader = null;
// biome-ignore lint/suspicious/noExplicitAny: Compiled query cache by language+pattern hash
queryCache = new Map();
queryLoader = new TreeSitterQueryLoader();
verbose;
constructor(verbose = false) {
this.grammarsDir = this.findGrammarsDir();
this.verbose = verbose;
this.treeCache = new TreeCache(50, verbose);
}
/** Debug logging helper */
dbg(msg) {
if (this.verbose) {
console.error(`[tree-sitter] ${msg}`); // pi-lens-ignore: console-statement — intentional verbose logger
}
}
/**
* Resolve a web-tree-sitter asset path using multiple strategies:
* 1. Node module resolution via createRequire (handles hoisted installs — issue #20)
* 2. Package-root walk from import.meta.url (handles on-the-fly TS compilation by pi)
* 3. process.cwd() fallback
*/
resolveWebTreeSitterAsset(asset) {
// Strategy 1: Node module resolution (hoisted installs, pnpm workspaces)
try {
const resolved = _require.resolve(`web-tree-sitter/${asset}`);
if (fs.existsSync(resolved))
return resolved;
}
catch {
/* fall through */
}
// Strategy 2: Walk up from this module to find package.json, then into node_modules.
// This is required when pi compiles TS on-the-fly to a temp directory —
// createRequire(import.meta.url) resolves from the temp dir and can't find
// web-tree-sitter, but the package root (where package.json lives) still has
// the correct node_modules layout.
try {
const candidate = resolvePackagePath(import.meta.url, "node_modules", "web-tree-sitter", asset);
if (fs.existsSync(candidate))
return candidate;
}
catch {
/* fall through */
}
// Strategy 3: cwd fallback
const cwdCandidate = path.join(process.cwd(), "node_modules", "web-tree-sitter", asset);
if (fs.existsSync(cwdCandidate))
return cwdCandidate;
return undefined;
}
/**
* The `grammars/` dir bundled inside the pi-lens package (the core grammars
* shipped in the tarball, so common languages parse offline on every package
* manager). Resolved from the package root; cached. Absent in a source
* checkout where `prepare` hasn't populated it.
*/
_bundledGrammarsDir;
bundledGrammarsDir() {
// Cache only a positive hit; keep re-checking until it exists (prepare may
// not have populated it yet at first probe).
if (this._bundledGrammarsDir)
return this._bundledGrammarsDir;
try {
const dir = resolvePackagePath(import.meta.url, "grammars");
if (fs.existsSync(dir))
this._bundledGrammarsDir = dir;
return this._bundledGrammarsDir;
}
catch {
return undefined;
}
}
/**
* All directories that may hold grammar wasms, in precedence order: the
* bundled core dir, the resolved `this.grammarsDir`, and the web-tree-sitter
* grammars dir (the lazy-fetch write target). Deduped.
*/
grammarSourceDirs() {
const dirs = [];
const push = (d) => {
if (d && !dirs.includes(d))
dirs.push(d);
};
push(this.bundledGrammarsDir());
push(this.grammarsDir || undefined);
push(this.resolveWebTreeSitterAsset("grammars"));
return dirs;
}
/** Absolute path to `grammarFile` across all source dirs, else undefined. */
resolveGrammarFile(grammarFile) {
for (const dir of this.grammarSourceDirs()) {
const candidate = path.join(dir, grammarFile);
if (fs.existsSync(candidate))
return candidate;
}
return undefined;
}
/** Find tree-sitter grammar directory */
findGrammarsDir() {
const grammarsDir = this.resolveWebTreeSitterAsset("grammars");
if (grammarsDir &&
fs.existsSync(path.join(grammarsDir, "tree-sitter-typescript.wasm"))) {
return grammarsDir;
}
// Fallback: a real `tree-sitter-wasms` package, if the user installed one
// (it is not a pi-lens dependency — grammars ship bundled / lazy-fetched).
try {
const wasmsOut = path.join(path.dirname(_require.resolve("tree-sitter-wasms/package.json")), "out");
if (fs.existsSync(wasmsOut))
return wasmsOut;
}
catch {
/* fall through */
}
const cwdWasms = path.join(process.cwd(), "node_modules", "tree-sitter-wasms", "out");
if (fs.existsSync(cwdWasms))
return cwdWasms;
return "";
}
/**
* The directory where grammars SHOULD live (web-tree-sitter/grammars),
* whether or not it exists yet — so we can create + populate it when the
* postinstall download was skipped (pnpm/bun). Returns undefined if
* web-tree-sitter itself can't be located.
*/
grammarsWriteDir() {
try {
let dir = path.dirname(_require.resolve("web-tree-sitter"));
while (path.basename(dir) !== "web-tree-sitter" &&
dir !== path.dirname(dir)) {
dir = path.dirname(dir);
}
if (path.basename(dir) === "web-tree-sitter") {
return path.join(dir, "grammars");
}
}
catch {
/* fall through */
}
return undefined;
}
/**
* Ensure a single grammar wasm is on disk, fetching it at runtime if the
* postinstall didn't (pnpm/bun skip lifecycle scripts — the documented
* build-scripts gap). Idempotent and de-duplicated per file. Best-effort:
* a failed fetch (e.g. offline) degrades to "grammar unavailable", never
* throws.
*/
async ensureGrammar(grammarFile) {
if (this.resolveGrammarFile(grammarFile)) {
return true;
}
const inflight = this.grammarEnsurePromises.get(grammarFile);
if (inflight)
return inflight;
const task = (async () => {
const dir = this.grammarsDir && fs.existsSync(this.grammarsDir)
? this.grammarsDir
: this.grammarsWriteDir();
if (!dir)
return false;
// Reuse the shared single-file downloader (same CDN/source as the
// postinstall) — see clients/grammar-source.ts.
const ok = await downloadGrammar(dir, grammarFile);
if (ok) {
if (!this.grammarsDir)
this.grammarsDir = dir;
console.error(`[pi-lens] fetched missing tree-sitter grammar ${grammarFile} at runtime (install scripts were skipped by the package manager)`);
}
else {
// Surface the degradation once per grammar (the promise cache dedupes)
// instead of failing silently — otherwise pnpm/bun users offline get
// no signal that a language's tree-sitter features are unavailable.
console.error(`[pi-lens] tree-sitter grammar '${grammarFile}' is unavailable — ` +
`symbol search, module reports and structural rules for this language will be degraded. ` +
`The package manager skipped install scripts and the runtime download failed (offline or CDN unreachable). ` +
`Fix: reinstall with a manager that runs postinstall, allow its build scripts ` +
`(pnpm approve-builds / bun trustedDependencies), or restore network access.`);
}
return ok;
})();
this.grammarEnsurePromises.set(grammarFile, task);
return task;
}
/** Initialize tree-sitter WASM runtime */
async init() {
if (this.initialized)
return true;
if (this.initPromise)
return this.initPromise;
this.initPromise = (async () => {
try {
const mod = await loadWebTreeSitter();
// biome-ignore lint/suspicious/noExplicitAny: web-tree-sitter module shape varies (Parser direct / default-wrapped)
const anyMod = mod;
const ParserClass = anyMod.Parser || anyMod.default || anyMod;
if (!ParserClass || typeof ParserClass.init !== "function") {
this.dbg("Parser class not found or missing init method");
return false;
}
// biome-ignore lint/suspicious/noExplicitAny: Parser class type
this.ParserClass = ParserClass;
// Store Language loader from module (not from Parser)
this.LanguageLoader = mod.Language;
// Resolve WASM path using same multi-strategy helper (hoisted installs +
// on-the-fly compilation by pi).
const wasmPath = this.resolveWebTreeSitterAsset("tree-sitter.wasm");
if (!wasmPath) {
this.dbg("Could not resolve tree-sitter.wasm");
return false;
}
const wasmDir = path.dirname(wasmPath);
this.dbg(`Looking for WASM at: ${wasmPath}, exists: ${fs.existsSync(wasmPath)}`);
await ParserClass.init({
locateFile: (scriptName) => {
const fullPath = path.join(wasmDir, scriptName);
this.dbg(`locateFile: ${scriptName} -> ${fullPath}`);
return fullPath;
},
});
this.initialized = true;
return true;
}
catch (err) {
this.dbg(`Init error: ${err}`);
return false;
}
finally {
this.initPromise = null;
}
})();
return this.initPromise;
}
/** Load language grammar */
async loadLanguage(languageId) {
this.dbg(`Loading language: ${languageId}`);
if (this.languages.has(languageId)) {
this.dbg(`Language ${languageId} already loaded`);
return this.languages.get(languageId);
}
if (!this.ParserClass) {
this.dbg(`ParserClass not initialized`);
return null;
}
const grammarFile = LANGUAGE_TO_GRAMMAR[languageId];
if (!grammarFile) {
this.dbg(`No grammar file for ${languageId}`);
return null;
}
// A grammar that fatally crashes this runtime (uncatchable V8 abort) must
// never be loaded — skip it and degrade to "unavailable" (#423/#432). The
// grammar-health nightly is what decides membership of BLOCKED_GRAMMARS.
const blockReason = grammarBlockReason(grammarFile);
if (blockReason) {
this.dbg(`Grammar ${grammarFile} blocked on this runtime — ${blockReason}`);
return null;
}
// Look across the bundled core `grammars/` dir and the postinstall/lazy
// dir. Lazily fetch only if the grammar is in neither (pnpm/bun skip
// postinstall; the long-tail grammars aren't bundled). Only the language
// actually being parsed is fetched.
let grammarPath = this.resolveGrammarFile(grammarFile);
if (!grammarPath) {
if (await this.ensureGrammar(grammarFile)) {
grammarPath = this.resolveGrammarFile(grammarFile);
}
}
this.dbg(`Grammar path: ${grammarPath}, exists: ${grammarPath && fs.existsSync(grammarPath)}`);
if (!grammarPath || !fs.existsSync(grammarPath)) {
this.dbg(`Grammar file not found: ${grammarPath}`);
return null;
}
try {
if (!this.LanguageLoader?.load) {
this.dbg(`LanguageLoader.load not available`);
return null;
}
this.dbg(`Calling Language.load...`);
const language = await this.LanguageLoader.load(grammarPath);
this.dbg(`Language loaded: ${language?.name || "unknown"}`);
if (language) {
this.languages.set(languageId, language);
}
return language;
}
catch (err) {
this.dbg(`Language load error: ${err}`);
return null;
}
}
/** Get or create parser for a language */
async getParser(languageId) {
if (this.parsers.has(languageId)) {
return this.parsers.get(languageId);
}
const language = await this.loadLanguage(languageId);
if (!language || !this.ParserClass)
return null;
const parser = new this.ParserClass();
parser.setLanguage(language);
this.parsers.set(languageId, parser);
return parser;
}
/** Parse a file and return the AST tree */
async parseFile(filePath, languageId, contentOverride) {
this.dbg(`Parsing ${filePath} with language ${languageId}`);
const parser = await this.getParser(languageId);
if (!parser) {
this.dbg(`Failed to get parser for ${languageId}`);
return null;
}
try {
const content = contentOverride ?? fs.readFileSync(filePath, "utf-8");
this.dbg(`File content length: ${content.length}`);
// Check cache first
const cachedTree = this.treeCache.get(filePath, content, languageId);
if (cachedTree) {
this.dbg(`Using cached tree for ${filePath}`);
return cachedTree;
}
// Parse and cache
const tree = parser.parse(content);
this.dbg(`Parsed, root node type: ${tree.rootNode.type}`);
// Cache the tree
this.treeCache.set(filePath, content, languageId, tree);
return tree;
}
catch (err) {
this.dbg(`Parse error: ${err}`);
return null;
}
}
/**
* Detect and extract injected content from template literals
* Used for security analysis (SQL injection, unsafe regex, etc.)
*/
extractInjections(filePath, content) {
const injections = [];
// Pattern: sql`SELECT * FROM users` or query`...`
const sqlPattern = /\b(sql|query|execute)\s*`([^`]+)`/gi;
let match;
while ((match = sqlPattern.exec(content)) !== null) {
const lines = content.slice(0, match.index).split("\n");
injections.push({
type: "sql",
content: match[2],
line: lines.length,
column: lines[lines.length - 1].length,
});
}
// Pattern: styled.div`color: red;` or css`...`
const cssPattern = /\b(styled(?:\.\w+)?|css)\s*`([^`]+)`/gi;
while ((match = cssPattern.exec(content)) !== null) {
const lines = content.slice(0, match.index).split("\n");
injections.push({
type: "css",
content: match[2],
line: lines.length,
column: lines[lines.length - 1].length,
});
}
// Pattern: new RegExp(`pattern`)
const regexPattern = /new\s+RegExp\s*\(\s*`([^`]+)`/gi;
while ((match = regexPattern.exec(content)) !== null) {
const lines = content.slice(0, match.index).split("\n");
injections.push({
type: "regex",
content: match[1],
line: lines.length,
column: lines[lines.length - 1].length,
});
}
this.dbg(`Found ${injections.length} injections in ${filePath}`);
return injections;
}
/** Check if tree-sitter is available (a core grammar resolves somewhere). */
isAvailable() {
// Available if the core TS grammar resolves in ANY source dir — the bundled
// `grammars/` counts even when web-tree-sitter/grammars is empty (no
// postinstall on pnpm/bun, or a fresh CI checkout).
if (this.resolveGrammarFile("tree-sitter-typescript.wasm"))
return true;
// Re-evaluate the legacy dir in case grammars were installed after start.
const dir = this.findGrammarsDir();
this.grammarsDir = dir;
return !!dir && fs.existsSync(dir);
}
/** Check if specific language is supported */
async isLanguageSupported(languageId) {
if (!this.initialized)
await this.init();
const language = await this.loadLanguage(languageId);
return language !== null;
}
/** Get loaded language for symbol extraction */
getLanguage(languageId) {
return this.languages.get(languageId) || null;
}
// --- Structural Search ---
/**
* Search for a structural pattern in files
*
* @param pattern - Pattern with metavariables (e.g., "console.log($MSG)")
* @param languageId - Language ID (typescript, python, etc.)
* @param rootDir - Directory to search
* @param options - Search options
* @returns Array of matches with captures
*/
async structuralSearch(pattern, languageId, rootDir, options = {}) {
if (!this.initialized) {
const ok = await this.init();
if (!ok)
return [];
}
try {
await this.queryLoader.loadQueries(rootDir);
}
catch (err) {
this.dbg(`Failed to load queries for ${rootDir}: ${err}`);
}
// Compile pattern into tree-sitter query
this.dbg(`Compiling pattern: ${pattern.slice(0, 50)}...`);
const compiled = await this.compileQuery(pattern, languageId);
if (!compiled) {
this.dbg(`Pattern compilation failed`);
return [];
}
this.dbg(`Pattern compiled, metavars: ${compiled.metavars.join(", ")}`);
// Collect source files
const files = this.collectFiles(rootDir, languageId, options.fileFilter);
this.dbg(`Scanning ${files.length} files...`);
const matches = [];
const maxResults = options.maxResults ?? 50;
for (const file of files) {
if (matches.length >= maxResults)
break;
const fileMatches = await this.searchFileWithQuery(file, compiled.query, compiled.metavars, languageId, pattern, compiled.postFilter, compiled.postFilterParams);
matches.push(...fileMatches);
}
return matches.slice(0, maxResults);
}
/**
* Run a preloaded query definition against a single file.
*
* Optimized for dispatch runner usage to avoid per-query directory scans.
*/
async runQueryOnFile(queryDef, filePath, languageId, options = {}, contentOverride) {
if (!this.initialized) {
const ok = await this.init();
if (!ok)
return [];
}
const compiled = await this.compileRawQuery(queryDef.id, queryDef.query, queryDef.metavars, queryDef.language || languageId, queryDef.post_filter, queryDef.post_filter_params);
if (!compiled)
return [];
const matches = await this.searchFileWithQuery(filePath, compiled.query, compiled.metavars, languageId, queryDef.id, compiled.postFilter, compiled.postFilterParams, contentOverride);
const maxResults = options.maxResults ?? 50;
return matches.slice(0, maxResults);
}
/**
* Convert pattern to tree-sitter query
* First tries to load from query files, then falls back to inline patterns
*/
patternToQuery(pattern, languageId) {
// Try to find matching query from loaded files
const loadedQuery = this.queryLoader.findMatchingQuery(pattern, languageId);
if (loadedQuery) {
this.dbg(`Using loaded query: ${loadedQuery.id}`);
return {
query: loadedQuery.query,
metavars: loadedQuery.metavars,
postFilter: loadedQuery.post_filter,
postFilterParams: loadedQuery.post_filter_params,
queryDef: loadedQuery,
};
}
// Fallback to inline patterns
return this.getInlinePattern(pattern);
}
/**
* Inline patterns as fallback when no query file matches
*/
getInlinePattern(pattern) {
// Pattern: async function $NAME($$$PARAMS) { $BODY }
if (pattern.includes("async function") && pattern.includes("$NAME")) {
return {
query: `(function_declaration
"async"
name: (identifier) @NAME
parameters: (formal_parameters) @PARAMS
body: (statement_block) @BODY)`,
metavars: ["NAME", "PARAMS", "BODY"],
};
}
// Pattern: console.$METHOD($MSG)
if (pattern.includes("console")) {
return {
query: `(call_expression
function: (member_expression
object: (identifier) @OBJ (#eq? @OBJ "console")
property: (property_identifier) @METHOD)
arguments: (arguments) @ARGS)`,
metavars: ["OBJ", "METHOD", "ARGS"],
};
}
// Pattern: function $NAME($$$PARAMS) { $BODY } - match long parameter lists
if (pattern.includes("function $NAME") && pattern.includes("PARAMS")) {
return {
query: `(function_declaration
name: (identifier) @NAME
parameters: (formal_parameters) @PARAMS
body: (statement_block) @BODY)`,
metavars: ["NAME", "PARAMS", "BODY"],
postFilter: "count_params",
postFilterParams: { min_params: 6 },
};
}
// Pattern: promise chains with .then().catch().then() - 3+ levels
if (pattern.includes(".then") && pattern.includes(".catch")) {
return {
query: `(call_expression
function: (member_expression
object: (call_expression
function: (member_expression
object: (call_expression
function: (member_expression
property: (property_identifier) @M1)
arguments: (arguments))
property: (property_identifier) @M2)
arguments: (arguments))
property: (property_identifier) @M3)
arguments: (arguments))
(#match? @M1 "^(then|catch)$")
(#match? @M2 "^(then|catch)$")
(#match? @M3 "^(then|catch)$")`,
metavars: ["M1", "M2", "M3"],
};
}
// Fallback: try to create a simple identifier capture
const simpleMatch = pattern.match(/\$([A-Z_][A-Z0-9_]*)/);
if (simpleMatch) {
const name = simpleMatch[1];
return {
query: `(identifier) @${name}`,
metavars: [name],
};
}
// If we can't convert, return empty to trigger fallback
return { query: "", metavars: [] };
}
/**
* Inject native tree-sitter predicates into S-expression query
* This moves text filtering to WASM for better performance
*/
/** Generate cache key for compiled query */
getQueryCacheKey(pattern, languageId) {
// Simple hash for the query string
let hash = 0;
for (let i = 0; i < pattern.length; i++) {
const char = pattern.charCodeAt(i);
hash = ((hash << 5) - hash + char) | 0; // NOSONAR: intentional 32-bit truncation for hash stability, not float→int conversion
}
return `${languageId}:${hash.toString(36)}`;
}
/** Compile a pattern into a tree-sitter Query with caching */
async compileQuery(pattern, languageId) {
const cacheKey = this.getQueryCacheKey(pattern, languageId);
// Check cache first
if (this.queryCache.has(cacheKey)) {
this.dbg(`Query cache hit: ${cacheKey}`);
return this.queryCache.get(cacheKey);
}
const language = await this.loadLanguage(languageId);
if (!language) {
this.dbg(`Could not load language ${languageId}`);
return null;
}
const { query: queryStr, metavars, postFilter, postFilterParams, } = this.patternToQuery(pattern, languageId);
this.dbg(`Query string: ${queryStr.slice(0, 100)}...`);
try {
// biome-ignore lint/suspicious/noExplicitAny: Query constructor
const Query = (await loadWebTreeSitter()).Query;
// biome-ignore lint/suspicious/noExplicitAny: Language type compatibility
const query = new Query(language, queryStr);
this.dbg(`Query compiled with ${query.patternCount} patterns`);
const result = { query, metavars, postFilter, postFilterParams };
// Cache the compiled query
this.queryCache.set(cacheKey, result);
return result;
}
catch (err) {
this.dbg(`Query compilation failed: ${err}`);
return null;
}
}
/** Compile a raw tree-sitter query string with caching */
async compileRawQuery(queryId, queryStr, metavars, languageId, postFilter, postFilterParams) {
const cacheKey = this.getQueryCacheKey(`raw:${queryId}:${queryStr}`, languageId);
if (this.queryCache.has(cacheKey)) {
return this.queryCache.get(cacheKey);
}
const language = await this.loadLanguage(languageId);
if (!language)
return null;
try {
// biome-ignore lint/suspicious/noExplicitAny: Query constructor from web-tree-sitter
const Query = (await loadWebTreeSitter()).Query;
// biome-ignore lint/suspicious/noExplicitAny: Language type compatibility
const query = new Query(language, queryStr);
const result = { query, metavars, postFilter, postFilterParams };
this.queryCache.set(cacheKey, result);
return result;
}
catch (err) {
this.dbg(`Raw query compilation failed (${queryId}): ${err}`);
return null;
}
}
hasChildToken(node, token) {
return node.children?.some((child) => child.type === token || child.text === token);
}
containsYieldInFunctionBody(node, root = node) {
for (const child of node.children ?? []) {
if (child.type === "yield")
return true;
if (child !== root &&
["function_definition", "class_definition", "lambda"].includes(child.type)) {
continue;
}
if (this.containsYieldInFunctionBody(child, root))
return true;
}
return false;
}
isLikelySqlAlchemyReceiver(text) {
const tail = text.split(".").pop() ?? text;
return new Set([
"session",
"db_session",
"async_session",
"sync_session",
]).has(tail.toLowerCase());
}
isSafeSqlAlchemyExpressionCall(node) {
if (node.type !== "call")
return false;
const callee = node.children?.[0]?.text ?? "";
const expression = node.text;
return ["select", "insert", "update", "delete"].some((name) => callee === name || expression.startsWith(`${name}(`));
}
/**
* Post-filter predicate: returns true if the match should be kept, false to skip.
* Each branch is an independent filter identified by name — flat dispatch, no nesting.
*/
// biome-ignore lint/suspicious/noExplicitAny: postFilterParams is untyped per-filter config
applyPostFilter(postFilter, postFilterParams, captures) {
/**
* Extract the list of declared slot names from a class_definition's
* `__slots__` assignment. Returns:
* - `null` if the class has no `__slots__` declaration
* - an array of slot names (strings) otherwise
*
* Handles both common shapes:
* - string tuple/list: `__slots__ = ("a", "b")` or `['a', 'b']`
* - single string: `__slots__ = "a"` (Python's quirky single-slot form)
* - parent inheritance: returns null (we don't follow MRO)
*/
function extractSlots(classNode) {
const classText = classNode.text ?? "";
if (!classText.includes("__slots__"))
return null;
// biome-ignore lint/suspicious/noExplicitAny: AST iteration
const body = classNode.children?.find((c) => c.type === "block");
if (!body)
return null;
const slots = [];
// biome-ignore lint/suspicious/noExplicitAny: AST iteration
for (const stmt of body.children ?? []) {
if (stmt.type !== "expression_statement")
continue;
// biome-ignore lint/suspicious/noExplicitAny: AST traversal
const assignment = stmt.children?.find((c) => c.type === "assignment");
if (!assignment)
continue;
// biome-ignore lint/suspicious/noExplicitAny: LHS check
// LHS text may include a leading whitespace token from the AST
// (tree-sitter separates the space before the LHS identifier).
const lhsText = (assignment.children?.[0]?.text ?? "").trim();
if (lhsText !== "__slots__")
continue;
// biome-ignore lint/suspicious/noExplicitAny: RHS extraction
// children layout: [LHS identifier, `=` operator, RHS expression]
const rhs = assignment.children?.[2];
if (!rhs)
continue;
if (rhs.type === "string") {
// __slots__ = "a" — single string form (Python quirk)
const s = (rhs.text ?? "").replace(/^["']|["']$/g, "");
if (s)
slots.push(s);
}
else if (rhs.type === "tuple" || rhs.type === "list") {
// biome-ignore lint/suspicious/noExplicitAny: list element extraction
for (const el of rhs.children ?? []) {
if (!el.isNamed)
continue; // skip "," punctuation
if (el.type === "string") {
slots.push((el.text ?? "").replace(/^["']|["']$/g, ""));
}
}
}
break; // first __slots__ wins
}
return slots;
}
switch (postFilter) {
case "is_generator_with_valued_return": {
const returnNode = captures.RETURN;
const functionNode = captures.FUNCTION ??
(returnNode
? this.navigator.findParent(returnNode, ["function_definition"])
: undefined);
if (!functionNode)
return false;
// In the Python grammar, `async def` is also a function_definition with
// an anonymous `async` child. Coroutines may return values normally;
// only synchronous generator functions should be flagged.
if (this.hasChildToken(functionNode, "async"))
return false;
return this.containsYieldInFunctionBody(functionNode);
}
case "count_params": {
const paramsNode = captures.PARAMS;
if (!paramsNode)
return true;
// Count only truly required params — exclude:
// • optional_parameter nodes (foo?: T) if the grammar uses that type
// • required_parameter nodes that have a "?" child (same semantic,
// different grammar version — web-tree-sitter-typescript collapses
// both into required_parameter with a "?" token child)
// • params with a default value (text contains "=")
// biome-ignore lint/suspicious/noExplicitAny: Count parameter nodes
const paramCount = paramsNode.children.filter((c) => {
if (c.type !== "required_parameter")
return false;
if (c.text.includes("="))
return false;
// biome-ignore lint/suspicious/noExplicitAny: child node check
if (c.children?.some((ch) => ch.text === "?"))
return false;
return true;
}).length;
return paramCount >= (postFilterParams?.min_params ?? 6);
}
case "empty_body": {
const bodyNode = captures.BODY;
if (!bodyNode)
return true;
// biome-ignore lint/suspicious/noExplicitAny: Check for meaningful statements
const meaningful = bodyNode.children.filter((c) => c.isNamed &&
c.type !== "comment" &&
c.type !== "line_comment" &&
c.type !== "block_comment");
return meaningful.length === 0;
}
case "bare_except_only": {
const clauseNode = captures.CLAUSE;
if (!clauseNode)
return true;
// A typed `except` clause has a named child for the exception
// spec — one of: identifier (e.g. `except ValueError`),
// tuple (e.g. `except (E, F)`), or as_pattern (e.g. `except E as e`).
// Bare `except:` has NO named children (just the `except` keyword,
// the `:` colon, and the body block).
// biome-ignore lint/suspicious/noExplicitAny: AST iteration
const hasExceptionSpec = clauseNode.children.some((c) => {
if (!c.isNamed)
return false;
return (c.type === "identifier" ||
c.type === "tuple" ||
c.type === "as_pattern" ||
c.type === "parenthesized_expression");
});
// Fire ONLY when bare (no exception spec)
return !hasExceptionSpec;
}
case "eq_mod_fn": {
// Workaround for web-tree-sitter not auto-applying #eq? predicates
// on the structural pattern of a query that has predicates. The
// query captures @MOD, @FN but the predicates aren't enforced
// (see evaluatePredicates in clients/tree-sitter-client.ts).
// This filter re-applies the #eq? checks at post_filter time.
const mod = captures.MOD?.text ?? "";
const fn = captures.FN?.text ?? "";
return mod === "threading" && fn === "Thread";
}
case "regex_first_arg_identifier": {
// Workaround for web-tree-sitter not auto-applying #eq?/#match?
// predicates on the structural pattern (see evaluatePredicates).
// This post_filter re-applies both predicate checks AND
// the first-argument check:
// 1. MOD must be "re" (would-be #eq? @MOD "re")
// 2. FUNC must match the regex method pattern (#match? @FUNC ...)
// 3. First arg must be an identifier (dynamic pattern)
// String literals (r"...", "...") are safe static patterns.
const mod = captures.MOD?.text ?? "";
if (mod !== "re")
return false;
const func = captures.FUNC?.text ?? "";
if (!/^(compile|match|search|fullmatch|findall|finditer|sub|subn|split)$/.test(func)) {
return false;
}
const argsNode = captures.ARGS;
if (!argsNode)
return false;
// biome-ignore lint/suspicious/noExplicitAny: AST iteration
const firstNamed = (argsNode.children ?? []).find((c) => c.isNamed);
if (!firstNamed)
return false;
return firstNamed.type === "identifier";
}
case "open_mode_invalid": {
const modeNode = captures.MODE;
if (!modeNode)
return false;
// Python's open() mode accepts: r, w, a, x (basic), b/t/+ (suffix).
// Strip surrounding quotes from the string literal text.
const text = modeNode.text ?? "";
const stripped = text.replace(/^["']|["']$/g, "");
// Skip empty mode (defaults to 'r')
if (stripped.length === 0)
return false;
// Skip single-char modes (r/w/a/x — always valid)
if (stripped.length === 1)
return false;
// Must contain only valid characters
if (!/^[rwxabt+]+$/.test(stripped))
return true;
// Multi-char must be exactly: basic + optional (b|t) + optional +
// Examples valid: "rb", "rb+", "r+", "ab", "rt"
// Examples invalid: "rwb", "rrr", "rw", "rbb" (no + between r and w is invalid)
// The "rw" case (basic mode followed by another basic mode without +) is invalid
// Allow: [basic][bt]?[+]
const validShape = /^[rwax][bt]?\+?$/;
if (!validShape.test(stripped))
return true;
return false;
}
case "status_204_with_value_return": {
const funcNode = captures.FUNC;
const valNode = captures.VAL;
if (!funcNode || !valNode)
return false;
// Only fire if status_code=204
if (Number(valNode.text ?? 0) !== 204)
return false;
// Walk the function subtree looking for return_statement nodes.
// Manual BFS because web-tree-sitter doesn't expose
// descendantsOfType directly.
// biome-ignore lint/suspicious/noExplicitAny: tree-sitter node iteration
const queue = [funcNode];
while (queue.length > 0) {
const node = queue.shift();
if (node.type === "return_statement") {
// Has a value child (not just the `return` keyword)
// biome-ignore lint/suspicious/noExplicitAny: child check
const hasValue = node.children.some((c) => c.isNamed && c.type !== "comment");
if (hasValue)
return true;
}
// biome-ignore lint/suspicious/noExplicitAny: child queue
if (node.children)
queue.push(...node.children);
}
return false;
}
case "has_mixed_async": {
const bodyNode = captures.BODY;
if (!bodyNode)
return true;
const bodyText = bodyNode.text;
return (bodyText.includes("await") && /\.\s*(then|catch)\s*\(/.test(bodyText));
}
case "format_arity_mismatch": {
const formatNode = captures.FORMAT;
const argsNode = captures.ARGS;
if (!formatNode || !argsNode)
return false;
// Strip quotes from format string
const fmtText = (formatNode.text ?? "").replace(/^["']|["']$/g, "");
// Don't strip a leading "%" — the format string's contents are
// intact after stripping only the surrounding quotes. The original
// code stripped the first "%" thinking it was the operator, but
// the operator is a separate binary_operator node, not part of
// the string literal's text.
const fmt = fmtText;
// Count placeholders: %s, %d, %f, %(name)s, %i, etc.
// The simple %s/%d style: each %X counts as 1
// The %(name)s style: counts as 1 with name
// The %% escape: doesn't count
let placeholderCount = 0;
let namedKeys = [];
// biome-ignore lint/suspicious/noExplicitAny: regex match
const positionalRegex = /%(?:\([^)]+\))?[#0\- +]*\d*(?:\.\d+)?[hlL]?[diouxXeEfFgGcrs%]/g;
// biome-ignore lint/suspicious/noExplicitAny: regex match
const positionalMatches = fmt.match(positionalRegex) ?? [];
for (const m of positionalMatches) {
if (m === "%%")
continue;
placeholderCount++;
// biome-ignore lint/suspicious/noExplicitAny: capture group
const namedMatch = m.match(/^%\(([^)]+)\)/);
if (namedMatch)
namedKeys.push(namedMatch[1]);
}
// If format uses named placeholders, RHS should be a dict
if (namedKeys.length > 0) {
// Check if dict contains all named keys
if (argsNode.type === "dictionary") {
// biome-ignore lint/suspicious/noExplicitAny: AST iteration
const dictKeys = [];
for (const child of argsNode.children ?? []) {
// biome-ignore lint/suspicious/noExplicitAny: child check
if (child.type === "pair" && child.children?.[0]) {
// biome-ignore lint/suspicious/noExplicitAny: child text
// Strip quotes — child is a string literal node,
// text includes the surrounding "...".
dictKeys.push((child.children[0].text ?? "").replace(/^["']|["']$/g, ""));
}
}
const missing = namedKeys.filter((k) => !dictKeys.includes(k));
return missing.length > 0;
}
// Format uses named but RHS isn't a dict — definitely wrong
return true;
}
// Positional: count tuple args
if (argsNode.type === "tuple") {
const argCount = (argsNode.children ?? []).filter((c) => c.isNamed).length;
if (argCount !== placeholderCount)
return true;
}
return false;
}
case "aws_policy_public": {
const policyNode = captures.POLICY;
if (!policyNode)
return false;
const text = policyNode.text ?? "";
// Match patterns indicating public access
const patterns = [
/"Principal"\s*:\s*"\*"/, // direct wildcard
/"Principal"\s*:\s*\{\s*"AWS"\s*:\s*"\*"\s*\}/, // AWS wildcard
/"Effect"\s*:\s*"Allow"[\s\S]*?"Action"\s*:\s*"\*"[\s\S]*?"Resource"\s*:\s*"\*"/, // full admin
/"Principal"\s*:\s*"\*"/,
];
return patterns.some((p) => p.test(text));
}
case "slots_attribute_mismatch": {
const selfNode = captures.SELF;
const attrNode = captures.ATTR;
const methodNode = captures.METHOD;
if (!selfNode || !attrNode || !methodNode)
return false;
// Only consider self.X = (not other.X)
if (selfNode.text !== "self")
return false;
const attrName = attrNode.text ?? "";
// Find parent class_definition
// biome-ignore lint/suspicious/noExplicitAny: AST navigation
let parent = methodNode.parent;
while (parent && parent.type !== "class_definition") {
parent = parent.parent;
}
if (!parent)
return false;
// Parse the class's __slots__ list and check if attrName is in it.
// Fires ONLY when self.X = ... assigns to an attribute NOT in __slots__
// (a real S8494 violation — the assignment will raise AttributeError).
const slots = extractSlots(parent);
// null = no __slots__ declared in this class. [] = __slots__ declared
// but we couldn't parse it (treat as null to avoid FPs on inner-class
// parent walks where the parent text mentions __slots__ but the
// direct children don't contain the assignment).
if (slots === null || slots.length === 0)
return false;
return !slots.includes(attrName);
}
case "special_method_arity": {
const nameNode = captures.NAME;
const paramsNode = captures.PARAMS;
if (!nameNode || !paramsNode)
return false;
const name = nameNode.text ?? "";
// Expected arities: {method_name: expected_arg_count}
// (excluding `self`/`cls` which is always 1)
const expected = {
__del__: 0,
__repr__: 0,
__str__: 0,
__hash__: 0,
__bool__: 0,
__len__: 0,
__eq__: 1,
__lt__: 1,
__le__: 1,
__gt__: 1,
__ge__: 1,
__ne__: 1,
};
const expectedCount = expected[name];
if (expectedCount === undefined)
return false; // not in our list
// Count required params (excluding defaults)
// biome-ignore lint/suspicious/noExplicitAny: AST iteration
const paramCount = (paramsNode.children ?? []).filter((c) => {
if (c.type !== "identifier" && c.type !== "typed_parameter")
return false;
if (c.text.includes("="))
return false;
return true;
}).length;
// Expected total = expectedCount + 1 (for self/cls)
return paramCount !== expectedCount + 1;
}
case "no_super_call": {
const bodyNode = captures.BODY;
if (!bodyNode)
return true;
return !/(?<!\/\/.*)super\s*\(/.test(bodyNode.text);
}
case "in_test_block": {
const first = Object.values(captures)[0];
return !!first && this.navigator.isInTestBlock(first);
}
case "not_in_test_block": {
const first = Object.values(captures)[0];
return !first || !this.navigator.isInTestBlock(first);
}
case "not_in_try_catch": {
const first = Object.values(captures)[0];
return !first || !this.navigator.isInTryCatch(first);
}
case "in_try_catch": {
const first = Object.values(captures)[0];
return !!first && this.navigator.isInTryCatch(first);
}
case "name_matches_param": {
const nameNode = captures.NAME;
const paramNode = captures.PARAM;
return !!nameNode && !!paramNode && nameNode.text === paramNode.text;
}
case "not_in_function": {
const first = Object.values(captures)[0];
return (!first ||
!this.navigator.isInside(first, [
"function_definition",
"function_declaration",
"method_definition",
"arrow_function",
]));
}
case "check_secret_pattern": {
const varName = (captures.VARNAME?.text ?? "");
const varNameLower = varName.toLowerCase();
// Skip UPPER_CASE constants — they're module-level constants
// (e.g. `GITHUB_TYPE_FOR_PERSONAL_API_KEY = "..."`), not secrets.
// A constant has no lowercase letters in its name.
if (varName === varName.toUpperCase() && /[A-Z]/.test(varName)) {
return false;
}
return [
/api[_-]?key/,
/api[_-]?secret/,
/password/,
/passwd/,
/secret/,
/token/,
/auth/,
/private[_-]?key/,
/access[_-]?token/,
/credentials/,
/aws[_-]?secret/,
/github[_-]?token/,
/client[_-]?secret/,
].some((p) => p.test(varNameLower));
}
case "returns_error": {
const first = Object.values(captures)[0];
if (!first)
return false;
const funcNode = this.navigator.findParent(first, [
"function_declaration",
"method_declaration",
]);
if (!funcNode)
return false;
const signature = String(funcNode.text ?? "")
.split("{", 1)[0]
?.trim() ?? "";
const returnPart = signature
.match(/func\s*(?:\([^)]*\)\s*)?[A-Za-z_]\w*\s*\([^)]*\)\s*(.*)$/s)?.[1]
?.trim() ?? "";
return returnPart.length > 0 && /\berror\b/.test(returnPart);
}
case "python_empty_except": {
const bodyNode = captures.BODY;
if (!bodyNode)
return true;
// biome-ignore lint/suspicious/noExplicitAny: tree-sitter node
return !bodyNode.children.some((c) => c.isNamed && c.type !== "pass_statement" && c.type !== "comment");
}
case "ruby_empty_rescue": {
const bodyNode = captures.BODY;
if (!bodyNode)
return true;
// biome-ignore lint/suspicious/noExplicitAny: tree-sitter node
return !bodyNode.children.some((c) => c.isNamed && !["comment", "nil", "nil_literal"].includes(c.type));
}
case "ts_command_injection_sink":
return (captures.MOD?.text === "child_process" &&
/^(exec|execSync)$/.test(captures.FN?.text ?? ""));
case "ts_ssrf_sink": {
const fn = captures.FN?.text ?? "";
const obj = captures.OBJ?.text ?? "";
const urlText = captures.URL?.text ?? "";
const allowedFns = new Set([
"fetch",
"request",
"get",
"post",
"put",
"patch",
"delete",
]);
if (!allowedFns.has(fn))
return false;
// Only flag when the URL argument looks like it could carry external
// input: member expressions (req.url, ctx.query.x) or identifiers
// whose names suggest user/external provenance. Plain generic names
// like `url` or `path` in internal download utilities produce too
// many false positives — those need data-flow analysis to resolve.
const looksLikeExternalInput = urlText.includes(".") ||
/user|external|remote|input|target|webhook|callback|redirect|untrusted|arbitrary/i.test(urlText);
if (!looksLikeExternalInput)
return false;
if (!obj)
return fn === "fetch";
return new Set([
"axios",
"http",
"https",
"got",
"request",
"superagent",
"undici",
]).has(obj);
}
case "ts_weak_hash_algorithm":
return (captures.FN?.text === "createHash" &&
/^(md5|sha1)$/i.test(captures.ALG?.text ?? ""));
case "ts_insecure_random_source": {
if (captures.OBJ?.text !== "Math" || captures.FN?.text !== "random")
return false;
// Only flag when assigned to a security-sensitive variable name
const varName = captures.VAR?.text ?? "";
return /token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin/i.test(varName);
}
case "ts_detached_async_call":
return /(Async$|fetch$|request$)/.test(captures.FN?.text ?? "");
case "incomplete_assertion": {
const expectNode = captures.EXPECT;
if (!expectNode)
return false;
const CHAI_PROPERTY_ASSERTIONS = new Set([
"true",
"false",
"null",
"undefined",
"empty",
"NaN",
"finite",
"exist",
"arguments",
"extensible",
"sealed",
"frozen",
"locked",
]);
// The expect identifier is inside a call_expression. Walk up past that
// call_expression to the container that determines if it's a complete
// assertion or an incomplete one.
let current = expectNode.parent;
if (!current)
return false;
current = current.parent; // skip the expect(...) call_expression
if (!current)
return false;
// Bare expect(foo); or return expect(foo);
if (current.type === "expression_statement" ||
current.type === "return_statement")
return true;
let lastPropertyName = null;
while (current && current.type === "member_expression") {
const propNode = current.children?.find((c) => c.type === "property_identifier");
if (propNode)
lastPropertyName = propNode.text;
const parent = current.parent;
if (!parent)
return false;
if (parent.type === "expression_statement" ||
parent.type === "return_statement") {
if (lastPropertyName &&
CHAI_PROPERTY_ASSERTIONS.has(lastPropertyName))
return false;
return true;
}
if (parent.type === "call_expression")
return false;
current = parent;
}
return false;
}
case "py_command_injection_sink": {
const mod = captures.MOD?.text ?? "";
const fn = captures.FN?.text ?? "";
const kw = captures.KW?.text ?? "";
return ((mod === "os" && /^(system|popen)$/.test(fn)) ||
(mod === "subprocess" &&
/^(run|Popen|call|check_output|check_call)$/.test(fn) &&
kw === "shell"));
}
case "go_command_injection_sink":
return (captures.PKG?.text === "exec" &&
/^(Command|CommandContext)$/.test(captures.FN?.text ?? "") &&
/^"(sh|bash|zsh|cmd|powershell|pwsh)"$/.test(captures.SHELL?.text ?? "") &&
/^"(-c|\/c)"$/.test(captures.FLAG?.text ?? ""));
case "ruby_command_injection_sink":
return /^(system|exec|spawn|popen|capture3|capture2|capture2e)$/.test(captures.FN?.text ?? "");
case "py_ssrf_sink":
return (captures.MOD?.text === "requests" &&
/^(get|post|put|patch|delete|request|head|options)$/.test(captures.FN?.text ?? ""));
case "py_path_traversal_sink":
return /^(open|read_text|read_bytes|write_text|write_bytes|remove|unlink|rmdir)$/.test(captures.FN?.text ?? "");
case "go_path_traversal_sink":
return (/^(os|ioutil)$/.test(captures.PKG?.text ?? "") &&
/^(Open|OpenFile|ReadFile|WriteFile|Create|Remove|RemoveAll)$/.test(captures.FN?.text ?? ""));
case "py_sql_injection_sink": {
const fn = captures.FN?.text ?? "";
if (!new Set(["execute", "executemany", "query", "raw"]).has(fn)) {
return false;
}
const sqlNode = captures.SQL;
const receiver = captures.OBJ?.text ?? "";
// SQLAlchemy ORM sessions execute expression objects, not raw SQL
// strings. `session.execute(stmt)` and `session.execute(select(...))`
// are parameterized by construction and were too noisy as blockers.
if (fn === "execute" && this.isLikelySqlAlchemyReceiver(receiver)) {
return false;
}
if (sqlNode && this.isSafeSqlAlchemyExpressionCall(sqlNode)) {
return false;
}
return true;
}
case "go_sql_injection_sink":
return (/^(Query|QueryContext|QueryRow|QueryRowContext|Exec|ExecContext)$/.test(captures.DBFN?.text ?? "") &&
captures.FMTPKG?.text === "fmt" &&
captures.FMTFN?.text === "Sprintf");
case "py_insecure_deserialization_sink":
return (/^(pickle|yaml)$/.test(captures.MOD?.text ?? "") &&
/^(load|loads|unsafe_load)$/.test(captures.FN?.text ?? ""));
case "ruby_insecure_deserialization_sink":
return (/^(Marshal|YAML|Psych)$/.test(captures.MOD?.text ?? "") &&
/^(load|unsafe_load)$/.test(captures.FN?.text ?? ""));
case "match_captures": {
// Generic filter: each key in postFilterParams is a capture name,
// value is a regex string. All must match.
for (const [captureName, pattern] of Object.entries(postFilterParams ?? {})) {
const node = captures[captureName];
if (!node)
return false;
if (!new RegExp(pattern).test(node.text))
return false;
}
return true;
}
case "case_range_single_value": {
const start = captures.START?.text ?? "";
const end = captures.END?.text ?? "";
return start === end;
}
case "goto_jumps_backward": {
const label = captures.LABEL;
const gotoNode = captures.GOTO;
if (!label || !gotoNode)
return false;
return label.startIndex < gotoNode.startIndex;
}
case "goto_targets_inner_block": {
const target = captures.TARGET;
if (!target)
return false;
// A goto targets an inner block if its label is inside a
// compound_statement that is nested inside another compound_statement.
let depth = 0;
let node = target.parent;
while (node) {
if (node.type === "compound_statement")
depth++;
node = node.parent;
}
return depth >= 2;
}
case "c_memset_sensitive_arg": {
const callNode = captures.CALL;
if (!callNode || callNode.type !== "call_expression")
return false;
// Find the first argument in the argument_list
const argList = callNode.children?.find((c) => c.type === "argument_list");
if (!argList)
return false;
// First named child after the opening paren is the first arg
const firstArg = argList.children?.find((c) => c.isNamed);
if (!firstArg)
return false;
const argName = firstArg.text ?? "";
return /password|secret|key|token|credential|auth|private|passwd|pin|salt|nonce|iv|seed/i.test(argName);
}
case "c_stdlib_name": {
const name = captures.NAME?.text ?? "";
const STDLIB_NAMES = new Set([
"malloc",
"calloc",
"realloc",
"free",
"alloca",
"printf",
"fprintf",
"sprintf",
"snprintf",
"vprintf",
"vfprintf",
"vsprintf",
"vsnprintf",
"scanf",
"fscanf",
"sscanf",
"vscanf",
"vfscanf",
"vsscanf",
"strcpy",
"strncpy",
"strcat",
"strncat",
"strcmp",
"strncmp",
"strlen",
"strchr",
"strrchr",
"strstr",
"strerror",
"memcpy",
"memmove",
"memset",
"memcmp",
"memchr",
"fopen",
"fclose",
"fread",
"fwrite",
"fgets",
"fputs",
"getc",
"putc",
"getchar",
"putchar",
"exit",
"abort",
"assert",
"errno",
"abs",
"labs",
"llabs",
"div",
"ldiv",
"lldiv",
"atoi",
"atol",
"atoll",
"strtol",
"strtoll",
"strtoul",
"strtoull",
"strtod",
"strtof",
"strtold",
"qsort",
"bsearch",
"time",
"clock",
"difftime",
"mktime",
"strftime",
"getenv",
"setenv",
"putenv",
"system",
"isalpha",
"isdigit",
"isalnum",
"isspace",
"isupper",
"islower",
"toupper",
"tolower",
"sizeof",
"offsetof",
"NULL",
"EXIT_SUCCESS",
"EXIT_FAILURE",
]);
return STDLIB_NAMES.has(name);
}
case "c_octal_literal": {
const num = captures.NUM?.text ?? "";
return /^0[0-7]+$/.test(num);
}
case "c_noreturn_attr": {
const attr = captures.ATTR?.text ?? "";
return attr === "noreturn";
}
case "c_label_in_switch": {
const stmt = captures.STMT;
if (!stmt)
return false;
let node = stmt.parent;
while (node) {
if (node.type === "switch_statement")
return true;
node = node.parent;
}
return false;
}
default:
return true;
}
}
/**
* Evaluate text predicates (#match?, #eq?) for a query match.
* web-tree-sitter stores these as compiled functions in query.textPredicates[patternIndex]
* and does NOT apply them automatically via .matches().
*/
// biome-ignore lint/suspicious/noExplicitAny: web-tree-sitter types
evaluatePredicates(query, match) {
const predicates = query.textPredicates?.[match.patternIndex] ?? [];
return predicates.every((fn) => fn(match.captures));
}
/** Search a single file using tree-sitter Query */
async searchFileWithQuery(filePath,
// biome-ignore lint/suspicious/noExplicitAny: Query type from web-tree-sitter
query, metavars, languageId, _originalPattern, postFilter,
// biome-ignore lint/suspicious/noExplicitAny: Post filter params
postFilterParams, contentOverride) {
const tree = await this.parseFile(filePath, languageId, contentOverride);
if (!tree)
return [];
const matches = [];
try {
const queryMatches = query.matches(tree.rootNode);
for (const match of queryMatches) {
const captures = {};
for (const capture of match.captures) {
if (metavars.includes(capture.name)) {
captures[capture.name] = capture.node;
}
}
// Evaluate #match? and #eq? predicates that web-tree-sitter doesn't enforce automatically
if (!this.evaluatePredicates(query, match)) {
continue;
}
if (postFilter &&
!this.applyPostFilter(postFilter, postFilterParams, captures)) {
continue;
}
if (match.captures.length > 0) {
const firstNode = match.captures[0].node;
const textCaptures = {};
for (const [name, node] of Object.entries(captures)) {
textCaptures[name] = node.text;
}
matches.push({
file: filePath,
line: firstNode.startPosition.row + 1,
column: firstNode.startPosition.column + 1,
matchedText: firstNode.text,
nodeType: firstNode.type,
captures: textCaptures,
});
}
}
if (matches.length > 0) {
this.dbg(`Found ${matches.length} matches in ${path.basename(filePath)}`);
}
}
catch (err) {
this.dbg(`Query matching error: ${err}`);
}
return matches;
}
/** Collect source files for a language */
collectFiles(dir, languageId, fileFilter) {
const files = [];
const extensions = this.getExtensionsForLanguage(languageId);
const rootDir = path.resolve(dir);
const ignoreMatcher = getProjectIgnoreMatcher(rootDir);
// Hard cap on the walk itself (not just result collection). The per-file
// `maxResults` break upstream only stops gathering matches *after* the walk
// has already enumerated the whole tree — so a misrooted structuralSearch
// would still synchronously read every directory. Bound the walk (#262).
const scan = (d) => {
if (files.length >= TREE_SITTER_MAX_SCAN_FILES)
return;
try {
const entries = fs.readdirSync(d, { withFileTypes: true });
for (const entry of entries) {
if (files.length >= TREE_SITTER_MAX_SCAN_FILES)
return;
const full = path.join(d, entry.name);
if (entry.isDirectory()) {
if (isExcludedDirName(entry.name))
continue;
if (ignoreMatcher.isIgnored(full, true))
continue;
scan(full);
}
else if (extensions.some((ext) => entry.name.endsWith(ext))) {
if (ignoreMatcher.isIgnored(full, false))
continue;
if (!fileFilter || fileFilter(full)) {
files.push(full);
}
}
}
}
catch { }
};
scan(rootDir);
return files;
}
/** Get file extensions for a language */
getExtensionsForLanguage(languageId) {
const mapping = {
typescript: [".ts", ".mts", ".cts"],
tsx: [".tsx"],
javascript: [".js", ".mjs", ".cjs"],
python: [".py"],
rust: [".rs"],
go: [".go"],
java: [".java"],
kotlin: [".kt", ".kts"],
dart: [".dart"],
c: [".c", ".h"],
cpp: [".cpp", ".hpp", ".cc", ".hh"],
elixir: [".ex", ".exs"],
ruby: [".rb"],
};
return mapping[languageId] || [];
}
}
// --- Simplified Pattern Search (regex fallback) ---
/**
* Fallback structural search using regex when tree-sitter unavailable
* Less accurate but works without WASM dependencies
*/
export function regexStructuralSearch(pattern, files, options = {}) {
const matches = [];
const maxResults = options.maxResults ?? 50;
// Extract pattern structure for regex
// "console.log($MSG)" -> /console\.log\(([^)]+)\)/
const regexPattern = pattern
.replace(/\\/g, "\\\\")
.replace(/\./g, "\\.")
.replace(/\$\$\$[A-Z_][A-Z0-9_]*/g, "(.*?)") // variadic - non-greedy
.replace(/\$[A-Z_][A-Z0-9_]*/g, "([^,)]+)"); // single - capture group
try {
const regex = new RegExp(regexPattern, "g");
for (const file of files) {
if (matches.length >= maxResults)
break;
try {
const content = fs.readFileSync(file, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
regex.lastIndex = 0;
const match = regex.exec(lines[i]);
if (match) {
const captures = {};
// Extract captures
for (let j = 1; j < match.length; j++) {
captures[`$${j}`] = match[j];
}
matches.push({
file,
line: i + 1,
column: match.index + 1,
matchedText: match[0],
captures,
});
if (matches.length >= maxResults)
break;
}
}
}
catch { }
}
}
catch {
// Invalid regex
}
return matches;
}