@mandujs/core 0.54.32 → 0.55.0-beta.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 (414) hide show
  1. package/README.md +654 -654
  2. package/package.json +106 -210
  3. package/scripts/postinstall-lock.ts +153 -153
  4. package/src/a11y/__tests__/run-audit.test.ts +333 -333
  5. package/src/a11y/fix-hints.ts +76 -76
  6. package/src/a11y/index.ts +18 -18
  7. package/src/a11y/run-audit.ts +15 -15
  8. package/src/a11y/types.ts +125 -125
  9. package/src/agent/__tests__/apply.test.ts +224 -0
  10. package/src/agent/__tests__/context.test.ts +97 -98
  11. package/src/agent/apply.ts +1031 -0
  12. package/src/agent/context.ts +552 -552
  13. package/src/agent/index.ts +4 -3
  14. package/src/agent/plan.ts +161 -243
  15. package/src/agent/repair.ts +239 -162
  16. package/src/agent/sync.ts +197 -198
  17. package/src/agent/types.ts +242 -94
  18. package/src/agent/verify.ts +55 -55
  19. package/src/auth/__tests__/login.test.ts +1 -1
  20. package/src/auth/__tests__/password.test.ts +15 -5
  21. package/src/auth/__tests__/reset.test.ts +2 -2
  22. package/src/auth/__tests__/tokens.test.ts +274 -274
  23. package/src/auth/__tests__/verification.test.ts +274 -274
  24. package/src/auth/index.ts +76 -76
  25. package/src/auth/login.ts +225 -225
  26. package/src/auth/password.ts +14 -2
  27. package/src/auth/reset.ts +243 -243
  28. package/src/auth/tokens.ts +612 -612
  29. package/src/auth/verification.ts +253 -253
  30. package/src/brain/__tests__/redactor.test.ts +94 -94
  31. package/src/brain/adapters/__tests__/_helpers.ts +83 -83
  32. package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
  33. package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
  34. package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
  35. package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
  36. package/src/brain/adapters/anthropic-oauth.ts +1 -1
  37. package/src/brain/adapters/chatgpt-auth.ts +300 -300
  38. package/src/brain/adapters/index.ts +319 -319
  39. package/src/brain/adapters/oauth-flow.ts +439 -439
  40. package/src/brain/consent.ts +240 -240
  41. package/src/brain/credentials.ts +396 -396
  42. package/src/brain/doctor/analyzer.ts +7 -7
  43. package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
  44. package/src/bundler/__tests__/build-runner.ts +166 -166
  45. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
  46. package/src/bundler/__tests__/cold-start.test.ts +60 -60
  47. package/src/bundler/__tests__/css.test.ts +49 -20
  48. package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
  49. package/src/bundler/__tests__/extended-watch.test.ts +711 -711
  50. package/src/bundler/__tests__/fast-refresh.test.ts +24 -24
  51. package/src/bundler/__tests__/generation.test.ts +447 -0
  52. package/src/bundler/__tests__/hdr.test.ts +24 -18
  53. package/src/bundler/__tests__/hmr-client.test.ts +62 -24
  54. package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
  55. package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
  56. package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
  57. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
  58. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
  59. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
  60. package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
  61. package/src/bundler/analyzer.ts +15 -15
  62. package/src/bundler/budget.ts +404 -404
  63. package/src/bundler/build.test.ts +898 -915
  64. package/src/bundler/build.ts +113 -24
  65. package/src/bundler/client-boundary-transform.ts +977 -977
  66. package/src/bundler/css.ts +65 -44
  67. package/src/bundler/dev.ts +105 -73
  68. package/src/bundler/fast-refresh-preamble.ts +47 -47
  69. package/src/bundler/generation.ts +602 -0
  70. package/src/bundler/index.ts +3 -3
  71. package/src/bundler/manifest-schema.ts +55 -40
  72. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  73. package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
  74. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
  75. package/src/bundler/plugins/block-generated-imports.ts +16 -15
  76. package/src/bundler/plugins/index.ts +83 -83
  77. package/src/bundler/plugins/react-compiler-config.ts +108 -108
  78. package/src/bundler/plugins/react-compiler-lint.ts +253 -253
  79. package/src/bundler/plugins/react-compiler.ts +162 -162
  80. package/src/bundler/prerender.ts +29 -9
  81. package/src/bundler/reverse-import-graph.ts +339 -339
  82. package/src/bundler/safe-build.test.ts +1 -1
  83. package/src/bundler/safe-build.ts +103 -103
  84. package/src/bundler/scenario-matrix.ts +229 -229
  85. package/src/bundler/types.ts +58 -56
  86. package/src/bundler/vendor-cache-types.ts +130 -130
  87. package/src/bundler/vendor-cache.ts +526 -526
  88. package/src/change/snapshot.ts +18 -18
  89. package/src/client/Form.tsx +105 -105
  90. package/src/client/Link.tsx +9 -9
  91. package/src/client/__tests__/props-serialization.test.ts +37 -37
  92. package/src/client/__tests__/use-sse.test.ts +153 -153
  93. package/src/client/globals.ts +1 -1
  94. package/src/client/hooks.ts +362 -362
  95. package/src/client/hydrate.ts +2 -2
  96. package/src/client/index.ts +1 -1
  97. package/src/client/island.ts +79 -79
  98. package/src/client/prefetch-helper.ts +55 -55
  99. package/src/client/props-serialization.ts +233 -233
  100. package/src/client/runtime-entry.ts +598 -598
  101. package/src/client/runtime.ts +1 -1
  102. package/src/client/serialize.ts +50 -50
  103. package/src/client/use-fetch.ts +6 -6
  104. package/src/client/use-head.ts +197 -197
  105. package/src/client/use-sse.ts +378 -378
  106. package/src/client/window-state.ts +101 -101
  107. package/src/components/Image-compat.ts +3 -0
  108. package/src/components/Image.tsx +162 -162
  109. package/src/config/validate.ts +1 -1
  110. package/src/config/watcher.ts +311 -311
  111. package/src/constants.ts +40 -40
  112. package/src/content/collection.ts +9 -9
  113. package/src/content/content-layer.ts +7 -7
  114. package/src/content/data-store.ts +245 -245
  115. package/src/content/frontmatter.ts +189 -189
  116. package/src/content/generate-types.ts +1 -1
  117. package/src/content/index.ts +2 -2
  118. package/src/content/loader-context.ts +171 -171
  119. package/src/content/loaders/api.ts +216 -216
  120. package/src/content/loaders/file.ts +172 -172
  121. package/src/content/loaders/glob.ts +253 -253
  122. package/src/content/loaders/index.ts +34 -34
  123. package/src/content/loaders/types.ts +137 -137
  124. package/src/content/meta-store.ts +209 -209
  125. package/src/content/prebuild.test.ts +571 -571
  126. package/src/content/prebuild.ts +636 -636
  127. package/src/content/schema.ts +20 -20
  128. package/src/content/sidebar.ts +630 -630
  129. package/src/content/slug.ts +110 -110
  130. package/src/content/types.ts +282 -282
  131. package/src/content/watcher.ts +135 -135
  132. package/src/contract/client-safe.test.ts +42 -42
  133. package/src/contract/client-safe.ts +114 -114
  134. package/src/contract/define.ts +11 -11
  135. package/src/contract/index.ts +1 -1
  136. package/src/contract/normalize.test.ts +276 -276
  137. package/src/contract/normalize.ts +410 -410
  138. package/src/contract/registry.test.ts +206 -206
  139. package/src/contract/route-helpers.ts +1 -1
  140. package/src/contract/rpc.ts +443 -443
  141. package/src/contract/types.ts +58 -58
  142. package/src/db/__tests__/db.test.ts +9 -9
  143. package/src/db/index.ts +2 -2
  144. package/src/db/migrations/history-table.ts +345 -345
  145. package/src/db/migrations/index.ts +3 -3
  146. package/src/db/migrations/lock.ts +324 -324
  147. package/src/db/migrations/runner.ts +650 -650
  148. package/src/deploy/cache.ts +140 -140
  149. package/src/deploy/compile/vercel.ts +344 -344
  150. package/src/deploy/index.ts +87 -87
  151. package/src/deploy/inference/brain.ts +268 -268
  152. package/src/deploy/inference/context.ts +82 -82
  153. package/src/deploy/inference/filling-extract.ts +245 -245
  154. package/src/deploy/inference/heuristic.ts +182 -182
  155. package/src/deploy/intent.ts +173 -173
  156. package/src/deploy/plan.ts +178 -178
  157. package/src/design/__tests__/agents-link.test.ts +109 -109
  158. package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
  159. package/src/design/__tests__/lint.test.ts +110 -110
  160. package/src/design/__tests__/parser.test.ts +195 -195
  161. package/src/design/__tests__/tailwind-theme.test.ts +229 -229
  162. package/src/design/agents-link.ts +165 -165
  163. package/src/design/diff.ts +138 -138
  164. package/src/design/extract.ts +285 -285
  165. package/src/design/index.ts +102 -102
  166. package/src/design/lint.ts +209 -209
  167. package/src/design/parser.ts +555 -555
  168. package/src/design/patch.ts +241 -241
  169. package/src/design/scaffold.ts +147 -147
  170. package/src/design/tailwind-theme.ts +441 -441
  171. package/src/design/types.ts +210 -210
  172. package/src/desktop/__tests__/smoke.test.ts +2 -2
  173. package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
  174. package/src/desktop/__tests__/window.test.ts +20 -99
  175. package/src/desktop/__tests__/worker.test.ts +266 -266
  176. package/src/desktop/index.ts +43 -43
  177. package/src/desktop/types.ts +158 -158
  178. package/src/desktop/webview-fallback.ts +17 -4
  179. package/src/desktop/window.ts +17 -8
  180. package/src/desktop/worker.ts +180 -180
  181. package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
  182. package/src/dev-error-overlay/index.ts +30 -30
  183. package/src/dev-error-overlay/overlay-injector.ts +243 -243
  184. package/src/dev-error-overlay/overlay-styles.ts +52 -52
  185. package/src/dev-error-overlay/types.ts +66 -66
  186. package/src/devtools/ai/context-builder.ts +375 -375
  187. package/src/devtools/ai/index.ts +25 -25
  188. package/src/devtools/ai/mcp-connector.ts +25 -25
  189. package/src/devtools/client/catchers/error-catcher.ts +344 -344
  190. package/src/devtools/client/catchers/index.ts +18 -18
  191. package/src/devtools/client/components/index.ts +39 -39
  192. package/src/devtools/client/components/mandu-character.tsx +331 -331
  193. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  194. package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
  195. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  196. package/src/devtools/client/components/panel/network-panel.tsx +291 -291
  197. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  198. package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
  199. package/src/devtools/client/filters/context-filters.ts +282 -282
  200. package/src/devtools/client/filters/index.ts +16 -16
  201. package/src/devtools/client/index.ts +63 -63
  202. package/src/devtools/hook/create-hook.ts +207 -207
  203. package/src/devtools/hook/index.ts +13 -13
  204. package/src/devtools/index.ts +439 -439
  205. package/src/devtools/init.ts +265 -265
  206. package/src/devtools/protocol.ts +237 -237
  207. package/src/devtools/server/index.ts +17 -17
  208. package/src/devtools/types.ts +35 -35
  209. package/src/devtools/worker/index.ts +25 -25
  210. package/src/devtools/worker/redaction-worker.ts +233 -233
  211. package/src/diagnose/__tests__/checks.test.ts +136 -136
  212. package/src/diagnose/checks.ts +185 -185
  213. package/src/diagnose/index.ts +17 -17
  214. package/src/diagnose/run.ts +10 -10
  215. package/src/diagnose/types.ts +53 -53
  216. package/src/email/__tests__/email.test.ts +355 -355
  217. package/src/email/index.ts +282 -282
  218. package/src/email/smtp.ts +64 -64
  219. package/src/error/domains.ts +265 -265
  220. package/src/error/types.ts +6 -6
  221. package/src/errors/extractor.ts +409 -409
  222. package/src/errors/index.ts +19 -19
  223. package/src/filling/__tests__/session-sqlite.test.ts +5 -1
  224. package/src/filling/auth.ts +308 -308
  225. package/src/filling/body-parse.test.ts +60 -60
  226. package/src/filling/cookie-codec.ts +5 -3
  227. package/src/filling/deps.ts +265 -265
  228. package/src/filling/head-method.test.ts +154 -154
  229. package/src/filling/session-sqlite.ts +617 -617
  230. package/src/filling/sse.ts +5 -5
  231. package/src/filling/ws.ts +78 -78
  232. package/src/generator/generate.ts +30 -30
  233. package/src/generator/index.ts +3 -3
  234. package/src/generator/templates.test.ts +48 -48
  235. package/src/generator/templates.ts +219 -219
  236. package/src/guard/__tests__/design-inline-class.test.ts +219 -219
  237. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
  238. package/src/guard/analyzer.ts +360 -360
  239. package/src/guard/auto-correct.ts +1 -1
  240. package/src/guard/check.ts +39 -24
  241. package/src/guard/config-guard.ts +13 -13
  242. package/src/guard/contract-guard.ts +9 -9
  243. package/src/guard/define-rule.ts +243 -243
  244. package/src/guard/design-inline-class.ts +393 -393
  245. package/src/guard/file-type.test.ts +24 -24
  246. package/src/guard/fs-routes-policy.ts +51 -51
  247. package/src/guard/graph.ts +1 -1
  248. package/src/guard/healing.ts +36 -36
  249. package/src/guard/index.ts +11 -11
  250. package/src/guard/presets/atomic.ts +70 -70
  251. package/src/guard/presets/clean.ts +77 -77
  252. package/src/guard/presets/fsd.ts +79 -79
  253. package/src/guard/presets/hexagonal.ts +68 -68
  254. package/src/guard/reporter.ts +442 -442
  255. package/src/guard/rule-presets.ts +379 -379
  256. package/src/guard/semantic-slots.ts +1 -1
  257. package/src/guard/suggestions.ts +358 -358
  258. package/src/guard/tsgolint-bridge.ts +512 -512
  259. package/src/guard/types.ts +348 -348
  260. package/src/guard/watcher.ts +405 -405
  261. package/src/i18n/define.ts +126 -126
  262. package/src/i18n/index.ts +52 -52
  263. package/src/i18n/message-registry.ts +173 -173
  264. package/src/i18n/types.ts +112 -112
  265. package/src/id/__tests__/id.test.ts +3 -3
  266. package/src/id/index.ts +105 -105
  267. package/src/index.ts +9 -48
  268. package/src/internal/client-boundary.ts +266 -266
  269. package/src/internal/index.ts +2 -2
  270. package/src/kitchen/api/agent-devtools-api.ts +779 -779
  271. package/src/kitchen/api/bundle-inspector.ts +179 -0
  272. package/src/kitchen/api/errors-grouping.ts +126 -126
  273. package/src/kitchen/api/file-api.ts +11 -11
  274. package/src/kitchen/kitchen-handler.ts +36 -0
  275. package/src/kitchen/kitchen-ui.ts +456 -0
  276. package/src/logging/index.ts +22 -22
  277. package/src/logging/transports.ts +365 -365
  278. package/src/middleware/bridge.ts +147 -147
  279. package/src/middleware/compose.ts +134 -134
  280. package/src/middleware/compress.ts +62 -62
  281. package/src/middleware/cors.ts +47 -47
  282. package/src/middleware/csrf.ts +328 -328
  283. package/src/middleware/define.ts +132 -132
  284. package/src/middleware/jwt.ts +134 -134
  285. package/src/middleware/logger.ts +58 -58
  286. package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
  287. package/src/middleware/oauth/index.ts +505 -505
  288. package/src/middleware/oauth/providers.ts +115 -115
  289. package/src/middleware/rate-limit/index.ts +522 -522
  290. package/src/middleware/rate-limit/sqlite-store.ts +382 -382
  291. package/src/middleware/scheduler-cron.ts +96 -96
  292. package/src/middleware/secure/__tests__/secure.test.ts +360 -360
  293. package/src/middleware/secure/csp.ts +193 -193
  294. package/src/middleware/session.ts +174 -174
  295. package/src/middleware/timeout.ts +55 -55
  296. package/src/observability/logger-adapter.ts +36 -36
  297. package/src/observability/sqlite-store.ts +254 -254
  298. package/src/openapi/generator.ts +1 -1
  299. package/src/openapi/openapi.test.ts +43 -43
  300. package/src/perf/__tests__/user-marks.test.ts +354 -354
  301. package/src/perf/index.ts +133 -133
  302. package/src/perf/user-marks.ts +1 -1
  303. package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
  304. package/src/plugins/__tests__/runner.test.ts +409 -409
  305. package/src/plugins/define.ts +124 -124
  306. package/src/plugins/examples/dep-check-plugin.ts +80 -80
  307. package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
  308. package/src/plugins/examples/sitemap-plugin.ts +65 -65
  309. package/src/plugins/runner.ts +361 -361
  310. package/src/plugins/types.ts +368 -368
  311. package/src/report/index.ts +1 -1
  312. package/src/resource/__tests__/generator.test.ts +7 -7
  313. package/src/resource/__tests__/schema.test.ts +14 -14
  314. package/src/resource/ddl/__tests__/diff.test.ts +639 -639
  315. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  316. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
  317. package/src/resource/ddl/emit.ts +146 -146
  318. package/src/resource/ddl/persistence-types.ts +218 -218
  319. package/src/resource/ddl/type-map.ts +223 -223
  320. package/src/resource/ddl/types.ts +232 -232
  321. package/src/resource/generator-repo.ts +630 -630
  322. package/src/resource/generator-schema.ts +11 -11
  323. package/src/resource/generators/slot.ts +72 -72
  324. package/src/resource/schema.ts +21 -21
  325. package/src/router/client-entry.test.ts +227 -227
  326. package/src/router/client-entry.ts +218 -218
  327. package/src/router/fs-patterns.test.ts +96 -96
  328. package/src/router/fs-routes.test.ts +532 -532
  329. package/src/router/fs-routes.ts +53 -53
  330. package/src/router/fs-scanner.ts +216 -216
  331. package/src/router/fs-types.ts +19 -19
  332. package/src/router/route-source-analyzer.ts +521 -521
  333. package/src/routes/index.ts +74 -74
  334. package/src/routes/metadata-routes.ts +427 -427
  335. package/src/routes/types.ts +341 -341
  336. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  337. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
  338. package/src/runtime/__tests__/hdr-client.test.ts +223 -223
  339. package/src/runtime/__tests__/http-errors.test.ts +117 -117
  340. package/src/runtime/__tests__/inline-client-hydration.test.ts +237 -237
  341. package/src/runtime/__tests__/not-found.test.ts +152 -152
  342. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  343. package/src/runtime/__tests__/page-render-response.test.ts +517 -517
  344. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  345. package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
  346. package/src/runtime/adapter.ts +47 -47
  347. package/src/runtime/boundary.tsx +252 -252
  348. package/src/runtime/cache.ts +494 -494
  349. package/src/runtime/compose.ts +222 -222
  350. package/src/runtime/devtools-adapter.ts +68 -68
  351. package/src/runtime/escape.ts +34 -34
  352. package/src/runtime/fast-refresh-runtime.ts +322 -322
  353. package/src/runtime/fast-refresh-types.ts +1 -1
  354. package/src/runtime/handler.ts +65 -65
  355. package/src/runtime/handlers.ts +30 -11
  356. package/src/runtime/http-errors.ts +113 -113
  357. package/src/runtime/image-handler.ts +1 -1
  358. package/src/runtime/lifecycle.ts +381 -381
  359. package/src/runtime/logger.test.ts +345 -345
  360. package/src/runtime/middleware.ts +264 -264
  361. package/src/runtime/not-found.ts +93 -93
  362. package/src/runtime/observability-lifecycle.ts +290 -290
  363. package/src/runtime/openapi-endpoint.ts +236 -236
  364. package/src/runtime/page-render-response.ts +287 -274
  365. package/src/runtime/ppr.ts +74 -74
  366. package/src/runtime/rate-limit.ts +1 -1
  367. package/src/runtime/redirect.ts +1 -1
  368. package/src/runtime/registry.ts +171 -171
  369. package/src/runtime/request-middleware.ts +31 -31
  370. package/src/runtime/router.test.ts +4 -4
  371. package/src/runtime/router.ts +10 -10
  372. package/src/runtime/server.ts +127 -211
  373. package/src/runtime/shims.ts +48 -48
  374. package/src/runtime/ssr.ts +43 -17
  375. package/src/runtime/static-files.ts +369 -289
  376. package/src/runtime/streaming-ssr.ts +219 -180
  377. package/src/runtime/trace.ts +144 -144
  378. package/src/scheduler/__tests__/scheduler.test.ts +12 -11
  379. package/src/scheduler/index.ts +11 -5
  380. package/src/scheduler/validate.ts +169 -169
  381. package/src/seo/index.ts +219 -219
  382. package/src/seo/integration/ssr.ts +306 -306
  383. package/src/seo/render/basic.ts +435 -435
  384. package/src/seo/render/index.ts +143 -143
  385. package/src/seo/render/jsonld.ts +539 -539
  386. package/src/seo/render/opengraph.ts +197 -197
  387. package/src/seo/render/robots.ts +116 -116
  388. package/src/seo/render/sitemap.ts +137 -137
  389. package/src/seo/render/twitter.ts +127 -127
  390. package/src/seo/resolve/opengraph.ts +143 -143
  391. package/src/seo/resolve/robots.ts +73 -73
  392. package/src/seo/resolve/title.ts +94 -94
  393. package/src/seo/resolve/twitter.ts +73 -73
  394. package/src/seo/resolve/url.ts +104 -104
  395. package/src/seo/routes/index.ts +290 -290
  396. package/src/seo/types.ts +588 -588
  397. package/src/slot/validator.ts +39 -39
  398. package/src/spec/schema.ts +35 -35
  399. package/src/storage/s3/__tests__/s3.test.ts +479 -479
  400. package/src/storage/s3/index.ts +412 -412
  401. package/src/testing/__tests__/assertions.test.ts +632 -632
  402. package/src/testing/__tests__/reporter.test.ts +454 -454
  403. package/src/testing/assertions.ts +986 -986
  404. package/src/testing/db.ts +157 -157
  405. package/src/testing/index.ts +9 -3
  406. package/src/testing/lcov.ts +192 -0
  407. package/src/testing/mocks.ts +203 -203
  408. package/src/testing/session.ts +190 -190
  409. package/src/types/branded.ts +56 -56
  410. package/src/types/index.ts +1 -1
  411. package/src/utils/safe-io.ts +188 -188
  412. package/src/utils/string-safe.ts +298 -298
  413. package/src/watcher/__tests__/watcher.test.ts +59 -59
  414. package/src/watcher/watcher.ts +61 -61
