UNPKG

pi-lens

Version:

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

277 lines (276 loc) • 10.5 kB
import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { load as loadYaml } from "./deps/js-yaml.js"; import { resolvePackagePath } from "./package-root.js"; import { walkUpDirs } from "./path-utils.js"; // ast-grep's root config marker. The `ast-grep lsp` server is workspace-gated: // it only operates in a project that has an `sgconfig.y[a]ml` at (or above) the // file. These are the names ast-grep itself looks for as root markers. export const SGCONFIG_NAMES = ["sgconfig.yml", "sgconfig.yaml"]; /** Nearest `sgconfig.y[a]ml` walking up from `startDir`, or undefined if none. */ export function findLocalSgconfig(startDir) { for (const dir of walkUpDirs(startDir || process.cwd())) { for (const name of SGCONFIG_NAMES) { const candidate = path.join(dir, name); if (fs.existsSync(candidate)) return candidate; } } return undefined; } function canonicalDir(dir) { try { return fs.realpathSync.native(dir); } catch { return path.resolve(dir); } } /** * Rule sources in the precedence order shared by raw ast-grep/LSP and NAPI: * project primary, project secondary, bundled primary, bundled secondary. */ export function getAstGrepRuleSources(projectRoot = process.cwd()) { const root = path.resolve(projectRoot || process.cwd()); const packageRoot = resolvePackagePath(import.meta.url); const candidates = []; if (canonicalDir(root) !== canonicalDir(packageRoot)) { candidates.push({ dir: path.join(root, "rules", "ast-grep-rules", "rules"), origin: "project", tier: "primary", }, { dir: path.join(root, "rules", "ast-grep-rules", "coderabbit", "rules"), origin: "project", tier: "secondary", }); } candidates.push({ dir: path.join(packageRoot, "rules", "ast-grep-rules", "rules"), origin: "bundled", tier: "primary", }, { dir: path.join(packageRoot, "rules", "ast-grep-rules", "coderabbit", "rules"), origin: "bundled", tier: "secondary", }); const seen = new Set(); return candidates.filter((source) => { if (!fs.existsSync(source.dir)) return false; const key = canonicalDir(source.dir); if (seen.has(key)) return false; seen.add(key); return true; }); } /** Compatibility export retained for callers that only need ordered dirs. */ export function shippedRuleDirsInPrecedenceOrder(projectRoot = process.cwd()) { return getAstGrepRuleSources(projectRoot).map((source) => source.dir); } function findYamlFiles(dir) { const files = []; let entries; try { entries = fs .readdirSync(dir, { withFileTypes: true }) .sort((a, b) => a.name.localeCompare(b.name)); } catch { return files; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...findYamlFiles(full)); } else if (entry.isFile() && /\.ya?ml$/i.test(entry.name)) { files.push(full); } } return files; } function parseRuleId(raw) { const scalar = raw.match(/^id:\s*(.*?)\s*$/m)?.[1]; if (!scalar) return undefined; const withoutComment = scalar.replace(/\s+#.*$/, "").trim(); const unquoted = (withoutComment.startsWith('"') && withoutComment.endsWith('"')) || (withoutComment.startsWith("'") && withoutComment.endsWith("'")) ? withoutComment.slice(1, -1) : withoutComment; if (/^[A-Za-z0-9_.:-]+$/.test(unquoted)) return unquoted; try { const parsed = loadYaml(raw); if (!parsed || typeof parsed !== "object" || !("id" in parsed)) { return undefined; } const id = parsed.id; return typeof id === "string" ? id : undefined; } catch { return undefined; } } function parseRuleDocuments(content) { return content .split(/^---\s*$/m) .filter((document) => document.trim()) .map((raw) => ({ raw, id: parseRuleId(raw) })); } const bundledSnapshots = new Map(); function snapshotRuleSource(source) { const cacheKey = canonicalDir(source.dir); if (source.origin === "bundled") { const cached = bundledSnapshots.get(cacheKey); if (cached) return cached; } const hash = createHash("sha256"); const files = []; for (const file of findYamlFiles(source.dir)) { const relativePath = path.relative(source.dir, file); hash.update(relativePath); hash.update("\0"); // Guarded read (mirrors loadYamlRuleFiles in yaml-rule-parser.ts): a rule // file deleted/renamed between the directory walk and this read (editor // churn mid-session) must degrade to "skip this file", not throw — // resolveBaselineSgconfig sits on the ast-grep LSP spawn path. The // "missing" sentinel still perturbs the digest so the cache re-resolves // once the file is readable again. let content; try { content = fs.readFileSync(file, "utf8"); } catch { hash.update("missing"); hash.update("\0"); continue; } hash.update(content); hash.update("\0"); files.push({ file, relativePath, documents: parseRuleDocuments(content) }); } const snapshot = { ...source, files, digest: hash.digest("hex") }; if (source.origin === "bundled") bundledSnapshots.set(cacheKey, snapshot); return snapshot; } function sourceFingerprint(sources) { const hash = createHash("sha256"); for (const source of sources) { hash.update(source.origin); hash.update("\0"); hash.update(source.tier); hash.update("\0"); hash.update(canonicalDir(source.dir)); hash.update("\0"); hash.update(source.digest); hash.update("\0"); } return hash.digest("hex"); } function materializeMergedRuleDir(sources, mergedDir) { fs.rmSync(mergedDir, { recursive: true, force: true }); fs.mkdirSync(mergedDir, { recursive: true }); const claimedByEarlierSource = new Set(); for (const [sourceIndex, source] of sources.entries()) { const claimedThisSource = new Set(); for (const [fileIndex, file] of source.files.entries()) { const survivingDocuments = file.documents.filter((document) => { if (!document.id) return true; if (claimedByEarlierSource.has(document.id)) return false; claimedThisSource.add(document.id); return true; }); if (survivingDocuments.length === 0) continue; // Keep files flat for ast-grep's ruleDirs loader while retaining stable, // collision-free names for nested catalogs and duplicate basenames. const destination = path.join(mergedDir, `source${sourceIndex}-${String(fileIndex).padStart(5, "0")}.yml`); if (survivingDocuments.length === file.documents.length) { fs.copyFileSync(file.file, destination); } else { fs.writeFileSync(destination, `${survivingDocuments .map((document) => document.raw.trim()) .join("\n---\n")}\n`); } } for (const id of claimedThisSource) claimedByEarlierSource.add(id); } } const cachedBaselines = new Map(); function rootArtifactKey(root) { return createHash("sha256").update(root).digest("hex").slice(0, 12); } /** * Synthesize a per-workspace config whose merged rule directory applies the * same project-first, source-layer precedence as the NAPI fallback. */ export function resolveBaselineSgconfig(projectRoot = process.cwd()) { const root = canonicalDir(path.resolve(projectRoot || process.cwd())); const sources = getAstGrepRuleSources(root).map(snapshotRuleSource); if (sources.length === 0) return undefined; const fingerprint = sourceFingerprint(sources); const cached = cachedBaselines.get(root); if (cached?.fingerprint === fingerprint && fs.existsSync(cached.path) && fs.existsSync(cached.mergedDir)) { return cached.path; } const dir = path.join(os.tmpdir(), "pi-lens-ast-grep"); fs.mkdirSync(dir, { recursive: true }); const artifactKey = rootArtifactKey(root); const file = path.join(dir, `baseline-${process.pid}-${artifactKey}.sgconfig.yml`); const mergedDir = path.join(dir, `baseline-${process.pid}-${artifactKey}.rules`); const protectedArtifacts = new Set([file, mergedDir]); for (const baseline of cachedBaselines.values()) { protectedArtifacts.add(baseline.path); protectedArtifacts.add(baseline.mergedDir); } cleanupStaleBaselines(dir, protectedArtifacts); materializeMergedRuleDir(sources, mergedDir); const ruleDirForYaml = mergedDir.split(path.sep).join("/"); fs.writeFileSync(file, `ruleDirs:\n - ${JSON.stringify(ruleDirForYaml)}\n`); cachedBaselines.set(root, { fingerprint, path: file, mergedDir }); return file; } function cleanupStaleBaselines(dir, keep) { try { const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; for (const name of fs.readdirSync(dir)) { const isConfig = /^baseline(?:-\d+(?:-[a-f0-9]+)?)?\.sgconfig\.yml$/.test(name); const isMergedDir = /^baseline-\d+(?:-[a-f0-9]+)?\.rules$/.test(name); if (!isConfig && !isMergedDir) continue; const full = path.join(dir, name); if (keep.has(full)) continue; try { if (fs.statSync(full).mtimeMs < cutoff) { fs.rmSync(full, { recursive: isMergedDir, force: true }); } } catch { // Racing another session; leave its artifact alone. } } } catch { // Missing/unreadable temp directory; nothing to clean. } } /** Test-only: reset memoized generated configs and mutable snapshots. */ export function _resetBaselineSgconfigForTests() { cachedBaselines.clear(); bundledSnapshots.clear(); }