@0xcraft/powershot 1.1.1 → 1.1.2
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 +36 -25
- package/dist/cli/reports.js +3 -0
- package/dist/cli/review-command.js +8 -2
- package/dist/cli/session-command.js +2 -0
- package/dist/config.js +5 -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 +49 -0
- package/dist/plan.js +7 -0
- package/dist/report/markdown.js +18 -2
- package/dist/report/terminal.js +12 -1
- package/dist/report/viewer.js +11 -1
- package/dist/review.js +39 -18
- package/dist/selftest.js +250 -8
- package/dist/session.js +2 -0
- package/dist/verifiers/foreign-phantom-dep.js +8 -2
- package/docs/architecture.md +37 -11
- package/docs/ci.md +20 -12
- package/examples/github-actions/action.yml +0 -5
- package/examples/github-actions/cli.yml +1 -1
- package/examples/gitlab/.gitlab-ci.yml +1 -1
- package/package.json +1 -1
package/dist/report/terminal.js
CHANGED
|
@@ -106,12 +106,18 @@ export function terminal(findings, opts) {
|
|
|
106
106
|
out.push(' ' + bold('PowerShot') + dim(' · ' + opts.subtitle));
|
|
107
107
|
out.push(rule);
|
|
108
108
|
const incomplete = opts.state !== 'complete';
|
|
109
|
+
const portable = !incomplete && opts.coverage === 'portable';
|
|
109
110
|
if (findings.length === 0) {
|
|
110
111
|
out.push('', incomplete
|
|
111
112
|
? ' ' + yellow('!') + ' No findings — but this review is ' + opts.state + ', not a verdict.'
|
|
112
|
-
: ' ' + steel('✔') + ' No findings.');
|
|
113
|
+
: ' ' + steel('✔') + (portable ? ' No findings in portable coverage.' : ' No findings.'));
|
|
113
114
|
for (const reason of opts.notLookedAt)
|
|
114
115
|
out.push(dim(' ' + reason));
|
|
116
|
+
if (portable) {
|
|
117
|
+
out.push(dim(' Portable coverage: self-contained oracles ran; enriched semantic depth was unavailable.'));
|
|
118
|
+
for (const reason of opts.unavailableCoverage ?? [])
|
|
119
|
+
out.push(dim(' ' + reason));
|
|
120
|
+
}
|
|
115
121
|
out.push('');
|
|
116
122
|
return out.join('\n');
|
|
117
123
|
}
|
|
@@ -146,6 +152,11 @@ export function terminal(findings, opts) {
|
|
|
146
152
|
for (const reason of opts.notLookedAt)
|
|
147
153
|
out.push(dim(' ' + reason));
|
|
148
154
|
}
|
|
155
|
+
else if (portable) {
|
|
156
|
+
out.push(' ' + steel('◇ portable coverage') + dim(' · enriched semantic depth was unavailable'));
|
|
157
|
+
for (const reason of opts.unavailableCoverage ?? [])
|
|
158
|
+
out.push(dim(' ' + reason));
|
|
159
|
+
}
|
|
149
160
|
out.push('');
|
|
150
161
|
return out.join('\n');
|
|
151
162
|
}
|
package/dist/report/viewer.js
CHANGED
|
@@ -2,12 +2,19 @@ const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/
|
|
|
2
2
|
export function viewer(findings, meta) {
|
|
3
3
|
const verified = findings.filter((f) => f.class === 'verified').length;
|
|
4
4
|
const incomplete = meta.state !== 'complete';
|
|
5
|
+
const portable = !incomplete && meta.coverage === 'portable';
|
|
5
6
|
const warning = incomplete
|
|
6
7
|
? '<div class="warning"><strong>This review is ' + escape(meta.state) + ' — not a verdict.</strong>' +
|
|
7
8
|
(meta.notLookedAt.length > 0
|
|
8
9
|
? '<ul>' + meta.notLookedAt.map((reason) => '<li>' + escape(reason) + '</li>').join('') + '</ul>'
|
|
9
10
|
: '') + '</div>'
|
|
10
11
|
: '';
|
|
12
|
+
const coverage = portable
|
|
13
|
+
? '<div class="coverage"><strong>Portable coverage.</strong> Self-contained oracles ran; enriched semantic depth was unavailable.' +
|
|
14
|
+
((meta.unavailableCoverage?.length ?? 0) > 0
|
|
15
|
+
? '<ul>' + meta.unavailableCoverage.map((reason) => '<li>' + escape(reason) + '</li>').join('') + '</ul>'
|
|
16
|
+
: '') + '</div>'
|
|
17
|
+
: '';
|
|
11
18
|
const rows = findings
|
|
12
19
|
.map((f) => {
|
|
13
20
|
const frame = f.frame
|
|
@@ -70,6 +77,8 @@ export function viewer(findings, meta) {
|
|
|
70
77
|
.none { color:var(--muted); }
|
|
71
78
|
.warning { border:1px solid var(--amber); border-radius:6px; padding:10px 14px; margin:0 0 18px; }
|
|
72
79
|
.warning ul { margin:6px 0 0; }
|
|
80
|
+
.coverage { border:1px solid var(--steel); border-radius:6px; padding:10px 14px; margin:0 0 18px; }
|
|
81
|
+
.coverage ul { margin:6px 0 0; }
|
|
73
82
|
.f.put-away { opacity:.4; }
|
|
74
83
|
.f.put-away .title { text-decoration:line-through; }
|
|
75
84
|
.act { margin-left:8px; }
|
|
@@ -82,6 +91,7 @@ export function viewer(findings, meta) {
|
|
|
82
91
|
<p class="meta">${escape(meta.target)} · ${escape(meta.started.slice(0, 19).replace('T', ' '))} · session ${escape(meta.id)}<br>
|
|
83
92
|
${findings.length} finding(s) — ${verified} verified, ${findings.length - verified} judged</p>
|
|
84
93
|
${warning}
|
|
94
|
+
${coverage}
|
|
85
95
|
<div class="bar">
|
|
86
96
|
<button data-filter="all" aria-pressed="true">all</button>
|
|
87
97
|
<button data-filter="verified" aria-pressed="false">verified</button>
|
|
@@ -89,7 +99,7 @@ export function viewer(findings, meta) {
|
|
|
89
99
|
<button id="show-away" aria-pressed="false">show put away</button>
|
|
90
100
|
</div>
|
|
91
101
|
${findings.length === 0
|
|
92
|
-
? '<p class="none">' + (incomplete ? 'No findings from what completed.' : 'No findings.') + '</p>'
|
|
102
|
+
? '<p class="none">' + (incomplete ? 'No findings from what completed.' : portable ? 'No findings in portable coverage.' : 'No findings.') + '</p>'
|
|
93
103
|
: rows}
|
|
94
104
|
</div>
|
|
95
105
|
<script>
|
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,
|
package/dist/selftest.js
CHANGED
|
@@ -31,13 +31,13 @@ import { ProviderError, redact } from './judges/llm.js';
|
|
|
31
31
|
import { VERIFIERS } from './verifiers/index.js';
|
|
32
32
|
import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
|
|
33
33
|
import { tmpdir } from 'node:os';
|
|
34
|
-
import { join, sep } from 'node:path';
|
|
34
|
+
import { dirname, join, sep } from 'node:path';
|
|
35
35
|
import { runTool } from './judges/tools.js';
|
|
36
36
|
import { codeQuality } from './report/codequality.js';
|
|
37
37
|
import { viewer } from './report/viewer.js';
|
|
38
38
|
import { absorbDelegated, delegateBrief } from './delegate.js';
|
|
39
39
|
import { TARGETS, findTarget } from './agents.js';
|
|
40
|
-
import { packFor } from './lang/packs.js';
|
|
40
|
+
import { PACKS, packFor, parseIsolated } from './lang/packs.js';
|
|
41
41
|
import { isPhantom, pythonManifest, localModules } from './lang/python-deps.js';
|
|
42
42
|
import { isPhantomGem, rubyManifest } from './lang/ruby-deps.js';
|
|
43
43
|
import { pyrightAvailable } from './lang/pyright.js';
|
|
@@ -433,6 +433,35 @@ check('files are routed to the right language pack', () => {
|
|
|
433
433
|
assert.equal(packFor('src/a.ts'), undefined); // TypeScript keeps its own oracle
|
|
434
434
|
assert.equal(packFor('README.md'), undefined);
|
|
435
435
|
});
|
|
436
|
+
await checkAsync('isolated trees preserve Unicode offsets and grammar fields', async () => {
|
|
437
|
+
const pack = PACKS.find((candidate) => candidate.name === 'python');
|
|
438
|
+
const source = 'label = "é"\ndef answer(value: str) -> str:\n return value\n';
|
|
439
|
+
const [tree] = await parseIsolated(pack, [source]);
|
|
440
|
+
assert.ok(tree);
|
|
441
|
+
const signature = pack.signatures?.(tree.rootNode).get('answer');
|
|
442
|
+
assert.ok(signature);
|
|
443
|
+
assert.equal(signature.node.text, 'def answer(value: str) -> str:\n return value');
|
|
444
|
+
assert.deepEqual(signature.params, ['value: str']);
|
|
445
|
+
});
|
|
446
|
+
await checkAsync('language worker batching preserves every file past the 128-file boundary', async () => {
|
|
447
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-foreign-batches-')));
|
|
448
|
+
try {
|
|
449
|
+
const changes = [];
|
|
450
|
+
for (let index = 0; index < 129; index++) {
|
|
451
|
+
const path = 'python/module_' + index + '.py';
|
|
452
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true });
|
|
453
|
+
writeFileSync(join(dir, path), 'value = ' + index + '\n');
|
|
454
|
+
changes.push({ path, added: new Set([1]), before: 'value = -1\n' });
|
|
455
|
+
}
|
|
456
|
+
const grounded = await buildGround(dir, changes);
|
|
457
|
+
assert.equal(grounded.foreign.length, changes.length);
|
|
458
|
+
assert.deepEqual(grounded.foreign.map((file) => file.path), changes.map((file) => file.path));
|
|
459
|
+
assert.ok(grounded.foreign.every((file) => file.beforeTree !== undefined));
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
rmSync(dir, { recursive: true, force: true });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
436
465
|
// Per-language pack checks live in langtest.ts, one process each: eleven wasm
|
|
437
466
|
// grammars cannot share a process without exhausting it. `npm test` runs both.
|
|
438
467
|
console.log('\neditor and provider surface');
|
|
@@ -462,7 +491,7 @@ check('compact defaults the column when a finding has no span', () => {
|
|
|
462
491
|
});
|
|
463
492
|
check('each provider reads its own key', () => {
|
|
464
493
|
const base = { model: 'm', verifiers: ['*'], judges: ['*'], minSeverity: 'low',
|
|
465
|
-
ignore: [], promptCache: true };
|
|
494
|
+
ignore: [], coverage: 'portable', promptCache: true };
|
|
466
495
|
const saved = { a: process.env.ANTHROPIC_API_KEY, o: process.env.OPENAI_API_KEY,
|
|
467
496
|
g: process.env.GEMINI_API_KEY, gg: process.env.GOOGLE_API_KEY };
|
|
468
497
|
process.env.ANTHROPIC_API_KEY = 'a';
|
|
@@ -556,6 +585,30 @@ check('a review that did not complete never renders as clean', () => {
|
|
|
556
585
|
assert.match(partial, /partial, not a verdict/);
|
|
557
586
|
assert.match(partial, /outside\.ts \(no types\)/);
|
|
558
587
|
});
|
|
588
|
+
check('portable coverage is a verdict, but never masquerades as full semantic coverage', () => {
|
|
589
|
+
const unavailable = [
|
|
590
|
+
'1 file(s) without enriched semantic coverage: web/app.ts (types, references)',
|
|
591
|
+
'2 enriched check(s) unavailable: phantom-api (no types), contract-drift (no references)',
|
|
592
|
+
];
|
|
593
|
+
const out = terminal([], {
|
|
594
|
+
subtitle: 'workspace', verified: 0, judged: 0, state: 'complete', notLookedAt: [],
|
|
595
|
+
coverage: 'portable', unavailableCoverage: unavailable,
|
|
596
|
+
});
|
|
597
|
+
assert.match(out, /No findings in portable coverage\./);
|
|
598
|
+
assert.match(out, /web\/app\.ts \(types, references\)/);
|
|
599
|
+
assert.doesNotMatch(out, /not a verdict/);
|
|
600
|
+
const md = markdown([], {
|
|
601
|
+
state: 'complete', notLookedAt: [], coverage: 'portable',
|
|
602
|
+
files: [{ path: 'web/app.ts', unavailable: ['types', 'references'] }],
|
|
603
|
+
checks: { unavailable: [
|
|
604
|
+
{ check: 'phantom-api', missing: 'types' },
|
|
605
|
+
{ check: 'contract-drift', missing: 'references' },
|
|
606
|
+
] },
|
|
607
|
+
});
|
|
608
|
+
assert.match(md, /\*\*Portable coverage\.\*\*/);
|
|
609
|
+
assert.match(md, /No findings in portable coverage\./);
|
|
610
|
+
assert.doesNotMatch(md, /^No findings\.$/m);
|
|
611
|
+
});
|
|
559
612
|
check('findings are still shown when a stage failed, with the warning kept', () => {
|
|
560
613
|
const f = { id: 'F1', class: 'verified', check: 'phantom-dep', severity: 'high',
|
|
561
614
|
confidence: 'proven', file: 'a.ts', line: 1, title: 'missing dep' };
|
|
@@ -616,6 +669,23 @@ check('no manifest means nothing to be wrong about', () => {
|
|
|
616
669
|
assert.equal(pythonManifest('/definitely/not/a/repo'), undefined);
|
|
617
670
|
assert.deepEqual(localModules('/definitely/not/a/repo'), new Set());
|
|
618
671
|
});
|
|
672
|
+
check('local Python modules are discovered from the changed package, not the whole monorepo', () => {
|
|
673
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-py-local-')));
|
|
674
|
+
try {
|
|
675
|
+
const app = join(dir, 'services', 'api', 'src', 'app.py');
|
|
676
|
+
mkdirSync(join(dir, 'services', 'api', 'src', 'localpkg'), { recursive: true });
|
|
677
|
+
mkdirSync(join(dir, 'unrelated', 'hiddenpkg'), { recursive: true });
|
|
678
|
+
writeFileSync(app, 'from localpkg import value\n');
|
|
679
|
+
writeFileSync(join(dir, 'services', 'api', 'src', 'localpkg', '__init__.py'), 'value = 1\n');
|
|
680
|
+
writeFileSync(join(dir, 'unrelated', 'hiddenpkg', '__init__.py'), 'value = 2\n');
|
|
681
|
+
const local = localModules(dir, dirname(app));
|
|
682
|
+
assert.equal(local.has('localpkg'), true);
|
|
683
|
+
assert.equal(local.has('hiddenpkg'), false);
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
rmSync(dir, { recursive: true, force: true });
|
|
687
|
+
}
|
|
688
|
+
});
|
|
619
689
|
console.log('\nruby gems');
|
|
620
690
|
const gems = { names: new Set(['rails', 'httparty', 'sidekiq']) };
|
|
621
691
|
const rbLocal = new Set(['helpers', 'models']);
|
|
@@ -892,7 +962,7 @@ check('self-review publishes machine findings only for a complete verdict', () =
|
|
|
892
962
|
const workflow = readFileSync(join(process.cwd(), '.github', 'workflows', 'review.yml'), 'utf8');
|
|
893
963
|
assert.equal(workflow.match(/node "\$PSH" review/g)?.length, 1);
|
|
894
964
|
assert.match(workflow, /name: Check out the untrusted review target[\s\S]+allow-unsafe-pr-checkout: true/);
|
|
895
|
-
assert.
|
|
965
|
+
assert.doesNotMatch(workflow, /working-directory: target[\s\S]{0,120}npm ci/);
|
|
896
966
|
assert.match(workflow, /steps\.review\.outputs\.status == '0' \|\| steps\.review\.outputs\.status == '1'/);
|
|
897
967
|
assert.match(workflow, /sarif_file: powershot\.sarif\s+checkout_path: target\s+ref: refs\/pull\/\$\{\{ github\.event\.pull_request\.number \}\}\/head\s+sha: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/);
|
|
898
968
|
});
|
|
@@ -906,6 +976,10 @@ check('the public action persists judge answers and publishes only a verdict', (
|
|
|
906
976
|
assert.match(action, /inline-comments:\s*\n\s+description: [^\n]+\n\s+default: 'false'/);
|
|
907
977
|
assert.match(action, /Post inline comments[\s\S]+inputs\.inline-comments == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
|
|
908
978
|
assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/inline-comments\.js"/);
|
|
979
|
+
assert.match(action, /--report manifest=powershot\.manifest\.json/);
|
|
980
|
+
assert.match(action, /coverage=\$COVERAGE/);
|
|
981
|
+
assert.match(action, /m\.coverage === "full" \|\| m\.coverage === "portable" \? m\.coverage : "unknown"/);
|
|
982
|
+
assert.match(action, /Approve a clean review[\s\S]+steps\.review\.outputs\.coverage == 'full'/);
|
|
909
983
|
});
|
|
910
984
|
check('published CI examples preserve one verdict and its exit status', () => {
|
|
911
985
|
const action = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'action.yml'), 'utf8');
|
|
@@ -914,12 +988,13 @@ check('published CI examples preserve one verdict and its exit status', () => {
|
|
|
914
988
|
assert.match(action, /upload-sarif: 'true'/);
|
|
915
989
|
assert.match(action, /inline-comments: 'true'/);
|
|
916
990
|
assert.match(action, /runs-on: ubuntu-24\.04/);
|
|
917
|
-
assert.
|
|
918
|
-
assert.match(
|
|
991
|
+
assert.doesNotMatch(action, /npm ci|NPM_AUTH_TOKEN|NODE_AUTH_TOKEN/);
|
|
992
|
+
assert.match(action, /uses: xcrft\/powershot@v1/);
|
|
993
|
+
assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.2/);
|
|
919
994
|
assert.equal(github.match(/psh review/g)?.length, 1);
|
|
920
995
|
assert.match(github, /--report markdown=powershot\.md[\s\S]+--report sarif=powershot\.sarif/);
|
|
921
996
|
assert.match(github, /\|\| STATUS=\$\?[\s\S]+case "\$STATUS"/);
|
|
922
|
-
assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.
|
|
997
|
+
assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.2/);
|
|
923
998
|
assert.equal(gitlab.match(/psh review/g)?.length, 1);
|
|
924
999
|
assert.match(gitlab, /--format codequality > gl-code-quality-report\.json \|\| STATUS=\$\?/);
|
|
925
1000
|
assert.match(gitlab, /test "\$STATUS" -le 1 \|\| exit "\$STATUS"/);
|
|
@@ -940,6 +1015,15 @@ check('the viewer is one self-contained page', () => {
|
|
|
940
1015
|
assert.equal(/<(script|link|img)[^>]+(src|href)="http/.test(html), false); // no network needed
|
|
941
1016
|
assert.equal((html.match(/class="f /g) ?? []).length, 2);
|
|
942
1017
|
});
|
|
1018
|
+
check('the viewer labels a complete portable session', () => {
|
|
1019
|
+
const html = viewer([], {
|
|
1020
|
+
id: 'portable', target: 'workspace', started: '2026-01-01T10:00:00Z',
|
|
1021
|
+
state: 'complete', notLookedAt: [], coverage: 'portable',
|
|
1022
|
+
unavailableCoverage: ['1 file(s) without enriched semantic coverage: app.ts (types)'],
|
|
1023
|
+
});
|
|
1024
|
+
assert.match(html, /Portable coverage\./);
|
|
1025
|
+
assert.match(html, /No findings in portable coverage\./);
|
|
1026
|
+
});
|
|
943
1027
|
check('the viewer escapes content rather than rendering it', () => {
|
|
944
1028
|
const nasty = [{ ...sample[0], title: '<img src=x onerror=alert(1)>' }];
|
|
945
1029
|
const html = viewer(nasty, {
|
|
@@ -967,7 +1051,7 @@ check('delegated output distinguishes an empty verdict from malformed data', ()
|
|
|
967
1051
|
check('delegate --checks selects only the requested judging brief', () => {
|
|
968
1052
|
const cfg = {
|
|
969
1053
|
provider: 'anthropic', model: 'm', verifiers: ['*'], judges: ['*'],
|
|
970
|
-
minSeverity: 'low', ignore: [], promptCache: true,
|
|
1054
|
+
minSeverity: 'low', ignore: [], coverage: 'portable', promptCache: true,
|
|
971
1055
|
};
|
|
972
1056
|
const brief = delegateBrief(ground([{ path: 'a.ts', after: 'export const a = 1\n' }]), cfg, {
|
|
973
1057
|
checks: ['intent'], intent: 'add a',
|
|
@@ -1736,6 +1820,12 @@ check('judges accept both the plain list and the { enable } form', () => {
|
|
|
1736
1820
|
assert.equal(validateConfig({ judges: { enable: ['securty'] } }, KNOWN).length, 1);
|
|
1737
1821
|
assert.equal(validateConfig({ judges: 'security' }, KNOWN).length, 1); // not a list at all
|
|
1738
1822
|
});
|
|
1823
|
+
check('coverage is portable by default and strict only when requested', () => {
|
|
1824
|
+
assert.equal(loadConfig(process.cwd()).coverage, 'portable');
|
|
1825
|
+
assert.deepEqual(validateConfig({ coverage: 'portable' }, KNOWN), []);
|
|
1826
|
+
assert.deepEqual(validateConfig({ coverage: 'strict' }, KNOWN), []);
|
|
1827
|
+
assert.match(validateConfig({ coverage: 'complete' }, KNOWN)[0], /not one of: portable, strict/);
|
|
1828
|
+
});
|
|
1739
1829
|
console.log('\nsession safety');
|
|
1740
1830
|
check('a session will not be resumed by a different model than answered it', () => {
|
|
1741
1831
|
const dir = mkdtempSync(join(tmpdir(), 'psh-ses-'));
|
|
@@ -1997,6 +2087,32 @@ check('the manifest accounts for everything it selected', () => {
|
|
|
1997
2087
|
// and a failure anywhere means the run is not a verdict
|
|
1998
2088
|
assert.equal(m2.build({ ...base, failures: ['judge died'] }).state, 'failed');
|
|
1999
2089
|
});
|
|
2090
|
+
check('portable oracle gaps stay visible without turning the run into a partial verdict', () => {
|
|
2091
|
+
const manifest = new RunManifest('portable');
|
|
2092
|
+
manifest.ran('swallowed-error');
|
|
2093
|
+
const record = manifest.build({
|
|
2094
|
+
operation: 'review', target: { requested: {} },
|
|
2095
|
+
policy: { source: 'base', hash: 'h' },
|
|
2096
|
+
engine: { version: '0', tools: false, verifyOnly: true },
|
|
2097
|
+
files: [{
|
|
2098
|
+
path: 'web/app.ts', disposition: 'selected', bytes: 1, addedLines: 1,
|
|
2099
|
+
language: 'typescript', checks: ['swallowed-error'], unavailable: ['types', 'references'],
|
|
2100
|
+
}],
|
|
2101
|
+
skippedChecks: [],
|
|
2102
|
+
unavailableChecks: [
|
|
2103
|
+
{ check: 'phantom-api', missing: 'types' },
|
|
2104
|
+
{ check: 'contract-drift', missing: 'references' },
|
|
2105
|
+
],
|
|
2106
|
+
findings: { total: 0, verified: 0, judged: 0, dismissed: 0, droppedPosition: 0 },
|
|
2107
|
+
usage: { requests: 0, inputTokens: 0, outputTokens: 0, toolCalls: 0, elapsedMs: 0, units: 0 },
|
|
2108
|
+
failures: [],
|
|
2109
|
+
});
|
|
2110
|
+
assert.equal(record.state, 'complete');
|
|
2111
|
+
assert.deepEqual(record.notLookedAt, []);
|
|
2112
|
+
assert.deepEqual(record.files[0].unavailable, ['types', 'references']);
|
|
2113
|
+
assert.equal(record.checks.unavailable?.length, 2);
|
|
2114
|
+
assert.deepEqual(coverageProblems(record), []);
|
|
2115
|
+
});
|
|
2000
2116
|
check('a manifest that hides an unreached unit is caught as our bug', () => {
|
|
2001
2117
|
const broken = {
|
|
2002
2118
|
schema: SCHEMA, id: 'x', operation: 'review', started: '', ended: '',
|
|
@@ -2145,6 +2261,7 @@ await checkAsync('per-file limits follow the checks the caller actually selected
|
|
|
2145
2261
|
operation: 'scan', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
|
|
2146
2262
|
engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan?.items() ?? [],
|
|
2147
2263
|
skippedChecks: result.skippedChecks ?? [],
|
|
2264
|
+
unavailableChecks: result.unavailableChecks ?? [],
|
|
2148
2265
|
findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
|
|
2149
2266
|
dismissed: result.stats.dismissed, droppedPosition: 0 },
|
|
2150
2267
|
usage: result.usage, failures: result.failures,
|
|
@@ -2162,6 +2279,131 @@ await checkAsync('per-file limits follow the checks the caller actually selected
|
|
|
2162
2279
|
rmSync(dir, { recursive: true, force: true });
|
|
2163
2280
|
}
|
|
2164
2281
|
});
|
|
2282
|
+
await checkAsync('portable coverage reviews every declared language together without repository installs', async () => {
|
|
2283
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-portable-mixed-')));
|
|
2284
|
+
try {
|
|
2285
|
+
const sources = {
|
|
2286
|
+
python: { path: 'services/api/app.py', source: 'def answer() -> int:\n try:\n risky()\n except Exception:\n pass\n return 42\n' },
|
|
2287
|
+
go: { path: 'services/agent/main.go', source: 'package agent\nfunc answer() int {\n if err := risky(); err != nil {\n }\n return 42\n}\n' },
|
|
2288
|
+
java: { path: 'services/jvm/App.java', source: 'class App { int answer() { try { risky(); } catch (Exception e) { } return 42; } }\n' },
|
|
2289
|
+
rust: { path: 'crates/worker/src/lib.rs', source: 'pub fn answer() -> i32 { match risky() { Err(_) => {}, Ok(v) => v }; 42 }\n' },
|
|
2290
|
+
cpp: { path: 'native/app.cpp', source: 'int answer() { try { risky(); } catch (...) { } return 42; }\n' },
|
|
2291
|
+
c: { path: 'native/app.c', source: 'int answer(void) { return 42; }\n' },
|
|
2292
|
+
'c#': { path: 'dotnet/App.cs', source: 'class App { int Answer() { try { Risky(); } catch (Exception e) { } return 42; } }\n' },
|
|
2293
|
+
php: { path: 'php/app.php', source: '<?php function answer() { try { risky(); } catch (Exception $e) { } return 42; }\n' },
|
|
2294
|
+
kotlin: { path: 'android/App.kt', source: 'fun answer(): Int { try { risky() } catch (e: Exception) {} ; return 42 }\n' },
|
|
2295
|
+
ruby: { path: 'ruby/app.rb', source: 'def answer\n begin\n risky\n rescue => e\n end\n 42\nend\n' },
|
|
2296
|
+
solidity: { path: 'contracts/App.sol', source: 'contract App { function answer() public returns (uint) { try this.risky() { } catch { } return 42; } function risky() external {} }\n' },
|
|
2297
|
+
};
|
|
2298
|
+
assert.deepEqual(Object.keys(sources).sort(), PACKS.map((pack) => pack.name).sort());
|
|
2299
|
+
const native = [
|
|
2300
|
+
{ path: 'web/app.ts', source: 'export const answer = 42\n' },
|
|
2301
|
+
{ path: 'web/legacy.js', source: 'export const legacyAnswer = 42\n' },
|
|
2302
|
+
];
|
|
2303
|
+
const all = [...native, ...Object.values(sources)];
|
|
2304
|
+
for (const file of all) {
|
|
2305
|
+
mkdirSync(dirname(join(dir, file.path)), { recursive: true });
|
|
2306
|
+
writeFileSync(join(dir, file.path), file.source);
|
|
2307
|
+
}
|
|
2308
|
+
const changes = all.map((file) => ({
|
|
2309
|
+
path: file.path,
|
|
2310
|
+
added: new Set(file.source.split('\n').slice(0, -1).map((_, line) => line + 1)),
|
|
2311
|
+
before: file.source.replace('42', '41'),
|
|
2312
|
+
}));
|
|
2313
|
+
const manifest = new RunManifest('portable-mixed');
|
|
2314
|
+
const result = await review({
|
|
2315
|
+
root: dir, range: {}, changes, config: loadConfig(dir), verifyOnly: true, manifest,
|
|
2316
|
+
});
|
|
2317
|
+
const record = manifest.build({
|
|
2318
|
+
operation: 'review', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
|
|
2319
|
+
engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan.items(),
|
|
2320
|
+
skippedChecks: result.skippedChecks ?? [], unavailableChecks: result.unavailableChecks ?? [],
|
|
2321
|
+
findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
|
|
2322
|
+
dismissed: result.stats.dismissed, droppedPosition: result.droppedPosition ?? 0 },
|
|
2323
|
+
usage: result.usage, failures: result.failures,
|
|
2324
|
+
});
|
|
2325
|
+
assert.equal(record.state, 'complete');
|
|
2326
|
+
assert.deepEqual(record.notLookedAt, []);
|
|
2327
|
+
assert.equal(record.files.length, all.length);
|
|
2328
|
+
assert.ok(record.files.every((file) => file.disposition === 'selected'), 'a declared language cannot be waived');
|
|
2329
|
+
assert.ok(record.files.every((file) => file.checks.length > 0), 'every declared language needs baseline checks');
|
|
2330
|
+
const swallowed = new Set(result.findings.filter((finding) => finding.check === 'swallowed-error').map((finding) => finding.file));
|
|
2331
|
+
for (const [language, file] of Object.entries(sources)) {
|
|
2332
|
+
if (language !== 'c')
|
|
2333
|
+
assert.equal(swallowed.has(file.path), true, language + ' isolated AST must drive its oracle');
|
|
2334
|
+
}
|
|
2335
|
+
assert.deepEqual(record.files.find((file) => file.path === 'web/app.ts')?.unavailable, ['types', 'references']);
|
|
2336
|
+
assert.ok(record.checks.unavailable?.some((check) => check.check === 'phantom-api' && check.missing === 'types'));
|
|
2337
|
+
assert.deepEqual(coverageProblems(record), []);
|
|
2338
|
+
}
|
|
2339
|
+
finally {
|
|
2340
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
await checkAsync('strict coverage keeps missing semantic oracles verdict-blocking', async () => {
|
|
2344
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-strict-')));
|
|
2345
|
+
try {
|
|
2346
|
+
writeFileSync(join(dir, 'app.ts'), 'export const answer = 42\n');
|
|
2347
|
+
const manifest = new RunManifest('strict');
|
|
2348
|
+
const result = await review({
|
|
2349
|
+
root: dir,
|
|
2350
|
+
range: {},
|
|
2351
|
+
changes: [{ path: 'app.ts', added: new Set([1]), before: 'export const answer = 41\n' }],
|
|
2352
|
+
config: { ...loadConfig(dir), coverage: 'strict' },
|
|
2353
|
+
verifyOnly: true,
|
|
2354
|
+
manifest,
|
|
2355
|
+
});
|
|
2356
|
+
const record = manifest.build({
|
|
2357
|
+
operation: 'review', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
|
|
2358
|
+
engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan.items(),
|
|
2359
|
+
skippedChecks: result.skippedChecks ?? [], unavailableChecks: result.unavailableChecks ?? [],
|
|
2360
|
+
findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
|
|
2361
|
+
dismissed: result.stats.dismissed, droppedPosition: result.droppedPosition ?? 0 },
|
|
2362
|
+
usage: result.usage, failures: result.failures,
|
|
2363
|
+
});
|
|
2364
|
+
assert.equal(record.state, 'partial');
|
|
2365
|
+
assert.deepEqual(record.files[0].missing, ['types', 'references']);
|
|
2366
|
+
assert.deepEqual(record.files[0].unavailable, undefined);
|
|
2367
|
+
assert.ok(record.checks.skipped.some((check) => check.check === 'phantom-api' && check.missing === 'types'));
|
|
2368
|
+
}
|
|
2369
|
+
finally {
|
|
2370
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2371
|
+
}
|
|
2372
|
+
});
|
|
2373
|
+
await checkAsync('portable coverage still blocks when an existing foreign base cannot be parsed', async () => {
|
|
2374
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-foreign-base-limit-')));
|
|
2375
|
+
try {
|
|
2376
|
+
writeFileSync(join(dir, 'app.py'), 'def answer():\n return 42\n');
|
|
2377
|
+
const manifest = new RunManifest('foreign-base-limit');
|
|
2378
|
+
const result = await review({
|
|
2379
|
+
root: dir,
|
|
2380
|
+
range: {},
|
|
2381
|
+
changes: [{
|
|
2382
|
+
path: 'app.py',
|
|
2383
|
+
added: new Set([1, 2]),
|
|
2384
|
+
before: 'value = 1\n'.repeat(60_000),
|
|
2385
|
+
}],
|
|
2386
|
+
config: loadConfig(dir),
|
|
2387
|
+
verifyOnly: true,
|
|
2388
|
+
manifest,
|
|
2389
|
+
});
|
|
2390
|
+
const record = manifest.build({
|
|
2391
|
+
operation: 'review', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
|
|
2392
|
+
engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan.items(),
|
|
2393
|
+
skippedChecks: result.skippedChecks ?? [], unavailableChecks: result.unavailableChecks ?? [],
|
|
2394
|
+
findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
|
|
2395
|
+
dismissed: result.stats.dismissed, droppedPosition: result.droppedPosition ?? 0 },
|
|
2396
|
+
usage: result.usage, failures: result.failures,
|
|
2397
|
+
});
|
|
2398
|
+
assert.equal(record.state, 'partial');
|
|
2399
|
+
assert.ok(record.files[0].missing?.includes('base'));
|
|
2400
|
+
assert.ok(record.checks.skipped.some((check) => check.missing.includes('base')));
|
|
2401
|
+
assert.deepEqual(coverageProblems(record), []);
|
|
2402
|
+
}
|
|
2403
|
+
finally {
|
|
2404
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2405
|
+
}
|
|
2406
|
+
});
|
|
2165
2407
|
await checkAsync('foreign checks advertise only files their language pack can inspect', async () => {
|
|
2166
2408
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-pack-coverage-')));
|
|
2167
2409
|
try {
|
package/dist/session.js
CHANGED
|
@@ -106,6 +106,8 @@ export class Session {
|
|
|
106
106
|
judged: findings.filter((f) => f.class === 'judged').length,
|
|
107
107
|
state: verdict.state,
|
|
108
108
|
notLookedAt: verdict.notLookedAt,
|
|
109
|
+
coverage: verdict.coverage,
|
|
110
|
+
unavailableCoverage: verdict.unavailableCoverage,
|
|
109
111
|
};
|
|
110
112
|
this.save();
|
|
111
113
|
}
|
|
@@ -47,13 +47,19 @@ function pythonFindings(g) {
|
|
|
47
47
|
const python = g.foreign.filter((f) => f.pack.name === 'python');
|
|
48
48
|
if (python.length === 0)
|
|
49
49
|
return [];
|
|
50
|
-
const local = localModules(g.root);
|
|
51
50
|
const findings = [];
|
|
51
|
+
const modules = new Map();
|
|
52
52
|
for (const file of python) {
|
|
53
53
|
// the manifests governing this file, not just the repository's own
|
|
54
|
-
const
|
|
54
|
+
const fileDir = dirname(join(g.root, file.path));
|
|
55
|
+
const manifest = pythonManifest(g.root, fileDir);
|
|
55
56
|
if (!manifest)
|
|
56
57
|
continue;
|
|
58
|
+
let local = modules.get(fileDir);
|
|
59
|
+
if (!local) {
|
|
60
|
+
local = localModules(g.root, fileDir);
|
|
61
|
+
modules.set(fileDir, local);
|
|
62
|
+
}
|
|
57
63
|
for (const imported of file.pack.imports?.(file.tree.rootNode) ?? []) {
|
|
58
64
|
const line = imported.node.startPosition.row + 1;
|
|
59
65
|
if (!file.changed.added.has(line))
|