From ab8df36d53ac6d99d8638dd486f4132cde04acbc Mon Sep 17 00:00:00 2001 From: Mattia Manzati Date: Thu, 17 Sep 2026 10:33:36 +0200 Subject: [PATCH 1/2] feat: warn about unknown diagnostic rule names after config parsing --- .changeset/unknown-diagnostic-rule.md | 7 + .../030-tsoptions-validation.patch | 29 ++ .../typescript/030-tsoptions-validation.patch | 29 ++ _tools/gen_shims/config/tsoptions/foreach.go | 12 + etscheckerhooks/init.go | 2 + .../diagnostics/effectDiagnosticMessages.json | 4 + internal/effectconfigcheck/validation.go | 123 ++++++++ internal/effectconfigcheck/validation_test.go | 272 ++++++++++++++++++ internal/rule/rule.go | 6 + internal/rulerunner/diagnostics.go | 2 +- shim/diagnostics/shim.go | 1 + shim/tsoptions/foreach.go | 12 + shim/tsoptions/shim.go | 3 + 13 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 .changeset/unknown-diagnostic-rule.md create mode 100644 _patches/typescript-go/030-tsoptions-validation.patch create mode 100644 _patches/typescript/030-tsoptions-validation.patch create mode 100644 _tools/gen_shims/config/tsoptions/foreach.go create mode 100644 internal/effectconfigcheck/validation.go create mode 100644 internal/effectconfigcheck/validation_test.go create mode 100644 shim/tsoptions/foreach.go diff --git a/.changeset/unknown-diagnostic-rule.md b/.changeset/unknown-diagnostic-rule.md new file mode 100644 index 00000000..a8135d4e --- /dev/null +++ b/.changeset/unknown-diagnostic-rule.md @@ -0,0 +1,7 @@ +--- +"@effect/tsgo": minor +--- + +Warn when `diagnosticSeverity` contains an unknown Effect rule name, including in overrides and inherited configurations. For example, `"floatingEfect": "error"` now reports `effect(unknownRuleName)` instead of being silently ignored. + +The check uses the fully merged configuration. Set `"unknownRuleName": "off"` to disable it or `"unknownRuleName": "error"` to raise its severity. Local keys are underlined in tsconfig; inherited keys without local syntax produce a diagnostic without a source location. diff --git a/_patches/typescript-go/030-tsoptions-validation.patch b/_patches/typescript-go/030-tsoptions-validation.patch new file mode 100644 index 00000000..d464fc24 --- /dev/null +++ b/_patches/typescript-go/030-tsoptions-validation.patch @@ -0,0 +1,29 @@ +diff --git a/internal/tsoptions/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go +--- a/internal/tsoptions/tsconfigparsing.go ++++ b/internal/tsoptions/tsconfigparsing.go +@@ -19,6 +19,14 @@ + "github.com/microsoft/typescript-go/internal/vfs" + "github.com/microsoft/typescript-go/internal/vfs/vfsmatch" + ) ++ ++// ValidateCompilerOptionsCallback validates the final options after configuration ++// inheritance and existing options have been merged. ++var ValidateCompilerOptionsCallback func(*core.CompilerOptions, *ast.SourceFile) []*ast.Diagnostic ++ ++func RegisterValidateCompilerOptionsCallback(cb func(*core.CompilerOptions, *ast.SourceFile) []*ast.Diagnostic) { ++ ValidateCompilerOptionsCallback = cb ++} + + type extendsResult struct { + options *core.CompilerOptions +@@ -1367,6 +1375,10 @@ + return projectReferences + } + ++ if ValidateCompilerOptionsCallback != nil { ++ errors = append(errors, ValidateCompilerOptionsCallback(parsedConfig.options, tsconfigToSourceFile(sourceFile))...) ++ } ++ + fileNames, literalFileNamesLen := getFileNames(basePathForFileNames) + return &ParsedCommandLine{ + ParsedConfig: &core.ParsedOptions{ diff --git a/_patches/typescript/030-tsoptions-validation.patch b/_patches/typescript/030-tsoptions-validation.patch new file mode 100644 index 00000000..b8382981 --- /dev/null +++ b/_patches/typescript/030-tsoptions-validation.patch @@ -0,0 +1,29 @@ +diff --git a/tsc/internal/tsoptions/tsconfigparsing.go b/tsc/internal/tsoptions/tsconfigparsing.go +--- a/tsc/internal/tsoptions/tsconfigparsing.go ++++ b/tsc/internal/tsoptions/tsconfigparsing.go +@@ -22,6 +22,14 @@ + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfsmatch" + ) ++ ++// ValidateCompilerOptionsCallback validates the final options after configuration ++// inheritance and existing options have been merged. ++var ValidateCompilerOptionsCallback func(*core.CompilerOptions, *ast.SourceFile) []*ast.Diagnostic ++ ++func RegisterValidateCompilerOptionsCallback(cb func(*core.CompilerOptions, *ast.SourceFile) []*ast.Diagnostic) { ++ ValidateCompilerOptionsCallback = cb ++} + + type extendsResult struct { + options *core.CompilerOptions +@@ -1508,6 +1516,10 @@ + return projectReferences + } + ++ if ValidateCompilerOptionsCallback != nil { ++ errors = append(errors, ValidateCompilerOptionsCallback(parsedConfig.options, tsconfigToSourceFile(sourceFile))...) ++ } ++ + fileNames, literalFileNamesLen := getFileNames(basePathForFileNames) + compileOnSave := new(false) + if raw, ok := parsedConfig.raw.(*collections.OrderedMap[string, any]); ok { diff --git a/_tools/gen_shims/config/tsoptions/foreach.go b/_tools/gen_shims/config/tsoptions/foreach.go new file mode 100644 index 00000000..41dec680 --- /dev/null +++ b/_tools/gen_shims/config/tsoptions/foreach.go @@ -0,0 +1,12 @@ +package tsoptions + +import ( + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/tsoptions" +) + +// ForEachTsConfigPropArray forwards through a regular call because go:linkname +// does not support generic functions. +func ForEachTsConfigPropArray[T any](sourceFile *ast.SourceFile, propKey string, callback func(*ast.PropertyAssignment) *T) *T { + return tsoptions.ForEachTsConfigPropArray(sourceFile, propKey, callback) +} diff --git a/etscheckerhooks/init.go b/etscheckerhooks/init.go index 9201af62..d3572ac1 100644 --- a/etscheckerhooks/init.go +++ b/etscheckerhooks/init.go @@ -7,6 +7,7 @@ import ( "context" "github.com/effect-ts/tsgo/etscore" + "github.com/effect-ts/tsgo/internal/effectconfigcheck" "github.com/effect-ts/tsgo/internal/effectconfigraw" "github.com/effect-ts/tsgo/internal/rulerunner" "github.com/microsoft/TypeScript/tsc/shim/ast" @@ -19,6 +20,7 @@ func init() { // Set the version suffix so that core.Version() includes the Effect version core.SetVersionSuffix("+effect-tsgo." + etscore.EffectVersion) effectconfigraw.Register() + effectconfigcheck.Register() // Register the after check source file callback checker.RegisterAfterCheckSourceFileCallback(afterCheckSourceFile) } diff --git a/internal/diagnostics/effectDiagnosticMessages.json b/internal/diagnostics/effectDiagnosticMessages.json index 1db9e0fa..9ff46b55 100644 --- a/internal/diagnostics/effectDiagnosticMessages.json +++ b/internal/diagnostics/effectDiagnosticMessages.json @@ -526,5 +526,9 @@ "`Effect.andThen` expresses this sequencing more directly than `Effect.flatMap` with a zero-parameter callback. effect(flatMapIgnoredParamToAndThen)": { "category": "Suggestion", "code": 377132 + }, + "Unknown Effect diagnostic rule `{0}` in `diagnosticSeverity`. effect(unknownRuleName)": { + "category": "Warning", + "code": 377134 } } diff --git a/internal/effectconfigcheck/validation.go b/internal/effectconfigcheck/validation.go new file mode 100644 index 00000000..57937f18 --- /dev/null +++ b/internal/effectconfigcheck/validation.go @@ -0,0 +1,123 @@ +// Package effectconfigcheck validates resolved Effect compiler options. +package effectconfigcheck + +import ( + "slices" + + "github.com/effect-ts/tsgo/etscore" + "github.com/effect-ts/tsgo/internal/directives" + "github.com/effect-ts/tsgo/internal/rule" + "github.com/effect-ts/tsgo/internal/rules" + "github.com/microsoft/TypeScript/tsc/shim/ast" + "github.com/microsoft/TypeScript/tsc/shim/core" + "github.com/microsoft/TypeScript/tsc/shim/diagnostics" + "github.com/microsoft/TypeScript/tsc/shim/tsoptions" +) + +func Register() { + tsoptions.RegisterValidateCompilerOptionsCallback(validate) +} + +func validate(options *core.CompilerOptions, sourceFile *ast.SourceFile) []*ast.Diagnostic { + if options == nil || !etscore.DiagnosticsEnabled(options.Effect) { + return nil + } + config := options.Effect + severity, configured := config.DiagnosticSeverity[rule.UnknownRuleNameName] + if !configured { + severity = etscore.SeverityWarning + } + if severity.IsOff() { + return nil + } + + // Syntax is used only to locate diagnostics; validation uses the merged options. + plugin := effectPluginSyntax(sourceFile) + var result []*ast.Diagnostic + check := func(severities map[string]etscore.Severity, syntax *ast.Node) { + var unknown []string + for name := range severities { + if name != rule.UnusedDirectiveName && name != rule.UnknownRuleNameName && rule.ByName(rules.All, name) == nil { + unknown = append(unknown, name) + } + } + // Maps have no iteration order; keep CLI output and baselines deterministic. + slices.Sort(unknown) + for _, name := range unknown { + var node *ast.Node + if property := findProperty(syntax, name); property != nil { + node = property.Name() + } + diagnostic := tsoptions.CreateDiagnosticForNodeInSourceFileOrCompilerDiagnostic( + sourceFile, node, + diagnostics.Unknown_Effect_diagnostic_rule_0_in_diagnosticSeverity_effect_unknownRuleName, + name, + ) + diagnostic.SetCategory(directives.ToCategory(severity)) + result = append(result, diagnostic) + } + } + check(config.DiagnosticSeverity, propertyValue(plugin, "diagnosticSeverity")) + + // Inherited overrides precede local overrides. Only local object entries have + // syntax in this file, and the parser skips non-object entries. + var localOverrides []*ast.Node + for _, node := range arrayElements(propertyValue(plugin, "overrides")) { + if ast.IsObjectLiteralExpression(node) { + localOverrides = append(localOverrides, node) + } + } + localStart := len(config.Overrides) - len(localOverrides) + for i, override := range config.Overrides { + var syntax *ast.Node + if localStart >= 0 && i >= localStart { + syntax = propertyValue(propertyValue(localOverrides[i-localStart], "options"), "diagnosticSeverity") + } + check(override.Options.DiagnosticSeverity, syntax) + } + return result +} + +func effectPluginSyntax(sourceFile *ast.SourceFile) *ast.Node { + compilerOptions := tsoptions.ForEachTsConfigPropArray(sourceFile, "compilerOptions", func(property *ast.PropertyAssignment) *ast.PropertyAssignment { + return property + }) + if compilerOptions == nil { + return nil + } + for _, plugin := range arrayElements(propertyValue(compilerOptions.Initializer, "plugins")) { + name := propertyValue(plugin, "name") + if name != nil && ast.IsStringLiteralLike(name) && name.Text() == etscore.EffectPluginName { + return plugin + } + } + return nil +} + +func findProperty(node *ast.Node, name string) *ast.PropertyAssignment { + if node == nil || !ast.IsObjectLiteralExpression(node) { + return nil + } + var result *ast.PropertyAssignment + for _, property := range node.Properties() { + if ast.IsPropertyAssignment(property) && ast.GetTextOfPropertyName(property.Name()) == name { + // JSON parsing retains the last value for duplicate keys. + result = property.AsPropertyAssignment() + } + } + return result +} + +func propertyValue(node *ast.Node, name string) *ast.Node { + if property := findProperty(node, name); property != nil { + return property.Initializer + } + return nil +} + +func arrayElements(node *ast.Node) []*ast.Node { + if node != nil && ast.IsArrayLiteralExpression(node) { + return node.Elements() + } + return nil +} diff --git a/internal/effectconfigcheck/validation_test.go b/internal/effectconfigcheck/validation_test.go new file mode 100644 index 00000000..81a66793 --- /dev/null +++ b/internal/effectconfigcheck/validation_test.go @@ -0,0 +1,272 @@ +package effectconfigcheck_test + +import ( + "encoding/json" + "strings" + "testing" + "testing/fstest" + + _ "github.com/effect-ts/tsgo/etscheckerhooks" + "github.com/effect-ts/tsgo/etscore" + "github.com/microsoft/TypeScript/tsc/shim/ast" + "github.com/microsoft/TypeScript/tsc/shim/bundled" + "github.com/microsoft/TypeScript/tsc/shim/compiler" + "github.com/microsoft/TypeScript/tsc/shim/core" + "github.com/microsoft/TypeScript/tsc/shim/diagnostics" + "github.com/microsoft/TypeScript/tsc/shim/execute/tsc" + "github.com/microsoft/TypeScript/tsc/shim/tsoptions" + "github.com/microsoft/TypeScript/tsc/shim/tspath" + "github.com/microsoft/TypeScript/tsc/shim/vfs" + "github.com/microsoft/TypeScript/tsc/shim/vfs/vfstest" +) + +type parseHost struct{ fs vfs.FS } + +func (h *parseHost) FS() vfs.FS { return h.fs } +func (h *parseHost) GetCurrentDirectory() string { return "/" } + +func newHost(files map[string]string) *parseHost { + entries := map[string]any{"/main.ts": &fstest.MapFile{Data: []byte("export {}")}} + for name, text := range files { + entries[name] = &fstest.MapFile{Data: []byte(text)} + } + return &parseHost{fs: bundled.WrapFS(vfstest.FromMap(entries, true))} +} + +func parse(t *testing.T, host *parseHost, path string, cache tsoptions.ExtendedConfigCache) *tsoptions.ParsedCommandLine { + t.Helper() + text, ok := host.fs.ReadFile(path) + if !ok { + t.Fatalf("missing config %s", path) + } + source := tsoptions.NewTsconfigSourceFileFromFilePath(path, tspath.Path(path), text) + return tsoptions.ParseJsonSourceFileConfigFileContent(source, host, tspath.GetDirectoryPath(path), nil, nil, path, nil, nil, cache) +} + +func config(options string, extra string) string { + return `{"files":["/main.ts"],"compilerOptions":{"plugins":[{"name":"@effect/language-service",` + options + `}]}` + extra + `}` +} + +func unknownDiagnostics(config *tsoptions.ParsedCommandLine) []*ast.Diagnostic { + var result []*ast.Diagnostic + for _, diagnostic := range config.Errors { + if diagnostic.Code() == 377134 { + result = append(result, diagnostic) + } + } + return result +} + +func TestUnknownRuleNames(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name, options string + count int + category diagnostics.Category + }{ + {"typo", `"diagnosticSeverity":{"floatingEfect":"error"}`, 1, diagnostics.CategoryWarning}, + {"unknown disabled rule still invalid", `"diagnosticSeverity":{"floatingEfect":"off"}`, 1, diagnostics.CategoryWarning}, + {"known names", `"diagnosticSeverity":{"floatingEffect":"error","unusedDirective":"warning","unknownRuleName":"warning"}`, 0, diagnostics.CategoryWarning}, + {"error", `"diagnosticSeverity":{"floatingEfect":"error","unknownRuleName":"error"}`, 1, diagnostics.CategoryError}, + {"suggestion", `"diagnosticSeverity":{"floatingEfect":"error","unknownRuleName":"suggestion"}`, 1, diagnostics.CategorySuggestion}, + {"off", `"diagnosticSeverity":{"floatingEfect":"error","unknownRuleName":"off"}`, 0, diagnostics.CategoryWarning}, + {"disabled", `"diagnostics":false,"diagnosticSeverity":{"floatingEfect":"error"}`, 0, diagnostics.CategoryWarning}, + {"null", `"diagnosticSeverity":null,"overrides":[{"options":{"diagnosticSeverity":{"floatingEfect":"error"}}}]`, 0, diagnostics.CategoryWarning}, + {"override", `"overrides":[null,{"options":{"diagnosticSeverity":{"floatingEfect":"error"}}}]`, 1, diagnostics.CategoryWarning}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + host := newHost(map[string]string{"/tsconfig.json": config(tt.options, "")}) + parsed := parse(t, host, "/tsconfig.json", nil) + got := unknownDiagnostics(parsed) + if len(got) != tt.count { + t.Fatalf("want %d warnings, got %d: %v", tt.count, len(got), parsed.Errors) + } + for _, diagnostic := range got { + if diagnostic.Category() != tt.category { + t.Fatalf("wrong category: %v", diagnostic.Category()) + } + if diagnostic.File() == nil || diagnostic.File().FileName() != "/tsconfig.json" { + t.Fatal("missing local config location") + } + if text := diagnostic.File().Text()[diagnostic.Pos():diagnostic.End()]; text != `"floatingEfect"` { + t.Fatalf("wrong underline: %q", text) + } + } + }) + } +} + +func TestUnknownRuleNamesExtends(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name, base, child string + count int + category diagnostics.Category + local bool + }{ + {"inherited key", `"diagnosticSeverity":{"floatingEfect":"error"}`, `"diagnosticSeverity":{}`, 1, diagnostics.CategoryWarning, false}, + {"child silences base", `"diagnosticSeverity":{"floatingEfect":"error"}`, `"diagnosticSeverity":{"unknownRuleName":"off"}`, 0, diagnostics.CategoryWarning, false}, + {"child disables diagnostics", `"diagnosticSeverity":{"floatingEfect":"error"}`, `"diagnostics":false`, 0, diagnostics.CategoryWarning, false}, + {"child raises base", `"diagnosticSeverity":{"floatingEfect":"error"}`, `"diagnosticSeverity":{"unknownRuleName":"error"}`, 1, diagnostics.CategoryError, false}, + {"base silences child", `"diagnosticSeverity":{"unknownRuleName":"off"}`, `"diagnosticSeverity":{"floatingEfect":"error"}`, 0, diagnostics.CategoryWarning, true}, + {"base disables child", `"diagnostics":false`, `"diagnosticSeverity":{"floatingEfect":"error"}`, 0, diagnostics.CategoryWarning, true}, + {"base raises child", `"diagnosticSeverity":{"unknownRuleName":"error"}`, `"diagnosticSeverity":{"floatingEfect":"error"}`, 1, diagnostics.CategoryError, true}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + host := newHost(map[string]string{ + "/base.json": config(tt.base, ""), + "/middle.json": `{"extends":"./base.json"}`, + "/tsconfig.json": config(tt.child, `,"extends":"./middle.json"`), + }) + parsed := parse(t, host, "/tsconfig.json", &tsc.ExtendedConfigCache{}) + got := unknownDiagnostics(parsed) + if len(got) != tt.count { + t.Fatalf("want %d warnings, got %d: %v", tt.count, len(got), parsed.Errors) + } + for _, d := range got { + if d.Category() != tt.category { + t.Fatalf("wrong category: %v", d.Category()) + } + if (d.File() != nil) != tt.local { + t.Fatalf("wrong inherited/local location: %v", d.File()) + } + } + }) + } +} + +func TestInheritedOverridesUseLocalSyntaxOnly(t *testing.T) { + t.Parallel() + host := newHost(map[string]string{ + "/base.json": config(`"overrides":[{"options":{"diagnosticSeverity":{"inheritedTypo":"warning"}}}]`, ""), + "/tsconfig.json": config(`"overrides":[false,{"options":{"diagnosticSeverity":{"localTypo":"warning"}}}]`, `,"extends":"./base.json"`), + }) + got := unknownDiagnostics(parse(t, host, "/tsconfig.json", nil)) + if len(got) != 2 { + t.Fatalf("want two warnings, got %d", len(got)) + } + if got[0].File() != nil { + t.Fatal("inherited override must not be attributed to a local override") + } + if got[1].File() == nil { + t.Fatal("local override must have a location") + } + if text := got[1].File().Text()[got[1].Pos():got[1].End()]; text != `"localTypo"` { + t.Fatalf("wrong local underline: %q", text) + } +} + +func TestUnknownRuleNamesJSONAPI(t *testing.T) { + t.Parallel() + var raw any + if err := json.Unmarshal([]byte(config(`"diagnosticSeverity":{"floatingEfect":"error"}`, "")), &raw); err != nil { + t.Fatal(err) + } + parsed := tsoptions.ParseJsonConfigFileContent(raw, newHost(nil), "/", nil, "/tsconfig.json", nil, nil) + got := unknownDiagnostics(parsed) + if len(got) != 1 || got[0].File() != nil { + t.Fatalf("expected one locationless warning: %v", got) + } +} + +func TestProjectReferencesAndSharedExtends(t *testing.T) { + t.Parallel() + host := newHost(map[string]string{ + "/base.json": config(`"diagnosticSeverity":{"floatingEfect":"error"}`, ""), + "/tsconfig.json": `{"files":[],"references":[{"path":"./a"},{"path":"./b"},{"path":"./c"}]}`, + "/a/tsconfig.json": config(`"diagnosticSeverity":{"unknownRuleName":"off"}`, `,"extends":"../base.json"`), + "/b/tsconfig.json": config(`"diagnosticSeverity":{"unknownRuleName":"error"}`, `,"extends":"../base.json"`), + "/c/tsconfig.json": `{"extends":"../base.json"}`, + }) + cache := &tsc.ExtendedConfigCache{} + root := parse(t, host, "/tsconfig.json", cache) + program := compiler.NewProgram(compiler.ProgramOptions{ + Config: root, + Host: compiler.NewCompilerHost("/", host.fs, bundled.LibPath(), cache, nil), + SingleThreaded: core.TSTrue, + }) + if got := unknownDiagnostics(root); len(got) != 0 { + t.Fatal("reference diagnostics leaked into solution config") + } + refs := program.GetResolvedProjectReferences() + if len(refs) != 3 { + t.Fatalf("expected three resolved references, got %d", len(refs)) + } + for i, ref := range refs { + if ref == nil { + t.Fatalf("reference %d did not resolve", i) + } + got := unknownDiagnostics(ref) + if i == 0 { + if len(got) != 0 { + t.Fatal("project a did not silence inherited typo") + } + continue + } + category := diagnostics.CategoryWarning + if i == 1 { + category = diagnostics.CategoryError + } + if len(got) != 1 || got[0].Category() != category || got[0].File() != nil { + t.Fatalf("project %d: unexpected diagnostics %v", i, got) + } + } + // Parsing the base through the same cache must not reuse a child's severity. + got := unknownDiagnostics(parse(t, host, "/base.json", cache)) + if len(got) != 1 || got[0].Category() != diagnostics.CategoryWarning { + t.Fatalf("cached severity leaked: %v", got) + } +} + +func TestMultipleExtendsAndExistingOptions(t *testing.T) { + t.Parallel() + host := newHost(map[string]string{ + "/base.json": config(`"diagnosticSeverity":{"floatingEfect":"error"}`, ""), + "/severity.json": config(`"diagnosticSeverity":{"unknownRuleName":"error"}`, ""), + "/tsconfig.json": `{"extends":["./base.json","./severity.json"]}`, + }) + got := unknownDiagnostics(parse(t, host, "/tsconfig.json", nil)) + if len(got) != 1 || got[0].Category() != diagnostics.CategoryError { + t.Fatalf("multiple extends failed: %v", got) + } + text, _ := host.fs.ReadFile("/tsconfig.json") + source := tsoptions.NewTsconfigSourceFileFromFilePath("/tsconfig.json", "/tsconfig.json", text) + existing := &core.CompilerOptions{Effect: &etscore.EffectPluginOptions{Diagnostics: true, DiagnosticSeverity: map[string]etscore.Severity{"existingTypo": etscore.SeverityError}}} + parsed := tsoptions.ParseJsonSourceFileConfigFileContent(source, host, "/", existing, nil, "/tsconfig.json", nil, nil, nil) + got = unknownDiagnostics(parsed) + if len(got) != 1 || !strings.Contains(strings.Join(got[0].MessageArgs(), " "), "existingTypo") { + t.Fatalf("existing options were not validated after merging: %v", got) + } +} + +func TestOtherPluginsAndCompilerErrors(t *testing.T) { + t.Parallel() + host := newHost(map[string]string{ + "/tsconfig.json": `{"files":["/main.ts"],"compilerOptions":{"strcit":true,"plugins":[{"name":"other-plugin","diagnosticSeverity":{"unrelatedRule":"error"}}]}}`, + }) + parsed := parse(t, host, "/tsconfig.json", nil) + if len(unknownDiagnostics(parsed)) != 0 { + t.Fatal("validated an unrelated plugin") + } + if len(parsed.Errors) != 1 { + t.Fatalf("expected the existing compiler-option diagnostic, got %v", parsed.Errors) + } +} + +func TestUnknownRuleNamesDeterministicOrder(t *testing.T) { + t.Parallel() + host := newHost(map[string]string{ + "/tsconfig.json": config(`"diagnosticSeverity":{"zTypo":"warning","aTypo":"error","mTypo":"off"}`, ""), + }) + got := unknownDiagnostics(parse(t, host, "/tsconfig.json", nil)) + if len(got) != 3 { + t.Fatalf("expected three warnings, got %d", len(got)) + } + for i, name := range []string{"aTypo", "mTypo", "zTypo"} { + if args := got[i].MessageArgs(); len(args) != 1 || args[0] != name { + t.Fatalf("wrong diagnostic order: %v", args) + } + } +} diff --git a/internal/rule/rule.go b/internal/rule/rule.go index a135bb5a..d487ddf7 100644 --- a/internal/rule/rule.go +++ b/internal/rule/rule.go @@ -37,6 +37,12 @@ type Rule struct { Run func(ctx *Context) []*ast.Diagnostic } +// Configurable diagnostics that run outside the source-file rule registry. +const ( + UnusedDirectiveName = "unusedDirective" + UnknownRuleNameName = "unknownRuleName" +) + // ByName finds a rule by name in a slice. Returns nil if not found. func ByName(rules []Rule, name string) *Rule { for i := range rules { diff --git a/internal/rulerunner/diagnostics.go b/internal/rulerunner/diagnostics.go index caa39c86..461887a9 100644 --- a/internal/rulerunner/diagnostics.go +++ b/internal/rulerunner/diagnostics.go @@ -229,7 +229,7 @@ func createTransformedDiagnostic(original *ast.Diagnostic, newCategory tsdiag.Ca } func unusedDirectiveDiagnostics(sf *ast.SourceFile, allDirectives []directives.Directive, directiveSet *directives.DirectiveSet, resolvedSeverity map[string]etscore.Severity) []*ast.Diagnostic { - severity, ok := severityFromMap(resolvedSeverity, "unusedDirective") + severity, ok := severityFromMap(resolvedSeverity, rule.UnusedDirectiveName) if !ok { severity = etscore.SeverityWarning } diff --git a/shim/diagnostics/shim.go b/shim/diagnostics/shim.go index b9d03044..e293b791 100644 --- a/shim/diagnostics/shim.go +++ b/shim/diagnostics/shim.go @@ -2061,6 +2061,7 @@ var Unexpected_token_expected = diagnostics.Unexpected_token_expected var Unicode_escape_sequence_cannot_appear_here = diagnostics.Unicode_escape_sequence_cannot_appear_here var Unicode_escape_sequences_are_only_available_when_the_Unicode_u_flag_or_the_Unicode_Sets_v_flag_is_set = diagnostics.Unicode_escape_sequences_are_only_available_when_the_Unicode_u_flag_or_the_Unicode_Sets_v_flag_is_set var Unicode_property_value_expressions_are_only_available_when_the_Unicode_u_flag_or_the_Unicode_Sets_v_flag_is_set = diagnostics.Unicode_property_value_expressions_are_only_available_when_the_Unicode_u_flag_or_the_Unicode_Sets_v_flag_is_set +var Unknown_Effect_diagnostic_rule_0_in_diagnosticSeverity_effect_unknownRuleName = diagnostics.Unknown_Effect_diagnostic_rule_0_in_diagnosticSeverity_effect_unknownRuleName var Unknown_Unicode_property_name = diagnostics.Unknown_Unicode_property_name var Unknown_Unicode_property_name_or_value = diagnostics.Unknown_Unicode_property_name_or_value var Unknown_Unicode_property_value = diagnostics.Unknown_Unicode_property_value diff --git a/shim/tsoptions/foreach.go b/shim/tsoptions/foreach.go new file mode 100644 index 00000000..910b70b0 --- /dev/null +++ b/shim/tsoptions/foreach.go @@ -0,0 +1,12 @@ +package tsoptions + +import ( + "github.com/microsoft/TypeScript/tsc/internal/ast" + "github.com/microsoft/TypeScript/tsc/internal/tsoptions" +) + +// ForEachTsConfigPropArray forwards through a regular call because go:linkname +// does not support generic functions. +func ForEachTsConfigPropArray[T any](sourceFile *ast.SourceFile, propKey string, callback func(*ast.PropertyAssignment) *T) *T { + return tsoptions.ForEachTsConfigPropArray(sourceFile, propKey, callback) +} diff --git a/shim/tsoptions/shim.go b/shim/tsoptions/shim.go index f8405393..04996d62 100644 --- a/shim/tsoptions/shim.go +++ b/shim/tsoptions/shim.go @@ -118,10 +118,13 @@ type ParsedCommandLine = tsoptions.ParsedCommandLine type ParsedOptions = tsoptions.ParsedOptions //go:linkname RegisterMergeCompilerOptionsCallback github.com/microsoft/TypeScript/tsc/internal/tsoptions.RegisterMergeCompilerOptionsCallback func RegisterMergeCompilerOptionsCallback(cb func(targetOptions *core.CompilerOptions, sourceOptions *core.CompilerOptions, rawSource any, sourceConfigPath string, basePath string)) +//go:linkname RegisterValidateCompilerOptionsCallback github.com/microsoft/TypeScript/tsc/internal/tsoptions.RegisterValidateCompilerOptionsCallback +func RegisterValidateCompilerOptionsCallback(cb func(*core.CompilerOptions, *ast.SourceFile) []*ast.Diagnostic) type SourceOutputAndProjectReference = tsoptions.SourceOutputAndProjectReference type TSConfig = tsoptions.TSConfig //go:linkname TargetToLibMap github.com/microsoft/TypeScript/tsc/internal/tsoptions.TargetToLibMap func TargetToLibMap() map[core.ScriptTarget]string type TsConfigSourceFile = tsoptions.TsConfigSourceFile var TscBuildOption = tsoptions.TscBuildOption +var ValidateCompilerOptionsCallback = tsoptions.ValidateCompilerOptionsCallback var WatchNameMap = tsoptions.WatchNameMap From a856d627145201c5f4b267b6d629e90ae8c1abe8 Mon Sep 17 00:00:00 2001 From: Mattia Manzati Date: Thu, 17 Sep 2026 11:12:20 +0200 Subject: [PATCH 2/2] fix: normalize JSON config parsing across compiler providers TypeScript 7.0.2 requires an extra file extensions argument that the newer compiler removed. Match the existing source-file parser compatibility wrappers and use the compiler's ordered JSON representation in the regression test. --- .../typescript-go/tsoptions/compatibility.go | 26 +++++++++++++++++++ .../typescript-go/tsoptions/extra-shim.json | 2 +- .../typescript/tsoptions/compatibility.go | 21 +++++++++++++++ .../typescript/tsoptions/extra-shim.json | 2 +- internal/effectconfigcheck/validation_test.go | 10 +++---- shim/tsoptions/compatibility.go | 21 +++++++++++++++ shim/tsoptions/shim.go | 2 -- 7 files changed, 75 insertions(+), 9 deletions(-) diff --git a/_tools/gen_shims/providers/typescript-go/tsoptions/compatibility.go b/_tools/gen_shims/providers/typescript-go/tsoptions/compatibility.go index ed9f29bf..c575a7de 100644 --- a/_tools/gen_shims/providers/typescript-go/tsoptions/compatibility.go +++ b/_tools/gen_shims/providers/typescript-go/tsoptions/compatibility.go @@ -7,6 +7,32 @@ import ( "github.com/microsoft/typescript-go/internal/tspath" ) +func ParseJsonConfigFileContent( + json any, + host tsoptions.ParseConfigHost, + basePath string, + existingOptions *core.CompilerOptions, + configFileName string, + resolutionStack []tspath.Path, + extraFileExtensions any, + extendedConfigCache tsoptions.ExtendedConfigCache, +) *tsoptions.ParsedCommandLine { + var extensions []tsoptions.FileExtensionInfo + if extraFileExtensions != nil { + extensions = extraFileExtensions.([]tsoptions.FileExtensionInfo) + } + return tsoptions.ParseJsonConfigFileContent( + json, + host, + basePath, + existingOptions, + configFileName, + resolutionStack, + extensions, + extendedConfigCache, + ) +} + func ParseJsonSourceFileConfigFileContent( sourceFile *tsoptions.TsConfigSourceFile, host tsoptions.ParseConfigHost, diff --git a/_tools/gen_shims/providers/typescript-go/tsoptions/extra-shim.json b/_tools/gen_shims/providers/typescript-go/tsoptions/extra-shim.json index 671b3056..cb667a60 100644 --- a/_tools/gen_shims/providers/typescript-go/tsoptions/extra-shim.json +++ b/_tools/gen_shims/providers/typescript-go/tsoptions/extra-shim.json @@ -1,3 +1,3 @@ { - "IgnoreFunctions": ["ParseJsonSourceFileConfigFileContent"] + "IgnoreFunctions": ["ParseJsonSourceFileConfigFileContent", "ParseJsonConfigFileContent"] } diff --git a/_tools/gen_shims/providers/typescript/tsoptions/compatibility.go b/_tools/gen_shims/providers/typescript/tsoptions/compatibility.go index 8d0d29cc..fc955cfa 100644 --- a/_tools/gen_shims/providers/typescript/tsoptions/compatibility.go +++ b/_tools/gen_shims/providers/typescript/tsoptions/compatibility.go @@ -7,6 +7,27 @@ import ( "github.com/microsoft/typescript-go/internal/tspath" ) +func ParseJsonConfigFileContent( + json any, + host tsoptions.ParseConfigHost, + basePath string, + existingOptions *core.CompilerOptions, + configFileName string, + resolutionStack []tspath.Path, + _ any, + extendedConfigCache tsoptions.ExtendedConfigCache, +) *tsoptions.ParsedCommandLine { + return tsoptions.ParseJsonConfigFileContent( + json, + host, + basePath, + existingOptions, + configFileName, + resolutionStack, + extendedConfigCache, + ) +} + func ParseJsonSourceFileConfigFileContent( sourceFile *tsoptions.TsConfigSourceFile, host tsoptions.ParseConfigHost, diff --git a/_tools/gen_shims/providers/typescript/tsoptions/extra-shim.json b/_tools/gen_shims/providers/typescript/tsoptions/extra-shim.json index 671b3056..cb667a60 100644 --- a/_tools/gen_shims/providers/typescript/tsoptions/extra-shim.json +++ b/_tools/gen_shims/providers/typescript/tsoptions/extra-shim.json @@ -1,3 +1,3 @@ { - "IgnoreFunctions": ["ParseJsonSourceFileConfigFileContent"] + "IgnoreFunctions": ["ParseJsonSourceFileConfigFileContent", "ParseJsonConfigFileContent"] } diff --git a/internal/effectconfigcheck/validation_test.go b/internal/effectconfigcheck/validation_test.go index 81a66793..6deedb52 100644 --- a/internal/effectconfigcheck/validation_test.go +++ b/internal/effectconfigcheck/validation_test.go @@ -1,7 +1,6 @@ package effectconfigcheck_test import ( - "encoding/json" "strings" "testing" "testing/fstest" @@ -160,11 +159,12 @@ func TestInheritedOverridesUseLocalSyntaxOnly(t *testing.T) { func TestUnknownRuleNamesJSONAPI(t *testing.T) { t.Parallel() - var raw any - if err := json.Unmarshal([]byte(config(`"diagnosticSeverity":{"floatingEfect":"error"}`, "")), &raw); err != nil { - t.Fatal(err) + // Use the compiler's JSON representation, which both providers accept. + raw, errors := tsoptions.ParseConfigFileTextToJson("/tsconfig.json", "/tsconfig.json", config(`"diagnosticSeverity":{"floatingEfect":"error"}`, "")) + if len(errors) != 0 { + t.Fatalf("invalid config fixture: %v", errors) } - parsed := tsoptions.ParseJsonConfigFileContent(raw, newHost(nil), "/", nil, "/tsconfig.json", nil, nil) + parsed := tsoptions.ParseJsonConfigFileContent(raw, newHost(nil), "/", nil, "/tsconfig.json", nil, nil, nil) got := unknownDiagnostics(parsed) if len(got) != 1 || got[0].File() != nil { t.Fatalf("expected one locationless warning: %v", got) diff --git a/shim/tsoptions/compatibility.go b/shim/tsoptions/compatibility.go index 4ad89abf..b4a0a2df 100644 --- a/shim/tsoptions/compatibility.go +++ b/shim/tsoptions/compatibility.go @@ -7,6 +7,27 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/tspath" ) +func ParseJsonConfigFileContent( + json any, + host tsoptions.ParseConfigHost, + basePath string, + existingOptions *core.CompilerOptions, + configFileName string, + resolutionStack []tspath.Path, + _ any, + extendedConfigCache tsoptions.ExtendedConfigCache, +) *tsoptions.ParsedCommandLine { + return tsoptions.ParseJsonConfigFileContent( + json, + host, + basePath, + existingOptions, + configFileName, + resolutionStack, + extendedConfigCache, + ) +} + func ParseJsonSourceFileConfigFileContent( sourceFile *tsoptions.TsConfigSourceFile, host tsoptions.ParseConfigHost, diff --git a/shim/tsoptions/shim.go b/shim/tsoptions/shim.go index 04996d62..72be4645 100644 --- a/shim/tsoptions/shim.go +++ b/shim/tsoptions/shim.go @@ -99,8 +99,6 @@ func ParseConfigFileTextToJson(fileName string, path tspath.Path, jsonText strin type ParseConfigHost = tsoptions.ParseConfigHost //go:linkname ParseExtendedConfig github.com/microsoft/TypeScript/tsc/internal/tsoptions.ParseExtendedConfig func ParseExtendedConfig(fileName string, path tspath.Path, resolutionStack []tspath.Path, host tsoptions.ParseConfigHost, extendedConfigCache tsoptions.ExtendedConfigCache) *tsoptions.ExtendedConfigCacheEntry -//go:linkname ParseJsonConfigFileContent github.com/microsoft/TypeScript/tsc/internal/tsoptions.ParseJsonConfigFileContent -func ParseJsonConfigFileContent(json any, host tsoptions.ParseConfigHost, basePath string, existingOptions *core.CompilerOptions, configFileName string, resolutionStack []tspath.Path, extendedConfigCache tsoptions.ExtendedConfigCache) *tsoptions.ParsedCommandLine //go:linkname ParseListTypeOption github.com/microsoft/TypeScript/tsc/internal/tsoptions.ParseListTypeOption func ParseListTypeOption(opt *tsoptions.CommandLineOption, value string) ([]any, []*ast.Diagnostic) //go:linkname ParseString github.com/microsoft/TypeScript/tsc/internal/tsoptions.ParseString