@typeonce/ritmo-devtools 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +105 -21
  2. package/dist/Determinism.d.ts +70 -0
  3. package/dist/Determinism.d.ts.map +1 -0
  4. package/dist/Determinism.js +137 -0
  5. package/dist/Determinism.js.map +1 -0
  6. package/dist/Format.d.ts +8 -1
  7. package/dist/Format.d.ts.map +1 -1
  8. package/dist/Format.js +47 -5
  9. package/dist/Format.js.map +1 -1
  10. package/dist/Invariant.d.ts +34 -12
  11. package/dist/Invariant.d.ts.map +1 -1
  12. package/dist/Invariant.js +6 -19
  13. package/dist/Invariant.js.map +1 -1
  14. package/dist/Lint.d.ts +60 -0
  15. package/dist/Lint.d.ts.map +1 -0
  16. package/dist/Lint.js +15 -0
  17. package/dist/Lint.js.map +1 -0
  18. package/dist/Monitor.d.ts +18 -4
  19. package/dist/Monitor.d.ts.map +1 -1
  20. package/dist/Monitor.js +32 -21
  21. package/dist/Monitor.js.map +1 -1
  22. package/dist/Replay.d.ts +271 -0
  23. package/dist/Replay.d.ts.map +1 -0
  24. package/dist/Replay.js +335 -0
  25. package/dist/Replay.js.map +1 -0
  26. package/dist/ReplayNodeSink.d.ts +11 -0
  27. package/dist/ReplayNodeSink.d.ts.map +1 -0
  28. package/dist/ReplayNodeSink.js +29 -0
  29. package/dist/ReplayNodeSink.js.map +1 -0
  30. package/dist/ReplayVitePlugin.d.ts +41 -0
  31. package/dist/ReplayVitePlugin.d.ts.map +1 -0
  32. package/dist/ReplayVitePlugin.js +41 -0
  33. package/dist/ReplayVitePlugin.js.map +1 -0
  34. package/dist/ReplayViteSink.d.ts +30 -0
  35. package/dist/ReplayViteSink.d.ts.map +1 -0
  36. package/dist/ReplayViteSink.js +9 -0
  37. package/dist/ReplayViteSink.js.map +1 -0
  38. package/dist/Session.d.ts +66 -14
  39. package/dist/Session.d.ts.map +1 -1
  40. package/dist/Session.js +93 -7
  41. package/dist/Session.js.map +1 -1
  42. package/dist/cli.d.ts +3 -0
  43. package/dist/cli.d.ts.map +1 -0
  44. package/dist/cli.js +57 -0
  45. package/dist/cli.js.map +1 -0
  46. package/dist/index.d.ts +3 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/internal/monitor.d.ts +4 -2
  51. package/dist/internal/monitor.d.ts.map +1 -1
  52. package/dist/internal/monitor.js +9 -3
  53. package/dist/internal/monitor.js.map +1 -1
  54. package/dist/internal/version.d.ts +7 -0
  55. package/dist/internal/version.d.ts.map +1 -0
  56. package/dist/internal/version.js +7 -0
  57. package/dist/internal/version.js.map +1 -0
  58. package/package.json +5 -2
  59. package/src/Determinism.ts +190 -0
  60. package/src/Format.ts +50 -4
  61. package/src/Invariant.ts +38 -12
  62. package/src/Lint.ts +72 -0
  63. package/src/Monitor.ts +49 -26
  64. package/src/Replay.ts +583 -0
  65. package/src/ReplayNodeSink.ts +38 -0
  66. package/src/ReplayVitePlugin.ts +74 -0
  67. package/src/ReplayViteSink.ts +33 -0
  68. package/src/Session.ts +154 -24
  69. package/src/cli.ts +55 -0
  70. package/src/index.ts +3 -0
  71. package/src/internal/monitor.ts +12 -5
  72. package/src/internal/version.ts +6 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A Vite plugin (Node side) that receives the replays `ReplayViteSink`
