workspec 1.0.1 → 1.1.1

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  This package provides:
4
4
 
5
- - A programmatic WorkSpec v1.0.0 validator (`validate()`) that emits RFC 7807 Problem Details
5
+ - A programmatic WorkSpec v1.1.0 validator (`validate()`) that emits RFC 7807 Problem Details
6
6
  - A `workspec` CLI with `validate`, `migrate`, and `format` commands
7
7
 
8
8
  ## Install
@@ -31,8 +31,19 @@ Validate:
31
31
  ```bash
32
32
  workspec validate path/to/file.workspec.json
33
33
  workspec validate path/to/file.workspec.json --json
34
+ workspec validate -custom path/to/simulation-validator-custom.js path/to/file.workspec.json -y
35
+ workspec validate path/to/file.workspec.json --custom path/to/custom-validator.js --custom-catalog path/to/metrics-catalog-custom.json -y
34
36
  ```
35
37
 
38
+ `-custom/--custom` supports:
39
+ - Metrics Editor-style `validate*` functions in a plain `.js` file
40
+ - Node-style exports (`module.exports = function (...) { ... }` or `module.exports.validate = ...`)
41
+
42
+ Custom validators run user-provided JavaScript and may be dangerous/malicious. The CLI now requires an interactive `Y` confirmation before custom validation executes. Use `-y` / `--yes` to skip the confirmation (required in non-interactive runs like CI).
43
+ Custom validation execution is isolated in a subprocess with a hard timeout, and catalog/discovered entry points are restricted to `validate*` function names.
44
+
45
+ If `--custom-catalog` is omitted, the CLI auto-loads `metrics-catalog-custom.json` from the custom validator file's folder when present.
46
+
36
47
  Migrate (Previous UAW Syntax -> WorkSpec v1.0.0):
37
48
 
