@0xcraft/powershot 1.1.2 → 1.1.4

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
@@ -105,9 +105,13 @@ export class Session {
105
105
  verified: findings.filter((f) => f.class === 'verified').length,
106
106
  judged: findings.filter((f) => f.class === 'judged').length,
107
107
  state: verdict.state,
108
- notLookedAt: verdict.notLookedAt,
108
+ notLookedAt: [...verdict.notLookedAt],
109
109
  coverage: verdict.coverage,
110
- unavailableCoverage: verdict.unavailableCoverage,
110
+ verifyOnly: verdict.verifyOnly,
111
+ minSeverity: verdict.minSeverity,
112
+ filesReviewed: verdict.filesReviewed,
113
+ deterministicChecks: verdict.deterministicChecks,
114
+ scopeDetails: verdict.scopeDetails ? [...verdict.scopeDetails] : undefined,
111
115
  };
112
116
  this.save();
113
117
  }
@@ -1,4 +1,5 @@
1
- import { finding, topLevelDeclarations } from './foreign-tokens.js';
1
+ import { createReinventionScopeResolver, implementationFingerprint } from '#app/reinvention.js';
2
+ import { finding, tokensFor, topLevelDeclarations } from './foreign-tokens.js';
2
3
  /** Names too common to mean anything across files, as in the TypeScript version. */
