Skip to content

Commit 13b2938

Browse files
engalarclaude
andcommitted
feat: diag --check-units + grammar compatibility fixes
diag --check-units: - New command to detect orphan units (DB record, no file) and stale mxunit files (file exists, no DB record) - --fix flag auto-removes stale files - bson dump --format bson output format Grammar fixes for MDL baseline scripts: - Add DISPLAY, STRUCTURE to enumValueName rule - Support String(unlimited) syntax in dataType - Add ATTRIBUTE to attributeName rule - Support parenthesized CREATE ASSOCIATION with optional colons - Add SEND, REQUEST, TABLETWIDTH, PHONEWIDTH keywords Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ca65c96 commit 13b2938

13 files changed

Lines changed: 18333 additions & 11214 deletions

cmd/mxcli/cmd_bson_dump.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ Examples:
4444
4545
# Save dump to file
4646
mxcli bson dump -p app.mpr --type page --object "PgTest.MyPage" > mypage.json
47+
48+
# Extract raw BSON baseline for roundtrip testing
49+
mxcli bson dump -p app.mpr --type page --object "PgTest.MyPage" --format bson > mypage.mxunit
4750
`,
4851
Run: func(cmd *cobra.Command, args []string) {
4952
projectPath, _ := cmd.Flags().GetString("project")
@@ -136,6 +139,12 @@ Examples:
136139
os.Exit(1)
137140
}
138141

142+
if format == "bson" {
143+
// Write raw BSON bytes to stdout (for baseline extraction)
144+
os.Stdout.Write(obj.Contents)
145+
return
146+
}
147+
139148
if format == "ndsl" {
140149
var doc bson.D
141150
if err := bson.Unmarshal(obj.Contents, &doc); err != nil {
@@ -342,5 +351,5 @@ func init() {
342351
bsonDumpCmd.Flags().StringP("object", "o", "", "Object qualified name to dump (e.g., Module.PageName)")
343352
bsonDumpCmd.Flags().BoolP("list", "l", false, "List all objects of the specified type")
344353
bsonDumpCmd.Flags().StringSliceP("compare", "c", nil, "Compare two objects: --compare Obj1,Obj2")
345-
bsonDumpCmd.Flags().String("format", "json", "Output format: json, ndsl")
354+
bsonDumpCmd.Flags().String("format", "json", "Output format: json, ndsl, bson (raw bytes)")
346355
}

cmd/mxcli/diag.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"time"
1717

1818
"github.com/mendixlabs/mxcli/mdl/diaglog"
19+
"github.com/mendixlabs/mxcli/sdk/mpr"
1920
"github.com/spf13/cobra"
2021
)
2122

@@ -47,6 +48,18 @@ Examples:
4748
return
4849
}
4950

51+
checkUnits, _ := cmd.Flags().GetBool("check-units")
52+
fix, _ := cmd.Flags().GetBool("fix")
53+
if checkUnits {
54+
projectPath, _ := cmd.Flags().GetString("project")
55+
if projectPath == "" {
56+
fmt.Fprintln(os.Stderr, "Error: --check-units requires -p <project.mpr>")
57+
os.Exit(1)
58+
}
59+
runCheckUnits(projectPath, fix)
60+
return
61+
}
62+
5063
if tail > 0 {
5164
runDiagTail(logDir, tail)
5265
return
@@ -60,6 +73,8 @@ func init() {
6073
diagCmd.Flags().Bool("log-path", false, "Print log directory path")
6174
diagCmd.Flags().Bool("bundle", false, "Create tar.gz with logs for bug reports")
6275
diagCmd.Flags().Int("tail", 0, "Show last N log entries")
76+
diagCmd.Flags().Bool("check-units", false, "Check for orphan units and stale mxunit files (MPR v2)")
77+
diagCmd.Flags().Bool("fix", false, "Auto-fix issues found by --check-units")
6378
}
6479

6580
// runDiagInfo shows diagnostic summary.
@@ -252,3 +267,79 @@ func formatBytes(b int64) string {
252267
}
253268
return fmt.Sprintf("%d KB", b/1024)
254269
}
270+
271+
// runCheckUnits checks for orphan units (Unit table entry without mxunit file)
272+
// and stale mxunit files (file exists but no Unit table entry). MPR v2 only.
273+
func runCheckUnits(mprPath string, fix bool) {
274+
reader, err := mpr.Open(mprPath)
275+
if err != nil {
276+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
277+
os.Exit(1)
278+
}
279+
defer reader.Close()
280+
281+
contentsDir := reader.ContentsDir()
282+
if contentsDir == "" {
283+
fmt.Println("Not an MPR v2 project (no mprcontents directory)")
284+
return
285+
}
286+
287+
// Build set of unit UUIDs from database
288+
unitIDs, err := reader.ListAllUnitIDs()
289+
if err != nil {
290+
fmt.Fprintf(os.Stderr, "Error listing units: %v\n", err)
291+
os.Exit(1)
292+
}
293+
unitSet := make(map[string]bool, len(unitIDs))
294+
for _, id := range unitIDs {
295+
unitSet[id] = true
296+
}
297+
298+
// Scan mxunit files
299+
files, err := filepath.Glob(filepath.Join(contentsDir, "*", "*", "*.mxunit"))
300+
if err != nil {
301+
fmt.Fprintf(os.Stderr, "Error scanning mxunit files: %v\n", err)
302+
os.Exit(1)
303+
}
304+
fileSet := make(map[string]string, len(files)) // uuid → filepath
305+
for _, f := range files {
306+
uuid := strings.TrimSuffix(filepath.Base(f), ".mxunit")
307+
fileSet[uuid] = f
308+
}
309+
310+
// Check for orphan units (in DB but no file)
311+
orphans := 0
312+
for _, id := range unitIDs {
313+
if _, ok := fileSet[id]; !ok {
314+
fmt.Printf("ORPHAN UNIT: %s (in Unit table but no mxunit file)\n", id)
315+
orphans++
316+
}
317+
}
318+
319+
// Check for stale files (file exists but not in DB)
320+
stale := 0
321+
for uuid, fpath := range fileSet {
322+
if !unitSet[uuid] {
323+
fmt.Printf("STALE FILE: %s\n", uuid)
324+
stale++
325+
if fix {
326+
if err := os.Remove(fpath); err != nil {
327+
fmt.Fprintf(os.Stderr, " ERROR removing: %v\n", err)
328+
} else {
329+
fmt.Printf(" REMOVED: %s\n", fpath)
330+
// Clean empty parent dirs
331+
dir2 := filepath.Dir(fpath)
332+
os.Remove(dir2)
333+
dir1 := filepath.Dir(dir2)
334+
os.Remove(dir1)
335+
}
336+
}
337+
}
338+
}
339+
340+
fmt.Printf("\nSummary: %d units in DB, %d mxunit files, %d orphans, %d stale\n",
341+
len(unitIDs), len(files), orphans, stale)
342+
if stale > 0 && !fix {
343+
fmt.Println("Run with --fix to auto-remove stale files")
344+
}
345+
}

mdl/grammar/MDLLexer.g4

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,9 @@ INPUTREFERENCESETSELECTOR: I N P U T R E F E R E N C E S E T S E L E C T O R;
260260
FILEINPUT: F I L E I N P U T;
261261
IMAGEINPUT: I M A G E I N P U T;
262262

263-
// Custom/Filter widgets
263+
// Custom/Filter/Pluggable widgets
264264
CUSTOMWIDGET: C U S T O M W I D G E T;
265+
PLUGGABLEWIDGET: P L U G G A B L E W I D G E T;
265266
TEXTFILTER: T E X T F I L T E R;
266267
NUMBERFILTER: N U M B E R F I L T E R;
267268
DROPDOWNFILTER: D R O P D O W N F I L T E R;
@@ -316,6 +317,8 @@ COLLECTION: C O L L E C T I O N;
316317
STATICIMAGE: S T A T I C I M A G E;
317318
DYNAMICIMAGE: D Y N A M I C I M A G E;
318319
CUSTOMCONTAINER: C U S T O M C O N T A I N E R;
320+
TABCONTAINER: T A B C O N T A I N E R;
321+
TABPAGE: T A B P A G E;
319322
GROUPBOX: G R O U P B O X;
320323
VISIBLE: V I S I B L E;
321324
SAVECHANGES: S A V E C H A N G E S;

mdl/grammar/MDLParser.g4

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,8 @@ alterLayoutMapping
201201
;
202202

203203
alterPageAssignment
204-
: identifierOrKeyword EQUALS propertyValueV3 // Caption = 'Save'
204+
: DATASOURCE EQUALS dataSourceExprV3 // DataSource = SELECTION widgetName
205+
| identifierOrKeyword EQUALS propertyValueV3 // Caption = 'Save'
205206
| STRING_LITERAL EQUALS propertyValueV3 // 'showLabel' = false
206207
;
207208

@@ -554,6 +555,7 @@ attributeName
554555
: IDENTIFIER
555556
| QUOTED_IDENTIFIER // Escape any reserved word ("Range", `Order`)
556557
| commonNameKeyword
558+
| ATTRIBUTE // Allow 'Attribute' as attribute name
557559
;
558560

559561
attributeConstraint
@@ -596,7 +598,7 @@ attributeConstraint
596598
* ```
597599
*/
598600
dataType
599-
: STRING_TYPE (LPAREN NUMBER_LITERAL RPAREN)?
601+
: STRING_TYPE (LPAREN (NUMBER_LITERAL | IDENTIFIER) RPAREN)?
600602
| INTEGER_TYPE
601603
| LONG_TYPE
602604
| DECIMAL_TYPE
@@ -624,7 +626,7 @@ templateContext
624626

