@0xcraft/powershot 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/selftest.js CHANGED
@@ -48,12 +48,15 @@ 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';
54
55
  import { incompleteReasons } from './bench.js';
55
56
  import { PACKAGE_NAME, PACKAGE_VERSION } from './package-meta.js';
56
- import { addedLinesFromPatch, createReviewPayload, GitHubPullRequestApi, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
57
+ import { addedLinesFromPatch, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
58
+ import { createReviewPayload, GitHubPullRequestApi, } from './github/api.js';
59
+ import { LEGACY_SUMMARY_MARKER, summaryCommentBody, summaryMarker, syncSummaryComment, workflowCommentScope, } from './github/summary-comment.js';
57
60
  const root = '/repo';
58
61
  /** Build a Ground by hand so verifiers are testable without git or a real repo. */
59
62
  function ground(files, deps = []) {
@@ -584,30 +587,64 @@ check('a review that did not complete never renders as clean', () => {
584
587
  });
585
588
  assert.match(partial, /partial, not a verdict/);
586
589
  assert.match(partial, /outside\.ts \(no types\)/);
590
+ const partialMarkdown = markdown([], {
591
+ state: 'partial', notLookedAt: ['outside.ts (no types)'],
592
+ verifyOnly: true, minSeverity: 'medium', filesReviewed: 4, deterministicChecks: 7,
593
+ });
594
+ assert.match(partialMarkdown, /This review is partial — not a verdict/);
595
+ assert.match(partialMarkdown, /<summary>Why this is not a verdict<\/summary>/);
596
+ assert.match(partialMarkdown, /4 files reviewed · 7 deterministic checks/);
597
+ assert.doesNotMatch(partialMarkdown.slice(0, partialMarkdown.indexOf('<details>')), /outside\\?\.ts/);
598
+ assert.match(partialMarkdown.replace(/\\/g, ''), /outside\.ts \(no types\)/);
587
599
  });
588
600
  check('portable coverage is a verdict, but never masquerades as full semantic coverage', () => {
589
601
  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)',
602
+ '1 reviewed file lacked type information and a reference graph',
603
+ '2 checks requiring type information or a reference graph did not run: phantom-api, contract-drift',
592
604
  ];
593
605
  const out = terminal([], {
594
606
  subtitle: 'workspace', verified: 0, judged: 0, state: 'complete', notLookedAt: [],
595
- coverage: 'portable', unavailableCoverage: unavailable,
607
+ coverage: 'portable', verifyOnly: true, minSeverity: 'medium',
608
+ filesReviewed: 1, deterministicChecks: 1, scopeDetails: unavailable,
596
609
  });
597
- assert.match(out, /No findings in portable coverage\./);
598
- assert.match(out, /web\/app\.ts \(types, references\)/);
610
+ assert.match(out, /No medium-or-higher deterministic findings\./);
611
+ assert.match(out, /1 file reviewed · 1 deterministic check · portable coverage/);
612
+ assert.match(out, /1 reviewed file lacked type information and a reference graph/);
599
613
  assert.doesNotMatch(out, /not a verdict/);
600
- const md = markdown([], {
614
+ const selected = Array.from({ length: 20 }, (_, index) => ({
615
+ path: 'web/file-' + index + '.ts',
616
+ disposition: 'selected',
617
+ unavailable: index < 11 ? ['types', 'references'] : undefined,
618
+ }));
619
+ const waived = Array.from({ length: 16 }, (_, index) => ({
620
+ path: 'assets/file-' + index + '.json',
621
+ disposition: 'waived',
622
+ reason: 'no parser',
623
+ }));
624
+ const md = markdown([], summarizeRun({
601
625
  state: 'complete', notLookedAt: [], coverage: 'portable',
602
- files: [{ path: 'web/app.ts', unavailable: ['types', 'references'] }],
603
- checks: { unavailable: [
626
+ engine: { verifyOnly: true, minSeverity: 'medium' },
627
+ files: [...selected, ...waived],
628
+ checks: {
629
+ ran: Array.from({ length: 19 }, (_, index) => 'check-' + index),
630
+ unavailable: [
604
631
  { check: 'phantom-api', missing: 'types' },
605
632
  { 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);
633
+ { check: 'dead-on-arrival', missing: 'references' },
634
+ ],
635
+ },
636
+ }));
637
+ assert.match(md, /✅ \*\*No medium-or-higher deterministic findings\*\*/);
638
+ assert.match(md, /20 files reviewed · 19 deterministic checks · portable coverage/);
639
+ assert.match(md, /Model review was disabled \(`verify-only`\)\./);
640
+ assert.match(md, /<summary>Coverage details<\/summary>/);
641
+ const rendered = md.replace(/\\/g, '');
642
+ assert.match(rendered, /11 reviewed files? lacked type information and a reference graph/);
643
+ assert.match(rendered, /3 checks? requiring type information or a reference graph did not run: phantom-api, contract-drift, dead-on-arrival/);
644
+ assert.match(rendered, /16 changed files? not reviewed: no parser/);
645
+ assert.ok(md.indexOf('No medium-or-higher deterministic findings') < md.indexOf('Coverage details'));
646
+ assert.doesNotMatch(md, /web\/file-|assets\/file-/);
647
+ assert.doesNotMatch(md, /No findings in portable coverage\./);
611
648
  });
612
649
  check('findings are still shown when a stage failed, with the warning kept', () => {
613
650
  const f = { id: 'F1', class: 'verified', check: 'phantom-dep', severity: 'high',
@@ -724,6 +761,353 @@ const sample = [
724
761
  { id: 'F2', class: 'judged', check: 'plausible-logic', severity: 'low', confidence: 'tentative',
725
762
  file: 'src/b.ts', line: 9, title: 'off by one' },
726
763
  ];
764
+ const summaryHead = 'a'.repeat(40);
765
+ const summaryScope = 'xcrft/powershot/.github/workflows/review.yml:review';
766
+ const ownedSummaryMarker = summaryMarker(summaryScope);
767
+ const renderedSummary = (markdown) => summaryCommentBody(markdown, ownedSummaryMarker, summaryHead);
768
+ check('summary scope follows the workflow file and job, not its moving ref', () => {
769
+ assert.equal(workflowCommentScope('xcrft/powershot/.github/workflows/review.yml@refs/pull/6/merge', 'review'), summaryScope);
770
+ assert.equal(workflowCommentScope('xcrft/powershot/.github/workflows/review.yml@refs/heads/release@v1', 'review'), summaryScope);
771
+ });
772
+ await checkAsync('summary publishing creates a marked comment without touching another workflow', async () => {
773
+ const events = [];
774
+ const comments = [
775
+ { id: 8, body: '## PR Analysis', user: { login: 'github-actions[bot]' } },
776
+ { id: 9, body: ownedSummaryMarker, user: { login: 'human' } },
777
+ ];
778
+ const api = {
779
+ headSha: async () => summaryHead,
780
+ listIssueComments: async () => [...comments],
781
+ createIssueComment: async (body) => {
782
+ events.push(`create:${body}`);
783
+ const created = { id: 10, body, user: { login: 'github-actions[bot]' } };
784
+ comments.push(created);
785
+ return created;
786
+ },
787
+ updateIssueComment: async (id) => { events.push(`update:${id}`); },
788
+ deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
789
+ };
790
+ const result = await syncSummaryComment(api, '## PowerShot\n\nNo findings.\n', summaryHead, summaryScope);
791
+ assert.deepEqual(result, { state: 'created', commentId: 10, retired: 0 });
792
+ assert.deepEqual(events, [`create:${renderedSummary('## PowerShot\n\nNo findings.')}`]);
793
+ });
794
+ await checkAsync('summary reruns update the marked PowerShot comment, not the latest bot comment', async () => {
795
+ const events = [];
796
+ const api = {
797
+ headSha: async () => summaryHead,
798
+ listIssueComments: async () => [
799
+ { id: 10, body: renderedSummary('## PowerShot\n\nOld'), user: { login: 'github-actions[bot]' } },
800
+ { id: 11, body: '## Best Practices', user: { login: 'github-actions[bot]' } },
801
+ ],
802
+ createIssueComment: async () => { throw new Error('must not create'); },
803
+ updateIssueComment: async (id, body) => { events.push(`update:${id}:${body}`); },
804
+ deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
805
+ };
806
+ const markdown = '## PowerShot\n\nNew';
807
+ const result = await syncSummaryComment(api, markdown, summaryHead, summaryScope);
808
+ assert.deepEqual(result, { state: 'updated', commentId: 10, retired: 0 });
809
+ assert.deepEqual(events, [`update:10:${renderedSummary(markdown)}`]);
810
+ });
811
+ await checkAsync('summary reruns make no write when the marked body is current', async () => {
812
+ const body = renderedSummary('## PowerShot\n\nCurrent');
813
+ const api = {
814
+ headSha: async () => summaryHead,
815
+ listIssueComments: async () => [
816
+ { id: 10, body, user: { login: 'github-actions[bot]' } },
817
+ { id: 11, body: '## Best Practices', user: { login: 'github-actions[bot]' } },
818
+ ],
819
+ createIssueComment: async () => { throw new Error('must not create'); },
820
+ updateIssueComment: async () => { throw new Error('must not update'); },
821
+ deleteIssueComment: async () => { throw new Error('must not delete'); },
822
+ };
823
+ assert.deepEqual(await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope), {
824
+ state: 'unchanged', commentId: 10, retired: 0,
825
+ });
826
+ });
827
+ await checkAsync('summary publishing migrates the newest legacy PowerShot body once', async () => {
828
+ const events = [];
829
+ const comments = [
830
+ { id: 12, body: LEGACY_SUMMARY_MARKER, user: { login: 'github-actions[bot]' } },
831
+ { id: 14, body: '## PowerShot\n\nLatest', user: { login: 'github-actions[bot]' } },
832
+ { id: 15, body: '## PR Analysis', user: { login: 'github-actions[bot]' } },
833
+ ];
834
+ const api = {
835
+ headSha: async () => summaryHead,
836
+ listIssueComments: async () => [...comments],
837
+ createIssueComment: async (body) => {
838
+ events.push('create');
839
+ const created = { id: 16, body, user: { login: 'github-actions[bot]' } };
840
+ comments.push(created);
841
+ return created;
842
+ },
843
+ updateIssueComment: async () => { throw new Error('legacy comments must not be claimed with PATCH'); },
844
+ deleteIssueComment: async (id) => {
845
+ events.push(`delete:${id}`);
846
+ const index = comments.findIndex((comment) => comment.id === id);
847
+ if (index !== -1)
848
+ comments.splice(index, 1);
849
+ },
850
+ };
851
+ const result = await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope);
852
+ assert.deepEqual(result, { state: 'migrated', commentId: 16, retired: 1 });
853
+ assert.deepEqual(events, ['create', 'delete:14']);
854
+ assert.equal(comments.some((comment) => comment.id === 12), true);
855
+ });
856
+ await checkAsync('summary publishing never patches a candidate from another pull request head', async () => {
857
+ const previousHead = '9'.repeat(40);
858
+ const comments = [
859
+ { id: 16, body: summaryCommentBody('old head', ownedSummaryMarker, previousHead), user: { login: 'github-actions[bot]' } },
860
+ ];
861
+ const events = [];
862
+ const api = {
863
+ headSha: async () => summaryHead,
864
+ listIssueComments: async () => [...comments],
865
+ createIssueComment: async (body) => {
866
+ events.push('create');
867
+ const created = { id: 17, body, user: { login: 'github-actions[bot]' } };
868
+ comments.push(created);
869
+ return created;
870
+ },
871
+ updateIssueComment: async () => { throw new Error('must not patch a different-head candidate'); },
872
+ deleteIssueComment: async (id) => {
873
+ events.push(`delete:${id}`);
874
+ const index = comments.findIndex((comment) => comment.id === id);
875
+ if (index !== -1)
876
+ comments.splice(index, 1);
877
+ },
878
+ };
879
+ assert.deepEqual(await syncSummaryComment(api, 'current head', summaryHead, summaryScope), {
880
+ state: 'created', commentId: 17, retired: 1,
881
+ });
882
+ assert.deepEqual(events, ['create', 'delete:16']);
883
+ assert.deepEqual(comments.map((comment) => comment.id), [17]);
884
+ });
885
+ await checkAsync('summary ownership requires the marker at the start of the bot comment', async () => {
886
+ const events = [];
887
+ const comments = [{
888
+ id: 16,
889
+ body: `## PR Analysis\n\nQuoted output: ${ownedSummaryMarker}`,
890
+ user: { login: 'github-actions[bot]' },
891
+ }];
892
+ const api = {
893
+ headSha: async () => summaryHead,
894
+ listIssueComments: async () => [...comments],
895
+ createIssueComment: async (body) => {
896
+ events.push('create');
897
+ const created = { id: 17, body, user: { login: 'github-actions[bot]' } };
898
+ comments.push(created);
899
+ return created;
900
+ },
901
+ updateIssueComment: async (id) => { events.push(`update:${id}`); },
902
+ deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
903
+ };
904
+ assert.deepEqual(await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope), {
905
+ state: 'created', commentId: 17, retired: 0,
906
+ });
907
+ assert.deepEqual(events, ['create']);
908
+ });
909
+ await checkAsync('summary markers isolate different workflow jobs using the same bot', async () => {
910
+ const otherMarker = summaryMarker('xcrft/powershot/.github/workflows/audit.yml:audit');
911
+ const comments = [
912
+ {
913
+ id: 18,
914
+ body: summaryCommentBody('## PowerShot\n\nAudit', otherMarker, summaryHead),
915
+ user: { login: 'github-actions[bot]' },
916
+ },
917
+ ];
918
+ const api = {
919
+ headSha: async () => summaryHead,
920
+ listIssueComments: async () => [...comments],
921
+ createIssueComment: async (body) => {
922
+ const created = { id: 19, body, user: { login: 'github-actions[bot]' } };
923
+ comments.push(created);
924
+ return created;
925
+ },
926
+ updateIssueComment: async () => { throw new Error('must not update another workflow'); },
927
+ deleteIssueComment: async () => { throw new Error('must not delete another workflow'); },
928
+ };
929
+ const result = await syncSummaryComment(api, '## PowerShot\n\nReview', summaryHead, summaryScope);
930
+ assert.deepEqual(result, { state: 'created', commentId: 19, retired: 0 });
931
+ assert.equal(comments[0]?.body.includes('Audit'), true);
932
+ });
933
+ await checkAsync('summary publishing makes no writes for an outdated pull request head', async () => {
934
+ const api = {
935
+ headSha: async () => 'b'.repeat(40),
936
+ listIssueComments: async () => { throw new Error('must not list'); },
937
+ createIssueComment: async () => { throw new Error('must not create'); },
938
+ updateIssueComment: async () => { throw new Error('must not update'); },
939
+ deleteIssueComment: async () => { throw new Error('must not delete'); },
940
+ };
941
+ assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
942
+ state: 'outdated', retired: 0,
943
+ });
944
+ });
945
+ await checkAsync('summary publishing makes no writes when the head changes during reads', async () => {
946
+ let headReads = 0;
947
+ const api = {
948
+ headSha: async () => ++headReads === 1 ? summaryHead : 'b'.repeat(40),
949
+ listIssueComments: async () => [],
950
+ createIssueComment: async () => { throw new Error('must not create'); },
951
+ updateIssueComment: async () => { throw new Error('must not update'); },
952
+ deleteIssueComment: async () => { throw new Error('must not delete'); },
953
+ };
954
+ assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
955
+ state: 'outdated', retired: 0,
956
+ });
957
+ });
958
+ await checkAsync('summary publishing retires its own new comment when the head changes after create', async () => {
959
+ let headReads = 0;
960
+ const comments = [];
961
+ const api = {
962
+ headSha: async () => ++headReads < 3 ? summaryHead : 'b'.repeat(40),
963
+ listIssueComments: async () => [...comments],
964
+ createIssueComment: async (body) => {
965
+ const created = { id: 19, body, user: { login: 'github-actions[bot]' } };
966
+ comments.push(created);
967
+ return created;
968
+ },
969
+ updateIssueComment: async () => { throw new Error('must not update'); },
970
+ deleteIssueComment: async (id) => {
971
+ const index = comments.findIndex((comment) => comment.id === id);
972
+ if (index !== -1)
973
+ comments.splice(index, 1);
974
+ },
975
+ };
976
+ assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
977
+ state: 'outdated', retired: 1,
978
+ });
979
+ assert.deepEqual(comments, []);
980
+ });
981
+ await checkAsync('summary publishing stops after a same-head update when the pull request head changes', async () => {
982
+ const nextHead = 'b'.repeat(40);
983
+ let headReads = 0;
984
+ const oldCandidate = { id: 19, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } };
985
+ const newCandidate = {
986
+ id: 20,
987
+ body: summaryCommentBody('new head', ownedSummaryMarker, nextHead),
988
+ user: { login: 'github-actions[bot]' },
989
+ };
990
+ const comments = [oldCandidate];
991
+ const api = {
992
+ headSha: async () => ++headReads < 3 ? summaryHead : nextHead,
993
+ listIssueComments: async () => [...comments],
994
+ createIssueComment: async () => { throw new Error('must not create'); },
995
+ updateIssueComment: async (id, body) => {
996
+ assert.equal(id, oldCandidate.id);
997
+ oldCandidate.body = body;
998
+ comments.push(newCandidate);
999
+ },
1000
+ deleteIssueComment: async () => { throw new Error('must not delete after the head changes'); },
1001
+ };
1002
+ assert.deepEqual(await syncSummaryComment(api, 'updated old head', summaryHead, summaryScope), {
1003
+ state: 'outdated', retired: 0,
1004
+ });
1005
+ assert.equal(comments[1]?.body, newCandidate.body);
1006
+ });
1007
+ await checkAsync('summary reruns retire only older duplicates with the same scoped marker', async () => {
1008
+ const comments = [
1009
+ { id: 20, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } },
1010
+ { id: 21, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } },
1011
+ { id: 22, body: 'human note', user: { login: 'human' } },
1012
+ ];
1013
+ const api = {
1014
+ headSha: async () => summaryHead,
1015
+ listIssueComments: async () => [...comments],
1016
+ createIssueComment: async () => { throw new Error('must not create'); },
1017
+ updateIssueComment: async (id, body) => {
1018
+ const comment = comments.find((candidate) => candidate.id === id);
1019
+ if (comment)
1020
+ comment.body = body;
1021
+ },
1022
+ deleteIssueComment: async (id) => {
1023
+ const index = comments.findIndex((comment) => comment.id === id);
1024
+ if (index !== -1)
1025
+ comments.splice(index, 1);
1026
+ },
1027
+ };
1028
+ assert.deepEqual(await syncSummaryComment(api, 'current', summaryHead, summaryScope), {
1029
+ state: 'updated', commentId: 21, retired: 1,
1030
+ });
1031
+ assert.deepEqual(comments.map((comment) => comment.id), [21, 22]);
1032
+ });
1033
+ await checkAsync('concurrent first summary runs converge on one marked comment', async () => {
1034
+ let releaseInitialReads = () => undefined;
1035
+ const initialReads = new Promise((resolve) => { releaseInitialReads = resolve; });
1036
+ let reads = 0;
1037
+ let nextId = 20;
1038
+ const comments = [];
1039
+ const api = {
1040
+ headSha: async () => summaryHead,
1041
+ listIssueComments: async () => {
1042
+ if (reads < 2) {
1043
+ const snapshot = [...comments];
1044
+ reads++;
1045
+ if (reads === 2)
1046
+ releaseInitialReads();
1047
+ await initialReads;
1048
+ return snapshot;
1049
+ }
1050
+ return [...comments];
1051
+ },
1052
+ createIssueComment: async (body) => {
1053
+ const created = { id: nextId++, body, user: { login: 'github-actions[bot]' } };
1054
+ comments.push(created);
1055
+ return created;
1056
+ },
1057
+ updateIssueComment: async () => { throw new Error('must not update'); },
1058
+ deleteIssueComment: async (id) => {
1059
+ const index = comments.findIndex((comment) => comment.id === id);
1060
+ if (index !== -1)
1061
+ comments.splice(index, 1);
1062
+ },
1063
+ };
1064
+ await Promise.all([
1065
+ syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope),
1066
+ syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope),
1067
+ ]);
1068
+ assert.equal(comments.filter((comment) => comment.body.startsWith(ownedSummaryMarker)).length, 1);
1069
+ });
1070
+ await checkAsync('different workflows replace a shared legacy summary without cross-scope PATCH', async () => {
1071
+ let releaseInitialReads = () => undefined;
1072
+ const initialReads = new Promise((resolve) => { releaseInitialReads = resolve; });
1073
+ let reads = 0;
1074
+ let nextId = 31;
1075
+ const legacy = { id: 30, body: '## PowerShot\n\nLegacy', user: { login: 'github-actions[bot]' } };
1076
+ const comments = [legacy];
1077
+ const api = {
1078
+ headSha: async () => summaryHead,
1079
+ listIssueComments: async () => {
1080
+ if (reads < 2) {
1081
+ const snapshot = [...comments];
1082
+ reads++;
1083
+ if (reads === 2)
1084
+ releaseInitialReads();
1085
+ await initialReads;
1086
+ return snapshot;
1087
+ }
1088
+ return [...comments];
1089
+ },
1090
+ createIssueComment: async (body) => {
1091
+ const created = { id: nextId++, body, user: { login: 'github-actions[bot]' } };
1092
+ comments.push(created);
1093
+ return created;
1094
+ },
1095
+ updateIssueComment: async () => { throw new Error('legacy ownership must not use PATCH'); },
1096
+ deleteIssueComment: async (id) => {
1097
+ const index = comments.findIndex((comment) => comment.id === id);
1098
+ if (index !== -1)
1099
+ comments.splice(index, 1);
1100
+ },
1101
+ };
1102
+ const auditScope = 'xcrft/powershot/.github/workflows/audit.yml:audit';
1103
+ await Promise.all([
1104
+ syncSummaryComment(api, 'review', summaryHead, summaryScope),
1105
+ syncSummaryComment(api, 'audit', summaryHead, auditScope),
1106
+ ]);
1107
+ assert.equal(comments.some((comment) => comment.id === legacy.id), false);
1108
+ assert.equal(comments.filter((comment) => comment.body.startsWith(summaryMarker(summaryScope))).length, 1);
1109
+ assert.equal(comments.filter((comment) => comment.body.startsWith(summaryMarker(auditScope))).length, 1);
1110
+ });
727
1111
  check('GitHub patches expose only added right-side lines for inline comments', () => {
728
1112
  const patch = [
729
1113
  '@@ -1,3 +1,4 @@',
@@ -842,10 +1226,20 @@ await checkAsync('the GitHub client paginates files, submits the review contract
842
1226
  { id: 10, path: 'a.ts', line: 1, body: 'reply', in_reply_to_id: 9, user: { login: 'human' } },
843
1227
  ]);
844
1228
  }
1229
+ if (method === 'GET' && url.includes('/issues/7/comments')) {
1230
+ return json([{ id: 21, body: 'summary', user: { login: 'github-actions[bot]' } }]);
1231
+ }
845
1232
  if (method === 'POST' && url.endsWith('/pulls/7/reviews'))
846
1233
  return json({ id: 1 });
1234
+ if (method === 'POST' && url.endsWith('/issues/7/comments')) {
1235
+ return json({ id: 22, body: 'new summary', user: { login: 'github-actions[bot]' } }, { status: 201 });
1236
+ }
1237
+ if (method === 'PATCH' && url.endsWith('/issues/comments/21'))
1238
+ return json({ id: 21 });
847
1239
  if (method === 'DELETE' && url.endsWith('/pulls/comments/9'))
848
1240
  return json({ message: 'gone' }, { status: 404 });
1241
+ if (method === 'DELETE' && url.endsWith('/issues/comments/21'))
1242
+ return new Response(undefined, { status: 204 });
849
1243
  return json({ message: 'unexpected request' }, { status: 500 });
850
1244
  };
