UNPKG

@bomb.sh/tools

Version:

The internal dev, build, and lint CLI for Bombshell projects

306 lines (305 loc) 10.3 kB
import { getPublicSurface } from "../surface.mjs"; import { ToolsError, local } from "../utils.mjs"; import { parse } from "@bomb.sh/args"; import { fileURLToPath } from "node:url"; import { readFile, rm, writeFile } from "node:fs/promises"; import { x } from "tinyexec"; //#region src/commands/lint.ts const oxlintConfig = fileURLToPath(new URL("../../oxlintrc.json", import.meta.url)); /** * Rules that only apply to the public API surface (see `surface.ts`). * Moved from the global ruleset into `overrides` at runtime. */ const SURFACE_RULES = ["bombshell-dev/exported-function-async", "bombshell-dev/require-export-jsdoc"]; /** * Generate the effective oxlint config for the project at `cwd`. * * Surface-scoped rules are lifted out of the shared config's global ruleset * and re-applied via `overrides` limited to the package's public API surface, * so internal modules aren't held to public-API conventions. Written into the * project root because oxlint resolves `overrides.files` relative to the * config location; deleted after the run. */ async function withEffectiveConfig(run) { const cwd = process.cwd(); const base = JSON.parse(await readFile(oxlintConfig, "utf-8")); delete base.$schema; if (Array.isArray(base.jsPlugins)) base.jsPlugins = base.jsPlugins.map((plugin) => fileURLToPath(new URL(plugin, new URL("../../oxlintrc.json", import.meta.url)))); const surface = await getPublicSurface(new URL(`file://${cwd}/`)); const scoped = {}; for (const rule of SURFACE_RULES) if (base.rules?.[rule] !== void 0) { scoped[rule] = base.rules[rule]; delete base.rules[rule]; } if (surface.length > 0 && Object.keys(scoped).length > 0) base.overrides = [...base.overrides ?? [], { files: surface, rules: scoped }]; const configPath = `${cwd}/.bsh.oxlintrc.json`; await writeFile(configPath, JSON.stringify(base, null, 2)); try { return await run(configPath); } finally { await rm(configPath, { force: true }); } } async function runOxlint(targets, fix) { return withEffectiveConfig(async (config) => { const args = [ "-c", config, "--format=json", ...targets ]; if (fix) args.push("--fix"); const result = await x(local("oxlint"), args, { throwOnError: false }); try { return (JSON.parse(result.stdout).diagnostics ?? []).map((d) => ({ tool: "oxlint", level: d.severity === "error" ? "error" : "warning", code: d.code ?? "unknown", message: d.message, file: d.filename, line: d.labels?.[0]?.span?.line, column: d.labels?.[0]?.span?.column })); } catch { if (result.exitCode !== 0) throw new ToolsError(`oxlint exited with code ${result.exitCode}`, "oxlint-exit"); console.info(result.stdout); return []; } }); } /** * Mechanically fix knip-reported issues. Dependency hygiene (removing unused * deps from package.json) always runs with `--fix`; dead-code fixes (unused * exports/types) only run in `--strict` mode, matching the report tiers. * Unused files are never deleted automatically. */ async function runKnipFix(strict) { const types = strict ? "dependencies,exports,types" : "dependencies"; await x(local("knip"), [ "--fix", "--fix-type", types, "--no-progress" ], { throwOnError: false }); } /** * Knip dead-code issue kinds (unused exports/types/files) fire constantly * mid-implementation — an export is "unused" until its consumer exists. * They only carry signal as a commit-time gate, so they require `--strict`. * Dependency hygiene issues are stable and always reported. */ async function runKnip(options) { const result = await x(local("knip"), [ "--no-progress", "--reporter", "json" ], { throwOnError: false }); if (!result.stdout.trim()) return []; let json; try { json = JSON.parse(result.stdout); } catch { if (result.exitCode !== 0) throw new ToolsError(`knip exited with code ${result.exitCode}`, "knip-exit"); return []; } const violations = []; for (const issue of json.issues) { for (const dep of issue.dependencies ?? []) violations.push({ tool: "knip", level: "warning", code: "unused-dependency", message: `Unused dependency '${dep.name}'`, file: issue.file, line: dep.line, column: dep.col }); for (const dep of issue.devDependencies ?? []) violations.push({ tool: "knip", level: "warning", code: "unused-devDependency", message: `Unused devDependency '${dep.name}'`, file: issue.file, line: dep.line, column: dep.col }); if (!options?.strict) continue; for (const exp of issue.exports ?? []) violations.push({ tool: "knip", level: "warning", code: "unused-export", message: `Unused export '${exp.name}'`, file: issue.file, line: exp.line, column: exp.col }); for (const t of issue.types ?? []) violations.push({ tool: "knip", level: "warning", code: "unused-type", message: `Unused type '${t.name}'`, file: issue.file, line: t.line, column: t.col }); for (const file of issue.files ?? []) violations.push({ tool: "knip", level: "warning", code: "unused-file", message: `Unused file`, file: issue.file, line: file.line, column: file.col }); } return violations; } async function runTypeScript(targets) { const result = await x(local("tsgo"), [ "--noEmit", "--pretty", "false" ], { throwOnError: false }); const output = result.stdout + result.stderr; if (!output.trim()) { if (result.exitCode !== 0) throw new ToolsError(`tsgo exited with code ${result.exitCode}`, "tsgo-exit"); return []; } const violations = []; const re = /^(.+)\((\d+),(\d+)\): (error|warning) (TS\d+): (.+)$/gm; let match; while ((match = re.exec(output)) !== null) violations.push({ tool: "tsc", level: match[4] === "error" ? "error" : "warning", code: match[5], message: match[6], file: match[1], line: Number(match[2]), column: Number(match[3]) }); if (targets.length === 0) return violations; const prefixes = targets.map((t) => t.replace(/^\.\//, "")); return violations.filter((v) => v.file && prefixes.some((p) => v.file.startsWith(p))); } const colors = { error: "\x1B[31m", warning: "\x1B[33m", suggestion: "\x1B[34m", dim: "\x1B[2m", reset: "\x1B[0m" }; function printViolation(v) { const loc = v.line != null ? ` ${v.line}:${v.column ?? 0}` : " -"; const color = colors[v.level]; const tag = `${v.tool}/${v.code}`; console.log(`${colors.dim}${loc.padEnd(10)}${colors.reset}${color}${v.level.padEnd(12)}${colors.reset}${v.message} ${colors.dim}${tag}${colors.reset}`); } function countByLevel(violations) { const counts = { error: 0, warning: 0, suggestion: 0 }; for (const v of violations) counts[v.level]++; return counts; } function printSummary(violations) { const counts = countByLevel(violations); const parts = []; if (counts.error) parts.push(`${colors.error}${counts.error} error${counts.error > 1 ? "s" : ""}${colors.reset}`); if (counts.warning) parts.push(`${colors.warning}${counts.warning} warning${counts.warning > 1 ? "s" : ""}${colors.reset}`); if (counts.suggestion) parts.push(`${colors.suggestion}${counts.suggestion} suggestion${counts.suggestion > 1 ? "s" : ""}${colors.reset}`); console.log(parts.length > 0 ? `\n${parts.join(", ")}` : "\nNo issues found."); } /** * Print violations grouped by file. Errors are always shown in full. * Warnings collapse to a per-rule count unless `warnings` is set — they * don't affect the exit code, so a wall of them buries actual failures. */ function printViolations(violations, options) { const showWarnings = options?.warnings ?? false; const visible = showWarnings ? violations : violations.filter((v) => v.level === "error"); const grouped = /* @__PURE__ */ new Map(); for (const v of visible) { const key = v.file ?? "(project)"; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(v); } for (const [file, items] of grouped) { console.log(`\n${file}`); for (const v of items) printViolation(v); } if (!showWarnings) { const hidden = violations.filter((v) => v.level !== "error"); if (hidden.length > 0) { const byRule = /* @__PURE__ */ new Map(); for (const v of hidden) { const tag = `${v.tool}/${v.code}`; byRule.set(tag, (byRule.get(tag) ?? 0) + 1); } console.log(`\n${colors.dim}${hidden.length} warning${hidden.length > 1 ? "s" : ""} hidden (run with --warnings to show):${colors.reset}`); for (const [tag, count] of [...byRule].sort((a, b) => b[1] - a[1])) console.log(`${colors.dim} ${count} × ${tag}${colors.reset}`); } } printSummary(violations); } /** Machine-readable report for agents and CI. */ function printJson(violations) { console.log(JSON.stringify({ summary: countByLevel(violations), violations }, null, 2)); } async function collectViolations(targets, options) { const explicit = targets.length > 0; const results = await Promise.allSettled([ runOxlint(explicit ? targets : ["."]), runKnip({ strict: options?.strict }), runTypeScript(explicit ? targets : []) ]); const violations = []; let failed = false; for (const result of results) if (result.status === "fulfilled") violations.push(...result.value); else { failed = true; console.error(result.reason); } return { violations, failed }; } async function lint(ctx) { const args = parse(ctx.args, { boolean: [ "fix", "strict", "warnings" ], string: ["format"] }); const targets = args._.map(String); const json = args.format === "json"; const print = (violations) => json ? printJson(violations) : printViolations(violations, { warnings: args.warnings }); if (args.fix) { await runOxlint(targets.length > 0 ? targets : ["."], true); await runKnipFix(args.strict); const { violations: remaining, failed } = await collectViolations(targets, { strict: args.strict }); if (remaining.length > 0) { print(remaining); if (remaining.some((v) => v.level === "error") || failed) process.exit(1); return; } if (failed) process.exit(1); if (!json) console.log("No issues found."); return; } const { violations, failed } = await collectViolations(targets, { strict: args.strict }); print(violations); if (violations.some((v) => v.level === "error") || failed) process.exit(1); } //#endregion export { lint, printJson, printViolations, runKnip, runKnipFix, runOxlint }; //# sourceMappingURL=lint.mjs.map