@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.
- package/README.md +2 -2
- package/dist/git.js +5 -3
- package/dist/ground.js +61 -13
- package/dist/lang/packs.js +170 -8
- package/dist/langtest.js +1144 -2
- package/dist/reinvention.js +334 -0
- package/dist/report/sarif.js +2 -2
- package/dist/review.js +5 -1
- package/dist/selftest.js +575 -8
- package/dist/verifiers/dropped-guard.js +188 -84
- package/dist/verifiers/foreign-dropped-guard.js +174 -50
- package/dist/verifiers/foreign-reinvented.js +87 -26
- package/dist/verifiers/foreign-tokens.js +232 -20
- package/dist/verifiers/guard-diff.js +138 -0
- package/dist/verifiers/reinvented.js +31 -11
- package/docs/architecture.md +26 -1
- package/package.json +1 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
*
|
|
4
|
-
* and
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, 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
|
|
50
|
-
|
|
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 + '()
|
|
63
|
-
evidence: {
|
|
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: '
|
|
87
|
+
fix: 'Consider reusing ' + match.name + ' from ' + match.file + '; if the separation is intentional, keep the boundary explicit',
|
|
68
88
|
});
|
|
69
89
|
}
|
|
70
90
|
}
|