@smthrs/plan 0.0.0-stage → 1.0.0-rc.12

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 (209) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/LICENSE +21 -0
  3. package/README.md +230 -2
  4. package/THIRD_PARTY_NOTICES.md +48 -0
  5. package/dist/cjs/CachePolicy.d.ts +131 -0
  6. package/dist/cjs/CachePolicy.d.ts.map +1 -0
  7. package/dist/cjs/CachePolicy.js +50 -0
  8. package/dist/cjs/CachePolicy.js.map +7 -0
  9. package/dist/cjs/Effects.d.ts +258 -0
  10. package/dist/cjs/Effects.d.ts.map +1 -0
  11. package/dist/cjs/Effects.js +76 -0
  12. package/dist/cjs/Effects.js.map +7 -0
  13. package/dist/cjs/FileSet.d.ts +366 -0
  14. package/dist/cjs/FileSet.d.ts.map +1 -0
  15. package/dist/cjs/FileSet.js +196 -0
  16. package/dist/cjs/FileSet.js.map +7 -0
  17. package/dist/cjs/GraphBuildError.d.ts +120 -0
  18. package/dist/cjs/GraphBuildError.d.ts.map +1 -0
  19. package/dist/cjs/GraphBuildError.js +71 -0
  20. package/dist/cjs/GraphBuildError.js.map +7 -0
  21. package/dist/cjs/KeyMaterial.d.ts +107 -0
  22. package/dist/cjs/KeyMaterial.d.ts.map +1 -0
  23. package/dist/cjs/KeyMaterial.js +64 -0
  24. package/dist/cjs/KeyMaterial.js.map +7 -0
  25. package/dist/cjs/Node.d.ts +677 -0
  26. package/dist/cjs/Node.d.ts.map +1 -0
  27. package/dist/cjs/Node.js +229 -0
  28. package/dist/cjs/Node.js.map +7 -0
  29. package/dist/cjs/Placement.d.ts +117 -0
  30. package/dist/cjs/Placement.d.ts.map +1 -0
  31. package/dist/cjs/Placement.js +46 -0
  32. package/dist/cjs/Placement.js.map +7 -0
  33. package/dist/cjs/Plan.d.ts +489 -0
  34. package/dist/cjs/Plan.d.ts.map +1 -0
  35. package/dist/cjs/Plan.js +511 -0
  36. package/dist/cjs/Plan.js.map +7 -0
  37. package/dist/cjs/PlanDiff.d.ts +77 -0
  38. package/dist/cjs/PlanDiff.d.ts.map +1 -0
  39. package/dist/cjs/PlanDiff.js +149 -0
  40. package/dist/cjs/PlanDiff.js.map +7 -0
  41. package/dist/cjs/Planned.d.ts +121 -0
  42. package/dist/cjs/Planned.d.ts.map +1 -0
  43. package/dist/cjs/Planned.js +72 -0
  44. package/dist/cjs/Planned.js.map +7 -0
  45. package/dist/cjs/Repetition.d.ts +31 -0
  46. package/dist/cjs/Repetition.d.ts.map +1 -0
  47. package/dist/cjs/Repetition.js +36 -0
  48. package/dist/cjs/Repetition.js.map +7 -0
  49. package/dist/cjs/Scheduling.d.ts +89 -0
  50. package/dist/cjs/Scheduling.d.ts.map +1 -0
  51. package/dist/cjs/Scheduling.js +76 -0
  52. package/dist/cjs/Scheduling.js.map +7 -0
  53. package/dist/cjs/StepKey.d.ts +354 -0
  54. package/dist/cjs/StepKey.d.ts.map +1 -0
  55. package/dist/cjs/StepKey.js +313 -0
  56. package/dist/cjs/StepKey.js.map +7 -0
  57. package/dist/cjs/index.d.ts +91 -0
  58. package/dist/cjs/index.d.ts.map +1 -0
  59. package/dist/cjs/index.js +59 -0
  60. package/dist/cjs/index.js.map +7 -0
  61. package/dist/cjs/internal/ConflictAnnotation.d.ts +59 -0
  62. package/dist/cjs/internal/ConflictAnnotation.d.ts.map +1 -0
  63. package/dist/cjs/internal/ConflictAnnotation.js +247 -0
  64. package/dist/cjs/internal/ConflictAnnotation.js.map +7 -0
  65. package/dist/cjs/internal/EffectCandidates.d.ts +19 -0
  66. package/dist/cjs/internal/EffectCandidates.d.ts.map +1 -0
  67. package/dist/cjs/internal/EffectCandidates.js +92 -0
  68. package/dist/cjs/internal/EffectCandidates.js.map +7 -0
  69. package/dist/cjs/internal/JsonMirror.d.ts +34 -0
  70. package/dist/cjs/internal/JsonMirror.d.ts.map +1 -0
  71. package/dist/cjs/internal/JsonMirror.js +195 -0
  72. package/dist/cjs/internal/JsonMirror.js.map +7 -0
  73. package/dist/cjs/internal/SchemaIdentity.d.ts +140 -0
  74. package/dist/cjs/internal/SchemaIdentity.d.ts.map +1 -0
  75. package/dist/cjs/internal/SchemaIdentity.js +718 -0
  76. package/dist/cjs/internal/SchemaIdentity.js.map +7 -0
  77. package/dist/cjs/internal/effects.d.ts +245 -0
  78. package/dist/cjs/internal/effects.d.ts.map +1 -0
  79. package/dist/cjs/internal/effects.js +290 -0
  80. package/dist/cjs/internal/effects.js.map +7 -0
  81. package/dist/cjs/internal/node.d.ts +526 -0
  82. package/dist/cjs/internal/node.d.ts.map +1 -0
  83. package/dist/cjs/internal/node.js +313 -0
  84. package/dist/cjs/internal/node.js.map +7 -0
  85. package/dist/cjs/package.json +1 -0
  86. package/dist/cjs/test/PlanFixtures.d.ts +127 -0
  87. package/dist/cjs/test/PlanFixtures.d.ts.map +1 -0
  88. package/dist/cjs/test/PlanFixtures.js +59 -0
  89. package/dist/cjs/test/PlanFixtures.js.map +7 -0
  90. package/dist/esm/CachePolicy.d.ts +131 -0
  91. package/dist/esm/CachePolicy.d.ts.map +1 -0
  92. package/dist/esm/CachePolicy.js +117 -0
  93. package/dist/esm/CachePolicy.js.map +1 -0
  94. package/dist/esm/Effects.d.ts +258 -0
  95. package/dist/esm/Effects.d.ts.map +1 -0
  96. package/dist/esm/Effects.js +178 -0
  97. package/dist/esm/Effects.js.map +1 -0
  98. package/dist/esm/FileSet.d.ts +366 -0
  99. package/dist/esm/FileSet.d.ts.map +1 -0
  100. package/dist/esm/FileSet.js +398 -0
  101. package/dist/esm/FileSet.js.map +1 -0
  102. package/dist/esm/GraphBuildError.d.ts +120 -0
  103. package/dist/esm/GraphBuildError.d.ts.map +1 -0
  104. package/dist/esm/GraphBuildError.js +145 -0
  105. package/dist/esm/GraphBuildError.js.map +1 -0
  106. package/dist/esm/KeyMaterial.d.ts +107 -0
  107. package/dist/esm/KeyMaterial.d.ts.map +1 -0
  108. package/dist/esm/KeyMaterial.js +92 -0
  109. package/dist/esm/KeyMaterial.js.map +1 -0
  110. package/dist/esm/Node.d.ts +677 -0
  111. package/dist/esm/Node.d.ts.map +1 -0
  112. package/dist/esm/Node.js +586 -0
  113. package/dist/esm/Node.js.map +1 -0
  114. package/dist/esm/Placement.d.ts +117 -0
  115. package/dist/esm/Placement.d.ts.map +1 -0
  116. package/dist/esm/Placement.js +65 -0
  117. package/dist/esm/Placement.js.map +1 -0
  118. package/dist/esm/Plan.d.ts +489 -0
  119. package/dist/esm/Plan.d.ts.map +1 -0
  120. package/dist/esm/Plan.js +718 -0
  121. package/dist/esm/Plan.js.map +1 -0
  122. package/dist/esm/PlanDiff.d.ts +77 -0
  123. package/dist/esm/PlanDiff.d.ts.map +1 -0
  124. package/dist/esm/PlanDiff.js +205 -0
  125. package/dist/esm/PlanDiff.js.map +1 -0
  126. package/dist/esm/Planned.d.ts +121 -0
  127. package/dist/esm/Planned.d.ts.map +1 -0
  128. package/dist/esm/Planned.js +147 -0
  129. package/dist/esm/Planned.js.map +1 -0
  130. package/dist/esm/Repetition.d.ts +31 -0
  131. package/dist/esm/Repetition.d.ts.map +1 -0
  132. package/dist/esm/Repetition.js +24 -0
  133. package/dist/esm/Repetition.js.map +1 -0
  134. package/dist/esm/Scheduling.d.ts +89 -0
  135. package/dist/esm/Scheduling.d.ts.map +1 -0
  136. package/dist/esm/Scheduling.js +82 -0
  137. package/dist/esm/Scheduling.js.map +1 -0
  138. package/dist/esm/StepKey.d.ts +354 -0
  139. package/dist/esm/StepKey.d.ts.map +1 -0
  140. package/dist/esm/StepKey.js +503 -0
  141. package/dist/esm/StepKey.js.map +1 -0
  142. package/dist/esm/index.d.ts +91 -0
  143. package/dist/esm/index.d.ts.map +1 -0
  144. package/dist/esm/index.js +91 -0
  145. package/dist/esm/index.js.map +1 -0
  146. package/dist/esm/internal/ConflictAnnotation.d.ts +59 -0
  147. package/dist/esm/internal/ConflictAnnotation.d.ts.map +1 -0
  148. package/dist/esm/internal/ConflictAnnotation.js +332 -0
  149. package/dist/esm/internal/ConflictAnnotation.js.map +1 -0
  150. package/dist/esm/internal/EffectCandidates.d.ts +19 -0
  151. package/dist/esm/internal/EffectCandidates.d.ts.map +1 -0
  152. package/dist/esm/internal/EffectCandidates.js +86 -0
  153. package/dist/esm/internal/EffectCandidates.js.map +1 -0
  154. package/dist/esm/internal/JsonMirror.d.ts +34 -0
  155. package/dist/esm/internal/JsonMirror.d.ts.map +1 -0
  156. package/dist/esm/internal/JsonMirror.js +204 -0
  157. package/dist/esm/internal/JsonMirror.js.map +1 -0
  158. package/dist/esm/internal/SchemaIdentity.d.ts +140 -0
  159. package/dist/esm/internal/SchemaIdentity.d.ts.map +1 -0
  160. package/dist/esm/internal/SchemaIdentity.js +1002 -0
  161. package/dist/esm/internal/SchemaIdentity.js.map +1 -0
  162. package/dist/esm/internal/effects.d.ts +245 -0
  163. package/dist/esm/internal/effects.d.ts.map +1 -0
  164. package/dist/esm/internal/effects.js +459 -0
  165. package/dist/esm/internal/effects.js.map +1 -0
  166. package/dist/esm/internal/node.d.ts +526 -0
  167. package/dist/esm/internal/node.d.ts.map +1 -0
  168. package/dist/esm/internal/node.js +565 -0
  169. package/dist/esm/internal/node.js.map +1 -0
  170. package/dist/esm/test/PlanFixtures.d.ts +127 -0
  171. package/dist/esm/test/PlanFixtures.d.ts.map +1 -0
  172. package/dist/esm/test/PlanFixtures.js +60 -0
  173. package/dist/esm/test/PlanFixtures.js.map +1 -0
  174. package/docs/README.md +167 -0
  175. package/docs/api.md +1485 -0
  176. package/docs/concepts/authoring-ast.md +204 -0
  177. package/docs/concepts/effects-and-conflicts.md +135 -0
  178. package/docs/concepts/plan-value.md +168 -0
  179. package/docs/concepts/step-keys.md +179 -0
  180. package/docs/guides/author-a-node-graph.md +182 -0
  181. package/docs/guides/compile-a-plan.md +146 -0
  182. package/docs/guides/declare-file-effects.md +143 -0
  183. package/docs/guides/diff-two-plans.md +310 -0
  184. package/docs/installation.md +75 -0
  185. package/docs/quickstart.md +128 -0
  186. package/docs/testing.md +73 -0
  187. package/docs/troubleshooting.md +314 -0
  188. package/package.json +255 -3
  189. package/src/CachePolicy.ts +143 -0
  190. package/src/Effects.ts +302 -0
  191. package/src/FileSet.ts +493 -0
  192. package/src/GraphBuildError.ts +156 -0
  193. package/src/KeyMaterial.ts +111 -0
  194. package/src/Node.ts +969 -0
  195. package/src/Placement.ts +99 -0
  196. package/src/Plan.ts +911 -0
  197. package/src/PlanDiff.ts +244 -0
  198. package/src/Planned.ts +208 -0
  199. package/src/Repetition.ts +33 -0
  200. package/src/Scheduling.ts +154 -0
  201. package/src/StepKey.ts +736 -0
  202. package/src/index.ts +103 -0
  203. package/src/internal/ConflictAnnotation.ts +374 -0
  204. package/src/internal/EffectCandidates.ts +89 -0
  205. package/src/internal/JsonMirror.ts +255 -0
  206. package/src/internal/SchemaIdentity.ts +1096 -0
  207. package/src/internal/effects.ts +543 -0
  208. package/src/internal/node.ts +859 -0
  209. package/src/test/PlanFixtures.ts +76 -0