38
49
  ```bash
package/bin/workspec.js CHANGED
@@ -3,16 +3,18 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const readline = require('readline');
6
7
 
7
8
  const validator = require(path.join(__dirname, '..', 'workspec-validator.js'));
8
9
  const migrator = require(path.join(__dirname, '..', 'workspec-migrate-v1-to-v2.js'));
10
+ const customValidationRunner = require(path.join(__dirname, '..', 'custom-validation-runner.js'));
9
11
 
10
12
  function printHelp(exitCode = 0) {
11
13
  const lines = [
12
- 'workspec - WorkSpec v1.0.0 CLI',
14
+ 'workspec - WorkSpec v1.1.0 CLI',
13
15
  '',
14
16
  'Usage:',
15
- ' workspec validate <file.workspec.json> [--json]',
17
+ ' workspec validate <file.workspec.json> [-custom <validator.js>] [--custom-catalog <catalog.json>] [--json] [-y]',
16
18
  ' workspec migrate <file.json> --out <output.json> [--schema]',
17
19
  ' workspec format <file.json> [--write] [--out <output.json>]',
18
20
  '',
@@ -22,6 +24,10 @@ function printHelp(exitCode = 0) {
22
24
  ' format Pretty-print JSON (2-space).',
23
25
  '',
24
26
  'Flags:',
27
+ ' -custom <path> Run custom validator code (Metrics Editor compatible).',
28
+ ' --custom <path> Same as -custom.',
29
+ ' --custom-catalog <path> Optional metrics-catalog JSON for custom metrics.',
30
+ ' -y, --yes Skip custom validation safety confirmation prompt.',
25
31
  ' --json Print machine-readable problems JSON (validate only).',
26
32
  ' --out <path> Output path (migrate/format).',
27
33
  ' --write Write output (format only; defaults to stdout).',
@@ -67,6 +73,18 @@ function parseArgs(argv) {
67
73
  result.flags.json = true;
68
74
  continue;
69
75
  }
76
+ if (arg === '--yes' || arg === '-y') {
77
+ result.flags.yes = true;
78
+ continue;
79
+ }
80
+ if (arg === '--custom' || arg === '-custom') {
81
+ result.flags.custom = args.shift() || '';
82
+ continue;
83
+ }
84
+ if (arg === '--custom-catalog') {
85
+ result.flags.customCatalog = args.shift() || '';
86
+ continue;
87
+ }
70
88
  if (arg === '--write') {
71
89
  result.flags.write = true;
72
90
  continue;
@@ -79,7 +97,11 @@ function parseArgs(argv) {
79
97
  result.flags.out = args.shift() || '';
80
98
  continue;
81
99
  }
82
- if (arg.startsWith('--')) {
100
+ if (arg === '-') {
101
+ result.positionals.push(arg);
102
+ continue;
103
+ }
104
+ if (arg.startsWith('-')) {
83
105
  result.flags.unknown = (result.flags.unknown || []).concat([arg]);
84
106
  continue;
85
107
  }
@@ -103,6 +125,49 @@ function hasErrors(problems) {
103
125
  return problems.some((p) => p && p.severity === 'error');
104
126
  }
105
127
 
128
+ function promptConfirm(message) {
129
+ return new Promise((resolve) => {
130
+ const rl = readline.createInterface({
131
+ input: process.stdin,
132
+ output: process.stdout
133
+ });
134
+ rl.question(message, (answer) => {
135
+ rl.close();
136
+ resolve(String(answer || '').trim());
137
+ });
138
+ });
139
+ }
140
+
141
+ async function confirmCustomValidation(flags) {
142
+ if (!flags.custom || flags.yes) {
143
+ return true;
144
+ }
145
+
146
+ const warning = [
147
+ 'Custom validation runs JavaScript from the provided script.',
148
+ 'Only continue if you trust the author of this custom validation file.',
149
+ 'Continue custom validation? [Y/N] '
150
+ ].join('\n');
151
+
152
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
153
+ process.stderr.write(
154
+ 'Custom validation confirmation is required in interactive mode. ' +
155
+ 'Re-run with -y/--yes to skip confirmation.\n'
156
+ );
157
+ process.exitCode = 2;
158
+ return false;
159
+ }
160
+
161
+ const response = await promptConfirm(warning);
162
+ if (response.toUpperCase() !== 'Y') {
163
+ process.stderr.write('Custom validation cancelled.\n');
164
+ process.exitCode = 2;
165
+ return false;
166
+ }
167
+
168
+ return true;
169
+ }
170
+
106
171
  async function readInput(filePath) {
107
172
  if (!filePath || filePath === '-') {
108
173
  return readStdin();
@@ -111,12 +176,28 @@ async function readInput(filePath) {
111
176
  }
112
177
 
113
178
  async function handleValidate(filePath, flags) {
179
+ if (Object.prototype.hasOwnProperty.call(flags, 'custom') && !flags.custom) {
180
+ process.stderr.write('Missing custom validator path after -custom/--custom.\n');
181
+ printHelp(2);
182
+ return;
183
+ }
184
+
185
+ if (Object.prototype.hasOwnProperty.call(flags, 'customCatalog') && !flags.customCatalog) {
186
+ process.stderr.write('Missing path after --custom-catalog.\n');
187
+ printHelp(2);
188
+ return;
189
+ }
190
+
114
191
  if (!filePath) {
115
192
  process.stderr.write('Missing file path.\n');
116
193
  printHelp(2);
117
194
  return;
118
195
  }
119
196
 
197
+ if (!(await confirmCustomValidation(flags))) {
198
+ return;
199
+ }
200
+
120
201
  let raw;
121
202
  try {
122
203
  raw = await readInput(filePath);
@@ -136,7 +217,23 @@ async function handleValidate(filePath, flags) {
136
217
  }
137
218
 
138
219
  const result = validator.validate(parsed);
139
- const problems = Array.isArray(result?.problems) ? result.problems : [];
220
+ const builtinProblems = Array.isArray(result?.problems) ? result.problems : [];
221
+
222
+ let customProblems = [];
223
+ if (flags.custom) {
224
+ try {
225
+ customProblems = await customValidationRunner.runCustomValidation(parsed, {
226
+ customValidatorPath: flags.custom,
227
+ customCatalogPath: flags.customCatalog
228
+ });
229
+ } catch (error) {
230
+ process.stderr.write(`Custom validation failed: ${error.message}\n`);
231
+ process.exitCode = 2;
232
+ return;
233
+ }
234
+ }
235
+
236
+ const problems = [...builtinProblems, ...customProblems];
140
237
 
141
238
  if (flags.json) {
142
239
  process.stdout.write(toPrettyJson(problems));
@@ -0,0 +1,658 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawn } = require('child_process');
6
+
7
+ const WORKSPEC_NAMESPACE = 'https://universalautomation.wiki/workspec';
8
+ const EXECUTION_TIMEOUT_MS = 5000;
9
+ const SUBPROCESS_GRACE_MS = 1000;
10
+ const MAX_SUBPROCESS_OUTPUT_BYTES = 1024 * 1024;
11
+ const FUNCTION_NAME_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
12
+ const ALLOWED_VALIDATE_FUNCTION_RE = /^validate[A-Za-z0-9_$]*$/;
13
+ const VALIDATOR_EXTENSIONS = new Set(['.js', '.cjs']);
14
+ const CATALOG_EXTENSIONS = new Set(['.json']);
15
+
16
+ function isPlainObject(value) {
17
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
18
+ }
19
+
20
+ function ensureString(value) {
21
+ return (typeof value === 'string') ? value : '';
22
+ }
23
+
24
+ function safeTrim(value) {
25
+ return ensureString(value).trim();
26
+ }
27
+
28
+ function mapSeverity(value) {
29
+ const raw = safeTrim(value).toLowerCase();
30
+ if (raw === 'error' || raw === 'warning' || raw === 'info') return raw;
31
+ if (raw === 'suggestion') return 'info';
32
+ if (raw === 'success') return null;
33
+ return 'error';
34
+ }
35
+
36
+ function buildProblem(metricId, severity, title, detail, instance, context, suggestions, docUri) {
37
+ const safeMetricId = safeTrim(metricId) || 'custom.validation.error';
38
+ const safeSeverity = (severity === 'warning' || severity === 'info') ? severity : 'error';
39
+ const safeTitle = safeTrim(title) || 'Custom Validation';
40
+ const safeDetail = safeTrim(detail) || safeTitle;
41
+ const safeInstance = safeTrim(instance) || '/simulation';
42
+ const safeContext = isPlainObject(context) ? context : {};
43
+ const safeSuggestions = Array.isArray(suggestions)
44
+ ? suggestions.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
45
+ : [];
46
+
47
+ const result = {
48
+ type: `${WORKSPEC_NAMESPACE}/errors/${safeMetricId}`,
49
+ title: safeTitle,
50
+ severity: safeSeverity,
51
+ detail: safeDetail,
52
+ instance: safeInstance,
53
+ metric_id: safeMetricId,
54
+ context: safeContext,
55
+ suggestions: safeSuggestions
56
+ };
57
+
58
+ if (safeTrim(docUri)) {
59
+ result.doc_uri = safeTrim(docUri);
60
+ }
61
+
62
+ return result;
63
+ }
64
+
65
+ function normalizeProblem(result, fallbackMetricId) {
66
+ if (result === null || result === undefined) return null;
67
+
68
+ if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') {
69
+ return buildProblem(
70
+ fallbackMetricId || 'custom.validation.result',
71
+ 'error',
72
+ 'Custom Validation Result',
73
+ String(result),
74
+ '/simulation'
75
+ );
76
+ }
77
+
78
+ if (!isPlainObject(result)) {
79
+ return buildProblem(
80
+ fallbackMetricId || 'custom.validation.result',
81
+ 'error',
82
+ 'Custom Validation Result',
83
+ `Unsupported custom validation result type: ${typeof result}`,
84
+ '/simulation'
85
+ );
86
+ }
87
+
88
+ const metricId = safeTrim(result.metric_id || result.metricId || fallbackMetricId || 'custom.validation.result');
89
+ const severity = mapSeverity(result.severity || result.status);
90
+ if (!severity) return null;
91
+
92
+ const detail = safeTrim(result.detail || result.message || result.title || metricId);
93
+ const title = safeTrim(result.title) || 'Custom Validation';
94
+ const instance = safeTrim(result.instance) || '/simulation';
95
+ const context = isPlainObject(result.context) ? result.context : {};
96
+ const suggestions = Array.isArray(result.suggestions) ? result.suggestions : [];
97
+ const docUri = safeTrim(result.doc_uri || result.docUri);
98
+ const type = safeTrim(result.type);
99
+
100
+ const normalized = buildProblem(
101
+ metricId,
102
+ severity,
103
+ title,
104
+ detail,
105
+ instance,
106
+ context,
107
+ suggestions,
108
+ docUri
109
+ );
110
+
111
+ if (type) {
112
+ normalized.type = type;
113
+ }
114
+
115
+ return normalized;
116
+ }
117
+
118
+ function normalizeProblems(value, fallbackMetricId) {
119
+ if (value === null || value === undefined) return [];
120
+
121
+ if (Array.isArray(value)) {
122
+ return value
123
+ .map((entry) => normalizeProblem(entry, fallbackMetricId))
124
+ .filter(Boolean);
125
+ }
126
+
127
+ if (isPlainObject(value) && Array.isArray(value.problems)) {
128
+ return normalizeProblems(value.problems, fallbackMetricId);
129
+ }
130
+
131
+ if (isPlainObject(value) && Array.isArray(value.results)) {
132
+ return normalizeProblems(value.results, fallbackMetricId);
133
+ }
134
+
135
+ const single = normalizeProblem(value, fallbackMetricId);
136
+ return single ? [single] : [];
137
+ }
138
+
139
+ function getSimulation(documentValue) {
140
+ if (!isPlainObject(documentValue)) return {};
141
+ return isPlainObject(documentValue.simulation) ? documentValue.simulation : documentValue;
142
+ }
143
+
144
+ function timeToMinutes(timeStr) {
145
+ if (typeof timeStr !== 'string') return null;
146
+ const trimmed = timeStr.trim();
147
+ if (!/^\d{2}:\d{2}$/.test(trimmed)) return null;
148
+ const [hours, minutes] = trimmed.split(':').map(Number);
149
+ if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null;
150
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
151
+ return (hours * 60) + minutes;
152
+ }
153
+
154
+ function unwrapDefaultExport(value) {
155
+ if (value && typeof value === 'object' && value.default) return value.default;
156
+ return value;
157
+ }
158
+
159
+ function discoverValidationFunctions(sourceCode) {
160
+ const names = new Set();
161
+ const patterns = [
162
+ /\bfunction\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g,
163
+ /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?function\b/g,
164
+ /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g
165
+ ];
166
+
167
+ for (const re of patterns) {
168
+ let match;
169
+ while ((match = re.exec(sourceCode)) !== null) {
170
+ const name = safeTrim(match[1]);
171
+ if (name && ALLOWED_VALIDATE_FUNCTION_RE.test(name)) {
172
+ names.add(name);
173
+ }
174
+ }
175
+ }
176
+
177
+ return [...names];
178
+ }
179
+
180
+ function metricIdFromFunctionName(functionName) {
181
+ const trimmed = safeTrim(functionName);
182
+ if (!trimmed) return 'custom.validation.metric';
183
+ const lowered = trimmed.replace(/^validate/, '');
184
+ const metricStem = lowered
185
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
186
+ .replace(/[^A-Za-z0-9_]+/g, '_')
187
+ .replace(/^_+|_+$/g, '')
188
+ .toLowerCase();
189
+ return metricStem ? `custom.${metricStem}` : `custom.${trimmed.toLowerCase()}`;
190
+ }
191
+
192
+ function parseCatalog(catalogRawText, catalogPath) {
193
+ let parsed;
194
+ try {
195
+ parsed = JSON.parse(catalogRawText);
196
+ } catch (error) {
197
+ throw new Error(`Failed to parse custom metrics catalog (${catalogPath}): ${error.message}`);
198
+ }
199
+
200
+ if (!Array.isArray(parsed)) {
201
+ throw new Error(`Custom metrics catalog must be an array (${catalogPath}).`);
202
+ }
203
+
204
+ return parsed
205
+ .filter((item) => isPlainObject(item) && !Object.prototype.hasOwnProperty.call(item, 'disabled_metrics'))
206
+ .map((item) => {
207
+ const functionName = safeTrim(item.function || item.computation?.function_name);
208
+ const metricId = safeTrim(item.id);
209
+ return {
210
+ id: metricId || metricIdFromFunctionName(functionName),
211
+ functionName,
212
+ params: isPlainObject(item.params) ? item.params : {}
213
+ };
214
+ })
215
+ .filter((entry) => entry.functionName);
216
+ }
217
+
218
+ function loadMetricsCatalog(customValidatorPath, explicitCatalogPath) {
219
+ if (explicitCatalogPath) {
220
+ const raw = fs.readFileSync(explicitCatalogPath, 'utf8');
221
+ return parseCatalog(raw, explicitCatalogPath);
222
+ }
223
+
224
+ const sidecarPath = path.join(path.dirname(customValidatorPath), 'metrics-catalog-custom.json');
225
+ if (!fs.existsSync(sidecarPath)) return [];
226
+ const raw = fs.readFileSync(sidecarPath, 'utf8');
227
+ return parseCatalog(raw, sidecarPath);
228
+ }
229
+
230
+ function createHelpers(documentValue, problems) {
231
+ const simulation = getSimulation(documentValue);
232
+ return {
233
+ document: documentValue,
234
+ simulation,
235
+ _timeToMinutes: timeToMinutes,
236
+ buildProblem,
237
+ addProblem(problem) {
238
+ const normalized = normalizeProblems(problem, 'custom.validation.result');
239
+ problems.push(...normalized);
240
+ },
241
+ addResult(result) {
242
+ const normalized = normalizeProblems(result, 'custom.validation.result');
243
+ problems.push(...normalized);
244
+ }
245
+ };
246
+ }
247
+
248
+ async function runExportedValidation(exportedValue, helpers, problems) {
249
+ const exported = unwrapDefaultExport(exportedValue);
250
+ let handled = false;
251
+
252
+ async function invoke(fn, args, metricId) {
253
+ handled = true;
254
+ const output = await fn.apply(helpers, args);
255
+ problems.push(...normalizeProblems(output, metricId));
256
+ }
257
+
258
+ if (typeof exported === 'function') {
259
+ await invoke(exported, [helpers.document, helpers], 'custom.validation.exported');
260
+ } else if (isPlainObject(exported)) {
261
+ if (typeof exported.validate === 'function') {
262
+ await invoke(exported.validate, [helpers.document, helpers], 'custom.validation.exported');
263
+ }
264
+
265
+ if (Array.isArray(exported.constraints)) {
266
+ for (let i = 0; i < exported.constraints.length; i += 1) {
267
+ const fn = exported.constraints[i];
268
+ if (typeof fn === 'function') {
269
+ await invoke(fn, [helpers.document, helpers], `custom.constraint.${i + 1}`);
270
+ }
271
+ }
272
+ }
273
+
274
+ if (Array.isArray(exported.validators)) {
275
+ for (let i = 0; i < exported.validators.length; i += 1) {
276
+ const fn = exported.validators[i];
277
+ if (typeof fn === 'function') {
278
+ await invoke(fn, [helpers.document, helpers], `custom.validator.${i + 1}`);
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ return handled;
285
+ }
286
+
287
+ function buildInstrumentedSource(sourceCode, captureFunctionNames) {
288
+ const lines = [
289
+ '"use strict";',
290
+ 'const __workspecModule = { exports: {} };',
291
+ 'const __workspecExports = __workspecModule.exports;',
292
+ 'const __workspecConsole = { log: () => {}, warn: () => {}, error: () => {} };',
293
+ 'const __workspecRuntime = (function(require, process, Buffer, global, globalThis, module, exports, console) {',
294
+ ' "use strict";'
295
+ ];
296
+
297
+ lines.push(sourceCode);
298
+
299
+ lines.push(' return {');
300
+ lines.push(' exported: module.exports,');
301
+ lines.push(' functions: {');
302
+
303
+ if (captureFunctionNames.length > 0) {
304
+ captureFunctionNames.forEach((functionName, index) => {
305
+ const trailing = (index === captureFunctionNames.length - 1) ? '' : ',';
306
+ lines.push(` ${JSON.stringify(functionName)}: (typeof ${functionName} === "function" ? ${functionName} : undefined)${trailing}`);
307
+ });
308
+ }
309
+
310
+ lines.push(' }');
311
+ lines.push(' };');
312
+ lines.push('})(undefined, undefined, undefined, undefined, undefined, __workspecModule, __workspecExports, __workspecConsole);');
313
+ lines.push('module.exports = {');
314
+ lines.push(' __workspec_exported: __workspecRuntime.exported,');
315
+ lines.push(' __workspec_functions: __workspecRuntime.functions');
316
+ lines.push('};');
317
+
318
+ return lines.join('\n');
319
+ }
320
+
321
+ function loadInstrumentedValidator(sourceCode, captureFunctionNames) {
322
+ const instrumentedSource = buildInstrumentedSource(sourceCode, captureFunctionNames);
323
+ const loader = new Function('module', 'exports', instrumentedSource); // eslint-disable-line no-new-func
324
+ const localModule = { exports: {} };
325
+ loader(localModule, localModule.exports);
326
+
327
+ const payload = isPlainObject(localModule.exports) ? localModule.exports : {};
328
+ const exported = payload.__workspec_exported;
329
+ const functionMap = isPlainObject(payload.__workspec_functions) ? payload.__workspec_functions : {};
330
+
331
+ return {
332
+ exported,
333
+ functionMap
334
+ };
335
+ }
336
+
337
+ async function runAllowlistedValidation(functionMap, metrics, discoveredFunctions, helpers, problems) {
338
+ const allowedFunctions = new Set(discoveredFunctions);
339
+ const thisArg = {
340
+ simulation: helpers.simulation,
341
+ addResult: helpers.addResult,
342
+ addProblem: helpers.addProblem,
343
+ _timeToMinutes: helpers._timeToMinutes
344
+ };
345
+
346
+ for (const metric of metrics) {
347
+ const functionName = safeTrim(metric.functionName);
348
+ if (!FUNCTION_NAME_RE.test(functionName) || !ALLOWED_VALIDATE_FUNCTION_RE.test(functionName)) {
349
+ problems.push(buildProblem(
350
+ metric.id || 'custom.validation.function_name',
351
+ 'error',
352
+ 'Invalid Custom Function Name',
353
+ `Invalid custom validation function name '${functionName}'.`,
354
+ '/simulation'
355
+ ));
356
+ continue;
357
+ }
358
+
359
+ if (!allowedFunctions.has(functionName)) {
360
+ problems.push(buildProblem(
361
+ metric.id || metricIdFromFunctionName(functionName),
362
+ 'error',
363
+ 'Disallowed Custom Validation Function',
364
+ `Function '${functionName}' is not in the allowlisted validate* entry points.`,
365
+ '/simulation',
366
+ { function: functionName, metric_id: metric.id || metricIdFromFunctionName(functionName) }
367
+ ));
368
+ continue;
369
+ }
370
+
371
+ const fn = functionMap[functionName];
372
+ const metricPayload = {
373
+ id: metric.id || metricIdFromFunctionName(functionName),
374
+ params: isPlainObject(metric.params) ? metric.params : {}
375
+ };
376
+
377
+ if (typeof fn !== 'function') {
378
+ problems.push(buildProblem(
379
+ metricPayload.id,
380
+ 'error',
381
+ 'Missing Custom Validation Function',
382
+ `Function '${functionName}' was not found in custom validation code.`,
383
+ '/simulation',
384
+ { function: functionName, metric_id: metricPayload.id }
385
+ ));
386
+ continue;
387
+ }
388
+
389
+ const result = await fn.call(thisArg, metricPayload);
390
+ problems.push(...normalizeProblems(result, metricPayload.id));
391
+ }
392
+ }
393
+
394
+ function withTrailingSeparator(value) {
395
+ return value.endsWith(path.sep) ? value : `${value}${path.sep}`;
396
+ }
397
+
398
+ function isPathInside(candidate, parent) {
399
+ if (candidate === parent) return true;
400
+ return candidate.startsWith(withTrailingSeparator(parent));
401
+ }
402
+
403
+ function hasRelativeTraversal(inputPath) {
404
+ const normalized = path.normalize(inputPath);
405
+ if (normalized === '..') return true;
406
+ return normalized.startsWith(`..${path.sep}`) || normalized.includes(`${path.sep}..${path.sep}`);
407
+ }
408
+
409
+ function resolveAndValidateFilePath(inputPath, label, allowedExtensions) {
410
+ const trimmed = safeTrim(inputPath);
411
+ if (!trimmed) return '';
412
+
413
+ if (trimmed.includes('\0')) {
414
+ throw new Error(`${label} contains unsupported null bytes.`);
415
+ }
416
+
417
+ const isAbsolute = path.isAbsolute(trimmed);
418
+ if (!isAbsolute && hasRelativeTraversal(trimmed)) {
419
+ throw new Error(`${label} contains path traversal segments: '${trimmed}'.`);
420
+ }
421
+
422
+ const resolvedPath = isAbsolute
423
+ ? path.resolve(trimmed)
424
+ : path.resolve(process.cwd(), trimmed);
425
+
426
+ let stats;
427
+ try {
428
+ stats = fs.statSync(resolvedPath);
429
+ } catch (error) {
430
+ throw new Error(`Failed to read ${label} (${resolvedPath}): ${error.message}`);
431
+ }
432
+
433
+ if (!stats.isFile()) {
434
+ throw new Error(`${label} must reference a file: ${resolvedPath}`);
435
+ }
436
+
437
+ let realPath;
438
+ try {
439
+ realPath = fs.realpathSync(resolvedPath);
440
+ } catch (error) {
441
+ throw new Error(`Failed to resolve ${label} (${resolvedPath}): ${error.message}`);
442
+ }
443
+
444
+ if (!isAbsolute) {
445
+ const cwdReal = fs.realpathSync(process.cwd());
446
+ if (!isPathInside(realPath, cwdReal)) {
447
+ throw new Error(`${label} resolves outside the current working directory: '${trimmed}'.`);
448
+ }
449
+ }
450
+
451
+ const extension = path.extname(realPath).toLowerCase();
452
+ if (!allowedExtensions.has(extension)) {
453
+ throw new Error(`${label} must use one of: ${[...allowedExtensions].join(', ')}.`);
454
+ }
455
+
456
+ return realPath;
457
+ }
458
+
459
+ function resolveExecutionOptions(options = {}) {
460
+ const customValidatorPath = resolveAndValidateFilePath(
461
+ options.customValidatorPath || options.path || options.customPath,
462
+ 'Custom validator path',
463
+ VALIDATOR_EXTENSIONS
464
+ );
465
+
466
+ const customCatalogPath = resolveAndValidateFilePath(
467
+ options.customCatalogPath || options.catalogPath,
468
+ 'Custom catalog path',
469
+ CATALOG_EXTENSIONS
470
+ );
471
+
472
+ if (!customValidatorPath) {
473
+ throw new Error('Missing custom validator path.');
474
+ }
475
+
476
+ return {
477
+ customValidatorPath,
478
+ customCatalogPath
479
+ };
480
+ }
481
+
482
+ async function runCustomValidationInProcess(documentValue, options = {}) {
483
+ const { customValidatorPath, customCatalogPath } = resolveExecutionOptions(options);
484
+
485
+ let sourceCode;
486
+ try {
487
+ sourceCode = fs.readFileSync(customValidatorPath, 'utf8');
488
+ } catch (error) {
489
+ throw new Error(`Failed to read custom validator file (${customValidatorPath}): ${error.message}`);
490
+ }
491
+
492
+ const discoveredFunctions = discoverValidationFunctions(sourceCode);
493
+ const candidateCaptureNames = [...new Set(discoveredFunctions)];
494
+
495
+ let instrumented;
496
+ try {
497
+ instrumented = loadInstrumentedValidator(sourceCode, candidateCaptureNames);
498
+ } catch (error) {
499
+ throw new Error(`Failed to evaluate custom validator (${customValidatorPath}): ${error.message}`);
500
+ }
501
+
502
+ const problems = [];
503
+ const helpers = createHelpers(documentValue, problems);
504
+
505
+ const handled = await runExportedValidation(instrumented.exported, helpers, problems);
506
+ if (handled) {
507
+ return problems;
508
+ }
509
+
510
+ let metrics = loadMetricsCatalog(customValidatorPath, customCatalogPath);
511
+ if (metrics.length === 0) {
512
+ metrics = discoveredFunctions.map((functionName) => ({
513
+ id: metricIdFromFunctionName(functionName),
514
+ functionName,
515
+ params: {}
516
+ }));
517
+ }
518
+
519
+ if (metrics.length === 0) {
520
+ throw new Error(
521
+ `No custom validation entry points found in ${customValidatorPath}. ` +
522
+ 'Export a validate() function, or declare validate* functions in the file.'
523
+ );
524
+ }
525
+
526
+ try {
527
+ await runAllowlistedValidation(
528
+ instrumented.functionMap,
529
+ metrics,
530
+ discoveredFunctions,
531
+ helpers,
532
+ problems
533
+ );
534
+ } catch (error) {
535
+ throw new Error(`Custom validation execution failed (${customValidatorPath}): ${error.message}`);
536
+ }
537
+
538
+ return problems;
539
+ }
540
+
541
+ function runCustomValidationSubprocess(payload, timeoutMs) {
542
+ return new Promise((resolve, reject) => {
543
+ const workerPath = path.join(__dirname, 'custom-validation-worker.js');
544
+ const child = spawn(process.execPath, [workerPath], {
545
+ cwd: process.cwd(),
546
+ stdio: ['pipe', 'pipe', 'pipe'],
547
+ env: {
548
+ PATH: process.env.PATH || '',
549
+ NODE_ENV: 'production',
550
+ NODE_OPTIONS: ''
551
+ }
552
+ });
553
+
554
+ let stdout = '';
555
+ let stderr = '';
556
+ let stdoutBytes = 0;
557
+ let stderrBytes = 0;
558
+ let settled = false;
559
+
560
+ function fail(error) {
561
+ if (settled) return;
562
+ settled = true;
563
+ reject(error);
564
+ }
565
+
566
+ const timer = setTimeout(() => {
567
+ if (settled) return;
568
+ settled = true;
569
+ child.kill('SIGKILL');
570
+ reject(new Error(`Custom validation timed out after ${timeoutMs}ms.`));
571
+ }, timeoutMs + SUBPROCESS_GRACE_MS);
572
+
573
+ child.on('error', (error) => {
574
+ clearTimeout(timer);
575
+ fail(new Error(`Failed to start custom validation worker: ${error.message}`));
576
+ });
577
+
578
+ child.stdout.on('data', (chunk) => {
579
+ if (settled) return;
580
+ stdoutBytes += chunk.length;
581
+ if (stdoutBytes > MAX_SUBPROCESS_OUTPUT_BYTES) {
582
+ child.kill('SIGKILL');
583
+ clearTimeout(timer);
584
+ fail(new Error('Custom validation worker output exceeded the allowed limit.'));
585
+ return;
586
+ }
587
+ stdout += chunk.toString('utf8');
588
+ });
589
+
590
+ child.stderr.on('data', (chunk) => {
591
+ if (settled) return;
592
+ stderrBytes += chunk.length;
593
+ if (stderrBytes > MAX_SUBPROCESS_OUTPUT_BYTES) {
594
+ child.kill('SIGKILL');
595
+ clearTimeout(timer);
596
+ fail(new Error('Custom validation worker error output exceeded the allowed limit.'));
597
+ return;
598
+ }
599
+ stderr += chunk.toString('utf8');
600
+ });
601
+
602
+ child.on('close', (code, signal) => {
603
+ clearTimeout(timer);
604
+ if (settled) return;
605
+
606
+ if (signal && signal !== 'SIGTERM') {
607
+ fail(new Error(`Custom validation worker exited with signal ${signal}.`));
608
+ return;
609
+ }
610
+
611
+ let parsed;
612
+ try {
613
+ parsed = JSON.parse(stdout || '{}');
614
+ } catch (error) {
615
+ const details = stderr.trim() || `Invalid worker output: ${error.message}`;
616
+ fail(new Error(`Custom validation worker returned unreadable output. ${details}`));
617
+ return;
618
+ }
619
+
620
+ if (code !== 0 || !parsed.ok) {
621
+ const message = safeTrim(parsed.error) || safeTrim(stderr) || `Worker exited with code ${code}.`;
622
+ fail(new Error(message));
623
+ return;
624
+ }
625
+
626
+ if (!Array.isArray(parsed.problems)) {
627
+ fail(new Error('Custom validation worker returned an invalid payload.'));
628
+ return;
629
+ }
630
+
631
+ settled = true;
632
+ resolve(parsed.problems);
633
+ });
634
+
635
+ child.stdin.on('error', (error) => {
636
+ clearTimeout(timer);
637
+ fail(new Error(`Failed to send input to custom validation worker: ${error.message}`));
638
+ });
639
+
640
+ child.stdin.end(JSON.stringify(payload));
641
+ });
642
+ }
643
+
644
+ async function runCustomValidation(documentValue, options = {}) {
645
+ const executionOptions = resolveExecutionOptions(options);
646
+ return runCustomValidationSubprocess(
647
+ {
648
+ documentValue,
649
+ options: executionOptions
650
+ },
651
+ EXECUTION_TIMEOUT_MS
652
+ );
653
+ }
654
+
655
+ module.exports = {
656
+ runCustomValidation,
657
+ runCustomValidationInProcess
658
+ };
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { runCustomValidationInProcess } = require('./custom-validation-runner.js');
5
+
6
+ const MAX_INPUT_BYTES = 1024 * 1024;
7
+
8
+ function readStdin() {
9
+ return new Promise((resolve, reject) => {
10
+ let totalBytes = 0;
11
+ let data = '';
12
+
13
+ process.stdin.setEncoding('utf8');
14
+ process.stdin.on('data', (chunk) => {
15
+ totalBytes += chunk.length;
16
+ if (totalBytes > MAX_INPUT_BYTES) {
17
+ reject(new Error('Custom validation payload exceeded the allowed size.'));
18
+ return;
19
+ }
20
+ data += chunk;
21
+ });
22
+
23
+ process.stdin.on('end', () => resolve(data));
24
+ process.stdin.on('error', reject);
25
+ });
26
+ }
27
+
28
+ async function main() {
29
+ try {
30
+ const raw = await readStdin();
31
+ const payload = JSON.parse(raw || '{}');
32
+
33
+ const documentValue = payload?.documentValue;
34
+ const options = payload?.options || {};
35
+
36
+ const problems = await runCustomValidationInProcess(documentValue, options);
37
+ process.stdout.write(JSON.stringify({ ok: true, problems }));
38
+ process.exitCode = 0;
39
+ } catch (error) {
40
+ process.stdout.write(JSON.stringify({
41
+ ok: false,
42
+ error: (error && error.message) ? error.message : 'Unknown custom validation worker error.'
43
+ }));
44
+ process.exitCode = 1;
45
+ }
46
+ }
47
+
48
+ main();
package/index.js CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  const validator = require('./workspec-validator.js');
4
4
  const migrator = require('./workspec-migrate-v1-to-v2.js');
5
+ const customValidationRunner = require('./custom-validation-runner.js');
5
6
 
6
7
  module.exports = {
7
8
  ...validator,
8
- ...migrator
9
+ ...migrator,
10
+ ...customValidationRunner
9
11
  };
10
-
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "workspec",
3
3
  "private": false,
4
- "version": "1.0.1",
5
- "description": "WorkSpec v1.0.0 validator and CLI (workspec validate/migrate/format).",
4
+ "version": "1.1.1",
5
+ "description": "WorkSpec v1.1.0 validator and CLI (validate/migrate/format + custom validation).",
6
6
  "license": "AGPL-3.0",
7
7
  "main": "index.js",
8
8
  "bin": {
@@ -11,11 +11,14 @@
11
11
  "scripts": {
12
12
  "check:sync": "node scripts/check-sync.js",
13
13
  "sync:web": "node scripts/sync-web-assets.js",
14
- "test": "node scripts/self-test.js"
14
+ "test:integration": "node scripts/sync-web-assets.js && node scripts/test-playground-integration.js",
15
+ "test": "node scripts/self-test.js && npm run test:integration"
15
16
  },
16
17
  "files": [
17
18
  "bin/",
18
19
  "index.js",
20
+ "custom-validation-runner.js",
21
+ "custom-validation-worker.js",
19
22
  "workspec-validator.js",
20
23
  "workspec-migrate-v1-to-v2.js",
21
24
  "v2.0.schema.json",