625627
// Non-list data type - used for createObjectStatement to avoid matching "CREATE LIST OF"
626628
nonListDataType
627-
: STRING_TYPE (LPAREN NUMBER_LITERAL RPAREN)?
629+
: STRING_TYPE (LPAREN (NUMBER_LITERAL | IDENTIFIER) RPAREN)?
628630
| INTEGER_TYPE
629631
| LONG_TYPE
630632
| DECIMAL_TYPE
@@ -665,16 +667,20 @@ createAssociationStatement
665667
FROM qualifiedName
666668
TO qualifiedName
667669
associationOptions?
670+
| ASSOCIATION qualifiedName LPAREN
671+
FROM qualifiedName TO qualifiedName
672+
(COMMA associationOption)*
673+
RPAREN
668674
;
669675

670676
associationOptions
671677
: associationOption+
672678
;
673679

674680
associationOption
675-
: TYPE (REFERENCE | REFERENCE_SET)
676-
| OWNER (DEFAULT | BOTH)
677-
| STORAGE (COLUMN | TABLE)
681+
: TYPE COLON? (REFERENCE | REFERENCE_SET)
682+
| OWNER COLON? (DEFAULT | BOTH)
683+
| STORAGE COLON? (COLUMN | TABLE)
678684
| DELETE_BEHAVIOR deleteBehavior
679685
| COMMENT STRING_LITERAL
680686
;
@@ -771,6 +777,7 @@ enumValueName
771777
| SERVICE | SERVICES // OData/auth keywords used as enum values
772778
| GUEST | SESSION | BASIC | CLIENT | CLIENTS
773779
| PUBLISH | EXPOSE | EXTERNAL | PAGING | HEADERS
780+
| DISPLAY | STRUCTURE // Layout/structure keywords used as enum values
774781
;
775782

