@mantaq/inspect 0.3.0 → 0.5.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.
package/README.md CHANGED
@@ -1,16 +1,12 @@
1
1
  # @mantaq/inspect
2
2
 
3
- Turns your Mantaq machine into a picture you can walk: one command, no config.
3
+ Turns your Mantaq machine into a picture you can walk: add the Vite plugin, no other config.
4
4
 
5
- ```sh
6
- mantaq-inspect src/cart.ts # writes mantaq-viz.html and opens it
7
- ```
8
-
9
- It finds every exported actor, or function that builds one, from the file's types. It reads handler explanations and sample payloads from the source, fuzzes each machine that runs on a `VirtualClock`, and writes one self-contained HTML page that works offline: commit it, attach it to a PR. The viewer is [`@mantaq/viz`](../viz/README.md).
5
+ It finds every exported actor, or function that builds one, from the file's types. It reads handler explanations and sample payloads from the source, fuzzes each machine that runs on a `VirtualClock`, and shows them in a live page on your Vite dev server. The viewer is [`@mantaq/viz`](../viz/README.md).
10
6
 
11
- ## Three ways in
7
+ ## Ways in
12
8
 
13
- **CLI**: `mantaq-inspect <file.ts>... [--out FILE] [--json] [--live] [--no-fuzz] [--iterations N] [--config FILE] [--mode MODE] [--no-open] [--check]`. `--config` loads the files with that Vite config (env, aliases, plugins) and `--mode` picks its mode; without `--config` no project config is read. `--check` exits 1 if a warning was printed or fuzzing found a handler error (printed as `error:`), so CI can gate on design errors. `--json` writes the viz document instead of the page. `--live` bundles your machine files into the page with Vite, so it is still one offline HTML file but its steps run the real actors (same rules as the Vite plugin's live page); it cannot be combined with `--json`. Seed: `MANTAQ_SEED` (default from `@mantaq/pbt`). Same seed → same output.
9
+ **Gate in tests**: see the docs page "Design gate in vitest" for `expectNoDesignErrors`, `expectReached` and `expectAllStatesReached`.
14
10
 
15
11
  **Vite plugin**: a live page on your dev server that redraws, keeping your walk, when you save.
16
12
 
@@ -26,6 +22,8 @@ export default defineConfig({
26
22
 
27
23
  The live page runs the real actor, so it needs an exported factory on a `VirtualClock`; instance or `RealClock` machines fall back to the static walk.
28
24
 
25
+ `vite build` with the plugin also writes the page as a static site: `index.html`, `viz.json` and a script under `base` (set `base: "/"` to put them at the output root). Machines load through your Vite config, and the fuzz runs at build time.
26
+
29
27
  Options: `base` (default `/__mantaq/`), `fuzz` (`false`, or `{ seed, iterations }`; default 500 iterations so a save redraws fast).
30
28
 
31
29
  **Library**: one call per machine.
@@ -38,19 +36,21 @@ const { machine, warnings } = inspect({
38
36
  id: "cart",
39
37
  create: () => createCart(), // a fresh actor every call
40
38
  source: { file: "/abs/src/cart.ts", root: "/abs" }, // optional: explanations + payloads from types
41
- tests: [[{ kind: "event", event: "addItem", payload: { sku: "A1" } }]], // optional: "example tests" coverage
39
+ tests: [
40
+ { name: "add an item", steps: [{ kind: "event", event: "addItem", payload: { sku: "A1" } }] },
41
+ ], // optional: named runs for "example tests" coverage
42
42
  });
43
43
  const json = serializeViz(vizDocument([machine]));
44
44
  ```
45
45
 
46
- `inspectFile(file)` / `inspectModule(file, { load })` do the discovery the CLI does. The building blocks (`toVizMachine`, `annotateMachine`, `fuzz`, `fuzzedMachine`, `withTestCoverage`, `guidance`) stay exported for custom pipelines.
46
+ `inspectFile(file)` / `inspectModule(file, { load })` do the discovery the Vite plugin does, and read recorded runs from `__runs__/*.json` next to the machine file (`{ [machineId]: { name, end, steps }[] }`). A run is replayed on a fresh actor; internal events the replay already produces (timers, synchronous emits) are dropped, and a run that replays to a different state than `end` is dropped with a warning. The building blocks (`toVizMachine`, `annotateMachine`, `fuzz`, `fuzzedMachine`, `withTestCoverage`, `guidance`) stay exported for custom pipelines.
47
47
 
48
48
  ## What it needs from your file
49
49
 
50
50
  - **Exports.** An actor, or a function that returns one (or `{ actor }`). Functions are called with no arguments, with a `VirtualClock` when the first parameter is a clock, or with `{ clock }` when it is an options object with a `clock`. Functions with other required parameters are skipped. Discovery reads types only; nothing is called to find out.
51
51
  - **A fresh actor per call**, to fuzz. A file that only exports one actor instance is drawn, not fuzzed. When a file has factories, its actor instances are skipped.
52
52
  - **Exported factories.** Export a factory that takes ports and a clock, not only a built instance. See [Machines in an application](https://anderscan.github.io/mantaq/guides/machines-in-an-app/).
53
- - **A factory, for `--live`.** The live page recreates the actor on every step, so it needs an exported factory; a file that only exports an instance stays a static walk, with the usual warning.
53
+ - **A factory, for the live page.** The page recreates the actor on every step, so it needs an exported factory; a file that only exports an instance stays a static walk, with the usual warning.
54
54
  - **A `VirtualClock`**, to fuzz. An actor on the real clock is drawn, with a warning.
55
55
 
56
56
  Every part that cannot run is skipped with a warning, never guessed.
@@ -59,7 +59,8 @@ Every part that cannot run is skipped with a warning, never guessed.
59
59
 
60
60
  The viewer's "Coverage from" picker dims elements with zero hits. What dimmed means depends on the source:
61
61
 
62
- - **example tests** (`tested`): no test exercises this. Collected by replaying the event sequences you pass as `tests`, each on a fresh actor.
62
+ - **examples + fuzz** (`reached`): neither an example nor fuzzing reached this. The default when both sources are present, and the same merge `expectReached` uses. Fix a miss by adding an example to `tests`.
63
+ - **example tests** (`tested`): no test exercises this. Collected by replaying the named runs you pass as `tests`, each on a fresh actor.
63
64
  - **fuzz runs** (`coverage`): no fuzz run produced this. Fuzzing reaches almost everything, so little is dimmed. What does stay dimmed is usually a **declared but unreached edge**: the handler lists the target in `to`, but its guards may never allow it. Saga's `compensatingRefund -REFUND_DONE-> failed` is an example. The edge panel calls these out. Fuzzing is not proof, so check the handler.
64
65
 
65
66
  ## How each tool works
@@ -74,6 +75,18 @@ The viewer's "Coverage from" picker dims elements with zero hits. What dimmed me
74
75
 
75
76
  **Guidance** (`guidance`, Phase 5): literal seeding plus a branch-distance guide built from AST comparisons on payload and context. It helps when handlers compare payload fields against literals far from the samples, or count in context toward a threshold. See `docs/atlas/phase5-comparison.md`.
76
77
 
78
+ ## Record runs from your tests
79
+
80
+ In a vitest file, wrap the factory once and your tests become runs:
81
+
82
+ ```ts
83
+ import { recordRuns } from "@mantaq/inspect/record";
84
+
85
+ const make = recordRuns(createInviteLinkActor);
86
+ ```
87
+
88
+ Every passing test that builds an actor with `make` writes its events and clock moves to `__runs__/<test file>.json` next to the test, keyed by machine id. `inspectModule` and the Vite plugin read those files, so each test shows up in the live page's Runs list and counts as an example run. Run the whole test file to refresh it. With `CI` set, a run that differs from the committed file fails the test, like a stale snapshot. The factory may be called in the test or in `beforeEach`; a retried test is recorded once. Runs with a payload that does not survive JSON (functions, `bigint`, `Map`, class instances) are skipped with a warning. `vitest` is an optional peer dependency.
89
+
77
90
  ## What it cannot show
78
91
 
79
92
  - **Guards** are not evaluated. A declared edge says "the handler may return this", not "some input reaches it". Fuzz coverage and the AST's branch conditions are the evidence; neither is proof.
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as eventCatalog, i as Trace, n as Observation, o as execute, r as RunError, t as FuzzTarget } from "./run-BLt0mwXe.mjs";
2
- import { a as ModuleOptions, c as machineId, i as InspectedModule, l as vizDocument, n as InspectTarget, o as inspect, r as Inspected, s as inspectModule, t as FuzzSettings, u as AnalysisSource } from "./inspect-DvQtAcwv.mjs";
2
+ import { a as ModuleOptions, c as machineId, i as InspectedModule, l as vizDocument, n as InspectTarget, o as inspect, r as Inspected, s as inspectModule, t as FuzzSettings, u as AnalysisSource } from "./inspect-vSdt2qtL.mjs";
3
3
  import { VizComparison, VizDocument, VizExplanation, VizFuzzError, VizJson, VizMachine, VizStep } from "@mantaq/viz";
4
4
  import { History } from "@mantaq/traversal";
5
5
  import { Either } from "@mantaq/utils";
@@ -37,6 +37,8 @@ interface FuzzOptions {
37
37
  * the run in the corpus as a stepping stone even without new coverage.
38
38
  */
39
39
  guide?: (trace: Trace) => boolean;
40
+ /** Known-good sequences (e.g. example tests) the corpus starts from, before any mutation. */
41
+ initial?: ReadonlyArray<ReadonlyArray<VizStep>>;
40
42
  }
41
43
  interface CorpusEntry {
42
44
  steps: VizStep[];
@@ -149,8 +151,39 @@ interface LoadOptions {
149
151
  /** Load `file` with Vite (TS, tsconfig paths, extensionless imports) and inspect its machines. */
150
152
  export declare function inspectFile(file: string, options?: FileOptions, load?: LoadOptions): Promise<LoadedFile>;
151
153
  /**
152
- * A self-contained HTML page for `document`: open it from disk, attach it, commit it.
153
- * With `live` (a built script that drives the machines), that script replaces the static viewer.
154
+ * States no edge path leads to from the initial state. Empty when an undetermined
155
+ * edge hides the answer. Region states are never reported: the graph does not
156
+ * record where a region starts.
154
157
  */
155
- export declare function htmlPage(document: VizDocument, title: string, live?: string): Promise<string>;
156
- export { type AnalysisSource, type CorpusEntry, type FileOptions, type FuzzOptions, type FuzzResult, type FuzzSettings, type FuzzTarget, type HandlerSite, type InspectTarget, type Inspected, type InspectedModule, type LiteralPool, type MachineExport, type ModuleAnalysis, type ModuleOptions, type Observation, type RunError, type Trace, type VizInput, eventCatalog, execute, inspect, inspectModule, machineId, vizDocument };
158
+ export declare function unreachableStates(machine: VizMachine): string[];
159
+ /** Design errors of an inspected machine, one line each. Unreached edges and routing warnings are not errors. */
160
+ export declare function designErrors(machine: VizMachine): string[];
161
+ /**
162
+ * Call from a test: throws when fuzzing finds a handler or effect that throws,
163
+ * an effect sends an event its state does not handle, or a declared state has
164
+ * no path from the initial state. Runs are seeded, so the result is stable.
165
+ */
166
+ export declare function expectNoDesignErrors(target: InspectTarget): void;
167
+ interface ReachTransition {
168
+ from: string;
169
+ event: string;
170
+ /** Omit to accept any target. */
171
+ to?: string;
172
+ }
173
+ interface ReachExpectation {
174
+ states?: ReadonlyArray<string>;
175
+ transitions?: ReadonlyArray<ReachTransition>;
176
+ }
177
+ /** Ids of the states a fuzz run or the supplied test sequences visited. */
178
+ export declare function reachedStates(machine: VizMachine): string[];
179
+ /** What the expectation asks for and the machine did not reach, one line each. */
180
+ export declare function missedReach(machine: VizMachine, expected: ReachExpectation): string[];
181
+ /**
182
+ * Call from a test: throws unless fuzzing or the target's `tests` visited every
183
+ * listed state and took every listed transition. Reaching is proof; a miss only
184
+ * says nothing got there yet.
185
+ */
186
+ export declare function expectReached(target: InspectTarget, expected: ReachExpectation): void;
187
+ /** Call from a test: throws unless fuzzing or the target's `tests` visited every declared state. */
188
+ export declare function expectAllStatesReached(target: InspectTarget): void;
189
+ export { type AnalysisSource, type CorpusEntry, type FileOptions, type FuzzOptions, type FuzzResult, type FuzzSettings, type FuzzTarget, type HandlerSite, type InspectTarget, type Inspected, type InspectedModule, type LiteralPool, type MachineExport, type ModuleAnalysis, type ModuleOptions, type Observation, type ReachExpectation, type ReachTransition, type RunError, type Trace, type VizInput, eventCatalog, execute, inspect, inspectModule, machineId, vizDocument };
package/dist/index.mjs CHANGED
@@ -1,4 +1,99 @@
1
- import { C as toViz, S as shrink, _ as guidance, b as withTestCoverage, c as inspect, d as vizDocument, f as analyzeModule, g as branchDistanceGuide, h as branchDistance, l as inspectModule, m as annotateMachine, p as analyzeSource, s as inspectFile, u as machineId, v as literalPool, w as toVizMachine, x as fuzz, y as fuzzedMachine } from "./vite-BBW0CmYr.mjs";
1
+ import { _ as toViz, a as vizDocument, c as annotateMachine, d as guidance, f as literalPool, g as shrink, h as fuzz, l as branchDistance, m as withTestCoverage, n as inspectModule, o as analyzeModule, p as fuzzedMachine, r as machineId, s as analyzeSource, t as inspect, u as branchDistanceGuide, v as toVizMachine } from "./inspect-B7ZQCNM2.mjs";
2
2
  import { i as execute, r as eventCatalog } from "./run-B5huYj_r.mjs";
3
- import { t as htmlPage } from "./page-CK7-IsLJ.mjs";
4
- export { analyzeModule, analyzeSource, annotateMachine, branchDistance, branchDistanceGuide, eventCatalog, execute, fuzz, fuzzedMachine, guidance, htmlPage, inspect, inspectFile, inspectModule, literalPool, machineId, shrink, toViz, toVizMachine, vizDocument, withTestCoverage };
3
+ import { r as inspectFile } from "./load-CWubcSZU.mjs";
4
+ import { coveredByAny, unhandledSends } from "@mantaq/viz";
5
+ // src/gate.ts
6
+ /**
7
+ * States no edge path leads to from the initial state. Empty when an undetermined
8
+ * edge hides the answer. Region states are never reported: the graph does not
9
+ * record where a region starts.
10
+ */
11
+ function unreachableStates(machine) {
12
+ const reached = new Set(machine.states.filter((state) => state.initial || state.region !== null).map((state) => state.id));
13
+ const queue = [...reached];
14
+ let undetermined = false;
15
+ for (let next = queue.shift(); next !== void 0; next = queue.shift()) {
16
+ const from = next;
17
+ for (const edge of machine.edges) {
18
+ if (edge.kind === "effect" || edge.from !== from && !coveredByAny(edge.from, from)) continue;
19
+ if (edge.to === null) {
20
+ undetermined = true;
21
+ continue;
22
+ }
23
+ if (reached.has(edge.to)) continue;
24
+ reached.add(edge.to);
25
+ queue.push(edge.to);
26
+ }
27
+ }
28
+ if (undetermined) return [];
29
+ return machine.states.map((state) => state.id).filter((id) => !reached.has(id));
30
+ }
31
+ /** Design errors of an inspected machine, one line each. Unreached edges and routing warnings are not errors. */
32
+ function designErrors(machine) {
33
+ const thrown = (machine.fuzz?.errors ?? []).map((error) => `${error.event} in ${error.from} throws: ${error.message}`);
34
+ const sends = machine.states.flatMap((state) => unhandledSends(machine, state.id).map(({ effect, event }) => `effect ${effect} in ${state.id} sends ${event}, but ${state.id} has no handler for it`));
35
+ const orphans = unreachableStates(machine).map((id) => `state ${id} is declared but no path leads to it from the initial state`);
36
+ return [
37
+ ...thrown,
38
+ ...sends,
39
+ ...orphans
40
+ ];
41
+ }
42
+ /**
43
+ * Call from a test: throws when fuzzing finds a handler or effect that throws,
44
+ * an effect sends an event its state does not handle, or a declared state has
45
+ * no path from the initial state. Runs are seeded, so the result is stable.
46
+ */
47
+ function expectNoDesignErrors(target) {
48
+ const { machine } = inspect(target);
49
+ const errors = designErrors(machine);
50
+ if (errors.length === 0) return;
51
+ throw new Error(`${machine.id} has ${errors.length} design error${errors.length === 1 ? "" : "s"}:\n${errors.map((each) => ` - ${each}`).join("\n")}`);
52
+ }
53
+ // src/reach.ts
54
+ function hitCount(coverage) {
55
+ return coverage?.hits ?? 0;
56
+ }
57
+ /** Ids of the states a fuzz run or the supplied test sequences visited. */
58
+ function reachedStates(machine) {
59
+ return machine.states.filter((state) => hitCount(state.coverage) + hitCount(state.tested) > 0).map((state) => state.id);
60
+ }
61
+ function isTaken(edge) {
62
+ return hitCount(edge.coverage) + hitCount(edge.tested) > 0;
63
+ }
64
+ function describeTransition({ from, event, to }) {
65
+ return to === void 0 ? `${from} --${event}-->` : `${from} --${event}--> ${to}`;
66
+ }
67
+ /** What the expectation asks for and the machine did not reach, one line each. */
68
+ function missedReach(machine, expected) {
69
+ const reached = new Set(reachedStates(machine));
70
+ const states = (expected.states ?? []).map((id) => {
71
+ if (!machine.states.some((state) => state.id === id)) return `state ${id} does not exist`;
72
+ return reached.has(id) ? null : `state ${id} was not reached`;
73
+ });
74
+ const transitions = (expected.transitions ?? []).map((wanted) => {
75
+ const matching = machine.edges.filter((edge) => edge.kind !== "effect" && edge.kind !== "initial" && edge.from === wanted.from && edge.event === wanted.event && (wanted.to === void 0 || edge.to === wanted.to));
76
+ if (matching.length === 0) return `transition ${describeTransition(wanted)} does not exist`;
77
+ return matching.some(isTaken) ? null : `transition ${describeTransition(wanted)} was not taken`;
78
+ });
79
+ return [...states, ...transitions].filter((line) => line !== null);
80
+ }
81
+ function failOnMiss(machine, misses) {
82
+ if (misses.length === 0) return;
83
+ throw new Error(`${machine.id} missed ${misses.length} expected ${misses.length === 1 ? "reach" : "reaches"}:\n${misses.map((each) => ` - ${each}`).join("\n")}\nAdd a named run to \`tests\`, or raise \`fuzz.iterations\`, to cover it.`);
84
+ }
85
+ /**
86
+ * Call from a test: throws unless fuzzing or the target's `tests` visited every
87
+ * listed state and took every listed transition. Reaching is proof; a miss only
88
+ * says nothing got there yet.
89
+ */
90
+ function expectReached(target, expected) {
91
+ const { machine } = inspect(target);
92
+ failOnMiss(machine, missedReach(machine, expected));
93
+ }
94
+ /** Call from a test: throws unless fuzzing or the target's `tests` visited every declared state. */
95
+ function expectAllStatesReached(target) {
96
+ const { machine } = inspect(target);
97
+ failOnMiss(machine, missedReach(machine, { states: machine.states.map((state) => state.id) }));
98
+ }
99
+ export { analyzeModule, analyzeSource, annotateMachine, branchDistance, branchDistanceGuide, designErrors, eventCatalog, execute, expectAllStatesReached, expectNoDesignErrors, expectReached, fuzz, fuzzedMachine, guidance, inspect, inspectFile, inspectModule, literalPool, machineId, missedReach, reachedStates, shrink, toViz, toVizMachine, unreachableStates, vizDocument, withTestCoverage };