jev-affected 0.1.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 (47) hide show
  1. package/.env.example +3 -0
  2. package/CHANGELOG.md +8 -0
  3. package/CODE_OF_CONDUCT.md +7 -0
  4. package/CONTRIBUTING.md +16 -0
  5. package/EVALUATION.md +77 -0
  6. package/LICENSE +21 -0
  7. package/NOTICE +6 -0
  8. package/README.md +201 -0
  9. package/SECURITY.md +21 -0
  10. package/assets/README.md +5 -0
  11. package/assets/demo.gif +0 -0
  12. package/assets/demo.mp4 +0 -0
  13. package/assets/favicon.svg +1 -0
  14. package/assets/logo-dark.svg +1 -0
  15. package/assets/logo-mark.svg +1 -0
  16. package/assets/logo-wordmark.svg +1 -0
  17. package/assets/logo.svg +1 -0
  18. package/assets/social-card.png +0 -0
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.js +214 -0
  21. package/dist/config.d.ts +41 -0
  22. package/dist/config.js +97 -0
  23. package/dist/eval.d.ts +41 -0
  24. package/dist/eval.js +96 -0
  25. package/dist/executor.d.ts +6 -0
  26. package/dist/executor.js +68 -0
  27. package/dist/git.d.ts +21 -0
  28. package/dist/git.js +102 -0
  29. package/dist/index.d.ts +5 -0
  30. package/dist/index.js +5 -0
  31. package/dist/planner.d.ts +37 -0
  32. package/dist/planner.js +132 -0
  33. package/dist/provider.d.ts +21 -0
  34. package/dist/provider.js +28 -0
  35. package/evals/fixtures/api-response-field.json +53 -0
  36. package/evals/fixtures/auth-session-ttl.json +53 -0
  37. package/evals/fixtures/comment-only.json +53 -0
  38. package/evals/fixtures/db-schema.json +53 -0
  39. package/evals/fixtures/docs-only.json +53 -0
  40. package/evals/fixtures/logging-only.json +53 -0
  41. package/evals/fixtures/performance.json +53 -0
  42. package/examples/basic/jev-affected.yml +16 -0
  43. package/package.json +84 -0
  44. package/skills/jev-affected/SKILL.md +51 -0
  45. package/skills/jev-affected/agents/openai.yaml +7 -0
  46. package/skills/jev-affected/assets/icon-large.svg +4 -0
  47. package/skills/jev-affected/assets/icon-small.svg +4 -0
