@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.
package/README.md CHANGED
@@ -20,13 +20,14 @@ PowerShot reviews the failure modes that plausible-looking generated code tends
20
20
  hide: invented APIs, undeclared dependencies, dropped guards, swallowed errors,
21
21
  tests that prove nothing, bent expectations, stale callers, and duplicated helpers.
22
22
 
23
- It asks compilers, parsers, manifests, reference graphs, and pre/post ASTs first.
24
- Optional model judges only handle questions that still require judgement.
23
+ It asks self-contained parsers, manifests, and pre/post ASTs first, then uses compiler
24
+ types and reference graphs when the environment can supply them. Optional model judges
25
+ only handle questions that still require judgement.
25
26
 
26
27
  <table>
27
28
  <tr>
28
29
  <td width="33%"><strong>Deterministic first</strong><br>Local checks need no model, key, tokens, or network calls.</td>
29
- <td width="33%"><strong>Honest completion</strong><br>A partial run cannot present itself as a clean verdict.</td>
30
+ <td width="33%"><strong>Honest coverage</strong><br>Portable, full, partial, and failed runs stay distinguishable.</td>
30
31
  <td width="33%"><strong>CI-native output</strong><br>One run emits terminal, Markdown, JSON, SARIF, manifest, and Code Quality reports.</td>
31
32
  </tr>
32
33
  </table>
@@ -89,7 +90,7 @@ flowchart LR
89
90
 
90
91
  1. **Snapshot** resolves the exact tree the review is about.
91
92
  2. **Ground** builds the available type, syntax, dependency, and reference oracles.
92
- 3. **Plan** assigns checks and missing capabilities to each file individually.
93
+ 3. **Plan** assigns baseline checks and enriched semantic capabilities to each file individually.
93
94
  4. **Verify** runs deterministic checks and records what actually executed.
94
95
  5. **Judge** optionally reviews bounded bundles of related files.
95
96
  6. **Manifest** decides whether the result is complete, partial, or failed.
@@ -108,8 +109,11 @@ Every finding says where it came from:
108
109
  | `verified` | `firm` | A deterministic heuristic fired; inspect the evidence |
109
110
  | `judged` | `firm` or `tentative` | A model supplied the judgement and provenance |
110
111
 
111
- PowerShot stays silent when a required oracle is unavailable. The missing capability
112
- is written to the manifest instead of being counted as a pass.
112
+ Portable coverage is the default: self-contained oracles run without bootstrapping the
113
+ reviewed repository, while unavailable compiler/reference depth stays visible in the
114
+ manifest and reports. Set `"coverage": "strict"`, or explicitly select a check with
115
+ `--checks`, when a missing semantic oracle must make the run partial. An unavailable
116
+ oracle is never counted as a pass in either profile.
113
117
 
114
118
  ## Deterministic checks
115
119
 
@@ -174,33 +178,38 @@ commands.
174
178
 
175
179
  ```mermaid
176
180
  flowchart LR
177
- START["Selected files and checks"] --> ACCOUNT{"Everything accounted for?"}
178
- ACCOUNT -- "yes" --> FINDINGS{"Findings?"}
181
+ START["Selected files and checks"] --> ACCOUNT{"Required work accounted for?"}
182
+ ACCOUNT -- "yes" --> DEPTH{"Enriched semantic depth available?"}
183
+ DEPTH -- "yes" --> FULL["full coverage"]
184
+ DEPTH -- "no · portable policy" --> PORTABLE["portable coverage · gaps named"]
185
+ FULL --> FINDINGS{"Findings?"}
186
+ PORTABLE --> FINDINGS
179
187
  FINDINGS -- "no" --> CLEAN["exit 0 · complete and clean"]
180
188
  FINDINGS -- "yes" --> FOUND["exit 1 · complete with findings"]
181
- ACCOUNT -- "capability or budget gap" --> PARTIAL["exit 3 · partial"]
189
+ ACCOUNT -- "required oracle or budget gap" --> PARTIAL["exit 3 · partial"]
182
190
  ACCOUNT -- "required stage failed" --> FAILED["exit 3 · failed"]
183
191
 
184
192
  classDef neutral fill:#172033,stroke:#57a6ff,color:#f0f6fc,stroke-width:2px
185
193
  classDef good fill:#17251f,stroke:#4ac58b,color:#f0f6fc,stroke-width:2px
186
194
  classDef warn fill:#2a2117,stroke:#f2b84b,color:#f0f6fc,stroke-width:2px
187
195
  classDef bad fill:#2a191b,stroke:#ff675c,color:#f0f6fc,stroke-width:2px
188
- class START,ACCOUNT,FINDINGS neutral
196
+ class START,ACCOUNT,DEPTH,FINDINGS neutral
189
197
  class CLEAN good
190
- class FOUND,PARTIAL warn
198
+ class FULL,PORTABLE,FOUND,PARTIAL warn
191
199
  class FAILED bad
192
200
  ```
