179 lines
10 KiB
TypeScript
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)
|
|
})
|
|
})
|