From 56db17bd4cd87a2635b8da3d41641b488e90d400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Sun, 23 Aug 2026 14:41:49 +0000 Subject: [PATCH] refactor(cli): enforce utils leaf-layer dependency direction (#9146) (#9737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(cli): enforce utils leaf-layer dependency direction (#9146) Move domain-coupled modules out of packages/cli/src/utils into the directories that own them: config/ (dialogScopeUtils, settingsUtils), i18n/ (languageUtils), ui/ (handleAutoUpdate, standalone-update, systemInfo, systemInfoFields, update-relaunch, commands, doctorChecks), nonInteractive/ (nonInteractiveHelpers, chat-recording-failure, tool-result-boundary-diagnostics, permission-suggestions), serve/ (sandbox), services/housekeeping/ (scheduler, non-interactive-scheduler), and commands/review/ (findings). Extract the generic normalizePartList helper into utils/normalize-part-list.ts so utils consumers keep importing downward, and move the MergeStrategy enum into utils/deepMerge.ts (its owner). Add an eslint architecture rule (no-utils-upward-import) that forbids value imports from utils/ back up into a domain directory. Type-only imports stay exempt: they are erased at compile time and cannot create a runtime cycle (Settings in modelConfigUtils, CommandContext in sessionPaths). No behavior change: typecheck, build, and the affected unit tests pass. * fix: use Qwen Team 2026 license header on new files (#9146) * chore: refresh stale utils/ path references after leaf-layer move (#9146) * docs: reconcile no-utils-upward-import header with the allowed type-only set (#9146) * fix(cli): allowlist sandbox process.env accesses after leaf-layer move (#9146) * chore(ci): re-record qwen-autofix.yml size baseline after #9677 (#9146) #9677 recorded qwen-autofix.yml at 392111 bytes while the file it committed was already 397656, so every PR that merged main after it tripped the growth ratchet. Re-record the actual size; the file itself is unchanged by this PR. * fix(review): drop the stale utils/findings.ts digest root after the leaf-layer move (#9146) The #9146 move returned findings.ts to commands/review/, but the digest root lists merged from main still pinned it under utils/, where the file no longer exists — the absent root darkened every review's staleness check and failed review-source-digest.test.ts. Drop the stale file-shaped root from both digest copies and their pins; the commands/review/ directory root covers the validator at its new home, and the two utils helpers keep their file-shaped roots. * fix(review): colocate seatbelt profiles with the sandbox module (#9146) * fix(review): exempt inline type-only specifiers from the utils upward-import rule (#9146) * fix(review): report upward inline type-specifier imports under verbatimModuleSyntax (#9146) Co-authored-by: Qwen-Coder * test(review): pin mixed-specifier and zero-specifier upward imports in the utils rule (#9146) Co-authored-by: Qwen-Coder * test(review): anchor the nested-checkout utils rule fixture on the last marker (#9146) Co-authored-by: Qwen-Coder * test(review): pin that the utils/findings.ts digest root stays removed (#9146) Co-authored-by: Qwen-Coder * fix(review): reword stale-bundle SCOPE header to the post-move helper shape (#9146) * test(review): drop the pre-move utils/findings.ts from the skill-parity fixture (#9146) * test(serve): derive the seatbelt colocation tripwire from BUILTIN_SEATBELT_PROFILES (#9146) * fix(architecture): fail closed on computed dynamic imports in the utils leaf rule (#9146) * fix(cli): point settings.test.ts at the post-move settingsUtils path (#9146) main updated settings.test.ts after this branch moved settingsUtils.ts from utils/ into config/, and the merge kept main's old import specifier, which vite fails to resolve. Repoint it at ./settingsUtils.js; every other consumer already uses the new path. * fix(cli): close utils boundary review gaps * test(cli): cover utils boundary allow paths --------- Co-authored-by: Qwen-Coder --- .qwen/skills/autofix/scripts/run-agent.mjs | 2 +- docs/design/2026-07-21-lazy-undici-loading.md | 2 +- .../hot-reload/settings-change-detection.md | 2 +- docs/design/legacy-code-audit.md | 7 + .../slash-command/phase1-technical-design.md | 2 +- .../slash-command/phase3-technical-design.md | 4 +- docs/users/configuration/settings.md | 2 +- eslint-rules/no-utils-upward-import.js | 192 ++++++++++++++++++ eslint.config.js | 29 ++- packages/acp-bridge/src/workspacePaths.ts | 2 +- .../cli/src/acp-integration/acpAgent.test.ts | 6 +- packages/cli/src/acp-integration/acpAgent.ts | 6 +- .../src/acp-integration/session/Session.ts | 8 +- .../session/emitters/tool-call-emitter.ts | 2 +- .../session/history-replay-page.test.ts | 4 +- .../session/history-replay-page.ts | 2 +- packages/cli/src/commands/review.ts | 2 +- .../cli/src/commands/review/compose-review.ts | 2 +- .../review}/findings.test.ts | 0 .../{utils => commands/review}/findings.ts | 6 +- .../commands/review/lib/certification.test.ts | 2 +- .../src/commands/review/lib/certification.ts | 2 +- .../review/lib/review-settings.test.ts | 2 +- .../src/commands/review/lib/shell-quote.ts | 2 +- .../commands/review/lib/stale-bundle.test.ts | 14 +- .../src/commands/review/lib/stale-bundle.ts | 18 +- .../commands/review/lib/transcripts.test.ts | 2 +- .../src/commands/review/lib/transcripts.ts | 2 +- .../cli/src/commands/review/publish-assets.ts | 6 +- .../src/commands/review/save-artifact.test.ts | 2 +- .../cli/src/commands/review/save-artifact.ts | 2 +- packages/cli/src/commands/update.test.ts | 2 +- packages/cli/src/commands/update.ts | 2 +- .../src/{utils => config}/dialogScopeUtils.ts | 4 +- .../src/config/loadedSettingsAdapter.test.ts | 5 +- .../cli/src/config/loadedSettingsAdapter.ts | 2 +- .../src/config/migration/versions/v1-to-v2.ts | 2 +- .../src/config/migration/versions/v2-to-v3.ts | 2 +- .../src/config/migration/versions/v3-to-v4.ts | 2 +- packages/cli/src/config/settings.test.ts | 2 +- packages/cli/src/config/settings.ts | 5 +- .../cli/src/config/settingsSchema.test.ts | 2 +- packages/cli/src/config/settingsSchema.ts | 12 +- .../{utils => config}/settingsUtils.test.ts | 7 +- .../src/{utils => config}/settingsUtils.ts | 12 +- packages/cli/src/config/settingsWatcher.ts | 2 +- .../cli/src/dualOutput/DualOutputBridge.ts | 2 +- packages/cli/src/gemini.test.tsx | 12 +- packages/cli/src/gemini.tsx | 10 +- .../src/{utils => i18n}/languageUtils.test.ts | 4 +- .../cli/src/{utils => i18n}/languageUtils.ts | 4 +- .../chat-recording-failure.test.ts | 4 +- .../chat-recording-failure.ts | 6 +- .../controllers/permissionController.ts | 2 +- .../io/BaseJsonOutputAdapter.ts | 4 +- .../nonInteractive/io/JsonOutputAdapter.ts | 2 +- .../io/StreamJsonOutputAdapter.ts | 2 +- .../nonInteractiveHelpers.test.ts | 44 +--- .../nonInteractiveHelpers.ts | 28 +-- .../permission-suggestions.ts | 2 +- packages/cli/src/nonInteractive/session.ts | 2 +- .../tool-result-boundary-diagnostics.test.ts | 2 +- .../tool-result-boundary-diagnostics.ts | 2 +- packages/cli/src/nonInteractiveCli.ts | 6 +- packages/cli/src/nonInteractiveCliCommands.ts | 2 +- .../cli/src/serve/daemon-status-provider.ts | 2 +- .../cli/src/serve/process-env-guard.test.ts | 54 +++++ .../cli/src/serve/routes/workspace-models.ts | 2 +- .../serve/routes/workspace-settings.test.ts | 7 +- .../src/serve/routes/workspace-settings.ts | 2 +- .../sandbox-macos-permissive-closed.sb | 0 .../sandbox-macos-permissive-open.sb | 0 .../sandbox-macos-permissive-proxied.sb | 0 .../sandbox-macos-restrictive-closed.sb | 0 .../sandbox-macos-restrictive-open.sb | 0 .../sandbox-macos-restrictive-proxied.sb | 0 .../cli/src/{utils => serve}/sandbox.test.ts | 29 ++- packages/cli/src/{utils => serve}/sandbox.ts | 17 +- .../non-interactive-scheduler.test.ts | 9 +- .../housekeeping/scheduler.test.ts | 2 +- .../housekeeping/scheduler.ts | 6 +- .../cli/src/startup/startup-prefetch.test.ts | 4 +- packages/cli/src/startup/startup-prefetch.ts | 4 +- packages/cli/src/ui/AppContainer.test.tsx | 2 +- packages/cli/src/ui/AppContainer.tsx | 8 +- packages/cli/src/ui/auth/useAuth.test.ts | 4 +- .../cli/src/ui/commands/aboutCommand.test.ts | 4 +- packages/cli/src/ui/commands/aboutCommand.ts | 2 +- .../cli/src/ui/commands/bugCommand.test.ts | 4 +- packages/cli/src/ui/commands/bugCommand.ts | 4 +- .../{utils => ui/commands}/commands.test.ts | 2 +- .../src/{utils => ui/commands}/commands.ts | 2 +- .../cli/src/ui/commands/config-command.ts | 2 +- .../commands}/doctorChecks.test.ts | 12 +- .../{utils => ui/commands}/doctorChecks.ts | 10 +- .../cli/src/ui/commands/doctorCommand.test.ts | 4 +- packages/cli/src/ui/commands/doctorCommand.ts | 4 +- .../src/ui/commands/languageCommand.test.ts | 2 +- .../cli/src/ui/commands/languageCommand.ts | 2 +- .../src/ui/commands/update-command.test.ts | 2 +- .../cli/src/ui/commands/update-command.ts | 2 +- packages/cli/src/ui/components/AboutBox.tsx | 4 +- .../src/ui/components/ApprovalModeDialog.tsx | 2 +- .../src/ui/components/SettingsDialog.test.tsx | 12 +- .../cli/src/ui/components/SettingsDialog.tsx | 8 +- .../cli/src/ui/components/ThemeDialog.tsx | 2 +- .../ui/components/shared/ScopeSelector.tsx | 2 +- .../{utils => ui}/handleAutoUpdate.test.ts | 14 +- .../cli/src/{utils => ui}/handleAutoUpdate.ts | 12 +- .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 +- .../cli/src/ui/hooks/useCommandCompletion.tsx | 2 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 2 +- .../src/ui/hooks/useProviderUpdates.test.ts | 4 +- .../{utils => ui}/standalone-update.test.ts | 2 +- .../src/{utils => ui}/standalone-update.ts | 6 +- .../cli/src/{utils => ui}/systemInfo.test.ts | 6 +- packages/cli/src/{utils => ui}/systemInfo.ts | 4 +- .../{utils => ui}/systemInfoFields.test.ts | 0 .../cli/src/{utils => ui}/systemInfoFields.ts | 0 .../src/{utils => ui}/update-relaunch.test.ts | 6 +- .../cli/src/{utils => ui}/update-relaunch.ts | 6 +- packages/cli/src/ui/utils/commandUtils.ts | 2 +- packages/cli/src/utils/deepMerge.test.ts | 3 +- packages/cli/src/utils/deepMerge.ts | 11 +- packages/cli/src/utils/midTurnUserMessage.ts | 2 +- .../cli/src/utils/normalize-part-list.test.ts | 45 ++++ packages/cli/src/utils/normalize-part-list.ts | 31 +++ packages/cli/tsconfig.json | 2 +- .../artifacts/CodeReviewArtifactDetail.tsx | 4 +- scripts/copy_bundle_assets.js | 6 +- .../no-utils-upward-import-config.test.js | 92 +++++++++ scripts/tests/no-utils-upward-import.test.js | 178 ++++++++++++++++ scripts/tests/qwen-autofix-workflow.test.js | 2 +- scripts/tests/review-source-digest.test.ts | 39 +++- 134 files changed, 907 insertions(+), 355 deletions(-) create mode 100644 eslint-rules/no-utils-upward-import.js rename packages/cli/src/{utils => commands/review}/findings.test.ts (100%) rename packages/cli/src/{utils => commands/review}/findings.ts (99%) rename packages/cli/src/{utils => config}/dialogScopeUtils.ts (94%) rename packages/cli/src/{utils => config}/settingsUtils.test.ts (99%) rename packages/cli/src/{utils => config}/settingsUtils.ts (98%) rename packages/cli/src/{utils => i18n}/languageUtils.test.ts (99%) rename packages/cli/src/{utils => i18n}/languageUtils.ts (98%) rename packages/cli/src/{utils => nonInteractive}/chat-recording-failure.test.ts (96%) rename packages/cli/src/{utils => nonInteractive}/chat-recording-failure.ts (92%) rename packages/cli/src/{utils => nonInteractive}/nonInteractiveHelpers.test.ts (95%) rename packages/cli/src/{utils => nonInteractive}/nonInteractiveHelpers.ts (96%) rename packages/cli/src/{utils => nonInteractive}/permission-suggestions.ts (97%) rename packages/cli/src/{utils => nonInteractive}/tool-result-boundary-diagnostics.test.ts (99%) rename packages/cli/src/{utils => nonInteractive}/tool-result-boundary-diagnostics.ts (99%) rename packages/cli/src/{utils => serve}/sandbox-macos-permissive-closed.sb (100%) rename packages/cli/src/{utils => serve}/sandbox-macos-permissive-open.sb (100%) rename packages/cli/src/{utils => serve}/sandbox-macos-permissive-proxied.sb (100%) rename packages/cli/src/{utils => serve}/sandbox-macos-restrictive-closed.sb (100%) rename packages/cli/src/{utils => serve}/sandbox-macos-restrictive-open.sb (100%) rename packages/cli/src/{utils => serve}/sandbox-macos-restrictive-proxied.sb (100%) rename packages/cli/src/{utils => serve}/sandbox.test.ts (90%) rename packages/cli/src/{utils => serve}/sandbox.ts (98%) rename packages/cli/src/{utils => services}/housekeeping/non-interactive-scheduler.test.ts (97%) rename packages/cli/src/{utils => services}/housekeeping/scheduler.test.ts (99%) rename packages/cli/src/{utils => services}/housekeeping/scheduler.ts (98%) rename packages/cli/src/{utils => ui/commands}/commands.test.ts (99%) rename packages/cli/src/{utils => ui/commands}/commands.ts (98%) rename packages/cli/src/{utils => ui/commands}/doctorChecks.test.ts (97%) rename packages/cli/src/{utils => ui/commands}/doctorChecks.ts (97%) rename packages/cli/src/{utils => ui}/handleAutoUpdate.test.ts (98%) rename packages/cli/src/{utils => ui}/handleAutoUpdate.ts (96%) rename packages/cli/src/{utils => ui}/standalone-update.test.ts (99%) rename packages/cli/src/{utils => ui}/standalone-update.ts (99%) rename packages/cli/src/{utils => ui}/systemInfo.test.ts (98%) rename packages/cli/src/{utils => ui}/systemInfo.ts (98%) rename packages/cli/src/{utils => ui}/systemInfoFields.test.ts (100%) rename packages/cli/src/{utils => ui}/systemInfoFields.ts (100%) rename packages/cli/src/{utils => ui}/update-relaunch.test.ts (96%) rename packages/cli/src/{utils => ui}/update-relaunch.ts (94%) create mode 100644 packages/cli/src/utils/normalize-part-list.test.ts create mode 100644 packages/cli/src/utils/normalize-part-list.ts create mode 100644 scripts/tests/no-utils-upward-import-config.test.js create mode 100644 scripts/tests/no-utils-upward-import.test.js diff --git a/.qwen/skills/autofix/scripts/run-agent.mjs b/.qwen/skills/autofix/scripts/run-agent.mjs index 7d2eb53d95..31c568d587 100755 --- a/.qwen/skills/autofix/scripts/run-agent.mjs +++ b/.qwen/skills/autofix/scripts/run-agent.mjs @@ -221,7 +221,7 @@ function runQwen(options, prompt) { let idleTimedOut = false; let lastOutputAt = Date.now(); // The sandbox launcher prints the container name before the container - // starts (packages/cli/src/utils/sandbox.ts), so the FIRST match is this + // starts (packages/cli/src/serve/sandbox.ts), so the FIRST match is this // run's own container — the kill-path reap below relies on that ownership. let sandboxName = ''; let lineCarry = ''; diff --git a/docs/design/2026-07-21-lazy-undici-loading.md b/docs/design/2026-07-21-lazy-undici-loading.md index 61f5bf5e3b..83bbc90e38 100644 --- a/docs/design/2026-07-21-lazy-undici-loading.md +++ b/docs/design/2026-07-21-lazy-undici-loading.md @@ -26,7 +26,7 @@ The metafile showed eight value-import sites (type-only imports are free): | cli | `commands/channel/proxy.ts` | `EnvHttpProxyAgent`, `setGlobalDispatcher` | | cli | `utils/gitUtils.ts` | `ProxyAgent` | | cli | `services/setup-github.ts` | `ProxyAgent` | -| cli | `utils/standalone-update.ts` | `fetch` | +| cli | `ui/standalone-update.ts` | `fetch` | ## Design diff --git a/docs/design/hot-reload/settings-change-detection.md b/docs/design/hot-reload/settings-change-detection.md index 5b988959e3..a5409396dd 100644 --- a/docs/design/hot-reload/settings-change-detection.md +++ b/docs/design/hot-reload/settings-change-detection.md @@ -419,7 +419,7 @@ not be re-plumbed through a running session. ### Decision: Reuse the schema's `requiresRestart` flag (single source of truth) `settingsSchema.ts` already declares `requiresRestart: boolean` on **every** key, -and `packages/cli/src/utils/settingsUtils.ts` already exposes the lookups: +and `packages/cli/src/config/settingsUtils.ts` already exposes the lookups: - `requiresRestart(key: string): boolean` — flag for a dot-path key - `getFlattenedSchema()` — full flattened `key → definition` map diff --git a/docs/design/legacy-code-audit.md b/docs/design/legacy-code-audit.md index a55b1d17c1..184a5d1703 100644 --- a/docs/design/legacy-code-audit.md +++ b/docs/design/legacy-code-audit.md @@ -155,6 +155,13 @@ section; the benefit is that neither document lies about its flow. **Decisions** (rationale in the prose below): +> **Implementation note (2026-08-23):** #9146 moved the existing review +> findings schema to `packages/cli/src/commands/review/findings.ts` and made +> `packages/cli/src/utils/` a mechanically enforced leaf layer. The proposed +> shared-home placement below is retained as design history, not as an +> instruction to restore `utils/findings.ts`. A future `/audit` implementation +> must revisit the neutral contract ownership explicitly. + - `/audit` is a new skill with its own SKILL.md; `/review`'s SKILL.md and certifying path stay untouched — no in-place target-kind branches in the files `/review`'s coverage gate recomputes. diff --git a/docs/design/slash-command/phase1-technical-design.md b/docs/design/slash-command/phase1-technical-design.md index ef25b86ff7..0b3854bf35 100644 --- a/docs/design/slash-command/phase1-technical-design.md +++ b/docs/design/slash-command/phase1-technical-design.md @@ -577,7 +577,7 @@ const slashCommands = await getAvailableCommands( ### 9.3 不变的文件 -- `packages/cli/src/utils/commands.ts`(`parseSlashCommand` 无需修改) +- `packages/cli/src/ui/commands/commands.ts`(`parseSlashCommand` 无需修改) - `packages/cli/src/ui/hooks/slashCommandProcessor.ts`(interactive 路径无需修改) - `packages/cli/src/ui/noninteractive/nonInteractiveUi.ts`(stub UI 无需修改) - 所有命令的 `action` 实现(Phase 1 不修改任何命令行为) diff --git a/docs/design/slash-command/phase3-technical-design.md b/docs/design/slash-command/phase3-technical-design.md index 3cc741e195..067a3792a5 100644 --- a/docs/design/slash-command/phase3-technical-design.md +++ b/docs/design/slash-command/phase3-technical-design.md @@ -73,7 +73,7 @@ export type CommandSource = | ACP `argumentHint` | 已映射到 `availableCommands[].input.hint` | `acp-integration/session/Session.ts` | | ACP source/supportedModes/subcommands/modelInvocable | 未暴露 | `acp-integration/session/Session.ts` | | 冲突处理 | extension 命令冲突时已重命名为 `extensionName.commandName`,非 extension 同名为后加载覆盖前加载 | `services/CommandService.ts` | -| `/doctor` | 已实现,支持 `interactive` / `non_interactive` / `acp` | `ui/commands/doctorCommand.ts`、`utils/doctorChecks.ts` | +| `/doctor` | 已实现,支持 `interactive` / `non_interactive` / `acp` | `ui/commands/doctorCommand.ts`、`ui/commands/doctorChecks.ts` | ### 2.3 Claude Code 可借鉴点 @@ -548,7 +548,7 @@ type AcpSubcommandMeta = { - 模式:`['interactive', 'non_interactive', 'acp']` - interactive:展示 `HistoryItemDoctor` - non_interactive/acp:返回 JSON `message` -- 诊断逻辑:`packages/cli/src/utils/doctorChecks.ts` +- 诊断逻辑:`packages/cli/src/ui/commands/doctorChecks.ts` Phase 3 只需在 Help 和补全中为 `/doctor` 正确展示来源、mode;如需优化,可将 headless JSON 改为更适合人读的 Markdown,但这不是必需项。 diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 3d03cacb29..db07af7a06 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -766,7 +766,7 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe | `QWEN_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to. When set, overrides OTLP export. | Overrides the `telemetry.outfile` setting. | | `QWEN_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. | | `QWEN_SANDBOX_IMAGE` | Overrides sandbox image selection for Docker/Podman. | Takes precedence over `tools.sandboxImage`. | -| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | +| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/serve/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | | `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. | | `NO_COLOR` | Set to any value to disable all color output in the CLI. | | | `FORCE_HYPERLINK` | Override the OSC 8 clickable-link detection in the markdown renderer. Set to `1` (or any non-zero integer, or empty string) to force-enable; set to `0` or a non-numeric value such as `false` / `off` to force-disable. Honors `NO_COLOR` / `QWEN_DISABLE_HYPERLINKS` opt-outs above it. | Use this to opt into OSC 8 inside `tmux` / GNU `screen` (auto-detection refuses by default because the host terminal's capabilities are hidden behind the multiplexer). Requires `set -g allow-passthrough on` on tmux 3.3+. Also enables Hyper, which isn't auto-detected. | diff --git a/eslint-rules/no-utils-upward-import.js b/eslint-rules/no-utils-upward-import.js new file mode 100644 index 0000000000..48f6163307 --- /dev/null +++ b/eslint-rules/no-utils-upward-import.js @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; + +/** + * `packages/cli/src/utils/` is the leaf layer that every other directory + * imports. It must not import back up into a domain directory (`config/`, + * `ui/`, `i18n/`, `nonInteractive/`, `commands/`, `serve/`, + * `acp-integration/`, ...): that is the dependency-direction invariant + * tracked in #9146. + * + * The only permitted "upward" references are the type-only constructs that + * are genuinely erased at compile time: statement-level `import type`, + * `export type ... from`, and TS `import('...').T` type queries. Inline type + * specifiers (`import { type X } from` / `export { type X } from`) are + * reported instead: under this repo's `verbatimModuleSyntax`, tsc keeps the + * declaration and emits `import {} from` / `export {} from`, a runtime edge + * that still evaluates the target module. Everything else (value imports, + * value re-exports, dynamic `import()`) is reported too: a literal or + * single-segment template source is checked against its resolved path. CLI + * baseUrl specifiers rooted at `src/` are resolved from `packages/cli/` and + * checked the same way. A + * computed source (a multi-segment template or a `+` concatenation) whose + * statically known prefix is local is reported fail-closed, because + * interpolation can contribute a `../` step no static check can rule out. A + * computed source with no statically known local prefix is dropped, the same + * boundary applied to package and builtin specifiers. The two remaining + * instances (`Settings` in `modelConfigUtils.ts`, `CommandContext` in + * `sessionPaths.ts`) are this irreducible type-level coupling. + */ + +const CLI_PACKAGE_MARKER = 'packages/cli/'; +const CLI_UTILS_MARKER = `${CLI_PACKAGE_MARKER}src/utils/`; +const TEST_OR_FIXTURE_SEGMENTS = new Set(['__tests__', 'fixtures']); + +function isCliUtilsProductionFile(filename) { + if (!filename || filename === '' || filename === '') { + return false; + } + const normalized = path.normalize(filename).replaceAll('\\', '/'); + const start = normalized.lastIndexOf(CLI_UTILS_MARKER); + if (start < 0) { + return false; + } + const relativePath = normalized.slice(start + CLI_UTILS_MARKER.length); + if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(relativePath)) { + return false; + } + return !relativePath.split('/').some((s) => TEST_OR_FIXTURE_SEGMENTS.has(s)); +} + +function escapesUtils(filename, importedPath) { + const normalized = path.normalize(filename).replaceAll('\\', '/'); + const markerStart = normalized.lastIndexOf(CLI_UTILS_MARKER); + const utilsRoot = normalized.slice(0, markerStart + CLI_UTILS_MARKER.length); + const cliRoot = normalized.slice(0, markerStart + CLI_PACKAGE_MARKER.length); + const resolved = path.resolve( + importedPath.startsWith('src/') ? cliRoot : path.dirname(filename), + importedPath, + ); + return path + .relative(utilsRoot, resolved) + .replaceAll('\\', '/') + .startsWith('..'); +} + +/** + * The statically known leading characters of a computed dynamic-import + * source: the first quasi of a template literal, the string literal itself, + * or the leftmost operand of a `+` concatenation. Anything else (a bare + * identifier, a call, an empty first quasi) has no statically known prefix. + */ +function knownDynamicPrefix(node) { + if (node.type === 'Literal') { + return typeof node.value === 'string' ? node.value : null; + } + if (node.type === 'TemplateLiteral') { + return node.quasis[0].value.cooked; + } + if (node.type === 'BinaryExpression' && node.operator === '+') { + return knownDynamicPrefix(node.left); + } + return null; +} + +/** Whether a known prefix spells a relative specifier (`./`, `../`, `.`, `..`). */ +function isRelativePrefix(prefix) { + return ( + prefix === '.' || + prefix === '..' || + prefix.startsWith('./') || + prefix.startsWith('../') + ); +} + +function isCliBaseUrlPrefix(prefix) { + return prefix.startsWith('src/'); +} + +export default { + meta: { + type: 'problem', + docs: { + description: + 'packages/cli/src/utils must not import outside utils/ (leaf-layer dependency direction).', + }, + messages: { + noUtilsUpwardImport: + 'packages/cli/src/utils must not import outside utils/. ' + + 'Invert the dependency (pass the value in) or move the module to the ' + + 'domain directory that owns it (#9146).', + noUtilsUnprovableDynamicImport: + 'packages/cli/src/utils cannot statically prove this computed ' + + 'dynamic import() stays inside utils/ — interpolation can ' + + 'contribute a `../` step. Resolve the target through a literal or ' + + 'single-segment template source, or pass the module in (#9146).', + }, + }, + create(context) { + const { filename } = context; + if (!isCliUtilsProductionFile(filename)) { + return {}; + } + + const reportIfEscaping = (sourceNode, importedPath) => { + if ( + typeof importedPath === 'string' && + (importedPath.startsWith('.') || isCliBaseUrlPrefix(importedPath)) && + escapesUtils(filename, importedPath) + ) { + context.report({ node: sourceNode, messageId: 'noUtilsUpwardImport' }); + } + }; + + const checkStatic = (node) => { + // Statement-level type-only imports (`import type`, `export type ... + // from`) are erased at compile time and cannot create a runtime cycle. + // Inline type specifiers (`import { type X } from ...`) are NOT exempt: + // under this repo's `verbatimModuleSyntax`, tsc keeps the declaration + // and emits `import {} from ...` / `export {} from ...`, a runtime edge + // that evaluates the target module — so they are reported like value + // imports. + if (node.importKind === 'type' || node.exportKind === 'type') { + return; + } + reportIfEscaping(node.source, node.source?.value); + }; + + const checkDynamic = (node) => { + const { source } = node; + if (source.type === 'Literal') { + reportIfEscaping(source, source.value); + return; + } + if (source.type === 'TemplateLiteral' && source.quasis.length === 1) { + reportIfEscaping(source, source.quasis[0].value.cooked); + return; + } + // Computed sources — multi-segment templates and `+` concatenations — + // fail closed when their statically known prefix is relative: + // interpolation can contribute a `../` step, so no static check can + // prove the import stays inside utils/ (a leading `../` cannot be + // undone by interpolation at all). A computed source with no known + // local prefix — a bare identifier or a package-like prefix — is + // dropped, the same boundary applied to package and builtin static + // specifiers. CLI baseUrl sources rooted at `src/` are local too. + const prefix = knownDynamicPrefix(source); + if ( + typeof prefix === 'string' && + (isRelativePrefix(prefix) || isCliBaseUrlPrefix(prefix)) + ) { + context.report({ + node: source, + messageId: 'noUtilsUnprovableDynamicImport', + }); + } + }; + + return { + ImportDeclaration: checkStatic, + ExportNamedDeclaration: checkStatic, + ExportAllDeclaration: checkStatic, + ImportExpression: checkDynamic, + // TSImportType (`import('../config/x').T`) is type-only by definition, so + // it is intentionally not reported. + }; + }, +}; diff --git a/eslint.config.js b/eslint.config.js index 1ed8d964e8..f47b87fbf8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,6 +16,7 @@ import globals from 'globals'; import storybook from 'eslint-plugin-storybook'; import checkFile from 'eslint-plugin-check-file'; import noCoreRootBarrelImport from './eslint-rules/no-core-root-barrel-import.js'; +import noUtilsUpwardImport from './eslint-rules/no-utils-upward-import.js'; import { legacyFilenames } from './eslint.legacy-filenames.mjs'; // General syntax restrictions applied to every TS/TSX source file. Hoisted so @@ -109,24 +110,20 @@ export default tseslint.config( }, }, { - // `utils/` is the layer every other directory imports, so it must not - // import back into one. The daemon direction is clean and enforced here; - // the remaining `ui/`, `config/`, `i18n/` and `nonInteractive/` edges are - // tracked in #9146 and will be added to this group as they are resolved. + // `utils/` is the leaf layer that every other directory imports, so it + // must not import back up into a domain directory. Type-only imports are + // exempt: they are erased at compile time and cannot create a runtime + // cycle. See #9146. files: ['packages/cli/src/utils/**/*.{ts,tsx}'], - rules: { - 'no-restricted-imports': [ - 'error', - { - patterns: [ - { - group: ['**/serve/*', '**/serve/**'], - message: - 'packages/cli/src/utils must not import serve/. Move lifecycle-free logic down into utils/ instead (#9146).', - }, - ], + plugins: { + architecture: { + rules: { + 'no-utils-upward-import': noUtilsUpwardImport, }, - ], + }, + }, + rules: { + 'architecture/no-utils-upward-import': 'error', }, }, { diff --git a/packages/acp-bridge/src/workspacePaths.ts b/packages/acp-bridge/src/workspacePaths.ts index ef23166595..a60d92745e 100644 --- a/packages/acp-bridge/src/workspacePaths.ts +++ b/packages/acp-bridge/src/workspacePaths.ts @@ -12,7 +12,7 @@ const WINDOWS_ABSOLUTE_PATH_RE = /^([A-Za-z]):[\\/](.*)$/; /** * Maps a Windows-shaped absolute path to the container mount produced by the * host-side sandbox launcher (`C:\work\proj` → `/c/work/proj`, mirroring - * `getContainerPath` in `cli/src/utils/sandbox.ts`). + * `getContainerPath` in `cli/src/serve/sandbox.ts`). * * A Windows host relaunching `qwen serve` into a Linux Docker/Podman sandbox * translates the bind mount and `--workdir`, but path-valued CLI arguments diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 6ad752811c..ad166810d3 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -35,7 +35,7 @@ const { mockMcpPoolDrainAll } = vi.hoisted(() => ({ vi.mock('../utils/cleanup.js', () => ({ runExitCleanup: mockRunExitCleanup, })); -vi.mock('../utils/housekeeping/scheduler.js', () => ({ +vi.mock('../services/housekeeping/scheduler.js', () => ({ startNonInteractiveOpenAILogHousekeeping: mockStartNonInteractiveOpenAILogHousekeeping, })); @@ -886,7 +886,7 @@ vi.mock('./session/Session.js', () => { }), }; }); -vi.mock('../utils/languageUtils.js', () => ({ +vi.mock('../i18n/languageUtils.js', () => ({ updateOutputLanguageFile: vi.fn(), writeOutputLanguageAndRegisterPath: vi.fn( ( @@ -1011,7 +1011,7 @@ import { resolveOutputLanguageOrPreserveAuto, updateOutputLanguageFile, writeOutputLanguageAndRegisterPath, -} from '../utils/languageUtils.js'; +} from '../i18n/languageUtils.js'; import { buildAuthMethods } from './authMethods.js'; import { ACTIVE_WORK_HEARTBEAT_META_KEY, diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 45ff95d5d6..914bcc2260 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -187,7 +187,7 @@ import { ACP_EVENT_LOOP_STALL_RESTART_MS, CHANNEL_PROMPT_META_KEY, } from '@qwen-code/channel-base'; -import { observeAcpToolResultWire } from '../utils/tool-result-boundary-diagnostics.js'; +import { observeAcpToolResultWire } from '../nonInteractive/tool-result-boundary-diagnostics.js'; import { Readable, Writable } from 'node:stream'; import { normalizeDisabledToolList } from '../config/normalizeDisabledTools.js'; import { pipeline } from 'node:stream/promises'; @@ -279,11 +279,11 @@ import { resolveOutputLanguageOrPreserveAuto, getOutputLanguageFilePath, writeOutputLanguageAndRegisterPath, -} from '../utils/languageUtils.js'; +} from '../i18n/languageUtils.js'; import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js'; import { ACP_ERROR_CODES } from './errorCodes.js'; import { runExitCleanup } from '../utils/cleanup.js'; -import { startNonInteractiveOpenAILogHousekeeping } from '../utils/housekeeping/scheduler.js'; +import { startNonInteractiveOpenAILogHousekeeping } from '../services/housekeeping/scheduler.js'; import { appEvents, AppEvent } from '../utils/events.js'; import { setLanguageAsync, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 9852db14cf..d58cdf524d 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -270,10 +270,8 @@ import type { AgentSideConnection, } from '@agentclientprotocol/sdk'; import { SettingScope, type LoadedSettings } from '../../config/settings.js'; -import { - insertAfterFunctionResponses, - normalizePartList, -} from '../../utils/nonInteractiveHelpers.js'; +import { insertAfterFunctionResponses } from '../../nonInteractive/nonInteractiveHelpers.js'; +import { normalizePartList } from '../../utils/normalize-part-list.js'; import { prefixMidTurnUserMessageParts } from '../../utils/midTurnUserMessage.js'; import { handleSlashCommand, @@ -322,7 +320,7 @@ import type { } from './types.js'; import { HistoryReplayer } from './history-replayer.js'; import { projectAcpToolResultUpdate } from './acp-tool-result-text-projection.js'; -import { observeAcpToolResultProjection } from '../../utils/tool-result-boundary-diagnostics.js'; +import { observeAcpToolResultProjection } from '../../nonInteractive/tool-result-boundary-diagnostics.js'; import { ToolCallEmitter } from './emitters/tool-call-emitter.js'; import { ToolCallPreparationTracker } from './tool-call-preparation-tracker.js'; import { PlanEmitter } from './emitters/PlanEmitter.js'; diff --git a/packages/cli/src/acp-integration/session/emitters/tool-call-emitter.ts b/packages/cli/src/acp-integration/session/emitters/tool-call-emitter.ts index 5e5cff6428..0a05f4a712 100644 --- a/packages/cli/src/acp-integration/session/emitters/tool-call-emitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/tool-call-emitter.ts @@ -31,7 +31,7 @@ import { createTranscriptToolCallStartUpdate, } from '@qwen-code/acp-bridge/transcriptReplay'; import { sanitizeTerminalText } from '../../../ui/utils/textUtils.js'; -import { associateAcpToolResultArtifact } from '../../../utils/tool-result-boundary-diagnostics.js'; +import { associateAcpToolResultArtifact } from '../../../nonInteractive/tool-result-boundary-diagnostics.js'; const KIND_MAP: Record = { [Kind.Read]: 'read', diff --git a/packages/cli/src/acp-integration/session/history-replay-page.test.ts b/packages/cli/src/acp-integration/session/history-replay-page.test.ts index 512326844e..ebe35b9e9b 100644 --- a/packages/cli/src/acp-integration/session/history-replay-page.test.ts +++ b/packages/cli/src/acp-integration/session/history-replay-page.test.ts @@ -28,10 +28,10 @@ import { const observeAcpProjectionMock = vi.hoisted(() => vi.fn()); vi.mock( - '../../utils/tool-result-boundary-diagnostics.js', + '../../nonInteractive/tool-result-boundary-diagnostics.js', async (original) => ({ ...(await original< - typeof import('../../utils/tool-result-boundary-diagnostics.js') + typeof import('../../nonInteractive/tool-result-boundary-diagnostics.js') >()), observeAcpToolResultProjection: observeAcpProjectionMock, }), diff --git a/packages/cli/src/acp-integration/session/history-replay-page.ts b/packages/cli/src/acp-integration/session/history-replay-page.ts index 3795594724..917da35d17 100644 --- a/packages/cli/src/acp-integration/session/history-replay-page.ts +++ b/packages/cli/src/acp-integration/session/history-replay-page.ts @@ -21,7 +21,7 @@ import type { SessionUpdate } from '@agentclientprotocol/sdk'; import type { TranscriptReplayStateV1 } from '@qwen-code/acp-bridge/transcriptReplay'; import { Buffer } from 'node:buffer'; import { projectAcpToolResultUpdate } from './acp-tool-result-text-projection.js'; -import { observeAcpToolResultProjection } from '../../utils/tool-result-boundary-diagnostics.js'; +import { observeAcpToolResultProjection } from '../../nonInteractive/tool-result-boundary-diagnostics.js'; import { HistoryReplayer } from './history-replayer.js'; import type { PendingReplayToolCall } from './history-replayer.js'; import type { CumulativeUsage, SessionEmitterContext } from './types.js'; diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts index 0f02bf251f..bb75c00ee1 100644 --- a/packages/cli/src/commands/review.ts +++ b/packages/cli/src/commands/review.ts @@ -12,7 +12,7 @@ import type { Argv, CommandModule } from 'yargs'; import { parseArgsCommand } from './review/parse-args.js'; import { matchRemoteCommand } from './review/match-remote.js'; import { composeReviewCommand } from './review/compose-review.js'; -import { findingsCommand } from '../utils/findings.js'; +import { findingsCommand } from './review/findings.js'; import { recoverFindingsCommand } from './review/recover-findings.js'; import { fetchPrCommand } from './review/fetch-pr.js'; import { captureLocalCommand } from './review/capture-local.js'; diff --git a/packages/cli/src/commands/review/compose-review.ts b/packages/cli/src/commands/review/compose-review.ts index 47c86012df..b359d0e903 100644 --- a/packages/cli/src/commands/review/compose-review.ts +++ b/packages/cli/src/commands/review/compose-review.ts @@ -37,7 +37,7 @@ import { SOURCES, type Severity, type Source, -} from '../../utils/findings.js'; +} from './findings.js'; import { BRIEFS } from './lib/agent-briefs.js'; import { budgetStopDisclosure, diff --git a/packages/cli/src/utils/findings.test.ts b/packages/cli/src/commands/review/findings.test.ts similarity index 100% rename from packages/cli/src/utils/findings.test.ts rename to packages/cli/src/commands/review/findings.test.ts diff --git a/packages/cli/src/utils/findings.ts b/packages/cli/src/commands/review/findings.ts similarity index 99% rename from packages/cli/src/utils/findings.ts rename to packages/cli/src/commands/review/findings.ts index 28129e7517..ca72da12c9 100644 --- a/packages/cli/src/utils/findings.ts +++ b/packages/cli/src/commands/review/findings.ts @@ -42,9 +42,9 @@ import { } from 'node:fs'; import type { Stats } from 'node:fs'; import { dirname, resolve, sep } from 'node:path'; -import { writeStdoutLine, writeStderrLine } from './stdioHelpers.js'; -import type { AnchorRequest } from '../commands/review/lib/anchors.js'; -import { isSameFile } from '../commands/review/lib/same-file.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import type { AnchorRequest } from './lib/anchors.js'; +import { isSameFile } from './lib/same-file.js'; // These four lists have a second consumer: the Web Shell review renderer // (packages/web-shell/client/components/artifacts/CodeReviewArtifactDetail.tsx) diff --git a/packages/cli/src/commands/review/lib/certification.test.ts b/packages/cli/src/commands/review/lib/certification.test.ts index 65e5162816..4217c43f5c 100644 --- a/packages/cli/src/commands/review/lib/certification.test.ts +++ b/packages/cli/src/commands/review/lib/certification.test.ts @@ -112,7 +112,7 @@ describe('openedBrief / readBrief', () => { const arg = `{"absolute_path":${needle}}`; it('does not credit a shell command that merely MENTIONS the brief', () => { - // The trap a prose matcher walks into: `utils/findings.ts` has a + // The trap a prose matcher walks into: `findings.ts` has a // same-purpose-looking `namesPath` that matches on a name boundary, and // it credits this arg. Deleting a brief is not opening it — so this atom // matches the whole JSON string value instead, and keeps a different diff --git a/packages/cli/src/commands/review/lib/certification.ts b/packages/cli/src/commands/review/lib/certification.ts index d113dcf3b6..29235c50f7 100644 --- a/packages/cli/src/commands/review/lib/certification.ts +++ b/packages/cli/src/commands/review/lib/certification.ts @@ -70,7 +70,7 @@ export function declaresOwnUncoverable( * not one per half. This wrapper only spreads it over a record's call list; * every path atom below routes through it. * - * The name is deliberately not `namesPath`: `utils/findings.ts` has a + * The name is deliberately not `namesPath`: `findings.ts` has a * module-private `namesPath` that matches a path named in PROSE on a name * boundary — it credits `rm /plan/chunk-3.brief.md` for naming the brief. * Unifying these two would make `openedBrief` credit an agent for deleting a diff --git a/packages/cli/src/commands/review/lib/review-settings.test.ts b/packages/cli/src/commands/review/lib/review-settings.test.ts index 451aae11bc..b2d02d6103 100644 --- a/packages/cli/src/commands/review/lib/review-settings.test.ts +++ b/packages/cli/src/commands/review/lib/review-settings.test.ts @@ -36,7 +36,7 @@ vi.mock('../../../config/settings.js', async (importOriginal) => { return { ...actual, loadSettings: loadSettingsMock }; }); import { operatorReviewSettings } from './review-settings.js'; -import { getDialogSettingKeys } from '../../../utils/settingsUtils.js'; +import { getDialogSettingKeys } from '../../../config/settingsUtils.js'; function setReview(review: unknown): void { loadSettingsMock.mockReturnValue({ merged: { review } }); diff --git a/packages/cli/src/commands/review/lib/shell-quote.ts b/packages/cli/src/commands/review/lib/shell-quote.ts index 08e62a6206..3708decf3a 100644 --- a/packages/cli/src/commands/review/lib/shell-quote.ts +++ b/packages/cli/src/commands/review/lib/shell-quote.ts @@ -10,7 +10,7 @@ * plain `'…'` wrap traded that for breaking on the first embedded apostrophe * (`~/Documents/John's Projects/…` is an ordinary macOS workspace). The * `'\''` dance closes both: end the quote, emit a literal `'`, reopen. - * Same pattern as `shellQuoteForSh` in utils/standalone-update.ts. + * Same pattern as `shellQuoteForSh` in ui/standalone-update.ts. */ export function shellQuotePath(p: string): string { return `'${p.replace(/'/g, "'\\''")}'`; diff --git a/packages/cli/src/commands/review/lib/stale-bundle.test.ts b/packages/cli/src/commands/review/lib/stale-bundle.test.ts index 537ca6b938..262484db10 100644 --- a/packages/cli/src/commands/review/lib/stale-bundle.test.ts +++ b/packages/cli/src/commands/review/lib/stale-bundle.test.ts @@ -477,9 +477,12 @@ describe('the bundled skill stops on what this module prints', () => { const services = join(root, 'packages', 'cli', 'src', 'services'); mkdirSync(services, { recursive: true }); writeFileSync(join(services, 'review-worktree-lease.ts'), 'leases'); + // No `findings.ts` under `utils/`: it moved back under + // `commands/review/` (#9146), which the directory root already covers + // via `drive.ts`. No root digests `utils/` wholesale, so the materialized + // tree mirrors `reviewSourceRoots` exactly. const utils = join(root, 'packages', 'cli', 'src', 'utils'); mkdirSync(utils, { recursive: true }); - writeFileSync(join(utils, 'findings.ts'), 'validates'); writeFileSync(join(utils, 'shell-args.ts'), 'tokenizes'); writeFileSync(join(utils, 'paths.ts'), 'flattens'); const skillDir = join( @@ -586,12 +589,9 @@ describe('reviewSourceRoots', () => { ), kind: 'code', }, - // The review helpers lifted out of `commands/review/`; the digest - // covered them there before the lift. - { - path: join('/w', 'packages', 'cli', 'src', 'utils', 'findings.ts'), - kind: 'code', - }, + // The helpers of the findings validator live in `utils/`, outside the + // `review/` directory root; the validator itself lives back under + // `commands/review/` (#9146), which the directory root covers. { path: join('/w', 'packages', 'cli', 'src', 'utils', 'shell-args.ts'), kind: 'code', diff --git a/packages/cli/src/commands/review/lib/stale-bundle.ts b/packages/cli/src/commands/review/lib/stale-bundle.ts index 58ceff92d6..94abee2d35 100644 --- a/packages/cli/src/commands/review/lib/stale-bundle.ts +++ b/packages/cli/src/commands/review/lib/stale-bundle.ts @@ -30,8 +30,9 @@ // // SCOPE, so silence is not read as more than it is: the roots are the review // commands, the file that registers them, the review-only lease they import -// from `services/`, the three review helpers lifted into `utils/`, and the -// bundled skill — not the modules those import. Editing +// from `services/`, the two review helpers left in `utils/`, and the bundled +// skill — not the modules those import. The validator itself is back under +// `commands/review/`, which the directory root covers. Editing // `utils/stdioHelpers.ts` or a core helper on a review path and skipping the // rebuild produces no warning. The line drawn here is the code // whose behaviour a review is about; a quiet run means that code matches the @@ -361,14 +362,11 @@ export function reviewSourceRoots(repoRoot: string): ReviewSourceRoot[] { ), kind: 'code', }, - // The findings validator and its two helpers were lifted out of - // `commands/review/` into `utils/`; the digest covered them there, and a - // root list that lost them would keep both digest copies equal while a - // skipped rebuild silently runs the bundle's old validator. - { - path: join(repoRoot, 'packages', 'cli', 'src', 'utils', 'findings.ts'), - kind: 'code', - }, + // The two helpers of the findings validator live in `utils/`, outside + // the `review/` directory root; a root list that lost them would keep + // both digest copies equal while a skipped rebuild silently runs the + // bundle's old validator. The validator itself moved back into + // `commands/review/` (#9146), which the directory root covers. { path: join(repoRoot, 'packages', 'cli', 'src', 'utils', 'shell-args.ts'), kind: 'code', diff --git a/packages/cli/src/commands/review/lib/transcripts.test.ts b/packages/cli/src/commands/review/lib/transcripts.test.ts index eba2659b26..37ff810a78 100644 --- a/packages/cli/src/commands/review/lib/transcripts.test.ts +++ b/packages/cli/src/commands/review/lib/transcripts.test.ts @@ -827,7 +827,7 @@ describe('serializedArgsNamePath — the one needle both halves use', () => { it('does not credit a shell command that merely mentions the path', () => { // The divergence the review measured between this and the prose-boundary - // `namesPath` in `utils/findings.ts`, which returns true here. Both the + // `namesPath` in `findings.ts`, which returns true here. Both the // diff-read half and the brief atoms route through THIS one, so the // certification bar cannot credit `rm ` as opening it. expect( diff --git a/packages/cli/src/commands/review/lib/transcripts.ts b/packages/cli/src/commands/review/lib/transcripts.ts index 9448c99eb3..10acc4d6ab 100644 --- a/packages/cli/src/commands/review/lib/transcripts.ts +++ b/packages/cli/src/commands/review/lib/transcripts.ts @@ -264,7 +264,7 @@ function rangeOf(args: Record): [number, number] | null { * (normalisation, escaping, a stricter compare) reaches the whole bar at once * rather than half of it. * - * NOT `namesPath` in `utils/findings.ts`: that one matches a path mentioned in + * NOT `namesPath` in `findings.ts`: that one matches a path mentioned in * PROSE on a name boundary, so it credits `rm /plan/chunk-3.brief.md` for * naming the brief. Crediting an agent for deleting a file it never opened is * precisely what this predicate must not do, which is why the two keep diff --git a/packages/cli/src/commands/review/publish-assets.ts b/packages/cli/src/commands/review/publish-assets.ts index 0169cba6ab..31c43628ad 100644 --- a/packages/cli/src/commands/review/publish-assets.ts +++ b/packages/cli/src/commands/review/publish-assets.ts @@ -51,11 +51,7 @@ import { type AssetsManifest, type PublishedAsset, } from './lib/assets.js'; -import { - validateFindings, - buildReport, - type Finding, -} from '../../utils/findings.js'; +import { validateFindings, buildReport, type Finding } from './findings.js'; interface PublishAssetsArgs { pr: number; diff --git a/packages/cli/src/commands/review/save-artifact.test.ts b/packages/cli/src/commands/review/save-artifact.test.ts index f0fea9c6d0..326c911265 100644 --- a/packages/cli/src/commands/review/save-artifact.test.ts +++ b/packages/cli/src/commands/review/save-artifact.test.ts @@ -20,7 +20,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import yargs from 'yargs'; import type { Argv } from 'yargs'; -import { buildReport, type Finding } from '../../utils/findings.js'; +import { buildReport, type Finding } from './findings.js'; import { saveArtifactCommand, saveReviewArtifact } from './save-artifact.js'; // On a case-sensitive filesystem the alias below never exists, so that test diff --git a/packages/cli/src/commands/review/save-artifact.ts b/packages/cli/src/commands/review/save-artifact.ts index 43aa304a13..29cd5a9884 100644 --- a/packages/cli/src/commands/review/save-artifact.ts +++ b/packages/cli/src/commands/review/save-artifact.ts @@ -28,7 +28,7 @@ import { buildReport, type FindingsReport, validateFindings, -} from '../../utils/findings.js'; +} from './findings.js'; import { EFFORT_LEVELS, type ReviewEffort } from './parse-args.js'; import { REVIEWS_DIR } from './lib/paths.js'; import { isSameFile } from './lib/same-file.js'; diff --git a/packages/cli/src/commands/update.test.ts b/packages/cli/src/commands/update.test.ts index 5ad88cec93..23528c5bd8 100644 --- a/packages/cli/src/commands/update.test.ts +++ b/packages/cli/src/commands/update.test.ts @@ -52,7 +52,7 @@ vi.mock('../utils/installationInfo.js', () => ({ getInstallationInfo, resolveUpdateCommand, })); -vi.mock('../utils/standalone-update.js', () => ({ performStandaloneUpdate })); +vi.mock('../ui/standalone-update.js', () => ({ performStandaloneUpdate })); vi.mock('../utils/package.js', () => ({ getPackageJson })); vi.mock('../utils/stdioHelpers.js', () => ({ writeStdoutLine, diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 2e81513fac..ef5d2eea18 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -24,7 +24,7 @@ export const updateCommand: CommandModule = { import('../config/settings.js'), import('../ui/utils/updateCheck.js'), import('../utils/installationInfo.js'), - import('../utils/standalone-update.js'), + import('../ui/standalone-update.js'), import('../utils/stdioHelpers.js'), import('../utils/updateEventEmitter.js'), ]); diff --git a/packages/cli/src/utils/dialogScopeUtils.ts b/packages/cli/src/config/dialogScopeUtils.ts similarity index 94% rename from packages/cli/src/utils/dialogScopeUtils.ts rename to packages/cli/src/config/dialogScopeUtils.ts index 027928abcd..4c8f7cc07e 100644 --- a/packages/cli/src/utils/dialogScopeUtils.ts +++ b/packages/cli/src/config/dialogScopeUtils.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { LoadedSettings } from '../config/settings.js'; -import { SettingScope } from '../config/settings.js'; +import type { LoadedSettings } from './settings.js'; +import { SettingScope } from './settings.js'; import { settingExistsInScope } from './settingsUtils.js'; /** diff --git a/packages/cli/src/config/loadedSettingsAdapter.test.ts b/packages/cli/src/config/loadedSettingsAdapter.test.ts index b4445cab84..20f48f035e 100644 --- a/packages/cli/src/config/loadedSettingsAdapter.test.ts +++ b/packages/cli/src/config/loadedSettingsAdapter.test.ts @@ -10,9 +10,8 @@ import { SettingScope } from './settings.js'; // settingsUtils makes real fs calls in backup/restore — stub them out so the // tests can focus on adapter behavior without touching disk. -vi.mock('../utils/settingsUtils.js', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('./settingsUtils.js', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, backupSettingsFile: vi.fn(), diff --git a/packages/cli/src/config/loadedSettingsAdapter.ts b/packages/cli/src/config/loadedSettingsAdapter.ts index 49f93fa5a1..4644ad9cac 100644 --- a/packages/cli/src/config/loadedSettingsAdapter.ts +++ b/packages/cli/src/config/loadedSettingsAdapter.ts @@ -22,7 +22,7 @@ import { cleanupSettingsBackup, restoreSettingsFromBackup, getNestedProperty, -} from '../utils/settingsUtils.js'; +} from './settingsUtils.js'; export function createLoadedSettingsAdapter( settings: LoadedSettings, diff --git a/packages/cli/src/config/migration/versions/v1-to-v2.ts b/packages/cli/src/config/migration/versions/v1-to-v2.ts index 4dceffe444..e977d1a9d0 100644 --- a/packages/cli/src/config/migration/versions/v1-to-v2.ts +++ b/packages/cli/src/config/migration/versions/v1-to-v2.ts @@ -12,7 +12,7 @@ import { V1_TO_V2_PRESERVE_DISABLE_MAP, V2_CONTAINER_KEYS, } from './v1-to-v2-shared.js'; -import { setNestedPropertySafe } from '../../../utils/settingsUtils.js'; +import { setNestedPropertySafe } from '../../settingsUtils.js'; /** * Heuristic indicators for deciding whether an object is "V1-like". diff --git a/packages/cli/src/config/migration/versions/v2-to-v3.ts b/packages/cli/src/config/migration/versions/v2-to-v3.ts index 6c01334431..9825cb7d4d 100644 --- a/packages/cli/src/config/migration/versions/v2-to-v3.ts +++ b/packages/cli/src/config/migration/versions/v2-to-v3.ts @@ -9,7 +9,7 @@ import { deleteNestedPropertySafe, getNestedProperty, setNestedPropertySafe, -} from '../../../utils/settingsUtils.js'; +} from '../../settingsUtils.js'; /** * Path mapping for boolean polarity migration (V2 disable* -> V3 enable*). diff --git a/packages/cli/src/config/migration/versions/v3-to-v4.ts b/packages/cli/src/config/migration/versions/v3-to-v4.ts index 786cb17396..c328198880 100644 --- a/packages/cli/src/config/migration/versions/v3-to-v4.ts +++ b/packages/cli/src/config/migration/versions/v3-to-v4.ts @@ -8,7 +8,7 @@ import type { SettingsMigration } from '../types.js'; import { getNestedProperty, setNestedPropertySafe, -} from '../../../utils/settingsUtils.js'; +} from '../../settingsUtils.js'; const GIT_CO_AUTHOR_PATH = 'general.gitCoAuthor'; diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 466d8819cc..e8b54c013f 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -74,7 +74,7 @@ import { import { WORKSPACE_RESTRICTED_SETTINGS, WORKSPACE_RESTRICTED_SETTING_KEYS, -} from '../utils/settingsUtils.js'; +} from './settingsUtils.js'; import { needsMigration } from './migration/index.js'; import { QWEN_DIR } from '@qwen-code/qwen-code-core'; diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 3ecf75b03f..87d55dabdf 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -25,7 +25,6 @@ import { hasOwnModelProviders } from './modelProvidersScope.js'; import { type Settings, type MemoryImportFormat, - type MergeStrategy, type SettingsSchema, type SettingDefinition, getSettingsSchema, @@ -34,8 +33,8 @@ import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { setNestedPropertySafe, WORKSPACE_RESTRICTED_SETTINGS, -} from '../utils/settingsUtils.js'; -import { customDeepMerge } from '../utils/deepMerge.js'; +} from './settingsUtils.js'; +import { customDeepMerge, type MergeStrategy } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/jsonc-editor.js'; import { runMigrations, needsMigration } from './migration/index.js'; import { diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 788a3513e0..47edd33671 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -12,11 +12,11 @@ import { } from '@qwen-code/qwen-code-core'; import { getSettingsSchema, - MergeStrategy, type SettingDefinition, type Settings, type SettingsSchema, } from './settingsSchema.js'; +import { MergeStrategy } from '../utils/deepMerge.js'; import { MAX_CONCURRENT_SUB_SESSIONS_PER_CALLER, MAX_CONCURRENT_SUB_SESSIONS_TOTAL, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 108ada587f..11a35a6fde 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -29,6 +29,7 @@ import { } from '@qwen-code/qwen-code-core'; import type { CustomTheme } from '../ui/themes/theme.js'; import { getLanguageSettingsOptions } from '../i18n/languages.js'; +import { MergeStrategy } from '../utils/deepMerge.js'; export const DEFAULT_OPENAI_LOG_RETENTION_DAYS = 7; @@ -64,17 +65,6 @@ export interface SettingEnumOption { label: string; } -export enum MergeStrategy { - // Replace the old value with the new value. This is the default. - REPLACE = 'replace', - // Concatenate arrays. - CONCAT = 'concat', - // Merge arrays, ensuring unique values. - UNION = 'union', - // Shallow merge objects. - SHALLOW_MERGE = 'shallow_merge', -} - export interface SettingDefinition { type: SettingsType; label: string; diff --git a/packages/cli/src/utils/settingsUtils.test.ts b/packages/cli/src/config/settingsUtils.test.ts similarity index 99% rename from packages/cli/src/utils/settingsUtils.test.ts rename to packages/cli/src/config/settingsUtils.test.ts index b54125446d..afff80b544 100644 --- a/packages/cli/src/utils/settingsUtils.test.ts +++ b/packages/cli/src/config/settingsUtils.test.ts @@ -32,11 +32,10 @@ import { type Settings, type SettingsSchema, type SettingsSchemaType, -} from '../config/settingsSchema.js'; +} from './settingsSchema.js'; -vi.mock('../config/settingsSchema.js', async (importOriginal) => { - const original = - await importOriginal(); +vi.mock('./settingsSchema.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, getSettingsSchema: vi.fn(), diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/config/settingsUtils.ts similarity index 98% rename from packages/cli/src/utils/settingsUtils.ts rename to packages/cli/src/config/settingsUtils.ts index 5fa29e9c04..27bc9ad69f 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/config/settingsUtils.ts @@ -5,19 +5,15 @@ */ import * as fs from 'node:fs'; -import type { - Settings, - SettingScope, - LoadedSettings, -} from '../config/settings.js'; +import type { Settings, SettingScope, LoadedSettings } from './settings.js'; import type { SettingDefinition, SettingsSchema, SettingsValue, -} from '../config/settingsSchema.js'; -import { getSettingsSchema } from '../config/settingsSchema.js'; +} from './settingsSchema.js'; +import { getSettingsSchema } from './settingsSchema.js'; import { t } from '../i18n/index.js'; -import { isAutoLanguage } from './languageUtils.js'; +import { isAutoLanguage } from '../i18n/languageUtils.js'; // The schema is now nested, but many parts of the UI and logic work better // with a flattened structure and dot-notation keys. This section flattens the diff --git a/packages/cli/src/config/settingsWatcher.ts b/packages/cli/src/config/settingsWatcher.ts index 6a96948e58..2ff653326b 100644 --- a/packages/cli/src/config/settingsWatcher.ts +++ b/packages/cli/src/config/settingsWatcher.ts @@ -9,7 +9,7 @@ import * as path from 'node:path'; import { watch as watchFs, type FSWatcher } from 'chokidar'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { type LoadedSettings, SettingScope } from './settings.js'; -import { getFlattenedSchema } from '../utils/settingsUtils.js'; +import { getFlattenedSchema } from './settingsUtils.js'; const debugLogger = createDebugLogger('SETTINGS_WATCHER'); diff --git a/packages/cli/src/dualOutput/DualOutputBridge.ts b/packages/cli/src/dualOutput/DualOutputBridge.ts index a1316771e7..e3f3452161 100644 --- a/packages/cli/src/dualOutput/DualOutputBridge.ts +++ b/packages/cli/src/dualOutput/DualOutputBridge.ts @@ -21,7 +21,7 @@ import type { PermissionSuggestion } from '../nonInteractive/types.js'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; import { StreamJsonOutputAdapter } from '../nonInteractive/io/index.js'; -import { reportChatRecordingFailureToAdapter } from '../utils/chat-recording-failure.js'; +import { reportChatRecordingFailureToAdapter } from '../nonInteractive/chat-recording-failure.js'; const debugLogger = createDebugLogger('DUAL_OUTPUT'); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 7310cc60fe..583ba0715b 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -169,7 +169,7 @@ vi.mock('./utils/events.js', async (importOriginal) => { }; }); -vi.mock('./utils/sandbox.js', () => ({ +vi.mock('./serve/sandbox.js', () => ({ sandbox_command: vi.fn(() => ''), // Default to no sandbox command start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves })); @@ -206,7 +206,7 @@ vi.mock('./startup/startup-prefetch.js', () => ({ mockStartPostRenderPrefetches(...args), })); -vi.mock('./utils/update-relaunch.js', () => ({ +vi.mock('./ui/update-relaunch.js', () => ({ updateBeforeRelaunch: (...args: unknown[]) => mockUpdateBeforeRelaunch(...args), })); @@ -219,9 +219,11 @@ vi.mock('./acp-integration/acpAgent.js', () => ({ runAcpAgent: (...args: unknown[]) => mockRunAcpAgent(...args), })); -vi.mock('./utils/housekeeping/scheduler.js', async (importOriginal) => { +vi.mock('./services/housekeeping/scheduler.js', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal< + typeof import('./services/housekeeping/scheduler.js') + >(); return { ...actual, startNonInteractiveOpenAILogHousekeeping: (...args: unknown[]) => @@ -1232,7 +1234,7 @@ describe('gemini.tsx main function', () => { ); const { loadSettings } = await import('./config/settings.js'); const { loadSandboxConfig } = await import('./config/sandboxConfig.js'); - const { start_sandbox } = await import('./utils/sandbox.js'); + const { start_sandbox } = await import('./serve/sandbox.js'); const { relaunchOnExitCode } = await import('./utils/relaunch.js'); vi.mocked(start_sandbox).mockClear(); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 72409fdd80..d020437092 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -87,14 +87,14 @@ import { relaunchAppInChildProcess, relaunchOnExitCode, } from './utils/relaunch.js'; -import { start_sandbox } from './utils/sandbox.js'; +import { start_sandbox } from './serve/sandbox.js'; import { getStartupWarnings } from './utils/startupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { initializeWarningHandler } from './utils/warningHandler.js'; import { writeStderrLine, writeStderrLineSafe } from './utils/stdioHelpers.js'; import { sanitizeTerminalText } from './ui/utils/textUtils.js'; import { getHeadlessYoloSafetyWarning } from './utils/headlessSafetyWarnings.js'; -import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; +import { initializeLlmOutputLanguage } from './i18n/languageUtils.js'; import { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, @@ -518,9 +518,7 @@ export async function main() { await initializeI18n( resolveLanguageSetting(settings.merged.general?.language as string), ); - const { updateBeforeRelaunch } = await import( - './utils/update-relaunch.js' - ); + const { updateBeforeRelaunch } = await import('./ui/update-relaunch.js'); const shouldRelaunch = await updateBeforeRelaunch( settings, updateProjectRoot, @@ -873,7 +871,7 @@ export async function main() { const nonInteractiveHousekeeping = !config.isInteractive() || config.getExperimentalZedIntegration() - ? await import('./utils/housekeeping/scheduler.js') + ? await import('./services/housekeeping/scheduler.js') : undefined; if (nonInteractiveHousekeeping) { registerCleanup(() => diff --git a/packages/cli/src/utils/languageUtils.test.ts b/packages/cli/src/i18n/languageUtils.test.ts similarity index 99% rename from packages/cli/src/utils/languageUtils.test.ts rename to packages/cli/src/i18n/languageUtils.test.ts index ce357034d6..bfa9ae8aa3 100644 --- a/packages/cli/src/utils/languageUtils.test.ts +++ b/packages/cli/src/i18n/languageUtils.test.ts @@ -17,7 +17,7 @@ vi.mock('node:fs', () => ({ })); // Mock i18n module -vi.mock('../i18n/index.js', () => ({ +vi.mock('./index.js', () => ({ detectSystemLanguage: vi.fn(), getLanguageNameFromLocale: vi.fn((locale: string) => { const map: Record = { @@ -42,7 +42,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ }, })); -import * as i18n from '../i18n/index.js'; +import * as i18n from './index.js'; import { OUTPUT_LANGUAGE_AUTO, isAutoLanguage, diff --git a/packages/cli/src/utils/languageUtils.ts b/packages/cli/src/i18n/languageUtils.ts similarity index 98% rename from packages/cli/src/utils/languageUtils.ts rename to packages/cli/src/i18n/languageUtils.ts index 57c8a97dd9..ab1b859e7f 100644 --- a/packages/cli/src/utils/languageUtils.ts +++ b/packages/cli/src/i18n/languageUtils.ts @@ -13,8 +13,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { Storage } from '@qwen-code/qwen-code-core'; -import { getLanguageNameFromLocale } from '../i18n/index.js'; -import { SUPPORTED_LANGUAGES } from '../i18n/languages.js'; +import { getLanguageNameFromLocale } from './index.js'; +import { SUPPORTED_LANGUAGES } from './languages.js'; const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md'; const LLM_OUTPUT_LANGUAGE_MARKER_PREFIX = 'qwen-code:llm-output-language:'; diff --git a/packages/cli/src/utils/chat-recording-failure.test.ts b/packages/cli/src/nonInteractive/chat-recording-failure.test.ts similarity index 96% rename from packages/cli/src/utils/chat-recording-failure.test.ts rename to packages/cli/src/nonInteractive/chat-recording-failure.test.ts index eaaaab45fd..d3c184b8aa 100644 --- a/packages/cli/src/utils/chat-recording-failure.test.ts +++ b/packages/cli/src/nonInteractive/chat-recording-failure.test.ts @@ -11,7 +11,7 @@ import { type ChatRecordingFailureListener, type Config, } from '@qwen-code/qwen-code-core'; -import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js'; +import type { JsonOutputAdapterInterface } from './io/BaseJsonOutputAdapter.js'; import { createChatRecordingFailureSystemMessage, settleChatRecording, @@ -22,7 +22,7 @@ const { mockWriteStderrLine } = vi.hoisted(() => ({ mockWriteStderrLine: vi.fn(), })); -vi.mock('./stdioHelpers.js', () => ({ +vi.mock('../utils/stdioHelpers.js', () => ({ writeStderrLine: mockWriteStderrLine, })); diff --git a/packages/cli/src/utils/chat-recording-failure.ts b/packages/cli/src/nonInteractive/chat-recording-failure.ts similarity index 92% rename from packages/cli/src/utils/chat-recording-failure.ts rename to packages/cli/src/nonInteractive/chat-recording-failure.ts index 1bd860d088..2ba95a906c 100644 --- a/packages/cli/src/utils/chat-recording-failure.ts +++ b/packages/cli/src/nonInteractive/chat-recording-failure.ts @@ -11,10 +11,10 @@ import { type ChatRecordingFailureEvent, type Config, } from '@qwen-code/qwen-code-core'; -import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js'; -import type { CLISystemMessage } from '../nonInteractive/types.js'; +import type { JsonOutputAdapterInterface } from './io/BaseJsonOutputAdapter.js'; +import type { CLISystemMessage } from './types.js'; import { t } from '../i18n/index.js'; -import { writeStderrLine } from './stdioHelpers.js'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; export const CHAT_RECORDING_FAILURE_MESSAGE = 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then start a new session to resume recording. See the debug log for details.'; diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index 7f42f23401..378c582b3a 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -36,7 +36,7 @@ import type { PermissionSuggestion, } from '../../types.js'; import { BaseController } from './baseController.js'; -import { buildPermissionSuggestions } from '../../../utils/permission-suggestions.js'; +import { buildPermissionSuggestions } from '../../permission-suggestions.js'; const DEFAULT_CAN_USE_TOOL_TIMEOUT_MS = 60_000; diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index bb0f6cf0de..f9957738d5 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -42,9 +42,9 @@ import type { ToolUseBlock, Usage, } from '../types.js'; -import { functionResponsePartsToString } from '../../utils/nonInteractiveHelpers.js'; +import { functionResponsePartsToString } from '../nonInteractiveHelpers.js'; import { projectHeadlessToolResultContent } from './headless-tool-result-text-projection.js'; -import { observeHeadlessToolResultProjection } from '../../utils/tool-result-boundary-diagnostics.js'; +import { observeHeadlessToolResultProjection } from '../tool-result-boundary-diagnostics.js'; /** * Internal state for managing a single message context (main agent or subagent). diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts index d4fba881cd..f8ccb94ae7 100644 --- a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts @@ -11,7 +11,7 @@ import { type JsonOutputAdapterInterface, type ResultOptions, } from './BaseJsonOutputAdapter.js'; -import { observeHeadlessJsonToolResultWire } from '../../utils/tool-result-boundary-diagnostics.js'; +import { observeHeadlessJsonToolResultWire } from '../tool-result-boundary-diagnostics.js'; /** * JSON output adapter that collects all messages and emits them diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts index aec99fd80e..53ae034369 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts @@ -29,7 +29,7 @@ import { type ResultOptions, type JsonOutputAdapterInterface, } from './BaseJsonOutputAdapter.js'; -import { observeHeadlessToolResultWire } from '../../utils/tool-result-boundary-diagnostics.js'; +import { observeHeadlessToolResultWire } from '../tool-result-boundary-diagnostics.js'; /** * Stream JSON output adapter that emits messages immediately diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/nonInteractive/nonInteractiveHelpers.test.ts similarity index 95% rename from packages/cli/src/utils/nonInteractiveHelpers.test.ts rename to packages/cli/src/nonInteractive/nonInteractiveHelpers.test.ts index ee894ad37e..c5a0ad4f6a 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.test.ts +++ b/packages/cli/src/nonInteractive/nonInteractiveHelpers.test.ts @@ -17,13 +17,9 @@ import { OutputFormat, } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; -import type { - CLIUserMessage, - PermissionMode, -} from '../nonInteractive/types.js'; -import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js'; +import type { CLIUserMessage, PermissionMode } from './types.js'; +import type { JsonOutputAdapterInterface } from './io/BaseJsonOutputAdapter.js'; import { - normalizePartList, extractPartsFromUserMessage, computeUsageFromMetrics, buildSystemMessage, @@ -89,42 +85,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }; }); -describe('normalizePartList', () => { - it('should return empty array for null input', () => { - expect(normalizePartList(null)).toEqual([]); - }); - - it('should return empty array for undefined input', () => { - expect(normalizePartList(undefined as unknown as null)).toEqual([]); - }); - - it('should convert string to Part array', () => { - const result = normalizePartList('test string'); - expect(result).toEqual([{ text: 'test string' }]); - }); - - it('should convert array of strings to Part array', () => { - const result = normalizePartList(['hello', 'world']); - expect(result).toEqual([{ text: 'hello' }, { text: 'world' }]); - }); - - it('should convert array of mixed strings and Parts to Part array', () => { - const part: Part = { text: 'existing' }; - const result = normalizePartList(['new', part]); - expect(result).toEqual([{ text: 'new' }, part]); - }); - - it('should convert single Part object to array', () => { - const part: Part = { text: 'single part' }; - const result = normalizePartList(part); - expect(result).toEqual([part]); - }); - - it('should handle empty array', () => { - expect(normalizePartList([])).toEqual([]); - }); -}); - describe('extractPartsFromUserMessage', () => { it('should return null for undefined message', () => { expect(extractPartsFromUserMessage(undefined)).toBeNull(); diff --git a/packages/cli/src/utils/nonInteractiveHelpers.ts b/packages/cli/src/nonInteractive/nonInteractiveHelpers.ts similarity index 96% rename from packages/cli/src/utils/nonInteractiveHelpers.ts rename to packages/cli/src/nonInteractive/nonInteractiveHelpers.ts index c35828b389..f39d3c2de1 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.ts +++ b/packages/cli/src/nonInteractive/nonInteractiveHelpers.ts @@ -30,40 +30,16 @@ import type { Usage, PermissionMode, CLISystemMessage, -} from '../nonInteractive/types.js'; +} from './types.js'; import type { JsonOutputAdapterInterface, MessageEmitter, -} from '../nonInteractive/io/BaseJsonOutputAdapter.js'; +} from './io/BaseJsonOutputAdapter.js'; import { computeSessionStats } from '../ui/utils/computeStats.js'; import { getAvailableCommands } from '../nonInteractiveCliCommands.js'; const debugLogger = createDebugLogger('NON_INTERACTIVE'); -/** - * Normalizes various part list formats into a consistent Part[] array. - * - * @param parts - Input parts in various formats (string, Part, Part[], or null) - * @returns Normalized array of Part objects - */ -export function normalizePartList(parts: PartListUnion | null): Part[] { - if (!parts) { - return []; - } - - if (typeof parts === 'string') { - return [{ text: parts }]; - } - - if (Array.isArray(parts)) { - return parts.map((part) => - typeof part === 'string' ? { text: part } : (part as Part), - ); - } - - return [parts as Part]; -} - /** * Extracts user message parts from a CLI protocol message. * diff --git a/packages/cli/src/utils/permission-suggestions.ts b/packages/cli/src/nonInteractive/permission-suggestions.ts similarity index 97% rename from packages/cli/src/utils/permission-suggestions.ts rename to packages/cli/src/nonInteractive/permission-suggestions.ts index 4010ebb86e..820c7f73cd 100644 --- a/packages/cli/src/utils/permission-suggestions.ts +++ b/packages/cli/src/nonInteractive/permission-suggestions.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { PermissionSuggestion } from '../nonInteractive/types.js'; +import type { PermissionSuggestion } from './types.js'; function withWarnings( description: string, diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index 357c50276b..192bb3fe75 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -49,7 +49,7 @@ import { import { settleChatRecording, subscribeToHeadlessChatRecordingFailures, -} from '../utils/chat-recording-failure.js'; +} from './chat-recording-failure.js'; const debugLogger = createDebugLogger('NON_INTERACTIVE_SESSION'); diff --git a/packages/cli/src/utils/tool-result-boundary-diagnostics.test.ts b/packages/cli/src/nonInteractive/tool-result-boundary-diagnostics.test.ts similarity index 99% rename from packages/cli/src/utils/tool-result-boundary-diagnostics.test.ts rename to packages/cli/src/nonInteractive/tool-result-boundary-diagnostics.test.ts index b0afc0f8fc..bad3bbbd40 100644 --- a/packages/cli/src/utils/tool-result-boundary-diagnostics.test.ts +++ b/packages/cli/src/nonInteractive/tool-result-boundary-diagnostics.test.ts @@ -8,7 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { SessionUpdate } from '@agentclientprotocol/sdk'; import { LOAD_REPLAY_META_KEY } from '@qwen-code/acp-bridge/bridgeTypes'; import type { ToolResultBoundaryObservation } from '@qwen-code/qwen-code-core'; -import type { CLIUserMessage } from '../nonInteractive/types.js'; +import type { CLIUserMessage } from './types.js'; const { mockObserveBoundary } = vi.hoisted(() => ({ mockObserveBoundary: vi.fn( diff --git a/packages/cli/src/utils/tool-result-boundary-diagnostics.ts b/packages/cli/src/nonInteractive/tool-result-boundary-diagnostics.ts similarity index 99% rename from packages/cli/src/utils/tool-result-boundary-diagnostics.ts rename to packages/cli/src/nonInteractive/tool-result-boundary-diagnostics.ts index 71a4255836..c49e668b46 100644 --- a/packages/cli/src/utils/tool-result-boundary-diagnostics.ts +++ b/packages/cli/src/nonInteractive/tool-result-boundary-diagnostics.ts @@ -11,7 +11,7 @@ import { type ToolResultBoundaryArtifact, type ToolResultBoundaryValue, } from '@qwen-code/qwen-code-core'; -import type { CLIMessage, ToolResultBlock } from '../nonInteractive/types.js'; +import type { CLIMessage, ToolResultBlock } from './types.js'; interface ProjectedToolResult { mutated: boolean; diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 6dc99ef43b..a3b32f6440 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -94,7 +94,7 @@ import { RunBudgetEnforcer } from './utils/runBudget.js'; import { settleChatRecording, subscribeToHeadlessChatRecordingFailures, -} from './utils/chat-recording-failure.js'; +} from './nonInteractive/chat-recording-failure.js'; import { registerCleanup } from './utils/cleanup.js'; import { cleanupReviewWorktreeLeases } from './services/review-worktree-lease.js'; @@ -151,8 +151,8 @@ function suppressedOutputBody(structuredCaptured: boolean): string { : SUPPRESSED_OUTPUT_RETRY; } +import { normalizePartList } from './utils/normalize-part-list.js'; import { - normalizePartList, extractPartsFromUserMessage, buildSystemMessage, createToolProgressHandler, @@ -160,7 +160,7 @@ import { computeUsageFromMetrics, buildInitialSystemReminders, insertAfterFunctionResponses, -} from './utils/nonInteractiveHelpers.js'; +} from './nonInteractive/nonInteractiveHelpers.js'; // Human-readable labels for the detectors that can fire mid-stream. // Surfaced to stderr in TEXT mode so a headless run that halts on a loop diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 67a94eff76..e465b77c97 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -8,7 +8,7 @@ import type { PartListUnion } from '@google/genai'; import { parseSlashCommand, parseStackedSlashCommands, -} from './utils/commands.js'; +} from './ui/commands/commands.js'; import { Logger, uiTelemetryService, diff --git a/packages/cli/src/serve/daemon-status-provider.ts b/packages/cli/src/serve/daemon-status-provider.ts index b326ffaed3..b56704036a 100644 --- a/packages/cli/src/serve/daemon-status-provider.ts +++ b/packages/cli/src/serve/daemon-status-provider.ts @@ -26,7 +26,7 @@ import { type ServePreflightKind, type ServeWorkspaceEnvStatus, } from '@qwen-code/acp-bridge'; -import { getGitVersion, getNpmVersion } from '../utils/systemInfo.js'; +import { getGitVersion, getNpmVersion } from '../ui/systemInfo.js'; import { buildEnvStatusFromEnv, snapshotProcessEnv } from './env-snapshot.js'; const REQUIRED_NODE_MAJOR = 22; diff --git a/packages/cli/src/serve/process-env-guard.test.ts b/packages/cli/src/serve/process-env-guard.test.ts index dcc7eedfad..ea499b8fe6 100644 --- a/packages/cli/src/serve/process-env-guard.test.ts +++ b/packages/cli/src/serve/process-env-guard.test.ts @@ -164,6 +164,60 @@ const allowedProcessEnvAccesses = normalizeAllowances([ }, }, ], + [ + 'packages/cli/src/serve/sandbox.ts', + { + reason: + 'The sandbox launcher assembles the sandboxed child environment: ' + + 'it passes through the process environment, forwards provider keys, ' + + 'proxy settings, and debug switches, and reads the SANDBOX_* control ' + + 'variables. It entered the scanned serve/ layer via the #9146 ' + + 'leaf-layer move; its access surface is unchanged.', + accesses: { + 'computed:envVar': 2, + 'key:BUILD_SANDBOX': 2, + 'key:COLORTERM': 2, + 'key:DEBUG': 5, + 'key:DEBUG_MODE': 1, + 'key:DEBUG_PORT': 2, + 'key:GEMINI_API_KEY': 2, + 'key:GEMINI_MODEL': 2, + 'key:GOOGLE_API_KEY': 2, + 'key:GOOGLE_APPLICATION_CREDENTIALS': 2, + 'key:GOOGLE_CLOUD_LOCATION': 2, + 'key:GOOGLE_CLOUD_PROJECT': 2, + 'key:GOOGLE_GENAI_USE_GCA': 2, + 'key:GOOGLE_GENAI_USE_VERTEXAI': 2, + 'key:HTTP_PROXY': 2, + 'key:HTTPS_PROXY': 2, + 'key:NO_PROXY': 2, + 'key:NODE_ENV': 1, + 'key:NODE_OPTIONS': 1, + 'key:OPENAI_API_KEY': 2, + 'key:OPENAI_BASE_URL': 2, + 'key:OPENAI_MODEL': 2, + 'key:PATH': 2, + 'key:PYTHONPATH': 2, + 'key:QWEN_CODE_INTEGRATION_TEST': 1, + 'key:QWEN_CODE_MCP_APPROVALS_PATH': 2, + 'key:QWEN_CODE_SCRUB_ELECTRON_RUN_AS_NODE': 1, + 'key:QWEN_CODE_TEST_VAR': 2, + 'key:QWEN_SANDBOX_PROXY_COMMAND': 2, + 'key:SANDBOX_ENV': 2, + 'key:SANDBOX_FLAGS': 2, + 'key:SANDBOX_MOUNTS': 2, + 'key:SANDBOX_PORTS': 1, + 'key:SANDBOX_SET_UID_GID': 1, + 'key:SEATBELT_PROFILE': 1, + 'key:TERM': 2, + 'key:VIRTUAL_ENV': 1, + 'key:http_proxy': 2, + 'key:https_proxy': 2, + 'key:no_proxy': 2, + whole: 6, + }, + }, + ], [ 'packages/cli/src/serve/server/fs-factory.ts', { diff --git a/packages/cli/src/serve/routes/workspace-models.ts b/packages/cli/src/serve/routes/workspace-models.ts index aa9b96a99d..f04e9a27fa 100644 --- a/packages/cli/src/serve/routes/workspace-models.ts +++ b/packages/cli/src/serve/routes/workspace-models.ts @@ -11,7 +11,7 @@ import { getOwnKeyScope, getWritableScopes, } from '../../config/modelProvidersScope.js'; -import { getSettingDefinition } from '../../utils/settingsUtils.js'; +import { getSettingDefinition } from '../../config/settingsUtils.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { isActiveModelSelection, diff --git a/packages/cli/src/serve/routes/workspace-settings.test.ts b/packages/cli/src/serve/routes/workspace-settings.test.ts index aac6da4fd5..48b10c4a73 100644 --- a/packages/cli/src/serve/routes/workspace-settings.test.ts +++ b/packages/cli/src/serve/routes/workspace-settings.test.ts @@ -85,10 +85,9 @@ function makeQualifiedApp() { safeBody: (req) => req.body && typeof req.body === 'object' ? req.body : {}, persistSetting, - workspaceRegistry: - registry as unknown as Parameters< - typeof registerWorkspaceQualifiedSettingsRoutes - >[1]['workspaceRegistry'], + workspaceRegistry: registry as unknown as Parameters< + typeof registerWorkspaceQualifiedSettingsRoutes + >[1]['workspaceRegistry'], invalidateServeFeaturesCache: () => {}, }); diff --git a/packages/cli/src/serve/routes/workspace-settings.ts b/packages/cli/src/serve/routes/workspace-settings.ts index 32bcb60043..096b5e6979 100644 --- a/packages/cli/src/serve/routes/workspace-settings.ts +++ b/packages/cli/src/serve/routes/workspace-settings.ts @@ -21,7 +21,7 @@ import { getSettingDefinition, validateSettingValue, WORKSPACE_RESTRICTED_SETTING_KEYS, -} from '../../utils/settingsUtils.js'; +} from '../../config/settingsUtils.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { parseAndValidateWorkspaceClientId } from '../server/request-helpers.js'; import { diff --git a/packages/cli/src/utils/sandbox-macos-permissive-closed.sb b/packages/cli/src/serve/sandbox-macos-permissive-closed.sb similarity index 100% rename from packages/cli/src/utils/sandbox-macos-permissive-closed.sb rename to packages/cli/src/serve/sandbox-macos-permissive-closed.sb diff --git a/packages/cli/src/utils/sandbox-macos-permissive-open.sb b/packages/cli/src/serve/sandbox-macos-permissive-open.sb similarity index 100% rename from packages/cli/src/utils/sandbox-macos-permissive-open.sb rename to packages/cli/src/serve/sandbox-macos-permissive-open.sb diff --git a/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb b/packages/cli/src/serve/sandbox-macos-permissive-proxied.sb similarity index 100% rename from packages/cli/src/utils/sandbox-macos-permissive-proxied.sb rename to packages/cli/src/serve/sandbox-macos-permissive-proxied.sb diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb b/packages/cli/src/serve/sandbox-macos-restrictive-closed.sb similarity index 100% rename from packages/cli/src/utils/sandbox-macos-restrictive-closed.sb rename to packages/cli/src/serve/sandbox-macos-restrictive-closed.sb diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-open.sb b/packages/cli/src/serve/sandbox-macos-restrictive-open.sb similarity index 100% rename from packages/cli/src/utils/sandbox-macos-restrictive-open.sb rename to packages/cli/src/serve/sandbox-macos-restrictive-open.sb diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb b/packages/cli/src/serve/sandbox-macos-restrictive-proxied.sb similarity index 100% rename from packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb rename to packages/cli/src/serve/sandbox-macos-restrictive-proxied.sb diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/serve/sandbox.test.ts similarity index 90% rename from packages/cli/src/utils/sandbox.test.ts rename to packages/cli/src/serve/sandbox.test.ts index 4116b8e990..ee606f5875 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/serve/sandbox.test.ts @@ -32,14 +32,15 @@ vi.mock('node:child_process', async (importOriginal) => { }; }); -import { isContainerPathWithinWorkdir } from './sandbox-path.js'; +import { isContainerPathWithinWorkdir } from '../utils/sandbox-path.js'; import { + BUILTIN_SEATBELT_PROFILES, getSandboxPassthroughEnvArgs, resolveSeatbeltProfileFile, start_sandbox, } from './sandbox.js'; -import { parseSandboxImageName } from './sandboxImageName.js'; -import { parseSandboxMountSpec } from './sandboxMounts.js'; +import { parseSandboxImageName } from '../utils/sandboxImageName.js'; +import { parseSandboxMountSpec } from '../utils/sandboxMounts.js'; afterEach(() => { vi.restoreAllMocks(); @@ -157,23 +158,37 @@ describe('resolveSeatbeltProfileFile', () => { }); it('keeps source-mode seatbelt profile paths next to the module', () => { - const utilsDir = path.resolve( + const serveDir = path.resolve( path.sep, 'repo', 'packages', 'cli', 'src', - 'utils', + 'serve', ); const sourceUrl = pathToFileURL( - path.join(utilsDir, 'sandbox.ts'), + path.join(serveDir, 'sandbox.ts'), ).toString(); expect(resolveSeatbeltProfileFile('restrictive-closed', sourceUrl)).toBe( - path.join(utilsDir, 'sandbox-macos-restrictive-closed.sb'), + path.join(serveDir, 'sandbox-macos-restrictive-closed.sb'), ); }); + it('keeps every builtin seatbelt profile colocated with the real module', () => { + // Uses the default `import.meta.url` (the real module location), so this + // fails loudly if sandbox.ts or the .sb profiles move without the other. + // Iterate the module's own list rather than a hand-copied snapshot, so a + // profile added to `BUILTIN_SEATBELT_PROFILES` without its `.sb` file + // fails here instead of on a `sandbox-exec` ENOENT at launch. The length + // guard keeps an emptied list from passing the loop vacuously. + expect(BUILTIN_SEATBELT_PROFILES.length).toBeGreaterThan(0); + for (const profile of BUILTIN_SEATBELT_PROFILES) { + const profileFile = resolveSeatbeltProfileFile(profile); + expect(fs.existsSync(profileFile), `missing ${profileFile}`).toBe(true); + } + }); + it('keeps custom seatbelt profiles under project settings', () => { const bundleDir = path.resolve(path.sep, 'tmp', 'qwen', 'lib'); const chunkUrl = pathToFileURL( diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/serve/sandbox.ts similarity index 98% rename from packages/cli/src/utils/sandbox.ts rename to packages/cli/src/serve/sandbox.ts index af91d8e255..1a343b002b 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/serve/sandbox.ts @@ -22,15 +22,15 @@ import { resolveBundleDir, } from '@qwen-code/qwen-code-core'; import { randomBytes } from 'node:crypto'; -import { writeStderrLine } from './stdioHelpers.js'; -import { parseSandboxImageName } from './sandboxImageName.js'; -import { isContainerPathWithinWorkdir } from './sandbox-path.js'; -import { parseSandboxMountSpec } from './sandboxMounts.js'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; +import { parseSandboxImageName } from '../utils/sandboxImageName.js'; +import { isContainerPathWithinWorkdir } from '../utils/sandbox-path.js'; +import { parseSandboxMountSpec } from '../utils/sandboxMounts.js'; import { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, SKIP_UPDATE_CHECK_ENV_VAR, -} from './processUtils.js'; +} from '../utils/processUtils.js'; import { QWEN_CODE_DESKTOP_ENV, QWEN_CODE_SERVE_ENV, @@ -61,7 +61,12 @@ function ensureDirectoryAndGetRealPath(dir: string): string { const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'qwen-code-sandbox'; const SANDBOX_NETWORK_NAME = 'qwen-code-sandbox'; const SANDBOX_PROXY_NAME = 'qwen-code-sandbox-proxy'; -const BUILTIN_SEATBELT_PROFILES = [ +/** + * Exported so the colocation tripwire in `sandbox.test.ts` can iterate every + * builtin profile by construction instead of pinning a hand-copied snapshot + * that silently stops at the list as written. + */ +export const BUILTIN_SEATBELT_PROFILES = [ 'permissive-open', 'permissive-closed', 'permissive-proxied', diff --git a/packages/cli/src/utils/housekeeping/non-interactive-scheduler.test.ts b/packages/cli/src/services/housekeeping/non-interactive-scheduler.test.ts similarity index 97% rename from packages/cli/src/utils/housekeeping/non-interactive-scheduler.test.ts rename to packages/cli/src/services/housekeeping/non-interactive-scheduler.test.ts index 57ede7a79b..1017dc4434 100644 --- a/packages/cli/src/utils/housekeeping/non-interactive-scheduler.test.ts +++ b/packages/cli/src/services/housekeeping/non-interactive-scheduler.test.ts @@ -16,15 +16,18 @@ const mocks = vi.hoisted(() => ({ runThrottledOnce: vi.fn(), })); -vi.mock('./cleanup.js', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../utils/housekeeping/cleanup.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../../utils/housekeeping/cleanup.js') + >(); return { ...actual, cleanupOldOpenAILogs: mocks.cleanupOldOpenAILogs, }; }); -vi.mock('./throttledOnce.js', () => ({ +vi.mock('../../utils/housekeeping/throttledOnce.js', () => ({ runThrottledOnce: mocks.runThrottledOnce, })); diff --git a/packages/cli/src/utils/housekeeping/scheduler.test.ts b/packages/cli/src/services/housekeeping/scheduler.test.ts similarity index 99% rename from packages/cli/src/utils/housekeeping/scheduler.test.ts rename to packages/cli/src/services/housekeeping/scheduler.test.ts index 8c95ff7b56..ca0fc1845b 100644 --- a/packages/cli/src/utils/housekeeping/scheduler.test.ts +++ b/packages/cli/src/services/housekeeping/scheduler.test.ts @@ -25,7 +25,7 @@ import { noteInteraction, _resetForTesting as resetInteraction, _setLastInteractionForTesting, -} from './lastInteractionAt.js'; +} from '../../utils/housekeeping/lastInteractionAt.js'; const MS_PER_HOUR = 60 * 60 * 1000; const MS_PER_DAY = 24 * MS_PER_HOUR; diff --git a/packages/cli/src/utils/housekeeping/scheduler.ts b/packages/cli/src/services/housekeeping/scheduler.ts similarity index 98% rename from packages/cli/src/utils/housekeeping/scheduler.ts rename to packages/cli/src/services/housekeeping/scheduler.ts index f9f8c9d4b5..c24c5d8849 100644 --- a/packages/cli/src/utils/housekeeping/scheduler.ts +++ b/packages/cli/src/services/housekeeping/scheduler.ts @@ -21,9 +21,9 @@ import { cleanupOldOpenAILogs, cleanupOldSubagentTranscripts, getCutoffDate, -} from './cleanup.js'; -import { runThrottledOnce } from './throttledOnce.js'; -import { msSinceLastInteraction } from './lastInteractionAt.js'; +} from '../../utils/housekeeping/cleanup.js'; +import { runThrottledOnce } from '../../utils/housekeeping/throttledOnce.js'; +import { msSinceLastInteraction } from '../../utils/housekeeping/lastInteractionAt.js'; const debugLogger = createDebugLogger('HOUSEKEEPING'); diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index 364dc3f4dd..f81474af09 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -64,7 +64,7 @@ vi.mock('../utils/processUtils.js', () => ({ requestUpdateOnExit: (...args: unknown[]) => mockRequestUpdateOnExit(...args), })); -vi.mock('../utils/handleAutoUpdate.js', () => ({ +vi.mock('../ui/handleAutoUpdate.js', () => ({ handleAutoUpdate: (...args: unknown[]) => mockHandleAutoUpdate(...args), })); @@ -92,7 +92,7 @@ vi.mock('../core/initializer.js', () => ({ mockConnectIdeForStartup(...args), })); -vi.mock('../utils/housekeeping/scheduler.js', () => ({ +vi.mock('../services/housekeeping/scheduler.js', () => ({ startBackgroundHousekeeping: (...args: unknown[]) => mockStartBackgroundHousekeeping(...args), })); diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index 7b17922c77..df2ad9a16d 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -166,7 +166,7 @@ export function startPostRenderPrefetches( { t }, ] = await Promise.all([ import('../ui/utils/updateCheck.js'), - import('../utils/handleAutoUpdate.js'), + import('../ui/handleAutoUpdate.js'), import('../utils/installationInfo.js'), import('../utils/updateEventEmitter.js'), import('../i18n/index.js'), @@ -273,7 +273,7 @@ export function startPostRenderPrefetches( if (config.isInteractive()) { runDeferredTask('background_housekeeping', async () => { const { startBackgroundHousekeeping } = await import( - '../utils/housekeeping/scheduler.js' + '../services/housekeeping/scheduler.js' ); startBackgroundHousekeeping(config, settings); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index b63154127e..1a1e46bf4b 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -184,7 +184,7 @@ vi.mock('../services/prompt-stash.js'); // Mock external utilities vi.mock('../utils/events.js'); -vi.mock('../utils/handleAutoUpdate.js'); +vi.mock('./handleAutoUpdate.js'); vi.mock('../utils/cleanup.js'); const mockLoadHierarchicalGeminiMemory = vi.hoisted(() => vi.fn()); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 20794af242..697ee82584 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -179,7 +179,7 @@ import { detectWorkflowKeyword, buildWorkflowSteeringNotice, } from './utils/workflow-keyword.js'; -import { parseSlashCommand } from '../utils/commands.js'; +import { parseSlashCommand } from './commands/commands.js'; import { type LoadedSettings, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; import { ExtensionRefreshState } from '../config/extension-refresh-state.js'; @@ -199,7 +199,7 @@ import { useCommandMigration } from './hooks/useCommandMigration.js'; import { migrateTomlCommands } from '../services/command-migration-tool.js'; import { sendNotification } from '../services/notificationService.js'; import { type UpdateObject } from './utils/updateCheck.js'; -import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; +import { setUpdateHandler } from './handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { useMessageQueue, @@ -231,8 +231,8 @@ import { } from './contexts/BackgroundTaskViewContext.js'; import { getLiveAgentPanelLayoutKey } from './components/background-view/liveAgentPanelVisibility.js'; import { t } from '../i18n/index.js'; -import { TUI_CHAT_RECORDING_FAILURE_MESSAGE } from '../utils/chat-recording-failure.js'; -import { buildPermissionSuggestions } from '../utils/permission-suggestions.js'; +import { TUI_CHAT_RECORDING_FAILURE_MESSAGE } from '../nonInteractive/chat-recording-failure.js'; +import { buildPermissionSuggestions } from '../nonInteractive/permission-suggestions.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; import { useInitializationAuthError } from './hooks/useInitializationAuthError.js'; diff --git a/packages/cli/src/ui/auth/useAuth.test.ts b/packages/cli/src/ui/auth/useAuth.test.ts index 6e01d19852..d515730c12 100644 --- a/packages/cli/src/ui/auth/useAuth.test.ts +++ b/packages/cli/src/ui/auth/useAuth.test.ts @@ -30,9 +30,9 @@ vi.mock('../hooks/useQwenAuth.js', () => ({ })), })); -vi.mock('../../utils/settingsUtils.js', async (importOriginal) => { +vi.mock('../../config/settingsUtils.js', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal(); return { ...actual, backupSettingsFile: vi.fn(), diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index fd2d581429..36169c6274 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -9,10 +9,10 @@ import { aboutCommand } from './aboutCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; -import * as systemInfoUtils from '../../utils/systemInfo.js'; +import * as systemInfoUtils from '../systemInfo.js'; import * as sessionPathsUtils from '../../utils/sessionPaths.js'; -vi.mock('../../utils/systemInfo.js'); +vi.mock('../systemInfo.js'); vi.mock('../../utils/sessionPaths.js'); describe('aboutCommand', () => { diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 118d4d29b6..292d367bd8 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -7,7 +7,7 @@ import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { MessageType, type HistoryItemAbout } from '../types.js'; -import { getExtendedSystemInfo } from '../../utils/systemInfo.js'; +import { getExtendedSystemInfo } from '../systemInfo.js'; import { t } from '../../i18n/index.js'; import { collectSessionPathInfo, diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index ce46464924..8ea0f6ca6d 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -9,7 +9,7 @@ import { bugCommand } from './bugCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { AuthType } from '@qwen-code/qwen-code-core'; -import * as systemInfoUtils from '../../utils/systemInfo.js'; +import * as systemInfoUtils from '../systemInfo.js'; const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn()); @@ -22,7 +22,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { openBrowserSecurely: mockOpenBrowserSecurely, }; }); -vi.mock('../../utils/systemInfo.js'); +vi.mock('../systemInfo.js'); describe('bugCommand', () => { beforeEach(() => { diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 8caacebf6f..ad776d4d89 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -11,8 +11,8 @@ import { } from './types.js'; import { openBrowserSecurely } from '@qwen-code/qwen-code-core'; import { MessageType, type HistoryItem } from '../types.js'; -import { getExtendedSystemInfo } from '../../utils/systemInfo.js'; -import { getSystemInfoFields } from '../../utils/systemInfoFields.js'; +import { getExtendedSystemInfo } from '../systemInfo.js'; +import { getSystemInfoFields } from '../systemInfoFields.js'; import { t } from '../../i18n/index.js'; export const bugCommand: SlashCommand = { diff --git a/packages/cli/src/utils/commands.test.ts b/packages/cli/src/ui/commands/commands.test.ts similarity index 99% rename from packages/cli/src/utils/commands.test.ts rename to packages/cli/src/ui/commands/commands.test.ts index d5f7e7ec27..81e00ebf65 100644 --- a/packages/cli/src/utils/commands.test.ts +++ b/packages/cli/src/ui/commands/commands.test.ts @@ -10,7 +10,7 @@ import { parseSlashCommand, parseStackedSlashCommands, } from './commands.js'; -import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; +import { CommandKind, type SlashCommand } from './types.js'; // Mock command structure for testing const mockCommands: readonly SlashCommand[] = [ diff --git a/packages/cli/src/utils/commands.ts b/packages/cli/src/ui/commands/commands.ts similarity index 98% rename from packages/cli/src/utils/commands.ts rename to packages/cli/src/ui/commands/commands.ts index 134e5af8de..3827943b37 100644 --- a/packages/cli/src/utils/commands.ts +++ b/packages/cli/src/ui/commands/commands.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; +import { CommandKind, type SlashCommand } from './types.js'; /** Maximum number of stacked skill commands that can be loaded in one prompt. */ export const MAX_STACKED_SKILLS = 5; diff --git a/packages/cli/src/ui/commands/config-command.ts b/packages/cli/src/ui/commands/config-command.ts index 2a1644ac7a..7b7a597fd5 100644 --- a/packages/cli/src/ui/commands/config-command.ts +++ b/packages/cli/src/ui/commands/config-command.ts @@ -19,7 +19,7 @@ import { getNestedProperty, getSettingDefinition, validateSettingValue, -} from '../../utils/settingsUtils.js'; +} from '../../config/settingsUtils.js'; const SETTABLE_TYPES = new Set(['boolean', 'string', 'number', 'enum']); diff --git a/packages/cli/src/utils/doctorChecks.test.ts b/packages/cli/src/ui/commands/doctorChecks.test.ts similarity index 97% rename from packages/cli/src/utils/doctorChecks.test.ts rename to packages/cli/src/ui/commands/doctorChecks.test.ts index 2bcc49de8e..0c41cc0ae8 100644 --- a/packages/cli/src/utils/doctorChecks.test.ts +++ b/packages/cli/src/ui/commands/doctorChecks.test.ts @@ -6,14 +6,14 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { runDoctorChecks } from './doctorChecks.js'; -import { type CommandContext } from '../ui/commands/types.js'; -import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; -import * as systemInfoUtils from './systemInfo.js'; -import * as authModule from '../config/auth.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import * as systemInfoUtils from '../systemInfo.js'; +import * as authModule from '../../config/auth.js'; import * as allProviders from '@qwen-code/qwen-code-core'; -vi.mock('./systemInfo.js'); -vi.mock('../config/auth.js'); +vi.mock('../systemInfo.js'); +vi.mock('../../config/auth.js'); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actual = (await importOriginal()) as typeof import('@qwen-code/qwen-code-core'); diff --git a/packages/cli/src/utils/doctorChecks.ts b/packages/cli/src/ui/commands/doctorChecks.ts similarity index 97% rename from packages/cli/src/utils/doctorChecks.ts rename to packages/cli/src/ui/commands/doctorChecks.ts index 28f58556b4..16aad5ea1f 100644 --- a/packages/cli/src/utils/doctorChecks.ts +++ b/packages/cli/src/ui/commands/doctorChecks.ts @@ -6,17 +6,17 @@ import process from 'node:process'; import os from 'node:os'; -import { getNpmVersion, getGitVersion } from './systemInfo.js'; -import { validateAuthMethod } from '../config/auth.js'; +import { getNpmVersion, getGitVersion } from '../systemInfo.js'; +import { validateAuthMethod } from '../../config/auth.js'; import { findProviderByCredentials, canUseRipgrep, getMCPServerStatus, MCPServerStatus, } from '@qwen-code/qwen-code-core'; -import type { CommandContext } from '../ui/commands/types.js'; -import type { DoctorCheckResult } from '../ui/types.js'; -import { t } from '../i18n/index.js'; +import type { CommandContext } from './types.js'; +import type { DoctorCheckResult } from '../types.js'; +import { t } from '../../i18n/index.js'; const MIN_NODE_MAJOR = 22; diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index 0c1ad143cb..21ee25e03e 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -8,14 +8,14 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { doctorCommand } from './doctorCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import * as doctorChecksModule from '../../utils/doctorChecks.js'; +import * as doctorChecksModule from './doctorChecks.js'; import * as memoryDiagnosticsModule from '../../utils/memoryDiagnostics.js'; import * as cpuProfilerModule from '../../utils/cpuProfiler.js'; import { collectMemoryDiagnostics } from '@qwen-code/qwen-code-core'; import type { Content } from '@google/genai'; import type { DoctorCheckResult } from '../types.js'; -vi.mock('../../utils/doctorChecks.js'); +vi.mock('./doctorChecks.js'); vi.mock('../../utils/memoryDiagnostics.js'); vi.mock('../../utils/cpuProfiler.js'); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index c5c517801b..e1af69c4d2 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -7,7 +7,7 @@ import type { CommandContext, SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import type { HistoryItemDoctor } from '../types.js'; -import { runDoctorChecks } from '../../utils/doctorChecks.js'; +import { runDoctorChecks } from './doctorChecks.js'; import { collectMemoryPressureSamples, formatMemoryDiagnostics, @@ -21,7 +21,7 @@ import { startCpuProfile, stopCpuProfile, } from '../../utils/cpuProfiler.js'; -import { rollbackStandaloneUpdate } from '../../utils/standalone-update.js'; +import { rollbackStandaloneUpdate } from '../standalone-update.js'; import { getInstallationInfo } from '../../utils/installationInfo.js'; import { t } from '../../i18n/index.js'; import { diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts index 61171d4cd8..e2689a92b3 100644 --- a/packages/cli/src/ui/commands/languageCommand.test.ts +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -79,7 +79,7 @@ import { languageCommand } from './languageCommand.js'; import { initializeLlmOutputLanguage, writeOutputLanguageFile, -} from '../../utils/languageUtils.js'; +} from '../../i18n/languageUtils.js'; describe('languageCommand', () => { let mockContext: CommandContext; diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index 9719cd502d..c1e1601186 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -28,7 +28,7 @@ import { isAutoLanguage, resolveOutputLanguageOrPreserveAuto, writeOutputLanguageAndRegisterPath, -} from '../../utils/languageUtils.js'; +} from '../../i18n/languageUtils.js'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; const debugLogger = createDebugLogger('LANGUAGE_COMMAND'); diff --git a/packages/cli/src/ui/commands/update-command.test.ts b/packages/cli/src/ui/commands/update-command.test.ts index 580a28c726..f0f8374a9d 100644 --- a/packages/cli/src/ui/commands/update-command.test.ts +++ b/packages/cli/src/ui/commands/update-command.test.ts @@ -45,7 +45,7 @@ vi.mock('../../utils/processUtils.js', () => ({ HOST_UPDATE_RELAUNCH_ENV_VAR: 'QWEN_CODE_HOST_UPDATE_RELAUNCH', relaunchForUpdate, })); -vi.mock('../../utils/standalone-update.js', () => ({ +vi.mock('../standalone-update.js', () => ({ performStandaloneUpdate, })); vi.mock('../../utils/installationInfo.js', () => ({ diff --git a/packages/cli/src/ui/commands/update-command.ts b/packages/cli/src/ui/commands/update-command.ts index 9683f53841..9a3b5e6113 100644 --- a/packages/cli/src/ui/commands/update-command.ts +++ b/packages/cli/src/ui/commands/update-command.ts @@ -28,7 +28,7 @@ export const updateCommand: SlashCommand = { ] = await Promise.all([ import('../utils/updateCheck.js'), import('../../utils/processUtils.js'), - import('../../utils/standalone-update.js'), + import('../standalone-update.js'), import('../../utils/installationInfo.js'), ]); const { formatUpdateInstructions, getInstallationInfo } = installationInfo; diff --git a/packages/cli/src/ui/components/AboutBox.tsx b/packages/cli/src/ui/components/AboutBox.tsx index 70ef137118..b992f23c2d 100644 --- a/packages/cli/src/ui/components/AboutBox.tsx +++ b/packages/cli/src/ui/components/AboutBox.tsx @@ -7,8 +7,8 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import type { ExtendedSystemInfo } from '../../utils/systemInfo.js'; -import { getSystemInfoFields } from '../../utils/systemInfoFields.js'; +import type { ExtendedSystemInfo } from '../systemInfo.js'; +import { getSystemInfoFields } from '../systemInfoFields.js'; import { t } from '../../i18n/index.js'; type AboutBoxProps = ExtendedSystemInfo & { diff --git a/packages/cli/src/ui/components/ApprovalModeDialog.tsx b/packages/cli/src/ui/components/ApprovalModeDialog.tsx index 0833d0714c..f85d291987 100644 --- a/packages/cli/src/ui/components/ApprovalModeDialog.tsx +++ b/packages/cli/src/ui/components/ApprovalModeDialog.tsx @@ -12,7 +12,7 @@ import { ApprovalMode, APPROVAL_MODES } from '@qwen-code/qwen-code-core'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; +import { getScopeMessageForSetting } from '../../config/dialogScopeUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { ScopeSelector } from './shared/ScopeSelector.js'; import { t } from '../../i18n/index.js'; diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index f12b1b5be4..d64371c223 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -33,8 +33,8 @@ import { getSettingDefinition, saveModifiedSettings, TEST_ONLY, -} from '../../utils/settingsUtils.js'; -import { OUTPUT_LANGUAGE_AUTO } from '../../utils/languageUtils.js'; +} from '../../config/settingsUtils.js'; +import { OUTPUT_LANGUAGE_AUTO } from '../../i18n/languageUtils.js'; // Mock the VimModeContext const mockToggleVimEnabled = vi.fn(); @@ -130,16 +130,16 @@ vi.mock('../contexts/VimModeContext.js', async () => { }; }); -vi.mock('../../utils/settingsUtils.js', async () => { - const actual = await vi.importActual('../../utils/settingsUtils.js'); +vi.mock('../../config/settingsUtils.js', async () => { + const actual = await vi.importActual('../../config/settingsUtils.js'); return { ...actual, saveModifiedSettings: vi.fn(), }; }); -vi.mock('../../utils/languageUtils.js', async () => { - const actual = await vi.importActual('../../utils/languageUtils.js'); +vi.mock('../../i18n/languageUtils.js', async () => { + const actual = await vi.importActual('../../i18n/languageUtils.js'); return { ...actual, updateOutputLanguageFile: vi.fn(), diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index e1764afa60..c5d27179b9 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -10,7 +10,7 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import type { LoadedSettings, Settings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; +import { getScopeMessageForSetting } from '../../config/dialogScopeUtils.js'; import { ScopeSelector } from './shared/ScopeSelector.js'; import { t } from '../../i18n/index.js'; import { ICON } from '../constants.js'; @@ -28,11 +28,11 @@ import { getNestedValue, getEffectiveValue, validateSettingValue, -} from '../../utils/settingsUtils.js'; +} from '../../config/settingsUtils.js'; import { isAutoLanguage, writeOutputLanguageAndRegisterPath, -} from '../../utils/languageUtils.js'; +} from '../../i18n/languageUtils.js'; import { useVimModeState, useVimModeActions, @@ -56,7 +56,7 @@ import { StatsDialog } from './StatsDialog.js'; import { getExtendedSystemInfo, type ExtendedSystemInfo, -} from '../../utils/systemInfo.js'; +} from '../systemInfo.js'; interface SettingsDialogProps { settings: LoadedSettings; diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 31e868f9c2..d4261d7eb7 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -18,7 +18,7 @@ import { DiffRenderer } from './messages/DiffRenderer.js'; import { colorizeCode } from '../utils/CodeColorizer.js'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; +import { getScopeMessageForSetting } from '../../config/dialogScopeUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { ScopeSelector } from './shared/ScopeSelector.js'; import { t } from '../../i18n/index.js'; diff --git a/packages/cli/src/ui/components/shared/ScopeSelector.tsx b/packages/cli/src/ui/components/shared/ScopeSelector.tsx index f5d265bfd6..b272492062 100644 --- a/packages/cli/src/ui/components/shared/ScopeSelector.tsx +++ b/packages/cli/src/ui/components/shared/ScopeSelector.tsx @@ -7,7 +7,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import type { SettingScope } from '../../../config/settings.js'; -import { getScopeItems } from '../../../utils/dialogScopeUtils.js'; +import { getScopeItems } from '../../../config/dialogScopeUtils.js'; import { RadioButtonSelect } from './RadioButtonSelect.js'; import { t } from '../../../i18n/index.js'; diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/ui/handleAutoUpdate.test.ts similarity index 98% rename from packages/cli/src/utils/handleAutoUpdate.test.ts rename to packages/cli/src/ui/handleAutoUpdate.test.ts index ed7cc61487..78a21cc258 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/ui/handleAutoUpdate.test.ts @@ -10,14 +10,14 @@ import { getInstallationInfo, getHomebrewLatestVersion, PackageManager, -} from './installationInfo.js'; -import { updateEventEmitter } from './updateEventEmitter.js'; -import type { UpdateObject } from '../ui/utils/updateCheck.js'; +} from '../utils/installationInfo.js'; +import { updateEventEmitter } from '../utils/updateEventEmitter.js'; +import type { UpdateObject } from './utils/updateCheck.js'; import type { LoadedSettings } from '../config/settings.js'; import EventEmitter from 'node:events'; import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js'; import { performStandaloneUpdate } from './standalone-update.js'; -import { MessageType } from '../ui/types.js'; +import { MessageType } from './types.js'; import os from 'node:os'; const { mockT, identityT } = vi.hoisted(() => { @@ -30,8 +30,8 @@ const { mockT, identityT } = vi.hoisted(() => { vi.mock('../i18n/index.js', () => ({ t: mockT })); -vi.mock('./installationInfo.js', async () => { - const actual = await vi.importActual('./installationInfo.js'); +vi.mock('../utils/installationInfo.js', async () => { + const actual = await vi.importActual('../utils/installationInfo.js'); return { ...actual, getInstallationInfo: vi.fn(), @@ -43,7 +43,7 @@ vi.mock('./standalone-update.js', () => ({ performStandaloneUpdate: vi.fn(), })); -vi.mock('./updateEventEmitter.js', async () => { +vi.mock('../utils/updateEventEmitter.js', async () => { const { EventEmitter } = await import('node:events'); return { updateEventEmitter: new EventEmitter(), diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/ui/handleAutoUpdate.ts similarity index 96% rename from packages/cli/src/utils/handleAutoUpdate.ts rename to packages/cli/src/ui/handleAutoUpdate.ts index 7695215845..94dc75c385 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/ui/handleAutoUpdate.ts @@ -5,18 +5,18 @@ */ import semver from 'semver'; -import type { UpdateObject } from '../ui/utils/updateCheck.js'; +import type { UpdateObject } from './utils/updateCheck.js'; import type { LoadedSettings } from '../config/settings.js'; import { getHomebrewLatestVersion, getInstallationInfo, PackageManager, resolveUpdateCommand, -} from './installationInfo.js'; -import { updateEventEmitter } from './updateEventEmitter.js'; -import type { HistoryItemWithoutId } from '../ui/types.js'; -import { MessageType } from '../ui/types.js'; -import { spawnWrapper } from './spawnWrapper.js'; +} from '../utils/installationInfo.js'; +import { updateEventEmitter } from '../utils/updateEventEmitter.js'; +import type { HistoryItemWithoutId } from './types.js'; +import { MessageType } from './types.js'; +import { spawnWrapper } from '../utils/spawnWrapper.js'; import { performStandaloneUpdate } from './standalone-update.js'; import { t } from '../i18n/index.js'; import type { spawn } from 'node:child_process'; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 61d7f35426..b02ee3da80 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -63,7 +63,7 @@ import { parseSlashCommand, parseStackedSlashCommands, MAX_STACKED_SKILLS, -} from '../../utils/commands.js'; +} from '../commands/commands.js'; import { AppEvent } from '../../utils/events.js'; import { t } from '../../i18n/index.js'; import { refreshExtensionContentRuntime } from '../../config/extension-runtime-reload.js'; diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index b57cc0b112..b6c9b5af07 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -30,7 +30,7 @@ import { isStackedSkillCompletableCommand, isValidStackedSkillPrefix, parseSlashCommand, -} from '../../utils/commands.js'; +} from '../commands/commands.js'; export enum CompletionMode { IDLE = 'IDLE', diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index eaaadd2dbe..90ace47dcd 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -104,7 +104,7 @@ import { } from '../utils/markdownUtilities.js'; import { fitPendingSlice } from '../utils/pending-rendered-height.js'; import { useStateAndRef } from './useStateAndRef.js'; -import { normalizePartList } from '../../utils/nonInteractiveHelpers.js'; +import { normalizePartList } from '../../utils/normalize-part-list.js'; import { isInlineModelOverrideAllowed } from '../../utils/acpModelUtils.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts index d5ec09c172..fdcd6928fd 100644 --- a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts @@ -21,9 +21,9 @@ import { } from '@qwen-code/qwen-code-core'; import { useProviderUpdates } from './useProviderUpdates.js'; -vi.mock('../../utils/settingsUtils.js', async (importOriginal) => { +vi.mock('../../config/settingsUtils.js', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal(); return { ...actual, backupSettingsFile: vi.fn(), diff --git a/packages/cli/src/utils/standalone-update.test.ts b/packages/cli/src/ui/standalone-update.test.ts similarity index 99% rename from packages/cli/src/utils/standalone-update.test.ts rename to packages/cli/src/ui/standalone-update.test.ts index dcdb2b915b..196509ad5f 100644 --- a/packages/cli/src/utils/standalone-update.test.ts +++ b/packages/cli/src/ui/standalone-update.test.ts @@ -125,7 +125,7 @@ describe('standalone-update', () => { describe('ensureBinWrapper', () => { it('uses an independent launcher wait budget in the deferred Windows swap script', () => { const source = fs.readFileSync( - path.resolve('src/utils/standalone-update.ts'), + path.resolve('src/ui/standalone-update.ts'), 'utf8', ); diff --git a/packages/cli/src/utils/standalone-update.ts b/packages/cli/src/ui/standalone-update.ts similarity index 99% rename from packages/cli/src/utils/standalone-update.ts rename to packages/cli/src/ui/standalone-update.ts index c95fab1042..87ef059077 100644 --- a/packages/cli/src/utils/standalone-update.ts +++ b/packages/cli/src/ui/standalone-update.ts @@ -16,9 +16,9 @@ import type { Response as UndiciResponse } from 'undici'; import * as tar from 'tar'; import type { ReadEntry } from 'tar'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; -import { loadUndici } from './load-undici.js'; -import { verifySignature } from './standalone-update-verify.js'; -import { updateEventEmitter } from './updateEventEmitter.js'; +import { loadUndici } from '../utils/load-undici.js'; +import { verifySignature } from '../utils/standalone-update-verify.js'; +import { updateEventEmitter } from '../utils/updateEventEmitter.js'; import { t } from '../i18n/index.js'; const debugLogger = createDebugLogger('STANDALONE_UPDATE'); diff --git a/packages/cli/src/utils/systemInfo.test.ts b/packages/cli/src/ui/systemInfo.test.ts similarity index 98% rename from packages/cli/src/utils/systemInfo.test.ts rename to packages/cli/src/ui/systemInfo.test.ts index 526ad93e76..ee8fa1a46c 100644 --- a/packages/cli/src/utils/systemInfo.test.ts +++ b/packages/cli/src/ui/systemInfo.test.ts @@ -12,12 +12,12 @@ import { getSandboxEnv, getIdeClientName, } from './systemInfo.js'; -import type { CommandContext } from '../ui/commands/types.js'; +import type { CommandContext } from './commands/types.js'; import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; import type * as child_process from 'node:child_process'; import os from 'node:os'; import { IdeClient } from '@qwen-code/qwen-code-core'; -import * as versionUtils from './version.js'; +import * as versionUtils from '../utils/version.js'; // `getNpmVersion` / `getGitVersion` use `execFile` callback-style. Mock // the named export via `vi.hoisted` so the spy reference is the same one @@ -73,7 +73,7 @@ vi.mock('node:os', async (importOriginal) => { }; }); -vi.mock('./version.js', () => ({ +vi.mock('../utils/version.js', () => ({ getCliVersion: vi.fn(), })); diff --git a/packages/cli/src/utils/systemInfo.ts b/packages/cli/src/ui/systemInfo.ts similarity index 98% rename from packages/cli/src/utils/systemInfo.ts rename to packages/cli/src/ui/systemInfo.ts index 63905adb27..06d36b4ac1 100644 --- a/packages/cli/src/utils/systemInfo.ts +++ b/packages/cli/src/ui/systemInfo.ts @@ -7,8 +7,8 @@ import process from 'node:process'; import os from 'node:os'; import { execFile } from 'node:child_process'; -import type { CommandContext } from '../ui/commands/types.js'; -import { getCliVersion } from './version.js'; +import type { CommandContext } from './commands/types.js'; +import { getCliVersion } from '../utils/version.js'; import { IdeClient, AuthType, diff --git a/packages/cli/src/utils/systemInfoFields.test.ts b/packages/cli/src/ui/systemInfoFields.test.ts similarity index 100% rename from packages/cli/src/utils/systemInfoFields.test.ts rename to packages/cli/src/ui/systemInfoFields.test.ts diff --git a/packages/cli/src/utils/systemInfoFields.ts b/packages/cli/src/ui/systemInfoFields.ts similarity index 100% rename from packages/cli/src/utils/systemInfoFields.ts rename to packages/cli/src/ui/systemInfoFields.ts diff --git a/packages/cli/src/utils/update-relaunch.test.ts b/packages/cli/src/ui/update-relaunch.test.ts similarity index 96% rename from packages/cli/src/utils/update-relaunch.test.ts rename to packages/cli/src/ui/update-relaunch.test.ts index 130a87a6d6..10a0a7a5f5 100644 --- a/packages/cli/src/utils/update-relaunch.test.ts +++ b/packages/cli/src/ui/update-relaunch.test.ts @@ -13,16 +13,16 @@ const getInstallationInfo = vi.hoisted(() => vi.fn()); const performStandaloneUpdate = vi.hoisted(() => vi.fn()); const writeStderrLine = vi.hoisted(() => vi.fn()); -vi.mock('../ui/utils/updateCheck.js', () => ({ +vi.mock('./utils/updateCheck.js', () => ({ checkForUpdatesDetailed, // Classification behavior is covered by updateCheck.test.ts; a fixed reason // keeps this suite from importing the real update-notifier chain. describeUpdateCheckFailure: () => 'registry error', })); vi.mock('./handleAutoUpdate.js', () => ({ handleAutoUpdate })); -vi.mock('./installationInfo.js', () => ({ getInstallationInfo })); +vi.mock('../utils/installationInfo.js', () => ({ getInstallationInfo })); vi.mock('./standalone-update.js', () => ({ performStandaloneUpdate })); -vi.mock('./stdioHelpers.js', () => ({ writeStderrLine })); +vi.mock('../utils/stdioHelpers.js', () => ({ writeStderrLine })); vi.mock('../i18n/index.js', () => ({ t: (message: string, params?: Record) => params diff --git a/packages/cli/src/utils/update-relaunch.ts b/packages/cli/src/ui/update-relaunch.ts similarity index 94% rename from packages/cli/src/utils/update-relaunch.ts rename to packages/cli/src/ui/update-relaunch.ts index 23da4dafa1..af7eca39c6 100644 --- a/packages/cli/src/utils/update-relaunch.ts +++ b/packages/cli/src/ui/update-relaunch.ts @@ -5,7 +5,7 @@ */ import type { LoadedSettings } from '../config/settings.js'; -import { writeStderrLine } from './stdioHelpers.js'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; const UPDATE_CHECK_FAILED_MESSAGE = 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.'; @@ -26,9 +26,9 @@ export async function updateBeforeRelaunch( { performStandaloneUpdate }, { t }, ] = await Promise.all([ - import('../ui/utils/updateCheck.js'), + import('./utils/updateCheck.js'), import('./handleAutoUpdate.js'), - import('./installationInfo.js'), + import('../utils/installationInfo.js'), import('./standalone-update.js'), import('../i18n/index.js'), ]); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index a6c73cde28..5b04aa71b9 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -11,7 +11,7 @@ import { isStackedSkillCompletableCommand, isValidStackedSkillPrefix, parseSlashCommand, -} from '../../utils/commands.js'; +} from '../commands/commands.js'; import type { SlashCommand } from '../commands/types.js'; import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js'; import { MessageType } from '../types.js'; diff --git a/packages/cli/src/utils/deepMerge.test.ts b/packages/cli/src/utils/deepMerge.test.ts index 0603aafb3a..f62d911509 100644 --- a/packages/cli/src/utils/deepMerge.test.ts +++ b/packages/cli/src/utils/deepMerge.test.ts @@ -5,8 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { customDeepMerge } from './deepMerge.js'; -import { MergeStrategy } from '../config/settingsSchema.js'; +import { customDeepMerge, MergeStrategy } from './deepMerge.js'; describe('customDeepMerge', () => { it('should merge simple objects', () => { diff --git a/packages/cli/src/utils/deepMerge.ts b/packages/cli/src/utils/deepMerge.ts index 85200ddbae..23250aeb3f 100644 --- a/packages/cli/src/utils/deepMerge.ts +++ b/packages/cli/src/utils/deepMerge.ts @@ -4,7 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { MergeStrategy } from '../config/settingsSchema.js'; +export enum MergeStrategy { + // Replace the old value with the new value. This is the default. + REPLACE = 'replace', + // Concatenate arrays. + CONCAT = 'concat', + // Merge arrays, ensuring unique values. + UNION = 'union', + // Shallow merge objects. + SHALLOW_MERGE = 'shallow_merge', +} export type Mergeable = | string diff --git a/packages/cli/src/utils/midTurnUserMessage.ts b/packages/cli/src/utils/midTurnUserMessage.ts index 097caeda93..1b37c78a8c 100644 --- a/packages/cli/src/utils/midTurnUserMessage.ts +++ b/packages/cli/src/utils/midTurnUserMessage.ts @@ -5,7 +5,7 @@ */ import type { Part, PartListUnion } from '@google/genai'; -import { normalizePartList } from './nonInteractiveHelpers.js'; +import { normalizePartList } from './normalize-part-list.js'; export const MID_TURN_USER_MESSAGE_PREFIX = '\n[User message received during tool execution]: '; diff --git a/packages/cli/src/utils/normalize-part-list.test.ts b/packages/cli/src/utils/normalize-part-list.test.ts new file mode 100644 index 0000000000..2ebfadc991 --- /dev/null +++ b/packages/cli/src/utils/normalize-part-list.test.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import type { Part } from '@google/genai'; +import { normalizePartList } from './normalize-part-list.js'; + +describe('normalizePartList', () => { + it('should return empty array for null input', () => { + expect(normalizePartList(null)).toEqual([]); + }); + + it('should return empty array for undefined input', () => { + expect(normalizePartList(undefined as unknown as null)).toEqual([]); + }); + + it('should convert string to Part array', () => { + const result = normalizePartList('test string'); + expect(result).toEqual([{ text: 'test string' }]); + }); + + it('should convert array of strings to Part array', () => { + const result = normalizePartList(['hello', 'world']); + expect(result).toEqual([{ text: 'hello' }, { text: 'world' }]); + }); + + it('should convert array of mixed strings and Parts to Part array', () => { + const part: Part = { text: 'existing' }; + const result = normalizePartList(['new', part]); + expect(result).toEqual([{ text: 'new' }, part]); + }); + + it('should convert single Part object to array', () => { + const part: Part = { text: 'single part' }; + const result = normalizePartList(part); + expect(result).toEqual([part]); + }); + + it('should handle empty array', () => { + expect(normalizePartList([])).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/normalize-part-list.ts b/packages/cli/src/utils/normalize-part-list.ts new file mode 100644 index 0000000000..fa767dc6a6 --- /dev/null +++ b/packages/cli/src/utils/normalize-part-list.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Part, PartListUnion } from '@google/genai'; + +/** + * Normalizes various part list formats into a consistent Part[] array. + * + * @param parts - Input parts in various formats (string, Part, Part[], or null) + * @returns Normalized array of Part objects + */ +export function normalizePartList(parts: PartListUnion | null): Part[] { + if (!parts) { + return []; + } + + if (typeof parts === 'string') { + return [{ text: parts }]; + } + + if (Array.isArray(parts)) { + return parts.map((part) => + typeof part === 'string' ? { text: part } : (part as Part), + ); + } + + return [parts as Part]; +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index dc3ca2f4a1..e0b73a9d46 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -61,7 +61,7 @@ "src/serve/auth.test.ts", "src/serve/server.test.ts", "src/utils/cleanup.test.ts", - "src/utils/handleAutoUpdate.test.ts", + "src/ui/handleAutoUpdate.test.ts", "src/utils/startupWarnings.test.ts", "src/ui/App.test.tsx", "src/ui/commands/aboutCommand.test.ts", diff --git a/packages/web-shell/client/components/artifacts/CodeReviewArtifactDetail.tsx b/packages/web-shell/client/components/artifacts/CodeReviewArtifactDetail.tsx index 0fc9a825e3..c92d28a649 100644 --- a/packages/web-shell/client/components/artifacts/CodeReviewArtifactDetail.tsx +++ b/packages/web-shell/client/components/artifacts/CodeReviewArtifactDetail.tsx @@ -10,10 +10,10 @@ import styles from './CodeReviewArtifactDetail.module.css'; import type { ArtifactWorkspaceActions } from './useArtifactWorkspaceTarget'; // Hand-duplicated from the CLI's canonical lists in -// packages/cli/src/utils/findings.ts. The parser below fails closed +// packages/cli/src/commands/review/findings.ts. The parser below fails closed // on any value missing here, so when the CLI adds one, update this copy and // the contract fixture (__fixtures__/code-review-artifact-v1.json) with it. -// The CLI-side vocabulary snapshot (packages/cli/src/utils/findings.test.ts) +// The CLI-side vocabulary snapshot (packages/cli/src/commands/review/findings.test.ts) // turns red on that change and names this file. const SEVERITIES = ['Critical', 'Suggestion', 'Nice to have'] as const; const CONFIDENCES = ['high', 'low'] as const; diff --git a/scripts/copy_bundle_assets.js b/scripts/copy_bundle_assets.js index 73842ac35a..1beeb22366 100644 --- a/scripts/copy_bundle_assets.js +++ b/scripts/copy_bundle_assets.js @@ -110,13 +110,9 @@ export function reviewSourceDigestForBuild(root) { ), kind: 'code', }, - // Mirrors the lifted-helpers roots `reviewSourceRoots` (stale-bundle.ts) + // Mirrors the utils-helpers roots `reviewSourceRoots` (stale-bundle.ts) // adds; the repo-tree case in review-source-digest.test.ts holds the two // equal. - { - path: join(root, 'packages', 'cli', 'src', 'utils', 'findings.ts'), - kind: 'code', - }, { path: join(root, 'packages', 'cli', 'src', 'utils', 'shell-args.ts'), kind: 'code', diff --git a/scripts/tests/no-utils-upward-import-config.test.js b/scripts/tests/no-utils-upward-import-config.test.js new file mode 100644 index 0000000000..09913b8df6 --- /dev/null +++ b/scripts/tests/no-utils-upward-import-config.test.js @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { ESLint } from 'eslint'; + +describe('utils upward-import flat-config integration', () => { + it('reports value imports but allows type-only upward imports', async () => { + const eslint = new ESLint({ + cwd: process.cwd(), + overrideConfigFile: 'eslint.config.js', + }); + const [ + prodStatic, + prodTypeOnly, + prodExportTypeOnly, + prodInlineType, + prodMixedSpecifiers, + prodTypeQuery, + prodDynamic, + testStatic, + ] = await Promise.all([ + eslint.lintText("import value from '../config/settings.js';", { + filePath: 'packages/cli/src/utils/fixture-boundary.ts', + }), + eslint.lintText( + "import type { Settings } from '../config/settings.js';", + { + filePath: 'packages/cli/src/utils/fixture-boundary.ts', + }, + ), + eslint.lintText( + "export type { Settings } from '../config/settings.js';", + { + filePath: 'packages/cli/src/utils/fixture-boundary.ts', + }, + ), + eslint.lintText( + "import { type Settings } from '../config/settings.js';", + { + filePath: 'packages/cli/src/utils/fixture-boundary.ts', + }, + ), + eslint.lintText( + "import { type Settings, loadSettings } from '../config/settings.js';", + { + filePath: 'packages/cli/src/utils/fixture-boundary.ts', + }, + ), + eslint.lintText( + "type Settings = import('../config/settings.js').Settings;", + { + filePath: 'packages/cli/src/utils/fixture-boundary.ts', + }, + ), + eslint.lintText("import('../config/settings.js');", { + filePath: 'packages/cli/src/utils/fixture-boundary.ts', + }), + eslint.lintText("import value from '../config/settings.js';", { + filePath: 'packages/cli/src/utils/fixture-boundary.test.ts', + }), + ]); + + const hasViolation = (results) => + results.some((r) => + r.messages.some( + (m) => m.ruleId === 'architecture/no-utils-upward-import', + ), + ); + + // value imports (static and dynamic) are caught + expect(hasViolation(prodStatic)).toBe(true); + expect(hasViolation(prodDynamic)).toBe(true); + // statement-level type-only imports are erased at compile time and stay + // allowed + expect(hasViolation(prodTypeOnly)).toBe(false); + expect(hasViolation(prodExportTypeOnly)).toBe(false); + expect(hasViolation(prodTypeQuery)).toBe(false); + // inline type specifiers are NOT erased under this repo's + // `verbatimModuleSyntax`: tsc emits `import {} from ...`, a runtime edge + // that evaluates the target module, so the rule reports them + expect(hasViolation(prodInlineType)).toBe(true); + // a mixed value+type specifier list keeps the value edge, so it is + // reported too (guards an exemption that tested only the type specifiers) + expect(hasViolation(prodMixedSpecifiers)).toBe(true); + // test files stay exempt via the rule's own test/fixture exemption + expect(hasViolation(testStatic)).toBe(false); + }); +}); diff --git a/scripts/tests/no-utils-upward-import.test.js b/scripts/tests/no-utils-upward-import.test.js new file mode 100644 index 0000000000..2422fc2b2e --- /dev/null +++ b/scripts/tests/no-utils-upward-import.test.js @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { Linter } from 'eslint'; +import rule from '../../eslint-rules/no-utils-upward-import.js'; + +function runRule(code, filename) { + const linter = new Linter({ configType: 'eslintrc' }); + linter.defineRule('architecture/no-utils-upward-import', rule); + return linter.verify( + code, + { + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + rules: { 'architecture/no-utils-upward-import': 'error' }, + }, + { filename }, + ); +} + +describe('no-utils-upward-import', () => { + it.each([ + ['packages/cli/src/utils/deepMerge.ts', '../config/settings.js'], + [ + 'packages/cli/src/utils/housekeeping/cleanup.ts', + '../../config/settings.js', + ], + ['packages/cli/src/utils/foo.ts', '../ui/commands/types.js'], + ['packages/cli/src/utils/foo.ts', '../i18n/index.js'], + ['packages/cli/src/utils/foo.ts', 'src/config/settings.js'], + [ + 'packages/cli/src/utils/foo.ts', + '../nonInteractive/nonInteractiveHelpers.js', + ], + // nested checkout: the marker appears twice, so the utils root must be + // anchored on the LAST one — an indexOf anchor would resolve the import + // inside the outer utils root and report nothing + [ + '/tmp/packages/cli/src/utils/nested/packages/cli/src/utils/foo.ts', + '../config/settings.js', + ], + ])('rejects upward imports from %s', (filename, importedPath) => { + expect( + runRule(`import value from '${importedPath}';`, filename), + ).toHaveLength(1); + }); + + it('rejects export and dynamic upward sources', () => { + const file = 'packages/cli/src/utils/foo.ts'; + expect( + runRule("export { value } from '../config/settings.js';", file), + ).toHaveLength(1); + // a zero-specifier re-export is a runtime edge (`export {} from` survives + // emission), not a vacuous `[].every(...)` type-only exemption + expect( + runRule("export {} from '../config/settings.js';", file), + ).toHaveLength(1); + expect( + runRule("export * from '../config/settings.js';", file), + ).toHaveLength(1); + expect(runRule("import('../config/settings.js');", file)).toHaveLength(1); + expect(runRule('import(`../config/settings.js`);', file)).toHaveLength(1); + expect(runRule("import('src/config/settings.js');", file)).toHaveLength(1); + }); + + it('fails closed on computed dynamic sources with a local known prefix', () => { + const file = 'packages/cli/src/utils/foo.ts'; + // A multi-segment template: interpolation can contribute a `../` step, + // and a leading `../` cannot be undone, so the import cannot be proven + // to stay inside utils/ — reported even though part is interpolated. + expect(runRule('import(`../i18n/${locale}.js`);', file)).toHaveLength(1); + // A `+` concatenation with a relative leftmost literal, same reasoning. + expect(runRule("import('../config/' + name + '.js');", file)).toHaveLength( + 1, + ); + // A relative `./` prefix is also unprovable (interpolation could still + // climb), so it is reported too. + expect(runRule('import(`./sub/${name}.js`);', file)).toHaveLength(1); + expect(runRule('import(`src/config/${name}.js`);', file)).toHaveLength(1); + }); + + it('drops computed dynamic sources with no statically known local prefix', () => { + const file = 'packages/cli/src/utils/foo.ts'; + // A bare identifier or a package-like prefix is the same boundary the + // static check applies to non-relative specifiers — not reported. + expect(runRule('import(moduleName);', file)).toHaveLength(0); + expect(runRule('import(`${pkg}/entry.js`);', file)).toHaveLength(0); + }); + + it('allows imports that stay within utils', () => { + expect( + runRule('import(`./sibling.js`);', 'packages/cli/src/utils/foo.ts'), + ).toHaveLength(0); + expect( + runRule( + "import value from './sibling.js';", + 'packages/cli/src/utils/foo.ts', + ), + ).toHaveLength(0); + expect( + runRule( + "import value from '../cleanup.js';", + 'packages/cli/src/utils/housekeeping/cleanup.ts', + ), + ).toHaveLength(0); + expect( + runRule( + "import value from '../../utils/sibling.js';", + 'packages/cli/src/utils/housekeeping/cleanup.ts', + ), + ).toHaveLength(0); + expect( + runRule( + "import value from 'src/utils/sibling.js';", + 'packages/cli/src/utils/foo.ts', + ), + ).toHaveLength(0); + // same doubly-nested shape as the reject case: a sibling import stays + // inside the LAST marker's utils root + expect( + runRule( + "import value from './sibling.js';", + '/tmp/packages/cli/src/utils/nested/packages/cli/src/utils/foo.ts', + ), + ).toHaveLength(0); + expect( + runRule( + "import value from 'src/utils/sibling.js';", + '/tmp/packages/cli/src/utils/nested/packages/cli/src/utils/foo.ts', + ), + ).toHaveLength(0); + }); + + it('allows package and builtin imports', () => { + const file = 'packages/cli/src/utils/foo.ts'; + expect(runRule("import fs from 'node:fs';", file)).toHaveLength(0); + expect( + runRule("import value from '@qwen-code/qwen-code-core';", file), + ).toHaveLength(0); + }); + + it('ignores tests, fixtures, __tests__, and non-utils consumers', () => { + expect( + runRule( + "import value from '../config/settings.js';", + 'packages/cli/src/utils/foo.test.ts', + ), + ).toHaveLength(0); + expect( + runRule( + "import value from '../config/settings.js';", + 'packages/cli/src/utils/foo.spec.ts', + ), + ).toHaveLength(0); + expect( + runRule( + "import value from '../config/settings.js';", + 'packages/cli/src/utils/__tests__/helper.ts', + ), + ).toHaveLength(0); + expect( + runRule( + "import value from '../config/settings.js';", + 'packages/cli/src/utils/fixtures/helper.ts', + ), + ).toHaveLength(0); + // non-utils consumers may import utils freely + expect( + runRule( + "import value from '../utils/sibling.js';", + 'packages/cli/src/config/foo.ts', + ), + ).toHaveLength(0); + }); +}); diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 15ceb42e05..b9c9afa9a8 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -21332,7 +21332,7 @@ describe('stale sandbox container cleanup', () => { '#!/bin/bash\necho "$@" >> "${AGENT_WORKDIR}/docker-calls.txt"\nexit 0\n', ); chmodSync(join(bin, 'docker'), 0o755); - // The launcher line exactly as packages/cli/src/utils/sandbox.ts + // The launcher line exactly as packages/cli/src/serve/sandbox.ts // prints it, then the wedge shape: one line, then silence. const stub = join(dir, 'qwen'); writeFileSync( diff --git a/scripts/tests/review-source-digest.test.ts b/scripts/tests/review-source-digest.test.ts index 2cdc23fc87..7fdebb7aae 100644 --- a/scripts/tests/review-source-digest.test.ts +++ b/scripts/tests/review-source-digest.test.ts @@ -135,15 +135,20 @@ describe('the build stamp and the staleness check agree', () => { join(services, 'review-worktree-lease.ts'), 'leases the worktree', ); - // The lifted review helpers are file-shaped roots outside `commands/`, + // The utils review helpers are file-shaped roots outside `commands/`, // same as the lease. Materialized here so their pins are local: the // repo-tree case holds them only through the real files, and would - // silently drop the pins the day those files moved. + // silently drop the pins the day those files moved. The findings + // validator itself lives back under the `review/` directory root + // (#9146), materialized there below. const utils = join(root, 'packages', 'cli', 'src', 'utils'); mkdirSync(utils, { recursive: true }); - writeFileSync(join(utils, 'findings.ts'), 'validates the findings'); writeFileSync(join(utils, 'shell-args.ts'), 'tokenizes the args'); writeFileSync(join(utils, 'paths.ts'), 'flattens the slug'); + writeFileSync( + join(cli, 'review', 'findings.ts'), + 'validates the findings', + ); writeFileSync(join(cli, 'review', 'drive.ts'), 'drives'); writeFileSync(join(cli, 'review', 'lib', 'ledger.ts'), 'ledgers'); // One production file per admitted code extension: dropping a member @@ -261,22 +266,36 @@ describe('the build stamp and the staleness check agree', () => { }, ); - it('digests the review helpers lifted into utils/', () => { - // findings.ts and its two helpers were lifted out of commands/review/, - // which the digest covered. Root lists that lost them would keep both - // copies equal while a skipped rebuild silently runs the bundle's old - // validator — name the files so the omission fails here. + it('digests the review helpers that live outside commands/review/', () => { + // The two helpers of findings.ts live in utils/, outside the directory + // root. Root lists that lost them would keep both copies equal while a + // skipped rebuild silently runs the bundle's old validator — name the + // files so the omission fails here. findings.ts itself moved back into + // commands/review/ (#9146), which the directory root covers. const roots = reviewSourceRoots(repoRoot).map((r) => r.path); const utils = join(repoRoot, 'packages', 'cli', 'src', 'utils'); - expect(roots).toContain(join(utils, 'findings.ts')); expect(roots).toContain(join(utils, 'shell-args.ts')); expect(roots).toContain(join(utils, 'paths.ts')); + // findings.ts moved back into commands/review/ (#9146): re-adding its old + // utils/ root would find no file there, and both digest copies skip + // absent roots — every lane stays green while absentRoots darkens every + // review's staleness check. Pin the removal. + expect(roots).not.toContain(join(utils, 'findings.ts')); // List membership is not tree existence: a moved file keeps its // root listed — both digest copies stay equal — while absentRoots // darkens every review's staleness check. - expect(existsSync(join(utils, 'findings.ts'))).toBe(true); expect(existsSync(join(utils, 'shell-args.ts'))).toBe(true); expect(existsSync(join(utils, 'paths.ts'))).toBe(true); + const reviewDir = join( + repoRoot, + 'packages', + 'cli', + 'src', + 'commands', + 'review', + ); + expect(roots).toContain(reviewDir); + expect(existsSync(join(reviewDir, 'findings.ts'))).toBe(true); }); it('the skill allowlist covers everything the copier would ship', () => {