@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.
Files changed (199) hide show
  1. package/CHANGELOG.md +410 -0
  2. package/LICENSE +21 -0
  3. package/README.md +168 -0
  4. package/bridge/mcp-server.cjs +14171 -0
  5. package/package.json +98 -0
  6. package/src/adapters/capabilities.ts +178 -0
  7. package/src/adapters/claude-code.ts +110 -0
  8. package/src/adapters/cline.ts +98 -0
  9. package/src/adapters/codex.ts +173 -0
  10. package/src/adapters/copilot.ts +106 -0
  11. package/src/adapters/cursor.ts +97 -0
  12. package/src/adapters/degradation.ts +95 -0
  13. package/src/adapters/index.ts +324 -0
  14. package/src/adapters/kiro-frontmatter.ts +139 -0
  15. package/src/adapters/kiro-inclusion.ts +86 -0
  16. package/src/adapters/kiro.ts +412 -0
  17. package/src/adapters/qdeveloper.ts +115 -0
  18. package/src/adapters/types.ts +81 -0
  19. package/src/adapters/windsurf.ts +96 -0
  20. package/src/admin.ts +283 -0
  21. package/src/asset-conventions.ts +118 -0
  22. package/src/attribution-backfill.ts +319 -0
  23. package/src/attribution-report.ts +95 -0
  24. package/src/attribution.ts +239 -0
  25. package/src/backends/github.ts +194 -0
  26. package/src/backends/http.ts +122 -0
  27. package/src/backends/index.ts +39 -0
  28. package/src/backends/local.ts +47 -0
  29. package/src/backends/s3.ts +157 -0
  30. package/src/backends/types.ts +59 -0
  31. package/src/base-cache.ts +270 -0
  32. package/src/browse-ui.ts +3754 -0
  33. package/src/browse.ts +1038 -0
  34. package/src/build.ts +1108 -0
  35. package/src/catalog.ts +204 -0
  36. package/src/cli-deprecated.ts +29 -0
  37. package/src/cli.ts +773 -0
  38. package/src/collection-admin.ts +287 -0
  39. package/src/collection-builder.ts +464 -0
  40. package/src/collections.ts +116 -0
  41. package/src/compatibility.ts +105 -0
  42. package/src/config.ts +743 -0
  43. package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
  44. package/src/eval.ts +1169 -0
  45. package/src/file-writer.ts +61 -0
  46. package/src/format-registry.ts +141 -0
  47. package/src/guild/auto-updater.ts +163 -0
  48. package/src/guild/backend-resolver.ts +49 -0
  49. package/src/guild/cli.ts +592 -0
  50. package/src/guild/collection-expander.ts +47 -0
  51. package/src/guild/global-cache.ts +247 -0
  52. package/src/guild/hook-generator.ts +100 -0
  53. package/src/guild/manifest.ts +154 -0
  54. package/src/guild/path-utils.ts +12 -0
  55. package/src/guild/sync.ts +622 -0
  56. package/src/guild/version-resolver.ts +42 -0
  57. package/src/help/metadata.ts +445 -0
  58. package/src/help/renderer.ts +265 -0
  59. package/src/help/typo-suggester.ts +25 -0
  60. package/src/hooks/expression.ts +493 -0
  61. package/src/hooks/pipeline.ts +141 -0
  62. package/src/import.ts +773 -0
  63. package/src/importers/claude-code.ts +134 -0
  64. package/src/importers/cline.ts +103 -0
  65. package/src/importers/codex.ts +140 -0
  66. package/src/importers/copilot.ts +103 -0
  67. package/src/importers/cursor.ts +105 -0
  68. package/src/importers/index.ts +390 -0
  69. package/src/importers/kiro.ts +110 -0
  70. package/src/importers/qdeveloper.ts +103 -0
  71. package/src/importers/types.ts +54 -0
  72. package/src/importers/windsurf.ts +104 -0
  73. package/src/install.ts +1005 -0
  74. package/src/manifest-admin.ts +306 -0
  75. package/src/mcp-bridge.ts +240 -0
  76. package/src/mutation/delta.ts +50 -0
  77. package/src/mutation/history.ts +66 -0
  78. package/src/mutation/operators.ts +524 -0
  79. package/src/mutation/runner.ts +332 -0
  80. package/src/new.ts +106 -0
  81. package/src/outcomes/collision.ts +127 -0
  82. package/src/outcomes/normalize.ts +208 -0
  83. package/src/outcomes/registry.ts +173 -0
  84. package/src/parser.ts +446 -0
  85. package/src/provenance-backfill-cli.ts +319 -0
  86. package/src/provenance-backfill.ts +520 -0
  87. package/src/publish.ts +354 -0
  88. package/src/reconcile-orchestrator.ts +502 -0
  89. package/src/reconcile-report-renderer.ts +176 -0
  90. package/src/resolve-body.ts +15 -0
  91. package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
  92. package/src/rosetta/builtins/contracts.ts +1033 -0
  93. package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
  94. package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
  95. package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
  96. package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
  97. package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
  98. package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
  99. package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
  100. package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
  101. package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
  102. package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
  103. package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
  104. package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
  105. package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
  106. package/src/rosetta/builtins/sources/cline-native.ts +176 -0
  107. package/src/rosetta/builtins/sources/codex-native.ts +343 -0
  108. package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
  109. package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
  110. package/src/rosetta/builtins/sources/index.ts +95 -0
  111. package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
  112. package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
  113. package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
  114. package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
  115. package/src/rosetta/builtins/sources/superpowers.ts +240 -0
  116. package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
  117. package/src/rosetta/builtins/targets/claude-code.ts +181 -0
  118. package/src/rosetta/builtins/targets/cline.ts +87 -0
  119. package/src/rosetta/builtins/targets/codex.ts +226 -0
  120. package/src/rosetta/builtins/targets/copilot.ts +103 -0
  121. package/src/rosetta/builtins/targets/cursor.ts +87 -0
  122. package/src/rosetta/builtins/targets/index.ts +60 -0
  123. package/src/rosetta/builtins/targets/kiro.ts +278 -0
  124. package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
  125. package/src/rosetta/builtins/targets/windsurf.ts +87 -0
  126. package/src/rosetta/canonical.ts +729 -0
  127. package/src/rosetta/compatibility.ts +432 -0
  128. package/src/rosetta/contracts.ts +329 -0
  129. package/src/rosetta/detector.ts +724 -0
  130. package/src/rosetta/diagnostics.ts +630 -0
  131. package/src/rosetta/engine-bootstrap.ts +103 -0
  132. package/src/rosetta/engine.ts +744 -0
  133. package/src/rosetta/index.ts +381 -0
  134. package/src/rosetta/inspection.ts +530 -0
  135. package/src/rosetta/plan.ts +448 -0
  136. package/src/rosetta/provenance-digest.ts +369 -0
  137. package/src/rosetta/reconcile.ts +812 -0
  138. package/src/rosetta/redaction.ts +467 -0
  139. package/src/rosetta/registry.ts +712 -0
  140. package/src/rosetta/renderers.ts +571 -0
  141. package/src/rosetta/request-guard.ts +335 -0
  142. package/src/rosetta/resolution.ts +419 -0
  143. package/src/rosetta/source-accounting.ts +233 -0
  144. package/src/rosetta/templates.ts +129 -0
  145. package/src/rosetta-cli.ts +717 -0
  146. package/src/rosetta-docs-generator.ts +793 -0
  147. package/src/rosetta-profiles-cli.ts +367 -0
  148. package/src/schemas.ts +1712 -0
  149. package/src/spec-coordination.ts +1141 -0
  150. package/src/temper.ts +747 -0
  151. package/src/template-bundle-loader.ts +312 -0
  152. package/src/template-engine.ts +53 -0
  153. package/src/translation-application-policy.ts +496 -0
  154. package/src/translation-orchestrator.ts +1013 -0
  155. package/src/translation-plan-applier.ts +473 -0
  156. package/src/tutorial.ts +305 -0
  157. package/src/validate.ts +1093 -0
  158. package/src/versioning.ts +553 -0
  159. package/src/wizard.ts +660 -0
  160. package/src/workspace.ts +237 -0
  161. package/templates/eval-contexts/claude-code.md.njk +6 -0
  162. package/templates/eval-contexts/cline.md.njk +6 -0
  163. package/templates/eval-contexts/copilot.md.njk +6 -0
  164. package/templates/eval-contexts/cursor.md.njk +6 -0
  165. package/templates/eval-contexts/kiro.md.njk +10 -0
  166. package/templates/eval-contexts/qdeveloper.md.njk +6 -0
  167. package/templates/eval-contexts/windsurf.md.njk +6 -0
  168. package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
  169. package/templates/harness-adapters/_base/base.md.njk +16 -0
  170. package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
  171. package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
  172. package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
  173. package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
  174. package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
  175. package/templates/harness-adapters/cline/hook.sh.njk +4 -0
  176. package/templates/harness-adapters/cline/mcp.json.njk +1 -0
  177. package/templates/harness-adapters/cline/rule.md.njk +1 -0
  178. package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
  179. package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
  180. package/templates/harness-adapters/codex/skill.md.njk +27 -0
  181. package/templates/harness-adapters/copilot/agents.md.njk +1 -0
  182. package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
  183. package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
  184. package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
  185. package/templates/harness-adapters/cursor/rule.md.njk +6 -0
  186. package/templates/harness-adapters/kiro/hook.json.njk +1 -0
  187. package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
  188. package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
  189. package/templates/harness-adapters/kiro/power.md.njk +12 -0
  190. package/templates/harness-adapters/kiro/steering.md.njk +16 -0
  191. package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
  192. package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
  193. package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
  194. package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
  195. package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
  196. package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
  197. package/templates/knowledge/hooks.yaml.njk +4 -0
  198. package/templates/knowledge/knowledge.md.njk +53 -0
  199. 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
+ }