From e73f987cfa01aecc82cc291127effcef1405459e Mon Sep 17 00:00:00 2001 From: uditDewan Date: Tue, 14 Jul 2026 00:15:09 -0400 Subject: [PATCH 1/2] Handle extensionless root files gracefully instead of panicking An extensionless root file that exists on disk crashed the compiler with "ScriptKind must be specified when parsing source file". The root file task produced the TS6231 diagnostic but still parsed the file, and the file name gave the parser no script kind to work with. - Root file tasks whose name fails extension resolution no longer parse the file; they only carry their processing diagnostic (TS6231), which matches TypeScript 6 behavior. - Files legitimately included without a recognized extension (e.g. via allowNonTsExtensions in inferred projects) now default to ScriptKindTS at the parse boundaries, mirroring ensureScriptKind in TypeScript 6. --- internal/compiler/fileloader.go | 1 + internal/compiler/filesparser.go | 6 +++ internal/compiler/host.go | 2 +- internal/core/core.go | 10 ++++ internal/execute/tsctests/tsc_test.go | 19 ++++++++ internal/project/overlayfs.go | 4 +- internal/project/overlayfs_test.go | 30 ++++++++++++ internal/project/session_test.go | 21 +++++++++ .../extensionless-file-in-tsconfig-exists.js | 47 +++++++++++++++++++ ...tensionless-file-on-command-line-exists.js | 39 +++++++++++++++ 10 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 testdata/baselines/reference/tsc/commandLine/extensionless-file-in-tsconfig-exists.js create mode 100644 testdata/baselines/reference/tsc/commandLine/extensionless-file-on-command-line-exists.js diff --git a/internal/compiler/fileloader.go b/internal/compiler/fileloader.go index f8d006e9258..faf7096898c 100644 --- a/internal/compiler/fileloader.go +++ b/internal/compiler/fileloader.go @@ -214,6 +214,7 @@ func (p *fileLoader) addRootFileTask(fileName string, libFile *LibFile, includeR } if diagnostic != nil { rootTask.normalizedFilePath = absPath + rootTask.failedLookup = true rootTask.processingDiagnostics = []*processingDiagnostic{{ kind: processingDiagnosticKindExplainingFileInclude, data: &includeExplainingDiagnostic{ diff --git a/internal/compiler/filesparser.go b/internal/compiler/filesparser.go index 5817fc6bb86..41905aa547f 100644 --- a/internal/compiler/filesparser.go +++ b/internal/compiler/filesparser.go @@ -26,6 +26,7 @@ type parseTask struct { loaded bool startedSubTasks bool isForAutomaticTypeDirective bool + failedLookup bool includeReason *FileIncludeReason packageId module.PackageId @@ -60,6 +61,11 @@ func (t *parseTask) load(loader *fileLoader) { t.loadAutomaticTypeDirectives(loader) return } + if t.failedLookup { + // The root file name did not resolve to a supported extension; the task + // exists only to carry its processing diagnostic, so nothing is parsed. + return + } if loader.opts.Tracing != nil { defer loader.opts.Tracing.Push(tracing.PhaseProgram, "findSourceFile", map[string]any{"fileName": t.normalizedFilePath}, false)() } diff --git a/internal/compiler/host.go b/internal/compiler/host.go index 17751307fe6..ed04fe9344f 100644 --- a/internal/compiler/host.go +++ b/internal/compiler/host.go @@ -80,7 +80,7 @@ func (h *compilerHost) GetSourceFile(opts ast.SourceFileParseOptions) *ast.Sourc if !ok { return nil } - return parser.ParseSourceFile(opts, text, core.GetScriptKindFromFileName(opts.FileName)) + return parser.ParseSourceFile(opts, text, core.EnsureScriptKindFromFileName(opts.FileName)) } func (h *compilerHost) GetResolvedProjectReference(fileName string, path tspath.Path) *tsoptions.ParsedCommandLine { diff --git a/internal/core/core.go b/internal/core/core.go index 1ce98a8c744..3df82f0e916 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -543,6 +543,16 @@ func GetScriptKindFromFileName(fileName string) ScriptKind { return ScriptKindUnknown } +// EnsureScriptKindFromFileName is like GetScriptKindFromFileName, but defaults to +// ScriptKindTS when the file name has no recognized extension (e.g. files included +// with allowNonTsExtensions), so the result is always safe to hand to the parser. +func EnsureScriptKindFromFileName(fileName string) ScriptKind { + if kind := GetScriptKindFromFileName(fileName); kind != ScriptKindUnknown { + return kind + } + return ScriptKindTS +} + // Given a name and a list of names that are *not* equal to the name, return a spelling suggestion if there is one that is close enough. // Names less than length 3 only check for case-insensitive equality. // diff --git a/internal/execute/tsctests/tsc_test.go b/internal/execute/tsctests/tsc_test.go index 29cba7ba4e2..8e566bf58f3 100644 --- a/internal/execute/tsctests/tsc_test.go +++ b/internal/execute/tsctests/tsc_test.go @@ -273,6 +273,25 @@ func TestTscMissingFiles(t *testing.T) { }, commandLineArgs: []string{"-p", "./tsconfig.json"}, }, + { + subScenario: "extensionless file in tsconfig exists", + files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": stringtestutil.Dedent( + `{ + "files": ["./src/script"] + }`, + ), + "/home/src/workspaces/project/src/script": `const n: number = "s";`, + }, + commandLineArgs: []string{"-p", "./tsconfig.json"}, + }, + { + subScenario: "extensionless file on command line exists", + files: FileMap{ + "/home/src/workspaces/project/script": `const n: number = "s";`, + }, + commandLineArgs: []string{"script"}, + }, { subScenario: "extensionless file in extended tsconfig in different folder does not exist", files: FileMap{ diff --git a/internal/project/overlayfs.go b/internal/project/overlayfs.go index 1b300a01312..7cde858c228 100644 --- a/internal/project/overlayfs.go +++ b/internal/project/overlayfs.go @@ -99,7 +99,7 @@ func (f *diskFile) IsOverlay() bool { } func (f *diskFile) Kind() core.ScriptKind { - return core.GetScriptKindFromFileName(f.fileName) + return core.EnsureScriptKindFromFileName(f.fileName) } func (f *diskFile) Clone() *diskFile { @@ -312,7 +312,7 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma } scriptKind := lsconv.LanguageKindToScriptKind(events.openChange.LanguageKind) if scriptKind == core.ScriptKindUnknown { - scriptKind = core.GetScriptKindFromFileName(uri.FileName()) + scriptKind = core.EnsureScriptKindFromFileName(uri.FileName()) } newOverlays[path] = newOverlay( uri.FileName(), diff --git a/internal/project/overlayfs_test.go b/internal/project/overlayfs_test.go index 4659530ff7b..adc47ed13c2 100644 --- a/internal/project/overlayfs_test.go +++ b/internal/project/overlayfs_test.go @@ -17,6 +17,7 @@ func TestProcessChanges(t *testing.T) { testFS := vfstest.FromMap(map[string]string{ "/test1.ts": "// existing content", "/test2.ts": "// existing content", + "/script": "// extensionless content", }, false /* useCaseSensitiveFileNames */) return newOverlayFS( testFS, @@ -183,6 +184,35 @@ func TestProcessChanges(t *testing.T) { assert.Equal(t, fh.Kind(), core.ScriptKindTS) }) + t.Run("open extensionless file with unknown language kind falls back to TS", func(t *testing.T) { + t.Parallel() + fs := createOverlayFS() + uri := lsproto.DocumentUri("file:///script") + + fs.processChanges([]FileChange{ + { + Kind: FileChangeKindOpen, + URI: uri, + Version: 1, + Content: "const x = 1;", + LanguageKind: lsproto.LanguageKind("plaintext"), + }, + }) + + fh := fs.getFile(uri.FileName()) + assert.Assert(t, fh != nil) + assert.Equal(t, fh.Kind(), core.ScriptKindTS) + }) + + t.Run("extensionless disk file falls back to TS", func(t *testing.T) { + t.Parallel() + fs := createOverlayFS() + + fh := fs.getFile("/script") + assert.Assert(t, fh != nil) + assert.Equal(t, fh.Kind(), core.ScriptKindTS) + }) + t.Run("watch change on overlay marks as not matching disk", func(t *testing.T) { t.Parallel() fs := createOverlayFS() diff --git a/internal/project/session_test.go b/internal/project/session_test.go index 79208dfdf49..707b36ca537 100644 --- a/internal/project/session_test.go +++ b/internal/project/session_test.go @@ -111,6 +111,27 @@ func TestSession(t *testing.T) { program := ls.GetProgram() assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/index.js") != nil) }) + + t.Run("inferred project extensionless file", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/script": `const x = 1;`, + } + session, _ := projecttestutil.Setup(files) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/script", 1, files["/home/projects/TS/p1/script"].(string), lsproto.LanguageKind("plaintext")) + + snapshot := session.Snapshot() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/script") + assert.NilError(t, err) + program := ls.GetProgram() + file := program.GetSourceFile("/home/projects/TS/p1/script") + assert.Assert(t, file != nil) + assert.Equal(t, file.ScriptKind, core.ScriptKindTS) + }) }) t.Run("watchChange and didOpen in same batch rebuilds program", func(t *testing.T) { diff --git a/testdata/baselines/reference/tsc/commandLine/extensionless-file-in-tsconfig-exists.js b/testdata/baselines/reference/tsc/commandLine/extensionless-file-in-tsconfig-exists.js new file mode 100644 index 00000000000..1bb69d8e84d --- /dev/null +++ b/testdata/baselines/reference/tsc/commandLine/extensionless-file-in-tsconfig-exists.js @@ -0,0 +1,47 @@ +currentDirectory::/home/src/workspaces/project +useCaseSensitiveFileNames::true +Input:: +//// [/home/src/workspaces/project/src/script] *new* +const n: number = "s"; +//// [/home/src/workspaces/project/tsconfig.json] *new* +{ + "files": ["./src/script"] + } + +tsgo -p ./tsconfig.json +ExitStatus:: DiagnosticsPresent_OutputsGenerated +Output:: +error TS6231: Could not resolve the path '/home/src/workspaces/project/src/script' with the extensions: '.ts', '.tsx', '.d.ts', '.cts', '.d.cts', '.mts', '.d.mts'. + The file is in the program because: + Part of 'files' list in tsconfig.json + tsconfig.json:2:31 - File is matched by 'files' list specified here. + 2 "files": ["./src/script"] +    ~~~~~~~~~~~~~~ + + +Found 1 error. + +//// [/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts] *Lib* +/// +interface Boolean {} +interface Function {} +interface CallableFunction {} +interface NewableFunction {} +interface IArguments {} +interface Number { toExponential: any; } +interface Object {} +interface RegExp {} +interface String { charAt: any; } +interface Array { length: number; [n: number]: T; } +interface ReadonlyArray {} +interface SymbolConstructor { + (desc?: string | number): symbol; + for(name: string): symbol; + readonly toStringTag: symbol; +} +declare var Symbol: SymbolConstructor; +interface Symbol { + readonly [Symbol.toStringTag]: string; +} +declare const console: { log(msg: any): void; }; + diff --git a/testdata/baselines/reference/tsc/commandLine/extensionless-file-on-command-line-exists.js b/testdata/baselines/reference/tsc/commandLine/extensionless-file-on-command-line-exists.js new file mode 100644 index 00000000000..7689ac25359 --- /dev/null +++ b/testdata/baselines/reference/tsc/commandLine/extensionless-file-on-command-line-exists.js @@ -0,0 +1,39 @@ +currentDirectory::/home/src/workspaces/project +useCaseSensitiveFileNames::true +Input:: +//// [/home/src/workspaces/project/script] *new* +const n: number = "s"; + +tsgo script +ExitStatus:: DiagnosticsPresent_OutputsGenerated +Output:: +error TS6231: Could not resolve the path 'script' with the extensions: '.ts', '.tsx', '.d.ts', '.cts', '.d.cts', '.mts', '.d.mts'. + The file is in the program because: + Root file specified for compilation + +Found 1 error. + +//// [/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts] *Lib* +/// +interface Boolean {} +interface Function {} +interface CallableFunction {} +interface NewableFunction {} +interface IArguments {} +interface Number { toExponential: any; } +interface Object {} +interface RegExp {} +interface String { charAt: any; } +interface Array { length: number; [n: number]: T; } +interface ReadonlyArray {} +interface SymbolConstructor { + (desc?: string | number): symbol; + for(name: string): symbol; + readonly toStringTag: symbol; +} +declare var Symbol: SymbolConstructor; +interface Symbol { + readonly [Symbol.toStringTag]: string; +} +declare const console: { log(msg: any): void; }; + From 1b3f9a7f8c3cee9c8a31aa1c991425549c000983 Mon Sep 17 00:00:00 2001 From: uditDewan Date: Sat, 25 Jul 2026 00:47:07 -0400 Subject: [PATCH 2/2] Also default the script kind for declaration map sources `getOrParseSourceFile` in the source-definition resolver parses whatever a declaration map lists in `sources`, which is an arbitrary string. When that names an existing file with no recognized extension, the parse hit the same "ScriptKind must be specified" assert and crashed the request. --- ...efinitionExtensionlessMappedSource_test.go | 29 +++++++++++++++++++ internal/ls/sourcedefinition.go | 4 ++- ...onExtensionlessMappedSource.baseline.jsonc | 7 +++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 internal/fourslash/tests/goToSourceDefinitionExtensionlessMappedSource_test.go create mode 100644 testdata/baselines/reference/fourslash/goToSourceDefinition/goToSourceDefinitionExtensionlessMappedSource.baseline.jsonc diff --git a/internal/fourslash/tests/goToSourceDefinitionExtensionlessMappedSource_test.go b/internal/fourslash/tests/goToSourceDefinitionExtensionlessMappedSource_test.go new file mode 100644 index 00000000000..a97a3260616 --- /dev/null +++ b/internal/fourslash/tests/goToSourceDefinitionExtensionlessMappedSource_test.go @@ -0,0 +1,29 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +// A declaration map can name anything in its `sources`, including a file whose +// name has no recognized extension. Parsing that file used to hit the parser's +// "ScriptKind must be specified" assert and crash the request. +func TestGoToSourceDefinitionExtensionlessMappedSource(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /lib/helper.d.ts +export declare function helper(): string; +//# sourceMappingURL=helper.d.ts.map +// @Filename: /lib/helper.d.ts.map +{"version":3,"file":"helper.d.ts","sourceRoot":"","sources":["helper"],"names":[],"mappings":"AAAA,wBAAgB,MAAM,IAAI,MAAM,CAAC"} +// @Filename: /lib/helper +export function helper(): string { return ""; } +// @Filename: /index.ts +import { /*usage*/helper } from "./lib/helper"; +helper();` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyBaselineGoToSourceDefinition(t, "usage") +} diff --git a/internal/ls/sourcedefinition.go b/internal/ls/sourcedefinition.go index 023e6da1a59..fe3958e9fd1 100644 --- a/internal/ls/sourcedefinition.go +++ b/internal/ls/sourcedefinition.go @@ -421,7 +421,9 @@ func (r *sourceDefResolver) getOrParseSourceFile(fileName string) *ast.SourceFil sourceFile = parser.ParseSourceFile( ast.SourceFileParseOptions{FileName: fileName, Path: r.ls.toPath(fileName)}, text, - core.GetScriptKindFromFileName(fileName), + // A declaration map's `sources` entries are arbitrary strings, so the + // file name here may not have a recognized extension. + core.EnsureScriptKindFromFileName(fileName), ) binder.BindSourceFile(sourceFile) } diff --git a/testdata/baselines/reference/fourslash/goToSourceDefinition/goToSourceDefinitionExtensionlessMappedSource.baseline.jsonc b/testdata/baselines/reference/fourslash/goToSourceDefinition/goToSourceDefinitionExtensionlessMappedSource.baseline.jsonc new file mode 100644 index 00000000000..d64e5499d7f --- /dev/null +++ b/testdata/baselines/reference/fourslash/goToSourceDefinition/goToSourceDefinitionExtensionlessMappedSource.baseline.jsonc @@ -0,0 +1,7 @@ +// === goToSourceDefinition === +// === /lib/helper === +// <|export function [|helper|](): string { return ""; }|> + +// === /index.ts === +// import { /*GOTO SOURCE DEF*/helper } from "./lib/helper"; +// helper(); \ No newline at end of file