diff --git a/apps/agent/package.json b/apps/agent/package.json index b5f171a..fead71b 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -18,7 +18,7 @@ "@dspack-studio/contracts": "workspace:*", "@dspack-studio/replay": "workspace:*", "@aestheticfunction/dspack-emit": "^0.4.1", - "@aestheticfunction/dspack-export": "^0.4.0", + "@aestheticfunction/dspack-export": "^0.5.0", "@aestheticfunction/dspack-spec": "^0.4.2", "@dspack-studio/composer-core": "workspace:*" }, diff --git a/apps/agent/src/project.test.ts b/apps/agent/src/project.test.ts index 47b8243..b734447 100644 --- a/apps/agent/src/project.test.ts +++ b/apps/agent/src/project.test.ts @@ -116,16 +116,110 @@ describe("rediscover", () => { ); const { status, payload } = await call("rediscover", { path: root }); expect(status).toBe(200); - expect(payload.report.addedComponents).toContain("spark-line"); - // Human-owned components section preserved: enrichment survives the merge. - expect(payload.report.preservedHumanOwned).toContain("components"); + // The demo project ships a v1 ledger: this first rediscovery migrates it + // (human-owned components section -> entries unattributed, byte-identical + // ones re-adopted as tool-owned). Migration cannot distinguish + // "hand-deleted" from "new since the snapshot", so the fresh-only id + // ASKS instead of silently adding. + expect(payload.report.migration).toBe("human-owned"); + expect(payload.contract.metadata["x-bootstrap"].ledger).toBe("2"); + expect(payload.report.components.added).toEqual([]); + expect(payload.report.components.deletedAwaitingDecision).toContain("spark-line"); + expect(payload.contract.components["spark-line"]).toBeUndefined(); + // Human-owned entries preserved verbatim: enrichment survives the merge. expect(payload.contract.components["action-button"].props.label.required).toBe(true); expect(payload.contract.components["action-button"].whenToUse).toBeTruthy(); // Governance carried over verbatim. expect(payload.contract.rules.length).toBeGreaterThan(0); - // Ledger still reports the section human-owned after the merge. + // Section state derives from entries under v2: enrichment keeps it human-owned. const byName = Object.fromEntries(payload.ledger.sections.map((s: any) => [s.section, s.state])); expect(byName.components).toBe("human-owned"); + expect(payload.ledger.entryLevel).toBe(true); + // Restoring the genuinely-new component is one explicit decision. + const restored = await call("rediscover", { path: root, restoreTopLevel: ["spark-line"] }); + expect(restored.status).toBe(200); + expect(restored.payload.report.components.restoredTopLevel).toEqual([{ id: "spark-line" }]); + expect(restored.payload.contract.components["spark-line"]).toBeDefined(); + const entries = Object.fromEntries(restored.payload.ledger.componentEntries.map((e: any) => [e.id, e.state])); + expect(entries["spark-line"]).toBe("tool-owned"); // restored tool-owned + expect(["human-owned", "unattributed"]).toContain(entries["action-button"]); // enriched, yours + }); + + it("refuses a malformed restoreTopLevel with 400 before touching the project", async () => { + const bad = await call("rediscover", { path: root, restoreTopLevel: [42] }); + expect(bad.status).toBe(400); + expect(bad.payload.error).toContain("array of component id strings"); + }); + + it("skip-and-ask: a hand-deleted entry is never silently restored; tombstoning ends the asking", async () => { + const { writeFileSync } = await import("node:fs"); + // Hand-delete spark-line from the document (the ledger hash remains). + const contract = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")); + delete contract.components["spark-line"]; + writeFileSync(join(root, "acme-ui.dspack.json"), JSON.stringify(contract, null, 2) + "\n"); + + // Rediscovery: the source still has spark-line, but restoration is skipped. + const first = await call("rediscover", { path: root }); + expect(first.status).toBe(200); + expect(first.payload.contract.components["spark-line"]).toBeUndefined(); + expect(first.payload.report.components.deletedAwaitingDecision).toContain("spark-line"); + const orphan = first.payload.ledger.componentEntries.find((e: any) => e.id === "spark-line"); + expect(orphan.state).toBe("orphaned"); // deletion memory survives the merge + + // Decide: tombstone it (what the composer's "Never rediscover" button saves). + const decided = structuredClone(first.payload.contract); + decided.metadata["x-bootstrap"].doNotRediscover = ["spark-line"]; + delete decided.metadata["x-bootstrap"].components["spark-line"]; + const saved = await call("save", { path: root, kind: "contract", document: decided }); + expect(saved.payload.ok).toBe(true); + + // Rediscovery now skips it unambiguously, and keeps skipping it. + const second = await call("rediscover", { path: root }); + expect(second.status).toBe(200); + expect(second.payload.report.components.suppressed).toContain("spark-line"); + expect(second.payload.report.components.deletedAwaitingDecision).not.toContain("spark-line"); + expect(second.payload.contract.components["spark-line"]).toBeUndefined(); + expect(second.payload.ledger.componentEntries.find((e: any) => e.id === "spark-line").state).toBe("tombstoned"); + }); + + it("restoredConflict: authored sub-component blocks re-add until the explicit restore-top-level intent", async () => { + // Undo the tombstone and author spark-line as a sub-component of + // action-button (the #13 restructure shape, through the real routes). + const contract = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")); + contract.metadata["x-bootstrap"].doNotRediscover = []; + contract.components["action-button"].composition = { + subComponents: [{ id: "spark-line", name: "SparkLine", description: "Inline trend inside the button." }], + }; + const saved = await call("save", { path: root, kind: "contract", document: contract }); + expect(saved.payload.ok).toBe(true); + + // Outcome 3 first (leave unresolved): reported, never re-added. + const unresolved = await call("rediscover", { path: root }); + expect(unresolved.status).toBe(200); + // The shipped demo project carries its own #13-shaped conflicts + // (info-card sub-vocabulary discovered top-level in source), so assert + // on spark-line specifically rather than the whole list. + expect(unresolved.payload.report.components.restoredConflict).toContainEqual({ id: "spark-line", parent: "action-button" }); + expect(unresolved.payload.contract.components["spark-line"]).toBeUndefined(); + + // A contradictory intent refuses with the tool's words (nothing partial). + const contradicted = await call("rediscover", { path: root, restoreTopLevel: ["not-in-source"] }); + expect(contradicted.status).toBe(409); + expect(contradicted.payload.error).toContain("not-in-source"); + + // Outcome 2: the explicit intent restores tool-owned, nested preserved. + const restored = await call("rediscover", { path: root, restoreTopLevel: ["spark-line"] }); + expect(restored.status).toBe(200); + expect(restored.payload.report.components.restoredTopLevel).toEqual([{ id: "spark-line", parent: "action-button" }]); + expect(restored.payload.report.components.restoredConflict.map((x: any) => x.id)).not.toContain("spark-line"); + expect(restored.payload.contract.components["spark-line"]).toBeDefined(); + expect(restored.payload.contract.components["action-button"].composition.subComponents[0].id).toBe("spark-line"); + expect(restored.payload.ledger.componentEntries.find((e: any) => e.id === "spark-line").state).toBe("tool-owned"); + + // Subsequent runs treat it as ordinary tool-owned; the conflict is gone. + const after = await call("rediscover", { path: root }); + expect(after.payload.report.components.restoredConflict.map((x: any) => x.id)).not.toContain("spark-line"); + expect(after.payload.report.components.unchanged).toContain("spark-line"); }); }); diff --git a/apps/agent/src/project.ts b/apps/agent/src/project.ts index 77b7040..60cc8a5 100644 --- a/apps/agent/src/project.ts +++ b/apps/agent/src/project.ts @@ -229,12 +229,19 @@ async function discover(ctx: ProjectContext) { * Section-scoped rediscovery: fresh extraction merged at the ledger's * granularity (dspack-export regenerateSections) — tool-owned refreshes, * human-owned and governance preserved, new components added. + * `restoreTopLevel` passes explicit owner intents through (the ratified + * "Restore top-level" conflict resolution); the tool's refusals are + * returned verbatim. */ -async function rediscover(ctx: ProjectContext) { +async function rediscover(ctx: ProjectContext, body: Record) { const config = exportConfig(ctx); if (!existsSync(ctx.contractPath)) { throw new ProjectError(409, "no contract exists yet; run discovery first"); } + const restoreTopLevel = body.restoreTopLevel; + if (restoreTopLevel !== undefined && (!Array.isArray(restoreTopLevel) || restoreTopLevel.some((id) => typeof id !== "string"))) { + throw new ProjectError(400, "restoreTopLevel must be an array of component id strings"); + } const existing = readJson(ctx.contractPath) as Parameters[0]; let fresh; try { @@ -242,8 +249,15 @@ async function rediscover(ctx: ProjectContext) { } catch (e) { throw new ProjectError(409, (e instanceof Error ? e.message : String(e)).trim()); } - const result = regenerateSections(existing, fresh); + const result = regenerateSections(existing, fresh, restoreTopLevel ? { restoreTopLevel: restoreTopLevel as string[] } : undefined); if (!result.ok) throw new ProjectError(409, result.reason); + // One-validator principle: every contract write passes the same harness + // gate as /project/save — a merge that produced an invalid document is + // refused, not persisted. + const report = documentReport(result.document as unknown as Record, specValidators()); + if (!report.valid) { + throw new ProjectError(409, `rediscovery produced a document the harness rejects; nothing was written: ${report.errors.join("; ")}`); + } atomicWriteJson(ctx.contractPath, result.document); const contract = result.document as unknown as Record; return { ok: true, contract, ledger: await ledgerStatus(contract), report: result.report }; @@ -473,7 +487,7 @@ export async function handleProjectRoute( json(res, 200, await discover(ctx), cors); return true; case "rediscover": - json(res, 200, await rediscover(ctx), cors); + json(res, 200, await rediscover(ctx, body), cors); return true; case "emit": json(res, 200, emit(ctx), cors); diff --git a/apps/composer/app/agent-client.ts b/apps/composer/app/agent-client.ts index ed7bb1d..5134ff0 100644 --- a/apps/composer/app/agent-client.ts +++ b/apps/composer/app/agent-client.ts @@ -61,17 +61,39 @@ export async function probeAgent(): Promise { } } +/** + * dspack-export 0.5.0's RegenerateReport (shape owned there). The entry- + * level classes are the ratified regeneration-state table; every one is + * rendered, none is acted on without an explicit human decision. + */ export interface RediscoverReport { refreshed: string[]; preservedHumanOwned: string[]; keptMissingInFresh: string[]; - addedComponents: string[]; + migration?: "tool-owned" | "human-owned"; + components: { + added: string[]; + refreshed: string[]; + unchanged: string[]; + readopted: string[]; + preservedEnriched: Array<{ id: string; freshDelta: Array<{ path: string; fresh: unknown }> }>; + removedWithSource: string[]; + keptMissingInFresh: string[]; + deletedAwaitingDecision: string[]; + suppressed: string[]; + suppressedButPresent: string[]; + restoredConflict: Array<{ id: string; parent: string }>; + restoredTopLevel: Array<{ id: string; parent?: string }>; + }; } export const agentConnect = (path: string) => post("/project/connect", { path }); export const agentDiscover = (path: string) => post<{ ok: boolean; log: string; contract: Record; ledger: LedgerStatus }>("/project/discover", { path }); -export const agentRediscover = (path: string) => - post<{ ok: boolean; contract: Record; ledger: LedgerStatus; report: RediscoverReport }>("/project/rediscover", { path }); +export const agentRediscover = (path: string, restoreTopLevel?: string[]) => + post<{ ok: boolean; contract: Record; ledger: LedgerStatus; report: RediscoverReport }>( + "/project/rediscover", + restoreTopLevel ? { path, restoreTopLevel } : { path }, + ); export const agentEmit = (path: string) => post("/project/emit", { path }); export const agentValidate = (path: string) => post("/project/validate", { path }); export const agentSave = (path: string, kind: "contract" | "profile", document: unknown) => diff --git a/apps/composer/app/state.tsx b/apps/composer/app/state.tsx index fef27ca..8ef9f2f 100644 --- a/apps/composer/app/state.tsx +++ b/apps/composer/app/state.tsx @@ -8,8 +8,18 @@ * edits live in memory only. * Files are the source of truth; this state is a view of them. */ -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; -import { ledgerStatus, type ComposerFinding, type LedgerStatus, type ProjectManifest } from "@dspack-studio/composer-core"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + addTombstone, + applyFreshFact, + ledgerStatus, + removeTombstone, + restoreComponent, + type ComposerFinding, + type FreshFact, + type LedgerStatus, + type ProjectManifest, +} from "@dspack-studio/composer-core"; import { agentConnect, agentDiscover, @@ -18,6 +28,7 @@ import { agentSave, probeAgent, type EmitPayload, + type RediscoverReport, type ValidatePayload, } from "./agent-client"; import { browserEmit, contractSurfaces, lintOneSurface, validateContract } from "./validation"; @@ -33,6 +44,8 @@ export interface ComposerState { contract: Record | null; profile: Record | null; ledger: LedgerStatus | null; + /** The last rediscovery's full report; review surface, never auto-acted. */ + rediscovery: RediscoverReport | null; emit: EmitPayload | null; validate: ValidatePayload | null; busy: string | null; @@ -45,6 +58,13 @@ export interface ComposerState { rediscover: () => Promise; saveContract: (doc: Record) => Promise; saveProfile: (doc: Record) => Promise; + /** Explicit deletion decisions (ledger v2): restore or tombstone an id. */ + resolveDeletion: (id: string, decision: "restore" | "tombstone") => Promise; + /** Explicit restoredConflict decisions, phrased as intent (ratified). */ + resolveConflict: (id: string, decision: "keep-nested" | "restore-top-level") => Promise; + clearTombstone: (id: string) => Promise; + /** Explicit acceptance of one fresh-side fact into a human-owned entry. */ + acceptFreshFact: (componentId: string, fact: FreshFact) => Promise; runEmit: () => Promise; runValidate: () => void; } @@ -64,12 +84,18 @@ export function ComposerProvider({ children }: { children: ReactNode }) { const [contract, setContract] = useState | null>(null); const [profile, setProfile] = useState | null>(null); const [ledger, setLedger] = useState(null); + const [rediscovery, setRediscovery] = useState(null); const [emit, setEmit] = useState(null); const [validate, setValidate] = useState(null); const [busy, setBusy] = useState(null); const [notice, setNotice] = useState(null); const [selected, setSelected] = useState(null); const [extraSurfaces, setExtraSurfaces] = useState>([]); + // Serializes the ledger-decision actions: two rapid clicks would otherwise + // both compute from the same stale contract closure and the second save + // would silently drop the first decision. A ref, not state — the guard + // must hold before React re-renders the disabled buttons. + const decisionLock = useRef(false); useEffect(() => { void probeAgent().then(setAgentUp); @@ -107,6 +133,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) { setProfile(prof); setExtraSurfaces(DEMO_EXTRA_SURFACES); recomputeEmit(doc, prof, DEMO_EXTRA_SURFACES); + setRediscovery(null); setValidate(null); setSelected(null); setNotice("Demo project loaded. Edits stay in memory and every gate runs live in this browser; run the local agent to work on real files."); @@ -137,6 +164,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) { setLedger(v.ledger); setExtraSurfaces(v.extraSurfaces ?? []); recomputeEmit(doc, prof, v.extraSurfaces ?? []); + setRediscovery(null); setValidate(null); setSelected(null); setNotice(v.profileIssue ? `Connected. Profile issue: ${v.profileIssue}` : `Connected to ${path}.`); @@ -159,6 +187,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) { } setContract(result.value.contract as Record); setLedger(result.value.ledger); + setRediscovery(null); recomputeEmit(result.value.contract as Record, profile); setNotice(`Discovery complete: ${result.value.log}`); }, [mode, projectPath, profile, recomputeEmit]); @@ -183,12 +212,15 @@ export function ComposerProvider({ children }: { children: ReactNode }) { const v = result.value; setContract(v.contract as Record); setLedger(v.ledger); + setRediscovery(v.report); recomputeEmit(v.contract as Record, profile); - const r = v.report; + const c = v.report.components; + const decisions = c.deletedAwaitingDecision.length + c.restoredConflict.length; setNotice( - `Rediscovery merged: refreshed [${r.refreshed.join(", ") || "none"}]; preserved human-owned [${r.preservedHumanOwned.join(", ") || "none"}]` + - (r.addedComponents.length ? `; new components added: ${r.addedComponents.join(", ")}` : "") + - (r.keptMissingInFresh.length ? `; kept despite missing in fresh: ${r.keptMissingInFresh.join(", ")}` : ""), + `Rediscovery merged per entry: ${c.added.length} added, ${c.refreshed.length} refreshed, ${c.unchanged.length} unchanged, ` + + `${c.preservedEnriched.length} preserved human-owned, ${c.removedWithSource.length} removed with source` + + (v.report.migration ? ` (ledger migrated to v2, ${v.report.migration} section)` : "") + + (decisions ? ` — ${decisions} decision(s) awaiting you below.` : "."), ); }, [mode, projectPath, profile, recomputeEmit]); @@ -244,6 +276,159 @@ export function ComposerProvider({ children }: { children: ReactNode }) { [mode, projectPath], ); + /** + * Ledger-v2 decisions. Every one is a named human action on the contract + * document — the tool computes the edit, the person authorizes it, the + * ordinary save path (ledger-preserving, shape-gated) persists it. + */ + const resolveDeletion = useCallback( + async (id: string, decision: "restore" | "tombstone") => { + if (!contract || decisionLock.current) return; + decisionLock.current = true; + try { + const result = decision === "restore" ? restoreComponent(contract, id) : addTombstone(contract, id); + if (!result.ok) { + setNotice(`Cannot ${decision} '${id}': ${result.reason}`); + return; + } + await saveContract(result.document); + setRediscovery((r) => + r + ? { + ...r, + components: { + ...r.components, + deletedAwaitingDecision: r.components.deletedAwaitingDecision.filter((d) => d !== id), + ...(decision === "tombstone" ? { suppressed: [...r.components.suppressed, id] } : {}), + }, + } + : r, + ); + setNotice( + decision === "restore" + ? `'${id}' will be restored from source on the next rediscovery (deletion memory cleared).` + : `'${id}' tombstoned: rediscovery will never re-add it. Remove the tombstone from the Ownership panel to undo.`, + ); + } finally { + decisionLock.current = false; + } + }, + [contract, saveContract], + ); + + /** + * The ratified restoredConflict outcomes, phrased as intent: + * - keep nested: tombstone the id + retire the memory (a document edit + * saved through the ordinary ledger-preserving path); the conflict + * stops reporting on subsequent rediscoveries. + * - restore top-level: a one-shot explicit intent passed to the tool — + * the entry returns from fresh extraction as tool-owned alongside the + * nested authored one. Refusals are the tool's words verbatim. + * Not calling either is the third outcome: nothing changes, the memory + * and the report persist. + */ + const resolveConflict = useCallback( + async (id: string, decision: "keep-nested" | "restore-top-level") => { + if (!contract || decisionLock.current) return; + decisionLock.current = true; + try { + if (decision === "keep-nested") { + const result = addTombstone(contract, id); + if (!result.ok) { + setNotice(`Cannot keep '${id}' nested: ${result.reason}`); + return; + } + await saveContract(result.document); + setRediscovery((r) => + r + ? { + ...r, + components: { + ...r.components, + restoredConflict: r.components.restoredConflict.filter((c) => c.id !== id), + suppressed: [...r.components.suppressed, id], + }, + } + : r, + ); + setNotice(`Keeping '${id}' nested: rediscovery will never re-add the top-level entry (tombstoned; removable in the Ownership panel).`); + return; + } + if (mode !== "agent") { + setNotice("Restoring the top-level entry re-runs dspack-export on your machine; connect through the local agent first."); + return; + } + setBusy("restoring"); + const result = await agentRediscover(projectPath, [id]); + setBusy(null); + if (!result.ok) { + setNotice(`Restore refused: ${result.error}`); + return; + } + const v = result.value; + setContract(v.contract as Record); + setLedger(v.ledger); + setRediscovery(v.report); + recomputeEmit(v.contract as Record, profile); + setNotice(`'${id}' restored as a top-level component (tool-owned); your nested representation is untouched — both now exist.`); + } finally { + decisionLock.current = false; + } + }, + [contract, mode, projectPath, profile, recomputeEmit, saveContract], + ); + + const clearTombstone = useCallback( + async (id: string) => { + if (!contract || decisionLock.current) return; + decisionLock.current = true; + try { + const result = removeTombstone(contract, id); + if (!result.ok) { + setNotice(`Cannot remove tombstone '${id}': ${result.reason}`); + return; + } + await saveContract(result.document); + setNotice(`Tombstone removed: the next rediscovery may re-add '${id}'.`); + } finally { + decisionLock.current = false; + } + }, + [contract, saveContract], + ); + + const acceptFreshFact = useCallback( + async (componentId: string, fact: FreshFact) => { + if (!contract || decisionLock.current) return; + decisionLock.current = true; + try { + const result = applyFreshFact(contract, componentId, fact); + if (!result.ok) { + setNotice(`Cannot accept ${fact.path} on '${componentId}': ${result.reason}`); + return; + } + await saveContract(result.document); + setRediscovery((r) => + r + ? { + ...r, + components: { + ...r.components, + preservedEnriched: r.components.preservedEnriched.map((p) => + p.id === componentId ? { ...p, freshDelta: p.freshDelta.filter((f) => f.path !== fact.path) } : p, + ), + }, + } + : r, + ); + setNotice(`Accepted ${fact.path} into '${componentId}' (the entry stays human-owned).`); + } finally { + decisionLock.current = false; + } + }, + [contract, saveContract], + ); + const runEmit = useCallback(async () => { if (mode !== "agent") { setNotice("Live re-emission runs dspack-emit on your files — the demo shows the build-time emit. Connect through the local agent to re-emit."); @@ -283,6 +468,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) { contract, profile, ledger, + rediscovery, emit, validate, busy, @@ -295,10 +481,14 @@ export function ComposerProvider({ children }: { children: ReactNode }) { rediscover, saveContract, saveProfile, + resolveDeletion, + resolveConflict, + clearTombstone, + acceptFreshFact, runEmit, runValidate, }), - [mode, agentUp, projectPath, manifest, contract, profile, ledger, emit, validate, busy, notice, selected, connect, loadDemo, discover, rediscover, saveContract, saveProfile, runEmit, runValidate], + [mode, agentUp, projectPath, manifest, contract, profile, ledger, rediscovery, emit, validate, busy, notice, selected, connect, loadDemo, discover, rediscover, saveContract, saveProfile, resolveDeletion, resolveConflict, clearTombstone, acceptFreshFact, runEmit, runValidate], ); return {children}; diff --git a/apps/composer/app/views/inventory-view.tsx b/apps/composer/app/views/inventory-view.tsx index 292ce4a..1a56ff4 100644 --- a/apps/composer/app/views/inventory-view.tsx +++ b/apps/composer/app/views/inventory-view.tsx @@ -8,10 +8,13 @@ import { useComposer } from "../state"; * the profile), renderable/validated read from the latest emit. */ export function InventoryView({ onOpen }: { onOpen: () => void }) { - const { contract, profile, emit, setSelected } = useComposer(); + const { contract, profile, emit, ledger, setSelected } = useComposer(); if (!contract) return

No contract loaded.

; const components = Object.entries((contract.components ?? {}) as Record); + // Ledger v2 only: per-entry ownership (v1 documents show section-level + // ownership on the Project view instead of inventing entry states here). + const ownership = new Map((ledger?.componentEntries ?? []).map((e) => [e.id, e])); const plans = new Map((profile?.components ?? []).map((p: any) => [p.dspackId, p])); const casualties = new Map((profile?.casualtyComponents ?? []).map((c: any) => [c.dspackId, c])); const coverageFindings = new Set((emit?.findings ?? []).filter((f) => f.gate === "coverage").map((f) => f.target)); @@ -59,6 +62,17 @@ export function InventoryView({ onOpen }: { onOpen: () => void }) { {entry.whenToUse ? chip("described", "var(--ok)") : chip("bare", "var(--fg-faint)")} {props.length === 0 && chip("needs props", "var(--warn)")} + {(() => { + const own = ownership.get(id); + if (!own) return null; + const label = own.state === "unattributed" ? "yours" : own.state === "human-owned" ? "yours" : own.state; + return ( + <> + {chip(label, own.state === "tool-owned" ? "var(--info)" : "var(--ok)")} + {own.alsoTombstoned && chip("tombstoned", "var(--warn)")} + + ); + })()} {casualty diff --git a/apps/composer/app/views/project-view.tsx b/apps/composer/app/views/project-view.tsx index 9a220c3..ed12fdb 100644 --- a/apps/composer/app/views/project-view.tsx +++ b/apps/composer/app/views/project-view.tsx @@ -8,6 +8,9 @@ const STATE_COLOR: Record = { "tool-owned": "var(--info)", "human-owned": "var(--ok)", "human-authored": "var(--ok)", + unattributed: "var(--ok)", + orphaned: "var(--warn)", + tombstoned: "var(--fg-faint)", absent: "var(--fg-faint)", }; @@ -41,9 +44,119 @@ function progressRows(state: ReturnType): Array<{ label: str ]; } +/** + * The rediscovery report, rendered as decisions rather than a log line. + * Every entry-level class from dspack-export's ratified table appears; + * the ones that need a person (deletions, conflicts, fresh facts on + * enriched entries) carry their explicit actions. Nothing here acts on + * its own — the buttons ARE the acceptance. + */ +function RediscoveryReport() { + const { rediscovery, resolveDeletion, resolveConflict, acceptFreshFact, busy } = useComposer(); + if (!rediscovery) return null; + const c = rediscovery.components; + const line = (label: string, ids: string[], color = "var(--fg-body)") => + ids.length > 0 && ( +
  • + {label}{" "} + {ids.join(", ")} +
  • + ); + const enrichedWithFacts = c.preservedEnriched.filter((p) => p.freshDelta.length > 0); + + return ( +
    +

    + Last rediscovery +

    +
      + {line("added", c.added, "var(--ok)")} + {line("refreshed", c.refreshed, "var(--info)")} + {line("readopted", c.readopted, "var(--info)")} + {line("preserved (yours)", c.preservedEnriched.map((p) => p.id), "var(--ok)")} + {line("removed with source", c.removedWithSource, "var(--warn)")} + {line("kept, missing in source", c.keptMissingInFresh, "var(--warn)")} + {line("skipped (tombstoned)", c.suppressed, "var(--fg-faint)")} + {line("tombstoned but present", c.suppressedButPresent, "var(--warn)")} + {line("restored top-level (both exist)", c.restoredTopLevel.map((x) => (x.parent ? `${x.id} (nested in ${x.parent} kept)` : x.id)), "var(--ok)")} +
    + + {c.deletedAwaitingDecision.length > 0 && ( +
    +

    Deletions awaiting your decision

    +

    + These were deleted from the document but still exist in source. Rediscovery never restores them on its own: + restore to bring one back from source next time, tombstone it so it is never re-added, or decide later — + the memory keeps. +

    + {c.deletedAwaitingDecision.map((id) => ( +
    + {id} + + +
    + ))} +
    + )} + + {c.restoredConflict.length > 0 && ( +
    +

    Restructured, not re-added

    +

    + Each of these exists in source as a top-level component, but you authored it as a sub-component of another + entry. Rediscovery never decides which representation you meant: keep yours nested, restore the top-level + entry alongside it, or decide later — nothing changes until you choose. +

    + {c.restoredConflict.map(({ id, parent }) => ( +
    + + {id} nested in {parent} + + + +
    + ))} +
    + )} + + {enrichedWithFacts.length > 0 && ( +
    +

    Fresh facts on entries you own

    +

    + Review information from the latest extraction — never merged on its own. Accepting writes the one fact into + your entry (which stays yours); anything more than a scalar or a pure addition is authored by hand. +

    + {enrichedWithFacts.map((p) => + p.freshDelta.map((fact) => ( +
    + + {p.id} + {fact.path} ={" "} + {JSON.stringify(fact.fresh)} + + +
    + )), + )} +
    + )} +
    + ); +} + export function ProjectView({ onNavigate }: { onNavigate: (view: View) => void }) { const state = useComposer(); - const { mode, agentUp, manifest, ledger, connect, loadDemo, discover, rediscover, busy } = state; + const { mode, agentUp, manifest, ledger, connect, loadDemo, discover, rediscover, clearTombstone, resolveDeletion, busy } = state; const [path, setPath] = useState(""); const rows = progressRows(state); @@ -85,9 +198,9 @@ export function ProjectView({ onNavigate }: { onNavigate: (view: View) => void } First bootstrap

    - Rediscover merges at the ledger's granularity: tool-owned sections refresh, human-owned sections and - governance are preserved, newly discovered components are added. First bootstrap keeps the whole-file - refusal table; refusals are shown verbatim. + Rediscover merges per component entry (ledger v2): tool-owned entries refresh, entries you edited are + preserved verbatim, new components are added, deleted ones stay deleted until you decide below. First + bootstrap keeps the whole-file refusal table; refusals are shown verbatim.

    )} @@ -132,6 +245,45 @@ export function ProjectView({ onNavigate }: { onNavigate: (view: View) => void } ))} + {ledger.entryLevel && ( + <> +

    Components, per entry

    +

    + Ledger v2: ownership is decided entry by entry. Orphaned means you deleted the entry and the memory of + that deletion is kept — rediscovery skips it until you decide. Tombstoned means never re-add. +

    + + + {ledger.componentEntries.map((e) => ( + + + + + + ))} + +
    {e.id} + {e.state === "unattributed" ? "human-owned" : e.state} + {e.alsoTombstoned ? " · tombstoned" : ""} + + {e.state === "orphaned" && ( + <> + {" "} + + + )} + {(e.state === "tombstoned" || e.alsoTombstoned) && ( + + )} +
    + + )} {ledger.awaitingAuthorship.length > 0 && ( <>

    Awaiting authorship

    @@ -149,6 +301,7 @@ export function ProjectView({ onNavigate }: { onNavigate: (view: View) => void } ) : (

    No contract loaded.

    )} + ); diff --git a/packages/composer-core/fixtures/shadcn-demo.v2.dspack.json b/packages/composer-core/fixtures/shadcn-demo.v2.dspack.json new file mode 100644 index 0000000..f17b56e --- /dev/null +++ b/packages/composer-core/fixtures/shadcn-demo.v2.dspack.json @@ -0,0 +1,318 @@ +{ + "dspack": "0.4", + "name": "Shadcn Demo", + "description": "Demo shadcn-style design system used as the dspack-export golden fixture.", + "version": "1.0.0", + "metadata": { + "generatedBy": "@aestheticfunction/dspack-export@0.5.0", + "generatedAt": "2026-06-10T00:00:00.000Z", + "source": "fixtures/shadcn-demo", + "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", + "x-bootstrap": { + "ledger": "2", + "spec": "0.4", + "generated": { + "tokens": "d031a79c0a10556c38b099145be82d26eed263713c60922efab56338aca69f15", + "components": "452923cc85bea76d7cd784299cf28dcd79b853961a0c65aa7d31cd186c9f74cd", + "frameworkBindings": "9dea8ada96c2e74adcbf96dc3234d13994b88c6a3a96f12a7e68ed354d89bd1e", + "themes": "46510dd813828a275bd601d0f0348934b9cd1b8b29b4cc525538472f3958173f", + "layout": "84240158ff29a8c25fd428fdf24f3409df36710fabef89086d711704bc0130c2" + }, + "components": { + "badge": "8441ce8aef1597b09e3e9ad14e71e1dba5b00e519a90b7e0cfd139263d4085f6", + "button": "115d3bed70bd9ceab5338e9f1e3e379ac61f3b0c35992ee83fe7dd59e6dfb573", + "card": "3c0e8da41c1130bd245a08bea4c9287812cfe449a5e468dd03e7c3c36c459644", + "card-header": "5467b6377f5a356856ab89ba2a45120b89b2e98a1f926b842d95339b5dadcc8a", + "card-title": "b475008cd40f61288baa5bd7af9c7a947bdd0a890756ee865025b2a83f51d479", + "card-content": "6464cf3e1ef6f041a2feb434c95bd74cd6a92ef023f900d4c7b5bf5f05a573df", + "input": "e591c2b2c642c2a169fdead4a85ccc4123a76db88d7cfb918a32f9aca6677a9c" + }, + "doNotRediscover": [], + "awaitingAuthorship": [ + "categories", + "intents", + "rules", + "examples", + "patterns", + "antiPatterns", + "components.*.whenToUse", + "components.*.accessibility", + "components.*.composition", + "components.*.constraints" + ] + } + }, + "tokens": { + "color": { + "description": "Semantic color tokens extracted from CSS custom properties. Values are the default (light) theme.", + "tier": "semantic", + "values": { + "background": { + "value": "hsl(0 0% 100%)", + "type": "color", + "description": "Page background color." + }, + "foreground": { + "value": "hsl(222.2 84% 4.9%)", + "type": "color", + "description": "Default text color on the page background." + }, + "card": { + "value": "hsl(0 0% 100%)", + "type": "color", + "description": "Card surface background." + }, + "card-foreground": { + "value": "hsl(222.2 84% 4.9%)", + "type": "color", + "description": "Text color on card surfaces." + }, + "primary": { + "value": "hsl(222.2 47.4% 11.2%)", + "type": "color", + "description": "Primary brand color for prominent interactive elements." + }, + "primary-foreground": { + "value": "hsl(210 40% 98%)", + "type": "color", + "description": "Text color on primary-colored surfaces." + }, + "secondary": { + "value": "hsl(210 40% 96.1%)", + "type": "color", + "description": "Secondary surface color for less prominent elements." + }, + "secondary-foreground": { + "value": "hsl(222.2 47.4% 11.2%)", + "type": "color", + "description": "Text color on secondary surfaces." + }, + "muted": { + "value": "hsl(210 40% 96.1%)", + "type": "color", + "description": "Muted background for subdued UI regions." + }, + "muted-foreground": { + "value": "hsl(215.4 16.3% 46.9%)", + "type": "color", + "description": "Subdued text color." + }, + "accent": { + "value": "hsl(210 40% 96.1%)", + "type": "color", + "description": "Accent background for hover and highlight states." + }, + "accent-foreground": { + "value": "hsl(222.2 47.4% 11.2%)", + "type": "color", + "description": "Text color on accent surfaces." + }, + "destructive": { + "value": "hsl(0 84.2% 60.2%)", + "type": "color", + "description": "Color for destructive actions and errors." + }, + "destructive-foreground": { + "value": "hsl(210 40% 98%)", + "type": "color", + "description": "Text color on destructive surfaces." + }, + "border": { + "value": "hsl(214.3 31.8% 91.4%)", + "type": "color", + "description": "Default border color." + }, + "input": { + "value": "hsl(214.3 31.8% 91.4%)", + "type": "color", + "description": "Form input border color." + }, + "ring": { + "value": "hsl(222.2 84% 4.9%)", + "type": "color", + "description": "Focus ring color." + } + } + }, + "radius": { + "description": "Border radius tokens extracted from CSS custom properties.", + "tier": "semantic", + "values": { + "radius": { + "value": "0.5rem", + "type": "borderRadius" + } + } + } + }, + "components": { + "badge": { + "name": "Badge", + "description": "Displays a badge or a component that looks like a badge.", + "x-componentKey": "ui/Badge", + "props": { + "variant": { + "type": "enum", + "values": [ + "default", + "secondary", + "destructive", + "outline" + ], + "default": "default", + "propRole": "choice" + } + } + }, + "button": { + "name": "Button", + "description": "Displays a button or a component that looks like a button.", + "x-componentKey": "ui/Button", + "props": { + "variant": { + "type": "enum", + "values": [ + "default", + "destructive", + "outline", + "secondary", + "ghost", + "link" + ], + "default": "default", + "propRole": "choice" + }, + "size": { + "type": "enum", + "values": [ + "default", + "sm", + "lg", + "icon" + ], + "default": "default", + "propRole": "dimension" + }, + "asChild": { + "type": "boolean", + "propRole": "flag", + "description": "Render as the child element via Radix Slot instead of a native button.", + "default": false + } + } + }, + "card": { + "name": "Card", + "description": "Displays a card container with header, content, and footer sections.", + "x-componentKey": "ui/Card" + }, + "card-header": { + "name": "CardHeader", + "description": "Header section of a Card.", + "x-componentKey": "ui/CardHeader" + }, + "card-title": { + "name": "CardTitle", + "description": "Title text within a CardHeader.", + "x-componentKey": "ui/CardTitle" + }, + "card-content": { + "name": "CardContent", + "description": "Main content section of a Card.", + "x-componentKey": "ui/CardContent" + }, + "input": { + "name": "Input", + "description": "Displays a form input field.", + "x-componentKey": "ui/Input" + } + }, + "frameworkBindings": { + "react": { + "name": "React", + "components": { + "badge": { + "importPath": "./components/ui/badge", + "exportName": "Badge" + }, + "button": { + "importPath": "./components/ui/button", + "exportName": "Button" + }, + "card": { + "importPath": "./components/ui/card", + "exportName": "Card" + }, + "card-header": { + "importPath": "./components/ui/card", + "exportName": "CardHeader" + }, + "card-title": { + "importPath": "./components/ui/card", + "exportName": "CardTitle" + }, + "card-content": { + "importPath": "./components/ui/card", + "exportName": "CardContent" + }, + "input": { + "importPath": "./components/ui/input", + "exportName": "Input" + } + } + } + }, + "themes": { + "dark": { + "name": "Dark", + "description": "Dark theme overrides extracted from the .dark CSS block.", + "overrides": { + "color.background": "hsl(222.2 84% 4.9%)", + "color.foreground": "hsl(210 40% 98%)", + "color.card": "hsl(222.2 84% 4.9%)", + "color.card-foreground": "hsl(210 40% 98%)", + "color.primary": "hsl(210 40% 98%)", + "color.primary-foreground": "hsl(222.2 47.4% 11.2%)", + "color.secondary": "hsl(217.2 32.6% 17.5%)", + "color.secondary-foreground": "hsl(210 40% 98%)", + "color.muted": "hsl(217.2 32.6% 17.5%)", + "color.muted-foreground": "hsl(215 20.2% 65.1%)", + "color.accent": "hsl(217.2 32.6% 17.5%)", + "color.accent-foreground": "hsl(210 40% 98%)", + "color.destructive": "hsl(0 62.8% 30.6%)", + "color.destructive-foreground": "hsl(210 40% 98%)", + "color.border": "hsl(217.2 32.6% 17.5%)", + "color.input": "hsl(217.2 32.6% 17.5%)", + "color.ring": "hsl(212.7 26.8% 83.9%)" + } + } + }, + "layout": { + "breakpoints": { + "sm": { + "minWidth": "640px", + "description": "Small devices and large phones in landscape." + }, + "md": { + "minWidth": "768px", + "description": "Tablets." + }, + "lg": { + "minWidth": "1024px", + "description": "Laptops and small desktops." + }, + "xl": { + "minWidth": "1280px", + "description": "Desktops." + }, + "2xl": { + "minWidth": "1536px", + "description": "Large desktops." + } + }, + "spacingScale": { + "baseUnit": "0.25rem", + "description": "Spacing follows a 0.25rem base unit; use integer multiples of the scale." + } + } +} diff --git a/packages/composer-core/src/composer-core.test.ts b/packages/composer-core/src/composer-core.test.ts index 2ab3884..290ff0d 100644 --- a/packages/composer-core/src/composer-core.test.ts +++ b/packages/composer-core/src/composer-core.test.ts @@ -13,7 +13,16 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { parseProjectManifest } from "./project"; -import { ledgerStatus, preservesLedger, sectionHash } from "./ledger"; +import { + addTombstone, + applyFreshFact, + componentEntryStatuses, + ledgerStatus, + preservesLedger, + removeTombstone, + restoreComponent, + sectionHash, +} from "./ledger"; import { countBySeverity, finding } from "./findings"; import { COMPOSER_ADAPTERS, composerAdapter } from "./adapters"; @@ -22,6 +31,10 @@ const fixture = (name: string) => const pristine = fixture("acme-ui.dspack.json"); const enriched = fixture("acme-ui.enriched.dspack.json"); +// A real dspack-export 0.5.0 golden (the shadcn-demo fixture): ledger v2 +// with per-entry hashes. Entry-hash fidelity and every entry-level state +// below are pinned against this artifact, not hand-written ledgers. +const v2 = fixture("shadcn-demo.v2.dspack.json"); describe("project manifest", () => { const valid = { @@ -92,6 +105,188 @@ describe("ledger reading (pinned to real dspack-export output)", () => { }); }); +describe("ledger v2 (entry-level, pinned to a real dspack-export 0.5.0 golden)", () => { + it("matches dspack-export's per-entry hashes byte for byte", async () => { + const recorded = v2.metadata["x-bootstrap"].components as Record; + expect(Object.keys(recorded).length).toBeGreaterThan(0); + for (const [id, hash] of Object.entries(recorded)) { + expect(await sectionHash(v2.components[id]), id).toBe(hash); + } + }); + + it("reports the pristine v2 golden all tool-owned, section state derived from entries", async () => { + const status = await ledgerStatus(v2); + expect(status.entryLevel).toBe(true); + expect(status.componentEntries.every((e) => e.state === "tool-owned")).toBe(true); + expect(status.sections.find((s) => s.section === "components")?.state).toBe("tool-owned"); + }); + + it("v1 documents stay section-level: no entry states invented", async () => { + const status = await ledgerStatus(enriched); + expect(status.entryLevel).toBe(false); + expect(status.componentEntries).toEqual([]); + }); + + it("distinguishes human-owned, unattributed, orphaned, and tombstoned entries", async () => { + const doc = structuredClone(v2); + doc.components.button.whenToUse = "Any user-initiated action."; // stale hash + delete doc.metadata["x-bootstrap"].components.card; // present, no record + delete doc.components.badge; // record, no entry + doc.metadata["x-bootstrap"].doNotRediscover = ["input"]; + delete doc.components.input; + delete doc.metadata["x-bootstrap"].components.input; + + const byId = Object.fromEntries((await componentEntryStatuses(doc)).map((e) => [e.id, e.state])); + expect(byId.button).toBe("human-owned"); + expect(byId.card).toBe("unattributed"); + expect(byId.badge).toBe("orphaned"); + expect(byId.input).toBe("tombstoned"); + // Any non-tool-owned state makes the section human-owned at v2. + const status = await ledgerStatus(doc); + expect(status.sections.find((s) => s.section === "components")?.state).toBe("human-owned"); + }); + + it("round-trips byte-stably through save/reload with ownership intact", async () => { + const doc = structuredClone(v2); + doc.components.button.whenToUse = "Any user-initiated action."; + delete doc.components.badge; // orphan = deletion memory + const before = JSON.stringify(doc); + const reloaded = JSON.parse(before); // the save/export/reload path IS JSON + expect(JSON.stringify(reloaded)).toBe(before); + const a = await componentEntryStatuses(doc); + const b = await componentEntryStatuses(reloaded); + expect(b).toEqual(a); // ownership, orphan memory, and hashes all survive + expect(b.find((e) => e.id === "badge")?.state).toBe("orphaned"); + }); + + it("restoreComponent clears exactly the orphaned record, nothing else", async () => { + const doc = structuredClone(v2); + delete doc.components.badge; + const result = restoreComponent(doc, "badge"); + expect(result.ok).toBe(true); + if (!result.ok) return; + const ledger = (result.document.metadata as any)["x-bootstrap"]; + expect(ledger.components.badge).toBeUndefined(); + // Byte-identical outside that one record. + const expected = structuredClone(doc); + delete (expected.metadata as any)["x-bootstrap"].components.badge; + expect(JSON.stringify(result.document)).toBe(JSON.stringify(expected)); + // Refuses when there is no deletion to resolve. + expect(restoreComponent(v2, "badge").ok).toBe(false); + expect(restoreComponent(doc, "not-a-component").ok).toBe(false); + }); + + it("addTombstone/removeTombstone round-trip; tombstoning an orphan retires its hash", () => { + const doc = structuredClone(v2); + delete doc.components.badge; + const dead = addTombstone(doc, "badge"); + expect(dead.ok).toBe(true); + if (!dead.ok) return; + const ledger = (dead.document.metadata as any)["x-bootstrap"]; + expect(ledger.doNotRediscover).toEqual(["badge"]); + expect(ledger.components.badge).toBeUndefined(); // decision made, memory retired + const undone = removeTombstone(dead.document, "badge"); + expect(undone.ok).toBe(true); + if (!undone.ok) return; + expect((undone.document.metadata as any)["x-bootstrap"].doNotRediscover).toEqual([]); + expect(removeTombstone(doc, "badge").ok).toBe(false); // nothing to remove + }); + + it("v2 actions refuse on v1 documents (version floor)", () => { + expect(restoreComponent(enriched, "action-button").ok).toBe(false); + expect(addTombstone(enriched, "action-button").ok).toBe(false); + }); + + it("preservesLedger also guards wholesale deletion-memory destruction on v2", () => { + const doc = structuredClone(v2); + (doc.metadata as any)["x-bootstrap"].doNotRediscover = ["badge"]; + const noMap = structuredClone(doc); + delete (noMap.metadata as any)["x-bootstrap"].components; + expect(preservesLedger(doc, noMap)).toBe(false); + const noTombstones = structuredClone(doc); + delete (noTombstones.metadata as any)["x-bootstrap"].doNotRediscover; + expect(preservesLedger(doc, noTombstones)).toBe(false); + const downgraded = structuredClone(doc); + delete (downgraded.metadata as any)["x-bootstrap"].ledger; + expect(preservesLedger(doc, downgraded)).toBe(false); + // Granular decisions keep the structures present and pass. + const tombstoned = addTombstone(structuredClone(doc), "another"); + expect(tombstoned.ok && preservesLedger(doc, tombstoned.document)).toBe(true); + }); +}); + +describe("freshDelta acceptance (explicit, scalar leaves and pure additions only)", () => { + it("applies a scalar leaf replacement to the entry", () => { + const result = applyFreshFact(v2, "button", { path: "/description", fresh: "A clickable control." }); + expect(result.ok).toBe(true); + if (result.ok) expect((result.document.components as any).button.description).toBe("A clickable control."); + expect((v2.components as any).button.description).not.toBe("A clickable control."); // input untouched + }); + + it("applies a pure prop addition, refuses overwriting an existing prop", () => { + const added = applyFreshFact(v2, "button", { path: "/props/loading", fresh: { type: "boolean" } }); + expect(added.ok).toBe(true); + if (added.ok) expect((added.document.components as any).button.props.loading).toEqual({ type: "boolean" }); + const existing = Object.keys((v2.components as any).button.props)[0]; + expect(applyFreshFact(v2, "button", { path: `/props/${existing}`, fresh: {} }).ok).toBe(false); + }); + + it("appends only new enum values; refuses unsupported paths", () => { + const doc = structuredClone(v2); + const [prop, descriptor] = Object.entries((doc.components as any).button.props).find( + ([, d]: [string, any]) => Array.isArray(d.values), + ) as [string, any]; + const had = descriptor.values.length; + const result = applyFreshFact(doc, "button", { path: `/props/${prop}/values`, fresh: [descriptor.values[0], "brand-new"] }); + expect(result.ok).toBe(true); + if (result.ok) { + const values = (result.document.components as any).button.props[prop].values; + expect(values.length).toBe(had + 1); // the known value was not duplicated + expect(values).toContain("brand-new"); + } + expect(applyFreshFact(doc, "button", { path: "/composition/subComponents", fresh: [] }).ok).toBe(false); + expect(applyFreshFact(doc, "missing", { path: "/description", fresh: "x" }).ok).toBe(false); + }); + + it("preserves authored order (append-only) and refuses non-list authored values", () => { + const doc = structuredClone(v2) as any; + const prop = Object.keys(doc.components.button.props)[0]; + doc.components.button.props[prop] = { type: "string", values: ["alpha", "beta"] }; + const r = applyFreshFact(doc, "button", { path: `/props/${prop}/values`, fresh: ["beta", "gamma"] }); + expect(r.ok).toBe(true); + if (r.ok) expect((r.document.components as any).button.props[prop].values).toEqual(["alpha", "beta", "gamma"]); + // Authored non-list values must never be replaced by acceptance. + const authored = structuredClone(v2) as any; + authored.components.button.props[prop] = { type: "string", values: "sm | lg" }; + const refused = applyFreshFact(authored, "button", { path: `/props/${prop}/values`, fresh: ["xl"] }); + expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.reason).toContain("by hand"); + }); + + it("addTombstone deduplicates and is byte-bounded; clearTombstone removes exactly one id", () => { + const doc = structuredClone(v2) as any; + delete doc.components.badge; + const once = addTombstone(doc, "badge"); + expect(once.ok).toBe(true); + if (!once.ok) return; + const twice = addTombstone(once.document, "badge"); + expect(twice.ok).toBe(true); + if (!twice.ok) return; + expect((twice.document.metadata as any)["x-bootstrap"].doNotRediscover).toEqual(["badge"]); + // Byte-identical outside the two intended ledger edits. + const expected = structuredClone(doc); + (expected.metadata as any)["x-bootstrap"].doNotRediscover = ["badge"]; + delete (expected.metadata as any)["x-bootstrap"].components.badge; + expect(JSON.stringify(twice.document)).toBe(JSON.stringify(expected)); + // Exactly one id leaves a multi-entry list; the others are decisions too. + const multi = structuredClone(v2) as any; + multi.metadata["x-bootstrap"].doNotRediscover = ["alpha", "badge", "omega"]; + const cleared = removeTombstone(multi, "badge"); + expect(cleared.ok).toBe(true); + if (cleared.ok) expect((cleared.document.metadata as any)["x-bootstrap"].doNotRediscover).toEqual(["alpha", "omega"]); + }); +}); + describe("findings", () => { it("counts by severity", () => { const counts = countBySeverity([ diff --git a/packages/composer-core/src/index.ts b/packages/composer-core/src/index.ts index f28bb9e..cdd08f6 100644 --- a/packages/composer-core/src/index.ts +++ b/packages/composer-core/src/index.ts @@ -9,10 +9,20 @@ export { export { sectionHash, ledgerStatus, + componentEntryStatuses, preservesLedger, + restoreComponent, + addTombstone, + removeTombstone, + applyFreshFact, + LEDGER_V2, type LedgerStatus, type SectionStatus, type SectionState, + type ComponentEntryStatus, + type ComponentEntryState, + type LedgerActionResult, + type FreshFact, } from "./ledger"; export { finding, countBySeverity, type ComposerFinding, type FindingGate, type FindingSeverity } from "./findings"; export { diff --git a/packages/composer-core/src/ledger.ts b/packages/composer-core/src/ledger.ts index 9aca806..2ed39d3 100644 --- a/packages/composer-core/src/ledger.ts +++ b/packages/composer-core/src/ledger.ts @@ -26,20 +26,52 @@ export interface SectionStatus { state: SectionState; } +/** + * Ledger v2 (dspack-export 0.5.0): ownership of the components section is + * tracked per entry. The three additional states have no section-level + * analogue: an ORPHANED record is deletion memory (the entry was hand- + * deleted; rediscovery skips it and asks), a TOMBSTONED id must never be + * re-added, and an UNATTRIBUTED entry is present with no record (human- + * owned; the post-migration form of enrichment). + */ +export type ComponentEntryState = + | "tool-owned" + | "human-owned" // stale recorded hash: edited after bootstrap + | "unattributed" // present, no recorded hash: human-owned, migration form + | "orphaned" // recorded hash, entry absent: deletion awaiting a decision + | "tombstoned"; // listed in doNotRediscover, entry absent + +export interface ComponentEntryStatus { + id: string; + state: ComponentEntryState; + /** A present entry can ALSO be tombstoned (suppressedButPresent interop). */ + alsoTombstoned?: boolean; +} + export interface LedgerStatus { /** True when metadata["x-bootstrap"] exists (bootstrap provenance available). */ hasLedger: boolean; + /** True when the ledger tracks components per entry (ledger v2). */ + entryLevel: boolean; sections: SectionStatus[]; + /** Per-entry component states; empty on v1 ledgers (section-level only). */ + componentEntries: ComponentEntryStatus[]; /** The authorship todo list, verbatim from the ledger. */ awaitingAuthorship: string[]; } interface BootstrapLedger { + ledger?: string; spec?: string; generated?: Record; + components?: Record; + doNotRediscover?: string[]; awaitingAuthorship?: string[]; } +/** The ledger version whose entry-level semantics this module reads. */ +export const LEDGER_V2 = "2"; + /** sha256 hex of JSON.stringify(value) — dspack-export's sectionHash, on WebCrypto. */ export async function sectionHash(value: unknown): Promise { const bytes = new TextEncoder().encode(JSON.stringify(value)); @@ -62,15 +94,54 @@ const REPORTED_SECTIONS = [ "antiPatterns", ]; -export async function ledgerStatus(doc: Record): Promise { +function ledgerOf(doc: Record): BootstrapLedger | undefined { const metadata = (doc.metadata ?? {}) as Record; - const ledger = (metadata["x-bootstrap"] ?? undefined) as BootstrapLedger | undefined; + return (metadata["x-bootstrap"] ?? undefined) as BootstrapLedger | undefined; +} + +/** Entry-level component states. Empty on v1 ledgers (no per-entry map). */ +export async function componentEntryStatuses(doc: Record): Promise { + const ledger = ledgerOf(doc); + if (ledger?.ledger !== LEDGER_V2 || ledger.components === undefined) return []; + const entries = (doc.components ?? {}) as Record; + const recorded = ledger.components; + const tombstones = new Set(ledger.doNotRediscover ?? []); + + const statuses: ComponentEntryStatus[] = []; + for (const [id, entry] of Object.entries(entries)) { + const hash = recorded[id]; + const state: ComponentEntryState = + hash === undefined ? "unattributed" : (await sectionHash(entry)) === hash ? "tool-owned" : "human-owned"; + statuses.push(tombstones.has(id) ? { id, state, alsoTombstoned: true } : { id, state }); + } + for (const id of Object.keys(recorded)) { + if (!(id in entries)) statuses.push({ id, state: "orphaned" }); + } + for (const id of tombstones) { + if (!(id in entries)) statuses.push({ id, state: "tombstoned" }); + } + return statuses; +} + +export async function ledgerStatus(doc: Record): Promise { + const ledger = ledgerOf(doc); const generated = ledger?.generated ?? {}; + const componentEntries = await componentEntryStatuses(doc); + const entryLevel = componentEntries.length > 0 || (ledger?.ledger === LEDGER_V2 && ledger.components !== undefined); const sections: SectionStatus[] = []; for (const section of REPORTED_SECTIONS) { const value = doc[section]; const recorded = generated[section]; + if (section === "components" && entryLevel && value !== undefined) { + // v2: the whole-section signal is deliberately omitted whenever any + // entry is human-owned or any tombstone/orphan exists (so pre-v2 + // tools fail closed). Derive the section state from the entries. + const allToolOwned = + componentEntries.length > 0 && componentEntries.every((e) => e.state === "tool-owned" && !e.alsoTombstoned); + sections.push({ section, state: allToolOwned ? "tool-owned" : "human-owned" }); + continue; + } if (value === undefined) { sections.push({ section, state: "absent" }); } else if (recorded === undefined) { @@ -83,18 +154,157 @@ export async function ledgerStatus(doc: Record): Promise, incoming: Record): boolean { - const had = ((existing.metadata ?? {}) as Record)["x-bootstrap"] !== undefined; - if (!had) return true; - return ((incoming.metadata ?? {}) as Record)["x-bootstrap"] !== undefined; + const had = ledgerOf(existing); + if (had === undefined) return true; + const kept = ledgerOf(incoming); + if (kept === undefined) return false; + if (had.ledger === LEDGER_V2) { + if (kept.ledger !== LEDGER_V2) return false; + if (had.components !== undefined && kept.components === undefined) return false; + if ((had.doNotRediscover?.length ?? 0) > 0 && kept.doNotRediscover === undefined) return false; + } + return true; +} + +/* ------------------------------------------------------------------ */ +/* Explicit v2 decisions. Each returns a NEW document; nothing here */ +/* runs without a person clicking the action that names it. */ +/* ------------------------------------------------------------------ */ + +export type LedgerActionResult = { ok: true; document: Record } | { ok: false; reason: string }; + +function withV2Ledger( + doc: Record, + mutate: (ledger: BootstrapLedger) => string | undefined, +): LedgerActionResult { + const next = structuredClone(doc); + const ledger = ledgerOf(next); + if (ledger?.ledger !== LEDGER_V2 || ledger.components === undefined) { + return { ok: false, reason: "entry-level decisions need a ledger-v2 document (rediscover with dspack-export ≥ 0.5.0 first)" }; + } + const reason = mutate(ledger); + return reason === undefined ? { ok: true, document: next } : { ok: false, reason }; +} + +/** + * Resolve a deletion by RESTORING: forget the orphaned hash so the next + * rediscovery re-adds the component as newly discovered, tool-owned. The + * entry itself comes back from fresh extraction — this tool never invents + * content, it only clears the memory that was blocking restoration. + */ +export function restoreComponent(doc: Record, id: string): LedgerActionResult { + return withV2Ledger(doc, (ledger) => { + const entries = (doc.components ?? {}) as Record; + if (id in entries) return `'${id}' is present in the document; there is no deletion to resolve`; + if (ledger.components![id] === undefined) return `'${id}' has no orphaned ledger record`; + delete ledger.components![id]; + return undefined; + }); +} + +/** + * Resolve a deletion by SUPPRESSING: tombstone the id so rediscovery never + * re-adds it, and retire the orphaned hash (the decision is made). + */ +export function addTombstone(doc: Record, id: string): LedgerActionResult { + return withV2Ledger(doc, (ledger) => { + const list = (ledger.doNotRediscover ??= []); + if (!list.includes(id)) list.push(id); + const entries = (doc.components ?? {}) as Record; + if (!(id in entries)) delete ledger.components![id]; + return undefined; + }); +} + +/** Remove a tombstone: the next rediscovery may re-add the component. */ +export function removeTombstone(doc: Record, id: string): LedgerActionResult { + return withV2Ledger(doc, (ledger) => { + const list = ledger.doNotRediscover ?? []; + const at = list.indexOf(id); + if (at === -1) return `'${id}' is not tombstoned`; + list.splice(at, 1); + return undefined; + }); +} + +/* ------------------------------------------------------------------ */ +/* freshDelta acceptance. The report's fresh-side facts are review */ +/* information; ACCEPTING one writes it into the human-owned entry. */ +/* Only the two ratified shapes apply: a scalar leaf, or a pure */ +/* addition. Anything else is refused — author the change by hand. */ +/* ------------------------------------------------------------------ */ + +/** One fresh-side fact from dspack-export's RegenerateReport (shape owned there). */ +export interface FreshFact { + path: string; + fresh: unknown; +} + +const isScalar = (v: unknown) => v === null || ["string", "number", "boolean"].includes(typeof v); + +/** + * Apply one accepted fact to a component entry. Supported paths mirror + * computeFreshDelta in dspack-export: + * /name /description /status scalar leaf replacement + * /props/ pure addition (descriptor object) + * /props//values pure addition (append new values) + * /props//(type|default|required) scalar leaf replacement + */ +export function applyFreshFact(doc: Record, componentId: string, fact: FreshFact): LedgerActionResult { + const next = structuredClone(doc); + const entry = ((next.components ?? {}) as Record>)[componentId]; + if (!entry) return { ok: false, reason: `component '${componentId}' is not in the document` }; + + const segments = fact.path.split("/").filter(Boolean); + if (segments.length === 1 && ["name", "description", "status"].includes(segments[0])) { + if (!isScalar(fact.fresh)) return { ok: false, reason: `'${fact.path}' is not a scalar leaf` }; + entry[segments[0]] = fact.fresh; + return { ok: true, document: next }; + } + if (segments[0] === "props" && segments.length === 2) { + const props = ((entry.props ??= {}) as Record); + if (segments[1] in props) { + return { ok: false, reason: `prop '${segments[1]}' already exists; accepting would overwrite authored data` }; + } + props[segments[1]] = fact.fresh; + return { ok: true, document: next }; + } + if (segments[0] === "props" && segments.length === 3) { + const props = (entry.props ?? {}) as Record>; + const descriptor = props[segments[1]]; + if (!descriptor) return { ok: false, reason: `prop '${segments[1]}' is not in the entry` }; + if (segments[2] === "values") { + if (!Array.isArray(fact.fresh)) return { ok: false, reason: "a values fact must be a list of added values" }; + if (descriptor.values !== undefined && !Array.isArray(descriptor.values)) { + return { ok: false, reason: `authored values on '${segments[1]}' is not a list; accepting would replace it — author this change by hand` }; + } + const current = Array.isArray(descriptor.values) ? descriptor.values : []; + const known = new Set(current.map((v) => JSON.stringify(v))); + descriptor.values = [...current, ...fact.fresh.filter((v) => !known.has(JSON.stringify(v)))]; + return { ok: true, document: next }; + } + if (["type", "default", "required"].includes(segments[2])) { + if (!isScalar(fact.fresh)) return { ok: false, reason: `'${fact.path}' is not a scalar leaf` }; + descriptor[segments[2]] = fact.fresh; + return { ok: true, document: next }; + } + } + return { ok: false, reason: `unsupported fact path '${fact.path}' — author this change by hand` }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24052c7..691d704 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^0.4.1 version: 0.4.1 '@aestheticfunction/dspack-export': - specifier: ^0.4.0 - version: 0.4.0 + specifier: ^0.5.0 + version: 0.5.0 '@aestheticfunction/dspack-gen': specifier: ^0.1.3 version: 0.1.3(zod@4.4.3) @@ -419,8 +419,8 @@ packages: resolution: {integrity: sha512-/p1Y4dAWlcwtPcfM81t2EjPChifCb9VvnIQCZ9A1dkbU13mSVYk+AQquxvY51PV9naxFAktFYLdx1mFjIOZqrQ==} hasBin: true - '@aestheticfunction/dspack-export@0.4.0': - resolution: {integrity: sha512-Y7q5ZcwnpxOXMWvnEBQ422LPBXSaluXKKr1xWZPv3xaq26DpB3WSU8rPrqpPhqZ0LLb4wUQ7I57I8t+QGlsFlg==} + '@aestheticfunction/dspack-export@0.5.0': + resolution: {integrity: sha512-wW0bVYww8ylG4oosAClWUwQXWMcPsBkx5XmP0x5ytQEMAcBOUKXrSk9hFAiwON41lfP22mNpR7SLyAHPBZvOtA==} engines: {node: '>=22.0.0'} hasBin: true @@ -2325,7 +2325,7 @@ snapshots: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) - '@aestheticfunction/dspack-export@0.4.0': + '@aestheticfunction/dspack-export@0.5.0': dependencies: '@babel/parser': 7.29.7 '@babel/traverse': 7.29.7