@cortexkit/opencode-magic-context 0.34.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +20 -3
  2. package/dist/config/migrate-experimental.d.ts +2 -1
  3. package/dist/config/migrate-experimental.d.ts.map +1 -1
  4. package/dist/config/project-security.d.ts +2 -0
  5. package/dist/config/project-security.d.ts.map +1 -1
  6. package/dist/config/schema/magic-context.d.ts +8 -15
  7. package/dist/config/schema/magic-context.d.ts.map +1 -1
  8. package/dist/features/magic-context/dreamer/classify.d.ts +1 -0
  9. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/map-memories.d.ts +1 -0
  11. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  12. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +1 -0
  13. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  14. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts +2 -0
  15. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts.map +1 -1
  16. package/dist/features/magic-context/dreamer/task-executor.d.ts +4 -1
  17. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/task-gates.d.ts +19 -1
  19. package/dist/features/magic-context/dreamer/task-gates.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/task-registry.d.ts +28 -0
  21. package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +7 -1
  23. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  24. package/dist/features/magic-context/dreamer/verify.d.ts +1 -0
  25. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  26. package/dist/features/magic-context/migrations.d.ts +23 -1
  27. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  28. package/dist/features/magic-context/mural/compress-cues.d.ts +1 -0
  29. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  30. package/dist/features/magic-context/storage-db.d.ts +3 -2
  31. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  32. package/dist/features/magic-context/storage.d.ts +1 -1
  33. package/dist/features/magic-context/storage.d.ts.map +1 -1
  34. package/dist/hooks/magic-context/command-handler.d.ts +1 -0
  35. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  36. package/dist/hooks/magic-context/execute-status.d.ts +5 -1
  37. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  38. package/dist/hooks/magic-context/hook.d.ts +3 -5
  39. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  40. package/dist/hooks/magic-context/inject-compartments.d.ts +1 -1
  41. package/dist/hooks/magic-context/live-session-state.d.ts +3 -0
  42. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +1 -1
  44. package/dist/hooks/magic-context/transform.d.ts +2 -2
  45. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  46. package/dist/{index-56yjaeb2.js → index-2y0ybv5x.js} +11 -1
  47. package/dist/{index-s0mdn690.js → index-x8spt5wy.js} +110 -28
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +389 -104
  50. package/dist/plugin/dream-timer.d.ts +4 -2
  51. package/dist/plugin/dream-timer.d.ts.map +1 -1
  52. package/dist/plugin/hooks/create-session-hooks.d.ts +1 -1
  53. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  54. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  55. package/dist/{recomp-orchestrator-8e82vmvb.js → recomp-orchestrator-k72ejkkh.js} +1 -1
  56. package/dist/shared/rpc-types.d.ts +5 -0
  57. package/dist/shared/rpc-types.d.ts.map +1 -1
  58. package/dist/shared/rpc-utils.d.ts +19 -0
  59. package/dist/shared/rpc-utils.d.ts.map +1 -1
  60. package/dist/shared/tui-runtime-specifiers.d.ts +20 -0
  61. package/dist/shared/tui-runtime-specifiers.d.ts.map +1 -0
  62. package/dist/{task-config-5nx3qfz8.js → task-config-gbp7r23g.js} +1 -1
  63. package/package.json +2 -2
  64. package/src/config/index.test.ts +32 -0
  65. package/src/config/migrate-experimental.test.ts +41 -0
  66. package/src/config/migrate-experimental.ts +36 -10
  67. package/src/config/project-security.test.ts +22 -0
  68. package/src/config/project-security.ts +23 -0
  69. package/src/config/schema/magic-context.test.ts +2 -1
  70. package/src/config/schema/magic-context.ts +12 -23
  71. package/src/features/magic-context/dreamer/task-registry.ts +47 -0
  72. package/src/shared/rpc-types.ts +9 -0
  73. package/src/shared/rpc-utils.test.ts +157 -0
  74. package/src/shared/rpc-utils.ts +137 -0
  75. package/src/shared/tui-runtime-specifiers.ts +32 -0
  76. package/src/tui/slots/sidebar-content.tsx +50 -8
  77. package/src/tui/tui-compiled-runtime-imports.test.ts +151 -0
  78. package/src/tui-compiled/slots/sidebar-content.tsx +234 -145
