@codedology/code-forge 0.15.2 → 0.15.3
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 +56 -0
- package/README.md +5 -1
- package/docs/README.md +2 -0
- package/docs/reference/blocks.json +154 -1
- package/docs/reference/config.md +27 -14
- package/package.json +1 -1
- package/schema/code-forge.schema.json +57 -2
- package/schema/outcome.schema.json +45 -0
- package/schema/pipeline.schema.json +30 -0
- package/scripts/first-use.mjs +8 -0
- package/scripts/gen-config-doc.mjs +1 -1
- package/skill/references/code.md +15 -0
- package/skill/references/ledger.md +13 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +146 -30
- package/src/cli/context.mjs +17 -7
- package/src/cli/gates.mjs +76 -10
- package/src/cli/help.mjs +4 -0
- package/src/cli/outcome.mjs +87 -0
- package/src/cli/pipeline.mjs +61 -0
- package/src/cli/proof.mjs +10 -7
- package/src/cli/report.mjs +53 -6
- package/src/cli/review-file.mjs +168 -38
- package/src/cli/run.mjs +3 -2
- package/src/cli/sweep.mjs +28 -0
- package/src/cli/validate.mjs +6 -0
- package/src/config/validate.mjs +83 -1
- package/src/context/pack.mjs +53 -3
- package/src/doctor/local.mjs +22 -4
- package/src/engines/builders/codex.mjs +27 -7
- package/src/engines/builders/grok.mjs +11 -5
- package/src/engines/codex-home.mjs +18 -15
- package/src/engines/sentinel.mjs +11 -0
- package/src/gates/detect.mjs +6 -3
- package/src/gates/run.mjs +295 -27
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +143 -5
- package/src/outcome/outcome.mjs +108 -0
- package/src/pipeline/pipeline.mjs +112 -0
- package/src/proof/red-green.mjs +41 -13
- package/src/proof/runners.mjs +28 -4
- package/src/proof/worktree.mjs +25 -15
- package/src/review/agent-waiver.mjs +18 -4
- package/src/review/engine.mjs +25 -5
- package/src/review/gate-check.mjs +39 -1
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/packet.mjs +34 -6
- package/src/review/related.mjs +112 -0
- package/src/review/reopen.mjs +4 -3
- package/src/review/session-retry.mjs +59 -0
- package/src/session/spawn.mjs +54 -3
- package/src/state/block.mjs +101 -34
- package/src/state/config-snapshot.mjs +13 -3
- package/src/state/run.mjs +3 -2
- package/src/util/exec.mjs +48 -31
- package/src/util/reaper.mjs +238 -8
- package/src/worker/engine.mjs +7 -3
- package/src/worker/loop.mjs +67 -14
- package/src/worker/queue.mjs +106 -23
- package/src/worker/watch.mjs +2 -2
package/src/gates/run.mjs
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* stops early.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { constants } from 'node:fs';
|
|
11
|
+
import { lstat, open, readdir, realpath } from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { customGateMessages } from '../config/validate.mjs';
|
|
9
14
|
import { exec } from '../util/exec.mjs';
|
|
10
15
|
import { registerSecret } from '../util/redact.mjs';
|
|
11
16
|
import { CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
@@ -16,6 +21,9 @@ import { NO_BLOCK_SCOPE, TEST_FILES_TOKEN, changedTestFiles, envOf, expandArgv,
|
|
|
16
21
|
/** @typedef {import('./detect.mjs').DetectedGates} DetectedGates */
|
|
17
22
|
/** @typedef {import('./expand.mjs').TestScope} TestScope */
|
|
18
23
|
/** @typedef {{n: number, files: string[], failed: boolean}} Repeats */
|
|
24
|
+
/** @typedef {{name: string, argv: string[], env?: Record<string, string>, evidence?: string[]}} CustomGate */
|
|
25
|
+
/** @typedef {{path: string, bytes: number, sha256: string}} Evidence */
|
|
26
|
+
/** @typedef {{kind: string, names: string[]}} KindGates */
|
|
19
27
|
/**
|
|
20
28
|
* Internal block context carried with the gate plan through the proof lock, so repeats run
|
|
21
29
|
* inside the same slot as the initial commands.
|
|
@@ -24,17 +32,22 @@ import { NO_BLOCK_SCOPE, TEST_FILES_TOKEN, changedTestFiles, envOf, expandArgv,
|
|
|
24
32
|
|
|
25
33
|
/**
|
|
26
34
|
* @typedef {object} GateResult
|
|
27
|
-
* @property {
|
|
35
|
+
* @property {string} gate
|
|
36
|
+
* @property {number} duration_ms - wall time including repeats; zero for skipped gates.
|
|
37
|
+
* @property {true} [custom]
|
|
38
|
+
* @property {Evidence[]} [evidence]
|
|
39
|
+
* @property {string[]} [missing]
|
|
28
40
|
* @property {string[] | null} argv
|
|
29
|
-
* @property {boolean} skipped - true when
|
|
41
|
+
* @property {boolean} skipped - true when unconfigured or excluded by the block's kind set.
|
|
30
42
|
* @property {boolean} ok - true when skipped, or when the command exited with code 0 (and, for a
|
|
31
43
|
* `failOnStdout` gate, its stdout was empty).
|
|
32
44
|
* @property {number | null} code
|
|
33
45
|
* @property {string} stdout
|
|
34
46
|
* @property {string} stderr
|
|
47
|
+
* @property {string} [message] - termination diagnostic with the last lines of both streams.
|
|
35
48
|
* @property {'related'|'full'|'changed'} [scope] - only on a gate with a scope token.
|
|
36
49
|
* @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
|
|
37
|
-
* @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'|'flaky-test'} [reason] - fallback or
|
|
50
|
+
* @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'|'flaky-test'|'timeout'|'prompt'|'spawn-error'|'terminated'|NodeJS.Signals|`not in gates.kinds.${string}`} [reason] - fallback, exclusion or gate failure.
|
|
38
51
|
* @property {Repeats} [repeats]
|
|
39
52
|
* @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
|
|
40
53
|
* NAMES, sorted (the values are never in a result).
|
|
@@ -43,6 +56,177 @@ import { NO_BLOCK_SCOPE, TEST_FILES_TOKEN, changedTestFiles, envOf, expandArgv,
|
|
|
43
56
|
/** Fixed run order — always all four, regardless of an earlier one's result. */
|
|
44
57
|
const GATE_ORDER = /** @type {const} */ (['test', 'lint', 'types', 'format']);
|
|
45
58
|
|
|
59
|
+
/** Leave room below typical ARG_MAX limits for the inherited environment and spawn overhead. */
|
|
60
|
+
const FORMAT_ARGV_BUDGET_BYTES = 100 * 1024;
|
|
61
|
+
|
|
62
|
+
/** Repeat the executable and flags in every batch; count UTF-8 bytes and NUL terminators.
|
|
63
|
+
* @param {string[]} prefix @param {string[]} files @param {number} budget
|
|
64
|
+
* @returns {string[][]}
|
|
65
|
+
*/
|
|
66
|
+
function formatBatches(prefix, files, budget) {
|
|
67
|
+
if (!Number.isSafeInteger(budget) || budget <= 0) throw new TypeError('format: argv byte budget must be a positive safe integer');
|
|
68
|
+
const prefixBytes = prefix.reduce((sum, arg) => sum + Buffer.byteLength(arg) + 1, 0);
|
|
69
|
+
const batches = [];
|
|
70
|
+
let batch = prefix.slice();
|
|
71
|
+
let bytes = prefixBytes;
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
const size = Buffer.byteLength(file) + 1;
|
|
74
|
+
if (prefixBytes + size > budget) throw new RangeError(`format: command and file exceed argv byte budget: ${file}`);
|
|
75
|
+
if (bytes + size > budget) {
|
|
76
|
+
batches.push(batch);
|
|
77
|
+
batch = prefix.slice();
|
|
78
|
+
bytes = prefixBytes;
|
|
79
|
+
}
|
|
80
|
+
batch.push(file);
|
|
81
|
+
bytes += size;
|
|
82
|
+
}
|
|
83
|
+
batches.push(batch);
|
|
84
|
+
return batches;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @param {string} stream @returns {string} */
|
|
88
|
+
function outputTail(stream) {
|
|
89
|
+
const tail = stream.trimEnd().split(/\r?\n/).slice(-10).join('\n');
|
|
90
|
+
return tail ? `${tail}\n` : '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Existing supported files only, never a directory or glob for the formatter to traverse.
|
|
94
|
+
* @param {string} cwd @param {'pint'|'prettier'} formatter
|
|
95
|
+
* @param {TestScope | import('./expand.mjs').ChangedScope} scope
|
|
96
|
+
* @returns {Promise<string[]>}
|
|
97
|
+
*/
|
|
98
|
+
async function formatFiles(cwd, formatter, scope) {
|
|
99
|
+
const changed = scope.scope === 'changed';
|
|
100
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
|
|
101
|
+
const opts = { cwd, env, timeoutMs: 30000 };
|
|
102
|
+
const listed = await exec(['git', 'ls-files', '-z', '--cached', ...(changed ? ['--others'] : []), '--exclude-standard'], opts);
|
|
103
|
+
if (listed.result !== 'ok') throw new Error(`format: cannot list Git files: ${listed.stderr || listed.error || listed.signal || 'git failed'}`);
|
|
104
|
+
const wanted = changed ? new Set(scope.changedFiles) : null;
|
|
105
|
+
const files = [...new Set(listed.stdout.split('\0').filter((file) => file && (!wanted || wanted.has(file))))].sort();
|
|
106
|
+
if (files.length === 0) return [];
|
|
107
|
+
// --no-index also excludes force-added ignored paths. A cached path alone is not proof
|
|
108
|
+
// that the current ignore rules permit it.
|
|
109
|
+
const ignored = await exec(['git', 'check-ignore', '--no-index', '-z', '--stdin'], {
|
|
110
|
+
...opts, input: `${files.join('\0')}\0`, okExitCodes: [0, 1],
|
|
111
|
+
});
|
|
112
|
+
if (ignored.result !== 'ok') throw new Error(`format: cannot check Git ignores: ${ignored.stderr || ignored.error || ignored.signal || 'git failed'}`);
|
|
113
|
+
const excluded = new Set(ignored.stdout.split('\0'));
|
|
114
|
+
const root = await realpath(cwd);
|
|
115
|
+
const selected = [];
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
if (excluded.has(file)) continue;
|
|
118
|
+
const supported = formatter === 'pint' ? /\.php$/i.test(file)
|
|
119
|
+
: /\.(?:[cm]?[jt]sx?|json5?|jsonc|css|scss|less|html?|vue|md|mdx|markdown|ya?ml|graphql|gql|hbs|handlebars|mustache|mjml)$/i.test(file)
|
|
120
|
+
|| ['.prettierrc', '.babelrc', '.eslintrc', '.stylelintrc'].includes(path.basename(file));
|
|
121
|
+
if (!supported) continue;
|
|
122
|
+
try {
|
|
123
|
+
const absolute = path.resolve(root, file);
|
|
124
|
+
if (!(await lstat(absolute)).isFile() || await realpath(absolute) !== absolute) continue;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (['ENOENT', 'ENOTDIR'].includes(error.code)) continue;
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
// Leading dashes cannot become options, and existing paths are not treated as globs.
|
|
130
|
+
selected.push(`./${file}`);
|
|
131
|
+
}
|
|
132
|
+
return selected;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Unanswered prompt suffixes; inspect only each stream's last non-empty line. */
|
|
136
|
+
export const UNANSWERED_PROMPT_PATTERN = /(?:\?|\(y(?:\/n)?\)|\[y\/n\]|yes\/no)\s*$/i;
|
|
137
|
+
|
|
138
|
+
/** Simple globs only: * stays in one segment; ** can cross segments.
|
|
139
|
+
* @param {string} pattern @returns {RegExp}
|
|
140
|
+
*/
|
|
141
|
+
function evidenceMatcher(pattern) {
|
|
142
|
+
const normalized = pattern.split('/').filter((part) => part !== '.' && part !== '').join('/');
|
|
143
|
+
let source = '';
|
|
144
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
145
|
+
const char = normalized[i];
|
|
146
|
+
if (char === '*' && normalized[i + 1] === '*') {
|
|
147
|
+
i++;
|
|
148
|
+
if (normalized[i + 1] === '/') { source += '(?:.*/)?'; i++; }
|
|
149
|
+
else source += '.*';
|
|
150
|
+
} else if (char === '*') source += '[^/]*';
|
|
151
|
+
else source += char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
152
|
+
}
|
|
153
|
+
return new RegExp(`^${source}$`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Missing, inaccessible or replaced entries are not evidence.
|
|
157
|
+
* @param {NodeJS.ErrnoException} error @returns {boolean}
|
|
158
|
+
*/
|
|
159
|
+
function evidenceUnavailable(error) {
|
|
160
|
+
return ['ENOENT', 'ENOTDIR', 'EACCES', 'ELOOP'].includes(error.code);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Never traverse symlinks. Hash at most 200 unique files, without retaining their content.
|
|
164
|
+
* @param {string} cwd @param {string[]} patterns
|
|
165
|
+
* @returns {Promise<{evidence: Evidence[], missing?: string[]}>}
|
|
166
|
+
*/
|
|
167
|
+
async function collectEvidence(cwd, patterns) {
|
|
168
|
+
if (patterns.length === 0) return { evidence: [] };
|
|
169
|
+
let root;
|
|
170
|
+
try { root = await realpath(cwd); }
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (evidenceUnavailable(error)) return { evidence: [], missing: patterns };
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
const matchers = patterns.map(evidenceMatcher);
|
|
176
|
+
/** @type {Set<string>} */
|
|
177
|
+
const matched = new Set();
|
|
178
|
+
/** @param {string} dir @returns {Promise<void>} */
|
|
179
|
+
async function walk(dir) {
|
|
180
|
+
let entries;
|
|
181
|
+
try { entries = await readdir(path.join(root, dir), { withFileTypes: true }); }
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (evidenceUnavailable(error)) return;
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
const file = dir ? `${dir}/${entry.name}` : entry.name;
|
|
188
|
+
if (entry.isSymbolicLink()) continue;
|
|
189
|
+
// Also check the resolved path before descending/opening, in case a parent changed.
|
|
190
|
+
let resolved;
|
|
191
|
+
try { resolved = await realpath(path.join(root, file)); }
|
|
192
|
+
catch (error) { if (evidenceUnavailable(error)) continue; throw error; }
|
|
193
|
+
const relative = path.relative(root, resolved);
|
|
194
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) continue;
|
|
195
|
+
if (entry.isDirectory()) await walk(file);
|
|
196
|
+
else if (entry.isFile()) {
|
|
197
|
+
if (matchers.some((matcher) => matcher.test(file))) matched.add(file);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
await walk('');
|
|
202
|
+
/** @type {Evidence[]} */
|
|
203
|
+
const evidence = [];
|
|
204
|
+
for (const file of [...matched].sort().slice(0, 200)) {
|
|
205
|
+
try {
|
|
206
|
+
// Recheck parents at the time of hashing, rather than relying on the earlier walk.
|
|
207
|
+
const resolved = await realpath(path.join(root, file));
|
|
208
|
+
const relative = path.relative(root, resolved);
|
|
209
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { matched.delete(file); continue; }
|
|
210
|
+
const handle = await open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
211
|
+
try {
|
|
212
|
+
if (!(await handle.stat()).isFile()) { matched.delete(file); continue; }
|
|
213
|
+
const hash = createHash('sha256');
|
|
214
|
+
let bytes = 0;
|
|
215
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) {
|
|
216
|
+
bytes += chunk.length;
|
|
217
|
+
hash.update(chunk);
|
|
218
|
+
}
|
|
219
|
+
evidence.push({ path: file, bytes, sha256: hash.digest('hex') });
|
|
220
|
+
} finally { await handle.close(); }
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (!evidenceUnavailable(error)) throw error;
|
|
223
|
+
matched.delete(file);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const missing = patterns.filter((_, i) => ![...matched].some((file) => matchers[i].test(file)));
|
|
227
|
+
return { evidence, ...(missing.length ? { missing } : {}) };
|
|
228
|
+
}
|
|
229
|
+
|
|
46
230
|
/**
|
|
47
231
|
* A gate command is either a plain argv array or `{argv, failOnStdout?, env?}` (detect.mjs's
|
|
48
232
|
* `GateCommand`: `failOnStdout` for `gofmt -l`, which always exits 0; B60 `env` from the config).
|
|
@@ -55,14 +239,47 @@ function normalizeCommand(command) {
|
|
|
55
239
|
return { argv: command.argv, failOnStdout: command.failOnStdout === true, env: envOf(command) }; // `env: {}` is no env
|
|
56
240
|
}
|
|
57
241
|
|
|
242
|
+
/** Interpret timeouts and unanswered prompts for every gate, including repeats.
|
|
243
|
+
* @param {import('../util/exec.mjs').ExecResult} res
|
|
244
|
+
* @param {number} timeoutMs
|
|
245
|
+
* @param {boolean} [failOnStdout]
|
|
246
|
+
* @returns {Pick<GateResult, 'ok'|'code'|'stdout'|'stderr'|'reason'|'message'>}
|
|
247
|
+
*/
|
|
248
|
+
function gateOutcome(res, timeoutMs, failOnStdout = false) {
|
|
249
|
+
const prompt = res.result === 'ok' && [res.stdout, res.stderr].some((stream) =>
|
|
250
|
+
UNANSWERED_PROMPT_PATTERN.test(stream.trimEnd().split(/\r?\n/).at(-1)));
|
|
251
|
+
const reason = res.timedOut ? 'timeout'
|
|
252
|
+
: res.code === null ? res.signal ?? (res.error ? 'spawn-error' : 'terminated')
|
|
253
|
+
: prompt ? 'prompt' : undefined;
|
|
254
|
+
const diagnostic = res.timedOut
|
|
255
|
+
? `gate timeout after ${timeoutMs / 1000} seconds; raise gates.timeout_s (or --timeout-ms)`
|
|
256
|
+
: res.code === null ? `gate terminated: ${res.signal ?? res.error ?? 'no exit status (no signal reported)'}`
|
|
257
|
+
: prompt ? 'gate ended on an interactive prompt; install the tool or make the command non-interactive' : '';
|
|
258
|
+
const tails = ['stdout', 'stderr'].flatMap((stream) => {
|
|
259
|
+
const tail = res[stream].trimEnd().split(/\r?\n/).slice(-10).join('\n');
|
|
260
|
+
return tail ? [`${stream} tail:\n${tail}`] : [];
|
|
261
|
+
});
|
|
262
|
+
const message = [diagnostic, ...tails].join('\n');
|
|
263
|
+
return {
|
|
264
|
+
ok: res.result === 'ok' && !reason && (!failOnStdout || res.stdout.trim().length === 0),
|
|
265
|
+
code: res.code,
|
|
266
|
+
stdout: res.stdout,
|
|
267
|
+
stderr: diagnostic
|
|
268
|
+
? `${res.stderr}${res.stderr && !res.stderr.endsWith('\n') ? '\n' : ''}${diagnostic}\n`
|
|
269
|
+
: res.stderr,
|
|
270
|
+
...(reason ? { reason } : {}),
|
|
271
|
+
...(diagnostic ? { message } : {}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
58
275
|
/**
|
|
59
276
|
* B60: `opts.scope` is the block's {@link TestScope} (`testScope`, gates/expand.mjs); a gate argv
|
|
60
277
|
* item that is exactly `{test_files}` expands to its files, or is dropped for a full run. Left out,
|
|
61
278
|
* no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
|
|
62
|
-
* variables added to the inherited environment
|
|
63
|
-
* @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate, repeatNewTests?: RepeatNewTests, lightTestFiles?: string[]}} gates
|
|
64
|
-
* @param {{cwd: string, timeoutMs?: number, scope?: TestScope | import('./expand.mjs').ChangedScope}} opts
|
|
65
|
-
* @returns {Promise<{results: GateResult[], allOk: boolean, repeats?: Repeats}>}
|
|
279
|
+
* variables added to the inherited environment, overriding the CI=1/npm_config_yes=false defaults.
|
|
280
|
+
* @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate, custom?: CustomGate[], repeatNewTests?: RepeatNewTests, lightTestFiles?: string[], kindGates?: KindGates}} gates
|
|
281
|
+
* @param {{cwd: string, timeoutMs?: number, scope?: TestScope | import('./expand.mjs').ChangedScope, formatArgvBudgetBytes?: number}} opts - formatArgvBudgetBytes overrides the detected formatter batch budget (default 100 KiB).
|
|
282
|
+
* @returns {Promise<{results: GateResult[], allOk: boolean, duration_ms: number, repeats?: Repeats}>}
|
|
66
283
|
*/
|
|
67
284
|
export async function runGates(gates, opts) {
|
|
68
285
|
if (!gates || typeof gates !== 'object') {
|
|
@@ -73,48 +290,80 @@ export async function runGates(gates, opts) {
|
|
|
73
290
|
if (typeof cwd !== 'string' || cwd.length === 0) {
|
|
74
291
|
throw new TypeError('runGates: opts.cwd must be a non-empty string');
|
|
75
292
|
}
|
|
293
|
+
const customErrors = customGateMessages(gates.custom);
|
|
294
|
+
if (customErrors.length > 0) throw new TypeError(customErrors.join('; '));
|
|
295
|
+
const kindSet = gates.lightTestFiles ? undefined : gates.kindGates;
|
|
296
|
+
const excluded = (/** @type {string} */ name) => kindSet && !kindSet.names.includes(name);
|
|
297
|
+
const skipReason = () => /** @type {const} */ (`not in gates.kinds.${kindSet.kind}`);
|
|
76
298
|
|
|
77
299
|
// B60: gate env is not a secret store. Its values are never put in a result (only `env_keys`);
|
|
78
300
|
// a value of 8+ characters is also masked in the child's own output (a shorter one would mask
|
|
79
301
|
// unrelated text — `MODE=test` vs `npm test`)
|
|
80
302
|
for (const value of maskableEnvValues(gates)) registerSecret(value);
|
|
303
|
+
for (const gate of gates.custom ?? []) {
|
|
304
|
+
for (const value of maskableEnvValues({ gate })) registerSecret(value);
|
|
305
|
+
}
|
|
81
306
|
// Every gate is normalized and expanded BEFORE any runs, so a bad gate throws with nothing run.
|
|
82
307
|
// B60: config validation refuses a gate whose only item is {test_files}; the empty-argv check
|
|
83
308
|
// is the guard for one that reaches here anyway: it names the gate, not an opaque spawn error.
|
|
84
|
-
const planned =
|
|
309
|
+
const planned = [];
|
|
310
|
+
for (const gate of GATE_ORDER) {
|
|
85
311
|
const command = gates[gate] ?? null;
|
|
86
|
-
if (command === null)
|
|
312
|
+
if (command === null || excluded(gate)) { planned.push({ gate, command: null }); continue; }
|
|
87
313
|
const normalized = normalizeCommand(command);
|
|
88
314
|
if (normalized.argv[0] === CHANGED_FILES_TOKEN) throw new TypeError(`gates.${gate}: '{changed_files}' cannot be the command (argv[0])`);
|
|
89
|
-
|
|
315
|
+
let { argv, scoped } = expandArgv(normalized.argv, scope);
|
|
90
316
|
if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once ${normalized.argv.map((item) => `'${item}'`).join(' and ')} is dropped for the full suite — put the test command before it`);
|
|
91
|
-
|
|
92
|
-
|
|
317
|
+
const formatter = gate === 'format' && !Array.isArray(command) ? command.formatter : undefined;
|
|
318
|
+
let batches;
|
|
319
|
+
if (formatter) {
|
|
320
|
+
const files = await formatFiles(cwd, formatter, scope);
|
|
321
|
+
if (files.length === 0) { planned.push({ gate, command: null, noFiles: true }); continue; }
|
|
322
|
+
batches = formatBatches(argv, files, opts.formatArgvBudgetBytes ?? FORMAT_ARGV_BUDGET_BYTES);
|
|
323
|
+
argv = argv.concat(files);
|
|
324
|
+
}
|
|
325
|
+
planned.push({ gate, command: { argv, batches, originalArgv: normalized.argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } });
|
|
326
|
+
}
|
|
93
327
|
/** @type {GateResult[]} */
|
|
94
328
|
const results = [];
|
|
95
|
-
for (const { gate, command } of planned) {
|
|
329
|
+
for (const { gate, command, noFiles } of planned) {
|
|
96
330
|
if (command === null) {
|
|
97
|
-
results.push({ gate, argv: null, skipped: true, ok: true, code: null, stdout: '', stderr: ''
|
|
331
|
+
results.push({ gate, duration_ms: 0, argv: null, skipped: true, ok: true, code: null, stdout: '', stderr: '',
|
|
332
|
+
...(gates[gate] != null && excluded(gate) ? { reason: skipReason() } : {}),
|
|
333
|
+
...(noFiles ? { reason: 'no_changes' } : {}),
|
|
334
|
+
});
|
|
98
335
|
continue;
|
|
99
336
|
}
|
|
100
|
-
const { argv, scoped, failOnStdout, env } = command;
|
|
337
|
+
const { argv, batches = [argv], scoped, failOnStdout, env } = command;
|
|
101
338
|
// Each command is run and its exit status read on its own — never `&&`-chained with the
|
|
102
339
|
// next, so a red gate is reported red without swallowing the gates that follow it.
|
|
103
|
-
const
|
|
104
|
-
const
|
|
340
|
+
const execOpts = { cwd, timeoutMs, env: { ...process.env, CI: '1', npm_config_yes: 'false', ...env } };
|
|
341
|
+
const started = performance.now();
|
|
342
|
+
const outcomes = [];
|
|
343
|
+
for (const batch of batches) {
|
|
344
|
+
const res = await exec(batch, execOpts);
|
|
345
|
+
const batchOutcome = gateOutcome(res, timeoutMs, failOnStdout);
|
|
346
|
+
outcomes.push({ ...batchOutcome, ...(batches.length > 1 ? {
|
|
347
|
+
stdout: outputTail(batchOutcome.stdout), stderr: outputTail(batchOutcome.stderr),
|
|
348
|
+
} : {}) });
|
|
349
|
+
}
|
|
350
|
+
// Retain the first failure's status and diagnostic even if later batches pass.
|
|
351
|
+
const outcome = {
|
|
352
|
+
...(outcomes.find((entry) => !entry.ok) ?? outcomes[0]),
|
|
353
|
+
stdout: outcomes.map((entry) => entry.stdout).join(''),
|
|
354
|
+
stderr: outcomes.map((entry) => entry.stderr).join(''),
|
|
355
|
+
};
|
|
105
356
|
const result = /** @type {GateResult} */ ({
|
|
106
357
|
gate,
|
|
358
|
+
duration_ms: 0,
|
|
107
359
|
argv,
|
|
108
360
|
skipped: false,
|
|
109
|
-
ok,
|
|
110
|
-
code: res.code,
|
|
111
|
-
stdout: res.stdout,
|
|
112
|
-
stderr: res.stderr,
|
|
113
361
|
...(scoped ? scopeFields(scope) : {}),
|
|
362
|
+
...outcome,
|
|
114
363
|
...(env ? { env_keys: Object.keys(env).sort() } : {}),
|
|
115
364
|
});
|
|
116
365
|
const repeat = gates.repeatNewTests;
|
|
117
|
-
if (gate === 'test' && ok && repeat) {
|
|
366
|
+
if (gate === 'test' && outcome.ok && repeat) {
|
|
118
367
|
if (!command.originalArgv.includes(TEST_FILES_TOKEN)) {
|
|
119
368
|
repeat.onSkip?.('repeat_new_tests: skipped (gates.test has no {test_files})');
|
|
120
369
|
} else {
|
|
@@ -123,19 +372,38 @@ export async function runGates(gates, opts) {
|
|
|
123
372
|
result.repeats = { n: repeat.n, files, failed: false };
|
|
124
373
|
const repeatArgv = expandArgv(command.originalArgv, { scope: 'related', files }).argv;
|
|
125
374
|
for (let i = 0; i < repeat.n; i++) {
|
|
126
|
-
const repeated = await exec(repeatArgv,
|
|
127
|
-
const
|
|
375
|
+
const repeated = await exec(repeatArgv, execOpts);
|
|
376
|
+
const repeatedOutcome = gateOutcome(repeated, timeoutMs, failOnStdout);
|
|
128
377
|
// Keep the first failed run's output, even when a later repeat passes.
|
|
129
|
-
if (!
|
|
378
|
+
if (!repeatedOutcome.ok && !result.repeats.failed) {
|
|
130
379
|
result.repeats.failed = true;
|
|
131
|
-
Object.assign(result, {
|
|
380
|
+
Object.assign(result, { reason: 'flaky-test', argv: repeatArgv, ...repeatedOutcome });
|
|
132
381
|
}
|
|
133
382
|
}
|
|
134
383
|
}
|
|
135
384
|
}
|
|
136
385
|
}
|
|
386
|
+
result.duration_ms = Math.round(performance.now() - started);
|
|
137
387
|
results.push(result);
|
|
138
388
|
}
|
|
389
|
+
for (const gate of gates.custom ?? []) {
|
|
390
|
+
if (excluded(gate.name)) {
|
|
391
|
+
results.push({ gate: gate.name, duration_ms: 0, custom: true, argv: null, skipped: true, ok: true, code: null,
|
|
392
|
+
stdout: '', stderr: '', reason: skipReason(), evidence: [],
|
|
393
|
+
});
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const { argv, env } = normalizeCommand(gate);
|
|
397
|
+
const started = performance.now();
|
|
398
|
+
const res = await exec(argv, { cwd, timeoutMs, env: { ...process.env, CI: '1', npm_config_yes: 'false', ...env } });
|
|
399
|
+
const evidence = await collectEvidence(cwd, gate.evidence ?? []);
|
|
400
|
+
results.push({ gate: gate.name, custom: true, argv, skipped: false,
|
|
401
|
+
duration_ms: Math.round(performance.now() - started),
|
|
402
|
+
...gateOutcome(res, timeoutMs),
|
|
403
|
+
...(env ? { env_keys: Object.keys(env).sort() } : {}),
|
|
404
|
+
...evidence,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
139
407
|
const repeats = results.find((r) => r.repeats)?.repeats;
|
|
140
|
-
return { results, allOk: results.every((r) => r.ok), ...(repeats ? { repeats } : {}) };
|
|
408
|
+
return { results, allOk: results.every((r) => r.ok), duration_ms: results.reduce((sum, r) => sum + r.duration_ms, 0), ...(repeats ? { repeats } : {}) };
|
|
141
409
|
}
|
package/src/ledger/export.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `report --export <dir>` — writes one JSON file per report section (plan §6.2;
|
|
2
|
+
* `report --export <dir>` — writes one JSON file per report section (plan §6.2; 17 sections since B154).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { mkdir, writeFile } from 'node:fs/promises';
|
package/src/ledger/report.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `code-forge report` (plan §6.2, R4): builds the
|
|
2
|
+
* `code-forge report` (plan §6.2, R4): builds the report sections from a project's ledger rows.
|
|
3
3
|
* Pure data, no I/O — terminal rendering + `--export` live in `src/cli/report.mjs`.
|
|
4
4
|
*
|
|
5
5
|
* Shadow rows (`row.source === 'shadow'`) are excluded everywhere here (plan §6.3: shadow-sampled
|
|
@@ -80,19 +80,42 @@ export function buildRunModes(rows) {
|
|
|
80
80
|
return runs.map((run) => ({ run, mode: brief.has(run) ? 'brief' : 'plan' }));
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/** A full ISO timestamp with a timezone; reject coerced values and impossible calendar dates.
|
|
84
|
+
* @param {unknown} ts @returns {string | null} */
|
|
85
|
+
function timestamp(ts) {
|
|
86
|
+
if (typeof ts !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(ts)
|
|
87
|
+
|| !Number.isFinite(Date.parse(ts))) return null;
|
|
88
|
+
const day = ts.slice(0, 10);
|
|
89
|
+
const date = Date.parse(`${day}T00:00:00.000Z`);
|
|
90
|
+
return Number.isFinite(date) && new Date(date).toISOString().slice(0, 10) === day ? ts : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Missing or reversed endpoints cannot establish elapsed time.
|
|
94
|
+
* @param {string | null} start @param {string | null} end @returns {number | null} */
|
|
95
|
+
function elapsed(start, end) {
|
|
96
|
+
if (start === null || end === null) return null;
|
|
97
|
+
const ms = Date.parse(end) - Date.parse(start);
|
|
98
|
+
return ms >= 0 ? ms : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
/**
|
|
84
102
|
* Cost per block — completed AND still open (B33: a running total while the block is open):
|
|
85
|
-
* review $ / coder $ / S1+S2 $ / facts $ / proof ms,
|
|
103
|
+
* review $ / coder $ / S1+S2 $ / facts $ / proof ms, measured gate/review ms,
|
|
104
|
+
* `completed` (code close with no pending human check) and
|
|
86
105
|
* `unknownUsdSessions` (spend rows whose price is unknown, counted 0 in the totals). Grouped by
|
|
87
106
|
* run + block, so a block id reused in a second run is its own entry; a row without a run joins
|
|
88
107
|
* the entry of its block when that block id appears in exactly one run (else a `run: null` entry); shadow rows are dropped here too, exactly as `buildSpend` does. An entry is listed
|
|
89
|
-
* once it has a spend row
|
|
108
|
+
* once it has a spend row, a `block.close` row, or is stopped (`stopped: true`, omitted otherwise).
|
|
90
109
|
*/
|
|
91
110
|
function costPerBlock(rows) {
|
|
92
111
|
/** @type {Map<string, any>} */
|
|
93
112
|
const byBlock = new Map();
|
|
113
|
+
/** @type {Map<string, {firstDispatch: string | null, completion: string | null, stopped: string | null, awaiting: string | null, humanWait: number | null}>} */
|
|
114
|
+
const times = new Map();
|
|
94
115
|
/** @type {Set<string>} run+block keys with at least one spend row */
|
|
95
116
|
const spent = new Set();
|
|
117
|
+
const gated = new Set();
|
|
118
|
+
const reviewed = new Set();
|
|
96
119
|
const closedKeys = new Set();
|
|
97
120
|
// never split one block: a row without a run joins its block's entry when that block id appears
|
|
98
121
|
// in exactly one run; only a block seen in several runs leaves such a row in a `run: null` entry
|
|
@@ -126,18 +149,52 @@ function costPerBlock(rows) {
|
|
|
126
149
|
factsUsd: 0,
|
|
127
150
|
otherUsd: 0,
|
|
128
151
|
proofTimeMs: 0,
|
|
152
|
+
wallMs: null,
|
|
153
|
+
humanWaitMs: null,
|
|
129
154
|
totalUsd: 0,
|
|
130
155
|
unknownUsdSessions: 0,
|
|
131
156
|
});
|
|
157
|
+
times.set(key, { firstDispatch: null, completion: null, stopped: null, awaiting: null, humanWait: null });
|
|
132
158
|
}
|
|
133
159
|
const e = byBlock.get(key);
|
|
160
|
+
const time = times.get(key);
|
|
161
|
+
if (['dispatch', 'block.close', 'block.awaiting_human_check', 'block.human_pass'].includes(row.event)) {
|
|
162
|
+
delete e.stopped;
|
|
163
|
+
time.stopped = null;
|
|
164
|
+
}
|
|
134
165
|
if (row.event === 'dispatch') {
|
|
135
166
|
e.level = row.level ?? e.level;
|
|
136
167
|
e.lane = row.lane ?? e.lane;
|
|
168
|
+
e.completed = false;
|
|
137
169
|
}
|
|
138
170
|
if (row.event === 'block.close') { e.completed = true; closedKeys.add(key); }
|
|
139
171
|
if (row.event === 'block.awaiting_human_check') e.completed = false;
|
|
140
172
|
if (row.event === 'block.human_pass') e.completed = true;
|
|
173
|
+
if (row.event === 'block.stop') { e.completed = false; e.stopped = true; }
|
|
174
|
+
// Invalid timestamps have no effect on time; lifecycle and spend still count normally.
|
|
175
|
+
const ts = timestamp(row.ts);
|
|
176
|
+
if (ts !== null) {
|
|
177
|
+
if (row.event === 'dispatch') {
|
|
178
|
+
time.firstDispatch ??= ts;
|
|
179
|
+
time.completion = null;
|
|
180
|
+
time.awaiting = null;
|
|
181
|
+
time.humanWait = null;
|
|
182
|
+
}
|
|
183
|
+
if (row.event === 'block.close') {
|
|
184
|
+
time.completion = ts;
|
|
185
|
+
time.humanWait = null;
|
|
186
|
+
}
|
|
187
|
+
if (row.event === 'block.awaiting_human_check') {
|
|
188
|
+
time.awaiting = ts;
|
|
189
|
+
time.completion = null;
|
|
190
|
+
time.humanWait = null;
|
|
191
|
+
}
|
|
192
|
+
if (row.event === 'block.human_pass') {
|
|
193
|
+
time.completion = ts;
|
|
194
|
+
time.humanWait = elapsed(time.awaiting, ts);
|
|
195
|
+
}
|
|
196
|
+
if (row.event === 'block.stop') time.stopped = ts;
|
|
197
|
+
}
|
|
141
198
|
if (isSpendRow(row)) {
|
|
142
199
|
spent.add(key);
|
|
143
200
|
const cost = sessionCostUsd(row);
|
|
@@ -153,14 +210,73 @@ function costPerBlock(rows) {
|
|
|
153
210
|
}
|
|
154
211
|
}
|
|
155
212
|
if (row.event === 'proof' && typeof row.duration_ms === 'number') e.proofTimeMs += row.duration_ms;
|
|
213
|
+
// gate.red is a diagnostic for a command already included in gate.done's total.
|
|
214
|
+
// Leave legacy entries without a gateTimeMs field when no measured run is available.
|
|
215
|
+
if (row.event === 'gate.done' && typeof row.duration_ms === 'number' && Number.isFinite(row.duration_ms) && row.duration_ms >= 0) {
|
|
216
|
+
e.gateTimeMs = (e.gateTimeMs ?? 0) + row.duration_ms;
|
|
217
|
+
gated.add(key);
|
|
218
|
+
}
|
|
219
|
+
if (row.event === 'review.result' && typeof row.duration_ms === 'number' && Number.isFinite(row.duration_ms) && row.duration_ms >= 0) {
|
|
220
|
+
e.reviewTimeMs = (e.reviewTimeMs ?? 0) + row.duration_ms;
|
|
221
|
+
reviewed.add(key);
|
|
222
|
+
}
|
|
156
223
|
}
|
|
157
|
-
const out = [...byBlock.entries()].filter(([key, e]) => e.completed || spent.has(key) || closedKeys.has(key)).map(([, e]) =>
|
|
224
|
+
const out = [...byBlock.entries()].filter(([key, e]) => e.completed || e.stopped || spent.has(key) || gated.has(key) || reviewed.has(key) || closedKeys.has(key)).map(([key, e]) => {
|
|
225
|
+
const time = times.get(key);
|
|
226
|
+
if (e.completed) {
|
|
227
|
+
e.wallMs = elapsed(time.firstDispatch, time.completion);
|
|
228
|
+
e.humanWaitMs = time.humanWait;
|
|
229
|
+
}
|
|
230
|
+
if (e.stopped) e.wallMs = elapsed(time.firstDispatch, time.stopped);
|
|
231
|
+
return e;
|
|
232
|
+
});
|
|
158
233
|
for (const e of out) {
|
|
159
234
|
for (const k of ['reviewUsd', 'coderUsd', 's1s2Usd', 'factsUsd', 'otherUsd', 'totalUsd']) e[k] = Math.round(e[k] * 10000) / 10000;
|
|
160
235
|
}
|
|
161
236
|
return out;
|
|
162
237
|
}
|
|
163
238
|
|
|
239
|
+
/** Per-run wall time, spend and distinct block counts, plus the ledger/site total.
|
|
240
|
+
* Rows and costs have already had shadow rows removed. Untimed runs contribute 0 to total wallMs.
|
|
241
|
+
* Rows without string run ids go to outside_runs; total.usd includes outside_runs.usd.
|
|
242
|
+
* @param {Array<Record<string, any>>} rows @param {any[]} costs */
|
|
243
|
+
function runTime(rows, costs) {
|
|
244
|
+
/** @type {Map<string, Array<Record<string, any>>>} */
|
|
245
|
+
const groups = new Map();
|
|
246
|
+
const outside = [];
|
|
247
|
+
for (const row of rows) {
|
|
248
|
+
if (typeof row.run !== 'string') { outside.push(row); continue; }
|
|
249
|
+
const run = row.run;
|
|
250
|
+
if (!groups.has(run)) groups.set(run, []);
|
|
251
|
+
groups.get(run).push(row);
|
|
252
|
+
}
|
|
253
|
+
const spend = new Map(spendByRun(rows).map((r) => [r.run, r.usd]));
|
|
254
|
+
const runs = [...groups].map(([run, list]) => {
|
|
255
|
+
const timed = list.filter((r) => timestamp(r.ts) !== null);
|
|
256
|
+
const started = timed.find((r) => r.event === 'run.start')?.ts ?? timed[0]?.ts ?? null;
|
|
257
|
+
const ended = timed.filter((r) => r.event === 'run.end').at(-1)?.ts ?? null;
|
|
258
|
+
const open = ended === null;
|
|
259
|
+
return {
|
|
260
|
+
run, started, ended,
|
|
261
|
+
wallMs: elapsed(started, open ? timed.at(-1)?.ts ?? null : ended),
|
|
262
|
+
usd: spend.get(run) ?? 0,
|
|
263
|
+
blocks_closed: costs.filter((b) => b.run === run && b.completed).length,
|
|
264
|
+
blocks_stopped: new Set(list.filter((r) => r.event === 'block.stop' && r.block).map((r) => r.block)).size,
|
|
265
|
+
...(open ? { open: true } : {}),
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
runs,
|
|
270
|
+
...(outside.length ? { outside_runs: { usd: sumSpend(outside).usd, rows: outside.length } } : {}),
|
|
271
|
+
total: {
|
|
272
|
+
runs: runs.length,
|
|
273
|
+
wallMs: runs.reduce((sum, r) => sum + (r.wallMs ?? 0), 0),
|
|
274
|
+
usd: sumSpend(rows).usd,
|
|
275
|
+
blocks_closed: runs.reduce((sum, r) => sum + r.blocks_closed, 0),
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
164
280
|
const laneDistribution = (rows) =>
|
|
165
281
|
rows.filter((r) => r.event === 'dispatch').map((r) => ({ block: r.block, lanePicked: r.lane ?? null, levelOpened: r.level ?? null }));
|
|
166
282
|
|
|
@@ -274,8 +390,26 @@ const runStopCounts = (rows) => ({
|
|
|
274
390
|
noEngineStops: rows.filter((r) => r.event === 'run.stop' && r.reason === 'no_engine').length,
|
|
275
391
|
});
|
|
276
392
|
|
|
393
|
+
/** Specialist deliverables are separate from calibration's `outcome` events.
|
|
394
|
+
* @param {import('./write.mjs').LedgerRow[]} rows
|
|
395
|
+
*/
|
|
396
|
+
function specialistOutcomes(rows) {
|
|
397
|
+
const outcomes = rows.filter((r) => r.event === 'outcome.recorded').map((r) => ({
|
|
398
|
+
skill: r.skill, step: r.step ?? null, status: r.status,
|
|
399
|
+
cost_usd: r.cost_usd ?? null, minutes: r.minutes ?? null,
|
|
400
|
+
}));
|
|
401
|
+
return {
|
|
402
|
+
outcomes,
|
|
403
|
+
total: {
|
|
404
|
+
count: outcomes.length,
|
|
405
|
+
cost_usd: outcomes.reduce((sum, r) => sum + (r.cost_usd ?? 0), 0),
|
|
406
|
+
minutes: outcomes.reduce((sum, r) => sum + (r.minutes ?? 0), 0),
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
277
411
|
/** The exact, ordered set of section keys `buildReport` produces (plan §6.2; B66 appended
|
|
278
|
-
* `outside_review`,
|
|
412
|
+
* `outside_review`, B124b `agent_waivers`, B147 `run_time`, B154 `specialist_outcomes` — append new sections, never reorder) —
|
|
279
413
|
* exported so tests (and `--export`) can assert against ONE source of truth instead of a copy. */
|
|
280
414
|
export const SECTION_NAMES = Object.freeze([
|
|
281
415
|
'cost_per_block',
|
|
@@ -293,6 +427,8 @@ export const SECTION_NAMES = Object.freeze([
|
|
|
293
427
|
'run_stop_counts',
|
|
294
428
|
'outside_review',
|
|
295
429
|
'agent_waivers',
|
|
430
|
+
'run_time',
|
|
431
|
+
'specialist_outcomes',
|
|
296
432
|
]);
|
|
297
433
|
|
|
298
434
|
/**
|
|
@@ -325,6 +461,8 @@ export function buildReport(rows) {
|
|
|
325
461
|
unwaived: r.slice(i + 1).some((undo) => undo.event === 'review.unwaived' && undo.by === 'human'
|
|
326
462
|
&& undo.run === row.run && undo.block === row.block && undo.file === row.file && undo.finding === row.finding) }]
|
|
327
463
|
: []),
|
|
464
|
+
run_time: (/** @type {any[]} */ r) => runTime(r, costs),
|
|
465
|
+
specialist_outcomes: specialistOutcomes,
|
|
328
466
|
};
|
|
329
467
|
/** @type {Record<string, any>} */
|
|
330
468
|
const sections = {};
|