From dd25af7d14232b24d3b5a7f578650b77de933c2b Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:49:34 +0200 Subject: [PATCH 01/19] fix(lint): resolve package metadata path centrally --- src/tools/eslint/packageJsonPath.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/tools/eslint/packageJsonPath.ts 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; + } +} From 2e270ba4ea4120772cdbff9df188a47296f902ff Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:49:51 +0200 Subject: [PATCH 02/19] refactor(lint): share package metadata resolution --- src/tools/eslint/profile.ts | 49 ++++++------------------------------- 1 file changed, 8 insertions(+), 41 deletions(-) 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 } : {}), From 9bbdd4074af5eab7a343cb369f9394198718c5e8 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:50:03 +0200 Subject: [PATCH 03/19] fix(lint): resolve declared package entrypoints --- src/tools/eslint/packageEntrypoints.ts | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/tools/eslint/packageEntrypoints.ts diff --git a/src/tools/eslint/packageEntrypoints.ts b/src/tools/eslint/packageEntrypoints.ts new file mode 100644 index 0000000..ddd53da --- /dev/null +++ b/src/tools/eslint/packageEntrypoints.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs'; +import { dirname, relative, 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 resolvePackageEntrypointIgnores(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, options)))]; +} + +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, + options: DevtoolsConfigOptions, +): 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); + const relativeBase = relative(options.tsconfigRootDir, absoluteBase).replaceAll('\\', '/'); + return SOURCE_EXTENSIONS.map((extension) => `${relativeBase}.${extension}`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} From 3aacc056d7d0458b305059302ce884aa173f7a43 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:50:16 +0200 Subject: [PATCH 04/19] fix(lint): allow declared package entrypoints --- src/tools/eslint/moduleOwnership.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/tools/eslint/moduleOwnership.ts b/src/tools/eslint/moduleOwnership.ts index eb901f5..f16dd80 100644 --- a/src/tools/eslint/moduleOwnership.ts +++ b/src/tools/eslint/moduleOwnership.ts @@ -15,9 +15,7 @@ const INDEX_BARREL_FILES = [ 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', ); @@ -29,15 +27,13 @@ const noForwardExportsRule: Rule.RuleModule = { schema: [], 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) { return { 'Program > *'(node: Rule.Node) { - if (isForwardExport(context, node)) { - context.report({ node, messageId: 'forwardExport' }); - } + if (isForwardExport(context, node)) context.report({ node, messageId: 'forwardExport' }); }, }; }, @@ -47,10 +43,13 @@ 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], + ignores: [...INDEX_BARREL_FILES, ...packageEntrypoints], plugins: { ankhorage: moduleOwnershipPlugin }, rules: { 'ankhorage/no-forward-exports': 'error' }, }; From f2d7a42d355d8cb048e51ae54fbbe9047525e180 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:50:49 +0200 Subject: [PATCH 05/19] fix(lint): exempt declared package entrypoints --- src/tools/eslint/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/tools/eslint/index.ts b/src/tools/eslint/index.ts index 2313902..df30d37 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 { resolvePackageEntrypointIgnores } 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 = resolvePackageEntrypointIgnores(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] : []), From 66f087d566c08509b7039e6eb01e209055a0eae8 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:51:20 +0200 Subject: [PATCH 06/19] test(lint): cover declared non-index package entrypoints --- src/tools/eslint/integration.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/tools/eslint/integration.test.ts b/src/tools/eslint/integration.test.ts index a06fd56..792a5c8 100644 --- a/src/tools/eslint/integration.test.ts +++ b/src/tools/eslint/integration.test.ts @@ -102,6 +102,34 @@ 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', + }, + }, + }), + ); + + const root = await workspace.lint("export * from './index';\n", 'src/root.ts'); + const binding = await workspace.lint( + "export { value } from './value';\n", + 'src/bindingAuthoringModel.ts', + ); + const undeclared = await workspace.lint("export * from './value';\n", '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'); From e9a2a2090e40fe7de19dca9deaa376c3497ffbc4 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:51:29 +0200 Subject: [PATCH 07/19] chore: add package entrypoint lint changeset --- .changeset/quiet-entrypoints.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quiet-entrypoints.md diff --git a/.changeset/quiet-entrypoints.md b/.changeset/quiet-entrypoints.md new file mode 100644 index 0000000..cdd049d --- /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. From 502f08da30b454c4db90fd4723b4c31b51cc3fa5 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:54:43 +0200 Subject: [PATCH 08/19] style(lint): format package entrypoint resolver --- src/tools/eslint/packageEntrypoints.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tools/eslint/packageEntrypoints.ts b/src/tools/eslint/packageEntrypoints.ts index ddd53da..41844b4 100644 --- a/src/tools/eslint/packageEntrypoints.ts +++ b/src/tools/eslint/packageEntrypoints.ts @@ -18,7 +18,9 @@ export function resolvePackageEntrypointIgnores(options: DevtoolsConfigOptions): ...collectStringTargets(packageJson.exports), ]; - return [...new Set(targets.flatMap((target) => toSourceCandidates(target, packageJsonPath, options)))]; + return [ + ...new Set(targets.flatMap((target) => toSourceCandidates(target, packageJsonPath, options))), + ]; } function readPackageJson(packageJsonPath: string): Record { From 903c5ee24902f6f33f8cf7f9e50b6857732e2234 Mon Sep 17 00:00:00 2001 From: artiphishle Date: Mon, 17 Aug 2026 04:54:56 +0200 Subject: [PATCH 09/19] chore(lint): fix --- src/tools/eslint/packageEntrypoints.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tools/eslint/packageEntrypoints.ts b/src/tools/eslint/packageEntrypoints.ts index ddd53da..41844b4 100644 --- a/src/tools/eslint/packageEntrypoints.ts +++ b/src/tools/eslint/packageEntrypoints.ts @@ -18,7 +18,9 @@ export function resolvePackageEntrypointIgnores(options: DevtoolsConfigOptions): ...collectStringTargets(packageJson.exports), ]; - return [...new Set(targets.flatMap((target) => toSourceCandidates(target, packageJsonPath, options)))]; + return [ + ...new Set(targets.flatMap((target) => toSourceCandidates(target, packageJsonPath, options))), + ]; } function readPackageJson(packageJsonPath: string): Record { From f4390a540e2e80c6b98e80b8a4565ceab020a68f Mon Sep 17 00:00:00 2001 From: artiphishle Date: Mon, 17 Aug 2026 04:55:31 +0200 Subject: [PATCH 10/19] docs: update --- .changeset/quiet-entrypoints.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quiet-entrypoints.md b/.changeset/quiet-entrypoints.md index cdd049d..d6f4918 100644 --- a/.changeset/quiet-entrypoints.md +++ b/.changeset/quiet-entrypoints.md @@ -1,5 +1,5 @@ --- -"@ankhorage/devtools": patch +'@ankhorage/devtools': patch --- Allow `ankhorage/no-forward-exports` in public package entrypoints declared through package metadata while keeping ordinary implementation modules ownership-strict. From b345e73d931c93935a261e465f8e3bbbebc96486 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:57:16 +0200 Subject: [PATCH 11/19] test(lint): create nested workspace directories --- src/tools/eslint/integration.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tools/eslint/integration.test.ts b/src/tools/eslint/integration.test.ts index 792a5c8..a78a62d 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'; @@ -31,6 +31,7 @@ async function createLintWorkspace( root, async lint(code, fileName, fix = false) { const filePath = path.join(root, fileName); + await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, code); const eslint = new ESLint({ cwd: root, From 10c0f8f1fb3ee89d5430d8168734d49bcbd58de1 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:00:05 +0200 Subject: [PATCH 12/19] fix(lint): scope forward-export exemptions explicitly --- src/tools/eslint/moduleOwnership.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/tools/eslint/moduleOwnership.ts b/src/tools/eslint/moduleOwnership.ts index f16dd80..4a63d0f 100644 --- a/src/tools/eslint/moduleOwnership.ts +++ b/src/tools/eslint/moduleOwnership.ts @@ -43,14 +43,20 @@ const moduleOwnershipPlugin = { rules: { 'no-forward-exports': noForwardExportsRule }, } satisfies ESLint.Plugin; -export function createModuleOwnershipConfig( - files: string[], - packageEntrypoints: string[] = [], -): FlatConfigItem { +export function createModuleOwnershipConfig(files: string[]): FlatConfigItem { return { files, - ignores: [...INDEX_BARREL_FILES, ...packageEntrypoints], plugins: { ankhorage: moduleOwnershipPlugin }, rules: { 'ankhorage/no-forward-exports': 'error' }, }; } + +export function createModuleOwnershipExemptionConfig( + packageEntrypoints: string[], +): FlatConfigItem { + return { + files: [...INDEX_BARREL_FILES, ...packageEntrypoints], + plugins: { ankhorage: moduleOwnershipPlugin }, + rules: { 'ankhorage/no-forward-exports': 'off' }, + }; +} From a70e1d51e565631696329741b64cd007e78602e4 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:00:17 +0200 Subject: [PATCH 13/19] refactor(lint): name entrypoint paths by purpose --- src/tools/eslint/packageEntrypoints.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/eslint/packageEntrypoints.ts b/src/tools/eslint/packageEntrypoints.ts index 41844b4..0fca2f0 100644 --- a/src/tools/eslint/packageEntrypoints.ts +++ b/src/tools/eslint/packageEntrypoints.ts @@ -7,7 +7,7 @@ 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 resolvePackageEntrypointIgnores(options: DevtoolsConfigOptions): string[] { +export function resolvePackageEntrypointFiles(options: DevtoolsConfigOptions): string[] { const packageJsonPath = resolveProjectPackageJsonPath(options); if (packageJsonPath === null) return []; From c15c88ee2fb1946e161b837576a6218b82b72434 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:00:53 +0200 Subject: [PATCH 14/19] fix(lint): exempt only declared forward-export entrypoints --- src/tools/eslint/index.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/tools/eslint/index.ts b/src/tools/eslint/index.ts index df30d37..c54fe22 100644 --- a/src/tools/eslint/index.ts +++ b/src/tools/eslint/index.ts @@ -34,8 +34,11 @@ import simpleImportSort from 'eslint-plugin-simple-import-sort'; import unusedImports from 'eslint-plugin-unused-imports'; import tseslint from 'typescript-eslint'; -import { createModuleOwnershipConfig } from './moduleOwnership.js'; -import { resolvePackageEntrypointIgnores } from './packageEntrypoints.js'; +import { + createModuleOwnershipConfig, + createModuleOwnershipExemptionConfig, +} from './moduleOwnership.js'; +import { resolvePackageEntrypointFiles } from './packageEntrypoints.js'; import { resolveEslintProfile } from './profile.js'; import type { DevtoolsConfigOptions, @@ -79,14 +82,15 @@ interface NormalizedConfigOptions { export function createConfig(options: DevtoolsConfigOptions): Linter.Config[] { const normalized = normalizeOptions(options); const profile = resolveEslintProfile(options); - const packageEntrypoints = resolvePackageEntrypointIgnores(options); + const packageEntrypoints = resolvePackageEntrypointFiles(options); return defineConfig( { ignores: [...defaultIgnores, ...normalized.additionalIgnores] }, { ...js.configs.recommended, files: normalized.files }, ...createTypeCheckedConfigs(normalized), createBaseConfig(normalized), - createModuleOwnershipConfig(normalized.files, packageEntrypoints), + createModuleOwnershipConfig(normalized.files), + createModuleOwnershipExemptionConfig(packageEntrypoints), ...createProfileConfigs(profile, normalized.files), ...normalized.overrides, ...(normalized.includePrettier ? [prettierConfig] : []), From 0d4ed49ade5308c7db62aa538ca9ec86d86641b2 Mon Sep 17 00:00:00 2001 From: artiphishle Date: Mon, 17 Aug 2026 05:17:18 +0200 Subject: [PATCH 15/19] chore(lint): fix --- src/tools/eslint/moduleOwnership.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/tools/eslint/moduleOwnership.ts b/src/tools/eslint/moduleOwnership.ts index 4a63d0f..f22b99f 100644 --- a/src/tools/eslint/moduleOwnership.ts +++ b/src/tools/eslint/moduleOwnership.ts @@ -51,9 +51,7 @@ export function createModuleOwnershipConfig(files: string[]): FlatConfigItem { }; } -export function createModuleOwnershipExemptionConfig( - packageEntrypoints: string[], -): FlatConfigItem { +export function createModuleOwnershipExemptionConfig(packageEntrypoints: string[]): FlatConfigItem { return { files: [...INDEX_BARREL_FILES, ...packageEntrypoints], plugins: { ankhorage: moduleOwnershipPlugin }, From 9257d78ce34cdaba8b89a99e7d734d35ccc552a2 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:21:50 +0200 Subject: [PATCH 16/19] fix(eslint): match forward export entrypoints by filename --- src/tools/eslint/moduleOwnership.ts | 58 ++++++++++++++++++----------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/src/tools/eslint/moduleOwnership.ts b/src/tools/eslint/moduleOwnership.ts index f22b99f..15aa321 100644 --- a/src/tools/eslint/moduleOwnership.ts +++ b/src/tools/eslint/moduleOwnership.ts @@ -1,17 +1,10 @@ +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); @@ -21,16 +14,42 @@ function isForwardExport(context: Rule.RuleContext, node: Rule.Node): boolean { ); } +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 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' }); @@ -43,18 +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, plugins: { ankhorage: moduleOwnershipPlugin }, - rules: { 'ankhorage/no-forward-exports': 'error' }, - }; -} - -export function createModuleOwnershipExemptionConfig(packageEntrypoints: string[]): FlatConfigItem { - return { - files: [...INDEX_BARREL_FILES, ...packageEntrypoints], - plugins: { ankhorage: moduleOwnershipPlugin }, - rules: { 'ankhorage/no-forward-exports': 'off' }, + rules: { + 'ankhorage/no-forward-exports': ['error', { allowedFiles: packageEntrypoints }], + }, }; } From 70e69b72db6b6a7b47d39bcafbc229964e66f770 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:22:02 +0200 Subject: [PATCH 17/19] fix(eslint): resolve absolute package entrypoint files --- src/tools/eslint/packageEntrypoints.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/tools/eslint/packageEntrypoints.ts b/src/tools/eslint/packageEntrypoints.ts index 0fca2f0..7a6775c 100644 --- a/src/tools/eslint/packageEntrypoints.ts +++ b/src/tools/eslint/packageEntrypoints.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import { dirname, relative, resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { resolveProjectPackageJsonPath } from './packageJsonPath.js'; import type { DevtoolsConfigOptions } from './types.js'; @@ -18,9 +18,7 @@ export function resolvePackageEntrypointFiles(options: DevtoolsConfigOptions): s ...collectStringTargets(packageJson.exports), ]; - return [ - ...new Set(targets.flatMap((target) => toSourceCandidates(target, packageJsonPath, options))), - ]; + return [...new Set(targets.flatMap((target) => toSourceCandidates(target, packageJsonPath)))]; } function readPackageJson(packageJsonPath: string): Record { @@ -38,11 +36,7 @@ function collectStringTargets(value: unknown): string[] { return Object.values(value).flatMap(collectStringTargets); } -function toSourceCandidates( - target: string, - packageJsonPath: string, - options: DevtoolsConfigOptions, -): string[] { +function toSourceCandidates(target: string, packageJsonPath: string): string[] { const normalizedTarget = target.replace(/^\.\//u, ''); const sourceTarget = normalizedTarget.startsWith('dist/') ? `src/${normalizedTarget.slice('dist/'.length)}` @@ -51,8 +45,7 @@ function toSourceCandidates( const sourceBase = sourceTarget.replace(OUTPUT_EXTENSION, ''); const absoluteBase = resolve(dirname(packageJsonPath), sourceBase); - const relativeBase = relative(options.tsconfigRootDir, absoluteBase).replaceAll('\\', '/'); - return SOURCE_EXTENSIONS.map((extension) => `${relativeBase}.${extension}`); + return SOURCE_EXTENSIONS.map((extension) => `${absoluteBase}.${extension}`); } function isRecord(value: unknown): value is Record { From 298645ee4a32b3119c5a37ed7165d1eb33ae4d45 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:22:26 +0200 Subject: [PATCH 18/19] fix(eslint): pass entrypoints to ownership rule --- src/tools/eslint/index.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/tools/eslint/index.ts b/src/tools/eslint/index.ts index c54fe22..d80c3d6 100644 --- a/src/tools/eslint/index.ts +++ b/src/tools/eslint/index.ts @@ -34,10 +34,7 @@ import simpleImportSort from 'eslint-plugin-simple-import-sort'; import unusedImports from 'eslint-plugin-unused-imports'; import tseslint from 'typescript-eslint'; -import { - createModuleOwnershipConfig, - createModuleOwnershipExemptionConfig, -} from './moduleOwnership.js'; +import { createModuleOwnershipConfig } from './moduleOwnership.js'; import { resolvePackageEntrypointFiles } from './packageEntrypoints.js'; import { resolveEslintProfile } from './profile.js'; import type { @@ -89,8 +86,7 @@ export function createConfig(options: DevtoolsConfigOptions): Linter.Config[] { { ...js.configs.recommended, files: normalized.files }, ...createTypeCheckedConfigs(normalized), createBaseConfig(normalized), - createModuleOwnershipConfig(normalized.files), - createModuleOwnershipExemptionConfig(packageEntrypoints), + createModuleOwnershipConfig(normalized.files, packageEntrypoints), ...createProfileConfigs(profile, normalized.files), ...normalized.overrides, ...(normalized.includePrettier ? [prettierConfig] : []), From e1a095163885bb4794a4386b43ce322a0a5e32d9 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:32:57 +0200 Subject: [PATCH 19/19] test(eslint): stabilize package entrypoint integration fixture --- src/tools/eslint/integration.test.ts | 61 ++++++++++++++++++---------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/src/tools/eslint/integration.test.ts b/src/tools/eslint/integration.test.ts index a78a62d..bb865b6 100644 --- a/src/tools/eslint/integration.test.ts +++ b/src/tools/eslint/integration.test.ts @@ -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,26 +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 mkdir(path.dirname(filePath), { recursive: true }); - 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, }; } @@ -56,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])); } @@ -119,12 +133,15 @@ it('allows forward exports from declared non-index package entrypoints', async ( }), ); - const root = await workspace.lint("export * from './index';\n", 'src/root.ts'); - const binding = await workspace.lint( - "export { value } from './value';\n", - 'src/bindingAuthoringModel.ts', - ); - const undeclared = await workspace.lint("export * from './value';\n", 'src/implementation.ts'); + 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');