@0xcraft/powershot 1.0.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.
Files changed (87) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +306 -0
  3. package/dist/agents.js +82 -0
  4. package/dist/bench.js +179 -0
  5. package/dist/budget.js +59 -0
  6. package/dist/bundle.js +173 -0
  7. package/dist/cache.js +155 -0
  8. package/dist/cli/agent-command.js +27 -0
  9. package/dist/cli/app.js +31 -0
  10. package/dist/cli/args.js +76 -0
  11. package/dist/cli/bench-command.js +89 -0
  12. package/dist/cli/dismiss-command.js +42 -0
  13. package/dist/cli/environment.js +32 -0
  14. package/dist/cli/reports.js +64 -0
  15. package/dist/cli/review-command.js +268 -0
  16. package/dist/cli/session-command.js +62 -0
  17. package/dist/cli.js +7 -0
  18. package/dist/config.js +130 -0
  19. package/dist/delegate.js +84 -0
  20. package/dist/dismissed.js +130 -0
  21. package/dist/fspolicy.js +62 -0
  22. package/dist/git.js +238 -0
  23. package/dist/ground.js +286 -0
  24. package/dist/judges/judge.js +85 -0
  25. package/dist/judges/llm.js +234 -0
  26. package/dist/judges/prompts.js +86 -0
  27. package/dist/judges/tools.js +125 -0
  28. package/dist/lang/packs.js +557 -0
  29. package/dist/lang/pyright.js +108 -0
  30. package/dist/lang/python-deps.js +174 -0
  31. package/dist/lang/ruby-deps.js +77 -0
  32. package/dist/langtest.js +248 -0
  33. package/dist/manifest.js +209 -0
  34. package/dist/otel.js +75 -0
  35. package/dist/package-meta.js +13 -0
  36. package/dist/package-smoke.js +110 -0
  37. package/dist/plan.js +134 -0
  38. package/dist/position.js +94 -0
  39. package/dist/report/ansi.js +18 -0
  40. package/dist/report/codequality.js +19 -0
  41. package/dist/report/compact.js +15 -0
  42. package/dist/report/highlight.js +54 -0
  43. package/dist/report/markdown.js +113 -0
  44. package/dist/report/sarif.js +66 -0
  45. package/dist/report/terminal.js +170 -0
  46. package/dist/report/viewer.js +148 -0
  47. package/dist/review.js +355 -0
  48. package/dist/scan.js +67 -0
  49. package/dist/selftest.js +1928 -0
  50. package/dist/session.js +140 -0
  51. package/dist/snapshot.js +101 -0
  52. package/dist/text.js +50 -0
  53. package/dist/types.js +2 -0
  54. package/dist/verifiers/assertion-drift.js +137 -0
  55. package/dist/verifiers/contract-drift.js +140 -0
  56. package/dist/verifiers/copy-paste-drift.js +106 -0
  57. package/dist/verifiers/dead-on-arrival.js +92 -0
  58. package/dist/verifiers/dropped-guard.js +144 -0
  59. package/dist/verifiers/foreign-contract-drift.js +114 -0
  60. package/dist/verifiers/foreign-copy-paste-drift.js +83 -0
  61. package/dist/verifiers/foreign-dropped-guard.js +78 -0
  62. package/dist/verifiers/foreign-phantom-api.js +36 -0
  63. package/dist/verifiers/foreign-phantom-config.js +40 -0
  64. package/dist/verifiers/foreign-phantom-dep.js +82 -0
  65. package/dist/verifiers/foreign-reinvented.js +65 -0
  66. package/dist/verifiers/foreign-scope-creep.js +42 -0
  67. package/dist/verifiers/foreign-swallowed-error.js +36 -0
  68. package/dist/verifiers/foreign-tests.js +143 -0
  69. package/dist/verifiers/foreign-tokens.js +94 -0
  70. package/dist/verifiers/foreign.js +16 -0
  71. package/dist/verifiers/index.js +38 -0
  72. package/dist/verifiers/lying-comment.js +90 -0
  73. package/dist/verifiers/phantom-api.js +88 -0
  74. package/dist/verifiers/phantom-config.js +93 -0
  75. package/dist/verifiers/phantom-dep.js +110 -0
  76. package/dist/verifiers/reinvented.js +74 -0
  77. package/dist/verifiers/scope-creep.js +77 -0
  78. package/dist/verifiers/swallowed-error.js +110 -0
  79. package/dist/verifiers/vacuous-test.js +138 -0
  80. package/docs/architecture.md +191 -0
  81. package/docs/assets/cli-preview.svg +68 -0
  82. package/docs/assets/powershot-logo.png +0 -0
  83. package/docs/ci.md +151 -0
  84. package/examples/github-actions/action.yml +23 -0
  85. package/examples/github-actions/cli.yml +43 -0
  86. package/examples/gitlab/.gitlab-ci.yml +21 -0
  87. package/package.json +65 -0
