@open-cr-agent/cli 0.2.0 → 0.4.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.
@@ -1,9 +1,13 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { RISK_TIERS, } from "@open-cr-agent/core";
3
+ import { OcraError, } from "@open-cr-agent/core";
4
+ import { AGENT_ROLES, EFFORT_LEVELS, 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";
8
+ // How much a model reasons before answering (ADR-0025); unset leaves the
9
+ // provider's default.
10
+ const effort = z.enum(EFFORT_LEVELS);
7
11
  const modelChain = z
8
12
  .union([z.string().min(1), z.array(z.string().min(1)).min(1)])
9
13
  .transform((value) => (typeof value === "string" ? [value] : value));
@@ -39,15 +43,27 @@ const providerSchema = z
39
43
  models: z
40
44
  .record(z.string().regex(/^[\w./:@-]{1,200}$/), z.object({ input: price, output: price, cachedInput: price.optional() }).strict())
41
45
  .refine((models) => Object.keys(models).length > 0, "must list at least one model"),
46
+ // How the endpoint takes a reasoning effort: "openai" sends
47
+ // reasoning_effort (the default), "openrouter" sends reasoning.effort.
48
+ effort: z.enum(["openai", "openrouter"]).optional(),
42
49
  })
43
50
  .strict();
51
+ // The runtimes ocra ships; a plugin may register another name, so the
52
+ // schema lists these for editors and still accepts any other string.
53
+ const BUILTIN_RUNTIMES = ["opencode", "direct"];
44
54
  const configSchema = z
45
55
  .object({
56
+ $schema: z.string().optional(),
46
57
  models: z
47
58
  .object({ top: modelChain, standard: modelChain, light: modelChain })
48
59
  .partial()
49
60
  .strict()
50
61
  .default({}),
62
+ effort: z
63
+ .object({ top: effort, standard: effort, light: effort })
64
+ .partial()
65
+ .strict()
66
+ .default({}),
51
67
  concurrency: z.number().int().min(1).max(32).optional(),
52
68
  taskTimeoutMinutes: z.number().positive().optional(),
53
69
  runTimeoutMinutes: z.number().positive().optional(),
@@ -55,6 +71,20 @@ const configSchema = z
55
71
  judge: z.boolean().optional(),
56
72
  maxCostUsd: z.number().positive().optional(),
57
73
  maxTasks: z.number().int().min(1).max(1_000).optional(),
74
+ // Unset: each provider's default. A seed makes sampling repeatable only
75
+ // where the provider supports it.
76
+ sampling: z
77
+ .object({
78
+ temperature: z.number().min(0).max(2),
79
+ seed: z
80
+ .number()
81
+ .int()
82
+ .min(0)
83
+ .max(2 ** 31 - 1),
84
+ })
85
+ .partial()
86
+ .strict()
87
+ .optional(),
58
88
  github: z
59
89
  .object({ requestChanges: z.boolean(), botLogin: z.string().min(1) })
60
90
  .partial()
@@ -62,36 +92,65 @@ const configSchema = z
62
92
  .default({}),
63
93
  include: z.array(z.string().min(1)).default([]),
64
94
  exclude: z.array(z.string().min(1)).default([]),
65
- runtime: z.string().min(1).default("opencode"),
95
+ runtime: z.enum(BUILTIN_RUNTIMES).or(z.string().min(1)).default("opencode"),
66
96
  plugins: z.array(z.string().min(1)).default([]),
67
97
  reviewers: z
68
98
  .record(z.string(), z
69
99
  .object({
70
100
  enabled: z.boolean(),
71
101
  minTier: z.enum(RISK_TIERS),
102
+ effort,
72
103
  })
73
104
  .partial()
74
105
  .strict())
75
106
  .default({}),
107
+ roles: z
108
+ .partialRecord(z.enum(AGENT_ROLES), z.object({ effort }).partial().strict())
109
+ .default({}),
76
110
  pluginSettings: z.record(z.string(), z.unknown()).default({}),
77
111
  providers: z.record(z.string().regex(/^[a-z][a-z0-9-]{0,39}$/), providerSchema).default({}),
78
112
  extends: z.string().min(1).optional(),
79
113
  })
80
114
  .strict();
