Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions packages/cli/src/lib/tsconfig-gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import * as v from 'valibot';
import type { PackageCapabilities } from './discovery.ts';
import { readJsonFile } from './file-writer.ts';

/** Directories included in tsconfig.json for type-checking. */
const typeCheckInclude = ['bin', 'scripts', 'src', 'test', 'e2e', '*.config.*'] as const;
/** Directories and file patterns included in tsconfig.json for type-checking. */
export const typeCheckInclude = ['bin', 'scripts', 'src', 'test', 'e2e', '*', '.*'] as const;

/** Directories included in tsconfig.build.json for compilation. */
export const buildInclude = ['bin', 'src'] as const;
Expand Down
28 changes: 22 additions & 6 deletions packages/cli/src/lib/turbo-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
typecheckTs,
} from '../commands/leaf/index.ts';
import type { WorkspaceDiscovery } from './discovery.ts';
import { typeCheckInclude } from './tsconfig-gen.ts';
import { aggregateTasks } from './turbo-aggregates.ts';

/** Turbo-only aggregate task names (no CLI handler). */
Expand Down Expand Up @@ -98,11 +99,23 @@ export const resolveToolFlags = (discovery: WorkspaceDiscovery): ToolFlags => {
/** Creates a topological (cross-package) task dependency. */
const topo = (task: string): string => `^${task}`;

/** Script file extensions for turbo input globs. Sorted alphabetically. */
const scriptExts = ['cjs', 'cts', 'js', 'jsx', 'mjs', 'mts', 'ts', 'tsx'] as const;

const isGlob = (pattern: string): boolean => /[*?\{]/v.test(pattern);

/** Converts a tsconfig `include` entry to a turbo input glob. */
const toTurboGlob = (include: string): string =>
isGlob(include) ? include : `${include}/**`;
/**
* Converts a tsconfig `include` entry to turbo input glob(s).
* Directories become recursive (`src` → `src/**`).
* Root-level globs (`*`, `.*`) expand to per-extension patterns
* since turbo globs are extension-agnostic unlike tsconfig.
*/
const toTurboGlobs = (include: string): readonly string[] => {
if (include === '*') return scriptExts.map(ext => `*.${ext}`);
if (include === '.*') return scriptExts.map(ext => `.*.${ext}`);
if (isGlob(include)) return [include];
return [`${include}/**`];
};

const typecheckTasks = (flags: ToolFlags): readonly ConditionalEntry<TurboTask>[] => [
{
Expand All @@ -112,7 +125,7 @@ const typecheckTasks = (flags: ToolFlags): readonly ConditionalEntry<TurboTask>[
dependsOn: [...(flags.hasGenerate ? [Aggregate.generate] : [])],
inputs: [
'$TURBO_ROOT$/tsconfig.base.json',
'bin/**', 'src/**', 'test/**', 'e2e/**', 'scripts/**',
...typeCheckInclude.flatMap(toTurboGlobs),
'tsconfig.json', 'tsconfig.*.json',
],
outputs: [],
Expand All @@ -128,7 +141,7 @@ const compileTasks = (flags: ToolFlags): readonly ConditionalEntry<TurboTask>[]
dependsOn: [topo(compileTs.name), ...(flags.hasGenerate ? [Aggregate.generate] : [])],
inputs: [
'$TURBO_ROOT$/tsconfig.base.json', '$TURBO_ROOT$/tsconfig.build.json',
...flags.compileIncludes.map(toTurboGlob), 'tsconfig.build.json',
...flags.compileIncludes.flatMap(toTurboGlobs), 'tsconfig.build.json',
],
outputs: ['dist/source/**'],
},
Expand Down Expand Up @@ -172,7 +185,10 @@ const lintTasks = (flags: ToolFlags): readonly ConditionalEntry<TurboTask>[] =>
* vitest configs are executable TypeScript, not statically parseable.
* Broadening beyond test directories is intentional: tests import source.
*/
const testInputs = ['bin/**', 'src/**', 'test/**', 'scripts/**', 'vitest.config.ts'];
const testInputs = [
'bin/**', 'src/**', 'test/**', 'scripts/**',
'vitest.config.*', '!vitest.config.e2e.*',
];

const testTasks = (flags: ToolFlags): readonly ConditionalEntry<TurboTask>[] => {
const deps = [
Expand Down
27 changes: 21 additions & 6 deletions packages/cli/test/turbo-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ describe.concurrent(generateTurboJson, () => {
expect(result.tasks).toHaveProperty('typecheck:ts');
});

it('typecheck:ts inputs include root-level script files', ({ expect }) => {
const discovery = makeDiscovery([
makeCapabilities({ hasTypeScript: true }),
]);

const result = generateTurboJson(discovery);
const inputs = result.tasks['typecheck:ts']?.inputs ?? [];

expect(inputs).toStrictEqual(expect.arrayContaining([
'*.ts', '*.mjs', '.*.ts', '.*.mjs',
]));
});

it('excludes typecheck:ts when no package has TypeScript', ({ expect }) => {
const discovery = makeDiscovery([makeCapabilities()]);

Expand Down Expand Up @@ -283,27 +296,29 @@ describe.concurrent(generateTurboJson, () => {
expect(inputs).not.toContain('scripts/**');
});

it('test:vitest:fast inputs use explicit vitest config filename', ({ expect }) => {
it('test:vitest:fast inputs match all vitest configs except e2e', ({ expect }) => {
const discovery = makeDiscovery([
makeCapabilities({ hasTest: true, hasVitest: true }),
]);

const result = generateTurboJson(discovery);
const inputs = result.tasks['test:vitest:fast']?.inputs ?? [];

expect(inputs).toContain('vitest.config.ts');
expect(inputs).not.toContain('vitest.config.*');
expect(inputs).toStrictEqual(expect.arrayContaining([
'vitest.config.*', '!vitest.config.e2e.*',
]));
});

it('test:vitest:slow inputs use explicit vitest config filename', ({ expect }) => {
it('test:vitest:slow inputs match all vitest configs except e2e', ({ expect }) => {
const discovery = makeDiscovery([
makeCapabilities({ hasTest: true, hasVitest: true }),
]);

const result = generateTurboJson(discovery);
const inputs = result.tasks['test:vitest:slow']?.inputs ?? [];

expect(inputs).toContain('vitest.config.ts');
expect(inputs).not.toContain('vitest.config.*');
expect(inputs).toStrictEqual(expect.arrayContaining([
'vitest.config.*', '!vitest.config.e2e.*',
]));
});
});
3 changes: 2 additions & 1 deletion packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"src",
"test",
"e2e",
"*.config.*"
"*",
".*"
]
}
23 changes: 23 additions & 0 deletions packages/eslint-config/src/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* All script file extensions supported by the shared ESLint configuration.
* Sorted alphabetically. Used to derive glob patterns for plugin configs.
*/
export const scriptFileExtensions = [
'cjs', 'cts', 'js', 'jsx', 'mjs', 'mts', 'ts', 'tsx',
] as const;

/** Glob patterns matching all script files in any directory. */
export const scriptFiles: string[] = scriptFileExtensions.map(
ext => `**/*.${ext}`,
);

/**
* TypeScript-only file extensions. Used to scope rules that require
* TypeScript syntax (e.g. `export type`, `: ReturnType`, `import type`).
*/
export const tsOnlyExtensions = ['cts', 'mts', 'ts', 'tsx'] as const;

/** Glob patterns matching only TypeScript files. */
export const tsOnlyFiles: string[] = tsOnlyExtensions.map(
ext => `**/*.${ext}`,
);
17 changes: 13 additions & 4 deletions packages/eslint-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@
/// <reference path="./eslint-plugin-promise.d.ts" />
import type { Linter } from 'eslint';
import { defineConfig } from 'eslint/config';
import { scriptFileExtensions } from './files.ts';
import { plugins } from './plugins/index.ts';

export {
scriptFileExtensions,
scriptFiles,
tsOnlyExtensions,
tsOnlyFiles,
} from './files.ts';

const entryPointDirs = ['**/bin', '**/scripts'] as const;

/** Default file patterns for entry points (bin and scripts directories). */
export const defaultEntryPoints: readonly string[] = [
'**/bin/**/*.ts',
'**/scripts/**/*.ts',
];
export const defaultEntryPoints: readonly string[] = entryPointDirs.flatMap(
dir => scriptFileExtensions.map(ext => `${dir}/**/*.${ext}`),
);

/** Options for the shared ESLint configuration. */
export interface ESLintConfigureOptions {
Expand Down
7 changes: 4 additions & 3 deletions packages/eslint-config/src/plugins/core.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Linter } from 'eslint';
import { scriptFiles } from '../files.ts';
import type { PluginFactory } from '../index.ts';

// --- Core ESLint rules not covered by presets ---

/** Core ESLint rules not covered by presets. */
const coreRuleConfig: Linter.Config = {
files: ['**/*.ts'],
files: [...scriptFiles],
rules: {
// Justification: Methods that don't use this should be static or extracted
'class-methods-use-this': 'warn',
Expand Down Expand Up @@ -48,7 +49,7 @@ const browserConfigs = (
entryPoints: readonly string[],
): Linter.Config[] => [
{
files: ['**/*.ts'],
files: [...scriptFiles],
rules: {
// Justification: Browser code should use a logger, not console
'no-alert': 'warn',
Expand All @@ -70,7 +71,7 @@ const plugin: PluginFactory = (options) => {
return [
coreRuleConfig,
{
files: ['**/*.ts'],
files: [...scriptFiles],
rules: {
// Justification: Prevents subtle unicode bugs; /v for server, /u for browser compat
'require-unicode-regexp': ['warn', { requireFlag: unicodeFlag }],
Expand Down
3 changes: 2 additions & 1 deletion packages/eslint-config/src/plugins/import-x.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { Linter } from 'eslint';
import importPlugin from 'eslint-plugin-import-x';
import { scriptFiles } from '../files.ts';
import type { PluginFactory } from '../index.ts';

// --- import-x (ordering only) ---

/** Import ordering rules via eslint-plugin-import-x. */
const importConfig: Linter.Config = {
files: ['**/*.ts'],
files: [...scriptFiles],
plugins: { 'import-x': importPlugin },
rules: {
// Justification: Case-insensitive alphabetical grouping by import type
Expand Down
3 changes: 2 additions & 1 deletion packages/eslint-config/src/plugins/jsdoc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Linter } from 'eslint';
import jsdoc from 'eslint-plugin-jsdoc';
import { tsOnlyFiles } from '../files.ts';
import type { PluginFactory } from '../index.ts';

// --- JSDoc ---
Expand All @@ -14,7 +15,7 @@ const tsdocConfig = jsdoc.configs['flat/recommended-tsdoc'] as unknown as Linter

const plugin: PluginFactory = () => [
{
files: ['**/*.ts'],
files: [...tsOnlyFiles],
plugins: tsdocConfig.plugins ?? {},
rules: {
...tsdocConfig.rules,
Expand Down
5 changes: 2 additions & 3 deletions packages/eslint-config/src/plugins/node.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { defineConfig } from 'eslint/config';
import nodePlugin from 'eslint-plugin-n';
import tseslint from 'typescript-eslint';
import { scriptFiles } from '../files.ts';
import type { PluginFactory } from '../index.ts';
import { resolveParserOptions } from './typescript.ts';

// --- Node.js ---

const nodeFiles = ['**/*.ts', '**/*.mts', '**/*.cts'];

const plugin: PluginFactory = options => [
...defineConfig({
extends: [nodePlugin.configs['flat/recommended-module']],
files: nodeFiles,
files: [...scriptFiles],
languageOptions: {
parser: tseslint.parser,
parserOptions: resolveParserOptions(options),
Expand Down
3 changes: 2 additions & 1 deletion packages/eslint-config/src/plugins/regexp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import regexpPlugin from 'eslint-plugin-regexp';
import { scriptFiles } from '../files.ts';
import type { PluginFactory } from '../index.ts';

// --- Regexp ---
Expand All @@ -7,7 +8,7 @@ import type { PluginFactory } from '../index.ts';
const plugin: PluginFactory = () => [
{
...regexpPlugin.configs['flat/recommended'],
files: ['**/*.ts'],
files: [...scriptFiles],
},
];

Expand Down
7 changes: 4 additions & 3 deletions packages/eslint-config/src/plugins/stylistic.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import stylistic from '@stylistic/eslint-plugin';
import { scriptFiles } from '../files.ts';
import type { PluginFactory } from '../index.ts';

// --- Stylistic ---

/** Stylistic formatting rules for TypeScript files. */
/** Stylistic formatting rules for script files. */
const plugin: PluginFactory = () => [
{
...stylistic.configs.customize({ braceStyle: '1tbs', semi: true, severity: 'warn' }),
files: ['**/*.ts'],
files: [...scriptFiles],
},
{
files: ['**/*.ts'],
files: [...scriptFiles],
rules: {
/*
* Justification: 100 is a compromise between full-screen monitors and
Expand Down
41 changes: 25 additions & 16 deletions packages/eslint-config/src/plugins/typescript.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import type { Linter } from 'eslint';
import tseslint from 'typescript-eslint';
import { scriptFiles, tsOnlyFiles } from '../files.ts';
import type { PluginFactory, ResolvedOptions } from '../index.ts';

// --- TypeScript rule overrides (on top of strictTypeChecked + stylisticTypeChecked) ---

/** TypeScript rule overrides on top of strictTypeChecked + stylisticTypeChecked. */
const tsRuleOverrides: Linter.Config = {
files: ['**/*.ts'],
/** Rule overrides that apply to all script files (JS + TS). */
const scriptRuleOverrides: Linter.Config = {
files: [...scriptFiles],
rules: {
// Justification: Enforces export type for type-only re-exports
'@typescript-eslint/consistent-type-exports': 'warn',
// Justification: Stable return types; catches accidental API changes
'@typescript-eslint/explicit-function-return-type': ['warn', {
allowDirectConstAssertionInArrowFunctions: true,
allowExpressions: true,
allowHigherOrderFunctions: true,
allowTypedFunctionExpressions: true,
}],
// Justification: Property syntax is contravariant (safe); method syntax is bivariant (unsafe)
'@typescript-eslint/method-signature-style': 'warn',
// Justification: Prevents snake_case drift from agents and API boundaries
Expand All @@ -28,8 +20,6 @@ const tsRuleOverrides: Linter.Config = {
trailingUnderscore: 'allow',
},
],
// Justification: Prevents runtime imports for type-only specifiers
'@typescript-eslint/no-import-type-side-effects': 'warn',
// Justification: Common sentinel values that are universally understood
'@typescript-eslint/no-magic-numbers': ['warn', {
ignore: [-1, 0, 1, 100],
Expand All @@ -53,6 +43,24 @@ const tsRuleOverrides: Linter.Config = {
},
};

/** Rule overrides that require TypeScript syntax (export type, : ReturnType, import type). */
const tsOnlyRuleOverrides: Linter.Config = {
files: [...tsOnlyFiles],
rules: {
// Justification: Enforces export type for type-only re-exports
'@typescript-eslint/consistent-type-exports': 'warn',
// Justification: Stable return types; catches accidental API changes
'@typescript-eslint/explicit-function-return-type': ['warn', {
allowDirectConstAssertionInArrowFunctions: true,
allowExpressions: true,
allowHigherOrderFunctions: true,
allowTypedFunctionExpressions: true,
}],
// Justification: Prevents runtime imports for type-only specifiers
'@typescript-eslint/no-import-type-side-effects': 'warn',
},
};

/** Resolves TypeScript parser options from the shared config options. */
export const resolveParserOptions = (
options: ResolvedOptions,
Expand All @@ -67,10 +75,11 @@ const plugin: PluginFactory = options => [
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{ languageOptions: { parserOptions: resolveParserOptions(options) } },
tsRuleOverrides,
scriptRuleOverrides,
tsOnlyRuleOverrides,
{
files: ['**/*'],
ignores: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'],
ignores: [...scriptFiles],
...tseslint.configs.disableTypeChecked,
},
];
Expand Down
Loading
Loading