776783
enumerationOptions
@@ -1780,6 +1787,8 @@ useFragmentRef
17801787
// V3 Widget: WIDGET name (Props) { children }
17811788
widgetV3
17821789
: widgetTypeV3 IDENTIFIER widgetPropertiesV3? widgetBodyV3?
1790+
| PLUGGABLEWIDGET STRING_LITERAL IDENTIFIER widgetPropertiesV3? widgetBodyV3? // PLUGGABLEWIDGET 'widget.id' name
1791+
| CUSTOMWIDGET STRING_LITERAL IDENTIFIER widgetPropertiesV3? widgetBodyV3? // CUSTOMWIDGET 'widget.id' name (legacy)
17831792
;
17841793

17851794
// V3 Widget types (same as V2)
@@ -1822,6 +1831,8 @@ widgetTypeV3
18221831
| STATICIMAGE
18231832
| DYNAMICIMAGE
18241833
| CUSTOMCONTAINER
1834+
| TABCONTAINER
1835+
| TABPAGE
18251836
| GROUPBOX
18261837
;
18271838

@@ -1862,6 +1873,7 @@ widgetPropertyV3
18621873
| EDITABLE COLON propertyValueV3 // Editable: Never | Always
18631874
| TOOLTIP COLON propertyValueV3 // Tooltip: 'text'
18641875
| IDENTIFIER COLON propertyValueV3 // Generic: any other property
1876+
| keyword COLON propertyValueV3 // Generic: keyword as property name (for pluggable widgets)
18651877
;
18661878

18671879
// Filter type values - handle keywords like CONTAINS that are also filter types
@@ -2573,7 +2585,7 @@ widgetTypeKeyword
25732585
| COMBOBOX | DYNAMICTEXT | ACTIONBUTTON | LINKBUTTON | DATAVIEW
25742586
| LISTVIEW | DATAGRID | GALLERY | LAYOUTGRID | IMAGE | STATICIMAGE
25752587
| DYNAMICIMAGE | HEADER | FOOTER | SNIPPETCALL | NAVIGATIONLIST
2576-
| CUSTOMCONTAINER | DROPDOWN | REFERENCESELECTOR | GROUPBOX
2588+
| CUSTOMCONTAINER | TABCONTAINER | TABPAGE | DROPDOWN | REFERENCESELECTOR | GROUPBOX
25772589
| IDENTIFIER
25782590
;
25792591

@@ -3228,7 +3240,7 @@ keyword
32283240
| ACTIONBUTTON | CHECKBOX | COMBOBOX | CONTROLBAR | DATAGRID | DATAVIEW // Widget keywords
32293241
| DATEPICKER | DYNAMICTEXT | GALLERY | LAYOUTGRID | LINKBUTTON | LISTVIEW
32303242
| NAVIGATIONLIST | RADIOBUTTONS | SEARCHBAR | SNIPPETCALL | TEXTAREA | TEXTBOX
3231-
| IMAGE | STATICIMAGE | DYNAMICIMAGE | CUSTOMCONTAINER | GROUPBOX
3243+
| IMAGE | STATICIMAGE | DYNAMICIMAGE | CUSTOMCONTAINER | TABCONTAINER | TABPAGE | GROUPBOX
32323244
| HEADER | FOOTER | IMAGEINPUT
32333245
| VERSION | TIMEOUT | PATH | PUBLISH | PUBLISHED | EXPOSE | NAMESPACE_KW | SOURCE_KW | CONTRACT | CHANNELS | MESSAGES // OData/AsyncAPI keywords
32343246
| SESSION | GUEST | BASIC | AUTHENTICATION | ODATA | SERVICE | CLIENT | CLIENTS | SERVICES

mdl/grammar/parser/MDLLexer.interp

Lines changed: 10 additions & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)