@0xcraft/powershot 1.0.1 → 1.1.1

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
@@ -211,17 +211,39 @@ The composite action is the shortest setup for GitHub:
211
211
  with:
212
212
  fetch-depth: 0
213
213
 
214
+ - run: npm ci --ignore-scripts
215
+
214
216
  - uses: xcrft/powershot@v1
215
217
  with:
216
218
  verify-only: 'true'
217
219
  upload-sarif: 'true'
218
220
  comment: 'true'
221
+ inline-comments: 'true'
219
222
  fail-on-findings: 'true'
220
223
  ```
221
224
 
225
+ Install the checked-out project's dependencies before PowerShot so TypeScript can
226
+ resolve its declared ambient types. The example disables lifecycle scripts because
227
+ pull-request code is untrusted; use the equivalent safe install for another package
228
+ manager. In a monorepo, install from the workspace root or add safe install steps for
229
+ the affected package roots.
230
+
231
+ PowerShot discovers `tsconfig.json` and `tsconfig.*.json` along the ancestor chain of
232
+ each changed file. One review can use several independent package projects, skip
233
+ empty solution configs in favour of their leaf configs, and type-check test files
234
+ that a production config excludes. Discovery is change-scoped: unrelated packages
235
+ and configless source trees are not crawled just to build the TypeScript ground.
236
+
222
237
  `@v1` follows compatible `1.x` releases. Pin the action to a full commit SHA in a
223
238
  protected required workflow when immutable dependencies are required.
224
239
 
240
+ `inline-comments` is opt-in. It posts at most ten `verified` + `proven` findings of
241
+ `medium` severity or higher as one GitHub review, and only when GitHub confirms the
242
+ finding line was added by the pull request. Reruns keep matching bot comments and
243
+ retire stale PowerShot copies that have no replies. Human comments and discussions
244
+ are preserved. Every finding still stays in the full report and, when `comment` is
245
+ enabled, the summary comment.
246
+
225
247
  The [CI guide](docs/ci.md) covers exit handling, Git history, one-run/many-report
226
248
  artifacts, GitLab Code Quality, local parity, and recommended gate policies.
227
249
 
@@ -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