Files
2026-07-13 12:20:01 +08:00

179 lines
10 KiB
TypeScript

import { readFile } from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
// Cross-skill contract parity for ce-babysit-pr's delegation seams. These tokens are protocol
// shared between a producer skill and a consumer skill; a rename or drop on one side alone breaks
// the loop silently (babysit mis-parses a ce-debug status, or references a trajectory field
// pr-snapshot no longer emits). Each assertion below fails under exactly that one-sided drift.
//
// Sensitivity note: presence/exact-set based. It catches renames and drops (the drift that
// actually happens); a field added to BOTH sides in the same change is in sync and intentionally
// does not fail. The emitter-set check additionally catches an emitter-only addition.
async function readRepoFile(relativePath: string): Promise<string> {
return readFile(path.join(process.cwd(), relativePath), "utf8")
}
const BABYSIT = "skills/ce-babysit-pr/SKILL.md"
const CEDEBUG_PIPELINE = "skills/ce-debug/references/pipeline-mode.md"
const CERESOLVE = "skills/ce-resolve-pr-feedback/SKILL.md"
const CERESOLVE_FULL_MODE = "skills/ce-resolve-pr-feedback/references/full-mode.md"
const PR_SNAPSHOT = "skills/ce-babysit-pr/scripts/pr-snapshot"
// ce-debug's pipeline-mode structured return. babysit branches on this exact set (Step 2 step 5)
// and warns "do not invent infra-retry/stale" — so both the vocabulary and the ban are protocol.
const CEDEBUG_STATUS = ["fixed-and-pushed", "diagnosed-no-fix", "flaky-infra", "needs-human"]
// pr-snapshot's trajectory block (emitted by _update_trajectory). The subset each consumer cites
// by name is protocol for that consumer: rename a field in the emitter and the citation dangles.
const TRAJECTORY_FIELDS = [
"check_recur_max",
"recurring_checks",
"unresolved_threads",
"unresolved_series",
"unresolved_trend",
"new_threads_this_tick",
"stream_alternations",
"heads_since_progress",
]
const BABYSIT_TRAJECTORY_REFS = [
"check_recur_max",
"recurring_checks",
"unresolved_trend",
"new_threads_this_tick",
"stream_alternations",
"heads_since_progress",
]
const CERESOLVE_TRAJECTORY_REFS = ["unresolved_trend", "new_threads_this_tick"]
function emittedTrajectoryKeys(script: string): string[] {
const fn = script.slice(script.indexOf("def _update_trajectory"))
const retStart = fn.indexOf("return {")
const block = fn.slice(retStart, fn.indexOf("\n }", retStart))
return [...block.matchAll(/"([a-z_]+)":/g)].map((m) => m[1])
}
describe("ce-babysit-pr cross-skill contract parity", () => {
test("ce-debug pipeline return-status enum agrees between producer and babysit consumer", async () => {
const [producer, consumer] = await Promise.all([readRepoFile(CEDEBUG_PIPELINE), readRepoFile(BABYSIT)])
for (const status of CEDEBUG_STATUS) {
expect(producer, `ce-debug must still emit '${status}'`).toContain(status)
expect(consumer, `babysit must still branch on '${status}'`).toContain(status)
}
// The ban babysit states must remain true of the producer, or the warning is stale.
expect(producer).not.toContain("infra-retry")
expect(consumer).toContain("do not invent `infra-retry`")
})
test("pr-snapshot emits exactly the canonical trajectory field set", async () => {
const keys = emittedTrajectoryKeys(await readRepoFile(PR_SNAPSHOT))
expect(keys.sort()).toEqual([...TRAJECTORY_FIELDS].sort())
})
test("the delegated-mutation exclusion boundary is stated at all three ends of the chain", async () => {
// babysit passes a bounded scope whose exclusions (never rebase/force-push/merge/approve) the
// delegates must honor. If either child drops the boundary, babysit's contract is one-sided.
// 'rebase' and 'force-push' are specific enough to canary the exclusion block; 'merge' is not
// (merge-ready / merge conflict are ordinary prose here).
const [babysit, cedebug, ceresolve] = await Promise.all([
readRepoFile(BABYSIT),
readRepoFile(CEDEBUG_PIPELINE),
readRepoFile(CERESOLVE),
])
for (const [name, text] of [["babysit", babysit], ["ce-debug", cedebug], ["ce-resolve", ceresolve]] as const) {
expect(text, `${name} must state the 'rebase' exclusion`).toContain("rebase")
expect(text, `${name} must state the 'force-push' exclusion`).toContain("force-push")
}
})
test("every trajectory field cited in consumer prose is one pr-snapshot actually emits", async () => {
const script = await readRepoFile(PR_SNAPSHOT)
const emitted = new Set(emittedTrajectoryKeys(script))
const [babysit, ceresolve] = await Promise.all([readRepoFile(BABYSIT), readRepoFile(CERESOLVE)])
for (const field of BABYSIT_TRAJECTORY_REFS) {
expect(emitted.has(field), `babysit cites '${field}' but pr-snapshot no longer emits it`).toBe(true)
expect(babysit).toContain(field)
}
for (const field of CERESOLVE_TRAJECTORY_REFS) {
expect(emitted.has(field), `ce-resolve cites '${field}' but pr-snapshot no longer emits it`).toBe(true)
expect(ceresolve).toContain(field)
}
})
test("babysit's default mode is a self-sustaining in-session watch backed by pr-snapshot watch", async () => {
// The self-initiation contract: babysit does NOT do one tick and hand back a resume command by
// default; it backgrounds the token-free change-detector and stays in-session, woken by a sentinel.
const [babysit, script] = await Promise.all([readRepoFile(BABYSIT), readRepoFile(PR_SNAPSHOT)])
expect(babysit, "must describe the self-sustaining in-session watch").toMatch(/self-sustaining[, ]+in-session watch/i)
expect(babysit, "must invoke the pr-snapshot watch detector").toContain("pr-snapshot watch")
expect(babysit, "must wait on the BABYSIT_WAKE sentinel").toContain("BABYSIT_WAKE")
// producer side: the watch subcommand emits the sentinel and can wake on each precedence reason
expect(script).toContain("def cmd_watch")
expect(script).toContain("BABYSIT_WAKE")
for (const reason of ["terminal", "blocked-external", "actionable", "needs-human", "merge-ready"]) {
expect(script, `watch must be able to wake on '${reason}'`).toContain(reason)
}
})
test("babysit reconciles every passed comment so the loop can settle (never-settle fix)", async () => {
// Regression guard: marking only the comments ce-resolve explicitly 'handled' left its
// silently-dropped bot wrappers actionable forever, so counts.comments never reached 0.
const babysit = await readRepoFile(BABYSIT)
expect(babysit).toContain("silently drops")
expect(babysit, "must mark every passed comment, not only the handled ones").toMatch(/mark \*?every\*? comment you passed/i)
expect(babysit).toContain("never settle")
})
test("ce-resolve routes a whole-PR URL to full mode, a comment-fragment URL to targeted", async () => {
// babysit hands ce-resolve the fork->upstream PR URL; a bare /pull/N must run full mode against
// the parsed host/repo, while a comment-fragment URL stays targeted.
const ceresolve = await readRepoFile(CERESOLVE)
expect(ceresolve).toContain("PR URL")
expect(ceresolve).toContain("no comment fragment")
expect(ceresolve).toMatch(/#discussion_r|#issuecomment/)
})
test("ce-resolve passes the GHE host inline on every bundled-script call, not via one export", async () => {
// A single `export GH_HOST` does not survive between separate Bash tool calls, so each script
// call carries the host inline; on GHE, dropping it silently queries github.com.
const fullMode = await readRepoFile(CERESOLVE_FULL_MODE)
const prefixCount = (fullMode.match(/GH_HOST=<derived-host>/g) || []).length
expect(prefixCount, "each bundled-script call needs its own inline GH_HOST prefix").toBeGreaterThanOrEqual(4)
expect(fullMode, "must state that a single export does not carry between Bash calls").toContain("does **not** carry")
})
test("babysit's final merge-ready checkpoint self-refreshes a stale PR description via ce-commit-push-pr", async () => {
// Incremental commits during a watch leave the PR description stale; babysit must reflect on
// that before declaring merge-ready and route a stale one to ce-commit-push-pr's description
// update — autonomously, as an owned/pre-authorized mutation, not a user prompt.
const babysit = await readRepoFile(BABYSIT)
expect(babysit).toContain("PR-description freshness")
expect(babysit).toContain("description-update mode")
expect(babysit).toContain("ce-commit-push-pr")
expect(babysit, "description refresh must be in the owned mutation envelope").toMatch(/refresh(es|ing) (the |a )PR description/)
})
test("bounded-class sweep contract: babysit routes it, ce-resolve classifies/enumerates/bounds it", async () => {
// A correct finding recurring across sibling sites must be swept as one class, not dripped
// one-per-head. The split is protocol: babysit only recognizes + routes ("request a
// bounded-class assessment"); ce-resolve owns whether the sites are equivalent, the enumerated
// locations, and the fixer's mutation boundary. Dropping either side silently reverts to
// one-site-per-round (babysit routes a request nothing fulfills, or the resolver never sweeps).
const [babysit, watchLoop, fullMode, rubric, fixer] = await Promise.all([
readRepoFile(BABYSIT),
readRepoFile("skills/ce-babysit-pr/references/watch-loop.md"),
readRepoFile(CERESOLVE_FULL_MODE),
readRepoFile("skills/ce-resolve-pr-feedback/references/evaluation-rubric.md"),
readRepoFile("skills/ce-resolve-pr-feedback/references/agents/pr-comment-resolver.md"),
])
// Babysit side: recognizes + routes, does not decide/execute the sweep.
expect(babysit, "babysit Step 2 must request the assessment inline").toMatch(/bounded-class assessment/i)
expect(watchLoop).toMatch(/bounded-class assessment/i)
// Resolver side: owns classification, enumeration, and the fixer's enumerated mutation boundary.
expect(rubric).toContain("A validated finding can span sites this PR itself introduced")
expect(fullMode).toMatch(/Class fix:/)
expect(fixer, "class-fix mutation boundary must reach the fixer prompt").toMatch(/enumerated set is the mutation boundary/i)
})
})