@roopesh.yadava/qa-pack 1.4.0 → 1.5.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 +15 -1
- package/claude/skills/SKILLS_CONTEXT.md +61 -5
- package/claude/skills/automation/SKILL.md +53 -6
- package/claude/skills/bug-reporting/SKILL.md +38 -0
- package/claude/skills/manual-testing/SKILL.md +67 -2
- package/claude/skills/qa-agent/SKILL.md +30 -0
- package/claude/skills/qa-agent/product_context/README.md +16 -0
- package/claude/skills/qa-agent/toolkit/qa-toolkit.cjs +705 -0
- package/claude/skills/qa-insights/SKILL.md +55 -0
- package/claude/skills/roam-testing/SKILL.md +187 -0
- package/package.json +1 -1
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* qa-toolkit — deterministic helper CLI for qa-pack skills.
|
|
6
|
+
*
|
|
7
|
+
* Every command here does work that would otherwise cost an LLM a full file
|
|
8
|
+
* Read (or worse, an in-context reasoning pass) to accomplish: parsing
|
|
9
|
+
* markdown tables, hashing DOM snapshots, scoring string similarity, walking
|
|
10
|
+
* git history, aggregating dozens of product_context files. None of that
|
|
11
|
+
* needs a model — it needs a parser. Skills shell out here via Bash and read
|
|
12
|
+
* only the one-line (or few-line) result, never the underlying file.
|
|
13
|
+
*
|
|
14
|
+
* No npm dependencies (qa-pack ships none) — Node core only.
|
|
15
|
+
* Always invoked from the repo root, e.g.:
|
|
16
|
+
* node .claude/skills/qa-agent/toolkit/qa-toolkit.cjs <command> [--flags]
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const { spawnSync } = require('child_process');
|
|
23
|
+
|
|
24
|
+
const ROOT = process.cwd();
|
|
25
|
+
const PRODUCT_CONTEXT_ROOT = path.join(ROOT, '.claude', 'skills', 'qa-agent', 'product_context');
|
|
26
|
+
const OUTPUTS_DIR = path.join(ROOT, 'outputs');
|
|
27
|
+
const TRUST_THRESHOLD = 5;
|
|
28
|
+
|
|
29
|
+
// ── generic fs / arg helpers ─────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function productDir(product) { return path.join(PRODUCT_CONTEXT_ROOT, product); }
|
|
32
|
+
function contextFile(product) { return path.join(productDir(product), 'context.md'); }
|
|
33
|
+
function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); }
|
|
34
|
+
function todayStr() { return new Date().toISOString().slice(0, 10); }
|
|
35
|
+
|
|
36
|
+
function readFileSafe(p) {
|
|
37
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
function readJson(p, fallback) {
|
|
40
|
+
const raw = readFileSafe(p);
|
|
41
|
+
if (raw === null) return fallback;
|
|
42
|
+
try { return JSON.parse(raw); } catch { return fallback; }
|
|
43
|
+
}
|
|
44
|
+
function writeJson(p, obj) {
|
|
45
|
+
ensureDir(path.dirname(p));
|
|
46
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
|
|
47
|
+
}
|
|
48
|
+
function readStdinSync() {
|
|
49
|
+
try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function listProductFolders() {
|
|
53
|
+
if (!fs.existsSync(PRODUCT_CONTEXT_ROOT)) return [];
|
|
54
|
+
return fs.readdirSync(PRODUCT_CONTEXT_ROOT, { withFileTypes: true })
|
|
55
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(PRODUCT_CONTEXT_ROOT, e.name, 'context.md')))
|
|
56
|
+
.map((e) => e.name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseArgs(argv) {
|
|
60
|
+
const args = {};
|
|
61
|
+
for (let i = 0; i < argv.length; i++) {
|
|
62
|
+
const tok = argv[i];
|
|
63
|
+
if (tok.startsWith('--')) {
|
|
64
|
+
const key = tok.slice(2);
|
|
65
|
+
const next = argv[i + 1];
|
|
66
|
+
if (next === undefined || next.startsWith('--')) args[key] = true;
|
|
67
|
+
else { args[key] = next; i++; }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return args;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requireArgs(args, names) {
|
|
74
|
+
const missing = names.filter((n) => args[n] === undefined);
|
|
75
|
+
if (missing.length) {
|
|
76
|
+
console.log(`ERROR: missing required flag(s): ${missing.map((n) => '--' + n).join(', ')}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── markdown table parsing (context.md tables) ───────────────────────────
|
|
82
|
+
|
|
83
|
+
function extractSectionLines(content, headingText) {
|
|
84
|
+
const lines = content.split('\n');
|
|
85
|
+
const headingRe = new RegExp('^##\\s+' + headingText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*$', 'i');
|
|
86
|
+
const start = lines.findIndex((l) => headingRe.test(l.trim()));
|
|
87
|
+
if (start === -1) return null;
|
|
88
|
+
let end = lines.length;
|
|
89
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
90
|
+
if (/^##\s/.test(lines[i])) { end = i; break; }
|
|
91
|
+
}
|
|
92
|
+
return lines.slice(start + 1, end);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function splitRow(line) {
|
|
96
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((s) => s.trim());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseMarkdownTable(sectionLines) {
|
|
100
|
+
const tableLines = sectionLines.filter((l) => l.trim().startsWith('|'));
|
|
101
|
+
if (tableLines.length < 2) return { headers: [], rows: [] };
|
|
102
|
+
const headers = splitRow(tableLines[0]);
|
|
103
|
+
const rows = tableLines.slice(2).map((line) => {
|
|
104
|
+
const cells = splitRow(line);
|
|
105
|
+
const obj = {};
|
|
106
|
+
headers.forEach((h, i) => { obj[h] = cells[i] !== undefined ? cells[i] : ''; });
|
|
107
|
+
return obj;
|
|
108
|
+
});
|
|
109
|
+
return { headers, rows };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isPlaceholderRow(row) {
|
|
113
|
+
const values = Object.values(row);
|
|
114
|
+
if (!values.length) return true;
|
|
115
|
+
if (/^—+$/.test(values[0] || '')) return true;
|
|
116
|
+
const joined = values.join(' ');
|
|
117
|
+
return /\{[^}]+\}/.test(joined) || /No bugs filed yet/i.test(joined);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Returns { found: bool, rows: [...] } — found=false means no context.md at all yet. */
|
|
121
|
+
function getTable(product, headingText) {
|
|
122
|
+
const content = readFileSafe(contextFile(product));
|
|
123
|
+
if (content === null) return { found: false, rows: [] };
|
|
124
|
+
const section = extractSectionLines(content, headingText);
|
|
125
|
+
if (!section) return { found: true, rows: [] };
|
|
126
|
+
const { rows } = parseMarkdownTable(section);
|
|
127
|
+
return { found: true, rows: rows.filter((r) => !isPlaceholderRow(r)) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getProductInfo(product) {
|
|
131
|
+
const content = readFileSafe(contextFile(product));
|
|
132
|
+
const info = {};
|
|
133
|
+
if (!content) return info;
|
|
134
|
+
const patterns = {
|
|
135
|
+
name: /\*\*Product Name\*\*:\s*(.+)/i,
|
|
136
|
+
appUrl: /\*\*App URL\*\*:\s*(.+)/i,
|
|
137
|
+
env: /\*\*Environment\*\*:\s*(.+)/i,
|
|
138
|
+
key: /\*\*Jira Project Key\*\*:\s*(.+)/i,
|
|
139
|
+
};
|
|
140
|
+
for (const [k, re] of Object.entries(patterns)) {
|
|
141
|
+
const m = content.match(re);
|
|
142
|
+
if (m) info[k] = m[1].trim();
|
|
143
|
+
}
|
|
144
|
+
return info;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── list-products ─────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function cmdListProducts() {
|
|
150
|
+
const products = listProductFolders();
|
|
151
|
+
if (!products.length) { console.log('NO_PRODUCTS'); return; }
|
|
152
|
+
products.forEach((p) => console.log(p));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── get-bugs / get-runs (cheap reads — no LLM file parse needed) ─────────
|
|
156
|
+
|
|
157
|
+
function cmdGetBugs(args) {
|
|
158
|
+
requireArgs(args, ['product']);
|
|
159
|
+
const { found, rows } = getTable(args.product, 'Known Bugs');
|
|
160
|
+
if (!found) return console.log('NO_CONTEXT_FILE');
|
|
161
|
+
if (!rows.length) return console.log('NO_BUGS');
|
|
162
|
+
rows.forEach((r) => console.log([r['Bug ID'], r['Title'], r['Severity'], r['Status'], r['Card']].join(' | ')));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cmdGetSelectors(args) {
|
|
166
|
+
requireArgs(args, ['product']);
|
|
167
|
+
const { found, rows } = getTable(args.product, 'Element Selectors');
|
|
168
|
+
if (!found) return console.log('NO_CONTEXT_FILE');
|
|
169
|
+
const filtered = args.url ? rows.filter((r) => (r['Page URL'] || '').includes(args.url)) : rows;
|
|
170
|
+
if (!filtered.length) return console.log('NO_SELECTORS');
|
|
171
|
+
filtered.forEach((r) =>
|
|
172
|
+
console.log([r['Element Label'], r['Page URL'], r['Locator'], r['Method'], r['data-testid'], r['Card']].join(' | '))
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function cmdGetRuns(args) {
|
|
177
|
+
requireArgs(args, ['product']);
|
|
178
|
+
const { found, rows } = getTable(args.product, 'Runs Log');
|
|
179
|
+
if (!found) return console.log('NO_CONTEXT_FILE');
|
|
180
|
+
const filtered = args.phase
|
|
181
|
+
? rows.filter((r) => (r['Phase'] || '').toLowerCase().includes(String(args.phase).toLowerCase()))
|
|
182
|
+
: rows;
|
|
183
|
+
if (!filtered.length) return console.log('NO_RUNS');
|
|
184
|
+
filtered.forEach((r) =>
|
|
185
|
+
console.log([r['Date'], r['Card'], r['Phase'], r['Outcome'], r['Bugs Filed'], r['Reuse %'], r['Notes']].join(' | '))
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── fingerprint: DOM fingerprint cache ────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function hashList(items) {
|
|
192
|
+
const sorted = [...new Set(items)].sort();
|
|
193
|
+
return crypto.createHash('sha1').update(sorted.join('\n')).digest('hex').slice(0, 16);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function cmdFingerprint(args) {
|
|
197
|
+
requireArgs(args, ['product', 'url']);
|
|
198
|
+
const testids = String(args.testids || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
199
|
+
const file = path.join(productDir(args.product), 'dom-fingerprints.json');
|
|
200
|
+
const store = readJson(file, {});
|
|
201
|
+
const newHash = hashList(testids);
|
|
202
|
+
const prev = store[args.url];
|
|
203
|
+
const changed = !prev || prev.hash !== newHash;
|
|
204
|
+
store[args.url] = { hash: newHash, count: testids.length, updatedAt: todayStr() };
|
|
205
|
+
writeJson(file, store);
|
|
206
|
+
|
|
207
|
+
if (!prev) console.log('NEW — no prior fingerprint for this page. Run full DOM discovery.');
|
|
208
|
+
else if (changed) {
|
|
209
|
+
console.log(`CHANGED — DOM signature differs from ${prev.updatedAt} (${prev.count} → ${testids.length} testids). Re-discover this page.`);
|
|
210
|
+
} else {
|
|
211
|
+
console.log(`UNCHANGED — matches fingerprint from ${prev.updatedAt}. Skip DOM re-discovery, reuse cached selectors.`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── pii-scan: PII / secrets scan before any Jira post ─────────────────────
|
|
216
|
+
|
|
217
|
+
const PII_PATTERNS = [
|
|
218
|
+
{ name: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g },
|
|
219
|
+
{ name: 'ssn', re: /\b\d{3}-\d{2}-\d{4}\b/g },
|
|
220
|
+
{ name: 'credit_card', re: /\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{1,4}\b|\b\d{15,16}\b/g },
|
|
221
|
+
{ name: 'aws_key', re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
222
|
+
{ name: 'openai_key', re: /\bsk-[A-Za-z0-9]{20,}\b/g },
|
|
223
|
+
{ name: 'github_token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
|
|
224
|
+
{ name: 'slack_token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
225
|
+
{ name: 'gcp_key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
226
|
+
{ name: 'jwt', re: /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
|
|
227
|
+
{ name: 'generic_secret_assignment', re: /\b(api[_-]?key|secret|password|passwd|token)\b\s*[:=]\s*["']?[^\s"'<]{6,}/gi },
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
function redact(match) {
|
|
231
|
+
const s = String(match);
|
|
232
|
+
if (s.length <= 4) return '*'.repeat(s.length);
|
|
233
|
+
return s.slice(0, 2) + '*'.repeat(Math.max(s.length - 4, 3)) + s.slice(-2);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function cmdPiiScan(args) {
|
|
237
|
+
let text;
|
|
238
|
+
if (args.file) {
|
|
239
|
+
text = readFileSafe(args.file);
|
|
240
|
+
if (text === null) { console.log(`ERROR: cannot read ${args.file}`); process.exit(1); }
|
|
241
|
+
} else {
|
|
242
|
+
text = readStdinSync();
|
|
243
|
+
}
|
|
244
|
+
const findings = [];
|
|
245
|
+
for (const { name, re } of PII_PATTERNS) {
|
|
246
|
+
const matches = text.match(re) || [];
|
|
247
|
+
if (matches.length) findings.push({ name, count: matches.length, sample: redact(matches[0]) });
|
|
248
|
+
}
|
|
249
|
+
if (!findings.length) return console.log('CLEAN');
|
|
250
|
+
console.log(`FLAGGED: ${findings.length} pattern type(s) detected — confirm with the user before posting`);
|
|
251
|
+
findings.forEach((f) => console.log(` - ${f.name}: ${f.count} match(es), e.g. "${f.sample}"`));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── dup-bug: duplicate-bug detector ───────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function tokenize(s) {
|
|
257
|
+
return String(s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter((w) => w.length > 2);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function diceSimilarity(a, b) {
|
|
261
|
+
const setA = new Set(tokenize(a));
|
|
262
|
+
const setB = new Set(tokenize(b));
|
|
263
|
+
if (!setA.size || !setB.size) return 0;
|
|
264
|
+
let overlap = 0;
|
|
265
|
+
for (const w of setA) if (setB.has(w)) overlap++;
|
|
266
|
+
return (2 * overlap) / (setA.size + setB.size);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function resolveTextArg(args, inlineKey, fileKey) {
|
|
270
|
+
if (args[fileKey]) {
|
|
271
|
+
const content = readFileSafe(args[fileKey]);
|
|
272
|
+
if (content === null) { console.log(`ERROR: cannot read ${args[fileKey]}`); process.exit(1); }
|
|
273
|
+
return content.trim();
|
|
274
|
+
}
|
|
275
|
+
return args[inlineKey];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function cmdDupBug(args) {
|
|
279
|
+
requireArgs(args, ['product']);
|
|
280
|
+
const summary = resolveTextArg(args, 'summary', 'summary-file');
|
|
281
|
+
if (!summary) { console.log('ERROR: --summary or --summary-file required'); process.exit(1); }
|
|
282
|
+
const { found, rows } = getTable(args.product, 'Known Bugs');
|
|
283
|
+
if (!found || !rows.length) return console.log('NO_DUPLICATE_FOUND — no bug history yet');
|
|
284
|
+
const scored = rows
|
|
285
|
+
.map((r) => ({ id: r['Bug ID'], title: r['Title'], status: r['Status'], score: diceSimilarity(summary, r['Title']) }))
|
|
286
|
+
.filter((r) => r.score >= 0.35)
|
|
287
|
+
.sort((a, b) => b.score - a.score)
|
|
288
|
+
.slice(0, 3);
|
|
289
|
+
if (!scored.length) return console.log('NO_DUPLICATE_FOUND');
|
|
290
|
+
scored.forEach((r) => console.log(`POSSIBLE_DUPLICATE: ${r.id} (score ${r.score.toFixed(2)}, status ${r.status}) — "${r.title}"`));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── cost-estimate: pre-run cost estimate ──────────────────────────────────
|
|
294
|
+
|
|
295
|
+
function tryReadTrackingLog() {
|
|
296
|
+
const dir = process.env.QA_TRACKING_DIR;
|
|
297
|
+
if (!dir) return null;
|
|
298
|
+
return readJson(path.join(dir, 'token_usage_log.json'), null);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function avgTokensForCards(tracking, cardIds) {
|
|
302
|
+
if (!Array.isArray(tracking)) return null;
|
|
303
|
+
const entries = tracking.filter((e) => e && cardIds.has(e.card));
|
|
304
|
+
const totals = entries.map((e) => (e.input_tokens || 0) + (e.output_tokens || 0)).filter((v) => v > 0);
|
|
305
|
+
if (!totals.length) return null;
|
|
306
|
+
return Math.round(totals.reduce((a, b) => a + b, 0) / totals.length);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function cmdCostEstimate(args) {
|
|
310
|
+
requireArgs(args, ['product']);
|
|
311
|
+
const { found, rows } = getTable(args.product, 'Runs Log');
|
|
312
|
+
if (!found || !rows.length) {
|
|
313
|
+
console.log(`No run history yet for ${args.product} — first run, no estimate available.`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const phase = args.phase ? String(args.phase) : '';
|
|
317
|
+
const relevant = phase ? rows.filter((r) => (r['Phase'] || '').toLowerCase().includes(phase.toLowerCase())) : rows;
|
|
318
|
+
const pool = relevant.length ? relevant : rows;
|
|
319
|
+
const n = pool.length;
|
|
320
|
+
|
|
321
|
+
const reuseVals = pool.map((r) => parseFloat((r['Reuse %'] || '').replace('%', ''))).filter((v) => !isNaN(v));
|
|
322
|
+
const avgReuse = reuseVals.length ? Math.round(reuseVals.reduce((a, b) => a + b, 0) / reuseVals.length) : null;
|
|
323
|
+
const bugRuns = pool.filter((r) => (r['Bugs Filed'] || '').trim() && r['Bugs Filed'] !== '—').length;
|
|
324
|
+
|
|
325
|
+
const tracking = tryReadTrackingLog();
|
|
326
|
+
const avgTok = tracking ? avgTokensForCards(tracking, new Set(pool.map((r) => r['Card']))) : null;
|
|
327
|
+
const tokenLine = avgTok ? `, ~${avgTok.toLocaleString()} tokens/run avg` : '';
|
|
328
|
+
|
|
329
|
+
console.log(
|
|
330
|
+
`Estimated for ${args.product}${phase ? ' (Phase ' + phase + ')' : ''}: based on ${n} past run(s), ` +
|
|
331
|
+
`avg reuse ${avgReuse !== null ? avgReuse + '%' : 'n/a'}${tokenLine}. ${bugRuns}/${n} past runs found bugs.`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── risk-score: risk-based test ordering ──────────────────────────────────
|
|
336
|
+
|
|
337
|
+
function gitChangedFiles(days) {
|
|
338
|
+
const res = spawnSync('git', ['log', `--since=${days} days ago`, '--name-only', '--pretty=format:'], {
|
|
339
|
+
cwd: ROOT, encoding: 'utf8',
|
|
340
|
+
});
|
|
341
|
+
if (res.status !== 0 || !res.stdout) return [];
|
|
342
|
+
return res.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function cmdRiskScore(args) {
|
|
346
|
+
requireArgs(args, ['product', 'modules']);
|
|
347
|
+
const modules = String(args.modules).split(',').map((s) => s.trim()).filter(Boolean);
|
|
348
|
+
if (!modules.length) { console.log('ERROR: --modules "a,b,c" required'); process.exit(1); }
|
|
349
|
+
const changedFiles = gitChangedFiles(180);
|
|
350
|
+
const { rows: bugRows } = getTable(args.product, 'Known Bugs');
|
|
351
|
+
|
|
352
|
+
const scored = modules
|
|
353
|
+
.map((mod) => {
|
|
354
|
+
const kw = mod.toLowerCase();
|
|
355
|
+
const churn = changedFiles.filter((f) => f.toLowerCase().includes(kw)).length;
|
|
356
|
+
const bugs = (bugRows || []).filter((r) => (r['Title'] || '').toLowerCase().includes(kw)).length;
|
|
357
|
+
return { mod, churn, bugs, risk: churn * 0.6 + bugs * 3 };
|
|
358
|
+
})
|
|
359
|
+
.sort((a, b) => b.risk - a.risk);
|
|
360
|
+
|
|
361
|
+
scored.forEach((s, i) =>
|
|
362
|
+
console.log(`${i + 1}. ${s.mod} — risk ${s.risk.toFixed(1)} (${s.churn} commits/180d, ${s.bugs} known bug${s.bugs === 1 ? '' : 's'})`)
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── trust: trust ratchet on auto-approve ──────────────────────────────────
|
|
367
|
+
|
|
368
|
+
function trustFile(product) { return path.join(productDir(product), 'trust.json'); }
|
|
369
|
+
|
|
370
|
+
function cmdTrustRecord(args) {
|
|
371
|
+
requireArgs(args, ['product', 'gate', 'result']);
|
|
372
|
+
const file = trustFile(args.product);
|
|
373
|
+
const store = readJson(file, {});
|
|
374
|
+
if (!store[args.gate]) store[args.gate] = { clean: 0, edited: 0, streak: 0 };
|
|
375
|
+
if (args.result === 'clean') { store[args.gate].clean++; store[args.gate].streak++; }
|
|
376
|
+
else { store[args.gate].edited++; store[args.gate].streak = 0; }
|
|
377
|
+
writeJson(file, store);
|
|
378
|
+
console.log(`Recorded — ${args.gate}: streak ${store[args.gate].streak}, ${store[args.gate].clean} clean / ${store[args.gate].edited} edited total.`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function cmdTrustStatus(args) {
|
|
382
|
+
requireArgs(args, ['product']);
|
|
383
|
+
const store = readJson(trustFile(args.product), {});
|
|
384
|
+
const gates = Object.keys(store);
|
|
385
|
+
if (!gates.length) return console.log('NOT_ELIGIBLE — no gate history yet for this product.');
|
|
386
|
+
const minStreak = Math.min(...gates.map((g) => store[g].streak || 0));
|
|
387
|
+
const summary = gates.map((g) => `${g}: streak ${store[g].streak}`).join(', ');
|
|
388
|
+
if (minStreak >= TRUST_THRESHOLD) {
|
|
389
|
+
console.log(`ELIGIBLE — ${summary}. All gates clean for ${minStreak}+ consecutive runs; offer to raise autonomy for this product.`);
|
|
390
|
+
} else {
|
|
391
|
+
console.log(`NOT_ELIGIBLE — ${summary} (needs ${TRUST_THRESHOLD} consecutive clean approvals to qualify).`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── locator: self-improving locator memory ────────────────────────────────
|
|
396
|
+
|
|
397
|
+
function locatorFile(product) { return path.join(productDir(product), 'locator-learnings.md'); }
|
|
398
|
+
|
|
399
|
+
function ensureLocatorFile(product) {
|
|
400
|
+
const file = locatorFile(product);
|
|
401
|
+
if (!fs.existsSync(file)) {
|
|
402
|
+
ensureDir(path.dirname(file));
|
|
403
|
+
fs.writeFileSync(
|
|
404
|
+
file,
|
|
405
|
+
`# Locator Learnings — ${product}\n` +
|
|
406
|
+
`Auto-maintained by qa-toolkit.cjs — do not edit by hand.\n\n` +
|
|
407
|
+
`| Page | Element | Failed Locator | Working Locator | Reason | Card | Date |\n` +
|
|
408
|
+
`|------|---------|-----------------|------------------|--------|------|------|\n`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return file;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Markdown-table-safe cell: locators legitimately contain "|" (xpath unions especially) —
|
|
415
|
+
// escape it rather than let it silently corrupt the table's column count on re-parse.
|
|
416
|
+
function tableCell(v) {
|
|
417
|
+
return String(v ?? '').replace(/\|/g, '|').replace(/\n/g, ' ').trim();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function cmdLocatorRecord(args) {
|
|
421
|
+
// Free-text locator strings routinely contain quotes/brackets that are unsafe to inline
|
|
422
|
+
// into a shell command — accept a JSON --file as the safe path; individual --flags remain
|
|
423
|
+
// for short, technical values a skill is confident contain no special characters.
|
|
424
|
+
let fields = args;
|
|
425
|
+
if (args.file) {
|
|
426
|
+
const parsed = readJson(args.file, null);
|
|
427
|
+
if (!parsed) { console.log(`ERROR: cannot read/parse JSON at ${args.file}`); process.exit(1); }
|
|
428
|
+
fields = parsed;
|
|
429
|
+
}
|
|
430
|
+
const { product, page, element, failed, fixed, reason, card } = fields;
|
|
431
|
+
if (!product || !page || !element || !fixed) {
|
|
432
|
+
console.log('ERROR: --product --page --element --fixed required (or an equivalent --file JSON)');
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
const file = ensureLocatorFile(product);
|
|
436
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
437
|
+
const key = `${page}|||${element}`.toLowerCase();
|
|
438
|
+
const rowLine = `| ${tableCell(page)} | ${tableCell(element)} | ${tableCell(failed || '—')} | ${tableCell(fixed)} | ${tableCell(reason || '—')} | ${tableCell(card || '—')} | ${todayStr()} |`;
|
|
439
|
+
|
|
440
|
+
const filtered = lines.filter((l) => {
|
|
441
|
+
if (!l.trim().startsWith('|')) return true;
|
|
442
|
+
const cells = splitRow(l);
|
|
443
|
+
if (cells.length < 3 || cells[0] === 'Page') return true; // keep header row
|
|
444
|
+
return `${cells[0]}|||${cells[1]}`.toLowerCase() !== key;
|
|
445
|
+
});
|
|
446
|
+
while (filtered.length && filtered[filtered.length - 1].trim() === '') filtered.pop();
|
|
447
|
+
filtered.push(rowLine);
|
|
448
|
+
fs.writeFileSync(file, filtered.join('\n') + '\n');
|
|
449
|
+
console.log(`Locator learning recorded: ${element} on ${page} → ${fixed}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function cmdLocatorQuery(args) {
|
|
453
|
+
requireArgs(args, ['product']);
|
|
454
|
+
const content = readFileSafe(locatorFile(args.product));
|
|
455
|
+
if (!content) return console.log('NONE — no locator learnings yet for this product.');
|
|
456
|
+
const dataLines = content.split('\n').filter(
|
|
457
|
+
(l) => l.trim().startsWith('|') && !l.includes('---') && !l.trim().startsWith('| Page |')
|
|
458
|
+
);
|
|
459
|
+
const matches = args.page ? dataLines.filter((l) => l.toLowerCase().includes(String(args.page).toLowerCase())) : dataLines;
|
|
460
|
+
if (!matches.length) return console.log('NONE — no learnings for this page yet.');
|
|
461
|
+
matches.forEach((l) => console.log(l.trim()));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── shared stats collector (dashboard / digest / roi) ─────────────────────
|
|
465
|
+
|
|
466
|
+
function pctList(rows, field) {
|
|
467
|
+
return rows.map((r) => parseFloat((r[field] || '').replace('%', ''))).filter((v) => !isNaN(v));
|
|
468
|
+
}
|
|
469
|
+
function avg(nums) {
|
|
470
|
+
return nums.length ? Math.round(nums.reduce((a, b) => a + b, 0) / nums.length) : null;
|
|
471
|
+
}
|
|
472
|
+
function bugsFiledCount(row) {
|
|
473
|
+
const bf = (row['Bugs Filed'] || '').trim();
|
|
474
|
+
return !bf || bf === '—' ? 0 : bf.split(',').filter(Boolean).length;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function collectProductStats(product) {
|
|
478
|
+
const info = getProductInfo(product);
|
|
479
|
+
const runs = getTable(product, 'Runs Log').rows;
|
|
480
|
+
const bugs = getTable(product, 'Known Bugs').rows;
|
|
481
|
+
const flows = getTable(product, 'Covered Flows').rows;
|
|
482
|
+
const openBugs = bugs.filter((b) => !/closed|done|fixed|resolved/i.test(b['Status'] || '')).length;
|
|
483
|
+
return {
|
|
484
|
+
product,
|
|
485
|
+
name: info.name || product,
|
|
486
|
+
env: info.env || '—',
|
|
487
|
+
runCount: runs.length,
|
|
488
|
+
bugCount: bugs.length,
|
|
489
|
+
openBugs,
|
|
490
|
+
flowCount: flows.length,
|
|
491
|
+
avgReuse: avg(pctList(runs, 'Reuse %')),
|
|
492
|
+
lastRun: runs.length ? runs[runs.length - 1] : null,
|
|
493
|
+
runs,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ── dashboard: product health dashboard ───────────────────────────────────
|
|
498
|
+
|
|
499
|
+
function escapeHtml(s) {
|
|
500
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function cmdDashboard() {
|
|
504
|
+
const products = listProductFolders();
|
|
505
|
+
if (!products.length) return console.log('NO_PRODUCTS — no product has run yet.');
|
|
506
|
+
const stats = products.map(collectProductStats).sort((a, b) => b.runCount - a.runCount);
|
|
507
|
+
const totalRuns = stats.reduce((a, s) => a + s.runCount, 0);
|
|
508
|
+
const totalOpenBugs = stats.reduce((a, s) => a + s.openBugs, 0);
|
|
509
|
+
|
|
510
|
+
const rows = stats.map((s) => `
|
|
511
|
+
<tr>
|
|
512
|
+
<td>${escapeHtml(s.name)}</td>
|
|
513
|
+
<td>${escapeHtml(s.env)}</td>
|
|
514
|
+
<td class="num">${s.runCount}</td>
|
|
515
|
+
<td class="num">${s.avgReuse !== null ? s.avgReuse + '%' : '—'}</td>
|
|
516
|
+
<td class="num">${s.flowCount}</td>
|
|
517
|
+
<td class="num ${s.openBugs > 0 ? 'warn' : ''}">${s.openBugs} / ${s.bugCount}</td>
|
|
518
|
+
<td>${s.lastRun ? escapeHtml(s.lastRun['Date'] + ' · ' + s.lastRun['Card']) : '—'}</td>
|
|
519
|
+
</tr>`).join('');
|
|
520
|
+
|
|
521
|
+
const html = `<!doctype html>
|
|
522
|
+
<html><head><meta charset="utf-8"><title>QA Product Health Dashboard</title>
|
|
523
|
+
<style>
|
|
524
|
+
:root{ --bg:#f4f5f2; --panel:#fff; --ink:#1c2420; --muted:#5b6659; --line:#dfe3d8; --warn:#a6472b; }
|
|
525
|
+
@media (prefers-color-scheme: dark){ :root{ --bg:#12160f; --panel:#181f16; --ink:#e8ece2; --muted:#93998c; --line:#2b342a; --warn:#e08767; } }
|
|
526
|
+
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;padding:2.5rem 1.5rem}
|
|
527
|
+
.wrap{max-width:980px;margin:0 auto}
|
|
528
|
+
h1{font-size:1.5rem;margin:0 0 .25rem}
|
|
529
|
+
.meta{color:var(--muted);font-size:.85rem;margin-bottom:1.75rem}
|
|
530
|
+
.stats{display:flex;gap:1rem;margin-bottom:1.75rem;flex-wrap:wrap}
|
|
531
|
+
.stat{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:.85rem 1.1rem;min-width:140px}
|
|
532
|
+
.stat b{display:block;font-size:1.4rem} .stat span{color:var(--muted);font-size:.78rem;text-transform:uppercase;letter-spacing:.04em}
|
|
533
|
+
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
|
534
|
+
th,td{padding:.6rem .8rem;border-bottom:1px solid var(--line);text-align:left;font-size:.88rem}
|
|
535
|
+
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
|
|
536
|
+
td.num{font-variant-numeric:tabular-nums;text-align:right} td.warn{color:var(--warn);font-weight:600}
|
|
537
|
+
tr:last-child td{border-bottom:none}
|
|
538
|
+
.wrap{overflow-x:auto}
|
|
539
|
+
</style></head>
|
|
540
|
+
<body><div class="wrap">
|
|
541
|
+
<h1>QA Product Health Dashboard</h1>
|
|
542
|
+
<div class="meta">Generated ${todayStr()} from ${products.length} product context file(s)</div>
|
|
543
|
+
<div class="stats">
|
|
544
|
+
<div class="stat"><b>${products.length}</b><span>Products</span></div>
|
|
545
|
+
<div class="stat"><b>${totalRuns}</b><span>Total runs</span></div>
|
|
546
|
+
<div class="stat"><b>${totalOpenBugs}</b><span>Open bugs</span></div>
|
|
547
|
+
</div>
|
|
548
|
+
<table>
|
|
549
|
+
<thead><tr><th>Product</th><th>Env</th><th>Runs</th><th>Avg reuse</th><th>Flows covered</th><th>Open / total bugs</th><th>Last run</th></tr></thead>
|
|
550
|
+
<tbody>${rows}</tbody>
|
|
551
|
+
</table>
|
|
552
|
+
</div></body></html>`;
|
|
553
|
+
|
|
554
|
+
ensureDir(OUTPUTS_DIR);
|
|
555
|
+
fs.writeFileSync(path.join(OUTPUTS_DIR, 'dashboard.html'), html);
|
|
556
|
+
console.log(`Dashboard saved: outputs/dashboard.html (${products.length} products, ${totalRuns} runs, ${totalOpenBugs} open bugs)`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── digest: QA weekly digest ───────────────────────────────────────────────
|
|
560
|
+
|
|
561
|
+
function withinDays(dateStr, days) {
|
|
562
|
+
const d = new Date(dateStr);
|
|
563
|
+
if (isNaN(d.getTime())) return false;
|
|
564
|
+
const cutoff = new Date();
|
|
565
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
566
|
+
return d >= cutoff;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function cmdDigest(args) {
|
|
570
|
+
const days = parseInt(args.days, 10) || 7;
|
|
571
|
+
const products = listProductFolders();
|
|
572
|
+
if (!products.length) return console.log('NO_PRODUCTS');
|
|
573
|
+
|
|
574
|
+
let totalRuns = 0;
|
|
575
|
+
let totalBugs = 0;
|
|
576
|
+
const sections = [];
|
|
577
|
+
for (const product of products) {
|
|
578
|
+
const info = getProductInfo(product);
|
|
579
|
+
const runs = getTable(product, 'Runs Log').rows.filter((r) => withinDays(r['Date'], days));
|
|
580
|
+
if (!runs.length) continue;
|
|
581
|
+
totalRuns += runs.length;
|
|
582
|
+
const bugsFiled = runs.reduce((sum, r) => sum + bugsFiledCount(r), 0);
|
|
583
|
+
totalBugs += bugsFiled;
|
|
584
|
+
const avgReuse = avg(pctList(runs, 'Reuse %'));
|
|
585
|
+
sections.push(
|
|
586
|
+
`### ${info.name || product}\n- Runs: ${runs.length} (${runs.map((r) => r['Card']).join(', ')})\n` +
|
|
587
|
+
`- Bugs filed: ${bugsFiled}\n- Avg reuse: ${avgReuse !== null ? avgReuse + '%' : 'n/a'}\n`
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const today = todayStr();
|
|
592
|
+
const md =
|
|
593
|
+
`# QA Weekly Digest — ${today}\n` +
|
|
594
|
+
`Window: last ${days} day(s) · ${sections.length}/${products.length} product(s) active\n\n` +
|
|
595
|
+
`**Totals:** ${totalRuns} run(s) · ${totalBugs} bug(s) filed\n\n` +
|
|
596
|
+
(sections.length ? sections.join('\n') : '_No runs recorded in this window._\n');
|
|
597
|
+
|
|
598
|
+
ensureDir(OUTPUTS_DIR);
|
|
599
|
+
const file = path.join(OUTPUTS_DIR, `qa-weekly-digest-${today}.md`);
|
|
600
|
+
fs.writeFileSync(file, md);
|
|
601
|
+
console.log(
|
|
602
|
+
`Digest saved: outputs/qa-weekly-digest-${today}.md — ${totalRuns} runs, ${totalBugs} bugs across ` +
|
|
603
|
+
`${sections.length} active product(s) in the last ${days} days.`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── roi: token-spend / ROI view ────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
function cmdRoi() {
|
|
610
|
+
const products = listProductFolders();
|
|
611
|
+
if (!products.length) return console.log('NO_PRODUCTS');
|
|
612
|
+
|
|
613
|
+
const tracking = tryReadTrackingLog();
|
|
614
|
+
const hasTracking = Array.isArray(tracking) && tracking.length > 0;
|
|
615
|
+
|
|
616
|
+
const rowsOut = [];
|
|
617
|
+
let totalRuns = 0;
|
|
618
|
+
let totalBugs = 0;
|
|
619
|
+
let totalTokens = 0;
|
|
620
|
+
let tokenRuns = 0;
|
|
621
|
+
|
|
622
|
+
for (const product of products) {
|
|
623
|
+
const runs = getTable(product, 'Runs Log').rows;
|
|
624
|
+
if (!runs.length) continue;
|
|
625
|
+
totalRuns += runs.length;
|
|
626
|
+
const bugsFiled = runs.reduce((sum, r) => sum + bugsFiledCount(r), 0);
|
|
627
|
+
totalBugs += bugsFiled;
|
|
628
|
+
const avgReuse = avg(pctList(runs, 'Reuse %'));
|
|
629
|
+
|
|
630
|
+
let avgTokens = null;
|
|
631
|
+
if (hasTracking) {
|
|
632
|
+
const cardIds = new Set(runs.map((r) => r['Card']));
|
|
633
|
+
const entries = tracking.filter((e) => e && cardIds.has(e.card));
|
|
634
|
+
const totals = entries.map((e) => (e.input_tokens || 0) + (e.output_tokens || 0)).filter((v) => v > 0);
|
|
635
|
+
if (totals.length) {
|
|
636
|
+
avgTokens = Math.round(totals.reduce((a, b) => a + b, 0) / totals.length);
|
|
637
|
+
totalTokens += totals.reduce((a, b) => a + b, 0);
|
|
638
|
+
tokenRuns += totals.length;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
rowsOut.push({ product, runs: runs.length, bugsFiled, avgReuse, avgTokens });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const today = todayStr();
|
|
645
|
+
let md = `# QA Token-Spend / ROI View — ${today}\n\n`;
|
|
646
|
+
md += `| Product | Runs | Bugs found | Avg reuse | Avg tokens/run | Bugs per 1k tokens |\n`;
|
|
647
|
+
md += `|---|---|---|---|---|---|\n`;
|
|
648
|
+
for (const r of rowsOut) {
|
|
649
|
+
const bugsPer1k = r.avgTokens && r.bugsFiled ? ((r.bugsFiled / r.runs) / (r.avgTokens / 1000)).toFixed(2) : '—';
|
|
650
|
+
md += `| ${r.product} | ${r.runs} | ${r.bugsFiled} | ${r.avgReuse !== null ? r.avgReuse + '%' : '—'} | ` +
|
|
651
|
+
`${r.avgTokens !== null ? r.avgTokens.toLocaleString() : '—'} | ${bugsPer1k} |\n`;
|
|
652
|
+
}
|
|
653
|
+
md += `\n**Totals:** ${totalRuns} runs · ${totalBugs} bugs found`;
|
|
654
|
+
if (hasTracking && tokenRuns) {
|
|
655
|
+
md += ` · ${Math.round(totalTokens / tokenRuns).toLocaleString()} avg tokens/run across ${tokenRuns} tracked run(s)\n`;
|
|
656
|
+
} else {
|
|
657
|
+
md += `\n\n_Token columns require \`QA_TRACKING_DIR\` set in \`.env\` with an active token tracker — ` +
|
|
658
|
+
`showing coverage/bug metrics only until then._\n`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
ensureDir(OUTPUTS_DIR);
|
|
662
|
+
fs.writeFileSync(path.join(OUTPUTS_DIR, 'roi-report.md'), md);
|
|
663
|
+
console.log(
|
|
664
|
+
`ROI report saved: outputs/roi-report.md — ${totalRuns} runs, ${totalBugs} bugs` +
|
|
665
|
+
`${hasTracking ? ', token data included' : ' (token data unavailable — set QA_TRACKING_DIR to enable)'}.`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ── dispatch ────────────────────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
function main() {
|
|
672
|
+
const [, , command, ...rest] = process.argv;
|
|
673
|
+
const args = parseArgs(rest);
|
|
674
|
+
const handlers = {
|
|
675
|
+
'list-products': cmdListProducts,
|
|
676
|
+
'get-bugs': cmdGetBugs,
|
|
677
|
+
'get-runs': cmdGetRuns,
|
|
678
|
+
'get-selectors': cmdGetSelectors,
|
|
679
|
+
fingerprint: cmdFingerprint,
|
|
680
|
+
'pii-scan': cmdPiiScan,
|
|
681
|
+
'dup-bug': cmdDupBug,
|
|
682
|
+
'cost-estimate': cmdCostEstimate,
|
|
683
|
+
'risk-score': cmdRiskScore,
|
|
684
|
+
'trust-record': cmdTrustRecord,
|
|
685
|
+
'trust-status': cmdTrustStatus,
|
|
686
|
+
'locator-record': cmdLocatorRecord,
|
|
687
|
+
'locator-query': cmdLocatorQuery,
|
|
688
|
+
dashboard: cmdDashboard,
|
|
689
|
+
digest: cmdDigest,
|
|
690
|
+
roi: cmdRoi,
|
|
691
|
+
};
|
|
692
|
+
const handler = handlers[command];
|
|
693
|
+
if (!handler) {
|
|
694
|
+
console.log(
|
|
695
|
+
'Usage: qa-toolkit.cjs <command> [--flags]\n' +
|
|
696
|
+
'Commands: list-products, get-bugs, get-runs, get-selectors, fingerprint, pii-scan, dup-bug,\n' +
|
|
697
|
+
' cost-estimate, risk-score, trust-record, trust-status,\n' +
|
|
698
|
+
' locator-record, locator-query, dashboard, digest, roi'
|
|
699
|
+
);
|
|
700
|
+
process.exit(command ? 1 : 0);
|
|
701
|
+
}
|
|
702
|
+
handler(args);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
main();
|