Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/fix-issue/findings/mdl-executor.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -714,3 +714,4 @@
{"area":"mdl/executor","date":"2026-09-25","symptom":"`alter page FeedbackModule.ShareFeedback_Logo { insert after textBox1 { image zzImg (ImageType: imageUrl, ImageUrl: '{1}', ImageUrlParams: [{1} = ImageB64]) } }` passed `check --references`; exec wrote a bare AttributeRef and `mx check` could not LOAD the project (ArgumentNullException setting 'Attribute'). Same at page top level outside any data container; a text box's `Attribute:` there is silently dropped (CE7005).","cause":"ALTER's entity context comes from the STORED document (nearest enclosing data source, or a flow source's return type via resolveDataSourceFlowEntity). With the flow missing (Feedback v4.0.2 ships no DS_FeedbackForm) or no container at all, entityContext is \"\" and resolveAttributePath returns the bare name. CREATE PAGE refused this at check time (relaxExcludedWidgetRefs/unscopedBindings, #678); ALTER's check never opened the document, so nothing could know the scope.","file":"mdl/executor/validate_alter_unscoped.go, mdl/executor/cmd_alter_page.go (alterEntityContext), mdl/executor/validate.go (bindingsWithoutScope)","insight":"For ALTER, scope is a property of the stored document, not the statement: a check-time question about it must open the document (OpenPageForMutation, never Save; validate_alter_set.go already does this) and ask through the SAME function exec uses, so the INSERT/REPLACE entity resolution was lifted into alterEntityContext rather than restated, and the binding walk lifted out of unscopedBindings (bindingsWithoutScope) rather than copied. Controls that keep it from blocking working scripts: skip a target the stored doc lacks (added earlier in the script), a flow the script declares with an entity return (sc.flowParams), DataGrid2 column and list-view-template paths, and documents the script creates. Reproduce with a Studio Pro-authored page whose flow is genuinely absent.","refs":["#678","#685"]}
{"area": "mdl/executor", "date": "2026-09-25", "symptom": "`describe page` on a File Uploader (files mode) emits `DataSource: association …`, and exec of that output fails: \"widget `upFiles` (fileuploader) exposes 2 datasources, so a generic `datasource:` clause is ambiguous — name the one you mean: associatedFiles, associatedImages\"", "cause": "DESCRIBE chose generic vs named by counting CONFIGURED datasources (namedCustomWidgetDataSources drops unset ones, so files mode = 1), while the builder's refuseAmbiguousGenericDataSource counts DECLARED datasource mappings (a generated def maps every top-level datasource = 2). Two sides of one round trip deciding the same question from different evidence.", "file": "mdl/executor/cmd_pages_describe_parse.go", "insight": "When describe and build each decide 'is this ambiguous?', they must count the same set. Fix read the DECLARED count from the stored schema (PropertyTypes with ValueType.Type=DataSource, excluding IsLinked) and excluded widgets with an embedded .def.json — those are hand-written and pick one datasource mapping per mode, so a database-mode ComboBox (two declared) must keep the generic clause. The #956 bug-test script itself authored the refused generic clause on a File Uploader: a bug-test that only runs `mxcli check` without a project can't see an exec-time refusal, so grep bug-tests for the old spelling whenever a builder starts refusing one.", "refs": ["mendixlabs/mxcli#1199", "mendixlabs/mxcli#956", "mendixlabs/mxcli#1109"], "rules": []}
{"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe output that does not re-parse or loses data (ako/mxcli#707): an entity string default or validation message containing ' was emitted unescaped; so were module-role descriptions, published OData/REST Path/Version/Namespace/Summary/Folder, and REST client BaseUrl/Path/header values; an agent `mcp service` block with a Description lacked the comma after `Enabled`; workflow decision / parallel split captions came back only as `-- caption` comments (replay reset them to 'Decision' / 'Parallel split'); `describe demo user` emitted `password '***'`, which replay stored as the password; `describe settings` printed `DatabasePassword = '<plaintext>'`.", "cause": "Hand-rolled `'%s'` emit sites that put the quotes and the escaping in different places (the #1006 source-scan guard covered only cmd_workflows.go); a block emitter with no separator logic, unlike its sibling; captions treated as commentary although the grammar has `comment '…'` for both activities; secrets printed as data, with a placeholder the writer took literally.", "file": "mdl/executor/cmd_entities_describe.go, cmd_security.go, cmd_security_write.go, cmd_odata.go, cmd_published_rest.go, cmd_rest_clients.go, cmd_agenteditor_agents.go, cmd_workflows.go, cmd_settings.go", "fix": "Every emit site uses mdlQuoted; TestDescribers_HaveNoHandRolledStringLiterals now scans all seven describer files. MCP block writes the comma like the tool block. workflowCaptionClauses emits `comment '…'` for a non-default caption and computes the name clause against the caption the writer will store. DatabasePassword is omitted with a comment (create or modify is a patch, so replay keeps it). Demo users are described as `create or modify … password '***'`, and the executor treats '***' as 'keep the stored password', refusing it for a user that does not exist.", "insight": "Assert round trips by reparsing describe output with the real visitor and comparing the AST value to the stored one, not by substring. For secrets the right placeholder is one the WRITER understands: omission works where the create is a patch (configuration); where the grammar requires the value (demo user) give the placeholder a meaning (keep stored) and refuse it where that meaning is empty, so a replay can neither leak nor silently set a credential.", "test": "mdl/executor/issue707_describe_roundtrip_test.go"}
{"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe microflow \u2026 with handles (ako/mxcli#713) printed no handle for an activity inside an `on error { \u2026 }` block, and the alter-target resolver counted such activities after the whole main flow: on SUB_Feedback_PostToAppInsights `return * @1` picked `return $Response`, although describe prints the handler's `return empty` first. Also `$Response` did not address a REST call whose output is on its result handling (and cast, create list, web service, workflow, XML/JSON, database-query outputs).", "cause": "Error-handler bodies are rendered by collectErrorHandlerStatements, a second describer that returned bare strings and never wrote the source map, so those nodes had no line to rank or print a handle at; unranked candidates were appended last. The output-variable switch was copied from actionOutputVariableName, which had drifted from the formatter.", "file": "mdl/executor/cmd_microflows_show_helpers.go; mdl/backend/mfmutator/target.go", "fix": "collectErrorHandlerStatementSpans reports each handler-body object's statement span; emitActivityStatement and emitCommentedErrorHandler record them in the source map (additive entries in ELK sourceMap too). mfmutator.OutputVariable reads the variable where the formatter does, for every action it prints as `$X = \u2026`. A comment rendering (`-- Unsupported \u2026`) is no statement, so it never becomes a handle.", "insight": "Any node the describer prints through a side path must enter the source map, or everything keyed on print order (ordinals, handles, ELK highlighting) silently disagrees with the text. Check ranking against a Studio Pro flow that has a handler body, not just VAL_Feedback.", "test": "TestDescribeWithHandles_ErrorHandlerBody, TestMicroflowTargets_PedAppEveryFlowRanksAndResolves, TestOutputVariable_EveryActionDescribePrintsAnAssignmentFor, TestCandidate_CommentRenderingIsNoStatement"}
1 change: 1 addition & 0 deletions cmd/mxcli/lsp_completions_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions cmd/mxcli/syntax/features_microflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,37 @@ func init() {
SeeAlso: []string{"microflow.merge-join", "microflow.control-flow"},
})

