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 +12 -1
- package/bin/workspec.js +101 -4
- package/custom-validation-runner.js +658 -0
- package/custom-validation-worker.js +48 -0
- package/index.js +3 -2
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This package provides:
|
|
4
4
|
|
|
5
|
-
- A programmatic WorkSpec v1.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
5
|
-
"description": "WorkSpec v1.
|
|
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/
|
|
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",
|