Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 95 additions & 77 deletions packages/local-runtime/model-store-yaml/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,90 +289,98 @@ export class YamlModelStore implements ModelStorePort {
}

async validateModel(workspace: WorkspaceRef): Promise<ModelValidationResult> {
const errors: string[] = [];
const diagnostics: NonNullable<ModelValidationResult["diagnostics"]> = [];
const nodeSchemaErrors: NonNullable<ModelValidationResult["nodeSchemaErrors"]> = [];
for (const required of [".archcontext/manifest.yaml", ".archcontext/product.yaml"]) {
return validateModelFiles(listModelFiles(workspace.root));
}

async writeChangeSetPreview(changeSet: unknown): Promise<{ digest: string; summary: string }> {
return { digest: digestJson(changeSet as Json), summary: "ChangeSet preview generated" };
}
}

/**
* The one model validator (#576). It validates a set of model files, whatever their source: the
* YAML store passes the files on disk, and the ledger read path passes the ledger projection merged
* with the files the ledger does not own. Both read modes therefore apply the same checks. It
* orders its input itself, so the error order and the model digest never depend on the caller.
*/
export function validateModelFiles(input: ModelFile[]): ModelValidationResult {
const files = orderModelFiles(input);
const errors: string[] = [];
const diagnostics: NonNullable<ModelValidationResult["diagnostics"]> = [];
const nodeSchemaErrors: NonNullable<ModelValidationResult["nodeSchemaErrors"]> = [];
const paths = new Set(files.map((file) => file.path));
for (const required of [".archcontext/manifest.yaml", ".archcontext/product.yaml"]) {
if (paths.has(required)) continue;
const error = `missing ${required}`;
errors.push(error);
diagnostics.push({ path: required, code: "missing-file", error });
}
// Parsed node and relation records with their paths, for the cross-entity topology rules.
const topologyRecords: { nodes: Record<string, Json>[]; relations: Record<string, Json>[] } = { nodes: [], relations: [] };
const topologyRecordPaths = new Map<string, string>();
const stateMachineFiles: { path: string; value: Json }[] = [];
for (const file of files) {
if (file.path.startsWith(".archcontext/model/") && /\.ya?ml$/.test(file.path)) {
try {
readFileSync(resolve(workspace.root, required), "utf8");
} catch {
const error = `missing ${required}`;
errors.push(error);
diagnostics.push({ path: required, code: "missing-file", error });
}
}
const files = listModelFiles(workspace.root);
// Parsed node and relation records with their paths, for the cross-entity topology rules.
const topologyRecords: { nodes: Record<string, Json>[]; relations: Record<string, Json>[] } = { nodes: [], relations: [] };
const topologyRecordPaths = new Map<string, string>();
const stateMachineFiles: { path: string; value: Json }[] = [];
for (const file of files) {
if (file.path.startsWith(".archcontext/model/") && /\.ya?ml$/.test(file.path)) {
try {
const value = parseJsonOrStableYaml(file.body, file.path);
for (const [directory, records] of [["nodes", topologyRecords.nodes], ["relations", topologyRecords.relations]] as const) {
if (!file.path.startsWith(`.archcontext/model/${directory}/`) || !isJsonRecord(value)) continue;
records.push(value);
if (typeof value.id === "string") topologyRecordPaths.set(value.id, file.path);
}
if (file.path.startsWith(".archcontext/model/nodes/")) {
const { issues } = validateJsonSchema(nodeSchema, value);
for (const issue of issues) {
const error = `${file.path}: ${issue.path}: ${issue.message}`;
errors.push(error);
nodeSchemaErrors.push({ path: file.path, error });
}
continue;
}
if (isStateMachineModelPath(file.path)) {
stateMachineFiles.push({ path: file.path, value });
continue;
}
// The loader reads only direct children, so a model file in a subdirectory would be a
// machine nothing checks. Parse and schemaVersion failures keep their generic errors.
if (file.path.startsWith(`${STATE_MACHINES_MODEL_DIRECTORY}/`) && file.schemaVersion.startsWith("archcontext.")) {
errors.push(`${file.path}: state-machine-path-nested: a state machine must be a direct child of ${STATE_MACHINES_MODEL_DIRECTORY}/`);
const value = parseJsonOrStableYaml(file.body, file.path);
for (const [directory, records] of [["nodes", topologyRecords.nodes], ["relations", topologyRecords.relations]] as const) {
if (!file.path.startsWith(`.archcontext/model/${directory}/`) || !isJsonRecord(value)) continue;
records.push(value);
if (typeof value.id === "string") topologyRecordPaths.set(value.id, file.path);
}
if (file.path.startsWith(".archcontext/model/nodes/")) {
const { issues } = validateJsonSchema(nodeSchema, value);
for (const issue of issues) {
const error = `${file.path}: ${issue.path}: ${issue.message}`;
errors.push(error);
nodeSchemaErrors.push({ path: file.path, error });
}
} catch (error) {
errors.push(`${file.path}: invalid model YAML: ${error instanceof Error ? error.message : String(error)}`);
continue;
}
}
if (!file.schemaVersion.startsWith("archcontext.")) {
const error = `${file.path}: missing schemaVersion`;
errors.push(error);
diagnostics.push({ path: file.path, code: "schema-version-missing", error });
}
if (file.path.startsWith(".archcontext/model/flows/") && file.schemaVersion !== "archcontext.flow/v1") {
errors.push(`${file.path}: expected archcontext.flow/v1, got ${file.schemaVersion || "missing"}`);
if (isStateMachineModelPath(file.path)) {
stateMachineFiles.push({ path: file.path, value });
continue;
}
// The loader reads only direct children, so a model file in a subdirectory would be a
// machine nothing checks. Parse and schemaVersion failures keep their generic errors.
if (file.path.startsWith(`${STATE_MACHINES_MODEL_DIRECTORY}/`) && file.schemaVersion.startsWith("archcontext.")) {
errors.push(`${file.path}: state-machine-path-nested: a state machine must be a direct child of ${STATE_MACHINES_MODEL_DIRECTORY}/`);
}
} catch (error) {
errors.push(`${file.path}: invalid model YAML: ${error instanceof Error ? error.message : String(error)}`);
continue;
}
}
for (const issue of runtimeTopologyModelIssues(topologyRecords)) {
errors.push(`${topologyRecordPaths.get(issue.entityId) ?? issue.entityId}: ${issue.field}: ${issue.invariant}`);
if (!file.schemaVersion.startsWith("archcontext.")) {
const error = `${file.path}: missing schemaVersion`;
errors.push(error);
diagnostics.push({ path: file.path, code: "schema-version-missing", error });
}
if (file.path.startsWith(".archcontext/model/flows/") && file.schemaVersion !== "archcontext.flow/v1") {
errors.push(`${file.path}: expected archcontext.flow/v1, got ${file.schemaVersion || "missing"}`);
}
errors.push(...stateMachinesFromModelFiles(stateMachineFiles, topologyRecords.nodes).issues);
const adr = validateAdrAppliesTo(files);
const constraints = readDependencyConstraints(files);
const reviewPolicy = readReviewPolicy(files);
errors.push(...adr.errors, ...constraints.errors, ...reviewPolicy.errors);
const referenceErrors = [...adr.referenceErrors, ...constraints.referenceErrors];
const warnings = [...constraints.warnings, ...reviewPolicy.warnings, ...manifestDecisionDiagnostics(files)];
const modelDigest = digestJson(files.map((file) => ({ path: file.path, digest: file.digest })));
return {
valid: errors.length === 0,
errors,
...(diagnostics.length > 0 ? { diagnostics } : {}),
...(referenceErrors.length > 0 ? { referenceErrors } : {}),
...(nodeSchemaErrors.length > 0 ? { nodeSchemaErrors } : {}),
...(constraints.layerOrderErrors.length > 0 ? { layerOrderErrors: constraints.layerOrderErrors } : {}),
...(warnings.length > 0 ? { warnings } : {}),
modelDigest
};
}

async writeChangeSetPreview(changeSet: unknown): Promise<{ digest: string; summary: string }> {
return { digest: digestJson(changeSet as Json), summary: "ChangeSet preview generated" };
for (const issue of runtimeTopologyModelIssues(topologyRecords)) {
errors.push(`${topologyRecordPaths.get(issue.entityId) ?? issue.entityId}: ${issue.field}: ${issue.invariant}`);
}
errors.push(...stateMachinesFromModelFiles(stateMachineFiles, topologyRecords.nodes).issues);
const adr = validateAdrAppliesTo(files);
const constraints = readDependencyConstraints(files);
const reviewPolicy = readReviewPolicy(files);
errors.push(...adr.errors, ...constraints.errors, ...reviewPolicy.errors);
const referenceErrors = [...adr.referenceErrors, ...constraints.referenceErrors];
const warnings = [...constraints.warnings, ...reviewPolicy.warnings, ...manifestDecisionDiagnostics(files)];
const modelDigest = digestJson(files.map((file) => ({ path: file.path, digest: file.digest })));
return {
valid: errors.length === 0,
errors,
...(diagnostics.length > 0 ? { diagnostics } : {}),
...(referenceErrors.length > 0 ? { referenceErrors } : {}),
...(nodeSchemaErrors.length > 0 ? { nodeSchemaErrors } : {}),
...(constraints.layerOrderErrors.length > 0 ? { layerOrderErrors: constraints.layerOrderErrors } : {}),
...(warnings.length > 0 ? { warnings } : {}),
modelDigest
};
}

/**
Expand Down Expand Up @@ -426,6 +434,15 @@ export function listModelFiles(root: string): ModelFile[] {
});
}

/** The one model file order: by path in UTF-16 code unit order, as `Array.prototype.sort` orders strings. */
export function orderModelFiles(files: ModelFile[]): ModelFile[] {
return [...files].sort((left, right) => compareModelPaths(left.path, right.path));
}

function compareModelPaths(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}

function collectArchContextFiles(root: string, entries: string[]): string[] {
const files: string[] = [];
for (const entry of entries) {
Expand All @@ -438,7 +455,7 @@ function collectArchContextFiles(root: string, entries: string[]): string[] {
}
for (const child of walk(entry)) files.push(child);
}
return [...new Set(files)].sort();
return [...new Set(files)].sort(compareModelPaths);

function walk(relativeDir: string): string[] {
const out: string[] = [];
Expand Down Expand Up @@ -466,7 +483,8 @@ function readdirOrFile(path: string): "directory" | "file" {
}
}

function extractSchemaVersion(body: string, path: string): string {
/** The schemaVersion of a model file body, as every ModelFile producer derives it for the validator. */
export function extractSchemaVersion(body: string, path: string): string {
if (path.startsWith(".archcontext/model/") && /\.ya?ml$/.test(path)) {
try {
const parsed = parseJsonOrStableYaml(body, path) as { schemaVersion?: unknown } | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { REVIEW_FAIL_ON_CATEGORIES, digestJson, stableYaml, type Json } from "@a
import { ChangeSetEngine } from "@archcontext/core/changeset-engine";
import { CONSTRAINT_RULE_TYPES, readDependencyConstraints, readReviewPolicy } from "@archcontext/core/architecture-domain";
import { loadStateMachinesFromArchContext } from "@archcontext/core/state-machine-engine";
import { initializeArchContextModel, listModelFiles, planGeneratedProjection, YamlModelStore } from "../src/index";
import { initializeArchContextModel, listModelFiles, planGeneratedProjection, validateModelFiles, YamlModelStore } from "../src/index";

const NODE_PATH = ".archcontext/model/nodes/module.api.yaml";
const NODE_BODY = "schemaVersion: archcontext.node/v2\nid: module.api\nkind: module\nname: API\nstatus: active\nparent: capability.architecture.context\nsummary: Serves API requests.\n";
Expand Down Expand Up @@ -43,6 +43,35 @@ function validate(root: string) {
return new YamlModelStore().validateModel({ root, repositoryId: "repo.test", headSha: "abc" });
}

describe("validateModelFiles required files and file order (#576)", () => {
test("a model without its product file fails with a missing-file diagnostic", async () => {
const root = modelRoot();
try {
rmSync(join(root, ".archcontext/product.yaml"));
expect(await validate(root)).toMatchObject({
valid: false,
errors: ["missing .archcontext/product.yaml"],
diagnostics: [{ path: ".archcontext/product.yaml", code: "missing-file", error: "missing .archcontext/product.yaml" }]
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test("validateModelFiles orders its input, so the caller's order changes neither errors nor digest", () => {
const root = modelRoot();
try {
for (const name of ["a", "B"]) writeFileSync(join(root, `.archcontext/model/nodes/${name}.yaml`), "id: broken\n", "utf8");
const files = listModelFiles(root);
const result = validateModelFiles(files);
expect(result.errors[0]).toStartWith(".archcontext/model/nodes/B.yaml: ");
expect(validateModelFiles([...files].sort((left, right) => right.path.localeCompare(left.path)))).toEqual(result);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});

describe("YamlModelStore flow mapping schema versions", () => {
test.each([",", ", ", ",\n"])("accepts supported flow mappings separated by %j", async separator => {
const root = modelRoot();
Expand Down
66 changes: 4 additions & 62 deletions packages/local-runtime/runtime-daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,8 @@ import { join, resolve } from "node:path";
import {
bindRepository,
computeWorktreeDigest,
readDependencyConstraints,
manifestDecisionDiagnostics,
readReviewPolicy,
repositoryFingerprint,
validateAdrAppliesTo,
type Landscape,
type RepositoryRegistration
} from "@archcontext/core/architecture-domain";
Expand Down Expand Up @@ -83,7 +80,7 @@ import { assertNoCallerProvidedAttestationFields, digestJson, errorEnvelope, fai
import { type ProjectionRequestV1, type ProjectionApplyRecoveryIntentV1 } from "@archcontext/contracts";
import { findRepositoryRoot, listGitVisibleFiles, readHeadSha } from "@archcontext/local-runtime/git-adapter";
import { defaultLocalStorePath, migrateLegacyLocalStoreIfNeeded, runtimeStatePaths, SqliteLocalStore, type RuntimeAgentJobQueueScope, type RuntimeLocalStore, type UnresolvedChangeSetJournal } from "@archcontext/local-runtime/local-store-sqlite";
import { ArchContextInitRefusedError, initializeArchContextModel, listModelFiles, planGeneratedProjection, rebuildGeneratedProjection, YamlModelStore, type ModelFile } from "@archcontext/local-runtime/model-store-yaml";
import { ArchContextInitRefusedError, extractSchemaVersion, initializeArchContextModel, listModelFiles, orderModelFiles, planGeneratedProjection, rebuildGeneratedProjection, validateModelFiles, YamlModelStore, type ModelFile } from "@archcontext/local-runtime/model-store-yaml";
import { createNodeInvestigationTransport } from "./investigation-transport";
import { auditConsentRequiredEnvelope, readAuditConsent } from "./audit-consent";
import { localEgressStatus } from "./egress";
Expand Down Expand Up @@ -214,15 +211,8 @@ class ArchitectureLedgerReadModelStore implements ModelStorePort {
async validateModel(workspace: WorkspaceRef): Promise<ModelValidationResult> {
if (this.architectureLedger.readAuthority !== "ledger") return this.fallback.validateModel(workspace);
const readback = await this.loadLedgerModel(workspace);
const { errors, referenceErrors, warnings, layerOrderErrors, diagnostics } = validateLedgerModelFiles(readback.files);
const result: ArchitectureLedgerReadModelValidation = {
valid: errors.length === 0,
errors,
...(diagnostics?.length ? { diagnostics } : {}),
...(referenceErrors.length > 0 ? { referenceErrors } : {}),
...(layerOrderErrors.length > 0 ? { layerOrderErrors } : {}),
...(warnings.length > 0 ? { warnings } : {}),
modelDigest: modelDigestForFiles(readback.files),
...validateModelFiles(readback.files),
architectureLedger: {
...this.architectureLedger,
readAuthority: "ledger",
Expand All @@ -245,15 +235,14 @@ class ArchitectureLedgerReadModelStore implements ModelStorePort {
const projectedFiles: ModelFile[] = projectArchitectureLedgerStateToYamlFiles(state).map((file) => ({
path: file.path,
body: file.body,
schemaVersion: schemaVersionFromModelBody(file.body),
schemaVersion: extractSchemaVersion(file.body, file.path),
digest: file.digest
}));
const fallbackFiles = (await this.fallback.loadModel(workspace))
.filter(isModelFile)
.filter((file) => !isArchitectureLedgerManagedModelPath(file.path));
const files = [...fallbackFiles, ...projectedFiles].sort((left, right) => left.path.localeCompare(right.path));
return {
files,
files: orderModelFiles([...fallbackFiles, ...projectedFiles]),
state,
graphDigest: architectureLedgerStateDigest(state)
};
Expand Down Expand Up @@ -1403,41 +1392,6 @@ function architectureLedgerScopeForWorkspace(workspace: WorkspaceRef): Architect
};
}

export function validateLedgerModelFiles(files: ModelFile[]): { errors: string[]; referenceErrors: string[]; warnings: string[]; layerOrderErrors: string[]; diagnostics?: ModelValidationResult["diagnostics"] } {
const errors: string[] = [];
const diagnostics: NonNullable<ModelValidationResult["diagnostics"]> = [];
const paths = new Set(files.map((file) => file.path));
for (const required of [".archcontext/manifest.yaml", ".archcontext/product.yaml"]) {
if (!paths.has(required)) {
const error = `missing ${required}`;
errors.push(error);
diagnostics.push({ path: required, code: "missing-file", error });
}
}
for (const file of files) {
if (!file.schemaVersion.startsWith("archcontext.")) {
const error = `${file.path}: missing schemaVersion`;
errors.push(error);
diagnostics.push({ path: file.path, code: "schema-version-missing", error });
}
}
const adr = validateAdrAppliesTo(files);
const constraints = readDependencyConstraints(files);
const reviewPolicy = readReviewPolicy(files);
errors.push(...adr.errors, ...constraints.errors, ...reviewPolicy.errors);
return {
errors,
referenceErrors: [...adr.referenceErrors, ...constraints.referenceErrors],
...(diagnostics.length ? { diagnostics } : {}),
layerOrderErrors: constraints.layerOrderErrors,
warnings: [...constraints.warnings, ...reviewPolicy.warnings, ...manifestDecisionDiagnostics(files)]
};
}

function modelDigestForFiles(files: ModelFile[]): string {
return digestJson(files.map((file) => ({ path: file.path, digest: file.digest })) as unknown as Json);
}

function isModelFile(value: unknown): value is ModelFile {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const file = value as Partial<ModelFile>;
Expand All @@ -1453,18 +1407,6 @@ function isArchitectureLedgerManagedModelPath(path: string): boolean {
|| path.startsWith(".archcontext/model/constraints/");
}

function schemaVersionFromModelBody(body: string): string {
const match = body.match(/schemaVersion:\s*"?([^"\n]+)"?/);
if (match) return match[1].trim();
try {
const parsed = JSON.parse(body) as { schemaVersion?: unknown };
if (typeof parsed.schemaVersion === "string") return parsed.schemaVersion;
} catch {
// Stable YAML projection files are handled by the regex path above.
}
return "";
}


export async function createStartedDaemon(deps: RuntimeDeps = {}): Promise<ArchctxDaemon> {
try {
Expand Down
Loading
Loading