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]>
This commit is contained in:
ChiGaoand易良 authored and GitHub committed 2026-08-28 02:44:46 +00:00
1 parent 7357136dd1
commit b91c281e34
10 files changed
+2591

No files matched your search

+4
View File
@@ -413,6 +413,10 @@ jobs:
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run check:desktop-isolation'
- name: 'Check TUI dependency direction'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run check:tui-dep-direction'
- name: 'Install linters'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'node scripts/lint.js --setup'
+247
View File
@@ -4407,6 +4407,124 @@
"node": ">=14"
}
},
"node_modules/@opentui/core-darwin-arm64": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@opentui/core-darwin-arm64/-/core-darwin-arm64-0.5.8.tgz",
"integrity": "sha512-c9Y1FBrSnA4sKUCMETsrLYOmsMTyJae8mU9cE6M4o9rXcr3ZLPA7o9AkPA3S0+kH+kZ0o1Fn0wLejgNxvEp5mg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@opentui/core-darwin-x64": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@opentui/core-darwin-x64/-/core-darwin-x64-0.5.8.tgz",
"integrity": "sha512-oZ/6Iz1KN+4volMFKmmvziYJhMgyyJ99LfK1S+uPRyIRqzT3CESoJ58D4h04M1g0dHeHE4PvkU1p3uQKrXiP3g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@opentui/core-linux-arm64": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64/-/core-linux-arm64-0.5.8.tgz",
"integrity": "sha512-N6i/ocrsTjIq9aUQyrfJkqUo+tc4P5ZS6xz38Cm1MhtDzwBE+CrNIJdHfoJfmRLhJkYHlw6Z0ToICn8ZNj4Bpw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@opentui/core-linux-arm64-musl": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64-musl/-/core-linux-arm64-musl-0.5.8.tgz",
"integrity": "sha512-eFMB41AWODaYf8PsCx3vtTMX33tFgtSpR9tKNnUErCXlcnZ+WCRHVaJ9F6DrVNX3ir6HmgbwAmaWXvaVF+kLtg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@opentui/core-linux-x64": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@opentui/core-linux-x64/-/core-linux-x64-0.5.8.tgz",
"integrity": "sha512-/2QM7/wMnML/sxchzbwgoU5tUu/7k836/kSOKMti8opjuecv1K+WWNKGXufhTNeRcXFZaba5rsCdYrf3VqnVsQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@opentui/core-linux-x64-musl": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@opentui/core-linux-x64-musl/-/core-linux-x64-musl-0.5.8.tgz",
"integrity": "sha512-YXo+qUHYmep2uvv3ECvTeqr10aD7+lBsavYmsTLBzS5hHabbzlQ10oX/99nIRPC3Au1BMWD6d6zQP5czPx23eg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@opentui/core-win32-arm64": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@opentui/core-win32-arm64/-/core-win32-arm64-0.5.8.tgz",
"integrity": "sha512-7qBdhEAlh4tLFzW7nWLPlREtNiF6NZMDMi+4uDpUlAMhRavLr6wjcIcgfhNAF/puq06DjuZlPTwaMCgB3qOuwA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@opentui/core-win32-x64": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@opentui/core-win32-x64/-/core-win32-x64-0.5.8.tgz",
"integrity": "sha512-Z76YaTKnmRDSHKdKa7iTBCXBQdInQLq7UG3qIE84nvyHCDtkhYtPWOaCzDisSgXC8Y6hJt6NiFbaNjQyxPkfpQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxc-parser/binding-android-arm-eabi": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz",
@@ -11958,6 +12076,16 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bun-ffi-structs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/bun-ffi-structs/-/bun-ffi-structs-0.3.1.tgz",
"integrity": "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"typescript": "^5"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -29692,6 +29820,8 @@
},
"devDependencies": {
"@babel/runtime": "^7.27.6",
"@opentui/core": "0.5.8",
"@opentui/react": "0.5.8",
"@testing-library/react": "^16.3.0",
"@types/archiver": "^6.0.3",
"@types/command-exists": "^1.2.3",
@@ -29732,6 +29862,59 @@
"@teddyzhu/clipboard-win32-x64-msvc": "0.0.5"
}
},
"packages/cli/node_modules/@opentui/core": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@opentui/core/-/core-0.5.8.tgz",
"integrity": "sha512-GbZ+nSLYZqxj2Z5TU19Mx6IsAvVsn2+7WEXz+6OlMGoootvt3TxP3vWwfGI0jWk9qp1ftRlML1JPNEkzy+9I8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-ffi-structs": "0.3.1",
"diff": "9.0.0",
"marked": "17.0.1",
"string-width": "7.2.0",
"strip-ansi": "7.1.2"
},
"optionalDependencies": {
"@opentui/core-darwin-arm64": "0.5.8",
"@opentui/core-darwin-x64": "0.5.8",
"@opentui/core-linux-arm64": "0.5.8",
"@opentui/core-linux-arm64-musl": "0.5.8",
"@opentui/core-linux-x64": "0.5.8",
"@opentui/core-linux-x64-musl": "0.5.8",
"@opentui/core-win32-arm64": "0.5.8",
"@opentui/core-win32-x64": "0.5.8"
},
"peerDependencies": {
"web-tree-sitter": "0.25.10"
}
},
"packages/cli/node_modules/@opentui/core/node_modules/diff": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"packages/cli/node_modules/@opentui/react": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@opentui/react/-/react-0.5.8.tgz",
"integrity": "sha512-l3N/Kbg5V+OABp60A5iCfEj3WK5pKVUDmUrVPf/xgct9R7Xy2Jbw/0MoSFpgr3odq3CuWprYajnoLCR9f81BRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@opentui/core": "0.5.8",
"react-reconciler": "^0.33.0"
},
"peerDependencies": {
"react": ">=19.2.0",
"react-devtools-core": "^7.0.1",
"ws": "^8.18.0"
}
},
"packages/cli/node_modules/@testing-library/react": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
@@ -29862,6 +30045,19 @@
"markdown-it": "bin/markdown-it.mjs"
}
},
"packages/cli/node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"dev": true,
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"packages/cli/node_modules/mdurl": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
@@ -29883,6 +30079,41 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/cli/node_modules/react-devtools-core": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-7.0.1.tgz",
"integrity": "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
}
},
"packages/cli/node_modules/react-devtools-core/node_modules/ws": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
"integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"packages/cli/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@@ -29906,6 +30137,22 @@
"integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==",
"license": "MIT"
},
"packages/cli/node_modules/web-tree-sitter": {
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz",
"integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/emscripten": "^1.40.0"
},
"peerDependenciesMeta": {
"@types/emscripten": {
"optional": true
}
}
},
"packages/cli/node_modules/wrap-ansi": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz",
+1
View File
@@ -91,6 +91,7 @@
"telemetry": "node scripts/telemetry.js",
"check:lockfile": "node scripts/check-lockfile.js",
"check:desktop-isolation": "node scripts/check-desktop-isolation.js",
"check:tui-dep-direction": "node scripts/check-tui-dep-direction.mjs",
"check:serve-fast-path-bundle": "node scripts/clean-package-build-artifacts.js && npm run build -- --cli-only && cross-env DEV=true npm run bundle && node scripts/check-serve-fast-path-bundle.js",
"clean": "node scripts/clean.js",
"pre-commit": "node scripts/pre-commit.js"
+2
View File
@@ -100,6 +100,8 @@
},
"devDependencies": {
"@babel/runtime": "^7.27.6",
"@opentui/core": "0.5.8",
"@opentui/react": "0.5.8",
"@testing-library/react": "^16.3.0",
"@types/archiver": "^6.0.3",
"@types/command-exists": "^1.2.3",
@@ -0,0 +1,235 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Stream aggregation window: the renderer-shared coalescing semantics.
* Consecutive same-group events merge into one group per window;
* never-same-group events (images) ride as single-event groups; consumers
* drive boundaries via flushNow and failed attempts via discard.
*/
import { describe, expect, it, vi } from 'vitest';
import {
STREAM_UPDATE_WINDOW_MS,
createStreamAggregator,
} from './stream-aggregation.js';
type TestEvent =
| { kind: 'text'; value: string }
| { kind: 'thought'; value: string }
| { kind: 'image'; value: string };
interface Harness {
flushed: TestEvent[];
mergeCalls: number;
push: (event: TestEvent) => void;
flushNow: () => void;
discard: () => void;
}
function createHarness(options?: { windowMs?: number }): Harness {
const flushed: TestEvent[] = [];
let mergeCalls = 0;
const aggregator = createStreamAggregator<TestEvent>({
...(options?.windowMs !== undefined ? { windowMs: options.windowMs } : {}),
sameGroup: (a, b) => a.kind === b.kind,
mergeGroup: (group) => {
mergeCalls++;
return {
kind: group[0].kind,
value: group.map((event) => event.value).join(''),
} as TestEvent;
},
onFlush: (event) => flushed.push(event),
});
return {
flushed,
get mergeCalls() {
return mergeCalls;
},
push: (event) => aggregator.push(event),
flushNow: () => aggregator.flushNow(),
discard: () => aggregator.discard(),
};
}
describe('createStreamAggregator', () => {
it('exposes the shared 60ms window', () => {
expect(STREAM_UPDATE_WINDOW_MS).toBe(60);
});
it('flushes at the default window when windowMs is omitted', () => {
vi.useFakeTimers();
try {
const h = createHarness();
h.push({ kind: 'text', value: 'a' });
expect(h.flushed).toEqual([]);
vi.advanceTimersByTime(STREAM_UPDATE_WINDOW_MS - 1);
expect(h.flushed).toEqual([]);
vi.advanceTimersByTime(1);
expect(h.flushed).toEqual([{ kind: 'text', value: 'a' }]);
} finally {
vi.useRealTimers();
}
});
it('coalesces consecutive same-group events inside one window', () => {
vi.useFakeTimers();
try {
const h = createHarness({ windowMs: 20 });
h.push({ kind: 'text', value: 'a' });
h.push({ kind: 'text', value: 'b' });
h.push({ kind: 'text', value: 'c' });
expect(h.flushed).toEqual([]);
vi.advanceTimersByTime(20);
expect(h.flushed).toEqual([{ kind: 'text', value: 'abc' }]);
expect(h.mergeCalls).toBe(1);
} finally {
vi.useRealTimers();
}
});
it('flushes a single-event group untouched without merging', () => {
vi.useFakeTimers();
try {
const h = createHarness({ windowMs: 20 });
h.push({ kind: 'text', value: 'a' });
vi.advanceTimersByTime(20);
expect(h.flushed).toEqual([{ kind: 'text', value: 'a' }]);
expect(h.mergeCalls).toBe(0);
} finally {
vi.useRealTimers();
}
});
it('splits groups at kind changes and preserves order', () => {
vi.useFakeTimers();
try {
const h = createHarness({ windowMs: 20 });
h.push({ kind: 'thought', value: 't1' });
h.push({ kind: 'thought', value: 't2' });
h.push({ kind: 'text', value: 'a' });
h.push({ kind: 'text', value: 'b' });
vi.advanceTimersByTime(20);
expect(h.flushed).toEqual([
{ kind: 'thought', value: 't1t2' },
{ kind: 'text', value: 'ab' },
]);
} finally {
vi.useRealTimers();
}
});
it('rides never-same-group events (images) as single-event groups', () => {
vi.useFakeTimers();
try {
// Images ride the window as their own group instead of merging with
// adjacent text or with each other.
const h = createHarness({ windowMs: 20 });
h.push({ kind: 'text', value: 'before' });
h.push({ kind: 'image', value: 'img' });
h.push({ kind: 'text', value: 'after' });
expect(h.flushed).toEqual([]);
vi.advanceTimersByTime(20);
expect(h.flushed).toEqual([
{ kind: 'text', value: 'before' },
{ kind: 'image', value: 'img' },
{ kind: 'text', value: 'after' },
]);
} finally {
vi.useRealTimers();
}
});
it('starts the window at the first buffered event, not per event', () => {
vi.useFakeTimers();
try {
const h = createHarness({ windowMs: 30 });
h.push({ kind: 'text', value: 'a' });
vi.advanceTimersByTime(20);
h.push({ kind: 'text', value: 'b' });
// 20ms after the second push = 40ms after the first: past the window.
vi.advanceTimersByTime(20);
expect(h.flushed).toEqual([{ kind: 'text', value: 'ab' }]);
} finally {
vi.useRealTimers();
}
});
it('flushNow drains synchronously and cancels the timer', () => {
vi.useFakeTimers();
try {
const h = createHarness({ windowMs: 20 });
h.push({ kind: 'text', value: 'a' });
h.flushNow();
expect(h.flushed).toEqual([{ kind: 'text', value: 'a' }]);
vi.advanceTimersByTime(50);
expect(h.flushed).toHaveLength(1);
// Idempotent on an empty buffer.
h.flushNow();
expect(h.flushed).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it('re-arms the window for events pushed after a flush', () => {
vi.useFakeTimers();
try {
const h = createHarness({ windowMs: 20 });
h.push({ kind: 'text', value: 'a' });
h.flushNow();
h.push({ kind: 'text', value: 'b' });
vi.advanceTimersByTime(20);
expect(h.flushed).toEqual([
{ kind: 'text', value: 'a' },
{ kind: 'text', value: 'b' },
]);
} finally {
vi.useRealTimers();
}
});
it('discard drops pending events without flushing', () => {
vi.useFakeTimers();
try {
const h = createHarness({ windowMs: 20 });
h.push({ kind: 'text', value: 'a' });
h.discard();
vi.advanceTimersByTime(50);
expect(h.flushed).toEqual([]);
// The buffer is gone: later pushes start fresh.
h.push({ kind: 'text', value: 'b' });
vi.advanceTimersByTime(20);
expect(h.flushed).toEqual([{ kind: 'text', value: 'b' }]);
} finally {
vi.useRealTimers();
}
});
it('hasPending reports buffered events by predicate', () => {
const aggregator = createStreamAggregator<TestEvent>({
windowMs: 20,
sameGroup: (a, b) => a.kind === b.kind,
mergeGroup: (group) =>
({
kind: group[0].kind,
value: group.map((e) => e.value).join(''),
}) as TestEvent,
onFlush: () => {},
});
expect(aggregator.hasPending()).toBe(false);
expect(aggregator.hasPending((e) => e.kind === 'thought')).toBe(false);
aggregator.push({ kind: 'text', value: 'a' });
expect(aggregator.hasPending()).toBe(true);
expect(aggregator.hasPending((e) => e.kind === 'thought')).toBe(false);
aggregator.push({ kind: 'thought', value: 'why' });
expect(aggregator.hasPending((e) => e.kind === 'thought')).toBe(true);
aggregator.flushNow();
expect(aggregator.hasPending()).toBe(false);
});
});
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Framework-neutral stream aggregation window.
*
* One place for the 60ms coalescing semantics shared by the renderers:
* token-level events arrive far faster than a terminal frame, so
* consecutive events merge into one group per window, while consumers
* flush pending groups at turn boundaries (flushNow) or drop them on a
* failed attempt (discard), preserving cross-kind ordering and
* turn-boundary semantics. The constant below and its tests are the
* canonical record of the window value until a design document lands.
*
* This module must not import react / solid / ink / @opentui — the rule is
* enforced by `scripts/check-tui-dep-direction.mjs` (wired into CI via
* `npm run check:tui-dep-direction`).
*/
/** The aggregation window both renderers coalesce stream events into. */
export const STREAM_UPDATE_WINDOW_MS = 60;
export interface StreamAggregationOptions<E> {
/** Coalescing window; defaults to {@link STREAM_UPDATE_WINDOW_MS}. */
windowMs?: number;
/**
* Whether `next` merges into the group headed by `head`. Events that are
* never same-group (e.g. images) ride the window as single-event groups.
*/
sameGroup: (head: E, next: E) => boolean;
/**
* Collapse a multi-event group into one. Only called for groups of two or
* more events; single-event groups pass through untouched.
*/
mergeGroup: (group: readonly E[]) => E;
/** Consume a flushed event — a merged group or a pass-through. */
onFlush: (event: E) => void;
}
export interface StreamAggregator<E> {
/** Stream arrival path: buffer, coalesce, or pass through. */
push(event: E): void;
/**
* Whether any event is sitting in the pending buffer, optionally limited
* to events matching `predicate`. Consumers use this to detect pending
* cross-kind transitions (e.g. "is buffered reasoning waiting when the
* answer starts streaming?").
*/
hasPending(predicate?: (event: E) => boolean): boolean;
/**
* Flush every pending group now, synchronously, and cancel the window
* timer. Boundary handling and external pre-snapshot drains call this
* (e.g. cancellation must land buffered content in React state before
* the caller snapshots it).
*/
flushNow(): void;
/** Drop all pending events without flushing (failed-attempt reset). */
discard(): void;
}
export function createStreamAggregator<E>(
options: StreamAggregationOptions<E>,
): StreamAggregator<E> {
const { sameGroup, mergeGroup, onFlush } = options;
const windowMs = options.windowMs ?? STREAM_UPDATE_WINDOW_MS;
const buffer: E[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const cancelTimer = () => {
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTimer = null;
}
};
const flushNow = () => {
cancelTimer();
while (buffer.length > 0) {
const group: E[] = [buffer.shift()!];
while (buffer.length > 0 && sameGroup(group[0], buffer[0])) {
group.push(buffer.shift()!);
}
onFlush(group.length === 1 ? group[0] : mergeGroup(group));
}
};
const schedule = () => {
if (flushTimer === null) {
flushTimer = setTimeout(flushNow, windowMs);
}
};
return {
push(event: E) {
buffer.push(event);
schedule();
},
hasPending(predicate?: (event: E) => boolean) {
return predicate ? buffer.some(predicate) : buffer.length > 0;
},
flushNow,
discard() {
cancelTimer();
buffer.length = 0;
},
};
}
@@ -0,0 +1,429 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import {
initialStreamingModelState,
reduceStreamEvent,
reduceStreamEvents,
selectIsDone,
selectIsStreaming,
selectItemById,
selectItems,
} from './streaming-model.js';
import type {
HistoryItem,
StreamingModelState,
StreamEvent,
} from './streaming-model.js';
function reduceAll(events: StreamEvent[]): StreamingModelState {
return reduceStreamEvents(initialStreamingModelState, events);
}
describe('streamingModel', () => {
describe('text merging', () => {
it('merges consecutive text deltas into one streaming assistant item', () => {
const state = reduceAll([
{ type: 'text', delta: 'Hello ' },
{ type: 'text', delta: 'world' },
]);
const items = selectItems(state);
expect(items).toHaveLength(1);
expect(items[0]).toEqual({
kind: 'assistant',
id: 'assistant-0',
text: 'Hello world',
streaming: true,
});
expect(selectIsStreaming(state)).toBe(true);
});
it('starts a new assistant item after a tool interrupts the text stream', () => {
const state = reduceAll([
{ type: 'text', delta: 'before' },
{ type: 'tool-start', id: 't1', tool: 'Read', title: 'Read foo.ts' },
{ type: 'tool-end', id: 't1', success: true, summary: '1 line' },
{ type: 'text', delta: 'after' },
]);
const items = selectItems(state);
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({
kind: 'assistant',
text: 'before',
streaming: false,
});
expect(items[2]).toMatchObject({
kind: 'assistant',
id: 'assistant-1',
text: 'after',
streaming: true,
});
});
});
describe('thinking start / end', () => {
it('merges thinking deltas until thinking-end, then opens a new block', () => {
const state = reduceAll([
{ type: 'thinking', delta: 'consider ' },
{ type: 'thinking', delta: 'options' },
{ type: 'thinking-end' },
{ type: 'thinking', delta: 'second burst' },
]);
const items = selectItems(state);
expect(items).toHaveLength(2);
expect(items[0]).toEqual({
kind: 'thinking',
id: 'thinking-0',
text: 'consider options',
done: true,
});
expect(items[1]).toEqual({
kind: 'thinking',
id: 'thinking-1',
text: 'second burst',
done: false,
});
});
it('treats thinking-end without an active thinking block as a no-op', () => {
const state = reduceAll([{ type: 'thinking-end' }]);
expect(selectItems(state)).toHaveLength(0);
});
});
describe('tool start / output / end', () => {
it('accumulates tool output by id and closes with success and summary', () => {
const state = reduceAll([
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls -la' },
{ type: 'tool-output', id: 't1', delta: 'drwxr-xr-x\n' },
{ type: 'tool-output', id: 't1', delta: 'total 0\n' },
{ type: 'tool-end', id: 't1', success: true, summary: '2 lines' },
]);
const tool = selectItemById(state, 't1');
expect(tool).toEqual({
kind: 'tool',
id: 't1',
tool: 'Bash',
title: 'ls -la',
output: 'drwxr-xr-x\ntotal 0\n',
done: true,
success: true,
summary: '2 lines',
});
});
it('records failed tools', () => {
const state = reduceAll([
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'false' },
{ type: 'tool-end', id: 't1', success: false, summary: 'exit 1' },
]);
expect(selectItemById(state, 't1')).toMatchObject({
done: true,
success: false,
summary: 'exit 1',
});
});
it('ignores tool-output and tool-end for unknown ids', () => {
const before = reduceAll([
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
]);
const after = reduceAll([
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
{ type: 'tool-output', id: 'ghost', delta: 'nope' },
{ type: 'tool-end', id: 'ghost', success: true, summary: 'nope' },
]);
expect(after.items).toEqual(before.items);
});
it('closes a trailing streaming assistant item on tool-start', () => {
const state = reduceAll([
{ type: 'text', delta: 'intro' },
{ type: 'tool-start', id: 't1', tool: 'Read', title: 'Read bar.ts' },
]);
expect(state.items[0]).toMatchObject({
kind: 'assistant',
streaming: false,
});
});
it('resets the existing item when a tool-start is replayed', () => {
// A retry/reconnect can re-emit a start whose id already exists; a
// duplicate item would strand later events (first id hit) and leave
// the ghost at done:false forever.
const state = reduceAll([
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
{ type: 'tool-output', id: 't1', delta: 'stale' },
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls -la' },
{ type: 'tool-output', id: 't1', delta: 'fresh' },
{ type: 'tool-end', id: 't1', success: true, summary: 'ok' },
]);
expect(
selectItems(state).filter((item) => item.kind === 'tool'),
).toHaveLength(1);
expect(selectItemById(state, 't1')).toMatchObject({
title: 'ls -la',
output: 'fresh',
done: true,
});
});
});
describe('user events', () => {
it('produces a user item carrying text, promptId and sentToModel', () => {
const state = reduceAll([
{
type: 'user',
text: 'run the tests',
promptId: 'sess########7',
sentToModel: true,
},
]);
const items = selectItems(state);
expect(items).toHaveLength(1);
expect(items[0]).toEqual({
kind: 'user',
id: 'user-0',
text: 'run the tests',
promptId: 'sess########7',
sentToModel: true,
});
});
it('closes a trailing streaming assistant item', () => {
const state = reduceAll([
{ type: 'text', delta: 'intro' },
{ type: 'user', text: 'next turn' },
]);
expect(state.items[0]).toMatchObject({
kind: 'assistant',
streaming: false,
});
expect(state.items[1]).toMatchObject({ kind: 'user', text: 'next turn' });
});
});
describe('task progress / end', () => {
it('keeps only the tail of task progress lines', () => {
const events: StreamEvent[] = [
{
type: 'task-start',
id: 's1',
name: 'researcher',
description: 'bench ink frames',
},
];
for (const line of ['one', 'two', 'three', 'four', 'five']) {
events.push({ type: 'task-progress', id: 's1', line });
}
const task = selectItemById(reduceAll(events), 's1');
expect(task).toMatchObject({
kind: 'task',
done: false,
progress: ['three', 'four', 'five'],
});
});
it('ignores task-progress and task-end for unknown ids', () => {
const start = {
type: 'task-start',
id: 's1',
name: 'n',
description: 'd',
} as const;
const before = reduceAll([start]);
const after = reduceAll([
start,
{ type: 'task-progress', id: 'ghost', line: 'nope' },
{ type: 'task-end', id: 'ghost', tools: 1, seconds: 1, tokens: '1' },
]);
expect(after.items).toEqual(before.items);
});
it('resets the existing item when a task-start is replayed', () => {
const state = reduceAll([
{ type: 'task-start', id: 's1', name: 'n', description: 'd' },
{ type: 'task-progress', id: 's1', line: 'stale' },
{ type: 'task-start', id: 's1', name: 'n2', description: 'd2' },
{ type: 'task-progress', id: 's1', line: 'fresh' },
{ type: 'task-end', id: 's1', tools: 1, seconds: 1, tokens: '1' },
]);
expect(
selectItems(state).filter((item) => item.kind === 'task'),
).toHaveLength(1);
expect(selectItemById(state, 's1')).toMatchObject({
name: 'n2',
progress: ['fresh'],
done: true,
});
});
it('closes the task with formatted stats', () => {
const state = reduceAll([
{
type: 'task-start',
id: 's1',
name: 'researcher',
description: 'bench ink frames',
},
{ type: 'task-progress', id: 's1', line: '↳ done' },
{
type: 'task-end',
id: 's1',
tools: 3,
seconds: 12.4,
tokens: '2.1k',
},
]);
expect(selectItemById(state, 's1')).toEqual({
kind: 'task',
id: 's1',
name: 'researcher',
description: 'bench ink frames',
progress: ['↳ done'],
done: true,
stats: '3 tools · 12.4s · 2.1k tokens',
});
});
});
describe('done', () => {
it('finalizes the trailing assistant item and clears streaming', () => {
const state = reduceAll([
{ type: 'text', delta: 'all done ' },
{ type: 'text', delta: 'now' },
{ type: 'done' },
]);
expect(selectItems(state)[0]).toMatchObject({
kind: 'assistant',
text: 'all done now',
streaming: false,
});
expect(selectIsStreaming(state)).toBe(false);
expect(selectIsDone(state)).toBe(true);
});
it('accepts further events after done (multi-turn fold)', () => {
const state = reduceAll([
{ type: 'text', delta: 'turn one' },
{ type: 'done' },
{ type: 'text', delta: 'turn two' },
]);
expect(selectItems(state)).toHaveLength(2);
expect(selectIsStreaming(state)).toBe(true);
expect(selectIsDone(state)).toBe(false);
});
it('folds a full mixed scenario in order', () => {
const state = reduceAll([
{ type: 'thinking', delta: 'plan' },
{ type: 'thinking-end' },
{ type: 'text', delta: 'let me look' },
{ type: 'tool-start', id: 't1', tool: 'Read', title: 'Read a.ts' },
{ type: 'tool-output', id: 't1', delta: 'contents' },
{ type: 'tool-end', id: 't1', success: true, summary: 'ok' },
{ type: 'text', delta: 'found it' },
{
type: 'task-start',
id: 's1',
name: 'worker',
description: 'verify',
},
{ type: 'task-progress', id: 's1', line: 'step' },
{
type: 'task-end',
id: 's1',
tools: 1,
seconds: 2,
tokens: '0.1k',
},
{ type: 'text', delta: 'summary' },
{ type: 'done' },
]);
expect(selectItems(state).map((item) => item.kind)).toEqual([
'thinking',
'assistant',
'tool',
'assistant',
'task',
'assistant',
]);
expect(selectItemById(state, 't1')).toMatchObject({ done: true });
expect(selectItemById(state, 's1')).toMatchObject({ done: true });
expect(selectIsDone(state)).toBe(true);
});
});
it('never mutates items held by earlier states on any updating branch', () => {
// Every branch that rewrites an existing item relies on
// replace-not-mutate discipline over objects shared by reference with
// every previously returned state, so fold one event per branch and
// deep-check each captured snapshot after the whole fold.
const scenarios: StreamEvent[][] = [
[
{ type: 'text', delta: 'a' },
{ type: 'text', delta: 'b' },
],
[
{ type: 'thinking', delta: 'a' },
{ type: 'thinking', delta: 'b' },
],
[
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
{ type: 'tool-output', id: 't1', delta: 'x' },
],
[
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
{ type: 'tool-end', id: 't1', success: true, summary: 'ok' },
],
[
{ type: 'task-start', id: 's1', name: 'n', description: 'd' },
{ type: 'task-progress', id: 's1', line: 'step' },
],
[
{ type: 'task-start', id: 's1', name: 'n', description: 'd' },
{ type: 'task-end', id: 's1', tools: 1, seconds: 1, tokens: '1' },
],
// closeTrailingAssistant rewrites the streaming assistant when a
// start/user/done event lands — pin the rewrite through a captured
// snapshot that must survive the fold.
[
{ type: 'text', delta: 'a' },
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
],
[{ type: 'text', delta: 'a' }, { type: 'done' }],
// thinking-end rewrites the trailing thinking item to done.
[{ type: 'thinking', delta: 'a' }, { type: 'thinking-end' }],
];
for (const events of scenarios) {
let state = initialStreamingModelState;
const captured: Array<{
items: readonly HistoryItem[];
clone: HistoryItem[];
}> = [];
for (const event of events) {
state = reduceStreamEvent(state, event);
captured.push({
items: state.items,
clone: structuredClone([...state.items]),
});
}
for (const { items, clone } of captured) {
expect([...items]).toEqual(clone);
}
}
});
it('never mutates the previous state', () => {
const before = reduceAll([{ type: 'text', delta: 'a' }]);
const beforeItems = selectItems(before);
reduceStreamEvent(before, { type: 'text', delta: 'b' });
expect(selectItems(before)).toBe(beforeItems);
expect(beforeItems).toHaveLength(1);
expect(beforeItems[0]).toMatchObject({ text: 'a' });
});
});
@@ -0,0 +1,303 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Framework-neutral streaming state machine: folds agent-loop stream events
* into render-ready history items. Extracted from the OpenTUI POC's applyEvent
* reducer so any renderer binding (ink today, OpenTUI tomorrow) shares one
* source of truth for history.
*
* This module must not import react / solid / ink / @opentui — the rule is
* enforced by `scripts/check-tui-dep-direction.mjs` (wired into CI via
* `npm run check:tui-dep-direction`).
*/
export type StreamEvent =
| {
type: 'user';
text: string;
/** Stable turn key (`sessionId########promptCount`) — file checkpoints
* are keyed by it, so /rewind needs it on the user item. */
promptId?: string;
/** False when the echoed command was handled locally (never sent). */
sentToModel?: boolean;
}
| { type: 'thinking'; delta: string }
| { type: 'thinking-end' }
| { type: 'text'; delta: string }
| { type: 'tool-start'; id: string; tool: string; title: string }
| { type: 'tool-output'; id: string; delta: string }
| { type: 'tool-end'; id: string; success: boolean; summary: string }
| { type: 'task-start'; id: string; name: string; description: string }
| { type: 'task-progress'; id: string; line: string }
| {
type: 'task-end';
id: string;
tools: number;
seconds: number;
tokens: string;
}
| { type: 'done' };
export type HistoryItem =
| {
kind: 'user';
id: string;
text: string;
promptId?: string;
sentToModel?: boolean;
}
| { kind: 'thinking'; id: string; text: string; done: boolean }
| { kind: 'assistant'; id: string; text: string; streaming: boolean }
| {
kind: 'tool';
id: string;
tool: string;
title: string;
output: string;
done: boolean;
success?: boolean;
summary?: string;
}
| {
kind: 'task';
id: string;
name: string;
description: string;
progress: string[];
done: boolean;
stats?: string;
};
/**
* Fold state of the streamed history. Named distinctly from the UI
* lifecycle enum `StreamingState` (ui/types.ts) — this one tracks history
* items, that one tracks Idle/Responding/WaitingForConfirmation.
*/
export interface StreamingModelState {
readonly items: readonly HistoryItem[];
readonly streaming: boolean;
readonly done: boolean;
/** Bookkeeping: id source for items whose events carry no id. */
readonly nextSeq: number;
}
export const initialStreamingModelState: StreamingModelState = {
items: [],
streaming: false,
done: false,
nextSeq: 0,
};
const TASK_PROGRESS_TAIL = 3;
function findItemIndex(
items: readonly HistoryItem[],
kind: 'tool' | 'task',
id: string,
): number {
return items.findIndex((item) => item.kind === kind && item.id === id);
}
/**
* Pure reducer: apply one stream event to the history state.
* Never mutates the input state.
*/
export function reduceStreamEvent(
state: StreamingModelState,
event: StreamEvent,
): StreamingModelState {
const items = [...state.items];
const last = items[items.length - 1];
let nextSeq = state.nextSeq;
const closeTrailingAssistant = () => {
if (last?.kind === 'assistant' && last.streaming) {
items[items.length - 1] = { ...last, streaming: false };
}
};
switch (event.type) {
case 'user': {
closeTrailingAssistant();
items.push({
kind: 'user',
id: `user-${nextSeq++}`,
text: event.text,
promptId: event.promptId,
sentToModel: event.sentToModel,
});
break;
}
case 'thinking': {
if (last?.kind === 'thinking' && !last.done) {
items[items.length - 1] = { ...last, text: last.text + event.delta };
} else {
items.push({
kind: 'thinking',
id: `thinking-${nextSeq++}`,
text: event.delta,
done: false,
});
}
break;
}
case 'thinking-end': {
if (last?.kind === 'thinking') {
items[items.length - 1] = { ...last, done: true };
}
break;
}
case 'text': {
if (last?.kind === 'assistant' && last.streaming) {
items[items.length - 1] = { ...last, text: last.text + event.delta };
} else {
items.push({
kind: 'assistant',
id: `assistant-${nextSeq++}`,
text: event.delta,
streaming: true,
});
}
break;
}
case 'tool-start': {
closeTrailingAssistant();
const fresh: HistoryItem = {
kind: 'tool',
id: event.id,
tool: event.tool,
title: event.title,
output: '',
done: false,
};
// A retry/reconnect can replay a start for an id that already exists;
// reset that item instead of pushing a duplicate that later events
// (which match the first id hit) would leave stranded at done:false.
const index = findItemIndex(items, 'tool', event.id);
if (index >= 0) {
items[index] = fresh;
} else {
items.push(fresh);
}
break;
}
case 'tool-output': {
const index = findItemIndex(items, 'tool', event.id);
if (index >= 0) {
const tool = items[index] as Extract<HistoryItem, { kind: 'tool' }>;
items[index] = { ...tool, output: tool.output + event.delta };
}
break;
}
case 'tool-end': {
const index = findItemIndex(items, 'tool', event.id);
if (index >= 0) {
const tool = items[index] as Extract<HistoryItem, { kind: 'tool' }>;
items[index] = {
...tool,
done: true,
success: event.success,
summary: event.summary,
};
}
break;
}
case 'task-start': {
closeTrailingAssistant();
const fresh: HistoryItem = {
kind: 'task',
id: event.id,
name: event.name,
description: event.description,
progress: [],
done: false,
};
// Replayed-start reset; see the tool-start case for the reasoning.
const index = findItemIndex(items, 'task', event.id);
if (index >= 0) {
items[index] = fresh;
} else {
items.push(fresh);
}
break;
}
case 'task-progress': {
const index = findItemIndex(items, 'task', event.id);
if (index >= 0) {
const task = items[index] as Extract<HistoryItem, { kind: 'task' }>;
items[index] = {
...task,
progress: [
...task.progress.slice(-(TASK_PROGRESS_TAIL - 1)),
event.line,
],
};
}
break;
}
case 'task-end': {
const index = findItemIndex(items, 'task', event.id);
if (index >= 0) {
const task = items[index] as Extract<HistoryItem, { kind: 'task' }>;
items[index] = {
...task,
done: true,
stats: `${event.tools} tools · ${event.seconds}s · ${event.tokens} tokens`,
};
}
break;
}
case 'done': {
closeTrailingAssistant();
break;
}
default: {
// Exhaustiveness guard: a new StreamEvent variant without a reducer
// case must fail compilation, not silently drop from history.
const unhandled: never = event;
throw new Error(
`unhandled stream event: ${(unhandled as { type: string }).type}`,
);
}
}
return {
items,
streaming: event.type !== 'done',
done: event.type === 'done',
nextSeq,
};
}
/** Fold a sequence of events over a starting state. */
export function reduceStreamEvents(
state: StreamingModelState,
events: readonly StreamEvent[],
): StreamingModelState {
return events.reduce(reduceStreamEvent, state);
}
export function selectItems(
state: StreamingModelState,
): readonly HistoryItem[] {
return state.items;
}
export function selectIsStreaming(state: StreamingModelState): boolean {
return state.streaming;
}
export function selectIsDone(state: StreamingModelState): boolean {
return state.done;
}
export function selectItemById(
state: StreamingModelState,
id: string,
): HistoryItem | undefined {
return state.items.find((item) => item.id === id);
}
+502
View File
@@ -0,0 +1,502 @@
#!/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();
}
@@ -0,0 +1,758 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
bannedFamily,
checkRule,
findImports,
listSourceFiles,
symlinkedPathComponents,
} from '../check-tui-dep-direction.mjs';
const temporaryDirectories = [];
function makeTemporaryDirectory() {
const directory = mkdtempSync(join(tmpdir(), 'tui-dep-direction-'));
temporaryDirectories.push(directory);
return directory;
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe('bannedFamily', () => {
it.each([
['ink', 'ink'],
['ink/patch-console', 'ink'],
['ink-gradient', 'ink'],
['@inkjs/ui', 'ink'],
['react', 'react'],
['react/jsx-runtime', 'react'],
['react-dom', 'react'],
['solid-js', 'solid'],
['solid-js/web', 'solid'],
['solid-app-router', 'solid'],
['@solidjs/router', 'solid'],
['@solid-primitives/utils', 'solid'],
['@solid/meta', 'solid'],
['@react-aria/button', 'react'],
['@react-spring/web', 'react'],
['@opentui/core', '@opentui'],
['@opentui', '@opentui'],
])('bans %s as the %s family', (spec, family) => {
expect(bannedFamily(spec)).toBe(family);
});
it.each([
'inkwell',
'inkjet',
'reactivedb',
'solidity-parser',
'openai',
'@open-telemetry/api',
'./local.js',
'@qwen-code/qwen-code-core',
])('allows %s', (spec) => {
expect(bannedFamily(spec)).toBeNull();
});
});
describe('findImports', () => {
const specs = (source, fileName) =>
findImports(source, fileName).map((imp) => `${imp.kind}:${imp.spec}`);
it('detects every import shape a module can use', () => {
expect(
specs(
[
"import { render } from 'ink';",
"import type { ReactNode } from 'react';",
"import 'solid-js/web';",
"export * as inkUtils from 'ink-testing-library';",
"export type { Props } from '@opentui/core';",
"export { Box } from 'ink';",
"const lazy = await import('@solidjs/router');",
"const legacy = require('react-dom');",
"vi.mock('ink-spinner');",
].join('\n'),
),
).toEqual([
'import:ink',
'import:react',
'import:solid-js/web',
'export-from:ink-testing-library',
'export-from:@opentui/core',
'export-from:ink',
'dynamic-import:@solidjs/router',
'require:react-dom',
'vi.mock:ink-spinner',
]);
});
it('ignores import-shaped text inside comments and strings', () => {
expect(
specs(
[
"// import { render } from 'ink';",
"/* require('react') */",
"* header note mentioning import from 'ink'",
'const fixture = "import { Box } from \'ink\';";',
'const template = `require("react")`;',
].join('\n'),
),
).toEqual([]);
});
it('survives a regex literal full of quote characters', () => {
// The regex-masked lexer desynced here and masked the real import.
const source = [
'const re = /[\'"](ink|react)[\'"]/g;',
"import { render } from 'ink';",
].join('\n');
expect(specs(source)).toEqual(['import:ink']);
});
it('finds a dynamic import inside template-literal interpolation', () => {
const source = ["const loaded = `${await import('@opentui/core')}`;"].join(
'\n',
);
expect(specs(source)).toEqual(['dynamic-import:@opentui/core']);
});
it('detects import-type queries (type X = import("...").Y)', () => {
expect(
specs(
[
'type Node = import("react").ReactNode;',
"type Box = import('ink').Box['props'];",
'type Core = import(`@opentui/core`).TuiApp;',
].join('\n'),
),
).toEqual([
'import-type:react',
'import-type:ink',
'import-type:@opentui/core',
]);
});
it('accepts interpolation-free template literals as call specifiers', () => {
expect(
specs(
[
'const lazy = await import(`@solidjs/router`);',
'const legacy = require(`react-dom`);',
'vi.mock(`ink-spinner`);',
].join('\n'),
),
).toEqual([
'dynamic-import:@solidjs/router',
'require:react-dom',
'vi.mock:ink-spinner',
]);
});
it('still ignores interpolated specifiers (not statically knowable)', () => {
const source = [
'const mod = await import(`react${suffix}`);',
'const legacy = require(`${name}/ink`);',
'vi.mock(`ink-${variant}`);',
].join('\n');
expect(specs(source)).toEqual([]);
});
it('detects import-equals forms (import x = require("..."))', () => {
expect(
specs(
[
"import ink = require('ink');",
"export import reactDom = require('react-dom');",
'import wrapped = require(`solid-js`);',
].join('\n'),
),
).toEqual([
'import-equals:ink',
'import-equals:react-dom',
'import-equals:solid-js',
]);
});
it('ignores namespace import-equals (no module specifier)', () => {
expect(specs('import ns = Some.Namespace;\n')).toEqual([]);
});
it('detects module-resolution probes that name a framework', () => {
expect(
specs(
[
"const p = require.resolve('ink');",
"const m = import.meta.resolve('react');",
].join('\n'),
),
).toEqual(['require.resolve:ink', 'import.meta.resolve:react']);
});
it('detects the vi module-loading family', () => {
expect(
specs(
[
"vi.mock('ink');",
"vi.doMock('react');",
"await vi.importActual('solid-js');",
"await vi.importMock('@opentui/core');",
].join('\n'),
),
).toEqual([
'vi.mock:ink',
'vi.doMock:react',
'vi.importActual:solid-js',
'vi.importMock:@opentui/core',
]);
});
it('detects CommonJS require indirections', () => {
expect(
specs(
[
"const ink = module.require('ink');",
"const react = require.main.require('react');",
].join('\n'),
),
).toEqual(['module.require:ink', 'require.main.require:react']);
});
it('detects ambient module declarations', () => {
expect(
specs("declare module 'react' { export type X = number; }\n"),
).toEqual(['ambient-module:react']);
expect(specs('declare global { const g: number; }\n')).toEqual([]);
});
it('reports line numbers matching the source', () => {
const source = '\n\n' + "import { Box } from 'ink';";
expect(findImports(source)[0].line).toBe(3);
});
});
describe('checkRule', () => {
function writeSource(root, relativePath, source) {
const filePath = join(root, relativePath);
mkdirSync(join(filePath, '..'), { recursive: true });
writeFileSync(filePath, source);
return filePath;
}
it('flags banned imports and reports clean trees', () => {
const root = makeTemporaryDirectory();
writeSource(root, 'dirty.ts', "import { render } from 'ink';\n");
writeSource(root, 'nested/clean.ts', "import { z } from 'zod';\n");
const dirty = checkRule({
label: 'dirty',
root,
rules: { noFramework: true },
});
expect(dirty.violations).toHaveLength(1);
expect(dirty.violations[0]).toContain("import 'ink'");
expect(dirty.violations[0]).toContain('dirty.ts:1');
rmSync(join(root, 'dirty.ts'));
const clean = checkRule({
label: 'clean',
root,
rules: { noFramework: true },
});
expect(clean.violations).toEqual([]);
expect(clean.scanned).toBe(1);
});
it('scans .mts and .cts sources the enumeration layer covers', () => {
const root = makeTemporaryDirectory();
writeSource(root, 'probe.mts', "import { render } from 'ink';\n");
writeSource(root, 'helper.cts', "const react = require('react');\n");
const result = checkRule({
label: 'mts',
root,
rules: { noFramework: true },
});
expect(result.violations).toHaveLength(2);
});
it('flags relative imports escaping a self-contained root', () => {
const root = makeTemporaryDirectory();
writeSource(
root,
'model/inner.ts',
"import { helper } from '../helper.js';\n",
);
writeSource(root, 'helper.ts', 'export const helper = 1;\n');
const result = checkRule({
label: 'self-contained',
root: join(root, 'model'),
rules: { selfContained: true },
});
expect(result.violations).toHaveLength(1);
expect(result.violations[0]).toContain('escapes the framework-neutral');
const contained = checkRule({
label: 'self-contained',
root,
rules: { selfContained: true },
});
expect(contained.violations).toEqual([]);
});
it('fails closed on symlinks inside the rule root instead of following them', () => {
const root = makeTemporaryDirectory();
const scanRoot = join(root, 'scanned');
mkdirSync(scanRoot);
const target = join(scanRoot, 'real');
writeSource(root, 'scanned/real/leak.ts', "import { Box } from 'ink';\n");
writeSource(root, 'scanned/clean.ts', "import { z } from 'zod';\n");
symlinkSync(target, join(scanRoot, 'linked-dir'), 'dir');
symlinkSync(
join(target, 'leak.ts'),
join(scanRoot, 'linked-file.ts'),
'file',
);
const result = checkRule({
label: 'symlinks',
root: scanRoot,
rules: { noFramework: true },
});
// leak.ts is scanned once as a real file; both symlinks fail the gate
// and are never followed, so no link can mask or fake an import.
expect(result.violations).toHaveLength(1);
expect(result.symlinks).toHaveLength(2);
});
it('fails closed when a symlink target escapes the rule root', () => {
const root = makeTemporaryDirectory();
writeSource(root, 'outside/config/settings.ts', 'export const x = 1;\n');
writeSource(root, 'outside/sneaky.ts', "import { render } from 'ink';\n");
const scanRoot = join(root, 'ui-model');
mkdirSync(scanRoot);
writeSource(root, 'ui-model/clean.ts', "import { z } from 'zod';\n");
// A file served from outside the rule root whose lexical relative
// imports would resolve inside it; the link must fail the gate, not be
// scanned.
symlinkSync(
join(root, 'outside', 'sneaky.ts'),
join(scanRoot, 'dialog-scope.ts'),
'file',
);
symlinkSync(
join(root, 'outside', 'config'),
join(scanRoot, 'config-dir'),
'dir',
);
const result = checkRule({
label: 'escaped',
root: scanRoot,
rules: { noFramework: true, selfContained: true },
});
expect(result.symlinks).toHaveLength(2);
expect(result.symlinks.join(' ')).toContain('dialog-scope.ts');
expect(result.symlinks.join(' ')).toContain('config-dir');
// Symlinked entries are not scanned, so they cannot fake a clean result.
expect(result.scanned).toBe(1);
expect(result.violations).toEqual([]);
});
it('fails closed on a link whose lexical path masks a physical escape', () => {
// Reviewer's end-to-end witness: deep/deeper/link.ts -> dist/target.ts,
// where the physical target imports ../../../cli/secret.js. Lexical
// resolution from the link stays inside the root (and dist/ is skipped),
// so only the fail-closed link diagnostic prevents a false PASS.
const root = makeTemporaryDirectory();
writeSource(root, 'dist/target.ts', "import '../../../cli/secret.js';\n");
writeSource(root, 'deep/deeper/keep.ts', "import { z } from 'zod';\n");
symlinkSync(
join(root, 'dist', 'target.ts'),
join(root, 'deep', 'deeper', 'link.ts'),
'file',
);
const result = checkRule({
label: 'masked-escape',
root,
rules: { noFramework: true, selfContained: true },
});
expect(result.violations).toEqual([]);
expect(result.symlinks).toHaveLength(1);
expect(result.symlinks[0]).toContain('link.ts');
});
it('flags a bare import of the cli package under noRelativeIntoCli', () => {
const root = makeTemporaryDirectory();
writeSource(
root,
'core/a.ts',
"import { run } from '@qwen-code/qwen-code';\n",
);
writeSource(root, 'core/b.ts', "import { z } from 'zod';\n");
const flagged = checkRule({
label: 'bare-cli',
root,
rules: { noRelativeIntoCli: true },
});
expect(flagged.violations).toHaveLength(1);
expect(flagged.violations[0]).toContain(
'(bare import reaches into packages/cli)',
);
const otherRule = checkRule({
label: 'bare-cli-other-rules',
root,
rules: { noFramework: true, selfContained: true },
});
expect(otherRule.violations).toEqual([]);
});
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
'reports unlistable directories instead of silently dropping them',
() => {
const root = makeTemporaryDirectory();
const blocked = join(root, 'blocked');
writeSource(root, 'blocked/hidden.ts', "import 'ink';\n");
chmodSync(blocked, 0o000);
const result = checkRule({
label: 'unreadable',
root,
rules: { noFramework: true },
});
chmodSync(blocked, 0o700);
expect(result.unreadableDirs.length).toBeGreaterThan(0);
expect(result.unreadableDirs.join(' ')).toContain('blocked');
},
);
});
describe('listSourceFiles', () => {
it('reports symlinked aliases instead of following them', () => {
const root = makeTemporaryDirectory();
mkdirSync(join(root, 'real'));
writeFileSync(join(root, 'real', 'a.ts'), "import { z } from 'zod';\n");
symlinkSync(join(root, 'real'), join(root, 'alias-one'), 'dir');
symlinkSync(join(root, 'real'), join(root, 'alias-two'), 'dir');
const { files, symlinks, unreadableDirs } = listSourceFiles(root);
expect(files).toHaveLength(1);
expect(symlinks).toHaveLength(2);
expect(unreadableDirs).toEqual([]);
});
});
describe('symlinkedPathComponents', () => {
it('returns [] for an ordinary path', () => {
const anchor = makeTemporaryDirectory();
const root = join(anchor, 'packages', 'cli', 'src', 'ui', 'model');
mkdirSync(root, { recursive: true });
expect(symlinkedPathComponents(root, anchor)).toEqual([]);
});
it('rejects a symlinked rule root (substituted scan)', () => {
const anchor = makeTemporaryDirectory();
mkdirSync(join(anchor, 'clean-elsewhere'));
symlinkSync(
join(anchor, 'clean-elsewhere'),
join(anchor, 'ui-model'),
'dir',
);
expect(symlinkedPathComponents(join(anchor, 'ui-model'), anchor)).toEqual([
join(anchor, 'ui-model'),
]);
});
it('rejects a symlinked ancestor component (reviewer bypass)', () => {
const anchor = makeTemporaryDirectory();
mkdirSync(join(anchor, 'real-src', 'ui', 'model'), { recursive: true });
symlinkSync(join(anchor, 'real-src'), join(anchor, 'src'), 'dir');
expect(
symlinkedPathComponents(join(anchor, 'src', 'ui', 'model'), anchor),
).toEqual([join(anchor, 'src')]);
});
it('stops at a missing component without throwing', () => {
const anchor = makeTemporaryDirectory();
expect(
symlinkedPathComponents(join(anchor, 'nope', 'model'), anchor),
).toEqual([]);
});
});
describe('end-to-end gate run (main wiring)', () => {
// Regression guard for the root-path check wired into main(): the helper
// is unit-tested above, but nothing but a full run proves main() actually
// invokes it before trusting the scan. Build a fixture checkout inside the
// (git-ignored) .qwen dir so the copied script resolves `typescript` from
// the real node_modules, run it as a subprocess, and assert exit codes.
const gatePath = fileURLToPath(
new URL('../check-tui-dep-direction.mjs', import.meta.url),
);
const repoRoot = fileURLToPath(new URL('../..', import.meta.url));
function makeFixtureCheckout() {
mkdirSync(join(repoRoot, '.qwen'), { recursive: true });
const base = mkdtempSync(join(repoRoot, '.qwen', 'gate-e2e-'));
temporaryDirectories.push(base);
mkdirSync(join(base, 'scripts'));
mkdirSync(join(base, 'packages', 'core', 'src'), { recursive: true });
mkdirSync(join(base, 'packages', 'cli', 'src', 'ui', 'model'), {
recursive: true,
});
copyFileSync(
gatePath,
join(base, 'scripts', 'check-tui-dep-direction.mjs'),
);
// The gate reads the cli package's own name from its manifest to block
// bare imports of it, so the fixture must carry one.
writeFileSync(
join(base, 'packages', 'cli', 'package.json'),
JSON.stringify({ name: '@qwen-code/qwen-code' }),
);
writeFileSync(
join(base, 'packages', 'core', 'src', 'clean.ts'),
"import { z } from 'zod';\n",
);
writeFileSync(
join(base, 'packages', 'cli', 'src', 'ui', 'model', 'model.ts'),
"import { z } from 'zod';\n",
);
return base;
}
function runGate(base) {
try {
const out = execFileSync(
process.execPath,
[join(base, 'scripts', 'check-tui-dep-direction.mjs')],
{ encoding: 'utf8', timeout: 30000 },
);
return { code: 0, out };
} catch (error) {
return {
code: error.status,
out: `${error.stdout ?? ''}${error.stderr ?? ''}`,
};
}
}
it('passes a clean fixture checkout', () => {
const base = makeFixtureCheckout();
const { code, out } = runGate(base);
expect(code).toBe(0);
expect(out).toContain('PASS — dependency direction holds.');
}, 40000);
it('does not run main when the module is imported, not executed', () => {
const base = makeFixtureCheckout();
// The entry guard must suppress the scan for library importers; if it
// fired, the gate report would appear before the sentinel.
const out = execFileSync(
process.execPath,
[
'--input-type=module',
'-e',
`await import('file://${join(base, 'scripts', 'check-tui-dep-direction.mjs')}'); process.stdout.write('imported-only');`,
],
{ encoding: 'utf8', timeout: 30000 },
);
expect(out).toBe('imported-only');
}, 40000);
it('fails when a rule root is itself a symlink', () => {
const base = makeFixtureCheckout();
const model = join(base, 'packages', 'cli', 'src', 'ui', 'model');
rmSync(model, { recursive: true, force: true });
mkdirSync(join(base, 'substitute-model'));
writeFileSync(
join(base, 'substitute-model', 'witness.ts'),
"import { render } from 'ink';\n",
);
symlinkSync(join(base, 'substitute-model'), model, 'dir');
const { code, out } = runGate(base);
expect(code).toBe(1);
expect(out).toContain('error: symlink in rule root path');
// The redirected target must be rejected before enumeration: its
// witness file is never parsed, so no violation from it can appear.
expect(out).not.toContain("import 'ink'");
}, 40000);
it('fails a symlinked root even when the target is empty', () => {
const base = makeFixtureCheckout();
const model = join(base, 'packages', 'cli', 'src', 'ui', 'model');
rmSync(model, { recursive: true, force: true });
mkdirSync(join(base, 'empty-substitute'));
symlinkSync(join(base, 'empty-substitute'), model, 'dir');
const { code, out } = runGate(base);
expect(code).toBe(1);
expect(out).toContain('error: symlink in rule root path');
expect(out).not.toContain('not found or has no source files');
}, 40000);
it('fails when a rule-root ancestor is a symlink', () => {
const base = makeFixtureCheckout();
const uiDir = join(base, 'packages', 'cli', 'src', 'ui');
renameSync(uiDir, join(base, 'packages', 'cli', 'src', 'ui-real'));
symlinkSync(join(base, 'packages', 'cli', 'src', 'ui-real'), uiDir, 'dir');
const { code, out } = runGate(base);
expect(code).toBe(1);
expect(out).toContain('error: symlink in rule root path');
}, 40000);
it('fails on a banned import in the scanned tree', () => {
const base = makeFixtureCheckout();
writeFileSync(
join(base, 'packages', 'core', 'src', 'leak.ts'),
"import { render } from 'ink';\n",
);
// .js is the only enrolled extension with live population under a rule
// root, so pin its membership with a planted violation too.
writeFileSync(
join(base, 'packages', 'core', 'src', 'leak.js'),
"import { useState } from 'react';\n",
);
const { code, out } = runGate(base);
expect(code).toBe(1);
expect(out).toContain("import 'ink'");
expect(out).toContain("import 'react'");
expect(out).toContain('FAIL — dependency-direction violations found.');
}, 40000);
it('fails on a bare import of the cli package name', () => {
const base = makeFixtureCheckout();
writeFileSync(
join(base, 'packages', 'core', 'src', 'leak.ts'),
"import { run } from '@qwen-code/qwen-code';\n",
);
const { code, out } = runGate(base);
expect(code).toBe(1);
expect(out).toContain('(bare import reaches into packages/cli)');
}, 40000);
it('fails on a relative import that reaches into packages/cli', () => {
const base = makeFixtureCheckout();
writeFileSync(
join(base, 'packages', 'core', 'src', 'leak.ts'),
"import { helper } from '../../cli/src/helper.js';\n",
);
const { code, out } = runGate(base);
expect(code).toBe(1);
expect(out).toContain('(relative import reaches into packages/cli)');
}, 40000);
it('fails on violations in the ui/model rule arm', () => {
const base = makeFixtureCheckout();
writeFileSync(
join(base, 'packages', 'cli', 'src', 'ui', 'model', 'leak.ts'),
["import { render } from 'ink';", "import '../outside.js';"].join('\n'),
);
const { code, out } = runGate(base);
expect(code).toBe(1);
// Pins the second checkRule wiring (rules object + enumeration): the
// noFramework and selfContained arms of Rule 2 must both fire.
expect(out).toContain('(ink import in framework-neutral code)');
expect(out).toContain(
'(relative import escapes the framework-neutral directory)',
);
}, 40000);
it('fails when the core rule root is itself a symlink', () => {
const base = makeFixtureCheckout();
const coreSrc = join(base, 'packages', 'core', 'src');
rmSync(coreSrc, { recursive: true, force: true });
mkdirSync(join(base, 'substitute-core'));
writeFileSync(
join(base, 'substitute-core', 'clean.ts'),
"import { z } from 'zod';\n",
);
symlinkSync(join(base, 'substitute-core'), coreSrc, 'dir');
const { code, out } = runGate(base);
expect(code).toBe(1);
expect(out).toContain('error: symlink in rule root path');
}, 40000);
it('fails when the ui/model rule root is empty', () => {
const base = makeFixtureCheckout();
rmSync(join(base, 'packages', 'cli', 'src', 'ui', 'model', 'model.ts'));
const { code, out } = runGate(base);
expect(code).toBe(1);
expect(out).toContain('packages/cli/src/ui/model');
expect(out).toContain('not found or has no source files');
}, 40000);
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
'fails on an unlistable directory inside the scanned tree',
() => {
const base = makeFixtureCheckout();
const blocked = join(base, 'packages', 'core', 'src', 'blocked');
mkdirSync(blocked);
writeFileSync(join(blocked, 'hidden.ts'), "import 'ink';\n");
chmodSync(blocked, 0o000);
const { code, out } = runGate(base);
chmodSync(blocked, 0o700);
expect(code).toBe(1);
expect(out).toContain('error: could not list directory');
},
40000,
);
it('fails on a skippable directory name inside the scanned tree', () => {
const base = makeFixtureCheckout();
mkdirSync(join(base, 'packages', 'core', 'src', 'node_modules'));
const { code, out } = runGate(base);
expect(code).toBe(1);
expect(out).toContain('error: skippable directory inside scanned tree');
}, 40000);
it('fails on a symlink inside the scanned tree', () => {
const base = makeFixtureCheckout();
symlinkSync(
join(base, 'packages', 'core', 'src', 'clean.ts'),
join(base, 'packages', 'core', 'src', 'alias.ts'),
'file',
);
const { code, out } = runGate(base);
expect(code).toBe(1);
expect(out).toContain('error: symlink in scanned tree (not followed)');
}, 40000);
it('fails when a rule root has no source files', () => {
const base = makeFixtureCheckout();
rmSync(join(base, 'packages', 'core', 'src'), {
recursive: true,
force: true,
});
const { code, out } = runGate(base);
expect(code).toBe(1);
expect(out).toContain('not found or has no source files');
}, 40000);
});