mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* 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 <[email protected]> * test(review): pin mixed-specifier and zero-specifier upward imports in the utils rule (#9146) Co-authored-by: Qwen-Coder <[email protected]> * test(review): anchor the nested-checkout utils rule fixture on the last marker (#9146) Co-authored-by: Qwen-Coder <[email protected]> * test(review): pin that the utils/findings.ts digest root stays removed (#9146) Co-authored-by: Qwen-Coder <[email protected]> * 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 <[email protected]>
This commit is contained in:
1 parent
e9ccd3c67e
commit
56db17bd4c
134 files changed
+907
-355
No files matched your search
@@ -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)
|
||||
) {
|
||||
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.
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user