@@ -0,0 +1,41 @@
1
+ import { z } from "zod";
2
+ export declare class ConfigError extends Error {
3
+ }
4
+ export declare const secretPatterns: string[];
5
+ export declare const configSchema: z.ZodObject<{
6
+ version: z.ZodLiteral<1>;
7
+ model: z.ZodDefault<z.ZodString>;
8
+ base: z.ZodOptional<z.ZodString>;
9
+ policy: z.ZodDefault<z.ZodObject<{
10
+ uncertain: z.ZodDefault<z.ZodLiteral<"run">>;
11
+ onError: z.ZodDefault<z.ZodLiteral<"run">>;
12
+ }, z.core.$strict>>;
13
+ defaults: z.ZodDefault<z.ZodObject<{
14
+ skipBelow: z.ZodOptional<z.ZodNumber>;
15
+ threshold: z.ZodOptional<z.ZodNumber>;
16
+ }, z.core.$strict>>;
17
+ ignore: z.ZodDefault<z.ZodArray<z.ZodString>>;
18
+ analysis: z.ZodDefault<z.ZodObject<{
19
+ maxDiffBytes: z.ZodDefault<z.ZodNumber>;
20
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
21
+ exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
22
+ }, z.core.$strict>>;
23
+ execution: z.ZodDefault<z.ZodObject<{
24
+ parallel: z.ZodDefault<z.ZodBoolean>;
25
+ concurrency: z.ZodDefault<z.ZodNumber>;
26
+ }, z.core.$strict>>;
27
+ tasks: z.ZodRecord<z.ZodString, z.ZodObject<{
28
+ command: z.ZodString;
29
+ when: z.ZodOptional<z.ZodString>;
30
+ always: z.ZodDefault<z.ZodBoolean>;
31
+ allowSkip: z.ZodDefault<z.ZodBoolean>;
32
+ include: z.ZodOptional<z.ZodArray<z.ZodString>>;
33
+ ignore: z.ZodDefault<z.ZodArray<z.ZodString>>;
34
+ skipBelow: z.ZodOptional<z.ZodNumber>;
35
+ threshold: z.ZodOptional<z.ZodNumber>;
36
+ }, z.core.$strict>>;
37
+ }, z.core.$strict>;
38
+ export type Config = z.infer<typeof configSchema>;
39
+ export declare function parseConfig(value: unknown): Config;
40
+ export declare function loadConfig(path?: string): Promise<Config>;
41
+ export declare const template = "version: 1\nmodel: jev-latest\ndefaults:\n skipBelow: 0.10\ntasks:\n unit:\n command: npm test\n when: Could this change alter runtime application behavior?\n typecheck:\n command: npm run typecheck\n always: true\n";
package/dist/config.js ADDED
@@ -0,0 +1,97 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parse } from "yaml";
3
+ import { z } from "zod";
4
+ export class ConfigError extends Error {
5
+ }
6
+ const probability = z.number().min(0).max(1);
7
+ const limits = z
8
+ .object({
9
+ skipBelow: probability.optional(),
10
+ threshold: probability.optional(),
11
+ })
12
+ .strict()
13
+ .refine((x) => x.skipBelow === undefined || x.threshold === undefined, "Use skipBelow or threshold, not both");
14
+ const patterns = z.array(z.string().min(1));
15
+ export const secretPatterns = [
16
+ "**/.env*",
17
+ "**/*.pem",
18
+ "**/*.key",
19
+ "**/*credentials*",
20
+ "**/*secret*",
21
+ "**/*token*",
22
+ ];
23
+ export const configSchema = z
24
+ .object({
25
+ version: z.literal(1),
26
+ model: z.string().min(1).default("jev-latest"),
27
+ base: z.string().min(1).optional(),
28
+ policy: z
29
+ .object({
30
+ uncertain: z.literal("run").default("run"),
31
+ onError: z.literal("run").default("run"),
32
+ })
33
+ .strict()
34
+ .default({ uncertain: "run", onError: "run" }),
35
+ defaults: limits.default({ skipBelow: 0.1 }),
36
+ ignore: patterns.default([]),
37
+ analysis: z
38
+ .object({
39
+ maxDiffBytes: z.number().int().positive().default(100000),
40
+ timeoutMs: z.number().int().positive().default(10000),
41
+ exclude: patterns.default([]),
42
+ })
43
+ .strict()
44
+ .default({ maxDiffBytes: 100000, timeoutMs: 10000, exclude: [] }),
45
+ execution: z
46
+ .object({
47
+ parallel: z.boolean().default(false),
48
+ concurrency: z.number().int().min(1).max(64).default(4),
49
+ })
50
+ .strict()
51
+ .default({ parallel: false, concurrency: 4 }),
52
+ tasks: z
53
+ .record(z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), z
54
+ .object({
55
+ command: z.string().trim().min(1),
56
+ when: z.string().trim().min(1).optional(),
57
+ always: z.boolean().default(false),
58
+ allowSkip: z.boolean().default(true),
59
+ include: patterns.optional(),
60
+ ignore: patterns.default([]),
61
+ skipBelow: probability.optional(),
62
+ threshold: probability.optional(),
63
+ })
64
+ .strict()
65
+ .refine((t) => t.always || !t.allowSkip || !!t.when, "when is required for skippable tasks")
66
+ .refine((t) => t.skipBelow === undefined || t.threshold === undefined, "Use skipBelow or threshold, not both"))
67
+ .refine((t) => Object.keys(t).length > 0, "At least one task is required"),
68
+ })
69
+ .strict();
70
+ export function parseConfig(value) {
71
+ const r = configSchema.safeParse(value);
72
+ if (!r.success)
73
+ throw new ConfigError(r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n"));
74
+ return r.data;
75
+ }
76
+ export async function loadConfig(path = "jev-affected.yml") {
77
+ try {
78
+ return parseConfig(parse(await readFile(path, "utf8")));
79
+ }
80
+ catch (e) {
81
+ if (e instanceof ConfigError)
82
+ throw e;
83
+ throw new ConfigError("Cannot read or parse YAML configuration.");
84
+ }
85
+ }
86
+ export const template = `version: 1
87
+ model: jev-latest
88
+ defaults:
89
+ skipBelow: 0.10
90
+ tasks:
91
+ unit:
92
+ command: npm test
93
+ when: Could this change alter runtime application behavior?
94
+ typecheck:
95
+ command: npm run typecheck
96
+ always: true
97
+ `;
package/dist/eval.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ export declare function evaluate(directory: string, live?: boolean): Promise<{
2
+ mode: string;
3
+ cases: {
4
+ name: any;
5
+ falseSkips: number;
6
+ model: string | null;
7
+ usage: {
8
+ input_tokens: number;
9
+ output_tokens: number;
10
+ } | undefined;
11
+ selected: number;
12
+ tasks: {
13
+ id: string;
14
+ expected: any;
15
+ decision: "run" | "skip";
16
+ probability: number | null;
17
+ threshold: number;
18
+ reason: string;
19
+ }[];
20
+ }[];
21
+ falseSkips: number;
22
+ invalidCases: number;
23
+ reducedCases: number;
24
+ passed: boolean;
25
+ falseSkipRate: number;
26
+ unnecessaryRunRate: number;
27
+ taskReduction: number;
28
+ apiCalls: number;
29
+ latencyMs: number;
30
+ usage: {
31
+ inputTokens: number;
32
+ outputTokens: number;
33
+ };
34
+ gate: {
35
+ maxFalseSkips: number;
36
+ maxInvalidCases: number;
37
+ minReducedCases: number;
38
+ minTaskReduction: number;
39
+ };
40
+ analysisCost: null;
41
+ }>;
package/dist/eval.js ADDED
@@ -0,0 +1,96 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { ConfigError, parseConfig } from "./config.js";
4
+ import { createPlan } from "./planner.js";
5
+ import { JevProvider } from "./provider.js";
6
+ export async function evaluate(directory, live = false) {
7
+ let required = 0, unneeded = 0, falseSkips = 0, unnecessaryRuns = 0, skipped = 0, total = 0, apiCalls = 0, latencyMs = 0, invalidCases = 0, reducedCases = 0, inputTokens = 0, outputTokens = 0;
8
+ const cases = [];
9
+ for (const file of (await readdir(directory))
10
+ .filter((f) => f.endsWith(".json"))
11
+ .sort()) {
12
+ const fixture = JSON.parse(await readFile(resolve(directory, file), "utf8"));
13
+ const config = parseConfig(fixture.config);
14
+ if (!fixture.expected ||
15
+ Object.keys(fixture.expected).length !== Object.keys(config.tasks).length)
16
+ throw new ConfigError("Each evaluation task needs an expected decision.");
17
+ const plan = await createPlan({
18
+ config,
19
+ state: fixture.state,
20
+ cache: false,
21
+ provider: live
22
+ ? new JevProvider()
23
+ : { analyze: async () => fixture.response },
24
+ });
25
+ let misses = 0;
26
+ for (const t of plan.tasks) {
27
+ const expected = fixture.expected[t.id];
28
+ if (expected !== "run" && expected !== "skip")
29
+ throw new ConfigError("Invalid evaluation expectation");
30
+ total++;
31
+ if (t.decision === "skip")
32
+ skipped++;
33
+ if (expected === "run") {
34
+ required++;
35
+ if (t.decision === "skip") {
36
+ falseSkips++;
37
+ misses++;
38
+ }
39
+ }
40
+ else {
41
+ unneeded++;
42
+ if (t.decision === "run")
43
+ unnecessaryRuns++;
44
+ }
45
+ }
46
+ apiCalls += live ? plan.metrics.apiCalls : 0;
47
+ inputTokens += plan.metrics.usage?.input_tokens ?? 0;
48
+ outputTokens += plan.metrics.usage?.output_tokens ?? 0;
49
+ if (plan.tasks.some((t) => ["provider-fallback", "invalid-answer", "incomplete-state"].includes(t.reason)))
50
+ invalidCases++;
51
+ if (plan.tasks.some((t) => t.decision === "skip"))
52
+ reducedCases++;
53
+ latencyMs += plan.metrics.latencyMs;
54
+ cases.push({
55
+ name: fixture.name,
56
+ falseSkips: misses,
57
+ model: plan.model,
58
+ usage: plan.metrics.usage,
59
+ selected: plan.tasks.filter((t) => t.decision === "run").length,
60
+ tasks: plan.tasks.map((t) => ({
61
+ id: t.id,
62
+ expected: fixture.expected[t.id],
63
+ decision: t.decision,
64
+ probability: t.probability,
65
+ threshold: t.threshold,
66
+ reason: t.reason,
67
+ })),
68
+ });
69
+ }
70
+ if (!cases.length)
71
+ throw new ConfigError("No evaluation fixtures found.");
72
+ return {
73
+ mode: live ? "live" : "synthetic-offline",
74
+ cases,
75
+ falseSkips,
76
+ invalidCases,
77
+ reducedCases,
78
+ passed: falseSkips === 0 &&
79
+ invalidCases === 0 &&
80
+ reducedCases >= 2 &&
81
+ (total ? skipped / total : 0) >= 0.25,
82
+ falseSkipRate: required ? falseSkips / required : 0,
83
+ unnecessaryRunRate: unneeded ? unnecessaryRuns / unneeded : 0,
84
+ taskReduction: total ? skipped / total : 0,
85
+ apiCalls,
86
+ latencyMs,
87
+ usage: { inputTokens, outputTokens },
88
+ gate: {
89
+ maxFalseSkips: 0,
90
+ maxInvalidCases: 0,
91
+ minReducedCases: 2,
92
+ minTaskReduction: 0.25,
93
+ },
94
+ analysisCost: null,
95
+ };
96
+ }
@@ -0,0 +1,6 @@
1
+ import type { Plan } from "./planner.js";
2
+ export declare function executePlan(plan: Plan, options?: {
3
+ cwd?: string;
4
+ concurrency?: number;
5
+ json?: boolean;
6
+ }): Promise<number>;
@@ -0,0 +1,68 @@
1
+ import { spawn } from "node:child_process";
2
+ export async function executePlan(plan, options = {}) {
3
+ const tasks = plan.tasks.filter((t) => t.decision === "run");
4
+ const concurrency = options.concurrency ?? 1;
5
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 64)
6
+ throw new RangeError("concurrency must be an integer from 1 to 64");
7
+ const children = new Set();
8
+ let interrupted = false;
9
+ const stop = () => {
10
+ interrupted = true;
11
+ for (const child of children) {
12
+ if (!child.pid)
13
+ continue;
14
+ if (process.platform === "win32") {
15
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true });
16
+ killer.on("error", () => child.kill());
17
+ }
18
+ else {
19
+ try {
20
+ process.kill(-child.pid, "SIGTERM");
21
+ }
22
+ catch {
23
+ child.kill();
24
+ }
25
+ }
26
+ }
27
+ };
28
+ process.on("SIGINT", stop);
29
+ process.on("SIGTERM", stop);
30
+ let next = 0, failed = false;
31
+ const worker = async () => {
32
+ while (!interrupted && next < tasks.length) {
33
+ const task = tasks[next++];
34
+ if (!task)
35
+ break;
36
+ const code = await new Promise((resolve) => {
37
+ const child = spawn(task.command, {
38
+ cwd: options.cwd,
39
+ shell: true,
40
+ detached: process.platform !== "win32",
41
+ windowsHide: true,
42
+ stdio: options.json
43
+ ? ["inherit", process.stderr, process.stderr]
44
+ : "inherit",
45
+ });
46
+ children.add(child);
47
+ child.on("error", () => {
48
+ children.delete(child);
49
+ resolve(1);
50
+ });
51
+ child.on("close", (code) => {
52
+ children.delete(child);
53
+ resolve(code ?? 1);
54
+ });
55
+ });
56
+ if (code !== 0)
57
+ failed = true;
58
+ }
59
+ };
60
+ try {
61
+ await Promise.all(Array.from({ length: Math.min(tasks.length, concurrency) }, worker));
62
+ }
63
+ finally {
64
+ process.off("SIGINT", stop);
65
+ process.off("SIGTERM", stop);
66
+ }
67
+ return failed || interrupted ? 1 : 0;
68
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { type Config } from "./config.js";
2
+ export declare function git(cwd: string, ...args: string[]): Promise<string>;
3
+ export declare const matches: (path: string, patterns: string[]) => boolean;
4
+ export interface ChangedFile {
5
+ path: string;
6
+ oldPath?: string;
7
+ status: string;
8
+ }
9
+ export interface ChangeState {
10
+ base: string;
11
+ head: string;
12
+ files: ChangedFile[];
13
+ diff: string;
14
+ incomplete: boolean;
15
+ warnings: string[];
16
+ }
17
+ export declare function collectChanges(config: Config, options?: {
18
+ cwd?: string;
19
+ base?: string;
20
+ head?: string;
21
+ }): Promise<ChangeState>;
package/dist/git.js ADDED
@@ -0,0 +1,102 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import picomatch from "picomatch";
4
+ import { ConfigError, secretPatterns } from "./config.js";
5
+ const exec = promisify(execFile);
6
+ export async function git(cwd, ...args) {
7
+ return (await exec("git", args, {
8
+ cwd,
9
+ encoding: "utf8",
10
+ maxBuffer: 32 * 1024 * 1024,
11
+ env: { ...process.env, GIT_LITERAL_PATHSPECS: "1" },
12
+ })).stdout;
13
+ }
14
+ export const matches = (path, patterns) => patterns.length > 0 && picomatch(patterns, { dot: true })(path);
15
+ export async function collectChanges(config, options = {}) {
16
+ const cwd = options.cwd ?? process.cwd();
17
+ const resolve = async (ref) => (await git(cwd, "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`)).trim();
18
+ let head;
19
+ try {
20
+ head = await resolve(options.head ?? "HEAD");
21
+ }
22
+ catch {
23
+ throw new ConfigError("Cannot resolve head commit.");
24
+ }
25
+ const explicit = options.base ?? config.base;
26
+ const candidates = explicit
27
+ ? [explicit]
28
+ : [
29
+ process.env.GITHUB_BASE_REF
30
+ ? `origin/${process.env.GITHUB_BASE_REF}`
31
+ : undefined,
32
+ "origin/main",
33
+ "main",
34
+ "master",
35
+ ].filter((x) => !!x);
36
+ let base;
37
+ for (const ref of candidates) {
38
+ try {
39
+ base = (await git(cwd, "merge-base", await resolve(ref), head)).trim();
40
+ break;
41
+ }
42
+ catch { }
43
+ }
44
+ if (!base)
45
+ throw new ConfigError("Cannot resolve Git base. Fetch the base branch or pass --base.");
46
+ const parts = (await git(cwd, "diff", "--name-status", "-z", "--find-renames", base, head, "--")).split("\0");
47
+ const files = [];
48
+ for (let i = 0; i < parts.length && parts[i];) {
49
+ const status = parts[i++];
50
+ const path = parts[i++];
51
+ if (!status || !path)
52
+ throw new Error("Malformed Git file listing");
53
+ if (status.startsWith("R") || status.startsWith("C")) {
54
+ const target = parts[i++];
55
+ if (!target)
56
+ throw new Error("Malformed Git rename listing");
57
+ files.push({ status, oldPath: path, path: target });
58
+ }
59
+ else
60
+ files.push({ status, path });
61
+ }
62
+ const state = {
63
+ base,
64
+ head,
65
+ files: [],
66
+ diff: "",
67
+ incomplete: false,
68
+ warnings: [],
69
+ };
70
+ for (const file of files) {
71
+ const paths = [file.path, ...(file.oldPath ? [file.oldPath] : [])];
72
+ if (paths.some((p) => matches(p, [...secretPatterns, ...config.analysis.exclude]))) {
73
+ state.incomplete = true;
74
+ state.warnings.push("Excluded sensitive file change; running all tasks.");
75
+ continue;
76
+ }
77
+ if (paths.every((p) => matches(p, config.ignore)))
78
+ continue;
79
+ state.files.push(file);
80
+ try {
81
+ const patch = await git(cwd, "diff", "--no-ext-diff", "--no-textconv", "--find-renames", "--unified=3", base, head, "--", ...paths);
82
+ if (patch.includes("Binary files ") ||
83
+ patch.includes("Subproject commit ")) {
84
+ state.incomplete = true;
85
+ state.warnings.push("Binary or submodule change; running all tasks.");
86
+ }
87
+ if (Buffer.byteLength(state.diff) + Buffer.byteLength(patch) >
88
+ config.analysis.maxDiffBytes) {
89
+ state.incomplete = true;
90
+ state.warnings.push("Diff exceeds analysis limit; running all tasks.");
91
+ }
92
+ else
93
+ state.diff += patch;
94
+ }
95
+ catch {
96
+ state.incomplete = true;
97
+ state.warnings.push("Diff could not be read; running all tasks.");
98
+ }
99
+ }
100
+ state.warnings = [...new Set(state.warnings)];
101
+ return state;
102
+ }
@@ -0,0 +1,5 @@
1
+ export { type Config, ConfigError, loadConfig, parseConfig } from "./config.js";
2
+ export { executePlan } from "./executor.js";
3
+ export { type ChangeState, collectChanges } from "./git.js";
4
+ export { createPlan, type Plan, type TaskDecision } from "./planner.js";
5
+ export { type AnalysisInput, type AnalysisResult, type DecisionProvider, JevProvider, } from "./provider.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { ConfigError, loadConfig, parseConfig } from "./config.js";
2
+ export { executePlan } from "./executor.js";
3
+ export { collectChanges } from "./git.js";
4
+ export { createPlan } from "./planner.js";
5
+ export { JevProvider, } from "./provider.js";
@@ -0,0 +1,37 @@
1
+ import { type Config } from "./config.js";
2
+ import { type ChangeState } from "./git.js";
3
+ import { type AnalysisResult, type DecisionProvider } from "./provider.js";
4
+ export interface TaskDecision {
5
+ id: string;
6
+ command: string;
7
+ condition?: string;
8
+ decision: "run" | "skip";
9
+ probability: number | null;
10
+ threshold: number;
11
+ reason: string;
12
+ files: string[];
13
+ }
14
+ export interface Plan {
15
+ version: 1;
16
+ base: string;
17
+ head: string;
18
+ model: string | null;
19
+ requestedModel: string;
20
+ tasks: TaskDecision[];
21
+ warnings: string[];
22
+ metrics: {
23
+ apiCalls: number;
24
+ cacheHits: number;
25
+ latencyMs: number;
26
+ usage?: AnalysisResult["usage"];
27
+ };
28
+ }
29
+ export declare function createPlan(input: {
30
+ config: Config;
31
+ cwd?: string;
32
+ base?: string;
33
+ head?: string;
34
+ state?: ChangeState;
35
+ provider?: DecisionProvider;
36
+ cache?: boolean;
37
+ }): Promise<Plan>;
@@ -0,0 +1,132 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { parseConfig, secretPatterns } from "./config.js";
5
+ import { collectChanges, git, matches } from "./git.js";
6
+ import { JevProvider, } from "./provider.js";
7
+ export async function createPlan(input) {
8
+ const start = Date.now(), config = parseConfig(input.config), cwd = input.cwd ?? process.cwd();
9
+ const state = structuredClone(input.state ?? (await collectChanges(config, input)));
10
+ if (Buffer.byteLength(state.diff) > config.analysis.maxDiffBytes ||
11
+ state.files.some((f) => [f.path, ...(f.oldPath ? [f.oldPath] : [])].some((p) => matches(p, [...secretPatterns, ...config.analysis.exclude])))) {
12
+ state.incomplete = true;
13
+ state.diff = "";
14
+ state.warnings.push("Supplied state exceeds analysis limits or includes excluded paths; running all tasks.");
15
+ }
16
+ const plan = {
17
+ version: 1,
18
+ base: state.base,
19
+ head: state.head,
20
+ model: null,
21
+ requestedModel: config.model,
22
+ tasks: [],
23
+ warnings: [...state.warnings],
24
+ metrics: { apiCalls: 0, cacheHits: 0, latencyMs: 0 },
25
+ };
26
+ const questions = Object.create(null);
27
+ for (const [id, t] of Object.entries(config.tasks)) {
28
+ const files = state.files
29
+ .filter((f) => [f.path, ...(f.oldPath ? [f.oldPath] : [])].some((p) => (!t.include || matches(p, t.include)) && !matches(p, t.ignore)))
30
+ .map((f) => f.path);
31
+ const task = {
32
+ id,
33
+ command: t.command,
34
+ condition: t.when,
35
+ decision: "run",
36
+ probability: null,
37
+ threshold: t.skipBelow ??
38
+ t.threshold ??
39
+ config.defaults.skipBelow ??
40
+ config.defaults.threshold ??
41
+ 0.1,
42
+ reason: "semantic",
43
+ files,
44
+ };
45
+ if (t.always || !t.allowSkip)
46
+ task.reason = "protected";
47
+ else if (state.incomplete)
48
+ task.reason = "incomplete-state";
49
+ else if (!files.length) {
50
+ task.decision = "skip";
51
+ task.reason = "no-matching-changes";
52
+ }
53
+ else if (t.when)
54
+ questions[id] = t.when;
55
+ plan.tasks.push(task);
56
+ }
57
+ if (Object.keys(questions).length) {
58
+ let result;
59
+ let cachePath;
60
+ const pinned = /^jev-\d+\.\d+\.\d+$/.test(config.model);
61
+ if (input.cache !== false && !input.provider && pinned) {
62
+ try {
63
+ const dir = resolve(cwd, (await git(cwd, "rev-parse", "--git-path", "jev-affected/cache")).trim());
64
+ const key = createHash("sha256")
65
+ .update(JSON.stringify({
66
+ version: 1,
67
+ model: config.model,
68
+ config,
69
+ questions,
70
+ state,
71
+ }))
72
+ .digest("hex");
73
+ cachePath = resolve(dir, `${key}.json`);
74
+ const cached = JSON.parse(await readFile(cachePath, "utf8"));
75
+ if (cached.model === config.model) {
76
+ result = cached;
77
+ plan.metrics.cacheHits = 1;
78
+ }
79
+ }
80
+ catch { }
81
+ }
82
+ try {
83
+ if (!result) {
84
+ plan.metrics.apiCalls = 1;
85
+ result = await (input.provider ?? new JevProvider()).analyze({
86
+ state,
87
+ questions,
88
+ model: config.model,
89
+ timeoutMs: config.analysis.timeoutMs,
90
+ });
91
+ }
92
+ if (!result.model || !/^jev-\d+\.\d+\.\d+$/.test(result.model))
93
+ throw new Error("Missing actual model version");
94
+ plan.model = result.model;
95
+ plan.metrics.usage = result.usage;
96
+ for (const task of plan.tasks) {
97
+ if (!(task.id in questions))
98
+ continue;
99
+ const p = result.probabilities[task.id];
100
+ if (typeof p !== "number" || !Number.isFinite(p) || p < 0 || p > 1) {
101
+ task.reason = "invalid-answer";
102
+ continue;
103
+ }
104
+ task.probability = p;
105
+ task.decision = p < task.threshold ? "skip" : "run";
106
+ }
107
+ if (cachePath &&
108
+ result.model === config.model &&
109
+ plan.tasks.every((t) => t.reason !== "invalid-answer")) {
110
+ try {
111
+ await mkdir(resolve(cachePath, ".."), { recursive: true });
112
+ await writeFile(cachePath, JSON.stringify(result), { mode: 0o600 });
113
+ }
114
+ catch {
115
+ plan.warnings.push("Cache could not be written.");
116
+ }
117
+ }
118
+ }
119
+ catch {
120
+ for (const t of plan.tasks) {
121
+ if (t.id in questions) {
122
+ t.decision = "run";
123
+ t.probability = null;
124
+ t.reason = "provider-fallback";
125
+ }
126
+ }
127
+ plan.warnings.push("Jev unavailable or response invalid; running candidate tasks.");
128
+ }
129
+ }
130
+ plan.metrics.latencyMs = Date.now() - start;
131
+ return plan;
132
+ }
@@ -0,0 +1,21 @@
1
+ import type { ChangeState } from "./git.js";
2
+ export interface AnalysisInput {
3
+ state: ChangeState;
4
+ questions: Record<string, string>;
5
+ model: string;
6
+ timeoutMs: number;
7
+ }
8
+ export interface AnalysisResult {
9
+ model: string;
10
+ probabilities: Record<string, number>;
11
+ usage?: {
12
+ input_tokens: number;
13
+ output_tokens: number;
14
+ };
15
+ }
16
+ export interface DecisionProvider {
17
+ analyze(input: AnalysisInput): Promise<AnalysisResult>;
18
+ }
19
+ export declare class JevProvider implements DecisionProvider {
20
+ analyze(input: AnalysisInput): Promise<AnalysisResult>;
21
+ }