@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 +127 -5
- package/package.json +1 -1
- package/src/commands/config-refresh.ts +103 -0
- package/src/detect/migrate.ts +37 -4
- package/src/detect/passthrough.ts +108 -0
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.
|
|
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
|
|
package/src/detect/migrate.ts
CHANGED
|
@@ -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
|
+
}
|