@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/README.md +37 -25
- package/dist/cli/reports.js +4 -3
- package/dist/cli/review-command.js +4 -1
- package/dist/cli/session-command.js +6 -0
- package/dist/config.js +5 -0
- package/dist/github/api.js +198 -0
- package/dist/github/inline-comments.js +3 -154
- package/dist/github/summary-comment.js +149 -0
- package/dist/ground.js +61 -8
- package/dist/lang/packs.js +97 -14
- package/dist/lang/parse-worker.js +15 -0
- package/dist/lang/python-deps.js +21 -8
- package/dist/manifest.js +32 -0
- package/dist/package-smoke.js +4 -0
- package/dist/plan.js +7 -0
- package/dist/report/markdown.js +31 -3
- package/dist/report/summary.js +103 -0
- package/dist/report/terminal.js +19 -1
- package/dist/report/viewer.js +22 -3
- package/dist/review.js +39 -18
- package/dist/selftest.js +665 -10
- package/dist/session.js +7 -1
- package/dist/verifiers/foreign-phantom-dep.js +8 -2
- package/docs/architecture.md +38 -11
- package/docs/ci.md +45 -12
- package/examples/github-actions/action.yml +4 -5
- package/examples/github-actions/cli.yml +1 -1
- package/examples/gitlab/.gitlab-ci.yml +1 -1
- package/package.json +1 -1
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,
|
|
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.
|
|
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, /
|
|
918
|
-
assert.
|
|
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\.
|
|
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 {
|