workspec 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -3
- package/bin/workspec.js +49 -5
- package/custom-validation-runner.js +420 -0
- package/index.js +3 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,9 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
This package provides:
|
|
4
4
|
|
|
5
|
-
- A programmatic WorkSpec
|
|
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
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Install globally to use `workspec` from any terminal/command prompt path:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g workspec
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
After install, run:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
workspec --help
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This follows npm's standard cross-platform CLI pattern via `package.json#bin`:
|
|
23
|
+
|
|
24
|
+
- macOS/Linux: npm links an executable on your PATH
|
|
25
|
+
- Windows: npm creates command shims (`workspec.cmd`/`workspec.ps1`)
|
|
26
|
+
|
|
8
27
|
## CLI
|
|
9
28
|
|
|
10
29
|
Validate:
|
|
@@ -12,9 +31,17 @@ Validate:
|
|
|
12
31
|
```bash
|
|
13
32
|
workspec validate path/to/file.workspec.json
|
|
14
33
|
workspec validate path/to/file.workspec.json --json
|
|
34
|
+
workspec validate -custom path/to/simulation-validator-custom.js path/to/file.workspec.json
|
|
35
|
+
workspec validate path/to/file.workspec.json --custom path/to/custom-validator.js --custom-catalog path/to/metrics-catalog-custom.json
|
|
15
36
|
```
|
|
16
37
|
|
|
17
|
-
|
|
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
|
+
If `--custom-catalog` is omitted, the CLI auto-loads `metrics-catalog-custom.json` from the custom validator file's folder when present.
|
|
43
|
+
|
|
44
|
+
Migrate (Previous UAW Syntax -> WorkSpec v1.0.0):
|
|
18
45
|
|
|
19
46
|
```bash
|
|
20
47
|
workspec migrate legacy.json --out migrated.workspec.json --schema
|
|
@@ -25,4 +52,3 @@ Format JSON:
|
|
|
25
52
|
```bash
|
|
26
53
|
workspec format file.json --write
|
|
27
54
|
```
|
|
28
|
-
|
package/bin/workspec.js
CHANGED
|
@@ -6,22 +6,26 @@ const path = require('path');
|
|
|
6
6
|
|
|
7
7
|
const validator = require(path.join(__dirname, '..', 'workspec-validator.js'));
|
|
8
8
|
const migrator = require(path.join(__dirname, '..', 'workspec-migrate-v1-to-v2.js'));
|
|
9
|
+
const customValidationRunner = require(path.join(__dirname, '..', 'custom-validation-runner.js'));
|
|
9
10
|
|
|
10
11
|
function printHelp(exitCode = 0) {
|
|
11
12
|
const lines = [
|
|
12
|
-
'workspec - WorkSpec
|
|
13
|
+
'workspec - WorkSpec v1.1.0 CLI',
|
|
13
14
|
'',
|
|
14
15
|
'Usage:',
|
|
15
|
-
' workspec validate <file.workspec.json> [--json]',
|
|
16
|
+
' workspec validate <file.workspec.json> [-custom <validator.js>] [--custom-catalog <catalog.json>] [--json]',
|
|
16
17
|
' workspec migrate <file.json> --out <output.json> [--schema]',
|
|
17
18
|
' workspec format <file.json> [--write] [--out <output.json>]',
|
|
18
19
|
'',
|
|
19
20
|
'Commands:',
|
|
20
21
|
' validate Validate a WorkSpec document (RFC 7807 output model).',
|
|
21
|
-
' migrate
|
|
22
|
+
' migrate Previous UAW Syntax -> WorkSpec v1.0.0.',
|
|
22
23
|
' format Pretty-print JSON (2-space).',
|
|
23
24
|
'',
|
|
24
25
|
'Flags:',
|
|
26
|
+
' -custom <path> Run custom validator code (Metrics Editor compatible).',
|
|
27
|
+
' --custom <path> Same as -custom.',
|
|
28
|
+
' --custom-catalog <path> Optional metrics-catalog JSON for custom metrics.',
|
|
25
29
|
' --json Print machine-readable problems JSON (validate only).',
|
|
26
30
|
' --out <path> Output path (migrate/format).',
|
|
27
31
|
' --write Write output (format only; defaults to stdout).',
|
|
@@ -67,6 +71,14 @@ function parseArgs(argv) {
|
|
|
67
71
|
result.flags.json = true;
|
|
68
72
|
continue;
|
|
69
73
|
}
|
|
74
|
+
if (arg === '--custom' || arg === '-custom') {
|
|
75
|
+
result.flags.custom = args.shift() || '';
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (arg === '--custom-catalog') {
|
|
79
|
+
result.flags.customCatalog = args.shift() || '';
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
70
82
|
if (arg === '--write') {
|
|
71
83
|
result.flags.write = true;
|
|
72
84
|
continue;
|
|
@@ -79,7 +91,11 @@ function parseArgs(argv) {
|
|
|
79
91
|
result.flags.out = args.shift() || '';
|
|
80
92
|
continue;
|
|
81
93
|
}
|
|
82
|
-
if (arg
|
|
94
|
+
if (arg === '-') {
|
|
95
|
+
result.positionals.push(arg);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (arg.startsWith('-')) {
|
|
83
99
|
result.flags.unknown = (result.flags.unknown || []).concat([arg]);
|
|
84
100
|
continue;
|
|
85
101
|
}
|
|
@@ -111,6 +127,18 @@ async function readInput(filePath) {
|
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
async function handleValidate(filePath, flags) {
|
|
130
|
+
if (Object.prototype.hasOwnProperty.call(flags, 'custom') && !flags.custom) {
|
|
131
|
+
process.stderr.write('Missing custom validator path after -custom/--custom.\n');
|
|
132
|
+
printHelp(2);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (Object.prototype.hasOwnProperty.call(flags, 'customCatalog') && !flags.customCatalog) {
|
|
137
|
+
process.stderr.write('Missing path after --custom-catalog.\n');
|
|
138
|
+
printHelp(2);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
114
142
|
if (!filePath) {
|
|
115
143
|
process.stderr.write('Missing file path.\n');
|
|
116
144
|
printHelp(2);
|
|
@@ -136,7 +164,23 @@ async function handleValidate(filePath, flags) {
|
|
|
136
164
|
}
|
|
137
165
|
|
|
138
166
|
const result = validator.validate(parsed);
|
|
139
|
-
const
|
|
167
|
+
const builtinProblems = Array.isArray(result?.problems) ? result.problems : [];
|
|
168
|
+
|
|
169
|
+
let customProblems = [];
|
|
170
|
+
if (flags.custom) {
|
|
171
|
+
try {
|
|
172
|
+
customProblems = await customValidationRunner.runCustomValidation(parsed, {
|
|
173
|
+
customValidatorPath: flags.custom,
|
|
174
|
+
customCatalogPath: flags.customCatalog
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
process.stderr.write(`Custom validation failed: ${error.message}\n`);
|
|
178
|
+
process.exitCode = 2;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const problems = [...builtinProblems, ...customProblems];
|
|
140
184
|
|
|
141
185
|
if (flags.json) {
|
|
142
186
|
process.stdout.write(toPrettyJson(problems));
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const vm = require('vm');
|
|
6
|
+
|
|
7
|
+
const WORKSPEC_NAMESPACE = 'https://universalautomation.wiki/workspec';
|
|
8
|
+
const EXECUTION_TIMEOUT_MS = 5000;
|
|
9
|
+
const FUNCTION_NAME_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
10
|
+
|
|
11
|
+
function isPlainObject(value) {
|
|
12
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ensureString(value) {
|
|
16
|
+
return (typeof value === 'string') ? value : '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeTrim(value) {
|
|
20
|
+
return ensureString(value).trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mapSeverity(value) {
|
|
24
|
+
const raw = safeTrim(value).toLowerCase();
|
|
25
|
+
if (raw === 'error' || raw === 'warning' || raw === 'info') return raw;
|
|
26
|
+
if (raw === 'suggestion') return 'info';
|
|
27
|
+
if (raw === 'success') return null;
|
|
28
|
+
return 'error';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildProblem(metricId, severity, title, detail, instance, context, suggestions, docUri) {
|
|
32
|
+
const safeMetricId = safeTrim(metricId) || 'custom.validation.error';
|
|
33
|
+
const safeSeverity = (severity === 'warning' || severity === 'info') ? severity : 'error';
|
|
34
|
+
const safeTitle = safeTrim(title) || 'Custom Validation';
|
|
35
|
+
const safeDetail = safeTrim(detail) || safeTitle;
|
|
36
|
+
const safeInstance = safeTrim(instance) || '/simulation';
|
|
37
|
+
const safeContext = isPlainObject(context) ? context : {};
|
|
38
|
+
const safeSuggestions = Array.isArray(suggestions)
|
|
39
|
+
? suggestions.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
|
|
40
|
+
: [];
|
|
41
|
+
|
|
42
|
+
const result = {
|
|
43
|
+
type: `${WORKSPEC_NAMESPACE}/errors/${safeMetricId}`,
|
|
44
|
+
title: safeTitle,
|
|
45
|
+
severity: safeSeverity,
|
|
46
|
+
detail: safeDetail,
|
|
47
|
+
instance: safeInstance,
|
|
48
|
+
metric_id: safeMetricId,
|
|
49
|
+
context: safeContext,
|
|
50
|
+
suggestions: safeSuggestions
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (safeTrim(docUri)) {
|
|
54
|
+
result.doc_uri = safeTrim(docUri);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeProblem(result, fallbackMetricId) {
|
|
61
|
+
if (result === null || result === undefined) return null;
|
|
62
|
+
|
|
63
|
+
if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') {
|
|
64
|
+
return buildProblem(
|
|
65
|
+
fallbackMetricId || 'custom.validation.result',
|
|
66
|
+
'error',
|
|
67
|
+
'Custom Validation Result',
|
|
68
|
+
String(result),
|
|
69
|
+
'/simulation'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!isPlainObject(result)) {
|
|
74
|
+
return buildProblem(
|
|
75
|
+
fallbackMetricId || 'custom.validation.result',
|
|
76
|
+
'error',
|
|
77
|
+
'Custom Validation Result',
|
|
78
|
+
`Unsupported custom validation result type: ${typeof result}`,
|
|
79
|
+
'/simulation'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const metricId = safeTrim(result.metric_id || result.metricId || fallbackMetricId || 'custom.validation.result');
|
|
84
|
+
const severity = mapSeverity(result.severity || result.status);
|
|
85
|
+
if (!severity) return null;
|
|
86
|
+
|
|
87
|
+
const detail = safeTrim(result.detail || result.message || result.title || metricId);
|
|
88
|
+
const title = safeTrim(result.title) || 'Custom Validation';
|
|
89
|
+
const instance = safeTrim(result.instance) || '/simulation';
|
|
90
|
+
const context = isPlainObject(result.context) ? result.context : {};
|
|
91
|
+
const suggestions = Array.isArray(result.suggestions) ? result.suggestions : [];
|
|
92
|
+
const docUri = safeTrim(result.doc_uri || result.docUri);
|
|
93
|
+
const type = safeTrim(result.type);
|
|
94
|
+
|
|
95
|
+
const normalized = buildProblem(
|
|
96
|
+
metricId,
|
|
97
|
+
severity,
|
|
98
|
+
title,
|
|
99
|
+
detail,
|
|
100
|
+
instance,
|
|
101
|
+
context,
|
|
102
|
+
suggestions,
|
|
103
|
+
docUri
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (type) {
|
|
107
|
+
normalized.type = type;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return normalized;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeProblems(value, fallbackMetricId) {
|
|
114
|
+
if (value === null || value === undefined) return [];
|
|
115
|
+
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
return value
|
|
118
|
+
.map((entry) => normalizeProblem(entry, fallbackMetricId))
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isPlainObject(value) && Array.isArray(value.problems)) {
|
|
123
|
+
return normalizeProblems(value.problems, fallbackMetricId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (isPlainObject(value) && Array.isArray(value.results)) {
|
|
127
|
+
return normalizeProblems(value.results, fallbackMetricId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const single = normalizeProblem(value, fallbackMetricId);
|
|
131
|
+
return single ? [single] : [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolvePath(inputPath) {
|
|
135
|
+
if (!inputPath) return '';
|
|
136
|
+
if (path.isAbsolute(inputPath)) return inputPath;
|
|
137
|
+
return path.resolve(process.cwd(), inputPath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getSimulation(documentValue) {
|
|
141
|
+
if (!isPlainObject(documentValue)) return {};
|
|
142
|
+
return isPlainObject(documentValue.simulation) ? documentValue.simulation : documentValue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function timeToMinutes(timeStr) {
|
|
146
|
+
if (typeof timeStr !== 'string') return null;
|
|
147
|
+
const trimmed = timeStr.trim();
|
|
148
|
+
if (!/^\d{2}:\d{2}$/.test(trimmed)) return null;
|
|
149
|
+
const [hours, minutes] = trimmed.split(':').map(Number);
|
|
150
|
+
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null;
|
|
151
|
+
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
|
152
|
+
return (hours * 60) + minutes;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function unwrapDefaultExport(value) {
|
|
156
|
+
if (value && typeof value === 'object' && value.default) return value.default;
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function discoverValidationFunctions(sourceCode) {
|
|
161
|
+
const names = new Set();
|
|
162
|
+
const patterns = [
|
|
163
|
+
/\bfunction\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g,
|
|
164
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?function\b/g,
|
|
165
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
for (const re of patterns) {
|
|
169
|
+
let match;
|
|
170
|
+
while ((match = re.exec(sourceCode)) !== null) {
|
|
171
|
+
const name = safeTrim(match[1]);
|
|
172
|
+
if (name && /^validate[A-Za-z0-9_$]*/.test(name)) {
|
|
173
|
+
names.add(name);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return [...names];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function metricIdFromFunctionName(functionName) {
|
|
182
|
+
const trimmed = safeTrim(functionName);
|
|
183
|
+
if (!trimmed) return 'custom.validation.metric';
|
|
184
|
+
const lowered = trimmed.replace(/^validate/, '');
|
|
185
|
+
const metricStem = lowered
|
|
186
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
187
|
+
.replace(/[^A-Za-z0-9_]+/g, '_')
|
|
188
|
+
.replace(/^_+|_+$/g, '')
|
|
189
|
+
.toLowerCase();
|
|
190
|
+
return metricStem ? `custom.${metricStem}` : `custom.${trimmed.toLowerCase()}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseCatalog(catalogRawText, catalogPath) {
|
|
194
|
+
let parsed;
|
|
195
|
+
try {
|
|
196
|
+
parsed = JSON.parse(catalogRawText);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
throw new Error(`Failed to parse custom metrics catalog (${catalogPath}): ${error.message}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!Array.isArray(parsed)) {
|
|
202
|
+
throw new Error(`Custom metrics catalog must be an array (${catalogPath}).`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return parsed
|
|
206
|
+
.filter((item) => isPlainObject(item) && !Object.prototype.hasOwnProperty.call(item, 'disabled_metrics'))
|
|
207
|
+
.map((item) => {
|
|
208
|
+
const functionName = safeTrim(item.function || item.computation?.function_name);
|
|
209
|
+
const metricId = safeTrim(item.id);
|
|
210
|
+
return {
|
|
211
|
+
id: metricId || metricIdFromFunctionName(functionName),
|
|
212
|
+
functionName,
|
|
213
|
+
params: isPlainObject(item.params) ? item.params : {}
|
|
214
|
+
};
|
|
215
|
+
})
|
|
216
|
+
.filter((entry) => entry.functionName);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function loadMetricsCatalog(customValidatorPath, explicitCatalogPath) {
|
|
220
|
+
const resolvedExplicit = resolvePath(explicitCatalogPath);
|
|
221
|
+
if (resolvedExplicit) {
|
|
222
|
+
const raw = fs.readFileSync(resolvedExplicit, 'utf8');
|
|
223
|
+
return parseCatalog(raw, resolvedExplicit);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const sidecarPath = path.join(path.dirname(customValidatorPath), 'metrics-catalog-custom.json');
|
|
227
|
+
if (!fs.existsSync(sidecarPath)) return [];
|
|
228
|
+
const raw = fs.readFileSync(sidecarPath, 'utf8');
|
|
229
|
+
return parseCatalog(raw, sidecarPath);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function createHelpers(documentValue, problems) {
|
|
233
|
+
const simulation = getSimulation(documentValue);
|
|
234
|
+
return {
|
|
235
|
+
document: documentValue,
|
|
236
|
+
simulation,
|
|
237
|
+
_timeToMinutes: timeToMinutes,
|
|
238
|
+
buildProblem,
|
|
239
|
+
addProblem(problem) {
|
|
240
|
+
const normalized = normalizeProblems(problem, 'custom.validation.result');
|
|
241
|
+
problems.push(...normalized);
|
|
242
|
+
},
|
|
243
|
+
addResult(result) {
|
|
244
|
+
const normalized = normalizeProblems(result, 'custom.validation.result');
|
|
245
|
+
problems.push(...normalized);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function runExportedValidation(exportedValue, helpers, problems) {
|
|
251
|
+
const exported = unwrapDefaultExport(exportedValue);
|
|
252
|
+
let handled = false;
|
|
253
|
+
|
|
254
|
+
async function invoke(fn, args, metricId) {
|
|
255
|
+
handled = true;
|
|
256
|
+
const output = await fn.apply(helpers, args);
|
|
257
|
+
problems.push(...normalizeProblems(output, metricId));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (typeof exported === 'function') {
|
|
261
|
+
await invoke(exported, [helpers.document, helpers], 'custom.validation.exported');
|
|
262
|
+
} else if (isPlainObject(exported)) {
|
|
263
|
+
if (typeof exported.validate === 'function') {
|
|
264
|
+
await invoke(exported.validate, [helpers.document, helpers], 'custom.validation.exported');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (Array.isArray(exported.constraints)) {
|
|
268
|
+
for (let i = 0; i < exported.constraints.length; i += 1) {
|
|
269
|
+
const fn = exported.constraints[i];
|
|
270
|
+
if (typeof fn === 'function') {
|
|
271
|
+
await invoke(fn, [helpers.document, helpers], `custom.constraint.${i + 1}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (Array.isArray(exported.validators)) {
|
|
277
|
+
for (let i = 0; i < exported.validators.length; i += 1) {
|
|
278
|
+
const fn = exported.validators[i];
|
|
279
|
+
if (typeof fn === 'function') {
|
|
280
|
+
await invoke(fn, [helpers.document, helpers], `custom.validator.${i + 1}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return handled;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function runBrowserStyleValidation(sourceCode, metrics, helpers, customValidatorPath, problems) {
|
|
290
|
+
const sandbox = {
|
|
291
|
+
simulation: helpers.simulation,
|
|
292
|
+
addResult: helpers.addResult,
|
|
293
|
+
addProblem: helpers.addProblem,
|
|
294
|
+
_timeToMinutes: helpers._timeToMinutes,
|
|
295
|
+
console: {
|
|
296
|
+
log: (...args) => console.log('[workspec custom]', ...args),
|
|
297
|
+
warn: (...args) => console.warn('[workspec custom]', ...args),
|
|
298
|
+
error: (...args) => console.error('[workspec custom]', ...args)
|
|
299
|
+
},
|
|
300
|
+
require: undefined,
|
|
301
|
+
process: undefined,
|
|
302
|
+
module: undefined,
|
|
303
|
+
exports: undefined,
|
|
304
|
+
Buffer: undefined
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const thisArg = {
|
|
308
|
+
simulation: helpers.simulation,
|
|
309
|
+
addResult: helpers.addResult,
|
|
310
|
+
addProblem: helpers.addProblem,
|
|
311
|
+
_timeToMinutes: helpers._timeToMinutes
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const context = vm.createContext(sandbox);
|
|
315
|
+
const script = new vm.Script(sourceCode, { filename: customValidatorPath });
|
|
316
|
+
script.runInContext(context, { timeout: EXECUTION_TIMEOUT_MS });
|
|
317
|
+
|
|
318
|
+
for (const metric of metrics) {
|
|
319
|
+
const functionName = safeTrim(metric.functionName);
|
|
320
|
+
if (!FUNCTION_NAME_RE.test(functionName)) {
|
|
321
|
+
problems.push(buildProblem(
|
|
322
|
+
metric.id || 'custom.validation.function_name',
|
|
323
|
+
'error',
|
|
324
|
+
'Invalid Custom Function Name',
|
|
325
|
+
`Invalid custom validation function name '${functionName}'.`,
|
|
326
|
+
'/simulation'
|
|
327
|
+
));
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
context.__metric = {
|
|
332
|
+
id: metric.id || metricIdFromFunctionName(functionName),
|
|
333
|
+
params: isPlainObject(metric.params) ? metric.params : {}
|
|
334
|
+
};
|
|
335
|
+
context.__thisArg = thisArg;
|
|
336
|
+
|
|
337
|
+
const invocation = new vm.Script(
|
|
338
|
+
`(typeof ${functionName} === 'function') ? ${functionName}.call(__thisArg, __metric) : "__MISSING_FUNCTION__"`
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const result = invocation.runInContext(context, { timeout: EXECUTION_TIMEOUT_MS });
|
|
342
|
+
if (result === '__MISSING_FUNCTION__') {
|
|
343
|
+
problems.push(buildProblem(
|
|
344
|
+
context.__metric.id,
|
|
345
|
+
'error',
|
|
346
|
+
'Missing Custom Validation Function',
|
|
347
|
+
`Function '${functionName}' was not found in custom validation code.`,
|
|
348
|
+
'/simulation',
|
|
349
|
+
{ function: functionName, metric_id: context.__metric.id }
|
|
350
|
+
));
|
|
351
|
+
} else {
|
|
352
|
+
problems.push(...normalizeProblems(result, context.__metric.id));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function runCustomValidation(documentValue, options = {}) {
|
|
358
|
+
const customValidatorPath = resolvePath(options.customValidatorPath || options.path || options.customPath);
|
|
359
|
+
const customCatalogPath = resolvePath(options.customCatalogPath || options.catalogPath);
|
|
360
|
+
|
|
361
|
+
if (!customValidatorPath) {
|
|
362
|
+
throw new Error('Missing custom validator path.');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let sourceCode;
|
|
366
|
+
try {
|
|
367
|
+
sourceCode = fs.readFileSync(customValidatorPath, 'utf8');
|
|
368
|
+
} catch (error) {
|
|
369
|
+
throw new Error(`Failed to read custom validator file (${customValidatorPath}): ${error.message}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const problems = [];
|
|
373
|
+
const helpers = createHelpers(documentValue, problems);
|
|
374
|
+
|
|
375
|
+
let exported = null;
|
|
376
|
+
let exportedLoaded = false;
|
|
377
|
+
try {
|
|
378
|
+
const resolved = require.resolve(customValidatorPath);
|
|
379
|
+
delete require.cache[resolved];
|
|
380
|
+
exported = require(resolved);
|
|
381
|
+
exportedLoaded = true;
|
|
382
|
+
} catch (_error) {
|
|
383
|
+
exportedLoaded = false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (exportedLoaded) {
|
|
387
|
+
const handled = await runExportedValidation(exported, helpers, problems);
|
|
388
|
+
if (handled) {
|
|
389
|
+
return problems;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let metrics = loadMetricsCatalog(customValidatorPath, customCatalogPath);
|
|
394
|
+
if (metrics.length === 0) {
|
|
395
|
+
metrics = discoverValidationFunctions(sourceCode).map((functionName) => ({
|
|
396
|
+
id: metricIdFromFunctionName(functionName),
|
|
397
|
+
functionName,
|
|
398
|
+
params: {}
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (metrics.length === 0) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`No custom validation entry points found in ${customValidatorPath}. ` +
|
|
405
|
+
'Export a validate() function, or declare validate* functions in the file.'
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
runBrowserStyleValidation(sourceCode, metrics, helpers, customValidatorPath, problems);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
throw new Error(`Custom validation execution failed (${customValidatorPath}): ${error.message}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return problems;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
module.exports = {
|
|
419
|
+
runCustomValidation
|
|
420
|
+
};
|
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
|
|
4
|
+
"version": "1.1.0",
|
|
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": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"bin/",
|
|
18
18
|
"index.js",
|
|
19
|
+
"custom-validation-runner.js",
|
|
19
20
|
"workspec-validator.js",
|
|
20
21
|
"workspec-migrate-v1-to-v2.js",
|
|
21
22
|
"v2.0.schema.json",
|