fix(compiler-cli): re-tag SourceFiles after TsCreateProgramDriver.updateFiles()

TypeScript reuses SourceFile objects between old and new programs, so untagging the old program also untags shared files in the new program. Re-apply shim tags on the new program to prevent getSemanticDiagnostics() crashes with TS 5.5+.
This commit is contained in:
LordKay-sudo
2026-07-14 18:18:35 +02:00
committed by GitHub
parent ca377900a2
commit ae0ec7315c
2 changed files with 161 additions and 1 deletions
@@ -272,5 +272,12 @@ export class TsCreateProgramDriver implements ProgramDriver {
// TS 5.5 not having the files tagged while producing diagnostics can lead to errors. See:
// https://github.com/microsoft/TypeScript/pull/58398
untagAllTsFiles(oldProgram);
// Re-tag the new program's files. Since TS reuses SourceFile objects between old and new
// programs, untagging the old program also untags shared files in the new program.
// Without re-tagging, getSemanticDiagnostics() crashes with "Cannot destructure property
// 'pos' of 'file.referencedFiles[index]'" because fileProcessingDiagnostics recorded
// indices into the tagged (longer) referencedFiles array during program creation.
retagAllTsFiles(this.program);
}
}
@@ -11,7 +11,7 @@ import ts from 'typescript';
import {absoluteFrom, AbsoluteFsPath, getSourceFileOrError} from '../../file_system';
import {runInEachFileSystem} from '../../file_system/testing';
import {FileUpdate, TsCreateProgramDriver, UpdateMode} from '../../program_driver';
import {sfExtensionData, ShimReferenceTagger} from '../../shims';
import {sfExtensionData, ShimReferenceTagger, untagAllTsFiles} from '../../shims';
import {expectCompleteReuse, makeProgram} from '../../testing';
import {OptimizeFor} from '../api';
@@ -64,9 +64,89 @@ runInEachFileSystem(() => {
expectCompleteReuse(programStrategy.getProgram());
});
it('should retain shim tags on shared SourceFiles after updateFiles', () => {
assertShimTagsRetainedAfterUpdate(UpdateMode.Complete);
});
it('should retain shim tags on shared SourceFiles after incremental updateFiles', () => {
const {program, host, options, typecheckPath, mainPath} =
makeProgramWhereShimIsIncludedViaReference();
const programStrategy = new TsCreateProgramDriver(program, host, options, ['ngtypecheck']);
programStrategy.updateFiles(
new Map([[typecheckPath, createUpdate('export const VERSION = 2;')]]),
UpdateMode.Complete,
);
programStrategy.updateFiles(
new Map([[typecheckPath, createUpdate('export const VERSION = 3;')]]),
UpdateMode.Incremental,
);
assertSharedSourceFileShimTags(programStrategy.getProgram(), mainPath);
});
it('should allow untagging the input program for emit after updateFiles', () => {
const {program, host, options, typecheckPath, mainPath} =
makeProgramWhereShimIsIncludedViaReference();
const programStrategy = new TsCreateProgramDriver(program, host, options, ['ngtypecheck']);
// Update only the shim file so /main.ts remains a shared SourceFile between programs.
programStrategy.updateFiles(
new Map([[typecheckPath, createUpdate('export const VERSION = 2;')]]),
UpdateMode.Complete,
);
const mainSf = getSourceFileOrError(program, mainPath);
const ext = sfExtensionData(mainSf);
expect(ext.taggedReferenceFiles).not.toBeNull();
expect(mainSf.referencedFiles.length).toEqual(ext.taggedReferenceFiles!.length);
// prepareEmit() untags the input program before emit (#56945).
untagAllTsFiles(program);
expect(ext.originalReferencedFiles).not.toBeNull();
expect(mainSf.referencedFiles).toEqual(ext.originalReferencedFiles!);
for (const ref of mainSf.referencedFiles) {
expect(ref.fileName).not.toContain('ngtypecheck');
}
});
});
});
function assertShimTagsRetainedAfterUpdate(updateMode: UpdateMode): void {
const {program, host, options, typecheckPath, mainPath} =
makeProgramWhereShimIsIncludedViaReference();
const programStrategy = new TsCreateProgramDriver(program, host, options, ['ngtypecheck']);
// Update only the shim file so /main.ts remains a shared SourceFile between programs.
programStrategy.updateFiles(
new Map([[typecheckPath, createUpdate('export const VERSION = 2;')]]),
updateMode,
);
assertSharedSourceFileShimTags(programStrategy.getProgram(), mainPath);
}
function assertSharedSourceFileShimTags(program: ts.Program, mainPath: AbsoluteFsPath): void {
const mainSf = getSourceFileOrError(program, mainPath);
const ext = sfExtensionData(mainSf);
// Without the fix, untagging the old program also untags shared SourceFiles in the new
// program, leaving referencedFiles shorter than taggedReferenceFiles.
expect(ext.taggedReferenceFiles).not.toBeNull();
expect(mainSf.referencedFiles.length).toEqual(ext.taggedReferenceFiles!.length);
// Replicates the TS 5.5+ crash from #68164: with composite, a shim included only via
// referencedFiles gets a lazy "not listed in project" diagnostic. Expanding that diagnostic
// walks the ReferenceFile include reason and reads main.referencedFiles[index].
expect(() => program.getSemanticDiagnostics(mainSf)).not.toThrow();
}
function createUpdate(text: string): FileUpdate {
return {
newText: text,
@@ -116,3 +196,76 @@ function makeSingleFileProgramWithTypecheckShim(): {
return {program, host, options, mainPath, typecheckPath};
}
/**
* Build a program where the ngtypecheck shim is pulled in only through a tagged
* `referencedFiles` entry (not as a root). Combined with `composite`, TypeScript records a lazy
* project-file-list diagnostic whose expansion exercises `referencedFiles[index]` — the path that
* crashes when shared SourceFiles are untagged after `updateFiles()`.
*/
function makeProgramWhereShimIsIncludedViaReference(): {
program: ts.Program;
host: ts.CompilerHost;
options: ts.CompilerOptions;
mainPath: AbsoluteFsPath;
typecheckPath: AbsoluteFsPath;
} {
const mainPath = absoluteFrom('/main.ts');
const typecheckPath = absoluteFrom('/main.ngtypecheck.ts');
const {host, options} = makeProgram(
[
{
name: mainPath,
contents: 'export const NOT_A_COMPONENT = true;',
},
{
name: typecheckPath,
contents: 'export const VERSION = 1;',
isRoot: false,
},
],
{
composite: true,
declaration: true,
},
/* host */ undefined,
/* checkForErrors */ false,
);
const shimTagger = new ShimReferenceTagger(['ngtypecheck']);
// Inherit prototype methods from the real host; object spread would drop them.
const taggingHost = Object.create(host) as ts.CompilerHost;
taggingHost.getSourceFile = (
fileName: string,
languageVersionOrOptions: ts.ScriptTarget | ts.CreateSourceFileOptions,
onError?: (message: string) => void,
shouldCreateNewSourceFile?: boolean,
): ts.SourceFile | undefined => {
const sf = host.getSourceFile(
fileName,
languageVersionOrOptions,
onError,
shouldCreateNewSourceFile,
);
if (sf !== undefined) {
shimTagger.tag(sf);
}
return sf;
};
const program = ts.createProgram({
rootNames: [host.getCanonicalFileName(mainPath)],
options,
host: taggingHost,
});
shimTagger.finalize();
const typecheckSf = getSourceFileOrError(program, typecheckPath);
sfExtensionData(typecheckSf).fileShim = {
extension: 'ngtypecheck',
generatedFrom: mainPath,
};
// Return the unwrapped host; TsCreateProgramDriver installs its own tagging host on updates.
return {program, host, options, mainPath, typecheckPath};
}