semantifold 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +6 -3
  2. package/bin/semantifold.js +6 -0
  3. package/build/bin/semantifold.d.ts +3 -0
  4. package/build/bin/semantifold.d.ts.map +1 -0
  5. package/build/bin/semantifold.js +5 -0
  6. package/build/index.d.ts +8 -0
  7. package/build/index.d.ts.map +1 -1
  8. package/build/index.js +9 -1
  9. package/build/src/artifact-path.d.ts +16 -0
  10. package/build/src/artifact-path.d.ts.map +1 -1
  11. package/build/src/artifact-path.js +29 -3
  12. package/build/src/backends/c-check.d.ts +7 -0
  13. package/build/src/backends/c-check.d.ts.map +1 -0
  14. package/build/src/backends/c-check.js +65 -0
  15. package/build/src/backends/check-plan.d.ts +96 -0
  16. package/build/src/backends/check-plan.d.ts.map +1 -0
  17. package/build/src/backends/check-plan.js +135 -0
  18. package/build/src/backends/cpp-check.d.ts +7 -0
  19. package/build/src/backends/cpp-check.d.ts.map +1 -0
  20. package/build/src/backends/cpp-check.js +59 -0
  21. package/build/src/backends/csharp-check.d.ts +7 -0
  22. package/build/src/backends/csharp-check.d.ts.map +1 -0
  23. package/build/src/backends/csharp-check.js +125 -0
  24. package/build/src/backends/csharp.d.ts +1 -0
  25. package/build/src/backends/csharp.d.ts.map +1 -1
  26. package/build/src/backends/csharp.js +5 -4
  27. package/build/src/backends/dart-check.d.ts +7 -0
  28. package/build/src/backends/dart-check.d.ts.map +1 -0
  29. package/build/src/backends/dart-check.js +103 -0
  30. package/build/src/backends/dart.d.ts +2 -0
  31. package/build/src/backends/dart.d.ts.map +1 -1
  32. package/build/src/backends/dart.js +5 -5
  33. package/build/src/backends/go-check.d.ts +7 -0
  34. package/build/src/backends/go-check.d.ts.map +1 -0
  35. package/build/src/backends/go-check.js +77 -0
  36. package/build/src/backends/go.d.ts +1 -0
  37. package/build/src/backends/go.d.ts.map +1 -1
  38. package/build/src/backends/go.js +3 -3
  39. package/build/src/backends/java-check.d.ts +21 -0
  40. package/build/src/backends/java-check.d.ts.map +1 -0
  41. package/build/src/backends/java-check.js +73 -0
  42. package/build/src/backends/javascript-check.d.ts +7 -0
  43. package/build/src/backends/javascript-check.d.ts.map +1 -0
  44. package/build/src/backends/javascript-check.js +29 -0
  45. package/build/src/backends/kotlin-check.d.ts +7 -0
  46. package/build/src/backends/kotlin-check.d.ts.map +1 -0
  47. package/build/src/backends/kotlin-check.js +31 -0
  48. package/build/src/backends/kotlin.d.ts +1 -1
  49. package/build/src/backends/kotlin.js +2 -2
  50. package/build/src/backends/php-check.d.ts +7 -0
  51. package/build/src/backends/php-check.d.ts.map +1 -0
  52. package/build/src/backends/php-check.js +21 -0
  53. package/build/src/backends/program.d.ts +7 -0
  54. package/build/src/backends/program.d.ts.map +1 -1
  55. package/build/src/backends/program.js +16 -1
  56. package/build/src/backends/python-check.d.ts +7 -0
  57. package/build/src/backends/python-check.d.ts.map +1 -0
  58. package/build/src/backends/python-check.js +28 -0
  59. package/build/src/backends/ruby-check.d.ts +7 -0
  60. package/build/src/backends/ruby-check.d.ts.map +1 -0
  61. package/build/src/backends/ruby-check.js +21 -0
  62. package/build/src/backends/rust-check.d.ts +7 -0
  63. package/build/src/backends/rust-check.d.ts.map +1 -0
  64. package/build/src/backends/rust-check.js +65 -0
  65. package/build/src/backends/shared.d.ts.map +1 -1
  66. package/build/src/backends/shared.js +29 -1
  67. package/build/src/backends/swift-check.d.ts +7 -0
  68. package/build/src/backends/swift-check.d.ts.map +1 -0
  69. package/build/src/backends/swift-check.js +77 -0
  70. package/build/src/backends/typescript-check.d.ts +7 -0
  71. package/build/src/backends/typescript-check.d.ts.map +1 -0
  72. package/build/src/backends/typescript-check.js +33 -0
  73. package/build/src/backends/zig-check.d.ts +7 -0
  74. package/build/src/backends/zig-check.d.ts.map +1 -0
  75. package/build/src/backends/zig-check.js +70 -0
  76. package/build/src/diagnostic.d.ts +13 -1
  77. package/build/src/diagnostic.d.ts.map +1 -1
  78. package/build/src/diagnostic.js +14 -2
  79. package/build/src/frontends/java.d.ts.map +1 -1
  80. package/build/src/frontends/java.js +23 -1
  81. package/build/src/frontends/javascript-typescript.d.ts.map +1 -1
  82. package/build/src/frontends/javascript-typescript.js +45 -4
  83. package/build/src/frontends/php.d.ts.map +1 -1
  84. package/build/src/frontends/php.js +54 -3
  85. package/build/src/frontends/program.d.ts +6 -0
  86. package/build/src/frontends/program.d.ts.map +1 -1
  87. package/build/src/frontends/program.js +9 -1
  88. package/build/src/frontends/ruby.d.ts.map +1 -1
  89. package/build/src/frontends/ruby.js +55 -5
  90. package/build/src/language-registry.d.ts +54 -0
  91. package/build/src/language-registry.d.ts.map +1 -1
  92. package/build/src/language-registry.js +124 -10
  93. package/build/src/project-build.d.ts +172 -0
  94. package/build/src/project-build.d.ts.map +1 -0
  95. package/build/src/project-build.js +320 -0
  96. package/build/src/project-cli.d.ts +40 -0
  97. package/build/src/project-cli.d.ts.map +1 -0
  98. package/build/src/project-cli.js +145 -0
  99. package/build/src/project-manifest.d.ts +148 -0
  100. package/build/src/project-manifest.d.ts.map +1 -0
  101. package/build/src/project-manifest.js +432 -0
  102. package/build/src/project-reporter.d.ts +81 -0
  103. package/build/src/project-reporter.d.ts.map +1 -0
  104. package/build/src/project-reporter.js +293 -0
  105. package/build/src/project-snapshot.d.ts +129 -0
  106. package/build/src/project-snapshot.d.ts.map +1 -0
  107. package/build/src/project-snapshot.js +362 -0
  108. package/build/src/project-watch-reporter.d.ts +125 -0
  109. package/build/src/project-watch-reporter.d.ts.map +1 -0
  110. package/build/src/project-watch-reporter.js +280 -0
  111. package/build/src/project-watch.d.ts +133 -0
  112. package/build/src/project-watch.d.ts.map +1 -0
  113. package/build/src/project-watch.js +839 -0
  114. package/build/src/publication.d.ts +69 -5
  115. package/build/src/publication.d.ts.map +1 -1
  116. package/build/src/publication.js +246 -57
  117. package/build/src/semantic/effects.js +3 -1
  118. package/build/src/semantic/provenance.d.ts.map +1 -1
  119. package/build/src/semantic/provenance.js +11 -1
  120. package/build/src/semantic/types.d.ts +355 -4
  121. package/build/src/semantic/types.d.ts.map +1 -1
  122. package/build/src/semantic/types.js +92 -3
  123. package/build/src/semantic/validate.js +18 -1
  124. package/build/src/subprocess.d.ts +8 -0
  125. package/build/src/subprocess.d.ts.map +1 -1
  126. package/build/src/subprocess.js +50 -9
  127. package/build/src/target-check.d.ts +52 -0
  128. package/build/src/target-check.d.ts.map +1 -0
  129. package/build/src/target-check.js +796 -0
  130. package/build/src/toolchains.d.ts.map +1 -1
  131. package/build/src/toolchains.js +3 -3
  132. package/docs/adr-expression-breadth.md +186 -0
  133. package/docs/architecture.md +3 -1
  134. package/docs/c.md +4 -0
  135. package/docs/cpp.md +4 -0
  136. package/docs/dart.md +4 -0
  137. package/docs/generated-artifact-publication.md +11 -8
  138. package/docs/kotlin.md +2 -2
  139. package/docs/language-support.md +40 -6
  140. package/docs/parser-qualification.md +1 -1
  141. package/docs/project-build-cli.md +147 -0
  142. package/docs/project-watch.md +60 -0
  143. package/docs/rust.md +4 -0
  144. package/docs/swift.md +2 -0
  145. package/docs/testing.md +70 -3
  146. package/docs/watch-build-pipeline.md +39 -23
  147. package/docs/zig.md +2 -0
  148. package/examples/jsdoc-java-watch/expected-output.txt +3 -0
  149. package/examples/jsdoc-java-watch/semantifold.json +23 -0
  150. package/examples/jsdoc-java-watch/src/main.js +21 -0
  151. package/index.js +8 -0
  152. package/package.json +6 -1
  153. package/src/artifact-path.js +31 -2
  154. package/src/backends/c-check.js +80 -0
  155. package/src/backends/check-plan.js +147 -0
  156. package/src/backends/cpp-check.js +73 -0
  157. package/src/backends/csharp-check.js +139 -0
  158. package/src/backends/csharp.js +4 -3
  159. package/src/backends/dart-check.js +117 -0
  160. package/src/backends/dart.js +4 -4
  161. package/src/backends/go-check.js +89 -0
  162. package/src/backends/go.js +2 -2
  163. package/src/backends/java-check.js +77 -0
  164. package/src/backends/javascript-check.js +33 -0
  165. package/src/backends/kotlin-check.js +41 -0
  166. package/src/backends/kotlin.js +1 -1
  167. package/src/backends/php-check.js +23 -0
  168. package/src/backends/program.js +17 -0
  169. package/src/backends/python-check.js +30 -0
  170. package/src/backends/ruby-check.js +23 -0
  171. package/src/backends/rust-check.js +78 -0
  172. package/src/backends/shared.js +30 -0
  173. package/src/backends/swift-check.js +89 -0
  174. package/src/backends/typescript-check.js +37 -0
  175. package/src/backends/zig-check.js +81 -0
  176. package/src/diagnostic.js +9 -1
  177. package/src/frontends/java.js +24 -0
  178. package/src/frontends/javascript-typescript.js +49 -2
  179. package/src/frontends/php.js +61 -2
  180. package/src/frontends/program.js +9 -0
  181. package/src/frontends/ruby.js +62 -2
  182. package/src/language-registry.js +130 -9
  183. package/src/project-build.js +353 -0
  184. package/src/project-cli.js +155 -0
  185. package/src/project-manifest.js +479 -0
  186. package/src/project-reporter.js +320 -0
  187. package/src/project-snapshot.js +403 -0
  188. package/src/project-watch-reporter.js +303 -0
  189. package/src/project-watch.js +870 -0
  190. package/src/publication.js +275 -58
  191. package/src/semantic/effects.js +1 -0
  192. package/src/semantic/provenance.js +8 -0
  193. package/src/semantic/types.js +101 -2
  194. package/src/semantic/validate.js +26 -0
  195. package/src/subprocess.js +49 -8
  196. package/src/target-check.js +844 -0
  197. package/src/toolchains.js +3 -2
  198. package/tsconfig.json +1 -1
