Files
qwen-code/scripts/check-tui-dep-direction.mjs
ChiGaoand易良 b91c281e34 feat(cli): OpenTUI migration infra batch — locked deps, dependency-direction arch check, framework-neutral streaming model (#10134)
* feat(cli): add locked @opentui 0.5.8 renderer dependencies

Infra batch of the OpenTUI migration tracked in #8662. Versions are pinned (not ranged) so renderer behavior stays reproducible across the landing batches.

* feat(cli): add dependency-direction arch check and framework-neutral streaming model

Infra batch of the OpenTUI migration tracked in #8662. The arch check keeps packages/core and the new ui/model layer free of renderer-framework imports; the streaming model folds agent-loop stream events into render-ready history items so both renderers share one source of truth. Both modules gain consumers in later batches.

* fix(cli): OpenTUI infra batch review — AST dep-direction gate wired to CI, streaming-model hardening, dev-only renderer deps (R1-1..R1-23)

* fix(cli): report dep-direction violations relative to the rule root

* fix(repo): cover import-type/template-literal specifiers and contain symlink traversal in dep-direction gate

- findImports now handles ImportTypeNode (type X = import("...").Y) and accepts interpolation-free template literals as specifiers for import()/require()/vi.mock(), so every statically knowable module specifier is classified; interpolated templates stay skipped.

- listSourceFiles resolves each symlink target against the physical rule root and reports escaped links as gate failures, so traversal cannot leave the root and relative imports cannot be masked by lexical symlink paths.

* fix(repo): fail the dep-direction gate closed on any symlink

A symlink's bytes live wherever the link points, but the gate resolves a
file's relative imports from the path it was reached at. Following links
therefore desyncs lexical resolution from physical location and can report
an escape that physically stays in the rule root, or pass an import that
physically escapes — the latter reproduced end-to-end on the prior head via
a link into a skipped dist directory. Neither direction is auditable
link-by-link, and the tree commits no symlinks, so the walker now reports
any symlink and fails the gate instead of following it. This also removes
the relative()-based containment predicate, which accepted cross-volume
absolute results on Windows.

* fix(repo): reject symlinked rule-root paths in the dep-direction gate

readdirSync follows a symlink transparently, so a rule root that is itself
a link — or whose ancestor components are — let a commit substitute the
protected scan with a clean tree elsewhere while the configured path read
unchanged and no in-tree symlink diagnostic fired (both forms reproduced:
swapping packages/cli/src/ui/model for a link exited 0, as did swapping the
ui ancestor). Every path component below the repo root is now lstat-checked
before enumeration and any symlink fails the gate.

* fix(repo): classify import-equals and resolution probes in dep-direction gate

Self-audit found three statically knowable framework-reference forms the
AST walk skipped: import-equals (`import x = require("ink")`, with or
without the export modifier), `require.resolve("ink")`, and
`import.meta.resolve("ink")` — the latter two name a framework package
even though they never load it. All three are now classified with probe
tests; the namespace form of import-equals (no module specifier) stays
ignored. The dynamic-import operator aliasing limitation (`const i =
import; i("...")`) is documented in the detector contract rather than
silently left open.

* test(repo): end-to-end the dep-direction gate's main wiring

Mutation audit found the main() wiring unguarded: removing the root-path
symlink check, the in-tree symlink failure loop, the violation aggregation,
or the populated-root guard left every unit test green while a manual probe
showed the bypass re-opening. Six subprocess tests now run the real script
against a fixture checkout (clean tree, symlinked rule root, symlinked
ancestor, banned import, in-tree symlink, empty rule root) and assert exit
codes and diagnostics; each of those four deletions now fails the suite.

* fix(repo): validate rule-root paths before enumerating in dep-direction gate

The root-path symlink check ran after requirePopulatedRoot() had already
walked both roots, so a symlinked rule root pointed at an external
directory got its files read and parsed before the link was diagnosed,
and an empty external target exited through the populated-root guard
without ever reaching the symlink diagnostic. Reject any link in the
rule-root path components before enumeration and exit immediately. The
end-to-end fixture tests now assert the redirected target is never
scanned (a banned import in the substitute stays out of the output) and
that an empty substitute reports the symlink error, not the empty-root
error.

* fix(repo): harden dep-direction gate per round-2 review findings

- Rule 1 now blocks bare imports of the cli package's own name (read from
  its manifest so a rename cannot decouple the check): the workspace
  symlink makes `import ... from '@qwen-code/qwen-code'` resolve from
  core, which is exactly the core->cli reach the rule forbids.