Register(SyntaxFeature{
Path: "microflow.describe-handles",
Summary: "DESCRIBE MICROFLOW ... WITH HANDLES: print each activity's content address",
Keywords: []string{
"handles", "handle", "describe", "target", "address", "alter microflow",
"ordinal", "content addressing", "wildcard",
},
Syntax: "DESCRIBE MICROFLOW Module.Name WITH HANDLES;\n\n" +
"-- Prints '-- handle: <target>' above each activity: the address that\n" +
"-- selects it in an ALTER MICROFLOW target (ADR-0012). Activities have no\n" +
"-- names, so a target names one by content, in this order of preference:\n" +
"-- $Var the activity whose output variable is $Var\n" +
"-- 'Caption' a split, or an activity with a custom caption\n" +
"-- commit $Order a statement pattern; * matches any run of\n" +
"-- log * node 'Debug' * tokens, and the pattern spans the WHOLE\n" +
"-- statement (end with * to match a prefix)\n" +
"-- A target matching several activities is an error that lists each with\n" +
"-- its ordinal (@1, @2, ... in describe order); it is never a guess.\n" +
"-- The handles are comments, so the output still executes unchanged.\n" +
"-- Cannot be combined with NORMALIZED, whose graph is not the stored one.",
Example: "DESCRIBE MICROFLOW FeedbackModule.VAL_Feedback WITH HANDLES;\n\n" +
"-- emits, among others:\n" +
"-- -- handle: $IsValidEmail\n" +
"-- $IsValidEmail = call java action FeedbackModule.ValidateEmail(...);\n" +
"-- -- handle: 'Email is Valid?'\n" +
"-- if not($IsValidEmail) then\n" +
"-- -- handle: set $ValidFeedback = false @3\n" +
"-- set $ValidFeedback = false;",
SeeAlso: []string{"microflow.normalized-describe"},
})

