From 152a49d5f28e31c1502e00d10a8ccba8ab540592 Mon Sep 17 00:00:00 2001 From: KazariAI <166915487+KazariAI@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:33:39 +0800 Subject: [PATCH] fix(bridge): distinguish package-json completion provenance --- patches/typescript-go/0004-api-rpc.patch | 20 ++-- .../triage-packagejson-autoimport-details.mjs | 91 ++++++++++++------- 2 files changed, 73 insertions(+), 38 deletions(-) diff --git a/patches/typescript-go/0004-api-rpc.patch b/patches/typescript-go/0004-api-rpc.patch index 8c5bcb3..c6efeb8 100644 --- a/patches/typescript-go/0004-api-rpc.patch +++ b/patches/typescript-go/0004-api-rpc.patch @@ -958,7 +958,7 @@ index 0917c385d6..b2c40e7440 100644 // SourceFileResponse contains the binary-encoded AST data for a source file. diff --git a/internal/api/session.go b/internal/api/session.go -index 7e29c44b6f..9e3442635b 100644 +index 7e29c44b6f..713e0fd9d2 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -3,6 +3,7 @@ package api @@ -2128,12 +2128,12 @@ index 7e29c44b6f..9e3442635b 100644 + return nil, prepErr + } + defer prepared.Deref(s.projectSession) -+ prepProgram := prepProj.GetProgram() -+ sourceFile = prepProgram.GetSourceFile(params.File.ToFileName()) ++ program = prepProj.GetProgram() ++ sourceFile = program.GetSourceFile(params.File.ToFileName()) + if sourceFile == nil { + return nil, nil + } -+ langSvc = ls.NewLanguageService(prepProj.ID(), prepProgram, &completionsPrefsHost{ ++ langSvc = ls.NewLanguageService(prepProj.ID(), program, &completionsPrefsHost{ + Host: prepared, + prefs: mergeCompletionsPreferences(prepared.GetPreferences(""), params.Preferences), + }, "") @@ -2182,7 +2182,7 @@ index 7e29c44b6f..9e3442635b 100644 entry.LabelDetails = &CompletionEntryLabelDetailsResponse{ Detail: item.LabelDetails.Detail, Description: item.LabelDetails.Description, -@@ -3237,12 +3940,63 @@ func (s *Session) handleGetCompletionsAtPosition(ctx context.Context, params *Ge +@@ -3237,12 +3940,69 @@ func (s *Session) handleGetCompletionsAtPosition(ctx context.Context, params *Ge if item.Symbol != nil { entry.Symbol = sd.newSymbolResponse(item.Symbol, params.Project) } @@ -2201,7 +2201,13 @@ index 7e29c44b6f..9e3442635b 100644 + moduleSpecifier = item.Data.AutoImport.ModuleSpecifier + } + entry.SourceDisplay = []DisplayPartResponse{{Text: moduleSpecifier, Kind: "text"}} -+ if providerDeps != nil && item.Data.AutoImport != nil && providerDeps.Has(packageNameFromSpecifier(item.Data.AutoImport.ModuleSpecifier)) { ++ // Provider-covered packages can also be visible in the host ++ // program; host-origin candidates must resolve through the host, ++ // not the smaller package-json provider. ++ if providerDeps != nil && ++ item.Data.AutoImport != nil && ++ providerDeps.Has(packageNameFromSpecifier(item.Data.AutoImport.ModuleSpecifier)) && ++ program.GetSourceFile(item.ModuleFileName) == nil { + entry.IsPackageJsonImport = ptrTrue() + } + entry.Data = &CompletionEntryDataResponse{ @@ -2250,7 +2256,7 @@ index 7e29c44b6f..9e3442635b 100644 } // handleGetReferencedSymbolsForNode returns node handles for all references found at a node. -@@ -3284,11 +4038,11 @@ func (s *Session) handleGetReferencedSymbolsForNode(ctx context.Context, params +@@ -3284,11 +4044,11 @@ func (s *Session) handleGetReferencedSymbolsForNode(ctx context.Context, params var refs []NodeHandle for _, ref := range entry.References() { if ref.IsNodeEntry() { diff --git a/tools/triage-packagejson-autoimport-details.mjs b/tools/triage-packagejson-autoimport-details.mjs index 3a22541..543a487 100644 --- a/tools/triage-packagejson-autoimport-details.mjs +++ b/tools/triage-packagejson-autoimport-details.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node /** - * Witness: package-json auto-imports keep `isPackageJsonImport` on completion - * entry DATA, not just the entry. Details round-trips data alone; without the - * flag its resolver checks the main program instead of the provider and fails. + * Witness: package-json auto-imports keep stock provenance through details. + * The provider entry (`ufo`) carries the flag on entry AND data; a subpath + * already in the host program (`ufo/host`) does not. Details round-trips data + * alone, so the data flag is what selects the resolver's program. */ import fs from 'node:fs'; import os from 'node:os'; @@ -13,20 +14,32 @@ 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 root = fs.mkdtempSync(path.join(os.tmpdir(), 'tnb-packagejson-autoimport-')); -const pkg = path.join(root, 'node_modules', 'pathe'); +const pkg = path.join(root, 'node_modules/ufo'); const main = path.join(root, 'main.ts'); -const content = 'join;\n'; +const host = path.join(root, 'host.ts'); +const content = 'joinURL;\n'; +const preferences = { includePackageJsonAutoImports: 'on' }; -fs.mkdirSync(pkg, { recursive: true }); +fs.mkdirSync(path.join(pkg, 'dist'), { recursive: true }); fs.writeFileSync(main, content); -fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ dependencies: { pathe: '*' } })); -fs.writeFileSync(path.join(root, 'tsconfig.json'), JSON.stringify({ compilerOptions: { module: 'nodenext', moduleResolution: 'nodenext' }, include: ['*.ts'] })); +fs.writeFileSync(host, 'import { parsePath } from "ufo/host";\nparsePath;\n'); +fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ dependencies: { ufo: '*' } })); +fs.writeFileSync(path.join(root, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { module: 'nodenext', moduleResolution: 'nodenext' }, + include: ['*.ts'], +})); fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ - name: 'pathe', types: './index.d.ts', + name: 'ufo', + exports: { + '.': { types: './dist/index.d.ts', import: './dist/index.mjs' }, + './host': { types: './dist/host.d.ts', import: './dist/host.mjs' }, + }, })); -fs.writeFileSync(path.join(pkg, 'index.d.ts'), 'export declare function join(...paths: string[]): string;\n'); +fs.writeFileSync(path.join(pkg, 'dist/index.d.ts'), 'export declare function joinURL(...paths: string[]): string;\n'); +fs.writeFileSync(path.join(pkg, 'dist/index.mjs'), 'export function joinURL() {}\n'); +fs.writeFileSync(path.join(pkg, 'dist/host.d.ts'), 'export declare function parsePath(path: string): string;\nexport declare function joinURL(path: string): string;\n'); +fs.writeFileSync(path.join(pkg, 'dist/host.mjs'), 'export function parsePath() {}\nexport function joinURL() {}\n'); -const preferences = { includePackageJsonAutoImports: 'on' }; try { const result = await withTsserver({ tsserverPath, @@ -38,29 +51,45 @@ try { const completion = await send('completionInfo', { file: main, line: 1, - offset: 5, + offset: 8, includeExternalModuleExports: true, }); - const entry = completion.body?.entries?.find(candidate => candidate.name === 'join'); - if (!entry?.source) throw new Error('completionInfo did not return a sourced join entry'); - if (!entry.data?.tnbCompletionData) throw new Error('completionInfo did not preserve native completion resolve data'); - if (!entry.data.isPackageJsonImport) throw new Error('completion entry data lost isPackageJsonImport'); + const bySource = new Map((completion.body?.entries ?? []) + .filter(item => item.name === 'joinURL') + .map(item => [item.source, item])); + const expected = new Map([ + ['ufo', true], + ['ufo/host', false], + ]); + if (bySource.size !== expected.size || ![...expected.keys()].every(source => bySource.has(source))) { + throw new Error(`expected joinURL from ${[...expected.keys()].join(' + ')}, got ${[...bySource.keys()].join(' + ')}`); + } - const details = await send('completionEntryDetails', { - file: main, - line: 1, - offset: 5, - includeExternalModuleExports: true, - entryNames: [{ name: entry.name, source: entry.source, data: entry.data }], - preferences, - }); - if (!details.success) throw new Error(details.message || 'completionEntryDetails failed'); - const edit = details.body?.[0]?.codeActions?.flatMap(action => action.changes ?? []) - .filter(change => change.fileName === main) - .flatMap(change => change.textChanges ?? []) - .find(change => change.newText.includes('join') && change.newText.includes('pathe')); - if (!edit) throw new Error('completionEntryDetails returned no pathe import edit'); - return { source: entry.source, edit: edit.newText }; + const results = []; + for (const [source, expectedFlag] of expected) { + const item = bySource.get(source); + if (Boolean(item.isPackageJsonImport) !== expectedFlag || Boolean(item.data?.isPackageJsonImport) !== expectedFlag) { + throw new Error(`${source}: flags=[${Boolean(item.isPackageJsonImport)}, ${Boolean(item.data?.isPackageJsonImport)}], want [${expectedFlag}, ${expectedFlag}]`); + } + if (expectedFlag && !item.data?.tnbCompletionData) throw new Error(`${source}: lost native completion resolve data`); + + const details = await send('completionEntryDetails', { + file: main, + line: 1, + offset: 8, + includeExternalModuleExports: true, + entryNames: [{ name: item.name, source: item.source, data: item.data }], + preferences, + }); + if (!details.success) throw new Error(details.message || `${source}: completionEntryDetails failed`); + const edit = details.body?.[0]?.codeActions?.flatMap(action => action.changes ?? []) + .filter(change => change.fileName === main) + .flatMap(change => change.textChanges ?? []) + .find(change => change.newText.includes(`from "${source}"`)); + if (!edit) throw new Error(`${source}: completionEntryDetails returned no import edit`); + results.push({ source, edit: edit.newText }); + } + return results; }); console.log(`ok package-json auto-import completion details: ${JSON.stringify(result)}`); }