@0xcraft/powershot 1.1.1 → 1.1.3

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.
@@ -0,0 +1,149 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { stripControl } from '#app/text.js';
5
+ import { expectedHeadShaFromEnvironment, githubPullRequestApiFromEnvironment, requiredEnvironment, } from './api.js';
6
+ export const LEGACY_SUMMARY_MARKER = '<!-- powershot:summary:v1 -->';
7
+ const BOT_LOGIN = 'github-actions[bot]';
8
+ const LEGACY_HEADING = /^## PowerShot(?:\r?\n|$)/;
9
+ /** Scope ownership to one workflow job without exposing its repository path in the comment. */
10
+ export function summaryMarker(scope) {
11
+ if (scope.length === 0)
12
+ throw new Error('PowerShot summary scope is required');
13
+ const digest = createHash('sha256').update(scope).digest('hex').slice(0, 24);
14
+ return `<!-- powershot:summary:v2:${digest} -->`;
15
+ }
16
+ /** Stable workflow identity: keep the repository/path, discard its moving ref. */
17
+ export function workflowCommentScope(workflowRef, job) {
18
+ const structuralSeparator = workflowRef.indexOf('@refs/');
19
+ const separator = structuralSeparator === -1 ? workflowRef.lastIndexOf('@') : structuralSeparator;
20
+ if (separator < 1 || separator === workflowRef.length - 1) {
21
+ throw new Error('GITHUB_WORKFLOW_REF must contain a workflow path and ref');
22
+ }
23
+ if (job.length === 0)
24
+ throw new Error('GITHUB_JOB is required');
25
+ return `${workflowRef.slice(0, separator)}:${job}`;
26
+ }
27
+ function headMarker(headSha) {
28
+ return `<!-- powershot:head:${headSha.toLowerCase()} -->`;
29
+ }
30
+ export function summaryCommentBody(markdown, marker, headSha) {
31
+ const report = markdown.trimEnd();
32
+ const ownership = `${marker}\n${headMarker(headSha)}`;
33
+ return report.length === 0 ? ownership : `${ownership}\n\n${report}`;
34
+ }
35
+ function owns(body, marker) {
36
+ return body === marker || body.startsWith(marker + '\n') || body.startsWith(marker + '\r\n');
37
+ }
38
+ function ownsHead(body, marker, headSha) {
39
+ const ownership = `${marker}\n${headMarker(headSha)}`;
40
+ return body === ownership || body.startsWith(ownership + '\n') || body.startsWith(ownership + '\r\n');
41
+ }
42
+ function latest(comments) {
43
+ return comments.reduce((selected, comment) => selected === undefined || comment.id > selected.id ? comment : selected, undefined);
44
+ }
45
+ async function retireComments(api, comments, keepId) {
46
+ const staleIds = comments
47
+ .filter((comment) => comment.id !== keepId)
48
+ .map((comment) => comment.id)
49
+ .filter((id, index, ids) => ids.indexOf(id) === index)
50
+ .sort((left, right) => left - right);
51
+ for (const id of staleIds)
52
+ await api.deleteIssueComment(id);
53
+ return staleIds.length;
54
+ }
55
+ async function retireCreatedIfUnchanged(api, id, body) {
56
+ const created = (await api.listIssueComments())
57
+ .find((comment) => comment.id === id && comment.body === body);
58
+ if (created === undefined)
59
+ return 0;
60
+ await api.deleteIssueComment(id);
61
+ return 1;
62
+ }
63
+ async function reconcileCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments, createdByThisRun, legacy) {
64
+ // REST has no atomic create-if-absent. Relisting makes same-head participants
65
+ // converge without allowing an old-head run to patch or retire a newer candidate.
66
+ const reconciled = (await api.listIssueComments()).filter((comment) => comment.user?.login === BOT_LOGIN &&
67
+ owns(comment.body, marker) &&
68
+ ownsHead(comment.body, marker, expectedHeadSha));
69
+ if (await api.headSha() !== expectedHeadSha) {
70
+ const retired = createdByThisRun === undefined
71
+ ? 0
72
+ : await retireCreatedIfUnchanged(api, createdByThisRun.id, body);
73
+ return { state: 'outdated', retired };
74
+ }
75
+ const keep = latest(reconciled);
76
+ if (keep === undefined)
77
+ throw new Error('GitHub API did not return the summary comment it created');
78
+ const retired = await retireComments(api, [...reconciled, ...previousHeadComments, ...(legacy === undefined ? [] : [legacy])], keep.id);
79
+ if (await api.headSha() !== expectedHeadSha) {
80
+ const createdRetired = createdByThisRun === undefined
81
+ ? 0
82
+ : await retireCreatedIfUnchanged(api, createdByThisRun.id, body);
83
+ return { state: 'outdated', retired: retired + createdRetired };
84
+ }
85
+ return { state, commentId: keep.id, retired };
86
+ }
87
+ async function createCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments, legacy) {
88
+ const created = await api.createIssueComment(body);
89
+ if (await api.headSha() !== expectedHeadSha) {
90
+ const retired = await retireCreatedIfUnchanged(api, created.id, body);
91
+ return { state: 'outdated', retired };
92
+ }
93
+ return reconcileCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments, created, legacy);
94
+ }
95
+ /** Reconcile only this workflow's PowerShot summary against the current pull-request head. */
96
+ export async function syncSummaryComment(api, markdown, expectedHeadSha, scope) {
97
+ if (await api.headSha() !== expectedHeadSha)
98
+ return { state: 'outdated', retired: 0 };
99
+ const marker = summaryMarker(scope);
100
+ const body = summaryCommentBody(markdown, marker, expectedHeadSha);
101
+ const comments = await api.listIssueComments();
102
+ if (await api.headSha() !== expectedHeadSha)
103
+ return { state: 'outdated', retired: 0 };
104
+ const botComments = comments.filter((comment) => comment.user?.login === BOT_LOGIN);
105
+ const markedComments = botComments.filter((comment) => owns(comment.body, marker));
106
+ const currentHeadComments = markedComments.filter((comment) => ownsHead(comment.body, marker, expectedHeadSha));
107
+ const previousHeadComments = markedComments.filter((comment) => !ownsHead(comment.body, marker, expectedHeadSha));
108
+ const candidate = latest(currentHeadComments);
109
+ if (candidate !== undefined) {
110
+ const state = candidate.body === body ? 'unchanged' : 'updated';
111
+ if (state === 'updated') {
112
+ // A comment id never changes head ownership. This PATCH can race only with
113
+ // another run for the same head, never with a newer pull-request head.
114
+ await api.updateIssueComment(candidate.id, body);
115
+ if (await api.headSha() !== expectedHeadSha)
116
+ return { state: 'outdated', retired: 0 };
117
+ }
118
+ return reconcileCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments);
119
+ }
120
+ // Legacy comments have no workflow or head identity, so claiming one with
121
+ // PATCH would let two workflows overwrite each other. Create the scoped,
122
+ // head-owned replacement first and retire only the legacy snapshot later.
123
+ const legacy = latest(botComments.filter((comment) => owns(comment.body, LEGACY_SUMMARY_MARKER) || LEGACY_HEADING.test(comment.body)));
124
+ if (legacy !== undefined) {
125
+ return createCandidate(api, marker, body, expectedHeadSha, 'migrated', previousHeadComments, legacy);
126
+ }
127
+ return createCandidate(api, marker, body, expectedHeadSha, 'created', previousHeadComments);
128
+ }
129
+ function oneLine(value, limit) {
130
+ return stripControl(value).replace(/\r?\n/g, ' ').slice(0, limit);
131
+ }
132
+ async function main() {
133
+ const result = await syncSummaryComment(githubPullRequestApiFromEnvironment(), await readFile('powershot.md', 'utf8'), expectedHeadShaFromEnvironment(), workflowCommentScope(requiredEnvironment('GITHUB_WORKFLOW_REF'), requiredEnvironment('GITHUB_JOB')));
134
+ if (result.state === 'outdated') {
135
+ process.stdout.write('PowerShot skipped the summary because the pull request head changed.\n');
136
+ return;
137
+ }
138
+ const retired = result.retired === 0 ? '' : ` ${result.retired} duplicate(s) retired.`;
139
+ process.stdout.write(`PowerShot summary comment: ${result.state}.${retired}\n`);
140
+ }
141
+ const entry = process.argv[1];
142
+ if (entry !== undefined && import.meta.url === pathToFileURL(entry).href) {
143
+ main().catch((error) => {
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ process.stderr.write('PowerShot summary comment failed: ' + oneLine(message, 1_000) + '\n');
146
+ process.exitCode = 1;
147
+ });
148
+ }
149
+ //# sourceMappingURL=summary-comment.js.map
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
@@ -65,12 +65,20 @@ export class RunManifest {
65
65
  ...file,
66
66
  checks: [...file.checks],
67
67
  missing: file.missing ? [...file.missing] : undefined,
68
+ unavailable: file.unavailable ? [...file.unavailable] : undefined,
68
69
  })),
