@0xcraft/powershot 1.1.2 → 1.1.4

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/dist/selftest.js CHANGED
@@ -22,7 +22,7 @@ import { stripControl } from './text.js';
22
22
  import { review } from './review.js';
23
23
  import { withTargetTree } from './snapshot.js';
24
24
  import { loadConfig } from './config.js';
25
- import { execFileSync } from 'node:child_process';
25
+ import { execFileSync, spawnSync } from 'node:child_process';
26
26
  import { insideRepo, repoPath } from './fspolicy.js';
27
27
  import { Budget, parseLimits } from './budget.js';
28
28
  import { SelectionPlan, capabilitiesOf } from './plan.js';
@@ -48,12 +48,16 @@ import { compact } from './report/compact.js';
48
48
  import { apiKey } from './judges/llm.js';
49
49
  import { sarif } from './report/sarif.js';
50
50
  import { markdown } from './report/markdown.js';
51
+ import { summarizeRun } from './report/summary.js';
51
52
  import { wrap } from './report/terminal.js';
52
53
  import { highlight, isJsx } from './report/highlight.js';
53
54
  import { buildGround, normalizeName, readEnvManifest, relPath } from './ground.js';
55
+ import { implementationFingerprint, reinventionScope, typescriptImplementationFingerprint } from './reinvention.js';
54
56
  import { incompleteReasons } from './bench.js';
55
57
  import { PACKAGE_NAME, PACKAGE_VERSION } from './package-meta.js';
