@0xcraft/powershot 1.1.1 → 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
@@ -31,13 +31,13 @@ import { ProviderError, redact } from './judges/llm.js';
31
31
  import { VERIFIERS } from './verifiers/index.js';
32
32
  import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
33
33
  import { tmpdir } from 'node:os';
34
- import { join, sep } from 'node:path';
34
+ import { dirname, join, sep } from 'node:path';
35
35
  import { runTool } from './judges/tools.js';
36
36
  import { codeQuality } from './report/codequality.js';
37
37
  import { viewer } from './report/viewer.js';
38
38
  import { absorbDelegated, delegateBrief } from './delegate.js';
39
39
  import { TARGETS, findTarget } from './agents.js';
40
- import { packFor } from './lang/packs.js';
40
+ import { PACKS, packFor, parseIsolated } from './lang/packs.js';
41
41
  import { isPhantom, pythonManifest, localModules } from './lang/python-deps.js';
42
42
  import { isPhantomGem, rubyManifest } from './lang/ruby-deps.js';
43
43
  import { pyrightAvailable } from './lang/pyright.js';
@@ -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 = []) {
@@ -433,6 +436,35 @@ check('files are routed to the right language pack', () => {
433
436
  assert.equal(packFor('src/a.ts'), undefined); // TypeScript keeps its own oracle
434
437
  assert.equal(packFor('README.md'), undefined);
435
438
  });
439
+ await checkAsync('isolated trees preserve Unicode offsets and grammar fields', async () => {
440
+ const pack = PACKS.find((candidate) => candidate.name === 'python');
441
+ const source = 'label = "é"\ndef answer(value: str) -> str:\n return value\n';
442
+ const [tree] = await parseIsolated(pack, [source]);
443
+ assert.ok(tree);
444
+ const signature = pack.signatures?.(tree.rootNode).get('answer');
445
+ assert.ok(signature);
446
+ assert.equal(signature.node.text, 'def answer(value: str) -> str:\n return value');
447
+ assert.deepEqual(signature.params, ['value: str']);
448
+ });
449
+ await checkAsync('language worker batching preserves every file past the 128-file boundary', async () => {
450
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-foreign-batches-')));
451
+ try {
452
+ const changes = [];
453
+ for (let index = 0; index < 129; index++) {
454
+ const path = 'python/module_' + index + '.py';
455
+ mkdirSync(dirname(join(dir, path)), { recursive: true });
456
+ writeFileSync(join(dir, path), 'value = ' + index + '\n');
457
+ changes.push({ path, added: new Set([1]), before: 'value = -1\n' });
458
+ }
459
+ const grounded = await buildGround(dir, changes);
460
+ assert.equal(grounded.foreign.length, changes.length);
461
+ assert.deepEqual(grounded.foreign.map((file) => file.path), changes.map((file) => file.path));
462
+ assert.ok(grounded.foreign.every((file) => file.beforeTree !== undefined));
463
+ }
464
+ finally {
465
+ rmSync(dir, { recursive: true, force: true });
466
+ }
467
+ });
436
468
  // Per-language pack checks live in langtest.ts, one process each: eleven wasm
437
469
  // grammars cannot share a process without exhausting it. `npm test` runs both.
438
470
  console.log('\neditor and provider surface');
@@ -462,7 +494,7 @@ check('compact defaults the column when a finding has no span', () => {
462
494
  });
463
495
  check('each provider reads its own key', () => {
464
496
  const base = { model: 'm', verifiers: ['*'], judges: ['*'], minSeverity: 'low',
465
- ignore: [], promptCache: true };
497
+ ignore: [], coverage: 'portable', promptCache: true };
466
498
  const saved = { a: process.env.ANTHROPIC_API_KEY, o: process.env.OPENAI_API_KEY,
467
499
  g: process.env.GEMINI_API_KEY, gg: process.env.GOOGLE_API_KEY };
468
500
  process.env.ANTHROPIC_API_KEY = 'a';
@@ -555,6 +587,64 @@ check('a review that did not complete never renders as clean', () => {
555
587
  });
556
588
  assert.match(partial, /partial, not a verdict/);
557
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\)/);
599
+ });
600
+ check('portable coverage is a verdict, but never masquerades as full semantic coverage', () => {
601
+ const unavailable = [
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',
604
+ ];
605
+ const out = terminal([], {
606
+ subtitle: 'workspace', verified: 0, judged: 0, state: 'complete', notLookedAt: [],
607
+ coverage: 'portable', verifyOnly: true, minSeverity: 'medium',
608
+ filesReviewed: 1, deterministicChecks: 1, scopeDetails: unavailable,
609
+ });
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/);
613
+ assert.doesNotMatch(out, /not a verdict/);
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({
625
+ state: 'complete', notLookedAt: [], coverage: 'portable',
626
+ engine: { verifyOnly: true, minSeverity: 'medium' },
627
+ files: [...selected, ...waived],
628
+ checks: {
629
+ ran: Array.from({ length: 19 }, (_, index) => 'check-' + index),
630
+ unavailable: [
631
+ { check: 'phantom-api', missing: 'types' },
632
+ { check: 'contract-drift', missing: 'references' },
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\./);
558
648
  });
559
649
  check('findings are still shown when a stage failed, with the warning kept', () => {
560
650
  const f = { id: 'F1', class: 'verified', check: 'phantom-dep', severity: 'high',
@@ -616,6 +706,23 @@ check('no manifest means nothing to be wrong about', () => {
616
706
  assert.equal(pythonManifest('/definitely/not/a/repo'), undefined);
617
707
  assert.deepEqual(localModules('/definitely/not/a/repo'), new Set());
618
708
  });
709
+ check('local Python modules are discovered from the changed package, not the whole monorepo', () => {
710
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-py-local-')));
711
+ try {
712
+ const app = join(dir, 'services', 'api', 'src', 'app.py');
713
+ mkdirSync(join(dir, 'services', 'api', 'src', 'localpkg'), { recursive: true });
714
+ mkdirSync(join(dir, 'unrelated', 'hiddenpkg'), { recursive: true });
715
+ writeFileSync(app, 'from localpkg import value\n');
716
+ writeFileSync(join(dir, 'services', 'api', 'src', 'localpkg', '__init__.py'), 'value = 1\n');
717
+ writeFileSync(join(dir, 'unrelated', 'hiddenpkg', '__init__.py'), 'value = 2\n');
718
+ const local = localModules(dir, dirname(app));
719
+ assert.equal(local.has('localpkg'), true);
720
+ assert.equal(local.has('hiddenpkg'), false);
721
+ }
722
+ finally {
723
+ rmSync(dir, { recursive: true, force: true });
724
+ }
725
+ });
619
726
  console.log('\nruby gems');
620
727
  const gems = { names: new Set(['rails', 'httparty', 'sidekiq']) };
621
728
  const rbLocal = new Set(['helpers', 'models']);
@@ -654,6 +761,353 @@ const sample = [
654
761
  { id: 'F2', class: 'judged', check: 'plausible-logic', severity: 'low', confidence: 'tentative',
655
762
  file: 'src/b.ts', line: 9, title: 'off by one' },
656
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
+ });
657
1111
  check('GitHub patches expose only added right-side lines for inline comments', () => {
658
1112
  const patch = [
659
1113
  '@@ -1,3 +1,4 @@',
@@ -772,10 +1226,20 @@ await checkAsync('the GitHub client paginates files, submits the review contract
772
1226
  { id: 10, path: 'a.ts', line: 1, body: 'reply', in_reply_to_id: 9, user: { login: 'human' } },
773
1227
  ]);
774
1228
  }
1229
+ if (method === 'GET' && url.includes('/issues/7/comments')) {
1230
+ return json([{ id: 21, body: 'summary', user: { login: 'github-actions[bot]' } }]);
1231
+ }
775
1232
  if (method === 'POST' && url.endsWith('/pulls/7/reviews'))
776
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 });
777
1239
  if (method === 'DELETE' && url.endsWith('/pulls/comments/9'))
778
1240
  return json({ message: 'gone' }, { status: 404 });
1241
+ if (method === 'DELETE' && url.endsWith('/issues/comments/21'))
1242
+ return new Response(undefined, { status: 204 });
779
1243
  return json({ message: 'unexpected request' }, { status: 500 });
780
1244
  };
781
1245
  globalThis.fetch = fakeFetch;
@@ -791,11 +1255,19 @@ await checkAsync('the GitHub client paginates files, submits the review contract
791
1255
  assert.equal(comments[1]?.inReplyToId, 9);
792
1256
  await api.createReview('a'.repeat(40), [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }]);
793
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);
794
1266
  }
