@reventlessdev/reventless-gwt 1.0.0-alpha.100

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 (184) hide show
  1. package/CHANGELOG.md +916 -0
  2. package/LICENSE +202 -0
  3. package/bin/reventless-dev.mjs +21 -0
  4. package/package.json +54 -0
  5. package/rescript.json +36 -0
  6. package/src/.gwtignore +10 -0
  7. package/src/Automation_GWT.res +207 -0
  8. package/src/Automation_GWT.res.mjs +160 -0
  9. package/src/Behavior_GWT.res +569 -0
  10. package/src/Behavior_GWT.res.mjs +523 -0
  11. package/src/Bind.res +29 -0
  12. package/src/Bind.res.mjs +22 -0
  13. package/src/BuildClassifier.res +147 -0
  14. package/src/BuildClassifier.res.mjs +136 -0
  15. package/src/Cancellation.res +37 -0
  16. package/src/Cancellation.res.mjs +46 -0
  17. package/src/ChildProcess.res +102 -0
  18. package/src/ChildProcess.res.mjs +107 -0
  19. package/src/Cli.res +905 -0
  20. package/src/Cli.res.mjs +952 -0
  21. package/src/Collector.res +212 -0
  22. package/src/Collector.res.mjs +218 -0
  23. package/src/ComponentMeta.res +77 -0
  24. package/src/ComponentMeta.res.mjs +91 -0
  25. package/src/ComponentScan.res +103 -0
  26. package/src/ComponentScan.res.mjs +105 -0
  27. package/src/Delegate_GWT.res +303 -0
  28. package/src/Delegate_GWT.res.mjs +372 -0
  29. package/src/Diff.res +91 -0
  30. package/src/Diff.res.mjs +98 -0
  31. package/src/Discovery.res +105 -0
  32. package/src/Discovery.res.mjs +99 -0
  33. package/src/DomainDeadCode.res +98 -0
  34. package/src/DomainDeadCode.res.mjs +72 -0
  35. package/src/DomainGraph.res +0 -0
  36. package/src/DomainGraph.res.mjs +0 -0
  37. package/src/EventMapping_GWT.res +29 -0
  38. package/src/EventMapping_GWT.res.mjs +44 -0
  39. package/src/Filter.res +45 -0
  40. package/src/Filter.res.mjs +47 -0
  41. package/src/Flow_GWT.res +664 -0
  42. package/src/Flow_GWT.res.mjs +604 -0
  43. package/src/FormatterHuman.res +109 -0
  44. package/src/FormatterHuman.res.mjs +115 -0
  45. package/src/FormatterJson.res +264 -0
  46. package/src/FormatterJson.res.mjs +322 -0
  47. package/src/FormatterJunit.res +93 -0
  48. package/src/FormatterJunit.res.mjs +90 -0
  49. package/src/FormatterTap.res +100 -0
  50. package/src/FormatterTap.res.mjs +131 -0
  51. package/src/FormatterVsCode.res +352 -0
  52. package/src/FormatterVsCode.res.mjs +546 -0
  53. package/src/Hint.res +119 -0
  54. package/src/Hint.res.mjs +124 -0
  55. package/src/InboundTranslation_GWT.res +83 -0
  56. package/src/InboundTranslation_GWT.res.mjs +94 -0
  57. package/src/JestBind.res +70 -0
  58. package/src/JestBind.res.mjs +63 -0
  59. package/src/Loader.res +24 -0
  60. package/src/Loader.res.mjs +23 -0
  61. package/src/LocalHost.res +119 -0
  62. package/src/LocalHost.res.mjs +99 -0
  63. package/src/Mapping_GWT.res +474 -0
  64. package/src/Mapping_GWT.res.mjs +322 -0
  65. package/src/MismatchRender.res +72 -0
  66. package/src/MismatchRender.res.mjs +127 -0
  67. package/src/MultiSourceProjection_GWT.res +316 -0
  68. package/src/MultiSourceProjection_GWT.res.mjs +354 -0
  69. package/src/OutboundTranslation_GWT.res +238 -0
  70. package/src/OutboundTranslation_GWT.res.mjs +186 -0
  71. package/src/Outcome.res +97 -0
  72. package/src/Outcome.res.mjs +93 -0
  73. package/src/PackageScan.res +80 -0
  74. package/src/PackageScan.res.mjs +81 -0
  75. package/src/PlatformRunner.res +181 -0
  76. package/src/PlatformRunner.res.mjs +176 -0
  77. package/src/PlatformScan.res +142 -0
  78. package/src/PlatformScan.res.mjs +132 -0
  79. package/src/ProcessManager.res +135 -0
  80. package/src/ProcessManager.res.mjs +119 -0
  81. package/src/Projection_GWT.res +312 -0
  82. package/src/Projection_GWT.res.mjs +332 -0
  83. package/src/Query_GWT.res +379 -0
  84. package/src/Query_GWT.res.mjs +367 -0
  85. package/src/RenderRescript.res +125 -0
  86. package/src/RenderRescript.res.mjs +141 -0
  87. package/src/RunWorker.res +24 -0
  88. package/src/RunWorker.res.mjs +16 -0
  89. package/src/RunnerTypes.res +68 -0
  90. package/src/RunnerTypes.res.mjs +60 -0
  91. package/src/SideEffect_GWT.res +157 -0
  92. package/src/SideEffect_GWT.res.mjs +113 -0
  93. package/src/StateChange/ExternalAddCategorySlice.res +25 -0
  94. package/src/StateChange/ExternalAddCategorySlice.res.mjs +34 -0
  95. package/src/StateChange/ExternalAddCategorySlice_Behavior.res +19 -0
  96. package/src/StateChange/ExternalAddCategorySlice_Behavior.res.mjs +46 -0
  97. package/src/StateChange/WithFixtures.res +22 -0
  98. package/src/StateChange/WithFixtures.res.mjs +31 -0
  99. package/src/StateChange/WithFixtures_Behavior.res +17 -0
  100. package/src/StateChange/WithFixtures_Behavior.res.mjs +37 -0
  101. package/src/StateChange/WithManualOpen.res +23 -0
  102. package/src/StateChange/WithManualOpen.res.mjs +31 -0
  103. package/src/StateChange/WithManualOpen_Behavior.res +17 -0
  104. package/src/StateChange/WithManualOpen_Behavior.res.mjs +37 -0
  105. package/src/StubRuntime.res +54 -0
  106. package/src/StubRuntime.res.mjs +55 -0
  107. package/src/TestFixtures.res +16 -0
  108. package/src/TestFixtures.res.mjs +34 -0
  109. package/src/Watch.res +108 -0
  110. package/src/Watch.res.mjs +85 -0
  111. package/src/WatcherProbe.res +56 -0
  112. package/src/WatcherProbe.res.mjs +59 -0
  113. package/src/Worker.res +24 -0
  114. package/src/Worker.res.mjs +9 -0
  115. package/tests/AutomationGwtTest.res +71 -0
  116. package/tests/AutomationGwtTest.res.mjs +189 -0
  117. package/tests/BuildClassifierTest.res +133 -0
  118. package/tests/BuildClassifierTest.res.mjs +176 -0
  119. package/tests/CliRunEntryTest.res +57 -0
  120. package/tests/CliRunEntryTest.res.mjs +65 -0
  121. package/tests/CliWatchScopeTest.res +62 -0
  122. package/tests/CliWatchScopeTest.res.mjs +95 -0
  123. package/tests/ComponentMetaTest.res +67 -0
  124. package/tests/ComponentMetaTest.res.mjs +71 -0
  125. package/tests/DelegateGwtTest.res +487 -0
  126. package/tests/DelegateGwtTest.res.mjs +891 -0
  127. package/tests/DiscoveryTest.res +42 -0
  128. package/tests/DiscoveryTest.res.mjs +43 -0
  129. package/tests/DomainDeadCodeTest.res +119 -0
  130. package/tests/DomainDeadCodeTest.res.mjs +158 -0
  131. package/tests/DomainGraphTest.res +448 -0
  132. package/tests/DomainGraphTest.res.mjs +505 -0
  133. package/tests/FlowAggregateGwtTest.res +171 -0
  134. package/tests/FlowAggregateGwtTest.res.mjs +273 -0
  135. package/tests/FlowCrossPluginGwtTest.res +340 -0
  136. package/tests/FlowCrossPluginGwtTest.res.mjs +798 -0
  137. package/tests/FlowGwtTest.res +222 -0
  138. package/tests/FlowGwtTest.res.mjs +433 -0
  139. package/tests/FormatterGoldenTest.res +125 -0
  140. package/tests/FormatterGoldenTest.res.mjs +162 -0
  141. package/tests/FormatterVsCodeMessageTest.res +41 -0
  142. package/tests/FormatterVsCodeMessageTest.res.mjs +42 -0
  143. package/tests/InboundTranslationGwtTest.res +34 -0
  144. package/tests/InboundTranslationGwtTest.res.mjs +99 -0
  145. package/tests/LocalHostIntegration.res +70 -0
  146. package/tests/LocalHostIntegration.res.mjs +64 -0
  147. package/tests/LocalHostTest.res +71 -0
  148. package/tests/LocalHostTest.res.mjs +63 -0
  149. package/tests/MappingGwtTest.res +350 -0
  150. package/tests/MappingGwtTest.res.mjs +596 -0
  151. package/tests/OutboundTranslationGwtTest.res +59 -0
  152. package/tests/OutboundTranslationGwtTest.res.mjs +139 -0
  153. package/tests/PlatformRunnerTest.res +66 -0
  154. package/tests/PlatformRunnerTest.res.mjs +85 -0
  155. package/tests/PlatformScanTest.res +61 -0
  156. package/tests/PlatformScanTest.res.mjs +56 -0
  157. package/tests/QueryGwtTest.res +161 -0
  158. package/tests/QueryGwtTest.res.mjs +314 -0
  159. package/tests/QueryResolverGwtTest.res +71 -0
  160. package/tests/QueryResolverGwtTest.res.mjs +136 -0
  161. package/tests/RunnerUnitTest.res +219 -0
  162. package/tests/RunnerUnitTest.res.mjs +244 -0
  163. package/tests/SideEffect/FakeOrderNotification.res +23 -0
  164. package/tests/SideEffect/FakeOrderNotification.res.mjs +36 -0
  165. package/tests/SideEffect/FakeOrderNotificationGwtTest.res +35 -0
  166. package/tests/SideEffect/FakeOrderNotificationGwtTest.res.mjs +73 -0
  167. package/tests/SideEffect/MockEmail.res +18 -0
  168. package/tests/SideEffect/MockEmail.res.mjs +47 -0
  169. package/tests/StateChange/ExternalAddCategorySlice_GWT.res +27 -0
  170. package/tests/StateChange/ExternalAddCategorySlice_GWT.res.mjs +70 -0
  171. package/tests/StateChange/WithFixtures_Fixtures.res +9 -0
  172. package/tests/StateChange/WithFixtures_Fixtures.res.mjs +20 -0
  173. package/tests/StateChange/WithFixtures_GWT.res +23 -0
  174. package/tests/StateChange/WithFixtures_GWT.res.mjs +56 -0
  175. package/tests/StateChange/WithManualOpen_Fixtures.res +7 -0
  176. package/tests/StateChange/WithManualOpen_Fixtures.res.mjs +20 -0
  177. package/tests/StateChange/WithManualOpen_GWT.res +17 -0
  178. package/tests/StateChange/WithManualOpen_GWT.res.mjs +56 -0
  179. package/tests/StateChangeSliceGwtTest.res +160 -0
  180. package/tests/StateChangeSliceGwtTest.res.mjs +280 -0
  181. package/tests/StateViewSliceGwtTest.res +64 -0
  182. package/tests/StateViewSliceGwtTest.res.mjs +161 -0
  183. package/tests/WatchDebounceTest.res +106 -0
  184. package/tests/WatchDebounceTest.res.mjs +128 -0