56
- import { addedLinesFromPatch, createReviewPayload, GitHubPullRequestApi, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
58
+ import { addedLinesFromPatch, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
59
+ import { createReviewPayload, GitHubPullRequestApi, } from './github/api.js';
60
+ import { LEGACY_SUMMARY_MARKER, summaryCommentBody, summaryMarker, syncSummaryComment, workflowCommentScope, } from './github/summary-comment.js';
57
61
  const root = '/repo';
58
62
  /** Build a Ground by hand so verifiers are testable without git or a real repo. */
59
63
  function ground(files, deps = []) {
@@ -80,13 +84,26 @@ function ground(files, deps = []) {
80
84
  const symbolIndex = new Map();
81
85
  for (const sf of project.getSourceFiles()) {
82
86
  const rel = sf.getFilePath().slice(root.length + 1);
87
+ const input = files.find((file) => file.path === rel);
83
88
  for (const [name, decls] of sf.getExportedDeclarations()) {
84
89
  const decl = decls[0];
85
90
  if (!decl)
86
91
  continue;
92
+ const fingerprint = typescriptImplementationFingerprint(decl);
93
+ if (!fingerprint)
94
+ continue;
87
95
  const key = normalizeName(name);
88
96
  const list = symbolIndex.get(key) ?? [];
89
- list.push({ file: rel, name, line: decl.getStartLineNumber() });
97
+ const before = input?.before === undefined ? undefined : beforeProject.getSourceFile('/before/' + rel);
98
+ const existedInBase = (before?.getExportedDeclarations().get(name) ?? []).some((baseDeclaration) => typescriptImplementationFingerprint(baseDeclaration) === fingerprint);
99
+ list.push({
100
+ file: rel,
101
+ name,
102
+ line: decl.getStartLineNumber(),
103
+ fingerprint,
104
+ existedInBase,
105
+ scope: reinventionScope(root, rel),
106
+ });
90
107
  symbolIndex.set(key, list);
91
108
  }
92
109
  }
@@ -143,14 +160,40 @@ check('resolves a scoped subpath to its package', () => {
143
160
  });
144
161
  console.log('\nreinvented');
145
162
  check('fires when a helper already exists elsewhere', () => {
163
+ const existing = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
146
164
  const g = ground([
147
- { path: 'lib/currency.ts', after: 'export function formatMinorUnits(n: number) { return n / 100 }\n' },
148
- { path: 'utils/money.ts', after: 'export function formatMinorUnits(n: number) { return n / 100 }\n' },
165
+ { path: 'lib/currency.ts', before: existing, after: existing },
166
+ { path: 'utils/money.ts', after: existing },
149
167
  ]);
150
168
  const found = reinvented.run(g);
151
169
  assert.ok(found.length >= 1, 'expected a duplication finding');
152
170
  assert.equal(found[0].confidence, 'firm'); // heuristic, never claims `proven`
153
171
  });
172
+ check('silent when matching helpers are both new in the change', () => {
173
+ const added = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
174
+ const g = ground([
175
+ { path: 'lib/currency.ts', after: added },
176
+ { path: 'utils/money.ts', after: added },
177
+ ]);
178
+ assert.equal(fires(reinvented, g), false);
179
+ });
180
+ check('silent when the matching implementation already existed in the changed file', () => {
181
+ const existing = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
182
+ const g = ground([
183
+ { path: 'lib/currency.ts', before: existing, after: existing },
184
+ { path: 'utils/money.ts', before: existing, after: existing },
185
+ ]);
186
+ assert.equal(fires(reinvented, g), false);
187
+ });
188
+ check('silent when the candidate only became equivalent in this change', () => {
189
+ const before = 'export function formatMinorUnits(n: number) { return n / 10 }\n';
190
+ const after = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
191
+ const g = ground([
192
+ { path: 'lib/currency.ts', before, after },
193
+ { path: 'utils/money.ts', after },
194
+ ]);
195
+ assert.equal(fires(reinvented, g), false);
196
+ });
154
197
  check('silent on a genuinely new name', () => {
155
198
  const g = ground([
156
199
  { path: 'lib/currency.ts', after: 'export function formatMinorUnits(n: number) { return n / 100 }\n' },
@@ -165,6 +208,125 @@ check('silent on short and generic names', () => {
165
208
  ]);
166
209
  assert.equal(fires(reinvented, g), false);
167
210
  });
211
+ check('silent when only the helper name matches', () => {
212
+ const existing = 'export function runCheck(currentVersion: string, availableVersion: string) { return currentVersion !== availableVersion }\n';
213
+ const g = ground([
214
+ { path: 'web/use-app-updater.ts', before: existing, after: existing },
215
+ {
216
+ path: 'scripts/check-import-boundaries.mjs',
217
+ after: 'function runCheck({ srcRoot, allowlist }) { return allowlist.filter((entry) => !entry.startsWith(srcRoot)) }\n',
218
+ },
219
+ ]);
220
+ assert.equal(fires(reinvented, g), false);
221
+ });
222
+ await checkAsync('silent across package boundaries', async () => {
223
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-scope-')));
224
+ try {
225
+ mkdirSync(join(dir, 'apps/web/src'), { recursive: true });
226
+ mkdirSync(join(dir, 'tools/scripts'), { recursive: true });
227
+ writeFileSync(join(dir, 'apps/web/package.json'), '{"name":"web","private":true}');
228
+ writeFileSync(join(dir, 'tools/scripts/package.json'), '{"name":"scripts","private":true}');
229
+ writeFileSync(join(dir, 'tsconfig.json'), '{"compilerOptions":{"allowJs":true},"include":["apps/**/*.mjs","tools/**/*.mjs"]}');
230
+ writeFileSync(join(dir, 'apps/web/src/normalize.mjs'), 'export function normalizePayload(value) { return value.trim() }\n');
231
+ writeFileSync(join(dir, 'tools/scripts/normalize.mjs'), 'function normalizePayload(value) { return value.trim() }\n');
232
+ const g = await buildGround(dir, [
233
+ { path: 'tools/scripts/normalize.mjs', added: new Set([1]) },
234
+ ]);
235
+ assert.equal(fires(reinvented, g), false);
236
+ }
237
+ finally {
238
+ rmSync(dir, { recursive: true, force: true });
239
+ }
240
+ });
241
+ await checkAsync('silent when only a new barrel alias gives the candidate a matching name', async () => {
242
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-alias-')));
243
+ try {
244
+ mkdirSync(join(dir, 'lib'), { recursive: true });
245
+ mkdirSync(join(dir, 'scripts'), { recursive: true });
246
+ writeFileSync(join(dir, 'package.json'), '{"name":"fixture","private":true}');
247
+ writeFileSync(join(dir, 'tsconfig.json'), '{"include":["lib/**/*.ts","scripts/**/*.ts"]}');
248
+ writeFileSync(join(dir, 'lib/base.ts'), 'export function calculateVersion(current: string, available: string) { return current !== available }\n');
249
+ writeFileSync(join(dir, 'lib/index.ts'), "export { calculateVersion as runCheck } from './base.js'\n");
250
+ writeFileSync(join(dir, 'scripts/run-check.ts'), 'function runCheck(current: string, available: string) { return current !== available }\n');
251
+ const g = await buildGround(dir, [
252
+ { path: 'lib/index.ts', added: new Set([1]), before: '' },
253
+ { path: 'scripts/run-check.ts', added: new Set([1]) },
254
+ ]);
255
+ assert.equal(fires(reinvented, g), false);
256
+ }
257
+ finally {
258
+ rmSync(dir, { recursive: true, force: true });
259
+ }
260
+ });
261
+ check('recognizes package boundaries for every declared language family', () => {
262
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-markers-')));
263
+ try {
264
+ const packages = [
265
+ ['typescript', 'package.json'],
266
+ ['python', 'pyproject.toml'],
267
+ ['rust', 'Cargo.toml'],
268
+ ['go', 'go.mod'],
269
+ ['jvm', 'build.gradle.kts'],
270
+ ['c-cpp', 'CMakeLists.txt'],
271
+ ['csharp', 'App.csproj'],
272
+ ['php', 'composer.json'],
273
+ ['ruby', 'Gemfile'],
274
+ ['solidity', 'foundry.toml'],
275
+ ];
276
+ for (const [name, marker] of packages) {
277
+ mkdirSync(join(dir, name, 'src'), { recursive: true });
278
+ writeFileSync(join(dir, name, marker), '');
279
+ assert.equal(reinventionScope(dir, name + '/src/file.txt'), name);
280
+ }
281
+ }
282
+ finally {
283
+ rmSync(dir, { recursive: true, force: true });
284
+ }
285
+ });
286
+ check('implementation fingerprints cannot confuse token boundaries with token text', () => {
287
+ const oneToken = implementationFingerprint([{ type: '1', text: 'x\u00002\u0000y' }]);
288
+ const twoTokens = implementationFingerprint([{ type: '1', text: 'x' }, { type: '2', text: 'y' }]);
289
+ assert.notEqual(oneToken, twoTokens);
290
+ });
291
+ check('public CLI rejects the name-only repro and keeps an exact-match control', () => {
292
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-cli-')));
293
+ const cli = join(process.cwd(), 'dist', 'cli.js');
294
+ const git = (...args) => {
295
+ execFileSync('git', args, { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'] });
296
+ };
297
+ const run = () => {
298
+ const result = spawnSync(process.execPath, [cli, 'review', '--verify-only', '--checks', 'reinvented', '--from', 'HEAD~1', '--to', 'HEAD', '--format', 'compact'], { cwd: dir, env: { ...process.env, CI: 'true' }, encoding: 'utf8' });
299
+ return { status: result.status, stdout: result.stdout, stderr: result.stderr };
300
+ };
301
+ try {
302
+ git('init', '-q', '.');
303
+ git('config', 'user.name', 'PowerShot Tests');
304
+ git('config', 'user.email', 'tests@powershot.invalid');
305
+ mkdirSync(join(dir, 'src'), { recursive: true });
306
+ mkdirSync(join(dir, 'scripts'), { recursive: true });
307
+ writeFileSync(join(dir, 'package.json'), '{"name":"fixture","private":true}\n');
308
+ writeFileSync(join(dir, 'tsconfig.json'), '{"compilerOptions":{"allowJs":true},"include":["src/**/*.ts","scripts/**/*"]}\n');
309
+ const existing = 'export function runCheck(currentVersion: string, availableVersion: string) { return currentVersion !== availableVersion }\n';
310
+ writeFileSync(join(dir, 'src/use-app-updater.ts'), existing);
311
+ git('add', '.');
312
+ git('commit', '-q', '-m', 'base');
313
+ writeFileSync(join(dir, 'scripts/check-import-boundaries.mjs'), 'function runCheck({ srcRoot, allowlist }) { return allowlist.filter((entry) => !entry.startsWith(srcRoot)) }\n');
314
+ git('add', '.');
315
+ git('commit', '-q', '-m', 'different helper with same name');
316
+ const nameOnly = run();
317
+ assert.equal(nameOnly.status, 0, nameOnly.stderr || nameOnly.stdout);
318
+ assert.doesNotMatch(nameOnly.stdout, /\[reinvented\]/);
319
+ writeFileSync(join(dir, 'scripts/duplicate.ts'), existing);
320
+ git('add', '.');
321
+ git('commit', '-q', '-m', 'exact duplicate');
322
+ const exact = run();
323
+ assert.equal(exact.status, 1, exact.stderr || exact.stdout);
324
+ assert.match(exact.stdout, /\[reinvented\]/);
325
+ }
326
+ finally {
327
+ rmSync(dir, { recursive: true, force: true });
328
+ }
329
+ });
168
330
  console.log('\ndropped-guard');
169
331
  check('fires when an early-return guard disappears', () => {
170
332
  const before = 'export function close(inv: any) {\n if (!inv.customer) return null\n return inv.total\n}\n';
@@ -584,30 +746,64 @@ check('a review that did not complete never renders as clean', () => {
584
746
  });
585
747
  assert.match(partial, /partial, not a verdict/);
586
748
  assert.match(partial, /outside\.ts \(no types\)/);
749
+ const partialMarkdown = markdown([], {
750
+ state: 'partial', notLookedAt: ['outside.ts (no types)'],
751
+ verifyOnly: true, minSeverity: 'medium', filesReviewed: 4, deterministicChecks: 7,
752
+ });
753
+ assert.match(partialMarkdown, /This review is partial — not a verdict/);
754
+ assert.match(partialMarkdown, /<summary>Why this is not a verdict<\/summary>/);
755
+ assert.match(partialMarkdown, /4 files reviewed · 7 deterministic checks/);
756
+ assert.doesNotMatch(partialMarkdown.slice(0, partialMarkdown.indexOf('<details>')), /outside\\?\.ts/);
757
+ assert.match(partialMarkdown.replace(/\\/g, ''), /outside\.ts \(no types\)/);
587
758
  });
588
759
  check('portable coverage is a verdict, but never masquerades as full semantic coverage', () => {
589
760
  const unavailable = [
590
- '1 file(s) without enriched semantic coverage: web/app.ts (types, references)',
591
- '2 enriched check(s) unavailable: phantom-api (no types), contract-drift (no references)',
761
+ '1 reviewed file lacked type information and a reference graph',
762
+ '2 checks requiring type information or a reference graph did not run: phantom-api, contract-drift',
592
763
  ];
593
764
  const out = terminal([], {
594
765
  subtitle: 'workspace', verified: 0, judged: 0, state: 'complete', notLookedAt: [],
595
- coverage: 'portable', unavailableCoverage: unavailable,
766
+ coverage: 'portable', verifyOnly: true, minSeverity: 'medium',
767
+ filesReviewed: 1, deterministicChecks: 1, scopeDetails: unavailable,
596
768
  });
597
- assert.match(out, /No findings in portable coverage\./);
598
- assert.match(out, /web\/app\.ts \(types, references\)/);
769
+ assert.match(out, /No medium-or-higher deterministic findings\./);
770
+ assert.match(out, /1 file reviewed · 1 deterministic check · portable coverage/);
771
+ assert.match(out, /1 reviewed file lacked type information and a reference graph/);
599
772
  assert.doesNotMatch(out, /not a verdict/);
600
- const md = markdown([], {
773
+ const selected = Array.from({ length: 20 }, (_, index) => ({
774
+ path: 'web/file-' + index + '.ts',
775
+ disposition: 'selected',
776
+ unavailable: index < 11 ? ['types', 'references'] : undefined,
777
+ }));
778
+ const waived = Array.from({ length: 16 }, (_, index) => ({
779
+ path: 'assets/file-' + index + '.json',
780
+ disposition: 'waived',
781
+ reason: 'no parser',
782
+ }));
783
+ const md = markdown([], summarizeRun({
601
784
  state: 'complete', notLookedAt: [], coverage: 'portable',
602
- files: [{ path: 'web/app.ts', unavailable: ['types', 'references'] }],
603
- checks: { unavailable: [
785
+ engine: { verifyOnly: true, minSeverity: 'medium' },
786
+ files: [...selected, ...waived],
787
+ checks: {
788
+ ran: Array.from({ length: 19 }, (_, index) => 'check-' + index),
789
+ unavailable: [
604
790
  { check: 'phantom-api', missing: 'types' },
605
791
  { check: 'contract-drift', missing: 'references' },
606
- ] },
607
- });
608
- assert.match(md, /\*\*Portable coverage\.\*\*/);
609
- assert.match(md, /No findings in portable coverage\./);
610
- assert.doesNotMatch(md, /^No findings\.$/m);
792
+ { check: 'dead-on-arrival', missing: 'references' },
793
+ ],
794
+ },
795
+ }));
796
+ assert.match(md, /✅ \*\*No medium-or-higher deterministic findings\*\*/);
797
+ assert.match(md, /20 files reviewed · 19 deterministic checks · portable coverage/);
798
+ assert.match(md, /Model review was disabled \(`verify-only`\)\./);
799
+ assert.match(md, /<summary>Coverage details<\/summary>/);
800
+ const rendered = md.replace(/\\/g, '');
801
+ assert.match(rendered, /11 reviewed files? lacked type information and a reference graph/);
802
+ assert.match(rendered, /3 checks? requiring type information or a reference graph did not run: phantom-api, contract-drift, dead-on-arrival/);
803
+ assert.match(rendered, /16 changed files? not reviewed: no parser/);
804
+ assert.ok(md.indexOf('No medium-or-higher deterministic findings') < md.indexOf('Coverage details'));
805
+ assert.doesNotMatch(md, /web\/file-|assets\/file-/);
806
+ assert.doesNotMatch(md, /No findings in portable coverage\./);
611
807
  });
612
808
  check('findings are still shown when a stage failed, with the warning kept', () => {
613
809
  const f = { id: 'F1', class: 'verified', check: 'phantom-dep', severity: 'high',
@@ -724,6 +920,353 @@ const sample = [
724
920
  { id: 'F2', class: 'judged', check: 'plausible-logic', severity: 'low', confidence: 'tentative',
725
921
  file: 'src/b.ts', line: 9, title: 'off by one' },
726
922
  ];
923
+ const summaryHead = 'a'.repeat(40);
924
+ const summaryScope = 'xcrft/powershot/.github/workflows/review.yml:review';
925
+ const ownedSummaryMarker = summaryMarker(summaryScope);
926
+ const renderedSummary = (markdown) => summaryCommentBody(markdown, ownedSummaryMarker, summaryHead);
927
+ check('summary scope follows the workflow file and job, not its moving ref', () => {
928
+ assert.equal(workflowCommentScope('xcrft/powershot/.github/workflows/review.yml@refs/pull/6/merge', 'review'), summaryScope);
929
+ assert.equal(workflowCommentScope('xcrft/powershot/.github/workflows/review.yml@refs/heads/release@v1', 'review'), summaryScope);
930
+ });
931
+ await checkAsync('summary publishing creates a marked comment without touching another workflow', async () => {
932
+ const events = [];
933
+ const comments = [
934
+ { id: 8, body: '## PR Analysis', user: { login: 'github-actions[bot]' } },
935
+ { id: 9, body: ownedSummaryMarker, user: { login: 'human' } },
936
+ ];
937
+ const api = {
938
+ headSha: async () => summaryHead,
939
+ listIssueComments: async () => [...comments],
940
+ createIssueComment: async (body) => {
941
+ events.push(`create:${body}`);
942
+ const created = { id: 10, body, user: { login: 'github-actions[bot]' } };
943
+ comments.push(created);
944
+ return created;
945
+ },
946
+ updateIssueComment: async (id) => { events.push(`update:${id}`); },
947
+ deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
948
+ };
949
+ const result = await syncSummaryComment(api, '## PowerShot\n\nNo findings.\n', summaryHead, summaryScope);
950
+ assert.deepEqual(result, { state: 'created', commentId: 10, retired: 0 });
951
+ assert.deepEqual(events, [`create:${renderedSummary('## PowerShot\n\nNo findings.')}`]);
952
+ });
953
+ await checkAsync('summary reruns update the marked PowerShot comment, not the latest bot comment', async () => {
954
+ const events = [];
955
+ const api = {
956
+ headSha: async () => summaryHead,
957
+ listIssueComments: async () => [
958
+ { id: 10, body: renderedSummary('## PowerShot\n\nOld'), user: { login: 'github-actions[bot]' } },
959
+ { id: 11, body: '## Best Practices', user: { login: 'github-actions[bot]' } },
960
+ ],
961
+ createIssueComment: async () => { throw new Error('must not create'); },
962
+ updateIssueComment: async (id, body) => { events.push(`update:${id}:${body}`); },
963
+ deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
964
+ };
965
+ const markdown = '## PowerShot\n\nNew';
966
+ const result = await syncSummaryComment(api, markdown, summaryHead, summaryScope);
967
+ assert.deepEqual(result, { state: 'updated', commentId: 10, retired: 0 });
968
+ assert.deepEqual(events, [`update:10:${renderedSummary(markdown)}`]);
969
+ });
970
+ await checkAsync('summary reruns make no write when the marked body is current', async () => {
971
+ const body = renderedSummary('## PowerShot\n\nCurrent');
972
+ const api = {
973
+ headSha: async () => summaryHead,
974
+ listIssueComments: async () => [
975
+ { id: 10, body, user: { login: 'github-actions[bot]' } },
976
+ { id: 11, body: '## Best Practices', user: { login: 'github-actions[bot]' } },
977
+ ],
978
+ createIssueComment: async () => { throw new Error('must not create'); },
979
+ updateIssueComment: async () => { throw new Error('must not update'); },
980
+ deleteIssueComment: async () => { throw new Error('must not delete'); },
981
+ };
982
+ assert.deepEqual(await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope), {
983
+ state: 'unchanged', commentId: 10, retired: 0,
984
+ });
985
+ });
986
+ await checkAsync('summary publishing migrates the newest legacy PowerShot body once', async () => {
987
+ const events = [];
988
+ const comments = [
989
+ { id: 12, body: LEGACY_SUMMARY_MARKER, user: { login: 'github-actions[bot]' } },
990
+ { id: 14, body: '## PowerShot\n\nLatest', user: { login: 'github-actions[bot]' } },
991
+ { id: 15, body: '## PR Analysis', user: { login: 'github-actions[bot]' } },
992
+ ];
993
+ const api = {
994
+ headSha: async () => summaryHead,
995
+ listIssueComments: async () => [...comments],
996
+ createIssueComment: async (body) => {
997
+ events.push('create');
998
+ const created = { id: 16, body, user: { login: 'github-actions[bot]' } };
999
+ comments.push(created);
1000
+ return created;
1001
+ },
1002
+ updateIssueComment: async () => { throw new Error('legacy comments must not be claimed with PATCH'); },
1003
+ deleteIssueComment: async (id) => {
1004
+ events.push(`delete:${id}`);
1005
+ const index = comments.findIndex((comment) => comment.id === id);
1006
+ if (index !== -1)
1007
+ comments.splice(index, 1);
1008
+ },
1009
+ };
1010
+ const result = await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope);
1011
+ assert.deepEqual(result, { state: 'migrated', commentId: 16, retired: 1 });
1012
+ assert.deepEqual(events, ['create', 'delete:14']);
1013
+ assert.equal(comments.some((comment) => comment.id === 12), true);
1014
+ });
1015
+ await checkAsync('summary publishing never patches a candidate from another pull request head', async () => {
1016
+ const previousHead = '9'.repeat(40);
1017
+ const comments = [
1018
+ { id: 16, body: summaryCommentBody('old head', ownedSummaryMarker, previousHead), user: { login: 'github-actions[bot]' } },
1019
+ ];
1020
+ const events = [];
1021
+ const api = {
1022
+ headSha: async () => summaryHead,
1023
+ listIssueComments: async () => [...comments],
1024
+ createIssueComment: async (body) => {
1025
+ events.push('create');
1026
+ const created = { id: 17, body, user: { login: 'github-actions[bot]' } };
1027
+ comments.push(created);
1028
+ return created;
1029
+ },
1030
+ updateIssueComment: async () => { throw new Error('must not patch a different-head candidate'); },
1031
+ deleteIssueComment: async (id) => {
1032
+ events.push(`delete:${id}`);
1033
+ const index = comments.findIndex((comment) => comment.id === id);
1034
+ if (index !== -1)
1035
+ comments.splice(index, 1);
1036
+ },
1037
+ };
1038
+ assert.deepEqual(await syncSummaryComment(api, 'current head', summaryHead, summaryScope), {
1039
+ state: 'created', commentId: 17, retired: 1,
1040
+ });
1041
+ assert.deepEqual(events, ['create', 'delete:16']);
1042
+ assert.deepEqual(comments.map((comment) => comment.id), [17]);
1043
+ });
1044
+ await checkAsync('summary ownership requires the marker at the start of the bot comment', async () => {
1045
+ const events = [];
1046
+ const comments = [{
1047
+ id: 16,
1048
+ body: `## PR Analysis\n\nQuoted output: ${ownedSummaryMarker}`,
1049
+ user: { login: 'github-actions[bot]' },
1050
+ }];
1051
+ const api = {
1052
+ headSha: async () => summaryHead,
1053
+ listIssueComments: async () => [...comments],
1054
+ createIssueComment: async (body) => {
1055
+ events.push('create');
1056
+ const created = { id: 17, body, user: { login: 'github-actions[bot]' } };
1057
+ comments.push(created);
1058
+ return created;
1059
+ },
1060
+ updateIssueComment: async (id) => { events.push(`update:${id}`); },
1061
+ deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
1062
+ };
1063
+ assert.deepEqual(await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope), {
1064
+ state: 'created', commentId: 17, retired: 0,
1065
+ });
1066
+ assert.deepEqual(events, ['create']);
1067
+ });
1068
+ await checkAsync('summary markers isolate different workflow jobs using the same bot', async () => {
1069
+ const otherMarker = summaryMarker('xcrft/powershot/.github/workflows/audit.yml:audit');
1070
+ const comments = [
1071
+ {
1072
+ id: 18,
1073
+ body: summaryCommentBody('## PowerShot\n\nAudit', otherMarker, summaryHead),
1074
+ user: { login: 'github-actions[bot]' },
1075
+ },
1076
+ ];
1077
+ const api = {
1078
+ headSha: async () => summaryHead,
1079
+ listIssueComments: async () => [...comments],
1080
+ createIssueComment: async (body) => {
1081
+ const created = { id: 19, body, user: { login: 'github-actions[bot]' } };
1082
+ comments.push(created);
1083
+ return created;
1084
+ },
1085
+ updateIssueComment: async () => { throw new Error('must not update another workflow'); },
1086
+ deleteIssueComment: async () => { throw new Error('must not delete another workflow'); },
1087
+ };
1088
+ const result = await syncSummaryComment(api, '## PowerShot\n\nReview', summaryHead, summaryScope);
1089
+ assert.deepEqual(result, { state: 'created', commentId: 19, retired: 0 });
1090
+ assert.equal(comments[0]?.body.includes('Audit'), true);
1091
+ });
1092
+ await checkAsync('summary publishing makes no writes for an outdated pull request head', async () => {
1093
+ const api = {
1094
+ headSha: async () => 'b'.repeat(40),
1095
+ listIssueComments: async () => { throw new Error('must not list'); },
1096
+ createIssueComment: async () => { throw new Error('must not create'); },
1097
+ updateIssueComment: async () => { throw new Error('must not update'); },
1098
+ deleteIssueComment: async () => { throw new Error('must not delete'); },
1099
+ };
1100
+ assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
1101
+ state: 'outdated', retired: 0,
1102
+ });
1103
+ });
1104
+ await checkAsync('summary publishing makes no writes when the head changes during reads', async () => {
1105
+ let headReads = 0;
1106
+ const api = {
1107
+ headSha: async () => ++headReads === 1 ? summaryHead : 'b'.repeat(40),
1108
+ listIssueComments: async () => [],
1109
+ createIssueComment: async () => { throw new Error('must not create'); },
1110
+ updateIssueComment: async () => { throw new Error('must not update'); },
1111
+ deleteIssueComment: async () => { throw new Error('must not delete'); },
1112
+ };
1113
+ assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
1114
+ state: 'outdated', retired: 0,
1115
+ });
1116
+ });
1117
+ await checkAsync('summary publishing retires its own new comment when the head changes after create', async () => {
1118
+ let headReads = 0;
1119
+ const comments = [];
1120
+ const api = {
1121
+ headSha: async () => ++headReads < 3 ? summaryHead : 'b'.repeat(40),
1122
+ listIssueComments: async () => [...comments],
1123
+ createIssueComment: async (body) => {
1124
+ const created = { id: 19, body, user: { login: 'github-actions[bot]' } };
1125
+ comments.push(created);
1126
+ return created;
1127
+ },
1128
+ updateIssueComment: async () => { throw new Error('must not update'); },
1129
+ deleteIssueComment: async (id) => {
1130
+ const index = comments.findIndex((comment) => comment.id === id);
1131
+ if (index !== -1)
1132
+ comments.splice(index, 1);
1133
+ },
1134
+ };
1135
+ assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
1136
+ state: 'outdated', retired: 1,
1137
+ });
1138
+ assert.deepEqual(comments, []);
1139
+ });
1140
+ await checkAsync('summary publishing stops after a same-head update when the pull request head changes', async () => {
1141
+ const nextHead = 'b'.repeat(40);
1142
+ let headReads = 0;
1143
+ const oldCandidate = { id: 19, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } };
1144
+ const newCandidate = {
1145
+ id: 20,
1146
+ body: summaryCommentBody('new head', ownedSummaryMarker, nextHead),
1147
+ user: { login: 'github-actions[bot]' },
1148
+ };
1149
+ const comments = [oldCandidate];
1150
+ const api = {
1151
+ headSha: async () => ++headReads < 3 ? summaryHead : nextHead,
1152
+ listIssueComments: async () => [...comments],
1153
+ createIssueComment: async () => { throw new Error('must not create'); },
1154
+ updateIssueComment: async (id, body) => {
1155
+ assert.equal(id, oldCandidate.id);
1156
+ oldCandidate.body = body;
1157
+ comments.push(newCandidate);
1158
+ },
1159
+ deleteIssueComment: async () => { throw new Error('must not delete after the head changes'); },
1160
+ };
1161
+ assert.deepEqual(await syncSummaryComment(api, 'updated old head', summaryHead, summaryScope), {
1162
+ state: 'outdated', retired: 0,
1163
+ });
1164
+ assert.equal(comments[1]?.body, newCandidate.body);
1165
+ });
1166
+ await checkAsync('summary reruns retire only older duplicates with the same scoped marker', async () => {
1167
+ const comments = [
1168
+ { id: 20, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } },
1169
+ { id: 21, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } },
1170
+ { id: 22, body: 'human note', user: { login: 'human' } },
1171
+ ];
1172
+ const api = {
1173
+ headSha: async () => summaryHead,
1174
+ listIssueComments: async () => [...comments],
1175
+ createIssueComment: async () => { throw new Error('must not create'); },
1176
+ updateIssueComment: async (id, body) => {
1177
+ const comment = comments.find((candidate) => candidate.id === id);
1178
+ if (comment)
1179
+ comment.body = body;
1180
+ },
1181
+ deleteIssueComment: async (id) => {
1182
+ const index = comments.findIndex((comment) => comment.id === id);
1183
+ if (index !== -1)
1184
+ comments.splice(index, 1);
1185
+ },
1186
+ };
1187
+ assert.deepEqual(await syncSummaryComment(api, 'current', summaryHead, summaryScope), {
1188
+ state: 'updated', commentId: 21, retired: 1,
1189
+ });
1190
+ assert.deepEqual(comments.map((comment) => comment.id), [21, 22]);
1191
+ });
1192
+ await checkAsync('concurrent first summary runs converge on one marked comment', async () => {
1193
+ let releaseInitialReads = () => undefined;
1194
+ const initialReads = new Promise((resolve) => { releaseInitialReads = resolve; });
1195
+ let reads = 0;
1196
+ let nextId = 20;
1197
+ const comments = [];
1198
+ const api = {
1199
+ headSha: async () => summaryHead,
1200
+ listIssueComments: async () => {
1201
+ if (reads < 2) {
1202
+ const snapshot = [...comments];
1203
+ reads++;
1204
+ if (reads === 2)
1205
+ releaseInitialReads();
1206
+ await initialReads;
1207
+ return snapshot;
1208
+ }
1209
+ return [...comments];
1210
+ },
1211
+ createIssueComment: async (body) => {
1212
+ const created = { id: nextId++, body, user: { login: 'github-actions[bot]' } };
1213
+ comments.push(created);
1214
+ return created;
1215
+ },
1216
+ updateIssueComment: async () => { throw new Error('must not update'); },
1217
+ deleteIssueComment: async (id) => {
1218
+ const index = comments.findIndex((comment) => comment.id === id);
1219
+ if (index !== -1)
1220
+ comments.splice(index, 1);
1221
+ },
1222
+ };
1223
+ await Promise.all([
1224
+ syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope),
1225
+ syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope),
1226
+ ]);
1227
+ assert.equal(comments.filter((comment) => comment.body.startsWith(ownedSummaryMarker)).length, 1);
1228
+ });
1229
+ await checkAsync('different workflows replace a shared legacy summary without cross-scope PATCH', async () => {
1230
+ let releaseInitialReads = () => undefined;
1231
+ const initialReads = new Promise((resolve) => { releaseInitialReads = resolve; });
1232
+ let reads = 0;
1233
+ let nextId = 31;
1234
+ const legacy = { id: 30, body: '## PowerShot\n\nLegacy', user: { login: 'github-actions[bot]' } };
1235
+ const comments = [legacy];
1236
+ const api = {
1237
+ headSha: async () => summaryHead,
1238
+ listIssueComments: async () => {
1239
+ if (reads < 2) {
1240
+ const snapshot = [...comments];
1241
+ reads++;
1242
+ if (reads === 2)
1243
+ releaseInitialReads();
1244
+ await initialReads;
1245
+ return snapshot;
1246
+ }
1247
+ return [...comments];
1248
+ },
1249
+ createIssueComment: async (body) => {
1250
+ const created = { id: nextId++, body, user: { login: 'github-actions[bot]' } };
1251
+ comments.push(created);
1252
+ return created;
1253
+ },
1254
+ updateIssueComment: async () => { throw new Error('legacy ownership must not use PATCH'); },
1255
+ deleteIssueComment: async (id) => {
1256
+ const index = comments.findIndex((comment) => comment.id === id);
1257
+ if (index !== -1)
1258
+ comments.splice(index, 1);
1259
+ },
1260
+ };
1261
+ const auditScope = 'xcrft/powershot/.github/workflows/audit.yml:audit';
1262
+ await Promise.all([
1263
+ syncSummaryComment(api, 'review', summaryHead, summaryScope),
1264
+ syncSummaryComment(api, 'audit', summaryHead, auditScope),
1265
+ ]);
1266
+ assert.equal(comments.some((comment) => comment.id === legacy.id), false);
1267
+ assert.equal(comments.filter((comment) => comment.body.startsWith(summaryMarker(summaryScope))).length, 1);
1268
+ assert.equal(comments.filter((comment) => comment.body.startsWith(summaryMarker(auditScope))).length, 1);
1269
+ });
727
1270
  check('GitHub patches expose only added right-side lines for inline comments', () => {
728
1271
  const patch = [
729
1272
  '@@ -1,3 +1,4 @@',
@@ -842,10 +1385,20 @@ await checkAsync('the GitHub client paginates files, submits the review contract
842
1385
  { id: 10, path: 'a.ts', line: 1, body: 'reply', in_reply_to_id: 9, user: { login: 'human' } },
843
1386
  ]);
844
1387
  }
1388
+ if (method === 'GET' && url.includes('/issues/7/comments')) {
1389
+ return json([{ id: 21, body: 'summary', user: { login: 'github-actions[bot]' } }]);
1390
+ }
845
1391
  if (method === 'POST' && url.endsWith('/pulls/7/reviews'))
846
1392
  return json({ id: 1 });
1393
+ if (method === 'POST' && url.endsWith('/issues/7/comments')) {
1394
+ return json({ id: 22, body: 'new summary', user: { login: 'github-actions[bot]' } }, { status: 201 });
1395
+ }
1396
+ if (method === 'PATCH' && url.endsWith('/issues/comments/21'))
1397
+ return json({ id: 21 });
847
1398
  if (method === 'DELETE' && url.endsWith('/pulls/comments/9'))
848
1399
  return json({ message: 'gone' }, { status: 404 });
1400
+ if (method === 'DELETE' && url.endsWith('/issues/comments/21'))
1401
+ return new Response(undefined, { status: 204 });
849
1402
  return json({ message: 'unexpected request' }, { status: 500 });
850
1403
  };