3
+ * posts from the page and writes them into the game's repository as
4
+ * `.replays/<time>.json`, logging the path, the tick count, and the reason.
5
+ * Development only: it does nothing in a build.
6
+ *
7
+ * Add `.replays/` to the game's `.gitignore`.
8
+ *
9
+ * @module ReplayVitePlugin
10
+ * @docGroup devtools
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // vite.config.ts
15
+ * import * as ReplayVitePlugin from "@typeonce/ritmo-devtools/ReplayVitePlugin"
16
+ * export default defineConfig({ plugins: [ReplayVitePlugin.plugin()] })
17
+ * ```
18
+ */
19
+ import type { IncomingMessage, ServerResponse } from "node:http"
20
+
21
+ import * as Replay from "./Replay.ts"
22
+ import * as ReplayNodeSink from "./ReplayNodeSink.ts"
23
+ import { endpoint } from "./ReplayViteSink.ts"
24
+
25
+ /** The part of Vite's dev server the plugin uses. */
26
+ export interface DevServer {
27
+ readonly middlewares: {
28
+ readonly use: (path: string, handler: (request: IncomingMessage, response: ServerResponse, next: () => void) => void) => void
29
+ }
30
+ readonly config: { readonly root: string; readonly logger: { readonly info: (message: string) => void } }
31
+ }
32
+
33
+ /** A Vite plugin object. */
34
+ export interface Plugin {
35
+ readonly name: string
36
+ readonly apply: "serve"
37
+ readonly configureServer: (server: DevServer) => void
38
+ }
39
+
40
+ export const plugin = (options: { readonly directory?: string } = {}): Plugin => ({
41
+ name: "ritmo-replays",
42
+ apply: "serve",
43
+ configureServer: (server) => {
44
+ const sink = ReplayNodeSink.make({ directory: `${server.config.root}/${options.directory ?? ".replays"}` })
45
+ server.middlewares.use(endpoint, (request, response, next) => {
46
+ if (request.method !== "POST") return next()
47
+ let body = ""
48
+ request.setEncoding("utf8")
49
+ request.on("data", (chunk: string) => {
50
+ body += chunk
51
+ })
52
+ request.on("end", () => {
53
+ let data: unknown
54
+ try {
55
+ data = JSON.parse(body)
56
+ } catch {
57
+ data = undefined
58
+ }
59
+ const parsed = Replay.parse(data)
60
+ if (!parsed.ok) {
61
+ response.statusCode = 400
62
+ response.end(parsed.error._tag === "InvalidReplay" ? parsed.error.message : parsed.error._tag)
63
+ return
64
+ }
65
+ sink.save(parsed.value)
66
+ const path = sink.written.at(-1)!.slice(server.config.root.length + 1)
67
+ const ticks = parsed.value.ticks.length
68
+ server.config.logger.info(`[ritmo] replay saved: ${path} (${ticks} ticks): ${parsed.value.reason}`)
69
+ response.statusCode = 201
70
+ response.end(path)
71
+ })
72
+ })
73
+ }
74
+ })
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A `Replay.Sink` for a page served by Vite in development: each saved
3
+ * replay is posted to the dev server, where `ReplayVitePlugin` writes it
4
+ * into the game's repository (`.replays/`). The page's `fetch` is passed in.
5
+ *
6
+ * @module ReplayViteSink
7
+ * @docGroup devtools
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * if (import.meta.env.DEV) {
12
+ * const sink = ReplayViteSink.make({ fetch: (url, init) => fetch(url, init) })
13
+ * }
14
+ * ```
15
+ */
16
+ import type * as Replay from "./Replay.ts"
17
+
18
+ /** The dev server's endpoint for replays (what `ReplayVitePlugin` serves). */
19
+ export const endpoint = "/__ritmo/replays"
20
+
21
+ export interface Options {
22
+ /** The page's `fetch` (or any function with its shape for these two arguments). */
23
+ readonly fetch: (url: string, init: { readonly method: "POST"; readonly headers: Readonly<Record<string, string>>; readonly body: string }) => Promise<unknown>
24
+ /** Called when a post fails (the dev server is down, the plugin is not installed). */
25
+ readonly onError?: (error: unknown) => void
26
+ }
27
+
28
+ export const make = (options: Options): Replay.Sink => ({
29
+ save: (file) => {
30
+ void options.fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(file) })
31
+ .catch((error: unknown) => options.onError?.(error))
32
+ }
33
+ })
package/src/Session.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A debug session around a runtime made with `debug: true`.
2
+ * A debug session around a runtime made with `debug: { hostClock }`.
3
3
  *
