@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/plan.js CHANGED
@@ -92,6 +92,31 @@ export class SelectionPlan {
92
92
  keep(changed) {
93
93
  return changed.filter((c) => this.rows.get(c.path)?.disposition === 'selected');
94
94
  }
95
+ /**
96
+ * Finish the file-level selection after parsers have had one chance to load it.
97
+ *
98
+ * Review and delegation both promise to describe the same change. Keeping this
99
+ * transition on the plan prevents either caller from silently inventing its own
100
+ * meaning for a deleted, unsupported, or unavailable source file.
101
+ */
102
+ accountForGround(changed, ground) {
103
+ const grounded = new Set([
104
+ ...ground.files.map((file) => file.changed.path),
105
+ ...ground.foreign.map((file) => file.path),
106
+ ]);
107
+ for (const file of changed) {
108
+ if (file.deleted) {
109
+ this.waive(file.path, 'deleted file has no current source to review');
110
+ continue;
111
+ }
112
+ if (grounded.has(file.path))
113
+ continue;
114
+ if (packFor(file.path))
115
+ this.fail(file.path, 'declared language parser unavailable');
116
+ else
117
+ this.waive(file.path, 'no parser for this language');
118
+ }
119
+ }
95
120
  items() {
96
121
  return [...this.rows.values()];
97
122
  }
@@ -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');
@@ -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 callable whose token-identical implementation already existed in the same package',
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',
@@ -73,6 +73,7 @@ export function summarizeRun(record) {
73
73
  filesReviewed: hasFiles
74
74
  ? record.files.filter((file) => file.disposition === 'selected').length
75
75
  : undefined,
76
+ filesChanged: hasFiles ? record.files.length : undefined,
76
77
  deterministicChecks: hasChecks ? new Set(record.checks.ran).size : undefined,
77
78
  scopeDetails: scopeDetails(record),
78
79
  };
@@ -88,13 +89,20 @@ export function noFindingsLabel(summary) {
88
89
  }
89
90
  export function scopeLine(summary) {
90
91
  const parts = [];
91
- if (summary.filesReviewed !== undefined)
92
- parts.push(plural(summary.filesReviewed, 'file') + ' reviewed');
92
+ if (summary.filesReviewed !== undefined) {
93
+ parts.push(summary.filesChanged === undefined
94
+ ? plural(summary.filesReviewed, 'file') + ' reviewed'
95
+ : summary.filesReviewed + '/' + summary.filesChanged + ' changed ' +
96
+ (summary.filesChanged === 1 ? 'file' : 'files') + ' reviewed');
97
+ }
93
98
  if (summary.deterministicChecks !== undefined) {
94
99
  parts.push(plural(summary.deterministicChecks, 'deterministic check'));
95
100
  }
96
- if (summary.coverage !== undefined)
97
- parts.push(summary.coverage + ' coverage');
101
+ if (summary.coverage !== undefined) {
102
+ parts.push(summary.coverage === 'full'
103
+ ? 'full applicable-oracle coverage'
104
+ : 'portable oracle coverage');
105
+ }
98
106
  return parts.length > 0 ? parts.join(' · ') : undefined;
99
107
  }
100
108
  export function modeNote(summary, verifyOnly = 'verify-only') {
package/dist/review.js CHANGED
@@ -12,7 +12,6 @@ import { apiKey } from './judges/llm.js';
12
12
  import { enabled } from './config.js';
13
13
  import { SelectionPlan, capabilitiesOf } from './plan.js';
14
14
  import { Budget } from './budget.js';
15
- import { packFor } from './lang/packs.js';
16
15
  import { SEVERITIES } from './types.js';
17
16
  import { stripControl, stripPath } from './text.js';
18
17
  export function atLeast(severity, min) {
@@ -150,7 +149,7 @@ export async function review(opts) {
150
149
  const budget = opts.budget ?? new Budget();
151
150
  const manifest = opts.manifest;
152
151
  const groundDone = stage('ground');
153
- const g = await buildGround(root, changed, opts.signal);
152
+ const g = await buildGround(root, changed, opts.signal, all);
154
153
  groundDone(g.sourceFiles.length + ' files · ' + g.symbolIndex.size + ' symbols' +
155
154
  (g.configFiles.length === 0
156
155
  ? ' · no usable relevant tsconfig, type checks disabled'
@@ -174,17 +173,10 @@ export async function review(opts) {
174
173
  // Naming a check explicitly is a request for that oracle, even under the portable
175
174
  // default. Strict policy makes the same promise for every configured verifier.
176
175
  const requireEnrichedOracles = config.coverage === 'strict' || opts.checks !== undefined;
177
- // a file the change touched that no parser produced a tree for was not reviewed,
178
- // whatever the summary says about the ones that were
179
- const grounded = new Set([...g.files.map((f) => f.changed.path), ...g.foreign.map((f) => f.path)]);
180
- for (const c of changed) {
181
- if (grounded.has(c.path))
182
- continue;
183
- if (packFor(c.path))
184
- plan.fail(c.path, 'declared language parser unavailable');
185
- else
186
- plan.waive(c.path, 'no parser for this language');
187
- }
176
+ // A file the change touched that no parser produced a tree for was not reviewed,
177
+ // whatever the summary says about the ones that were. Delegation uses this exact
178
+ // transition too, so its task cannot disagree with the review it will feed.
179
+ plan.accountForGround(changed, g);
188
180
  // Capabilities belong to files, not runs. A typed file beside one excluded from
189
181
  // tsconfig must not make the latter look checked, and an old Ruby file must not
190
182
  // make a new Python file eligible for a before/after oracle.