Register(SyntaxFeature{
Path: "microflow.merge-join",
Summary: "Named join points: MERGE <label> and JOIN <label>",
Expand Down
1 change: 1 addition & 0 deletions docs/01-project/MDL_QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ rather than updating the first.
| Show nanoflows | `show nanoflows [in module];` | List all or filter by module |
| Describe microflow | `describe microflow Module.Name;` | Full MDL with activities |
| Describe microflow (normalized) | `describe microflow Module.Name normalized;` | Folds crossed branches into one condition instead of flattening them. Opt-in: the output re-executes to an equivalent graph with fewer nodes and a different layout |
| Describe microflow (with handles) | `describe microflow Module.Name with handles;` | Prints `-- handle: <target>` above each activity: its content address for `alter microflow` — output `$Var`, `'Caption'`, or a statement pattern with `*` wildcards (anchored at both ends), plus `@n` when several match. Comments only; cannot be combined with `normalized` |
| Describe nanoflow | `describe nanoflow Module.Name;` | Full MDL with activities |
| Rename microflow | `rename microflow Module.Old to New;` | Updates all references |
| Rename nanoflow | `rename nanoflow Module.Old to New;` | Updates all references |
Expand Down
5 changes: 5 additions & 0 deletions mdl/ast/ast_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,11 @@ type DescribeStmt struct {
// and silently reshaping someone's diagram because they asked to read it
// is its own guard-don't-drop violation.
Normalized bool
// WithHandles makes DESCRIBE MICROFLOW print, above each activity, the
// content address an `alter microflow` target would use for it (output
// variable, caption or statement, with an ordinal when needed). The
// handles are comments, so the output still executes unchanged.
WithHandles bool
}

func (s *DescribeStmt) isStatement() {}
Expand Down
192 changes: 192 additions & 0 deletions mdl/backend/mfmutator/resolve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: Apache-2.0

package mfmutator

import (
"fmt"
"sort"
"strings"
)

// NotFoundError reports a target that addresses no activity.
type NotFoundError struct {
Target Target
// Hint lists what the target could have meant, e.g. the output variables
// the flow does have. May be empty.
Hint string
}

func (e *NotFoundError) Error() string {
msg := fmt.Sprintf("no activity matches %s %s", e.Target.Kind, e.Target.Text)
if e.Hint != "" {
msg += "; " + e.Hint
}
return msg
}

// AmbiguousError reports a target that addresses more than one activity and
// carries no ordinal (or an ordinal past the last match). It lists each match
// under the address that selects it.
type AmbiguousError struct {
Target Target
Matches []Candidate
}

func (e *AmbiguousError) Error() string {
var b strings.Builder
if e.Target.Ordinal > len(e.Matches) {
fmt.Fprintf(&b, "%s %s @%d: there are only %d matches:", e.Target.Kind, e.Target.Text, e.Target.Ordinal, len(e.Matches))
} else {
fmt.Fprintf(&b, "%s %s matches %d activities; add an ordinal to choose one:", e.Target.Kind, e.Target.Text, len(e.Matches))
}
for i, c := range e.Matches {
fmt.Fprintf(&b, "\n %s @%d -- %s", e.Target.Text, i+1, describeCandidate(c))
}
return b.String()
}

// Resolve returns the one candidate t addresses. Zero matches is a
// NotFoundError; more than one without an ordinal, or an ordinal past the
// last match, is an AmbiguousError. It never picks among matches on its own.
func Resolve(cands []Candidate, t Target) (Candidate, error) {
idx := matchIndices(cands, t)
switch {
case len(idx) == 0:
return Candidate{}, &NotFoundError{Target: t, Hint: notFoundHint(cands, t)}
case t.Ordinal > len(idx):
return Candidate{}, &AmbiguousError{Target: t, Matches: pick(cands, idx)}
case t.Ordinal > 0:
return cands[idx[t.Ordinal-1]], nil
case len(idx) == 1:
return cands[idx[0]], nil
default:
return Candidate{}, &AmbiguousError{Target: t, Matches: pick(cands, idx)}
}
}

// ResolveText parses text as a target and resolves it.
func ResolveText(cands []Candidate, text string) (Candidate, error) {
t, err := ParseTarget(text)
if err != nil {
return Candidate{}, err
}
return Resolve(cands, t)
}

func matchIndices(cands []Candidate, t Target) []int {
var idx []int
for i := range cands {
if cands[i].matches(t) {
idx = append(idx, i)
}
}
return idx
}

func pick(cands []Candidate, idx []int) []Candidate {
out := make([]Candidate, len(idx))
for i, j := range idx {
out[i] = cands[j]
}
return out
}

// notFoundHint names the addresses of the same kind that do exist, which is
// usually enough to spot a typo, and says when an anchored pattern only
// failed because the statement goes on.
func notFoundHint(cands []Candidate, t Target) string {
switch t.Kind {
case ByOutputVariable:
return listHint("output variables", cands, func(c Candidate) string {
if c.OutputVariable == "" {
return ""
}
return "$" + c.OutputVariable
})
case ByCaption:
return listHint("captions", cands, func(c Candidate) string {
if c.Caption == "" {
return ""
}
return quote(c.Caption)
})
default:
open := append(append([]Token{}, t.Pattern...), Token{Kind: TokStar, Text: "*"})
for i := range cands {
if cands[i].matchesPattern(open) {
return fmt.Sprintf("patterns match the whole statement: end it with * to match %q", cands[i].Statement)
}
}
return ""
}
}

func listHint(what string, cands []Candidate, name func(Candidate) string) string {
seen := map[string]bool{}
var names []string
for _, c := range cands {
if n := name(c); n != "" && !seen[n] {
seen[n] = true
names = append(names, n)
}
}
if len(names) == 0 {
return "the microflow has no " + what
}
sort.Strings(names)
return "the microflow's " + what + " are " + strings.Join(names, ", ")
}

// Handle returns the preferred address of cands[i]: its output variable, else
// its caption, else its statement — the first of those that selects it alone.
// When none is unique it returns the first available form with the ordinal
// that selects it. It returns "" for a candidate nothing can address (an end
// event describe renders as nothing).
//
// Handles are what `describe … with handles` prints, so each one is checked
// by resolving it rather than assumed: a statement containing `*` (a
// multiplication) reads back as a wildcard pattern and may select more than
// its own activity.
func Handle(cands []Candidate, i int) string {
c := cands[i]
var forms []string
if c.OutputVariable != "" {
forms = append(forms, "$"+c.OutputVariable)
}
if c.Caption != "" && !strings.ContainsAny(c.Caption, "\r\n") {
// A handle is printed on one comment line; a multi-line caption
// cannot be, so such an activity is addressed by its statement.
forms = append(forms, quote(c.Caption))
}
if c.Statement != "" {
forms = append(forms, c.Statement)
}

fallback := ""
for _, form := range forms {
t, err := ParseTarget(form)
if err != nil || t.Ordinal != 0 {
// A statement ending in `@<digits>` would read back with an
// ordinal; it cannot serve as its own handle.
continue
}
idx := matchIndices(cands, t)
pos := -1
for k, j := range idx {
if j == i {
pos = k
break
}
}
if pos < 0 {
continue
}
if len(idx) == 1 {
return form
}
if fallback == "" {
fallback = fmt.Sprintf("%s @%d", form, pos+1)
}
}
return fallback
}
Loading
Loading