bmad-method-test-architecture-enterprise 3.0.2 → 3.2.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.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The evidence and decision projection of a discovery campaign: every candidate record of
3
+ * `discovery/<campaignId>/`, as JSON and as text, so a reviewer sees what was proposed, what was observed, what was
4
+ * decided and what is still open.
5
+ *
6
+ * The projection is advisory. It computes no score and recalculates no native verdict: each row repeats what the record
7
+ * holds, and the totals add those rows up. A stop is as visible as a success: a candidate that errored, one whose trials
8
+ * went missing, a candidate file that cannot be read, an accepted risk and a promotion that is still waiting for its
9
+ * native qualification each have a row and a count. The text prints free text through the same escaping `tea-evaluate`
10
+ * applies to its findings, so a hypothesis, a rationale or a file name cannot start a forged line.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+
18
+ const { CandidateError, DISCOVERY_DIRECTORY, readCandidate } = require('./candidate');
19
+ const { ReviewError } = require('./review');
20
+ const { escapeUnprintable, findingLine } = require('../finding-lines');
21
+
22
+ const REPORT_KIND = 'exploratory-candidate-report';
23
+ const REPORT_VERSION = 1;
24
+ const CAMPAIGN_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
25
+ const CANDIDATE_SUFFIX = '.candidate.json';
26
+ const TRIAL_KEYS = Object.freeze(['planned', 'completed', 'errors', 'missing']);
27
+
28
+ /**
29
+ * Reads every candidate file of a campaign. A file the reader refuses is kept as an unreadable entry with its problems, so
30
+ * a candidate never drops out of the report because it is invalid.
31
+ *
32
+ * @param {string} folder the evaluation folder
33
+ * @param {string} campaignId
34
+ * @returns {{campaignId: string, candidates: object[], unreadable: Array<{file: string, problems: string[]}>}}
35
+ * @throws {ReviewError} when the campaign ID is malformed or the campaign directory is absent
36
+ */
37
+ function readCampaign(folder, campaignId) {
38
+ if (typeof campaignId !== 'string' || !CAMPAIGN_ID.test(campaignId)) {
39
+ throw new ReviewError([`${JSON.stringify(campaignId)} is not a campaign ID (lowercase words joined by hyphens)`]);
40
+ }
41
+ const directory = `${DISCOVERY_DIRECTORY}/${campaignId}`;
42
+ let names;
43
+ try {
44
+ names = fs.readdirSync(path.join(folder, directory));
45
+ } catch {
46
+ throw new ReviewError([`${directory} does not exist in the evaluation folder`]);
47
+ }
48
+ const candidates = [];
49
+ const unreadable = [];
50
+ for (const name of names.filter((entry) => entry.endsWith(CANDIDATE_SUFFIX)).sort()) {
51
+ const file = `${directory}/${name}`;
52
+ try {
53
+ candidates.push(readCandidate(folder, file));
54
+ } catch (error) {
55
+ if (!(error instanceof CandidateError)) throw error;
56
+ unreadable.push({ file, problems: error.problems });
57
+ }
58
+ }
59
+ return { campaignId, candidates, unreadable };
60
+ }
61
+
62
+ /** What a reviewer does next with a candidate in this state. */
63
+ function nextAction(record) {
64
+ const stops = new Set(['generation-error', 'inapplicable', 'binding-not-consumed', 'unexercised', 'judge-failure', 'budget-exhausted']);
65
+ if (['generated', 'applicable', 'observed'].includes(record.state) && stops.has(record.disposition)) {
66
+ return `stopped at ${record.disposition}; fix its cause and run the candidate again`;
67
+ }
68
+ switch (record.state) {
69
+ case 'generated':
70
+ case 'applicable': {
71
+ return 'observe the baseline and candidate arms';
72
+ }
73
+ case 'observed': {
74
+ if (record.disposition === 'target-error') return 'fix the target error and observe again; the evidence supports no review';
75
+ if (record.disposition === 'undetermined') return 'collect the missing or errored trials and observe again';
76
+ return 'classify the observations, then review';
77
+ }
78
+ case 'reviewed': {
79
+ return record.disposition.startsWith('confirmed-undesirable')
80
+ ? 'promote to a draft mutation'
81
+ : `none; the review concluded ${record.disposition}`;
82
+ }
83
+ case 'promoted': {
84
+ return record.promotion.qualificationReceipt === null
85
+ ? 'author the probe and oracle that demonstrate the defect, then run tea-evaluate check, preflight and scoring; the draft counts for nothing until a native qualification receipt exists'
86
+ : 'none; the native qualified probe carries the defect from here';
87
+ }
88
+ default: {
89
+ return 'review by hand; this report does not recognize the state';
90
+ }
91
+ }
92
+ }
93
+
94
+ /** The tally of `values`, keyed by value, in first-seen order. */
95
+ function tally(values) {
96
+ const counts = {};
97
+ for (const value of values) counts[value] = (counts[value] ?? 0) + 1;
98
+ return counts;
99
+ }
100
+
101
+ /** One candidate as a report row: what the record holds, nothing recomputed. */
102
+ function candidateRow(record) {
103
+ return {
104
+ candidateId: record.candidateId,
105
+ identity: record.identity,
106
+ state: record.state,
107
+ disposition: record.disposition,
108
+ hypothesis: record.hypothesis,
109
+ edit: { target: record.target.artifact, find: record.edit.find, replace: record.edit.replace },
110
+ observations: record.observations.map(({ observationId, arm, evidence, trials }) => ({ observationId, arm, evidence, trials })),
111
+ independentEvidence: record.independentEvidence.map(({ kind, reference, digest }) => ({ kind, reference, digest })),
112
+ review:
113
+ record.review === null
114
+ ? null
115
+ : { reviewer: record.review.reviewer, decidedAt: record.review.decidedAt, rationale: record.review.rationale },
116
+ acceptedRisk:
117
+ record.acceptedRisk === null
118
+ ? null
119
+ : {
120
+ reviewer: record.acceptedRisk.reviewer,
121
+ decidedAt: record.acceptedRisk.decidedAt,
122
+ rationale: record.acceptedRisk.rationale,
123
+ revisitWhen: record.acceptedRisk.revisitWhen,
124
+ },
125
+ promotion:
126
+ record.promotion === null
127
+ ? null
128
+ : {
129
+ draftMutationId: record.promotion.draftMutationId,
130
+ qualification: record.promotion.qualificationReceipt === null ? 'pending' : 'receipt-attached',
131
+ },
132
+ nextAction: nextAction(record),
133
+ };
134
+ }
135
+
136
+ /**
137
+ * The projection of a campaign as JSON-serializable data.
138
+ *
139
+ * @param {ReturnType<typeof readCampaign>} campaign
140
+ * @returns {object}
141
+ */
142
+ function projectCampaign(campaign) {
143
+ const rows = campaign.candidates.map((record) => candidateRow(record));
144
+ const trials = Object.fromEntries(TRIAL_KEYS.map((key) => [key, 0]));
145
+ for (const row of rows) {
146
+ for (const observation of row.observations) for (const key of TRIAL_KEYS) trials[key] += observation.trials[key];
147
+ }
148
+ return {
149
+ kind: REPORT_KIND,
150
+ schemaVersion: REPORT_VERSION,
151
+ advisory: true,
152
+ campaignId: campaign.campaignId,
153
+ counts: {
154
+ files: rows.length + campaign.unreadable.length,
155
+ candidates: rows.length,
156
+ unreadable: campaign.unreadable.length,
157
+ byState: tally(rows.map((row) => row.state)),
158
+ byDisposition: tally(rows.map((row) => row.disposition ?? 'none')),
159
+ trials,
160
+ acceptedRisk: rows.filter((row) => row.acceptedRisk !== null).length,
161
+ promotions: {
162
+ pendingQualification: rows.filter((row) => row.promotion?.qualification === 'pending').length,
163
+ receiptAttached: rows.filter((row) => row.promotion?.qualification === 'receipt-attached').length,
164
+ },
165
+ },
166
+ candidates: rows,
167
+ unreadable: campaign.unreadable.map(({ file, problems }) => ({ file, problems })),
168
+ };
169
+ }
170
+
171
+ /** `counts` as `name n, name n`, or `none`. */
172
+ function listCounts(counts) {
173
+ const entries = Object.entries(counts);
174
+ return entries.length === 0 ? 'none' : entries.map(([name, count]) => `${name} ${count}`).join(', ');
175
+ }
176
+
177
+ /** `value` as one printable line. */
178
+ function line(value) {
179
+ return escapeUnprintable(value);
180
+ }
181
+
182
+ /** The trial counts of an observation as text. */
183
+ function trialText(trials) {
184
+ return TRIAL_KEYS.map((key) => `${key} ${trials[key]}`).join(' ');
185
+ }
186
+
187
+ /**
188
+ * The projection as text a person reads.
189
+ *
190
+ * @param {ReturnType<typeof projectCampaign>} report
191
+ * @returns {string}
192
+ */
193
+ function renderCampaign(report) {
194
+ const { counts } = report;
195
+ const lines = [
196
+ `campaign ${line(report.campaignId)}: advisory evidence for review; nothing here is scored`,
197
+ `files ${counts.files}: ${counts.candidates} readable, ${counts.unreadable} unreadable`,
198
+ `states: ${listCounts(counts.byState)}`,
199
+ `dispositions: ${listCounts(counts.byDisposition)}`,
200
+ `trials: ${trialText(counts.trials)}`,
201
+ `accepted risk: ${counts.acceptedRisk}, kept in every count`,
202
+ `promotions: ${counts.promotions.pendingQualification} pending native qualification, ${counts.promotions.receiptAttached} with a receipt`,
203
+ ];
204
+ for (const row of report.candidates) {
205
+ lines.push(
206
+ '',
207
+ `${row.candidateId} ${row.state} ${row.disposition ?? 'no disposition'}`,
208
+ ` hypothesis: ${line(row.hypothesis)}`,
209
+ ` edit ${line(row.edit.target)}`,
210
+ ` - ${line(row.edit.find)}`,
211
+ ` + ${line(row.edit.replace)}`,
212
+ );
213
+ for (const observation of row.observations) {
214
+ lines.push(` ${observation.arm} ${observation.observationId}: ${trialText(observation.trials)} evidence ${observation.evidence}`);
215
+ }
216
+ if (row.observations.length === 0) lines.push(' observations: none');
217
+ for (const evidence of row.independentEvidence) {
218
+ lines.push(` independent evidence: ${line(evidence.kind)}, ${line(evidence.reference)} (${evidence.digest})`);
219
+ }
220
+ if (row.review !== null) {
221
+ lines.push(` review: ${line(row.review.reviewer)} at ${line(row.review.decidedAt)}: ${line(row.review.rationale)}`);
222
+ }
223
+ if (row.acceptedRisk !== null) {
224
+ lines.push(
225
+ ` accepted risk: ${line(row.acceptedRisk.reviewer)} at ${line(row.acceptedRisk.decidedAt)}: ${line(row.acceptedRisk.rationale)}; revisit when ${line(row.acceptedRisk.revisitWhen)}`,
226
+ );
227
+ }
228
+ if (row.promotion !== null) {
229
+ lines.push(` promotion: draft mutation ${row.promotion.draftMutationId}, qualification ${row.promotion.qualification}`);
230
+ }
231
+ lines.push(` next: ${row.nextAction}`);
232
+ }
233
+ if (report.unreadable.length > 0) lines.push('', 'unreadable:');
234
+ const printed = lines.map((text) => `${text}\n`);
235
+ for (const { file, problems } of report.unreadable) printed.push(findingLine(file, 'unreadable-candidate', problems.join('; ')));
236
+ return printed.join('');
237
+ }
238
+
239
+ module.exports = { REPORT_KIND, REPORT_VERSION, projectCampaign, readCampaign, renderCampaign };