From 992a790988d029e4e52c1688e732148fb30e5513 Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Fri, 15 May 2026 00:08:32 +0800 Subject: [PATCH 1/4] Add support for wildcard documentPath in skip rules --- default.yaml | 2 ++ lint/config.go | 23 +++++++++++++---- lint/config_internal_test.go | 50 ++++++++++++++++++++++++++++++++++++ lint/config_test.go | 35 +++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/default.yaml b/default.yaml index 9feef1c..3e53129 100644 --- a/default.yaml +++ b/default.yaml @@ -9,6 +9,8 @@ lint: noCache: false concurrency: 4 regoTrace: false + # skip: maps document path (relative to model source, or absolute) to rules to skip. + # Use the map key "*" (quoted in YAML: "*") to apply the listed rules to every document, after path-specific entries. skip: {} cache: directory: .mendix-cache/mxlint diff --git a/lint/config.go b/lint/config.go index c501d2b..3bad402 100644 --- a/lint/config.go +++ b/lint/config.go @@ -13,6 +13,9 @@ import ( const configFileName = "mxlint.yaml" +// skipPathAllDocuments is the lint.skip map key that applies listed rules to every document. +const skipPathAllDocuments = "*" + type Config struct { Rules ConfigRulesSpec `yaml:"rules"` Lint ConfigLintSpec `yaml:"lint"` @@ -352,6 +355,15 @@ func mergeConfig(base *Config, overlay *Config) { } } +func matchConfigSkipRules(entries []ConfigSkipRule, ruleNumber string) (bool, string) { + for _, entry := range entries { + if entry.Rule == "" || entry.Rule == "*" || entry.Rule == ruleNumber { + return true, formatConfigSkipReason(entry) + } + } + return false, "" +} + func shouldSkipByConfig(inputFilePath string, ruleNumber string, modelSourcePath string) (bool, string) { cfg := getConfig() if cfg == nil || len(cfg.Lint.Skip) == 0 { @@ -363,14 +375,15 @@ func shouldSkipByConfig(inputFilePath string, ruleNumber string, modelSourcePath if !ok { continue } - - for _, entry := range entries { - if entry.Rule == "" || entry.Rule == "*" || entry.Rule == ruleNumber { - return true, formatConfigSkipReason(entry) - } + if skip, reason := matchConfigSkipRules(entries, ruleNumber); skip { + return true, reason } } + if entries, ok := cfg.Lint.Skip[skipPathAllDocuments]; ok { + return matchConfigSkipRules(entries, ruleNumber) + } + return false, "" } diff --git a/lint/config_internal_test.go b/lint/config_internal_test.go index f84f205..95a9a66 100644 --- a/lint/config_internal_test.go +++ b/lint/config_internal_test.go @@ -15,6 +15,7 @@ func TestNormalizeSkipPath(t *testing.T) { {name: "leading slash", input: "/example/doc.yaml", expected: "example/doc.yaml"}, {name: "collapse separators", input: "example//nested/../doc", expected: "example/doc"}, {name: "dot path becomes empty", input: ".", expected: ""}, + {name: "all-documents wildcard key", input: "*", expected: "*"}, } for _, tt := range tests { @@ -130,3 +131,52 @@ func TestShouldSkipByConfig(t *testing.T) { } }) } + +func TestShouldSkipByConfig_AllDocumentsWildcard(t *testing.T) { + SetConfig(&Config{ + Lint: ConfigLintSpec{ + Skip: map[string][]ConfigSkipRule{ + skipPathAllDocuments: { + {Rule: "001_002", Reason: "skip everywhere"}, + }, + }, + }, + }) + t.Cleanup(func() { + SetConfig(&Config{}) + }) + + skip, reason := shouldSkipByConfig("/tmp/modelsource/example/other.yaml", "001_002", "/tmp/modelsource") + if !skip { + t.Fatal("expected skip=true for lint.skip document path *") + } + if reason != "skip everywhere" { + t.Fatalf("expected global skip reason, got %q", reason) + } +} + +func TestShouldSkipByConfig_PathSpecificBeforeAllDocumentsWildcard(t *testing.T) { + SetConfig(&Config{ + Lint: ConfigLintSpec{ + Skip: map[string][]ConfigSkipRule{ + skipPathAllDocuments: { + {Rule: "001_002", Reason: "from star"}, + }, + "example/doc": { + {Rule: "001_002", Reason: "from path"}, + }, + }, + }, + }) + t.Cleanup(func() { + SetConfig(&Config{}) + }) + + skip, reason := shouldSkipByConfig("/tmp/modelsource/example/doc.yaml", "001_002", "/tmp/modelsource") + if !skip { + t.Fatal("expected skip=true") + } + if reason != "from path" { + t.Fatalf("expected path-specific skip to win, got %q", reason) + } +} diff --git a/lint/config_test.go b/lint/config_test.go index 9c581e3..16b6810 100644 --- a/lint/config_test.go +++ b/lint/config_test.go @@ -355,6 +355,41 @@ func TestLoadMergedConfig_NormalizesSkipMapKeys(t *testing.T) { } } +func TestLoadMergedConfig_SkipAllDocumentsWildcardKey(t *testing.T) { + projectDir := t.TempDir() + setDefaultConfigForTest(t, "") + projectConfig := `lint: + skip: + "*": + - rule: "001_002" + reason: global doc skip +` + if err := os.WriteFile(filepath.Join(projectDir, "mxlint.yaml"), []byte(projectConfig), 0644); err != nil { + t.Fatalf("failed to write project config: %v", err) + } + + cfg, err := LoadMergedConfig(projectDir) + if err != nil { + t.Fatalf("LoadMergedConfig returned error: %v", err) + } + + if _, ok := cfg.Lint.Skip["*"]; !ok { + t.Fatalf("expected skip key *, got %#v", cfg.Lint.Skip) + } + SetConfig(cfg) + t.Cleanup(func() { + SetConfig(&Config{}) + }) + + skip, reason := shouldSkipRule("", "001_002", true, "/tmp/modelsource/any/nested/file.yaml", "/tmp/modelsource") + if !skip { + t.Fatal("expected lint.skip * document path to match any file") + } + if reason != "global doc skip" { + t.Fatalf("expected configured reason, got %s", reason) + } +} + func TestLoadMergedConfig_LintConcurrencyAndTrace(t *testing.T) { projectDir := t.TempDir() setDefaultConfigForTest(t, "") From 4b9a24789904238ad0e992bd62ca725009e10f75 Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Fri, 15 May 2026 00:09:00 +0800 Subject: [PATCH 2/4] Add example of non-PE --- resources/app-mpr-v2/App.mpr | Bin 73728 -> 73728 bytes ...10c6774-5724-49fa-ab92-b8ea23d08191.mxunit | Bin 191 -> 1242 bytes ...3929370-5574-4a6d-82b9-d3a11fae8e4a.mxunit | Bin 0 -> 2238 bytes .../Module2/DomainModels$DomainModel.yaml | 36 +++++++++++- ...roflowNonPersist.Microflows$Microflow.yaml | 53 ++++++++++++++++++ resources/modelsource-v2/app.yaml | 3 + 6 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 resources/app-mpr-v2/mprcontents/83/92/83929370-5574-4a6d-82b9-d3a11fae8e4a.mxunit create mode 100644 resources/modelsource-v2/Module2/MicroflowNonPersist.Microflows$Microflow.yaml diff --git a/resources/app-mpr-v2/App.mpr b/resources/app-mpr-v2/App.mpr index 61c44c09dc5e674372c6c2f7e506ab5b67f5d4de..f0106a5c0932d96082e79a355a5839f9880ef630 100644 GIT binary patch delta 299 zcmZoTz|wGlWr8##+e8^>Mz)O!HU26|W(KAv78Vw|hGwRgx+aO{DY}Wt$!5C7NomF= zhAAmVi7AGg0v`EjCi!QSXNxaDSArn`mu8sz2|6%-aa8Wk5tYL^&hnxz#4 zSZ?N?`%{Q<)@0TV=1g21lWjKq+ibJ3l$|kZ^QIkPjAEk9I!pzVCpDLZ=6W^lyu46; zU7r_Y$7Z=*&q5XWxOOq{FXVUPd(79ur^|0Zb`0P8ylx_#m6*r zu)BZ`cpp;8~ZenJhZ+=Q@ zPO%CShk?a0FE76&u_QA;ub6=cs8Yayff-~Ni(_$desU%@8Rw$>;$ocAOfLD!rManj z5JMRl!G^i!m1LG=rWP}(16>TYg4vLPlL^`VyDu)SUwU?i%c29>Px7pUPeI);0W<;0 z$;B#Q!zw*<3vz(2OioTME)FWq0h*2K{F0KQ%%sv1G;!>%D+4+gRX>w!ML~X1iBD=- zY7PTC&;=fuDJiLW46N>q)hD{w_^s^2|+4Plb4x70h-?EJ*}; z9~hdRc`2zC=x*`JPX>oC0|!uvk%@t|p(!x*eG@B_Qr%PYQi~FEGOHj876BC?BKnSa zpmCIiw|7&$=yRigpHiU#B?nZ55>S5mXvT1OBo;eoB<7{3rZ`nP0z-&_0Z9nx+*BmN zqEsM11y#_$9Ox{lnt;@z;>_Zb#H1XMr%QjC^?mZB@F1B z6tHhFV~c^=h=B(sE!gT_VR&?Ei*w(a5P_oe&gz3aEeJfGdyL)XvCks=8*|iz&e&3z x7ECOFX~Bf3wD2KdYfA3|H`lJn?-8wPSGEs)T3~>t1wnXP@XOC5EjchS006LzOKboD delta 24 ecmcb`xu20~Kf^?(4ZJ)I3=9GW49q}^fdK$Zuml7E diff --git a/resources/app-mpr-v2/mprcontents/83/92/83929370-5574-4a6d-82b9-d3a11fae8e4a.mxunit b/resources/app-mpr-v2/mprcontents/83/92/83929370-5574-4a6d-82b9-d3a11fae8e4a.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..9c3062dd5e6c10c6e2d761e9bbcb1d157e86c42b GIT binary patch literal 2238 zcmc&$QEU`t5T5Q`^#C6bMZ_pHdMJTt?yd^NYM|U5^h$eeuC(@vxZRz$Yxn;D`2XF~ zLxWNbF$6`c;0wlJ0`)~SK4@$N32HF3(DIAl^Cu^{H#;(uJvgr7wPau;EA$#1c~4 ziDQmTswcz?4wgemF$D><95>uD#i9E#sIx?V%&=V(O%mtFDqJ`nIEpr0PqH@4G?77k zn@S6;AR`o=G8Ie1(}cK&Y4wd44n7ml703U$e{yB2Ygd5jR?-b)l*u04?IJT!*@Jji zm)NJSCCR2_^r6t zLXmaFC{iLL7#+dwKElZbTt}DgZBDhMKWMFDl)KpfW?qGIUXG0j6q1{t1TSN}hEOOBN%>*meMQ?>=?(>i8h@{nKr^SXm z>MbIDT6Xq;Zj8*?G2Zk}lyFd>94@e|ZK<+?)~5?gAFwgxY!Ua&b4B~ob@+B93z@dD!eL9nQY}XX)Tf)8qYGudscR#ObXEbzuy5>w=n> YHdeTtJxhxp;bKI6fB5w(Fd7{27iH3sRR910 literal 0 HcmV?d00001 diff --git a/resources/modelsource-v2/Module2/DomainModels$DomainModel.yaml b/resources/modelsource-v2/Module2/DomainModels$DomainModel.yaml index fbbd271..11dc0f7 100644 --- a/resources/modelsource-v2/Module2/DomainModels$DomainModel.yaml +++ b/resources/modelsource-v2/Module2/DomainModels$DomainModel.yaml @@ -3,4 +3,38 @@ Annotations: [] Associations: [] CrossAssociations: [] Documentation: "" -Entities: [] +Entities: + - $Type: DomainModels$EntityImpl + AccessRules: [] + Attributes: [] + Documentation: "" + Events: [] + ExportLevel: Hidden + Indexes: [] + MaybeGeneralization: + $Type: DomainModels$NoGeneralization + HasChangedByAttr: false + HasChangedDateAttr: false + HasCreatedDateAttr: false + HasOwnerAttr: false + Persistable: true + Name: EntityPersist + Source: null + ValidationRules: [] + - $Type: DomainModels$EntityImpl + AccessRules: [] + Attributes: [] + Documentation: "" + Events: [] + ExportLevel: Hidden + Indexes: [] + MaybeGeneralization: + $Type: DomainModels$NoGeneralization + HasChangedByAttr: false + HasChangedDateAttr: false + HasCreatedDateAttr: false + HasOwnerAttr: false + Persistable: false + Name: EntityNonPersist + Source: null + ValidationRules: [] diff --git a/resources/modelsource-v2/Module2/MicroflowNonPersist.Microflows$Microflow.yaml b/resources/modelsource-v2/Module2/MicroflowNonPersist.Microflows$Microflow.yaml new file mode 100644 index 0000000..ff2a9c0 --- /dev/null +++ b/resources/modelsource-v2/Module2/MicroflowNonPersist.Microflows$Microflow.yaml @@ -0,0 +1,53 @@ +$Type: Microflows$Microflow +AllowConcurrentExecution: true +AllowedModuleRoles: [] +ApplyEntityAccess: false +ConcurrencyErrorMicroflow: "" +ConcurrenyErrorMessage: + $Type: Texts$Text + Items: [] +Documentation: "" +Excluded: false +ExportLevel: Hidden +MarkAsUsed: false +MicroflowActionInfo: null +MicroflowReturnType: + $Type: DataTypes$VoidType +Name: MicroflowNonPersist +ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$StartEvent + - $Type: Microflows$EndEvent + Documentation: "" + ReturnValue: "" + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$CreateChangeAction + Commit: "No" + Entity: Module2.EntityNonPersist + ErrorHandlingType: Rollback + Items: [] + RefreshInClient: false + VariableName: NewEntityNonPersist + AutoGenerateCaption: true + BackgroundColor: Default + Caption: Activity + Disabled: false + Documentation: "" +ReturnVariableName: "" +Url: "" +UrlSearchParameters: [] +WorkflowActionInfo: null +pseudocode: |- + MICROFLOW: MicroflowNonPersist + RETURN TYPE: void + + PSEUDOCODE + ---------- + BEGIN + + // action: Microflows$CreateChangeAction + + + END diff --git a/resources/modelsource-v2/app.yaml b/resources/modelsource-v2/app.yaml index b4bca09..2a128cb 100644 --- a/resources/modelsource-v2/app.yaml +++ b/resources/modelsource-v2/app.yaml @@ -37,6 +37,9 @@ content: - name: MicroflowLoopExample.Microflows$Microflow.yaml type: file path: Module2/MicroflowLoopExample.Microflows$Microflow.yaml + - name: MicroflowNonPersist.Microflows$Microflow.yaml + type: file + path: Module2/MicroflowNonPersist.Microflows$Microflow.yaml - name: MultiLineMicroflow.Microflows$Microflow.yaml type: file path: Module2/MultiLineMicroflow.Microflows$Microflow.yaml From 5d461b78db4afe2feaba0877b8f8c502e386c972 Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Sun, 17 May 2026 00:39:47 +0800 Subject: [PATCH 3/4] introduce new io functions for reading JSON and yaml files --- lint/lint_javascript.go | 94 +++++++++++--- lint/lint_javascript_test.go | 239 +++++++++++++++++++++++++++++++++++ mpr/mpr_v2_test.go | 2 +- 3 files changed, 319 insertions(+), 16 deletions(-) diff --git a/lint/lint_javascript.go b/lint/lint_javascript.go index 45386cb..27a3345 100644 --- a/lint/lint_javascript.go +++ b/lint/lint_javascript.go @@ -1,6 +1,7 @@ package lint import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -50,10 +51,49 @@ func resolvePath(pathArg string, workingDirectory string, allowedRoot string) (s return absFullPath, nil } +// readYAMLDocumentFromPath reads a YAML file and decodes it into a map suitable for JavaScript rules. +func readYAMLDocumentFromPath(absPath string) (map[string]interface{}, error) { + documentContent, err := os.ReadFile(absPath) + if err != nil { + return nil, err + } + + var data map[string]interface{} + var node yaml.Node + err = yaml.Unmarshal(documentContent, &node) + if err != nil { + return nil, err + } + err = node.Decode(&data) + if err != nil { + return nil, err + } + return data, nil +} + +// readJSONDocumentFromPath reads a JSON file and decodes it into a value suitable for JavaScript rules. +func readJSONDocumentFromPath(absPath string) (interface{}, error) { + documentContent, err := os.ReadFile(absPath) + if err != nil { + return nil, err + } + + var data interface{} + err = json.Unmarshal(documentContent, &data) + if err != nil { + return nil, err + } + return data, nil +} + // setupJavascriptVM creates a new sobek VM with the mxlint object exposed. // The mxlint object provides utility functions for JavaScript rules: // - mxlint.io.readfile(path): Reads a file and returns its contents as a string. // The path is resolved relative to the workingDirectory. +// - mxlint.io.readYaml(path): Reads a YAML file and returns its parsed content as an object. +// The path is resolved relative to the workingDirectory. +// - mxlint.io.readJson(path): Reads a JSON file and returns its parsed content. +// The path is resolved relative to the workingDirectory. // - mxlint.io.listdir(path): Lists the contents of a directory and returns an array of filenames. // The path is resolved relative to the workingDirectory. // - mxlint.io.isdir(path): Returns true if the path is a directory, false otherwise. @@ -88,6 +128,44 @@ func setupJavascriptVM(workingDirectory string, allowedRoot string) *sobek.Runti return vm.ToValue(string(content)) }) + // Set the readYaml function + io.Set("readYaml", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readYaml requires a file path argument"))) + } + filepathArg := call.Argument(0).String() + + absPath, err := resolvePath(filepathArg, workingDirectory, allowedRoot) + if err != nil { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readYaml: %w", err))) + } + + data, err := readYAMLDocumentFromPath(absPath) + if err != nil { + panic(vm.NewGoError(err)) + } + return vm.ToValue(data) + }) + + // Set the readJson function + io.Set("readJson", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readJson requires a file path argument"))) + } + filepathArg := call.Argument(0).String() + + absPath, err := resolvePath(filepathArg, workingDirectory, allowedRoot) + if err != nil { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readJson: %w", err))) + } + + data, err := readJSONDocumentFromPath(absPath) + if err != nil { + panic(vm.NewGoError(err)) + } + return vm.ToValue(data) + }) + // Set the listdir function io.Set("listdir", func(call sobek.FunctionCall) sobek.Value { if len(call.Arguments) == 0 { @@ -144,26 +222,12 @@ func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber s ruleContent, _ := os.ReadFile(rulePath) log.Debugf("js file: \n%s", ruleContent) - documentContent, err := os.ReadFile(inputFilePath) + data, err := readYAMLDocumentFromPath(inputFilePath) if err != nil { log.Errorf("Error reading YAML file %q (rule: %q): %s\n", inputFilePath, rulePath, err) return nil, err } - // parse the input file as YAML - var data map[string]interface{} - var node yaml.Node - err = yaml.Unmarshal(documentContent, &node) - if err != nil { - log.Errorf("Error parsing YAML file %q (rule: %q): %s\n", inputFilePath, rulePath, err) - return nil, err - } - err = node.Decode(&data) - if err != nil { - log.Errorf("Error decoding YAML file %q (rule: %q): %s\n", inputFilePath, rulePath, err) - return nil, err - } - // Check if this rule should be skipped based on noqa directives doc, _ := data["Documentation"].(string) shouldSkip, reason := shouldSkipRule(doc, ruleNumber, ignoreNoqa, inputFilePath, modelSourcePath) diff --git a/lint/lint_javascript_test.go b/lint/lint_javascript_test.go index 1ba8e5c..bc550c5 100644 --- a/lint/lint_javascript_test.go +++ b/lint/lint_javascript_test.go @@ -159,6 +159,223 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) } +func TestSetupJavascriptVM_MxlintReadYaml(t *testing.T) { + tempDir := t.TempDir() + + yamlContent := `$Type: DomainModels$DomainModel +Entities: + - Name: EntityNonPersist + MaybeGeneralization: + Persistable: false +` + yamlPath := filepath.Join(tempDir, "model.yaml") + if err := os.WriteFile(yamlPath, []byte(yamlContent), 0644); err != nil { + t.Fatalf("Failed to create test yaml file: %v", err) + } + + t.Run("read yaml with relative path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := `const doc = mxlint.io.readYaml("model.yaml"); +doc.$Type === "DomainModels$DomainModel" && doc.Entities[0].Name === "EntityNonPersist"` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if !result.ToBoolean() { + t.Errorf("Expected parsed YAML object, got %v", result.Export()) + } + }) + + t.Run("read nonexistent yaml throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readYaml("nonexistent.yaml"); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() == "no error" { + t.Error("Expected an error when reading nonexistent yaml file") + } + }) + + t.Run("readYaml without argument throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readYaml(); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() == "no error" { + t.Error("Expected an error when calling readYaml without argument") + } + }) + + t.Run("path traversal is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readYaml("../../../etc/passwd"); + "no error"; + } catch (e) { + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() != "blocked" { + t.Errorf("Expected path traversal to be blocked, got: %s", result.String()) + } + }) +} + +func TestSetupJavascriptVM_MxlintReadJson(t *testing.T) { + tempDir := t.TempDir() + + jsonContent := `{"$Type":"DomainModels$DomainModel","Entities":[{"Name":"EntityNonPersist"}]}` + jsonPath := filepath.Join(tempDir, "model.json") + if err := os.WriteFile(jsonPath, []byte(jsonContent), 0644); err != nil { + t.Fatalf("Failed to create test json file: %v", err) + } + + t.Run("read json with relative path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := `const doc = mxlint.io.readJson("model.json"); +doc.$Type === "DomainModels$DomainModel" && doc.Entities[0].Name === "EntityNonPersist"` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if !result.ToBoolean() { + t.Errorf("Expected parsed JSON object, got %v", result.Export()) + } + }) + + t.Run("read json array", func(t *testing.T) { + arrayPath := filepath.Join(tempDir, "array.json") + if err := os.WriteFile(arrayPath, []byte(`["a","b"]`), 0644); err != nil { + t.Fatalf("Failed to create array json file: %v", err) + } + + vm := setupJavascriptVM(tempDir, tempDir) + + script := `const items = mxlint.io.readJson("array.json"); +items.length === 2 && items[0] === "a" && items[1] === "b"` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if !result.ToBoolean() { + t.Errorf("Expected parsed JSON array, got %v", result.Export()) + } + }) + + t.Run("read nonexistent json throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readJson("nonexistent.json"); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() == "no error" { + t.Error("Expected an error when reading nonexistent json file") + } + }) + + t.Run("invalid json throws error", func(t *testing.T) { + invalidPath := filepath.Join(tempDir, "invalid.json") + if err := os.WriteFile(invalidPath, []byte(`{$Type: bad}`), 0644); err != nil { + t.Fatalf("Failed to create invalid json file: %v", err) + } + + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readJson("invalid.json"); + "no error"; + } catch (e) { + "error"; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() == "no error" { + t.Error("Expected an error when reading invalid json file") + } + }) + + t.Run("readJson without argument throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readJson(); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() == "no error" { + t.Error("Expected an error when calling readJson without argument") + } + }) + + t.Run("path traversal is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readJson("../../../etc/passwd"); + "no error"; + } catch (e) { + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() != "blocked" { + t.Errorf("Expected path traversal to be blocked, got: %s", result.String()) + } + }) +} + func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { // Create a temporary directory for test files tempDir := t.TempDir() @@ -559,6 +776,28 @@ func TestMxlintObjectAvailable(t *testing.T) { t.Errorf("Expected mxlint.io.readfile to be a function, got %q", result.String()) } + // Check that mxlint.io.readYaml is a function + script = `typeof mxlint.io.readYaml` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "function" { + t.Errorf("Expected mxlint.io.readYaml to be a function, got %q", result.String()) + } + + // Check that mxlint.io.readJson is a function + script = `typeof mxlint.io.readJson` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "function" { + t.Errorf("Expected mxlint.io.readJson to be a function, got %q", result.String()) + } + // Check that mxlint.io.listdir is a function script = `typeof mxlint.io.listdir` result, err = vm.RunString(script) diff --git a/mpr/mpr_v2_test.go b/mpr/mpr_v2_test.go index 8330b44..feabc47 100644 --- a/mpr/mpr_v2_test.go +++ b/mpr/mpr_v2_test.go @@ -30,7 +30,7 @@ func TestMPRV2Metadata(t *testing.T) { t.Errorf("Failed to decode metadata file: %v", err) } // check metadata - expectedProductVersion := "10.24.9.81004" + expectedProductVersion := "10.24.16.96987" if metadataObj.ProductVersion != expectedProductVersion { t.Errorf("ProductVersion is incorrect. Expected: %s, Got: %s", expectedProductVersion, metadataObj.ProductVersion) } From f9c6161f6ade28804b18724ce5d249cd450b93f7 Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Sun, 17 May 2026 00:43:17 +0800 Subject: [PATCH 4/4] Add new example using mxlint.io.readYaml function --- .../rules/001_0006_no_npe_in_microflow.js | 65 +++++++++++++++++++ .../001_0006_no_npe_in_microflow_test.yaml | 33 ++++++++++ .../Module2/DomainModels$DomainModel.yaml | 7 ++ 3 files changed, 105 insertions(+) create mode 100644 resources/rules/001_0006_no_npe_in_microflow.js create mode 100644 resources/rules/001_0006_no_npe_in_microflow_test.yaml create mode 100644 resources/rules/Module2/DomainModels$DomainModel.yaml diff --git a/resources/rules/001_0006_no_npe_in_microflow.js b/resources/rules/001_0006_no_npe_in_microflow.js new file mode 100644 index 0000000..fc383ab --- /dev/null +++ b/resources/rules/001_0006_no_npe_in_microflow.js @@ -0,0 +1,65 @@ +const metadata = { + scope: "package", + title: "No NPE in microflow", + description: "Disallow Non-persistent entity usage in persistent microflows", + authors: ["Xiwen Cheng "], + custom: { + category: "Security", + rulename: "NonPersistentEntityUsageInPersistentMicroflows", + severity: "HIGH", + rulenumber: "001_0006", + remediation: "Remove the non-persistent entity from the persistent microflow", + input: ".*\\$Microflow\\.yaml" + } +}; + +function entityName(entityRef) { + const parts = entityRef.split("."); + return parts[parts.length - 1]; +} + +function isNPE(entity) { + // read the parent domain model and check if the entity is NPE + // entity: Module2.EntityNonPersist + if (entity === undefined) { + return false; + } + const moduleName = entity.split(".")[0]; + const domainModelPath = [moduleName, "DomainModels$DomainModel.yaml"].join("/"); + const domainModel = mxlint.io.readYaml(domainModelPath); + if (domainModel === undefined || domainModel.Entities === undefined) { + return false; + } + const name = entityName(entity); + const found = domainModel.Entities.find(e => e.Name === name); + if (found === undefined || found.MaybeGeneralization === undefined) { + return false; + } + return found.MaybeGeneralization.Persistable === false; +} + +function rule(input = {}) { + const errors = []; + + // for each entity, check the entity type via the parent domain model if it's NPE or not + for (const object of input.ObjectCollection.Objects) { + if (object.$Type === "Microflows$ActionActivity") { + const entity = object.Action.Entity; + try { + if (isNPE(entity)) { + errors.push("Non-persistent entity used in microflow: " + entity); + } + } catch (e) { + errors.push("Failed to check if entity is NPE: " + e); + } + } + } + // Determine final authorization decision + const allow = errors.length === 0; + + return { + allow, + errors + }; +} + diff --git a/resources/rules/001_0006_no_npe_in_microflow_test.yaml b/resources/rules/001_0006_no_npe_in_microflow_test.yaml new file mode 100644 index 0000000..a8a7819 --- /dev/null +++ b/resources/rules/001_0006_no_npe_in_microflow_test.yaml @@ -0,0 +1,33 @@ +TestCases: +- name: allow when Name is set + allow: true + input: + Name: MicroflowNonPersist + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$StartEvent + - $Type: Microflows$EndEvent + Documentation: "" + ReturnValue: "" + +- name: deny when NonPersistentEntity is used + allow: false + input: + Name: MicroflowNonPersist + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$StartEvent + - $Type: Microflows$EndEvent + Documentation: "" + ReturnValue: "" + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$CreateChangeAction + Commit: "No" + Entity: Module2.EntityNonPersist + ErrorHandlingType: Rollback + Items: [] + RefreshInClient: false + VariableName: NewEntityNonPersist \ No newline at end of file diff --git a/resources/rules/Module2/DomainModels$DomainModel.yaml b/resources/rules/Module2/DomainModels$DomainModel.yaml new file mode 100644 index 0000000..f7fdc48 --- /dev/null +++ b/resources/rules/Module2/DomainModels$DomainModel.yaml @@ -0,0 +1,7 @@ +$Type: DomainModels$DomainModel +Entities: + - $Type: DomainModels$EntityImpl + Name: EntityNonPersist + MaybeGeneralization: + $Type: DomainModels$NoGeneralization + Persistable: false