@open-cr-agent/cli 0.1.2 → 0.3.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/memory.js +2 -2
- package/dist/metrics.d.ts +47 -0
- package/dist/metrics.js +227 -0
- package/dist/review/args.d.ts +15 -3
- package/dist/review/args.js +86 -12
- package/dist/review/command.d.ts +2 -0
- package/dist/review/command.js +46 -15
- package/dist/review/config.d.ts +29 -4
- package/dist/review/config.js +125 -10
- package/dist/review/plan-render.d.ts +1 -1
- package/dist/review/progress.js +1 -1
- package/dist/review/provenance.d.ts +6 -0
- package/dist/review/provenance.js +38 -0
- package/dist/review/remote-config.d.ts +2 -0
- package/dist/review/remote-config.js +19 -9
- package/dist/review/render.d.ts +2 -1
- package/dist/review/render.js +16 -7
- package/dist/review/runtimes.d.ts +5 -0
- package/dist/review/runtimes.js +29 -0
- package/dist/review/sarif-input.d.ts +4 -0
- package/dist/review/sarif-input.js +34 -0
- package/dist/review/sarif.d.ts +4 -0
- package/dist/review/sarif.js +153 -0
- package/dist/review/target.d.ts +5 -3
- package/dist/review/target.js +125 -7
- package/dist/review/terminal.js +1 -1
- package/dist/run.d.ts +0 -1
- package/dist/run.js +18 -8
- package/dist/version.d.ts +2 -0
- package/dist/version.js +4 -0
- package/package.json +12 -6
package/dist/review/config.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { type ModelChains, type RepoRule } from "@open-cr-agent/core";
|
|
1
|
+
import { type CustomProvider, type ModelChains, OcraError, type RepoRule } from "@open-cr-agent/core";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
export declare const CONFIG_PATH = ".ocra/config.json";
|
|
4
4
|
declare const configSchema: z.ZodObject<{
|
|
5
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
5
6
|
models: z.ZodDefault<z.ZodObject<{
|
|
6
7
|
top: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
7
8
|
standard: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
@@ -14,13 +15,20 @@ declare const configSchema: z.ZodObject<{
|
|
|
14
15
|
judge: z.ZodOptional<z.ZodBoolean>;
|
|
15
16
|
maxCostUsd: z.ZodOptional<z.ZodNumber>;
|
|
16
17
|
maxTasks: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
sampling: z.ZodOptional<z.ZodObject<{
|
|
19
|
+
temperature: z.ZodOptional<z.ZodNumber>;
|
|
20
|
+
seed: z.ZodOptional<z.ZodNumber>;
|
|
21
|
+
}, z.core.$strict>>;
|
|
17
22
|
github: z.ZodDefault<z.ZodObject<{
|
|
18
23
|
requestChanges: z.ZodOptional<z.ZodBoolean>;
|
|
19
24
|
botLogin: z.ZodOptional<z.ZodString>;
|
|
20
25
|
}, z.core.$strict>>;
|
|
21
26
|
include: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
22
27
|
exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
23
|
-
runtime: z.ZodDefault<z.
|
|
28
|
+
runtime: z.ZodDefault<z.ZodUnion<[z.ZodEnum<{
|
|
29
|
+
direct: "direct";
|
|
30
|
+
opencode: "opencode";
|
|
31
|
+
}>, z.ZodString]>>;
|
|
24
32
|
plugins: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
25
33
|
reviewers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
26
34
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -31,19 +39,36 @@ declare const configSchema: z.ZodObject<{
|
|
|
31
39
|
}>>;
|
|
32
40
|
}, z.core.$strict>>>;
|
|
33
41
|
pluginSettings: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
42
|
+
providers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
43
|
+
type: z.ZodLiteral<"openai-compatible">;
|
|
44
|
+
baseUrl: z.ZodURL;
|
|
45
|
+
apiKeyEnv: z.ZodOptional<z.ZodString>;
|
|
46
|
+
models: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
47
|
+
input: z.ZodNumber;
|
|
48
|
+
output: z.ZodNumber;
|
|
49
|
+
cachedInput: z.ZodOptional<z.ZodNumber>;
|
|
50
|
+
}, z.core.$strict>>;
|
|
51
|
+
}, z.core.$strict>>>;
|
|
34
52
|
extends: z.ZodOptional<z.ZodString>;
|
|
35
53
|
}, z.core.$strict>;
|
|
36
|
-
export
|
|
54
|
+
export declare const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
|
|
55
|
+
export declare function configJsonSchema(): Record<string, unknown>;
|
|
56
|
+
export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "providers"> & {
|
|
37
57
|
models: ModelChains;
|
|
58
|
+
providers: Record<string, CustomProvider>;
|
|
38
59
|
rules: RepoRule[];
|
|
39
60
|
};
|
|
40
61
|
export interface LoadOptions {
|
|
41
62
|
repository: boolean;
|
|
42
63
|
read?: (path: string) => Promise<string | undefined>;
|
|
64
|
+
file?: string;
|
|
43
65
|
fetch?: typeof fetch;
|
|
44
66
|
warn?: (message: string) => void;
|
|
45
67
|
}
|
|
46
|
-
export declare class ConfigError extends
|
|
68
|
+
export declare class ConfigError extends OcraError {
|
|
69
|
+
constructor(message: string, options?: {
|
|
70
|
+
cause?: unknown;
|
|
71
|
+
});
|
|
47
72
|
}
|
|
48
73
|
export declare function loadConfig(root: string, env: Readonly<Record<string, string | undefined>>, options?: LoadOptions): Promise<CliConfig>;
|
|
49
74
|
export {};
|
package/dist/review/config.js
CHANGED
|
@@ -1,14 +1,53 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { OcraError, } from "@open-cr-agent/core";
|
|
4
|
+
import { RISK_TIERS } from "@open-cr-agent/core/internal";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { fetchRemoteConfig, mergeConfig } from "./remote-config.js";
|
|
6
7
|
export const CONFIG_PATH = ".ocra/config.json";
|
|
7
8
|
const modelChain = z
|
|
8
9
|
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
|
9
10
|
.transform((value) => (typeof value === "string" ? [value] : value));
|
|
11
|
+
// Where review code is sent: over https, or plain http only to this machine.
|
|
12
|
+
// OpenCode replaces {env:NAME} and {file:path} anywhere in its configuration,
|
|
13
|
+
// so an address with braces could carry a variable or a file (the checkout's
|
|
14
|
+
// .git/config holds its token) to the endpoint.
|
|
15
|
+
const endpoint = z
|
|
16
|
+
.url()
|
|
17
|
+
.refine((value) => {
|
|
18
|
+
const url = new URL(value);
|
|
19
|
+
if (url.protocol === "https:")
|
|
20
|
+
return true;
|
|
21
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
22
|
+
return (url.protocol === "http:" &&
|
|
23
|
+
(host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host)));
|
|
24
|
+
}, "must be an https URL, or http on this machine (localhost, 127.0.0.1, ::1)")
|
|
25
|
+
.refine((value) => !/[{}]/.test(value), "must not contain { or }");
|
|
26
|
+
// The platforms' and clouds' credentials never go to a model endpoint; the
|
|
27
|
+
// same prefixes are never passed to the runtime by prefix (server-env.ts).
|
|
28
|
+
const PLATFORM_TOKENS = /^(GITHUB_|GH_|GITLAB_|CI_|ACTIONS_|RUNNER_|AWS_|AZURE_|NPM_|SSH_|OCRA_)/;
|
|
29
|
+
const price = z.number().min(0).max(10_000);
|
|
30
|
+
const providerSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
type: z.literal("openai-compatible"),
|
|
33
|
+
baseUrl: endpoint,
|
|
34
|
+
apiKeyEnv: z
|
|
35
|
+
.string()
|
|
36
|
+
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be an environment variable name")
|
|
37
|
+
.refine((name) => !PLATFORM_TOKENS.test(name), "must not name a platform or cloud credential")
|
|
38
|
+
.optional(),
|
|
39
|
+
// Prices in US dollars per million tokens; 0 means unpriced.
|
|
40
|
+
models: z
|
|
41
|
+
.record(z.string().regex(/^[\w./:@-]{1,200}$/), z.object({ input: price, output: price, cachedInput: price.optional() }).strict())
|
|
42
|
+
.refine((models) => Object.keys(models).length > 0, "must list at least one model"),
|
|
43
|
+
})
|
|
44
|
+
.strict();
|
|
45
|
+
// The runtimes ocra ships; a plugin may register another name, so the
|
|
46
|
+
// schema lists these for editors and still accepts any other string.
|
|
47
|
+
const BUILTIN_RUNTIMES = ["opencode", "direct"];
|
|
10
48
|
const configSchema = z
|
|
11
49
|
.object({
|
|
50
|
+
$schema: z.string().optional(),
|
|
12
51
|
models: z
|
|
13
52
|
.object({ top: modelChain, standard: modelChain, light: modelChain })
|
|
14
53
|
.partial()
|
|
@@ -21,6 +60,20 @@ const configSchema = z
|
|
|
21
60
|
judge: z.boolean().optional(),
|
|
22
61
|
maxCostUsd: z.number().positive().optional(),
|
|
23
62
|
maxTasks: z.number().int().min(1).max(1_000).optional(),
|
|
63
|
+
// Unset: each provider's default. A seed makes sampling repeatable only
|
|
64
|
+
// where the provider supports it.
|
|
65
|
+
sampling: z
|
|
66
|
+
.object({
|
|
67
|
+
temperature: z.number().min(0).max(2),
|
|
68
|
+
seed: z
|
|
69
|
+
.number()
|
|
70
|
+
.int()
|
|
71
|
+
.min(0)
|
|
72
|
+
.max(2 ** 31 - 1),
|
|
73
|
+
})
|
|
74
|
+
.partial()
|
|
75
|
+
.strict()
|
|
76
|
+
.optional(),
|
|
24
77
|
github: z
|
|
25
78
|
.object({ requestChanges: z.boolean(), botLogin: z.string().min(1) })
|
|
26
79
|
.partial()
|
|
@@ -28,7 +81,7 @@ const configSchema = z
|
|
|
28
81
|
.default({}),
|
|
29
82
|
include: z.array(z.string().min(1)).default([]),
|
|
30
83
|
exclude: z.array(z.string().min(1)).default([]),
|
|
31
|
-
runtime: z.string().min(1).default("opencode"),
|
|
84
|
+
runtime: z.enum(BUILTIN_RUNTIMES).or(z.string().min(1)).default("opencode"),
|
|
32
85
|
plugins: z.array(z.string().min(1)).default([]),
|
|
33
86
|
reviewers: z
|
|
34
87
|
.record(z.string(), z
|
|
@@ -40,23 +93,44 @@ const configSchema = z
|
|
|
40
93
|
.strict())
|
|
41
94
|
.default({}),
|
|
42
95
|
pluginSettings: z.record(z.string(), z.unknown()).default({}),
|
|
96
|
+
providers: z.record(z.string().regex(/^[a-z][a-z0-9-]{0,39}$/), providerSchema).default({}),
|
|
43
97
|
extends: z.string().min(1).optional(),
|
|
44
98
|
})
|
|
45
99
|
.strict();
|
|
100
|
+
export const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
|
|
101
|
+
// JSON Schema (draft 2020-12) of .ocra/config.json, for editors: what a
|
|
102
|
+
// file may say, so defaults are optional and model chains take either form.
|
|
103
|
+
export function configJsonSchema() {
|
|
104
|
+
const generated = z.toJSONSchema(configSchema, { target: "draft-2020-12", io: "input" });
|
|
105
|
+
const { $schema, ...rest } = generated;
|
|
106
|
+
return {
|
|
107
|
+
$schema,
|
|
108
|
+
$id: CONFIG_SCHEMA_ID,
|
|
109
|
+
title: "ocra configuration",
|
|
110
|
+
description: "The .ocra/config.json file of a repository, or a file passed with --config. Unknown keys are rejected.",
|
|
111
|
+
...rest,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
46
114
|
const MODEL_ENV = {
|
|
47
115
|
top: "OCRA_MODEL_TOP",
|
|
48
116
|
standard: "OCRA_MODEL_STANDARD",
|
|
49
117
|
light: "OCRA_MODEL_LIGHT",
|
|
50
118
|
};
|
|
51
|
-
export class ConfigError extends
|
|
119
|
+
export class ConfigError extends OcraError {
|
|
120
|
+
constructor(message, options) {
|
|
121
|
+
super("CONFIG_INVALID", message, options);
|
|
122
|
+
this.name = "ConfigError";
|
|
123
|
+
}
|
|
52
124
|
}
|
|
53
125
|
// Without the repository's file, only defaults and environment variables
|
|
54
126
|
// apply: that file can name plugins, and plugins run code.
|
|
55
127
|
export async function loadConfig(root, env, options = { repository: true }) {
|
|
56
128
|
const read = options.read ?? ((path) => readConfigFromDisk(root, path));
|
|
57
|
-
const { config: parsed, rules } = options.
|
|
58
|
-
? await readConfigFile(
|
|
59
|
-
:
|
|
129
|
+
const { config: parsed, rules } = options.file
|
|
130
|
+
? await readConfigFile(ownFile(options.file), options, options.file)
|
|
131
|
+
: options.repository
|
|
132
|
+
? await readConfigFile(read, options, CONFIG_PATH)
|
|
133
|
+
: { config: configSchema.parse({}), rules: [] };
|
|
60
134
|
const models = {};
|
|
61
135
|
for (const [tier, chain] of Object.entries(parsed.models)) {
|
|
62
136
|
if (chain)
|
|
@@ -70,11 +144,50 @@ export async function loadConfig(root, env, options = { repository: true }) {
|
|
|
70
144
|
if (chain.length > 0)
|
|
71
145
|
models[tier] = chain;
|
|
72
146
|
}
|
|
73
|
-
|
|
147
|
+
for (const model of unpriced(parsed.providers, models)) {
|
|
148
|
+
options.warn?.(`${model} has a price of 0: reported cost and --max-cost-usd do not count its tokens`);
|
|
149
|
+
}
|
|
150
|
+
return { ...parsed, models, providers: toProviders(parsed.providers), rules };
|
|
151
|
+
}
|
|
152
|
+
function toProviders(declared) {
|
|
153
|
+
return Object.fromEntries(Object.entries(declared).map(([id, p]) => [
|
|
154
|
+
id,
|
|
155
|
+
{
|
|
156
|
+
baseUrl: p.baseUrl,
|
|
157
|
+
...(p.apiKeyEnv ? { apiKeyEnv: p.apiKeyEnv } : {}),
|
|
158
|
+
models: Object.fromEntries(Object.entries(p.models).map(([model, price]) => [
|
|
159
|
+
model,
|
|
160
|
+
{
|
|
161
|
+
input: price.input,
|
|
162
|
+
output: price.output,
|
|
163
|
+
...(price.cachedInput === undefined ? {} : { cachedInput: price.cachedInput }),
|
|
164
|
+
},
|
|
165
|
+
])),
|
|
166
|
+
},
|
|
167
|
+
]));
|
|
168
|
+
}
|
|
169
|
+
// Models of declared providers, named in a chain, priced at 0 per token.
|
|
170
|
+
function unpriced(providers, models) {
|
|
171
|
+
const named = new Set(Object.values(models).flat());
|
|
172
|
+
return [...named].filter((model) => {
|
|
173
|
+
const slash = model.indexOf("/");
|
|
174
|
+
const price = providers[model.slice(0, slash)]?.models[model.slice(slash + 1)];
|
|
175
|
+
return price !== undefined && price.input === 0 && price.output === 0;
|
|
176
|
+
});
|
|
74
177
|
}
|
|
75
178
|
// The user's own checkout, read like any local file (links followed): unlike
|
|
76
179
|
// the review's reads, which never follow links (vcs-local), this is trusted
|
|
77
180
|
// configuration, and --no-repo-config skips it for code that is not.
|
|
181
|
+
function ownFile(file) {
|
|
182
|
+
return async () => {
|
|
183
|
+
try {
|
|
184
|
+
return await readFile(file, "utf8");
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
throw new ConfigError(`cannot read ${file}: ${error.message}`, { cause: error });
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
78
191
|
async function readConfigFromDisk(root, path) {
|
|
79
192
|
try {
|
|
80
193
|
return await readFile(join(root, path), "utf8");
|
|
@@ -85,7 +198,7 @@ async function readConfigFromDisk(root, path) {
|
|
|
85
198
|
throw error;
|
|
86
199
|
}
|
|
87
200
|
}
|
|
88
|
-
async function readConfigFile(read, options) {
|
|
201
|
+
async function readConfigFile(read, options, label) {
|
|
89
202
|
const text = await read(CONFIG_PATH);
|
|
90
203
|
if (text === undefined)
|
|
91
204
|
return { config: configSchema.parse({}), rules: [] };
|
|
@@ -94,11 +207,13 @@ async function readConfigFile(read, options) {
|
|
|
94
207
|
data = JSON.parse(text);
|
|
95
208
|
}
|
|
96
209
|
catch (error) {
|
|
97
|
-
throw new ConfigError(`${
|
|
210
|
+
throw new ConfigError(`${label} is not valid JSON: ${error.message}`, {
|
|
211
|
+
cause: error,
|
|
212
|
+
});
|
|
98
213
|
}
|
|
99
214
|
const local = configSchema.safeParse(data);
|
|
100
215
|
if (!local.success)
|
|
101
|
-
throw new ConfigError(`${
|
|
216
|
+
throw new ConfigError(`${label} is invalid: ${z.prettifyError(local.error)}`);
|
|
102
217
|
if (!local.data.extends)
|
|
103
218
|
return { config: local.data, rules: [] };
|
|
104
219
|
// A shared configuration that cannot be loaded costs its defaults, not the
|
package/dist/review/progress.js
CHANGED
|
@@ -33,7 +33,7 @@ export class ProgressPrinter {
|
|
|
33
33
|
function describe(event) {
|
|
34
34
|
switch (event.type) {
|
|
35
35
|
case "run_started":
|
|
36
|
-
return `Reviewing: ${event.changeRequest.title}`;
|
|
36
|
+
return `Reviewing: ${event.changeRequest.title} · run ${event.runId}`;
|
|
37
37
|
case "files_selected":
|
|
38
38
|
return `${event.selected} file(s) selected, ${event.excluded} excluded · risk tier: ${event.tier}`;
|
|
39
39
|
case "files_bundled":
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Sampling } from "@open-cr-agent/core";
|
|
2
|
+
import type { ReviewArgs } from "./args.js";
|
|
3
|
+
import type { CliConfig } from "./config.js";
|
|
4
|
+
export declare function requestedSampling(config: CliConfig, args: ReviewArgs): Sampling;
|
|
5
|
+
export declare function configHash(config: CliConfig, args: ReviewArgs): string;
|
|
6
|
+
//# sourceMappingURL=provenance.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { stableHash } from "@open-cr-agent/core/internal";
|
|
2
|
+
// The sampling a review asks for: the configuration's, with --temperature
|
|
3
|
+
// and --seed on top.
|
|
4
|
+
export function requestedSampling(config, args) {
|
|
5
|
+
const temperature = args.temperature ?? config.sampling?.temperature;
|
|
6
|
+
const seed = args.seed ?? config.sampling?.seed;
|
|
7
|
+
return {
|
|
8
|
+
...(temperature === undefined ? {} : { temperature }),
|
|
9
|
+
...(seed === undefined ? {} : { seed }),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
// The effective configuration and the flags that change what is reviewed
|
|
13
|
+
// and how, so two runs can tell they were set up alike. Keys are names of
|
|
14
|
+
// environment variables, never their values; a password written into a
|
|
15
|
+
// URL is dropped. Sampling is recorded on its own.
|
|
16
|
+
export function configHash(config, args) {
|
|
17
|
+
const { sampling: _, providers, extends: shared, ...rest } = config;
|
|
18
|
+
return stableHash({
|
|
19
|
+
...rest,
|
|
20
|
+
...(shared === undefined ? {} : { extends: withoutCredentials(shared) }),
|
|
21
|
+
providers: Object.fromEntries(Object.entries(providers).map(([id, p]) => [
|
|
22
|
+
id,
|
|
23
|
+
{ ...p, baseUrl: withoutCredentials(p.baseUrl) },
|
|
24
|
+
])),
|
|
25
|
+
flags: { reviewers: args.reviewers, ultra: args.ultra, maxCostUsd: args.maxCostUsd },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function withoutCredentials(value) {
|
|
29
|
+
if (!URL.canParse(value))
|
|
30
|
+
return value;
|
|
31
|
+
const url = new URL(value);
|
|
32
|
+
if (url.username === "" && url.password === "")
|
|
33
|
+
return value;
|
|
34
|
+
url.username = "";
|
|
35
|
+
url.password = "";
|
|
36
|
+
return url.toString();
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=provenance.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const remoteConfigSchema: z.ZodObject<{
|
|
3
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
3
4
|
models: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
|
|
4
5
|
light: "light";
|
|
5
6
|
standard: "standard";
|
|
@@ -16,6 +17,7 @@ export declare const remoteConfigSchema: z.ZodObject<{
|
|
|
16
17
|
exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
17
18
|
reviewers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
18
19
|
github: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
20
|
+
providers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
19
21
|
rules: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
20
22
|
path: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
|
|
21
23
|
rule: z.ZodString;
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { OcraError } from "@open-cr-agent/core";
|
|
3
|
+
import { repoRuleSchema } from "@open-cr-agent/core/internal";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
const MAX_BYTES = 256 * 1024;
|
|
5
6
|
const TIMEOUT_MS = 10_000;
|
|
6
|
-
// What a shared configuration may set: settings
|
|
7
|
-
// a
|
|
7
|
+
// What a shared configuration may set: settings, rules and model providers
|
|
8
|
+
// (a company gateway, for example), never plugins or a runtime, because it
|
|
9
|
+
// is fetched from outside the repository.
|
|
8
10
|
export const remoteConfigSchema = z
|
|
9
11
|
.object({
|
|
12
|
+
$schema: z.string().optional(),
|
|
10
13
|
models: z.record(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
|
|
11
14
|
concurrency: z.unknown().optional(),
|
|
12
15
|
taskTimeoutMinutes: z.unknown().optional(),
|
|
@@ -19,6 +22,7 @@ export const remoteConfigSchema = z
|
|
|
19
22
|
exclude: z.array(z.string()).optional(),
|
|
20
23
|
reviewers: z.record(z.string(), z.unknown()).optional(),
|
|
21
24
|
github: z.record(z.string(), z.unknown()).optional(),
|
|
25
|
+
providers: z.record(z.string(), z.unknown()).optional(),
|
|
22
26
|
rules: z.array(repoRuleSchema).max(500).optional(),
|
|
23
27
|
})
|
|
24
28
|
.strict();
|
|
@@ -27,7 +31,7 @@ export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
|
|
|
27
31
|
const [address = "", fragment] = spec.split("#", 2);
|
|
28
32
|
const url = new URL(address);
|
|
29
33
|
if (url.protocol !== "https:")
|
|
30
|
-
throw new
|
|
34
|
+
throw new OcraError("CONFIG_INVALID", `extends must be an https URL, got ${url.protocol}`);
|
|
31
35
|
const pinned = fragment?.startsWith("sha256=")
|
|
32
36
|
? fragment.slice("sha256=".length).toLowerCase()
|
|
33
37
|
: undefined;
|
|
@@ -37,16 +41,22 @@ export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
|
|
|
37
41
|
redirect: "error",
|
|
38
42
|
});
|
|
39
43
|
if (!response.ok)
|
|
40
|
-
throw new
|
|
44
|
+
throw new OcraError("CONFIG_INVALID", `${url.href} answered ${response.status}`);
|
|
41
45
|
const body = await readLimited(response, MAX_BYTES, url.href);
|
|
42
46
|
if (pinned !== undefined) {
|
|
43
47
|
const actual = createHash("sha256").update(body).digest("hex");
|
|
44
48
|
if (actual !== pinned)
|
|
45
|
-
throw new
|
|
49
|
+
throw new OcraError("CONFIG_INVALID", `${url.href} does not match its pinned sha256`);
|
|
46
50
|
}
|
|
47
51
|
const parsed = remoteConfigSchema.safeParse(JSON.parse(body));
|
|
48
52
|
if (!parsed.success)
|
|
49
|
-
throw new
|
|
53
|
+
throw new OcraError("CONFIG_INVALID", `${url.href} is invalid: ${z.prettifyError(parsed.error)}`);
|
|
54
|
+
// A provider decides where the code under review goes: unpinned, whoever
|
|
55
|
+
// serves the file could send it to an endpoint of their choosing.
|
|
56
|
+
if (pinned === undefined && Object.keys(parsed.data.providers ?? {}).length > 0) {
|
|
57
|
+
const digest = createHash("sha256").update(body).digest("hex");
|
|
58
|
+
throw new OcraError("CONFIG_INVALID", `${url.href} declares providers, which a shared configuration may do only when pinned; check its content, then extend "${address}#sha256=${digest}"`);
|
|
59
|
+
}
|
|
50
60
|
return parsed.data;
|
|
51
61
|
}
|
|
52
62
|
// Stops reading at the limit instead of buffering whatever the server sends.
|
|
@@ -63,7 +73,7 @@ async function readLimited(response, limit, name) {
|
|
|
63
73
|
size += value.byteLength;
|
|
64
74
|
if (size > limit) {
|
|
65
75
|
await reader.cancel();
|
|
66
|
-
throw new
|
|
76
|
+
throw new OcraError("CONFIG_INVALID", `${name} is larger than 256 KB`);
|
|
67
77
|
}
|
|
68
78
|
chunks.push(value);
|
|
69
79
|
}
|
|
@@ -73,7 +83,7 @@ async function readLimited(response, limit, name) {
|
|
|
73
83
|
export function mergeConfig(remote, local) {
|
|
74
84
|
const { rules: _rules, ...settings } = remote;
|
|
75
85
|
const merged = { ...settings, ...local };
|
|
76
|
-
for (const key of ["models", "reviewers", "github"]) {
|
|
86
|
+
for (const key of ["models", "reviewers", "github", "providers"]) {
|
|
77
87
|
const a = settings[key];
|
|
78
88
|
const b = local[key];
|
|
79
89
|
if (a !== undefined || b !== undefined)
|
package/dist/review/render.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type ReviewReport
|
|
1
|
+
import { type ReviewReport } from "@open-cr-agent/core";
|
|
2
|
+
import { serializeOutput } from "@open-cr-agent/core/internal";
|
|
2
3
|
export declare function renderJson(report: ReviewReport): string;
|
|
3
4
|
export declare const safeJson: typeof serializeOutput;
|
|
4
5
|
export declare function renderText(report: ReviewReport, sessionDir?: string): string;
|
package/dist/review/render.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { coverageGaps,
|
|
1
|
+
import { coverageGaps, toReportOutput, } from "@open-cr-agent/core";
|
|
2
|
+
import { serializeOutput } from "@open-cr-agent/core/internal";
|
|
2
3
|
import { forTerminal } from "./terminal.js";
|
|
3
4
|
const SEVERITIES = ["critical", "warning", "suggestion"];
|
|
4
5
|
const VERIFICATION = {
|
|
@@ -14,7 +15,7 @@ export function renderJson(report) {
|
|
|
14
15
|
export const safeJson = serializeOutput;
|
|
15
16
|
export function renderText(report, sessionDir) {
|
|
16
17
|
const incomplete = report.tasks.filter((t) => t.status !== "completed");
|
|
17
|
-
const { notReviewed, nothingReviewed } = coverageGaps(report
|
|
18
|
+
const { notReviewed, nothingReviewed } = coverageGaps(report);
|
|
18
19
|
const lines = [
|
|
19
20
|
`Review: ${report.changeRequest.title}`,
|
|
20
21
|
coverageLine(report),
|
|
@@ -79,12 +80,14 @@ export function renderText(report, sessionDir) {
|
|
|
79
80
|
lines.push(`Incomplete: ${incomplete.length} of ${report.tasks.length} review task(s) did not finish.`);
|
|
80
81
|
}
|
|
81
82
|
if (notReviewed > 0) {
|
|
82
|
-
|
|
83
|
+
const limit = report.spendLimit?.reached
|
|
84
|
+
? `; the spend limit of $${report.spendLimit.usd} was reached`
|
|
85
|
+
: "";
|
|
86
|
+
lines.push(`Incomplete: ${notReviewed} selected file(s) were not reviewed${limit}.`);
|
|
83
87
|
}
|
|
84
88
|
for (const warning of report.warnings)
|
|
85
89
|
lines.push(`Warning: ${warning}`);
|
|
86
|
-
|
|
87
|
-
lines.push(`Session: ${sessionDir}`);
|
|
90
|
+
lines.push(`Run: ${report.runId}${sessionDir ? ` (${sessionDir})` : ""}`);
|
|
88
91
|
return forTerminal(`${lines.join("\n")}\n`);
|
|
89
92
|
}
|
|
90
93
|
function emptyMessage(report, nothingReviewed, notReviewed) {
|
|
@@ -111,7 +114,7 @@ function coverageLine(report) {
|
|
|
111
114
|
`Risk tier: ${report.tier}`,
|
|
112
115
|
`${count("reviewed")} reviewed`,
|
|
113
116
|
`${count("failed")} failed`,
|
|
114
|
-
...(unreviewed > 0 ? [`${unreviewed} not
|
|
117
|
+
...(unreviewed > 0 ? [`${unreviewed} not started`] : []),
|
|
115
118
|
...(count("unchanged") > 0 ? [`${count("unchanged")} unchanged since the last review`] : []),
|
|
116
119
|
`${count("excluded")} excluded`,
|
|
117
120
|
].join(" · ");
|
|
@@ -147,6 +150,12 @@ function groupByFile(findings) {
|
|
|
147
150
|
function summaryLine(report) {
|
|
148
151
|
const counts = SEVERITIES.map((s) => `${report.findings.filter((f) => f.severity === s).length} ${s}`);
|
|
149
152
|
const { inputTokens, cachedTokens, outputTokens, reasoningTokens, costUsd } = report.usage;
|
|
150
|
-
return `${report.findings.length} finding(s) (${counts.join(", ")}) · tokens: ${inputTokens} in (${cachedTokens} cached), ${outputTokens} out, ${reasoningTokens} reasoning · $${costUsd.toFixed(4)}`;
|
|
153
|
+
return `${report.findings.length} finding(s) (${counts.join(", ")}) · tokens: ${inputTokens} in (${cachedTokens} cached), ${outputTokens} out, ${reasoningTokens} reasoning · $${costUsd.toFixed(4)}${spendLimit(report)}`;
|
|
154
|
+
}
|
|
155
|
+
function spendLimit(report) {
|
|
156
|
+
const limit = report.spendLimit;
|
|
157
|
+
if (!limit)
|
|
158
|
+
return "";
|
|
159
|
+
return ` of $${limit.usd}${limit.reached ? " (limit reached)" : ""}`;
|
|
151
160
|
}
|
|
152
161
|
//# sourceMappingURL=render.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type OcraPlugin } from "@open-cr-agent/core";
|
|
2
|
+
export type RuntimeLoaders = Readonly<Record<string, () => Promise<OcraPlugin>>>;
|
|
3
|
+
export declare const BUILTIN_RUNTIMES: RuntimeLoaders;
|
|
4
|
+
export declare function loadRuntime(runtime: string, packageName: string, load: () => Promise<OcraPlugin>): Promise<OcraPlugin>;
|
|
5
|
+
//# sourceMappingURL=runtimes.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { OcraError } from "@open-cr-agent/core";
|
|
2
|
+
import { VERSION } from "../version.js";
|
|
3
|
+
export const BUILTIN_RUNTIMES = {
|
|
4
|
+
opencode: () => loadRuntime("opencode", "@open-cr-agent/runtime-opencode", async () => (await import("@open-cr-agent/runtime-opencode")).opencodeRuntimePlugin),
|
|
5
|
+
direct: () => loadRuntime("direct", "@open-cr-agent/runtime-direct", async () => (await import("@open-cr-agent/runtime-direct")).directRuntimePlugin),
|
|
6
|
+
};
|
|
7
|
+
export async function loadRuntime(runtime, packageName, load) {
|
|
8
|
+
try {
|
|
9
|
+
return await load();
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (!isMissingPackage(error, packageName))
|
|
13
|
+
throw error;
|
|
14
|
+
const other = runtime === "direct" ? "opencode" : "direct";
|
|
15
|
+
throw new OcraError("RUNTIME_START_FAILED", `The ${runtime} runtime needs ${packageName}, which is not installed ` +
|
|
16
|
+
"(an optional dependency of @open-cr-agent/cli, left out by --omit=optional). " +
|
|
17
|
+
`Install it next to ocra with npm install ${packageName}@${VERSION} (-g for a global ocra), ` +
|
|
18
|
+
`or set "runtime": "${other}" in the configuration.`, { cause: error });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Node reports a bare specifier it cannot resolve as ERR_MODULE_NOT_FOUND,
|
|
22
|
+
// naming the package; a module missing inside the package names another.
|
|
23
|
+
function isMissingPackage(error, packageName) {
|
|
24
|
+
if (!(error instanceof Error))
|
|
25
|
+
return false;
|
|
26
|
+
const code = error.code;
|
|
27
|
+
return code === "ERR_MODULE_NOT_FOUND" && error.message.includes(`'${packageName}'`);
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=runtimes.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { parseSarifLog } from "@open-cr-agent/core";
|
|
4
|
+
import { ConfigError } from "./config.js";
|
|
5
|
+
// A log is untrusted input like everything else that reaches the review; it
|
|
6
|
+
// is bounded before it is parsed.
|
|
7
|
+
export const MAX_SARIF_BYTES = 20 * 1024 * 1024;
|
|
8
|
+
export async function loadSarifLogs(paths, cwd) {
|
|
9
|
+
const logs = [];
|
|
10
|
+
for (const path of paths) {
|
|
11
|
+
const file = resolve(cwd, path);
|
|
12
|
+
let text;
|
|
13
|
+
try {
|
|
14
|
+
const { size } = await stat(file);
|
|
15
|
+
if (size > MAX_SARIF_BYTES) {
|
|
16
|
+
throw new ConfigError(`--import-sarif ${path}: larger than ${MAX_SARIF_BYTES} bytes`);
|
|
17
|
+
}
|
|
18
|
+
text = await readFile(file, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error instanceof ConfigError)
|
|
22
|
+
throw error;
|
|
23
|
+
throw new ConfigError(`--import-sarif ${path}: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
logs.push(parseSarifLog(text));
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new ConfigError(`--import-sarif ${path}: ${error.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return logs;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=sarif-input.js.map
|