@thinkingsage/kanon 0.8.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/CHANGELOG.md +410 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/bridge/mcp-server.cjs +14171 -0
- package/package.json +98 -0
- package/src/adapters/capabilities.ts +178 -0
- package/src/adapters/claude-code.ts +110 -0
- package/src/adapters/cline.ts +98 -0
- package/src/adapters/codex.ts +173 -0
- package/src/adapters/copilot.ts +106 -0
- package/src/adapters/cursor.ts +97 -0
- package/src/adapters/degradation.ts +95 -0
- package/src/adapters/index.ts +324 -0
- package/src/adapters/kiro-frontmatter.ts +139 -0
- package/src/adapters/kiro-inclusion.ts +86 -0
- package/src/adapters/kiro.ts +412 -0
- package/src/adapters/qdeveloper.ts +115 -0
- package/src/adapters/types.ts +81 -0
- package/src/adapters/windsurf.ts +96 -0
- package/src/admin.ts +283 -0
- package/src/asset-conventions.ts +118 -0
- package/src/attribution-backfill.ts +319 -0
- package/src/attribution-report.ts +95 -0
- package/src/attribution.ts +239 -0
- package/src/backends/github.ts +194 -0
- package/src/backends/http.ts +122 -0
- package/src/backends/index.ts +39 -0
- package/src/backends/local.ts +47 -0
- package/src/backends/s3.ts +157 -0
- package/src/backends/types.ts +59 -0
- package/src/base-cache.ts +270 -0
- package/src/browse-ui.ts +3754 -0
- package/src/browse.ts +1038 -0
- package/src/build.ts +1108 -0
- package/src/catalog.ts +204 -0
- package/src/cli-deprecated.ts +29 -0
- package/src/cli.ts +773 -0
- package/src/collection-admin.ts +287 -0
- package/src/collection-builder.ts +464 -0
- package/src/collections.ts +116 -0
- package/src/compatibility.ts +105 -0
- package/src/config.ts +743 -0
- package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
- package/src/eval.ts +1169 -0
- package/src/file-writer.ts +61 -0
- package/src/format-registry.ts +141 -0
- package/src/guild/auto-updater.ts +163 -0
- package/src/guild/backend-resolver.ts +49 -0
- package/src/guild/cli.ts +592 -0
- package/src/guild/collection-expander.ts +47 -0
- package/src/guild/global-cache.ts +247 -0
- package/src/guild/hook-generator.ts +100 -0
- package/src/guild/manifest.ts +154 -0
- package/src/guild/path-utils.ts +12 -0
- package/src/guild/sync.ts +622 -0
- package/src/guild/version-resolver.ts +42 -0
- package/src/help/metadata.ts +445 -0
- package/src/help/renderer.ts +265 -0
- package/src/help/typo-suggester.ts +25 -0
- package/src/hooks/expression.ts +493 -0
- package/src/hooks/pipeline.ts +141 -0
- package/src/import.ts +773 -0
- package/src/importers/claude-code.ts +134 -0
- package/src/importers/cline.ts +103 -0
- package/src/importers/codex.ts +140 -0
- package/src/importers/copilot.ts +103 -0
- package/src/importers/cursor.ts +105 -0
- package/src/importers/index.ts +390 -0
- package/src/importers/kiro.ts +110 -0
- package/src/importers/qdeveloper.ts +103 -0
- package/src/importers/types.ts +54 -0
- package/src/importers/windsurf.ts +104 -0
- package/src/install.ts +1005 -0
- package/src/manifest-admin.ts +306 -0
- package/src/mcp-bridge.ts +240 -0
- package/src/mutation/delta.ts +50 -0
- package/src/mutation/history.ts +66 -0
- package/src/mutation/operators.ts +524 -0
- package/src/mutation/runner.ts +332 -0
- package/src/new.ts +106 -0
- package/src/outcomes/collision.ts +127 -0
- package/src/outcomes/normalize.ts +208 -0
- package/src/outcomes/registry.ts +173 -0
- package/src/parser.ts +446 -0
- package/src/provenance-backfill-cli.ts +319 -0
- package/src/provenance-backfill.ts +520 -0
- package/src/publish.ts +354 -0
- package/src/reconcile-orchestrator.ts +502 -0
- package/src/reconcile-report-renderer.ts +176 -0
- package/src/resolve-body.ts +15 -0
- package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
- package/src/rosetta/builtins/contracts.ts +1033 -0
- package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
- package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
- package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
- package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
- package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
- package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
- package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
- package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
- package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
- package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
- package/src/rosetta/builtins/sources/cline-native.ts +176 -0
- package/src/rosetta/builtins/sources/codex-native.ts +343 -0
- package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
- package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
- package/src/rosetta/builtins/sources/index.ts +95 -0
- package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
- package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
- package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
- package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
- package/src/rosetta/builtins/sources/superpowers.ts +240 -0
- package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
- package/src/rosetta/builtins/targets/claude-code.ts +181 -0
- package/src/rosetta/builtins/targets/cline.ts +87 -0
- package/src/rosetta/builtins/targets/codex.ts +226 -0
- package/src/rosetta/builtins/targets/copilot.ts +103 -0
- package/src/rosetta/builtins/targets/cursor.ts +87 -0
- package/src/rosetta/builtins/targets/index.ts +60 -0
- package/src/rosetta/builtins/targets/kiro.ts +278 -0
- package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
- package/src/rosetta/builtins/targets/windsurf.ts +87 -0
- package/src/rosetta/canonical.ts +729 -0
- package/src/rosetta/compatibility.ts +432 -0
- package/src/rosetta/contracts.ts +329 -0
- package/src/rosetta/detector.ts +724 -0
- package/src/rosetta/diagnostics.ts +630 -0
- package/src/rosetta/engine-bootstrap.ts +103 -0
- package/src/rosetta/engine.ts +744 -0
- package/src/rosetta/index.ts +381 -0
- package/src/rosetta/inspection.ts +530 -0
- package/src/rosetta/plan.ts +448 -0
- package/src/rosetta/provenance-digest.ts +369 -0
- package/src/rosetta/reconcile.ts +812 -0
- package/src/rosetta/redaction.ts +467 -0
- package/src/rosetta/registry.ts +712 -0
- package/src/rosetta/renderers.ts +571 -0
- package/src/rosetta/request-guard.ts +335 -0
- package/src/rosetta/resolution.ts +419 -0
- package/src/rosetta/source-accounting.ts +233 -0
- package/src/rosetta/templates.ts +129 -0
- package/src/rosetta-cli.ts +717 -0
- package/src/rosetta-docs-generator.ts +793 -0
- package/src/rosetta-profiles-cli.ts +367 -0
- package/src/schemas.ts +1712 -0
- package/src/spec-coordination.ts +1141 -0
- package/src/temper.ts +747 -0
- package/src/template-bundle-loader.ts +312 -0
- package/src/template-engine.ts +53 -0
- package/src/translation-application-policy.ts +496 -0
- package/src/translation-orchestrator.ts +1013 -0
- package/src/translation-plan-applier.ts +473 -0
- package/src/tutorial.ts +305 -0
- package/src/validate.ts +1093 -0
- package/src/versioning.ts +553 -0
- package/src/wizard.ts +660 -0
- package/src/workspace.ts +237 -0
- package/templates/eval-contexts/claude-code.md.njk +6 -0
- package/templates/eval-contexts/cline.md.njk +6 -0
- package/templates/eval-contexts/copilot.md.njk +6 -0
- package/templates/eval-contexts/cursor.md.njk +6 -0
- package/templates/eval-contexts/kiro.md.njk +10 -0
- package/templates/eval-contexts/qdeveloper.md.njk +6 -0
- package/templates/eval-contexts/windsurf.md.njk +6 -0
- package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
- package/templates/harness-adapters/_base/base.md.njk +16 -0
- package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
- package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
- package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
- package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
- package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
- package/templates/harness-adapters/cline/hook.sh.njk +4 -0
- package/templates/harness-adapters/cline/mcp.json.njk +1 -0
- package/templates/harness-adapters/cline/rule.md.njk +1 -0
- package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
- package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
- package/templates/harness-adapters/codex/skill.md.njk +27 -0
- package/templates/harness-adapters/copilot/agents.md.njk +1 -0
- package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
- package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
- package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
- package/templates/harness-adapters/cursor/rule.md.njk +6 -0
- package/templates/harness-adapters/kiro/hook.json.njk +1 -0
- package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
- package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
- package/templates/harness-adapters/kiro/power.md.njk +12 -0
- package/templates/harness-adapters/kiro/steering.md.njk +16 -0
- package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
- package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
- package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
- package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
- package/templates/knowledge/hooks.yaml.njk +4 -0
- package/templates/knowledge/knowledge.md.njk +53 -0
- package/templates/knowledge/mcp-servers.yaml.njk +2 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { exists, readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import * as yaml from "js-yaml";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { looksLikeSecret } from "./rosetta/redaction";
|
|
7
|
+
import {
|
|
8
|
+
type AcquisitionProfile,
|
|
9
|
+
AcquisitionProfileSchema,
|
|
10
|
+
type FieldOwnershipClass,
|
|
11
|
+
FieldOwnershipClassSchema,
|
|
12
|
+
type FieldOwnershipPolicy,
|
|
13
|
+
type ReconcilableField,
|
|
14
|
+
ReconcilableFieldSchema,
|
|
15
|
+
type TranslationProfile,
|
|
16
|
+
TranslationProfileSchema,
|
|
17
|
+
} from "./schemas";
|
|
18
|
+
|
|
19
|
+
// --- Legacy Upstream Schema (Req 10.6, 10.7, 14.8) ---
|
|
20
|
+
|
|
21
|
+
export const UpstreamConfigSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
repo: z.string().min(1),
|
|
24
|
+
branch: z.string().min(1).default("main"),
|
|
25
|
+
remote: z.string().optional(),
|
|
26
|
+
prefix: z.string().optional(),
|
|
27
|
+
format: z.string().optional(),
|
|
28
|
+
collection: z.string().optional(),
|
|
29
|
+
knowledgeDir: z.string().optional(),
|
|
30
|
+
skillsPath: z.string().optional(),
|
|
31
|
+
})
|
|
32
|
+
.passthrough();
|
|
33
|
+
export type UpstreamConfig = z.infer<typeof UpstreamConfigSchema>;
|
|
34
|
+
|
|
35
|
+
// --- Config Schemas ---
|
|
36
|
+
|
|
37
|
+
const GitHubBackendConfigSchema = z.object({
|
|
38
|
+
type: z.literal("github"),
|
|
39
|
+
repo: z.string().min(1),
|
|
40
|
+
releasePrefix: z.string().default("v"),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const S3BackendConfigSchema = z.object({
|
|
44
|
+
type: z.literal("s3"),
|
|
45
|
+
bucket: z.string().min(1),
|
|
46
|
+
prefix: z.string().optional(),
|
|
47
|
+
region: z.string().optional(),
|
|
48
|
+
endpoint: z.string().optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const HttpBackendConfigSchema = z.object({
|
|
52
|
+
type: z.literal("http"),
|
|
53
|
+
baseUrl: z.string().url(),
|
|
54
|
+
token: z.string().optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const LocalBackendConfigSchema = z.object({
|
|
58
|
+
type: z.literal("local"),
|
|
59
|
+
path: z.string().min(1),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const BackendConfigSchema = z.discriminatedUnion("type", [
|
|
63
|
+
GitHubBackendConfigSchema,
|
|
64
|
+
S3BackendConfigSchema,
|
|
65
|
+
HttpBackendConfigSchema,
|
|
66
|
+
LocalBackendConfigSchema,
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// --- Mutation Testing (Req 5.3) ---
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The mutation operators applied to adapter source files during
|
|
73
|
+
* `kanon eval --mutation` (Req 5.3). Each operator introduces a single,
|
|
74
|
+
* targeted change so the test suite can be checked for its ability to
|
|
75
|
+
* detect (kill) the mutant.
|
|
76
|
+
*/
|
|
77
|
+
export const MutationOperatorSchema = z.enum([
|
|
78
|
+
"statement-deletion",
|
|
79
|
+
"conditional-boundary",
|
|
80
|
+
"arithmetic-replacement",
|
|
81
|
+
"string-literal",
|
|
82
|
+
"return-value",
|
|
83
|
+
]);
|
|
84
|
+
export type MutationOperator = z.infer<typeof MutationOperatorSchema>;
|
|
85
|
+
|
|
86
|
+
/** Default operator set — all five operators run when none are configured. */
|
|
87
|
+
export const ALL_MUTATION_OPERATORS: MutationOperator[] = [
|
|
88
|
+
...MutationOperatorSchema.options,
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
export const ForgeConfigSchema = z.object({
|
|
92
|
+
publish: z
|
|
93
|
+
.object({
|
|
94
|
+
backend: z.string().default("github"),
|
|
95
|
+
github: z
|
|
96
|
+
.object({
|
|
97
|
+
repo: z.string().optional(),
|
|
98
|
+
releasePrefix: z.string().default("v"),
|
|
99
|
+
})
|
|
100
|
+
.optional(),
|
|
101
|
+
})
|
|
102
|
+
.optional(),
|
|
103
|
+
|
|
104
|
+
install: z
|
|
105
|
+
.object({
|
|
106
|
+
backends: z.record(z.string(), BackendConfigSchema).default({}),
|
|
107
|
+
cacheDir: z.string().optional(),
|
|
108
|
+
})
|
|
109
|
+
.optional(),
|
|
110
|
+
|
|
111
|
+
governance: z
|
|
112
|
+
.object({
|
|
113
|
+
official: z
|
|
114
|
+
.object({
|
|
115
|
+
allowedAuthors: z.array(z.string()).default([]),
|
|
116
|
+
})
|
|
117
|
+
.optional(),
|
|
118
|
+
})
|
|
119
|
+
.optional(),
|
|
120
|
+
|
|
121
|
+
kiro: z
|
|
122
|
+
.object({
|
|
123
|
+
progressiveSteering: z
|
|
124
|
+
.object({
|
|
125
|
+
alwaysWarnThreshold: z.number().min(0).max(1).default(0.5),
|
|
126
|
+
})
|
|
127
|
+
.default({ alwaysWarnThreshold: 0.5 }),
|
|
128
|
+
})
|
|
129
|
+
.optional(),
|
|
130
|
+
|
|
131
|
+
eval: z
|
|
132
|
+
.object({
|
|
133
|
+
// Mutation operators to apply; defaults to all five (Req 5.3).
|
|
134
|
+
mutationOperators: z
|
|
135
|
+
.array(MutationOperatorSchema)
|
|
136
|
+
.default([...ALL_MUTATION_OPERATORS]),
|
|
137
|
+
})
|
|
138
|
+
.optional(),
|
|
139
|
+
|
|
140
|
+
// --- Rosetta Stone Profiles (Req 10.3–10.8, 13.12) ---
|
|
141
|
+
|
|
142
|
+
acquisitions: z.record(z.string(), AcquisitionProfileSchema).optional(),
|
|
143
|
+
|
|
144
|
+
translations: z.record(z.string(), TranslationProfileSchema).optional(),
|
|
145
|
+
|
|
146
|
+
// --- Legacy Upstream Support (Req 10.6, 10.7, 14.8) ---
|
|
147
|
+
// Accepted during migration; normalized into acquisition + translation profiles.
|
|
148
|
+
|
|
149
|
+
upstreams: z.record(z.string(), UpstreamConfigSchema).optional(),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export type ForgeConfig = z.infer<typeof ForgeConfigSchema>;
|
|
153
|
+
export type BackendConfig = z.infer<typeof BackendConfigSchema>;
|
|
154
|
+
|
|
155
|
+
const EMPTY_CONFIG: ForgeConfig = {};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Load and merge kanon configuration from:
|
|
159
|
+
* 1. Per-repo `kanon.config.yaml` in current working directory (committed),
|
|
160
|
+
* falling back to the deprecated `forge.config.yaml` if present (Req FR-6)
|
|
161
|
+
* 2. User-global `~/.forge/config.yaml` (never committed, higher credential precedence)
|
|
162
|
+
*
|
|
163
|
+
* Per-repo config takes precedence for project-level settings;
|
|
164
|
+
* user-global config takes precedence for credentials and personal overrides.
|
|
165
|
+
*
|
|
166
|
+
* Legacy `upstreams` entries are normalized into typed acquisition/translation
|
|
167
|
+
* profiles so downstream code always sees the new format (Req 10.6, 10.7, 14.8).
|
|
168
|
+
*/
|
|
169
|
+
export async function loadForgeConfig(): Promise<ForgeConfig> {
|
|
170
|
+
const repoConfig = await loadRepoConfigFile();
|
|
171
|
+
const userConfig = await loadConfigFile(
|
|
172
|
+
join(homedir(), ".forge", "config.yaml"),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Deep merge: user config overrides repo config for top-level keys
|
|
176
|
+
const merged = deepMerge(repoConfig, userConfig);
|
|
177
|
+
|
|
178
|
+
// Normalize legacy upstreams into typed profiles
|
|
179
|
+
return normalizeUpstreams(merged);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Load the repo-level config, preferring `kanon.config.yaml` and falling
|
|
184
|
+
* back to the deprecated `forge.config.yaml` (Req FR-6). A deprecation
|
|
185
|
+
* warning is printed to stderr only when the legacy file is used.
|
|
186
|
+
*/
|
|
187
|
+
async function loadRepoConfigFile(): Promise<ForgeConfig> {
|
|
188
|
+
const kanonPath = join(process.cwd(), "kanon.config.yaml");
|
|
189
|
+
if (await exists(kanonPath)) {
|
|
190
|
+
return loadConfigFile(kanonPath);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const legacyPath = join(process.cwd(), "forge.config.yaml");
|
|
194
|
+
if (await exists(legacyPath)) {
|
|
195
|
+
console.error(
|
|
196
|
+
"Warning: `forge.config.yaml` is deprecated, rename it to `kanon.config.yaml`.",
|
|
197
|
+
);
|
|
198
|
+
return loadConfigFile(legacyPath);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return EMPTY_CONFIG;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function loadConfigFile(filePath: string): Promise<ForgeConfig> {
|
|
205
|
+
if (!(await exists(filePath))) return EMPTY_CONFIG;
|
|
206
|
+
|
|
207
|
+
let raw: string;
|
|
208
|
+
try {
|
|
209
|
+
raw = await readFile(filePath, "utf-8");
|
|
210
|
+
} catch {
|
|
211
|
+
return EMPTY_CONFIG;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let parsed: unknown;
|
|
215
|
+
try {
|
|
216
|
+
parsed = yaml.load(raw);
|
|
217
|
+
} catch {
|
|
218
|
+
console.error(
|
|
219
|
+
`Warning: Could not parse config file ${filePath} — skipping`,
|
|
220
|
+
);
|
|
221
|
+
return EMPTY_CONFIG;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const result = ForgeConfigSchema.safeParse(parsed);
|
|
225
|
+
if (!result.success) {
|
|
226
|
+
console.error(`Warning: Invalid config at ${filePath} — using defaults`);
|
|
227
|
+
return EMPTY_CONFIG;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return result.data;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function deepMerge<T extends Record<string, unknown>>(base: T, override: T): T {
|
|
234
|
+
const result = { ...base } as Record<string, unknown>;
|
|
235
|
+
for (const [key, value] of Object.entries(override)) {
|
|
236
|
+
if (
|
|
237
|
+
value !== undefined &&
|
|
238
|
+
value !== null &&
|
|
239
|
+
typeof value === "object" &&
|
|
240
|
+
!Array.isArray(value) &&
|
|
241
|
+
typeof result[key] === "object" &&
|
|
242
|
+
result[key] !== null
|
|
243
|
+
) {
|
|
244
|
+
result[key] = deepMerge(
|
|
245
|
+
result[key] as Record<string, unknown>,
|
|
246
|
+
value as Record<string, unknown>,
|
|
247
|
+
);
|
|
248
|
+
} else if (value !== undefined) {
|
|
249
|
+
result[key] = value;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return result as T;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
256
|
+
// Legacy Upstream Normalization (Req 10.6, 10.7, 13.12, 14.8)
|
|
257
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
258
|
+
|
|
259
|
+
/** Diagnostics produced during upstream normalization. */
|
|
260
|
+
export interface UpstreamNormalizationDiagnostic {
|
|
261
|
+
readonly path: string;
|
|
262
|
+
readonly message: string;
|
|
263
|
+
readonly severity: "error" | "warning";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Result from normalizeUpstreams including the merged config and any diagnostics. */
|
|
267
|
+
export interface UpstreamNormalizationResult {
|
|
268
|
+
readonly config: ForgeConfig;
|
|
269
|
+
readonly diagnostics: readonly UpstreamNormalizationDiagnostic[];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Normalize legacy `upstreams` entries into typed acquisition and translation
|
|
274
|
+
* profiles. Existing profiles with the same key are NOT overwritten.
|
|
275
|
+
*
|
|
276
|
+
* Emits deprecation diagnostics when legacy upstreams are present, and
|
|
277
|
+
* blocking diagnostics when literal credentials are detected in upstream values.
|
|
278
|
+
*
|
|
279
|
+
* Requirements: 10.6, 10.7, 13.12, 14.8
|
|
280
|
+
*/
|
|
281
|
+
export function normalizeUpstreams(config: ForgeConfig): ForgeConfig {
|
|
282
|
+
const result = normalizeUpstreamsWithDiagnostics(config);
|
|
283
|
+
|
|
284
|
+
// Emit deprecation and credential warnings to stderr
|
|
285
|
+
for (const diag of result.diagnostics) {
|
|
286
|
+
const prefix = diag.severity === "error" ? "Error" : "Warning";
|
|
287
|
+
console.error(`${prefix}: [${diag.path}] ${diag.message}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return result.config;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Normalize legacy `upstreams` into typed profiles and return both the merged
|
|
295
|
+
* config and structured diagnostics (useful for programmatic consumers and tests).
|
|
296
|
+
*/
|
|
297
|
+
export function normalizeUpstreamsWithDiagnostics(
|
|
298
|
+
config: ForgeConfig,
|
|
299
|
+
): UpstreamNormalizationResult {
|
|
300
|
+
if (!config.upstreams || Object.keys(config.upstreams).length === 0) {
|
|
301
|
+
return { config, diagnostics: [] };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const diagnostics: UpstreamNormalizationDiagnostic[] = [];
|
|
305
|
+
const acquisitions: Record<string, AcquisitionProfile> = {
|
|
306
|
+
...(config.acquisitions ?? {}),
|
|
307
|
+
};
|
|
308
|
+
const translations: Record<string, TranslationProfile> = {
|
|
309
|
+
...(config.translations ?? {}),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Emit a single deprecation notice
|
|
313
|
+
diagnostics.push({
|
|
314
|
+
path: "upstreams",
|
|
315
|
+
message:
|
|
316
|
+
"the `upstreams` key is deprecated; migrate to explicit `acquisitions` and `translations` profiles",
|
|
317
|
+
severity: "warning",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
for (const [key, upstream] of Object.entries(config.upstreams)) {
|
|
321
|
+
// Scan for literal credentials in upstream values
|
|
322
|
+
const credentialDiags = scanUpstreamForCredentials(key, upstream);
|
|
323
|
+
diagnostics.push(...credentialDiags);
|
|
324
|
+
|
|
325
|
+
// If credential errors were found, skip normalization for this entry
|
|
326
|
+
if (credentialDiags.some((d) => d.severity === "error")) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Create acquisition profile (do NOT overwrite existing)
|
|
331
|
+
if (!(key in acquisitions)) {
|
|
332
|
+
acquisitions[key] = {
|
|
333
|
+
repo: upstream.repo,
|
|
334
|
+
branch: upstream.branch ?? "main",
|
|
335
|
+
remote: upstream.remote ?? key,
|
|
336
|
+
...(upstream.prefix ? { checkoutPrefix: upstream.prefix } : {}),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Create translation profile (do NOT overwrite existing)
|
|
341
|
+
if (!(key in translations)) {
|
|
342
|
+
translations[key] = {
|
|
343
|
+
...(upstream.format ? { sourceFormat: upstream.format } : {}),
|
|
344
|
+
...(upstream.knowledgeDir
|
|
345
|
+
? { canonicalDestination: upstream.knowledgeDir }
|
|
346
|
+
: {}),
|
|
347
|
+
collections: upstream.collection ? [upstream.collection] : [],
|
|
348
|
+
...(upstream.skillsPath ? { sourceSubpath: upstream.skillsPath } : {}),
|
|
349
|
+
strict: false,
|
|
350
|
+
options: {},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const normalized: ForgeConfig = {
|
|
356
|
+
...config,
|
|
357
|
+
acquisitions,
|
|
358
|
+
translations,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
return { config: normalized, diagnostics };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Scan upstream entry values for literal secrets.
|
|
366
|
+
* URLs (https://) and approved references (${ENV_VAR}) are exempted.
|
|
367
|
+
*/
|
|
368
|
+
function scanUpstreamForCredentials(
|
|
369
|
+
key: string,
|
|
370
|
+
upstream: Record<string, unknown>,
|
|
371
|
+
): UpstreamNormalizationDiagnostic[] {
|
|
372
|
+
const diagnostics: UpstreamNormalizationDiagnostic[] = [];
|
|
373
|
+
|
|
374
|
+
for (const [field, value] of Object.entries(upstream)) {
|
|
375
|
+
if (typeof value !== "string") continue;
|
|
376
|
+
|
|
377
|
+
// Exempt URLs
|
|
378
|
+
if (URL_PATTERN.test(value)) continue;
|
|
379
|
+
|
|
380
|
+
// Exempt approved references
|
|
381
|
+
if (APPROVED_REFERENCE_PATTERN.test(value)) continue;
|
|
382
|
+
|
|
383
|
+
// Check for literal secrets
|
|
384
|
+
if (looksLikeSecret(value)) {
|
|
385
|
+
diagnostics.push({
|
|
386
|
+
path: `upstreams.${key}.${field}`,
|
|
387
|
+
message:
|
|
388
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${ENV_VAR} in user message
|
|
389
|
+
"literal credential detected; use an approved reference (${ENV_VAR}) instead",
|
|
390
|
+
severity: "error",
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return diagnostics;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Resolve the install backends declared in config into a name → BackendConfig map.
|
|
400
|
+
* Always includes a "local" backend pointing to the current dist/ directory.
|
|
401
|
+
*/
|
|
402
|
+
export function resolveBackendConfigs(
|
|
403
|
+
config: ForgeConfig,
|
|
404
|
+
): Map<string, BackendConfig> {
|
|
405
|
+
const backends = new Map<string, BackendConfig>();
|
|
406
|
+
|
|
407
|
+
// Built-in default: local dist/
|
|
408
|
+
backends.set("local", { type: "local", path: "." });
|
|
409
|
+
|
|
410
|
+
// Backends declared in config
|
|
411
|
+
for (const [name, backendConfig] of Object.entries(
|
|
412
|
+
config.install?.backends ?? {},
|
|
413
|
+
)) {
|
|
414
|
+
backends.set(name, backendConfig);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return backends;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
421
|
+
// Profile Validation (Req 10.3–10.8, 13.12)
|
|
422
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
423
|
+
|
|
424
|
+
/** A single field-addressed diagnostic produced during profile validation. */
|
|
425
|
+
export interface ProfileDiagnostic {
|
|
426
|
+
readonly path: string;
|
|
427
|
+
readonly message: string;
|
|
428
|
+
readonly severity: "error" | "warning";
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Result of validating acquisition and translation profiles. */
|
|
432
|
+
export interface ProfileValidationResult {
|
|
433
|
+
readonly valid: boolean;
|
|
434
|
+
readonly diagnostics: readonly ProfileDiagnostic[];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Pattern for valid kebab-case profile keys. */
|
|
438
|
+
const KEBAB_CASE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
439
|
+
|
|
440
|
+
/** Pattern for approved credential references: ${ENV_VAR} */
|
|
441
|
+
const APPROVED_REFERENCE_PATTERN = /^\$\{[A-Z_][A-Z0-9_]*\}$/;
|
|
442
|
+
|
|
443
|
+
/** Traversal indicators for path validation. */
|
|
444
|
+
const PATH_TRAVERSAL_PATTERN = /(?:^|\/)\.\./;
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Options for profile validation. When a registry is provided,
|
|
448
|
+
* source format IDs are cross-validated against it.
|
|
449
|
+
*/
|
|
450
|
+
export interface ProfileValidationOptions {
|
|
451
|
+
/** Known format IDs from the translation registry, if available. */
|
|
452
|
+
readonly knownFormatIds?: ReadonlySet<string>;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Returns true when a string value is an approved credential reference
|
|
457
|
+
* (e.g., `${MY_SECRET}`) rather than a literal secret.
|
|
458
|
+
*/
|
|
459
|
+
function isApprovedReference(value: string): boolean {
|
|
460
|
+
return APPROVED_REFERENCE_PATTERN.test(value);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Pattern for values that are clearly URLs, not secrets. */
|
|
464
|
+
const URL_PATTERN = /^https?:\/\//i;
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Validate all acquisition and translation profiles in a ForgeConfig.
|
|
468
|
+
* Returns field-addressed diagnostics for invalid profile keys, unknown
|
|
469
|
+
* format IDs, path traversal, and literal credential values.
|
|
470
|
+
*
|
|
471
|
+
* Requirements: 10.3, 10.4, 10.5, 10.6, 10.7, 10.8, 13.12
|
|
472
|
+
*/
|
|
473
|
+
export function validateProfiles(
|
|
474
|
+
config: ForgeConfig,
|
|
475
|
+
options: ProfileValidationOptions = {},
|
|
476
|
+
): ProfileValidationResult {
|
|
477
|
+
const diagnostics: ProfileDiagnostic[] = [];
|
|
478
|
+
|
|
479
|
+
// Validate acquisition profiles
|
|
480
|
+
if (config.acquisitions) {
|
|
481
|
+
for (const [key, profile] of Object.entries(config.acquisitions)) {
|
|
482
|
+
validateProfileKey("acquisitions", key, diagnostics);
|
|
483
|
+
validateAcquisitionProfile(key, profile, diagnostics);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Validate translation profiles
|
|
488
|
+
if (config.translations) {
|
|
489
|
+
for (const [key, profile] of Object.entries(config.translations)) {
|
|
490
|
+
validateProfileKey("translations", key, diagnostics);
|
|
491
|
+
validateTranslationProfile(key, profile, options, diagnostics);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
valid: diagnostics.every((d) => d.severity !== "error"),
|
|
497
|
+
diagnostics,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Validate that a profile key is kebab-case.
|
|
503
|
+
*/
|
|
504
|
+
function validateProfileKey(
|
|
505
|
+
section: string,
|
|
506
|
+
key: string,
|
|
507
|
+
diagnostics: ProfileDiagnostic[],
|
|
508
|
+
): void {
|
|
509
|
+
if (!KEBAB_CASE_PATTERN.test(key)) {
|
|
510
|
+
diagnostics.push({
|
|
511
|
+
path: `${section}.${key}`,
|
|
512
|
+
message: `profile key "${key}" must be kebab-case`,
|
|
513
|
+
severity: "error",
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Validate an acquisition profile for credential leakage.
|
|
520
|
+
*/
|
|
521
|
+
function validateAcquisitionProfile(
|
|
522
|
+
key: string,
|
|
523
|
+
profile: AcquisitionProfile,
|
|
524
|
+
diagnostics: ProfileDiagnostic[],
|
|
525
|
+
): void {
|
|
526
|
+
scanForCredentials("acquisitions", key, profile, diagnostics);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Validate a translation profile for format IDs, paths, defaults, and credentials.
|
|
531
|
+
*/
|
|
532
|
+
function validateTranslationProfile(
|
|
533
|
+
key: string,
|
|
534
|
+
profile: TranslationProfile,
|
|
535
|
+
options: ProfileValidationOptions,
|
|
536
|
+
diagnostics: ProfileDiagnostic[],
|
|
537
|
+
): void {
|
|
538
|
+
const { knownFormatIds } = options;
|
|
539
|
+
|
|
540
|
+
// Cross-validate source format against the registry
|
|
541
|
+
if (profile.sourceFormat && knownFormatIds) {
|
|
542
|
+
if (!knownFormatIds.has(profile.sourceFormat)) {
|
|
543
|
+
diagnostics.push({
|
|
544
|
+
path: `translations.${key}.sourceFormat`,
|
|
545
|
+
message: `unknown format "${profile.sourceFormat}"`,
|
|
546
|
+
severity: "error",
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Cross-validate target format against the registry
|
|
552
|
+
if (profile.targetFormat && knownFormatIds) {
|
|
553
|
+
if (!knownFormatIds.has(profile.targetFormat)) {
|
|
554
|
+
diagnostics.push({
|
|
555
|
+
path: `translations.${key}.targetFormat`,
|
|
556
|
+
message: `unknown format "${profile.targetFormat}"`,
|
|
557
|
+
severity: "error",
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Validate canonical destination path (no traversal)
|
|
563
|
+
if (profile.canonicalDestination) {
|
|
564
|
+
if (PATH_TRAVERSAL_PATTERN.test(profile.canonicalDestination)) {
|
|
565
|
+
diagnostics.push({
|
|
566
|
+
path: `translations.${key}.canonicalDestination`,
|
|
567
|
+
message: `path "${profile.canonicalDestination}" must not contain traversal (..)`,
|
|
568
|
+
severity: "error",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Validate canonical schema version if provided
|
|
574
|
+
if (profile.canonicalSchemaVersion) {
|
|
575
|
+
// Already validated by Zod schema — presence here means it passed
|
|
576
|
+
// We can add registry-backed version range checks when the registry provides them
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Scan for literal credentials
|
|
580
|
+
scanForCredentials("translations", key, profile, diagnostics);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Recursively scan profile fields for literal credential values.
|
|
585
|
+
* Approved references like ${ENV_VAR} are acceptable.
|
|
586
|
+
* Literal secrets produce blocking diagnostics.
|
|
587
|
+
*/
|
|
588
|
+
function scanForCredentials(
|
|
589
|
+
section: string,
|
|
590
|
+
profileKey: string,
|
|
591
|
+
obj: Record<string, unknown>,
|
|
592
|
+
diagnostics: ProfileDiagnostic[],
|
|
593
|
+
fieldPath: string[] = [],
|
|
594
|
+
): void {
|
|
595
|
+
for (const [field, value] of Object.entries(obj)) {
|
|
596
|
+
const currentPath = [...fieldPath, field];
|
|
597
|
+
if (typeof value === "string") {
|
|
598
|
+
// Skip approved references, URLs, and non-secret strings
|
|
599
|
+
if (
|
|
600
|
+
!isApprovedReference(value) &&
|
|
601
|
+
!URL_PATTERN.test(value) &&
|
|
602
|
+
looksLikeSecret(value)
|
|
603
|
+
) {
|
|
604
|
+
diagnostics.push({
|
|
605
|
+
path: `${section}.${profileKey}.${currentPath.join(".")}`,
|
|
606
|
+
message:
|
|
607
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${ENV_VAR} in user message
|
|
608
|
+
"literal credential detected; use an approved reference (${ENV_VAR}) instead",
|
|
609
|
+
severity: "error",
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
} else if (
|
|
613
|
+
value !== null &&
|
|
614
|
+
value !== undefined &&
|
|
615
|
+
typeof value === "object" &&
|
|
616
|
+
!Array.isArray(value)
|
|
617
|
+
) {
|
|
618
|
+
scanForCredentials(
|
|
619
|
+
section,
|
|
620
|
+
profileKey,
|
|
621
|
+
value as Record<string, unknown>,
|
|
622
|
+
diagnostics,
|
|
623
|
+
currentPath,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
630
|
+
// Field Ownership Policy Validation (Req 18.14)
|
|
631
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* The result of validating a Field_Ownership_Policy for a single upstream.
|
|
635
|
+
*
|
|
636
|
+
* When `valid` is true, `policy` carries the parsed, complete policy ready for
|
|
637
|
+
* the pure reconciliation core; `diagnostics` is empty. When `valid` is false,
|
|
638
|
+
* `policy` is undefined and `diagnostics` describes every problem: fields
|
|
639
|
+
* classified outside {@link ReconcilableFieldSchema} and reconcilable fields
|
|
640
|
+
* left unclassified (Requirement 18.14).
|
|
641
|
+
*/
|
|
642
|
+
export interface FieldOwnershipPolicyValidationResult {
|
|
643
|
+
readonly valid: boolean;
|
|
644
|
+
readonly policy: FieldOwnershipPolicy | undefined;
|
|
645
|
+
readonly diagnostics: readonly ProfileDiagnostic[];
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Validate a Field_Ownership_Policy for reconciliation configuration.
|
|
650
|
+
*
|
|
651
|
+
* The base `FieldOwnershipPolicySchema` (task 19.1) constrains keys to
|
|
652
|
+
* {@link ReconcilableFieldSchema} and values to `FieldOwnershipClassSchema`. The
|
|
653
|
+
* Configuration_Validator, in addition, enforces COMPLETENESS: it rejects a
|
|
654
|
+
* policy that omits a classification for any reconcilable field
|
|
655
|
+
* (Requirement 18.14).
|
|
656
|
+
*
|
|
657
|
+
* This validator produces clean, field-addressed diagnostics for every problem
|
|
658
|
+
* class, independent of the underlying schema's record-key mechanics:
|
|
659
|
+
* 1. Non-object shape — the policy value must be a plain object.
|
|
660
|
+
* 2. Unknown/misclassified fields — a key outside {@link ReconcilableFieldSchema}
|
|
661
|
+
* is a field-addressed error at that key.
|
|
662
|
+
* 3. Invalid ownership class — a value that is not a `FieldOwnershipClass`.
|
|
663
|
+
* 4. Unclassified/omitted fields — every member of
|
|
664
|
+
* {@link ReconcilableFieldSchema} must appear; any omission is a
|
|
665
|
+
* field-addressed error naming the missing field.
|
|
666
|
+
*
|
|
667
|
+
* The documented `DEFAULT_FIELD_OWNERSHIP_POLICY` is complete by construction
|
|
668
|
+
* and therefore always validates.
|
|
669
|
+
*
|
|
670
|
+
* @param policy The policy value from configuration.
|
|
671
|
+
* @param section The config path prefix used in diagnostics (default
|
|
672
|
+
* `"fieldOwnership"`).
|
|
673
|
+
* @returns A validation result with the parsed complete policy or diagnostics.
|
|
674
|
+
*/
|
|
675
|
+
export function validateFieldOwnershipPolicy(
|
|
676
|
+
policy: unknown,
|
|
677
|
+
section = "fieldOwnership",
|
|
678
|
+
): FieldOwnershipPolicyValidationResult {
|
|
679
|
+
const diagnostics: ProfileDiagnostic[] = [];
|
|
680
|
+
|
|
681
|
+
// Step 1: shape. The policy must be a plain object (record of field → class).
|
|
682
|
+
if (policy === null || typeof policy !== "object" || Array.isArray(policy)) {
|
|
683
|
+
diagnostics.push({
|
|
684
|
+
path: section,
|
|
685
|
+
message:
|
|
686
|
+
"Field_Ownership_Policy must be an object mapping reconcilable fields to ownership classes",
|
|
687
|
+
severity: "error",
|
|
688
|
+
});
|
|
689
|
+
return { valid: false, policy: undefined, diagnostics };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const known: ReadonlySet<string> = new Set(ReconcilableFieldSchema.options);
|
|
693
|
+
const entries = Object.entries(policy as Record<string, unknown>);
|
|
694
|
+
const classified: Partial<Record<ReconcilableField, FieldOwnershipClass>> =
|
|
695
|
+
{};
|
|
696
|
+
|
|
697
|
+
// Step 2: per-key validation. Reject keys outside the reconcilable set and
|
|
698
|
+
// values that are not a valid ownership class, each field-addressed.
|
|
699
|
+
for (const [key, value] of entries) {
|
|
700
|
+
if (!known.has(key)) {
|
|
701
|
+
diagnostics.push({
|
|
702
|
+
path: `${section}.${key}`,
|
|
703
|
+
message: `Field_Ownership_Policy references field "${key}" outside ReconcilableFieldSchema`,
|
|
704
|
+
severity: "error",
|
|
705
|
+
});
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const classResult = FieldOwnershipClassSchema.safeParse(value);
|
|
709
|
+
if (!classResult.success) {
|
|
710
|
+
diagnostics.push({
|
|
711
|
+
path: `${section}.${key}`,
|
|
712
|
+
message: `Field_Ownership_Policy classification for "${key}" must be one of ${FieldOwnershipClassSchema.options
|
|
713
|
+
.map((option) => `"${option}"`)
|
|
714
|
+
.join(", ")}`,
|
|
715
|
+
severity: "error",
|
|
716
|
+
});
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
classified[key as ReconcilableField] = classResult.data;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Step 3: completeness. Every reconcilable field must be classified; a
|
|
723
|
+
// missing classification is an error naming the omitted field (Req 18.14).
|
|
724
|
+
for (const field of ReconcilableFieldSchema.options) {
|
|
725
|
+
if (!(field in classified)) {
|
|
726
|
+
diagnostics.push({
|
|
727
|
+
path: `${section}.${field}`,
|
|
728
|
+
message: `Field_Ownership_Policy omits a classification for reconcilable field "${field}"`,
|
|
729
|
+
severity: "error",
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (diagnostics.length > 0) {
|
|
735
|
+
return { valid: false, policy: undefined, diagnostics };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return {
|
|
739
|
+
valid: true,
|
|
740
|
+
policy: classified as FieldOwnershipPolicy,
|
|
741
|
+
diagnostics: [],
|
|
742
|
+
};
|
|
743
|
+
}
|