@@ -0,0 +1,310 @@
1
+ ---
2
+ title: "Diff two plans"
3
+ description: "Compare a re-plan against the plan before it: what the verdict is, how a re-key is attributed to the field that caused it, how a step whose positional id moved is paired by its key, and why the report is in no digest."
4
+ sidebar:
5
+ order: 5
6
+ ---
7
+
8
+ "What is different about this run" is a set comparison, not archaeology.
9
+ `PlanDiff.diff` is that comparison as a value.
10
+
11
+ ## Before you start
12
+
13
+ The examples run with `node <file>.ts` on Node.js 26.4.0 or later and need no
14
+ engine, database, or jj. Compiling a plan needs Effect's `Crypto` service, which
15
+ `@effect/platform-node` supplies. In a project that installed `@smthrs/flows`,
16
+ add this package and `@smthrs/flow` at the version of `@smthrs/flows` you have,
17
+ plus the platform package:
18
+
19
+ ```bash
20
+ npm install @smthrs/plan@1.0.0-rc.12 @smthrs/flow@1.0.0-rc.12 @effect/platform-node
21
+ ```
22
+
23
+ The outputs below are real output from the release that install line names.
24
+
25
+ ## Compare a re-plan
26
+
27
+ Create `diff.ts`. It compiles the two drafts from
28
+ [Compile drafts into a plan](./compile-a-plan.md), edits one field of one
29
+ draft's body, compiles again, and compares:
30
+
31
+ ```ts
32
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
33
+ import * as KeyMaterial from "@smthrs/plan/KeyMaterial"
34
+ import * as Plan from "@smthrs/plan/Plan"
35
+ import * as PlanDiff from "@smthrs/plan/PlanDiff"
36
+ import * as Effect from "effect/Effect"
37
+
38
+ const readPr: Plan.NodeDraft = {
39
+ id: "read-pr",
40
+ material: {
41
+ version: KeyMaterial.version,
42
+ kind: "sealed",
43
+ body: { action: "read-pr", pr: 4821 },
44
+ inputs: [],
45
+ layers: [],
46
+ capabilities: ["net:get"]
47
+ },
48
+ effects: { reads: [], writes: ["pr.json"], boundaryMode: "hard" }
49
+ }
50
+
51
+ const runTests: Plan.NodeDraft = {
52
+ id: "run-tests",
53
+ material: {
54
+ version: KeyMaterial.version,
55
+ kind: "sealed",
56
+ body: { action: "run-tests" },
57
+ inputs: [{ _tag: "Ref", from: "read-pr", path: [] }],
58
+ layers: [],
59
+ capabilities: []
60
+ },
61
+ effects: { reads: ["pr.json"], writes: ["report.json"], boundaryMode: "hard" }
62
+ }
63
+
64
+ const compile = (nodes: ReadonlyArray<Plan.NodeDraft>) =>
65
+ Plan.compile({ planId: "review-4821", flow: "example/Review", nodes })
66
+
67
+ export const compare = Effect.gen(function*() {
68
+ const before = yield* compile([readPr, runTests])
69
+ const after = yield* compile([
70
+ readPr,
71
+ { ...runTests, material: { ...runTests.material, body: { action: "run-tests", jobs: 4 } } }
72
+ ])
73
+ const changes: PlanDiff.PlanDiff = PlanDiff.diff(before, after)
74
+ return changes.rekeyed.map((entry) => `${entry.id}: ${entry.changed.join(", ")}`)
75
+ }).pipe(Effect.provide(NodeCrypto.layer))
76
+
77
+ console.log(await Effect.runPromise(compare))
78
+ ```
79
+
80
+ ```bash
81
+ node diff.ts
82
+ ```
83
+
84
+ ```text
85
+ [ 'run-tests: body' ]
86
+ ```
87
+
88
+ `compare` answers `["run-tests: body"]`. The edit moved one field of one node's
89
+ body, so that node re-keyed and the report says which field did it.
90
+
91
+ ## The verdict is the key
92
+
93
+ Two nodes with the same key are the same step, under the same id or a moved
94
+ one. Nothing else needs saying, and `diff` says nothing else. Four lists come
95
+ back:
96
+
97
+ | Field | Contents |
98
+ | ----------- | --------------------------------------------------------------------- |
99
+ | `added` | Node ids in `next` that pair with no node in `previous`. |
100
+ | `removed` | Node ids in `previous` that pair with no node in `next`. |
101
+ | `rekeyed` | Nodes paired by id whose key moved, with `from`, `to`, and `changed`. |
102
+ | `unchanged` | Node ids in `next` paired with a node of the same key. |
103
+
104
+ ## The attribution is for a human
105
+
106
+ `Rekeyed.changed` is a list of field labels: `["body", "input[1]"]`. It is
107
+ derived by comparing declarations field by field, and it is deliberately part of
108
+ no digest. Changing how the report reads can never change what a plan hashes to.
109
+
110
+ The labels mirror the hashed declaration: the effect tier `kind`, the fields
111
+ the hashed material body folds, and the input positions:
112
+
113
+ ```text
114
+ kind body layers capabilities effects version nondeterministic placement input[n]
115
+ ```
116
+
117
+ An `input[n]` entry appears when that input's declaration changed, and also when
118
+ the declaration is unchanged but the node it references itself re-keyed. A node
119
+ re-keyed purely by an upstream edit is therefore attributed to the input
120
+ position that references it, even behind an unprojected `Pending`, rather than
121
+ reported as nothing changed.
122
+
123
+ `changed` is empty only when none of the compared fields moved, which means the
124
+ key moved for a reason the comparison does not model. Treat an empty `changed`
125
+ on a re-keyed node as a bug report, not as noise.
126
+
127
+ ## Node ids are positional
128
+
129
+ A node id is the node's structural address in the body, such as
130
+ `root.flow.andThen`, not the name of its step. Adding a step to a flow can move
131
+ the steps around it to new addresses while their keys stay the same. `diff`
132
+ therefore pairs nodes in three passes, each over the nodes no earlier pass
133
+ paired:
134
+
135
+ 1. The same id with the same key: unchanged.
136
+ 2. The same key under a different id: the same step at a new address, reported
137
+ as unchanged under its id in `next`.
138
+ 3. The same id with a different key: re-keyed.
139
+
140
+ Whatever is left in `next` is `added`, and whatever is left in `previous` is
141
+ `removed`. `added`, `rekeyed`, and `unchanged` name nodes by their id in
142
+ `next`; `removed` names nodes by their id in `previous`.
143
+
144
+ Create `inserted.ts`. It compiles two steps, then the same two steps with a
145
+ third inserted before them, so every id shifts by one:
146
+
147
+ ```ts
148
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
149
+ import * as KeyMaterial from "@smthrs/plan/KeyMaterial"
150
+ import * as Plan from "@smthrs/plan/Plan"
151
+ import * as PlanDiff from "@smthrs/plan/PlanDiff"
152
+ import * as Effect from "effect/Effect"
153
+
154
+ /** A step whose id is its position, as a flow's structural ids are. */
155
+ const step = (position: number, action: string): Plan.NodeDraft => ({
156
+ id: `step-${position}`,
157
+ material: {
158
+ version: KeyMaterial.version,
159
+ kind: "sealed",
160
+ body: { action },
161
+ inputs: [],
162
+ layers: [],
163
+ capabilities: []
164
+ },
165
+ effects: { reads: [], writes: [], boundaryMode: "hard" }
166
+ })
167
+
168
+ const compile = (actions: ReadonlyArray<string>) =>
169
+ Plan.compile({
170
+ planId: "checks-1",
171
+ flow: "example/Checks",
172
+ nodes: actions.map((action, index) => step(index, action))
173
+ })
174
+
175
+ const program = Effect.gen(function*() {
176
+ const previous = yield* compile(["lint", "test"])
177
+ const next = yield* compile(["format", "lint", "test"])
178
+ console.log(PlanDiff.diff(previous, next))
179
+ }).pipe(Effect.provide(NodeCrypto.layer))
180
+
181
+ await Effect.runPromise(program)
182
+ ```
183
+
184
+ ```bash
185
+ node inserted.ts
186
+ ```
187
+
188
+ ```text
189
+ {
190
+ added: [ 'step-0' ],
191
+ removed: [],
192
+ rekeyed: [],
193
+ unchanged: [ 'step-1', 'step-2' ]
194
+ }
195
+ ```
196
+
197
+ Only the inserted `format` step is new. `lint` and `test` moved from `step-0`
198
+ and `step-1` to `step-1` and `step-2` with the keys they had, so they come back
199
+ as `unchanged`.
200
+
201
+ ## Comparison uses the key's own JSON semantics
202
+
203
+ Each compared field is projected through the same JSON mirror the keys are
204
+ derived from, so two `Date` bodies a generation apart attribute to `body` rather
205
+ than to nothing. The projection runs no accessor.
206
+
207
+ A field the projection refuses compares by an identity token scoped to that node
208
+ and field. The same value stays equal to itself, two distinct refused values
209
+ never compare equal, and `diff` stays a total function even for a value
210
+ canonical serialization would reject. `diff` never fails and never needs a
211
+ service.
212
+
213
+ ## Reporting a re-plan
214
+
215
+ The pairing this is built for is a re-plan of the same flow: compile the flow
216
+ again against the current source and compare the result with the plan an
217
+ operator last saw. `added` and `removed` are the shape change, `rekeyed` is the
218
+ work that will not come from cache, and `unchanged` is everything a cache hit
219
+ will skip. That reading holds when a step is added or removed and ids move,
220
+ because `diff` pairs a moved step by its key, as
221
+ [Node ids are positional](#node-ids-are-positional) shows.
222
+
223
+ On a body built with `Flow.make`, `added` and `rekeyed` also list structural
224
+ nodes: each `AndThen` that sequences steps, and the `FlowCall` at `root`. Their
225
+ keys fold their children's keys, so they move whenever a child does, and they
226
+ run no work. Count steps as the entries whose material body names an `action`.
227
+
228
+ Create `replan.ts`. It appends a `Summarize` step to a two-step review and
229
+ compares the two plans:
230
+
231
+ ```ts
232
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
233
+ import { Action, Flow, Graph } from "@smthrs/flow"
234
+ import { Node } from "@smthrs/plan"
235
+ import * as Plan from "@smthrs/plan/Plan"
236
+ import * as PlanDiff from "@smthrs/plan/PlanDiff"
237
+ import * as Effect from "effect/Effect"
238
+ import * as Schema from "effect/Schema"
239
+
240
+ const ReadPr = Action.make("review/ReadPr", { payload: { pr: Schema.Number }, success: Schema.String })
241
+ const RunTests = Action.make("review/RunTests", { payload: { diff: Schema.String }, success: Schema.String })
242
+ const Summarize = Action.make("review/Summarize", { payload: { report: Schema.String }, success: Schema.String })
243
+
244
+ const review = (summarize: boolean) =>
245
+ Flow.make("review/Review", {
246
+ payload: { pr: Schema.Number },
247
+ success: Schema.String,
248
+ body: (payload) => {
249
+ const tested = ReadPr.call(payload).pipe(Node.bindPlanned((diff) => RunTests.call({ diff })))
250
+ return summarize ? tested.pipe(Node.bindPlanned((report) => Summarize.call({ report }))) : tested
251
+ }
252
+ })
253
+
254
+ const compile = (summarize: boolean) =>
255
+ Plan.compile({
256
+ planId: "review-4821",
257
+ flow: "review/Review",
258
+ nodes: Graph.drafts(Graph.build(review(summarize), { pr: 4821 }))
259
+ })
260
+
261
+ /** A step is a node whose material body names an action; the rest only compose steps. */
262
+ const isStep = (node: Plan.PlanNode) => {
263
+ const body = node.material.body
264
+ return typeof body === "object" && body !== null && "action" in body
265
+ }
266
+
267
+ const program = Effect.gen(function*() {
268
+ const previous = yield* compile(false)
269
+ const next = yield* compile(true)
270
+ const changes = PlanDiff.diff(previous, next)
271
+ const steps = new Set(next.nodes.filter(isStep).map((node) => node.id))
272
+ console.log({
273
+ added: changes.added,
274
+ rekeyed: changes.rekeyed.map((entry) => entry.id),
275
+ addedSteps: changes.added.filter((id) => steps.has(id)),
276
+ rekeyedSteps: changes.rekeyed.filter((entry) => steps.has(entry.id)).map((entry) => entry.id)
277
+ })
278
+ }).pipe(Effect.provide(NodeCrypto.layer))
279
+
280
+ await Effect.runPromise(program)
281
+ ```
282
+
283
+ ```bash
284
+ node replan.ts
285
+ ```
286
+
287
+ ```text
288
+ {
289
+ added: [ 'root.flow.andThen', 'root.flow.then' ],
290
+ rekeyed: [ 'root.flow', 'root' ],
291
+ addedSteps: [ 'root.flow.then' ],
292
+ rekeyedSteps: []
293
+ }
294
+ ```
295
+
296
+ One step was added: `Summarize`, at `root.flow.then`. `ReadPr` and `RunTests`
297
+ moved under the new `AndThen` with the keys they had, so they are `unchanged`.
298
+ The other three entries are structure: two `AndThen` nodes and the `FlowCall`.
299
+
300
+ The command line prints the same comparison through
301
+ [`smthrs flow plan`](/cli/flow), and the plan card it renders is what
302
+ [`smthrs approvals approve`](/cli/approvals) binds a decision to.
303
+
304
+ ## Next
305
+
306
+ - [Inspect the plan a body builds](../../../flow/docs/guides/inspect-the-plan.md):
307
+ build a flow's graph and read its nodes before anything runs.
308
+ - [Step keys](../concepts/step-keys.md): why an upstream edit re-keys a
309
+ downstream node whose own declaration did not change.
310
+ - [The plan value](../concepts/plan-value.md): the digests a diff sits beside.
@@ -0,0 +1,75 @@
1
+ ---
2
+ title: "Installation"
3
+ description: "Install @smthrs/plan and satisfy its peer requirements."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ ## Requirements
9
+
10
+ - Node.js 26.4.0 or later.
11
+ - `effect` `^4.0.4`. The package's schemas, layers, and error classes are
12
+ Effect values, so it uses the one `effect` your application installs.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install @smthrs/plan@next effect
18
+ ```
19
+
20
+ The `next` tag carries the 1.0 release candidates.
21
+
22
+ That is enough to build node graphs, compile plans, and diff them. Compiling
23
+ asks for Effect's `Crypto` service, which a platform package supplies:
24
+
25
+ ```bash
26
+ pnpm add @effect/platform-node @effect/platform-node-shared
27
+ ```
28
+
29
+ `@smthrs/crypto`, `@smthrs/canonical` and [`@smthrs/keys`](/api/keys) arrive as
30
+ dependencies of this package; you do not install them yourself unless you use
31
+ them directly.
32
+
33
+ ## What persistence adds
34
+
35
+ This package performs no I/O. Recording a compiled plan is
36
+ [`@smthrs/plan-store`](/api/plan-store), which owns the SQL and brings
37
+ [`@smthrs/database`](/api/database) with it.
38
+
39
+ ## Import forms
40
+
41
+ The root entry point re-exports every module as a namespace:
42
+
43
+ ```ts
44
+ import { FileSet, Node, Plan, PlanDiff } from "@smthrs/plan"
45
+ ```
46
+
47
+ Each module is also its own entry point. Prefer this form: it keeps a bundle to
48
+ the modules you actually reach for.
49
+
50
+ ```ts
51
+ import * as Node from "@smthrs/plan/Node"
52
+ import * as Plan from "@smthrs/plan/Plan"
53
+ ```
54
+
55
+ Both forms resolve to the same values.
56
+
57
+ ## What you cannot import
58
+
59
+ `@smthrs/plan/internal/*` is blocked by the export map, and so is
60
+ `@smthrs/plan/<Module>/index`. Node reports `ERR_PACKAGE_PATH_NOT_EXPORTED` for
61
+ either.
62
+
63
+ `@smthrs/plan/test/PlanFixtures` is the exception that is meant for test code:
64
+ it builds drafts and compiles them, and
65
+ [`@smthrs/plan-store`](/api/plan-store) drives the same fixtures against the
66
+ persisted form.
67
+
68
+ ## Browser support
69
+
70
+ The package is browser-safe: it resolves no `node:` built-in and binds no
71
+ database. Compiling, diffing, and building node graphs all work in a browser.
72
+
73
+ ## Next
74
+
75
+ [Quickstart](./quickstart.md) compiles a plan, appends to it, and diffs it.
@@ -0,0 +1,128 @@
1
+ ---
2
+ title: "Quickstart"
3
+ description: "Compile a two-node plan, read its step keys, append a third node at the next generation, and diff the two graphs."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ This quickstart takes one plan from drafts to a keyed graph and grows it. By the
9
+ end you will have a compiled graph with real step keys, a third node added at
10
+ generation 1 with the first two untouched, and a diff that says exactly what
11
+ changed.
12
+
13
+ Nothing here executes a node or writes a row. A plan is inert: persisting one is
14
+ [`@smthrs/plan-store`](/api/plan-store), and driving one is
15
+ [`@smthrs/engine-store`](/api/engine-store)'s `PlanScheduler`.
16
+
17
+ ## Prerequisites
18
+
19
+ - Node.js 26.4.0 or later.
20
+ - A project with the packages installed: `npm install @smthrs/plan@next @effect/platform-node effect`.
21
+
22
+ `@effect/platform-node` supplies Effect's `Crypto` service, which is the only
23
+ thing compiling a plan asks for.
24
+
25
+ ## Declare two nodes
26
+
27
+ Create `quickstart.ts`. A `Plan.NodeDraft` is a node without its key: an id, the
28
+ key material that decides its identity, and the file effects it declares.
29
+
30
+ ```ts
31
+ import * as KeyMaterial from "@smthrs/plan/KeyMaterial"
32
+ import * as Plan from "@smthrs/plan/Plan"
33
+
34
+ /** Everything hashed about one node. `sealed` is the tier that may be reused across runs. */
35
+ const material = (body: unknown, inputs: ReadonlyArray<KeyMaterial.InputRef> = []): KeyMaterial.KeyMaterial => ({
36
+ version: KeyMaterial.version,
37
+ kind: "sealed",
38
+ body,
39
+ inputs,
40
+ layers: [],
41
+ capabilities: []
42
+ })
43
+
44
+ /** Two steps: the second consumes the first's result and reads the file it wrote. */
45
+ const drafts: ReadonlyArray<Plan.NodeDraft> = [
46
+ {
47
+ id: "read-pr",
48
+ material: material({ action: "read-pr", pr: 4821 }),
49
+ effects: { reads: [], writes: ["pr.json"], boundaryMode: "hard" }
50
+ },
51
+ {
52
+ id: "run-tests",
53
+ material: material({ action: "run-tests" }, [{ _tag: "Ref", from: "read-pr", path: [] }]),
54
+ effects: { reads: ["pr.json"], writes: ["report.json"], boundaryMode: "hard" }
55
+ }
56
+ ]
57
+ ```
58
+
59
+ The `Ref` input is the only place `run-tests` names `read-pr`. That one
60
+ declaration becomes both the hashed dependency and the graph edge, so an edge
61
+ and a key can never disagree about what a node consumes.
62
+
63
+ ## Compile, append, diff
64
+
65
+ ```ts
66
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
67
+ import * as PlanDiff from "@smthrs/plan/PlanDiff"
68
+ import * as Effect from "effect/Effect"
69
+
70
+ const program = Effect.gen(function*() {
71
+ const plan = yield* Plan.compile({ planId: "review-4821", flow: "example/Review", nodes: drafts })
72
+ console.log(plan.digest)
73
+
74
+ const grown = yield* Plan.append(plan, [{
75
+ id: "post-comment",
76
+ material: material({ action: "post-comment" }, [{ _tag: "Pending", from: "run-tests" }]),
77
+ effects: { reads: ["report.json"], writes: [], boundaryMode: "hard" }
78
+ }])
79
+
80
+ for (const node of grown.nodes) {
81
+ console.log(node.generation, node.id, node.key, node.dependsOn)
82
+ }
83
+ console.log(grown.generation, grown.baseDigest === plan.digest)
84
+ console.log(PlanDiff.diff(plan, grown).added)
85
+ })
86
+
87
+ await Effect.runPromise(program.pipe(Effect.provide(NodeCrypto.layer), Effect.orDie))
88
+ ```
89
+
90
+ Run the file with your TypeScript runner. The digests on your machine match
91
+ these, because a plan is a pure function of its declarations:
92
+
93
+ ```text
94
+ key1_61edccc875ff69b798e0c3874d167f65ff0a250b9fe67d27558b854fa2832302
95
+ 0 read-pr key1_6353326ab2a03804d6acfe916debc4f04f09f9deb8f0e93c1ebea3cdd983db18 []
96
+ 0 run-tests key1_e58229e3be2f6abf6f818baf8577fe7045edba6f5e88097b633898478da93985 [ 'read-pr' ]
97
+ 1 post-comment key1_0ac2aef1d5d4226849c6009450ad987b47dc02cf39d39ab975db3c673698241d [ 'run-tests' ]
98
+ 1 true
99
+ [ 'post-comment' ]
100
+ ```
101
+
102
+ ## What just happened
103
+
104
+ `Plan.compile` put the drafts in topological order, substituted each dependency
105
+ reference for the already computed key of the node it names, annotated the
106
+ overlap between write sets, and derived the plan digest. It read no file, no
107
+ clock, and no network: the only service it asked for was `Crypto`.
108
+
109
+ `Plan.append` then produced generation 1. The two original nodes kept their id,
110
+ key, edges, and generation byte for byte, and `post-comment` arrived pre-keyed
111
+ against them. The `true` line is `baseDigest` still naming the digest a human
112
+ would have approved, even though `digest` has moved on.
113
+
114
+ Read `post-comment`'s edge: it depends on `run-tests`, which it never consumed a
115
+ value from. Two separate declarations put it there. Its `Pending` input is an
116
+ ordering reference, and it reads `report.json`, which `run-tests` writes, so the
117
+ reader-after-writer pass would have ordered it behind the producer anyway.
118
+
119
+ ## Next steps
120
+
121
+ - [The plan value](./concepts/plan-value.md): generations, digests, and why a
122
+ plan grows instead of being rewritten.
123
+ - [Step keys](./concepts/step-keys.md): what goes into a key, what deliberately
124
+ does not, and why invalidation is re-keying.
125
+ - [Diff two plans](./guides/diff-two-plans.md): report what a re-plan changed,
126
+ attributed to the field that changed it.
127
+ - [Persist a plan](../../plan-store/docs/guides/persist-a-plan.md): the
128
+ append-only store, in production shape.
@@ -0,0 +1,73 @@
1
+ ---
2
+ title: "Testing"
3
+ description: "How to test code that builds plans: the one service to provide, a real in-memory store, and the assertions worth writing."
4
+ ---
5
+
6
+ ## Testing code that builds plans
7
+
8
+ Compiling asks for Effect's `Crypto` service and nothing else, so a plan test
9
+ needs one layer:
10
+
11
+ ```ts
12
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
13
+ import type * as Crypto from "effect/Crypto"
14
+ import * as Effect from "effect/Effect"
15
+
16
+ /** Provides concrete Node cryptography to a test Effect. */
17
+ export const withCrypto = <A, E>(effect: Effect.Effect<A, E, Crypto.Crypto>): Effect.Effect<A, E> =>
18
+ Effect.provide(effect, NodeCrypto.layer)
19
+
20
+ /** The same, for an Effect expected to fail: yields the typed error. */
21
+ export const withCryptoFailure = <A, E>(effect: Effect.Effect<A, E, Crypto.Crypto>): Effect.Effect<E, A> =>
22
+ withCrypto(Effect.flip(effect))
23
+ ```
24
+
25
+ Those two helpers are the whole harness. No clock, no filesystem, and no fake
26
+ anything: a plan is a pure function of its declarations, so a plan test is an
27
+ ordinary value test.
28
+
29
+ Testing persistence is [`@smthrs/plan-store`](/api/plan-store)'s
30
+ [testing page](../../plan-store/docs/testing.md). It drives the same
31
+ fixtures this package ships at `@smthrs/plan/test/PlanFixtures`, so a draft
32
+ builder is written once and used on both sides.
33
+
34
+ ### Assert on keys, not on shape
35
+
36
+ A plan's value is that identity is computable. The assertions that earn their
37
+ place say so:
38
+
39
+ - An edit re-keys its node and its dependent cone, and nothing else.
40
+ - A rename re-keys nothing, because ids are lookup addresses.
41
+ - An ordering edge, a priority change, or a conflict annotation leaves every key
42
+ where it was.
43
+ - Compiling the same drafts twice produces the same digest.
44
+
45
+ ### Mutate the caller's draft after compiling
46
+
47
+ A compiled plan is a deep-frozen snapshot. The cheapest proof is to compile,
48
+ mutate the object you passed in, and assert the stored material and the plan
49
+ digest did not move. The values worth trying are a `Date`, a `URL`, and an
50
+ object with a custom `toJSON`, because each one keys through a different path.
51
+
52
+ ### Keep fixtures out of test modules
53
+
54
+ Importing a `*.test.ts` module registers its suites in the importer, so shared
55
+ fixtures belong in an ordinary module. When one suite pulls a helper out of
56
+ another suite's file, it re-runs every case in that file, and a fast suite
57
+ inherits a slow one's runtime and its timeouts for work it never asked for.
58
+
59
+ ### Keep resource checks independent of wall time
60
+
61
+ The package runs files serially, including the dense compiler cases with their
62
+ original graph sizes, digest and edge-order assertions. FileSet's synchronous
63
+ adversarial matcher tests charge thread CPU time against the existing 100 ms
64
+ budget, excluding descheduling and work on other threads.
65
+
66
+ The planned-value lint test builds a dedicated consumer fixture project against
67
+ the real Node types. It asserts the exact conditional-truthiness diagnostics
68
+ without asking the editor project service to discover other workspace projects.
69
+ An isolated Node 24 run measured 6.96 seconds user plus 1.56 seconds system CPU
70
+ with `/usr/bin/time -p`. This case alone allows 70 seconds: eight times that CPU
71
+ cost, rounded up, for a heavily shared host. The package's 30-second default is
72
+ unchanged. Coverage reports and V8 scratch files use a per-process directory so
73
+ concurrent package invocations cannot clean each other's reports.