@moxxy/core 0.3.0

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 (332) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/dist/events/factory.d.ts +6 -0
  4. package/dist/events/factory.d.ts.map +1 -0
  5. package/dist/events/factory.js +14 -0
  6. package/dist/events/factory.js.map +1 -0
  7. package/dist/events/log.d.ts +92 -0
  8. package/dist/events/log.d.ts.map +1 -0
  9. package/dist/events/log.js +244 -0
  10. package/dist/events/log.js.map +1 -0
  11. package/dist/index.d.ts +40 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +41 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/logger.d.ts +16 -0
  16. package/dist/logger.d.ts.map +1 -0
  17. package/dist/logger.js +27 -0
  18. package/dist/logger.js.map +1 -0
  19. package/dist/permissions/engine.d.ts +94 -0
  20. package/dist/permissions/engine.d.ts.map +1 -0
  21. package/dist/permissions/engine.js +203 -0
  22. package/dist/permissions/engine.js.map +1 -0
  23. package/dist/permissions/resolvers.d.ts +8 -0
  24. package/dist/permissions/resolvers.d.ts.map +1 -0
  25. package/dist/permissions/resolvers.js +8 -0
  26. package/dist/permissions/resolvers.js.map +1 -0
  27. package/dist/plugins/discovery.d.ts +9 -0
  28. package/dist/plugins/discovery.d.ts.map +1 -0
  29. package/dist/plugins/discovery.js +112 -0
  30. package/dist/plugins/discovery.js.map +1 -0
  31. package/dist/plugins/host-options.d.ts +72 -0
  32. package/dist/plugins/host-options.d.ts.map +1 -0
  33. package/dist/plugins/host-options.js +2 -0
  34. package/dist/plugins/host-options.js.map +1 -0
  35. package/dist/plugins/host.d.ts +53 -0
  36. package/dist/plugins/host.d.ts.map +1 -0
  37. package/dist/plugins/host.js +269 -0
  38. package/dist/plugins/host.js.map +1 -0
  39. package/dist/plugins/lifecycle.d.ts +31 -0
  40. package/dist/plugins/lifecycle.d.ts.map +1 -0
  41. package/dist/plugins/lifecycle.js +100 -0
  42. package/dist/plugins/lifecycle.js.map +1 -0
  43. package/dist/plugins/loader.d.ts +7 -0
  44. package/dist/plugins/loader.d.ts.map +1 -0
  45. package/dist/plugins/loader.js +68 -0
  46. package/dist/plugins/loader.js.map +1 -0
  47. package/dist/plugins/package-requirements.d.ts +16 -0
  48. package/dist/plugins/package-requirements.d.ts.map +1 -0
  49. package/dist/plugins/package-requirements.js +52 -0
  50. package/dist/plugins/package-requirements.js.map +1 -0
  51. package/dist/plugins/registry-kinds.d.ts +58 -0
  52. package/dist/plugins/registry-kinds.d.ts.map +1 -0
  53. package/dist/plugins/registry-kinds.js +139 -0
  54. package/dist/plugins/registry-kinds.js.map +1 -0
  55. package/dist/plugins/toposort.d.ts +24 -0
  56. package/dist/plugins/toposort.d.ts.map +1 -0
  57. package/dist/plugins/toposort.js +67 -0
  58. package/dist/plugins/toposort.js.map +1 -0
  59. package/dist/preferences.d.ts +40 -0
  60. package/dist/preferences.d.ts.map +1 -0
  61. package/dist/preferences.js +58 -0
  62. package/dist/preferences.js.map +1 -0
  63. package/dist/registries/active-backend-registry.d.ts +65 -0
  64. package/dist/registries/active-backend-registry.d.ts.map +1 -0
  65. package/dist/registries/active-backend-registry.js +108 -0
  66. package/dist/registries/active-backend-registry.js.map +1 -0
  67. package/dist/registries/active-def-registry.d.ts +59 -0
  68. package/dist/registries/active-def-registry.d.ts.map +1 -0
  69. package/dist/registries/active-def-registry.js +63 -0
  70. package/dist/registries/active-def-registry.js.map +1 -0
  71. package/dist/registries/agents.d.ts +16 -0
  72. package/dist/registries/agents.d.ts.map +1 -0
  73. package/dist/registries/agents.js +17 -0
  74. package/dist/registries/agents.js.map +1 -0
  75. package/dist/registries/cache-strategies.d.ts +12 -0
  76. package/dist/registries/cache-strategies.d.ts.map +1 -0
  77. package/dist/registries/cache-strategies.js +13 -0
  78. package/dist/registries/cache-strategies.js.map +1 -0
  79. package/dist/registries/channels.d.ts +16 -0
  80. package/dist/registries/channels.d.ts.map +1 -0
  81. package/dist/registries/channels.js +35 -0
  82. package/dist/registries/channels.js.map +1 -0
  83. package/dist/registries/commands.d.ts +29 -0
  84. package/dist/registries/commands.d.ts.map +1 -0
  85. package/dist/registries/commands.js +72 -0
  86. package/dist/registries/commands.js.map +1 -0
  87. package/dist/registries/compactors.d.ts +12 -0
  88. package/dist/registries/compactors.d.ts.map +1 -0
  89. package/dist/registries/compactors.js +13 -0
  90. package/dist/registries/compactors.js.map +1 -0
  91. package/dist/registries/def-map-registry.d.ts +33 -0
  92. package/dist/registries/def-map-registry.d.ts.map +1 -0
  93. package/dist/registries/def-map-registry.js +36 -0
  94. package/dist/registries/def-map-registry.js.map +1 -0
  95. package/dist/registries/embedders.d.ts +16 -0
  96. package/dist/registries/embedders.d.ts.map +1 -0
  97. package/dist/registries/embedders.js +17 -0
  98. package/dist/registries/embedders.js.map +1 -0
  99. package/dist/registries/isolators.d.ts +21 -0
  100. package/dist/registries/isolators.d.ts.map +1 -0
  101. package/dist/registries/isolators.js +32 -0
  102. package/dist/registries/isolators.js.map +1 -0
  103. package/dist/registries/modes.d.ts +28 -0
  104. package/dist/registries/modes.d.ts.map +1 -0
  105. package/dist/registries/modes.js +81 -0
  106. package/dist/registries/modes.js.map +1 -0
  107. package/dist/registries/providers.d.ts +44 -0
  108. package/dist/registries/providers.d.ts.map +1 -0
  109. package/dist/registries/providers.js +95 -0
  110. package/dist/registries/providers.js.map +1 -0
  111. package/dist/registries/skills.d.ts +24 -0
  112. package/dist/registries/skills.d.ts.map +1 -0
  113. package/dist/registries/skills.js +81 -0
  114. package/dist/registries/skills.js.map +1 -0
  115. package/dist/registries/surfaces.d.ts +13 -0
  116. package/dist/registries/surfaces.d.ts.map +1 -0
  117. package/dist/registries/surfaces.js +14 -0
  118. package/dist/registries/surfaces.js.map +1 -0
  119. package/dist/registries/synthesizers.d.ts +26 -0
  120. package/dist/registries/synthesizers.d.ts.map +1 -0
  121. package/dist/registries/synthesizers.js +34 -0
  122. package/dist/registries/synthesizers.js.map +1 -0
  123. package/dist/registries/tools.d.ts +49 -0
  124. package/dist/registries/tools.d.ts.map +1 -0
  125. package/dist/registries/tools.js +98 -0
  126. package/dist/registries/tools.js.map +1 -0
  127. package/dist/registries/transcribers.d.ts +17 -0
  128. package/dist/registries/transcribers.d.ts.map +1 -0
  129. package/dist/registries/transcribers.js +18 -0
  130. package/dist/registries/transcribers.js.map +1 -0
  131. package/dist/registries/tunnel-providers.d.ts +12 -0
  132. package/dist/registries/tunnel-providers.d.ts.map +1 -0
  133. package/dist/registries/tunnel-providers.js +13 -0
  134. package/dist/registries/tunnel-providers.js.map +1 -0
  135. package/dist/registries/view-renderers.d.ts +13 -0
  136. package/dist/registries/view-renderers.d.ts.map +1 -0
  137. package/dist/registries/view-renderers.js +14 -0
  138. package/dist/registries/view-renderers.js.map +1 -0
  139. package/dist/registries/workflow-executors.d.ts +13 -0
  140. package/dist/registries/workflow-executors.d.ts.map +1 -0
  141. package/dist/registries/workflow-executors.js +14 -0
  142. package/dist/registries/workflow-executors.js.map +1 -0
  143. package/dist/requirements.d.ts +59 -0
  144. package/dist/requirements.d.ts.map +1 -0
  145. package/dist/requirements.js +149 -0
  146. package/dist/requirements.js.map +1 -0
  147. package/dist/run-turn.d.ts +6 -0
  148. package/dist/run-turn.d.ts.map +1 -0
  149. package/dist/run-turn.js +141 -0
  150. package/dist/run-turn.js.map +1 -0
  151. package/dist/session-runtime.d.ts +67 -0
  152. package/dist/session-runtime.d.ts.map +1 -0
  153. package/dist/session-runtime.js +2 -0
  154. package/dist/session-runtime.js.map +1 -0
  155. package/dist/session.d.ts +203 -0
  156. package/dist/session.d.ts.map +1 -0
  157. package/dist/session.js +399 -0
  158. package/dist/session.js.map +1 -0
  159. package/dist/sessions/persistence.d.ts +184 -0
  160. package/dist/sessions/persistence.d.ts.map +1 -0
  161. package/dist/sessions/persistence.js +442 -0
  162. package/dist/sessions/persistence.js.map +1 -0
  163. package/dist/setup-agent.d.ts +91 -0
  164. package/dist/setup-agent.d.ts.map +1 -0
  165. package/dist/setup-agent.js +138 -0
  166. package/dist/setup-agent.js.map +1 -0
  167. package/dist/skills/index.d.ts +4 -0
  168. package/dist/skills/index.d.ts.map +1 -0
  169. package/dist/skills/index.js +4 -0
  170. package/dist/skills/index.js.map +1 -0
  171. package/dist/skills/loader.d.ts +16 -0
  172. package/dist/skills/loader.d.ts.map +1 -0
  173. package/dist/skills/loader.js +99 -0
  174. package/dist/skills/loader.js.map +1 -0
  175. package/dist/skills/parse.d.ts +5 -0
  176. package/dist/skills/parse.d.ts.map +1 -0
  177. package/dist/skills/parse.js +8 -0
  178. package/dist/skills/parse.js.map +1 -0
  179. package/dist/skills/synthesize-draft.d.ts +8 -0
  180. package/dist/skills/synthesize-draft.d.ts.map +1 -0
  181. package/dist/skills/synthesize-draft.js +32 -0
  182. package/dist/skills/synthesize-draft.js.map +1 -0
  183. package/dist/skills/synthesize.d.ts +33 -0
  184. package/dist/skills/synthesize.d.ts.map +1 -0
  185. package/dist/skills/synthesize.js +241 -0
  186. package/dist/skills/synthesize.js.map +1 -0
  187. package/dist/subagents/events.d.ts +20 -0
  188. package/dist/subagents/events.d.ts.map +1 -0
  189. package/dist/subagents/events.js +146 -0
  190. package/dist/subagents/events.js.map +1 -0
  191. package/dist/subagents/registry.d.ts +40 -0
  192. package/dist/subagents/registry.d.ts.map +1 -0
  193. package/dist/subagents/registry.js +42 -0
  194. package/dist/subagents/registry.js.map +1 -0
  195. package/dist/subagents/run-child.d.ts +43 -0
  196. package/dist/subagents/run-child.d.ts.map +1 -0
  197. package/dist/subagents/run-child.js +276 -0
  198. package/dist/subagents/run-child.js.map +1 -0
  199. package/dist/subagents/spawn.d.ts +8 -0
  200. package/dist/subagents/spawn.d.ts.map +1 -0
  201. package/dist/subagents/spawn.js +8 -0
  202. package/dist/subagents/spawn.js.map +1 -0
  203. package/dist/subagents/tools.d.ts +4 -0
  204. package/dist/subagents/tools.d.ts.map +1 -0
  205. package/dist/subagents/tools.js +13 -0
  206. package/dist/subagents/tools.js.map +1 -0
  207. package/dist/subagents.d.ts +3 -0
  208. package/dist/subagents.d.ts.map +1 -0
  209. package/dist/subagents.js +3 -0
  210. package/dist/subagents.js.map +1 -0
  211. package/dist/surfaces/host.d.ts +53 -0
  212. package/dist/surfaces/host.d.ts.map +1 -0
  213. package/dist/surfaces/host.js +177 -0
  214. package/dist/surfaces/host.js.map +1 -0
  215. package/dist/tunnel/localhost.d.ts +7 -0
  216. package/dist/tunnel/localhost.d.ts.map +1 -0
  217. package/dist/tunnel/localhost.js +21 -0
  218. package/dist/tunnel/localhost.js.map +1 -0
  219. package/dist/usage-stats.d.ts +42 -0
  220. package/dist/usage-stats.d.ts.map +1 -0
  221. package/dist/usage-stats.js +83 -0
  222. package/dist/usage-stats.js.map +1 -0
  223. package/dist/view/default-renderer.d.ts +7 -0
  224. package/dist/view/default-renderer.d.ts.map +1 -0
  225. package/dist/view/default-renderer.js +14 -0
  226. package/dist/view/default-renderer.js.map +1 -0
  227. package/dist/view/parse.d.ts +7 -0
  228. package/dist/view/parse.d.ts.map +1 -0
  229. package/dist/view/parse.js +479 -0
  230. package/dist/view/parse.js.map +1 -0
  231. package/package.json +61 -0
  232. package/src/events/factory.ts +27 -0
  233. package/src/events/log.test.ts +470 -0
  234. package/src/events/log.ts +256 -0
  235. package/src/index.ts +102 -0
  236. package/src/logger.ts +46 -0
  237. package/src/permissions/engine.test.ts +206 -0
  238. package/src/permissions/engine.ts +247 -0
  239. package/src/permissions/resolvers.test.ts +40 -0
  240. package/src/permissions/resolvers.ts +17 -0
  241. package/src/plugins/discovery.test.ts +115 -0
  242. package/src/plugins/discovery.ts +124 -0
  243. package/src/plugins/host-options.ts +73 -0
  244. package/src/plugins/host-view-tunnel.test.ts +86 -0
  245. package/src/plugins/host.test.ts +434 -0
  246. package/src/plugins/host.ts +327 -0
  247. package/src/plugins/lifecycle.test.ts +187 -0
  248. package/src/plugins/lifecycle.ts +136 -0
  249. package/src/plugins/loader.ts +79 -0
  250. package/src/plugins/package-requirements.ts +50 -0
  251. package/src/plugins/registry-kinds.test.ts +162 -0
  252. package/src/plugins/registry-kinds.ts +210 -0
  253. package/src/plugins/toposort.test.ts +66 -0
  254. package/src/plugins/toposort.ts +69 -0
  255. package/src/preferences.test.ts +105 -0
  256. package/src/preferences.ts +84 -0
  257. package/src/registries/active-backend-registry.ts +142 -0
  258. package/src/registries/active-def-registry.test.ts +103 -0
  259. package/src/registries/active-def-registry.ts +97 -0
  260. package/src/registries/agents.ts +18 -0
  261. package/src/registries/cache-strategies.ts +14 -0
  262. package/src/registries/channels.ts +42 -0
  263. package/src/registries/commands.test.ts +71 -0
  264. package/src/registries/commands.ts +78 -0
  265. package/src/registries/compactors.ts +14 -0
  266. package/src/registries/def-map-registry.test.ts +67 -0
  267. package/src/registries/def-map-registry.ts +59 -0
  268. package/src/registries/embedders.test.ts +54 -0
  269. package/src/registries/embedders.ts +18 -0
  270. package/src/registries/isolators.test.ts +43 -0
  271. package/src/registries/isolators.ts +38 -0
  272. package/src/registries/modes.test.ts +66 -0
  273. package/src/registries/modes.ts +80 -0
  274. package/src/registries/providers.test.ts +50 -0
  275. package/src/registries/providers.ts +97 -0
  276. package/src/registries/semantics.test.ts +117 -0
  277. package/src/registries/skills.ts +87 -0
  278. package/src/registries/surfaces.ts +18 -0
  279. package/src/registries/synthesizers.test.ts +63 -0
  280. package/src/registries/synthesizers.ts +36 -0
  281. package/src/registries/tools.test.ts +142 -0
  282. package/src/registries/tools.ts +145 -0
  283. package/src/registries/transcribers.test.ts +70 -0
  284. package/src/registries/transcribers.ts +19 -0
  285. package/src/registries/tunnel-providers.test.ts +30 -0
  286. package/src/registries/tunnel-providers.ts +14 -0
  287. package/src/registries/view-renderers.test.ts +68 -0
  288. package/src/registries/view-renderers.ts +15 -0
  289. package/src/registries/workflow-executors.test.ts +53 -0
  290. package/src/registries/workflow-executors.ts +15 -0
  291. package/src/requirements.targetinfo.test.ts +70 -0
  292. package/src/requirements.test.ts +111 -0
  293. package/src/requirements.ts +262 -0
  294. package/src/run-turn.test.ts +178 -0
  295. package/src/run-turn.ts +156 -0
  296. package/src/session-runtime.ts +75 -0
  297. package/src/session.test.ts +216 -0
  298. package/src/session.ts +491 -0
  299. package/src/sessions/persistence.flush.test.ts +100 -0
  300. package/src/sessions/persistence.ready.test.ts +91 -0
  301. package/src/sessions/persistence.test.ts +274 -0
  302. package/src/sessions/persistence.ts +498 -0
  303. package/src/setup-agent.test.ts +72 -0
  304. package/src/setup-agent.ts +189 -0
  305. package/src/skills/index.ts +14 -0
  306. package/src/skills/loader.test.ts +102 -0
  307. package/src/skills/loader.ts +114 -0
  308. package/src/skills/parse.test.ts +59 -0
  309. package/src/skills/parse.ts +11 -0
  310. package/src/skills/synthesize-draft.test.ts +79 -0
  311. package/src/skills/synthesize-draft.ts +46 -0
  312. package/src/skills/synthesize.test.ts +170 -0
  313. package/src/skills/synthesize.ts +304 -0
  314. package/src/subagents/events.test.ts +56 -0
  315. package/src/subagents/events.ts +194 -0
  316. package/src/subagents/registry.ts +62 -0
  317. package/src/subagents/run-child.test.ts +185 -0
  318. package/src/subagents/run-child.ts +425 -0
  319. package/src/subagents/spawn.ts +12 -0
  320. package/src/subagents/tools.ts +19 -0
  321. package/src/subagents.ts +2 -0
  322. package/src/surfaces/host.test.ts +330 -0
  323. package/src/surfaces/host.ts +192 -0
  324. package/src/tunnel/localhost.test.ts +22 -0
  325. package/src/tunnel/localhost.ts +21 -0
  326. package/src/usage-stats.test.ts +77 -0
  327. package/src/usage-stats.ts +119 -0
  328. package/src/view/default-renderer.test.ts +29 -0
  329. package/src/view/default-renderer.ts +14 -0
  330. package/src/view/parse-extended.test.ts +255 -0
  331. package/src/view/parse.test.ts +232 -0
  332. package/src/view/parse.ts +520 -0
