Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/lint-public-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@bomb.sh/tools': minor
---

Scopes the `bombshell-dev/exported-function-async` and `bombshell-dev/require-export-jsdoc` lint rules to a package's public API surface

`bsh lint` now derives the public surface from `package.json` (`exports`, `bin`, and `main`/`module`, mapping `dist/` paths back to `src/`), including conventional `packages/*/` workspace members. These two rules no longer fire on internal modules — only on files consumers can actually import. Packages without a publish surface (apps, examples) are exempt entirely, and wildcard passthrough exports (`"./*": "./dist/*"`) don't designate surface.
38 changes: 38 additions & 0 deletions src/commands/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,44 @@ describe('lint command', () => {

expect(violations).toEqual([]);
});

it('scopes surface rules to the public API surface', async () => {
fixture = await createFixture({
'package.json': {
name: 'test-pkg',
exports: { '.': './dist/index.mjs' },
},
src: {
'index.ts': 'export function syncPublic() { return 1; }\n',
'internal.ts': 'export function syncInternal() { return 2; }\n',
},
});
process.chdir(fileURLToPath(fixture.root));

const violations = await runOxlint(['./src']);
const asyncWarnings = violations.filter(
(v) => v.code === 'bombshell-dev(exported-function-async)',
);

expect(asyncWarnings).toHaveLength(1);
expect(asyncWarnings[0]!.file).toBe('src/index.ts');
});

it('silences surface rules for packages without a publish surface', async () => {
fixture = await createFixture({
'package.json': { name: 'test-app', private: true },
src: {
'index.ts': 'export function syncInternal() { return 1; }\n',
},
});
process.chdir(fileURLToPath(fixture.root));

const violations = await runOxlint(['./src']);

expect(violations.filter((v) => v.code === 'bombshell-dev(exported-function-async)')).toEqual(
[],
);
});
});

describe('runKnip', () => {
Expand Down
112 changes: 84 additions & 28 deletions src/commands/lint.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,66 @@
import { fileURLToPath } from 'node:url';
import { readFile, rm, writeFile } from 'node:fs/promises';
import { parse } from '@bomb.sh/args';
import { x } from 'tinyexec';
import type { JSONReport as KnipJSONReport } from 'knip';
import type { CommandContext } from '../context.ts';
import { getPublicSurface } from '../surface.ts';
import { local } from '../utils.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<T>(run: (configPath: string) => Promise<T>): Promise<T> {
const cwd = process.cwd();
const base = JSON.parse(await readFile(oxlintConfig, 'utf-8'));
delete base.$schema;

// jsPlugins paths are relative to the shared config — make them absolute
// so the generated copy can live anywhere.
if (Array.isArray(base.jsPlugins)) {
base.jsPlugins = base.jsPlugins.map((plugin: string) =>
fileURLToPath(new URL(plugin, new URL('../../oxlintrc.json', import.meta.url))),
);
}

const surface = await getPublicSurface(new URL(`file://${cwd}/`));
const scoped: Record<string, unknown> = {};
for (const rule of SURFACE_RULES) {
if (base.rules?.[rule] !== undefined) {
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 });
}
}

// -- Types --

interface Violation {
Expand All @@ -22,34 +76,36 @@ interface Violation {
// -- Tool Runners --

export async function runOxlint(targets: string[], fix?: boolean): Promise<Violation[]> {
const args = ['-c', oxlintConfig, '--format=json', ...targets];
if (fix) args.push('--fix');
const result = await x(local('oxlint'), args, { throwOnError: false });
try {
const json = JSON.parse(result.stdout);
return (json.diagnostics ?? []).map(
(d: {
message: string;
code: string;
severity: string;
filename?: string;
labels?: Array<{ span?: { line?: number; column?: number } }>;
}) => ({
tool: 'oxlint' as const,
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 {
// in some cases, failures or no-ops do not produce valid JSON
// fallback to raw output rather than throwing an error
console.log(result.stdout);
return [];
}
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 {
const json = JSON.parse(result.stdout);
return (json.diagnostics ?? []).map(
(d: {
message: string;
code: string;
severity: string;
filename?: string;
labels?: Array<{ span?: { line?: number; column?: number } }>;
}) => ({
tool: 'oxlint' as const,
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 {
// in some cases, failures or no-ops do not produce valid JSON
// fallback to raw output rather than throwing an error
console.log(result.stdout);
return [];
}
});
}

export async function runKnip(): Promise<Violation[]> {
Expand Down
101 changes: 101 additions & 0 deletions src/surface.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { createFixture } from './test-utils/index.ts';
import { getPublicSurface } from './surface.ts';

describe('getPublicSurface', () => {
it('maps exports dist paths back to src', async () => {
const fixture = await createFixture({
'package.json': {
name: 'test-pkg',
exports: {
'.': { types: './dist/index.d.mts', import: './dist/index.mjs' },
'./utils': './dist/utils.mjs',
},
},
});
const surface = await getPublicSurface(fixture.root);
expect(surface.sort()).toEqual(['src/index.ts', 'src/utils.ts']);
});

it('maps bin entries to src', async () => {
const fixture = await createFixture({
'package.json': { name: 'test-cli', bin: { 'test-cli': './dist/cli.mjs' } },
});
const surface = await getPublicSurface(fixture.root);
expect(surface).toEqual(['src/cli.ts']);
});

it('skips wildcard passthrough exports', async () => {
const fixture = await createFixture({
'package.json': {
name: 'test-pkg',
exports: { '.': './dist/index.mjs', './*': './dist/*' },
},
});
const surface = await getPublicSurface(fixture.root);
expect(surface).toEqual(['src/index.ts']);
});

it('keeps source-exporting packages as-is', async () => {
const fixture = await createFixture({
'package.json': { name: 'test-pkg', exports: './src/index.ts' },
});
const surface = await getPublicSurface(fixture.root);
expect(surface).toEqual(['src/index.ts']);
});

it('falls back to main/module when exports is absent', async () => {
const fixture = await createFixture({
'package.json': { name: 'test-pkg', main: './dist/index.cjs', module: './dist/index.mjs' },
});
const surface = await getPublicSurface(fixture.root);
expect(surface).toEqual(['src/index.ts']);
});

it('ignores non-code targets', async () => {
const fixture = await createFixture({
'package.json': {
name: 'test-pkg',
exports: {
'.': './dist/index.mjs',
'./package.json': './package.json',
'./skills/*': './skills/*',
'./styles.css': './dist/styles.css',
},
},
});
const surface = await getPublicSurface(fixture.root);
expect(surface).toEqual(['src/index.ts']);
});

it('returns empty for packages without a publish surface', async () => {
const fixture = await createFixture({
'package.json': { name: 'test-app', private: true },
});
const surface = await getPublicSurface(fixture.root);
expect(surface).toEqual([]);
});

it('collects surface from conventional workspace packages', async () => {
const fixture = await createFixture({
'package.json': { name: 'test-mono', private: true },
packages: {
alpha: {
'package.json': { name: 'alpha', exports: './dist/index.mjs' },
},
beta: {
'package.json': {
name: 'beta',
exports: { '.': './dist/mod.mjs', './extra': './dist/extra.mjs' },
},
},
},
});
const surface = await getPublicSurface(fixture.root);
expect(surface.sort()).toEqual([
'packages/alpha/src/index.ts',
'packages/beta/src/extra.ts',
'packages/beta/src/mod.ts',
]);
});
});
Loading
Loading