Skip to content

Commit c276677

Browse files
authored
feat(lint): tier knip dead-code behind --strict, restructure output (#51)
- errors in full, warnings collapsed to per-rule counts (--warnings to show) - --format json machine-readable report - knip unused-export/type/file require --strict; dep hygiene always runs - fix tsgo silently checking nothing (TS5112): always project mode, explicit targets filter the report after the fact - default oxlint target widened from ./src to project-wide - disable unicorn/consistent-function-scoping and no-underscore-dangle - --fix exits non-zero only on remaining errors
1 parent 74356f7 commit c276677

6 files changed

Lines changed: 166 additions & 53 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@bomb.sh/tools': minor
3+
---
4+
5+
Restructures `bsh lint` output and tiers knip's dead-code checks behind `--strict`
6+
7+
- Errors are now always shown in full; warnings collapse to a per-rule count (pass `--warnings` to see them). Warnings never affect the exit code, so this keeps actual failures visible.
8+
- Adds `--format json` for a machine-readable report (`{ summary, violations }`). Use `pnpm -s run lint -- --format json` to keep stdout clean.
9+
- Knip's dead-code issues (unused exports, types, and files) now only run with `--strict` — they fire constantly mid-implementation and are only meaningful as a commit-time gate. Dependency hygiene issues (unused dependencies/devDependencies) still always run.
10+
- Fixes `tsgo` silently type-checking nothing: file arguments made it skip the project `tsconfig` (TS5112), so default runs reported zero type errors. Type checking now always runs in project mode and explicit targets filter the report instead. This may surface previously hidden type errors.
11+
- Widens the default oxlint target from `./src` to the whole project (gitignored paths like `dist/` are respected), matching the scope of knip and tsgo.
12+
- Turns off `unicorn/consistent-function-scoping` and `no-underscore-dangle`, which fired frequently but were never deliberately enabled.
13+
- `--fix` now only exits non-zero when errors remain (previously any warning failed the run).

oxlintrc.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
"import/no-default-export": "error",
4545
"bombshell-dev/no-generic-error": "error",
4646
"bombshell-dev/require-export-jsdoc": "warn",
47-
"bombshell-dev/exported-function-async": "warn"
47+
"bombshell-dev/exported-function-async": "warn",
48+
49+
"unicorn/consistent-function-scoping": "off",
50+
"no-underscore-dangle": "off"
4851
}
4952
}

