Implement checked workspace integration contracts (WI-01)
This commit is contained in:
@@ -27,6 +27,24 @@ export interface PackageArtifacts {
|
||||
sourceDigest: string;
|
||||
versions: ExecutionPlan["definition"]["versions"];
|
||||
implementations: ArtifactImplementation[];
|
||||
components?: ComponentArtifact[];
|
||||
}
|
||||
export const componentSharedModules = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-dom/client",
|
||||
"react/jsx-runtime",
|
||||
"@quixos/camino-react",
|
||||
] as const;
|
||||
export interface ComponentArtifact {
|
||||
id: string;
|
||||
subject: string;
|
||||
compatibility: "quixos-react-component-v1";
|
||||
path: string;
|
||||
exportName: string;
|
||||
sharedModules: Record<(typeof componentSharedModules)[number], string>;
|
||||
files: ArtifactFile[];
|
||||
contentDigest: string;
|
||||
}
|
||||
export interface ImplementationSelection {
|
||||
target: string;
|
||||
@@ -119,12 +137,73 @@ export function implementationDigest(implementation: Omit<ArtifactImplementation
|
||||
files: [...implementation.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
||||
});
|
||||
}
|
||||
export function componentArtifactDigest(component: Omit<ComponentArtifact, "contentDigest">): string {
|
||||
return hexDigest({
|
||||
...component,
|
||||
files: [...component.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
||||
});
|
||||
}
|
||||
function parseArtifactFiles(value: unknown, path: string): ArtifactFile[] {
|
||||
const files = array(value, path).map((value): ArtifactFile => {
|
||||
const file = object(value, path);
|
||||
keys(file, ["path", "digest", "bytes", "mediaType"], [], path);
|
||||
if (!Number.isSafeInteger(file.bytes) || (file.bytes as number) < 0 || (file.bytes as number) > 256 * 1024 * 1024)
|
||||
fail(path, "Artifact file exceeds size bound");
|
||||
return {
|
||||
path: artifactPath(file.path),
|
||||
digest: digest(file.digest, path),
|
||||
bytes: file.bytes as number,
|
||||
mediaType: text(file.mediaType, path),
|
||||
};
|
||||
});
|
||||
unique(
|
||||
files.map((file) => file.path),
|
||||
path,
|
||||
);
|
||||
return files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
||||
}
|
||||
function parseComponent(value: unknown, index: number): ComponentArtifact {
|
||||
const path = `components[${index}]`,
|
||||
entry = object(value, path);
|
||||
keys(
|
||||
entry,
|
||||
["id", "subject", "compatibility", "path", "exportName", "sharedModules", "files", "contentDigest"],
|
||||
[],
|
||||
path,
|
||||
);
|
||||
if (entry.compatibility !== "quixos-react-component-v1") fail(path, "Unsupported component ABI");
|
||||
const files = parseArtifactFiles(entry.files, `${path}.files`);
|
||||
const shared = object(entry.sharedModules, `${path}.sharedModules`);
|
||||
keys(shared, [...componentSharedModules], [], `${path}.sharedModules`);
|
||||
const sharedModules = Object.fromEntries(
|
||||
componentSharedModules.map((name) => [name, artifactPath(shared[name])]),
|
||||
) as ComponentArtifact["sharedModules"];
|
||||
const entryPath = artifactPath(entry.path),
|
||||
exportName = text(entry.exportName, path);
|
||||
if (!/^[$A-Z_a-z][$\w]*$/.test(exportName)) fail(path, "Expected a JavaScript component export name");
|
||||
for (const modulePath of [entryPath, ...Object.values(sharedModules)])
|
||||
if (!files.some((file) => file.path === modulePath && file.mediaType === "text/javascript"))
|
||||
fail(path, "Component entry and shared modules require declared JavaScript files");
|
||||
const component: ComponentArtifact = {
|
||||
id: text(entry.id, path),
|
||||
subject: text(entry.subject, path),
|
||||
compatibility: "quixos-react-component-v1",
|
||||
path: entryPath,
|
||||
exportName,
|
||||
sharedModules,
|
||||
files,
|
||||
contentDigest: digest(entry.contentDigest, path),
|
||||
};
|
||||
const { contentDigest, ...body } = component;
|
||||
if (componentArtifactDigest(body) !== contentDigest) fail(path, "Component metadata digest mismatch");
|
||||
return component;
|
||||
}
|
||||
export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
||||
const record = object(value, "manifest");
|
||||
keys(
|
||||
record,
|
||||
["schemaVersion", "packageRevisionId", "bindingDigest", "sourceDigest", "versions", "implementations"],
|
||||
[],
|
||||
["components"],
|
||||
"manifest",
|
||||
);
|
||||
if (record.schemaVersion !== 1) fail("manifest", "Unsupported artifact manifest version");
|
||||
@@ -152,26 +231,7 @@ export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
||||
if (entry.compatibility === "quixos-js-module-v1") text(entry.registryExport, `${path}.registryExport`);
|
||||
const exports = array(entry.exports, `${path}.exports`).map((value) => text(value, `${path}.exports`));
|
||||
unique(exports, `${path}.exports`);
|
||||
const files = array(entry.files, `${path}.files`).map((value): ArtifactFile => {
|
||||
const file = object(value, `${path}.file`);
|
||||
keys(file, ["path", "digest", "bytes", "mediaType"], [], `${path}.file`);
|
||||
if (
|
||||
!Number.isSafeInteger(file.bytes) ||
|
||||
(file.bytes as number) < 0 ||
|
||||
(file.bytes as number) > 256 * 1024 * 1024
|
||||
)
|
||||
fail(path, "Artifact file exceeds size bound");
|
||||
return {
|
||||
path: artifactPath(file.path),
|
||||
digest: digest(file.digest, path),
|
||||
bytes: file.bytes as number,
|
||||
mediaType: text(file.mediaType, path),
|
||||
};
|
||||
});
|
||||
unique(
|
||||
files.map((file) => file.path),
|
||||
`${path}.files`,
|
||||
);
|
||||
const files = parseArtifactFiles(entry.files, `${path}.files`);
|
||||
const entryPath = artifactPath(entry.path);
|
||||
if (!files.some((file) => file.path === entryPath)) fail(path, "Entrypoint is not covered by the file manifest");
|
||||
const result: ArtifactImplementation = {
|
||||
@@ -194,6 +254,13 @@ export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
||||
implementations.map((implementation) => implementation.id),
|
||||
"implementations",
|
||||
);
|
||||
const components =
|
||||
record.components === undefined ? undefined : array(record.components, "components").map(parseComponent);
|
||||
if (components)
|
||||
unique(
|
||||
components.map((component) => component.id),
|
||||
"components",
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
packageRevisionId: text(record.packageRevisionId, "packageRevisionId"),
|
||||
@@ -201,11 +268,20 @@ export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
||||
sourceDigest: digest(record.sourceDigest, "sourceDigest"),
|
||||
versions: versions as unknown as PackageArtifacts["versions"],
|
||||
implementations,
|
||||
...(components ? { components } : {}),
|
||||
};
|
||||
}
|
||||
function checkCoverage(expected: PlanPackage, manifest: PackageArtifacts): void {
|
||||
if (expected.bindingDigest !== manifest.bindingDigest || expected.sourceDigest !== manifest.sourceDigest)
|
||||
fail(expected.revision, "Substituted binding schema or source revision");
|
||||
for (const component of manifest.components ?? []) {
|
||||
const declaration = expected.components?.find((entry) => entry.id === component.id);
|
||||
if (!declaration || declaration.subject !== component.subject || declaration.runtime !== "react")
|
||||
fail(component.id, "Component differs from its declared subject/runtime contract");
|
||||
}
|
||||
for (const component of expected.components ?? [])
|
||||
if (!manifest.components?.some((entry) => entry.id === component.id))
|
||||
fail(component.id, "Missing component artifact");
|
||||
for (const implementation of manifest.implementations)
|
||||
for (const id of implementation.exports) {
|
||||
const entry = expected.exports.find((e) => e.id === id);
|
||||
@@ -272,6 +348,19 @@ export async function checkExecutionWorld(
|
||||
digest: implementation.contentDigest,
|
||||
});
|
||||
}
|
||||
for (const component of manifest.components ?? []) {
|
||||
for (const file of component.files) {
|
||||
const existing = files.get(file.path);
|
||||
if (existing && canonicalJson(existing) !== canonicalJson(file))
|
||||
fail(file.path, "Conflicting file descriptors");
|
||||
files.set(file.path, file);
|
||||
}
|
||||
artifacts.push({
|
||||
binding: JSON.stringify([expected.revision, "component", component.id]),
|
||||
target: "browser-renderer",
|
||||
digest: component.contentDigest,
|
||||
});
|
||||
}
|
||||
for (const file of files.values()) {
|
||||
const bytes = await read(expected.revision, file.path, file.bytes);
|
||||
if (bytes.byteLength !== file.bytes || createHash("sha256").update(bytes).digest("hex") !== file.digest)
|
||||
|
||||
Reference in New Issue
Block a user