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 CHANGED
@@ -2,9 +2,28 @@
2
2
 
3
3
  This package provides:
4
4
 
5
- - A programmatic WorkSpec v2.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
+ ## 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
- Migrate v1 → v2:
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 v2.0 CLI',
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 Migrate WorkSpec v1.0 -> v2.0.',
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.startsWith('--')) {
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 problems = Array.isArray(result?.problems) ? result.problems : [];
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.0.0",
5
- "description": "WorkSpec v2.0 validator and CLI (workspec validate/migrate/format).",
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",