@massu/core 1.1.0 → 1.2.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/dist/cli.js CHANGED
@@ -22570,6 +22570,48 @@ var init_server = __esm({
22570
22570
  }
22571
22571
  });
22572
22572
 
22573
+ // src/detect/passthrough.ts
22574
+ function copyUnknownKeys(source, target, handledKeys) {
22575
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
22576
+ return;
22577
+ }
22578
+ for (const k3 of Object.keys(source)) {
22579
+ if (UNSAFE_KEYS.has(k3)) continue;
22580
+ if (source[k3] === void 0) continue;
22581
+ if (handledKeys.has(k3)) continue;
22582
+ if (Object.prototype.hasOwnProperty.call(target, k3)) continue;
22583
+ target[k3] = safeClone(source[k3]);
22584
+ }
22585
+ }
22586
+ function preserveNestedSubkeys(sourceBlock, targetBlock) {
22587
+ if (sourceBlock === null || sourceBlock === void 0 || typeof sourceBlock !== "object" || Array.isArray(sourceBlock)) {
22588
+ return;
22589
+ }
22590
+ const src = sourceBlock;
22591
+ for (const k3 of Object.keys(src)) {
22592
+ if (UNSAFE_KEYS.has(k3)) continue;
22593
+ if (src[k3] === void 0) continue;
22594
+ if (Object.prototype.hasOwnProperty.call(targetBlock, k3)) continue;
22595
+ targetBlock[k3] = safeClone(src[k3]);
22596
+ }
22597
+ }
22598
+ function safeClone(v3) {
22599
+ if (typeof structuredClone === "function") {
22600
+ try {
22601
+ return structuredClone(v3);
22602
+ } catch {
22603
+ }
22604
+ }
22605
+ return v3;
22606
+ }
22607
+ var UNSAFE_KEYS;
22608
+ var init_passthrough = __esm({
22609
+ "src/detect/passthrough.ts"() {
22610
+ "use strict";
22611
+ UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
22612
+ }
22613
+ });
22614
+
22573
22615
  // src/commands/config-refresh.ts
22574
22616
  var config_refresh_exports = {};
22575
22617
  __export(config_refresh_exports, {
@@ -22629,6 +22671,67 @@ function mergeRefresh(existing, refreshed) {
22629
22671
  out[field] = existing[field];
22630
22672
  }
22631
22673
  }
22674
+ if (typeof existing.toolPrefix === "string" && existing.toolPrefix !== "") {
22675
+ out.toolPrefix = existing.toolPrefix;
22676
+ }
22677
+ for (const block of ["framework", "paths", "project"]) {
22678
+ const existingBlock = existing[block];
22679
+ const outBlock = out[block];
22680
+ if (existingBlock && typeof existingBlock === "object" && !Array.isArray(existingBlock) && outBlock && typeof outBlock === "object" && !Array.isArray(outBlock)) {
22681
+ preserveNestedSubkeys(existingBlock, outBlock);
22682
+ }
22683
+ }
22684
+ const existingProject = existing.project;
22685
+ const outProject = out.project;
22686
+ if (existingProject && typeof existingProject === "object" && !Array.isArray(existingProject) && outProject && typeof outProject === "object" && !Array.isArray(outProject)) {
22687
+ const userRoot = existingProject.root;
22688
+ if (typeof userRoot === "string" && userRoot !== "") {
22689
+ outProject.root = userRoot;
22690
+ }
22691
+ }
22692
+ const existingPaths = existing.paths;
22693
+ const outPaths = out.paths;
22694
+ if (existingPaths && typeof existingPaths === "object" && !Array.isArray(existingPaths) && outPaths && typeof outPaths === "object" && !Array.isArray(outPaths)) {
22695
+ const existingAliases = existingPaths.aliases;
22696
+ const outAliases = outPaths.aliases;
22697
+ if (existingAliases && typeof existingAliases === "object" && !Array.isArray(existingAliases) && outAliases && typeof outAliases === "object" && !Array.isArray(outAliases)) {
22698
+ outPaths.aliases = {
22699
+ ...outAliases,
22700
+ ...existingAliases
22701
+ };
22702
+ } else if (existingAliases && typeof existingAliases === "object" && !Array.isArray(existingAliases)) {
22703
+ outPaths.aliases = existingAliases;
22704
+ }
22705
+ }
22706
+ const existingVer = existing.verification;
22707
+ const outVer = out.verification;
22708
+ if (existingVer && typeof existingVer === "object" && !Array.isArray(existingVer) && outVer && typeof outVer === "object" && !Array.isArray(outVer)) {
22709
+ const eVer = existingVer;
22710
+ const oVer = outVer;
22711
+ for (const lang of Object.keys(eVer)) {
22712
+ const userLang = eVer[lang];
22713
+ if (userLang === void 0) continue;
22714
+ if (!(lang in oVer)) {
22715
+ oVer[lang] = userLang;
22716
+ } else if (userLang && typeof userLang === "object" && !Array.isArray(userLang) && oVer[lang] && typeof oVer[lang] === "object" && !Array.isArray(oVer[lang])) {
22717
+ oVer[lang] = {
22718
+ ...oVer[lang],
22719
+ ...userLang
22720
+ };
22721
+ }
22722
+ }
22723
+ }
22724
+ const handledTopLevel = /* @__PURE__ */ new Set([
22725
+ "schema_version",
22726
+ "project",
22727
+ "framework",
22728
+ "paths",
22729
+ "toolPrefix",
22730
+ "verification",
22731
+ "detection",
22732
+ ...PRESERVED_FIELDS
22733
+ ]);
22734
+ copyUnknownKeys(existing, out, handledTopLevel);
22632
22735
  return out;
22633
22736
  }