851
1245
  globalThis.fetch = fakeFetch;
@@ -861,11 +1255,19 @@ await checkAsync('the GitHub client paginates files, submits the review contract
861
1255
  assert.equal(comments[1]?.inReplyToId, 9);
862
1256
  await api.createReview('a'.repeat(40), [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }]);
863
1257
  await api.deleteReviewComment(9);
1258
+ assert.deepEqual(await api.listIssueComments(), [
1259
+ { id: 21, body: 'summary', user: { login: 'github-actions[bot]' } },
1260
+ ]);
1261
+ await api.updateIssueComment(21, 'updated summary');
1262
+ assert.deepEqual(await api.createIssueComment('new summary'), {
1263
+ id: 22, body: 'new summary', user: { login: 'github-actions[bot]' },
1264
+ });
1265
+ await api.deleteIssueComment(21);
864
1266
  }
865
1267
  finally {
866
1268
  globalThis.fetch = originalFetch;
867
1269
  }
868
- const submitted = calls.find((call) => call.method === 'POST');
1270
+ const submitted = calls.find((call) => call.method === 'POST' && call.url.endsWith('/pulls/7/reviews'));
869
1271
  assert.ok(submitted?.body);
870
1272
  assert.deepEqual(JSON.parse(submitted.body), {
871
1273
  commit_id: 'a'.repeat(40),
@@ -874,6 +1276,9 @@ await checkAsync('the GitHub client paginates files, submits the review contract
874
1276
  comments: [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }],
875
1277
  });