@@ -1,986 +1,986 @@
1
- /**
2
- * Phase C.1 — Semantic Primitives (@mandujs/core/testing barrel).
3
- *
4
- * Mandu-specific assertion primitives that the generic Playwright / bun:test
5
- * vocabulary cannot express cleanly:
6
- *
7
- * - `expectContract` — Zod shape validation with strict/loose/drift
8
- * modes and path-based `ignorePaths`. Replaces `JSON.stringify`
9
- * comparisons.
10
- * - `expectNavigation` — redirect-chain capture for Playwright pages.
11
- * - `waitForIsland` — polls `data-island="<name>"`'s
12
- * `data-hydrated`/`data-island-state` attribute. Short-circuits for
13
- * `hydration:none` strategies.
14
- * - `assertStreamBoundary`— consumes a streaming SSR response and counts
15
- * `<!--$-->` / `<!--/$-->` boundary markers. Validates shell chunk
16
- * byte budgets and tail chunk content.
17
- * - `expectSemantic` — agent-delegated oracle. Writes an entry to
18
- * `.mandu/ate-oracle-queue.jsonl` for an agent to judge later. CI is
19
- * never blocked (`MANDU_ATE_DETERMINISTIC_ONLY=1` skips queueing).
20
- * `promoteVerdicts: true` option lets a previously-failed verdict
21
- * become a deterministic fail on re-run.
22
- *
23
- * Spec: docs/ate/phase-c-spec.md §C.1.
24
- *
25
- * These primitives live in `@mandujs/core/testing` (not `@mandujs/ate`) so
26
- * they are usable in any Mandu project test file, with or without the ATE
27
- * MCP server.
28
- */
29
-
30
- import {
31
- existsSync,
32
- mkdirSync,
33
- readFileSync,
34
- writeFileSync,
35
- appendFileSync,
36
- } from "node:fs";
37
- import { join, dirname, isAbsolute } from "node:path";
38
-
39
- // ────────────────────────────────────────────────────────────────────────────
40
- // expectContract
41
- // ────────────────────────────────────────────────────────────────────────────
42
-
43
- /**
44
- * Minimal structural type of a Zod schema. We don't import `zod` here —
45
- * the caller provides the schema and we only call `safeParse`. This keeps
46
- * `@mandujs/core/testing` free of a zod peer dep for consumers who don't
47
- * use it.
48
- */
49
- /**
50
- * Structural type matching both real `zod` schemas and hand-rolled
51
- * fakes. We intentionally widen the `error.issues` shape to `unknown[]`
52
- * — the primitive normalizes each issue internally so the call sites
53
- * don't have to worry about Zod version drift.
54
- */
55
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
- export interface ZodLikeSchema<T = unknown> {
57
- safeParse: (input: unknown) => {
58
- success: boolean;
59
- data?: T;
60
- // Intentionally loose — see note above.
61
- error?: { issues: readonly unknown[] } | undefined;
62
- };
63
- }
64
-
65
- interface NormalizedIssue {
66
- path: Array<string | number>;
67
- message: string;
68
- code?: string;
69
- expected?: string;
70
- received?: string;
71
- }
72
-
73
- function normalizeIssue(raw: unknown): NormalizedIssue {
74
- const obj = (raw ?? {}) as Record<string, unknown>;
75
- const pathRaw = obj.path;
76
- const path: Array<string | number> = Array.isArray(pathRaw)
77
- ? (pathRaw.filter((p) => typeof p === "string" || typeof p === "number") as Array<
78
- string | number
79
- >)
80
- : [];
81
- return {
82
- path,
83
- message: typeof obj.message === "string" ? obj.message : "",
84
- ...(typeof obj.code === "string" ? { code: obj.code } : {}),
85
- ...(typeof obj.expected === "string" ? { expected: obj.expected } : {}),
86
- ...(typeof obj.received === "string" ? { received: obj.received } : {}),
87
- };
88
- }
89
-
90
- export type ContractMode = "strict" | "loose" | "drift-tolerant";
91
-
92
- export interface ContractViolation {
93
- path: string;
94
- expected: string;
95
- actual: string;
96
- severity: "critical" | "warning";
97
- }
98
-
99
- export interface ExpectContractOptions {
100
- mode?: ContractMode;
101
- /**
102
- * Dot-notation paths to ignore. Use `.createdAt`, `.items[0].updatedAt`,
103
- * or `.user.id`. Matching is prefix-aware — `.user` ignores every
104
- * descendant field.
105
- */
106
- ignorePaths?: string[];
107
- }
108
-
109
- export interface ExpectContractResult {
110
- status: "pass" | "fail";
111
- violations: ContractViolation[];
112
- }
113
-
114
- /**
115
- * Validate `actual` against a Zod schema and surface structured
116
- * violations. Throws when `status === "fail"` unless `mode` is
117
- * `drift-tolerant`, which only records warnings.
118
- *
119
- * @example
120
- * ```ts
121
- * expectContract(await res.json(), SignupResponseSchema, {
122
- * mode: "loose",
123
- * ignorePaths: [".createdAt", ".user.id"],
124
- * });
125
- * ```
126
- */
127
- export function expectContract<T>(
128
- actual: unknown,
129
- schema: ZodLikeSchema<T>,
130
- options: ExpectContractOptions = {},
131
- ): ExpectContractResult {
132
- const mode: ContractMode = options.mode ?? "strict";
133
- const ignore = options.ignorePaths ?? [];
134
-
135
- const result = schema.safeParse(actual);
136
-
137
- if (result.success) {
138
- // Strict mode: walk `actual` looking for keys not mentioned anywhere
139
- // in the schema. We can't introspect a Zod schema structurally without
140
- // the runtime — instead we round-trip through `safeParse`'s `data`
141
- // which a default Zod schema strips unknown keys from (unless the
142
- // schema uses `.passthrough()` / `.strict()`). If `data` shape differs
143
- // from `actual` in strict mode, we flag the extras.
144
- if (mode === "strict" && result.data !== undefined) {
145
- const extras = findExtraKeys("", actual, result.data, ignore);
146
- if (extras.length > 0) {
147
- const violations: ContractViolation[] = extras.map((e) => ({
148
- path: e,
149
- expected: "(not in schema)",
150
- actual: "extra key present",
151
- severity: "critical",
152
- }));
153
- throwViolations(violations, "strict");
154
- return { status: "fail", violations };
155
- }
156
- }
157
- return { status: "pass", violations: [] };
158
- }
159
-
160
- // safeParse failed — translate Zod issues to our violation shape.
161
- const rawIssues = result.error?.issues ?? [];
162
- const violations: ContractViolation[] = [];
163
- for (const raw of rawIssues) {
164
- const issue = normalizeIssue(raw);
165
- const path = pathToString(issue.path);
166
- if (isIgnored(path, ignore)) continue;
167
-
168
- // loose mode forgives extra-key errors (unrecognized keys) but keeps
169
- // `missing_required` + format violations.
170
- if (mode === "loose" && isExtraKeyIssue(issue)) continue;
171
-
172
- const severity: "critical" | "warning" =
173
- mode === "drift-tolerant" ? "warning" : "critical";
174
- violations.push({
175
- path,
176
- expected: issue.expected ?? issue.message,
177
- actual: issue.received ?? describeActual(actual, issue.path),
178
- severity,
179
- });
180
- }
181
-
182
- if (violations.length === 0) {
183
- return { status: "pass", violations: [] };
184
- }
185
-
186
- if (mode === "drift-tolerant") {
187
- // Warnings collected; no throw. Caller may log via
188
- // `mandu_ate_remember({ kind: "contract_drift" })`.
189
- return { status: "fail", violations };
190
- }
191
-
192
- throwViolations(violations, mode);
193
- return { status: "fail", violations };
194
- }
195
-
196
- function isExtraKeyIssue(issue: { code?: string; message?: string }): boolean {
197
- if (issue.code === "unrecognized_keys") return true;
198
- return /unrecognized key/i.test(issue.message ?? "");
199
- }
200
-
201
- function throwViolations(violations: ContractViolation[], mode: ContractMode): never {
202
- const summary = violations
203
- .slice(0, 5)
204
- .map((v) => ` - ${v.path}: expected ${v.expected}, got ${v.actual}`)
205
- .join("\n");
206
- const more = violations.length > 5 ? `\n ... +${violations.length - 5} more` : "";
207
- throw new ContractAssertionError(
208
- `expectContract (${mode}) failed with ${violations.length} violation(s):\n${summary}${more}`,
209
- violations,
210
- );
211
- }
212
-
213
- export class ContractAssertionError extends Error {
214
- violations: ContractViolation[];
215
- constructor(message: string, violations: ContractViolation[]) {
216
- super(message);
217
- this.name = "ContractAssertionError";
218
- this.violations = violations;
219
- }
220
- }
221
-
222
- function pathToString(path: Array<string | number>): string {
223
- let out = "";
224
- for (const seg of path) {
225
- if (typeof seg === "number") out += `[${seg}]`;
226
- else out += `.${seg}`;
227
- }
228
- return out;
229
- }
230
-
231
- function isIgnored(path: string, ignore: string[]): boolean {
232
- for (const ign of ignore) {
233
- if (path === ign || path.startsWith(ign + ".") || path.startsWith(ign + "[")) {
234
- return true;
235
- }
236
- }
237
- return false;
238
- }
239
-
240
- function describeActual(actual: unknown, path: Array<string | number>): string {
241
- let cursor: unknown = actual;
242
- for (const seg of path) {
243
- if (cursor === null || cursor === undefined) return "undefined";
244
- cursor = (cursor as Record<string | number, unknown>)[seg as string];
245
- }
246
- if (cursor === null) return "null";
247
- if (cursor === undefined) return "undefined";
248
- if (typeof cursor === "object") return JSON.stringify(cursor).slice(0, 60);
249
- return JSON.stringify(cursor);
250
- }
251
-
252
- /**
253
- * Find keys present in `actual` that are NOT present in `parsed` — extra
254
- * keys Zod stripped during safeParse. Only used in strict mode.
255
- */
256
- function findExtraKeys(
257
- prefix: string,
258
- actual: unknown,
259
- parsed: unknown,
260
- ignore: string[],
261
- ): string[] {
262
- const out: string[] = [];
263
- if (!actual || typeof actual !== "object" || Array.isArray(actual)) return out;
264
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return out;
265
-
266
- const actualKeys = Object.keys(actual as Record<string, unknown>);
267
- const parsedKeys = new Set(Object.keys(parsed as Record<string, unknown>));
268
-
269
- for (const key of actualKeys) {
270
- const path = `${prefix}.${key}`;
271
- if (isIgnored(path, ignore)) continue;
272
- if (!parsedKeys.has(key)) {
273
- out.push(path);
274
- continue;
275
- }
276
- const aVal = (actual as Record<string, unknown>)[key];
277
- const pVal = (parsed as Record<string, unknown>)[key];
278
- if (
279
- aVal !== null &&
280
- typeof aVal === "object" &&
281
- !Array.isArray(aVal) &&
282
- pVal !== null &&
283
- typeof pVal === "object" &&
284
- !Array.isArray(pVal)
285
- ) {
286
- out.push(...findExtraKeys(path, aVal, pVal, ignore));
287
- }
288
- }
289
-
290
- return out;
291
- }
292
-
293
- // ────────────────────────────────────────────────────────────────────────────
294
- // expectNavigation
295
- // ────────────────────────────────────────────────────────────────────────────
296
-
297
- /**
298
- * Minimal Playwright Page shape — we only touch the bits we need so
299
- * consumers can use `@playwright/test`'s `Page` without creating an
300
- * import coupling.
301
- */
302
- export interface PlaywrightLikePage {
303
- url(): string;
304
- on(event: "framenavigated", listener: (frame: { url: () => string }) => void): void;
305
- off?(event: "framenavigated", listener: (frame: { url: () => string }) => void): void;
306
- waitForURL?(url: string | RegExp, opts?: { timeout?: number }): Promise<void>;
307
- waitForLoadState?(state?: "load" | "networkidle" | "domcontentloaded", opts?: { timeout?: number }): Promise<void>;
308
- }
309
-
310
- export interface ExpectNavigationInput {
311
- from?: string;
312
- to: string | RegExp;
313
- /** Exact chain length — overrides `maxRedirects` when both set. */
314
- redirectCount?: number;
315
- /** ≤ — chain length must not exceed this. */
316
- maxRedirects?: number;
317
- /** Milliseconds to wait for the terminal URL to match. Default 5000. */
318
- timeoutMs?: number;
319
- }
320
-
321
- export interface ExpectNavigationResult {
322
- status: "pass";
323
- chain: string[];
324
- finalUrl: string;
325
- }
326
-
327
- /**
328
- * Validate a redirect chain. Installs a `framenavigated` listener before
329
- * asserting the final URL matches `to`. When `from` is given we assert
330
- * the starting URL matches first.
331
- *
332
- * Emits a structured `failure.v1` `redirect_unexpected` error on
333
- * mismatch — callers should wrap in try/catch to translate to their
334
- * runner's fail helper.
335
- */
336
- export async function expectNavigation(
337
- page: PlaywrightLikePage,
338
- expectation: ExpectNavigationInput,
339
- ): Promise<ExpectNavigationResult> {
340
- const timeoutMs = expectation.timeoutMs ?? 5000;
341
- const chain: string[] = [];
342
- let firstUrl: string | null = null;
343
-
344
- const onNav = (frame: { url: () => string }) => {
345
- const u = frame.url();
346
- if (firstUrl === null) firstUrl = u;
347
- // Only append when the URL actually changes.
348
- if (chain.length === 0 || chain[chain.length - 1] !== u) {
349
- chain.push(u);
350
- }
351
- };
352
- page.on("framenavigated", onNav);
353
-
354
- try {
355
- // Seed the chain with the current URL before waiting, so synchronous
356
- // navigations are captured even if `framenavigated` fires before we
357
- // attach.
358
- const current = page.url();
359
- if (current) chain.push(current);
360
-
361
- if (page.waitForURL) {
362
- try {
363
- await page.waitForURL(expectation.to, { timeout: timeoutMs });
364
- } catch {
365
- // fall through — we'll diagnose below
366
- }
367
- } else {
368
- // Fallback poll when waitForURL isn't available (mock pages etc).
369
- const deadline = Date.now() + timeoutMs;
370
- while (Date.now() < deadline) {
371
- if (urlMatches(page.url(), expectation.to)) break;
372
- await new Promise((r) => setTimeout(r, 25));
373
- }
374
- }
375
-
376
- const finalUrl = page.url();
377
- if (chain.length === 0 || chain[chain.length - 1] !== finalUrl) {
378
- chain.push(finalUrl);
379
- }
380
-
381
- // Validate `from`.
382
- if (expectation.from !== undefined) {
383
- const first = chain[0];
384
- if (first && !urlMatches(first, expectation.from)) {
385
- throw new NavigationAssertionError(
386
- `expectNavigation: starting URL mismatch. Expected ${expectation.from}, got ${first}`,
387
- { from: expectation.from, expectedTo: String(expectation.to), actualTo: finalUrl, chain },
388
- );
389
- }
390
- }
391
-
392
- // Validate final URL.
393
- if (!urlMatches(finalUrl, expectation.to)) {
394
- throw new NavigationAssertionError(
395
- `expectNavigation: terminal URL mismatch. Expected ${expectation.to}, got ${finalUrl}`,
396
- { from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
397
- );
398
- }
399
-
400
- // Validate chain length.
401
- // Redirect count excludes the starting URL itself when `from` is set.
402
- const chainHops = expectation.from !== undefined ? Math.max(0, chain.length - 1) : chain.length;
403
- if (expectation.redirectCount !== undefined && chainHops !== expectation.redirectCount) {
404
- throw new NavigationAssertionError(
405
- `expectNavigation: redirect count mismatch. Expected exactly ${expectation.redirectCount}, got ${chainHops}`,
406
- { from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
407
- );
408
- }
409
- if (expectation.maxRedirects !== undefined && chainHops > expectation.maxRedirects) {
410
- throw new NavigationAssertionError(
411
- `expectNavigation: redirect chain too long. Expected ≤ ${expectation.maxRedirects}, got ${chainHops}`,
412
- { from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
413
- );
414
- }
415
-
416
- return { status: "pass", chain, finalUrl };
417
- } finally {
418
- if (page.off) {
419
- try {
420
- page.off("framenavigated", onNav);
421
- } catch {
422
- // ignore
423
- }
424
- }
425
- }
426
- }
427
-
428
- export class NavigationAssertionError extends Error {
429
- /** Shape matches `failure.v1` `redirect_unexpected` detail. */
430
- detail: {
431
- from: string;
432
- expectedTo: string;
433
- actualTo: string;
434
- chain: string[];
435
- };
436
- kind = "redirect_unexpected" as const;
437
- constructor(
438
- message: string,
439
- detail: { from: string; expectedTo: string; actualTo: string; chain: string[] },
440
- ) {
441
- super(message);
442
- this.name = "NavigationAssertionError";
443
- this.detail = detail;
444
- }
445
- }
446
-
447
- function urlMatches(actual: string, expected: string | RegExp): boolean {
448
- if (expected instanceof RegExp) return expected.test(actual);
449
- // String match — treat as substring OR exact. "/kr" should match
450
- // "http://localhost/kr" too.
451
- if (actual === expected) return true;
452
- try {
453
- const parsed = new URL(actual);
454
- if (parsed.pathname === expected) return true;
455
- if (parsed.pathname + parsed.search === expected) return true;
456
- } catch {
457
- // fall through
458
- }
459
- return actual.includes(expected);
460
- }
461
-
462
- // ────────────────────────────────────────────────────────────────────────────
463
- // waitForIsland
464
- // ────────────────────────────────────────────────────────────────────────────
465
-
466
- export interface WaitForIslandOptions {
467
- timeoutMs?: number;
468
- /** "hydrated" (default) or "visible". "visible" only checks mount. */
469
- state?: "hydrated" | "visible";
470
- /**
471
- * Override how islands expose their strategy. When this returns
472
- * "none" we short-circuit and resolve immediately.
473
- */
474
- strategyOf?: (page: PlaywrightIslandPage, name: string) => Promise<"none" | "other" | null>;
475
- }
476
-
477
- export interface PlaywrightIslandPage {
478
- /**
479
- * Invoke a JS function in the browser context. We use
480
- * `page.evaluate(fn, arg)` — same signature as Playwright's `Page`.
481
- */
482
- evaluate<T, A>(fn: (arg: A) => T, arg: A): Promise<T>;
483
- }
484
-
485
- /**
486
- * Wait until `[data-island="<name>"]` is hydrated.
487
- *
488
- * `hydration:none` strategy islands resolve immediately (Mandu's SSR
489
- * emits `data-island-strategy="none"` for those — we check that first
490
- * and short-circuit).
491
- *
492
- * Primary signal: `data-hydrated="true"` attribute (the one emitted by
493
- * `@mandujs/core/client/hydrate`). Fallback: `data-island-state="hydrated"`.
494
- */
495
- export async function waitForIsland(
496
- page: PlaywrightIslandPage,
497
- name: string,
498
- options: WaitForIslandOptions = {},
499
- ): Promise<void> {
500
- const timeoutMs = options.timeoutMs ?? 3000;
501
- const state = options.state ?? "hydrated";
502
-
503
- // Short-circuit for hydration:none islands.
504
- try {
505
- const strategy = await page.evaluate(
506
- (n: string) => {
507
- const el = document.querySelector(`[data-island="${n}"]`);
508
- if (!el) return null;
509
- const s = el.getAttribute("data-island-strategy");
510
- return s ?? null;
511
- },
512
- name,
513
- );
514
- if (strategy === "none") {
515
- return;
516
- }
517
- } catch {
518
- // fall through — evaluate may be unsupported in mocks
519
- }
520
-
521
- const deadline = Date.now() + timeoutMs;
522
- const pollInterval = 25;
523
-
524
- while (Date.now() < deadline) {
525
- try {
526
- const status = await page.evaluate(
527
- (args: { name: string; state: string }) => {
528
- const el = document.querySelector(`[data-island="${args.name}"]`);
529
- if (!el) return { mounted: false, hydrated: false };
530
- const mounted = true;
531
- if (args.state === "visible") return { mounted, hydrated: mounted };
532
- // primary
533
- if (el.getAttribute("data-hydrated") === "true") {
534
- return { mounted, hydrated: true };
535
- }
536
- // fallback
537
- if (el.getAttribute("data-island-state") === "hydrated") {
538
- return { mounted, hydrated: true };
539
- }
540
- return { mounted, hydrated: false };
541
- },
542
- { name, state },
543
- );
544
- if (state === "visible" && status.mounted) return;
545
- if (status.hydrated) return;
546
- } catch {
547
- // evaluate failure — treat as not-yet-ready and retry.
548
- }
549
- await new Promise((r) => setTimeout(r, pollInterval));
550
- }
551
-
552
- throw new HydrationTimeoutError(
553
- `waitForIsland: island "${name}" did not reach state=${state} within ${timeoutMs}ms`,
554
- { island: name, waitedMs: timeoutMs },
555
- );
556
- }
557
-
558
- export class HydrationTimeoutError extends Error {
559
- kind = "hydration_timeout" as const;
560
- detail: { island: string; waitedMs: number };
561
- constructor(message: string, detail: { island: string; waitedMs: number }) {
562
- super(message);
563
- this.name = "HydrationTimeoutError";
564
- this.detail = detail;
565
- }
566
- }
567
-
568
- // ────────────────────────────────────────────────────────────────────────────
569
- // assertStreamBoundary
570
- // ────────────────────────────────────────────────────────────────────────────
571
-
572
- export interface AssertStreamBoundaryInput {
573
- /** Substrings the FIRST decoded chunk must contain (all of them). */
574
- shellChunkContains?: string[];
575
- /** Exact count of `<!--$-->` / `<!--/$-->` pairs. */
576
- boundaryCount?: number;
577
- /** First-chunk size guard (bytes). */
578
- firstChunkMaxSizeBytes?: number;
579
- /** Any one of these strings must appear in the final chunk. */
580
- tailChunkContainsAnyOf?: string[];
581
- }
582
-
583
- export interface AssertStreamBoundaryResult {
584
- status: "pass";
585
- chunks: number;
586
- totalBytes: number;
587
- boundaryOpenCount: number;
588
- boundaryCloseCount: number;
589
- }
590
-
591
- /**
592
- * Consume a streaming Response chunk-by-chunk and validate boundary
593
- * markers / shell content / byte budgets.
594
- *
595
- * Throws `StreamBoundaryError` (failure.v1-shaped) on mismatch.
596
- */
597
- export async function assertStreamBoundary(
598
- response: Response,
599
- expectations: AssertStreamBoundaryInput,
600
- ): Promise<AssertStreamBoundaryResult> {
601
- const body = response.body;
602
- if (!body) {
603
- throw new StreamBoundaryError("assertStreamBoundary: response has no body", {
604
- reason: "no_body",
605
- });
606
- }
607
-
608
- const reader = body.getReader();
609
- const decoder = new TextDecoder("utf-8", { fatal: false });
610
- const chunks: string[] = [];
611
- let totalBytes = 0;
612
- let firstChunkBytes = 0;
613
-
614
- try {
615
- // eslint-disable-next-line no-constant-condition
616
- while (true) {
617
- const { done, value } = await reader.read();
618
- if (done) break;
619
- if (!value) continue;
620
- const bytes = value.byteLength;
621
- totalBytes += bytes;
622
- if (chunks.length === 0) firstChunkBytes = bytes;
623
- chunks.push(decoder.decode(value, { stream: true }));
624
- }
625
- // flush trailing decoder state
626
- chunks.push(decoder.decode());
627
- } finally {
628
- try {
629
- reader.releaseLock();
630
- } catch {
631
- // ignore
632
- }
633
- }
634
-
635
- const shell = chunks[0] ?? "";
636
- // Walk back to find the last non-empty chunk. The TextDecoder's final
637
- // flush can append an empty string — that would give us a useless tail.
638
- let tail = "";
639
- for (let i = chunks.length - 1; i >= 0; i--) {
640
- if (chunks[i] && chunks[i].length > 0) {
641
- tail = chunks[i];
642
- break;
643
- }
644
- }
645
- if (!tail) tail = chunks.join("");
646
- const full = chunks.join("");
647
-
648
- // shellChunkContains
649
- if (expectations.shellChunkContains) {
650
- for (const needle of expectations.shellChunkContains) {
651
- if (!shell.includes(needle)) {
652
- throw new StreamBoundaryError(
653
- `assertStreamBoundary: first chunk missing expected content "${needle}"`,
654
- {
655
- reason: "shell_missing_content",
656
- missing: needle,
657
- shellPreview: shell.slice(0, 200),
658
- },
659
- );
660
- }
661
- }
662
- }
663
-
664
- // firstChunkMaxSizeBytes
665
- if (
666
- expectations.firstChunkMaxSizeBytes !== undefined &&
667
- firstChunkBytes > expectations.firstChunkMaxSizeBytes
668
- ) {
669
- throw new StreamBoundaryError(
670
- `assertStreamBoundary: first chunk ${firstChunkBytes} bytes exceeds budget ${expectations.firstChunkMaxSizeBytes}`,
671
- {
672
- reason: "shell_over_budget",
673
- firstChunkBytes,
674
- budget: expectations.firstChunkMaxSizeBytes,
675
- },
676
- );
677
- }
678
-
679
- // boundary count — count occurrences of `<!--$-->` (open) and
680
- // `<!--/$-->` (close). A "boundary" is one open+close pair, so we
681
- // expect the open count to equal the close count AND equal
682
- // `expectations.boundaryCount`.
683
- const openCount = countOccurrences(full, "<!--$-->");
684
- const closeCount = countOccurrences(full, "<!--/$-->");
685
- if (
686
- expectations.boundaryCount !== undefined &&
687
- openCount !== expectations.boundaryCount
688
- ) {
689
- throw new StreamBoundaryError(
690
- `assertStreamBoundary: expected ${expectations.boundaryCount} Suspense boundaries, saw ${openCount} open / ${closeCount} close`,
691
- { reason: "boundary_count_mismatch", expected: expectations.boundaryCount, openCount, closeCount },
692
- );
693
- }
694
-
695
- // tailChunkContainsAnyOf
696
- if (expectations.tailChunkContainsAnyOf && expectations.tailChunkContainsAnyOf.length > 0) {
697
- const hit = expectations.tailChunkContainsAnyOf.some((n) => tail.includes(n));
698
- if (!hit) {
699
- throw new StreamBoundaryError(
700
- `assertStreamBoundary: tail chunk missing any of [${expectations.tailChunkContainsAnyOf.join(", ")}]`,
701
- {
702
- reason: "tail_missing_content",
703
- candidates: expectations.tailChunkContainsAnyOf,
704
- tailPreview: tail.slice(-200),
705
- },
706
- );
707
- }
708
- }
709
-
710
- return {
711
- status: "pass",
712
- chunks: chunks.length,
713
- totalBytes,
714
- boundaryOpenCount: openCount,
715
- boundaryCloseCount: closeCount,
716
- };
717
- }
718
-
719
- export class StreamBoundaryError extends Error {
720
- kind = "stream_boundary_mismatch" as const;
721
- detail: Record<string, unknown>;
722
- constructor(message: string, detail: Record<string, unknown>) {
723
- super(message);
724
- this.name = "StreamBoundaryError";
725
- this.detail = detail;
726
- }
727
- }
728
-
729
- function countOccurrences(haystack: string, needle: string): number {
730
- if (!needle) return 0;
731
- let count = 0;
732
- let idx = 0;
733
- while ((idx = haystack.indexOf(needle, idx)) !== -1) {
734
- count++;
735
- idx += needle.length;
736
- }
737
- return count;
738
- }
739
-
740
- // ────────────────────────────────────────────────────────────────────────────
741
- // expectSemantic — agent-delegated oracle
742
- // ────────────────────────────────────────────────────────────────────────────
743
-
744
- export interface ExpectSemanticOptions {
745
- /** What to capture. Default "both". */
746
- capture?: "screenshot" | "dom" | "both";
747
- /** Treat the queueing as non-blocking (default). */
748
- deferToAgent?: boolean;
749
- /**
750
- * When true, past `failed` verdicts for the same claim promote to a
751
- * deterministic fail on this run. Default false. Per §C.1.5.
752
- */
753
- promoteVerdicts?: boolean;
754
- /** Override the repo root — default `process.cwd()`. */
755
- repoRoot?: string;
756
- /** Override the spec path recorded in the queue entry. */
757
- specPath?: string;
758
- /** Override the runId recorded in the queue entry. */
759
- runId?: string;
760
- /** Inject a fixed timestamp — for goldens / tests. */
761
- now?: () => string;
762
- /**
763
- * Optional DOM snapshot — tests pass it in explicitly so we don't have
764
- * to stand up a headless browser. In real use this is captured via
765
- * `page.content()`.
766
- */
767
- domSnapshot?: string;
768
- /**
769
- * Optional screenshot bytes — tests pass it in explicitly so we don't
770
- * require Playwright at callers.
771
- */
772
- screenshotBytes?: Uint8Array;
773
- }
774
-
775
- export interface ExpectSemanticPage {
776
- /** Playwright Page.content(). */
777
- content?(): Promise<string>;
778
- /** Playwright Page.screenshot() — returns binary. */
779
- screenshot?(opts?: Record<string, unknown>): Promise<Uint8Array>;
780
- }
781
-
782
- export interface OracleQueueEntry {
783
- assertionId: string;
784
- specPath: string;
785
- runId: string;
786
- claim: string;
787
- artifactPath: string;
788
- status: "pending" | "passed" | "failed";
789
- verdict?: {
790
- judgedBy: "agent" | "human";
791
- reason: string;
792
- timestamp: string;
793
- };
794
- timestamp: string;
795
- }
796
-
797
- export interface ExpectSemanticResult {
798
- status: "pass" | "fail";
799
- assertionId: string;
800
- /** Set when a past verdict triggered a promoted deterministic fail. */
801
- promotedFromVerdict?: OracleQueueEntry["verdict"];
802
- /** True when we skipped queueing (CI / DETERMINISTIC_ONLY). */
803
- deferred: boolean;
804
- }
805
-
806
- /**
807
- * Queue a semantic claim for agent judgment.
808
- *
809
- * Default behaviour is **non-blocking** — we enqueue to
810
- * `.mandu/ate-oracle-queue.jsonl`, return `status: "pass"`, and let a
811
- * later agent session judge via `mandu_ate_oracle_verdict`.
812
- *
813
- * Two escape hatches:
814
- * - `MANDU_ATE_DETERMINISTIC_ONLY=1` → no file writes, return immediately.
815
- * - `promoteVerdicts: true` + past `failed` verdict for the same claim
816
- * → throw `SemanticDivergenceError` to regress the spec.
817
- */
818
- export function expectSemantic(
819
- page: ExpectSemanticPage,
820
- claim: string,
821
- options: ExpectSemanticOptions = {},
822
- ): ExpectSemanticResult {
823
- const repoRoot = options.repoRoot ?? process.cwd();
824
- const deterministicOnly = process.env.MANDU_ATE_DETERMINISTIC_ONLY === "1";
825
-
826
- // Compute a stable assertionId — hash of (claim + specPath). This is
827
- // how promoteVerdicts matches past verdicts without relying on random
828
- // ids.
829
- const specPath = options.specPath ?? inferSpecPath();
830
- const runId = options.runId ?? deriveRunId();
831
- const assertionId = stableAssertionId(claim, specPath);
832
-
833
- // promoteVerdicts — scan existing queue for the same assertionId.
834
- if (options.promoteVerdicts) {
835
- const past = findPastVerdict(repoRoot, assertionId);
836
- if (past && past.status === "failed" && past.verdict) {
837
- throw new SemanticDivergenceError(
838
- `expectSemantic: past verdict flagged this claim as failed. claim="${claim}" reason="${past.verdict.reason}"`,
839
- { claim, evidence: past.artifactPath, oraclePending: false },
840
- );
841
- }
842
- }
843
-
844
- if (deterministicOnly) {
845
- return {
846
- status: "pass",
847
- assertionId,
848
- deferred: true,
849
- };
850
- }
851
-
852
- // Create artifact dir + write captures.
853
- const nowStr = options.now ? options.now() : new Date().toISOString();
854
- const artifactDir = join(repoRoot, ".mandu", "ate-oracle-queue", runId, assertionId);
855
- try {
856
- mkdirSync(artifactDir, { recursive: true });
857
- } catch {
858
- // fall through — queue is best-effort
859
- }
860
-
861
- const capture = options.capture ?? "both";
862
- // Pull from explicit options first (test path), fall back to page calls.
863
- const domPromise =
864
- options.domSnapshot !== undefined
865
- ? Promise.resolve(options.domSnapshot)
866
- : capture === "screenshot" || !page.content
867
- ? Promise.resolve(null)
868
- : page.content().catch(() => null);
869
- const screenshotPromise =
870
- options.screenshotBytes !== undefined
871
- ? Promise.resolve(options.screenshotBytes)
872
- : capture === "dom" || !page.screenshot
873
- ? Promise.resolve(null)
874
- : page.screenshot().catch(() => null);
875
-
876
- // Write captures synchronously after they resolve. expectSemantic is
877
- // declared synchronous in §C.1.5 — but file writes here are also
878
- // expected to not block on network, so we kick off a microtask and
879
- // return. This matches the spec's "non-blocking for CI" contract.
880
- void Promise.all([domPromise, screenshotPromise]).then(([dom, shot]) => {
881
- try {
882
- if (dom !== null && dom !== undefined) {
883
- writeFileSync(join(artifactDir, "dom.html"), dom, "utf8");
884
- }
885
- if (shot) {
886
- writeFileSync(join(artifactDir, "screenshot.png"), Buffer.from(shot));
887
- }
888
- } catch {
889
- // swallow
890
- }
891
- });
892
-
893
- // Append the queue entry immediately so agents see it before the
894
- // artifacts finish writing.
895
- const entry: OracleQueueEntry = {
896
- assertionId,
897
- specPath,
898
- runId,
899
- claim,
900
- artifactPath: artifactDir.replace(/\\/g, "/"),
901
- status: "pending",
902
- timestamp: nowStr,
903
- };
904
- try {
905
- const queuePath = join(repoRoot, ".mandu", "ate-oracle-queue.jsonl");
906
- mkdirSync(dirname(queuePath), { recursive: true });
907
- appendFileSync(queuePath, `${JSON.stringify(entry)}\n`, "utf8");
908
- } catch {
909
- // swallow — queue write is best-effort.
910
- }
911
-
912
- return {
913
- status: "pass",
914
- assertionId,
915
- deferred: true,
916
- };
917
- }
918
-
919
- export class SemanticDivergenceError extends Error {
920
- kind = "semantic_divergence" as const;
921
- detail: { claim: string; evidence?: string; oraclePending: boolean };
922
- constructor(
923
- message: string,
924
- detail: { claim: string; evidence?: string; oraclePending: boolean },
925
- ) {
926
- super(message);
927
- this.name = "SemanticDivergenceError";
928
- this.detail = detail;
929
- }
930
- }
931
-
932
- function stableAssertionId(claim: string, specPath: string): string {
933
- // Small deterministic hash — DJB2 variant. Don't need cryptographic
934
- // strength; we just need stable ids across runs.
935
- const input = `${specPath}|${claim}`;
936
- let hash = 5381;
937
- for (let i = 0; i < input.length; i++) {
938
- hash = ((hash << 5) + hash + input.charCodeAt(i)) >>> 0;
939
- }
940
- return `sem-${hash.toString(16).padStart(8, "0")}`;
941
- }
942
-
943
- function inferSpecPath(): string {
944
- // Best-effort: peek at the stack for the first frame outside this file.
945
- const err = new Error();
946
- const stack = err.stack ?? "";
947
- const lines = stack.split("\n");
948
- for (const line of lines) {
949
- if (line.includes("assertions.ts")) continue;
950
- const match = line.match(/\(([^)]+?):(\d+):(\d+)\)/) || line.match(/at\s+([^\s]+?):(\d+):(\d+)/);
951
- if (match) {
952
- const file = match[1];
953
- if (isAbsolute(file) && !file.includes("node_modules")) {
954
- return file.replace(/\\/g, "/");
955
- }
956
- }
957
- }
958
- return "unknown.spec.ts";
959
- }
960
-
961
- function deriveRunId(): string {
962
- return process.env.MANDU_ATE_RUN_ID ?? `run-${Date.now().toString(36)}`;
963
- }
964
-
965
- function findPastVerdict(repoRoot: string, assertionId: string): OracleQueueEntry | null {
966
- const path = join(repoRoot, ".mandu", "ate-oracle-queue.jsonl");
967
- if (!existsSync(path)) return null;
968
- try {
969
- const content = readFileSync(path, "utf8");
970
- // Walk from the tail — most recent verdict for this id wins.
971
- const lines = content.split("\n").filter(Boolean);
972
- for (let i = lines.length - 1; i >= 0; i--) {
973
- try {
974
- const e = JSON.parse(lines[i]) as OracleQueueEntry;
975
- if (e.assertionId === assertionId && e.status !== "pending") {
976
- return e;
977
- }
978
- } catch {
979
- // skip corrupt line
980
- }
981
- }
982
- } catch {
983
- // swallow
984
- }
985
- return null;
986
- }
1
+ /**
2
+ * Phase C.1 — Semantic Primitives (@mandujs/core/testing barrel).
3
+ *
4
+ * Mandu-specific assertion primitives that the generic Playwright / bun:test
5
+ * vocabulary cannot express cleanly:
6
+ *
7
+ * - `expectContract` — Zod shape validation with strict/loose/drift
8
+ * modes and path-based `ignorePaths`. Replaces `JSON.stringify`
9
+ * comparisons.
10
+ * - `expectNavigation` — redirect-chain capture for Playwright pages.
11
+ * - `waitForIsland` — polls `data-island="<name>"`'s
12
+ * `data-hydrated`/`data-island-state` attribute. Short-circuits for
13
+ * `hydration:none` strategies.
14
+ * - `assertStreamBoundary`— consumes a streaming SSR response and counts
15
+ * `<!--$-->` / `<!--/$-->` boundary markers. Validates shell chunk
16
+ * byte budgets and tail chunk content.
17
+ * - `expectSemantic` — agent-delegated oracle. Writes an entry to
18
+ * `.mandu/ate-oracle-queue.jsonl` for an agent to judge later. CI is
19
+ * never blocked (`MANDU_ATE_DETERMINISTIC_ONLY=1` skips queueing).
20
+ * `promoteVerdicts: true` option lets a previously-failed verdict
21
+ * become a deterministic fail on re-run.
22
+ *
23
+ * Spec: docs/ate/phase-c-spec.md §C.1.
24
+ *
25
+ * These primitives live in `@mandujs/core/testing` (not `@mandujs/ate`) so
26
+ * they are usable in any Mandu project test file, with or without the ATE
27
+ * MCP server.
28
+ */
29
+
30
+ import {
31
+ existsSync,
32
+ mkdirSync,
33
+ readFileSync,
34
+ writeFileSync,
35
+ appendFileSync,
36
+ } from "node:fs";
37
+ import { join, dirname, isAbsolute } from "node:path";
38
+
39
+ // ────────────────────────────────────────────────────────────────────────────
40
+ // expectContract
41
+ // ────────────────────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Minimal structural type of a Zod schema. We don't import `zod` here —
45
+ * the caller provides the schema and we only call `safeParse`. This keeps
46
+ * `@mandujs/core/testing` free of a zod peer dep for consumers who don't
47
+ * use it.
48
+ */
49
+ /**
50
+ * Structural type matching both real `zod` schemas and hand-rolled
51
+ * fakes. We intentionally widen the `error.issues` shape to `unknown[]`
52
+ * — the primitive normalizes each issue internally so the call sites
53
+ * don't have to worry about Zod version drift.
54
+ */
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
+ export interface ZodLikeSchema<T = unknown> {
57
+ safeParse: (input: unknown) => {
58
+ success: boolean;
59
+ data?: T;
60
+ // Intentionally loose — see note above.
61
+ error?: { issues: readonly unknown[] } | undefined;
62
+ };
63
+ }
64
+
65
+ interface NormalizedIssue {
66
+ path: Array<string | number>;
67
+ message: string;
68
+ code?: string;
69
+ expected?: string;
70
+ received?: string;
71
+ }
72
+
73
+ function normalizeIssue(raw: unknown): NormalizedIssue {
74
+ const obj = (raw ?? {}) as Record<string, unknown>;
75
+ const pathRaw = obj.path;
76
+ const path: Array<string | number> = Array.isArray(pathRaw)
77
+ ? (pathRaw.filter((p) => typeof p === "string" || typeof p === "number") as Array<
78
+ string | number
79
+ >)
80
+ : [];
81
+ return {
82
+ path,
83
+ message: typeof obj.message === "string" ? obj.message : "",
84
+ ...(typeof obj.code === "string" ? { code: obj.code } : {}),
85
+ ...(typeof obj.expected === "string" ? { expected: obj.expected } : {}),
86
+ ...(typeof obj.received === "string" ? { received: obj.received } : {}),
87
+ };
88
+ }
89
+
90
+ export type ContractMode = "strict" | "loose" | "drift-tolerant";
91
+
92
+ export interface ContractViolation {
93
+ path: string;
94
+ expected: string;
95
+ actual: string;
96
+ severity: "critical" | "warning";
97
+ }
98
+
99
+ export interface ExpectContractOptions {
100
+ mode?: ContractMode;
101
+ /**
102
+ * Dot-notation paths to ignore. Use `.createdAt`, `.items[0].updatedAt`,
103
+ * or `.user.id`. Matching is prefix-aware — `.user` ignores every
104
+ * descendant field.
105
+ */
106
+ ignorePaths?: string[];
107
+ }
108
+
109
+ export interface ExpectContractResult {
110
+ status: "pass" | "fail";
111
+ violations: ContractViolation[];
112
+ }
113
+
114
+ /**
115
+ * Validate `actual` against a Zod schema and surface structured
116
+ * violations. Throws when `status === "fail"` unless `mode` is
117
+ * `drift-tolerant`, which only records warnings.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * expectContract(await res.json(), SignupResponseSchema, {
122
+ * mode: "loose",
123
+ * ignorePaths: [".createdAt", ".user.id"],
124
+ * });
125
+ * ```
126
+ */
127
+ export function expectContract<T>(
128
+ actual: unknown,
129
+ schema: ZodLikeSchema<T>,
130
+ options: ExpectContractOptions = {},
131
+ ): ExpectContractResult {
132
+ const mode: ContractMode = options.mode ?? "strict";
133
+ const ignore = options.ignorePaths ?? [];
134
+
135
+ const result = schema.safeParse(actual);
136
+
137
+ if (result.success) {
138
+ // Strict mode: walk `actual` looking for keys not mentioned anywhere
139
+ // in the schema. We can't introspect a Zod schema structurally without
140
+ // the runtime — instead we round-trip through `safeParse`'s `data`
141
+ // which a default Zod schema strips unknown keys from (unless the
142
+ // schema uses `.passthrough()` / `.strict()`). If `data` shape differs
143
+ // from `actual` in strict mode, we flag the extras.
144
+ if (mode === "strict" && result.data !== undefined) {
145
+ const extras = findExtraKeys("", actual, result.data, ignore);
146
+ if (extras.length > 0) {
147
+ const violations: ContractViolation[] = extras.map((e) => ({
148
+ path: e,
149
+ expected: "(not in schema)",
150
+ actual: "extra key present",
151
+ severity: "critical",
152
+ }));
153
+ throwViolations(violations, "strict");
154
+ return { status: "fail", violations };
155
+ }
156
+ }
157
+ return { status: "pass", violations: [] };
158
+ }
159
+
160
+ // safeParse failed — translate Zod issues to our violation shape.
161
+ const rawIssues = result.error?.issues ?? [];
162
+ const violations: ContractViolation[] = [];
163
+ for (const raw of rawIssues) {
164
+ const issue = normalizeIssue(raw);
165
+ const path = pathToString(issue.path);
166
+ if (isIgnored(path, ignore)) continue;
167
+
168
+ // loose mode forgives extra-key errors (unrecognized keys) but keeps
169
+ // `missing_required` + format violations.
170
+ if (mode === "loose" && isExtraKeyIssue(issue)) continue;
171
+
172
+ const severity: "critical" | "warning" =
173
+ mode === "drift-tolerant" ? "warning" : "critical";
174
+ violations.push({
175
+ path,
176
+ expected: issue.expected ?? issue.message,
177
+ actual: issue.received ?? describeActual(actual, issue.path),
178
+ severity,
179
+ });
180
+ }
181
+
182
+ if (violations.length === 0) {
183
+ return { status: "pass", violations: [] };
184
+ }
185
+
186
+ if (mode === "drift-tolerant") {
187
+ // Warnings collected; no throw. Caller may log via
188
+ // `mandu_ate_remember({ kind: "contract_drift" })`.
189
+ return { status: "fail", violations };
190
+ }
191
+
192
+ throwViolations(violations, mode);
193
+ return { status: "fail", violations };
194
+ }
195
+
196
+ function isExtraKeyIssue(issue: { code?: string; message?: string }): boolean {
197
+ if (issue.code === "unrecognized_keys") return true;
198
+ return /unrecognized key/i.test(issue.message ?? "");
199
+ }
200
+
201
+ function throwViolations(violations: ContractViolation[], mode: ContractMode): never {
202
+ const summary = violations
203
+ .slice(0, 5)
204
+ .map((v) => ` - ${v.path}: expected ${v.expected}, got ${v.actual}`)
205
+ .join("\n");
206
+ const more = violations.length > 5 ? `\n ... +${violations.length - 5} more` : "";
207
+ throw new ContractAssertionError(
208
+ `expectContract (${mode}) failed with ${violations.length} violation(s):\n${summary}${more}`,
209
+ violations,
210
+ );
211
+ }
212
+
213
+ export class ContractAssertionError extends Error {
214
+ violations: ContractViolation[];
215
+ constructor(message: string, violations: ContractViolation[]) {
216
+ super(message);
217
+ this.name = "ContractAssertionError";
218
+ this.violations = violations;
219
+ }
220
+ }
221
+
222
+ function pathToString(path: Array<string | number>): string {
223
+ let out = "";
224
+ for (const seg of path) {
225
+ if (typeof seg === "number") out += `[${seg}]`;
226
+ else out += `.${seg}`;
227
+ }
228
+ return out;
229
+ }
230
+
231
+ function isIgnored(path: string, ignore: string[]): boolean {
232
+ for (const ign of ignore) {
233
+ if (path === ign || path.startsWith(ign + ".") || path.startsWith(ign + "[")) {
234
+ return true;
235
+ }
236
+ }
237
+ return false;
238
+ }
239
+
240
+ function describeActual(actual: unknown, path: Array<string | number>): string {
241
+ let cursor: unknown = actual;
242
+ for (const seg of path) {
243
+ if (cursor === null || cursor === undefined) return "undefined";
244
+ cursor = (cursor as Record<string | number, unknown>)[seg as string];
245
+ }
246
+ if (cursor === null) return "null";
247
+ if (cursor === undefined) return "undefined";
248
+ if (typeof cursor === "object") return JSON.stringify(cursor).slice(0, 60);
249
+ return JSON.stringify(cursor);
250
+ }
251
+
252
+ /**
253
+ * Find keys present in `actual` that are NOT present in `parsed` — extra
254
+ * keys Zod stripped during safeParse. Only used in strict mode.
255
+ */
256
+ function findExtraKeys(
257
+ prefix: string,
258
+ actual: unknown,
259
+ parsed: unknown,
260
+ ignore: string[],
261
+ ): string[] {
262
+ const out: string[] = [];
263
+ if (!actual || typeof actual !== "object" || Array.isArray(actual)) return out;
264
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return out;
265
+
266
+ const actualKeys = Object.keys(actual as Record<string, unknown>);
267
+ const parsedKeys = new Set(Object.keys(parsed as Record<string, unknown>));
268
+
269
+ for (const key of actualKeys) {
270
+ const path = `${prefix}.${key}`;
271
+ if (isIgnored(path, ignore)) continue;
272
+ if (!parsedKeys.has(key)) {
273
+ out.push(path);
274
+ continue;
275
+ }
276
+ const aVal = (actual as Record<string, unknown>)[key];
277
+ const pVal = (parsed as Record<string, unknown>)[key];
278
+ if (
279
+ aVal !== null &&
280
+ typeof aVal === "object" &&
281
+ !Array.isArray(aVal) &&
282
+ pVal !== null &&
283
+ typeof pVal === "object" &&
284
+ !Array.isArray(pVal)
285
+ ) {
286
+ out.push(...findExtraKeys(path, aVal, pVal, ignore));
287
+ }
288
+ }
289
+
290
+ return out;
291
+ }
292
+
293
+ // ────────────────────────────────────────────────────────────────────────────
294
+ // expectNavigation
295
+ // ────────────────────────────────────────────────────────────────────────────
296
+
297
+ /**
298
+ * Minimal Playwright Page shape — we only touch the bits we need so
299
+ * consumers can use `@playwright/test`'s `Page` without creating an
300
+ * import coupling.
301
+ */
302
+ export interface PlaywrightLikePage {
303
+ url(): string;
304
+ on(event: "framenavigated", listener: (frame: { url: () => string }) => void): void;
305
+ off?(event: "framenavigated", listener: (frame: { url: () => string }) => void): void;
306
+ waitForURL?(url: string | RegExp, opts?: { timeout?: number }): Promise<void>;
307
+ waitForLoadState?(state?: "load" | "networkidle" | "domcontentloaded", opts?: { timeout?: number }): Promise<void>;
308
+ }
309
+
310
+ export interface ExpectNavigationInput {
311
+ from?: string;
312
+ to: string | RegExp;
313
+ /** Exact chain length — overrides `maxRedirects` when both set. */
314
+ redirectCount?: number;
315
+ /** ≤ — chain length must not exceed this. */
316
+ maxRedirects?: number;
317
+ /** Milliseconds to wait for the terminal URL to match. Default 5000. */
318
+ timeoutMs?: number;
319
+ }
320
+
321
+ export interface ExpectNavigationResult {
322
+ status: "pass";
323
+ chain: string[];
324
+ finalUrl: string;
325
+ }
326
+
327
+ /**
328
+ * Validate a redirect chain. Installs a `framenavigated` listener before
329
+ * asserting the final URL matches `to`. When `from` is given we assert
330
+ * the starting URL matches first.
331
+ *
332
+ * Emits a structured `failure.v1` `redirect_unexpected` error on
333
+ * mismatch — callers should wrap in try/catch to translate to their
334
+ * runner's fail helper.
335
+ */
336
+ export async function expectNavigation(
337
+ page: PlaywrightLikePage,
338
+ expectation: ExpectNavigationInput,
339
+ ): Promise<ExpectNavigationResult> {
340
+ const timeoutMs = expectation.timeoutMs ?? 5000;
341
+ const chain: string[] = [];
342
+ let firstUrl: string | null = null;
343
+
344
+ const onNav = (frame: { url: () => string }) => {
345
+ const u = frame.url();
346
+ if (firstUrl === null) firstUrl = u;
347
+ // Only append when the URL actually changes.
348
+ if (chain.length === 0 || chain[chain.length - 1] !== u) {
349
+ chain.push(u);
350
+ }
351
+ };
352
+ page.on("framenavigated", onNav);
353
+
354
+ try {
355
+ // Seed the chain with the current URL before waiting, so synchronous
356
+ // navigations are captured even if `framenavigated` fires before we
357
+ // attach.
358
+ const current = page.url();
359
+ if (current) chain.push(current);
360
+
361
+ if (page.waitForURL) {
362
+ try {
363
+ await page.waitForURL(expectation.to, { timeout: timeoutMs });
364
+ } catch {
365
+ // fall through — we'll diagnose below
366
+ }
367
+ } else {
368
+ // Fallback poll when waitForURL isn't available (mock pages etc).
369
+ const deadline = Date.now() + timeoutMs;
370
+ while (Date.now() < deadline) {
371
+ if (urlMatches(page.url(), expectation.to)) break;
372
+ await new Promise((r) => setTimeout(r, 25));
373
+ }
374
+ }
375
+
376
+ const finalUrl = page.url();
377
+ if (chain.length === 0 || chain[chain.length - 1] !== finalUrl) {
378
+ chain.push(finalUrl);
379
+ }
380
+
381
+ // Validate `from`.
382
+ if (expectation.from !== undefined) {
383
+ const first = chain[0];
384
+ if (first && !urlMatches(first, expectation.from)) {
385
+ throw new NavigationAssertionError(
386
+ `expectNavigation: starting URL mismatch. Expected ${expectation.from}, got ${first}`,
387
+ { from: expectation.from, expectedTo: String(expectation.to), actualTo: finalUrl, chain },
388
+ );
389
+ }
390
+ }
391
+
392
+ // Validate final URL.
393
+ if (!urlMatches(finalUrl, expectation.to)) {
394
+ throw new NavigationAssertionError(
395
+ `expectNavigation: terminal URL mismatch. Expected ${expectation.to}, got ${finalUrl}`,
396
+ { from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
397
+ );
398
+ }
399
+
400
+ // Validate chain length.
401
+ // Redirect count excludes the starting URL itself when `from` is set.
402
+ const chainHops = expectation.from !== undefined ? Math.max(0, chain.length - 1) : chain.length;
403
+ if (expectation.redirectCount !== undefined && chainHops !== expectation.redirectCount) {
404
+ throw new NavigationAssertionError(
405
+ `expectNavigation: redirect count mismatch. Expected exactly ${expectation.redirectCount}, got ${chainHops}`,
406
+ { from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
407
+ );
408
+ }
409
+ if (expectation.maxRedirects !== undefined && chainHops > expectation.maxRedirects) {
410
+ throw new NavigationAssertionError(
411
+ `expectNavigation: redirect chain too long. Expected ≤ ${expectation.maxRedirects}, got ${chainHops}`,
412
+ { from: expectation.from ?? "", expectedTo: String(expectation.to), actualTo: finalUrl, chain },
413
+ );
414
+ }
415
+
416
+ return { status: "pass", chain, finalUrl };
417
+ } finally {
418
+ if (page.off) {
419
+ try {
420
+ page.off("framenavigated", onNav);
421
+ } catch {
422
+ // ignore
423
+ }
424
+ }
425
+ }
426
+ }
427
+
428
+ export class NavigationAssertionError extends Error {
429
+ /** Shape matches `failure.v1` `redirect_unexpected` detail. */
430
+ detail: {
431
+ from: string;
432
+ expectedTo: string;
433
+ actualTo: string;
434
+ chain: string[];
435
+ };
436
+ kind = "redirect_unexpected" as const;
437
+ constructor(
438
+ message: string,
439
+ detail: { from: string; expectedTo: string; actualTo: string; chain: string[] },
440
+ ) {
441
+ super(message);
442
+ this.name = "NavigationAssertionError";
443
+ this.detail = detail;
444
+ }
445
+ }
446
+
447
+ function urlMatches(actual: string, expected: string | RegExp): boolean {
448
+ if (expected instanceof RegExp) return expected.test(actual);
449
+ // String match — treat as substring OR exact. "/kr" should match
450
+ // "http://localhost/kr" too.
451
+ if (actual === expected) return true;
452
+ try {
453
+ const parsed = new URL(actual);
454
+ if (parsed.pathname === expected) return true;
455
+ if (parsed.pathname + parsed.search === expected) return true;
456
+ } catch {
457
+ // fall through
458
+ }
459
+ return actual.includes(expected);
460
+ }
461
+
462
+ // ────────────────────────────────────────────────────────────────────────────
463
+ // waitForIsland
464
+ // ────────────────────────────────────────────────────────────────────────────
465
+
466
+ export interface WaitForIslandOptions {
467
+ timeoutMs?: number;
468
+ /** "hydrated" (default) or "visible". "visible" only checks mount. */
469
+ state?: "hydrated" | "visible";
470
+ /**
471
+ * Override how islands expose their strategy. When this returns
472
+ * "none" we short-circuit and resolve immediately.
473
+ */
474
+ strategyOf?: (page: PlaywrightIslandPage, name: string) => Promise<"none" | "other" | null>;
475
+ }
476
+
477
+ export interface PlaywrightIslandPage {
478
+ /**
479
+ * Invoke a JS function in the browser context. We use
480
+ * `page.evaluate(fn, arg)` — same signature as Playwright's `Page`.
481
+ */
482
+ evaluate<T, A>(fn: (arg: A) => T, arg: A): Promise<T>;
483
+ }
484
+
485
+ /**
486
+ * Wait until `[data-island="<name>"]` is hydrated.
487
+ *
488
+ * `hydration:none` strategy islands resolve immediately (Mandu's SSR
489
+ * emits `data-island-strategy="none"` for those — we check that first
490
+ * and short-circuit).
491
+ *
492
+ * Primary signal: `data-hydrated="true"` attribute (the one emitted by
493
+ * `@mandujs/core/client/hydrate`). Fallback: `data-island-state="hydrated"`.
494
+ */
495
+ export async function waitForIsland(
496
+ page: PlaywrightIslandPage,
497
+ name: string,
498
+ options: WaitForIslandOptions = {},
499
+ ): Promise<void> {
500
+ const timeoutMs = options.timeoutMs ?? 3000;
501
+ const state = options.state ?? "hydrated";
502
+
503
+ // Short-circuit for hydration:none islands.
504
+ try {
505
+ const strategy = await page.evaluate(
506
+ (n: string) => {
507
+ const el = document.querySelector(`[data-island="${n}"]`);
508
+ if (!el) return null;
509
+ const s = el.getAttribute("data-island-strategy");
510
+ return s ?? null;
511
+ },
512
+ name,
513
+ );
514
+ if (strategy === "none") {
515
+ return;
516
+ }
517
+ } catch {
518
+ // fall through — evaluate may be unsupported in mocks
519
+ }
520
+
521
+ const deadline = Date.now() + timeoutMs;
522
+ const pollInterval = 25;
523
+
524
+ while (Date.now() < deadline) {
525
+ try {
526
+ const status = await page.evaluate(
527
+ (args: { name: string; state: string }) => {
528
+ const el = document.querySelector(`[data-island="${args.name}"]`);
529
+ if (!el) return { mounted: false, hydrated: false };
530
+ const mounted = true;
531
+ if (args.state === "visible") return { mounted, hydrated: mounted };
532
+ // primary
533
+ if (el.getAttribute("data-hydrated") === "true") {
534
+ return { mounted, hydrated: true };
535
+ }
536
+ // fallback
537
+ if (el.getAttribute("data-island-state") === "hydrated") {
538
+ return { mounted, hydrated: true };
539
+ }
540
+ return { mounted, hydrated: false };
541
+ },
542
+ { name, state },
543
+ );
544
+ if (state === "visible" && status.mounted) return;
545
+ if (status.hydrated) return;
546
+ } catch {
547
+ // evaluate failure — treat as not-yet-ready and retry.
548
+ }
549
+ await new Promise((r) => setTimeout(r, pollInterval));
550
+ }
551
+
552
+ throw new HydrationTimeoutError(
553
+ `waitForIsland: island "${name}" did not reach state=${state} within ${timeoutMs}ms`,
554
+ { island: name, waitedMs: timeoutMs },
555
+ );
556
+ }
557
+
558
+ export class HydrationTimeoutError extends Error {
559
+ kind = "hydration_timeout" as const;
560
+ detail: { island: string; waitedMs: number };
561
+ constructor(message: string, detail: { island: string; waitedMs: number }) {
562
+ super(message);
563
+ this.name = "HydrationTimeoutError";
564
+ this.detail = detail;
565
+ }
566
+ }
567
+
568
+ // ────────────────────────────────────────────────────────────────────────────
569
+ // assertStreamBoundary
570
+ // ────────────────────────────────────────────────────────────────────────────
571
+
572
+ export interface AssertStreamBoundaryInput {
573
+ /** Substrings the FIRST decoded chunk must contain (all of them). */
574
+ shellChunkContains?: string[];
575
+ /** Exact count of `<!--$-->` / `<!--/$-->` pairs. */
576
+ boundaryCount?: number;
577
+ /** First-chunk size guard (bytes). */
578
+ firstChunkMaxSizeBytes?: number;
579
+ /** Any one of these strings must appear in the final chunk. */
580
+ tailChunkContainsAnyOf?: string[];
581
+ }
582
+
583
+ export interface AssertStreamBoundaryResult {
584
+ status: "pass";
585
+ chunks: number;
586
+ totalBytes: number;
587
+ boundaryOpenCount: number;
588
+ boundaryCloseCount: number;
589
+ }
590
+
591
+ /**
592
+ * Consume a streaming Response chunk-by-chunk and validate boundary
593
+ * markers / shell content / byte budgets.
594
+ *
595
+ * Throws `StreamBoundaryError` (failure.v1-shaped) on mismatch.
596
+ */
597
+ export async function assertStreamBoundary(
598
+ response: Response,
599
+ expectations: AssertStreamBoundaryInput,
600
+ ): Promise<AssertStreamBoundaryResult> {
601
+ const body = response.body;
602
+ if (!body) {
603
+ throw new StreamBoundaryError("assertStreamBoundary: response has no body", {
604
+ reason: "no_body",
605
+ });
606
+ }
607
+
608
+ const reader = body.getReader();
609
+ const decoder = new TextDecoder("utf-8", { fatal: false });
610
+ const chunks: string[] = [];
611
+ let totalBytes = 0;
612
+ let firstChunkBytes = 0;
613
+
614
+ try {
615
+ // eslint-disable-next-line no-constant-condition
616
+ while (true) {
617
+ const { done, value } = await reader.read();
618
+ if (done) break;
619
+ if (!value) continue;
620
+ const bytes = value.byteLength;
621
+ totalBytes += bytes;
622
+ if (chunks.length === 0) firstChunkBytes = bytes;
623
+ chunks.push(decoder.decode(value, { stream: true }));
624
+ }
625
+ // flush trailing decoder state
626
+ chunks.push(decoder.decode());
627
+ } finally {
628
+ try {
629
+ reader.releaseLock();
630
+ } catch {
631
+ // ignore
632
+ }
633
+ }
634
+
635
+ const shell = chunks[0] ?? "";
636
+ // Walk back to find the last non-empty chunk. The TextDecoder's final
637
+ // flush can append an empty string — that would give us a useless tail.
638
+ let tail = "";
639
+ for (let i = chunks.length - 1; i >= 0; i--) {
640
+ if (chunks[i] && chunks[i].length > 0) {
641
+ tail = chunks[i];
642
+ break;
643
+ }
644
+ }
645
+ if (!tail) tail = chunks.join("");
646
+ const full = chunks.join("");
647
+
648
+ // shellChunkContains
649
+ if (expectations.shellChunkContains) {
650
+ for (const needle of expectations.shellChunkContains) {
651
+ if (!shell.includes(needle)) {
652
+ throw new StreamBoundaryError(
653
+ `assertStreamBoundary: first chunk missing expected content "${needle}"`,
654
+ {
655
+ reason: "shell_missing_content",
656
+ missing: needle,
657
+ shellPreview: shell.slice(0, 200),
658
+ },
659
+ );
660
+ }
661
+ }
662
+ }
663
+
664
+ // firstChunkMaxSizeBytes
665
+ if (
666
+ expectations.firstChunkMaxSizeBytes !== undefined &&
667
+ firstChunkBytes > expectations.firstChunkMaxSizeBytes
668
+ ) {
669
+ throw new StreamBoundaryError(
670
+ `assertStreamBoundary: first chunk ${firstChunkBytes} bytes exceeds budget ${expectations.firstChunkMaxSizeBytes}`,
671
+ {
672
+ reason: "shell_over_budget",
673
+ firstChunkBytes,
674
+ budget: expectations.firstChunkMaxSizeBytes,
675
+ },
676
+ );
677
+ }
678
+
679
+ // boundary count — count occurrences of `<!--$-->` (open) and
680
+ // `<!--/$-->` (close). A "boundary" is one open+close pair, so we
681
+ // expect the open count to equal the close count AND equal
682
+ // `expectations.boundaryCount`.
683
+ const openCount = countOccurrences(full, "<!--$-->");
684
+ const closeCount = countOccurrences(full, "<!--/$-->");
685
+ if (
686
+ expectations.boundaryCount !== undefined &&
687
+ openCount !== expectations.boundaryCount
688
+ ) {
689
+ throw new StreamBoundaryError(
690
+ `assertStreamBoundary: expected ${expectations.boundaryCount} Suspense boundaries, saw ${openCount} open / ${closeCount} close`,
691
+ { reason: "boundary_count_mismatch", expected: expectations.boundaryCount, openCount, closeCount },
692
+ );
693
+ }
694
+
695
+ // tailChunkContainsAnyOf
696
+ if (expectations.tailChunkContainsAnyOf && expectations.tailChunkContainsAnyOf.length > 0) {
697
+ const hit = expectations.tailChunkContainsAnyOf.some((n) => tail.includes(n));
698
+ if (!hit) {
699
+ throw new StreamBoundaryError(
700
+ `assertStreamBoundary: tail chunk missing any of [${expectations.tailChunkContainsAnyOf.join(", ")}]`,
701
+ {
702
+ reason: "tail_missing_content",
703
+ candidates: expectations.tailChunkContainsAnyOf,
704
+ tailPreview: tail.slice(-200),
705
+ },
706
+ );
707
+ }
708
+ }
709
+
710
+ return {
711
+ status: "pass",
712
+ chunks: chunks.length,
713
+ totalBytes,
714
+ boundaryOpenCount: openCount,
715
+ boundaryCloseCount: closeCount,
716
+ };
717
+ }
718
+
719
+ export class StreamBoundaryError extends Error {
720
+ kind = "stream_boundary_mismatch" as const;
721
+ detail: Record<string, unknown>;
722
+ constructor(message: string, detail: Record<string, unknown>) {
723
+ super(message);
724
+ this.name = "StreamBoundaryError";
725
+ this.detail = detail;
726
+ }
727
+ }
728
+
729
+ function countOccurrences(haystack: string, needle: string): number {
730
+ if (!needle) return 0;
731
+ let count = 0;
732
+ let idx = 0;
733
+ while ((idx = haystack.indexOf(needle, idx)) !== -1) {
734
+ count++;
735
+ idx += needle.length;
736
+ }
737
+ return count;
738
+ }
739
+
740
+ // ────────────────────────────────────────────────────────────────────────────
741
+ // expectSemantic — agent-delegated oracle
742
+ // ────────────────────────────────────────────────────────────────────────────
743
+
744
+ export interface ExpectSemanticOptions {
745
+ /** What to capture. Default "both". */
746
+ capture?: "screenshot" | "dom" | "both";
747
+ /** Treat the queueing as non-blocking (default). */
748
+ deferToAgent?: boolean;
749
+ /**
750
+ * When true, past `failed` verdicts for the same claim promote to a
751
+ * deterministic fail on this run. Default false. Per §C.1.5.
752
+ */
753
+ promoteVerdicts?: boolean;
754
+ /** Override the repo root — default `process.cwd()`. */
755
+ repoRoot?: string;
756
+ /** Override the spec path recorded in the queue entry. */
757
+ specPath?: string;
758
+ /** Override the runId recorded in the queue entry. */
759
+ runId?: string;
760
+ /** Inject a fixed timestamp — for goldens / tests. */
761
+ now?: () => string;
762
+ /**
763
+ * Optional DOM snapshot — tests pass it in explicitly so we don't have
764
+ * to stand up a headless browser. In real use this is captured via
765
+ * `page.content()`.
766
+ */
767
+ domSnapshot?: string;
768
+ /**
769
+ * Optional screenshot bytes — tests pass it in explicitly so we don't
770
+ * require Playwright at callers.
771
+ */
772
+ screenshotBytes?: Uint8Array;
773
+ }
774
+
775
+ export interface ExpectSemanticPage {
776
+ /** Playwright Page.content(). */
777
+ content?(): Promise<string>;
778
+ /** Playwright Page.screenshot() — returns binary. */
779
+ screenshot?(opts?: Record<string, unknown>): Promise<Uint8Array>;
780
+ }
781
+
782
+ export interface OracleQueueEntry {
783
+ assertionId: string;
784
+ specPath: string;
785
+ runId: string;
786
+ claim: string;
787
+ artifactPath: string;
788
+ status: "pending" | "passed" | "failed";
789
+ verdict?: {
790
+ judgedBy: "agent" | "human";
791
+ reason: string;
792
+ timestamp: string;
793
+ };
794
+ timestamp: string;
795
+ }
796
+
797
+ export interface ExpectSemanticResult {
798
+ status: "pass" | "fail";
799
+ assertionId: string;
800
+ /** Set when a past verdict triggered a promoted deterministic fail. */
801
+ promotedFromVerdict?: OracleQueueEntry["verdict"];
802
+ /** True when we skipped queueing (CI / DETERMINISTIC_ONLY). */
803
+ deferred: boolean;
804
+ }
805
+
806
+ /**
807
+ * Queue a semantic claim for agent judgment.
808
+ *
809
+ * Default behaviour is **non-blocking** — we enqueue to
810
+ * `.mandu/ate-oracle-queue.jsonl`, return `status: "pass"`, and let a
811
+ * later agent session judge via `mandu_ate_oracle_verdict`.
812
+ *
813
+ * Two escape hatches:
814
+ * - `MANDU_ATE_DETERMINISTIC_ONLY=1` → no file writes, return immediately.
815
+ * - `promoteVerdicts: true` + past `failed` verdict for the same claim
816
+ * → throw `SemanticDivergenceError` to regress the spec.
817
+ */
818
+ export function expectSemantic(
819
+ page: ExpectSemanticPage,
820
+ claim: string,
821
+ options: ExpectSemanticOptions = {},
822
+ ): ExpectSemanticResult {
823
+ const repoRoot = options.repoRoot ?? process.cwd();
824
+ const deterministicOnly = process.env.MANDU_ATE_DETERMINISTIC_ONLY === "1";
825
+
826
+ // Compute a stable assertionId — hash of (claim + specPath). This is
827
+ // how promoteVerdicts matches past verdicts without relying on random
828
+ // ids.
829
+ const specPath = options.specPath ?? inferSpecPath();
830
+ const runId = options.runId ?? deriveRunId();
831
+ const assertionId = stableAssertionId(claim, specPath);
832
+
833
+ // promoteVerdicts — scan existing queue for the same assertionId.
834
+ if (options.promoteVerdicts) {
835
+ const past = findPastVerdict(repoRoot, assertionId);
836
+ if (past && past.status === "failed" && past.verdict) {
837
+ throw new SemanticDivergenceError(
838
+ `expectSemantic: past verdict flagged this claim as failed. claim="${claim}" reason="${past.verdict.reason}"`,
839
+ { claim, evidence: past.artifactPath, oraclePending: false },
840
+ );
841
+ }
842
+ }
843
+
844
+ if (deterministicOnly) {
845
+ return {
846
+ status: "pass",
847
+ assertionId,
848
+ deferred: true,
849
+ };
850
+ }
851
+
852
+ // Create artifact dir + write captures.
853
+ const nowStr = options.now ? options.now() : new Date().toISOString();
854
+ const artifactDir = join(repoRoot, ".mandu", "ate-oracle-queue", runId, assertionId);
855
+ try {
856
+ mkdirSync(artifactDir, { recursive: true });
857
+ } catch {
858
+ // fall through — queue is best-effort
859
+ }
860
+
861
+ const capture = options.capture ?? "both";
862
+ // Pull from explicit options first (test path), fall back to page calls.
863
+ const domPromise =
864
+ options.domSnapshot !== undefined
865
+ ? Promise.resolve(options.domSnapshot)
866
+ : capture === "screenshot" || !page.content
867
+ ? Promise.resolve(null)
868
+ : page.content().catch(() => null);
869
+ const screenshotPromise =
870
+ options.screenshotBytes !== undefined
871
+ ? Promise.resolve(options.screenshotBytes)
872
+ : capture === "dom" || !page.screenshot
873
+ ? Promise.resolve(null)
874
+ : page.screenshot().catch(() => null);
875
+
876
+ // Write captures synchronously after they resolve. expectSemantic is
877
+ // declared synchronous in §C.1.5 — but file writes here are also
878
+ // expected to not block on network, so we kick off a microtask and
879
+ // return. This matches the spec's "non-blocking for CI" contract.
880
+ void Promise.all([domPromise, screenshotPromise]).then(([dom, shot]) => {
881
+ try {
882
+ if (dom !== null && dom !== undefined) {
883
+ writeFileSync(join(artifactDir, "dom.html"), dom, "utf8");
884
+ }
885
+ if (shot) {
886
+ writeFileSync(join(artifactDir, "screenshot.png"), Buffer.from(shot));
887
+ }
888
+ } catch {
889
+ // swallow
890
+ }
891
+ });
892
+
893
+ // Append the queue entry immediately so agents see it before the
894
+ // artifacts finish writing.
895
+ const entry: OracleQueueEntry = {
896
+ assertionId,
897
+ specPath,
898
+ runId,
899
+ claim,
900
+ artifactPath: artifactDir.replace(/\\/g, "/"),
901
+ status: "pending",
902
+ timestamp: nowStr,
903
+ };
904
+ try {
905
+ const queuePath = join(repoRoot, ".mandu", "ate-oracle-queue.jsonl");
906
+ mkdirSync(dirname(queuePath), { recursive: true });
907
+ appendFileSync(queuePath, `${JSON.stringify(entry)}\n`, "utf8");
908
+ } catch {
909
+ // swallow — queue write is best-effort.
910
+ }
911
+
912
+ return {
913
+ status: "pass",
914
+ assertionId,
915
+ deferred: true,
916
+ };
917
+ }
918
+
919
+ export class SemanticDivergenceError extends Error {
920
+ kind = "semantic_divergence" as const;
921
+ detail: { claim: string; evidence?: string; oraclePending: boolean };
922
+ constructor(
923
+ message: string,
924
+ detail: { claim: string; evidence?: string; oraclePending: boolean },
925
+ ) {
926
+ super(message);
927
+ this.name = "SemanticDivergenceError";
928
+ this.detail = detail;
929
+ }
930
+ }
931
+
932
+ function stableAssertionId(claim: string, specPath: string): string {
933
+ // Small deterministic hash — DJB2 variant. Don't need cryptographic
934
+ // strength; we just need stable ids across runs.
935
+ const input = `${specPath}|${claim}`;
936
+ let hash = 5381;
937
+ for (let i = 0; i < input.length; i++) {
938
+ hash = ((hash << 5) + hash + input.charCodeAt(i)) >>> 0;
939
+ }
940
+ return `sem-${hash.toString(16).padStart(8, "0")}`;
941
+ }
942
+
943
+ function inferSpecPath(): string {
944
+ // Best-effort: peek at the stack for the first frame outside this file.
945
+ const err = new Error();
946
+ const stack = err.stack ?? "";
947
+ const lines = stack.split("\n");
948
+ for (const line of lines) {
949
+ if (line.includes("assertions.ts")) continue;
950
+ const match = line.match(/\(([^)]+?):(\d+):(\d+)\)/) || line.match(/at\s+([^\s]+?):(\d+):(\d+)/);
951
+ if (match) {
952
+ const file = match[1];
953
+ if (isAbsolute(file) && !file.includes("node_modules")) {
954
+ return file.replace(/\\/g, "/");
955
+ }
956
+ }
957
+ }
958
+ return "unknown.spec.ts";
959
+ }
960
+
961
+ function deriveRunId(): string {
962
+ return process.env.MANDU_ATE_RUN_ID ?? `run-${Date.now().toString(36)}`;
963
+ }
964
+
965
+ function findPastVerdict(repoRoot: string, assertionId: string): OracleQueueEntry | null {
966
+ const path = join(repoRoot, ".mandu", "ate-oracle-queue.jsonl");
967
+ if (!existsSync(path)) return null;
968
+ try {
969
+ const content = readFileSync(path, "utf8");
970
+ // Walk from the tail — most recent verdict for this id wins.
971
+ const lines = content.split("\n").filter(Boolean);
972
+ for (let i = lines.length - 1; i >= 0; i--) {
973
+ try {
974
+ const e = JSON.parse(lines[i]) as OracleQueueEntry;
975
+ if (e.assertionId === assertionId && e.status !== "pending") {
976
+ return e;
977
+ }
978
+ } catch {
979
+ // skip corrupt line
980
+ }
981
+ }
982
+ } catch {
983
+ // swallow
984
+ }
985
+ return null;
986
+ }