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

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 (199) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -2
  4. package/dist/cjs/CachePolicy.d.ts +131 -0
  5. package/dist/cjs/CachePolicy.d.ts.map +1 -0
  6. package/dist/cjs/CachePolicy.js +50 -0
  7. package/dist/cjs/CachePolicy.js.map +7 -0
  8. package/dist/cjs/Effects.d.ts +240 -0
  9. package/dist/cjs/Effects.d.ts.map +1 -0
  10. package/dist/cjs/Effects.js +76 -0
  11. package/dist/cjs/Effects.js.map +7 -0
  12. package/dist/cjs/FileSet.d.ts +288 -0
  13. package/dist/cjs/FileSet.d.ts.map +1 -0
  14. package/dist/cjs/FileSet.js +191 -0
  15. package/dist/cjs/FileSet.js.map +7 -0
  16. package/dist/cjs/GraphBuildError.d.ts +122 -0
  17. package/dist/cjs/GraphBuildError.d.ts.map +1 -0
  18. package/dist/cjs/GraphBuildError.js +71 -0
  19. package/dist/cjs/GraphBuildError.js.map +7 -0
  20. package/dist/cjs/KeyMaterial.d.ts +102 -0
  21. package/dist/cjs/KeyMaterial.d.ts.map +1 -0
  22. package/dist/cjs/KeyMaterial.js +64 -0
  23. package/dist/cjs/KeyMaterial.js.map +7 -0
  24. package/dist/cjs/Node.d.ts +606 -0
  25. package/dist/cjs/Node.d.ts.map +1 -0
  26. package/dist/cjs/Node.js +207 -0
  27. package/dist/cjs/Node.js.map +7 -0
  28. package/dist/cjs/Placement.d.ts +117 -0
  29. package/dist/cjs/Placement.d.ts.map +1 -0
  30. package/dist/cjs/Placement.js +46 -0
  31. package/dist/cjs/Placement.js.map +7 -0
  32. package/dist/cjs/Plan.d.ts +440 -0
  33. package/dist/cjs/Plan.d.ts.map +1 -0
  34. package/dist/cjs/Plan.js +508 -0
  35. package/dist/cjs/Plan.js.map +7 -0
  36. package/dist/cjs/PlanDiff.d.ts +58 -0
  37. package/dist/cjs/PlanDiff.d.ts.map +1 -0
  38. package/dist/cjs/PlanDiff.js +124 -0
  39. package/dist/cjs/PlanDiff.js.map +7 -0
  40. package/dist/cjs/Planned.d.ts +121 -0
  41. package/dist/cjs/Planned.d.ts.map +1 -0
  42. package/dist/cjs/Planned.js +72 -0
  43. package/dist/cjs/Planned.js.map +7 -0
  44. package/dist/cjs/Repetition.d.ts +31 -0
  45. package/dist/cjs/Repetition.d.ts.map +1 -0
  46. package/dist/cjs/Repetition.js +36 -0
  47. package/dist/cjs/Repetition.js.map +7 -0
  48. package/dist/cjs/Scheduling.d.ts +89 -0
  49. package/dist/cjs/Scheduling.d.ts.map +1 -0
  50. package/dist/cjs/Scheduling.js +76 -0
  51. package/dist/cjs/Scheduling.js.map +7 -0
  52. package/dist/cjs/StepKey.d.ts +333 -0
  53. package/dist/cjs/StepKey.d.ts.map +1 -0
  54. package/dist/cjs/StepKey.js +311 -0
  55. package/dist/cjs/StepKey.js.map +7 -0
  56. package/dist/cjs/index.d.ts +91 -0
  57. package/dist/cjs/index.d.ts.map +1 -0
  58. package/dist/cjs/index.js +59 -0
  59. package/dist/cjs/index.js.map +7 -0
  60. package/dist/cjs/internal/ConflictAnnotation.d.ts +59 -0
  61. package/dist/cjs/internal/ConflictAnnotation.d.ts.map +1 -0
  62. package/dist/cjs/internal/ConflictAnnotation.js +247 -0
  63. package/dist/cjs/internal/ConflictAnnotation.js.map +7 -0
  64. package/dist/cjs/internal/EffectCandidates.d.ts +19 -0
  65. package/dist/cjs/internal/EffectCandidates.d.ts.map +1 -0
  66. package/dist/cjs/internal/EffectCandidates.js +92 -0
  67. package/dist/cjs/internal/EffectCandidates.js.map +7 -0
  68. package/dist/cjs/internal/JsonMirror.d.ts +34 -0
  69. package/dist/cjs/internal/JsonMirror.d.ts.map +1 -0
  70. package/dist/cjs/internal/JsonMirror.js +195 -0
  71. package/dist/cjs/internal/JsonMirror.js.map +7 -0
  72. package/dist/cjs/internal/effects.d.ts +245 -0
  73. package/dist/cjs/internal/effects.d.ts.map +1 -0
  74. package/dist/cjs/internal/effects.js +290 -0
  75. package/dist/cjs/internal/effects.js.map +7 -0
  76. package/dist/cjs/internal/node.d.ts +526 -0
  77. package/dist/cjs/internal/node.d.ts.map +1 -0
  78. package/dist/cjs/internal/node.js +312 -0
  79. package/dist/cjs/internal/node.js.map +7 -0
  80. package/dist/cjs/package.json +1 -0
  81. package/dist/cjs/test/PlanFixtures.d.ts +127 -0
  82. package/dist/cjs/test/PlanFixtures.d.ts.map +1 -0
  83. package/dist/cjs/test/PlanFixtures.js +59 -0
  84. package/dist/cjs/test/PlanFixtures.js.map +7 -0
  85. package/dist/esm/CachePolicy.d.ts +131 -0
  86. package/dist/esm/CachePolicy.d.ts.map +1 -0
  87. package/dist/esm/CachePolicy.js +117 -0
  88. package/dist/esm/CachePolicy.js.map +1 -0
  89. package/dist/esm/Effects.d.ts +240 -0
  90. package/dist/esm/Effects.d.ts.map +1 -0
  91. package/dist/esm/Effects.js +167 -0
  92. package/dist/esm/Effects.js.map +1 -0
  93. package/dist/esm/FileSet.d.ts +288 -0
  94. package/dist/esm/FileSet.d.ts.map +1 -0
  95. package/dist/esm/FileSet.js +324 -0
  96. package/dist/esm/FileSet.js.map +1 -0
  97. package/dist/esm/GraphBuildError.d.ts +122 -0
  98. package/dist/esm/GraphBuildError.d.ts.map +1 -0
  99. package/dist/esm/GraphBuildError.js +146 -0
  100. package/dist/esm/GraphBuildError.js.map +1 -0
  101. package/dist/esm/KeyMaterial.d.ts +102 -0
  102. package/dist/esm/KeyMaterial.d.ts.map +1 -0
  103. package/dist/esm/KeyMaterial.js +85 -0
  104. package/dist/esm/KeyMaterial.js.map +1 -0
  105. package/dist/esm/Node.d.ts +606 -0
  106. package/dist/esm/Node.d.ts.map +1 -0
  107. package/dist/esm/Node.js +521 -0
  108. package/dist/esm/Node.js.map +1 -0
  109. package/dist/esm/Placement.d.ts +117 -0
  110. package/dist/esm/Placement.d.ts.map +1 -0
  111. package/dist/esm/Placement.js +65 -0
  112. package/dist/esm/Placement.js.map +1 -0
  113. package/dist/esm/Plan.d.ts +440 -0
  114. package/dist/esm/Plan.d.ts.map +1 -0
  115. package/dist/esm/Plan.js +673 -0
  116. package/dist/esm/Plan.js.map +1 -0
  117. package/dist/esm/PlanDiff.d.ts +58 -0
  118. package/dist/esm/PlanDiff.d.ts.map +1 -0
  119. package/dist/esm/PlanDiff.js +159 -0
  120. package/dist/esm/PlanDiff.js.map +1 -0
  121. package/dist/esm/Planned.d.ts +121 -0
  122. package/dist/esm/Planned.d.ts.map +1 -0
  123. package/dist/esm/Planned.js +147 -0
  124. package/dist/esm/Planned.js.map +1 -0
  125. package/dist/esm/Repetition.d.ts +31 -0
  126. package/dist/esm/Repetition.d.ts.map +1 -0
  127. package/dist/esm/Repetition.js +24 -0
  128. package/dist/esm/Repetition.js.map +1 -0
  129. package/dist/esm/Scheduling.d.ts +89 -0
  130. package/dist/esm/Scheduling.d.ts.map +1 -0
  131. package/dist/esm/Scheduling.js +82 -0
  132. package/dist/esm/Scheduling.js.map +1 -0
  133. package/dist/esm/StepKey.d.ts +333 -0
  134. package/dist/esm/StepKey.d.ts.map +1 -0
  135. package/dist/esm/StepKey.js +478 -0
  136. package/dist/esm/StepKey.js.map +1 -0
  137. package/dist/esm/index.d.ts +91 -0
  138. package/dist/esm/index.d.ts.map +1 -0
  139. package/dist/esm/index.js +91 -0
  140. package/dist/esm/index.js.map +1 -0
  141. package/dist/esm/internal/ConflictAnnotation.d.ts +59 -0
  142. package/dist/esm/internal/ConflictAnnotation.d.ts.map +1 -0
  143. package/dist/esm/internal/ConflictAnnotation.js +332 -0
  144. package/dist/esm/internal/ConflictAnnotation.js.map +1 -0
  145. package/dist/esm/internal/EffectCandidates.d.ts +19 -0
  146. package/dist/esm/internal/EffectCandidates.d.ts.map +1 -0
  147. package/dist/esm/internal/EffectCandidates.js +86 -0
  148. package/dist/esm/internal/EffectCandidates.js.map +1 -0
  149. package/dist/esm/internal/JsonMirror.d.ts +34 -0
  150. package/dist/esm/internal/JsonMirror.d.ts.map +1 -0
  151. package/dist/esm/internal/JsonMirror.js +204 -0
  152. package/dist/esm/internal/JsonMirror.js.map +1 -0
  153. package/dist/esm/internal/effects.d.ts +245 -0
  154. package/dist/esm/internal/effects.d.ts.map +1 -0
  155. package/dist/esm/internal/effects.js +459 -0
  156. package/dist/esm/internal/effects.js.map +1 -0
  157. package/dist/esm/internal/node.d.ts +526 -0
  158. package/dist/esm/internal/node.d.ts.map +1 -0
  159. package/dist/esm/internal/node.js +564 -0
  160. package/dist/esm/internal/node.js.map +1 -0
  161. package/dist/esm/test/PlanFixtures.d.ts +127 -0
  162. package/dist/esm/test/PlanFixtures.d.ts.map +1 -0
  163. package/dist/esm/test/PlanFixtures.js +60 -0
  164. package/dist/esm/test/PlanFixtures.js.map +1 -0
  165. package/docs/README.md +165 -0
  166. package/docs/api.md +1475 -0
  167. package/docs/concepts/authoring-ast.md +203 -0
  168. package/docs/concepts/effects-and-conflicts.md +135 -0
  169. package/docs/concepts/plan-value.md +168 -0
  170. package/docs/concepts/step-keys.md +179 -0
  171. package/docs/guides/author-a-node-graph.md +182 -0
  172. package/docs/guides/compile-a-plan.md +146 -0
  173. package/docs/guides/declare-file-effects.md +143 -0
  174. package/docs/guides/diff-two-plans.md +101 -0
  175. package/docs/installation.md +71 -0
  176. package/docs/quickstart.md +128 -0
  177. package/docs/testing.md +73 -0
  178. package/docs/troubleshooting.md +314 -0
  179. package/package.json +254 -3
  180. package/src/CachePolicy.ts +143 -0
  181. package/src/Effects.ts +284 -0
  182. package/src/FileSet.ts +411 -0
  183. package/src/GraphBuildError.ts +158 -0
  184. package/src/KeyMaterial.ts +106 -0
  185. package/src/Node.ts +871 -0
  186. package/src/Placement.ts +99 -0
  187. package/src/Plan.ts +868 -0
  188. package/src/PlanDiff.ts +199 -0
  189. package/src/Planned.ts +208 -0
  190. package/src/Repetition.ts +33 -0
  191. package/src/Scheduling.ts +154 -0
  192. package/src/StepKey.ts +710 -0
  193. package/src/index.ts +103 -0
  194. package/src/internal/ConflictAnnotation.ts +374 -0
  195. package/src/internal/EffectCandidates.ts +89 -0
  196. package/src/internal/JsonMirror.ts +255 -0
  197. package/src/internal/effects.ts +543 -0
  198. package/src/internal/node.ts +858 -0
  199. package/src/test/PlanFixtures.ts +76 -0
