@0xcraft/powershot 1.1.4 → 1.2.0
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 +23 -9
- package/dist/cli/args.js +2 -1
- package/dist/cli/review-command.js +30 -12
- package/dist/cli/session-command.js +1 -0
- package/dist/delegate.js +89 -13
- package/dist/git.js +5 -3
- package/dist/ground.js +19 -12
- package/dist/lang/packs.js +170 -8
- package/dist/langtest.js +1056 -6
- package/dist/plan.js +25 -0
- package/dist/reinvention.js +227 -2
- package/dist/report/sarif.js +2 -2
- package/dist/report/summary.js +12 -4
- package/dist/review.js +5 -13
- package/dist/selftest.js +507 -17
- package/dist/session.js +1 -0
- package/dist/verifiers/dropped-guard.js +188 -84
- package/dist/verifiers/foreign-dropped-guard.js +174 -50
- package/dist/verifiers/foreign-reinvented.js +22 -7
- package/dist/verifiers/foreign-tokens.js +228 -19
- package/dist/verifiers/guard-diff.js +138 -0
- package/dist/verifiers/reinvented.js +7 -5
- package/docs/architecture.md +33 -0
- package/docs/ci.md +9 -4
- package/package.json +1 -1
package/dist/session.js
CHANGED
|
@@ -110,6 +110,7 @@ export class Session {
|
|
|
110
110
|
verifyOnly: verdict.verifyOnly,
|
|
111
111
|
minSeverity: verdict.minSeverity,
|
|
112
112
|
filesReviewed: verdict.filesReviewed,
|
|
113
|
+
filesChanged: verdict.filesChanged,
|
|
113
114
|
deterministicChecks: verdict.deterministicChecks,
|
|
114
115
|
scopeDetails: verdict.scopeDetails ? [...verdict.scopeDetails] : undefined,
|
|
115
116
|
};
|
|
@@ -1,123 +1,224 @@
|
|
|
1
1
|
import { Node, SyntaxKind } from 'ts-morph';
|
|
2
2
|
import { locate, relPath } from '#app/ground.js';
|
|
3
|
+
import { typescriptSourceFingerprint, typescriptTokenFingerprint } from '#app/reinvention.js';
|
|
4
|
+
import { hasOtherExecutableChange, provenGuardRemovals, } from './guard-diff.js';
|
|
5
|
+
function fingerprintWithoutRange(root, start, end, marker) {
|
|
6
|
+
const source = root.getFullText();
|
|
7
|
+
const rootStart = root.getFullStart();
|
|
8
|
+
return typescriptSourceFingerprint(source.slice(0, start - rootStart) + marker + source.slice(end - rootStart));
|
|
9
|
+
}
|
|
10
|
+
function fingerprintWithoutRanges(root, ranges, marker) {
|
|
11
|
+
const source = root.getFullText();
|
|
12
|
+
const rootStart = root.getFullStart();
|
|
13
|
+
const ordered = [...ranges].sort((left, right) => left.getStart() - right.getStart());
|
|
14
|
+
const parts = [];
|
|
15
|
+
let cursor = 0;
|
|
16
|
+
for (const range of ordered) {
|
|
17
|
+
const start = range.getFullStart() - rootStart;
|
|
18
|
+
const end = range.getEnd() - rootStart;
|
|
19
|
+
if (start < cursor)
|
|
20
|
+
continue;
|
|
21
|
+
parts.push(source.slice(cursor, start), marker);
|
|
22
|
+
cursor = end;
|
|
23
|
+
}
|
|
24
|
+
parts.push(source.slice(cursor));
|
|
25
|
+
return typescriptSourceFingerprint(parts.join(''));
|
|
26
|
+
}
|
|
27
|
+
function fingerprintWithoutBody(identityNode, body) {
|
|
28
|
+
return fingerprintWithoutRange(identityNode, body.getStart(), body.getEnd(), '__powershot_body__');
|
|
29
|
+
}
|
|
30
|
+
function callable(name, node, moduleContract, identityNode = node, owner = '') {
|
|
31
|
+
if (!Node.isFunctionDeclaration(node) &&
|
|
32
|
+
!Node.isMethodDeclaration(node) &&
|
|
33
|
+
!Node.isArrowFunction(node) &&
|
|
34
|
+
!Node.isFunctionExpression(node))
|
|
35
|
+
return undefined;
|
|
36
|
+
const body = node.getBody();
|
|
37
|
+
if (!body)
|
|
38
|
+
return undefined;
|
|
39
|
+
const fingerprint = fingerprintWithoutBody(identityNode, body);
|
|
40
|
+
return fingerprint ? { name, node, identity: moduleContract + '|' + owner + '|' + name + '|' + fingerprint } : undefined;
|
|
41
|
+
}
|
|
42
|
+
function classContract(node) {
|
|
43
|
+
if (!Node.isClassDeclaration(node))
|
|
44
|
+
return undefined;
|
|
45
|
+
const members = node.getMembers();
|
|
46
|
+
const first = members[0];
|
|
47
|
+
const last = members.at(-1);
|
|
48
|
+
if (!first || !last)
|
|
49
|
+
return typescriptTokenFingerprint(node.getText());
|
|
50
|
+
return fingerprintWithoutRange(node, first.getStart(), last.getEnd(), '__powershot_members__');
|
|
51
|
+
}
|
|
52
|
+
function moduleContract(source) {
|
|
53
|
+
const bodies = [];
|
|
54
|
+
for (const declaration of source.getFunctions()) {
|
|
55
|
+
const body = declaration.getBody();
|
|
56
|
+
if (body)
|
|
57
|
+
bodies.push(body);
|
|
58
|
+
}
|
|
59
|
+
for (const declaration of source.getClasses()) {
|
|
60
|
+
for (const method of declaration.getMethods()) {
|
|
61
|
+
const body = method.getBody();
|
|
62
|
+
if (body)
|
|
63
|
+
bodies.push(body);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const declaration of source.getVariableDeclarations()) {
|
|
67
|
+
const initializer = declaration.getInitializer();
|
|
68
|
+
if (initializer && (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer))) {
|
|
69
|
+
bodies.push(initializer.getBody());
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return fingerprintWithoutRanges(source, bodies, '__powershot_callable_body__');
|
|
73
|
+
}
|
|
3
74
|
function functionsOf(sf) {
|
|
4
75
|
const out = [];
|
|
76
|
+
const module = moduleContract(sf);
|
|
77
|
+
if (!module)
|
|
78
|
+
return out;
|
|
5
79
|
for (const fn of sf.getFunctions()) {
|
|
6
80
|
const name = fn.getName();
|
|
7
|
-
|
|
8
|
-
|
|
81
|
+
const found = name ? callable(name, fn, module) : undefined;
|
|
82
|
+
if (found)
|
|
83
|
+
out.push(found);
|
|
9
84
|
}
|
|
10
85
|
for (const cls of sf.getClasses()) {
|
|
11
86
|
const prefix = (cls.getName() ?? 'anonymous') + '.';
|
|
12
|
-
|
|
13
|
-
|
|
87
|
+
const owner = classContract(cls);
|
|
88
|
+
if (!owner)
|
|
89
|
+
continue;
|
|
90
|
+
for (const m of cls.getMethods()) {
|
|
91
|
+
const found = callable(prefix + m.getName(), m, module, m, owner);
|
|
92
|
+
if (found)
|
|
93
|
+
out.push(found);
|
|
94
|
+
}
|
|
14
95
|
}
|
|
15
96
|
for (const v of sf.getVariableDeclarations()) {
|
|
16
97
|
const init = v.getInitializer();
|
|
17
98
|
if (init?.isKind(SyntaxKind.ArrowFunction) || init?.isKind(SyntaxKind.FunctionExpression)) {
|
|
18
|
-
|
|
99
|
+
const found = callable(v.getName(), init, module, v);
|
|
100
|
+
if (found)
|
|
101
|
+
out.push(found);
|
|
19
102
|
}
|
|
20
103
|
}
|
|
21
104
|
return out;
|
|
22
105
|
}
|
|
106
|
+
function functionsByName(sf) {
|
|
107
|
+
const out = new Map();
|
|
108
|
+
for (const fn of functionsOf(sf)) {
|
|
109
|
+
const list = out.get(fn.name) ?? [];
|
|
110
|
+
list.push(fn);
|
|
111
|
+
out.set(fn.name, list);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
23
115
|
/** collapse whitespace so reformatting alone never reads as a dropped guard */
|
|
24
116
|
function normalize(text) {
|
|
25
117
|
return text.replace(/\s+/g, ' ').trim();
|
|
26
118
|
}
|
|
27
|
-
/** every identifier the new version of a function still mentions */
|
|
28
|
-
function identifiersIn(fn) {
|
|
29
|
-
const out = new Set();
|
|
30
|
-
for (const id of fn.getDescendantsOfKind(SyntaxKind.Identifier))
|
|
31
|
-
out.add(id.getText());
|
|
32
|
-
return out;
|
|
33
|
-
}
|
|
34
119
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* is a field, and a rewritten body has no reason to mention it.
|
|
120
|
+
* A guard is an `if` whose branch bails out — throw, return, continue, or break.
|
|
121
|
+
* That shape is what protects the code below it, so losing one changes behaviour.
|
|
38
122
|
*/
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
123
|
+
function guardOf(statement) {
|
|
124
|
+
if (!Node.isIfStatement(statement))
|
|
125
|
+
return undefined;
|
|
126
|
+
if (statement.getElseStatement())
|
|
127
|
+
return undefined;
|
|
128
|
+
const branch = statement.getThenStatement();
|
|
129
|
+
const directBail = (node) => node.isKind(SyntaxKind.ThrowStatement) ||
|
|
130
|
+
node.isKind(SyntaxKind.ReturnStatement) ||
|
|
131
|
+
node.isKind(SyntaxKind.ContinueStatement) ||
|
|
132
|
+
node.isKind(SyntaxKind.BreakStatement);
|
|
133
|
+
const last = Node.isBlock(branch) ? branch.getStatements().at(-1) : branch;
|
|
134
|
+
if (!last || !directBail(last))
|
|
135
|
+
return undefined;
|
|
136
|
+
const expression = statement.getExpression();
|
|
137
|
+
const fingerprint = typescriptTokenFingerprint(expression.getText());
|
|
138
|
+
return fingerprint ? { key: 'if|' + fingerprint, label: normalize(expression.getText()) } : undefined;
|
|
139
|
+
}
|
|
140
|
+
function proofOf(fn) {
|
|
141
|
+
const statements = new Map();
|
|
142
|
+
const blocks = [];
|
|
143
|
+
for (const block of fn.node.getDescendantsOfKind(SyntaxKind.Block)) {
|
|
144
|
+
if (!belongsTo(fn.node, block))
|
|
47
145
|
continue;
|
|
48
|
-
|
|
146
|
+
const entries = [];
|
|
147
|
+
let complete = true;
|
|
148
|
+
for (const statement of block.getStatements()) {
|
|
149
|
+
const id = pathFrom(fn.node, statement);
|
|
150
|
+
const fingerprint = typescriptTokenFingerprint(statement.getText());
|
|
151
|
+
if (!id || !fingerprint) {
|
|
152
|
+
complete = false;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
statements.set(id, statement);
|
|
156
|
+
entries.push({ id, fingerprint, guard: guardOf(statement) });
|
|
157
|
+
}
|
|
158
|
+
if (complete)
|
|
159
|
+
blocks.push({ path: pathFrom(fn.node, block), entries });
|
|
49
160
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
161
|
+
return {
|
|
162
|
+
identity: fn.identity,
|
|
163
|
+
blocks,
|
|
164
|
+
residualFingerprint(omitted) {
|
|
165
|
+
const ranges = [];
|
|
166
|
+
for (const id of omitted) {
|
|
167
|
+
const statement = statements.get(id);
|
|
168
|
+
if (!statement)
|
|
169
|
+
return undefined;
|
|
170
|
+
ranges.push(statement);
|
|
171
|
+
}
|
|
172
|
+
return fingerprintWithoutRanges(fn.node.getSourceFile(), ranges, '');
|
|
173
|
+
},
|
|
174
|
+
};
|
|
62
175
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (roots.length === 0)
|
|
72
|
-
return false;
|
|
73
|
-
return [...after.values()].some((other) => roots.every((r) => other.includes(r)));
|
|
176
|
+
function belongsTo(root, node) {
|
|
177
|
+
let current = node.getParent();
|
|
178
|
+
while (current && current !== root) {
|
|
179
|
+
if (Node.isFunctionLikeDeclaration(current))
|
|
180
|
+
return false;
|
|
181
|
+
current = current.getParent();
|
|
182
|
+
}
|
|
183
|
+
return current === root;
|
|
74
184
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const bails = branch.getDescendantsOfKind(SyntaxKind.ThrowStatement).length > 0 ||
|
|
85
|
-
branch.getDescendantsOfKind(SyntaxKind.ReturnStatement).length > 0 ||
|
|
86
|
-
branch.getDescendantsOfKind(SyntaxKind.ContinueStatement).length > 0 ||
|
|
87
|
-
branch.getDescendantsOfKind(SyntaxKind.BreakStatement).length > 0 ||
|
|
88
|
-
Node.isThrowStatement(branch) ||
|
|
89
|
-
Node.isReturnStatement(branch);
|
|
90
|
-
if (bails)
|
|
91
|
-
guards.set(normalize(ifStmt.getExpression().getText()), rootsOf(ifStmt.getExpression()));
|
|
185
|
+
function pathFrom(root, node) {
|
|
186
|
+
const path = [];
|
|
187
|
+
let current = node;
|
|
188
|
+
while (current && current !== root) {
|
|
189
|
+
const parent = current.getParent();
|
|
190
|
+
if (!parent)
|
|
191
|
+
return '';
|
|
192
|
+
path.push(current.getChildIndex());
|
|
193
|
+
current = parent;
|
|
92
194
|
}
|
|
93
|
-
return
|
|
195
|
+
return path.reverse().join('.');
|
|
94
196
|
}
|
|
95
197
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* condition text was there and now it is not.
|
|
198
|
+
* The pre/post AST pair proves only a guard-only deletion from the same callable and
|
|
199
|
+
* lexical block. Refactors that move or change the continuation deliberately abstain.
|
|
99
200
|
*/
|
|
100
201
|
export const droppedGuard = {
|
|
101
202
|
name: 'dropped-guard',
|
|
102
203
|
needs: ['syntax', 'base'],
|
|
103
204
|
run(g) {
|
|
104
205
|
const findings = [];
|
|
105
|
-
for (const { sf, before } of g.files) {
|
|
206
|
+
for (const { sf, before, changed } of g.files) {
|
|
106
207
|
if (!before)
|
|
107
208
|
continue; // a new file cannot have dropped anything
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
209
|
+
const file = relPath(sf, g.root);
|
|
210
|
+
if (changed.beforePath && changed.beforePath !== changed.path)
|
|
211
|
+
continue;
|
|
212
|
+
if (hasOtherExecutableChange(g, file))
|
|
213
|
+
continue;
|
|
214
|
+
const after = functionsByName(sf);
|
|
215
|
+
for (const [name, previous] of functionsByName(before)) {
|
|
216
|
+
const current = after.get(name) ?? [];
|
|
217
|
+
if (previous.length !== 1 || current.length !== 1)
|
|
115
218
|
continue;
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const dropped =
|
|
119
|
-
.filter(([guard, roots]) => !has.has(guard) && stillApplicable(roots, available) && !guardedElsewhere(roots, has))
|
|
120
|
-
.map(([guard]) => guard);
|
|
219
|
+
const prev = previous[0];
|
|
220
|
+
const now = current[0];
|
|
221
|
+
const dropped = provenGuardRemovals(proofOf(prev), proofOf(now));
|
|
121
222
|
if (dropped.length === 0)
|
|
122
223
|
continue;
|
|
123
224
|
// one finding per function, not per guard: several guards lost in the same
|
|
@@ -128,13 +229,16 @@ export const droppedGuard = {
|
|
|
128
229
|
check: 'dropped-guard',
|
|
129
230
|
severity: 'high',
|
|
130
231
|
confidence: 'proven',
|
|
131
|
-
file
|
|
232
|
+
file,
|
|
132
233
|
line: now.node.getStartLineNumber(),
|
|
133
234
|
span: locate(sf, now.node.getStart(), Math.min(now.node.getWidth(), 80)).span,
|
|
134
235
|
title: dropped.map((d) => '`if (' + d + ')`').join(' and ') +
|
|
135
236
|
' ' + (dropped.length > 1 ? 'were' : 'was') +
|
|
136
|
-
' present in ' +
|
|
137
|
-
evidence: {
|
|
237
|
+
' present in ' + name + '() before this change and ' + (dropped.length > 1 ? 'are' : 'is') + ' gone',
|
|
238
|
+
evidence: {
|
|
239
|
+
oracle: 'pre/post control-flow AST',
|
|
240
|
+
detail: 'every other file token matches after removing only the guard, with no executable change in another source file',
|
|
241
|
+
},
|
|
138
242
|
});
|
|
139
243
|
}
|
|
140
244
|
}
|
|
@@ -1,38 +1,163 @@
|
|
|
1
1
|
import { nodesOfType } from '#app/lang/packs.js';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
continue;
|
|
13
|
-
const roots = nodesOfType(cond, pack.nodes.identifier).map((n) => n.text);
|
|
14
|
-
guards.set(cond.text.replace(/\s+/g, ' ').trim(), roots);
|
|
2
|
+
import { implementationFingerprint } from '#app/reinvention.js';
|
|
3
|
+
import { hasOtherExecutableChange, provenGuardRemovals, } from './guard-diff.js';
|
|
4
|
+
import { declaredName, fileScopeIdentity, finding, sourceTokensFor, tokensFor } from './foreign-tokens.js';
|
|
5
|
+
function bodyOf(root, types) {
|
|
6
|
+
const field = root.childForFieldName('body');
|
|
7
|
+
if (field && types.includes(field.type))
|
|
8
|
+
return field;
|
|
9
|
+
for (const child of root.namedChildren) {
|
|
10
|
+
if (types.includes(child.type))
|
|
11
|
+
return child;
|
|
15
12
|
}
|
|
16
|
-
return
|
|
13
|
+
return undefined;
|
|
17
14
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
function contractPart(node, bodyTypes, pack) {
|
|
16
|
+
const body = bodyOf(node, bodyTypes);
|
|
17
|
+
if (body)
|
|
18
|
+
return node.type + ':' + implementationFingerprint(tokensFor(node, pack, body));
|
|
19
|
+
const name = node.childForFieldName('name');
|
|
20
|
+
return name
|
|
21
|
+
? node.type + ':name:' + implementationFingerprint(tokensFor(name, pack))
|
|
22
|
+
: node.type + ':anonymous';
|
|
23
|
+
}
|
|
24
|
+
function callablesByIdentity(root, pack) {
|
|
21
25
|
const out = new Map();
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
const callableBodies = nodesOfType(root, pack.nodes.callable)
|
|
27
|
+
.flatMap((node) => {
|
|
28
|
+
const body = bodyOf(node, pack.nodes.callableBody);
|
|
29
|
+
return body ? [body] : [];
|
|
30
|
+
});
|
|
31
|
+
const moduleContract = 'module:' + implementationFingerprint(tokensFor(root, pack, callableBodies));
|
|
32
|
+
const visit = (node, context) => {
|
|
33
|
+
let currentContext = context;
|
|
34
|
+
if (pack.nodes.callableOwner.includes(node.type)) {
|
|
35
|
+
currentContext = [...context, contractPart(node, pack.nodes.callableOwnerBody, pack)];
|
|
36
|
+
}
|
|
37
|
+
if (pack.nodes.callable.includes(node.type)) {
|
|
38
|
+
const name = declaredName(node, pack);
|
|
39
|
+
const body = bodyOf(node, pack.nodes.callableBody);
|
|
40
|
+
if (name && body) {
|
|
41
|
+
const part = contractPart(node, pack.nodes.callableBody, pack);
|
|
42
|
+
const identity = [...currentContext, part].join('/');
|
|
43
|
+
const list = out.get(identity) ?? [];
|
|
44
|
+
list.push({ name, node, identity });
|
|
45
|
+
out.set(identity, list);
|
|
46
|
+
// A nested callable belongs to this exact outer callable, not merely to a
|
|
47
|
+
// same-named function elsewhere in the file.
|
|
48
|
+
currentContext = [...currentContext, part];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const child of node.namedChildren)
|
|
52
|
+
visit(child, currentContext);
|
|
53
|
+
};
|
|
54
|
+
const fileScope = fileScopeIdentity(root, pack);
|
|
55
|
+
visit(root, [moduleContract, ...(fileScope ? [fileScope] : [])]);
|
|
32
56
|
return out;
|
|
33
57
|
}
|
|
34
|
-
|
|
35
|
-
|
|
58
|
+
function endsInBail(node, pack) {
|
|
59
|
+
if (pack.nodes.bail.includes(node.type))
|
|
60
|
+
return true;
|
|
61
|
+
const children = node.namedChildren.filter((child) => !pack.nodes.comment.includes(child.type));
|
|
62
|
+
if (pack.nodes.block.includes(node.type)) {
|
|
63
|
+
const last = children.at(-1);
|
|
64
|
+
return last !== undefined && endsInBail(last, pack);
|
|
65
|
+
}
|
|
66
|
+
// Some grammars wrap a return expression in one expression-statement node.
|
|
67
|
+
return children.length === 1 && endsInBail(children[0], pack);
|
|
68
|
+
}
|
|
69
|
+
function childForAnyField(node, fields) {
|
|
70
|
+
for (const field of fields) {
|
|
71
|
+
const child = node.childForFieldName(field);
|
|
72
|
+
if (child)
|
|
73
|
+
return child;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function sameNode(left, right) {
|
|
78
|
+
return left.type === right.type &&
|
|
79
|
+
left.startPosition.row === right.startPosition.row &&
|
|
80
|
+
left.startPosition.column === right.startPosition.column &&
|
|
81
|
+
left.endPosition.row === right.endPosition.row &&
|
|
82
|
+
left.endPosition.column === right.endPosition.column;
|
|
83
|
+
}
|
|
84
|
+
function guardOf(statement, pack) {
|
|
85
|
+
let conditional = statement;
|
|
86
|
+
while (!pack.nodes.ifStatement.includes(conditional.type)) {
|
|
87
|
+
const children = conditional.namedChildren.filter((child) => !pack.nodes.comment.includes(child.type));
|
|
88
|
+
if (children.length !== 1)
|
|
89
|
+
return undefined;
|
|
90
|
+
conditional = children[0];
|
|
91
|
+
}
|
|
92
|
+
const children = conditional.namedChildren.filter((child) => !pack.nodes.comment.includes(child.type));
|
|
93
|
+
const condition = childForAnyField(conditional, pack.nodes.ifCondition) ?? children[0] ?? null;
|
|
94
|
+
const branch = childForAnyField(conditional, pack.nodes.ifBody) ?? children[1] ?? null;
|
|
95
|
+
if (!condition || !branch || !endsInBail(branch, pack))
|
|
96
|
+
return undefined;
|
|
97
|
+
if (childForAnyField(conditional, pack.nodes.ifAlternative))
|
|
98
|
+
return undefined;
|
|
99
|
+
// Postfix conditionals put the branch before the condition. Reject a real
|
|
100
|
+
// alternative by identity, not by assuming every grammar orders children alike.
|
|
101
|
+
if (children.some((child) => !sameNode(child, condition) && !sameNode(child, branch)))
|
|
102
|
+
return undefined;
|
|
103
|
+
const polarity = conditional.type.includes('unless') ? 'unless' : 'if';
|
|
104
|
+
return {
|
|
105
|
+
key: polarity + '|' + implementationFingerprint(tokensFor(condition, pack)),
|
|
106
|
+
label: condition.text.replace(/\s+/g, ' ').trim(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function proofOf(callable, pack, root) {
|
|
110
|
+
const out = [];
|
|
111
|
+
const statements = new Map();
|
|
112
|
+
const visit = (node, path) => {
|
|
113
|
+
// Nested callables have their own before/after identity. Including their blocks
|
|
114
|
+
// in the parent could pair a moved continuation with the wrong lexical scope.
|
|
115
|
+
if (node !== callable.node && pack.nodes.callable.includes(node.type))
|
|
116
|
+
return;
|
|
117
|
+
if (pack.nodes.block.includes(node.type)) {
|
|
118
|
+
const blockPath = path.join('.');
|
|
119
|
+
out.push({
|
|
120
|
+
path: blockPath,
|
|
121
|
+
entries: node.namedChildren
|
|
122
|
+
.filter((child) => !pack.nodes.comment.includes(child.type))
|
|
123
|
+
.map((statement, index) => {
|
|
124
|
+
const id = blockPath + ':statement:' + index;
|
|
125
|
+
statements.set(id, statement);
|
|
126
|
+
return {
|
|
127
|
+
id,
|
|
128
|
+
fingerprint: implementationFingerprint(tokensFor(statement, pack)),
|
|
129
|
+
guard: guardOf(statement, pack),
|
|
130
|
+
};
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
for (let index = 0; index < node.childCount; index++) {
|
|
135
|
+
const child = node.child(index);
|
|
136
|
+
if (child)
|
|
137
|
+
visit(child, [...path, index]);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
visit(callable.node, []);
|
|
141
|
+
return {
|
|
142
|
+
identity: callable.identity,
|
|
143
|
+
blocks: out,
|
|
144
|
+
residualFingerprint(omitted) {
|
|
145
|
+
const excluded = [];
|
|
146
|
+
for (const id of omitted) {
|
|
147
|
+
const statement = statements.get(id);
|
|
148
|
+
if (!statement)
|
|
149
|
+
return undefined;
|
|
150
|
+
excluded.push(statement);
|
|
151
|
+
}
|
|
152
|
+
return implementationFingerprint(sourceTokensFor(root, pack, excluded));
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Report only the narrow deterministic case: a uniquely identified callable and
|
|
158
|
+
* lexical block are unchanged except for deleting an unconditional early-exit guard.
|
|
159
|
+
* Helper extraction, delegation, moved code, and overloaded names all abstain.
|
|
160
|
+
*/
|
|
36
161
|
export const foreignDroppedGuard = {
|
|
37
162
|
name: 'dropped-guard',
|
|
38
163
|
needs: ['syntax', 'base'],
|
|
@@ -41,34 +166,33 @@ export const foreignDroppedGuard = {
|
|
|
41
166
|
for (const file of g.foreign) {
|
|
42
167
|
if (!file.beforeTree)
|
|
43
168
|
continue;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
169
|
+
if (file.changed.beforePath && file.changed.beforePath !== file.path)
|
|
170
|
+
continue;
|
|
171
|
+
if (hasOtherExecutableChange(g, file.path))
|
|
172
|
+
continue;
|
|
173
|
+
const before = callablesByIdentity(file.beforeTree.rootNode, file.pack);
|
|
174
|
+
const after = callablesByIdentity(file.tree.rootNode, file.pack);
|
|
175
|
+
for (const [identity, previous] of before) {
|
|
176
|
+
const current = after.get(identity) ?? [];
|
|
177
|
+
// A duplicate contract is still ambiguous. Without types or symbol
|
|
178
|
+
// resolution, choosing one declaration would turn ambiguity into fact.
|
|
179
|
+
if (previous.length !== 1 || current.length !== 1)
|
|
51
180
|
continue;
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const dropped =
|
|
55
|
-
.filter(([text, roots]) => {
|
|
56
|
-
if (has.has(text))
|
|
57
|
-
return false;
|
|
58
|
-
if (!roots.every((r) => available.has(r)))
|
|
59
|
-
return false; // obsolete with the code it protected
|
|
60
|
-
return ![...has.values()].some((other) => roots.every((r) => other.includes(r))); // respelled
|
|
61
|
-
})
|
|
62
|
-
.map(([text]) => text);
|
|
181
|
+
const prev = previous[0];
|
|
182
|
+
const now = current[0];
|
|
183
|
+
const dropped = provenGuardRemovals(proofOf(prev, file.pack, file.beforeTree.rootNode), proofOf(now, file.pack, file.tree.rootNode));
|
|
63
184
|
if (dropped.length === 0)
|
|
64
185
|
continue;
|
|
65
|
-
findings.push(finding(file, now, {
|
|
186
|
+
findings.push(finding(file, now.node, {
|
|
66
187
|
check: 'dropped-guard',
|
|
67
188
|
severity: 'high',
|
|
68
189
|
confidence: 'proven',
|
|
69
|
-
title: dropped.map((d) => '`' + d + '`').join(' and ') + ' guarded ' + name + '() before this change and ' +
|
|
190
|
+
title: dropped.map((d) => '`' + d + '`').join(' and ') + ' guarded ' + now.name + '() before this change and ' +
|
|
70
191
|
(dropped.length > 1 ? 'are' : 'is') + ' gone',
|
|
71
|
-
evidence: {
|
|
192
|
+
evidence: {
|
|
193
|
+
oracle: file.pack.name + ' pre/post control-flow AST',
|
|
194
|
+
detail: 'every other file token matches after removing only the guard, with no executable change in another source file',
|
|
195
|
+
},
|
|
72
196
|
}));
|
|
73
197
|
}
|
|
74
198
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
1
2
|
import { createReinventionScopeResolver, implementationFingerprint } from '#app/reinvention.js';
|
|
2
|
-
import { finding,
|
|
3
|
+
import { finding, reusableDeclarations } from './foreign-tokens.js';
|
|
3
4
|
/** Names too common to mean anything across files, as in the TypeScript version. */
|
|
4
5
|
const GENERIC = new Set([
|
|
5
6
|
'render', 'handler', 'handle', 'create', 'update', 'remove', 'delete', 'insert',
|
|
@@ -18,12 +19,19 @@ const TESTISH = /(^|\/)(tests?|spec|__tests__)\/|(^|\/)(test_[^/]+|[^/]+_test|[^
|
|
|
18
19
|
function normalized(name) {
|
|
19
20
|
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
20
21
|
}
|
|
21
|
-
function declarationIndex(root, pack) {
|
|
22
|
+
function declarationIndex(root, pack, path) {
|
|
22
23
|
const index = new Map();
|
|
23
|
-
|
|
24
|
-
|
|
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);
|
|
25
33
|
const list = index.get(key) ?? [];
|
|
26
|
-
list.push({ name, node, fingerprint: implementationFingerprint(
|
|
34
|
+
list.push({ name, node, fingerprint: implementationFingerprint(tokens) });
|
|
27
35
|
index.set(key, list);
|
|
28
36
|
}
|
|
29
37
|
return index;
|
|
@@ -43,8 +51,10 @@ export const foreignReinvented = {
|
|
|
43
51
|
const declarations = new Map();
|
|
44
52
|
for (const file of g.foreign) {
|
|
45
53
|
declarations.set(file.path, {
|
|
46
|
-
current: declarationIndex(file.tree.rootNode, file.pack),
|
|
47
|
-
base: file.beforeTree
|
|
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,
|
|
48
58
|
});
|
|
49
59
|
}
|
|
50
60
|
for (const file of g.foreign) {
|
|
@@ -58,6 +68,9 @@ export const foreignReinvented = {
|
|
|
58
68
|
// at the reviewed head. A removed or rewritten helper is not a candidate.
|
|
59
69
|
if (!currentDeclaration)
|
|
60
70
|
continue;
|
|
71
|
+
const beforePath = file.changed.beforePath ?? file.path;
|
|
72
|
+
if (scopeFor(beforePath) !== scopeFor(file.path))
|
|
73
|
+
continue;
|
|
61
74
|
const key = file.pack.name + '|' + nameKey;
|
|
62
75
|
const list = index.get(key) ?? [];
|
|
63
76
|
list.push({
|
|
@@ -73,6 +86,8 @@ export const foreignReinvented = {
|
|
|
73
86
|
for (const file of g.foreign) {
|
|
74
87
|
if (TESTISH.test(file.path))
|
|
75
88
|
continue;
|
|
89
|
+
if (file.changed.beforePath && file.changed.beforePath !== file.path)
|
|
90
|
+
continue;
|
|
76
91
|
const fileDeclarations = declarations.get(file.path);
|
|
77
92
|
for (const [nameKey, currentDeclarations] of fileDeclarations.current) {
|
|
78
93
|
for (const { name, node: decl, fingerprint } of currentDeclarations) {
|