@0xcraft/powershot 1.1.3 → 1.1.5

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.
@@ -1,4 +1,6 @@
1
- import { finding, topLevelDeclarations } from './foreign-tokens.js';
1
+ import { posix } from 'node:path';
2
+ import { createReinventionScopeResolver, implementationFingerprint } from '#app/reinvention.js';
3
+ import { finding, reusableDeclarations } from './foreign-tokens.js';
2
4
  /** Names too common to mean anything across files, as in the TypeScript version. */
3
5
  const GENERIC = new Set([
4
6
  'render', 'handler', 'handle', 'create', 'update', 'remove', 'delete', 'insert',
@@ -14,6 +16,26 @@ const GENERIC = new Set([
14
16
  * repository this was the single largest source of noise.
15
17
  */
16
18
  const TESTISH = /(^|\/)(tests?|spec|__tests__)\/|(^|\/)(test_[^/]+|[^/]+_test|[^/]+\.(test|spec))\.[a-z]+$/;
19
+ function normalized(name) {
20
+ return name.toLowerCase().replace(/[^a-z0-9]/g, '');
21
+ }
22
+ function declarationIndex(root, pack, path) {
23
+ const index = new Map();
24
+ const sourceScope = pack.name === 'go'
25
+ ? ['directory:' + posix.dirname(path.replaceAll('\\', '/'))]
26
+ : [];
27
+ for (const declaration of reusableDeclarations(root, pack, path)) {
28
+ const { scope, name, node, tokens } = declaration;
29
+ // Declaration names deliberately bridge snake/camel spelling. Language
30
+ // namespaces do not: changing only their case still names a different scope in
31
+ // case-sensitive languages.
32
+ const key = [...sourceScope, ...scope].join('::') + '|' + normalized(name);
33
+ const list = index.get(key) ?? [];
34
+ list.push({ name, node, fingerprint: implementationFingerprint(tokens) });
35
+ index.set(key, list);
36
+ }
37
+ return index;
38
+ }
17
39
  export const foreignReinvented = {
18
40
  name: 'reinvented',
19
41
  needs: ['syntax'],
@@ -24,39 +46,78 @@ export const foreignReinvented = {
24
46
  // Keyed by language as well as name: a Ruby `charge` and a C++ `charge` are two
25
47
  // unrelated functions that happen to share a word, and calling that duplication
26
48
  // would be nonsense — nothing can be reused across the boundary anyway.
49
+ const scopeFor = createReinventionScopeResolver(g.root);
27
50
  const index = new Map();
51
+ const declarations = new Map();
28
52
  for (const file of g.foreign) {
29
- if (TESTISH.test(file.path))
53
+ declarations.set(file.path, {
54
+ current: declarationIndex(file.tree.rootNode, file.pack, file.path),
55
+ base: file.beforeTree
56
+ ? declarationIndex(file.beforeTree.rootNode, file.pack, file.changed.beforePath ?? file.path)
57
+ : undefined,
58
+ });
59
+ }
60
+ for (const file of g.foreign) {
61
+ if (TESTISH.test(file.path) || !file.beforeTree)
30
62
  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);
63
+ const fileDeclarations = declarations.get(file.path);
64
+ for (const [nameKey, baseDeclarations] of fileDeclarations.base ?? []) {
65
+ for (const baseDeclaration of baseDeclarations) {
66
+ const currentDeclaration = (fileDeclarations.current.get(nameKey) ?? []).find((declaration) => declaration.fingerprint === baseDeclaration.fingerprint);
67
+ // The reusable declaration must both predate the change and remain available
68
+ // at the reviewed head. A removed or rewritten helper is not a candidate.
69
+ if (!currentDeclaration)
70
+ continue;
71
+ const beforePath = file.changed.beforePath ?? file.path;
72
+ if (scopeFor(beforePath) !== scopeFor(file.path))
73
+ continue;
74
+ const key = file.pack.name + '|' + nameKey;
75
+ const list = index.get(key) ?? [];
76
+ list.push({
77
+ file: file.path,
78
+ line: currentDeclaration.node.startPosition.row + 1,
79
+ fingerprint: baseDeclaration.fingerprint,
80
+ scope: scopeFor(file.path),
81
+ });
82
+ index.set(key, list);
83
+ }
36
84
  }
37
85
  }
38
86
  for (const file of g.foreign) {
39
87
  if (TESTISH.test(file.path))
40
88
  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
- }));
89
+ if (file.changed.beforePath && file.changed.beforePath !== file.path)
90
+ continue;
91
+ const fileDeclarations = declarations.get(file.path);
92
+ for (const [nameKey, currentDeclarations] of fileDeclarations.current) {
93
+ for (const { name, node: decl, fingerprint } of currentDeclarations) {
94
+ const line = decl.startPosition.row + 1;
95
+ if (!file.changed.added.has(line))
96
+ continue;
97
+ if (name.length < 6 || GENERIC.has(name.toLowerCase()))
98
+ continue;
99
+ const existedHere = (fileDeclarations.base?.get(nameKey) ?? []).some((baseDeclaration) => baseDeclaration.fingerprint === fingerprint);
100
+ if (existedHere)
101
+ continue;
102
+ const key = file.pack.name + '|' + nameKey;
103
+ const scope = scopeFor(file.path);
104
+ const match = (index.get(key) ?? []).find((candidate) => candidate.file !== file.path &&
105
+ candidate.scope === scope &&
106
+ candidate.fingerprint === fingerprint);
107
+ if (!match)
108
+ continue;
109
+ findings.push(finding(file, decl, {
110
+ check: 'reinvented',
111
+ severity: 'medium',
112
+ confidence: 'firm',
113
+ title: name + ' repeats the implementation at ' + match.file,
114
+ evidence: {
115
+ oracle: file.pack.name + ' base declaration + token fingerprint',
116
+ detail: 'token-identical implementation already present at ' + match.file + ':' + match.line,
117
+ },
118
+ fix: 'Consider reusing the existing declaration; if the separation is intentional, keep the boundary explicit',
119
+ }));
120
+ }
60
121
  }
61
122
  }
62
123
  return findings;
@@ -1,14 +1,28 @@
1
+ import { posix } from 'node:path';
1
2
  import { nodesOfType, walk } from '#app/lang/packs.js';
3
+ import { implementationFingerprint } from '#app/reinvention.js';
2
4
  /**
3
- * In tree-sitter-rust and -go a `string_literal` has exactly two children, the quotes,
4
- * and the text between them belongs to no node so walking to leaves loses it, and
5
- * two files differing only in what their strings say tokenize identically. Atomic.
5
+ * Some wasm grammars leave meaningful text outside child nodes: Rust/Go string
6
+ * contents and Kotlin's nullable `?` are examples. Keep those enclosing nodes atomic
7
+ * so a value or contract change cannot disappear from the fingerprint.
6
8
  */
7
- const ATOMIC = /string|char|raw_|heredoc|interpolat/;
9
+ const ATOMIC = /string|char|raw_|heredoc|interpolat|nullable_type/;
8
10
  /** The token stream: what a compiler sees, minus layout and comments. */
9
- export function tokensFor(root, pack) {
11
+ function nodeKey(node) {
12
+ return [
13
+ node.type,
14
+ node.startPosition.row,
15
+ node.startPosition.column,
16
+ node.endPosition.row,
17
+ node.endPosition.column,
18
+ ].join(':');
19
+ }
20
+ export function tokensFor(root, pack, exclude) {
10
21
  const out = [];
22
+ const excluded = new Set((exclude === undefined ? [] : Array.isArray(exclude) ? exclude : [exclude]).map(nodeKey));
11
23
  const visit = (n) => {
24
+ if (excluded.has(nodeKey(n)))
25
+ return;
12
26
  if (pack.nodes.comment.includes(n.type))
13
27
  return;
14
28
  if (ATOMIC.test(n.type)) {
@@ -29,6 +43,13 @@ export function tokensFor(root, pack) {
29
43
  visit(root);
30
44
  return out;
31
45
  }
46
+ /** Tokens that affect compilation, including comment-shaped file constraints. */
47
+ export function sourceTokensFor(root, pack, exclude) {
48
+ return [
49
+ ...(pack.fileConstraints?.(root) ?? []).map((text) => ({ type: '__file_constraint__', text })),
50
+ ...tokensFor(root, pack, exclude),
51
+ ];
52
+ }
32
53
  export function commentText(root, pack) {
33
54
  const parts = [];
34
55
  walk(root, (n) => {
@@ -52,6 +73,16 @@ export function documentsSomething(root, pack) {
52
73
  export function same(a, b) {
53
74
  return a.length === b.length && a.every((t, i) => t.type === b[i].type && t.text === b[i].text);
54
75
  }
76
+ /** Layout-insensitive identity of a file's declared package/module, when it has one. */
77
+ export function fileScopeIdentity(root, pack) {
78
+ const tokens = root.namedChildren
79
+ .filter((child) => pack.nodes.fileScope.includes(child.type))
80
+ .flatMap((child) => tokensFor(child, pack));
81
+ const constraints = pack.fileConstraints?.(root) ?? [];
82
+ return tokens.length === 0 && constraints.length === 0
83
+ ? undefined
84
+ : 'file:' + JSON.stringify({ tokens, constraints });
85
+ }
55
86
  export function finding(file, node, f) {
56
87
  return {
57
88
  id: '',
@@ -68,27 +99,208 @@ export function finding(file, node, f) {
68
99
  */
69
100
  export function declaredName(decl, pack) {
70
101
  const field = decl.childForFieldName(pack.nodes.declarationName);
102
+ // tree-sitter-kotlin exposes the declaration identifier as a named child but
103
+ // assigns no field name to it. Keep the grammar field authoritative when one is
104
+ // present; otherwise the first identifier inside the declaration is its name.
71
105
  if (!field)
72
- return undefined;
106
+ return nodesOfType(decl, pack.nodes.identifier)[0]?.text;
73
107
  if (field.childCount === 0)
74
108
  return field.text;
75
109
  // C and C++ wrap the name in a declarator; look inside that, never wider
76
110
  return nodesOfType(field, pack.nodes.identifier)[0]?.text;
77
111
  }
78
- /** Module level only: two classes sharing a method name is polymorphism. */
79
- export function topLevelDeclarations(root, pack) {
80
- const all = nodesOfType(root, pack.nodes.declaration);
81
- const out = new Map();
82
- for (const decl of all) {
83
- const nested = all.some((other) => other !== decl &&
84
- other.startPosition.row <= decl.startPosition.row &&
85
- other.endPosition.row >= decl.endPosition.row);
86
- if (nested)
87
- continue;
88
- const name = declaredName(decl, pack);
89
- if (name)
90
- out.set(name, decl);
112
+ export function reusableDeclarations(root, pack, bindingPath = '') {
113
+ const raw = [];
114
+ const contexts = [];
115
+ const bindings = [];
116
+ const namesIn = (tokens) => [
117
+ ...new Set(tokens.filter((token) => pack.nodes.bindingIdentifier.includes(token.type)).map((token) => token.text)),
118
+ ];
119
+ const addBinding = (scope, node, tokens, indexNames) => {
120
+ if (indexNames.length > 0) {
121
+ bindings.push({
122
+ node,
123
+ tokens,
124
+ scope,
125
+ indexNames,
126
+ references: namesIn(tokens),
127
+ fingerprint: implementationFingerprint([
128
+ { type: '__binding_scope__', text: JSON.stringify(scope) },
129
+ ...tokens,
130
+ ]),
131
+ });
132
+ }
133
+ };
134
+ const visit = (container, scope, fingerprintRoot) => {
135
+ let prefix = [];
136
+ let blocked = false;
137
+ for (const child of container.namedChildren) {
138
+ if (pack.nodes.comment.includes(child.type))
139
+ continue;
140
+ if (pack.nodes.reusablePrefix.includes(child.type)) {
141
+ prefix.push(child);
142
+ continue;
143
+ }
144
+ if (pack.nodes.reusableBlocker.includes(child.type)) {
145
+ blocked = true;
146
+ continue;
147
+ }
148
+ if (pack.nodes.bindingContext.includes(child.type)) {
149
+ contexts.push({ scope, node: child, tokens: tokensFor(child, pack) });
150
+ prefix = [];
151
+ blocked = false;
152
+ continue;
153
+ }
154
+ if (pack.nodes.reusableDeclaration.includes(child.type)) {
155
+ if (blocked || (pack.reusableAcrossFiles && !pack.reusableAcrossFiles(child))) {
156
+ prefix = [];
157
+ blocked = false;
158
+ continue;
159
+ }
160
+ const name = declaredName(child, pack);
161
+ if (!name) {
162
+ if (pack.nodes.bindingDeclaration.includes(child.type)) {
163
+ const bindingTokens = tokensFor(child, pack);
164
+ addBinding(scope, child, bindingTokens, namesIn(bindingTokens));
165
+ }
166
+ prefix = [];
167
+ blocked = false;
168
+ continue;
169
+ }
170
+ const ownTokens = [
171
+ ...prefix.flatMap((node) => tokensFor(node, pack)),
172
+ ...tokensFor(fingerprintRoot ?? child, pack),
173
+ ];
174
+ raw.push({
175
+ scope,
176
+ name,
177
+ node: child,
178
+ tokens: ownTokens,
179
+ });
180
+ addBinding(scope, child, ownTokens, [name]);
181
+ prefix = [];
182
+ blocked = false;
183
+ continue;
184
+ }
185
+ if (pack.nodes.bindingDeclaration.includes(child.type)) {
186
+ const bindingTokens = tokensFor(child, pack);
187
+ addBinding(scope, child, bindingTokens, namesIn(bindingTokens));
188
+ prefix = [];
189
+ blocked = false;
190
+ continue;
191
+ }
192
+ if (!pack.nodes.reusableContainer.includes(child.type)) {
193
+ prefix = [];
194
+ blocked = false;
195
+ continue;
196
+ }
197
+ let nextScope = scope;
198
+ if (pack.nodes.reusableScope.includes(child.type)) {
199
+ const name = child.childForFieldName('name')?.text;
200
+ // An anonymous namespace cannot provide a cross-file reuse candidate.
201
+ if (!name)
202
+ continue;
203
+ nextScope = [...scope, name];
204
+ }
205
+ visit(child, nextScope, fingerprintRoot ?? (pack.nodes.reusableWrapper.includes(child.type) ? child : undefined));
206
+ prefix = [];
207
+ blocked = false;
208
+ }
209
+ };
210
+ const fileScope = fileScopeIdentity(root, pack);
211
+ visit(root, fileScope ? [fileScope] : []);
212
+ const scopeKey = (scope) => JSON.stringify(scope);
213
+ const scopePrefixes = (scope) => Array.from({ length: scope.length + 1 }, (_, length) => scopeKey(scope.slice(0, length)));
214
+ const byName = new Map();
215
+ for (const binding of bindings) {
216
+ for (const name of binding.indexNames) {
217
+ const byScope = byName.get(name) ?? new Map();
218
+ const scoped = byScope.get(scopeKey(binding.scope)) ?? [];
219
+ scoped.push(binding);
220
+ byScope.set(scopeKey(binding.scope), scoped);
221
+ byName.set(name, byScope);
222
+ }
91
223
  }
92
- return out;
224
+ const needsBindingDirectory = (tokens) => {
225
+ const text = tokens.map((token) => token.text).join(' ');
226
+ return /\brequire_relative\b|\b(?:require|include)(?:_once)?\b|\bfrom\s+\.+|["']\.\.?\/|#\s*include\s*"|\buse\s+(?:self|super)\s*::|\bmod\s+[A-Za-z_]\w*\s*;/.test(text);
227
+ };
228
+ const contextsByScope = new Map();
229
+ for (const context of contexts) {
230
+ const scoped = contextsByScope.get(scopeKey(context.scope)) ?? [];
231
+ scoped.push(context);
232
+ contextsByScope.set(scopeKey(context.scope), scoped);
233
+ }
234
+ const contextCache = new Map();
235
+ const contextFor = (scope) => {
236
+ const key = scopeKey(scope);
237
+ const known = contextCache.get(key);
238
+ if (known)
239
+ return known;
240
+ const visibleContexts = scopePrefixes(scope)
241
+ .flatMap((prefix) => contextsByScope.get(prefix) ?? [])
242
+ .sort((left, right) => left.node.startPosition.row - right.node.startPosition.row ||
243
+ left.node.startPosition.column - right.node.startPosition.column);
244
+ const ordered = visibleContexts.flatMap((context) => [
245
+ { type: '__binding_scope__', text: JSON.stringify(context.scope) },
246
+ ...context.tokens,
247
+ ]);
248
+ const value = {
249
+ tokens: ordered.length === 0
250
+ ? []
251
+ : [{ type: '__binding_context__', text: implementationFingerprint(ordered) }],
252
+ needsDirectory: visibleContexts.some((context) => needsBindingDirectory(context.tokens)),
253
+ };
254
+ contextCache.set(key, value);
255
+ return value;
256
+ };
257
+ const identity = (node) => [
258
+ node.type,
259
+ node.startPosition.row,
260
+ node.startPosition.column,
261
+ node.endPosition.row,
262
+ node.endPosition.column,
263
+ ].join(':');
264
+ return raw.map((declaration) => {
265
+ const context = contextFor(declaration.scope);
266
+ const selected = new Map();
267
+ const pending = namesIn(declaration.tokens);
268
+ const visitedNames = new Set();
269
+ const target = identity(declaration.node);
270
+ while (pending.length > 0) {
271
+ const name = pending.pop();
272
+ if (visitedNames.has(name))
273
+ continue;
274
+ visitedNames.add(name);
275
+ const byScope = byName.get(name);
276
+ for (const binding of scopePrefixes(declaration.scope).flatMap((prefix) => byScope?.get(prefix) ?? [])) {
277
+ const key = identity(binding.node);
278
+ if (key === target || selected.has(key))
279
+ continue;
280
+ selected.set(key, binding);
281
+ // More than this is a generated binding graph, not a helper a human can
282
+ // meaningfully reuse. Abstain instead of doing quadratic work on it.
283
+ if (selected.size > 512)
284
+ return [];
285
+ for (const referenced of binding.references) {
286
+ if (!visitedNames.has(referenced))
287
+ pending.push(referenced);
288
+ }
289
+ }
290
+ }
291
+ const bindingTokens = [...selected.values()]
292
+ .sort((left, right) => left.node.startPosition.row - right.node.startPosition.row ||
293
+ left.node.startPosition.column - right.node.startPosition.column)
294
+ .map((binding) => ({ type: '__binding__', text: binding.fingerprint }));
295
+ const directoryToken = bindingPath !== '' && (context.needsDirectory ||
296
+ needsBindingDirectory(declaration.tokens) ||
297
+ [...selected.values()].some((binding) => needsBindingDirectory(binding.tokens)))
298
+ ? [{ type: '__binding_directory__', text: posix.dirname(bindingPath.replaceAll('\\', '/')) }]
299
+ : [];
300
+ return [{
301
+ ...declaration,
302
+ tokens: [...directoryToken, ...context.tokens, ...bindingTokens, ...declaration.tokens],
303
+ }];
304
+ }).flat();
93
305
  }
94
306
  //# sourceMappingURL=foreign-tokens.js.map
@@ -0,0 +1,138 @@
1
+ import { packFor } from '#app/lang/packs.js';
2
+ import { implementationFingerprint, typescriptSourceFingerprint } from '#app/reinvention.js';
3
+ import { sourceTokensFor } from './foreign-tokens.js';
4
+ const NATIVE_SOURCE = /\.[cm]?[jt]sx?$/i;
5
+ const EXECUTABLE_CHANGES = new WeakMap();
6
+ function executableChanges(g) {
7
+ const known = EXECUTABLE_CHANGES.get(g);
8
+ if (known)
9
+ return known;
10
+ const changed = new Set();
11
+ const represented = new Set();
12
+ for (const file of g.files) {
13
+ represented.add(file.changed.path);
14
+ const after = typescriptSourceFingerprint(file.sf.getFullText());
15
+ const before = typescriptSourceFingerprint(file.before?.getFullText() ?? '');
16
+ if (!after || !before || after !== before || file.changed.beforePath !== undefined) {
17
+ changed.add(file.changed.path);
18
+ }
19
+ }
20
+ for (const file of g.foreign) {
21
+ represented.add(file.path);
22
+ const after = implementationFingerprint(sourceTokensFor(file.tree.rootNode, file.pack));
23
+ const before = implementationFingerprint(file.beforeTree ? sourceTokensFor(file.beforeTree.rootNode, file.pack) : []);
24
+ if (after !== before || file.changed.beforePath !== undefined)
25
+ changed.add(file.path);
26
+ }
27
+ const supported = (path) => path !== undefined && (NATIVE_SOURCE.test(path) || packFor(path) !== undefined);
28
+ for (const file of g.inventory ?? g.changed) {
29
+ if (represented.has(file.path))
30
+ continue;
31
+ if (supported(file.path) || supported(file.beforePath))
32
+ changed.add(file.path);
33
+ }
34
+ EXECUTABLE_CHANGES.set(g, changed);
35
+ return changed;
36
+ }
37
+ /**
38
+ * A guard transfer into another changed file is outside a local syntax proof. Keep a
39
+ * HIGH/proven result only when every other supported source file is token-stable.
40
+ */
41
+ export function hasOtherExecutableChange(g, currentPath) {
42
+ const changed = executableChanges(g);
43
+ return changed.size > (changed.has(currentPath) ? 1 : 0);
44
+ }
45
+ /**
46
+ * Return the guards removed by an otherwise token-identical block rewrite.
47
+ *
48
+ * This is deliberately a narrow proof. A helper extraction, inserted validation call,
49
+ * changed continuation, or moved statement makes the block ambiguous and produces no
50
+ * deterministic finding. Those changes need semantic judgement; a pre/post syntax
51
+ * oracle cannot honestly call them lost guards.
52
+ */
53
+ function guardOnlyDeletion(before, after) {
54
+ const removed = [];
55
+ let left = 0;
56
+ let right = 0;
57
+ while (left < before.entries.length && right < after.entries.length) {
58
+ const old = before.entries[left];
59
+ const current = after.entries[right];
60
+ if (old.fingerprint === current.fingerprint) {
61
+ left++;
62
+ right++;
63
+ continue;
64
+ }
65
+ if (old.guard !== undefined) {
66
+ // A guard only protects a continuation in its own block. If it is the last
67
+ // meaningful statement, deleting it is not the failure this check promises.
68
+ if (before.entries.slice(left + 1).some((entry) => entry.guard === undefined)) {
69
+ removed.push({ id: old.id, ...old.guard });
70
+ }
71
+ left++;
72
+ continue;
73
+ }
74
+ return undefined;
75
+ }
76
+ if (right !== after.entries.length)
77
+ return undefined;
78
+ while (left < before.entries.length) {
79
+ const old = before.entries[left];
80
+ if (old.guard === undefined)
81
+ return undefined;
82
+ if (before.entries.slice(left + 1).some((entry) => entry.guard === undefined)) {
83
+ removed.push({ id: old.id, ...old.guard });
84
+ }
85
+ left++;
86
+ }
87
+ return removed.length > 0 ? removed : undefined;
88
+ }
89
+ /**
90
+ * Guards whose removal is the only executable change in a uniquely matched block.
91
+ * Ambiguous duplicate blocks are skipped rather than paired by traversal order.
92
+ */
93
+ export function provenGuardRemovals(before, after) {
94
+ // A name is not a callable contract. Changing a parameter, receiver, owner, or
95
+ // return type can make a formerly necessary guard obsolete.
96
+ if (before.identity !== after.identity)
97
+ return [];
98
+ const uniqueByPath = (blocks) => {
99
+ const out = new Map();
100
+ for (const block of blocks) {
101
+ if (out.has(block.path))
102
+ out.set(block.path, undefined);
103
+ else
104
+ out.set(block.path, block);
105
+ }
106
+ return out;
107
+ };
108
+ const current = uniqueByPath(after.blocks);
109
+ const remainingGuards = new Set(after.blocks.flatMap((block) => block.entries.flatMap((entry) => entry.guard === undefined ? [] : [entry.guard.key])));
110
+ const structurallyRemoved = [];
111
+ for (const [path, previous] of uniqueByPath(before.blocks)) {
112
+ const now = current.get(path);
113
+ if (!previous || !now)
114
+ continue;
115
+ structurallyRemoved.push(...(guardOnlyDeletion(previous, now) ?? []));
116
+ }
117
+ if (structurallyRemoved.length === 0)
118
+ return [];
119
+ // A block-local diff is not enough: another branch or sibling statement may have
120
+ // changed the guarantee that made the guard obsolete. Remove the candidate guard
121
+ // nodes from the old callable and require every remaining compiler-visible token
122
+ // to equal the new callable. This is the fact behind the `proven` confidence.
123
+ const omitted = new Set(structurallyRemoved.map((guard) => guard.id));
124
+ const previousResidual = before.residualFingerprint(omitted);
125
+ const currentResidual = after.residualFingerprint(new Set());
126
+ if (!previousResidual || previousResidual !== currentResidual)
127
+ return [];
128
+ const reportable = new Map();
129
+ for (const guard of structurallyRemoved) {
130
+ // A syntactically identical guard elsewhere in the callable may dominate the
131
+ // continuation. Without a control-flow graph, abstaining is the only proof-safe
132
+ // answer; false negatives are preferable to a false HIGH/proven defect.
133
+ if (!remainingGuards.has(guard.key))
134
+ reportable.set(guard.key, guard.label);
135
+ }
136
+ return [...reportable.values()];
137
+ }
138
+ //# sourceMappingURL=guard-diff.js.map
@@ -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.
@@ -10,13 +11,15 @@ const GENERIC = new Set([
10
11
  'handler', 'handle', 'create', 'update', 'remove', 'delete', 'list', 'find',
11
12
  'parse', 'format', 'load', 'save', 'toString', 'default', 'config', 'options',
12
13
  ]);
13
- function declaredNames(sf) {
14
+ function declaredNames(sf, bindingPath) {
14
15
  const out = [];
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, bindingPath);
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, bindingPath);
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,30 @@ 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, changed.beforePath ?? file) : [];
53
+ for (const { name, line, span, fingerprint } of declaredNames(sf, file)) {
45
54
  if (!changed.added.has(line))
46
55
  continue;
56
+ if (changed.beforePath && changed.beforePath !== changed.path)
57
+ continue;
47
58
  if (name.length < 6 || GENERIC.has(name) || GENERIC.has(name.toLowerCase()))
48
59
  continue;
49
- const existing = (g.symbolIndex.get(normalizeName(name)) ?? []).filter((s) => s.file !== file);
50
- const match = existing[0];
60
+ const existedHere = baseDeclarations.some((declaration) => normalizeName(declaration.name) === normalizeName(name) &&
61
+ declaration.fingerprint === fingerprint);
62
+ if (existedHere)
63
+ continue;
64
+ const match = (g.symbolIndex.get(normalizeName(name)) ?? []).find((symbol) => symbol.file !== file &&
65
+ symbol.existedInBase &&
66
+ symbol.scope === scope &&
67
+ symbol.fingerprint === fingerprint);
51
68
  if (!match)
52
69
  continue;
53
70
  findings.push({
@@ -59,12 +76,15 @@ export const reinvented = {
59
76
  file,
60
77
  line,
61
78
  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 },
79
+ title: name + '() repeats the implementation at ' + match.file + ':' + match.name,
80
+ evidence: {
81
+ oracle: 'base export + callable token fingerprint',
82
+ detail: 'token-identical implementation already exported from ' + match.file + ':' + match.line,
83
+ },
64
84
  // Deliberately not an import statement: the correct specifier depends on the
65
85
  // repo's module resolution, and a wrong one would be exactly the kind of
66
86
  // confidently-wrong output this tool exists to catch.
67
- fix: 'Reuse ' + match.name + ' from ' + match.file + ' instead of redeclaring it',
87
+ fix: 'Consider reusing ' + match.name + ' from ' + match.file + '; if the separation is intentional, keep the boundary explicit',
68
88
  });
69
89
  }
70
90
  }