package/dist/ground.js ADDED
@@ -0,0 +1,286 @@
1
+ import { Project, SyntaxKind } from 'ts-morph';
2
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
+ import { decode } from './text.js';
4
+ import { join, dirname } from 'node:path';
5
+ import { packFor, parse } from './lang/packs.js';
6
+ import { insideRepo, isSymlink, repoPath } from './fspolicy.js';
7
+ const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
8
+ export function normalizeName(n) {
9
+ return n.toLowerCase().replace(/[^a-z0-9]/g, '');
10
+ }
11
+ /**
12
+ * Walks up, but never past `stop`.
13
+ *
14
+ * A tsconfig above the repository is somebody else's, and letting one configure the
15
+ * program that reviews this repository lets a parent directory decide what is
16
+ * type-checked and where paths resolve to.
17
+ */
18
+ function findUp(from, name, stop = from) {
19
+ let dir = from;
20
+ for (;;) {
21
+ const p = join(dir, name);
22
+ if (existsSync(p))
23
+ return p;
24
+ if (dir === stop)
25
+ return undefined;
26
+ const parent = dirname(dir);
27
+ if (parent === dir)
28
+ return undefined;
29
+ dir = parent;
30
+ }
31
+ }
32
+ function depsIn(pkgPath) {
33
+ const deps = new Set();
34
+ try {
35
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
36
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
37
+ for (const name of Object.keys(pkg[field] ?? {}))
38
+ deps.add(name);
39
+ }
40
+ }
41
+ catch {
42
+ // an unparseable manifest means we cannot claim a dep is missing
43
+ }
44
+ return deps;
45
+ }
46
+ /**
47
+ * Dependencies visible to one file, which in a workspace is not the same as the
48
+ * repository's. A monorepo declares react in apps/web/package.json and nowhere else,
49
+ * so reading only the root manifest would call every real dependency phantom.
50
+ */
51
+ function makeDepsFor(root) {
52
+ const cache = new Map();
53
+ return (absPath) => {
54
+ let dir = dirname(absPath);
55
+ const cached = cache.get(dir);
56
+ if (cached)
57
+ return cached;
58
+ const deps = new Set();
59
+ for (;;) {
60
+ const pkgPath = join(dir, 'package.json');
61
+ if (existsSync(pkgPath))
62
+ for (const d of depsIn(pkgPath))
63
+ deps.add(d);
64
+ if (dir === root || dirname(dir) === dir)
65
+ break;
66
+ dir = dirname(dir);
67
+ }
68
+ cache.set(dirname(absPath), deps);
69
+ return deps;
70
+ };
71
+ }
72
+ /**
73
+ * Build the oracle once per run: a type-checked project over the working tree,
74
+ * a syntax-only project holding the base-ref versions, and a symbol index.
75
+ */
76
+ export async function buildGround(root, changed, signal) {
77
+ const tsConfigFilePath = findUp(root, 'tsconfig.json');
78
+ const typed = Boolean(tsConfigFilePath);
79
+ const project = typed
80
+ ? new Project({ tsConfigFilePath })
81
+ : new Project({ compilerOptions: { allowJs: true, checkJs: false } });
82
+ if (!typed)
83
+ project.addSourceFilesAtPaths([`${root}/**/*.{ts,tsx,js,jsx,mts,cts}`, `!${root}/**/node_modules/**`]);
84
+ // What the tsconfig itself owns. A file added past this point is present for
85
+ // reading, but the checker has no program for it — asking one for diagnostics
86
+ // throws from inside TypeScript, which took the whole review down with it.
87
+ const owned = new Set(project.getSourceFiles().map((f) => repoPath(root, String(f.getFilePath()))));
88
+ // A changed file may be new, or excluded from tsconfig — make sure it is present.
89
+ // `readable` is the gate for every file that becomes reviewable: the glob above
90
+ // follows symlinks, so passing this on the way in is not enough on its own.
91
+ const readable = (path) => {
92
+ const abs = insideRepo(root, path);
93
+ return abs && !isSymlink(abs) ? abs : undefined;
94
+ };
95
+ for (const c of changed) {
96
+ if (!CODE_EXT.test(c.path))
97
+ continue;
98
+ const abs = readable(c.path);
99
+ if (!abs)
100
+ continue;
101
+ if (!project.getSourceFile(abs) && existsSync(abs))
102
+ project.addSourceFileAtPath(abs);
103
+ }
104
+ const beforeProject = new Project({ useInMemoryFileSystem: true });
105
+ const files = [];
106
+ for (const c of changed) {
107
+ if (!CODE_EXT.test(c.path))
108
+ continue;
109
+ const abs = readable(c.path);
110
+ if (!abs)
111
+ continue;
112
+ const sf = project.getSourceFile(abs);
113
+ if (!sf)
114
+ continue;
115
+ const before = c.before === undefined ? undefined : beforeProject.createSourceFile(`/before/${c.path}`, c.before, { overwrite: true });
116
+ files.push({ sf, changed: c, before, typed: typed && owned.has(repoPath(root, c.path)) });
117
+ }
118
+ return {
119
+ root,
120
+ project,
121
+ beforeProject,
122
+ changed,
123
+ files,
124
+ symbolIndex: buildSymbolIndex(project, root),
125
+ deps: makeDepsFor(root)(join(root, 'x.ts')),
126
+ depsFor: makeDepsFor(root),
127
+ typed,
128
+ internalPrefixes: pathAliasPrefixes(project, root),
129
+ foreign: await parseForeign(root, changed, signal),
130
+ envManifest: readEnvManifest(root),
131
+ };
132
+ }
133
+ /**
134
+ * Prefixes that `compilerOptions.paths` maps back into the repo — `@/*` and friends.
135
+ * They look like package names but resolve to local files, so treating them as
136
+ * dependencies would be wrong.
137
+ */
138
+ function pathAliasPrefixes(project, root) {
139
+ const prefixes = new Set();
140
+ for (const pattern of Object.keys(project.getCompilerOptions().paths ?? {})) {
141
+ prefixes.add(pattern.replace(/\*$/, ''));
142
+ }
143
+ // a workspace declares its aliases in each package's tsconfig, and the root config
144
+ // often only `extends` a shared base — so the root's paths are not the whole story
145
+ for (const file of tsconfigsIn(root)) {
146
+ try {
147
+ const raw = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1');
148
+ const parsed = JSON.parse(raw.replace(/,(\s*[}\]])/g, '$1'));
149
+ for (const pattern of Object.keys(parsed?.compilerOptions?.paths ?? {})) {
150
+ prefixes.add(String(pattern).replace(/\*$/, ''));
151
+ }
152
+ }
153
+ catch {
154
+ // an unreadable or non-standard tsconfig simply contributes no aliases
155
+ }
156
+ }
157
+ return [...prefixes];
158
+ }
159
+ /** every tsconfig in the repo, capped so a huge monorepo cannot stall the run */
160
+ function tsconfigsIn(root) {
161
+ const out = [];
162
+ const walk = (dir, depth) => {
163
+ if (depth > 4 || out.length > 60)
164
+ return;
165
+ let entries;
166
+ try {
167
+ entries = readdirSync(dir, { withFileTypes: true });
168
+ }
169
+ catch {
170
+ return;
171
+ }
172
+ for (const e of entries) {
173
+ if (e.name === 'node_modules' || e.name.startsWith('.'))
174
+ continue;
175
+ const full = join(dir, e.name);
176
+ if (e.isDirectory())
177
+ walk(full, depth + 1);
178
+ else if (e.name === 'tsconfig.json')
179
+ out.push(full);
180
+ }
181
+ };
182
+ walk(root, 0);
183
+ return out;
184
+ }
185
+ /**
186
+ * Changed files the TypeScript project cannot hold. Nine of the checks need only a
187
+ * parse tree, so a Python or Go file is reviewable the moment its grammar loads —
188
+ * the four that need types simply do not run on it.
189
+ */
190
+ const ENV_MANIFESTS = ['.env.example', '.env.sample', '.env.template', '.env.defaults', '.env.dist'];
191
+ /**
192
+ * Keys declared in whatever env manifest the repo keeps. Read once here rather than
193
+ * per verifier, so every language's phantom-config compares against the same truth.
194
+ */
195
+ export function readEnvManifest(root) {
196
+ for (const name of ENV_MANIFESTS) {
197
+ const path = join(root, name);
198
+ if (!existsSync(path))
199
+ continue;
200
+ const keys = new Set();
201
+ for (const line of decode(readFileSync(path)).split(/\r?\n/)) {
202
+ // A commented entry is how a template documents an optional variable —
203
+ // `# OPENAI_BASE_URL=` says the name exists and may be left unset, so treating
204
+ // it as undeclared reports the very file that documents it.
205
+ const match = /^\s*#?\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
206
+ if (match?.[1])
207
+ keys.add(match[1]);
208
+ }
209
+ return { keys, file: name };
210
+ }
211
+ return undefined;
212
+ }
213
+ async function parseForeign(root, changed, signal) {
214
+ const out = [];
215
+ for (const c of changed) {
216
+ // parsing thousands of files is where a large scan spends its time, so a signal
217
+ // has to be honoured here rather than only once the checks begin
218
+ if (signal?.aborted)
219
+ break;
220
+ const pack = packFor(c.path);
221
+ if (!pack)
222
+ continue;
223
+ const abs = insideRepo(root, c.path);
224
+ if (!abs || !existsSync(abs))
225
+ continue;
226
+ // a generated or minified file is not source anyone reviews, and parsing one
227
+ // costs seconds to produce findings nobody acts on
228
+ if ((statSync(abs, { throwIfNoEntry: false })?.size ?? 0) > 512 * 1024)
229
+ continue;
230
+ const tree = await parse(pack, decode(readFileSync(abs)));
231
+ if (!tree)
232
+ continue;
233
+ const beforeTree = c.before === undefined ? undefined : await parse(pack, c.before);
234
+ out.push({ path: c.path, pack, tree, beforeTree, changed: c });
235
+ }
236
+ return out;
237
+ }
238
+ function buildSymbolIndex(project, root) {
239
+ const index = new Map();
240
+ for (const sf of project.getSourceFiles()) {
241
+ const path = String(sf.getFilePath());
242
+ // the project glob follows symlinked directories, so what it loaded is not
243
+ // proof of where the file is
244
+ if (path.includes('/node_modules/') || !insideRepo(root, path))
245
+ continue;
246
+ for (const [name, decls] of sf.getExportedDeclarations()) {
247
+ const decl = decls[0];
248
+ if (!decl)
249
+ continue;
250
+ // only index things that could plausibly be reimplemented
251
+ const kind = decl.getKind();
252
+ if (kind !== SyntaxKind.FunctionDeclaration &&
253
+ kind !== SyntaxKind.VariableDeclaration &&
254
+ kind !== SyntaxKind.ClassDeclaration)
255
+ continue;
256
+ // a barrel re-exports another module's symbol, so record where it is actually
257
+ // declared — otherwise `export * from './x'` makes every symbol look duplicated
258
+ const declPath = String(decl.getSourceFile().getFilePath());
259
+ if (declPath.includes('/node_modules/') || !insideRepo(root, declPath))
260
+ continue;
261
+ const rel = repoPath(root, declPath);
262
+ const key = normalizeName(name);
263
+ const list = index.get(key) ?? [];
264
+ if (list.some((e) => e.file === rel && e.line === decl.getStartLineNumber()))
265
+ continue;
266
+ list.push({ file: rel, name, line: decl.getStartLineNumber() });
267
+ index.set(key, list);
268
+ }
269
+ }
270
+ return index;
271
+ }
272
+ /**
273
+ * Resolve an absolute position into the line and column a reader can point at.
274
+ * Verifiers use this so a finding marks the exact token, not just the line.
275
+ */
276
+ export function locate(sf, start, length) {
277
+ const { line, column } = sf.getLineAndColumnAtPos(start);
278
+ return { line, span: { column, length } };
279
+ }
280
+ export function locateNode(node) {
281
+ return locate(node.getSourceFile(), node.getStart(), node.getWidth());
282
+ }
283
+ export function relPath(sf, root) {
284
+ return repoPath(root, sf.getFilePath());
285
+ }
286
+ //# sourceMappingURL=ground.js.map
@@ -0,0 +1,85 @@
1
+ import { lines as splitLines } from '#app/text.js';
2
+ import { complete, extractJsonArray } from './llm.js';
3
+ import { TOOLS, runTool } from './tools.js';
4
+ import { CONTEXT, reviewables } from '#app/bundle.js';
5
+ import { COMMON } from './prompts.js';
6
+ const SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
7
+ /**
8
+ * The changed lines with a little context, marked so the model can tell what the
9
+ * change introduced from what merely surrounds it.
10
+ */
11
+ export function renderChanges(files) {
12
+ const parts = [];
13
+ for (const f of files) {
14
+ const all = splitLines(f.text);
15
+ const added = [...f.added].sort((a, b) => a - b);
16
+ if (added.length === 0)
17
+ continue;
18
+ const show = new Set();
19
+ for (const n of added)
20
+ for (let i = n - CONTEXT; i <= n + CONTEXT; i++)
21
+ if (i >= 1 && i <= all.length)
22
+ show.add(i);
23
+ const body = [...show]
24
+ .sort((a, b) => a - b)
25
+ .map((n) => (f.added.has(n) ? '+' : ' ') + String(n).padStart(5) + ' | ' + (all[n - 1] ?? ''))
26
+ .join('\n');
27
+ parts.push('=== ' + f.path + ' ===\n' + body);
28
+ }
29
+ return parts.join('\n\n');
30
+ }
31
+ /** Parse whatever the model returned into findings, discarding anything malformed. */
32
+ export function parseFindings(raw, check) {
33
+ const out = [];
34
+ for (const item of extractJsonArray(raw)) {
35
+ const f = item;
36
+ const file = typeof f.file === 'string' ? f.file : undefined;
37
+ const line = typeof f.line === 'number' ? f.line : undefined;
38
+ const title = typeof f.title === 'string' ? f.title : undefined;
39
+ if (!file || !line || !title)
40
+ continue;
41
+ out.push({
42
+ id: '',
43
+ class: 'judged',
44
+ check,
45
+ severity: (SEVERITIES.has(String(f.severity)) ? f.severity : 'medium'),
46
+ confidence: f.confidence === 'firm' ? 'firm' : 'tentative',
47
+ file,
48
+ line,
49
+ title,
50
+ evidence: typeof f.why === 'string' ? { oracle: 'agent', detail: f.why } : undefined,
51
+ fix: typeof f.fix === 'string' ? f.fix : undefined,
52
+ // a patch, and only ever one line — anything longer is advice wearing a
53
+ // suggestion's clothes, and it would be applied with a single click
54
+ suggestion: typeof f.suggestion === 'string' && f.suggestion.trim() !== '' && !f.suggestion.includes('\n')
55
+ ? f.suggestion
56
+ : undefined,
57
+ });
58
+ }
59
+ return out;
60
+ }
61
+ export async function runJudge(spec, g, cfg, opts = {}) {
62
+ const files = opts.bundle?.files ?? reviewables(g);
63
+ const changes = renderChanges(files);
64
+ if (!changes.trim())
65
+ return [];
66
+ if (spec.needsIntent && !opts.intent)
67
+ return []; // nothing to compare the diff against
68
+ const preamble = spec.needsIntent
69
+ ? 'This change states that it does the following:\n\n' + opts.intent + '\n\n'
70
+ : '';
71
+ const tools = opts.useTools
72
+ ? { defs: TOOLS, run: (name, input) => runTool(g, name, input) }
73
+ : undefined;
74
+ const guidance = tools
75
+ ? '\n\nYou may read files, search the repository, and list references before answering.' +
76
+ ' Check a suspicion rather than reporting it unverified. When done, answer with the JSON array.'
77
+ : '';
78
+ const { text: raw, usage } = await complete(cfg, {
79
+ system: COMMON + '\n\n' + spec.brief + guidance,
80
+ user: preamble + 'Review these changed lines (marked with +).\n\n' + changes,
81
+ }, 2000, tools);
82
+ opts.budget?.spend({ ...usage, units: 1 });
83
+ return parseFindings(raw, spec.name);
84
+ }
85
+ //# sourceMappingURL=judge.js.map
@@ -0,0 +1,234 @@
1
+ export class ProviderError extends Error {
2
+ kind;
3
+ provider;
4
+ retryable;
5
+ status;
6
+ constructor(kind, provider, message, retryable = false, status) {
7
+ super(provider + ' ' + kind + ': ' + redact(message));
8
+ this.kind = kind;
9
+ this.provider = provider;
10
+ this.retryable = retryable;
11
+ this.status = status;
12
+ this.name = 'ProviderError';
13
+ }
14
+ }
15
+ /**
16
+ * A key must never reach a log, a manifest, or a pull-request comment.
17
+ *
18
+ * Provider error bodies echo request context, and a failure reason is one of the few
19
+ * strings this tool copies verbatim into places other people read.
20
+ */
21
+ export function redact(text) {
22
+ return text
23
+ .replace(/\b(sk-[A-Za-z0-9_-]{8,}|gsk_[A-Za-z0-9]{8,}|AIza[A-Za-z0-9_-]{20,})/g, '<redacted>')
24
+ .replace(/([Bb]earer\s+)[A-Za-z0-9._-]{8,}/g, '$1<redacted>')
25
+ .replace(/("?(?:api[_-]?key|x-api-key|authorization)"?\s*[:=]\s*"?)[^",\s]{8,}/gi, '$1<redacted>')
26
+ .slice(0, 2000);
27
+ }
28
+ function classify(status, body, provider) {
29
+ if (status === 401 || status === 403) {
30
+ return new ProviderError('auth', provider, 'rejected the key (' + status + '). ' + body, false, status);
31
+ }
32
+ if (status === 429)
33
+ return new ProviderError('rate_limit', provider, body, true, status);
34
+ if (status === 529 || status === 503 || status === 502) {
35
+ return new ProviderError('overload', provider, body, true, status);
36
+ }
37
+ if (status === 400 || status === 404 || status === 422) {
38
+ // a model name that does not exist, or a request this provider will never accept
39
+ return new ProviderError('configuration', provider, 'rejected the request (' + status + '). ' + body, false, status);
40
+ }
41
+ return new ProviderError(status >= 500 ? 'overload' : 'unknown', provider, body, status >= 500, status);
42
+ }
43
+ const NO_USAGE = () => ({ inputTokens: 0, outputTokens: 0, requests: 0, toolCalls: 0 });
44
+ /** How many times a judge may call tools before it must answer. Bounds the bill. */
45
+ const MAX_TURNS = 8;
46
+ /** A hung request must fail, not wait. */
47
+ const REQUEST_TIMEOUT_MS = Number(process.env.POWERSHOT_TIMEOUT_MS) || 120_000;
48
+ async function post(url, init, provider) {
49
+ const controller = new AbortController();
50
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
51
+ try {
52
+ return await fetch(url, { ...init, signal: controller.signal });
53
+ }
54
+ catch (e) {
55
+ const err = e;
56
+ if (err.name === 'AbortError' || err.name === 'TimeoutError') {
57
+ throw new ProviderError('timeout', provider, 'no response within ' + Math.round(REQUEST_TIMEOUT_MS / 1000) + 's', true);
58
+ }
59
+ throw new ProviderError('unknown', provider, err.message ?? String(e), true);
60
+ }
61
+ finally {
62
+ clearTimeout(timer);
63
+ }
64
+ }
65
+ /**
66
+ * One request, no SDK. Both providers speak JSON over HTTPS and we need exactly
67
+ * one call shape, so a dependency would buy nothing.
68
+ */
69
+ export async function complete(cfg, msg, maxTokens = 2000, tools) {
70
+ if (cfg.provider === 'openai')
71
+ return openai(cfg, msg, maxTokens);
72
+ if (cfg.provider === 'gemini')
73
+ return gemini(cfg, msg, maxTokens);
74
+ return anthropic(cfg, msg, maxTokens, tools);
75
+ }
76
+ export function apiKey(cfg) {
77
+ if (cfg.provider === 'openai')
78
+ return process.env.OPENAI_API_KEY;
79
+ if (cfg.provider === 'gemini')
80
+ return process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY;
81
+ return process.env.ANTHROPIC_API_KEY;
82
+ }
83
+ /** *_BASE_URL is a base, not a full endpoint — join without doubling the slash. */
84
+ export function endpoint(base, fallback, path) {
85
+ return (base ?? fallback).replace(/\/+$/, '') + path;
86
+ }
87
+ /**
88
+ * The agent loop: the judge may read files, search, and walk references before it
89
+ * answers, so it can check a suspicion against the repository instead of guessing
90
+ * from the diff alone. Bounded by MAX_TURNS — an agent that will not conclude is a
91
+ * cost leak, and the last turn's text is taken as its answer either way.
92
+ */
93
+ async function anthropic(cfg, msg, maxTokens, tools) {
94
+ const key = process.env.ANTHROPIC_API_KEY;
95
+ if (!key)
96
+ throw new ProviderError('configuration', 'Anthropic', 'ANTHROPIC_API_KEY is not set');
97
+ const url = endpoint(process.env.ANTHROPIC_BASE_URL, 'https://api.anthropic.com', '/v1/messages');
98
+ const messages = [{ role: 'user', content: msg.user }];
99
+ const usage = NO_USAGE();
100
+ let text = '';
101
+ for (let turn = 0; turn < (tools ? MAX_TURNS : 1); turn++) {
102
+ // The system prompt and tool definitions are byte-identical across every review
103
+ // unit and every judge, so they are marked cacheable: a change split into N units
104
+ // pays to read them once instead of N times. Only the prefix is cached — the diff
105
+ // itself differs per call and is not marked.
106
+ const cacheable = cfg.promptCache !== false;
107
+ const body = {
108
+ model: cfg.model,
109
+ max_tokens: maxTokens,
110
+ // A review that reports nine findings on one run and none on the next is not a
111
+ // review anyone can act on, and it makes a resumed run disagree with the one it
112
+ // continues. The Gemini path already pinned this; the default was left floating.
113
+ temperature: 0,
114
+ system: cacheable
115
+ ? [{ type: 'text', text: msg.system, cache_control: { type: 'ephemeral' } }]
116
+ : msg.system,
117
+ messages,
118
+ };
119
+ if (tools) {
120
+ body.tools = cacheable
121
+ ? tools.defs.map((d, i) => i === tools.defs.length - 1 ? { ...d, cache_control: { type: 'ephemeral' } } : d)
122
+ : tools.defs;
123
+ }
124
+ const res = await post(url, {
125
+ method: 'POST',
126
+ headers: { 'content-type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01' },
127
+ body: JSON.stringify(body),
128
+ }, 'Anthropic');
129
+ if (!res.ok)
130
+ throw classify(res.status, await res.text(), 'Anthropic');
131
+ const json = await res.json();
132
+ usage.requests++;
133
+ usage.inputTokens += Number(json.usage?.input_tokens ?? 0) + Number(json.usage?.cache_read_input_tokens ?? 0);
134
+ usage.outputTokens += Number(json.usage?.output_tokens ?? 0);
135
+ const content = json.content ?? [];
136
+ text = content.map((b) => b.text ?? '').join('');
137
+ const calls = content.filter((b) => b.type === 'tool_use');
138
+ if (!tools || calls.length === 0)
139
+ return { text, usage };
140
+ usage.toolCalls += calls.length;
141
+ messages.push({ role: 'assistant', content });
142
+ messages.push({
143
+ role: 'user',
144
+ content: calls.map((c) => ({
145
+ type: 'tool_result',
146
+ tool_use_id: c.id,
147
+ content: tools.run(c.name, (c.input ?? {})),
148
+ })),
149
+ });
150
+ }
151
+ return { text, usage };
152
+ }
153
+ async function openai(cfg, msg, maxTokens) {
154
+ const key = process.env.OPENAI_API_KEY;
155
+ if (!key)
156
+ throw new ProviderError('configuration', 'OpenAI', 'OPENAI_API_KEY is not set');
157
+ const res = await post(endpoint(process.env.OPENAI_BASE_URL, 'https://api.openai.com/v1', '/chat/completions'), {
158
+ method: 'POST',
159
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + key },
160
+ body: JSON.stringify({
161
+ model: cfg.model,
162
+ max_completion_tokens: maxTokens,
163
+ messages: [
164
+ { role: 'system', content: msg.system },
165
+ { role: 'user', content: msg.user },
166
+ ],
167
+ }),
168
+ }, 'OpenAI');
169
+ if (!res.ok)
170
+ throw classify(res.status, await res.text(), 'OpenAI');
171
+ const json = await res.json();
172
+ return {
173
+ text: json.choices?.[0]?.message?.content ?? '',
174
+ usage: {
175
+ requests: 1,
176
+ inputTokens: Number(json.usage?.prompt_tokens ?? 0),
177
+ outputTokens: Number(json.usage?.completion_tokens ?? 0),
178
+ toolCalls: 0,
179
+ },
180
+ };
181
+ }
182
+ /**
183
+ * Gemini speaks a different shape but the same idea: one system instruction, one user
184
+ * turn, text back. Single-shot — the tool loop is Anthropic-only for now, and a judge
185
+ * without tools still does the whole job from the diff.
186
+ */
187
+ async function gemini(cfg, msg, maxTokens) {
188
+ const key = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY;
189
+ if (!key)
190
+ throw new ProviderError('configuration', 'Gemini', 'GEMINI_API_KEY is not set');
191
+ const base = endpoint(process.env.GEMINI_BASE_URL, 'https://generativelanguage.googleapis.com', '/v1beta/models/');
192
+ const res = await post(base + encodeURIComponent(cfg.model) + ':generateContent', {
193
+ method: 'POST',
194
+ headers: { 'content-type': 'application/json', 'x-goog-api-key': key },
195
+ body: JSON.stringify({
196
+ systemInstruction: { parts: [{ text: msg.system }] },
197
+ contents: [{ role: 'user', parts: [{ text: msg.user }] }],
198
+ generationConfig: { maxOutputTokens: maxTokens, temperature: 0 },
199
+ }),
200
+ }, 'Gemini');
201
+ if (!res.ok)
202
+ throw classify(res.status, await res.text(), 'Gemini');
203
+ const json = await res.json();
204
+ return {
205
+ text: (json.candidates?.[0]?.content?.parts ?? []).map((p) => p.text ?? '').join(''),
206
+ usage: {
207
+ requests: 1,
208
+ inputTokens: Number(json.usageMetadata?.promptTokenCount ?? 0),
209
+ outputTokens: Number(json.usageMetadata?.candidatesTokenCount ?? 0),
210
+ toolCalls: 0,
211
+ },
212
+ };
213
+ }
214
+ /** Models like to wrap JSON in prose or fences. Take the first array we can parse. */
215
+ export function extractJsonArray(text) {
216
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
217
+ const candidates = [fenced?.[1], text].filter(Boolean);
218
+ for (const c of candidates) {
219
+ const start = c.indexOf('[');
220
+ const end = c.lastIndexOf(']');
221
+ if (start === -1 || end <= start)
222
+ continue;
223
+ try {
224
+ const parsed = JSON.parse(c.slice(start, end + 1));
225
+ if (Array.isArray(parsed))
226
+ return parsed;
227
+ }
228
+ catch {
229
+ // try the next candidate
230
+ }
231
+ }
232
+ return [];
233
+ }
234
+ //# sourceMappingURL=llm.js.map