@@ -0,0 +1,157 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ import { afterEach, describe, expect, test } from "bun:test";
4
+ import type { execFileSync } from "node:child_process";
5
+ import type { readFileSync } from "node:fs";
6
+ import {
7
+ __resetRpcIdentityTestHooks,
8
+ __setRpcIdentityTestHooks,
9
+ isPidIdentityPlausible,
10
+ type RpcPortFileRecord,
11
+ } from "./rpc-utils";
12
+
13
+ const PID = 1234;
14
+ const NOW_MS = 2_000_000;
15
+ const UPTIME_SECONDS = 1_000;
16
+
17
+ function record(startedAt: number): RpcPortFileRecord {
18
+ return { port: 43123, pid: PID, started_at: startedAt };
19
+ }
20
+
21
+ function procStat(startTimeTicks: number): string {
22
+ // After the closing command-name parenthesis, field 3 is `state` and field
23
+ // 22 is the twentieth value in the suffix.
24
+ return `${PID} (opencode) S ${Array.from({ length: 18 }, () => "0").join(" ")} ${startTimeTicks}`;
25
+ }
26
+
27
+ function linuxFiles(files: Record<string, string | Error>): typeof readFileSync {
28
+ return ((path: string | URL) => {
29
+ const value = files[String(path)];
30
+ if (value instanceof Error) throw value;
31
+ if (value === undefined) throw new Error(`unexpected read: ${String(path)}`);
32
+ return value;
33
+ }) as typeof readFileSync;
34
+ }
35
+
36
+ function psOutput(output: string | Error): typeof execFileSync {
37
+ return (() => {
38
+ if (output instanceof Error) throw output;
39
+ return output;
40
+ }) as typeof execFileSync;
41
+ }
42
+
43
+ afterEach(() => {
44
+ __resetRpcIdentityTestHooks();
45
+ });
46
+
47
+ describe("isPidIdentityPlausible", () => {
48
+ test("rejects a reused Linux PID when proc start time is substantially newer", () => {
49
+ const readPaths: string[] = [];
50
+ __setRpcIdentityTestHooks({
51
+ platform: "linux",
52
+ nowMs: () => NOW_MS,
53
+ readFileSync: ((path: string | URL) => {
54
+ readPaths.push(String(path));
55
+ const files = {
56
+ [`/proc/${PID}/stat`]: procStat(10_000),
57
+ "/proc/uptime": `${UPTIME_SECONDS}.0 0.0`,
58
+ };
59
+ return files[String(path) as keyof typeof files];
60
+ }) as typeof readFileSync,
61
+ execFileSync: (() => {
62
+ throw new Error("ps must not run on Linux");
63
+ }) as typeof execFileSync,
64
+ });
65
+
66
+ expect(isPidIdentityPlausible(record(500_000))).toBe(false);
67
+ expect(readPaths).toEqual([`/proc/${PID}/stat`, "/proc/uptime"]);
68
+ });
69
+
70
+ test("accepts a genuine Linux record when the process started no later than the record", () => {
71
+ __setRpcIdentityTestHooks({
72
+ platform: "linux",
73
+ nowMs: () => NOW_MS,
74
+ readFileSync: linuxFiles({
75
+ [`/proc/${PID}/stat`]: procStat(10_000),
76
+ "/proc/uptime": `${UPTIME_SECONDS}.0 0.0`,
77
+ }),
78
+ });
79
+
80
+ // The mocked process start is 1,100,000ms. The 120s tolerance is part of
81
+ // the contract because port-file creation follows process startup.
82
+ expect(isPidIdentityPlausible(record(1_000_000))).toBe(true);
83
+ });
84
+
85
+ test("treats an unreadable Linux start-time probe as live", () => {
86
+ __setRpcIdentityTestHooks({
87
+ platform: "linux",
88
+ readFileSync: linuxFiles({
89
+ [`/proc/${PID}/stat`]: new Error("procfs unavailable"),
90
+ }),
91
+ });
92
+
93
+ expect(isPidIdentityPlausible(record(500_000))).toBe(true);
94
+ });
95
+
96
+ test("uses the legacy Linux command fallback and fails closed on probe errors", () => {
97
+ __setRpcIdentityTestHooks({
98
+ platform: "linux",
99
+ readFileSync: linuxFiles({
100
+ [`/proc/${PID}/cmdline`]: "/usr/sbin/opendkim --config /etc/opendkim.conf",
101
+ }),
102
+ });
103
+ expect(isPidIdentityPlausible(record(0))).toBe(false);
104
+
105
+ __setRpcIdentityTestHooks({
106
+ platform: "linux",
107
+ readFileSync: linuxFiles({
108
+ [`/proc/${PID}/cmdline`]: "/usr/local/bin/opencode serve",
109
+ }),
110
+ });
111
+ expect(isPidIdentityPlausible(record(0))).toBe(true);
112
+
113
+ __setRpcIdentityTestHooks({
114
+ platform: "linux",
115
+ readFileSync: linuxFiles({
116
+ [`/proc/${PID}/cmdline`]: new Error("procfs unavailable"),
117
+ }),
118
+ });
119
+ expect(isPidIdentityPlausible(record(0))).toBe(true);
120
+ });
121
+
122
+ test("uses ps start time and command probes on non-Linux platforms", () => {
123
+ __setRpcIdentityTestHooks({
124
+ platform: "darwin",
125
+ execFileSync: psOutput("Mon Aug 7 00:00:00 1970"),
126
+ });
127
+ expect(
128
+ isPidIdentityPlausible(record(Date.parse("Mon Aug 7 00:00:00 1970") - 121_000)),
129
+ ).toBe(false);
130
+
131
+ __setRpcIdentityTestHooks({
132
+ platform: "darwin",
133
+ execFileSync: psOutput("Mon Aug 7 00:00:00 1970"),
134
+ });
135
+ expect(
136
+ isPidIdentityPlausible(record(Date.parse("Mon Aug 7 00:00:00 1970") - 120_000)),
137
+ ).toBe(true);
138
+
139
+ __setRpcIdentityTestHooks({
140
+ platform: "darwin",
141
+ execFileSync: psOutput("/usr/sbin/opendkim -f"),
142
+ });
143
+ expect(isPidIdentityPlausible(record(0))).toBe(false);
144
+
145
+ __setRpcIdentityTestHooks({
146
+ platform: "darwin",
147
+ execFileSync: psOutput("/Applications/OpenCode.app/Contents/MacOS/opencode"),
148
+ });
149
+ expect(isPidIdentityPlausible(record(0))).toBe(true);
150
+
151
+ __setRpcIdentityTestHooks({
152
+ platform: "darwin",
153
+ execFileSync: psOutput(new Error("ps unavailable")),
154
+ });
155
+ expect(isPidIdentityPlausible(record(0))).toBe(true);
156
+ });
157
+ });
@@ -1,4 +1,6 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
2
4
  import { join } from "node:path";