851
1404
  globalThis.fetch = fakeFetch;
@@ -861,11 +1414,19 @@ await checkAsync('the GitHub client paginates files, submits the review contract
861
1414
  assert.equal(comments[1]?.inReplyToId, 9);
862
1415
  await api.createReview('a'.repeat(40), [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }]);
863
1416
  await api.deleteReviewComment(9);
1417
+ assert.deepEqual(await api.listIssueComments(), [
1418
+ { id: 21, body: 'summary', user: { login: 'github-actions[bot]' } },
1419
+ ]);
1420
+ await api.updateIssueComment(21, 'updated summary');
1421
+ assert.deepEqual(await api.createIssueComment('new summary'), {
1422
+ id: 22, body: 'new summary', user: { login: 'github-actions[bot]' },
1423
+ });
1424
+ await api.deleteIssueComment(21);
864
1425
  }
865
1426
  finally {
866
1427
  globalThis.fetch = originalFetch;
867
1428
  }
868
- const submitted = calls.find((call) => call.method === 'POST');
1429
+ const submitted = calls.find((call) => call.method === 'POST' && call.url.endsWith('/pulls/7/reviews'));
869
1430
  assert.ok(submitted?.body);
870
1431
  assert.deepEqual(JSON.parse(submitted.body), {
871
1432
  commit_id: 'a'.repeat(40),
@@ -874,6 +1435,9 @@ await checkAsync('the GitHub client paginates files, submits the review contract
874
1435
  comments: [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }],
875
1436
  });
