@reventlessdev/reventless-gwt 1.0.0-alpha.75 → 1.0.0-alpha.77

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 (70) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +8 -8
  3. package/src/Behavior_GWT.res +125 -159
  4. package/src/Behavior_GWT.res.mjs +156 -59
  5. package/src/Bind.res.mjs +2 -2
  6. package/src/BuildClassifier.res +15 -5
  7. package/src/BuildClassifier.res.mjs +3 -3
  8. package/src/Cancellation.res +6 -0
  9. package/src/Cancellation.res.mjs +3 -2
  10. package/src/ChildProcess.res +19 -1
  11. package/src/ChildProcess.res.mjs +16 -1
  12. package/src/Cli.res +260 -26
  13. package/src/Cli.res.mjs +227 -29
  14. package/src/Collector.res +13 -2
  15. package/src/Collector.res.mjs +8 -5
  16. package/src/ComponentMeta.res +16 -43
  17. package/src/ComponentMeta.res.mjs +12 -41
  18. package/src/Discovery.res +19 -19
  19. package/src/Discovery.res.mjs +5 -9
  20. package/src/DomainGraph.res +0 -0
  21. package/src/DomainGraph.res.mjs +0 -0
  22. package/src/Filter.res +11 -2
  23. package/src/Filter.res.mjs +10 -3
  24. package/src/FormatterHuman.res +14 -43
  25. package/src/FormatterHuman.res.mjs +21 -28
  26. package/src/FormatterJson.res +12 -5
  27. package/src/FormatterJson.res.mjs +8 -6
  28. package/src/FormatterTap.res +13 -52
  29. package/src/FormatterTap.res.mjs +25 -43
  30. package/src/FormatterVsCode.res +4 -19
  31. package/src/FormatterVsCode.res.mjs +4 -23
  32. package/src/JestBind.res +1 -1
  33. package/src/JestBind.res.mjs +2 -2
  34. package/src/LocalHost.res +11 -27
  35. package/src/LocalHost.res.mjs +6 -34
  36. package/src/MismatchRender.res +72 -0
  37. package/src/MismatchRender.res.mjs +127 -0
  38. package/src/Outcome.res +0 -64
  39. package/src/Outcome.res.mjs +0 -47
  40. package/src/PlatformRunner.res +27 -6
  41. package/src/PlatformRunner.res.mjs +27 -6
  42. package/src/PlatformScan.res +14 -10
  43. package/src/PlatformScan.res.mjs +14 -13
  44. package/src/ProcessManager.res +51 -7
  45. package/src/ProcessManager.res.mjs +43 -4
  46. package/src/RunWorker.res +24 -0
  47. package/src/RunWorker.res.mjs +16 -0
  48. package/src/Watch.res +46 -30
  49. package/src/Watch.res.mjs +34 -32
  50. package/src/WatcherProbe.res +11 -3
  51. package/src/WatcherProbe.res.mjs +15 -2
  52. package/src/Worker.res +24 -0
  53. package/src/Worker.res.mjs +9 -0
  54. package/tests/BuildClassifierTest.res +39 -0
  55. package/tests/BuildClassifierTest.res.mjs +44 -0
  56. package/tests/CliRunEntryTest.res +57 -0
  57. package/tests/CliRunEntryTest.res.mjs +65 -0
  58. package/tests/CliWatchScopeTest.res +62 -0
  59. package/tests/CliWatchScopeTest.res.mjs +95 -0
  60. package/tests/ComponentMetaTest.res +15 -0
  61. package/tests/ComponentMetaTest.res.mjs +14 -0
  62. package/tests/DomainGraphTest.res +2 -2
  63. package/tests/FlowAggregateGwtTest.res +2 -0
  64. package/tests/FlowAggregateGwtTest.res.mjs +4 -0
  65. package/tests/FormatterGoldenTest.res +125 -0
  66. package/tests/FormatterGoldenTest.res.mjs +162 -0
  67. package/tests/MappingGwtTest.res +2 -0
  68. package/tests/MappingGwtTest.res.mjs +4 -0
  69. package/tests/WatchDebounceTest.res +35 -7
  70. package/tests/WatchDebounceTest.res.mjs +37 -6
package/src/Bind.res.mjs CHANGED
@@ -6,12 +6,12 @@ let describe = Collector$ReventlessGwt.pushDescribe;
6
6
 
