@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/README.md +4 -3
- package/dist/cli/reports.js +4 -6
- package/dist/cli/review-command.js +4 -7
- package/dist/cli/session-command.js +5 -1
- package/dist/github/api.js +198 -0
- package/dist/github/inline-comments.js +3 -154
- package/dist/github/summary-comment.js +149 -0
- package/dist/ground.js +49 -8
- package/dist/langtest.js +93 -1
- package/dist/manifest.js +0 -17
- package/dist/package-smoke.js +4 -0
- package/dist/reinvention.js +109 -0
- package/dist/report/markdown.js +28 -16
- package/dist/report/sarif.js +1 -1
- package/dist/report/summary.js +103 -0
- package/dist/report/terminal.js +18 -11
- package/dist/report/viewer.js +18 -9
- package/dist/selftest.js +594 -22
- package/dist/session.js +6 -2
- package/dist/verifiers/foreign-reinvented.js +72 -26
- package/dist/verifiers/foreign-tokens.js +4 -1
- package/dist/verifiers/reinvented.js +28 -10
- package/docs/architecture.md +2 -1
- package/docs/ci.md +25 -0
- package/examples/github-actions/action.yml +4 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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({
|
|
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;
|
package/dist/package-smoke.js
CHANGED
|
@@ -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
|
package/dist/report/markdown.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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.**
|
|
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,
|
|
89
|
+
'## PowerShot', '', ...banner,
|
|
79
90
|
incomplete
|
|
80
91
|
? 'No findings *from what it managed to review*.'
|
|
81
|
-
:
|
|
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
|
|
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) {
|
package/dist/report/sarif.js
CHANGED
|
@@ -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: '
|
|
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
|
package/dist/report/terminal.js
CHANGED
|
@@ -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
|
|
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('✔') +
|
|
115
|
+
: ' ' + steel('✔') + ' ' + noFindingsLabel(opts) + '.');
|
|
114
116
|
for (const reason of opts.notLookedAt)
|
|
115
117
|
out.push(dim(' ' + reason));
|
|
116
|
-
if (
|
|
117
|
-
out.push(dim('
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
156
|
-
out.push(' ' + steel('◇
|
|
157
|
-
|
|
158
|
-
|
|
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
|
}
|