@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/README.md CHANGED
@@ -123,8 +123,8 @@ oracle is never counted as a pass in either profile.
123
123
  | `phantom-dep` | Imports absent from project manifests | Dependency manifests |
124
124
  | `phantom-config` | Configuration keys with no declared source | Repository config index |
125
125
  | `contract-drift` | Signature changes with callers left behind | Types and references |
126
- | `reinvented` | New callables that exactly repeat an implementation already present in the same package | Base symbol + token fingerprint |
127
- | `dropped-guard` | Removed guards, early returns, protective branches | Pre/post AST |
126
+ | `reinvented` | New cross-file declarations with a token-identical implementation, package, visibility, wrapper, and binding context | Base declaration + scoped token fingerprint |
127
+ | `dropped-guard` | Early-exit guards deleted while every other token in the file and changed source set stays unchanged | Pre/post control-flow AST |
128
128
  | `swallowed-error` | Empty or ineffective error handling | AST shape |
129
129
  | `vacuous-test` | Tests that do not assert behavior | Test AST |
130
130
  | `assertion-drift` | Expectations changed under stable behavior | Pre/post test AST |
@@ -180,8 +180,8 @@ commands.
180
180
  flowchart LR
181
181
  START["Selected files and checks"] --> ACCOUNT{"Required work accounted for?"}
182
182
  ACCOUNT -- "yes" --> DEPTH{"Enriched semantic depth available?"}
183
- DEPTH -- "yes" --> FULL["full coverage"]
184
- DEPTH -- "no · portable policy" --> PORTABLE["portable coverage · gaps named"]
183
+ DEPTH -- "yes" --> FULL["full applicable-oracle coverage"]
184
+ DEPTH -- "no · portable policy" --> PORTABLE["portable oracle coverage · gaps named"]
185
185
  FULL --> FINDINGS{"Findings?"}
186
186
  PORTABLE --> FINDINGS
187
187
  FINDINGS -- "no" --> CLEAN["exit 0 · complete and clean"]
@@ -206,11 +206,11 @@ flowchart LR
206
206
  | `2` | Command or Git input was invalid |
207
207
  | `3` | Review is incomplete; findings may be missing |
208
208
 
209
- A clean report names its effective severity threshold, deterministic/model mode, file
210
- and check counts, and coverage level. A partial or failed review instead says it is not
211
- a verdict and keeps the missing work visible. Use `--format manifest` to inspect file
212
- dispositions, executed and unavailable checks, failures, judge units, and
213
- `notLookedAt`.
209
+ A clean report names its effective severity threshold, deterministic/model mode,
210
+ reviewed/changed file ratio, check count, and oracle coverage level. A partial or
211
+ failed review instead says it is not a verdict and keeps the missing work visible. Use
212
+ `--format manifest` to inspect file dispositions, executed and unavailable checks,
213
+ failures, judge units, and `notLookedAt`.
214
214
 
215
215
  ## CI integration
216
216
 
@@ -298,6 +298,20 @@ psh delegate > /tmp/powershot-brief.md
298
298
  psh review --verify-only --absorb /tmp/powershot-findings.json