4
4
  * The session names the schedules it drives, keeps a bounded history of
5
5
  * trace events, and answers questions about it as text and data:
@@ -8,7 +8,8 @@
8
8
  * at the first system failure, thrown defect, or violated invariant.
9
9
  * - `journal(filter)` lists what happened, one fact per line, filtered by
10
10
  * frames, entity, component, resource, event, machine, system, or kind.
11
- * - `why(entity, component)` lists the latest changes to one component.
11
+ * - `why(entity, component)` lists the latest changes to one component, and
12
+ * the systems whose declared `spawns` could have spawned the entity.
12
13
  * - `system(name)` shows one system's declared access and recent activity.
13
14
  * - `describe()`, `dump(filter)`, `streams()` render the core debug handle.
14
15
  * - `report()` summarizes per-system timing and warnings accumulated while
@@ -34,10 +35,13 @@
34
35
  * console.log(session.journal({ frames: [180, 185], entity: 1 }))
35
36
  * ```
36
37
  */
38
+ import { HostClock } from "@typeonce/ritmo"
37
39
  import type * as Debug from "@typeonce/ritmo/Debug"
40
+ import type * as Inspector from "@typeonce/ritmo/Inspector"
38
41
  import type * as Machine from "@typeonce/ritmo/Machine"
39
42
  import type * as Result from "@typeonce/ritmo/Result"
40
- import type { ExecutableScheduleDefinition } from "@typeonce/ritmo/Schedule"
43
+ import type * as Runtime from "@typeonce/ritmo/Runtime"
44
+ import type { AnyScheduleOrder, ExecutableScheduleDefinition } from "@typeonce/ritmo/Schedule"
41
45
  import type { Schema } from "@typeonce/ritmo/Schema"
42
46
 
43
47
  import * as Format from "./Format.ts"
@@ -46,17 +50,58 @@ import * as Rendered from "./Rendered.ts"
46
50
  import { nonFinitePath } from "./internal/values.ts"
47
51
 
48
52
  /**
49
- * What a session needs from a runtime: its debug handle and the dynamic
50
- * `tryTick`, which reports missing requirements as data.
53
+ * What a session needs from a runtime: its debug handle, the dynamic
54
+ * `tryTick`, which reports missing requirements as data, `inspect` for
55
+ * invariants, and what it was provided with, which invariants are checked
56
+ * against.
51
57
  */
52
- export interface Target<S extends Schema.Any, Root> {
58
+ export interface Target<
59
+ S extends Schema.Any,
60
+ Root,
61
+ Services extends Record<string, unknown> = Record<string, unknown>,
62
+ Resources extends object = object,
63
+ Machines extends Record<string, unknown> = Record<string, unknown>
64
+ > {
53
65
  readonly debug: Debug.Handle<S, Root>
54
- readonly tryTick: (schedule: ExecutableScheduleDefinition<S, any, any, any, any>) => Result.Result<void, unknown>
66
+ readonly tryTick: (schedule: ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>) => Result.Result<void, unknown>
67
+ readonly inspect: (inspector: never) => unknown
68
+ readonly services: Services
69
+ readonly resourceValues: Resources
70
+ readonly machineValues: Machines
55
71
  }
56
72
 
57
- export interface Options<S extends Schema.Any, Root, Names extends string> {
73
+ type InvariantRequirementErrors<
74
+ Invariants extends ReadonlyArray<Invariant<Invariant.CheckInspector>>,
75
+ Services extends Record<string, unknown>,
76
+ Resources extends object,
77
+ Machines extends Record<string, unknown>
78
+ > = Runtime.ValidateInspector<Invariants[number]["inspector"], Services, Resources, Machines> extends {
79
+ readonly __fixRuntimeRequirements__: infer Errors
80
+ }
81
+ ? Errors
82
+ : never
83
+
84
+ /**
85
+ * Rejects invariants whose inspectors read a resource, service, or machine
86
+ * the runtime does not provide, naming each one.
87
+ */
88
+ export type ValidateInvariants<
89
+ Invariants extends ReadonlyArray<Invariant<Invariant.CheckInspector>>,
90
+ Services extends Record<string, unknown>,
91
+ Resources extends object,
92
+ Machines extends Record<string, unknown>
93
+ > = [InvariantRequirementErrors<Invariants, Services, Resources, Machines>] extends [never]
94
+ ? unknown
95
+ : { readonly __fixInvariantRequirements__: InvariantRequirementErrors<Invariants, Services, Resources, Machines> }
96
+
97
+ export interface Options<
98
+ S extends Schema.Any,
99
+ Root,
100
+ Names extends string,
101
+ Invariants extends ReadonlyArray<Invariant<Invariant.CheckInspector>> = readonly []
102
+ > {
58
103
  /** The schedules `run` can drive, by name. Also names them in traces. */
59
- readonly schedules: Readonly<Record<Names, ExecutableScheduleDefinition<S, any, Root, any, any>>>
104
+ readonly schedules: Readonly<Record<Names, ExecutableScheduleDefinition<S, any, Root, any, any, AnyScheduleOrder>>>
60
105
  /**
61
106
  * More schedules to include in `describe()`, its lints, and the access
62
107
  * maps, without making them runnable: typically a render schedule that
@@ -64,11 +109,22 @@ export interface Options<S extends Schema.Any, Root, Names extends string> {
64
109
  * and components only those schedules read are reported as never read.
65
110
  * Their services show as not provided, which is not a lint.
66
111
  */
67
- readonly describe?: Readonly<Record<string, ExecutableScheduleDefinition<S, any, Root, any, any>>>
68
- /** Checked after every frame of every run. */
69
- readonly invariants?: ReadonlyArray<Invariant>
112
+ readonly describe?: Readonly<Record<string, ExecutableScheduleDefinition<S, any, Root, any, any, AnyScheduleOrder>>>
113
+ /** Events the host publishes with `runtime.send`: written, for the lints (see `Debug.Handle.declareHostEvents`). */
114
+ readonly hostEvents?: ReadonlyArray<Schema.EventDescriptor<S>>
115
+ /**
116
+ * Checked after every frame of every run, in order; the first violation
117
+ * stops the run. Built with `Invariant.fromInspector(Game.Inspector(...))`.
118
+ */
119
+ readonly invariants?: Invariants
70
120
  /** Frames of trace history kept for `journal` and `why`. Defaults to 600. */
71
121
  readonly history?: number
122
+ /**
123
+ * Times runs (`RunResult.ms`). `HostClock.test()` makes every timing 0,
124
+ * so text output is identical across runs (give the runtime's
125
+ * `debug: { hostClock }` a test clock too).
126
+ */
127
+ readonly hostClock: HostClock.HostClock
72
128
  /** Value formatting for all text output. */
73
129
  readonly format?: Format.ValueOptions
74
130
  }
@@ -86,8 +142,24 @@ export type Stop =
86
142
  | { readonly reason: "until"; readonly frame: number }
87
143
  | { readonly reason: "failure"; readonly frame: number; readonly system: string; readonly error: unknown }
88
144
  | { readonly reason: "missingRequirements"; readonly frame: number; readonly requirements: unknown }
145
+ /** A `Game.System.thisTick` read whose writer runs after it, or that has none; nothing ran. */
146
+ | { readonly reason: "tickOrder"; readonly frame: number; readonly issues: ReadonlyArray<string> }
89
147
  | { readonly reason: "defect"; readonly frame: number; readonly error: unknown }
90
148
  | { readonly reason: "invariant"; readonly frame: number; readonly invariant: string; readonly message: string }
149
+ /**
150
+ * A failed tick result nobody read (for example a setup tick the game ran
151
+ * before this run): `frame` is the failed tick's, `skipped` the systems
152
+ * that did not run then.
153
+ */
154
+ | {
155
+ readonly reason: "ignored-failure"
156
+ readonly frame: number
157
+ readonly schedule: string
158
+ readonly system: string | undefined
159
+ readonly error: unknown
160
+ readonly skipped: ReadonlyArray<string>
161
+ readonly when: "next-tick" | "session-end"
162
+ }
91
163
 
92
164
  export interface RunResult {
93
165
  readonly schedule: string
@@ -165,6 +237,7 @@ export interface Warning {
165
237
  | "system-failed"
166
238
  | "non-finite-value"
167
239
  | "population-growing"
240
+ | "ignored-failure"
168
241
  readonly message: string
169
242
  readonly count: number
170
243
  readonly firstFrame: number
@@ -237,22 +310,32 @@ const growsSteadily = (samples: ReadonlyArray<number>): boolean => {
237
310
  return true
238
311
  }
239
312
 
240
- const describeError = (error: unknown): { readonly kind?: unknown; readonly system?: unknown; readonly error?: unknown; readonly requirements?: unknown } =>
313
+ const describeError = (error: unknown): { readonly kind?: unknown; readonly system?: unknown; readonly error?: unknown; readonly requirements?: unknown; readonly issues?: ReadonlyArray<{ readonly message: string }> } =>
241
314
  typeof error === "object" && error !== null ? error : {}
242
315
 
243
- export const make = <S extends Schema.Any, Root, const Names extends string>(
244
- runtime: Target<S, Root>,
245
- options: Options<S, Root, Names>
316
+ export const make = <
317
+ S extends Schema.Any,
318
+ Root,
319
+ Services extends Record<string, unknown>,
320
+ Resources extends object,
321
+ Machines extends Record<string, unknown>,
322
+ const Names extends string,
323
+ const Invariants extends ReadonlyArray<Invariant<Inspector.InspectorDefinition<any, string | undefined, Root, any, any>>> = readonly []
324
+ >(
325
+ runtime: Target<S, Root, Services, Resources, Machines>,
326
+ options: Options<S, Root, Names, Invariants> & ValidateInvariants<Invariants, Services, Resources, Machines>
246
327
  ): Session<S, Names> => {
328
+ const inspect = runtime.inspect as (inspector: Inspector.Inspector.Any) => string | undefined
247
329
  const debug = runtime.debug
248
330
  const capacity = options.history ?? 600
249
331
  const format = options.format ?? {}
250
- const schedules = options.schedules as Readonly<Record<string, ExecutableScheduleDefinition<S, any, Root, any, any>>>
332
+ const schedules = options.schedules as Readonly<Record<string, ExecutableScheduleDefinition<S, any, Root, any, any, AnyScheduleOrder>>>
251
333
  const described = options.describe ?? {}
252
334
  for (const name of Object.keys(described)) {
253
335
  if (name in schedules) throw new Error(`Session: "${name}" is both a runnable and a described-only schedule`)
254
336
  }
255
337
  debug.nameSchedules({ ...described, ...schedules })
338
+ debug.declareHostEvents(options.hostEvents ?? [])
256
339
 
257
340
  const history: Array<FrameRecord> = []
258
341
  let current: FrameRecord = { frame: debug.frame(), events: [] }
@@ -387,14 +470,25 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
387
470
  }
388
471
  }
389
472
  break
473
+ case "failure.ignored":
474
+ ignored = event
475
+ warn("ignored-failure", `ignored:${event.frame}`, Format.ignoredFailure(event, format), event.frame)
476
+ break
390
477
  case "transition":
391
478
  if (event.outcome !== "failed") pendingStates.delete(event.machine)
392
479
  if (event.outcome === "failed" || event.outcome === "enterFailed") {
393
- warn("transition-failed", `transition:${event.machine}:${String(event.from)}:${String(event.to)}`, `transition ${event.machine} ${String(event.from)} -> ${String(event.to)} ${event.outcome}`, event.frame)
480
+ warn("transition-failed", `transition:${event.machine}:${String(event.from)}:${String(event.to)}`, `transition ${Format.transition(event)} ${event.outcome}`, event.frame)
394
481
  }
395
482
  break
396
483
  }
397
484
  }
485
+ /** The last `failure.ignored` event seen, for `run` to stop on. */
486
+ let ignored: Debug.FailureIgnoredEvent | undefined
487
+ const takeIgnored = (): Debug.FailureIgnoredEvent | undefined => {
488
+ const found = ignored
489
+ ignored = undefined
490
+ return found
491
+ }
398
492
  const stopObserving = debug.observe(record)
399
493
 
400
494
  const historyRange = (): readonly [number, number] | undefined => {
@@ -405,7 +499,7 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
405
499
  const run = (name: Names, runOptions: RunOptions = {}): Rendered.Rendered<RunResult> => {
406
500
  const schedule = schedules[name]!
407
501
  const frames = runOptions.frames ?? 1
408
- const started = performance.now()
502
+ const started = options.hostClock.now()
409
503
  const warningsBefore = new Map([...warnings].map(([key, warning]) => [key, warning.count]))
410
504
  const initial = debug.population()
411
505
  const entityCounts: Array<number> = []
@@ -418,6 +512,7 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
418
512
  let first: number | undefined
419
513
  let last: number | undefined
420
514
  let stop: Stop = { reason: "completed" }
515
+ takeIgnored()
421
516
  for (let index = 0; index < frames; index++) {
422
517
  let result: Result.Result<void, unknown>
423
518
  try {
@@ -433,17 +528,25 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
433
528
  first ??= frame
434
529
  last = frame
435
530
  sample()
531
+ const unread = takeIgnored()
532
+ if (unread !== undefined) {
533
+ const { type: _type, ...found } = unread
534
+ stop = { reason: "ignored-failure", ...found }
535
+ break
536
+ }
436
537
  if (!result.ok) {
437
538
  const error = describeError(result.error)
438
539
  stop = error.kind === "MissingRuntimeRequirements"
439
540
  ? { reason: "missingRequirements", frame, requirements: error.requirements }
440
- : { reason: "failure", frame, system: typeof error.system === "string" ? error.system : "(unknown)", error: error.error }
541
+ : error.kind === "TickOrderViolation"
542
+ ? { reason: "tickOrder", frame, issues: (error.issues ?? []).map((issue) => issue.message) }
543
+ : { reason: "failure", frame, system: typeof error.system === "string" ? error.system : "(unknown)", error: error.error }
441
544
  break
442
545
  }
443
546
  const violation = (options.invariants ?? []).reduce<{ readonly invariant: string; readonly message: string } | undefined>(
444
547
  (found, invariant) => {
445
548
  if (found) return found
446
- const message = invariant.check()
549
+ const message = inspect(invariant.inspector)
447
550
  return message === undefined ? undefined : { invariant: invariant.name, message }
448
551
  },
449
552
  undefined
@@ -488,7 +591,7 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
488
591
  frames: first === undefined || last === undefined ? 0 : last - first + 1,
489
592
  range: first === undefined || last === undefined ? undefined : [first, last],
490
593
  stop,
491
- ms: performance.now() - started,
594
+ ms: HostClock.elapsed(started, options.hostClock.now()),
492
595
  lintWarnings: lintWarnings.length,
493
596
  warnings: raised,
494
597
  entities: range(initial.entities, entityCounts),
@@ -554,12 +657,30 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
554
657
  : component.name in currentValue.components
555
658
  ? `e${entity} ${component.name} is now ${Format.value(currentValue.components[component.name], format)}`
556
659
  : `e${entity} has no ${component.name} now`
557
- const header = lines.length === 0
660
+ const changes = lines.length === 0
558
661
  ? `${now}; no change to it in ${rangeText()}`
559
662
  : `${now}; last ${lines.length} changes (oldest first):`
663
+ const spawners = spawnersOf(currentValue === undefined ? [component.name] : Object.keys(currentValue.components), component.name)
664
+ const header = `${changes}\n${spawners.length === 0
665
+ ? `no described system declares spawns of ${component.name}`
666
+ : `could have spawned it: ${spawners.join(", ")}`}`
560
667
  return Rendered.make(lines, renderLines(header, lines))
561
668
  }
562
669
 
670
+ /**
671
+ * The described systems whose declared `spawns` include `component` and
672
+ * cover every component the entity has that no described system may
673
+ * insert (so it can only have come from the spawn).
674
+ */
675
+ const spawnersOf = (has: ReadonlyArray<string>, component: string): Array<string> => {
676
+ const systems = debug.describe().systems
677
+ const insertable = new Set(systems.flatMap((system) => system.commands.inserts))
678
+ const fromSpawn = has.filter((name) => !insertable.has(name))
679
+ return systems
680
+ .filter((system) => system.commands.spawns.includes(component) && fromSpawn.every((name) => system.commands.spawns.includes(name)))
681
+ .map((system) => system.name)
682
+ }
683
+
563
684
  const whyResource = (resource: Schema.ResourceDescriptor<S>, whyOptions: { readonly limit?: number } = {}) => {
564
685
  const lines = collectLines({ resource, kinds: ["resource"], limit: whyOptions.limit ?? 5 })
565
686
  const resources = debug.dump({ limit: 0 }).resources
@@ -597,6 +718,8 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
597
718
  }
598
719
 
599
720
  const report = () => {
721
+ // The report is where a session ends: a failure still unread shows here.
722
+ debug.checkIgnoredFailures()
600
723
  const systems = [...stats.keys()].map((name) => statsFor(name)!).sort((left, right) => right.totalMs - left.totalMs)
601
724
  const data: Report = {
602
725
  frames: debug.frame(),
@@ -644,7 +767,10 @@ export const make = <S extends Schema.Any, Root, const Names extends string>(
644
767
  },
645
768
  report,
646
769
  frame: () => debug.frame(),
647
- close: stopObserving
770
+ close: () => {
771
+ debug.checkIgnoredFailures()
772
+ stopObserving()
773
+ }
648
774
  }
649
775
  }
650
776
 
@@ -665,5 +791,9 @@ const renderRun = (result: RunResult, format: Format.ValueOptions): string => {
665
791
  return `${head}, DEFECT at f${stop.frame}: ${stop.error instanceof Error ? stop.error.stack ?? stop.error.message : Format.value(stop.error, format)}`
666
792
  case "invariant":
667
793
  return `${head}, INVARIANT "${stop.invariant}" violated at f${stop.frame}: ${stop.message}`
794
+ case "tickOrder":
795
+ return `${head}, NOT RUN after f${stop.frame}: ${stop.issues.join("; ")}`
796
+ case "ignored-failure":
797
+ return `${head}, STOPPED: ${Format.ignoredFailure({ type: "failure.ignored", ...stop }, format)}, at f${stop.frame}`
668
798
  }
669
799
  }
package/src/cli.ts ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `ritmo-devtools`: devtools from the command line.
4
+ *
5
+ * ritmo-devtools lint <entry>
6
+ *
7
+ * Imports `<entry>` (a module exporting `lintTarget`, see the `Lint` module),
8
+ * prints every lint of its schedules with its fix, and exits with 1 when one
9
+ * is a warning. Info-level lints are printed and do not fail.
10
+ *
11
+ * @module cli
12
+ * @docGroup devtools
13
+ */
14
+ import { resolve } from "node:path"
15
+ import { pathToFileURL } from "node:url"
16
+
17
+ import * as Lint from "./Lint.ts"
18
+
19
+ const usage = "usage: ritmo-devtools lint <entry>\n <entry> exports lintTarget: () => { runtime, schedules, describe?, hostEvents? } (see the Lint module)"
20
+
21
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null
22
+
23
+ /** Checks at runtime what the type of `lintTarget`'s result promises, so a wrong entry fails with a message. */
24
+ const isTarget = (value: unknown): value is Lint.Target<any, any> =>
25
+ isRecord(value)
26
+ && isRecord(value["runtime"])
27
+ && isRecord(value["runtime"]["debug"])
28
+ && typeof value["runtime"]["debug"]["describe"] === "function"
29
+ && isRecord(value["schedules"])
30
+ && (value["describe"] === undefined || isRecord(value["describe"]))
31
+ && (value["hostEvents"] === undefined || Array.isArray(value["hostEvents"]))
32
+
33
+ const main = async (args: ReadonlyArray<string>): Promise<number> => {
34
+ const [command, entry] = args
35
+ if (command !== "lint" || entry === undefined) {
36
+ console.error(usage)
37
+ return 2
38
+ }
39
+ const loaded: unknown = await import(pathToFileURL(resolve(entry)).href)
40
+ const make = isRecord(loaded) ? loaded["lintTarget"] : undefined
41
+ if (typeof make !== "function") {
42
+ console.error(`${entry} must export lintTarget: a function returning { runtime, schedules, describe? } (see the Lint module)`)
43
+ return 2
44
+ }
45
+ const target: unknown = await make()
46
+ if (!isTarget(target)) {
47
+ console.error(`lintTarget in ${entry} must return { runtime, schedules, describe? } with a runtime made with debug: { hostClock }`)
48
+ return 2
49
+ }
50
+ const report = Lint.run(target)
51
+ console.log(report.text)
52
+ return report.data.ok ? 0 : 1
53
+ }
54
+
55
+ process.exitCode = await main(process.argv.slice(2))
package/src/index.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  /** Debug sessions, trace history, text reports, and a live browser monitor for ritmo runtimes. */
2
2
  export * as Format from "./Format.ts"
3
+ export * as Determinism from "./Determinism.ts"
3
4
  export * as Invariant from "./Invariant.ts"
5
+ export * as Lint from "./Lint.ts"
4
6
  export * as Monitor from "./Monitor.ts"
7
+ export * as Replay from "./Replay.ts"
5
8
  export * as Rendered from "./Rendered.ts"
6
9
  export * as Session from "./Session.ts"
7
10
 
@@ -45,7 +45,7 @@ export interface Sample {
45
45
  }
46
46
 
47
47
  export interface Alert {
48
- readonly code: "system-failed" | "non-finite-value" | "discarded-messages"
48
+ readonly code: "system-failed" | "non-finite-value" | "discarded-messages" | "ignored-failure"
49
49
  readonly message: string
50
50
  readonly count: number
51
51
  readonly firstFrame: number
@@ -100,16 +100,20 @@ export class Collector {
100
100
  private readonly maxLines: number
101
101
  private readonly population: () => Debug.Population
102
102
 
103
- constructor(population: () => Debug.Population, now: number, options: CollectorOptions = {}) {
103
+ private readonly clock: () => number
104
+
105
+ /** `now` reads the host clock (milliseconds); the collector times its own work with it. */
106
+ constructor(population: () => Debug.Population, now: () => number, options: CollectorOptions = {}) {
104
107
  this.population = population
105
- this.windowStart = now
108
+ this.clock = now
109
+ this.windowStart = now()
106
110
  this.maxHistory = options.history ?? 60
107
111
  this.maxLines = options.entityLines ?? 40
108
112
  }
109
113
 
110
114
  /** Folds one trace event. Pass it to `debug.observe`. */
111
115
  readonly observe = (event: Debug.TraceEvent): void => {
112
- const started = performance.now()
116
+ const started = this.clock()
113
117
  switch (event.type) {
114
118
  case "system":
115
119
  this.onSystem(event)
@@ -130,6 +134,9 @@ export class Collector {
130
134
  case "deferred":
131
135
  this.onDeferred(event)
132
136
  break
137
+ case "failure.ignored":
138
+ this.alert("ignored-failure", `ignored:${event.frame}`, Format.ignoredFailure(event), event.frame)
139
+ break
133
140
  case "schedule.end":
134
141
  if (!event.schedule.includes(" > ")) {
135
142
  const schedule = this.scheduleOf(event.schedule)
@@ -139,7 +146,7 @@ export class Collector {
139
146
  }
140
147
  break
141
148
  }
142
- this.overhead += performance.now() - started
149
+ this.overhead += Math.max(0, this.clock() - started)
143
150
  }
144
151
 
145
152
  private windowOf(system: string): MutableWindow {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The package version, written into replay files. Kept equal to
3
+ * `package.json` by `scripts/sync-version.ts` (run by \`pnpm version-packages\`)
4
+ * and checked by a test.
5
+ */
6
+ export const libraryVersion = "0.8.0"