pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
432 lines (431 loc) • 18.4 kB
JavaScript
/**
* 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;