795
1267
  finally {
796
1268
  globalThis.fetch = originalFetch;
797
1269
  }
798
- const submitted = calls.find((call) => call.method === 'POST');
1270
+ const submitted = calls.find((call) => call.method === 'POST' && call.url.endsWith('/pulls/7/reviews'));
799
1271
  assert.ok(submitted?.body);
800
1272
  assert.deepEqual(JSON.parse(submitted.body), {
801
1273
  commit_id: 'a'.repeat(40),
@@ -804,6 +1276,9 @@ await checkAsync('the GitHub client paginates files, submits the review contract
804
1276
  comments: [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }],
805
1277
  });
806
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);
807
1282
  });
808
1283
  await checkAsync('inline synchronization creates one review before removing stale comments', async () => {
809
1284
  const finding = {
@@ -892,7 +1367,7 @@ check('self-review publishes machine findings only for a complete verdict', () =
892
1367
  const workflow = readFileSync(join(process.cwd(), '.github', 'workflows', 'review.yml'), 'utf8');
893
1368
  assert.equal(workflow.match(/node "\$PSH" review/g)?.length, 1);
894
1369
  assert.match(workflow, /name: Check out the untrusted review target[\s\S]+allow-unsafe-pr-checkout: true/);
895
- assert.match(workflow, /name: Install the target type environment[\s\S]+working-directory: target[\s\S]+npm ci --ignore-scripts/);
1370
+ assert.doesNotMatch(workflow, /working-directory: target[\s\S]{0,120}npm ci/);
896
1371
  assert.match(workflow, /steps\.review\.outputs\.status == '0' \|\| steps\.review\.outputs\.status == '1'/);
897
1372
  assert.match(workflow, /sarif_file: powershot\.sarif\s+checkout_path: target\s+ref: refs\/pull\/\$\{\{ github\.event\.pull_request\.number \}\}\/head\s+sha: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/);
898
1373
  });
@@ -906,6 +1381,15 @@ check('the public action persists judge answers and publishes only a verdict', (
906
1381
  assert.match(action, /inline-comments:\s*\n\s+description: [^\n]+\n\s+default: 'false'/);
907
1382
  assert.match(action, /Post inline comments[\s\S]+inputs\.inline-comments == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
908
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 \}\}/);
1389
+ assert.match(action, /--report manifest=powershot\.manifest\.json/);
1390
+ assert.match(action, /coverage=\$COVERAGE/);
1391
+ assert.match(action, /m\.coverage === "full" \|\| m\.coverage === "portable" \? m\.coverage : "unknown"/);
1392
+ assert.match(action, /Approve a clean review[\s\S]+steps\.review\.outputs\.coverage == 'full'/);
909
1393
  });