115
+ export const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
116
+ // JSON Schema (draft 2020-12) of .ocra/config.json, for editors: what a
117
+ // file may say, so defaults are optional and model chains take either form.
118
+ export function configJsonSchema() {
119
+ const generated = z.toJSONSchema(configSchema, { target: "draft-2020-12", io: "input" });
120
+ const { $schema, ...rest } = generated;
121
+ return {
122
+ $schema,
123
+ $id: CONFIG_SCHEMA_ID,
124
+ title: "ocra configuration",
125
+ description: "The .ocra/config.json file of a repository, or a file passed with --config. Unknown keys are rejected.",
126
+ ...rest,
127
+ };
128
+ }
81
129
  const MODEL_ENV = {
82
130
  top: "OCRA_MODEL_TOP",
83
131
  standard: "OCRA_MODEL_STANDARD",
84
132
  light: "OCRA_MODEL_LIGHT",
85
133
  };
86
- export class ConfigError extends Error {
134
+ const EFFORT_ENV = {
135
+ top: "OCRA_EFFORT_TOP",
136
+ standard: "OCRA_EFFORT_STANDARD",
137
+ light: "OCRA_EFFORT_LIGHT",
138
+ };
139
+ export class ConfigError extends OcraError {
140
+ constructor(message, options) {
141
+ super("CONFIG_INVALID", message, options);
142
+ this.name = "ConfigError";
143
+ }
87
144
  }
88
145
  // Without the repository's file, only defaults and environment variables
89
146
  // apply: that file can name plugins, and plugins run code.
90
147
  export async function loadConfig(root, env, options = { repository: true }) {
91
148
  const read = options.read ?? ((path) => readConfigFromDisk(root, path));
92
- const { config: parsed, rules } = options.repository
93
- ? await readConfigFile(read, options)
94
- : { config: configSchema.parse({}), rules: [] };
149
+ const { config: parsed, rules } = options.file
150
+ ? await readConfigFile(ownFile(options.file), options, options.file)
151
+ : options.repository
152
+ ? await readConfigFile(read, options, CONFIG_PATH)
153
+ : { config: configSchema.parse({}), rules: [] };
95
154
  const models = {};
96
155
  for (const [tier, chain] of Object.entries(parsed.models)) {
97
156
  if (chain)
@@ -105,10 +164,29 @@ export async function loadConfig(root, env, options = { repository: true }) {
105
164
  if (chain.length > 0)
106
165
  models[tier] = chain;
107
166
  }
167
+ const efforts = {
168
+ ...parsed.effort,
169
+ };
170
+ for (const [tier, name] of Object.entries(EFFORT_ENV)) {
171
+ const value = env[name]?.trim();
172
+ if (!value)
173
+ continue;
174
+ const level = effort.safeParse(value);
175
+ if (!level.success) {
176
+ throw new ConfigError(`${name} must be one of ${EFFORT_LEVELS.join(", ")}`);
177
+ }
178
+ efforts[tier] = level.data;
179
+ }
108
180
  for (const model of unpriced(parsed.providers, models)) {
109
181
  options.warn?.(`${model} has a price of 0: reported cost and --max-cost-usd do not count its tokens`);
110
182
  }
111
- return { ...parsed, models, providers: toProviders(parsed.providers), rules };
183
+ return {
184
+ ...parsed,
185
+ models,
186
+ effort: efforts,
187
+ providers: toProviders(parsed.providers),
188
+ rules,
189
+ };
112
190
  }
113
191
  function toProviders(declared) {
114
192
  return Object.fromEntries(Object.entries(declared).map(([id, p]) => [
@@ -116,6 +194,7 @@ function toProviders(declared) {
116
194
  {
117
195
  baseUrl: p.baseUrl,
118
196
  ...(p.apiKeyEnv ? { apiKeyEnv: p.apiKeyEnv } : {}),
197
+ ...(p.effort ? { effort: p.effort } : {}),
119
198
  models: Object.fromEntries(Object.entries(p.models).map(([model, price]) => [
120
199
  model,
121
200
  {
@@ -139,6 +218,16 @@ function unpriced(providers, models) {
139
218
  // The user's own checkout, read like any local file (links followed): unlike
140
219
  // the review's reads, which never follow links (vcs-local), this is trusted
141
220
  // configuration, and --no-repo-config skips it for code that is not.
221
+ function ownFile(file) {
222
+ return async () => {
223
+ try {
224
+ return await readFile(file, "utf8");
225
+ }
226
+ catch (error) {
227
+ throw new ConfigError(`cannot read ${file}: ${error.message}`, { cause: error });
228
+ }
229
+ };
230
+ }
142
231
  async function readConfigFromDisk(root, path) {
143
232
  try {
144
233
  return await readFile(join(root, path), "utf8");
@@ -149,7 +238,7 @@ async function readConfigFromDisk(root, path) {
149
238
  throw error;
150
239
  }
151
240
  }
152
- async function readConfigFile(read, options) {
241
+ async function readConfigFile(read, options, label) {
153
242
  const text = await read(CONFIG_PATH);
154
243
  if (text === undefined)
155
244
  return { config: configSchema.parse({}), rules: [] };
@@ -158,11 +247,13 @@ async function readConfigFile(read, options) {
158
247
  data = JSON.parse(text);
159
248
  }
160
249
  catch (error) {
161
- throw new ConfigError(`${CONFIG_PATH} is not valid JSON: ${error.message}`);
250
+ throw new ConfigError(`${label} is not valid JSON: ${error.message}`, {
251
+ cause: error,
252
+ });
162
253
  }
163
254
  const local = configSchema.safeParse(data);
164
255
  if (!local.success)
165
- throw new ConfigError(`${CONFIG_PATH} is invalid: ${z.prettifyError(local.error)}`);
256
+ throw new ConfigError(`${label} is invalid: ${z.prettifyError(local.error)}`);
166
257
  if (!local.data.extends)
167
258
  return { config: local.data, rules: [] };
168
259
  // A shared configuration that cannot be loaded costs its defaults, not the
@@ -1,3 +1,3 @@
1
- import type { ReviewPreview } from "@open-cr-agent/core";
1
+ import type { ReviewPreview } from "@open-cr-agent/core/internal";
2
2
  export declare function renderPlan(preview: ReviewPreview): string;
3
3
  //# sourceMappingURL=plan-render.d.ts.map
@@ -20,7 +20,7 @@ export function renderPlan(preview) {
20
20
  lines.push("", `Review tasks: ${preview.tasks.length}`);
21
21
  const width = Math.max(0, ...preview.tasks.map((t) => t.taskId.length));
22
22
  for (const t of preview.tasks) {
23
- lines.push(` ${t.taskId.padEnd(width)} ~${number.format(t.promptTokens)} prompt tokens${t.planPromptTokens === undefined ? "" : ` + plan ~${number.format(t.planPromptTokens)}`} ${t.bundle}`);
23
+ lines.push(` ${t.taskId.padEnd(width)} ~${number.format(t.promptTokens)} prompt tokens${t.planPromptTokens === undefined ? "" : ` + plan ~${number.format(t.planPromptTokens)}`}${t.effort === undefined ? "" : ` effort ${t.effort}`} ${t.bundle}`);
24
24
  }
25
25
  if (preview.skipped.length > 0) {
26
26
  lines.push("", "Skipped by the review matrix:");
@@ -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,41 @@
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. Effort is covered; left
16
+ // unset it adds nothing, so configurations that set none keep their hash.
17
+ export function configHash(config, args) {
18
+ const { sampling: _, providers, extends: shared, effort, roles, ...rest } = config;
19
+ return stableHash({
20
+ ...rest,
21
+ ...(Object.keys(effort).length > 0 ? { effort } : {}),
22
+ ...(Object.keys(roles).length > 0 ? { roles } : {}),
23
+ ...(shared === undefined ? {} : { extends: withoutCredentials(shared) }),
24
+ providers: Object.fromEntries(Object.entries(providers).map(([id, p]) => [
25
+ id,
26
+ { ...p, baseUrl: withoutCredentials(p.baseUrl) },
27
+ ])),
28
+ flags: { reviewers: args.reviewers, ultra: args.ultra, maxCostUsd: args.maxCostUsd },
29
+ });
30
+ }
31
+ function withoutCredentials(value) {
32
+ if (!URL.canParse(value))
33
+ return value;
34
+ const url = new URL(value);
35
+ if (url.username === "" && url.password === "")
36
+ return value;
37
+ url.username = "";
38
+ url.password = "";
39
+ return url.toString();
40
+ }
41
+ //# sourceMappingURL=provenance.js.map
@@ -1,10 +1,16 @@
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";
6
7
  top: "top";
7
8
  }>, z.ZodUnknown>>;
9
+ effort: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
10
+ light: "light";
11
+ standard: "standard";
12
+ top: "top";
13
+ }> & z.core.$partial, z.ZodUnknown>>;
8
14
  concurrency: z.ZodOptional<z.ZodUnknown>;
9
15
  taskTimeoutMinutes: z.ZodOptional<z.ZodUnknown>;
10
16
  runTimeoutMinutes: z.ZodOptional<z.ZodUnknown>;
@@ -15,6 +21,7 @@ export declare const remoteConfigSchema: z.ZodObject<{
15
21
  include: z.ZodOptional<z.ZodArray<z.ZodString>>;
16
22
  exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
17
23
  reviewers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
24
+ roles: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
18
25
  github: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
19
26
  providers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
20
27
  rules: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { repoRuleSchema } from "@open-cr-agent/core";
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;
@@ -8,7 +9,9 @@ const TIMEOUT_MS = 10_000;
8
9
  // is fetched from outside the repository.
9
10
  export const remoteConfigSchema = z
10
11
  .object({
12
+ $schema: z.string().optional(),
11
13
  models: z.record(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
14
+ effort: z.partialRecord(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
12
15
  concurrency: z.unknown().optional(),
13
16
  taskTimeoutMinutes: z.unknown().optional(),
14
17
  runTimeoutMinutes: z.unknown().optional(),
@@ -19,6 +22,7 @@ export const remoteConfigSchema = z
19
22
  include: z.array(z.string()).optional(),
20
23
  exclude: z.array(z.string()).optional(),
21
24
  reviewers: z.record(z.string(), z.unknown()).optional(),
25
+ roles: z.record(z.string(), z.unknown()).optional(),
22
26
  github: z.record(z.string(), z.unknown()).optional(),
23
27
  providers: z.record(z.string(), z.unknown()).optional(),
24
28
  rules: z.array(repoRuleSchema).max(500).optional(),
@@ -29,7 +33,7 @@ export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
29
33
  const [address = "", fragment] = spec.split("#", 2);
30
34
  const url = new URL(address);
31
35
  if (url.protocol !== "https:")
32
- throw new Error(`extends must be an https URL, got ${url.protocol}`);
36
+ throw new OcraError("CONFIG_INVALID", `extends must be an https URL, got ${url.protocol}`);
33
37
  const pinned = fragment?.startsWith("sha256=")
34
38
  ? fragment.slice("sha256=".length).toLowerCase()
35
39
  : undefined;
@@ -39,21 +43,21 @@ export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
39
43
  redirect: "error",
40
44
  });
41
45
  if (!response.ok)
42
- throw new Error(`${url.href} answered ${response.status}`);
46
+ throw new OcraError("CONFIG_INVALID", `${url.href} answered ${response.status}`);
43
47
  const body = await readLimited(response, MAX_BYTES, url.href);
44
48
  if (pinned !== undefined) {
45
49
  const actual = createHash("sha256").update(body).digest("hex");
46
50
  if (actual !== pinned)
47
- throw new Error(`${url.href} does not match its pinned sha256`);
51
+ throw new OcraError("CONFIG_INVALID", `${url.href} does not match its pinned sha256`);
48
52
  }
49
53
  const parsed = remoteConfigSchema.safeParse(JSON.parse(body));
50
54
  if (!parsed.success)
51
- throw new Error(`${url.href} is invalid: ${z.prettifyError(parsed.error)}`);
55
+ throw new OcraError("CONFIG_INVALID", `${url.href} is invalid: ${z.prettifyError(parsed.error)}`);
52
56
  // A provider decides where the code under review goes: unpinned, whoever
53
57
  // serves the file could send it to an endpoint of their choosing.
54
58
  if (pinned === undefined && Object.keys(parsed.data.providers ?? {}).length > 0) {
55
59
  const digest = createHash("sha256").update(body).digest("hex");
56
- throw new Error(`${url.href} declares providers, which a shared configuration may do only when pinned; check its content, then extend "${address}#sha256=${digest}"`);
60
+ 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}"`);
57
61
  }
58
62
  return parsed.data;
59
63
  }
@@ -71,7 +75,7 @@ async function readLimited(response, limit, name) {
71
75
  size += value.byteLength;
72
76
  if (size > limit) {
73
77
  await reader.cancel();
74
- throw new Error(`${name} is larger than 256 KB`);
78
+ throw new OcraError("CONFIG_INVALID", `${name} is larger than 256 KB`);
75
79
  }
76
80
  chunks.push(value);
77
81
  }
@@ -81,7 +85,7 @@ async function readLimited(response, limit, name) {
81
85
  export function mergeConfig(remote, local) {
82
86
  const { rules: _rules, ...settings } = remote;
83
87
  const merged = { ...settings, ...local };
84
- for (const key of ["models", "reviewers", "github", "providers"]) {
88
+ for (const key of ["models", "effort", "reviewers", "roles", "github", "providers"]) {
85
89
  const a = settings[key];
86
90
  const b = local[key];
87
91
  if (a !== undefined || b !== undefined)
@@ -1,4 +1,5 @@
1
- import { type ReviewReport, serializeOutput } from "@open-cr-agent/core";
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;
@@ -1,4 +1,5 @@
1
- import { coverageGaps, serializeOutput, toReportOutput, } from "@open-cr-agent/core";
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 = {
@@ -86,8 +87,7 @@ export function renderText(report, sessionDir) {
86
87
  }
87
88
  for (const warning of report.warnings)
88
89
  lines.push(`Warning: ${warning}`);
89
- if (sessionDir)
90
- lines.push(`Session: ${sessionDir}`);
90
+ lines.push(`Run: ${report.runId}${sessionDir ? ` (${sessionDir})` : ""}`);
91
91
  return forTerminal(`${lines.join("\n")}\n`);
92
92
  }
93
93
  function emptyMessage(report, nothingReviewed, notReviewed) {
@@ -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,4 @@
1
+ import { type SarifLog } from "@open-cr-agent/core";
2
+ export declare const MAX_SARIF_BYTES: number;
3
+ export declare function loadSarifLogs(paths: readonly string[], cwd: string): Promise<SarifLog[]>;
4
+ //# sourceMappingURL=sarif-input.d.ts.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
@@ -1,4 +1,5 @@
1
- import { coverageGaps, serializeOutput, } from "@open-cr-agent/core";
1
+ import { coverageGaps, } from "@open-cr-agent/core";
2
+ import { serializeOutput } from "@open-cr-agent/core/internal";
2
3
  // `--format sarif`: the review as a SARIF 2.1.0 log, for code scanning and
3
4
  // security dashboards. One rule per reviewer category, one result per
4
5
  // finding. Findings an earlier review reported in files this run did not
@@ -26,17 +27,20 @@ export function renderSarif(report, version) {
26
27
  version: "2.1.0",
27
28
  runs: [
28
29
  {
30
+ automationDetails: { id: `ocra/${report.runId}` },
29
31
  tool: {
30
32
  driver: {
31
33
  name: "ocra",
32
- informationUri: "https://ocra.majincheng.com",
34
+ informationUri: "https://ocracloud.com",
33
35
  version,
34
36
  semanticVersion: version,
35
37
  rules: categories.map((id) => ({
36
38
  id,
37
39
  name: id,
38
40
  shortDescription: {
39
- text: id === CARRIED ? CARRIED_DESCRIPTION : `Findings of ocra's ${id} reviewer`,
41
+ text: id === CARRIED
42
+ ? CARRIED_DESCRIPTION
43
+ : `Findings of the ${id} reviewer or analyzer`,
40
44
  },
41
45
  properties: { tags: [id] },
42
46
  })),
@@ -100,6 +104,8 @@ function fromFinding(f, index) {
100
104
  verification: f.verification ?? "unchecked",
101
105
  status: f.status === "unfixed" ? "unfixed" : "new",
102
106
  ...(f.lowConfidence ? { lowConfidence: true } : {}),
107
+ task: f.provenance.task,
108
+ ...(f.provenance.model === undefined ? {} : { model: f.provenance.model }),
103
109
  },
104
110
  };
105
111
  }
@@ -1,4 +1,4 @@
1
- import type { OcraPlugin, PluginRegistry, VcsAdapter } from "@open-cr-agent/core";
1
+ import { type OcraPlugin, type PluginRegistry, type VcsAdapter } from "@open-cr-agent/core";
2
2
  import type { MergeRequestTarget, PullRequestTarget, ReviewArgs } from "./args.js";
3
3
  import { type CliConfig } from "./config.js";
4
4
  export interface ReviewTarget {
@@ -11,7 +11,7 @@ export interface ReviewTarget {
11
11
  }
12
12
  type Env = Readonly<Record<string, string | undefined>>;
13
13
  export declare function localTarget(args: ReviewArgs, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch): Promise<ReviewTarget>;
14
- export declare function pullRequestTarget(pr: PullRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean): Promise<ReviewTarget>;
15
- export declare function mergeRequestTarget(mr: MergeRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean): Promise<ReviewTarget>;
14
+ export declare function pullRequestTarget(pr: PullRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean, configFile?: string): Promise<ReviewTarget>;
15
+ export declare function mergeRequestTarget(mr: MergeRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean, configFile?: string): Promise<ReviewTarget>;
16
16
  export {};
17
17
  //# sourceMappingURL=target.d.ts.map
@@ -1,17 +1,21 @@
1
- import { DEFAULT_BOT_LOGIN, GitHubApi } from "@open-cr-agent/vcs-github";
2
- import { GitLabApi } from "@open-cr-agent/vcs-gitlab";
3
- import { ensureCommits, filesChangedSince, LocalGitAdapter } from "@open-cr-agent/vcs-local";
1
+ import { dirname } from "node:path";
2
+ import { OcraError, } from "@open-cr-agent/core";
3
+ import { DEFAULT_BOT_LOGIN, GitHubApi } from "@open-cr-agent/vcs-github/internal";
4
+ import { GitLabApi } from "@open-cr-agent/vcs-gitlab/internal";
5
+ import { ensureCommits, filesChangedSince, LocalGitAdapter, } from "@open-cr-agent/vcs-local/internal";
4
6
  import { ConfigError, loadConfig } from "./config.js";
5
7
  import { loadExternalPlugins } from "./plugins.js";
6
8
  export async function localTarget(args, cwd, root, env, warn, fetchImpl) {
7
9
  const config = await loadConfig(root, env, {
8
10
  repository: !args.ignoreRepoConfig,
11
+ ...(args.configFile ? { file: args.configFile } : {}),
9
12
  warn,
10
13
  ...(fetchImpl ? { fetch: fetchImpl } : {}),
11
14
  });
12
15
  return {
13
16
  config,
14
- plugins: await loadExternalPlugins(config.plugins, root),
17
+ // Plugins named by the user's own file are resolved from where it is.
18
+ plugins: await loadExternalPlugins(config.plugins, args.configFile ? dirname(args.configFile) : root),
15
19
  createVcs: (registry) => registry.createVcs("local", { cwd, target: args.target }),
16
20
  ...(args.ignoreRepoConfig ? { readTrusted: untrustedTreeReader(args, cwd) } : {}),
17
21
  publish: false,
@@ -29,10 +33,11 @@ function untrustedTreeReader(args, cwd) {
29
33
  }
30
34
  // The pull request's own files are untrusted: configuration, guidelines and
31
35
  // rules come from its base commit, and repository plugins never load.
32
- export async function pullRequestTarget(pr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false) {
36
+ export async function pullRequestTarget(pr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false, configFile) {
33
37
  const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
34
- if (!token)
35
- throw new ConfigError("--pr needs a GitHub token in GITHUB_TOKEN or GH_TOKEN");
38
+ if (!token) {
39
+ throw new OcraError("CONFIG_CREDENTIALS_MISSING", "--pr needs a GitHub token in GITHUB_TOKEN or GH_TOKEN");
40
+ }
36
41
  const [owner = "", repo = ""] = (pr.repo ?? (await repositoryName(root, env))).split("/");
37
42
  const apiOptions = { token, ...(env.GITHUB_API_URL ? { baseUrl: env.GITHUB_API_URL } : {}) };
38
43
  const api = new GitHubApi({ owner, repo }, fetchImpl ? { ...apiOptions, fetch: fetchImpl } : apiOptions);
@@ -43,11 +48,12 @@ export async function pullRequestTarget(pr, cwd, root, env, warn, fetchImpl, ign
43
48
  const config = await loadConfig(root, env, {
44
49
  repository: !ignoreRepoConfig,
45
50
  read: readTrusted,
51
+ ...(configFile ? { file: configFile } : {}),
46
52
  warn,
47
53
  ...(fetchImpl ? { fetch: fetchImpl } : {}),
48
54
  });
49
55
  if (config.plugins.length > 0) {
50
- warn("plugins in .ocra/config.json are not loaded for pull requests");
56
+ warn("plugins in the configuration are not loaded for pull requests");
51
57
  }
52
58
  const code = new LocalGitAdapter({
53
59
  cwd,
@@ -81,10 +87,10 @@ const GITLAB_API = "https://gitlab.com/api/v4";
81
87
  // target branch the merge request was based on, and repository plugins never
82
88
  // load. GitLab CI sets CI_API_V4_URL and CI_PROJECT_ID; GITLAB_TOKEN must be
83
89
  // a token that may write notes, which CI_JOB_TOKEN may not.
84
- export async function mergeRequestTarget(mr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false) {
90
+ export async function mergeRequestTarget(mr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false, configFile) {
85
91
  const token = env.GITLAB_TOKEN;
86
92
  if (!token) {
87
- throw new ConfigError("--mr needs a GitLab token in GITLAB_TOKEN: a project access token with the api scope and the Developer role (CI_JOB_TOKEN cannot post comments)");
93
+ throw new OcraError("CONFIG_CREDENTIALS_MISSING", "--mr needs a GitLab token in GITLAB_TOKEN with the api scope and the Developer role: a project access token, or on GitLab.com Free a personal access token of a dedicated account (CI_JOB_TOKEN cannot post comments)");
88
94
  }
89
95
  const origin = await originUrl(root);
90
96
  const project = mr.project ?? env.CI_PROJECT_ID ?? gitlabProject(origin);
@@ -96,19 +102,21 @@ export async function mergeRequestTarget(mr, cwd, root, env, warn, fetchImpl, ig
96
102
  });
97
103
  const merge = await api.getMergeRequest(mr.iid);
98
104
  const refs = merge.diff_refs;
99
- if (!refs)
100
- throw new ConfigError(`merge request !${mr.iid} has no diff yet; try again shortly`);
105
+ if (!refs) {
106
+ throw new OcraError("VCS_NOT_READY", `merge request !${mr.iid} has no diff yet; try again shortly`);
107
+ }
101
108
  await ensureCommits(root, [refs.start_sha, refs.head_sha], [`refs/merge-requests/${mr.iid}/head`]);
102
109
  const base = new LocalGitAdapter({ cwd, target: { mode: "commit", commit: refs.start_sha } });
103
110
  const readTrusted = (path) => base.readFile(path);
104
111
  const config = await loadConfig(root, env, {
105
112
  repository: !ignoreRepoConfig,
106
113
  read: readTrusted,
114
+ ...(configFile ? { file: configFile } : {}),
107
115
  warn,
108
116
  ...(fetchImpl ? { fetch: fetchImpl } : {}),
109
117
  });
110
118
  if (config.plugins.length > 0) {
111
- warn("plugins in .ocra/config.json are not loaded for merge requests");
119
+ warn("plugins in the configuration are not loaded for merge requests");
112
120
  }
113
121
  const code = new LocalGitAdapter({
114
122
  cwd,
@@ -1,4 +1,4 @@
1
- import { isUnsafeCodePoint } from "@open-cr-agent/core";
1
+ import { isUnsafeCodePoint } from "@open-cr-agent/core/internal";
2
2
  // Findings, commit messages and errors can carry text a diff planted; none of
3
3
  // it may drive the user's terminal. C0 and C1 controls (ANSI and OSC escapes
4
4
  // start with ESC or a C1 code) except tab and newline, carriage returns, and