diff --git a/packages/acp-bridge/tsconfig.json b/packages/acp-bridge/tsconfig.json index 4bb585f54b8..0ec0bcf86da 100644 --- a/packages/acp-bridge/tsconfig.json +++ b/packages/acp-bridge/tsconfig.json @@ -15,6 +15,7 @@ "@qwen-code/qwen-code-core/transcriptRecords": [ "../core/src/utils/transcript-records.ts" ], + "@qwen-code/qwen-code-core/goalWire": ["../core/src/goals/goal-wire.ts"], "@qwen-code/qwen-code-core/noFollowOpen": [ "../core/src/utils/no-follow-open.ts" ], diff --git a/packages/cli/src/remoteInput/RemoteInputWatcher.ts b/packages/cli/src/remoteInput/RemoteInputWatcher.ts index 144ab7af3f1..8a3eabf4e29 100644 --- a/packages/cli/src/remoteInput/RemoteInputWatcher.ts +++ b/packages/cli/src/remoteInput/RemoteInputWatcher.ts @@ -15,7 +15,7 @@ import { watchFile, } from 'node:fs'; import { createInterface } from 'node:readline'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core/debugLogger'; const debugLogger = createDebugLogger('REMOTE_INPUT'); diff --git a/packages/cli/src/services/tips/tipHistory.ts b/packages/cli/src/services/tips/tipHistory.ts index d616920fcf7..feb11547d63 100644 --- a/packages/cli/src/services/tips/tipHistory.ts +++ b/packages/cli/src/services/tips/tipHistory.ts @@ -10,7 +10,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { atomicWriteFileSync, Storage } from '@qwen-code/qwen-code-core'; +import { Storage } from '@qwen-code/qwen-code-core/storage'; +import { atomicWriteFileSync } from '@qwen-code/qwen-code-core/atomicFileWrite'; interface TipHistoryEntry { totalShown: number; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index d74554a4fa1..be0cb3f7934 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -14,9 +14,29 @@ "@qwen-code/qwen-code-core/transcriptRecords": [ "../core/src/utils/transcript-records.ts" ], + "@qwen-code/qwen-code-core/goalWire": ["../core/src/goals/goal-wire.ts"], "@qwen-code/qwen-code-core/noFollowOpen": [ "../core/src/utils/no-follow-open.ts" ], + "@qwen-code/qwen-code-core/storage": ["../core/src/config/storage.ts"], + "@qwen-code/qwen-code-core/atomicFileWrite": [ + "../core/src/utils/atomicFileWrite.ts" + ], + "@qwen-code/qwen-code-core/debugLogger": [ + "../core/src/utils/debugLogger.ts" + ], + "@qwen-code/qwen-code-core/envVarResolver": [ + "../core/src/utils/envVarResolver.ts" + ], + "@qwen-code/qwen-code-core/toolWriteOrigin": [ + "../core/src/services/tool-write-origin.ts" + ], + "@qwen-code/qwen-code-core/memoryScopes": [ + "../core/src/memory/scopes.ts" + ], + "@qwen-code/qwen-code-core/conversationsRuntimeMarker": [ + "../core/src/utils/conversations-runtime-marker.ts" + ], "@qwen-code/qwen-code-core/*": ["../core/src/*"], "@qwen-code/acp-bridge": ["../acp-bridge/src/index.ts"], "@qwen-code/acp-bridge/transcriptReplay": [ diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index c4247728d6b..f6c244ba088 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -8,156 +8,202 @@ import { defineConfig } from 'vitest/config'; import path from 'node:path'; +const CORE_SRC = path.resolve(__dirname, '../core/src'); + +// Vite takes `alias` as an object or as an ordered array of {find, +// replacement}; only the array form accepts a RegExp, and core needs one. +// Order is precedence — the first entry whose `find` matches wins. +const toAliases = (map: Record) => + Object.entries(map).map(([find, replacement]) => ({ find, replacement })); + export default defineConfig({ resolve: { - alias: { - '@qwen-code/qwen-code-core/noFollowOpen': path.resolve( - __dirname, - '../core/src/utils/no-follow-open.ts', - ), - '@qwen-code/qwen-code-core/subSessionConstants': path.resolve( - __dirname, - '../core/src/tools/sub-session-constants.ts', - ), - '@qwen-code/qwen-code-core/goalWire': path.resolve( - __dirname, - '../core/src/goals/goal-wire.ts', - ), - '@qwen-code/qwen-code-core/transcriptRecords': path.resolve( - __dirname, - '../core/src/utils/transcript-records.ts', - ), - '@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve( - __dirname, - '../core/src/hooks/user-prompt-submit-context.ts', - ), - '@qwen-code/qwen-code-core/memoryScopes': path.resolve( - __dirname, - '../core/src/memory/scopes.ts', - ), - '@qwen-code/qwen-code-core/toolWriteOrigin': path.resolve( - __dirname, - '../core/src/services/tool-write-origin.ts', - ), - '@qwen-code/qwen-code-core/envVarResolver': path.resolve( - __dirname, - '../core/src/utils/envVarResolver.ts', - ), - '@qwen-code/qwen-code-core/conversationsRuntimeMarker': path.resolve( - __dirname, - '../core/src/utils/conversations-runtime-marker.ts', - ), - '@qwen-code/qwen-code-core': path.resolve(__dirname, '../core/index.ts'), - // cli's daemon-status-provider.test.ts imports `FakeAgent` / - // `makeChannel` from acp-bridge's package-private - // `internal/testUtils` module. This alias overrides the runtime - // resolution so vitest reads the .ts source directly instead of - // the build-then-stale `dist/` copy. - '@qwen-code/acp-bridge/internal/testUtils': path.resolve( - __dirname, - '../acp-bridge/src/internal/testUtils.ts', - ), - // Same rationale as above: bridgeErrors and status subpaths - // resolve to dist/ via package.json exports, but tests in the - // monorepo worktree need the live source (dist may be stale or - // absent during development). - '@qwen-code/acp-bridge/bridgeErrors': path.resolve( - __dirname, - '../acp-bridge/src/bridgeErrors.ts', - ), - '@qwen-code/acp-bridge/status': path.resolve( - __dirname, - '../acp-bridge/src/status.ts', - ), - '@qwen-code/acp-bridge/bridge': path.resolve( - __dirname, - '../acp-bridge/src/bridge.ts', - ), - '@qwen-code/acp-bridge/spawnChannel': path.resolve( - __dirname, - '../acp-bridge/src/spawnChannel.ts', - ), - '@qwen-code/acp-bridge/processRegistry': path.resolve( - __dirname, - '../acp-bridge/src/process-registry.ts', - ), - '@qwen-code/acp-bridge/daemonMemoryBudget': path.resolve( - __dirname, - '../acp-bridge/src/daemon-memory-budget.ts', - ), - '@qwen-code/acp-bridge/ndJsonStream': path.resolve( - __dirname, - '../acp-bridge/src/ndJsonStream.ts', - ), - '@qwen-code/acp-bridge/logRedaction': path.resolve( - __dirname, - '../acp-bridge/src/logRedaction.ts', - ), - '@qwen-code/acp-bridge/bridgeClient': path.resolve( - __dirname, - '../acp-bridge/src/bridgeClient.ts', - ), - '@qwen-code/acp-bridge/bridgeOptions': path.resolve( - __dirname, - '../acp-bridge/src/bridgeOptions.ts', - ), - '@qwen-code/acp-bridge/promptLedger': path.resolve( - __dirname, - '../acp-bridge/src/prompt-ledger.ts', - ), - '@qwen-code/acp-bridge/bridgeTypes': path.resolve( - __dirname, - '../acp-bridge/src/bridgeTypes.ts', - ), - '@qwen-code/acp-bridge/bridgeFileSystem': path.resolve( - __dirname, - '../acp-bridge/src/bridgeFileSystem.ts', - ), - '@qwen-code/acp-bridge/sessionArtifacts': path.resolve( - __dirname, - '../acp-bridge/src/sessionArtifacts.ts', - ), - '@qwen-code/acp-bridge/eventBus': path.resolve( - __dirname, - '../acp-bridge/src/eventBus.ts', - ), - '@qwen-code/acp-bridge/replayWindowLimits': path.resolve( - __dirname, - '../acp-bridge/src/replayWindowLimits.ts', - ), - '@qwen-code/acp-bridge/transcriptReplay': path.resolve( - __dirname, - '../acp-bridge/src/transcript-replay.ts', - ), - '@qwen-code/acp-bridge/workspacePaths': path.resolve( - __dirname, - '../acp-bridge/src/workspacePaths.ts', - ), - '@qwen-code/acp-bridge/externalToolGuard': path.resolve( - __dirname, - '../acp-bridge/src/externalToolGuard.ts', - ), - '@qwen-code/audio-capture': path.resolve( - __dirname, - '../audio-capture/src/index.ts', - ), - '@qwen-code/sdk/daemon/transcript': path.resolve( - __dirname, - '../sdk-typescript/src/daemon/transcript.ts', - ), - '@qwen-code/sdk/daemon/ui/transcript': path.resolve( - __dirname, - '../sdk-typescript/src/daemon/ui/transcript.ts', - ), - '@qwen-code/sdk/daemon/types': path.resolve( - __dirname, - '../sdk-typescript/src/daemon/types.ts', - ), - '@qwen-code/sdk/daemon': path.resolve( - __dirname, - '../sdk-typescript/src/daemon/index.ts', - ), - }, + alias: [ + // Named core subpaths. None of these targets can be derived from the + // specifier (noFollowOpen lives at utils/no-follow-open.ts), so they + // have to be matched ahead of the wildcard below. A new named subpath + // added to packages/cli/tsconfig.json must be added here too, above + // the wildcard, or the wildcard will claim it and point at a file + // that does not exist. + ...toAliases({ + '@qwen-code/qwen-code-core/noFollowOpen': path.resolve( + __dirname, + '../core/src/utils/no-follow-open.ts', + ), + '@qwen-code/qwen-code-core/subSessionConstants': path.resolve( + __dirname, + '../core/src/tools/sub-session-constants.ts', + ), + '@qwen-code/qwen-code-core/goalWire': path.resolve( + __dirname, + '../core/src/goals/goal-wire.ts', + ), + '@qwen-code/qwen-code-core/transcriptRecords': path.resolve( + __dirname, + '../core/src/utils/transcript-records.ts', + ), + '@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve( + __dirname, + '../core/src/hooks/user-prompt-submit-context.ts', + ), + '@qwen-code/qwen-code-core/memoryScopes': path.resolve( + __dirname, + '../core/src/memory/scopes.ts', + ), + '@qwen-code/qwen-code-core/toolWriteOrigin': path.resolve( + __dirname, + '../core/src/services/tool-write-origin.ts', + ), + '@qwen-code/qwen-code-core/envVarResolver': path.resolve( + __dirname, + '../core/src/utils/envVarResolver.ts', + ), + '@qwen-code/qwen-code-core/storage': path.resolve( + __dirname, + '../core/src/config/storage.ts', + ), + '@qwen-code/qwen-code-core/atomicFileWrite': path.resolve( + __dirname, + '../core/src/utils/atomicFileWrite.ts', + ), + '@qwen-code/qwen-code-core/debugLogger': path.resolve( + __dirname, + '../core/src/utils/debugLogger.ts', + ), + '@qwen-code/qwen-code-core/conversationsRuntimeMarker': path.resolve( + __dirname, + '../core/src/utils/conversations-runtime-marker.ts', + ), + }), + // Mirrors `"@qwen-code/qwen-code-core/*": ["../core/src/*"]` from + // packages/cli/tsconfig.json. esbuild reads that paths block when it + // bundles; Vitest does not read tsconfig paths at all, so this file is + // the only place the mapping exists for a test run and the two have to + // be kept in sync by hand. Importing one core module rather than the + // package root depends on this entry. + { + find: /^@qwen-code\/qwen-code-core\/(.*)$/, + replacement: `${CORE_SRC}/$1`, + }, + // The package root is matched exactly. Spelled as a string it would + // also match everything beneath it and rewrite each subpath into a + // path under index.ts. + { + find: /^@qwen-code\/qwen-code-core$/, + replacement: path.resolve(__dirname, '../core/index.ts'), + }, + ...toAliases({ + // cli's daemon-status-provider.test.ts imports `FakeAgent` / + // `makeChannel` from acp-bridge's package-private + // `internal/testUtils` module. This alias overrides the runtime + // resolution so vitest reads the .ts source directly instead of + // the build-then-stale `dist/` copy. + '@qwen-code/acp-bridge/internal/testUtils': path.resolve( + __dirname, + '../acp-bridge/src/internal/testUtils.ts', + ), + // Same rationale as above: bridgeErrors and status subpaths + // resolve to dist/ via package.json exports, but tests in the + // monorepo worktree need the live source (dist may be stale or + // absent during development). + '@qwen-code/acp-bridge/bridgeErrors': path.resolve( + __dirname, + '../acp-bridge/src/bridgeErrors.ts', + ), + '@qwen-code/acp-bridge/status': path.resolve( + __dirname, + '../acp-bridge/src/status.ts', + ), + '@qwen-code/acp-bridge/bridge': path.resolve( + __dirname, + '../acp-bridge/src/bridge.ts', + ), + '@qwen-code/acp-bridge/spawnChannel': path.resolve( + __dirname, + '../acp-bridge/src/spawnChannel.ts', + ), + '@qwen-code/acp-bridge/processRegistry': path.resolve( + __dirname, + '../acp-bridge/src/process-registry.ts', + ), + '@qwen-code/acp-bridge/daemonMemoryBudget': path.resolve( + __dirname, + '../acp-bridge/src/daemon-memory-budget.ts', + ), + '@qwen-code/acp-bridge/ndJsonStream': path.resolve( + __dirname, + '../acp-bridge/src/ndJsonStream.ts', + ), + '@qwen-code/acp-bridge/logRedaction': path.resolve( + __dirname, + '../acp-bridge/src/logRedaction.ts', + ), + '@qwen-code/acp-bridge/bridgeClient': path.resolve( + __dirname, + '../acp-bridge/src/bridgeClient.ts', + ), + '@qwen-code/acp-bridge/bridgeOptions': path.resolve( + __dirname, + '../acp-bridge/src/bridgeOptions.ts', + ), + '@qwen-code/acp-bridge/promptLedger': path.resolve( + __dirname, + '../acp-bridge/src/prompt-ledger.ts', + ), + '@qwen-code/acp-bridge/bridgeTypes': path.resolve( + __dirname, + '../acp-bridge/src/bridgeTypes.ts', + ), + '@qwen-code/acp-bridge/bridgeFileSystem': path.resolve( + __dirname, + '../acp-bridge/src/bridgeFileSystem.ts', + ), + '@qwen-code/acp-bridge/sessionArtifacts': path.resolve( + __dirname, + '../acp-bridge/src/sessionArtifacts.ts', + ), + '@qwen-code/acp-bridge/eventBus': path.resolve( + __dirname, + '../acp-bridge/src/eventBus.ts', + ), + '@qwen-code/acp-bridge/replayWindowLimits': path.resolve( + __dirname, + '../acp-bridge/src/replayWindowLimits.ts', + ), + '@qwen-code/acp-bridge/transcriptReplay': path.resolve( + __dirname, + '../acp-bridge/src/transcript-replay.ts', + ), + '@qwen-code/acp-bridge/workspacePaths': path.resolve( + __dirname, + '../acp-bridge/src/workspacePaths.ts', + ), + '@qwen-code/acp-bridge/externalToolGuard': path.resolve( + __dirname, + '../acp-bridge/src/externalToolGuard.ts', + ), + '@qwen-code/audio-capture': path.resolve( + __dirname, + '../audio-capture/src/index.ts', + ), + '@qwen-code/sdk/daemon/transcript': path.resolve( + __dirname, + '../sdk-typescript/src/daemon/transcript.ts', + ), + '@qwen-code/sdk/daemon/ui/transcript': path.resolve( + __dirname, + '../sdk-typescript/src/daemon/ui/transcript.ts', + ), + '@qwen-code/sdk/daemon/types': path.resolve( + __dirname, + '../sdk-typescript/src/daemon/types.ts', + ), + '@qwen-code/sdk/daemon': path.resolve( + __dirname, + '../sdk-typescript/src/daemon/index.ts', + ), + }), + ], }, test: { // See packages/core/vitest.config.ts: raise the per-test ceiling above diff --git a/packages/core/package.json b/packages/core/package.json index 8e559c43669..0b658ca05ad 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,6 +45,18 @@ "types": "./dist/src/utils/no-follow-open.d.ts", "import": "./dist/src/utils/no-follow-open.js" }, + "./storage": { + "types": "./dist/src/config/storage.d.ts", + "import": "./dist/src/config/storage.js" + }, + "./atomicFileWrite": { + "types": "./dist/src/utils/atomicFileWrite.d.ts", + "import": "./dist/src/utils/atomicFileWrite.js" + }, + "./debugLogger": { + "types": "./dist/src/utils/debugLogger.d.ts", + "import": "./dist/src/utils/debugLogger.js" + }, "./conversationsRuntimeMarker": { "types": "./dist/src/utils/conversations-runtime-marker.d.ts", "import": "./dist/src/utils/conversations-runtime-marker.js" diff --git a/packages/sdk-typescript/tsconfig.build.json b/packages/sdk-typescript/tsconfig.build.json index 61dbca5b1d8..6cee9e28d71 100644 --- a/packages/sdk-typescript/tsconfig.build.json +++ b/packages/sdk-typescript/tsconfig.build.json @@ -8,7 +8,14 @@ "declarationMap": false, "sourceMap": false, "emitDeclarationOnly": true, - "removeComments": false + "removeComments": false, + // Reset the paths inherited from tsconfig.json: they map core subpaths to + // core sources for the cli bundle's esbuild discovery, but in this plain + // declaration build they would pull those sources into the program, + // re-root the inferred rootDir above this package, and nest every emitted + // .d.ts under dist/sdk-typescript/src/ (dropping dist/daemon/index.d.ts). + // Resolving core through its exports map keeps the dist layout stable. + "paths": {} }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"] diff --git a/packages/sdk-typescript/tsconfig.json b/packages/sdk-typescript/tsconfig.json index 8682024336e..ff2dc96f767 100644 --- a/packages/sdk-typescript/tsconfig.json +++ b/packages/sdk-typescript/tsconfig.json @@ -34,7 +34,19 @@ /* Module Resolution */ "resolveJsonModule": true, - "types": ["node"] + "types": ["node"], + "baseUrl": ".", + // The cli bundle pulls these sources in through the cli tsconfig + // `@qwen-code/sdk/*` mapping, and mainBuild discovers this file per + // importing file (it passes no `tsconfig` option). Without a named entry + // here, core subpath imports fall back to the `exports` map and bundle a + // packages/core/dist copy next to the packages/core/src copy every + // package-root import loads. Mirrors packages/acp-bridge/tsconfig.json. + "paths": { + "@qwen-code/qwen-code-core/transcriptRecords": [ + "../core/src/utils/transcript-records.ts" + ] + } }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "test", ".integration-tests"] diff --git a/packages/sdk-typescript/tsconfig.reference.json b/packages/sdk-typescript/tsconfig.reference.json index 63b8d095e99..c16bae74f22 100644 --- a/packages/sdk-typescript/tsconfig.reference.json +++ b/packages/sdk-typescript/tsconfig.reference.json @@ -4,5 +4,10 @@ "composite": true, "rootDir": "./src", "tsBuildInfoFile": "./dist/tsconfig.reference.tsbuildinfo" - } + }, + // The paths entry inherited from tsconfig.json resolves core subpath imports + // to core sources; in a composite tsc --build graph that file belongs to + // the core project, so it must be referenced here (mirrors + // packages/acp-bridge/tsconfig.json) or the build fails TS6059/TS6307. + "references": [{ "path": "../core" }] } diff --git a/scripts/dev.js b/scripts/dev.js index 34e5e5d50c8..072cc6533a2 100755 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -65,19 +65,51 @@ const cliEntry = join(cliPackageDir, 'index.ts'); const tmpDir = mkdtempSync(join(tmpdir(), 'qwen-dev-')); const loaderPath = join(tmpDir, 'loader.mjs'); -const coreSourcePath = join(root, 'packages', 'core', 'index.ts'); -const coreSourceUrl = pathToFileURL(coreSourcePath).href; +const coreDir = join(root, 'packages', 'core'); +const coreSpecifier = '@qwen-code/qwen-code-core'; +const coreSourceUrl = pathToFileURL(join(coreDir, 'index.ts')).href; + +// Dev runs the CLI from TypeScript source, so every core specifier has to land +// on packages/core/src. Intercepting only the package root is not enough: a +// named subpath (`…/debugLogger`) resolves through the package's `exports` map +// into packages/core/dist, while the root loads src — one process holding two +// instances of the same module, with split static state and a debug logger +// bound to a session the other copy never set. Deriving the map from `exports` +// keeps the interception complete as subpaths are added. +const coreExports = JSON.parse( + readFileSync(join(coreDir, 'package.json'), 'utf-8'), +).exports; + +const coreSubpathSourceUrls = {}; +for (const [subpath, conditions] of Object.entries(coreExports ?? {})) { + const distEntry = conditions?.import; + // Skips the root (handled below) and the string-valued entries — `./dist/*`, + // `./src/*`, `./package.json` — whose target is not one fixed source file. + if (subpath === '.' || typeof distEntry !== 'string') continue; + if (!distEntry.startsWith('./dist/')) continue; + const sourcePath = join( + coreDir, + distEntry.slice('./dist/'.length).replace(/\.js$/, '.ts'), + ); + if (existsSync(sourcePath)) { + coreSubpathSourceUrls[`${coreSpecifier}/${subpath.slice(2)}`] = + pathToFileURL(sourcePath).href; + } +} const loaderCode = ` -import { pathToFileURL } from 'node:url'; - const coreSourceUrl = '${coreSourceUrl}'; +const coreSubpathSourceUrls = ${JSON.stringify(coreSubpathSourceUrls, null, 2)}; export function resolve(specifier, context, nextResolve) { - if (specifier === '@qwen-code/qwen-code-core') { + const url = + specifier === '${coreSpecifier}' + ? coreSourceUrl + : coreSubpathSourceUrls[specifier]; + if (url) { return { shortCircuit: true, - url: coreSourceUrl, + url, format: 'module', }; } diff --git a/scripts/tests/core-subpath-exports-resolution.test.js b/scripts/tests/core-subpath-exports-resolution.test.js new file mode 100644 index 00000000000..11660b8c850 --- /dev/null +++ b/scripts/tests/core-subpath-exports-resolution.test.js @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Resolution guards for the core subpath scheme. +// +// cli sources import individual core modules through subpath specifiers +// (e.g. `@qwen-code/qwen-code-core/storage`). Typecheck resolves them via +// tsconfig `paths`, unit tests via the vitest alias list, and the bundle via +// esbuild's paths reading — none of which consults `exports`. Two failure +// modes stay invisible to all of those gates: +// +// 1. The built-but-unbundled CLI (`npm start`, `npm run build-and-start`) +// resolves subpaths through `packages/core/package.json` `exports` alone, +// so a specifier with no exports entry — or an entry whose import target +// is typo'd or redirected — crashes or misloads at module load. The +// plain-Node guard below resolves every specifier in a real child `node` +// process (no vitest aliases, no tsconfig paths), pins the resolved URL +// to the expected dist target, and asserts that target exists. +// 2. The bundle chain reads `packages/cli/tsconfig.json` `paths`; a subpath +// without a named entry falls through the wildcard to a nonexistent file +// and then back to the `exports` map, bundling a `packages/core/dist/**` +// copy while every package-root import loads `packages/core/src/**` — +// two instances of barrel-exported, stateful modules in one process, the +// module-identity failure #10908's Known risks name. The esbuild guard +// below bundles every core subpath statically imported from +// packages/cli/src — plus the subpaths the bundle reaches through +// @qwen-code/acp-bridge and @qwen-code/sdk — under the tsconfig each +// route's importing file discovers, and asserts every input lands under +// packages/core/src, never packages/core/dist. +// +// The pinned-target check makes a built core `dist` a prerequisite of the +// plain-Node guard (`import.meta.resolve` alone deliberately does not need +// one). This lane's vitest config does not wire +// `scripts/vitest-global-setup.js` (that guard is a globalSetup only in the +// packages/core and packages/cli configs, and its DIST_PREREQUISITES has no +// key covering this lane), so a missing dist surfaces as the existence +// assertion below naming the absent file — run `npm run build` from the +// repository root to produce it (#9149). + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { buildSync } from 'esbuild'; +import { describe, expect, it } from 'vitest'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '../..'); + +// Every core subpath specifier statically imported from packages/cli/src +// (storage, atomicFileWrite, debugLogger, noFollowOpen, envVarResolver, +// toolWriteOrigin, memoryScopes, conversationsRuntimeMarker), plus the +// subpaths `npm start` reaches through @qwen-code/acp-bridge +// (subSessionConstants, goalWire, transcriptRecords). Values are the dist +// targets pinned by the exports map in packages/core/package.json. Probes +// run from packages/cli; acp-bridge is a `file:` dependency there, so +// acp-bridge-routed specifiers resolve identically from that cwd. +const expectedDistTargets = { + '@qwen-code/qwen-code-core/storage': + 'packages/core/dist/src/config/storage.js', + '@qwen-code/qwen-code-core/atomicFileWrite': + 'packages/core/dist/src/utils/atomicFileWrite.js', + '@qwen-code/qwen-code-core/debugLogger': + 'packages/core/dist/src/utils/debugLogger.js', + '@qwen-code/qwen-code-core/noFollowOpen': + 'packages/core/dist/src/utils/no-follow-open.js', + '@qwen-code/qwen-code-core/envVarResolver': + 'packages/core/dist/src/utils/envVarResolver.js', + '@qwen-code/qwen-code-core/toolWriteOrigin': + 'packages/core/dist/src/services/tool-write-origin.js', + '@qwen-code/qwen-code-core/memoryScopes': + 'packages/core/dist/src/memory/scopes.js', + '@qwen-code/qwen-code-core/conversationsRuntimeMarker': + 'packages/core/dist/src/utils/conversations-runtime-marker.js', + '@qwen-code/qwen-code-core/subSessionConstants': + 'packages/core/dist/src/tools/sub-session-constants.js', + '@qwen-code/qwen-code-core/goalWire': + 'packages/core/dist/src/goals/goal-wire.js', + '@qwen-code/qwen-code-core/transcriptRecords': + 'packages/core/dist/src/utils/transcript-records.js', +}; + +function probe(specifier) { + const script = ` + try { + console.log('OK ' + import.meta.resolve(${JSON.stringify(specifier)})); + } catch (error) { + console.log('FAIL ' + (error.code ?? error.name)); + } + `; + return execFileSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: join(root, 'packages', 'cli'), + encoding: 'utf8', + }).trim(); +} + +describe('core subpath specifiers resolve under plain Node', () => { + it.each(Object.entries(expectedDistTargets))( + '%s resolves to its pinned exports target', + (specifier, expectedTarget) => { + const output = probe(specifier); + expect(output).toMatch(/^OK /); + const url = output.slice('OK '.length); + expect(url).toBe(pathToFileURL(join(root, expectedTarget)).href); + expect(existsSync(fileURLToPath(url))).toBe(true); + }, + ); +}); + +// The core subpath specifiers the cli bundle resolves under +// packages/cli/tsconfig.json `paths` — everything statically imported from +// packages/cli/src, plus goalWire, which the bundle reaches through +// @qwen-code/acp-bridge's transcript-replay — each mapped to the core source +// file the matching named `paths` entry must resolve it to. +const expectedSrcTargets = { + '@qwen-code/qwen-code-core/storage': 'packages/core/src/config/storage.ts', + '@qwen-code/qwen-code-core/atomicFileWrite': + 'packages/core/src/utils/atomicFileWrite.ts', + '@qwen-code/qwen-code-core/debugLogger': + 'packages/core/src/utils/debugLogger.ts', + '@qwen-code/qwen-code-core/noFollowOpen': + 'packages/core/src/utils/no-follow-open.ts', + '@qwen-code/qwen-code-core/envVarResolver': + 'packages/core/src/utils/envVarResolver.ts', + '@qwen-code/qwen-code-core/toolWriteOrigin': + 'packages/core/src/services/tool-write-origin.ts', + '@qwen-code/qwen-code-core/memoryScopes': + 'packages/core/src/memory/scopes.ts', + '@qwen-code/qwen-code-core/conversationsRuntimeMarker': + 'packages/core/src/utils/conversations-runtime-marker.ts', + '@qwen-code/qwen-code-core/goalWire': 'packages/core/src/goals/goal-wire.ts', +}; + +describe('core subpath specifiers bundle from the core src tree', () => { + it('resolves every bundled core subpath under packages/core/src', () => { + const result = buildSync({ + absWorkingDir: root, + stdin: { + contents: Object.keys(expectedSrcTargets) + .map((specifier) => `import ${JSON.stringify(specifier)};`) + .join('\n'), + resolveDir: join(root, 'packages', 'cli'), + sourcefile: 'core-subpath-bundle-probe.ts', + loader: 'ts', + }, + bundle: true, + write: false, + metafile: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + tsconfig: join(root, 'packages', 'cli', 'tsconfig.json'), + }); + // esbuild emits metafile input keys with the platform path separator + // (backslash on Windows); normalize to forward slashes so the target + // literals above and the leak filter below compare identically on every + // runner. + const inputs = Object.keys(result.metafile.inputs).map((input) => + input.replace(/\\/g, '/'), + ); + expect( + inputs.filter((input) => input.includes('packages/core/dist/')), + ).toEqual([]); + for (const target of Object.values(expectedSrcTargets)) { + expect(inputs).toContain(target); + } + }); +}); + +// The core subpath specifiers the bundle reaches through @qwen-code/acp-bridge +// — goalWire and transcriptRecords from transcript-replay.ts, +// subSessionConstants from bridgeOptions.ts, noFollowOpen from +// sessionArtifacts.ts — each mapped to the core source file the matching +// named `paths` entry in packages/acp-bridge/tsconfig.json must resolve it +// to. esbuild.config.js's mainBuild passes no `tsconfig` option, so the +// shipped bundle resolves these by discovering packages/acp-bridge/tsconfig.json +// per importing file, not the cli tsconfig pinned above — a guard arm that +// only probes the cli route is blind to mutations of these entries. +const expectedAcpBridgeSrcTargets = { + '@qwen-code/qwen-code-core/goalWire': 'packages/core/src/goals/goal-wire.ts', + '@qwen-code/qwen-code-core/transcriptRecords': + 'packages/core/src/utils/transcript-records.ts', + '@qwen-code/qwen-code-core/subSessionConstants': + 'packages/core/src/tools/sub-session-constants.ts', + '@qwen-code/qwen-code-core/noFollowOpen': + 'packages/core/src/utils/no-follow-open.ts', +}; + +describe('acp-bridge-routed core subpaths bundle from the core src tree', () => { + it('resolves every acp-bridge-routed subpath under packages/core/src', () => { + const result = buildSync({ + absWorkingDir: root, + stdin: { + contents: Object.keys(expectedAcpBridgeSrcTargets) + .map((specifier) => `import ${JSON.stringify(specifier)};`) + .join('\n'), + resolveDir: join(root, 'packages', 'acp-bridge', 'src'), + sourcefile: 'core-subpath-bundle-probe-acp-bridge.ts', + loader: 'ts', + }, + bundle: true, + write: false, + metafile: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + // Deliberately no `tsconfig` option: mainBuild carries none, so esbuild + // discovers packages/acp-bridge/tsconfig.json per importing file — the + // resolution route the shipped bundle actually uses for these imports. + }); + // Same backslash normalization as the cli-routed arm above. + const inputs = Object.keys(result.metafile.inputs).map((input) => + input.replace(/\\/g, '/'), + ); + expect( + inputs.filter((input) => input.includes('packages/core/dist/')), + ).toEqual([]); + for (const target of Object.values(expectedAcpBridgeSrcTargets)) { + expect(inputs).toContain(target); + } + }); +}); + +// The core subpath specifiers the bundle reaches through @qwen-code/sdk — +// transcriptRecords from daemon/ui/chat-record-transcript.ts, pulled in via +// the cli tsconfig `@qwen-code/sdk/*` mapping (cli sources such as +// ui/utils/export/export-transcript-document.ts import +// `@qwen-code/sdk/daemon/transcript`). mainBuild passes no `tsconfig` +// option, so the shipped bundle resolves these by discovering +// packages/sdk-typescript/tsconfig.json per importing file — a guard arm +// that only probes the cli and acp-bridge routes is blind to mutations of +// the named entry there. +const expectedSdkSrcTargets = { + '@qwen-code/qwen-code-core/transcriptRecords': + 'packages/core/src/utils/transcript-records.ts', +}; + +describe('sdk-routed core subpaths bundle from the core src tree', () => { + it('resolves every sdk-routed subpath under packages/core/src', () => { + const result = buildSync({ + absWorkingDir: root, + stdin: { + contents: Object.keys(expectedSdkSrcTargets) + .map((specifier) => `import ${JSON.stringify(specifier)};`) + .join('\n'), + resolveDir: join(root, 'packages', 'sdk-typescript', 'src'), + sourcefile: 'core-subpath-bundle-probe-sdk.ts', + loader: 'ts', + }, + bundle: true, + write: false, + metafile: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + // Deliberately no `tsconfig` option: mainBuild carries none, so esbuild + // discovers packages/sdk-typescript/tsconfig.json per importing file — + // the resolution route the shipped bundle actually uses for these + // imports. + }); + // Same backslash normalization as the cli-routed arm above. + const inputs = Object.keys(result.metafile.inputs).map((input) => + input.replace(/\\/g, '/'), + ); + expect( + inputs.filter((input) => input.includes('packages/core/dist/')), + ).toEqual([]); + for (const target of Object.values(expectedSdkSrcTargets)) { + expect(inputs).toContain(target); + } + }); +}); diff --git a/scripts/tests/dev.test.js b/scripts/tests/dev.test.js index 65f9e76f283..447cfa3a85f 100644 --- a/scripts/tests/dev.test.js +++ b/scripts/tests/dev.test.js @@ -5,11 +5,22 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFile, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const { spawnMock, platformMock, existsSyncMock } = vi.hoisted(() => ({ +const { + spawnMock, + platformMock, + existsSyncMock, + readFileSyncMock, + writeFileSyncMock, +} = vi.hoisted(() => ({ spawnMock: vi.fn(() => ({ on: vi.fn() })), platformMock: vi.fn(() => 'darwin'), existsSyncMock: vi.fn(() => false), + readFileSyncMock: vi.fn(() => JSON.stringify({ version: '0.0.0-test' })), + writeFileSyncMock: vi.fn(), })); vi.mock('node:child_process', () => ({ @@ -26,13 +37,13 @@ vi.mock('node:os', async (importOriginal) => { }); vi.mock('node:fs', () => ({ - writeFileSync: vi.fn(), + writeFileSync: writeFileSyncMock, mkdtempSync: vi.fn(() => '/tmp/qwen-dev-test'), rmSync: vi.fn(), existsSync: existsSyncMock, symlinkSync: vi.fn(), mkdirSync: vi.fn(), - readFileSync: vi.fn(() => JSON.stringify({ version: '0.0.0-test' })), + readFileSync: readFileSyncMock, })); const normalizePath = (path) => String(path).replaceAll('\\', '/'); @@ -137,4 +148,129 @@ describe('scripts/dev.js launcher', () => { else process.env.QWEN_CODE_CLI = inherited; } }); + + it('resolves core subpaths to packages/core/src, not the exports map dist', async () => { + // Intercepting only the package root leaves a named subpath to Node's + // `exports` map, which resolves into packages/core/dist while the root + // loads packages/core/src — one dev process holding two instances of the + // same module. Config binds the debug session on the src copy, so the dist + // copy's REMOTE_INPUT logger reads an empty session and every + // debugLogger(...) call in RemoteInputWatcher silently no-ops. + const repoRoot = fileURLToPath(new URL('../../', import.meta.url)); + const coreDir = join(repoRoot, 'packages', 'core'); + const corePackageJson = await readFile( + join(coreDir, 'package.json'), + 'utf-8', + ); + const existsOnDisk = async (p) => { + try { + await stat(p); + return true; + } catch { + return false; + } + }; + + // Ground truth independent of the implementation's derivation rule. + const expectedSources = { + '@qwen-code/qwen-code-core': 'packages/core/index.ts', + '@qwen-code/qwen-code-core/debugLogger': + 'packages/core/src/utils/debugLogger.ts', + '@qwen-code/qwen-code-core/storage': + 'packages/core/src/config/storage.ts', + '@qwen-code/qwen-code-core/atomicFileWrite': + 'packages/core/src/utils/atomicFileWrite.ts', + }; + + // Every named subpath the exports map publishes, so the interception + // cannot silently fall behind as subpaths are added. + const namedSubpaths = []; + for (const [subpath, conditions] of Object.entries( + JSON.parse(corePackageJson).exports ?? {}, + )) { + const distEntry = conditions?.import; + if (subpath === '.' || typeof distEntry !== 'string') continue; + if (!distEntry.startsWith('./dist/')) continue; + const sourcePath = join( + coreDir, + distEntry.slice('./dist/'.length).replace(/\.js$/, '.ts'), + ); + if (await existsOnDisk(sourcePath)) { + namedSubpaths.push(`@qwen-code/qwen-code-core/${subpath.slice(2)}`); + } + } + expect(namedSubpaths.length).toBeGreaterThan(0); + + const defaultRead = readFileSyncMock.getMockImplementation(); + const defaultExists = existsSyncMock.getMockImplementation(); + try { + readFileSyncMock.mockImplementation((filePath, ...rest) => + normalizePath(filePath).endsWith('packages/core/package.json') + ? corePackageJson + : defaultRead(filePath, ...rest), + ); + // The launcher only probes the source files it is about to map, so answer + // from the real tree: a mapping whose source is missing must be dropped + // rather than emitted. + existsSyncMock.mockImplementation((filePath) => { + const normalized = normalizePath(filePath); + return ( + normalized.includes('/packages/core/') && + (Object.values(expectedSources).some((s) => normalized.endsWith(s)) || + namedSubpaths.some((specifier) => { + const sub = specifier.slice('@qwen-code/qwen-code-core/'.length); + const distEntry = + JSON.parse(corePackageJson).exports[`./${sub}`]?.import; + return normalized.endsWith( + distEntry.slice('./dist/'.length).replace(/\.js$/, '.ts'), + ); + })) + ); + }); + + await import('../dev.js?subpath-source-map'); + + const loaderCall = writeFileSyncMock.mock.calls.find(([filePath]) => + normalizePath(filePath).endsWith('loader.mjs'), + ); + expect(loaderCall).toBeDefined(); + // Execute the hook the launcher actually generates instead of asserting + // on its source text. + const loader = await import( + `data:text/javascript;base64,${Buffer.from(loaderCall[1]).toString('base64')}` + ); + const nextResolve = (specifier) => ({ + url: `fallthrough:${specifier}`, + format: 'module', + shortCircuit: false, + }); + + for (const [specifier, expected] of Object.entries(expectedSources)) { + const resolved = loader.resolve(specifier, {}, nextResolve); + expect(resolved.shortCircuit, specifier).toBe(true); + const resolvedPath = normalizePath(fileURLToPath(resolved.url)); + expect(resolvedPath, specifier).toContain(expected); + expect(resolvedPath, specifier).not.toContain('/dist/'); + } + + // Completeness: no published named subpath may fall through to Node, + // which is the lane that reaches dist. + for (const specifier of namedSubpaths) { + const resolved = loader.resolve(specifier, {}, nextResolve); + expect(resolved.shortCircuit, specifier).toBe(true); + expect(resolved.url, specifier).not.toContain('fallthrough:'); + expect(normalizePath(fileURLToPath(resolved.url)), specifier).toContain( + '/packages/core/src/', + ); + } + + // Unrelated specifiers still reach Node's resolver. + expect(loader.resolve('node:fs', {}, nextResolve).url).toBe( + 'fallthrough:node:fs', + ); + } finally { + readFileSyncMock.mockImplementation(defaultRead); + existsSyncMock.mockImplementation(defaultExists); + } + }); });