@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/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(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.0\.1/);
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\.0\.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
- // a key used elsewhere in the codebase is a documentation gap, not an invention
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.project.getSourceFiles()) {
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. What the oracle settles is
75
- // that nothing in the repository declares or uses this key a deployment
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 + ' or used anywhere else',
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 and in no other source file',
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
  });
@@ -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 project, parse trees, manifests, symbol index | Check selection |
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-latest
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.0.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-latest
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'
@@ -19,7 +19,7 @@ jobs:
19
19
  node-version: '24'
20
20
 
21
21
  - name: Install PowerShot
22
- run: npm install --global --ignore-scripts @0xcraft/powershot@1.0.1
22
+ run: npm install --global --ignore-scripts @0xcraft/powershot@1.1.1
23
23
 
24
24
  - name: Review pull request
25
25
  env:
@@ -5,7 +5,7 @@ powershot:
5
5
  variables:
6
6
  GIT_DEPTH: "0"
7
7
  before_script:
8
- - npm install --global --ignore-scripts @0xcraft/powershot@1.0.1
8
+ - npm install --global --ignore-scripts @0xcraft/powershot@1.1.1
9
9
  script:
10
10
  - |
11
11
  STATUS=0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xcraft/powershot",
3
- "version": "1.0.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>",