@kontextmind/kxm 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +19 -0
- package/.kxm/README.md +14 -0
- package/.kxm/assets/README.md +5 -0
- package/.kxm/assets/retrospectives/README.md +5 -0
- package/.kxm/config/README.md +5 -0
- package/.kxm/config/agents.json +43 -0
- package/.kxm/config/env.example +56 -0
- package/.kxm/config/update.example.yaml +9 -0
- package/.kxm/config/workflows/fix.json +160 -0
- package/.kxm/config/workflows/jira-development.json +116 -0
- package/.kxm/config/workflows/provenance-quorum.json +150 -0
- package/.kxm/config/workflows/v04-dogfood.json +72 -0
- package/CHANGELOG.md +465 -0
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/SECURITY.md +72 -0
- package/docs/README.md +48 -0
- package/docs/agent-communication-envelopes-and-gates.md +553 -0
- package/docs/architecture.md +242 -0
- package/docs/assignment-runner.md +241 -0
- package/docs/configuration.md +361 -0
- package/docs/continuous-improvement.md +114 -0
- package/docs/getting-started.md +253 -0
- package/docs/kxm-handbook.md +1090 -0
- package/docs/operations.md +205 -0
- package/docs/provenance-gates.md +291 -0
- package/docs/skills.md +45 -0
- package/docs/templates/README.md +95 -0
- package/docs/templates/adr.md +88 -0
- package/docs/templates/architecture.md +120 -0
- package/docs/templates/bug-fix.md +109 -0
- package/docs/templates/feature.md +108 -0
- package/docs/templates/handoff.md +72 -0
- package/docs/templates/postmortem.md +77 -0
- package/docs/templates/research.md +100 -0
- package/docs/templates/review.md +85 -0
- package/docs/templates/runbook.md +73 -0
- package/docs/templates/test-plan.md +87 -0
- package/docs/templates/test-report.md +72 -0
- package/docs/test-matrix.md +121 -0
- package/docs/troubleshooting.md +249 -0
- package/docs/vnext/README.md +62 -0
- package/docs/vnext/architecture.md +185 -0
- package/docs/vnext/effects-and-recovery.md +172 -0
- package/docs/vnext/lifecycles.md +235 -0
- package/docs/vnext/migration.md +220 -0
- package/docs/vnext/routing.md +184 -0
- package/docs/vnext/synchronization.md +172 -0
- package/docs/vnext/terminology.md +240 -0
- package/docs/vnext/validation.md +335 -0
- package/docs/webhook-workflows.md +240 -0
- package/docs/workflow-guide.md +1150 -0
- package/examples/README.md +102 -0
- package/examples/provenance-workflow.json +40 -0
- package/examples/requester.ts +30 -0
- package/examples/reviewer-agent.ts +29 -0
- package/examples/roundtrip.ts +46 -0
- package/examples/vnext/.kxm/agents/coordinator.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-1.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-2.yaml +15 -0
- package/examples/vnext/.kxm/agents/critic-3.yaml +15 -0
- package/examples/vnext/.kxm/agents/implementer.yaml +15 -0
- package/examples/vnext/.kxm/agents/planner.yaml +13 -0
- package/examples/vnext/.kxm/agents/reproducer.yaml +15 -0
- package/examples/vnext/.kxm/agents/reviewer.yaml +15 -0
- package/examples/vnext/.kxm/gates.yaml +8 -0
- package/examples/vnext/.kxm/models/critic-claude.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-gemini.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-grok.yaml +12 -0
- package/examples/vnext/.kxm/models/implementation.yaml +14 -0
- package/examples/vnext/.kxm/models/primary.yaml +17 -0
- package/examples/vnext/.kxm/prices.yaml +111 -0
- package/examples/vnext/.kxm/project/env.yaml +7 -0
- package/examples/vnext/.kxm/project.yaml +32 -0
- package/examples/vnext/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/.kxm/workflows/default.yaml +92 -0
- package/examples/vnext/.kxm/workflows/fix.yaml +376 -0
- package/examples/vnext/.kxm/workflows/improve.yaml +57 -0
- package/examples/vnext/README.md +53 -0
- package/examples/vnext/records/assignment-result-recorded.json +63 -0
- package/examples/vnext/records/assignment-result.json +46 -0
- package/examples/vnext/records/context-candidate.json +42 -0
- package/examples/vnext/records/delivery-manifest.json +66 -0
- package/examples/vnext/records/effect-uncertainty-resolved-sync.json +67 -0
- package/examples/vnext/records/effect-uncertainty-resolved.json +62 -0
- package/examples/vnext/records/run-created.json +54 -0
- package/examples/vnext/records/sync-event.json +65 -0
- package/examples/vnext/repositories/api/.kxm/repo/env.yaml +7 -0
- package/examples/vnext/repositories/api/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/repositories/web/.kxm/repo/repo.yaml +8 -0
- package/examples/workflow-signal.ts +63 -0
- package/package.json +129 -0
- package/plugins/kxm/.claude-plugin/plugin.json +73 -0
- package/plugins/kxm/.mcp.json +19 -0
- package/plugins/kxm/README.md +93 -0
- package/plugins/kxm/dist/cli.js +42853 -0
- package/plugins/kxm/dist/client.js +416 -0
- package/plugins/kxm/dist/core.js +1823 -0
- package/plugins/kxm/dist/extension.js +3797 -0
- package/plugins/kxm/dist/mcp-server.js +17104 -0
- package/plugins/kxm/dist/runtime.js +23361 -0
- package/plugins/kxm/dist/server.js +13640 -0
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +21109 -0
- package/plugins/kxm/package.json +12 -0
- package/plugins/kxm/skills/kxm/SKILL.md +97 -0
- package/plugins/kxm/skills/kxm/references/protocol.md +103 -0
- package/plugins/kxm/skills/kxm-session/SKILL.md +53 -0
- package/plugins/kxm/src/arbiter.ts +355 -0
- package/plugins/kxm/src/artifacts-exist.ts +62 -0
- package/plugins/kxm/src/autocomplete.ts +236 -0
- package/plugins/kxm/src/cli.ts +3707 -0
- package/plugins/kxm/src/client.ts +614 -0
- package/plugins/kxm/src/commands.ts +1063 -0
- package/plugins/kxm/src/config.ts +290 -0
- package/plugins/kxm/src/context/providers.ts +101 -0
- package/plugins/kxm/src/context-packet.ts +332 -0
- package/plugins/kxm/src/context.ts +499 -0
- package/plugins/kxm/src/core.ts +6 -0
- package/plugins/kxm/src/database.ts +563 -0
- package/plugins/kxm/src/diagnostics.ts +184 -0
- package/plugins/kxm/src/envelope.ts +118 -0
- package/plugins/kxm/src/extension.ts +895 -0
- package/plugins/kxm/src/external-effects.ts +299 -0
- package/plugins/kxm/src/github-watch.ts +255 -0
- package/plugins/kxm/src/hub-binding.ts +160 -0
- package/plugins/kxm/src/hub.ts +2502 -0
- package/plugins/kxm/src/improve.ts +383 -0
- package/plugins/kxm/src/inbox.ts +10 -0
- package/plugins/kxm/src/kxm-install-kind.ts +113 -0
- package/plugins/kxm/src/kxm-update-config.ts +39 -0
- package/plugins/kxm/src/kxm-update.ts +238 -0
- package/plugins/kxm/src/local-snapshot.ts +406 -0
- package/plugins/kxm/src/logger.ts +198 -0
- package/plugins/kxm/src/mcp-server.ts +143 -0
- package/plugins/kxm/src/memory.ts +385 -0
- package/plugins/kxm/src/nous-pi.ts +287 -0
- package/plugins/kxm/src/nous-provider.ts +729 -0
- package/plugins/kxm/src/price-calc.ts +87 -0
- package/plugins/kxm/src/prices.ts +121 -0
- package/plugins/kxm/src/protocol.ts +172 -0
- package/plugins/kxm/src/recovery.ts +211 -0
- package/plugins/kxm/src/redact.ts +26 -0
- package/plugins/kxm/src/retrospective.ts +400 -0
- package/plugins/kxm/src/routing.ts +830 -0
- package/plugins/kxm/src/runtime.ts +9 -0
- package/plugins/kxm/src/server.ts +117 -0
- package/plugins/kxm/src/session-work.ts +571 -0
- package/plugins/kxm/src/session.ts +184 -0
- package/plugins/kxm/src/skills.ts +535 -0
- package/plugins/kxm/src/state.ts +326 -0
- package/plugins/kxm/src/store.ts +637 -0
- package/plugins/kxm/src/studio-layout.ts +268 -0
- package/plugins/kxm/src/suggest.ts +162 -0
- package/plugins/kxm/src/task-manager.ts +244 -0
- package/plugins/kxm/src/telemetry.ts +116 -0
- package/plugins/kxm/src/tui.ts +1046 -0
- package/plugins/kxm/src/vnext-bindings.ts +403 -0
- package/plugins/kxm/src/vnext-config.ts +1646 -0
- package/plugins/kxm/src/vnext-engine-artifacts.ts +86 -0
- package/plugins/kxm/src/vnext-engine-command.ts +533 -0
- package/plugins/kxm/src/vnext-engine-compile.ts +722 -0
- package/plugins/kxm/src/vnext-engine-evidence.ts +273 -0
- package/plugins/kxm/src/vnext-engine-fold.ts +1400 -0
- package/plugins/kxm/src/vnext-engine-gate-records.ts +583 -0
- package/plugins/kxm/src/vnext-engine-plan.ts +717 -0
- package/plugins/kxm/src/vnext-engine.ts +2458 -0
- package/plugins/kxm/src/vnext-gate-hash.ts +10 -0
- package/plugins/kxm/src/vnext-harness.ts +1142 -0
- package/plugins/kxm/src/vnext-init.ts +430 -0
- package/plugins/kxm/src/vnext-migrate.ts +1848 -0
- package/plugins/kxm/src/vnext-oneshot-producer.ts +424 -0
- package/plugins/kxm/src/vnext-permission.ts +936 -0
- package/plugins/kxm/src/vnext-pi-producer.ts +628 -0
- package/plugins/kxm/src/vnext-repair.ts +1094 -0
- package/plugins/kxm/src/vnext-runtime-owner.ts +320 -0
- package/plugins/kxm/src/vnext-runtime-store.ts +1560 -0
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +586 -0
- package/plugins/kxm/src/vnext-runtime.ts +663 -0
- package/plugins/kxm/src/vnext-template.ts +247 -0
- package/plugins/kxm/src/wiki.ts +313 -0
- package/plugins/kxm/src/workflow.ts +1548 -0
- package/schemas/vnext/README.md +46 -0
- package/schemas/vnext/agent.schema.json +40 -0
- package/schemas/vnext/assignment-result.schema.json +66 -0
- package/schemas/vnext/backup-manifest.schema.json +89 -0
- package/schemas/vnext/candidate.schema.json +109 -0
- package/schemas/vnext/common.schema.json +422 -0
- package/schemas/vnext/context-candidate.schema.json +76 -0
- package/schemas/vnext/context-packet.schema.json +192 -0
- package/schemas/vnext/delivery-manifest.schema.json +159 -0
- package/schemas/vnext/environment.schema.json +66 -0
- package/schemas/vnext/gate-registry.schema.json +109 -0
- package/schemas/vnext/handoff-manifest.schema.json +146 -0
- package/schemas/vnext/init-operation.schema.json +61 -0
- package/schemas/vnext/local-repository-bindings.schema.json +30 -0
- package/schemas/vnext/memory-record.schema.json +45 -0
- package/schemas/vnext/migration-decision.schema.json +26 -0
- package/schemas/vnext/migration-plan.schema.json +123 -0
- package/schemas/vnext/migration-receipt.schema.json +52 -0
- package/schemas/vnext/model.schema.json +42 -0
- package/schemas/vnext/permission-diff.schema.json +57 -0
- package/schemas/vnext/prices.schema.json +115 -0
- package/schemas/vnext/project.schema.json +85 -0
- package/schemas/vnext/repository.schema.json +24 -0
- package/schemas/vnext/run-event.schema.json +460 -0
- package/schemas/vnext/session-brief.schema.json +153 -0
- package/schemas/vnext/sync-event.schema.json +234 -0
- package/schemas/vnext/template-provenance.schema.json +38 -0
- package/schemas/vnext/workflow.schema.json +248 -0
- package/scripts/assignment-run.d.mts +354 -0
- package/scripts/assignment-run.mjs +4451 -0
- package/scripts/build-runtime.mjs +56 -0
- package/scripts/check-generated.mjs +77 -0
- package/scripts/check-versions.mjs +34 -0
- package/scripts/emit-codex-artifacts.d.mts +9 -0
- package/scripts/emit-codex-artifacts.mjs +91 -0
- package/scripts/harness-run.d.mts +83 -0
- package/scripts/harness-run.mjs +2095 -0
- package/scripts/kxm-hub.mjs +105 -0
- package/scripts/kxm-publish-npm.mjs +327 -0
- package/scripts/kxm-release-github.mjs +472 -0
- package/scripts/kxm-runtime-supervisor.mjs +7 -0
- package/scripts/kxm-worker.mjs +1127 -0
- package/scripts/kxm.mjs +27 -0
- package/scripts/roster-policy.d.mts +20 -0
- package/scripts/roster-policy.mjs +161 -0
- package/scripts/smoke-multi-pi.mjs +479 -0
|
@@ -0,0 +1,1646 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js";
|
|
7
|
+
import { isAlias, isCollection, isMap, isScalar, parseDocument, visit } from "yaml";
|
|
8
|
+
import { resolveVnextTemplateBaseline } from "./vnext-template.ts";
|
|
9
|
+
import { BUILTIN_HARNESS_IDS, DEFAULT_HARNESS, validateHarnessModelPair } from "./vnext-harness.ts";
|
|
10
|
+
|
|
11
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
12
|
+
export type JsonObject = { [key: string]: JsonValue };
|
|
13
|
+
|
|
14
|
+
export interface VnextYamlLimits {
|
|
15
|
+
maxDocumentBytes: number;
|
|
16
|
+
maxDepth: number;
|
|
17
|
+
maxScalarBytes: number;
|
|
18
|
+
maxCollectionItems: number;
|
|
19
|
+
maxTotalNodes: number;
|
|
20
|
+
maxKeys: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const VNEXT_YAML_LIMITS: Readonly<VnextYamlLimits> = Object.freeze({
|
|
24
|
+
maxDocumentBytes: 256 * 1024,
|
|
25
|
+
maxDepth: 32,
|
|
26
|
+
maxScalarBytes: 64 * 1024,
|
|
27
|
+
maxCollectionItems: 4096,
|
|
28
|
+
maxTotalNodes: 16_384,
|
|
29
|
+
maxKeys: 8192,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export type VnextValidationPhase = "discovery" | "parse" | "schema" | "path" | "reference" | "semantic";
|
|
33
|
+
|
|
34
|
+
export interface VnextConfigIssue {
|
|
35
|
+
phase: VnextValidationPhase;
|
|
36
|
+
code: string;
|
|
37
|
+
file: string;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class VnextConfigError extends Error {
|
|
42
|
+
readonly issues: readonly VnextConfigIssue[];
|
|
43
|
+
|
|
44
|
+
constructor(issues: readonly VnextConfigIssue[]) {
|
|
45
|
+
const sorted = sortIssues(issues);
|
|
46
|
+
super(sorted.map((issue) => `${issue.file}: ${issue.code}: ${issue.message}`).join("\n"));
|
|
47
|
+
this.name = "VnextConfigError";
|
|
48
|
+
this.issues = sorted;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type VnextResourceKind = "project" | "repository" | "agent" | "model" | "environment" | "workflow" | "gate-registry";
|
|
53
|
+
|
|
54
|
+
export interface VnextResource {
|
|
55
|
+
kind: VnextResourceKind;
|
|
56
|
+
id?: string;
|
|
57
|
+
file: string;
|
|
58
|
+
logicalPath: string;
|
|
59
|
+
value: JsonObject;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface VnextProjectBundle {
|
|
63
|
+
projectRoot: string;
|
|
64
|
+
project: VnextResource;
|
|
65
|
+
repositories: ReadonlyMap<string, VnextResource>;
|
|
66
|
+
agents: ReadonlyMap<string, VnextResource>;
|
|
67
|
+
models: ReadonlyMap<string, VnextResource>;
|
|
68
|
+
workflows: ReadonlyMap<string, VnextResource>;
|
|
69
|
+
environments: readonly VnextResource[];
|
|
70
|
+
gateRegistry?: VnextResource;
|
|
71
|
+
templateProvenance?: JsonObject;
|
|
72
|
+
migrationReceipt?: JsonObject;
|
|
73
|
+
resources: readonly VnextResource[];
|
|
74
|
+
configRevision: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface VnextConfigOptions {
|
|
78
|
+
schemasDir?: string;
|
|
79
|
+
repositoryBindings?: Readonly<Record<string, string>>;
|
|
80
|
+
registeredExecutors?: Iterable<string>;
|
|
81
|
+
registeredToolPresets?: Iterable<string>;
|
|
82
|
+
registeredHarnesses?: Iterable<string>;
|
|
83
|
+
/** Internal: migration apply validates the staged target before the receipt exists. */
|
|
84
|
+
allowUnreceiptedLegacyConfig?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type VnextInitializationMode = "create" | "migrate" | "repair" | "ready";
|
|
88
|
+
|
|
89
|
+
export interface VnextInitializationPlan {
|
|
90
|
+
mode: VnextInitializationMode;
|
|
91
|
+
inspectedFrom: string;
|
|
92
|
+
projectRoot?: string;
|
|
93
|
+
legacyRoot?: string;
|
|
94
|
+
changesRequired: boolean;
|
|
95
|
+
issues: readonly VnextConfigIssue[];
|
|
96
|
+
legacyInputs: readonly string[];
|
|
97
|
+
configRevision?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
101
|
+
const DEFAULT_SCHEMA_DIR = join(PACKAGE_ROOT, "schemas", "vnext");
|
|
102
|
+
const RESOURCE_SCHEMA: Readonly<Record<VnextResourceKind, { identity: string; file: string }>> = Object.freeze({
|
|
103
|
+
project: { identity: "kxm.project.v1", file: "project.schema.json" },
|
|
104
|
+
repository: { identity: "kxm.repository.v1", file: "repository.schema.json" },
|
|
105
|
+
agent: { identity: "kxm.agent.v1", file: "agent.schema.json" },
|
|
106
|
+
model: { identity: "kxm.model.v1", file: "model.schema.json" },
|
|
107
|
+
environment: { identity: "kxm.environment.v1", file: "environment.schema.json" },
|
|
108
|
+
workflow: { identity: "kxm.workflow.v1", file: "workflow.schema.json" },
|
|
109
|
+
"gate-registry": { identity: "kxm.gate-registry.v1", file: "gate-registry.schema.json" },
|
|
110
|
+
});
|
|
111
|
+
const IDENTIFIER = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
|
|
112
|
+
const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³]|conin\$|conout\$|clock\$)$/i;
|
|
113
|
+
const BUILTIN_EXECUTORS = ["local", "ssh", "exe-dev"];
|
|
114
|
+
const BUILTIN_TOOL_PRESETS = ["coordinator", "read-only", "workspace-writer", "tests-writer"];
|
|
115
|
+
const SECRET_VALUE_PATTERNS: readonly RegExp[] = [
|
|
116
|
+
/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/,
|
|
117
|
+
/\b(?:ghp|github_pat)_[A-Za-z0-9_]{20,}\b/,
|
|
118
|
+
/\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
119
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
|
|
120
|
+
/\bAKIA[A-Z0-9]{16}\b/,
|
|
121
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
|
|
122
|
+
];
|
|
123
|
+
const ALLOWED_YAML_TAGS = new Set([
|
|
124
|
+
"tag:yaml.org,2002:map",
|
|
125
|
+
"tag:yaml.org,2002:seq",
|
|
126
|
+
"tag:yaml.org,2002:str",
|
|
127
|
+
"tag:yaml.org,2002:null",
|
|
128
|
+
"tag:yaml.org,2002:bool",
|
|
129
|
+
"tag:yaml.org,2002:int",
|
|
130
|
+
"tag:yaml.org,2002:float",
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
function compareCodeUnits(left: string, right: string): number {
|
|
134
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function sortIssues(issues: readonly VnextConfigIssue[]): VnextConfigIssue[] {
|
|
138
|
+
return [...issues].sort((left, right) => compareCodeUnits(left.file, right.file)
|
|
139
|
+
|| compareCodeUnits(left.phase, right.phase)
|
|
140
|
+
|| compareCodeUnits(left.code, right.code)
|
|
141
|
+
|| compareCodeUnits(left.message, right.message));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function issue(phase: VnextValidationPhase, code: string, file: string, message: string): VnextConfigIssue {
|
|
145
|
+
return { phase, code, file, message };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function fail(phase: VnextValidationPhase, code: string, file: string, message: string): never {
|
|
149
|
+
throw new VnextConfigError([issue(phase, code, file, message)]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function decodeUtf8(input: string | Uint8Array, label: string): string {
|
|
153
|
+
if (typeof input === "string") return input;
|
|
154
|
+
try {
|
|
155
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(input);
|
|
156
|
+
} catch {
|
|
157
|
+
fail("parse", "invalid_utf8", label, "document is not valid UTF-8");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isJsonObject(value: JsonValue): value is JsonObject {
|
|
162
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function assertJsonValue(value: unknown, label: string, path = "$", seen = new Set<unknown>()): asserts value is JsonValue {
|
|
166
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
167
|
+
if (typeof value === "number") {
|
|
168
|
+
if (!Number.isFinite(value)) fail("parse", "non_json_number", label, `${path} is not a finite JSON number`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!value || typeof value !== "object") fail("parse", "non_json_value", label, `${path} is not JSON-compatible`);
|
|
172
|
+
if (seen.has(value)) fail("parse", "cyclic_value", label, `${path} is cyclic`);
|
|
173
|
+
seen.add(value);
|
|
174
|
+
if (Array.isArray(value)) {
|
|
175
|
+
for (const [index, candidate] of value.entries()) assertJsonValue(candidate, label, `${path}[${index}]`, seen);
|
|
176
|
+
} else {
|
|
177
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
178
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
179
|
+
fail("parse", "constructed_object", label, `${path} has a forbidden constructed type`);
|
|
180
|
+
}
|
|
181
|
+
for (const [key, candidate] of Object.entries(value)) assertJsonValue(candidate, label, `${path}.${key}`, seen);
|
|
182
|
+
}
|
|
183
|
+
seen.delete(value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Parse the restricted, JSON-compatible YAML profile used by all vNext configuration. */
|
|
187
|
+
export function parseRestrictedYaml(
|
|
188
|
+
input: string | Uint8Array,
|
|
189
|
+
label = "<yaml>",
|
|
190
|
+
limits: Readonly<VnextYamlLimits> = VNEXT_YAML_LIMITS,
|
|
191
|
+
): JsonObject {
|
|
192
|
+
const byteLength = typeof input === "string" ? Buffer.byteLength(input, "utf8") : input.byteLength;
|
|
193
|
+
if (byteLength > limits.maxDocumentBytes) {
|
|
194
|
+
fail("parse", "document_too_large", label, `document exceeds ${limits.maxDocumentBytes} bytes`);
|
|
195
|
+
}
|
|
196
|
+
const text = decodeUtf8(input, label);
|
|
197
|
+
const document = parseDocument(text, {
|
|
198
|
+
customTags: [],
|
|
199
|
+
strict: true,
|
|
200
|
+
uniqueKeys: true,
|
|
201
|
+
});
|
|
202
|
+
if (document.errors.length > 0) {
|
|
203
|
+
fail("parse", "invalid_yaml", label, document.errors.map((error) => error.message).join("; "));
|
|
204
|
+
}
|
|
205
|
+
if (document.warnings.length > 0) {
|
|
206
|
+
fail("parse", "yaml_warning", label, document.warnings.map((warning) => warning.message).join("; "));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let nodes = 0;
|
|
210
|
+
let keys = 0;
|
|
211
|
+
visit(document, (_key, node, path) => {
|
|
212
|
+
nodes += 1;
|
|
213
|
+
if (nodes > limits.maxTotalNodes) fail("parse", "node_limit", label, `document exceeds ${limits.maxTotalNodes} nodes`);
|
|
214
|
+
if (path.length > limits.maxDepth) fail("parse", "depth_limit", label, `document exceeds nesting depth ${limits.maxDepth}`);
|
|
215
|
+
if (isAlias(node)) fail("parse", "alias_forbidden", label, "aliases are forbidden");
|
|
216
|
+
if (node && typeof node === "object" && "anchor" in node && typeof node.anchor === "string") {
|
|
217
|
+
fail("parse", "anchor_forbidden", label, "anchors are forbidden");
|
|
218
|
+
}
|
|
219
|
+
if (isCollection(node) && node.items.length > limits.maxCollectionItems) {
|
|
220
|
+
fail("parse", "collection_limit", label, `collection exceeds ${limits.maxCollectionItems} items`);
|
|
221
|
+
}
|
|
222
|
+
if (isMap(node)) {
|
|
223
|
+
keys += node.items.length;
|
|
224
|
+
if (keys > limits.maxKeys) fail("parse", "key_limit", label, `document exceeds ${limits.maxKeys} mapping keys`);
|
|
225
|
+
for (const pair of node.items) {
|
|
226
|
+
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
|
|
227
|
+
fail("parse", "non_string_key", label, "mapping keys must be strings");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (isScalar(node) && typeof node.value === "string" && Buffer.byteLength(node.value, "utf8") > limits.maxScalarBytes) {
|
|
232
|
+
fail("parse", "scalar_limit", label, `scalar exceeds ${limits.maxScalarBytes} bytes`);
|
|
233
|
+
}
|
|
234
|
+
if (node && typeof node === "object" && "tag" in node && typeof node.tag === "string" && !ALLOWED_YAML_TAGS.has(node.tag)) {
|
|
235
|
+
fail("parse", "tag_forbidden", label, `tag ${node.tag} is forbidden`);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const value = document.toJS({ maxAliasCount: 0 }) as unknown;
|
|
240
|
+
assertJsonValue(value, label);
|
|
241
|
+
if (!isJsonObject(value)) fail("parse", "root_not_object", label, "resource root must be a mapping");
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function readJsonObject(file: string): Record<string, unknown> {
|
|
246
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
|
247
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${file} is not a JSON object`);
|
|
248
|
+
return parsed as Record<string, unknown>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export class VnextSchemaRegistry {
|
|
252
|
+
readonly schemasDir: string;
|
|
253
|
+
readonly ajv: Ajv2020;
|
|
254
|
+
readonly validators = new Map<VnextResourceKind, ValidateFunction>();
|
|
255
|
+
readonly localBindingsValidator: ValidateFunction;
|
|
256
|
+
readonly templateProvenanceValidator: ValidateFunction;
|
|
257
|
+
readonly initOperationValidator: ValidateFunction;
|
|
258
|
+
readonly migrationPlanValidator: ValidateFunction;
|
|
259
|
+
readonly migrationDecisionValidator: ValidateFunction;
|
|
260
|
+
readonly migrationReceiptValidator: ValidateFunction;
|
|
261
|
+
readonly permissionDiffValidator: ValidateFunction;
|
|
262
|
+
readonly runEventValidator: ValidateFunction;
|
|
263
|
+
|
|
264
|
+
constructor(schemasDir = DEFAULT_SCHEMA_DIR) {
|
|
265
|
+
this.schemasDir = resolve(schemasDir);
|
|
266
|
+
this.ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false });
|
|
267
|
+
const common = readJsonObject(join(this.schemasDir, "common.schema.json"));
|
|
268
|
+
this.ajv.addSchema(common);
|
|
269
|
+
for (const definition of Object.values(RESOURCE_SCHEMA)) {
|
|
270
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, definition.file)));
|
|
271
|
+
}
|
|
272
|
+
const localBindingsFile = "local-repository-bindings.schema.json";
|
|
273
|
+
const templateProvenanceFile = "template-provenance.schema.json";
|
|
274
|
+
const initOperationFile = "init-operation.schema.json";
|
|
275
|
+
const migrationPlanFile = "migration-plan.schema.json";
|
|
276
|
+
const migrationDecisionFile = "migration-decision.schema.json";
|
|
277
|
+
const migrationReceiptFile = "migration-receipt.schema.json";
|
|
278
|
+
const permissionDiffFile = "permission-diff.schema.json";
|
|
279
|
+
const runEventFile = "run-event.schema.json";
|
|
280
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, localBindingsFile)));
|
|
281
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, templateProvenanceFile)));
|
|
282
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, initOperationFile)));
|
|
283
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, migrationPlanFile)));
|
|
284
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, migrationDecisionFile)));
|
|
285
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, migrationReceiptFile)));
|
|
286
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, permissionDiffFile)));
|
|
287
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, runEventFile)));
|
|
288
|
+
for (const [kind, definition] of Object.entries(RESOURCE_SCHEMA) as [VnextResourceKind, { identity: string; file: string }][]) {
|
|
289
|
+
const validator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${definition.file}`);
|
|
290
|
+
if (!validator) throw new Error(`schema did not compile: ${definition.file}`);
|
|
291
|
+
this.validators.set(kind, validator);
|
|
292
|
+
}
|
|
293
|
+
const localBindingsValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${localBindingsFile}`);
|
|
294
|
+
const templateProvenanceValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${templateProvenanceFile}`);
|
|
295
|
+
const initOperationValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${initOperationFile}`);
|
|
296
|
+
const migrationPlanValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${migrationPlanFile}`);
|
|
297
|
+
const migrationDecisionValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${migrationDecisionFile}`);
|
|
298
|
+
const migrationReceiptValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${migrationReceiptFile}`);
|
|
299
|
+
const permissionDiffValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${permissionDiffFile}`);
|
|
300
|
+
const runEventValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${runEventFile}`);
|
|
301
|
+
if (!localBindingsValidator) throw new Error(`schema did not compile: ${localBindingsFile}`);
|
|
302
|
+
if (!templateProvenanceValidator) throw new Error(`schema did not compile: ${templateProvenanceFile}`);
|
|
303
|
+
if (!initOperationValidator) throw new Error(`schema did not compile: ${initOperationFile}`);
|
|
304
|
+
if (!migrationPlanValidator) throw new Error(`schema did not compile: ${migrationPlanFile}`);
|
|
305
|
+
if (!migrationDecisionValidator) throw new Error(`schema did not compile: ${migrationDecisionFile}`);
|
|
306
|
+
if (!migrationReceiptValidator) throw new Error(`schema did not compile: ${migrationReceiptFile}`);
|
|
307
|
+
if (!permissionDiffValidator) throw new Error(`schema did not compile: ${permissionDiffFile}`);
|
|
308
|
+
if (!runEventValidator) throw new Error(`schema did not compile: ${runEventFile}`);
|
|
309
|
+
this.localBindingsValidator = localBindingsValidator;
|
|
310
|
+
this.templateProvenanceValidator = templateProvenanceValidator;
|
|
311
|
+
this.initOperationValidator = initOperationValidator;
|
|
312
|
+
this.migrationPlanValidator = migrationPlanValidator;
|
|
313
|
+
this.migrationDecisionValidator = migrationDecisionValidator;
|
|
314
|
+
this.migrationReceiptValidator = migrationReceiptValidator;
|
|
315
|
+
this.permissionDiffValidator = permissionDiffValidator;
|
|
316
|
+
this.runEventValidator = runEventValidator;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
validate(kind: VnextResourceKind, value: JsonObject, file: string): VnextConfigIssue[] {
|
|
320
|
+
const definition = RESOURCE_SCHEMA[kind];
|
|
321
|
+
if (value.schema !== definition.identity) {
|
|
322
|
+
return [issue("schema", "schema_identity_mismatch", file, `expected ${definition.identity}, received ${String(value.schema)}`)];
|
|
323
|
+
}
|
|
324
|
+
const validator = this.validators.get(kind);
|
|
325
|
+
if (!validator) throw new Error(`missing vNext validator for ${kind}`);
|
|
326
|
+
if (validator(value)) return [];
|
|
327
|
+
return (validator.errors ?? []).map((error) => schemaIssue(file, error));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
validateLocalBindings(value: JsonObject, file: string): VnextConfigIssue[] {
|
|
331
|
+
return this.validateAuxiliary(value, file, "kxm.local-repository-bindings.v1", this.localBindingsValidator);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
validateTemplateProvenance(value: JsonObject, file: string): VnextConfigIssue[] {
|
|
335
|
+
return this.validateAuxiliary(value, file, "kxm.template-provenance.v1", this.templateProvenanceValidator);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
validateInitOperation(value: JsonObject, file: string): VnextConfigIssue[] {
|
|
339
|
+
return this.validateAuxiliary(value, file, "kxm.init-operation.v1", this.initOperationValidator);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
validateMigrationPlan(value: JsonObject, file: string): VnextConfigIssue[] {
|
|
343
|
+
return this.validateAuxiliary(value, file, "kxm.migration-plan.v1", this.migrationPlanValidator);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
validateMigrationDecision(value: JsonObject, file: string): VnextConfigIssue[] {
|
|
347
|
+
return this.validateAuxiliary(value, file, "kxm.migration-decision.v1", this.migrationDecisionValidator);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
validateMigrationReceipt(value: JsonObject, file: string): VnextConfigIssue[] {
|
|
351
|
+
return this.validateAuxiliary(value, file, "kxm.migration-receipt.v1", this.migrationReceiptValidator);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
validatePermissionDiff(value: JsonObject, file: string): VnextConfigIssue[] {
|
|
355
|
+
return this.validateAuxiliary(value, file, "kxm.permission-diff.v1", this.permissionDiffValidator);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private validateAuxiliary(value: JsonObject, file: string, identity: string, validator: ValidateFunction): VnextConfigIssue[] {
|
|
359
|
+
if (value.schema !== identity) {
|
|
360
|
+
return [issue("schema", "schema_identity_mismatch", file, `expected ${identity}, received ${String(value.schema)}`)];
|
|
361
|
+
}
|
|
362
|
+
if (validator(value)) return [];
|
|
363
|
+
return (validator.errors ?? []).map((error) => schemaIssue(file, error));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let cachedRunEventRegistry: VnextSchemaRegistry | undefined;
|
|
368
|
+
|
|
369
|
+
/** Validate a local run event against `kxm.run-event.v1`. Throws `run_event_invalid`. */
|
|
370
|
+
export function validateRunEvent(value: unknown, file: string): void {
|
|
371
|
+
const registry = (cachedRunEventRegistry ??= new VnextSchemaRegistry());
|
|
372
|
+
if (!registry.runEventValidator(value)) {
|
|
373
|
+
throw new VnextConfigError([issue(
|
|
374
|
+
"schema",
|
|
375
|
+
"run_event_invalid",
|
|
376
|
+
file,
|
|
377
|
+
registry.ajv.errorsText(registry.runEventValidator.errors, { separator: "; " }),
|
|
378
|
+
)]);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function schemaIssue(file: string, error: ErrorObject): VnextConfigIssue {
|
|
383
|
+
const location = error.instancePath || "/";
|
|
384
|
+
const suffix = error.params && "additionalProperty" in error.params
|
|
385
|
+
? ` (${String(error.params.additionalProperty)})`
|
|
386
|
+
: "";
|
|
387
|
+
return issue("schema", `schema_${error.keyword}`, file, `${location} ${error.message ?? "is invalid"}${suffix}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function canonicalHostPath(path: string): string {
|
|
391
|
+
const resolved = resolve(path);
|
|
392
|
+
try {
|
|
393
|
+
return realpathSync.native(resolved);
|
|
394
|
+
} catch {
|
|
395
|
+
return resolved;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function sameHostPath(left: string, right: string): boolean {
|
|
400
|
+
const resolvedLeft = canonicalHostPath(left);
|
|
401
|
+
const resolvedRight = canonicalHostPath(right);
|
|
402
|
+
return process.platform === "win32"
|
|
403
|
+
? resolvedLeft.toLocaleLowerCase("en-US") === resolvedRight.toLocaleLowerCase("en-US")
|
|
404
|
+
: resolvedLeft === resolvedRight;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function containedHostPath(root: string, candidate: string): boolean {
|
|
408
|
+
const path = relative(root, candidate);
|
|
409
|
+
return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function portableBindingIssue(root: string, pathHint: string, repositoryId: string): VnextConfigIssue | undefined {
|
|
413
|
+
let current = root;
|
|
414
|
+
for (const segment of pathHint === "." ? [] : pathHint.split("/")) {
|
|
415
|
+
current = join(current, segment);
|
|
416
|
+
if (!existsSync(current)) break;
|
|
417
|
+
const stat = lstatSync(current);
|
|
418
|
+
if (stat.isSymbolicLink()) {
|
|
419
|
+
return issue("path", "repository_binding_path_link", ".kxm/project.yaml", `portable pathHint for ${repositoryId} traverses a symbolic link or junction`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const binding = resolve(root, ...pathHint.split("/"));
|
|
423
|
+
if (existsSync(binding)) {
|
|
424
|
+
const realRoot = canonicalHostPath(root);
|
|
425
|
+
const realBinding = canonicalHostPath(binding);
|
|
426
|
+
if (!containedHostPath(realRoot, realBinding)) {
|
|
427
|
+
return issue("path", "repository_binding_outside_project", ".kxm/project.yaml", `portable pathHint for ${repositoryId} resolves outside the authoritative project root`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The portable relative-path rule every project-owned pathHint must satisfy. */
|
|
434
|
+
export function vnextPortablePath(path: string): boolean {
|
|
435
|
+
return portablePath(path);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function portablePath(path: string): boolean {
|
|
439
|
+
if (path === ".") return true;
|
|
440
|
+
if (path.includes("\\") || path.startsWith("/") || /^[A-Za-z]:/.test(path) || /[<>:"|?*\u0000-\u001F]/.test(path)) return false;
|
|
441
|
+
const segments = path.split("/");
|
|
442
|
+
return segments.every((segment) => segment.length > 0
|
|
443
|
+
&& segment !== "."
|
|
444
|
+
&& segment !== ".."
|
|
445
|
+
&& !WINDOWS_RESERVED.test((segment.split(".")[0] ?? segment).replace(/[ .]+$/g, ""))
|
|
446
|
+
&& !segment.endsWith(".")
|
|
447
|
+
&& !segment.endsWith(" ")
|
|
448
|
+
&& !segment.includes(":"));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The identifier rule every path-derived resource identity must satisfy. */
|
|
452
|
+
export function vnextResourceIdentifier(id: string): boolean {
|
|
453
|
+
return id.length <= 64 && IDENTIFIER.test(id) && !WINDOWS_RESERVED.test(id);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function resourceIdentifier(id: string): boolean {
|
|
457
|
+
return vnextResourceIdentifier(id);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function displayPath(root: string, file: string): string {
|
|
461
|
+
const candidate = relative(root, file).replaceAll("\\", "/");
|
|
462
|
+
return candidate && !candidate.startsWith("../") ? candidate : file.replaceAll("\\", "/");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function readResource(
|
|
466
|
+
registry: VnextSchemaRegistry,
|
|
467
|
+
root: string,
|
|
468
|
+
file: string,
|
|
469
|
+
logicalPath: string,
|
|
470
|
+
kind: VnextResourceKind,
|
|
471
|
+
id?: string,
|
|
472
|
+
containmentRoot = root,
|
|
473
|
+
): VnextResource {
|
|
474
|
+
const label = displayPath(root, file);
|
|
475
|
+
let stat;
|
|
476
|
+
try {
|
|
477
|
+
stat = lstatSync(file);
|
|
478
|
+
} catch {
|
|
479
|
+
fail("discovery", "resource_missing", label, "resource does not exist");
|
|
480
|
+
}
|
|
481
|
+
if (stat.isSymbolicLink()) fail("path", "resource_symlink", label, "configuration resources must not be symbolic links");
|
|
482
|
+
const boundary = resolve(containmentRoot);
|
|
483
|
+
let parent = dirname(file);
|
|
484
|
+
while (parent !== boundary) {
|
|
485
|
+
if (parent === dirname(parent)) fail("path", "resource_outside_project", label, "resource escapes the project root");
|
|
486
|
+
let parentStat;
|
|
487
|
+
try { parentStat = lstatSync(parent); }
|
|
488
|
+
catch { fail("discovery", "resource_parent_missing", label, "resource parent does not exist"); }
|
|
489
|
+
if (parentStat.isSymbolicLink()) fail("path", "resource_parent_symlink", label, "configuration resource parents must not be symbolic links");
|
|
490
|
+
parent = dirname(parent);
|
|
491
|
+
}
|
|
492
|
+
if (!stat.isFile()) fail("path", "resource_not_file", label, "configuration resource must be a regular file");
|
|
493
|
+
const value = parseRestrictedYaml(readFileSync(file), label);
|
|
494
|
+
const issues = registry.validate(kind, value, label);
|
|
495
|
+
if (issues.length > 0) throw new VnextConfigError(issues);
|
|
496
|
+
return { kind, ...(id === undefined ? {} : { id }), file, logicalPath, value };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function readTemplateProvenance(registry: VnextSchemaRegistry, root: string): JsonObject | undefined {
|
|
500
|
+
const file = join(root, ".kxm", "template-provenance.yaml");
|
|
501
|
+
if (!existsSync(file)) return undefined;
|
|
502
|
+
const label = ".kxm/template-provenance.yaml";
|
|
503
|
+
const stat = lstatSync(file);
|
|
504
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
505
|
+
fail("path", "resource_not_file", label, "template provenance must be a regular file, not a link or directory");
|
|
506
|
+
}
|
|
507
|
+
const configRoot = join(root, ".kxm");
|
|
508
|
+
let parent = dirname(file);
|
|
509
|
+
while (parent !== root) {
|
|
510
|
+
const parentStat = lstatSync(parent);
|
|
511
|
+
if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
|
|
512
|
+
fail("path", "resource_parent_symlink", label, "template provenance parents must be regular directories");
|
|
513
|
+
}
|
|
514
|
+
if (parent === configRoot) break;
|
|
515
|
+
parent = dirname(parent);
|
|
516
|
+
}
|
|
517
|
+
const value = parseRestrictedYaml(readFileSync(file), label);
|
|
518
|
+
const issues = registry.validateTemplateProvenance(value, label);
|
|
519
|
+
if (issues.length > 0) throw new VnextConfigError(issues);
|
|
520
|
+
if (!resolveVnextTemplateBaseline(value)) {
|
|
521
|
+
fail("semantic", "template_provenance_revision_invalid", label, "template provenance does not exactly match a supported built-in baseline");
|
|
522
|
+
}
|
|
523
|
+
const files = Array.isArray(value.files) ? value.files : [];
|
|
524
|
+
const seen = new Set<string>();
|
|
525
|
+
let prior = "";
|
|
526
|
+
let managedBytes = 0;
|
|
527
|
+
for (const candidate of files) {
|
|
528
|
+
const record = objectValue(candidate);
|
|
529
|
+
const path = record && stringValue(record.path);
|
|
530
|
+
if (!path) continue;
|
|
531
|
+
managedBytes += typeof record.bytes === "number" ? record.bytes : 0;
|
|
532
|
+
const folded = path.toLocaleLowerCase("en-US");
|
|
533
|
+
if (!path.startsWith(".kxm/") || path === label || !portablePath(path)) {
|
|
534
|
+
fail("path", "template_provenance_path_invalid", label, `managed path ${path} is not a portable project-configuration path`);
|
|
535
|
+
}
|
|
536
|
+
if (seen.has(folded)) fail("path", "template_provenance_path_collision", label, `managed path ${path} collides after case folding`);
|
|
537
|
+
if (prior && compareCodeUnits(prior, path) >= 0) fail("semantic", "template_provenance_order_invalid", label, "managed file entries must use strict code-unit order");
|
|
538
|
+
seen.add(folded);
|
|
539
|
+
prior = path;
|
|
540
|
+
}
|
|
541
|
+
if (managedBytes > 8 * 1024 * 1024) {
|
|
542
|
+
fail("parse", "template_provenance_bounds_exceeded", label, "managed template manifest exceeds 8388608 bytes");
|
|
543
|
+
}
|
|
544
|
+
return value;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function listNamedResources(
|
|
548
|
+
registry: VnextSchemaRegistry,
|
|
549
|
+
root: string,
|
|
550
|
+
directory: string,
|
|
551
|
+
logicalDirectory: string,
|
|
552
|
+
kind: "agent" | "model" | "workflow",
|
|
553
|
+
): Map<string, VnextResource> {
|
|
554
|
+
const resources = new Map<string, VnextResource>();
|
|
555
|
+
if (!existsSync(directory)) return resources;
|
|
556
|
+
const stat = lstatSync(directory);
|
|
557
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
558
|
+
fail("path", "resource_directory_invalid", displayPath(root, directory), "resource directory must be a regular directory");
|
|
559
|
+
}
|
|
560
|
+
const issues: VnextConfigIssue[] = [];
|
|
561
|
+
const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => compareCodeUnits(left.name, right.name));
|
|
562
|
+
for (const entry of entries) {
|
|
563
|
+
if (entry.isDirectory()) {
|
|
564
|
+
issues.push(issue("path", "nested_resource_directory", displayPath(root, join(directory, entry.name)), "nested resource directories are not allowed"));
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (!entry.name.toLowerCase().endsWith(".yaml") && !entry.name.toLowerCase().endsWith(".yml")) continue;
|
|
568
|
+
if (!entry.isFile() || extname(entry.name) !== ".yaml") {
|
|
569
|
+
issues.push(issue("path", "resource_filename_invalid", displayPath(root, join(directory, entry.name)), "resource must be a regular file with the exact .yaml extension"));
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const id = basename(entry.name, ".yaml");
|
|
573
|
+
if (!resourceIdentifier(id)) {
|
|
574
|
+
issues.push(issue("path", "resource_id_invalid", displayPath(root, join(directory, entry.name)), `filename-derived identity ${id} is invalid or platform-reserved`));
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const collision = [...resources.keys()].find((candidate) => candidate.toLocaleLowerCase("en-US") === id.toLocaleLowerCase("en-US"));
|
|
578
|
+
if (collision) {
|
|
579
|
+
issues.push(issue("path", "resource_id_collision", displayPath(root, join(directory, entry.name)), `${id} case-folds to existing ${collision}`));
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
const resource = readResource(registry, root, join(directory, entry.name), `${logicalDirectory}/${id}.yaml`, kind, id);
|
|
584
|
+
resources.set(id, resource);
|
|
585
|
+
} catch (error) {
|
|
586
|
+
if (error instanceof VnextConfigError) issues.push(...error.issues);
|
|
587
|
+
else throw error;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (issues.length > 0) throw new VnextConfigError(issues);
|
|
591
|
+
return resources;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function valuesOf(object: JsonObject, field: string): JsonValue[] {
|
|
595
|
+
const value = object[field];
|
|
596
|
+
return Array.isArray(value) ? value : [];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function objectValue(value: JsonValue | undefined): JsonObject | undefined {
|
|
600
|
+
return value !== undefined && isJsonObject(value) ? value : undefined;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function stringValue(value: JsonValue | undefined): string | undefined {
|
|
604
|
+
return typeof value === "string" ? value : undefined;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function numberValue(value: JsonValue | undefined, fallback: number): number {
|
|
608
|
+
return typeof value === "number" ? value : fallback;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function names(value: JsonValue | undefined): string[] {
|
|
612
|
+
return Array.isArray(value) ? value.filter((candidate): candidate is string => typeof candidate === "string") : [];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function mapResource(resource: VnextResource, map: Map<string, VnextResource>, issues: VnextConfigIssue[]): void {
|
|
616
|
+
const id = resource.id;
|
|
617
|
+
if (!id) return;
|
|
618
|
+
const collision = [...map.keys()].find((candidate) => candidate.toLocaleLowerCase("en-US") === id.toLocaleLowerCase("en-US"));
|
|
619
|
+
if (collision) {
|
|
620
|
+
issues.push(issue("path", "resource_id_collision", resource.logicalPath, `${id} case-folds to existing ${collision}`));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
map.set(id, resource);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function validatePortablePaths(resource: VnextResource, issues: VnextConfigIssue[]): void {
|
|
627
|
+
const candidates: { path: string; label: string }[] = [];
|
|
628
|
+
if (resource.kind === "project") {
|
|
629
|
+
for (const candidate of valuesOf(resource.value, "repositories")) {
|
|
630
|
+
const entry = objectValue(candidate);
|
|
631
|
+
const pathHint = entry && stringValue(entry.pathHint);
|
|
632
|
+
if (pathHint) candidates.push({ path: pathHint, label: "repositories[].pathHint" });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (resource.kind === "environment") {
|
|
636
|
+
const path = objectValue(resource.value.path);
|
|
637
|
+
if (path) {
|
|
638
|
+
for (const field of ["prepend", "append"] as const) {
|
|
639
|
+
for (const candidate of names(path[field])) candidates.push({ path: candidate, label: `path.${field}[]` });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
for (const candidate of candidates) {
|
|
644
|
+
if (!portablePath(candidate.path)) {
|
|
645
|
+
issues.push(issue("path", "portable_path_invalid", resource.logicalPath, `${candidate.label} is not a normalized forward-slash relative path: ${candidate.path}`));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function selectorCandidates(
|
|
651
|
+
selectorValue: JsonValue | undefined,
|
|
652
|
+
models: ReadonlyMap<string, VnextResource>,
|
|
653
|
+
file: string,
|
|
654
|
+
label: string,
|
|
655
|
+
issues: VnextConfigIssue[],
|
|
656
|
+
): VnextResource[] {
|
|
657
|
+
const selector = objectValue(selectorValue);
|
|
658
|
+
if (!selector) return [];
|
|
659
|
+
const profile = stringValue(selector.profile);
|
|
660
|
+
if (profile) {
|
|
661
|
+
const model = models.get(profile);
|
|
662
|
+
if (!model) issues.push(issue("reference", "model_profile_unknown", file, `${label} references unknown model profile ${profile}`));
|
|
663
|
+
return model ? [model] : [];
|
|
664
|
+
}
|
|
665
|
+
const tag = stringValue(selector.tag);
|
|
666
|
+
if (!tag) return [];
|
|
667
|
+
const capabilities = new Set(names(selector.capabilities));
|
|
668
|
+
const candidates = [...models.values()].filter((model) => names(model.value.tags).includes(tag)
|
|
669
|
+
&& [...capabilities].every((capability) => names(model.value.capabilities).includes(capability)));
|
|
670
|
+
if (candidates.length === 0) issues.push(issue("reference", "model_tag_unresolved", file, `${label} selector tag ${tag} has no matching profile`));
|
|
671
|
+
return candidates;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
interface ModelCandidate {
|
|
675
|
+
key: string;
|
|
676
|
+
profile?: string;
|
|
677
|
+
provider?: string;
|
|
678
|
+
model?: string;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function modelCandidates(selectorValue: JsonValue | undefined, models: ReadonlyMap<string, VnextResource>): ModelCandidate[] {
|
|
682
|
+
const selector = objectValue(selectorValue);
|
|
683
|
+
if (!selector) return [];
|
|
684
|
+
const profile = stringValue(selector.profile);
|
|
685
|
+
if (profile) {
|
|
686
|
+
const resource = models.get(profile);
|
|
687
|
+
if (!resource) return [];
|
|
688
|
+
return [{
|
|
689
|
+
key: `profile:${profile}`,
|
|
690
|
+
profile,
|
|
691
|
+
...(stringValue(resource.value.provider) ? { provider: stringValue(resource.value.provider)! } : {}),
|
|
692
|
+
...(stringValue(resource.value.model) ? { model: stringValue(resource.value.model)! } : {}),
|
|
693
|
+
}];
|
|
694
|
+
}
|
|
695
|
+
const tag = stringValue(selector.tag);
|
|
696
|
+
if (tag) {
|
|
697
|
+
const capabilities = new Set(names(selector.capabilities));
|
|
698
|
+
return [...models.values()]
|
|
699
|
+
.filter((resource) => names(resource.value.tags).includes(tag)
|
|
700
|
+
&& [...capabilities].every((capability) => names(resource.value.capabilities).includes(capability)))
|
|
701
|
+
.map((resource) => ({
|
|
702
|
+
key: `profile:${resource.id ?? "unknown"}`,
|
|
703
|
+
...(resource.id ? { profile: resource.id } : {}),
|
|
704
|
+
...(stringValue(resource.value.provider) ? { provider: stringValue(resource.value.provider)! } : {}),
|
|
705
|
+
...(stringValue(resource.value.model) ? { model: stringValue(resource.value.model)! } : {}),
|
|
706
|
+
}));
|
|
707
|
+
}
|
|
708
|
+
const provider = stringValue(selector.provider);
|
|
709
|
+
const model = stringValue(selector.model);
|
|
710
|
+
return provider && model ? [{ key: `direct:${provider}/${model}`, provider, model }] : [];
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function compatibleModel(left: ModelCandidate, right: ModelCandidate): boolean {
|
|
714
|
+
if (left.profile && right.profile) return left.profile === right.profile;
|
|
715
|
+
return left.provider === right.provider && left.model === right.model;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function diversityCandidates(
|
|
719
|
+
stepSelector: JsonValue | undefined,
|
|
720
|
+
allowedAgents: readonly string[],
|
|
721
|
+
agents: ReadonlyMap<string, VnextResource>,
|
|
722
|
+
models: ReadonlyMap<string, VnextResource>,
|
|
723
|
+
): ModelCandidate[] {
|
|
724
|
+
const stepCandidates = modelCandidates(stepSelector, models);
|
|
725
|
+
const resolved: ModelCandidate[] = [];
|
|
726
|
+
for (const agentId of allowedAgents) {
|
|
727
|
+
const agentSelector = agents.get(agentId)?.value.model;
|
|
728
|
+
const agentCandidates = modelCandidates(agentSelector, models);
|
|
729
|
+
if (stepSelector === undefined) resolved.push(...agentCandidates);
|
|
730
|
+
else if (agentSelector === undefined) resolved.push(...stepCandidates);
|
|
731
|
+
else resolved.push(...stepCandidates.filter((candidate) => agentCandidates.some((allowed) => compatibleModel(candidate, allowed))));
|
|
732
|
+
}
|
|
733
|
+
if (allowedAgents.length === 0) resolved.push(...stepCandidates);
|
|
734
|
+
return [...new Map(resolved.map((candidate) => [candidate.key, candidate])).values()];
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function validateEnvironment(resource: VnextResource, issues: VnextConfigIssue[]): void {
|
|
738
|
+
const values = objectValue(resource.value.values) ?? {};
|
|
739
|
+
const secretNames = new Set<string>();
|
|
740
|
+
for (const candidate of valuesOf(resource.value, "secrets")) {
|
|
741
|
+
const secret = objectValue(candidate);
|
|
742
|
+
const name = secret && stringValue(secret.name);
|
|
743
|
+
if (!name) continue;
|
|
744
|
+
if (secretNames.has(name)) issues.push(issue("semantic", "secret_name_duplicate", resource.logicalPath, `secret environment name ${name} is duplicated`));
|
|
745
|
+
secretNames.add(name);
|
|
746
|
+
if (Object.hasOwn(values, name)) issues.push(issue("semantic", "environment_name_conflict", resource.logicalPath, `${name} appears in both values and secrets`));
|
|
747
|
+
}
|
|
748
|
+
for (const [name, value] of Object.entries(values)) {
|
|
749
|
+
if (typeof value === "string" && SECRET_VALUE_PATTERNS.some((pattern) => pattern.test(value))) {
|
|
750
|
+
issues.push(issue("semantic", "probable_secret_value", resource.logicalPath, `${name} resembles a credential and must be represented by secrets[].ref`));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function accessRank(value: JsonValue | undefined): number {
|
|
756
|
+
return value === "write" ? 2 : value === "read" ? 1 : 0;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function validateToolScope(agent: VnextResource, step: JsonObject, file: string, stepId: string, issues: VnextConfigIssue[]): void {
|
|
760
|
+
const requested = objectValue(step.tools);
|
|
761
|
+
if (!requested) return;
|
|
762
|
+
const ceiling = objectValue(agent.value.tools);
|
|
763
|
+
if (!ceiling) {
|
|
764
|
+
issues.push(issue("semantic", "tool_scope_expansion", file, `${stepId} requests tools but agent ${agent.id ?? "unknown"} declares no tool ceiling`));
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const requestedPreset = stringValue(requested.preset);
|
|
768
|
+
const ceilingPreset = stringValue(ceiling.preset);
|
|
769
|
+
if (requestedPreset !== ceilingPreset) {
|
|
770
|
+
issues.push(issue("semantic", "tool_scope_expansion", file, `${stepId} tool preset ${String(requestedPreset)} does not preserve agent ${agent.id ?? "unknown"} preset ${String(ceilingPreset)}`));
|
|
771
|
+
}
|
|
772
|
+
const allowed = new Set(names(ceiling.allow));
|
|
773
|
+
const denied = new Set(names(ceiling.deny));
|
|
774
|
+
for (const tool of names(requested.allow)) {
|
|
775
|
+
if (!allowed.has(tool) || denied.has(tool)) issues.push(issue("semantic", "tool_scope_expansion", file, `${stepId} requests tool ${tool} beyond agent ${agent.id ?? "unknown"} explicit allowlist`));
|
|
776
|
+
}
|
|
777
|
+
const requestedDenied = new Set(names(requested.deny));
|
|
778
|
+
for (const tool of denied) {
|
|
779
|
+
if (!requestedDenied.has(tool)) issues.push(issue("semantic", "tool_scope_expansion", file, `${stepId} drops agent ${agent.id ?? "unknown"} denial for ${tool}`));
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function validateToolPolicy(resource: VnextResource, policy: JsonObject | undefined, label: string, issues: VnextConfigIssue[]): void {
|
|
784
|
+
if (!policy) return;
|
|
785
|
+
const allowed = new Set(names(policy.allow));
|
|
786
|
+
for (const tool of names(policy.deny)) {
|
|
787
|
+
if (allowed.has(tool)) issues.push(issue("semantic", "tool_policy_contradiction", resource.logicalPath, `${label} both allows and denies ${tool}`));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Validate an in-memory resource set with the same semantic rules as a loaded project. */
|
|
792
|
+
export function validateVnextResources(
|
|
793
|
+
resources: ReadonlyMap<string, { kind: VnextResourceKind; id?: string; value: JsonObject }>,
|
|
794
|
+
options: VnextConfigOptions = {},
|
|
795
|
+
): VnextConfigIssue[] {
|
|
796
|
+
const make = (logicalPath: string, kind: VnextResourceKind, id: string | undefined, value: JsonObject): VnextResource => ({
|
|
797
|
+
kind,
|
|
798
|
+
...(id === undefined ? {} : { id }),
|
|
799
|
+
file: logicalPath,
|
|
800
|
+
logicalPath,
|
|
801
|
+
value,
|
|
802
|
+
});
|
|
803
|
+
assertNoRegisteredGates(options);
|
|
804
|
+
let gateRegistry: VnextResource | undefined;
|
|
805
|
+
let project: VnextResource | undefined;
|
|
806
|
+
const repositories = new Map<string, VnextResource>();
|
|
807
|
+
const agents = new Map<string, VnextResource>();
|
|
808
|
+
const models = new Map<string, VnextResource>();
|
|
809
|
+
const workflows = new Map<string, VnextResource>();
|
|
810
|
+
const environments: VnextResource[] = [];
|
|
811
|
+
for (const [logicalPath, resource] of resources) {
|
|
812
|
+
const made = make(logicalPath, resource.kind, resource.id, resource.value);
|
|
813
|
+
if (resource.kind === "project") project = made;
|
|
814
|
+
else if (resource.kind === "repository" && resource.id) repositories.set(resource.id, made);
|
|
815
|
+
else if (resource.kind === "agent" && resource.id) agents.set(resource.id, made);
|
|
816
|
+
else if (resource.kind === "model" && resource.id) models.set(resource.id, made);
|
|
817
|
+
else if (resource.kind === "workflow" && resource.id) workflows.set(resource.id, made);
|
|
818
|
+
else if (resource.kind === "environment") environments.push(made);
|
|
819
|
+
else if (resource.kind === "gate-registry") gateRegistry = made;
|
|
820
|
+
}
|
|
821
|
+
if (!project) return [issue("discovery", "project_definition_missing", ".kxm/project.yaml", "resource set has no project definition")];
|
|
822
|
+
return validateBundle(project, repositories, agents, models, workflows, environments, options, gateRegistry);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function validateAgentScope(
|
|
826
|
+
agent: VnextResource,
|
|
827
|
+
step: JsonObject,
|
|
828
|
+
repositories: ReadonlySet<string>,
|
|
829
|
+
file: string,
|
|
830
|
+
stepId: string,
|
|
831
|
+
issues: VnextConfigIssue[],
|
|
832
|
+
): void {
|
|
833
|
+
validateToolScope(agent, step, file, stepId, issues);
|
|
834
|
+
const agentRepositories = objectValue(agent.value.repositories) ?? {};
|
|
835
|
+
const defaultAccess = agent.value.defaultRepositoryAccess;
|
|
836
|
+
for (const [repositoryId, requested] of Object.entries(objectValue(step.repositories) ?? {})) {
|
|
837
|
+
if (!repositories.has(repositoryId)) continue;
|
|
838
|
+
const ceiling = agentRepositories[repositoryId] ?? defaultAccess ?? "none";
|
|
839
|
+
if (accessRank(requested) > accessRank(ceiling)) {
|
|
840
|
+
issues.push(issue("semantic", "repository_scope_expansion", file, `${stepId} requests ${String(requested)} access to ${repositoryId} beyond agent ${agent.id ?? "unknown"} ceiling ${String(ceiling)}`));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const grants = new Set(valuesOf(agent.value, "secrets").map((candidate) => objectValue(candidate)).filter((candidate): candidate is JsonObject => Boolean(candidate)).map((candidate) => stringValue(candidate.ref)).filter((candidate): candidate is string => Boolean(candidate)));
|
|
844
|
+
for (const candidate of valuesOf(step, "secrets")) {
|
|
845
|
+
const reference = stringValue(objectValue(candidate)?.ref);
|
|
846
|
+
if (reference && !grants.has(reference)) {
|
|
847
|
+
issues.push(issue("semantic", "secret_scope_expansion", file, `${stepId} requests secret ${reference} beyond agent ${agent.id ?? "unknown"} ceiling`));
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function transition(value: JsonValue): { target?: string; maxTransitions?: number; terminalStatus?: string } {
|
|
853
|
+
if (typeof value === "string") return { target: value };
|
|
854
|
+
const object = objectValue(value);
|
|
855
|
+
const target = stringValue(object?.target);
|
|
856
|
+
const terminalStatus = stringValue(object?.terminalStatus);
|
|
857
|
+
return {
|
|
858
|
+
...(target === undefined ? {} : { target }),
|
|
859
|
+
...(typeof object?.maxTransitions === "number" ? { maxTransitions: object.maxTransitions } : {}),
|
|
860
|
+
...(terminalStatus === undefined ? {} : { terminalStatus }),
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function validateWorkflow(
|
|
865
|
+
workflow: VnextResource,
|
|
866
|
+
agents: ReadonlyMap<string, VnextResource>,
|
|
867
|
+
models: ReadonlyMap<string, VnextResource>,
|
|
868
|
+
repositories: ReadonlySet<string>,
|
|
869
|
+
gates: ReadonlySet<string> | undefined,
|
|
870
|
+
issues: VnextConfigIssue[],
|
|
871
|
+
): void {
|
|
872
|
+
const file = workflow.logicalPath;
|
|
873
|
+
const coordinator = stringValue(workflow.value.coordinator) ?? "coordinator";
|
|
874
|
+
if (!agents.has(coordinator)) issues.push(issue("reference", "coordinator_unknown", file, `coordinator ${coordinator} does not exist`));
|
|
875
|
+
const steps = valuesOf(workflow.value, "steps").map((candidate) => objectValue(candidate)).filter((candidate): candidate is JsonObject => Boolean(candidate));
|
|
876
|
+
const stepIndex = new Map<string, number>();
|
|
877
|
+
for (const [index, step] of steps.entries()) {
|
|
878
|
+
const id = stringValue(step.id);
|
|
879
|
+
if (!id) continue;
|
|
880
|
+
if (stepIndex.has(id)) issues.push(issue("semantic", "step_id_duplicate", file, `step ${id} is duplicated`));
|
|
881
|
+
else stepIndex.set(id, index);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
for (const field of ["reproOracle", "planHash"] as const) {
|
|
885
|
+
const oracle = objectValue(workflow.value[field]);
|
|
886
|
+
if (!oracle) continue;
|
|
887
|
+
const stageId = stringValue(oracle.stageId);
|
|
888
|
+
const evidenceKey = stringValue(oracle.evidenceKey);
|
|
889
|
+
const index = stageId === undefined ? undefined : stepIndex.get(stageId);
|
|
890
|
+
if (index === undefined) {
|
|
891
|
+
issues.push(issue("reference", "oracle_stage_unknown", file, `${field} references unknown stage ${String(stageId)}`));
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
const evidence = new Set(valuesOf(steps[index] ?? {}, "requiredEvidence").map((candidate) => stringValue(objectValue(candidate)?.key)).filter((candidate): candidate is string => Boolean(candidate)));
|
|
895
|
+
if (!evidenceKey || !evidence.has(evidenceKey)) issues.push(issue("reference", "oracle_evidence_unknown", file, `${field} references undeclared evidence ${String(evidenceKey)} in ${stageId}`));
|
|
896
|
+
}
|
|
897
|
+
const planStages = new Set(names(workflow.value.requirePlanHash));
|
|
898
|
+
if (planStages.size > 0 && !objectValue(workflow.value.planHash)) issues.push(issue("semantic", "plan_hash_missing", file, "requirePlanHash requires planHash"));
|
|
899
|
+
for (const stageId of planStages) {
|
|
900
|
+
if (!stepIndex.has(stageId)) issues.push(issue("reference", "plan_hash_stage_unknown", file, `requirePlanHash references unknown stage ${stageId}`));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const adjacency = steps.map(() => new Set<number>());
|
|
904
|
+
const terminalReachable = new Set<number>();
|
|
905
|
+
const completedTerminalSources = new Set<number>();
|
|
906
|
+
let hasBackEdge = false;
|
|
907
|
+
for (const [index, step] of steps.entries()) {
|
|
908
|
+
const stepId = stringValue(step.id) ?? `step-${index}`;
|
|
909
|
+
const kind = stringValue(step.kind);
|
|
910
|
+
validateToolPolicy(workflow, objectValue(step.tools), `${stepId}.tools`, issues);
|
|
911
|
+
const primaryAgentId = stringValue(step.agent);
|
|
912
|
+
if ((kind === "agent" || kind === "moa") && primaryAgentId && !agents.has(primaryAgentId)) {
|
|
913
|
+
issues.push(issue("reference", "agent_unknown", file, `${stepId} references unknown agent ${primaryAgentId}`));
|
|
914
|
+
}
|
|
915
|
+
const gate = stringValue(step.gate);
|
|
916
|
+
if (kind === "gate" && gate) {
|
|
917
|
+
if (!gates) issues.push(issue("reference", "gate_registry_missing", file, `${stepId} requires .kxm/gates.yaml`));
|
|
918
|
+
else if (!gates.has(gate)) issues.push(issue("reference", "gate_unknown", file, `${stepId} references unregistered gate ${gate}`));
|
|
919
|
+
}
|
|
920
|
+
if (step.expect !== undefined && (kind !== "gate" || !["pass", "fail"].includes(String(step.expect)))) {
|
|
921
|
+
issues.push(issue("semantic", "gate_expect_invalid", file, `${stepId} expect is gate-only pass or fail`));
|
|
922
|
+
}
|
|
923
|
+
if (kind === "gate" && Object.keys(objectValue(step.on) ?? {}).some((outcome) => outcome === "implementation_failure" || outcome === "repro_missing")) {
|
|
924
|
+
issues.push(issue("semantic", "gate_outcome_renamed", file, `${stepId} must use implementation-failure and repro-missing`));
|
|
925
|
+
}
|
|
926
|
+
for (const repositoryId of Object.keys(objectValue(step.repositories) ?? {})) {
|
|
927
|
+
if (!repositories.has(repositoryId)) issues.push(issue("reference", "repository_unknown", file, `${stepId} references unknown repository ${repositoryId}`));
|
|
928
|
+
}
|
|
929
|
+
selectorCandidates(step.model, models, file, `${stepId}.model`, issues);
|
|
930
|
+
|
|
931
|
+
const assignment = objectValue(step.assignments);
|
|
932
|
+
const declaredAgents = assignment ? names(assignment.allowedAgents) : [];
|
|
933
|
+
const allowedAgents = declaredAgents.length > 0 ? declaredAgents : primaryAgentId ? [primaryAgentId] : [];
|
|
934
|
+
for (const agentId of allowedAgents) {
|
|
935
|
+
const agent = agents.get(agentId);
|
|
936
|
+
if (!agent) issues.push(issue("reference", "assignment_agent_unknown", file, `${stepId} allows unknown agent ${agentId}`));
|
|
937
|
+
else {
|
|
938
|
+
validateAgentScope(agent, step, repositories, file, stepId, issues);
|
|
939
|
+
if (step.model !== undefined && agent.value.model !== undefined) {
|
|
940
|
+
const requestedModels = modelCandidates(step.model, models);
|
|
941
|
+
const ceilingModels = modelCandidates(agent.value.model, models);
|
|
942
|
+
const effectiveModels = requestedModels.filter((requested) => ceilingModels.some((ceiling) => compatibleModel(requested, ceiling)));
|
|
943
|
+
// A workflow selector is always intersected with the selected
|
|
944
|
+
// agent's model ceiling; it never replaces that ceiling. Reject an
|
|
945
|
+
// empty intersection now so dispatch cannot fall back to the raw,
|
|
946
|
+
// broader selector later.
|
|
947
|
+
if (requestedModels.length > 0 && effectiveModels.length === 0) {
|
|
948
|
+
issues.push(issue("semantic", "model_selector_incompatible", file, `${stepId} model selector has no candidate within agent ${agentId} ceiling`));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (primaryAgentId && allowedAgents.length > 0 && !allowedAgents.includes(primaryAgentId)) {
|
|
954
|
+
issues.push(issue("semantic", "primary_agent_ineligible", file, `${stepId} primary agent ${primaryAgentId} is not in allowedAgents`));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const minimum = numberValue(assignment?.minimum, 1);
|
|
958
|
+
const target = numberValue(assignment?.target, minimum);
|
|
959
|
+
const maximum = numberValue(assignment?.maximum, target);
|
|
960
|
+
const maxParallel = numberValue(assignment?.maxParallel, maximum);
|
|
961
|
+
if (!(minimum <= target && target <= maximum)) issues.push(issue("semantic", "assignment_bounds_invalid", file, `${stepId} must satisfy minimum <= target <= maximum`));
|
|
962
|
+
if (maxParallel > maximum) issues.push(issue("semantic", "assignment_parallelism_invalid", file, `${stepId} maxParallel exceeds maximum`));
|
|
963
|
+
if (assignment && allowedAgents.length > 0 && maximum > allowedAgents.length && kind === "moa") {
|
|
964
|
+
issues.push(issue("semantic", "assignment_pool_too_small", file, `${stepId} maximum ${maximum} exceeds ${allowedAgents.length} eligible agents`));
|
|
965
|
+
}
|
|
966
|
+
const maxWriteRepositories = assignment && typeof assignment.maxWriteRepositories === "number" ? assignment.maxWriteRepositories : undefined;
|
|
967
|
+
if (maxWriteRepositories !== undefined) {
|
|
968
|
+
const writable = Object.values(objectValue(step.repositories) ?? {}).filter((access) => access === "write").length;
|
|
969
|
+
if (maxWriteRepositories > writable) issues.push(issue("semantic", "write_repository_bound_invalid", file, `${stepId} maxWriteRepositories exceeds writable repository scope`));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const join = objectValue(step.join);
|
|
973
|
+
const minimumPassed = join && typeof join.minimumPassed === "number" ? join.minimumPassed : undefined;
|
|
974
|
+
if (minimumPassed !== undefined && minimumPassed > maximum) issues.push(issue("semantic", "join_impossible", file, `${stepId} minimumPassed exceeds assignment maximum`));
|
|
975
|
+
|
|
976
|
+
const distinctBy = names(assignment?.distinctBy);
|
|
977
|
+
if (distinctBy.length > 0) {
|
|
978
|
+
const candidates = diversityCandidates(step.model, allowedAgents, agents, models);
|
|
979
|
+
for (const dimension of distinctBy) {
|
|
980
|
+
const distinct = new Set(candidates.map((candidate) => candidate[dimension as "profile" | "provider" | "model"]).filter((value): value is string => Boolean(value)));
|
|
981
|
+
if (distinct.size < target) issues.push(issue("semantic", "model_diversity_impossible", file, `${stepId} requires ${target} distinct ${dimension} values but resolves ${distinct.size}`));
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const evidenceKeys = new Set<string>();
|
|
986
|
+
for (const candidate of valuesOf(step, "requiredEvidence")) {
|
|
987
|
+
const requirement = objectValue(candidate);
|
|
988
|
+
if (!requirement) continue;
|
|
989
|
+
const key = stringValue(requirement.key);
|
|
990
|
+
if (key) {
|
|
991
|
+
if (evidenceKeys.has(key)) issues.push(issue("semantic", "evidence_key_duplicate", file, `${stepId} evidence key ${key} is duplicated`));
|
|
992
|
+
evidenceKeys.add(key);
|
|
993
|
+
}
|
|
994
|
+
const policy = objectValue(requirement.producerPolicy);
|
|
995
|
+
if (!policy) continue;
|
|
996
|
+
const eligible = names(policy.eligibleAgents);
|
|
997
|
+
const producerMinimum = numberValue(policy.minimumProducers, 1);
|
|
998
|
+
if (producerMinimum > target || producerMinimum > eligible.length) issues.push(issue("semantic", "producer_minimum_impossible", file, `${stepId}.${String(key)} producer minimum exceeds its eligible or target pool`));
|
|
999
|
+
for (const agentId of eligible) {
|
|
1000
|
+
if (!agents.has(agentId)) issues.push(issue("reference", "producer_agent_unknown", file, `${stepId}.${String(key)} references unknown producer ${agentId}`));
|
|
1001
|
+
if (allowedAgents.length > 0 && !allowedAgents.includes(agentId)) issues.push(issue("semantic", "producer_agent_ineligible", file, `${stepId}.${String(key)} producer ${agentId} is outside allowedAgents`));
|
|
1002
|
+
}
|
|
1003
|
+
const degradation = objectValue(policy.degradation);
|
|
1004
|
+
if (degradation && numberValue(degradation.minimumProducers, 1) >= producerMinimum) issues.push(issue("semantic", "producer_degradation_invalid", file, `${stepId}.${String(key)} degradation must lower minimumProducers`));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const writes = Object.values(objectValue(step.repositories) ?? {}).some((access) => access === "write");
|
|
1008
|
+
const planHashStage = stringValue(objectValue(workflow.value.planHash)?.stageId);
|
|
1009
|
+
const planHashIndex = planHashStage === undefined ? undefined : stepIndex.get(planHashStage);
|
|
1010
|
+
if (writes && planHashIndex !== undefined && index > planHashIndex && !planStages.has(stepId)) {
|
|
1011
|
+
issues.push(issue("semantic", "mutation_missing_plan_hash", file, `${stepId} mutates after the approved-plan stage but is absent from requirePlanHash`));
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const outcomes = objectValue(step.on) ?? {};
|
|
1015
|
+
if (Object.keys(outcomes).length === 0) issues.push(issue("semantic", "step_transitions_missing", file, `${stepId} has no declared transitions`));
|
|
1016
|
+
for (const [outcome, rawTransition] of Object.entries(outcomes)) {
|
|
1017
|
+
const parsed = transition(rawTransition);
|
|
1018
|
+
if (parsed.target === "$terminal") {
|
|
1019
|
+
terminalReachable.add(index);
|
|
1020
|
+
if (parsed.terminalStatus === "completed") completedTerminalSources.add(index);
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
const targetIndex = parsed.target === undefined ? undefined : stepIndex.get(parsed.target);
|
|
1024
|
+
if (targetIndex === undefined) {
|
|
1025
|
+
issues.push(issue("reference", "transition_target_unknown", file, `${stepId}.${outcome} references unknown target ${String(parsed.target)}`));
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
adjacency[index]?.add(targetIndex);
|
|
1029
|
+
if (targetIndex <= index) {
|
|
1030
|
+
hasBackEdge = true;
|
|
1031
|
+
if (parsed.maxTransitions === undefined) issues.push(issue("semantic", "back_edge_unbounded", file, `${stepId}.${outcome} back-edge requires maxTransitions`));
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (hasBackEdge && typeof objectValue(workflow.value.limits)?.maxTransitions !== "number") {
|
|
1037
|
+
issues.push(issue("semantic", "workflow_cycle_unbounded", file, "workflow with back-edges requires limits.maxTransitions"));
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const readyIndex = stepIndex.get("ready");
|
|
1041
|
+
const verifyIndex = stepIndex.get("verify");
|
|
1042
|
+
if (readyIndex !== undefined && verifyIndex !== undefined) {
|
|
1043
|
+
const verifyTargets = adjacency[verifyIndex] ?? new Set();
|
|
1044
|
+
if (!verifyTargets.has(readyIndex)) {
|
|
1045
|
+
issues.push(issue("semantic", "verify_must_precede_ready", file, "verify must transition to ready"));
|
|
1046
|
+
}
|
|
1047
|
+
for (const [srcIndex, targets] of adjacency.entries()) {
|
|
1048
|
+
if (targets.has(readyIndex) && srcIndex !== verifyIndex) {
|
|
1049
|
+
issues.push(issue("semantic", "verify_must_precede_ready", file, `step ${String(steps[srcIndex]?.id)} transitions to ready bypassing verify`));
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (steps.length > 0) {
|
|
1054
|
+
const reachable = new Set<number>([0]);
|
|
1055
|
+
const queue = [0];
|
|
1056
|
+
while (queue.length > 0) {
|
|
1057
|
+
const current = queue.shift();
|
|
1058
|
+
if (current === undefined) break;
|
|
1059
|
+
for (const target of adjacency[current] ?? []) if (!reachable.has(target)) { reachable.add(target); queue.push(target); }
|
|
1060
|
+
}
|
|
1061
|
+
for (const [index, step] of steps.entries()) if (!reachable.has(index)) issues.push(issue("semantic", "step_unreachable", file, `step ${String(step.id)} is unreachable from the first step`));
|
|
1062
|
+
|
|
1063
|
+
for (const [requiredIndex, requiredStep] of steps.entries()) {
|
|
1064
|
+
if (requiredStep.kind !== "gate" && requiredStep.kind !== "approval") continue;
|
|
1065
|
+
const reachableWithoutRequired = new Set<number>();
|
|
1066
|
+
const queueWithoutRequired = requiredIndex === 0 ? [] : [0];
|
|
1067
|
+
if (requiredIndex !== 0) reachableWithoutRequired.add(0);
|
|
1068
|
+
while (queueWithoutRequired.length > 0) {
|
|
1069
|
+
const current = queueWithoutRequired.shift();
|
|
1070
|
+
if (current === undefined) break;
|
|
1071
|
+
for (const target of adjacency[current] ?? []) {
|
|
1072
|
+
if (target === requiredIndex || reachableWithoutRequired.has(target)) continue;
|
|
1073
|
+
reachableWithoutRequired.add(target);
|
|
1074
|
+
queueWithoutRequired.push(target);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if ([...completedTerminalSources].some((source) => source !== requiredIndex && reachableWithoutRequired.has(source))) {
|
|
1078
|
+
issues.push(issue("semantic", "required_step_bypass", file, `a completed path can bypass required ${String(requiredStep.id)}`));
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const canTerminate = new Set(terminalReachable);
|
|
1083
|
+
let changed = true;
|
|
1084
|
+
while (changed) {
|
|
1085
|
+
changed = false;
|
|
1086
|
+
for (const [index, targets] of adjacency.entries()) {
|
|
1087
|
+
if (!canTerminate.has(index) && [...targets].some((target) => canTerminate.has(target))) { canTerminate.add(index); changed = true; }
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
for (const index of reachable) if (!canTerminate.has(index)) issues.push(issue("semantic", "step_cannot_terminate", file, `step ${String(steps[index]?.id)} has no path to a terminal transition`));
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function validateModelReferences(models: ReadonlyMap<string, VnextResource>, issues: VnextConfigIssue[]): void {
|
|
1095
|
+
for (const model of models.values()) {
|
|
1096
|
+
for (const [index, fallback] of valuesOf(model.value, "fallbacks").entries()) {
|
|
1097
|
+
selectorCandidates(fallback, models, model.logicalPath, `fallbacks[${index}]`, issues);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
const visiting = new Set<string>();
|
|
1101
|
+
const visited = new Set<string>();
|
|
1102
|
+
const walk = (id: string, path: string[]): void => {
|
|
1103
|
+
if (visiting.has(id)) {
|
|
1104
|
+
issues.push(issue("semantic", "model_fallback_cycle", models.get(id)?.logicalPath ?? ".kxm/models", `fallback profile cycle: ${[...path, id].join(" -> ")}`));
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (visited.has(id)) return;
|
|
1108
|
+
visiting.add(id);
|
|
1109
|
+
const model = models.get(id);
|
|
1110
|
+
if (model) {
|
|
1111
|
+
for (const fallback of valuesOf(model.value, "fallbacks")) {
|
|
1112
|
+
const profile = stringValue(objectValue(fallback)?.profile);
|
|
1113
|
+
if (profile && models.has(profile)) walk(profile, [...path, id]);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
visiting.delete(id);
|
|
1117
|
+
visited.add(id);
|
|
1118
|
+
};
|
|
1119
|
+
for (const id of models.keys()) walk(id, []);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function validateBundle(
|
|
1123
|
+
project: VnextResource,
|
|
1124
|
+
repositories: ReadonlyMap<string, VnextResource>,
|
|
1125
|
+
agents: ReadonlyMap<string, VnextResource>,
|
|
1126
|
+
models: ReadonlyMap<string, VnextResource>,
|
|
1127
|
+
workflows: ReadonlyMap<string, VnextResource>,
|
|
1128
|
+
environments: readonly VnextResource[],
|
|
1129
|
+
options: VnextConfigOptions,
|
|
1130
|
+
gateRegistry?: VnextResource,
|
|
1131
|
+
): VnextConfigIssue[] {
|
|
1132
|
+
const issues: VnextConfigIssue[] = [];
|
|
1133
|
+
const entries = valuesOf(project.value, "repositories").map((candidate) => objectValue(candidate)).filter((candidate): candidate is JsonObject => Boolean(candidate));
|
|
1134
|
+
const repositoryIds = new Set<string>();
|
|
1135
|
+
const foldedRepositories = new Map<string, string>();
|
|
1136
|
+
let controls = 0;
|
|
1137
|
+
for (const entry of entries) {
|
|
1138
|
+
const id = stringValue(entry.id);
|
|
1139
|
+
if (!id) continue;
|
|
1140
|
+
const folded = id.toLocaleLowerCase("en-US");
|
|
1141
|
+
const previous = foldedRepositories.get(folded);
|
|
1142
|
+
if (previous) issues.push(issue("path", "repository_id_collision", project.logicalPath, `${id} case-folds to existing ${previous}`));
|
|
1143
|
+
else foldedRepositories.set(folded, id);
|
|
1144
|
+
repositoryIds.add(id);
|
|
1145
|
+
if (entry.role === "control") controls += 1;
|
|
1146
|
+
}
|
|
1147
|
+
if (controls !== 1) issues.push(issue("semantic", "control_repository_count", project.logicalPath, `expected exactly one control repository, received ${controls}`));
|
|
1148
|
+
for (const repository of repositories.values()) {
|
|
1149
|
+
if (repository.value.projectId !== project.value.id) issues.push(issue("reference", "repository_project_mismatch", repository.logicalPath, "projectId does not match the authoritative project"));
|
|
1150
|
+
const id = stringValue(repository.value.repositoryId);
|
|
1151
|
+
if (!id || !repositoryIds.has(id)) issues.push(issue("reference", "repository_definition_unknown", repository.logicalPath, `repositoryId ${String(id)} is not bound by project.yaml`));
|
|
1152
|
+
if (repository.id !== id) issues.push(issue("reference", "repository_binding_identity_mismatch", repository.logicalPath, `binding ${String(repository.id)} declares repositoryId ${String(id)}`));
|
|
1153
|
+
}
|
|
1154
|
+
for (const resource of [project, ...environments]) validatePortablePaths(resource, issues);
|
|
1155
|
+
for (const environment of environments) validateEnvironment(environment, issues);
|
|
1156
|
+
|
|
1157
|
+
const executors = new Set(options.registeredExecutors ?? BUILTIN_EXECUTORS);
|
|
1158
|
+
const gates = gateRegistry ? new Set(Object.keys(objectValue(gateRegistry.value.gates) ?? {})) : undefined;
|
|
1159
|
+
for (const [id, value] of Object.entries(objectValue(gateRegistry?.value.gates) ?? {})) {
|
|
1160
|
+
const definition = objectValue(value);
|
|
1161
|
+
const executable = Array.isArray(definition?.argv) ? definition.argv[0] : undefined;
|
|
1162
|
+
if (definition?.kind === "command" && typeof executable === "string" &&
|
|
1163
|
+
(executable === "." || executable === ".." || executable.includes("\\") || (!executable.startsWith("/") && executable.includes("/")))) {
|
|
1164
|
+
issues.push(issue("semantic", "gate_executable_invalid", ".kxm/gates.yaml", `${id} argv[0] must be a bare executable or absolute POSIX path`));
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const presets = new Set(options.registeredToolPresets ?? BUILTIN_TOOL_PRESETS);
|
|
1168
|
+
const harnesses = new Set(options.registeredHarnesses ?? BUILTIN_HARNESS_IDS);
|
|
1169
|
+
const defaultExecutor = stringValue(project.value.defaultExecutor);
|
|
1170
|
+
if (defaultExecutor && !executors.has(defaultExecutor)) issues.push(issue("reference", "executor_unknown", project.logicalPath, `defaultExecutor ${defaultExecutor} is not registered`));
|
|
1171
|
+
const defaultHarness = stringValue(project.value.defaultHarness) ?? DEFAULT_HARNESS;
|
|
1172
|
+
if (!harnesses.has(defaultHarness)) issues.push(issue("reference", "harness_unknown", project.logicalPath, `defaultHarness ${defaultHarness} is not registered`));
|
|
1173
|
+
|
|
1174
|
+
for (const agent of agents.values()) {
|
|
1175
|
+
validateToolPolicy(agent, objectValue(agent.value.tools), "tools", issues);
|
|
1176
|
+
const executor = stringValue(agent.value.executor);
|
|
1177
|
+
if (executor && !executors.has(executor)) issues.push(issue("reference", "executor_unknown", agent.logicalPath, `executor ${executor} is not registered`));
|
|
1178
|
+
const harness = stringValue(agent.value.harness) ?? defaultHarness;
|
|
1179
|
+
if (!harnesses.has(harness)) issues.push(issue("reference", "harness_unknown", agent.logicalPath, `harness ${harness} is not registered`));
|
|
1180
|
+
const preset = stringValue(objectValue(agent.value.tools)?.preset);
|
|
1181
|
+
if (preset && !presets.has(preset)) issues.push(issue("reference", "tool_preset_unknown", agent.logicalPath, `tool preset ${preset} is not registered`));
|
|
1182
|
+
for (const repositoryId of Object.keys(objectValue(agent.value.repositories) ?? {})) {
|
|
1183
|
+
if (!repositoryIds.has(repositoryId)) issues.push(issue("reference", "repository_unknown", agent.logicalPath, `references unknown repository ${repositoryId}`));
|
|
1184
|
+
}
|
|
1185
|
+
const candidates = selectorCandidates(agent.value.model, models, agent.logicalPath, "model", issues);
|
|
1186
|
+
const declaredHarness = stringValue(agent.value.harness);
|
|
1187
|
+
if (declaredHarness && harnesses.has(declaredHarness)) {
|
|
1188
|
+
for (const candidate of candidates) {
|
|
1189
|
+
const candidateProvider = stringValue(candidate.value.provider);
|
|
1190
|
+
const candidateModel = stringValue(candidate.value.model);
|
|
1191
|
+
const validation = validateHarnessModelPair(declaredHarness, { provider: candidateProvider, model: candidateModel });
|
|
1192
|
+
if (!validation.valid) {
|
|
1193
|
+
issues.push(issue("semantic", validation.issue ?? "harness_unhosted_model", agent.logicalPath, validation.message ?? `harness ${declaredHarness} cannot host model ${candidateModel ?? candidate.logicalPath}`));
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
validateModelReferences(models, issues);
|
|
1199
|
+
|
|
1200
|
+
const defaultWorkflow = stringValue(project.value.defaultWorkflow) ?? "default";
|
|
1201
|
+
if (!workflows.has(defaultWorkflow)) issues.push(issue("reference", "default_workflow_unknown", project.logicalPath, `default workflow ${defaultWorkflow} does not exist`));
|
|
1202
|
+
for (const workflow of workflows.values()) validateWorkflow(workflow, agents, models, repositoryIds, gates, issues);
|
|
1203
|
+
return sortIssues(issues);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
export function vnextCanonicalJson(value: JsonValue): string {
|
|
1207
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
1208
|
+
if (Array.isArray(value)) return `[${value.map((candidate) => vnextCanonicalJson(candidate)).join(",")}]`;
|
|
1209
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${vnextCanonicalJson(value[key] as JsonValue)}`).join(",")}}`;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function canonicalize(value: JsonValue): string {
|
|
1213
|
+
return vnextCanonicalJson(value);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function bundleRevision(resources: readonly VnextResource[]): string {
|
|
1217
|
+
const hash = createHash("sha256");
|
|
1218
|
+
for (const resource of [...resources].sort((left, right) => compareCodeUnits(left.logicalPath, right.logicalPath))) {
|
|
1219
|
+
hash.update(resource.logicalPath, "utf8");
|
|
1220
|
+
hash.update("\0", "utf8");
|
|
1221
|
+
hash.update(canonicalize(resource.value), "utf8");
|
|
1222
|
+
hash.update("\0", "utf8");
|
|
1223
|
+
}
|
|
1224
|
+
return `sha256:${hash.digest("hex")}`;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function gitEnvironment(): NodeJS.ProcessEnv {
|
|
1228
|
+
return Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_")));
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** Ask Git for the authoritative nearest worktree root with Git overrides removed. */
|
|
1232
|
+
export function discoverGitRoot(start = process.cwd()): string | undefined {
|
|
1233
|
+
let current = resolve(start);
|
|
1234
|
+
if (existsSync(current) && !lstatSync(current).isDirectory()) current = dirname(current);
|
|
1235
|
+
const result = spawnSync("git", ["-C", current, "rev-parse", "--show-toplevel"], {
|
|
1236
|
+
encoding: "utf8",
|
|
1237
|
+
env: gitEnvironment(),
|
|
1238
|
+
timeout: 5000,
|
|
1239
|
+
windowsHide: true,
|
|
1240
|
+
});
|
|
1241
|
+
if (result.status !== 0 || result.error || !result.stdout.trim()) return undefined;
|
|
1242
|
+
const root = resolve(result.stdout.trim());
|
|
1243
|
+
return existsSync(root) && lstatSync(root).isDirectory() ? root : undefined;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** Find the authoritative vNext project only at the nearest Git worktree root. */
|
|
1247
|
+
export function discoverVnextProjectRoot(start = process.cwd()): string | undefined {
|
|
1248
|
+
const gitRoot = discoverGitRoot(start);
|
|
1249
|
+
return gitRoot && existsSync(join(gitRoot, ".kxm", "project.yaml")) ? gitRoot : undefined;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** The former caller-supplied gate allowlist cannot substitute for reviewed YAML. */
|
|
1253
|
+
export function assertNoRegisteredGates(options: object): void {
|
|
1254
|
+
if ("registeredGates" in options) {
|
|
1255
|
+
throw new VnextConfigError([issue("semantic", "registered_gates_removed", ".kxm/gates.yaml", "registeredGates was removed; declare gates in .kxm/gates.yaml")]);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** Load and semantically validate one complete, path-derived vNext configuration bundle. */
|
|
1260
|
+
export function loadVnextProject(projectRoot: string, options: VnextConfigOptions = {}): VnextProjectBundle {
|
|
1261
|
+
assertNoRegisteredGates(options);
|
|
1262
|
+
const root = resolve(projectRoot);
|
|
1263
|
+
let migrationReceipt: JsonObject | undefined;
|
|
1264
|
+
if (legacyConfigFilesAt(root).length > 0 && options.allowUnreceiptedLegacyConfig !== true) {
|
|
1265
|
+
const receiptCheck = readVnextMigrationReceipt(root, options);
|
|
1266
|
+
if (receiptCheck.issues.length > 0) throw new VnextConfigError(receiptCheck.issues);
|
|
1267
|
+
migrationReceipt = receiptCheck.receipt;
|
|
1268
|
+
}
|
|
1269
|
+
const registry = new VnextSchemaRegistry(options.schemasDir);
|
|
1270
|
+
const project = readResource(registry, root, join(root, ".kxm", "project.yaml"), ".kxm/project.yaml", "project");
|
|
1271
|
+
const earlyIssues: VnextConfigIssue[] = [];
|
|
1272
|
+
validatePortablePaths(project, earlyIssues);
|
|
1273
|
+
const templateProvenance = readTemplateProvenance(registry, root);
|
|
1274
|
+
if (templateProvenance && templateProvenance.inputs && typeof templateProvenance.inputs === "object" && !Array.isArray(templateProvenance.inputs)) {
|
|
1275
|
+
const provenanceProjectId = stringValue((templateProvenance.inputs as JsonObject).projectId);
|
|
1276
|
+
if (provenanceProjectId !== stringValue(project.value.id)) {
|
|
1277
|
+
earlyIssues.push(issue("semantic", "template_provenance_project_mismatch", ".kxm/template-provenance.yaml", "template provenance belongs to a different project identity"));
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
if (migrationReceipt && stringValue(migrationReceipt.projectId) !== stringValue(project.value.id)) {
|
|
1281
|
+
earlyIssues.push(issue("semantic", "migration_project_mismatch", VNEXT_MIGRATION_RECEIPT_PATH, "migration receipt belongs to a different project identity"));
|
|
1282
|
+
}
|
|
1283
|
+
const declaredRepositoryIds = new Set(valuesOf(project.value, "repositories")
|
|
1284
|
+
.map((candidate) => stringValue(objectValue(candidate)?.id))
|
|
1285
|
+
.filter((candidate): candidate is string => candidate !== undefined));
|
|
1286
|
+
for (const repositoryId of Object.keys(options.repositoryBindings ?? {}).sort(compareCodeUnits)) {
|
|
1287
|
+
if (!resourceIdentifier(repositoryId)) {
|
|
1288
|
+
earlyIssues.push(issue("path", "repository_binding_id_invalid", ".kxm/project.yaml", `host-local binding identity ${repositoryId} is invalid`));
|
|
1289
|
+
} else if (!declaredRepositoryIds.has(repositoryId)) {
|
|
1290
|
+
earlyIssues.push(issue("reference", "repository_binding_unknown", ".kxm/project.yaml", `host-local binding references unknown repository ${repositoryId}`));
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
if (earlyIssues.length > 0) throw new VnextConfigError(earlyIssues);
|
|
1294
|
+
|
|
1295
|
+
const agents = listNamedResources(registry, root, join(root, ".kxm", "agents"), ".kxm/agents", "agent");
|
|
1296
|
+
const models = listNamedResources(registry, root, join(root, ".kxm", "models"), ".kxm/models", "model");
|
|
1297
|
+
const workflows = listNamedResources(registry, root, join(root, ".kxm", "workflows"), ".kxm/workflows", "workflow");
|
|
1298
|
+
const gatePath = join(root, ".kxm", "gates.yaml");
|
|
1299
|
+
const gateRegistry = existsSync(gatePath) ? readResource(registry, root, gatePath, ".kxm/gates.yaml", "gate-registry") : undefined;
|
|
1300
|
+
const environments: VnextResource[] = [];
|
|
1301
|
+
const repositories = new Map<string, VnextResource>();
|
|
1302
|
+
const loadIssues: VnextConfigIssue[] = [];
|
|
1303
|
+
|
|
1304
|
+
const projectEnvironment = join(root, ".kxm", "project", "env.yaml");
|
|
1305
|
+
if (existsSync(projectEnvironment)) {
|
|
1306
|
+
try { environments.push(readResource(registry, root, projectEnvironment, ".kxm/project/env.yaml", "environment")); }
|
|
1307
|
+
catch (error) { if (error instanceof VnextConfigError) loadIssues.push(...error.issues); else throw error; }
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const seenBindings = new Map<string, string>();
|
|
1311
|
+
for (const candidate of valuesOf(project.value, "repositories")) {
|
|
1312
|
+
const entry = objectValue(candidate);
|
|
1313
|
+
const repositoryId = entry && stringValue(entry.id);
|
|
1314
|
+
const pathHint = entry && stringValue(entry.pathHint);
|
|
1315
|
+
const role = entry && stringValue(entry.role);
|
|
1316
|
+
const required = entry?.required !== false;
|
|
1317
|
+
if (!repositoryId) continue;
|
|
1318
|
+
|
|
1319
|
+
const hasLocalBinding = Object.hasOwn(options.repositoryBindings ?? {}, repositoryId);
|
|
1320
|
+
const localBinding = options.repositoryBindings?.[repositoryId];
|
|
1321
|
+
if (hasLocalBinding && (!localBinding || !isAbsolute(localBinding))) {
|
|
1322
|
+
loadIssues.push(issue("path", "repository_binding_not_absolute", ".kxm/project.yaml", `host-local binding for ${repositoryId} must be absolute`));
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
if (role === "control" && localBinding && !sameHostPath(localBinding, root)) {
|
|
1326
|
+
loadIssues.push(issue("path", "control_repository_binding_invalid", ".kxm/project.yaml", `control repository ${repositoryId} must bind to the authoritative project root`));
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
if (role === "control" && pathHint !== undefined && pathHint !== ".") {
|
|
1330
|
+
loadIssues.push(issue("path", "control_repository_path_invalid", ".kxm/project.yaml", `control repository ${repositoryId} must use pathHint .`));
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
if (!hasLocalBinding && role !== "control" && pathHint && portablePath(pathHint)) {
|
|
1334
|
+
const pathIssue = portableBindingIssue(root, pathHint, repositoryId);
|
|
1335
|
+
if (pathIssue) {
|
|
1336
|
+
loadIssues.push(pathIssue);
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
const binding = hasLocalBinding && localBinding
|
|
1341
|
+
? resolve(localBinding)
|
|
1342
|
+
: role === "control"
|
|
1343
|
+
? root
|
|
1344
|
+
: pathHint && portablePath(pathHint)
|
|
1345
|
+
? resolve(root, ...pathHint.split("/"))
|
|
1346
|
+
: undefined;
|
|
1347
|
+
if (!binding) {
|
|
1348
|
+
if (required) loadIssues.push(issue("discovery", "repository_binding_missing", ".kxm/project.yaml", `required repository ${repositoryId} has no portable pathHint or host-local binding`));
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
if (!existsSync(binding)) {
|
|
1352
|
+
if (required || hasLocalBinding) loadIssues.push(issue("discovery", "repository_binding_unavailable", ".kxm/project.yaml", `${hasLocalBinding ? "explicit" : "required"} repository binding ${repositoryId} is unavailable`));
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
const bindingStat = lstatSync(binding);
|
|
1356
|
+
if (bindingStat.isSymbolicLink() || !bindingStat.isDirectory()) {
|
|
1357
|
+
loadIssues.push(issue("path", "repository_binding_invalid", ".kxm/project.yaml", `repository binding ${repositoryId} must be a regular directory`));
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
if (role === "member") {
|
|
1361
|
+
const repositoryGitRoot = discoverGitRoot(binding);
|
|
1362
|
+
if (!repositoryGitRoot || !sameHostPath(repositoryGitRoot, binding)) {
|
|
1363
|
+
loadIssues.push(issue("discovery", "repository_git_root_invalid", ".kxm/project.yaml", `member repository ${repositoryId} must bind to its authoritative Git worktree root`));
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
const foldedBinding = canonicalHostPath(binding).toLocaleLowerCase("en-US");
|
|
1368
|
+
const priorBinding = seenBindings.get(foldedBinding);
|
|
1369
|
+
if (priorBinding && priorBinding !== repositoryId) {
|
|
1370
|
+
loadIssues.push(issue("path", "repository_binding_collision", ".kxm/project.yaml", `${repositoryId} and ${priorBinding} use the same repository binding`));
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1373
|
+
seenBindings.set(foldedBinding, repositoryId);
|
|
1374
|
+
const repositoryFile = join(binding, ".kxm", "repo", "repo.yaml");
|
|
1375
|
+
let definitionValidated = false;
|
|
1376
|
+
if (existsSync(repositoryFile)) {
|
|
1377
|
+
try {
|
|
1378
|
+
const resource = readResource(registry, root, repositoryFile, `.kxm/repositories/${repositoryId}/repo.yaml`, "repository", repositoryId, binding);
|
|
1379
|
+
if (resource.value.repositoryId !== repositoryId) {
|
|
1380
|
+
loadIssues.push(issue("reference", "repository_binding_identity_mismatch", resource.logicalPath, `binding ${repositoryId} declares repositoryId ${String(resource.value.repositoryId)}`));
|
|
1381
|
+
} else {
|
|
1382
|
+
mapResource(resource, repositories, loadIssues);
|
|
1383
|
+
definitionValidated = repositories.get(repositoryId) === resource;
|
|
1384
|
+
}
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
if (error instanceof VnextConfigError) loadIssues.push(...error.issues);
|
|
1387
|
+
else throw error;
|
|
1388
|
+
}
|
|
1389
|
+
} else if (required || hasLocalBinding) {
|
|
1390
|
+
loadIssues.push(issue("discovery", "repository_definition_missing", `.kxm/repositories/${repositoryId}/repo.yaml`, `${hasLocalBinding ? "explicit" : "required"} repository ${repositoryId} has no repo.yaml`));
|
|
1391
|
+
}
|
|
1392
|
+
const environmentFile = join(binding, ".kxm", "repo", "env.yaml");
|
|
1393
|
+
if (existsSync(environmentFile)) {
|
|
1394
|
+
if (!definitionValidated) {
|
|
1395
|
+
loadIssues.push(issue("reference", "repository_environment_without_definition", `.kxm/repositories/${repositoryId}/env.yaml`, `repository ${repositoryId} environment requires a validated matching repo.yaml`));
|
|
1396
|
+
} else {
|
|
1397
|
+
try { environments.push(readResource(registry, root, environmentFile, `.kxm/repositories/${repositoryId}/env.yaml`, "environment", undefined, binding)); }
|
|
1398
|
+
catch (error) { if (error instanceof VnextConfigError) loadIssues.push(...error.issues); else throw error; }
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (loadIssues.length > 0) throw new VnextConfigError(loadIssues);
|
|
1403
|
+
|
|
1404
|
+
const issues = validateBundle(project, repositories, agents, models, workflows, environments, options, gateRegistry);
|
|
1405
|
+
if (issues.length > 0) throw new VnextConfigError(issues);
|
|
1406
|
+
const resources = [project, ...repositories.values(), ...agents.values(), ...models.values(), ...workflows.values(), ...environments, ...(gateRegistry ? [gateRegistry] : [])]
|
|
1407
|
+
.sort((left, right) => compareCodeUnits(left.logicalPath, right.logicalPath));
|
|
1408
|
+
return {
|
|
1409
|
+
projectRoot: root,
|
|
1410
|
+
project,
|
|
1411
|
+
repositories,
|
|
1412
|
+
agents,
|
|
1413
|
+
models,
|
|
1414
|
+
workflows,
|
|
1415
|
+
environments,
|
|
1416
|
+
...(gateRegistry ? { gateRegistry } : {}),
|
|
1417
|
+
...(templateProvenance === undefined ? {} : { templateProvenance }),
|
|
1418
|
+
...(migrationReceipt === undefined ? {} : { migrationReceipt }),
|
|
1419
|
+
resources,
|
|
1420
|
+
configRevision: bundleRevision(resources),
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Fully verify a migration receipt against the loaded target bundle: target
|
|
1426
|
+
* revision and per-resource bytes. Load-time coexistence only pins the
|
|
1427
|
+
* legacy sources; this stricter check backs `kxm migrate verify`.
|
|
1428
|
+
*/
|
|
1429
|
+
export function verifyVnextMigrationReceiptTarget(
|
|
1430
|
+
root: string,
|
|
1431
|
+
receipt: JsonObject,
|
|
1432
|
+
resources: readonly VnextResource[],
|
|
1433
|
+
configRevision: string,
|
|
1434
|
+
): VnextConfigIssue[] {
|
|
1435
|
+
const issues: VnextConfigIssue[] = [];
|
|
1436
|
+
if (receipt.configRevision !== configRevision) {
|
|
1437
|
+
issues.push(issue("semantic", "migration_target_changed", VNEXT_MIGRATION_RECEIPT_PATH, "target configuration revision no longer matches the migration receipt"));
|
|
1438
|
+
}
|
|
1439
|
+
const declared = new Map<string, JsonObject>();
|
|
1440
|
+
for (const candidate of Array.isArray(receipt.resources) ? receipt.resources : []) {
|
|
1441
|
+
if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
|
|
1442
|
+
const record = candidate as JsonObject;
|
|
1443
|
+
if (typeof record.path === "string") declared.set(record.path, record);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
const installedPaths = new Set<string>();
|
|
1447
|
+
for (const resource of resources) {
|
|
1448
|
+
// Repository resources are loaded from their bound worktree paths; only
|
|
1449
|
+
// control-tree files participate in receipt byte checks.
|
|
1450
|
+
const relative = resource.file.startsWith(root) ? relativePortable(root, resource.file) : undefined;
|
|
1451
|
+
if (!relative) continue;
|
|
1452
|
+
installedPaths.add(relative);
|
|
1453
|
+
const record = declared.get(relative);
|
|
1454
|
+
if (!record) {
|
|
1455
|
+
issues.push(issue("semantic", "migration_target_unreceipted", relative, "installed resource is not covered by the migration receipt"));
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
const current = hashFileRecord(root, relative);
|
|
1459
|
+
if (!current || current.sha256 !== record.sha256 || current.bytes !== record.bytes) {
|
|
1460
|
+
issues.push(issue("semantic", "migration_target_modified", relative, "installed resource bytes differ from the migration receipt"));
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
for (const path of declared.keys()) {
|
|
1464
|
+
if (!installedPaths.has(path)) {
|
|
1465
|
+
issues.push(issue("semantic", "migration_target_missing", path, "receipt-covered resource is not part of the validated configuration"));
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return sortIssues(issues);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function relativePortable(root: string, absolute: string): string | undefined {
|
|
1472
|
+
const rel = relative(root, absolute);
|
|
1473
|
+
if (!rel || isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) return undefined;
|
|
1474
|
+
return rel.split(sep).join("/");
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function legacyInputsAt(root: string): string[] {
|
|
1478
|
+
const candidates = [
|
|
1479
|
+
".kxm/config/agents.json",
|
|
1480
|
+
".kxm/config/gates.json",
|
|
1481
|
+
".kxm/config/workflows",
|
|
1482
|
+
".kxm/state/kxm.db",
|
|
1483
|
+
];
|
|
1484
|
+
return candidates.filter((candidate) => existsSync(join(root, ...candidate.split("/"))));
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
export const VNEXT_MIGRATION_RECEIPT_PATH = ".kxm/migration-receipt.yaml";
|
|
1488
|
+
const LEGACY_CONFIG_FILES = [".kxm/config/agents.json", ".kxm/config/gates.json"] as const;
|
|
1489
|
+
|
|
1490
|
+
/** Individual legacy configuration files (not directories, not runtime state) that a migration receipt must cover. */
|
|
1491
|
+
export function legacyConfigFilesAt(root: string): string[] {
|
|
1492
|
+
const files: string[] = [];
|
|
1493
|
+
// Never traverse linked parent components for authoritative legacy bytes;
|
|
1494
|
+
// surface the linked path itself so readers reject it fail-closed.
|
|
1495
|
+
for (const component of [".kxm", ".kxm/config"] as const) {
|
|
1496
|
+
const absolute = join(root, ...component.split("/"));
|
|
1497
|
+
if (existsSync(absolute) && lstatSync(absolute).isSymbolicLink()) return [component];
|
|
1498
|
+
}
|
|
1499
|
+
for (const candidate of LEGACY_CONFIG_FILES) {
|
|
1500
|
+
if (existsSync(join(root, ...candidate.split("/")))) files.push(candidate);
|
|
1501
|
+
}
|
|
1502
|
+
const workflowsDir = join(root, ".kxm", "config", "workflows");
|
|
1503
|
+
if (existsSync(workflowsDir)) {
|
|
1504
|
+
const stat = lstatSync(workflowsDir);
|
|
1505
|
+
if (stat.isSymbolicLink()) {
|
|
1506
|
+
// Never traverse a link for authoritative legacy bytes; surface the
|
|
1507
|
+
// linked directory itself so readers reject it fail-closed.
|
|
1508
|
+
files.push(".kxm/config/workflows");
|
|
1509
|
+
return files;
|
|
1510
|
+
}
|
|
1511
|
+
if (stat.isDirectory()) {
|
|
1512
|
+
for (const entry of readdirSync(workflowsDir, { withFileTypes: true })
|
|
1513
|
+
.filter((candidate) => (candidate.isFile() || candidate.isSymbolicLink()) && candidate.name.endsWith(".json"))
|
|
1514
|
+
.sort((left, right) => compareCodeUnits(left.name, right.name))) {
|
|
1515
|
+
// Linked workflow files are listed so readers reject them fail-closed
|
|
1516
|
+
// (legacy_source_invalid) instead of silently omitting them.
|
|
1517
|
+
files.push(`.kxm/config/workflows/${entry.name}`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
return files;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
export interface VnextMigrationReceiptCheck {
|
|
1525
|
+
receipt?: JsonObject;
|
|
1526
|
+
issues: VnextConfigIssue[];
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function hashFileRecord(root: string, relativePath: string): { path: string; sha256: string; bytes: number } | undefined {
|
|
1530
|
+
const absolute = join(root, ...relativePath.split("/"));
|
|
1531
|
+
if (!existsSync(absolute)) return undefined;
|
|
1532
|
+
const bytes = readFileSync(absolute);
|
|
1533
|
+
return {
|
|
1534
|
+
path: relativePath,
|
|
1535
|
+
sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
|
1536
|
+
bytes: bytes.byteLength,
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
export function migrationReceiptSelfHash(receipt: JsonObject): string {
|
|
1541
|
+
const { receiptSha256: _ignored, ...unsigned } = receipt;
|
|
1542
|
+
return `sha256:${createHash("sha256").update(vnextCanonicalJson(unsigned as JsonObject), "utf8").digest("hex")}`;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Read and structurally verify a migration receipt: schema, self-hash, and
|
|
1547
|
+
* exact coverage of the legacy configuration files still present. Does not
|
|
1548
|
+
* compare the target configuration revision (computed by the caller).
|
|
1549
|
+
*/
|
|
1550
|
+
export function readVnextMigrationReceipt(root: string, options: VnextConfigOptions = {}): VnextMigrationReceiptCheck {
|
|
1551
|
+
const receiptPath = join(root, ...VNEXT_MIGRATION_RECEIPT_PATH.split("/"));
|
|
1552
|
+
const legacyFiles = legacyConfigFilesAt(root);
|
|
1553
|
+
if (!existsSync(receiptPath)) {
|
|
1554
|
+
return {
|
|
1555
|
+
issues: legacyFiles.map((file) => issue("semantic", "legacy_vnext_conflict", file, "legacy and vNext configuration cannot coexist before an accepted migration receipt")),
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
const receiptStat = lstatSync(receiptPath);
|
|
1559
|
+
if (receiptStat.isSymbolicLink() || !receiptStat.isFile()) {
|
|
1560
|
+
return { issues: [issue("path", "migration_receipt_invalid", VNEXT_MIGRATION_RECEIPT_PATH, "migration receipt must be a regular file, not a link")] };
|
|
1561
|
+
}
|
|
1562
|
+
let receipt: JsonObject;
|
|
1563
|
+
try {
|
|
1564
|
+
receipt = parseRestrictedYaml(readFileSync(receiptPath), VNEXT_MIGRATION_RECEIPT_PATH);
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
if (error instanceof VnextConfigError) return { issues: [...error.issues] };
|
|
1567
|
+
throw error;
|
|
1568
|
+
}
|
|
1569
|
+
const registry = new VnextSchemaRegistry(options.schemasDir);
|
|
1570
|
+
const schemaIssues = registry.validateMigrationReceipt(receipt, VNEXT_MIGRATION_RECEIPT_PATH);
|
|
1571
|
+
if (schemaIssues.length > 0) return { issues: schemaIssues };
|
|
1572
|
+
if (receipt.receiptSha256 !== migrationReceiptSelfHash(receipt)) {
|
|
1573
|
+
return { issues: [issue("semantic", "migration_receipt_hash_mismatch", VNEXT_MIGRATION_RECEIPT_PATH, "receipt self-hash does not match its content")] };
|
|
1574
|
+
}
|
|
1575
|
+
const sources = (Array.isArray(receipt.sources) ? receipt.sources : [])
|
|
1576
|
+
.map((candidate) => (candidate && typeof candidate === "object" && !Array.isArray(candidate) ? (candidate as JsonObject).path : undefined))
|
|
1577
|
+
.filter((candidate): candidate is string => typeof candidate === "string")
|
|
1578
|
+
.sort(compareCodeUnits);
|
|
1579
|
+
const issues: VnextConfigIssue[] = [];
|
|
1580
|
+
const declared = new Set(sources);
|
|
1581
|
+
for (const file of legacyFiles) {
|
|
1582
|
+
if (!declared.has(file)) {
|
|
1583
|
+
issues.push(issue("semantic", "migration_source_unmigrated", file, "legacy configuration file is not covered by the migration receipt"));
|
|
1584
|
+
continue;
|
|
1585
|
+
}
|
|
1586
|
+
const record = (Array.isArray(receipt.sources) ? receipt.sources : [])
|
|
1587
|
+
.map((candidate) => (candidate && typeof candidate === "object" && !Array.isArray(candidate) ? candidate as JsonObject : undefined))
|
|
1588
|
+
.find((candidate) => candidate?.path === file);
|
|
1589
|
+
const current = hashFileRecord(root, file);
|
|
1590
|
+
if (!current || current.sha256 !== record?.sha256 || current.bytes !== record?.bytes) {
|
|
1591
|
+
issues.push(issue("semantic", "migration_source_changed", file, "legacy configuration changed after the migration receipt was issued"));
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
for (const file of sources) {
|
|
1595
|
+
if (!legacyFiles.includes(file)) {
|
|
1596
|
+
issues.push(issue("semantic", "migration_source_missing", file, "receipt covers a legacy configuration file that no longer exists"));
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
return issues.length > 0 ? { issues } : { receipt, issues: [] };
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function discoverLegacyRoot(start: string): { root: string; inputs: string[] } | undefined {
|
|
1603
|
+
let initial = resolve(start);
|
|
1604
|
+
if (existsSync(initial) && !lstatSync(initial).isDirectory()) initial = dirname(initial);
|
|
1605
|
+
// Never cross the nearest Git worktree boundary and accidentally select a
|
|
1606
|
+
// parent project or host-level ~/.kxm state.
|
|
1607
|
+
const candidate = discoverGitRoot(initial) ?? initial;
|
|
1608
|
+
const inputs = legacyInputsAt(candidate);
|
|
1609
|
+
return inputs.length > 0 ? { root: candidate, inputs } : undefined;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/** Classify init without changing files. Applying create/repair/migration is a separate, explicit operation. */
|
|
1613
|
+
export function planVnextInitialization(start = process.cwd(), options: VnextConfigOptions = {}): VnextInitializationPlan {
|
|
1614
|
+
const inspectedFrom = resolve(start);
|
|
1615
|
+
const projectRoot = discoverVnextProjectRoot(inspectedFrom);
|
|
1616
|
+
if (projectRoot) {
|
|
1617
|
+
try {
|
|
1618
|
+
const bundle = loadVnextProject(projectRoot, options);
|
|
1619
|
+
return { mode: "ready", inspectedFrom, projectRoot, changesRequired: false, issues: [], legacyInputs: [], configRevision: bundle.configRevision };
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
if (!(error instanceof VnextConfigError)) throw error;
|
|
1622
|
+
const legacyInputs = legacyInputsAt(projectRoot);
|
|
1623
|
+
if (legacyInputs.length > 0) {
|
|
1624
|
+
return { mode: "migrate", inspectedFrom, projectRoot, legacyRoot: projectRoot, changesRequired: true, issues: error.issues, legacyInputs };
|
|
1625
|
+
}
|
|
1626
|
+
return { mode: "repair", inspectedFrom, projectRoot, changesRequired: true, issues: error.issues, legacyInputs: [] };
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
const legacy = discoverLegacyRoot(inspectedFrom);
|
|
1630
|
+
if (legacy) {
|
|
1631
|
+
return { mode: "migrate", inspectedFrom, legacyRoot: legacy.root, changesRequired: true, issues: [], legacyInputs: legacy.inputs };
|
|
1632
|
+
}
|
|
1633
|
+
const gitRoot = discoverGitRoot(inspectedFrom);
|
|
1634
|
+
const candidateRoot = gitRoot ?? inspectedFrom;
|
|
1635
|
+
if (existsSync(join(candidateRoot, ".kxm"))) {
|
|
1636
|
+
return {
|
|
1637
|
+
mode: "repair",
|
|
1638
|
+
inspectedFrom,
|
|
1639
|
+
projectRoot: candidateRoot,
|
|
1640
|
+
changesRequired: true,
|
|
1641
|
+
issues: [issue("discovery", "project_definition_missing", ".kxm/project.yaml", "partial .kxm state has no authoritative project.yaml")],
|
|
1642
|
+
legacyInputs: [],
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
return { mode: "create", inspectedFrom, projectRoot: candidateRoot, changesRequired: true, issues: [], legacyInputs: [] };
|
|
1646
|
+
}
|