Skip to content

Commit bab124b

Browse files
committed
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.
1 parent e185408 commit bab124b

File tree

7 files changed

+666
-54
lines changed

7 files changed

+666
-54
lines changed

plans/refactor/STATE.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@
55
- First implementation slice for CLI execution semantics has been delivered.
66
- The target architecture is described in [`PLAN.md`](PLAN.md).
77

8+
## Implemented in full multi-tsconfig targeting alignment slice
9+
10+
- Completed canonical tsconfig normalization in [`src/config.ts`](../../src/config.ts):
11+
- all configured `domains.eslint.tsconfigPaths` are resolved to absolute paths
12+
- unreadable or missing tsconfig files are filtered out
13+
- resulting tsconfig list is deduplicated and deterministically sorted
14+
- fallback to root `tsconfig.json` remains when available and readable
15+
- Completed union-based ESLint target derivation in [`src/utils.ts`](../../src/utils.ts):
16+
- target files are now derived from the union of all configured tsconfig includes
17+
- excludes are merged with cross-tsconfig overlap handling
18+
- `forceInclude` is merged on top and can suppress matching ignore entries
19+
- file and ignore output is deduplicated and deterministically sorted
20+
- Completed include normalization correctness in [`src/utils.ts`](../../src/utils.ts):
21+
- include entries that already carry an extension/glob extension are preserved as-is
22+
- only extensionless include entries are expanded with supported ESLint extensions
23+
- malformed glob synthesis paths from blanket suffixing are eliminated
24+
- Completed parser-project/target alignment across ESLint runtime paths:
25+
- [`src/configs/js.ts`](../../src/configs/js.ts) continues to source parser project from resolved tsconfig list
26+
- [`src/utils.ts`](../../src/utils.ts) now injects parser `project` override from the same resolved config used for target derivation
27+
- [`src/domains/eslint.ts`](../../src/domains/eslint.ts) detection now derives default scope from the same multi-tsconfig union logic
28+
- Added regression coverage for multi-tsconfig alignment:
29+
- config normalization and missing/unreadable filtering in [`tests/config.test.ts`](../../tests/config.test.ts)
30+
- multi-tsconfig detection and include/exclude/forceInclude behaviors in [`tests/domains/index.test.ts`](../../tests/domains/index.test.ts)
31+
- user-visible stability with missing configured tsconfig path in [`tests/bin/lint.test.ts`](../../tests/bin/lint.test.ts)
32+
833
## Implemented in config-schema cleanup + API-boundary slice
934

1035
- Completed lint runtime config loading/normalization using a single explicit schema:

src/config.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
import fs from 'node:fs';
77
import path from 'node:path';
88
import process from 'node:process';
9+
import ts from 'typescript';
910

1011
const MATRIXAI_LINT_CONFIG_FILENAME = 'matrixai-lint-config.json';
1112

@@ -29,14 +30,40 @@ function stripLeadingDotSlash(value: string): string {
2930
return value.replace(/^\.\//, '');
3031
}
3132

33+
function dedupeAndSort(values: readonly string[]): string[] {
34+
return [...new Set(values)].sort();
35+
}
36+
37+
function isReadableTsconfigPath(tsconfigPath: string): boolean {
38+
let stats: fs.Stats;
39+
try {
40+
stats = fs.statSync(tsconfigPath);
41+
} catch {
42+
return false;
43+
}
44+
45+
if (!stats.isFile()) {
46+
return false;
47+
}
48+
49+
const readResult = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
50+
return readResult.error == null;
51+
}
52+
3253
function sanitizeTsconfigPaths(rawValue: unknown, root: string): string[] {
33-
return toStringArray(rawValue)
34-
.map((tsconfigPath) => path.resolve(root, tsconfigPath))
35-
.filter((tsconfigPath) => fs.existsSync(tsconfigPath));
54+
return dedupeAndSort(
55+
toStringArray(rawValue)
56+
.map((tsconfigPath) => path.resolve(root, tsconfigPath))
57+
.filter((tsconfigPath) => isReadableTsconfigPath(tsconfigPath)),
58+
);
3659
}
3760

3861
function sanitizeForceInclude(rawValue: unknown): string[] {
39-
return toStringArray(rawValue).map((glob) => stripLeadingDotSlash(glob));
62+
return dedupeAndSort(
63+
toStringArray(rawValue)
64+
.map((glob) => stripLeadingDotSlash(glob))
65+
.filter((glob) => glob.length > 0),
66+
);
4067
}
4168

4269
function normalizeLintConfig({
@@ -63,19 +90,21 @@ function normalizeLintConfig({
6390
? rawDomains.eslint
6491
: ({} as Record<string, unknown>);
6592

66-
const tsconfigPaths = sanitizeTsconfigPaths(
93+
let tsconfigPaths = sanitizeTsconfigPaths(
6794
rawEslintDomain.tsconfigPaths,
6895
resolvedRoot,
6996
);
7097
const forceInclude = sanitizeForceInclude(rawEslintDomain.forceInclude);
7198

7299
if (tsconfigPaths.length === 0) {
73100
const rootTsconfigPath = path.join(resolvedRoot, 'tsconfig.json');
74-
if (fs.existsSync(rootTsconfigPath)) {
101+
if (isReadableTsconfigPath(rootTsconfigPath)) {
75102
tsconfigPaths.push(rootTsconfigPath);
76103
}
77104
}
78105

106+
tsconfigPaths = dedupeAndSort(tsconfigPaths);
107+
79108
return {
80109
version: 2,
81110
root: resolvedRoot,

src/domains/eslint.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
resolveSearchRootsFromPatterns,
66
} from './files.js';
77
import * as utils from '../utils.js';
8+
import { resolveLintConfig } from '../config.js';
89

910
const ESLINT_FILE_EXTENSIONS = [
1011
'.js',
@@ -20,15 +21,39 @@ const ESLINT_FILE_EXTENSIONS = [
2021

2122
const DEFAULT_ESLINT_SEARCH_ROOTS = ['./src', './scripts', './tests'];
2223

24+
function resolveESLintDetectionPatterns(
25+
eslintPatterns: readonly string[] | undefined,
26+
): string[] {
27+
if (eslintPatterns != null && eslintPatterns.length > 0) {
28+
return [...eslintPatterns];
29+
}
30+
31+
const resolvedConfig = resolveLintConfig();
32+
const { tsconfigPaths, forceInclude } = resolvedConfig.domains.eslint;
33+
34+
if (tsconfigPaths.length === 0) {
35+
return DEFAULT_ESLINT_SEARCH_ROOTS;
36+
}
37+
38+
const { files } = utils.buildPatterns(
39+
tsconfigPaths,
40+
forceInclude,
41+
process.cwd(),
42+
resolvedConfig.root,
43+
);
44+
if (files.length === 0) {
45+
return DEFAULT_ESLINT_SEARCH_ROOTS;
46+
}
47+
48+
return files;
49+
}
50+
2351
function createESLintDomainPlugin(): LintDomainPlugin {
2452
return {
2553
domain: 'eslint',
2654
description: 'Lint JavaScript/TypeScript/JSON files with ESLint.',
2755
detect: ({ eslintPatterns }) => {
28-
const patterns =
29-
eslintPatterns != null && eslintPatterns.length > 0
30-
? eslintPatterns
31-
: DEFAULT_ESLINT_SEARCH_ROOTS;
56+
const patterns = resolveESLintDetectionPatterns(eslintPatterns);
3257
const searchRoots = resolveSearchRootsFromPatterns(patterns);
3358
const matchedFiles = collectFilesByExtensions(
3459
searchRoots,

0 commit comments

Comments
 (0)