@0xcraft/powershot 1.1.0 → 1.1.2
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 +43 -18
- package/dist/cli/reports.js +3 -0
- package/dist/cli/review-command.js +8 -2
- package/dist/cli/session-command.js +2 -0
- package/dist/config.js +5 -0
- package/dist/ground.js +297 -95
- package/dist/judges/tools.js +7 -7
- 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 +49 -0
- package/dist/plan.js +7 -0
- package/dist/report/markdown.js +18 -2
- package/dist/report/terminal.js +12 -1
- package/dist/report/viewer.js +11 -1
- package/dist/review.js +43 -20
- package/dist/selftest.js +402 -8
- package/dist/session.js +2 -0
- package/dist/verifiers/foreign-phantom-dep.js +8 -2
- package/dist/verifiers/phantom-config.js +7 -6
- package/docs/architecture.md +52 -12
- package/docs/ci.md +21 -4
- 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';
|
|
@@ -50,7 +50,7 @@ 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
56
|
import { addedLinesFromPatch, createReviewPayload, GitHubPullRequestApi, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
|
|
@@ -90,7 +90,7 @@ function ground(files, deps = []) {
|
|
|
90
90
|
symbolIndex.set(key, list);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
-
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: [] };
|
|
94
94
|
}
|
|
95
95
|
let failures = 0;
|
|
96
96
|
function check(name, fn) {
|
|
@@ -433,6 +433,35 @@ check('files are routed to the right language pack', () => {
|
|
|
433
433
|
assert.equal(packFor('src/a.ts'), undefined); // TypeScript keeps its own oracle
|
|
434
434
|
assert.equal(packFor('README.md'), undefined);
|
|
435
435
|
});
|
|
436
|
+
await checkAsync('isolated trees preserve Unicode offsets and grammar fields', async () => {
|
|
437
|
+
const pack = PACKS.find((candidate) => candidate.name === 'python');
|
|
438
|
+
const source = 'label = "é"\ndef answer(value: str) -> str:\n return value\n';
|
|
439
|
+
const [tree] = await parseIsolated(pack, [source]);
|
|
440
|
+
assert.ok(tree);
|
|
441
|
+
const signature = pack.signatures?.(tree.rootNode).get('answer');
|
|
442
|
+
assert.ok(signature);
|
|
443
|
+
assert.equal(signature.node.text, 'def answer(value: str) -> str:\n return value');
|
|
444
|
+
assert.deepEqual(signature.params, ['value: str']);
|
|
445
|
+
});
|
|
446
|
+
await checkAsync('language worker batching preserves every file past the 128-file boundary', async () => {
|
|
447
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-foreign-batches-')));
|
|
448
|
+
try {
|
|
449
|
+
const changes = [];
|
|
450
|
+
for (let index = 0; index < 129; index++) {
|
|
451
|
+
const path = 'python/module_' + index + '.py';
|
|
452
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true });
|
|
453
|
+
writeFileSync(join(dir, path), 'value = ' + index + '\n');
|
|
454
|
+
changes.push({ path, added: new Set([1]), before: 'value = -1\n' });
|
|
455
|
+
}
|
|
456
|
+
const grounded = await buildGround(dir, changes);
|
|
457
|
+
assert.equal(grounded.foreign.length, changes.length);
|
|
458
|
+
assert.deepEqual(grounded.foreign.map((file) => file.path), changes.map((file) => file.path));
|
|
459
|
+
assert.ok(grounded.foreign.every((file) => file.beforeTree !== undefined));
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
rmSync(dir, { recursive: true, force: true });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
436
465
|
// Per-language pack checks live in langtest.ts, one process each: eleven wasm
|
|
437
466
|
// grammars cannot share a process without exhausting it. `npm test` runs both.
|
|
438
467
|
console.log('\neditor and provider surface');
|
|
@@ -462,7 +491,7 @@ check('compact defaults the column when a finding has no span', () => {
|
|
|
462
491
|
});
|
|
463
492
|
check('each provider reads its own key', () => {
|
|
464
493
|
const base = { model: 'm', verifiers: ['*'], judges: ['*'], minSeverity: 'low',
|
|
465
|
-
ignore: [], promptCache: true };
|
|
494
|
+
ignore: [], coverage: 'portable', promptCache: true };
|
|
466
495
|
const saved = { a: process.env.ANTHROPIC_API_KEY, o: process.env.OPENAI_API_KEY,
|
|
467
496
|
g: process.env.GEMINI_API_KEY, gg: process.env.GOOGLE_API_KEY };
|
|
468
497
|
process.env.ANTHROPIC_API_KEY = 'a';
|
|
@@ -556,6 +585,30 @@ check('a review that did not complete never renders as clean', () => {
|
|
|
556
585
|
assert.match(partial, /partial, not a verdict/);
|
|
557
586
|
assert.match(partial, /outside\.ts \(no types\)/);
|
|
558
587
|
});
|
|
588
|
+
check('portable coverage is a verdict, but never masquerades as full semantic coverage', () => {
|
|
589
|
+
const unavailable = [
|
|
590
|
+
'1 file(s) without enriched semantic coverage: web/app.ts (types, references)',
|
|
591
|
+
'2 enriched check(s) unavailable: phantom-api (no types), contract-drift (no references)',
|
|
592
|
+
];
|
|
593
|
+
const out = terminal([], {
|
|
594
|
+
subtitle: 'workspace', verified: 0, judged: 0, state: 'complete', notLookedAt: [],
|
|
595
|
+
coverage: 'portable', unavailableCoverage: unavailable,
|
|
596
|
+
});
|
|
597
|
+
assert.match(out, /No findings in portable coverage\./);
|
|
598
|
+
assert.match(out, /web\/app\.ts \(types, references\)/);
|
|
599
|
+
assert.doesNotMatch(out, /not a verdict/);
|
|
600
|
+
const md = markdown([], {
|
|
601
|
+
state: 'complete', notLookedAt: [], coverage: 'portable',
|
|
602
|
+
files: [{ path: 'web/app.ts', unavailable: ['types', 'references'] }],
|
|
603
|
+
checks: { unavailable: [
|
|
604
|
+
{ check: 'phantom-api', missing: 'types' },
|
|
605
|
+
{ check: 'contract-drift', missing: 'references' },
|
|
606
|
+
] },
|
|
607
|
+
});
|
|
608
|
+
assert.match(md, /\*\*Portable coverage\.\*\*/);
|
|
609
|
+
assert.match(md, /No findings in portable coverage\./);
|
|
610
|
+
assert.doesNotMatch(md, /^No findings\.$/m);
|
|
611
|
+
});
|
|
559
612
|
check('findings are still shown when a stage failed, with the warning kept', () => {
|
|
560
613
|
const f = { id: 'F1', class: 'verified', check: 'phantom-dep', severity: 'high',
|
|
561
614
|
confidence: 'proven', file: 'a.ts', line: 1, title: 'missing dep' };
|
|
@@ -616,6 +669,23 @@ check('no manifest means nothing to be wrong about', () => {
|
|
|
616
669
|
assert.equal(pythonManifest('/definitely/not/a/repo'), undefined);
|
|
617
670
|
assert.deepEqual(localModules('/definitely/not/a/repo'), new Set());
|
|
618
671
|
});
|
|
672
|
+
check('local Python modules are discovered from the changed package, not the whole monorepo', () => {
|
|
673
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-py-local-')));
|
|
674
|
+
try {
|
|
675
|
+
const app = join(dir, 'services', 'api', 'src', 'app.py');
|
|
676
|
+
mkdirSync(join(dir, 'services', 'api', 'src', 'localpkg'), { recursive: true });
|
|
677
|
+
mkdirSync(join(dir, 'unrelated', 'hiddenpkg'), { recursive: true });
|
|
678
|
+
writeFileSync(app, 'from localpkg import value\n');
|
|
679
|
+
writeFileSync(join(dir, 'services', 'api', 'src', 'localpkg', '__init__.py'), 'value = 1\n');
|
|
680
|
+
writeFileSync(join(dir, 'unrelated', 'hiddenpkg', '__init__.py'), 'value = 2\n');
|
|
681
|
+
const local = localModules(dir, dirname(app));
|
|
682
|
+
assert.equal(local.has('localpkg'), true);
|
|
683
|
+
assert.equal(local.has('hiddenpkg'), false);
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
rmSync(dir, { recursive: true, force: true });
|
|
687
|
+
}
|
|
688
|
+
});
|
|
619
689
|
console.log('\nruby gems');
|
|
620
690
|
const gems = { names: new Set(['rails', 'httparty', 'sidekiq']) };
|
|
621
691
|
const rbLocal = new Set(['helpers', 'models']);
|
|
@@ -892,7 +962,9 @@ check('self-review publishes machine findings only for a complete verdict', () =
|
|
|
892
962
|
const workflow = readFileSync(join(process.cwd(), '.github', 'workflows', 'review.yml'), 'utf8');
|
|
893
963
|
assert.equal(workflow.match(/node "\$PSH" review/g)?.length, 1);
|
|
894
964
|
assert.match(workflow, /name: Check out the untrusted review target[\s\S]+allow-unsafe-pr-checkout: true/);
|
|
965
|
+
assert.doesNotMatch(workflow, /working-directory: target[\s\S]{0,120}npm ci/);
|
|
895
966
|
assert.match(workflow, /steps\.review\.outputs\.status == '0' \|\| steps\.review\.outputs\.status == '1'/);
|
|
967
|
+
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 \}\}/);
|
|
896
968
|
});
|
|
897
969
|
check('the public action persists judge answers and publishes only a verdict', () => {
|
|
898
970
|
const action = readFileSync(join(process.cwd(), 'action.yml'), 'utf8');
|
|
@@ -904,6 +976,10 @@ check('the public action persists judge answers and publishes only a verdict', (
|
|
|
904
976
|
assert.match(action, /inline-comments:\s*\n\s+description: [^\n]+\n\s+default: 'false'/);
|
|
905
977
|
assert.match(action, /Post inline comments[\s\S]+inputs\.inline-comments == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
|
|
906
978
|
assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/inline-comments\.js"/);
|
|
979
|
+
assert.match(action, /--report manifest=powershot\.manifest\.json/);
|
|
980
|
+
assert.match(action, /coverage=\$COVERAGE/);
|
|
981
|
+
assert.match(action, /m\.coverage === "full" \|\| m\.coverage === "portable" \? m\.coverage : "unknown"/);
|
|
982
|
+
assert.match(action, /Approve a clean review[\s\S]+steps\.review\.outputs\.coverage == 'full'/);
|
|
907
983
|
});
|
|
908
984
|
check('published CI examples preserve one verdict and its exit status', () => {
|
|
909
985
|
const action = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'action.yml'), 'utf8');
|
|
@@ -912,11 +988,13 @@ check('published CI examples preserve one verdict and its exit status', () => {
|
|
|
912
988
|
assert.match(action, /upload-sarif: 'true'/);
|
|
913
989
|
assert.match(action, /inline-comments: 'true'/);
|
|
914
990
|
assert.match(action, /runs-on: ubuntu-24\.04/);
|
|
915
|
-
assert.
|
|
991
|
+
assert.doesNotMatch(action, /npm ci|NPM_AUTH_TOKEN|NODE_AUTH_TOKEN/);
|
|
992
|
+
assert.match(action, /uses: xcrft\/powershot@v1/);
|
|
993
|
+
assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.2/);
|
|
916
994
|
assert.equal(github.match(/psh review/g)?.length, 1);
|
|
917
995
|
assert.match(github, /--report markdown=powershot\.md[\s\S]+--report sarif=powershot\.sarif/);
|
|
918
996
|
assert.match(github, /\|\| STATUS=\$\?[\s\S]+case "\$STATUS"/);
|
|
919
|
-
assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.
|
|
997
|
+
assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.2/);
|
|
920
998
|
assert.equal(gitlab.match(/psh review/g)?.length, 1);
|
|
921
999
|
assert.match(gitlab, /--format codequality > gl-code-quality-report\.json \|\| STATUS=\$\?/);
|
|
922
1000
|
assert.match(gitlab, /test "\$STATUS" -le 1 \|\| exit "\$STATUS"/);
|
|
@@ -937,6 +1015,15 @@ check('the viewer is one self-contained page', () => {
|
|
|
937
1015
|
assert.equal(/<(script|link|img)[^>]+(src|href)="http/.test(html), false); // no network needed
|
|
938
1016
|
assert.equal((html.match(/class="f /g) ?? []).length, 2);
|
|
939
1017
|
});
|
|
1018
|
+
check('the viewer labels a complete portable session', () => {
|
|
1019
|
+
const html = viewer([], {
|
|
1020
|
+
id: 'portable', target: 'workspace', started: '2026-01-01T10:00:00Z',
|
|
1021
|
+
state: 'complete', notLookedAt: [], coverage: 'portable',
|
|
1022
|
+
unavailableCoverage: ['1 file(s) without enriched semantic coverage: app.ts (types)'],
|
|
1023
|
+
});
|
|
1024
|
+
assert.match(html, /Portable coverage\./);
|
|
1025
|
+
assert.match(html, /No findings in portable coverage\./);
|
|
1026
|
+
});
|
|
940
1027
|
check('the viewer escapes content rather than rendering it', () => {
|
|
941
1028
|
const nasty = [{ ...sample[0], title: '<img src=x onerror=alert(1)>' }];
|
|
942
1029
|
const html = viewer(nasty, {
|
|
@@ -964,7 +1051,7 @@ check('delegated output distinguishes an empty verdict from malformed data', ()
|
|
|
964
1051
|
check('delegate --checks selects only the requested judging brief', () => {
|
|
965
1052
|
const cfg = {
|
|
966
1053
|
provider: 'anthropic', model: 'm', verifiers: ['*'], judges: ['*'],
|
|
967
|
-
minSeverity: 'low', ignore: [], promptCache: true,
|
|
1054
|
+
minSeverity: 'low', ignore: [], coverage: 'portable', promptCache: true,
|
|
968
1055
|
};
|
|
969
1056
|
const brief = delegateBrief(ground([{ path: 'a.ts', after: 'export const a = 1\n' }]), cfg, {
|
|
970
1057
|
checks: ['intent'], intent: 'add a',
|
|
@@ -1378,6 +1465,155 @@ check('refuses to run without a tsconfig rather than guessing', () => {
|
|
|
1378
1465
|
const g = ground([{ path: 'a.ts', after: 'export const x = totallyUnknownGlobal\n' }]);
|
|
1379
1466
|
assert.equal(phantomApi.run(g).length, 0);
|
|
1380
1467
|
});
|
|
1468
|
+
await checkAsync('an unresolved type environment is partial, not a proven phantom API', async () => {
|
|
1469
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-phantom-api-types-')));
|
|
1470
|
+
try {
|
|
1471
|
+
mkdirSync(join(dir, 'src'));
|
|
1472
|
+
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
|
1473
|
+
compilerOptions: {
|
|
1474
|
+
target: 'ES2023', module: 'ESNext', moduleResolution: 'Bundler', lib: ['ES2023'], strict: true,
|
|
1475
|
+
},
|
|
1476
|
+
include: ['src'],
|
|
1477
|
+
}));
|
|
1478
|
+
const source = "import { fileURLToPath } from 'node:url'\nexport const here = fileURLToPath(import.meta.url)\n";
|
|
1479
|
+
writeFileSync(join(dir, 'src', 'a.ts'), source);
|
|
1480
|
+
const result = await review({
|
|
1481
|
+
root: dir,
|
|
1482
|
+
range: {},
|
|
1483
|
+
changes: [{ path: 'src/a.ts', added: new Set([1, 2]) }],
|
|
1484
|
+
config: loadConfig(dir),
|
|
1485
|
+
verifyOnly: true,
|
|
1486
|
+
checks: ['phantom-api'],
|
|
1487
|
+
});
|
|
1488
|
+
assert.deepEqual(result.findings, []);
|
|
1489
|
+
assert.deepEqual(result.plan?.items()[0]?.missing, ['types']);
|
|
1490
|
+
assert.deepEqual(result.skippedChecks, [{ check: 'phantom-api', missing: 'types' }]);
|
|
1491
|
+
}
|
|
1492
|
+
finally {
|
|
1493
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
await checkAsync('phantom-api still proves a property error with a complete type environment', async () => {
|
|
1497
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-phantom-api-complete-')));
|
|
1498
|
+
try {
|
|
1499
|
+
mkdirSync(join(dir, 'src'));
|
|
1500
|
+
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
|
1501
|
+
compilerOptions: {
|
|
1502
|
+
target: 'ES2023', module: 'ESNext', moduleResolution: 'Bundler', lib: ['ES2023'], strict: true,
|
|
1503
|
+
},
|
|
1504
|
+
include: ['src'],
|
|
1505
|
+
}));
|
|
1506
|
+
const source = "export const value = 'ok'.definitelyMissing()\n";
|
|
1507
|
+
writeFileSync(join(dir, 'src', 'a.ts'), source);
|
|
1508
|
+
const result = await review({
|
|
1509
|
+
root: dir,
|
|
1510
|
+
range: {},
|
|
1511
|
+
changes: [{ path: 'src/a.ts', added: new Set([1]) }],
|
|
1512
|
+
config: loadConfig(dir),
|
|
1513
|
+
verifyOnly: true,
|
|
1514
|
+
checks: ['phantom-api'],
|
|
1515
|
+
});
|
|
1516
|
+
assert.equal(result.findings.length, 1);
|
|
1517
|
+
assert.equal(result.findings[0]?.check, 'phantom-api');
|
|
1518
|
+
assert.equal(result.findings[0]?.confidence, 'proven');
|
|
1519
|
+
assert.deepEqual(result.skippedChecks, []);
|
|
1520
|
+
}
|
|
1521
|
+
finally {
|
|
1522
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
await checkAsync('nested solution and leaf configs type both source and excluded test files', async () => {
|
|
1526
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-nested-tsconfig-')));
|
|
1527
|
+
try {
|
|
1528
|
+
const web = join(dir, 'packages', 'web');
|
|
1529
|
+
mkdirSync(join(web, 'src'), { recursive: true });
|
|
1530
|
+
writeFileSync(join(web, 'tsconfig.json'), JSON.stringify({
|
|
1531
|
+
files: [],
|
|
1532
|
+
references: [{ path: './tsconfig.app.json' }],
|
|
1533
|
+
}));
|
|
1534
|
+
writeFileSync(join(web, 'tsconfig.app.json'), JSON.stringify({
|
|
1535
|
+
compilerOptions: {
|
|
1536
|
+
target: 'ES2023', module: 'ESNext', moduleResolution: 'Bundler', strict: true,
|
|
1537
|
+
},
|
|
1538
|
+
include: ['src'],
|
|
1539
|
+
exclude: ['src/**/*.test.ts'],
|
|
1540
|
+
}));
|
|
1541
|
+
writeFileSync(join(web, 'src', 'existing.ts'), "export const existing = 'ok'\n");
|
|
1542
|
+
writeFileSync(join(web, 'src', 'app.ts'), "export const app = 'ok'.definitelyMissing()\n");
|
|
1543
|
+
writeFileSync(join(web, 'src', 'app.test.ts'), "export const test = 'ok'.alsoMissing()\n");
|
|
1544
|
+
// An unrelated broken project must never be opened just because it is somewhere
|
|
1545
|
+
// in the same monorepo.
|
|
1546
|
+
mkdirSync(join(dir, 'packages', 'unrelated'), { recursive: true });
|
|
1547
|
+
writeFileSync(join(dir, 'packages', 'unrelated', 'tsconfig.json'), '{broken');
|
|
1548
|
+
const changes = [
|
|
1549
|
+
{ path: 'packages/web/src/app.ts', added: new Set([1]) },
|
|
1550
|
+
{ path: 'packages/web/src/app.test.ts', added: new Set([1]) },
|
|
1551
|
+
];
|
|
1552
|
+
const result = await review({
|
|
1553
|
+
root: dir,
|
|
1554
|
+
range: {},
|
|
1555
|
+
changes,
|
|
1556
|
+
config: loadConfig(dir),
|
|
1557
|
+
verifyOnly: true,
|
|
1558
|
+
checks: ['phantom-api'],
|
|
1559
|
+
});
|
|
1560
|
+
assert.equal(result.findings.length, 2);
|
|
1561
|
+
assert.deepEqual(result.skippedChecks, []);
|
|
1562
|
+
assert.deepEqual(result.plan?.items().map((item) => item.missing), [undefined, undefined]);
|
|
1563
|
+
const g = await buildGround(dir, changes);
|
|
1564
|
+
assert.deepEqual(g.configFiles, ['packages/web/tsconfig.app.json']);
|
|
1565
|
+
assert.deepEqual(g.files.map((file) => file.typed), [true, true]);
|
|
1566
|
+
assert.equal(g.sourceFiles.length, 3);
|
|
1567
|
+
}
|
|
1568
|
+
finally {
|
|
1569
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
await checkAsync('one review can reuse several independent package projects', async () => {
|
|
1573
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-many-projects-')));
|
|
1574
|
+
try {
|
|
1575
|
+
const changes = [];
|
|
1576
|
+
for (const name of ['api', 'web']) {
|
|
1577
|
+
const packageDir = join(dir, 'packages', name);
|
|
1578
|
+
mkdirSync(join(packageDir, 'src'), { recursive: true });
|
|
1579
|
+
writeFileSync(join(packageDir, 'tsconfig.json'), JSON.stringify({
|
|
1580
|
+
compilerOptions: { target: 'ES2023', module: 'ESNext', strict: true },
|
|
1581
|
+
include: ['src'],
|
|
1582
|
+
}));
|
|
1583
|
+
writeFileSync(join(packageDir, 'src', 'one.ts'), 'export const one = 1\n');
|
|
1584
|
+
writeFileSync(join(packageDir, 'src', 'two.ts'), 'export const two = 2\n');
|
|
1585
|
+
changes.push({ path: 'packages/' + name + '/src/one.ts', added: new Set([1]) }, { path: 'packages/' + name + '/src/two.ts', added: new Set([1]) });
|
|
1586
|
+
}
|
|
1587
|
+
const g = await buildGround(dir, changes);
|
|
1588
|
+
assert.deepEqual(g.configFiles, [
|
|
1589
|
+
'packages/api/tsconfig.json',
|
|
1590
|
+
'packages/web/tsconfig.json',
|
|
1591
|
+
]);
|
|
1592
|
+
assert.deepEqual(g.files.map((file) => file.typed), [true, true, true, true]);
|
|
1593
|
+
assert.equal(g.sourceFiles.length, 4);
|
|
1594
|
+
}
|
|
1595
|
+
finally {
|
|
1596
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
await checkAsync('a configless repository parses changed files without loading the monorepo', async () => {
|
|
1600
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-focused-ground-')));
|
|
1601
|
+
try {
|
|
1602
|
+
mkdirSync(join(dir, 'changed'), { recursive: true });
|
|
1603
|
+
writeFileSync(join(dir, 'changed', 'one.ts'), 'export const one = 1\n');
|
|
1604
|
+
for (let i = 0; i < 100; i++) {
|
|
1605
|
+
const packageDir = join(dir, 'packages', 'package-' + i);
|
|
1606
|
+
mkdirSync(packageDir, { recursive: true });
|
|
1607
|
+
writeFileSync(join(packageDir, 'unrelated.ts'), 'export const unrelated = ' + i + '\n');
|
|
1608
|
+
writeFileSync(join(packageDir, 'tsconfig.json'), '{broken');
|
|
1609
|
+
}
|
|
1610
|
+
const g = await buildGround(dir, [{ path: 'changed/one.ts', added: new Set([1]) }]);
|
|
1611
|
+
assert.equal(g.sourceFiles.length, 1);
|
|
1612
|
+
}
|
|
1613
|
+
finally {
|
|
1614
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1615
|
+
}
|
|
1616
|
+
});
|
|
1381
1617
|
console.log('\nhelpers');
|
|
1382
1618
|
check('extractJsonArray survives prose and code fences', () => {
|
|
1383
1619
|
assert.deepEqual(extractJsonArray('Sure!\n```json\n[{"a":1}]\n```\n'), [{ a: 1 }]);
|
|
@@ -1584,6 +1820,12 @@ check('judges accept both the plain list and the { enable } form', () => {
|
|
|
1584
1820
|
assert.equal(validateConfig({ judges: { enable: ['securty'] } }, KNOWN).length, 1);
|
|
1585
1821
|
assert.equal(validateConfig({ judges: 'security' }, KNOWN).length, 1); // not a list at all
|
|
1586
1822
|
});
|
|
1823
|
+
check('coverage is portable by default and strict only when requested', () => {
|
|
1824
|
+
assert.equal(loadConfig(process.cwd()).coverage, 'portable');
|
|
1825
|
+
assert.deepEqual(validateConfig({ coverage: 'portable' }, KNOWN), []);
|
|
1826
|
+
assert.deepEqual(validateConfig({ coverage: 'strict' }, KNOWN), []);
|
|
1827
|
+
assert.match(validateConfig({ coverage: 'complete' }, KNOWN)[0], /not one of: portable, strict/);
|
|
1828
|
+
});
|
|
1587
1829
|
console.log('\nsession safety');
|
|
1588
1830
|
check('a session will not be resumed by a different model than answered it', () => {
|
|
1589
1831
|
const dir = mkdtempSync(join(tmpdir(), 'psh-ses-'));
|
|
@@ -1845,6 +2087,32 @@ check('the manifest accounts for everything it selected', () => {
|
|
|
1845
2087
|
// and a failure anywhere means the run is not a verdict
|
|
1846
2088
|
assert.equal(m2.build({ ...base, failures: ['judge died'] }).state, 'failed');
|
|
1847
2089
|
});
|
|
2090
|
+
check('portable oracle gaps stay visible without turning the run into a partial verdict', () => {
|
|
2091
|
+
const manifest = new RunManifest('portable');
|
|
2092
|
+
manifest.ran('swallowed-error');
|
|
2093
|
+
const record = manifest.build({
|
|
2094
|
+
operation: 'review', target: { requested: {} },
|
|
2095
|
+
policy: { source: 'base', hash: 'h' },
|
|
2096
|
+
engine: { version: '0', tools: false, verifyOnly: true },
|
|
2097
|
+
files: [{
|
|
2098
|
+
path: 'web/app.ts', disposition: 'selected', bytes: 1, addedLines: 1,
|
|
2099
|
+
language: 'typescript', checks: ['swallowed-error'], unavailable: ['types', 'references'],
|
|
2100
|
+
}],
|
|
2101
|
+
skippedChecks: [],
|
|
2102
|
+
unavailableChecks: [
|
|
2103
|
+
{ check: 'phantom-api', missing: 'types' },
|
|
2104
|
+
{ check: 'contract-drift', missing: 'references' },
|
|
2105
|
+
],
|
|
2106
|
+
findings: { total: 0, verified: 0, judged: 0, dismissed: 0, droppedPosition: 0 },
|
|
2107
|
+
usage: { requests: 0, inputTokens: 0, outputTokens: 0, toolCalls: 0, elapsedMs: 0, units: 0 },
|
|
2108
|
+
failures: [],
|
|
2109
|
+
});
|
|
2110
|
+
assert.equal(record.state, 'complete');
|
|
2111
|
+
assert.deepEqual(record.notLookedAt, []);
|
|
2112
|
+
assert.deepEqual(record.files[0].unavailable, ['types', 'references']);
|
|
2113
|
+
assert.equal(record.checks.unavailable?.length, 2);
|
|
2114
|
+
assert.deepEqual(coverageProblems(record), []);
|
|
2115
|
+
});
|
|
1848
2116
|
check('a manifest that hides an unreached unit is caught as our bug', () => {
|
|
1849
2117
|
const broken = {
|
|
1850
2118
|
schema: SCHEMA, id: 'x', operation: 'review', started: '', ended: '',
|
|
@@ -1993,6 +2261,7 @@ await checkAsync('per-file limits follow the checks the caller actually selected
|
|
|
1993
2261
|
operation: 'scan', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
|
|
1994
2262
|
engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan?.items() ?? [],
|
|
1995
2263
|
skippedChecks: result.skippedChecks ?? [],
|
|
2264
|
+
unavailableChecks: result.unavailableChecks ?? [],
|
|
1996
2265
|
findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
|
|
1997
2266
|
dismissed: result.stats.dismissed, droppedPosition: 0 },
|
|
1998
2267
|
usage: result.usage, failures: result.failures,
|
|
@@ -2010,6 +2279,131 @@ await checkAsync('per-file limits follow the checks the caller actually selected
|
|
|
2010
2279
|
rmSync(dir, { recursive: true, force: true });
|
|
2011
2280
|
}
|
|
2012
2281
|
});
|
|
2282
|
+
await checkAsync('portable coverage reviews every declared language together without repository installs', async () => {
|
|
2283
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-portable-mixed-')));
|
|
2284
|
+
try {
|
|
2285
|
+
const sources = {
|
|
2286
|
+
python: { path: 'services/api/app.py', source: 'def answer() -> int:\n try:\n risky()\n except Exception:\n pass\n return 42\n' },
|
|
2287
|
+
go: { path: 'services/agent/main.go', source: 'package agent\nfunc answer() int {\n if err := risky(); err != nil {\n }\n return 42\n}\n' },
|
|
2288
|
+
java: { path: 'services/jvm/App.java', source: 'class App { int answer() { try { risky(); } catch (Exception e) { } return 42; } }\n' },
|
|
2289
|
+
rust: { path: 'crates/worker/src/lib.rs', source: 'pub fn answer() -> i32 { match risky() { Err(_) => {}, Ok(v) => v }; 42 }\n' },
|
|
2290
|
+
cpp: { path: 'native/app.cpp', source: 'int answer() { try { risky(); } catch (...) { } return 42; }\n' },
|
|
2291
|
+
c: { path: 'native/app.c', source: 'int answer(void) { return 42; }\n' },
|
|
2292
|
+
'c#': { path: 'dotnet/App.cs', source: 'class App { int Answer() { try { Risky(); } catch (Exception e) { } return 42; } }\n' },
|
|
2293
|
+
php: { path: 'php/app.php', source: '<?php function answer() { try { risky(); } catch (Exception $e) { } return 42; }\n' },
|
|
2294
|
+
kotlin: { path: 'android/App.kt', source: 'fun answer(): Int { try { risky() } catch (e: Exception) {} ; return 42 }\n' },
|
|
2295
|
+
ruby: { path: 'ruby/app.rb', source: 'def answer\n begin\n risky\n rescue => e\n end\n 42\nend\n' },
|
|
2296
|
+
solidity: { path: 'contracts/App.sol', source: 'contract App { function answer() public returns (uint) { try this.risky() { } catch { } return 42; } function risky() external {} }\n' },
|
|
2297
|
+
};
|
|
2298
|
+
assert.deepEqual(Object.keys(sources).sort(), PACKS.map((pack) => pack.name).sort());
|
|
2299
|
+
const native = [
|
|
2300
|
+
{ path: 'web/app.ts', source: 'export const answer = 42\n' },
|
|
2301
|
+
{ path: 'web/legacy.js', source: 'export const legacyAnswer = 42\n' },
|
|
2302
|
+
];
|
|
2303
|
+
const all = [...native, ...Object.values(sources)];
|
|
2304
|
+
for (const file of all) {
|
|
2305
|
+
mkdirSync(dirname(join(dir, file.path)), { recursive: true });
|
|
2306
|
+
writeFileSync(join(dir, file.path), file.source);
|
|
2307
|
+
}
|
|
2308
|
+
const changes = all.map((file) => ({
|
|
2309
|
+
path: file.path,
|
|
2310
|
+
added: new Set(file.source.split('\n').slice(0, -1).map((_, line) => line + 1)),
|
|
2311
|
+
before: file.source.replace('42', '41'),
|
|
2312
|
+
}));
|
|
2313
|
+
const manifest = new RunManifest('portable-mixed');
|
|
2314
|
+
const result = await review({
|
|
2315
|
+
root: dir, range: {}, changes, config: loadConfig(dir), verifyOnly: true, manifest,
|
|
2316
|
+
});
|
|
2317
|
+
const record = manifest.build({
|
|
2318
|
+
operation: 'review', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
|
|
2319
|
+
engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan.items(),
|
|
2320
|
+
skippedChecks: result.skippedChecks ?? [], unavailableChecks: result.unavailableChecks ?? [],
|
|
2321
|
+
findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
|
|
2322
|
+
dismissed: result.stats.dismissed, droppedPosition: result.droppedPosition ?? 0 },
|
|
2323
|
+
usage: result.usage, failures: result.failures,
|
|
2324
|
+
});
|
|
2325
|
+
assert.equal(record.state, 'complete');
|
|
2326
|
+
assert.deepEqual(record.notLookedAt, []);
|
|
2327
|
+
assert.equal(record.files.length, all.length);
|
|
2328
|
+
assert.ok(record.files.every((file) => file.disposition === 'selected'), 'a declared language cannot be waived');
|
|
2329
|
+
assert.ok(record.files.every((file) => file.checks.length > 0), 'every declared language needs baseline checks');
|
|
2330
|
+
const swallowed = new Set(result.findings.filter((finding) => finding.check === 'swallowed-error').map((finding) => finding.file));
|
|
2331
|
+
for (const [language, file] of Object.entries(sources)) {
|
|
2332
|
+
if (language !== 'c')
|
|
2333
|
+
assert.equal(swallowed.has(file.path), true, language + ' isolated AST must drive its oracle');
|
|
2334
|
+
}
|
|
2335
|
+
assert.deepEqual(record.files.find((file) => file.path === 'web/app.ts')?.unavailable, ['types', 'references']);
|
|
2336
|
+
assert.ok(record.checks.unavailable?.some((check) => check.check === 'phantom-api' && check.missing === 'types'));
|
|
2337
|
+
assert.deepEqual(coverageProblems(record), []);
|
|
2338
|
+
}
|
|
2339
|
+
finally {
|
|
2340
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
await checkAsync('strict coverage keeps missing semantic oracles verdict-blocking', async () => {
|
|
2344
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-strict-')));
|
|
2345
|
+
try {
|
|
2346
|
+
writeFileSync(join(dir, 'app.ts'), 'export const answer = 42\n');
|
|
2347
|
+
const manifest = new RunManifest('strict');
|
|
2348
|
+
const result = await review({
|
|
2349
|
+
root: dir,
|
|
2350
|
+
range: {},
|
|
2351
|
+
changes: [{ path: 'app.ts', added: new Set([1]), before: 'export const answer = 41\n' }],
|
|
2352
|
+
config: { ...loadConfig(dir), coverage: 'strict' },
|
|
2353
|
+
verifyOnly: true,
|
|
2354
|
+
manifest,
|
|
2355
|
+
});
|
|
2356
|
+
const record = manifest.build({
|
|
2357
|
+
operation: 'review', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
|
|
2358
|
+
engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan.items(),
|
|
2359
|
+
skippedChecks: result.skippedChecks ?? [], unavailableChecks: result.unavailableChecks ?? [],
|
|
2360
|
+
findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
|
|
2361
|
+
dismissed: result.stats.dismissed, droppedPosition: result.droppedPosition ?? 0 },
|
|
2362
|
+
usage: result.usage, failures: result.failures,
|
|
2363
|
+
});
|
|
2364
|
+
assert.equal(record.state, 'partial');
|
|
2365
|
+
assert.deepEqual(record.files[0].missing, ['types', 'references']);
|
|
2366
|
+
assert.deepEqual(record.files[0].unavailable, undefined);
|
|
2367
|
+
assert.ok(record.checks.skipped.some((check) => check.check === 'phantom-api' && check.missing === 'types'));
|
|
2368
|
+
}
|
|
2369
|
+
finally {
|
|
2370
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2371
|
+
}
|
|
2372
|
+
});
|
|
2373
|
+
await checkAsync('portable coverage still blocks when an existing foreign base cannot be parsed', async () => {
|
|
2374
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-foreign-base-limit-')));
|
|
2375
|
+
try {
|
|
2376
|
+
writeFileSync(join(dir, 'app.py'), 'def answer():\n return 42\n');
|
|
2377
|
+
const manifest = new RunManifest('foreign-base-limit');
|
|
2378
|
+
const result = await review({
|
|
2379
|
+
root: dir,
|
|
2380
|
+
range: {},
|
|
2381
|
+
changes: [{
|
|
2382
|
+
path: 'app.py',
|
|
2383
|
+
added: new Set([1, 2]),
|
|
2384
|
+
before: 'value = 1\n'.repeat(60_000),
|
|
2385
|
+
}],
|
|
2386
|
+
config: loadConfig(dir),
|
|
2387
|
+
verifyOnly: true,
|
|
2388
|
+
manifest,
|
|
2389
|
+
});
|
|
2390
|
+
const record = manifest.build({
|
|
2391
|
+
operation: 'review', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
|
|
2392
|
+
engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan.items(),
|
|
2393
|
+
skippedChecks: result.skippedChecks ?? [], unavailableChecks: result.unavailableChecks ?? [],
|
|
2394
|
+
findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
|
|
2395
|
+
dismissed: result.stats.dismissed, droppedPosition: result.droppedPosition ?? 0 },
|
|
2396
|
+
usage: result.usage, failures: result.failures,
|
|
2397
|
+
});
|
|
2398
|
+
assert.equal(record.state, 'partial');
|
|
2399
|
+
assert.ok(record.files[0].missing?.includes('base'));
|
|
2400
|
+
assert.ok(record.checks.skipped.some((check) => check.missing.includes('base')));
|
|
2401
|
+
assert.deepEqual(coverageProblems(record), []);
|
|
2402
|
+
}
|
|
2403
|
+
finally {
|
|
2404
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2405
|
+
}
|
|
2406
|
+
});
|
|
2013
2407
|
await checkAsync('foreign checks advertise only files their language pack can inspect', async () => {
|
|
2014
2408
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-pack-coverage-')));
|
|
2015
2409
|
try {
|
package/dist/session.js
CHANGED
|
@@ -106,6 +106,8 @@ export class Session {
|
|
|
106
106
|
judged: findings.filter((f) => f.class === 'judged').length,
|
|
107
107
|
state: verdict.state,
|
|
108
108
|
notLookedAt: verdict.notLookedAt,
|
|
109
|
+
coverage: verdict.coverage,
|
|
110
|
+
unavailableCoverage: verdict.unavailableCoverage,
|
|
109
111
|
};
|
|
110
112
|
this.save();
|
|
111
113
|
}
|
|
@@ -47,13 +47,19 @@ function pythonFindings(g) {
|
|
|
47
47
|
const python = g.foreign.filter((f) => f.pack.name === 'python');
|
|
48
48
|
if (python.length === 0)
|
|
49
49
|
return [];
|
|
50
|
-
const local = localModules(g.root);
|
|
51
50
|
const findings = [];
|
|
51
|
+
const modules = new Map();
|
|
52
52
|
for (const file of python) {
|
|
53
53
|
// the manifests governing this file, not just the repository's own
|
|
54
|
-
const
|
|
54
|
+
const fileDir = dirname(join(g.root, file.path));
|
|
55
|
+
const manifest = pythonManifest(g.root, fileDir);
|
|
55
56
|
if (!manifest)
|
|
56
57
|
continue;
|
|
58
|
+
let local = modules.get(fileDir);
|
|
59
|
+
if (!local) {
|
|
60
|
+
local = localModules(g.root, fileDir);
|
|
61
|
+
modules.set(fileDir, local);
|
|
62
|
+
}
|
|
57
63
|
for (const imported of file.pack.imports?.(file.tree.rootNode) ?? []) {
|
|
58
64
|
const line = imported.node.startPosition.row + 1;
|
|
59
65
|
if (!file.changed.added.has(line))
|
|
@@ -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
|
});
|