From fac15d53f81b3c6cb2e4bb15fca59850aa18c20a Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:58:57 +0200 Subject: [PATCH 1/5] fix: remove forward export policy --- src/tools/eslint/index.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/tools/eslint/index.ts b/src/tools/eslint/index.ts index d80c3d6..f29dcef 100644 --- a/src/tools/eslint/index.ts +++ b/src/tools/eslint/index.ts @@ -7,10 +7,8 @@ * * 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 - * 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. + * lines per file, and modified cyclomatic complexity 15. 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 @@ -34,8 +32,6 @@ 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 { resolvePackageEntrypointFiles } from './packageEntrypoints.js'; import { resolveEslintProfile } from './profile.js'; import type { DevtoolsConfigOptions, @@ -79,14 +75,12 @@ 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, packageEntrypoints), ...createProfileConfigs(profile, normalized.files), ...normalized.overrides, ...(normalized.includePrettier ? [prettierConfig] : []), From 37e4b2e1dde2ff1838cd4a0a41a39ced80feda98 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:59:27 +0200 Subject: [PATCH 2/5] test: remove forward export policy coverage --- src/tools/eslint/integration.test.ts | 63 +--------------------------- 1 file changed, 1 insertion(+), 62 deletions(-) diff --git a/src/tools/eslint/integration.test.ts b/src/tools/eslint/integration.test.ts index bb865b6..cadaa5a 100644 --- a/src/tools/eslint/integration.test.ts +++ b/src/tools/eslint/integration.test.ts @@ -13,8 +13,6 @@ 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( @@ -58,16 +56,9 @@ async function createLintWorkspace( await write(code, fileName); return lintFile(fileName, fix); }, - lintFile, - write, }; } -async function lintFresh(code: string, fileName: string): Promise { - const workspace = await createLintWorkspace(); - return workspace.lint(code, fileName); -} - function ruleIds(result: LintResult): string[] { expect(result.messages.filter((message) => message.fatal === true)).toEqual([]); return result.messages.flatMap((message) => (message.ruleId === null ? [] : [message.ruleId])); @@ -109,63 +100,11 @@ it('executes profile-specific React and React Native rules', async () => { expect(ruleIds(nativeResult)).toContain('react-native/no-inline-styles'); }); -it('keeps export sorting active inside index barrels', async () => { +it('keeps export sorting active', async () => { const workspace = await createLintWorkspace(); const source = "export { z } from './z';\nexport { a } from './a';\n"; const result = await workspace.lint(source, 'index.ts'); expect(ruleIds(result)).toContain('simple-import-sort/exports'); - 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'); - const star = await lintFresh("export * from './value';\n", 'star.ts'); - - expect(ruleIds(named)).toContain('ankhorage/no-forward-exports'); - expect(ruleIds(typed)).toContain('ankhorage/no-forward-exports'); - expect(ruleIds(star)).toContain('ankhorage/no-forward-exports'); -}); - -it('allows declarations exported where they are defined and index barrel forward exports', async () => { - const value = await lintFresh('export const value = 1;\n', 'owned.ts'); - const type = await lintFresh('export type Value = string;\n', 'owned-type.ts'); - const barrel = await lintFresh("export { value } from './value';\n", 'index.ts'); - - expect(ruleIds(value)).not.toContain('ankhorage/no-forward-exports'); - expect(ruleIds(type)).not.toContain('ankhorage/no-forward-exports'); - expect(ruleIds(barrel)).not.toContain('ankhorage/no-forward-exports'); }); it('keeps the central TypeScript, unused-import, formatting, and restricted-import rules active', async () => { From a5739978c78f4db86657b14d700ac4ee41458cfa Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:01:05 +0200 Subject: [PATCH 3/5] refactor: remove forward export rule --- src/tools/eslint/moduleOwnership.ts | 76 ----------------------------- 1 file changed, 76 deletions(-) delete mode 100644 src/tools/eslint/moduleOwnership.ts diff --git a/src/tools/eslint/moduleOwnership.ts b/src/tools/eslint/moduleOwnership.ts deleted file mode 100644 index 15aa321..0000000 --- a/src/tools/eslint/moduleOwnership.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { basename, normalize } from 'node:path'; - -import type { ESLint, Rule } from 'eslint'; - -import type { FlatConfigItem } from './types.js'; - -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; - 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: [ - { - 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' }); - }, - }; - }, -}; - -const moduleOwnershipPlugin = { - rules: { 'no-forward-exports': noForwardExportsRule }, -} satisfies ESLint.Plugin; - -export function createModuleOwnershipConfig( - files: string[], - packageEntrypoints: string[] = [], -): FlatConfigItem { - return { - files, - plugins: { ankhorage: moduleOwnershipPlugin }, - rules: { - 'ankhorage/no-forward-exports': ['error', { allowedFiles: packageEntrypoints }], - }, - }; -} From cdc853052f610aee0fc3aca9faa6b641204bc8a3 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:01:14 +0200 Subject: [PATCH 4/5] refactor: remove forward export entrypoint resolver --- src/tools/eslint/packageEntrypoints.ts | 53 -------------------------- 1 file changed, 53 deletions(-) delete mode 100644 src/tools/eslint/packageEntrypoints.ts diff --git a/src/tools/eslint/packageEntrypoints.ts b/src/tools/eslint/packageEntrypoints.ts deleted file mode 100644 index 7a6775c..0000000 --- a/src/tools/eslint/packageEntrypoints.ts +++ /dev/null @@ -1,53 +0,0 @@ -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); -} From 9066ccb46f507637f7cf900d4c132cd6a70133b0 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:01:23 +0200 Subject: [PATCH 5/5] chore: add forward export policy changeset --- .changeset/remove-forward-export-policy.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/remove-forward-export-policy.md diff --git a/.changeset/remove-forward-export-policy.md b/.changeset/remove-forward-export-policy.md new file mode 100644 index 0000000..35a88a1 --- /dev/null +++ b/.changeset/remove-forward-export-policy.md @@ -0,0 +1,5 @@ +--- +'@ankhorage/devtools': patch +--- + +Remove the organization-wide `ankhorage/no-forward-exports` ESLint policy and its package-entrypoint resolver. Forward-export and module-ownership decisions are no longer enforced globally by Devtools.