@jaybeeuu/agent-cortex 2.8.0 → 2.9.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/CHANGELOG.md +173 -0
- package/README.md +26 -3
- package/agents/README.md +2 -2
- package/agents/guppi/agent.md +162 -0
- package/agents/guppi/claude/frontmatter.json +6 -0
- package/agents/guppi/pi/frontmatter.json +6 -0
- package/agents/plan/agent.md +6 -0
- package/agents/ralph-plan/agent.md +6 -0
- package/bin/installers/claude.mjs +1 -20
- package/bin/installers/pi.mjs +8 -2
- package/extensions/interrupt-bookmark/README.md +129 -0
- package/extensions/interrupt-bookmark/index.test.ts +717 -0
- package/extensions/interrupt-bookmark/index.ts +354 -0
- package/extensions/interrupt-bookmark/package.json +20 -0
- package/extensions/skill-stats/README.md +84 -3
- package/extensions/skill-stats/index.ts +62 -3
- package/extensions/skill-stats/package.json +1 -1
- package/extensions/skill-stats/rollup.test.ts +643 -0
- package/extensions/skill-stats/rollup.ts +415 -0
- package/extensions/skill-stats/timeline.ts +101 -0
- package/package.json +1 -1
- package/scripts/lib/manifest.mjs +29 -0
- package/skills/review/review-defects/EXAMPLES.md +80 -4
- package/skills/review/review-defects/SKILL.md +45 -15
- package/skills/review/review-report/EXAMPLES.md +114 -0
- package/skills/review/review-report/SKILL.md +138 -0
- package/skills/review/review-report/scripts/report.mjs +1032 -0
- package/skills/workflow/hitl-queue/SKILL.md +134 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
- package/skills/workflow/plan/SKILL.md +13 -0
- package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
- package/skills/workflow/prioritise-work/SKILL.md +150 -0
- package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
- package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
- package/skills/workflow/score-bead/FORMAT.md +73 -0
- package/skills/workflow/score-bead/SKILL.md +112 -0
- package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
- package/skills/workflow/using-agent-skills/SKILL.md +6 -1
- package/token-map.json +8 -2
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Aggregate the `defect:<class>` labels the review stage applies, measure the pipeline's own
|
|
3
|
+
// state, and print one bounded report. Filing that report upstream is a separate step.
|
|
4
|
+
//
|
|
5
|
+
// The class vocabulary is not defined here. `skills/review/review-defects/SKILL.md` owns the
|
|
6
|
+
// closed list of classes and the worked example for each, so this script parses the
|
|
7
|
+
// table out of that file at run time: the words a reviewer applies and the words this report
|
|
8
|
+
// counts cannot drift apart. A class that appears as a label but not in that table is
|
|
9
|
+
// reported as vocabulary drift, never counted as a class of its own.
|
|
10
|
+
//
|
|
11
|
+
// Two tracker reads, both read-only:
|
|
12
|
+
//
|
|
13
|
+
// bd list --label-pattern 'defect:*' --all --readonly --brief --json the defect labels
|
|
14
|
+
// bd list --all --readonly --brief --json the chore tree
|
|
15
|
+
//
|
|
16
|
+
// `--all` is not optional on either. `bd list` hides closed beads by default, defects are
|
|
17
|
+
// attached to beads that are normally already closed, so a query without it reports
|
|
18
|
+
// "No issues found" on a label that is set.
|
|
19
|
+
//
|
|
20
|
+
// A label is removed when the review that confirms the fix runs (`review-defects`, "Remove it
|
|
21
|
+
// when the fix closes"). The counts therefore describe the unconfirmed defects at the moment
|
|
22
|
+
// of the report, never the whole history of defects. The trend compares snapshots, and the
|
|
23
|
+
// report says so in those words.
|
|
24
|
+
//
|
|
25
|
+
// No retry, no write to the tracker, no network call unless `--file` is passed. Zero
|
|
26
|
+
// dependencies, async throughout, argv arrays only — never a shell string — even though
|
|
27
|
+
// `check-no-sync` does not scan `skills/*/scripts`.
|
|
28
|
+
//
|
|
29
|
+
// Run:
|
|
30
|
+
//
|
|
31
|
+
// node report.mjs [--baseline <path>] [--record] [--file] [--repo <owner/name>] \
|
|
32
|
+
// [--json] [--cwd <dir>] [--bd <path>] [--gh <path>] [--git <path>] [--now <rfc3339>]
|
|
33
|
+
|
|
34
|
+
import { execFile } from 'node:child_process';
|
|
35
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
36
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
37
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
38
|
+
import { promisify } from 'node:util';
|
|
39
|
+
|
|
40
|
+
const execFileAsync = promisify(execFile);
|
|
41
|
+
|
|
42
|
+
// ─── Contract ─────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** Schema version of the machine-readable model, and of the baseline file it writes. */
|
|
45
|
+
export const SCHEMA_VERSION = 1;
|
|
46
|
+
|
|
47
|
+
/** Every defect label starts with this prefix; the class is the rest of the label. */
|
|
48
|
+
export const DEFECT_PREFIX = 'defect:';
|
|
49
|
+
|
|
50
|
+
/** The review stage's chore label. */
|
|
51
|
+
export const STAGE_REVIEW = 'stage:review';
|
|
52
|
+
|
|
53
|
+
/** The fix chore label. */
|
|
54
|
+
export const STAGE_FIX = 'stage:fix';
|
|
55
|
+
|
|
56
|
+
/** The class vocabulary's one authored home: the skill directory that owns it. */
|
|
57
|
+
export const VOCABULARY_SKILL = 'review-defects';
|
|
58
|
+
|
|
59
|
+
/** The file inside that skill directory that carries the vocabulary table. */
|
|
60
|
+
export const VOCABULARY_FILE = 'SKILL.md';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The canonical defect read, in the form `review-defects` documents. `--all` carries the
|
|
64
|
+
* whole point: without it a query for a label on a closed bead returns nothing.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFECT_QUERY = /** @type {const} */ ([
|
|
67
|
+
'list',
|
|
68
|
+
'--label-pattern',
|
|
69
|
+
`${DEFECT_PREFIX}*`,
|
|
70
|
+
'--all',
|
|
71
|
+
'--readonly',
|
|
72
|
+
'--brief',
|
|
73
|
+
'--json',
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/** The chore-tree read: parent links, dependencies and stage labels, no free-form text. */
|
|
77
|
+
export const BULK_QUERY = /** @type {const} */ ([
|
|
78
|
+
'list',
|
|
79
|
+
'--all',
|
|
80
|
+
'--readonly',
|
|
81
|
+
'--brief',
|
|
82
|
+
'--json',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
/** Classes printed before the rest are summarised by count. */
|
|
86
|
+
export const MAX_CLASSES = 3;
|
|
87
|
+
|
|
88
|
+
/** Example bead ids printed per class. */
|
|
89
|
+
export const MAX_EXAMPLES = 2;
|
|
90
|
+
|
|
91
|
+
/** The package whose manifest declares the default feedback target, as published. */
|
|
92
|
+
export const PACKAGE_NAME = '@jaybeeuu/agent-cortex';
|
|
93
|
+
|
|
94
|
+
/** The package name `install pi` stages: a private build, never published. */
|
|
95
|
+
export const STAGED_PACKAGE_NAME = '@jaybeeuu/agent-cortex-pi';
|
|
96
|
+
|
|
97
|
+
/** The plugin name `install claude` writes into the Claude plugin manifest. */
|
|
98
|
+
export const PLUGIN_NAME = 'agent-cortex';
|
|
99
|
+
|
|
100
|
+
/** The Claude plugin manifest, relative to the plugin root an install stages. */
|
|
101
|
+
export const PLUGIN_MANIFEST = '.claude-plugin/plugin.json';
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The manifests an install carries, and the identity each must declare to be this package's.
|
|
105
|
+
* Both name the same thing — where this package came from — in the shape its harness reads.
|
|
106
|
+
* `test/review-report.test.mjs` stages both installs, so an installer that renames its generated
|
|
107
|
+
* manifest fails there rather than silently losing the default at install time.
|
|
108
|
+
* @type {readonly { file: string, identities: readonly string[] }[]}
|
|
109
|
+
*/
|
|
110
|
+
const MANIFEST_FILES = [
|
|
111
|
+
{ file: 'package.json', identities: [PACKAGE_NAME, STAGED_PACKAGE_NAME] },
|
|
112
|
+
{ file: PLUGIN_MANIFEST, identities: [PLUGIN_NAME] },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** Environment override for the feedback repository. */
|
|
116
|
+
export const REPO_ENV = 'AGENT_CORTEX_FEEDBACK_REPO';
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The skill that owns the rule behind each class: the file to change when this class keeps
|
|
120
|
+
* appearing. Keys are the vocabulary's classes; the test asserts every one of them exists on
|
|
121
|
+
* disk and that no class is left unmapped.
|
|
122
|
+
* @type {Readonly<Record<string, readonly string[]>>}
|
|
123
|
+
*/
|
|
124
|
+
export const DEFECT_OWNERS = {
|
|
125
|
+
'test-unproven': ['style-tests', 'implement-task'],
|
|
126
|
+
'test-cannot-fail': ['style-tests', 'implement-task'],
|
|
127
|
+
'criteria-unmet': ['implement-task', 'create-task'],
|
|
128
|
+
'scope-creep': ['implement-task'],
|
|
129
|
+
'style-violation': ['style-code'],
|
|
130
|
+
'missing-proof': ['implement-task', 'run-pipeline-stage'],
|
|
131
|
+
regression: ['implement-task', 'style-tests'],
|
|
132
|
+
'docs-drift': ['maintain-agent-docs', 'style-documentation'],
|
|
133
|
+
'unsafe-instruction': ['write-a-skill'],
|
|
134
|
+
'ambiguous-rule': ['write-a-skill'],
|
|
135
|
+
'silent-degradation': ['style-tests', 'run-pipeline-stage'],
|
|
136
|
+
unclassified: ['review-defects'],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** A usage mistake: the caller asked for something malformed. Exit code 2. */
|
|
140
|
+
export class UsageError extends Error {}
|
|
141
|
+
|
|
142
|
+
/** A read, a write or a filing that failed for good. Exit code 1. */
|
|
143
|
+
export class ReportError extends Error {
|
|
144
|
+
/** @param {string} message @param {{ step?: string }} [detail] */
|
|
145
|
+
constructor(message, { step } = {}) {
|
|
146
|
+
super(message);
|
|
147
|
+
this.name = 'ReportError';
|
|
148
|
+
this.step = step ?? 'report';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/** A child-process call that returns stdout. @typedef {(args: string[]) => Promise<string>} Runner */
|
|
155
|
+
|
|
156
|
+
/** One dependency edge, as `bd list --json` prints it.
|
|
157
|
+
* @typedef {object} Dependency
|
|
158
|
+
* @property {string} depends_on_id
|
|
159
|
+
* @property {string} type
|
|
160
|
+
*/
|
|
161
|
+
|
|
162
|
+
/** The bead fields this report reads. Everything else is ignored.
|
|
163
|
+
* @typedef {object} Bead
|
|
164
|
+
* @property {string} id
|
|
165
|
+
* @property {string[]} [labels]
|
|
166
|
+
* @property {string | null} [parent]
|
|
167
|
+
* @property {Dependency[]} [dependencies]
|
|
168
|
+
*/
|
|
169
|
+
|
|
170
|
+
/** Counts for one defect class.
|
|
171
|
+
* @typedef {object} ClassCount
|
|
172
|
+
* @property {number} count Labels carrying this class.
|
|
173
|
+
* @property {number} changes Distinct parents those labels sit on.
|
|
174
|
+
* @property {string[]} beads The labelled bead ids, sorted.
|
|
175
|
+
*/
|
|
176
|
+
|
|
177
|
+
/** The measured state of the tracker.
|
|
178
|
+
* @typedef {object} Model
|
|
179
|
+
* @property {number} schema
|
|
180
|
+
* @property {string} [generatedAt]
|
|
181
|
+
* @property {string} [project]
|
|
182
|
+
* @property {number} beadsRead
|
|
183
|
+
* @property {{ labelled: number, changes: number, byClass: Record<string, ClassCount>, unknownClasses: string[] }} defects
|
|
184
|
+
* @property {{ cohorts: number, firstPass: number, rate: number | null }} reviews
|
|
185
|
+
* @property {{ total: number, changes: number, byStage: Record<string, number>, unattributed: number }} fixRounds
|
|
186
|
+
*/
|
|
187
|
+
|
|
188
|
+
/** A per-value change between two reports.
|
|
189
|
+
* @typedef {object} Delta
|
|
190
|
+
* @property {string} key
|
|
191
|
+
* @property {number} current
|
|
192
|
+
* @property {number} previous
|
|
193
|
+
* @property {number} delta
|
|
194
|
+
*/
|
|
195
|
+
|
|
196
|
+
/** The comparison of this report with the previous one.
|
|
197
|
+
* @typedef {object} Comparison
|
|
198
|
+
* @property {string} path
|
|
199
|
+
* @property {string | null} reportedAt
|
|
200
|
+
* @property {Delta[]} classes
|
|
201
|
+
* @property {Delta} labelled
|
|
202
|
+
* @property {{ current: number | null, previous: number | null, points: number | null }} rate
|
|
203
|
+
* @property {Delta[]} stages
|
|
204
|
+
*/
|
|
205
|
+
|
|
206
|
+
/** What the renderer needs that is not a measurement.
|
|
207
|
+
* @typedef {object} Context
|
|
208
|
+
* @property {string} project
|
|
209
|
+
* @property {string | null} baselinePath
|
|
210
|
+
* @property {Comparison | null} comparison
|
|
211
|
+
* @property {boolean} baselineRecorded
|
|
212
|
+
*/
|
|
213
|
+
|
|
214
|
+
/** Injected collaborators, so a test drives the script without a tracker or a network.
|
|
215
|
+
* @typedef {object} ReportDeps
|
|
216
|
+
* @property {Runner} [bd]
|
|
217
|
+
* @property {Runner} [gh]
|
|
218
|
+
* @property {Runner} [git]
|
|
219
|
+
* @property {Date} [now]
|
|
220
|
+
* @property {string} [cwd]
|
|
221
|
+
* @property {string} [scriptPath]
|
|
222
|
+
* @property {string} [envRepo]
|
|
223
|
+
* @property {((chunk: string) => void)} [write] Sink that receives the report as soon as it is
|
|
224
|
+
* rendered — before the filing target is resolved — so a run that cannot file still prints what
|
|
225
|
+
* it measured.
|
|
226
|
+
*/
|
|
227
|
+
|
|
228
|
+
// ─── Vocabulary ──────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
const CLASS_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Read the closed class list out of the vocabulary table in `review-defects/SKILL.md`.
|
|
234
|
+
*
|
|
235
|
+
* One source, not two: the table is the authored list, and parsing it means a class added
|
|
236
|
+
* there is counted here without a second edit. A table that no longer parses is a loud
|
|
237
|
+
* failure, not an empty vocabulary.
|
|
238
|
+
* @param {string} markdown @returns {string[]}
|
|
239
|
+
*/
|
|
240
|
+
export function parseVocabulary(markdown) {
|
|
241
|
+
const lines = markdown.split('\n');
|
|
242
|
+
const start = lines.findIndex((line) => /^##\s/.test(line) && /closed list/i.test(line));
|
|
243
|
+
if (start === -1) {
|
|
244
|
+
throw new ReportError('cannot find the class vocabulary: no "closed list" section in the skill', {
|
|
245
|
+
step: 'vocabulary',
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/** @type {string[]} */
|
|
249
|
+
const classes = [];
|
|
250
|
+
for (const line of lines.slice(start + 1)) {
|
|
251
|
+
if (/^##\s/.test(line)) break;
|
|
252
|
+
if (!line.trimStart().startsWith('|')) continue;
|
|
253
|
+
const cell = line.split('|')[1]?.trim() ?? '';
|
|
254
|
+
if (!cell.startsWith('`') || !cell.endsWith('`')) continue;
|
|
255
|
+
const name = cell.slice(1, -1).trim();
|
|
256
|
+
if (CLASS_NAME.test(name) && !classes.includes(name)) classes.push(name);
|
|
257
|
+
}
|
|
258
|
+
if (classes.length === 0) {
|
|
259
|
+
throw new ReportError('cannot find the class vocabulary: the closed-list table yielded no class', {
|
|
260
|
+
step: 'vocabulary',
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return classes;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Read the vocabulary out of `review-defects/SKILL.md`.
|
|
268
|
+
*
|
|
269
|
+
* The skill group directories are flattened at install time, so the authoring layout
|
|
270
|
+
* (`skills/review/review-defects`) and the installed layout (`skills/review-defects`) sit at
|
|
271
|
+
* different depths from this script. Walk up until the skill directory is found, rather than
|
|
272
|
+
* assuming either depth.
|
|
273
|
+
* @param {string} scriptPath @returns {Promise<string[]>}
|
|
274
|
+
*/
|
|
275
|
+
export async function readVocabulary(scriptPath) {
|
|
276
|
+
let dir = dirname(scriptPath);
|
|
277
|
+
for (let depth = 0; depth < 4; depth += 1) {
|
|
278
|
+
const parent = dirname(dir);
|
|
279
|
+
if (parent === dir) break;
|
|
280
|
+
dir = parent;
|
|
281
|
+
const file = join(dir, VOCABULARY_SKILL, VOCABULARY_FILE);
|
|
282
|
+
try {
|
|
283
|
+
return parseVocabulary(await readFile(file, 'utf-8'));
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT') continue;
|
|
286
|
+
throw new ReportError(`cannot read the class vocabulary from ${file}`, { step: 'vocabulary' });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
throw new ReportError(
|
|
290
|
+
`cannot find the ${VOCABULARY_SKILL} skill beside ${scriptPath}: it owns the class vocabulary`,
|
|
291
|
+
{ step: 'vocabulary' },
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── Measurement ─────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The class named by a bead's labels, if it carries one.
|
|
299
|
+
* @param {Bead} bead @returns {string[]}
|
|
300
|
+
*/
|
|
301
|
+
export function defectClasses(bead) {
|
|
302
|
+
return (bead.labels ?? [])
|
|
303
|
+
.filter((label) => label.startsWith(DEFECT_PREFIX))
|
|
304
|
+
.map((label) => label.slice(DEFECT_PREFIX.length));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The `stage:<name>` label a bead carries, if any.
|
|
309
|
+
* @param {Bead} bead @returns {string | null}
|
|
310
|
+
*/
|
|
311
|
+
export function stageLabel(bead) {
|
|
312
|
+
const label = (bead.labels ?? []).find((value) => value.startsWith('stage:'));
|
|
313
|
+
return label === undefined ? null : label.slice('stage:'.length);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The bead a chore belongs to: its parent, or the bead itself when it has none.
|
|
318
|
+
* @param {Bead} bead @returns {string}
|
|
319
|
+
*/
|
|
320
|
+
export function ownerBead(bead) {
|
|
321
|
+
return bead.parent ?? bead.id;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Measure the four inputs: defects by class, first-pass review rate, fix rounds per stage,
|
|
326
|
+
* and the vocabulary drift.
|
|
327
|
+
*
|
|
328
|
+
* A review cohort is a parent with at least one `stage:review` chore. A rejected review
|
|
329
|
+
* always creates a fresh review chore beside the fix (`run-pipeline-stage`, Remediation), so a
|
|
330
|
+
* parent holding exactly one review chore passed review on its first pass, and a parent
|
|
331
|
+
* holding more than one did not. That is the whole derivation: no stage prose is read.
|
|
332
|
+
*
|
|
333
|
+
* A fix round is attributed to the stage its fresh chore re-runs, which that chore declares as
|
|
334
|
+
* a `blocks` dependency on the fix.
|
|
335
|
+
* @param {{ beads: Bead[], defects: Bead[], vocabulary: string[] }} input @returns {Model}
|
|
336
|
+
*/
|
|
337
|
+
export function aggregate({ beads, defects, vocabulary }) {
|
|
338
|
+
/** @type {Record<string, ClassCount>} */
|
|
339
|
+
const byClass = {};
|
|
340
|
+
/** @type {string[]} */
|
|
341
|
+
const unknownClasses = [];
|
|
342
|
+
/** @type {Map<string, Set<string>>} */
|
|
343
|
+
const changesByClass = new Map();
|
|
344
|
+
|
|
345
|
+
for (const bead of defects) {
|
|
346
|
+
for (const name of defectClasses(bead)) {
|
|
347
|
+
if (!vocabulary.includes(name)) {
|
|
348
|
+
if (!unknownClasses.includes(name)) unknownClasses.push(name);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const entry = (byClass[name] ??= { count: 0, changes: 0, beads: [] });
|
|
352
|
+
entry.count += 1;
|
|
353
|
+
entry.beads.push(bead.id);
|
|
354
|
+
const changes = changesByClass.get(name) ?? new Set();
|
|
355
|
+
changes.add(ownerBead(bead));
|
|
356
|
+
changesByClass.set(name, changes);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const [name, entry] of Object.entries(byClass)) {
|
|
360
|
+
entry.beads.sort();
|
|
361
|
+
entry.changes = changesByClass.get(name)?.size ?? 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const defectChanges = new Set(defects.map(ownerBead));
|
|
365
|
+
|
|
366
|
+
/** @type {Map<string, number>} */
|
|
367
|
+
const reviewCounts = new Map();
|
|
368
|
+
/** @type {Set<string>} */
|
|
369
|
+
const fixIds = new Set();
|
|
370
|
+
/** @type {Map<string, string>} */
|
|
371
|
+
const freshStageByFix = new Map();
|
|
372
|
+
for (const bead of beads) {
|
|
373
|
+
if ((bead.labels ?? []).includes(STAGE_REVIEW)) {
|
|
374
|
+
const parent = ownerBead(bead);
|
|
375
|
+
reviewCounts.set(parent, (reviewCounts.get(parent) ?? 0) + 1);
|
|
376
|
+
}
|
|
377
|
+
if ((bead.labels ?? []).includes(STAGE_FIX)) fixIds.add(bead.id);
|
|
378
|
+
}
|
|
379
|
+
for (const bead of beads) {
|
|
380
|
+
const stage = stageLabel(bead);
|
|
381
|
+
if (stage === null) continue;
|
|
382
|
+
for (const dependency of bead.dependencies ?? []) {
|
|
383
|
+
if (dependency.type === 'blocks' && fixIds.has(dependency.depends_on_id)) {
|
|
384
|
+
freshStageByFix.set(dependency.depends_on_id, stage);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** @type {Record<string, number>} */
|
|
390
|
+
const byStage = {};
|
|
391
|
+
let unattributed = 0;
|
|
392
|
+
/** @type {Set<string>} */
|
|
393
|
+
const fixChanges = new Set();
|
|
394
|
+
for (const bead of beads) {
|
|
395
|
+
if (!(bead.labels ?? []).includes(STAGE_FIX)) continue;
|
|
396
|
+
fixChanges.add(ownerBead(bead));
|
|
397
|
+
const stage = freshStageByFix.get(bead.id);
|
|
398
|
+
if (stage === undefined) {
|
|
399
|
+
unattributed += 1;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
byStage[stage] = (byStage[stage] ?? 0) + 1;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const cohorts = reviewCounts.size;
|
|
406
|
+
const firstPass = [...reviewCounts.values()].filter((count) => count === 1).length;
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
schema: SCHEMA_VERSION,
|
|
410
|
+
beadsRead: beads.length,
|
|
411
|
+
defects: {
|
|
412
|
+
labelled: Object.values(byClass).reduce((sum, entry) => sum + entry.count, 0),
|
|
413
|
+
changes: defectChanges.size,
|
|
414
|
+
byClass,
|
|
415
|
+
unknownClasses,
|
|
416
|
+
},
|
|
417
|
+
reviews: { cohorts, firstPass, rate: cohorts === 0 ? null : firstPass / cohorts },
|
|
418
|
+
fixRounds: { total: fixIds.size, changes: fixChanges.size, byStage, unattributed },
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Compare this report with the one at `path`. A file this script did not write is refused
|
|
424
|
+
* rather than read as zeros: an unreadable baseline must not become an invented trend.
|
|
425
|
+
* @param {Model} current @param {unknown} previous @param {string} path @returns {Comparison}
|
|
426
|
+
*/
|
|
427
|
+
export function compare(current, previous, path) {
|
|
428
|
+
if (typeof previous !== 'object' || previous === null) {
|
|
429
|
+
throw new ReportError(`baseline ${path} is not a report this script wrote`, { step: 'baseline' });
|
|
430
|
+
}
|
|
431
|
+
const model = /** @type {Partial<Model>} */ (previous);
|
|
432
|
+
if (model.schema !== SCHEMA_VERSION) {
|
|
433
|
+
throw new ReportError(
|
|
434
|
+
`baseline ${path} declares schema ${String(model.schema)}, this script writes ${SCHEMA_VERSION}`,
|
|
435
|
+
{ step: 'baseline' },
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const previousClasses = model.defects?.byClass ?? {};
|
|
440
|
+
const previousStages = model.fixRounds?.byStage ?? {};
|
|
441
|
+
const names = [...new Set([...Object.keys(current.defects.byClass), ...Object.keys(previousClasses)])];
|
|
442
|
+
const classNames = [...names].sort((a, b) => a.localeCompare(b));
|
|
443
|
+
|
|
444
|
+
/** @param {string} key @param {number} now @param {number} before @returns {Delta} */
|
|
445
|
+
const delta = (key, now, before) => ({ key, current: now, previous: before, delta: now - before });
|
|
446
|
+
|
|
447
|
+
const currentRate = current.reviews.rate;
|
|
448
|
+
const previousRate = model.reviews?.rate ?? null;
|
|
449
|
+
const points =
|
|
450
|
+
currentRate === null || previousRate === null
|
|
451
|
+
? null
|
|
452
|
+
: Math.round((currentRate - previousRate) * 100);
|
|
453
|
+
|
|
454
|
+
const stageNames = [...new Set([...Object.keys(current.fixRounds.byStage), ...Object.keys(previousStages)])];
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
path,
|
|
458
|
+
reportedAt: model.generatedAt ?? null,
|
|
459
|
+
classes: classNames.map((name) =>
|
|
460
|
+
delta(name, current.defects.byClass[name]?.count ?? 0, previousClasses[name]?.count ?? 0),
|
|
461
|
+
),
|
|
462
|
+
labelled: delta('defects', current.defects.labelled, model.defects?.labelled ?? 0),
|
|
463
|
+
rate: { current: currentRate, previous: previousRate, points },
|
|
464
|
+
stages: stageNames
|
|
465
|
+
.sort((a, b) => a.localeCompare(b))
|
|
466
|
+
.map((name) => delta(name, current.fixRounds.byStage[name] ?? 0, previousStages[name] ?? 0)),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ─── Rendering ───────────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
/** A signed count, or "no change". @param {number} value */
|
|
473
|
+
function signed(value) {
|
|
474
|
+
if (value === 0) return 'no change';
|
|
475
|
+
return value > 0 ? `+${value}` : String(value);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** A rate as a whole percentage. @param {number} rate */
|
|
479
|
+
function percent(rate) {
|
|
480
|
+
return `${Math.round(rate * 100)}%`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** The owning skills for a class, as skill paths a reader can open. @param {string} name */
|
|
484
|
+
function owners(name) {
|
|
485
|
+
const skills = DEFECT_OWNERS[name];
|
|
486
|
+
if (skills === undefined || skills.length === 0) return 'no owning skill mapped';
|
|
487
|
+
return skills.map((skill) => `${skill}/SKILL.md`).join(', ');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The defect section: the top classes, their counts, two example beads and the owning skill.
|
|
492
|
+
* A tracker with no label says so rather than printing a row of zeros.
|
|
493
|
+
* @param {Model} model @returns {string[]}
|
|
494
|
+
*/
|
|
495
|
+
function defectSection(model) {
|
|
496
|
+
const { byClass, labelled, changes, unknownClasses } = model.defects;
|
|
497
|
+
const lines = [
|
|
498
|
+
`Defects by class — \`bd list --label-pattern '${DEFECT_PREFIX}*' --all\``,
|
|
499
|
+
];
|
|
500
|
+
|
|
501
|
+
if (labelled === 0 && unknownClasses.length === 0) {
|
|
502
|
+
lines.push(
|
|
503
|
+
' No defect label is set on any bead, closed beads included. There is nothing to count,',
|
|
504
|
+
' so no zero is printed as if it were a measurement.',
|
|
505
|
+
);
|
|
506
|
+
return lines;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const ranked = Object.entries(byClass)
|
|
510
|
+
.sort(([nameA, a], [nameB, b]) => b.count - a.count || nameA.localeCompare(nameB))
|
|
511
|
+
.slice(0, MAX_CLASSES);
|
|
512
|
+
const width = Math.max(...ranked.map(([name]) => name.length));
|
|
513
|
+
|
|
514
|
+
ranked.forEach(([name, entry], index) => {
|
|
515
|
+
const examples = entry.beads.slice(0, MAX_EXAMPLES).join(', ');
|
|
516
|
+
const label = entry.count === 1 ? 'defect' : 'defects';
|
|
517
|
+
lines.push(
|
|
518
|
+
` ${index + 1}. ${name.padEnd(width)} ${entry.count} ${label} in ${entry.changes} ` +
|
|
519
|
+
`${entry.changes === 1 ? 'change' : 'changes'} · owners: ${owners(name)}`,
|
|
520
|
+
` example${entry.beads.length === 1 ? '' : 's'}: ${examples}`,
|
|
521
|
+
);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const classCount = Object.keys(byClass).length;
|
|
525
|
+
const remainingClasses = classCount - ranked.length;
|
|
526
|
+
const tail = remainingClasses > 0 ? ` Top ${ranked.length} of ${classCount} classes.` : '';
|
|
527
|
+
lines.push(
|
|
528
|
+
` ${labelled} unconfirmed ${labelled === 1 ? 'defect' : 'defects'} across ${changes} ` +
|
|
529
|
+
`${changes === 1 ? 'change' : 'changes'}.${tail}`,
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
if (unknownClasses.length > 0) {
|
|
533
|
+
lines.push(
|
|
534
|
+
` Unknown class in the labels: ${unknownClasses.map((name) => `${DEFECT_PREFIX}${name}`).join(', ')}. ` +
|
|
535
|
+
'Not in the vocabulary — treat each as a review defect, not a class.',
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
return lines;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The review section: the rate, its derivation, or the reason there is none.
|
|
543
|
+
* @param {Model} model @returns {string[]}
|
|
544
|
+
*/
|
|
545
|
+
function reviewSection(model) {
|
|
546
|
+
const { cohorts, firstPass, rate } = model.reviews;
|
|
547
|
+
const lines = ['First-pass review rate'];
|
|
548
|
+
if (cohorts === 0 || rate === null) {
|
|
549
|
+
lines.push(
|
|
550
|
+
' No review stage chore is recorded in this tracker, so no rate is reported — absent',
|
|
551
|
+
' is not zero.',
|
|
552
|
+
);
|
|
553
|
+
return lines;
|
|
554
|
+
}
|
|
555
|
+
lines.push(
|
|
556
|
+
` ${firstPass} of ${cohorts} review cohorts passed with no fix round — ${percent(rate)}`,
|
|
557
|
+
' A cohort is a parent holding at least one stage:review chore; a rejected review always',
|
|
558
|
+
' creates a fresh one, so a parent with exactly one passed first time.',
|
|
559
|
+
);
|
|
560
|
+
return lines;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The fix-round section, grouped by the stage each fix round repaired.
|
|
565
|
+
* @param {Model} model @returns {string[]}
|
|
566
|
+
*/
|
|
567
|
+
function fixSection(model) {
|
|
568
|
+
const { total, changes, byStage, unattributed } = model.fixRounds;
|
|
569
|
+
const lines = ['Fix rounds'];
|
|
570
|
+
if (total === 0) {
|
|
571
|
+
lines.push(' No stage:fix chore is recorded in this tracker.');
|
|
572
|
+
return lines;
|
|
573
|
+
}
|
|
574
|
+
const parts = Object.entries(byStage)
|
|
575
|
+
.sort(([nameA, a], [nameB, b]) => b - a || nameA.localeCompare(nameB))
|
|
576
|
+
.map(([name, count]) => `${name} ${count}`);
|
|
577
|
+
if (unattributed > 0) parts.push(`unattributed ${unattributed}`);
|
|
578
|
+
lines.push(
|
|
579
|
+
` ${total} in total across ${changes} ${changes === 1 ? 'change' : 'changes'} — ${parts.join(', ')}`,
|
|
580
|
+
);
|
|
581
|
+
return lines;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The trend section. A trend needs a previous report; without one this says so, and a single
|
|
586
|
+
* labelled defect is never dressed up as a direction.
|
|
587
|
+
* @param {Context} context @returns {string[]}
|
|
588
|
+
*/
|
|
589
|
+
function trendSection(context) {
|
|
590
|
+
const lines = ['Change since the previous report'];
|
|
591
|
+
const comparison = context.comparison;
|
|
592
|
+
|
|
593
|
+
if (context.baselinePath === null) {
|
|
594
|
+
lines.push(
|
|
595
|
+
' None — no previous report was named. This is a snapshot, not a trend.',
|
|
596
|
+
' Pass --baseline <path> to compare against one, and --record to keep this one.',
|
|
597
|
+
);
|
|
598
|
+
return lines;
|
|
599
|
+
}
|
|
600
|
+
if (comparison === null) {
|
|
601
|
+
lines.push(
|
|
602
|
+
` None — no report exists at ${context.baselinePath} yet. This is a snapshot, not a trend.`,
|
|
603
|
+
' Re-run with --record to keep this report as the baseline.',
|
|
604
|
+
);
|
|
605
|
+
return lines;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const reportedAt = comparison.reportedAt ?? 'an unrecorded time';
|
|
609
|
+
lines.push(` Against ${comparison.path}, reported ${reportedAt}`);
|
|
610
|
+
|
|
611
|
+
const changed = comparison.classes.filter((entry) => entry.delta !== 0);
|
|
612
|
+
if (changed.length === 0) {
|
|
613
|
+
lines.push(' No defect class changed.');
|
|
614
|
+
} else {
|
|
615
|
+
const shown = changed
|
|
616
|
+
.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta) || a.key.localeCompare(b.key))
|
|
617
|
+
.slice(0, MAX_CLASSES);
|
|
618
|
+
const rest = changed.length - shown.length;
|
|
619
|
+
const listed = shown.map((entry) => `${entry.key} ${entry.current} (${signed(entry.delta)})`);
|
|
620
|
+
lines.push(` ${listed.join(', ')}${rest > 0 ? `, and ${rest} more` : ''}`);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (comparison.rate.current !== null && comparison.rate.previous !== null) {
|
|
624
|
+
const points = comparison.rate.points ?? 0;
|
|
625
|
+
const change = points === 0 ? 'no change' : `${signed(points)} points`;
|
|
626
|
+
lines.push(
|
|
627
|
+
` First-pass review rate ${percent(comparison.rate.current)} ` +
|
|
628
|
+
`(from ${percent(comparison.rate.previous)}, ${change})`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
const stageChanges = comparison.stages
|
|
632
|
+
.filter((entry) => entry.delta !== 0)
|
|
633
|
+
.map((entry) => `${entry.key} ${entry.current} (${signed(entry.delta)})`);
|
|
634
|
+
if (stageChanges.length > 0) lines.push(` Fix rounds ${stageChanges.join(', ')}`);
|
|
635
|
+
if (comparison.labelled.current <= 1) {
|
|
636
|
+
lines.push(
|
|
637
|
+
` ${comparison.labelled.current} labelled defect: a single data point, so there is no trend yet.`,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
return lines;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Render the whole report. Pure: it prints what was measured and never calls out.
|
|
645
|
+
* @param {Model} model @param {Context} context @returns {string}
|
|
646
|
+
*/
|
|
647
|
+
export function renderReport(model, context) {
|
|
648
|
+
const lines = [
|
|
649
|
+
`Defect report — ${context.project}`,
|
|
650
|
+
`Reported ${model.generatedAt ?? '(no clock)'}. Read ${model.beadsRead} beads from the tracker.`,
|
|
651
|
+
'',
|
|
652
|
+
...defectSection(model),
|
|
653
|
+
'',
|
|
654
|
+
...reviewSection(model),
|
|
655
|
+
'',
|
|
656
|
+
...fixSection(model),
|
|
657
|
+
'',
|
|
658
|
+
...trendSection(context),
|
|
659
|
+
'',
|
|
660
|
+
'A defect label is removed when the review that confirms its fix runs, so every count',
|
|
661
|
+
'above is the unconfirmed defect set at this moment, not the history of defects.',
|
|
662
|
+
];
|
|
663
|
+
if (context.baselineRecorded && context.baselinePath !== null) {
|
|
664
|
+
lines.push(`This report was recorded as the baseline at ${context.baselinePath}.`);
|
|
665
|
+
}
|
|
666
|
+
return `${lines.join('\n')}\n`;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ─── Reading the tracker ─────────────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Build a runner for one binary. argv arrays only, so a value carrying spaces or quotes
|
|
673
|
+
* reaches the binary unmangled.
|
|
674
|
+
* @param {string} bin @param {string} cwd @returns {Runner}
|
|
675
|
+
*/
|
|
676
|
+
export function makeRunner(bin, cwd) {
|
|
677
|
+
return async (args) => {
|
|
678
|
+
const { stdout } = await execFileAsync(bin, args, {
|
|
679
|
+
cwd,
|
|
680
|
+
encoding: 'utf-8',
|
|
681
|
+
timeout: 60_000,
|
|
682
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
683
|
+
});
|
|
684
|
+
return stdout;
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Parse the JSON array a `bd list --json` call prints.
|
|
690
|
+
* @param {string} stdout @param {string} step @returns {Bead[]}
|
|
691
|
+
*/
|
|
692
|
+
export function parseBeads(stdout, step) {
|
|
693
|
+
let parsed;
|
|
694
|
+
try {
|
|
695
|
+
parsed = JSON.parse(stdout);
|
|
696
|
+
} catch {
|
|
697
|
+
throw new ReportError(`bd ${step} printed output that is not JSON`, { step });
|
|
698
|
+
}
|
|
699
|
+
if (!Array.isArray(parsed)) {
|
|
700
|
+
throw new ReportError(`bd ${step} did not print a bead array`, { step });
|
|
701
|
+
}
|
|
702
|
+
return /** @type {Bead[]} */ (parsed);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Read both bead sets through the given runner.
|
|
707
|
+
* @param {Runner} bd @returns {Promise<{ beads: Bead[], defects: Bead[] }>}
|
|
708
|
+
*/
|
|
709
|
+
export async function collect(bd) {
|
|
710
|
+
const beads = parseBeads(await bd([...BULK_QUERY]), 'chore read');
|
|
711
|
+
const defects = parseBeads(await bd([...DEFECT_QUERY]), 'defect read');
|
|
712
|
+
return { beads, defects };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ─── Project and filing target ───────────────────────────────────────────────
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Turn a remote URL or an `owner/name` slug into `owner/name`.
|
|
719
|
+
* @param {string} value @returns {string | null}
|
|
720
|
+
*/
|
|
721
|
+
export function parseSlug(value) {
|
|
722
|
+
const trimmed = value.trim();
|
|
723
|
+
const patterns = [
|
|
724
|
+
/^[^@/\s]+@[^:/\s]+:(.+)$/, // git@host:owner/name.git
|
|
725
|
+
/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\/(.+)$/i, // https://host/owner/name.git
|
|
726
|
+
/^([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)$/, // owner/name
|
|
727
|
+
];
|
|
728
|
+
for (const pattern of patterns) {
|
|
729
|
+
const match = pattern.exec(trimmed);
|
|
730
|
+
if (match === null) continue;
|
|
731
|
+
const parts = match[1].replace(/\.git$/, '').split('/').filter(Boolean);
|
|
732
|
+
if (parts.length < 2) return null;
|
|
733
|
+
return parts.slice(-2).join('/');
|
|
734
|
+
}
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Identify the project being reported on. A repository with no remote is a normal case: it
|
|
740
|
+
* is named by its directory, and the report says the remote is absent.
|
|
741
|
+
* @param {Runner} git @param {string} cwd @returns {Promise<string>}
|
|
742
|
+
*/
|
|
743
|
+
export async function identifyProject(git, cwd) {
|
|
744
|
+
try {
|
|
745
|
+
const slug = parseSlug(await git(['remote', 'get-url', 'origin']));
|
|
746
|
+
if (slug !== null) return slug;
|
|
747
|
+
} catch {
|
|
748
|
+
// No origin: fall through to the working directory.
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
const top = (await git(['rev-parse', '--show-toplevel'])).trim();
|
|
752
|
+
if (top !== '') return `${basename(top)} (no git remote)`;
|
|
753
|
+
} catch {
|
|
754
|
+
// Not a git repository either.
|
|
755
|
+
}
|
|
756
|
+
return `${basename(cwd)} (not a git repository)`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Read the feedback repository out of the manifest of the install this skill ships inside, so no
|
|
761
|
+
* repository is written into shipped code: the default is whatever the installed artefact declares.
|
|
762
|
+
*
|
|
763
|
+
* Two installs, two manifests. `install pi` stages a package whose `package.json` declares the
|
|
764
|
+
* repository; `install claude` stages a Claude plugin whose `.claude-plugin/plugin.json` declares
|
|
765
|
+
* it. Each manifest is read only when it names this package: a manifest carrying any other name is
|
|
766
|
+
* the consuming project's own, and must never become the repository a report is filed against.
|
|
767
|
+
* @param {string} scriptPath @returns {Promise<string | null>}
|
|
768
|
+
*/
|
|
769
|
+
export async function manifestRepo(scriptPath) {
|
|
770
|
+
let dir = dirname(scriptPath);
|
|
771
|
+
for (;;) {
|
|
772
|
+
for (const { file, identities } of MANIFEST_FILES) {
|
|
773
|
+
const slug = await readManifestRepo(join(dir, file), identities);
|
|
774
|
+
if (slug !== null) return slug;
|
|
775
|
+
}
|
|
776
|
+
const parent = dirname(dir);
|
|
777
|
+
if (parent === dir) return null;
|
|
778
|
+
dir = parent;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The repository one manifest declares, or null when the file is absent, unparseable, or the
|
|
784
|
+
* manifest of something else.
|
|
785
|
+
* @param {string} file @param {readonly string[]} identities @returns {Promise<string | null>}
|
|
786
|
+
*/
|
|
787
|
+
async function readManifestRepo(file, identities) {
|
|
788
|
+
let manifest;
|
|
789
|
+
try {
|
|
790
|
+
manifest = JSON.parse(await readFile(file, 'utf-8'));
|
|
791
|
+
} catch {
|
|
792
|
+
// No manifest here, or one that does not parse: keep walking up.
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
if (!identities.includes(manifest?.name)) return null;
|
|
796
|
+
const repository = manifest.repository;
|
|
797
|
+
const value = typeof repository === 'string' ? repository : repository?.url;
|
|
798
|
+
return typeof value === 'string' ? parseSlug(value) : null;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Resolve the repository a report is filed against: the flag, then the environment, then the
|
|
803
|
+
* package manifest's own declaration. Never a hardcoded slug, never a hostname.
|
|
804
|
+
* @param {{ flag: string | null, env: string | undefined, scriptPath: string }} input
|
|
805
|
+
* @returns {Promise<string>}
|
|
806
|
+
*/
|
|
807
|
+
export async function resolveRepo({ flag, env, scriptPath }) {
|
|
808
|
+
for (const [source, value] of [
|
|
809
|
+
['--repo', flag],
|
|
810
|
+
[REPO_ENV, env],
|
|
811
|
+
]) {
|
|
812
|
+
if (value === undefined || value === null || value === '') continue;
|
|
813
|
+
const slug = parseSlug(value);
|
|
814
|
+
if (slug === null) {
|
|
815
|
+
throw new UsageError(`${source} is not an owner/name repository: ${value}`);
|
|
816
|
+
}
|
|
817
|
+
return slug;
|
|
818
|
+
}
|
|
819
|
+
const fromManifest = await manifestRepo(scriptPath);
|
|
820
|
+
if (fromManifest !== null) return fromManifest;
|
|
821
|
+
throw new ReportError(
|
|
822
|
+
`cannot resolve the feedback repository: pass --repo <owner/name> or set ${REPO_ENV}`,
|
|
823
|
+
{ step: 'filing' },
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* The `gh` argv for filing one report. An argv array, never a shell string, so the body
|
|
829
|
+
* reaches `gh` byte for byte.
|
|
830
|
+
* @param {{ repo: string, title: string, body: string }} input @returns {string[]}
|
|
831
|
+
*/
|
|
832
|
+
export function filingArgs({ repo, title, body }) {
|
|
833
|
+
return ['issue', 'create', '--repo', repo, '--title', title, '--body', body];
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* @typedef {object} Options
|
|
840
|
+
* @property {string | null} baseline
|
|
841
|
+
* @property {boolean} record
|
|
842
|
+
* @property {boolean} file
|
|
843
|
+
* @property {string | null} repo
|
|
844
|
+
* @property {boolean} json
|
|
845
|
+
* @property {string | null} cwd
|
|
846
|
+
* @property {string | null} bd
|
|
847
|
+
* @property {string | null} gh
|
|
848
|
+
* @property {string | null} git
|
|
849
|
+
* @property {string | null} now
|
|
850
|
+
*/
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Parse the command line. Unknown flags are refused, so a typo cannot silently change what
|
|
854
|
+
* the report means.
|
|
855
|
+
* @param {string[]} argv @returns {Options}
|
|
856
|
+
*/
|
|
857
|
+
export function parseArgs(argv) {
|
|
858
|
+
/** @type {Options} */
|
|
859
|
+
const options = {
|
|
860
|
+
baseline: null,
|
|
861
|
+
record: false,
|
|
862
|
+
file: false,
|
|
863
|
+
repo: null,
|
|
864
|
+
json: false,
|
|
865
|
+
cwd: null,
|
|
866
|
+
bd: null,
|
|
867
|
+
gh: null,
|
|
868
|
+
git: null,
|
|
869
|
+
now: null,
|
|
870
|
+
};
|
|
871
|
+
/** @type {Record<string, 'baseline' | 'repo' | 'cwd' | 'bd' | 'gh' | 'git' | 'now'>} */
|
|
872
|
+
const valued = {
|
|
873
|
+
'--baseline': 'baseline',
|
|
874
|
+
'--repo': 'repo',
|
|
875
|
+
'--cwd': 'cwd',
|
|
876
|
+
'--bd': 'bd',
|
|
877
|
+
'--gh': 'gh',
|
|
878
|
+
'--git': 'git',
|
|
879
|
+
'--now': 'now',
|
|
880
|
+
};
|
|
881
|
+
/** @type {Record<string, 'record' | 'file' | 'json'>} */
|
|
882
|
+
const flags = { '--record': 'record', '--file': 'file', '--json': 'json' };
|
|
883
|
+
|
|
884
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
885
|
+
const arg = argv[index];
|
|
886
|
+
const flag = flags[arg];
|
|
887
|
+
if (flag !== undefined) {
|
|
888
|
+
options[flag] = true;
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
const key = valued[arg];
|
|
892
|
+
if (key !== undefined) {
|
|
893
|
+
const value = argv[index + 1];
|
|
894
|
+
if (value === undefined || value.startsWith('--')) {
|
|
895
|
+
throw new UsageError(`${arg} needs a value`);
|
|
896
|
+
}
|
|
897
|
+
options[key] = value;
|
|
898
|
+
index += 1;
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
throw new UsageError(`unknown argument: ${arg}`);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (options.record && options.baseline === null) {
|
|
905
|
+
throw new UsageError('--record needs --baseline <path>: that path is where the report is kept');
|
|
906
|
+
}
|
|
907
|
+
return options;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Run one report end to end. The report is printed before anything is filed, so a run without
|
|
912
|
+
* `--file` cannot reach the network at all, and a run that cannot resolve or reach its filing
|
|
913
|
+
* target still prints the report it measured.
|
|
914
|
+
* @param {string[]} argv @param {ReportDeps} deps
|
|
915
|
+
* @returns {Promise<{ stdout: string, model: Model, filed: string | null }>}
|
|
916
|
+
*/
|
|
917
|
+
export async function runCli(argv, deps = {}) {
|
|
918
|
+
const options = parseArgs(argv);
|
|
919
|
+
const cwd = resolve(options.cwd ?? deps.cwd ?? process.cwd());
|
|
920
|
+
const scriptPath = resolve(deps.scriptPath ?? fileURLToPath(import.meta.url));
|
|
921
|
+
const now = deps.now ?? (options.now === null ? new Date() : new Date(options.now));
|
|
922
|
+
if (Number.isNaN(now.getTime())) throw new UsageError(`--now is not a date: ${options.now}`);
|
|
923
|
+
|
|
924
|
+
const bd = deps.bd ?? makeRunner(options.bd ?? process.env.BD_PATH ?? 'bd', cwd);
|
|
925
|
+
const git = deps.git ?? makeRunner(options.git ?? process.env.GIT_PATH ?? 'git', cwd);
|
|
926
|
+
|
|
927
|
+
const vocabulary = await readVocabulary(scriptPath);
|
|
928
|
+
const { beads, defects } = await collect(bd);
|
|
929
|
+
const model = aggregate({ beads, defects, vocabulary });
|
|
930
|
+
model.generatedAt = now.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
931
|
+
model.project = await identifyProject(git, cwd);
|
|
932
|
+
|
|
933
|
+
/** @type {Comparison | null} */
|
|
934
|
+
let comparison = null;
|
|
935
|
+
const baselinePath = options.baseline === null ? null : resolve(cwd, options.baseline);
|
|
936
|
+
if (baselinePath !== null) {
|
|
937
|
+
let previous;
|
|
938
|
+
try {
|
|
939
|
+
previous = JSON.parse(await readFile(baselinePath, 'utf-8'));
|
|
940
|
+
} catch (error) {
|
|
941
|
+
const code = /** @type {NodeJS.ErrnoException} */ (error).code;
|
|
942
|
+
if (code !== 'ENOENT') {
|
|
943
|
+
throw new ReportError(`cannot read the baseline at ${baselinePath}`, { step: 'baseline' });
|
|
944
|
+
}
|
|
945
|
+
previous = null;
|
|
946
|
+
}
|
|
947
|
+
if (previous !== null) comparison = compare(model, previous, baselinePath);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const context = {
|
|
951
|
+
project: model.project,
|
|
952
|
+
baselinePath,
|
|
953
|
+
comparison,
|
|
954
|
+
baselineRecorded: options.record && baselinePath !== null,
|
|
955
|
+
};
|
|
956
|
+
const report = renderReport(model, context);
|
|
957
|
+
|
|
958
|
+
let stdout = options.json ? `${JSON.stringify(model, null, 2)}\n` : report;
|
|
959
|
+
const write = deps.write ?? (() => {});
|
|
960
|
+
|
|
961
|
+
if (context.baselineRecorded && baselinePath !== null) {
|
|
962
|
+
await mkdir(dirname(baselinePath), { recursive: true });
|
|
963
|
+
await writeFile(baselinePath, `${JSON.stringify(model, null, 2)}\n`);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Emit the report here, before the filing target is resolved: what was measured is not
|
|
967
|
+
// conditional on where it is filed, so a failure to resolve or reach the repository must not
|
|
968
|
+
// discard it.
|
|
969
|
+
write(stdout);
|
|
970
|
+
|
|
971
|
+
let filed = null;
|
|
972
|
+
if (options.file) {
|
|
973
|
+
const repo = await resolveRepo({
|
|
974
|
+
flag: options.repo,
|
|
975
|
+
env: deps.envRepo ?? process.env[REPO_ENV],
|
|
976
|
+
scriptPath,
|
|
977
|
+
});
|
|
978
|
+
const gh = deps.gh ?? makeRunner(options.gh ?? process.env.GH_PATH ?? 'gh', cwd);
|
|
979
|
+
const title = `Defect report — ${model.project} — ${model.generatedAt}`;
|
|
980
|
+
filed = (await gh(filingArgs({ repo, title, body: report }))).trim().split('\n').pop() ?? null;
|
|
981
|
+
const filedLine = `Filed: ${filed}\n`;
|
|
982
|
+
stdout += filedLine;
|
|
983
|
+
write(filedLine);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return { stdout, model, filed };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const USAGE = `Usage: node report.mjs [options]
|
|
990
|
+
|
|
991
|
+
--baseline <path> Compare against the report kept at this path
|
|
992
|
+
--record Keep this report at the --baseline path
|
|
993
|
+
--file File the report with \`gh issue create\` (the only networked step)
|
|
994
|
+
--repo <owner/name> Feedback repository (default: the repository this install declares)
|
|
995
|
+
--json Print the machine-readable model instead of the report
|
|
996
|
+
--cwd <dir> Directory the tracker and git commands run in
|
|
997
|
+
--bd --gh --git <path> Binaries to use (defaults: $BD_PATH, $GH_PATH, $GIT_PATH)
|
|
998
|
+
--now <rfc3339> Timestamp to report (default: now)
|
|
999
|
+
|
|
1000
|
+
${REPO_ENV} Feedback repository, when --repo is absent
|
|
1001
|
+
`;
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* The process entry point: print the report, then either file it or print the failure and exit
|
|
1005
|
+
* non-zero. The report goes out through a sink that writes it as soon as it is rendered, so a
|
|
1006
|
+
* filing failure leaves the measurement on stdout and the reason on stderr.
|
|
1007
|
+
* @param {string[]} argv @returns {Promise<number>}
|
|
1008
|
+
*/
|
|
1009
|
+
export async function main(argv) {
|
|
1010
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
1011
|
+
process.stdout.write(USAGE);
|
|
1012
|
+
return 0;
|
|
1013
|
+
}
|
|
1014
|
+
try {
|
|
1015
|
+
await runCli(argv, { write: (chunk) => process.stdout.write(chunk) });
|
|
1016
|
+
return 0;
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
if (error instanceof UsageError) {
|
|
1019
|
+
process.stderr.write(`review-report: ${error.message}\n\n${USAGE}`);
|
|
1020
|
+
return 2;
|
|
1021
|
+
}
|
|
1022
|
+
if (error instanceof ReportError) {
|
|
1023
|
+
process.stderr.write(`review-report: ${error.message}\n`);
|
|
1024
|
+
return 1;
|
|
1025
|
+
}
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1031
|
+
process.exit(await main(process.argv.slice(2)));
|
|
1032
|
+
}
|