From 22ce4661b1c79d6012a22625a24c32eb90b4b81f Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:48:52 +0800 Subject: [PATCH 1/2] fix(model): one model validator for YAML and ledger read modes (#576) YamlModelStore.validateModel and the ledger read path both call validateModelFiles in model-store-yaml. validateLedgerModelFiles, the ledger-only subset validator, is removed. Ledger read mode now runs node schema, runtime topology and state-machine checks, including the nested state-machine path error from #571. The ledger read path derives projected file schemaVersions with the shared extractSchemaVersion; schemaVersionFromModelBody is removed. --- .../model-store-yaml/src/index.ts | 159 +++++++++--------- .../test/validate-model.test.ts | 16 ++ .../local-runtime/runtime-daemon/src/index.ts | 63 +------ .../test/ledger-layer-validation.test.ts | 10 +- .../runtime-daemon/test/local-runtime.test.ts | 65 ++++++- 5 files changed, 171 insertions(+), 142 deletions(-) diff --git a/packages/local-runtime/model-store-yaml/src/index.ts b/packages/local-runtime/model-store-yaml/src/index.ts index 8614f78d..5f20db07 100644 --- a/packages/local-runtime/model-store-yaml/src/index.ts +++ b/packages/local-runtime/model-store-yaml/src/index.ts @@ -289,90 +289,96 @@ export class YamlModelStore implements ModelStorePort { } async validateModel(workspace: WorkspaceRef): Promise { - const errors: string[] = []; - const diagnostics: NonNullable = []; - const nodeSchemaErrors: NonNullable = []; - 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. + */ +export function validateModelFiles(files: ModelFile[]): ModelValidationResult { + const errors: string[] = []; + const diagnostics: NonNullable = []; + const nodeSchemaErrors: NonNullable = []; + 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[]; relations: Record[] } = { nodes: [], relations: [] }; + const topologyRecordPaths = new Map(); + 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[]; relations: Record[] } = { nodes: [], relations: [] }; - const topologyRecordPaths = new Map(); - 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 + }; } /** @@ -466,7 +472,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; diff --git a/packages/local-runtime/model-store-yaml/test/validate-model.test.ts b/packages/local-runtime/model-store-yaml/test/validate-model.test.ts index 39c66824..0bf8a5cf 100644 --- a/packages/local-runtime/model-store-yaml/test/validate-model.test.ts +++ b/packages/local-runtime/model-store-yaml/test/validate-model.test.ts @@ -43,6 +43,22 @@ function validate(root: string) { return new YamlModelStore().validateModel({ root, repositoryId: "repo.test", headSha: "abc" }); } +describe("YamlModelStore required files (#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 }); + } + }); +}); + describe("YamlModelStore flow mapping schema versions", () => { test.each([",", ", ", ",\n"])("accepts supported flow mappings separated by %j", async separator => { const root = modelRoot(); diff --git a/packages/local-runtime/runtime-daemon/src/index.ts b/packages/local-runtime/runtime-daemon/src/index.ts index 7fc3ffba..583c934b 100644 --- a/packages/local-runtime/runtime-daemon/src/index.ts +++ b/packages/local-runtime/runtime-daemon/src/index.ts @@ -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"; @@ -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, 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"; @@ -214,15 +211,8 @@ class ArchitectureLedgerReadModelStore implements ModelStorePort { async validateModel(workspace: WorkspaceRef): Promise { 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", @@ -245,7 +235,7 @@ 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)) @@ -1403,41 +1393,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 = []; - 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; @@ -1453,18 +1408,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 { try { diff --git a/packages/local-runtime/runtime-daemon/test/ledger-layer-validation.test.ts b/packages/local-runtime/runtime-daemon/test/ledger-layer-validation.test.ts index 26897296..ad31bd61 100644 --- a/packages/local-runtime/runtime-daemon/test/ledger-layer-validation.test.ts +++ b/packages/local-runtime/runtime-daemon/test/ledger-layer-validation.test.ts @@ -1,16 +1,16 @@ import { expect, test } from "bun:test"; import { digestJson } from "@archcontext/contracts"; import { changeSetBaseModelBlockingErrors, type ChangeSetDraft } from "@archcontext/core/changeset-engine"; -import { validateLedgerModelFiles } from "../src/index"; +import { validateModelFiles } from "@archcontext/local-runtime/model-store-yaml"; -test("ledger validation retains exact structured layer repair metadata without hiding unrelated errors", () => { +test("model validation of ledger files retains exact structured layer repair metadata without hiding unrelated errors", () => { const file = (path: string, value: any) => ({ path, body: JSON.stringify(value), schemaVersion: value.schemaVersion, digest: digestJson(value) }); - const read = validateLedgerModelFiles([ + const read = validateModelFiles([ file(".archcontext/manifest.yaml", { schemaVersion: "archcontext.manifest/v1", model: { layers: ["cli"] } }), - file(".archcontext/model/nodes/module.core.yaml", { schemaVersion: "archcontext.node/v2", id: "module.core", layer: "core" }) + file(".archcontext/model/nodes/module.core.yaml", { schemaVersion: "archcontext.node/v2", id: "module.core", kind: "module", name: "Core", status: "active", summary: "Core module.", layer: "core" }) ]); expect(read.layerOrderErrors).toEqual([".archcontext/manifest.yaml: architecture-node-layer-undeclared: module.core (core)"]); - expect(read.errors).toContain(read.layerOrderErrors[0]); + expect(read.errors).toContain(read.layerOrderErrors![0]); const draft = { operations: [{ op: "update_manifest_fields", path: ".archcontext/manifest.yaml", expectedHash: digestJson({}), fields: { "model.layers": ["cli", "core"] } }] } as unknown as ChangeSetDraft; expect(changeSetBaseModelBlockingErrors({ ...read, valid: false, modelDigest: digestJson({}) }, draft)).toEqual(["missing .archcontext/product.yaml"]); expect(changeSetBaseModelBlockingErrors({ ...read, valid: false, modelDigest: digestJson({}) }, { operations: [] } as unknown as ChangeSetDraft)).toEqual(read.errors); diff --git a/packages/local-runtime/runtime-daemon/test/local-runtime.test.ts b/packages/local-runtime/runtime-daemon/test/local-runtime.test.ts index 9b7defb7..e3dadb52 100644 --- a/packages/local-runtime/runtime-daemon/test/local-runtime.test.ts +++ b/packages/local-runtime/runtime-daemon/test/local-runtime.test.ts @@ -7,7 +7,8 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync import { tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; import { computeWorktreeDigest, repositoryFingerprint } from "@archcontext/core/architecture-domain"; -import { ARCHITECTURE_DOCS_RENDERER_VERSION, digestJson, projectionApplyReadbackRequestFromReceipt, type ProjectionApplyReceiptV1, type ProjectionRequestV1, type ProjectionResultV3, type StateMachineV1, type CodeFactsPort, type Json, type ModelStorePort, type NormalizedCodeContext } from "@archcontext/contracts"; +import { emptyArchitectureLedgerEvidenceState, planYamlToArchitectureLedgerImport } from "@archcontext/core/architecture-ledger"; +import { ARCHITECTURE_DOCS_RENDERER_VERSION, digestJson, projectionApplyReadbackRequestFromReceipt, stableYaml, type ProjectionApplyReceiptV1, type ProjectionRequestV1, type ProjectionResultV3, type StateMachineV1, type CodeFactsPort, type Json, type ModelStorePort, type NormalizedCodeContext } from "@archcontext/contracts"; import { assertNoCodeGraphInternalPathAccess, CodeGraphAdapter, REQUIRED_CODEGRAPH_VERSION, loadCapabilityCodeGraphProjectionInputs, prepareArchitectureDocumentationProjectionSnapshot } from "@archcontext/local-runtime/codegraph-adapter"; import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; import { MockCodeGraphProvider, declareOptionalCodeFacts } from "@archcontext/local-runtime/test/codegraph-factories"; @@ -2329,6 +2330,68 @@ setInterval(() => undefined, 1 << 30); } }); + test("ledger read mode validates with the YAML validator: a rejected model fails the same way and a valid model passes (#576)", async () => { + const fixture = (path: string) => JSON.parse(readFileSync(join(import.meta.dir, "../../../contracts/fixtures", path), "utf8")); + const topology = fixture("model/valid/runtime-topology.json") as { nodes: Record[]; relations: Record[] }; + const machine = { ...fixture("valid/architecture-state-machine.json"), capabilityId: "capability.architecture.context" }; + const mountPath = ".archcontext/model/nodes/mount.shop.tmp.yaml"; + const relationPath = ".archcontext/model/relations/relation.shop.api-runs-in-container.yaml"; + const nestedPath = ".archcontext/model/state-machines/sub/x.yaml"; + const cases: Array<{ name: string; write: (root: string) => void; errors: string[] }> = [ + { name: "valid model", write: () => {}, errors: [] }, + { + name: "node schema violation", + write: (root) => writeFileSync(join(root, mountPath), stableYaml({ schemaVersion: "archcontext.node/v2", id: "mount.shop.tmp", kind: "mount", name: "Scratch", status: "active", summary: "Scratch.", mount: { mode: "rw", order: 0 } }), "utf8"), + errors: [`${mountPath}: $.mount.target: required`] + }, + { + name: "topology error", + write: (root) => writeFileSync(join(root, relationPath), stableYaml({ ...topology.relations.find((relation) => relation.id === "relation.shop.api-runs-in-container")!, target: "component.shop.api" }), "utf8"), + errors: [`${relationPath}: target: topology-relation-target-kind-invalid`] + }, + { + name: "nested state machine", + write: (root) => { + mkdirSync(join(root, dirname(nestedPath)), { recursive: true }); + writeFileSync(join(root, nestedPath), stableYaml({ ...machine, id: "state-machine.checkout.nested" }), "utf8"); + }, + errors: [`${nestedPath}: state-machine-path-nested: a state machine must be a direct child of .archcontext/model/state-machines/`] + } + ]; + for (const { name, write, errors } of cases) { + const root = tempRepo(); + try { + initializeArchContextModel(root, "Ledger Validation Parity"); + mkdirSync(join(root, ".archcontext/model/relations"), { recursive: true }); + mkdirSync(join(root, ".archcontext/model/state-machines"), { recursive: true }); + for (const [directory, records] of [["nodes", topology.nodes], ["relations", topology.relations]] as const) { + for (const record of records) writeFileSync(join(root, `.archcontext/model/${directory}/${record.id}.yaml`), stableYaml(record), "utf8"); + } + writeFileSync(join(root, ".archcontext/model/state-machines/state-machine.checkout.order.yaml"), stableYaml(machine), "utf8"); + write(root); + const yaml = await new YamlModelStore().validateModel({ root, repositoryId: "repo.test", headSha: "abc" }); + expect({ name, valid: yaml.valid, errors: yaml.errors }).toEqual({ name, valid: errors.length === 0, errors }); + + // The ledger holds the nodes and relations; Git keeps only the files the ledger does not own. + const ledgerFiles = listModelFiles(root); + rmSync(join(root, ".archcontext/model/nodes"), { recursive: true, force: true }); + rmSync(join(root, ".archcontext/model/relations"), { recursive: true, force: true }); + class ImportedLedgerStore extends TestLocalStore { + override async readArchitectureLedgerState(scope: Parameters[0]) { + return planYamlToArchitectureLedgerImport({ ...scope, files: ledgerFiles, previousEvidenceState: emptyArchitectureLedgerEvidenceState(), createdAt: "2026-11-13T00:00:00.000Z" }).state; + } + } + const daemon = await createStartedTestDaemon({ localStore: new ImportedLedgerStore(), architectureLedger: { rolloutMode: "ledger-authoritative" } }); + const ledger = (await daemon.validate(root)).data as any; + expect(ledger.architectureLedger.readAuthority).toBe("ledger"); + expect(ledger.architectureLedger.entityCount).toBe(topology.nodes.length + 1); + expect({ name, valid: ledger.valid, errors: ledger.errors }).toEqual({ name, valid: yaml.valid, errors: yaml.errors }); + } finally { + removeTempRepo(root); + } + } + }); + test("apply_update restores a hand-deleted node that an ADR references (#163)", async () => { const root = tempRepo(); try { From 5c3670ae1054f2ecadf3b8c0334d6ab5768bbdb7 Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:07:41 +0800 Subject: [PATCH 2/2] fix(model): validateModelFiles owns the model file order (#576) validateModelFiles orders its input by path in code unit order, the order listModelFiles produces, so error order and modelDigest never depend on the caller. The ledger read path ordered its file set with localeCompare and now uses the same exported orderModelFiles. Siblings covered: every ledger-mode caller of the merged file set (validateModel and loadModel), and the YAML listModelFiles order, which now uses the same comparator. Parity test rows now run with test.each. --- .../model-store-yaml/src/index.ts | 17 ++++- .../test/validate-model.test.ts | 17 ++++- .../local-runtime/runtime-daemon/src/index.ts | 5 +- .../runtime-daemon/test/local-runtime.test.ts | 63 ++++++++++++------- 4 files changed, 70 insertions(+), 32 deletions(-) diff --git a/packages/local-runtime/model-store-yaml/src/index.ts b/packages/local-runtime/model-store-yaml/src/index.ts index 5f20db07..51cc3ecc 100644 --- a/packages/local-runtime/model-store-yaml/src/index.ts +++ b/packages/local-runtime/model-store-yaml/src/index.ts @@ -300,9 +300,11 @@ export class YamlModelStore implements ModelStorePort { /** * 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. + * 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(files: ModelFile[]): ModelValidationResult { +export function validateModelFiles(input: ModelFile[]): ModelValidationResult { + const files = orderModelFiles(input); const errors: string[] = []; const diagnostics: NonNullable = []; const nodeSchemaErrors: NonNullable = []; @@ -432,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) { @@ -444,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[] = []; diff --git a/packages/local-runtime/model-store-yaml/test/validate-model.test.ts b/packages/local-runtime/model-store-yaml/test/validate-model.test.ts index 0bf8a5cf..390f96cc 100644 --- a/packages/local-runtime/model-store-yaml/test/validate-model.test.ts +++ b/packages/local-runtime/model-store-yaml/test/validate-model.test.ts @@ -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"; @@ -43,7 +43,7 @@ function validate(root: string) { return new YamlModelStore().validateModel({ root, repositoryId: "repo.test", headSha: "abc" }); } -describe("YamlModelStore required files (#576)", () => { +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 { @@ -57,6 +57,19 @@ describe("YamlModelStore required files (#576)", () => { 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", () => { diff --git a/packages/local-runtime/runtime-daemon/src/index.ts b/packages/local-runtime/runtime-daemon/src/index.ts index 583c934b..097085c2 100644 --- a/packages/local-runtime/runtime-daemon/src/index.ts +++ b/packages/local-runtime/runtime-daemon/src/index.ts @@ -80,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, extractSchemaVersion, initializeArchContextModel, listModelFiles, planGeneratedProjection, rebuildGeneratedProjection, validateModelFiles, 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"; @@ -241,9 +241,8 @@ class ArchitectureLedgerReadModelStore implements ModelStorePort { 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) }; diff --git a/packages/local-runtime/runtime-daemon/test/local-runtime.test.ts b/packages/local-runtime/runtime-daemon/test/local-runtime.test.ts index e3dadb52..8709bb48 100644 --- a/packages/local-runtime/runtime-daemon/test/local-runtime.test.ts +++ b/packages/local-runtime/runtime-daemon/test/local-runtime.test.ts @@ -7,7 +7,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync import { tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; import { computeWorktreeDigest, repositoryFingerprint } from "@archcontext/core/architecture-domain"; -import { emptyArchitectureLedgerEvidenceState, planYamlToArchitectureLedgerImport } from "@archcontext/core/architecture-ledger"; +import { emptyArchitectureLedgerEvidenceState, planYamlToArchitectureLedgerImport, projectArchitectureLedgerStateToYamlFiles, type ArchitectureLedgerGraphState } from "@archcontext/core/architecture-ledger"; import { ARCHITECTURE_DOCS_RENDERER_VERSION, digestJson, projectionApplyReadbackRequestFromReceipt, stableYaml, type ProjectionApplyReceiptV1, type ProjectionRequestV1, type ProjectionResultV3, type StateMachineV1, type CodeFactsPort, type Json, type ModelStorePort, type NormalizedCodeContext } from "@archcontext/contracts"; import { assertNoCodeGraphInternalPathAccess, CodeGraphAdapter, REQUIRED_CODEGRAPH_VERSION, loadCapabilityCodeGraphProjectionInputs, prepareArchitectureDocumentationProjectionSnapshot } from "@archcontext/local-runtime/codegraph-adapter"; import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; @@ -15,7 +15,7 @@ import { MockCodeGraphProvider, declareOptionalCodeFacts } from "@archcontext/lo import { migrationSql, assertNoSourceStorageSchema, SQLITE_PRAGMAS, SqliteLocalStore } from "@archcontext/local-runtime/local-store-sqlite"; import { TestLocalStore } from "@archcontext/local-runtime/test/local-store-factories"; import { runArchitectureDebtCommand } from "@archcontext/local-runtime/architecture-debt"; -import { initializeArchContextModel, listModelFiles, planGeneratedProjection, YamlModelStore } from "@archcontext/local-runtime/model-store-yaml"; +import { extractSchemaVersion, initializeArchContextModel, listModelFiles, planGeneratedProjection, YamlModelStore } from "@archcontext/local-runtime/model-store-yaml"; import { ChangeSetEngine, type ApplyOptions, type ChangeSetDraft } from "@archcontext/core/changeset-engine"; import { createNodeInvestigationTransport } from "../src/investigation-transport"; import { createNodeGithubIssueExecutor, preflightGithubIssueDrafts, withGithubIssueBodyFile, type GithubIssueExecutorPort, type GithubIssuePreflightDraft } from "../src/github-issue-executor"; @@ -2330,35 +2330,36 @@ setInterval(() => undefined, 1 << 30); } }); - test("ledger read mode validates with the YAML validator: a rejected model fails the same way and a valid model passes (#576)", async () => { + describe("ledger read mode validates with the YAML validator: a rejected model fails the same way and a valid model passes (#576)", () => { const fixture = (path: string) => JSON.parse(readFileSync(join(import.meta.dir, "../../../contracts/fixtures", path), "utf8")); const topology = fixture("model/valid/runtime-topology.json") as { nodes: Record[]; relations: Record[] }; const machine = { ...fixture("valid/architecture-state-machine.json"), capabilityId: "capability.architecture.context" }; const mountPath = ".archcontext/model/nodes/mount.shop.tmp.yaml"; const relationPath = ".archcontext/model/relations/relation.shop.api-runs-in-container.yaml"; - const nestedPath = ".archcontext/model/state-machines/sub/x.yaml"; - const cases: Array<{ name: string; write: (root: string) => void; errors: string[] }> = [ - { name: "valid model", write: () => {}, errors: [] }, - { - name: "node schema violation", + const nested = (name: string) => `.archcontext/model/state-machines/sub/${name}`; + const misplaced = (name: string) => `${nested(name)}: state-machine-path-nested: a state machine must be a direct child of .archcontext/model/state-machines/`; + const writeNested = (root: string, name: string) => { + mkdirSync(join(root, dirname(nested(name))), { recursive: true }); + writeFileSync(join(root, nested(name)), stableYaml({ ...machine, id: `state-machine.checkout.${name.toLowerCase().replace(".yaml", "")}` }), "utf8"); + }; + const cases: Array<[string, { write: (root: string) => void; errors: string[] }]> = [ + ["valid model", { write: () => {}, errors: [] }], + ["node schema violation", { write: (root) => writeFileSync(join(root, mountPath), stableYaml({ schemaVersion: "archcontext.node/v2", id: "mount.shop.tmp", kind: "mount", name: "Scratch", status: "active", summary: "Scratch.", mount: { mode: "rw", order: 0 } }), "utf8"), errors: [`${mountPath}: $.mount.target: required`] - }, - { - name: "topology error", + }], + ["topology error", { write: (root) => writeFileSync(join(root, relationPath), stableYaml({ ...topology.relations.find((relation) => relation.id === "relation.shop.api-runs-in-container")!, target: "component.shop.api" }), "utf8"), errors: [`${relationPath}: target: topology-relation-target-kind-invalid`] - }, - { - name: "nested state machine", - write: (root) => { - mkdirSync(join(root, dirname(nestedPath)), { recursive: true }); - writeFileSync(join(root, nestedPath), stableYaml({ ...machine, id: "state-machine.checkout.nested" }), "utf8"); - }, - errors: [`${nestedPath}: state-machine-path-nested: a state machine must be a direct child of .archcontext/model/state-machines/`] - } + }], + ["nested state machine", { write: (root) => writeNested(root, "x.yaml"), errors: [misplaced("x.yaml")] }], + // Code unit order puts B before a; localeCompare puts a before B. + ["two invalid files whose paths sort differently under localeCompare", { + write: (root) => { writeNested(root, "a.yaml"); writeNested(root, "B.yaml"); }, + errors: [misplaced("B.yaml"), misplaced("a.yaml")] + }] ]; - for (const { name, write, errors } of cases) { + test.each(cases)("%s", async (name, { write, errors }) => { const root = tempRepo(); try { initializeArchContextModel(root, "Ledger Validation Parity"); @@ -2377,19 +2378,33 @@ setInterval(() => undefined, 1 << 30); rmSync(join(root, ".archcontext/model/nodes"), { recursive: true, force: true }); rmSync(join(root, ".archcontext/model/relations"), { recursive: true, force: true }); class ImportedLedgerStore extends TestLocalStore { + state?: ArchitectureLedgerGraphState; override async readArchitectureLedgerState(scope: Parameters[0]) { - return planYamlToArchitectureLedgerImport({ ...scope, files: ledgerFiles, previousEvidenceState: emptyArchitectureLedgerEvidenceState(), createdAt: "2026-11-13T00:00:00.000Z" }).state; + this.state = planYamlToArchitectureLedgerImport({ ...scope, files: ledgerFiles, previousEvidenceState: emptyArchitectureLedgerEvidenceState(), createdAt: "2026-11-13T00:00:00.000Z" }).state; + return this.state; } } - const daemon = await createStartedTestDaemon({ localStore: new ImportedLedgerStore(), architectureLedger: { rolloutMode: "ledger-authoritative" } }); + const store = new ImportedLedgerStore(); + const daemon = await createStartedTestDaemon({ localStore: store, architectureLedger: { rolloutMode: "ledger-authoritative" } }); const ledger = (await daemon.validate(root)).data as any; expect(ledger.architectureLedger.readAuthority).toBe("ledger"); expect(ledger.architectureLedger.entityCount).toBe(topology.nodes.length + 1); expect({ name, valid: ledger.valid, errors: ledger.errors }).toEqual({ name, valid: yaml.valid, errors: yaml.errors }); + // The two modes hash different file bodies, so their digests differ; each mode hashes its files in the one order. + const ledgerSet = [ + ...listModelFiles(root), + ...projectArchitectureLedgerStateToYamlFiles(store.state!).map((file) => ({ path: file.path, body: file.body, digest: file.digest, schemaVersion: extractSchemaVersion(file.body, file.path) })) + ]; + const digestInPathOrder = (files: Array<{ path: string; digest: string }>) => { + const byPath = new Map(files.map((file) => [file.path, file.digest])); + return digestJson([...byPath.keys()].sort().map((path) => ({ path, digest: byPath.get(path)! }))); + }; + expect(ledger.modelDigest).toBe(digestInPathOrder(ledgerSet)); + expect(yaml.modelDigest).toBe(digestInPathOrder(ledgerFiles)); } finally { removeTempRepo(root); } - } + }); }); test("apply_update restores a hand-deleted node that an ADR references (#163)", async () => {