@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/README.md
CHANGED
|
@@ -20,13 +20,14 @@ PowerShot reviews the failure modes that plausible-looking generated code tends
|
|
|
20
20
|
hide: invented APIs, undeclared dependencies, dropped guards, swallowed errors,
|
|
21
21
|
tests that prove nothing, bent expectations, stale callers, and duplicated helpers.
|
|
22
22
|
|
|
23
|
-
It asks
|
|
24
|
-
|
|
23
|
+
It asks self-contained parsers, manifests, and pre/post ASTs first, then uses compiler
|
|
24
|
+
types and reference graphs when the environment can supply them. Optional model judges
|
|
25
|
+
only handle questions that still require judgement.
|
|
25
26
|
|
|
26
27
|
<table>
|
|
27
28
|
<tr>
|
|
28
29
|
<td width="33%"><strong>Deterministic first</strong><br>Local checks need no model, key, tokens, or network calls.</td>
|
|
29
|
-
<td width="33%"><strong>Honest
|
|
30
|
+
<td width="33%"><strong>Honest coverage</strong><br>Portable, full, partial, and failed runs stay distinguishable.</td>
|
|
30
31
|
<td width="33%"><strong>CI-native output</strong><br>One run emits terminal, Markdown, JSON, SARIF, manifest, and Code Quality reports.</td>
|
|
31
32
|
</tr>
|
|
32
33
|
</table>
|
|
@@ -89,7 +90,7 @@ flowchart LR
|
|
|
89
90
|
|
|
90
91
|
1. **Snapshot** resolves the exact tree the review is about.
|
|
91
92
|
2. **Ground** builds the available type, syntax, dependency, and reference oracles.
|
|
92
|
-
3. **Plan** assigns checks and
|
|
93
|
+
3. **Plan** assigns baseline checks and enriched semantic capabilities to each file individually.
|
|
93
94
|
4. **Verify** runs deterministic checks and records what actually executed.
|
|
94
95
|
5. **Judge** optionally reviews bounded bundles of related files.
|
|
95
96
|
6. **Manifest** decides whether the result is complete, partial, or failed.
|
|
@@ -108,8 +109,11 @@ Every finding says where it came from:
|
|
|
108
109
|
| `verified` | `firm` | A deterministic heuristic fired; inspect the evidence |
|
|
109
110
|
| `judged` | `firm` or `tentative` | A model supplied the judgement and provenance |
|
|
110
111
|
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
Portable coverage is the default: self-contained oracles run without bootstrapping the
|
|
113
|
+
reviewed repository, while unavailable compiler/reference depth stays visible in the
|
|
114
|
+
manifest and reports. Set `"coverage": "strict"`, or explicitly select a check with
|
|
115
|
+
`--checks`, when a missing semantic oracle must make the run partial. An unavailable
|
|
116
|
+
oracle is never counted as a pass in either profile.
|
|
113
117
|
|
|
114
118
|
## Deterministic checks
|
|
115
119
|
|
|
@@ -174,33 +178,38 @@ commands.
|
|
|
174
178
|
|
|
175
179
|
```mermaid
|
|
176
180
|
flowchart LR
|
|
177
|
-
START["Selected files and checks"] --> ACCOUNT{"
|
|
178
|
-
ACCOUNT -- "yes" -->
|
|
181
|
+
START["Selected files and checks"] --> ACCOUNT{"Required work accounted for?"}
|
|
182
|
+
ACCOUNT -- "yes" --> DEPTH{"Enriched semantic depth available?"}
|
|
183
|
+
DEPTH -- "yes" --> FULL["full coverage"]
|
|
184
|
+
DEPTH -- "no · portable policy" --> PORTABLE["portable coverage · gaps named"]
|
|
185
|
+
FULL --> FINDINGS{"Findings?"}
|
|
186
|
+
PORTABLE --> FINDINGS
|
|
179
187
|
FINDINGS -- "no" --> CLEAN["exit 0 · complete and clean"]
|
|
180
188
|
FINDINGS -- "yes" --> FOUND["exit 1 · complete with findings"]
|
|
181
|
-
ACCOUNT -- "
|
|
189
|
+
ACCOUNT -- "required oracle or budget gap" --> PARTIAL["exit 3 · partial"]
|
|
182
190
|
ACCOUNT -- "required stage failed" --> FAILED["exit 3 · failed"]
|
|
183
191
|
|
|
184
192
|
classDef neutral fill:#172033,stroke:#57a6ff,color:#f0f6fc,stroke-width:2px
|
|
185
193
|
classDef good fill:#17251f,stroke:#4ac58b,color:#f0f6fc,stroke-width:2px
|
|
186
194
|
classDef warn fill:#2a2117,stroke:#f2b84b,color:#f0f6fc,stroke-width:2px
|
|
187
195
|
classDef bad fill:#2a191b,stroke:#ff675c,color:#f0f6fc,stroke-width:2px
|
|
188
|
-
class START,ACCOUNT,FINDINGS neutral
|
|
196
|
+
class START,ACCOUNT,DEPTH,FINDINGS neutral
|
|
189
197
|
class CLEAN good
|
|
190
|
-
class FOUND,PARTIAL warn
|
|
198
|
+
class FULL,PORTABLE,FOUND,PARTIAL warn
|
|
191
199
|
class FAILED bad
|
|
192
200
|
```
|
|
193
201
|
|
|
194
202
|
| Exit | Contract |
|
|
195
203
|
|---:|---|
|
|
196
|
-
| `0` | Review completed and found nothing at the selected severity |
|
|
197
|
-
| `1` | Review completed and reported findings |
|
|
204
|
+
| `0` | Review completed in full or portable coverage and found nothing at the selected severity |
|
|
205
|
+
| `1` | Review completed in full or portable coverage and reported findings |
|
|
198
206
|
| `2` | Command or Git input was invalid |
|
|
199
207
|
| `3` | Review is incomplete; findings may be missing |
|
|
200
208
|
|
|
201
|
-
“No findings
|
|
202
|
-
outcomes. Use `--format manifest` to inspect file
|
|
203
|
-
|
|
209
|
+
“No findings in portable coverage”, “No findings”, and “PowerShot could not look” are
|
|
210
|
+
intentionally different outcomes. Use `--format manifest` to inspect `coverage`, file
|
|
211
|
+
dispositions, executed and unavailable checks, failures, judge units, and
|
|
212
|
+
`notLookedAt`.
|
|
204
213
|
|
|
205
214
|
## CI integration
|
|
206
215
|
|
|
@@ -211,8 +220,6 @@ The composite action is the shortest setup for GitHub:
|
|
|
211
220
|
with:
|
|
212
221
|
fetch-depth: 0
|
|
213
222
|
|
|
214
|
-
- run: npm ci --ignore-scripts
|
|
215
|
-
|
|
216
223
|
- uses: xcrft/powershot@v1
|
|
217
224
|
with:
|
|
218
225
|
verify-only: 'true'
|
|
@@ -222,11 +229,11 @@ The composite action is the shortest setup for GitHub:
|
|
|
222
229
|
fail-on-findings: 'true'
|
|
223
230
|
```
|
|
224
231
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
232
|
+
The default portable profile needs no install from the checked-out repository. That is
|
|
233
|
+
the safe default for private monorepos and fork pull requests: do not expose a package
|
|
234
|
+
registry credential merely to enrich a review of untrusted code. If a trusted job
|
|
235
|
+
already has dependencies, PowerShot uses their TypeScript declarations automatically.
|
|
236
|
+
Set `"coverage": "strict"` when missing compiler/reference oracles must block instead.
|
|
230
237
|
|
|
231
238
|
PowerShot discovers `tsconfig.json` and `tsconfig.*.json` along the ancestor chain of
|
|
232
239
|
each changed file. One review can use several independent package projects, skip
|
|
@@ -266,6 +273,7 @@ Anthropic, OpenAI, and Gemini providers are supported.
|
|
|
266
273
|
"judges": {
|
|
267
274
|
"enable": ["plausible-logic", "test-adequacy", "intent"]
|
|
268
275
|
},
|
|
276
|
+
"coverage": "portable",
|
|
269
277
|
"minSeverity": "low",
|
|
270
278
|
"ignore": ["**/generated/**"],
|
|
271
279
|
"promptCache": true
|
|
@@ -300,8 +308,11 @@ psh review --verify-only --absorb /tmp/powershot-findings.json
|
|
|
300
308
|
| C | Syntax-backed checks that do not require exception semantics |
|
|
301
309
|
| Solidity | Declared syntax-backed checks |
|
|
302
310
|
|
|
303
|
-
|
|
304
|
-
|
|
311
|
+
Every declared language is parsed in disposable, language-isolated workers. Sources
|
|
312
|
+
are sent in bounded batches, so a mixed-language monorepo does not accumulate every
|
|
313
|
+
compiled WASM grammar in one process. If a declared parser cannot run, the review
|
|
314
|
+
fails loudly; it is never silently waived. All eleven packs plus TypeScript and
|
|
315
|
+
JavaScript are exercised together by the integration suite.
|
|
305
316
|
|
|
306
317
|
## Project guide
|
|
307
318
|
|
package/dist/cli/reports.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { unavailableCoverage } from '#app/manifest.js';
|
|
2
3
|
import { dim } from '#app/report/ansi.js';
|
|
3
4
|
import { codeQuality } from '#app/report/codequality.js';
|
|
4
5
|
import { compact } from '#app/report/compact.js';
|
|
@@ -44,6 +45,8 @@ export function renderReport(format, result, manifest, target) {
|
|
|
44
45
|
...result.stats,
|
|
45
46
|
state: manifest.state,
|
|
46
47
|
notLookedAt: manifest.notLookedAt,
|
|
48
|
+
coverage: manifest.coverage,
|
|
49
|
+
unavailableCoverage: unavailableCoverage(manifest),
|
|
47
50
|
});
|
|
48
51
|
}
|
|
49
52
|
export function publishReports(options) {
|
|
@@ -5,7 +5,7 @@ import { loadConfig, policyChanged } from '#app/config.js';
|
|
|
5
5
|
import { absorbDelegated, delegateBrief } from '#app/delegate.js';
|
|
6
6
|
import { baseRefOf, checkRange, headSha, repoRoot, shaOf } from '#app/git.js';
|
|
7
7
|
import { JUDGES } from '#app/judges/prompts.js';
|
|
8
|
-
import { RunManifest, coverageProblems, hashOf, writeManifest } from '#app/manifest.js';
|
|
8
|
+
import { RunManifest, coverageProblems, hashOf, unavailableCoverage, writeManifest } from '#app/manifest.js';
|
|
9
9
|
import { Trace } from '#app/otel.js';
|
|
10
10
|
import { PACKAGE_VERSION } from '#app/package-meta.js';
|
|
11
11
|
import { dim, yellow } from '#app/report/ansi.js';
|
|
@@ -225,6 +225,7 @@ export async function runReviewCommand(command, values, positionals) {
|
|
|
225
225
|
},
|
|
226
226
|
files: result.plan?.items() ?? [],
|
|
227
227
|
skippedChecks: result.skippedChecks ?? [],
|
|
228
|
+
unavailableChecks: result.unavailableChecks ?? [],
|
|
228
229
|
findings: {
|
|
229
230
|
total: result.findings.length,
|
|
230
231
|
verified: result.stats.verified,
|
|
@@ -246,7 +247,12 @@ export async function runReviewCommand(command, values, positionals) {
|
|
|
246
247
|
record.notLookedAt.push(failure);
|
|
247
248
|
process.stderr.write(yellow(' ◇ manifest') + dim(' ' + gaps.join('; ')) + '\n');
|
|
248
249
|
}
|
|
249
|
-
session?.saveReport(result.findings, {
|
|
250
|
+
session?.saveReport(result.findings, {
|
|
251
|
+
state: record.state,
|
|
252
|
+
notLookedAt: record.notLookedAt,
|
|
253
|
+
coverage: record.coverage,
|
|
254
|
+
unavailableCoverage: unavailableCoverage(record),
|
|
255
|
+
});
|
|
250
256
|
writeManifest(root, record);
|
|
251
257
|
publishReports({
|
|
252
258
|
format: values.format,
|
|
@@ -42,6 +42,8 @@ export function runSessionCommand(positionals) {
|
|
|
42
42
|
started: session.started,
|
|
43
43
|
state: session.report.state ?? 'unknown',
|
|
44
44
|
notLookedAt: session.report.notLookedAt ?? ['session predates verdict recording'],
|
|
45
|
+
coverage: session.report.coverage,
|
|
46
|
+
unavailableCoverage: session.report.unavailableCoverage,
|
|
45
47
|
}));
|
|
46
48
|
process.stdout.write(output + '\n');
|
|
47
49
|
return 0;
|
package/dist/config.js
CHANGED
|
@@ -9,6 +9,7 @@ const DEFAULTS = {
|
|
|
9
9
|
// security may duplicate SAST; convention needs repository idioms in the diff
|
|
10
10
|
judges: ['plausible-logic', 'test-adequacy', 'intent'],
|
|
11
11
|
minSeverity: 'low',
|
|
12
|
+
coverage: 'portable',
|
|
12
13
|
// findings in vendored trees are not decisions made by this repository
|
|
13
14
|
ignore: [
|
|
14
15
|
'**/node_modules/**', '**/dist/**', '**/build/**', '**/*.generated.*', '**/*.min.js',
|
|
@@ -33,6 +34,7 @@ export function policyChanged(root, baseRef) {
|
|
|
33
34
|
}
|
|
34
35
|
const KEYS = new Set([...Object.keys(DEFAULTS), 'checks']);
|
|
35
36
|
const PROVIDERS = new Set(['anthropic', 'openai', 'gemini']);
|
|
37
|
+
const COVERAGE = new Set(['portable', 'strict']);
|
|
36
38
|
/** A misspelled name in the config is the quietest way to get a clean review. */
|
|
37
39
|
export function validateConfig(raw, known) {
|
|
38
40
|
const problems = [];
|
|
@@ -50,6 +52,9 @@ export function validateConfig(raw, known) {
|
|
|
50
52
|
if (raw.minSeverity !== undefined && !SEVERITIES.includes(raw.minSeverity)) {
|
|
51
53
|
problems.push('minSeverity "' + String(raw.minSeverity) + '" is not one of: ' + SEVERITIES.join(', '));
|
|
52
54
|
}
|
|
55
|
+
if (raw.coverage !== undefined && !COVERAGE.has(String(raw.coverage))) {
|
|
56
|
+
problems.push('coverage "' + String(raw.coverage) + '" is not one of: ' + [...COVERAGE].join(', '));
|
|
57
|
+
}
|
|
53
58
|
for (const [field, names] of [['verifiers', known.verifiers], ['judges', known.judges]]) {
|
|
54
59
|
const value = raw[field];
|
|
55
60
|
if (value === undefined)
|
package/dist/ground.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Project, SyntaxKind } from 'ts-morph';
|
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { decode } from './text.js';
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
-
import { packFor,
|
|
5
|
+
import { PACKS, packFor, parseIsolated } from './lang/packs.js';
|
|
6
6
|
import { insideRepo, isSymlink, repoPath } from './fspolicy.js';
|
|
7
7
|
const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
8
8
|
const TS_CONFIG = /^tsconfig(?:\..+)?\.json$/i;
|
|
@@ -360,7 +360,7 @@ export function readEnvManifest(root) {
|
|
|
360
360
|
return undefined;
|
|
361
361
|
}
|
|
362
362
|
async function parseForeign(root, changed, signal) {
|
|
363
|
-
const
|
|
363
|
+
const byLanguage = new Map();
|
|
364
364
|
for (const c of changed) {
|
|
365
365
|
// parsing thousands of files is where a large scan spends its time, so a signal
|
|
366
366
|
// has to be honoured here rather than only once the checks begin
|
|
@@ -376,13 +376,66 @@ async function parseForeign(root, changed, signal) {
|
|
|
376
376
|
// costs seconds to produce findings nobody acts on
|
|
377
377
|
if ((statSync(abs, { throwIfNoEntry: false })?.size ?? 0) > 512 * 1024)
|
|
378
378
|
continue;
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
379
|
+
const list = byLanguage.get(pack.name) ?? [];
|
|
380
|
+
list.push({
|
|
381
|
+
changed: c,
|
|
382
|
+
source: decode(readFileSync(abs)),
|
|
383
|
+
// A generated base can be arbitrarily larger than the reviewed result. Do not
|
|
384
|
+
// smuggle it past the current-file limit through the before/after channel.
|
|
385
|
+
beforeSource: c.before !== undefined && Buffer.byteLength(c.before) <= 512 * 1024
|
|
386
|
+
? c.before
|
|
387
|
+
: undefined,
|
|
388
|
+
});
|
|
389
|
+
byLanguage.set(pack.name, list);
|
|
384
390
|
}
|
|
385
|
-
|
|
391
|
+
const parsed = new Map();
|
|
392
|
+
// A worker holds one grammar and a bounded source batch. This keeps both WASM
|
|
393
|
+
// compilation and structured-clone payloads independent of monorepo size.
|
|
394
|
+
const MAX_BATCH_BYTES = 8 * 1024 * 1024;
|
|
395
|
+
const MAX_BATCH_FILES = 128;
|
|
396
|
+
for (const pack of PACKS) {
|
|
397
|
+
const candidates = byLanguage.get(pack.name) ?? [];
|
|
398
|
+
for (let start = 0; start < candidates.length;) {
|
|
399
|
+
let end = start;
|
|
400
|
+
let bytes = 0;
|
|
401
|
+
while (end < candidates.length && end - start < MAX_BATCH_FILES) {
|
|
402
|
+
const candidate = candidates[end];
|
|
403
|
+
const next = Buffer.byteLength(candidate.source) + Buffer.byteLength(candidate.beforeSource ?? '');
|
|
404
|
+
if (end > start && bytes + next > MAX_BATCH_BYTES)
|
|
405
|
+
break;
|
|
406
|
+
bytes += next;
|
|
407
|
+
end++;
|
|
408
|
+
}
|
|
409
|
+
const batch = candidates.slice(start, end);
|
|
410
|
+
const sources = batch.flatMap((candidate) => candidate.beforeSource === undefined
|
|
411
|
+
? [candidate.source]
|
|
412
|
+
: [candidate.source, candidate.beforeSource]);
|
|
413
|
+
const trees = await parseIsolated(pack, sources, signal);
|
|
414
|
+
let index = 0;
|
|
415
|
+
for (const candidate of batch) {
|
|
416
|
+
const tree = trees[index++];
|
|
417
|
+
const beforeTree = candidate.beforeSource === undefined ? undefined : trees[index++];
|
|
418
|
+
if (!tree)
|
|
419
|
+
continue;
|
|
420
|
+
parsed.set(candidate.changed.path, {
|
|
421
|
+
path: candidate.changed.path,
|
|
422
|
+
pack,
|
|
423
|
+
tree,
|
|
424
|
+
beforeTree,
|
|
425
|
+
changed: candidate.changed,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
start = end;
|
|
429
|
+
if (signal?.aborted)
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
if (signal?.aborted)
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
return changed.flatMap((file) => {
|
|
436
|
+
const result = parsed.get(file.path);
|
|
437
|
+
return result ? [result] : [];
|
|
438
|
+
});
|
|
386
439
|
}
|
|
387
440
|
function buildSymbolIndex(sourceFiles, root) {
|
|
388
441
|
const index = new Map();
|
package/dist/lang/packs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
+
import { Worker } from 'node:worker_threads';
|
|
3
4
|
/** Shared defaults; a pack overrides only what its grammar spells differently. */
|
|
4
5
|
const COMMON_NODES = {
|
|
5
6
|
identifier: ['identifier'],
|
|
@@ -498,15 +499,6 @@ export function packFor(path) {
|
|
|
498
499
|
}
|
|
499
500
|
let ready;
|
|
500
501
|
const parsers = new Map();
|
|
501
|
-
/**
|
|
502
|
-
* Measured, not guessed, and the measurement is worth writing down because the naive
|
|
503
|
-
* one is misleading. Loading grammars is cheap — all eleven load for ~143MB. Parsing
|
|
504
|
-
* with them is not: V8 tiers up each wasm module in the background, and RSS climbed
|
|
505
|
-
* 63 → 690MB across eleven before the process died inside that compilation. Six
|
|
506
|
-
* grammars sat at ~131MB and were comfortable.
|
|
507
|
-
*/
|
|
508
|
-
const MAX_GRAMMARS = 6;
|
|
509
|
-
export const skippedLanguages = [];
|
|
510
502
|
/**
|
|
511
503
|
* Grammars load lazily and once. A repository with no Python pays nothing for
|
|
512
504
|
* Python, and the wasm runtime is only initialised when a foreign file appears.
|
|
@@ -515,11 +507,6 @@ async function parserFor(pack) {
|
|
|
515
507
|
const cached = parsers.get(pack.name);
|
|
516
508
|
if (cached)
|
|
517
509
|
return cached;
|
|
518
|
-
if (parsers.size >= MAX_GRAMMARS) {
|
|
519
|
-
if (!skippedLanguages.includes(pack.name))
|
|
520
|
-
skippedLanguages.push(pack.name);
|
|
521
|
-
return undefined;
|
|
522
|
-
}
|
|
523
510
|
try {
|
|
524
511
|
if (!ready) {
|
|
525
512
|
ready = (async () => {
|
|
@@ -554,4 +541,100 @@ export async function parse(pack, source) {
|
|
|
554
541
|
return undefined;
|
|
555
542
|
}
|
|
556
543
|
}
|
|
544
|
+
/** Turn a native WASM-backed tree into data that can cross a worker boundary. */
|
|
545
|
+
export function serializeTree(tree) {
|
|
546
|
+
const copy = (raw, field) => {
|
|
547
|
+
const node = raw;
|
|
548
|
+
const children = [];
|
|
549
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
550
|
+
const child = node.child(i);
|
|
551
|
+
if (child)
|
|
552
|
+
children.push(copy(child, node.fieldNameForChild(i) ?? undefined));
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
type: node.type,
|
|
556
|
+
startIndex: node.startIndex,
|
|
557
|
+
endIndex: node.endIndex,
|
|
558
|
+
startPosition: { ...node.startPosition },
|
|
559
|
+
endPosition: { ...node.endPosition },
|
|
560
|
+
named: node.isNamed,
|
|
561
|
+
field,
|
|
562
|
+
children,
|
|
563
|
+
};
|
|
564
|
+
};
|
|
565
|
+
return { root: copy(tree.rootNode) };
|
|
566
|
+
}
|
|
567
|
+
/** Restore the small Node interface the language-independent verifiers consume. */
|
|
568
|
+
function hydrateTree(source, tree) {
|
|
569
|
+
const hydrate = (data) => {
|
|
570
|
+
const children = data.children.map(hydrate);
|
|
571
|
+
return {
|
|
572
|
+
type: data.type,
|
|
573
|
+
get text() { return source.slice(data.startIndex, data.endIndex); },
|
|
574
|
+
startPosition: { ...data.startPosition },
|
|
575
|
+
endPosition: { ...data.endPosition },
|
|
576
|
+
childCount: children.length,
|
|
577
|
+
child: (index) => children[index] ?? null,
|
|
578
|
+
namedChildren: children.filter((_, index) => data.children[index]?.named),
|
|
579
|
+
childForFieldName: (name) => {
|
|
580
|
+
const index = data.children.findIndex((child) => child.field === name);
|
|
581
|
+
return index < 0 ? null : (children[index] ?? null);
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
};
|
|
585
|
+
return { rootNode: hydrate(tree.root) };
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Parse one language in a disposable worker.
|
|
589
|
+
*
|
|
590
|
+
* V8 keeps compiled WASM grammars alive longer than their JS parsers. Eleven
|
|
591
|
+
* grammars in one process reached ~690MB and killed real mixed-language runs.
|
|
592
|
+
* One worker owns one grammar, returns plain trees, and is then terminated, so
|
|
593
|
+
* compiled-grammar memory is bounded by one language rather than by the monorepo's
|
|
594
|
+
* language count. Plain trees still scale with the selected diff.
|
|
595
|
+
*/
|
|
596
|
+
export function parseIsolated(pack, sources, signal) {
|
|
597
|
+
if (sources.length === 0)
|
|
598
|
+
return Promise.resolve([]);
|
|
599
|
+
if (signal?.aborted)
|
|
600
|
+
return Promise.resolve(sources.map(() => undefined));
|
|
601
|
+
return new Promise((resolve) => {
|
|
602
|
+
let worker;
|
|
603
|
+
let settled = false;
|
|
604
|
+
const empty = () => sources.map(() => undefined);
|
|
605
|
+
const finish = (trees) => {
|
|
606
|
+
if (settled)
|
|
607
|
+
return;
|
|
608
|
+
settled = true;
|
|
609
|
+
signal?.removeEventListener('abort', abort);
|
|
610
|
+
// Resolve only after V8 has released this worker's WASM grammar. Otherwise a
|
|
611
|
+
// fast next batch can overlap termination and recreate the memory spike this
|
|
612
|
+
// isolation boundary exists to prevent.
|
|
613
|
+
void worker.terminate().then(() => resolve(trees), () => resolve(trees));
|
|
614
|
+
};
|
|
615
|
+
const abort = () => finish(empty());
|
|
616
|
+
try {
|
|
617
|
+
worker = new Worker(new URL('./parse-worker.js', import.meta.url), {
|
|
618
|
+
workerData: { language: pack.name, sources },
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
resolve(empty());
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
626
|
+
worker.once('message', (raw) => {
|
|
627
|
+
if (!Array.isArray(raw) || raw.length !== sources.length) {
|
|
628
|
+
finish(empty());
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
finish(raw.map((tree, index) => tree ? hydrateTree(sources[index], tree) : undefined));
|
|
632
|
+
});
|
|
633
|
+
worker.once('error', () => finish(empty()));
|
|
634
|
+
worker.once('exit', () => finish(empty()));
|
|
635
|
+
// Close the narrow race between the early check and listener registration.
|
|
636
|
+
if (signal?.aborted)
|
|
637
|
+
abort();
|
|
638
|
+
});
|
|
639
|
+
}
|
|
557
640
|
//# sourceMappingURL=packs.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { PACKS, parse, serializeTree } from './packs.js';
|
|
3
|
+
async function run(input) {
|
|
4
|
+
const pack = PACKS.find((candidate) => candidate.name === input.language);
|
|
5
|
+
if (!pack || !Array.isArray(input.sources))
|
|
6
|
+
return [];
|
|
7
|
+
const trees = [];
|
|
8
|
+
for (const source of input.sources) {
|
|
9
|
+
const tree = await parse(pack, source);
|
|
10
|
+
trees.push(tree ? serializeTree(tree) : undefined);
|
|
11
|
+
}
|
|
12
|
+
return trees;
|
|
13
|
+
}
|
|
14
|
+
void run(workerData).then((trees) => parentPort?.postMessage(trees), () => parentPort?.postMessage([]));
|
|
15
|
+
//# sourceMappingURL=parse-worker.js.map
|
package/dist/lang/python-deps.js
CHANGED
|
@@ -135,11 +135,20 @@ const SKIP_DIRS = new Set([
|
|
|
135
135
|
'node_modules', '.git', '.venv', 'venv', 'env', '__pycache__', 'dist', 'build',
|
|
136
136
|
'.mypy_cache', '.pytest_cache', '.tox', 'site-packages', 'target', '.next',
|
|
137
137
|
]);
|
|
138
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Importable names near one changed Python file.
|
|
140
|
+
*
|
|
141
|
+
* Only direct entries in its ancestor chain and conventional source roots are read.
|
|
142
|
+
* That bounds work by path depth instead of repository size, while still covering
|
|
143
|
+
* `src/pkg` beside `tests/test_pkg.py` and namespace packages without __init__.py.
|
|
144
|
+
*/
|
|
145
|
+
export function localModules(root, from = root) {
|
|
139
146
|
const local = new Set();
|
|
140
|
-
const
|
|
141
|
-
|
|
147
|
+
const scanned = new Set();
|
|
148
|
+
const inspect = (dir) => {
|
|
149
|
+
if (scanned.has(dir))
|
|
142
150
|
return;
|
|
151
|
+
scanned.add(dir);
|
|
143
152
|
let entries;
|
|
144
153
|
try {
|
|
145
154
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -147,21 +156,25 @@ export function localModules(root) {
|
|
|
147
156
|
catch {
|
|
148
157
|
return;
|
|
149
158
|
}
|
|
150
|
-
const isPackage = entries.some((e) => e.isFile() && e.name === '__init__.py');
|
|
151
159
|
for (const entry of entries) {
|
|
152
160
|
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name))
|
|
153
161
|
continue;
|
|
154
162
|
if (entry.isDirectory()) {
|
|
155
|
-
//
|
|
163
|
+
// Namespace packages are importable without __init__.py too.
|
|
156
164
|
local.add(entry.name);
|
|
157
|
-
walk(join(dir, entry.name), depth + 1);
|
|
158
165
|
}
|
|
159
|
-
else if (entry.name.endsWith('.py') &&
|
|
166
|
+
else if (entry.isFile() && entry.name.endsWith('.py') && entry.name !== '__init__.py') {
|
|
160
167
|
local.add(entry.name.slice(0, -3));
|
|
161
168
|
}
|
|
162
169
|
}
|
|
163
170
|
};
|
|
164
|
-
|
|
171
|
+
for (let dir = from;; dir = dirname(dir)) {
|
|
172
|
+
inspect(dir);
|
|
173
|
+
for (const source of ['src', 'lib', 'python'])
|
|
174
|
+
inspect(join(dir, source));
|
|
175
|
+
if (dir === root || dirname(dir) === dir)
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
165
178
|
return local;
|
|
166
179
|
}
|
|
167
180
|
export function isPhantom(importName, manifest, local) {
|
package/dist/manifest.js
CHANGED
|
@@ -2,6 +2,23 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
export const SCHEMA = 'powershot.run/v1';
|
|
5
|
+
/** Human-readable optional depth, kept separate from verdict-blocking notLookedAt. */
|
|
6
|
+
export function unavailableCoverage(record) {
|
|
7
|
+
const out = [];
|
|
8
|
+
const files = (record.files ?? []).filter((file) => file.unavailable?.length);
|
|
9
|
+
if (files.length > 0) {
|
|
10
|
+
out.push(files.length + ' file(s) without enriched semantic coverage: ' +
|
|
11
|
+
files.slice(0, 5).map((file) => file.path + ' (' + file.unavailable.join(', ') + ')').join(', ') +
|
|
12
|
+
(files.length > 5 ? ', …' : ''));
|
|
13
|
+
}
|
|
14
|
+
const checks = record.checks?.unavailable ?? [];
|
|
15
|
+
if (checks.length > 0) {
|
|
16
|
+
out.push(checks.length + ' enriched check(s) unavailable: ' +
|
|
17
|
+
checks.slice(0, 8).map((check) => check.check + ' (no ' + check.missing + ')').join(', ') +
|
|
18
|
+
(checks.length > 8 ? ', …' : ''));
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
5
22
|
/** The single state machine behind manifests, benches, renderers and exit codes. */
|
|
6
23
|
export function completionOf(parts) {
|
|
7
24
|
const waivedUnits = parts.units.filter((unit) => unit.outcome === 'waived').length;
|
|
@@ -65,12 +82,20 @@ export class RunManifest {
|
|
|
65
82
|
...file,
|
|
66
83
|
checks: [...file.checks],
|
|
67
84
|
missing: file.missing ? [...file.missing] : undefined,
|
|
85
|
+
unavailable: file.unavailable ? [...file.unavailable] : undefined,
|
|
68
86
|
})),
|
|
69
87
|
units: this.units.map((unit) => ({ ...unit })),
|
|
70
88
|
checks: {
|
|
71
89
|
ran: [...this.ranChecks],
|
|
72
90
|
skipped: parts.skippedChecks.map((check) => ({ ...check })),
|
|
91
|
+
...((parts.unavailableChecks?.length ?? 0) > 0
|
|
92
|
+
? { unavailable: parts.unavailableChecks.map((check) => ({ ...check })) }
|
|
93
|
+
: {}),
|
|
73
94
|
},
|
|
95
|
+
coverage: parts.files.some((file) => file.missing?.length || file.unavailable?.length) ||
|
|
96
|
+
parts.skippedChecks.length > 0 || (parts.unavailableChecks?.length ?? 0) > 0
|
|
97
|
+
? 'portable'
|
|
98
|
+
: 'full',
|
|
74
99
|
findings: { ...parts.findings },
|
|
75
100
|
usage: { ...parts.usage },
|
|
76
101
|
state: completion.state,
|
|
@@ -118,6 +143,15 @@ export function coverageProblems(m) {
|
|
|
118
143
|
if (f.disposition !== 'selected' && f.checks.length > 0) {
|
|
119
144
|
problems.push(f.path + ': ' + f.disposition + ' file received checks');
|
|
120
145
|
}
|
|
146
|
+
if (f.disposition !== 'selected' && f.unavailable?.length) {
|
|
147
|
+
problems.push(f.path + ': ' + f.disposition + ' file has unavailable coverage');
|
|
148
|
+
}
|
|
149
|
+
const missingCaps = new Set(f.missing ?? []);
|
|
150
|
+
for (const capability of f.unavailable ?? []) {
|
|
151
|
+
if (missingCaps.has(capability)) {
|
|
152
|
+
problems.push(f.path + ': capability is both required and unavailable: ' + capability);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
121
155
|
const local = new Set();
|
|
122
156
|
for (const check of f.checks) {
|
|
123
157
|
if (local.has(check))
|
|
@@ -157,6 +191,21 @@ export function coverageProblems(m) {
|
|
|
157
191
|
if (ran.has(check.check))
|
|
158
192
|
problems.push('check counted as both ran and skipped: ' + check.check);
|
|
159
193
|
}
|
|
194
|
+
const unavailable = new Set();
|
|
195
|
+
for (const check of m.checks.unavailable ?? []) {
|
|
196
|
+
if (unavailable.has(check.check))
|
|
197
|
+
problems.push('check counted twice as unavailable: ' + check.check);
|
|
198
|
+
unavailable.add(check.check);
|
|
199
|
+
if (skipped.has(check.check))
|
|
200
|
+
problems.push('check counted as both skipped and unavailable: ' + check.check);
|
|
201
|
+
}
|
|
202
|
+
const expectedCoverage = m.files.some((file) => file.missing?.length || file.unavailable?.length) ||
|
|
203
|
+
m.checks.skipped.length > 0 || (m.checks.unavailable?.length ?? 0) > 0
|
|
204
|
+
? 'portable'
|
|
205
|
+
: 'full';
|
|
206
|
+
if (m.coverage !== undefined && m.coverage !== expectedCoverage) {
|
|
207
|
+
problems.push('coverage is ' + m.coverage + ' but accounting says ' + expectedCoverage);
|
|
208
|
+
}
|
|
160
209
|
// a judged run that reports complete must have reached every unit it selected
|
|
161
210
|
const unreached = m.units.filter((u) => u.outcome === 'failed' || u.outcome === 'waived');
|
|
162
211
|
if (m.state === 'complete' && unreached.length > 0) {
|
package/dist/plan.js
CHANGED
|
@@ -67,6 +67,13 @@ export class SelectionPlan {
|
|
|
67
67
|
row.missing = [...new Set([...(row.missing ?? []), ...missing])];
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
/** Record optional semantic depth that this environment could not provide. */
|
|
71
|
+
noteUnavailable(path, unavailable) {
|
|
72
|
+
const row = this.rows.get(path);
|
|
73
|
+
if (row && row.disposition === 'selected' && unavailable.length > 0) {
|
|
74
|
+
row.unavailable = [...new Set([...(row.unavailable ?? []), ...unavailable])];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
70
77
|
/** Record coverage at the same file granularity used to decide applicability. */
|
|
71
78
|
checked(path, check) {
|
|
72
79
|
const row = this.rows.get(path);
|
package/dist/report/markdown.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unavailableCoverage } from '#app/manifest.js';
|
|
1
2
|
const MARK = { verified: '▣', judged: '▚' };
|
|
2
3
|
/** Untrusted prose encoded as literal CommonMark text. */
|
|
3
4
|
function text(s) {
|
|
@@ -63,12 +64,27 @@ export function markdown(findings, run) {
|
|
|
63
64
|
'',
|
|
64
65
|
]
|
|
65
66
|
: [];
|
|
67
|
+
const portable = run?.state === 'complete' && run.coverage === 'portable';
|
|
68
|
+
const coverage = portable
|
|
69
|
+
? [
|
|
70
|
+
'> [!NOTE]',
|
|
71
|
+
'> **Portable coverage.** Self-contained oracles ran; enriched semantic depth was unavailable:',
|
|
72
|
+
...unavailableCoverage(run).map((reason) => '> - ' + text(reason)),
|
|
73
|
+
'',
|
|
74
|
+
]
|
|
75
|
+
: [];
|
|
66
76
|
if (findings.length === 0) {
|
|
67
|
-
return [
|
|
77
|
+
return [
|
|
78
|
+
'## PowerShot', '', ...banner, ...coverage,
|
|
79
|
+
incomplete
|
|
80
|
+
? 'No findings *from what it managed to review*.'
|
|
81
|
+
: portable ? 'No findings in portable coverage.' : 'No findings.',
|
|
82
|
+
'',
|
|
83
|
+
].join('\n');
|
|
68
84
|
}
|
|
69
85
|
const verified = findings.filter((f) => f.class === 'verified').length;
|
|
70
86
|
const judged = findings.length - verified;
|
|
71
|
-
const out = ['## PowerShot', '', ...banner];
|
|
87
|
+
const out = ['## PowerShot', '', ...banner, ...coverage];
|
|
72
88
|
out.push('**' + verified + ' verified** (deterministic, 0 tokens) · **' + judged + ' judged** (agent)', '');
|
|
73
89
|
for (const [file, list] of group(findings)) {
|
|
74
90
|
out.push('### `' + path(file) + '`', '');
|