pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
638 lines (637 loc) • 26.4 kB
JavaScript
/**
* Tree-sitter Structural Analysis Runner
*
* Executes all loaded tree-sitter query files from rules/tree-sitter-queries/
* for fast AST-based pattern matching.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { RuleCache } from "../../cache/rule-cache.js";
import { isTestFile } from "../../file-utils.js";
import { resolvePackagePath } from "../../package-root.js";
import { buildOrUpdateGraph, computeImpactCascade, recordEntitySnapshotDiff, } from "../../review-graph/service.js";
import { getSharedTreeSitterClient, isTreeSitterWasmAborted, markTreeSitterWasmAborted, resolveTreeSitterLanguage, } from "../../tree-sitter-shared.js";
import { logTreeSitter } from "../../tree-sitter-logger.js";
import { isDisabledQueryFilePath, queryLoader, } from "../../tree-sitter-query-loader.js";
import { classifyDefect } from "../diagnostic-taxonomy.js";
import { PRIORITY } from "../priorities.js";
const blastCooldownByFile = new Map();
const BLAST_COOLDOWN_MS = 5_000;
function runBlastRadiusInBackground(cwd, filePath, languageId, facts) {
// Fire-and-forget: graph construction is expensive (~3s) and is enrichment-only.
// Running it in background keeps the dispatch result fast.
void (async () => {
try {
const graph = await buildOrUpdateGraph(cwd, [filePath], facts);
const impact = computeImpactCascade(graph, filePath);
logTreeSitter({
phase: "blast_radius",
filePath,
languageId,
metadata: {
changedSymbols: impact.changedSymbols,
neighborFiles: impact.neighborFiles,
directImporters: impact.directImporters,
directCallers: impact.directCallers,
riskFlags: impact.riskFlags,
},
});
}
catch {
/* best-effort enrichment */
}
})();
}
// Queries that are identical across TypeScript and JavaScript grammars.
const JSTS_SHARED_ENTITY_QUERIES = [
{
id: "entity-jsts-function",
kind: "function",
query: "(function_declaration name: (identifier) @NAME)",
},
{
id: "entity-jsts-method",
kind: "method",
query: "(method_definition name: (property_identifier) @NAME)",
},
{
id: "entity-jsts-arrow",
kind: "function",
query: "(lexical_declaration (variable_declarator name: (identifier) @NAME value: [(arrow_function) (function_expression)]))",
},
];
// TypeScript-only structural types — no JS equivalents in the grammar.
const TS_STRUCTURAL_ENTITY_QUERIES = [
{
id: "entity-ts-interface",
kind: "interface",
query: "(interface_declaration name: (type_identifier) @NAME)",
},
{
id: "entity-ts-type",
kind: "type",
query: "(type_alias_declaration name: (type_identifier) @NAME)",
},
{
id: "entity-ts-enum",
kind: "enum",
query: "(enum_declaration name: (identifier) @NAME)",
},
];
const ENTITY_QUERIES = {
typescript: [
// class name node differs between TS (type_identifier) and JS (identifier)
{
id: "entity-ts-class",
kind: "class",
query: "(class_declaration name: (type_identifier) @NAME)",
},
...JSTS_SHARED_ENTITY_QUERIES,
...TS_STRUCTURAL_ENTITY_QUERIES,
],
javascript: [
{
id: "entity-js-class",
kind: "class",
query: "(class_declaration name: (identifier) @NAME)",
},
...JSTS_SHARED_ENTITY_QUERIES,
],
python: [
{
id: "entity-py-function",
kind: "function",
query: "(function_definition name: (identifier) @NAME)",
},
{
id: "entity-py-class",
kind: "class",
query: "(class_definition name: (identifier) @NAME)",
},
],
go: [
{
id: "entity-go-function",
kind: "function",
query: "(function_declaration name: (identifier) @NAME)",
},
{
id: "entity-go-method",
kind: "method",
query: "(method_declaration name: (field_identifier) @NAME)",
},
{
id: "entity-go-type",
kind: "type",
query: "(type_spec name: (type_identifier) @NAME)",
},
],
rust: [
{
id: "entity-rs-function",
kind: "function",
query: "(function_item name: (identifier) @NAME)",
},
{
id: "entity-rs-struct",
kind: "struct",
query: "(struct_item name: (type_identifier) @NAME)",
},
{
id: "entity-rs-enum",
kind: "enum",
query: "(enum_item name: (type_identifier) @NAME)",
},
// trait changes break all implementors — critical for blast-radius
{
id: "entity-rs-trait",
kind: "trait",
query: "(trait_item name: (type_identifier) @NAME)",
},
{
id: "entity-rs-type",
kind: "type",
query: "(type_item name: (type_identifier) @NAME)",
},
],
ruby: [
{
id: "entity-rb-method",
kind: "method",
query: "(method name: (identifier) @NAME)",
},
// singleton methods (def self.foo) — class-level, miss changes without this
{
id: "entity-rb-singleton-method",
kind: "method",
query: "(singleton_method name: (identifier) @NAME)",
},
{
id: "entity-rb-class",
kind: "class",
query: "(class name: (constant) @NAME)",
},
{
id: "entity-rb-module",
kind: "module",
query: "(module name: (constant) @NAME)",
},
],
c: [
{
id: "entity-c-function",
kind: "function",
query: "(function_definition declarator: (function_declarator declarator: (identifier) @NAME))",
},
{
id: "entity-c-typedef",
kind: "type",
query: "(type_definition declarator: (type_identifier) @NAME)",
},
{
id: "entity-c-struct",
kind: "struct",
query: "(struct_specifier name: (type_identifier) @NAME)",
},
{
id: "entity-c-enum",
kind: "enum",
query: "(enum_specifier name: (type_identifier) @NAME)",
},
],
cpp: [
{
id: "entity-cpp-function",
kind: "function",
query: "(function_definition declarator: (function_declarator declarator: (identifier) @NAME))",
},
{
id: "entity-cpp-class",
kind: "class",
query: "(class_specifier name: (type_identifier) @NAME)",
},
{
id: "entity-cpp-struct",
kind: "struct",
query: "(struct_specifier name: (type_identifier) @NAME)",
},
{
id: "entity-cpp-namespace",
kind: "namespace",
query: "(namespace_definition name: (namespace_identifier) @NAME)",
},
],
};
async function extractEntitySnapshot(client, filePath, languageId) {
const defs = ENTITY_QUERIES[languageId] ?? [];
const snapshot = new Map();
for (const def of defs) {
const matches = await client.runQueryOnFile({
id: def.id,
name: def.id,
severity: "info",
category: "entity",
language: languageId,
message: "",
query: def.query,
metavars: ["NAME"],
has_fix: false,
filePath: "",
}, filePath, languageId, { maxResults: 200 });
for (const match of matches) {
const name = match.captures.NAME?.trim();
if (!name)
continue;
const key = `${def.kind}:${name}`;
snapshot.set(key, `${match.line}:${match.matchedText.slice(0, 400)}`);
}
}
return snapshot;
}
const SILENT_ERROR_QUERY_IDS = new Set([
"empty-catch",
"python-empty-except",
"ruby-empty-rescue",
"go-bare-error",
"no-discarded-error",
]);
function defaultFixSuggestion(defectClass, ruleId) {
if (defectClass === "silent-error") {
return "Handle the error path explicitly: add logging/telemetry and rethrow or return a typed error result.";
}
if (defectClass === "secrets") {
return "Move secret material to environment/secret manager and read it at runtime.";
}
if (defectClass === "injection") {
return "Replace dynamic execution/string interpolation with parameterized or allowlisted operations.";
}
if (defectClass === "async-misuse") {
return "Restructure async flow to handle errors and sequencing deterministically (await/try-catch or explicit concurrency control).";
}
if (ruleId.includes("unwrap")) {
return "Replace unwrap() with explicit error handling (match/if-let) or propagate with ?.";
}
return "Refactor this pattern to a safer, explicit form matching project conventions.";
}
function isLineInModifiedRanges(line, ranges) {
if (!ranges || ranges.length === 0)
return true;
return ranges.some((r) => line >= r.start && line <= r.end);
}
/**
* Calculate total lines changed in modified ranges.
* Used to skip expensive entity extraction for trivial changes.
*/
function getTotalLinesChanged(ranges) {
if (!ranges || ranges.length === 0)
return 0;
return ranges.reduce((total, r) => total + (r.end - r.start + 1), 0);
}
/** Threshold: skip entity extraction for changes under 5 lines */
const ENTITY_EXTRACTION_LINE_THRESHOLD = 5;
const treeSitterRunner = {
id: "tree-sitter",
appliesTo: ["jsts", "python", "go", "rust", "ruby", "cxx", "csharp", "php", "css"],
priority: PRIORITY.STRUCTURAL_ANALYSIS,
enabledByDefault: true,
skipTestFiles: false, // Run on test files too (structural issues matter there)
async run(ctx) {
// Use singleton client — WASM must never be re-initialized after first call
const client = getSharedTreeSitterClient();
logTreeSitter({ phase: "runner_start", filePath: ctx.filePath });
if (!client || !client.isAvailable()) {
logTreeSitter({
phase: "runner_skip",
filePath: ctx.filePath,
reason: isTreeSitterWasmAborted() ? "wasm_aborted" : "client_unavailable",
status: "skipped",
});
return { status: "skipped", diagnostics: [], semantic: "none" };
}
const initialized = await client.init();
if (!initialized) {
logTreeSitter({
phase: "runner_skip",
filePath: ctx.filePath,
reason: "client_init_failed",
status: "skipped",
});
return { status: "skipped", diagnostics: [], semantic: "none" };
}
// Determine language from file extension
const filePath = ctx.filePath;
const ext = path.extname(filePath).toLowerCase();
const languageId = resolveTreeSitterLanguage(filePath);
if (!languageId) {
logTreeSitter({
phase: "runner_skip",
filePath: ctx.filePath,
reason: `unsupported_extension:${ext}`,
status: "skipped",
});
return { status: "skipped", diagnostics: [], semantic: "none" };
}
// Try cache first, fall back to loading from disk
let languageQueries = [];
const cache = new RuleCache(languageId, ctx.cwd);
// Get all rule files for this language — project-local AND pi-lens built-ins.
// Both sets must be in the hash so the cache is invalidated when either changes.
const rulesDir = path.join(ctx.cwd, "rules", "tree-sitter-queries", languageId);
const builtinRulesDir = resolvePackagePath(import.meta.url, "rules", "tree-sitter-queries", languageId);
const ruleFileSet = new Set();
for (const dir of [rulesDir, builtinRulesDir]) {
if (fs.existsSync(dir)) {
for (const f of fs.readdirSync(dir)) {
if (f.endsWith(".yml"))
ruleFileSet.add(path.join(dir, f));
}
}
}
const ruleFiles = [...ruleFileSet];
// Try cache
const cached = cache.get(ruleFiles);
let cacheHit = false;
if (cached) {
// Use cached queries
cacheHit = true;
languageQueries = cached.queries
.map((q) => ({
...q,
has_fix: q.has_fix ?? false,
filePath: q.filePath ?? "",
}))
.filter((q) => !isDisabledQueryFilePath(q.filePath));
}
else {
// Load from disk
await queryLoader.loadQueries(ctx.cwd);
languageQueries = queryLoader.getQueriesForLanguage(languageId);
if (languageId === "javascript") {
// JavaScript files also match TypeScript rules (shared grammar)
const tsQueries = queryLoader.getQueriesForLanguage("typescript");
languageQueries = [...languageQueries, ...tsQueries];
}
// Save to cache
cache.set(ruleFiles, languageQueries.map((q) => ({
id: q.id,
name: q.name,
severity: q.severity,
language: q.language,
message: q.message,
query: q.query,
metavars: q.metavars,
post_filter: q.post_filter,
post_filter_params: q.post_filter_params,
defect_class: q.defect_class,
inline_tier: q.inline_tier,
has_fix: q.has_fix,
filePath: q.filePath,
})));
}
if (languageQueries.length === 0) {
logTreeSitter({
phase: "runner_complete",
filePath,
languageId,
status: "succeeded",
diagnostics: 0,
blocking: 0,
queryCount: 0,
effectiveQueryCount: 0,
});
return { status: "succeeded", diagnostics: [], semantic: "none" };
}
// Run all queries regardless of blockingOnly — warning-tier results are logged
// for diagnostic history but filtered from agent output by the dispatcher.
// Only skip "review" tier queries on write (too noisy / expensive).
// Per-rule test-file carve-out (#440): rules that are noise in tests (e.g.
// python-assert-production — `assert` is the idiomatic test assertion) opt
// out via `skip_test_files` while the runner otherwise runs on test files.
const fileIsTest = isTestFile(filePath);
const effectiveQueries = (ctx.blockingOnly
? languageQueries.filter((q) => q.inline_tier !== "review")
: languageQueries).filter((q) => !(fileIsTest && q.skip_test_files));
logTreeSitter({
phase: "queries_loaded",
filePath,
languageId,
queryCount: languageQueries.length,
effectiveQueryCount: effectiveQueries.length,
cacheHit,
metadata: { blockingOnly: !!ctx.blockingOnly },
});
const contentFromFacts = ctx.facts.getFileFact(filePath, "file.content");
const contentOverride = contentFromFacts !== undefined && contentFromFacts !== null
? contentFromFacts
: undefined;
// Run queries in parallel with concurrency limit for optimal performance
const CONCURRENCY_LIMIT = 6;
const queryResults = [];
for (let i = 0; i < effectiveQueries.length; i += CONCURRENCY_LIMIT) {
// Yield the event loop between batches so already-resolved promises from
// other parallel groups (LSP, eslint skip, etc.) can drain. Each wasm query
// batch is synchronous CPU work that would otherwise pin the event loop for
// the full runner duration, making other runners' latency measurements wrong.
if (i > 0)
await new Promise((r) => setImmediate(r));
const batch = effectiveQueries.slice(i, i + CONCURRENCY_LIMIT);
const batchResults = await Promise.all(batch.map(async (query) => {
const queryDiagnostics = [];
try {
const matches = await client.runQueryOnFile(query, filePath, languageId, { maxResults: 10 }, contentOverride);
for (const match of matches) {
// Get line/column from match (already 0-indexed from tree-sitter)
const line = match.line;
const column = match.column;
// Modified-ranges gate only applies to blocking-tier diagnostics.
// Warning-tier diagnostics always flow through for logging.
const isSeverityBlocking = query.severity === "error" ||
query.inline_tier === "blocking" ||
SILENT_ERROR_QUERY_IDS.has(query.id);
if (ctx.blockingOnly &&
isSeverityBlocking &&
!isLineInModifiedRanges(line + 1, ctx.modifiedRanges)) {
continue;
}
// Map severity to semantic
const semantic = query.severity === "error"
? "blocking"
: query.severity === "warning"
? "warning"
: "none";
const defectClass = query.defect_class ??
classifyDefect(query.id, "tree-sitter", query.message);
const suggestion = query.has_fix && query.fix_action
? `${query.fix_action} this statement`
: semantic === "blocking"
? defaultFixSuggestion(defectClass, query.id)
: undefined;
const hasSuggestedFix = !!query.has_fix;
queryDiagnostics.push({
id: `tree-sitter:${query.id}:${line}`,
message: query.message.replace(/\{\{(\w+)\}\}/g, (_, name) => match.captures[name]?.trim() ?? `{{${name}}}`),
filePath,
line: line + 1, // 1-indexed
column: column + 1, // 1-indexed
severity: query.severity,
semantic,
tool: "tree-sitter",
rule: query.id,
defectClass,
// Surface fix intent to agent — tree-sitter never auto-applies;
// linters (biome/ruff/eslint) own the autofix phase.
fixable: hasSuggestedFix,
autoFixAvailable: false,
fixKind: hasSuggestedFix ? "suggestion" : undefined,
fixSuggestion: suggestion,
matchedText: match.matchedText || undefined,
astNodeType: match.nodeType || undefined,
});
}
}
catch (err) {
// pi-lens-ignore: missing-error-propagation — per-query resilience loop, intentional
const msg = err instanceof Error ? err.message : String(err);
// Emscripten abort() corrupts the entire module-level wasm heap.
// Poison the singleton so no further queries attempt to use the dead runtime.
if (msg.includes("Aborted") || msg.includes("abort()")) {
markTreeSitterWasmAborted();
logTreeSitter({
phase: "query_error",
filePath,
languageId,
queryId: query.id,
error: "wasm_aborted_fatal",
});
}
else {
console.error(`[tree-sitter] Query ${query.id} failed:`, err);
logTreeSitter({
phase: "query_error",
filePath,
languageId,
queryId: query.id,
error: msg,
});
}
}
return queryDiagnostics;
}));
queryResults.push(...batchResults);
}
// Flatten all query results into final diagnostics array
const diagnostics = queryResults.flat();
// Skip expensive entity extraction for trivial changes (< 5 lines)
// This avoids ~500-800ms overhead for small edits like single-line fixes
const totalLinesChanged = getTotalLinesChanged(ctx.modifiedRanges);
const skipEntityExtraction = totalLinesChanged < ENTITY_EXTRACTION_LINE_THRESHOLD;
if (diagnostics.length === 0 && !skipEntityExtraction) {
try {
const snapshot = await extractEntitySnapshot(client, filePath, languageId);
const diff = recordEntitySnapshotDiff(ctx.facts, filePath, snapshot);
const changedEntityKeys = [
...diff.added,
...diff.modified,
...diff.removed,
];
if (changedEntityKeys.length > 0) {
logTreeSitter({
phase: "entity_diff",
filePath,
languageId,
metadata: {
added: diff.added,
modified: diff.modified,
removed: diff.removed,
totalChanged: changedEntityKeys.length,
},
});
const lastBlast = blastCooldownByFile.get(filePath) ?? 0;
if (Date.now() - lastBlast < BLAST_COOLDOWN_MS) {
logTreeSitter({
phase: "blast_radius",
filePath,
languageId,
metadata: { skipped: "cooldown", cooldownMs: BLAST_COOLDOWN_MS },
});
}
else {
blastCooldownByFile.set(filePath, Date.now());
runBlastRadiusInBackground(ctx.cwd, filePath, languageId, ctx.facts);
}
}
}
catch {
/* entity snapshot / blast-radius enrichment is best-effort */
}
logTreeSitter({
phase: "runner_complete",
filePath,
languageId,
status: "succeeded",
diagnostics: 0,
blocking: 0,
queryCount: languageQueries.length,
effectiveQueryCount: effectiveQueries.length,
});
return { status: "succeeded", diagnostics: [], semantic: "none" };
}
// Check if any blocking issues
const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
const blockingCount = diagnostics.filter((d) => d.semantic === "blocking").length;
try {
const snapshot = await extractEntitySnapshot(client, filePath, languageId);
const diff = recordEntitySnapshotDiff(ctx.facts, filePath, snapshot);
const changedEntityKeys = [
...diff.added,
...diff.modified,
...diff.removed,
];
if (changedEntityKeys.length > 0) {
logTreeSitter({
phase: "entity_diff",
filePath,
languageId,
metadata: {
added: diff.added,
modified: diff.modified,
removed: diff.removed,
totalChanged: changedEntityKeys.length,
},
});
const lastBlast = blastCooldownByFile.get(filePath) ?? 0;
if (Date.now() - lastBlast < BLAST_COOLDOWN_MS) {
logTreeSitter({
phase: "blast_radius",
filePath,
languageId,
metadata: { skipped: "cooldown", cooldownMs: BLAST_COOLDOWN_MS },
});
}
else {
blastCooldownByFile.set(filePath, Date.now());
runBlastRadiusInBackground(ctx.cwd, filePath, languageId, ctx.facts);
}
}
}
catch {
// best-effort experimental telemetry only
}
logTreeSitter({
phase: "runner_complete",
filePath,
languageId,
status: hasBlocking ? "failed" : "succeeded",
diagnostics: diagnostics.length,
blocking: blockingCount,
queryCount: languageQueries.length,
effectiveQueryCount: effectiveQueries.length,
});
return {
status: hasBlocking ? "failed" : "succeeded",
diagnostics,
semantic: hasBlocking ? "blocking" : "warning",
};
},
};
export default treeSitterRunner;