@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 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 helpers duplicating existing symbols | Symbol index |
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
- 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. */
@@ -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
@@ -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',
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
- list.push({ file: rel, name, line: decl.getStartLineNumber() });
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', after: 'export function formatMinorUnits(n: number) { return n / 100 }\n' },
151
- { path: 'utils/money.ts', after: 'export function formatMinorUnits(n: number) { return n / 100 }\n' },
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 { finding, topLevelDeclarations } from './foreign-tokens.js';
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
- if (TESTISH.test(file.path))
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
- for (const [name, decl] of topLevelDeclarations(file.tree.rootNode, file.pack)) {
32
- const key = file.pack.name + '|' + name.toLowerCase().replace(/[^a-z0-9]/g, '');
33
- const list = index.get(key) ?? [];
34
- list.push({ file: file.path, line: decl.startPosition.row + 1 });
35
- index.set(key, list);
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
- for (const [name, decl] of topLevelDeclarations(file.tree.rootNode, file.pack)) {
42
- const line = decl.startPosition.row + 1;
43
- if (!file.changed.added.has(line))
44
- continue;
45
- if (name.length < 6 || GENERIC.has(name.toLowerCase()))
46
- continue;
47
- const key = file.pack.name + '|' + name.toLowerCase().replace(/[^a-z0-9]/g, '');
48
- const match = (index.get(key) ?? []).find((e) => e.file !== file.path);
49
- // report the pair once, from the file that declares it later
50
- if (!match || match.file < file.path)
51
- continue;
52
- findings.push(finding(file, decl, {
53
- check: 'reinvented',
54
- severity: 'medium',
55
- confidence: 'firm',
56
- title: name + '() duplicates a declaration in ' + match.file,
57
- evidence: { oracle: file.pack.name + ' declarations', detail: 'already declared at ' + match.file + ':' + match.line },
58
- fix: 'Reuse the existing one instead of redeclaring it',
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 undefined;
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
- if (name && id)
19
- out.push({ name, line: fn.getStartLineNumber(), span: locate(sf, id.getStart(), id.getWidth()).span });
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
- out.push({ name: v.getName(), line: v.getStartLineNumber(), span: locate(sf, id.getStart(), id.getWidth()).span });
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
- for (const { sf, changed } of g.files) {
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
- for (const { name, line, span } of declaredNames(sf)) {
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 existing = (g.symbolIndex.get(normalizeName(name)) ?? []).filter((s) => s.file !== file);
50
- const match = existing[0];
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 + '() duplicates an existing export ' + match.file + ':' + match.name,
63
- evidence: { oracle: 'repo symbol index', detail: 'already exported from ' + match.file + ':' + match.line },
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: 'Reuse ' + match.name + ' from ' + match.file + ' instead of redeclaring it',
85
+ fix: 'Consider reusing ' + match.name + ' from ' + match.file + '; if the separation is intentional, keep the boundary explicit',
68
86
  });
69
87
  }
70
88
  }
@@ -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, symbol index | Check selection |
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",
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>",