@zeix/cause-effect 0.18.5 → 1.0.1

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 (63) hide show
  1. package/.github/copilot-instructions.md +2 -1
  2. package/.zed/settings.json +24 -1
  3. package/ARCHITECTURE.md +2 -2
  4. package/CHANGELOG.md +23 -0
  5. package/README.md +42 -1
  6. package/REQUIREMENTS.md +3 -3
  7. package/bench/reactivity.bench.ts +18 -7
  8. package/biome.json +1 -1
  9. package/eslint.config.js +2 -1
  10. package/index.dev.js +11 -5
  11. package/index.js +1 -1
  12. package/index.ts +1 -1
  13. package/package.json +6 -6
  14. package/skills/cause-effect/SKILL.md +69 -0
  15. package/skills/cause-effect/agents/openai.yaml +4 -0
  16. package/skills/cause-effect/references/api-facts.md +179 -0
  17. package/skills/cause-effect/references/error-classes.md +153 -0
  18. package/skills/cause-effect/references/non-obvious-behaviors.md +173 -0
  19. package/skills/cause-effect/references/signal-types.md +288 -0
  20. package/skills/cause-effect/workflows/answer-question.md +54 -0
  21. package/skills/cause-effect/workflows/debug.md +71 -0
  22. package/skills/cause-effect/workflows/use-api.md +63 -0
  23. package/skills/cause-effect-dev/SKILL.md +75 -0
  24. package/skills/cause-effect-dev/agents/openai.yaml +4 -0
  25. package/skills/cause-effect-dev/references/api-facts.md +96 -0
  26. package/skills/cause-effect-dev/references/error-classes.md +97 -0
  27. package/skills/cause-effect-dev/references/internal-types.md +54 -0
  28. package/skills/cause-effect-dev/references/non-obvious-behaviors.md +146 -0
  29. package/skills/cause-effect-dev/references/source-map.md +45 -0
  30. package/skills/cause-effect-dev/workflows/answer-question.md +55 -0
  31. package/skills/cause-effect-dev/workflows/fix-bug.md +63 -0
  32. package/skills/cause-effect-dev/workflows/implement-feature.md +46 -0
  33. package/skills/cause-effect-dev/workflows/write-tests.md +64 -0
  34. package/skills/changelog-keeper/SKILL.md +47 -37
  35. package/skills/tech-writer/SKILL.md +94 -0
  36. package/skills/tech-writer/references/document-map.md +199 -0
  37. package/skills/tech-writer/references/tone-guide.md +189 -0
  38. package/skills/tech-writer/workflows/consistency-review.md +98 -0
  39. package/skills/tech-writer/workflows/update-after-change.md +65 -0
  40. package/skills/tech-writer/workflows/update-agent-docs.md +77 -0
  41. package/skills/tech-writer/workflows/update-architecture.md +61 -0
  42. package/skills/tech-writer/workflows/update-jsdoc.md +72 -0
  43. package/skills/tech-writer/workflows/update-public-api.md +59 -0
  44. package/skills/tech-writer/workflows/update-requirements.md +80 -0
  45. package/src/graph.ts +8 -4
  46. package/src/nodes/collection.ts +42 -2
  47. package/src/nodes/effect.ts +13 -1
  48. package/src/nodes/list.ts +28 -4
  49. package/src/nodes/memo.ts +0 -1
  50. package/src/nodes/sensor.ts +10 -4
  51. package/src/nodes/store.ts +11 -0
  52. package/src/signal.ts +6 -0
  53. package/test/benchmark.test.ts +25 -11
  54. package/test/collection.test.ts +6 -3
  55. package/test/effect.test.ts +2 -1
  56. package/test/list.test.ts +8 -4
  57. package/test/regression.test.ts +4 -2
  58. package/test/store.test.ts +8 -4
  59. package/test/util/dependency-graph.ts +12 -6
  60. package/tsconfig.json +14 -1
  61. package/types/index.d.ts +1 -1
  62. package/types/src/graph.d.ts +2 -2
  63. package/OWNERSHIP_BUG.md +0 -95