876
1437
  assert.equal(calls.filter((call) => call.url.includes('/files')).length, 2);
1438
+ assert.equal(calls.some((call) => call.method === 'PATCH' && call.url.endsWith('/issues/comments/21') && call.body === '{"body":"updated summary"}'), true);
1439
+ assert.equal(calls.some((call) => call.method === 'POST' && call.url.endsWith('/issues/7/comments') && call.body === '{"body":"new summary"}'), true);
1440
+ assert.equal(calls.some((call) => call.method === 'DELETE' && call.url.endsWith('/issues/comments/21')), true);
877
1441
  });
878
1442
  await checkAsync('inline synchronization creates one review before removing stale comments', async () => {
879
1443
  const finding = {
@@ -976,6 +1540,11 @@ check('the public action persists judge answers and publishes only a verdict', (
976
1540
  assert.match(action, /inline-comments:\s*\n\s+description: [^\n]+\n\s+default: 'false'/);
977
1541
  assert.match(action, /Post inline comments[\s\S]+inputs\.inline-comments == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
978
1542
  assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/inline-comments\.js"/);
1543
+ assert.doesNotMatch(action, /--edit-last/);
1544
+ assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/summary-comment\.js"/);
1545
+ assert.match(action, /POWERSHOT_HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/);
1546
+ assert.match(action, /GITHUB_WORKFLOW_REF: \$\{\{ github\.workflow_ref \}\}/);
1547
+ assert.match(action, /GITHUB_JOB: \$\{\{ github\.job \}\}/);
979
1548
  assert.match(action, /--report manifest=powershot\.manifest\.json/);
980
1549
  assert.match(action, /coverage=\$COVERAGE/);
981
1550
  assert.match(action, /m\.coverage === "full" \|\| m\.coverage === "portable" \? m\.coverage : "unknown"/);
@@ -988,6 +1557,7 @@ check('published CI examples preserve one verdict and its exit status', () => {
988
1557
  assert.match(action, /upload-sarif: 'true'/);
989
1558
  assert.match(action, /inline-comments: 'true'/);
990
1559
  assert.match(action, /runs-on: ubuntu-24\.04/);
1560
+ assert.match(action, /concurrency:[\s\S]+github\.workflow[\s\S]+cancel-in-progress: true/);
991
1561
  assert.doesNotMatch(action, /npm ci|NPM_AUTH_TOKEN|NODE_AUTH_TOKEN/);
992
1562
  assert.match(action, /uses: xcrft\/powershot@v1/);
993
1563
  assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.2/);
@@ -1019,10 +1589,12 @@ check('the viewer labels a complete portable session', () => {
1019
1589
  const html = viewer([], {
1020
1590
  id: 'portable', target: 'workspace', started: '2026-01-01T10:00:00Z',
1021
1591
  state: 'complete', notLookedAt: [], coverage: 'portable',
1022
- unavailableCoverage: ['1 file(s) without enriched semantic coverage: app.ts (types)'],
1592
+ verifyOnly: true, minSeverity: 'medium', filesReviewed: 1, deterministicChecks: 2,
1593
+ scopeDetails: ['1 reviewed file lacked type information'],
1023
1594
  });
1024
- assert.match(html, /Portable coverage\./);
1025
- assert.match(html, /No findings in portable coverage\./);
1595
+ assert.match(html, /1 file reviewed · 2 deterministic checks · portable coverage/);
1596
+ assert.match(html, /No medium-or-higher deterministic findings\./);
1597
+ assert.match(html, /<summary>Coverage details<\/summary>/);
1026
1598
  });
1027
1599
  check('the viewer escapes content rather than rendering it', () => {
1028
1600
  const nasty = [{ ...sample[0], title: '<img src=x onerror=alert(1)>' }];