22634
22737
  function renderDiff(diff) {
@@ -22721,6 +22824,7 @@ var init_config_refresh = __esm({
22721
22824
  "use strict";
22722
22825
  init_detect();
22723
22826
  init_drift();
22827
+ init_passthrough();
22724
22828
  init_init();
22725
22829
  PRESERVED_FIELDS = [
22726
22830
  "rules",
@@ -22738,7 +22842,8 @@ var init_config_refresh = __esm({
22738
22842
  "cloud",
22739
22843
  "conventions",
22740
22844
  "autoLearning",
22741
- "python"
22845
+ "python",
22846
+ "toolPrefix"
22742
22847
  ];
22743
22848
  }
22744
22849
  });
@@ -22849,6 +22954,7 @@ function migrateV1ToV2(v1Config, detection) {
22849
22954
  if (Object.keys(languageEntries).length > 0) {
22850
22955
  framework.languages = languageEntries;
22851
22956
  }
22957
+ preserveNestedSubkeys(v1Framework, framework);
22852
22958
  let pathsSource = typeof v1Paths.source === "string" ? v1Paths.source : "src";
22853
22959
  if (pathsSource === "src" && primary) {
22854
22960
  const primaryDirs = detection.sourceDirs[primary]?.source_dirs ?? [];
@@ -22862,13 +22968,16 @@ function migrateV1ToV2(v1Config, detection) {
22862
22968
  for (const k3 of ["routers", "routerRoot", "pages", "middleware", "schema", "components", "hooks"]) {
22863
22969
  if (typeof v1Paths[k3] === "string") paths[k3] = v1Paths[k3];
22864
22970
  }
22971
+ preserveNestedSubkeys(v1Paths, paths);
22865
22972
  const verification = buildVerificationBlock(detection, v1Verification);
22973
+ const project = {
22974
+ name: typeof v1Project.name === "string" ? v1Project.name : "my-project",
22975
+ root: typeof v1Project.root === "string" ? v1Project.root : "auto"
22976
+ };
22977
+ preserveNestedSubkeys(v1Project, project);
22866
22978
  const v22 = {
22867
22979
  schema_version: 2,
22868
- project: {
22869
- name: typeof v1Project.name === "string" ? v1Project.name : "my-project",
22870
- root: typeof v1Project.root === "string" ? v1Project.root : "auto"
22871
- },
22980
+ project,
22872
22981
  framework,
22873
22982
  paths,
22874
22983
  toolPrefix: typeof v1.toolPrefix === "string" ? v1.toolPrefix : "massu"
@@ -22878,6 +22987,17 @@ function migrateV1ToV2(v1Config, detection) {
22878
22987
  v22[field] = v1[field];
22879
22988
  }
22880
22989
  }
22990
+ const handledTopLevel = /* @__PURE__ */ new Set([
22991
+ "schema_version",
22992
+ "project",
22993
+ "framework",
22994
+ "paths",
22995
+ "toolPrefix",
22996
+ "verification",
22997
+ "python",
22998
+ ...PRESERVED_FIELDS2
22999
+ ]);
23000
+ copyUnknownKeys(v1, v22, handledTopLevel);
22881
23001
  if (!Array.isArray(v22.domains)) {
22882
23002
  v22.domains = [];
22883
23003
  }
@@ -22908,6 +23028,7 @@ function migrateV1ToV2(v1Config, detection) {
22908
23028
  } else if (existing.orm !== void 0) {
22909
23029
  pythonBlock.orm = existing.orm;
22910
23030
  }
23031
+ preserveNestedSubkeys(v1.python, pythonBlock);
22911
23032
  v22.python = pythonBlock;
22912
23033
  } else if (v1.python !== void 0) {
22913
23034
  v22.python = v1.python;
@@ -22918,6 +23039,7 @@ var PRESERVED_FIELDS2;
22918
23039
  var init_migrate = __esm({
22919
23040
  "src/detect/migrate.ts"() {
22920
23041
  "use strict";
23042
+ init_passthrough();
22921
23043
  PRESERVED_FIELDS2 = [
22922
23044
  "rules",
22923
23045
  "domains",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
6
6
  "main": "src/server.ts",
@@ -29,6 +29,7 @@ import { parse as parseYaml } from 'yaml';
29
29
  import { runDetection } from '../detect/index.ts';
30
30
  import { computeFingerprint } from '../detect/drift.ts';
31
31
  import type { AnyConfig } from '../detect/migrate.ts';
32
+ import { copyUnknownKeys, preserveNestedSubkeys } from '../detect/passthrough.ts';
32
33
  import { buildConfigFromDetection, renderConfigYaml, writeConfigAtomic } from './init.ts';
33
34
 
34
35
  const PRESERVED_FIELDS = [
@@ -48,6 +49,7 @@ const PRESERVED_FIELDS = [
48
49
  'conventions',
49
50
  'autoLearning',
50
51
  'python',
52
+ 'toolPrefix',
51
53
  ] as const;
52
54
 
53
55
  export interface ConfigRefreshOptions {
@@ -116,12 +118,113 @@ export function computeDiff(before: AnyConfig, after: AnyConfig): DiffLine[] {
116
118
  }
117
119
 
118
120
  export function mergeRefresh(existing: AnyConfig, refreshed: AnyConfig): AnyConfig {
121
+ // P1-008: Start from detector output (fresh framework, paths.source, verification, detection).
119
122
  const out: AnyConfig = { ...refreshed };
123
+
124
+ // Restore user-authored top-level sections verbatim.
120
125
  for (const field of PRESERVED_FIELDS) {
121
126
  if (existing[field] !== undefined) {
122
127
  out[field] = existing[field];
123
128
  }
124
129
  }
130
+
131
+ // Restore toolPrefix from existing (never let detector-defaulted 'massu' overwrite a custom prefix).
132
+ if (typeof existing.toolPrefix === 'string' && existing.toolPrefix !== '') {
133
+ out.toolPrefix = existing.toolPrefix;
134
+ }
135
+
136
+ // For detector-owned blocks (framework, paths, project), preserve any user subkey the detector didn't emit.
137
+ for (const block of ['framework', 'paths', 'project'] as const) {
138
+ const existingBlock = existing[block];
139
+ const outBlock = out[block];
140
+ if (
141
+ existingBlock && typeof existingBlock === 'object' && !Array.isArray(existingBlock) &&
142
+ outBlock && typeof outBlock === 'object' && !Array.isArray(outBlock)
143
+ ) {
144
+ preserveNestedSubkeys(existingBlock, outBlock as Record<string, unknown>);
145
+ }
146
+ }
147
+
148
+ // Restore user-set project.root (detector at init.ts:418 always writes 'auto'; user value wins).
149
+ // Separated from the block loop above for readability (A-004 architecture-review follow-up).
150
+ const existingProject = existing.project;
151
+ const outProject = out.project;
152
+ if (
153
+ existingProject && typeof existingProject === 'object' && !Array.isArray(existingProject) &&
154
+ outProject && typeof outProject === 'object' && !Array.isArray(outProject)
155
+ ) {
156
+ const userRoot = (existingProject as Record<string, unknown>).root;
157
+ if (typeof userRoot === 'string' && userRoot !== '') {
158
+ (outProject as Record<string, unknown>).root = userRoot;
159
+ }
160
+ }
161
+
162
+ // paths.aliases is a 2-level-nested user block. Detector always writes
163
+ // { '@': <source-dir> }; user-authored alias map must survive. Spread user
164
+ // over detector so user keys win for any overlap AND user-only keys survive.
165
+ // (P5-002 discovery — hedge's paths.aliases['@'] was being overwritten.)
166
+ const existingPaths = existing.paths;
167
+ const outPaths = out.paths;
168
+ if (
169
+ existingPaths && typeof existingPaths === 'object' && !Array.isArray(existingPaths) &&
170
+ outPaths && typeof outPaths === 'object' && !Array.isArray(outPaths)
171
+ ) {
172
+ const existingAliases = (existingPaths as Record<string, unknown>).aliases;
173
+ const outAliases = (outPaths as Record<string, unknown>).aliases;
174
+ if (
175
+ existingAliases && typeof existingAliases === 'object' && !Array.isArray(existingAliases) &&
176
+ outAliases && typeof outAliases === 'object' && !Array.isArray(outAliases)
177
+ ) {
178
+ (outPaths as Record<string, unknown>).aliases = {
179
+ ...(outAliases as Record<string, unknown>),
180
+ ...(existingAliases as Record<string, unknown>),
181
+ };
182
+ } else if (existingAliases && typeof existingAliases === 'object' && !Array.isArray(existingAliases)) {
183
+ (outPaths as Record<string, unknown>).aliases = existingAliases;
184
+ }
185
+ }
186
+
187
+ // verification is the other 2-level-nested detector-owned block. Semantics
188
+ // mirror migrate.ts:132-138 buildVerificationBlock: user's custom language
189
+ // sections (e.g., hedge's `gateway`, `ios`, `runtime`, `web`) survive
190
+ // wholesale; user's command overrides on shared languages (e.g., `python`)
191
+ // win over detector defaults. (P5-002 discovery — hedge was losing 15
192
+ // verification command entries across 4 custom language sections plus
193
+ // having 4 python commands overwritten with detector defaults.)
194
+ const existingVer = existing.verification;
195
+ const outVer = out.verification;
196
+ if (
197
+ existingVer && typeof existingVer === 'object' && !Array.isArray(existingVer) &&
198
+ outVer && typeof outVer === 'object' && !Array.isArray(outVer)
199
+ ) {
200
+ const eVer = existingVer as Record<string, unknown>;
201
+ const oVer = outVer as Record<string, unknown>;
202
+ for (const lang of Object.keys(eVer)) {
203
+ const userLang = eVer[lang];
204
+ if (userLang === undefined) continue;
205
+ if (!(lang in oVer)) {
206
+ // User-custom language (no detector counterpart) → preserve wholesale.
207
+ oVer[lang] = userLang;
208
+ } else if (
209
+ userLang && typeof userLang === 'object' && !Array.isArray(userLang) &&
210
+ oVer[lang] && typeof oVer[lang] === 'object' && !Array.isArray(oVer[lang])
211
+ ) {
212
+ // Shared language → user commands win over detector defaults (spread).
213
+ oVer[lang] = {
214
+ ...(oVer[lang] as Record<string, unknown>),
215
+ ...(userLang as Record<string, unknown>),
216
+ };
217
+ }
218
+ }
219
+ }
220
+
221
+ // Preserve top-level user keys not handled above (mirrors P1-001 passthrough for upgrade).
222
+ const handledTopLevel = new Set<string>([
223
+ 'schema_version', 'project', 'framework', 'paths', 'toolPrefix', 'verification', 'detection',
224
+ ...PRESERVED_FIELDS,
225
+ ]);
226
+ copyUnknownKeys(existing, out, handledTopLevel);
227
+
125
228
  return out;
126
229
  }
127
230
 
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import type { DetectionResult, SupportedLanguage, VRCommandSet } from './index.ts';
25
+ import { copyUnknownKeys, preserveNestedSubkeys } from './passthrough.ts';
25
26
 
26
27
  /**
27
28
  * Shape accepted for input. We intentionally use `Record<string, unknown>`
@@ -190,6 +191,9 @@ export function migrateV1ToV2(
190
191
  if (Object.keys(languageEntries).length > 0) {
191
192
  framework.languages = languageEntries;
192
193
  }
194
+ // P1-004: preserve any v1Framework subkey the explicit rebuild didn't emit
195
+ // (e.g., hedge's `framework.{python, rust, swift, typescript}` language sub-blocks).
196
+ preserveNestedSubkeys(v1Framework, framework);
193
197
 
194
198
  // Paths: preserve user-set fields; fill `source` from detection if user had 'src' default.
195
199
  let pathsSource: string = typeof v1Paths.source === 'string' ? v1Paths.source : 'src';
@@ -207,16 +211,24 @@ export function migrateV1ToV2(
207
211
  for (const k of ['routers', 'routerRoot', 'pages', 'middleware', 'schema', 'components', 'hooks']) {
208
212
  if (typeof v1Paths[k] === 'string') paths[k] = v1Paths[k];
209
213
  }
214
+ // P1-005: preserve any v1Paths subkey the explicit rebuild didn't emit
215
+ // (e.g., hedge's 19 custom `paths.*` entries like adr, plans, monorepo_root).
216
+ preserveNestedSubkeys(v1Paths, paths);
210
217
 
211
218
  const verification = buildVerificationBlock(detection, v1Verification);
212
219
 
220
+ // P1-006: build project block with nested passthrough so custom subkeys
221
+ // (e.g., hedge's `project.description`) survive the migration.
222
+ const project: Record<string, unknown> = {
223
+ name: typeof v1Project.name === 'string' ? v1Project.name : 'my-project',
224
+ root: typeof v1Project.root === 'string' ? v1Project.root : 'auto',
225
+ };
226
+ preserveNestedSubkeys(v1Project, project);
227
+
213
228
  // Construct v2 output.
214
229
  const v2: AnyConfig = {
215
230
  schema_version: 2,
216
- project: {
217
- name: typeof v1Project.name === 'string' ? v1Project.name : 'my-project',
218
- root: typeof v1Project.root === 'string' ? v1Project.root : 'auto',
219
- },
231
+ project,
220
232
  framework,
221
233
  paths,
222
234
  toolPrefix: typeof v1.toolPrefix === 'string' ? v1.toolPrefix : 'massu',
@@ -229,6 +241,24 @@ export function migrateV1ToV2(
229
241
  }
230
242
  }
231
243
 
244
+ // P1-001: preserve any v1 top-level key not already handled by the explicit
245
+ // migrator. This is the generalization of PRESERVED_FIELDS — custom sections
246
+ // like `services`, `workflow`, `north_stars` (hedge) now pass through.
247
+ //
248
+ // `detection` is intentionally NOT in handledTopLevel: when a v2 config is
249
+ // fed back in (idempotence check at migrate.ts:16), the existing `detection`
250
+ // block round-trips via this passthrough path. It gets re-stamped with a
251
+ // fresh fingerprint by the caller at config-upgrade.ts:96-99 right after
252
+ // migrateV1ToV2 returns. Any future v2-only top-level key added here must
253
+ // either appear in this list (with explicit handling above) or round-trip
254
+ // through this passthrough — never add a v2-only key that does neither.
255
+ // (A-006 architecture-review follow-up.)
256
+ const handledTopLevel = new Set<string>([
257
+ 'schema_version', 'project', 'framework', 'paths', 'toolPrefix',
258
+ 'verification', 'python', ...PRESERVED_FIELDS,
259
+ ]);
260
+ copyUnknownKeys(v1, v2, handledTopLevel);
261
+
232
262
  // Ensure domains / rules exist as arrays (v2 requires them).
233
263
  if (!Array.isArray(v2.domains)) {
234
264
  v2.domains = [];
@@ -268,6 +298,9 @@ export function migrateV1ToV2(
268
298
  } else if (existing.orm !== undefined) {
269
299
  pythonBlock.orm = existing.orm;
270
300
  }
301
+ // P1-007: preserve any v1 python subkey not already handled above
302
+ // (e.g., `python.test_framework`, `python.database`).
303
+ preserveNestedSubkeys(v1.python, pythonBlock);
271
304
  v2.python = pythonBlock;
272
305
  } else if (v1.python !== undefined) {
273
306
  // Preserve even if detection didn't find python (e.g. non-monorepo-with-python).
@@ -0,0 +1,108 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Shared passthrough helpers for config migration + refresh.
6
+ *
7
+ * These helpers exist to prevent the class of bug fixed in @massu/core@1.2.0
8
+ * (incident 2026-04-19-config-upgrade-data-loss): a hand-maintained allow-list
9
+ * in `migrate.ts` silently dropped any v1 top-level key not on the list, and
10
+ * the parallel rebuild blocks inside `framework` / `paths` / `project` /
11
+ * `python` did the same thing at the nested level.
12
+ *
13
+ * Both helpers are TARGET-WINS: the migrator writes the keys it actively owns,
14
+ * then the helper fills in everything else the user had. A user-authored value
15
+ * in `target` is NEVER overwritten by the source.
16
+ *
17
+ * Why two exports instead of one:
18
+ * - `copyUnknownKeys` takes an explicit `handledKeys` set — used for TOP-LEVEL
19
+ * passthrough where the caller enumerates the keys it migrated explicitly
20
+ * (e.g., schema_version, project, framework, paths, toolPrefix, …).
21
+ * - `preserveNestedSubkeys` takes no handled-set — used for NESTED passthrough
22
+ * where the target block was just rebuilt, so `k in target` already skips
23
+ * any key the rebuild populated. Splitting the two makes callsites
24
+ * self-documenting without a verbose `new Set()` argument at every nested
25
+ * call (A-002 architecture-review follow-up).
26
+ */
27
+
28
+ /** Keys that would mutate Object.prototype if copied as own properties. Explicit
29
+ * denylist defense-in-depth on top of the existing `k in target` guard and the
30
+ * `yaml@2.8` parser's non-polluting behavior (S-001 security-review follow-up). */
31
+ const UNSAFE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype']);
32
+
33
+ /**
34
+ * Copy any key from `source` into `target` that target doesn't already have set,
35
+ * skipping keys listed in `handledKeys`. Target values ALWAYS win — this function
36
+ * never overwrites an existing target key.
37
+ *
38
+ * - If source[k] is undefined → skip (undefined is not a preservable value).
39
+ * - If k is an UNSAFE_KEYS entry → skip (prototype-pollution defense).
40
+ * - If handledKeys.has(k) → skip (caller has its own handling).
41
+ * - If target already owns k → skip (target wins).
42
+ * - Otherwise → target[k] = deepClone(source[k]).
43
+ *
44
+ * Values are DEEP-CLONED via structuredClone so that mutating the output v2
45
+ * object never reaches back into the v1 input (S-002 security-review follow-up).
46
+ * Preserves the migrator's "pure data in, pure data out" contract.
47
+ */
48
+ export function copyUnknownKeys(
49
+ source: Record<string, unknown>,
50
+ target: Record<string, unknown>,
51
+ handledKeys: ReadonlySet<string>
52
+ ): void {
53
+ if (source === null || typeof source !== 'object' || Array.isArray(source)) {
54
+ return;
55
+ }
56
+ for (const k of Object.keys(source)) {
57
+ if (UNSAFE_KEYS.has(k)) continue;
58
+ if (source[k] === undefined) continue;
59
+ if (handledKeys.has(k)) continue;
60
+ if (Object.prototype.hasOwnProperty.call(target, k)) continue;
61
+ target[k] = safeClone(source[k]);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Preserve every subkey from sourceBlock into targetBlock that targetBlock
67
+ * doesn't already have. Target values ALWAYS win.
68
+ *
69
+ * If sourceBlock is not a plain object (string, array, null, undefined),
70
+ * return early — there are no subkeys to preserve. This matches the coercion
71
+ * semantics of `getRecord` in migrate.ts and prevents throws on loose v1 inputs
72
+ * like `framework: "typescript"`.
73
+ *
74
+ * Values are deep-cloned (see copyUnknownKeys); UNSAFE_KEYS are skipped.
75
+ */
76
+ export function preserveNestedSubkeys(
77
+ sourceBlock: unknown,
78
+ targetBlock: Record<string, unknown>
79
+ ): void {
80
+ if (
81
+ sourceBlock === null ||
82
+ sourceBlock === undefined ||
83
+ typeof sourceBlock !== 'object' ||
84
+ Array.isArray(sourceBlock)
85
+ ) {
86
+ return;
87
+ }
88
+ const src = sourceBlock as Record<string, unknown>;
89
+ for (const k of Object.keys(src)) {
90
+ if (UNSAFE_KEYS.has(k)) continue;
91
+ if (src[k] === undefined) continue;
92
+ if (Object.prototype.hasOwnProperty.call(targetBlock, k)) continue;
93
+ targetBlock[k] = safeClone(src[k]);
94
+ }
95
+ }
96
+
97
+ /** structuredClone with a fallback for environments without it (Node <17). */
98
+ function safeClone<T>(v: T): T {
99
+ if (typeof structuredClone === 'function') {
100
+ try {
101
+ return structuredClone(v);
102
+ } catch {
103
+ // structuredClone throws on functions, DOM nodes, etc. — YAML never produces
104
+ // those, but if a caller passes something exotic, fall through to shallow.
105
+ }
106
+ }
107
+ return v;
108
+ }