@0xcraft/powershot 1.1.1 → 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.
@@ -106,12 +106,18 @@ export function terminal(findings, opts) {
106
106
  out.push(' ' + bold('PowerShot') + dim(' · ' + opts.subtitle));
107
107
  out.push(rule);
108
108
  const incomplete = opts.state !== 'complete';
109
+ const portable = !incomplete && opts.coverage === 'portable';
109
110
  if (findings.length === 0) {
110
111
  out.push('', incomplete
111
112
  ? ' ' + yellow('!') + ' No findings — but this review is ' + opts.state + ', not a verdict.'
112
- : ' ' + steel('✔') + ' No findings.');
113
+ : ' ' + steel('✔') + (portable ? ' No findings in portable coverage.' : ' No findings.'));
113
114
  for (const reason of opts.notLookedAt)
114
115
  out.push(dim(' ' + reason));
116
+ if (portable) {
117
+ out.push(dim(' Portable coverage: self-contained oracles ran; enriched semantic depth was unavailable.'));
118
+ for (const reason of opts.unavailableCoverage ?? [])
119
+ out.push(dim(' ' + reason));
120
+ }
115
121
  out.push('');
116
122
  return out.join('\n');
117
123
  }
@@ -146,6 +152,11 @@ export function terminal(findings, opts) {
146
152
  for (const reason of opts.notLookedAt)
147
153
  out.push(dim(' ' + reason));
148
154
  }
155
+ else if (portable) {
156
+ out.push(' ' + steel('◇ portable coverage') + dim(' · enriched semantic depth was unavailable'));
157
+ for (const reason of opts.unavailableCoverage ?? [])
158
+ out.push(dim(' ' + reason));
159
+ }
149
160
  out.push('');
150
161
  return out.join('\n');
151
162
  }
