@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/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
- if (name)
8
- out.push({ name, node: fn });
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
- for (const m of cls.getMethods())
13
- out.push({ name: prefix + m.getName(), node: m });
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
- out.push({ name: v.getName(), node: v });
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
- * The things a condition actually reads: the base of each reference, never the
36
- * property names hanging off it. In `!inv.customer` the root is `inv` `customer`
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 rootsOf(condition) {
40
- const roots = [];
41
- for (const id of condition.getDescendantsOfKind(SyntaxKind.Identifier)) {
42
- const parent = id.getParent();
43
- // skip the `.name` half of a property access, and object-literal keys
44
- if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === id)
45
- continue;
46
- if (Node.isPropertyAssignment(parent) && parent.getNameNode() === id)
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
- roots.push(id.getText());
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
- if (Node.isIdentifier(condition))
51
- roots.push(condition.getText());
52
- return roots;
53
- }
54
- /**
55
- * A guard whose roots the rewritten function no longer has is not a guard someone
56
- * dropped — it stopped existing along with the code it protected. `formatWeight`
57
- * rewritten from ounces to grams cannot keep `if (pounds === 0)`, and reporting it
58
- * would accuse a deliberate rewrite of losing a check it could not have kept.
59
- */
60
- function stillApplicable(roots, available) {
61
- return roots.every((n) => available.has(n) || GLOBALS.has(n));
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
- * The same check, respelled. When `pool` turns from an array into a number,
65
- * `pool.length === 0` becomes `pool === 0`: different text, same guarantee. Matching
66
- * on what a guard reads rather than how it is written keeps a rewrite from reading
67
- * as a removal — at the cost of missing a guard narrowed on the same variable, which
68
- * is the direction this tool chooses to err in.
69
- */
70
- function guardedElsewhere(roots, after) {
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
- const GLOBALS = new Set(['undefined', 'NaN', 'Number', 'String', 'Array', 'Object', 'Boolean', 'Math', 'JSON', 'Date']);
76
- /**
77
- * A guard is an `if` whose branch bails out — throw, return, continue, or break.
78
- * That shape is what protects the code below it, so losing one changes behaviour.
79
- */
80
- function guardsOf(fn) {
81
- const guards = new Map();
82
- for (const ifStmt of fn.getDescendantsOfKind(SyntaxKind.IfStatement)) {
83
- const branch = ifStmt.getThenStatement();
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 guards;
195
+ return path.reverse().join('.');
94
196
  }
95
197
  /**
96
- * A guard that existed before the change and is gone after it, in a function that
97
- * still exists. The pre/post AST pair is the oracle, so this is proven: the
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 after = new Map(functionsOf(sf).map((f) => [f.name, f]));
109
- for (const prev of functionsOf(before)) {
110
- const now = after.get(prev.name);
111
- if (!now)
112
- continue; // function removed entirely — that is a different finding
113
- const had = guardsOf(prev.node);
114
- if (had.size === 0)
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 has = guardsOf(now.node);
117
- const available = identifiersIn(now.node);
118
- const dropped = [...had.entries()]
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: relPath(sf, g.root),
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 ' + prev.name + '() before this change and ' + (dropped.length > 1 ? 'are' : 'is') + ' gone',
137
- evidence: { oracle: 'pre/post AST', detail: 'early-exit branch removed while the function still exists and still uses the same names' },
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 { declaredName, finding } from './foreign-tokens.js';
3
- function guardsOf(fn, pack) {
4
- const guards = new Map();
5
- for (const stmt of nodesOfType(fn, pack.nodes.ifStatement)) {
6
- const cond = stmt.childForFieldName(pack.nodes.ifCondition);
7
- const body = stmt.childForFieldName(pack.nodes.ifBody);
8
- if (!cond || !body)
9
- continue;
10
- const bails = nodesOfType(body, pack.nodes.bail).length > 0;
11
- if (!bails)
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 guards;
13
+ return undefined;
17
14
  }
18
- /** Declarations at module level — the names another file could actually import. */
19
- export function functionsByName(root, pack) {
20
- const all = nodesOfType(root, pack.nodes.declaration);
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
- for (const decl of all) {
23
- const containsAnother = all.some((other) => other !== decl &&
24
- other.startPosition.row >= decl.startPosition.row &&
25
- other.endPosition.row <= decl.endPosition.row);
26
- if (containsAnother)
27
- continue;
28
- const name = declaredName(decl, pack);
29
- if (name)
30
- out.set(name, decl);
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
- /** A guard the change removed from a function that still exists — see the TS version
35
- * for why obsolete and respelled guards are excluded. */
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
- const after = functionsByName(file.tree.rootNode, file.pack);
45
- for (const [name, prev] of functionsByName(file.beforeTree.rootNode, file.pack)) {
46
- const now = after.get(name);
47
- if (!now)
48
- continue;
49
- const had = guardsOf(prev, file.pack);
50
- if (had.size === 0)
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 has = guardsOf(now, file.pack);
53
- const available = new Set(nodesOfType(now, file.pack.nodes.identifier).map((n) => n.text));
54
- const dropped = [...had.entries()]
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: { oracle: file.pack.name + ' pre/post AST', detail: 'early-exit branch removed while the function still uses the same names' },
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, tokensFor, topLevelDeclarations } from './foreign-tokens.js';
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
- for (const [name, node] of topLevelDeclarations(root, pack)) {
24
- const key = normalized(name);
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(tokensFor(node, pack)) });
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 ? declarationIndex(file.beforeTree.rootNode, file.pack) : undefined,
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) {