@0xcraft/powershot 1.1.1 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -25
- package/dist/cli/reports.js +4 -3
- package/dist/cli/review-command.js +4 -1
- package/dist/cli/session-command.js +6 -0
- package/dist/config.js +5 -0
- package/dist/github/api.js +198 -0
- package/dist/github/inline-comments.js +3 -154
- package/dist/github/summary-comment.js +149 -0
- package/dist/ground.js +61 -8
- package/dist/lang/packs.js +97 -14
- package/dist/lang/parse-worker.js +15 -0
- package/dist/lang/python-deps.js +21 -8
- package/dist/manifest.js +32 -0
- package/dist/package-smoke.js +4 -0
- package/dist/plan.js +7 -0
- package/dist/report/markdown.js +31 -3
- package/dist/report/summary.js +103 -0
- package/dist/report/terminal.js +19 -1
- package/dist/report/viewer.js +22 -3
- package/dist/review.js +39 -18
- package/dist/selftest.js +665 -10
- package/dist/session.js +7 -1
- package/dist/verifiers/foreign-phantom-dep.js +8 -2
- package/docs/architecture.md +38 -11
- package/docs/ci.md +45 -12
- package/examples/github-actions/action.yml +4 -5
- package/examples/github-actions/cli.yml +1 -1
- package/examples/gitlab/.gitlab-ci.yml +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { stripControl } from '#app/text.js';
|
|
5
|
+
import { expectedHeadShaFromEnvironment, githubPullRequestApiFromEnvironment, requiredEnvironment, } from './api.js';
|
|
6
|
+
export const LEGACY_SUMMARY_MARKER = '<!-- powershot:summary:v1 -->';
|
|
7
|
+
const BOT_LOGIN = 'github-actions[bot]';
|
|
8
|
+
const LEGACY_HEADING = /^## PowerShot(?:\r?\n|$)/;
|
|
9
|
+
/** Scope ownership to one workflow job without exposing its repository path in the comment. */
|
|
10
|
+
export function summaryMarker(scope) {
|
|
11
|
+
if (scope.length === 0)
|
|
12
|
+
throw new Error('PowerShot summary scope is required');
|
|
13
|
+
const digest = createHash('sha256').update(scope).digest('hex').slice(0, 24);
|
|
14
|
+
return `<!-- powershot:summary:v2:${digest} -->`;
|
|
15
|
+
}
|
|
16
|
+
/** Stable workflow identity: keep the repository/path, discard its moving ref. */
|
|
17
|
+
export function workflowCommentScope(workflowRef, job) {
|
|
18
|
+
const structuralSeparator = workflowRef.indexOf('@refs/');
|
|
19
|
+
const separator = structuralSeparator === -1 ? workflowRef.lastIndexOf('@') : structuralSeparator;
|
|
20
|
+
if (separator < 1 || separator === workflowRef.length - 1) {
|
|
21
|
+
throw new Error('GITHUB_WORKFLOW_REF must contain a workflow path and ref');
|
|
22
|
+
}
|
|
23
|
+
if (job.length === 0)
|
|
24
|
+
throw new Error('GITHUB_JOB is required');
|
|
25
|
+
return `${workflowRef.slice(0, separator)}:${job}`;
|
|
26
|
+
}
|
|
27
|
+
function headMarker(headSha) {
|
|
28
|
+
return `<!-- powershot:head:${headSha.toLowerCase()} -->`;
|
|
29
|
+
}
|
|
30
|
+
export function summaryCommentBody(markdown, marker, headSha) {
|
|
31
|
+
const report = markdown.trimEnd();
|
|
32
|
+
const ownership = `${marker}\n${headMarker(headSha)}`;
|
|
33
|
+
return report.length === 0 ? ownership : `${ownership}\n\n${report}`;
|
|
34
|
+
}
|
|
35
|
+
function owns(body, marker) {
|
|
36
|
+
return body === marker || body.startsWith(marker + '\n') || body.startsWith(marker + '\r\n');
|
|
37
|
+
}
|
|
38
|
+
function ownsHead(body, marker, headSha) {
|
|
39
|
+
const ownership = `${marker}\n${headMarker(headSha)}`;
|
|
40
|
+
return body === ownership || body.startsWith(ownership + '\n') || body.startsWith(ownership + '\r\n');
|
|
41
|
+
}
|
|
42
|
+
function latest(comments) {
|
|
43
|
+
return comments.reduce((selected, comment) => selected === undefined || comment.id > selected.id ? comment : selected, undefined);
|
|
44
|
+
}
|
|
45
|
+
async function retireComments(api, comments, keepId) {
|
|
46
|
+
const staleIds = comments
|
|
47
|
+
.filter((comment) => comment.id !== keepId)
|
|
48
|
+
.map((comment) => comment.id)
|
|
49
|
+
.filter((id, index, ids) => ids.indexOf(id) === index)
|
|
50
|
+
.sort((left, right) => left - right);
|
|
51
|
+
for (const id of staleIds)
|
|
52
|
+
await api.deleteIssueComment(id);
|
|
53
|
+
return staleIds.length;
|
|
54
|
+
}
|
|
55
|
+
async function retireCreatedIfUnchanged(api, id, body) {
|
|
56
|
+
const created = (await api.listIssueComments())
|
|
57
|
+
.find((comment) => comment.id === id && comment.body === body);
|
|
58
|
+
if (created === undefined)
|
|
59
|
+
return 0;
|
|
60
|
+
await api.deleteIssueComment(id);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
async function reconcileCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments, createdByThisRun, legacy) {
|
|
64
|
+
// REST has no atomic create-if-absent. Relisting makes same-head participants
|
|
65
|
+
// converge without allowing an old-head run to patch or retire a newer candidate.
|
|
66
|
+
const reconciled = (await api.listIssueComments()).filter((comment) => comment.user?.login === BOT_LOGIN &&
|
|
67
|
+
owns(comment.body, marker) &&
|
|
68
|
+
ownsHead(comment.body, marker, expectedHeadSha));
|
|
69
|
+
if (await api.headSha() !== expectedHeadSha) {
|
|
70
|
+
const retired = createdByThisRun === undefined
|
|
71
|
+
? 0
|
|
72
|
+
: await retireCreatedIfUnchanged(api, createdByThisRun.id, body);
|
|
73
|
+
return { state: 'outdated', retired };
|
|
74
|
+
}
|
|
75
|
+
const keep = latest(reconciled);
|
|
76
|
+
if (keep === undefined)
|
|
77
|
+
throw new Error('GitHub API did not return the summary comment it created');
|
|
78
|
+
const retired = await retireComments(api, [...reconciled, ...previousHeadComments, ...(legacy === undefined ? [] : [legacy])], keep.id);
|
|
79
|
+
if (await api.headSha() !== expectedHeadSha) {
|
|
80
|
+
const createdRetired = createdByThisRun === undefined
|
|
81
|
+
? 0
|
|
82
|
+
: await retireCreatedIfUnchanged(api, createdByThisRun.id, body);
|
|
83
|
+
return { state: 'outdated', retired: retired + createdRetired };
|
|
84
|
+
}
|
|
85
|
+
return { state, commentId: keep.id, retired };
|
|
86
|
+
}
|
|
87
|
+
async function createCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments, legacy) {
|
|
88
|
+
const created = await api.createIssueComment(body);
|
|
89
|
+
if (await api.headSha() !== expectedHeadSha) {
|
|
90
|
+
const retired = await retireCreatedIfUnchanged(api, created.id, body);
|
|
91
|
+
return { state: 'outdated', retired };
|
|
92
|
+
}
|
|
93
|
+
return reconcileCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments, created, legacy);
|
|
94
|
+
}
|
|
95
|
+
/** Reconcile only this workflow's PowerShot summary against the current pull-request head. */
|
|
96
|
+
export async function syncSummaryComment(api, markdown, expectedHeadSha, scope) {
|
|
97
|
+
if (await api.headSha() !== expectedHeadSha)
|
|
98
|
+
return { state: 'outdated', retired: 0 };
|
|
99
|
+
const marker = summaryMarker(scope);
|
|
100
|
+
const body = summaryCommentBody(markdown, marker, expectedHeadSha);
|
|
101
|
+
const comments = await api.listIssueComments();
|
|
102
|
+
if (await api.headSha() !== expectedHeadSha)
|
|
103
|
+
return { state: 'outdated', retired: 0 };
|
|
104
|
+
const botComments = comments.filter((comment) => comment.user?.login === BOT_LOGIN);
|
|
105
|
+
const markedComments = botComments.filter((comment) => owns(comment.body, marker));
|
|
106
|
+
const currentHeadComments = markedComments.filter((comment) => ownsHead(comment.body, marker, expectedHeadSha));
|
|
107
|
+
const previousHeadComments = markedComments.filter((comment) => !ownsHead(comment.body, marker, expectedHeadSha));
|
|
108
|
+
const candidate = latest(currentHeadComments);
|
|
109
|
+
if (candidate !== undefined) {
|
|
110
|
+
const state = candidate.body === body ? 'unchanged' : 'updated';
|
|
111
|
+
if (state === 'updated') {
|
|
112
|
+
// A comment id never changes head ownership. This PATCH can race only with
|
|
113
|
+
// another run for the same head, never with a newer pull-request head.
|
|
114
|
+
await api.updateIssueComment(candidate.id, body);
|
|
115
|
+
if (await api.headSha() !== expectedHeadSha)
|
|
116
|
+
return { state: 'outdated', retired: 0 };
|
|
117
|
+
}
|
|
118
|
+
return reconcileCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments);
|
|
119
|
+
}
|
|
120
|
+
// Legacy comments have no workflow or head identity, so claiming one with
|
|
121
|
+
// PATCH would let two workflows overwrite each other. Create the scoped,
|
|
122
|
+
// head-owned replacement first and retire only the legacy snapshot later.
|
|
123
|
+
const legacy = latest(botComments.filter((comment) => owns(comment.body, LEGACY_SUMMARY_MARKER) || LEGACY_HEADING.test(comment.body)));
|
|
124
|
+
if (legacy !== undefined) {
|
|
125
|
+
return createCandidate(api, marker, body, expectedHeadSha, 'migrated', previousHeadComments, legacy);
|
|
126
|
+
}
|
|
127
|
+
return createCandidate(api, marker, body, expectedHeadSha, 'created', previousHeadComments);
|
|
128
|
+
}
|
|
129
|
+
function oneLine(value, limit) {
|
|
130
|
+
return stripControl(value).replace(/\r?\n/g, ' ').slice(0, limit);
|
|
131
|
+
}
|
|
132
|
+
async function main() {
|
|
133
|
+
const result = await syncSummaryComment(githubPullRequestApiFromEnvironment(), await readFile('powershot.md', 'utf8'), expectedHeadShaFromEnvironment(), workflowCommentScope(requiredEnvironment('GITHUB_WORKFLOW_REF'), requiredEnvironment('GITHUB_JOB')));
|
|
134
|
+
if (result.state === 'outdated') {
|
|
135
|
+
process.stdout.write('PowerShot skipped the summary because the pull request head changed.\n');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const retired = result.retired === 0 ? '' : ` ${result.retired} duplicate(s) retired.`;
|
|
139
|
+
process.stdout.write(`PowerShot summary comment: ${result.state}.${retired}\n`);
|
|
140
|
+
}
|
|
141
|
+
const entry = process.argv[1];
|
|
142
|
+
if (entry !== undefined && import.meta.url === pathToFileURL(entry).href) {
|
|
143
|
+
main().catch((error) => {
|
|
144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
145
|
+
process.stderr.write('PowerShot summary comment failed: ' + oneLine(message, 1_000) + '\n');
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=summary-comment.js.map
|
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
|
@@ -65,12 +65,20 @@ export class RunManifest {
|
|
|
65
65
|
...file,
|
|
66
66
|
checks: [...file.checks],
|
|
67
67
|
missing: file.missing ? [...file.missing] : undefined,
|
|
68
|
+
unavailable: file.unavailable ? [...file.unavailable] : undefined,
|
|
68
69
|
})),
|
|
69
70
|
units: this.units.map((unit) => ({ ...unit })),
|
|
70
71
|
checks: {
|
|
71
72
|
ran: [...this.ranChecks],
|
|
72
73
|
skipped: parts.skippedChecks.map((check) => ({ ...check })),
|
|
74
|
+
...((parts.unavailableChecks?.length ?? 0) > 0
|
|
75
|
+
? { unavailable: parts.unavailableChecks.map((check) => ({ ...check })) }
|
|
76
|
+
: {}),
|
|
73
77
|
},
|
|
78
|
+
coverage: parts.files.some((file) => file.missing?.length || file.unavailable?.length) ||
|
|
79
|
+
parts.skippedChecks.length > 0 || (parts.unavailableChecks?.length ?? 0) > 0
|
|
80
|
+
? 'portable'
|
|
81
|
+
: 'full',
|
|
74
82
|
findings: { ...parts.findings },
|
|
75
83
|
usage: { ...parts.usage },
|
|
76
84
|
state: completion.state,
|
|
@@ -118,6 +126,15 @@ export function coverageProblems(m) {
|
|
|
118
126
|
if (f.disposition !== 'selected' && f.checks.length > 0) {
|
|
119
127
|
problems.push(f.path + ': ' + f.disposition + ' file received checks');
|
|
120
128
|
}
|
|
129
|
+
if (f.disposition !== 'selected' && f.unavailable?.length) {
|
|
130
|
+
problems.push(f.path + ': ' + f.disposition + ' file has unavailable coverage');
|
|
131
|
+
}
|
|
132
|
+
const missingCaps = new Set(f.missing ?? []);
|
|
133
|
+
for (const capability of f.unavailable ?? []) {
|
|
134
|
+
if (missingCaps.has(capability)) {
|
|
135
|
+
problems.push(f.path + ': capability is both required and unavailable: ' + capability);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
121
138
|
const local = new Set();
|
|
122
139
|
for (const check of f.checks) {
|
|
123
140
|
if (local.has(check))
|
|
@@ -157,6 +174,21 @@ export function coverageProblems(m) {
|
|
|
157
174
|
if (ran.has(check.check))
|
|
158
175
|
problems.push('check counted as both ran and skipped: ' + check.check);
|
|
159
176
|
}
|
|
177
|
+
const unavailable = new Set();
|
|
178
|
+
for (const check of m.checks.unavailable ?? []) {
|
|
179
|
+
if (unavailable.has(check.check))
|
|
180
|
+
problems.push('check counted twice as unavailable: ' + check.check);
|
|
181
|
+
unavailable.add(check.check);
|
|
182
|
+
if (skipped.has(check.check))
|
|
183
|
+
problems.push('check counted as both skipped and unavailable: ' + check.check);
|
|
184
|
+
}
|
|
185
|
+
const expectedCoverage = m.files.some((file) => file.missing?.length || file.unavailable?.length) ||
|
|
186
|
+
m.checks.skipped.length > 0 || (m.checks.unavailable?.length ?? 0) > 0
|
|
187
|
+
? 'portable'
|
|
188
|
+
: 'full';
|
|
189
|
+
if (m.coverage !== undefined && m.coverage !== expectedCoverage) {
|
|
190
|
+
problems.push('coverage is ' + m.coverage + ' but accounting says ' + expectedCoverage);
|
|
191
|
+
}
|
|
160
192
|
// a judged run that reports complete must have reached every unit it selected
|
|
161
193
|
const unreached = m.units.filter((u) => u.outcome === 'failed' || u.outcome === 'waived');
|
|
162
194
|
if (m.state === 'complete' && unreached.length > 0) {
|
package/dist/package-smoke.js
CHANGED
|
@@ -64,8 +64,12 @@ try {
|
|
|
64
64
|
throw new Error('architecture guide is missing');
|
|
65
65
|
if (!existsSync(join(installed, 'docs', 'ci.md')))
|
|
66
66
|
throw new Error('CI guide is missing');
|
|
67
|
+
if (!existsSync(join(installed, 'dist', 'github', 'api.js')))
|
|
68
|
+
throw new Error('GitHub REST runtime is missing');
|
|
67
69
|
if (!existsSync(join(installed, 'dist', 'github', 'inline-comments.js')))
|
|
68
70
|
throw new Error('inline review runtime is missing');
|
|
71
|
+
if (!existsSync(join(installed, 'dist', 'github', 'summary-comment.js')))
|
|
72
|
+
throw new Error('summary comment runtime is missing');
|
|
69
73
|
if (!existsSync(join(installed, 'examples', 'github-actions', 'cli.yml')))
|
|
70
74
|
throw new Error('CI example is missing');
|
|
71
75
|
if (!existsSync(join(installed, 'examples', 'gitlab', '.gitlab-ci.yml')))
|
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 { modeNote, noFindingsLabel, scopeLine, } from './summary.js';
|
|
1
2
|
const MARK = { verified: '▣', judged: '▚' };
|
|
2
3
|
/** Untrusted prose encoded as literal CommonMark text. */
|
|
3
4
|
function text(s) {
|
|
@@ -50,6 +51,26 @@ function group(findings) {
|
|
|
50
51
|
list.sort((a, b) => a.line - b.line);
|
|
51
52
|
return out;
|
|
52
53
|
}
|
|
54
|
+
function runSummary(run) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const scope = scopeLine(run);
|
|
57
|
+
const mode = modeNote(run, '`verify-only`');
|
|
58
|
+
const details = run.state === 'complete'
|
|
59
|
+
? run.scopeDetails ?? []
|
|
60
|
+
: [...run.notLookedAt, ...(run.scopeDetails ?? [])];
|
|
61
|
+
if (scope)
|
|
62
|
+
out.push(scope, '');
|
|
63
|
+
if (mode)
|
|
64
|
+
out.push(mode, '');
|
|
65
|
+
if (details.length > 0) {
|
|
66
|
+
out.push('<details>', '<summary>' +
|
|
67
|
+
(run.state !== 'complete'
|
|
68
|
+
? 'Why this is not a verdict'
|
|
69
|
+
: run.coverage === 'portable' ? 'Coverage details' : 'Review scope') +
|
|
70
|
+
'</summary>', '', ...details.map((detail) => '- ' + text(detail)), '', '</details>', '');
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
53
74
|
export function markdown(findings, run) {
|
|
54
75
|
// "No findings" from a run that could not look is the one thing this must never
|
|
55
76
|
// say on its own — the reader takes a comment at face value, and a red job beside
|
|
@@ -58,18 +79,25 @@ export function markdown(findings, run) {
|
|
|
58
79
|
const banner = incomplete
|
|
59
80
|
? [
|
|
60
81
|
'> [!WARNING]',
|
|
61
|
-
'> **This review is ' + text(run.state) + ' — not a verdict.**
|
|
62
|
-
...run.notLookedAt.slice(0, 8).map((f) => '> - ' + text(f)),
|
|
82
|
+
'> **This review is ' + text(run.state) + ' — not a verdict.** Some files or checks were not reviewed.',
|
|
63
83
|
'',
|
|
64
84
|
]
|
|
65
85
|
: [];
|
|
86
|
+
const summary = run ? runSummary(run) : [];
|
|
66
87
|
if (findings.length === 0) {
|
|
67
|
-
return [
|
|
88
|
+
return [
|
|
89
|
+
'## PowerShot', '', ...banner,
|
|
90
|
+
incomplete
|
|
91
|
+
? 'No findings *from what it managed to review*.'
|
|
92
|
+
: run ? '✅ **' + noFindingsLabel(run) + '**' : 'No findings.',
|
|
93
|
+
'', ...summary,
|
|
94
|
+
].join('\n');
|
|
68
95
|
}
|
|
69
96
|
const verified = findings.filter((f) => f.class === 'verified').length;
|
|
70
97
|
const judged = findings.length - verified;
|
|
71
98
|
const out = ['## PowerShot', '', ...banner];
|
|
72
99
|
out.push('**' + verified + ' verified** (deterministic, 0 tokens) · **' + judged + ' judged** (agent)', '');
|
|
100
|
+
out.push(...summary);
|
|
73
101
|
for (const [file, list] of group(findings)) {
|
|
74
102
|
out.push('### `' + path(file) + '`', '');
|
|
75
103
|
for (const f of list) {
|