@0xcraft/powershot 1.0.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/LICENSE +202 -0
- package/README.md +306 -0
- package/dist/agents.js +82 -0
- package/dist/bench.js +179 -0
- package/dist/budget.js +59 -0
- package/dist/bundle.js +173 -0
- package/dist/cache.js +155 -0
- package/dist/cli/agent-command.js +27 -0
- package/dist/cli/app.js +31 -0
- package/dist/cli/args.js +76 -0
- package/dist/cli/bench-command.js +89 -0
- package/dist/cli/dismiss-command.js +42 -0
- package/dist/cli/environment.js +32 -0
- package/dist/cli/reports.js +64 -0
- package/dist/cli/review-command.js +268 -0
- package/dist/cli/session-command.js +62 -0
- package/dist/cli.js +7 -0
- package/dist/config.js +130 -0
- package/dist/delegate.js +84 -0
- package/dist/dismissed.js +130 -0
- package/dist/fspolicy.js +62 -0
- package/dist/git.js +238 -0
- package/dist/ground.js +286 -0
- package/dist/judges/judge.js +85 -0
- package/dist/judges/llm.js +234 -0
- package/dist/judges/prompts.js +86 -0
- package/dist/judges/tools.js +125 -0
- package/dist/lang/packs.js +557 -0
- package/dist/lang/pyright.js +108 -0
- package/dist/lang/python-deps.js +174 -0
- package/dist/lang/ruby-deps.js +77 -0
- package/dist/langtest.js +248 -0
- package/dist/manifest.js +209 -0
- package/dist/otel.js +75 -0
- package/dist/package-meta.js +13 -0
- package/dist/package-smoke.js +110 -0
- package/dist/plan.js +134 -0
- package/dist/position.js +94 -0
- package/dist/report/ansi.js +18 -0
- package/dist/report/codequality.js +19 -0
- package/dist/report/compact.js +15 -0
- package/dist/report/highlight.js +54 -0
- package/dist/report/markdown.js +113 -0
- package/dist/report/sarif.js +66 -0
- package/dist/report/terminal.js +170 -0
- package/dist/report/viewer.js +148 -0
- package/dist/review.js +355 -0
- package/dist/scan.js +67 -0
- package/dist/selftest.js +1928 -0
- package/dist/session.js +140 -0
- package/dist/snapshot.js +101 -0
- package/dist/text.js +50 -0
- package/dist/types.js +2 -0
- package/dist/verifiers/assertion-drift.js +137 -0
- package/dist/verifiers/contract-drift.js +140 -0
- package/dist/verifiers/copy-paste-drift.js +106 -0
- package/dist/verifiers/dead-on-arrival.js +92 -0
- package/dist/verifiers/dropped-guard.js +144 -0
- package/dist/verifiers/foreign-contract-drift.js +114 -0
- package/dist/verifiers/foreign-copy-paste-drift.js +83 -0
- package/dist/verifiers/foreign-dropped-guard.js +78 -0
- package/dist/verifiers/foreign-phantom-api.js +36 -0
- package/dist/verifiers/foreign-phantom-config.js +40 -0
- package/dist/verifiers/foreign-phantom-dep.js +82 -0
- package/dist/verifiers/foreign-reinvented.js +65 -0
- package/dist/verifiers/foreign-scope-creep.js +42 -0
- package/dist/verifiers/foreign-swallowed-error.js +36 -0
- package/dist/verifiers/foreign-tests.js +143 -0
- package/dist/verifiers/foreign-tokens.js +94 -0
- package/dist/verifiers/foreign.js +16 -0
- package/dist/verifiers/index.js +38 -0
- package/dist/verifiers/lying-comment.js +90 -0
- package/dist/verifiers/phantom-api.js +88 -0
- package/dist/verifiers/phantom-config.js +93 -0
- package/dist/verifiers/phantom-dep.js +110 -0
- package/dist/verifiers/reinvented.js +74 -0
- package/dist/verifiers/scope-creep.js +77 -0
- package/dist/verifiers/swallowed-error.js +110 -0
- package/dist/verifiers/vacuous-test.js +138 -0
- package/docs/architecture.md +191 -0
- package/docs/assets/cli-preview.svg +68 -0
- package/docs/assets/powershot-logo.png +0 -0
- package/docs/ci.md +151 -0
- package/examples/github-actions/action.yml +23 -0
- package/examples/github-actions/cli.yml +43 -0
- package/examples/gitlab/.gitlab-ci.yml +21 -0
- package/package.json +65 -0
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
export const HELP = `
|
|
3
|
+
psh — code review for machine-written code
|
|
4
|
+
|
|
5
|
+
psh review staged + unstaged + untracked
|
|
6
|
+
psh review --from main --to feat/x branch range
|
|
7
|
+
psh review --commit <hash> a single commit
|
|
8
|
+
psh scan <path> audit existing files, no git history needed
|
|
9
|
+
psh delegate emit the judging work for an agent you already pay for
|
|
10
|
+
psh session list | view <id> runs that can be resumed, or replayed as a page
|
|
11
|
+
psh session diff <a> <b> what one review found that the other did not
|
|
12
|
+
psh dismiss <id> [--reason "..."] record that a finding is correct, and stop showing it
|
|
13
|
+
psh dismiss list | restore <fp> what has been waved through, and undoing it
|
|
14
|
+
psh agent <name> | list teach a coding agent to use this tool
|
|
15
|
+
psh bench --repo <path> replay real commits and count what the checks say
|
|
16
|
+
|
|
17
|
+
Options
|
|
18
|
+
--verify-only deterministic checks only — no model, no tokens
|
|
19
|
+
--checks a,b run only these checks
|
|
20
|
+
--min-severity <s> info | low | medium | high | critical (default: low)
|
|
21
|
+
--format <f> text · compact · markdown · json · sarif · codequality · manifest
|
|
22
|
+
--absorb <file> merge findings a delegated agent produced
|
|
23
|
+
--last <n> bench: how many commits to replay (default 50)
|
|
24
|
+
--cases <dir> bench: labelled .json cases to score against
|
|
25
|
+
--tools let judges read files and search before answering
|
|
26
|
+
--resume <id> continue a run, reusing judge results already paid for
|
|
27
|
+
--output <path> write the report to a file instead of stdout
|
|
28
|
+
--report <f>=<path> write this format to that file; repeatable, one run
|
|
29
|
+
--budget k=v,... requests · inputTokens · outputTokens · toolCalls · elapsedMs · units
|
|
30
|
+
--no-cache ask every judge again, ignoring answers already paid for
|
|
31
|
+
--max-bundle <n> lines per judge prompt (default 1200)
|
|
32
|
+
--help
|
|
33
|
+
|
|
34
|
+
Exit codes: 0 clean · 1 findings reported · 2 bad usage · 3 review incomplete.
|
|
35
|
+
A stage that failed makes the run non-zero even with nothing to report — "we found
|
|
36
|
+
nothing" and "we could not look" must not be the same answer to a pipeline.
|
|
37
|
+
`;
|
|
38
|
+
export function parseCliArgs(args = process.argv.slice(2)) {
|
|
39
|
+
return parseArgs({
|
|
40
|
+
args,
|
|
41
|
+
allowPositionals: true,
|
|
42
|
+
options: {
|
|
43
|
+
from: { type: 'string' },
|
|
44
|
+
to: { type: 'string' },
|
|
45
|
+
commit: { type: 'string' },
|
|
46
|
+
format: { type: 'string', default: 'text' },
|
|
47
|
+
checks: { type: 'string' },
|
|
48
|
+
'min-severity': { type: 'string' },
|
|
49
|
+
'verify-only': { type: 'boolean', default: false },
|
|
50
|
+
repo: { type: 'string' },
|
|
51
|
+
last: { type: 'string' },
|
|
52
|
+
cases: { type: 'string' },
|
|
53
|
+
tools: { type: 'boolean', default: false },
|
|
54
|
+
resume: { type: 'string' },
|
|
55
|
+
output: { type: 'string' },
|
|
56
|
+
'no-cache': { type: 'boolean', default: false },
|
|
57
|
+
report: { type: 'string', multiple: true },
|
|
58
|
+
budget: { type: 'string' },
|
|
59
|
+
'show-dismissed': { type: 'boolean', default: false },
|
|
60
|
+
reason: { type: 'string' },
|
|
61
|
+
'max-bundle': { type: 'string' },
|
|
62
|
+
absorb: { type: 'string' },
|
|
63
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export function positiveNumber(value, flag, fallback) {
|
|
68
|
+
if (value === undefined)
|
|
69
|
+
return fallback;
|
|
70
|
+
const parsed = Number(value);
|
|
71
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
72
|
+
return parsed;
|
|
73
|
+
process.stderr.write(flag + ' must be a positive number, got "' + String(value) + '"\n');
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=args.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { f1, loadCases, precision, recall, replayRepo, runCases } from '#app/bench.js';
|
|
2
|
+
import { repoRoot } from '#app/git.js';
|
|
3
|
+
import { dim, bold, steel, yellow } from '#app/report/ansi.js';
|
|
4
|
+
import { positiveNumber } from './args.js';
|
|
5
|
+
function percentage(value) {
|
|
6
|
+
return (value * 100).toFixed(1) + '%';
|
|
7
|
+
}
|
|
8
|
+
function total(scores) {
|
|
9
|
+
return scores.reduce((sum, score) => ({ tp: sum.tp + score.tp, fp: sum.fp + score.fp, fn: sum.fn + score.fn }), { tp: 0, fp: 0, fn: 0 });
|
|
10
|
+
}
|
|
11
|
+
export async function runBenchCommand(values) {
|
|
12
|
+
const root = values.repo !== undefined ? repoRoot(values.repo) : repoRoot(process.cwd());
|
|
13
|
+
const out = process.stdout;
|
|
14
|
+
if (values.cases !== undefined) {
|
|
15
|
+
const cases = loadCases(values.cases);
|
|
16
|
+
if (cases.length === 0) {
|
|
17
|
+
process.stderr.write('No .json cases found in ' + values.cases + '\n');
|
|
18
|
+
return 2;
|
|
19
|
+
}
|
|
20
|
+
const reports = await runCases(root, cases);
|
|
21
|
+
out.write('\n ' + bold('PowerShot bench') + dim(' · ' + cases.length + ' labelled case(s)') + '\n\n');
|
|
22
|
+
for (const report of reports) {
|
|
23
|
+
if (!report.complete) {
|
|
24
|
+
out.write(' ' + yellow(report.state) + ' ' + report.name + dim(' ' + report.reasons.join('; ')) + '\n');
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const exact = report.score.fp === 0 && report.score.fn === 0;
|
|
28
|
+
out.write(' ' + (exact ? steel('pass') : yellow('diff')) + ' ' + report.name +
|
|
29
|
+
dim(' tp ' + report.score.tp + ' · fp ' + report.score.fp + ' · fn ' + report.score.fn) + '\n');
|
|
30
|
+
}
|
|
31
|
+
const complete = reports.filter((report) => report.complete);
|
|
32
|
+
const score = total(complete.map((report) => report.score));
|
|
33
|
+
if (complete.length > 0) {
|
|
34
|
+
out.write('\n ' + bold('precision ' + percentage(precision(score))) + dim(' · ') +
|
|
35
|
+
'recall ' + percentage(recall(score)) + dim(' · ') + 'F1 ' + percentage(f1(score)) + '\n\n');
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
out.write('\n ' + yellow('No complete case could be scored.') + '\n\n');
|
|
39
|
+
}
|
|
40
|
+
if (complete.length !== reports.length)
|
|
41
|
+
return 3;
|
|
42
|
+
return score.fp + score.fn > 0 ? 1 : 0;
|
|
43
|
+
}
|
|
44
|
+
const last = positiveNumber(values.last, '--last', 50);
|
|
45
|
+
if (last === undefined)
|
|
46
|
+
return 2;
|
|
47
|
+
process.stderr.write(dim(' ◇ replaying ' + last + ' commit(s) from ' + root) + '\n');
|
|
48
|
+
const report = await replayRepo(root, last, (line) => process.stderr.write(dim(' ◇ ' + line) + '\n'));
|
|
49
|
+
out.write('\n ' + bold('PowerShot bench') + dim(' · ' + report.commits + ' complete commit(s)') + '\n');
|
|
50
|
+
out.write(dim(' ' + '─'.repeat(70)) + '\n\n');
|
|
51
|
+
if (report.findings === 0 && report.partial === 0 && report.failed === 0 && report.falseClean === 0) {
|
|
52
|
+
out.write(' ' + steel('✔') + ' No findings across ' + report.commits + ' reviewed commits.\n');
|
|
53
|
+
out.write(dim(' On already-merged history that is the result to want: the checks stayed quiet.') + '\n\n');
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
out.write(' ' + report.findings + ' finding(s) on ' + report.noisy.length + ' of ' + report.commits + ' complete commits\n');
|
|
57
|
+
out.write(dim(' ' + report.verified + ' verified · ' + report.judged + ' judged' +
|
|
58
|
+
(report.positions.added + report.positions.context > 0
|
|
59
|
+
? ' · judged on a changed line: ' + report.positions.added + '/' + (report.positions.added + report.positions.context)
|
|
60
|
+
: '')) + '\n');
|
|
61
|
+
const coverage = report.coverage;
|
|
62
|
+
out.write(dim(' coverage ' + coverage.selected + ' reviewed · ' + coverage.limited + ' limited · ' +
|
|
63
|
+
coverage.waived + ' waived · ' + coverage.failed + ' failed') + '\n');
|
|
64
|
+
out.write((report.falseClean > 0 ? yellow(' ' + report.falseClean + ' commit(s) reported clean by an incomplete run')
|
|
65
|
+
: dim(' no commit was reported clean by an incomplete run')) + '\n');
|
|
66
|
+
if (report.usage.requests > 0) {
|
|
67
|
+
out.write(dim(' ' + report.usage.requests + ' request(s) · ' + report.usage.inputTokens + ' in · ' +
|
|
68
|
+
report.usage.outputTokens + ' out · ' + Math.round(report.usage.elapsedMs / 1000) + 's') + '\n');
|
|
69
|
+
}
|
|
70
|
+
out.write(dim(' Already-merged code — treat each as a likely false positive until read.') + '\n\n');
|
|
71
|
+
const rows = [...report.byCheck.entries()].sort((left, right) => right[1] - left[1]);
|
|
72
|
+
for (const [check, count] of rows) {
|
|
73
|
+
out.write(' ' + String(count).padStart(4) + ' ' + check + dim(' ' + (count / report.commits).toFixed(2) + ' per commit') + '\n');
|
|
74
|
+
}
|
|
75
|
+
out.write('\n');
|
|
76
|
+
for (const commit of report.noisy.slice(0, 12)) {
|
|
77
|
+
out.write(dim(' ' + commit.commit + ' ' + commit.subject.slice(0, 58)) + '\n');
|
|
78
|
+
for (const finding of commit.findings.slice(0, 4)) {
|
|
79
|
+
out.write(' ' + yellow(finding.check) + ' ' + dim(finding.file + ':' + finding.line) + ' ' + finding.title.slice(0, 60) + '\n');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (report.partial > 0)
|
|
83
|
+
out.write(dim('\n ' + report.partial + ' commit(s) were partial and not scored') + '\n');
|
|
84
|
+
if (report.failed > 0)
|
|
85
|
+
out.write(dim(' ' + report.failed + ' commit(s) failed and were not scored') + '\n');
|
|
86
|
+
out.write('\n');
|
|
87
|
+
return report.partial > 0 || report.failed > 0 || report.falseClean > 0 ? 3 : 0;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=bench-command.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Dismissals, lastReport } from '#app/dismissed.js';
|
|
2
|
+
import { repoRoot } from '#app/git.js';
|
|
3
|
+
import { dim, bold, steel } from '#app/report/ansi.js';
|
|
4
|
+
export function runDismissCommand(positionals, reason) {
|
|
5
|
+
const root = repoRoot(process.cwd());
|
|
6
|
+
const dismissals = Dismissals.open(root);
|
|
7
|
+
const subject = positionals[1];
|
|
8
|
+
if (!subject || subject === 'list') {
|
|
9
|
+
const all = dismissals.list();
|
|
10
|
+
if (all.length === 0) {
|
|
11
|
+
process.stdout.write(dim('\n Nothing has been dismissed.\n\n'));
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
process.stdout.write('\n ' + bold('Dismissed') + dim(' · committed, so the team shares it') + '\n\n');
|
|
15
|
+
for (const dismissal of all) {
|
|
16
|
+
process.stdout.write(' ' + steel(dismissal.fingerprint.slice(0, 8)) + ' ' + dismissal.check + dim(' · ' + dismissal.file) + '\n');
|
|
17
|
+
process.stdout.write(dim(' ' + dismissal.code.slice(0, 68)) + '\n');
|
|
18
|
+
if (dismissal.reason)
|
|
19
|
+
process.stdout.write(dim(' because: ' + dismissal.reason) + '\n');
|
|
20
|
+
}
|
|
21
|
+
process.stdout.write('\n' + dim(' psh dismiss restore <fingerprint>') + '\n\n');
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
if (subject === 'restore') {
|
|
25
|
+
const fingerprint = positionals[2];
|
|
26
|
+
if (!fingerprint || !dismissals.remove(fingerprint)) {
|
|
27
|
+
process.stderr.write('No dismissal starting with "' + (fingerprint ?? '') + '". Try: psh dismiss list\n');
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
process.stdout.write(dim(' restored — it will be reported again\n'));
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
const finding = lastReport(root).find((item) => item.id.toLowerCase() === subject.toLowerCase());
|
|
34
|
+
if (!finding) {
|
|
35
|
+
process.stderr.write('No finding "' + subject + '" in the last review. Run psh review first.\n');
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
const added = dismissals.add(finding, reason, new Date().toISOString());
|
|
39
|
+
process.stdout.write((added ? ' dismissed ' : ' already dismissed ') + finding.check + ' at ' + finding.file + ':' + finding.line + '\n');
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=dismiss-command.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { repoRoot } from '#app/git.js';
|
|
3
|
+
import { dim, yellow } from '#app/report/ansi.js';
|
|
4
|
+
const REPOSITORY_ENV = /_API_KEY$/;
|
|
5
|
+
export function loadRepositoryEnv() {
|
|
6
|
+
try {
|
|
7
|
+
if (process.env.CI)
|
|
8
|
+
return;
|
|
9
|
+
const inherited = { ...process.env };
|
|
10
|
+
process.loadEnvFile(join(repoRoot(process.cwd()), '.env'));
|
|
11
|
+
const refused = [];
|
|
12
|
+
for (const key of Object.keys(process.env)) {
|
|
13
|
+
if (Object.prototype.hasOwnProperty.call(inherited, key))
|
|
14
|
+
continue;
|
|
15
|
+
if (REPOSITORY_ENV.test(key))
|
|
16
|
+
continue;
|
|
17
|
+
delete process.env[key];
|
|
18
|
+
refused.push(key);
|
|
19
|
+
}
|
|
20
|
+
for (const [key, value] of Object.entries(inherited)) {
|
|
21
|
+
if (value !== undefined)
|
|
22
|
+
process.env[key] = value;
|
|
23
|
+
}
|
|
24
|
+
if (refused.length > 0) {
|
|
25
|
+
process.stderr.write(yellow(' ◇ .env') + dim(' ignored ' + refused.join(', ') + ' — only *_API_KEY may come from the repository') + '\n');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// A missing .env and a directory outside Git are both normal startup states.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=environment.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { dim } from '#app/report/ansi.js';
|
|
3
|
+
import { codeQuality } from '#app/report/codequality.js';
|
|
4
|
+
import { compact } from '#app/report/compact.js';
|
|
5
|
+
import { markdown } from '#app/report/markdown.js';
|
|
6
|
+
import { sarif } from '#app/report/sarif.js';
|
|
7
|
+
import { terminal } from '#app/report/terminal.js';
|
|
8
|
+
export const REPORT_FORMATS = ['text', 'compact', 'markdown', 'json', 'sarif', 'codequality', 'manifest'];
|
|
9
|
+
export function isReportFormat(value) {
|
|
10
|
+
return REPORT_FORMATS.includes(value);
|
|
11
|
+
}
|
|
12
|
+
export function parseReportSpecs(values = []) {
|
|
13
|
+
const reports = [];
|
|
14
|
+
for (const spec of values) {
|
|
15
|
+
const separator = spec.indexOf('=');
|
|
16
|
+
const format = separator === -1 ? spec : spec.slice(0, separator);
|
|
17
|
+
if (!isReportFormat(format))
|
|
18
|
+
return '--report format must be one of: ' + REPORT_FORMATS.join(', ');
|
|
19
|
+
const path = separator === -1 ? '' : spec.slice(separator + 1);
|
|
20
|
+
if (!path)
|
|
21
|
+
return '--report needs a path: --report ' + format + '=<file>';
|
|
22
|
+
reports.push({ format, path });
|
|
23
|
+
}
|
|
24
|
+
return reports;
|
|
25
|
+
}
|
|
26
|
+
function jsonResult(result) {
|
|
27
|
+
return JSON.stringify(result, (_key, value) => (value instanceof Set ? [...value] : value), 2) + '\n';
|
|
28
|
+
}
|
|
29
|
+
export function renderReport(format, result, manifest, target) {
|
|
30
|
+
if (format === 'manifest')
|
|
31
|
+
return JSON.stringify(manifest, null, 2) + '\n';
|
|
32
|
+
if (format === 'compact')
|
|
33
|
+
return compact(result.findings);
|
|
34
|
+
if (format === 'codequality')
|
|
35
|
+
return codeQuality(result.findings);
|
|
36
|
+
if (format === 'sarif')
|
|
37
|
+
return sarif(result.findings);
|
|
38
|
+
if (format === 'json')
|
|
39
|
+
return jsonResult(result);
|
|
40
|
+
if (format === 'markdown')
|
|
41
|
+
return markdown(result.findings, manifest);
|
|
42
|
+
return terminal(result.findings, {
|
|
43
|
+
subtitle: target,
|
|
44
|
+
...result.stats,
|
|
45
|
+
state: manifest.state,
|
|
46
|
+
notLookedAt: manifest.notLookedAt,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function publishReports(options) {
|
|
50
|
+
const { result, manifest, target } = options;
|
|
51
|
+
for (const report of options.additional) {
|
|
52
|
+
writeFileSync(report.path, renderReport(report.format, result, manifest, target));
|
|
53
|
+
process.stderr.write(dim(' written to ' + report.path) + '\n');
|
|
54
|
+
}
|
|
55
|
+
const primary = renderReport(options.format, result, manifest, target);
|
|
56
|
+
if (options.output !== undefined) {
|
|
57
|
+
writeFileSync(options.output, primary);
|
|
58
|
+
process.stderr.write(dim(' written to ' + options.output) + '\n');
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
process.stdout.write(primary);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=reports.js.map
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { Budget, parseLimits } from '#app/budget.js';
|
|
4
|
+
import { loadConfig, policyChanged } from '#app/config.js';
|
|
5
|
+
import { absorbDelegated, delegateBrief } from '#app/delegate.js';
|
|
6
|
+
import { baseRefOf, checkRange, headSha, repoRoot, shaOf } from '#app/git.js';
|
|
7
|
+
import { JUDGES } from '#app/judges/prompts.js';
|
|
8
|
+
import { RunManifest, coverageProblems, hashOf, writeManifest } from '#app/manifest.js';
|
|
9
|
+
import { Trace } from '#app/otel.js';
|
|
10
|
+
import { PACKAGE_VERSION } from '#app/package-meta.js';
|
|
11
|
+
import { dim, yellow } from '#app/report/ansi.js';
|
|
12
|
+
import { progress, stage } from '#app/report/terminal.js';
|
|
13
|
+
import { review } from '#app/review.js';
|
|
14
|
+
import { scanPaths } from '#app/scan.js';
|
|
15
|
+
import { Session } from '#app/session.js';
|
|
16
|
+
import { withTargetTree } from '#app/snapshot.js';
|
|
17
|
+
import { SEVERITIES } from '#app/types.js';
|
|
18
|
+
import { VERIFIERS } from '#app/verifiers/index.js';
|
|
19
|
+
import { positiveNumber } from './args.js';
|
|
20
|
+
import { REPORT_FORMATS, isReportFormat, parseReportSpecs, publishReports, } from './reports.js';
|
|
21
|
+
function targetName(command, values, positionals) {
|
|
22
|
+
if (command === 'scan')
|
|
23
|
+
return 'scan ' + (positionals[1] ?? '.');
|
|
24
|
+
if (values.commit)
|
|
25
|
+
return 'commit ' + values.commit;
|
|
26
|
+
if (values.from)
|
|
27
|
+
return values.from + '...' + values.to;
|
|
28
|
+
return 'workspace';
|
|
29
|
+
}
|
|
30
|
+
export async function runReviewCommand(command, values, positionals) {
|
|
31
|
+
if (!isReportFormat(values.format)) {
|
|
32
|
+
process.stderr.write('--format must be one of: ' + REPORT_FORMATS.join(', ') + '\n');
|
|
33
|
+
return 2;
|
|
34
|
+
}
|
|
35
|
+
if ((values.from && !values.to) || (values.to && !values.from)) {
|
|
36
|
+
process.stderr.write('--from and --to must be given together.\n');
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
const root = repoRoot(process.cwd());
|
|
40
|
+
const range = { from: values.from, to: values.to, commit: values.commit };
|
|
41
|
+
// Ref validation must precede policy loading because the policy is read through Git.
|
|
42
|
+
try {
|
|
43
|
+
checkRange(root, range);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
process.stderr.write(error.message + '\n');
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
const gated = values.from !== undefined || values.commit !== undefined;
|
|
50
|
+
const policyBase = gated ? baseRefOf(root, range) : undefined;
|
|
51
|
+
const config = loadConfig(root, {
|
|
52
|
+
verifiers: [...new Set(VERIFIERS.flatMap((verifier) => [verifier.name, verifier.id ?? verifier.name]))],
|
|
53
|
+
judges: JUDGES.map((judge) => judge.name),
|
|
54
|
+
}, policyBase);
|
|
55
|
+
const verifierChecks = new Set(VERIFIERS.flatMap((verifier) => [verifier.name, verifier.id ?? verifier.name]));
|
|
56
|
+
const judgeChecks = new Set(JUDGES.map((judge) => judge.name));
|
|
57
|
+
const requestedChecks = values.checks?.split(',').map((check) => check.trim()).filter(Boolean);
|
|
58
|
+
if (values.checks !== undefined && requestedChecks?.length === 0) {
|
|
59
|
+
process.stderr.write('--checks must name at least one check\n');
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
if (requestedChecks) {
|
|
63
|
+
const knownChecks = new Set([...verifierChecks, ...judgeChecks]);
|
|
64
|
+
const unknown = requestedChecks.filter((name) => !knownChecks.has(name));
|
|
65
|
+
if (unknown.length > 0) {
|
|
66
|
+
process.stderr.write('--checks has unknown name(s): ' + unknown.join(', ') + '\n');
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
if (command === 'delegate') {
|
|
70
|
+
const deterministic = requestedChecks.filter((name) => !judgeChecks.has(name));
|
|
71
|
+
if (deterministic.length > 0) {
|
|
72
|
+
process.stderr.write('delegate accepts judge names only, not: ' + deterministic.join(', ') + '\n');
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (values['verify-only']) {
|
|
77
|
+
const judges = requestedChecks.filter((name) => judgeChecks.has(name) && !verifierChecks.has(name));
|
|
78
|
+
if (judges.length > 0) {
|
|
79
|
+
process.stderr.write('--verify-only cannot run judge check(s): ' + judges.join(', ') + '\n');
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (policyBase && policyChanged(root, policyBase)) {
|
|
85
|
+
process.stderr.write(yellow(' ◇ policy') + dim(' this change edits powershot.config.json — reviewed under the base policy') + '\n');
|
|
86
|
+
}
|
|
87
|
+
if (values['min-severity']) {
|
|
88
|
+
const minimum = values['min-severity'];
|
|
89
|
+
if (!SEVERITIES.includes(minimum)) {
|
|
90
|
+
process.stderr.write('--min-severity must be one of: ' + SEVERITIES.join(', ') + '\n');
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
config.minSeverity = minimum;
|
|
94
|
+
}
|
|
95
|
+
const bundleLimit = positiveNumber(values['max-bundle'], '--max-bundle', 1200);
|
|
96
|
+
if (bundleLimit === undefined)
|
|
97
|
+
return 2;
|
|
98
|
+
const maxBundle = values['max-bundle'] === undefined ? undefined : bundleLimit;
|
|
99
|
+
const reportSpecs = parseReportSpecs(values.report);
|
|
100
|
+
if (typeof reportSpecs === 'string') {
|
|
101
|
+
process.stderr.write(reportSpecs + '\n');
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
if (command === 'delegate' && values.absorb !== undefined) {
|
|
105
|
+
process.stderr.write('--absorb cannot be used with delegate\n');
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
// Invalid delegated output must not leave behind a resumable session.
|
|
109
|
+
const absorbed = values.absorb !== undefined
|
|
110
|
+
? absorbDelegated(readFileSync(values.absorb, 'utf8'))
|
|
111
|
+
: undefined;
|
|
112
|
+
let limits = {};
|
|
113
|
+
if (values.budget !== undefined) {
|
|
114
|
+
const parsed = parseLimits(values.budget);
|
|
115
|
+
if (typeof parsed === 'string') {
|
|
116
|
+
process.stderr.write(parsed + '\n');
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
limits = parsed;
|
|
120
|
+
}
|
|
121
|
+
const quiet = values.format === 'json' || values.format === 'sarif' || values.format === 'compact';
|
|
122
|
+
const target = targetName(command, values, positionals);
|
|
123
|
+
let session;
|
|
124
|
+
if (values.resume !== undefined) {
|
|
125
|
+
session = Session.open(root, values.resume);
|
|
126
|
+
if (!session) {
|
|
127
|
+
process.stderr.write('No session ' + values.resume + '. Try: psh session list\n');
|
|
128
|
+
return 2;
|
|
129
|
+
}
|
|
130
|
+
if (!session.askedBy(config.provider, config.model)) {
|
|
131
|
+
const previous = session.asked;
|
|
132
|
+
process.stderr.write('Session ' + values.resume + ' was judged by ' + previous?.provider + '/' + previous?.model +
|
|
133
|
+
', and this run would use ' + config.provider + '/' + config.model + '.\n' +
|
|
134
|
+
'Resuming would mix two reviewers into one report. Start a fresh run, or switch back.\n');
|
|
135
|
+
return 2;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (!values['verify-only'] && command !== 'delegate') {
|
|
139
|
+
session = Session.create(root, target, { provider: config.provider, model: config.model });
|
|
140
|
+
}
|
|
141
|
+
if (command === 'delegate') {
|
|
142
|
+
const { buildGround } = await import('#app/ground.js');
|
|
143
|
+
const { collectChanges, statedIntent } = await import('#app/git.js');
|
|
144
|
+
const { matchesAny } = await import('#app/config.js');
|
|
145
|
+
const changes = collectChanges(root, range).filter((change) => !matchesAny(change.path, config.ignore));
|
|
146
|
+
if (changes.length === 0) {
|
|
147
|
+
process.stderr.write('Nothing to review.\n');
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
const ground = await withTargetTree(root, range, (tree) => buildGround(tree, changes));
|
|
151
|
+
process.stdout.write(delegateBrief(ground, config, {
|
|
152
|
+
intent: statedIntent(root, range),
|
|
153
|
+
maxBundleLines: maxBundle,
|
|
154
|
+
checks: requestedChecks,
|
|
155
|
+
}));
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
const canceller = new AbortController();
|
|
159
|
+
let interrupted = false;
|
|
160
|
+
const onInterrupt = () => {
|
|
161
|
+
if (interrupted)
|
|
162
|
+
process.exit(130);
|
|
163
|
+
interrupted = true;
|
|
164
|
+
canceller.abort();
|
|
165
|
+
process.stderr.write(dim('\n ◇ stopping after the current unit — press Ctrl-C again to exit now') + '\n');
|
|
166
|
+
};
|
|
167
|
+
const manifest = new RunManifest(session?.id ?? createHash('sha1').update(target + ':' + Date.now()).digest('hex').slice(0, 8));
|
|
168
|
+
const trace = new Trace();
|
|
169
|
+
const runSpan = trace.span('review', { target });
|
|
170
|
+
const result = await withTargetTree(root, command === 'scan' ? {} : range, (tree) => review({
|
|
171
|
+
root: tree,
|
|
172
|
+
stateRoot: root,
|
|
173
|
+
range,
|
|
174
|
+
changes: command === 'scan' ? scanPaths(root, positionals[1] ?? '.') : undefined,
|
|
175
|
+
config,
|
|
176
|
+
session,
|
|
177
|
+
tools: values.tools,
|
|
178
|
+
maxBundleLines: maxBundle,
|
|
179
|
+
signal: canceller.signal,
|
|
180
|
+
cache: !values['no-cache'],
|
|
181
|
+
budget: new Budget(limits),
|
|
182
|
+
manifest,
|
|
183
|
+
showDismissed: values['show-dismissed'],
|
|
184
|
+
absorbed,
|
|
185
|
+
onCancelable: (active) => {
|
|
186
|
+
if (!active) {
|
|
187
|
+
process.off('SIGINT', onInterrupt);
|
|
188
|
+
process.off('SIGTERM', onInterrupt);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
process.on('SIGINT', onInterrupt);
|
|
192
|
+
process.on('SIGTERM', onInterrupt);
|
|
193
|
+
},
|
|
194
|
+
verifyOnly: values['verify-only'],
|
|
195
|
+
checks: requestedChecks,
|
|
196
|
+
onProgress: quiet ? undefined : (line) => process.stderr.write(progress(line) + '\n'),
|
|
197
|
+
onStage: quiet ? undefined : stage,
|
|
198
|
+
}));
|
|
199
|
+
runSpan({
|
|
200
|
+
findings: result.findings.length,
|
|
201
|
+
verified: result.stats.verified,
|
|
202
|
+
judged: result.stats.judged,
|
|
203
|
+
files: result.stats.files,
|
|
204
|
+
});
|
|
205
|
+
await trace.flush();
|
|
206
|
+
const record = manifest.build({
|
|
207
|
+
operation: command,
|
|
208
|
+
repositoryHead: headSha(root),
|
|
209
|
+
target: {
|
|
210
|
+
requested: { from: values.from, to: values.to, commit: values.commit },
|
|
211
|
+
base: policyBase ? shaOf(root, policyBase) : undefined,
|
|
212
|
+
head: headSha(root),
|
|
213
|
+
},
|
|
214
|
+
policy: {
|
|
215
|
+
source: policyBase ? 'base' : 'head',
|
|
216
|
+
ref: policyBase,
|
|
217
|
+
hash: hashOf(JSON.stringify(config)),
|
|
218
|
+
},
|
|
219
|
+
engine: {
|
|
220
|
+
version: PACKAGE_VERSION,
|
|
221
|
+
provider: config.provider,
|
|
222
|
+
model: config.model,
|
|
223
|
+
tools: values.tools,
|
|
224
|
+
verifyOnly: values['verify-only'],
|
|
225
|
+
},
|
|
226
|
+
files: result.plan?.items() ?? [],
|
|
227
|
+
skippedChecks: result.skippedChecks ?? [],
|
|
228
|
+
findings: {
|
|
229
|
+
total: result.findings.length,
|
|
230
|
+
verified: result.stats.verified,
|
|
231
|
+
judged: result.stats.judged,
|
|
232
|
+
dismissed: result.stats.dismissed,
|
|
233
|
+
droppedPosition: result.droppedPosition ?? 0,
|
|
234
|
+
},
|
|
235
|
+
usage: result.usage ?? { requests: 0, inputTokens: 0, outputTokens: 0, toolCalls: 0, elapsedMs: 0, units: 0 },
|
|
236
|
+
failures: result.failures,
|
|
237
|
+
cancelled: result.cancelled,
|
|
238
|
+
budgetStop: result.budgetStop,
|
|
239
|
+
});
|
|
240
|
+
const gaps = coverageProblems(record);
|
|
241
|
+
if (gaps.length > 0) {
|
|
242
|
+
const failure = 'manifest: ' + gaps.join('; ');
|
|
243
|
+
result.failures.push(failure);
|
|
244
|
+
record.state = 'failed';
|
|
245
|
+
record.failures.push(failure);
|
|
246
|
+
record.notLookedAt.push(failure);
|
|
247
|
+
process.stderr.write(yellow(' ◇ manifest') + dim(' ' + gaps.join('; ')) + '\n');
|
|
248
|
+
}
|
|
249
|
+
session?.saveReport(result.findings, { state: record.state, notLookedAt: record.notLookedAt });
|
|
250
|
+
writeManifest(root, record);
|
|
251
|
+
publishReports({
|
|
252
|
+
format: values.format,
|
|
253
|
+
output: values.output,
|
|
254
|
+
additional: reportSpecs,
|
|
255
|
+
result,
|
|
256
|
+
manifest: record,
|
|
257
|
+
target,
|
|
258
|
+
});
|
|
259
|
+
if (values.format === 'text' && session && !values['verify-only']) {
|
|
260
|
+
process.stderr.write(dim(' session ' + session.id + ' · psh review --resume ' + session.id) + '\n');
|
|
261
|
+
}
|
|
262
|
+
if (interrupted)
|
|
263
|
+
return 130;
|
|
264
|
+
if (result.failures.length > 0 || gaps.length > 0 || record.state !== 'complete')
|
|
265
|
+
return 3;
|
|
266
|
+
return result.findings.length > 0 ? 1 : 0;
|
|
267
|
+
}
|
|
268
|
+
//# sourceMappingURL=review-command.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { repoRoot } from '#app/git.js';
|
|
4
|
+
import { dim, bold, steel, yellow } from '#app/report/ansi.js';
|
|
5
|
+
import { viewer } from '#app/report/viewer.js';
|
|
6
|
+
import { Session } from '#app/session.js';
|
|
7
|
+
export function runSessionCommand(positionals) {
|
|
8
|
+
const root = repoRoot(process.cwd());
|
|
9
|
+
const operation = positionals[1];
|
|
10
|
+
if (operation === 'diff') {
|
|
11
|
+
const older = positionals[2] ? Session.open(root, positionals[2]) : undefined;
|
|
12
|
+
const newer = positionals[3] ? Session.open(root, positionals[3]) : undefined;
|
|
13
|
+
if (!older?.report || !newer?.report || older.report.state !== 'complete' || newer.report.state !== 'complete') {
|
|
14
|
+
process.stderr.write('Need two complete sessions: psh session diff <older> <newer>\n');
|
|
15
|
+
return 2;
|
|
16
|
+
}
|
|
17
|
+
const { fixed, introduced, remaining } = Session.compare(older, newer);
|
|
18
|
+
const out = process.stdout;
|
|
19
|
+
out.write('\n ' + bold('Between ') + older.id + dim(' and ') + newer.id + '\n\n');
|
|
20
|
+
const show = (label, list, paint) => {
|
|
21
|
+
out.write(' ' + paint(String(list.length).padStart(3) + ' ' + label) + '\n');
|
|
22
|
+
for (const finding of list.slice(0, 10)) {
|
|
23
|
+
out.write(dim(' ' + finding.check + ' ' + finding.file + ':' + finding.line + ' ') + finding.title.slice(0, 60) + '\n');
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
show('fixed', fixed, steel);
|
|
27
|
+
show('introduced', introduced, yellow);
|
|
28
|
+
out.write(' ' + dim(String(remaining.length).padStart(3) + ' still there') + '\n\n');
|
|
29
|
+
return introduced.length > 0 ? 1 : 0;
|
|
30
|
+
}
|
|
31
|
+
if (operation === 'view') {
|
|
32
|
+
const id = positionals[2];
|
|
33
|
+
const session = id ? Session.open(root, id) : undefined;
|
|
34
|
+
if (!session?.report) {
|
|
35
|
+
process.stderr.write('No finished session ' + (id ?? '') + '. Try: psh session list\n');
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
const output = join(root, '.powershot', 'sessions', session.id + '.html');
|
|
39
|
+
writeFileSync(output, viewer(session.report.findings, {
|
|
40
|
+
id: session.id,
|
|
41
|
+
target: session.target,
|
|
42
|
+
started: session.started,
|
|
43
|
+
state: session.report.state ?? 'unknown',
|
|
44
|
+
notLookedAt: session.report.notLookedAt ?? ['session predates verdict recording'],
|
|
45
|
+
}));
|
|
46
|
+
process.stdout.write(output + '\n');
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const rows = Session.list(root);
|
|
50
|
+
if (rows.length === 0) {
|
|
51
|
+
process.stdout.write(dim('\n No resumable sessions.\n\n'));
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
process.stdout.write('\n ' + bold('Sessions') + '\n\n');
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
process.stdout.write(' ' + steel(row.id) + ' ' + dim(row.started.slice(0, 19).replace('T', ' ')) +
|
|
57
|
+
' ' + row.target + dim(' ' + row.done + ' unit(s) done') + '\n');
|
|
58
|
+
}
|
|
59
|
+
process.stdout.write('\n' + dim(' psh review --resume <id>') + '\n\n');
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=session-command.js.map
|