@0xcraft/powershot 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/github/inline-comments.js +392 -0
- package/dist/ground.js +236 -87
- package/dist/judges/tools.js +7 -7
- package/dist/package-smoke.js +2 -0
- package/dist/review.js +4 -2
- package/dist/selftest.js +366 -4
- package/dist/verifiers/phantom-config.js +7 -6
- package/docs/architecture.md +15 -1
- package/docs/ci.md +23 -3
- package/examples/github-actions/action.yml +7 -1
- 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
|
@@ -50,9 +50,10 @@ import { sarif } from './report/sarif.js';
|
|
|
50
50
|
import { markdown } from './report/markdown.js';
|
|
51
51
|
import { wrap } from './report/terminal.js';
|
|
52
52
|
import { highlight, isJsx } from './report/highlight.js';
|
|
53
|
-
import { normalizeName, readEnvManifest, relPath } from './ground.js';
|
|
53
|
+
import { buildGround, normalizeName, readEnvManifest, relPath } from './ground.js';
|
|
54
54
|
import { incompleteReasons } from './bench.js';
|
|
55
55
|
import { PACKAGE_NAME, PACKAGE_VERSION } from './package-meta.js';
|
|
56
|
+
import { addedLinesFromPatch, createReviewPayload, GitHubPullRequestApi, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
|
|
56
57
|
const root = '/repo';
|
|
57
58
|
/** Build a Ground by hand so verifiers are testable without git or a real repo. */
|
|
58
59
|
function ground(files, deps = []) {
|
|
@@ -89,7 +90,7 @@ function ground(files, deps = []) {
|
|
|
89
90
|
symbolIndex.set(key, list);
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
|
-
return { root, project, beforeProject, changed, files: entries, symbolIndex, deps: new Set(deps), depsFor: () => new Set(deps), typed: false, internalPrefixes: [], foreign: [] };
|
|
93
|
+
return { root, sourceFiles: project.getSourceFiles(), configFiles: [], beforeProject, changed, files: entries, symbolIndex, deps: new Set(deps), depsFor: () => new Set(deps), typed: false, internalPrefixes: [], foreign: [] };
|
|
93
94
|
}
|
|
94
95
|
let failures = 0;
|
|
95
96
|
function check(name, fn) {
|
|
@@ -653,6 +654,210 @@ const sample = [
|
|
|
653
654
|
{ id: 'F2', class: 'judged', check: 'plausible-logic', severity: 'low', confidence: 'tentative',
|
|
654
655
|
file: 'src/b.ts', line: 9, title: 'off by one' },
|
|
655
656
|
];
|
|
657
|
+
check('GitHub patches expose only added right-side lines for inline comments', () => {
|
|
658
|
+
const patch = [
|
|
659
|
+
'@@ -1,3 +1,4 @@',
|
|
660
|
+
' context',
|
|
661
|
+
'-old',
|
|
662
|
+
'+new',
|
|
663
|
+
'+extra',
|
|
664
|
+
' tail',
|
|
665
|
+
'@@ -20,0 +22,2 @@',
|
|
666
|
+
'+later',
|
|
667
|
+
'\',
|
|
668
|
+
'+latest',
|
|
669
|
+
].join('\n');
|
|
670
|
+
assert.deepEqual([...addedLinesFromPatch(patch)], [2, 3, 22, 23]);
|
|
671
|
+
});
|
|
672
|
+
check('inline review selects only proven medium-or-higher verified findings on added lines', () => {
|
|
673
|
+
const finding = (overrides) => ({
|
|
674
|
+
id: 'F', class: 'verified', check: 'phantom-dep', severity: 'high', confidence: 'proven',
|
|
675
|
+
file: 'src/a.ts', line: 3, title: 'finding', ...overrides,
|
|
676
|
+
});
|
|
677
|
+
const findings = [
|
|
678
|
+
finding({ id: 'critical', severity: 'critical', line: 4, title: 'critical finding' }),
|
|
679
|
+
finding({ id: 'medium', severity: 'medium', line: 3, title: 'medium finding' }),
|
|
680
|
+
finding({ id: 'context', line: 2 }),
|
|
681
|
+
finding({ id: 'low', severity: 'low' }),
|
|
682
|
+
finding({ id: 'judged', class: 'judged' }),
|
|
683
|
+
finding({ id: 'firm', confidence: 'firm' }),
|
|
684
|
+
finding({ id: 'other-file', file: 'src/missing.ts' }),
|
|
685
|
+
];
|
|
686
|
+
const files = [{ filename: 'src/a.ts', patch: '@@ -2,1 +2,3 @@\n context\n+first\n+second' }];
|
|
687
|
+
assert.deepEqual(selectInlineComments(findings, files).map((comment) => comment.line), [4, 3]);
|
|
688
|
+
assert.deepEqual(selectInlineComments(findings, files, 1).map((comment) => comment.line), [4]);
|
|
689
|
+
assert.deepEqual(selectInlineComments(findings, files, 0), []);
|
|
690
|
+
const many = Array.from({ length: 12 }, (_, index) => finding({ line: index + 3, title: `finding ${index}` }));
|
|
691
|
+
const manyPatch = '@@ -2,0 +3,12 @@\n' + many.map((_, index) => `+line ${index}`).join('\n');
|
|
692
|
+
assert.equal(selectInlineComments(many, [{ filename: 'src/a.ts', patch: manyPatch }], 100).length, 10);
|
|
693
|
+
});
|
|
694
|
+
check('inline comment markdown renders finding prose literally and carries a stable marker', () => {
|
|
695
|
+
const finding = {
|
|
696
|
+
id: 'F1', class: 'verified', check: 'check`id', severity: 'high', confidence: 'proven',
|
|
697
|
+
file: 'src/a.ts', line: 3, title: '@team <img src=x> [click](https://example.invalid)',
|
|
698
|
+
evidence: { oracle: 'manifest', detail: 'line one\n> forged quote' },
|
|
699
|
+
};
|
|
700
|
+
const [comment] = selectInlineComments([finding], [{ filename: finding.file, patch: '@@ -2,0 +3,1 @@\n+line' }]);
|
|
701
|
+
assert.ok(comment);
|
|
702
|
+
assert.equal(comment.body.includes('@team'), false);
|
|
703
|
+
assert.equal(comment.body.includes('<img'), false);
|
|
704
|
+
assert.equal(comment.body.includes('[click]('), false);
|
|
705
|
+
assert.equal(comment.body.endsWith(inlineMarker(finding)), true);
|
|
706
|
+
assert.equal(comment.body.match(/<!-- powershot:inline:/g)?.length, 1);
|
|
707
|
+
});
|
|
708
|
+
check('inline publishing rejects a malformed machine report instead of silently dropping it', () => {
|
|
709
|
+
assert.deepEqual(parseReviewFindings(JSON.stringify({ findings: [sample[0]] })), [sample[0]]);
|
|
710
|
+
assert.throws(() => parseReviewFindings(JSON.stringify({ findings: [{ ...sample[0], line: 0 }] })), /invalid contract/);
|
|
711
|
+
assert.throws(() => parseReviewFindings('{}'), /findings array/);
|
|
712
|
+
});
|
|
713
|
+
check('inline reruns keep exact bot comments, batch only missing ones, and retire stale bot copies', () => {
|
|
714
|
+
const findings = [
|
|
715
|
+
{ id: 'F1', class: 'verified', check: 'a', severity: 'critical', confidence: 'proven', file: 'a.ts', line: 1, title: 'one' },
|
|
716
|
+
{ id: 'F2', class: 'verified', check: 'b', severity: 'high', confidence: 'proven', file: 'b.ts', line: 2, title: 'two' },
|
|
717
|
+
];
|
|
718
|
+
const desired = selectInlineComments(findings, [
|
|
719
|
+
{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' },
|
|
720
|
+
{ filename: 'b.ts', patch: '@@ -1,0 +2,1 @@\n+two' },
|
|
721
|
+
]);
|
|
722
|
+
const existing = [
|
|
723
|
+
{ id: 1, path: desired[0].path, line: desired[0].line, body: desired[0].body, user: { login: 'github-actions[bot]' } },
|
|
724
|
+
{ id: 2, path: desired[0].path, line: desired[0].line, body: desired[0].body, user: { login: 'github-actions[bot]' } },
|
|
725
|
+
{ id: 3, path: desired[0].path, line: desired[0].line, body: 'old\n' + inlineMarker(findings[0]), user: { login: 'github-actions[bot]' } },
|
|
726
|
+
{ id: 4, path: desired[1].path, line: desired[1].line, body: desired[1].body, user: { login: 'human' } },
|
|
727
|
+
{ id: 5, path: desired[0].path, line: desired[0].line, body: 'discussion', user: { login: 'human' }, inReplyToId: 2 },
|
|
728
|
+
{ id: 6, path: desired[0].path, line: desired[0].line, body: 'discussed old\n' + inlineMarker(findings[0]), user: { login: 'github-actions[bot]' } },
|
|
729
|
+
{ id: 7, path: desired[0].path, line: desired[0].line, body: 'still investigating', user: { login: 'human' }, inReplyToId: 6 },
|
|
730
|
+
];
|
|
731
|
+
const plan = reconcileInlineComments(desired, existing);
|
|
732
|
+
assert.equal(plan.kept, 1);
|
|
733
|
+
assert.deepEqual(plan.create, [desired[1]]);
|
|
734
|
+
assert.deepEqual(plan.staleIds, [1, 3]);
|
|
735
|
+
const settled = reconcileInlineComments(desired, desired.map((comment, index) => ({
|
|
736
|
+
id: index + 10, path: comment.path, line: comment.line, body: comment.body,
|
|
737
|
+
user: { login: 'github-actions[bot]' },
|
|
738
|
+
})));
|
|
739
|
+
assert.deepEqual(settled, { create: [], staleIds: [], kept: 2 });
|
|
740
|
+
});
|
|
741
|
+
check('the batched GitHub review carries the required comment body and current commit', () => {
|
|
742
|
+
const comment = { path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' };
|
|
743
|
+
const payload = createReviewPayload('a'.repeat(40), [comment]);
|
|
744
|
+
assert.equal(payload.commit_id, 'a'.repeat(40));
|
|
745
|
+
assert.equal(payload.event, 'COMMENT');
|
|
746
|
+
assert.match(payload.body, /1 proven verified finding/);
|
|
747
|
+
assert.deepEqual(payload.comments, [comment]);
|
|
748
|
+
});
|
|
749
|
+
await checkAsync('the GitHub client paginates files, submits the review contract, and treats delete 404 as settled', async () => {
|
|
750
|
+
const originalFetch = globalThis.fetch;
|
|
751
|
+
const calls = [];
|
|
752
|
+
const json = (value, init = {}) => new Response(JSON.stringify(value), { status: 200, ...init });
|
|
753
|
+
const fakeFetch = async (input, init) => {
|
|
754
|
+
const url = String(input);
|
|
755
|
+
const method = init?.method ?? 'GET';
|
|
756
|
+
const body = typeof init?.body === 'string' ? init.body : undefined;
|
|
757
|
+
calls.push({ url, method, body });
|
|
758
|
+
assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer token');
|
|
759
|
+
if (url.endsWith('/pulls/7'))
|
|
760
|
+
return json({ head: { sha: 'a'.repeat(40) } });
|
|
761
|
+
if (url.includes('/pulls/7/files') && !url.includes('page=2')) {
|
|
762
|
+
return json([{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' }], {
|
|
763
|
+
headers: { link: '<https://api.github.test/repos/acme/repo/pulls/7/files?per_page=100&page=2>; rel="next"' },
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
if (url.includes('/pulls/7/files') && url.includes('page=2')) {
|
|
767
|
+
return json([{ filename: 'b.bin' }]);
|
|
768
|
+
}
|
|
769
|
+
if (url.includes('/pulls/7/comments')) {
|
|
770
|
+
return json([
|
|
771
|
+
{ id: 9, path: 'a.ts', line: 1, body: 'old', user: { login: 'github-actions[bot]' } },
|
|
772
|
+
{ id: 10, path: 'a.ts', line: 1, body: 'reply', in_reply_to_id: 9, user: { login: 'human' } },
|
|
773
|
+
]);
|
|
774
|
+
}
|
|
775
|
+
if (method === 'POST' && url.endsWith('/pulls/7/reviews'))
|
|
776
|
+
return json({ id: 1 });
|
|
777
|
+
if (method === 'DELETE' && url.endsWith('/pulls/comments/9'))
|
|
778
|
+
return json({ message: 'gone' }, { status: 404 });
|
|
779
|
+
return json({ message: 'unexpected request' }, { status: 500 });
|
|
780
|
+
};
|
|
781
|
+
globalThis.fetch = fakeFetch;
|
|
782
|
+
try {
|
|
783
|
+
const api = new GitHubPullRequestApi('https://api.github.test', 'token', 'acme', 'repo', 7);
|
|
784
|
+
assert.equal(await api.headSha(), 'a'.repeat(40));
|
|
785
|
+
assert.deepEqual(await api.listFiles(), [
|
|
786
|
+
{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' },
|
|
787
|
+
{ filename: 'b.bin', patch: undefined },
|
|
788
|
+
]);
|
|
789
|
+
const comments = await api.listReviewComments();
|
|
790
|
+
assert.equal(comments[0]?.id, 9);
|
|
791
|
+
assert.equal(comments[1]?.inReplyToId, 9);
|
|
792
|
+
await api.createReview('a'.repeat(40), [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }]);
|
|
793
|
+
await api.deleteReviewComment(9);
|
|
794
|
+
}
|
|
795
|
+
finally {
|
|
796
|
+
globalThis.fetch = originalFetch;
|
|
797
|
+
}
|
|
798
|
+
const submitted = calls.find((call) => call.method === 'POST');
|
|
799
|
+
assert.ok(submitted?.body);
|
|
800
|
+
assert.deepEqual(JSON.parse(submitted.body), {
|
|
801
|
+
commit_id: 'a'.repeat(40),
|
|
802
|
+
body: 'PowerShot posted 1 proven verified finding(s) on changed lines.',
|
|
803
|
+
event: 'COMMENT',
|
|
804
|
+
comments: [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }],
|
|
805
|
+
});
|
|
806
|
+
assert.equal(calls.filter((call) => call.url.includes('/files')).length, 2);
|
|
807
|
+
});
|
|
808
|
+
await checkAsync('inline synchronization creates one review before removing stale comments', async () => {
|
|
809
|
+
const finding = {
|
|
810
|
+
id: 'F1', class: 'verified', check: 'a', severity: 'high', confidence: 'proven',
|
|
811
|
+
file: 'a.ts', line: 1, title: 'one',
|
|
812
|
+
};
|
|
813
|
+
const events = [];
|
|
814
|
+
const api = {
|
|
815
|
+
headSha: async () => 'a'.repeat(40),
|
|
816
|
+
listFiles: async () => [{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' }],
|
|
817
|
+
listReviewComments: async () => [{
|
|
818
|
+
id: 7, path: 'old.ts', line: 1,
|
|
819
|
+
body: 'old\n<!-- powershot:inline:v1:0123456789abcdef01234567 -->',
|
|
820
|
+
user: { login: 'github-actions[bot]' },
|
|
821
|
+
}],
|
|
822
|
+
createReview: async (commitId, comments) => {
|
|
823
|
+
events.push(`create:${commitId}:${comments.length}`);
|
|
824
|
+
},
|
|
825
|
+
deleteReviewComment: async (id) => {
|
|
826
|
+
events.push(`delete:${id}`);
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
const result = await syncInlineComments(api, [finding], 'a'.repeat(40));
|
|
830
|
+
assert.deepEqual(events, [`create:${'a'.repeat(40)}:1`, 'delete:7']);
|
|
831
|
+
assert.deepEqual(result, { outdated: false, desired: 1, created: 1, kept: 0, retired: 1 });
|
|
832
|
+
});
|
|
833
|
+
await checkAsync('inline synchronization makes no writes for an outdated pull request head', async () => {
|
|
834
|
+
let reads = 0;
|
|
835
|
+
const api = {
|
|
836
|
+
headSha: async () => 'b'.repeat(40),
|
|
837
|
+
listFiles: async () => { reads++; return []; },
|
|
838
|
+
listReviewComments: async () => { reads++; return []; },
|
|
839
|
+
createReview: async () => { throw new Error('must not create'); },
|
|
840
|
+
deleteReviewComment: async () => { throw new Error('must not delete'); },
|
|
841
|
+
};
|
|
842
|
+
const result = await syncInlineComments(api, [], 'a'.repeat(40));
|
|
843
|
+
assert.equal(reads, 0);
|
|
844
|
+
assert.deepEqual(result, { outdated: true, desired: 0, created: 0, kept: 0, retired: 0 });
|
|
845
|
+
});
|
|
846
|
+
await checkAsync('inline synchronization makes no writes when the pull request head changes during reads', async () => {
|
|
847
|
+
let headReads = 0;
|
|
848
|
+
let writes = 0;
|
|
849
|
+
const api = {
|
|
850
|
+
headSha: async () => ++headReads === 1 ? 'a'.repeat(40) : 'b'.repeat(40),
|
|
851
|
+
listFiles: async () => [{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' }],
|
|
852
|
+
listReviewComments: async () => [],
|
|
853
|
+
createReview: async () => { writes++; },
|
|
854
|
+
deleteReviewComment: async () => { writes++; },
|
|
855
|
+
};
|
|
856
|
+
const result = await syncInlineComments(api, [sample[0]], 'a'.repeat(40));
|
|
857
|
+
assert.equal(headReads, 2);
|
|
858
|
+
assert.equal(writes, 0);
|
|
859
|
+
assert.deepEqual(result, { outdated: true, desired: 0, created: 0, kept: 0, retired: 0 });
|
|
860
|
+
});
|
|
656
861
|
check('nested modules use the native package import map', () => {
|
|
657
862
|
const manifest = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'));
|
|
658
863
|
assert.equal(manifest.imports?.['#app/*.js'], './dist/*.js');
|
|
@@ -687,7 +892,9 @@ check('self-review publishes machine findings only for a complete verdict', () =
|
|
|
687
892
|
const workflow = readFileSync(join(process.cwd(), '.github', 'workflows', 'review.yml'), 'utf8');
|
|
688
893
|
assert.equal(workflow.match(/node "\$PSH" review/g)?.length, 1);
|
|
689
894
|
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/);
|
|
690
896
|
assert.match(workflow, /steps\.review\.outputs\.status == '0' \|\| steps\.review\.outputs\.status == '1'/);
|
|
897
|
+
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 \}\}/);
|
|
691
898
|
});
|
|
692
899
|
check('the public action persists judge answers and publishes only a verdict', () => {
|
|
693
900
|
const action = readFileSync(join(process.cwd(), 'action.yml'), 'utf8');
|
|
@@ -696,17 +903,23 @@ check('the public action persists judge answers and publishes only a verdict', (
|
|
|
696
903
|
assert.match(action, /restore-keys:/);
|
|
697
904
|
assert.match(action, /upload-sarif:\s*\n\s+description: [^\n]+\n\s+default: 'true'/);
|
|
698
905
|
assert.match(action, /Upload SARIF[\s\S]+inputs\.upload-sarif == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
|
|
906
|
+
assert.match(action, /inline-comments:\s*\n\s+description: [^\n]+\n\s+default: 'false'/);
|
|
907
|
+
assert.match(action, /Post inline comments[\s\S]+inputs\.inline-comments == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
|
|
908
|
+
assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/inline-comments\.js"/);
|
|
699
909
|
});
|
|
700
910
|
check('published CI examples preserve one verdict and its exit status', () => {
|
|
701
911
|
const action = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'action.yml'), 'utf8');
|
|
702
912
|
const github = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'cli.yml'), 'utf8');
|
|
703
913
|
const gitlab = readFileSync(join(process.cwd(), 'examples', 'gitlab', '.gitlab-ci.yml'), 'utf8');
|
|
704
914
|
assert.match(action, /upload-sarif: 'true'/);
|
|
705
|
-
assert.match(
|
|
915
|
+
assert.match(action, /inline-comments: 'true'/);
|
|
916
|
+
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/);
|
|
706
919
|
assert.equal(github.match(/psh review/g)?.length, 1);
|
|
707
920
|
assert.match(github, /--report markdown=powershot\.md[\s\S]+--report sarif=powershot\.sarif/);
|
|
708
921
|
assert.match(github, /\|\| STATUS=\$\?[\s\S]+case "\$STATUS"/);
|
|
709
|
-
assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.
|
|
922
|
+
assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.1/);
|
|
710
923
|
assert.equal(gitlab.match(/psh review/g)?.length, 1);
|
|
711
924
|
assert.match(gitlab, /--format codequality > gl-code-quality-report\.json \|\| STATUS=\$\?/);
|
|
712
925
|
assert.match(gitlab, /test "\$STATUS" -le 1 \|\| exit "\$STATUS"/);
|
|
@@ -1168,6 +1381,155 @@ check('refuses to run without a tsconfig rather than guessing', () => {
|
|
|
1168
1381
|
const g = ground([{ path: 'a.ts', after: 'export const x = totallyUnknownGlobal\n' }]);
|
|
1169
1382
|
assert.equal(phantomApi.run(g).length, 0);
|
|
1170
1383
|
});
|
|
1384
|
+
await checkAsync('an unresolved type environment is partial, not a proven phantom API', async () => {
|
|
1385
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-phantom-api-types-')));
|
|
1386
|
+
try {
|
|
1387
|
+
mkdirSync(join(dir, 'src'));
|
|
1388
|
+
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
|
1389
|
+
compilerOptions: {
|
|
1390
|
+
target: 'ES2023', module: 'ESNext', moduleResolution: 'Bundler', lib: ['ES2023'], strict: true,
|
|
1391
|
+
},
|
|
1392
|
+
include: ['src'],
|
|
1393
|
+
}));
|
|
1394
|
+
const source = "import { fileURLToPath } from 'node:url'\nexport const here = fileURLToPath(import.meta.url)\n";
|
|
1395
|
+
writeFileSync(join(dir, 'src', 'a.ts'), source);
|
|
1396
|
+
const result = await review({
|
|
1397
|
+
root: dir,
|
|
1398
|
+
range: {},
|
|
1399
|
+
changes: [{ path: 'src/a.ts', added: new Set([1, 2]) }],
|
|
1400
|
+
config: loadConfig(dir),
|
|
1401
|
+
verifyOnly: true,
|
|
1402
|
+
checks: ['phantom-api'],
|
|
1403
|
+
});
|
|
1404
|
+
assert.deepEqual(result.findings, []);
|
|
1405
|
+
assert.deepEqual(result.plan?.items()[0]?.missing, ['types']);
|
|
1406
|
+
assert.deepEqual(result.skippedChecks, [{ check: 'phantom-api', missing: 'types' }]);
|
|
1407
|
+
}
|
|
1408
|
+
finally {
|
|
1409
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
await checkAsync('phantom-api still proves a property error with a complete type environment', async () => {
|
|
1413
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-phantom-api-complete-')));
|
|
1414
|
+
try {
|
|
1415
|
+
mkdirSync(join(dir, 'src'));
|
|
1416
|
+
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
|
1417
|
+
compilerOptions: {
|
|
1418
|
+
target: 'ES2023', module: 'ESNext', moduleResolution: 'Bundler', lib: ['ES2023'], strict: true,
|
|
1419
|
+
},
|
|
1420
|
+
include: ['src'],
|
|
1421
|
+
}));
|
|
1422
|
+
const source = "export const value = 'ok'.definitelyMissing()\n";
|
|
1423
|
+
writeFileSync(join(dir, 'src', 'a.ts'), source);
|
|
1424
|
+
const result = await review({
|
|
1425
|
+
root: dir,
|
|
1426
|
+
range: {},
|
|
1427
|
+
changes: [{ path: 'src/a.ts', added: new Set([1]) }],
|
|
1428
|
+
config: loadConfig(dir),
|
|
1429
|
+
verifyOnly: true,
|
|
1430
|
+
checks: ['phantom-api'],
|
|
1431
|
+
});
|
|
1432
|
+
assert.equal(result.findings.length, 1);
|
|
1433
|
+
assert.equal(result.findings[0]?.check, 'phantom-api');
|
|
1434
|
+
assert.equal(result.findings[0]?.confidence, 'proven');
|
|
1435
|
+
assert.deepEqual(result.skippedChecks, []);
|
|
1436
|
+
}
|
|
1437
|
+
finally {
|
|
1438
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
await checkAsync('nested solution and leaf configs type both source and excluded test files', async () => {
|
|
1442
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-nested-tsconfig-')));
|
|
1443
|
+
try {
|
|
1444
|
+
const web = join(dir, 'packages', 'web');
|
|
1445
|
+
mkdirSync(join(web, 'src'), { recursive: true });
|
|
1446
|
+
writeFileSync(join(web, 'tsconfig.json'), JSON.stringify({
|
|
1447
|
+
files: [],
|
|
1448
|
+
references: [{ path: './tsconfig.app.json' }],
|
|
1449
|
+
}));
|
|
1450
|
+
writeFileSync(join(web, 'tsconfig.app.json'), JSON.stringify({
|
|
1451
|
+
compilerOptions: {
|
|
1452
|
+
target: 'ES2023', module: 'ESNext', moduleResolution: 'Bundler', strict: true,
|
|
1453
|
+
},
|
|
1454
|
+
include: ['src'],
|
|
1455
|
+
exclude: ['src/**/*.test.ts'],
|
|
1456
|
+
}));
|
|
1457
|
+
writeFileSync(join(web, 'src', 'existing.ts'), "export const existing = 'ok'\n");
|
|
1458
|
+
writeFileSync(join(web, 'src', 'app.ts'), "export const app = 'ok'.definitelyMissing()\n");
|
|
1459
|
+
writeFileSync(join(web, 'src', 'app.test.ts'), "export const test = 'ok'.alsoMissing()\n");
|
|
1460
|
+
// An unrelated broken project must never be opened just because it is somewhere
|
|
1461
|
+
// in the same monorepo.
|
|
1462
|
+
mkdirSync(join(dir, 'packages', 'unrelated'), { recursive: true });
|
|
1463
|
+
writeFileSync(join(dir, 'packages', 'unrelated', 'tsconfig.json'), '{broken');
|
|
1464
|
+
const changes = [
|
|
1465
|
+
{ path: 'packages/web/src/app.ts', added: new Set([1]) },
|
|
1466
|
+
{ path: 'packages/web/src/app.test.ts', added: new Set([1]) },
|
|
1467
|
+
];
|
|
1468
|
+
const result = await review({
|
|
1469
|
+
root: dir,
|
|
1470
|
+
range: {},
|
|
1471
|
+
changes,
|
|
1472
|
+
config: loadConfig(dir),
|
|
1473
|
+
verifyOnly: true,
|
|
1474
|
+
checks: ['phantom-api'],
|
|
1475
|
+
});
|
|
1476
|
+
assert.equal(result.findings.length, 2);
|
|
1477
|
+
assert.deepEqual(result.skippedChecks, []);
|
|
1478
|
+
assert.deepEqual(result.plan?.items().map((item) => item.missing), [undefined, undefined]);
|
|
1479
|
+
const g = await buildGround(dir, changes);
|
|
1480
|
+
assert.deepEqual(g.configFiles, ['packages/web/tsconfig.app.json']);
|
|
1481
|
+
assert.deepEqual(g.files.map((file) => file.typed), [true, true]);
|
|
1482
|
+
assert.equal(g.sourceFiles.length, 3);
|
|
1483
|
+
}
|
|
1484
|
+
finally {
|
|
1485
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
await checkAsync('one review can reuse several independent package projects', async () => {
|
|
1489
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-many-projects-')));
|
|
1490
|
+
try {
|
|
1491
|
+
const changes = [];
|
|
1492
|
+
for (const name of ['api', 'web']) {
|
|
1493
|
+
const packageDir = join(dir, 'packages', name);
|
|
1494
|
+
mkdirSync(join(packageDir, 'src'), { recursive: true });
|
|
1495
|
+
writeFileSync(join(packageDir, 'tsconfig.json'), JSON.stringify({
|
|
1496
|
+
compilerOptions: { target: 'ES2023', module: 'ESNext', strict: true },
|
|
1497
|
+
include: ['src'],
|
|
1498
|
+
}));
|
|
1499
|
+
writeFileSync(join(packageDir, 'src', 'one.ts'), 'export const one = 1\n');
|
|
1500
|
+
writeFileSync(join(packageDir, 'src', 'two.ts'), 'export const two = 2\n');
|
|
1501
|
+
changes.push({ path: 'packages/' + name + '/src/one.ts', added: new Set([1]) }, { path: 'packages/' + name + '/src/two.ts', added: new Set([1]) });
|
|
1502
|
+
}
|
|
1503
|
+
const g = await buildGround(dir, changes);
|
|
1504
|
+
assert.deepEqual(g.configFiles, [
|
|
1505
|
+
'packages/api/tsconfig.json',
|
|
1506
|
+
'packages/web/tsconfig.json',
|
|
1507
|
+
]);
|
|
1508
|
+
assert.deepEqual(g.files.map((file) => file.typed), [true, true, true, true]);
|
|
1509
|
+
assert.equal(g.sourceFiles.length, 4);
|
|
1510
|
+
}
|
|
1511
|
+
finally {
|
|
1512
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
await checkAsync('a configless repository parses changed files without loading the monorepo', async () => {
|
|
1516
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-focused-ground-')));
|
|
1517
|
+
try {
|
|
1518
|
+
mkdirSync(join(dir, 'changed'), { recursive: true });
|
|
1519
|
+
writeFileSync(join(dir, 'changed', 'one.ts'), 'export const one = 1\n');
|
|
1520
|
+
for (let i = 0; i < 100; i++) {
|
|
1521
|
+
const packageDir = join(dir, 'packages', 'package-' + i);
|
|
1522
|
+
mkdirSync(packageDir, { recursive: true });
|
|
1523
|
+
writeFileSync(join(packageDir, 'unrelated.ts'), 'export const unrelated = ' + i + '\n');
|
|
1524
|
+
writeFileSync(join(packageDir, 'tsconfig.json'), '{broken');
|
|
1525
|
+
}
|
|
1526
|
+
const g = await buildGround(dir, [{ path: 'changed/one.ts', added: new Set([1]) }]);
|
|
1527
|
+
assert.equal(g.sourceFiles.length, 1);
|
|
1528
|
+
}
|
|
1529
|
+
finally {
|
|
1530
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1531
|
+
}
|
|
1532
|
+
});
|
|
1171
1533
|
console.log('\nhelpers');
|
|
1172
1534
|
check('extractJsonArray survives prose and code fences', () => {
|
|
1173
1535
|
assert.deepEqual(extractJsonArray('Sure!\n```json\n[{"a":1}]\n```\n'), [{ a: 1 }]);
|
|
@@ -47,10 +47,11 @@ export const phantomConfig = {
|
|
|
47
47
|
const manifest = g.envManifest;
|
|
48
48
|
if (!manifest)
|
|
49
49
|
return [];
|
|
50
|
-
//
|
|
50
|
+
// A key used elsewhere in the relevant project is established configuration,
|
|
51
|
+
// even if the shared manifest is behind. Suppress that lower-signal case.
|
|
51
52
|
const usedElsewhere = new Set();
|
|
52
53
|
const changedPaths = new Set(g.changed.map((c) => c.path));
|
|
53
|
-
for (const sf of g.
|
|
54
|
+
for (const sf of g.sourceFiles) {
|
|
54
55
|
const path = relPath(sf, g.root);
|
|
55
56
|
if (changedPaths.has(path) || path.includes('node_modules'))
|
|
56
57
|
continue;
|
|
@@ -71,17 +72,17 @@ export const phantomConfig = {
|
|
|
71
72
|
class: 'verified',
|
|
72
73
|
check: 'phantom-config',
|
|
73
74
|
severity: 'medium',
|
|
74
|
-
// The manifest is not the process environment.
|
|
75
|
-
// that
|
|
75
|
+
// The manifest is not the process environment. The exact observation is
|
|
76
|
+
// only that the checked-in manifest does not declare the key; a deployment
|
|
76
77
|
// can still set it, so "will be undefined" is an inference, not a fact.
|
|
77
78
|
confidence: 'firm',
|
|
78
79
|
file: relPath(sf, g.root),
|
|
79
80
|
line: read.line,
|
|
80
81
|
span: locate(sf, read.start, read.width).span,
|
|
81
|
-
title: 'Reads process.env.' + read.name + ', which is not declared in ' + manifest.file
|
|
82
|
+
title: 'Reads process.env.' + read.name + ', which is not declared in ' + manifest.file,
|
|
82
83
|
evidence: {
|
|
83
84
|
oracle: manifest.file,
|
|
84
|
-
detail: 'the key appears in no manifest entry
|
|
85
|
+
detail: 'the key appears in no manifest entry',
|
|
85
86
|
},
|
|
86
87
|
fix: 'Add ' + read.name + ' to ' + manifest.file + ', or drop the reference if it was invented',
|
|
87
88
|
});
|
package/docs/architecture.md
CHANGED
|
@@ -59,7 +59,7 @@ engine. The engine does not depend on a workflow provider or terminal layout.
|
|
|
59
59
|
|---|---|---|
|
|
60
60
|
| `src/cli/` | Argument parsing, command dispatch, report publication, exit mapping | Review algorithms |
|
|
61
61
|
| `src/review.ts` | One review run and its stage orchestration | CLI parsing or presentation |
|
|
62
|
-
| `src/ground.ts` | TypeScript
|
|
62
|
+
| `src/ground.ts` | Change-scoped TypeScript projects, parse trees, manifests, symbol index | Check selection |
|
|
63
63
|
| `src/plan.ts` | File selection and per-file capability accounting | Finding generation |
|
|
64
64
|
| `src/manifest.ts` | Completion state and the authoritative run record | Rendering |
|
|
65
65
|
| `src/verifiers/` | Deterministic check implementations | Model calls |
|
|
@@ -126,6 +126,20 @@ A run can contain a typed TypeScript file beside a Python file or a TypeScript f
|
|
|
126
126
|
excluded from `tsconfig`. Capabilities therefore live on each selected file. A checker
|
|
127
127
|
available somewhere in the run is not evidence that it inspected every file.
|
|
128
128
|
|
|
129
|
+
### Monorepo grounding follows the change
|
|
130
|
+
|
|
131
|
+
For each changed TypeScript or JavaScript file, grounding inspects only its ancestor
|
|
132
|
+
directories for `tsconfig.json` and `tsconfig.*.json`. The nearest config that owns
|
|
133
|
+
the file wins. Empty solution configs yield to their leaf configs, and an excluded
|
|
134
|
+
test can reuse the closest non-empty leaf project when its type environment resolves.
|
|
135
|
+
Projects and directory listings are cached across files, then their source closures
|
|
136
|
+
are deduplicated for syntax searches and symbol indexing.
|
|
137
|
+
|
|
138
|
+
There is deliberately no repository-wide fallback glob. When no relevant config
|
|
139
|
+
exists, only changed files are parsed and type-dependent capabilities remain absent.
|
|
140
|
+
That keeps a configless or mixed-language monorepo proportional to the review rather
|
|
141
|
+
than to the repository.
|
|
142
|
+
|
|
129
143
|
### The manifest owns completion
|
|
130
144
|
|
|
131
145
|
Findings alone cannot distinguish a clean review from an interrupted or unsupported
|
package/docs/ci.md
CHANGED
|
@@ -27,7 +27,8 @@ There are two independent decisions:
|
|
|
27
27
|
## GitHub Action
|
|
28
28
|
|
|
29
29
|
The action runs one review, adds a job summary, uploads SARIF when enabled, and can
|
|
30
|
-
maintain one pull-request comment.
|
|
30
|
+
maintain one pull-request comment. Its opt-in inline mode posts a bounded batched
|
|
31
|
+
review on changed lines.
|
|
31
32
|
|
|
32
33
|
```yaml
|
|
33
34
|
name: PowerShot
|
|
@@ -42,23 +43,41 @@ permissions:
|
|
|
42
43
|
|
|
43
44
|
jobs:
|
|
44
45
|
review:
|
|
45
|
-
runs-on: ubuntu-
|
|
46
|
+
runs-on: ubuntu-24.04
|
|
46
47
|
steps:
|
|
47
48
|
- uses: actions/checkout@v7
|
|
48
49
|
with:
|
|
49
50
|
fetch-depth: 0
|
|
50
51
|
|
|
52
|
+
- run: npm ci --ignore-scripts
|
|
53
|
+
|
|
51
54
|
- uses: xcrft/powershot@v1
|
|
52
55
|
with:
|
|
53
56
|
verify-only: 'true'
|
|
54
57
|
upload-sarif: 'true'
|
|
55
58
|
comment: 'true'
|
|
59
|
+
inline-comments: 'true'
|
|
56
60
|
fail-on-findings: 'true'
|
|
57
61
|
```
|
|
58
62
|
|
|
63
|
+
Type-aware checks use the checked-out project's declarations, so install its
|
|
64
|
+
dependencies before PowerShot. Lifecycle scripts are disabled here because pull
|
|
65
|
+
request code is untrusted; use the equivalent safe install for another package
|
|
66
|
+
manager. For a monorepo without a root install, repeat the safe install step with the
|
|
67
|
+
relevant package `working-directory`. PowerShot finds nested `tsconfig.json` and
|
|
68
|
+
`tsconfig.*.json` files automatically; the workflow does not need to list projects.
|
|
69
|
+
|
|
59
70
|
Set `upload-sarif: 'false'` and omit `security-events: write` when GitHub code scanning
|
|
60
71
|
is unavailable or the workflow should not publish SARIF.
|
|
61
72
|
|
|
73
|
+
`inline-comments: 'true'` requires `pull-requests: write`. It publishes at most ten
|
|
74
|
+
findings as one review. Only deterministic `verified` findings with `proven`
|
|
75
|
+
confidence, severity `medium` or higher, and a GitHub-confirmed added line qualify.
|
|
76
|
+
Findings on context lines or files whose patch GitHub omitted stay in the full report.
|
|
77
|
+
Reruns preserve exact bot comments, create only missing comments, and remove stale
|
|
78
|
+
PowerShot inline copies without replies. Human comments and replied-to discussions
|
|
79
|
+
are never modified.
|
|
80
|
+
|
|
62
81
|
The major tag follows compatible `1.x` releases. Pin a full commit SHA in a protected
|
|
63
82
|
required workflow when immutable dependencies are required. The copy-paste version
|
|
64
83
|
lives at [`examples/github-actions/action.yml`](../examples/github-actions/action.yml).
|
|
@@ -73,6 +92,7 @@ lives at [`examples/github-actions/action.yml`](../examples/github-actions/actio
|
|
|
73
92
|
| `checks` | empty | Select comma-separated check ids |
|
|
74
93
|
| `upload-sarif` | `true` | Upload a complete SARIF report to GitHub code scanning |
|
|
75
94
|
| `comment` | `true` | Maintain a pull-request comment |
|
|
95
|
+
| `inline-comments` | `false` | Post up to ten proven verified findings as one inline review |
|
|
76
96
|
| `fail-on-findings` | `false` | Turn a complete finding verdict into a failed job |
|
|
77
97
|
| `approve` | `false` | Approve only a complete, clean review |
|
|
78
98
|
|
|
@@ -88,7 +108,7 @@ step inside a larger quality job. The complete example is
|
|
|
88
108
|
The core pattern is:
|
|
89
109
|
|
|
90
110
|
```bash
|
|
91
|
-
npm install --global --ignore-scripts @0xcraft/powershot@1.
|
|
111
|
+
npm install --global --ignore-scripts @0xcraft/powershot@1.1.1
|
|
92
112
|
|
|
93
113
|
STATUS=0
|
|
94
114
|
psh review --verify-only \
|
|
@@ -10,15 +10,21 @@ permissions:
|
|
|
10
10
|
|
|
11
11
|
jobs:
|
|
12
12
|
review:
|
|
13
|
-
runs-on: ubuntu-
|
|
13
|
+
runs-on: ubuntu-24.04
|
|
14
14
|
steps:
|
|
15
15
|
- uses: actions/checkout@v7
|
|
16
16
|
with:
|
|
17
17
|
fetch-depth: 0
|
|
18
18
|
|
|
19
|
+
# Type-aware checks need the repository's declared types. Keep lifecycle
|
|
20
|
+
# scripts disabled when pull-request code is not trusted. In a monorepo,
|
|
21
|
+
# install at the workspace root or set working-directory to the package root.
|
|
22
|
+
- run: npm ci --ignore-scripts
|
|
23
|
+
|
|
19
24
|
- uses: xcrft/powershot@v1
|
|
20
25
|
with:
|
|
21
26
|
verify-only: 'true'
|
|
22
27
|
upload-sarif: 'true'
|
|
23
28
|
comment: 'true'
|
|
29
|
+
inline-comments: 'true'
|
|
24
30
|
fail-on-findings: 'true'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0xcraft/powershot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Oracle-first code review for machine-written code, with deterministic verification and CI-ready reports.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "aglumova <alina.glumova@gmail.com>",
|