From 58be12f21ad9b3fb2206ad873dcd9033c235cef8 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 22 Jul 2026 22:59:36 -0400 Subject: [PATCH] feat(lint): scope public-API rules to the package's public surface Derives the public API surface from package.json (exports/bin/main, dist paths mapped back to src) and applies exported-function-async and require-export-jsdoc only to those files via oxlint overrides, instead of firing on every export in the repo. Internal modules, apps without a publish surface, and wildcard passthroughs are exempt. --- .changeset/lint-public-surface.md | 7 ++ src/commands/lint.test.ts | 38 ++++++++++ src/commands/lint.ts | 112 ++++++++++++++++++++------- src/surface.test.ts | 101 +++++++++++++++++++++++++ src/surface.ts | 122 ++++++++++++++++++++++++++++++ 5 files changed, 352 insertions(+), 28 deletions(-) create mode 100644 .changeset/lint-public-surface.md create mode 100644 src/surface.test.ts create mode 100644 src/surface.ts diff --git a/.changeset/lint-public-surface.md b/.changeset/lint-public-surface.md new file mode 100644 index 0000000..9cfe2d7 --- /dev/null +++ b/.changeset/lint-public-surface.md @@ -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. diff --git a/src/commands/lint.test.ts b/src/commands/lint.test.ts index f442245..53523d3 100644 --- a/src/commands/lint.test.ts +++ b/src/commands/lint.test.ts @@ -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', () => { diff --git a/src/commands/lint.ts b/src/commands/lint.ts index 5cc0dba..99efbba 100644 --- a/src/commands/lint.ts +++ b/src/commands/lint.ts @@ -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(run: (configPath: string) => Promise): Promise { + 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 = {}; + 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 { @@ -22,34 +76,36 @@ interface Violation { // -- Tool Runners -- export async function runOxlint(targets: string[], fix?: boolean): Promise { - 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 { diff --git a/src/surface.test.ts b/src/surface.test.ts new file mode 100644 index 0000000..2210393 --- /dev/null +++ b/src/surface.test.ts @@ -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', + ]); + }); +}); diff --git a/src/surface.ts b/src/surface.ts new file mode 100644 index 0000000..92c523f --- /dev/null +++ b/src/surface.ts @@ -0,0 +1,122 @@ +import { NodeHfs } from '@humanfs/node'; + +const hfs = new NodeHfs(); + +/** + * A `package.json` shape covering the publish-surface fields we read. + * Everything is optional — real-world manifests are messy. + */ +interface PackageJson { + main?: unknown; + module?: unknown; + bin?: unknown; + exports?: unknown; +} + +/** + * Compute the public API surface of the package(s) rooted at `root`. + * + * The public surface is the set of source files consumers can import — + * derived from `package.json` `exports`, `bin`, and `main`/`module` fields + * by mapping published `dist/` paths back to their `src/` origins. + * + * Returns oxlint-style glob patterns relative to `root`. Packages without a + * publish surface (apps, examples, private tooling) yield an empty array — + * surface-scoped lint rules stay silent there. + * + * Monorepos are handled by convention: each `packages//package.json` + * contributes its own surface, prefixed with `packages//`. + */ +export async function getPublicSurface(root: URL): Promise { + const patterns = new Set(); + + const roots = new Map([['', root]]); + const packagesDir = new URL('packages/', root); + if (await hfs.isDirectory(packagesDir)) { + for await (const entry of hfs.list(packagesDir)) { + if (!entry.isDirectory) continue; + const pkgRoot = new URL(`packages/${entry.name}/`, root); + if (await hfs.isFile(new URL('package.json', pkgRoot))) { + roots.set(`packages/${entry.name}`, pkgRoot); + } + } + } + + for (const [prefix, pkgRoot] of roots) { + const pkg = (await hfs.json(new URL('package.json', pkgRoot))) as PackageJson | undefined; + if (!pkg) continue; + for (const pattern of surfacePatterns(pkg)) { + patterns.add(prefix ? `${prefix}/${pattern}` : pattern); + } + } + + return [...patterns]; +} + +/** Map a package.json's publish fields to source glob patterns. */ +function surfacePatterns(pkg: PackageJson): string[] { + const targets = new Set(); + + if (pkg.exports !== undefined) { + for (const value of walkExports(pkg.exports)) targets.add(value); + } else { + // Legacy fields only apply when `exports` is absent + if (typeof pkg.main === 'string') targets.add(pkg.main); + if (typeof pkg.module === 'string') targets.add(pkg.module); + } + if (typeof pkg.bin === 'string') { + targets.add(pkg.bin); + } else if (pkg.bin && typeof pkg.bin === 'object') { + for (const value of Object.values(pkg.bin)) { + if (typeof value === 'string') targets.add(value); + } + } + + const patterns = new Set(); + for (const target of targets) { + const pattern = toSourcePattern(target); + if (pattern) patterns.add(pattern); + } + return [...patterns]; +} + +/** Collect every string leaf from an `exports` value of any shape. */ +function* walkExports(value: unknown): Generator { + if (typeof value === 'string') { + yield value; + } else if (Array.isArray(value)) { + for (const item of value) yield* walkExports(item); + } else if (value && typeof value === 'object') { + for (const item of Object.values(value)) yield* walkExports(item); + } +} + +/** + * Map a published file target back to its source glob pattern. + * + * - `./dist/index.mjs` → `src/index.ts` + * - `./src/index.ts` (source-exporting packages) → `src/index.ts` + * + * Wildcard passthroughs (`"./*": "./dist/*"`) are skipped: they make files + * importable by convention, but don't designate a curated public API, so + * surface-scoped rules would fire on internal modules. + * + * Returns `undefined` for targets that aren't code entry points + * (`./package.json`, asset paths, etc.). + */ +function toSourcePattern(target: string): string | undefined { + if (!target.startsWith('./')) return undefined; + let path = target.slice(2); + + // Published artifacts map back to src/; anything else must already be source + if (path.startsWith('dist/')) { + path = `src/${path.slice('dist/'.length)}`; + } else if (!path.startsWith('src/')) { + return undefined; + } + + if (path.includes('*')) return undefined; + + const source = path.replace(/\.(d\.[cm]ts|[cm]?[jt]s)$/, '.ts'); + return source.endsWith('.ts') ? source : undefined; +}