refactor(cli): enforce utils leaf-layer dependency direction (#9146) (#9737)

* 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:
易良andQwen-Coder authored and GitHub committed 2026-08-23 14:41:49 +00:00
1 parent e9ccd3c67e
commit 56db17bd4c
134 files changed
+907 -355

No files matched your search

+192
View File
@@ -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.
};
},
};