910
1394
  check('published CI examples preserve one verdict and its exit status', () => {
911
1395
  const action = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'action.yml'), 'utf8');
@@ -914,12 +1398,14 @@ check('published CI examples preserve one verdict and its exit status', () => {
914
1398
  assert.match(action, /upload-sarif: 'true'/);
915
1399
  assert.match(action, /inline-comments: 'true'/);
916
1400
  assert.match(action, /runs-on: ubuntu-24\.04/);
917
- assert.match(action, /npm ci --ignore-scripts[\s\S]+uses: xcrft\/powershot@v1/);
918
- assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.1/);
1401
+ assert.match(action, /concurrency:[\s\S]+github\.workflow[\s\S]+cancel-in-progress: true/);
1402
+ assert.doesNotMatch(action, /npm ci|NPM_AUTH_TOKEN|NODE_AUTH_TOKEN/);
1403
+ assert.match(action, /uses: xcrft\/powershot@v1/);
1404
+ assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.2/);
919
1405
  assert.equal(github.match(/psh review/g)?.length, 1);
920
1406
  assert.match(github, /--report markdown=powershot\.md[\s\S]+--report sarif=powershot\.sarif/);
921
1407
  assert.match(github, /\|\| STATUS=\$\?[\s\S]+case "\$STATUS"/);
922
- assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.1/);
1408
+ assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.2/);
923
1409
  assert.equal(gitlab.match(/psh review/g)?.length, 1);
924
1410
  assert.match(gitlab, /--format codequality > gl-code-quality-report\.json \|\| STATUS=\$\?/);
925
1411
  assert.match(gitlab, /test "\$STATUS" -le 1 \|\| exit "\$STATUS"/);