@@ -0,0 +1,119 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { addModelTotals, createMutex, type ModelUsageTotals } from '@moxxy/sdk';
5
+ import { writeFileAtomic } from '@moxxy/sdk/server';
6
+
7
+ /**
8
+ * Cross-session token usage, persisted at ~/.moxxy/usage.json. A forward-going
9
+ * aggregate keyed by `"<provider>/<model>"`: each session folds its own
10
+ * `provider_response` usage and merges the delta in on shutdown (see
11
+ * `@moxxy/plugin-usage-stats`). Purely additive counters — no derived/cost
12
+ * fields — so a session's contribution is added once and the file stays a sum.
13
+ *
14
+ * Like preferences, this is best-effort: a missing or malformed file reads as
15
+ * empty, and a write failure never blocks shutdown.
16
+ */
17
+ export interface StoredModelUsage extends ModelUsageTotals {
18
+ /** ISO timestamp of the first session that recorded usage for this model. */
19
+ readonly firstSeen: string;
20
+ /** ISO timestamp of the most recent session that recorded usage. */
21
+ readonly lastSeen: string;
22
+ }
23
+
24
+ export interface UsageStatsFile {
25
+ readonly version: 1;
26
+ /** ISO timestamp of the last merge/clear. */
27
+ readonly updatedAt: string;
28
+ /** Per-`provider/model` lifetime totals. */
29
+ readonly models: Record<string, StoredModelUsage>;
30
+ }
31
+
32
+ export function usageStatsPath(): string {
33
+ return path.join(os.homedir(), '.moxxy', 'usage.json');
34
+ }
35
+
36
+ function emptyStats(): UsageStatsFile {
37
+ return { version: 1, updatedAt: new Date().toISOString(), models: {} };
38
+ }
39
+
40
+ /**
41
+ * Read the usage aggregate. Returns an empty file when missing or unparseable —
42
+ * usage stats are an optional, non-load-blocking layer.
43
+ */
44
+ export async function loadUsageStats(filePath: string = usageStatsPath()): Promise<UsageStatsFile> {
45
+ try {
46
+ const raw = await fs.readFile(filePath, 'utf8');
47
+ const parsed = JSON.parse(raw) as unknown;
48
+ if (
49
+ parsed &&
50
+ typeof parsed === 'object' &&
51
+ typeof (parsed as UsageStatsFile).models === 'object' &&
52
+ (parsed as UsageStatsFile).models !== null
53
+ ) {
54
+ return parsed as UsageStatsFile;
55
+ }
56
+ } catch {
57
+ // missing or malformed — start fresh
58
+ }
59
+ return emptyStats();
60
+ }
61
+
62
+ async function writeAtomic(file: UsageStatsFile, filePath: string): Promise<void> {
63
+ await writeFileAtomic(filePath, JSON.stringify(file, null, 2) + '\n');
64
+ }
65
+
66
+ // Serializes the read-modify-write in `mergeUsageStats` so two concurrent
67
+ // merges (e.g. overlapping shutdowns) can't both read the same snapshot and
68
+ // have the second write clobber the first's delta.
69
+ const mergeMutex = createMutex();
70
+
71
+ /**
72
+ * Merge a session's per-model delta into the persisted aggregate and write it
73
+ * back. Reads the current file, adds each delta field-wise, refreshes
74
+ * first/lastSeen, and writes atomically. Returns the updated file.
75
+ *
76
+ * Best-effort: a write failure logs to stderr but does not throw — losing one
77
+ * session's stats must never block shutdown.
78
+ */
79
+ export async function mergeUsageStats(
80
+ delta: Record<string, ModelUsageTotals>,
81
+ filePath: string = usageStatsPath(),
82
+ ): Promise<UsageStatsFile> {
83
+ // An empty delta writes nothing, so there's no read-modify-write to
84
+ // serialize: skip the mutex entirely and read the current file directly. A
85
+ // session with no recorded usage produces an empty delta on every shutdown,
86
+ // so this avoids needless I/O + contention on the common no-op path.
87
+ const keys = Object.keys(delta);
88
+ if (keys.length === 0) return loadUsageStats(filePath);
89
+
90
+ return mergeMutex.run(async () => {
91
+ const current = await loadUsageStats(filePath);
92
+
93
+ const now = new Date().toISOString();
94
+ const models: Record<string, StoredModelUsage> = { ...current.models };
95
+ for (const key of keys) {
96
+ const d = delta[key]!;
97
+ if (d.calls === 0) continue;
98
+ const existing = models[key];
99
+ models[key] = existing
100
+ ? { ...addModelTotals(existing, d), firstSeen: existing.firstSeen, lastSeen: now }
101
+ : { ...d, firstSeen: now, lastSeen: now };
102
+ }
103
+ const next: UsageStatsFile = { version: 1, updatedAt: now, models };
104
+ try {
105
+ await writeAtomic(next, filePath);
106
+ } catch (err) {
107
+ process.stderr.write(
108
+ `moxxy: failed to persist usage stats to ${filePath}: ` +
109
+ `${err instanceof Error ? err.message : String(err)}\n`,
110
+ );
111
+ }
112
+ return next;
113
+ });
114
+ }
115
+
116
+ /** Reset the aggregate to empty (the user-facing `/usage clear`). */
117
+ export async function clearUsageStats(filePath: string = usageStatsPath()): Promise<void> {
118
+ await writeAtomic(emptyStats(), filePath);
119
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DEFAULT_VIEW_TAGS } from '@moxxy/sdk';
3
+ import { defaultViewRenderer } from './default-renderer.js';
4
+
5
+ describe('defaultViewRenderer', () => {
6
+ it('is named moxxy/default and exposes the default allow-list', () => {
7
+ expect(defaultViewRenderer.name).toBe('moxxy/default');
8
+ expect(defaultViewRenderer.allowList).toBe(DEFAULT_VIEW_TAGS);
9
+ });
10
+
11
+ it('parse returns ok for a valid spec and the expected title', () => {
12
+ const r = defaultViewRenderer.parse('<view title="hi"><text>x</text></view>');
13
+ expect(r.ok).toBe(true);
14
+ if (r.ok) expect(r.doc.title).toBe('hi');
15
+ });
16
+
17
+ it('parse returns errors for an invalid spec', () => {
18
+ const r = defaultViewRenderer.parse('<view><nope/></view>');
19
+ expect(r.ok).toBe(false);
20
+ });
21
+
22
+ it('validate flags a hand-built unknown tag, passes a parsed doc', () => {
23
+ const r = defaultViewRenderer.parse('<view><text>x</text></view>');
24
+ if (!r.ok) throw new Error('expected ok');
25
+ expect(defaultViewRenderer.validate(r.doc)).toEqual([]);
26
+ const bad = defaultViewRenderer.validate({ root: { kind: 'element', tag: 'iframe', props: {}, children: [] } });
27
+ expect(bad.length).toBeGreaterThan(0);
28
+ });
29
+ });
@@ -0,0 +1,14 @@
1
+ import { DEFAULT_VIEW_TAGS, defineViewRenderer } from '@moxxy/sdk';
2
+ import { parseView, validateDoc } from './parse.js';
3
+
4
+ /**
5
+ * The built-in view-spec renderer, seeded into every session's
6
+ * {@link ViewRendererRegistry}. Plugins may register/replace and `setActive`
7
+ * an alternative renderer with a different vocabulary.
8
+ */
9
+ export const defaultViewRenderer = defineViewRenderer({
10
+ name: 'moxxy/default',
11
+ allowList: DEFAULT_VIEW_TAGS,
12
+ parse: (source) => parseView(source, DEFAULT_VIEW_TAGS),
13
+ validate: (doc) => validateDoc(doc, DEFAULT_VIEW_TAGS),
14
+ });
@@ -0,0 +1,255 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DEFAULT_VIEW_TAGS, type ViewNode } from '@moxxy/sdk';
3
+ import { countNodes, parseView } from './parse.js';
4
+
5
+ const parse = (src: string) => parseView(src, DEFAULT_VIEW_TAGS);
6
+ const ok = (src: string) => {
7
+ const r = parse(src);
8
+ if (!r.ok) throw new Error(`expected ok, got errors: ${r.errors.map((e) => e.message).join('; ')}`);
9
+ return r.doc;
10
+ };
11
+ const errs = (src: string): string[] => {
12
+ const r = parse(src);
13
+ if (r.ok) throw new Error('expected errors');
14
+ return r.errors.map((e) => e.message);
15
+ };
16
+ const hasErr = (src: string, re: RegExp) => expect(errs(src).some((m) => re.test(m))).toBe(true);
17
+
18
+ function find(node: ViewNode, tag: string): Extract<ViewNode, { kind: 'element' }> | null {
19
+ if (node.kind === 'element') {
20
+ if (node.tag === tag) return node;
21
+ for (const c of node.children) {
22
+ const hit = find(c, tag);
23
+ if (hit) return hit;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+ function findAll(node: ViewNode, tag: string): Array<Extract<ViewNode, { kind: 'element' }>> {
29
+ const out: Array<Extract<ViewNode, { kind: 'element' }>> = [];
30
+ const walk = (n: ViewNode) => {
31
+ if (n.kind !== 'element') return;
32
+ if (n.tag === tag) out.push(n);
33
+ n.children.forEach(walk);
34
+ };
35
+ walk(node);
36
+ return out;
37
+ }
38
+ function firstText(node: ViewNode): string | null {
39
+ if (node.kind === 'text') return node.value;
40
+ for (const c of node.children) {
41
+ const t = firstText(c);
42
+ if (t != null) return t;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ describe('attribute coercion', () => {
48
+ it('coerces booleans: bare, ="true", ="false"', () => {
49
+ const doc = ok('<view><input name="a" required /><input name="b" required="true" /><input name="c" required="false" /></view>');
50
+ const inputs = findAll(doc.root, 'input');
51
+ expect(inputs[0]!.props.required).toBe(true);
52
+ expect(inputs[1]!.props.required).toBe(true);
53
+ expect(inputs[2]!.props.required).toBe(false);
54
+ });
55
+
56
+ it('coerces numbers including negatives and floats are range-checked', () => {
57
+ expect(find(ok('<view><grid cols="4"></grid></view>').root, 'grid')!.props.cols).toBe(4);
58
+ hasErr('<view><grid cols="0"></grid></view>', /must be ≥ 1/);
59
+ hasErr('<view><grid cols="abc"></grid></view>', /must be a number/);
60
+ hasErr('<view><heading level="5">x</heading></view>', /must be ≤ 3/);
61
+ });
62
+
63
+ it('validates each enum attribute', () => {
64
+ ok('<view><stack gap="lg" align="center"></stack></view>');
65
+ ok('<view><row justify="between"></row></view>');
66
+ ok('<view><text tone="muted" weight="bold">x</text></view>');
67
+ ok('<view><button action="a" label="b" variant="danger" /></view>');
68
+ ok('<view><input name="n" type="email" /></view>');
69
+ hasErr('<view><stack gap="huge"></stack></view>', /gap.*must be one of/);
70
+ hasErr('<view><input name="n" type="color" /></view>', /type.*must be one of/);
71
+ hasErr('<view><button action="a" label="b" variant="rainbow" /></view>', /variant.*must be one of/);
72
+ });
73
+
74
+ it('rejects a value-less non-boolean attribute', () => {
75
+ hasErr('<view><text tone>x</text></view>', /tone.*must be one of|expects a value/);
76
+ });
77
+
78
+ it('parses single-quoted, double-quoted, and brace values', () => {
79
+ const doc = ok(`<view title='single'><image src="https://x/i.png" alt='a "b"' /></view>`);
80
+ expect(doc.title).toBe('single');
81
+ });
82
+ });
83
+
84
+ describe('text + entities + comments', () => {
85
+ it('decodes entities and collapses whitespace', () => {
86
+ const doc = ok('<view><text>a &amp; b &lt;tag&gt; &quot;q&quot;</text></view>');
87
+ expect(firstText(find(doc.root, 'text')!)).toBe('a & b <tag> "q"');
88
+ });
89
+
90
+ it('drops whitespace-only text nodes', () => {
91
+ const doc = ok('<view>\n <stack>\n <text>hi</text>\n </stack>\n</view>');
92
+ const stack = find(doc.root, 'stack')!;
93
+ expect(stack.children.every((c) => c.kind === 'element')).toBe(true);
94
+ });
95
+
96
+ it('strips comments', () => {
97
+ const doc = ok('<view><!-- a comment --><text>x</text></view>');
98
+ expect(find(doc.root, 'text')).not.toBeNull();
99
+ });
100
+
101
+ it('treats self-closing and explicit-close equivalently', () => {
102
+ expect(ok('<view><divider /></view>')).toBeTruthy();
103
+ // divider is a void element; an explicit close with content would error,
104
+ // but an empty explicit close is acceptable structurally.
105
+ expect(parse('<view><divider></divider></view>').ok).toBe(true);
106
+ });
107
+ });
108
+
109
+ describe('nesting rules', () => {
110
+ it('accepts table → tr → th/td', () => {
111
+ ok('<view><table><tr><th>H</th></tr><tr><td>1</td></tr></table></view>');
112
+ });
113
+ it('rejects table → td directly (must be tr)', () => {
114
+ hasErr('<view><table><td>x</td></table></view>', /<table> may not contain <td>/);
115
+ });
116
+ it('rejects list → text (must be item)', () => {
117
+ hasErr('<view><list><text>x</text></list></view>', /<list> may not contain <text>/);
118
+ });
119
+ it('accepts list → item', () => {
120
+ ok('<view><list ordered="true"><item>one</item><item>two</item></list></view>');
121
+ });
122
+ it('rejects select → input (must be option)', () => {
123
+ hasErr('<view><select name="s"><input name="x" /></select></view>', /<select> may not contain <input>/);
124
+ });
125
+ it('rejects children on void elements', () => {
126
+ hasErr('<view><image src="https://x/i.png">child</image></view>', /<image> may not have children|may not contain text/);
127
+ });
128
+ });
129
+
130
+ describe('structural errors collect or fail clearly', () => {
131
+ it('reports multiple validation errors in one pass', () => {
132
+ const messages = errs('<view><blink/><text foo="1">x</text></view>');
133
+ expect(messages.length).toBeGreaterThanOrEqual(2);
134
+ });
135
+ it('unbalanced / mismatched / multi-root', () => {
136
+ hasErr('<view><card>', /unclosed tag <card>/);
137
+ hasErr('<view><card></row></view>', /mismatched closing tag/);
138
+ hasErr('<a/><b/>', /exactly one root element/);
139
+ hasErr(' ', /exactly one root element|root/);
140
+ });
141
+ });
142
+
143
+ describe('security rejections', () => {
144
+ it('rejects script/style/iframe/object tags as unknown', () => {
145
+ for (const tag of ['script', 'style', 'iframe', 'object', 'embed', 'svg']) {
146
+ hasErr(`<view><${tag}>x</${tag}></view>`, new RegExp(`unknown tag <${tag}>`));
147
+ }
148
+ });
149
+ it('rejects every on* handler attribute', () => {
150
+ for (const h of ['onclick', 'onload', 'onerror', 'onmouseover']) {
151
+ hasErr(`<view><button action="a" label="b" ${h}="evil()" /></view>`, /forbidden attribute/);
152
+ }
153
+ });
154
+ it('URL scheme allow-list on href and src', () => {
155
+ ok('<view><link href="https://ok.com">x</link></view>');
156
+ ok('<view><link href="mailto:a@b.com">x</link></view>');
157
+ ok('<view><link href="/relative/path">x</link></view>');
158
+ ok('<view><link href="#anchor">x</link></view>');
159
+ ok('<view><image src="data:image/png;base64,AAAA" /></view>');
160
+ hasErr('<view><link href="javascript:alert(1)">x</link></view>', /disallowed URL scheme/);
161
+ hasErr('<view><link href="vbscript:x">y</link></view>', /disallowed URL scheme/);
162
+ hasErr('<view><image src="data:text/html,<script>" />\n</view>', /disallowed URL scheme/);
163
+ hasErr('<view><link href="ftp://x">y</link></view>', /disallowed URL scheme/);
164
+ });
165
+ });
166
+
167
+ describe('action extraction', () => {
168
+ it('form action carries all descendant field names (excluding buttons)', () => {
169
+ const doc = ok(`
170
+ <view><form action="go" submit="Go">
171
+ <input name="a" /><row><select name="b"><option value="x">x</option></select></row>
172
+ <checkbox name="c" /><button action="other" label="Other" />
173
+ </form></view>`);
174
+ const form = find(doc.root, 'form')!;
175
+ expect(form.action?.name).toBe('go');
176
+ expect(form.action?.fields).toEqual(['a', 'b', 'c']);
177
+ });
178
+ it('standalone button parses fields csv (trimmed, empties dropped)', () => {
179
+ const doc = ok('<view><button action="x" label="L" fields=" a , ,b " /></view>');
180
+ expect(find(doc.root, 'button')!.action?.fields).toEqual(['a', 'b']);
181
+ });
182
+ it('button with no fields has empty fields array', () => {
183
+ const doc = ok('<view><button action="x" label="L" /></view>');
184
+ expect(find(doc.root, 'button')!.action?.fields).toEqual([]);
185
+ });
186
+ });
187
+
188
+ describe('client-side navigation (to → nav)', () => {
189
+ it('sets node.nav from a `to` on a link and on a button', () => {
190
+ const doc = ok('<view><link to="search">Back</link><button to="results" label="Results" /></view>');
191
+ expect(find(doc.root, 'link')!.nav).toBe('search');
192
+ expect(find(doc.root, 'button')!.nav).toBe('results');
193
+ });
194
+ it('a button with action (no to) carries an action, not nav', () => {
195
+ const doc = ok('<view><button action="go" label="Go" /></view>');
196
+ const b = find(doc.root, 'button')!;
197
+ expect(b.action?.name).toBe('go');
198
+ expect(b.nav).toBeUndefined();
199
+ });
200
+ it('reads the view name onto the root props', () => {
201
+ const doc = ok('<view name="search" title="Search">x</view>');
202
+ const root = doc.root as Extract<typeof doc.root, { kind: 'element' }>;
203
+ expect(root.props.name).toBe('search');
204
+ });
205
+ });
206
+
207
+ describe('results expansion', () => {
208
+ const doc = ok(`
209
+ <view><results>
210
+ <result id="UA42" title="United SFO→JFK" subtitle="08:10 → 16:45" badge="$312" />
211
+ <result id="B6-11" title="JetBlue SFO→JFK" subtitle="11:30 → 20:05" badge="$289" />
212
+ </results></view>`);
213
+
214
+ it('removes component tags and emits one card per result', () => {
215
+ expect(find(doc.root, 'results')).toBeNull();
216
+ expect(find(doc.root, 'result')).toBeNull();
217
+ expect(findAll(doc.root, 'card')).toHaveLength(2);
218
+ });
219
+ it('defaults each result to an open:<id> action button', () => {
220
+ const buttons = findAll(doc.root, 'button');
221
+ expect(buttons.map((b) => b.action?.name)).toEqual(['open:UA42', 'open:B6-11']);
222
+ });
223
+ it('renders title, subtitle and badge in the tree', () => {
224
+ const texts = findAll(doc.root, 'text').map(firstText);
225
+ expect(texts).toContain('United SFO→JFK');
226
+ expect(texts).toContain('08:10 → 16:45');
227
+ expect(findAll(doc.root, 'badge').map(firstText)).toContain('$312');
228
+ });
229
+ it('honours an explicit `to` (client nav) or `action` over the default', () => {
230
+ const d = ok('<view><results><result title="A" to="detail:a" /><result title="B" action="buy:b" /></results></view>');
231
+ const buttons = findAll(d.root, 'button');
232
+ expect(buttons[0]!.nav).toBe('detail:a');
233
+ expect(buttons[1]!.action?.name).toBe('buy:b');
234
+ });
235
+ it('an expanded tree contains no component tags', () => {
236
+ expect(findAll(doc.root, 'results').length + findAll(doc.root, 'result').length).toBe(0);
237
+ });
238
+ });
239
+
240
+ describe('countNodes', () => {
241
+ it('counts elements and text nodes', () => {
242
+ const doc = ok('<view><text>a</text><text>b</text></view>');
243
+ // view + 2 text elements + 2 text nodes = 5
244
+ expect(countNodes(doc.root)).toBe(5);
245
+ });
246
+ });
247
+
248
+ describe('large / deep documents', () => {
249
+ it('parses deeply nested stacks', () => {
250
+ let inner = '<text>deep</text>';
251
+ for (let i = 0; i < 40; i++) inner = `<stack>${inner}</stack>`;
252
+ const doc = ok(`<view>${inner}</view>`);
253
+ expect(firstText(doc.root)).toBe('deep');
254
+ });
255
+ });
@@ -0,0 +1,232 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DEFAULT_VIEW_TAGS, type ViewNode } from '@moxxy/sdk';
3
+ import { parseView, validateDoc } from './parse.js';
4
+
5
+ const parse = (src: string) => parseView(src, DEFAULT_VIEW_TAGS);
6
+
7
+ function find(node: ViewNode, tag: string): Extract<ViewNode, { kind: 'element' }> | null {
8
+ if (node.kind === 'element') {
9
+ if (node.tag === tag) return node;
10
+ for (const c of node.children) {
11
+ const hit = find(c, tag);
12
+ if (hit) return hit;
13
+ }
14
+ }
15
+ return null;
16
+ }
17
+
18
+ function findAll(node: ViewNode, tag: string): Array<Extract<ViewNode, { kind: 'element' }>> {
19
+ const out: Array<Extract<ViewNode, { kind: 'element' }>> = [];
20
+ const walk = (n: ViewNode) => {
21
+ if (n.kind !== 'element') return;
22
+ if (n.tag === tag) out.push(n);
23
+ n.children.forEach(walk);
24
+ };
25
+ walk(node);
26
+ return out;
27
+ }
28
+
29
+ describe('parseView — valid', () => {
30
+ it('parses a form, extracts the title and the form action + fields', () => {
31
+ const res = parse(`
32
+ <view title="Find flights">
33
+ <form action="search_flights" submit="Search">
34
+ <row gap="md">
35
+ <input name="from" label="From" required />
36
+ <input name="to" label="To" required />
37
+ </row>
38
+ <input name="depart" type="date" label="Depart" />
39
+ <select name="cabin" value="economy">
40
+ <option value="economy">Economy</option>
41
+ <option value="business">Business</option>
42
+ </select>
43
+ </form>
44
+ </view>
45
+ `);
46
+ expect(res.ok).toBe(true);
47
+ if (!res.ok) return;
48
+ expect(res.doc.title).toBe('Find flights');
49
+ const form = find(res.doc.root, 'form')!;
50
+ expect(form.action?.name).toBe('search_flights');
51
+ expect(form.action?.fields).toEqual(['from', 'to', 'depart', 'cabin']);
52
+ // submit is a recognized attribute, coerced onto props
53
+ expect(form.props.submit).toBe('Search');
54
+ });
55
+
56
+ it('expands results into a card stack with open buttons', () => {
57
+ const res = parse(`
58
+ <view title="2 results">
59
+ <results>
60
+ <result id="UA42" title="United SFO→JFK" subtitle="08:10 → 16:45" badge="$312" />
61
+ <result id="B6-11" title="JetBlue SFO→JFK" subtitle="11:30 → 20:05" badge="$289" />
62
+ </results>
63
+ </view>
64
+ `);
65
+ expect(res.ok).toBe(true);
66
+ if (!res.ok) return;
67
+ // No component tags survive expansion.
68
+ expect(find(res.doc.root, 'results')).toBeNull();
69
+ expect(find(res.doc.root, 'result')).toBeNull();
70
+ const cards = findAll(res.doc.root, 'card');
71
+ expect(cards).toHaveLength(2);
72
+ const buttons = findAll(res.doc.root, 'button');
73
+ // No explicit action/to → defaults to open:<id>.
74
+ expect(buttons.map((b) => b.action?.name)).toEqual(['open:UA42', 'open:B6-11']);
75
+ });
76
+
77
+ it('coerces number, boolean and enum attributes', () => {
78
+ const res = parse(`<view><grid cols="3" gap="lg"><input name="q" required /></grid></view>`);
79
+ expect(res.ok).toBe(true);
80
+ if (!res.ok) return;
81
+ const grid = find(res.doc.root, 'grid')!;
82
+ expect(grid.props.cols).toBe(3);
83
+ expect(grid.props.gap).toBe('lg');
84
+ const input = find(res.doc.root, 'input')!;
85
+ expect(input.props.required).toBe(true);
86
+ });
87
+
88
+ it('u50-2: decodes HTML entities in attribute string values (consistent with text)', () => {
89
+ const res = parse(`<view><image src="/logo.png" alt="Tom &amp; Jerry &lt;3" /></view>`);
90
+ expect(res.ok).toBe(true);
91
+ if (!res.ok) return;
92
+ const img = find(res.doc.root, 'image')!;
93
+ expect(img.props.alt).toBe('Tom & Jerry <3');
94
+ });
95
+
96
+ it('u50-2: entity-decoded href is still scheme-checked on the decoded value', () => {
97
+ // A plain disallowed scheme stays rejected (unchanged).
98
+ expect(parse(`<view><link href="javascript:alert(1)">x</link></view>`).ok).toBe(false);
99
+ // An href whose entities decode to a still-safe value is accepted, with
100
+ // the decoded form on props.
101
+ const res = parse(`<view><link href="https://x.test/?a=1&amp;b=2">x</link></view>`);
102
+ expect(res.ok).toBe(true);
103
+ if (!res.ok) return;
104
+ expect(find(res.doc.root, 'link')!.props.href).toBe('https://x.test/?a=1&b=2');
105
+ });
106
+
107
+ it('carries explicit button fields from a csv', () => {
108
+ const res = parse(`<view><button action="go" label="Go" fields="a, b ,c" /></view>`);
109
+ expect(res.ok).toBe(true);
110
+ if (!res.ok) return;
111
+ expect(find(res.doc.root, 'button')!.action?.fields).toEqual(['a', 'b', 'c']);
112
+ });
113
+ });
114
+
115
+ describe('parseView — rejected (security + allow-list)', () => {
116
+ const expectError = (src: string, match: RegExp) => {
117
+ const res = parse(src);
118
+ expect(res.ok).toBe(false);
119
+ if (res.ok) return;
120
+ expect(res.errors.some((e) => match.test(e.message))).toBe(true);
121
+ };
122
+
123
+ it('rejects unknown tags (incl. <script>)', () => {
124
+ expectError(`<view><script>alert(1)</script></view>`, /unknown tag <script>/);
125
+ expectError(`<view><marquee>hi</marquee></view>`, /unknown tag <marquee>/);
126
+ });
127
+
128
+ it('rejects unknown attributes', () => {
129
+ expectError(`<view><text style="color:red">x</text></view>`, /unknown attribute "style"/);
130
+ });
131
+
132
+ it('u50-3: an unknown tag yields exactly one error, not one-per-attribute', () => {
133
+ const res = parse(`<view><evil a="1" b="2" /></view>`);
134
+ expect(res.ok).toBe(false);
135
+ if (res.ok) return;
136
+ // Exactly the tag error — no `unknown attribute` spam for a/b on top of it.
137
+ expect(res.errors.some((e) => /unknown tag <evil>/.test(e.message))).toBe(true);
138
+ expect(res.errors.some((e) => /unknown attribute/.test(e.message))).toBe(false);
139
+ expect(res.errors).toHaveLength(1);
140
+ });
141
+
142
+ it('u50-3: on* handlers on an unknown tag are still rejected (no attr-suppression bypass)', () => {
143
+ const res = parse(`<view><evil onclick="boom()" /></view>`);
144
+ expect(res.ok).toBe(false);
145
+ if (res.ok) return;
146
+ expect(res.errors.some((e) => /unknown tag <evil>/.test(e.message))).toBe(true);
147
+ expect(res.errors.some((e) => /forbidden attribute "onclick"/.test(e.message))).toBe(true);
148
+ });
149
+
150
+ it('rejects on* handler attributes', () => {
151
+ expectError(`<view><button action="x" label="y" onclick="evil()" /></view>`, /forbidden attribute "onclick"/);
152
+ });
153
+
154
+ it('rejects disallowed URL schemes on href/src', () => {
155
+ expectError(`<view><link href="javascript:alert(1)">x</link></view>`, /disallowed URL scheme/);
156
+ expectError(`<view><image src="javascript:alert(1)" /></view>`, /disallowed URL scheme/);
157
+ });
158
+
159
+ it('allows https and relative URLs', () => {
160
+ expect(parse(`<view><link href="https://example.com">x</link></view>`).ok).toBe(true);
161
+ expect(parse(`<view><link href="/local">x</link></view>`).ok).toBe(true);
162
+ });
163
+
164
+ it('allows mailto/tel hrefs and data:image src; rejects data:text src', () => {
165
+ expect(parse(`<view><link href="mailto:a@b.c">x</link></view>`).ok).toBe(true);
166
+ expect(parse(`<view><link href="tel:+15551234">x</link></view>`).ok).toBe(true);
167
+ expect(parse(`<view><image src="data:image/png;base64,AAAA" /></view>`).ok).toBe(true);
168
+ expectError(`<view><image src="data:text/html,<script>1</script>" /></view>`, /disallowed URL scheme/);
169
+ expectError(`<view><link href="data:image/png;base64,AAAA">x</link></view>`, /disallowed URL scheme/);
170
+ });
171
+
172
+ it('rejects out-of-range numbers and bad enums', () => {
173
+ expectError(`<view><grid cols="9"></grid></view>`, /must be ≤ 6/);
174
+ expectError(`<view><text tone="rainbow">x</text></view>`, /must be one of/);
175
+ });
176
+
177
+ it('rejects missing required attributes', () => {
178
+ expectError(`<view><input label="no name" /></view>`, /missing required attribute "name"/);
179
+ });
180
+
181
+ it('rejects disallowed children', () => {
182
+ expectError(`<view><list><text>not an item</text></list></view>`, /<list> may not contain <text>/);
183
+ expectError(`<view><divider>nope</divider></view>`, /<divider> may not contain text/);
184
+ });
185
+
186
+ it('rejects structural errors', () => {
187
+ expectError(`<view><card>unclosed`, /unclosed tag <card>/);
188
+ expectError(`<view></card></view>`, /mismatched closing tag/);
189
+ expectError(`<card>a</card><card>b</card>`, /exactly one root element/);
190
+ });
191
+ });
192
+
193
+ describe('validateDoc', () => {
194
+ it('passes a parsed (expanded) doc and flags a hand-built bad node', () => {
195
+ const ok = parse(`<view><text>hi</text></view>`);
196
+ expect(ok.ok).toBe(true);
197
+ if (!ok.ok) return;
198
+ expect(validateDoc(ok.doc, DEFAULT_VIEW_TAGS)).toEqual([]);
199
+
200
+ const bad = validateDoc(
201
+ { root: { kind: 'element', tag: 'evil', props: {}, children: [] } },
202
+ DEFAULT_VIEW_TAGS,
203
+ );
204
+ expect(bad.some((e) => /unknown tag <evil>/.test(e.message))).toBe(true);
205
+ });
206
+
207
+ it('rejects disallowed URL schemes in a hand-built AST (href/src never bypass the parser)', () => {
208
+ const errsFor = (tag: string, props: Record<string, string>) =>
209
+ validateDoc(
210
+ {
211
+ root: {
212
+ kind: 'element',
213
+ tag: 'view',
214
+ props: {},
215
+ children: [{ kind: 'element', tag, props, children: [] }],
216
+ },
217
+ },
218
+ DEFAULT_VIEW_TAGS,
219
+ );
220
+ expect(
221
+ errsFor('link', { href: 'javascript:alert(1)' }).some((e) => /disallowed URL scheme/.test(e.message)),
222
+ ).toBe(true);
223
+ expect(
224
+ errsFor('image', { src: 'data:text/html,<script>1</script>' }).some((e) =>
225
+ /disallowed URL scheme/.test(e.message),
226
+ ),
227
+ ).toBe(true);
228
+ // Safe schemes stay clean.
229
+ expect(errsFor('link', { href: 'https://example.com' })).toEqual([]);
230
+ expect(errsFor('image', { src: 'data:image/png;base64,AAAA' })).toEqual([]);
231
+ });
232
+ });