@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.
@@ -0,0 +1,334 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { Node, ts, } from 'ts-morph';
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
+ }
68
+ const SCOPE_FILES = [
69
+ 'package.json',
70
+ 'pyproject.toml', 'setup.py', 'setup.cfg',
71
+ 'Cargo.toml', 'go.mod',
72
+ 'pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts',
73
+ 'composer.json', 'Gemfile',
74
+ 'CMakeLists.txt', 'meson.build',
75
+ 'foundry.toml',
76
+ ];
77
+ const SCOPE_SUFFIX = /\.(?:csproj|sln|gemspec)$/i;
78
+ function declaresScope(dir) {
79
+ if (SCOPE_FILES.some((name) => existsSync(resolve(dir, name))))
80
+ return true;
81
+ try {
82
+ return readdirSync(dir, { withFileTypes: true }).some((entry) => entry.isFile() && SCOPE_SUFFIX.test(entry.name));
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ /**
89
+ * Resolve package boundaries once per directory. A symbol index can contain tens of
90
+ * thousands of declarations in a monorepo, so walking and reading every ancestor for
91
+ * every symbol would turn a conservative check into the slowest part of the review.
92
+ */
93
+ export function createReinventionScopeResolver(root) {
94
+ root = resolve(root);
95
+ const cache = new Map();
96
+ const scopeForDirectory = (dir) => {
97
+ const cached = cache.get(dir);
98
+ if (cached !== undefined)
99
+ return cached;
100
+ let scope;
101
+ if (declaresScope(dir))
102
+ scope = repoPath(root, dir);
103
+ else if (dir === root)
104
+ scope = '';
105
+ else {
106
+ const parent = dirname(dir);
107
+ scope = parent === dir || !insideRepo(root, parent) ? '' : scopeForDirectory(parent);
108
+ }
109
+ cache.set(dir, scope);
110
+ return scope;
111
+ };
112
+ return (file) => {
113
+ const abs = insideRepo(root, file);
114
+ return abs ? scopeForDirectory(dirname(abs)) : '';
115
+ };
116
+ }
117
+ /** Nearest language-appropriate package boundary, or the repository root. */
118
+ export function reinventionScope(root, file) {
119
+ return createReinventionScopeResolver(root)(file);
120
+ }
121
+ function callable(node) {
122
+ if (Node.isFunctionDeclaration(node) || Node.isArrowFunction(node) || Node.isFunctionExpression(node))
123
+ return node;
124
+ if (!Node.isVariableDeclaration(node))
125
+ return undefined;
126
+ const init = node.getInitializer();
127
+ return init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) ? init : undefined;
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
+ }
262
+ /**
263
+ * Exact program tokens for a callable, excluding its export modifier and declared
264
+ * name. Layout and comments may differ; parameters, types, operators, callees and
265
+ * literals may not. That is deliberately conservative: a name match proposes a
266
+ * candidate, but only equivalent executable text is deterministic evidence that a
267
+ * helper was reimplemented.
268
+ */
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;
275
+ const fn = callable(node);
276
+ const body = fn?.getBody();
277
+ if (!fn || !body) {
278
+ cache.set(bindingPath, null);
279
+ return undefined;
280
+ }
281
+ const generator = !Node.isArrowFunction(fn) && fn.isGenerator();
282
+ const source = [
283
+ bindingContext(node, bindingPath),
284
+ fn.isAsync() ? 'async' : 'sync',
285
+ generator ? 'generator' : 'plain',
286
+ '<' + fn.getTypeParameters().map((parameter) => parameter.getText()).join(',') + '>',
287
+ '(' + fn.getParameters().map((parameter) => parameter.getText()).join(',') + ')',
288
+ ':' + (fn.getReturnTypeNode()?.getText() ?? ''),
289
+ body.getText(),
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) {
297
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.JSX, source);
298
+ const tokens = [];
299
+ let token = scanner.scan();
300
+ for (let count = 0; token !== ts.SyntaxKind.EndOfFileToken && count < 100_000; count++) {
301
+ tokens.push({ type: token, text: scanner.getTokenText() });
302
+ token = scanner.scan();
303
+ }
304
+ // Fail closed instead of hashing a shared prefix of two exceptionally large
305
+ // callables and presenting that collision as duplication evidence.
306
+ if (token !== ts.SyntaxKind.EndOfFileToken)
307
+ return undefined;
308
+ return implementationFingerprint(tokens);
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
+ }
326
+ /** Stable hash of compiler-visible tokens; comments and layout never enter it. */
327
+ export function implementationFingerprint(tokens) {
328
+ const hash = createHash('sha256');
329
+ for (const token of tokens) {
330
+ hash.update(JSON.stringify([token.type, token.text])).update('\n');
331
+ }
332
+ return hash.digest('hex');
333
+ }
334
+ //# sourceMappingURL=reinvention.js.map
@@ -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: 'Declares a helper that already exists in the repository',
14
- 'dropped-guard': 'A guard present before the change is gone after it',
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))