@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/bench.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { review } from './review.js';
|
|
6
|
+
import { openTargetTree, withTargetTree } from './snapshot.js';
|
|
7
|
+
import { decode } from './text.js';
|
|
8
|
+
import { completionOf } from './manifest.js';
|
|
9
|
+
export function key(f) {
|
|
10
|
+
return f.check + '@' + f.file + ':' + f.line;
|
|
11
|
+
}
|
|
12
|
+
export function score(found, expected) {
|
|
13
|
+
const got = new Set(found.map(key));
|
|
14
|
+
const want = new Set(expected);
|
|
15
|
+
let tp = 0;
|
|
16
|
+
for (const k of got)
|
|
17
|
+
if (want.has(k))
|
|
18
|
+
tp++;
|
|
19
|
+
return { tp, fp: got.size - tp, fn: want.size - tp };
|
|
20
|
+
}
|
|
21
|
+
export function precision(s) {
|
|
22
|
+
return s.tp + s.fp === 0 ? 1 : s.tp / (s.tp + s.fp);
|
|
23
|
+
}
|
|
24
|
+
export function recall(s) {
|
|
25
|
+
return s.tp + s.fn === 0 ? 1 : s.tp / (s.tp + s.fn);
|
|
26
|
+
}
|
|
27
|
+
export function f1(s) {
|
|
28
|
+
const p = precision(s);
|
|
29
|
+
const r = recall(s);
|
|
30
|
+
return p + r === 0 ? 0 : (2 * p * r) / (p + r);
|
|
31
|
+
}
|
|
32
|
+
function git(root, args) {
|
|
33
|
+
return decode(execFileSync('git', args, { cwd: root, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
34
|
+
}
|
|
35
|
+
/** The most recent commits, newest first, excluding merges (a merge has no own diff). */
|
|
36
|
+
export function recentCommits(root, count) {
|
|
37
|
+
return git(root, ['log', '--no-merges', '--format=%H', '-n', String(count)]).split('\n').filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
export function reviewCompletion(result) {
|
|
40
|
+
return completionOf({
|
|
41
|
+
files: result.plan?.items() ?? [],
|
|
42
|
+
units: [],
|
|
43
|
+
skippedChecks: result.skippedChecks ?? [],
|
|
44
|
+
failures: result.failures,
|
|
45
|
+
cancelled: result.cancelled,
|
|
46
|
+
budgetStop: result.budgetStop,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function incompleteReasons(result) {
|
|
50
|
+
return reviewCompletion(result).notLookedAt;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Replay real history through the deterministic checks.
|
|
54
|
+
*
|
|
55
|
+
* On a codebase that was reviewed and shipped, a finding is far more likely to be a
|
|
56
|
+
* false positive than a defect nobody noticed. That makes this the cheapest honest
|
|
57
|
+
* precision signal available without labelling anything — and it is measured on real
|
|
58
|
+
* code rather than on fixtures written by the same person who wrote the checks.
|
|
59
|
+
*/
|
|
60
|
+
export async function replayRepo(root, count, onProgress) {
|
|
61
|
+
const commits = recentCommits(root, count);
|
|
62
|
+
const report = {
|
|
63
|
+
commits: 0, findings: 0, byCheck: new Map(), noisy: [], partial: 0, failed: 0,
|
|
64
|
+
verified: 0, judged: 0, byConfidence: new Map(),
|
|
65
|
+
coverage: { selected: 0, limited: 0, waived: 0, failed: 0 },
|
|
66
|
+
falseClean: 0, positions: { added: 0, context: 0 },
|
|
67
|
+
usage: { requests: 0, inputTokens: 0, outputTokens: 0, elapsedMs: 0 },
|
|
68
|
+
};
|
|
69
|
+
if (commits.length === 0)
|
|
70
|
+
return report;
|
|
71
|
+
// One checkout, moved from commit to commit. Each replay has to read the files as
|
|
72
|
+
// they were at that commit — measuring an old diff against today's working tree is
|
|
73
|
+
// what made the previous numbers describe a state that never existed — but paying
|
|
74
|
+
// for a fresh worktree per commit would make the benchmark unusable.
|
|
75
|
+
const tree = openTargetTree(root, commits[0]);
|
|
76
|
+
try {
|
|
77
|
+
for (const commit of commits) {
|
|
78
|
+
let result;
|
|
79
|
+
try {
|
|
80
|
+
tree.checkout(commit);
|
|
81
|
+
// the policy of the commit being replayed, not today's: scoring old history
|
|
82
|
+
// against a rule set written afterwards measures the rules, not the history
|
|
83
|
+
result = await review({
|
|
84
|
+
root: tree.dir,
|
|
85
|
+
stateRoot: root,
|
|
86
|
+
range: { commit },
|
|
87
|
+
config: loadConfig(tree.dir),
|
|
88
|
+
verifyOnly: true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// a commit the pipeline cannot process (submodule, binary-only, first commit)
|
|
93
|
+
report.failed++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const item of result.plan?.items() ?? []) {
|
|
97
|
+
if (item.disposition === 'selected' && item.missing?.length)
|
|
98
|
+
report.coverage.limited++;
|
|
99
|
+
else
|
|
100
|
+
report.coverage[item.disposition] = (report.coverage[item.disposition] ?? 0) + 1;
|
|
101
|
+
}
|
|
102
|
+
if (result.usage) {
|
|
103
|
+
report.usage.requests += result.usage.requests;
|
|
104
|
+
report.usage.inputTokens += result.usage.inputTokens;
|
|
105
|
+
report.usage.outputTokens += result.usage.outputTokens;
|
|
106
|
+
report.usage.elapsedMs += result.usage.elapsedMs;
|
|
107
|
+
}
|
|
108
|
+
const completion = reviewCompletion(result);
|
|
109
|
+
if (result.findings.length === 0 && completion.state !== 'complete')
|
|
110
|
+
report.falseClean++;
|
|
111
|
+
if (result.failures.length > 0 || completion.state === 'failed') {
|
|
112
|
+
report.failed++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (completion.state === 'partial') {
|
|
116
|
+
report.partial++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
report.commits++;
|
|
120
|
+
if (result.findings.length === 0)
|
|
121
|
+
continue;
|
|
122
|
+
report.findings += result.findings.length;
|
|
123
|
+
for (const f of result.findings) {
|
|
124
|
+
report.byCheck.set(f.check, (report.byCheck.get(f.check) ?? 0) + 1);
|
|
125
|
+
if (f.class === 'verified')
|
|
126
|
+
report.verified++;
|
|
127
|
+
else
|
|
128
|
+
report.judged++;
|
|
129
|
+
report.byConfidence.set(f.confidence, (report.byConfidence.get(f.confidence) ?? 0) + 1);
|
|
130
|
+
if (f.positioning === 'added')
|
|
131
|
+
report.positions.added++;
|
|
132
|
+
else if (f.positioning === 'context')
|
|
133
|
+
report.positions.context++;
|
|
134
|
+
}
|
|
135
|
+
let subject = '';
|
|
136
|
+
try {
|
|
137
|
+
subject = git(root, ['log', '-1', '--format=%s', commit]).trim();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// a missing subject is cosmetic; the findings still count
|
|
141
|
+
}
|
|
142
|
+
report.noisy.push({ commit: commit.slice(0, 8), subject, findings: result.findings });
|
|
143
|
+
onProgress?.(commit.slice(0, 8) + ' ' + result.findings.length + ' finding(s) — ' + subject);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
tree.close();
|
|
148
|
+
}
|
|
149
|
+
return report;
|
|
150
|
+
}
|
|
151
|
+
/** Labelled cases from a directory of .json files, for the repos where truth is known. */
|
|
152
|
+
export function loadCases(dir) {
|
|
153
|
+
if (!existsSync(dir))
|
|
154
|
+
return [];
|
|
155
|
+
return readdirSync(dir)
|
|
156
|
+
.filter((f) => f.endsWith('.json'))
|
|
157
|
+
.map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')));
|
|
158
|
+
}
|
|
159
|
+
export async function runCases(root, cases) {
|
|
160
|
+
const out = [];
|
|
161
|
+
for (const c of cases) {
|
|
162
|
+
// the same bug replayRepo had: scoring an old commit against today's tree makes
|
|
163
|
+
// every labelled expectation a claim about a state that never existed
|
|
164
|
+
try {
|
|
165
|
+
const result = await withTargetTree(root, { commit: c.commit }, (tree) => review({ root: tree, stateRoot: root, range: { commit: c.commit }, config: loadConfig(tree), verifyOnly: true }));
|
|
166
|
+
const completion = reviewCompletion(result);
|
|
167
|
+
if (completion.state !== 'complete') {
|
|
168
|
+
out.push({ name: c.name, complete: false, state: completion.state, reasons: completion.notLookedAt, found: result.findings });
|
|
169
|
+
}
|
|
170
|
+
else
|
|
171
|
+
out.push({ name: c.name, complete: true, score: score(result.findings, c.expect), found: result.findings });
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
out.push({ name: c.name, complete: false, state: 'failed', reasons: [error.message], found: [] });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=bench.js.map
|
package/dist/budget.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ceiling on what a review may spend, and a record of what it did.
|
|
3
|
+
*
|
|
4
|
+
* Exhaustion is a planned `partial` outcome with unreached units named, not an
|
|
5
|
+
* execution failure or a clean verdict.
|
|
6
|
+
*/
|
|
7
|
+
export class Budget {
|
|
8
|
+
limits;
|
|
9
|
+
used = { requests: 0, inputTokens: 0, outputTokens: 0, toolCalls: 0, elapsedMs: 0, units: 0 };
|
|
10
|
+
startedAt;
|
|
11
|
+
constructor(limits = {}, now = Date.now()) {
|
|
12
|
+
this.limits = limits;
|
|
13
|
+
this.startedAt = now;
|
|
14
|
+
}
|
|
15
|
+
/** What is exhausted, or undefined while there is room for one more unit. */
|
|
16
|
+
exhausted(now = Date.now()) {
|
|
17
|
+
const elapsed = now - this.startedAt;
|
|
18
|
+
const over = [
|
|
19
|
+
['requests', this.used.requests, this.limits.requests],
|
|
20
|
+
['inputTokens', this.used.inputTokens, this.limits.inputTokens],
|
|
21
|
+
['outputTokens', this.used.outputTokens, this.limits.outputTokens],
|
|
22
|
+
['toolCalls', this.used.toolCalls, this.limits.toolCalls],
|
|
23
|
+
['elapsedMs', elapsed, this.limits.elapsedMs],
|
|
24
|
+
['units', this.used.units, this.limits.units],
|
|
25
|
+
];
|
|
26
|
+
for (const [name, used, limit] of over) {
|
|
27
|
+
if (limit !== undefined && used >= limit)
|
|
28
|
+
return String(name) + ' budget reached (' + used + '/' + limit + ')';
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
spend(delta) {
|
|
33
|
+
for (const [k, v] of Object.entries(delta)) {
|
|
34
|
+
if (typeof v === 'number')
|
|
35
|
+
this.used[k] += v;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
finish(now = Date.now()) {
|
|
39
|
+
this.used.elapsedMs = now - this.startedAt;
|
|
40
|
+
return this.used;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** `--budget requests=40,inputTokens=2000000,elapsedMs=600000` */
|
|
44
|
+
export function parseLimits(spec) {
|
|
45
|
+
const limits = {};
|
|
46
|
+
const known = new Set(['requests', 'inputTokens', 'outputTokens', 'toolCalls', 'elapsedMs', 'units']);
|
|
47
|
+
for (const part of spec.split(',').map((p) => p.trim()).filter(Boolean)) {
|
|
48
|
+
const at = part.indexOf('=');
|
|
49
|
+
const name = at === -1 ? part : part.slice(0, at);
|
|
50
|
+
if (!known.has(name))
|
|
51
|
+
return 'unknown budget "' + name + '" — known: ' + [...known].join(', ');
|
|
52
|
+
const value = Number(part.slice(at + 1));
|
|
53
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
54
|
+
return 'budget ' + name + ' must be a positive number';
|
|
55
|
+
limits[name] = value;
|
|
56
|
+
}
|
|
57
|
+
return limits;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=budget.js.map
|
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { relPath } from './ground.js';
|
|
2
|
+
import { lines as splitLines } from './text.js';
|
|
3
|
+
/** Past this a model attends to less of the file. Cut into units, never skipped. */
|
|
4
|
+
const MAX_LINES_PER_UNIT = 400;
|
|
5
|
+
export const CONTEXT = 3;
|
|
6
|
+
export function shownLines(files) {
|
|
7
|
+
const out = new Map();
|
|
8
|
+
for (const f of files) {
|
|
9
|
+
const seen = out.get(f.path) ?? new Set();
|
|
10
|
+
for (const n of f.added)
|
|
11
|
+
for (let i = n - CONTEXT; i <= n + CONTEXT; i++)
|
|
12
|
+
if (i >= 1)
|
|
13
|
+
seen.add(i);
|
|
14
|
+
out.set(f.path, seen);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
export function reviewableOf(f) {
|
|
19
|
+
return { path: f.changed.path, added: f.changed.added, text: f.sf.getFullText() };
|
|
20
|
+
}
|
|
21
|
+
export function reviewableOfForeign(f) {
|
|
22
|
+
return { path: f.path, added: f.changed.added, text: f.tree.rootNode.text };
|
|
23
|
+
}
|
|
24
|
+
export function reviewables(g) {
|
|
25
|
+
return [...g.files.map(reviewableOf), ...g.foreign.map(reviewableOfForeign)];
|
|
26
|
+
}
|
|
27
|
+
/** Roughly what a file costs a prompt: its changed lines plus the context around them. */
|
|
28
|
+
function weightOf(r) {
|
|
29
|
+
return Math.min(r.added.size * 4, 600);
|
|
30
|
+
}
|
|
31
|
+
function chunk(r) {
|
|
32
|
+
if (r.added.size <= MAX_LINES_PER_UNIT)
|
|
33
|
+
return [r];
|
|
34
|
+
const ordered = [...r.added].sort((a, b) => a - b);
|
|
35
|
+
const out = [];
|
|
36
|
+
for (let i = 0; i < ordered.length; i += MAX_LINES_PER_UNIT) {
|
|
37
|
+
out.push({ path: r.path, added: new Set(ordered.slice(i, i + MAX_LINES_PER_UNIT)), text: r.text });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Files that import one another. A judge reasoning about a stale read needs caller
|
|
43
|
+
* and callee at once. Only TS has a resolved import graph; foreign files pack by size.
|
|
44
|
+
*/
|
|
45
|
+
function components(g) {
|
|
46
|
+
const byPath = new Map();
|
|
47
|
+
for (const f of g.files)
|
|
48
|
+
byPath.set(f.sf.getFilePath(), f);
|
|
49
|
+
const parent = new Map();
|
|
50
|
+
const find = (x) => {
|
|
51
|
+
let root = x;
|
|
52
|
+
while (parent.get(root) !== root)
|
|
53
|
+
root = parent.get(root) ?? root;
|
|
54
|
+
return root;
|
|
55
|
+
};
|
|
56
|
+
const union = (a, b) => {
|
|
57
|
+
const ra = find(a);
|
|
58
|
+
const rb = find(b);
|
|
59
|
+
if (ra !== rb)
|
|
60
|
+
parent.set(ra, rb);
|
|
61
|
+
};
|
|
62
|
+
for (const path of byPath.keys())
|
|
63
|
+
parent.set(path, path);
|
|
64
|
+
for (const f of g.files) {
|
|
65
|
+
for (const imp of f.sf.getImportDeclarations()) {
|
|
66
|
+
const target = imp.getModuleSpecifierSourceFile();
|
|
67
|
+
if (!target)
|
|
68
|
+
continue;
|
|
69
|
+
const targetPath = target.getFilePath();
|
|
70
|
+
if (byPath.has(targetPath))
|
|
71
|
+
union(f.sf.getFilePath(), targetPath);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const groups = new Map();
|
|
75
|
+
for (const [path, f] of byPath) {
|
|
76
|
+
const root = find(path);
|
|
77
|
+
const list = groups.get(root) ?? [];
|
|
78
|
+
list.push(reviewableOf(f));
|
|
79
|
+
groups.set(root, list);
|
|
80
|
+
}
|
|
81
|
+
const out = [...groups.values()];
|
|
82
|
+
const byKey = new Map();
|
|
83
|
+
for (const f of g.foreign) {
|
|
84
|
+
const key = groupKey(f.path);
|
|
85
|
+
const list = byKey.get(key) ?? [];
|
|
86
|
+
list.push(reviewableOfForeign(f));
|
|
87
|
+
byKey.set(key, list);
|
|
88
|
+
}
|
|
89
|
+
out.push(...byKey.values());
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** Keep related files together while bin-packing to minimize model calls. */
|
|
93
|
+
export function bundle(g, maxLines) {
|
|
94
|
+
const groups = components(g)
|
|
95
|
+
.map((files) => files.flatMap(chunk))
|
|
96
|
+
.map((files) => ({ files, lines: files.reduce((n, f) => n + weightOf(f), 0) }))
|
|
97
|
+
.sort((a, b) => b.lines - a.lines);
|
|
98
|
+
const bundles = [];
|
|
99
|
+
for (const group of groups) {
|
|
100
|
+
// better a split component than one prompt the model reads the beginning of
|
|
101
|
+
if (group.lines > maxLines && group.files.length > 1) {
|
|
102
|
+
let current = { files: [], lines: 0 };
|
|
103
|
+
for (const f of group.files) {
|
|
104
|
+
const w = weightOf(f);
|
|
105
|
+
if (current.lines + w > maxLines && current.files.length > 0) {
|
|
106
|
+
bundles.push(current);
|
|
107
|
+
current = { files: [], lines: 0 };
|
|
108
|
+
}
|
|
109
|
+
current.files.push(f);
|
|
110
|
+
current.lines += w;
|
|
111
|
+
}
|
|
112
|
+
if (current.files.length > 0)
|
|
113
|
+
bundles.push(current);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const room = bundles.find((b) => b.lines + group.lines <= maxLines);
|
|
117
|
+
if (room) {
|
|
118
|
+
room.files.push(...group.files);
|
|
119
|
+
room.lines += group.lines;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
bundles.push({ files: [...group.files], lines: group.lines });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return bundles;
|
|
126
|
+
}
|
|
127
|
+
/** Paths no unit carries. What this reports makes the run incomplete, not clean. */
|
|
128
|
+
export function uncovered(g, units) {
|
|
129
|
+
const seen = new Set(units.flatMap((u) => u.files.map((f) => f.path)));
|
|
130
|
+
return reviewables(g)
|
|
131
|
+
.map((r) => r.path)
|
|
132
|
+
.filter((p) => !seen.has(p));
|
|
133
|
+
}
|
|
134
|
+
export function bundleName(b, root) {
|
|
135
|
+
const first = b.files[0];
|
|
136
|
+
if (!first)
|
|
137
|
+
return 'empty';
|
|
138
|
+
// a chunked file appears in several units; the first line tells them apart
|
|
139
|
+
const ordered = [...first.added].sort((a, b) => a - b);
|
|
140
|
+
const suffix = ordered.length > 0 && splitLines(first.text).length > ordered.length ? '@' + ordered[0] : '';
|
|
141
|
+
const name = first.path + suffix;
|
|
142
|
+
return b.files.length === 1 ? name : name + ' +' + (b.files.length - 1);
|
|
143
|
+
}
|
|
144
|
+
export { relPath };
|
|
145
|
+
/**
|
|
146
|
+
* What binds two files of a language into one conversation.
|
|
147
|
+
*
|
|
148
|
+
* The TypeScript half has a resolved import graph; nothing else does, and packing the
|
|
149
|
+
* rest by size alone put a header in one unit and its implementation in another —
|
|
150
|
+
* exactly the pair a judge needs together to see a contract drift. These are the
|
|
151
|
+
* cheap structural relationships each language already encodes in its paths.
|
|
152
|
+
*/
|
|
153
|
+
export function groupKey(path) {
|
|
154
|
+
const dir = path.slice(0, path.lastIndexOf('/') + 1);
|
|
155
|
+
const name = path.slice(dir.length);
|
|
156
|
+
const dot = name.lastIndexOf('.');
|
|
157
|
+
if (dot <= 0)
|
|
158
|
+
return path;
|
|
159
|
+
const ext = name.slice(dot);
|
|
160
|
+
const stem = name.slice(0, dot);
|
|
161
|
+
// a header and its implementation are one contract written down twice
|
|
162
|
+
if (/^\.(h|hpp|hh|hxx|c|cc|cpp|cxx|m|mm)$/.test(ext))
|
|
163
|
+
return dir + stem;
|
|
164
|
+
// a package is the unit in Go, Java, Kotlin and C#
|
|
165
|
+
if (/^\.(go|java|kt|kts|cs)$/.test(ext))
|
|
166
|
+
return dir;
|
|
167
|
+
// elsewhere a module and the tests that cover it argue about the same behaviour
|
|
168
|
+
if (/^\.(py|pyi|rs|rb|php|swift)$/.test(ext)) {
|
|
169
|
+
return dir + stem.replace(/^test_/, '').replace(/_test$/, '').replace(/_spec$/, '').replace(/Test$/, '');
|
|
170
|
+
}
|
|
171
|
+
return path;
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=bundle.js.map
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
const FILE = '.powershot/judge-cache.json';
|
|
7
|
+
const MAX_ENTRIES = 2000;
|
|
8
|
+
function readTree(file) {
|
|
9
|
+
try {
|
|
10
|
+
return existsSync(file) ? readFileSync(file, 'utf8') : undefined;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A per-repository cache the reviewed tree has no way to reach. POWERSHOT_CACHE_DIR
|
|
18
|
+
* lets CI point it at something it restores between runs.
|
|
19
|
+
*/
|
|
20
|
+
function outsideTree(root) {
|
|
21
|
+
const home = process.env.POWERSHOT_CACHE_DIR ??
|
|
22
|
+
process.env.XDG_CACHE_HOME ??
|
|
23
|
+
join(homedir(), process.platform === 'darwin' ? 'Library/Caches' : '.cache');
|
|
24
|
+
const id = createHash('sha256').update(repositoryIdentity(root)).digest('hex').slice(0, 32);
|
|
25
|
+
const file = join(home, 'powershot', id, 'judge-cache.json');
|
|
26
|
+
assertOutside(root, file);
|
|
27
|
+
return file;
|
|
28
|
+
}
|
|
29
|
+
function realOf(root) {
|
|
30
|
+
try {
|
|
31
|
+
return realpathSync(root);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return root;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function repositoryIdentity(root) {
|
|
38
|
+
const git = (args) => {
|
|
39
|
+
try {
|
|
40
|
+
return execFileSync('git', args, {
|
|
41
|
+
cwd: root,
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
44
|
+
}).trim() || undefined;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const remote = git(['config', '--get', 'remote.origin.url']);
|
|
51
|
+
if (remote)
|
|
52
|
+
return 'remote:' + remote;
|
|
53
|
+
const roots = git(['rev-list', '--max-parents=0', '--all']);
|
|
54
|
+
if (roots)
|
|
55
|
+
return 'roots:' + roots.split('\n').sort().join(',');
|
|
56
|
+
return 'path:' + realOf(root);
|
|
57
|
+
}
|
|
58
|
+
/** Resolve the nearest existing ancestor, so nonexistent paths and symlink parents are safe. */
|
|
59
|
+
function canonical(path) {
|
|
60
|
+
let current = resolve(path);
|
|
61
|
+
const tail = [];
|
|
62
|
+
while (!existsSync(current)) {
|
|
63
|
+
const parent = dirname(current);
|
|
64
|
+
if (parent === current)
|
|
65
|
+
break;
|
|
66
|
+
tail.unshift(basename(current));
|
|
67
|
+
current = parent;
|
|
68
|
+
}
|
|
69
|
+
return join(realOf(current), ...tail);
|
|
70
|
+
}
|
|
71
|
+
function assertOutside(root, file) {
|
|
72
|
+
const rel = relative(canonical(root), canonical(file));
|
|
73
|
+
const inside = rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
|
|
74
|
+
if (inside) {
|
|
75
|
+
throw new Error('POWERSHOT_CACHE_DIR must resolve outside the reviewed repository');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Judge answers keyed by every input that can change the answer. */
|
|
79
|
+
export class JudgeCache {
|
|
80
|
+
file;
|
|
81
|
+
entries;
|
|
82
|
+
reviewedRoot;
|
|
83
|
+
constructor(file, entries, reviewedRoot) {
|
|
84
|
+
this.file = file;
|
|
85
|
+
this.entries = entries;
|
|
86
|
+
this.reviewedRoot = reviewedRoot;
|
|
87
|
+
}
|
|
88
|
+
/** Gated runs share a per-repository cache that the reviewed tree cannot write. */
|
|
89
|
+
static open(root, gated = false) {
|
|
90
|
+
const file = gated ? outsideTree(root) : join(root, FILE);
|
|
91
|
+
const text = readTree(file);
|
|
92
|
+
if (text === undefined)
|
|
93
|
+
return new JudgeCache(file, {}, gated ? root : undefined);
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(text);
|
|
96
|
+
const entries = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
97
|
+
? parsed
|
|
98
|
+
: {};
|
|
99
|
+
return new JudgeCache(file, entries, gated ? root : undefined);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return new JudgeCache(file, {}, gated ? root : undefined); // a corrupt cache is not worth failing a review over
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Everything an answer depends on. Leave one out and the key collides. */
|
|
106
|
+
static key(parts) {
|
|
107
|
+
return createHash('sha256')
|
|
108
|
+
.update(parts.judge)
|
|
109
|
+
.update(' ')
|
|
110
|
+
.update(parts.provider)
|
|
111
|
+
.update(' ')
|
|
112
|
+
.update(parts.model)
|
|
113
|
+
.update(' ')
|
|
114
|
+
.update(parts.prompt)
|
|
115
|
+
.update(' ')
|
|
116
|
+
.update(parts.tools ? 'tools' : 'no-tools')
|
|
117
|
+
.update(' ')
|
|
118
|
+
.update(parts.content)
|
|
119
|
+
.update(' ')
|
|
120
|
+
.update(parts.intent ?? '')
|
|
121
|
+
.digest('hex')
|
|
122
|
+
.slice(0, 32);
|
|
123
|
+
}
|
|
124
|
+
get(key) {
|
|
125
|
+
return this.entries[key]?.findings;
|
|
126
|
+
}
|
|
127
|
+
put(key, findings, now) {
|
|
128
|
+
this.entries[key] = { findings, seen: now };
|
|
129
|
+
}
|
|
130
|
+
/** Once at the end: a disk write per model call would eat what the cache saves. */
|
|
131
|
+
save() {
|
|
132
|
+
if (this.reviewedRoot)
|
|
133
|
+
assertOutside(this.reviewedRoot, this.file);
|
|
134
|
+
try {
|
|
135
|
+
const keys = Object.keys(this.entries);
|
|
136
|
+
if (keys.length > MAX_ENTRIES) {
|
|
137
|
+
// oldest first, so a long-lived repository does not grow a cache without bound
|
|
138
|
+
const ordered = keys.sort((a, b) => (this.entries[a].seen < this.entries[b].seen ? -1 : 1));
|
|
139
|
+
for (const k of ordered.slice(0, keys.length - MAX_ENTRIES))
|
|
140
|
+
delete this.entries[k];
|
|
141
|
+
}
|
|
142
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
143
|
+
const temporary = this.file + '.tmp-' + process.pid;
|
|
144
|
+
writeFileSync(temporary, JSON.stringify(this.entries), { mode: 0o600 });
|
|
145
|
+
renameSync(temporary, this.file);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// failing to persist a cache must never fail the review it was speeding up
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
get size() {
|
|
152
|
+
return Object.keys(this.entries).length;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=cache.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { TARGETS, findTarget } from '#app/agents.js';
|
|
4
|
+
import { repoRoot } from '#app/git.js';
|
|
5
|
+
import { dim, bold, steel } from '#app/report/ansi.js';
|
|
6
|
+
export function runAgentCommand(name) {
|
|
7
|
+
if (!name || name === 'list') {
|
|
8
|
+
process.stdout.write('\n ' + bold('Agent targets') + '\n\n');
|
|
9
|
+
for (const target of TARGETS) {
|
|
10
|
+
process.stdout.write(' ' + steel(target.name.padEnd(8)) + dim(target.path) + '\n');
|
|
11
|
+
process.stdout.write(' ' + ' '.repeat(8) + dim(target.serves) + '\n\n');
|
|
12
|
+
}
|
|
13
|
+
process.stdout.write(dim(' psh agent agents # the standard file, covers most tools\n\n'));
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
const target = findTarget(name);
|
|
17
|
+
if (!target) {
|
|
18
|
+
process.stderr.write('Unknown agent "' + name + '". Try: psh agent list\n');
|
|
19
|
+
return 2;
|
|
20
|
+
}
|
|
21
|
+
const output = join(repoRoot(process.cwd()), target.path);
|
|
22
|
+
mkdirSync(dirname(output), { recursive: true });
|
|
23
|
+
writeFileSync(output, target.render());
|
|
24
|
+
process.stdout.write(output + '\n');
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=agent-command.js.map
|
package/dist/cli/app.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { parseCliArgs, HELP } from './args.js';
|
|
2
|
+
import { runAgentCommand } from './agent-command.js';
|
|
3
|
+
import { runBenchCommand } from './bench-command.js';
|
|
4
|
+
import { runDismissCommand } from './dismiss-command.js';
|
|
5
|
+
import { loadRepositoryEnv } from './environment.js';
|
|
6
|
+
import { runReviewCommand } from './review-command.js';
|
|
7
|
+
import { runSessionCommand } from './session-command.js';
|
|
8
|
+
const REVIEW_COMMANDS = new Set(['review', 'scan', 'delegate']);
|
|
9
|
+
export async function runCli(args = process.argv.slice(2)) {
|
|
10
|
+
loadRepositoryEnv();
|
|
11
|
+
const { values, positionals } = parseCliArgs(args);
|
|
12
|
+
if (values.help || positionals[0] === 'help') {
|
|
13
|
+
process.stdout.write(HELP);
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
const command = positionals[0] ?? 'review';
|
|
17
|
+
if (command === 'bench')
|
|
18
|
+
return runBenchCommand(values);
|
|
19
|
+
if (command === 'agent')
|
|
20
|
+
return runAgentCommand(positionals[1]);
|
|
21
|
+
if (command === 'dismiss')
|
|
22
|
+
return runDismissCommand(positionals, values.reason);
|
|
23
|
+
if (command === 'session')
|
|
24
|
+
return runSessionCommand(positionals);
|
|
25
|
+
if (!REVIEW_COMMANDS.has(command)) {
|
|
26
|
+
process.stderr.write('Unknown command "' + command + '". Try: psh --help\n');
|
|
27
|
+
return 2;
|
|
28
|
+
}
|
|
29
|
+
return runReviewCommand(command, values, positionals);
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=app.js.map
|