193
201
 
194
202
  | Exit | Contract |
195
203
  |---:|---|
196
- | `0` | Review completed and found nothing at the selected severity |
197
- | `1` | Review completed and reported findings |
204
+ | `0` | Review completed in full or portable coverage and found nothing at the selected severity |
205
+ | `1` | Review completed in full or portable coverage and reported findings |
198
206
  | `2` | Command or Git input was invalid |
199
207
  | `3` | Review is incomplete; findings may be missing |
200
208
 
201
- “No findings and “PowerShot could not look” are intentionally different pipeline
202
- outcomes. Use `--format manifest` to inspect file dispositions, executed checks,
203
- skips, failures, judge units, and `notLookedAt`.
209
+ “No findings in portable coverage”, “No findings”, and “PowerShot could not look” are
210
+ intentionally different outcomes. Use `--format manifest` to inspect `coverage`, file
211
+ dispositions, executed and unavailable checks, failures, judge units, and
212
+ `notLookedAt`.
204
213
 
205
214
  ## CI integration
206
215
 
@@ -211,8 +220,6 @@ The composite action is the shortest setup for GitHub:
211
220
  with:
212
221
  fetch-depth: 0
213
222
 
214
- - run: npm ci --ignore-scripts
215
-
216
223
  - uses: xcrft/powershot@v1
217
224
  with:
218
225
  verify-only: 'true'
@@ -222,11 +229,11 @@ The composite action is the shortest setup for GitHub:
222
229
  fail-on-findings: 'true'