@@ -0,0 +1,97 @@
1
+ <overview>
2
+ Error classes thrown by @zeix/cause-effect and the conditions that trigger them. Read this when writing error-handling code, testing error conditions, or diagnosing an unexpected throw.
3
+ </overview>
4
+
5
+ <error_table>
6
+ | Class | When thrown |
7
+ |---|---|
8
+ | `NullishSignalValueError` | Signal value is `null` or `undefined` |
9
+ | `InvalidSignalValueError` | Value fails the `guard` predicate |
10
+ | `InvalidCallbackError` | A required callback argument is not a function |
11
+ | `DuplicateKeyError` | List/Collection key collision on insert |
12
+ | `UnsetSignalValueError` | Reading a Sensor or Task before it has produced its first value |
13
+ | `ReadonlySignalError` | Attempting to write to a read-only signal |
14
+ | `RequiredOwnerError` | `createEffect` called outside an owner (effect or scope) |
15
+ | `CircularDependencyError` | A cycle is detected in the reactive graph |
16
+
17
+ All error classes are defined in `src/errors.ts`.
18
+ </error_table>
19
+
20
+ <error_details>
21
+
22
+ <NullishSignalValueError>
23
+ Thrown when a signal's value is `null` or `undefined`. Because all signal generics use `T extends {}`, nullish values are excluded by type — this error surfaces the constraint at runtime for cases where type safety is bypassed (e.g. untyped interop, type assertions).
24
+ </NullishSignalValueError>
25
+
26
+ <InvalidSignalValueError>
27
+ Thrown when a value passed to `set()` fails the `guard` predicate supplied in the signal's options. This is the runtime enforcement of custom type narrowing at signal boundaries.
28
+
29
+ ```typescript
30
+ const age = createState(0, {
31
+ guard: (v): v is number => typeof v === 'number' && v >= 0,
32
+ })
33
+ age.set(-1) // throws InvalidSignalValueError
34
+ ```
35
+ </InvalidSignalValueError>
36
+
37
+ <InvalidCallbackError>
38
+ Thrown when a required callback argument (e.g. the computation function passed to `createMemo`, `createTask`, or `createEffect`) is not a function. Catches programming errors like passing `undefined` or a non-function value.
39
+ </InvalidCallbackError>
40
+
41
+ <DuplicateKeyError>
42
+ Thrown when inserting an item into a List or Collection whose key already exists. Keys must be unique within a given List or Collection. Use `update()` or `set()` to change an existing entry instead.
43
+ </DuplicateKeyError>
44
+
45
+ <UnsetSignalValueError>
46
+ Thrown when `.get()` is called on a Sensor or Task before it has emitted its first value. Unlike State, Sensor and Task have no initial value — they start in an explicitly unset state.
47
+
48
+ Handle this with `match`, which provides a `nil` branch for the unset case:
49
+
50
+ ```typescript
51
+ match([sensor, task], {
52
+ ok: ([s, t]) => render(s, t),
53
+ nil: () => showSpinner(),
54
+ })
55
+ ```
56
+ </UnsetSignalValueError>
57
+
58
+ <ReadonlySignalError>
59
+ Thrown when code attempts to call `.set()` on a signal that was created as or converted to a read-only signal. Derived signals (Memo, Task) are inherently read-only; certain factory options can also produce read-only State or Sensor instances.
60
+ </ReadonlySignalError>
61
+
62
+ <RequiredOwnerError>
63
+ Thrown when `createEffect` is called without an active owner in the current execution context. Effects must be created inside a `createScope` callback or inside another `createEffect` callback so that their cleanup can be registered.
64
+
65
+ ```typescript
66
+ // Wrong — no active owner
67
+ createEffect(() => console.log('runs')) // throws RequiredOwnerError
68
+
69
+ // Correct — wrapped in a scope
70
+ const dispose = createScope(() => {
71
+ createEffect(() => console.log('runs'))
72
+ })
73
+ ```
74
+ </RequiredOwnerError>
75
+
76
+ <CircularDependencyError>
77
+ Thrown when the graph engine detects a cycle during propagation — a signal that, directly or transitively, depends on itself. Cycles make it impossible to determine a stable evaluation order and are always a programming error.
78
+
79
+ Common cause: a Memo or Task that writes to a State it also reads, or two Memos that read each other.
80
+ </CircularDependencyError>
81
+
82
+ </error_details>
83
+
84
+ <testing_error_conditions>
85
+ Use `expect(() => ...).toThrow(ErrorClass)` to assert that a specific error is thrown:
86
+
87
+ ```typescript
88
+ import { InvalidSignalValueError, createState } from '@zeix/cause-effect'
89
+
90
+ test('rejects negative age', () => {
91
+ const age = createState(0, { guard: (v): v is number => typeof v === 'number' && v >= 0 })
92
+ expect(() => age.set(-1)).toThrow(InvalidSignalValueError)
93
+ })
94
+ ```
95
+
96
+ Import error classes directly from `src/errors.ts` in internal tests, or from the package root in consumer-facing tests.
97
+ </testing_error_conditions>
@@ -0,0 +1,54 @@
1
+ <overview>
2
+ Internal node shapes and global pointers in the @zeix/cause-effect graph engine. Read this when working on graph propagation, ownership, or cleanup.
3
+ </overview>
4
+
5
+ <node_shapes>
6
+ Five node shapes are used internally. Source files are authoritative — these are summaries only.
7
+
8
+ | Shape | Role | File |
9
+ |---|---|---|
10
+ | `StateNode<T>` | Source only — holds a value, no dependencies | `src/nodes/state.ts` |
11
+ | `MemoNode<T>` | Source + sink — derives a value from dependencies | `src/nodes/memo.ts` |
12
+ | `TaskNode<T>` | Source + sink + `AbortController` — async derivation with cancellation | `src/nodes/task.ts` |
13
+ | `EffectNode` | Sink + owner — runs side effects, owns child effects/scopes | `src/nodes/effect.ts` |
14
+ | `Scope` | Owner only — groups cleanup registrations, no reactive tracking | `src/graph.ts` |
15
+
16
+ `Slot`, `Store`, `List`, and `Collection` are built on top of `MemoNode<T>` internally.
17
+ </node_shapes>
18
+
19
+ <global_pointers>
20
+ Two independent global pointers are maintained by `src/graph.ts`:
21
+
22
+ **`activeSink`**
23
+ - Set to the currently-running Memo, Task, or Effect during its computation
24
+ - Any signal read while `activeSink` is set records a dependency edge to it
25
+ - Nulled by `untrack(fn)` — reads inside `fn` do not create edges
26
+ - Reset to `null` after each computation completes
27
+
28
+ **`activeOwner`**
29
+ - Set to the currently-running Effect or Scope
30
+ - Any cleanup registered while `activeOwner` is set is attached to that owner
31
+ - Nulled by `unown(fn)` — cleanups registered inside `fn` are not owned by the current context
32
+ - Use `unown` in `connectedCallback` for nodes whose lifecycle is managed by the DOM, not by the reactive graph
33
+ </global_pointers>
34
+
35
+ <ownership_vs_tracking>
36
+ The two pointers are independent and serve different purposes:
37
+
38
+ | Pointer | Purpose | Nulled by |
39
+ |---|---|---|
40
+ | `activeSink` | Dependency tracking (what re-runs when a source changes) | `untrack()` |
41
+ | `activeOwner` | Cleanup registration (what is disposed when an owner is disposed) | `unown()` |
42
+
43
+ A computation can track dependencies without owning cleanups, and vice versa. These are not the same concept.
44
+ </ownership_vs_tracking>
45
+
46
+ <flag_semantics>
47
+ Propagation is driven by bitmask flags defined in `src/graph.ts`. Read that file and `ARCHITECTURE.md` for the full semantics. Key flags:
48
+
49
+ - `FLAG_DIRTY` — node's value is stale and must recompute
50
+ - `FLAG_CHECK` — node may be stale; check sources before deciding whether to recompute
51
+ - `FLAG_NOTIFIED` — node has already been scheduled in the current flush
52
+
53
+ `FLAG_CHECK` is how `equals` suppresses subtrees: when a Memo recomputes to the same value, downstream nodes are marked `FLAG_CHECK` instead of `FLAG_DIRTY`, and they skip recomputation if their sources have not actually changed.
54
+ </flag_semantics>
@@ -0,0 +1,146 @@
1
+ <overview>
2
+ Counterintuitive behaviors in @zeix/cause-effect that commonly cause bugs or confusion. Read this when debugging unexpected reactive behavior, or when writing tests that cover edge cases.
3
+ </overview>
4
+
5
+ <direct_lookups_do_not_track>
6
+ **`byKey()`, `at()`, `keyAt()`, and `indexOfKey()` do not create graph edges.** They are direct lookups into the internal map/array — calling them inside an effect or memo does not subscribe to structural changes.
7
+
8
+ To react to structural changes (key added, key removed, order changed), read a tracking accessor instead:
9
+
10
+ | You want to react to | Read this |
11
+ |---|---|
12
+ | Any structural change | `collection.get()` or `list.get()` |
13
+ | Key set membership | `collection.keys()` |
14
+ | Length / item count | `collection.length` |
15
+ | A specific item's value | `collection.get()` then access the item |
16
+
17
+ ```typescript
18
+ // Wrong — effect does not re-run when keys are added or removed
19
+ createEffect(() => {
20
+ const item = collection.byKey('id-123')
21
+ render(item)
22
+ })
23
+
24
+ // Correct — reading keys() creates a dependency on structural changes
25
+ createEffect(() => {
26
+ const keys = collection.keys() // tracks structure
27
+ const item = collection.byKey('id-123') // safe after establishing the edge
28
+ render(item)
29
+ })
30
+ ```
31
+ </direct_lookups_do_not_track>
32
+
33
+ <conditional_reads_delay_watched>
34
+ **Conditional signal reads delay `watched` activation.** The `watched` callback on a State or Sensor fires when the first downstream effect subscribes. If a signal is only read inside a branch that hasn't executed yet, `watched` does not fire until that branch runs.
35
+
36
+ Read all signals you care about eagerly — before any conditional logic — to ensure `watched` fires on the first effect run:
37
+
38
+ ```typescript
39
+ // Bad — `derived` is only read after `task` resolves to `ok`
40
+ // `derived.watched` does not fire until the task has a value
41
+ createEffect(() => {
42
+ match([task], {
43
+ ok: ([result]) => render(derived.get(), result),
44
+ nil: () => showSpinner(),
45
+ })
46
+ })
47
+
48
+ // Good — both signals are read on every run, regardless of task state
49
+ // Both `watched` callbacks fire immediately when the effect is created
50
+ createEffect(() => {
51
+ match([task, derived], {
52
+ ok: ([result, value]) => render(value, result),
53
+ nil: () => showSpinner(),
54
+ })
55
+ })
56
+ ```
57
+
58
+ This also applies to plain `if` / ternary / `&&` patterns — any signal read gated behind a condition may not establish its dependency edge until the condition is true.
59
+ </conditional_reads_delay_watched>
60
+
61
+ <equals_suppresses_subtrees>
62
+ **`equals` suppresses entire downstream subgraphs, not just the node it is set on.** When a Memo or State recomputes to a value that is `equals` to the previous one, all downstream nodes receive `FLAG_CHECK` instead of `FLAG_DIRTY`. Those nodes skip recomputation entirely without running their callbacks.
63
+
64
+ This is a powerful optimization, but it has a non-obvious consequence: a custom `equals` on an intermediate Memo can silently prevent large parts of the graph from updating, even if upstream sources changed.
65
+
66
+ ```typescript
67
+ const source = createState({ x: 1, y: 2 })
68
+
69
+ // This memo compares by x only
70
+ const xOnly = createMemo(
71
+ () => source.get().x,
72
+ { equals: (a, b) => a === b }
73
+ )
74
+
75
+ // This effect depends on xOnly
76
+ // It will NOT re-run if source changes but x stays the same,
77
+ // even if y changed dramatically
78
+ createEffect(() => {
79
+ console.log('x is', xOnly.get())
80
+ })
81
+ ```
82
+
83
+ When debugging "why did my effect not re-run", check for custom `equals` on intermediate memos in the dependency chain.
84
+ </equals_suppresses_subtrees>
85
+
86
+ <watched_stable_through_mutations>
87
+ **`watched` stays active through structural mutations.** The `watched` callback on a List or Collection source is called once when the first downstream effect subscribes, and `unwatched` is called when the last downstream effect unsubscribes. Structural mutations (adding items, removing items, updating values) do not call `unwatched` and then `watched` again — the callback remains active for the lifetime of the subscription.
88
+
89
+ ```typescript
90
+ const list = createList(
91
+ () => fetchItems(), // watched: start polling / open WebSocket
92
+ () => stopPolling(), // unwatched: stop polling / close WebSocket
93
+ )
94
+
95
+ // Adding or removing items from the list does NOT restart the watched/unwatched cycle.
96
+ // The data source stays open as long as at least one effect is subscribed.
97
+ list.push({ id: '1', name: 'Item 1' }) // watched callback is NOT called again
98
+ list.delete('1') // watched callback is NOT called again
99
+ ```
100
+ </watched_stable_through_mutations>
101
+
102
+ <task_abort_on_dependency_change>
103
+ **A Task's `AbortSignal` is aborted when dependencies change before the async operation completes.** If a Task's sources update while the previous `Promise` is still pending, a new run is scheduled and the previous `AbortController` is aborted. Not forwarding the signal to cancellable async operations will cause stale results to overwrite fresh ones.
104
+
105
+ ```typescript
106
+ // Wrong — fetch is not cancellable; stale response may arrive after a newer one
107
+ const results = createTask(async () => {
108
+ return fetch(`/api/search?q=${query.get()}`).then(r => r.json())
109
+ })
110
+
111
+ // Correct — abort signal forwarded; stale requests are cancelled
112
+ const results = createTask(async (prev, signal) => {
113
+ return fetch(`/api/search?q=${query.get()}`, { signal }).then(r => r.json())
114
+ })
115
+ ```
116
+ </task_abort_on_dependency_change>
117
+
118
+ <sensor_unset_before_first_value>
119
+ **Reading a Sensor or Task before it has produced a value throws `UnsetSignalValueError`.** Unlike State, these signals have no initial value — they are explicitly "unset" until the first value arrives.
120
+
121
+ Guard against this with `match` or by checking the signal's status before reading:
122
+
123
+ ```typescript
124
+ const sensor = createSensor(set => {
125
+ const id = setInterval(() => set(Date.now()), 1000)
126
+ return () => clearInterval(id)
127
+ })
128
+
129
+ // Wrong — throws UnsetSignalValueError on first run, before the interval fires
130
+ createEffect(() => {
131
+ console.log(sensor.get())
132
+ })
133
+
134
+ // Correct — match handles the nil (unset) case explicitly
135
+ createEffect(() => {
136
+ match([sensor], {
137
+ ok: ([timestamp]) => console.log(timestamp),
138
+ nil: () => console.log('waiting for first value…'),
139
+ })
140
+ })
141
+ ```
142
+ </sensor_unset_before_first_value>
143
+
144
+ <scope_cleanup_is_synchronous>
145
+ **Scope and Effect cleanup runs synchronously when the returned `Cleanup` function is called.** It does not wait for the current flush to complete. Calling cleanup during a flush (e.g. inside a batch callback) is safe but will immediately dispose the owner and all its children.
146
+ </scope_cleanup_is_synchronous>
@@ -0,0 +1,45 @@
1
+ <overview>
2
+ Where to find things in the @zeix/cause-effect codebase. Read this before locating any source file.
3
+ </overview>
4
+
5
+ <authoritative_documents>
6
+ | What you need | Where to look |
7
+ |---|---|
8
+ | Vision, audience, constraints, non-goals | `REQUIREMENTS.md` |
9
+ | Mental model, non-obvious behaviors, TS constraints | `CLAUDE.md` |
10
+ | Full API reference with examples | `README.md` |
11
+ | Mapping from React/Vue/Angular patterns; when to use each signal type | `GUIDE.md` |
12
+ | Graph engine architecture, node shapes, propagation | `ARCHITECTURE.md` |
13
+ | Public API surface (all exports, types) | `index.ts` |
14
+ | Core graph engine (flags, propagation, flush, ownership) | `src/graph.ts` |
15
+ | Error classes | `src/errors.ts` |
16
+ | Signal base types and type guards | `src/signal.ts` |
17
+ | Shared utilities | `src/util.ts` |
18
+ </authoritative_documents>
19
+
20
+ <signal_source_files>
21
+ Each signal type lives in its own file under `src/nodes/`:
22
+
23
+ | Signal | File | Factory | Type guard |
24
+ |---|---|---|---|
25
+ | State | `src/nodes/state.ts` | `createState()` | `isState()` |
26
+ | Sensor | `src/nodes/sensor.ts` | `createSensor()` | `isSensor()` |
27
+ | Memo | `src/nodes/memo.ts` | `createMemo()` | `isMemo()` |
28
+ | Task | `src/nodes/task.ts` | `createTask()` | `isTask()` |
29
+ | Effect | `src/nodes/effect.ts` | `createEffect()` | — |
30
+ | Slot | `src/nodes/slot.ts` | `createSlot()` | `isSlot()` |
31
+ | Store | `src/nodes/store.ts` | `createStore()` | `isStore()` |
32
+ | List | `src/nodes/list.ts` | `createList()` | `isList()` |
33
+ | Collection | `src/nodes/collection.ts` | `createCollection()` / `deriveCollection()` | `isCollection()` |
34
+
35
+ `match()` and `MatchHandlers` live in `src/nodes/effect.ts` alongside `createEffect`.
36
+ </signal_source_files>
37
+
38
+ <quick_lookup>
39
+ - Changing a signal's public API → read the signal's source file + `index.ts` + the relevant section of `README.md`
40
+ - Changing graph traversal, flags, or flush order → read `src/graph.ts` + `ARCHITECTURE.md`
41
+ - Adding or changing error conditions → read `src/errors.ts`
42
+ - Adding a shared utility → check `src/util.ts` first; add there if it belongs to multiple nodes
43
+ - Checking type constraints or TS-specific decisions → read `CLAUDE.md`
44
+ - Verifying a feature is in scope → read `REQUIREMENTS.md`
45
+ </quick_lookup>
@@ -0,0 +1,55 @@
1
+ <required_reading>
2
+ Load references based on the question type — only what is needed:
3
+
4
+ - API usage or call signatures → references/api-facts.md + references/source-map.md
5
+ - Internal architecture, node shapes, ownership → references/internal-types.md
6
+ - Graph propagation, flags, flush order → references/internal-types.md (then read `ARCHITECTURE.md` if still unclear)
7
+ - Unexpected or counterintuitive behavior → references/non-obvious-behaviors.md
8
+ - A thrown error → references/error-classes.md
9
+ - Design rationale or constraints → `REQUIREMENTS.md` + `CLAUDE.md`
10
+ - Comparing signal types or migration from React/Vue/Angular → references/source-map.md (then read `GUIDE.md`)
11
+ </required_reading>
12
+
13
+ <process>
14
+ ## Step 1: Categorise the question
15
+
16
+ Identify which category applies:
17
+
18
+ | Category | Signal words |
19
+ |---|---|
20
+ | API / call signature | "how do I use", "what does X return", "what are the options for" |
21
+ | Internal architecture | "how does it work internally", "what is a node", "how is ownership tracked" |
22
+ | Graph / propagation | "when does it re-run", "why did X not update", "what is FLAG_CHECK" |
23
+ | Non-obvious behavior | "why does this not work", "is this a bug", "why is `watched` not firing" |
24
+ | Error meaning | "what does X error mean", "when is Y thrown" |
25
+ | Design rationale | "why was X designed this way", "why is null excluded", "why no X signal type" |
26
+ | Signal type comparison | "when should I use Memo vs Task", "what is a Slot", "Sensor vs State" |
27
+
28
+ ## Step 2: Load the relevant references
29
+
30
+ Read only the reference files listed for that category above. Do not load references speculatively.
31
+
32
+ If a reference points to an authoritative document (`ARCHITECTURE.md`, `GUIDE.md`, `REQUIREMENTS.md`, `CLAUDE.md`), read that document only if the reference files do not fully resolve the question.
33
+
34
+ ## Step 3: Read source if needed
35
+
36
+ If the question requires knowing exact implementation details (option defaults, internal flag values, exact type signatures), use references/source-map.md to locate and read the relevant source file.
37
+
38
+ Never guess at implementation details. If uncertain, read the source.
39
+
40
+ ## Step 4: Answer
41
+
42
+ Ground every claim in a source. Cite the file when the answer is non-obvious (e.g. "per `ARCHITECTURE.md`…" or "in `src/nodes/memo.ts`…").
43
+
44
+ For counterintuitive behaviors, include a minimal code example showing the correct pattern alongside the incorrect one. Use the examples in references/non-obvious-behaviors.md as a model.
45
+
46
+ For design rationale questions, distinguish between hard constraints (stated in `REQUIREMENTS.md`) and soft conventions (described in `CLAUDE.md`).
47
+ </process>
48
+
49
+ <success_criteria>
50
+ - Answer is grounded in authoritative sources, not inference
51
+ - Source cited when the answer is non-obvious
52
+ - Counterintuitive behaviors include a correct vs incorrect code example
53
+ - Design answers distinguish constraints from conventions
54
+ - No reference files loaded beyond what the question required
55
+ </success_criteria>
@@ -0,0 +1,63 @@
1
+ <required_reading>
2
+ 1. references/source-map.md — locate the relevant source file(s)
3
+ 2. references/non-obvious-behaviors.md — the bug may be a known gotcha
4
+ 3. references/internal-types.md — if the bug involves graph propagation, ownership, or node state
5
+ 4. references/error-classes.md — if the bug manifests as an unexpected thrown error
6
+ </required_reading>
7
+
8
+ <process>
9
+ ## Step 1: Reproduce
10
+
11
+ Identify the smallest possible reproduction. If a failing test exists, run it:
12
+
13
+ ```bash
14
+ bun test --test-name-pattern "name of failing test"
15
+ ```
16
+
17
+ If no test exists, write one that demonstrates the incorrect behavior before touching any source.
18
+
19
+ ## Step 2: Read the relevant source
20
+
21
+ Use references/source-map.md to locate the source file(s) involved. Read them in full. Do not guess at the cause before reading.
22
+
23
+ ## Step 3: Check known gotchas
24
+
25
+ Read references/non-obvious-behaviors.md. Many apparent bugs are actually expected behaviors:
26
+ - Lookup methods (`byKey`, `at`, `keyAt`, `indexOfKey`) do not create graph edges
27
+ - Conditional signal reads can delay `watched` activation
28
+ - A custom `equals` on an intermediate Memo suppresses entire downstream subgraphs
29
+
30
+ If the reported behavior matches a known non-obvious behavior, explain it rather than patching it.
31
+
32
+ ## Step 4: Check graph-level issues
33
+
34
+ If the bug involves unexpected re-runs, missing updates, or ownership/cleanup problems, read `ARCHITECTURE.md` for flag semantics and propagation rules.
35
+
36
+ ## Step 5: Identify the root cause
37
+
38
+ Trace the failure to its origin — do not fix the symptom. Confirm the root cause by reasoning through the propagation path or ownership chain before writing any fix.
39
+
40
+ ## Step 6: Fix
41
+
42
+ Apply the minimal change that addresses the root cause. Follow existing conventions:
43
+ - Flag names and bitmask operations from `src/graph.ts`
44
+ - Error types from `src/errors.ts`
45
+ - Do not introduce new utilities if `src/util.ts` already covers the need
46
+
47
+ ## Step 7: Verify
48
+
49
+ Run the full test suite:
50
+
51
+ ```bash
52
+ bun test
53
+ ```
54
+
55
+ All tests must pass. If the reproduction test did not exist before Step 1, confirm it now passes too.
56
+ </process>
57
+
58
+ <success_criteria>
59
+ - Root cause identified (not just symptom suppressed)
60
+ - Minimal fix applied
61
+ - Reproduction test passes
62
+ - `bun test` passes with no regressions
63
+ </success_criteria>
@@ -0,0 +1,46 @@
1
+ <required_reading>
2
+ 1. references/source-map.md — locate the relevant source file(s)
3
+ 2. references/api-facts.md — API constraints and callback patterns
4
+ 3. references/non-obvious-behaviors.md — if the change touches graph edges, ownership, or reactive tracking
5
+ </required_reading>
6
+
7
+ <process>
8
+ ## Step 1: Confirm scope
9
+
10
+ Read `REQUIREMENTS.md`. Verify the feature is in scope — the signal type set is complete and new types are explicitly out of scope. If the request would add a new signal type, stop and explain this constraint.
11
+
12
+ ## Step 2: Locate relevant source
13
+
14
+ Use references/source-map.md to identify:
15
+ - Which signal file(s) in `src/nodes/` are involved
16
+ - Which authoritative documents to read (README.md for API shape, ARCHITECTURE.md for graph-level changes)
17
+
18
+ ## Step 3: Read before writing
19
+
20
+ Read the identified source file(s) in full. Do not propose or write any code before doing this.
21
+
22
+ If the change touches graph propagation, flag semantics, or ownership: read `ARCHITECTURE.md` in full.
23
+
24
+ If the change affects the public API surface: read the relevant section of `README.md` and `index.ts`.
25
+
26
+ ## Step 4: Implement
27
+
28
+ Make the change. Follow existing conventions in the file:
29
+ - Naming patterns (`createX`, `isX`, node shape fields)
30
+ - Internal flag usage (defined in `src/graph.ts`)
31
+ - Error types from `src/errors.ts`
32
+ - Utility functions from `src/util.ts` before writing new ones
33
+
34
+ ## Step 5: Verify
35
+
36
+ Run `bun test`. Fix any failures before considering the task done.
37
+
38
+ If the public API changed, check that `README.md` and `index.ts` are consistent with the new behavior.
39
+ </process>
40
+
41
+ <success_criteria>
42
+ - Feature works as specified
43
+ - Follows existing naming and structural conventions
44
+ - `bun test` passes with no regressions
45
+ - Public API surface in `index.ts` and `README.md` is consistent with the change
46
+ </success_criteria>
@@ -0,0 +1,64 @@
1
+ <required_reading>
2
+ 1. references/source-map.md — locate the signal type or module being tested
3
+ 2. references/api-facts.md — correct API usage for test cases
4
+ 3. references/non-obvious-behaviors.md — ensure gotchas are covered
5
+ 4. references/error-classes.md — cover expected error conditions
6
+ </required_reading>
7
+
8
+ <process>
9
+ ## Step 1: Identify what to test
10
+
11
+ Clarify the scope before writing anything:
12
+ - Which signal type or behavior is under test?
13
+ - Is this a new test, an extension of existing coverage, or a regression test for a known bug?
14
+
15
+ ## Step 2: Read the source
16
+
17
+ Use references/source-map.md to locate the relevant source file in `src/nodes/`. Read it in full. Tests must match what the implementation actually does, not what you expect it to do.
18
+
19
+ ## Step 3: Read existing tests
20
+
21
+ Find the existing test file for the signal type (look adjacent to or named after the source file). Read it to understand:
22
+ - Test structure and helper patterns in use
23
+ - Which behaviors are already covered
24
+ - Naming conventions
25
+
26
+ ## Step 4: Identify gaps
27
+
28
+ Cross-reference the source, existing tests, and these reference files:
29
+ - references/non-obvious-behaviors.md — are gotchas covered?
30
+ - references/error-classes.md — are thrown errors tested?
31
+ - references/api-facts.md — are all option variants exercised (e.g. `equals`, `guard`)?
32
+
33
+ ## Step 5: Write tests
34
+
35
+ Follow the conventions of the existing test suite. Each test should:
36
+ - Have a descriptive name that states the expected behavior
37
+ - Be self-contained (no shared mutable state between tests)
38
+ - Cover one specific behavior per test
39
+
40
+ Priority order for coverage:
41
+ 1. Happy path (normal usage)
42
+ 2. Edge cases and boundary conditions
43
+ 3. Expected error throws (use `expect(() => ...).toThrow(ErrorClass)`)
44
+ 4. Non-obvious behaviors from references/non-obvious-behaviors.md
45
+ 5. Interaction with `batch`, `untrack`, `unown` if the signal participates in those
46
+
47
+ ## Step 6: Verify
48
+
49
+ Run the full test suite:
50
+
51
+ ```bash
52
+ bun test
53
+ ```
54
+
55
+ All tests — new and existing — must pass.
56
+ </process>
57
+
58
+ <success_criteria>
59
+ - Tests cover the specified signal type or behavior
60
+ - Each test is self-contained and has a descriptive name
61
+ - Expected error conditions are tested with the correct error class
62
+ - At least one relevant non-obvious behavior is covered if applicable
63
+ - `bun test` passes with no regressions
64
+ </success_criteria>