@@ -2,12 +2,19 @@ const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/
2
2
  export function viewer(findings, meta) {
3
3
  const verified = findings.filter((f) => f.class === 'verified').length;
4
4
  const incomplete = meta.state !== 'complete';
5
+ const portable = !incomplete && meta.coverage === 'portable';
5
6
  const warning = incomplete
6
7
  ? '<div class="warning"><strong>This review is ' + escape(meta.state) + ' — not a verdict.</strong>' +
7
8
  (meta.notLookedAt.length > 0
8
9
  ? '<ul>' + meta.notLookedAt.map((reason) => '<li>' + escape(reason) + '</li>').join('') + '</ul>'
9
10
  : '') + '</div>'
10
11
  : '';
12
+ const coverage = portable
13
+ ? '<div class="coverage"><strong>Portable coverage.</strong> Self-contained oracles ran; enriched semantic depth was unavailable.' +
14
+ ((meta.unavailableCoverage?.length ?? 0) > 0
15
+ ? '<ul>' + meta.unavailableCoverage.map((reason) => '<li>' + escape(reason) + '</li>').join('') + '</ul>'
16
+ : '') + '</div>'
17
+ : '';
11
18
  const rows = findings
12
19
  .map((f) => {
13
20
  const frame = f.frame
@@ -70,6 +77,8 @@ export function viewer(findings, meta) {
70
77
  .none { color:var(--muted); }
71
78
  .warning { border:1px solid var(--amber); border-radius:6px; padding:10px 14px; margin:0 0 18px; }
72
79
  .warning ul { margin:6px 0 0; }
80
+ .coverage { border:1px solid var(--steel); border-radius:6px; padding:10px 14px; margin:0 0 18px; }
81
+ .coverage ul { margin:6px 0 0; }
73
82
  .f.put-away { opacity:.4; }
74
83
  .f.put-away .title { text-decoration:line-through; }
75
84
  .act { margin-left:8px; }
@@ -82,6 +91,7 @@ export function viewer(findings, meta) {
82
91
  <p class="meta">${escape(meta.target)} · ${escape(meta.started.slice(0, 19).replace('T', ' '))} · session ${escape(meta.id)}<br>
83
92
  ${findings.length} finding(s) — ${verified} verified, ${findings.length - verified} judged</p>
84
93
  ${warning}
94
+ ${coverage}
85
95
  <div class="bar">
86
96
  <button data-filter="all" aria-pressed="true">all</button>
87
97
  <button data-filter="verified" aria-pressed="false">verified</button>
@@ -89,7 +99,7 @@ export function viewer(findings, meta) {
89
99
  <button id="show-away" aria-pressed="false">show put away</button>
90
100
  </div>
91
101
  ${findings.length === 0
92
- ? '<p class="none">' + (incomplete ? 'No findings from what completed.' : 'No findings.') + '</p>'
102
+ ? '<p class="none">' + (incomplete ? 'No findings from what completed.' : portable ? 'No findings in portable coverage.' : 'No findings.') + '</p>'
93
103
  : rows}
94
104
  </div>
95
105
  <script>
package/dist/review.js CHANGED
@@ -2,7 +2,6 @@ import { buildGround } from './ground.js';
2
2
  import { baseRefOf, collectChanges, statedIntent } from './git.js';
3
3
  import { bundle, bundleName, reviewables, uncovered } from './bundle.js';
4
4
  import { attachFrames, positionable } from './position.js';
5
- import { skippedLanguages } from './lang/packs.js';
6
5
  import { JudgeCache } from './cache.js';
7
6
  import { Dismissals, rememberReport } from './dismissed.js';
8
7
  import { renderChanges } from './judges/judge.js';
@@ -16,7 +15,6 @@ import { Budget } from './budget.js';
16
15
  import { packFor } from './lang/packs.js';
17
16
  import { SEVERITIES } from './types.js';
18
17
  import { stripControl, stripPath } from './text.js';
19
- const packOf = (path) => packFor(path)?.name ?? 'other';
20
18
  export function atLeast(severity, min) {
21
19
  return SEVERITIES.indexOf(severity) >= SEVERITIES.indexOf(min);
22
20
  }
@@ -33,20 +31,24 @@ export function titleOverlap(a, b) {
33
31
  shared++;
34
32
  return shared / Math.min(left.size, right.size);
35
33
  }
34
+ const PORTABLE_OPTIONAL = new Set(['types', 'references', 'python-types']);
36
35
  /**
37
36
  * Files this verifier can actually answer for, with unavailable oracles kept per
38
- * file. `base` is applicability rather than a missing capability: a before/after
39
- * check has no question to ask about a newly created file.
37
+ * file. A before/after check has no question to ask about a newly created file;
38
+ * when an existing file has a base snapshot that cannot be parsed, `base` is a real
39
+ * missing capability and remains verdict-blocking in every coverage profile.
40
40
  */
41
41
  function verifierTargets(v, g, have) {
42
42
  if (v.domain === 'typescript') {
43
43
  return g.files
44
- .filter((file) => !v.needs.includes('base') || file.before !== undefined)
44
+ .filter((file) => !v.needs.includes('base') || file.changed.before !== undefined)
45
45
  .map((file) => ({
46
46
  kind: 'typescript',
47
47
  path: file.changed.path,
48
48
  file,
49
49
  missing: v.needs.filter((need) => {
50
+ if (need === 'base')
51
+ return file.before === undefined;
50
52
  if (need === 'types' || need === 'references')
51
53
  return !file.typed;
52
54
  if (need === 'python-types')
@@ -59,12 +61,14 @@ function verifierTargets(v, g, have) {
59
61
  return g.foreign
60
62
  .filter((file) => v.domain !== 'python' || file.pack.name === 'python')
61
63
  .filter((file) => !v.supports || v.supports(file))
62
- .filter((file) => !v.needs.includes('base') || file.beforeTree !== undefined)
64
+ .filter((file) => !v.needs.includes('base') || file.changed.before !== undefined)
63
65
  .map((file) => ({
64
66
  kind: 'foreign',
65
67
  path: file.path,
66
68
  file,
67
69
  missing: v.needs.filter((need) => {
70
+ if (need === 'base')
71
+ return file.beforeTree === undefined;
68
72
  if (need === 'python-types')
69
73
  return file.pack.name !== 'python' || !have.has('python-types');
70
74
  if (need === 'types' || need === 'references')
@@ -142,6 +146,7 @@ export async function review(opts) {
142
146
  return { findings: [], stats: { files: 0, verified: 0, judged: 0, dismissed: 0 }, failures, plan };
143
147
  }
144
148
  const skipped = new Map();
149
+ const unavailable = new Map();
145
150
  const budget = opts.budget ?? new Budget();
146
151
  const manifest = opts.manifest;
147
152
  const groundDone = stage('ground');
@@ -166,27 +171,33 @@ export async function review(opts) {
166
171
  targets.set(verifier, files);
167
172
  }
168
173
  const selectedVerifiers = [...targets.keys()];
174
+ // Naming a check explicitly is a request for that oracle, even under the portable
175
+ // default. Strict policy makes the same promise for every configured verifier.
176
+ const requireEnrichedOracles = config.coverage === 'strict' || opts.checks !== undefined;
169
177
  // a file the change touched that no parser produced a tree for was not reviewed,
170
178
  // whatever the summary says about the ones that were
171
179
  const grounded = new Set([...g.files.map((f) => f.changed.path), ...g.foreign.map((f) => f.path)]);
172
180
  for (const c of changed) {
173
- if (!grounded.has(c.path))
181
+ if (grounded.has(c.path))
182
+ continue;
183
+ if (packFor(c.path))
184
+ plan.fail(c.path, 'declared language parser unavailable');
185
+ else
174
186
  plan.waive(c.path, 'no parser for this language');
175
187
  }
176
188
  // Capabilities belong to files, not runs. A typed file beside one excluded from
177
189
  // tsconfig must not make the latter look checked, and an old Ruby file must not
178
190
  // make a new Python file eligible for a before/after oracle.
179
191
  for (const files of targets.values()) {
180
- for (const file of files)
181
- plan.limit(file.path, file.missing);
182
- }
183
- if (skippedLanguages.length > 0) {
184
- for (const c of changed) {
185
- if (!grounded.has(c.path) && skippedLanguages.includes(packOf(c.path))) {
186
- plan.fail(c.path, 'grammar budget reached');
187
- }
192
+ for (const file of files) {
193
+ const required = file.missing.filter((capability) => !PORTABLE_OPTIONAL.has(capability));
194
+ const enriched = file.missing.filter((capability) => PORTABLE_OPTIONAL.has(capability));
195
+ plan.limit(file.path, required);
196
+ if (requireEnrichedOracles)
197
+ plan.limit(file.path, enriched);
198
+ else
199
+ plan.noteUnavailable(file.path, enriched);
188
200
  }
189
- failures.push('not reviewed, grammar budget reached: ' + skippedLanguages.join(', '));
190
201
  }
191
202
  for (const line of plan.summary())
192
203
  say('selection ' + line);
@@ -201,8 +212,14 @@ export async function review(opts) {
201
212
  const files = targets.get(v);
202
213
  const eligible = files.filter((file) => file.missing.length === 0);
203
214
  const missing = [...new Set(files.flatMap((file) => file.missing))];
215
+ const check = v.id ?? v.name;
216
+ const onlyEnrichedMissing = missing.every((capability) => PORTABLE_OPTIONAL.has(capability));
217
+ if (missing.length > 0 && !requireEnrichedOracles && onlyEnrichedMissing) {
218
+ unavailable.set(check, missing.join(', '));
219
+ }
204
220
  if (missing.length > 0 && eligible.length === 0) {
205
- skipped.set(v.id ?? v.name, missing.join(', '));
221
+ if (requireEnrichedOracles || !onlyEnrichedMissing)
222
+ skipped.set(check, missing.join(', '));
206
223
  continue;
207
224
  }
208
225
  // a scan spends most of its time here, so Ctrl-C has to reach this half
@@ -211,7 +228,6 @@ export async function review(opts) {
211
228
  break;
212
229
  }
213
230
  ran++;
214
- const check = v.id ?? v.name;
215
231
  manifest?.ran(check);
216
232
  for (const file of eligible)
217
233
  plan.checked(file.path, check);
@@ -227,6 +243,10 @@ export async function review(opts) {
227
243
  const names = [...skipped].map(([n, why]) => n + ' (no ' + why + ')');
228
244
  say('skipped ' + names.join(', '));
229
245
  }
246
+ if (unavailable.size > 0) {
247
+ const names = [...unavailable].map(([n, why]) => n + ' (no ' + why + ')');
248
+ say('coverage portable · enriched checks unavailable: ' + names.join(', '));
249
+ }
230
250
  // --checks overrides the config rather than filtering it
231
251
  const isGated = range.from !== undefined || range.commit !== undefined;
232
252
  const judgeCache = opts.cache === false || verifyOnly ? undefined : JudgeCache.open(repo, isGated);
@@ -348,6 +368,7 @@ export async function review(opts) {
348
368
  failures,
349
369
  plan,
350
370
  skippedChecks: [...skipped].map(([check, missing]) => ({ check, missing })),
371
+ unavailableChecks: [...unavailable].map(([check, missing]) => ({ check, missing })),
351
372
  usage: budget.finish(),
352
373
  budgetStop,
353
374
  cancelled: opts.signal?.aborted ?? false,
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';
@@ -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,7 @@ 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/);
895
- assert.match(workflow, /name: Install the target type environment[\s\S]+working-directory: target[\s\S]+npm ci --ignore-scripts/);
965
+ assert.doesNotMatch(workflow, /working-directory: target[\s\S]{0,120}npm ci/);
896
966
  assert.match(workflow, /steps\.review\.outputs\.status == '0' \|\| steps\.review\.outputs\.status == '1'/);
897
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 \}\}/);
898
968
  });
@@ -906,6 +976,10 @@ check('the public action persists judge answers and publishes only a verdict', (
906
976
  assert.match(action, /inline-comments:\s*\n\s+description: [^\n]+\n\s+default: 'false'/);
907
977
  assert.match(action, /Post inline comments[\s\S]+inputs\.inline-comments == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
908
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'/);
909
983
  });
910
984
  check('published CI examples preserve one verdict and its exit status', () => {
911
985
  const action = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'action.yml'), 'utf8');
@@ -914,12 +988,13 @@ check('published CI examples preserve one verdict and its exit status', () => {
914
988
  assert.match(action, /upload-sarif: 'true'/);
915
989
  assert.match(action, /inline-comments: 'true'/);
916
990
  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/);
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/);
919
994
  assert.equal(github.match(/psh review/g)?.length, 1);
920
995
  assert.match(github, /--report markdown=powershot\.md[\s\S]+--report sarif=powershot\.sarif/);
921
996
  assert.match(github, /\|\| STATUS=\$\?[\s\S]+case "\$STATUS"/);
922
- assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.1/);
997
+ assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.2/);
923
998
  assert.equal(gitlab.match(/psh review/g)?.length, 1);
924
999
  assert.match(gitlab, /--format codequality > gl-code-quality-report\.json \|\| STATUS=\$\?/);
925
1000
  assert.match(gitlab, /test "\$STATUS" -le 1 \|\| exit "\$STATUS"/);
@@ -940,6 +1015,15 @@ check('the viewer is one self-contained page', () => {
940
1015
  assert.equal(/<(script|link|img)[^>]+(src|href)="http/.test(html), false); // no network needed
941
1016
  assert.equal((html.match(/class="f /g) ?? []).length, 2);
942
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
+ });
943
1027
  check('the viewer escapes content rather than rendering it', () => {
944
1028
  const nasty = [{ ...sample[0], title: '<img src=x onerror=alert(1)>' }];
945
1029
  const html = viewer(nasty, {
@@ -967,7 +1051,7 @@ check('delegated output distinguishes an empty verdict from malformed data', ()
967
1051
  check('delegate --checks selects only the requested judging brief', () => {
968
1052
  const cfg = {
969
1053
  provider: 'anthropic', model: 'm', verifiers: ['*'], judges: ['*'],
970
- minSeverity: 'low', ignore: [], promptCache: true,
1054
+ minSeverity: 'low', ignore: [], coverage: 'portable', promptCache: true,
971
1055
  };
972
1056
  const brief = delegateBrief(ground([{ path: 'a.ts', after: 'export const a = 1\n' }]), cfg, {
973
1057
  checks: ['intent'], intent: 'add a',
@@ -1736,6 +1820,12 @@ check('judges accept both the plain list and the { enable } form', () => {
1736
1820
  assert.equal(validateConfig({ judges: { enable: ['securty'] } }, KNOWN).length, 1);
1737
1821
  assert.equal(validateConfig({ judges: 'security' }, KNOWN).length, 1); // not a list at all
1738
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
+ });
1739
1829
  console.log('\nsession safety');
1740
1830
  check('a session will not be resumed by a different model than answered it', () => {
1741
1831
  const dir = mkdtempSync(join(tmpdir(), 'psh-ses-'));
@@ -1997,6 +2087,32 @@ check('the manifest accounts for everything it selected', () => {
1997
2087
  // and a failure anywhere means the run is not a verdict
1998
2088
  assert.equal(m2.build({ ...base, failures: ['judge died'] }).state, 'failed');
1999
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
+ });
2000
2116
  check('a manifest that hides an unreached unit is caught as our bug', () => {
2001
2117
  const broken = {
2002
2118
  schema: SCHEMA, id: 'x', operation: 'review', started: '', ended: '',
@@ -2145,6 +2261,7 @@ await checkAsync('per-file limits follow the checks the caller actually selected
2145
2261
  operation: 'scan', target: { requested: {} }, policy: { source: 'default', hash: 'h' },
2146
2262
  engine: { version: '0', tools: false, verifyOnly: true }, files: result.plan?.items() ?? [],
2147
2263
  skippedChecks: result.skippedChecks ?? [],
2264
+ unavailableChecks: result.unavailableChecks ?? [],
2148
2265
  findings: { total: result.findings.length, verified: result.stats.verified, judged: result.stats.judged,
2149
2266
  dismissed: result.stats.dismissed, droppedPosition: 0 },
2150
2267
  usage: result.usage, failures: result.failures,
@@ -2162,6 +2279,131 @@ await checkAsync('per-file limits follow the checks the caller actually selected
2162
2279
  rmSync(dir, { recursive: true, force: true });
2163
2280
  }
2164
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
+ });
2165
2407
  await checkAsync('foreign checks advertise only files their language pack can inspect', async () => {
2166
2408
  const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-pack-coverage-')));
2167
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 manifest = pythonManifest(g.root, dirname(join(g.root, file.path)));
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))