semantifold 0.9.0 → 0.10.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 (171) 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/php-check.d.ts +7 -0
  49. package/build/src/backends/php-check.d.ts.map +1 -0
  50. package/build/src/backends/php-check.js +21 -0
  51. package/build/src/backends/program.d.ts +7 -0
  52. package/build/src/backends/program.d.ts.map +1 -1
  53. package/build/src/backends/program.js +16 -1
  54. package/build/src/backends/python-check.d.ts +7 -0
  55. package/build/src/backends/python-check.d.ts.map +1 -0
  56. package/build/src/backends/python-check.js +28 -0
  57. package/build/src/backends/ruby-check.d.ts +7 -0
  58. package/build/src/backends/ruby-check.d.ts.map +1 -0
  59. package/build/src/backends/ruby-check.js +21 -0
  60. package/build/src/backends/rust-check.d.ts +7 -0
  61. package/build/src/backends/rust-check.d.ts.map +1 -0
  62. package/build/src/backends/rust-check.js +65 -0
  63. package/build/src/backends/swift-check.d.ts +7 -0
  64. package/build/src/backends/swift-check.d.ts.map +1 -0
  65. package/build/src/backends/swift-check.js +77 -0
  66. package/build/src/backends/typescript-check.d.ts +7 -0
  67. package/build/src/backends/typescript-check.d.ts.map +1 -0
  68. package/build/src/backends/typescript-check.js +33 -0
  69. package/build/src/backends/zig-check.d.ts +7 -0
  70. package/build/src/backends/zig-check.d.ts.map +1 -0
  71. package/build/src/backends/zig-check.js +70 -0
  72. package/build/src/diagnostic.d.ts +13 -1
  73. package/build/src/diagnostic.d.ts.map +1 -1
  74. package/build/src/diagnostic.js +14 -2
  75. package/build/src/frontends/program.d.ts +6 -0
  76. package/build/src/frontends/program.d.ts.map +1 -1
  77. package/build/src/frontends/program.js +9 -1
  78. package/build/src/language-registry.d.ts +54 -0
  79. package/build/src/language-registry.d.ts.map +1 -1
  80. package/build/src/language-registry.js +107 -5
  81. package/build/src/project-build.d.ts +172 -0
  82. package/build/src/project-build.d.ts.map +1 -0
  83. package/build/src/project-build.js +320 -0
  84. package/build/src/project-cli.d.ts +40 -0
  85. package/build/src/project-cli.d.ts.map +1 -0
  86. package/build/src/project-cli.js +145 -0
  87. package/build/src/project-manifest.d.ts +148 -0
  88. package/build/src/project-manifest.d.ts.map +1 -0
  89. package/build/src/project-manifest.js +432 -0
  90. package/build/src/project-reporter.d.ts +81 -0
  91. package/build/src/project-reporter.d.ts.map +1 -0
  92. package/build/src/project-reporter.js +293 -0
  93. package/build/src/project-snapshot.d.ts +129 -0
  94. package/build/src/project-snapshot.d.ts.map +1 -0
  95. package/build/src/project-snapshot.js +362 -0
  96. package/build/src/project-watch-reporter.d.ts +125 -0
  97. package/build/src/project-watch-reporter.d.ts.map +1 -0
  98. package/build/src/project-watch-reporter.js +280 -0
  99. package/build/src/project-watch.d.ts +133 -0
  100. package/build/src/project-watch.d.ts.map +1 -0
  101. package/build/src/project-watch.js +839 -0
  102. package/build/src/publication.d.ts +69 -5
  103. package/build/src/publication.d.ts.map +1 -1
  104. package/build/src/publication.js +246 -57
  105. package/build/src/semantic/types.d.ts +303 -2
  106. package/build/src/semantic/types.d.ts.map +1 -1
  107. package/build/src/semantic/types.js +79 -2
  108. package/build/src/subprocess.d.ts +8 -0
  109. package/build/src/subprocess.d.ts.map +1 -1
  110. package/build/src/subprocess.js +50 -9
  111. package/build/src/target-check.d.ts +52 -0
  112. package/build/src/target-check.d.ts.map +1 -0
  113. package/build/src/target-check.js +796 -0
  114. package/build/src/toolchains.d.ts.map +1 -1
  115. package/build/src/toolchains.js +3 -3
  116. package/docs/architecture.md +3 -1
  117. package/docs/c.md +4 -0
  118. package/docs/cpp.md +4 -0
  119. package/docs/dart.md +4 -0
  120. package/docs/generated-artifact-publication.md +11 -8
  121. package/docs/kotlin.md +2 -2
  122. package/docs/language-support.md +29 -3
  123. package/docs/project-build-cli.md +147 -0
  124. package/docs/project-watch.md +60 -0
  125. package/docs/rust.md +4 -0
  126. package/docs/swift.md +2 -0
  127. package/docs/testing.md +70 -3
  128. package/docs/watch-build-pipeline.md +39 -23
  129. package/docs/zig.md +2 -0
  130. package/examples/jsdoc-java-watch/expected-output.txt +3 -0
  131. package/examples/jsdoc-java-watch/semantifold.json +23 -0
  132. package/examples/jsdoc-java-watch/src/main.js +21 -0
  133. package/index.js +8 -0
  134. package/package.json +6 -1
  135. package/src/artifact-path.js +31 -2
  136. package/src/backends/c-check.js +80 -0
  137. package/src/backends/check-plan.js +147 -0
  138. package/src/backends/cpp-check.js +73 -0
  139. package/src/backends/csharp-check.js +139 -0
  140. package/src/backends/csharp.js +4 -3
  141. package/src/backends/dart-check.js +117 -0
  142. package/src/backends/dart.js +4 -4
  143. package/src/backends/go-check.js +89 -0
  144. package/src/backends/go.js +2 -2
  145. package/src/backends/java-check.js +77 -0
  146. package/src/backends/javascript-check.js +33 -0
  147. package/src/backends/kotlin-check.js +41 -0
  148. package/src/backends/php-check.js +23 -0
  149. package/src/backends/program.js +17 -0
  150. package/src/backends/python-check.js +30 -0
  151. package/src/backends/ruby-check.js +23 -0
  152. package/src/backends/rust-check.js +78 -0
  153. package/src/backends/swift-check.js +89 -0
  154. package/src/backends/typescript-check.js +37 -0
  155. package/src/backends/zig-check.js +81 -0
  156. package/src/diagnostic.js +9 -1
  157. package/src/frontends/program.js +9 -0
  158. package/src/language-registry.js +113 -4
  159. package/src/project-build.js +353 -0
  160. package/src/project-cli.js +155 -0
  161. package/src/project-manifest.js +479 -0
  162. package/src/project-reporter.js +320 -0
  163. package/src/project-snapshot.js +403 -0
  164. package/src/project-watch-reporter.js +303 -0
  165. package/src/project-watch.js +870 -0
  166. package/src/publication.js +275 -58
  167. package/src/semantic/types.js +87 -1
  168. package/src/subprocess.js +49 -8
  169. package/src/target-check.js +844 -0
  170. package/src/toolchains.js +3 -2
  171. package/tsconfig.json +1 -1
