diff --git a/plans/refactor/STATE.md b/plans/refactor/STATE.md index 93421a3..122f93d 100644 --- a/plans/refactor/STATE.md +++ b/plans/refactor/STATE.md @@ -5,6 +5,31 @@ - First implementation slice for CLI execution semantics has been delivered. - The target architecture is described in [`PLAN.md`](PLAN.md). +## Implemented in full multi-tsconfig targeting alignment slice + +- Completed canonical tsconfig normalization in [`src/config.ts`](../../src/config.ts): + - all configured `domains.eslint.tsconfigPaths` are resolved to absolute paths + - unreadable or missing tsconfig files are filtered out + - resulting tsconfig list is deduplicated and deterministically sorted + - fallback to root `tsconfig.json` remains when available and readable +- Completed union-based ESLint target derivation in [`src/utils.ts`](../../src/utils.ts): + - target files are now derived from the union of all configured tsconfig includes + - excludes are merged with cross-tsconfig overlap handling + - `forceInclude` is merged on top and can suppress matching ignore entries + - file and ignore output is deduplicated and deterministically sorted +- Completed include normalization correctness in [`src/utils.ts`](../../src/utils.ts): + - include entries that already carry an extension/glob extension are preserved as-is + - only extensionless include entries are expanded with supported ESLint extensions + - malformed glob synthesis paths from blanket suffixing are eliminated +- Completed parser-project/target alignment across ESLint runtime paths: + - [`src/configs/js.ts`](../../src/configs/js.ts) continues to source parser project from resolved tsconfig list + - [`src/utils.ts`](../../src/utils.ts) now injects parser `project` override from the same resolved config used for target derivation + - [`src/domains/eslint.ts`](../../src/domains/eslint.ts) detection now derives default scope from the same multi-tsconfig union logic +- Added regression coverage for multi-tsconfig alignment: + - config normalization and missing/unreadable filtering in [`tests/config.test.ts`](../../tests/config.test.ts) + - multi-tsconfig detection and include/exclude/forceInclude behaviors in [`tests/domains/index.test.ts`](../../tests/domains/index.test.ts) + - user-visible stability with missing configured tsconfig path in [`tests/bin/lint.test.ts`](../../tests/bin/lint.test.ts) + ## Implemented in config-schema cleanup + API-boundary slice - Completed lint runtime config loading/normalization using a single explicit schema: diff --git a/src/config.ts b/src/config.ts index f875419..f0bb6f2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ import type { import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import ts from 'typescript'; const MATRIXAI_LINT_CONFIG_FILENAME = 'matrixai-lint-config.json'; @@ -29,14 +30,40 @@ function stripLeadingDotSlash(value: string): string { return value.replace(/^\.\//, ''); } +function dedupeAndSort(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} + +function isReadableTsconfigPath(tsconfigPath: string): boolean { + let stats: fs.Stats; + try { + stats = fs.statSync(tsconfigPath); + } catch { + return false; + } + + if (!stats.isFile()) { + return false; + } + + const readResult = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + return readResult.error == null; +} + function sanitizeTsconfigPaths(rawValue: unknown, root: string): string[] { - return toStringArray(rawValue) - .map((tsconfigPath) => path.resolve(root, tsconfigPath)) - .filter((tsconfigPath) => fs.existsSync(tsconfigPath)); + return dedupeAndSort( + toStringArray(rawValue) + .map((tsconfigPath) => path.resolve(root, tsconfigPath)) + .filter((tsconfigPath) => isReadableTsconfigPath(tsconfigPath)), + ); } function sanitizeForceInclude(rawValue: unknown): string[] { - return toStringArray(rawValue).map((glob) => stripLeadingDotSlash(glob)); + return dedupeAndSort( + toStringArray(rawValue) + .map((glob) => stripLeadingDotSlash(glob)) + .filter((glob) => glob.length > 0), + ); } function normalizeLintConfig({ @@ -63,7 +90,7 @@ function normalizeLintConfig({ ? rawDomains.eslint : ({} as Record); - const tsconfigPaths = sanitizeTsconfigPaths( + let tsconfigPaths = sanitizeTsconfigPaths( rawEslintDomain.tsconfigPaths, resolvedRoot, ); @@ -71,11 +98,13 @@ function normalizeLintConfig({ if (tsconfigPaths.length === 0) { const rootTsconfigPath = path.join(resolvedRoot, 'tsconfig.json'); - if (fs.existsSync(rootTsconfigPath)) { + if (isReadableTsconfigPath(rootTsconfigPath)) { tsconfigPaths.push(rootTsconfigPath); } } + tsconfigPaths = dedupeAndSort(tsconfigPaths); + return { version: 2, root: resolvedRoot, diff --git a/src/domains/eslint.ts b/src/domains/eslint.ts index a55b443..5f35f40 100644 --- a/src/domains/eslint.ts +++ b/src/domains/eslint.ts @@ -5,6 +5,7 @@ import { resolveSearchRootsFromPatterns, } from './files.js'; import * as utils from '../utils.js'; +import { resolveLintConfig } from '../config.js'; const ESLINT_FILE_EXTENSIONS = [ '.js', @@ -20,15 +21,39 @@ const ESLINT_FILE_EXTENSIONS = [ const DEFAULT_ESLINT_SEARCH_ROOTS = ['./src', './scripts', './tests']; +function resolveESLintDetectionPatterns( + eslintPatterns: readonly string[] | undefined, +): string[] { + if (eslintPatterns != null && eslintPatterns.length > 0) { + return [...eslintPatterns]; + } + + const resolvedConfig = resolveLintConfig(); + const { tsconfigPaths, forceInclude } = resolvedConfig.domains.eslint; + + if (tsconfigPaths.length === 0) { + return DEFAULT_ESLINT_SEARCH_ROOTS; + } + + const { files } = utils.buildPatterns( + tsconfigPaths, + forceInclude, + process.cwd(), + resolvedConfig.root, + ); + if (files.length === 0) { + return DEFAULT_ESLINT_SEARCH_ROOTS; + } + + return files; +} + function createESLintDomainPlugin(): LintDomainPlugin { return { domain: 'eslint', description: 'Lint JavaScript/TypeScript/JSON files with ESLint.', detect: ({ eslintPatterns }) => { - const patterns = - eslintPatterns != null && eslintPatterns.length > 0 - ? eslintPatterns - : DEFAULT_ESLINT_SEARCH_ROOTS; + const patterns = resolveESLintDetectionPatterns(eslintPatterns); const searchRoots = resolveSearchRootsFromPatterns(patterns); const matchedFiles = collectFilesByExtensions( searchRoots, diff --git a/src/utils.ts b/src/utils.ts index 233164a..be043b0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,6 +10,28 @@ import { ESLint } from 'eslint'; import { LogLevel } from '@matrixai/logger'; import { resolveLintConfig } from './config.js'; +const ESLINT_TARGET_EXTENSIONS = [ + 'js', + 'mjs', + 'cjs', + 'jsx', + 'ts', + 'tsx', + 'mts', + 'cts', + 'json', +] as const; + +const ESLINT_TARGET_EXTENSION_GLOB = `.{${ESLINT_TARGET_EXTENSIONS.join(',')}}`; + +const DEFAULT_IGNORE_PATTERNS = [ + 'node_modules/**', + 'bower_components/**', + 'jspm_packages/**', +] as const; + +const GLOB_META_PATTERN = /[*?[\]{}()!+@]/; + /** * Convert verbosity count to logger level. */ @@ -38,8 +60,16 @@ async function runESLint({ }): Promise { const dirname = path.dirname(url.fileURLToPath(import.meta.url)); const defaultConfigPath = path.resolve(dirname, './configs/js.js'); + const lintConfig = resolvedConfig ?? resolveLintConfig(); + const parserProjectOverride = { + languageOptions: { + parserOptions: { + project: lintConfig.domains.eslint.tsconfigPaths, + }, + }, + }; - // PATH A – user supplied explicit globs + // PATH A - user supplied explicit globs if (explicitGlobs?.length) { logger.info('Linting with explicit patterns:'); explicitGlobs.forEach((g) => logger.info(' ' + g)); @@ -50,13 +80,13 @@ async function runESLint({ errorOnUnmatchedPattern: false, warnIgnored: false, ignorePatterns: [], // Trust caller entirely + overrideConfig: parserProjectOverride, }); return await lintAndReport(eslint, explicitGlobs, fix, logger); } - // PATH B – default behaviour (tsconfig + matrix config) - const lintConfig = resolvedConfig ?? resolveLintConfig(); + // PATH B - default behaviour (tsconfig + matrix config) const { forceInclude, tsconfigPaths } = lintConfig.domains.eslint; if (tsconfigPaths.length === 0) { @@ -68,10 +98,19 @@ async function runESLint({ tsconfigPaths.forEach((p) => logger.info(' ' + p)); const { files: patterns, ignore: ignorePats } = buildPatterns( - tsconfigPaths[0], + tsconfigPaths, forceInclude, + process.cwd(), + lintConfig.root, ); + if (patterns.length === 0) { + logger.warn( + '[matrixai-lint] ⚠ No ESLint targets were derived from configured tsconfig paths.', + ); + return false; + } + logger.info('Linting files:'); patterns.forEach((p) => logger.info(' ' + p)); @@ -81,6 +120,7 @@ async function runESLint({ errorOnUnmatchedPattern: false, warnIgnored: false, ignorePatterns: ignorePats, + overrideConfig: parserProjectOverride, }); return await lintAndReport(eslint, patterns, fix, logger); @@ -156,11 +196,143 @@ function commandExists(cmd: string): boolean { return result.status === 0; } +function toStringArray(value: unknown): string[] { + if (typeof value === 'string') { + return [value]; + } + + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string'); + } + + return []; +} + +function dedupeAndSort(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} + +function normalizeGlobValue(value: string): string { + return value.trim().replace(/\\/g, '/').replace(/^\.\//, ''); +} + +function rebasePatternToCwd({ + pattern, + baseDir, + cwd, +}: { + pattern: string; + baseDir: string; + cwd: string; +}): string { + const normalizedPattern = normalizeGlobValue(pattern); + if (normalizedPattern.length === 0) { + return ''; + } + + const platformPattern = normalizedPattern.split('/').join(path.sep); + const absolutePattern = path.isAbsolute(platformPattern) + ? platformPattern + : path.resolve(baseDir, platformPattern); + const relativePattern = path + .relative(cwd, absolutePattern) + .split(path.sep) + .join('/'); + + if (relativePattern.length === 0) { + return '.'; + } + + return normalizeGlobValue(relativePattern); +} + +function hasExtensionOrGlobExtensionPattern(value: string): boolean { + return /(^|\/)[^/]*\.[^/]*$/.test(value); +} + +function expandExtensionlessPattern(value: string): string { + const normalized = normalizeGlobValue(value).replace(/\/+$/, ''); + + if (normalized.length === 0) { + return ''; + } + + if (hasExtensionOrGlobExtensionPattern(normalized)) { + return normalized; + } + + if (!GLOB_META_PATTERN.test(normalized)) { + return `${normalized}/**/*${ESLINT_TARGET_EXTENSION_GLOB}`; + } + + if (normalized === '**') { + return `**/*${ESLINT_TARGET_EXTENSION_GLOB}`; + } + + if (normalized.endsWith('/**')) { + return `${normalized}/*${ESLINT_TARGET_EXTENSION_GLOB}`; + } + + return `${normalized}${ESLINT_TARGET_EXTENSION_GLOB}`; +} + +function normalizeIncludePatterns(values: readonly string[]): string[] { + return dedupeAndSort( + values + .map((value) => expandExtensionlessPattern(value)) + .filter((value) => value.length > 0), + ); +} + +function normalizeExcludePatterns(values: readonly string[]): string[] { + return dedupeAndSort( + values + .map((value) => normalizeGlobValue(value).replace(/\/+$/, '')) + .filter((value) => value.length > 0), + ); +} + +function patternPrefix(value: string): string { + const normalized = normalizeGlobValue(value); + const segments = normalized + .split('/') + .filter((segment) => segment.length > 0); + const prefixSegments: string[] = []; + + for (const segment of segments) { + if (GLOB_META_PATTERN.test(segment)) { + break; + } + prefixSegments.push(segment); + } + + return prefixSegments.join('/'); +} + +function patternsOverlapByPrefix(left: string, right: string): boolean { + if (left === right) { + return true; + } + + const leftPrefix = patternPrefix(left); + const rightPrefix = patternPrefix(right); + + if (leftPrefix.length === 0 || rightPrefix.length === 0) { + return false; + } + + return ( + leftPrefix === rightPrefix || + leftPrefix.startsWith(`${rightPrefix}/`) || + rightPrefix.startsWith(`${leftPrefix}/`) + ); +} + /** * Builds file and ignore patterns based on a given TypeScript configuration file path, * with optional forced inclusion of specific paths. * - * @param tsconfigPath - The path to the TypeScript configuration file (tsconfig.json). + * @param tsconfigPaths - One or more paths to TypeScript configuration files. * @param forceInclude - An optional array of paths or patterns to forcefully include, * even if they overlap with excluded patterns. * @returns An object containing: @@ -173,33 +345,109 @@ function commandExists(cmd: string): boolean { * default ignore patterns for common directories like `node_modules` are added. */ function buildPatterns( - tsconfigPath: string, + tsconfigPaths: readonly string[], forceInclude: string[] = [], + cwd = process.cwd(), + forceIncludeBaseDir = cwd, ): { files: string[]; ignore: string[]; } { - const { config } = ts.readConfigFile(tsconfigPath, ts.sys.readFile); - const strip = (p: string) => p.replace(/^\.\//, ''); + const normalizedForceInclude = normalizeIncludePatterns( + forceInclude.map((value) => + rebasePatternToCwd({ pattern: value, baseDir: forceIncludeBaseDir, cwd }), + ), + ); + const forceIncludeRaw = dedupeAndSort( + forceInclude + .map((value) => + rebasePatternToCwd({ + pattern: value, + baseDir: forceIncludeBaseDir, + cwd, + }), + ) + .map((value) => normalizeGlobValue(value).replace(/\/+$/, '')) + .filter((value) => value.length > 0), + ); - const include = (config.include ?? []).map(strip); - const exclude = (config.exclude ?? []).map(strip); + const includePatternsByTsconfig: string[][] = []; + const excludePatternsByTsconfig: string[][] = []; - // ForceInclude overrides exclude - const ignore = exclude.filter( - (ex) => !forceInclude.some((fi) => fi.startsWith(ex) || ex.startsWith(fi)), - ); + for (const tsconfigPath of tsconfigPaths) { + if (!fs.existsSync(tsconfigPath)) { + continue; + } - const files = [ - ...include.map((g) => `${g}.{js,mjs,ts,mts,jsx,tsx,json}`), - ...forceInclude.map((g) => `${g}.{js,mjs,ts,mts,jsx,tsx,json}`), - ]; + const tsconfigDir = path.dirname(tsconfigPath); + + const readResult = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (readResult.error != null || readResult.config == null) { + continue; + } + + const config = readResult.config as Record; - if (exclude.length <= 0) { - ignore.push('node_modules/**', 'bower_components/**', 'jspm_packages/**'); + const rawInclude = toStringArray(config.include).map((pattern) => + rebasePatternToCwd({ pattern, baseDir: tsconfigDir, cwd }), + ); + const defaultInclude = rebasePatternToCwd({ + pattern: '**/*', + baseDir: tsconfigDir, + cwd, + }); + const normalizedInclude = normalizeIncludePatterns( + rawInclude.length > 0 ? rawInclude : [defaultInclude], + ); + const normalizedExclude = normalizeExcludePatterns( + toStringArray(config.exclude).map((pattern) => + rebasePatternToCwd({ pattern, baseDir: tsconfigDir, cwd }), + ), + ); + + includePatternsByTsconfig.push(normalizedInclude); + excludePatternsByTsconfig.push(normalizedExclude); } - return { files, ignore }; + const include = dedupeAndSort([ + ...includePatternsByTsconfig.flat(), + ...normalizedForceInclude, + ]); + + const ignoreCandidates: string[] = []; + excludePatternsByTsconfig.forEach((excludePatterns, index) => { + if (excludePatterns.length === 0) { + ignoreCandidates.push(...DEFAULT_IGNORE_PATTERNS); + return; + } + + for (const excludePattern of excludePatterns) { + const overlappedByOtherTsconfigInclude = includePatternsByTsconfig.some( + (includePatterns, includeIndex) => + includeIndex !== index && + includePatterns.some((includePattern) => + patternsOverlapByPrefix(includePattern, excludePattern), + ), + ); + + if (!overlappedByOtherTsconfigInclude) { + ignoreCandidates.push(excludePattern); + } + } + }); + + const ignore = dedupeAndSort(ignoreCandidates).filter((ignorePattern) => { + const overlappedByNormalized = normalizedForceInclude.some( + (forceIncludePattern) => + patternsOverlapByPrefix(ignorePattern, forceIncludePattern), + ); + const overlappedByRaw = forceIncludeRaw.some((forceIncludePattern) => + patternsOverlapByPrefix(ignorePattern, forceIncludePattern), + ); + return !overlappedByNormalized && !overlappedByRaw; + }); + + return { files: include, ignore }; } export { diff --git a/tests/bin/lint.test.ts b/tests/bin/lint.test.ts index bf3fb4d..e5b4526 100644 --- a/tests/bin/lint.test.ts +++ b/tests/bin/lint.test.ts @@ -231,6 +231,25 @@ describe('matrixai-lint CLI domain semantics', () => { }); test('canonical lint script flags are accepted by main', async () => { + jest + .spyOn(childProcess, 'spawnSync') + .mockImplementation((file: string, args?: readonly string[]) => { + const commandName = args?.[0]; + const isShellcheckProbe = + (file === 'which' || file === 'where') && + commandName === 'shellcheck'; + + return { + pid: 0, + output: [null, null, null], + stdout: null, + stderr: null, + status: isShellcheckProbe ? 0 : 0, + signal: null, + error: undefined, + } as unknown as ReturnType; + }); + await expect( main([ 'node', @@ -246,7 +265,7 @@ describe('matrixai-lint CLI domain semantics', () => { 'scripts', 'tests', ]), - ).rejects.toBeDefined(); + ).resolves.toBeUndefined(); }); test('unknown option handling rejects typoed flags', async () => { @@ -260,4 +279,27 @@ describe('matrixai-lint CLI domain semantics', () => { main(['node', 'matrixai-lint', '-v', '-v', '--domain', 'markdown']), ).resolves.toBeUndefined(); }); + + test('default run tolerates missing configured tsconfig paths', async () => { + await fs.promises.writeFile( + path.join(dataDir, 'matrixai-lint-config.json'), + JSON.stringify( + { + version: 2, + domains: { + eslint: { + tsconfigPaths: ['./tsconfig.json', './missing/tsconfig.json'], + }, + }, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + await expect( + main(['node', 'matrixai-lint', '--domain', 'eslint']), + ).resolves.toBeUndefined(); + }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index bd24652..f4a493e 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,6 +1,5 @@ import path from 'node:path'; import fs from 'node:fs'; -import { jest } from '@jest/globals'; import { parseLintConfig, resolveLintConfig } from '#config.js'; describe('lint config schema', () => { @@ -30,28 +29,33 @@ describe('lint config schema', () => { } }); - test('parseLintConfig accepts explicit config schema and normalizes root-relative paths', () => { - const repoRoot = path.resolve('/tmp', 'repo-root'); + test('parseLintConfig accepts explicit config schema and normalizes root-relative paths', async () => { + const repoRoot = await fs.promises.mkdtemp(path.join(tmpDir, 'repo-root-')); const configFilePath = path.join(repoRoot, 'matrixai-lint-config.json'); + const workspaceRoot = path.join(repoRoot, 'workspace'); + const rootTsconfigPath = path.join(workspaceRoot, 'tsconfig.json'); + const coreTsconfigPath = path.join( + workspaceRoot, + 'packages', + 'core', + 'tsconfig.json', + ); - const existsSpy = jest - .spyOn(fs, 'existsSync') - .mockImplementation((targetPath) => { - const normalized = path.resolve(String(targetPath)); - return ( - normalized === path.resolve(repoRoot, 'workspace', 'tsconfig.json') || - normalized === - path.resolve( - repoRoot, - 'workspace', - 'packages', - 'core', - 'tsconfig.json', - ) - ); + try { + await fs.promises.mkdir(path.dirname(coreTsconfigPath), { + recursive: true, }); + await fs.promises.writeFile( + rootTsconfigPath, + JSON.stringify({ include: ['./src/**/*'] }, null, 2) + '\n', + 'utf8', + ); + await fs.promises.writeFile( + coreTsconfigPath, + JSON.stringify({ include: ['./src/**/*'] }, null, 2) + '\n', + 'utf8', + ); - try { const resolved = parseLintConfig({ rawConfig: { version: 2, @@ -61,9 +65,10 @@ describe('lint config schema', () => { tsconfigPaths: [ './tsconfig.json', './packages/core/tsconfig.json', + './packages/core/tsconfig.json', './missing/tsconfig.json', ], - forceInclude: ['./scripts', './src/overrides'], + forceInclude: ['./scripts', './src/overrides', './scripts', ''], }, }, }, @@ -74,15 +79,78 @@ describe('lint config schema', () => { expect(resolved.source).toBe('config'); expect(resolved.root).toBe(path.resolve(repoRoot, 'workspace')); expect(resolved.domains.eslint.tsconfigPaths).toStrictEqual([ - path.resolve(repoRoot, 'workspace', 'tsconfig.json'), path.resolve(repoRoot, 'workspace', 'packages/core', 'tsconfig.json'), + path.resolve(repoRoot, 'workspace', 'tsconfig.json'), ]); expect(resolved.domains.eslint.forceInclude).toStrictEqual([ 'scripts', 'src/overrides', ]); } finally { - existsSpy.mockRestore(); + await fs.promises.rm(repoRoot, { recursive: true, force: true }); + } + }); + + test('parseLintConfig deterministically filters unreadable and missing tsconfig paths', async () => { + const repoRoot = await fs.promises.mkdtemp( + path.join(tmpDir, 'repo-readability-'), + ); + const configFilePath = path.join(repoRoot, 'matrixai-lint-config.json'); + const workspaceRoot = path.join(repoRoot, 'workspace'); + + const validA = path.join(workspaceRoot, 'a', 'tsconfig.json'); + const validB = path.join(workspaceRoot, 'b', 'tsconfig.json'); + const broken = path.join(workspaceRoot, 'broken', 'tsconfig.json'); + + try { + await fs.promises.mkdir(path.dirname(validA), { recursive: true }); + await fs.promises.mkdir(path.dirname(validB), { recursive: true }); + await fs.promises.mkdir(path.dirname(broken), { recursive: true }); + + await fs.promises.writeFile( + validA, + JSON.stringify({ include: ['./src/**/*'] }, null, 2) + '\n', + 'utf8', + ); + await fs.promises.writeFile( + validB, + JSON.stringify({ include: ['./src/**/*'] }, null, 2) + '\n', + 'utf8', + ); + await fs.promises.writeFile(broken, '{"include": ["./src/**/*"]', 'utf8'); + + const resolved = parseLintConfig({ + rawConfig: { + version: 2, + root: './workspace', + domains: { + eslint: { + tsconfigPaths: [ + './b/tsconfig.json', + './a/tsconfig.json', + './broken/tsconfig.json', + './missing/tsconfig.json', + './a/tsconfig.json', + ], + }, + }, + }, + repoRoot, + configFilePath, + }); + + const expected = [ + path.resolve(repoRoot, 'workspace', 'a', 'tsconfig.json'), + path.resolve(repoRoot, 'workspace', 'b', 'tsconfig.json'), + ].map((p) => p.split(path.sep).join(path.posix.sep)); + + const actual = resolved.domains.eslint.tsconfigPaths.map((p) => + p.split(path.sep).join(path.posix.sep), + ); + + expect(actual).toStrictEqual(expected); + } finally { + await fs.promises.rm(repoRoot, { recursive: true, force: true }); } }); diff --git a/tests/domains/index.test.ts b/tests/domains/index.test.ts index 9586255..a0e5a21 100644 --- a/tests/domains/index.test.ts +++ b/tests/domains/index.test.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; +import fs from 'node:fs'; import Logger, { LogLevel } from '@matrixai/logger'; import { createLintDomainRegistry, @@ -5,8 +7,10 @@ import { evaluateLintDomains, listLintDomains, resolveDomainSelection, + createBuiltInDomainRegistry, type LintDomainPlugin, } from '#domains/index.js'; +import * as utils from '#utils.js'; const testLogger = new Logger('matrixai-lint-test', LogLevel.INFO, []); @@ -325,6 +329,100 @@ describe('domain engine', () => { { domain: 'markdown', description: 'markdown test plugin' }, ]); }); + + test('eslint detection derives scope from canonical multi-tsconfig union', async () => { + const tmpRoot = await fs.promises.mkdtemp( + path.join(tmpDir, 'domain-eslint-union-'), + ); + + const previousCwd = process.cwd(); + + try { + process.chdir(tmpRoot); + + await fs.promises.mkdir(path.join(tmpRoot, 'pkg-a', 'src'), { + recursive: true, + }); + await fs.promises.mkdir(path.join(tmpRoot, 'pkg-b', 'src'), { + recursive: true, + }); + + await fs.promises.writeFile( + path.join(tmpRoot, 'pkg-a', 'src', 'a.ts'), + 'export const a = 1;\n', + 'utf8', + ); + await fs.promises.writeFile( + path.join(tmpRoot, 'pkg-b', 'src', 'b.ts'), + 'export const b = 1;\n', + 'utf8', + ); + + await fs.promises.writeFile( + path.join(tmpRoot, 'pkg-a', 'tsconfig.json'), + JSON.stringify({ include: ['./src/**/*'] }, null, 2) + '\n', + 'utf8', + ); + await fs.promises.writeFile( + path.join(tmpRoot, 'pkg-b', 'tsconfig.json'), + JSON.stringify({ include: ['./src/**/*'] }, null, 2) + '\n', + 'utf8', + ); + + await fs.promises.writeFile( + path.join(tmpRoot, 'matrixai-lint-config.json'), + JSON.stringify( + { + version: 2, + root: '.', + domains: { + eslint: { + tsconfigPaths: [ + './pkg-a/tsconfig.json', + './pkg-b/tsconfig.json', + ], + }, + }, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + const registry = createBuiltInDomainRegistry({ + prettierConfigPath: path.join(tmpRoot, 'prettier.config.js'), + }); + + const decisions = await evaluateLintDomains({ + registry, + selectedDomains: new Set(['eslint']), + explicitlyRequestedDomains: new Set(['eslint']), + selectionSources: new Map([['eslint', 'domain-flag']]), + executionOrder: ['eslint', 'shell', 'markdown'], + context: { + fix: false, + logger: testLogger, + isConfigValid: true, + }, + }); + + const eslintDecision = decisions.find( + (decision) => decision.domain === 'eslint', + ); + const matchedFiles = (eslintDecision?.detection?.matchedFiles ?? []).map( + (p) => p.split(path.sep).join(path.posix.sep), + ); + + expect(matchedFiles).toEqual( + expect.arrayContaining(['pkg-a/src/a.ts', 'pkg-b/src/b.ts']), + ); + expect(eslintDecision?.plannedAction).toBe('run'); + } finally { + process.chdir(previousCwd); + await fs.promises.rm(tmpRoot, { recursive: true, force: true }); + } + }); }); describe('domain selection', () => { @@ -374,3 +472,109 @@ describe('domain selection', () => { expect(selectionSources.has('shell')).toBe(false); }); }); + +describe('eslint target derivation', () => { + test('preserves extension-bearing include entries while expanding extensionless entries', async () => { + const tmpRoot = await fs.promises.mkdtemp( + path.join(tmpDir, 'domain-eslint-patterns-'), + ); + + try { + const pkgOne = path.join(tmpRoot, 'pkg-one'); + const pkgTwo = path.join(tmpRoot, 'pkg-two'); + + await fs.promises.mkdir(pkgOne, { recursive: true }); + await fs.promises.mkdir(pkgTwo, { recursive: true }); + + const pkgOneTsconfig = path.join(pkgOne, 'tsconfig.json'); + const pkgTwoTsconfig = path.join(pkgTwo, 'tsconfig.json'); + + await fs.promises.writeFile( + pkgOneTsconfig, + JSON.stringify({ include: ['./src/**/*.tsx'] }, null, 2) + '\n', + 'utf8', + ); + await fs.promises.writeFile( + pkgTwoTsconfig, + JSON.stringify({ include: ['./src/**/*'] }, null, 2) + '\n', + 'utf8', + ); + + const patterns = utils.buildPatterns( + [pkgOneTsconfig, pkgTwoTsconfig], + [], + tmpRoot, + tmpRoot, + ); + + expect(patterns.files).toContain('pkg-one/src/**/*.tsx'); + expect(patterns.files).toContain( + 'pkg-two/src/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,json}', + ); + expect(patterns.files).not.toContain( + 'pkg-one/src/**/*.tsx.{js,mjs,cjs,jsx,ts,tsx,mts,cts,json}', + ); + } finally { + await fs.promises.rm(tmpRoot, { recursive: true, force: true }); + } + }); + + test('exclude and forceInclude interactions are stable across multiple tsconfigs', async () => { + const tmpRoot = await fs.promises.mkdtemp( + path.join(tmpDir, 'domain-eslint-exclude-force-'), + ); + + try { + const pkgOne = path.join(tmpRoot, 'pkg-one'); + const pkgTwo = path.join(tmpRoot, 'pkg-two'); + + await fs.promises.mkdir(pkgOne, { recursive: true }); + await fs.promises.mkdir(pkgTwo, { recursive: true }); + + const pkgOneTsconfig = path.join(pkgOne, 'tsconfig.json'); + const pkgTwoTsconfig = path.join(pkgTwo, 'tsconfig.json'); + + await fs.promises.writeFile( + pkgOneTsconfig, + JSON.stringify( + { + include: ['./src/**/*'], + exclude: ['./scripts/**'], + }, + null, + 2, + ) + '\n', + 'utf8', + ); + await fs.promises.writeFile( + pkgTwoTsconfig, + JSON.stringify( + { + include: ['./src/**/*'], + exclude: ['./generated/**'], + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + const patterns = utils.buildPatterns( + [pkgOneTsconfig, pkgTwoTsconfig], + ['pkg-one/scripts'], + tmpRoot, + tmpRoot, + ); + + expect(patterns.files).toEqual( + expect.arrayContaining([ + 'pkg-one/scripts/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,json}', + ]), + ); + expect(patterns.ignore).not.toContain('pkg-one/scripts/**'); + expect(patterns.ignore).toContain('pkg-two/generated/**'); + } finally { + await fs.promises.rm(tmpRoot, { recursive: true, force: true }); + } + }); +});