From bab124bef8dc1d20b0a32d378e736148a09ee18d Mon Sep 17 00:00:00 2001 From: Roger Qiu Date: Fri, 20 Feb 2026 15:07:45 +0000 Subject: [PATCH 1/3] feat: added multiple tsconfig union target derivation - TypeScript config resolution and validation (`ts.readConfigFile`, file readability checks) - Canonical normalization pipeline: - `path.resolve` - `Set` + `.sort()` for deterministic dedupe ordering - Multi-tsconfig union target derivation - ESLint target glob normalization: - extension-bearing pattern detection - extensionless expansion with supported extension set - Include/exclude/forceInclude merge behavior across multiple tsconfigs - Cross-tsconfig overlap heuristics for ignore filtering - Parser project alignment in ESLint runtime: - `@typescript-eslint/parser` project list synchronized with runtime-derived tsconfig set - Domain engine + ESLint domain detection alignment with runtime target derivation - Jest regression testing with temp filesystem fixtures - ESM/Jest mocking limitations (read-only exports / non-configurable properties) Files and Code Sections: - `src/config.ts` (modified) - Why important: central canonical tsconfig list normalization for all downstream consumers. - Key edits: - added tsconfig readability validation (not just exists) - dedupe + deterministic sort for tsconfigPaths and forceInclude - fallback root tsconfig only if readable - Key snippet: ```ts 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 dedupeAndSort( toStringArray(rawValue) .map((tsconfigPath) => path.resolve(root, tsconfigPath)) .filter((tsconfigPath) => isReadableTsconfigPath(tsconfigPath)), ); } ``` - `src/utils.ts` (modified heavily) - Why important: target derivation moved from single-tsconfig to full union; include/exclude/forceInclude correctness; parser project runtime alignment. - Key edits: - `buildPatterns` now accepts all tsconfig paths (not first entry) - include normalization preserves extension-bearing entries, expands only extensionless patterns - tsconfig-relative include/exclude rebased to cwd - cross-tsconfig exclude overlap handling + forceInclude ignore suppression - deterministic dedupe/sort for files and ignores - runtime `overrideConfig` injects parserOptions.project from same resolved config used for target derivation - Key snippets: ```ts function buildPatterns( tsconfigPaths: readonly string[], forceInclude: string[] = [], cwd = process.cwd(), forceIncludeBaseDir = cwd, ): { files: string[]; ignore: string[] } { ... } ``` ```ts 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}`; } ``` ```ts const lintConfig = resolvedConfig ?? resolveLintConfig(); const parserProjectOverride = { languageOptions: { parserOptions: { project: lintConfig.domains.eslint.tsconfigPaths, }, }, }; const eslint = new ESLint({ overrideConfigFile: configPath || defaultConfigPath, ... overrideConfig: parserProjectOverride, }); ``` - `src/domains/eslint.ts` (modified) - Why important: ESLint domain detection now uses canonical multi-tsconfig union logic (same derivation family as run path). - Key edits: - new `resolveESLintDetectionPatterns` using `resolveLintConfig` + `utils.buildPatterns(...)` - default detection falls back to default roots only when no tsconfigs/files are derivable - Key snippet: ```ts 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; } ``` - `src/configs/js.ts` (read, not modified) - Why important: confirmed parser project already sourced from `resolveLintConfig().domains.eslint.tsconfigPaths`; alignment then enforced at runtime via `src/utils.ts`. - `tests/config.test.ts` (modified) - Why important: regression coverage for canonical normalization, deterministic sort, dedupe, missing/unreadable filtering. - Key changes: - switched from brittle mocking to real temp-file fixtures - added/updated tests asserting dedupe/sort and unreadable tsconfig filtering - Representative assertions: ```ts expect(resolved.domains.eslint.tsconfigPaths).toStrictEqual([ path.resolve(repoRoot, 'workspace', 'packages/core', 'tsconfig.json'), path.resolve(repoRoot, 'workspace', 'tsconfig.json'), ]); ``` - `tests/domains/index.test.ts` (modified) - Why important: regression coverage for domain-level multi-tsconfig detection and buildPatterns behavior. - Added tests: - `eslint detection derives scope from canonical multi-tsconfig union` - `preserves extension-bearing include entries while expanding extensionless entries` - `exclude and forceInclude interactions are stable across multiple tsconfigs` - Representative assertion: ```ts expect(patterns.files).toContain('pkg-one/src/**/*.tsx'); expect(patterns.files).toContain( 'pkg-two/src/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,json}', ); ``` - `tests/bin/lint.test.ts` (modified) - Why important: user-visible CLI stability behavior update. - Changes: - test for canonical lint script flags updated to `.resolves.toBeUndefined()` after alignment fix - added `default run tolerates missing configured tsconfig paths` - Representative snippet: ```ts await expect( main(['node', 'matrixai-lint', '--domain', 'eslint']), ).resolves.toBeUndefined(); ``` - `plans/refactor/STATE.md` (modified) - Why important: milestone state/documentation update required by user. - Added section: `Implemented in full multi-tsconfig targeting alignment slice`, documenting guarantees and touched areas. --- plans/refactor/STATE.md | 25 ++++ src/config.ts | 41 ++++- src/domains/eslint.ts | 33 +++- src/utils.ts | 290 +++++++++++++++++++++++++++++++++--- tests/bin/lint.test.ts | 25 +++- tests/config.test.ts | 106 ++++++++++--- tests/domains/index.test.ts | 200 +++++++++++++++++++++++++ 7 files changed, 666 insertions(+), 54 deletions(-) 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..0a06da7 100644 --- a/tests/bin/lint.test.ts +++ b/tests/bin/lint.test.ts @@ -246,7 +246,7 @@ describe('matrixai-lint CLI domain semantics', () => { 'scripts', 'tests', ]), - ).rejects.toBeDefined(); + ).resolves.toBeUndefined(); }); test('unknown option handling rejects typoed flags', async () => { @@ -260,4 +260,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..798b2fd 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,72 @@ 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, + }); + + expect(resolved.domains.eslint.tsconfigPaths).toStrictEqual([ + path.resolve(repoRoot, 'workspace', 'a', 'tsconfig.json'), + path.resolve(repoRoot, 'workspace', 'b', 'tsconfig.json'), + ]); + } 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..41830f9 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,96 @@ 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', + ); + expect(eslintDecision?.detection?.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 +468,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 }); + } + }); +}); From 11ff6ed25a0bb4853052bf45325f32ca865e1fad Mon Sep 17 00:00:00 2001 From: Roger Qiu Date: Sat, 21 Feb 2026 08:16:05 +0000 Subject: [PATCH 2/3] ci: fixing for windows and macos - path normalization for platform-agnostic expectations in tests/config.test.ts and tests/domains/index.test.ts. - canonical flags test now mocks shellcheck availability to avoid platform-dependent failure in tests/bin/lint.test.ts. --- tests/bin/lint.test.ts | 18 ++++++++++++++++++ tests/config.test.ts | 10 ++++++++-- tests/domains/index.test.ts | 6 +++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/tests/bin/lint.test.ts b/tests/bin/lint.test.ts index 0a06da7..7345251 100644 --- a/tests/bin/lint.test.ts +++ b/tests/bin/lint.test.ts @@ -231,6 +231,24 @@ 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', diff --git a/tests/config.test.ts b/tests/config.test.ts index 798b2fd..f4a493e 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -139,10 +139,16 @@ describe('lint config schema', () => { configFilePath, }); - expect(resolved.domains.eslint.tsconfigPaths).toStrictEqual([ + 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 41830f9..a0e5a21 100644 --- a/tests/domains/index.test.ts +++ b/tests/domains/index.test.ts @@ -410,7 +410,11 @@ describe('domain engine', () => { const eslintDecision = decisions.find( (decision) => decision.domain === 'eslint', ); - expect(eslintDecision?.detection?.matchedFiles).toEqual( + 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'); From c3b59975a3f5e3352c949dae5bcf0a29df12f4a6 Mon Sep 17 00:00:00 2001 From: Roger Qiu Date: Sat, 21 Feb 2026 13:05:11 +0000 Subject: [PATCH 3/3] chore: lintfixed --- tests/bin/lint.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bin/lint.test.ts b/tests/bin/lint.test.ts index 7345251..e5b4526 100644 --- a/tests/bin/lint.test.ts +++ b/tests/bin/lint.test.ts @@ -236,7 +236,8 @@ describe('matrixai-lint CLI domain semantics', () => { .mockImplementation((file: string, args?: readonly string[]) => { const commandName = args?.[0]; const isShellcheckProbe = - (file === 'which' || file === 'where') && commandName === 'shellcheck'; + (file === 'which' || file === 'where') && + commandName === 'shellcheck'; return { pid: 0,