@0xcraft/powershot 1.1.3 → 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 +1 -1
- package/dist/ground.js +49 -8
- package/dist/langtest.js +93 -1
- package/dist/reinvention.js +109 -0
- package/dist/report/sarif.js +1 -1
- package/dist/selftest.js +163 -4
- 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 +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ oracle is never counted as a pass in either profile.
|
|
|
123
123
|
| `phantom-dep` | Imports absent from project manifests | Dependency manifests |
|
|
124
124
|
| `phantom-config` | Configuration keys with no declared source | Repository config index |
|
|
125
125
|
| `contract-drift` | Signature changes with callers left behind | Types and references |
|
|
126
|
-
| `reinvented` | New
|
|
126
|
+
| `reinvented` | New callables that exactly repeat an implementation already present in the same package | Base symbol + token fingerprint |
|
|
127
127
|
| `dropped-guard` | Removed guards, early returns, protective branches | Pre/post AST |
|
|
128
128
|
| `swallowed-error` | Empty or ineffective error handling | AST shape |
|
|
129
129
|
| `vacuous-test` | Tests that do not assert behavior | Test AST |
|
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. */
|
|
@@ -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/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',
|
package/dist/selftest.js
CHANGED
|
@@ -22,7 +22,7 @@ import { stripControl } from './text.js';
|
|
|
22
22
|
import { review } from './review.js';
|
|
23
23
|
import { withTargetTree } from './snapshot.js';
|
|
24
24
|
import { loadConfig } from './config.js';
|
|
25
|
-
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
26
26
|
import { insideRepo, repoPath } from './fspolicy.js';
|
|
27
27
|
import { Budget, parseLimits } from './budget.js';
|
|
28
28
|
import { SelectionPlan, capabilitiesOf } from './plan.js';
|
|
@@ -52,6 +52,7 @@ import { summarizeRun } from './report/summary.js';
|
|
|
52
52
|
import { wrap } from './report/terminal.js';
|
|
53
53
|
import { highlight, isJsx } from './report/highlight.js';
|
|
54
54
|
import { buildGround, normalizeName, readEnvManifest, relPath } from './ground.js';
|
|
55
|
+
import { implementationFingerprint, reinventionScope, typescriptImplementationFingerprint } from './reinvention.js';
|
|
55
56
|
import { incompleteReasons } from './bench.js';
|
|
56
57
|
import { PACKAGE_NAME, PACKAGE_VERSION } from './package-meta.js';
|
|
57
58
|
import { addedLinesFromPatch, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
|
|
@@ -83,13 +84,26 @@ function ground(files, deps = []) {
|
|
|
83
84
|
const symbolIndex = new Map();
|
|
84
85
|
for (const sf of project.getSourceFiles()) {
|
|
85
86
|
const rel = sf.getFilePath().slice(root.length + 1);
|
|
87
|
+
const input = files.find((file) => file.path === rel);
|
|
86
88
|
for (const [name, decls] of sf.getExportedDeclarations()) {
|
|
87
89
|
const decl = decls[0];
|
|
88
90
|
if (!decl)
|
|
89
91
|
continue;
|
|
92
|
+
const fingerprint = typescriptImplementationFingerprint(decl);
|
|
93
|
+
if (!fingerprint)
|
|
94
|
+
continue;
|
|
90
95
|
const key = normalizeName(name);
|
|
91
96
|
const list = symbolIndex.get(key) ?? [];
|
|
92
|
-
|
|
97
|
+
const before = input?.before === undefined ? undefined : beforeProject.getSourceFile('/before/' + rel);
|
|
98
|
+
const existedInBase = (before?.getExportedDeclarations().get(name) ?? []).some((baseDeclaration) => typescriptImplementationFingerprint(baseDeclaration) === fingerprint);
|
|
99
|
+
list.push({
|
|
100
|
+
file: rel,
|
|
101
|
+
name,
|
|
102
|
+
line: decl.getStartLineNumber(),
|
|
103
|
+
fingerprint,
|
|
104
|
+
existedInBase,
|
|
105
|
+
scope: reinventionScope(root, rel),
|
|
106
|
+
});
|
|
93
107
|
symbolIndex.set(key, list);
|
|
94
108
|
}
|
|
95
109
|
}
|
|
@@ -146,14 +160,40 @@ check('resolves a scoped subpath to its package', () => {
|
|
|
146
160
|
});
|
|
147
161
|
console.log('\nreinvented');
|
|
148
162
|
check('fires when a helper already exists elsewhere', () => {
|
|
163
|
+
const existing = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
149
164
|
const g = ground([
|
|
150
|
-
{ path: 'lib/currency.ts',
|
|
151
|
-
{ path: 'utils/money.ts', after:
|
|
165
|
+
{ path: 'lib/currency.ts', before: existing, after: existing },
|
|
166
|
+
{ path: 'utils/money.ts', after: existing },
|
|
152
167
|
]);
|
|
153
168
|
const found = reinvented.run(g);
|
|
154
169
|
assert.ok(found.length >= 1, 'expected a duplication finding');
|
|
155
170
|
assert.equal(found[0].confidence, 'firm'); // heuristic, never claims `proven`
|
|
156
171
|
});
|
|
172
|
+
check('silent when matching helpers are both new in the change', () => {
|
|
173
|
+
const added = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
174
|
+
const g = ground([
|
|
175
|
+
{ path: 'lib/currency.ts', after: added },
|
|
176
|
+
{ path: 'utils/money.ts', after: added },
|
|
177
|
+
]);
|
|
178
|
+
assert.equal(fires(reinvented, g), false);
|
|
179
|
+
});
|
|
180
|
+
check('silent when the matching implementation already existed in the changed file', () => {
|
|
181
|
+
const existing = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
182
|
+
const g = ground([
|
|
183
|
+
{ path: 'lib/currency.ts', before: existing, after: existing },
|
|
184
|
+
{ path: 'utils/money.ts', before: existing, after: existing },
|
|
185
|
+
]);
|
|
186
|
+
assert.equal(fires(reinvented, g), false);
|
|
187
|
+
});
|
|
188
|
+
check('silent when the candidate only became equivalent in this change', () => {
|
|
189
|
+
const before = 'export function formatMinorUnits(n: number) { return n / 10 }\n';
|
|
190
|
+
const after = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
191
|
+
const g = ground([
|
|
192
|
+
{ path: 'lib/currency.ts', before, after },
|
|
193
|
+
{ path: 'utils/money.ts', after },
|
|
194
|
+
]);
|
|
195
|
+
assert.equal(fires(reinvented, g), false);
|
|
196
|
+
});
|
|
157
197
|
check('silent on a genuinely new name', () => {
|
|
158
198
|
const g = ground([
|
|
159
199
|
{ path: 'lib/currency.ts', after: 'export function formatMinorUnits(n: number) { return n / 100 }\n' },
|
|
@@ -168,6 +208,125 @@ check('silent on short and generic names', () => {
|
|
|
168
208
|
]);
|
|
169
209
|
assert.equal(fires(reinvented, g), false);
|
|
170
210
|
});
|
|
211
|
+
check('silent when only the helper name matches', () => {
|
|
212
|
+
const existing = 'export function runCheck(currentVersion: string, availableVersion: string) { return currentVersion !== availableVersion }\n';
|
|
213
|
+
const g = ground([
|
|
214
|
+
{ path: 'web/use-app-updater.ts', before: existing, after: existing },
|
|
215
|
+
{
|
|
216
|
+
path: 'scripts/check-import-boundaries.mjs',
|
|
217
|
+
after: 'function runCheck({ srcRoot, allowlist }) { return allowlist.filter((entry) => !entry.startsWith(srcRoot)) }\n',
|
|
218
|
+
},
|
|
219
|
+
]);
|
|
220
|
+
assert.equal(fires(reinvented, g), false);
|
|
221
|
+
});
|
|
222
|
+
await checkAsync('silent across package boundaries', async () => {
|
|
223
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-scope-')));
|
|
224
|
+
try {
|
|
225
|
+
mkdirSync(join(dir, 'apps/web/src'), { recursive: true });
|
|
226
|
+
mkdirSync(join(dir, 'tools/scripts'), { recursive: true });
|
|
227
|
+
writeFileSync(join(dir, 'apps/web/package.json'), '{"name":"web","private":true}');
|
|
228
|
+
writeFileSync(join(dir, 'tools/scripts/package.json'), '{"name":"scripts","private":true}');
|
|
229
|
+
writeFileSync(join(dir, 'tsconfig.json'), '{"compilerOptions":{"allowJs":true},"include":["apps/**/*.mjs","tools/**/*.mjs"]}');
|
|
230
|
+
writeFileSync(join(dir, 'apps/web/src/normalize.mjs'), 'export function normalizePayload(value) { return value.trim() }\n');
|
|
231
|
+
writeFileSync(join(dir, 'tools/scripts/normalize.mjs'), 'function normalizePayload(value) { return value.trim() }\n');
|
|
232
|
+
const g = await buildGround(dir, [
|
|
233
|
+
{ path: 'tools/scripts/normalize.mjs', added: new Set([1]) },
|
|
234
|
+
]);
|
|
235
|
+
assert.equal(fires(reinvented, g), false);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
rmSync(dir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
await checkAsync('silent when only a new barrel alias gives the candidate a matching name', async () => {
|
|
242
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-alias-')));
|
|
243
|
+
try {
|
|
244
|
+
mkdirSync(join(dir, 'lib'), { recursive: true });
|
|
245
|
+
mkdirSync(join(dir, 'scripts'), { recursive: true });
|
|
246
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"fixture","private":true}');
|
|
247
|
+
writeFileSync(join(dir, 'tsconfig.json'), '{"include":["lib/**/*.ts","scripts/**/*.ts"]}');
|
|
248
|
+
writeFileSync(join(dir, 'lib/base.ts'), 'export function calculateVersion(current: string, available: string) { return current !== available }\n');
|
|
249
|
+
writeFileSync(join(dir, 'lib/index.ts'), "export { calculateVersion as runCheck } from './base.js'\n");
|
|
250
|
+
writeFileSync(join(dir, 'scripts/run-check.ts'), 'function runCheck(current: string, available: string) { return current !== available }\n');
|
|
251
|
+
const g = await buildGround(dir, [
|
|
252
|
+
{ path: 'lib/index.ts', added: new Set([1]), before: '' },
|
|
253
|
+
{ path: 'scripts/run-check.ts', added: new Set([1]) },
|
|
254
|
+
]);
|
|
255
|
+
assert.equal(fires(reinvented, g), false);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
rmSync(dir, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
check('recognizes package boundaries for every declared language family', () => {
|
|
262
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-markers-')));
|
|
263
|
+
try {
|
|
264
|
+
const packages = [
|
|
265
|
+
['typescript', 'package.json'],
|
|
266
|
+
['python', 'pyproject.toml'],
|
|
267
|
+
['rust', 'Cargo.toml'],
|
|
268
|
+
['go', 'go.mod'],
|
|
269
|
+
['jvm', 'build.gradle.kts'],
|
|
270
|
+
['c-cpp', 'CMakeLists.txt'],
|
|
271
|
+
['csharp', 'App.csproj'],
|
|
272
|
+
['php', 'composer.json'],
|
|
273
|
+
['ruby', 'Gemfile'],
|
|
274
|
+
['solidity', 'foundry.toml'],
|
|
275
|
+
];
|
|
276
|
+
for (const [name, marker] of packages) {
|
|
277
|
+
mkdirSync(join(dir, name, 'src'), { recursive: true });
|
|
278
|
+
writeFileSync(join(dir, name, marker), '');
|
|
279
|
+
assert.equal(reinventionScope(dir, name + '/src/file.txt'), name);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
rmSync(dir, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
check('implementation fingerprints cannot confuse token boundaries with token text', () => {
|
|
287
|
+
const oneToken = implementationFingerprint([{ type: '1', text: 'x\u00002\u0000y' }]);
|
|
288
|
+
const twoTokens = implementationFingerprint([{ type: '1', text: 'x' }, { type: '2', text: 'y' }]);
|
|
289
|
+
assert.notEqual(oneToken, twoTokens);
|
|
290
|
+
});
|
|
291
|
+
check('public CLI rejects the name-only repro and keeps an exact-match control', () => {
|
|
292
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-cli-')));
|
|
293
|
+
const cli = join(process.cwd(), 'dist', 'cli.js');
|
|
294
|
+
const git = (...args) => {
|
|
295
|
+
execFileSync('git', args, { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
296
|
+
};
|
|
297
|
+
const run = () => {
|
|
298
|
+
const result = spawnSync(process.execPath, [cli, 'review', '--verify-only', '--checks', 'reinvented', '--from', 'HEAD~1', '--to', 'HEAD', '--format', 'compact'], { cwd: dir, env: { ...process.env, CI: 'true' }, encoding: 'utf8' });
|
|
299
|
+
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
300
|
+
};
|
|
301
|
+
try {
|
|
302
|
+
git('init', '-q', '.');
|
|
303
|
+
git('config', 'user.name', 'PowerShot Tests');
|
|
304
|
+
git('config', 'user.email', 'tests@powershot.invalid');
|
|
305
|
+
mkdirSync(join(dir, 'src'), { recursive: true });
|
|
306
|
+
mkdirSync(join(dir, 'scripts'), { recursive: true });
|
|
307
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"fixture","private":true}\n');
|
|
308
|
+
writeFileSync(join(dir, 'tsconfig.json'), '{"compilerOptions":{"allowJs":true},"include":["src/**/*.ts","scripts/**/*"]}\n');
|
|
309
|
+
const existing = 'export function runCheck(currentVersion: string, availableVersion: string) { return currentVersion !== availableVersion }\n';
|
|
310
|
+
writeFileSync(join(dir, 'src/use-app-updater.ts'), existing);
|
|
311
|
+
git('add', '.');
|
|
312
|
+
git('commit', '-q', '-m', 'base');
|
|
313
|
+
writeFileSync(join(dir, 'scripts/check-import-boundaries.mjs'), 'function runCheck({ srcRoot, allowlist }) { return allowlist.filter((entry) => !entry.startsWith(srcRoot)) }\n');
|
|
314
|
+
git('add', '.');
|
|
315
|
+
git('commit', '-q', '-m', 'different helper with same name');
|
|
316
|
+
const nameOnly = run();
|
|
317
|
+
assert.equal(nameOnly.status, 0, nameOnly.stderr || nameOnly.stdout);
|
|
318
|
+
assert.doesNotMatch(nameOnly.stdout, /\[reinvented\]/);
|
|
319
|
+
writeFileSync(join(dir, 'scripts/duplicate.ts'), existing);
|
|
320
|
+
git('add', '.');
|
|
321
|
+
git('commit', '-q', '-m', 'exact duplicate');
|
|
322
|
+
const exact = run();
|
|
323
|
+
assert.equal(exact.status, 1, exact.stderr || exact.stdout);
|
|
324
|
+
assert.match(exact.stdout, /\[reinvented\]/);
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
rmSync(dir, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
171
330
|
console.log('\ndropped-guard');
|
|
172
331
|
check('fires when an early-return guard disappears', () => {
|
|
173
332
|
const before = 'export function close(inv: any) {\n if (!inv.customer) return null\n return inv.total\n}\n';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createReinventionScopeResolver, implementationFingerprint } from '#app/reinvention.js';
|
|
2
|
+
import { finding, tokensFor, topLevelDeclarations } from './foreign-tokens.js';
|
|
2
3
|
/** Names too common to mean anything across files, as in the TypeScript version. */
|
|
3
4
|
const GENERIC = new Set([
|
|
4
5
|
'render', 'handler', 'handle', 'create', 'update', 'remove', 'delete', 'insert',
|
|
@@ -14,6 +15,19 @@ const GENERIC = new Set([
|
|
|
14
15
|
* repository this was the single largest source of noise.
|
|
15
16
|
*/
|
|
16
17
|
const TESTISH = /(^|\/)(tests?|spec|__tests__)\/|(^|\/)(test_[^/]+|[^/]+_test|[^/]+\.(test|spec))\.[a-z]+$/;
|
|
18
|
+
function normalized(name) {
|
|
19
|
+
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
20
|
+
}
|
|
21
|
+
function declarationIndex(root, pack) {
|
|
22
|
+
const index = new Map();
|
|
23
|
+
for (const [name, node] of topLevelDeclarations(root, pack)) {
|
|
24
|
+
const key = normalized(name);
|
|
25
|
+
const list = index.get(key) ?? [];
|
|
26
|
+
list.push({ name, node, fingerprint: implementationFingerprint(tokensFor(node, pack)) });
|
|
27
|
+
index.set(key, list);
|
|
28
|
+
}
|
|
29
|
+
return index;
|
|
30
|
+
}
|
|
17
31
|
export const foreignReinvented = {
|
|
18
32
|
name: 'reinvented',
|
|
19
33
|
needs: ['syntax'],
|
|
@@ -24,39 +38,71 @@ export const foreignReinvented = {
|
|
|
24
38
|
// Keyed by language as well as name: a Ruby `charge` and a C++ `charge` are two
|
|
25
39
|
// unrelated functions that happen to share a word, and calling that duplication
|
|
26
40
|
// would be nonsense — nothing can be reused across the boundary anyway.
|
|
41
|
+
const scopeFor = createReinventionScopeResolver(g.root);
|
|
27
42
|
const index = new Map();
|
|
43
|
+
const declarations = new Map();
|
|
28
44
|
for (const file of g.foreign) {
|
|
29
|
-
|
|
45
|
+
declarations.set(file.path, {
|
|
46
|
+
current: declarationIndex(file.tree.rootNode, file.pack),
|
|
47
|
+
base: file.beforeTree ? declarationIndex(file.beforeTree.rootNode, file.pack) : undefined,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
for (const file of g.foreign) {
|
|
51
|
+
if (TESTISH.test(file.path) || !file.beforeTree)
|
|
30
52
|
continue;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
const fileDeclarations = declarations.get(file.path);
|
|
54
|
+
for (const [nameKey, baseDeclarations] of fileDeclarations.base ?? []) {
|
|
55
|
+
for (const baseDeclaration of baseDeclarations) {
|
|
56
|
+
const currentDeclaration = (fileDeclarations.current.get(nameKey) ?? []).find((declaration) => declaration.fingerprint === baseDeclaration.fingerprint);
|
|
57
|
+
// The reusable declaration must both predate the change and remain available
|
|
58
|
+
// at the reviewed head. A removed or rewritten helper is not a candidate.
|
|
59
|
+
if (!currentDeclaration)
|
|
60
|
+
continue;
|
|
61
|
+
const key = file.pack.name + '|' + nameKey;
|
|
62
|
+
const list = index.get(key) ?? [];
|
|
63
|
+
list.push({
|
|
64
|
+
file: file.path,
|
|
65
|
+
line: currentDeclaration.node.startPosition.row + 1,
|
|
66
|
+
fingerprint: baseDeclaration.fingerprint,
|
|
67
|
+
scope: scopeFor(file.path),
|
|
68
|
+
});
|
|
69
|
+
index.set(key, list);
|
|
70
|
+
}
|
|
36
71
|
}
|
|
37
72
|
}
|
|
38
73
|
for (const file of g.foreign) {
|
|
39
74
|
if (TESTISH.test(file.path))
|
|
40
75
|
continue;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
const fileDeclarations = declarations.get(file.path);
|
|
77
|
+
for (const [nameKey, currentDeclarations] of fileDeclarations.current) {
|
|
78
|
+
for (const { name, node: decl, fingerprint } of currentDeclarations) {
|
|
79
|
+
const line = decl.startPosition.row + 1;
|
|
80
|
+
if (!file.changed.added.has(line))
|
|
81
|
+
continue;
|
|
82
|
+
if (name.length < 6 || GENERIC.has(name.toLowerCase()))
|
|
83
|
+
continue;
|
|
84
|
+
const existedHere = (fileDeclarations.base?.get(nameKey) ?? []).some((baseDeclaration) => baseDeclaration.fingerprint === fingerprint);
|
|
85
|
+
if (existedHere)
|
|
86
|
+
continue;
|
|
87
|
+
const key = file.pack.name + '|' + nameKey;
|
|
88
|
+
const scope = scopeFor(file.path);
|
|
89
|
+
const match = (index.get(key) ?? []).find((candidate) => candidate.file !== file.path &&
|
|
90
|
+
candidate.scope === scope &&
|
|
91
|
+
candidate.fingerprint === fingerprint);
|
|
92
|
+
if (!match)
|
|
93
|
+
continue;
|
|
94
|
+
findings.push(finding(file, decl, {
|
|
95
|
+
check: 'reinvented',
|
|
96
|
+
severity: 'medium',
|
|
97
|
+
confidence: 'firm',
|
|
98
|
+
title: name + ' repeats the implementation at ' + match.file,
|
|
99
|
+
evidence: {
|
|
100
|
+
oracle: file.pack.name + ' base declaration + token fingerprint',
|
|
101
|
+
detail: 'token-identical implementation already present at ' + match.file + ':' + match.line,
|
|
102
|
+
},
|
|
103
|
+
fix: 'Consider reusing the existing declaration; if the separation is intentional, keep the boundary explicit',
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
60
106
|
}
|
|
61
107
|
}
|
|
62
108
|
return findings;
|
|
@@ -68,8 +68,11 @@ export function finding(file, node, f) {
|
|
|
68
68
|
*/
|
|
69
69
|
export function declaredName(decl, pack) {
|
|
70
70
|
const field = decl.childForFieldName(pack.nodes.declarationName);
|
|
71
|
+
// tree-sitter-kotlin exposes the declaration identifier as a named child but
|
|
72
|
+
// assigns no field name to it. Keep the grammar field authoritative when one is
|
|
73
|
+
// present; otherwise the first identifier inside the declaration is its name.
|
|
71
74
|
if (!field)
|
|
72
|
-
return
|
|
75
|
+
return nodesOfType(decl, pack.nodes.identifier)[0]?.text;
|
|
73
76
|
if (field.childCount === 0)
|
|
74
77
|
return field.text;
|
|
75
78
|
// C and C++ wrap the name in a declarator; look inside that, never wider
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SyntaxKind } from 'ts-morph';
|
|
2
2
|
import { locate, normalizeName, relPath } from '#app/ground.js';
|
|
3
|
+
import { createReinventionScopeResolver, typescriptImplementationFingerprint } from '#app/reinvention.js';
|
|
3
4
|
/**
|
|
4
5
|
* Names too generic to mean anything across files — two `render`s are usually
|
|
5
6
|
* two different things, not a duplication.
|
|
@@ -15,8 +16,10 @@ function declaredNames(sf) {
|
|
|
15
16
|
for (const fn of sf.getFunctions()) {
|
|
16
17
|
const name = fn.getName();
|
|
17
18
|
const id = fn.getNameNode();
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
const fingerprint = typescriptImplementationFingerprint(fn);
|
|
20
|
+
if (name && id && fingerprint) {
|
|
21
|
+
out.push({ name, line: fn.getStartLineNumber(), span: locate(sf, id.getStart(), id.getWidth()).span, fingerprint });
|
|
22
|
+
}
|
|
20
23
|
}
|
|
21
24
|
for (const v of sf.getVariableDeclarations()) {
|
|
22
25
|
const init = v.getInitializer();
|
|
@@ -24,7 +27,10 @@ function declaredNames(sf) {
|
|
|
24
27
|
continue;
|
|
25
28
|
if (init.isKind(SyntaxKind.ArrowFunction) || init.isKind(SyntaxKind.FunctionExpression)) {
|
|
26
29
|
const id = v.getNameNode();
|
|
27
|
-
|
|
30
|
+
const fingerprint = typescriptImplementationFingerprint(v);
|
|
31
|
+
if (fingerprint) {
|
|
32
|
+
out.push({ name: v.getName(), line: v.getStartLineNumber(), span: locate(sf, id.getStart(), id.getWidth()).span, fingerprint });
|
|
33
|
+
}
|
|
28
34
|
}
|
|
29
35
|
}
|
|
30
36
|
return out;
|
|
@@ -35,19 +41,28 @@ export const reinvented = {
|
|
|
35
41
|
needs: ['syntax'],
|
|
36
42
|
run(g) {
|
|
37
43
|
const findings = [];
|
|
38
|
-
|
|
44
|
+
const scopeFor = createReinventionScopeResolver(g.root);
|
|
45
|
+
for (const { sf, changed, before } of g.files) {
|
|
39
46
|
const file = relPath(sf, g.root);
|
|
47
|
+
const scope = scopeFor(file);
|
|
40
48
|
// a fixture builder repeated across test files is a deliberate trade, and two
|
|
41
49
|
// tests describing the same scenario naturally share a name
|
|
42
50
|
if (TEST_FILE.test(file))
|
|
43
51
|
continue;
|
|
44
|
-
|
|
52
|
+
const baseDeclarations = before ? declaredNames(before) : [];
|
|
53
|
+
for (const { name, line, span, fingerprint } of declaredNames(sf)) {
|
|
45
54
|
if (!changed.added.has(line))
|
|
46
55
|
continue;
|
|
47
56
|
if (name.length < 6 || GENERIC.has(name) || GENERIC.has(name.toLowerCase()))
|
|
48
57
|
continue;
|
|
49
|
-
const
|
|
50
|
-
|
|
58
|
+
const existedHere = baseDeclarations.some((declaration) => normalizeName(declaration.name) === normalizeName(name) &&
|
|
59
|
+
declaration.fingerprint === fingerprint);
|
|
60
|
+
if (existedHere)
|
|
61
|
+
continue;
|
|
62
|
+
const match = (g.symbolIndex.get(normalizeName(name)) ?? []).find((symbol) => symbol.file !== file &&
|
|
63
|
+
symbol.existedInBase &&
|
|
64
|
+
symbol.scope === scope &&
|
|
65
|
+
symbol.fingerprint === fingerprint);
|
|
51
66
|
if (!match)
|
|
52
67
|
continue;
|
|
53
68
|
findings.push({
|
|
@@ -59,12 +74,15 @@ export const reinvented = {
|
|
|
59
74
|
file,
|
|
60
75
|
line,
|
|
61
76
|
span,
|
|
62
|
-
title: name + '()
|
|
63
|
-
evidence: {
|
|
77
|
+
title: name + '() repeats the implementation at ' + match.file + ':' + match.name,
|
|
78
|
+
evidence: {
|
|
79
|
+
oracle: 'base export + callable token fingerprint',
|
|
80
|
+
detail: 'token-identical implementation already exported from ' + match.file + ':' + match.line,
|
|
81
|
+
},
|
|
64
82
|
// Deliberately not an import statement: the correct specifier depends on the
|
|
65
83
|
// repo's module resolution, and a wrong one would be exactly the kind of
|
|
66
84
|
// confidently-wrong output this tool exists to catch.
|
|
67
|
-
fix: '
|
|
85
|
+
fix: 'Consider reusing ' + match.name + ' from ' + match.file + '; if the separation is intentional, keep the boundary explicit',
|
|
68
86
|
});
|
|
69
87
|
}
|
|
70
88
|
}
|
package/docs/architecture.md
CHANGED
|
@@ -59,7 +59,7 @@ engine. The engine does not depend on a workflow provider or terminal layout.
|
|
|
59
59
|
|---|---|---|
|
|
60
60
|
| `src/cli/` | Argument parsing, command dispatch, report publication, exit mapping | Review algorithms |
|
|
61
61
|
| `src/review.ts` | One review run and its stage orchestration | CLI parsing or presentation |
|
|
62
|
-
| `src/ground.ts` | Change-scoped TypeScript projects, parse trees, manifests,
|
|
62
|
+
| `src/ground.ts` | Change-scoped TypeScript projects, parse trees, manifests, base-aware implementation index | Check selection |
|
|
63
63
|
| `src/plan.ts` | File selection and per-file capability accounting | Finding generation |
|
|
64
64
|
| `src/manifest.ts` | Completion state and the authoritative run record | Rendering |
|
|
65
65
|
| `src/verifiers/` | Deterministic check implementations | Model calls |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0xcraft/powershot",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "Oracle-first code review for machine-written code, with deterministic verification and CI-ready reports.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "aglumova <alina.glumova@gmail.com>",
|