@mmerterden/multi-agent-pipeline 20.16.0 → 20.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +6 -5
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +14 -12
- package/docs/features.md +10 -2
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +41 -6
- package/install/codex.mjs +3 -3
- package/manifest.json +62 -49
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/bug-bash/SKILL.md +154 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +13 -9
- package/pipeline/commands/multi-agent/setup/SKILL.md +8 -6
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +5 -1
- package/pipeline/commands/sim-test.md +71 -6
- package/pipeline/lib/plugin-skill-roots.mjs +80 -0
- package/pipeline/lib/usage-lifecycle.mjs +83 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +20 -4
- package/pipeline/multi-agent-refs/features/assertion-judge.md +76 -0
- package/pipeline/multi-agent-refs/features/exploratory-testing.md +140 -0
- package/pipeline/multi-agent-refs/features/skill-conformance.md +1 -1
- package/pipeline/multi-agent-refs/features/usage-reporting.md +129 -36
- package/pipeline/multi-agent-refs/features/valven.md +30 -3
- package/pipeline/multi-agent-refs/help/en.md +2 -0
- package/pipeline/multi-agent-refs/help/tr.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +2 -2
- package/pipeline/schemas/bug-bash-verification.schema.json +41 -0
- package/pipeline/schemas/explore-run.schema.json +108 -0
- package/pipeline/schemas/prefs.schema.json +24 -3
- package/pipeline/scripts/_stack-routing.mjs +2 -0
- package/pipeline/scripts/bug-bash.mjs +379 -0
- package/pipeline/scripts/explore-findings.mjs +410 -0
- package/pipeline/scripts/gen-skills-index.mjs +1 -2
- package/pipeline/scripts/pr-timeline.mjs +135 -0
- package/pipeline/scripts/pre-commit-check.sh +4 -2
- package/pipeline/scripts/skill-conformance.mjs +17 -71
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
- package/pipeline/scripts/uninstall.mjs +29 -8
- package/pipeline/scripts/usage-identity.mjs +311 -26
- package/pipeline/scripts/usage-register.mjs +268 -62
- package/pipeline/scripts/usage-report.mjs +59 -7
- package/pipeline/scripts/valven-gate.mjs +292 -11
- package/pipeline/skills/.skill-manifest.json +18 -10
- package/pipeline/skills/.skills-index.json +28 -6
- package/pipeline/skills/shared/README.md +6 -4
- package/pipeline/skills/shared/core/multi-agent-bug-bash/SKILL.md +150 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +5 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +4 -1
- package/pipeline/skills/shared/external/e2e-testing/SKILL.md +124 -0
- package/pipeline/skills/shared/external/e2e-testing/references/document-fixtures.md +65 -0
- package/pipeline/skills/shared/external/valven-rules/SKILL.md +93 -0
- package/pipeline/skills/shared/{core → external}/valven-rules/references/rules.yml +52 -6
- package/pipeline/skills/skills-index.md +4 -2
- package/pipeline/skills/shared/core/valven-rules/SKILL.md +0 -62
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/mmerterden/multi-agent-pipeline/pipeline/schemas/explore-run.schema.json",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"title": "Multi-Agent Pipeline - exploratory test run",
|
|
6
|
+
"description": "One exploratory session of /multi-agent:test or one /multi-agent:bug-bash charter, written as the run goes so explore-findings.mjs can decide when it must stop (check) and what its verdict is (report). Findings are claims with evidence; a bug bash proves them with a reproducing test before reporting them.",
|
|
7
|
+
"type": "object",
|
|
8
|
+
"required": ["goal", "budget", "steps", "findings"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"goal": { "type": "string", "minLength": 1 },
|
|
11
|
+
"platform": { "enum": ["ios", "android", "web"] },
|
|
12
|
+
"persona": { "type": "string" },
|
|
13
|
+
"budget": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"description": "Per-run budget; explore-findings.mjs fills defaults (8 steps, 10 minutes, 150000 tokens) and clamps to steps 1-12, minutes 3-15, tokens 20000-400000.",
|
|
16
|
+
"properties": {
|
|
17
|
+
"steps": { "type": "integer", "minimum": 1 },
|
|
18
|
+
"minutes": { "type": "number", "minimum": 0 },
|
|
19
|
+
"tokens": { "type": "integer", "minimum": 0 }
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"usage": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"steps": { "type": "integer", "minimum": 0 },
|
|
26
|
+
"minutes": { "type": "number", "minimum": 0 },
|
|
27
|
+
"tokens": { "type": "integer", "minimum": 0 }
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"ended": { "enum": ["finished", "step-limit", "time", "tokens", "stuck", "aborted"] },
|
|
31
|
+
"steps": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"items": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"required": ["index", "status"],
|
|
36
|
+
"properties": {
|
|
37
|
+
"index": { "type": "integer", "minimum": 1 },
|
|
38
|
+
"title": { "type": "string" },
|
|
39
|
+
"status": { "enum": ["passed", "failed", "blocked", "exhausted"] },
|
|
40
|
+
"code": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"description": "The toolkit's verdict or locator code when the step did not pass: ASSERTION_FAILED and ASSERTION_INCONCLUSIVE are product failures; CREDENTIAL_MISSING, ENVIRONMENT_UNAVAILABLE and SEED_DATA_MISSING block by cause; LOCATOR_NOT_FOUND, LOCATOR_AMBIGUOUS and REPLAY_DIVERGED block as automation; BUDGET_EXCEEDED is exhausted."
|
|
43
|
+
},
|
|
44
|
+
"detail": { "type": "string" },
|
|
45
|
+
"trace": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Path of the toolkit's failure trace.md, when it wrote one."
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"findings": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"items": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"required": ["kind", "severity", "title", "expected", "observed", "repro", "evidence"],
|
|
57
|
+
"properties": {
|
|
58
|
+
"id": { "type": "string" },
|
|
59
|
+
"step": { "type": "integer", "minimum": 1 },
|
|
60
|
+
"kind": {
|
|
61
|
+
"enum": ["issue", "warning"],
|
|
62
|
+
"description": "issue: a functional defect, fails the run. warning: cosmetic, does not."
|
|
63
|
+
},
|
|
64
|
+
"severity": {
|
|
65
|
+
"type": "integer",
|
|
66
|
+
"minimum": 1,
|
|
67
|
+
"maximum": 5,
|
|
68
|
+
"description": "1 trivial, 2 low, 3 medium, 4 high, 5 critical."
|
|
69
|
+
},
|
|
70
|
+
"title": { "type": "string", "minLength": 1 },
|
|
71
|
+
"screen": { "type": "string" },
|
|
72
|
+
"expected": { "type": "string", "minLength": 1 },
|
|
73
|
+
"observed": { "type": "string", "minLength": 1 },
|
|
74
|
+
"repro": {
|
|
75
|
+
"type": "array",
|
|
76
|
+
"minItems": 1,
|
|
77
|
+
"items": { "type": "string", "minLength": 1 }
|
|
78
|
+
},
|
|
79
|
+
"evidence": {
|
|
80
|
+
"type": "array",
|
|
81
|
+
"minItems": 1,
|
|
82
|
+
"items": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"required": ["kind", "path"],
|
|
85
|
+
"properties": {
|
|
86
|
+
"kind": { "enum": ["screenshot", "ui-tree", "video", "trace", "log", "text"] },
|
|
87
|
+
"path": { "type": "string", "minLength": 1 }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"spec": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"description": "The acceptance criterion the expectation comes from, quoted, or 'no spec, model expectation'."
|
|
94
|
+
},
|
|
95
|
+
"judge": {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"description": "Present when a visual or semantic assertion decided the finding: the fresh judge's checked verdict.",
|
|
98
|
+
"properties": {
|
|
99
|
+
"verdict": { "enum": ["passed", "failed"] },
|
|
100
|
+
"code": { "type": ["string", "null"] },
|
|
101
|
+
"reason": { "type": "string" }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -1272,11 +1272,24 @@
|
|
|
1272
1272
|
"type": "string",
|
|
1273
1273
|
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
|
1274
1274
|
"default": "mmerterden/multi-agent-pipeline",
|
|
1275
|
-
"description": "owner/repo whose read access decides the reporting identity (usage-identity.mjs). Reporting uses the first
|
|
1275
|
+
"description": "owner/repo whose read access decides the reporting identity (usage-identity.mjs). Reporting uses the first account on the machine that can read it (gh accounts, then the git credential helper, then SSH keys), and sends nothing when none can."
|
|
1276
1276
|
},
|
|
1277
1277
|
"login": {
|
|
1278
1278
|
"type": ["string", "null"],
|
|
1279
|
-
"description": "Cached result of the identity check: the
|
|
1279
|
+
"description": "Cached result of the identity check: the GitHub account on this machine that can read accessRepo, or null when none could. Written by usage-identity.mjs; refresh with `node ~/.claude/scripts/usage-identity.mjs --refresh`."
|
|
1280
|
+
},
|
|
1281
|
+
"loginMethod": {
|
|
1282
|
+
"type": ["string", "null"],
|
|
1283
|
+
"enum": ["gh", "credential", "ssh", null],
|
|
1284
|
+
"description": "How the cached login was found: a gh account, the git credential helper's github.com token, or an SSH key. Registration proves the login the same way. Absent on a cache written before 20.17.1, which means gh."
|
|
1285
|
+
},
|
|
1286
|
+
"loginKey": {
|
|
1287
|
+
"type": ["string", "null"],
|
|
1288
|
+
"description": "Path of the SSH private key the login was found with (loginMethod ssh only). A missing key file invalidates the cache."
|
|
1289
|
+
},
|
|
1290
|
+
"loginFallback": {
|
|
1291
|
+
"type": "boolean",
|
|
1292
|
+
"description": "Deprecated (20.17.0). Marked a cached login that could not read accessRepo; such a cache entry is re-checked at once and the key is removed."
|
|
1280
1293
|
},
|
|
1281
1294
|
"loginRepo": {
|
|
1282
1295
|
"type": "string",
|
|
@@ -1294,6 +1307,14 @@
|
|
|
1294
1307
|
"type": "string",
|
|
1295
1308
|
"description": "The login the stored ingest token was issued for. usage-register.mjs replaces the token when the verified login differs, because the server pins each token to one login."
|
|
1296
1309
|
},
|
|
1310
|
+
"registeredAt": {
|
|
1311
|
+
"type": "string",
|
|
1312
|
+
"description": "ISO time of the registration that issued the stored token."
|
|
1313
|
+
},
|
|
1314
|
+
"registeredFallback": {
|
|
1315
|
+
"type": "boolean",
|
|
1316
|
+
"description": "Deprecated (20.17.0). true marked a registration made with an account that cannot read accessRepo; it is not treated as pinned and is replaced once an account with access is found."
|
|
1317
|
+
},
|
|
1297
1318
|
"includeTitle": {
|
|
1298
1319
|
"type": "boolean",
|
|
1299
1320
|
"default": false,
|
|
@@ -1503,7 +1524,7 @@
|
|
|
1503
1524
|
"valven": {
|
|
1504
1525
|
"type": "object",
|
|
1505
1526
|
"additionalProperties": false,
|
|
1506
|
-
"description": "valven-rules delivery gate (
|
|
1527
|
+
"description": "valven-rules delivery gate (the valven-rules skill, shipped in ai-common-toolkit). Runs at Phase 2 exit (Gate 6), Phase 4 Step 2.95 before push, Phase 3 Step 1.762 and every /multi-agent:review. Findings carry tag `valven`; an accepted important or blocking one sets NEEDS WORK, and an oversized branch is split into stacked PRs by valven-split.mjs.",
|
|
1507
1528
|
"properties": {
|
|
1508
1529
|
"enabled": {
|
|
1509
1530
|
"type": "boolean",
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bug-bash.mjs - charters, merge and the proof rule of /multi-agent:bug-bash.
|
|
4
|
+
*
|
|
5
|
+
* Exploratory sessions produce claims. A claim becomes a bug in the report only
|
|
6
|
+
* when a reproducing test fails with ASSERTION_FAILED on the assertion that
|
|
7
|
+
* encodes it; every other claim is listed as rejected, with the reason. The
|
|
8
|
+
* model plans charters, drives the sessions and writes the repro tests; this
|
|
9
|
+
* script holds the parts that must not be judgement:
|
|
10
|
+
*
|
|
11
|
+
* charters --file FILE
|
|
12
|
+
* Validates `[{slug, persona, area, start, goal, budget?, stance?}]`:
|
|
13
|
+
* at most MAX_CHARTERS, unique kebab-case slugs, a known persona (or
|
|
14
|
+
* `custom` with its stance written out), a one-sentence goal, a start
|
|
15
|
+
* screen or route. Prints the charters with each budget resolved.
|
|
16
|
+
* Exit 1 when any charter is invalid.
|
|
17
|
+
*
|
|
18
|
+
* merge --sessions DIR
|
|
19
|
+
* Reads DIR/<slug>.json, one exploratory run each (explore-run schema),
|
|
20
|
+
* and merges issues that describe one defect (same screen, same
|
|
21
|
+
* expectation) into candidates BB-1..n, keeping the clearest repro and
|
|
22
|
+
* every charter that hit it. Warnings and contract failures are kept
|
|
23
|
+
* apart; each charter's verdict and blocked causes are summarised.
|
|
24
|
+
*
|
|
25
|
+
* report --merged FILE --verifications FILE [--json]
|
|
26
|
+
* Applies `[{id, status, repro?, reason?, note?, rootCause?, productionRisk?}]`
|
|
27
|
+
* to the candidates and renders the report: confirmed bugs, production
|
|
28
|
+
* risks, rejected by reason, warnings, charters.
|
|
29
|
+
*
|
|
30
|
+
* Exit: 0 printed, 1 invalid charters, 2 usage or unreadable input.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
34
|
+
import { join, basename } from "node:path";
|
|
35
|
+
import { pathToFileURL } from "node:url";
|
|
36
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
37
|
+
import {
|
|
38
|
+
resolveBudget,
|
|
39
|
+
validateFinding,
|
|
40
|
+
evaluateRun,
|
|
41
|
+
SEVERITY_WORDS,
|
|
42
|
+
} from "./explore-findings.mjs";
|
|
43
|
+
|
|
44
|
+
export const MAX_CHARTERS = 10;
|
|
45
|
+
export const MAX_PARALLEL = 4;
|
|
46
|
+
|
|
47
|
+
export const PERSONAS = Object.freeze({
|
|
48
|
+
"first-time-user":
|
|
49
|
+
"A first-time user: follows the screen's own cues, reads every label, and reports anything confusing, broken or inconsistent.",
|
|
50
|
+
skeptic:
|
|
51
|
+
"Distrusts every number, date, count and claim on screen and cross-checks each against every other place it appears.",
|
|
52
|
+
fuzzer:
|
|
53
|
+
"At every input runs the charter's input matrix (empty, very long, unicode, leading spaces, special characters) before anything else, judging each entry before the next.",
|
|
54
|
+
"state-keeper":
|
|
55
|
+
"Creates, edits and deletes, then backgrounds, relaunches, reloads or goes back after each change, and reports state that is lost or stale.",
|
|
56
|
+
"error-paths":
|
|
57
|
+
"Takes the unhappy path on purpose: wrong credentials, offline, denied permissions, expired sessions; reports errors that are missing, misleading or leak detail.",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const REJECT_REASONS = [
|
|
61
|
+
"artifact",
|
|
62
|
+
"environment",
|
|
63
|
+
"design",
|
|
64
|
+
"fixture",
|
|
65
|
+
"did-not-reproduce",
|
|
66
|
+
"duplicate",
|
|
67
|
+
"unproven",
|
|
68
|
+
"unverified",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
72
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim().length > 0;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {object[]} list
|
|
76
|
+
* @returns {{charters: object[], errors: string[]}}
|
|
77
|
+
*/
|
|
78
|
+
export function validateCharters(list) {
|
|
79
|
+
const errors = [];
|
|
80
|
+
const charters = [];
|
|
81
|
+
if (!Array.isArray(list) || !list.length) {
|
|
82
|
+
return { charters, errors: ["charters must be a non-empty array"] };
|
|
83
|
+
}
|
|
84
|
+
if (list.length > MAX_CHARTERS) {
|
|
85
|
+
errors.push(`at most ${MAX_CHARTERS} charters; got ${list.length}`);
|
|
86
|
+
}
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
list.forEach((c, i) => {
|
|
89
|
+
const at = `charter ${i + 1}${c?.slug ? ` (${c.slug})` : ""}`;
|
|
90
|
+
if (!SLUG.test(c?.slug || "")) errors.push(`${at}: slug must be kebab-case`);
|
|
91
|
+
else if (seen.has(c.slug)) errors.push(`${at}: duplicate slug`);
|
|
92
|
+
seen.add(c?.slug);
|
|
93
|
+
if (c?.persona === "custom") {
|
|
94
|
+
if (!nonEmpty(c.stance)) errors.push(`${at}: a custom persona needs its stance written out`);
|
|
95
|
+
} else if (!PERSONAS[c?.persona]) {
|
|
96
|
+
errors.push(`${at}: persona must be one of ${Object.keys(PERSONAS).join(", ")} or custom`);
|
|
97
|
+
}
|
|
98
|
+
if (!nonEmpty(c?.area)) errors.push(`${at}: area is required`);
|
|
99
|
+
if (!nonEmpty(c?.start))
|
|
100
|
+
errors.push(`${at}: start (the screen or route it begins at) is required`);
|
|
101
|
+
const goal = (c?.goal || "").trim();
|
|
102
|
+
if (!goal) errors.push(`${at}: goal is required`);
|
|
103
|
+
else if (/\n/.test(goal) || /[.!?]\s+\S/.test(goal)) {
|
|
104
|
+
errors.push(`${at}: goal must be one sentence: one area, one posture`);
|
|
105
|
+
}
|
|
106
|
+
charters.push({
|
|
107
|
+
...c,
|
|
108
|
+
stance: c?.persona === "custom" ? c.stance : PERSONAS[c?.persona],
|
|
109
|
+
budget: resolveBudget(c?.budget),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
return { charters, errors };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const norm = (s) =>
|
|
116
|
+
String(s || "")
|
|
117
|
+
.toLowerCase()
|
|
118
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
119
|
+
.trim();
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {{slug:string, persona?:string, run:object}[]} sessions
|
|
123
|
+
*/
|
|
124
|
+
export function mergeSessions(sessions) {
|
|
125
|
+
const byKey = new Map();
|
|
126
|
+
const warnings = [];
|
|
127
|
+
const invalid = [];
|
|
128
|
+
const charters = [];
|
|
129
|
+
for (const s of sessions) {
|
|
130
|
+
const e = evaluateRun(s.run);
|
|
131
|
+
charters.push({
|
|
132
|
+
slug: s.slug,
|
|
133
|
+
persona: s.persona || s.run?.persona || null,
|
|
134
|
+
goal: s.run?.goal || null,
|
|
135
|
+
verdict: e.verdict,
|
|
136
|
+
ended: e.ended,
|
|
137
|
+
usage: e.usage,
|
|
138
|
+
blocked: Object.fromEntries(
|
|
139
|
+
Object.entries(e.blocked)
|
|
140
|
+
.filter(([, l]) => l.length)
|
|
141
|
+
.map(([k, l]) => [k, l.length]),
|
|
142
|
+
),
|
|
143
|
+
});
|
|
144
|
+
for (const f of s.run?.findings || []) {
|
|
145
|
+
const errors = validateFinding(f);
|
|
146
|
+
if (errors.length) {
|
|
147
|
+
invalid.push({ charter: s.slug, title: f?.title ?? null, errors });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (f.kind === "warning") {
|
|
151
|
+
warnings.push({ ...f, charters: [s.slug] });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const key = `${norm(f.screen)}|${norm(f.expected)}`;
|
|
155
|
+
const prev = byKey.get(key);
|
|
156
|
+
if (!prev) {
|
|
157
|
+
byKey.set(key, { ...f, charters: [s.slug] });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!prev.charters.includes(s.slug)) prev.charters.push(s.slug);
|
|
161
|
+
if (f.repro.length > prev.repro.length) {
|
|
162
|
+
Object.assign(prev, {
|
|
163
|
+
...f,
|
|
164
|
+
charters: prev.charters,
|
|
165
|
+
severity: Math.max(prev.severity, f.severity),
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
prev.severity = Math.max(prev.severity, f.severity);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const candidates = [...byKey.values()]
|
|
173
|
+
.sort((a, b) => b.severity - a.severity)
|
|
174
|
+
.map((f, i) => ({ id: `BB-${i + 1}`, ...f }));
|
|
175
|
+
warnings.sort((a, b) => b.severity - a.severity);
|
|
176
|
+
return { candidates, warnings, invalid, charters };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function proofProblem(repro) {
|
|
180
|
+
if (!repro || typeof repro !== "object") return "no repro test was recorded";
|
|
181
|
+
if (!nonEmpty(repro.path)) return "repro.path (the test file) is required";
|
|
182
|
+
if (!nonEmpty(repro.command)) return "repro.command (how it was run) is required";
|
|
183
|
+
if (!nonEmpty(repro.assertion))
|
|
184
|
+
return "repro.assertion (the check that encodes the finding) is required";
|
|
185
|
+
if (repro.code === "PASSED" || repro.code === null || repro.code === undefined) {
|
|
186
|
+
return "did-not-reproduce";
|
|
187
|
+
}
|
|
188
|
+
if (repro.code !== "ASSERTION_FAILED") {
|
|
189
|
+
return `the repro failed with ${repro.code}, not ASSERTION_FAILED: the test is wrong, fix it and rerun`;
|
|
190
|
+
}
|
|
191
|
+
if (!nonEmpty(repro.failedAt) || repro.failedAt.trim() !== repro.assertion.trim()) {
|
|
192
|
+
return `the repro failed on a different assertion (${repro.failedAt || "none recorded"}) than the one that encodes the finding`;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {ReturnType<typeof mergeSessions>} merged
|
|
199
|
+
* @param {object[]} verifications
|
|
200
|
+
*/
|
|
201
|
+
export function buildReport(merged, verifications) {
|
|
202
|
+
const byId = new Map((verifications || []).map((v) => [v?.id, v]));
|
|
203
|
+
const confirmed = [];
|
|
204
|
+
const rejected = [];
|
|
205
|
+
const productionRisks = [];
|
|
206
|
+
for (const c of merged.candidates) {
|
|
207
|
+
const v = byId.get(c.id);
|
|
208
|
+
if (!v) {
|
|
209
|
+
rejected.push({ ...c, reason: "unverified", note: "no verification was recorded" });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (v.status === "confirmed") {
|
|
213
|
+
const problem = proofProblem(v.repro);
|
|
214
|
+
if (!problem) {
|
|
215
|
+
confirmed.push({
|
|
216
|
+
...c,
|
|
217
|
+
proof: v.repro,
|
|
218
|
+
rootCause: v.rootCause || null,
|
|
219
|
+
note: v.note || null,
|
|
220
|
+
});
|
|
221
|
+
} else if (problem === "did-not-reproduce") {
|
|
222
|
+
rejected.push({
|
|
223
|
+
...c,
|
|
224
|
+
reason: "did-not-reproduce",
|
|
225
|
+
note: v.note || "the repro test passed",
|
|
226
|
+
proof: v.repro,
|
|
227
|
+
});
|
|
228
|
+
} else {
|
|
229
|
+
rejected.push({ ...c, reason: "unproven", note: problem, proof: v.repro });
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (v.status !== "rejected" || !REJECT_REASONS.includes(v.reason)) {
|
|
234
|
+
rejected.push({
|
|
235
|
+
...c,
|
|
236
|
+
reason: "unverified",
|
|
237
|
+
note: `verification needs status confirmed, or rejected with a reason from: ${REJECT_REASONS.join(", ")}`,
|
|
238
|
+
});
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const row = { ...c, reason: v.reason, note: v.note || null };
|
|
242
|
+
rejected.push(row);
|
|
243
|
+
if (v.reason === "environment" && v.productionRisk === true) productionRisks.push(row);
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
confirmed,
|
|
247
|
+
productionRisks,
|
|
248
|
+
rejected,
|
|
249
|
+
warnings: merged.warnings,
|
|
250
|
+
invalid: merged.invalid,
|
|
251
|
+
charters: merged.charters,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** @param {ReturnType<typeof buildReport>} r */
|
|
256
|
+
export function renderBugBash(r) {
|
|
257
|
+
const out = [
|
|
258
|
+
"# Bug bash report",
|
|
259
|
+
"",
|
|
260
|
+
`Confirmed: ${r.confirmed.length} · production risks (unverified): ${r.productionRisks.length} · rejected: ${r.rejected.length} · warnings: ${r.warnings.length} · charters: ${r.charters.length}`,
|
|
261
|
+
"",
|
|
262
|
+
`## Confirmed (${r.confirmed.length})`,
|
|
263
|
+
"",
|
|
264
|
+
];
|
|
265
|
+
r.confirmed.forEach((b) => {
|
|
266
|
+
out.push(
|
|
267
|
+
`### ${b.id} [${SEVERITY_WORDS[b.severity]}] ${b.title}`,
|
|
268
|
+
"",
|
|
269
|
+
`- **Screen**: ${b.screen || "-"}`,
|
|
270
|
+
`- **Expected / observed**: ${b.expected} / ${b.observed}`,
|
|
271
|
+
`- **Repro steps**: ${b.repro.map((s, i) => `${i + 1}. ${s}`).join(" ")}`,
|
|
272
|
+
`- **Repro test**: \`${b.proof.path}\` (\`${b.proof.command}\`) fails with ASSERTION_FAILED at \`${b.proof.assertion}\``,
|
|
273
|
+
`- **Root cause**: ${b.rootCause || "not located"}`,
|
|
274
|
+
`- **Evidence**: ${b.evidence.map((e) => `${e.kind}=${e.path}`).join(" ")}`,
|
|
275
|
+
`- **Found by**: ${b.charters.join(", ")}`,
|
|
276
|
+
"",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
if (!r.confirmed.length) out.push("None.", "");
|
|
280
|
+
out.push(`## Production risks (${r.productionRisks.length}) - unverified`, "");
|
|
281
|
+
for (const p of r.productionRisks) out.push(`- ${p.id} ${p.title}: ${p.note || "-"}`);
|
|
282
|
+
if (!r.productionRisks.length) out.push("None.");
|
|
283
|
+
out.push("", `## Rejected (${r.rejected.length})`, "");
|
|
284
|
+
for (const reason of REJECT_REASONS) {
|
|
285
|
+
const rows = r.rejected.filter((x) => x.reason === reason);
|
|
286
|
+
if (!rows.length) continue;
|
|
287
|
+
out.push(`### ${reason} (${rows.length})`, "");
|
|
288
|
+
for (const x of rows) out.push(`- ${x.id} ${x.title}: ${x.note || "-"}`);
|
|
289
|
+
out.push("");
|
|
290
|
+
}
|
|
291
|
+
if (!r.rejected.length) out.push("None.", "");
|
|
292
|
+
out.push(`## Warnings (${r.warnings.length})`, "");
|
|
293
|
+
for (const w of r.warnings)
|
|
294
|
+
out.push(`- [${SEVERITY_WORDS[w.severity]}] ${w.title} (${w.charters.join(", ")})`);
|
|
295
|
+
if (!r.warnings.length) out.push("None.");
|
|
296
|
+
if (r.invalid.length) {
|
|
297
|
+
out.push("", `## Findings that failed the contract (${r.invalid.length})`, "");
|
|
298
|
+
for (const v of r.invalid)
|
|
299
|
+
out.push(`- ${v.charter}: ${v.title ?? "(untitled)"}: ${v.errors.join("; ")}`);
|
|
300
|
+
}
|
|
301
|
+
out.push(
|
|
302
|
+
"",
|
|
303
|
+
"## Charters",
|
|
304
|
+
"",
|
|
305
|
+
"| Charter | Persona | Verdict | Ended | Blocked |",
|
|
306
|
+
"|---|---|---|---|---|",
|
|
307
|
+
);
|
|
308
|
+
for (const c of r.charters) {
|
|
309
|
+
const blocked = Object.entries(c.blocked)
|
|
310
|
+
.map(([k, n]) => `${k} ${n}`)
|
|
311
|
+
.join(", ");
|
|
312
|
+
out.push(`| ${c.slug} | ${c.persona || "-"} | ${c.verdict} | ${c.ended} | ${blocked || "-"} |`);
|
|
313
|
+
}
|
|
314
|
+
return `${out.join("\n")}\n`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function arg(argv, flag) {
|
|
318
|
+
const i = argv.indexOf(flag);
|
|
319
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
|
|
323
|
+
|
|
324
|
+
function readSessions(dir) {
|
|
325
|
+
return readdirSync(dir)
|
|
326
|
+
.filter((f) => f.endsWith(".json"))
|
|
327
|
+
.sort()
|
|
328
|
+
.map((f) => {
|
|
329
|
+
const run = readJson(join(dir, f));
|
|
330
|
+
return { slug: basename(f, ".json"), persona: run.persona || null, run };
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function main() {
|
|
335
|
+
const argv = process.argv.slice(2);
|
|
336
|
+
const mode = argv[0];
|
|
337
|
+
const usage = () => {
|
|
338
|
+
process.stderr.write(
|
|
339
|
+
"usage: bug-bash.mjs charters --file FILE\n" +
|
|
340
|
+
" bug-bash.mjs merge --sessions DIR\n" +
|
|
341
|
+
" bug-bash.mjs report --merged FILE --verifications FILE [--json]\n",
|
|
342
|
+
);
|
|
343
|
+
process.exitCode = 2;
|
|
344
|
+
};
|
|
345
|
+
try {
|
|
346
|
+
if (mode === "charters" && arg(argv, "--file")) {
|
|
347
|
+
const r = validateCharters(readJson(arg(argv, "--file")));
|
|
348
|
+
process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
349
|
+
if (r.errors.length) process.exitCode = 1;
|
|
350
|
+
} else if (mode === "merge" && arg(argv, "--sessions")) {
|
|
351
|
+
const merged = mergeSessions(readSessions(arg(argv, "--sessions")));
|
|
352
|
+
process.stdout.write(`${JSON.stringify(merged, null, 2)}\n`);
|
|
353
|
+
} else if (mode === "report" && arg(argv, "--merged") && arg(argv, "--verifications")) {
|
|
354
|
+
const r = buildReport(
|
|
355
|
+
readJson(arg(argv, "--merged")),
|
|
356
|
+
readJson(arg(argv, "--verifications")),
|
|
357
|
+
);
|
|
358
|
+
process.stdout.write(
|
|
359
|
+
argv.includes("--json") ? `${JSON.stringify(r, null, 2)}\n` : renderBugBash(r),
|
|
360
|
+
);
|
|
361
|
+
} else {
|
|
362
|
+
usage();
|
|
363
|
+
}
|
|
364
|
+
} catch (err) {
|
|
365
|
+
process.stderr.write(`bug-bash: ${err.message}\n`);
|
|
366
|
+
process.exitCode = 2;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function invokedDirectly() {
|
|
371
|
+
if (!process.argv[1]) return false;
|
|
372
|
+
try {
|
|
373
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (invokedDirectly()) runMain("bug-bash", main);
|