69
70
  units: this.units.map((unit) => ({ ...unit })),
70
71
  checks: {
71
72
  ran: [...this.ranChecks],
72
73
  skipped: parts.skippedChecks.map((check) => ({ ...check })),
74
+ ...((parts.unavailableChecks?.length ?? 0) > 0
75
+ ? { unavailable: parts.unavailableChecks.map((check) => ({ ...check })) }
76
+ : {}),
73
77
  },
78
+ coverage: parts.files.some((file) => file.missing?.length || file.unavailable?.length) ||
79
+ parts.skippedChecks.length > 0 || (parts.unavailableChecks?.length ?? 0) > 0
80
+ ? 'portable'
81
+ : 'full',
74
82
  findings: { ...parts.findings },
75
83
  usage: { ...parts.usage },
76
84
  state: completion.state,
@@ -118,6 +126,15 @@ export function coverageProblems(m) {
118
126
  if (f.disposition !== 'selected' && f.checks.length > 0) {
119
127
  problems.push(f.path + ': ' + f.disposition + ' file received checks');
120
128
  }
129
+ if (f.disposition !== 'selected' && f.unavailable?.length) {
130
+ problems.push(f.path + ': ' + f.disposition + ' file has unavailable coverage');
131
+ }
132
+ const missingCaps = new Set(f.missing ?? []);
133
+ for (const capability of f.unavailable ?? []) {
134
+ if (missingCaps.has(capability)) {
135
+ problems.push(f.path + ': capability is both required and unavailable: ' + capability);
136
+ }
137
+ }
121
138
  const local = new Set();
122
139
  for (const check of f.checks) {
123
140
  if (local.has(check))
@@ -157,6 +174,21 @@ export function coverageProblems(m) {
157
174
  if (ran.has(check.check))
158
175
  problems.push('check counted as both ran and skipped: ' + check.check);
159
176
  }
177
+ const unavailable = new Set();
178
+ for (const check of m.checks.unavailable ?? []) {
179
+ if (unavailable.has(check.check))
180
+ problems.push('check counted twice as unavailable: ' + check.check);
181
+ unavailable.add(check.check);
182
+ if (skipped.has(check.check))
183
+ problems.push('check counted as both skipped and unavailable: ' + check.check);
184
+ }
185
+ const expectedCoverage = m.files.some((file) => file.missing?.length || file.unavailable?.length) ||
186
+ m.checks.skipped.length > 0 || (m.checks.unavailable?.length ?? 0) > 0
187
+ ? 'portable'
188
+ : 'full';
189
+ if (m.coverage !== undefined && m.coverage !== expectedCoverage) {
190
+ problems.push('coverage is ' + m.coverage + ' but accounting says ' + expectedCoverage);
191
+ }
160
192
  // a judged run that reports complete must have reached every unit it selected
161
193
  const unreached = m.units.filter((u) => u.outcome === 'failed' || u.outcome === 'waived');
162
194
  if (m.state === 'complete' && unreached.length > 0) {
@@ -64,8 +64,12 @@ try {
64
64
  throw new Error('architecture guide is missing');
65
65
  if (!existsSync(join(installed, 'docs', 'ci.md')))
66
66
  throw new Error('CI guide is missing');
67
+ if (!existsSync(join(installed, 'dist', 'github', 'api.js')))
68
+ throw new Error('GitHub REST runtime is missing');
67
69
  if (!existsSync(join(installed, 'dist', 'github', 'inline-comments.js')))
68
70
  throw new Error('inline review runtime is missing');
71
+ if (!existsSync(join(installed, 'dist', 'github', 'summary-comment.js')))
72
+ throw new Error('summary comment runtime is missing');
69
73
  if (!existsSync(join(installed, 'examples', 'github-actions', 'cli.yml')))
70
74
  throw new Error('CI example is missing');
71
75
  if (!existsSync(join(installed, 'examples', 'gitlab', '.gitlab-ci.yml')))
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 { modeNote, noFindingsLabel, scopeLine, } from './summary.js';
1
2
  const MARK = { verified: '▣', judged: '▚' };
2
3
  /** Untrusted prose encoded as literal CommonMark text. */
3
4
  function text(s) {
@@ -50,6 +51,26 @@ function group(findings) {
50
51
  list.sort((a, b) => a.line - b.line);
51
52
  return out;
52
53
  }
54
+ function runSummary(run) {
55
+ const out = [];
56
+ const scope = scopeLine(run);
57
+ const mode = modeNote(run, '`verify-only`');
58
+ const details = run.state === 'complete'
59
+ ? run.scopeDetails ?? []
60
+ : [...run.notLookedAt, ...(run.scopeDetails ?? [])];
61
+ if (scope)
62
+ out.push(scope, '');
63
+ if (mode)
64
+ out.push(mode, '');
65
+ if (details.length > 0) {
66
+ out.push('<details>', '<summary>' +
67
+ (run.state !== 'complete'
68
+ ? 'Why this is not a verdict'
69
+ : run.coverage === 'portable' ? 'Coverage details' : 'Review scope') +
70
+ '</summary>', '', ...details.map((detail) => '- ' + text(detail)), '', '</details>', '');
71
+ }
72
+ return out;
73
+ }
53
74
  export function markdown(findings, run) {
54
75
  // "No findings" from a run that could not look is the one thing this must never
55
76
  // say on its own — the reader takes a comment at face value, and a red job beside
@@ -58,18 +79,25 @@ export function markdown(findings, run) {
58
79
  const banner = incomplete
59
80
  ? [
60
81
  '> [!WARNING]',
61
- '> **This review is ' + text(run.state) + ' — not a verdict.** Something was not looked at:',
62
- ...run.notLookedAt.slice(0, 8).map((f) => '> - ' + text(f)),
82
+ '> **This review is ' + text(run.state) + ' — not a verdict.** Some files or checks were not reviewed.',
63
83
  '',
64
84
  ]
65
85
  : [];
86
+ const summary = run ? runSummary(run) : [];
66
87
  if (findings.length === 0) {
67
- return ['## PowerShot', '', ...banner, incomplete ? 'No findings *from what it managed to review*.' : 'No findings.', ''].join('\n');
88
+ return [
89
+ '## PowerShot', '', ...banner,
90
+ incomplete
91
+ ? 'No findings *from what it managed to review*.'
92
+ : run ? '✅ **' + noFindingsLabel(run) + '**' : 'No findings.',
93
+ '', ...summary,
94
+ ].join('\n');
68
95
  }
69
96
  const verified = findings.filter((f) => f.class === 'verified').length;
70
97
  const judged = findings.length - verified;
71
98
  const out = ['## PowerShot', '', ...banner];
72
99
  out.push('**' + verified + ' verified** (deterministic, 0 tokens) · **' + judged + ' judged** (agent)', '');
100
+ out.push(...summary);
73
101
  for (const [file, list] of group(findings)) {
74
102
  out.push('### `' + path(file) + '`', '');
75
103
  for (const f of list) {