package/src/index.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The plan value: a keyed action graph, its diff, and the step-key compiler
3
+ * that gives every node its identity.
4
+ *
5
+ * A plan is a `Node` graph with every key computed, produced by the plan phase
6
+ * and inert until run. This package is that value and nothing more: it
7
+ * performs no I/O at all and never executes anything. Persisting a plan is
8
+ * `@smthrs/plan-store`; driving one is `@smthrs/engine-store`'s
9
+ * `PlanScheduler`.
10
+ *
11
+ * Above the persisted form sits the authoring AST: `Node` describes a plan as
12
+ * pure data, and `Planned` is the placeholder a body sees where a step result
13
+ * will be. Both build plans; neither runs one.
14
+ *
15
+ * @since 0.1.0
16
+ */
17
+
18
+ /**
19
+ * @since 0.1.0
20
+ * @category errors
21
+ * @slop
22
+ */
23
+ export * as GraphBuildError from "./GraphBuildError.ts"
24
+
25
+ /**
26
+ * @since 1.0.0-rc.0
27
+ * @category models
28
+ */
29
+ export * as CachePolicy from "./CachePolicy.ts"
30
+
31
+ /**
32
+ * @since 1.0.0-rc.0
33
+ * @category models
34
+ */
35
+ export * as Repetition from "./Repetition.ts"
36
+
37
+ /**
38
+ * @since 0.1.0
39
+ * @category models
40
+ * @slop
41
+ */
42
+ export * as KeyMaterial from "./KeyMaterial.ts"
43
+
44
+ /**
45
+ * @since 0.1.0
46
+ * @category models
47
+ * @slop
48
+ */
49
+ export * as FileSet from "./FileSet.ts"
50
+
51
+ /**
52
+ * @since 0.1.0
53
+ * @category models
54
+ * @slop
55
+ */
56
+ export * as Effects from "./Effects.ts"
57
+
58
+ /**
59
+ * @since 0.1.0
60
+ * @category models
61
+ * @slop
62
+ */
63
+ export * as Node from "./Node.ts"
64
+
65
+ /**
66
+ * @since 0.1.0
67
+ * @category models
68
+ * @slop
69
+ */
70
+ export * as Plan from "./Plan.ts"
71
+
72
+ /**
73
+ * @since 0.1.0
74
+ * @category constructors
75
+ * @slop
76
+ */
77
+ export * as PlanDiff from "./PlanDiff.ts"
78
+
79
+ /**
80
+ * @since 1.0.0
81
+ * @category models
82
+ */
83
+ export * as Placement from "./Placement.ts"
84
+
85
+ /**
86
+ * @since 0.1.0
87
+ * @category models
88
+ * @slop
89
+ */
90
+ export * as Planned from "./Planned.ts"
91
+
92
+ /**
93
+ * @since 0.1.0
94
+ * @category constructors
95
+ * @slop
96
+ */
97
+ export * as StepKey from "./StepKey.ts"
98
+
99
+ /**
100
+ * @since 1.0.0
101
+ * @category constructors
102
+ */
103
+ export * as Scheduling from "./Scheduling.ts"
@@ -0,0 +1,374 @@
1
+ /**
2
+ * The compiler's effect analysis: which writers conflict, and which readers
3
+ * must wait for a producer. Its vocabulary is pairs of declared effects; it
4
+ * never re-keys a node, because ordering is not key material.
5
+ *
6
+ * @since 0.1.0
7
+ * @private
8
+ */
9
+
10
+ import * as Effect from "effect/Effect"
11
+ import * as FileSet from "../FileSet.ts"
12
+ import * as KeyMaterial from "../KeyMaterial.ts"
13
+ import type * as Plan from "../Plan.ts"
14
+ import * as EffectCandidates from "./EffectCandidates.ts"
15
+
16
+ /**
17
+ * What {@link annotate} found, by node id and in discovery order: the conflict
18
+ * annotations a node carries and the ordering edges it gains. A frozen node
19
+ * never appears, because its row is never rewritten.
20
+ *
21
+ * @private
22
+ */
23
+ interface Annotations {
24
+ readonly conflicts: ReadonlyMap<string, ReadonlyArray<Plan.ConflictAnnotation>>
25
+ readonly ordering: ReadonlyMap<string, ReadonlyArray<string>>
26
+ }
27
+
28
+ /**
29
+ * A graph the analysis refuses, in the fields a `PlanError` carries.
30
+ *
31
+ * @private
32
+ */
33
+ interface Refusal {
34
+ readonly code: "cycle" | "graph_too_large" | "overlap_forbidden"
35
+ readonly message: string
36
+ }
37
+
38
+ /** @private */
39
+ const refuse = (code: Refusal["code"], message: string) => Effect.fail<Refusal>({ code, message })
40
+
41
+ /**
42
+ * Resolves the pair's verdict. `fail` dominates — a flow that promised
43
+ * disjointness must not be quietly serialized — then `lane`, because "when
44
+ * either writer requests `lane`, both receive lane annotations"; `serialize`
45
+ * is the default whenever an overlap is detected at all.
46
+ *
47
+ * @private
48
+ */
49
+ const pairStrategy = (left: Plan.PairStrategy, right: Plan.PairStrategy): Plan.PairStrategy =>
50
+ left === "fail" || right === "fail" ? "fail" : left === "lane" || right === "lane" ? "lane" : "serialize"
51
+
52
+ /**
53
+ * `stop-merge` dominates for the same reason `lane` does: it is the strategy a
54
+ * declaration opts into, and a pair cannot half-merge.
55
+ *
56
+ * @private
57
+ */
58
+ const pairRuntime = (left: Plan.RuntimeStrategy, right: Plan.RuntimeStrategy): Plan.RuntimeStrategy =>
59
+ left === "stop-merge" || right === "stop-merge" ? "stop-merge" : "delay-rebase"
60
+
61
+ /**
62
+ * Every path a node mutates. A removal moves a path's content exactly as a
63
+ * write does — a reader that runs before it sees different bytes than one that
64
+ * runs after — so both passes below treat the two as one set.
65
+ *
66
+ * @private
67
+ */
68
+ const producedPaths = (effects: Plan.NodeEffects): ReadonlyArray<FileSet.Entry> => [
69
+ ...FileSet.expand(effects.writes),
70
+ ...effects.removes ?? []
71
+ ]
72
+
73
+ /**
74
+ * How an annotation and a refusal name one declared entry.
75
+ *
76
+ * @private
77
+ */
78
+ const describe = (entry: FileSet.Entry): string =>
79
+ typeof entry === "string" ? entry : entry._tag === "TreeArtifact" ? entry.path : entry.include.join(",")
80
+
81
+ /** @private */
82
+ const overlap = (
83
+ left: ReadonlyArray<FileSet.Entry>,
84
+ right: ReadonlyArray<FileSet.Entry>
85
+ ): ReadonlyArray<string> =>
86
+ left.flatMap((leftEntry) =>
87
+ right.some((rightEntry) => FileSet.overlaps(leftEntry, rightEntry)) ? [describe(leftEntry)] : []
88
+ )
89
+
90
+ /**
91
+ * The read declarations of one node that a writer's produced set covers. The
92
+ * conflict pass compares write sets against write sets, so this relation is
93
+ * the one it cannot see. Rendered the way {@link overlap} renders a write,
94
+ * because a refusal names them.
95
+ *
96
+ * @private
97
+ */
98
+ const readOverlap = (
99
+ reads: ReadonlyArray<FileSet.ReadEntry>,
100
+ produced: ReadonlyArray<FileSet.Entry>
101
+ ): ReadonlyArray<string> =>
102
+ reads.flatMap((entry) => produced.some((output) => FileSet.overlaps(entry, output)) ? [describe(entry)] : [])
103
+
104
+ /**
105
+ * Two passes over the graph.
106
+ *
107
+ * The first detects write overlaps and annotates the conflicting pair, adding
108
+ * the ordering edge a `serialize` verdict implies. Nodes already ordered by a
109
+ * dependency path are not conflicts.
110
+ *
111
+ * The second adds reader-after-writer edges: a node that reads a path another
112
+ * node writes is ordered behind its producer. That pair is not a conflict,
113
+ * because nothing needs annotating and no strategy applies. It is a missing
114
+ * edge, so only `dependsOn` grows. When the graph already orders the producer
115
+ * behind its reader by explicit material dependencies, the reader consumes
116
+ * the earlier version. An opposing inferred ordering still fails with `cycle`.
117
+ *
118
+ * Nodes are visited in plan order, so a `serialize` edge always points from
119
+ * the earlier declaration to the later one and can never close a cycle. The
120
+ * first `frozen` nodes were recorded by an earlier generation: append-only
121
+ * means their rows are never rewritten, so a pair discovered during
122
+ * elaboration is annotated on the NEW node only — which is also the node the
123
+ * ordering edge lands on, and the annotation names the other side either way.
124
+ * `replayGenerations` re-derives a recorded plan one generation at a time
125
+ * under that same rule.
126
+ *
127
+ * @since 0.1.0
128
+ * @private
129
+ */
130
+ export const annotate = (
131
+ nodes: ReadonlyArray<Plan.PlanNode>,
132
+ frozen: number,
133
+ replayGenerations: boolean
134
+ ): Effect.Effect<Annotations, Refusal> =>
135
+ Effect.gen(function*() {
136
+ const expanded = new Map(nodes.map((node) => [node.id, {
137
+ produced: producedPaths(node.effects),
138
+ reads: FileSet.expandReads(node.effects.reads)
139
+ }]))
140
+ const candidates = EffectCandidates.make(nodes.map((node) => expanded.get(node.id)!.produced))
141
+ const conflicts = new Map<string, Array<Plan.ConflictAnnotation>>()
142
+ const ordering = new Map<string, Array<string>>()
143
+ const edges = new Map(nodes.map((node) => [node.id, new Set(node.dependsOn)]))
144
+ // Keep declaration sequencing separate from inferred ordering, including
145
+ // for frozen generations whose `dependsOn` already contains inferred edges.
146
+ const declaredEdges = new Map(nodes.map((node) => [node.id, KeyMaterial.dependencies(node.material)]))
147
+ // One index and mutable graph serve every replayed generation. Future
148
+ // producers are filtered at the generation boundary, never re-indexed.
149
+ const ordinals = new Map(nodes.map((node, index) => [node.id, index]))
150
+ const dependents = new Map(nodes.map((node) => [node.id, new Set<string>()]))
151
+ for (const node of nodes) {
152
+ for (const dependency of node.dependsOn) dependents.get(dependency)!.add(node.id)
153
+ }
154
+ const words = Math.ceil(nodes.length / 32)
155
+ const ancestors = new Map<string, Uint32Array>()
156
+ // Charge a pair for bookkeeping as well as its path comparisons. The
157
+ // separate pair ceiling also bounds persisted annotations and edges.
158
+ let work = 0
159
+ let pairs = 0
160
+ let nextYield = 2_048
161
+ const checkpoint = Effect.gen(function*() {
162
+ if (work > 10_000_000 || pairs > 250_000) {
163
+ return yield* refuse(
164
+ "graph_too_large",
165
+ "Plan effect analysis exceeds its work budget (10000000 work units or 250000 candidate pairs)"
166
+ )
167
+ }
168
+ nextYield = Math.min(work + 2_048, 10_000_001)
169
+ yield* Effect.yieldNow
170
+ })
171
+ // Cache transitive ancestors as bitsets. Adding an edge updates its source
172
+ // immediately and invalidates only cached dependents. In particular, dense
173
+ // writers never walk the growing dependency DAG for each conflicting pair.
174
+ const closure = (id: string): Effect.Effect<Uint32Array, Refusal> =>
175
+ Effect.gen(function*() {
176
+ const cached = ancestors.get(id)
177
+ if (cached !== undefined) return cached
178
+ const frame = (id: string) => {
179
+ const bits = new Uint32Array(words)
180
+ const ordinal = ordinals.get(id)!
181
+ bits[ordinal >>> 5]! |= 1 << (ordinal & 31)
182
+ return { id, bits, dependencies: Array.from(edges.get(id)!), next: 0 }
183
+ }
184
+ const stack = [frame(id)]
185
+ while (stack.length > 0) {
186
+ const current = stack[stack.length - 1]!
187
+ const dependency = current.dependencies[current.next]
188
+ if (dependency === undefined) {
189
+ ancestors.set(current.id, current.bits)
190
+ stack.pop()
191
+ continue
192
+ }
193
+ const bits = ancestors.get(dependency)
194
+ if (bits === undefined) {
195
+ stack.push(frame(dependency))
196
+ continue
197
+ }
198
+ for (let word = 0; word < words; word++) {
199
+ current.bits[word]! |= bits[word]!
200
+ if (++work >= nextYield) yield* checkpoint
201
+ }
202
+ current.next++
203
+ }
204
+ return ancestors.get(id)!
205
+ })
206
+ const reaches = (from: string, to: string) =>
207
+ Effect.gen(function*() {
208
+ const bits = yield* closure(from)
209
+ const ordinal = ordinals.get(to)!
210
+ return (bits[ordinal >>> 5]! & (1 << (ordinal & 31))) !== 0
211
+ })
212
+ const addEdge = (from: string, to: string) =>
213
+ Effect.gen(function*() {
214
+ const source = yield* closure(from)
215
+ const target = yield* closure(to)
216
+ for (let word = 0; word < words; word++) {
217
+ source[word]! |= target[word]!
218
+ if (++work >= nextYield) yield* checkpoint
219
+ }
220
+ const stack = [from]
221
+ while (stack.length > 0) {
222
+ for (const dependent of dependents.get(stack.pop()!)!) {
223
+ if (++work >= nextYield) yield* checkpoint
224
+ if (!ancestors.delete(dependent)) continue
225
+ stack.push(dependent)
226
+ }
227
+ }
228
+ edges.get(from)!.add(to)
229
+ dependents.get(to)!.add(from)
230
+ })
231
+ // Routes are only needed for explicit version sequencing and a cycle's
232
+ // diagnostic. Bound and yield these walks too.
233
+ const route = (
234
+ from: string,
235
+ to: string,
236
+ graph: ReadonlyMap<string, Iterable<string>> = edges
237
+ ): Effect.Effect<ReadonlyArray<string> | undefined, Refusal> =>
238
+ Effect.gen(function*() {
239
+ const arrivedFrom = new Map<string, string>()
240
+ const seen = new Set<string>([from])
241
+ const stack = [from]
242
+ while (stack.length > 0) {
243
+ const current = stack.pop()!
244
+ if (current === to) {
245
+ const chain = [current]
246
+ for (let step = arrivedFrom.get(current); step !== undefined; step = arrivedFrom.get(step)) {
247
+ chain.push(step)
248
+ }
249
+ return chain.reverse()
250
+ }
251
+ for (const next of graph.get(current)!) {
252
+ if (++work >= nextYield) yield* checkpoint
253
+ if (seen.has(next)) continue
254
+ seen.add(next)
255
+ arrivedFrom.set(next, current)
256
+ stack.push(next)
257
+ }
258
+ }
259
+ return undefined
260
+ })
261
+ const ranges: Array<{ start: number; end: number }> = []
262
+ let start = frozen
263
+ for (let end = start + 1; end <= nodes.length; end++) {
264
+ if (end === nodes.length || (replayGenerations && nodes[end]!.generation !== nodes[start]!.generation)) {
265
+ ranges.push({ start, end })
266
+ start = end
267
+ }
268
+ }
269
+ for (const { start, end } of ranges) {
270
+ for (let index = start; index < end; index++) {
271
+ const later = nodes[index]!
272
+ const laterProduced = expanded.get(later.id)!.produced
273
+ if (laterProduced.length === 0) continue
274
+ const matches = candidates(laterProduced)
275
+ work += matches.length + laterProduced.length
276
+ if (work >= nextYield) yield* checkpoint
277
+ for (const before of matches) {
278
+ if (before >= index) break
279
+ pairs++
280
+ work += 32
281
+ if (work >= nextYield || pairs > 250_000) yield* checkpoint
282
+ const earlier = nodes[before]!
283
+ const earlierProduced = expanded.get(earlier.id)!.produced
284
+ work += earlierProduced.length * laterProduced.length
285
+ if (work >= nextYield) yield* checkpoint
286
+ const paths = overlap(earlierProduced, laterProduced)
287
+ if (paths.length === 0) continue
288
+ if (yield* reaches(later.id, earlier.id)) continue
289
+ const strategy = pairStrategy(earlier.strategy, later.strategy)
290
+ const runtime = pairRuntime(earlier.runtime, later.runtime)
291
+ if (strategy === "fail") {
292
+ return yield* refuse(
293
+ "overlap_forbidden",
294
+ `Nodes ${earlier.id} and ${later.id} both write ${paths.join(", ")}`
295
+ )
296
+ }
297
+ const annotation = (other: string): Plan.ConflictAnnotation => ({ with: other, paths, strategy, runtime })
298
+ if (before >= start) {
299
+ const previous = conflicts.get(earlier.id) ?? []
300
+ previous.push(annotation(later.id))
301
+ conflicts.set(earlier.id, previous)
302
+ }
303
+ const previous = conflicts.get(later.id) ?? []
304
+ previous.push(annotation(earlier.id))
305
+ conflicts.set(later.id, previous)
306
+ if (strategy === "serialize") {
307
+ const added = ordering.get(later.id) ?? []
308
+ added.push(earlier.id)
309
+ ordering.set(later.id, added)
310
+ yield* addEdge(later.id, earlier.id)
311
+ }
312
+ }
313
+ }
314
+ // Reader-after-writer. A node that READS a path another node WRITES was
315
+ // ordered by nothing: the pass above compares write sets against write
316
+ // sets, so reader and writer could be admitted in the same wavefront
317
+ // round. The reader then measures pre-producer bytes and — because the
318
+ // dispatch key honestly folds the digest it measured — caches that wrong
319
+ // execution as a legitimate one. `PlanScheduler.measure` already assumes
320
+ // "their preceding producer has settled"; this pass is what makes that
321
+ // assumption true. Explicit read-before-write sequencing instead consumes
322
+ // an earlier version: a source input, or another preceding writer's output.
323
+ //
324
+ // Ordering only, exactly like a `serialize` edge: it enters `dependsOn`
325
+ // and never key material, because the reader computes the same result
326
+ // either way and its content dependence is already keyed by the hermetic
327
+ // boundary digests measured at dispatch.
328
+ for (let index = start; index < end; index++) {
329
+ const reader = nodes[index]!
330
+ // Append-only: a frozen node's row is never rewritten, so the edge lands
331
+ // on the new node only — the same rule the conflict pass follows.
332
+ const reads = expanded.get(reader.id)!.reads
333
+ if (reads.length === 0) continue
334
+ const matches = candidates(reads)
335
+ work += matches.length + reads.length
336
+ if (work >= nextYield) yield* checkpoint
337
+ for (const writerIndex of matches) {
338
+ if (writerIndex >= end) break
339
+ pairs++
340
+ work += 32
341
+ if (work >= nextYield || pairs > 250_000) yield* checkpoint
342
+ const writer = nodes[writerIndex]!
343
+ if (writer.id === reader.id) continue
344
+ const produced = expanded.get(writer.id)!.produced
345
+ work += reads.length * produced.length
346
+ if (work >= nextYield) yield* checkpoint
347
+ const paths = readOverlap(reads, produced)
348
+ if (paths.length === 0) continue
349
+ // Already ordered, by a material edge, a serialize edge, or a path
350
+ // through either.
351
+ if (yield* reaches(reader.id, writer.id)) continue
352
+ // Explicit sequencing selects the version being read. Do not reverse
353
+ // it just because a later node writes the same path. Only declared
354
+ // Ref/Pending paths establish this intent; a serialize edge or an
355
+ // earlier inferred producer edge cannot silently select old bytes.
356
+ if (yield* reaches(writer.id, reader.id)) {
357
+ if ((yield* route(writer.id, reader.id, declaredEdges)) !== undefined) continue
358
+ const contradiction = (yield* route(writer.id, reader.id))!
359
+ return yield* refuse(
360
+ "cycle",
361
+ `Plan cycle: node ${reader.id} reads ${paths.join(", ")}, which node ${writer.id} produces, ` +
362
+ `so ${reader.id} must follow ${writer.id}, but ${writer.id} already depends on ${reader.id} ` +
363
+ `through ${contradiction.join(" -> ")}`
364
+ )
365
+ }
366
+ const added = ordering.get(reader.id) ?? []
367
+ added.push(writer.id)
368
+ ordering.set(reader.id, added)
369
+ yield* addEdge(reader.id, writer.id)
370
+ }
371
+ }
372
+ }
373
+ return { conflicts, ordering }
374
+ })
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Rebuildable candidate index for the compiler's existing overlap predicate.
3
+ * Exact paths and tree prefixes share a trie keyed by case-folded segments, the
4
+ * form FileSet.overlaps compares. A plain string is a literal path there, even
5
+ * one holding `*`. Globs stay conservative: their exclusions and pair semantics
6
+ * are decided by FileSet.overlaps, never here.
7
+ * Nothing in this index becomes persisted plan or key material.
8
+ * @since 0.1.0
9
+ * @private
10
+ */
11
+
12
+ import * as FileSet from "../FileSet.ts"
13
+
14
+ interface Branch {
15
+ readonly children: Map<string, Branch>
16
+ readonly exact: Set<number>
17
+ readonly trees: Set<number>
18
+ }
19
+
20
+ const branch = (): Branch => ({ children: new Map(), exact: new Set(), trees: new Set() })
21
+ const add = (target: Set<number>, values: ReadonlySet<number>): void => {
22
+ for (const value of values) target.add(value)
23
+ }
24
+ /** A Glob has no single trie position, so it stays conservative. */
25
+ const patternLike = (entry: FileSet.Entry): entry is FileSet.Glob => typeof entry !== "string" && entry._tag === "Glob"
26
+ const ordered = (values: ReadonlySet<number>): Array<number> => Array.from(values).sort((left, right) => left - right)
27
+
28
+ /**
29
+ * Selects possible producers in declaration order; callers retain the final
30
+ * FileSet.overlaps check for every selected pair.
31
+ * @since 0.1.0
32
+ * @private
33
+ */
34
+ export const make = (produced: ReadonlyArray<ReadonlyArray<FileSet.Entry>>) => {
35
+ const root = branch()
36
+ const all = new Set<number>()
37
+ const globs = new Set<number>()
38
+ for (let owner = 0; owner < produced.length; owner++) {
39
+ for (const entry of produced[owner]!) {
40
+ all.add(owner)
41
+ if (patternLike(entry)) {
42
+ globs.add(owner)
43
+ continue
44
+ }
45
+ const path = typeof entry === "string" ? entry : entry.path
46
+ let current = root
47
+ for (const segment of FileSet.folded(path).split("/")) {
48
+ let child = current.children.get(segment)
49
+ if (child === undefined) {
50
+ child = branch()
51
+ current.children.set(segment, child)
52
+ }
53
+ current = child
54
+ }
55
+ const owners = typeof entry === "string" ? current.exact : current.trees
56
+ owners.add(owner)
57
+ }
58
+ }
59
+
60
+ return (entries: ReadonlyArray<FileSet.Entry>): ReadonlyArray<number> => {
61
+ const found = new Set<number>()
62
+ for (const entry of entries) {
63
+ // A glob can overlap every other glob/tree; literal matches and
64
+ // exclusions remain the final predicate's responsibility as well.
65
+ if (patternLike(entry)) return ordered(all)
66
+ add(found, globs)
67
+ const path = typeof entry === "string" ? entry : entry.path
68
+ let current: Branch | undefined = root
69
+ for (const segment of FileSet.folded(path).split("/")) {
70
+ current = current.children.get(segment)
71
+ if (current === undefined) break
72
+ add(found, current.trees)
73
+ }
74
+ if (current === undefined) continue
75
+ add(found, current.exact)
76
+ if (typeof entry === "string") continue
77
+ const stack = Array.from(current.children.values())
78
+ while (stack.length > 0) {
79
+ const descendant = stack.pop()!
80
+ add(found, descendant.exact)
81
+ add(found, descendant.trees)
82
+ for (const child of descendant.children.values()) stack.push(child)
83
+ }
84
+ }
85
+ // Original declaration order controls annotations, conflict precedence,
86
+ // and cycle diagnostics, irrespective of trie or Set traversal order.
87
+ return ordered(found)
88
+ }
89
+ }