Skip to content

Commit 7d1b519

Browse files
committed
fix: preserve config paths for aliased lint targets
1 parent e93158b commit 7d1b519

13 files changed

Lines changed: 373 additions & 102 deletions

cmd/rslint/api.go

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/microsoft/typescript-go/shim/core"
1616
"github.com/microsoft/typescript-go/shim/scanner"
1717
"github.com/microsoft/typescript-go/shim/tspath"
18+
"github.com/microsoft/typescript-go/shim/vfs"
1819
"github.com/microsoft/typescript-go/shim/vfs/cachedvfs"
1920
"github.com/microsoft/typescript-go/shim/vfs/osvfs"
2021
api "github.com/web-infra-dev/rslint/internal/api"
@@ -87,17 +88,16 @@ func (h *IPCHandler) HandleLint(req api.LintRequest) (*api.LintResponse, error)
8788
}
8889
}
8990
// Apply file contents if provided
91+
var fileContents map[string]string
9092
if len(req.FileContents) > 0 {
9193
if allowedFiles == nil {
9294
allowedFiles = make([]string, 0, len(req.FileContents))
9395
}
94-
fileContents := make(map[string]string, len(req.FileContents))
96+
fileContents = make(map[string]string, len(req.FileContents))
9597
for k, v := range req.FileContents {
9698
normalizedPath := addAllowedFile(k)
9799
fileContents[normalizedPath] = v
98100
}
99-
fs = utils.NewOverlayVFS(fs, fileContents)
100-
101101
}
102102

103103
// Initialize rule registry with all available rules
@@ -121,6 +121,10 @@ func (h *IPCHandler) HandleLint(req api.LintRequest) (*api.LintResponse, error)
121121
configDirectory = currentDirectory
122122
}
123123
configDirectory = tspath.NormalizePath(configDirectory)
124+
if len(fileContents) > 0 {
125+
addEquivalentFileContentPaths(fileContents, configDirectory, currentDirectory, fs)
126+
fs = utils.NewOverlayVFS(fs, fileContents)
127+
}
124128
var gitignoreGlobs []string
125129
if len(allowedFiles) > 0 {
126130
gitignoreGlobs = rslintconfig.ReadGitignoreAsGlobsForFiles(configDirectory, fs, allowedFiles)
@@ -166,10 +170,10 @@ func (h *IPCHandler) HandleLint(req api.LintRequest) (*api.LintResponse, error)
166170
// list).
167171
// The --api path never runs the type-check phase (RunLinterOptions.TypeCheck
168172
// stays false), so there is no per-program type-check skip mask to build.
169-
programs, typeInfoFiles, _, _, targetsByProgram := buildProgramsWithLintTargets(
173+
programs, typeInfoFiles, _, _, targetsByProgram, configPathBySourcePath := buildProgramsWithLintTargets(
170174
programs, nil, rslintConfig, configDirectory, nil, nil, fs, allowedFiles, nil, parseCache, false,
171175
)
172-
fileConfigResolver := rslintconfig.NewFileConfigResolver(rslintConfig, configDirectory, true)
176+
fileConfigResolver := newLintConfigResolver(nil, rslintConfig, configDirectory, true, typeInfoFiles, configPathBySourcePath)
173177

174178
// Collect diagnostics and source files
175179
var diagnostics []api.Diagnostic
@@ -337,7 +341,7 @@ func (h *IPCHandler) HandleLint(req api.LintRequest) (*api.LintResponse, error)
337341
// so a rule carrying a plugin prefix runs only when its plugin is
338342
// declared in the config's `plugins` — matching CLI and ESLint
339343
// semantics (a rule whose plugin is not declared is skipped).
340-
return fileConfigResolver.ActiveRulesForFile(sourceFile.FileName(), typeInfoFiles)
344+
return fileConfigResolver.ActiveRulesForFile(sourceFile.FileName())
341345
},
342346
OnDiagnostic: diagnosticCollector,
343347
})
@@ -435,6 +439,56 @@ func (h *IPCHandler) HandleLint(req api.LintRequest) (*api.LintResponse, error)
435439
return response, nil
436440
}
437441

