UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

432 lines (431 loc) • 18.4 kB
/** * ast-grep NAPI runner for dispatch system * * Uses @ast-grep/napi for programmatic parsing instead of CLI. * Handles TypeScript/JavaScript/CSS/HTML files with YAML rule support. * * Replaces CLI-based runners for faster performance (100x speedup). */ import * as fs from "node:fs"; import * as path from "node:path"; import { loadAstGrepNapi, } from "../../deps/ast-grep-napi.js"; import { getAstGrepRuleSources, } from "../../sgconfig.js"; import { hasEslintConfig } from "../../tool-policy.js"; import { enabledAuxiliaryLspServerIds } from "../auxiliary-lsp.js"; import { classifyDefect } from "../diagnostic-taxonomy.js"; import { PRIORITY } from "../priorities.js"; import { calculateRuleComplexity, isOverlyBroadPattern, isStructuredRule, loadYamlRules, loadYamlRulesFresh, MAX_BLOCKING_RULE_COMPLEXITY, } from "./yaml-rule-parser.js"; // Lazy load the napi package let sg; let sgLoadAttempted = false; export async function loadSg() { if (sg) return sg; if (sgLoadAttempted) return undefined; // Don't retry if already failed sgLoadAttempted = true; try { sg = await loadAstGrepNapi(); return sg; } catch { return undefined; } } // Supported extensions for NAPI const SUPPORTED_EXTS = [".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".htm"]; /** Maximum matches per rule to prevent excessive false positives */ const MAX_MATCHES_PER_RULE = 10; /** Maximum total diagnostics per file to prevent output spam */ const MAX_TOTAL_DIAGNOSTICS = 50; /** * #660: this runner used to skip a hardcoded set of rule ids * (`constructor-super`, `empty-catch`, `long-parameter-list`, * `nested-ternary`, `no-dupe-class-members`) on the assumption that the * tree-sitter query runner (priority 14) already covered them, to avoid * double-reporting. That assumption was false for every entry: three of * them (`nested-ternary`, `long-parameter-list`, `no-dupe-class-members`) * have no active tree-sitter query — their would-be queries either live * under `rules/tree-sitter-queries/typescript-disabled/` (excluded from * loading, see clients/tree-sitter-query-loader.ts) or were never written — * so those three rule ids had ZERO coverage in the NAPI fallback runner * (used when the ast-grep binary isn't installed) despite having a * perfectly good, shipped, active ast-grep rule sitting right there. The * other two (`constructor-super`, `empty-catch`) are disabled everywhere * (ast-grep AND tree-sitter, see rules-disabled/, #206), so skipping them * was already a no-op. The whole skip-set has been removed; if tree-sitter * coverage is ever added back for one of these rule ids, reintroduce a * scoped skip alongside the query that actually covers it — don't recreate * a blanket assumption-based list. * * Note: `no-dupe-class-members` didn't actually fire immediately * post-removal — its rule YAML uses a top-level `utils:` block that this * runner's native-config passthrough dropped entirely, a separate bug * (affecting 5 shipped rules, not just this one) fixed in #663. */ /** * Rules commonly covered by ESLint/Biome correctness checks. * We can suppress these from ast-grep in lint-enabled projects to reduce noise. */ const LINTER_OVERLAP = new Set([ "getter-return", "no-array-constructor", "no-async-promise-executor", "no-await-in-loop", "no-case-declarations", "no-compare-neg-zero", "no-cond-assign", "no-constant-condition", "no-constructor-return", "no-dupe-args", "no-dupe-keys", "no-extra-boolean-cast", "no-new-symbol", "no-new-wrappers", "no-prototype-builtins", ]); const NON_SUPPRESSIBLE = new Set([ "empty-catch", "no-discarded-error", "unchecked-throwing-call", ]); function defaultFixSuggestion(defectClass, ruleId) { if (defectClass === "silent-error") { return "Handle the error path explicitly: log context and rethrow or return a typed error result."; } if (defectClass === "secrets") { return "Remove hardcoded secret material and load values from env/secret manager."; } if (defectClass === "injection") { return "Avoid dynamic execution/interpolation here; use parameterized APIs or strict allowlists."; } if (defectClass === "async-misuse") { return "Make async flow explicit: await consistently and handle rejection/error paths."; } if (ruleId.includes("unsafe") || ruleId.includes("security")) { return "Refactor to a safer API usage with explicit validation and bounded behavior."; } return "Refactor this pattern to the safer equivalent used in the codebase."; } function explicitRuleFixSuggestion(rule) { const raw = (rule.fix ?? rule.note ?? "").trim(); if (!raw) return undefined; const oneLine = raw.replace(/\s+/g, " ").trim(); return oneLine.length > 240 ? `${oneLine.slice(0, 237)}...` : oneLine; } function normalizeRuleId(ruleId) { return ruleId.replace(/-js$/, ""); } export function canHandle(filePath) { return SUPPORTED_EXTS.includes(path.extname(filePath).toLowerCase()); } /** * The TypeScript grammar is a syntactic superset of JavaScript, so a * `JavaScript`-tagged rule using generic node kinds (`variable_declarator`, * `assignment_expression`, …) still matches against a parsed `.ts`/`.tsx` * root — and vice versa isn't an issue since JS files never parse * TS-only syntax, but a `TypeScript`-tagged rule with a plain-JS-compatible * body would equally double-fire alongside a `JavaScript` twin on a `.ts` * file. Without this, `language:` reads as a real filter but isn't one for * ts↔js pairs, so twin rules sharing a base name (e.g. `hardcoded-url` / * `hardcoded-url-js`) both match the same construct in the SAME runner * invocation (#657). Returns undefined for extensions this scoping doesn't * apply to (css/html), where no filtering is added. */ export function ruleLanguageForFile(filePath) { const ext = path.extname(filePath).toLowerCase(); switch (ext) { case ".ts": case ".tsx": return "typescript"; case ".js": case ".jsx": return "javascript"; default: return undefined; } } export function getLang(filePath, sgModule) { const ext = path.extname(filePath).toLowerCase(); switch (ext) { case ".ts": return sgModule.ts; case ".tsx": return sgModule.tsx; case ".js": case ".jsx": return sgModule.js; case ".css": return sgModule.css; case ".html": case ".htm": return sgModule.html; default: return undefined; } } function duplicateRuleIds(rules) { const counts = new Map(); for (const rule of rules) { counts.set(rule.id, (counts.get(rule.id) ?? 0) + 1); } return Array.from(counts) .filter(([, count]) => count > 1) .map(([id]) => id) .sort((a, b) => a.localeCompare(b)); } function appendDuplicateRuleDiagnostics(diagnostics, seenRuleIds, duplicateIds, source, filePath, maxTotalDiagnostics) { const sourceLabel = `${source.origin} ${source.tier} rules`; for (const ruleId of duplicateIds) { diagnostics.push({ id: `ast-grep-napi-config-duplicate-${source.origin}-${source.tier}-${ruleId}`, message: `Duplicate ast-grep rule id "${ruleId}" in ${sourceLabel}`, filePath, line: 1, column: 1, severity: "error", semantic: "blocking", tool: "ast-grep-napi", rule: ruleId, defectClass: "correctness", fixable: false, autoFixAvailable: false, fixSuggestion: `Give every rule in ${sourceLabel} a unique id`, }); seenRuleIds.add(ruleId); if (diagnostics.length >= maxTotalDiagnostics) return true; } return false; } /** * Run the shipped ast-grep YAML ruleset against a parsed file via napi's native * engine, applying the same suppression policy (linter/tree-sitter overlap, * overly-broad-pattern guard) as the per-edit runner. Extracted so the * project-wide scanner can reuse the identical engine + rules WITHOUT the * ast-grep binary — closing the no-binary gap (#308) — while the per-edit runner * keeps its tight budgets. Callers pass the already-parsed `rootNode` so they * control parsing/size gating. */ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}) { const maxMatchesPerRule = options.maxMatchesPerRule ?? MAX_MATCHES_PER_RULE; const maxTotalDiagnostics = options.maxTotalDiagnostics ?? MAX_TOTAL_DIAGNOSTICS; const blockingOnly = options.blockingOnly === true; const log = options.log; const diagnostics = []; const seenRuleIds = new Set(); const suppressLinterOverlap = kind === "jsts" && hasEslintConfig(cwd); const fileLang = ruleLanguageForFile(filePath); // Shared with the raw sgconfig materializer so both surfaces walk the same // workspace-rooted sources in the same precedence order. const ruleSources = getAstGrepRuleSources(options.projectRoot ?? cwd); for (const source of ruleSources) { let rules; try { // Project rules are mutable during a session, so their cache fingerprints // relative paths and contents. Bundled catalogs are immutable per install. const loader = source.origin === "project" ? loadYamlRulesFresh : loadYamlRules; rules = loader(source.dir); } catch { continue; } const duplicates = duplicateRuleIds(rules); if (appendDuplicateRuleDiagnostics(diagnostics, seenRuleIds, duplicates, source, filePath, maxTotalDiagnostics)) { return diagnostics; } const duplicateSet = new Set(duplicates); for (const rule of rules) { if (duplicateSet.has(rule.id)) continue; // Cross-layer collisions keep the first (higher-precedence) source. if (seenRuleIds.has(rule.id)) continue; seenRuleIds.add(rule.id); if (blockingOnly && rule.severity !== "error") continue; if (suppressLinterOverlap && LINTER_OVERLAP.has(normalizeRuleId(rule.id)) && !NON_SUPPRESSIBLE.has(normalizeRuleId(rule.id))) { continue; } // Skip rules whose top-level pattern is overly broad ($NAME, $X, etc.) // without additional structural constraints to narrow matches. if (rule.rule && isOverlyBroadPattern(rule.rule.pattern) && !isStructuredRule(rule)) { continue; } const lang = rule.language?.toLowerCase(); if (lang && lang !== "typescript" && lang !== "javascript") { continue; } // Scope TypeScript/JavaScript-tagged rules to the file's actual // grammar (#657) — otherwise a `-js` twin sharing generic node // kinds with its TS sibling double-fires on every .ts file. if (lang && fileLang && lang !== fileLang) { continue; } if (blockingOnly && rule.rule) { const complexity = calculateRuleComplexity(rule.rule); if (complexity > MAX_BLOCKING_RULE_COMPLEXITY) { continue; } } if (!rule.rule) continue; try { let matches = []; // Delegate matching to napi's native engine, which handles the // full ast-grep rule grammar (pattern, kind, has/inside/follows/ // precedes/stopBy/field/nthChild, any/all/not) plus metavariable // `constraints` (#206) AND top-level `utils` — reusable named // matchers referenced via `matches: <name>` inside `rule` // (#663; `NapiConfig.utils: Record<string, Rule>` per // @ast-grep/napi's types, same shape napi already expects for // `rule`/`constraints`). A faithful js-yaml parse feeds the rule // object straight through. If napi rejects the rule (a malformed // or invalid-kind rule, or an unresolved `matches:` reference), // skip it — never silently match nothing through a partial // interpreter. const nativeConfig = { rule: rule.rule }; if (rule.constraints) nativeConfig.constraints = rule.constraints; if (rule.utils) nativeConfig.utils = rule.utils; try { matches = rootNode.findAll(nativeConfig); } catch (err) { matches = []; log?.(`ast-grep-napi: rule "${rule.id}" rejected by native engine (${err instanceof Error ? err.message : String(err)})`); } const limitedMatches = matches.slice(0, maxMatchesPerRule); for (const match of limitedMatches) { if (diagnostics.length >= maxTotalDiagnostics) break; const node = match; const range = node.range(); const severity = rule.severity === "error" ? "error" : "warning"; const semantic = severity === "error" ? "blocking" : "warning"; const defectClass = classifyDefect(rule.id, "ast-grep-napi", rule.message || rule.id); const ruleFix = explicitRuleFixSuggestion(rule); diagnostics.push({ id: `ast-grep-napi-${range.start.line}-${rule.id}`, message: `[${rule.metadata?.category || "slop"}] ${rule.message || rule.id}`, filePath, line: range.start.line + 1, column: range.start.column + 1, severity, semantic, tool: "ast-grep-napi", rule: rule.id, defectClass, fixable: !!ruleFix, autoFixAvailable: false, fixKind: ruleFix ? "suggestion" : undefined, fixSuggestion: semantic === "blocking" ? (ruleFix ?? defaultFixSuggestion(defectClass, rule.id)) : ruleFix, }); } if (diagnostics.length >= maxTotalDiagnostics) break; } catch { // Rule failed, skip } } } return diagnostics; } // --- Runner Definition --- const astGrepNapiRunner = { id: "ast-grep-napi", appliesTo: ["jsts"], priority: PRIORITY.SPECIALIZED_ANALYSIS, enabledByDefault: true, skipTestFiles: true, async run(ctx) { if (!canHandle(ctx.filePath)) { return { status: "skipped", diagnostics: [], semantic: "none" }; } // #239 Phase 2: the ast-grep LSP supersedes this in-process runner when its // binary is available — same Rust engine, plus codeAction fixes, and it runs // the shipped baseline ruleset via `--config`. Skip here so we don't double- // report against the LSP's `tool: ast-grep` diagnostics. Resume ONLY as the // fallback when the binary is absent / can't spawn (Gate B). const astGrepLspEnabled = enabledAuxiliaryLspServerIds((f) => ctx.pi?.getFlag?.(f)).includes("ast-grep"); if (astGrepLspEnabled && (await ctx.hasTool("ast-grep"))) { return { status: "skipped", diagnostics: [], semantic: "none" }; } const sgModule = await loadSg(); if (!sgModule) { return { status: "skipped", diagnostics: [], semantic: "none" }; } if (!fs.existsSync(ctx.filePath)) { return { status: "skipped", diagnostics: [], semantic: "none" }; } const lang = getLang(ctx.filePath, sgModule); if (!lang) { return { status: "skipped", diagnostics: [], semantic: "none" }; } let stats; try { stats = fs.statSync(ctx.filePath); } catch { return { status: "skipped", diagnostics: [], semantic: "none" }; } if (stats.size > 1024 * 1024) { return { status: "skipped", diagnostics: [], semantic: "none" }; } let content; const contentFromFacts = ctx.facts.getFileFact(ctx.filePath, "file.content"); if (contentFromFacts !== undefined && contentFromFacts !== null) { content = contentFromFacts; } else { try { content = fs.readFileSync(ctx.filePath, "utf-8"); } catch { return { status: "skipped", diagnostics: [], semantic: "none" }; } } let root; try { root = lang.parse(content); } catch { return { status: "skipped", diagnostics: [], semantic: "none" }; } let rootNode; try { rootNode = root.root(); } catch { return { status: "skipped", diagnostics: [], semantic: "none" }; } const diagnostics = evaluateAstGrepRules(ctx.filePath, rootNode, ctx.cwd, ctx.kind, { blockingOnly: ctx.blockingOnly, projectRoot: ctx.projectRoot, log: (message) => ctx.log(message), }); const hasBlocking = diagnostics.some((d) => d.semantic === "blocking"); let semantic = "none"; if (hasBlocking) { semantic = "blocking"; } else if (diagnostics.length > 0) { semantic = "warning"; } return { status: hasBlocking ? "failed" : "succeeded", diagnostics, semantic, }; }, }; export default astGrepNapiRunner;