diff --git a/.gitignore b/.gitignore index 74a6163..c20e03f 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,4 @@ docs-old/ git-short git-short.sh .claude/CLAUDE.local.md +mise.toml diff --git a/examples/tsconfig.inherit.array-extends.json b/examples/tsconfig.inherit.array-extends.json new file mode 100644 index 0000000..9e9a1c9 --- /dev/null +++ b/examples/tsconfig.inherit.array-extends.json @@ -0,0 +1,3 @@ +{ + "extends": ["./tsconfig.inherit.base.json"] +} diff --git a/examples/tsconfig.inherit.base.json b/examples/tsconfig.inherit.base.json new file mode 100644 index 0000000..a810323 --- /dev/null +++ b/examples/tsconfig.inherit.base.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "strict": true + }, + "include": ["src/**/*.ts", "src/**/*.vue"], + "exclude": ["dist/**", "node_modules/**"] +} diff --git a/examples/tsconfig.inherit.invalid-extends.json b/examples/tsconfig.inherit.invalid-extends.json new file mode 100644 index 0000000..86e1a0c --- /dev/null +++ b/examples/tsconfig.inherit.invalid-extends.json @@ -0,0 +1,4 @@ +{ + "extends": [123], + "include": ["src/**/*.ts"] +} diff --git a/examples/tsconfig.inherit.leaf.json b/examples/tsconfig.inherit.leaf.json new file mode 100644 index 0000000..1400363 --- /dev/null +++ b/examples/tsconfig.inherit.leaf.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.inherit.mid.json" +} diff --git a/examples/tsconfig.inherit.mid.json b/examples/tsconfig.inherit.mid.json new file mode 100644 index 0000000..42ff9cc --- /dev/null +++ b/examples/tsconfig.inherit.mid.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.inherit.base.json", + "exclude": ["dist/**", "node_modules/**", "**/*.test.ts"] +} diff --git a/examples/tsconfig.inherit.no-ext.json b/examples/tsconfig.inherit.no-ext.json new file mode 100644 index 0000000..d6d01ec --- /dev/null +++ b/examples/tsconfig.inherit.no-ext.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.inherit.base" +} diff --git a/examples/tsconfig.inherit.no-include.json b/examples/tsconfig.inherit.no-include.json new file mode 100644 index 0000000..87bd5ca --- /dev/null +++ b/examples/tsconfig.inherit.no-include.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "strict": true + }, + "exclude": ["dist/**"] +} diff --git a/examples/tsconfig.inherit.override.json b/examples/tsconfig.inherit.override.json new file mode 100644 index 0000000..6cdb3bd --- /dev/null +++ b/examples/tsconfig.inherit.override.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.inherit.base.json", + "include": ["src/**/*.ts"] +} diff --git a/src/compilers/__tests__/get.inherited.file.scope.test.ts b/src/compilers/__tests__/get.inherited.file.scope.test.ts new file mode 100644 index 0000000..5c6031e --- /dev/null +++ b/src/compilers/__tests__/get.inherited.file.scope.test.ts @@ -0,0 +1,94 @@ +import { getInheritedFileScope } from '#/compilers/getInheritedFileScope'; +import { posixJoin } from '#/modules/path/modules/posixJoin'; +import { describe, expect, it } from 'vitest'; + +const examplesDir = posixJoin(process.cwd(), 'examples'); + +describe('getInheritedFileScope', () => { + it('returns include and exclude directly declared in the file', () => { + const tsconfigPath = posixJoin(examplesDir, 'tsconfig.inherit.base.json'); + const result = getInheritedFileScope(tsconfigPath); + + expect(result.include).toEqual(['src/**/*.ts', 'src/**/*.vue']); + expect(result.exclude).toEqual(['dist/**', 'node_modules/**']); + }); + + it('inherits include from base when child has no include', () => { + // tsconfig.inherit.mid.json extends base and has only exclude + const tsconfigPath = posixJoin(examplesDir, 'tsconfig.inherit.mid.json'); + const result = getInheritedFileScope(tsconfigPath); + + expect(result.include).toEqual(['src/**/*.ts', 'src/**/*.vue']); + expect(result.exclude).toEqual(['dist/**', 'node_modules/**', '**/*.test.ts']); + }); + + it('traverses two levels to find include', () => { + // tsconfig.inherit.leaf.json → mid (no include) → base (has include) + const tsconfigPath = posixJoin(examplesDir, 'tsconfig.inherit.leaf.json'); + const result = getInheritedFileScope(tsconfigPath); + + expect(result.include).toEqual(['src/**/*.ts', 'src/**/*.vue']); + expect(result.exclude).toEqual(['dist/**', 'node_modules/**', '**/*.test.ts']); + }); + + it('child include overrides base include', () => { + // tsconfig.inherit.override.json extends base but declares its own include + const tsconfigPath = posixJoin(examplesDir, 'tsconfig.inherit.override.json'); + const result = getInheritedFileScope(tsconfigPath); + + expect(result.include).toEqual(['src/**/*.ts']); + expect(result.exclude).toEqual(['dist/**', 'node_modules/**']); + }); + + it('returns empty arrays when no include or exclude found in chain', () => { + const tsconfigPath = posixJoin(examplesDir, 'tsconfig.empty.json'); + const result = getInheritedFileScope(tsconfigPath); + + expect(result.include).toEqual([]); + expect(result.exclude).toEqual([]); + }); + + it('returns only exclude when chain has no include', () => { + const tsconfigPath = posixJoin(examplesDir, 'tsconfig.inherit.no-include.json'); + const result = getInheritedFileScope(tsconfigPath); + + expect(result.include).toEqual([]); + expect(result.exclude).toEqual(['dist/**']); + }); + + it('does not loop infinitely on a non-existent extends target', () => { + const tsconfigPath = posixJoin(examplesDir, 'tsconfig.inherit.base.json'); + // base has no extends, so traversal stops after one file + const result = getInheritedFileScope(tsconfigPath); + + expect(result).toBeDefined(); + }); + + it('handles array extends (TypeScript 5.0+) and inherits include from the first entry', () => { + // tsconfig.inherit.array-extends.json: { "extends": ["./tsconfig.inherit.base.json"] } + const tsconfigPath = posixJoin(examplesDir, 'tsconfig.inherit.array-extends.json'); + const result = getInheritedFileScope(tsconfigPath); + + expect(result.include).toEqual(['src/**/*.ts', 'src/**/*.vue']); + expect(result.exclude).toEqual(['dist/**', 'node_modules/**']); + }); + + it('resolves extends path that omits the .json extension', () => { + // tsconfig.inherit.no-ext.json: { "extends": "./tsconfig.inherit.base" } + const tsconfigPath = posixJoin(examplesDir, 'tsconfig.inherit.no-ext.json'); + const result = getInheritedFileScope(tsconfigPath); + + expect(result.include).toEqual(['src/**/*.ts', 'src/**/*.vue']); + expect(result.exclude).toEqual(['dist/**', 'node_modules/**']); + }); + + it('stops traversal when extends array contains a non-string entry', () => { + // tsconfig.inherit.invalid-extends.json: { "extends": [123], "include": [...] } + // has its own include, but extends[0] is a number → traversal stops before following parent + const tsconfigPath = posixJoin(examplesDir, 'tsconfig.inherit.invalid-extends.json'); + const result = getInheritedFileScope(tsconfigPath); + + expect(result.include).toEqual(['src/**/*.ts']); + expect(result.exclude).toEqual([]); + }); +}); diff --git a/src/compilers/getInheritedFileScope.ts b/src/compilers/getInheritedFileScope.ts new file mode 100644 index 0000000..7c9455e --- /dev/null +++ b/src/compilers/getInheritedFileScope.ts @@ -0,0 +1,60 @@ +import { getFileScope } from '#/compilers/getFileScope'; +import path from 'node:path'; +import * as tsm from 'ts-morph'; + +interface IFileScope { + include: string[]; + exclude: string[]; +} + +/** + * Traverse the tsconfig extends chain and collect include/exclude patterns. + * + * TypeScript's extends is an overwrite (not merge): the most-derived config wins. + * include and exclude are resolved independently — each comes from the first file + * in the chain that explicitly declares it. + * + * @param tsconfigPath - absolute path to the tsconfig file + */ +export function getInheritedFileScope(tsconfigPath: string): IFileScope { + const visited = new Set(); + let currentPath = path.resolve(tsconfigPath); + let foundInclude: string[] | null = null; + let foundExclude: string[] | null = null; + + while (!visited.has(currentPath)) { + visited.add(currentPath); + + const configFile = tsm.ts.readConfigFile(currentPath, tsm.ts.sys.readFile.bind(tsm.ts)); + if (configFile.error != null) break; + + const { include, exclude } = getFileScope(configFile.config); + + if (foundInclude == null && include.length > 0) { + foundInclude = include; + } + + if (foundExclude == null && exclude.length > 0) { + foundExclude = exclude; + } + + if (foundInclude != null && foundExclude != null) break; + + const raw = configFile.config as { extends?: unknown }; + const extendsValue: unknown = raw.extends; + if (extendsValue == null) break; + + // TypeScript 5.0+ supports array extends; earlier versions use a string + const extendsEntries: unknown[] = Array.isArray(extendsValue) ? extendsValue : [extendsValue]; + const firstExtends: unknown = extendsEntries[0]; + if (typeof firstExtends !== 'string') break; + + const resolved = path.resolve(path.dirname(currentPath), firstExtends); + currentPath = resolved.endsWith('.json') ? resolved : `${resolved}.json`; + } + + return { + include: foundInclude ?? [], + exclude: foundExclude ?? [], + }; +} diff --git a/src/configs/transforms/createBuildOptions.ts b/src/configs/transforms/createBuildOptions.ts index 9fefda6..d6b1fe4 100644 --- a/src/configs/transforms/createBuildOptions.ts +++ b/src/configs/transforms/createBuildOptions.ts @@ -59,11 +59,23 @@ export async function createBuildOptions( ...option, include: getTsIncludeFiles({ config: { include: option.include }, - extend: { tsconfig, resolved: { projectDirPath: projectPath } }, + extend: { + tsconfig, + resolved: { + projectDirPath: path.dirname(projectPath), + projectFilePath: projectPath, + }, + }, }), exclude: getTsExcludeFiles({ config: { exclude: option.exclude }, - extend: { tsconfig }, + extend: { + tsconfig, + resolved: { + projectDirPath: path.dirname(projectPath), + projectFilePath: projectPath, + }, + }, }), }, ); @@ -81,11 +93,23 @@ export async function createBuildOptions( ...option, include: getTsIncludeFiles({ config: { include: option.include }, - extend: { tsconfig, resolved: { projectDirPath: projectPath } }, + extend: { + tsconfig, + resolved: { + projectDirPath: path.dirname(projectPath), + projectFilePath: projectPath, + }, + }, }), exclude: getTsExcludeFiles({ config: { exclude: option.exclude }, - extend: { tsconfig }, + extend: { + tsconfig, + resolved: { + projectDirPath: path.dirname(projectPath), + projectFilePath: projectPath, + }, + }, }), }, ); @@ -102,11 +126,23 @@ export async function createBuildOptions( ...option, include: getTsIncludeFiles({ config: { include: option.include }, - extend: { tsconfig, resolved: { projectDirPath: projectPath } }, + extend: { + tsconfig, + resolved: { + projectDirPath: path.dirname(projectPath), + projectFilePath: projectPath, + }, + }, }), exclude: getTsExcludeFiles({ config: { exclude: option.exclude }, - extend: { tsconfig }, + extend: { + tsconfig, + resolved: { + projectDirPath: path.dirname(projectPath), + projectFilePath: projectPath, + }, + }, }), }, ); @@ -125,7 +161,10 @@ export async function createBuildOptions( ? toArray(argv.include) : getTsIncludeFiles({ config: { include: [] }, - extend: { tsconfig, resolved: { projectDirPath: projectPath } }, + extend: { + tsconfig, + resolved: { projectDirPath: path.dirname(projectPath), projectFilePath: projectPath }, + }, }); const exclude = @@ -133,7 +172,10 @@ export async function createBuildOptions( ? toArray(argv.exclude) : getTsExcludeFiles({ config: { exclude: [] }, - extend: { tsconfig }, + extend: { + tsconfig, + resolved: { projectDirPath: path.dirname(projectPath), projectFilePath: projectPath }, + }, }); const mode = argv.mode ?? CE_CTIX_BUILD_MODE.BUNDLE_MODE; diff --git a/src/modules/file/getTsExcludeFiles.ts b/src/modules/file/getTsExcludeFiles.ts index 728d930..b6245dd 100644 --- a/src/modules/file/getTsExcludeFiles.ts +++ b/src/modules/file/getTsExcludeFiles.ts @@ -1,15 +1,17 @@ -import { getFileScope } from '#/compilers/getFileScope'; +import { getInheritedFileScope } from '#/compilers/getInheritedFileScope'; import type { IExtendOptions } from '#/configs/interfaces/IExtendOptions'; import type { IModeGenerateOptions } from '#/configs/interfaces/IModeGenerateOptions'; export function getTsExcludeFiles(config: { config: Pick; - extend: Pick; + extend: Pick & { + resolved: Pick; + }; }): string[] { if (config.config.exclude != null && config.config.exclude.length > 0) { return config.config.exclude; } - const { exclude } = getFileScope(config.extend.tsconfig.raw); + const { exclude } = getInheritedFileScope(config.extend.resolved.projectFilePath); return exclude; } diff --git a/src/modules/file/getTsIncludeFiles.ts b/src/modules/file/getTsIncludeFiles.ts index 8826550..f104435 100644 --- a/src/modules/file/getTsIncludeFiles.ts +++ b/src/modules/file/getTsIncludeFiles.ts @@ -1,27 +1,18 @@ -import { getFileScope } from '#/compilers/getFileScope'; +import { getInheritedFileScope } from '#/compilers/getInheritedFileScope'; import type { IExtendOptions } from '#/configs/interfaces/IExtendOptions'; import type { IModeGenerateOptions } from '#/configs/interfaces/IModeGenerateOptions'; -import { isDescendant } from 'my-node-fp'; export function getTsIncludeFiles(config: { config: Pick; extend: Pick & { - resolved: Pick; + resolved: Pick; }; }): string[] { if (config.config.include != null && config.config.include.length > 0) { return config.config.include; } - const { include } = getFileScope(config.extend.tsconfig.raw); + const { include } = getInheritedFileScope(config.extend.resolved.projectFilePath); - if (include.length > 0) { - return include; - } - - const filePaths = config.extend.tsconfig.fileNames.filter((filePath) => - isDescendant(config.extend.resolved.projectDirPath, filePath), - ); - - return filePaths; + return include; }