Skip to content

Commit 5a3af99

Browse files
akoclaude
andcommitted
feat: mxcli check --post-migration scans for legacy native widgets
After a Mendix major-version upgrade, Studio Pro does NOT auto-migrate native-stack widgets to their pluggable replacements. Authors are left to find them by hand. \`mxcli check --post-migration -p app.mpr\` now scans every page and snippet in a project and reports each occurrence of a deprecated native widget with a hint to the recommended pluggable equivalent. Implementation: - \`mdl/executor/keyword_dispatch.go\` gains a \`LegacyWidgets\` catalog — hand-maintained editorial data mapping a parsed Go widget type name (e.g. \"DataGrid\") to its BSON \$Type, the Mendix version where it was deprecated, and the recommended replacement. - \`cmd/mxcli/cmd_check_post_migration.go\` opens the project, walks every page and snippet via reflection (handles arbitrary widget containment without hard-coding container types), looks up each struct's type name against \`LegacyWidgets\`, and emits MDL-WIDGET02 violations. - Hierarchy resolution reuses \`executor.NewContainerHierarchy\` so reports include qualified module.document names. - Version-gated: a legacy widget that's still supported on the project's Mendix version is not flagged. Initial catalog entry: native \`Forms\$DataGrid\` deprecated from Mendix 11.0.0 in favor of pluggable Datagrid 2.x (the \`DATAGRID\` MDL keyword already resolves to this on 11.0+ via the dispatch table). Verified end-to-end against five existing Mendix projects (test5-app, test-1024-app, Evora-FactoryManagement, MxGraphStudioDemo, LatoProductInventory) — all clean (no native DataGrids on any of them, which is the expected modern state). Positive path covered by unit tests planting a \`*pages.DataGrid\` in a synthetic widget tree. This closes the last #540 acceptance checkbox. Three follow-ups filed for the remaining items: - #568 — version-aware per-property gating - #569 — BSON drift classification across .mpk upgrades - #570 — mxcli syntax see-also links to schema show Closes: #540 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c83ee5e commit 5a3af99

5 files changed

Lines changed: 360 additions & 0 deletions

File tree

cmd/mxcli/cmd_check.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,20 @@ for the module reference.
2929
3030
Output includes structured rule IDs (MDL prefix) for each validation issue.
3131
32+
Use --post-migration to scan an existing project (independent of the script)
33+
for legacy native widgets that have pluggable replacements — Studio Pro does
34+
not auto-migrate these on a Mendix major-version upgrade.
35+
3236
Examples:
3337
# Check syntax only (no project needed)
3438
mxcli check script.mdl
3539
3640
# Check syntax and validate references against a project
3741
mxcli check script.mdl -p app.mpr --references
3842
43+
# Scan the project for legacy native widgets after a Mendix upgrade
44+
mxcli check script.mdl -p app.mpr --post-migration
45+
3946
# Output as JSON or SARIF
4047
mxcli check script.mdl --format json
4148
mxcli check script.mdl --format sarif
@@ -45,6 +52,7 @@ Examples:
4552
filePath := args[0]
4653
projectPath, _ := cmd.Flags().GetString("project")
4754
checkRefs, _ := cmd.Flags().GetBool("references")
55+
postMigration, _ := cmd.Flags().GetBool("post-migration")
4856
format := resolveFormat(cmd, "text")
4957
isStructured := format != "" && format != "text"
5058

@@ -197,6 +205,39 @@ Examples:
197205
}
198206
}
199207

208+
// Post-migration scan: walk the project for native widgets that
209+
// have pluggable replacements (Studio Pro does not auto-migrate
210+
// these on a Mendix major-version upgrade).
211+
if postMigration {
212+
if projectPath == "" {
213+
fmt.Fprintln(os.Stderr, "Error: --project (-p) is required for --post-migration")
214+
os.Exit(1)
215+
}
216+
if !isStructured {
217+
fmt.Printf("\nScanning project for legacy native widgets: %s\n", projectPath)
218+
}
219+
legacyViolations, err := scanLegacyWidgets(projectPath)
220+
if err != nil {
221+
fmt.Fprintf(os.Stderr, "Error scanning project: %v\n", err)
222+
os.Exit(1)
223+
}
224+
if isStructured {
225+
formatter.Format(legacyViolations, os.Stderr)
226+
} else if len(legacyViolations) > 0 {
227+
fmt.Fprintln(os.Stderr)
228+
formatter.Format(legacyViolations, os.Stderr)
229+
fmt.Fprintf(os.Stderr, "\n✗ %d legacy widget(s) found\n", len(legacyViolations))
230+
} else {
231+
fmt.Printf("✓ No legacy native widgets found\n")
232+
}
233+
if len(legacyViolations) > 0 {
234+
summary := linter.Summarize(legacyViolations)
235+
if summary.Errors > 0 {
236+
os.Exit(1)
237+
}
238+
}
239+
}
240+
200241
if !isStructured {
201242
fmt.Println("\nCheck passed!")
202243
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
// Post-migration scan for legacy native widgets in a Mendix project.
4+
//
5+
// When a project is upgraded from Mendix 10.x to 11.x, Studio Pro does NOT
6+
// auto-rewrite native-stack widgets (e.g. Forms$DataGrid) to their pluggable
7+
// replacements. The author has to migrate them by hand. This scanner walks
8+
// every page and snippet, looks for legacy widget types listed in
9+
// executor.LegacyWidgets, and reports each occurrence with a hint to the
10+
// recommended pluggable equivalent.
11+
12+
package main
13+
14+
import (
15+
"fmt"
16+
"reflect"
17+
"sort"
18+
19+
"github.com/mendixlabs/mxcli/mdl/executor"
20+
"github.com/mendixlabs/mxcli/mdl/linter"
21+
"github.com/mendixlabs/mxcli/sdk/mpr"
22+
)
23+
24+
// legacyHit records one legacy widget occurrence with enough context to
25+
// produce a useful diagnostic.
26+
type legacyHit struct {
27+
Module string // qualified module name (may be empty if hierarchy resolution failed)
28+
Document string // page or snippet name
29+
DocKind string // "page" or "snippet"
30+
WidgetName string // widget instance name as authored in Studio Pro
31+
Entry *executor.LegacyWidget
32+
}
33+
34+
// scanLegacyWidgets opens the given .mpr, walks all pages and snippets, and
35+
// returns a violation for every legacy native widget found that's deprecated
36+
// on the project's Mendix version.
37+
func scanLegacyWidgets(projectPath string) ([]linter.Violation, error) {
38+
reader, err := mpr.Open(projectPath)
39+
if err != nil {
40+
return nil, fmt.Errorf("opening project: %w", err)
41+
}
42+
defer reader.Close()
43+
44+
mendixVersion, _ := reader.GetMendixVersion()
45+
46+
hierarchy, err := executor.NewContainerHierarchy(reader)
47+
if err != nil {
48+
return nil, fmt.Errorf("building project hierarchy: %w", err)
49+
}
50+
51+
var hits []legacyHit
52+
53+
pgs, err := reader.ListPages()
54+
if err != nil {
55+
return nil, fmt.Errorf("listing pages: %w", err)
56+
}
57+
for _, pg := range pgs {
58+
module := hierarchy.GetModuleName(hierarchy.FindModuleID(pg.ContainerID))
59+
walkForLegacyWidgets(reflect.ValueOf(pg), mendixVersion, func(entry *executor.LegacyWidget, name string) {
60+
hits = append(hits, legacyHit{
61+
Module: module, Document: pg.Name, DocKind: "page",
62+
WidgetName: name, Entry: entry,
63+
})
64+
})
65+
}
66+
67+
sns, err := reader.ListSnippets()
68+
if err != nil {
69+
return nil, fmt.Errorf("listing snippets: %w", err)
70+
}
71+
for _, sn := range sns {
72+
module := hierarchy.GetModuleName(hierarchy.FindModuleID(sn.ContainerID))
73+
walkForLegacyWidgets(reflect.ValueOf(sn), mendixVersion, func(entry *executor.LegacyWidget, name string) {
74+
hits = append(hits, legacyHit{
75+
Module: module, Document: sn.Name, DocKind: "snippet",
76+
WidgetName: name, Entry: entry,
77+
})
78+
})
79+
}
80+
81+
sortHits(hits)
82+
return hitsToViolations(hits, mendixVersion), nil
83+
}
84+
85+
// walkForLegacyWidgets recursively walks the reflect.Value of a parsed page
86+
// or snippet. Any struct whose Go type name matches a known legacy widget
87+
// triggers the callback.
88+
//
89+
// We match by `reflect.Type.Name()` (e.g. "DataGrid") rather than by type
90+
// assertion against an interface, because the parsed page widgets in
91+
// sdk/pages don't all implement a common Widget interface uniformly. Type
92+
// names are stable enough — the catalog (executor.LegacyWidgets) is small
93+
// and hand-maintained.
94+
func walkForLegacyWidgets(v reflect.Value, version string, visit func(*executor.LegacyWidget, string)) {
95+
for v.Kind() == reflect.Pointer {
96+
if v.IsNil() {
97+
return
98+
}
99+
v = v.Elem()
100+
}
101+
102+
switch v.Kind() {
103+
case reflect.Struct:
104+
if entry := executor.FindLegacyWidget(v.Type().Name()); entry != nil && entry.IsDeprecatedOnVersion(version) {
105+
visit(entry, widgetNameFrom(v))
106+
}
107+
for i := 0; i < v.NumField(); i++ {
108+
f := v.Field(i)
109+
if !f.CanInterface() {
110+
continue
111+
}
112+
walkForLegacyWidgets(f, version, visit)
113+
}
114+
case reflect.Slice, reflect.Array:
115+
for i := 0; i < v.Len(); i++ {
116+
walkForLegacyWidgets(v.Index(i), version, visit)
117+
}
118+
case reflect.Interface:
119+
if !v.IsNil() {
120+
walkForLegacyWidgets(v.Elem(), version, visit)
121+
}
122+
}
123+
}
124+
125+
// widgetNameFrom reads the Name field from an embedded BaseWidget if present.
126+
// Returns "" when no name is available.
127+
func widgetNameFrom(v reflect.Value) string {
128+
// BaseWidget.Name is reachable via field path on every widget struct.
129+
if f := v.FieldByName("BaseWidget"); f.IsValid() && f.Kind() == reflect.Struct {
130+
if n := f.FieldByName("Name"); n.IsValid() && n.Kind() == reflect.String {
131+
return n.String()
132+
}
133+
}
134+
// Fallback: direct Name field (some non-widget types).
135+
if n := v.FieldByName("Name"); n.IsValid() && n.Kind() == reflect.String {
136+
return n.String()
137+
}
138+
return ""
139+
}
140+
141+
// sortHits orders hits by module, document, widget for stable output.
142+
func sortHits(hits []legacyHit) {
143+
sort.Slice(hits, func(i, j int) bool {
144+
if hits[i].Module != hits[j].Module {
145+
return hits[i].Module < hits[j].Module
146+
}
147+
if hits[i].Document != hits[j].Document {
148+
return hits[i].Document < hits[j].Document
149+
}
150+
return hits[i].WidgetName < hits[j].WidgetName
151+
})
152+
}
153+
154+
// hitsToViolations converts legacy hits into linter violations with the
155+
// MDL-WIDGET02 rule code.
156+
func hitsToViolations(hits []legacyHit, version string) []linter.Violation {
157+
if len(hits) == 0 {
158+
return nil
159+
}
160+
out := make([]linter.Violation, 0, len(hits))
161+
for _, h := range hits {
162+
qualified := h.Document
163+
if h.Module != "" {
164+
qualified = h.Module + "." + h.Document
165+
}
166+
name := h.WidgetName
167+
if name == "" {
168+
name = "(unnamed)"
169+
}
170+
msg := fmt.Sprintf(
171+
"%s %s: widget `%s` uses deprecated native `%s` (deprecated from Mendix %s) — %s",
172+
h.DocKind, qualified, name, h.Entry.BSONType, h.Entry.DeprecatedFrom, h.Entry.Hint,
173+
)
174+
if version != "" {
175+
msg += fmt.Sprintf(" (project is on %s)", version)
176+
}
177+
out = append(out, linter.Violation{
178+
RuleID: "MDL-WIDGET02",
179+
Severity: linter.SeverityWarning,
180+
Message: msg,
181+
})
182+
}
183+
return out
184+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package main
4+
5+
import (
6+
"reflect"
7+
"testing"
8+
9+
"github.com/mendixlabs/mxcli/mdl/executor"
10+
"github.com/mendixlabs/mxcli/sdk/pages"
11+
)
12+
13+
// TestWalkForLegacyWidgets verifies the reflection-based walker finds a
14+
// DataGrid embedded at arbitrary depth in a parsed page widget tree.
15+
func TestWalkForLegacyWidgets(t *testing.T) {
16+
dg := &pages.DataGrid{}
17+
dg.Name = "GridA"
18+
19+
// Build a tree: outer DataView → controlBarWidgets list with a DataGrid.
20+
outer := &pages.DataView{
21+
Widgets: []pages.Widget{dg},
22+
}
23+
24+
var hits []string
25+
walkForLegacyWidgets(reflect.ValueOf(outer), "11.0.0", func(entry *executor.LegacyWidget, name string) {
26+
hits = append(hits, entry.GoTypeName+":"+name)
27+
})
28+
29+
if len(hits) != 1 {
30+
t.Fatalf("expected 1 hit, got %d (%v)", len(hits), hits)
31+
}
32+
if hits[0] != "DataGrid:GridA" {
33+
t.Errorf("expected DataGrid:GridA, got %s", hits[0])
34+
}
35+
}
36+
37+
// TestWalkForLegacyWidgets_NoFalsePositives confirms that a widget tree
38+
// without legacy widgets yields no hits.
39+
func TestWalkForLegacyWidgets_NoFalsePositives(t *testing.T) {
40+
outer := &pages.DataView{
41+
Widgets: []pages.Widget{},
42+
}
43+
var hits int
44+
walkForLegacyWidgets(reflect.ValueOf(outer), "11.0.0", func(*executor.LegacyWidget, string) {
45+
hits++
46+
})
47+
if hits != 0 {
48+
t.Errorf("expected 0 hits on empty DataView, got %d", hits)
49+
}
50+
}
51+
52+
// TestWalkForLegacyWidgets_VersionGate confirms the scanner skips legacy
53+
// widgets when the project version is BEFORE the deprecation cut-off.
54+
func TestWalkForLegacyWidgets_VersionGate(t *testing.T) {
55+
dg := &pages.DataGrid{}
56+
dg.Name = "GridA"
57+
outer := &pages.DataView{Widgets: []pages.Widget{dg}}
58+
59+
var hits int
60+
walkForLegacyWidgets(reflect.ValueOf(outer), "10.18.0", func(*executor.LegacyWidget, string) {
61+
hits++
62+
})
63+
if hits != 0 {
64+
t.Errorf("expected DataGrid to be allowed on Mendix 10.18; got %d hits", hits)
65+
}
66+
}

cmd/mxcli/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ func init() {
252252
// Check command flags
253253
checkCmd.Flags().BoolP("references", "r", false, "Validate references against the project")
254254
checkCmd.Flags().String("format", "text", "Output format: text, json, sarif")
255+
checkCmd.Flags().Bool("post-migration", false, "Scan the project for legacy native widgets that survived a Mendix upgrade (requires -p)")
255256

256257
// Diff command flags
257258
diffCmd.Flags().StringP("format", "f", "unified", "Output format: unified, side, struct")

0 commit comments

Comments
 (0)