52803dda05
Add kinded parameters, capability bounds, Self, aliases and closed application identities. Check generic implementations universally and build candidate-specific codecs and descriptors from immutable schemas. Preserve lexical aliases and exact dispatch identities in package and host bindings. Add an imperative CRUD+index domain scaffold with explicit soft-deletion semantics, source/codegen regression coverage, installed CLI tests and an authoring guide. Existing Web Studio opaque props and class-level create-menu migration are separate from the implemented language core.
400 lines
18 KiB
TypeScript
400 lines
18 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import type { Binding, Conformance, DependencyBinding, PersistentAttachment, WorkspaceRevision } from "./types.js";
|
|
import { validateWorkspaceRevision } from "./validation.js";
|
|
|
|
/** Content hashing is independent of JSON object insertion order, not array order. */
|
|
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
|
export const canonicalJson = (value: unknown): string => {
|
|
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
|
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
if (typeof value === "object" && value !== null) {
|
|
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
throw new Error("Expected a plain JSON object");
|
|
return `{${Object.entries(value)
|
|
.filter(([, entry]) => entry !== undefined)
|
|
.sort(([a], [b]) => compareText(a, b))
|
|
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
|
|
.join(",")}}`;
|
|
}
|
|
throw new Error(`Cannot hash non-JSON value: ${typeof value}`);
|
|
};
|
|
export const contentDigest = (value: unknown) =>
|
|
`sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
|
const semantic = (value: unknown): unknown => {
|
|
if (Array.isArray(value)) return value.map(semantic);
|
|
if (value && typeof value === "object")
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation")
|
|
// These are authored data/maps, not schema nodes. A user field literally
|
|
// named displayName or documentation is semantic and must stay in the hash.
|
|
.map(([key, entry]) => [key, key === "defaultValue" || key === "fields" ? entry : semantic(entry)]),
|
|
);
|
|
return value;
|
|
};
|
|
const sorted = <T>(entries: readonly T[], key: (entry: T) => string) =>
|
|
[...entries].sort((a, b) => compareText(key(a), key(b)));
|
|
export const conformanceIdentity = (entry: Conformance): string =>
|
|
entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`;
|
|
|
|
export type StorageContract = {
|
|
id: string;
|
|
ownerId: string;
|
|
kind: "state" | "edge";
|
|
digest: string;
|
|
definition: unknown;
|
|
};
|
|
/** Automatic evolution preserves values; it never interprets migration code or
|
|
* guesses that a new nominal message descriptor means the same representation. */
|
|
export const storageChangeRequiresMigration = (
|
|
previous: StorageContract | undefined,
|
|
next: StorageContract | undefined,
|
|
oldAtomIds: ReadonlySet<string>,
|
|
): boolean => {
|
|
if (!next) return true;
|
|
const after = next.definition as PersistentAttachment;
|
|
if (!previous) {
|
|
if (after.kind === "state")
|
|
return (
|
|
oldAtomIds.has(after.attachedTo) && after.defaultValue === undefined && after.valueType.kind !== "optional"
|
|
);
|
|
return after.endpoints.some(
|
|
(endpoint) =>
|
|
endpoint.cardinality === "exactly-one" &&
|
|
(endpoint.constraint.kind !== "atom" || oldAtomIds.has(endpoint.constraint.atomId)),
|
|
);
|
|
}
|
|
if (previous.ownerId !== next.ownerId || previous.kind !== next.kind) return true;
|
|
const before = previous.definition as PersistentAttachment;
|
|
if (before.kind === "state" && after.kind === "state") {
|
|
// Capture materializes old defaults, so changing a default affects only
|
|
// newly constructed objects, not existing sparse state.
|
|
const { defaultValue: _beforeDefault, ...beforeStorage } = before;
|
|
const { defaultValue: _afterDefault, ...afterStorage } = after;
|
|
return canonicalJson(beforeStorage) !== canonicalJson(afterStorage);
|
|
}
|
|
return canonicalJson(before) !== canonicalJson(after);
|
|
};
|
|
export const storageContracts = (workspace: WorkspaceRevision): StorageContract[] => {
|
|
const result: StorageContract[] = [];
|
|
const add = (attachment: PersistentAttachment, ownerId: string) => {
|
|
const definition = semantic(attachment);
|
|
result.push({
|
|
id: attachment.id,
|
|
ownerId,
|
|
kind: attachment.kind,
|
|
definition,
|
|
digest: contentDigest({ ownerId, definition }),
|
|
});
|
|
};
|
|
for (const attachment of workspace.sharedAttachments) add(attachment, "legacy:workspace");
|
|
for (const conformance of workspace.conformances)
|
|
for (const attachment of conformance.privateAttachments) add(attachment, conformanceIdentity(conformance));
|
|
return sorted(result, (entry) => entry.id);
|
|
};
|
|
|
|
type GraphNode = { value: unknown; dependencies: Set<string>; reviewProviders: Set<string> };
|
|
export type RuntimeContract = {
|
|
groupId: string;
|
|
packageId: string;
|
|
packageRevisionId: string;
|
|
digest: string;
|
|
reviewProviders: string[];
|
|
dependencies: Array<{ id: string; digest: string }>;
|
|
};
|
|
|
|
/** Build only outbound execution dependencies. Incoming callers never retain or invalidate a provider. */
|
|
export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[] => {
|
|
const nodes = new Map<string, GraphNode>();
|
|
const node = (key: string, value: unknown) => {
|
|
const result = { value: semantic(value), dependencies: new Set<string>(), reviewProviders: new Set<string>() };
|
|
nodes.set(key, result);
|
|
return result;
|
|
};
|
|
const conformanceKey = (atom: string, iface: string) => `conformance:${atom}:${iface}`;
|
|
for (const storage of storageContracts(workspace)) node(`attachment:${storage.id}`, storage);
|
|
for (const iface of workspace.interfaceImports)
|
|
node(`interface:${iface.revisionId}`, {
|
|
...iface,
|
|
members: sorted(iface.members, (entry) => entry.id).map((entry) => ({
|
|
...entry,
|
|
operations: sorted(entry.operations, (operation) => operation.id),
|
|
})),
|
|
});
|
|
for (const pkg of workspace.packageImports)
|
|
node(`package:${pkg.revisionId}`, {
|
|
...pkg,
|
|
semanticMajor: pkg.semanticMajor ?? 1,
|
|
exports: sorted(pkg.exports, (entry) => entry.id).map((entry) => ({
|
|
...entry,
|
|
dependencyPorts: sorted(entry.dependencyPorts, (port) => port.id),
|
|
})),
|
|
});
|
|
const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
|
|
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
|
|
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`);
|
|
if (binding.kind === "constructor") {
|
|
parent.dependencies.add(`constructor:${binding.atomId}`);
|
|
const ctor = workspace.constructors.find((entry) => entry.atomId === binding.atomId);
|
|
const pkg = workspace.packageImports.find((entry) => entry.revisionId === ctor?.packageRevisionId);
|
|
if (pkg) reviews?.add(pkg.packageId);
|
|
}
|
|
if (binding.kind !== "constructor" && binding.via) parent.dependencies.add(`attachment:${binding.via.edgeTypeId}`);
|
|
if (binding.kind === "interface") {
|
|
parent.dependencies.add(`interface:${binding.interfaceRevisionId}`);
|
|
// An edge traversal may select any matching target. Conservatively include every possible witness.
|
|
for (const conformance of workspace.conformances) {
|
|
if (
|
|
conformance.interfaceRevisionId === binding.interfaceRevisionId &&
|
|
(binding.via || conformance.atomId === atomId)
|
|
) {
|
|
parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
|
|
reviews?.add(conformanceIdentity(conformance));
|
|
for (const operation of conformance.operationBindings) {
|
|
if (operation.binding.kind !== "package") continue;
|
|
const revisionId = operation.binding.packageRevisionId;
|
|
const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId);
|
|
if (pkg) reviews?.add(pkg.packageId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
const binding = (parent: GraphNode, value: Binding, atomId: string, context: unknown) => {
|
|
if (value.kind !== "package") {
|
|
dependency(parent, value, atomId);
|
|
return;
|
|
}
|
|
const packageNode = nodes.get(`package:${value.packageRevisionId}`)!;
|
|
parent.dependencies.add(`package:${value.packageRevisionId}`);
|
|
const normalized = { ...value, dependencies: sorted(value.dependencies, (entry) => entry.portId) };
|
|
const key = `binding:${contentDigest({ atomId, context, value: semantic(normalized) })}`;
|
|
const bound = node(key, { atomId, context, binding: normalized });
|
|
packageNode.dependencies.add(key);
|
|
for (const port of value.dependencies) dependency(bound, port.binding, atomId, packageNode.reviewProviders);
|
|
};
|
|
for (const conformance of workspace.conformances) {
|
|
const parent = node(conformanceKey(conformance.atomId, conformance.interfaceRevisionId), {
|
|
id: conformanceIdentity(conformance),
|
|
semanticMajor: conformance.semanticMajor ?? 1,
|
|
atomId: conformance.atomId,
|
|
interfaceRevisionId: conformance.interfaceRevisionId,
|
|
operations: sorted(conformance.operationBindings, (entry) => entry.operationId),
|
|
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
|
|
});
|
|
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
|
|
const contract = workspace.interfaceImports.find((entry) => entry.revisionId === conformance.interfaceRevisionId);
|
|
for (const required of contract?.requiredInterfaces ?? [])
|
|
parent.dependencies.add(conformanceKey(conformance.atomId, required));
|
|
for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`);
|
|
for (const operation of conformance.operationBindings)
|
|
binding(parent, operation.binding, conformance.atomId, {
|
|
conformanceId: conformanceIdentity(conformance),
|
|
semanticMajor: conformance.semanticMajor ?? 1,
|
|
operationId: operation.operationId,
|
|
});
|
|
for (const materialization of conformance.relationshipMaterializations) {
|
|
parent.dependencies.add(`constructor:${materialization.constructorAtomId}`);
|
|
parent.dependencies.add(`attachment:${materialization.edgeTypeId}`);
|
|
}
|
|
}
|
|
for (const constructor of workspace.constructors) {
|
|
const parent = node(`constructor:${constructor.atomId}`, constructor);
|
|
binding(parent, { kind: "package", ...constructor }, constructor.atomId, { constructor: constructor.atomId });
|
|
}
|
|
const counts = new Map<string, number>();
|
|
for (const pkg of workspace.packageImports) counts.set(pkg.packageId, (counts.get(pkg.packageId) ?? 0) + 1);
|
|
return sorted(
|
|
workspace.packageImports.map((pkg): RuntimeContract => {
|
|
const visited = new Set<string>();
|
|
const walk = (key: string) => {
|
|
if (visited.has(key)) return;
|
|
const entry = nodes.get(key);
|
|
if (!entry) throw new Error(`Unresolved execution dependency ${key}`);
|
|
visited.add(key);
|
|
for (const target of entry.dependencies) walk(target);
|
|
};
|
|
walk(`package:${pkg.revisionId}`);
|
|
const dependencies = [...visited].sort().map((id) => ({ id, digest: contentDigest(nodes.get(id)!.value) }));
|
|
return {
|
|
groupId: counts.get(pkg.packageId) === 1 ? pkg.packageId : `${pkg.packageId}#${pkg.revisionId}`,
|
|
packageId: pkg.packageId,
|
|
packageRevisionId: pkg.revisionId,
|
|
digest: contentDigest(dependencies),
|
|
reviewProviders: [...nodes.get(`package:${pkg.revisionId}`)!.reviewProviders].sort(),
|
|
dependencies,
|
|
};
|
|
}),
|
|
(entry) => entry.groupId,
|
|
);
|
|
};
|
|
|
|
export type EvolutionReview = {
|
|
requirementDigest: string;
|
|
decision: "changed" | "accepted-unchanged";
|
|
rationale: string;
|
|
agentId: string;
|
|
};
|
|
export type ReviewRequirement = {
|
|
consumerId: string;
|
|
providerId: string;
|
|
oldMajor: number;
|
|
newMajor: number;
|
|
requirementDigest: string;
|
|
};
|
|
export type RuntimeAction = {
|
|
groupId: string;
|
|
action: "keep" | "start" | "replace" | "retire";
|
|
previous?: RuntimeContract;
|
|
candidate?: RuntimeContract;
|
|
reasons: string[];
|
|
};
|
|
export type EvolutionReport = {
|
|
schemaVersion: 1;
|
|
baselineDigest: string | null;
|
|
candidateDigest: string;
|
|
checkerVersion: string;
|
|
runtimeActions: RuntimeAction[];
|
|
storageChanges: Array<{
|
|
id: string;
|
|
kind: "add" | "remove" | "change";
|
|
requiresMigration: boolean;
|
|
previous?: StorageContract;
|
|
candidate?: StorageContract;
|
|
}>;
|
|
migrationRequired: string[];
|
|
reviews: Array<ReviewRequirement & { accepted: boolean }>;
|
|
packageChecks: Array<{ groupId: string; contractDigest: string }>;
|
|
blockers: string[];
|
|
};
|
|
|
|
export const planEvolution = (
|
|
baseline: WorkspaceRevision | null,
|
|
candidate: WorkspaceRevision,
|
|
options: { reviews?: EvolutionReview[]; allowLegacy?: boolean } = {},
|
|
): EvolutionReport => {
|
|
const issues = validateWorkspaceRevision(candidate);
|
|
if (issues.length)
|
|
throw new Error(
|
|
`Invalid candidate workspace:\n${issues.map((entry) => `${entry.path}: ${entry.message}`).join("\n")}`,
|
|
);
|
|
if (baseline && baseline.workspaceId !== candidate.workspaceId)
|
|
throw new Error("Cannot evolve a different workspace");
|
|
const candidateDigest = contentDigest(candidate);
|
|
const checkerVersion = "quixos-evolution-v1";
|
|
const blockers: string[] = [];
|
|
if (!options.allowLegacy) {
|
|
if (candidate.sharedAttachments.length)
|
|
blockers.push("Assign legacy workspace-shared attachments to explicit conformance owners");
|
|
for (const entry of candidate.conformances)
|
|
if (!entry.id)
|
|
blockers.push(`Conformance ${entry.atomId} as ${entry.interfaceRevisionId} requires an authored ID`);
|
|
}
|
|
const previousRuntimes = new Map((baseline ? runtimeContracts(baseline) : []).map((entry) => [entry.groupId, entry]));
|
|
const nextRuntimes = new Map(runtimeContracts(candidate).map((entry) => [entry.groupId, entry]));
|
|
const runtimeActions: RuntimeAction[] = [...new Set([...previousRuntimes.keys(), ...nextRuntimes.keys()])]
|
|
.sort()
|
|
.map((groupId) => {
|
|
const previous = previousRuntimes.get(groupId),
|
|
next = nextRuntimes.get(groupId);
|
|
const before = new Map(previous?.dependencies.map((entry) => [entry.id, entry.digest]));
|
|
const after = new Map(next?.dependencies.map((entry) => [entry.id, entry.digest]));
|
|
const reasons = [...new Set([...before.keys(), ...after.keys()])]
|
|
.sort()
|
|
.filter((id) => before.get(id) !== after.get(id));
|
|
return {
|
|
groupId,
|
|
action: !previous ? "start" : !next ? "retire" : previous.digest === next.digest ? "keep" : "replace",
|
|
...(previous ? { previous } : {}),
|
|
...(next ? { candidate: next } : {}),
|
|
reasons,
|
|
};
|
|
});
|
|
const beforeStorage = new Map((baseline ? storageContracts(baseline) : []).map((entry) => [entry.id, entry]));
|
|
const afterStorage = new Map(storageContracts(candidate).map((entry) => [entry.id, entry]));
|
|
const storageChanges: EvolutionReport["storageChanges"] = [];
|
|
for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) {
|
|
const previous = beforeStorage.get(id),
|
|
next = afterStorage.get(id);
|
|
if (previous?.digest !== next?.digest)
|
|
storageChanges.push({
|
|
id,
|
|
kind: !previous ? "add" : !next ? "remove" : "change",
|
|
requiresMigration: storageChangeRequiresMigration(
|
|
previous,
|
|
next,
|
|
new Set(baseline?.atoms.map((atom) => atom.id) ?? []),
|
|
),
|
|
...(previous ? { previous } : {}),
|
|
...(next ? { candidate: next } : {}),
|
|
});
|
|
}
|
|
const providers = (workspace: WorkspaceRevision) => [
|
|
...workspace.packageImports.map((entry) => ({
|
|
id: entry.packageId as string,
|
|
revision: entry.revisionId as string,
|
|
major: entry.semanticMajor ?? 1,
|
|
node: `package:${entry.revisionId}`,
|
|
digest: contentDigest(entry),
|
|
})),
|
|
...workspace.conformances.map((entry) => ({
|
|
id: conformanceIdentity(entry),
|
|
revision: contentDigest(entry),
|
|
major: entry.semanticMajor ?? 1,
|
|
node: `conformance:${entry.atomId}:${entry.interfaceRevisionId}`,
|
|
digest: contentDigest(entry),
|
|
})),
|
|
];
|
|
const oldProviders = baseline ? providers(baseline) : [];
|
|
const reviews: EvolutionReport["reviews"] = [];
|
|
for (const provider of providers(candidate)) {
|
|
const old = oldProviders.filter((entry) => entry.id === provider.id);
|
|
if (old.length > 1) {
|
|
blockers.push(`Ambiguous semantic-major lineage for ${provider.id}`);
|
|
continue;
|
|
}
|
|
if (!old[0] || old[0].major === provider.major) continue;
|
|
if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`);
|
|
for (const consumer of nextRuntimes.values()) {
|
|
if (consumer.packageId === provider.id || !consumer.reviewProviders.includes(provider.id)) continue;
|
|
const requirement = {
|
|
consumerId: consumer.groupId,
|
|
providerId: provider.id,
|
|
oldMajor: old[0].major,
|
|
newMajor: provider.major,
|
|
};
|
|
const requirementDigest = contentDigest({
|
|
...requirement,
|
|
oldProvider: old[0].digest,
|
|
newProvider: provider.digest,
|
|
consumer: consumer.digest,
|
|
checkerVersion,
|
|
});
|
|
const accepted = (options.reviews ?? []).some(
|
|
(entry) =>
|
|
entry.requirementDigest === requirementDigest &&
|
|
["changed", "accepted-unchanged"].includes(entry.decision) &&
|
|
entry.rationale.trim() &&
|
|
entry.agentId.trim(),
|
|
);
|
|
reviews.push({ ...requirement, requirementDigest, accepted });
|
|
if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`);
|
|
}
|
|
}
|
|
return {
|
|
schemaVersion: 1,
|
|
baselineDigest: baseline ? contentDigest(baseline) : null,
|
|
candidateDigest,
|
|
checkerVersion,
|
|
runtimeActions,
|
|
storageChanges,
|
|
migrationRequired: storageChanges.filter((entry) => entry.requiresMigration).map((entry) => entry.id),
|
|
reviews,
|
|
packageChecks: runtimeActions
|
|
.filter((entry) => entry.candidate && entry.action !== "keep")
|
|
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })),
|
|
blockers,
|
|
};
|
|
};
|