diff --git a/src/lib/project-context.test.ts b/src/lib/project-context.test.ts index e9e8913..1f0501c 100644 --- a/src/lib/project-context.test.ts +++ b/src/lib/project-context.test.ts @@ -35,7 +35,7 @@ import { type ProjectContextRuntime, } from "./project-context"; import { CODEWITH_NATIVE_IMPORTS_ENV, planSessionRender, type SessionRenderTool } from "./session-render"; -import { applySessionRender } from "./session-apply"; +import { applySessionRender, restoreSessionRenderSnapshot } from "./session-apply"; import { makeTempRoot } from "./test-temp-root"; let tmpRoot = ""; @@ -114,6 +114,23 @@ function bundleJson(bundle = makeBundle()): string { return `${JSON.stringify(bundle)}\n`; } +function stableStringifyForTest(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringifyForTest).join(",")}]`; + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort((left, right) => left.localeCompare(right)) + .map((key) => `${JSON.stringify(key)}:${stableStringifyForTest(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function legacyProjectContextHash(bundle: ProjectContextBundleV1): string { + const { hash: _hash, ...legacyPayload } = bundle; + return `sha256:${createHash("sha256").update(stableStringifyForTest(legacyPayload)).digest("hex")}`; +} + function expectCode(fn: () => unknown, code: string): void { try { fn(); @@ -133,6 +150,49 @@ describe("project context bundle validation", () => { expect(parsed.commands).toHaveLength(2); }); + test("keeps the source hash stable across generation times but changes it for durable payload changes", () => { + const original = makeBundle(); + const regenerated = { + ...original, + generated_at: "2026-07-22T10:01:00.000Z", + }; + const changed = { + ...regenerated, + project: { + ...regenerated.project, + name: "Changed Durable Project Name", + }, + }; + + expect(computeProjectContextSourceHash(regenerated)).toBe( + computeProjectContextSourceHash(original), + ); + expect(computeProjectContextSourceHash(changed)).not.toBe( + computeProjectContextSourceHash(original), + ); + }); + + test("accepts canonical and exact legacy v1 hashes while rejecting tampered and hashless bundles", () => { + const canonical = makeBundle(); + const legacy: ProjectContextBundleV1 = { + ...canonical, + hash: legacyProjectContextHash(canonical), + }; + const tampered: ProjectContextBundleV1 = { + ...legacy, + project: { + ...legacy.project, + name: "Tampered Durable Project Name", + }, + }; + const hashless = { ...canonical, hash: "" }; + + expect(parseProjectContextBundle(canonical).hash).toBe(canonical.hash); + expect(parseProjectContextBundle(legacy).hash).toBe(canonical.hash); + expectCode(() => parseProjectContextBundle(tampered), "PROJECT_CONTEXT_HASH_MISMATCH"); + expectCode(() => parseProjectContextBundle(hashless), "PROJECT_CONTEXT_INVALID"); + }); + test("rejects additional properties, inconsistent hashes, bad enums, and too many argv commands", () => { const extra = { ...makeBundle(), metadata: { arbitrary: true } }; expectCode(() => parseProjectContextBundle(JSON.stringify(extra)), "PROJECT_CONTEXT_INVALID"); @@ -438,6 +498,77 @@ describe("project context adapters and managed edits", () => { expect(readFileSync(target, "utf8").match(/project context BEGIN/g)).toHaveLength(1); }); + test("fails a fused END marker without force and force-repairs it without consuming following user bytes", () => { + const targetHome = join(tmpRoot, ".codewith"); + const target = join(targetHome, "CODEWITH.md"); + const prefix = "Owner prefix stays byte-for-byte.\n\n"; + const suffix = "## Modus Operandi\n\nOwner suffix stays byte-for-byte.\n"; + mkdirSync(targetHome, { recursive: true }); + writeFileSync(target, prefix); + + const bundle = makeBundle(); + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(bundle), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:01:00.000Z"), + codewith_native_imports: true, + }); + + const rendered = readFileSync(target, "utf8"); + const endMarkerEnd = rendered.lastIndexOf(" -->") + " -->".length; + expect(endMarkerEnd).toBeGreaterThan(" -->".length); + const malformed = `${rendered.slice(0, endMarkerEnd)}${suffix}`; + writeFileSync(target, malformed); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(bundle), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:01:00.000Z"), + codewith_native_imports: true, + }), "MANAGED_BLOCK_INVALID"); + expect(readFileSync(target, "utf8")).toBe(malformed); + + const repaired = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(bundle), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:01:00.000Z"), + codewith_native_imports: true, + force: true, + }); + expect(repaired.applied).toBe(true); + expect(repaired.snapshot_path).not.toBeNull(); + const repairedContent = readFileSync(target, "utf8"); + expect(repairedContent.startsWith(prefix)).toBe(true); + expect(repairedContent.endsWith(suffix)).toBe(true); + expect(repairedContent).toContain(" -->\n## Modus Operandi"); + + process.env[CODEWITH_NATIVE_IMPORTS_ENV] = "1"; + const sessionPlan = planSessionRender({ + tool: "codewith", + profile: "fused-marker-repair", + targetHome, + sources: [{ + id: "global-rules", + layer: "global", + content: "## Modus Operandi\n\nOwner suffix stays byte-for-byte.\n", + }], + }); + expect(sessionPlan.files.find((file) => file.path === target)?.content).toContain( + PROJECT_CONTEXT_MANAGED_COMMENT, + ); + + const restored = restoreSessionRenderSnapshot(repaired.snapshot_path!); + expect(restored.restored).toBe(true); + expect(restored.conflicts).toEqual([]); + expect(readFileSync(target, "utf8")).toBe(malformed); + }); + test("rejects a well-formed managed block for another project even with force", () => { const target = join(tmpRoot, "AGENTS.md"); writeFileSync(target, [ @@ -724,6 +855,90 @@ describe("legacy migration and compatibility", () => { } }); + test("keeps regenerated bundles idempotent across a managed Codewith render and two post-render applies", () => { + const root = join(tmpRoot, "codewith-regenerated-bundle"); + const targetHome = join(root, ".codewith"); + mkdirSync(targetHome, { recursive: true }); + const original = makeBundle(); + const legacyOriginal: ProjectContextBundleV1 = { + ...original, + hash: legacyProjectContextHash(original), + }; + + const initial = applyProjectContext({ + workspace_root: root, + runtime: "codewith", + bundle_json: bundleJson(legacyOriginal), + source_path: join(root, "bundle.json"), + now: new Date("2026-07-22T10:01:00.000Z"), + codewith_native_imports: true, + }); + expect(initial.applied).toBe(true); + expect(initial.hash).toBe(original.hash); + + process.env[CODEWITH_NATIVE_IMPORTS_ENV] = "1"; + const sessionPlan = planSessionRender({ + tool: "codewith", + profile: "live-codewith", + targetHome, + sources: [{ + id: "global-rules", + layer: "global", + content: "Managed Codewith rules.", + }], + }); + const sessionResult = applySessionRender(sessionPlan); + expect(sessionResult.applied).toBe(true); + expect(sessionResult.conflicts).toEqual([]); + const managedRules = sessionPlan.files.find((file) => file.content.includes("Managed Codewith rules.")); + expect(managedRules).toBeDefined(); + + const regenerated: ProjectContextBundleV1 = { + ...original, + generated_at: "2026-07-22T10:02:00.000Z", + hash: original.hash, + }; + expect(computeProjectContextSourceHash(regenerated)).toBe(original.hash); + + const firstPostRender = applyProjectContext({ + workspace_root: root, + runtime: "codewith", + bundle_json: bundleJson(regenerated), + source_path: join(root, "regenerated-bundle.json"), + now: new Date("2026-07-22T10:03:00.000Z"), + codewith_native_imports: true, + }); + expect(firstPostRender.applied).toBe(true); + expect(firstPostRender.snapshot_path).toBeNull(); + + const managedPaths = [ + join(targetHome, "CODEWITH.md"), + join(targetHome, ".hasna", "session-render-manifest.json"), + managedRules!.path, + join(root, ...PROJECT_CONTEXT_FRAGMENT_PATH.split("/")), + join(root, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")), + join(root, ".hasna", "project-context-cache.json"), + ]; + const afterFirstPostRender = managedPaths.map((path) => readFileSync(path, "utf8")); + + const secondPostRender = applyProjectContext({ + workspace_root: root, + runtime: "codewith", + bundle_json: bundleJson(regenerated), + source_path: join(root, "regenerated-bundle.json"), + now: new Date("2026-07-22T10:03:00.000Z"), + codewith_native_imports: true, + }); + expect(secondPostRender.applied).toBe(true); + expect(secondPostRender.snapshot_path).toBeNull(); + expect(managedPaths.map((path) => readFileSync(path, "utf8"))).toEqual(afterFirstPostRender); + + const rendered = readFileSync(join(targetHome, "CODEWITH.md"), "utf8"); + expect(readFileSync(managedRules!.path, "utf8")).toContain("Managed Codewith rules."); + expect(rendered).toContain(`@../${PROJECT_CONTEXT_FRAGMENT_PATH}`); + expect(rendered.match(/project context BEGIN/g)).toHaveLength(1); + }); + test("rejects a stale session plan instead of downgrading newer durable project context", () => { const first = planSessionRender({ tool: "codex", @@ -974,6 +1189,161 @@ describe("legacy migration and compatibility", () => { }); describe("cache, revision, crash, and race safety", () => { + test("rejects an unproven same-revision manifest hash even when cache, marker, and fragment agree", () => { + const bundle = makeBundle(); + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(bundle), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:00:30.000Z"), + }); + + const manifestPath = join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + projectContext: { hash: string }; + }; + manifest.projectContext.hash = `sha256:${"b".repeat(64)}`; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(bundle), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:00:30.000Z"), + }), "PROJECT_CONTEXT_REVISION_CONFLICT"); + }); + + test("migrates persisted legacy v1 hashes to canonical state and recovers an interrupted apply", () => { + const original = makeBundle(); + const canonicalHash = original.hash; + const legacyHash = legacyProjectContextHash(original); + const legacyBundle = { ...original, hash: legacyHash }; + expect(legacyHash).not.toBe(canonicalHash); + expect(parseProjectContextBundle(legacyBundle).hash).toBe(canonicalHash); + + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(original), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:00:30.000Z"), + }); + + const targetPath = join(tmpRoot, "AGENTS.md"); + const fragmentPath = join(tmpRoot, ...PROJECT_CONTEXT_FRAGMENT_PATH.split("/")); + const cachePath = join(tmpRoot, ".hasna", "project-context-cache.json"); + const manifestPath = join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")); + const sessionManifestPath = join(tmpRoot, ".hasna", "session-render-manifest.json"); + for (const path of [targetPath, fragmentPath, cachePath]) { + writeFileSync(path, readFileSync(path, "utf8").replaceAll(canonicalHash, legacyHash)); + } + + const legacyRenderedPayloadSha256 = createHash("sha256") + .update(JSON.stringify(legacyBundle)) + .digest("hex"); + const rewriteManifest = (path: string, sourceHash: string): void => { + const manifest = JSON.parse( + readFileSync(path, "utf8").replaceAll(canonicalHash, legacyHash), + ) as { + sourceHash: string; + sources: Array<{ id: string; renderedPayloadSha256?: string }>; + files: Array<{ path: string; sha256: string }>; + }; + manifest.sourceHash = sourceHash; + for (const source of manifest.sources) { + if (source.id === "project-context-bundle") { + source.renderedPayloadSha256 = legacyRenderedPayloadSha256; + } + } + for (const file of manifest.files) { + file.sha256 = createHash("sha256").update(readFileSync(file.path, "utf8")).digest("hex"); + } + writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`); + }; + rewriteManifest(manifestPath, legacyHash); + rewriteManifest( + sessionManifestPath, + createHash("sha256") + .update(stableStringifyForTest({ previous: null, projectContext: legacyHash })) + .digest("hex"), + ); + + const regenerated: ProjectContextBundleV1 = { + ...original, + generated_at: "2026-07-22T10:01:00.000Z", + hash: canonicalHash, + }; + expect(computeProjectContextSourceHash(regenerated)).toBe(canonicalHash); + expect(legacyProjectContextHash(regenerated)).not.toBe(legacyHash); + + expect(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(regenerated), + source_path: join(tmpRoot, "regenerated-bundle.json"), + now: new Date("2026-07-22T10:02:00.000Z"), + test_hooks: { + before_manifest: () => { + throw new Error("simulated migration crash before final manifest"); + }, + }, + })).toThrow("simulated migration crash before final manifest"); + expect((JSON.parse(readFileSync(cachePath, "utf8")) as { hash: string }).hash).toBe(canonicalHash); + expect( + (JSON.parse(readFileSync(manifestPath, "utf8")) as { projectContext: { hash: string } }) + .projectContext.hash, + ).toBe(legacyHash); + + const changedAfterCrash: ProjectContextBundleV1 = { + ...regenerated, + project: { + ...regenerated.project, + name: "Changed Durable Project Name", + }, + hash: "", + }; + changedAfterCrash.hash = computeProjectContextSourceHash(changedAfterCrash); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(changedAfterCrash), + source_path: join(tmpRoot, "changed-after-crash.json"), + now: new Date("2026-07-22T10:02:00.000Z"), + }), "MANAGED_BLOCK_CONFLICT"); + + const migrated = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(regenerated), + source_path: join(tmpRoot, "regenerated-bundle.json"), + now: new Date("2026-07-22T10:02:00.000Z"), + }); + expect(migrated.applied).toBe(true); + expect(migrated.snapshot_path).not.toBeNull(); + expect((JSON.parse(readFileSync(cachePath, "utf8")) as { hash: string }).hash).toBe(canonicalHash); + expect( + (JSON.parse(readFileSync(manifestPath, "utf8")) as { projectContext: { hash: string } }) + .projectContext.hash, + ).toBe(canonicalHash); + expect(readFileSync(targetPath, "utf8")).toContain(canonicalHash); + expect(readFileSync(targetPath, "utf8")).not.toContain(legacyHash); + + const managedPaths = [targetPath, fragmentPath, cachePath, manifestPath, sessionManifestPath]; + const afterMigration = managedPaths.map((path) => readFileSync(path, "utf8")); + const repeated = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(regenerated), + source_path: join(tmpRoot, "regenerated-bundle.json"), + now: new Date("2026-07-22T10:02:00.000Z"), + }); + expect(repeated.applied).toBe(true); + expect(repeated.snapshot_path).toBeNull(); + expect(managedPaths.map((path) => readFileSync(path, "utf8"))).toEqual(afterMigration); + }); + test("uses only a compatible same-ID bounded stale cache with a visible age", () => { applyProjectContext({ workspace_root: tmpRoot, diff --git a/src/lib/project-context.ts b/src/lib/project-context.ts index b7002d5..19cfa88 100644 --- a/src/lib/project-context.ts +++ b/src/lib/project-context.ts @@ -21,7 +21,12 @@ import { basename, dirname, isAbsolute, join, parse, relative, resolve } from "n import { z } from "zod"; import { scanSecrets } from "./redact.js"; import type { SessionRenderFile, SessionRenderManifest, SessionRenderMode, SessionRenderTool } from "./session-render.js"; -import { CODEWITH_NATIVE_IMPORTS_ENV, SESSION_INSTRUCTION_LAYERS, SESSION_RENDER_SCHEMA } from "./session-render-contract.js"; +import { + CODEWITH_NATIVE_IMPORTS_ENV, + SESSION_INSTRUCTION_LAYERS, + SESSION_RENDER_SCHEMA, + SESSION_RENDER_SNAPSHOT_RELATIVE_DIR, +} from "./session-render-contract.js"; export const PROJECT_CONTEXT_SCHEMA = "hasna.projects.project_context_bundle.v1" as const; export const PROJECT_CONTEXT_MAX_INPUT_BYTES = 8 * 1024; @@ -235,6 +240,26 @@ const storedManifestObservationSchema = z.object({ } }); +const projectContextMetadataSnapshotSchema = z.object({ + schema: z.literal("hasna.configs.session-render-snapshot/v1"), + kind: z.literal("project-context-metadata"), + createdAt: isoTimestamp, + projectId: safeId, + revision: revisionSchema, + hash: hashSchema, + status: z.enum(["fresh", "stale-source", "stale-cache"]), + files: z.array(z.object({ + relativePath: z.enum([ + PROJECT_CONTEXT_FRAGMENT_PATH, + "CLAUDE.md", + ".codewith/CODEWITH.md", + "AGENTS.md", + ]), + role: z.enum(["fragment", "index"]), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + }).strict()).min(1).max(2), +}).strict(); + const projectContextCacheSchema = z.object({ schema: z.literal(PROJECT_CONTEXT_CACHE_SCHEMA), cached_at: isoTimestamp, @@ -490,6 +515,14 @@ export function computeProjectContextSourceHash(value: unknown): string { } export function parseProjectContextBundle(input: string | unknown): ProjectContextBundleV1 { + return parseProjectContextBundleInternal(input, true, true); +} + +function parseProjectContextBundleInternal( + input: string | unknown, + allowLegacyHash: boolean, + normalizeLegacyHash = false, +): ProjectContextBundleV1 { let encoded: string; try { const serialized = typeof input === "string" ? input : JSON.stringify(input); @@ -527,10 +560,15 @@ export function parseProjectContextBundle(input: string | unknown): ProjectConte validateIdentityConsistency(bundle); rejectCredentialLikeBundle(bundle); const expected = computeProjectContextSourceHash(bundle); - if (bundle.hash !== expected) { + const matchesLegacyHash = bundle.hash !== expected + && allowLegacyHash + && bundle.hash === computeLegacyProjectContextSourceHash(bundle); + if (bundle.hash !== expected && !matchesLegacyHash) { throw new ProjectContextError("PROJECT_CONTEXT_HASH_MISMATCH", "bundle hash does not match its canonical allowlisted payload"); } - return bundle; + return matchesLegacyHash && normalizeLegacyHash + ? { ...bundle, hash: expected } + : bundle; } export function planProjectContext(input: ProjectContextPlanInput): ProjectContextPlan { @@ -841,6 +879,7 @@ export function applyProjectContext(options: ProjectContextApplyOptions): Projec path: runtimePaths(workspaceRoot, plan.runtime).sessionManifest, content: `${JSON.stringify(sessionManifest, null, 2)}\n`, }; + const manifestContent = `${JSON.stringify(buildManifest(plan, now), null, 2)}\n`; options.test_hooks?.before_compare?.({ attempt, plan }); if (!hashesStillMatch(plan.expected_hashes, workspaceRoot)) { if (attempt === 0) { @@ -853,7 +892,15 @@ export function applyProjectContext(options: ProjectContextApplyOptions): Projec assertWorkspaceLockHeld(lockPath, lock!, workspaceRoot); try { - snapshotPath = writeMetadataSnapshot(plan, now); + const metadataSnapshotPath = writeMetadataSnapshot(plan, now); + const rollbackSnapshotPath = writeProjectContextRollbackSnapshot(plan, now, [ + { path: plan.fragment_path, content: plan.fragment, role: "fragment" }, + { path: plan.target_path, content: plan.target_content, role: "index" }, + { path: plan.cache_path, content: cacheContent, role: "config" }, + { path: sessionOutput.path, content: sessionOutput.content, role: "manifest" }, + { path: plan.manifest_path, content: manifestContent, role: "manifest" }, + ]); + snapshotPath = rollbackSnapshotPath ?? metadataSnapshotPath; atomicWriteFile( plan.fragment_path, plan.fragment, @@ -910,10 +957,9 @@ export function applyProjectContext(options: ProjectContextApplyOptions): Projec options.test_hooks?.before_manifest?.({ attempt, plan }); assertWorkspaceLockHeld(lockPath, lock!, workspaceRoot); assertRenderedOutputsStable(plan, cacheContent, sessionOutput); - const manifest = buildManifest(plan, now); atomicWriteFile( plan.manifest_path, - `${JSON.stringify(manifest, null, 2)}\n`, + manifestContent, workspaceRoot, 0o600, expectedPlanHash(plan, plan.manifest_path), @@ -1028,10 +1074,14 @@ function resolveBundleForApply( if (cache.project_id !== options.expected_project_id || cache.bundle.project.id !== options.expected_project_id) { throw new ProjectContextError("PROJECT_CONTEXT_CACHE_ID_MISMATCH", "cached project context belongs to a different project"); } - const bundle = parseProjectContextBundle(cache.bundle); - if (bundle.revision !== cache.revision || bundle.hash !== cache.hash) { + const cachedBundle = cache.bundle; + if (cachedBundle.revision !== cache.revision || cachedBundle.hash !== cache.hash) { throw new ProjectContextError("PROJECT_CONTEXT_CACHE_INVALID", "cached revision or hash metadata is inconsistent"); } + const canonicalHash = computeProjectContextSourceHash(cachedBundle); + const bundle = cachedBundle.hash === canonicalHash + ? cachedBundle + : { ...cachedBundle, hash: canonicalHash }; const ageSeconds = Math.max( staleCacheAgeInSeconds(bundle.generated_at, now, "bundle generated_at"), staleCacheAgeInSeconds(cache.cached_at, now, "cache cached_at"), @@ -1145,11 +1195,13 @@ function parseManagedBlock(content: string, force: boolean): { block: ManagedBlo const structurallyInvalid = malformed || starts.length !== 1 || ends.length !== 1 || starts[0]!.start >= ends[0]!.start; if (structurallyInvalid) { if (!force) throw new ProjectContextError("MANAGED_BLOCK_INVALID", "managed project-context markers are duplicate, nested, malformed, or unbalanced"); + const firstMarkerRange = markerCommentRange(markerLines[0]!); + const lastMarkerRange = markerCommentRange(markerLines[markerLines.length - 1]!); return { block: null, forceRange: { - start: markerLines[0]!.start, - end: lineContentEnd(markerLines[markerLines.length - 1]!), + start: firstMarkerRange?.start ?? markerLines[0]!.start, + end: lastMarkerRange?.end ?? lineContentEnd(markerLines[markerLines.length - 1]!), }, }; } @@ -1180,6 +1232,20 @@ function parseManagedBlock(content: string, force: boolean): { block: ManagedBlo }; } +function markerCommentRange(line: { text: string; start: number }): { start: number; end: number } | null { + const canonicalMarker = line.text.indexOf(PROJECT_CONTEXT_MANAGED_COMMENT); + const legacyMarker = /@hasna\/configs project context/i.exec(line.text)?.index ?? -1; + const marker = canonicalMarker >= 0 ? canonicalMarker : legacyMarker; + if (marker < 0) return null; + const commentStart = line.text.lastIndexOf("", marker); + if (commentStart < 0 || commentEnd < 0) return null; + return { + start: line.start + commentStart, + end: line.start + commentEnd + "-->".length, + }; +} + function parseMarkerLine(text: string): { kind: "BEGIN" | "END"; id: string; revision: string; hash: string; legacy: boolean } | null { const line = text.replace(/[\r\n]+$/, ""); const canonical = line.match(/^$/); @@ -1206,7 +1272,14 @@ function replaceOrAppendManagedBlock( legacy: { start: number; end: number } | null, ): string { const range = parsed.block ?? parsed.forceRange ?? legacy; - if (range) return `${content.slice(0, range.start)}${block}${content.slice(range.end)}`; + if (range) { + const before = content.slice(0, range.start); + const after = content.slice(range.end); + const eol = preferredEol(content); + const beforeSeparator = before && !/[\r\n]$/.test(before) ? eol : ""; + const afterSeparator = after && !/^[\r\n]/.test(after) ? eol : ""; + return `${before}${beforeSeparator}${block}${afterSeparator}${after}`; + } if (!content) return `${block}\n`; const eol = preferredEol(content); const separator = content.endsWith("\n") || content.endsWith("\r") ? eol : `${eol}${eol}`; @@ -1250,13 +1323,40 @@ function findLegacyCodewithWorkspaceSection( function assertRevisionOrdering(plan: ProjectContextPlan, force: boolean): void { const observations: Array<{ source: string; id: string; revision: string; hash: string }> = []; + const cache = readProjectContextCache(plan.cache_path, plan.workspace_root); + const canonicalCacheHash = cache === null ? null : computeProjectContextSourceHash(cache.bundle); + const normalizePersistedHash = (revision: string, hash: string): string => ( + cache !== null && + canonicalCacheHash !== null && + revision === cache.revision && + hash === cache.hash + ) ? canonicalCacheHash : hash; const manifest = readProjectContextManifest(plan.manifest_path, plan.workspace_root); if (manifest) { + const manifestHashHasRecoveryProof = manifest.projectContext.hash === plan.bundle.hash + || metadataSnapshotMatchesManifest(plan, manifest); + const canonicalStateAlreadyInstalled = ( + cache !== null && + canonicalCacheHash === plan.bundle.hash && + cache.project_id === plan.bundle.project.id && + cache.revision === plan.bundle.revision && + plan.marker !== null && + plan.marker.id === plan.bundle.project.id && + plan.marker.revision === plan.bundle.revision && + plan.marker.hash === plan.bundle.hash && + existsSync(plan.fragment_path) && + fragmentMatchesBundle(plan.fragment_path, plan.bundle, plan.workspace_root) && + manifestHashHasRecoveryProof + ); observations.push({ source: "manifest", id: manifest.projectContext.projectId, revision: manifest.projectContext.revision, - hash: manifest.projectContext.hash, + hash: canonicalStateAlreadyInstalled && + manifest.projectContext.projectId === plan.bundle.project.id && + manifest.projectContext.revision === plan.bundle.revision + ? plan.bundle.hash + : normalizePersistedHash(manifest.projectContext.revision, manifest.projectContext.hash), }); const fragmentEntry = manifest.files.find((file) => file.relativePath === PROJECT_CONTEXT_FRAGMENT_PATH); if (fragmentEntry && existsSync(plan.fragment_path)) { @@ -1266,9 +1366,15 @@ function assertRevisionOrdering(plan: ProjectContextPlan, force: boolean): void } } } - const cache = readProjectContextCache(plan.cache_path, plan.workspace_root); - if (cache) observations.push({ source: "cache", id: cache.project_id, revision: cache.revision, hash: cache.hash }); - if (plan.marker) observations.push({ source: "marker", id: plan.marker.id, revision: plan.marker.revision, hash: plan.marker.hash }); + if (cache) observations.push({ source: "cache", id: cache.project_id, revision: cache.revision, hash: canonicalCacheHash! }); + if (plan.marker) { + observations.push({ + source: "marker", + id: plan.marker.id, + revision: plan.marker.revision, + hash: normalizePersistedHash(plan.marker.revision, plan.marker.hash), + }); + } for (const observation of observations) { if (observation.id !== plan.bundle.project.id) { @@ -1395,6 +1501,14 @@ function buildSessionCompatibilityManifest(plan: ProjectContextPlan, now: Date): }; const targetOwner = isRecord(existing["targetOwner"]) ? existing["targetOwner"] : {}; const adapterMode = plan.native_imports ? "native-imports" : "flattened-markdown"; + const existingProjectContext = isRecord(existing["projectContext"]) ? existing["projectContext"] : null; + const existingProjectContextHash = existingProjectContext === null + ? null + : safeLegacyMetadataString(existingProjectContext["hash"], null); + const existingSourceHash = typeof existing["sourceHash"] === "string" ? existing["sourceHash"] : null; + const sourceHash = existingProjectContextHash === plan.bundle.hash && existingSourceHash !== null + ? existingSourceHash + : sha256(stableStringify({ previous: existingSourceHash, projectContext: plan.bundle.hash })); return credentialSafeSessionManifest({ schema: SESSION_RENDER_SCHEMA, tool, @@ -1418,7 +1532,7 @@ function buildSessionCompatibilityManifest(plan: ProjectContextPlan, now: Date): blockers: [], generatedAt: now.toISOString(), env: sanitizeLegacyEnvironment(existing["env"]), - sourceHash: sha256(stableStringify({ previous: typeof existing["sourceHash"] === "string" ? existing["sourceHash"] : null, projectContext: plan.bundle.hash })), + sourceHash, sources, skippedSources: sanitizeLegacySkippedSources(existing["skippedSources"]), files: [...files.filter((file) => file["relativePath"] !== targetRelativePath), updatedTarget], @@ -1702,6 +1816,102 @@ function writeMetadataSnapshot(plan: ProjectContextPlan, now: Date): string | nu return snapshotPath; } +function metadataSnapshotMatchesManifest( + plan: ProjectContextPlan, + manifest: ProjectContextManifestObservation, +): boolean { + const snapshotDir = resolve(plan.workspace_root, ...PROJECT_CONTEXT_SNAPSHOT_DIR.split("/")); + const snapshotPath = resolve( + snapshotDir, + `${safeFilename(manifest.projectContext.revision)}-${manifest.projectContext.hash.slice(-12)}.json`, + ); + if (!existsSync(snapshotPath)) return false; + const record = readJsonRecord(snapshotPath, plan.workspace_root); + const result = projectContextMetadataSnapshotSchema.safeParse(record); + if (!result.success) return false; + if ( + result.data.projectId !== manifest.projectContext.projectId + || result.data.revision !== manifest.projectContext.revision + || result.data.hash !== manifest.projectContext.hash + || result.data.status !== manifest.projectContext.status + ) return false; + const sortFiles = (files: T[]): T[] => + [...files].sort((left, right) => left.relativePath.localeCompare(right.relativePath)); + const snapshotFiles = sortFiles(result.data.files); + const manifestFiles = sortFiles(manifest.files.map((file) => ({ + relativePath: file.relativePath, + role: file.role, + sha256: file.sha256, + }))); + return JSON.stringify(snapshotFiles) === JSON.stringify(manifestFiles); +} + +function writeProjectContextRollbackSnapshot( + plan: ProjectContextPlan, + now: Date, + outputs: Array<{ path: string; content: string; role: SessionRenderFile["role"] }>, +): string | null { + const targetExpectedHash = expectedPlanHash(plan, plan.target_path); + if (targetExpectedHash === sha256(plan.target_content)) return null; + + const files = outputs.flatMap((output) => { + const expectedHash = expectedPlanHash(plan, output.path); + if (expectedHash === null) return []; + const content = readUtf8RegularFile( + output.path, + plan.workspace_root, + managedObservationMaxBytes(relativePosix(plan.workspace_root, output.path)), + ); + if (sha256(content) !== expectedHash) { + throw new ProjectContextHashRace( + `managed path changed while creating rollback evidence: ${relativePosix(plan.workspace_root, output.path)}`, + ); + } + return [{ + path: output.path, + relativePath: relativePosix(plan.workspace_root, output.path), + role: output.role, + sha256: expectedHash, + content, + }]; + }); + const afterFiles = outputs.map((output) => { + const previousHash = expectedPlanHash(plan, output.path); + const nextHash = sha256(output.content); + return { + path: output.path, + relativePath: relativePosix(plan.workspace_root, output.path), + role: output.role, + action: previousHash === null ? "create" : previousHash === nextHash ? "unchanged" : "update", + sha256: nextHash, + }; + }); + const snapshotDir = resolve(plan.workspace_root, ...SESSION_RENDER_SNAPSHOT_RELATIVE_DIR.split("/")); + ensureSafeDirectory(snapshotDir, plan.workspace_root, 0o700); + const timestamp = now.toISOString().replace(/[:.]/g, "-"); + const snapshotPath = resolve(snapshotDir, `${timestamp}-${randomUUID()}.json`); + const snapshot = { + schema: "hasna.configs.session-render-snapshot/v2", + createdAt: now.toISOString(), + tool: manifestTool(plan.runtime), + profile: "project-context", + targetHome: plan.workspace_root, + targetKind: "project-root", + manifestPath: plan.manifest_path, + previousManifest: null, + files, + afterFiles, + }; + atomicWriteFile( + snapshotPath, + `${JSON.stringify(snapshot, null, 2)}\n`, + plan.workspace_root, + 0o600, + null, + ); + return snapshotPath; +} + function readProjectContextManifest(path: string, workspaceRoot: string): ProjectContextManifestObservation | null { if (!existsSync(path)) return null; const record = readJsonRecord(path, workspaceRoot); @@ -1724,7 +1934,7 @@ function readProjectContextCache(path: string, workspaceRoot: string): ProjectCo if (!result.success) { throw new ProjectContextError("PROJECT_CONTEXT_CACHE_INVALID", "cache is malformed or incompatible"); } - const bundle = parseProjectContextBundle(result.data.bundle); + const bundle = parseProjectContextBundleInternal(result.data.bundle, true); if ( result.data.project_id !== bundle.project.id || result.data.revision !== bundle.revision || @@ -3553,12 +3763,22 @@ function removeHashForFingerprint(value: unknown): unknown { if (!isRecord(value)) return value; const copy: Record = {}; for (const [key, item] of Object.entries(value)) { - if (key === "hash") continue; + if (key === "generated_at" || key === "hash") continue; copy[key] = item; } return copy; } +function computeLegacyProjectContextSourceHash(value: unknown): string { + if (!isRecord(value)) return `sha256:${sha256(stableStringify(value))}`; + const copy: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (key === "hash") continue; + copy[key] = item; + } + return `sha256:${sha256(stableStringify(copy))}`; +} + function sha256(content: string): string { return createHash("sha256").update(content).digest("hex"); }