442+
func addEquivalentFileContentPaths(fileContents map[string]string, configDirectory string, currentDirectory string, fs vfs.FS) {
443+
if len(fileContents) == 0 || fs == nil {
444+
return
445+
}
446+
447+
type fileContentEntry struct {
448+
path string
449+
content string
450+
}
451+
entries := make([]fileContentEntry, 0, len(fileContents))
452+
for filePath, content := range fileContents {
453+
entries = append(entries, fileContentEntry{path: filePath, content: content})
454+
}
455+
456+
comparePathOptions := tspath.ComparePathsOptions{
457+
CurrentDirectory: currentDirectory,
458+
UseCaseSensitiveFileNames: fs.UseCaseSensitiveFileNames(),
459+
}
460+
addAlias := func(alias string, content string) {
461+
if alias == "" {
462+
return
463+
}
464+
if _, exists := fileContents[alias]; exists {
465+
return
466+
}
467+
fileContents[alias] = content
468+
}
469+
addDirectoryAlias := func(fromDir string, toDir string, filePath string, content string) {
470+
if fromDir == "" || toDir == "" || !tspath.ContainsPath(fromDir, filePath, comparePathOptions) {
471+
return
472+
}
473+
relativePath := tspath.GetRelativePathFromDirectory(fromDir, filePath, comparePathOptions)
474+
if relativePath == "" {
475+
return
476+
}
477+
addAlias(tspath.ResolvePath(toDir, relativePath), content)
478+
}
479+
480+
realConfigDirectory := fs.Realpath(configDirectory)
481+
for _, entry := range entries {
482+
if realPath := fs.Realpath(entry.path); realPath != "" && realPath != entry.path {
483+
addAlias(realPath, entry.content)
484+
}
485+
if realConfigDirectory != "" && tspath.ComparePaths(configDirectory, realConfigDirectory, comparePathOptions) != 0 {
486+
addDirectoryAlias(configDirectory, realConfigDirectory, entry.path, entry.content)
487+
addDirectoryAlias(realConfigDirectory, configDirectory, entry.path, entry.content)
488+
}
489+
}
490+
}
491+
438492
// HandleGetAstInfo handles get AST info requests in IPC mode
439493
func (h *IPCHandler) HandleGetAstInfo(req api.GetAstInfoRequest) (*api.GetAstInfoResponse, error) {
440494
// Fixed user file name for program creation

cmd/rslint/api_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,51 @@ func TestHandleLint_NoConfigEnablesNoRules(t *testing.T) {
635635
}
636636
}
637637

638+
func TestHandleLint_FileContentsKeepTypeInfoWhenProgramUsesSymlinkSource(t *testing.T) {
639+
realDir := t.TempDir()
640+
linkDir := filepath.Join(filepath.Dir(realDir), filepath.Base(realDir)+"-link")
641+
if err := os.Symlink(realDir, linkDir); err != nil {
642+
t.Skipf("symlink unavailable: %v", err)
643+
}
644+
defer os.Remove(linkDir)
645+
646+
srcDir := filepath.Join(realDir, "src")
647+
if err := os.MkdirAll(srcDir, 0o755); err != nil {
648+
t.Fatalf("mkdir src: %v", err)
649+
}
650+
if err := os.WriteFile(filepath.Join(srcDir, "a.ts"), []byte("const clean = 1;\n"), 0o644); err != nil {
651+
t.Fatalf("write source: %v", err)
652+
}
653+
if err := os.WriteFile(filepath.Join(realDir, "tsconfig.json"), []byte(`{"include":["src/a.ts"]}`), 0o644); err != nil {
654+
t.Fatalf("write tsconfig: %v", err)
655+
}
656+
657+
config := json.RawMessage(`[{
658+
"languageOptions": { "parserOptions": { "project": ["./tsconfig.json"] } },
659+
"plugins": ["@typescript-eslint"],
660+
"rules": { "@typescript-eslint/no-unsafe-member-access": "error" }
661+
}]`)
662+
realTarget := filepath.Join(realDir, "src", "a.ts")
663+
664+
handler := &IPCHandler{}
665+
response, err := handler.HandleLint(api.LintRequest{
666+
Config: config,
667+
ConfigDirectory: linkDir,
668+
WorkingDirectory: linkDir,
669+
Files: []string{realTarget},
670+
FileContents: map[string]string{realTarget: "let b: any = 10;\nb.c = 20;\n"},
671+
})
672+
if err != nil {
673+
t.Fatalf("HandleLint returned error: %v", err)
674+
}
675+
if len(response.Diagnostics) != 1 {
676+
t.Fatalf("expected one type-aware overlay diagnostic, got %d: %+v", len(response.Diagnostics), response.Diagnostics)
677+
}
678+
if got := response.Diagnostics[0].RuleName; got != "@typescript-eslint/no-unsafe-member-access" {
679+
t.Fatalf("expected no-unsafe-member-access diagnostic from typed overlay content, got %q", got)
680+
}
681+
}
682+
638683
// scenario b (in-memory file命门): a requested file outside every tsconfig
639684
// Program (a "gap" file) must still be linted by non-type-aware rules via the
640685
// fallback Program — it must NOT be silently skipped with 0 diagnostics.