@@ -940,6 +1426,17 @@ check('the viewer is one self-contained page', () => {
940
1426
  assert.equal(/<(script|link|img)[^>]+(src|href)="http/.test(html), false); // no network needed
941
1427
  assert.equal((html.match(/class="f /g) ?? []).length, 2);
942
1428
  });
1429
+ check('the viewer labels a complete portable session', () => {
1430
+ const html = viewer([], {
1431
+ id: 'portable', target: 'workspace', started: '2026-01-01T10:00:00Z',
1432
+ state: 'complete', notLookedAt: [], coverage: 'portable',
1433
+ verifyOnly: true, minSeverity: 'medium', filesReviewed: 1, deterministicChecks: 2,
1434
+ scopeDetails: ['1 reviewed file lacked type information'],
1435
+ });
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>/);
1439
+ });
943
1440
  check('the viewer escapes content rather than rendering it', () => {
944
1441
  const nasty = [{ ...sample[0], title: '<img src=x onerror=alert(1)>' }];
945
1442
  const html = viewer(nasty, {
@@ -967,7 +1464,7 @@ check('delegated output distinguishes an empty verdict from malformed data', ()
967
1464
  check('delegate --checks selects only the requested judging brief', () => {
968
1465
  const cfg = {
969
1466
  provider: 'anthropic', model: 'm', verifiers: ['*'], judges: ['*'],
970
- minSeverity: 'low', ignore: [], promptCache: true,
1467
+ minSeverity: 'low', ignore: [], coverage: 'portable', promptCache: true,
971
1468
  };
972
1469
  const brief = delegateBrief(ground([{ path: 'a.ts', after: 'export const a = 1\n' }]), cfg, {
973
1470
  checks: ['intent'], intent: 'add a',
@@ -1736,6 +2233,12 @@ check('judges accept both the plain list and the { enable } form', () => {
1736
2233
  assert.equal(validateConfig({ judges: { enable: ['securty'] } }, KNOWN).length, 1);
1737
2234
  assert.equal(validateConfig({ judges: 'security' }, KNOWN).length, 1); // not a list at all
1738
2235
  });
2236
+ check('coverage is portable by default and strict only when requested', () => {
2237
+ assert.equal(loadConfig(process.cwd()).coverage, 'portable');
2238
+ assert.deepEqual(validateConfig({ coverage: 'portable' }, KNOWN), []);
2239
+ assert.deepEqual(validateConfig({ coverage: 'strict' }, KNOWN), []);
2240
+ assert.match(validateConfig({ coverage: 'complete' }, KNOWN)[0], /not one of: portable, strict/);
2241
+ });
1739
2242
  console.log('\nsession safety');
1740
2243
  check('a session will not be resumed by a different model than answered it', () => {
1741
2244
  const dir = mkdtempSync(join(tmpdir(), 'psh-ses-'));
@@ -1997,6 +2500,32 @@ check('the manifest accounts for everything it selected', () => {
1997
2500
  // and a failure anywhere means the run is not a verdict
1998
2501
  assert.equal(m2.build({ ...base, failures: ['judge died'] }).state, 'failed');
1999
2502
  });
2503
+ check('portable oracle gaps stay visible without turning the run into a partial verdict', () => {
2504
+ const manifest = new RunManifest('portable');
2505
+ manifest.ran('swallowed-error');
2506
+ const record = manifest.build({
2507
+ operation: 'review', target: { requested: {} },
2508
+ policy: { source: 'base', hash: 'h' },
2509
+ engine: { version: '0', tools: false, verifyOnly: true },
2510
+ files: [{
2511
+ path: 'web/app.ts', disposition: 'selected', bytes: 1, addedLines: 1,
2512
+ language: 'typescript', checks: ['swallowed-error'], unavailable: ['types', 'references'],
2513
+ }],
2514
+ skippedChecks: [],
2515
+ unavailableChecks: [
2516
+ { check: 'phantom-api', missing: 'types' },
2517
+ { check: 'contract-drift', missing: 'references' },
2518
+ ],
2519
+ findings: { total: 0, verified: 0, judged: 0, dismissed: 0, droppedPosition: 0 },
2520
+ usage: { requests: 0, inputTokens: 0, outputTokens: 0, toolCalls: 0, elapsedMs: 0, units: 0 },
2521
+ failures: [],
2522
+ });
2523
+ assert.equal(record.state, 'complete');
2524
+ assert.deepEqual(record.notLookedAt, []);
2525
+ assert.deepEqual(record.files[0].unavailable, ['types', 'references']);
2526
+ assert.equal(record.checks.unavailable?.length, 2);
2527
+ assert.deepEqual(coverageProblems(record), []);
2528
+ });
2000
2529
  check('a manifest that hides an unreached unit is caught as our bug', () => {
2001
2530
  const broken = {
2002
2531
  schema: SCHEMA, id: 'x', operation: 'review', started: '', ended: '',
@@ -2145,6 +2674,7 @@ await checkAsync('per-file limits follow the checks the caller actually selected
2145
2674
  operation: 'scan', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
2146
2675
  engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan?.items() ?? [],
2147
2676
  skippedChecks: result.skippedChecks ?? [],
2677
+ unavailableChecks: result.unavailableChecks ?? [],
2148
2678
  findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
2149
2679
  dismissed: result.stats.dismissed, droppedPosition: 0 },
2150
2680
  usage: result.usage, failures: result.failures,
@@ -2162,6 +2692,131 @@ await checkAsync('per-file limits follow the checks the caller actually selected
2162
2692
  rmSync(dir, { recursive: true, force: true });
2163
2693
  }
2164
2694
  });
2695
+ await checkAsync('portable coverage reviews every declared language together without repository installs', async () => {
2696
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-portable-mixed-')));
2697
+ try {
2698
+ const sources = {
2699
+ python: { path: 'services/api/app.py', source: 'def answer() -> int:\n try:\n risky()\n except Exception:\n pass\n return 42\n' },
2700
+ go: { path: 'services/agent/main.go', source: 'package agent\nfunc answer() int {\n if err := risky(); err != nil {\n }\n return 42\n}\n' },
2701
+ java: { path: 'services/jvm/App.java', source: 'class App { int answer() { try { risky(); } catch (Exception e) { } return 42; } }\n' },
2702
+ rust: { path: 'crates/worker/src/lib.rs', source: 'pub fn answer() -> i32 { match risky() { Err(_) => {}, Ok(v) => v }; 42 }\n' },
2703
+ cpp: { path: 'native/app.cpp', source: 'int answer() { try { risky(); } catch (...) { } return 42; }\n' },
2704
+ c: { path: 'native/app.c', source: 'int answer(void) { return 42; }\n' },
2705
+ 'c#': { path: 'dotnet/App.cs', source: 'class App { int Answer() { try { Risky(); } catch (Exception e) { } return 42; } }\n' },
2706
+ php: { path: 'php/app.php', source: '<?php function answer() { try { risky(); } catch (Exception $e) { } return 42; }\n' },
2707
+ kotlin: { path: 'android/App.kt', source: 'fun answer(): Int { try { risky() } catch (e: Exception) {} ; return 42 }\n' },
2708
+ ruby: { path: 'ruby/app.rb', source: 'def answer\n begin\n risky\n rescue => e\n end\n 42\nend\n' },
2709
+ solidity: { path: 'contracts/App.sol', source: 'contract App { function answer() public returns (uint) { try this.risky() { } catch { } return 42; } function risky() external {} }\n' },
2710
+ };
2711
+ assert.deepEqual(Object.keys(sources).sort(), PACKS.map((pack) => pack.name).sort());
2712
+ const native = [
2713
+ { path: 'web/app.ts', source: 'export const answer = 42\n' },
2714
+ { path: 'web/legacy.js', source: 'export const legacyAnswer = 42\n' },
2715
+ ];
2716
+ const all = [...native, ...Object.values(sources)];
2717
+ for (const file of all) {
2718
+ mkdirSync(dirname(join(dir, file.path)), { recursive: true });
2719
+ writeFileSync(join(dir, file.path), file.source);
2720
+ }
2721
+ const changes = all.map((file) => ({
2722
+ path: file.path,
2723
+ added: new Set(file.source.split('\n').slice(0, -1).map((_, line) => line + 1)),
2724
+ before: file.source.replace('42', '41'),
2725
+ }));
2726
+ const manifest = new RunManifest('portable-mixed');
2727
+ const result = await review({
2728
+ root: dir, range: {}, changes, config: loadConfig(dir), verifyOnly: true, manifest,
2729
+ });
2730
+ const record = manifest.build({
2731
+ operation: 'review', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
2732
+ engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan.items(),
2733
+ skippedChecks: result.skippedChecks ?? [], unavailableChecks: result.unavailableChecks ?? [],
2734
+ findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
2735
+ dismissed: result.stats.dismissed, droppedPosition: result.droppedPosition ?? 0 },
2736
+ usage: result.usage, failures: result.failures,
2737
+ });
2738
+ assert.equal(record.state, 'complete');
2739
+ assert.deepEqual(record.notLookedAt, []);
2740
+ assert.equal(record.files.length, all.length);
2741
+ assert.ok(record.files.every((file) => file.disposition === 'selected'), 'a declared language cannot be waived');
2742
+ assert.ok(record.files.every((file) => file.checks.length > 0), 'every declared language needs baseline checks');
2743
+ const swallowed = new Set(result.findings.filter((finding) => finding.check === 'swallowed-error').map((finding) => finding.file));
2744
+ for (const [language, file] of Object.entries(sources)) {
2745
+ if (language !== 'c')
2746
+ assert.equal(swallowed.has(file.path), true, language + ' isolated AST must drive its oracle');
2747
+ }
2748
+ assert.deepEqual(record.files.find((file) => file.path === 'web/app.ts')?.unavailable, ['types', 'references']);
2749
+ assert.ok(record.checks.unavailable?.some((check) => check.check === 'phantom-api' && check.missing === 'types'));
2750
+ assert.deepEqual(coverageProblems(record), []);
2751
+ }
2752
+ finally {
2753
+ rmSync(dir, { recursive: true, force: true });
2754
+ }
2755
+ });
2756
+ await checkAsync('strict coverage keeps missing semantic oracles verdict-blocking', async () => {
2757
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-strict-')));
2758
+ try {
2759
+ writeFileSync(join(dir, 'app.ts'), 'export const answer = 42\n');
2760
+ const manifest = new RunManifest('strict');
2761
+ const result = await review({
2762
+ root: dir,
2763
+ range: {},
2764
+ changes: [{ path: 'app.ts', added: new Set([1]), before: 'export const answer = 41\n' }],
2765
+ config: { ...loadConfig(dir), coverage: 'strict' },
2766
+ verifyOnly: true,
2767
+ manifest,
2768
+ });
2769
+ const record = manifest.build({
2770
+ operation: 'review', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
2771
+ engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan.items(),
2772
+ skippedChecks: result.skippedChecks ?? [], unavailableChecks: result.unavailableChecks ?? [],
2773
+ findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
2774
+ dismissed: result.stats.dismissed, droppedPosition: result.droppedPosition ?? 0 },
2775
+ usage: result.usage, failures: result.failures,
2776
+ });
2777
+ assert.equal(record.state, 'partial');
2778
+ assert.deepEqual(record.files[0].missing, ['types', 'references']);
2779
+ assert.deepEqual(record.files[0].unavailable, undefined);
2780
+ assert.ok(record.checks.skipped.some((check) => check.check === 'phantom-api' && check.missing === 'types'));
2781
+ }
2782
+ finally {
2783
+ rmSync(dir, { recursive: true, force: true });
2784
+ }
2785
+ });
2786
+ await checkAsync('portable coverage still blocks when an existing foreign base cannot be parsed', async () => {
2787
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-foreign-base-limit-')));
2788
+ try {
2789
+ writeFileSync(join(dir, 'app.py'), 'def answer():\n return 42\n');
2790
+ const manifest = new RunManifest('foreign-base-limit');
2791
+ const result = await review({
2792
+ root: dir,
2793
+ range: {},
2794
+ changes: [{
2795
+ path: 'app.py',
2796
+ added: new Set([1, 2]),
2797
+ before: 'value = 1\n'.repeat(60_000),
2798
+ }],
2799
+ config: loadConfig(dir),
2800
+ verifyOnly: true,
2801
+ manifest,
2802
+ });
2803
+ const record = manifest.build({
2804
+ operation: 'review', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
2805
+ engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan.items(),
2806
+ skippedChecks: result.skippedChecks ?? [], unavailableChecks: result.unavailableChecks ?? [],
2807
+ findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
2808
+ dismissed: result.stats.dismissed, droppedPosition: result.droppedPosition ?? 0 },
2809
+ usage: result.usage, failures: result.failures,
2810
+ });
2811
+ assert.equal(record.state, 'partial');
2812
+ assert.ok(record.files[0].missing?.includes('base'));
2813
+ assert.ok(record.checks.skipped.some((check) => check.missing.includes('base')));
2814
+ assert.deepEqual(coverageProblems(record), []);
2815
+ }
2816
+ finally {
2817
+ rmSync(dir, { recursive: true, force: true });
2818
+ }
2819
+ });
2165
2820
  await checkAsync('foreign checks advertise only files their language pack can inspect', async () => {
2166
2821
  const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-pack-coverage-')));
2167
2822
  try {