package/src/Cli.res ADDED
@@ -0,0 +1,905 @@
1
+ // Entry point for the `reventless-gwt` CLI. Orchestrates discovery, the
2
+ // module-level Collector, each formatter, and the watch / cancellation
3
+ // plumbing. Parses a minimal argv layer — no optparse library, no colour
4
+ // auto-detection — kept deliberately thin so the formatters own all
5
+ // presentational logic.
6
+
7
+ let toolVersion = "0.1.0"
8
+
9
+ type format = Human | Json | Tap | Junit | VsCode
10
+
11
+ type subcommand = Run | Discover | Watch | Platform
12
+
13
+ type options = {
14
+ subcommand: subcommand,
15
+ format: format,
16
+ stream: bool,
17
+ watch: bool,
18
+ filters: array<string>,
19
+ schemaVersion: option<string>,
20
+ roots: array<string>,
21
+ // `platform` only: the REVENTLESS_LOCAL_BACKEND value passed to the spawned
22
+ // platform child ("memory" default, or "sqlite:<path>[?reset]").
23
+ backend: string,
24
+ // `platform` only: pin the spawned platform to the fixed default ports
25
+ // (4000/4001/3001/3002) instead of allocating ephemeral ones. The VS Code
26
+ // extension passes this when it is also launching the host-shell UI, whose
27
+ // Vite proxy hardcodes localhost:4000/4001.
28
+ uiPorts: bool,
29
+ // `platform --list` only: enumerate the launchable platform packages (one
30
+ // NDJSON `{name, dir}` line each) and exit, rather than spawning one. Used by
31
+ // the VS Code extension to populate its active-app picker.
32
+ listPlatforms: bool,
33
+ toolVersion: string,
34
+ // When `Some`, `runOnce` runs exactly these compiled test paths instead of
35
+ // walking the tree. Set by the watch loop's worker passes (the parent owns
36
+ // discovery and narrows to the affected package); `None` for the one-shot
37
+ // `run`/`discover` commands, which discover from `roots`.
38
+ paths: option<array<string>>,
39
+ }
40
+
41
+ @val external argv: array<string> = "process.argv"
42
+
43
+ // chokidar reports paths in the same form as its watched roots (relative when
44
+ // roots are `["."]`), while `Discovery` returns absolute paths. Normalize a
45
+ // changed path to absolute before mapping it to an owning package so the
46
+ // prefix match against the discovered set holds.
47
+ @module("node:path") external resolvePath: string => string = "resolve"
48
+
49
+ @val external now: unit => float = "performance.now"
50
+
51
+ type hash
52
+ @module("node:crypto") external createHash: string => hash = "createHash"
53
+ @send external hashUpdate: (hash, string) => hash = "update"
54
+ @send external hashDigest: (hash, string) => string = "digest"
55
+ let sha256 = (s: string): string => createHash("sha256")->hashUpdate(s)->hashDigest("hex")
56
+
57
+ // Last-emitted domain-analysis fingerprint (deadCode + graph + definitions all
58
+ // derive from the loaded plugin structures). Persists across a watch session's
59
+ // repeated discovery emits so an unchanged graph isn't re-sent — a redundant
60
+ // re-emit forces the client to re-render the domain view for nothing.
61
+ let lastDomainHash: ref<option<string>> = ref(None)
62
+
63
+ let dateNowIso: unit => string = %raw(`() => new Date().toISOString()`)
64
+
65
+ let parseFormat = (s: string) =>
66
+ switch s {
67
+ | "human" => Ok(Human)
68
+ | "json" => Ok(Json)
69
+ | "tap" => Ok(Tap)
70
+ | "junit" => Ok(Junit)
71
+ | "vscode" => Ok(VsCode)
72
+ | other => Error("Unknown --format value: " ++ other)
73
+ }
74
+
75
+ // With no explicit path argument, scan the whole working-directory subtree.
76
+ // `Discovery.walk` already prunes `node_modules`/`lib`/`.git`/`dist`/`.history`,
77
+ // so pointing at the cwd auto-discovers every plugin's GWT tests in a
78
+ // multi-package workspace (e.g. an example root) with no roots configured.
79
+ let defaultRoots = () => ["."]
80
+
81
+ let help = () => `reventless-dev — Reventless dev CLI: GWT runner, domain graph, components, platform runner
82
+
83
+ USAGE:
84
+ reventless-dev run [--format=<fmt>] [--filter=<id>] [--stream] [--watch] [path...]
85
+ reventless-dev discover [--format=vscode] [path...]
86
+ reventless-dev watch [--format=<fmt>] [--filter=<id>] [path...]
87
+ reventless-dev platform [--format=vscode] [--backend=<b>] [--ui-ports] [path...]
88
+ reventless-dev platform --list [path...]
89
+
90
+ FORMATS:
91
+ human ANSI-coloured terminal output (default)
92
+ json Structured JSON envelope (--stream for NDJSON)
93
+ tap TAP 14 with YAML diagnostics
94
+ junit JUnit XML (single batch)
95
+ vscode NDJSON event stream for the VS Code Testing API
96
+
97
+ FLAGS:
98
+ --filter <id> Restrict execution to tests whose id contains <id>.
99
+ Repeatable.
100
+ --stream NDJSON streaming (json/vscode)
101
+ --watch Re-run on file change
102
+ --schema-version <v> Pin a JSON schema version for stable AI prompts
103
+ --backend <b> platform: storage backend for the spawned local platform
104
+ ("memory" default, or "sqlite:<path>[?reset]")
105
+ --ui-ports platform: pin the platform to the fixed default ports
106
+ (4000/4001/3001/3002) instead of ephemeral ones, so the
107
+ host-shell UI's Vite proxy can reach it
108
+ --list platform: list launchable platform packages as NDJSON
109
+ ({name, dir} per line) and exit
110
+ --help Show this help and exit
111
+
112
+ Exit code is 1 if any test failed, 0 otherwise.
113
+ `
114
+
115
+ let parseArgv = (argv: array<string>): result<options, string> => {
116
+ let slice = argv->Array.slice(~start=2, ~end=argv->Array.length)
117
+ let subcommand = ref(Run)
118
+ let format = ref(Human)
119
+ let stream = ref(false)
120
+ let watch = ref(false)
121
+ let filters = ref([])
122
+ let schemaVersion: ref<option<string>> = ref(None)
123
+ let roots = ref([])
124
+ let backend = ref("memory")
125
+ let uiPorts = ref(false)
126
+ let listPlatforms = ref(false)
127
+ let error = ref(None)
128
+ let showHelp = ref(false)
129
+ let i = ref(0)
130
+ let len = slice->Array.length
131
+ if len > 0 {
132
+ let first = slice->Array.getUnsafe(0)
133
+ switch first {
134
+ | "run" => {
135
+ subcommand := Run
136
+ i := 1
137
+ }
138
+ | "discover" => {
139
+ subcommand := Discover
140
+ format := VsCode
141
+ i := 1
142
+ }
143
+ | "watch" => {
144
+ subcommand := Watch
145
+ watch := true
146
+ i := 1
147
+ }
148
+ | "platform" => {
149
+ subcommand := Platform
150
+ i := 1
151
+ }
152
+ | "--help" | "-h" => showHelp := true
153
+ | _ => ()
154
+ }
155
+ }
156
+ while i.contents < len && error.contents == None && !showHelp.contents {
157
+ let arg = slice->Array.getUnsafe(i.contents)
158
+ if arg == "--help" || arg == "-h" {
159
+ showHelp := true
160
+ } else if String.startsWith(arg, "--format=") {
161
+ let v = String.slice(arg, ~start=9, ~end=String.length(arg))
162
+ switch parseFormat(v) {
163
+ | Ok(f) => format := f
164
+ | Error(e) => error := Some(e)
165
+ }
166
+ } else if arg == "--format" && i.contents + 1 < len {
167
+ let v = slice->Array.getUnsafe(i.contents + 1)
168
+ switch parseFormat(v) {
169
+ | Ok(f) => format := f
170
+ | Error(e) => error := Some(e)
171
+ }
172
+ i := i.contents + 1
173
+ } else if String.startsWith(arg, "--filter=") {
174
+ let v = String.slice(arg, ~start=9, ~end=String.length(arg))
175
+ filters := Array.concat(filters.contents, [v])
176
+ } else if arg == "--filter" && i.contents + 1 < len {
177
+ let v = slice->Array.getUnsafe(i.contents + 1)
178
+ filters := Array.concat(filters.contents, [v])
179
+ i := i.contents + 1
180
+ } else if arg == "--stream" {
181
+ stream := true
182
+ } else if arg == "--watch" {
183
+ watch := true
184
+ } else if String.startsWith(arg, "--schema-version=") {
185
+ let v = String.slice(arg, ~start=17, ~end=String.length(arg))
186
+ schemaVersion := Some(v)
187
+ } else if String.startsWith(arg, "--backend=") {
188
+ backend := String.slice(arg, ~start=10, ~end=String.length(arg))
189
+ } else if arg == "--backend" && i.contents + 1 < len {
190
+ backend := slice->Array.getUnsafe(i.contents + 1)
191
+ i := i.contents + 1
192
+ } else if arg == "--ui-ports" {
193
+ uiPorts := true
194
+ } else if arg == "--list" {
195
+ listPlatforms := true
196
+ } else if String.startsWith(arg, "--") {
197
+ error := Some("Unknown flag: " ++ arg)
198
+ } else {
199
+ roots := Array.concat(roots.contents, [arg])
200
+ }
201
+ i := i.contents + 1
202
+ }
203
+ if showHelp.contents {
204
+ Error(help())
205
+ } else {
206
+ switch error.contents {
207
+ | Some(e) => Error(e ++ "\n\n" ++ help())
208
+ | None =>
209
+ Ok({
210
+ subcommand: subcommand.contents,
211
+ format: format.contents,
212
+ stream: stream.contents,
213
+ watch: watch.contents,
214
+ filters: filters.contents,
215
+ schemaVersion: schemaVersion.contents,
216
+ roots: roots.contents->Array.length == 0 ? defaultRoots() : roots.contents,
217
+ backend: backend.contents,
218
+ uiPorts: uiPorts.contents,
219
+ listPlatforms: listPlatforms.contents,
220
+ toolVersion,
221
+ paths: None,
222
+ })
223
+ }
224
+ }
225
+ }
226
+
227
+ // Apply --filter: pass if id contains every filter substring, OR if no filters.
228
+ let passesFilter = (id: string, filters: array<string>) =>
229
+ filters->Array.length == 0 ||
230
+ filters->Array.some(f => id->String.includes(f))
231
+
232
+ // Extract a human-readable message from any thrown value. JS Errors carry a
233
+ // `.message`; ReScript exceptions are tagged objects (`RE_EXN_ID` + an optional
234
+ // string payload), e.g. `failwith("…")` raises `Failure("…")`. Without this the
235
+ // catch block below collapsed every ReScript exception to "unknown error",
236
+ // hiding messages like a slice's `failwith("not implemented: …")`.
237
+ let exnMessage: exn => string = %raw(`function(e) {
238
+ if (e == null) return "unknown error"
239
+ if (typeof e === "string") return e
240
+ if (typeof e.message === "string" && e.message.length) return e.message
241
+ if (typeof e.RE_EXN_ID === "string") {
242
+ var id = e.RE_EXN_ID
243
+ var tag = id.lastIndexOf(".") >= 0 ? id.slice(id.lastIndexOf(".") + 1) : id
244
+ return (typeof e._1 === "string" && e._1.length) ? e._1 : tag
245
+ }
246
+ return "unknown error"
247
+ }`)
248
+
249
+ // Default per-test deadline (ms) when a test body doesn't set `~timeout`.
250
+ // Mirrors Jest's default so behaviour is consistent across both runners.
251
+ let defaultTimeoutMs = 5000
252
+
253
+ // Resolve to a timeout `Outcome` after `ms`. Raced against the test body so a
254
+ // hung await reports a failure instead of wedging the entire run.
255
+ type timeoutHandle
256
+ @val external setTimeout: (unit => unit, int) => timeoutHandle = "setTimeout"
257
+ @val external clearTimeout: timeoutHandle => unit = "clearTimeout"
258
+ let raceWithTimeout = (body: unit => promise<Outcome.outcome>, ms: int): promise<
259
+ Outcome.outcome,
260
+ > => {
261
+ let handleRef: ref<option<timeoutHandle>> = ref(None)
262
+ let timeout = Promise.make((resolve, _reject) => {
263
+ handleRef :=
264
+ Some(
265
+ setTimeout(
266
+ () =>
267
+ resolve(
268
+ Outcome.fail(
269
+ Throw({error: `timed out after ${Int.toString(ms)}ms`, stack: ""}),
270
+ ),
271
+ ),
272
+ ms,
273
+ ),
274
+ )
275
+ })
276
+ Promise.race([body(), timeout])->Promise.then(o => {
277
+ switch handleRef.contents {
278
+ | Some(h) => clearTimeout(h)
279
+ | None => ()
280
+ }
281
+ Promise.resolve(o)
282
+ })
283
+ }
284
+
285
+ // Execute a single entry, catching exceptions and thrown values so a
286
+ // misbehaved slice doesn't abort the rest of the suite.
287
+ let runEntry = async (entry: Collector.entry): RunnerTypes.testResult => {
288
+ let start = now()
289
+ let status = ref(RunnerTypes.Pass)
290
+ let mismatch: ref<option<Outcome.mismatch>> = ref(None)
291
+ switch entry.status {
292
+ | Collector.Skipped => status := Skip
293
+ | _ =>
294
+ try {
295
+ let deadline = entry.timeout->Option.getOr(defaultTimeoutMs)
296
+ let outcome = await raceWithTimeout(entry.body, deadline)
297
+ switch outcome {
298
+ | Ok() => status := Pass
299
+ | Error(m) => {
300
+ status := Fail
301
+ mismatch := Some(m)
302
+ }
303
+ }
304
+ } catch {
305
+ | exn => {
306
+ status := Fail
307
+ let err = exnMessage(exn)
308
+ let stack: string = %raw(`(e => (e && e.stack) || "")`)(exn)
309
+ mismatch := Some(Outcome.Throw({error: err, stack: stack}))
310
+ }
311
+ }
312
+ }
313
+ let durationMs = now() -. start
314
+ {
315
+ id: entry.id,
316
+ name: entry.name,
317
+ describePath: entry.describePath,
318
+ status: status.contents,
319
+ durationMs,
320
+ location: entry.location,
321
+ slice: entry.slice,
322
+ mismatch: mismatch.contents,
323
+ skipReason: entry.status == Collector.Skipped ? Some("skipped at source") : None,
324
+ }
325
+ }
326
+
327
+ let loadAndCollect = async (path: string): array<Collector.entry> => {
328
+ Collector.activate()
329
+ Collector.setCurrentFile(path)
330
+ try {
331
+ await Loader.loadFile(path)
332
+ } catch {
333
+ | exn =>
334
+ let msg =
335
+ exn
336
+ ->JsExn.fromException
337
+ ->Option.flatMap(JsExn.message)
338
+ ->Option.getOr("import failed")
339
+ Collector.push(
340
+ `<file-load-error>`,
341
+ () =>
342
+ Promise.resolve(
343
+ Outcome.fail(
344
+ Throw({error: "Failed to load " ++ path ++ ": " ++ msg, stack: ""}),
345
+ ),
346
+ ),
347
+ )
348
+ }
349
+ let entries = Collector.drain()
350
+ Collector.deactivate()
351
+ entries
352
+ }
353
+
354
+ let runFiles = async (
355
+ opts: options,
356
+ paths: array<string>,
357
+ ~onFileFinished: option<RunnerTypes.fileResult => unit>=?,
358
+ ~onTestStart: option<(string, Collector.entry) => unit>=?,
359
+ ~onTestFinished: option<(string, RunnerTypes.testResult) => unit>=?,
360
+ ): RunnerTypes.runResult => {
361
+ let startedAt = dateNowIso()
362
+ let startTime = now()
363
+ let files: array<RunnerTypes.fileResult> = []
364
+ for i in 0 to paths->Array.length - 1 {
365
+ let path = paths->Array.getUnsafe(i)
366
+ if Cancellation.isCancelled() {
367
+ ()
368
+ } else {
369
+ let entries = await loadAndCollect(path)
370
+ let onlyActive = Collector.hasOnly.contents
371
+ let selected = entries->Array.filter(e => {
372
+ let keepByOnly = onlyActive ? e.status == Only : true
373
+ // Match filters against both the bare entry id (`describe::name`) and
374
+ // the fully-qualified id the VS Code extension uses
375
+ // (`<absFile>::<describe>::<name>`). Without the qualified form the
376
+ // extension's `--filter=<testId>` never matches — see continuous-run
377
+ // stale-failure fix.
378
+ let qualifiedId = path ++ "::" ++ e.id
379
+ let keepByFilter =
380
+ passesFilter(e.id, opts.filters) || passesFilter(qualifiedId, opts.filters)
381
+ keepByOnly && keepByFilter
382
+ })
383
+ let tests: array<RunnerTypes.testResult> = []
384
+ for j in 0 to selected->Array.length - 1 {
385
+ if Cancellation.isCancelled() {
386
+ let e = selected->Array.getUnsafe(j)
387
+ let r: RunnerTypes.testResult = {
388
+ id: e.id,
389
+ name: e.name,
390
+ describePath: e.describePath,
391
+ status: Skip,
392
+ durationMs: 0.0,
393
+ location: e.location,
394
+ slice: e.slice,
395
+ mismatch: None,
396
+ skipReason: Some("cancelled"),
397
+ }
398
+ tests->Array.push(r)
399
+ switch onTestFinished {
400
+ | Some(cb) => cb(path, r)
401
+ | None => ()
402
+ }
403
+ } else {
404
+ let entry = selected->Array.getUnsafe(j)
405
+ switch onTestStart {
406
+ | Some(cb) => cb(path, entry)
407
+ | None => ()
408
+ }
409
+ let r = await runEntry(entry)
410
+ tests->Array.push(r)
411
+ switch onTestFinished {
412
+ | Some(cb) => cb(path, r)
413
+ | None => ()
414
+ }
415
+ }
416
+ }
417
+ let fr: RunnerTypes.fileResult = {path, tests}
418
+ files->Array.push(fr)
419
+ switch onFileFinished {
420
+ | Some(cb) => cb(fr)
421
+ | None => ()
422
+ }
423
+ }
424
+ }
425
+ let durationMs = now() -. startTime
426
+ let summary = RunnerTypes.summaryOf(files, durationMs, startedAt)
427
+ {summary, files}
428
+ }
429
+
430
+ let emitResult = (opts: options, r: RunnerTypes.runResult) =>
431
+ switch opts.format {
432
+ | Human => FormatterHuman.emit(r)
433
+ | Json =>
434
+ if opts.stream {
435
+ FormatterJson.streamRunEnd(r)
436
+ } else {
437
+ FormatterJson.emit(r, ~toolVersion=opts.toolVersion, ~schemaVersion=?opts.schemaVersion)
438
+ }
439
+ | Tap => FormatterTap.emit(r)
440
+ | Junit => FormatterJunit.emit(r)
441
+ | VsCode => FormatterVsCode.runEnd(r.summary)
442
+ }
443
+
444
+ // Watch re-run scope. A plain edit under a test-owning package narrows the pass
445
+ // to that package's test files (`RunPackages`); structural rebuilds, new-file
446
+ // adds, and any non-VsCode watch use `RunAll`. Scopes accumulate while a pass is
447
+ // in flight — `RunAll` is absorbing (an all-run unioned with anything is an
448
+ // all-run), and `RunPackages` unions its dirs.
449
+ type runScope = RunAll | RunPackages(array<string>)
450
+
451
+ let mergeScope = (a: runScope, b: runScope): runScope =>
452
+ switch (a, b) {
453
+ | (RunAll, _) | (_, RunAll) => RunAll
454
+ | (RunPackages(x), RunPackages(y)) =>
455
+ let seen = Dict.make()
456
+ let union = []
457
+ Array.concat(x, y)->Array.forEach(d =>
458
+ switch seen->Dict.get(d) {
459
+ | Some(_) => ()
460
+ | None => {
461
+ seen->Dict.set(d, true)
462
+ union->Array.push(d)
463
+ }
464
+ }
465
+ )
466
+ RunPackages(union)
467
+ }
468
+
469
+ // True when `path` sits inside directory `dir` (dir is an ancestor). Lets a
470
+ // package dir be mapped to its test files by prefix, with no per-path fs walk.
471
+ let pathUnderDir = (path: string, dir: string): bool =>
472
+ String.startsWith(path, String.endsWith(dir, "/") ? dir : dir ++ "/")
473
+
474
+ // The subset of `allPaths` a scope re-runs.
475
+ let scopeSubset = (scope: runScope, allPaths: array<string>): array<string> =>
476
+ switch scope {
477
+ | RunAll => allPaths
478
+ | RunPackages(dirs) => allPaths->Array.filter(p => dirs->Array.some(d => pathUnderDir(p, d)))
479
+ }
480
+
481
+ let runOnce = async (opts: options): int => {
482
+ // A watch worker pass supplies the exact path set (parent-owned discovery,
483
+ // narrowed to the affected package); the one-shot commands discover the tree.
484
+ let paths = switch opts.paths {
485
+ | Some(p) => p
486
+ | None => await Discovery.discover(opts.roots)
487
+ }
488
+ if opts.format == Json && opts.stream {
489
+ FormatterJson.streamRunStart(
490
+ ~toolVersion=opts.toolVersion,
491
+ ~startedAt=dateNowIso(),
492
+ ~schemaVersion=?opts.schemaVersion,
493
+ )
494
+ }
495
+ let onFileFinished: option<RunnerTypes.fileResult => unit> = switch (
496
+ opts.format,
497
+ opts.stream,
498
+ ) {
499
+ | (Json, true) =>
500
+ Some(
501
+ (f: RunnerTypes.fileResult) => {
502
+ f.tests->Array.forEach(FormatterJson.streamTest)
503
+ FormatterJson.streamFileFinished(f)
504
+ },
505
+ )
506
+ | _ => None
507
+ }
508
+ // VS Code emits per-test events (testStart / testPass / testFail / testSkip)
509
+ // so the extension can update the UI in real time. IDs are prefixed with the
510
+ // absolute file path to match the ids emitted by `discover`.
511
+ let onTestStart: option<(string, Collector.entry) => unit> =
512
+ opts.format == VsCode
513
+ ? Some((path, e) => FormatterVsCode.testStart(path ++ "::" ++ e.id))
514
+ : None
515
+ let onTestFinished: option<(string, RunnerTypes.testResult) => unit> =
516
+ opts.format == VsCode
517
+ ? Some(
518
+ (path, t) => {
519
+ let id = path ++ "::" ++ t.id
520
+ switch t.status {
521
+ | Pass => FormatterVsCode.testPass(id, t.durationMs)
522
+ | Fail => FormatterVsCode.testFail(t, id)
523
+ | Skip =>
524
+ FormatterVsCode.testSkip(id, t.skipReason->Option.getOr("skipped"))
525
+ }
526
+ },
527
+ )
528
+ : None
529
+ // For streaming formatters, emit per-file events; emitResult only emits
530
+ // the terminal `runEnd`.
531
+ if opts.format == Json && opts.stream {
532
+ paths->Array.forEach(p => FormatterJson.streamFileStart(p))
533
+ } else if opts.format == VsCode {
534
+ FormatterVsCode.runStart(~total=0, ~filter=opts.filters)
535
+ }
536
+ let result = await runFiles(opts, paths, ~onFileFinished?, ~onTestStart?, ~onTestFinished?)
537
+ emitResult(opts, result)
538
+ result.summary.failed > 0 ? 1 : 0
539
+ }
540
+
541
+ // Reflects each plugin's pluginStructure via the local host (a single cold load) and
542
+ // emits the domain-analysis events: `deadCode` (orphan events) and `graph` (the
543
+ // Event-Modeling node/edge model). Skips silently when there are no plugin packages
544
+ // or the local platform isn't resolvable, and swallows any load failure so the
545
+ // discovery stream is never compromised.
546
+ let emitDomainAnalysis = async (pkgs: array<PackageScan.pkg>): unit =>
547
+ switch LocalHost.discover(~packageDirs=pkgs->Array.map(p => p.dir)) {
548
+ | [] => ()
549
+ | plugins =>
550
+ switch LocalHost.resolveLocalPlatform(~fromPackageDir=(plugins->Array.getUnsafe(0)).packageDir) {
551
+ | None => ()
552
+ | Some(platformModulePath) =>
553
+ try {
554
+ let loaded = await LocalHost.loadGraph(~platformModulePath, ~plugins)
555
+ // Field schemas for command/event/read-side state (Phase 6.3): the same
556
+ // per-plugin entry the Platform_ComponentDefinitions GraphQL query returns.
557
+ let definitions =
558
+ loaded.structures->Array.map(((pluginId, s)) =>
559
+ ReventlessCore.Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId, s)
560
+ )
561
+ // deadCode + graph + definitions are all pure functions of `structures`,
562
+ // so the definitions JSON is a faithful fingerprint of all three. Skip
563
+ // the whole emit when it hasn't moved since the last discovery.
564
+ let fingerprint = sha256(JSON.stringify(JSON.Array(definitions)))
565
+ if lastDomainHash.contents != Some(fingerprint) {
566
+ lastDomainHash := Some(fingerprint)
567
+ FormatterVsCode.deadCode(DomainDeadCode.analyze(~structures=loaded.structures))
568
+ FormatterVsCode.graph(DomainGraph.build(~structures=loaded.structures))
569
+ FormatterVsCode.definitions(definitions)
570
+ }
571
+ } catch {
572
+ | _ => ()
573
+ }
574
+ }
575
+ }
576
+
577
+ // Emits the full VS Code discovery stream (tree items + `packages`) for a set
578
+ // of already-discovered test files. Shared by `discover` and `watch`.
579
+ let emitDiscovery = async (paths: array<string>): unit => {
580
+ FormatterVsCode.discoverStart()
581
+ let fileTests: array<(string, array<RunnerTypes.testResult>)> = []
582
+ for i in 0 to paths->Array.length - 1 {
583
+ let path = paths->Array.getUnsafe(i)
584
+ let entries = await loadAndCollect(path)
585
+ let tests: array<RunnerTypes.testResult> =
586
+ entries->Array.map(e => {
587
+ RunnerTypes.id: e.id,
588
+ name: e.name,
589
+ describePath: e.describePath,
590
+ status: Skip,
591
+ durationMs: 0.0,
592
+ location: e.location,
593
+ slice: e.slice,
594
+ mismatch: None,
595
+ skipReason: None,
596
+ })
597
+ fileTests->Array.push((path, tests))
598
+ }
599
+ FormatterVsCode.emitDiscoveryItems(fileTests)
600
+ let total = fileTests->Array.reduce(0, (a, (_, ts)) => a + ts->Array.length)
601
+ let pkgs = PackageScan.scan(paths)
602
+ FormatterVsCode.packages(pkgs)
603
+ // Full component inventory (incl. untested components) from each package's src/.
604
+ let comps = await ComponentScan.scan(pkgs->Array.map(p => p.dir))
605
+ FormatterVsCode.components(comps)
606
+ FormatterVsCode.discoverEnd(total)
607
+ // Domain analysis (dead-code + graph): emitted after the core stream so the tree
608
+ // shows first. One cold load of the local platform reflects pluginStructure —
609
+ // best-effort; never breaks discovery.
610
+ await emitDomainAnalysis(pkgs)
611
+ }
612
+
613
+ let runDiscover = async (opts: options): int => {
614
+ let paths = await Discovery.discover(opts.roots)
615
+ await emitDiscovery(paths)
616
+ 0
617
+ }
618
+
619
+ // Spawns (or adopts) a `rescript build -w` per package owning tests, wiring its
620
+ // output to build-status events. Only for the VS Code client — a human `watch`
621
+ // in a terminal keeps the lean re-run-only loop.
622
+ let startBuildWatchers = (paths: array<string>): unit => {
623
+ Cancellation.onCancel(ProcessManager.killAll)
624
+ PackageScan.scan(paths)->Array.forEach(pkg =>
625
+ if WatcherProbe.hasLiveWatcher(pkg.dir) {
626
+ // A developer's own watcher already covers this package — defer to it.
627
+ FormatterVsCode.buildExternal(pkg.dir)
628
+ } else {
629
+ let feed = BuildClassifier.make({
630
+ onStart: () => FormatterVsCode.buildStart(pkg.dir),
631
+ onOk: ms => FormatterVsCode.buildOk(pkg.dir, ms),
632
+ onFail: msg => FormatterVsCode.buildFail(pkg.dir, msg),
633
+ })
634
+ ProcessManager.spawnWatcher(pkg, ~onStdout=(_dir, line) => feed(line))
635
+ }
636
+ )
637
+ }
638
+
639
+ let runWatch = async (opts: options): int => {
640
+ let roots = opts.roots
641
+ // Parent-owned discovery set for the VS Code engine: walked once here and
642
+ // refreshed only on structural rebuilds / new-file adds. A plain edit's re-run
643
+ // pass reuses it (no per-pass tree re-walk) and narrows to the edited file's
644
+ // owning test package (`affectedScope`), so an unrelated plugin's tests aren't
645
+ // re-executed. The per-test NDJSON protocol keeps the client's other results
646
+ // untouched across a narrowed pass.
647
+ let discoveredPaths: ref<array<string>> = ref([])
648
+ // For the VS Code client, watch mode is the whole engine: emit the test tree
649
+ // and package set, take over the ReScript builds, then run + re-run on change.
650
+ if opts.format == VsCode {
651
+ let paths = await Discovery.discover(roots)
652
+ discoveredPaths := paths
653
+ await emitDiscovery(paths)
654
+ startBuildWatchers(paths)
655
+ }
656
+ // Each re-run pass executes in a short-lived Worker so it gets a fresh ESM
657
+ // module registry (recompiled implementation modules are re-imported instead
658
+ // of served stale from Node's never-evicting cache) and reclaims the imported
659
+ // graph on exit. The worker's stdout is piped to ours, so its NDJSON reaches
660
+ // the client unchanged. One worker per pass; the parent keeps ownership of the
661
+ // build watchers and chokidar.
662
+ let currentWorker: ref<option<Worker.t>> = ref(None)
663
+ Cancellation.onCancel(() =>
664
+ switch currentWorker.contents {
665
+ | Some(w) =>
666
+ let _ = Worker.terminate(w)
667
+ | None => ()
668
+ }
669
+ )
670
+ // Run one pass in a fresh worker over `paths`. `None` lets the worker discover
671
+ // the full tree itself — kept for a human terminal `watch`, whose full-summary
672
+ // output must always reflect every file.
673
+ let runInWorker = (paths: option<array<string>>): promise<unit> =>
674
+ Promise.make((resolve, _reject) => {
675
+ let w = Worker.make(Worker.runWorkerUrl, {workerData: {...opts, paths}})
676
+ currentWorker := Some(w)
677
+ let settled = ref(false)
678
+ let settle = () =>
679
+ if !settled.contents {
680
+ settled := true
681
+ currentWorker := None
682
+ resolve()
683
+ }
684
+ w->Worker.on("exit", (_code: int) => settle())
685
+ w->Worker.on("error", e => {
686
+ Console.error2("gwt run worker error:", e)
687
+ settle()
688
+ })
689
+ })
690
+ // Resolve a scope to the concrete path subset for a worker pass. The VS Code
691
+ // engine passes the parent-owned subset; a human terminal `watch` passes None
692
+ // (worker discovers), preserving its full-summary semantics.
693
+ let pathsForScope = (scope: runScope): option<array<string>> =>
694
+ opts.format == VsCode ? Some(scopeSubset(scope, discoveredPaths.contents)) : None
695
+ // Single-flight run state machine. A re-run requested while a pass is in flight
696
+ // coalesces into `pendingScope` (scopes merge; `RunAll` absorbing) and drains
697
+ // after the current pass. Holding `runInProgress` across the whole drain loop
698
+ // means only one worker ever runs at a time — a third concurrent request can't
699
+ // corrupt the run or interleave NDJSON.
700
+ let runInProgress = ref(false)
701
+ let pendingScope: ref<option<runScope>> = ref(None)
702
+ let requestRun = async (scope: runScope) =>
703
+ if runInProgress.contents {
704
+ pendingScope :=
705
+ Some(
706
+ switch pendingScope.contents {
707
+ | Some(s) => mergeScope(s, scope)
708
+ | None => scope
709
+ },
710
+ )
711
+ } else {
712
+ runInProgress := true
713
+ let _ = await runInWorker(pathsForScope(scope))
714
+ let rec drain = async () =>
715
+ switch pendingScope.contents {
716
+ | Some(next) => {
717
+ pendingScope := None
718
+ let _ = await runInWorker(pathsForScope(next))
719
+ await drain()
720
+ }
721
+ | None => runInProgress := false
722
+ }
723
+ await drain()
724
+ }
725
+ let _ = await requestRun(RunAll)
726
+ // Re-walk discovery (VS Code) then re-run everything. Used after a structural
727
+ // clean-rebuild, whose relocation may have changed the discovered test set.
728
+ let rediscoverAndRun = async () => {
729
+ if opts.format == VsCode {
730
+ discoveredPaths := (await Discovery.discover(roots))
731
+ }
732
+ let _ = await requestRun(RunAll)
733
+ }
734
+ // A *relocation* (a module moved to another directory — a chapter rename /
735
+ // ungroup / move-to-chapter in the extension, or a terminal `mv`) leaves
736
+ // dependents' emitted `.res.mjs` pointing at the old path: incremental
737
+ // compilation never marks them dirty (their source + the moved module's
738
+ // interface hash are unchanged), so the live watcher and a restart both skip
739
+ // them and the stale import surfaces as a `<file-load-error>`. The only
740
+ // reliable repair is a `rescript clean` of the owning package. Detect the
741
+ // structural signal — an `unlink` of a source `.res` (a module left a
742
+ // directory) — and clean-rebuild that package, gating the re-run on build
743
+ // completion. Edits and adds stay on the lean incremental re-run. VS Code
744
+ // only: a human terminal `watch` keeps the re-run-only loop (it doesn't own
745
+ // the builds, so there's nothing to clean-rebuild).
746
+ // Guards against two structural paths from the *same* burst (a multi-file move
747
+ // within one package) each launching a concurrent clean-rebuild of that
748
+ // package. The first sets the flag; siblings coalesce into its completion
749
+ // re-run. Watch now emits one callback per distinct structural path, so this
750
+ // is what makes "rebuild every distinct owning package" (not per-file) true.
751
+ let rebuilding: Dict.t<bool> = Dict.make()
752
+ let structuralRebuild = (path: string): unit =>
753
+ switch PackageScan.findOwning(path) {
754
+ | None => ignore(requestRun(RunAll))
755
+ | Some(pkg) if rebuilding->Dict.get(pkg.dir)->Option.getOr(false) => ()
756
+ | Some(pkg) =>
757
+ rebuilding->Dict.set(pkg.dir, true)
758
+ let t0 = Date.now()
759
+ FormatterVsCode.buildStart(pkg.dir)
760
+ // Surface a compile error during the rebuild (the clean/build stream is
761
+ // classified for the error marker); the re-run is gated on completion
762
+ // below, not on the classifier, since a one-shot `rescript build` has no
763
+ // "Finished … compilation" terminator.
764
+ let sawError = ref(false)
765
+ let feed = BuildClassifier.make({
766
+ onStart: () => (),
767
+ onOk: _ms => (),
768
+ onFail: msg => {
769
+ sawError := true
770
+ FormatterVsCode.buildFail(pkg.dir, msg)
771
+ },
772
+ })
773
+ ProcessManager.cleanRebuild(
774
+ pkg,
775
+ ~onStdout=(_dir, line) => feed(line),
776
+ ~onDone=ok => {
777
+ rebuilding->Dict.set(pkg.dir, false)
778
+ // Only report success when the build actually succeeded. On failure
779
+ // the classifier's `onFail` has usually already emitted the compiler
780
+ // error; emit a fallback `buildFail` only if it didn't (e.g. a crash
781
+ // with no recognisable marker), so we never claim `buildOk` over a
782
+ // broken build and re-run against stale `.res.mjs`.
783
+ if ok {
784
+ FormatterVsCode.buildOk(pkg.dir, Date.now() -. t0)
785
+ } else if !sawError.contents {
786
+ FormatterVsCode.buildFail(pkg.dir, "clean rebuild failed")
787
+ }
788
+ ignore(rediscoverAndRun())
789
+ },
790
+ )
791
+ }
792
+ // A test file added mid-session runs (discover re-scans each pass) but never
793
+ // appears in the client's tree, which was emitted once at start. On an `Add`
794
+ // of a *source* `.res` re-emit the discovery stream (idempotent snapshot) and
795
+ // (re)claim build watchers for any new package before re-running.
796
+ let refreshDiscovery = async () => {
797
+ let paths = await Discovery.discover(roots)
798
+ discoveredPaths := paths
799
+ await emitDiscovery(paths)
800
+ startBuildWatchers(paths)
801
+ let _ = await requestRun(RunAll)
802
+ }
803
+ // Narrow a plain edit's re-run to the edited file's owning test package (VS
804
+ // Code only). `RunAll` when the file has no owning package, or the owner isn't
805
+ // one of the discovered test packages (e.g. a framework edit that ripples into
806
+ // several plugins — full re-run is the safe choice there). A human terminal
807
+ // `watch` always re-runs everything (full-summary output).
808
+ let affectedScope = (path: string): runScope =>
809
+ if opts.format == VsCode {
810
+ let abs = resolvePath(path)
811
+ switch PackageScan.findOwning(PackageScan.dirname(abs)) {
812
+ | Some(pkg) if discoveredPaths.contents->Array.some(p => pathUnderDir(p, pkg.dir)) =>
813
+ RunPackages([pkg.dir])
814
+ | _ => RunAll
815
+ }
816
+ } else {
817
+ RunAll
818
+ }
819
+ let _watcher = Watch.start(roots, (event, path) =>
820
+ if opts.format == VsCode && Watch.isStructuralSource(event, path) {
821
+ structuralRebuild(path)
822
+ } else if opts.format == VsCode && event == Watch.Add && path->String.endsWith(".res") {
823
+ ignore(refreshDiscovery())
824
+ } else {
825
+ ignore(requestRun(affectedScope(path)))
826
+ }
827
+ )
828
+ // Keep the process alive until cancelled. Awaiting here (rather than letting
829
+ // runWatch return) is essential: the bin wrapper calls `process.exit` on the
830
+ // resolved code, so a returning runWatch would tear down chokidar and the
831
+ // spawned build watchers immediately. Cancellation handlers run in
832
+ // registration order — ProcessManager.killAll first, then this resolve — so
833
+ // the watchers are killed before the process exits.
834
+ await Promise.make((resolve, _reject) => Cancellation.onCancel(() => resolve()))
835
+ 0
836
+ }
837
+
838
+ // Launches an app's reventless-local platform as a managed child with the domain
839
+ // event tap on, streaming lifecycle + events. For the VS Code client the callbacks
840
+ // emit NDJSON; a human `platform` prints readable lines and passes the child's own
841
+ // logs through.
842
+ let runPlatform = async (opts: options): int => {
843
+ let callbacks: PlatformRunner.callbacks = if opts.format == VsCode {
844
+ {
845
+ onStart: (~package, ~dir, ~domainPort, ~platformPort) =>
846
+ FormatterVsCode.platformStart(~package, ~dir, ~domainPort, ~platformPort),
847
+ onReady: (~domainEndpoint) => FormatterVsCode.platformReady(~domainEndpoint),
848
+ onDomainEvent: json => FormatterVsCode.domainEvent(json),
849
+ onLog: line => FormatterVsCode.platformLog(~line),
850
+ onStop: code => FormatterVsCode.platformStop(~code),
851
+ }
852
+ } else {
853
+ {
854
+ onStart: (~package, ~dir as _, ~domainPort, ~platformPort as _) =>
855
+ Console.log(`▶ platform ${package} starting (domain http://localhost:${Int.toString(domainPort)})`),
856
+ onReady: (~domainEndpoint) => Console.log(`✓ platform ready — ${domainEndpoint}`),
857
+ onDomainEvent: json => Console.log("· event " ++ JSON.stringify(json)),
858
+ onLog: line => Console.log(line),
859
+ onStop: code =>
860
+ Console.log(`■ platform stopped (code ${code->Option.mapOr("?", c => Int.toString(c))})`),
861
+ }
862
+ }
863
+ await PlatformRunner.run(
864
+ ~roots=opts.roots,
865
+ ~backend=opts.backend,
866
+ ~fixedPorts=opts.uiPorts,
867
+ ~callbacks,
868
+ )
869
+ }
870
+
871
+ // `platform --list`: enumerate the launchable platform packages under the roots
872
+ // and print one NDJSON `{name, dir}` object per line. The VS Code extension reads
873
+ // these to populate its active-app picker (app root = the package's parent dir).
874
+ // Plain stdout, no protocol handshake — it's a one-shot query, not a stream.
875
+ let listPlatforms = async (opts: options): int => {
876
+ let pkgs = await PlatformScan.scan(opts.roots)
877
+ pkgs->Array.forEach(pkg => {
878
+ let obj = Dict.fromArray([("name", JSON.String(pkg.name)), ("dir", JSON.String(pkg.dir))])
879
+ Console.log(JSON.stringify(JSON.Object(obj)))
880
+ })
881
+ 0
882
+ }
883
+
884
+ let main = async (): int => {
885
+ Cancellation.install()
886
+ let args = argv
887
+ switch parseArgv(args) {
888
+ | Error(msg) => {
889
+ Console.error(msg)
890
+ args->Array.some(a => a == "--help" || a == "-h") ? 0 : 2
891
+ }
892
+ | Ok(opts) =>
893
+ // Protocol handshake first, so a version-skewed extension can detect the
894
+ // CLI before interpreting the stream.
895
+ if opts.format == VsCode {
896
+ FormatterVsCode.hello()
897
+ }
898
+ switch opts.subcommand {
899
+ | Run => await runOnce(opts)
900
+ | Discover => await runDiscover(opts)
901
+ | Watch => await runWatch(opts)
902
+ | Platform => opts.listPlatforms ? await listPlatforms(opts) : await runPlatform(opts)
903
+ }
904
+ }
905
+ }