@0xcraft/powershot 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -214,13 +214,22 @@ The composite action is the shortest setup for GitHub:
214
214
  - uses: xcrft/powershot@v1
215
215
  with:
216
216
  verify-only: 'true'
217
+ upload-sarif: 'true'
217
218
  comment: 'true'
219
+ inline-comments: 'true'
218
220
  fail-on-findings: 'true'
219
221
  ```
220
222
 
221
223
  `@v1` follows compatible `1.x` releases. Pin the action to a full commit SHA in a
222
224
  protected required workflow when immutable dependencies are required.
223
225
 
226
+ `inline-comments` is opt-in. It posts at most ten `verified` + `proven` findings of
227
+ `medium` severity or higher as one GitHub review, and only when GitHub confirms the
228
+ finding line was added by the pull request. Reruns keep matching bot comments and
229
+ retire stale PowerShot copies that have no replies. Human comments and discussions
230
+ are preserved. Every finding still stays in the full report and, when `comment` is
231
+ enabled, the summary comment.
232
+
224
233
  The [CI guide](docs/ci.md) covers exit handling, Git history, one-run/many-report
225
234
  artifacts, GitLab Code Quality, local parity, and recommended gate policies.
226
235
 
@@ -0,0 +1,392 @@
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 { SEVERITIES } from '#app/types.js';
6
+ const INLINE_COMMENT_LIMIT = 10;
7
+ const API_VERSION = '2022-11-28';
8
+ const API_TIMEOUT_MS = 20_000;
9
+ const MAX_API_PAGES = 100;
10
+ const BOT_LOGIN = 'github-actions[bot]';
11
+ const MARKER_PATTERN = /<!-- powershot:inline:v1:[a-f0-9]{24} -->/;
12
+ const COMMONMARK_PUNCTUATION = new Set(`!"#$%&'()*+,-./:;<=>?@[\\]^_\`{|}~`);
13
+ export function createReviewPayload(commitId, comments) {
14
+ return {
15
+ commit_id: commitId,
16
+ body: `PowerShot posted ${comments.length} proven verified finding(s) on changed lines.`,
17
+ event: 'COMMENT',
18
+ comments,
19
+ };
20
+ }
21
+ function oneLine(value, limit) {
22
+ return stripControl(value).replace(/\r?\n/g, ' ').slice(0, limit);
23
+ }
24
+ /** Untrusted finding prose rendered as literal CommonMark without mentions or HTML. */
25
+ function literal(value, limit = 1_200) {
26
+ let out = '';
27
+ for (const char of oneLine(value, limit)) {
28
+ if (char === '&')
29
+ out += '&amp;';
30
+ else if (char === '<')
31
+ out += '&lt;';
32
+ else if (char === '>')
33
+ out += '&gt;';
34
+ else if (char === '@')
35
+ out += '&#64;';
36
+ else
37
+ out += COMMONMARK_PUNCTUATION.has(char) ? '\\' + char : char;
38
+ }
39
+ return out;
40
+ }
41
+ function code(value) {
42
+ return oneLine(value, 160).replace(/`/g, '');
43
+ }
44
+ /** Stable across reruns; unlike the display id, it does not depend on finding order. */
45
+ export function inlineMarker(finding) {
46
+ const key = JSON.stringify([finding.check, finding.file, finding.line, finding.title]);
47
+ const digest = createHash('sha256').update(key).digest('hex').slice(0, 24);
48
+ return `<!-- powershot:inline:v1:${digest} -->`;
49
+ }
50
+ function inlineBody(finding) {
51
+ const body = [
52
+ `**PowerShot · ${finding.severity.toUpperCase()} · \`${code(finding.check)}\` · verified/proven**`,
53
+ '',
54
+ literal(finding.title),
55
+ ];
56
+ if (finding.evidence) {
57
+ body.push('', `> _${literal(finding.evidence.oracle, 240)}_: ${literal(finding.evidence.detail)}`);
58
+ }
59
+ body.push('', inlineMarker(finding));
60
+ return body.join('\n');
61
+ }
62
+ /** Parse right-side line numbers from the unified patch returned by GitHub. */
63
+ export function addedLinesFromPatch(patch) {
64
+ const added = new Set();
65
+ let rightLine;
66
+ for (const raw of patch.split('\n')) {
67
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
68
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
69
+ if (hunk) {
70
+ rightLine = Number(hunk[1]);
71
+ continue;
72
+ }
73
+ if (rightLine === undefined || line === '\')
74
+ continue;
75
+ if (line.startsWith('+')) {
76
+ added.add(rightLine);
77
+ rightLine++;
78
+ }
79
+ else if (line.startsWith('-')) {
80
+ continue;
81
+ }
82
+ else if (line.startsWith(' ')) {
83
+ rightLine++;
84
+ }
85
+ }
86
+ return added;
87
+ }
88
+ function compareText(left, right) {
89
+ return left < right ? -1 : left > right ? 1 : 0;
90
+ }
91
+ /**
92
+ * Select the small, high-confidence subset suitable for review comments.
93
+ *
94
+ * A missing or truncated GitHub patch is deliberately ineligible: the finding is
95
+ * still in the full report, but there is no proven right-side location to attach to.
96
+ */
97
+ export function selectInlineComments(findings, files, limit = INLINE_COMMENT_LIMIT) {
98
+ const changed = new Map();
99
+ for (const file of files) {
100
+ if (file.patch === undefined)
101
+ continue;
102
+ const lines = changed.get(file.filename) ?? new Set();
103
+ for (const line of addedLinesFromPatch(file.patch))
104
+ lines.add(line);
105
+ changed.set(file.filename, lines);
106
+ }
107
+ const eligible = findings
108
+ .filter((finding) => finding.class === 'verified' &&
109
+ finding.confidence === 'proven' &&
110
+ SEVERITIES.indexOf(finding.severity) >= SEVERITIES.indexOf('medium') &&
111
+ changed.get(finding.file)?.has(finding.line) === true)
112
+ .sort((left, right) => SEVERITIES.indexOf(right.severity) - SEVERITIES.indexOf(left.severity) ||
113
+ compareText(left.file, right.file) ||
114
+ left.line - right.line ||
115
+ compareText(left.check, right.check) ||
116
+ compareText(left.title, right.title));
117
+ const out = [];
118
+ const seen = new Set();
119
+ const requested = Number.isFinite(limit) ? Math.floor(limit) : 0;
120
+ const bounded = Math.min(INLINE_COMMENT_LIMIT, Math.max(0, requested));
121
+ if (bounded === 0)
122
+ return out;
123
+ for (const finding of eligible) {
124
+ const marker = inlineMarker(finding);
125
+ if (seen.has(marker))
126
+ continue;
127
+ seen.add(marker);
128
+ out.push({ path: finding.file, line: finding.line, side: 'RIGHT', body: inlineBody(finding) });
129
+ if (out.length === bounded)
130
+ break;
131
+ }
132
+ return out;
133
+ }
134
+ function markerIn(body) {
135
+ return MARKER_PATTERN.exec(body)?.[0];
136
+ }
137
+ /** Plan an idempotent rerun without deleting any human-authored comment. */
138
+ export function reconcileInlineComments(desired, existing) {
139
+ const managed = existing.filter((comment) => comment.user?.login === BOT_LOGIN && markerIn(comment.body) !== undefined);
140
+ const repliedTo = new Set(existing.flatMap((comment) => comment.inReplyToId === undefined ? [] : [comment.inReplyToId]));
141
+ const used = new Set();
142
+ const create = [];
143
+ let kept = 0;
144
+ for (const wanted of desired) {
145
+ const marker = markerIn(wanted.body);
146
+ const matches = managed.filter((comment) => !used.has(comment.id) &&
147
+ markerIn(comment.body) === marker &&
148
+ comment.path === wanted.path &&
149
+ comment.line === wanted.line &&
150
+ comment.body === wanted.body);
151
+ const match = matches.find((comment) => repliedTo.has(comment.id)) ?? matches[0];
152
+ if (match) {
153
+ used.add(match.id);
154
+ kept++;
155
+ }
156
+ else {
157
+ create.push(wanted);
158
+ }
159
+ }
160
+ const staleIds = [...new Set(managed
161
+ .filter((comment) => !used.has(comment.id) && !repliedTo.has(comment.id))
162
+ .map((comment) => comment.id))]
163
+ .sort((left, right) => left - right);
164
+ return { create, staleIds, kept };
165
+ }
166
+ /** Create missing comments as one review, then retire superseded bot comments. */
167
+ export async function syncInlineComments(api, findings, expectedHeadSha, limit = INLINE_COMMENT_LIMIT) {
168
+ if (await api.headSha() !== expectedHeadSha) {
169
+ return { outdated: true, desired: 0, created: 0, kept: 0, retired: 0 };
170
+ }
171
+ const [files, existing] = await Promise.all([api.listFiles(), api.listReviewComments()]);
172
+ if (await api.headSha() !== expectedHeadSha) {
173
+ return { outdated: true, desired: 0, created: 0, kept: 0, retired: 0 };
174
+ }
175
+ const desired = selectInlineComments(findings, files, limit);
176
+ const plan = reconcileInlineComments(desired, existing);
177
+ if (plan.create.length > 0)
178
+ await api.createReview(expectedHeadSha, plan.create);
179
+ for (const id of plan.staleIds)
180
+ await api.deleteReviewComment(id);
181
+ return {
182
+ outdated: false,
183
+ desired: desired.length,
184
+ created: plan.create.length,
185
+ kept: plan.kept,
186
+ retired: plan.staleIds.length,
187
+ };
188
+ }
189
+ function record(value) {
190
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
191
+ }
192
+ function requiredString(value, name) {
193
+ if (typeof value !== 'string' || value.length === 0)
194
+ throw new Error(`GitHub API returned no ${name}`);
195
+ return value;
196
+ }
197
+ export function parseReviewFindings(source) {
198
+ const document = JSON.parse(source);
199
+ if (!record(document) || !Array.isArray(document.findings)) {
200
+ throw new Error('powershot.json does not contain a findings array');
201
+ }
202
+ return document.findings.map((value, index) => {
203
+ if (!record(value))
204
+ throw new Error(`powershot.json finding ${index + 1} is not an object`);
205
+ if (typeof value.id !== 'string' ||
206
+ (value.class !== 'verified' && value.class !== 'judged') ||
207
+ typeof value.check !== 'string' ||
208
+ !SEVERITIES.includes(value.severity) ||
209
+ (value.confidence !== 'proven' && value.confidence !== 'firm' && value.confidence !== 'tentative') ||
210
+ typeof value.file !== 'string' ||
211
+ !Number.isSafeInteger(value.line) || Number(value.line) < 1 ||
212
+ typeof value.title !== 'string') {
213
+ throw new Error(`powershot.json finding ${index + 1} has an invalid contract`);
214
+ }
215
+ let evidence;
216
+ if (value.evidence !== undefined) {
217
+ if (!record(value.evidence) || typeof value.evidence.oracle !== 'string' || typeof value.evidence.detail !== 'string') {
218
+ throw new Error(`powershot.json finding ${index + 1} has invalid evidence`);
219
+ }
220
+ evidence = { oracle: value.evidence.oracle, detail: value.evidence.detail };
221
+ }
222
+ return {
223
+ id: value.id,
224
+ class: value.class,
225
+ check: value.check,
226
+ severity: value.severity,
227
+ confidence: value.confidence,
228
+ file: value.file,
229
+ line: Number(value.line),
230
+ title: value.title,
231
+ evidence,
232
+ };
233
+ });
234
+ }
235
+ function nextLink(value) {
236
+ if (value === null)
237
+ return undefined;
238
+ for (const part of value.split(',')) {
239
+ const match = /^\s*<([^>]+)>;\s*rel="([^"]+)"\s*$/.exec(part);
240
+ if (match?.[2] === 'next')
241
+ return match[1];
242
+ }
243
+ return undefined;
244
+ }
245
+ function errorDetail(value) {
246
+ return oneLine(value, 500);
247
+ }
248
+ export class GitHubPullRequestApi {
249
+ token;
250
+ pullNumber;
251
+ base;
252
+ pullPath;
253
+ constructor(apiUrl, token, owner, repository, pullNumber) {
254
+ this.token = token;
255
+ this.pullNumber = pullNumber;
256
+ this.base = apiUrl.replace(/\/$/, '');
257
+ this.pullPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls`;
258
+ }
259
+ url(endpoint) {
260
+ if (!endpoint.startsWith('http://') && !endpoint.startsWith('https://'))
261
+ return this.base + endpoint;
262
+ if (endpoint !== this.base && !endpoint.startsWith(this.base + '/')) {
263
+ throw new Error('GitHub pagination left the configured API origin');
264
+ }
265
+ return endpoint;
266
+ }
267
+ async request(method, endpoint, body, acceptedStatuses = []) {
268
+ const response = await fetch(this.url(endpoint), {
269
+ method,
270
+ headers: {
271
+ Accept: 'application/vnd.github+json',
272
+ Authorization: `Bearer ${this.token}`,
273
+ 'User-Agent': 'PowerShot',
274
+ 'X-GitHub-Api-Version': API_VERSION,
275
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
276
+ },
277
+ body: body === undefined ? undefined : JSON.stringify(body),
278
+ signal: AbortSignal.timeout(API_TIMEOUT_MS),
279
+ });
280
+ const source = await response.text();
281
+ if (!response.ok) {
282
+ if (acceptedStatuses.includes(response.status))
283
+ return { data: undefined };
284
+ throw new Error(`GitHub API ${method} failed with ${response.status}: ${errorDetail(source)}`);
285
+ }
286
+ const data = source.length === 0 ? undefined : JSON.parse(source);
287
+ return { data, next: nextLink(response.headers.get('link')) };
288
+ }
289
+ async all(endpoint) {
290
+ const out = [];
291
+ const seen = new Set();
292
+ let next = endpoint + (endpoint.includes('?') ? '&' : '?') + 'per_page=100';
293
+ for (let page = 0; next !== undefined; page++) {
294
+ if (page >= MAX_API_PAGES)
295
+ throw new Error('GitHub API pagination exceeded its safety limit');
296
+ if (seen.has(next))
297
+ throw new Error('GitHub API returned a pagination cycle');
298
+ seen.add(next);
299
+ const response = await this.request('GET', next);
300
+ if (!Array.isArray(response.data))
301
+ throw new Error('GitHub API returned a non-array page');
302
+ out.push(...response.data);
303
+ next = response.next;
304
+ }
305
+ return out;
306
+ }
307
+ async headSha() {
308
+ const { data } = await this.request('GET', `${this.pullPath}/${this.pullNumber}`);
309
+ if (!record(data) || !record(data.head))
310
+ throw new Error('GitHub API returned no pull request head');
311
+ return requiredString(data.head.sha, 'pull request head SHA');
312
+ }
313
+ async listFiles() {
314
+ const values = await this.all(`${this.pullPath}/${this.pullNumber}/files`);
315
+ return values.map((value, index) => {
316
+ if (!record(value) || typeof value.filename !== 'string') {
317
+ throw new Error(`GitHub API pull file ${index + 1} has an invalid contract`);
318
+ }
319
+ if (value.patch !== undefined && typeof value.patch !== 'string') {
320
+ throw new Error(`GitHub API pull file ${index + 1} has an invalid patch`);
321
+ }
322
+ return { filename: value.filename, patch: value.patch };
323
+ });
324
+ }
325
+ async listReviewComments() {
326
+ const values = await this.all(`${this.pullPath}/${this.pullNumber}/comments`);
327
+ return values.map((value, index) => {
328
+ if (!record(value) ||
329
+ !Number.isSafeInteger(value.id) ||
330
+ typeof value.path !== 'string' ||
331
+ (value.line !== null && !Number.isSafeInteger(value.line)) ||
332
+ (value.body !== null && typeof value.body !== 'string') ||
333
+ (value.in_reply_to_id !== undefined && value.in_reply_to_id !== null && !Number.isSafeInteger(value.in_reply_to_id))) {
334
+ throw new Error(`GitHub API review comment ${index + 1} has an invalid contract`);
335
+ }
336
+ const user = record(value.user) && typeof value.user.login === 'string'
337
+ ? { login: value.user.login }
338
+ : undefined;
339
+ return {
340
+ id: Number(value.id),
341
+ path: value.path,
342
+ line: value.line === null ? null : Number(value.line),
343
+ body: value.body ?? '',
344
+ user,
345
+ inReplyToId: value.in_reply_to_id === undefined || value.in_reply_to_id === null
346
+ ? undefined
347
+ : Number(value.in_reply_to_id),
348
+ };
349
+ });
350
+ }
351
+ async createReview(commitId, comments) {
352
+ await this.request('POST', `${this.pullPath}/${this.pullNumber}/reviews`, createReviewPayload(commitId, comments));
353
+ }
354
+ async deleteReviewComment(id) {
355
+ await this.request('DELETE', `${this.pullPath}/comments/${id}`, undefined, [404]);
356
+ }
357
+ }
358
+ function environment(name) {
359
+ const value = process.env[name];
360
+ if (value === undefined || value.length === 0)
361
+ throw new Error(`${name} is required`);
362
+ return value;
363
+ }
364
+ async function main() {
365
+ const repository = environment('GITHUB_REPOSITORY').split('/');
366
+ if (repository.length !== 2 || !repository[0] || !repository[1]) {
367
+ throw new Error('GITHUB_REPOSITORY must be owner/repository');
368
+ }
369
+ const pullNumber = Number(environment('POWERSHOT_PR_NUMBER'));
370
+ if (!Number.isSafeInteger(pullNumber) || pullNumber < 1)
371
+ throw new Error('POWERSHOT_PR_NUMBER must be positive');
372
+ const expectedHeadSha = environment('POWERSHOT_HEAD_SHA');
373
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(expectedHeadSha))
374
+ throw new Error('POWERSHOT_HEAD_SHA is invalid');
375
+ const findings = parseReviewFindings(await readFile('powershot.json', 'utf8'));
376
+ const api = new GitHubPullRequestApi(environment('GITHUB_API_URL'), environment('GITHUB_TOKEN'), repository[0], repository[1], pullNumber);
377
+ const result = await syncInlineComments(api, findings, expectedHeadSha);
378
+ if (result.outdated) {
379
+ process.stdout.write('PowerShot skipped inline comments because the pull request head changed.\n');
380
+ return;
381
+ }
382
+ process.stdout.write(`PowerShot inline comments: ${result.created} created, ${result.kept} kept, ${result.retired} retired.\n`);
383
+ }
384
+ const entry = process.argv[1];
385
+ if (entry !== undefined && import.meta.url === pathToFileURL(entry).href) {
386
+ main().catch((error) => {
387
+ const message = error instanceof Error ? error.message : String(error);
388
+ process.stderr.write('PowerShot inline comments failed: ' + oneLine(message, 1_000) + '\n');
389
+ process.exitCode = 1;
390
+ });
391
+ }
392
+ //# sourceMappingURL=inline-comments.js.map
@@ -64,6 +64,8 @@ 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', 'inline-comments.js')))
68
+ throw new Error('inline review runtime is missing');
67
69
  if (!existsSync(join(installed, 'examples', 'github-actions', 'cli.yml')))
68
70
  throw new Error('CI example is missing');
69
71
  if (!existsSync(join(installed, 'examples', 'gitlab', '.gitlab-ci.yml')))
package/dist/selftest.js CHANGED
@@ -53,6 +53,7 @@ import { highlight, isJsx } from './report/highlight.js';
53
53
  import { normalizeName, readEnvManifest, relPath } from './ground.js';
54
54
  import { incompleteReasons } from './bench.js';
55
55
  import { PACKAGE_NAME, PACKAGE_VERSION } from './package-meta.js';
56
+ import { addedLinesFromPatch, createReviewPayload, GitHubPullRequestApi, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
56
57
  const root = '/repo';
57
58
  /** Build a Ground by hand so verifiers are testable without git or a real repo. */
58
59
  function ground(files, deps = []) {
@@ -653,6 +654,210 @@ const sample = [
653
654
  { id: 'F2', class: 'judged', check: 'plausible-logic', severity: 'low', confidence: 'tentative',
654
655
  file: 'src/b.ts', line: 9, title: 'off by one' },
655
656
  ];
657
+ check('GitHub patches expose only added right-side lines for inline comments', () => {
658
+ const patch = [
659
+ '@@ -1,3 +1,4 @@',
660
+ ' context',
661
+ '-old',
662
+ '+new',
663
+ '+extra',
664
+ ' tail',
665
+ '@@ -20,0 +22,2 @@',
666
+ '+later',
667
+ '\',
668
+ '+latest',
669
+ ].join('\n');
670
+ assert.deepEqual([...addedLinesFromPatch(patch)], [2, 3, 22, 23]);
671
+ });
672
+ check('inline review selects only proven medium-or-higher verified findings on added lines', () => {
673
+ const finding = (overrides) => ({
674
+ id: 'F', class: 'verified', check: 'phantom-dep', severity: 'high', confidence: 'proven',
675
+ file: 'src/a.ts', line: 3, title: 'finding', ...overrides,
676
+ });
677
+ const findings = [
678
+ finding({ id: 'critical', severity: 'critical', line: 4, title: 'critical finding' }),
679
+ finding({ id: 'medium', severity: 'medium', line: 3, title: 'medium finding' }),
680
+ finding({ id: 'context', line: 2 }),
681
+ finding({ id: 'low', severity: 'low' }),
682
+ finding({ id: 'judged', class: 'judged' }),
683
+ finding({ id: 'firm', confidence: 'firm' }),
684
+ finding({ id: 'other-file', file: 'src/missing.ts' }),
685
+ ];
686
+ const files = [{ filename: 'src/a.ts', patch: '@@ -2,1 +2,3 @@\n context\n+first\n+second' }];
687
+ assert.deepEqual(selectInlineComments(findings, files).map((comment) => comment.line), [4, 3]);
688
+ assert.deepEqual(selectInlineComments(findings, files, 1).map((comment) => comment.line), [4]);
689
+ assert.deepEqual(selectInlineComments(findings, files, 0), []);
690
+ const many = Array.from({ length: 12 }, (_, index) => finding({ line: index + 3, title: `finding ${index}` }));
691
+ const manyPatch = '@@ -2,0 +3,12 @@\n' + many.map((_, index) => `+line ${index}`).join('\n');
692
+ assert.equal(selectInlineComments(many, [{ filename: 'src/a.ts', patch: manyPatch }], 100).length, 10);
693
+ });
694
+ check('inline comment markdown renders finding prose literally and carries a stable marker', () => {
695
+ const finding = {
696
+ id: 'F1', class: 'verified', check: 'check`id', severity: 'high', confidence: 'proven',
697
+ file: 'src/a.ts', line: 3, title: '@team <img src=x> [click](https://example.invalid)',
698
+ evidence: { oracle: 'manifest', detail: 'line one\n> forged quote' },
699
+ };
700
+ const [comment] = selectInlineComments([finding], [{ filename: finding.file, patch: '@@ -2,0 +3,1 @@\n+line' }]);
701
+ assert.ok(comment);
702
+ assert.equal(comment.body.includes('@team'), false);
703
+ assert.equal(comment.body.includes('<img'), false);
704
+ assert.equal(comment.body.includes('[click]('), false);
705
+ assert.equal(comment.body.endsWith(inlineMarker(finding)), true);
706
+ assert.equal(comment.body.match(/<!-- powershot:inline:/g)?.length, 1);
707
+ });
708
+ check('inline publishing rejects a malformed machine report instead of silently dropping it', () => {
709
+ assert.deepEqual(parseReviewFindings(JSON.stringify({ findings: [sample[0]] })), [sample[0]]);
710
+ assert.throws(() => parseReviewFindings(JSON.stringify({ findings: [{ ...sample[0], line: 0 }] })), /invalid contract/);
711
+ assert.throws(() => parseReviewFindings('{}'), /findings array/);
712
+ });
713
+ check('inline reruns keep exact bot comments, batch only missing ones, and retire stale bot copies', () => {
714
+ const findings = [
715
+ { id: 'F1', class: 'verified', check: 'a', severity: 'critical', confidence: 'proven', file: 'a.ts', line: 1, title: 'one' },
716
+ { id: 'F2', class: 'verified', check: 'b', severity: 'high', confidence: 'proven', file: 'b.ts', line: 2, title: 'two' },
717
+ ];
718
+ const desired = selectInlineComments(findings, [
719
+ { filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' },
720
+ { filename: 'b.ts', patch: '@@ -1,0 +2,1 @@\n+two' },
721
+ ]);
722
+ const existing = [
723
+ { id: 1, path: desired[0].path, line: desired[0].line, body: desired[0].body, user: { login: 'github-actions[bot]' } },
724
+ { id: 2, path: desired[0].path, line: desired[0].line, body: desired[0].body, user: { login: 'github-actions[bot]' } },
725
+ { id: 3, path: desired[0].path, line: desired[0].line, body: 'old\n' + inlineMarker(findings[0]), user: { login: 'github-actions[bot]' } },
726
+ { id: 4, path: desired[1].path, line: desired[1].line, body: desired[1].body, user: { login: 'human' } },
727
+ { id: 5, path: desired[0].path, line: desired[0].line, body: 'discussion', user: { login: 'human' }, inReplyToId: 2 },
728
+ { id: 6, path: desired[0].path, line: desired[0].line, body: 'discussed old\n' + inlineMarker(findings[0]), user: { login: 'github-actions[bot]' } },
729
+ { id: 7, path: desired[0].path, line: desired[0].line, body: 'still investigating', user: { login: 'human' }, inReplyToId: 6 },
730
+ ];
731
+ const plan = reconcileInlineComments(desired, existing);
732
+ assert.equal(plan.kept, 1);
733
+ assert.deepEqual(plan.create, [desired[1]]);
734
+ assert.deepEqual(plan.staleIds, [1, 3]);
735
+ const settled = reconcileInlineComments(desired, desired.map((comment, index) => ({
736
+ id: index + 10, path: comment.path, line: comment.line, body: comment.body,
737
+ user: { login: 'github-actions[bot]' },
738
+ })));
739
+ assert.deepEqual(settled, { create: [], staleIds: [], kept: 2 });
740
+ });
741
+ check('the batched GitHub review carries the required comment body and current commit', () => {
742
+ const comment = { path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' };
743
+ const payload = createReviewPayload('a'.repeat(40), [comment]);
744
+ assert.equal(payload.commit_id, 'a'.repeat(40));
745
+ assert.equal(payload.event, 'COMMENT');
746
+ assert.match(payload.body, /1 proven verified finding/);
747
+ assert.deepEqual(payload.comments, [comment]);
748
+ });
749
+ await checkAsync('the GitHub client paginates files, submits the review contract, and treats delete 404 as settled', async () => {
750
+ const originalFetch = globalThis.fetch;
751
+ const calls = [];
752
+ const json = (value, init = {}) => new Response(JSON.stringify(value), { status: 200, ...init });
753
+ const fakeFetch = async (input, init) => {
754
+ const url = String(input);
755
+ const method = init?.method ?? 'GET';
756
+ const body = typeof init?.body === 'string' ? init.body : undefined;
757
+ calls.push({ url, method, body });
758
+ assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer token');
759
+ if (url.endsWith('/pulls/7'))
760
+ return json({ head: { sha: 'a'.repeat(40) } });
761
+ if (url.includes('/pulls/7/files') && !url.includes('page=2')) {
762
+ return json([{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' }], {
763
+ headers: { link: '<https://api.github.test/repos/acme/repo/pulls/7/files?per_page=100&page=2>; rel="next"' },
764
+ });
765
+ }
766
+ if (url.includes('/pulls/7/files') && url.includes('page=2')) {
767
+ return json([{ filename: 'b.bin' }]);
768
+ }
769
+ if (url.includes('/pulls/7/comments')) {
770
+ return json([
771
+ { id: 9, path: 'a.ts', line: 1, body: 'old', user: { login: 'github-actions[bot]' } },
772
+ { id: 10, path: 'a.ts', line: 1, body: 'reply', in_reply_to_id: 9, user: { login: 'human' } },
773
+ ]);
774
+ }
775
+ if (method === 'POST' && url.endsWith('/pulls/7/reviews'))
776
+ return json({ id: 1 });
777
+ if (method === 'DELETE' && url.endsWith('/pulls/comments/9'))
778
+ return json({ message: 'gone' }, { status: 404 });
779
+ return json({ message: 'unexpected request' }, { status: 500 });
780
+ };
781
+ globalThis.fetch = fakeFetch;
782
+ try {
783
+ const api = new GitHubPullRequestApi('https://api.github.test', 'token', 'acme', 'repo', 7);
784
+ assert.equal(await api.headSha(), 'a'.repeat(40));
785
+ assert.deepEqual(await api.listFiles(), [
786
+ { filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' },
787
+ { filename: 'b.bin', patch: undefined },
788
+ ]);
789
+ const comments = await api.listReviewComments();
790
+ assert.equal(comments[0]?.id, 9);
791
+ assert.equal(comments[1]?.inReplyToId, 9);
792
+ await api.createReview('a'.repeat(40), [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }]);
793
+ await api.deleteReviewComment(9);
794
+ }
795
+ finally {
796
+ globalThis.fetch = originalFetch;
797
+ }
798
+ const submitted = calls.find((call) => call.method === 'POST');
799
+ assert.ok(submitted?.body);
800
+ assert.deepEqual(JSON.parse(submitted.body), {
801
+ commit_id: 'a'.repeat(40),
802
+ body: 'PowerShot posted 1 proven verified finding(s) on changed lines.',
803
+ event: 'COMMENT',
804
+ comments: [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }],
805
+ });
806
+ assert.equal(calls.filter((call) => call.url.includes('/files')).length, 2);
807
+ });
808
+ await checkAsync('inline synchronization creates one review before removing stale comments', async () => {
809
+ const finding = {
810
+ id: 'F1', class: 'verified', check: 'a', severity: 'high', confidence: 'proven',
811
+ file: 'a.ts', line: 1, title: 'one',
812
+ };
813
+ const events = [];
814
+ const api = {
815
+ headSha: async () => 'a'.repeat(40),
816
+ listFiles: async () => [{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' }],
817
+ listReviewComments: async () => [{
818
+ id: 7, path: 'old.ts', line: 1,
819
+ body: 'old\n<!-- powershot:inline:v1:0123456789abcdef01234567 -->',
820
+ user: { login: 'github-actions[bot]' },
821
+ }],
822
+ createReview: async (commitId, comments) => {
823
+ events.push(`create:${commitId}:${comments.length}`);
824
+ },
825
+ deleteReviewComment: async (id) => {
826
+ events.push(`delete:${id}`);
827
+ },
828
+ };
829
+ const result = await syncInlineComments(api, [finding], 'a'.repeat(40));
830
+ assert.deepEqual(events, [`create:${'a'.repeat(40)}:1`, 'delete:7']);
831
+ assert.deepEqual(result, { outdated: false, desired: 1, created: 1, kept: 0, retired: 1 });
832
+ });
833
+ await checkAsync('inline synchronization makes no writes for an outdated pull request head', async () => {
834
+ let reads = 0;
835
+ const api = {
836
+ headSha: async () => 'b'.repeat(40),
837
+ listFiles: async () => { reads++; return []; },
838
+ listReviewComments: async () => { reads++; return []; },
839
+ createReview: async () => { throw new Error('must not create'); },
840
+ deleteReviewComment: async () => { throw new Error('must not delete'); },
841
+ };
842
+ const result = await syncInlineComments(api, [], 'a'.repeat(40));
843
+ assert.equal(reads, 0);
844
+ assert.deepEqual(result, { outdated: true, desired: 0, created: 0, kept: 0, retired: 0 });
845
+ });
846
+ await checkAsync('inline synchronization makes no writes when the pull request head changes during reads', async () => {
847
+ let headReads = 0;
848
+ let writes = 0;
849
+ const api = {
850
+ headSha: async () => ++headReads === 1 ? 'a'.repeat(40) : 'b'.repeat(40),
851
+ listFiles: async () => [{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' }],
852
+ listReviewComments: async () => [],
853
+ createReview: async () => { writes++; },
854
+ deleteReviewComment: async () => { writes++; },
855
+ };
856
+ const result = await syncInlineComments(api, [sample[0]], 'a'.repeat(40));
857
+ assert.equal(headReads, 2);
858
+ assert.equal(writes, 0);
859
+ assert.deepEqual(result, { outdated: true, desired: 0, created: 0, kept: 0, retired: 0 });
860
+ });
656
861
  check('nested modules use the native package import map', () => {
657
862
  const manifest = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'));
658
863
  assert.equal(manifest.imports?.['#app/*.js'], './dist/*.js');
@@ -694,16 +899,24 @@ check('the public action persists judge answers and publishes only a verdict', (
694
899
  assert.match(action, /uses: actions\/cache@[a-f0-9]{40}/);
695
900
  assert.match(action, /POWERSHOT_CACHE_DIR: \$\{\{ runner\.temp \}\}\/powershot-cache/);
696
901
  assert.match(action, /restore-keys:/);
697
- assert.match(action, /Upload SARIF[\s\S]+steps\.review\.outputs\.complete == 'true'/);
902
+ assert.match(action, /upload-sarif:\s*\n\s+description: [^\n]+\n\s+default: 'true'/);
903
+ assert.match(action, /Upload SARIF[\s\S]+inputs\.upload-sarif == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
904
+ assert.match(action, /inline-comments:\s*\n\s+description: [^\n]+\n\s+default: 'false'/);
905
+ assert.match(action, /Post inline comments[\s\S]+inputs\.inline-comments == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
906
+ assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/inline-comments\.js"/);
698
907
  });
699
908
  check('published CI examples preserve one verdict and its exit status', () => {
909
+ const action = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'action.yml'), 'utf8');
700
910
  const github = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'cli.yml'), 'utf8');
701
911
  const gitlab = readFileSync(join(process.cwd(), 'examples', 'gitlab', '.gitlab-ci.yml'), 'utf8');
702
- assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.0\.0/);
912
+ assert.match(action, /upload-sarif: 'true'/);
913
+ assert.match(action, /inline-comments: 'true'/);
914
+ assert.match(action, /runs-on: ubuntu-24\.04/);
915
+ assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.0/);
703
916
  assert.equal(github.match(/psh review/g)?.length, 1);
704
917
  assert.match(github, /--report markdown=powershot\.md[\s\S]+--report sarif=powershot\.sarif/);
705
918
  assert.match(github, /\|\| STATUS=\$\?[\s\S]+case "\$STATUS"/);
706
- assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.0\.0/);
919
+ assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.0/);
707
920
  assert.equal(gitlab.match(/psh review/g)?.length, 1);
708
921
  assert.match(gitlab, /--format codequality > gl-code-quality-report\.json \|\| STATUS=\$\?/);
709
922
  assert.match(gitlab, /test "\$STATUS" -le 1 \|\| exit "\$STATUS"/);
package/docs/ci.md CHANGED
@@ -26,8 +26,9 @@ There are two independent decisions:
26
26
 
27
27
  ## GitHub Action
28
28
 
29
- The action runs one review, adds a job summary, uploads SARIF, and can maintain one
30
- pull-request comment.
29
+ The action runs one review, adds a job summary, uploads SARIF when enabled, and can
30
+ maintain one pull-request comment. Its opt-in inline mode posts a bounded batched
31
+ review on changed lines.
31
32
 
32
33
  ```yaml
33
34
  name: PowerShot
@@ -42,7 +43,7 @@ permissions:
42
43
 
43
44
  jobs:
44
45
  review:
45
- runs-on: ubuntu-latest
46
+ runs-on: ubuntu-24.04
46
47
  steps:
47
48
  - uses: actions/checkout@v7
48
49
  with:
@@ -51,10 +52,23 @@ jobs:
51
52
  - uses: xcrft/powershot@v1
52
53
  with:
53
54
  verify-only: 'true'
55
+ upload-sarif: 'true'
54
56
  comment: 'true'
57
+ inline-comments: 'true'
55
58
  fail-on-findings: 'true'
56
59
  ```
57
60
 
61
+ Set `upload-sarif: 'false'` and omit `security-events: write` when GitHub code scanning
62
+ is unavailable or the workflow should not publish SARIF.
63
+
64
+ `inline-comments: 'true'` requires `pull-requests: write`. It publishes at most ten
65
+ findings as one review. Only deterministic `verified` findings with `proven`
66
+ confidence, severity `medium` or higher, and a GitHub-confirmed added line qualify.
67
+ Findings on context lines or files whose patch GitHub omitted stay in the full report.
68
+ Reruns preserve exact bot comments, create only missing comments, and remove stale
69
+ PowerShot inline copies without replies. Human comments and replied-to discussions
70
+ are never modified.
71
+
58
72
  The major tag follows compatible `1.x` releases. Pin a full commit SHA in a protected
59
73
  required workflow when immutable dependencies are required. The copy-paste version
60
74
  lives at [`examples/github-actions/action.yml`](../examples/github-actions/action.yml).
@@ -67,7 +81,9 @@ lives at [`examples/github-actions/action.yml`](../examples/github-actions/actio
67
81
  | `anthropic-api-key` | empty | Enable configured model judges |
68
82
  | `min-severity` | `low` | Filter findings below a severity |
69
83
  | `checks` | empty | Select comma-separated check ids |
84
+ | `upload-sarif` | `true` | Upload a complete SARIF report to GitHub code scanning |
70
85
  | `comment` | `true` | Maintain a pull-request comment |
86
+ | `inline-comments` | `false` | Post up to ten proven verified findings as one inline review |
71
87
  | `fail-on-findings` | `false` | Turn a complete finding verdict into a failed job |
72
88
  | `approve` | `false` | Approve only a complete, clean review |
73
89
 
@@ -83,7 +99,7 @@ step inside a larger quality job. The complete example is
83
99
  The core pattern is:
84
100
 
85
101
  ```bash
86
- npm install --global --ignore-scripts @0xcraft/powershot@1.0.0
102
+ npm install --global --ignore-scripts @0xcraft/powershot@1.1.0
87
103
 
88
104
  STATUS=0
89
105
  psh review --verify-only \
@@ -10,7 +10,7 @@ permissions:
10
10
 
11
11
  jobs:
12
12
  review:
13
- runs-on: ubuntu-latest
13
+ runs-on: ubuntu-24.04
14
14
  steps:
15
15
  - uses: actions/checkout@v7
16
16
  with:
@@ -19,5 +19,7 @@ jobs:
19
19
  - uses: xcrft/powershot@v1
20
20
  with:
21
21
  verify-only: 'true'
22
+ upload-sarif: 'true'
22
23
  comment: 'true'
24
+ inline-comments: 'true'
23
25
  fail-on-findings: 'true'
@@ -19,7 +19,7 @@ jobs:
19
19
  node-version: '24'
20
20
 
21
21
  - name: Install PowerShot
22
- run: npm install --global --ignore-scripts @0xcraft/powershot@1.0.0
22
+ run: npm install --global --ignore-scripts @0xcraft/powershot@1.1.0
23
23
 
24
24
  - name: Review pull request
25
25
  env:
@@ -5,7 +5,7 @@ powershot:
5
5
  variables:
6
6
  GIT_DEPTH: "0"
7
7
  before_script:
8
- - npm install --global --ignore-scripts @0xcraft/powershot@1.0.0
8
+ - npm install --global --ignore-scripts @0xcraft/powershot@1.1.0
9
9
  script:
10
10
  - |
11
11
  STATUS=0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xcraft/powershot",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Oracle-first code review for machine-written code, with deterministic verification and CI-ready reports.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "aglumova <alina.glumova@gmail.com>",