From 64a2c8e1fb8cd0e16e50eedd15bd5b569fe9a449 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 20:29:11 +0000 Subject: [PATCH 1/2] Fix nondeterministic originalName derivation in app.yaml export resolveOriginalPath iterated over the path map in Go's randomized order and derived a directory's original path from whichever mapped child it saw first. A child whose original path does not preserve the disk directory depth (e.g. Module2/_/Constant_2...) could hijack the prefix, which made the 'Test export mpr-v2' CI job fail intermittently. Scan candidates in sorted order and prefer structure-preserving mappings, falling back to depth-changing ones only when no aligned child exists. Co-authored-by: Xiwen Cheng --- mpr/mpr.go | 65 +++++++++++++++++++++++++++++++++++++------------ mpr/mpr_test.go | 34 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/mpr/mpr.go b/mpr/mpr.go index 83be89f..f74d32a 100644 --- a/mpr/mpr.go +++ b/mpr/mpr.go @@ -1134,29 +1134,62 @@ func buildFileStructure(basePath string, currentPath string, pathMap map[string] } // resolveOriginalPath returns the Mendix original path for a disk-relative path. -// For directories, derives the original prefix from any mapped child file. +// For directories, derives the original prefix from a mapped child file. func resolveOriginalPath(diskRel string, pathMap map[string]string) string { diskRel = filepath.ToSlash(diskRel) if diskRel == "" || diskRel == "." { return diskRel } - if pathMap != nil { - if mapped, ok := pathMap[diskRel]; ok && mapped != "" { - return filepath.ToSlash(mapped) + if pathMap == nil { + return diskRel + } + if mapped, ok := pathMap[diskRel]; ok && mapped != "" { + return filepath.ToSlash(mapped) + } + return deriveOriginalDirPath(diskRel, pathMap) +} + +// deriveOriginalDirPath derives a directory's original path from the mapped +// child files below it. Children whose original path has the same depth as +// their disk path map one-to-one onto the directory structure, so their prefix +// is authoritative; depth-changing mappings are only used as a fallback. +// Candidates are scanned in sorted order so the result never depends on Go's +// randomized map iteration order. +func deriveOriginalDirPath(diskRel string, pathMap map[string]string) string { + prefix := diskRel + "/" + dirDepth := len(strings.Split(diskRel, "/")) + + type childMapping struct { + disk string + orig string + } + children := make([]childMapping, 0, len(pathMap)) + for disk, orig := range pathMap { + disk = filepath.ToSlash(disk) + if strings.HasPrefix(disk, prefix) { + children = append(children, childMapping{disk: disk, orig: filepath.ToSlash(orig)}) } - prefix := diskRel + "/" - diskParts := strings.Split(diskRel, "/") - for disk, orig := range pathMap { - disk = filepath.ToSlash(disk) - orig = filepath.ToSlash(orig) - if !strings.HasPrefix(disk, prefix) { - continue - } - origParts := strings.Split(orig, "/") - if len(origParts) >= len(diskParts) { - return strings.Join(origParts[:len(diskParts)], "/") - } + } + sort.Slice(children, func(i, j int) bool { + return children[i].disk < children[j].disk + }) + + fallback := "" + for _, child := range children { + origParts := strings.Split(child.orig, "/") + if len(origParts) < dirDepth { + continue } + derived := strings.Join(origParts[:dirDepth], "/") + if len(origParts) == len(strings.Split(child.disk, "/")) { + return derived + } + if fallback == "" { + fallback = derived + } + } + if fallback != "" { + return fallback } return diskRel } diff --git a/mpr/mpr_test.go b/mpr/mpr_test.go index 383e987..67c60e5 100644 --- a/mpr/mpr_test.go +++ b/mpr/mpr_test.go @@ -1385,6 +1385,40 @@ func TestGenerateAppYamlTruncationMapping(t *testing.T) { } } +func TestResolveOriginalPathIsDeterministic(t *testing.T) { + // Regression test: Module2 contains one child whose original path does not + // preserve the directory depth ("_" folder). With randomized map iteration + // order, that child could hijack the derived original path for Module2. + pathMap := map[string]string{ + "Module2/DomainModels$DomainModel.yaml": "Module2/DomainModels$DomainModel.yaml", + "Module2/Projects$ModuleSettings.yaml": "Module2/Projects$ModuleSettings.yaml", + "Module2/_/Constant_2.Constants$Constant.yaml": "Constant_2.Constants$Constant.yaml", + } + + for i := 0; i < 100; i++ { + if got := resolveOriginalPath("Module2", pathMap); got != "Module2" { + t.Fatalf("iteration %d: resolveOriginalPath(Module2) = %q, want %q", i, got, "Module2") + } + // Only child has a shorter original path, so no prefix can be derived. + if got := resolveOriginalPath("Module2/_", pathMap); got != "Module2/_" { + t.Fatalf("iteration %d: resolveOriginalPath(Module2/_) = %q, want %q", i, got, "Module2/_") + } + } +} + +func TestResolveOriginalPathDepthChangingFallback(t *testing.T) { + // A folder whose only mapped child has a deeper original path (e.g. the + // original folder name contained path separators) still derives its + // original prefix from that child. + pathMap := map[string]string{ + "Module2/Folder_trunc name/Constant_3.Constants$Constant.yaml": "Module2/Folder/very long name/Constant_3.Constants$Constant.yaml", + } + + if got := resolveOriginalPath("Module2/Folder_trunc name", pathMap); got != "Module2/Folder" { + t.Fatalf("resolveOriginalPath = %q, want %q", got, "Module2/Folder") + } +} + func TestExportMetadata_SortsModulesByName(t *testing.T) { tmpDir, err := os.MkdirTemp("", "mpr-test-metadata-sort-*") if err != nil { From ed597b0699f5f7ec535f85f25487931d66e97347 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 20:29:11 +0000 Subject: [PATCH 2/2] CI: sync lint ruleset into scratch dir instead of resources/rules The lint job's config set rules.path to ./resources/rules while inheriting the default remote ruleset, so SyncRulesets deleted the checked-in rules fixtures on the runner and replaced them with the downloaded bundle. Point rules.path at .ci/rules so the sync cannot overwrite repository files. Co-authored-by: Xiwen Cheng --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aad2b1..8ce6083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,8 +158,11 @@ jobs: chmod +x ./bin/mxlint mkdir -p .ci cat > .ci/lint.yaml << 'EOF' + # The default config syncs the published mxlint-rules bundle into + # rules.path, wiping whatever is there. Use a scratch directory so the + # sync cannot overwrite the checked-in resources/rules fixtures. rules: - path: ./resources/rules + path: .ci/rules modelsource: resources/modelsource-v1 lint: xunitReport: report.xml