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.
- package/README.md +6 -3
- package/bin/semantifold.js +6 -0
- package/build/bin/semantifold.d.ts +3 -0
- package/build/bin/semantifold.d.ts.map +1 -0
- package/build/bin/semantifold.js +5 -0
- package/build/index.d.ts +8 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +9 -1
- package/build/src/artifact-path.d.ts +16 -0
- package/build/src/artifact-path.d.ts.map +1 -1
- package/build/src/artifact-path.js +29 -3
- package/build/src/backends/c-check.d.ts +7 -0
- package/build/src/backends/c-check.d.ts.map +1 -0
- package/build/src/backends/c-check.js +65 -0
- package/build/src/backends/check-plan.d.ts +96 -0
- package/build/src/backends/check-plan.d.ts.map +1 -0
- package/build/src/backends/check-plan.js +135 -0
- package/build/src/backends/cpp-check.d.ts +7 -0
- package/build/src/backends/cpp-check.d.ts.map +1 -0
- package/build/src/backends/cpp-check.js +59 -0
- package/build/src/backends/csharp-check.d.ts +7 -0
- package/build/src/backends/csharp-check.d.ts.map +1 -0
- package/build/src/backends/csharp-check.js +125 -0
- package/build/src/backends/csharp.d.ts +1 -0
- package/build/src/backends/csharp.d.ts.map +1 -1
- package/build/src/backends/csharp.js +5 -4
- package/build/src/backends/dart-check.d.ts +7 -0
- package/build/src/backends/dart-check.d.ts.map +1 -0
- package/build/src/backends/dart-check.js +103 -0
- package/build/src/backends/dart.d.ts +2 -0
- package/build/src/backends/dart.d.ts.map +1 -1
- package/build/src/backends/dart.js +5 -5
- package/build/src/backends/go-check.d.ts +7 -0
- package/build/src/backends/go-check.d.ts.map +1 -0
- package/build/src/backends/go-check.js +77 -0
- package/build/src/backends/go.d.ts +1 -0
- package/build/src/backends/go.d.ts.map +1 -1
- package/build/src/backends/go.js +3 -3
- package/build/src/backends/java-check.d.ts +21 -0
- package/build/src/backends/java-check.d.ts.map +1 -0
- package/build/src/backends/java-check.js +73 -0
- package/build/src/backends/javascript-check.d.ts +7 -0
- package/build/src/backends/javascript-check.d.ts.map +1 -0
- package/build/src/backends/javascript-check.js +29 -0
- package/build/src/backends/kotlin-check.d.ts +7 -0
- package/build/src/backends/kotlin-check.d.ts.map +1 -0
- package/build/src/backends/kotlin-check.js +31 -0
- package/build/src/backends/kotlin.d.ts +1 -1
- package/build/src/backends/kotlin.js +2 -2
- package/build/src/backends/php-check.d.ts +7 -0
- package/build/src/backends/php-check.d.ts.map +1 -0
- package/build/src/backends/php-check.js +21 -0
- package/build/src/backends/program.d.ts +7 -0
- package/build/src/backends/program.d.ts.map +1 -1
- package/build/src/backends/program.js +16 -1
- package/build/src/backends/python-check.d.ts +7 -0
- package/build/src/backends/python-check.d.ts.map +1 -0
- package/build/src/backends/python-check.js +28 -0
- package/build/src/backends/ruby-check.d.ts +7 -0
- package/build/src/backends/ruby-check.d.ts.map +1 -0
- package/build/src/backends/ruby-check.js +21 -0
- package/build/src/backends/rust-check.d.ts +7 -0
- package/build/src/backends/rust-check.d.ts.map +1 -0
- package/build/src/backends/rust-check.js +65 -0
- package/build/src/backends/shared.d.ts.map +1 -1
- package/build/src/backends/shared.js +29 -1
- package/build/src/backends/swift-check.d.ts +7 -0
- package/build/src/backends/swift-check.d.ts.map +1 -0
- package/build/src/backends/swift-check.js +77 -0
- package/build/src/backends/typescript-check.d.ts +7 -0
- package/build/src/backends/typescript-check.d.ts.map +1 -0
- package/build/src/backends/typescript-check.js +33 -0
- package/build/src/backends/zig-check.d.ts +7 -0
- package/build/src/backends/zig-check.d.ts.map +1 -0
- package/build/src/backends/zig-check.js +70 -0
- package/build/src/diagnostic.d.ts +13 -1
- package/build/src/diagnostic.d.ts.map +1 -1
- package/build/src/diagnostic.js +14 -2
- package/build/src/frontends/java.d.ts.map +1 -1
- package/build/src/frontends/java.js +23 -1
- package/build/src/frontends/javascript-typescript.d.ts.map +1 -1
- package/build/src/frontends/javascript-typescript.js +45 -4
- package/build/src/frontends/php.d.ts.map +1 -1
- package/build/src/frontends/php.js +54 -3
- package/build/src/frontends/program.d.ts +6 -0
- package/build/src/frontends/program.d.ts.map +1 -1
- package/build/src/frontends/program.js +9 -1
- package/build/src/frontends/ruby.d.ts.map +1 -1
- package/build/src/frontends/ruby.js +55 -5
- package/build/src/language-registry.d.ts +54 -0
- package/build/src/language-registry.d.ts.map +1 -1
- package/build/src/language-registry.js +124 -10
- package/build/src/project-build.d.ts +172 -0
- package/build/src/project-build.d.ts.map +1 -0
- package/build/src/project-build.js +320 -0
- package/build/src/project-cli.d.ts +40 -0
- package/build/src/project-cli.d.ts.map +1 -0
- package/build/src/project-cli.js +145 -0
- package/build/src/project-manifest.d.ts +148 -0
- package/build/src/project-manifest.d.ts.map +1 -0
- package/build/src/project-manifest.js +432 -0
- package/build/src/project-reporter.d.ts +81 -0
- package/build/src/project-reporter.d.ts.map +1 -0
- package/build/src/project-reporter.js +293 -0
- package/build/src/project-snapshot.d.ts +129 -0
- package/build/src/project-snapshot.d.ts.map +1 -0
- package/build/src/project-snapshot.js +362 -0
- package/build/src/project-watch-reporter.d.ts +125 -0
- package/build/src/project-watch-reporter.d.ts.map +1 -0
- package/build/src/project-watch-reporter.js +280 -0
- package/build/src/project-watch.d.ts +133 -0
- package/build/src/project-watch.d.ts.map +1 -0
- package/build/src/project-watch.js +839 -0
- package/build/src/publication.d.ts +69 -5
- package/build/src/publication.d.ts.map +1 -1
- package/build/src/publication.js +246 -57
- package/build/src/semantic/effects.js +3 -1
- package/build/src/semantic/provenance.d.ts.map +1 -1
- package/build/src/semantic/provenance.js +11 -1
- package/build/src/semantic/types.d.ts +355 -4
- package/build/src/semantic/types.d.ts.map +1 -1
- package/build/src/semantic/types.js +92 -3
- package/build/src/semantic/validate.js +18 -1
- package/build/src/subprocess.d.ts +8 -0
- package/build/src/subprocess.d.ts.map +1 -1
- package/build/src/subprocess.js +50 -9
- package/build/src/target-check.d.ts +52 -0
- package/build/src/target-check.d.ts.map +1 -0
- package/build/src/target-check.js +796 -0
- package/build/src/toolchains.d.ts.map +1 -1
- package/build/src/toolchains.js +3 -3
- package/docs/adr-expression-breadth.md +186 -0
- package/docs/architecture.md +3 -1
- package/docs/c.md +4 -0
- package/docs/cpp.md +4 -0
- package/docs/dart.md +4 -0
- package/docs/generated-artifact-publication.md +11 -8
- package/docs/kotlin.md +2 -2
- package/docs/language-support.md +40 -6
- package/docs/parser-qualification.md +1 -1
- package/docs/project-build-cli.md +147 -0
- package/docs/project-watch.md +60 -0
- package/docs/rust.md +4 -0
- package/docs/swift.md +2 -0
- package/docs/testing.md +70 -3
- package/docs/watch-build-pipeline.md +39 -23
- package/docs/zig.md +2 -0
- package/examples/jsdoc-java-watch/expected-output.txt +3 -0
- package/examples/jsdoc-java-watch/semantifold.json +23 -0
- package/examples/jsdoc-java-watch/src/main.js +21 -0
- package/index.js +8 -0
- package/package.json +6 -1
- package/src/artifact-path.js +31 -2
- package/src/backends/c-check.js +80 -0
- package/src/backends/check-plan.js +147 -0
- package/src/backends/cpp-check.js +73 -0
- package/src/backends/csharp-check.js +139 -0
- package/src/backends/csharp.js +4 -3
- package/src/backends/dart-check.js +117 -0
- package/src/backends/dart.js +4 -4
- package/src/backends/go-check.js +89 -0
- package/src/backends/go.js +2 -2
- package/src/backends/java-check.js +77 -0
- package/src/backends/javascript-check.js +33 -0
- package/src/backends/kotlin-check.js +41 -0
- package/src/backends/kotlin.js +1 -1
- package/src/backends/php-check.js +23 -0
- package/src/backends/program.js +17 -0
- package/src/backends/python-check.js +30 -0
- package/src/backends/ruby-check.js +23 -0
- package/src/backends/rust-check.js +78 -0
- package/src/backends/shared.js +30 -0
- package/src/backends/swift-check.js +89 -0
- package/src/backends/typescript-check.js +37 -0
- package/src/backends/zig-check.js +81 -0
- package/src/diagnostic.js +9 -1
- package/src/frontends/java.js +24 -0
- package/src/frontends/javascript-typescript.js +49 -2
- package/src/frontends/php.js +61 -2
- package/src/frontends/program.js +9 -0
- package/src/frontends/ruby.js +62 -2
- package/src/language-registry.js +130 -9
- package/src/project-build.js +353 -0
- package/src/project-cli.js +155 -0
- package/src/project-manifest.js +479 -0
- package/src/project-reporter.js +320 -0
- package/src/project-snapshot.js +403 -0
- package/src/project-watch-reporter.js +303 -0
- package/src/project-watch.js +870 -0
- package/src/publication.js +275 -58
- package/src/semantic/effects.js +1 -0
- package/src/semantic/provenance.js +8 -0
- package/src/semantic/types.js +101 -2
- package/src/semantic/validate.js +26 -0
- package/src/subprocess.js +49 -8
- package/src/target-check.js +844 -0
- package/src/toolchains.js +3 -2
- 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
|
+
*/
|