3
4
  const GENERIC = new Set([
4
5
  'render', 'handler', 'handle', 'create', 'update', 'remove', 'delete', 'insert',
@@ -14,6 +15,19 @@ const GENERIC = new Set([
14
15
  * repository this was the single largest source of noise.
15
16
  */
16
17
  const TESTISH = /(^|\/)(tests?|spec|__tests__)\/|(^|\/)(test_[^/]+|[^/]+_test|[^/]+\.(test|spec))\.[a-z]+$/;
18
+ function normalized(name) {
19
+ return name.toLowerCase().replace(/[^a-z0-9]/g, '');
20
+ }
21
+ function declarationIndex(root, pack) {
22
+ const index = new Map();
23
+ for (const [name, node] of topLevelDeclarations(root, pack)) {
24
+ const key = normalized(name);
25
+ const list = index.get(key) ?? [];
26
+ list.push({ name, node, fingerprint: implementationFingerprint(tokensFor(node, pack)) });
27
+ index.set(key, list);
28
+ }
29
+ return index;
30
+ }
17
31
  export const foreignReinvented = {
18
32
  name: 'reinvented',
19
33
  needs: ['syntax'],
@@ -24,39 +38,71 @@ export const foreignReinvented = {
24
38
  // Keyed by language as well as name: a Ruby `charge` and a C++ `charge` are two
25
39
  // unrelated functions that happen to share a word, and calling that duplication
26
40
  // would be nonsense — nothing can be reused across the boundary anyway.
41
+ const scopeFor = createReinventionScopeResolver(g.root);
27
42
  const index = new Map();
43
+ const declarations = new Map();
28
44
  for (const file of g.foreign) {
29
- if (TESTISH.test(file.path))
45
+ declarations.set(file.path, {
46
+ current: declarationIndex(file.tree.rootNode, file.pack),
47
+ base: file.beforeTree ? declarationIndex(file.beforeTree.rootNode, file.pack) : undefined,
48
+ });
49
+ }
50
+ for (const file of g.foreign) {
51
+ if (TESTISH.test(file.path) || !file.beforeTree)
30
52
  continue;
31
- for (const [name, decl] of topLevelDeclarations(file.tree.rootNode, file.pack)) {
32
- const key = file.pack.name + '|' + name.toLowerCase().replace(/[^a-z0-9]/g, '');
33
- const list = index.get(key) ?? [];
34
- list.push({ file: file.path, line: decl.startPosition.row + 1 });
35
- index.set(key, list);
53
+ const fileDeclarations = declarations.get(file.path);
54
+ for (const [nameKey, baseDeclarations] of fileDeclarations.base ?? []) {
55
+ for (const baseDeclaration of baseDeclarations) {
56
+ const currentDeclaration = (fileDeclarations.current.get(nameKey) ?? []).find((declaration) => declaration.fingerprint === baseDeclaration.fingerprint);
57
+ // The reusable declaration must both predate the change and remain available
58
+ // at the reviewed head. A removed or rewritten helper is not a candidate.
59
+ if (!currentDeclaration)
60
+ continue;
61
+ const key = file.pack.name + '|' + nameKey;
62
+ const list = index.get(key) ?? [];
63
+ list.push({
64
+ file: file.path,
65
+ line: currentDeclaration.node.startPosition.row + 1,
66
+ fingerprint: baseDeclaration.fingerprint,
67
+ scope: scopeFor(file.path),
68
+ });
69
+ index.set(key, list);
70
+ }
36
71
  }
37
72
  }
38
73
  for (const file of g.foreign) {
39
74
  if (TESTISH.test(file.path))
40
75
  continue;
41
- for (const [name, decl] of topLevelDeclarations(file.tree.rootNode, file.pack)) {
42
- const line = decl.startPosition.row + 1;
43
- if (!file.changed.added.has(line))
44
- continue;
45
- if (name.length < 6 || GENERIC.has(name.toLowerCase()))
46
- continue;
47
- const key = file.pack.name + '|' + name.toLowerCase().replace(/[^a-z0-9]/g, '');
48
- const match = (index.get(key) ?? []).find((e) => e.file !== file.path);
49
- // report the pair once, from the file that declares it later
50
- if (!match || match.file < file.path)
51
- continue;
52
- findings.push(finding(file, decl, {
53
- check: 'reinvented',
54
- severity: 'medium',
55
- confidence: 'firm',
56
- title: name + '() duplicates a declaration in ' + match.file,
57
- evidence: { oracle: file.pack.name + ' declarations', detail: 'already declared at ' + match.file + ':' + match.line },
58
- fix: 'Reuse the existing one instead of redeclaring it',
59
- }));
76
+ const fileDeclarations = declarations.get(file.path);
77
+ for (const [nameKey, currentDeclarations] of fileDeclarations.current) {
78
+ for (const { name, node: decl, fingerprint } of currentDeclarations) {
79
+ const line = decl.startPosition.row + 1;
80
+ if (!file.changed.added.has(line))
81
+ continue;
82
+ if (name.length < 6 || GENERIC.has(name.toLowerCase()))
83
+ continue;
84
+ const existedHere = (fileDeclarations.base?.get(nameKey) ?? []).some((baseDeclaration) => baseDeclaration.fingerprint === fingerprint);
85
+ if (existedHere)
86
+ continue;
87
+ const key = file.pack.name + '|' + nameKey;
88
+ const scope = scopeFor(file.path);
89
+ const match = (index.get(key) ?? []).find((candidate) => candidate.file !== file.path &&
90
+ candidate.scope === scope &&
91
+ candidate.fingerprint === fingerprint);
92
+ if (!match)
93
+ continue;
94
+ findings.push(finding(file, decl, {
95
+ check: 'reinvented',
96
+ severity: 'medium',
97
+ confidence: 'firm',
98
+ title: name + ' repeats the implementation at ' + match.file,
99
+ evidence: {
100
+ oracle: file.pack.name + ' base declaration + token fingerprint',
101
+ detail: 'token-identical implementation already present at ' + match.file + ':' + match.line,
102
+ },
103
+ fix: 'Consider reusing the existing declaration; if the separation is intentional, keep the boundary explicit',
104
+ }));
105
+ }
60
106
  }
61
107
  }
62
108
  return findings;
@@ -68,8 +68,11 @@ export function finding(file, node, f) {
68
68
  */
69
69
  export function declaredName(decl, pack) {
70
70
  const field = decl.childForFieldName(pack.nodes.declarationName);
71
+ // tree-sitter-kotlin exposes the declaration identifier as a named child but
72
+ // assigns no field name to it. Keep the grammar field authoritative when one is
73
+ // present; otherwise the first identifier inside the declaration is its name.
71
74
  if (!field)
72
- return undefined;
75
+ return nodesOfType(decl, pack.nodes.identifier)[0]?.text;
73
76
  if (field.childCount === 0)
74
77
  return field.text;
75
78
  // C and C++ wrap the name in a declarator; look inside that, never wider
@@ -1,5 +1,6 @@
1
1
  import { SyntaxKind } from 'ts-morph';
2
2
  import { locate, normalizeName, relPath } from '#app/ground.js';
3
+ import { createReinventionScopeResolver, typescriptImplementationFingerprint } from '#app/reinvention.js';
3
4
  /**
4
5
  * Names too generic to mean anything across files — two `render`s are usually
5
6
  * two different things, not a duplication.
@@ -15,8 +16,10 @@ function declaredNames(sf) {
15
16
  for (const fn of sf.getFunctions()) {
16
17
  const name = fn.getName();
17
18
  const id = fn.getNameNode();
18
- if (name && id)
19
- out.push({ name, line: fn.getStartLineNumber(), span: locate(sf, id.getStart(), id.getWidth()).span });
19
+ const fingerprint = typescriptImplementationFingerprint(fn);
20
+ if (name && id && fingerprint) {
21
+ out.push({ name, line: fn.getStartLineNumber(), span: locate(sf, id.getStart(), id.getWidth()).span, fingerprint });
22
+ }
20
23
  }
21
24
  for (const v of sf.getVariableDeclarations()) {
22
25
  const init = v.getInitializer();
@@ -24,7 +27,10 @@ function declaredNames(sf) {
24
27
  continue;
25
28
  if (init.isKind(SyntaxKind.ArrowFunction) || init.isKind(SyntaxKind.FunctionExpression)) {
26
29
  const id = v.getNameNode();
27
- out.push({ name: v.getName(), line: v.getStartLineNumber(), span: locate(sf, id.getStart(), id.getWidth()).span });
30
+ const fingerprint = typescriptImplementationFingerprint(v);
31
+ if (fingerprint) {
32
+ out.push({ name: v.getName(), line: v.getStartLineNumber(), span: locate(sf, id.getStart(), id.getWidth()).span, fingerprint });
33
+ }
28
34
  }
29
35
  }
30
36
  return out;
@@ -35,19 +41,28 @@ export const reinvented = {
35
41
  needs: ['syntax'],
36
42
  run(g) {
37
43
  const findings = [];
38
- for (const { sf, changed } of g.files) {
44
+ const scopeFor = createReinventionScopeResolver(g.root);
45
+ for (const { sf, changed, before } of g.files) {
39
46
  const file = relPath(sf, g.root);
47
+ const scope = scopeFor(file);
40
48
  // a fixture builder repeated across test files is a deliberate trade, and two
41
49
  // tests describing the same scenario naturally share a name
42
50
  if (TEST_FILE.test(file))
43
51
  continue;
44
- for (const { name, line, span } of declaredNames(sf)) {
52
+ const baseDeclarations = before ? declaredNames(before) : [];
53
+ for (const { name, line, span, fingerprint } of declaredNames(sf)) {
45
54
  if (!changed.added.has(line))
46
55
  continue;
47
56
  if (name.length < 6 || GENERIC.has(name) || GENERIC.has(name.toLowerCase()))
48
57
  continue;
49
- const existing = (g.symbolIndex.get(normalizeName(name)) ?? []).filter((s) => s.file !== file);
50
- const match = existing[0];
58
+ const existedHere = baseDeclarations.some((declaration) => normalizeName(declaration.name) === normalizeName(name) &&
59
+ declaration.fingerprint === fingerprint);
60
+ if (existedHere)
61
+ continue;
62
+ const match = (g.symbolIndex.get(normalizeName(name)) ?? []).find((symbol) => symbol.file !== file &&
63
+ symbol.existedInBase &&
64
+ symbol.scope === scope &&
65
+ symbol.fingerprint === fingerprint);
51
66
  if (!match)
52
67
  continue;
53
68
  findings.push({
@@ -59,12 +74,15 @@ export const reinvented = {
59
74
  file,
60
75
  line,
61
76
  span,
62
- title: name + '() duplicates an existing export ' + match.file + ':' + match.name,
63
- evidence: { oracle: 'repo symbol index', detail: 'already exported from ' + match.file + ':' + match.line },
77
+ title: name + '() repeats the implementation at ' + match.file + ':' + match.name,
78
+ evidence: {
79
+ oracle: 'base export + callable token fingerprint',
80
+ detail: 'token-identical implementation already exported from ' + match.file + ':' + match.line,
81
+ },
64
82
  // Deliberately not an import statement: the correct specifier depends on the
65
83
  // repo's module resolution, and a wrong one would be exactly the kind of
66
84
  // confidently-wrong output this tool exists to catch.
67
- fix: 'Reuse ' + match.name + ' from ' + match.file + ' instead of redeclaring it',
85
+ fix: 'Consider reusing ' + match.name + ' from ' + match.file + '; if the separation is intentional, keep the boundary explicit',
68
86
  });
69
87
  }
70
88
  }
@@ -59,13 +59,14 @@ engine. The engine does not depend on a workflow provider or terminal layout.
59
59
  |---|---|---|
60
60
  | `src/cli/` | Argument parsing, command dispatch, report publication, exit mapping | Review algorithms |
61
61
  | `src/review.ts` | One review run and its stage orchestration | CLI parsing or presentation |
62
- | `src/ground.ts` | Change-scoped TypeScript projects, parse trees, manifests, symbol index | Check selection |
62
+ | `src/ground.ts` | Change-scoped TypeScript projects, parse trees, manifests, base-aware implementation index | Check selection |
63
63
  | `src/plan.ts` | File selection and per-file capability accounting | Finding generation |
64
64
  | `src/manifest.ts` | Completion state and the authoritative run record | Rendering |
65
65
  | `src/verifiers/` | Deterministic check implementations | Model calls |
66
66
  | `src/judges/` | Prompt data, bounded model loop, tool adapter | Git target selection |
67
67
  | `src/lang/` | Language-pack data, isolated parser workers, optional language oracles | Cross-run policy |
68
68
  | `src/report/` | Pure output adapters | Re-running or reinterpreting a review |
69
+ | `src/github/` | GitHub REST transport and pull-request publication reconciliation | Review decisions or report rendering |
69
70
  | `src/bench.ts` | Historical and labelled evaluation | Production command dispatch |
70
71
  | `src/session.ts`, `src/cache.ts` | Reuse of completed judge work | Completion decisions |
71
72
 
package/docs/ci.md CHANGED
@@ -41,6 +41,10 @@ name: PowerShot
41
41
  on:
42
42
  pull_request:
43
43
 
44
+ concurrency:
45
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
46
+ cancel-in-progress: true
47
+
44
48
  permissions:
45
49
  contents: read
46
50
  pull-requests: write
@@ -77,6 +81,27 @@ and bounded batches, so all declared languages can coexist in one monorepo revie
77
81
  Set `upload-sarif: 'false'` and omit `security-events: write` when GitHub code scanning
78
82
  is unavailable or the workflow should not publish SARIF.
79
83
 
84
+ `comment: 'true'` maintains one summary through a hidden marker scoped to the caller's
85
+ workflow file and job. Reruns update only that exact `github-actions[bot]` comment, so
86
+ another workflow or job using the same bot identity is left alone. Each candidate also
87
+ records its pull-request head. A new head gets a new candidate, which means an old run
88
+ never patches or retires the current head's comment. The first scoped run replaces the
89
+ newest unmarked legacy `## PowerShot` summary from v1.1.2 or older without claiming the
90
+ ambiguous legacy comment through `PATCH`.
91
+
92
+ The comment leads with the verdict, effective severity threshold, review mode, and
93
+ aggregate file/check counts. Portable gaps and files outside parser coverage stay
94
+ visible under a collapsed coverage section without filling the timeline with paths.
95
+ The generated `powershot.manifest.json` keeps the per-file accounting for workflows
96
+ that want to persist it as an artifact.
97
+
98
+ PowerShot checks the target head throughout reconciliation and removes its own
99
+ just-created candidate if it observes a changed head. Simultaneous same-head runs
100
+ relist and converge on one scoped candidate when they complete. Keep the example's
101
+ `concurrency` block to reduce overlap and canceled stale work. GitHub's issue-comment
102
+ REST API has no atomic create-if-absent operation, and cancellation cannot stop a REST
103
+ request already in flight, so concurrency reduces but cannot eliminate that window.
104
+
80
105
  `inline-comments: 'true'` requires `pull-requests: write`. It publishes at most ten
81
106
  findings as one review. Only deterministic `verified` findings with `proven`
82
107
  confidence, severity `medium` or higher, and a GitHub-confirmed added line qualify.
@@ -3,6 +3,10 @@ name: PowerShot
3
3
  on:
4
4
  pull_request:
5
5
 
6
+ concurrency:
7
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
8
+ cancel-in-progress: true
9
+
6
10
  permissions:
7
11
  contents: read
8
12
  pull-requests: write
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xcraft/powershot",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Oracle-first code review for machine-written code, with deterministic verification and CI-ready reports.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "aglumova <alina.glumova@gmail.com>",