diff --git a/patches/typescript/0001-tsgo-hooks.patch b/patches/typescript/0001-tsgo-hooks.patch index c9608a5..4a62dec 100644 --- a/patches/typescript/0001-tsgo-hooks.patch +++ b/patches/typescript/0001-tsgo-hooks.patch @@ -609,7 +609,7 @@ index 6034c9dfc3..0c9a6178db 100644 let computedWithoutCacheCount = 0; const fixes = flatMap(exportInfo, (exportInfo, i) => { diff --git a/src/services/completions.ts b/src/services/completions.ts -index 28d29136da..e267aa7847 100644 +index 28d29136da..48fa363863 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -175,6 +175,7 @@ import { @@ -964,33 +964,40 @@ index 28d29136da..e267aa7847 100644 symbolToOriginInfoMap[symbols.length] = origin; symbolToSortTextMap[symbolId] = importStatementCompletion ? SortText.LocationPriority : SortText.AutoImportSuggestions; symbols.push(symbol); -@@ -5383,7 +5511,7 @@ function getAutoImportSymbolFromCompletionEntryData(name: string, data: Completi +@@ -5382,14 +5510,25 @@ function getRelevantTokens(position: number, sourceFile: SourceFile): { contextT + function getAutoImportSymbolFromCompletionEntryData(name: string, data: CompletionEntryData, program: Program, host: LanguageServiceHost): { symbol: Symbol; origin: SymbolOriginInfoExport | SymbolOriginInfoResolvedExport; } | undefined { const containingProgram = data.isPackageJsonImport ? host.getPackageJsonAutoImportProvider!()! : program; const checker = containingProgram.getTypeChecker(); - const moduleSymbol = data.ambientModuleName ? checker.tryFindAmbientModule(data.ambientModuleName) : +- const moduleSymbol = data.ambientModuleName ? checker.tryFindAmbientModule(data.ambientModuleName) : - data.fileName ? checker.getMergedSymbol(Debug.checkDefined(containingProgram.getSourceFile(data.fileName)).symbol) : -+ data.fileName ? getModuleSymbolForCompletionEntryData(containingProgram, checker, data.fileName) : - undefined; - +- undefined; +- ++ const tsgoProgram = containingProgram as Program & { ++ readonly isTsgoBackedProgram: true; ++ getCompletionEntryDataSymbols(fileName: string | undefined, ambientModuleName: string | undefined, exportName: string): { moduleSymbol: Symbol; symbol: Symbol; } | undefined; ++ }; ++ let moduleSymbol: Symbol | undefined; ++ let symbol: Symbol | undefined; ++ if (tsgoProgram.isTsgoBackedProgram === true) { ++ const resolved = tsgoProgram.getCompletionEntryDataSymbols(data.fileName, data.ambientModuleName, data.exportName); ++ if (resolved) ({ moduleSymbol, symbol } = resolved); ++ } ++ else { ++ moduleSymbol = data.ambientModuleName ? checker.tryFindAmbientModule(data.ambientModuleName) : ++ data.fileName ? checker.getMergedSymbol(Debug.checkDefined(containingProgram.getSourceFile(data.fileName)).symbol) : ++ undefined; ++ symbol = moduleSymbol && (data.exportName === InternalSymbolName.ExportEquals ++ ? checker.resolveExternalModuleSymbol(moduleSymbol) ++ : checker.tryGetMemberInModuleExportsAndProperties(data.exportName, moduleSymbol)); ++ } if (!moduleSymbol) return undefined; -@@ -5396,6 +5524,16 @@ function getAutoImportSymbolFromCompletionEntryData(name: string, data: Completi - return { symbol, origin: completionEntryDataToSymbolOriginInfo(data, name, moduleSymbol) }; - } - -+function getModuleSymbolForCompletionEntryData(program: Program, checker: TypeChecker, fileName: string): Symbol { -+ const sourceFile = Debug.checkDefined(program.getSourceFile(fileName)); -+ // tsgo-backed programs hand out a host-binder facade for sf.symbol whose -+ // object identity does not match the registry-proxied symbols inside -+ // export info maps; resolve through the bridged checker when it can answer -+ // (absent on stock checkers — falls back to stock behavior). -+ const forSourceFile = (checker as unknown as { getModuleSymbolForSourceFile?: (sourceFile: SourceFile) => Symbol | undefined; }).getModuleSymbolForSourceFile; -+ return checker.getMergedSymbol(forSourceFile?.(sourceFile) ?? Debug.checkDefined(sourceFile.symbol)); -+} -+ - interface CompletionEntryDisplayNameForSymbol { - readonly name: string; - readonly needsConvertPropertyAccess: boolean; -@@ -6089,6 +6227,23 @@ function isDeprecated(symbol: Symbol, checker: TypeChecker) { +- let symbol = data.exportName === InternalSymbolName.ExportEquals +- ? checker.resolveExternalModuleSymbol(moduleSymbol) +- : checker.tryGetMemberInModuleExportsAndProperties(data.exportName, moduleSymbol); + if (!symbol) return undefined; + const isDefaultExport = data.exportName === InternalSymbolName.Default; + symbol = isDefaultExport && getLocalSymbolForExportDefault(symbol) || symbol; +@@ -6089,6 +6228,23 @@ function isDeprecated(symbol: Symbol, checker: TypeChecker) { return !!length(declarations) && every(declarations, isDeprecatedDeclaration); } diff --git a/patches/typescript/overlay/src/compiler/tsgoChecker.ts b/patches/typescript/overlay/src/compiler/tsgoChecker.ts index bd220a1..6b6e9d1 100644 --- a/patches/typescript/overlay/src/compiler/tsgoChecker.ts +++ b/patches/typescript/overlay/src/compiler/tsgoChecker.ts @@ -19,7 +19,7 @@ import * as ts from "./_namespaces/ts.js"; import { bindSourceFile } from "./binder.js"; import { createSourceFile } from "./parser.js"; -import { SyntaxKind, SymbolFlags, SymbolFormatFlags, NodeFlags, ModifierFlags, JSDocParsingMode, ModuleKind, StructureIsReused, EmitHint, EmitFlags, type Path, type Program, type TypeChecker } from "./types.js"; +import { SyntaxKind, SymbolFlags, SymbolFormatFlags, NodeFlags, ModifierFlags, JSDocParsingMode, ModuleKind, StructureIsReused, EmitHint, EmitFlags, InternalSymbolName, type Path, type Program, type TypeChecker } from "./types.js"; import { getBuildInfoText, getTsBuildInfoEmitOutputFilePath, createPrinterWithRemoveComments } from "./emitter.js"; import { usingSingleLineStringWriter, canHaveJSDoc } from "./utilities.js"; import { getParseTreeNode, isFunctionLike } from "./utilitiesPublic.js"; @@ -8196,6 +8196,18 @@ export function createTsgoProgram( return result; }, getTypeChecker: () => checker, + // Stock completion details compare these against the batch export map + // by object identity, so both symbols stay in the registry domain. + getCompletionEntryDataSymbols: (fileName: string | undefined, ambientModuleName: string | undefined, exportName: string) => { + const moduleSymbol = ambientModuleName + ? checker.tryFindAmbientModule(ambientModuleName) + : fileName + ? checker.getModuleSymbolForSourceFile(thinProgram.getSourceFile(fileName)) + : undefined; + if (!moduleSymbol) return undefined; + const symbol = checker.getExportForCompletionEntryData(exportName, moduleSymbol); + return symbol ? { moduleSymbol, symbol } : undefined; + }, getConfigFileParsingDiagnostics: () => configDiags, getOptionsDiagnostics: () => [], getSemanticDiagnostics: (sourceFile?: any) => { @@ -10867,10 +10879,10 @@ export function createTsgoChecker(program: any): any { return true; } - function tryGetMemberInModuleExportsImpl(memberName: any, moduleSymbol: any): any { + function getMemberInModuleExportsRaw(memberName: any, moduleSymbol: any): any { if (moduleSymbol?.exports) { const sym = moduleSymbol.exports.get(memberName); - if (sym) return refineNavSymbol(sym); + if (sym) return sym; // Fall through on miss: the raw host table holds direct members only, // while stock's getExportsOfModule (checker.ts:5155) also resolves // `export *` chains — let the Go side answer those. Returning @@ -10881,12 +10893,16 @@ export function createTsgoChecker(program: any): any { } ensureProject(); try { - return refineNavSymbol(rpc().getMemberInModuleExports(moduleSymbol, memberName)); + return rpc().getMemberInModuleExports(moduleSymbol, memberName); } catch { return undefined; } } - function tryGetMemberInModuleExportsAndPropertiesImpl(memberName: any, moduleSymbol: any): any { - const symbol = tryGetMemberInModuleExportsImpl(memberName, moduleSymbol); + function tryGetMemberInModuleExportsImpl(memberName: any, moduleSymbol: any): any { + return refineNavSymbol(getMemberInModuleExportsRaw(memberName, moduleSymbol)); + } + + function getMemberInModuleExportsAndPropertiesRaw(memberName: any, moduleSymbol: any): any { + const symbol = getMemberInModuleExportsRaw(memberName, moduleSymbol); if (symbol) return symbol; ensureProject(); @@ -10897,7 +10913,11 @@ export function createTsgoChecker(program: any): any { const exportEqualsType = getTypeOfSymbolForExportEquals(exportEquals); if (!exportEqualsType || !shouldTreatPropertiesOfExternalModuleAsExports(exportEqualsType)) return undefined; - return refineNavSymbol(resolvePropertyOfType(exportEqualsType, memberName)); + return resolvePropertyOfType(exportEqualsType, memberName); + } + + function tryGetMemberInModuleExportsAndPropertiesImpl(memberName: any, moduleSymbol: any): any { + return refineNavSymbol(getMemberInModuleExportsAndPropertiesRaw(memberName, moduleSymbol)); } function getSymbolFlagsImpl(symbol: any, excludeLocalMeanings?: boolean): number { @@ -13080,6 +13100,13 @@ export function createTsgoChecker(program: any): any { tryGetMemberInModuleExportsAndProperties(memberName: any, moduleSymbol: any): any { return tryGetMemberInModuleExportsAndPropertiesImpl(memberName, moduleSymbol); }, + // Completion details match this result against the batch export map by + // object identity; navigation remapping would replace the registry symbol. + getExportForCompletionEntryData(exportName: any, moduleSymbol: any): any { + return exportName === InternalSymbolName.ExportEquals + ? resolveExternalModuleSymbolImpl(moduleSymbol) + : getMemberInModuleExportsAndPropertiesRaw(exportName, moduleSymbol); + }, resolveExternalModuleSymbol(moduleSymbol: any): any { return refineNavSymbol(resolveExternalModuleSymbolImpl(moduleSymbol)); }, diff --git a/tools/ci-witness-groups.mjs b/tools/ci-witness-groups.mjs index 4b99b3a..49a050d 100644 --- a/tools/ci-witness-groups.mjs +++ b/tools/ci-witness-groups.mjs @@ -40,7 +40,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -const TOTAL = 70; +const TOTAL = 71; // Witnesses intentionally NOT in the matrix — run on demand (reasons above). const LOCAL_ONLY = [ @@ -137,6 +137,7 @@ const groups = [ 'check-readme-ledger', 'triage-overlay-delta-sync', 'triage-completion-details-array', + 'triage-local-autoimport-details', 'triage-alias-self-loop', 'triage-alias-nil', 'triage-empty-literal', diff --git a/tools/triage-local-autoimport-details.mjs b/tools/triage-local-autoimport-details.mjs new file mode 100644 index 0000000..9563a50 --- /dev/null +++ b/tools/triage-local-autoimport-details.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * Issue #58: a project-local auto-import completion must resolve to an import + * edit when its entry data is sent back through completionEntryDetails. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tnbHarnessEnv, withTsserver } from './tsserver-harness.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tsserverPath = path.join(repoRoot, 'lib', 'tsserver.js'); +const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'tnb-local-autoimport-')); +const main = path.join(fixture, 'src', 'main.ts'); +const model = path.join(fixture, 'src', 'model.ts'); + +fs.mkdirSync(path.dirname(main), { recursive: true }); +fs.writeFileSync(path.join(fixture, 'tsconfig.json'), JSON.stringify({ include: ['./src/*.ts'] })); +fs.writeFileSync(main, 'ReadRecordModel;\n'); +fs.writeFileSync(model, 'export const ReadRecordModel = {};\n'); + +try { + const result = await withTsserver({ + tsserverPath, + args: ['--disableAutomaticTypingAcquisition', '--suppressDiagnosticEvents'], + env: tnbHarnessEnv(), + }, async ({ send }) => { + await send('configure', { + preferences: { + includeCompletionsForModuleExports: true, + includeCompletionsWithInsertText: true, + }, + }); + await send('updateOpen', { + changedFiles: [], + closedFiles: [], + openFiles: [{ file: main, fileContent: 'ReadRecordModel;\n', projectRootPath: fixture }], + }); + + const completion = await send('completionInfo', { + file: main, + line: 1, + offset: 16, + includeExternalModuleExports: true, + includeInsertTextCompletions: true, + }); + const entry = completion.body?.entries?.find(candidate => candidate.name === 'ReadRecordModel' && candidate.source === './model'); + if (!entry) throw new Error('completionInfo did not return ReadRecordModel from ./model'); + + const details = await send('completionEntryDetails', { + file: main, + line: 1, + offset: 16, + entryNames: [{ name: entry.name, source: entry.source, data: entry.data }], + }); + if (!details.success) throw new Error(details.message || 'completionEntryDetails failed'); + const changes = details.body?.[0]?.codeActions?.flatMap(action => action.changes ?? []) ?? []; + const importEdit = changes + .filter(change => change.fileName === main) + .flatMap(change => change.textChanges ?? []) + .find(change => change.newText.includes('ReadRecordModel') && change.newText.includes('./model')); + if (!importEdit) throw new Error('completionEntryDetails returned no ./model import edit'); + return { data: entry.data, edit: importEdit.newText }; + }); + console.log(`ok project-local auto-import details: ${JSON.stringify(result)}`); +} +finally { + fs.rmSync(fixture, { recursive: true, force: true }); +} +process.exit(0);