299
299
  ```
300
300
 
301
+ Delegation uses the same `SelectionPlan` and target snapshot as a real review. The
302
+ brief names every changed file as selected, policy-waived, or failed, so unsupported
303
+ or oversized files cannot disappear between task creation and absorption. For an
304
+ agent that consumes structured input, request the versioned task directly:
305
+
306
+ ```bash
307
+ psh delegate --format json > /tmp/powershot-task.json
308
+ ```
309
+
310
+ `powershot.delegate/v1` contains the resolved file dispositions, applicable judges,
311
+ bounded review units, changed-line excerpts, intent, and the exact output contract.
312
+ Creating either form is LLM-free and does not create a session. Deterministic checks
313
+ run when the returned finding array is absorbed by `psh review --verify-only`.
314
+
301
315
  ## Language coverage
302
316
 
303
317
  | Language | Available oracles |
package/dist/cli/args.js CHANGED
@@ -6,7 +6,8 @@ psh — code review for machine-written code
6
6
  psh review --from main --to feat/x branch range
7
7
  psh review --commit <hash> a single commit
8
8
  psh scan <path> audit existing files, no git history needed
9
- psh delegate emit the judging work for an agent you already pay for
9
+ psh delegate [--format text|markdown|json]
10
+ emit a selection-accounted task for your existing agent
10
11
  psh session list | view <id> runs that can be resumed, or replayed as a page
11
12
  psh session diff <a> <b> what one review found that the other did not
12
13
  psh dismiss <id> [--reason "..."] record that a finding is correct, and stop showing it
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import { Budget, parseLimits } from '#app/budget.js';
4
4
  import { loadConfig, policyChanged } from '#app/config.js';
5
- import { absorbDelegated, delegateBrief } from '#app/delegate.js';
5
+ import { absorbDelegated, buildDelegateTask, delegateBrief, delegateJson } from '#app/delegate.js';
6
6
  import { baseRefOf, checkRange, headSha, repoRoot, shaOf } from '#app/git.js';
7
7
  import { JUDGES } from '#app/judges/prompts.js';
8
8
  import { RunManifest, coverageProblems, hashOf, writeManifest } from '#app/manifest.js';
@@ -13,6 +13,7 @@ import { progress, stage } from '#app/report/terminal.js';
13
13
  import { summarizeRun } from '#app/report/summary.js';
14
14
  import { review } from '#app/review.js';
15
15
  import { scanPaths } from '#app/scan.js';
16
+ import { SelectionPlan } from '#app/plan.js';
16
17
  import { Session } from '#app/session.js';
17
18
  import { withTargetTree } from '#app/snapshot.js';
18
19
  import { SEVERITIES } from '#app/types.js';
@@ -33,6 +34,10 @@ export async function runReviewCommand(command, values, positionals) {
33
34
  process.stderr.write('--format must be one of: ' + REPORT_FORMATS.join(', ') + '\n');
34
35
  return 2;
35
36
  }
37
+ if (command === 'delegate' && !['text', 'markdown', 'json'].includes(values.format)) {
38
+ process.stderr.write('delegate --format must be one of: text, markdown, json\n');
39
+ return 2;
40
+ }
36
41
  if ((values.from && !values.to) || (values.to && !values.from)) {
37
42
  process.stderr.write('--from and --to must be given together.\n');
38
43
  return 2;
@@ -142,19 +147,32 @@ export async function runReviewCommand(command, values, positionals) {
142
147
  if (command === 'delegate') {
143
148
  const { buildGround } = await import('#app/ground.js');
144
149
  const { collectChanges, statedIntent } = await import('#app/git.js');
145
- const { matchesAny } = await import('#app/config.js');
146
- const changes = collectChanges(root, range).filter((change) => !matchesAny(change.path, config.ignore));
147
- if (changes.length === 0) {
150
+ const all = collectChanges(root, range);
151
+ if (all.length === 0) {
148
152
  process.stderr.write('Nothing to review.\n');
149
153
  return 0;
150
154
  }
151
- const ground = await withTargetTree(root, range, (tree) => buildGround(tree, changes));
152
- process.stdout.write(delegateBrief(ground, config, {
153
- intent: statedIntent(root, range),
154
- maxBundleLines: maxBundle,
155
- checks: requestedChecks,
156
- }));
157
- return 0;
155
+ const task = await withTargetTree(root, range, async (tree) => {
156
+ const plan = SelectionPlan.build(tree, all, config);
157
+ const selected = plan.keep(all);
158
+ const ground = await buildGround(tree, selected, undefined, all);
159
+ plan.accountForGround(selected, ground);
160
+ return buildDelegateTask(ground, config, plan.items(), {
161
+ intent: statedIntent(root, range),
162
+ maxBundleLines: maxBundle,
163
+ checks: requestedChecks,
164
+ target: { from: values.from, to: values.to, commit: values.commit },
165
+ });
166
+ });
167
+ const rendered = values.format === 'json' ? delegateJson(task) : delegateBrief(task);
168
+ if (values.output !== undefined) {
169
+ writeFileSync(values.output, rendered);
170
+ process.stderr.write(dim(' written to ' + values.output) + '\n');
171
+ }
172
+ else {
173
+ process.stdout.write(rendered);
174
+ }
175
+ return task.state === 'failed' ? 3 : 0;
158
176
  }
159
177
  const canceller = new AbortController();
160
178
  let interrupted = false;
@@ -46,6 +46,7 @@ export function runSessionCommand(positionals) {
46
46
  verifyOnly: session.report.verifyOnly,
47
47
  minSeverity: session.report.minSeverity,
48
48
  filesReviewed: session.report.filesReviewed,
49
+ filesChanged: session.report.filesChanged,
49
50
  deterministicChecks: session.report.deterministicChecks,
50
51
  scopeDetails: session.report.scopeDetails,
51
52
  }));
package/dist/delegate.js CHANGED
@@ -2,44 +2,120 @@ import { bundle, bundleName } from './bundle.js';
2
2
  import { renderChanges } from './judges/judge.js';
3
3
  import { COMMON, JUDGES } from './judges/prompts.js';
4
4
  import { enabled } from './config.js';
5
- export function delegateBrief(g, cfg, opts) {
5
+ import { stripPath } from './text.js';
6
+ export const DELEGATE_SCHEMA = 'powershot.delegate/v1';
7
+ export function buildDelegateTask(g, cfg, files, opts) {
6
8
  const units = bundle(g, opts.maxBundleLines ?? 1200);
7
9
  const judges = JUDGES.filter((j) => opts.checks ? opts.checks.includes(j.name) : enabled(cfg.judges, j.name));
10
+ return {
11
+ schema: DELEGATE_SCHEMA,
12
+ state: files.some((file) => file.disposition === 'failed') ? 'failed' : 'complete',
13
+ target: opts.target ?? {},
14
+ intent: opts.intent,
15
+ files: files.map(({ path, disposition, reason, bytes, addedLines, language }) => ({
16
+ path,
17
+ disposition,
18
+ reason,
19
+ bytes,
20
+ addedLines,
21
+ language,
22
+ })),
23
+ judges: judges.map((judge) => {
24
+ const usesIntent = judge.needsIntent === true;
25
+ const applicable = !usesIntent || Boolean(opts.intent);
26
+ return {
27
+ name: judge.name,
28
+ brief: judge.brief,
29
+ applicable,
30
+ usesIntent,
31
+ reason: applicable ? undefined : 'no stated intent available',
32
+ };
33
+ }),
34
+ instructions: {
35
+ summary: 'Perform only the judgement work in this task. PowerShot runs its deterministic checks when these findings are absorbed.',
36
+ groundRules: COMMON,
37
+ output: {
38
+ type: 'json-array',
39
+ empty: [],
40
+ example: [{
41
+ file: 'src/a.ts',
42
+ line: 12,
43
+ severity: 'high',
44
+ confidence: 'firm',
45
+ check: 'plausible-logic',
46
+ title: '…',
47
+ why: '…',
48
+ fix: '…',
49
+ }],
50
+ },
51
+ },
52
+ units: units.map((unit, index) => ({
53
+ id: index + 1,
54
+ name: bundleName(unit, g.root),
55
+ files: unit.files.map((file) => file.path),
56
+ changes: renderChanges(unit.files),
57
+ })),
58
+ };
59
+ }
60
+ const cell = (value) => stripPath(value).replace(/\|/g, '\\|');
61
+ export function delegateBrief(task) {
62
+ const scope = task.files.length === 0
63
+ ? ['_No changed files._']
64
+ : [
65
+ '| File | Disposition | Language | Added | Reason |',
66
+ '|---|---|---|---:|---|',
67
+ ...task.files.map((file) => '| ' + cell(file.path) + ' | ' + file.disposition + ' | ' + file.language + ' | ' + file.addedLines +
68
+ ' | ' + cell(file.reason ?? '') + ' |'),
69
+ ];
8
70
  const out = [
9
71
  '# PowerShot review brief',
10
72
  '',
11
- 'The deterministic checks have already run; what follows is the judgement work.',
73
+ task.instructions.summary,
12
74
  'Act as each judge below over each review unit, then reply with a single JSON array',
13
75
  'combining every finding. Add nothing outside the array.',
14
76
  '',
77
+ '## Scope',
78
+ '',
79
+ 'Task state: **' + task.state + '**.',
80
+ '',
81
+ ...scope,
82
+ '',
15
83
  '## Output contract',
16
84
  '',
17
85
  '```json',
