@bomb.sh/tools
Version:
The internal dev, build, and lint CLI for Bombshell projects
306 lines (305 loc) • 10.3 kB
JavaScript
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