semantifold 0.1.1 → 0.2.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 (90) hide show
  1. package/README.md +13 -6
  2. package/build/index.d.ts +32 -3
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +99 -4
  5. package/build/src/acceptance.d.ts +22 -0
  6. package/build/src/acceptance.d.ts.map +1 -0
  7. package/build/src/acceptance.js +339 -0
  8. package/build/src/array.d.ts +7 -0
  9. package/build/src/array.d.ts.map +1 -0
  10. package/build/src/array.js +18 -0
  11. package/build/src/artifact-path.d.ts +13 -0
  12. package/build/src/artifact-path.d.ts.map +1 -0
  13. package/build/src/artifact-path.js +30 -0
  14. package/build/src/artifacts.d.ts +7 -0
  15. package/build/src/artifacts.d.ts.map +1 -0
  16. package/build/src/artifacts.js +389 -0
  17. package/build/src/backends/identifiers.d.ts.map +1 -1
  18. package/build/src/backends/identifiers.js +10 -2
  19. package/build/src/backends/index.d.ts.map +1 -1
  20. package/build/src/backends/index.js +19 -37
  21. package/build/src/backends/python.d.ts +8 -0
  22. package/build/src/backends/python.d.ts.map +1 -0
  23. package/build/src/backends/python.js +162 -0
  24. package/build/src/backends/scalars.d.ts.map +1 -1
  25. package/build/src/backends/scalars.js +2 -1
  26. package/build/src/backends/shared.d.ts.map +1 -1
  27. package/build/src/backends/shared.js +9 -6
  28. package/build/src/binary-mapping.d.ts +32 -0
  29. package/build/src/binary-mapping.d.ts.map +1 -0
  30. package/build/src/binary-mapping.js +285 -0
  31. package/build/src/diagnostic.d.ts +36 -4
  32. package/build/src/diagnostic.d.ts.map +1 -1
  33. package/build/src/diagnostic.js +42 -3
  34. package/build/src/frontends/index.d.ts.map +1 -1
  35. package/build/src/frontends/index.js +5 -23
  36. package/build/src/frontends/python.d.ts +14 -0
  37. package/build/src/frontends/python.d.ts.map +1 -0
  38. package/build/src/frontends/python.js +615 -0
  39. package/build/src/frontends/scalars.d.ts.map +1 -1
  40. package/build/src/frontends/scalars.js +6 -1
  41. package/build/src/language-registry.d.ts +113 -0
  42. package/build/src/language-registry.d.ts.map +1 -0
  43. package/build/src/language-registry.js +372 -0
  44. package/build/src/mapping.js +3 -2
  45. package/build/src/semantic/location.d.ts +7 -0
  46. package/build/src/semantic/location.d.ts.map +1 -1
  47. package/build/src/semantic/location.js +10 -1
  48. package/build/src/semantic/provenance.js +3 -3
  49. package/build/src/semantic/types.d.ts +399 -2
  50. package/build/src/semantic/types.d.ts.map +1 -1
  51. package/build/src/semantic/types.js +110 -2
  52. package/build/src/subprocess.d.ts +29 -0
  53. package/build/src/subprocess.d.ts.map +1 -0
  54. package/build/src/subprocess.js +207 -0
  55. package/build/src/timeout.d.ts +8 -0
  56. package/build/src/timeout.d.ts.map +1 -0
  57. package/build/src/timeout.js +11 -0
  58. package/build/src/toolchains.d.ts +83 -0
  59. package/build/src/toolchains.d.ts.map +1 -0
  60. package/build/src/toolchains.js +413 -0
  61. package/docs/architecture.md +7 -3
  62. package/docs/language-support.md +25 -4
  63. package/docs/parser-qualification.md +70 -0
  64. package/docs/source-maps.md +31 -4
  65. package/docs/testing.md +21 -2
  66. package/index.js +109 -4
  67. package/package.json +4 -2
  68. package/src/acceptance.js +359 -0
  69. package/src/array.js +19 -0
  70. package/src/artifact-path.js +34 -0
  71. package/src/artifacts.js +420 -0
  72. package/src/backends/identifiers.js +9 -1
  73. package/src/backends/index.js +16 -32
  74. package/src/backends/python.js +175 -0
  75. package/src/backends/scalars.js +1 -0
  76. package/src/backends/shared.js +8 -5
  77. package/src/binary-mapping.js +305 -0
  78. package/src/diagnostic.js +34 -2
  79. package/src/frontends/index.js +5 -20
  80. package/src/frontends/python.js +689 -0
  81. package/src/frontends/scalars.js +5 -0
  82. package/src/language-registry.js +403 -0
  83. package/src/mapping.js +2 -1
  84. package/src/semantic/location.js +10 -0
  85. package/src/semantic/provenance.js +2 -2
  86. package/src/semantic/types.js +124 -1
  87. package/src/subprocess.js +203 -0
  88. package/src/timeout.js +12 -0
  89. package/src/toolchains.js +444 -0
  90. package/tsconfig.json +1 -0