- Classify the remaining statically knowable forms: the vi module-loading
  family (mock/doMock/importActual/importMock), module.require,
  require.main.require, and ambient module declarations.
- Fail closed on skipped-directory names (node_modules/dist/.git) inside a
  protected root instead of silently shrinking the scan.
- Replace process.exit(1) with process.exitCode + natural unwind so
  buffered diagnostics flush to CI pipes before termination.
- Add the missing witnesses: noRelativeIntoCli relative and bare arms, the
  ui/model rule arm (both messages), the core-root symlink arm, the empty
  ui/model arm, a .js extension fixture, an unlistable-directory e2e case,
  the skippable-directory case, @solid/ and @react-* table rows, and the
  entry-guard import-suppression side.

* fix(cli): reset items on replayed stream starts and pin rewrite branches

A retry/reconnect can re-emit a tool-start or task-start whose id already
exists; the unconditional push created a duplicate that later events (which
match the first id hit) left stranded at done:false forever. Reset the
existing item instead. Also extend the fold immutability matrix with the
two rewrite branches it did not exercise (closeTrailingAssistant via
text->tool-start and text->done, and the thinking-end rewrite), so an
in-place mutation in either branch now turns the suite red.

---------

Co-authored-by: 易良 <[email protected]>
2026-08-28 02:44:46 +00:00