src/commands/__snapshots__/lint.test.ts.snap

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,29 @@ exports[`lint command > runKnip > detects unused exports and unused files 1`] =
2323
]
2424
`;
2525

26+
exports[`lint command > runKnip > reports dead code only in strict mode 1`] = `
27+
[
28+
{
29+
"code": "unused-file",
30+
"column": undefined,
31+
"file": "src/unused-file.ts",
32+
"level": "warning",
33+
"line": undefined,
34+
"message": "Unused file",
35+
"tool": "knip",
36+
},
37+
{
38+
"code": "unused-export",
39+
"column": 20,
40+
"file": "src/used.ts",
41+
"level": "warning",
42+
"line": 3,
43+
"message": "Unused export 'unusedExport'",
44+
"tool": "knip",
45+
},
46+
]
47+
`;
48+
2649
exports[`lint command > runOxlint > detects violations in bad code 1`] = `
2750
[
2851
{

src/commands/lint.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ describe('lint command', () => {
8686
});
8787

8888
describe('runKnip', () => {
89-
it('detects unused exports and unused files', async () => {
90-
fixture = await createFixture({
89+
const knipFixture = () =>
90+
createFixture({
9191
'package.json': {
9292
name: 'test-pkg',
9393
version: '1.0.0',
@@ -109,12 +109,24 @@ describe('lint command', () => {
109109
`,
110110
},
111111
});
112+
113+
it('reports dead code only in strict mode', async () => {
114+
fixture = await knipFixture();
112115
process.chdir(fileURLToPath(fixture.root));
113116

114-
const violations = await runKnip();
117+
const violations = await runKnip({ strict: true });
115118

116119
expect(violations).not.toEqual([]);
117120
expect(violations).toMatchSnapshot();
118121
});
122+
123+
it('filters dead-code issues by default', async () => {
124+
fixture = await knipFixture();
125+
process.chdir(fileURLToPath(fixture.root));
126+
127+
const violations = await runKnip();
128+
129+
expect(violations).toEqual([]);
130+
});
119131
});
120132
});

src/commands/lint.ts

Lines changed: 110 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,13 @@ export async function runOxlint(targets: string[], fix?: boolean): Promise<Viola
108108
});
109109
}
110110

111-
export async function runKnip(): Promise<Violation[]> {
111+
/**
112+
* Knip dead-code issue kinds (unused exports/types/files) fire constantly
113+
* mid-implementation — an export is "unused" until its consumer exists.
114+
* They only carry signal as a commit-time gate, so they require `--strict`.
115+
* Dependency hygiene issues are stable and always reported.
116+
*/
117+
export async function runKnip(options?: { strict?: boolean }): Promise<Violation[]> {
112118
const args = ['--no-progress', '--reporter', 'json'];
113119
const result = await x(local('knip'), args, { throwOnError: false });
114120
if (!result.stdout.trim()) return [];
@@ -139,6 +145,7 @@ export async function runKnip(): Promise<Violation[]> {
139145
column: dep.col,
140146
});
141147
}
148+
if (!options?.strict) continue;
142149
for (const exp of issue.exports ?? []) {
143150
violations.push({
144151
tool: 'knip',
@@ -178,11 +185,12 @@ export async function runKnip(): Promise<Violation[]> {
178185
}
179186

180187
async function runTypeScript(targets: string[]): Promise<Violation[]> {
181-
const args =
182-
targets.length > 0
183-
? ['--noEmit', '--pretty', 'false', ...targets]
184-
: ['--noEmit', '--pretty', 'false'];
185-
const result = await x(local('tsgo'), args, { throwOnError: false });
188+
// Always run in project mode: passing files on the command line makes
189+
// tsgo skip the tsconfig (TS5112). Explicit targets filter the report
190+
// after the fact instead.
191+
const result = await x(local('tsgo'), ['--noEmit', '--pretty', 'false'], {
192+
throwOnError: false,
193+
});
186194
const output = result.stdout + result.stderr;
187195
if (!output.trim()) return [];
188196

@@ -200,41 +208,38 @@ async function runTypeScript(targets: string[]): Promise<Violation[]> {
200208
column: Number(match[3]),
201209
});
202210
}
203-
return violations;
211+
if (targets.length === 0) return violations;
212+
const prefixes = targets.map((t) => t.replace(/^\.\//, ''));
213+
return violations.filter((v) => v.file && prefixes.some((p) => v.file!.startsWith(p)));
204214
}
205215

206216
// -- Output --
207217

208-
export function printViolations(violations: Violation[]) {
209-
const grouped = new Map<string, Violation[]>();
210-
for (const v of violations) {
211-
const key = v.file ?? '(project)';
212-
if (!grouped.has(key)) grouped.set(key, []);
213-
grouped.get(key)!.push(v);
214-
}
215-
216-
const colors = {
217-
error: '\x1b[31m',
218-
warning: '\x1b[33m',
219-
suggestion: '\x1b[34m',
220-
dim: '\x1b[2m',
221-
reset: '\x1b[0m',
222-
};
218+
const colors = {
219+
error: '\x1b[31m',
220+
warning: '\x1b[33m',
221+
suggestion: '\x1b[34m',
222+
dim: '\x1b[2m',
223+
reset: '\x1b[0m',
224+
};
223225

224-
for (const [file, items] of grouped) {
225-
console.log(`\n${file}`);
226-
for (const v of items) {
227-
const loc = v.line != null ? ` ${v.line}:${v.column ?? 0}` : ' -';
228-
const color = colors[v.level];
229-
const tag = `${v.tool}/${v.code}`;
230-
console.log(
231-
`${colors.dim}${loc.padEnd(10)}${colors.reset}${color}${v.level.padEnd(12)}${colors.reset}${v.message} ${colors.dim}${tag}${colors.reset}`,
232-
);
233-
}
234-
}
226+
function printViolation(v: Violation) {
227+
const loc = v.line != null ? ` ${v.line}:${v.column ?? 0}` : ' -';
228+
const color = colors[v.level];
229+
const tag = `${v.tool}/${v.code}`;
230+
console.log(
231+
`${colors.dim}${loc.padEnd(10)}${colors.reset}${color}${v.level.padEnd(12)}${colors.reset}${v.message} ${colors.dim}${tag}${colors.reset}`,
232+
);
233+
}
235234

235+
function countByLevel(violations: Violation[]) {
236236
const counts = { error: 0, warning: 0, suggestion: 0 };
237237
for (const v of violations) counts[v.level]++;
238+
return counts;
239+
}
240+
241+
function printSummary(violations: Violation[]) {
242+
const counts = countByLevel(violations);
238243
const parts = [];
239244
if (counts.error)
240245
parts.push(`${colors.error}${counts.error} error${counts.error > 1 ? 's' : ''}${colors.reset}`);
@@ -246,17 +251,69 @@ export function printViolations(violations: Violation[]) {
246251
parts.push(
247252
`${colors.suggestion}${counts.suggestion} suggestion${counts.suggestion > 1 ? 's' : ''}${colors.reset}`,
248253
);
249-
if (parts.length > 0) {
250-
console.log(`\n${parts.join(', ')}`);
251-
} else {
252-
console.log('\nNo issues found.');
254+
console.log(parts.length > 0 ? `\n${parts.join(', ')}` : '\nNo issues found.');
255+
}
256+
257+
/**
258+
* Print violations grouped by file. Errors are always shown in full.
259+
* Warnings collapse to a per-rule count unless `warnings` is set — they
260+
* don't affect the exit code, so a wall of them buries actual failures.
261+
*/
262+
export function printViolations(violations: Violation[], options?: { warnings?: boolean }) {
263+
const showWarnings = options?.warnings ?? false;
264+
const visible = showWarnings ? violations : violations.filter((v) => v.level === 'error');
265+
266+
const grouped = new Map<string, Violation[]>();
267+
for (const v of visible) {
268+
const key = v.file ?? '(project)';
269+
if (!grouped.has(key)) grouped.set(key, []);
270+
grouped.get(key)!.push(v);
271+
}
272+
273+
for (const [file, items] of grouped) {
274+
console.log(`\n${file}`);
275+
for (const v of items) printViolation(v);
276+
}
277+
278+
if (!showWarnings) {
279+
const hidden = violations.filter((v) => v.level !== 'error');
280+
if (hidden.length > 0) {
281+
const byRule = new Map<string, number>();
282+
for (const v of hidden) {
283+
const tag = `${v.tool}/${v.code}`;
284+
byRule.set(tag, (byRule.get(tag) ?? 0) + 1);
285+
}
286+
console.log(
287+
`\n${colors.dim}${hidden.length} warning${hidden.length > 1 ? 's' : ''} hidden (run with --warnings to show):${colors.reset}`,
288+
);
289+
for (const [tag, count] of [...byRule].sort((a, b) => b[1] - a[1])) {
290+
console.log(`${colors.dim} ${count} × ${tag}${colors.reset}`);
291+
}
292+
}
253293
}
294+
295+
printSummary(violations);
296+
}
297+
298+
/** Machine-readable report for agents and CI. */
299+
export function printJson(violations: Violation[]) {
300+
console.log(JSON.stringify({ summary: countByLevel(violations), violations }, null, 2));
254301
}
255302

256303
// -- Main --
257304

258-
async function collectViolations(targets: string[]): Promise<Violation[]> {
259-
const results = await Promise.allSettled([runOxlint(targets), runKnip(), runTypeScript(targets)]);
305+
async function collectViolations(
306+
targets: string[],
307+
options?: { strict?: boolean },
308+
): Promise<Violation[]> {
309+
// oxlint honors targets (default: project-wide); tsgo runs in project
310+
// mode unless the user explicitly narrowed the target set.
311+
const explicit = targets.length > 0;
312+
const results = await Promise.allSettled([
313+
runOxlint(explicit ? targets : ['.']),
314+
runKnip({ strict: options?.strict }),
315+
runTypeScript(explicit ? targets : []),
316+
]);
260317

261318
const violations: Violation[] = [];
262319
for (const result of results) {
@@ -271,26 +328,31 @@ async function collectViolations(targets: string[]): Promise<Violation[]> {
271328

272329
export async function lint(ctx: CommandContext) {
273330
const args = parse(ctx.args, {
274-
boolean: ['fix'],
331+
boolean: ['fix', 'strict', 'warnings'],
332+
string: ['format'],
275333
});
276-
const targets = args._.length > 0 ? args._.map(String) : ['./src'];
334+
const targets = args._.map(String);
335+
const json = args.format === 'json';
336+
const print = (violations: Violation[]) =>
337+
json ? printJson(violations) : printViolations(violations, { warnings: args.warnings });
277338

278339
if (args.fix) {
279-
await runOxlint(targets, true);
340+
await runOxlint(targets.length > 0 ? targets : ['.'], true);
280341

281342
// Report remaining
282-
const remaining = await collectViolations(targets);
343+
const remaining = await collectViolations(targets, { strict: args.strict });
283344
if (remaining.length > 0) {
284-
printViolations(remaining);
285-
process.exit(1);
345+
print(remaining);
346+
if (remaining.some((v) => v.level === 'error')) process.exit(1);
347+
return;
286348
}
287-
console.log('No issues found.');
349+
if (!json) console.log('No issues found.');
288350
return;
289351
}
290352

291353
// Default: report only
292-
const violations = await collectViolations(targets);
293-
printViolations(violations);
354+
const violations = await collectViolations(targets, { strict: args.strict });
355+
print(violations);
294356
if (violations.some((v) => v.level === 'error')) {
295357
process.exit(1);
296358
}

src/commands/publint.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export async function publintCommand() {
1313
column: undefined,
1414
}));
1515

16-
printViolations(violations);
16+
printViolations(violations, { warnings: true });
1717
if (violations.some((v) => v.level === 'error')) {
1818
process.exit(1);
1919
}

0 commit comments

Comments
 (0)