@0xcraft/powershot 1.1.4 → 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 +19 -12
- package/dist/lang/packs.js +170 -8
- package/dist/langtest.js +1056 -6
- package/dist/reinvention.js +227 -2
- package/dist/report/sarif.js +2 -2
- package/dist/review.js +5 -1
- package/dist/selftest.js +417 -9
- 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 +25 -0
- package/package.json +1 -1
package/dist/reinvention.js
CHANGED
|
@@ -3,6 +3,68 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import { Node, ts, } from 'ts-morph';
|
|
5
5
|
import { insideRepo, repoPath } from './fspolicy.js';
|
|
6
|
+
/**
|
|
7
|
+
* Compiler-resolved exports plus direct syntax exports.
|
|
8
|
+
*
|
|
9
|
+
* ts-morph's export map can be empty for JavaScript source even when the parser sees
|
|
10
|
+
* an `export` modifier. The syntax fallback keeps JS at the same evidence level as
|
|
11
|
+
* TypeScript without treating an unexported helper as reusable across files.
|
|
12
|
+
*/
|
|
13
|
+
export function exportedDeclarations(source) {
|
|
14
|
+
const out = [];
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
const add = (name, node) => {
|
|
17
|
+
if (!name || !node)
|
|
18
|
+
return;
|
|
19
|
+
const key = [
|
|
20
|
+
name,
|
|
21
|
+
node.getSourceFile().getFilePath(),
|
|
22
|
+
node.getKind(),
|
|
23
|
+
node.getStart(),
|
|
24
|
+
node.getEnd(),
|
|
25
|
+
].join('\u0000');
|
|
26
|
+
if (seen.has(key))
|
|
27
|
+
return;
|
|
28
|
+
seen.add(key);
|
|
29
|
+
out.push({ name, node });
|
|
30
|
+
};
|
|
31
|
+
for (const [name, declarations] of source.getExportedDeclarations()) {
|
|
32
|
+
for (const declaration of declarations)
|
|
33
|
+
add(name, declaration);
|
|
34
|
+
}
|
|
35
|
+
const locals = new Map();
|
|
36
|
+
const addLocal = (name, node) => {
|
|
37
|
+
if (!name)
|
|
38
|
+
return;
|
|
39
|
+
const declarations = locals.get(name) ?? [];
|
|
40
|
+
declarations.push(node);
|
|
41
|
+
locals.set(name, declarations);
|
|
42
|
+
};
|
|
43
|
+
for (const declaration of source.getFunctions())
|
|
44
|
+
addLocal(declaration.getName(), declaration);
|
|
45
|
+
for (const declaration of source.getVariableDeclarations())
|
|
46
|
+
addLocal(declaration.getName(), declaration);
|
|
47
|
+
for (const declaration of source.getClasses())
|
|
48
|
+
addLocal(declaration.getName(), declaration);
|
|
49
|
+
for (const declaration of source.getExportDeclarations()) {
|
|
50
|
+
if (declaration.getModuleSpecifier())
|
|
51
|
+
continue;
|
|
52
|
+
for (const specifier of declaration.getNamedExports()) {
|
|
53
|
+
const exportedName = specifier.getAliasNode()?.getText() ?? specifier.getName();
|
|
54
|
+
for (const local of locals.get(specifier.getName()) ?? [])
|
|
55
|
+
add(exportedName, local);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const declaration of source.getFunctions()) {
|
|
59
|
+
if (declaration.hasExportKeyword())
|
|
60
|
+
add(declaration.getName(), declaration);
|
|
61
|
+
}
|
|
62
|
+
for (const declaration of source.getVariableDeclarations()) {
|
|
63
|
+
if (declaration.getVariableStatement()?.hasExportKeyword())
|
|
64
|
+
add(declaration.getName(), declaration);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
6
68
|
const SCOPE_FILES = [
|
|
7
69
|
'package.json',
|
|
8
70
|
'pyproject.toml', 'setup.py', 'setup.cfg',
|
|
@@ -64,6 +126,139 @@ function callable(node) {
|
|
|
64
126
|
const init = node.getInitializer();
|
|
65
127
|
return init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) ? init : undefined;
|
|
66
128
|
}
|
|
129
|
+
function identifierTexts(source) {
|
|
130
|
+
const identifiers = new Set();
|
|
131
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.JSX, source);
|
|
132
|
+
let token = scanner.scan();
|
|
133
|
+
for (let count = 0; token !== ts.SyntaxKind.EndOfFileToken && count < 100_000; count++) {
|
|
134
|
+
if (token === ts.SyntaxKind.Identifier)
|
|
135
|
+
identifiers.add(scanner.getTokenText());
|
|
136
|
+
token = scanner.scan();
|
|
137
|
+
}
|
|
138
|
+
return identifiers;
|
|
139
|
+
}
|
|
140
|
+
/** Comments whose contents change TypeScript/JavaScript binding or compilation. */
|
|
141
|
+
function semanticDirectives(source) {
|
|
142
|
+
const directives = [];
|
|
143
|
+
for (const [index, line] of source.split(/\r?\n/).entries()) {
|
|
144
|
+
const text = line.trim();
|
|
145
|
+
if (text.startsWith('#!') ||
|
|
146
|
+
/^\/\/\/\s*<reference\b/.test(text) ||
|
|
147
|
+
/^\/\/\s*@(ts-check|ts-nocheck|jsx\w*)\b/.test(text))
|
|
148
|
+
directives.push(String(index + 1) + ':' + text);
|
|
149
|
+
}
|
|
150
|
+
return directives;
|
|
151
|
+
}
|
|
152
|
+
function contains(container, target) {
|
|
153
|
+
return container.getSourceFile() === target.getSourceFile() &&
|
|
154
|
+
container.getStart() <= target.getStart() &&
|
|
155
|
+
container.getEnd() >= target.getEnd();
|
|
156
|
+
}
|
|
157
|
+
const BINDING_INDEX = new WeakMap();
|
|
158
|
+
const IDENTIFIERS = new WeakMap();
|
|
159
|
+
const BINDING_CONTEXT = new WeakMap();
|
|
160
|
+
const TYPESCRIPT_FINGERPRINT = new WeakMap();
|
|
161
|
+
function identifiersOf(node) {
|
|
162
|
+
const known = IDENTIFIERS.get(node);
|
|
163
|
+
if (known)
|
|
164
|
+
return known;
|
|
165
|
+
const identifiers = identifierTexts(node.getText());
|
|
166
|
+
IDENTIFIERS.set(node, identifiers);
|
|
167
|
+
return identifiers;
|
|
168
|
+
}
|
|
169
|
+
/** Build the file's binding index once, however many exported aliases inspect it. */
|
|
170
|
+
function bindingIndex(source) {
|
|
171
|
+
const known = BINDING_INDEX.get(source);
|
|
172
|
+
if (known)
|
|
173
|
+
return known;
|
|
174
|
+
const imports = [];
|
|
175
|
+
const byName = new Map();
|
|
176
|
+
const add = (names, node) => {
|
|
177
|
+
const binding = { names, node };
|
|
178
|
+
for (const name of names) {
|
|
179
|
+
const bindings = byName.get(name) ?? [];
|
|
180
|
+
bindings.push(binding);
|
|
181
|
+
byName.set(name, bindings);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const named = (name, node) => {
|
|
185
|
+
if (name)
|
|
186
|
+
add([name], node);
|
|
187
|
+
};
|
|
188
|
+
for (const statement of source.getStatements()) {
|
|
189
|
+
if (Node.isImportDeclaration(statement) || Node.isImportEqualsDeclaration(statement)) {
|
|
190
|
+
imports.push(statement);
|
|
191
|
+
}
|
|
192
|
+
else if (Node.isFunctionDeclaration(statement) ||
|
|
193
|
+
Node.isClassDeclaration(statement) ||
|
|
194
|
+
Node.isInterfaceDeclaration(statement) ||
|
|
195
|
+
Node.isTypeAliasDeclaration(statement) ||
|
|
196
|
+
Node.isEnumDeclaration(statement) ||
|
|
197
|
+
Node.isModuleDeclaration(statement)) {
|
|
198
|
+
named(statement.getName(), statement);
|
|
199
|
+
}
|
|
200
|
+
else if (Node.isVariableStatement(statement)) {
|
|
201
|
+
for (const declaration of statement.getDeclarations()) {
|
|
202
|
+
const nameNode = declaration.getNameNode();
|
|
203
|
+
const names = Node.isIdentifier(nameNode)
|
|
204
|
+
? [nameNode.getText()]
|
|
205
|
+
: nameNode.getDescendantsOfKind(ts.SyntaxKind.Identifier).map((identifier) => identifier.getText());
|
|
206
|
+
add(names, declaration);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const index = { imports, byName };
|
|
211
|
+
BINDING_INDEX.set(source, index);
|
|
212
|
+
return index;
|
|
213
|
+
}
|
|
214
|
+
function normalizedDirectory(path) {
|
|
215
|
+
return dirname(path.replaceAll('\\', '/'));
|
|
216
|
+
}
|
|
217
|
+
function hasRelativeModuleReference(nodes) {
|
|
218
|
+
for (const node of nodes) {
|
|
219
|
+
if (/(?:\bfrom\s*|\bimport\s*\(|\brequire\s*\()\s*["']\.\.?\//.test(node.getText()) ||
|
|
220
|
+
(Node.isImportDeclaration(node) && node.getModuleSpecifierValue().startsWith('.')))
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
/** Imports plus only the transitive top-level bindings named by a callable. */
|
|
226
|
+
function bindingContext(target, bindingPath) {
|
|
227
|
+
const cache = BINDING_CONTEXT.get(target) ?? new Map();
|
|
228
|
+
BINDING_CONTEXT.set(target, cache);
|
|
229
|
+
const known = cache.get(bindingPath);
|
|
230
|
+
if (known !== undefined)
|
|
231
|
+
return known;
|
|
232
|
+
const index = bindingIndex(target.getSourceFile());
|
|
233
|
+
const selected = new Set(index.imports);
|
|
234
|
+
const pending = [...identifiersOf(target)];
|
|
235
|
+
const visitedNames = new Set();
|
|
236
|
+
while (pending.length > 0) {
|
|
237
|
+
const name = pending.pop();
|
|
238
|
+
if (visitedNames.has(name))
|
|
239
|
+
continue;
|
|
240
|
+
visitedNames.add(name);
|
|
241
|
+
for (const binding of index.byName.get(name) ?? []) {
|
|
242
|
+
if (selected.has(binding.node) || contains(binding.node, target))
|
|
243
|
+
continue;
|
|
244
|
+
selected.add(binding.node);
|
|
245
|
+
for (const identifier of identifiersOf(binding.node)) {
|
|
246
|
+
if (!visitedNames.has(identifier))
|
|
247
|
+
pending.push(identifier);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const ordered = [...selected]
|
|
252
|
+
.sort((left, right) => left.getStart() - right.getStart());
|
|
253
|
+
const bindingDirectory = hasRelativeModuleReference([...ordered, target])
|
|
254
|
+
? 'const __powershot_binding_directory__ = ' + JSON.stringify(normalizedDirectory(bindingPath))
|
|
255
|
+
: '';
|
|
256
|
+
const directives = semanticDirectives(target.getSourceFile().getFullText())
|
|
257
|
+
.map((directive) => 'const __powershot_directive__ = ' + JSON.stringify(directive));
|
|
258
|
+
const context = [bindingDirectory, ...directives, ...ordered.map((node) => node.getText())].join('\n');
|
|
259
|
+
cache.set(bindingPath, context);
|
|
260
|
+
return context;
|
|
261
|
+
}
|
|
67
262
|
/**
|
|
68
263
|
* Exact program tokens for a callable, excluding its export modifier and declared
|
|
69
264
|
* name. Layout and comments may differ; parameters, types, operators, callees and
|
|
@@ -71,13 +266,21 @@ function callable(node) {
|
|
|
71
266
|
* candidate, but only equivalent executable text is deterministic evidence that a
|
|
72
267
|
* helper was reimplemented.
|
|
73
268
|
*/
|
|
74
|
-
export function typescriptImplementationFingerprint(node) {
|
|
269
|
+
export function typescriptImplementationFingerprint(node, bindingPath = node.getSourceFile().getFilePath()) {
|
|
270
|
+
const cache = TYPESCRIPT_FINGERPRINT.get(node) ?? new Map();
|
|
271
|
+
TYPESCRIPT_FINGERPRINT.set(node, cache);
|
|
272
|
+
const known = cache.get(bindingPath);
|
|
273
|
+
if (known !== undefined)
|
|
274
|
+
return known ?? undefined;
|
|
75
275
|
const fn = callable(node);
|
|
76
276
|
const body = fn?.getBody();
|
|
77
|
-
if (!fn || !body)
|
|
277
|
+
if (!fn || !body) {
|
|
278
|
+
cache.set(bindingPath, null);
|
|
78
279
|
return undefined;
|
|
280
|
+
}
|
|
79
281
|
const generator = !Node.isArrowFunction(fn) && fn.isGenerator();
|
|
80
282
|
const source = [
|
|
283
|
+
bindingContext(node, bindingPath),
|
|
81
284
|
fn.isAsync() ? 'async' : 'sync',
|
|
82
285
|
generator ? 'generator' : 'plain',
|
|
83
286
|
'<' + fn.getTypeParameters().map((parameter) => parameter.getText()).join(',') + '>',
|
|
@@ -85,6 +288,12 @@ export function typescriptImplementationFingerprint(node) {
|
|
|
85
288
|
':' + (fn.getReturnTypeNode()?.getText() ?? ''),
|
|
86
289
|
body.getText(),
|
|
87
290
|
].join('\n');
|
|
291
|
+
const fingerprint = typescriptTokenFingerprint(source);
|
|
292
|
+
cache.set(bindingPath, fingerprint ?? null);
|
|
293
|
+
return fingerprint;
|
|
294
|
+
}
|
|
295
|
+
/** Exact TypeScript/JavaScript program tokens, excluding layout and comments. */
|
|
296
|
+
export function typescriptTokenFingerprint(source) {
|
|
88
297
|
const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.JSX, source);
|
|
89
298
|
const tokens = [];
|
|
90
299
|
let token = scanner.scan();
|
|
@@ -98,6 +307,22 @@ export function typescriptImplementationFingerprint(node) {
|
|
|
98
307
|
return undefined;
|
|
99
308
|
return implementationFingerprint(tokens);
|
|
100
309
|
}
|
|
310
|
+
/** Program tokens plus the otherwise-comment-shaped directives a compiler consumes. */
|
|
311
|
+
export function typescriptSourceFingerprint(source) {
|
|
312
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.JSX, source);
|
|
313
|
+
const tokens = [];
|
|
314
|
+
let token = scanner.scan();
|
|
315
|
+
for (let count = 0; token !== ts.SyntaxKind.EndOfFileToken && count < 100_000; count++) {
|
|
316
|
+
tokens.push({ type: token, text: scanner.getTokenText() });
|
|
317
|
+
token = scanner.scan();
|
|
318
|
+
}
|
|
319
|
+
if (token !== ts.SyntaxKind.EndOfFileToken)
|
|
320
|
+
return undefined;
|
|
321
|
+
for (const directive of semanticDirectives(source)) {
|
|
322
|
+
tokens.push({ type: '__semantic_directive__', text: directive });
|
|
323
|
+
}
|
|
324
|
+
return implementationFingerprint(tokens);
|
|
325
|
+
}
|
|
101
326
|
/** Stable hash of compiler-visible tokens; comments and layout never enter it. */
|
|
102
327
|
export function implementationFingerprint(tokens) {
|
|
103
328
|
const hash = createHash('sha256');
|
package/dist/report/sarif.js
CHANGED
|
@@ -10,8 +10,8 @@ function level(severity) {
|
|
|
10
10
|
const DESCRIPTIONS = {
|
|
11
11
|
'phantom-api': 'Calls an API that does not exist — hallucinated method, property, or arity',
|
|
12
12
|
'phantom-dep': 'Imports a package that is not a declared dependency',
|
|
13
|
-
reinvented: 'Adds a
|
|
14
|
-
'dropped-guard': '
|
|
13
|
+
reinvented: 'Adds a cross-file declaration matching an existing declaration by implementation, package, visibility, wrapper, and binding context',
|
|
14
|
+
'dropped-guard': 'Deletes an early-exit guard while every other token in the file and changed source set remains unchanged',
|
|
15
15
|
'swallowed-error': 'Error handling that discards the failure',
|
|
16
16
|
'vacuous-test': 'A test that asserts nothing, or mocks the unit under test',
|
|
17
17
|
'assertion-drift': 'An expected value edited to match new output',
|
package/dist/review.js
CHANGED
|
@@ -150,7 +150,7 @@ export async function review(opts) {
|
|
|
150
150
|
const budget = opts.budget ?? new Budget();
|
|
151
151
|
const manifest = opts.manifest;
|
|
152
152
|
const groundDone = stage('ground');
|
|
153
|
-
const g = await buildGround(root, changed, opts.signal);
|
|
153
|
+
const g = await buildGround(root, changed, opts.signal, all);
|
|
154
154
|
groundDone(g.sourceFiles.length + ' files · ' + g.symbolIndex.size + ' symbols' +
|
|
155
155
|
(g.configFiles.length === 0
|
|
156
156
|
? ' · no usable relevant tsconfig, type checks disabled'
|
|
@@ -178,6 +178,10 @@ export async function review(opts) {
|
|
|
178
178
|
// whatever the summary says about the ones that were
|
|
179
179
|
const grounded = new Set([...g.files.map((f) => f.changed.path), ...g.foreign.map((f) => f.path)]);
|
|
180
180
|
for (const c of changed) {
|
|
181
|
+
if (c.deleted) {
|
|
182
|
+
plan.waive(c.path, 'deleted file has no current source to review');
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
181
185
|
if (grounded.has(c.path))
|
|
182
186
|
continue;
|
|
183
187
|
if (packFor(c.path))
|