@0xcraft/powershot 1.0.1 → 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
@@ -216,12 +216,20 @@ The composite action is the shortest setup for GitHub:
216
216
  verify-only: 'true'
217
217
  upload-sarif: 'true'
218
218
  comment: 'true'
219
+ inline-comments: 'true'
219
220
  fail-on-findings: 'true'
220
221
  ```
221
222
 
222
223
  `@v1` follows compatible `1.x` releases. Pin the action to a full commit SHA in a
223
224
  protected required workflow when immutable dependencies are required.
224
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
+
225
233
  The [CI guide](docs/ci.md) covers exit handling, Git history, one-run/many-report
226
234
  artifacts, GitLab Code Quality, local parity, and recommended gate policies.
227
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');
@@ -696,17 +901,22 @@ check('the public action persists judge answers and publishes only a verdict', (
696
901
  assert.match(action, /restore-keys:/);
697
902
  assert.match(action, /upload-sarif:\s*\n\s+description: [^\n]+\n\s+default: 'true'/);
698
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"/);
699
907
  });
700
908
  check('published CI examples preserve one verdict and its exit status', () => {
701
909
  const action = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'action.yml'), 'utf8');
702
910
  const github = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'cli.yml'), 'utf8');
703
911
  const gitlab = readFileSync(join(process.cwd(), 'examples', 'gitlab', '.gitlab-ci.yml'), 'utf8');
704
912
  assert.match(action, /upload-sarif: 'true'/);
705
- assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.0\.1/);
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/);
706
916
  assert.equal(github.match(/psh review/g)?.length, 1);
707
917
  assert.match(github, /--report markdown=powershot\.md[\s\S]+--report sarif=powershot\.sarif/);
708
918
  assert.match(github, /\|\| STATUS=\$\?[\s\S]+case "\$STATUS"/);
709
- assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.0\.1/);
919
+ assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.0/);
710
920
  assert.equal(gitlab.match(/psh review/g)?.length, 1);
711
921
  assert.match(gitlab, /--format codequality > gl-code-quality-report\.json \|\| STATUS=\$\?/);
712
922
  assert.match(gitlab, /test "\$STATUS" -le 1 \|\| exit "\$STATUS"/);
package/docs/ci.md CHANGED
@@ -27,7 +27,8 @@ There are two independent decisions:
27
27
  ## GitHub Action
28
28
 
29
29
  The action runs one review, adds a job summary, uploads SARIF when enabled, and can
30
- maintain one pull-request comment.
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:
@@ -53,12 +54,21 @@ jobs:
53
54
  verify-only: 'true'
54
55
  upload-sarif: 'true'
55
56
  comment: 'true'
57
+ inline-comments: 'true'
56
58
  fail-on-findings: 'true'
57
59
  ```
58
60
 
59
61
  Set `upload-sarif: 'false'` and omit `security-events: write` when GitHub code scanning
60
62
  is unavailable or the workflow should not publish SARIF.
61
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
+
62
72
  The major tag follows compatible `1.x` releases. Pin a full commit SHA in a protected
63
73
  required workflow when immutable dependencies are required. The copy-paste version
64
74
  lives at [`examples/github-actions/action.yml`](../examples/github-actions/action.yml).
@@ -73,6 +83,7 @@ lives at [`examples/github-actions/action.yml`](../examples/github-actions/actio
73
83
  | `checks` | empty | Select comma-separated check ids |
74
84
  | `upload-sarif` | `true` | Upload a complete SARIF report to GitHub code scanning |
75
85
  | `comment` | `true` | Maintain a pull-request comment |
86
+ | `inline-comments` | `false` | Post up to ten proven verified findings as one inline review |
76
87
  | `fail-on-findings` | `false` | Turn a complete finding verdict into a failed job |
77
88
  | `approve` | `false` | Approve only a complete, clean review |
78
89
 
@@ -88,7 +99,7 @@ step inside a larger quality job. The complete example is
88
99
  The core pattern is:
89
100
 
90
101
  ```bash
91
- npm install --global --ignore-scripts @0xcraft/powershot@1.0.1
102
+ npm install --global --ignore-scripts @0xcraft/powershot@1.1.0
92
103
 
93
104
  STATUS=0
94
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:
@@ -21,4 +21,5 @@ jobs:
21
21
  verify-only: 'true'
22
22
  upload-sarif: 'true'
23
23
  comment: 'true'
24
+ inline-comments: 'true'
24
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.1
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.1
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.1",
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>",