3
5
 
4
6
  export interface RpcPortFileRecord {
@@ -58,6 +60,141 @@ export function isPidAlive(pid: number): boolean {
58
60
  }
59
61
  }
60
62
 
63
+ const RPC_IDENTITY_SKEW_TOLERANCE_MS = 120_000;
64
+ const LINUX_CLOCK_TICKS_PER_SECOND = 100;
65
+ const PS_PROBE_TIMEOUT_MS = 1_000;
66
+ const OPEN_CODE_COMMAND_MARKERS = ["opencode", "node", "bun", "electron"];
67
+
68
+ let rpcIdentityReadFileSync: typeof readFileSync = readFileSync;
69
+ let rpcIdentityExecFileSync: typeof execFileSync = execFileSync;
70
+ let rpcIdentityPlatform: NodeJS.Platform = process.platform;
71
+ let rpcIdentityNowMs: () => number = () => Date.now();
72
+
73
+ function parseLinuxProcessStartTime(statContent: string, uptimeContent: string): number | null {
74
+ const closingCommandName = statContent.lastIndexOf(")");
75
+ if (closingCommandName < 0) return null;
76
+
77
+ // The fields after the command name begin at field 3 (`state`), so field 22
78
+ // (`starttime`) is index 19 in this suffix. The command name can contain ')',
79
+ // hence the last closing parenthesis rather than the first one is significant.
80
+ const statFields = statContent
81
+ .slice(closingCommandName + 1)
82
+ .trim()
83
+ .split(/\s+/);
84
+ const startTimeTicks = Number(statFields[19]);
85
+ const uptimeSeconds = Number(uptimeContent.trim().split(/\s+/)[0]);
86
+ if (
87
+ !Number.isFinite(startTimeTicks) ||
88
+ startTimeTicks < 0 ||
89
+ !Number.isFinite(uptimeSeconds) ||
90
+ uptimeSeconds < 0
91
+ ) {
92
+ return null;
93
+ }
94
+
95
+ const processStartTime =
96
+ rpcIdentityNowMs() -
97
+ uptimeSeconds * 1_000 +
98
+ (startTimeTicks / LINUX_CLOCK_TICKS_PER_SECOND) * 1_000;
99
+ return Number.isFinite(processStartTime) ? processStartTime : null;
100
+ }
101
+
102
+ function readLinuxProcessStartTime(pid: number): number | null {
103
+ try {
104
+ const statContent = String(rpcIdentityReadFileSync(`/proc/${pid}/stat`, "utf8"));
105
+ const uptimeContent = String(rpcIdentityReadFileSync("/proc/uptime", "utf8"));
106
+ return parseLinuxProcessStartTime(statContent, uptimeContent);
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ function readPsProcessStartTime(pid: number): number | null {
113
+ try {
114
+ const output = rpcIdentityExecFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
115
+ encoding: "utf8",
116
+ timeout: PS_PROBE_TIMEOUT_MS,
117
+ });
118
+ const processStartTime = Date.parse(String(output).trim());
119
+ return Number.isFinite(processStartTime) ? processStartTime : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function readLinuxProcessCommand(pid: number): string | null {
126
+ try {
127
+ return String(rpcIdentityReadFileSync(`/proc/${pid}/cmdline`, "utf8"));
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function readPsProcessCommand(pid: number): string | null {
134
+ try {
135
+ const output = rpcIdentityExecFileSync("ps", ["-p", String(pid), "-o", "command="], {
136
+ encoding: "utf8",
137
+ timeout: PS_PROBE_TIMEOUT_MS,
138
+ });
139
+ return String(output);
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ function commandLooksLikeOpenCode(command: string): boolean {
146
+ const normalized = command.toLowerCase();
147
+ return OPEN_CODE_COMMAND_MARKERS.some((marker) => normalized.includes(marker));
148
+ }
149
+
150
+ /**
151
+ * Verify that a live PID still belongs to the process that wrote a port record.
152
+ *
153
+ * A PID can be reused after its original process exits. On Linux, procfs gives
154
+ * us a process start time without spawning a helper; other platforms use `ps`
155
+ * only on this cold database-open guard path. Legacy records without a start
156
+ * time use a weaker command-name check, and every probe failure stays live so
157
+ * this identity check cannot weaken the migration guard on uncertainty.
158
+ */
159
+ export function isPidIdentityPlausible(record: RpcPortFileRecord): boolean {
160
+ if (!Number.isInteger(record.pid) || record.pid <= 0) return false;
161
+
162
+ if (Number.isFinite(record.started_at) && record.started_at > 0) {
163
+ const processStartTime =
164
+ rpcIdentityPlatform === "linux"
165
+ ? readLinuxProcessStartTime(record.pid)
166
+ : readPsProcessStartTime(record.pid);
167
+ if (processStartTime === null) return true;
168
+ return processStartTime <= record.started_at + RPC_IDENTITY_SKEW_TOLERANCE_MS;
169
+ }
170
+
171
+ const command =
172
+ rpcIdentityPlatform === "linux"
173
+ ? readLinuxProcessCommand(record.pid)
174
+ : readPsProcessCommand(record.pid);
175
+ if (command === null) return true;
176
+ return commandLooksLikeOpenCode(command);
177
+ }
178
+
179
+ export function __setRpcIdentityTestHooks(hooks: {
180
+ readFileSync?: typeof readFileSync;
181
+ execFileSync?: typeof execFileSync;
182
+ platform?: NodeJS.Platform;
183
+ nowMs?: () => number;
184
+ }): void {
185
+ rpcIdentityReadFileSync = hooks.readFileSync ?? readFileSync;
186
+ rpcIdentityExecFileSync = hooks.execFileSync ?? execFileSync;
187
+ rpcIdentityPlatform = hooks.platform ?? process.platform;
188
+ rpcIdentityNowMs = hooks.nowMs ?? (() => Date.now());
189
+ }
190
+
191
+ export function __resetRpcIdentityTestHooks(): void {
192
+ rpcIdentityReadFileSync = readFileSync;
193
+ rpcIdentityExecFileSync = execFileSync;
194
+ rpcIdentityPlatform = process.platform;
195
+ rpcIdentityNowMs = () => Date.now();
196
+ }
197
+
61
198
  export function parseRpcPortFile(content: string, fallbackPid = 0): RpcPortFileRecord | null {
62
199
  const trimmed = content.trim();
63
200
  if (!trimmed) return null;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The module specifiers the compiled TUI is allowed to resolve through OpenCode's
3
+ * process-wide OpenTUI runtime registry (`opentui:runtime-module:<encoded>`).
4
+ *
5
+ * Single source of truth, shared by `scripts/build-tui.ts` (which rewrites these
6
+ * specifiers during the Solid transform) and
7
+ * `src/tui/tui-compiled-runtime-imports.test.ts` (which verifies every emitted
8
+ * specifier names a real export). Keeping one list means the guard test cannot
9
+ * drift into skipping a specifier the build actually rewrites — a skipped
10
+ * specifier is exactly the silent hole the test exists to close.
11
+ *
12
+ * Lives in `src/shared/` rather than `src/tui/` on purpose: `build-tui.ts` copies
13
+ * every file under `src/tui/` into the shipped `src/tui-compiled/` bundle, and
14
+ * this list is build/test tooling that the runtime bundle must not carry.
15
+ */
16
+ export const TUI_RUNTIME_SPECIFIERS = [
17
+ "@opentui/core",
18
+ "@opentui/core/testing",
19
+ "@opentui/solid",
20
+ "@opentui/solid/components",
21
+ "@opentui/solid/jsx-runtime",
22
+ "@opentui/solid/jsx-dev-runtime",
23
+ "solid-js",
24
+ "solid-js/store",
25
+ ] as const;
26
+
27
+ export type TuiRuntimeSpecifier = (typeof TUI_RUNTIME_SPECIFIERS)[number];
28
+
29
+ /** Virtual module id OpenCode registers for a runtime specifier. */
30
+ export function runtimeModuleId(specifier: string): string {
31
+ return `opentui:runtime-module:${encodeURIComponent(specifier)}`;
32
+ }
@@ -1,5 +1,5 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
2
+ import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
3
3
  import type { TuiSlotPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
4
4
  import packageJson from "../../../package.json"
5
5
  import { badgeTextColor } from '../badge-contrast';
@@ -763,6 +763,14 @@ const SidebarContent = (props: {
763
763
  </box>
764
764
  )}
765
765
 
766
+ {s()?.dreamerProgress && (
767
+ <box marginTop={1} width="100%">
768
+ <text fg={props.theme.warning}>
769
+ Dreamer {s()!.dreamerProgress!.task}: {s()!.dreamerProgress!.processed}/{s()!.dreamerProgress!.total} processed
770
+ </text>
771
+ </box>
772
+ )}
773
+
766
774
  {/* Token breakdown bar. In collapsed mode the header, bar and the
767
775
  3 summary rows stack with no vertical padding for a compact look;
768
776
  expanded mode keeps the 1-row gap above the bar. */}
@@ -817,6 +825,16 @@ const SidebarContent = (props: {
817
825
  <text fg={props.theme.textMuted}>idle</text>
818
826
  )}
819
827
  </box>
828
+ <Show when={s()?.dreamerProgress}>
829
+ {(progress) => (
830
+ <box width="100%" flexDirection="row" justifyContent="space-between">
831
+ <text fg={props.theme.textMuted}>Dreamer</text>
832
+ <text fg={props.theme.warning}>
833
+ {progress().task} {progress().processed}/{progress().total}
834
+ </text>
835
+ </box>
836
+ )}
837
+ </Show>
820
838
  <box width="100%" flexDirection="row" justifyContent="space-between">
821
839
  <text fg={props.theme.textMuted}>Memories</text>
822
840
  <text fg={props.theme.textMuted}>
@@ -953,15 +971,39 @@ const SidebarContent = (props: {
953
971
  )}
954
972
 
955
973
  {/* Dreamer */}
956
- {sections().dreamer && s()?.lastDreamerRunAt && (
974
+ {sections().dreamer && (s()?.lastDreamerRunAt || s()?.dreamerProgress) && (
957
975
  <>
958
976
  <SectionHeader theme={props.theme} title="Dreamer" />
959
- <StatRow
960
- theme={props.theme}
961
- label="Last run"
962
- value={relativeTime(s()!.lastDreamerRunAt!)}
963
- dim
964
- />
977
+ <Show when={s()?.dreamerProgress}>
978
+ {(progress) => (
979
+ <StatRow
980
+ theme={props.theme}
981
+ label="Current"
982
+ value={`${progress().task} ${progress().processed}/${progress().total}`}
983
+ warning
984
+ />
985
+ )}
986
+ </Show>
987
+ <Show when={s()?.lastDreamerRunAt}>
988
+ {(lastRunAt) => (
989
+ <StatRow
990
+ theme={props.theme}
991
+ label="Last run"
992
+ value={relativeTime(lastRunAt())}
993
+ dim
994
+ />
995
+ )}
996
+ </Show>
997
+ <For each={Object.entries(s()?.dreamerBacklog ?? {})}>
998
+ {([task, backlog]) => (
999
+ <StatRow
1000
+ theme={props.theme}
1001
+ label={task}
1002
+ value={`${backlog.pending}/${backlog.total}`}
1003
+ dim
1004
+ />
1005
+ )}
1006
+ </For>
965
1007
  </>
966
1008
  )}
967
1009
 
@@ -0,0 +1,151 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { TUI_RUNTIME_SPECIFIERS } from "../shared/tui-runtime-specifiers";
6
+
7
+ /**
8
+ * Every `opentui:runtime-module:*` import in the compiled TUI bundle must name an
9
+ * export the target module actually has.
10
+ *
11
+ * The Solid transform routes ALL of its emitted imports — renderer helpers and
12
+ * control-flow builtins (`For`, `Show`, `Index`, `Switch`, `Match`, ...) alike —
13
+ * through the single `moduleName` that `scripts/build-tui.ts` passes it, which is
14
+ * `@opentui/solid`. But `@opentui/solid` only re-exports its own renderer
15
+ * helpers; the builtins live in `solid-js`. So a bundle built without the
16
+ * redirect in `build-tui.ts` contains
17
+ *
18
+ * import { For as _$For } from "opentui:runtime-module:%40opentui%2Fsolid";
19
+ *
20
+ * and the host throws `Export named 'For' not found` while loading the plugin.
21
+ * OpenCode swallows that error, so the only visible symptom is a missing sidebar
22
+ * and missing /ctx-* commands — no crash and nothing on stderr.
23
+ *
24
+ * This test resolves each specifier against the real module export sets instead
25
+ * of a hardcoded list, so it keeps holding when OpenTUI changes which names it
26
+ * re-exports. It covers every specifier `build-tui.ts` rewrites (via the shared
27
+ * `TUI_RUNTIME_SPECIFIERS` list) and fails on any runtime module id outside that
28
+ * set, so a future import from e.g. `solid-js/store` cannot slip through
29
+ * unverified.
30
+ */
31
+ describe("compiled TUI runtime imports", () => {
32
+ const COMPILED_ROOT = join(import.meta.dir, "..", "tui-compiled");
33
+ const IMPORT_PATTERN = /import \{([^}]+)\} from "opentui:runtime-module:([^"]+)"/g;
34
+
35
+ function compiledFiles(dir: string): string[] {
36
+ const found: string[] = [];
37
+ for (const entry of readdirSync(dir)) {
38
+ const full = join(dir, entry);
39
+ if (statSync(full).isDirectory()) {
40
+ found.push(...compiledFiles(full));
41
+ } else if (entry.endsWith(".ts") || entry.endsWith(".tsx")) {
42
+ found.push(full);
43
+ }
44
+ }
45
+ return found;
46
+ }
47
+
48
+ /** Export sets for EVERY specifier build-tui.ts rewrites, keyed by module id.
49
+ * Built from the shared list so the test cannot cover fewer modules than the
50
+ * build rewrites. */
51
+ async function loadExportSets(): Promise<Record<string, Set<string>>> {
52
+ const entries = await Promise.all(
53
+ TUI_RUNTIME_SPECIFIERS.map(
54
+ async (specifier) =>
55
+ [specifier, new Set(Object.keys(await import(specifier)))] as const,
56
+ ),
57
+ );
58
+ return Object.fromEntries(entries);
59
+ }
60
+
61
+ test("every runtime specifier exists in the module it is imported from", async () => {
62
+ const exportSets = await loadExportSets();
63
+
64
+ const unresolved: string[] = [];
65
+ let checked = 0;
66
+
67
+ for (const file of compiledFiles(COMPILED_ROOT)) {
68
+ const source = readFileSync(file, "utf8");
69
+ for (const match of source.matchAll(IMPORT_PATTERN)) {
70
+ const moduleId = decodeURIComponent(match[2] ?? "");
71
+ const exports = exportSets[moduleId];
72
+ if (!exports) {
73
+ // A runtime module id outside the rewritten set means the build
74
+ // emitted something this guard does not know how to verify.
75
+ // Failing is the only safe answer: skipping it silently is the
76
+ // exact hole that let the `For` misroute ship.
77
+ unresolved.push(
78
+ `${file}: imports from unknown runtime module '${moduleId}' — ` +
79
+ "add it to TUI_RUNTIME_SPECIFIERS so it can be verified",
80
+ );
81
+ continue;
82
+ }
83
+
84
+ for (const specifier of (match[1] ?? "").split(",")) {
85
+ const importedName = specifier
86
+ .trim()
87
+ .split(/\s+as\s+/)[0]
88
+ ?.trim();
89
+ if (!importedName) continue;
90
+ checked += 1;
91
+ if (!exports.has(importedName)) {
92
+ unresolved.push(
93
+ `${file}: '${importedName}' is not exported by ${moduleId}`,
94
+ );
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ expect(checked).toBeGreaterThan(0);
101
+ expect(unresolved).toEqual([]);
102
+ });
103
+
104
+ test("every rewritten specifier resolves to a non-empty module", async () => {
105
+ // A specifier that stops resolving already fails loudly on its own: the
106
+ // dynamic import inside loadExportSets throws and takes the test with it.
107
+ // What that does NOT catch is a specifier that resolves to an empty module
108
+ // (a renamed or emptied subpath export), which would make the guard above
109
+ // report every import from it as unresolved for a misleading reason.
110
+ const exportSets = await loadExportSets();
111
+
112
+ const empty = TUI_RUNTIME_SPECIFIERS.filter(
113
+ (specifier) => (exportSets[specifier]?.size ?? 0) === 0,
114
+ );
115
+ expect(empty).toEqual([]);
116
+ });
117
+
118
+ test("TUI_RUNTIME_SPECIFIERS has no duplicates", () => {
119
+ // Object.fromEntries silently collapses duplicate keys, so a duplicated
120
+ // entry would shrink the export-set map without any other signal.
121
+ expect([...new Set(TUI_RUNTIME_SPECIFIERS)]).toEqual([...TUI_RUNTIME_SPECIFIERS]);
122
+ });
123
+
124
+ test("solid control-flow builtins are imported from solid-js, not @opentui/solid", async () => {
125
+ const openTuiExports = new Set(Object.keys(await import("@opentui/solid")));
126
+ const solidExports = new Set(Object.keys(await import("solid-js")));
127
+
128
+ // The builtins that belong to solid-js alone — the exact set the
129
+ // transform would otherwise misroute to @opentui/solid.
130
+ const misroutable = [...solidExports].filter((name) => !openTuiExports.has(name));
131
+
132
+ const violations: string[] = [];
133
+ for (const file of compiledFiles(COMPILED_ROOT)) {
134
+ const source = readFileSync(file, "utf8");
135
+ for (const match of source.matchAll(IMPORT_PATTERN)) {
136
+ if (decodeURIComponent(match[2] ?? "") !== "@opentui/solid") continue;
137
+ for (const specifier of (match[1] ?? "").split(",")) {
138
+ const importedName = specifier
139
+ .trim()
140
+ .split(/\s+as\s+/)[0]
141
+ ?.trim();
142
+ if (importedName && misroutable.includes(importedName)) {
143
+ violations.push(`${file}: '${importedName}' must come from solid-js`);
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ expect(violations).toEqual([]);
150
+ });
151
+ });