cmd/rslint/cmd.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,7 +1203,7 @@ func executeLintPipeline(args lintArgs, ctx context.Context, dispatch linter.Esl
12031203
// then bind selected files to existing Programs or an AST-only fallback
12041204
// Program. Shared with the --api path via buildProgramsWithLintTargets, so
12051205
// target discovery, fallback creation, and type-aware gating stay aligned.
1206-
programs, typeInfoFiles, capturedGapFiles, targetFiles, targetsByProgram := buildProgramsWithLintTargets(
1206+
programs, typeInfoFiles, capturedGapFiles, targetFiles, targetsByProgram, configPathBySourcePath := buildProgramsWithLintTargets(
12071207
programs,
12081208
targetConfigMap,
12091209
targetRslintConfig,
@@ -1285,6 +1285,7 @@ func executeLintPipeline(args lintArgs, ctx context.Context, dispatch linter.Esl
12851285
currentDirectory,
12861286
enforcePlugins,
12871287
typeInfoFiles,
1288+
configPathBySourcePath,
12881289
)
12891290
getRulesForFile := func(sourceFile *ast.SourceFile) []linter.ConfiguredRule {
12901291
return fileConfigResolver.ActiveRulesForFile(sourceFile.FileName())
@@ -1413,24 +1414,43 @@ func executeLintPipeline(args lintArgs, ctx context.Context, dispatch linter.Esl
14131414

14141415
// Re-lint: collect remaining diagnostics.
14151416
fixProgramIndex := buildProgramFileIndex(newPrograms, fs, singleThreaded)
1416-
fixTargetsByProgram := assignLintTargetsToPrograms(newPrograms, targetConfigMap, newProgramConfigDirs, targetFiles, fixProgramIndex)
1417+
fixTargetsByProgram, fixConfigPathBySourcePath := assignLintTargetsToPrograms(newPrograms, targetConfigMap, newProgramConfigDirs, targetFiles, fixProgramIndex, fs)
14171418
fixSkipMask := buildTypeCheckSkipMask(newPrograms)
1419+
fixTypeInfoFiles := buildTypeInfoFilesForPrograms(newPrograms, fixSkipMask, fs, singleThreaded)
1420+
fixConfigResolver := newLintConfigResolver(
1421+
configMap,
1422+
rslintConfig,
1423+
currentDirectory,
1424+
enforcePlugins,
1425+
fixTypeInfoFiles,
1426+
fixConfigPathBySourcePath,
1427+
)
1428+
fixGetRulesForFile := func(sourceFile *ast.SourceFile) []linter.ConfiguredRule {
1429+
return fixConfigResolver.ActiveRulesForFile(sourceFile.FileName())
1430+
}
1431+
var fixRulesForFile linter.RuleHandler
1432+
if !typeCheckOnly {
1433+
fixRulesForFile = fixGetRulesForFile
1434+
}
1435+
fixShouldReportLintSyntax := func(filePath string) bool {
1436+
return fixConfigResolver.ConfigForFile(filePath) != nil
1437+
}
14181438
var passDiags []rule.RuleDiagnostic
14191439
passDiags = append(passDiags, collectTargetSyntacticDiagnostics(
14201440
newPrograms,
14211441
fixTargetsByProgram,
14221442
fixSkipMask,
14231443
typeCheck,
14241444
typeCheckOnly,
1425-
shouldReportLintSyntax,
1445+
fixShouldReportLintSyntax,
14261446
)...)
14271447
fixRunOpts := linter.RunLinterOptions{
14281448
Programs: newPrograms,
14291449
SingleThreaded: singleThreaded,
14301450
Scope: linter.FileScope{Files: allowFiles, Dirs: allowDirs},
14311451
TargetFiles: fixTargetsByProgram,
1432-
GetRulesForFile: getRulesForFile,
1433-
TypeInfoFiles: typeInfoFiles,
1452+
GetRulesForFile: fixRulesForFile,
1453+
TypeInfoFiles: fixTypeInfoFiles,
14341454
TypeCheck: typeCheck,
14351455
SkipTypeCheckPrograms: fixSkipMask,
14361456
OnDiagnostic: func(d rule.RuleDiagnostic) {
@@ -1444,7 +1464,10 @@ func executeLintPipeline(args lintArgs, ctx context.Context, dispatch linter.Esl
14441464
// plugin diagnostics from being lost when allDiags is replaced.
14451465
var fixPluginCh <-chan []rule.RuleDiagnostic
14461466
if hasEslintPlugins {
1447-
fixPluginInputs := buildPluginFileInputs(fixRunOpts, pluginResolver)
1467+
fixPluginInputs := buildPluginFileInputs(fixRunOpts, pluginConfigResolver{
1468+
lintResolver: fixConfigResolver,
1469+
originalConfigDir: originalConfigDir,
1470+
})
14481471
fixPluginCh = dispatchPluginLintAsync(ctx, dispatch, fixPluginInputs, fix, pluginSuggestionsMode(fix))
14491472
}
14501473
passResult, _ := linter.RunLinter(fixRunOpts)

cmd/rslint/cmd_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1119,7 +1119,7 @@ func TestCLIRuleOverlayDoesNotWidenTargetDiscovery(t *testing.T) {
11191119
if exitCode != 0 {
11201120
t.Fatalf("createProgramsForConfig exit code = %d", exitCode)
11211121
}
1122-
programs, typeInfoFiles, _, targetFiles, targetsByProgram := buildProgramsWithLintTargets(
1122+
programs, typeInfoFiles, _, targetFiles, targetsByProgram, _ := buildProgramsWithLintTargets(
11231123
programs, nil, targetConfig, dir, nil, nil, fs, nil, []string{tspath.NormalizePath(dir)}, parseCache, true,
11241124
)
11251125
if len(targetFiles) != 1 || !strings.HasSuffix(targetFiles[0], "/a.ts") {

cmd/rslint/eslint_plugin.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,16 @@ func (r pluginConfigResolver) resolve(filePath string) (wireKey string, merged *
2929
if r.lintResolver == nil {
3030
return "", nil
3131
}
32-
cfgDir, resolver, ok := r.lintResolver.resolverForFile(filePath)
32+
configPath := r.lintResolver.configPathFor(filePath)
33+
cfgDir, resolver, ok := r.lintResolver.resolverForFile(configPath)
3334
if !ok {
3435
return "", nil
3536
}
3637
wireKey = cfgDir
3738
if raw, ok := r.originalConfigDir[cfgDir]; ok {
3839
wireKey = raw
3940
}
40-
return wireKey, resolver.ConfigForFile(filePath)
41+
return wireKey, resolver.ConfigForFile(configPath)
4142
}
4243

4344
// buildPluginFileInputs collects, from RunLinter's lint targets, the files that

cmd/rslint/eslint_plugin_test.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestPluginConfigResolver_NormalizedMatchRawWireKey(t *testing.T) {
2828

2929
// Windows: file matches on the normalized key, wire key is the RAW string.
3030
r := pluginConfigResolver{
31-
lintResolver: newLintConfigResolver(p.ConfigMap, nil, "", false, nil),
31+
lintResolver: newLintConfigResolver(p.ConfigMap, nil, "", false, nil, nil),
3232
originalConfigDir: p.OriginalConfigDir,
3333
}
3434
wireKey, merged := r.resolve(norm + "/src/a.ts")
@@ -47,6 +47,7 @@ func TestPluginConfigResolver_NormalizedMatchRawWireKey(t *testing.T) {
4747
"",
4848
false,
4949
nil,
50+
nil,
5051
),
5152
}
5253
if wk, m := posix.resolve("/posix/proj/a.ts"); wk != "/posix/proj" || m == nil {
@@ -120,7 +121,7 @@ func TestPluginConfigResolver_Branches(t *testing.T) {
120121

121122
// Multi-config, file under no config -> ("", nil).
122123
r := pluginConfigResolver{
123-
lintResolver: newLintConfigResolver(p.ConfigMap, nil, "", false, nil),
124+
lintResolver: newLintConfigResolver(p.ConfigMap, nil, "", false, nil, nil),
124125
originalConfigDir: p.OriginalConfigDir,
125126
}
126127
if wk, m := r.resolve("/elsewhere/a.ts"); wk != "" || m != nil {
@@ -129,7 +130,7 @@ func TestPluginConfigResolver_Branches(t *testing.T) {
129130

130131
// Single-config (configMap==nil): wireKey is currentDirectory; merged from rslintConfig.
131132
single := pluginConfigResolver{
132-
lintResolver: newLintConfigResolver(nil, p.ConfigMap["/proj"], "/proj", false, nil),
133+
lintResolver: newLintConfigResolver(nil, p.ConfigMap["/proj"], "/proj", false, nil, nil),
133134
}
134135
if wk, m := single.resolve("/proj/a.ts"); wk != "/proj" || m == nil {
135136
t.Errorf("single-config -> (currentDirectory, merged), got (%q, nil=%v)", wk, m == nil)
@@ -155,7 +156,7 @@ func TestLintConfigResolver_NearestConfigAndTypeInfoGate(t *testing.T) {
155156
typeInfoFiles := map[string]struct{}{
156157
"/repo/packages/app/src/typed.ts": {},
157158
}
158-
resolver := newLintConfigResolver(configMap, nil, "", false, typeInfoFiles)
159+
resolver := newLintConfigResolver(configMap, nil, "", false, typeInfoFiles, nil)
159160

160161
gapRules := configuredRuleNameSet(resolver.ActiveRulesForFile("/repo/packages/app/src/gap.ts"))
161162
if gapRules["@typescript-eslint/require-await"] {
@@ -183,6 +184,39 @@ func TestLintConfigResolver_NearestConfigAndTypeInfoGate(t *testing.T) {
183184
}
184185
}
185186

187+
func TestLintConfigResolver_UsesConfigPathAliasForRulesAndGlobals(t *testing.T) {
188+
rslintconfig.RegisterAllRules()
189+
190+
cfg := rslintconfig.RslintConfig{{
191+
Files: []string{"src/**/*.ts"},
192+
LanguageOptions: &rslintconfig.LanguageOptions{Raw: map[string]any{
193+
"globals": map[string]any{
194+
"aliasedGlobal": "readonly",
195+
},
196+
}},
197+
Rules: rslintconfig.Rules{"no-console": "error"},
198+
}}
199+
resolver := newLintConfigResolver(
200+
nil,
201+
cfg,
202+
"/repo",
203+
false,
204+
map[string]struct{}{"/outside/real-a.ts": {}},
205+
map[string]string{"/outside/real-a.ts": "/repo/src/a.ts"},
206+
)
207+
208+
rules := resolver.ActiveRulesForFile("/outside/real-a.ts")
209+
if len(rules) != 1 || rules[0].Name != "no-console" {
210+
t.Fatalf("expected aliased source path to use config path rules, got %v", configuredRuleNameSet(rules))
211+
}
212+
if !rules[0].Globals["aliasedGlobal"] {
213+
t.Fatalf("expected aliased source path to carry globals from config path")
214+
}
215+
if resolver.ConfigForFile("/outside/real-a.ts") == nil {
216+
t.Fatalf("expected aliased source path to resolve merged config")
217+
}
218+
}
219+
186220
func configuredRuleNameSet(rules []linter.ConfiguredRule) map[string]bool {
187221
names := make(map[string]bool, len(rules))
188222
for _, r := range rules {

0 commit comments

Comments
 (0)