diff --git a/.changeset/quiet-entrypoints.md b/.changeset/quiet-entrypoints.md new file mode 100644 index 0000000..d6f4918 --- /dev/null +++ b/.changeset/quiet-entrypoints.md @@ -0,0 +1,5 @@ +--- +'@ankhorage/devtools': patch +--- + +Allow `ankhorage/no-forward-exports` in public package entrypoints declared through package metadata while keeping ordinary implementation modules ownership-strict. diff --git a/src/tools/eslint/index.ts b/src/tools/eslint/index.ts index 2313902..d80c3d6 100644 --- a/src/tools/eslint/index.ts +++ b/src/tools/eslint/index.ts @@ -8,9 +8,9 @@ * Every profile includes the shared TypeScript, import, unused-import, Prettier, security, and * quality rules. The common quality limits are 50 effective lines per function, 300 effective * lines per file, and modified cyclomatic complexity 15. Forward exports are forbidden outside - * explicit `index.*` barrels so implementation files export only symbols they own. React adds - * React and Hooks correctness rules; React Native composes the React profile and adds focused - * React Native rules. + * declared package entrypoints and explicit `index.*` barrels so implementation files export only + * symbols they own. React adds React and Hooks correctness rules; React Native composes the React + * profile and adds focused React Native rules. * * Repository-specific behavior stays additive: `additionalIgnores`, `restrictedImports`, and * `overrides` extend the central policy instead of replacing it. Narrow local overrides remain the @@ -35,6 +35,7 @@ import unusedImports from 'eslint-plugin-unused-imports'; import tseslint from 'typescript-eslint'; import { createModuleOwnershipConfig } from './moduleOwnership.js'; +import { resolvePackageEntrypointFiles } from './packageEntrypoints.js'; import { resolveEslintProfile } from './profile.js'; import type { DevtoolsConfigOptions, @@ -78,13 +79,14 @@ interface NormalizedConfigOptions { export function createConfig(options: DevtoolsConfigOptions): Linter.Config[] { const normalized = normalizeOptions(options); const profile = resolveEslintProfile(options); + const packageEntrypoints = resolvePackageEntrypointFiles(options); return defineConfig( { ignores: [...defaultIgnores, ...normalized.additionalIgnores] }, { ...js.configs.recommended, files: normalized.files }, ...createTypeCheckedConfigs(normalized), createBaseConfig(normalized), - createModuleOwnershipConfig(normalized.files), + createModuleOwnershipConfig(normalized.files, packageEntrypoints), ...createProfileConfigs(profile, normalized.files), ...normalized.overrides, ...(normalized.includePrettier ? [prettierConfig] : []), diff --git a/src/tools/eslint/integration.test.ts b/src/tools/eslint/integration.test.ts index a06fd56..bb865b6 100644 --- a/src/tools/eslint/integration.test.ts +++ b/src/tools/eslint/integration.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -13,6 +13,8 @@ type LintResult = Awaited>[number]; interface LintWorkspace { readonly root: string; lint(code: string, fileName: string, fix?: boolean): Promise; + lintFile(fileName: string, fix?: boolean): Promise; + write(code: string, fileName: string): Promise; } async function createLintWorkspace( @@ -27,25 +29,37 @@ async function createLintWorkspace( }), ); + async function write(code: string, fileName: string): Promise { + const filePath = path.join(root, fileName); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, code); + } + + async function lintFile(fileName: string, fix = false): Promise { + const filePath = path.join(root, fileName); + const eslint = new ESLint({ + cwd: root, + fix, + overrideConfigFile: true, + overrideConfig: createConfig({ + tsconfigRootDir: root, + project: ['./tsconfig.json'], + files: ['**/*.{ts,tsx}'], + profile, + }), + }); + const [result] = await eslint.lintFiles([filePath]); + return result; + } + return { root, async lint(code, fileName, fix = false) { - const filePath = path.join(root, fileName); - await writeFile(filePath, code); - const eslint = new ESLint({ - cwd: root, - fix, - overrideConfigFile: true, - overrideConfig: createConfig({ - tsconfigRootDir: root, - project: ['./tsconfig.json'], - files: ['**/*.{ts,tsx}'], - profile, - }), - }); - const [result] = await eslint.lintFiles([filePath]); - return result; + await write(code, fileName); + return lintFile(fileName, fix); }, + lintFile, + write, }; } @@ -55,6 +69,7 @@ async function lintFresh(code: string, fileName: string): Promise { } function ruleIds(result: LintResult): string[] { + expect(result.messages.filter((message) => message.fatal === true)).toEqual([]); return result.messages.flatMap((message) => (message.ruleId === null ? [] : [message.ruleId])); } @@ -102,6 +117,37 @@ it('keeps export sorting active inside index barrels', async () => { expect(ruleIds(result)).not.toContain('ankhorage/no-forward-exports'); }); +it('allows forward exports from declared non-index package entrypoints', async () => { + const workspace = await createLintWorkspace(); + await writeFile( + path.join(workspace.root, 'package.json'), + JSON.stringify({ + main: './dist/root.js', + types: './dist/root.d.ts', + exports: { + './binding': { + types: './dist/bindingAuthoringModel.d.ts', + import: './dist/bindingAuthoringModel.js', + }, + }, + }), + ); + + await Promise.all([ + workspace.write("export * from './index';\n", 'src/root.ts'), + workspace.write("export { value } from './value';\n", 'src/bindingAuthoringModel.ts'), + workspace.write("export * from './value';\n", 'src/implementation.ts'), + ]); + + const root = await workspace.lintFile('src/root.ts'); + const binding = await workspace.lintFile('src/bindingAuthoringModel.ts'); + const undeclared = await workspace.lintFile('src/implementation.ts'); + + expect(ruleIds(root)).not.toContain('ankhorage/no-forward-exports'); + expect(ruleIds(binding)).not.toContain('ankhorage/no-forward-exports'); + expect(ruleIds(undeclared)).toContain('ankhorage/no-forward-exports'); +}); + it('rejects named, type, and star forward exports outside index barrels', async () => { const named = await lintFresh("export { value } from './value';\n", 'named.ts'); const typed = await lintFresh("export type { Value } from './value';\n", 'typed.ts'); diff --git a/src/tools/eslint/moduleOwnership.ts b/src/tools/eslint/moduleOwnership.ts index eb901f5..15aa321 100644 --- a/src/tools/eslint/moduleOwnership.ts +++ b/src/tools/eslint/moduleOwnership.ts @@ -1,43 +1,58 @@ +import { basename, normalize } from 'node:path'; + import type { ESLint, Rule } from 'eslint'; import type { FlatConfigItem } from './types.js'; -const INDEX_BARREL_FILES = [ - '**/index.ts', - '**/index.tsx', - '**/index.js', - '**/index.jsx', - '**/index.mts', - '**/index.cts', - '**/index.mjs', - '**/index.cjs', -] as const; +const INDEX_BARREL_FILE = /^index\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/u; function isForwardExport(context: Rule.RuleContext, node: Rule.Node): boolean { const tokens = context.sourceCode.getTokens(node); - if (tokens.at(0)?.value !== 'export') { - return false; - } + if (tokens.at(0)?.value !== 'export') return false; return tokens.some( (token, index) => token.value === 'from' && tokens.at(index + 1)?.type === 'String', ); } +function isAllowedForwardExportFile(context: Rule.RuleContext): boolean { + const filename = normalize(context.filename); + if (INDEX_BARREL_FILE.test(basename(filename))) return true; + return readAllowedFiles(context.options.at(0)).some((file) => normalize(file) === filename); +} + +function readAllowedFiles(value: unknown): readonly string[] { + if (!isRecord(value)) return []; + const { allowedFiles } = value; + if (!Array.isArray(allowedFiles)) return []; + return allowedFiles.filter((file): file is string => typeof file === 'string'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + const noForwardExportsRule: Rule.RuleModule = { meta: { type: 'problem', - schema: [], + schema: [ + { + type: 'object', + properties: { + allowedFiles: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }, + ], messages: { forwardExport: - 'Forward exports are forbidden outside index barrels. Import directly from the owning module.', + 'Forward exports are forbidden outside package entrypoints and index barrels. Import directly from the owning module.', }, }, create(context) { + if (isAllowedForwardExportFile(context)) return {}; return { 'Program > *'(node: Rule.Node) { - if (isForwardExport(context, node)) { - context.report({ node, messageId: 'forwardExport' }); - } + if (isForwardExport(context, node)) context.report({ node, messageId: 'forwardExport' }); }, }; }, @@ -47,11 +62,15 @@ const moduleOwnershipPlugin = { rules: { 'no-forward-exports': noForwardExportsRule }, } satisfies ESLint.Plugin; -export function createModuleOwnershipConfig(files: string[]): FlatConfigItem { +export function createModuleOwnershipConfig( + files: string[], + packageEntrypoints: string[] = [], +): FlatConfigItem { return { files, - ignores: [...INDEX_BARREL_FILES], plugins: { ankhorage: moduleOwnershipPlugin }, - rules: { 'ankhorage/no-forward-exports': 'error' }, + rules: { + 'ankhorage/no-forward-exports': ['error', { allowedFiles: packageEntrypoints }], + }, }; } diff --git a/src/tools/eslint/packageEntrypoints.ts b/src/tools/eslint/packageEntrypoints.ts new file mode 100644 index 0000000..7a6775c --- /dev/null +++ b/src/tools/eslint/packageEntrypoints.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import { resolveProjectPackageJsonPath } from './packageJsonPath.js'; +import type { DevtoolsConfigOptions } from './types.js'; + +const SOURCE_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mts', 'cts', 'mjs', 'cjs'] as const; +const OUTPUT_EXTENSION = /(?:\.d)?\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/u; + +export function resolvePackageEntrypointFiles(options: DevtoolsConfigOptions): string[] { + const packageJsonPath = resolveProjectPackageJsonPath(options); + if (packageJsonPath === null) return []; + + const packageJson = readPackageJson(packageJsonPath); + const targets = [ + ...collectStringTargets(packageJson.main), + ...collectStringTargets(packageJson.types), + ...collectStringTargets(packageJson.exports), + ]; + + return [...new Set(targets.flatMap((target) => toSourceCandidates(target, packageJsonPath)))]; +} + +function readPackageJson(packageJsonPath: string): Record { + const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as unknown; + if (!isRecord(parsed)) { + throw new Error(`Expected package.json to contain a JSON object: ${packageJsonPath}`); + } + return parsed; +} + +function collectStringTargets(value: unknown): string[] { + if (typeof value === 'string') return [value]; + if (Array.isArray(value)) return value.flatMap(collectStringTargets); + if (!isRecord(value)) return []; + return Object.values(value).flatMap(collectStringTargets); +} + +function toSourceCandidates(target: string, packageJsonPath: string): string[] { + const normalizedTarget = target.replace(/^\.\//u, ''); + const sourceTarget = normalizedTarget.startsWith('dist/') + ? `src/${normalizedTarget.slice('dist/'.length)}` + : normalizedTarget; + if (!sourceTarget.startsWith('src/')) return []; + + const sourceBase = sourceTarget.replace(OUTPUT_EXTENSION, ''); + const absoluteBase = resolve(dirname(packageJsonPath), sourceBase); + return SOURCE_EXTENSIONS.map((extension) => `${absoluteBase}.${extension}`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/tools/eslint/packageJsonPath.ts b/src/tools/eslint/packageJsonPath.ts new file mode 100644 index 0000000..36045ea --- /dev/null +++ b/src/tools/eslint/packageJsonPath.ts @@ -0,0 +1,20 @@ +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import type { DevtoolsConfigOptions } from './types.js'; + +export function resolveProjectPackageJsonPath(options: DevtoolsConfigOptions): string | null { + if (options.packageJsonPath !== undefined) { + return resolve(options.tsconfigRootDir, options.packageJsonPath); + } + + let directory = resolve(options.tsconfigRootDir); + for (;;) { + const candidate = resolve(directory, 'package.json'); + if (existsSync(candidate)) return candidate; + + const parent = dirname(directory); + if (parent === directory) return null; + directory = parent; + } +} diff --git a/src/tools/eslint/profile.ts b/src/tools/eslint/profile.ts index 96b1111..17de275 100644 --- a/src/tools/eslint/profile.ts +++ b/src/tools/eslint/profile.ts @@ -1,5 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { readFileSync } from 'node:fs'; import { detectProject, @@ -7,6 +6,7 @@ import { type ProjectDetectionInput, } from '@ankhorage/utility/project'; +import { resolveProjectPackageJsonPath } from './packageJsonPath.js'; import type { DevtoolsConfigOptions, DevtoolsEslintProfile, @@ -17,9 +17,7 @@ export function resolveEslintProfile( options: DevtoolsConfigOptions, ): ResolvedDevtoolsEslintProfile { const requestedProfile = options.profile ?? 'auto'; - if (requestedProfile !== 'auto') { - return requestedProfile; - } + if (requestedProfile !== 'auto') return requestedProfile; const packageJsonPath = resolveProjectPackageJsonPath(options); const input = packageJsonPath === null ? {} : readDetectionInput(packageJsonPath); @@ -30,38 +28,13 @@ export function resolveEslintProfileFromDetectionInput( requestedProfile: DevtoolsEslintProfile, input: ProjectDetectionInput, ): ResolvedDevtoolsEslintProfile { - if (requestedProfile !== 'auto') { - return requestedProfile; - } + if (requestedProfile !== 'auto') return requestedProfile; const { traits } = detectProject(input); - if (traits.has('react-native') || traits.has('expo')) { - return 'react-native'; - } - + if (traits.has('react-native') || traits.has('expo')) return 'react-native'; return traits.has('react') ? 'react' : 'base'; } -function resolveProjectPackageJsonPath(options: DevtoolsConfigOptions): string | null { - if (options.packageJsonPath !== undefined) { - return resolve(options.tsconfigRootDir, options.packageJsonPath); - } - - let directory = resolve(options.tsconfigRootDir); - for (;;) { - const candidate = resolve(directory, 'package.json'); - if (existsSync(candidate)) { - return candidate; - } - - const parent = dirname(directory); - if (parent === directory) { - return null; - } - directory = parent; - } -} - function readDetectionInput(packageJsonPath: string): ProjectDetectionInput { const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as unknown; if (!isRecord(parsed)) { @@ -86,23 +59,17 @@ function optionalDependencyMap( } function toDependencyMap(value: unknown): ProjectDependencyMap | undefined { - if (!isRecord(value)) { - return undefined; - } + if (!isRecord(value)) return undefined; const dependencies: Record = {}; for (const [name, version] of Object.entries(value)) { - if (typeof version === 'string') { - dependencies[name] = version; - } + if (typeof version === 'string') dependencies[name] = version; } return dependencies; } function optionalEngines(value: unknown): Partial { - if (!isRecord(value)) { - return {}; - } + if (!isRecord(value)) return {}; const engines = { ...(typeof value.bun === 'string' ? { bun: value.bun } : {}),