18
- '[{"file":"src/a.ts","line":12,"severity":"high","confidence":"firm",',
19
- ' "check":"plausible-logic","title":"…","why":"…","fix":"…"}]',
86
+ JSON.stringify(task.instructions.output.example, null, 2),
20
87
  '```',
21
88
  '',
22
- 'Return `[]` when a judge finds nothing. An empty array is a success, not a failure.',
89
+ 'Return `' + JSON.stringify(task.instructions.output.empty) +
90
+ '` when a judge finds nothing. An empty array is a success, not a failure.',
23
91
  '',
24
92
  '## Ground rules',
25
93
  '',
26
- COMMON,
94
+ task.instructions.groundRules,
27
95
  '',
28
96
  ];
29
- for (const judge of judges) {
97
+ for (const judge of task.judges) {
30
98
  out.push('## Judge: ' + judge.name, '', judge.brief, '');
31
- if (judge.needsIntent) {
32
- out.push(opts.intent
33
- ? 'This change states that it does the following:\n\n> ' + opts.intent.split('\n').join('\n> ')
99
+ if (judge.usesIntent) {
100
+ out.push(task.intent
101
+ ? 'This change states that it does the following:\n\n> ' + task.intent.split('\n').join('\n> ')
34
102
  : '_No stated intent available (no commit in range) — skip this judge._', '');
35
103
  }
36
104
  }
37
105
  out.push('## Review units', '');
38
- units.forEach((unit, i) => {
39
- out.push('### Unit ' + (i + 1) + ' ' + bundleName(unit, g.root), '', '```', renderChanges(unit.files), '```', '');
40
- });
106
+ if (task.units.length === 0) {
107
+ out.push('_No judgement units. Every changed file was excluded or failed preparation; inspect Scope._', '');
108
+ }
109
+ else {
110
+ task.units.forEach((unit) => {
111
+ out.push('### Unit ' + unit.id + ' — ' + cell(unit.name), '', '```', unit.changes, '```', '');
112
+ });
113
+ }
41
114
  return out.join('\n');
42
115
  }
116
+ export function delegateJson(task) {
117
+ return JSON.stringify(task, null, 2) + '\n';
118
+ }
43
119
  export function absorbDelegated(json) {
44
120
  let parsed;
45
121
  try {
package/dist/git.js CHANGED
@@ -89,7 +89,7 @@ function changedPaths(raw) {
89
89
  }
90
90
  const path = fields[i++];
91
91
  if (path)
92
- out.push({ path, beforePath: status === 'A' ? undefined : path });
92
+ out.push({ path, beforePath: status === 'A' ? undefined : path, deleted: status === 'D' });
93
93
  }
94
94
  return out;
95
95
  }
@@ -195,9 +195,9 @@ export function collectChanges(root, range) {
195
195
  }
196
196
  const files = [];
197
197
  const tracked = changedPaths(git(root, [
198
- 'diff', '--name-status', '-z', '--diff-filter=ACMRT', '--find-renames', ...diffArgs,
198
+ 'diff', '--name-status', '-z', '--diff-filter=ACDMRT', '--find-renames', ...diffArgs,
199
199
  ]));
200
- for (const { path, beforePath } of tracked) {
200
+ for (const { path, beforePath, deleted } of tracked) {
201
201
  const pathspecs = beforePath && beforePath !== path
202
202
  ? [':(literal)' + beforePath, ':(literal)' + path]
203
203
  : [':(literal)' + path];
@@ -207,6 +207,8 @@ export function collectChanges(root, range) {
207
207
  ]);
208
208
  files.push({
209
209
  path,
210
+ beforePath: beforePath && beforePath !== path ? beforePath : undefined,
211
+ deleted,
210
212
  added: addedLinesInPatch(patch),
211
213
  before: beforePath === undefined ? undefined : fileAtRef(root, baseRef, beforePath),
212
214
  });
package/dist/ground.js CHANGED
@@ -4,7 +4,7 @@ import { decode } from './text.js';
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { PACKS, packFor, parseIsolated } from './lang/packs.js';
6
6
  import { insideRepo, isSymlink, repoPath } from './fspolicy.js';
7
- import { createReinventionScopeResolver, typescriptImplementationFingerprint } from './reinvention.js';
7
+ import { createReinventionScopeResolver, exportedDeclarations, typescriptImplementationFingerprint } from './reinvention.js';
8
8
  const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
9
9
  const TS_CONFIG = /^tsconfig(?:\..+)?\.json$/i;
10
10
  const MISSING_TYPE_PREFIXES = [
@@ -216,7 +216,7 @@ function makeDepsFor(root) {
216
216
  * packages, a syntax-only project for unconfigured changes and the base-ref trees,
217
217
  * and one deduplicated symbol index over the relevant project closures.
218
218
  */
219
- export async function buildGround(root, changed, signal) {
219
+ export async function buildGround(root, changed, signal, inventory = changed) {
220
220
  root = resolve(root);
221
221
  const directoryCache = new Map();
222
222
  const projectCache = new Map();
@@ -253,6 +253,7 @@ export async function buildGround(root, changed, signal) {
253
253
  }
254
254
  }
255
255
  const beforeProject = new Project({ useInMemoryFileSystem: true });
256
+ const beforeSources = new Map();
256
257
  const files = [];
257
258
  for (const c of changed) {
258
259
  if (!CODE_EXT.test(c.path))
@@ -264,7 +265,12 @@ export async function buildGround(root, changed, signal) {
264
265
  const sf = configured?.project.getSourceFile(abs) ?? syntaxProject.getSourceFile(abs);
265
266
  if (!sf)
266
267
  continue;
267
- const before = c.before === undefined ? undefined : beforeProject.createSourceFile(`/before/${c.path}`, c.before, { overwrite: true });
268
+ const beforeKey = c.beforePath ?? c.path;
269
+ let before = c.before === undefined ? undefined : beforeSources.get(beforeKey);
270
+ if (c.before !== undefined && !before) {
271
+ before = beforeProject.createSourceFile(`/before/${beforeKey}`, c.before);
272
+ beforeSources.set(beforeKey, before);
273
+ }
268
274
  files.push({
269
275
  sf,
270
276
  changed: c,
@@ -289,6 +295,7 @@ export async function buildGround(root, changed, signal) {
289
295
  configFiles,
290
296
  beforeProject,
291
297
  changed,
298
+ inventory,
292
299
  files,
293
300
  symbolIndex: buildSymbolIndex(sourceFiles, root, changed, beforeProject),
294
301
  deps: depsFor(join(root, 'x.ts')),
@@ -465,24 +472,18 @@ function buildSymbolIndex(sourceFiles, root, changed, beforeProject) {
465
472
  // proof of where the file is
466
473
  if (path.includes('/node_modules/') || !insideRepo(root, path))
467
474
  continue;
468
- for (const [name, decls] of sf.getExportedDeclarations()) {
475
+ for (const { name, node: decl } of exportedDeclarations(sf)) {
469
476
  const key = normalizeName(name);
470
477
  // Fingerprint only names the change could have introduced. This keeps index
471
478
  // construction proportional to the diff even when the project closure is a
472
479
  // very large monorepo.
473
480
  if (!relevantNames.has(key))
474
481
  continue;
475
- const decl = decls[0];
476
- if (!decl)
477
- continue;
478
482
  // only index things that could plausibly be reimplemented
479
483
  const kind = decl.getKind();
480
484
  if (kind !== SyntaxKind.FunctionDeclaration &&
481
485
  kind !== SyntaxKind.VariableDeclaration)
482
486
  continue;
483
- const fingerprint = typescriptImplementationFingerprint(decl);
484
- if (!fingerprint)
485
- continue;
486
487
  // A barrel alias can be new in this change even when its underlying callable
487
488
  // predates it. Index the declaration from its own module, where both its name
488
489
  // and base existence can be proved, rather than manufacturing history for the
@@ -493,9 +494,15 @@ function buildSymbolIndex(sourceFiles, root, changed, beforeProject) {
493
494
  if (declPath.includes('/node_modules/') || !insideRepo(root, declPath))
494
495
  continue;
495
496
  const rel = repoPath(root, declPath);
497
+ const fingerprint = typescriptImplementationFingerprint(decl, rel);
498
+ if (!fingerprint)
499
+ continue;
496
500
  const change = changes.get(rel);
497
- const before = change?.before === undefined ? undefined : beforeProject.getSourceFile('/before/' + rel);
498
- const existedInBase = change === undefined || (before?.getExportedDeclarations().get(name) ?? []).some((baseDeclaration) => typescriptImplementationFingerprint(baseDeclaration) === fingerprint);
501
+ const beforePath = change?.beforePath ?? rel;
502
+ const before = change?.before === undefined ? undefined : beforeProject.getSourceFile('/before/' + beforePath);
503
+ const sameScope = scopeFor(beforePath) === scopeFor(rel);
504
+ const existedInBase = change === undefined || (sameScope && before ? exportedDeclarations(before).some((baseDeclaration) => baseDeclaration.name === name &&
505
+ typescriptImplementationFingerprint(baseDeclaration.node, beforePath) === fingerprint) : false);
499
506
  const list = index.get(key) ?? [];
500
507
  if (list.some((e) => e.file === rel && e.line === decl.getStartLineNumber()))
501
508
  continue;