@@ -0,0 +1,403 @@
1
+ // @ts-check
2
+
3
+ import {createHash} from "node:crypto"
4
+ import {constants} from "node:fs"
5
+ import {lstat, open, realpath} from "node:fs/promises"
6
+ import {SemantifoldDiagnostic} from "./diagnostic.js"
7
+ import {SemantifoldProject} from "./project-manifest.js"
8
+
9
+ const snapshotAttempts = 2
10
+ /** @type {WeakMap<ProjectSnapshotBuilder, (event: Readonly<{attempt: number, pass: number, path: string}>) => Promise<void> | void>} */
11
+ const snapshotReadObservers = new WeakMap()
12
+
13
+ /**
14
+ * Installs an internal deterministic read observer for real-filesystem snapshot specs.
15
+ * This helper is intentionally not part of the package root API.
16
+ * @param {ProjectSnapshotBuilder} builder - Snapshot builder instance.
17
+ * @param {(event: Readonly<{attempt: number, pass: number, path: string}>) => Promise<void> | void} observer - Ordered read observer.
18
+ * @returns {void}
19
+ */
20
+ export function observeProjectSnapshotReads(builder, observer) {
21
+ if (!(builder instanceof ProjectSnapshotBuilder) || typeof observer != "function") {
22
+ throw new TypeError("Invalid project snapshot read observer.")
23
+ }
24
+ snapshotReadObservers.set(builder, observer)
25
+ }
26
+
27
+ /**
28
+ * One complete immutable ordered source snapshot.
29
+ */
30
+ export class ProjectSnapshot {
31
+ /**
32
+ * Constructs a trusted snapshot value.
33
+ * @param {{hash: string, manifestText: string, projectId: string, sources: SnapshotSource[]}} value - Stable snapshot fields.
34
+ */
35
+ constructor(value) {
36
+ this.hash = value.hash
37
+ this.manifestText = value.manifestText
38
+ this.projectId = value.projectId
39
+ this.sources = Object.freeze(value.sources.map(source => Object.freeze({...source})))
40
+ Object.freeze(this)
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Reads one complete graph twice before exposing an immutable source snapshot.
46
+ */
47
+ export class ProjectSnapshotBuilder {
48
+ /**
49
+ * Builds a stable snapshot with one bounded retry when the graph changes mid-read.
50
+ * @param {SemantifoldProject} project - Validated immutable project.
51
+ * @returns {Promise<ProjectSnapshot>} Stable complete snapshot.
52
+ */
53
+ async build(project) {
54
+ if (!(project instanceof SemantifoldProject)) {
55
+ snapshotFailure("INVALID_PROJECT_SNAPSHOT", "project", "Snapshot building requires a validated Semantifold project.")
56
+ }
57
+ let unstablePath = "semantifold.json"
58
+
59
+ for (let attempt = 0; attempt < snapshotAttempts; attempt += 1) {
60
+ try {
61
+ const first = await readProjectGraph(project, this, attempt + 1, 1)
62
+ const second = await readProjectGraph(project, this, attempt + 1, 2)
63
+ const difference = graphDifference(first, second, project)
64
+
65
+ if (difference !== null) {
66
+ unstablePath = difference
67
+ continue
68
+ }
69
+ const finalDifference = await graphStateDifference(project, second)
70
+
71
+ if (finalDifference !== null) {
72
+ unstablePath = finalDifference
73
+ continue
74
+ }
75
+ const manifestText = decodeUtf8(first.manifestBytes, "semantifold.json", project.id)
76
+
77
+ if (manifestText != project.manifestText) {
78
+ snapshotFailure("UNSTABLE_PROJECT_SNAPSHOT", project.id,
79
+ "Project manifest changed after it was validated; restart the build from the new manifest.", manifestLocation())
80
+ }
81
+ const sources = project.sources.map((source, index) => ({
82
+ id: source.id,
83
+ language: source.language,
84
+ path: source.path,
85
+ source: decodeUtf8(first.sourceBytes[index], source.path, project.id)
86
+ }))
87
+
88
+ return new ProjectSnapshot({
89
+ hash: snapshotHash(first.manifestBytes, sources),
90
+ manifestText,
91
+ projectId: project.id,
92
+ sources
93
+ })
94
+ } catch (error) {
95
+ if (!(error instanceof UnstableSnapshotRead)) throw error
96
+ unstablePath = error.path
97
+ }
98
+ }
99
+
100
+ return snapshotFailure("UNSTABLE_PROJECT_SNAPSHOT", project.id,
101
+ "Project manifest or source files changed repeatedly while the snapshot was loading.", sourceLocation(unstablePath))
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Reads every declared file in stable manifest order.
107
+ * @param {SemantifoldProject} project - Validated project.
108
+ * @param {ProjectSnapshotBuilder} builder - Active builder.
109
+ * @param {number} attempt - One-based bounded attempt.
110
+ * @param {number} pass - One-based verification pass.
111
+ * @returns {Promise<ProjectGraphRead>} Complete graph bytes.
112
+ */
113
+ async function readProjectGraph(project, builder, attempt, pass) {
114
+ const manifest = await readStableRegularFile(project.manifestPath, "semantifold.json", project.id, true)
115
+
116
+ await snapshotReadObservers.get(builder)?.(Object.freeze({attempt, pass, path: "semantifold.json"}))
117
+ /** @type {Buffer[]} */
118
+ const sourceBytes = []
119
+ /** @type {string[]} */
120
+ const sourceIdentities = []
121
+ /** @type {string[]} */
122
+ const sourceStates = []
123
+ const uniqueSourceIdentities = new Set()
124
+
125
+ for (const source of project.sources) {
126
+ const read = await readStableRegularFile(source.absolutePath, source.path, project.id, false)
127
+
128
+ if (uniqueSourceIdentities.has(read.identity)) {
129
+ snapshotFailure("PROJECT_SOURCE_ALIAS", project.id,
130
+ "Project source paths must not alias the same file.", sourceLocation(source.path))
131
+ }
132
+ uniqueSourceIdentities.add(read.identity)
133
+ sourceBytes.push(read.bytes)
134
+ sourceIdentities.push(read.identity)
135
+ sourceStates.push(read.state)
136
+ await snapshotReadObservers.get(builder)?.(Object.freeze({attempt, pass, path: source.path}))
137
+ }
138
+
139
+ return {
140
+ manifestBytes: manifest.bytes,
141
+ manifestIdentity: manifest.identity,
142
+ manifestState: manifest.state,
143
+ sourceBytes,
144
+ sourceIdentities,
145
+ sourceStates
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Reads exact bytes through one descriptor while detecting replacement or mutation.
151
+ * @param {string} filename - Canonical absolute path.
152
+ * @param {string} displayPath - Project-relative diagnostic path.
153
+ * @param {string} projectId - Project identity.
154
+ * @param {boolean} manifest - Whether this is the project manifest.
155
+ * @returns {Promise<StableFileRead>} Stable bytes and filesystem identity.
156
+ */
157
+ async function readStableRegularFile(filename, displayPath, projectId, manifest) {
158
+ let handle
159
+
160
+ try {
161
+ handle = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
162
+ const beforePath = await realpath(filename)
163
+ const before = await handle.stat({bigint: true})
164
+ const beforeEntry = await lstat(filename, {bigint: true})
165
+
166
+ if (!before.isFile() || !beforeEntry.isFile() || beforePath != filename || !sameFileIdentity(before, beforeEntry)) {
167
+ throw new UnstableSnapshotRead(displayPath)
168
+ }
169
+ const bytes = await handle.readFile()
170
+ const after = await handle.stat({bigint: true})
171
+ const afterPath = await realpath(filename)
172
+ const afterEntry = await lstat(filename, {bigint: true})
173
+
174
+ if (!after.isFile() || !afterEntry.isFile() || afterPath != filename || !sameFileState(before, after) ||
175
+ !sameFileIdentity(after, afterEntry) || BigInt(bytes.byteLength) != after.size) {
176
+ throw new UnstableSnapshotRead(displayPath)
177
+ }
178
+
179
+ return {
180
+ bytes,
181
+ identity: `${String(before.dev)}:${String(before.ino)}`,
182
+ state: stableFileState(before)
183
+ }
184
+ } catch (error) {
185
+ if (error instanceof UnstableSnapshotRead) throw error
186
+ return snapshotFailure(manifest ? "PROJECT_MANIFEST_READ_FAILED" : "PROJECT_SOURCE_READ_FAILED", projectId,
187
+ manifest ? "Project manifest could not be read into a stable snapshot." : "Declared source could not be read into a stable snapshot.",
188
+ sourceLocation(displayPath), error)
189
+ } finally {
190
+ await handle?.close()
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Compares descriptor state around one read.
196
+ * @param {import("node:fs").BigIntStats} left - State before the read.
197
+ * @param {import("node:fs").BigIntStats} right - State after the read.
198
+ * @returns {boolean} Whether identity and mutation-sensitive metadata agree.
199
+ */
200
+ function sameFileState(left, right) {
201
+ return left.dev == right.dev && left.ino == right.ino && left.size == right.size &&
202
+ left.mtimeNs == right.mtimeNs && left.ctimeNs == right.ctimeNs
203
+ }
204
+
205
+ /**
206
+ * Compares the path entry with the opened descriptor identity.
207
+ * @param {import("node:fs").BigIntStats} left - Open descriptor or path state.
208
+ * @param {import("node:fs").BigIntStats} right - Open descriptor or path state.
209
+ * @returns {boolean} Whether both identify the same filesystem node.
210
+ */
211
+ function sameFileIdentity(left, right) {
212
+ return left.dev == right.dev && left.ino == right.ino
213
+ }
214
+
215
+ /**
216
+ * Serializes mutation-sensitive descriptor state for complete-graph comparison.
217
+ * @param {import("node:fs").BigIntStats} status - Stable descriptor state.
218
+ * @returns {string} Internal state identity.
219
+ */
220
+ function stableFileState(status) {
221
+ return [status.dev, status.ino, status.size, status.mtimeNs, status.ctimeNs].map(String).join(":")
222
+ }
223
+
224
+ /**
225
+ * Locates the first byte difference between two complete graph reads.
226
+ * @param {ProjectGraphRead} left - First graph.
227
+ * @param {ProjectGraphRead} right - Second graph.
228
+ * @param {SemantifoldProject} project - Ordered path authority.
229
+ * @returns {string | null} Project-relative changed path, or null.
230
+ */
231
+ function graphDifference(left, right, project) {
232
+ if (left.manifestIdentity != right.manifestIdentity || left.manifestState != right.manifestState ||
233
+ !left.manifestBytes.equals(right.manifestBytes)) {
234
+ return "semantifold.json"
235
+ }
236
+ if (left.sourceBytes.length != right.sourceBytes.length) return "semantifold.json"
237
+ const index = left.sourceBytes.findIndex((bytes, sourceIndex) =>
238
+ left.sourceIdentities[sourceIndex] != right.sourceIdentities[sourceIndex] ||
239
+ left.sourceStates[sourceIndex] != right.sourceStates[sourceIndex] || !bytes.equals(right.sourceBytes[sourceIndex]))
240
+
241
+ return index == -1 ? null : project.sources[index].path
242
+ }
243
+
244
+ /**
245
+ * Revalidates every path entry after the final complete graph read.
246
+ * @param {SemantifoldProject} project - Ordered path authority.
247
+ * @param {ProjectGraphRead} graph - Final complete graph read.
248
+ * @returns {Promise<string | null>} First changed project-relative path, or null.
249
+ */
250
+ async function graphStateDifference(project, graph) {
251
+ const entries = [{
252
+ filename: project.manifestPath,
253
+ identity: graph.manifestIdentity,
254
+ path: "semantifold.json",
255
+ state: graph.manifestState
256
+ }, ...project.sources.map((source, index) => ({
257
+ filename: source.absolutePath,
258
+ identity: graph.sourceIdentities[index],
259
+ path: source.path,
260
+ state: graph.sourceStates[index]
261
+ }))]
262
+
263
+ for (const entry of entries) {
264
+ try {
265
+ const status = await lstat(entry.filename, {bigint: true})
266
+ const exact = await realpath(entry.filename)
267
+
268
+ if (!status.isFile() || exact != entry.filename || `${String(status.dev)}:${String(status.ino)}` != entry.identity ||
269
+ stableFileState(status) != entry.state) return entry.path
270
+ } catch (_error) {
271
+ return entry.path
272
+ }
273
+ }
274
+
275
+ return null
276
+ }
277
+
278
+ /**
279
+ * Computes a framed deterministic SHA-256 identity over exact manifest and ordered source inputs.
280
+ * @param {Buffer} manifestBytes - Exact manifest bytes.
281
+ * @param {readonly SnapshotSource[]} sources - Ordered decoded sources.
282
+ * @returns {string} Lowercase SHA-256 digest.
283
+ */
284
+ function snapshotHash(manifestBytes, sources) {
285
+ const hash = createHash("sha256")
286
+
287
+ updateHash(hash, "SemantifoldProjectSnapshot/1")
288
+ updateHash(hash, manifestBytes)
289
+ for (const source of sources) {
290
+ updateHash(hash, source.id)
291
+ updateHash(hash, source.path)
292
+ updateHash(hash, source.language)
293
+ updateHash(hash, source.source)
294
+ }
295
+
296
+ return hash.digest("hex")
297
+ }
298
+
299
+ /**
300
+ * Adds one length-framed field to a content hash.
301
+ * @param {import("node:crypto").Hash} hash - Active hash.
302
+ * @param {string | Buffer} value - Field bytes.
303
+ * @returns {void}
304
+ */
305
+ function updateHash(hash, value) {
306
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8")
307
+ const length = Buffer.alloc(8)
308
+
309
+ length.writeBigUInt64BE(BigInt(bytes.byteLength))
310
+ hash.update(length)
311
+ hash.update(bytes)
312
+ }
313
+
314
+ /**
315
+ * Decodes strict UTF-8 for parser inputs.
316
+ * @param {Buffer} bytes - Exact file bytes.
317
+ * @param {string} filename - Project-relative filename.
318
+ * @param {string} projectId - Project identity.
319
+ * @returns {string} Decoded source.
320
+ */
321
+ function decodeUtf8(bytes, filename, projectId) {
322
+ try {
323
+ return new TextDecoder("utf-8", {fatal: true}).decode(bytes)
324
+ } catch (error) {
325
+ return snapshotFailure("INVALID_PROJECT_SOURCE_ENCODING", projectId,
326
+ "Project manifests and sources must be valid UTF-8.", sourceLocation(filename), error)
327
+ }
328
+ }
329
+
330
+ /** Internal marker for a read that may safely use the bounded retry. */
331
+ class UnstableSnapshotRead extends Error {
332
+ /**
333
+ * Creates an internal retry marker.
334
+ * @param {string} path - Project-relative unstable path.
335
+ */
336
+ constructor(path) {
337
+ super("Unstable project snapshot read.")
338
+ this.path = path
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Creates the stable manifest boundary location.
344
+ * @returns {import("./semantic/types.js").SourceLocation} Manifest location.
345
+ */
346
+ function manifestLocation() {
347
+ return sourceLocation("semantifold.json")
348
+ }
349
+
350
+ /**
351
+ * Creates a stable project-relative file location.
352
+ * @param {string} filename - Project-relative filename.
353
+ * @returns {import("./semantic/types.js").SourceLocation} Start-of-file location.
354
+ */
355
+ function sourceLocation(filename) {
356
+ const point = {column: 1, line: 1, offset: 0}
357
+
358
+ return {end: point, filename, start: point}
359
+ }
360
+
361
+ /**
362
+ * Throws one stable snapshot diagnostic.
363
+ * @param {string} code - Stable diagnostic code.
364
+ * @param {string} projectId - Project identity.
365
+ * @param {string} message - Stable detail.
366
+ * @param {import("./semantic/types.js").SourceLocation} [location] - Project-relative location.
367
+ * @param {unknown} [error] - Preserved cause.
368
+ * @returns {never} Always throws.
369
+ */
370
+ function snapshotFailure(code, projectId, message, location, error) {
371
+ throw new SemantifoldDiagnostic({
372
+ cause: error instanceof Error ? error : undefined,
373
+ code,
374
+ language: projectId,
375
+ location,
376
+ message
377
+ })
378
+ }
379
+
380
+ /**
381
+ * @typedef SnapshotSource
382
+ * @property {string} id - Stable semantic module identity.
383
+ * @property {import("./semantic/types.js").SemanticLanguage} language - Registered source language.
384
+ * @property {string} path - Project-relative source path.
385
+ * @property {string} source - Exact decoded source text.
386
+ */
387
+
388
+ /**
389
+ * @typedef ProjectGraphRead
390
+ * @property {Buffer} manifestBytes - Exact manifest bytes.
391
+ * @property {string} manifestIdentity - Stable manifest device/inode identity.
392
+ * @property {string} manifestState - Stable manifest mutation-sensitive state.
393
+ * @property {Buffer[]} sourceBytes - Exact source bytes in manifest order.
394
+ * @property {string[]} sourceIdentities - Stable source device/inode identities in manifest order.
395
+ * @property {string[]} sourceStates - Stable source mutation-sensitive states in manifest order.
396
+ */
397
+
398
+ /**
399
+ * @typedef StableFileRead
400
+ * @property {Buffer} bytes - Exact stable file bytes.
401
+ * @property {string} identity - Stable device/inode identity.
402
+ * @property {string} state - Stable mutation-sensitive file state.
403
+ */
@@ -0,0 +1,303 @@
1
+ // @ts-check
2
+
3
+ import {diagnosticRecord, formatHumanProjectFailure, normalizeDiagnostic} from "./project-reporter.js"
4
+
5
+ /**
6
+ * Emits long-lived watch state with one terminal record for every admitted cycle and one for the watcher.
7
+ */
8
+ export class ProjectWatchReporter {
9
+ /** @type {"human" | "ndjson"} */
10
+ #format
11
+ /** @type {import("./project-reporter.js").WritableOutput} */
12
+ #stdout
13
+ /** @type {import("./project-reporter.js").WritableOutput} */
14
+ #stderr
15
+ /** @type {() => number} */
16
+ #now
17
+ /** @type {string | null} */
18
+ #projectId = null
19
+ /** @type {string | null} */
20
+ #snapshotHash = null
21
+ /** @type {number | null} */
22
+ #cycle = null
23
+ /** @type {readonly string[]} */
24
+ #changedPaths = Object.freeze([])
25
+ #cycleStartedAt = 0
26
+ #watchTerminal = false
27
+ #cycleTerminal = true
28
+
29
+ /**
30
+ * Creates a watch reporter.
31
+ * @param {{format?: "human" | "ndjson", now?: () => number, stderr?: import("./project-reporter.js").WritableOutput, stdout?: import("./project-reporter.js").WritableOutput}} [options] - Output mode, clock, and sinks.
32
+ */
33
+ constructor(options = {}) {
34
+ const format = options.format ?? "human"
35
+
36
+ if (format != "human" && format != "ndjson" || options.now !== undefined && typeof options.now != "function") {
37
+ throw new TypeError("Invalid project watch reporter options.")
38
+ }
39
+ this.#format = format
40
+ this.#stdout = options.stdout ?? process.stdout
41
+ this.#stderr = options.stderr ?? process.stderr
42
+ this.#now = options.now ?? (() => performance.now())
43
+ if (typeof this.#stdout?.write != "function" || typeof this.#stderr?.write != "function") {
44
+ throw new TypeError("Project watch reporter requires writable output sinks.")
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Reports validated watch startup.
50
+ * @param {string} projectId - Stable initial project identity.
51
+ * @returns {void}
52
+ */
53
+ watchStarted(projectId) {
54
+ this.#projectId = projectId
55
+ this.#emit({state: "watch-started"})
56
+ }
57
+
58
+ /**
59
+ * Reports the selected event backend.
60
+ * @param {"native" | "polling"} backend - Active event backend.
61
+ * @returns {void}
62
+ */
63
+ watchBackend(backend) {
64
+ this.#emit({backend, state: "watch-backend"})
65
+ }
66
+
67
+ /**
68
+ * Begins one admitted build cycle.
69
+ * @param {ProjectWatchCycleContext} cycle - Cycle context.
70
+ * @returns {void}
71
+ */
72
+ cycleStarted(cycle) {
73
+ if (!this.#cycleTerminal || this.#watchTerminal) throw new TypeError("Project watch reporter cycle lifecycle is invalid.")
74
+ this.#cycle = cycle.cycle
75
+ this.#cycleTerminal = false
76
+ this.#changedPaths = Object.freeze([...cycle.changedPaths])
77
+ this.#projectId = cycle.projectId
78
+ this.#snapshotHash = cycle.snapshotHash ?? null
79
+ this.#cycleStartedAt = this.#now()
80
+ this.#emit({changedPaths: this.#changedPaths, ...(cycle.snapshotHash === undefined ? {} : {snapshotHash: cycle.snapshotHash}), state: "cycle-started"})
81
+ }
82
+
83
+ /**
84
+ * Reports the cycle's validated project identity.
85
+ * @param {string} projectId - Validated project identity.
86
+ * @returns {void}
87
+ */
88
+ projectLoaded(projectId) {
89
+ this.#projectId = projectId
90
+ this.#emit({state: "project-loaded"})
91
+ }
92
+
93
+ /**
94
+ * Reports the cycle's complete source snapshot.
95
+ * @param {string} snapshotHash - Complete source snapshot hash.
96
+ * @returns {void}
97
+ */
98
+ snapshotLoaded(snapshotHash) {
99
+ this.#snapshotHash = snapshotHash
100
+ this.#emit({snapshotHash, state: "snapshot-loaded"})
101
+ }
102
+
103
+ /**
104
+ * Reports one generated target.
105
+ * @param {import("./project-build.js").ProjectBuildTargetResult} target - Generated target.
106
+ * @returns {void}
107
+ */
108
+ targetGenerated(target) {
109
+ this.#emit({
110
+ artifactCount: target.artifactCount,
111
+ language: target.language,
112
+ role: target.role,
113
+ state: "target-generated",
114
+ target: target.id
115
+ })
116
+ }
117
+
118
+ /**
119
+ * Reports one completed target-check stage.
120
+ * @param {{id: string, language: string, stage: import("./semantic/types.js").TargetCheckStageResult}} checked - Check stage.
121
+ * @returns {void}
122
+ */
123
+ targetChecked(checked) {
124
+ this.#emit({
125
+ argv: checked.stage.argv,
126
+ durationMs: checked.stage.durationMs,
127
+ exitCode: checked.stage.exitCode,
128
+ language: checked.language,
129
+ signal: checked.stage.signal,
130
+ stage: checked.stage.stage,
131
+ state: "target-checked",
132
+ stderr: checked.stage.stderr,
133
+ stdout: checked.stage.stdout,
134
+ target: checked.id,
135
+ tool: checked.stage.tool
136
+ })
137
+ }
138
+
139
+ /**
140
+ * Emits one successful or recovered cycle terminal.
141
+ * @param {import("./project-build.js").ProjectBuildSuccess} result - Published result.
142
+ * @param {boolean} recovered - Whether the prior ordinary cycle failed.
143
+ * @returns {void}
144
+ */
145
+ cycleSucceeded(result, recovered) {
146
+ this.#requireCycle()
147
+ this.#cycleTerminal = true
148
+ this.#snapshotHash = result.snapshotHash
149
+ const state = recovered ? "cycle-recovered" : "cycle-succeeded"
150
+
151
+ if (this.#format == "human") {
152
+ const verb = recovered ? "recovered" : "succeeded"
153
+
154
+ this.#stdout.write(`Watch cycle ${this.#cycle} ${verb}: project '${result.projectId}' published generation '${result.generationId}'.\n`)
155
+ return
156
+ }
157
+ this.#write({
158
+ changedPaths: this.#changedPaths,
159
+ checked: result.checked,
160
+ durationMs: this.#duration(),
161
+ generationId: result.generationId,
162
+ snapshotHash: result.snapshotHash,
163
+ state,
164
+ targets: result.targets.map(target => ({id: target.id, language: target.language, role: target.role})),
165
+ terminal: true,
166
+ terminalScope: "cycle"
167
+ })
168
+ }
169
+
170
+ /**
171
+ * Emits one failed, cancelled, or superseded cycle terminal.
172
+ * @param {unknown} failure - Preserved cycle failure.
173
+ * @param {"cancelled" | "failed" | "superseded"} status - Terminal classification.
174
+ * @returns {void}
175
+ */
176
+ cycleFailed(failure, status) {
177
+ this.#requireCycle()
178
+ this.#cycleTerminal = true
179
+ const diagnostic = normalizeDiagnostic(failure)
180
+
181
+ if (this.#format == "human") {
182
+ this.#stderr.write(formatHumanProjectFailure(diagnostic, `Watch cycle ${this.#cycle} ${status}`))
183
+ return
184
+ }
185
+ this.#write({
186
+ changedPaths: this.#changedPaths,
187
+ diagnostic: diagnosticRecord(diagnostic),
188
+ durationMs: this.#duration(),
189
+ ...(this.#snapshotHash === null ? {} : {snapshotHash: this.#snapshotHash}),
190
+ state: `cycle-${status}`,
191
+ terminal: true,
192
+ terminalScope: "cycle"
193
+ })
194
+ }
195
+
196
+ /**
197
+ * Reports reconciliation whose complete content hash did not change.
198
+ * @param {readonly string[]} changedPaths - Coalesced hint paths.
199
+ * @returns {void}
200
+ */
201
+ unchanged(changedPaths) {
202
+ this.#emit({changedPaths, state: "reconciled-unchanged"})
203
+ }
204
+
205
+ /**
206
+ * Reports the first filesystem hint admitted while the current cycle remains active.
207
+ * @returns {void}
208
+ */
209
+ cycleDirty() {
210
+ this.#requireCycle()
211
+ this.#emit({state: "cycle-dirty"})
212
+ }
213
+
214
+ /**
215
+ * Emits the sole clean watcher terminal.
216
+ * @param {string} reason - Shutdown reason.
217
+ * @returns {void}
218
+ */
219
+ watchStopped(reason) {
220
+ this.#requireWatchTerminal()
221
+ this.#watchTerminal = true
222
+ if (this.#format == "human") {
223
+ this.#stdout.write(`Watch stopped: ${reason}\n`)
224
+ return
225
+ }
226
+ this.#write({reason, state: "watch-stopped", terminal: true, terminalScope: "watch"})
227
+ }
228
+
229
+ /**
230
+ * Emits the sole startup/configuration watcher terminal.
231
+ * @param {unknown} failure - Startup/configuration failure.
232
+ * @returns {void}
233
+ */
234
+ watchFailed(failure) {
235
+ this.#requireWatchTerminal()
236
+ this.#watchTerminal = true
237
+ const diagnostic = normalizeDiagnostic(failure)
238
+
239
+ if (this.#format == "human") {
240
+ this.#stderr.write(formatHumanProjectFailure(diagnostic, "Watch failed"))
241
+ return
242
+ }
243
+ this.#write({diagnostic: diagnosticRecord(diagnostic), state: "watch-failed", terminal: true, terminalScope: "watch"})
244
+ }
245
+
246
+ /**
247
+ * Computes a stable non-negative cycle duration.
248
+ * @returns {number} Cycle duration.
249
+ */
250
+ #duration() {
251
+ const value = this.#now() - this.#cycleStartedAt
252
+
253
+ return Number.isFinite(value) && value >= 0 ? value : 0
254
+ }
255
+
256
+ /**
257
+ * Emits a non-terminal machine state when selected.
258
+ * @param {Record<string, unknown>} fields - State fields.
259
+ * @returns {void}
260
+ */
261
+ #emit(fields) {
262
+ if (this.#format == "ndjson") this.#write(fields)
263
+ }
264
+
265
+ /**
266
+ * Writes one complete NDJSON record.
267
+ * @param {Record<string, unknown>} fields - Protocol fields.
268
+ * @returns {void}
269
+ */
270
+ #write(fields) {
271
+ this.#stdout.write(`${JSON.stringify({
272
+ cycle: this.#cycle,
273
+ project: this.#projectId,
274
+ schema: "SemantifoldWatchEvent",
275
+ version: 1,
276
+ ...fields
277
+ })}\n`)
278
+ }
279
+
280
+ /**
281
+ * Requires one open cycle terminal slot.
282
+ * @returns {void}
283
+ */
284
+ #requireCycle() {
285
+ if (this.#cycleTerminal || this.#watchTerminal) throw new TypeError("Project watch reporter has no open cycle.")
286
+ }
287
+
288
+ /**
289
+ * Requires one open watcher terminal slot.
290
+ * @returns {void}
291
+ */
292
+ #requireWatchTerminal() {
293
+ if (this.#watchTerminal || !this.#cycleTerminal) throw new TypeError("Project watch reporter watcher lifecycle is invalid.")
294
+ }
295
+ }
296
+
297
+ /**
298
+ * @typedef ProjectWatchCycleContext
299
+ * @property {readonly string[]} changedPaths - Stable changed paths.
300
+ * @property {number} cycle - One-based cycle number.
301
+ * @property {string} projectId - Stable project identity.
302
+ * @property {string} [snapshotHash] - Complete snapshot hash when reconciliation succeeded.
303
+ */