@@ -0,0 +1,203 @@
1
+ // @ts-check
2
+
3
+ import {spawn} from "node:child_process"
4
+ import {StringDecoder} from "node:string_decoder"
5
+
6
+ const forcedTerminationGraceMs = 250
7
+ const retainedPipeGraceMs = 250
8
+ const deadlineFailures = new WeakSet()
9
+
10
+ /**
11
+ * Executes one exact file while owning its complete bounded lifecycle.
12
+ * @param {object} input - Process request.
13
+ * @param {readonly string[]} input.arguments - Exact argument array.
14
+ * @param {string} [input.cwd] - Working directory.
15
+ * @param {Readonly<Record<string, string>>} input.environment - Exact environment.
16
+ * @param {string} input.executable - Exact executable path.
17
+ * @param {number} input.maxBuffer - Maximum bytes captured per output stream.
18
+ * @param {number} input.timeoutMs - Deadline before termination begins.
19
+ * @returns {Promise<{stderr: string, stdout: string}>} Output after the owned process group closes.
20
+ */
21
+ export function executeFileWithDeadline({arguments: processArguments, cwd, environment, executable, maxBuffer, timeoutMs}) {
22
+ if (process.platform == "win32") {
23
+ return Promise.reject(Object.assign(new Error("Owned process-tree termination is unavailable on this platform."), {
24
+ code: "ENOTSUP",
25
+ stderr: "",
26
+ stdout: ""
27
+ }))
28
+ }
29
+
30
+ return new Promise((resolve, reject) => {
31
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
32
+ let deadlineTimer
33
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
34
+ let forceTimer
35
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
36
+ let pipeTimer
37
+ /** @type {Error | undefined} */
38
+ let launchError
39
+ let deadlineExceeded = false
40
+ let outputExceeded = false
41
+ let settled = false
42
+ const stderr = outputCapture(maxBuffer, () => terminateForOutputLimit())
43
+ const stdout = outputCapture(maxBuffer, () => terminateForOutputLimit())
44
+ const child = spawn(executable, [...processArguments], {
45
+ cwd,
46
+ detached: true,
47
+ env: environment,
48
+ stdio: ["ignore", "pipe", "pipe"]
49
+ })
50
+ const processGroupId = child.pid
51
+
52
+ child.stderr.on("data", stderr.receive)
53
+ child.stdout.on("data", stdout.receive)
54
+ child.once("error", (error) => {
55
+ launchError = error
56
+ })
57
+ child.once("spawn", () => {
58
+ if (processGroupId == undefined || !verifyOwnedProcessGroup(processGroupId)) {
59
+ launchError = Object.assign(new Error("Spawned subprocess did not establish its required process group."), {code: "ENOTSUP"})
60
+ child.kill("SIGKILL")
61
+ return
62
+ }
63
+
64
+ deadlineTimer = setTimeout(() => {
65
+ if (settled) return
66
+ deadlineExceeded = true
67
+ beginTermination(processGroupId)
68
+ }, timeoutMs)
69
+ })
70
+ child.once("close", (code, signal) => {
71
+ settled = true
72
+ if (deadlineTimer != undefined) clearTimeout(deadlineTimer)
73
+ if (forceTimer != undefined) clearTimeout(forceTimer)
74
+ if (pipeTimer != undefined) clearTimeout(pipeTimer)
75
+ const capturedStderr = stderr.finish()
76
+ const capturedStdout = stdout.finish()
77
+
78
+ if (launchError) {
79
+ reject(Object.assign(launchError, {stderr: capturedStderr, stdout: capturedStdout}))
80
+ } else if (outputExceeded) {
81
+ reject(Object.assign(new Error(`Subprocess exceeded its ${maxBuffer}-byte output capture limit.`), {
82
+ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
83
+ signal,
84
+ stderr: capturedStderr,
85
+ stdout: capturedStdout
86
+ }))
87
+ } else if (deadlineExceeded) {
88
+ const timeoutError = Object.assign(new Error("Subprocess exceeded its owned deadline."), {
89
+ code,
90
+ signal,
91
+ stderr: capturedStderr,
92
+ stdout: capturedStdout
93
+ })
94
+
95
+ deadlineFailures.add(timeoutError)
96
+ reject(timeoutError)
97
+ } else if (code != 0 || signal != null) {
98
+ reject(Object.assign(new Error("Subprocess failed after launch."), {
99
+ code,
100
+ signal,
101
+ stderr: capturedStderr,
102
+ stdout: capturedStdout
103
+ }))
104
+ } else resolve({stderr: capturedStderr, stdout: capturedStdout})
105
+ })
106
+
107
+ /** Begins bounded termination after output capture crosses its limit. */
108
+ function terminateForOutputLimit() {
109
+ if (outputExceeded || settled) return
110
+ outputExceeded = true
111
+ if (processGroupId != undefined) beginTermination(processGroupId)
112
+ }
113
+
114
+ /**
115
+ * Signals the owned group now and escalates if its inherited pipes remain open.
116
+ * @param {number} ownedGroupId - Spawn-verified process-group identity.
117
+ */
118
+ function beginTermination(ownedGroupId) {
119
+ signalOwnedProcessGroup(ownedGroupId, "SIGTERM")
120
+ if (!settled && forceTimer == undefined) {
121
+ forceTimer = setTimeout(() => {
122
+ if (settled) return
123
+ signalOwnedProcessGroup(ownedGroupId, "SIGKILL")
124
+ pipeTimer = setTimeout(() => {
125
+ if (settled) return
126
+ child.stdout.destroy()
127
+ child.stderr.destroy()
128
+ }, retainedPipeGraceMs)
129
+ }, forcedTerminationGraceMs)
130
+ }
131
+ }
132
+ })
133
+ }
134
+
135
+ /**
136
+ * Creates one byte-bounded UTF-8 stream capture.
137
+ * @param {number} maximumBytes - Maximum captured bytes.
138
+ * @param {() => void} onExceeded - First-overflow callback.
139
+ * @returns {{finish: () => string, receive: (chunk: Buffer) => void}} Capture callbacks.
140
+ */
141
+ function outputCapture(maximumBytes, onExceeded) {
142
+ const decoder = new StringDecoder("utf8")
143
+ let byteLength = 0
144
+ let content = ""
145
+ let exceeded = false
146
+
147
+ return {
148
+ finish() {
149
+ return content + decoder.end()
150
+ },
151
+ receive(chunk) {
152
+ if (exceeded) return
153
+ const remaining = maximumBytes - byteLength
154
+
155
+ if (chunk.length > remaining) {
156
+ if (remaining > 0) content += decoder.write(chunk.subarray(0, remaining))
157
+ exceeded = true
158
+ onExceeded()
159
+ return
160
+ }
161
+ byteLength += chunk.length
162
+ content += decoder.write(chunk)
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Verifies that the spawned child is the live leader of its distinct POSIX group.
169
+ * @param {number} processGroupId - Spawn-returned child PID and required group ID.
170
+ * @returns {boolean} Whether the owned group exists.
171
+ */
172
+ function verifyOwnedProcessGroup(processGroupId) {
173
+ try {
174
+ process.kill(-processGroupId, 0)
175
+ return true
176
+ } catch (error) {
177
+ if (error instanceof Error && "code" in error && error.code == "ESRCH") return false
178
+ throw error
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Signals only the verified process group created for the exact spawned child.
184
+ * @param {number} processGroupId - Spawn-returned child PID and POSIX process-group ID.
185
+ * @param {"SIGKILL" | "SIGTERM"} signal - Lifecycle signal.
186
+ * @returns {void}
187
+ */
188
+ function signalOwnedProcessGroup(processGroupId, signal) {
189
+ try {
190
+ process.kill(-processGroupId, signal)
191
+ } catch (error) {
192
+ if (!(error instanceof Error && "code" in error && error.code == "ESRCH")) throw error
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Reports whether this module's deadline timer initiated termination.
198
+ * @param {unknown} error - Subprocess failure.
199
+ * @returns {boolean} Whether the failure belongs to an owned deadline.
200
+ */
201
+ export function exceededOwnedDeadline(error) {
202
+ return typeof error == "object" && error != null && deadlineFailures.has(error)
203
+ }
package/src/timeout.js ADDED
@@ -0,0 +1,12 @@
1
+ // @ts-check
2
+
3
+ export const maximumTimeoutMs = 2_147_483_647
4
+
5
+ /**
6
+ * Checks whether a public subprocess timeout fits one exact Node timer.
7
+ * @param {unknown} value - Candidate timeout.
8
+ * @returns {value is number} Whether the timeout is a supported positive integer.
9
+ */
10
+ export function isSupportedTimeoutMs(value) {
11
+ return typeof value == "number" && Number.isSafeInteger(value) && value > 0 && value <= maximumTimeoutMs
12
+ }
@@ -0,0 +1,444 @@
1
+ // @ts-check
2
+
3
+ import {constants as fsConstants} from "node:fs"
4
+ import {access, realpath, stat} from "node:fs/promises"
5
+ import path from "node:path"
6
+ import {isDenseArray} from "./array.js"
7
+ import {SemantifoldDiagnostic} from "./diagnostic.js"
8
+ import {exceededOwnedDeadline, executeFileWithDeadline} from "./subprocess.js"
9
+ import {isSupportedTimeoutMs, maximumTimeoutMs} from "./timeout.js"
10
+
11
+ const defaultTimeoutMs = 10_000
12
+
13
+ export const canonicalToolchains = deepFreeze({
14
+ php: definition("php", "SEMANTIFOLD_PHP", ["--version"], /^PHP 8\./u),
15
+ ruby: definition("ruby", "SEMANTIFOLD_RUBY", ["--version"], /^ruby 3\./u),
16
+ node: definition("node", "SEMANTIFOLD_NODE", ["--version"], /^v24\./u),
17
+ tsc: definition("tsc", "SEMANTIFOLD_TSC", ["--version"], /^Version 7\./u),
18
+ javac: definition("javac", "SEMANTIFOLD_JAVAC", ["-version"], /^javac (?:1[7-9]|2[0-9])\./u),
19
+ java: definition("java", "SEMANTIFOLD_JAVA", ["-version"], /^(?:openjdk|java) version "(?:1[7-9]|2[0-9])\./u),
20
+ python: definition("python3", "SEMANTIFOLD_PYTHON", ["--version"], /^Python 3\./u)
21
+ })
22
+
23
+ /**
24
+ * Discovers one repository-declared canonical toolchain.
25
+ * @param {keyof typeof canonicalToolchains} id - Canonical toolchain ID.
26
+ * @param {{environment?: Readonly<Record<string, string | undefined>>, override?: string, timeoutMs?: number}} [options] - Discovery options.
27
+ * @returns {Promise<import("./semantic/types.js").DiscoveredToolchain>} Exact executable and version.
28
+ */
29
+ export async function discoverCanonicalToolchain(id, options = {}) {
30
+ if (typeof id != "string" || id.length == 0) {
31
+ invalidToolchain("Canonical toolchain ID must be a non-empty primitive string.", "toolchain")
32
+ }
33
+ if (!isPlainObject(options)) invalidToolchain("Canonical toolchain options must be an object.", id)
34
+ const definition = Object.hasOwn(canonicalToolchains, id)
35
+ ? canonicalToolchains[/** @type {keyof typeof canonicalToolchains} */ (id)]
36
+ : undefined
37
+
38
+ if (!definition) invalidToolchain(`Unknown canonical toolchain '${id}'.`, id)
39
+ const configurationEnvironment = options.environment === undefined
40
+ ? undefined
41
+ : snapshotEnvironment(options.environment, id)
42
+ const environment = configurationEnvironment ?? defaultEnvironment()
43
+ const configured = options.override === undefined
44
+ ? configurationEnvironment === undefined
45
+ ? process.env[definition.overrideEnvironmentVariable]
46
+ : configurationEnvironment[definition.overrideEnvironmentVariable]
47
+ : options.override
48
+
49
+ return discoverToolchain({
50
+ canonicalCommand: definition.canonicalCommand,
51
+ environment,
52
+ id,
53
+ override: configured,
54
+ supportedVersion: definition.supportedVersion,
55
+ timeoutMs: options.timeoutMs,
56
+ versionArguments: definition.versionArguments
57
+ })
58
+ }
59
+
60
+ /**
61
+ * Resolves and versions one configured or canonical executable without invoking a shell.
62
+ * @param {object} input - Discovery request.
63
+ * @param {string} input.canonicalCommand - Canonical PATH command.
64
+ * @param {Readonly<Record<string, string | undefined>>} [input.environment] - Explicit discovery environment.
65
+ * @param {string} input.id - Toolchain ID.
66
+ * @param {string} [input.override] - Configured absolute executable path.
67
+ * @param {RegExp} [input.supportedVersion] - Full captured-output version policy.
68
+ * @param {number} [input.timeoutMs] - Version command timeout.
69
+ * @param {string[]} input.versionArguments - Exact version argument array.
70
+ * @returns {Promise<import("./semantic/types.js").DiscoveredToolchain>} Discovered tool.
71
+ */
72
+ export async function discoverToolchain(input) {
73
+ if (!isPlainObject(input)) invalidToolchain("Toolchain discovery requires a request object.", "toolchain")
74
+ const versionArgumentCandidate = input.versionArguments
75
+ const versionArguments = isDenseArray(versionArgumentCandidate) ? [...versionArgumentCandidate] : undefined
76
+ const candidate = {
77
+ canonicalCommand: input.canonicalCommand,
78
+ environment: input.environment === undefined ? defaultEnvironment() : input.environment,
79
+ id: input.id,
80
+ override: input.override,
81
+ supportedVersion: input.supportedVersion,
82
+ timeoutMs: input.timeoutMs === undefined ? defaultTimeoutMs : input.timeoutMs,
83
+ versionArguments
84
+ }
85
+
86
+ validateDiscovery(candidate)
87
+ const canonicalCommand = /** @type {string} */ (candidate.canonicalCommand)
88
+ const environment = /** @type {Readonly<Record<string, string | undefined>>} */ (candidate.environment)
89
+ const id = /** @type {string} */ (candidate.id)
90
+ const override = /** @type {string | undefined} */ (candidate.override)
91
+ const supportedVersion = candidate.supportedVersion instanceof RegExp
92
+ ? new RegExp(candidate.supportedVersion.source, candidate.supportedVersion.flags)
93
+ : undefined
94
+ const timeoutMs = /** @type {number} */ (candidate.timeoutMs)
95
+ const validatedVersionArguments = /** @type {string[]} */ (candidate.versionArguments)
96
+ const normalizedEnvironment = deterministicEnvironment(environment)
97
+ const {executable, source} = override === undefined
98
+ ? await resolveCanonical(id, canonicalCommand, normalizedEnvironment.PATH ?? "")
99
+ : {executable: await resolveOverride(id, override, canonicalCommand), source: /** @type {const} */ ("override")}
100
+ /** @type {{stdout: string, stderr: string}} */
101
+ let result
102
+
103
+ try {
104
+ result = await executeFileWithDeadline({
105
+ arguments: validatedVersionArguments,
106
+ environment: normalizedEnvironment,
107
+ executable,
108
+ maxBuffer: 1024 * 1024,
109
+ timeoutMs
110
+ })
111
+ } catch (error) {
112
+ const processError = processErrorFields(error)
113
+ const timedOut = exceededOwnedDeadline(error)
114
+
115
+ throw new SemantifoldDiagnostic({
116
+ ...processError,
117
+ cause: error instanceof Error ? error : undefined,
118
+ code: timedOut ? "TOOL_VERSION_TIMEOUT" : "TOOL_VERSION_FAILURE",
119
+ command: canonicalCommand,
120
+ executable,
121
+ language: id,
122
+ message: timedOut
123
+ ? `Version command for '${executable}' exceeded ${timeoutMs}ms.`
124
+ : `Version command for '${executable}' failed.`,
125
+ stderr: processError.stderr,
126
+ stdout: processError.stdout
127
+ })
128
+ }
129
+
130
+ const {stderr, stdout} = result
131
+ const versionOutput = [stdout, stderr].filter((value) => value.length > 0).join("\n").trim()
132
+ const version = versionOutput.split(/\r?\n/u)[0] ?? ""
133
+
134
+ if (version.length == 0) {
135
+ throw new SemantifoldDiagnostic({
136
+ code: "TOOL_VERSION_FAILURE",
137
+ command: canonicalCommand,
138
+ executable,
139
+ language: id,
140
+ message: `Version command for '${executable}' returned no version output.`,
141
+ stderr,
142
+ stdout
143
+ })
144
+ }
145
+ if (supportedVersion && !supportedVersion.test(versionOutput)) {
146
+ throw new SemantifoldDiagnostic({
147
+ code: "TOOL_UNSUPPORTED_VERSION",
148
+ command: canonicalCommand,
149
+ executable,
150
+ language: id,
151
+ message: `Executable '${executable}' reported unsupported version '${version}'.`,
152
+ stderr,
153
+ stdout,
154
+ version
155
+ })
156
+ }
157
+
158
+ return deepFreeze({
159
+ command: canonicalCommand,
160
+ executable,
161
+ id,
162
+ source,
163
+ version,
164
+ versionArguments: validatedVersionArguments,
165
+ versionOutput
166
+ })
167
+ }
168
+
169
+ /**
170
+ * Builds one canonical immutable toolchain declaration.
171
+ * @param {string} canonicalCommand - PATH command.
172
+ * @param {string} overrideEnvironmentVariable - Configured override variable.
173
+ * @param {string[]} versionArguments - Exact version arguments.
174
+ * @param {RegExp} supportedVersion - Supported version output.
175
+ * @returns {{canonicalCommand: string, overrideEnvironmentVariable: string, supportedVersion: RegExp, versionArguments: string[]}} Declaration.
176
+ */
177
+ function definition(canonicalCommand, overrideEnvironmentVariable, versionArguments, supportedVersion) {
178
+ return {canonicalCommand, overrideEnvironmentVariable, supportedVersion, versionArguments}
179
+ }
180
+
181
+ /**
182
+ * Validates tool discovery before filesystem or process access.
183
+ * @param {{
184
+ * canonicalCommand: unknown,
185
+ * environment: unknown,
186
+ * id: unknown,
187
+ * override: unknown,
188
+ * supportedVersion: unknown,
189
+ * timeoutMs: unknown,
190
+ * versionArguments: unknown
191
+ * }} input - Candidate discovery fields.
192
+ * @returns {void}
193
+ */
194
+ function validateDiscovery(input) {
195
+ if (typeof input.id != "string" || input.id.length == 0 ||
196
+ typeof input.canonicalCommand != "string" || input.canonicalCommand.length == 0 ||
197
+ input.canonicalCommand.includes("/") || input.canonicalCommand.includes("\\") || input.canonicalCommand.includes("\0") ||
198
+ !isDenseArray(input.versionArguments) || !input.versionArguments.every(validArgument) ||
199
+ !isEnvironment(input.environment) || !isSupportedTimeoutMs(input.timeoutMs) ||
200
+ (input.override !== undefined && (typeof input.override != "string" || !path.isAbsolute(input.override) || input.override.includes("\0"))) ||
201
+ (input.supportedVersion !== undefined && (!(input.supportedVersion instanceof RegExp) || input.supportedVersion.global || input.supportedVersion.sticky))) {
202
+ invalidToolchain(`Toolchain discovery requires a canonical basename, absolute override, argument array, environment, and timeout from 1 through ${maximumTimeoutMs}ms.`,
203
+ typeof input.id == "string" ? input.id : "toolchain")
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Resolves one unambiguous canonical command from an explicit PATH.
209
+ * @param {string} id - Toolchain ID.
210
+ * @param {string} command - Canonical command.
211
+ * @param {string} pathValue - Explicit PATH.
212
+ * @returns {Promise<{executable: string, source: "canonical"}>} Exact executable.
213
+ */
214
+ async function resolveCanonical(id, command, pathValue) {
215
+ const matches = new Map()
216
+
217
+ for (const directory of pathValue.split(path.delimiter)) {
218
+ if (directory.length == 0 || !path.isAbsolute(directory)) continue
219
+ const candidate = path.join(directory, command)
220
+
221
+ try {
222
+ const exact = await resolveExecutableFile(candidate)
223
+
224
+ if (exact != undefined) matches.set(exact, exact)
225
+ } catch {
226
+ // A PATH candidate is absent or non-executable; discovery continues deterministically.
227
+ }
228
+ }
229
+
230
+ if (matches.size == 0) {
231
+ throw new SemantifoldDiagnostic({
232
+ code: "TOOL_NOT_FOUND",
233
+ command,
234
+ language: id,
235
+ message: `Canonical command '${command}' was not found as an executable on PATH.`
236
+ })
237
+ }
238
+ if (matches.size > 1) {
239
+ throw new SemantifoldDiagnostic({
240
+ code: "TOOL_AMBIGUOUS",
241
+ command,
242
+ language: id,
243
+ message: `Canonical command '${command}' resolves to multiple executables: ${[...matches.keys()].join(", ")}.`
244
+ })
245
+ }
246
+
247
+ return {executable: /** @type {string} */ ([...matches.keys()][0]), source: /** @type {const} */ ("canonical")}
248
+ }
249
+
250
+ /**
251
+ * Resolves one configured absolute executable.
252
+ * @param {string} id - Toolchain ID.
253
+ * @param {string} override - Absolute override.
254
+ * @param {string} command - Canonical command.
255
+ * @returns {Promise<string>} Exact executable path.
256
+ */
257
+ async function resolveOverride(id, override, command) {
258
+ /** @type {Error | undefined} */
259
+ let cause
260
+
261
+ try {
262
+ const exact = await resolveExecutableFile(override)
263
+
264
+ if (exact != undefined) return exact
265
+ } catch (error) {
266
+ cause = error instanceof Error ? error : undefined
267
+ }
268
+
269
+ throw new SemantifoldDiagnostic({
270
+ cause,
271
+ code: "TOOL_NOT_FOUND",
272
+ command,
273
+ executable: override,
274
+ language: id,
275
+ message: `Configured executable '${override}' is missing or not executable.`
276
+ })
277
+ }
278
+
279
+ /**
280
+ * Resolves one candidate to an executable regular file.
281
+ * @param {string} candidate - Candidate path.
282
+ * @returns {Promise<string | undefined>} Exact regular executable, if the file type is valid.
283
+ */
284
+ async function resolveExecutableFile(candidate) {
285
+ const exact = await realpath(candidate)
286
+ const status = await stat(exact)
287
+
288
+ if (!status.isFile()) return undefined
289
+ await access(exact, fsConstants.X_OK)
290
+
291
+ return exact
292
+ }
293
+
294
+ /**
295
+ * Produces the minimal deterministic process environment.
296
+ * @param {Readonly<Record<string, string | undefined>>} environment - Explicit inputs.
297
+ * @returns {Readonly<Record<string, string>>} Normalized environment.
298
+ */
299
+ export function deterministicEnvironment(environment = defaultEnvironment()) {
300
+ const entries = environmentEntries(environment)
301
+
302
+ if (entries == undefined) invalidToolchain("Process environment must be a plain own-property data record of string or undefined values.", "environment")
303
+ /** @type {Record<string, string>} */
304
+ const normalized = Object.create(null)
305
+
306
+ for (const [key, value] of entries) {
307
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) || key.includes("\0") || (value !== undefined && value.includes("\0"))) {
308
+ invalidToolchain("Process environment contains an invalid key or value.", "environment")
309
+ }
310
+ if (value !== undefined) normalized[key] = value
311
+ }
312
+ normalized.LANG = "C.UTF-8"
313
+ normalized.LC_ALL = "C.UTF-8"
314
+ normalized.TZ = "UTC"
315
+ normalized.PATH ??= "/usr/local/bin:/usr/bin:/bin"
316
+
317
+ return Object.freeze(normalized)
318
+ }
319
+
320
+ /**
321
+ * Retains only executable lookup from the ambient environment.
322
+ * @returns {Readonly<Record<string, string | undefined>>} Minimal default environment.
323
+ */
324
+ function defaultEnvironment() {
325
+ return {PATH: process.env.PATH}
326
+ }
327
+
328
+ /**
329
+ * Narrows stable fields from a version subprocess rejection.
330
+ * @param {unknown} error - Process rejection.
331
+ * @returns {{exitCode: number | undefined, signal: string | undefined, stderr: string, stdout: string}} Stable fields.
332
+ */
333
+ function processErrorFields(error) {
334
+ if (!error || typeof error != "object") return {exitCode: undefined, signal: undefined, stderr: "", stdout: ""}
335
+ const value = /** @type {Record<string, unknown>} */ (error)
336
+
337
+ return {
338
+ exitCode: typeof value.code == "number" ? value.code : undefined,
339
+ signal: typeof value.signal == "string" ? value.signal : undefined,
340
+ stderr: typeof value.stderr == "string" ? value.stderr : "",
341
+ stdout: typeof value.stdout == "string" ? value.stdout : ""
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Checks one shell-free argument.
347
+ * @param {unknown} value - Candidate argument.
348
+ * @returns {value is string} Whether the argument is safe to pass directly.
349
+ */
350
+ function validArgument(value) {
351
+ return typeof value == "string" && !value.includes("\0")
352
+ }
353
+
354
+ /**
355
+ * Checks explicit process-environment values.
356
+ * @param {unknown} value - Candidate environment.
357
+ * @returns {value is Readonly<Record<string, string | undefined>>} Whether entries are strings or undefined.
358
+ */
359
+ function isEnvironment(value) {
360
+ return environmentEntries(value) != undefined
361
+ }
362
+
363
+ /**
364
+ * Detaches an explicit environment before canonical override lookup can yield.
365
+ * @param {unknown} value - Candidate environment record.
366
+ * @param {string} id - Toolchain identity.
367
+ * @returns {Readonly<Record<string, string | undefined>>} Owned environment data.
368
+ */
369
+ function snapshotEnvironment(value, id) {
370
+ const entries = environmentEntries(value)
371
+
372
+ if (entries == undefined) {
373
+ invalidToolchain("Canonical toolchain environment must be a plain own-property data record of string or undefined values.", id)
374
+ }
375
+ /** @type {Record<string, string | undefined>} */
376
+ const snapshot = Object.create(null)
377
+
378
+ for (const [key, entry] of entries) snapshot[key] = entry
379
+
380
+ return Object.freeze(snapshot)
381
+ }
382
+
383
+ /**
384
+ * Reads only enumerable own data properties from an ordinary or null-prototype record.
385
+ * @param {unknown} value - Candidate environment record.
386
+ * @returns {[string, string | undefined][] | undefined} Detached entries when valid.
387
+ */
388
+ function environmentEntries(value) {
389
+ if (typeof value != "object" || value == null || Array.isArray(value)) return undefined
390
+
391
+ try {
392
+ const prototype = Object.getPrototypeOf(value)
393
+
394
+ if (prototype !== Object.prototype && prototype !== null) return undefined
395
+ /** @type {[string, string | undefined][]} */
396
+ const entries = []
397
+
398
+ for (const key of Reflect.ownKeys(value)) {
399
+ if (typeof key != "string") return undefined
400
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
401
+
402
+ if (descriptor == undefined || !descriptor.enumerable || !("value" in descriptor) ||
403
+ (descriptor.value !== undefined && typeof descriptor.value != "string")) return undefined
404
+ entries.push([key, descriptor.value])
405
+ }
406
+
407
+ return entries
408
+ } catch {
409
+ return undefined
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Checks for an ordinary string-keyed object.
415
+ * @param {unknown} value - Candidate value.
416
+ * @returns {value is Record<string, unknown>} Whether the value is a plain object.
417
+ */
418
+ function isPlainObject(value) {
419
+ return typeof value == "object" && value != null && !Array.isArray(value) && Object.getPrototypeOf(value) == Object.prototype
420
+ }
421
+
422
+ /**
423
+ * Throws a normalized discovery-configuration diagnostic.
424
+ * @param {string} message - Failure detail.
425
+ * @param {string} id - Toolchain identity.
426
+ * @returns {never} Always throws.
427
+ */
428
+ function invalidToolchain(message, id) {
429
+ throw new SemantifoldDiagnostic({code: "INVALID_TOOLCHAIN", language: id, message})
430
+ }
431
+
432
+ /**
433
+ * Deeply freezes canonical declarations and discovery results.
434
+ * @template T
435
+ * @param {T} value - Toolchain-owned value.
436
+ * @returns {T} Frozen value.
437
+ */
438
+ function deepFreeze(value) {
439
+ if (!value || typeof value != "object" || Object.isFrozen(value)) return value
440
+
441
+ for (const nested of Object.values(value)) deepFreeze(nested)
442
+
443
+ return Object.freeze(value)
444
+ }
package/tsconfig.json CHANGED
@@ -10,6 +10,7 @@
10
10
  "target": "ES2024",
11
11
  "moduleResolution": "nodenext",
12
12
  "module": "nodenext",
13
+ "types": ["node"],
13
14
  "strict": true
14
15
  },
15
16
  "include": ["index.js", "src/**/*"]