503 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* Dependency-direction architecture check for the OpenTUI migration
* (tracking QwenLM/qwen-code#8662).
*
* Rules:
* 1. packages/core/src must stay framework-neutral — no imports of ink,
* react, solid, or @opentui/*, and nothing that reaches into
* packages/cli (relative paths or the cli package's own bare name).
* 2. packages/cli/src/ui/model (framework-neutral streaming state) must
* not import react, solid, ink, or @opentui/* either, and must be
* self-contained — no relative import may resolve outside the
* directory, so no framework-dependent sibling can leak in through a
* relative path.
*
* Usage: node scripts/check-tui-dep-direction.mjs
* Exit 0 = all rules hold; exit 1 = violations found (or the scan itself
* was incomplete — unlistable directories, skipped-directory names
* (node_modules/dist/.git), and any symlink fail the gate instead of
* silently shrinking it, whether the link sits inside a scanned tree or
* in a rule root's own path, because a scan reached through a link can be
* substituted by a commit while the path reads unchanged). Failures set
* `process.exitCode` and let the process exit naturally so buffered
* diagnostics are flushed to CI pipes.
*
* Detection parses each file with the TypeScript compiler (already a repo
* devDependency) and walks ImportDeclaration / ExportDeclaration / dynamic
* import() / require() / module.require() / require.main.require() / the
* vi.mock family (mock/doMock/importActual/importMock) / import-type
* (type X = import("...")) / import-equals (import x = require("...")) /
* require.resolve() / import.meta.resolve() / ambient module declarations
* (declare module "..."), accepting string literals and interpolation-free
* template literals as specifiers, so comments, strings, regex literals
* and interpolated templates cannot mask or fake an import.
*/
import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
import { dirname, extname, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { stdout } from 'node:process';
import ts from 'typescript';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const CORE_SRC = join(repoRoot, 'packages', 'core', 'src');
const UI_MODEL = join(repoRoot, 'packages', 'cli', 'src', 'ui', 'model');
const CLI_PACKAGE = join(repoRoot, 'packages', 'cli');
// The cli package's own bare name resolves through the workspace symlink,
// so `import ... from '@qwen-code/qwen-code'` in core is a core->cli reach
// even though it is neither a banned family nor a relative path. Read the
// real name instead of hardcoding it so a rename cannot decouple the two.
const CLI_PACKAGE_NAME = JSON.parse(
readFileSync(join(CLI_PACKAGE, 'package.json'), 'utf8'),
).name;
const SOURCE_EXTENSIONS = new Set([
'.ts',
'.tsx',
'.js',
'.jsx',
'.mjs',
'.cjs',
'.mts',
'.cts',
]);
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']);
// vitest methods that load (or reconstruct) the named module for real —
// vi.mock registers a factory but still resolves the module path, and
// doMock/importActual/importMock load it outright.
const VI_MODULE_METHODS = new Set([
'mock',
'doMock',
'importActual',
'importMock',
]);
/**
* Walk a directory tree collecting source files. A gate whose only product
* is trust must not shrink silently: unlistable directories and
* skipped-directory names (node_modules/dist/.git — none belong inside a
* protected source root, and content hidden there would escape the scan)
* are collected as diagnostics for the caller to fail on.
*
* Symlinks fail closed. `checkRule` resolves a file's relative imports from
* the path the file was reached at, but a symlink's bytes live wherever the
* link points — so resolution from the link's lexical path can report an
* escape that physically stays in the root, or (worse) pass an import that
* physically escapes. Neither direction is auditable link-by-link, and the
* tree commits no symlinks, so any symlink is reported and fails the gate.
* Because symlinks are never followed, traversal cannot cycle or leave the
* root.
*/
function listSourceFiles(root) {
const files = [];
const unreadableDirs = [];
const symlinks = [];
const skippedDirs = [];
const walk = (dir) => {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch (error) {
unreadableDirs.push(`${dir} (${error.message})`);
return;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isSymbolicLink()) {
symlinks.push(full);
continue;
}
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) {
skippedDirs.push(full);
} else {
walk(full);
}
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(extname(entry.name))) {
files.push(full);
}
}
};
walk(root);
return { files: files.sort(), unreadableDirs, symlinks, skippedDirs };
}
/**
* Reject a symlinked rule root before walking it. `readdirSync` follows a
* symlink transparently, so if the root — or any ancestor component between
* `anchor` and it — is a link, a commit could substitute the protected scan
* with a clean tree elsewhere while the configured path reads unchanged and
* no in-tree symlink diagnostic fires. Every path component below the anchor
* is lstat-checked and any symlink is returned as a gate failure. A missing
* component stops the walk; `requirePopulatedRoot` reports the absent root.
*/
function symlinkedPathComponents(root, anchor = repoRoot) {
const links = [];
let current = anchor;
for (const part of root.slice(anchor.length + 1).split(sep)) {
if (!part) continue;
current = join(current, part);
let stats;
try {
stats = lstatSync(current);
} catch {
break;
}
if (stats.isSymbolicLink()) {
links.push(current);
}
}
return links;
}
/**
* Extract import specifiers via the TypeScript AST. Only statically
* knowable specifiers are reportable: string literals and
* interpolation-free template literals (which the runtime treats
* identically as module names); computed or interpolated ones (e.g.
* `import(variable)`) are skipped because no static specifier exists to
* classify. Classified forms: import / export-from declarations, dynamic
* `import()`, `require()` / `module.require()` / `require.main.require()`,
* the `vi` module-loading family (`vi.mock` / `vi.doMock` /
* `vi.importActual` / `vi.importMock`), import-type queries
* (`type X = import("...").Y`), import-equals (`import x = require("...")`,
* still a static framework reference), module-resolution probes
* (`require.resolve("...")` / `import.meta.resolve("...")`, which name a
* framework package even without loading it), and ambient module
* declarations (`declare module "..."` couples a file's types to the named
* module). Known limitation: aliasing the dynamic-import operator
* (`const i = import; i("...")`) defeats detection — that is data-flow
* obfuscation, visible in review.
*/
function findImports(source, fileName = 'module.ts') {
const sourceFile = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
/* setParentNodes */ true,
);
const found = [];
const lineOf = (node) =>
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line +
1;
const record = (kind, node, specifier) => {
found.push({ kind, spec: specifier, line: lineOf(node) });
};
// Specifiers a call/import can statically name: quoted literals plus
// template literals without `${}` interpolation (isStringLiteral rejects
// those, but they resolve to the same module name).
const staticSpecifier = (node) =>
node &&
(ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
? node.text
: undefined;
const visit = (node) => {
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier)
) {
record('import', node, node.moduleSpecifier.text);
} else if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
record('export-from', node, node.moduleSpecifier.text);
} else if (ts.isImportEqualsDeclaration(node)) {
// `import x = require("...")` (also with an export modifier) still
// names a module statically via its external module reference.
const reference = node.moduleReference;
const spec = ts.isExternalModuleReference(reference)
? staticSpecifier(reference.expression)
: undefined;
if (spec !== undefined) {
record('import-equals', node, spec);
}
} else if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
// `declare module "react" { ... }` — an ambient augmentation names a
// module with no import node, so classify the literal name.
record('ambient-module', node, node.name.text);
} else if (ts.isImportTypeNode(node)) {
const argument = node.argument;
const spec = ts.isLiteralTypeNode(argument)
? staticSpecifier(argument.literal)
: undefined;
if (spec !== undefined) {
record('import-type', node, spec);
}
} else if (ts.isCallExpression(node)) {
const spec = staticSpecifier(node.arguments[0]);
if (
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
spec !== undefined
) {
record('dynamic-import', node, spec);
} else if (
ts.isIdentifier(node.expression) &&
node.expression.text === 'require' &&
spec !== undefined
) {
record('require', node, spec);
} else if (
ts.isPropertyAccessExpression(node.expression) &&
spec !== undefined
) {
const callee = node.expression.expression;
const method = node.expression.name.text;
if (ts.isIdentifier(callee)) {
if (callee.text === 'require' && method === 'resolve') {
record('require.resolve', node, spec);
} else if (callee.text === 'module' && method === 'require') {
record('module.require', node, spec);
} else if (callee.text === 'vi' && VI_MODULE_METHODS.has(method)) {
record(`vi.${method}`, node, spec);
}
} else if (
ts.isMetaProperty(callee) &&
callee.keywordToken === ts.SyntaxKind.ImportKeyword &&
method === 'resolve'
) {
record('import.meta.resolve', node, spec);
} else if (
method === 'require' &&
ts.isPropertyAccessExpression(callee) &&
callee.name.text === 'main' &&
ts.isIdentifier(callee.expression) &&
callee.expression.text === 'require'
) {
record('require.main.require', node, spec);
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return found.sort((a, b) => a.line - b.line);
}
/**
* Classify a specifier into a banned framework family, treating each
* framework as its whole ecosystem (ink/react/solid prefixes and scoped
* ecosystem packages), matching how the migration isolates renderers.
*/
function bannedFamily(spec) {
if (
spec === 'ink' ||
spec.startsWith('ink/') ||
spec.startsWith('ink-') ||
spec.startsWith('@inkjs/')
) {
return 'ink';
}
if (
spec === 'react' ||
spec.startsWith('react/') ||
spec.startsWith('react-') ||
spec.startsWith('@react-')
) {
return 'react';
}
if (
spec === 'solid-js' ||
spec.startsWith('solid-js/') ||
spec.startsWith('solid-') ||
spec.startsWith('@solidjs/') ||
spec.startsWith('@solid-') ||
spec.startsWith('@solid/')
) {
return 'solid';
}
if (spec.startsWith('@opentui/') || spec === '@opentui') {
return '@opentui';
}
return null;
}
function checkRule({ label, root, rules, enumeration }) {
const { files, unreadableDirs, symlinks, skippedDirs } =
enumeration ?? listSourceFiles(root);
let specifiers = 0;
const violations = [];
for (const file of files) {
const source = readFileSync(file, 'utf8');
for (const imp of findImports(source, file)) {
specifiers++;
// Paths are reported relative to the rule root, not the repo root:
// slicing by repoRoot corrupts any root outside the checkout, which
// is how the unit tests exercise this function.
const rel = `${file.slice(root.length + 1)}:${imp.line}`;
if (rules.noFramework) {
const family = bannedFamily(imp.spec);
if (family) {
violations.push(
` ${rel} ${imp.kind} '${imp.spec}'` +
` (${family} import in framework-neutral code)`,
);
}
}
if (
rules.noRelativeIntoCli &&
(imp.spec === CLI_PACKAGE_NAME ||
imp.spec.startsWith(CLI_PACKAGE_NAME + '/'))
) {
// The cli package's own bare name resolves through the workspace
// symlink, so naming it is a reach into packages/cli even though
// the specifier is neither relative nor a banned framework family.
violations.push(
` ${rel} ${imp.kind} '${imp.spec}'` +
` (bare import reaches into packages/cli)`,
);
}
if (imp.spec.startsWith('.')) {
const resolved = resolve(dirname(file), imp.spec);
if (
rules.noRelativeIntoCli &&
(resolved === CLI_PACKAGE || resolved.startsWith(CLI_PACKAGE + sep))
) {
violations.push(
` ${rel} ${imp.kind} '${imp.spec}'` +
` (relative import reaches into packages/cli)`,
);
}
if (
rules.selfContained &&
resolved !== root &&
!resolved.startsWith(root + sep)
) {
violations.push(
` ${rel} ${imp.kind} '${imp.spec}'` +
` (relative import escapes the framework-neutral directory)`,
);
}
}
}
}
return {
label,
scanned: files.length,
specifiers,
violations,
unreadableDirs,
symlinks,
skippedDirs,
};
}
function printRule(result) {
stdout.write(`[rule] ${result.label}\n`);
stdout.write(
` scanned ${result.scanned} files, ${result.specifiers} import specifiers\n`,
);
if (result.violations.length === 0) {
stdout.write(' OK: no violations\n');
} else {
for (const violation of result.violations) {
stdout.write(`${violation}\n`);
}
}
stdout.write('\n');
}
function requirePopulatedRoot(root, label) {
const enumeration = listSourceFiles(root);
if (enumeration.files.length === 0) {
stdout.write(`error: ${label} (${root}) not found or has no source files`);
stdout.write('\n');
// Set the exit code and unwind instead of process.exit: exit() does not
// flush stdout buffered on a pipe, which would drop this very message —
// the one a failing gate most needs to show.
process.exitCode = 1;
return null;
}
return enumeration;
}
function main() {
// Validate the rule-root paths before enumerating: a symlinked root or
// ancestor redirects everything the walk reads, so reject the path before
// trusting the scan — otherwise files outside the rule root are parsed
// before the link is diagnosed, and an empty external target reports "no
// source files" instead of the redirected root.
for (const root of [CORE_SRC, UI_MODEL]) {
for (const link of symlinkedPathComponents(root)) {
stdout.write(`error: symlink in rule root path: ${link}\n`);
process.exitCode = 1;
return;
}
}
// Enumerate once per rule root so diagnostics (unlistable directories,
// symlinks) are attributed to the same rule block as the scan.
const coreEnumeration = requirePopulatedRoot(CORE_SRC, 'packages/core/src');
if (!coreEnumeration) return;
const uiModelEnumeration = requirePopulatedRoot(
UI_MODEL,
'packages/cli/src/ui/model',
);
if (!uiModelEnumeration) return;
stdout.write(
'TUI dependency-direction check (OpenTUI migration Phase 0)\n\n',
);
let failed = false;
const results = [
checkRule({
label: 'packages/core/src — framework-neutral business core',
root: CORE_SRC,
rules: { noFramework: true, noRelativeIntoCli: true },
enumeration: coreEnumeration,
}),
checkRule({
label: 'packages/cli/src/ui/model — framework-neutral streaming state',
root: UI_MODEL,
rules: { noFramework: true, selfContained: true },
enumeration: uiModelEnumeration,
}),
];
for (const result of results) {
printRule(result);
failed ||= result.violations.length > 0;
for (const dir of result.unreadableDirs) {
stdout.write(`error: could not list directory: ${dir}\n`);
failed = true;
}
for (const link of result.symlinks) {
stdout.write(`error: symlink in scanned tree (not followed): ${link}\n`);
failed = true;
}
for (const dir of result.skippedDirs) {
stdout.write(`error: skippable directory inside scanned tree: ${dir}\n`);
failed = true;
}
}
if (failed) {
stdout.write('FAIL — dependency-direction violations found.\n');
process.exitCode = 1;
return;
}
stdout.write('PASS — dependency direction holds.\n');
}
export {
bannedFamily,
checkRule,
findImports,
listSourceFiles,
symlinkedPathComponents,
};
if (
process.argv[1] &&
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main();
}