Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dd25af7
fix(lint): resolve package metadata path centrally
artiphishle Aug 17, 2026
2e270ba
refactor(lint): share package metadata resolution
artiphishle Aug 17, 2026
9bbdd40
fix(lint): resolve declared package entrypoints
artiphishle Aug 17, 2026
3aacc05
fix(lint): allow declared package entrypoints
artiphishle Aug 17, 2026
f2d7a42
fix(lint): exempt declared package entrypoints
artiphishle Aug 17, 2026
66f087d
test(lint): cover declared non-index package entrypoints
artiphishle Aug 17, 2026
e9a2a20
chore: add package entrypoint lint changeset
artiphishle Aug 17, 2026
502f08d
style(lint): format package entrypoint resolver
artiphishle Aug 17, 2026
903c5ee
chore(lint): fix
artiphishle Aug 17, 2026
db24a11
Merge branch 'lint/50-package-entrypoint-forward-exports' of github.c…
artiphishle Aug 17, 2026
f4390a5
docs: update
artiphishle Aug 17, 2026
b345e73
test(lint): create nested workspace directories
artiphishle Aug 17, 2026
10c0f8f
fix(lint): scope forward-export exemptions explicitly
artiphishle Aug 17, 2026
a70e1d5
refactor(lint): name entrypoint paths by purpose
artiphishle Aug 17, 2026
c15c88e
fix(lint): exempt only declared forward-export entrypoints
artiphishle Aug 17, 2026
0d4ed49
chore(lint): fix
artiphishle Aug 17, 2026
9257d78
fix(eslint): match forward export entrypoints by filename
artiphishle Aug 17, 2026
70e69b7
fix(eslint): resolve absolute package entrypoint files
artiphishle Aug 17, 2026
298645e
fix(eslint): pass entrypoints to ownership rule
artiphishle Aug 17, 2026
e1a0951
test(eslint): stabilize package entrypoint integration fixture
artiphishle Aug 17, 2026
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
5 changes: 5 additions & 0 deletions .changeset/quiet-entrypoints.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions src/tools/eslint/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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] : []),
Expand Down
78 changes: 62 additions & 16 deletions src/tools/eslint/integration.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -13,6 +13,8 @@ type LintResult = Awaited<ReturnType<ESLint['lintFiles']>>[number];
interface LintWorkspace {
readonly root: string;
lint(code: string, fileName: string, fix?: boolean): Promise<LintResult>;
lintFile(fileName: string, fix?: boolean): Promise<LintResult>;
write(code: string, fileName: string): Promise<void>;
}

async function createLintWorkspace(
Expand All @@ -27,25 +29,37 @@ async function createLintWorkspace(
}),
);

async function write(code: string, fileName: string): Promise<void> {
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<LintResult> {
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,
};
}

Expand All @@ -55,6 +69,7 @@ async function lintFresh(code: string, fileName: string): Promise<LintResult> {
}

function ruleIds(result: LintResult): string[] {
expect(result.messages.filter((message) => message.fatal === true)).toEqual([]);
return result.messages.flatMap((message) => (message.ruleId === null ? [] : [message.ruleId]));
}

Expand Down Expand Up @@ -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');
Expand Down
61 changes: 40 additions & 21 deletions src/tools/eslint/moduleOwnership.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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' });
},
};
},
Expand All @@ -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 }],
},
};
}
53 changes: 53 additions & 0 deletions src/tools/eslint/packageEntrypoints.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
20 changes: 20 additions & 0 deletions src/tools/eslint/packageJsonPath.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading