lastlight-evals 0.7.30 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -1
- package/datasets/dependency-merge/README.md +69 -0
- package/datasets/dependency-merge/instances.json +168 -0
- package/datasets/dependency-merge/tier.json +5 -0
- package/datasets/fix/README.md +57 -0
- package/datasets/fix/instances.json +229 -0
- package/datasets/fix/repos/fix__env-mismatch-node-version/.github/workflows/ci.yml +15 -0
- package/datasets/fix/repos/fix__env-mismatch-node-version/README.md +3 -0
- package/datasets/fix/repos/fix__env-mismatch-node-version/package.json +15 -0
- package/datasets/fix/repos/fix__env-mismatch-node-version/src/group.js +4 -0
- package/datasets/fix/repos/fix__env-mismatch-node-version/test/group.test.js +9 -0
- package/datasets/fix/repos/fix__flaky-promoted/.github/workflows/ci.yml +15 -0
- package/datasets/fix/repos/fix__flaky-promoted/README.md +3 -0
- package/datasets/fix/repos/fix__flaky-promoted/package.json +12 -0
- package/datasets/fix/repos/fix__flaky-promoted/src/poll.js +9 -0
- package/datasets/fix/repos/fix__flaky-promoted/test/poll.test.js +8 -0
- package/datasets/fix/repos/fix__flaky-timeout/.github/workflows/ci.yml +15 -0
- package/datasets/fix/repos/fix__flaky-timeout/README.md +3 -0
- package/datasets/fix/repos/fix__flaky-timeout/package.json +12 -0
- package/datasets/fix/repos/fix__flaky-timeout/src/poll.js +9 -0
- package/datasets/fix/repos/fix__flaky-timeout/test/poll.test.js +8 -0
- package/datasets/fix/repos/fix__infra-dependent-e2e/.github/workflows/ci.yml +28 -0
- package/datasets/fix/repos/fix__infra-dependent-e2e/README.md +3 -0
- package/datasets/fix/repos/fix__infra-dependent-e2e/e2e/smoke.test.js +10 -0
- package/datasets/fix/repos/fix__infra-dependent-e2e/package.json +12 -0
- package/datasets/fix/repos/fix__infra-dependent-e2e/src/retry.js +13 -0
- package/datasets/fix/repos/fix__infra-dependent-e2e/test/retry.test.js +8 -0
- package/datasets/fix/repos/fix__reproducible-api-removal/.github/workflows/ci.yml +15 -0
- package/datasets/fix/repos/fix__reproducible-api-removal/README.md +3 -0
- package/datasets/fix/repos/fix__reproducible-api-removal/package.json +15 -0
- package/datasets/fix/repos/fix__reproducible-api-removal/src/slug.js +6 -0
- package/datasets/fix/repos/fix__reproducible-api-removal/test/slug.test.js +7 -0
- package/datasets/fix/repos/fix__reproducible-api-removal/vendor/tiny-case/index.js +4 -0
- package/datasets/fix/repos/fix__upstream-broken-base-red/.github/workflows/ci.yml +15 -0
- package/datasets/fix/repos/fix__upstream-broken-base-red/README.md +3 -0
- package/datasets/fix/repos/fix__upstream-broken-base-red/package.json +12 -0
- package/datasets/fix/repos/fix__upstream-broken-base-red/src/format.js +6 -0
- package/datasets/fix/repos/fix__upstream-broken-base-red/src/rates.js +6 -0
- package/datasets/fix/repos/fix__upstream-broken-base-red/test/format.test.js +7 -0
- package/datasets/fix/repos/fix__upstream-broken-base-red/test/rates.test.js +7 -0
- package/datasets/fix/repos-head/fix__env-mismatch-node-version/package.json +15 -0
- package/datasets/fix/repos-head/fix__flaky-promoted/package.json +12 -0
- package/datasets/fix/repos-head/fix__flaky-timeout/package.json +12 -0
- package/datasets/fix/repos-head/fix__infra-dependent-e2e/package.json +12 -0
- package/datasets/fix/repos-head/fix__reproducible-api-removal/package.json +15 -0
- package/datasets/fix/repos-head/fix__reproducible-api-removal/vendor/tiny-case/index.js +4 -0
- package/datasets/fix/repos-head/fix__upstream-broken-base-red/package.json +12 -0
- package/datasets/fix/tier.json +5 -0
- package/datasets/repo-config/instances.json +35 -0
- package/datasets/repo-config/lastlight/repoconfig__prompt-override/agent-context/repo-notes.md +9 -0
- package/datasets/repo-config/lastlight/repoconfig__prompt-override/lastlight.yml +22 -0
- package/datasets/repo-config/lastlight/repoconfig__prompt-override/workflows/prompts/answer.md +27 -0
- package/datasets/repo-config/tier.json +5 -0
- package/dist/fake-github.js +228 -5
- package/dist/fake-github.js.map +1 -1
- package/dist/grade.js +90 -1
- package/dist/grade.js.map +1 -1
- package/dist/mechanism.test.js +420 -2
- package/dist/mechanism.test.js.map +1 -1
- package/dist/pr-context.js +162 -0
- package/dist/pr-context.js.map +1 -0
- package/dist/repo-config.js +141 -0
- package/dist/repo-config.js.map +1 -0
- package/dist/run-instance.js +134 -7
- package/dist/run-instance.js.map +1 -1
- package/dist/run.js +8 -0
- package/dist/run.js.map +1 -1
- package/dist/schema.js +0 -13
- package/dist/schema.js.map +1 -1
- package/dist/seed.js +7 -0
- package/dist/seed.js.map +1 -1
- package/package.json +4 -4
package/dist/mechanism.test.js
CHANGED
|
@@ -7,21 +7,27 @@
|
|
|
7
7
|
* - agentic-pi's `githubApiBaseUrl` seam actually routes Octokit at it;
|
|
8
8
|
* - workspace seeding + execution grading flip red→green correctly.
|
|
9
9
|
*/
|
|
10
|
-
import { describe, it, expect } from "vitest";
|
|
10
|
+
import { describe, it, expect, afterAll } from "vitest";
|
|
11
11
|
import { GitHubClient } from "agentic-pi/dist/extensions/github/client.js";
|
|
12
12
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
import { tmpdir } from "node:os";
|
|
15
16
|
import { startFakeGitHub } from "./fake-github.js";
|
|
17
|
+
import { appliedRepoConfigKeys, loadRepoConfigFixture, resolveEvalRepoConfig, } from "./repo-config.js";
|
|
16
18
|
import { seedWorkspace, seedWorkspaceFromGit, prFilesFromGit, injectRepoContext } from "./seed.js";
|
|
17
19
|
import { gitDiffAgainstBase } from "./run-instance.js";
|
|
18
20
|
import { execFileSync } from "node:child_process";
|
|
19
|
-
import { gradeExecution, gradeBehavioral, gradeTriage, gradeReview, fBeta } from "./grade.js";
|
|
21
|
+
import { gradeExecution, gradeBehavioral, gradeTriage, gradeReview, gradeMarkers, fBeta } from "./grade.js";
|
|
22
|
+
import { prContextPatch } from "./pr-context.js";
|
|
23
|
+
import { defaultFixConfig } from "lastlight-core/evals";
|
|
20
24
|
import { computeMartianRanking } from "./report.js";
|
|
21
25
|
import { loadMergedConfig, resolvePhaseModel } from "./config.js";
|
|
22
26
|
import { modelsArm, configArm, releaseOverlayGuard } from "./arm.js";
|
|
23
27
|
import { collectMetrics } from "./metrics.js";
|
|
24
28
|
const staticAuth = { getToken: async () => "fake-token", expiresAt: null, canRefresh: false };
|
|
29
|
+
/** The shipped `datasets/` root — resolved from this file, not the cwd. */
|
|
30
|
+
const DATASETS = join(fileURLToPath(new URL(".", import.meta.url)), "..", "datasets");
|
|
25
31
|
describe("fake GitHub + agentic-pi github tools (baseUrl seam)", () => {
|
|
26
32
|
it("serves seeded issues and records mutations made through the real GitHubClient", async () => {
|
|
27
33
|
const fake = await startFakeGitHub({
|
|
@@ -199,6 +205,191 @@ describe("fake GitHub — PR + review endpoints (pr-review tier)", () => {
|
|
|
199
205
|
}
|
|
200
206
|
});
|
|
201
207
|
});
|
|
208
|
+
describe("fake GitHub — the repo-config layer seam (issue #180)", () => {
|
|
209
|
+
const seed = (repoConfig) => startFakeGitHub({ owner: "acme", repo: "widget", defaultBranch: "trunk", repoConfig });
|
|
210
|
+
it("reports `absent` for a repo with no .lastlight/ — the pre-#180 default", async () => {
|
|
211
|
+
// Every eval case that predates this feature takes this path, so it has to
|
|
212
|
+
// be a plain negative answer (never an error) AND report the trust ref.
|
|
213
|
+
const fake = await seed();
|
|
214
|
+
try {
|
|
215
|
+
expect(await fake.fetchRepoConfigTree("acme", "widget")).toEqual({
|
|
216
|
+
status: "absent",
|
|
217
|
+
defaultBranch: "trunk",
|
|
218
|
+
});
|
|
219
|
+
expect(fake.repoConfigFetches()).toBe(1);
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
await fake.close();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
it("serves a seeded tree as `ok`, honouring includePath and the file cap", async () => {
|
|
226
|
+
const fake = await seed([
|
|
227
|
+
{ path: "lastlight.yml", content: "models: {}\n" },
|
|
228
|
+
{ path: "workflows/prompts/answer.md", content: "repo prompt" },
|
|
229
|
+
// Shared real estate: build-handoff docs live under `.lastlight/` too, and
|
|
230
|
+
// `fetchRepoLayer` filters them out BEFORE spending the byte budget.
|
|
231
|
+
{ path: "issue-42/architect-plan.md", content: "not part of the layer" },
|
|
232
|
+
]);
|
|
233
|
+
try {
|
|
234
|
+
const all = await fake.fetchRepoConfigTree("acme", "widget");
|
|
235
|
+
expect(all.status).toBe("ok");
|
|
236
|
+
if (all.status !== "ok")
|
|
237
|
+
return;
|
|
238
|
+
expect(all.defaultBranch).toBe("trunk");
|
|
239
|
+
expect(all.treeSha).toMatch(/^[0-9a-f]{40}$/);
|
|
240
|
+
expect(all.files.map((f) => f.path)).toEqual([
|
|
241
|
+
"lastlight.yml",
|
|
242
|
+
"workflows/prompts/answer.md",
|
|
243
|
+
"issue-42/architect-plan.md",
|
|
244
|
+
]);
|
|
245
|
+
expect(all.files[0].mode).toBe("100644");
|
|
246
|
+
expect(all.files[1].content.toString("utf8")).toBe("repo prompt");
|
|
247
|
+
expect(all.truncated).toBe(false);
|
|
248
|
+
const filtered = await fake.fetchRepoConfigTree("acme", "widget", {
|
|
249
|
+
includePath: (p) => p.startsWith("workflows/"),
|
|
250
|
+
});
|
|
251
|
+
expect(filtered.status === "ok" && filtered.files.map((f) => f.path)).toEqual([
|
|
252
|
+
"workflows/prompts/answer.md",
|
|
253
|
+
]);
|
|
254
|
+
const capped = await fake.fetchRepoConfigTree("acme", "widget", { maxFiles: 1 });
|
|
255
|
+
expect(capped.status === "ok" && capped.files).toHaveLength(1);
|
|
256
|
+
expect(capped.status === "ok" && capped.truncated).toBe(true);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await fake.close();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
it("answers `not-modified` to a matching treeSha/etag (the warm-cache path)", async () => {
|
|
263
|
+
const fake = await seed([{ path: "lastlight.yml", content: "models: {}\n" }]);
|
|
264
|
+
try {
|
|
265
|
+
const first = await fake.fetchRepoConfigTree("acme", "widget");
|
|
266
|
+
if (first.status !== "ok")
|
|
267
|
+
throw new Error("expected ok");
|
|
268
|
+
// Content-exact conditional: same subtree sha ⇒ nothing downloaded.
|
|
269
|
+
const again = await fake.fetchRepoConfigTree("acme", "widget", { treeSha: first.treeSha });
|
|
270
|
+
expect(again).toEqual({ status: "not-modified", defaultBranch: "trunk", treeSha: first.treeSha, etag: first.etag });
|
|
271
|
+
// Root-tree ETag conditional (what octokit surfaces as a 304).
|
|
272
|
+
const byEtag = await fake.fetchRepoConfigTree("acme", "widget", { etag: first.etag, treeSha: first.treeSha });
|
|
273
|
+
expect(byEtag.status).toBe("not-modified");
|
|
274
|
+
// A stale sha still downloads.
|
|
275
|
+
expect((await fake.fetchRepoConfigTree("acme", "widget", { treeSha: "stale" })).status).toBe("ok");
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
await fake.close();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
it("is loud about a repo it was never seeded with (a wiring bug, not 'no config')", async () => {
|
|
282
|
+
const fake = await seed([{ path: "lastlight.yml", content: "models: {}\n" }]);
|
|
283
|
+
try {
|
|
284
|
+
await expect(fake.fetchRepoConfigTree("other", "repo")).rejects.toThrow(/no repo-config fixture/);
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
await fake.close();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
describe("repo-config layer — fixture → core's real resolver (issue #180)", () => {
|
|
292
|
+
const cacheRoots = [];
|
|
293
|
+
const cacheRoot = () => {
|
|
294
|
+
const dir = mkdtempSync(join(tmpdir(), "ll-eval-repocfg-"));
|
|
295
|
+
cacheRoots.push(dir);
|
|
296
|
+
return dir;
|
|
297
|
+
};
|
|
298
|
+
afterAll(() => {
|
|
299
|
+
for (const dir of cacheRoots)
|
|
300
|
+
rmSync(dir, { recursive: true, force: true });
|
|
301
|
+
});
|
|
302
|
+
const resolve = (fake, repo, workflowName = "answer") => resolveEvalRepoConfig({
|
|
303
|
+
repo,
|
|
304
|
+
workflowName,
|
|
305
|
+
client: fake,
|
|
306
|
+
defaultModel: "anthropic/claude-haiku-4-5",
|
|
307
|
+
cacheRoot: cacheRoot(),
|
|
308
|
+
});
|
|
309
|
+
it("reads a case's fixture from <datasetDir>/lastlight/<id>/, laid out as the repo commits it", () => {
|
|
310
|
+
const files = loadRepoConfigFixture(join(DATASETS, "repo-config"), "repoconfig__prompt-override");
|
|
311
|
+
expect(files?.map((f) => f.path)).toEqual([
|
|
312
|
+
"agent-context/repo-notes.md",
|
|
313
|
+
"lastlight.yml",
|
|
314
|
+
"workflows/prompts/answer.md",
|
|
315
|
+
]);
|
|
316
|
+
// No fixture directory ⇒ no declaration. This is how every other tier stays
|
|
317
|
+
// on the operator config without knowing the feature exists.
|
|
318
|
+
expect(loadRepoConfigFixture(join(DATASETS, "repo-config"), "repoconfig__no-layer")).toBeUndefined();
|
|
319
|
+
expect(loadRepoConfigFixture(undefined, "anything")).toBeUndefined();
|
|
320
|
+
});
|
|
321
|
+
it("unpacks the shipped fixture, merges the in-bounds keys and drops the rest", async () => {
|
|
322
|
+
// The real production chain: fetchRepoLayer → sanitizeRepoFiles → unpack →
|
|
323
|
+
// resolveRepoConfig. We assert its OUTPUT, not a re-implementation of it.
|
|
324
|
+
const files = loadRepoConfigFixture(join(DATASETS, "repo-config"), "repoconfig__prompt-override");
|
|
325
|
+
const fake = await startFakeGitHub({ owner: "lastlight-evals", repo: "lantern", repoConfig: files });
|
|
326
|
+
try {
|
|
327
|
+
const { repoConfig, refusal } = await resolve(fake, "lastlight-evals/lantern");
|
|
328
|
+
expect(refusal).toBeUndefined();
|
|
329
|
+
expect(repoConfig).toBeDefined();
|
|
330
|
+
// Always the default branch — the trust ref, never a PR head.
|
|
331
|
+
expect(repoConfig.defaultBranch).toBe("main");
|
|
332
|
+
// The assets the repo contributed, unpacked under a real host path the
|
|
333
|
+
// runner's per-run asset resolver layers on top of the built-ins.
|
|
334
|
+
expect(repoConfig.assets).toEqual(["agent-context/repo-notes.md", "workflows/prompts/answer.md"]);
|
|
335
|
+
const prompt = join(repoConfig.assetRoot, "workflows", "prompts", "answer.md");
|
|
336
|
+
expect(readFileSync(prompt, "utf8")).toContain("REPO PROMPT APPLIED");
|
|
337
|
+
// In-bounds config merged, with `repo` provenance…
|
|
338
|
+
expect(repoConfig.disabled.crons).toEqual(["weekly-security-scan"]);
|
|
339
|
+
expect(appliedRepoConfigKeys(repoConfig)).toEqual(["disabled.crons"]);
|
|
340
|
+
// …and the out-of-bounds key dropped with a warning, not an exception.
|
|
341
|
+
expect(repoConfig.warnings.map((w) => w.code)).toContain("key-not-allowed");
|
|
342
|
+
// The arm's model survives a layer that doesn't set `models:` — otherwise
|
|
343
|
+
// a repo layer would silently rewrite the comparison axis.
|
|
344
|
+
expect(repoConfig.models.default).toBe("anthropic/claude-haiku-4-5");
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await fake.close();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
it("degrades to the operator config for a repo with no layer, and when the seam throws", async () => {
|
|
351
|
+
const plain = await startFakeGitHub({ owner: "lastlight-evals", repo: "plain" });
|
|
352
|
+
try {
|
|
353
|
+
// `absent` ⇒ no layer at all ⇒ `runWorkflow` is called exactly as it was
|
|
354
|
+
// before the feature existed.
|
|
355
|
+
expect(await resolve(plain, "lastlight-evals/plain")).toEqual({});
|
|
356
|
+
// A client that FAILS, and one that doesn't implement the seam at all
|
|
357
|
+
// (a third-party mock predating #180 — a synchronous TypeError, not a
|
|
358
|
+
// rejection), must both degrade rather than take the run down.
|
|
359
|
+
for (const broken of [{ fetchRepoConfigTree: () => Promise.reject(new Error("boom")) }, {}]) {
|
|
360
|
+
const out = await resolveEvalRepoConfig({
|
|
361
|
+
repo: "lastlight-evals/plain",
|
|
362
|
+
workflowName: "answer",
|
|
363
|
+
client: broken,
|
|
364
|
+
defaultModel: "anthropic/claude-haiku-4-5",
|
|
365
|
+
cacheRoot: cacheRoot(),
|
|
366
|
+
});
|
|
367
|
+
expect(out.repoConfig).toBeUndefined();
|
|
368
|
+
expect(out.refusal).toBeUndefined();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
await plain.close();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
it("surfaces a repo's own disabled.workflows as a refusal, not a layer", async () => {
|
|
376
|
+
const fake = await startFakeGitHub({
|
|
377
|
+
owner: "acme",
|
|
378
|
+
repo: "widget",
|
|
379
|
+
repoConfig: [{ path: "lastlight.yml", content: "disabled:\n workflows:\n - answer\n" }],
|
|
380
|
+
});
|
|
381
|
+
try {
|
|
382
|
+
const { repoConfig, refusal } = await resolve(fake, "acme/widget", "answer");
|
|
383
|
+
expect(repoConfig).toBeUndefined();
|
|
384
|
+
expect(refusal).toMatch(/disables the "answer" workflow/);
|
|
385
|
+
// A workflow the repo did NOT opt out of still resolves normally.
|
|
386
|
+
expect((await resolve(fake, "acme/widget", "pr-review")).refusal).toBeUndefined();
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
await fake.close();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
});
|
|
202
393
|
describe("prFilesFromGit — GitHub /pulls/:n/files payload from a real git diff", () => {
|
|
203
394
|
it("reports added/modified files with counts and per-file patch hunks", () => {
|
|
204
395
|
const dir = mkdtempSync(join(tmpdir(), "prfiles-"));
|
|
@@ -774,4 +965,231 @@ describe("collectMetrics — subscription-model cost fallback", () => {
|
|
|
774
965
|
}
|
|
775
966
|
});
|
|
776
967
|
});
|
|
968
|
+
// ── The PR state machine seam (issues #251, #252) ───────────────────────────
|
|
969
|
+
describe("PR context — core's own projection, not a copy", () => {
|
|
970
|
+
const args = {
|
|
971
|
+
repo: "acme/widget",
|
|
972
|
+
prNumber: 412,
|
|
973
|
+
title: "Bump tiny-case from 2.4.0 to 3.0.0",
|
|
974
|
+
body: "Bumps tiny-case.",
|
|
975
|
+
branch: "dependabot/npm_and_yarn/tiny-case-3",
|
|
976
|
+
};
|
|
977
|
+
it("projects the CI evidence the fix prompts render", () => {
|
|
978
|
+
const ctx = prContextPatch({
|
|
979
|
+
...args,
|
|
980
|
+
seed: {
|
|
981
|
+
checks_state: "failing",
|
|
982
|
+
base_checks_state: "passing",
|
|
983
|
+
attempt: 2,
|
|
984
|
+
ci_jobs: [
|
|
985
|
+
{ name: "CI / test", log_excerpt: "SyntaxError: no export named 'kebab'", failing_step: "npm test" },
|
|
986
|
+
],
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
// `{{ciSection}}` is the block the whole diagnosis rests on; `{{#if ciSection}}`
|
|
990
|
+
// is how the prompt gates it, so an empty string here means the agent is
|
|
991
|
+
// asked to diagnose a failure it was told nothing about.
|
|
992
|
+
expect(String(ctx.ciSection)).toContain("CI FAILURES");
|
|
993
|
+
expect(String(ctx.ciSection)).toContain("kebab");
|
|
994
|
+
expect(ctx.attempt).toBe(2);
|
|
995
|
+
expect(ctx.maxAttempts).toBe(defaultFixConfig().maxAttempts);
|
|
996
|
+
expect(ctx.checksState).toBe("failing");
|
|
997
|
+
expect(ctx.baseChecksState).toBe("passing");
|
|
998
|
+
expect(ctx.ciLogsAvailable).toBe(true);
|
|
999
|
+
// Both harness-owned paths reach the prompt as variables, so a fork that
|
|
1000
|
+
// hardcoded the old ones is not silently in play.
|
|
1001
|
+
expect(String(ctx.verifyScript)).toContain(".git/");
|
|
1002
|
+
expect(String(ctx.notesFile)).toContain(".git/");
|
|
1003
|
+
});
|
|
1004
|
+
it("carries the flaky promotion the third attempt turns on", () => {
|
|
1005
|
+
const ctx = prContextPatch({
|
|
1006
|
+
...args,
|
|
1007
|
+
seed: { attempt: 3, flaky_deferrals: 2, checks_state: "failing" },
|
|
1008
|
+
});
|
|
1009
|
+
// The signal that stops attempt 3 from re-reporting `flaky` and then
|
|
1010
|
+
// running the fix phase against a verdict that says "change nothing".
|
|
1011
|
+
expect(ctx.flakyPromoted).toBe(true);
|
|
1012
|
+
expect(ctx.flakyDeferrals).toBe(2);
|
|
1013
|
+
expect(ctx.maxFlakyDeferrals).toBe(defaultFixConfig().maxFlakyDeferrals);
|
|
1014
|
+
});
|
|
1015
|
+
it("projects the merge gate's verdict AND its reason, from one decision", () => {
|
|
1016
|
+
const green = prContextPatch({
|
|
1017
|
+
...args,
|
|
1018
|
+
seed: { checks_state: "passing", settled_check_count: 3 },
|
|
1019
|
+
});
|
|
1020
|
+
expect(green.mayMerge).toBe(true);
|
|
1021
|
+
expect(typeof green.mayMergeReason).toBe("string");
|
|
1022
|
+
const pending = prContextPatch({ ...args, seed: { checks_state: "pending" } });
|
|
1023
|
+
expect(pending.mayMerge).toBe(false);
|
|
1024
|
+
// The reason is produced BY the decision — the panel, the log line and the
|
|
1025
|
+
// prompt are three renderings of it, never three prose variants.
|
|
1026
|
+
expect(String(pending.mayMergeReason).length).toBeGreaterThan(0);
|
|
1027
|
+
});
|
|
1028
|
+
it("seeds the policy blocks a prompt renders, under the case's overrides", () => {
|
|
1029
|
+
const ctx = prContextPatch({ ...args, seed: { dependencies: { autoMergeMaxImpact: "low" } } });
|
|
1030
|
+
expect(ctx.dependencies.autoMergeMaxImpact).toBe("low");
|
|
1031
|
+
// Everything the case did NOT override is the shipped default, so a case
|
|
1032
|
+
// states only what it is actually testing.
|
|
1033
|
+
expect(ctx.fix.maxAttempts).toBe(defaultFixConfig().maxAttempts);
|
|
1034
|
+
});
|
|
1035
|
+
it("a case with no seed still gets a coherent snapshot", () => {
|
|
1036
|
+
const ctx = prContextPatch(args);
|
|
1037
|
+
expect(ctx.attempt).toBe(1);
|
|
1038
|
+
expect(ctx.ciSection).toBe("");
|
|
1039
|
+
expect(ctx.prNumber).toBe(412);
|
|
1040
|
+
});
|
|
1041
|
+
});
|
|
1042
|
+
// ── Marker grading (fix / dependency-merge) ─────────────────────────────────
|
|
1043
|
+
describe("gradeMarkers", () => {
|
|
1044
|
+
const diagnose = (cls) => `DIAGNOSIS_COMPLETE: pr=412 attempt=1 class=${cls} cause=renamed export ci_vs_local=none`;
|
|
1045
|
+
it("grades the diagnosis class off the real marker line", () => {
|
|
1046
|
+
const phases = [{ output: `Looked at the log.\n${diagnose("reproducible")}` }];
|
|
1047
|
+
expect(gradeMarkers({ diagnosis_class: "reproducible" }, phases).ok).toBe(true);
|
|
1048
|
+
expect(gradeMarkers({ diagnosis_class: "flaky" }, phases).ok).toBe(false);
|
|
1049
|
+
});
|
|
1050
|
+
it("does not accept a bare mention of the tag", () => {
|
|
1051
|
+
// The disagreement that let a silent no-op run pass as a diagnosis (#251):
|
|
1052
|
+
// the phase gate tested `output.includes(tag)` while the parser required
|
|
1053
|
+
// `<TAG>:`. Grading goes through CORE's parser, so the two agree here.
|
|
1054
|
+
const phases = [{ output: "I'll finish with DIAGNOSIS_COMPLETE once I understand this." }];
|
|
1055
|
+
const g = gradeMarkers({ diagnosis_class: "reproducible" }, phases);
|
|
1056
|
+
expect(g.ok).toBe(false);
|
|
1057
|
+
expect(g.checks[0].detail).toContain("no marker");
|
|
1058
|
+
});
|
|
1059
|
+
it("takes the LAST marker across phases — a loop's later iteration supersedes", () => {
|
|
1060
|
+
const phases = [
|
|
1061
|
+
{ output: diagnose("flaky") },
|
|
1062
|
+
{ output: diagnose("reproducible") },
|
|
1063
|
+
];
|
|
1064
|
+
expect(gradeMarkers({ diagnosis_class: "reproducible" }, phases).ok).toBe(true);
|
|
1065
|
+
});
|
|
1066
|
+
it("accepts any of a set where two verdicts are both defensible", () => {
|
|
1067
|
+
const phases = [{ output: diagnose("env-mismatch") }];
|
|
1068
|
+
expect(gradeMarkers({ diagnosis_class_any_of: ["reproducible", "env-mismatch"] }, phases).ok).toBe(true);
|
|
1069
|
+
expect(gradeMarkers({ diagnosis_class_any_of: ["flaky"] }, phases).ok).toBe(false);
|
|
1070
|
+
});
|
|
1071
|
+
it("grades the fix outcome and its gate", () => {
|
|
1072
|
+
const phases = [{ output: "CI_FIX_COMPLETE: pr=412 attempt=1 outcome=pushed tried=rename gate=green" }];
|
|
1073
|
+
expect(gradeMarkers({ fix_outcome: "pushed", fix_gate: "green" }, phases).ok).toBe(true);
|
|
1074
|
+
expect(gradeMarkers({ fix_gate: "red" }, phases).ok).toBe(false);
|
|
1075
|
+
});
|
|
1076
|
+
it("grades the merge assessment's impact and action", () => {
|
|
1077
|
+
const phases = [
|
|
1078
|
+
{ output: "ASSESSMENT_COMPLETE: pr=501 verdict=TRIVIAL impact=low action=automerge" },
|
|
1079
|
+
];
|
|
1080
|
+
expect(gradeMarkers({ assessment_impact: "low", assessment_action: "automerge" }, phases).ok).toBe(true);
|
|
1081
|
+
expect(gradeMarkers({ assessment_impact: "high" }, phases).ok).toBe(false);
|
|
1082
|
+
});
|
|
1083
|
+
it("is a no-op for a case that declares no expectations", () => {
|
|
1084
|
+
expect(gradeMarkers(undefined, [{ output: "anything" }]).ok).toBe(true);
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
// ── The merge + Actions endpoints the two new tiers need ────────────────────
|
|
1088
|
+
describe("fake GitHub — merge, auto-merge, diff and Actions", () => {
|
|
1089
|
+
const pr = {
|
|
1090
|
+
number: 501,
|
|
1091
|
+
title: "Bump @types/node from 22 to 26",
|
|
1092
|
+
body: "bump",
|
|
1093
|
+
base_ref: "main",
|
|
1094
|
+
head_ref: "dependabot/types-node-26",
|
|
1095
|
+
base_commit: "0".repeat(40),
|
|
1096
|
+
head_commit: "0".repeat(40),
|
|
1097
|
+
user: "dependabot[bot]",
|
|
1098
|
+
files: [
|
|
1099
|
+
{
|
|
1100
|
+
sha: "0".repeat(40),
|
|
1101
|
+
filename: "package.json",
|
|
1102
|
+
status: "modified",
|
|
1103
|
+
additions: 1,
|
|
1104
|
+
deletions: 1,
|
|
1105
|
+
changes: 2,
|
|
1106
|
+
patch: '@@ -12,7 +12,7 @@\n- "@types/node": "^22.10.2",\n+ "@types/node": "^26.1.1",',
|
|
1107
|
+
},
|
|
1108
|
+
],
|
|
1109
|
+
};
|
|
1110
|
+
it("enables auto-merge through the GraphQL mutation the real tool uses", async () => {
|
|
1111
|
+
// There is NO REST endpoint for this. A REST-only fake 404s the merge
|
|
1112
|
+
// workflow's preferred path and the agent silently falls back to a direct
|
|
1113
|
+
// merge — every case would then measure the fallback.
|
|
1114
|
+
const fake = await startFakeGitHub({ owner: "acme", repo: "app", pulls: [pr] });
|
|
1115
|
+
try {
|
|
1116
|
+
const gh = new GitHubClient(staticAuth, { baseUrl: fake.url });
|
|
1117
|
+
const res = (await gh.enablePullRequestAutoMerge("acme", "app", 501, "squash"));
|
|
1118
|
+
expect(res.ok).toBe(true);
|
|
1119
|
+
expect(fake.autoMergeOf(501)?.method).toBe("squash");
|
|
1120
|
+
// Auto-merge is NOT a merge: the PR is still open, waiting on its checks.
|
|
1121
|
+
expect(fake.mergeOf(501)).toBeUndefined();
|
|
1122
|
+
}
|
|
1123
|
+
finally {
|
|
1124
|
+
await fake.close();
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
it("records a direct merge separately from auto-merge", async () => {
|
|
1128
|
+
const fake = await startFakeGitHub({ owner: "acme", repo: "app", pulls: [pr] });
|
|
1129
|
+
try {
|
|
1130
|
+
const gh = new GitHubClient(staticAuth, { baseUrl: fake.url });
|
|
1131
|
+
await gh.mergePullRequest("acme", "app", 501, { merge_method: "squash" });
|
|
1132
|
+
expect(fake.mergeOf(501)?.method).toBe("squash");
|
|
1133
|
+
expect(fake.autoMergeOf(501)).toBeUndefined();
|
|
1134
|
+
// …and the two are gradeable as the different decisions they are.
|
|
1135
|
+
expect(gradeBehavioral({ pr_merged: true, auto_merge_enabled: false }, fake, { issueNumber: 501, branch: "main" }).ok).toBe(true);
|
|
1136
|
+
expect(gradeBehavioral({ pr_merged: false }, fake, { issueNumber: 501, branch: "main" }).ok).toBe(false);
|
|
1137
|
+
}
|
|
1138
|
+
finally {
|
|
1139
|
+
await fake.close();
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
it("serves the PR patch to the diff media type, not JSON", async () => {
|
|
1143
|
+
const fake = await startFakeGitHub({ owner: "acme", repo: "app", pulls: [pr] });
|
|
1144
|
+
try {
|
|
1145
|
+
fake.setPullFiles(501, pr.files);
|
|
1146
|
+
const gh = new GitHubClient(staticAuth, { baseUrl: fake.url });
|
|
1147
|
+
const diff = (await gh.getPullRequestDiff("acme", "app", 501));
|
|
1148
|
+
// A JSON body here would hand the impact assessment an object where it
|
|
1149
|
+
// expects a patch — it would then reason about the wrong artifact.
|
|
1150
|
+
expect(String(diff)).toContain("diff --git a/package.json");
|
|
1151
|
+
expect(String(diff)).toContain("@types/node");
|
|
1152
|
+
}
|
|
1153
|
+
finally {
|
|
1154
|
+
await fake.close();
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
it("serves the CI reads from the same fixture the prompt was built from", async () => {
|
|
1158
|
+
const fake = await startFakeGitHub({
|
|
1159
|
+
owner: "acme",
|
|
1160
|
+
repo: "app",
|
|
1161
|
+
pulls: [pr],
|
|
1162
|
+
actions: {
|
|
1163
|
+
headSha: "f1a0b22",
|
|
1164
|
+
jobs: [{ name: "CI / test", log: "SyntaxError: no export named 'kebab'", failingStep: "npm test" }],
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
try {
|
|
1168
|
+
const gh = new GitHubClient(staticAuth, { baseUrl: fake.url });
|
|
1169
|
+
const runs = (await gh.listWorkflowRuns("acme", "app", {}));
|
|
1170
|
+
expect(runs.workflow_runs.length).toBe(1);
|
|
1171
|
+
const jobs = (await gh.listWorkflowRunJobs("acme", "app", runs.workflow_runs[0].id));
|
|
1172
|
+
expect(jobs.jobs[0].name).toBe("CI / test");
|
|
1173
|
+
const logs = (await gh.getJobLogs("acme", "app", jobs.jobs[0].id));
|
|
1174
|
+
// An agent that digs into the logs must read the SAME failure the prompt
|
|
1175
|
+
// told it about — one seed, two surfaces.
|
|
1176
|
+
expect(JSON.stringify(logs)).toContain("kebab");
|
|
1177
|
+
}
|
|
1178
|
+
finally {
|
|
1179
|
+
await fake.close();
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
it("keeps an un-fixtured Actions route a loud 404", async () => {
|
|
1183
|
+
// The rest of this file's contract: an unimplemented route 404s rather than
|
|
1184
|
+
// returning a plausible empty answer.
|
|
1185
|
+
const fake = await startFakeGitHub({ owner: "acme", repo: "app", pulls: [pr] });
|
|
1186
|
+
try {
|
|
1187
|
+
const res = await fetch(`${fake.url}/repos/acme/app/actions/runs`);
|
|
1188
|
+
expect(res.status).toBe(404);
|
|
1189
|
+
}
|
|
1190
|
+
finally {
|
|
1191
|
+
await fake.close();
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
777
1195
|
//# sourceMappingURL=mechanism.test.js.map
|