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,359 @@
1
+ // @ts-check
2
+
3
+ import {mkdir, mkdtemp, rm, writeFile} from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import {isDenseArray} from "./array.js"
7
+ import {createGeneratedArtifactSet} from "./artifacts.js"
8
+ import {SemantifoldDiagnostic} from "./diagnostic.js"
9
+ import {exceededOwnedDeadline, executeFileWithDeadline} from "./subprocess.js"
10
+ import {isSupportedTimeoutMs, maximumTimeoutMs} from "./timeout.js"
11
+ import {deterministicEnvironment} from "./toolchains.js"
12
+
13
+ const acceptanceMaxBuffer = 16 * 1024 * 1024
14
+ const launchErrorCodes = new Set([
15
+ "E2BIG", "EACCES", "EAGAIN", "ELOOP", "EMFILE", "ENAMETOOLONG", "ENFILE", "ENOENT", "ENOEXEC", "ENOMEM", "ENOTDIR", "ENOTSUP", "EPERM", "ETXTBSY"
16
+ ])
17
+ const orderedStages = Object.freeze(["parse", "generate", "compile", "link", "validate", "instantiate", "execute"])
18
+ const stageIndexes = new Map(orderedStages.map((stage, index) => [stage, index]))
19
+
20
+ /**
21
+ * Materializes one artifact set and runs ordered acceptance stages without a shell.
22
+ * @param {object} input - Acceptance request.
23
+ * @param {import("./semantic/types.js").GeneratedArtifactSet} input.artifacts - Complete input artifacts.
24
+ * @param {Readonly<Record<string, string | undefined>>} [input.environment] - Explicit process environment.
25
+ * @param {{arguments: string[], stage: import("./semantic/types.js").AcceptanceStage, tool: import("./semantic/types.js").DiscoveredToolchain}[]} input.stages - Ordered stages.
26
+ * @param {string} input.target - Target identity.
27
+ * @param {number} [input.timeoutMs] - Per-stage timeout.
28
+ * @returns {Promise<import("./semantic/types.js").AcceptanceResult>} Captured results after cleanup.
29
+ */
30
+ export async function runAcceptanceStages(input) {
31
+ if (!isPlainObject(input)) invalidRunner("Acceptance requires a request object.", "acceptance")
32
+ const artifacts = /** @type {import("./semantic/types.js").GeneratedArtifactSet} */ (input.artifacts)
33
+ const environment = /** @type {Readonly<Record<string, string | undefined>>} */ (
34
+ input.environment === undefined ? {PATH: process.env.PATH} : input.environment)
35
+ const stages = /** @type {{arguments: string[], stage: import("./semantic/types.js").AcceptanceStage, tool: import("./semantic/types.js").DiscoveredToolchain}[]} */ (input.stages)
36
+ const target = /** @type {string} */ (input.target)
37
+ const timeoutMs = /** @type {number} */ (input.timeoutMs === undefined ? 10_000 : input.timeoutMs)
38
+ const request = validateRequest({artifacts, environment, stages, target, timeoutMs})
39
+ const directory = await createAcceptanceDirectory(request.target)
40
+ /** @type {unknown} */
41
+ let primaryFailure
42
+ /** @type {import("./semantic/types.js").AcceptanceResult | undefined} */
43
+ let result
44
+ let primaryFailed = false
45
+
46
+ try {
47
+ await materializeArtifacts(request.artifacts, directory, request.target)
48
+
49
+ /** @type {import("./semantic/types.js").AcceptanceStageResult[]} */
50
+ const results = []
51
+
52
+ for (const stage of request.stages) {
53
+ try {
54
+ const result = await executeFileWithDeadline({
55
+ arguments: stage.arguments,
56
+ cwd: directory,
57
+ environment: request.environment,
58
+ executable: stage.tool.executable,
59
+ maxBuffer: acceptanceMaxBuffer,
60
+ timeoutMs: request.timeoutMs
61
+ })
62
+
63
+ results.push(Object.freeze({
64
+ arguments: Object.freeze([...stage.arguments]),
65
+ executable: stage.tool.executable,
66
+ stage: stage.stage,
67
+ stderr: result.stderr,
68
+ stdout: result.stdout,
69
+ version: stage.tool.version
70
+ }))
71
+ } catch (error) {
72
+ throw processDiagnostic(error, stage, request.target, request.timeoutMs)
73
+ }
74
+ }
75
+
76
+ result = Object.freeze({directory, stages: Object.freeze(results), target: request.target})
77
+ } catch (error) {
78
+ primaryFailed = true
79
+ primaryFailure = error
80
+ }
81
+
82
+ /** @type {unknown} */
83
+ let cleanupFailure
84
+ let cleanupFailed = false
85
+
86
+ try {
87
+ await removeAcceptanceDirectory(directory, request.target)
88
+ } catch (error) {
89
+ cleanupFailed = true
90
+ cleanupFailure = error
91
+ }
92
+
93
+ if (primaryFailed && cleanupFailed && primaryFailure instanceof Error) {
94
+ throw preservePrimaryFailure(primaryFailure, cleanupFailure, request.target)
95
+ }
96
+ if (primaryFailed) throw primaryFailure
97
+ if (cleanupFailed) throw cleanupFailure
98
+
99
+ return /** @type {import("./semantic/types.js").AcceptanceResult} */ (result)
100
+ }
101
+
102
+ /**
103
+ * Validates and detaches a staged acceptance request before filesystem mutation.
104
+ * @param {{
105
+ * artifacts: import("./semantic/types.js").GeneratedArtifactSet,
106
+ * environment: Readonly<Record<string, string | undefined>>,
107
+ * stages: {arguments: string[], stage: import("./semantic/types.js").AcceptanceStage, tool: import("./semantic/types.js").DiscoveredToolchain}[],
108
+ * target: string,
109
+ * timeoutMs: number
110
+ * }} input - Candidate request.
111
+ * @returns {{
112
+ * artifacts: import("./semantic/types.js").GeneratedArtifactSet,
113
+ * environment: Readonly<Record<string, string>>,
114
+ * stages: {arguments: string[], stage: import("./semantic/types.js").AcceptanceStage, tool: Readonly<{executable: string, version: string}>}[],
115
+ * target: string,
116
+ * timeoutMs: number
117
+ * }} Validated request.
118
+ */
119
+ function validateRequest(input) {
120
+ try {
121
+ if (typeof input.target != "string" || input.target.length == 0 || !isSupportedTimeoutMs(input.timeoutMs) ||
122
+ !isDenseArray(input.stages) || input.stages.length == 0) {
123
+ invalidRunner(`Acceptance requires a target, timeout from 1 through ${maximumTimeoutMs}ms, and non-empty ordered stages.`, input.target)
124
+ }
125
+ const artifacts = createGeneratedArtifactSet(input.artifacts)
126
+
127
+ if (artifacts.target != input.target) invalidRunner("Acceptance target must match the generated artifact-set target.", input.target)
128
+ const environment = deterministicEnvironment(input.environment)
129
+ let previousIndex = -1
130
+ const seen = new Set()
131
+ /** @type {{arguments: string[], stage: import("./semantic/types.js").AcceptanceStage, tool: Readonly<{executable: string, version: string}>}[]} */
132
+ const stages = []
133
+
134
+ for (let stagePosition = 0; stagePosition < input.stages.length; stagePosition += 1) {
135
+ const stage = input.stages[stagePosition]
136
+
137
+ if (!isPlainObject(stage)) invalidRunner("Acceptance stages must be distinct known stage records.", input.target)
138
+ const stageName = stage.stage
139
+
140
+ if (typeof stageName != "string" || !stageIndexes.has(stageName) || seen.has(stageName)) {
141
+ invalidRunner("Acceptance stages must be distinct known stage records.", input.target)
142
+ }
143
+ const index = /** @type {number} */ (stageIndexes.get(stageName))
144
+
145
+ if (index < previousIndex) invalidRunner("Acceptance stages must follow parse-to-execute order.", input.target)
146
+ previousIndex = index
147
+ seen.add(stageName)
148
+ const argumentCandidate = stage.arguments
149
+ const stageArguments = isDenseArray(argumentCandidate) ? [...argumentCandidate] : undefined
150
+ const toolCandidate = stage.tool
151
+ const tool = isPlainObject(toolCandidate) ? Object.freeze({
152
+ executable: toolCandidate.executable,
153
+ id: toolCandidate.id,
154
+ source: toolCandidate.source,
155
+ version: toolCandidate.version
156
+ }) : undefined
157
+
158
+ if (stageArguments == undefined || !stageArguments.every((argument) => typeof argument == "string" && !argument.includes("\0")) ||
159
+ !isTool(tool)) invalidRunner(`Acceptance stage '${stageName}' has an invalid tool or argument array.`, input.target)
160
+
161
+ stages.push(Object.freeze({arguments: stageArguments, stage: stageName, tool}))
162
+ }
163
+
164
+ return {artifacts, environment, stages, target: input.target, timeoutMs: input.timeoutMs}
165
+ } catch (error) {
166
+ if (error instanceof SemantifoldDiagnostic && error.code == "INVALID_ACCEPTANCE_RUNNER") throw error
167
+ if (error instanceof SemantifoldDiagnostic) {
168
+ throw new SemantifoldDiagnostic({
169
+ cause: error,
170
+ code: "INVALID_ACCEPTANCE_RUNNER",
171
+ language: typeof input.target == "string" ? input.target : "acceptance",
172
+ message: `Acceptance runner input is invalid: ${error.detail}`
173
+ })
174
+ }
175
+
176
+ return invalidRunner("Acceptance runner input is invalid.", typeof input.target == "string" ? input.target : "acceptance", error)
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Normalizes a subprocess failure with exact stage and toolchain context.
182
+ * @param {unknown} error - Subprocess rejection.
183
+ * @param {{arguments: string[], stage: string, tool: Readonly<{executable: string, version: string}>}} stage - Failed stage.
184
+ * @param {string} target - Acceptance target.
185
+ * @param {number} timeoutMs - Configured stage timeout.
186
+ * @returns {SemantifoldDiagnostic} Normalized failure.
187
+ */
188
+ function processDiagnostic(error, stage, target, timeoutMs) {
189
+ const fields = processErrorFields(error)
190
+ const outputLimited = fields.nativeCode == "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
191
+ const timedOut = !outputLimited && exceededOwnedDeadline(error)
192
+ const launchFailure = fields.nativeCode != undefined && launchErrorCodes.has(fields.nativeCode)
193
+ const signaled = !outputLimited && !timedOut && fields.signal != undefined
194
+ const code = outputLimited ? "ACCEPTANCE_OUTPUT_LIMIT" : timedOut ? "ACCEPTANCE_TIMEOUT" : launchFailure ? "ACCEPTANCE_LAUNCH_FAILURE" : signaled
195
+ ? "ACCEPTANCE_SIGNAL"
196
+ : "ACCEPTANCE_NONZERO_EXIT"
197
+ const reason = outputLimited ? `exceeded the ${acceptanceMaxBuffer}-byte output capture limit (${fields.nativeCode})` : timedOut
198
+ ? `timed out after ${timeoutMs}ms` : launchFailure
199
+ ? `could not launch (${fields.nativeCode})`
200
+ : signaled ? `terminated by signal ${fields.signal}`
201
+ : fields.exitCode != undefined ? `exited nonzero (${fields.exitCode})`
202
+ : `failed after launch (${fields.nativeCode ?? "unknown"})`
203
+
204
+ return new SemantifoldDiagnostic({
205
+ cause: outputLimited ? undefined : error instanceof Error ? error : undefined,
206
+ code,
207
+ executable: stage.tool.executable,
208
+ exitCode: fields.exitCode,
209
+ language: target,
210
+ message: `Acceptance stage '${stage.stage}' ${reason} using '${stage.tool.executable}' (${stage.tool.version}).`,
211
+ signal: fields.signal,
212
+ stage: stage.stage,
213
+ stderr: fields.stderr,
214
+ stdout: fields.stdout,
215
+ version: stage.tool.version
216
+ })
217
+ }
218
+
219
+ /**
220
+ * Narrows the stable fields of an opaque child-process failure.
221
+ * @param {unknown} error - Process rejection.
222
+ * @returns {{exitCode: number | undefined, nativeCode: string | undefined, signal: string | undefined, stderr: string, stdout: string}} Stable fields.
223
+ */
224
+ function processErrorFields(error) {
225
+ if (!error || typeof error != "object") return {exitCode: undefined, nativeCode: undefined, signal: undefined, stderr: "", stdout: ""}
226
+ const value = /** @type {Record<string, unknown>} */ (error)
227
+ const nativeCode = typeof value.code == "string" ? value.code : undefined
228
+ const outputLimited = nativeCode == "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
229
+
230
+ return {
231
+ exitCode: typeof value.code == "number" ? value.code : undefined,
232
+ nativeCode,
233
+ signal: typeof value.signal == "string" ? value.signal : undefined,
234
+ stderr: !outputLimited && typeof value.stderr == "string" ? value.stderr : "",
235
+ stdout: !outputLimited && typeof value.stdout == "string" ? value.stdout : ""
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Checks the discovered-tool fields consumed by the runner.
241
+ * @param {unknown} value - Candidate tool.
242
+ * @returns {value is import("./semantic/types.js").DiscoveredToolchain} Whether the tool contract is present.
243
+ */
244
+ function isTool(value) {
245
+ return isPlainObject(value) && typeof value.id == "string" && typeof value.executable == "string" && path.isAbsolute(value.executable) &&
246
+ !value.executable.includes("\0") && typeof value.version == "string" && value.version.length > 0 &&
247
+ (value.source == "canonical" || value.source == "override")
248
+ }
249
+
250
+ /**
251
+ * Keeps the primary acceptance error while recording bounded cleanup evidence in its cause chain.
252
+ * @param {Error} primaryFailure - Original materialization or stage failure.
253
+ * @param {unknown} cleanupFailure - Cleanup rejection.
254
+ * @param {string} target - Acceptance target.
255
+ * @returns {Error} The original primary error with structured cleanup evidence.
256
+ */
257
+ function preservePrimaryFailure(primaryFailure, cleanupFailure, target) {
258
+ const cleanupEvidence = new SemantifoldDiagnostic({
259
+ code: "ACCEPTANCE_CLEANUP_FAILURE",
260
+ language: cleanupFailure instanceof SemantifoldDiagnostic ? cleanupFailure.language : target,
261
+ message: "Isolated acceptance-directory cleanup failed after the primary acceptance failure."
262
+ })
263
+ const causes = primaryFailure.cause instanceof Error ? [primaryFailure.cause, cleanupEvidence] : [cleanupEvidence]
264
+
265
+ Object.defineProperty(primaryFailure, "cause", {
266
+ configurable: true,
267
+ value: new AggregateError(causes, "Acceptance failed and isolated-directory cleanup also failed."),
268
+ writable: true
269
+ })
270
+
271
+ return primaryFailure
272
+ }
273
+
274
+ /**
275
+ * Throws a normalized runner-configuration diagnostic.
276
+ * @param {string} message - Failure detail.
277
+ * @param {unknown} target - Candidate target identity.
278
+ * @param {unknown} [error] - Preserved cause.
279
+ * @returns {never} Always throws.
280
+ */
281
+ function invalidRunner(message, target, error) {
282
+ throw new SemantifoldDiagnostic({
283
+ cause: error instanceof Error ? error : undefined,
284
+ code: "INVALID_ACCEPTANCE_RUNNER",
285
+ language: typeof target == "string" && target.length > 0 ? target : "acceptance",
286
+ message
287
+ })
288
+ }
289
+
290
+ /**
291
+ * Checks for an ordinary string-keyed object.
292
+ * @param {unknown} value - Candidate value.
293
+ * @returns {value is Record<string, unknown>} Whether the value is a plain object.
294
+ */
295
+ function isPlainObject(value) {
296
+ return typeof value == "object" && value != null && !Array.isArray(value) && Object.getPrototypeOf(value) == Object.prototype
297
+ }
298
+
299
+ /**
300
+ * Removes the isolated acceptance directory with a normalized failure.
301
+ * @param {string} directory - Exact temporary directory.
302
+ * @param {string} target - Acceptance target.
303
+ * @returns {Promise<void>} Cleanup completion.
304
+ */
305
+ async function removeAcceptanceDirectory(directory, target) {
306
+ try {
307
+ await rm(directory, {force: true, recursive: true})
308
+ } catch (error) {
309
+ throw new SemantifoldDiagnostic({
310
+ cause: error instanceof Error ? error : undefined,
311
+ code: "ACCEPTANCE_CLEANUP_FAILURE",
312
+ language: target,
313
+ message: `Failed to remove isolated acceptance directory '${directory}'.`
314
+ })
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Creates one isolated acceptance directory with a normalized setup failure.
320
+ * @param {string} target - Acceptance target.
321
+ * @returns {Promise<string>} Exact temporary directory.
322
+ */
323
+ async function createAcceptanceDirectory(target) {
324
+ try {
325
+ return await mkdtemp(path.join(os.tmpdir(), "semantifold-acceptance-"))
326
+ } catch (error) {
327
+ throw new SemantifoldDiagnostic({
328
+ cause: error instanceof Error ? error : undefined,
329
+ code: "ACCEPTANCE_SETUP_FAILURE",
330
+ language: target,
331
+ message: "Failed to create an isolated acceptance directory."
332
+ })
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Writes only the already-validated generated artifacts into the isolated directory.
338
+ * @param {import("./semantic/types.js").GeneratedArtifactSet} artifacts - Complete validated set.
339
+ * @param {string} directory - Exact temporary directory.
340
+ * @param {string} target - Acceptance target.
341
+ * @returns {Promise<void>} Materialization completion.
342
+ */
343
+ async function materializeArtifacts(artifacts, directory, target) {
344
+ for (const artifact of artifacts.artifacts) {
345
+ const filename = path.join(directory, ...artifact.path.split("/"))
346
+
347
+ try {
348
+ await mkdir(path.dirname(filename), {recursive: true})
349
+ await writeFile(filename, artifact.content)
350
+ } catch (error) {
351
+ throw new SemantifoldDiagnostic({
352
+ cause: error instanceof Error ? error : undefined,
353
+ code: "ACCEPTANCE_MATERIALIZATION_FAILURE",
354
+ language: target,
355
+ message: `Failed to materialize generated artifact '${artifact.path}'.`
356
+ })
357
+ }
358
+ }
359
+ }
package/src/array.js ADDED
@@ -0,0 +1,19 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Checks that every index below an array's length is an own property.
5
+ * @param {unknown} value - Candidate ordered array.
6
+ * @returns {value is unknown[]} Whether the value is a dense array.
7
+ */
8
+ export function isDenseArray(value) {
9
+ if (!Array.isArray(value)) return false
10
+ let indexedProperties = 0
11
+
12
+ for (const property of Object.getOwnPropertyNames(value)) {
13
+ const index = Number(property)
14
+
15
+ if (Number.isInteger(index) && index >= 0 && index < value.length && String(index) == property) indexedProperties += 1
16
+ }
17
+
18
+ return indexedProperties == value.length
19
+ }
@@ -0,0 +1,34 @@
1
+ // @ts-check
2
+
3
+ import {hasOnlyUnicodeScalars} from "./semantic/scalars.js"
4
+
5
+ const lineTerminatorPattern = /[\n\r\u2028\u2029]/u
6
+
7
+ /**
8
+ * Validates filename metadata without imposing artifact-set path semantics.
9
+ * @param {unknown} value - Candidate filename.
10
+ * @returns {value is string} Whether the value is a non-empty single-line string.
11
+ */
12
+ export function isValidFilenameMetadata(value) {
13
+ return typeof value == "string" && value.length > 0 && !lineTerminatorPattern.test(value)
14
+ }
15
+
16
+ /**
17
+ * Validates a safe relative POSIX artifact path.
18
+ * @param {unknown} value - Candidate path.
19
+ * @returns {value is string} Whether the value is safe.
20
+ */
21
+ export function isSafeArtifactPath(value) {
22
+ if (typeof value != "string" || value.length == 0 || value.includes("\\") || value.includes("\0") ||
23
+ !hasOnlyUnicodeScalars(value)) return false
24
+ if (value.startsWith("/") || /^[A-Za-z]:/u.test(value)) return false
25
+
26
+ const parts = value.split("/")
27
+
28
+ return parts.every((part) => part.length > 0 && part != "." && part != ".." &&
29
+ [...part].every((character) => {
30
+ const codePoint = character.codePointAt(0)
31
+
32
+ return codePoint != undefined && codePoint > 31 && codePoint != 127
33
+ }))
34
+ }