diff --git a/packages/app-builder/generation/src/index.ts b/packages/app-builder/generation/src/index.ts index 1a55ae3e..793a4e04 100644 --- a/packages/app-builder/generation/src/index.ts +++ b/packages/app-builder/generation/src/index.ts @@ -3,6 +3,7 @@ export * as GenerationKernel from "./kernel.js" export { composeCatalog } from "./catalog.js" export * as Intent from "./intent.js" export * as Planner from "./planner.js" +export * as Pr130Regeneration from "./regeneration.js" export * as Replay from "./replay.js" export * as TodoEvolution from "./evolution.js" export * as TodoGeneration from "./generators/index.js" diff --git a/packages/app-builder/generation/src/regeneration.ts b/packages/app-builder/generation/src/regeneration.ts new file mode 100644 index 00000000..86673f75 --- /dev/null +++ b/packages/app-builder/generation/src/regeneration.ts @@ -0,0 +1,89 @@ +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" + +export const Pr130EvidenceClass = Schema.Literals([ + "unit-1-replay-completeness", + "unit-2-typed-kernel-catalog", + "unit-3-generation-surfaces", + "unit-4-atomic-todo-materialization", + "unit-5-todo-v1-parity", + "unit-6-public-cli-lifecycle", +]) +export type Pr130EvidenceClass = typeof Pr130EvidenceClass.Type +const CanonicalEvidenceClasses = Object.freeze(Pr130EvidenceClass.literals) + +export const Pr130FrozenHead = "d5928f99a122b04cb70ff68bfa02fa2d5648a5fc" + +export const Pr130ExpectedEvidenceIdentities: Readonly> = Object.freeze({ + "unit-1-replay-completeness": "613a9654a183910956cc72153f7f8c33b3a78282", + "unit-2-typed-kernel-catalog": "b85aabf05d6cb800e017cd5f4dc4e2ccc6a03e7f", + "unit-3-generation-surfaces": "76aab96389f0f0c7c4ab02b20dbf53b7e60abcfa", + "unit-4-atomic-todo-materialization": "21b92cc564ac806a82e50640552fdf8f0d6d674a", + "unit-5-todo-v1-parity": "2ad43e0cca44a0ed34db27fa29b344a71fc8e214", + "unit-6-public-cli-lifecycle": "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", +}) + +export const Pr130RegenerationProof = Schema.Struct({ + version: Schema.Literal("effectify.app-builder-pr130-regeneration-proof/1"), + evidence: Schema.Array( + Schema.Struct({ + evidenceClass: Pr130EvidenceClass, + identity: Schema.String, + }), + ), + pr130Head: Schema.String, +}) +export type Pr130RegenerationProof = typeof Pr130RegenerationProof.Type + +export const Pr130RegenerationBlockReason = Schema.TaggedUnion({ + MissingEvidence: { evidenceClass: Pr130EvidenceClass }, + MismatchedEvidence: { + actualIdentities: Schema.Array(Schema.String), + evidenceClass: Pr130EvidenceClass, + expectedIdentity: Schema.String, + }, + WrongPr130Head: { actualHead: Schema.String, expectedHead: Schema.String }, +}) +export type Pr130RegenerationBlockReason = typeof Pr130RegenerationBlockReason.Type + +export class Pr130RegenerationBlocked extends Data.TaggedError("Pr130RegenerationBlocked")<{ + readonly reasons: ReadonlyArray +}> {} + +export interface Pr130RegenerationEligibility { + readonly prNumber: 130 + readonly status: "eligible" +} + +const eligibility: Pr130RegenerationEligibility = Object.freeze({ prNumber: 130, status: "eligible" }) + +/** Evaluates canonical evidence only; it owns no external read or mutation capability. */ +export const requirePr130RegenerationEligibility = ( + proof: Pr130RegenerationProof, +): Effect.Effect => { + const reasons: Array = [] + for (const evidenceClass of CanonicalEvidenceClasses) { + const expectedIdentity = Pr130ExpectedEvidenceIdentities[evidenceClass] + const actualIdentities = proof.evidence + .filter((evidence) => evidence.evidenceClass === evidenceClass) + .map((evidence) => evidence.identity) + .sort() + if (actualIdentities.length === 0) { + reasons.push({ _tag: "MissingEvidence", evidenceClass }) + } else if (actualIdentities.length !== 1 || actualIdentities[0] !== expectedIdentity) { + reasons.push({ + _tag: "MismatchedEvidence", + actualIdentities: Object.freeze(actualIdentities), + evidenceClass, + expectedIdentity, + }) + } + } + if (proof.pr130Head !== Pr130FrozenHead) { + reasons.push({ _tag: "WrongPr130Head", actualHead: proof.pr130Head, expectedHead: Pr130FrozenHead }) + } + return reasons.length === 0 + ? Effect.succeed(eligibility) + : Effect.fail(new Pr130RegenerationBlocked({ reasons: Object.freeze(reasons) })) +} diff --git a/packages/app-builder/generation/tests/regeneration.test.ts b/packages/app-builder/generation/tests/regeneration.test.ts new file mode 100644 index 00000000..6a038cc7 --- /dev/null +++ b/packages/app-builder/generation/tests/regeneration.test.ts @@ -0,0 +1,161 @@ +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Result from "effect/Result" +import * as Generation from "../src/index.js" +import { + Pr130EvidenceClass, + Pr130FrozenHead, + type Pr130RegenerationProof, + requirePr130RegenerationEligibility, +} from "../src/regeneration.js" + +const ExpectedCanonicalEvidence: Pr130RegenerationProof["evidence"] = Object.freeze([ + { + evidenceClass: "unit-1-replay-completeness", + identity: "613a9654a183910956cc72153f7f8c33b3a78282", + }, + { + evidenceClass: "unit-2-typed-kernel-catalog", + identity: "b85aabf05d6cb800e017cd5f4dc4e2ccc6a03e7f", + }, + { + evidenceClass: "unit-3-generation-surfaces", + identity: "76aab96389f0f0c7c4ab02b20dbf53b7e60abcfa", + }, + { + evidenceClass: "unit-4-atomic-todo-materialization", + identity: "21b92cc564ac806a82e50640552fdf8f0d6d674a", + }, + { + evidenceClass: "unit-5-todo-v1-parity", + identity: "2ad43e0cca44a0ed34db27fa29b344a71fc8e214", + }, + { + evidenceClass: "unit-6-public-cli-lifecycle", + identity: "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", + }, +]) +const ExpectedCanonicalClasses = Object.freeze(ExpectedCanonicalEvidence.map(({ evidenceClass }) => evidenceClass)) +const ExpectedPr130Head = "d5928f99a122b04cb70ff68bfa02fa2d5648a5fc" + +const completeProof = (): Pr130RegenerationProof => ({ + version: "effectify.app-builder-pr130-regeneration-proof/1", + evidence: ExpectedCanonicalEvidence.map((evidence) => ({ ...evidence })), + pr130Head: ExpectedPr130Head, +}) + +it.effect("incomplete canonical proof blocks PR #130 regeneration before effects", () => + Effect.gen(function* () { + const failure = yield* requirePr130RegenerationEligibility({ + ...completeProof(), + evidence: [], + }).pipe(Effect.flip) + + expect(failure).toMatchObject({ _tag: "Pr130RegenerationBlocked" }) + expect(failure.reasons).toEqual( + ExpectedCanonicalClasses.map((evidenceClass) => ({ _tag: "MissingEvidence", evidenceClass })), + ) + }), +) + +it.effect("each canonical Unit evidence class is independently required", () => + Effect.gen(function* () { + for (const missing of ExpectedCanonicalClasses) { + const proof = completeProof() + const failure = yield* requirePr130RegenerationEligibility({ + ...proof, + evidence: proof.evidence.filter(({ evidenceClass }) => evidenceClass !== missing), + }).pipe(Effect.flip) + + expect(failure.reasons).toEqual([{ _tag: "MissingEvidence", evidenceClass: missing }]) + } + }), +) + +it.effect("mismatched evidence identities block PR #130 regeneration deterministically", () => + Effect.gen(function* () { + const evidence = completeProof() + .evidence.map(({ evidenceClass }) => ({ evidenceClass, identity: `stale:${evidenceClass}` })) + .reverse() + const failure = yield* requirePr130RegenerationEligibility({ ...completeProof(), evidence }).pipe(Effect.flip) + + expect(failure.reasons).toEqual( + ExpectedCanonicalEvidence.map(({ evidenceClass, identity }) => ({ + _tag: "MismatchedEvidence", + actualIdentities: [`stale:${evidenceClass}`], + evidenceClass, + expectedIdentity: identity, + })), + ) + }), +) + +it.effect("wrong frozen PR #130 head blocks eligibility", () => + Effect.gen(function* () { + const failure = yield* requirePr130RegenerationEligibility({ + ...completeProof(), + pr130Head: "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", + }).pipe(Effect.flip) + + expect(failure.reasons).toEqual([ + { + _tag: "WrongPr130Head", + actualHead: "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", + expectedHead: Pr130FrozenHead, + }, + ]) + }), +) + +it.effect("complete canonical proof returns regeneration eligibility only", () => + Effect.gen(function* () { + const proof = Object.freeze({ + ...completeProof(), + evidence: Object.freeze(completeProof().evidence.map((entry) => Object.freeze(entry))), + }) + const before = structuredClone(proof) + const eligibility = yield* requirePr130RegenerationEligibility(proof) + + expect(eligibility).toEqual({ prNumber: 130, status: "eligible" }) + expect(proof).toEqual(before) + }), +) + +it("frozen PR #130 head remains typed input evidence only", () => { + const source = readFileSync(fileURLToPath(new URL("../src/regeneration.ts", import.meta.url)), "utf8") + + expect(Pr130FrozenHead).toBe("d5928f99a122b04cb70ff68bfa02fa2d5648a5fc") + expect(source).not.toMatch(/node:|github|octokit|\bfetch\b|Effect\.(promise|tryPromise)/i) +}) + +it.effect("public evidence metadata cannot weaken the canonical Unit 6 requirement", () => + Effect.gen(function* () { + const publicClasses = Pr130EvidenceClass.literals + const original = [...publicClasses] + const deleted = Reflect.deleteProperty(publicClasses, "5") + const shortened = Reflect.set(publicClasses, "length", 5) + const outcome = yield* Effect.result( + requirePr130RegenerationEligibility({ + ...completeProof(), + evidence: ExpectedCanonicalEvidence.filter( + ({ evidenceClass }) => evidenceClass !== "unit-6-public-cli-lifecycle", + ), + }), + ) + for (const [index, evidenceClass] of original.entries()) { + Reflect.set(publicClasses, String(index), evidenceClass) + } + Reflect.set(publicClasses, "length", original.length) + + expect(Result.isFailure(outcome)).toBe(true) + if (Result.isFailure(outcome)) { + expect(outcome.failure.reasons).toEqual([ + { _tag: "MissingEvidence", evidenceClass: "unit-6-public-cli-lifecycle" }, + ]) + } + expect("Pr130EvidenceClasses" in Generation.Pr130Regeneration).toBe(false) + expect([deleted, shortened, Object.isFrozen(publicClasses)]).toEqual([false, false, true]) + }), +)