310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { spawnSync } from "node:child_process"
|
|
import {
|
|
mkdtempSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
mkdirSync,
|
|
} from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import path from "node:path"
|
|
|
|
// The cache helper is byte-duplicated per consuming skill (parity-guarded in
|
|
// repo-profile-cache-parity.test.ts). Behavior is identical, so exercise the
|
|
// canonical ce-pov copy here.
|
|
const SCRIPT = path.join(
|
|
__dirname,
|
|
"../skills/ce-pov/scripts/repo-profile-cache.py",
|
|
)
|
|
|
|
function git(cwd: string, ...args: string[]): string {
|
|
const r = spawnSync("git", args, { cwd, encoding: "utf8" })
|
|
if (r.status !== 0) {
|
|
throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`)
|
|
}
|
|
return r.stdout ?? ""
|
|
}
|
|
|
|
function run(
|
|
cwd: string,
|
|
...args: string[]
|
|
): { code: number; stdout: string; stderr: string } {
|
|
const r = spawnSync("python3", [SCRIPT, ...args], { cwd, encoding: "utf8" })
|
|
return {
|
|
code: r.status ?? -1,
|
|
stdout: r.stdout ?? "",
|
|
stderr: r.stderr ?? "",
|
|
}
|
|
}
|
|
|
|
/** Fresh git repo with a manifest + README, one commit. Unique root SHA. */
|
|
function makeRepo(): string {
|
|
const dir = mkdtempSync(path.join(tmpdir(), "repo-profile-"))
|
|
git(dir, "init", "-q")
|
|
git(dir, "config", "user.email", "test@example.com")
|
|
git(dir, "config", "user.name", "Test")
|
|
git(dir, "config", "commit.gpgsign", "false")
|
|
writeFileSync(
|
|
path.join(dir, "package.json"),
|
|
'{"name":"x","version":"1.0.0"}\n',
|
|
)
|
|
writeFileSync(path.join(dir, "README.md"), "# x\n")
|
|
git(dir, "add", "-A")
|
|
git(dir, "commit", "-q", "-m", "init")
|
|
return dir
|
|
}
|
|
|
|
/** A full, valid agnostic profile — `put` now requires every top-level key. */
|
|
const VALID_PROFILE = {
|
|
stack: { languages: ["bun"] },
|
|
dependencies: { top_level: [], project_license: "MIT" },
|
|
topology: { monorepo: false, module_layout: "src/" },
|
|
conventions: { testing: "bun test" },
|
|
vocabulary: { concepts_present: false, terms: [] },
|
|
}
|
|
|
|
/** Write a profile JSON file and `put` it; return the cache path. */
|
|
function putProfile(dir: string, profile: object = VALID_PROFILE): string {
|
|
const profileFile = path.join(dir, "profile.json")
|
|
writeFileSync(profileFile, JSON.stringify(profile))
|
|
const res = run(dir, "put", profileFile)
|
|
expect(res.code).toBe(0)
|
|
return res.stdout.trim()
|
|
}
|
|
|
|
function getHitProfile(stdout: string): unknown {
|
|
const nl = stdout.indexOf("\n")
|
|
return JSON.parse(stdout.slice(nl + 1).trim())
|
|
}
|
|
|
|
describe("repo-profile-cache helper", () => {
|
|
test("fresh repo with no entry → MISS + a cache path under /tmp", () => {
|
|
const dir = makeRepo()
|
|
const res = run(dir, "get")
|
|
expect(res.code).toBe(0)
|
|
expect(res.stdout.startsWith("MISS\n")).toBe(true)
|
|
const writePath = res.stdout.split("\n")[1]
|
|
expect(writePath).toContain("/compound-engineering/repo-profile/")
|
|
expect(writePath.endsWith(".json")).toBe(true)
|
|
})
|
|
|
|
test("put then get (clean tree) → HIT with the stored profile", () => {
|
|
const dir = makeRepo()
|
|
putProfile(dir)
|
|
const res = run(dir, "get")
|
|
expect(res.code).toBe(0)
|
|
expect(res.stdout.startsWith("HIT\n")).toBe(true)
|
|
expect(getHitProfile(res.stdout)).toEqual(VALID_PROFILE)
|
|
})
|
|
|
|
test("dirty NON-input file (untracked source) stays HIT", () => {
|
|
const dir = makeRepo()
|
|
putProfile(dir)
|
|
mkdirSync(path.join(dir, "src"))
|
|
writeFileSync(path.join(dir, "src", "app.js"), "console.log(1)\n")
|
|
const res = run(dir, "get")
|
|
expect(res.stdout.startsWith("HIT\n")).toBe(true)
|
|
})
|
|
|
|
test("modified manifest → MISS (cardinal-rule input guard)", () => {
|
|
const dir = makeRepo()
|
|
putProfile(dir)
|
|
writeFileSync(
|
|
path.join(dir, "package.json"),
|
|
'{"name":"x","version":"2.0.0"}\n',
|
|
)
|
|
const res = run(dir, "get")
|
|
expect(res.stdout.startsWith("MISS\n")).toBe(true)
|
|
})
|
|
|
|
test("new UNTRACKED manifest (??) → MISS (untracked-input guard)", () => {
|
|
const dir = makeRepo()
|
|
putProfile(dir)
|
|
mkdirSync(path.join(dir, "packages", "sub"), { recursive: true })
|
|
writeFileSync(
|
|
path.join(dir, "packages", "sub", "package.json"),
|
|
'{"name":"sub"}\n',
|
|
)
|
|
const res = run(dir, "get")
|
|
expect(res.stdout.startsWith("MISS\n")).toBe(true)
|
|
})
|
|
|
|
test("new untracked root AGENTS.md (??) → MISS", () => {
|
|
const dir = makeRepo()
|
|
putProfile(dir)
|
|
writeFileSync(path.join(dir, "AGENTS.md"), "# rules\n")
|
|
const res = run(dir, "get")
|
|
expect(res.stdout.startsWith("MISS\n")).toBe(true)
|
|
})
|
|
|
|
test("schema-version mismatch → MISS", () => {
|
|
const dir = makeRepo()
|
|
const cachePath = putProfile(dir)
|
|
const doc = JSON.parse(readFileSync(cachePath, "utf8"))
|
|
doc.profile_schema_version = "0"
|
|
writeFileSync(cachePath, JSON.stringify(doc))
|
|
const res = run(dir, "get")
|
|
expect(res.stdout.startsWith("MISS\n")).toBe(true)
|
|
})
|
|
|
|
test("malformed cache file → MISS (degrades, never raises)", () => {
|
|
const dir = makeRepo()
|
|
const cachePath = putProfile(dir)
|
|
writeFileSync(cachePath, "not json at all")
|
|
const res = run(dir, "get")
|
|
expect(res.code).toBe(0)
|
|
expect(res.stdout.startsWith("MISS\n")).toBe(true)
|
|
})
|
|
|
|
test("non-git directory → NO-CACHE", () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), "repo-profile-nogit-"))
|
|
const res = run(dir, "get")
|
|
expect(res.code).toBe(0)
|
|
expect(res.stdout.trim()).toBe("NO-CACHE")
|
|
})
|
|
|
|
test("multi-root history yields a deterministic single-root path", () => {
|
|
const dir = makeRepo()
|
|
const orig = git(dir, "rev-parse", "--abbrev-ref", "HEAD").trim()
|
|
git(dir, "checkout", "-q", "--orphan", "second")
|
|
writeFileSync(path.join(dir, "other.txt"), "x\n")
|
|
git(dir, "add", "-A")
|
|
git(dir, "commit", "-q", "-m", "second root")
|
|
git(dir, "checkout", "-q", orig)
|
|
git(dir, "merge", "-q", "--allow-unrelated-histories", "--no-edit", "second")
|
|
const res = run(dir, "get")
|
|
expect(res.code).toBe(0)
|
|
const writePath = res.stdout.split("\n")[1]
|
|
// The <root-sha> path component must be a single 40-hex SHA, not a
|
|
// newline-joined pair from multiple roots.
|
|
const rootComponent = writePath.split("/repo-profile/")[1].split("/")[0]
|
|
expect(rootComponent).toMatch(/^[0-9a-f]{40}$/)
|
|
})
|
|
|
|
test("put rejects a non-object / empty / partial profile → not cached", () => {
|
|
// empty, non-object, AND partial / wrapper objects missing required keys
|
|
// (a profiler failure that still returns JSON must not be cached + served)
|
|
const bad = ["{}", '"oops"', "[]", "42", "null", '{"stack":{}}', '{"oops":"x"}']
|
|
for (const garbage of bad) {
|
|
const dir = makeRepo()
|
|
const f = path.join(dir, "bad.json")
|
|
writeFileSync(f, garbage)
|
|
const put = run(dir, "put", f)
|
|
expect(put.code).toBe(0)
|
|
expect(put.stdout.trim()).toBe("NO-CACHE") // refused to persist
|
|
// and nothing was cached, so a subsequent get is a MISS, not a HIT
|
|
const get = run(dir, "get")
|
|
expect(get.stdout.startsWith("MISS\n")).toBe(true)
|
|
}
|
|
}, 30000)
|
|
|
|
test("put refuses a profile derived from a dirty tree (revert-staleness guard)", () => {
|
|
const dir = makeRepo()
|
|
// dirty a profile input, then derive + put while dirty
|
|
writeFileSync(path.join(dir, "package.json"), '{"name":"x","version":"DIRTY"}\n')
|
|
const f = path.join(dir, "profile.json")
|
|
writeFileSync(f, JSON.stringify(VALID_PROFILE))
|
|
expect(run(dir, "put", f).stdout.trim()).toBe("NO-CACHE") // not persisted — dirty
|
|
// revert → clean tree at the same HEAD; must still MISS, not serve the dirty profile
|
|
writeFileSync(path.join(dir, "package.json"), '{"name":"x","version":"1.0.0"}\n')
|
|
expect(run(dir, "get").stdout.startsWith("MISS\n")).toBe(true)
|
|
})
|
|
|
|
test("usage error on missing/garbage subcommand → exit 2", () => {
|
|
const dir = makeRepo()
|
|
expect(run(dir).code).toBe(2)
|
|
expect(run(dir, "frobnicate").code).toBe(2)
|
|
expect(run(dir, "put").code).toBe(2) // put with no file
|
|
})
|
|
})
|
|
|
|
describe("repo-profile-cache helper — review-driven invalidation cases", () => {
|
|
test("renaming a profile input AWAY → MISS (both rename endpoints count)", () => {
|
|
const dir = makeRepo()
|
|
putProfile(dir)
|
|
git(dir, "mv", "package.json", "pkg.json") // R package.json -> pkg.json
|
|
const res = run(dir, "get")
|
|
expect(res.stdout.startsWith("MISS\n")).toBe(true)
|
|
})
|
|
|
|
test("renaming a NON-input file → stays HIT", () => {
|
|
const dir = makeRepo()
|
|
mkdirSync(path.join(dir, "src"))
|
|
writeFileSync(path.join(dir, "src", "lib.js"), "export const x = 1\n")
|
|
git(dir, "add", "-A")
|
|
git(dir, "commit", "-q", "-m", "add lib")
|
|
putProfile(dir)
|
|
git(dir, "mv", "src/lib.js", "src/lib2.js")
|
|
expect(run(dir, "get").stdout.startsWith("HIT\n")).toBe(true)
|
|
})
|
|
|
|
test("nested (subdir) instruction file stays HIT; only root invalidates", () => {
|
|
const dir = makeRepo()
|
|
putProfile(dir)
|
|
mkdirSync(path.join(dir, "sub"))
|
|
writeFileSync(path.join(dir, "sub", "AGENTS.md"), "# nested\n")
|
|
expect(run(dir, "get").stdout.startsWith("HIT\n")).toBe(true)
|
|
})
|
|
|
|
test("CI/config prefixes invalidate; non-workflow .github file does not", () => {
|
|
// .cursor/ and .github/workflows/ are profile inputs (MISS)
|
|
for (const p of [".cursor/rules", ".github/workflows/ci.yml"]) {
|
|
const dir = makeRepo()
|
|
putProfile(dir)
|
|
mkdirSync(path.join(dir, path.dirname(p)), { recursive: true })
|
|
writeFileSync(path.join(dir, p), "x\n")
|
|
expect(run(dir, "get").stdout.startsWith("MISS\n")).toBe(true)
|
|
}
|
|
// a .github file NOT under workflows/ is not an input (HIT)
|
|
const dir = makeRepo()
|
|
putProfile(dir)
|
|
mkdirSync(path.join(dir, ".github"))
|
|
writeFileSync(path.join(dir, ".github", "ISSUE_TEMPLATE.md"), "x\n")
|
|
expect(run(dir, "get").stdout.startsWith("HIT\n")).toBe(true)
|
|
}, 30000)
|
|
|
|
test("non-JS ecosystem + monorepo + IaC + deploy inputs invalidate", () => {
|
|
for (const p of [
|
|
"App.csproj", // .NET project-file suffix
|
|
"Package.swift", // Swift/iOS
|
|
"go.work", // Go workspace
|
|
"nx.json", // monorepo orchestrator
|
|
"infra.tf", // Terraform (.tf suffix)
|
|
"terraform/main.tf", // Terraform dir prefix
|
|
"k8s/deployment.yaml", // Kubernetes dir prefix
|
|
"Pulumi.yaml", // Pulumi IaC
|
|
".nvmrc", // runtime version selector
|
|
".tool-versions", // asdf/mise version selector
|
|
".cursorrules", // legacy root Cursor rules
|
|
"vercel.json", // deploy descriptor
|
|
".gitlab-ci.yml", // non-GitHub CI
|
|
]) {
|
|
const dir = makeRepo()
|
|
putProfile(dir)
|
|
const fp = path.join(dir, p)
|
|
mkdirSync(path.dirname(fp), { recursive: true })
|
|
writeFileSync(fp, "x\n")
|
|
expect(run(dir, "get").stdout.startsWith("MISS\n")).toBe(true)
|
|
}
|
|
}, 30000)
|
|
|
|
test("revert of a dirtied input restores the HIT (recompute determinism)", () => {
|
|
const dir = makeRepo()
|
|
const orig = '{"name":"x","version":"1.0.0"}\n'
|
|
putProfile(dir)
|
|
writeFileSync(path.join(dir, "package.json"), '{"name":"x","version":"9"}\n')
|
|
expect(run(dir, "get").stdout.startsWith("MISS\n")).toBe(true)
|
|
writeFileSync(path.join(dir, "package.json"), orig) // revert → clean again
|
|
expect(run(dir, "get").stdout.startsWith("HIT\n")).toBe(true)
|
|
})
|
|
|
|
test("cached doc with a non-object profile → MISS (get-side shape guard)", () => {
|
|
const dir = makeRepo()
|
|
const cachePath = putProfile(dir)
|
|
const doc = JSON.parse(readFileSync(cachePath, "utf8"))
|
|
doc.profile = null // externally corrupted / poisoned
|
|
writeFileSync(cachePath, JSON.stringify(doc))
|
|
expect(run(dir, "get").stdout.startsWith("MISS\n")).toBe(true)
|
|
})
|
|
})
|