codeDiagnostic(CodeDiagnosticRequest param) {
return CompletableFuture.supplyAsync(() -> {
- DOMDocument xmlDocument = Utils.getDOMDocument(param.getCode(), uriResolverExtensionManager);
+ // Use the real file path (when supplied) as the document URI. Several diagnostics are
+ // gated on the document path — e.g. SynapseExpressionValidator only runs for files under
+ // src/main/wso2mi/artifacts — so the literal "temp" fallback would silently drop them.
+ String uri = param.getFileName() != null ? param.getFileName() : "temp";
+ DOMDocument xmlDocument = Utils.getDOMDocument(param.getCode(), uri, uriResolverExtensionManager);
return doDiagnostics(xmlDocument, NULL_CANCEL_CHECKER);
});
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/CodeDiagnosticRequest.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/CodeDiagnosticRequest.java
index 029d7027a..fe934de43 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/CodeDiagnosticRequest.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/CodeDiagnosticRequest.java
@@ -17,6 +17,7 @@
public class CodeDiagnosticRequest {
private String code;
+ private String fileName;
public String getCode() {
@@ -27,4 +28,14 @@ public void setCode(String code) {
this.code = code;
}
+
+ public String getFileName() {
+
+ return fileName;
+ }
+
+ public void setFileName(String fileName) {
+
+ this.fileName = fileName;
+ }
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java
index 98f2d8049..bafb8e2d3 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java
@@ -270,7 +270,24 @@ public static DOMDocument getDOMDocument(String content) {
public static DOMDocument getDOMDocument(String content, URIResolverExtensionManager resolverExtensionManager) {
- TextDocument document = new TextDocument(content, "temp");
+ return getDOMDocument(content, "temp", resolverExtensionManager);
+ }
+
+ /**
+ * Get the DOM document from the given xml content, using the provided URI as the document's
+ * system id. The URI matters for diagnostics that are gated on the document path (e.g.
+ * SynapseExpressionValidator only runs for files under src/main/wso2mi/artifacts), so callers
+ * that have a real file path should pass it instead of relying on the "temp" fallback.
+ *
+ * @param content the xml content
+ * @param uri the URI to assign to the parsed document
+ * @param resolverExtensionManager the URI resolver extension manager
+ * @return the DOM document for the given xml content
+ */
+ public static DOMDocument getDOMDocument(String content, String uri,
+ URIResolverExtensionManager resolverExtensionManager) {
+
+ TextDocument document = new TextDocument(content, uri);
return DOMParser.getInstance().parse(document, resolverExtensionManager);
}
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/CodeDiagnosticFileNameTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/CodeDiagnosticFileNameTest.java
new file mode 100644
index 000000000..552aa38a0
--- /dev/null
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/CodeDiagnosticFileNameTest.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com).
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * WSO2 LLC - support for WSO2 Micro Integrator Configuration
+ */
+
+package org.eclipse.lemminx.extensions.synapse;
+
+import org.eclipse.lemminx.AbstractCacheBasedTest;
+import org.eclipse.lemminx.XMLAssert;
+import org.eclipse.lemminx.customservice.synapse.utils.Utils;
+import org.eclipse.lemminx.dom.DOMDocument;
+import org.eclipse.lemminx.extensions.contentmodel.settings.ContentModelSettings;
+import org.eclipse.lemminx.extensions.contentmodel.settings.XMLValidationRootSettings;
+import org.eclipse.lemminx.services.XMLLanguageService;
+import org.eclipse.lsp4j.Diagnostic;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for the {@code synapse/codeDiagnostic} document-URI plumbing.
+ *
+ * The MI Copilot agent validates in-memory (unsaved) code via {@code synapse/codeDiagnostic}.
+ * Before the fix this path parsed the code with the literal URI {@code "temp"}, which made the
+ * URI-gated {@code SynapseExpressionValidator} (it only runs for files under
+ * {@code src/main/wso2mi/artifacts}) silently skip every expression diagnostic. The fix lets the
+ * caller supply the real file name as the document URI (with {@code "temp"} as the backward
+ * compatible fallback).
+ *
+ *
These tests exercise the exact pipeline {@code SynapseLanguageService.codeDiagnostic()} runs
+ * internally — {@code Utils.getDOMDocument(code, uri, resolver)} followed by
+ * {@code XMLLanguageService.doDiagnostics(...)} — and assert the operator-precedence warning is
+ * surfaced only when the URI is under the artifacts path.
+ */
+public class CodeDiagnosticFileNameTest extends AbstractCacheBasedTest {
+
+ private static final String SYNAPSE_NS = "http://ws.apache.org/ns/synapse";
+ private static final String SYNAPSE_CATALOG_440 =
+ "src/main/resources/org/eclipse/lemminx/schemas/440/catalog.xml";
+ // An absolute path under the project artifacts directory, as the MI extension sends.
+ private static final String ARTIFACT_URI =
+ "/home/proj/src/main/wso2mi/artifacts/sequences/Test.xml";
+
+ // with an unparenthesized comparison/logical mix — the precedence pitfall.
+ private static final String UNPARENTHESIZED = ""
+ + ""
+ + "";
+ // The corrected, explicitly parenthesized form.
+ private static final String PARENTHESIZED = ""
+ + ""
+ + "";
+
+ /**
+ * Reproduces the codeDiagnostic pipeline: build the DOM document with the given URI (via the
+ * fileName-aware overload the fix added), then run the full XML diagnostics pipeline.
+ */
+ private List codeDiagnostic(String code, String fileName) {
+ XMLLanguageService ls = new XMLLanguageService();
+ String uri = fileName != null ? fileName : "temp";
+ DOMDocument document = Utils.getDOMDocument(code, uri, ls.getResolverExtensionManager());
+ ls.setDocumentProvider(u -> document);
+
+ ContentModelSettings settings = new ContentModelSettings();
+ settings.setUseCache(false);
+ XMLValidationRootSettings validation = new XMLValidationRootSettings();
+ validation.setNoGrammar("ignore");
+ settings.setValidation(validation);
+ settings.setCatalogs(new String[]{SYNAPSE_CATALOG_440});
+ ls.doSave(new XMLAssert.SettingsSaveContext(settings));
+
+ return ls.doDiagnostics(document, settings.getValidation(), Collections.emptyMap(), () -> {});
+ }
+
+ private List precedenceWarnings(List diagnostics) {
+ return diagnostics.stream()
+ .filter(d -> d.getMessage() != null && d.getMessage().contains("Operator precedence"))
+ .collect(Collectors.toList());
+ }
+
+ @Test
+ public void testPrecedenceWarningReturnedForArtifactsPath() {
+ // With a real artifacts file name, the expression validator runs and reports the warning.
+ List diags = codeDiagnostic(UNPARENTHESIZED, ARTIFACT_URI);
+ assertFalse(precedenceWarnings(diags).isEmpty(),
+ "codeDiagnostic with an artifacts file name should surface the operator-precedence warning");
+ }
+
+ @Test
+ public void testNoPrecedenceWarningForParenthesizedExpression() {
+ // The corrected, parenthesized form must not be flagged even on the artifacts path.
+ List diags = codeDiagnostic(PARENTHESIZED, ARTIFACT_URI);
+ assertTrue(precedenceWarnings(diags).isEmpty(),
+ "Parenthesized comparisons should produce no operator-precedence warning");
+ }
+
+ @Test
+ public void testTempFallbackDocumentsUnchangedBehavior() {
+ // Backward compatibility: a null file name falls back to "temp", which is not under the
+ // artifacts path, so the expression validator stays disabled (unchanged pre-fix behavior).
+ List diags = codeDiagnostic(UNPARENTHESIZED, null);
+ assertTrue(precedenceWarnings(diags).isEmpty(),
+ "The \"temp\" fallback keeps the expression validator disabled, as before the fix");
+ }
+
+ // ===== URI plumbing unit checks (deterministic, no validation pipeline) =====
+
+ @Test
+ public void testGetDOMDocumentUsesProvidedUri() {
+ DOMDocument document = Utils.getDOMDocument(UNPARENTHESIZED, ARTIFACT_URI, null);
+ assertEquals(ARTIFACT_URI, document.getDocumentURI(),
+ "The 3-arg overload should assign the supplied URI to the document");
+ }
+
+ @Test
+ public void testGetDOMDocumentDefaultStillUsesTemp() {
+ // The legacy overloads (which delegate with no URI) must keep the "temp" URI unchanged.
+ DOMDocument document = Utils.getDOMDocument(UNPARENTHESIZED);
+ assertEquals("temp", document.getDocumentURI(),
+ "The legacy overloads should keep the \"temp\" URI for backward compatibility");
+ }
+
+ @Test
+ public void testCodeDiagnosticRequestCarriesFileName() {
+ org.eclipse.lemminx.customservice.synapse.CodeDiagnosticRequest request =
+ new org.eclipse.lemminx.customservice.synapse.CodeDiagnosticRequest();
+ request.setCode(UNPARENTHESIZED);
+ request.setFileName(ARTIFACT_URI);
+ assertEquals(UNPARENTHESIZED, request.getCode());
+ assertEquals(ARTIFACT_URI, request.getFileName(),
+ "CodeDiagnosticRequest must carry the fileName sent by the extension");
+ }
+}
From 27ecb313e0d647751e1239fa4436c8e25fbbe843 Mon Sep 17 00:00:00 2001
From: Isuru Wijesiri
Date: Fri, 19 Jun 2026 16:48:07 +0530
Subject: [PATCH 17/34] Flag unclosed ${ expression delimiters
An opening "${" with no matching "}" (e.g. expression="${payload.count > 0")
was silently accepted: SynapseExpressionValidator only validates a value that
both starts with "${" and ends with "}", so an unterminated expression is
treated as a plain string and the malformed ${} boundary is never checked.
Add an UnclosedExpression warning in SynapseDiagnosticsParticipant that scans
attribute values and leaf element text for a "${" with no matching "}". It runs
for any Synapse document (so both the editor's didOpen flow and the
synapse/codeDiagnostic path are covered) and skips raw-code (script) bodies.
The detector is false-positive-safe: the expression grammar has no {/} tokens
of its own (indexing uses [ ]), so the only braces inside ${...} are within
string literals -- the scan ignores those, so a valid expression such as
${concat('}', x)} is correctly recognized as closed.
Adds tests covering unclosed in attributes and text, the closed and {${...}}
forms, a brace inside a string literal, and the script-body exclusion.
---
.../SynapseDiagnosticsParticipant.java | 101 ++++++++++++++++++
.../SynapseDiagnosticsParticipantTest.java | 68 ++++++++++++
2 files changed, 169 insertions(+)
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipant.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipant.java
index 31023eb74..658793b3d 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipant.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipant.java
@@ -236,6 +236,10 @@ private void validateElement(DOMNode node, List diagnostics, DOMDocu
// Validate variable references in expression attributes
validateVariableReferences(element, diagnostics, document, definedVariables);
+ // Flag an opening "${" with no matching "}" (the malformed expression is otherwise
+ // treated as a plain string and reaches runtime with no feedback)
+ validateUnclosedExpressions(element, diagnostics, document);
+
// Cross-file reference validation
if (knownArtifacts != null) {
validateCrossReferences(element, diagnostics, knownArtifacts);
@@ -1250,6 +1254,103 @@ private void checkContentForUndefinedVariables(String content, Range range,
}
}
+ /**
+ * Flags an opening "${" that is never closed by a matching "}". Such a value is not recognized
+ * as a Synapse expression (it is treated as a plain string), so the malformed expression would
+ * otherwise reach runtime with no feedback. Scans attribute values and leaf element text,
+ * mirroring the variable-reference checks; raw-code elements (see {@link #RAW_TEXT_ELEMENTS})
+ * are skipped to avoid false positives.
+ */
+ private void validateUnclosedExpressions(DOMElement element, List diagnostics,
+ DOMDocument document) {
+ List attrs = element.getAttributeNodes();
+ if (attrs != null) {
+ for (DOMAttr attr : attrs) {
+ String attrValue = attr.getValue();
+ if (attrValue != null && attrValue.contains("${") && hasUnclosedExpression(attrValue)) {
+ reportUnclosedExpression(XMLPositionUtility.selectAttributeValue(attr), diagnostics);
+ }
+ }
+ }
+
+ if (hasChildElements(element)) {
+ return;
+ }
+ String localName = element.getLocalName();
+ if (localName != null && RAW_TEXT_ELEMENTS.contains(localName.toLowerCase())) {
+ return;
+ }
+ List children = element.getChildren();
+ if (children == null) {
+ return;
+ }
+ for (DOMNode child : children) {
+ if (!child.isText()) {
+ continue;
+ }
+ String text = child.getTextContent();
+ if (text != null && text.contains("${") && hasUnclosedExpression(text)) {
+ reportUnclosedExpression(
+ XMLPositionUtility.createRange(child.getStart(), child.getEnd(), document), diagnostics);
+ }
+ }
+ }
+
+ private void reportUnclosedExpression(Range range, List diagnostics) {
+ if (range == null) {
+ return;
+ }
+ Diagnostic d = new Diagnostic();
+ d.setRange(range);
+ d.setMessage("Unclosed expression: '${' is not terminated by a matching '}'. " +
+ "Synapse expressions must be written as ${...} — add the missing '}'.");
+ d.setSeverity(DiagnosticSeverity.Warning);
+ d.setSource(SOURCE);
+ d.setCode("UnclosedExpression");
+ diagnostics.add(d);
+ }
+
+ /**
+ * Returns true if {@code value} contains an opening "${" with no matching "}" closing it.
+ * Synapse expressions have no "{"/"}" tokens of their own (indexing uses "[" "]"), so the only
+ * braces that can appear inside ${...} are within string literals — which are skipped here, so a
+ * valid expression such as {@code ${concat('{', x)}} is not mistaken for unclosed.
+ */
+ private boolean hasUnclosedExpression(String value) {
+ int open = value.indexOf("${");
+ while (open >= 0) {
+ if (!hasClosingBrace(value, open + 2)) {
+ return true;
+ }
+ open = value.indexOf("${", open + 2);
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if there is a '}' at or after {@code from} that lies outside any string literal.
+ */
+ private boolean hasClosingBrace(String value, int from) {
+ boolean inString = false;
+ char quote = 0;
+ for (int i = from; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if (inString) {
+ if (c == '\\') {
+ i++; // skip the escaped character
+ } else if (c == quote) {
+ inString = false;
+ }
+ } else if (c == '"' || c == '\'') {
+ inString = true;
+ quote = c;
+ } else if (c == '}') {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Validate cross-file references (key, target, onError attributes).
*/
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipantTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipantTest.java
index 9fb188398..e5fe5f91e 100644
--- a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipantTest.java
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipantTest.java
@@ -538,6 +538,74 @@ public void testPlainTextWithoutExpressionNoWarning() {
assertTrue(diags.isEmpty(), "Plain text without a ${...} expression should not warn");
}
+ // ===== Unclosed expression delimiters =====
+
+ @Test
+ public void testUnclosedExpressionInAttributeWarns() {
+ // '${' opened but never closed — previously treated as a plain string with no feedback.
+ String xml = ""
+ + " 0\"/>"
+ + "";
+ List diags = diagnosticsWithCode(diagnose(xml), "UnclosedExpression");
+ assertEquals(1, diags.size(), "An unclosed ${ in an attribute should warn");
+ assertEquals(DiagnosticSeverity.Warning, diags.get(0).getSeverity());
+ assertTrue(diags.get(0).getMessage().contains("Unclosed expression"));
+ }
+
+ @Test
+ public void testClosedExpressionInAttributeNoWarning() {
+ String xml = ""
+ + " 0}\"/>"
+ + "";
+ List diags = diagnosticsWithCode(diagnose(xml), "UnclosedExpression");
+ assertTrue(diags.isEmpty(), "A properly closed ${...} must not be flagged");
+ }
+
+ @Test
+ public void testUnclosedExpressionInElementTextWarns() {
+ // Same gap in element text (e.g. a connector operation parameter).
+ String xml = ""
+ + ""
+ + "${payload.count
"
+ + ""
+ + "";
+ List diags = diagnosticsWithCode(diagnose(xml), "UnclosedExpression");
+ assertEquals(1, diags.size(), "An unclosed ${ in element text should warn");
+ assertTrue(diags.get(0).getMessage().contains("Unclosed expression"));
+ }
+
+ @Test
+ public void testWrappedClosedExpressionNoWarning() {
+ // The {${...}} form, properly closed, must not be flagged as unclosed.
+ String xml = ""
+ + ""
+ + "{${vars.q}}
"
+ + "";
+ List diags = diagnosticsWithCode(diagnose(xml), "UnclosedExpression");
+ assertTrue(diags.isEmpty(), "A closed {${...}} expression must not be flagged");
+ }
+
+ @Test
+ public void testExpressionWithBraceInStringLiteralNoWarning() {
+ // A '}' inside a string literal must not be mistaken for the closing delimiter, and the
+ // real closing '}' must still be recognized — so this valid expression is not flagged.
+ String xml = ""
+ + ""
+ + "";
+ List diags = diagnosticsWithCode(diagnose(xml), "UnclosedExpression");
+ assertTrue(diags.isEmpty(), "A brace inside a string literal must not cause a false positive");
+ }
+
+ @Test
+ public void testScriptBodyUnclosedExpressionNotFlagged() {
+ // Raw-code (script) bodies are excluded — a ${ in JS is not a Synapse expression.
+ String xml = ""
+ + ""
+ + "";
+ List diags = diagnosticsWithCode(diagnose(xml), "UnclosedExpression");
+ assertTrue(diags.isEmpty(), "Script bodies must not be scanned for unclosed expressions");
+ }
+
// ===== Non-Synapse document skipping =====
@Test
From d9d24154821694ded1a12e3226e9e08acc972006 Mon Sep 17 00:00:00 2001
From: Isuru Wijesiri
Date: Tue, 23 Jun 2026 12:02:57 +0530
Subject: [PATCH 18/34] Add skipCrossFileValidation opt-out and refresh stale
cross-file index
Two related fixes for the agent's per-file validation via synapse/codeDiagnostic,
where cross-file reference checks were firing spuriously.
1) Opt-in skipCrossFileValidation flag (default off) on synapse/codeDiagnostic.
The agent validates a file right after writing it, before the sibling
artifacts it references exist, so cross-file checks (UnresolvedArtifactReference,
UnresolvedConfigKeyReference, DuplicateArtifactName, UnknownTemplateParameter,
CircularArtifactReference, ...) misfire. The flag lets the agent suppress only
those checks; the editor and the explicit "validate all" path never set it and
are unchanged.
- CodeDiagnosticRequest: add boolean skipCrossFileValidation (default false).
- SynapseDiagnosticsParticipant: a thread-confined flag (set/cleared by
codeDiagnostic around doDiagnostics). When set, the run skips
buildArtifactNameIndex (so the knownArtifacts-gated checks and the filesystem
scan are skipped) and the two cross-file checks not gated on that index
(validateCallTemplateParams, validateDuplicateArtifactName). The skip path
mutates no shared index state, so concurrent editor validations are
unaffected; within-file checks (schema, expression/precedence,
UndefinedVariable, ...) keep running.
- SynapseLanguageService.codeDiagnostic(): set the flag from the request and
clear it in a finally block.
2) Refresh the cached cross-file artifact index when project files change.
The index is cached per project with a ~5s TTL, so just after a file is
written/saved the cached index can omit it, wrongly flagging a sibling that
exists on disk as unresolved (affects the editor and "validate all" too).
- SynapseDiagnosticsParticipant: a static epoch counter and
invalidateArtifactIndexCache(); each cache entry records its build epoch and
is honored only while that epoch still matches, so a bump forces a rebuild on
the next run even within the TTL.
- XMLWorkspaceService.didChangeWatchedFiles and XMLTextDocumentService.didSave:
bump the epoch when a changed/saved file under src/main/wso2mi is seen,
covering external/agent writes (file watcher) and editor saves.
Tests: skip=true drops UnresolvedArtifactReference while keeping the within-file
UndefinedVariable; default (false) still flags it; the request flag defaults
false; and a stale-index reference resolves after the sibling is written and the
cache is invalidated.
---
.../lemminx/SynapseLanguageService.java | 16 +-
.../lemminx/XMLTextDocumentService.java | 9 +-
.../eclipse/lemminx/XMLWorkspaceService.java | 7 +
.../synapse/CodeDiagnosticRequest.java | 17 ++
.../SynapseDiagnosticsParticipant.java | 81 ++++++++-
.../validator/SynapseExpressionValidator.java | 11 +-
.../synapse/CodeDiagnosticFileNameTest.java | 13 +-
.../SynapseDiagnosticsParticipantTest.java | 163 ++++++++++++++++++
8 files changed, 304 insertions(+), 13 deletions(-)
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/SynapseLanguageService.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/SynapseLanguageService.java
index 80741016c..328ba14ae 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/SynapseLanguageService.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/SynapseLanguageService.java
@@ -150,6 +150,7 @@
import org.eclipse.lemminx.customservice.synapse.idp.PdfToImagesRequest;
import org.eclipse.lemminx.dom.DOMDocument;
import org.eclipse.lemminx.extensions.contentmodel.settings.XMLValidationSettings;
+import org.eclipse.lemminx.extensions.synapse.SynapseDiagnosticsParticipant;
import org.eclipse.lemminx.services.extensions.completion.ICompletionResponse;
import org.eclipse.lemminx.settings.SharedSettings;
import org.eclipse.lemminx.uriresolver.URIResolverExtensionManager;
@@ -349,9 +350,18 @@ public CompletableFuture codeDiagnostic(CodeDiagnostic
// Use the real file path (when supplied) as the document URI. Several diagnostics are
// gated on the document path — e.g. SynapseExpressionValidator only runs for files under
// src/main/wso2mi/artifacts — so the literal "temp" fallback would silently drop them.
- String uri = param.getFileName() != null ? param.getFileName() : "temp";
- DOMDocument xmlDocument = Utils.getDOMDocument(param.getCode(), uri, uriResolverExtensionManager);
- return doDiagnostics(xmlDocument, NULL_CANCEL_CHECKER);
+ // Treat a blank fileName as missing, otherwise an unusable URI would skip those checks.
+ String uri = StringUtils.isBlank(param.getFileName()) ? "temp" : param.getFileName();
+ // Opt-out (default off) for cross-file reference checks: the agent validates a file
+ // before its referenced siblings are written, so those checks would fire spuriously.
+ // Set/clear around doDiagnostics on this thread; the editor never sets it.
+ try {
+ SynapseDiagnosticsParticipant.setSkipCrossFileValidation(param.isSkipCrossFileValidation());
+ DOMDocument xmlDocument = Utils.getDOMDocument(param.getCode(), uri, uriResolverExtensionManager);
+ return doDiagnostics(xmlDocument, NULL_CANCEL_CHECKER);
+ } finally {
+ SynapseDiagnosticsParticipant.clearSkipCrossFileValidation();
+ }
});
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
index 4c742b6ab..f75e3e65e 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
@@ -39,6 +39,7 @@
import org.eclipse.lemminx.dom.DOMDocument;
import org.eclipse.lemminx.dom.DOMParser;
import org.eclipse.lemminx.extensions.contentmodel.settings.XMLValidationRootSettings;
+import org.eclipse.lemminx.extensions.synapse.SynapseDiagnosticsParticipant;
import org.eclipse.lemminx.services.DocumentSymbolsResult;
import org.eclipse.lemminx.services.SymbolInformationResult;
import org.eclipse.lemminx.services.XMLLanguageService;
@@ -599,7 +600,13 @@ public CompletableFuture> colorPresentation(ColorPresent
public void didSave(DidSaveTextDocumentParams params) {
computeAsync((monitor) -> {
// A document was saved, collect documents to revalidate
- SaveContext context = new SaveContext(params.getTextDocument().getUri());
+ String savedUri = params.getTextDocument().getUri();
+ if (savedUri != null && savedUri.contains("src/main/wso2mi")) {
+ // An artifact/resource file was saved — drop the cached cross-file index so the
+ // revalidation below (and sibling files) sees the updated set instead of stale data.
+ SynapseDiagnosticsParticipant.invalidateArtifactIndexCache();
+ }
+ SaveContext context = new SaveContext(savedUri);
doSave(context);
// Manage didSave document lifecycle participants
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java
index f866168c8..e9d07c133 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java
@@ -21,6 +21,7 @@
import org.eclipse.lemminx.commons.WorkspaceFolders;
import org.eclipse.lemminx.customservice.synapse.utils.Constant;
+import org.eclipse.lemminx.extensions.synapse.SynapseDiagnosticsParticipant;
import org.eclipse.lemminx.services.extensions.commands.IXMLCommandService;
import org.eclipse.lsp4j.DidChangeConfigurationParams;
import org.eclipse.lsp4j.DidChangeWatchedFilesParams;
@@ -99,6 +100,12 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) {
} else if (change.getUri().contains(Constant.CONNECTORS) && change.getUri().contains(".zip")) {
((SynapseLanguageService) xmlLanguageServer.getSynapseLanguageService()).updateConnectors();
} else {
+ if (change.getUri().contains("src/main/wso2mi")) {
+ // An artifact/resource file changed on disk — drop the cached cross-file index so
+ // the next diagnostics run rebuilds it (otherwise a just-written sibling stays
+ // "unresolved" for up to the cache TTL).
+ SynapseDiagnosticsParticipant.invalidateArtifactIndexCache();
+ }
if (!xmlTextDocumentService.documentIsOpen(change.getUri())) {
xmlTextDocumentService.doSave(change.getUri());
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/CodeDiagnosticRequest.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/CodeDiagnosticRequest.java
index fe934de43..189eddd65 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/CodeDiagnosticRequest.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/CodeDiagnosticRequest.java
@@ -18,6 +18,7 @@ public class CodeDiagnosticRequest {
private String code;
private String fileName;
+ private boolean skipCrossFileValidation;
public String getCode() {
@@ -38,4 +39,20 @@ public void setFileName(String fileName) {
this.fileName = fileName;
}
+
+ /**
+ * When true, cross-file reference checks (which depend on other artifact files existing) are
+ * skipped for this request. Defaults to false, so the editor and the explicit "validate all"
+ * path are unaffected. The MI Copilot agent sets it for per-file auto-validation after a write,
+ * where a referenced sibling artifact may not exist on disk yet.
+ */
+ public boolean isSkipCrossFileValidation() {
+
+ return skipCrossFileValidation;
+ }
+
+ public void setSkipCrossFileValidation(boolean skipCrossFileValidation) {
+
+ this.skipCrossFileValidation = skipCrossFileValidation;
+ }
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipant.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipant.java
index 658793b3d..fd2fd9afa 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipant.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipant.java
@@ -47,6 +47,7 @@
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
@@ -72,6 +73,20 @@ public class SynapseDiagnosticsParticipant implements IDiagnosticsParticipant {
private static final long ARTIFACT_CACHE_TTL_MS = 5000; // 5 seconds
private final Map artifactIndexCache = new ConcurrentHashMap<>();
+ /**
+ * Invalidation signal for {@link #artifactIndexCache}. The cache is keyed per project with a
+ * short TTL; when an artifact/resource file under {@code src/main/wso2mi} changes, the language
+ * server bumps this epoch so the next diagnostics run rebuilds the index instead of serving a
+ * stale entry (which would wrongly flag a just-written sibling as unresolved for up to the TTL).
+ * A cache entry is only honored while its stored epoch matches the current one.
+ */
+ private static final AtomicLong artifactCacheEpoch = new AtomicLong();
+
+ /** Invalidate the cross-file artifact index cache (call when project artifact files change). */
+ public static void invalidateArtifactIndexCache() {
+ artifactCacheEpoch.incrementAndGet();
+ }
+
/** Template name -> absolute file path, populated during artifact index building. */
private volatile Map templateFilePaths = java.util.Collections.emptyMap();
/** Artifact names that appear in multiple files (duplicates). */
@@ -79,6 +94,28 @@ public class SynapseDiagnosticsParticipant implements IDiagnosticsParticipant {
/** Artifact names that participate in direct circular references (A->B->A). */
private volatile Set cyclicArtifacts = java.util.Collections.emptySet();
+ /**
+ * Request-scoped opt-out for cross-file (other-artifact-dependent) checks. The MI Copilot agent
+ * sets this for per-file auto-validation after a write, when sibling artifacts it references may
+ * not exist on disk yet, to suppress transient false positives. It is thread-confined and set by
+ * {@code SynapseLanguageService.codeDiagnostic()} around the {@code doDiagnostics} call; the
+ * editor/manual flows never set it, so cross-file validation stays on by default.
+ */
+ private static final ThreadLocal SKIP_CROSS_FILE_VALIDATION =
+ ThreadLocal.withInitial(() -> Boolean.FALSE);
+
+ public static void setSkipCrossFileValidation(boolean skip) {
+ SKIP_CROSS_FILE_VALIDATION.set(skip);
+ }
+
+ public static void clearSkipCrossFileValidation() {
+ SKIP_CROSS_FILE_VALIDATION.remove();
+ }
+
+ private static boolean isSkipCrossFileValidation() {
+ return Boolean.TRUE.equals(SKIP_CROSS_FILE_VALIDATION.get());
+ }
+
private static final Set SYNAPSE_ROOT_ELEMENTS = new HashSet<>(Arrays.asList(
"api", "proxy", "endpoint", "sequence", "inboundEndpoint", "template",
"task", "localEntry", "messageStore", "messageProcessor", "registry"
@@ -158,8 +195,13 @@ public class SynapseDiagnosticsParticipant implements IDiagnosticsParticipant {
"api", "proxy", "sequence", "inboundEndpoint", "resource"
));
+ // Synchronized because a single participant instance is registered in SynapsePlugin and
+ // diagnostics can run concurrently (editor validation and codeDiagnostic both execute async).
+ // The cross-file index is derived into shared instance fields per run, so serializing here keeps
+ // one request from clearing/overwriting that state while another is still validating. Runs are
+ // short (the project scan is cached), so the contention cost is minimal.
@Override
- public void doDiagnostics(DOMDocument xmlDocument, List diagnostics,
+ public synchronized void doDiagnostics(DOMDocument xmlDocument, List diagnostics,
XMLValidationSettings validationSettings, CancelChecker cancelChecker) {
DOMElement root = xmlDocument.getDocumentElement();
if (root == null) {
@@ -173,7 +215,18 @@ public void doDiagnostics(DOMDocument xmlDocument, List diagnostics,
if (SYNAPSE_NS.equals(namespace)) {
// Valid Synapse file — run all validations
Set definedVariables = new HashSet<>();
- Set knownArtifacts = buildArtifactNameIndex(xmlDocument, cancelChecker);
+ // Cross-file reference checks depend on the project-wide artifact index. When the caller
+ // opts out (agent per-file validation) the index is not built, avoiding the filesystem
+ // scan; it is also null when the project path is not derivable. In either case the
+ // index is unavailable, so clear the derived cross-file state — otherwise a stale index
+ // from a prior request could surface template/duplicate/cycle diagnostics here.
+ boolean skipCrossFile = isSkipCrossFileValidation();
+ Set knownArtifacts = skipCrossFile ? null : buildArtifactNameIndex(xmlDocument, cancelChecker);
+ if (knownArtifacts == null) {
+ this.templateFilePaths = java.util.Collections.emptyMap();
+ this.duplicateArtifactNames = java.util.Collections.emptySet();
+ this.cyclicArtifacts = java.util.Collections.emptySet();
+ }
// Detect MI runtime version for new-pattern hints
String projectPath = deriveProjectPath(xmlDocument);
@@ -965,6 +1018,8 @@ private void validateScriptMediator(DOMElement element, List diagnos
* P1-14: Validate call-template with-param names against template parameter declarations.
*/
private void validateCallTemplateParams(DOMElement element, List diagnostics) {
+ // Cross-file check: depends on the referenced template file (cycles + parameter declarations)
+ if (isSkipCrossFileValidation()) return;
String target = element.getAttribute("target");
if (StringUtils.isEmpty(target) || isExpression(target)) return;
@@ -1071,6 +1126,7 @@ private Map parseTemplateParameters(String filePath) {
* P1-19: Warn if the current document's root artifact name is duplicated in the project.
*/
private void validateDuplicateArtifactName(DOMElement root, List diagnostics) {
+ if (isSkipCrossFileValidation()) return; // cross-file check: needs the project-wide index
if (duplicateArtifactNames.isEmpty()) return;
String name = root.getAttribute("name");
if (name != null && duplicateArtifactNames.contains(name)) {
@@ -1196,7 +1252,9 @@ private void validateVariableReferencesInText(DOMElement element, List) commonly wrap
+ // ${vars.x} references in .
+ if (!child.isText() && !child.isCDATA()) {
continue;
}
String text = child.getTextContent();
@@ -1285,7 +1343,8 @@ private void validateUnclosedExpressions(DOMElement element, List di
return;
}
for (DOMNode child : children) {
- if (!child.isText()) {
+ // Include CDATA: ${...} can appear inside payloads too.
+ if (!child.isText() && !child.isCDATA()) {
continue;
}
String text = child.getTextContent();
@@ -1521,9 +1580,14 @@ private Set buildArtifactNameIndex(DOMDocument document, CancelChecker c
return null;
}
+ // Read the invalidation epoch up front: a cache entry built before the latest file change
+ // is treated as a miss even within its TTL, so a just-written sibling is picked up at once.
+ long epoch = artifactCacheEpoch.get();
+
// Check cache first
CachedArtifactIndex cached = artifactIndexCache.get(projectPath);
- if (cached != null && (System.currentTimeMillis() - cached.timestamp) < ARTIFACT_CACHE_TTL_MS) {
+ if (cached != null && cached.epoch == epoch
+ && (System.currentTimeMillis() - cached.timestamp) < ARTIFACT_CACHE_TTL_MS) {
// Restore all derived state so cross-reference checks see the same
// template paths, duplicates, and cycles as a fresh build would.
this.templateFilePaths = cached.templateFilePaths;
@@ -1568,7 +1632,7 @@ private Set buildArtifactNameIndex(DOMDocument document, CancelChecker c
this.duplicateArtifactNames = duplicates;
this.cyclicArtifacts = cycles;
artifactIndexCache.put(projectPath, new CachedArtifactIndex(
- artifactNames, templatePaths, duplicates, cycles, System.currentTimeMillis()));
+ artifactNames, templatePaths, duplicates, cycles, System.currentTimeMillis(), epoch));
return artifactNames;
}
@@ -2372,17 +2436,20 @@ private static class CachedArtifactIndex {
final Set duplicateArtifactNames;
final Set cyclicArtifacts;
final long timestamp;
+ final long epoch;
CachedArtifactIndex(Set artifactNames,
Map templateFilePaths,
Set duplicateArtifactNames,
Set cyclicArtifacts,
- long timestamp) {
+ long timestamp,
+ long epoch) {
this.artifactNames = artifactNames;
this.templateFilePaths = templateFilePaths;
this.duplicateArtifactNames = duplicateArtifactNames;
this.cyclicArtifacts = cyclicArtifacts;
this.timestamp = timestamp;
+ this.epoch = epoch;
}
}
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/validator/SynapseExpressionValidator.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/validator/SynapseExpressionValidator.java
index 6f6af2573..1800df10a 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/validator/SynapseExpressionValidator.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/synapse/validator/SynapseExpressionValidator.java
@@ -64,7 +64,16 @@ public void startDocument(XMLLocator locator, String encoding, NamespaceContext
private boolean isFileInArtifacts(String baseSystemId) {
- return baseSystemId.contains(TryOutConstants.PROJECT_ARTIFACT_PATH.toString());
+ if (baseSystemId == null) {
+ return false;
+ }
+ // Compare with forward slashes so the check holds on every OS: the document system id is
+ // typically a file:// URI (always '/'), while PROJECT_ARTIFACT_PATH.toString() uses the
+ // platform separator ('\' on Windows) — without normalizing, the gate would never match on
+ // Windows and expression validation would silently not run there.
+ String normalizedId = baseSystemId.replace('\\', '/');
+ String artifactsPath = TryOutConstants.PROJECT_ARTIFACT_PATH.toString().replace('\\', '/');
+ return normalizedId.contains(artifactsPath);
}
@Override
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/CodeDiagnosticFileNameTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/CodeDiagnosticFileNameTest.java
index 552aa38a0..51315537e 100644
--- a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/CodeDiagnosticFileNameTest.java
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/CodeDiagnosticFileNameTest.java
@@ -52,7 +52,8 @@ public class CodeDiagnosticFileNameTest extends AbstractCacheBasedTest {
private static final String SYNAPSE_NS = "http://ws.apache.org/ns/synapse";
private static final String SYNAPSE_CATALOG_440 =
"src/main/resources/org/eclipse/lemminx/schemas/440/catalog.xml";
- // An absolute path under the project artifacts directory, as the MI extension sends.
+ // An absolute path under the project artifacts directory, as the MI extension sends. The
+ // SynapseExpressionValidator gate is separator-agnostic, so this forward-slash URI works on all OSes.
private static final String ARTIFACT_URI =
"/home/proj/src/main/wso2mi/artifacts/sequences/Test.xml";
@@ -144,4 +145,14 @@ public void testCodeDiagnosticRequestCarriesFileName() {
assertEquals(ARTIFACT_URI, request.getFileName(),
"CodeDiagnosticRequest must carry the fileName sent by the extension");
}
+
+ @Test
+ public void testCodeDiagnosticRequestSkipCrossFileDefaultsFalse() {
+ org.eclipse.lemminx.customservice.synapse.CodeDiagnosticRequest request =
+ new org.eclipse.lemminx.customservice.synapse.CodeDiagnosticRequest();
+ assertFalse(request.isSkipCrossFileValidation(),
+ "skipCrossFileValidation must default to false so the editor/validate-all paths are unchanged");
+ request.setSkipCrossFileValidation(true);
+ assertTrue(request.isSkipCrossFileValidation());
+ }
}
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipantTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipantTest.java
index e5fe5f91e..585b37d47 100644
--- a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipantTest.java
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/synapse/SynapseDiagnosticsParticipantTest.java
@@ -606,6 +606,33 @@ public void testScriptBodyUnclosedExpressionNotFlagged() {
assertTrue(diags.isEmpty(), "Script bodies must not be scanned for unclosed expressions");
}
+ // ===== CDATA payloads are scanned too =====
+
+ @Test
+ public void testUndefinedVariableInCdataWarns() {
+ // ${vars.x} inside a CDATA payload (e.g. payloadFactory format) must still be validated.
+ String xml = ""
+ + ""
+ + ""
+ + ""
+ + "";
+ List diags = diagnosticsWithCode(diagnose(xml), "UndefinedVariable");
+ assertEquals(1, diags.size(), "Undefined variable referenced inside CDATA should warn");
+ assertTrue(diags.get(0).getMessage().contains("missingCdata"));
+ }
+
+ @Test
+ public void testUnclosedExpressionInCdataWarns() {
+ // An unclosed ${ inside a CDATA payload must still be flagged.
+ String xml = ""
+ + ""
+ + ""
+ + ""
+ + "";
+ List diags = diagnosticsWithCode(diagnose(xml), "UnclosedExpression");
+ assertEquals(1, diags.size(), "Unclosed ${ inside CDATA should warn");
+ }
+
// ===== Non-Synapse document skipping =====
@Test
@@ -1656,6 +1683,7 @@ public void restoreUserHome() {
originalUserHome = null;
}
SynapseLanguageService.setLoadedResourceFinder(null);
+ SynapseDiagnosticsParticipant.clearSkipCrossFileValidation();
}
/**
@@ -1744,4 +1772,139 @@ public void testUnknownSequenceStillFlaggedWithDependencies(@TempDir Path tempDi
assertEquals(1, unresolved.size());
assertTrue(unresolved.get(0).getMessage().contains("reallyDoesNotExist"));
}
+
+ // ===== skipCrossFileValidation opt-out (Change 1) =====
+
+ /** As {@link #diagnoseAtPath(String, Path)} but with the request-scoped cross-file opt-out set. */
+ private List diagnoseAtPath(String xml, Path xmlFilePath, boolean skipCrossFile) throws Exception {
+ try {
+ SynapseDiagnosticsParticipant.setSkipCrossFileValidation(skipCrossFile);
+ return diagnoseAtPath(xml, xmlFilePath);
+ } finally {
+ SynapseDiagnosticsParticipant.clearSkipCrossFileValidation();
+ }
+ }
+
+ @Test
+ public void testSkipCrossFileValidationSuppressesUnresolvedButKeepsWithinFileChecks(@TempDir Path tempDir)
+ throws Exception {
+ originalUserHome = System.getProperty("user.home");
+ System.setProperty("user.home", tempDir.toString());
+
+ Path consumer = tempDir.resolve("consumer");
+ Path apiXml = consumer.resolve("src/main/wso2mi/artifacts/apis/cvh.xml");
+ // References a sequence that does not exist (cross-file) AND an undefined variable (within-file).
+ String xml = ""
+ + ""
+ + ""
+ + ""
+ + "";
+
+ List diags = diagnoseAtPath(xml, apiXml, true);
+ assertTrue(diagnosticsWithCode(diags, "UnresolvedArtifactReference").isEmpty(),
+ "skipCrossFileValidation must suppress the cross-file UnresolvedArtifactReference");
+ assertEquals(1, diagnosticsWithCode(diags, "UndefinedVariable").size(),
+ "Within-file UndefinedVariable must still be reported when cross-file checks are skipped");
+ }
+
+ @Test
+ public void testCrossFileValidationDefaultStillFlagsUnresolved(@TempDir Path tempDir) throws Exception {
+ originalUserHome = System.getProperty("user.home");
+ System.setProperty("user.home", tempDir.toString());
+
+ Path consumer = tempDir.resolve("consumer");
+ Path apiXml = consumer.resolve("src/main/wso2mi/artifacts/apis/cvh.xml");
+ String xml = ""
+ + ""
+ + ""
+ + "";
+
+ // Default (flag false) — cross-file validation runs and flags the unresolved reference.
+ List diags = diagnoseAtPath(xml, apiXml, false);
+ assertEquals(1, diagnosticsWithCode(diags, "UnresolvedArtifactReference").size(),
+ "With cross-file validation on (default), an unresolved reference must still be flagged");
+ }
+
+ // ===== Cached artifact index invalidation (Change 2) =====
+
+ /** Runs diagnostics with a caller-supplied participant so its artifact-index cache persists across calls. */
+ private List diagnoseAtPathWith(SynapseDiagnosticsParticipant participant, String xml,
+ Path xmlFilePath) throws Exception {
+ Files.createDirectories(xmlFilePath.getParent());
+ Files.writeString(xmlFilePath, xml);
+ TextDocument textDocument = new TextDocument(xml, xmlFilePath.toUri().toString());
+ DOMDocument document = DOMParser.getInstance().parse(textDocument, null);
+ List diagnostics = new ArrayList<>();
+ participant.doDiagnostics(document, diagnostics, null, () -> {});
+ return diagnostics;
+ }
+
+ @Test
+ public void testInvalidateArtifactIndexCacheRebuildsAfterFileChange(@TempDir Path tempDir) throws Exception {
+ originalUserHome = System.getProperty("user.home");
+ System.setProperty("user.home", tempDir.toString());
+
+ Path consumer = tempDir.resolve("consumer");
+ Path apiXml = consumer.resolve("src/main/wso2mi/artifacts/apis/cvh.xml");
+ String api = ""
+ + ""
+ + "";
+
+ // Reuse one participant so its cross-file index cache survives across calls (as in production).
+ SynapseDiagnosticsParticipant participant = new SynapseDiagnosticsParticipant();
+
+ // 1. Sibling does not exist yet -> unresolved, and the index is now cached for this project.
+ List first = diagnoseAtPathWith(participant, api, apiXml);
+ assertEquals(1, diagnosticsWithCode(first, "UnresolvedArtifactReference").size(),
+ "Sibling 'sibling' does not exist yet -> should be flagged unresolved");
+
+ // 2. Write the sibling on disk. Within the TTL and without invalidation the cache is stale.
+ Path siblingXml = consumer.resolve("src/main/wso2mi/artifacts/sequences/sibling.xml");
+ Files.createDirectories(siblingXml.getParent());
+ Files.writeString(siblingXml, "");
+
+ List stale = diagnoseAtPathWith(participant, api, apiXml);
+ assertEquals(1, diagnosticsWithCode(stale, "UnresolvedArtifactReference").size(),
+ "Within the TTL and without invalidation, the stale cached index still flags it unresolved");
+
+ // 3. Invalidate -> the next run rebuilds the index and resolves the now-present sibling.
+ SynapseDiagnosticsParticipant.invalidateArtifactIndexCache();
+ List fresh = diagnoseAtPathWith(participant, api, apiXml);
+ assertTrue(diagnosticsWithCode(fresh, "UnresolvedArtifactReference").isEmpty(),
+ "After invalidation the rebuilt index includes the new sibling -> no longer unresolved");
+ }
+
+ @Test
+ public void testStaleCrossFileStateNotLeakedWhenIndexUnavailable(@TempDir Path tempDir) throws Exception {
+ originalUserHome = System.getProperty("user.home");
+ System.setProperty("user.home", tempDir.toString());
+
+ Path project = tempDir.resolve("proj");
+ // Two artifacts sharing a name -> "DupSeq" becomes a known duplicate for this project.
+ Path seqA = project.resolve("src/main/wso2mi/artifacts/sequences/a.xml");
+ Path seqB = project.resolve("src/main/wso2mi/artifacts/sequences/b.xml");
+ Files.createDirectories(seqA.getParent());
+ Files.writeString(seqA, "");
+ Files.writeString(seqB, "");
+
+ // Reuse one participant so its instance-level cross-file state persists across requests.
+ SynapseDiagnosticsParticipant participant = new SynapseDiagnosticsParticipant();
+
+ // Request A: validate a doc inside the project so the duplicate index is built into the
+ // participant's instance state (duplicateArtifactNames = { "DupSeq" }).
+ Path apiXml = project.resolve("src/main/wso2mi/artifacts/apis/cvh.xml");
+ diagnoseAtPathWith(participant, ""
+ + "",
+ apiXml);
+
+ // Request B (same participant): a doc named "DupSeq" whose project path is not derivable, so
+ // the cross-file index is unavailable. The stale duplicate state must be cleared, not reused.
+ TextDocument textB = new TextDocument(
+ "", "test.xml");
+ DOMDocument docB = DOMParser.getInstance().parse(textB, null);
+ List diagsB = new ArrayList<>();
+ participant.doDiagnostics(docB, diagsB, null, () -> {});
+ assertTrue(diagnosticsWithCode(diagsB, "DuplicateArtifactName").isEmpty(),
+ "Stale cross-file duplicate state must not leak to a request with no project index");
+ }
}
From 8c411d2f1c2b581a87ab7f8a8b10845f0ea7b465 Mon Sep 17 00:00:00 2001
From: Isuru Wijesiri
Date: Thu, 25 Jun 2026 17:12:37 +0530
Subject: [PATCH 19/34] Normalize path separators in artifact file-change cache
invalidation
The didSave and didChangeWatchedFiles handlers invalidate the cross-file artifact
index when a changed file's URI contains "src/main/wso2mi". LSP document URIs use
forward slashes on every OS, so this already works on Windows, but normalize the
URI's separators before the check so a backslash path would also match. This is a
small defensive hardening, consistent with the separator-agnostic artifacts-path
gate in SynapseExpressionValidator.
Follow-up to #552.
---
.../main/java/org/eclipse/lemminx/XMLTextDocumentService.java | 3 ++-
.../src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
index f75e3e65e..380748726 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
@@ -601,7 +601,8 @@ public void didSave(DidSaveTextDocumentParams params) {
computeAsync((monitor) -> {
// A document was saved, collect documents to revalidate
String savedUri = params.getTextDocument().getUri();
- if (savedUri != null && savedUri.contains("src/main/wso2mi")) {
+ // LSP document URIs use '/', but normalize defensively so a backslash path also matches on Windows.
+ if (savedUri != null && savedUri.replace('\\', '/').contains("src/main/wso2mi")) {
// An artifact/resource file was saved — drop the cached cross-file index so the
// revalidation below (and sibling files) sees the updated set instead of stale data.
SynapseDiagnosticsParticipant.invalidateArtifactIndexCache();
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java
index e9d07c133..738703dc5 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java
@@ -100,7 +100,8 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) {
} else if (change.getUri().contains(Constant.CONNECTORS) && change.getUri().contains(".zip")) {
((SynapseLanguageService) xmlLanguageServer.getSynapseLanguageService()).updateConnectors();
} else {
- if (change.getUri().contains("src/main/wso2mi")) {
+ // LSP URIs use '/', but normalize defensively so a backslash path also matches on Windows.
+ if (change.getUri().replace('\\', '/').contains("src/main/wso2mi")) {
// An artifact/resource file changed on disk — drop the cached cross-file index so
// the next diagnostics run rebuilds it (otherwise a just-written sibling stays
// "unresolved" for up to the cache TTL).
From 05a7114cfb9e4a33bd9efa9bbb40ee9f5dd12ed2 Mon Sep 17 00:00:00 2001
From: Chinthaka Jayatilake <37581983+ChinthakaJ98@users.noreply.github.com>
Date: Thu, 25 Jun 2026 21:47:37 +0530
Subject: [PATCH 20/34] Add support to the binds-to attribute in APIs
---
.../AbstractResourceFinder.java | 29 +++++++++++++++++++
.../NewProjectResourceFinder.java | 1 +
.../resourceFinder/pojo/ArtifactResource.java | 11 +++++++
.../pojo/RequestedResource.java | 13 +++++++++
.../syntaxTree/factory/APIFactory.java | 4 +++
.../syntaxTree/factory/ResourceFactory.java | 4 +++
.../synapse/syntaxTree/pojo/api/API.java | 11 +++++++
.../syntaxTree/pojo/api/APIResource.java | 11 +++++++
.../serializer/api/APISerializer.java | 3 ++
.../serializer/api/ResourceSerializer.java | 4 +++
.../customservice/synapse/utils/Constant.java | 1 +
.../org/eclipse/lemminx/schemas/430/api.xsd | 1 +
.../lemminx/schemas/430/misc/resource.xsd | 2 ++
.../org/eclipse/lemminx/schemas/440/api.xsd | 1 +
.../lemminx/schemas/440/misc/resource.xsd | 2 ++
15 files changed, 98 insertions(+)
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/AbstractResourceFinder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/AbstractResourceFinder.java
index 0a81af63e..5e35441e9 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/AbstractResourceFinder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/AbstractResourceFinder.java
@@ -672,6 +672,34 @@ private void traverseRegistryFolder(File folder, Map a
*/
public abstract Map findAllResources(String projectPath);
+ /**
+ * For each requested resource that declares a protocols filter, drops resources of that
+ * type whose protocol attribute is not in the list. Unfiltered types are left untouched.
+ *
+ * @param response the response whose resource list is filtered in place
+ * @param requestedResources the requested resources and protocol filters if any
+ */
+ protected void applyProtocolFilters(ResourceResponse response, List requestedResources) {
+
+ if (response.getResources() == null) {
+ return;
+ }
+ for (RequestedResource requested : requestedResources) {
+ List protocols = requested.getProtocols();
+ if (protocols == null || protocols.isEmpty()) {
+ continue;
+ }
+ response.getResources().removeIf(resource -> {
+ if (!requested.getType().equals(resource.getType())) {
+ return false;
+ }
+ String protocol = resource instanceof ArtifactResource
+ ? ((ArtifactResource) resource).getProtocol() : null;
+ return protocol == null || protocols.stream().noneMatch(p -> p.equalsIgnoreCase(protocol.trim()));
+ });
+ }
+ }
+
protected List findResourceInArtifacts(Path artifactsPath, List types) {
List resources = new ArrayList<>();
@@ -1025,6 +1053,7 @@ private Resource createArtifactResource(File file, DOMElement rootElement, Strin
artifact.setFrom(ARTIFACTS);
((ArtifactResource) artifact).setLocalEntry(isLocalEntry);
((ArtifactResource) artifact).setMcpInbound(Utils.isMcpInboundEndpoint(rootElement));
+ ((ArtifactResource) artifact).setProtocol(rootElement.getAttribute(Constant.PROTOCOL));
((ArtifactResource) artifact).setArtifactPath(file.getName());
((ArtifactResource) artifact).setAbsolutePath(file.getAbsolutePath());
return artifact;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/NewProjectResourceFinder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/NewProjectResourceFinder.java
index 07c74dfc2..03e8c09bc 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/NewProjectResourceFinder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/NewProjectResourceFinder.java
@@ -54,6 +54,7 @@ protected ResourceResponse findResources(String projectPath, List protocols;
public RequestedResource() {
@@ -48,4 +51,14 @@ public void setNeedRegistry(boolean needRegistry) {
this.needRegistry = needRegistry;
}
+
+ public List getProtocols() {
+
+ return protocols;
+ }
+
+ public void setProtocols(List protocols) {
+
+ this.protocols = protocols;
+ }
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/factory/APIFactory.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/factory/APIFactory.java
index 8be5470bc..392869a71 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/factory/APIFactory.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/factory/APIFactory.java
@@ -103,6 +103,10 @@ public void populateAttributes(STNode node, DOMElement element) {
if (Objects.nonNull(traceEnum)) {
api.setTrace(traceEnum);
}
+ String bindsTo = element.getAttribute(Constant.BINDS_TO);
+ if (Objects.nonNull(bindsTo)) {
+ api.setBindsTo(bindsTo);
+ }
}
public STNode createAPIResource(DOMNode node, String apiName) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/factory/ResourceFactory.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/factory/ResourceFactory.java
index 19f87f3e5..3a91d9d41 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/factory/ResourceFactory.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/factory/ResourceFactory.java
@@ -95,6 +95,10 @@ public void populateAttributes(STNode node, DOMElement element) {
if (Objects.nonNull(faultSequence)) {
apiResource.setFaultSequenceAttribute(faultSequence);
}
+ String bindsTo = element.getAttribute(Constant.BINDS_TO);
+ if (Objects.nonNull(bindsTo)) {
+ apiResource.setBindsTo(bindsTo);
+ }
}
private STNode createSequence(DOMNode node) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/pojo/api/API.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/pojo/api/API.java
index eb35fb30e..fff7a90b1 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/pojo/api/API.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/pojo/api/API.java
@@ -30,6 +30,7 @@ public class API extends STNode {
String description;
EnableDisable statistics;
EnableDisable trace;
+ String bindsTo;
public APIResource[] getResource() {
@@ -150,4 +151,14 @@ public void setTrace(EnableDisable trace) {
this.trace = trace;
}
+
+ public String getBindsTo() {
+
+ return bindsTo;
+ }
+
+ public void setBindsTo(String bindsTo) {
+
+ this.bindsTo = bindsTo;
+ }
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/pojo/api/APIResource.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/pojo/api/APIResource.java
index afeeea14a..5ca39019e 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/pojo/api/APIResource.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/pojo/api/APIResource.java
@@ -30,6 +30,7 @@ public class APIResource extends STNode {
String faultSequenceAttribute;
String uriTemplate;
String urlMapping;
+ String bindsTo;
public String getApi() {
@@ -141,6 +142,16 @@ public void setUrlMapping(String urlMapping) {
this.urlMapping = urlMapping;
}
+ public String getBindsTo() {
+
+ return bindsTo;
+ }
+
+ public void setBindsTo(String bindsTo) {
+
+ this.bindsTo = bindsTo;
+ }
+
public void addMethod(String method) {
if (this.methods == null) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/serializer/api/APISerializer.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/serializer/api/APISerializer.java
index 2f70276dd..8aa59a4e5 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/serializer/api/APISerializer.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/serializer/api/APISerializer.java
@@ -59,6 +59,9 @@ private static void addAttributes(API api, OMElement apiElt) {
if (api.getTrace() != null) {
apiElt.addAttribute("trace", api.getTrace().name(), null);
}
+ if (api.getBindsTo() != null) {
+ apiElt.addAttribute(Constant.BINDS_TO, api.getBindsTo(), null);
+ }
}
public static OMElement serializeVersioningStrategy(API api, OMElement apiElement) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/serializer/api/ResourceSerializer.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/serializer/api/ResourceSerializer.java
index 919d005a4..18ebe0e8f 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/serializer/api/ResourceSerializer.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/syntaxTree/serializer/api/ResourceSerializer.java
@@ -51,6 +51,10 @@ public static OMElement serializeResource(APIResource resource) {
resourceElt.addAttribute("url-mapping", resource.getUrlMapping(), null);
}
+ if (resource.getBindsTo() != null) {
+ resourceElt.addAttribute(Constant.BINDS_TO, resource.getBindsTo(), null);
+ }
+
if (resource.getInSequenceAttribute() != null) {
resourceElt.addAttribute("inSequence", resource.getInSequenceAttribute(), null);
} else {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Constant.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Constant.java
index 7772cf0ba..a1886b1dc 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Constant.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Constant.java
@@ -292,6 +292,7 @@ public class Constant {
public static final String HOSTNAME = "hostname";
public static final String VERSION_TYPE = "version-type";
public static final String PUBLISH_SWAGGER = "publishSwagger";
+ public static final String BINDS_TO = "binds-to";
public static final String URL_MAPPING = "url-mapping";
public static final String SPACE = " ";
public static final String WSDL_IMPORT = "wsdl:import";
diff --git a/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/schemas/430/api.xsd b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/schemas/430/api.xsd
index bf49c6330..93d56092b 100644
--- a/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/schemas/430/api.xsd
+++ b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/schemas/430/api.xsd
@@ -74,6 +74,7 @@