223
230
  ```
224
231
 
225
- Install the checked-out project's dependencies before PowerShot so TypeScript can
226
- resolve its declared ambient types. The example disables lifecycle scripts because
227
- pull-request code is untrusted; use the equivalent safe install for another package
228
- manager. In a monorepo, install from the workspace root or add safe install steps for
229
- the affected package roots.
232
+ The default portable profile needs no install from the checked-out repository. That is
233
+ the safe default for private monorepos and fork pull requests: do not expose a package
234
+ registry credential merely to enrich a review of untrusted code. If a trusted job
235
+ already has dependencies, PowerShot uses their TypeScript declarations automatically.
236
+ Set `"coverage": "strict"` when missing compiler/reference oracles must block instead.
230
237
 
231
238
  PowerShot discovers `tsconfig.json` and `tsconfig.*.json` along the ancestor chain of
232
239
  each changed file. One review can use several independent package projects, skip
@@ -266,6 +273,7 @@ Anthropic, OpenAI, and Gemini providers are supported.
266
273
  "judges": {
267
274
  "enable": ["plausible-logic", "test-adequacy", "intent"]
268
275
  },
276
+ "coverage": "portable",
269
277
  "minSeverity": "low",
270
278
  "ignore": ["**/generated/**"],
271
279
  "promptCache": true
@@ -300,8 +308,11 @@ psh review --verify-only --absorb /tmp/powershot-findings.json
300
308
  | C | Syntax-backed checks that do not require exception semantics |
301
309
  | Solidity | Declared syntax-backed checks |
302
310
 
303
- PowerShot loads a bounded number of grammars per run. Unsupported or unavailable
304
- coverage is named in the manifest.
311
+ Every declared language is parsed in disposable, language-isolated workers. Sources
312
+ are sent in bounded batches, so a mixed-language monorepo does not accumulate every
313
+ compiled WASM grammar in one process. If a declared parser cannot run, the review
314
+ fails loudly; it is never silently waived. All eleven packs plus TypeScript and
315
+ JavaScript are exercised together by the integration suite.
305
316
 
306
317
  ## Project guide
307
318
 
@@ -1,4 +1,5 @@
1
1
  import { writeFileSync } from 'node:fs';
2
+ import { unavailableCoverage } from '#app/manifest.js';
2
3
  import { dim } from '#app/report/ansi.js';
3
4
  import { codeQuality } from '#app/report/codequality.js';
4
5
  import { compact } from '#app/report/compact.js';
@@ -44,6 +45,8 @@ export function renderReport(format, result, manifest, target) {
44
45
  ...result.stats,
45
46
  state: manifest.state,
46
47
  notLookedAt: manifest.notLookedAt,
48
+ coverage: manifest.coverage,
49
+ unavailableCoverage: unavailableCoverage(manifest),
47
50
  });
48
51
  }
49
52
  export function publishReports(options) {
@@ -5,7 +5,7 @@ import { loadConfig, policyChanged } from '#app/config.js';
5
5
  import { absorbDelegated, delegateBrief } from '#app/delegate.js';
6
6
  import { baseRefOf, checkRange, headSha, repoRoot, shaOf } from '#app/git.js';
7
7
  import { JUDGES } from '#app/judges/prompts.js';
8
- import { RunManifest, coverageProblems, hashOf, writeManifest } from '#app/manifest.js';
8
+ import { RunManifest, coverageProblems, hashOf, unavailableCoverage, writeManifest } from '#app/manifest.js';
9
9
  import { Trace } from '#app/otel.js';
10
10
  import { PACKAGE_VERSION } from '#app/package-meta.js';
11
11
  import { dim, yellow } from '#app/report/ansi.js';
@@ -225,6 +225,7 @@ export async function runReviewCommand(command, values, positionals) {
225
225
  },
226
226
  files: result.plan?.items() ?? [],
227
227
  skippedChecks: result.skippedChecks ?? [],
228
+ unavailableChecks: result.unavailableChecks ?? [],
228
229
  findings: {
229
230
  total: result.findings.length,
230
231
  verified: result.stats.verified,
@@ -246,7 +247,12 @@ export async function runReviewCommand(command, values, positionals) {
246
247
  record.notLookedAt.push(failure);
247
248
  process.stderr.write(yellow(' ◇ manifest') + dim(' ' + gaps.join('; ')) + '\n');
248
249
  }
249
- session?.saveReport(result.findings, { state: record.state, notLookedAt: record.notLookedAt });
250
+ session?.saveReport(result.findings, {
251
+ state: record.state,
252
+ notLookedAt: record.notLookedAt,
253
+ coverage: record.coverage,
254
+ unavailableCoverage: unavailableCoverage(record),
255
+ });
250
256
  writeManifest(root, record);
251
257
  publishReports({
252
258
  format: values.format,
@@ -42,6 +42,8 @@ export function runSessionCommand(positionals) {
42
42
  started: session.started,
43
43
  state: session.report.state ?? 'unknown',
44
44
  notLookedAt: session.report.notLookedAt ?? ['session predates verdict recording'],
45
+ coverage: session.report.coverage,
46
+ unavailableCoverage: session.report.unavailableCoverage,
45
47
  }));
46
48
  process.stdout.write(output + '\n');
47
49
  return 0;
package/dist/config.js CHANGED
@@ -9,6 +9,7 @@ const DEFAULTS = {
9
9
  // security may duplicate SAST; convention needs repository idioms in the diff
10
10
  judges: ['plausible-logic', 'test-adequacy', 'intent'],
11
11
  minSeverity: 'low',
12
+ coverage: 'portable',
12
13
  // findings in vendored trees are not decisions made by this repository
13
14
  ignore: [
14
15
  '**/node_modules/**', '**/dist/**', '**/build/**', '**/*.generated.*', '**/*.min.js',
@@ -33,6 +34,7 @@ export function policyChanged(root, baseRef) {
33
34
  }
34
35
  const KEYS = new Set([...Object.keys(DEFAULTS), 'checks']);
35
36
  const PROVIDERS = new Set(['anthropic', 'openai', 'gemini']);
37
+ const COVERAGE = new Set(['portable', 'strict']);
36
38
  /** A misspelled name in the config is the quietest way to get a clean review. */
37
39
  export function validateConfig(raw, known) {
38
40
  const problems = [];
@@ -50,6 +52,9 @@ export function validateConfig(raw, known) {
50
52
  if (raw.minSeverity !== undefined && !SEVERITIES.includes(raw.minSeverity)) {
51
53
  problems.push('minSeverity "' + String(raw.minSeverity) + '" is not one of: ' + SEVERITIES.join(', '));
52
54
  }
55
+ if (raw.coverage !== undefined && !COVERAGE.has(String(raw.coverage))) {
56
+ problems.push('coverage "' + String(raw.coverage) + '" is not one of: ' + [...COVERAGE].join(', '));
57
+ }
53
58
  for (const [field, names] of [['verifiers', known.verifiers], ['judges', known.judges]]) {
54
59
  const value = raw[field];
55
60
  if (value === undefined)
package/dist/ground.js CHANGED
@@ -2,7 +2,7 @@ import { Project, SyntaxKind } from 'ts-morph';
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
3
  import { decode } from './text.js';
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
- import { packFor, parse } from './lang/packs.js';
5
+ import { PACKS, packFor, parseIsolated } from './lang/packs.js';
6
6
  import { insideRepo, isSymlink, repoPath } from './fspolicy.js';
7
7
  const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
8
8
  const TS_CONFIG = /^tsconfig(?:\..+)?\.json$/i;
@@ -360,7 +360,7 @@ export function readEnvManifest(root) {
360
360
  return undefined;
361
361
  }
362
362
  async function parseForeign(root, changed, signal) {
363
- const out = [];
363
+ const byLanguage = new Map();
364
364
  for (const c of changed) {
365
365
  // parsing thousands of files is where a large scan spends its time, so a signal
366
366
  // has to be honoured here rather than only once the checks begin
@@ -376,13 +376,66 @@ async function parseForeign(root, changed, signal) {
376
376
  // costs seconds to produce findings nobody acts on
377
377
  if ((statSync(abs, { throwIfNoEntry: false })?.size ?? 0) > 512 * 1024)
378
378
  continue;
379
- const tree = await parse(pack, decode(readFileSync(abs)));
380
- if (!tree)
381
- continue;
382
- const beforeTree = c.before === undefined ? undefined : await parse(pack, c.before);
383
- out.push({ path: c.path, pack, tree, beforeTree, changed: c });
379
+ const list = byLanguage.get(pack.name) ?? [];
380
+ list.push({
381
+ changed: c,
382
+ source: decode(readFileSync(abs)),
383
+ // A generated base can be arbitrarily larger than the reviewed result. Do not
384
+ // smuggle it past the current-file limit through the before/after channel.
385
+ beforeSource: c.before !== undefined && Buffer.byteLength(c.before) <= 512 * 1024
386
+ ? c.before
387
+ : undefined,
388
+ });
389
+ byLanguage.set(pack.name, list);
384
390
  }
385
- return out;
391
+ const parsed = new Map();
392
+ // A worker holds one grammar and a bounded source batch. This keeps both WASM
393
+ // compilation and structured-clone payloads independent of monorepo size.
394
+ const MAX_BATCH_BYTES = 8 * 1024 * 1024;
395
+ const MAX_BATCH_FILES = 128;
396
+ for (const pack of PACKS) {
397
+ const candidates = byLanguage.get(pack.name) ?? [];
398
+ for (let start = 0; start < candidates.length;) {
399
+ let end = start;
400
+ let bytes = 0;
401
+ while (end < candidates.length && end - start < MAX_BATCH_FILES) {
402
+ const candidate = candidates[end];
403
+ const next = Buffer.byteLength(candidate.source) + Buffer.byteLength(candidate.beforeSource ?? '');
404
+ if (end > start && bytes + next > MAX_BATCH_BYTES)
405
+ break;
406
+ bytes += next;
407
+ end++;
408
+ }
409
+ const batch = candidates.slice(start, end);
410
+ const sources = batch.flatMap((candidate) => candidate.beforeSource === undefined
411
+ ? [candidate.source]
412
+ : [candidate.source, candidate.beforeSource]);
413
+ const trees = await parseIsolated(pack, sources, signal);
414
+ let index = 0;
415
+ for (const candidate of batch) {
416
+ const tree = trees[index++];
417
+ const beforeTree = candidate.beforeSource === undefined ? undefined : trees[index++];
418
+ if (!tree)
419
+ continue;
420
+ parsed.set(candidate.changed.path, {
421
+ path: candidate.changed.path,
422
+ pack,
423
+ tree,
424
+ beforeTree,
425
+ changed: candidate.changed,
426
+ });
427
+ }
428
+ start = end;
429
+ if (signal?.aborted)
430
+ break;
431
+ }
432
+ if (signal?.aborted)
433
+ break;
434
+ }
435
+ return changed.flatMap((file) => {
436
+ const result = parsed.get(file.path);
437
+ return result ? [result] : [];
438
+ });
386
439
  }
387
440
  function buildSymbolIndex(sourceFiles, root) {
388
441
  const index = new Map();
@@ -1,5 +1,6 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { dirname, join } from 'node:path';
3
+ import { Worker } from 'node:worker_threads';
3
4
  /** Shared defaults; a pack overrides only what its grammar spells differently. */
4
5
  const COMMON_NODES = {
5
6
  identifier: ['identifier'],
@@ -498,15 +499,6 @@ export function packFor(path) {
498
499
  }
499
500
  let ready;
500
501
  const parsers = new Map();
501
- /**
502
- * Measured, not guessed, and the measurement is worth writing down because the naive
503
- * one is misleading. Loading grammars is cheap — all eleven load for ~143MB. Parsing
504
- * with them is not: V8 tiers up each wasm module in the background, and RSS climbed
505
- * 63 → 690MB across eleven before the process died inside that compilation. Six
506
- * grammars sat at ~131MB and were comfortable.
507
- */
508
- const MAX_GRAMMARS = 6;
509
- export const skippedLanguages = [];
510
502
  /**
511
503
  * Grammars load lazily and once. A repository with no Python pays nothing for
512
504
  * Python, and the wasm runtime is only initialised when a foreign file appears.
@@ -515,11 +507,6 @@ async function parserFor(pack) {
515
507
  const cached = parsers.get(pack.name);
516
508
  if (cached)
517
509
  return cached;
518
- if (parsers.size >= MAX_GRAMMARS) {
519
- if (!skippedLanguages.includes(pack.name))
520
- skippedLanguages.push(pack.name);
521
- return undefined;
522
- }
523
510
  try {
524
511
  if (!ready) {
525
512
  ready = (async () => {
@@ -554,4 +541,100 @@ export async function parse(pack, source) {
554
541
  return undefined;
555
542
  }
556
543
  }
544
+ /** Turn a native WASM-backed tree into data that can cross a worker boundary. */
545
+ export function serializeTree(tree) {
546
+ const copy = (raw, field) => {
547
+ const node = raw;
548
+ const children = [];
549
+ for (let i = 0; i < node.childCount; i++) {
550
+ const child = node.child(i);
551
+ if (child)
552
+ children.push(copy(child, node.fieldNameForChild(i) ?? undefined));
553
+ }
554
+ return {
555
+ type: node.type,
556
+ startIndex: node.startIndex,
557
+ endIndex: node.endIndex,
558
+ startPosition: { ...node.startPosition },
559
+ endPosition: { ...node.endPosition },
560
+ named: node.isNamed,
561
+ field,
562
+ children,
563
+ };
564
+ };
565
+ return { root: copy(tree.rootNode) };
566
+ }
567
+ /** Restore the small Node interface the language-independent verifiers consume. */
568
+ function hydrateTree(source, tree) {
569
+ const hydrate = (data) => {
570
+ const children = data.children.map(hydrate);
571
+ return {
572
+ type: data.type,
573
+ get text() { return source.slice(data.startIndex, data.endIndex); },
574
+ startPosition: { ...data.startPosition },
575
+ endPosition: { ...data.endPosition },
576
+ childCount: children.length,
577
+ child: (index) => children[index] ?? null,
578
+ namedChildren: children.filter((_, index) => data.children[index]?.named),
579
+ childForFieldName: (name) => {
580
+ const index = data.children.findIndex((child) => child.field === name);
581
+ return index < 0 ? null : (children[index] ?? null);
582
+ },
583
+ };
584
+ };
585
+ return { rootNode: hydrate(tree.root) };
586
+ }
587
+ /**
588
+ * Parse one language in a disposable worker.
589
+ *
590
+ * V8 keeps compiled WASM grammars alive longer than their JS parsers. Eleven
591
+ * grammars in one process reached ~690MB and killed real mixed-language runs.
592
+ * One worker owns one grammar, returns plain trees, and is then terminated, so
593
+ * compiled-grammar memory is bounded by one language rather than by the monorepo's
594
+ * language count. Plain trees still scale with the selected diff.
595
+ */
596
+ export function parseIsolated(pack, sources, signal) {
597
+ if (sources.length === 0)
598
+ return Promise.resolve([]);
599
+ if (signal?.aborted)
600
+ return Promise.resolve(sources.map(() => undefined));
601
+ return new Promise((resolve) => {
602
+ let worker;
603
+ let settled = false;
604
+ const empty = () => sources.map(() => undefined);
605
+ const finish = (trees) => {
606
+ if (settled)
607
+ return;
608
+ settled = true;
609
+ signal?.removeEventListener('abort', abort);
610
+ // Resolve only after V8 has released this worker's WASM grammar. Otherwise a
611
+ // fast next batch can overlap termination and recreate the memory spike this
612
+ // isolation boundary exists to prevent.
613
+ void worker.terminate().then(() => resolve(trees), () => resolve(trees));
614
+ };
615
+ const abort = () => finish(empty());
616
+ try {
617
+ worker = new Worker(new URL('./parse-worker.js', import.meta.url), {
618
+ workerData: { language: pack.name, sources },
619
+ });
620
+ }
621
+ catch {
622
+ resolve(empty());
623
+ return;
624
+ }
625
+ signal?.addEventListener('abort', abort, { once: true });
626
+ worker.once('message', (raw) => {
627
+ if (!Array.isArray(raw) || raw.length !== sources.length) {
628
+ finish(empty());
629
+ return;
630
+ }
631
+ finish(raw.map((tree, index) => tree ? hydrateTree(sources[index], tree) : undefined));
632
+ });
633
+ worker.once('error', () => finish(empty()));
634
+ worker.once('exit', () => finish(empty()));
635
+ // Close the narrow race between the early check and listener registration.
636
+ if (signal?.aborted)
637
+ abort();
638
+ });
639
+ }
557
640
  //# sourceMappingURL=packs.js.map
@@ -0,0 +1,15 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { PACKS, parse, serializeTree } from './packs.js';
3
+ async function run(input) {
4
+ const pack = PACKS.find((candidate) => candidate.name === input.language);
5
+ if (!pack || !Array.isArray(input.sources))
6
+ return [];
7
+ const trees = [];
8
+ for (const source of input.sources) {
9
+ const tree = await parse(pack, source);
10
+ trees.push(tree ? serializeTree(tree) : undefined);
11
+ }
12
+ return trees;
13
+ }
14
+ void run(workerData).then((trees) => parentPort?.postMessage(trees), () => parentPort?.postMessage([]));
15
+ //# sourceMappingURL=parse-worker.js.map
@@ -135,11 +135,20 @@ const SKIP_DIRS = new Set([
135
135
  'node_modules', '.git', '.venv', 'venv', 'env', '__pycache__', 'dist', 'build',
136
136
  '.mypy_cache', '.pytest_cache', '.tox', 'site-packages', 'target', '.next',
137
137
  ]);
138
- export function localModules(root) {
138
+ /**
139
+ * Importable names near one changed Python file.
140
+ *
141
+ * Only direct entries in its ancestor chain and conventional source roots are read.
142
+ * That bounds work by path depth instead of repository size, while still covering
143
+ * `src/pkg` beside `tests/test_pkg.py` and namespace packages without __init__.py.
144
+ */
145
+ export function localModules(root, from = root) {
139
146
  const local = new Set();
140
- const walk = (dir, depth) => {
141
- if (depth > 6 || local.size > 4000)
147
+ const scanned = new Set();
148
+ const inspect = (dir) => {
149
+ if (scanned.has(dir))
142
150
  return;
151
+ scanned.add(dir);
143
152
  let entries;
144
153
  try {
145
154
  entries = readdirSync(dir, { withFileTypes: true });
@@ -147,21 +156,25 @@ export function localModules(root) {
147
156
  catch {
148
157
  return;
149
158
  }
150
- const isPackage = entries.some((e) => e.isFile() && e.name === '__init__.py');
151
159
  for (const entry of entries) {
152
160
  if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name))
153
161
  continue;
154
162
  if (entry.isDirectory()) {
155
- // a package directory is importable by name; so is a plain source folder
163
+ // Namespace packages are importable without __init__.py too.
156
164
  local.add(entry.name);
157
- walk(join(dir, entry.name), depth + 1);
158
165
  }
159
- else if (entry.name.endsWith('.py') && (isPackage || depth <= 2)) {
166
+ else if (entry.isFile() && entry.name.endsWith('.py') && entry.name !== '__init__.py') {
160
167
  local.add(entry.name.slice(0, -3));
161
168
  }
162
169
  }
163
170
  };
164
- walk(root, 0);
171
+ for (let dir = from;; dir = dirname(dir)) {
172
+ inspect(dir);
173
+ for (const source of ['src', 'lib', 'python'])
174
+ inspect(join(dir, source));
175
+ if (dir === root || dirname(dir) === dir)
176
+ break;
177
+ }
165
178
  return local;
166
179
  }
167
180
  export function isPhantom(importName, manifest, local) {
package/dist/manifest.js CHANGED
@@ -2,6 +2,23 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  export const SCHEMA = 'powershot.run/v1';
5
+ /** Human-readable optional depth, kept separate from verdict-blocking notLookedAt. */
6
+ export function unavailableCoverage(record) {
7
+ const out = [];
8
+ const files = (record.files ?? []).filter((file) => file.unavailable?.length);
9
+ if (files.length > 0) {
10
+ out.push(files.length + ' file(s) without enriched semantic coverage: ' +
11
+ files.slice(0, 5).map((file) => file.path + ' (' + file.unavailable.join(', ') + ')').join(', ') +
12
+ (files.length > 5 ? ', …' : ''));
13
+ }
14
+ const checks = record.checks?.unavailable ?? [];
15
+ if (checks.length > 0) {
16
+ out.push(checks.length + ' enriched check(s) unavailable: ' +
17
+ checks.slice(0, 8).map((check) => check.check + ' (no ' + check.missing + ')').join(', ') +
18
+ (checks.length > 8 ? ', …' : ''));
19
+ }
20
+ return out;
21
+ }
5
22
  /** The single state machine behind manifests, benches, renderers and exit codes. */
6
23
  export function completionOf(parts) {
7
24
  const waivedUnits = parts.units.filter((unit) => unit.outcome === 'waived').length;
@@ -65,12 +82,20 @@ export class RunManifest {
65
82
  ...file,
66
83
  checks: [...file.checks],
67
84
  missing: file.missing ? [...file.missing] : undefined,
85
+ unavailable: file.unavailable ? [...file.unavailable] : undefined,
68
86
  })),
69
87
  units: this.units.map((unit) => ({ ...unit })),
70
88
  checks: {
71
89
  ran: [...this.ranChecks],
72
90
  skipped: parts.skippedChecks.map((check) => ({ ...check })),
91
+ ...((parts.unavailableChecks?.length ?? 0) > 0
92
+ ? { unavailable: parts.unavailableChecks.map((check) => ({ ...check })) }
93
+ : {}),
73
94
  },
95
+ coverage: parts.files.some((file) => file.missing?.length || file.unavailable?.length) ||
96
+ parts.skippedChecks.length > 0 || (parts.unavailableChecks?.length ?? 0) > 0
97
+ ? 'portable'
98
+ : 'full',
74
99
  findings: { ...parts.findings },
75
100
  usage: { ...parts.usage },
76
101
  state: completion.state,
@@ -118,6 +143,15 @@ export function coverageProblems(m) {
118
143
  if (f.disposition !== 'selected' && f.checks.length > 0) {
119
144
  problems.push(f.path + ': ' + f.disposition + ' file received checks');
120
145
  }
146
+ if (f.disposition !== 'selected' && f.unavailable?.length) {
147
+ problems.push(f.path + ': ' + f.disposition + ' file has unavailable coverage');
148
+ }
149
+ const missingCaps = new Set(f.missing ?? []);
150
+ for (const capability of f.unavailable ?? []) {
151
+ if (missingCaps.has(capability)) {
152
+ problems.push(f.path + ': capability is both required and unavailable: ' + capability);
153
+ }
154
+ }
121
155
  const local = new Set();
122
156
  for (const check of f.checks) {
123
157
  if (local.has(check))
@@ -157,6 +191,21 @@ export function coverageProblems(m) {
157
191
  if (ran.has(check.check))
158
192
  problems.push('check counted as both ran and skipped: ' + check.check);
159
193
  }
194
+ const unavailable = new Set();
195
+ for (const check of m.checks.unavailable ?? []) {
196
+ if (unavailable.has(check.check))
197
+ problems.push('check counted twice as unavailable: ' + check.check);
198
+ unavailable.add(check.check);
199
+ if (skipped.has(check.check))
200
+ problems.push('check counted as both skipped and unavailable: ' + check.check);
201
+ }
202
+ const expectedCoverage = m.files.some((file) => file.missing?.length || file.unavailable?.length) ||
203
+ m.checks.skipped.length > 0 || (m.checks.unavailable?.length ?? 0) > 0
204
+ ? 'portable'
205
+ : 'full';
206
+ if (m.coverage !== undefined && m.coverage !== expectedCoverage) {
207
+ problems.push('coverage is ' + m.coverage + ' but accounting says ' + expectedCoverage);
208
+ }
160
209
  // a judged run that reports complete must have reached every unit it selected
161
210
  const unreached = m.units.filter((u) => u.outcome === 'failed' || u.outcome === 'waived');
162
211
  if (m.state === 'complete' && unreached.length > 0) {
package/dist/plan.js CHANGED
@@ -67,6 +67,13 @@ export class SelectionPlan {
67
67
  row.missing = [...new Set([...(row.missing ?? []), ...missing])];
68
68
  }
69
69
  }
70
+ /** Record optional semantic depth that this environment could not provide. */
71
+ noteUnavailable(path, unavailable) {
72
+ const row = this.rows.get(path);
73
+ if (row && row.disposition === 'selected' && unavailable.length > 0) {
74
+ row.unavailable = [...new Set([...(row.unavailable ?? []), ...unavailable])];
75
+ }
76
+ }
70
77
  /** Record coverage at the same file granularity used to decide applicability. */
71
78
  checked(path, check) {
72
79
  const row = this.rows.get(path);
@@ -1,3 +1,4 @@
1
+ import { unavailableCoverage } from '#app/manifest.js';
1
2
  const MARK = { verified: '▣', judged: '▚' };
2
3
  /** Untrusted prose encoded as literal CommonMark text. */
3
4
  function text(s) {
@@ -63,12 +64,27 @@ export function markdown(findings, run) {
63
64
  '',
64
65
  ]
65
66
  : [];
67
+ const portable = run?.state === 'complete' && run.coverage === 'portable';
68
+ const coverage = portable
69
+ ? [
70
+ '> [!NOTE]',
71
+ '> **Portable coverage.** Self-contained oracles ran; enriched semantic depth was unavailable:',
72
+ ...unavailableCoverage(run).map((reason) => '> - ' + text(reason)),
73
+ '',
74
+ ]
75
+ : [];
66
76
  if (findings.length === 0) {
67
- return ['## PowerShot', '', ...banner, incomplete ? 'No findings *from what it managed to review*.' : 'No findings.', ''].join('\n');
77
+ return [
78
+ '## PowerShot', '', ...banner, ...coverage,
79
+ incomplete
80
+ ? 'No findings *from what it managed to review*.'
81
+ : portable ? 'No findings in portable coverage.' : 'No findings.',
82
+ '',
83
+ ].join('\n');
68
84
  }
69
85
  const verified = findings.filter((f) => f.class === 'verified').length;
70
86
  const judged = findings.length - verified;
71
- const out = ['## PowerShot', '', ...banner];
87
+ const out = ['## PowerShot', '', ...banner, ...coverage];
72
88
  out.push('**' + verified + ' verified** (deterministic, 0 tokens) · **' + judged + ' judged** (agent)', '');
73
89
  for (const [file, list] of group(findings)) {
74
90
  out.push('### `' + path(file) + '`', '');