diff --git a/packages/local-runtime/model-store-yaml/src/index.ts b/packages/local-runtime/model-store-yaml/src/index.ts index 8614f78d..51cc3ecc 100644 --- a/packages/local-runtime/model-store-yaml/src/index.ts +++ b/packages/local-runtime/model-store-yaml/src/index.ts @@ -289,90 +289,98 @@ 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. 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 = []; + 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 + }; } /** @@ -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) { @@ -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[] = []; @@ -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; 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..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,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(); diff --git a/packages/local-runtime/runtime-daemon/src/index.ts b/packages/local-runtime/runtime-daemon/src/index.ts index 7fc3ffba..097085c2 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, 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"; @@ -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,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) }; @@ -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 = []; - 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 +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 { 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..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,14 +7,15 @@ 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, 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"; import { MockCodeGraphProvider, declareOptionalCodeFacts } from "@archcontext/local-runtime/test/codegraph-factories"; 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"; @@ -2329,6 +2330,83 @@ setInterval(() => undefined, 1 << 30); } }); + 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 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`] + }], + ["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`] + }], + ["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")] + }] + ]; + test.each(cases)("%s", async (name, { write, errors }) => { + 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 { + state?: ArchitectureLedgerGraphState; + override async readArchitectureLedgerState(scope: Parameters[0]) { + this.state = planYamlToArchitectureLedgerImport({ ...scope, files: ledgerFiles, previousEvidenceState: emptyArchitectureLedgerEvidenceState(), createdAt: "2026-11-13T00:00:00.000Z" }).state; + return this.state; + } + } + 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 () => { const root = tempRepo(); try {