@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,936 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
VnextConfigError,
|
|
8
|
+
loadVnextProject,
|
|
9
|
+
parseRestrictedYaml,
|
|
10
|
+
vnextCanonicalJson,
|
|
11
|
+
vnextPortablePath,
|
|
12
|
+
vnextResourceIdentifier,
|
|
13
|
+
type JsonObject,
|
|
14
|
+
type JsonValue,
|
|
15
|
+
type VnextConfigIssue,
|
|
16
|
+
type VnextConfigOptions,
|
|
17
|
+
type VnextProjectBundle,
|
|
18
|
+
type VnextResource,
|
|
19
|
+
type VnextResourceKind,
|
|
20
|
+
} from "./vnext-config.ts";
|
|
21
|
+
|
|
22
|
+
/* ------------------------------------------------------------------ *
|
|
23
|
+
* Structured authority projections
|
|
24
|
+
* ------------------------------------------------------------------ */
|
|
25
|
+
|
|
26
|
+
export type VnextPermissionField =
|
|
27
|
+
| "repository-access"
|
|
28
|
+
| "tools"
|
|
29
|
+
| "secrets"
|
|
30
|
+
| "executor"
|
|
31
|
+
| "harness"
|
|
32
|
+
| "network"
|
|
33
|
+
| "model"
|
|
34
|
+
| "sync-policy"
|
|
35
|
+
| "snapshot-policy"
|
|
36
|
+
| "evidence-quorum"
|
|
37
|
+
| "transition"
|
|
38
|
+
| "budget"
|
|
39
|
+
| "resource-shape"
|
|
40
|
+
| "environment"
|
|
41
|
+
| "gate"
|
|
42
|
+
| "delivery";
|
|
43
|
+
|
|
44
|
+
export type VnextPermissionDirection = "expansion" | "narrowing" | "neutral";
|
|
45
|
+
|
|
46
|
+
export interface VnextAuthorityEntry {
|
|
47
|
+
/** Resource-relative pointer, e.g. /repositories/api or /steps/3/tools/preset. */
|
|
48
|
+
path: string;
|
|
49
|
+
field: VnextPermissionField;
|
|
50
|
+
/** Canonical JSON of the comparable value (never secret material). */
|
|
51
|
+
value: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const PROSE_FIELDS: Readonly<Record<VnextResourceKind, ReadonlySet<string>>> = {
|
|
55
|
+
project: new Set(["name", "description"]),
|
|
56
|
+
repository: new Set(["description"]),
|
|
57
|
+
agent: new Set(["purpose", "instructions"]),
|
|
58
|
+
model: new Set([]),
|
|
59
|
+
environment: new Set([]),
|
|
60
|
+
workflow: new Set(["description"]),
|
|
61
|
+
"gate-registry": new Set([]),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const ACCESS_RANK: Readonly<Record<string, number>> = { none: 0, read: 1, write: 2 };
|
|
65
|
+
const NETWORK_RANK: Readonly<Record<string, number>> = { none: 0, "provider-only": 1, restricted: 2, host: 3 };
|
|
66
|
+
const UNTRACKED_RANK: Readonly<Record<string, number>> = { "tracked-only": 0, ask: 1, bounded: 2 };
|
|
67
|
+
|
|
68
|
+
function canonical(value: JsonValue | undefined): string {
|
|
69
|
+
return vnextCanonicalJson(value === undefined ? null : value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function asObject(value: JsonValue | undefined): JsonObject | undefined {
|
|
73
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function valuesOf(value: JsonObject, key: string): JsonValue[] {
|
|
77
|
+
const candidate = value[key];
|
|
78
|
+
return Array.isArray(candidate) ? candidate : [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stringValue(value: JsonValue | undefined): string | undefined {
|
|
82
|
+
return typeof value === "string" ? value : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pointerEscape(segment: string): string {
|
|
86
|
+
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function secretGrantKeys(grants: JsonValue[]): Map<number, string> {
|
|
90
|
+
const counts = new Map<string, number>();
|
|
91
|
+
for (const grant of grants) {
|
|
92
|
+
const ref = stringValue(asObject(grant)?.ref);
|
|
93
|
+
if (ref) counts.set(ref, (counts.get(ref) ?? 0) + 1);
|
|
94
|
+
}
|
|
95
|
+
const keys = new Map<number, string>();
|
|
96
|
+
for (const [index, grant] of grants.entries()) {
|
|
97
|
+
const ref = stringValue(asObject(grant)?.ref);
|
|
98
|
+
keys.set(index, ref && counts.get(ref) === 1 ? `by-ref/${pointerEscape(ref)}` : String(index));
|
|
99
|
+
}
|
|
100
|
+
return keys;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Extract the authority-bearing fields of one resource as comparable entries. */
|
|
104
|
+
export function vnextAuthorityEntries(resource: { kind: VnextResourceKind; id?: string; value: JsonObject }): VnextAuthorityEntry[] {
|
|
105
|
+
const entries: VnextAuthorityEntry[] = [];
|
|
106
|
+
const push = (path: string, field: VnextPermissionField, value: JsonValue | undefined): void => {
|
|
107
|
+
entries.push({ path, field, value: canonical(value) });
|
|
108
|
+
};
|
|
109
|
+
const value = resource.value;
|
|
110
|
+
|
|
111
|
+
switch (resource.kind) {
|
|
112
|
+
case "project": {
|
|
113
|
+
push("/id", "resource-shape", value.id);
|
|
114
|
+
push("/defaultWorkflow", "delivery", value.defaultWorkflow);
|
|
115
|
+
push("/defaultExecutor", "executor", value.defaultExecutor);
|
|
116
|
+
push("/defaultHarness", "harness", value.defaultHarness ?? "pi");
|
|
117
|
+
const declarations = valuesOf(value, "repositories");
|
|
118
|
+
const declarationIds = new Map<string, number>();
|
|
119
|
+
for (const entry of declarations) {
|
|
120
|
+
const id = stringValue(asObject(entry)?.id);
|
|
121
|
+
if (id) declarationIds.set(id, (declarationIds.get(id) ?? 0) + 1);
|
|
122
|
+
}
|
|
123
|
+
for (const [index, repository] of declarations.entries()) {
|
|
124
|
+
const entry = asObject(repository);
|
|
125
|
+
if (!entry) continue;
|
|
126
|
+
const id = stringValue(entry.id);
|
|
127
|
+
const key = id && declarationIds.get(id) === 1 ? `by-id/${pointerEscape(id)}` : String(index);
|
|
128
|
+
push(`/repositories/${key}`, "resource-shape", entry);
|
|
129
|
+
}
|
|
130
|
+
const workspace = asObject(value.workspace);
|
|
131
|
+
if (workspace) push("/workspace", "snapshot-policy", workspace);
|
|
132
|
+
const sync = asObject(value.sync);
|
|
133
|
+
if (sync) push("/sync", "sync-policy", sync);
|
|
134
|
+
const limits = asObject(value.limits);
|
|
135
|
+
if (limits) push("/limits", "budget", limits);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case "repository": {
|
|
139
|
+
push("/projectId", "resource-shape", value.projectId);
|
|
140
|
+
push("/repositoryId", "resource-shape", value.repositoryId);
|
|
141
|
+
push("/defaultAccess", "repository-access", value.defaultAccess);
|
|
142
|
+
push("/ecosystems", "resource-shape", value.ecosystems);
|
|
143
|
+
push("/classification", "resource-shape", value.classification);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "agent": {
|
|
147
|
+
push("/model", "model", value.model);
|
|
148
|
+
push("/executor", "executor", value.executor);
|
|
149
|
+
push("/harness", "harness", value.harness ?? "pi");
|
|
150
|
+
push("/tools", "tools", value.tools);
|
|
151
|
+
push("/defaultRepositoryAccess", "repository-access", value.defaultRepositoryAccess);
|
|
152
|
+
const repositories = asObject(value.repositories);
|
|
153
|
+
for (const repositoryId of Object.keys(repositories ?? {}).sort()) {
|
|
154
|
+
push(`/repositories/${pointerEscape(repositoryId)}`, "repository-access", repositories?.[repositoryId]);
|
|
155
|
+
}
|
|
156
|
+
const secretKeys = secretGrantKeys(valuesOf(value, "secrets"));
|
|
157
|
+
for (const [index, grant] of valuesOf(value, "secrets").entries()) {
|
|
158
|
+
push(`/secrets/${secretKeys.get(index)}`, "secrets", grant);
|
|
159
|
+
}
|
|
160
|
+
push("/network", "network", value.network);
|
|
161
|
+
push("/resultSchema", "delivery", value.resultSchema);
|
|
162
|
+
const session = asObject(value.session);
|
|
163
|
+
if (session) push("/session", "resource-shape", session);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "model": {
|
|
167
|
+
push("/provider", "model", value.provider);
|
|
168
|
+
push("/model", "model", value.model);
|
|
169
|
+
push("/thinking", "model", value.thinking);
|
|
170
|
+
// Tags drive selector resolution; retagging retargets consumers.
|
|
171
|
+
push("/tags", "model", value.tags);
|
|
172
|
+
push("/capabilities", "model", value.capabilities);
|
|
173
|
+
push("/priority", "model", value.priority);
|
|
174
|
+
push("/fallbacks", "model", value.fallbacks);
|
|
175
|
+
const limits = asObject(value.limits);
|
|
176
|
+
if (limits) push("/limits", "budget", limits);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "environment": {
|
|
180
|
+
push("/path", "environment", value.path);
|
|
181
|
+
const envValues = asObject(value.values);
|
|
182
|
+
for (const key of Object.keys(envValues ?? {}).sort()) {
|
|
183
|
+
const entry = asObject(envValues?.[key]) as JsonObject | undefined;
|
|
184
|
+
// Never compare or store secret values: hash only the reference shape.
|
|
185
|
+
const projected: JsonValue | undefined = entry
|
|
186
|
+
? ({ ...entry, value: entry.value === undefined ? undefined : "<redacted>" } as JsonObject)
|
|
187
|
+
: (envValues?.[key] as JsonValue | undefined);
|
|
188
|
+
push(`/values/${pointerEscape(key)}`, "environment", projected);
|
|
189
|
+
}
|
|
190
|
+
const envSecretKeys = secretGrantKeys(valuesOf(value, "secrets"));
|
|
191
|
+
for (const [index, grant] of valuesOf(value, "secrets").entries()) {
|
|
192
|
+
push(`/secrets/${envSecretKeys.get(index)}`, "secrets", grant);
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case "gate-registry": {
|
|
197
|
+
push("/schema", "resource-shape", value.schema);
|
|
198
|
+
const gates = asObject(value.gates) ?? {};
|
|
199
|
+
for (const id of Object.keys(gates).sort()) {
|
|
200
|
+
const definition = asObject(gates[id]);
|
|
201
|
+
const path = `/gates/${pointerEscape(id)}`;
|
|
202
|
+
if (definition?.kind === "command") {
|
|
203
|
+
const { timeoutMs, ...authority } = definition;
|
|
204
|
+
push(path, "gate", authority);
|
|
205
|
+
push(`${path}/timeoutMs`, "budget", timeoutMs);
|
|
206
|
+
} else push(path, "gate", gates[id]);
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
case "workflow": {
|
|
211
|
+
push("/coordinator", "resource-shape", value.coordinator);
|
|
212
|
+
const limits = asObject(value.limits);
|
|
213
|
+
if (limits) push("/limits", "budget", limits);
|
|
214
|
+
if (value.reproOracle !== undefined) push("/reproOracle", "evidence-quorum", value.reproOracle);
|
|
215
|
+
if (value.planHash !== undefined) push("/planHash", "evidence-quorum", value.planHash);
|
|
216
|
+
if (value.requirePlanHash !== undefined) push("/requirePlanHash", "evidence-quorum", value.requirePlanHash);
|
|
217
|
+
for (const [index, stepEntry] of valuesOf(value, "steps").entries()) {
|
|
218
|
+
const step = asObject(stepEntry);
|
|
219
|
+
if (!step) continue;
|
|
220
|
+
const stepPath = `/steps/${index}`;
|
|
221
|
+
push(`${stepPath}/id`, "resource-shape", step.id);
|
|
222
|
+
push(`${stepPath}/kind`, "resource-shape", step.kind);
|
|
223
|
+
push(`${stepPath}/agent`, "resource-shape", step.agent);
|
|
224
|
+
push(`${stepPath}/gate`, "gate", step.gate);
|
|
225
|
+
if (step.kind === "gate") push(`${stepPath}/expect`, "gate", step.expect ?? "pass");
|
|
226
|
+
push(`${stepPath}/signal`, "delivery", step.signal);
|
|
227
|
+
push(`${stepPath}/model`, "model", step.model);
|
|
228
|
+
push(`${stepPath}/maxAttempts`, "budget", step.maxAttempts);
|
|
229
|
+
push(`${stepPath}/timeoutMs`, "budget", step.timeoutMs);
|
|
230
|
+
const repositories = asObject(step.repositories);
|
|
231
|
+
for (const repositoryId of Object.keys(repositories ?? {}).sort()) {
|
|
232
|
+
push(`${stepPath}/repositories/${pointerEscape(repositoryId)}`, "repository-access", repositories?.[repositoryId]);
|
|
233
|
+
}
|
|
234
|
+
push(`${stepPath}/tools`, "tools", step.tools);
|
|
235
|
+
const stepSecretKeys = secretGrantKeys(valuesOf(step, "secrets"));
|
|
236
|
+
for (const [grantIndex, grant] of valuesOf(step, "secrets").entries()) {
|
|
237
|
+
push(`${stepPath}/secrets/${stepSecretKeys.get(grantIndex)}`, "secrets", grant);
|
|
238
|
+
}
|
|
239
|
+
push(`${stepPath}/assignments`, "resource-shape", step.assignments);
|
|
240
|
+
push(`${stepPath}/join`, "evidence-quorum", step.join);
|
|
241
|
+
const evidence = valuesOf(step, "requiredEvidence");
|
|
242
|
+
for (const [evidenceIndex, evidenceEntry] of evidence.entries()) {
|
|
243
|
+
push(`${stepPath}/requiredEvidence/${evidenceIndex}`, "evidence-quorum", evidenceEntry);
|
|
244
|
+
}
|
|
245
|
+
push(`${stepPath}/safeSpeculation`, "resource-shape", step.safeSpeculation);
|
|
246
|
+
push(`${stepPath}/on`, "transition", step.on);
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return entries;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Prose/non-authority fields, surfaced separately so reviewers see them as neutral. */
|
|
255
|
+
export function vnextProseEntries(resource: { kind: VnextResourceKind; value: JsonObject }): Map<string, string> {
|
|
256
|
+
const entries = new Map<string, string>();
|
|
257
|
+
for (const field of PROSE_FIELDS[resource.kind]) {
|
|
258
|
+
if (resource.value[field] !== undefined) entries.set(`/${field}`, canonical(resource.value[field]));
|
|
259
|
+
}
|
|
260
|
+
if (resource.kind === "workflow") {
|
|
261
|
+
for (const [index, stepEntry] of valuesOf(resource.value, "steps").entries()) {
|
|
262
|
+
const step = asObject(stepEntry);
|
|
263
|
+
if (!step) continue;
|
|
264
|
+
for (const field of ["description", "instructions"] as const) {
|
|
265
|
+
if (step[field] !== undefined) entries.set(`/steps/${index}/${field}`, canonical(step[field]));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return entries;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* ------------------------------------------------------------------ *
|
|
273
|
+
* Diff
|
|
274
|
+
* ------------------------------------------------------------------ */
|
|
275
|
+
|
|
276
|
+
export interface VnextPermissionChange {
|
|
277
|
+
resource: string;
|
|
278
|
+
path: string;
|
|
279
|
+
field: VnextPermissionField;
|
|
280
|
+
direction: VnextPermissionDirection;
|
|
281
|
+
summary: string;
|
|
282
|
+
baseValueSha256?: string;
|
|
283
|
+
candidateValueSha256?: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface VnextPermissionDiff {
|
|
287
|
+
schema: "kxm.permission-diff.v1";
|
|
288
|
+
baseRevision: string;
|
|
289
|
+
candidateRevision: string;
|
|
290
|
+
changes: VnextPermissionChange[];
|
|
291
|
+
expansions: VnextPermissionChange[];
|
|
292
|
+
narrowings: VnextPermissionChange[];
|
|
293
|
+
neutralChanges: VnextPermissionChange[];
|
|
294
|
+
requiresReview: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function valueHash(canonicalValue: string): string {
|
|
298
|
+
return `sha256:${createHash("sha256").update(canonicalValue, "utf8").digest("hex")}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function rankDirection(rank: Readonly<Record<string, number>>, baseValue: string, candidateValue: string): VnextPermissionDirection {
|
|
302
|
+
const base = JSON.parse(baseValue) as unknown;
|
|
303
|
+
const candidate = JSON.parse(candidateValue) as unknown;
|
|
304
|
+
const baseRank = typeof base === "string" && base in rank ? rank[base] as number : undefined;
|
|
305
|
+
const candidateRank = typeof candidate === "string" && candidate in rank ? rank[candidate] as number : undefined;
|
|
306
|
+
if (baseRank === undefined || candidateRank === undefined) return "expansion";
|
|
307
|
+
if (candidateRank > baseRank) return "expansion";
|
|
308
|
+
if (candidateRank < baseRank) return "narrowing";
|
|
309
|
+
return "neutral";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Direction for a numeric bound: raising limits is expansion. */
|
|
313
|
+
function numericBoundDirection(baseValue: string, candidateValue: string): VnextPermissionDirection {
|
|
314
|
+
const base = JSON.parse(baseValue) as unknown;
|
|
315
|
+
const candidate = JSON.parse(candidateValue) as unknown;
|
|
316
|
+
if (typeof base !== "number" || typeof candidate !== "number") return "expansion";
|
|
317
|
+
if (candidate > base) return "expansion";
|
|
318
|
+
if (candidate < base) return "narrowing";
|
|
319
|
+
return "neutral";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function budgetDirection(baseValue: string, candidateValue: string): VnextPermissionDirection {
|
|
323
|
+
const base = asObject(JSON.parse(baseValue) as JsonValue);
|
|
324
|
+
const candidate = asObject(JSON.parse(candidateValue) as JsonValue);
|
|
325
|
+
if (!base || !candidate) return "expansion";
|
|
326
|
+
let sawExpansion = false;
|
|
327
|
+
let sawNarrowing = false;
|
|
328
|
+
for (const key of new Set([...Object.keys(base), ...Object.keys(candidate)])) {
|
|
329
|
+
const direction = numericBoundDirection(canonical(base[key] as JsonValue | undefined), canonical(candidate[key] as JsonValue | undefined));
|
|
330
|
+
if (direction === "expansion") sawExpansion = true;
|
|
331
|
+
if (direction === "narrowing") sawNarrowing = true;
|
|
332
|
+
}
|
|
333
|
+
// Mixed directions cannot be ordered conservatively.
|
|
334
|
+
if (sawExpansion) return "expansion";
|
|
335
|
+
return sawNarrowing ? "narrowing" : "neutral";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function quorumDirection(baseValue: string, candidateValue: string): VnextPermissionDirection {
|
|
339
|
+
const base = asObject(JSON.parse(baseValue) as JsonValue);
|
|
340
|
+
const candidate = asObject(JSON.parse(candidateValue) as JsonValue);
|
|
341
|
+
if (!base || !candidate) return "expansion";
|
|
342
|
+
// Join policies, oracles, and plan-hash retargets have no conservative
|
|
343
|
+
// order: any change can weaken the quorum path.
|
|
344
|
+
if (base.strategy !== undefined || candidate.strategy !== undefined) return "expansion";
|
|
345
|
+
if (base.stageId !== undefined || candidate.stageId !== undefined) return "expansion";
|
|
346
|
+
// Evidence requirement entries: key/kind must match for an ordered compare.
|
|
347
|
+
if (base.key === undefined || candidate.key === undefined || base.key !== candidate.key || base.kind !== candidate.kind) return "expansion";
|
|
348
|
+
const baseRequirementMinimum = typeof base.minimum === "number" ? base.minimum : 1;
|
|
349
|
+
const candidateRequirementMinimum = typeof candidate.minimum === "number" ? candidate.minimum : 1;
|
|
350
|
+
if (candidateRequirementMinimum < baseRequirementMinimum) return "expansion";
|
|
351
|
+
// A narrowing observed so far must never short-circuit the remaining
|
|
352
|
+
// quorum checks: a later expansion always wins the report.
|
|
353
|
+
let sawNarrowing = candidateRequirementMinimum > baseRequirementMinimum;
|
|
354
|
+
if (base.reusableAcrossAttempts === true && (candidate.reusableAcrossAttempts ?? false) === false) sawNarrowing = true;
|
|
355
|
+
if ((base.reusableAcrossAttempts ?? false) === false && candidate.reusableAcrossAttempts === true) return "expansion";
|
|
356
|
+
const CLASSIFIED = new Set(["key", "kind", "minimum", "reusableAcrossAttempts", "strategy", "stageId", "evidenceKey"]);
|
|
357
|
+
const CLASSIFIED_POLICY = new Set(["minimumProducers", "eligibleAgents", "degradation"]);
|
|
358
|
+
const residualOf = (value: string): JsonValue => {
|
|
359
|
+
const parsed = asObject(JSON.parse(value) as JsonValue);
|
|
360
|
+
if (!parsed) return null;
|
|
361
|
+
const residual: JsonObject = {};
|
|
362
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
363
|
+
if (CLASSIFIED.has(key)) continue;
|
|
364
|
+
if (key === "producerPolicy") {
|
|
365
|
+
const policy = asObject(entry);
|
|
366
|
+
if (!policy) {
|
|
367
|
+
residual[key] = entry;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
residual[key] = Object.fromEntries(Object.entries(policy).filter(([policyKey]) => !CLASSIFIED_POLICY.has(policyKey)));
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
residual[key] = entry;
|
|
374
|
+
}
|
|
375
|
+
return residual;
|
|
376
|
+
};
|
|
377
|
+
// Residual fail-closed check runs BEFORE any narrowing return: unknown or
|
|
378
|
+
// future fields always expand, even alongside an ordered narrowing.
|
|
379
|
+
if (vnextCanonicalJson(residualOf(baseValue)) !== vnextCanonicalJson(residualOf(candidateValue))) return "expansion";
|
|
380
|
+
const basePolicy = asObject(base.producerPolicy as JsonValue | undefined);
|
|
381
|
+
const candidatePolicy = asObject(candidate.producerPolicy as JsonValue | undefined);
|
|
382
|
+
if (!basePolicy && !candidatePolicy) return sawNarrowing ? "narrowing" : "neutral";
|
|
383
|
+
if (!basePolicy || !candidatePolicy) return "expansion";
|
|
384
|
+
const basePolicyMinimum = typeof basePolicy.minimumProducers === "number" ? basePolicy.minimumProducers : 1;
|
|
385
|
+
const candidatePolicyMinimum = typeof candidatePolicy.minimumProducers === "number" ? candidatePolicy.minimumProducers : 1;
|
|
386
|
+
if (candidatePolicyMinimum < basePolicyMinimum) return "expansion"; // weaker quorum
|
|
387
|
+
const baseEligible = new Set((Array.isArray(basePolicy.eligibleAgents) ? basePolicy.eligibleAgents : []).map(String));
|
|
388
|
+
const candidateEligible = new Set((Array.isArray(candidatePolicy.eligibleAgents) ? candidatePolicy.eligibleAgents : []).map(String));
|
|
389
|
+
for (const agent of baseEligible) if (!candidateEligible.has(agent)) return "expansion"; // producer removed
|
|
390
|
+
const baseDegradation = asObject(basePolicy.degradation as JsonValue | undefined);
|
|
391
|
+
const candidateDegradation = asObject(candidatePolicy.degradation as JsonValue | undefined);
|
|
392
|
+
if (!baseDegradation && candidateDegradation) return "expansion"; // degradation introduced
|
|
393
|
+
if (baseDegradation && candidateDegradation) {
|
|
394
|
+
const baseFloor = typeof baseDegradation.minimumProducers === "number" ? baseDegradation.minimumProducers : 1;
|
|
395
|
+
const candidateFloor = typeof candidateDegradation.minimumProducers === "number" ? candidateDegradation.minimumProducers : 1;
|
|
396
|
+
if (candidateFloor < baseFloor) return "expansion";
|
|
397
|
+
}
|
|
398
|
+
if (candidatePolicyMinimum > basePolicyMinimum) return "narrowing";
|
|
399
|
+
for (const agent of candidateEligible) if (!baseEligible.has(agent)) {
|
|
400
|
+
// A new producer enlarges the set of passing combinations at the same
|
|
401
|
+
// floor (a+c and b+c now pass where only a+b did): quorum weakening.
|
|
402
|
+
return "expansion";
|
|
403
|
+
}
|
|
404
|
+
return sawNarrowing ? "narrowing" : "neutral";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function snapshotDirection(baseValue: string, candidateValue: string): VnextPermissionDirection {
|
|
408
|
+
const base = asObject(JSON.parse(baseValue) as JsonValue);
|
|
409
|
+
const candidate = asObject(JSON.parse(candidateValue) as JsonValue);
|
|
410
|
+
if (!base || !candidate) return "expansion";
|
|
411
|
+
const baseSnapshot = asObject(base.dirtySnapshot as JsonValue | undefined);
|
|
412
|
+
const candidateSnapshot = asObject(candidate.dirtySnapshot as JsonValue | undefined);
|
|
413
|
+
if (!baseSnapshot || !candidateSnapshot) return "expansion";
|
|
414
|
+
const untracked = rankDirection(UNTRACKED_RANK, canonical(baseSnapshot.untracked as JsonValue | undefined), canonical(candidateSnapshot.untracked as JsonValue | undefined));
|
|
415
|
+
if (untracked === "expansion") return "expansion";
|
|
416
|
+
// Byte bounds matter only while the mode is bounded; downgrading the mode
|
|
417
|
+
// makes the bounds irrelevant rather than an expansion.
|
|
418
|
+
const candidateMode = stringValue(candidateSnapshot.untracked as JsonValue | undefined);
|
|
419
|
+
const bounds = candidateMode === "bounded"
|
|
420
|
+
? budgetDirection(
|
|
421
|
+
canonical({ maxUntrackedFileBytes: baseSnapshot.maxUntrackedFileBytes, maxUntrackedTotalBytes: baseSnapshot.maxUntrackedTotalBytes } as JsonValue),
|
|
422
|
+
canonical({ maxUntrackedFileBytes: candidateSnapshot.maxUntrackedFileBytes, maxUntrackedTotalBytes: candidateSnapshot.maxUntrackedTotalBytes } as JsonValue),
|
|
423
|
+
)
|
|
424
|
+
: "neutral";
|
|
425
|
+
if (bounds === "expansion") return "expansion";
|
|
426
|
+
// Residual fail-closed check: unknown future snapshot keys expand.
|
|
427
|
+
const CLASSIFIED_SNAPSHOT = new Set(["untracked", "maxUntrackedFileBytes", "maxUntrackedTotalBytes", "dirtySubmodules"]);
|
|
428
|
+
const residualOf = (snapshot: JsonObject): JsonValue => Object.fromEntries(Object.entries(snapshot).filter(([key]) => !CLASSIFIED_SNAPSHOT.has(key)));
|
|
429
|
+
if (vnextCanonicalJson(residualOf(baseSnapshot)) !== vnextCanonicalJson(residualOf(candidateSnapshot))) return "expansion";
|
|
430
|
+
return bounds === "narrowing" || untracked === "narrowing" ? "narrowing" : "neutral";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function classifyChange(
|
|
434
|
+
field: VnextPermissionField,
|
|
435
|
+
baseValue: string | undefined,
|
|
436
|
+
candidateValue: string | undefined,
|
|
437
|
+
context?: { fallbackRank?: number; baseFallbackRank?: number },
|
|
438
|
+
): VnextPermissionDirection {
|
|
439
|
+
if (baseValue === candidateValue) return "neutral";
|
|
440
|
+
if (baseValue === undefined || candidateValue === undefined) {
|
|
441
|
+
if (candidateValue === undefined) {
|
|
442
|
+
// Removal semantics depend on the effective fallback. A secret grant
|
|
443
|
+
// removal strictly narrows (no fallback exists). Everything else can
|
|
444
|
+
// widen the effective ceiling by falling back to a broader default, so
|
|
445
|
+
// it narrows only when the removed value is provably at or below the
|
|
446
|
+
// fallback rank; otherwise it expands.
|
|
447
|
+
if (field === "secrets") return "narrowing";
|
|
448
|
+
if (field === "repository-access" && context?.fallbackRank !== undefined) {
|
|
449
|
+
const removed = JSON.parse(baseValue as string) as unknown;
|
|
450
|
+
const removedRank = typeof removed === "string" && removed in ACCESS_RANK ? ACCESS_RANK[removed] as number : Number.POSITIVE_INFINITY;
|
|
451
|
+
// Removing an override lands on the fallback: strictly-weaker-than-
|
|
452
|
+
// fallback removals widen effective access; broader ones tighten it;
|
|
453
|
+
// equal-rank removals change nothing effective.
|
|
454
|
+
if (removedRank < context.fallbackRank) return "expansion";
|
|
455
|
+
return removedRank > context.fallbackRank ? "narrowing" : "neutral";
|
|
456
|
+
}
|
|
457
|
+
return "expansion";
|
|
458
|
+
}
|
|
459
|
+
// Addition: mirror of removal — an override stricter than the BASE
|
|
460
|
+
// fallback narrows, a broader one expands, an equal one is neutral.
|
|
461
|
+
if (field === "repository-access" && context?.baseFallbackRank !== undefined) {
|
|
462
|
+
const added = JSON.parse(candidateValue) as unknown;
|
|
463
|
+
const addedRank = typeof added === "string" && added in ACCESS_RANK ? ACCESS_RANK[added] as number : Number.POSITIVE_INFINITY;
|
|
464
|
+
if (addedRank > context.baseFallbackRank) return "expansion";
|
|
465
|
+
return addedRank < context.baseFallbackRank ? "narrowing" : "neutral";
|
|
466
|
+
}
|
|
467
|
+
return "expansion";
|
|
468
|
+
}
|
|
469
|
+
switch (field) {
|
|
470
|
+
case "repository-access": return rankDirection(ACCESS_RANK, baseValue, candidateValue);
|
|
471
|
+
case "network": return rankDirection(NETWORK_RANK, baseValue, candidateValue);
|
|
472
|
+
case "budget": {
|
|
473
|
+
// Scalar bounds (maxAttempts, timeoutMs) compare numerically; object
|
|
474
|
+
// bounds (limits/workspace) compare field-wise.
|
|
475
|
+
const baseParsed = JSON.parse(baseValue) as unknown;
|
|
476
|
+
const candidateParsed = JSON.parse(candidateValue) as unknown;
|
|
477
|
+
if (typeof baseParsed === "number" && typeof candidateParsed === "number") {
|
|
478
|
+
return numericBoundDirection(baseValue, candidateValue);
|
|
479
|
+
}
|
|
480
|
+
return budgetDirection(baseValue, candidateValue);
|
|
481
|
+
}
|
|
482
|
+
case "evidence-quorum": return quorumDirection(baseValue, candidateValue);
|
|
483
|
+
case "snapshot-policy": return snapshotDirection(baseValue, candidateValue);
|
|
484
|
+
case "secrets": {
|
|
485
|
+
const base = asObject(JSON.parse(baseValue) as JsonValue);
|
|
486
|
+
const candidate = asObject(JSON.parse(candidateValue) as JsonValue);
|
|
487
|
+
if (base && candidate && base.ref === candidate.ref) {
|
|
488
|
+
// Any non-required grant field (as, name, future keys) changing is a
|
|
489
|
+
// review-required expansion; then order by required (default true).
|
|
490
|
+
const { required: _b, ...baseRest } = base;
|
|
491
|
+
const { required: _c, ...candidateRest } = candidate;
|
|
492
|
+
if (vnextCanonicalJson(baseRest as JsonValue) !== vnextCanonicalJson(candidateRest as JsonValue)) return "expansion";
|
|
493
|
+
const baseRequired = (base.required ?? true) === true;
|
|
494
|
+
const candidateRequired = (candidate.required ?? true) === true;
|
|
495
|
+
if (baseRequired && !candidateRequired) return "narrowing";
|
|
496
|
+
if (!baseRequired && candidateRequired) return "expansion";
|
|
497
|
+
return "neutral";
|
|
498
|
+
}
|
|
499
|
+
return "expansion";
|
|
500
|
+
}
|
|
501
|
+
default:
|
|
502
|
+
// tools, executor, model, sync-policy, transition, resource-shape,
|
|
503
|
+
// environment, gate, delivery: no conservative order — any change is a
|
|
504
|
+
// review-required expansion.
|
|
505
|
+
return "expansion";
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function summarize(resource: string, path: string, field: VnextPermissionField, direction: VnextPermissionDirection, baseValue: string | undefined, candidateValue: string | undefined): string {
|
|
510
|
+
const verb = baseValue === undefined ? "added" : candidateValue === undefined ? "removed" : "changed";
|
|
511
|
+
return `${resource} ${path} ${field} ${verb} (${direction})`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Diff two complete bundles into a deterministic, classified permission report. */
|
|
515
|
+
export function computeVnextPermissionDiff(base: VnextProjectBundle, candidate: VnextProjectBundle): VnextPermissionDiff {
|
|
516
|
+
const changes: VnextPermissionChange[] = [];
|
|
517
|
+
const baseByPath = new Map(base.resources.map((resource) => [resource.logicalPath, resource]));
|
|
518
|
+
const candidateByPath = new Map(candidate.resources.map((resource) => [resource.logicalPath, resource]));
|
|
519
|
+
|
|
520
|
+
const allPaths = [...new Set([...baseByPath.keys(), ...candidateByPath.keys()])].sort();
|
|
521
|
+
for (const logicalPath of allPaths) {
|
|
522
|
+
const baseResource = baseByPath.get(logicalPath);
|
|
523
|
+
const candidateResource = candidateByPath.get(logicalPath);
|
|
524
|
+
if (baseResource && candidateResource) {
|
|
525
|
+
diffResource(logicalPath, baseResource, candidateResource, changes);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
// Resource added or removed: shape change. Added resources expand
|
|
529
|
+
// authority; removed resources narrow it — except models: removing one
|
|
530
|
+
// can retarget tag-based consumers onto another profile (an expansion).
|
|
531
|
+
const resource = candidateResource ?? baseResource as VnextResource;
|
|
532
|
+
const direction: VnextPermissionDirection = candidateResource
|
|
533
|
+
? "expansion"
|
|
534
|
+
: ((resource.kind === "model" || resource.kind === "gate-registry") ? "expansion" : "narrowing");
|
|
535
|
+
changes.push({
|
|
536
|
+
resource: logicalPath,
|
|
537
|
+
path: "/",
|
|
538
|
+
field: "resource-shape",
|
|
539
|
+
direction,
|
|
540
|
+
summary: `${logicalPath} ${candidateResource ? "added" : "removed"} (${direction})`,
|
|
541
|
+
...(candidateResource && resource.kind !== "environment" ? { candidateValueSha256: valueHash(canonical(resource.value)) } : {}),
|
|
542
|
+
...(!candidateResource && resource.kind !== "environment" ? { baseValueSha256: valueHash(canonical(resource.value)) } : {}),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const sorted = changes.sort((left, right) => left.resource < right.resource ? -1 : left.resource > right.resource ? 1 : left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
547
|
+
const expansions = sorted.filter((change) => change.direction === "expansion");
|
|
548
|
+
const narrowings = sorted.filter((change) => change.direction === "narrowing");
|
|
549
|
+
const neutralChanges = sorted.filter((change) => change.direction === "neutral");
|
|
550
|
+
return {
|
|
551
|
+
schema: "kxm.permission-diff.v1",
|
|
552
|
+
baseRevision: base.configRevision,
|
|
553
|
+
candidateRevision: candidate.configRevision,
|
|
554
|
+
changes: sorted,
|
|
555
|
+
expansions,
|
|
556
|
+
narrowings,
|
|
557
|
+
neutralChanges,
|
|
558
|
+
requiresReview: expansions.length > 0,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Diff two values of one resource kind (e.g. template baseline vs target). */
|
|
563
|
+
export function computeVnextResourcePermissionDiff(
|
|
564
|
+
kind: VnextResourceKind,
|
|
565
|
+
resourcePath: string,
|
|
566
|
+
baseValue: JsonObject,
|
|
567
|
+
candidateValue: JsonObject,
|
|
568
|
+
): VnextPermissionChange[] {
|
|
569
|
+
const changes: VnextPermissionChange[] = [];
|
|
570
|
+
const base: VnextResource = { kind, file: resourcePath, logicalPath: resourcePath, value: baseValue };
|
|
571
|
+
const candidate: VnextResource = { kind, file: resourcePath, logicalPath: resourcePath, value: candidateValue };
|
|
572
|
+
diffResource(resourcePath, base, candidate, changes);
|
|
573
|
+
return changes.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function diffResource(logicalPath: string, base: VnextResource, candidate: VnextResource, changes: VnextPermissionChange[]): void {
|
|
577
|
+
const baseEntries = new Map(vnextAuthorityEntries(base).map((entry) => [entry.path, entry]));
|
|
578
|
+
const candidateEntries = new Map(vnextAuthorityEntries(candidate).map((entry) => [entry.path, entry]));
|
|
579
|
+
const paths = [...new Set([...baseEntries.keys(), ...candidateEntries.keys()])].sort();
|
|
580
|
+
for (const path of paths) {
|
|
581
|
+
const baseEntry = baseEntries.get(path);
|
|
582
|
+
const candidateEntry = candidateEntries.get(path);
|
|
583
|
+
const field = (candidateEntry ?? baseEntry as VnextAuthorityEntry).field;
|
|
584
|
+
// For per-repository overrides, the effective fallback is the resource-
|
|
585
|
+
// level defaultRepositoryAccess (the loader's ceiling rule). Removal
|
|
586
|
+
// compares against the candidate's default; addition against the base's.
|
|
587
|
+
const context = field === "repository-access" && path.startsWith("/repositories/")
|
|
588
|
+
? {
|
|
589
|
+
fallbackRank: (() => {
|
|
590
|
+
const fallback = stringValue(candidate.value.defaultRepositoryAccess as JsonValue | undefined) ?? "none";
|
|
591
|
+
return fallback in ACCESS_RANK ? ACCESS_RANK[fallback] as number : 0;
|
|
592
|
+
})(),
|
|
593
|
+
baseFallbackRank: (() => {
|
|
594
|
+
const fallback = stringValue(base.value.defaultRepositoryAccess as JsonValue | undefined) ?? "none";
|
|
595
|
+
return fallback in ACCESS_RANK ? ACCESS_RANK[fallback] as number : 0;
|
|
596
|
+
})(),
|
|
597
|
+
}
|
|
598
|
+
: undefined;
|
|
599
|
+
const direction = classifyChange(field, baseEntry?.value, candidateEntry?.value, context);
|
|
600
|
+
if (direction === "neutral") continue;
|
|
601
|
+
changes.push({
|
|
602
|
+
resource: logicalPath,
|
|
603
|
+
path,
|
|
604
|
+
field,
|
|
605
|
+
direction,
|
|
606
|
+
summary: summarize(logicalPath, path, field, direction, baseEntry?.value, candidateEntry?.value),
|
|
607
|
+
...(baseEntry && field !== "environment" ? { baseValueSha256: valueHash(baseEntry.value) } : {}),
|
|
608
|
+
...(candidateEntry && field !== "environment" ? { candidateValueSha256: valueHash(candidateEntry.value) } : {}),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
// Prose changes are surfaced as neutral for reviewer visibility.
|
|
612
|
+
const baseProse = vnextProseEntries(base);
|
|
613
|
+
const candidateProse = vnextProseEntries(candidate);
|
|
614
|
+
for (const path of [...new Set([...baseProse.keys(), ...candidateProse.keys()])].sort()) {
|
|
615
|
+
if (baseProse.get(path) === candidateProse.get(path)) continue;
|
|
616
|
+
changes.push({
|
|
617
|
+
resource: logicalPath,
|
|
618
|
+
path,
|
|
619
|
+
field: "resource-shape",
|
|
620
|
+
direction: "neutral",
|
|
621
|
+
summary: `${logicalPath} ${path} prose ${baseProse.has(path) ? (candidateProse.has(path) ? "changed" : "removed") : "added"} (neutral)`,
|
|
622
|
+
...(baseProse.has(path) ? { baseValueSha256: valueHash(baseProse.get(path) as string) } : {}),
|
|
623
|
+
...(candidateProse.has(path) ? { candidateValueSha256: valueHash(candidateProse.get(path) as string) } : {}),
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/* ------------------------------------------------------------------ *
|
|
629
|
+
* Git base loading
|
|
630
|
+
* ------------------------------------------------------------------ */
|
|
631
|
+
|
|
632
|
+
function gitEnvironment(): NodeJS.ProcessEnv {
|
|
633
|
+
return Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_")));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function git(root: string, args: string[]): string {
|
|
637
|
+
const result = spawnSync("git", ["-C", root, ...args], {
|
|
638
|
+
encoding: "utf8",
|
|
639
|
+
env: gitEnvironment(),
|
|
640
|
+
timeout: 15_000,
|
|
641
|
+
windowsHide: true,
|
|
642
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
643
|
+
});
|
|
644
|
+
if (result.status !== 0 || result.error) {
|
|
645
|
+
throw new VnextConfigError([{
|
|
646
|
+
phase: "discovery",
|
|
647
|
+
code: "git_base_unavailable",
|
|
648
|
+
file: args[0] ?? "<git>",
|
|
649
|
+
message: (result.stderr || result.stdout || String(result.error ?? "")).trim() || "git command failed",
|
|
650
|
+
}]);
|
|
651
|
+
}
|
|
652
|
+
return result.stdout;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function gitBuffer(root: string, args: string[]): Buffer {
|
|
656
|
+
const result = spawnSync("git", ["-C", root, ...args], {
|
|
657
|
+
env: gitEnvironment(),
|
|
658
|
+
timeout: 15_000,
|
|
659
|
+
windowsHide: true,
|
|
660
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
661
|
+
});
|
|
662
|
+
if (result.status !== 0 || result.error) {
|
|
663
|
+
throw new VnextConfigError([{
|
|
664
|
+
phase: "discovery",
|
|
665
|
+
code: "git_base_unavailable",
|
|
666
|
+
file: args[0] ?? "<git>",
|
|
667
|
+
message: String(result.stderr || result.stdout || result.error || "git command failed").trim() || "git command failed",
|
|
668
|
+
}]);
|
|
669
|
+
}
|
|
670
|
+
return result.stdout as Buffer;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Resolve a revision to its tree SHA once so a moving ref cannot mix bases. */
|
|
674
|
+
function resolveTreeRevision(root: string, revision: string): string {
|
|
675
|
+
if (!/^[A-Za-z0-9._/^~-]+$/.test(revision) || revision.includes("..") || revision.startsWith("-")) {
|
|
676
|
+
throw new VnextConfigError([{
|
|
677
|
+
phase: "discovery",
|
|
678
|
+
code: "git_revision_invalid",
|
|
679
|
+
file: revision,
|
|
680
|
+
message: "base revision must be a plain Git revision name",
|
|
681
|
+
}]);
|
|
682
|
+
}
|
|
683
|
+
return git(root, ["rev-parse", "--verify", "--quiet", `${revision}^{tree}`]).trim();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Load the bundle as of a Git revision by materializing tracked files into a temporary shadow root. */
|
|
687
|
+
export function loadVnextProjectAtRevision(root: string, revision: string, options: VnextConfigOptions = {}): VnextProjectBundle {
|
|
688
|
+
const tree = resolveTreeRevision(root, revision);
|
|
689
|
+
const listing = gitBuffer(root, ["ls-tree", "-r", "-z", "--name-only", tree]).toString("utf8");
|
|
690
|
+
const paths = listing
|
|
691
|
+
.split("\u0000")
|
|
692
|
+
.filter((line) => line.length > 0 && (line.startsWith(".kxm/") || line.includes("/.kxm/")));
|
|
693
|
+
const shadow = mkdtempSync(join(tmpdir(), "kxm-permission-base-"));
|
|
694
|
+
const shadowResolved = resolve(shadow);
|
|
695
|
+
const shadowMembersDir = `.kxm-shadow-members-${createHash("sha256").update(shadowResolved, "utf8").digest("hex").slice(0, 8)}`;
|
|
696
|
+
try {
|
|
697
|
+
for (const path of paths) {
|
|
698
|
+
const segments = path.split("/");
|
|
699
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || /[\\:]/.test(segment))) {
|
|
700
|
+
throw new VnextConfigError([{
|
|
701
|
+
phase: "path",
|
|
702
|
+
code: "git_tree_path_invalid",
|
|
703
|
+
file: path,
|
|
704
|
+
message: "Git tree entry contains a traversal or non-portable segment",
|
|
705
|
+
}]);
|
|
706
|
+
}
|
|
707
|
+
const absolute = join(shadowResolved, ...segments);
|
|
708
|
+
const resolved = resolve(absolute);
|
|
709
|
+
if (!resolved.startsWith(`${shadowResolved}${sep}`)) {
|
|
710
|
+
throw new VnextConfigError([{
|
|
711
|
+
phase: "path",
|
|
712
|
+
code: "git_tree_path_invalid",
|
|
713
|
+
file: path,
|
|
714
|
+
message: "Git tree entry escapes the shadow root",
|
|
715
|
+
}]);
|
|
716
|
+
}
|
|
717
|
+
const content = gitBuffer(root, ["cat-file", "blob", `${tree}:${path}`]);
|
|
718
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
719
|
+
writeFileSync(absolute, content);
|
|
720
|
+
}
|
|
721
|
+
// Member repositories bind only at authoritative Git worktree roots.
|
|
722
|
+
// Initialize the shadow control root; member worktrees are gitlinks in
|
|
723
|
+
// the control tree and are materialized separately below.
|
|
724
|
+
initShadowGitRoot(shadowResolved);
|
|
725
|
+
// Nested member worktrees are gitlinks in the control tree: their content
|
|
726
|
+
// lives in each member's own history, and the control tree pins an exact
|
|
727
|
+
// commit for each. Materialize every BASE-declared member at its pinned
|
|
728
|
+
// gitlink commit. Members are enumerated from the base project.yaml so a
|
|
729
|
+
// candidate-added member reports as an expansion (base simply lacks it)
|
|
730
|
+
// and a removed member reports as a narrowing (base still has it).
|
|
731
|
+
const baseProjectFile = join(shadowResolved, ".kxm", "project.yaml");
|
|
732
|
+
let baseProject: BaseProjectMember[];
|
|
733
|
+
try {
|
|
734
|
+
baseProject = loadBaseProjectDeclarations(baseProjectFile);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
if (error instanceof VnextConfigError) {
|
|
737
|
+
throw new VnextConfigError(error.issues.map((entry) => ({ ...entry, message: `base ${revision}: ${entry.message}` })));
|
|
738
|
+
}
|
|
739
|
+
throw error;
|
|
740
|
+
}
|
|
741
|
+
const shadowBindings: Record<string, string> = {};
|
|
742
|
+
for (const member of baseProject) {
|
|
743
|
+
if (member.role !== "member") continue;
|
|
744
|
+
if (!vnextResourceIdentifier(member.repositoryId)) {
|
|
745
|
+
throw new VnextConfigError([{
|
|
746
|
+
phase: "path",
|
|
747
|
+
code: "git_tree_path_invalid",
|
|
748
|
+
file: ".kxm/project.yaml",
|
|
749
|
+
message: `member repository id ${JSON.stringify(member.repositoryId)} at the base revision is not a valid identifier`,
|
|
750
|
+
}]);
|
|
751
|
+
}
|
|
752
|
+
if (member.pathHint !== undefined && !portableMemberPathHint(member.pathHint)) {
|
|
753
|
+
throw new VnextConfigError([{
|
|
754
|
+
phase: "path",
|
|
755
|
+
code: "git_tree_path_invalid",
|
|
756
|
+
file: ".kxm/project.yaml",
|
|
757
|
+
message: `member repository ${member.repositoryId} has a non-portable pathHint at the base revision`,
|
|
758
|
+
}]);
|
|
759
|
+
}
|
|
760
|
+
// The object source for reads: a host-local binding for this exact id,
|
|
761
|
+
// else the live portable worktree at the SAME pathHint.
|
|
762
|
+
const boundPath = options.repositoryBindings?.[member.repositoryId];
|
|
763
|
+
const memberWorktree = boundPath
|
|
764
|
+
? resolve(boundPath)
|
|
765
|
+
: member.pathHint
|
|
766
|
+
? resolve(root, ...member.pathHint.split("/"))
|
|
767
|
+
: undefined;
|
|
768
|
+
if (!memberWorktree || !existsSync(memberWorktree)) {
|
|
769
|
+
if (member.required === false) continue; // optional member absent on this host (loader tolerates it)
|
|
770
|
+
throw new VnextConfigError([{
|
|
771
|
+
phase: "discovery",
|
|
772
|
+
code: "trust_scope_unsupported",
|
|
773
|
+
file: ".kxm/project.yaml",
|
|
774
|
+
message: `member repository ${member.repositoryId} has no resolvable worktree at the base revision; trust diff cannot materialize its pinned content`,
|
|
775
|
+
}]);
|
|
776
|
+
}
|
|
777
|
+
// The control tree's gitlink for this member path pins the reviewed commit.
|
|
778
|
+
let pinnedCommit: string | undefined;
|
|
779
|
+
if (member.pathHint) {
|
|
780
|
+
const gitlink = git(root, ["ls-tree", tree, "--", member.pathHint]).trim();
|
|
781
|
+
if (gitlink.length > 0) {
|
|
782
|
+
const match = /^160000 commit ([0-9a-f]{40,64})\t/.exec(gitlink);
|
|
783
|
+
if (!match) {
|
|
784
|
+
throw new VnextConfigError([{
|
|
785
|
+
phase: "discovery",
|
|
786
|
+
code: "trust_scope_unsupported",
|
|
787
|
+
file: ".kxm/project.yaml",
|
|
788
|
+
message: `member ${member.repositoryId} pathHint ${member.pathHint} is not a pinned gitlink at the base revision`,
|
|
789
|
+
}]);
|
|
790
|
+
}
|
|
791
|
+
pinnedCommit = match[1];
|
|
792
|
+
} else if (boundPath) {
|
|
793
|
+
// Host-locally bound member with no gitlink in the control tree:
|
|
794
|
+
// the only reviewable base is the member's own HEAD commit.
|
|
795
|
+
pinnedCommit = git(memberWorktree, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]).trim();
|
|
796
|
+
} else {
|
|
797
|
+
throw new VnextConfigError([{
|
|
798
|
+
phase: "discovery",
|
|
799
|
+
code: "trust_scope_unsupported",
|
|
800
|
+
file: ".kxm/project.yaml",
|
|
801
|
+
message: `member repository ${member.repositoryId} pathHint ${member.pathHint} is not tracked as a gitlink at the base revision (gitignored nested worktrees cannot be diffed)`,
|
|
802
|
+
}]);
|
|
803
|
+
}
|
|
804
|
+
} else if (boundPath) {
|
|
805
|
+
// Host-locally bound member with no pathHint: the only reviewable
|
|
806
|
+
// base is the member's own HEAD commit.
|
|
807
|
+
pinnedCommit = git(memberWorktree, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]).trim();
|
|
808
|
+
}
|
|
809
|
+
if (!pinnedCommit) {
|
|
810
|
+
throw new VnextConfigError([{
|
|
811
|
+
phase: "discovery",
|
|
812
|
+
code: "trust_scope_unsupported",
|
|
813
|
+
file: ".kxm/project.yaml",
|
|
814
|
+
message: `member repository ${member.repositoryId} declares no pathHint and has no host-local binding at the base revision`,
|
|
815
|
+
}]);
|
|
816
|
+
}
|
|
817
|
+
const memberTree = git(memberWorktree, ["rev-parse", "--verify", "--quiet", `${pinnedCommit}^{tree}`]).trim();
|
|
818
|
+
const memberListing = gitBuffer(memberWorktree, ["ls-tree", "-r", "-z", "--name-only", memberTree]).toString("utf8");
|
|
819
|
+
const memberPaths = memberListing.split("\u0000").filter((line) => line.length > 0 && (line.startsWith(".kxm/") || line.includes("/.kxm/")));
|
|
820
|
+
const memberShadowRoot = join(shadowResolved, shadowMembersDir, member.repositoryId);
|
|
821
|
+
if (!resolve(memberShadowRoot).startsWith(`${resolve(shadowResolved, shadowMembersDir)}${sep}`)) {
|
|
822
|
+
throw new VnextConfigError([{
|
|
823
|
+
phase: "path",
|
|
824
|
+
code: "git_tree_path_invalid",
|
|
825
|
+
file: ".kxm/project.yaml",
|
|
826
|
+
message: `member repository ${member.repositoryId} shadow path escapes the member shadow root`,
|
|
827
|
+
}]);
|
|
828
|
+
}
|
|
829
|
+
for (const path of memberPaths) {
|
|
830
|
+
const segments = path.split("/");
|
|
831
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || /[\\:]/.test(segment))) {
|
|
832
|
+
throw new VnextConfigError([{ phase: "path", code: "git_tree_path_invalid", file: path, message: "member Git tree entry contains a traversal or non-portable segment" }]);
|
|
833
|
+
}
|
|
834
|
+
const absolute = join(memberShadowRoot, ...segments);
|
|
835
|
+
const resolved = resolve(absolute);
|
|
836
|
+
if (!resolved.startsWith(`${resolve(memberShadowRoot)}${sep}`)) {
|
|
837
|
+
throw new VnextConfigError([{ phase: "path", code: "git_tree_path_invalid", file: path, message: "member Git tree entry escapes the shadow root" }]);
|
|
838
|
+
}
|
|
839
|
+
const content = gitBuffer(memberWorktree, ["cat-file", "blob", `${memberTree}:${path}`]);
|
|
840
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
841
|
+
writeFileSync(absolute, content);
|
|
842
|
+
}
|
|
843
|
+
initShadowGitRoot(memberShadowRoot);
|
|
844
|
+
shadowBindings[member.repositoryId] = memberShadowRoot;
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
return loadVnextProject(shadowResolved, { ...options, repositoryBindings: shadowBindings });
|
|
848
|
+
} catch (error) {
|
|
849
|
+
if (error instanceof VnextConfigError) {
|
|
850
|
+
throw new VnextConfigError(error.issues.map((entry) => ({ ...entry, message: `base ${revision}: ${entry.message}` })));
|
|
851
|
+
}
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
} finally {
|
|
855
|
+
rmSync(shadow, { recursive: true, force: true });
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
interface BaseProjectMember {
|
|
860
|
+
repositoryId: string;
|
|
861
|
+
role?: string;
|
|
862
|
+
pathHint?: string;
|
|
863
|
+
required?: boolean;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Portable member pathHint predicate (mirrors the loader's portable path rules; "." is never a member). */
|
|
867
|
+
function portableMemberPathHint(pathHint: string): boolean {
|
|
868
|
+
return pathHint !== "." && vnextPortablePath(pathHint);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
/** Read only the repository declarations from a materialized base project.yaml. */
|
|
874
|
+
function loadBaseProjectDeclarations(projectFile: string): BaseProjectMember[] {
|
|
875
|
+
if (!existsSync(projectFile)) return [];
|
|
876
|
+
const value = parseRestrictedYaml(readFileSync(projectFile, "utf8"), ".kxm/project.yaml");
|
|
877
|
+
const repositories = Array.isArray(value?.repositories) ? value.repositories : [];
|
|
878
|
+
const members: BaseProjectMember[] = [];
|
|
879
|
+
for (const entry of repositories) {
|
|
880
|
+
const record = entry && typeof entry === "object" && !Array.isArray(entry) ? entry as JsonObject : undefined;
|
|
881
|
+
const repositoryId = record && typeof record.id === "string" ? record.id : undefined;
|
|
882
|
+
if (!repositoryId) continue;
|
|
883
|
+
members.push({
|
|
884
|
+
repositoryId,
|
|
885
|
+
...(record && typeof record.role === "string" ? { role: record.role } : {}),
|
|
886
|
+
...(record && typeof record.pathHint === "string" ? { pathHint: record.pathHint } : {}),
|
|
887
|
+
...(record && typeof record.required === "boolean" ? { required: record.required } : {}),
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
return members;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function initShadowGitRoot(directory: string): void {
|
|
894
|
+
const result = spawnSync("git", ["-c", "init.defaultBranch=main", "init", "--quiet", directory], {
|
|
895
|
+
encoding: "utf8",
|
|
896
|
+
env: gitEnvironment(),
|
|
897
|
+
timeout: 10_000,
|
|
898
|
+
windowsHide: true,
|
|
899
|
+
});
|
|
900
|
+
if (result.status !== 0 || result.error) {
|
|
901
|
+
throw new VnextConfigError([{
|
|
902
|
+
phase: "discovery",
|
|
903
|
+
code: "git_base_unavailable",
|
|
904
|
+
file: directory,
|
|
905
|
+
message: (result.stderr || String(result.error ?? "")).trim() || "could not initialize the shadow Git root",
|
|
906
|
+
}]);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Diff the working tree against a Git revision. */
|
|
911
|
+
export function diffVnextProjectAgainstRevision(
|
|
912
|
+
root: string,
|
|
913
|
+
revision: string,
|
|
914
|
+
options: VnextConfigOptions = {},
|
|
915
|
+
): VnextPermissionDiff {
|
|
916
|
+
const base = loadVnextProjectAtRevision(root, revision, options);
|
|
917
|
+
const candidate = loadVnextProject(root, options);
|
|
918
|
+
return computeVnextPermissionDiff(base, candidate);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Human-readable report lines for the CLI. */
|
|
922
|
+
export function formatVnextPermissionDiff(diff: VnextPermissionDiff): string {
|
|
923
|
+
const lines: string[] = [];
|
|
924
|
+
lines.push(`permission diff: ${diff.baseRevision.slice(0, 19)}… -> ${diff.candidateRevision.slice(0, 19)}…`);
|
|
925
|
+
if (diff.changes.length === 0) {
|
|
926
|
+
lines.push("no authority-bearing or prose changes");
|
|
927
|
+
return lines.join("\n");
|
|
928
|
+
}
|
|
929
|
+
for (const change of diff.expansions) lines.push(`EXPANSION ${change.summary}`);
|
|
930
|
+
for (const change of diff.narrowings) lines.push(`narrowing ${change.summary}`);
|
|
931
|
+
for (const change of diff.neutralChanges) lines.push(`neutral ${change.summary}`);
|
|
932
|
+
lines.push(diff.requiresReview
|
|
933
|
+
? `${diff.expansions.length} expansion(s) require explicit reviewed trust action`
|
|
934
|
+
: "no expansions; no trust review required");
|
|
935
|
+
return lines.join("\n");
|
|
936
|
+
}
|