@0xcraft/powershot 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ground.js CHANGED
@@ -4,6 +4,7 @@ import { decode } from './text.js';
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { PACKS, packFor, parseIsolated } from './lang/packs.js';
6
6
  import { insideRepo, isSymlink, repoPath } from './fspolicy.js';
7
+ import { createReinventionScopeResolver, typescriptImplementationFingerprint } from './reinvention.js';
7
8
  const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
8
9
  const TS_CONFIG = /^tsconfig(?:\..+)?\.json$/i;
9
10
  const MISSING_TYPE_PREFIXES = [
@@ -289,7 +290,7 @@ export async function buildGround(root, changed, signal) {
289
290
  beforeProject,
290
291
  changed,
291
292
  files,
292
- symbolIndex: buildSymbolIndex(sourceFiles, root),
293
+ symbolIndex: buildSymbolIndex(sourceFiles, root, changed, beforeProject),
293
294
  deps: depsFor(join(root, 'x.ts')),
294
295
  depsFor,
295
296
  typed,
@@ -437,8 +438,27 @@ async function parseForeign(root, changed, signal) {
437
438
  return result ? [result] : [];
438
439
  });
439
440
  }
440
- function buildSymbolIndex(sourceFiles, root) {
441
+ function buildSymbolIndex(sourceFiles, root, changed, beforeProject) {
441
442
  const index = new Map();
443
+ const changes = new Map(changed.map((file) => [file.path, file]));
444
+ const scopeFor = createReinventionScopeResolver(root);
445
+ const relevantNames = new Set();
446
+ for (const sf of sourceFiles) {
447
+ const rel = repoPath(root, String(sf.getFilePath()));
448
+ if (!changes.has(rel))
449
+ continue;
450
+ for (const declaration of sf.getFunctions()) {
451
+ const name = declaration.getName();
452
+ if (name)
453
+ relevantNames.add(normalizeName(name));
454
+ }
455
+ for (const declaration of sf.getVariableDeclarations()) {
456
+ const initializer = declaration.getInitializer();
457
+ if (initializer?.isKind(SyntaxKind.ArrowFunction) || initializer?.isKind(SyntaxKind.FunctionExpression)) {
458
+ relevantNames.add(normalizeName(declaration.getName()));
459
+ }
460
+ }
461
+ }
442
462
  for (const sf of sourceFiles) {
443
463
  const path = String(sf.getFilePath());
444
464
  // the project glob follows symlinked directories, so what it loaded is not
@@ -446,26 +466,47 @@ function buildSymbolIndex(sourceFiles, root) {
446
466
  if (path.includes('/node_modules/') || !insideRepo(root, path))
447
467
  continue;
448
468
  for (const [name, decls] of sf.getExportedDeclarations()) {
469
+ const key = normalizeName(name);
470
+ // Fingerprint only names the change could have introduced. This keeps index
471
+ // construction proportional to the diff even when the project closure is a
472
+ // very large monorepo.
473
+ if (!relevantNames.has(key))
474
+ continue;
449
475
  const decl = decls[0];
450
476
  if (!decl)
451
477
  continue;
452
478
  // only index things that could plausibly be reimplemented
453
479
  const kind = decl.getKind();
454
480
  if (kind !== SyntaxKind.FunctionDeclaration &&
455
- kind !== SyntaxKind.VariableDeclaration &&
456
- kind !== SyntaxKind.ClassDeclaration)
481
+ kind !== SyntaxKind.VariableDeclaration)
482
+ continue;
483
+ const fingerprint = typescriptImplementationFingerprint(decl);
484
+ if (!fingerprint)
485
+ continue;
486
+ // A barrel alias can be new in this change even when its underlying callable
487
+ // predates it. Index the declaration from its own module, where both its name
488
+ // and base existence can be proved, rather than manufacturing history for the
489
+ // new alias or recording every `export *` as another copy.
490
+ if (decl.getSourceFile() !== sf)
457
491
  continue;
458
- // a barrel re-exports another module's symbol, so record where it is actually
459
- // declared — otherwise `export * from './x'` makes every symbol look duplicated
460
492
  const declPath = String(decl.getSourceFile().getFilePath());
461
493
  if (declPath.includes('/node_modules/') || !insideRepo(root, declPath))
462
494
  continue;
463
495
  const rel = repoPath(root, declPath);
464
- const key = normalizeName(name);
496
+ const change = changes.get(rel);
497
+ const before = change?.before === undefined ? undefined : beforeProject.getSourceFile('/before/' + rel);
498
+ const existedInBase = change === undefined || (before?.getExportedDeclarations().get(name) ?? []).some((baseDeclaration) => typescriptImplementationFingerprint(baseDeclaration) === fingerprint);
465
499
  const list = index.get(key) ?? [];
466
500
  if (list.some((e) => e.file === rel && e.line === decl.getStartLineNumber()))
467
501
  continue;
468
- list.push({ file: rel, name, line: decl.getStartLineNumber() });
502
+ list.push({
503
+ file: rel,
504
+ name,
505
+ line: decl.getStartLineNumber(),
506
+ fingerprint,
507
+ existedInBase,
508
+ scope: scopeFor(rel),
509
+ });
469
510
  index.set(key, list);
470
511
  }
471
512
  }
package/dist/langtest.js CHANGED
@@ -8,8 +8,9 @@
8
8
  */
9
9
  import assert from 'node:assert/strict';
10
10
  import { execFileSync } from 'node:child_process';
11
+ import { Project } from 'ts-morph';
11
12
  import { PACKS, parse } from './lang/packs.js';
12
- import { tokensFor } from './verifiers/foreign.js';
13
+ import { foreignReinvented, tokensFor } from './verifiers/foreign.js';
13
14
  /** each fixture: a handler that discards, one that genuinely handles, one explained */
14
15
  const FIXTURES = {
15
16
  python: 'def f():\n try:\n a()\n except Exception:\n pass\n try:\n b()\n except Exception as e:\n raise RuntimeError("x") from e\n try:\n c()\n except Exception:\n pass # deliberate\n',
@@ -32,6 +33,70 @@ const HANDLED = {
32
33
  rust: 'return Err(e)', cpp: 'throw;', 'c#': 'throw;', php: 'throw $e',
33
34
  kotlin: 'throw e', ruby: 'raise', solidity: 'revert("x")',
34
35
  };
36
+ const REINVENTED_NAME = {
37
+ python: ['def f():', 'def normalize_payload():'],
38
+ go: ['func F()', 'func NormalizePayload()'],
39
+ java: ['class A', 'class NormalizePayload'],
40
+ rust: ['fn f()', 'fn normalize_payload()'],
41
+ cpp: ['void f()', 'void normalizePayload()'],
42
+ c: ['int f(', 'int normalizePayload('],
43
+ 'c#': ['class A', 'class NormalizePayload'],
44
+ php: ['function f()', 'function normalizePayload()'],
45
+ kotlin: ['fun f()', 'fun normalizePayload()'],
46
+ ruby: ['def f\n', 'def normalize_payload\n'],
47
+ solidity: ['contract A', 'contract NormalizePayload'],
48
+ };
49
+ const REINVENTED_MUTATION = {
50
+ python: ['a()', 'z()'],
51
+ go: ['a()', 'z()'],
52
+ java: ['a()', 'z()'],
53
+ rust: ['a()', 'z()'],
54
+ cpp: ['a()', 'z()'],
55
+ c: ['return n;', 'return n + 1;'],
56
+ 'c#': ['A()', 'Z()'],
57
+ php: ['a()', 'z()'],
58
+ kotlin: ['a()', 'z()'],
59
+ ruby: [' a\n', ' z\n'],
60
+ solidity: ['a()', 'z()'],
61
+ };
62
+ function allLines(source) {
63
+ return new Set(Array.from({ length: source.split('\n').length }, (_, index) => index + 1));
64
+ }
65
+ async function foreignReinventionGround(pack, existingSource, addedSource, existingBeforeSource = existingSource, addedBeforeSource) {
66
+ const extension = pack.extensions[0];
67
+ const existingPath = 'z-existing' + extension;
68
+ const addedPath = 'a-new' + extension;
69
+ const existingTree = await parse(pack, existingSource);
70
+ const addedTree = await parse(pack, addedSource);
71
+ const beforeTree = existingBeforeSource === null ? undefined : await parse(pack, existingBeforeSource);
72
+ const addedBeforeTree = addedBeforeSource === undefined ? undefined : await parse(pack, addedBeforeSource);
73
+ if (!existingTree || !addedTree)
74
+ throw new Error('fixture did not parse');
75
+ const existingChange = {
76
+ path: existingPath,
77
+ added: existingBeforeSource === null ? allLines(existingSource) : new Set(),
78
+ before: existingBeforeSource ?? undefined,
79
+ };
80
+ const addedChange = { path: addedPath, added: allLines(addedSource), before: addedBeforeSource };
81
+ const foreign = [
82
+ { path: existingPath, pack, tree: existingTree, beforeTree, changed: existingChange },
83
+ { path: addedPath, pack, tree: addedTree, beforeTree: addedBeforeTree, changed: addedChange },
84
+ ];
85
+ return {
86
+ root: '/virtual/repo',
87
+ sourceFiles: [],
88
+ configFiles: [],
89
+ beforeProject: new Project({ useInMemoryFileSystem: true }),
90
+ changed: [existingChange, addedChange],
91
+ files: [],
92
+ symbolIndex: new Map(),
93
+ deps: new Set(),
94
+ depsFor: () => new Set(),
95
+ typed: false,
96
+ internalPrefixes: [],
97
+ foreign,
98
+ };
99
+ }
35
100
  async function one(name) {
36
101
  const pack = PACKS.find((p) => p.name === name);
37
102
  if (!pack) {
@@ -79,6 +144,33 @@ async function one(name) {
79
144
  assert.equal(hits.some((h) => h.node.text.includes(HANDLED[name])), false, 'reported a real handler');
80
145
  });
81
146
  }
147
+ if (source && tree) {
148
+ const rename = REINVENTED_NAME[name];
149
+ const mutation = REINVENTED_MUTATION[name];
150
+ assert.ok(rename && mutation, 'no reinvention fixture written');
151
+ const existing = source.replace(rename[0], rename[1]);
152
+ const different = existing.replace(mutation[0], mutation[1]);
153
+ const differentGround = await foreignReinventionGround(pack, existing, different);
154
+ const identicalGround = await foreignReinventionGround(pack, existing, existing);
155
+ const bothNewGround = await foreignReinventionGround(pack, existing, existing, null);
156
+ const candidateChangedGround = await foreignReinventionGround(pack, existing, existing, different);
157
+ const targetPreexistingGround = await foreignReinventionGround(pack, existing, existing, existing, existing);
158
+ check('reinvented ignores a same-name different implementation', () => {
159
+ assert.equal(foreignReinvented.run(differentGround).length, 0);
160
+ });
161
+ check('reinvented detects an exact implementation already present in the base', () => {
162
+ assert.ok(foreignReinvented.run(identicalGround).length >= 1);
163
+ });
164
+ check('reinvented does not compare two declarations both added by the change', () => {
165
+ assert.equal(foreignReinvented.run(bothNewGround).length, 0);
166
+ });
167
+ check('reinvented ignores a candidate that only became equivalent in this change', () => {
168
+ assert.equal(foreignReinvented.run(candidateChangedGround).length, 0);
169
+ });
170
+ check('reinvented ignores an implementation already present in the changed file', () => {
171
+ assert.equal(foreignReinvented.run(targetPreexistingGround).length, 0);
172
+ });
173
+ }
82
174
  return failed;
83
175
  }
84
176
  /** Signature comparison is Python-only for now, and is what decides a contract break. */
package/dist/manifest.js CHANGED
@@ -2,23 +2,6 @@ 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
- }
22
5
  /** The single state machine behind manifests, benches, renderers and exit codes. */
23
6
  export function completionOf(parts) {
24
7
  const waivedUnits = parts.units.filter((unit) => unit.outcome === 'waived').length;
@@ -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')))
@@ -0,0 +1,109 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { Node, ts, } from 'ts-morph';
5
+ import { insideRepo, repoPath } from './fspolicy.js';
6
+ const SCOPE_FILES = [
7
+ 'package.json',
8
+ 'pyproject.toml', 'setup.py', 'setup.cfg',
9
+ 'Cargo.toml', 'go.mod',
10
+ 'pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts',
11
+ 'composer.json', 'Gemfile',
12
+ 'CMakeLists.txt', 'meson.build',
13
+ 'foundry.toml',
14
+ ];
15
+ const SCOPE_SUFFIX = /\.(?:csproj|sln|gemspec)$/i;
16
+ function declaresScope(dir) {
17
+ if (SCOPE_FILES.some((name) => existsSync(resolve(dir, name))))
18
+ return true;
19
+ try {
20
+ return readdirSync(dir, { withFileTypes: true }).some((entry) => entry.isFile() && SCOPE_SUFFIX.test(entry.name));
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /**
27
+ * Resolve package boundaries once per directory. A symbol index can contain tens of
28
+ * thousands of declarations in a monorepo, so walking and reading every ancestor for
29
+ * every symbol would turn a conservative check into the slowest part of the review.
30
+ */
31
+ export function createReinventionScopeResolver(root) {
32
+ root = resolve(root);
33
+ const cache = new Map();
34
+ const scopeForDirectory = (dir) => {
35
+ const cached = cache.get(dir);
36
+ if (cached !== undefined)
37
+ return cached;
38
+ let scope;
39
+ if (declaresScope(dir))
40
+ scope = repoPath(root, dir);
41
+ else if (dir === root)
42
+ scope = '';
43
+ else {
44
+ const parent = dirname(dir);
45
+ scope = parent === dir || !insideRepo(root, parent) ? '' : scopeForDirectory(parent);
46
+ }
47
+ cache.set(dir, scope);
48
+ return scope;
49
+ };
50
+ return (file) => {
51
+ const abs = insideRepo(root, file);
52
+ return abs ? scopeForDirectory(dirname(abs)) : '';
53
+ };
54
+ }
55
+ /** Nearest language-appropriate package boundary, or the repository root. */
56
+ export function reinventionScope(root, file) {
57
+ return createReinventionScopeResolver(root)(file);
58
+ }
59
+ function callable(node) {
60
+ if (Node.isFunctionDeclaration(node) || Node.isArrowFunction(node) || Node.isFunctionExpression(node))
61
+ return node;
62
+ if (!Node.isVariableDeclaration(node))
63
+ return undefined;
64
+ const init = node.getInitializer();
65
+ return init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) ? init : undefined;
66
+ }
67
+ /**
68
+ * Exact program tokens for a callable, excluding its export modifier and declared
69
+ * name. Layout and comments may differ; parameters, types, operators, callees and
70
+ * literals may not. That is deliberately conservative: a name match proposes a
71
+ * candidate, but only equivalent executable text is deterministic evidence that a
72
+ * helper was reimplemented.
73
+ */
74
+ export function typescriptImplementationFingerprint(node) {
75
+ const fn = callable(node);
76
+ const body = fn?.getBody();
77
+ if (!fn || !body)
78
+ return undefined;
79
+ const generator = !Node.isArrowFunction(fn) && fn.isGenerator();
80
+ const source = [
81
+ fn.isAsync() ? 'async' : 'sync',
82
+ generator ? 'generator' : 'plain',
83
+ '<' + fn.getTypeParameters().map((parameter) => parameter.getText()).join(',') + '>',
84
+ '(' + fn.getParameters().map((parameter) => parameter.getText()).join(',') + ')',
85
+ ':' + (fn.getReturnTypeNode()?.getText() ?? ''),
86
+ body.getText(),
87
+ ].join('\n');
88
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.JSX, source);
89
+ const tokens = [];
90
+ let token = scanner.scan();
91
+ for (let count = 0; token !== ts.SyntaxKind.EndOfFileToken && count < 100_000; count++) {
92
+ tokens.push({ type: token, text: scanner.getTokenText() });
93
+ token = scanner.scan();
94
+ }
95
+ // Fail closed instead of hashing a shared prefix of two exceptionally large
96
+ // callables and presenting that collision as duplication evidence.
97
+ if (token !== ts.SyntaxKind.EndOfFileToken)
98
+ return undefined;
99
+ return implementationFingerprint(tokens);
100
+ }
101
+ /** Stable hash of compiler-visible tokens; comments and layout never enter it. */
102
+ export function implementationFingerprint(tokens) {
103
+ const hash = createHash('sha256');
104
+ for (const token of tokens) {
105
+ hash.update(JSON.stringify([token.type, token.text])).update('\n');
106
+ }
107
+ return hash.digest('hex');
108
+ }
109
+ //# sourceMappingURL=reinvention.js.map
@@ -1,4 +1,4 @@
1
- import { unavailableCoverage } from '#app/manifest.js';
1
+ import { modeNote, noFindingsLabel, scopeLine, } from './summary.js';
2
2
  const MARK = { verified: '▣', judged: '▚' };
3
3
  /** Untrusted prose encoded as literal CommonMark text. */
4
4
  function text(s) {
@@ -51,6 +51,26 @@ function group(findings) {
51
51
  list.sort((a, b) => a.line - b.line);
52
52
  return out;
53
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
+ }
54
74
  export function markdown(findings, run) {
55
75
  // "No findings" from a run that could not look is the one thing this must never
56
76
  // say on its own — the reader takes a comment at face value, and a red job beside
@@ -59,33 +79,25 @@ export function markdown(findings, run) {
59
79
  const banner = incomplete
60
80
  ? [
61
81
  '> [!WARNING]',
62
- '> **This review is ' + text(run.state) + ' — not a verdict.** Something was not looked at:',
63
- ...run.notLookedAt.slice(0, 8).map((f) => '> - ' + text(f)),
64
- '',
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)),
82
+ '> **This review is ' + text(run.state) + ' — not a verdict.** Some files or checks were not reviewed.',
73
83
  '',
74
84
  ]
75
85
  : [];
86
+ const summary = run ? runSummary(run) : [];
76
87
  if (findings.length === 0) {
77
88
  return [
78
- '## PowerShot', '', ...banner, ...coverage,
89
+ '## PowerShot', '', ...banner,
79
90
  incomplete
80
91
  ? 'No findings *from what it managed to review*.'
81
- : portable ? 'No findings in portable coverage.' : 'No findings.',
82
- '',
92
+ : run ? ' **' + noFindingsLabel(run) + '**' : 'No findings.',
93
+ '', ...summary,
83
94
  ].join('\n');
84
95
  }
85
96
  const verified = findings.filter((f) => f.class === 'verified').length;
86
97
  const judged = findings.length - verified;
87
- const out = ['## PowerShot', '', ...banner, ...coverage];
98
+ const out = ['## PowerShot', '', ...banner];
88
99
  out.push('**' + verified + ' verified** (deterministic, 0 tokens) · **' + judged + ' judged** (agent)', '');
100
+ out.push(...summary);
89
101
  for (const [file, list] of group(findings)) {
90
102
  out.push('### `' + path(file) + '`', '');
91
103
  for (const f of list) {
@@ -10,7 +10,7 @@ function level(severity) {
10
10
  const DESCRIPTIONS = {
11
11
  'phantom-api': 'Calls an API that does not exist — hallucinated method, property, or arity',
12
12
  'phantom-dep': 'Imports a package that is not a declared dependency',
13
- reinvented: 'Declares a helper that already exists in the repository',
13
+ reinvented: 'Adds a callable whose token-identical implementation already existed in the same package',
14
14
  'dropped-guard': 'A guard present before the change is gone after it',
15
15
  'swallowed-error': 'Error handling that discards the failure',
16
16
  'vacuous-test': 'A test that asserts nothing, or mocks the unit under test',
@@ -0,0 +1,103 @@
1
+ function plural(count, singular, pluralForm = singular + 's') {
2
+ return count + ' ' + (count === 1 ? singular : pluralForm);
3
+ }
4
+ function capabilityName(capability) {
5
+ return {
6
+ types: 'type information',
7
+ references: 'a reference graph',
8
+ 'python-types': 'Python type information',
9
+ base: 'a base revision',
10
+ syntax: 'a syntax tree',
11
+ }[capability] ?? capability;
12
+ }
13
+ function naturalList(values, conjunction = 'and') {
14
+ if (values.length < 2)
15
+ return values[0] ?? '';
16
+ if (values.length === 2)
17
+ return values[0] + ' ' + conjunction + ' ' + values[1];
18
+ return values.slice(0, -1).join(', ') + ', ' + conjunction + ' ' + values.at(-1);
19
+ }
20
+ function scopeDetails(record) {
21
+ const details = [];
22
+ const selected = (record.files ?? []).filter((file) => file.disposition === 'selected');
23
+ const unavailableGroups = new Map();
24
+ for (const file of selected) {
25
+ if (!file.unavailable?.length)
26
+ continue;
27
+ const order = ['types', 'references', 'python-types', 'base', 'syntax'];
28
+ const capabilities = [...new Set(file.unavailable)].sort((left, right) => {
29
+ const leftRank = order.indexOf(left);
30
+ const rightRank = order.indexOf(right);
31
+ return (leftRank === -1 ? order.length : leftRank) - (rightRank === -1 ? order.length : rightRank) ||
32
+ left.localeCompare(right);
33
+ });
34
+ const key = capabilities.join('\0');
35
+ const group = unavailableGroups.get(key) ?? { capabilities, count: 0 };
36
+ group.count++;
37
+ unavailableGroups.set(key, group);
38
+ }
39
+ for (const group of unavailableGroups.values()) {
40
+ const capabilities = naturalList(group.capabilities.map(capabilityName));
41
+ details.push(plural(group.count, 'reviewed file') + ' lacked ' + capabilities + '.');
42
+ }
43
+ const unavailableChecks = record.checks?.unavailable ?? [];
44
+ if (unavailableChecks.length > 0) {
45
+ const shown = unavailableChecks.slice(0, 8).map((check) => check.check);
46
+ const requirements = [...new Set(unavailableChecks.flatMap((check) => check.missing.split(/,\s*/)))]
47
+ .map(capabilityName);
48
+ details.push(plural(unavailableChecks.length, 'check') + ' requiring ' + naturalList(requirements, 'or') +
49
+ ' did not run: ' + shown.join(', ') +
50
+ (unavailableChecks.length > shown.length ? ', and ' + (unavailableChecks.length - shown.length) + ' more' : '') + '.');
51
+ }
52
+ const waived = new Map();
53
+ for (const file of record.files ?? []) {
54
+ if (file.disposition !== 'waived')
55
+ continue;
56
+ const reason = file.reason ?? 'unspecified reason';
57
+ waived.set(reason, (waived.get(reason) ?? 0) + 1);
58
+ }
59
+ for (const [reason, count] of waived) {
60
+ details.push(plural(count, 'changed file') + ' not reviewed: ' + reason + '.');
61
+ }
62
+ return details;
63
+ }
64
+ export function summarizeRun(record) {
65
+ const hasFiles = record.files !== undefined;
66
+ const hasChecks = record.checks?.ran !== undefined;
67
+ return {
68
+ state: record.state,
69
+ notLookedAt: [...record.notLookedAt],
70
+ coverage: record.coverage,
71
+ verifyOnly: record.engine?.verifyOnly,
72
+ minSeverity: record.engine?.minSeverity,
73
+ filesReviewed: hasFiles
74
+ ? record.files.filter((file) => file.disposition === 'selected').length
75
+ : undefined,
76
+ deterministicChecks: hasChecks ? new Set(record.checks.ran).size : undefined,
77
+ scopeDetails: scopeDetails(record),
78
+ };
79
+ }
80
+ export function noFindingsLabel(summary) {
81
+ const threshold = summary.minSeverity === undefined || summary.minSeverity === 'info'
82
+ ? ''
83
+ : summary.minSeverity === 'critical'
84
+ ? 'critical '
85
+ : summary.minSeverity + '-or-higher ';
86
+ const origin = summary.verifyOnly === true ? 'deterministic ' : '';
87
+ return 'No ' + threshold + origin + 'findings';
88
+ }
89
+ export function scopeLine(summary) {
90
+ const parts = [];
91
+ if (summary.filesReviewed !== undefined)
92
+ parts.push(plural(summary.filesReviewed, 'file') + ' reviewed');
93
+ if (summary.deterministicChecks !== undefined) {
94
+ parts.push(plural(summary.deterministicChecks, 'deterministic check'));
95
+ }
96
+ if (summary.coverage !== undefined)
97
+ parts.push(summary.coverage + ' coverage');
98
+ return parts.length > 0 ? parts.join(' · ') : undefined;
99
+ }
100
+ export function modeNote(summary, verifyOnly = 'verify-only') {
101
+ return summary.verifyOnly === true ? 'Model review was disabled (' + verifyOnly + ').' : undefined;
102
+ }
103
+ //# sourceMappingURL=summary.js.map
@@ -1,5 +1,6 @@
1
1
  import { bold, brightRed, dim, gray, red, steel, yellow } from './ansi.js';
2
2
  import { highlight, isJsx } from './highlight.js';
3
+ import { modeNote, noFindingsLabel, scopeLine } from './summary.js';
3
4
  const SEVERITY_COLOR = {
4
5
  critical: brightRed,
5
6
  high: red,
@@ -106,17 +107,22 @@ export function terminal(findings, opts) {
106
107
  out.push(' ' + bold('PowerShot') + dim(' · ' + opts.subtitle));
107
108
  out.push(rule);
108
109
  const incomplete = opts.state !== 'complete';
109
- const portable = !incomplete && opts.coverage === 'portable';
110
+ const scope = scopeLine(opts);
111
+ const mode = modeNote(opts);
110
112
  if (findings.length === 0) {
111
113
  out.push('', incomplete
112
114
  ? ' ' + yellow('!') + ' No findings — but this review is ' + opts.state + ', not a verdict.'
113
- : ' ' + steel('✔') + (portable ? ' No findings in portable coverage.' : ' No findings.'));
115
+ : ' ' + steel('✔') + ' ' + noFindingsLabel(opts) + '.');
114
116
  for (const reason of opts.notLookedAt)
115
117
  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));
118
+ if (scope)
119
+ out.push(dim(' ' + scope));
120
+ if (mode)
121
+ out.push(dim(' ' + mode));
122
+ if ((opts.scopeDetails?.length ?? 0) > 0) {
123
+ out.push(dim(' ' + (opts.coverage === 'portable' ? 'Coverage details:' : 'Review scope:')));
124
+ for (const detail of opts.scopeDetails)
125
+ out.push(dim(' - ' + detail));
120
126
  }
121
127
  out.push('');
122
128
  return out.join('\n');
@@ -152,11 +158,12 @@ export function terminal(findings, opts) {
152
158
  for (const reason of opts.notLookedAt)
153
159
  out.push(dim(' ' + reason));
154
160
  }
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
- }
161
+ if (scope)
162
+ out.push(' ' + steel('◇ ') + dim(scope));
163
+ if (mode)
164
+ out.push(dim(' ' + mode));
165
+ for (const detail of opts.scopeDetails ?? [])
166
+ out.push(dim(' ' + detail));
160
167
  out.push('');
161
168
  return out.join('\n');
162
169
  }