@@ -0,0 +1,870 @@
1
+ // @ts-check
2
+
3
+ import {watch as watchFileSystem} from "node:fs"
4
+ import {lstat} from "node:fs/promises"
5
+ import path from "node:path"
6
+ import {SemantifoldDiagnostic} from "./diagnostic.js"
7
+ import {buildWatchedProject, ProjectBuilder} from "./project-build.js"
8
+ import {ProjectManifestLoader, SemantifoldProject} from "./project-manifest.js"
9
+ import {ProjectSnapshot, ProjectSnapshotBuilder} from "./project-snapshot.js"
10
+ import {ProjectWatchReporter} from "./project-watch-reporter.js"
11
+
12
+ const defaultQuietPeriodMs = 50
13
+ const defaultPollIntervalMs = 1000
14
+ const maximumQuietPeriodMs = 10_000
15
+ const maximumPollIntervalMs = 60_000
16
+ /** @type {WeakMap<ProjectWatchCoordinator, (event: ProjectWatchObservation) => void>} */
17
+ const projectWatchObservers = new WeakMap()
18
+ /** @type {WeakMap<ProjectWatchCoordinator, typeof watchFileSystem>} */
19
+ const projectWatchNativeBackends = new WeakMap()
20
+ /** @type {WeakMap<ProjectWatchCoordinator, ProjectWatchStateReader>} */
21
+ const projectWatchStateReaders = new WeakMap()
22
+
23
+ /**
24
+ * Installs a synchronous lifecycle observer for focused coordination specs.
25
+ * This helper is intentionally not part of the package root API.
26
+ * @param {ProjectWatchCoordinator} coordinator - Coordinator instance.
27
+ * @param {(event: ProjectWatchObservation) => void} observer - Marker observer.
28
+ * @returns {void}
29
+ */
30
+ export function observeProjectWatch(coordinator, observer) {
31
+ if (!(coordinator instanceof ProjectWatchCoordinator) || typeof observer != "function") {
32
+ throw new TypeError("Invalid project watch observer.")
33
+ }
34
+ projectWatchObservers.set(coordinator, observer)
35
+ }
36
+
37
+ /**
38
+ * Creates a coordinator with one instance-owned native event backend for focused fallback specs.
39
+ * This helper is intentionally not part of the package root API.
40
+ * @param {{builder?: ProjectBuilder, manifestLoader?: ProjectManifestLoader, nativeWatch: typeof watchFileSystem, pollIntervalMs?: number, quietPeriodMs?: number, snapshotBuilder?: ProjectSnapshotBuilder, watchStateReader?: ProjectWatchStateReader}} options - Coordinator options and backends.
41
+ * @returns {ProjectWatchCoordinator} Configured coordinator.
42
+ */
43
+ export function createProjectWatchCoordinator(options) {
44
+ if (!isPlainObject(options) || typeof options.nativeWatch != "function" ||
45
+ options.watchStateReader !== undefined && typeof options.watchStateReader != "function") {
46
+ throw new TypeError("Invalid project watch coordinator dependencies.")
47
+ }
48
+ const coordinator = new ProjectWatchCoordinator({
49
+ ...(options.builder === undefined ? {} : {builder: options.builder}),
50
+ ...(options.manifestLoader === undefined ? {} : {manifestLoader: options.manifestLoader}),
51
+ ...(options.pollIntervalMs === undefined ? {} : {pollIntervalMs: options.pollIntervalMs}),
52
+ ...(options.quietPeriodMs === undefined ? {} : {quietPeriodMs: options.quietPeriodMs}),
53
+ ...(options.snapshotBuilder === undefined ? {} : {snapshotBuilder: options.snapshotBuilder})
54
+ })
55
+
56
+ projectWatchNativeBackends.set(coordinator, options.nativeWatch)
57
+ if (options.watchStateReader !== undefined) projectWatchStateReaders.set(coordinator, options.watchStateReader)
58
+
59
+ return coordinator
60
+ }
61
+
62
+ /**
63
+ * Turns filesystem hints into serialized complete project build transactions.
64
+ */
65
+ export class ProjectWatchCoordinator {
66
+ /** @type {ProjectBuilder} */
67
+ #builder
68
+ /** @type {ProjectManifestLoader} */
69
+ #manifestLoader
70
+ /** @type {ProjectSnapshotBuilder} */
71
+ #snapshotBuilder
72
+ #quietPeriodMs
73
+ #pollIntervalMs
74
+ /** @type {ProjectWatchReporter | undefined} */
75
+ #reporter
76
+ /** @type {string | undefined} */
77
+ #manifestPath
78
+ /** @type {SemantifoldProject | undefined} */
79
+ #project
80
+ /** @type {string | undefined} */
81
+ #publicationRoot
82
+ /** @type {ProjectSnapshot | undefined} */
83
+ #lastSnapshot
84
+ /** @type {string | undefined} */
85
+ #suppressedHash
86
+ /** @type {string | undefined} */
87
+ #suppressedInvalidState
88
+ /** @type {Map<string, string>} */
89
+ #watchStates = new Map()
90
+ /** @type {Set<string>} */
91
+ #watchedPaths = new Set()
92
+ /** @type {Map<string, string>} */
93
+ #displayPaths = new Map()
94
+ /** @type {import("node:fs").FSWatcher[]} */
95
+ #subscriptions = []
96
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
97
+ #quietTimer
98
+ /** @type {ReturnType<typeof setInterval> | undefined} */
99
+ #pollTimer
100
+ #polling = false
101
+ #pollActive = false
102
+ #pollFailed = false
103
+ #cycle = 0
104
+ #hintVersion = 0
105
+ #dirty = false
106
+ #cycleDirtyReported = false
107
+ #commitWindow = false
108
+ #hadFailure = false
109
+ #running = false
110
+ #stopping = false
111
+ #finished = false
112
+ #initialCycle = true
113
+ #stopReason = "watch stopped"
114
+ /** @type {Set<string>} */
115
+ #hintPaths = new Set()
116
+ /** @type {AbortController | undefined} */
117
+ #activeController
118
+ /** @type {AbortController | undefined} */
119
+ #publicationController
120
+ /** @type {Promise<void> | undefined} */
121
+ #activeCycle
122
+ /** @type {Promise<void> | undefined} */
123
+ #startup
124
+ /** @type {Promise<Readonly<ProjectWatchResult>> | undefined} */
125
+ #completion
126
+ /**
127
+ * Resolves the sole run completion.
128
+ * @type {(result: Readonly<ProjectWatchResult>) => void}
129
+ */
130
+ #resolveCompletion = () => {}
131
+ /** @type {() => void} */
132
+ #signalHandler
133
+
134
+ /**
135
+ * Creates one idle coordinator without touching the filesystem.
136
+ * @param {{builder?: ProjectBuilder, manifestLoader?: ProjectManifestLoader, pollIntervalMs?: number, quietPeriodMs?: number, snapshotBuilder?: ProjectSnapshotBuilder}} [options] - Collaborators and bounded timing policy.
137
+ */
138
+ constructor(options = {}) {
139
+ if (!isPlainObject(options) || Object.keys(options).some(key =>
140
+ !["builder", "manifestLoader", "pollIntervalMs", "quietPeriodMs", "snapshotBuilder"].includes(key)) ||
141
+ options.builder !== undefined && !(options.builder instanceof ProjectBuilder) ||
142
+ options.manifestLoader !== undefined && !(options.manifestLoader instanceof ProjectManifestLoader) ||
143
+ options.snapshotBuilder !== undefined && !(options.snapshotBuilder instanceof ProjectSnapshotBuilder)) {
144
+ throw new TypeError("Invalid project watch coordinator options.")
145
+ }
146
+ const quietPeriodMs = options.quietPeriodMs ?? defaultQuietPeriodMs
147
+ const pollIntervalMs = options.pollIntervalMs ?? defaultPollIntervalMs
148
+
149
+ if (!validMilliseconds(quietPeriodMs, maximumQuietPeriodMs) || !validMilliseconds(pollIntervalMs, maximumPollIntervalMs)) {
150
+ throw new TypeError("Project watch timing values must be bounded positive integer milliseconds.")
151
+ }
152
+ this.#builder = options.builder ?? new ProjectBuilder()
153
+ this.#manifestLoader = options.manifestLoader ?? new ProjectManifestLoader()
154
+ this.#snapshotBuilder = options.snapshotBuilder ?? new ProjectSnapshotBuilder()
155
+ this.#quietPeriodMs = quietPeriodMs
156
+ this.#pollIntervalMs = pollIntervalMs
157
+ this.#signalHandler = () => {
158
+ void this.stop("termination signal")
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Starts the initial build and remains active until shutdown or startup failure.
164
+ * @param {string} [projectPath] - Project manifest path.
165
+ * @param {ProjectWatchReporter} [reporter] - Watch protocol reporter.
166
+ * @param {{check?: boolean}} [options] - Whether every cycle runs target-owned checks.
167
+ * @returns {Promise<Readonly<ProjectWatchResult>>} Terminal watcher result.
168
+ */
169
+ run(projectPath = "./semantifold.json", reporter = new ProjectWatchReporter(), options = {}) {
170
+ if (this.#running || this.#completion !== undefined || typeof projectPath != "string" || projectPath.length == 0 ||
171
+ !(reporter instanceof ProjectWatchReporter) || !isPlainObject(options) ||
172
+ Object.keys(options).some(key => key != "check") || options.check !== undefined && typeof options.check != "boolean") {
173
+ throw new TypeError("Invalid or repeated project watch run.")
174
+ }
175
+ this.#running = true
176
+ this.#reporter = reporter
177
+ this.#manifestPath = projectPath
178
+ this.#check = options.check === true
179
+ this.#completion = new Promise(resolve => {
180
+ this.#resolveCompletion = resolve
181
+ })
182
+ process.once("SIGINT", this.#signalHandler)
183
+ process.once("SIGTERM", this.#signalHandler)
184
+ const startup = this.#start(this.#check)
185
+
186
+ this.#startup = startup
187
+ const startupSettled = () => {
188
+ if (this.#startup === startup) this.#startup = undefined
189
+ this.#finishStoppedIfIdle()
190
+ }
191
+
192
+ void startup.then(startupSettled, startupSettled)
193
+
194
+ return this.#completion
195
+ }
196
+
197
+ /**
198
+ * Stops admitting work, cancels the exact active operation, and waits through child close and staging cleanup.
199
+ * @param {string} [reason] - Stable shutdown reason.
200
+ * @returns {Promise<Readonly<ProjectWatchResult>>} Terminal watcher result.
201
+ */
202
+ async stop(reason = "shutdown requested") {
203
+ if (typeof reason != "string" || reason.length == 0) throw new TypeError("Project watch stop reason must be non-empty.")
204
+ if (this.#completion === undefined) throw new TypeError("Project watch coordinator has not started.")
205
+ if (!this.#stopping && !this.#finished) {
206
+ this.#stopping = true
207
+ this.#stopReason = reason
208
+ this.#closeEventBackends()
209
+ this.#activeController?.abort(reason)
210
+ this.#publicationController?.abort(reason)
211
+ this.#finishStoppedIfIdle()
212
+ }
213
+
214
+ return this.#completion
215
+ }
216
+
217
+ /**
218
+ * Establishes the validated graph, event backend, and initial cycle.
219
+ * @param {boolean} check - Cycle check mode.
220
+ * @returns {Promise<void>} Startup completion.
221
+ */
222
+ async #start(check) {
223
+ try {
224
+ const project = await this.#manifestLoader.load(this.#manifestPath)
225
+
226
+ if (this.#stopping) return
227
+ const snapshot = await this.#snapshotBuilder.build(project)
228
+
229
+ if (this.#stopping) return
230
+ this.#project = project
231
+ this.#lastSnapshot = snapshot
232
+ this.#setWatchTopology(project)
233
+ this.#watchStates = await this.#readWatchStates()
234
+ if (this.#stopping) return
235
+ this.#reporter?.watchStarted(project.id)
236
+ if (this.#stopping) return
237
+ this.#startEventBackend()
238
+ if (this.#stopping) return
239
+ await this.#admitCycle({changedPaths: [], check, project, snapshot})
240
+ } catch (error) {
241
+ if (!this.#stopping) this.#finishFailed(error)
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Runs exactly one owned cycle.
247
+ * @param {WatchCycleInput} input - Admitted cycle.
248
+ * @returns {Promise<void>}
249
+ */
250
+ async #admitCycle({changedPaths, check, failure, project, snapshot}) {
251
+ if (this.#activeCycle !== undefined || this.#stopping) return
252
+ this.#cycle += 1
253
+ const cycle = this.#cycle
254
+ const activeController = new AbortController()
255
+ const publicationController = new AbortController()
256
+
257
+ this.#activeController = activeController
258
+ this.#publicationController = publicationController
259
+ this.#dirty = false
260
+ this.#cycleDirtyReported = false
261
+ this.#commitWindow = false
262
+ this.#hintPaths.clear()
263
+ this.#reporter?.cycleStarted({
264
+ changedPaths,
265
+ cycle,
266
+ projectId: project?.id ?? this.#project?.id ?? "project",
267
+ ...(snapshot === undefined ? {} : {snapshotHash: snapshot.hash})
268
+ })
269
+ this.#observe({cycle, status: "started", type: "cycle-start"})
270
+ let terminalStatus = /** @type {"cancelled" | "failed" | "recovered" | "succeeded" | "superseded"} */ ("failed")
271
+ /** @type {unknown} */
272
+ let terminalFailure
273
+ /** @type {SemantifoldProject | undefined} */
274
+ let currentProject
275
+ /** @type {ProjectSnapshot | undefined} */
276
+ let currentSnapshot
277
+ const manifestPath = /** @type {string} */ (this.#manifestPath)
278
+ const reporter = /** @type {ProjectWatchReporter} */ (this.#reporter)
279
+ const execute = async () => {
280
+ try {
281
+ if (snapshot === undefined) {
282
+ throw failure ?? new SemantifoldDiagnostic({
283
+ code: "WATCH_SNAPSHOT_UNAVAILABLE",
284
+ language: project?.id ?? this.#project?.id ?? "project",
285
+ message: "The complete watched project graph is unavailable."
286
+ })
287
+ }
288
+ const result = await buildWatchedProject(this.#builder, manifestPath, reporter, {
289
+ check,
290
+ expectedSnapshotHash: snapshot.hash,
291
+ publicationSignal: publicationController.signal,
292
+ signal: activeController.signal,
293
+ validateCurrentSnapshot: async () => {
294
+ const guardHintVersion = this.#hintVersion
295
+ /** @type {SemantifoldProject} */
296
+ let reconciledProject
297
+ /** @type {ProjectSnapshot} */
298
+ let reconciledSnapshot
299
+
300
+ try {
301
+ reconciledProject = await this.#manifestLoader.load(manifestPath)
302
+ reconciledSnapshot = await this.#snapshotBuilder.build(reconciledProject)
303
+ } catch (error) {
304
+ throw new SemantifoldDiagnostic({
305
+ cause: error instanceof Error ? error : new Error(String(error)),
306
+ code: "WATCH_CANDIDATE_SUPERSEDED",
307
+ language: this.#project?.id ?? "project",
308
+ message: "The watched project graph became unavailable before candidate publication."
309
+ })
310
+ }
311
+
312
+ if (snapshot === undefined || reconciledSnapshot.hash != snapshot.hash) {
313
+ throw new SemantifoldDiagnostic({
314
+ code: "WATCH_CANDIDATE_SUPERSEDED",
315
+ language: reconciledProject.id,
316
+ message: "A newer complete project snapshot superseded the completed watch candidate."
317
+ })
318
+ }
319
+ currentProject = reconciledProject
320
+ currentSnapshot = reconciledSnapshot
321
+ this.#commitWindow = true
322
+ if (this.#hintVersion == guardHintVersion) {
323
+ this.#dirty = false
324
+ this.#hintPaths.clear()
325
+ } else {
326
+ this.#dirty = true
327
+ publicationController.abort("A filesystem hint arrived during final snapshot reconciliation.")
328
+ }
329
+ }
330
+ })
331
+
332
+ this.#commitWindow = false
333
+ terminalStatus = this.#hadFailure ? "recovered" : "succeeded"
334
+ this.#hadFailure = false
335
+ this.#suppressedHash = result.snapshotHash
336
+ if (currentProject !== undefined && currentSnapshot !== undefined) {
337
+ this.#project = currentProject
338
+ this.#lastSnapshot = currentSnapshot
339
+ this.#setWatchTopology(currentProject)
340
+ const watchStates = await this.#readWatchStates()
341
+ const changedWatchPaths = changedWatchStatePaths(this.#watchStates, watchStates, this.#displayPaths)
342
+
343
+ if (changedWatchPaths.length == 0) this.#watchStates = watchStates
344
+ else for (const changedPath of changedWatchPaths) this.#hint(changedPath, check)
345
+ }
346
+ this.#reporter?.cycleSucceeded(result, terminalStatus == "recovered")
347
+ } catch (error) {
348
+ terminalFailure = error
349
+ terminalStatus = this.#stopping ? "cancelled" : supersededFailure(error, this.#dirty) ? "superseded" : "failed"
350
+ if (terminalStatus == "failed") {
351
+ this.#hadFailure = true
352
+ if (snapshot !== undefined) this.#suppressedHash = snapshot.hash
353
+ }
354
+ this.#reporter?.cycleFailed(error, terminalStatus)
355
+ } finally {
356
+ this.#commitWindow = false
357
+ }
358
+ }
359
+
360
+ this.#activeCycle = execute()
361
+ await this.#activeCycle
362
+ this.#activeCycle = undefined
363
+ this.#activeController = undefined
364
+ this.#publicationController = undefined
365
+ this.#observe({cycle, status: terminalStatus, type: "cycle-terminal"})
366
+ if (this.#stopping) {
367
+ this.#finishStoppedIfIdle()
368
+ return
369
+ }
370
+ if (this.#initialCycle && terminalStatus == "failed") {
371
+ this.#finishFailed(terminalFailure)
372
+ return
373
+ }
374
+ if (terminalStatus == "succeeded" || terminalStatus == "recovered") this.#initialCycle = false
375
+ if (this.#dirty || terminalStatus == "superseded") {
376
+ queueMicrotask(() => void this.#reconcile(check))
377
+ } else {
378
+ this.#observe({cycle, status: "ready", type: "ready"})
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Re-reads the complete graph after a quiet period or active-cycle dirty marker.
384
+ * @param {boolean} check - Cycle check mode.
385
+ * @returns {Promise<void>} Reconciliation completion.
386
+ */
387
+ async #reconcile(check) {
388
+ if (this.#stopping || this.#activeCycle !== undefined) return
389
+ if (this.#quietTimer !== undefined) {
390
+ clearTimeout(this.#quietTimer)
391
+ this.#quietTimer = undefined
392
+ }
393
+ const hinted = [...this.#hintPaths].sort(compareStrings)
394
+
395
+ this.#hintPaths.clear()
396
+ this.#dirty = false
397
+ let project
398
+ let snapshot
399
+ let snapshotFailure
400
+
401
+ try {
402
+ project = await this.#manifestLoader.load(this.#manifestPath)
403
+ snapshot = await this.#snapshotBuilder.build(project)
404
+ this.#suppressedInvalidState = undefined
405
+ this.#setWatchTopology(project)
406
+ this.#watchStates = await this.#readWatchStates()
407
+ } catch (error) {
408
+ snapshotFailure = error
409
+ try {
410
+ this.#watchStates = await this.#readWatchStates()
411
+ } catch {
412
+ // The build cycle below owns and reports the original complete-graph failure.
413
+ }
414
+ const invalidState = watchStateKey(this.#watchStates)
415
+
416
+ if (invalidState == this.#suppressedInvalidState) {
417
+ this.#reporter?.unchanged(hinted)
418
+ this.#observe({status: "unchanged", type: "reconciled"})
419
+ return
420
+ }
421
+ this.#suppressedInvalidState = invalidState
422
+ }
423
+ if (snapshot !== undefined && snapshot.hash == this.#suppressedHash && !this.#hadFailure) {
424
+ this.#reporter?.unchanged(hinted)
425
+ this.#observe({status: "unchanged", type: "reconciled"})
426
+ return
427
+ }
428
+ const changedPaths = snapshot === undefined
429
+ ? hinted
430
+ : changedSnapshotPaths(this.#lastSnapshot, snapshot,
431
+ path.basename(project?.manifestPath ?? this.#manifestPath ?? "semantifold.json"))
432
+
433
+ if (snapshot !== undefined) this.#lastSnapshot = snapshot
434
+ await this.#admitCycle({changedPaths, check, failure: snapshotFailure, project, snapshot})
435
+ }
436
+
437
+ /**
438
+ * Coalesces one event hint without treating its filename as the build input set.
439
+ * @param {string | undefined} changedPath - Project-relative changed-path hint.
440
+ * @param {boolean} check - Check mode.
441
+ * @returns {void}
442
+ */
443
+ #hint(changedPath, check) {
444
+ if (this.#stopping) return
445
+ this.#hintVersion += 1
446
+ if (changedPath !== undefined) this.#hintPaths.add(changedPath)
447
+ if (this.#activeCycle !== undefined) {
448
+ this.#dirty = true
449
+ if (this.#commitWindow) this.#publicationController?.abort("A filesystem hint reached the active-pointer commit window.")
450
+ if (!this.#cycleDirtyReported) {
451
+ this.#cycleDirtyReported = true
452
+ this.#reporter?.cycleDirty()
453
+ this.#observe({cycle: this.#cycle, status: "dirty", type: "dirty"})
454
+ }
455
+ return
456
+ }
457
+ if (this.#quietTimer !== undefined) clearTimeout(this.#quietTimer)
458
+ this.#quietTimer = setTimeout(() => {
459
+ this.#quietTimer = undefined
460
+ void this.#reconcile(check)
461
+ }, this.#quietPeriodMs)
462
+ }
463
+
464
+ /**
465
+ * Updates the exact manifest/source watch topology.
466
+ * @param {SemantifoldProject} project - Current valid project.
467
+ * @returns {void}
468
+ */
469
+ #setWatchTopology(project) {
470
+ const watchedPaths = new Set([project.manifestPath, ...project.sources.map(({absolutePath}) => absolutePath)])
471
+ const displayPaths = new Map([[project.manifestPath, path.basename(project.manifestPath)]])
472
+
473
+ for (const source of project.sources) displayPaths.set(source.absolutePath, source.path)
474
+ const changed = [...watchedPaths].sort(compareStrings).join("\0") != [...this.#watchedPaths].sort(compareStrings).join("\0")
475
+
476
+ this.#watchedPaths = watchedPaths
477
+ this.#displayPaths = displayPaths
478
+ this.#publicationRoot = project.publicationRoot
479
+ if (changed && this.#subscriptions.length > 0 && !this.#polling && !this.#openNativeSubscriptions()) {
480
+ this.#switchToPolling()
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Starts native subscriptions or their polling fallback.
486
+ * @returns {void}
487
+ */
488
+ #startEventBackend() {
489
+ if (this.#openNativeSubscriptions()) {
490
+ this.#ensurePollingReconciliation()
491
+ this.#reporter?.watchBackend("native")
492
+ this.#observe({status: "native", type: "watch-backend"})
493
+ } else this.#switchToPolling()
494
+ }
495
+
496
+ /**
497
+ * Opens exact file and parent-directory subscriptions.
498
+ * @returns {boolean} Whether native subscriptions opened completely.
499
+ */
500
+ #openNativeSubscriptions() {
501
+ this.#closeSubscriptions()
502
+ const nativeWatch = projectWatchNativeBackends.get(this) ?? watchFileSystem
503
+
504
+ try {
505
+ for (const filename of this.#watchedPaths) {
506
+ const subscription = nativeWatch(filename, () => {
507
+ this.#hint(this.#displayPaths.get(filename), this.#currentCheckMode())
508
+ })
509
+
510
+ subscription.once("error", () => this.#switchToPolling())
511
+ this.#subscriptions.push(subscription)
512
+ }
513
+ const parents = new Set([...this.#watchedPaths].map(filename => path.dirname(filename)))
514
+
515
+ for (const directory of parents) {
516
+ const subscription = nativeWatch(directory, (_, filename) => {
517
+ if (filename == null) {
518
+ this.#hint(undefined, this.#currentCheckMode())
519
+ return
520
+ }
521
+ const absolute = path.join(directory, filename.toString())
522
+
523
+ if (this.#publicationRoot !== undefined && pathsOverlap(this.#publicationRoot, absolute)) return
524
+ this.#hint(this.#displayPaths.get(absolute), this.#currentCheckMode())
525
+ })
526
+
527
+ subscription.once("error", () => this.#switchToPolling())
528
+ this.#subscriptions.push(subscription)
529
+ }
530
+
531
+ return true
532
+ } catch {
533
+ this.#closeSubscriptions()
534
+ return false
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Permanently selects bounded polling after native backend failure.
540
+ * @returns {void}
541
+ */
542
+ #switchToPolling() {
543
+ if (this.#stopping || this.#polling) return
544
+ this.#polling = true
545
+ this.#closeSubscriptions()
546
+ this.#ensurePollingReconciliation()
547
+ this.#reporter?.watchBackend("polling")
548
+ this.#observe({status: "polling", type: "watch-backend"})
549
+ }
550
+
551
+ /**
552
+ * Compares one bounded polling state observation.
553
+ * @returns {Promise<void>} Poll completion.
554
+ */
555
+ async #poll() {
556
+ if (this.#pollActive || this.#stopping) return
557
+ this.#pollActive = true
558
+ try {
559
+ if (this.#suppressedInvalidState !== undefined) {
560
+ try {
561
+ const project = await this.#manifestLoader.load(this.#manifestPath)
562
+
563
+ await this.#snapshotBuilder.build(project)
564
+ if (this.#quietTimer === undefined) this.#hint(undefined, this.#currentCheckMode())
565
+ return
566
+ } catch {
567
+ // An unchanged invalid graph remains one failed cycle until it becomes readable and valid.
568
+ }
569
+ }
570
+ const current = await this.#readWatchStates()
571
+ const changed = changedWatchStatePaths(this.#watchStates, current, this.#displayPaths)
572
+
573
+ this.#pollFailed = false
574
+ if (changed.length == 0) return
575
+ this.#watchStates = current
576
+ for (const changedPath of changed) this.#hint(changedPath, this.#currentCheckMode())
577
+ } catch {
578
+ if (!this.#pollFailed) {
579
+ this.#pollFailed = true
580
+ this.#hint(undefined, this.#currentCheckMode())
581
+ }
582
+ } finally {
583
+ this.#pollActive = false
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Reads the immutable check mode captured by run.
589
+ * @returns {boolean} Active check mode.
590
+ */
591
+ #currentCheckMode() {
592
+ return this.#check
593
+ }
594
+
595
+ /**
596
+ * Reads one detached mutation-sensitive state observation through the selected backend.
597
+ * @returns {Promise<Map<string, string>>} Stable ordered path states.
598
+ */
599
+ #readWatchStates() {
600
+ const reader = projectWatchStateReaders.get(this) ?? readWatchStates
601
+
602
+ return reader(new Set(this.#watchedPaths))
603
+ }
604
+
605
+ #check = false
606
+
607
+ /**
608
+ * Starts one bounded reconciliation interval shared by native and fallback modes.
609
+ * @returns {void}
610
+ */
611
+ #ensurePollingReconciliation() {
612
+ this.#pollTimer ??= setInterval(() => void this.#poll(), this.#pollIntervalMs)
613
+ }
614
+
615
+ /**
616
+ * Closes every native subscription.
617
+ * @returns {void}
618
+ */
619
+ #closeSubscriptions() {
620
+ for (const subscription of this.#subscriptions) subscription.close()
621
+ this.#subscriptions = []
622
+ }
623
+
624
+ /**
625
+ * Closes subscriptions and pending reconciliation timers.
626
+ * @returns {void}
627
+ */
628
+ #closeEventBackends() {
629
+ if (this.#quietTimer !== undefined) clearTimeout(this.#quietTimer)
630
+ if (this.#pollTimer !== undefined) clearInterval(this.#pollTimer)
631
+ this.#quietTimer = undefined
632
+ this.#pollTimer = undefined
633
+ this.#closeSubscriptions()
634
+ }
635
+
636
+ /**
637
+ * Resolves shutdown only after startup and the active cycle have both relinquished ownership.
638
+ * @returns {void}
639
+ */
640
+ #finishStoppedIfIdle() {
641
+ if (this.#stopping && this.#startup === undefined && this.#activeCycle === undefined) this.#finishStopped()
642
+ }
643
+
644
+ /**
645
+ * Emits and resolves one clean watcher terminal.
646
+ * @returns {void}
647
+ */
648
+ #finishStopped() {
649
+ if (this.#finished) return
650
+ this.#finished = true
651
+ this.#running = false
652
+ this.#closeEventBackends()
653
+ this.#removeSignalHandlers()
654
+ this.#reporter?.watchStopped(this.#stopReason)
655
+ const result = Object.freeze({cycles: this.#cycle, reason: this.#stopReason, status: /** @type {const} */ ("stopped")})
656
+
657
+ this.#observe({status: "stopped", type: "stopped"})
658
+ this.#resolveCompletion(result)
659
+ }
660
+
661
+ /**
662
+ * Emits and resolves one startup-failure watcher terminal.
663
+ * @param {unknown} failure - Startup failure.
664
+ * @returns {void}
665
+ */
666
+ #finishFailed(failure) {
667
+ if (this.#finished) return
668
+ this.#finished = true
669
+ this.#running = false
670
+ this.#closeEventBackends()
671
+ this.#removeSignalHandlers()
672
+ this.#reporter?.watchFailed(failure)
673
+ const result = Object.freeze({cycles: this.#cycle, reason: "startup failure", status: /** @type {const} */ ("failed")})
674
+
675
+ this.#observe({status: "failed", type: "stopped"})
676
+ this.#resolveCompletion(result)
677
+ }
678
+
679
+ /**
680
+ * Removes process signal handlers owned by this coordinator.
681
+ * @returns {void}
682
+ */
683
+ #removeSignalHandlers() {
684
+ process.removeListener("SIGINT", this.#signalHandler)
685
+ process.removeListener("SIGTERM", this.#signalHandler)
686
+ }
687
+
688
+ /**
689
+ * Emits one internal deterministic lifecycle marker.
690
+ * @param {ProjectWatchObservation} event - Marker.
691
+ * @returns {void}
692
+ */
693
+ #observe(event) {
694
+ projectWatchObservers.get(this)?.(Object.freeze(event))
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Reads mutation-sensitive states only as polling/event deduplication hints.
700
+ * Content hashes remain authoritative for cycle admission.
701
+ * @param {Readonly<Set<string>>} filenames - Exact watched graph paths.
702
+ * @returns {Promise<Map<string, string>>} Stable ordered path states.
703
+ */
704
+ async function readWatchStates(filenames) {
705
+ const result = new Map()
706
+
707
+ for (const filename of [...filenames].sort(compareStrings)) {
708
+ try {
709
+ const status = await lstat(filename, {bigint: true})
710
+
711
+ result.set(filename, [status.dev, status.ino, status.mode, status.size, status.mtimeNs, status.ctimeNs].map(String).join(":"))
712
+ } catch (error) {
713
+ if (!isErrorCode(error, "ENOENT")) throw error
714
+ result.set(filename, "missing")
715
+ }
716
+ }
717
+
718
+ return result
719
+ }
720
+
721
+ /**
722
+ * @typedef {(filenames: Readonly<Set<string>>) => Promise<Map<string, string>>} ProjectWatchStateReader
723
+ */
724
+
725
+ /**
726
+ * Locates polling changes without treating metadata as build identity.
727
+ * @param {Map<string, string>} previous - Previous states.
728
+ * @param {Map<string, string>} current - Current states.
729
+ * @param {Map<string, string>} displayPaths - Stable project-relative names.
730
+ * @returns {string[]} Changed project paths.
731
+ */
732
+ function changedWatchStatePaths(previous, current, displayPaths) {
733
+ const filenames = new Set([...previous.keys(), ...current.keys()])
734
+
735
+ return [...filenames].filter(filename => previous.get(filename) != current.get(filename))
736
+ .map(filename => displayPaths.get(filename) ?? path.basename(filename)).sort(compareStrings)
737
+ }
738
+
739
+ /**
740
+ * Serializes one ordered polling state only for repeated-invalid reconciliation suppression.
741
+ * @param {Map<string, string>} states - Current watched path states.
742
+ * @returns {string} Stable state key.
743
+ */
744
+ function watchStateKey(states) {
745
+ return JSON.stringify([...states].sort(([left], [right]) => compareStrings(left, right)))
746
+ }
747
+
748
+ /**
749
+ * Checks whether either absolute path contains the other.
750
+ * @param {string} left - First absolute path.
751
+ * @param {string} right - Second absolute path.
752
+ * @returns {boolean} Whether the paths overlap.
753
+ */
754
+ function pathsOverlap(left, right) {
755
+ return pathWithin(left, right) || pathWithin(right, left)
756
+ }
757
+
758
+ /**
759
+ * Checks whether one absolute path is a root or descendant of another.
760
+ * @param {string} root - Absolute root.
761
+ * @param {string} candidate - Absolute candidate.
762
+ * @returns {boolean} Whether the candidate belongs to the root.
763
+ */
764
+ function pathWithin(root, candidate) {
765
+ const relative = path.relative(root, candidate)
766
+
767
+ return relative.length == 0 || relative != ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
768
+ }
769
+
770
+ /**
771
+ * Computes exact content/config changes between complete immutable snapshots.
772
+ * @param {ProjectSnapshot | undefined} previous - Previous admitted snapshot.
773
+ * @param {ProjectSnapshot} current - Current complete snapshot.
774
+ * @param {string} manifestPath - Project-root-relative manifest display path.
775
+ * @returns {string[]} Stable changed paths.
776
+ */
777
+ function changedSnapshotPaths(previous, current, manifestPath) {
778
+ if (previous === undefined) return [manifestPath, ...current.sources.map(({path: sourcePath}) => sourcePath)]
779
+ const changed = new Set()
780
+
781
+ if (previous.manifestText != current.manifestText) changed.add(manifestPath)
782
+ const previousSources = new Map(previous.sources.map(source => [source.path, source.source]))
783
+ const currentSources = new Map(current.sources.map(source => [source.path, source.source]))
784
+
785
+ for (const sourcePath of new Set([...previousSources.keys(), ...currentSources.keys()])) {
786
+ if (previousSources.get(sourcePath) != currentSources.get(sourcePath)) changed.add(sourcePath)
787
+ }
788
+
789
+ return [...changed].sort(compareStrings)
790
+ }
791
+
792
+ /**
793
+ * Recognizes failures caused only by deterministic supersession.
794
+ * @param {unknown} failure - Candidate failure.
795
+ * @param {boolean} dirty - Whether a newer hint exists.
796
+ * @returns {boolean} Whether the cycle was superseded.
797
+ */
798
+ function supersededFailure(failure, dirty) {
799
+ let current = failure
800
+
801
+ while (current instanceof Error) {
802
+ if ("code" in current && (current.code == "WATCH_CANDIDATE_SUPERSEDED" || current.code == "WATCH_SNAPSHOT_CHANGED" ||
803
+ dirty && current.code == "PUBLICATION_CANCELLED")) return true
804
+ current = current.cause
805
+ }
806
+
807
+ return false
808
+ }
809
+
810
+ /**
811
+ * Checks one ordinary object.
812
+ * @param {unknown} value - Candidate.
813
+ * @returns {value is Record<string, unknown>} Whether the value is a plain object.
814
+ */
815
+ function isPlainObject(value) {
816
+ return typeof value == "object" && value != null && !Array.isArray(value) && Object.getPrototypeOf(value) == Object.prototype
817
+ }
818
+
819
+ /**
820
+ * Checks one bounded positive integer timing value.
821
+ * @param {number} value - Candidate milliseconds.
822
+ * @param {number} maximum - Inclusive maximum.
823
+ * @returns {boolean} Whether the value is valid.
824
+ */
825
+ function validMilliseconds(value, maximum) {
826
+ return typeof value == "number" && Number.isInteger(value) && value > 0 && value <= maximum
827
+ }
828
+
829
+ /**
830
+ * Checks a narrowed filesystem error code.
831
+ * @param {unknown} error - Opaque error.
832
+ * @param {string} code - Expected code.
833
+ * @returns {boolean} Whether the code matches.
834
+ */
835
+ function isErrorCode(error, code) {
836
+ return error instanceof Error && "code" in error && error.code == code
837
+ }
838
+
839
+ /**
840
+ * Compares stable protocol strings.
841
+ * @param {string} left - Left string.
842
+ * @param {string} right - Right string.
843
+ * @returns {number} Sort order.
844
+ */
845
+ function compareStrings(left, right) {
846
+ return left.localeCompare(right, "en")
847
+ }
848
+
849
+ /**
850
+ * @typedef ProjectWatchObservation
851
+ * @property {"cycle-start" | "cycle-terminal" | "dirty" | "ready" | "reconciled" | "stopped" | "watch-backend"} type - Marker type.
852
+ * @property {number} [cycle] - Related cycle number.
853
+ * @property {"cancelled" | "dirty" | "failed" | "native" | "polling" | "ready" | "recovered" | "started" | "stopped" | "succeeded" | "superseded" | "unchanged"} status - Marker status.
854
+ */
855
+
856
+ /**
857
+ * @typedef ProjectWatchResult
858
+ * @property {"failed" | "stopped"} status - Watcher terminal state.
859
+ * @property {number} cycles - Admitted cycle count.
860
+ * @property {string} reason - Stable terminal reason.
861
+ */
862
+
863
+ /**
864
+ * @typedef WatchCycleInput
865
+ * @property {readonly string[]} changedPaths - Stable changed paths.
866
+ * @property {boolean} check - Whether target checks are enabled.
867
+ * @property {unknown} [failure] - Preserved reconciliation failure.
868
+ * @property {SemantifoldProject | undefined} project - Reconciled project, when valid.
869
+ * @property {ProjectSnapshot | undefined} snapshot - Reconciled snapshot, when valid.
870
+ */