@0xcraft/powershot 1.1.1 → 1.1.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/README.md +37 -25
- package/dist/cli/reports.js +4 -3
- package/dist/cli/review-command.js +4 -1
- package/dist/cli/session-command.js +6 -0
- package/dist/config.js +5 -0
- package/dist/github/api.js +198 -0
- package/dist/github/inline-comments.js +3 -154
- package/dist/github/summary-comment.js +149 -0
- package/dist/ground.js +61 -8
- package/dist/lang/packs.js +97 -14
- package/dist/lang/parse-worker.js +15 -0
- package/dist/lang/python-deps.js +21 -8
- package/dist/manifest.js +32 -0
- package/dist/package-smoke.js +4 -0
- package/dist/plan.js +7 -0
- package/dist/report/markdown.js +31 -3
- package/dist/report/summary.js +103 -0
- package/dist/report/terminal.js +19 -1
- package/dist/report/viewer.js +22 -3
- package/dist/review.js +39 -18
- package/dist/selftest.js +665 -10
- package/dist/session.js +7 -1
- package/dist/verifiers/foreign-phantom-dep.js +8 -2
- package/docs/architecture.md +38 -11
- package/docs/ci.md +45 -12
- package/examples/github-actions/action.yml +4 -5
- package/examples/github-actions/cli.yml +1 -1
- package/examples/gitlab/.gitlab-ci.yml +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
function plural(count, singular, pluralForm = singular + 's') {
|
|
2
|
+
return count + ' ' + (count === 1 ? singular : pluralForm);
|
|
3
|
+
}
|
|
4
|
+
function capabilityName(capability) {
|
|
5
|
+
return {
|
|
6
|
+
types: 'type information',
|
|
7
|
+
references: 'a reference graph',
|
|
8
|
+
'python-types': 'Python type information',
|
|
9
|
+
base: 'a base revision',
|
|
10
|
+
syntax: 'a syntax tree',
|
|
11
|
+
}[capability] ?? capability;
|
|
12
|
+
}
|
|
13
|
+
function naturalList(values, conjunction = 'and') {
|
|
14
|
+
if (values.length < 2)
|
|
15
|
+
return values[0] ?? '';
|
|
16
|
+
if (values.length === 2)
|
|
17
|
+
return values[0] + ' ' + conjunction + ' ' + values[1];
|
|
18
|
+
return values.slice(0, -1).join(', ') + ', ' + conjunction + ' ' + values.at(-1);
|
|
19
|
+
}
|
|
20
|
+
function scopeDetails(record) {
|
|
21
|
+
const details = [];
|
|
22
|
+
const selected = (record.files ?? []).filter((file) => file.disposition === 'selected');
|
|
23
|
+
const unavailableGroups = new Map();
|
|
24
|
+
for (const file of selected) {
|
|
25
|
+
if (!file.unavailable?.length)
|
|
26
|
+
continue;
|
|
27
|
+
const order = ['types', 'references', 'python-types', 'base', 'syntax'];
|
|
28
|
+
const capabilities = [...new Set(file.unavailable)].sort((left, right) => {
|
|
29
|
+
const leftRank = order.indexOf(left);
|
|
30
|
+
const rightRank = order.indexOf(right);
|
|
31
|
+
return (leftRank === -1 ? order.length : leftRank) - (rightRank === -1 ? order.length : rightRank) ||
|
|
32
|
+
left.localeCompare(right);
|
|
33
|
+
});
|
|
34
|
+
const key = capabilities.join('\0');
|
|
35
|
+
const group = unavailableGroups.get(key) ?? { capabilities, count: 0 };
|
|
36
|
+
group.count++;
|
|
37
|
+
unavailableGroups.set(key, group);
|
|
38
|
+
}
|
|
39
|
+
for (const group of unavailableGroups.values()) {
|
|
40
|
+
const capabilities = naturalList(group.capabilities.map(capabilityName));
|
|
41
|
+
details.push(plural(group.count, 'reviewed file') + ' lacked ' + capabilities + '.');
|
|
42
|
+
}
|
|
43
|
+
const unavailableChecks = record.checks?.unavailable ?? [];
|
|
44
|
+
if (unavailableChecks.length > 0) {
|
|
45
|
+
const shown = unavailableChecks.slice(0, 8).map((check) => check.check);
|
|
46
|
+
const requirements = [...new Set(unavailableChecks.flatMap((check) => check.missing.split(/,\s*/)))]
|
|
47
|
+
.map(capabilityName);
|
|
48
|
+
details.push(plural(unavailableChecks.length, 'check') + ' requiring ' + naturalList(requirements, 'or') +
|
|
49
|
+
' did not run: ' + shown.join(', ') +
|
|
50
|
+
(unavailableChecks.length > shown.length ? ', and ' + (unavailableChecks.length - shown.length) + ' more' : '') + '.');
|
|
51
|
+
}
|
|
52
|
+
const waived = new Map();
|
|
53
|
+
for (const file of record.files ?? []) {
|
|
54
|
+
if (file.disposition !== 'waived')
|
|
55
|
+
continue;
|
|
56
|
+
const reason = file.reason ?? 'unspecified reason';
|
|
57
|
+
waived.set(reason, (waived.get(reason) ?? 0) + 1);
|
|
58
|
+
}
|
|
59
|
+
for (const [reason, count] of waived) {
|
|
60
|
+
details.push(plural(count, 'changed file') + ' not reviewed: ' + reason + '.');
|
|
61
|
+
}
|
|
62
|
+
return details;
|
|
63
|
+
}
|
|
64
|
+
export function summarizeRun(record) {
|
|
65
|
+
const hasFiles = record.files !== undefined;
|
|
66
|
+
const hasChecks = record.checks?.ran !== undefined;
|
|
67
|
+
return {
|
|
68
|
+
state: record.state,
|
|
69
|
+
notLookedAt: [...record.notLookedAt],
|
|
70
|
+
coverage: record.coverage,
|
|
71
|
+
verifyOnly: record.engine?.verifyOnly,
|
|
72
|
+
minSeverity: record.engine?.minSeverity,
|
|
73
|
+
filesReviewed: hasFiles
|
|
74
|
+
? record.files.filter((file) => file.disposition === 'selected').length
|
|
75
|
+
: undefined,
|
|
76
|
+
deterministicChecks: hasChecks ? new Set(record.checks.ran).size : undefined,
|
|
77
|
+
scopeDetails: scopeDetails(record),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function noFindingsLabel(summary) {
|
|
81
|
+
const threshold = summary.minSeverity === undefined || summary.minSeverity === 'info'
|
|
82
|
+
? ''
|
|
83
|
+
: summary.minSeverity === 'critical'
|
|
84
|
+
? 'critical '
|
|
85
|
+
: summary.minSeverity + '-or-higher ';
|
|
86
|
+
const origin = summary.verifyOnly === true ? 'deterministic ' : '';
|
|
87
|
+
return 'No ' + threshold + origin + 'findings';
|
|
88
|
+
}
|
|
89
|
+
export function scopeLine(summary) {
|
|
90
|
+
const parts = [];
|
|
91
|
+
if (summary.filesReviewed !== undefined)
|
|
92
|
+
parts.push(plural(summary.filesReviewed, 'file') + ' reviewed');
|
|
93
|
+
if (summary.deterministicChecks !== undefined) {
|
|
94
|
+
parts.push(plural(summary.deterministicChecks, 'deterministic check'));
|
|
95
|
+
}
|
|
96
|
+
if (summary.coverage !== undefined)
|
|
97
|
+
parts.push(summary.coverage + ' coverage');
|
|
98
|
+
return parts.length > 0 ? parts.join(' · ') : undefined;
|
|
99
|
+
}
|
|
100
|
+
export function modeNote(summary, verifyOnly = 'verify-only') {
|
|
101
|
+
return summary.verifyOnly === true ? 'Model review was disabled (' + verifyOnly + ').' : undefined;
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=summary.js.map
|
package/dist/report/terminal.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bold, brightRed, dim, gray, red, steel, yellow } from './ansi.js';
|
|
2
2
|
import { highlight, isJsx } from './highlight.js';
|
|
3
|
+
import { modeNote, noFindingsLabel, scopeLine } from './summary.js';
|
|
3
4
|
const SEVERITY_COLOR = {
|
|
4
5
|
critical: brightRed,
|
|
5
6
|
high: red,
|
|
@@ -106,12 +107,23 @@ export function terminal(findings, opts) {
|
|
|
106
107
|
out.push(' ' + bold('PowerShot') + dim(' · ' + opts.subtitle));
|
|
107
108
|
out.push(rule);
|
|
108
109
|
const incomplete = opts.state !== 'complete';
|
|
110
|
+
const scope = scopeLine(opts);
|
|
111
|
+
const mode = modeNote(opts);
|
|
109
112
|
if (findings.length === 0) {
|
|
110
113
|
out.push('', incomplete
|
|
111
114
|
? ' ' + yellow('!') + ' No findings — but this review is ' + opts.state + ', not a verdict.'
|
|
112
|
-
: ' ' + steel('✔') + '
|
|
115
|
+
: ' ' + steel('✔') + ' ' + noFindingsLabel(opts) + '.');
|
|
113
116
|
for (const reason of opts.notLookedAt)
|
|
114
117
|
out.push(dim(' ' + reason));
|
|
118
|
+
if (scope)
|
|
119
|
+
out.push(dim(' ' + scope));
|
|
120
|
+
if (mode)
|
|
121
|
+
out.push(dim(' ' + mode));
|
|
122
|
+
if ((opts.scopeDetails?.length ?? 0) > 0) {
|
|
123
|
+
out.push(dim(' ' + (opts.coverage === 'portable' ? 'Coverage details:' : 'Review scope:')));
|
|
124
|
+
for (const detail of opts.scopeDetails)
|
|
125
|
+
out.push(dim(' - ' + detail));
|
|
126
|
+
}
|
|
115
127
|
out.push('');
|
|
116
128
|
return out.join('\n');
|
|
117
129
|
}
|
|
@@ -146,6 +158,12 @@ export function terminal(findings, opts) {
|
|
|
146
158
|
for (const reason of opts.notLookedAt)
|
|
147
159
|
out.push(dim(' ' + reason));
|
|
148
160
|
}
|
|
161
|
+
if (scope)
|
|
162
|
+
out.push(' ' + steel('◇ ') + dim(scope));
|
|
163
|
+
if (mode)
|
|
164
|
+
out.push(dim(' ' + mode));
|
|
165
|
+
for (const detail of opts.scopeDetails ?? [])
|
|
166
|
+
out.push(dim(' ' + detail));
|
|
149
167
|
out.push('');
|
|
150
168
|
return out.join('\n');
|
|
151
169
|
}
|
package/dist/report/viewer.js
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
|
+
import { modeNote, noFindingsLabel, scopeLine } from './summary.js';
|
|
1
2
|
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2
3
|
export function viewer(findings, meta) {
|
|
3
4
|
const verified = findings.filter((f) => f.class === 'verified').length;
|
|
4
5
|
const incomplete = meta.state !== 'complete';
|
|
6
|
+
const scope = scopeLine(meta);
|
|
7
|
+
const mode = modeNote(meta);
|
|
5
8
|
const warning = incomplete
|
|
6
9
|
? '<div class="warning"><strong>This review is ' + escape(meta.state) + ' — not a verdict.</strong>' +
|
|
7
10
|
(meta.notLookedAt.length > 0
|
|
8
11
|
? '<ul>' + meta.notLookedAt.map((reason) => '<li>' + escape(reason) + '</li>').join('') + '</ul>'
|
|
9
12
|
: '') + '</div>'
|
|
10
13
|
: '';
|
|
14
|
+
const context = scope || mode || (meta.scopeDetails?.length ?? 0) > 0
|
|
15
|
+
? '<div class="coverage">' +
|
|
16
|
+
(scope ? '<strong>' + escape(scope) + '</strong>' : '') +
|
|
17
|
+
(mode ? '<p>' + escape(mode) + '</p>' : '') +
|
|
18
|
+
((meta.scopeDetails?.length ?? 0) > 0
|
|
19
|
+
? '<details><summary>' + (meta.coverage === 'portable' ? 'Coverage details' : 'Review scope') + '</summary><ul>' +
|
|
20
|
+
meta.scopeDetails.map((detail) => '<li>' + escape(detail) + '</li>').join('') +
|
|
21
|
+
'</ul></details>'
|
|
22
|
+
: '') + '</div>'
|
|
23
|
+
: '';
|
|
24
|
+
const verdict = findings.length === 0
|
|
25
|
+
? '<p class="none">' +
|
|
26
|
+
(incomplete ? 'No findings from what completed.' : escape(noFindingsLabel(meta)) + '.') + '</p>'
|
|
27
|
+
: '';
|
|
11
28
|
const rows = findings
|
|
12
29
|
.map((f) => {
|
|
13
30
|
const frame = f.frame
|
|
@@ -70,6 +87,8 @@ export function viewer(findings, meta) {
|
|
|
70
87
|
.none { color:var(--muted); }
|
|
71
88
|
.warning { border:1px solid var(--amber); border-radius:6px; padding:10px 14px; margin:0 0 18px; }
|
|
72
89
|
.warning ul { margin:6px 0 0; }
|
|
90
|
+
.coverage { border:1px solid var(--steel); border-radius:6px; padding:10px 14px; margin:0 0 18px; }
|
|
91
|
+
.coverage ul { margin:6px 0 0; }
|
|
73
92
|
.f.put-away { opacity:.4; }
|
|
74
93
|
.f.put-away .title { text-decoration:line-through; }
|
|
75
94
|
.act { margin-left:8px; }
|
|
@@ -82,15 +101,15 @@ export function viewer(findings, meta) {
|
|
|
82
101
|
<p class="meta">${escape(meta.target)} · ${escape(meta.started.slice(0, 19).replace('T', ' '))} · session ${escape(meta.id)}<br>
|
|
83
102
|
${findings.length} finding(s) — ${verified} verified, ${findings.length - verified} judged</p>
|
|
84
103
|
${warning}
|
|
104
|
+
${verdict}
|
|
105
|
+
${context}
|
|
85
106
|
<div class="bar">
|
|
86
107
|
<button data-filter="all" aria-pressed="true">all</button>
|
|
87
108
|
<button data-filter="verified" aria-pressed="false">verified</button>
|
|
88
109
|
<button data-filter="judged" aria-pressed="false">judged</button>
|
|
89
110
|
<button id="show-away" aria-pressed="false">show put away</button>
|
|
90
111
|
</div>
|
|
91
|
-
${findings.length === 0
|
|
92
|
-
? '<p class="none">' + (incomplete ? 'No findings from what completed.' : 'No findings.') + '</p>'
|
|
93
|
-
: rows}
|
|
112
|
+
${findings.length === 0 ? '' : rows}
|
|
94
113
|
</div>
|
|
95
114
|
<script>
|
|
96
115
|
// Putting a finding away is per-reader and per-browser on purpose: this page is a
|
package/dist/review.js
CHANGED
|
@@ -2,7 +2,6 @@ import { buildGround } from './ground.js';
|
|
|
2
2
|
import { baseRefOf, collectChanges, statedIntent } from './git.js';
|
|
3
3
|
import { bundle, bundleName, reviewables, uncovered } from './bundle.js';
|
|
4
4
|
import { attachFrames, positionable } from './position.js';
|
|
5
|
-
import { skippedLanguages } from './lang/packs.js';
|
|
6
5
|
import { JudgeCache } from './cache.js';
|
|
7
6
|
import { Dismissals, rememberReport } from './dismissed.js';
|
|
8
7
|
import { renderChanges } from './judges/judge.js';
|
|
@@ -16,7 +15,6 @@ import { Budget } from './budget.js';
|
|
|
16
15
|
import { packFor } from './lang/packs.js';
|
|
17
16
|
import { SEVERITIES } from './types.js';
|
|
18
17
|
import { stripControl, stripPath } from './text.js';
|
|
19
|
-
const packOf = (path) => packFor(path)?.name ?? 'other';
|
|
20
18
|
export function atLeast(severity, min) {
|
|
21
19
|
return SEVERITIES.indexOf(severity) >= SEVERITIES.indexOf(min);
|
|
22
20
|
}
|
|
@@ -33,20 +31,24 @@ export function titleOverlap(a, b) {
|
|
|
33
31
|
shared++;
|
|
34
32
|
return shared / Math.min(left.size, right.size);
|
|
35
33
|
}
|
|
34
|
+
const PORTABLE_OPTIONAL = new Set(['types', 'references', 'python-types']);
|
|
36
35
|
/**
|
|
37
36
|
* Files this verifier can actually answer for, with unavailable oracles kept per
|
|
38
|
-
* file.
|
|
39
|
-
*
|
|
37
|
+
* file. A before/after check has no question to ask about a newly created file;
|
|
38
|
+
* when an existing file has a base snapshot that cannot be parsed, `base` is a real
|
|
39
|
+
* missing capability and remains verdict-blocking in every coverage profile.
|
|
40
40
|
*/
|
|
41
41
|
function verifierTargets(v, g, have) {
|
|
42
42
|
if (v.domain === 'typescript') {
|
|
43
43
|
return g.files
|
|
44
|
-
.filter((file) => !v.needs.includes('base') || file.before !== undefined)
|
|
44
|
+
.filter((file) => !v.needs.includes('base') || file.changed.before !== undefined)
|
|
45
45
|
.map((file) => ({
|
|
46
46
|
kind: 'typescript',
|
|
47
47
|
path: file.changed.path,
|
|
48
48
|
file,
|
|
49
49
|
missing: v.needs.filter((need) => {
|
|
50
|
+
if (need === 'base')
|
|
51
|
+
return file.before === undefined;
|
|
50
52
|
if (need === 'types' || need === 'references')
|
|
51
53
|
return !file.typed;
|
|
52
54
|
if (need === 'python-types')
|
|
@@ -59,12 +61,14 @@ function verifierTargets(v, g, have) {
|
|
|
59
61
|
return g.foreign
|
|
60
62
|
.filter((file) => v.domain !== 'python' || file.pack.name === 'python')
|
|
61
63
|
.filter((file) => !v.supports || v.supports(file))
|
|
62
|
-
.filter((file) => !v.needs.includes('base') || file.
|
|
64
|
+
.filter((file) => !v.needs.includes('base') || file.changed.before !== undefined)
|
|
63
65
|
.map((file) => ({
|
|
64
66
|
kind: 'foreign',
|
|
65
67
|
path: file.path,
|
|
66
68
|
file,
|
|
67
69
|
missing: v.needs.filter((need) => {
|
|
70
|
+
if (need === 'base')
|
|
71
|
+
return file.beforeTree === undefined;
|
|
68
72
|
if (need === 'python-types')
|
|
69
73
|
return file.pack.name !== 'python' || !have.has('python-types');
|
|
70
74
|
if (need === 'types' || need === 'references')
|
|
@@ -142,6 +146,7 @@ export async function review(opts) {
|
|
|
142
146
|
return { findings: [], stats: { files: 0, verified: 0, judged: 0, dismissed: 0 }, failures, plan };
|
|
143
147
|
}
|
|
144
148
|
const skipped = new Map();
|
|
149
|
+
const unavailable = new Map();
|
|
145
150
|
const budget = opts.budget ?? new Budget();
|
|
146
151
|
const manifest = opts.manifest;
|
|
147
152
|
const groundDone = stage('ground');
|
|
@@ -166,27 +171,33 @@ export async function review(opts) {
|
|
|
166
171
|
targets.set(verifier, files);
|
|
167
172
|
}
|
|
168
173
|
const selectedVerifiers = [...targets.keys()];
|
|
174
|
+
// Naming a check explicitly is a request for that oracle, even under the portable
|
|
175
|
+
// default. Strict policy makes the same promise for every configured verifier.
|
|
176
|
+
const requireEnrichedOracles = config.coverage === 'strict' || opts.checks !== undefined;
|
|
169
177
|
// a file the change touched that no parser produced a tree for was not reviewed,
|
|
170
178
|
// whatever the summary says about the ones that were
|
|
171
179
|
const grounded = new Set([...g.files.map((f) => f.changed.path), ...g.foreign.map((f) => f.path)]);
|
|
172
180
|
for (const c of changed) {
|
|
173
|
-
if (
|
|
181
|
+
if (grounded.has(c.path))
|
|
182
|
+
continue;
|
|
183
|
+
if (packFor(c.path))
|
|
184
|
+
plan.fail(c.path, 'declared language parser unavailable');
|
|
185
|
+
else
|
|
174
186
|
plan.waive(c.path, 'no parser for this language');
|
|
175
187
|
}
|
|
176
188
|
// Capabilities belong to files, not runs. A typed file beside one excluded from
|
|
177
189
|
// tsconfig must not make the latter look checked, and an old Ruby file must not
|
|
178
190
|
// make a new Python file eligible for a before/after oracle.
|
|
179
191
|
for (const files of targets.values()) {
|
|
180
|
-
for (const file of files)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
const required = file.missing.filter((capability) => !PORTABLE_OPTIONAL.has(capability));
|
|
194
|
+
const enriched = file.missing.filter((capability) => PORTABLE_OPTIONAL.has(capability));
|
|
195
|
+
plan.limit(file.path, required);
|
|
196
|
+
if (requireEnrichedOracles)
|
|
197
|
+
plan.limit(file.path, enriched);
|
|
198
|
+
else
|
|
199
|
+
plan.noteUnavailable(file.path, enriched);
|
|
188
200
|
}
|
|
189
|
-
failures.push('not reviewed, grammar budget reached: ' + skippedLanguages.join(', '));
|
|
190
201
|
}
|
|
191
202
|
for (const line of plan.summary())
|
|
192
203
|
say('selection ' + line);
|
|
@@ -201,8 +212,14 @@ export async function review(opts) {
|
|
|
201
212
|
const files = targets.get(v);
|
|
202
213
|
const eligible = files.filter((file) => file.missing.length === 0);
|
|
203
214
|
const missing = [...new Set(files.flatMap((file) => file.missing))];
|
|
215
|
+
const check = v.id ?? v.name;
|
|
216
|
+
const onlyEnrichedMissing = missing.every((capability) => PORTABLE_OPTIONAL.has(capability));
|
|
217
|
+
if (missing.length > 0 && !requireEnrichedOracles && onlyEnrichedMissing) {
|
|
218
|
+
unavailable.set(check, missing.join(', '));
|
|
219
|
+
}
|
|
204
220
|
if (missing.length > 0 && eligible.length === 0) {
|
|
205
|
-
|
|
221
|
+
if (requireEnrichedOracles || !onlyEnrichedMissing)
|
|
222
|
+
skipped.set(check, missing.join(', '));
|
|
206
223
|
continue;
|
|
207
224
|
}
|
|
208
225
|
// a scan spends most of its time here, so Ctrl-C has to reach this half
|
|
@@ -211,7 +228,6 @@ export async function review(opts) {
|
|
|
211
228
|
break;
|
|
212
229
|
}
|
|
213
230
|
ran++;
|
|
214
|
-
const check = v.id ?? v.name;
|
|
215
231
|
manifest?.ran(check);
|
|
216
232
|
for (const file of eligible)
|
|
217
233
|
plan.checked(file.path, check);
|
|
@@ -227,6 +243,10 @@ export async function review(opts) {
|
|
|
227
243
|
const names = [...skipped].map(([n, why]) => n + ' (no ' + why + ')');
|
|
228
244
|
say('skipped ' + names.join(', '));
|
|
229
245
|
}
|
|
246
|
+
if (unavailable.size > 0) {
|
|
247
|
+
const names = [...unavailable].map(([n, why]) => n + ' (no ' + why + ')');
|
|
248
|
+
say('coverage portable · enriched checks unavailable: ' + names.join(', '));
|
|
249
|
+
}
|
|
230
250
|
// --checks overrides the config rather than filtering it
|
|
231
251
|
const isGated = range.from !== undefined || range.commit !== undefined;
|
|
232
252
|
const judgeCache = opts.cache === false || verifyOnly ? undefined : JudgeCache.open(repo, isGated);
|
|
@@ -348,6 +368,7 @@ export async function review(opts) {
|
|
|
348
368
|
failures,
|
|
349
369
|
plan,
|
|
350
370
|
skippedChecks: [...skipped].map(([check, missing]) => ({ check, missing })),
|
|
371
|
+
unavailableChecks: [...unavailable].map(([check, missing]) => ({ check, missing })),
|
|
351
372
|
usage: budget.finish(),
|
|
352
373
|
budgetStop,
|
|
353
374
|
cancelled: opts.signal?.aborted ?? false,
|