feat: bind evidence trees to registry revisions
This commit is contained in:
@@ -154,6 +154,22 @@ export class GitWorkspaceRepository {
|
|||||||
return (await this.git(["rev-parse", `HEAD:${path}`])).trim();
|
return (await this.git(["rev-parse", `HEAD:${path}`])).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Assert that a canonical Evidence root is a Git tree at an exact commit. */
|
||||||
|
async assertTreeAtRevision(revision: string, repoRelativePath: string): Promise<void> {
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(revision)
|
||||||
|
|| !/^workspace-content\/[a-z][a-z0-9-]{2,62}\/evidence$/.test(repoRelativePath)) {
|
||||||
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence revision is invalid");
|
||||||
|
}
|
||||||
|
const type = (await this.git(
|
||||||
|
["cat-file", "-t", `${revision}:${repoRelativePath}`],
|
||||||
|
{},
|
||||||
|
"Workspace Evidence root is invalid",
|
||||||
|
)).trim();
|
||||||
|
if (type !== "tree") {
|
||||||
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence root is invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Write only a validated registry artifact below the checked-out repository. */
|
/** Write only a validated registry artifact below the checked-out repository. */
|
||||||
async writeRegistryFile(path: string, source: string): Promise<void> {
|
async writeRegistryFile(path: string, source: string): Promise<void> {
|
||||||
this.assertRegistryArtifactPath(path);
|
this.assertRegistryArtifactPath(path);
|
||||||
@@ -249,7 +265,11 @@ export class GitWorkspaceRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async git(args: string[], env: NodeJS.ProcessEnv = {}): Promise<string> {
|
private async git(
|
||||||
|
args: string[],
|
||||||
|
env: NodeJS.ProcessEnv = {},
|
||||||
|
invalidObjectMessage?: string,
|
||||||
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
"git",
|
"git",
|
||||||
@@ -258,6 +278,10 @@ export class GitWorkspaceRepository {
|
|||||||
);
|
);
|
||||||
return stdout;
|
return stdout;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (invalidObjectMessage && typeof error === "object" && error !== null
|
||||||
|
&& "code" in error && typeof error.code === "number") {
|
||||||
|
throw new WorkspaceRegistryError("workspace_invalid", invalidObjectMessage);
|
||||||
|
}
|
||||||
throw this.sanitizeGitError(error);
|
throw this.sanitizeGitError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,6 +387,9 @@ export class WorkspaceRegistry {
|
|||||||
}
|
}
|
||||||
if (request.action === "create" && existing) throw await this.conflictFor(request, status.head!, existing, local);
|
if (request.action === "create" && existing) throw await this.conflictFor(request, status.head!, existing, local);
|
||||||
if (request.action !== "create" && !existing) throw await this.conflictFor(request, status.head!, existing, local);
|
if (request.action !== "create" && !existing) throw await this.conflictFor(request, status.head!, existing, local);
|
||||||
|
if (request.action !== "delete") {
|
||||||
|
await this.assertEvidenceContext(request.workspace, status.head!);
|
||||||
|
}
|
||||||
|
|
||||||
const yamlPath = workspacePath(id);
|
const yamlPath = workspacePath(id);
|
||||||
const docPaths = this.documentationPaths(id);
|
const docPaths = this.documentationPaths(id);
|
||||||
@@ -472,6 +475,20 @@ export class WorkspaceRegistry {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertEvidenceContext(workspace: WorkspaceDescriptor, revision: string): Promise<void> {
|
||||||
|
if (!isCanonicalWorkspace(workspace)) return;
|
||||||
|
if (workspace.evidence?.source.type !== "filesystem") return;
|
||||||
|
// P6 owns recursive containment. Here we deliberately validate only the declared root object.
|
||||||
|
await this.repository.assertTreeAtRevision(revision, workspace.evidence.source.uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertSnapshotEvidenceContexts(state: ActiveState): Promise<void> {
|
||||||
|
for (const revision of state.revisions) {
|
||||||
|
const workspace = parseWorkspaceYaml(await readFile(revision.snapshotPath, "utf8"));
|
||||||
|
await this.assertEvidenceContext(workspace, revision.commit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async activate(commit: string): Promise<void> {
|
private async activate(commit: string): Promise<void> {
|
||||||
const safeHead = safeCommit(commit);
|
const safeHead = safeCommit(commit);
|
||||||
const files = await this.repository.workspacePaths();
|
const files = await this.repository.workspacePaths();
|
||||||
@@ -495,6 +512,7 @@ export class WorkspaceRegistry {
|
|||||||
if (workspace.workspace.id !== id) {
|
if (workspace.workspace.id !== id) {
|
||||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path");
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path");
|
||||||
}
|
}
|
||||||
|
await this.assertEvidenceContext(workspace, safeHead);
|
||||||
let snapshotSource = source;
|
let snapshotSource = source;
|
||||||
const state: WorkspaceRevision["state"] = isCanonicalWorkspace(workspace)
|
const state: WorkspaceRevision["state"] = isCanonicalWorkspace(workspace)
|
||||||
? "operational"
|
? "operational"
|
||||||
@@ -721,6 +739,7 @@ export class WorkspaceRegistry {
|
|||||||
`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`,
|
`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`,
|
||||||
]);
|
]);
|
||||||
await this.assertManifestFiles(directory, manifest.files, legacyExpected);
|
await this.assertManifestFiles(directory, manifest.files, legacyExpected);
|
||||||
|
await this.assertSnapshotEvidenceContexts(state);
|
||||||
const expected = this.expectedSnapshotFiles(state);
|
const expected = this.expectedSnapshotFiles(state);
|
||||||
const files = Object.fromEntries(expected.map((name) => [name, manifest.files[name]]));
|
const files = Object.fromEntries(expected.map((name) => [name, manifest.files[name]]));
|
||||||
await this.writeSnapshotManifest(directory, { ...state, files });
|
await this.writeSnapshotManifest(directory, { ...state, files });
|
||||||
@@ -757,6 +776,7 @@ export class WorkspaceRegistry {
|
|||||||
throw new Error("manifest revisions do not match active state");
|
throw new Error("manifest revisions do not match active state");
|
||||||
}
|
}
|
||||||
await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state));
|
await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state));
|
||||||
|
await this.assertSnapshotEvidenceContexts(state);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof WorkspaceRegistryError) throw error;
|
if (error instanceof WorkspaceRegistryError) throw error;
|
||||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed");
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed");
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ llm_policy:
|
|||||||
allowed: [zai/glm-5.2]
|
allowed: [zai/glm-5.2]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
function withFilesystemEvidence(source: string, id = "psd-clinical"): string {
|
||||||
|
return source.concat(`evidence:
|
||||||
|
source:
|
||||||
|
type: filesystem
|
||||||
|
uri: workspace-content/${id}/evidence
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
function withDwhRestTransport(source: string): string {
|
function withDwhRestTransport(source: string): string {
|
||||||
return source.replace(
|
return source.replace(
|
||||||
"supported_transports: [postgres_direct]",
|
"supported_transports: [postgres_direct]",
|
||||||
@@ -181,7 +189,15 @@ async function fixture(workspaceSource = validYaml): Promise<{
|
|||||||
await git(source, ["config", "user.email", "workspace-registry@example.invalid"]);
|
await git(source, ["config", "user.email", "workspace-registry@example.invalid"]);
|
||||||
mkdirSync(join(source, "workspaces"));
|
mkdirSync(join(source, "workspaces"));
|
||||||
writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), workspaceSource);
|
writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), workspaceSource);
|
||||||
await git(source, ["add", "workspaces/psd-clinical.yaml"]);
|
if (workspaceSource.includes("type: filesystem")) {
|
||||||
|
mkdirSync(join(source, "workspace-content", "psd-clinical", "evidence"), { recursive: true });
|
||||||
|
mkdirSync(join(source, "workspace-content", "research", "evidence"), { recursive: true });
|
||||||
|
writeFileSync(join(source, "workspace-content", "psd-clinical", "evidence", "guide.md"), "guide v1\n");
|
||||||
|
writeFileSync(join(source, "workspace-content", "research", "evidence", "guide.md"), "research guide\n");
|
||||||
|
await git(source, ["add", "workspaces", "workspace-content"]);
|
||||||
|
} else {
|
||||||
|
await git(source, ["add", "workspaces/psd-clinical.yaml"]);
|
||||||
|
}
|
||||||
await git(source, ["commit", "-m", "Initial workspace"]);
|
await git(source, ["commit", "-m", "Initial workspace"]);
|
||||||
await git(source, ["remote", "add", "origin", remote]);
|
await git(source, ["remote", "add", "origin", remote]);
|
||||||
await git(source, ["push", "origin", "main"]);
|
await git(source, ["push", "origin", "main"]);
|
||||||
@@ -247,6 +263,16 @@ function workspaceWith(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filesystemWorkspace(id: string): CanonicalWorkspace {
|
||||||
|
return parseWorkspaceYaml(withFilesystemEvidence(
|
||||||
|
validYaml
|
||||||
|
.replace("id: psd-clinical", `id: ${id}`)
|
||||||
|
.replace("name: Policlinico San Donato", `name: ${id}`)
|
||||||
|
.replace("collection: psd-clinical", `collection: ${id}`),
|
||||||
|
id,
|
||||||
|
)) as CanonicalWorkspace;
|
||||||
|
}
|
||||||
|
|
||||||
async function checkoutStatus(checkout: string): Promise<{ porcelain: string; divergence: string }> {
|
async function checkoutStatus(checkout: string): Promise<{ porcelain: string; divergence: string }> {
|
||||||
return {
|
return {
|
||||||
porcelain: await gitOutput(checkout, ["status", "--porcelain"]),
|
porcelain: await gitOutput(checkout, ["status", "--porcelain"]),
|
||||||
@@ -321,6 +347,144 @@ test("concurrent first lists lazily bootstrap a clean registry once safely", asy
|
|||||||
expect(existsSync(join(root, "state", "active.json"))).toBe(true);
|
expect(existsSync(join(root, "state", "active.json"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("publishes a filesystem descriptor only when its Evidence tree exists in the pulled base", async () => {
|
||||||
|
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||||
|
const root = join(remote.root, "registry");
|
||||||
|
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||||
|
await registry.bootstrap();
|
||||||
|
const evidencePath = "workspace-content/research/evidence";
|
||||||
|
const initialTree = await gitOutput(remote.root, [
|
||||||
|
"--git-dir", remote.remote, "rev-parse", `${remote.initialCommit}:${evidencePath}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const created = await registry.publish({
|
||||||
|
action: "create",
|
||||||
|
workspace: filesystemWorkspace("research"),
|
||||||
|
baseCommit: remote.initialCommit,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created?.commit).not.toBe(remote.initialCommit);
|
||||||
|
await expect(runFile("git", [
|
||||||
|
"--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:workspaces/research.yaml`,
|
||||||
|
], { cwd: remote.root })).resolves.toBeDefined();
|
||||||
|
await expect(runFile("git", [
|
||||||
|
"--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:${evidencePath}/guide.md`,
|
||||||
|
], { cwd: remote.root })).resolves.toBeDefined();
|
||||||
|
expect(await gitOutput(remote.root, [
|
||||||
|
"--git-dir", remote.remote, "rev-parse", `${created!.commit}:${evidencePath}`,
|
||||||
|
])).toBe(initialTree);
|
||||||
|
|
||||||
|
const remoteHeadBeforeMissing = await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"]);
|
||||||
|
await expect(registry.publish({
|
||||||
|
action: "create",
|
||||||
|
workspace: filesystemWorkspace("missing-tree"),
|
||||||
|
baseCommit: created!.commit,
|
||||||
|
})).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||||
|
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(
|
||||||
|
remoteHeadBeforeMissing,
|
||||||
|
);
|
||||||
|
expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["missing", "blob"])(
|
||||||
|
"rejects a remote filesystem descriptor with a %s Evidence root and keeps the active snapshot",
|
||||||
|
async (invalidKind) => {
|
||||||
|
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||||
|
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||||
|
await registry.bootstrap();
|
||||||
|
const evidenceRoot = join(remote.source, "workspace-content", "psd-clinical", "evidence");
|
||||||
|
rmSync(evidenceRoot, { recursive: true, force: true });
|
||||||
|
if (invalidKind === "blob") writeFileSync(evidenceRoot, "not a tree\n");
|
||||||
|
await git(remote.source, ["add", "-A", "workspace-content/psd-clinical/evidence"]);
|
||||||
|
await git(remote.source, ["commit", "-m", `Make Evidence root ${invalidKind}`]);
|
||||||
|
await git(remote.source, ["push", "origin", "main"]);
|
||||||
|
const invalidCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||||
|
|
||||||
|
await expect(registry.pull()).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||||
|
expect(invalidCommit).not.toBe(remote.initialCommit);
|
||||||
|
await expect(registry.read("psd-clinical")).resolves.toMatchObject({
|
||||||
|
revision: { commit: remote.initialCommit },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("creates an immutable descriptor revision for a content-only Evidence commit", async () => {
|
||||||
|
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||||
|
const root = join(remote.root, "registry");
|
||||||
|
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||||
|
await registry.bootstrap();
|
||||||
|
const initial = await registry.read("psd-clinical");
|
||||||
|
const evidencePath = "workspace-content/psd-clinical/evidence";
|
||||||
|
const initialTree = await gitOutput(remote.source, ["rev-parse", `${remote.initialCommit}:${evidencePath}`]);
|
||||||
|
writeFileSync(join(remote.source, evidencePath, "guide.md"), "guide v2\n");
|
||||||
|
await git(remote.source, ["add", `${evidencePath}/guide.md`]);
|
||||||
|
await git(remote.source, ["commit", "-m", "Update Evidence only"]);
|
||||||
|
await git(remote.source, ["push", "origin", "main"]);
|
||||||
|
const contentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||||
|
const contentTree = await gitOutput(remote.source, ["rev-parse", `${contentCommit}:${evidencePath}`]);
|
||||||
|
|
||||||
|
await registry.pull();
|
||||||
|
const current = await registry.read("psd-clinical");
|
||||||
|
|
||||||
|
expect(contentTree).not.toBe(initialTree);
|
||||||
|
expect(current.revision).toMatchObject({ commit: contentCommit, blob: initial.revision.blob });
|
||||||
|
expect(current.revision.snapshotPath).not.toBe(initial.revision.snapshotPath);
|
||||||
|
expect(readFileSync(current.revision.snapshotPath, "utf8")).toBe(
|
||||||
|
readFileSync(initial.revision.snapshotPath, "utf8"),
|
||||||
|
);
|
||||||
|
await expect(runFile("git", [
|
||||||
|
"--git-dir", remote.remote, "cat-file", "-e", `${contentCommit}:${evidencePath}/guide.md`,
|
||||||
|
], { cwd: remote.root })).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a stale API update after a content-only Evidence commit", async () => {
|
||||||
|
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||||
|
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||||
|
await registry.bootstrap();
|
||||||
|
const initial = await registry.read("psd-clinical");
|
||||||
|
const guide = join(remote.source, "workspace-content", "psd-clinical", "evidence", "guide.md");
|
||||||
|
writeFileSync(guide, "curator content\n");
|
||||||
|
await git(remote.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]);
|
||||||
|
await git(remote.source, ["commit", "-m", "Curator Evidence update"]);
|
||||||
|
await git(remote.source, ["push", "origin", "main"]);
|
||||||
|
const curatorCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||||
|
|
||||||
|
await expect(registry.publish({
|
||||||
|
action: "update",
|
||||||
|
workspace: filesystemWorkspace("psd-clinical"),
|
||||||
|
baseCommit: initial.revision.commit,
|
||||||
|
baseBlob: initial.revision.blob,
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: "workspace_conflict",
|
||||||
|
expected: { commit: initial.revision.commit, blob: initial.revision.blob },
|
||||||
|
actual: { commit: curatorCommit, blob: initial.revision.blob },
|
||||||
|
});
|
||||||
|
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(curatorCommit);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps content-only historical descriptor revisions distinguishable by commit", async () => {
|
||||||
|
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||||
|
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||||
|
await registry.bootstrap();
|
||||||
|
writeFileSync(
|
||||||
|
join(remote.source, "workspace-content", "psd-clinical", "evidence", "guide.md"),
|
||||||
|
"historical content\n",
|
||||||
|
);
|
||||||
|
await git(remote.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]);
|
||||||
|
await git(remote.source, ["commit", "-m", "Retained Evidence update"]);
|
||||||
|
await git(remote.source, ["push", "origin", "main"]);
|
||||||
|
const contentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||||
|
await registry.pull();
|
||||||
|
await registry.reconcileSnapshotRetention([remote.initialCommit]);
|
||||||
|
|
||||||
|
const retained = (await registry.listRetainedSnapshots()).filter(({ id }) => id === "psd-clinical");
|
||||||
|
expect(retained.map(({ commit }) => commit)).toEqual([contentCommit, remote.initialCommit]);
|
||||||
|
const oldPinned = await registry.readPinned("psd-clinical", remote.initialCommit);
|
||||||
|
const newPinned = await registry.readPinned("psd-clinical", contentCommit);
|
||||||
|
expect(oldPinned.workspaceConfigPath).not.toBe(newPinned.workspaceConfigPath);
|
||||||
|
expect(oldPinned.workspace).toEqual(newPinned.workspace);
|
||||||
|
});
|
||||||
|
|
||||||
test("publishes create, update, and delete with the configured Git author identity", async () => {
|
test("publishes create, update, and delete with the configured Git author identity", async () => {
|
||||||
const remote = await fixture();
|
const remote = await fixture();
|
||||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote, {
|
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
@@ -45,7 +45,7 @@ async function git(cwd: string, args: string[]): Promise<void> {
|
|||||||
await runFile("git", args, { cwd });
|
await runFile("git", args, { cwd });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function temporaryRemote(): Promise<{ root: string; remote: string; initialCommit: string }> {
|
async function temporaryRemote(): Promise<{ root: string; remote: string; source: string; initialCommit: string }> {
|
||||||
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-"));
|
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-"));
|
||||||
temporaryRoots.push(root);
|
temporaryRoots.push(root);
|
||||||
const remote = join(root, "remote.git");
|
const remote = join(root, "remote.git");
|
||||||
@@ -57,12 +57,23 @@ async function temporaryRemote(): Promise<{ root: string; remote: string; initia
|
|||||||
await git(source, ["config", "user.email", "workspace-registry@example.invalid"]);
|
await git(source, ["config", "user.email", "workspace-registry@example.invalid"]);
|
||||||
mkdirSync(join(source, "workspaces"));
|
mkdirSync(join(source, "workspaces"));
|
||||||
writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), validYaml);
|
writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), validYaml);
|
||||||
await git(source, ["add", "workspaces/psd-clinical.yaml"]);
|
writeFileSync(join(source, "workspaces", "research.yaml"), validYaml
|
||||||
|
.replace("id: psd-clinical", "id: research"));
|
||||||
|
mkdirSync(join(source, "workspace-content", "research", "evidence"), { recursive: true });
|
||||||
|
mkdirSync(join(source, "workspace-content", "other", "evidence"), { recursive: true });
|
||||||
|
mkdirSync(join(source, "workspace-content", "blob"), { recursive: true });
|
||||||
|
mkdirSync(join(source, "workspace-content", "link"), { recursive: true });
|
||||||
|
writeFileSync(join(source, "workspace-content", "research", "evidence", "guide.md"), "guide v1\n");
|
||||||
|
writeFileSync(join(source, "workspace-content", "other", "evidence", "other.md"), "other\n");
|
||||||
|
writeFileSync(join(source, "workspace-content", "blob", "evidence"), "not a tree\n");
|
||||||
|
symlinkSync("../research/evidence", join(source, "workspace-content", "link", "evidence"));
|
||||||
|
symlinkSync("guide.md", join(source, "workspace-content", "research", "evidence", "nested-link"));
|
||||||
|
await git(source, ["add", "workspaces", "workspace-content"]);
|
||||||
await git(source, ["commit", "-m", "Initial workspace"]);
|
await git(source, ["commit", "-m", "Initial workspace"]);
|
||||||
await git(source, ["remote", "add", "origin", remote]);
|
await git(source, ["remote", "add", "origin", remote]);
|
||||||
await git(source, ["push", "origin", "main"]);
|
await git(source, ["push", "origin", "main"]);
|
||||||
const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: source });
|
const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: source });
|
||||||
return { root, remote, initialCommit: stdout.trim() };
|
return { root, remote, source, initialCommit: stdout.trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function config(root: string, remoteUrl: string): WorkspaceRegistryConfig {
|
function config(root: string, remoteUrl: string): WorkspaceRegistryConfig {
|
||||||
@@ -94,6 +105,101 @@ test("bootstraps a persistent checkout from a local bare repository", async () =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts only a tree at the declared Evidence root for the requested revision", async () => {
|
||||||
|
const fixture = await temporaryRemote();
|
||||||
|
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
|
||||||
|
await repository.bootstrap();
|
||||||
|
|
||||||
|
await expect(repository.assertTreeAtRevision(
|
||||||
|
fixture.initialCommit,
|
||||||
|
"workspace-content/research/evidence",
|
||||||
|
)).resolves.toBeUndefined();
|
||||||
|
await expect(repository.assertTreeAtRevision(
|
||||||
|
fixture.initialCommit,
|
||||||
|
"workspace-content/missing/evidence",
|
||||||
|
)).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||||
|
await expect(repository.assertTreeAtRevision(
|
||||||
|
fixture.initialCommit,
|
||||||
|
"workspace-content/blob/evidence",
|
||||||
|
)).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||||
|
await expect(repository.assertTreeAtRevision(
|
||||||
|
fixture.initialCommit,
|
||||||
|
"workspace-content/link/evidence",
|
||||||
|
)).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redacts Git failures while checking an Evidence tree", async () => {
|
||||||
|
const fixture = await temporaryRemote();
|
||||||
|
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
|
||||||
|
await repository.bootstrap();
|
||||||
|
rmSync(repository.repoPath, { recursive: true, force: true });
|
||||||
|
|
||||||
|
const error = await repository.assertTreeAtRevision(
|
||||||
|
fixture.initialCommit,
|
||||||
|
"workspace-content/research/evidence",
|
||||||
|
).catch((failure: unknown) => failure);
|
||||||
|
expect(error).toMatchObject({ code: "git_unavailable", message: "Workspace Git operation failed" });
|
||||||
|
expect((error as Error).message).not.toContain(fixture.root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("binds Evidence tree validation to old and new content-only commits", async () => {
|
||||||
|
const fixture = await temporaryRemote();
|
||||||
|
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
|
||||||
|
await repository.bootstrap();
|
||||||
|
writeFileSync(join(fixture.source, "workspace-content", "research", "evidence", "guide.md"), "guide v2\n");
|
||||||
|
await git(fixture.source, ["add", "workspace-content/research/evidence/guide.md"]);
|
||||||
|
await git(fixture.source, ["commit", "-m", "Update Evidence content"]);
|
||||||
|
await git(fixture.source, ["push", "origin", "main"]);
|
||||||
|
const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: fixture.source });
|
||||||
|
const newCommit = stdout.trim();
|
||||||
|
await repository.pull();
|
||||||
|
const oldTree = (await runFile("git", ["rev-parse", `${fixture.initialCommit}:workspace-content/research/evidence`], {
|
||||||
|
cwd: fixture.source,
|
||||||
|
})).stdout.trim();
|
||||||
|
const newTree = (await runFile("git", ["rev-parse", `${newCommit}:workspace-content/research/evidence`], {
|
||||||
|
cwd: fixture.source,
|
||||||
|
})).stdout.trim();
|
||||||
|
|
||||||
|
expect(newTree).not.toBe(oldTree);
|
||||||
|
await expect(repository.assertTreeAtRevision(
|
||||||
|
fixture.initialCommit,
|
||||||
|
"workspace-content/research/evidence",
|
||||||
|
)).resolves.toBeUndefined();
|
||||||
|
await expect(repository.assertTreeAtRevision(
|
||||||
|
newCommit,
|
||||||
|
"workspace-content/research/evidence",
|
||||||
|
)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defers nested Evidence symlink containment to P6", async () => {
|
||||||
|
const fixture = await temporaryRemote();
|
||||||
|
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
|
||||||
|
await repository.bootstrap();
|
||||||
|
|
||||||
|
// Task 2 validates only the declared root object. Recursive containment remains a P6 boundary.
|
||||||
|
await expect(repository.assertTreeAtRevision(
|
||||||
|
fixture.initialCommit,
|
||||||
|
"workspace-content/research/evidence",
|
||||||
|
)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects malformed revisions and shell-like paths without executing them", async () => {
|
||||||
|
const fixture = await temporaryRemote();
|
||||||
|
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
|
||||||
|
await repository.bootstrap();
|
||||||
|
const marker = join(fixture.root, "shell-marker");
|
||||||
|
|
||||||
|
await expect(repository.assertTreeAtRevision(
|
||||||
|
"HEAD",
|
||||||
|
"workspace-content/research/evidence",
|
||||||
|
)).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||||
|
await expect(repository.assertTreeAtRevision(
|
||||||
|
fixture.initialCommit,
|
||||||
|
`workspace-content/research/evidence;touch ${marker}`,
|
||||||
|
)).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||||
|
expect(existsSync(marker)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("redacts failed Git checkout details behind a stable error code", async () => {
|
test("redacts failed Git checkout details behind a stable error code", async () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-missing-"));
|
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-missing-"));
|
||||||
temporaryRoots.push(root);
|
temporaryRoots.push(root);
|
||||||
|
|||||||
Reference in New Issue
Block a user