876
1278
  assert.equal(calls.filter((call) => call.url.includes('/files')).length, 2);
1279
+ assert.equal(calls.some((call) => call.method === 'PATCH' && call.url.endsWith('/issues/comments/21') && call.body === '{"body":"updated summary"}'), true);
1280
+ assert.equal(calls.some((call) => call.method === 'POST' && call.url.endsWith('/issues/7/comments') && call.body === '{"body":"new summary"}'), true);
1281
+ assert.equal(calls.some((call) => call.method === 'DELETE' && call.url.endsWith('/issues/comments/21')), true);
877
1282
  });
878
1283
  await checkAsync('inline synchronization creates one review before removing stale comments', async () => {
879
1284
  const finding = {
@@ -976,6 +1381,11 @@ check('the public action persists judge answers and publishes only a verdict', (
976
1381
  assert.match(action, /inline-comments:\s*\n\s+description: [^\n]+\n\s+default: 'false'/);
977
1382
  assert.match(action, /Post inline comments[\s\S]+inputs\.inline-comments == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
978
1383
  assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/inline-comments\.js"/);
1384
+ assert.doesNotMatch(action, /--edit-last/);
1385
+ assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/summary-comment\.js"/);
1386
+ assert.match(action, /POWERSHOT_HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/);
1387
+ assert.match(action, /GITHUB_WORKFLOW_REF: \$\{\{ github\.workflow_ref \}\}/);
1388
+ assert.match(action, /GITHUB_JOB: \$\{\{ github\.job \}\}/);
979
1389
  assert.match(action, /--report manifest=powershot\.manifest\.json/);
980
1390
  assert.match(action, /coverage=\$COVERAGE/);
981
1391
  assert.match(action, /m\.coverage === "full" \|\| m\.coverage === "portable" \? m\.coverage : "unknown"/);
@@ -988,6 +1398,7 @@ check('published CI examples preserve one verdict and its exit status', () => {
988
1398
  assert.match(action, /upload-sarif: 'true'/);
989
1399
  assert.match(action, /inline-comments: 'true'/);
990
1400
  assert.match(action, /runs-on: ubuntu-24\.04/);
1401
+ assert.match(action, /concurrency:[\s\S]+github\.workflow[\s\S]+cancel-in-progress: true/);
991
1402
  assert.doesNotMatch(action, /npm ci|NPM_AUTH_TOKEN|NODE_AUTH_TOKEN/);
992
1403
  assert.match(action, /uses: xcrft\/powershot@v1/);
993
1404
  assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.2/);
@@ -1019,10 +1430,12 @@ check('the viewer labels a complete portable session', () => {
1019
1430
  const html = viewer([], {
1020
1431
  id: 'portable', target: 'workspace', started: '2026-01-01T10:00:00Z',
1021
1432
  state: 'complete', notLookedAt: [], coverage: 'portable',
1022
- unavailableCoverage: ['1 file(s) without enriched semantic coverage: app.ts (types)'],
1433
+ verifyOnly: true, minSeverity: 'medium', filesReviewed: 1, deterministicChecks: 2,
1434
+ scopeDetails: ['1 reviewed file lacked type information'],
1023
1435
  });
1024
- assert.match(html, /Portable coverage\./);
1025
- assert.match(html, /No findings in portable coverage\./);
1436
+ assert.match(html, /1 file reviewed · 2 deterministic checks · portable coverage/);
1437
+ assert.match(html, /No medium-or-higher deterministic findings\./);
1438
+ assert.match(html, /<summary>Coverage details<\/summary>/);
1026
1439
  });
1027
1440
  check('the viewer escapes content rather than rendering it', () => {
1028
1441
  const nasty = [{ ...sample[0], title: '<img src=x onerror=alert(1)>' }];
package/dist/session.js CHANGED
@@ -105,9 +105,13 @@ export class Session {
105
105
  verified: findings.filter((f) => f.class === 'verified').length,
106
106
  judged: findings.filter((f) => f.class === 'judged').length,
107
107
  state: verdict.state,
108
- notLookedAt: verdict.notLookedAt,
108
+ notLookedAt: [...verdict.notLookedAt],
109
109
  coverage: verdict.coverage,
110
- unavailableCoverage: verdict.unavailableCoverage,
110
+ verifyOnly: verdict.verifyOnly,
111
+ minSeverity: verdict.minSeverity,
112
+ filesReviewed: verdict.filesReviewed,
113
+ deterministicChecks: verdict.deterministicChecks,
114
+ scopeDetails: verdict.scopeDetails ? [...verdict.scopeDetails] : undefined,
111
115
  };
112
116
  this.save();
113
117
  }
@@ -66,6 +66,7 @@ engine. The engine does not depend on a workflow provider or terminal layout.
66
66
  | `src/judges/` | Prompt data, bounded model loop, tool adapter | Git target selection |
67
67
  | `src/lang/` | Language-pack data, isolated parser workers, optional language oracles | Cross-run policy |
68
68
  | `src/report/` | Pure output adapters | Re-running or reinterpreting a review |
69
+ | `src/github/` | GitHub REST transport and pull-request publication reconciliation | Review decisions or report rendering |
69
70
  | `src/bench.ts` | Historical and labelled evaluation | Production command dispatch |
70
71
  | `src/session.ts`, `src/cache.ts` | Reuse of completed judge work | Completion decisions |
71
72
 
package/docs/ci.md CHANGED
@@ -41,6 +41,10 @@ name: PowerShot
41
41
  on:
42
42
  pull_request:
43
43
 
44
+ concurrency:
45
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
46
+ cancel-in-progress: true
47
+
44
48
  permissions:
45
49
  contents: read
46
50
  pull-requests: write
@@ -77,6 +81,27 @@ and bounded batches, so all declared languages can coexist in one monorepo revie
77
81
  Set `upload-sarif: 'false'` and omit `security-events: write` when GitHub code scanning
78
82
  is unavailable or the workflow should not publish SARIF.
79
83
 
84
+ `comment: 'true'` maintains one summary through a hidden marker scoped to the caller's
85
+ workflow file and job. Reruns update only that exact `github-actions[bot]` comment, so
86
+ another workflow or job using the same bot identity is left alone. Each candidate also
87
+ records its pull-request head. A new head gets a new candidate, which means an old run
88
+ never patches or retires the current head's comment. The first scoped run replaces the
89
+ newest unmarked legacy `## PowerShot` summary from v1.1.2 or older without claiming the
90
+ ambiguous legacy comment through `PATCH`.
91
+
92
+ The comment leads with the verdict, effective severity threshold, review mode, and
93
+ aggregate file/check counts. Portable gaps and files outside parser coverage stay
94
+ visible under a collapsed coverage section without filling the timeline with paths.
95
+ The generated `powershot.manifest.json` keeps the per-file accounting for workflows
96
+ that want to persist it as an artifact.
97
+
98
+ PowerShot checks the target head throughout reconciliation and removes its own
99
+ just-created candidate if it observes a changed head. Simultaneous same-head runs
100
+ relist and converge on one scoped candidate when they complete. Keep the example's
101
+ `concurrency` block to reduce overlap and canceled stale work. GitHub's issue-comment
102
+ REST API has no atomic create-if-absent operation, and cancellation cannot stop a REST
103
+ request already in flight, so concurrency reduces but cannot eliminate that window.
104
+
80
105
  `inline-comments: 'true'` requires `pull-requests: write`. It publishes at most ten
81
106
  findings as one review. Only deterministic `verified` findings with `proven`
82
107
  confidence, severity `medium` or higher, and a GitHub-confirmed added line qualify.
@@ -3,6 +3,10 @@ name: PowerShot
3
3
  on:
4
4
  pull_request:
5
5
 
6
+ concurrency:
7
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
8
+ cancel-in-progress: true
9
+
6
10
  permissions:
7
11
  contents: read
8
12
  pull-requests: write
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xcraft/powershot",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
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>",