react-state-basis 0.6.3 → 0.6.5

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
@@ -5,9 +5,10 @@
5
5
  <div align="center">
6
6
 
7
7
  # react-state-basis
8
- ### Runtime Architectural Auditor for React
9
8
 
10
- **Basis tracks when state updates (never what) to catch architectural debt that standard tools miss, while keeping your data private.**
9
+ ### Runtime diagnostics for React state
10
+
11
+ **Basis observes when state updates happen and uses those patterns to highlight state-management issues that can be difficult to spot in a code review or profiler. It does not inspect state values.**
11
12
 
12
13
  [![npm version](https://img.shields.io/npm/v/react-state-basis.svg?style=flat-square)](https://www.npmjs.com/package/react-state-basis)
13
14
  [![GitHub stars](https://img.shields.io/github/stars/liovic/react-state-basis.svg?style=flat-square)](https://github.com/liovic/react-state-basis/stargazers)
@@ -20,12 +21,16 @@
20
21
  ## Quick Start
21
22
 
22
23
  ### 1. Install
24
+
23
25
  ```bash
24
26
  npm i react-state-basis
25
27
  ```
26
28
 
27
- ### 2. Setup (Vite)
28
- Add the plugin to your `vite.config.ts`. The Babel plugin auto-labels your hooks - you continue importing from `react` as normal.
29
+ ### 2. Setup with Vite
30
+
31
+ Add the plugin to your `vite.config.ts`.
32
+
33
+ The Babel plugin labels React hooks automatically, so you can continue importing from `react` as usual.
29
34
 
30
35
  ```ts
31
36
  import { defineConfig } from 'vite';
@@ -34,43 +39,55 @@ import { basis } from 'react-state-basis/vite';
34
39
 
35
40
  export default defineConfig({
36
41
  plugins: [
37
- react({
38
- babel: { plugins: [['react-state-basis/plugin']] }
42
+ react({
43
+ babel: {
44
+ plugins: [['react-state-basis/plugin']],
45
+ },
39
46
  }),
40
- basis()
41
- ]
47
+ basis(),
48
+ ],
42
49
  });
43
50
  ```
51
+ This is the supported setup today. Next.js / SWC is not instrumented yet.
44
52
 
45
53
  ### 3. Initialize
54
+
46
55
  ```tsx
47
56
  import { BasisProvider } from 'react-state-basis';
48
57
 
49
58
  root.render(
50
- <BasisProvider
59
+ <BasisProvider
51
60
  debug={true}
52
- showHUD={true} // Set to false for console-only forensics
61
+ showHUD={true}
53
62
  >
54
63
  <App />
55
64
  </BasisProvider>
56
65
  );
57
66
  ```
58
67
 
59
- ### 4. Verify the Signal
60
- Drop this pattern into any component. For this pattern, Basis typically flags the rhythm of the debt within ~100ms; detection latency can vary for other patterns.
68
+ Set `showHUD={false}` to keep the diagnostics in the console without showing the overlay.
69
+
70
+ ### 4. Try it
71
+
72
+ For example:
61
73
 
62
74
  ```tsx
63
75
  const [a, setA] = useState(0);
64
76
  const [b, setB] = useState(0);
65
77
 
66
78
  useEffect(() => {
67
- setB(a + 1); // ⚡ BASIS: "Double Render Detected"
79
+ setB(a + 1);
68
80
  }, [a]);
69
81
 
70
- return <button onClick={() => setA(a + 1)}>Pulse Basis</button>;
82
+ return (
83
+ <button onClick={() => setA(a + 1)}>
84
+ Update
85
+ </button>
86
+ );
71
87
  ```
72
88
 
73
- Click the button. You should see this in your console:
89
+ When the button is clicked, Basis can identify the effect-driven update pattern and report where it originated. You should see in your console:
90
+
74
91
  ```
75
92
  ⚡ BASIS | DOUBLE RENDER
76
93
  📍 Location: YourComponent.tsx
@@ -78,64 +95,199 @@ Issue: effect_L5 triggers b in a separate frame.
78
95
  Fix: Derive b during the render phase (remove effect) or wrap in useMemo.
79
96
  ```
80
97
 
81
- ---
82
-
83
- ### 5. Control & Scope
84
- * **Ghost Mode:** Disable the visual overlay while keeping console-based forensics active by setting `showHUD={false}` on the provider.
85
- * **Selective Auditing:** Add `// @basis-ignore` at the top of any file to disable instrumentation. Recommended for:
86
- * High-frequency animation logic (>60fps)
87
- * Third-party library wrappers
88
- * Intentional synchronization (e.g., local mirrors of external caches)
98
+ Detection happens at runtime and timing varies by pattern.
89
99
 
90
100
  ---
91
101
 
92
102
  ## HUD
93
103
 
94
- The optional overlay shows your component's state updates in real time. Purple pulses mark updates coming from Context; red pulses mark state that looks like a redundant copy of something else.
104
+ The optional HUD shows state updates as they happen.
95
105
 
96
106
  <p align="center">
97
- <img src="./assets/050Basis.gif" width="800" alt="Basis Demo" />
107
+ <img src="./assets/050Basis.gif" width="800" alt="Basis Demo">
98
108
  </p>
99
109
 
100
- > **Note:** The HUD shows updates as they happen. The **Architectural Health Report** (Console) looks at the whole update graph together, so it can catch patterns a single glance at the HUD would miss.
110
+ The HUD is useful for seeing individual updates. The console report looks at the observed update graph over time, which can reveal patterns that are harder to see from a single interaction.
101
111
 
102
112
  ---
103
113
 
104
- ## What Basis Detects
114
+ ## What Basis Looks For
115
+
116
+ Basis does not try to determine whether your state is "correct." Instead, it looks for update patterns that are often worth investigating.
117
+
118
+ ### Effect-driven updates
119
+
120
+ A `useEffect` causes another state update immediately after rendering.
121
+
122
+ This can be a sign that some state could be derived during render instead.
123
+
124
+ ### Correlated state
125
+
126
+ Two pieces of state repeatedly update within the same time window.
127
+
128
+ For example:
129
+
130
+ ```tsx
131
+ const [isLoading, setIsLoading] = useState(false);
132
+ const [isSuccess, setIsSuccess] = useState(false);
133
+ ```
134
+
135
+ If these values consistently change together, it may be worth checking whether they could be represented by a single state value.
136
+
137
+ Basis reports the correlation; it does not assume that the states should be merged.
138
+
139
+ ### Fragmented updates
105
140
 
106
- Basis watches *when* your state updates and looks for timing patterns that usually mean architectural debt - two states always changing together, an effect immediately re-triggering a render, a click that fans out into updates across unrelated files. Every flag below is a signal to investigate, not a verified defect:
141
+ A single interaction causes updates across multiple components, files, contexts, or stores.
107
142
 
108
- - **⚡ Double Renders (Sync Leaks)** - A `useEffect` triggers a state update immediately after a render, forcing the browser to paint twice.
109
- - **⚡ Prime Movers (Likely Root Causes)** - Skips downstream symptoms and points you to the hook or event most likely to have started the chain reaction. When multiple updates fire in the same tick, Basis ranks candidates by their position in the update graph rather than asserting a single definitive cause.
110
- - **⚡ Fragmented Updates** - A single click forces updates in multiple different files or contexts at once (tearing risk).
111
- - **Context Mirroring** - You're redundantly copying Global Context data into local state, creating two sources of truth.
112
- - **Duplicate State** - Two variables always update at the exact same time and should probably be merged (e.g. `isLoading` + `isSuccess`).
113
- - **🛑 Infinite Loops** - A safety circuit-breaker that kills the auditor before a recursive update freezes your browser.
143
+ Sometimes this is intentional. In other cases, it can indicate that state ownership is spread across several places.
114
144
 
115
- Under the hood this is timing correlation over an update graph - ideas borrowed loosely from graph theory and signal processing, not a formal proof. [**See examples & fixes →**](https://github.com/liovic/react-state-basis/wiki/The-Forensic-Catalog)
145
+ ### Context mirroring
146
+
147
+ Local state is repeatedly updated from Context state.
148
+
149
+ This can create two representations of the same information and is worth reviewing when the local copy does not have an independent purpose.
150
+
151
+ ### Update origins
152
+
153
+ When several updates occur together, Basis can use the observed update graph to identify which updates appear upstream of others.
154
+
155
+ This is intended to help investigate a chain of updates rather than simply reporting every downstream symptom.
156
+
157
+ ### Infinite update protection
158
+
159
+ Basis includes safeguards to stop its own instrumentation from continuing indefinitely when an application enters a recursive update loop.
116
160
 
117
161
  ---
118
162
 
119
- ## Reports & Telemetry
163
+ ### Important: these are signals, not proofs
164
+
165
+ Basis uses runtime timing and correlation heuristics.
120
166
 
121
- ### Architectural Health Report
122
- Check your entire app's state architecture by running `window.printBasisReport()` in the console.
167
+ A detected pattern is **not automatically a bug**, and Basis does not know the intent behind your application architecture.
123
168
 
124
- * **Refactor Priorities:** Ranks issues by blast radius on the update graph, so you can see which hook or event has the widest fan-out across the rest of your app.
125
- * **Efficiency Score:** A rough ratio of independent update sources vs effect-driven follow-up updates. Diagnostic, not a grade.
126
- * **Sync Issues:** Groups variables that tend to update together into clusters (e.g., boolean pairs that are really one piece of state).
169
+ Use the results as prompts for investigation rather than as rules for how React code should be written.
127
170
 
128
- ### Hardware Telemetry
129
- Verify engine efficiency and heap stability in real-time via `window.getBasisMetrics()`.
171
+ [See examples and possible fixes →](https://github.com/liovic/react-state-basis/wiki/The-Forensic-Catalog)
130
172
 
131
173
  ---
132
174
 
133
- ## Real-World Evidence
175
+ ## Reports
176
+
177
+ Run:
178
+
179
+ ```js
180
+ window.printBasisReport()
181
+ ```
182
+
183
+ to print a summary of the observed update graph.
134
184
 
135
- Basis has been tested against industry-standard codebases:
185
+ The report can include:
136
186
 
137
- * **Excalidraw (114k⭐)** - Proposed a theme-sync fix [**PR #10637**](https://github.com/excalidraw/excalidraw/pull/10637) (not merged)
138
- * **shadcn-admin (10k⭐)** - Detected redundant state pattern in viewport detection hooks. [**PR #274**](https://github.com/satnaing/shadcn-admin/pull/274) (merged)
187
+ * **Update sources** - where observed update chains appear to originate.
188
+ * **Fan-out** - which updates are followed by the largest number of downstream updates.
189
+ * **Correlated state** - state variables that repeatedly update together.
190
+ * **Effect-driven updates** - updates that occur as a consequence of effects.
191
+ * **Engine metrics** - runtime measurements collected by the Basis engine.
192
+
193
+ These metrics are diagnostic rather than a score for the quality of your application.
194
+
195
+ ### Causal graph
196
+
197
+ `printBasisReport()` gives a diagnosis. If you want to see the evidence that report is based on, Basis also exposes the observed update graph directly.
198
+
199
+ `printBasisGraph()` does nothing unless `debug` is on (same as `printBasisReport()`). `getBasisGraph()` always returns the current snapshot, regardless of `debug`, since it's just serializing the graph, not printing anything.
200
+
201
+ ```js
202
+ window.printBasisGraph()
203
+ ```
204
+
205
+ prints it to the console, grouped by source:
206
+
207
+ ```
208
+ 📊 BASIS | CAUSAL GRAPH 7 nodes · 7 edges · 2 sources · buffer window 50
209
+ parent → child = observed cause → update. (×N) = times in this window.
210
+ ⚡ Event · 3 targets · ×2
211
+ BooleanEntanglement.tsx → isLoading redundant
212
+ BooleanEntanglement.tsx → isSuccess redundant
213
+ BooleanEntanglement.tsx → hasData redundant
214
+ ↯ WeatherLab.tsx → effect @ L7
215
+ WeatherLab.tsx → fahrenheit (×2)
216
+ ```
217
+
218
+ For the raw data, either from the console:
219
+
220
+ ```js
221
+ window.getBasisGraph()
222
+ ```
223
+
224
+ or imported directly, if you're building your own tooling on top of it rather than reading it from the console:
225
+
226
+ ```ts
227
+ import { getBasisGraph } from 'react-state-basis';
228
+ import type { BasisGraphJSON } from 'react-state-basis';
229
+ ```
230
+
231
+ Either way it returns:
232
+
233
+ ```ts
234
+ {
235
+ generatedAt: number;
236
+ bufferWindowSize: number;
237
+ eventTtlMs: number;
238
+ nodes: { id, name, file, role, density, redundant }[];
239
+ edges: { source, target, weight }[];
240
+ eventGroups: { sourceIds, occurrences, edges }[];
241
+ }
242
+ ```
243
+
244
+ A few things worth knowing about the shape:
245
+
246
+ * `role` distinguishes real state (`local` / `context` / `store` / `proj`) from `effect` sources, virtual `event` triggers, and `unknown` (a graph edge with no recognized shape - e.g. a custom integration recording edges directly). `effect`, `event`, and `unknown` all have `density: null` - none of them has a real update history of its own.
247
+ * `bufferWindowSize` and `eventTtlMs` are two different clocks: state nodes' `density` is measured over the last `bufferWindowSize` ticks, while virtual `event` nodes are pruned after `eventTtlMs` of inactivity. They're unrelated, so don't read one as describing the other.
248
+ * User interactions are recorded as virtual, per-frame `event` triggers. Repeated interactions that produce the exact same fan-out (same targets, same weights) are collapsed into one entry in `eventGroups`, so clicking the same button 20 times doesn't produce 20 near-identical entries. `nodes` and `edges` remain the full, ungrouped data if you need it - the node/edge counts in the header above are always raw counts, not the number of lines shown after grouping.
249
+ * Only nodes that appear on at least one edge are included - a registered variable that has never caused or received an update won't show up.
250
+
251
+ This is meant for inspecting what Basis actually observed, building your own tooling on top of it, or attaching to a bug report - not as a second diagnostic layer alongside `printBasisReport()`.
252
+
253
+ ### Runtime metrics
254
+
255
+ You can inspect engine metrics with:
256
+
257
+ ```js
258
+ window.getBasisMetrics()
259
+ ```
260
+
261
+ ---
262
+
263
+ ## Controlling the Instrumentation
264
+
265
+ ### Console-only mode
266
+
267
+ Disable the HUD while keeping diagnostics enabled:
268
+
269
+ ```tsx
270
+ <BasisProvider showHUD={false}>
271
+ <App />
272
+ </BasisProvider>
273
+ ```
274
+
275
+ ### Ignoring files
276
+
277
+ Add:
278
+
279
+ ```ts
280
+ // @basis-ignore
281
+ ```
282
+
283
+ to a file to disable Basis instrumentation for that file.
284
+
285
+ This can be useful for:
286
+
287
+ * high-frequency animation code
288
+ * third-party library wrappers
289
+ * intentionally synchronized state
290
+ * code where instrumentation is not useful
139
291
 
140
292
  ---
141
293
 
@@ -143,62 +295,89 @@ Basis has been tested against industry-standard codebases:
143
295
 
144
296
  ### Zustand
145
297
 
146
- Wrap your store with `basisLogger` to give Basis visibility into external
147
- store updates. Store signals appear in the HUD and health report alongside your React state.
298
+ Basis can observe Zustand store updates alongside React state.
148
299
 
149
300
  ```typescript
150
301
  import { create } from 'zustand';
151
302
  import { basisLogger } from 'react-state-basis/zustand';
152
303
 
153
304
  export const useStore = create(
154
- basisLogger((set) => ({
155
- theme: 'light',
156
- toggleTheme: () => set((state) => ({
157
- theme: state.theme === 'light' ? 'dark' : 'light'
158
- })),
159
- }), 'MyStore')
305
+ basisLogger(
306
+ (set) => ({
307
+ theme: 'light',
308
+
309
+ toggleTheme: () =>
310
+ set((state) => ({
311
+ theme: state.theme === 'light' ? 'dark' : 'light',
312
+ })),
313
+ }),
314
+ 'MyStore'
315
+ )
160
316
  );
161
317
  ```
162
318
 
163
- This enables detection of **Store Mirroring**, **Store Sync Leaks**, and
164
- **Global Event Fragmentation** across React and Zustand state simultaneously.
319
+ This allows React and Zustand updates to appear in the same runtime graph.
165
320
 
166
- [See full Zustand example →](./examples/basis-zustand/)
321
+ [See the Zustand example →](./examples/basis-zustand/)
167
322
 
168
- ### More integrations coming
323
+ ### Planned integrations
169
324
 
170
- Planned: XState, React Query, Redux Toolkit. Community PRs welcome.
325
+ XState, React Query, and Redux Toolkit are planned.
326
+
327
+ Community contributions are welcome.
171
328
 
172
329
  ---
173
330
 
174
331
  ## Performance & Privacy
175
332
 
176
- **Development:** <1ms overhead per update cycle, zero heap growth
177
- **Production:** ~0.01ms per hook (monitoring disabled, ~2-3KB bundle)
178
- **Privacy:** Only tracks update timing, never state values
333
+ Basis is designed primarily as a development-time diagnostic tool.
334
+
335
+ * **Development:** instrumentation overhead is designed to remain small; current benchmarks show less than 1ms per update cycle in tested scenarios.
336
+ * **Production:** monitoring is disabled, with a small production footprint.
337
+ * **Privacy:** Basis records update timing and relationships, not application state values.
338
+
339
+ Actual overhead depends on the application and instrumentation configuration.
179
340
 
180
- [**See benchmarks →**](https://github.com/liovic/react-state-basis/wiki/Performance-Forensics)
341
+ [See benchmarks ](https://github.com/liovic/react-state-basis/wiki/Performance-Forensics)
181
342
 
182
343
  ---
183
344
 
184
- ## Documentation & Theory
345
+ ## Real-World Examples
185
346
 
186
- The engine uses graph and timing heuristics to infer likely architectural issues from *when* state changes, not *what* it changes to. [**The wiki**](https://github.com/liovic/react-state-basis/wiki) explains the full mental model, the math it borrows from, and the engine internals. It is a heuristic, not a proof.
347
+ Basis has also been tested against existing open-source applications.
348
+
349
+ * **Excalidraw** - Basis identified a theme synchronization pattern and a possible simplification. [PR #10637](https://github.com/excalidraw/excalidraw/pull/10637) was proposed but not merged.
350
+ * **shadcn-admin** - Basis identified a redundant state pattern in viewport detection hooks. [PR #274](https://github.com/satnaing/shadcn-admin/pull/274) was merged.
351
+
352
+ These examples are intended as demonstrations of the tool's output, not as claims that every detected pattern represents a defect.
187
353
 
188
354
  ---
189
355
 
190
- ## Roadmap
356
+ ## How It Works
357
+
358
+ Basis observes the timing and relationships between state updates while your application runs.
359
+
360
+ It builds an in-memory representation of those updates and applies heuristics to identify recurring patterns.
361
+
362
+ It does **not** need to inspect the values stored in your state to perform these checks.
191
363
 
192
- Each version answers a different architectural question:
364
+ The analysis is intentionally heuristic. Runtime behavior can show that two things consistently happen together, but it cannot by itself prove why they happen together or whether the relationship is intentional.
193
365
 
194
- **v0.4.x** - Detect states that always move together *(The Correlation Era)*
195
- ✓ **v0.5.x** - Detect local copies of Context *(The Decomposition Era)*
196
- **v0.6.x** - Rank which update fans out widest *(The Graph Era)*
197
- **v0.7.x** - Detect derivative vs. independent state *(The Information Era)*
198
- **v0.8.x** - Estimate how much local state a component actually needs *(The Manifold Era)*
366
+ For a deeper look at the implementation and underlying model:
367
+
368
+ [Read the documentation and theory →](https://github.com/liovic/react-state-basis/wiki)
369
+
370
+ ---
371
+
372
+ ## Roadmap
199
373
 
374
+ * ✓ **v0.4.x** - Identify state that repeatedly updates together
375
+ * ✓ **v0.5.x** - Identify local state synchronized from Context
376
+ * → **v0.6.x** - Analyze update fan-out and likely upstream sources
377
+ * **v0.7.x** - Improve detection of derived vs. independent state
378
+ * **v0.8.x** - Explore how much local state components actually use
200
379
 
201
- [**More info**](https://github.com/liovic/react-state-basis/wiki/Roadmap)
380
+ [See the full roadmap →](https://github.com/liovic/react-state-basis/wiki/Roadmap)
202
381
 
203
382
  ---
204
383
 
@@ -61,6 +61,27 @@ var calculateSpectralInfluence = (graph, maxIterations = 20, tolerance = 1e-3) =
61
61
  }
62
62
  return scores;
63
63
  };
64
+ var groupEventSources = (nodes, edges) => {
65
+ const outgoing = /* @__PURE__ */ new Map();
66
+ edges.forEach((e) => {
67
+ if (!outgoing.has(e.source)) outgoing.set(e.source, []);
68
+ outgoing.get(e.source).push(e);
69
+ });
70
+ const eventSourceIds = nodes.filter((n) => n.role === "event").map((n) => n.id);
71
+ const signatureOf = (sourceEdges) => sourceEdges.map((e) => `${e.target}@${e.weight}`).sort().join("|");
72
+ const buckets = /* @__PURE__ */ new Map();
73
+ eventSourceIds.forEach((id) => {
74
+ const sig = signatureOf(outgoing.get(id) || []);
75
+ if (!buckets.has(sig)) buckets.set(sig, []);
76
+ buckets.get(sig).push(id);
77
+ });
78
+ const groups = Array.from(buckets.values()).map((sourceIds) => ({
79
+ sourceIds,
80
+ occurrences: sourceIds.length,
81
+ edges: (outgoing.get(sourceIds[0]) || []).slice()
82
+ }));
83
+ return groups.sort((a, b) => b.edges.length - a.edges.length || b.occurrences - a.occurrences);
84
+ };
64
85
 
65
86
  // src/core/constants.ts
66
87
  var WINDOW_SIZE = 50;
@@ -80,6 +101,7 @@ var parseLabel = (label) => {
80
101
  return { file: parts[0] || "Unknown", name: parts[1] || base };
81
102
  };
82
103
  var isSameField = (labelA, labelB) => stripInstance(labelA) === stripInstance(labelB);
104
+ var isEffectLabel = (name) => /^effect_L\d+(:\d+)?$/.test(name) || name === "anonymous_effect" || name === "anonymous_layout_effect";
83
105
 
84
106
  // src/core/ranker.ts
85
107
  var identifyTopIssues = (graph, history2, redundantLabels2, violationMap) => {
@@ -475,6 +497,99 @@ var displayCausalHint = (targetLabel, targetMeta, sourceLabel, sourceMeta) => {
475
497
  }
476
498
  console.groupEnd();
477
499
  };
500
+ var splitHookLine = (raw) => {
501
+ const m = raw.match(/^(.*):(\d+)$/);
502
+ if (!m) return { hook: raw };
503
+ return { hook: m[1], line: Number(m[2]) };
504
+ };
505
+ var formatHook = (raw) => {
506
+ const { hook } = splitHookLine(raw);
507
+ if (!isEffectLabel(hook)) return hook;
508
+ const lineMatch = hook.match(/L(\d+)$/);
509
+ return lineMatch ? `effect @ L${lineMatch[1]}` : "effect (anonymous)";
510
+ };
511
+ var formatNode = (node, fallbackId = "?") => {
512
+ if (!node) return fallbackId;
513
+ if (node.role === "event") return "Event";
514
+ const hook = formatHook(node.name || node.id);
515
+ if (node.file && hook) return `${node.file} \u2192 ${hook}`;
516
+ return hook || node.id;
517
+ };
518
+ var displayGraphReport = (graph) => {
519
+ if (!isWeb) return;
520
+ if (graph.nodes.length === 0) {
521
+ console.log(
522
+ `%c \u{1F4CA} BASIS | CAUSAL GRAPH %c(no data yet)`,
523
+ STYLES.headerIdentity,
524
+ `color: ${THEME.muted}; font-style: italic;`
525
+ );
526
+ return;
527
+ }
528
+ const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
529
+ const outgoing = /* @__PURE__ */ new Map();
530
+ graph.edges.forEach((e) => {
531
+ if (!outgoing.has(e.source)) outgoing.set(e.source, []);
532
+ outgoing.get(e.source).push(e);
533
+ });
534
+ const eventGroups = graph.eventGroups.map((g) => ({
535
+ sourceIds: g.sourceIds,
536
+ sourceNode: nodeById.get(g.sourceIds[0]),
537
+ edges: g.edges,
538
+ occurrences: g.occurrences
539
+ }));
540
+ const groupedSourceIds = new Set(graph.eventGroups.flatMap((g) => g.sourceIds));
541
+ const nonEventGroups = Array.from(outgoing.keys()).filter((id) => !groupedSourceIds.has(id)).map((id) => ({ sourceIds: [id], sourceNode: nodeById.get(id), edges: outgoing.get(id), occurrences: 1 }));
542
+ const groups = [...eventGroups, ...nonEventGroups].sort(
543
+ (a, b) => b.edges.length - a.edges.length || b.occurrences - a.occurrences
544
+ );
545
+ console.group(
546
+ `%c \u{1F4CA} BASIS | CAUSAL GRAPH %c${graph.nodes.length} nodes \xB7 ${graph.edges.length} edges \xB7 ${groups.length} sources \xB7 buffer window ${graph.bufferWindowSize}`,
547
+ STYLES.headerIdentity,
548
+ `color: ${THEME.muted}; font-weight: normal; font-style: italic;`
549
+ );
550
+ console.log(
551
+ `%cparent \u2192 child = observed cause \u2192 update. (\xD7N) = times in this window. Event groups with the same fan-out are collapsed.`,
552
+ STYLES.subText
553
+ );
554
+ groups.forEach((group) => {
555
+ const isEvent = group.sourceNode?.role === "event";
556
+ const isCtx = group.sourceNode?.role === "context" /* CONTEXT */;
557
+ const isFx = group.sourceNode?.role === "effect";
558
+ const isUnknown = group.sourceNode?.role === "unknown";
559
+ const icon = isEvent ? "\u26A1" : isCtx ? "\u03A9" : isFx ? "\u21AF" : isUnknown ? "?" : "\u25CF";
560
+ const color = isEvent ? THEME.solution : isCtx ? THEME.context : THEME.identity;
561
+ const fanout = group.edges.length;
562
+ const hits = group.occurrences;
563
+ const hitLabel = hits > 1 ? ` \xB7 \xD7${hits}` : "";
564
+ const title = isEvent ? `Event \xB7 ${fanout} target${fanout === 1 ? "" : "s"}${hitLabel}` : formatNode(group.sourceNode, group.sourceIds[0]);
565
+ console.groupCollapsed(
566
+ `%c${icon} %c${title}`,
567
+ `color: ${color};`,
568
+ "font-family: monospace; font-weight: 600;"
569
+ );
570
+ group.edges.slice().sort((a, b) => b.weight - a.weight).forEach((edge) => {
571
+ const target = nodeById.get(edge.target);
572
+ const label = formatNode(target, edge.target);
573
+ const weight = edge.weight > 1 ? ` (\xD7${edge.weight})` : "";
574
+ if (target?.redundant) {
575
+ console.log(
576
+ `%c ${label}%c${weight} %credundant`,
577
+ `color: ${THEME.muted}; font-family: monospace;`,
578
+ `color: ${THEME.muted}; font-style: italic;`,
579
+ `color: ${THEME.problem}; font-weight: bold;`
580
+ );
581
+ } else {
582
+ console.log(
583
+ `%c ${label}%c${weight}`,
584
+ `color: ${THEME.muted}; font-family: monospace;`,
585
+ `color: ${THEME.muted}; font-style: italic;`
586
+ );
587
+ }
588
+ });
589
+ console.groupEnd();
590
+ });
591
+ console.groupEnd();
592
+ };
478
593
  var displayViolentBreaker = (label, count, threshold) => {
479
594
  if (!isWeb) return;
480
595
  const { name } = parseLabel(label);
@@ -837,6 +952,13 @@ var unregisterVariable = (l) => {
837
952
  instance.loopCounters.delete(l);
838
953
  instance.pausedVariables.delete(l);
839
954
  instance.redundantLabels.delete(l);
955
+ instance.graph.delete(l);
956
+ instance.graph.forEach((targets) => targets.delete(l));
957
+ instance.violationMap.delete(l);
958
+ instance.violationMap.forEach((list) => {
959
+ const idx = list.findIndex((v) => v.target === l);
960
+ if (idx !== -1) list.splice(idx, 1);
961
+ });
840
962
  };
841
963
  var beginEffectTracking = (l) => {
842
964
  if (instance.config.debug) instance.currentEffectSource = l;
@@ -854,9 +976,53 @@ var getBasisMetrics = () => ({
854
976
  analysis_ms: instance.metrics.lastAnalysisTimeMs.toFixed(3),
855
977
  entropy: instance.metrics.systemEntropy.toFixed(3)
856
978
  });
979
+ var getBasisGraph = () => {
980
+ const nodeIds = /* @__PURE__ */ new Set();
981
+ const edges = [];
982
+ instance.graph.forEach((targets, source) => {
983
+ nodeIds.add(source);
984
+ targets.forEach((weight, target) => {
985
+ nodeIds.add(target);
986
+ edges.push({ source, target, weight });
987
+ });
988
+ });
989
+ const nodes = Array.from(nodeIds).map((id) => {
990
+ if (id.startsWith("Event_Tick_")) {
991
+ return { id, name: "Event", file: "(shared trigger)", role: "event", density: null, redundant: false };
992
+ }
993
+ const meta = instance.history.get(id);
994
+ const { file, name } = parseLabel(id);
995
+ if (!meta) {
996
+ const role = isEffectLabel(name) ? "effect" : "unknown";
997
+ return { id, name, file, role, density: null, redundant: false };
998
+ }
999
+ return {
1000
+ id,
1001
+ name,
1002
+ file,
1003
+ role: meta.role,
1004
+ density: meta.density,
1005
+ redundant: instance.redundantLabels.has(id)
1006
+ };
1007
+ });
1008
+ return {
1009
+ generatedAt: Date.now(),
1010
+ bufferWindowSize: WINDOW_SIZE,
1011
+ eventTtlMs: EVENT_TTL,
1012
+ nodes,
1013
+ edges,
1014
+ eventGroups: groupEventSources(nodes, edges)
1015
+ };
1016
+ };
1017
+ var printBasisGraph = () => {
1018
+ if (!instance.config.debug) return;
1019
+ displayGraphReport(getBasisGraph());
1020
+ };
857
1021
  if (typeof window !== "undefined") {
858
1022
  window.printBasisReport = printBasisHealthReport;
859
1023
  window.getBasisMetrics = getBasisMetrics;
1024
+ window.getBasisGraph = getBasisGraph;
1025
+ window.printBasisGraph = printBasisGraph;
860
1026
  }
861
1027
 
862
1028
  export {
@@ -872,6 +1038,8 @@ export {
872
1038
  beginEffectTracking,
873
1039
  endEffectTracking,
874
1040
  printBasisHealthReport,
875
- getBasisMetrics
1041
+ getBasisMetrics,
1042
+ getBasisGraph,
1043
+ printBasisGraph
876
1044
  };
877
- //# sourceMappingURL=chunk-E3D6YEKB.mjs.map
1045
+ //# sourceMappingURL=chunk-EJXGN76H.mjs.map