@hegemonart/get-design-done 1.22.0 → 1.23.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.
@@ -204,11 +204,176 @@ function loadMotionMapSchema(projectRoot) {
204
204
  return JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
205
205
  }
206
206
 
207
+ // ---------------------------------------------------------------------------
208
+ // Phase 23 Plan 23-01 — planner + verifier decision contracts.
209
+ //
210
+ // These parsers ride the same extract→parse→validate pipeline as
211
+ // parseMotionMap. Validation is structural (required fields, enums,
212
+ // types) — full JSON Schema validation is delegated to ajv when callers
213
+ // want strict enforcement; the in-line validators keep the no-deps
214
+ // guarantee for the hot path.
215
+ // ---------------------------------------------------------------------------
216
+
217
+ const VALID_VERIFIER_VERDICTS = ['pass', 'fail', 'gap'];
218
+ const VALID_GAP_SEVERITIES = ['P0', 'P1', 'P2', 'P3'];
219
+ const VALID_VERIFIER_CONFIDENCE = ['high', 'med', 'low'];
220
+
221
+ function validatePlannerDecision(data) {
222
+ const errors = [];
223
+ if (!data || typeof data !== 'object') {
224
+ return { ok: false, errors: ['Top-level value is not an object'] };
225
+ }
226
+ if (data.schema_version !== '1.0.0') {
227
+ errors.push(`schema_version must be "1.0.0" (got ${JSON.stringify(data.schema_version)})`);
228
+ }
229
+ if (typeof data.plan_id !== 'string' || data.plan_id.length === 0) {
230
+ errors.push('plan_id is required (non-empty string)');
231
+ }
232
+ if (!Array.isArray(data.tasks) || data.tasks.length === 0) {
233
+ errors.push('tasks must be a non-empty array');
234
+ } else {
235
+ data.tasks.forEach((task, i) => {
236
+ const tag = `tasks[${i}]`;
237
+ if (!task || typeof task !== 'object') {
238
+ errors.push(`${tag} is not an object`);
239
+ return;
240
+ }
241
+ if (typeof task.task_id !== 'string' || task.task_id.length === 0) {
242
+ errors.push(`${tag}.task_id is required (non-empty string)`);
243
+ }
244
+ if (typeof task.summary !== 'string' || task.summary.length < 3) {
245
+ errors.push(`${tag}.summary required, ≥3 chars`);
246
+ }
247
+ if (!Array.isArray(task.touches) || task.touches.some((t) => typeof t !== 'string')) {
248
+ errors.push(`${tag}.touches must be an array of strings`);
249
+ }
250
+ if (task.dependencies !== undefined &&
251
+ (!Array.isArray(task.dependencies) ||
252
+ task.dependencies.some((d) => typeof d !== 'string'))) {
253
+ errors.push(`${tag}.dependencies must be an array of strings`);
254
+ }
255
+ if (task.parallel_safe !== undefined && typeof task.parallel_safe !== 'boolean') {
256
+ errors.push(`${tag}.parallel_safe must be boolean`);
257
+ }
258
+ if (task.estimated_minutes !== undefined &&
259
+ (typeof task.estimated_minutes !== 'number' || task.estimated_minutes < 0)) {
260
+ errors.push(`${tag}.estimated_minutes must be a non-negative number`);
261
+ }
262
+ });
263
+ }
264
+ if (!Array.isArray(data.waves) || data.waves.length === 0) {
265
+ errors.push('waves must be a non-empty array');
266
+ } else {
267
+ data.waves.forEach((wave, i) => {
268
+ const tag = `waves[${i}]`;
269
+ if (!wave || typeof wave !== 'object') {
270
+ errors.push(`${tag} is not an object`);
271
+ return;
272
+ }
273
+ if (typeof wave.wave !== 'string' || wave.wave.length === 0) {
274
+ errors.push(`${tag}.wave required (non-empty string)`);
275
+ }
276
+ if (!Array.isArray(wave.task_ids) || wave.task_ids.length === 0) {
277
+ errors.push(`${tag}.task_ids must be a non-empty array`);
278
+ }
279
+ });
280
+ }
281
+ return errors.length === 0 ? { ok: true, data } : { ok: false, errors };
282
+ }
283
+
284
+ function validateVerifierDecision(data) {
285
+ const errors = [];
286
+ if (!data || typeof data !== 'object') {
287
+ return { ok: false, errors: ['Top-level value is not an object'] };
288
+ }
289
+ if (data.schema_version !== '1.0.0') {
290
+ errors.push(`schema_version must be "1.0.0" (got ${JSON.stringify(data.schema_version)})`);
291
+ }
292
+ if (!VALID_VERIFIER_VERDICTS.includes(data.verdict)) {
293
+ errors.push(`verdict must be one of [${VALID_VERIFIER_VERDICTS.join('|')}] (got ${JSON.stringify(data.verdict)})`);
294
+ }
295
+ if (!Array.isArray(data.gaps)) {
296
+ errors.push('gaps must be an array');
297
+ } else {
298
+ data.gaps.forEach((gap, i) => {
299
+ const tag = `gaps[${i}]`;
300
+ if (!gap || typeof gap !== 'object') {
301
+ errors.push(`${tag} is not an object`);
302
+ return;
303
+ }
304
+ if (typeof gap.id !== 'string' || gap.id.length === 0) {
305
+ errors.push(`${tag}.id is required (non-empty string)`);
306
+ }
307
+ if (!VALID_GAP_SEVERITIES.includes(gap.severity)) {
308
+ errors.push(`${tag}.severity must be one of [${VALID_GAP_SEVERITIES.join('|')}]`);
309
+ }
310
+ if (typeof gap.area !== 'string' || gap.area.length === 0) {
311
+ errors.push(`${tag}.area is required (non-empty string)`);
312
+ }
313
+ if (typeof gap.summary !== 'string' || gap.summary.length < 3) {
314
+ errors.push(`${tag}.summary required, ≥3 chars`);
315
+ }
316
+ });
317
+ }
318
+ if (!Array.isArray(data.must_fix_before_ship) ||
319
+ data.must_fix_before_ship.some((s) => typeof s !== 'string')) {
320
+ errors.push('must_fix_before_ship must be an array of strings');
321
+ }
322
+ if (!VALID_VERIFIER_CONFIDENCE.includes(data.confidence)) {
323
+ errors.push(`confidence must be one of [${VALID_VERIFIER_CONFIDENCE.join('|')}]`);
324
+ }
325
+ return errors.length === 0 ? { ok: true, data } : { ok: false, errors };
326
+ }
327
+
328
+ /**
329
+ * Extract + validate the planner decision JSON block from markdown output.
330
+ * @param {string} markdown
331
+ * @returns {{ ok: true, data: object } | { ok: false, error: string }}
332
+ */
333
+ function parsePlannerDecision(markdown) {
334
+ const extracted = extractJsonBlock(markdown);
335
+ if (!extracted.ok) return { ok: false, error: extracted.error };
336
+ const parsed = parseJson(extracted.raw);
337
+ if (!parsed.ok) return { ok: false, error: parsed.error };
338
+ const validated = validatePlannerDecision(parsed.data);
339
+ if (!validated.ok) {
340
+ return {
341
+ ok: false,
342
+ error: `Planner decision contract violations:\n${validated.errors.map((e) => ` - ${e}`).join('\n')}`,
343
+ };
344
+ }
345
+ return { ok: true, data: validated.data };
346
+ }
347
+
348
+ /**
349
+ * Extract + validate the verifier decision JSON block from markdown output.
350
+ * @param {string} markdown
351
+ * @returns {{ ok: true, data: object } | { ok: false, error: string }}
352
+ */
353
+ function parseVerifierDecision(markdown) {
354
+ const extracted = extractJsonBlock(markdown);
355
+ if (!extracted.ok) return { ok: false, error: extracted.error };
356
+ const parsed = parseJson(extracted.raw);
357
+ if (!parsed.ok) return { ok: false, error: parsed.error };
358
+ const validated = validateVerifierDecision(parsed.data);
359
+ if (!validated.ok) {
360
+ return {
361
+ ok: false,
362
+ error: `Verifier decision contract violations:\n${validated.errors.map((e) => ` - ${e}`).join('\n')}`,
363
+ };
364
+ }
365
+ return { ok: true, data: validated.data };
366
+ }
367
+
207
368
  module.exports = {
208
369
  parseMotionMap,
370
+ parsePlannerDecision,
371
+ parseVerifierDecision,
209
372
  parseGenericContract,
210
373
  loadMotionMapSchema,
211
374
  validateMotionMap,
375
+ validatePlannerDecision,
376
+ validateVerifierDecision,
212
377
  extractJsonBlock,
213
378
  parseJson,
214
379
  // Exported for testing
@@ -217,4 +382,7 @@ module.exports = {
217
382
  VALID_TRANSITION_FAMILIES,
218
383
  VALID_DURATION_CLASSES,
219
384
  VALID_TRIGGERS,
385
+ VALID_VERIFIER_VERDICTS,
386
+ VALID_GAP_SEVERITIES,
387
+ VALID_VERIFIER_CONFIDENCE,
220
388
  };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * reference-resolver.cjs — `type:<key>` → registry entry + excerpt
3
+ * (Plan 23-05).
4
+ *
5
+ * Builds on `scripts/lib/reference-registry.cjs#list` (Phase 14.5).
6
+ * Adds the resolution direction: given a key surfaced by an agent
7
+ * author, return the single matching entry plus a short excerpt
8
+ * suitable for inlining into prompts.
9
+ *
10
+ * Lookup order (first match wins):
11
+ * 1. exact `name` match
12
+ * 2. slug match against path basename without extension
13
+ * 3. singularize fuzzy match (strip trailing 's')
14
+ * 4. type==key AND only one entry exists at that type
15
+ *
16
+ * Ambiguous match → throws RangeError with candidate list.
17
+ * No match → returns null.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ const registry = require('./reference-registry.cjs');
26
+
27
+ /**
28
+ * @typedef {Object} ResolverHit
29
+ * @property {string} name
30
+ * @property {string} path
31
+ * @property {string} type
32
+ * @property {string} excerpt
33
+ * @property {string} [tier]
34
+ */
35
+
36
+ const DEFAULT_MAX_CHARS = 200;
37
+
38
+ /**
39
+ * Pull a 200-char excerpt from a markdown file. Strips frontmatter,
40
+ * fences, comments, headers; collapses whitespace; truncates with `'…'`.
41
+ *
42
+ * @param {string} absolutePath
43
+ * @param {{maxChars?: number}} [opts]
44
+ * @returns {string}
45
+ */
46
+ function excerptOf(absolutePath, opts = {}) {
47
+ const maxChars = typeof opts.maxChars === 'number' ? opts.maxChars : DEFAULT_MAX_CHARS;
48
+ let raw;
49
+ try {
50
+ raw = fs.readFileSync(absolutePath, 'utf8');
51
+ } catch {
52
+ return '';
53
+ }
54
+ // Drop YAML frontmatter.
55
+ raw = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
56
+ // Drop fenced code blocks.
57
+ raw = raw.replace(/```[\s\S]*?```/g, '');
58
+ // Drop HTML comments. Iterate until stable so that nested or
59
+ // adjacent `<!-- … -->` sequences cannot smuggle a residual `<!--`
60
+ // through a single regex pass (CodeQL js/incomplete-multi-character-
61
+ // sanitization). We're not building defense-in-depth against
62
+ // real markup attacks here — these excerpts are local doc files —
63
+ // but the loop costs nothing and silences the alert.
64
+ let prev;
65
+ do {
66
+ prev = raw;
67
+ raw = raw.replace(/<!--[\s\S]*?-->/g, '');
68
+ } while (raw !== prev);
69
+ // Drop heading lines.
70
+ raw = raw.replace(/^#{1,6}\s.*$/gm, '');
71
+ // Take first non-empty paragraph.
72
+ const paragraphs = raw.split(/\r?\n\s*\r?\n/).map((p) => p.trim()).filter(Boolean);
73
+ if (paragraphs.length === 0) return '';
74
+ let p = paragraphs[0].replace(/\s+/g, ' ').trim();
75
+ if (p.length > maxChars) {
76
+ p = p.slice(0, Math.max(0, maxChars - 1)) + '…';
77
+ }
78
+ return p;
79
+ }
80
+
81
+ /**
82
+ * Map a registry entry + cwd to a ResolverHit.
83
+ */
84
+ function hitFor(entry, cwd) {
85
+ const abs = path.resolve(cwd, entry.path);
86
+ /** @type {ResolverHit} */
87
+ const hit = {
88
+ name: entry.name,
89
+ path: entry.path,
90
+ type: entry.type,
91
+ excerpt: excerptOf(abs),
92
+ };
93
+ if (entry.tier) hit.tier = entry.tier;
94
+ return hit;
95
+ }
96
+
97
+ /**
98
+ * Resolve `type:<key>` (or bare `<key>`) to a single registry hit.
99
+ *
100
+ * @param {string} typeKey
101
+ * @param {{cwd?: string}} [opts]
102
+ * @returns {ResolverHit | null}
103
+ */
104
+ function resolve(typeKey, opts = {}) {
105
+ if (typeof typeKey !== 'string' || typeKey.length === 0) return null;
106
+ const cwd = opts.cwd ?? path.resolve(__dirname, '..', '..');
107
+ const key = typeKey.replace(/^type:/, '').trim().toLowerCase();
108
+ if (key.length === 0) return null;
109
+ const all = registry.list({ cwd });
110
+
111
+ // 1. Exact name match.
112
+ const exact = all.find((e) => e.name.toLowerCase() === key);
113
+ if (exact) return hitFor(exact, cwd);
114
+
115
+ // 2. Slug match against path basename (no extension).
116
+ const bySlug = all.filter((e) => {
117
+ const slug = path.posix.basename(e.path, path.posix.extname(e.path)).toLowerCase();
118
+ return slug === key;
119
+ });
120
+ if (bySlug.length === 1) return hitFor(bySlug[0], cwd);
121
+ if (bySlug.length > 1) {
122
+ throw new RangeError(
123
+ `reference-resolver: ambiguous slug match for "${typeKey}" — candidates: ${bySlug.map((e) => e.name).join(', ')}`,
124
+ );
125
+ }
126
+
127
+ // 3. Singularize: strip trailing 's' from key, then prefix-match name.
128
+ if (key.endsWith('s') && key.length > 1) {
129
+ const stem = key.slice(0, -1);
130
+ const stemHits = all.filter((e) => e.name.toLowerCase().startsWith(stem));
131
+ if (stemHits.length === 1) return hitFor(stemHits[0], cwd);
132
+ if (stemHits.length > 1) {
133
+ throw new RangeError(
134
+ `reference-resolver: ambiguous singularize match for "${typeKey}" — candidates: ${stemHits.map((e) => e.name).join(', ')}`,
135
+ );
136
+ }
137
+ }
138
+
139
+ // 4. type==key AND single entry at that type.
140
+ const byType = all.filter((e) => e.type.toLowerCase() === key);
141
+ if (byType.length === 1) return hitFor(byType[0], cwd);
142
+ if (byType.length > 1) {
143
+ throw new RangeError(
144
+ `reference-resolver: ambiguous type-only match for "${typeKey}" — multiple entries at type=${key}: ${byType.map((e) => e.name).join(', ')}`,
145
+ );
146
+ }
147
+
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Bulk resolver — used by the prompt-builder.
153
+ *
154
+ * @param {string[]} typeKeys
155
+ * @param {{cwd?: string, ignoreMissing?: boolean}} [opts]
156
+ * @returns {ResolverHit[]}
157
+ */
158
+ function resolveAll(typeKeys, opts = {}) {
159
+ if (!Array.isArray(typeKeys)) {
160
+ throw new TypeError('reference-resolver: typeKeys must be an array');
161
+ }
162
+ /** @type {ResolverHit[]} */
163
+ const hits = [];
164
+ /** @type {string[]} */
165
+ const missing = [];
166
+ for (const k of typeKeys) {
167
+ const h = resolve(k, opts);
168
+ if (h) hits.push(h);
169
+ else missing.push(k);
170
+ }
171
+ if (missing.length > 0 && !opts.ignoreMissing) {
172
+ throw new Error(
173
+ `reference-resolver: unresolved keys: ${missing.join(', ')}. Pass {ignoreMissing: true} to skip.`,
174
+ );
175
+ }
176
+ return hits;
177
+ }
178
+
179
+ module.exports = {
180
+ resolve,
181
+ resolveAll,
182
+ excerptOf,
183
+ DEFAULT_MAX_CHARS,
184
+ };
@@ -0,0 +1,201 @@
1
+ /**
2
+ * touches-analyzer/index.cjs — parse `Touches:` lines from task markdown
3
+ * and produce a pairwise parallelism verdict (Plan 23-03).
4
+ *
5
+ * Encodes the prompt-only heuristic from `reference/parallelism-rules.md`
6
+ * into auditable code. Used by /gdd:plan and /gdd:execute to decide
7
+ * which tasks can run concurrently in a wave.
8
+ *
9
+ * Verdict rules (first match wins):
10
+ * 1. empty globs → sequential, 'unknown-touches'
11
+ * 2. literal glob equality → sequential, 'shared-glob'
12
+ * 3. shared component dir → sequential, 'shared-component-dir'
13
+ * 4. resolved-file overlap → sequential, 'shared-file'
14
+ * 5. otherwise → parallel, 'disjoint'
15
+ *
16
+ * No external deps. Designed to be required from CommonJS callers.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const { readFileSync } = require('node:fs');
22
+ const path = require('node:path');
23
+
24
+ const TOUCHES_RE = /^[ \t]{0,4}Touches:\s*(.+?)\s*$/gm;
25
+
26
+ /**
27
+ * Normalise a glob/path: convert `\\` → `/`, lowercase for case-insensitive
28
+ * comparison. Returned strings are used as map keys.
29
+ *
30
+ * @param {string} g
31
+ * @returns {string}
32
+ */
33
+ function normalize(g) {
34
+ return g.replace(/\\/g, '/').toLowerCase();
35
+ }
36
+
37
+ /**
38
+ * Extract `Touches:` lines from markdown.
39
+ *
40
+ * @param {string} markdown
41
+ * @returns {string[]} globs in declaration order, deduped (case-insensitive)
42
+ */
43
+ function parseTouches(markdown) {
44
+ if (typeof markdown !== 'string' || markdown.length === 0) return [];
45
+ const out = [];
46
+ const seen = new Set();
47
+ TOUCHES_RE.lastIndex = 0;
48
+ let m;
49
+ while ((m = TOUCHES_RE.exec(markdown)) !== null) {
50
+ const body = m[1];
51
+ for (const raw of body.split(',')) {
52
+ const trimmed = raw.trim();
53
+ if (trimmed.length === 0) continue;
54
+ const key = normalize(trimmed);
55
+ if (seen.has(key)) continue;
56
+ seen.add(key);
57
+ out.push(trimmed);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * Parse a task markdown file by path.
65
+ *
66
+ * @param {string} filePath
67
+ * @returns {{taskId: string, globs: string[]}}
68
+ */
69
+ function parseTouchesFile(filePath) {
70
+ const md = readFileSync(filePath, 'utf8');
71
+ const base = path.basename(filePath).replace(/\.md$/i, '');
72
+ return { taskId: base, globs: parseTouches(md) };
73
+ }
74
+
75
+ /**
76
+ * Compute the directory prefix for a glob at `componentDepth - 1` segments.
77
+ * Returns null when the glob's first segment is `**` or contains `..` (no
78
+ * meaningful prefix).
79
+ *
80
+ * @param {string} glob
81
+ * @param {number} componentDepth
82
+ * @returns {string|null}
83
+ */
84
+ function componentDirPrefix(glob, componentDepth) {
85
+ const norm = glob.replace(/\\/g, '/');
86
+ if (norm.startsWith('..') || norm.startsWith('**')) return null;
87
+ // Strip leading './'.
88
+ const cleaned = norm.startsWith('./') ? norm.slice(2) : norm;
89
+ const segments = cleaned.split('/');
90
+ const wanted = Math.max(0, componentDepth - 1);
91
+ if (segments.length < wanted) return null;
92
+ const prefixSegs = segments.slice(0, wanted);
93
+ // The prefix must contain at least one *literal* (no `**`) segment.
94
+ const hasLiteral = prefixSegs.some((s) => s.length > 0 && s !== '**');
95
+ if (!hasLiteral) return null;
96
+ return prefixSegs.join('/').toLowerCase();
97
+ }
98
+
99
+ /**
100
+ * @typedef {Object} TouchesEntry
101
+ * @property {string} taskId
102
+ * @property {string[]} globs
103
+ * @property {string[]} [resolved]
104
+ */
105
+
106
+ /**
107
+ * @typedef {Object} Verdict
108
+ * @property {'parallel'|'sequential'} verdict
109
+ * @property {string} reason
110
+ * @property {string[]} [evidence]
111
+ */
112
+
113
+ /**
114
+ * Pairwise verdict.
115
+ *
116
+ * @param {TouchesEntry} a
117
+ * @param {TouchesEntry} b
118
+ * @param {{componentDepth?: number}} [opts]
119
+ * @returns {Verdict}
120
+ */
121
+ function pairwiseVerdict(a, b, opts = {}) {
122
+ const componentDepth = opts.componentDepth ?? 3;
123
+ if (!a || !b || !Array.isArray(a.globs) || !Array.isArray(b.globs)) {
124
+ return { verdict: 'sequential', reason: 'unknown-touches' };
125
+ }
126
+ if (a.globs.length === 0 || b.globs.length === 0) {
127
+ return { verdict: 'sequential', reason: 'unknown-touches' };
128
+ }
129
+ // Rule 2: literal glob equality (case-insensitive).
130
+ const aSet = new Set(a.globs.map(normalize));
131
+ for (const bg of b.globs) {
132
+ if (aSet.has(normalize(bg))) {
133
+ return { verdict: 'sequential', reason: 'shared-glob', evidence: [bg] };
134
+ }
135
+ }
136
+ // Rule 3: shared component directory.
137
+ const aPrefixes = new Set(
138
+ a.globs.map((g) => componentDirPrefix(g, componentDepth)).filter((p) => p !== null),
139
+ );
140
+ const sharedPrefixes = [];
141
+ for (const bg of b.globs) {
142
+ const pfx = componentDirPrefix(bg, componentDepth);
143
+ if (pfx !== null && aPrefixes.has(pfx)) sharedPrefixes.push(pfx);
144
+ }
145
+ if (sharedPrefixes.length > 0) {
146
+ return {
147
+ verdict: 'sequential',
148
+ reason: 'shared-component-dir',
149
+ evidence: Array.from(new Set(sharedPrefixes)),
150
+ };
151
+ }
152
+ // Rule 4: resolved file intersection.
153
+ if (Array.isArray(a.resolved) && Array.isArray(b.resolved)) {
154
+ const aFiles = new Set(a.resolved.map(normalize));
155
+ const overlap = [];
156
+ for (const bf of b.resolved) {
157
+ if (aFiles.has(normalize(bf))) overlap.push(bf);
158
+ }
159
+ if (overlap.length > 0) {
160
+ return { verdict: 'sequential', reason: 'shared-file', evidence: overlap };
161
+ }
162
+ }
163
+ return { verdict: 'parallel', reason: 'disjoint' };
164
+ }
165
+
166
+ /**
167
+ * Build the upper-triangular N×N verdict table.
168
+ *
169
+ * @param {TouchesEntry[]} entries
170
+ * @param {{componentDepth?: number}} [opts]
171
+ * @returns {Array<{a: string, b: string, verdict: string, reason: string, evidence?: string[]}>}
172
+ */
173
+ function verdictMatrix(entries, opts = {}) {
174
+ if (!Array.isArray(entries)) {
175
+ throw new TypeError('verdictMatrix: entries must be an array');
176
+ }
177
+ const out = [];
178
+ for (let i = 0; i < entries.length; i++) {
179
+ for (let j = i + 1; j < entries.length; j++) {
180
+ const v = pairwiseVerdict(entries[i], entries[j], opts);
181
+ const row = {
182
+ a: entries[i].taskId,
183
+ b: entries[j].taskId,
184
+ verdict: v.verdict,
185
+ reason: v.reason,
186
+ };
187
+ if (v.evidence) row.evidence = v.evidence;
188
+ out.push(row);
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+
194
+ module.exports = {
195
+ parseTouches,
196
+ parseTouchesFile,
197
+ pairwiseVerdict,
198
+ verdictMatrix,
199
+ componentDirPrefix,
200
+ normalize,
201
+ };