7
7
  function test(slice, name, body) {
8
8
  let location = Collector$ReventlessGwt.captureLocation(1);
9
- Collector$ReventlessGwt.push(slice, location, name, () => Promise.resolve(body()));
9
+ Collector$ReventlessGwt.push(slice, location, undefined, name, () => Promise.resolve(body()));
10
10
  }
11
11
 
12
12
  function testPromise(slice, name, body) {
13
13
  let location = Collector$ReventlessGwt.captureLocation(1);
14
- Collector$ReventlessGwt.push(slice, location, name, body);
14
+ Collector$ReventlessGwt.push(slice, location, undefined, name, body);
15
15
  }
16
16
 
17
17
  export {
@@ -74,11 +74,21 @@ let make = (~watchdogMs: int=120_000, cb: callbacks): (string => unit) => {
74
74
  buffer := []
75
75
  }
76
76
 
77
- // After output settles, a compile that saw an error (and thus no "Finished"
78
- // terminator) is reported failed, capturing a concise message.
77
+ // Cap the failure message so a pathological build can't emit an unbounded
78
+ // line. 20 was too tight a multi-error build has several "We've found a bug"
79
+ // blocks and the tail (later errors) was silently dropped; keep enough to
80
+ // carry a handful of blocks.
81
+ let maxMessageLines = 200
82
+
83
+ // After output settles, a build that saw an error (and thus no "Finished"
84
+ // terminator) is reported failed, capturing a concise message. The condition
85
+ // is `hasError` alone — not `compiling && hasError` — so an error emitted
86
+ // *before* the first "Parsed …" line (a crashed toolchain, a pnpm/resolution
87
+ // failure) still settles into an `onFail` instead of leaving the consumer
88
+ // pinned at its previous state forever.
79
89
  let settle = () =>
80
- if compiling.contents && hasError.contents {
81
- let msg = buffer.contents->Array.slice(~start=0, ~end=20)->Array.join("\n")
90
+ if hasError.contents {
91
+ let msg = buffer.contents->Array.slice(~start=0, ~end=maxMessageLines)->Array.join("\n")
82
92
  clearWatchdog()
83
93
  cb.onFail(msg)
84
94
  reset()
@@ -126,7 +136,7 @@ let make = (~watchdogMs: int=120_000, cb: callbacks): (string => unit) => {
126
136
  }
127
137
  } else {
128
138
  if String.length(l) > 0 {
129
- buffer := Array.concat(buffer.contents, [l])
139
+ buffer.contents->Array.push(l)
130
140
  }
131
141
  if isErrorMarker(l) {
132
142
  hasError := true
@@ -71,10 +71,10 @@ function make(watchdogMsOpt, cb) {
71
71
  buffer.contents = [];
72
72
  };
73
73
  let settle = () => {
74
- if (!(compiling.contents && hasError.contents)) {
74
+ if (!hasError.contents) {
75
75
  return;
76
76
  }
77
- let msg = buffer.contents.slice(0, 20).join("\n");
77
+ let msg = buffer.contents.slice(0, 200).join("\n");
78
78
  clearWatchdog();
79
79
  cb.onFail(msg);
80
80
  reset();
@@ -116,7 +116,7 @@ function make(watchdogMsOpt, cb) {
116
116
  }
117
117
  } else {
118
118
  if (l.length > 0) {
119
- buffer.contents = buffer.contents.concat([l]);
119
+ buffer.contents.push(l);
120
120
  }
121
121
  if (isErrorMarker(l)) {
122
122
  hasError.contents = true;
@@ -24,6 +24,12 @@ let install = () => {
24
24
  | _ => ()
25
25
  }
26
26
  )
27
+ } else {
28
+ // Second signal: the first requested a graceful drain, but a hung await
29
+ // can keep the process alive indefinitely. Honour the operator's repeat
30
+ // SIGINT/SIGTERM as a hard exit (130 = terminated by SIGINT) so only the
31
+ // one signal — not SIGKILL — is needed to break out.
32
+ process->exit(130)
27
33
  }
28
34
  }
29
35
  process->onSignal("SIGINT", handler)
@@ -19,7 +19,9 @@ function onCancel(handler) {
19
19
 
20
20
  function install() {
21
21
  let handler = () => {
22
- if (!cancelled.contents) {
22
+ if (cancelled.contents) {
23
+ process.exit(130);
24
+ } else {
23
25
  cancelled.contents = true;
24
26
  handlers.contents.forEach(h => {
25
27
  try {
@@ -28,7 +30,6 @@ function install() {
28
30
  return;
29
31
  }
30
32
  });
31
- return;
32
33
  }
33
34
  };
34
35
  process.on("SIGINT", handler);
@@ -35,7 +35,9 @@ let mergeEnv = (overrides: option<Dict.t<string>>): Dict.t<string> => {
35
35
  @get external _stderr: t => stream = "stderr"
36
36
  @send external _streamSetEncoding: (stream, string) => unit = "setEncoding"
37
37
  @send external _streamOn: (stream, string, string => unit) => unit = "on"
38
+ @send external _streamOnEnd: (stream, string, unit => unit) => unit = "on"
38
39
  @send external _procOnClose: (t, string, Nullable.t<int> => unit) => unit = "on"
40
+ @send external _procOnError: (t, string, JsExn.t => unit) => unit = "on"
39
41
  @send external _kill: (t, string) => bool = "kill"
40
42
  @val external _processKill: (int, string) => unit = "process.kill"
41
43
 
@@ -45,7 +47,10 @@ let spawn = (cmd: string, args: array<string>, ~cwd: string, ~env: option<Dict.t
45
47
  let pid = (proc: t): option<int> => proc->_pid->Nullable.toOption
46
48
 
47
49
  // Subscribe to a stream line-by-line (stdout or stderr), buffering partial
48
- // chunks. Used to classify ReScript compiler markers in Phase 5.
50
+ // chunks. Used to classify ReScript compiler markers in Phase 5. On stream
51
+ // `end` the final unterminated line is flushed — crash/panic messages
52
+ // typically lack a trailing newline, so without this the last (often most
53
+ // diagnostic) line was silently dropped.
49
54
  let onLines = (s: stream, cb: string => unit): unit => {
50
55
  _streamSetEncoding(s, "utf8")
51
56
  let buf = ref("")
@@ -63,6 +68,13 @@ let onLines = (s: stream, cb: string => unit): unit => {
63
68
  }
64
69
  drain()
65
70
  })
71
+ _streamOnEnd(s, "end", () =>
72
+ if buf.contents != "" {
73
+ let last = buf.contents
74
+ buf := ""
75
+ cb(last)
76
+ }
77
+ )
66
78
  }
67
79
 
68
80
  let onStdoutLine = (proc: t, cb: string => unit): unit => onLines(proc->_stdout, cb)
@@ -70,6 +82,12 @@ let onStderrLine = (proc: t, cb: string => unit): unit => onLines(proc->_stderr,
70
82
  let onClose = (proc: t, cb: option<int> => unit): unit =>
71
83
  _procOnClose(proc, "close", code => cb(code->Nullable.toOption))
72
84
 
85
+ // A spawn failure (ENOENT for a missing binary, EACCES, …) emits `"error"` on
86
+ // the child, not `"close"`. Without a listener Node re-throws it as an uncaught
87
+ // exception and crashes the whole CLI. Route the message to the caller instead.
88
+ let onError = (proc: t, cb: string => unit): unit =>
89
+ _procOnError(proc, "error", e => cb(e->JsExn.message->Option.getOr("spawn failed")))
90
+
73
91
  // Kill the child's whole process group (negative pid) so the rescript watcher
74
92
  // pnpm spawned dies too. Falls back to a direct kill if the pid is gone.
75
93
  let killTree = (proc: t, signal: string): unit =>
@@ -1,5 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
5
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
4
6
  import * as Nodechild_process from "node:child_process";
5
7
 
@@ -49,6 +51,14 @@ function onLines(s, cb) {
49
51
  };
50
52
  drain();
51
53
  });
54
+ s.on("end", () => {
55
+ if (buf.contents === "") {
56
+ return;
57
+ }
58
+ let last = buf.contents;
59
+ buf.contents = "";
60
+ cb(last);
61
+ });
52
62
  }
53
63
 
54
64
  function onStdoutLine(proc, cb) {
@@ -63,6 +73,10 @@ function onClose(proc, cb) {
63
73
  proc.on("close", code => cb((code == null) ? undefined : Primitive_option.some(code)));
64
74
  }
65
75
 
76
+ function onError(proc, cb) {
77
+ proc.on("error", e => cb(Stdlib_Option.getOr(Stdlib_JsExn.message(e), "spawn failed")));
78
+ }
79
+
66
80
  function killTree(proc, signal) {
67
81
  let p = Primitive_option.fromNullable(proc.pid);
68
82
  if (p !== undefined) {
@@ -87,6 +101,7 @@ export {
87
101
  onStdoutLine,
88
102
  onStderrLine,
89
103
  onClose,
104
+ onError,
90
105
  killTree,
91
106
  }
92
- /* node:child_process Not a pure module */
107
+ /* Stdlib_JsExn Not a pure module */
package/src/Cli.res CHANGED
@@ -31,12 +31,35 @@ type options = {
31
31
  // the VS Code extension to populate its active-app picker.
32
32
  listPlatforms: bool,
33
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>>,
34
39
  }
35
40
 
36
41
  @val external argv: array<string> = "process.argv"
37
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
+
38
49
  @val external now: unit => float = "performance.now"
39
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
+
40
63
  let dateNowIso: unit => string = %raw(`() => new Date().toISOString()`)
41
64
 
42
65
  let parseFormat = (s: string) =>
@@ -195,6 +218,7 @@ let parseArgv = (argv: array<string>): result<options, string> => {
195
218
  uiPorts: uiPorts.contents,
196
219
  listPlatforms: listPlatforms.contents,
197
220
  toolVersion,
221
+ paths: None,
198
222
  })
199
223
  }
200
224
  }
@@ -222,6 +246,42 @@ let exnMessage: exn => string = %raw(`function(e) {
222
246
  return "unknown error"
223
247
  }`)
224
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
+
225
285
  // Execute a single entry, catching exceptions and thrown values so a
226
286
  // misbehaved slice doesn't abort the rest of the suite.
227
287
  let runEntry = async (entry: Collector.entry): RunnerTypes.testResult => {
@@ -232,7 +292,8 @@ let runEntry = async (entry: Collector.entry): RunnerTypes.testResult => {
232
292
  | Collector.Skipped => status := Skip
233
293
  | _ =>
234
294
  try {
235
- let outcome = await entry.body()
295
+ let deadline = entry.timeout->Option.getOr(defaultTimeoutMs)
296
+ let outcome = await raceWithTimeout(entry.body, deadline)
236
297
  switch outcome {
237
298
  | Ok() => status := Pass
238
299
  | Error(m) => {
@@ -373,17 +434,63 @@ let emitResult = (opts: options, r: RunnerTypes.runResult) =>
373
434
  if opts.stream {
374
435
  FormatterJson.streamRunEnd(r)
375
436
  } else {
376
- FormatterJson.emit(r, ~toolVersion=opts.toolVersion)
437
+ FormatterJson.emit(r, ~toolVersion=opts.toolVersion, ~schemaVersion=?opts.schemaVersion)
377
438
  }
378
439
  | Tap => FormatterTap.emit(r)
379
440
  | Junit => FormatterJunit.emit(r)
380
441
  | VsCode => FormatterVsCode.runEnd(r.summary)
381
442
  }
382
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
+
383
481
  let runOnce = async (opts: options): int => {
384
- let paths = await Discovery.discover(opts.roots)
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
+ }
385
488
  if opts.format == Json && opts.stream {
386
- FormatterJson.streamRunStart(~toolVersion=opts.toolVersion, ~startedAt=dateNowIso())
489
+ FormatterJson.streamRunStart(
490
+ ~toolVersion=opts.toolVersion,
491
+ ~startedAt=dateNowIso(),
492
+ ~schemaVersion=?opts.schemaVersion,
493
+ )
387
494
  }
388
495
  let onFileFinished: option<RunnerTypes.fileResult => unit> = switch (
389
496
  opts.format,
@@ -445,15 +552,22 @@ let emitDomainAnalysis = async (pkgs: array<PackageScan.pkg>): unit =>
445
552
  | Some(platformModulePath) =>
446
553
  try {
447
554
  let loaded = await LocalHost.loadGraph(~platformModulePath, ~plugins)
448
- FormatterVsCode.deadCode(DomainDeadCode.analyze(~structures=loaded.structures))
449
- FormatterVsCode.graph(DomainGraph.build(~structures=loaded.structures))
450
555
  // Field schemas for command/event/read-side state (Phase 6.3): the same
451
556
  // per-plugin entry the Platform_ComponentDefinitions GraphQL query returns.
452
- FormatterVsCode.definitions(
557
+ let definitions =
453
558
  loaded.structures->Array.map(((pluginId, s)) =>
454
559
  ReventlessCore.Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId, s)
455
- ),
456
- )
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
+ }
457
571
  } catch {
458
572
  | _ => ()
459
573
  }
@@ -524,30 +638,99 @@ let startBuildWatchers = (paths: array<string>): unit => {
524
638
 
525
639
  let runWatch = async (opts: options): int => {
526
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([])
527
648
  // For the VS Code client, watch mode is the whole engine: emit the test tree
528
649
  // and package set, take over the ReScript builds, then run + re-run on change.
529
650
  if opts.format == VsCode {
530
651
  let paths = await Discovery.discover(roots)
652
+ discoveredPaths := paths
531
653
  await emitDiscovery(paths)
532
654
  startBuildWatchers(paths)
533
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.
534
700
  let runInProgress = ref(false)
535
- let rerunPending = ref(false)
536
- let run = async () => {
701
+ let pendingScope: ref<option<runScope>> = ref(None)
702
+ let requestRun = async (scope: runScope) =>
537
703
  if runInProgress.contents {
538
- rerunPending := true
704
+ pendingScope :=
705
+ Some(
706
+ switch pendingScope.contents {
707
+ | Some(s) => mergeScope(s, scope)
708
+ | None => scope
709
+ },
710
+ )
539
711
  } else {
540
712
  runInProgress := true
541
- let _ = await runOnce(opts)
542
- runInProgress := false
543
- if rerunPending.contents {
544
- rerunPending := false
545
- let _ = await runOnce(opts)
546
- ()
547
- }
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()
548
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)
549
733
  }
550
- let _ = await run()
551
734
  // A *relocation* (a module moved to another directory — a chapter rename /
552
735
  // ungroup / move-to-chapter in the extension, or a terminal `mv`) leaves
553
736
  // dependents' emitted `.res.mjs` pointing at the old path: incremental
@@ -560,35 +743,86 @@ let runWatch = async (opts: options): int => {
560
743
  // completion. Edits and adds stay on the lean incremental re-run. VS Code
561
744
  // only: a human terminal `watch` keeps the re-run-only loop (it doesn't own
562
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()
563
752
  let structuralRebuild = (path: string): unit =>
564
753
  switch PackageScan.findOwning(path) {
565
- | None => ignore(run())
754
+ | None => ignore(requestRun(RunAll))
755
+ | Some(pkg) if rebuilding->Dict.get(pkg.dir)->Option.getOr(false) => ()
566
756
  | Some(pkg) =>
757
+ rebuilding->Dict.set(pkg.dir, true)
567
758
  let t0 = Date.now()
568
759
  FormatterVsCode.buildStart(pkg.dir)
569
760
  // Surface a compile error during the rebuild (the clean/build stream is
570
761
  // classified for the error marker); the re-run is gated on completion
571
762
  // below, not on the classifier, since a one-shot `rescript build` has no
572
763
  // "Finished … compilation" terminator.
764
+ let sawError = ref(false)
573
765
  let feed = BuildClassifier.make({
574
766
  onStart: () => (),
575
767
  onOk: _ms => (),
576
- onFail: msg => FormatterVsCode.buildFail(pkg.dir, msg),
768
+ onFail: msg => {
769
+ sawError := true
770
+ FormatterVsCode.buildFail(pkg.dir, msg)
771
+ },
577
772
  })
578
773
  ProcessManager.cleanRebuild(
579
774
  pkg,
580
775
  ~onStdout=(_dir, line) => feed(line),
581
- ~onDone=() => {
582
- FormatterVsCode.buildOk(pkg.dir, Date.now() -. t0)
583
- ignore(run())
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())
584
789
  },
585
790
  )
586
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
+ }
587
819
  let _watcher = Watch.start(roots, (event, path) =>
588
820
  if opts.format == VsCode && Watch.isStructuralSource(event, path) {
589
821
  structuralRebuild(path)
822
+ } else if opts.format == VsCode && event == Watch.Add && path->String.endsWith(".res") {
823
+ ignore(refreshDiscovery())
590
824
  } else {
591
- ignore(run())
825
+ ignore(requestRun(affectedScope(path)))
592
826
  }
593
827
  )
594
828
  // Keep the process alive until cancelled. Awaiting here (rather than letting