Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a983ece
refactor(cli): enforce utils leaf-layer dependency direction (#9146)
yiliang114 Aug 22, 2026
7735a3b
fix: use Qwen Team 2026 license header on new files (#9146)
yiliang114 Aug 22, 2026
204b3c3
chore: refresh stale utils/ path references after leaf-layer move (#9…
yiliang114 Aug 22, 2026
21d21a0
Merge remote-tracking branch 'origin/main' into codex/9146-utils-leaf…
yiliang114 Aug 22, 2026
a81cbe7
docs: reconcile no-utils-upward-import header with the allowed type-o…
yiliang114 Aug 22, 2026
beab2dc
fix(cli): allowlist sandbox process.env accesses after leaf-layer mov…
yiliang114 Aug 22, 2026
1e48458
chore(ci): re-record qwen-autofix.yml size baseline after #9677 (#9146)
yiliang114 Aug 22, 2026
0566edc
fix(review): drop the stale utils/findings.ts digest root after the l…
yiliang114 Aug 22, 2026
0dca7b0
fix(review): colocate seatbelt profiles with the sandbox module (#9146)
yiliang114 Aug 22, 2026
25670ab
fix(review): exempt inline type-only specifiers from the utils upward…
yiliang114 Aug 22, 2026
21cfacf
fix(review): report upward inline type-specifier imports under verbat…
yiliang114 Aug 22, 2026
6980025
test(review): pin mixed-specifier and zero-specifier upward imports i…
yiliang114 Aug 22, 2026
bf6b9ba
test(review): anchor the nested-checkout utils rule fixture on the la…
yiliang114 Aug 22, 2026
8071456
test(review): pin that the utils/findings.ts digest root stays remove…
yiliang114 Aug 22, 2026
256abb1
Merge remote-tracking branch 'origin/main' into codex/9146-utils-leaf…
yiliang114 Aug 23, 2026
1b870e2
fix(review): reword stale-bundle SCOPE header to the post-move helper…
yiliang114 Aug 23, 2026
ab039d6
test(review): drop the pre-move utils/findings.ts from the skill-pari…
yiliang114 Aug 23, 2026
25687c4
test(serve): derive the seatbelt colocation tripwire from BUILTIN_SEA…
yiliang114 Aug 23, 2026
764d603
fix(architecture): fail closed on computed dynamic imports in the uti…
yiliang114 Aug 23, 2026
98f0134
fix(cli): point settings.test.ts at the post-move settingsUtils path …
yiliang114 Aug 23, 2026
e81731f
fix(cli): close utils boundary review gaps
yiliang114 Aug 23, 2026
85320da
Merge remote-tracking branch 'origin/main' into codex/pr-9737-closeou…
yiliang114 Aug 23, 2026
1445a76
test(cli): cover utils boundary allow paths
yiliang114 Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .qwen/skills/autofix/scripts/run-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
2 changes: 1 addition & 1 deletion docs/design/2026-07-21-lazy-undici-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/design/hot-reload/settings-change-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/design/legacy-code-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/design/slash-command/phase1-technical-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 不修改任何命令行为)
Expand Down
4 changes: 2 additions & 2 deletions docs/design/slash-command/phase3-technical-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 可借鉴点

Expand Down Expand Up @@ -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,但这不是必需项。

Expand Down
2 changes: 1 addition & 1 deletion docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<profile_name>`: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-<profile_name>.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. `<profile_name>`: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-<profile_name>.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. |
Expand Down
192 changes: 192 additions & 0 deletions eslint-rules/no-utils-upward-import.js
Original file line number Diff line number Diff line change
@@ -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 === '<input>' || filename === '<text>') {
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)
) {
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
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;
}
Comment thread
yiliang114 marked this conversation as resolved.
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.
};
},
};
Loading
Loading