From 0af66683fc8fb608620e97e61088d42637bca1f6 Mon Sep 17 00:00:00 2001 From: atheate Date: Thu, 30 Jul 2026 16:18:15 +0200 Subject: [PATCH 1/6] source code init --- .gitignore | 2 + CLAUDE.md | 128 ++++++ README.md | 56 ++- X-Splinter.sln | 45 ++ .../Services/ElementIndexerTestFixture.cs | 71 ++++ .../Services/ExtensionBuilderTestFixture.cs | 94 +++++ .../Services/ReferenceRewriterTestFixture.cs | 113 +++++ .../Services/XmiSplitterServiceTestFixture.cs | 180 ++++++++ XSplinter.Tests/XSplinter.Tests.csproj | 31 ++ XSplinter/Configuration/PackageConfig.cs | 37 ++ XSplinter/Configuration/SplitterConfig.cs | 28 ++ XSplinter/Program.cs | 124 ++++++ XSplinter/Services/ElementIndexer.cs | 98 +++++ XSplinter/Services/ExtensionBuilder.cs | 146 +++++++ XSplinter/Services/IElementIndexer.cs | 48 +++ XSplinter/Services/IExtensionBuilder.cs | 68 +++ XSplinter/Services/IReferenceRewriter.cs | 35 ++ XSplinter/Services/IXmiFileService.cs | 48 +++ XSplinter/Services/IXmiSplitterService.cs | 33 ++ XSplinter/Services/PackageEntry.cs | 20 + XSplinter/Services/ReferenceRewriter.cs | 79 ++++ XSplinter/Services/XmiFileService.cs | 46 +++ XSplinter/Services/XmiSplitterService.cs | 387 ++++++++++++++++++ XSplinter/XSplinter.csproj | 16 + example/packages.json | 8 + 25 files changed, 1940 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 X-Splinter.sln create mode 100644 XSplinter.Tests/Services/ElementIndexerTestFixture.cs create mode 100644 XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs create mode 100644 XSplinter.Tests/Services/ReferenceRewriterTestFixture.cs create mode 100644 XSplinter.Tests/Services/XmiSplitterServiceTestFixture.cs create mode 100644 XSplinter.Tests/XSplinter.Tests.csproj create mode 100644 XSplinter/Configuration/PackageConfig.cs create mode 100644 XSplinter/Configuration/SplitterConfig.cs create mode 100644 XSplinter/Program.cs create mode 100644 XSplinter/Services/ElementIndexer.cs create mode 100644 XSplinter/Services/ExtensionBuilder.cs create mode 100644 XSplinter/Services/IElementIndexer.cs create mode 100644 XSplinter/Services/IExtensionBuilder.cs create mode 100644 XSplinter/Services/IReferenceRewriter.cs create mode 100644 XSplinter/Services/IXmiFileService.cs create mode 100644 XSplinter/Services/IXmiSplitterService.cs create mode 100644 XSplinter/Services/PackageEntry.cs create mode 100644 XSplinter/Services/ReferenceRewriter.cs create mode 100644 XSplinter/Services/XmiFileService.cs create mode 100644 XSplinter/Services/XmiSplitterService.cs create mode 100644 XSplinter/XSplinter.csproj create mode 100644 example/packages.json diff --git a/.gitignore b/.gitignore index d5a18de..6e8e0d5 100644 --- a/.gitignore +++ b/.gitignore @@ -427,3 +427,5 @@ FodyWeavers.xsd *.msix *.msm *.msp + +.idea/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9e5089e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,128 @@ +# CLAUDE.md + +Guidance for Claude Code when working in this repository. + +## Overview + +**X-Splinter** is a .NET 10 command-line utility that splits a single monolithic +Enterprise Architect (EA) XMI export into multiple, smaller XMI files — one per +UML package. As it splits, it rewrites cross-package references so the resulting +files still resolve against each other: an intra-file `xmi:idref` that points to +an element now living in a *different* output file is converted into a cross-file +`href="targetFile.xmi#id"`. This lets a consumer (e.g. UML4NET) load the pieces +independently while types still resolve across files. + +## Build & Run + +Requires the **.NET 10 SDK**. + +```bash +# Build +dotnet build X-Splinter.sln + +# Run +dotnet run --project XSplinter/XSplinter.csproj -- [--output ] +``` + +- `` — the monolithic XMI file exported from Enterprise Architect. +- `` — the splitter configuration (see below). +- `--output ` — output directory (optional; defaults to the current directory `.`). +- Exit code: `0` on success, `1` on error (missing files, bad config, or a splitting exception). + +## Configuration + +The config file is JSON deserialized into `SplitterConfig` (property matching is +case-insensitive). A sample lives at `example/packages.json` — note this is a +*splitter config sample*, **not** an npm/Node file. + +```json +{ + "rootPackageName": "5. Data Structure", + "packages": [ + { "name": "Primitives", "outputFile": "CSharp_Primitives.xmi", "convertToLibrary": true }, + { "name": "Forge", "outputFile": "Forge.xmi" }, + { "name": "FunctionalData", "outputFile": "FunctionalData.xmi" } + ] +} +``` + +- `rootPackageName` — name of the root container package inside the XMI's `uml:Model`. +- `packages[]` — the child packages to extract, each with: + - `name` — the UML package name as it appears in the XMI. + - `outputFile` — the filename to write this package to. + - `convertToLibrary` (optional, default `false`): + - `false` → full EA model output: `uml:Model name="EA_Model"` wrapper plus a + filtered `xmi:Extension` section. + - `true` → plain `uml:Package` output without the model wrapper or EA metadata, + suitable for a reusable library. + +## Architecture + +The processing pipeline lives in the `XSplinter` project. Each service exposes an +interface (`IElementIndexer`, `IReferenceRewriter`, `IExtensionBuilder`, +`IXmiFileService`, `IXmiSplitterService`) and dependencies are passed via constructor +injection so the orchestration can be unit tested with mocks. + +- **`XSplinter/Program.cs`** (`XSplinter`) — entry point. Parses args, loads/validates + the input and config files, deserializes `SplitterConfig`, and delegates to + `XmiSplitterService.Split(inputPath, config, outputDirectory)`. Logging via + `Microsoft.Extensions.Logging` console logger. +- **`XSplinter/Configuration/SplitterConfig.cs`**, **`XSplinter/Configuration/PackageConfig.cs`** + (`XSplinter.Configuration`) — the config models described above. +- **`XSplinter/Services/XmiSplitterService.cs`** (`IXmiSplitterService`) — the orchestrator. + Loads the XMI and, per run: finds the root package by `rootPackageName` under `uml:Model`, + locates each configured child package, builds the global element index and per-package id + sets, maps EA connectors to packages, then for each package clones the node, rewrites + cross-package references, and emits either a library document (plain `uml:Package`) or a + full EA document (`uml:Model` + filtered `xmi:Extension`). It has a convenience constructor + that wires up the concrete collaborators, plus a constructor that accepts injected + collaborators for testing. +- **`XSplinter/Services/XmiFileService.cs`** (`IXmiFileService`) — abstracts the file-system + interactions (`Load`, `EnsureDirectory`, `Save`) so `Split` can be tested without touching + the disk. Registers the `CodePagesEncodingProvider` so the `windows-1252` XML declaration + used by the outputs is honoured. +- **`XSplinter/Services/PackageEntry.cs`** — `record PackageEntry(string PackageName, string OutputFile)`; + associates an element id with its owning package and output file. +- **`XSplinter/Services/ElementIndexer.cs`** (`IElementIndexer`) — `IndexElementIds` recursively + maps every `xmi:id` in a package subtree to a `PackageEntry`; `CollectAllIds` gathers all + `xmi:id` + `xmi:idref` values in a subtree (used to assign EA Extension entries). +- **`XSplinter/Services/ReferenceRewriter.cs`** (`IReferenceRewriter`) — walks a cloned package + tree and, for `type` and `constrainedElement` nodes whose `xmi:idref` points into a + *different* package, replaces the idref with `href="outputFile.xmi#id"`. Intra-package + references are left untouched. `Rewrite` takes the element index as a parameter, keeping the + service stateless. +- **`XSplinter/Services/ExtensionBuilder.cs`** (`IExtensionBuilder`) — `BuildConnectorPackageMap` + assigns each EA connector to the package that owns both/either endpoint; `Build` produces a + filtered `xmi:Extension` element (elements + connectors for one package, plus a stub + `EA_PrimitiveTypes_Package`). + +## Conventions + +- `using` directives placed **inside** the namespace. +- Explicit `this.` qualification for instance members. +- XML-doc comments on all public types and members. +- One type per file. +- `record` for immutable data. +- Starion Group copyright file header on every file. +- Nullable reference types and ImplicitUsings enabled. + +## Testing + +Unit tests live in the **`XSplinter.Tests`** project (a sibling in `X-Splinter.sln`), +using **NUnit** and **Moq**. + +```bash +dotnet test X-Splinter.sln +``` + +- One `[TestFixture]` per service (`TestFixture`), with a `[SetUp] public void Setup()`. +- The pure transformation services (`ElementIndexer`, `ReferenceRewriter`, `ExtensionBuilder`) + are tested directly against crafted `XElement`/`XDocument` inputs — no mocks needed. +- `XmiSplitterServiceTestFixture` drives the orchestrator with **Moq** mocks of the service + interfaces (including `IXmiFileService`, so no disk access), asserting the wiring and the + `convertToLibrary` vs full-EA branching. Assertions use the classic NUnit constraint model + (`Assert.That` / `Assert.Multiple`). + +For an end-to-end check, run the CLI against a representative monolithic EA XMI export with a +matching config, writing to a temporary directory, then compare each per-package output file +against a known-good baseline (byte-for-byte hash or `diff`) to catch regressions. diff --git a/README.md b/README.md index 3d9843e..5937310 100644 --- a/README.md +++ b/README.md @@ -1 +1,55 @@ -# X-Splinter \ No newline at end of file +# X-Splinter + +**X-Splinter** is a .NET 10 command-line tool that splits a single monolithic Enterprise Architect (EA) XMI export into separate, per-package XMI files. Cross-package references are rewritten from internal `xmi:idref`s into cross-file `href="targetFile.xmi#id"` references, so the resulting files can be loaded independently while types still resolve across files (e.g. with UML4NET). + +## Features + +- Splits one EA XMI export into one output file per configured UML package. +- Rewrites cross-package references into resolvable cross-file `href`s. +- Rebuilds a filtered EA `xmi:Extension` section (elements and connectors) for each package. +- Optionally emits a package as a plain reusable `uml:Package` library (`convertToLibrary`). + +## Build + +Requires the **.NET 10 SDK**. + +```bash +dotnet build X-Splinter.sln +``` + +## Usage + +```bash +dotnet run --project XSplinter/XSplinter.csproj -- [--output ] +``` + +- `` — the monolithic XMI file exported from Enterprise Architect. +- `` — the splitter configuration (see below). +- `--output ` — output directory (optional; defaults to the current directory). + +## Configuration + +The configuration is a JSON file describing the root container package and the child packages to extract: + +```json +{ + "rootPackageName": "5. Data Structure", + "packages": [ + { "name": "Primitives", "outputFile": "CSharp_Primitives.xmi", "convertToLibrary": true }, + { "name": "Forge", "outputFile": "Forge.xmi" }, + { "name": "FunctionalData", "outputFile": "FunctionalData.xmi" } + ] +} +``` + +- `rootPackageName` — name of the root container package inside the XMI's `uml:Model`. +- `packages[]` — the packages to extract, each with a `name`, an `outputFile`, and an optional + `convertToLibrary` flag. When `convertToLibrary` is `true`, the package is written as a plain + `uml:Package` without the EA model wrapper or extension metadata; when `false` (default), the + full EA model structure is preserved. + +A sample configuration is available in [`example/packages.json`](example/packages.json). + +# License + +X-Splinter is provided to the community under the Apache License 2.0. See the [LICENSE](LICENSE) file for the full text. diff --git a/X-Splinter.sln b/X-Splinter.sln new file mode 100644 index 0000000..a146446 --- /dev/null +++ b/X-Splinter.sln @@ -0,0 +1,45 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XSplinter", "XSplinter\XSplinter.csproj", "{3BC93A44-23EB-43D2-98AE-321569509BEE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XSplinter.Tests", "XSplinter.Tests\XSplinter.Tests.csproj", "{FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Debug|x64.ActiveCfg = Debug|Any CPU + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Debug|x64.Build.0 = Debug|Any CPU + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Debug|x86.ActiveCfg = Debug|Any CPU + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Debug|x86.Build.0 = Debug|Any CPU + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Release|Any CPU.Build.0 = Release|Any CPU + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Release|x64.ActiveCfg = Release|Any CPU + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Release|x64.Build.0 = Release|Any CPU + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Release|x86.ActiveCfg = Release|Any CPU + {3BC93A44-23EB-43D2-98AE-321569509BEE}.Release|x86.Build.0 = Release|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Debug|x64.ActiveCfg = Debug|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Debug|x64.Build.0 = Debug|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Debug|x86.ActiveCfg = Debug|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Debug|x86.Build.0 = Debug|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Release|Any CPU.Build.0 = Release|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Release|x64.ActiveCfg = Release|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Release|x64.Build.0 = Release|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Release|x86.ActiveCfg = Release|Any CPU + {FCBF5B2F-D9B2-4C41-938B-D7AE91323D15}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/XSplinter.Tests/Services/ElementIndexerTestFixture.cs b/XSplinter.Tests/Services/ElementIndexerTestFixture.cs new file mode 100644 index 0000000..021ea72 --- /dev/null +++ b/XSplinter.Tests/Services/ElementIndexerTestFixture.cs @@ -0,0 +1,71 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Tests.Services +{ + using System.Collections.Generic; + using System.Xml.Linq; + + using NUnit.Framework; + + using XSplinter.Services; + + /// + /// Suite of tests for the class. + /// + [TestFixture] + public class ElementIndexerTestFixture + { + private const string XmiNamespace = "http://www.omg.org/spec/XMI/20131001"; + + private ElementIndexer elementIndexer; + private static readonly string[] Expected = ["p1", "r1", "c1", "r2"]; + private static readonly string[] ExpectedArray = ["p1", "c1", "c2"]; + + [SetUp] + public void Setup() + { + this.elementIndexer = new ElementIndexer(XmiNamespace); + } + + [Test] + public void Verify_that_IndexElementIds_maps_every_xmi_id_to_the_owning_package() + { + var element = XElement.Parse( + $"" + + " " + + " " + + ""); + + var index = new Dictionary(); + + this.elementIndexer.IndexElementIds(element, "Forge", "Forge.xmi", index); + + Assert.Multiple(() => + { + Assert.That(index.Keys, Is.EquivalentTo(ExpectedArray)); + Assert.That(index["p1"], Is.EqualTo(new PackageEntry("Forge", "Forge.xmi"))); + Assert.That(index["c1"].OutputFile, Is.EqualTo("Forge.xmi")); + }); + } + + [Test] + public void Verify_that_CollectAllIds_gathers_both_ids_and_idrefs() + { + var element = XElement.Parse( + $"" + + " " + + " " + + ""); + + var ids = new HashSet(); + + this.elementIndexer.CollectAllIds(element, ids); + + Assert.That(ids, Is.EquivalentTo(Expected)); + } + } +} diff --git a/XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs b/XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs new file mode 100644 index 0000000..4164504 --- /dev/null +++ b/XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs @@ -0,0 +1,94 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Tests.Services +{ + using System.Collections.Generic; + using System.Linq; + using System.Xml.Linq; + + using NUnit.Framework; + + using XSplinter.Services; + + /// + /// Suite of tests for the class. + /// + [TestFixture] + public class ExtensionBuilderTestFixture + { + private const string XmiNamespace = "http://www.omg.org/spec/XMI/20131001"; + + private XNamespace xmi; + private ExtensionBuilder extensionBuilder; + + [SetUp] + public void Setup() + { + this.xmi = XmiNamespace; + this.extensionBuilder = new ExtensionBuilder(XmiNamespace); + } + + [Test] + public void Verify_that_a_connector_is_assigned_to_the_package_owning_its_endpoints() + { + var connectors = new List + { + XElement.Parse($"") + }; + + var packageElementIds = new Dictionary> + { + ["Forge"] = ["a", "b"], + ["Primitives"] = ["x"] + }; + + var map = this.extensionBuilder.BuildConnectorPackageMap(connectors, packageElementIds, new[] { "Forge", "Primitives" }); + + Assert.That(map["conn1"], Is.EqualTo("Forge")); + } + + [Test] + public void Verify_that_Build_filters_elements_and_connectors_to_the_target_package() + { + var allElements = new List + { + XElement.Parse($""), + XElement.Parse($"") + }; + + var allConnectors = new List + { + XElement.Parse($""), + XElement.Parse($"") + }; + + var connectorPackageMap = new Dictionary + { + ["c1"] = "Forge", + ["c2"] = "Primitives" + }; + + var packageIds = new HashSet { "e1" }; + + var extension = this.extensionBuilder.Build("Forge", packageIds, allElements, allConnectors, connectorPackageMap); + + var elements = extension.Element("elements")!.Elements("element").ToList(); + var connectors = extension.Element("connectors")!.Elements("connector").ToList(); + + Assert.Multiple(() => + { + Assert.That(extension.Name, Is.EqualTo(this.xmi + "Extension")); + Assert.That(elements, Has.Count.EqualTo(1)); + Assert.That((string)elements[0].Attribute(this.xmi + "idref"), Is.EqualTo("e1")); + Assert.That(connectors, Has.Count.EqualTo(1)); + Assert.That((string)connectors[0].Attribute(this.xmi + "idref"), Is.EqualTo("c1")); + Assert.That(extension.Element("primitivetypes"), Is.Not.Null); + Assert.That(extension.Element("profiles"), Is.Not.Null); + }); + } + } +} diff --git a/XSplinter.Tests/Services/ReferenceRewriterTestFixture.cs b/XSplinter.Tests/Services/ReferenceRewriterTestFixture.cs new file mode 100644 index 0000000..60f5850 --- /dev/null +++ b/XSplinter.Tests/Services/ReferenceRewriterTestFixture.cs @@ -0,0 +1,113 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Tests.Services +{ + using System.Collections.Generic; + using System.Xml.Linq; + + using NUnit.Framework; + + using XSplinter.Services; + + /// + /// Suite of tests for the class. + /// + [TestFixture] + public class ReferenceRewriterTestFixture + { + private const string XmiNamespace = "http://www.omg.org/spec/XMI/20131001"; + + private XNamespace xmi; + private ReferenceRewriter referenceRewriter; + + [SetUp] + public void Setup() + { + this.xmi = XmiNamespace; + this.referenceRewriter = new ReferenceRewriter(XmiNamespace); + } + + [Test] + public void Verify_that_a_cross_package_type_reference_is_rewritten_to_an_href() + { + var element = XElement.Parse( + $""); + + var index = new Dictionary + { + ["externalId"] = new PackageEntry("Primitives", "CSharp_Primitives.xmi") + }; + + this.referenceRewriter.Rewrite(element, "Forge", index); + + var type = element.Element("type")!; + + Assert.Multiple(() => + { + Assert.That((string)type.Attribute(this.xmi + "idref"), Is.Null); + Assert.That((string)type.Attribute("href"), Is.EqualTo("CSharp_Primitives.xmi#externalId")); + }); + } + + [Test] + public void Verify_that_an_intra_package_reference_is_left_untouched() + { + var element = XElement.Parse( + $""); + + var index = new Dictionary + { + ["forgeId"] = new ("Forge", "Forge.xmi") + }; + + this.referenceRewriter.Rewrite(element, "Forge", index); + + var type = element.Element("type")!; + + Assert.Multiple(() => + { + Assert.That((string)type.Attribute(this.xmi + "idref"), Is.EqualTo("forgeId")); + Assert.That((string)type.Attribute("href"), Is.Null); + }); + } + + [Test] + public void Verify_that_a_cross_package_constrainedElement_reference_is_rewritten() + { + var element = XElement.Parse( + $""); + + var index = new Dictionary + { + ["externalId"] = new ("Primitives", "CSharp_Primitives.xmi") + }; + + this.referenceRewriter.Rewrite(element, "Forge", index); + + var constrained = element.Element("constrainedElement")!; + + Assert.That((string)constrained.Attribute("href"), Is.EqualTo("CSharp_Primitives.xmi#externalId")); + } + + [Test] + public void Verify_that_a_reference_to_an_unknown_id_is_left_untouched() + { + var element = XElement.Parse( + $""); + + this.referenceRewriter.Rewrite(element, "Forge", new Dictionary()); + + var type = element.Element("type")!; + + Assert.Multiple(() => + { + Assert.That((string)type.Attribute(this.xmi + "idref"), Is.EqualTo("unknown")); + Assert.That((string)type.Attribute("href"), Is.Null); + }); + } + } +} diff --git a/XSplinter.Tests/Services/XmiSplitterServiceTestFixture.cs b/XSplinter.Tests/Services/XmiSplitterServiceTestFixture.cs new file mode 100644 index 0000000..d12525d --- /dev/null +++ b/XSplinter.Tests/Services/XmiSplitterServiceTestFixture.cs @@ -0,0 +1,180 @@ +namespace XSplinter.Tests.Services +{ + using System.Xml.Linq; + + using Microsoft.Extensions.Logging.Abstractions; + + using Moq; + + using NUnit.Framework; + + using XSplinter.Configuration; + using XSplinter.Services; + + /// + /// Suite of tests for the orchestrator, using mocked + /// collaborators so that the orchestration logic can be exercised without touching the disk. + /// + [TestFixture] + public class XmiSplitterServiceTestFixture + { + private const string XmiNamespace = "http://www.omg.org/spec/XMI/20131001"; + private const string UmlNamespace = "http://www.omg.org/spec/UML/20161101"; + + private Mock elementIndexer; + private Mock referenceRewriter; + private Mock extensionBuilder; + private Mock fileService; + private List<(XDocument Document, string OutputPath)> savedDocuments; + private SplitterConfig config; + private XmiSplitterService xmiSplitterService; + + [SetUp] + public void Setup() + { + this.elementIndexer = new Mock(); + this.referenceRewriter = new Mock(); + this.extensionBuilder = new Mock(); + this.fileService = new Mock(); + + this.extensionBuilder + .Setup(x => x.BuildConnectorPackageMap( + It.IsAny>(), + It.IsAny>>(), + It.IsAny>())) + .Returns(new Dictionary()); + + this.extensionBuilder + .Setup(x => x.Build( + It.IsAny(), + It.IsAny>(), + It.IsAny>(), + It.IsAny>(), + It.IsAny>())) + .Returns(new XElement(XName.Get("Extension", XmiNamespace))); + + this.fileService.Setup(x => x.Load(It.IsAny())).Returns(CreateMonolith); + + this.savedDocuments = []; + + this.fileService + .Setup(x => x.Save(It.IsAny(), It.IsAny())) + .Callback((document, path) => this.savedDocuments.Add((document, path))); + + this.config = new SplitterConfig + { + RootPackageName = "5. Data Structure", + Packages = + [ + new PackageConfig { Name = "Primitives", OutputFile = "CSharp_Primitives.xmi", ConvertToLibrary = true }, + new PackageConfig { Name = "Forge", OutputFile = "Forge.xmi" } + ] + }; + + this.xmiSplitterService = new XmiSplitterService( + NullLogger.Instance, + this.elementIndexer.Object, + this.referenceRewriter.Object, + this.extensionBuilder.Object, + this.fileService.Object); + } + + [Test] + public void Verify_that_a_convertToLibrary_package_is_written_as_a_plain_uml_Package() + { + this.xmiSplitterService.Split("input.xmi", this.config, "output"); + + var libraryDocument = this.savedDocuments.Single(document => document.OutputPath.EndsWith("CSharp_Primitives.xmi")).Document; + var fullDocument = this.savedDocuments.Single(document => document.OutputPath.EndsWith("Forge.xmi")).Document; + + XNamespace uml = UmlNamespace; + XNamespace xmi = XmiNamespace; + + Assert.Multiple(() => + { + Assert.That(libraryDocument.Root!.Element(uml + "Package"), Is.Not.Null); + Assert.That(libraryDocument.Root!.Element(uml + "Model"), Is.Null); + Assert.That(libraryDocument.Descendants(xmi + "Extension"), Is.Empty); + + Assert.That(fullDocument.Root!.Element(uml + "Model"), Is.Not.Null); + Assert.That(fullDocument.Descendants(xmi + "Extension"), Is.Not.Empty); + }); + } + + [Test] + public void Verify_that_Split_ensures_the_output_directory_exists() + { + this.xmiSplitterService.Split("input.xmi", this.config, "output"); + + this.fileService.Verify(x => x.EnsureDirectory("output"), Times.Once); + } + + [Test] + public void Verify_that_Split_rewrites_references_for_each_package() + { + this.xmiSplitterService.Split("input.xmi", this.config, "output"); + + this.referenceRewriter.Verify( + x => x.Rewrite(It.IsAny(), It.IsAny(), It.IsAny>()), + Times.Exactly(2)); + } + + [Test] + public void Verify_that_Split_throws_when_the_root_package_is_missing() + { + this.fileService.Setup(x => x.Load(It.IsAny())).Returns(CreateMonolithWithoutRoot); + + Assert.That( + () => this.xmiSplitterService.Split("input.xmi", this.config, "output"), + Throws.InstanceOf()); + } + + [Test] + public void Verify_that_Split_writes_one_document_per_configured_package() + { + this.xmiSplitterService.Split("input.xmi", this.config, "output"); + + Assert.Multiple(() => + { + Assert.That(this.savedDocuments, Has.Count.EqualTo(2)); + + Assert.That(this.savedDocuments.Select(document => document.OutputPath), Is.EquivalentTo(new[] + { + Path.Combine("output", "CSharp_Primitives.xmi"), + Path.Combine("output", "Forge.xmi") + })); + }); + } + + private static XDocument CreateMonolith() + { + return XDocument.Parse( + $"" + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + ""); + } + + private static XDocument CreateMonolithWithoutRoot() + { + return XDocument.Parse( + $"" + + " " + + " " + + " " + + ""); + } + } +} diff --git a/XSplinter.Tests/XSplinter.Tests.csproj b/XSplinter.Tests/XSplinter.Tests.csproj new file mode 100644 index 0000000..388414d --- /dev/null +++ b/XSplinter.Tests/XSplinter.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + disable + XSplinter.Tests + false + true + + + + + + + all + + + + all + + + all + + + + + + + + diff --git a/XSplinter/Configuration/PackageConfig.cs b/XSplinter/Configuration/PackageConfig.cs new file mode 100644 index 0000000..3a75705 --- /dev/null +++ b/XSplinter/Configuration/PackageConfig.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Configuration +{ + /// + /// Represents the configuration for a single UML package to be extracted + /// from the monolithic XMI file into its own output file. + /// + public class PackageConfig + { + /// + /// Gets or sets the name of the UML package as it appears in the XMI + /// (e.g. "Primitives", "Forge", "FunctionalData"). + /// + public string Name { get; set; } = ""; + + /// + /// Gets or sets the filename for the output XMI file + /// (e.g. "CSharp_Primitives.xmi"). + /// + public string OutputFile { get; set; } = ""; + + /// + /// Gets or sets a value indicating whether this package should be converted into a + /// simple reusable library. When false (the default), the package is written + /// as a full Enterprise Architect model (uml:Model name="EA_Model" wrapper and + /// xmi:Extension section). When set to true, the package is output as a + /// standard uml:Package element without the model wrapper or EA metadata, + /// suitable for use as a reusable library. + /// + public bool ConvertToLibrary { get; set; } = false; + } +} diff --git a/XSplinter/Configuration/SplitterConfig.cs b/XSplinter/Configuration/SplitterConfig.cs new file mode 100644 index 0000000..9ea2afa --- /dev/null +++ b/XSplinter/Configuration/SplitterConfig.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Configuration +{ + using System.Collections.Generic; + + /// + /// Represents the top-level configuration for the XMI splitter, + /// defining the root package and the list of child packages to extract. + /// + public class SplitterConfig + { + /// + /// Gets or sets the name of the root container package in the XMI + /// (e.g. "5. Data Structure"). + /// + public string RootPackageName { get; set; } = ""; + + /// + /// Gets or sets the list of packages to extract into separate XMI files. + /// + public List Packages { get; set; } = []; + } +} diff --git a/XSplinter/Program.cs b/XSplinter/Program.cs new file mode 100644 index 0000000..d676acb --- /dev/null +++ b/XSplinter/Program.cs @@ -0,0 +1,124 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter +{ + using System; + using System.IO; + using System.Text.Json; + + using Microsoft.Extensions.Logging; + + using XSplinter.Configuration; + using XSplinter.Services; + + /// + /// Entry point for the XSplinter console application. + /// Splits a monolithic Enterprise Architect XMI export into separate + /// XMI files per package with cross-file href references. + /// + public class Program + { + /// + /// The application entry point. + /// + /// + /// Command-line arguments: input-xmi config-json [--output dir]. + /// + /// + /// 0 on success; 1 on error. + /// + public static int Main(string[] args) + { + using var loggerFactory = LoggerFactory.Create(builder => + { + builder + .SetMinimumLevel(LogLevel.Information) + .AddConsole(); + }); + + var logger = loggerFactory.CreateLogger(); + + if (args.Length < 2) + { + PrintUsage(logger); + return 1; + } + + var inputPath = args[0]; + var configPath = args[1]; + var outputDirectory = "."; + + for (var argIndex = 2; argIndex < args.Length; argIndex++) + { + if (args[argIndex] == "--output" && argIndex + 1 < args.Length) + { + outputDirectory = args[++argIndex]; + } + } + + if (!File.Exists(inputPath)) + { + logger.LogError("Input file not found: {InputPath}", inputPath); + return 1; + } + + if (!File.Exists(configPath)) + { + logger.LogError("Config file not found: {ConfigPath}", configPath); + return 1; + } + + var config = JsonSerializer.Deserialize( + File.ReadAllText(configPath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (config == null) + { + logger.LogError("Failed to parse configuration file"); + return 1; + } + + try + { + var splitterLogger = loggerFactory.CreateLogger(); + var splitter = new XmiSplitterService(splitterLogger); + splitter.Split(inputPath, config, outputDirectory); + return 0; + } + catch (Exception exception) + { + logger.LogError(exception, "Splitting failed"); + return 1; + } + } + + /// + /// Logs usage instructions. + /// + /// + /// The used to log the usage instructions. + /// + private static void PrintUsage(ILogger logger) + { + logger.LogInformation( + "Usage: XSplinter [--output ]\n\n" + + "Splits a monolithic Enterprise Architect XMI export into separate\n" + + "XMI files per package with cross-file href references.\n\n" + + "config.json format:\n" + + "{{\n" + + " \"rootPackageName\": \"5. Data Structure\",\n" + + " \"packages\": [\n" + + " {{ \"name\": \"Primitives\", \"outputFile\": \"CSharp_Primitives.xmi\", \"convertToLibrary\": true }},\n" + + " {{ \"name\": \"Forge\", \"outputFile\": \"Forge.xmi\" }},\n" + + " {{ \"name\": \"FunctionalData\", \"outputFile\": \"FunctionalData.xmi\" }}\n" + + " ]\n" + + "}}\n\n" + + "Set convertToLibrary to true for reusable library packages that are\n" + + "output as a simple uml:Package without EA metadata."); + } + } +} diff --git a/XSplinter/Services/ElementIndexer.cs b/XSplinter/Services/ElementIndexer.cs new file mode 100644 index 0000000..5a555a3 --- /dev/null +++ b/XSplinter/Services/ElementIndexer.cs @@ -0,0 +1,98 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Services +{ + using System.Collections.Generic; + using System.Xml.Linq; + + /// + /// Builds an index that maps every xmi:id found within a package subtree + /// to its owning package, enabling cross-package reference detection. + /// + public class ElementIndexer : IElementIndexer + { + /// + /// The XMI namespace URI. + /// + private readonly XNamespace xmi; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The XMI namespace used in the source document. + /// + public ElementIndexer(XNamespace xmiNamespace) + { + this.xmi = xmiNamespace; + } + + /// + /// Scans the given element and all its descendants, recording every + /// xmi:id attribute value into the provided index. + /// + /// + /// The root element of the package subtree to index. + /// + /// + /// The name of the owning package. + /// + /// + /// The output filename for the owning package. + /// + /// + /// The dictionary to populate with element-id to mappings. + /// + public void IndexElementIds(XElement element, string packageName, string outputFile, Dictionary index) + { + var id = (string?)element.Attribute(this.xmi + "id"); + + if (id != null) + { + index[id] = new PackageEntry(packageName, outputFile); + } + + foreach (var child in element.Elements()) + { + this.IndexElementIds(child, packageName, outputFile, index); + } + } + + /// + /// Collects all xmi:id and xmi:idref attribute values + /// from the given element and its descendants into a set. + /// This is used to determine which EA Extension entries belong to a package. + /// + /// + /// The root element of the package subtree to scan. + /// + /// + /// The set to populate with all discovered identifiers. + /// + public void CollectAllIds(XElement element, HashSet ids) + { + var id = (string?)element.Attribute(this.xmi + "id"); + + if (id != null) + { + ids.Add(id); + } + + var idref = (string?)element.Attribute(this.xmi + "idref"); + + if (idref != null) + { + ids.Add(idref); + } + + foreach (var child in element.Elements()) + { + this.CollectAllIds(child, ids); + } + } + } +} diff --git a/XSplinter/Services/ExtensionBuilder.cs b/XSplinter/Services/ExtensionBuilder.cs new file mode 100644 index 0000000..a1398f4 --- /dev/null +++ b/XSplinter/Services/ExtensionBuilder.cs @@ -0,0 +1,146 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Services +{ + using System.Collections.Generic; + using System.Linq; + using System.Xml.Linq; + + /// + /// Builds the xmi:Extension section for each split XMI file, + /// filtering the EA-proprietary elements and connectors to include only + /// those belonging to the target package. + /// + public class ExtensionBuilder : IExtensionBuilder + { + /// + /// The XMI namespace URI. + /// + private readonly XNamespace xmi; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The XMI namespace used in the source document. + /// + public ExtensionBuilder(XNamespace xmiNamespace) + { + this.xmi = xmiNamespace; + } + + /// + /// Maps each connector (by its xmi:idref) to the package that owns it. + /// A connector is assigned to the package that contains both its source and target, + /// or to the first package that contains at least one endpoint. + /// + /// + /// The list of connector elements from the EA Extension section. + /// + /// + /// A dictionary mapping each package name to the set of element IDs it contains. + /// + /// + /// The ordered list of package names to iterate over when assigning ownership. + /// + /// + /// A dictionary mapping each connector's xmi:idref to its owning package name. + /// + public Dictionary BuildConnectorPackageMap( + List connectors, + Dictionary> packageElementIds, + IEnumerable packageNames) + { + var connectorPackageMap = new Dictionary(); + + foreach (var connector in connectors) + { + var connectorId = (string?)connector.Attribute(this.xmi + "idref") ?? ""; + var sourceId = (string?)connector.Element("source")?.Attribute(this.xmi + "idref") ?? ""; + var targetId = (string?)connector.Element("target")?.Attribute(this.xmi + "idref") ?? ""; + + foreach (var packageName in packageNames) + { + var ids = packageElementIds[packageName]; + + if (ids.Contains(sourceId) || ids.Contains(targetId)) + { + connectorPackageMap[connectorId] = packageName; + + if (ids.Contains(sourceId) && ids.Contains(targetId)) + { + break; + } + } + } + } + + return connectorPackageMap; + } + + /// + /// Builds the xmi:Extension element for a single output XMI file, + /// filtering elements and connectors to include only those belonging to + /// the specified package. + /// + /// + /// The name of the package being written. + /// + /// + /// The set of element IDs belonging to this package. + /// + /// + /// All element entries from the source EA Extension section. + /// + /// + /// All connector entries from the source EA Extension section. + /// + /// + /// The mapping of connector IDs to owning package names. + /// + /// + /// A new xmi:Extension element containing only the filtered entries. + /// + public XElement Build( + string packageName, + HashSet packageIds, + List allElements, + List allConnectors, + Dictionary connectorPackageMap) + { + var filteredElements = allElements + .Where(element => + { + var idref = (string?)element.Attribute(this.xmi + "idref") ?? ""; + return packageIds.Contains(idref); + }) + .Select(element => new XElement(element)) + .ToList(); + + var filteredConnectors = allConnectors + .Where(connector => + { + var idref = (string?)connector.Attribute(this.xmi + "idref") ?? ""; + return connectorPackageMap.TryGetValue(idref, out var owner) && owner == packageName; + }) + .Select(connector => new XElement(connector)) + .ToList(); + + return new XElement(this.xmi + "Extension", + new XAttribute("extender", "Enterprise Architect"), + new XAttribute("extenderID", "6.5"), + new XElement("elements", filteredElements), + new XElement("connectors", filteredConnectors), + new XElement("primitivetypes", + new XElement("packagedElement", + new XAttribute(this.xmi + "type", "uml:Package"), + new XAttribute(this.xmi + "id", "EAPrimitiveTypesPackage"), + new XAttribute("name", "EA_PrimitiveTypes_Package"))), + new XElement("profiles")); + } + } +} diff --git a/XSplinter/Services/IElementIndexer.cs b/XSplinter/Services/IElementIndexer.cs new file mode 100644 index 0000000..56bc024 --- /dev/null +++ b/XSplinter/Services/IElementIndexer.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Services +{ + using System.Collections.Generic; + using System.Xml.Linq; + + /// + /// Defines the contract for indexing the xmi:id and xmi:idref + /// values found within a package subtree. + /// + public interface IElementIndexer + { + /// + /// Scans the given element and all its descendants, recording every + /// xmi:id attribute value into the provided index. + /// + /// + /// The root element of the package subtree to index. + /// + /// + /// The name of the owning package. + /// + /// + /// The output filename for the owning package. + /// + /// + /// The dictionary to populate with element-id to mappings. + /// + void IndexElementIds(XElement element, string packageName, string outputFile, Dictionary index); + + /// + /// Collects all xmi:id and xmi:idref attribute values + /// from the given element and its descendants into a set. + /// + /// + /// The root element of the package subtree to scan. + /// + /// + /// The set to populate with all discovered identifiers. + /// + void CollectAllIds(XElement element, HashSet ids); + } +} diff --git a/XSplinter/Services/IExtensionBuilder.cs b/XSplinter/Services/IExtensionBuilder.cs new file mode 100644 index 0000000..e3adf2c --- /dev/null +++ b/XSplinter/Services/IExtensionBuilder.cs @@ -0,0 +1,68 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Services +{ + using System.Collections.Generic; + using System.Xml.Linq; + + /// + /// Defines the contract for building the filtered EA xmi:Extension section + /// for each split XMI file. + /// + public interface IExtensionBuilder + { + /// + /// Maps each connector (by its xmi:idref) to the package that owns it. + /// + /// + /// The list of connector elements from the EA Extension section. + /// + /// + /// A dictionary mapping each package name to the set of element IDs it contains. + /// + /// + /// The ordered list of package names to iterate over when assigning ownership. + /// + /// + /// A dictionary mapping each connector's xmi:idref to its owning package name. + /// + Dictionary BuildConnectorPackageMap( + List connectors, + Dictionary> packageElementIds, + IEnumerable packageNames); + + /// + /// Builds the xmi:Extension element for a single output XMI file, + /// filtering elements and connectors to include only those belonging to + /// the specified package. + /// + /// + /// The name of the package being written. + /// + /// + /// The set of element IDs belonging to this package. + /// + /// + /// All element entries from the source EA Extension section. + /// + /// + /// All connector entries from the source EA Extension section. + /// + /// + /// The mapping of connector IDs to owning package names. + /// + /// + /// A new xmi:Extension element containing only the filtered entries. + /// + XElement Build( + string packageName, + HashSet packageIds, + List allElements, + List allConnectors, + Dictionary connectorPackageMap); + } +} diff --git a/XSplinter/Services/IReferenceRewriter.cs b/XSplinter/Services/IReferenceRewriter.cs new file mode 100644 index 0000000..e22d155 --- /dev/null +++ b/XSplinter/Services/IReferenceRewriter.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Services +{ + using System.Collections.Generic; + using System.Xml.Linq; + + /// + /// Defines the contract for rewriting cross-package xmi:idref references + /// into cross-file href references. + /// + public interface IReferenceRewriter + { + /// + /// Recursively walks the given element tree and rewrites any xmi:idref + /// on type or constrainedElement nodes that reference an element + /// in a different package into an href="filename.xmi#id" attribute. + /// Intra-package references are left unchanged. + /// + /// + /// The root element of the cloned package subtree to rewrite. + /// + /// + /// The name of the package being written, used to detect cross-package references. + /// + /// + /// The element index mapping each xmi:id to its owning . + /// + void Rewrite(XElement element, string currentPackageName, IReadOnlyDictionary elementIndex); + } +} diff --git a/XSplinter/Services/IXmiFileService.cs b/XSplinter/Services/IXmiFileService.cs new file mode 100644 index 0000000..58c3135 --- /dev/null +++ b/XSplinter/Services/IXmiFileService.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Services +{ + using System.Xml.Linq; + + /// + /// Abstracts the file-system interactions used by the splitter (loading the input + /// XMI, ensuring the output directory exists, and saving output documents), so that + /// the orchestration logic can be unit tested without touching the disk. + /// + public interface IXmiFileService + { + /// + /// Loads the XMI document located at the given path. + /// + /// + /// The path to the XMI file to load. + /// + /// + /// The loaded . + /// + XDocument Load(string path); + + /// + /// Ensures the given output directory exists, creating it if necessary. + /// + /// + /// The directory path to create. + /// + void EnsureDirectory(string path); + + /// + /// Saves the given document to the specified path. + /// + /// + /// The to save. + /// + /// + /// The destination file path. + /// + void Save(XDocument document, string path); + } +} diff --git a/XSplinter/Services/IXmiSplitterService.cs b/XSplinter/Services/IXmiSplitterService.cs new file mode 100644 index 0000000..cc05627 --- /dev/null +++ b/XSplinter/Services/IXmiSplitterService.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Services +{ + using XSplinter.Configuration; + + /// + /// Defines the contract for the orchestrator that splits a monolithic Enterprise + /// Architect XMI export into separate XMI files per package. + /// + public interface IXmiSplitterService + { + /// + /// Splits the monolithic XMI file at into + /// separate files according to the provided , + /// writing the results to the . + /// + /// + /// The path to the monolithic XMI file exported from Enterprise Architect. + /// + /// + /// The splitter configuration defining the root package and child packages. + /// + /// + /// The directory in which to write the split XMI files. + /// + void Split(string inputPath, SplitterConfig config, string outputDirectory); + } +} diff --git a/XSplinter/Services/PackageEntry.cs b/XSplinter/Services/PackageEntry.cs new file mode 100644 index 0000000..dbf36f3 --- /dev/null +++ b/XSplinter/Services/PackageEntry.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Services +{ + /// + /// Associates an element identifier with the package that owns it and the + /// output file that package is written to. + /// + /// + /// The name of the owning package. + /// + /// + /// The output filename for the owning package. + /// + public record PackageEntry(string PackageName, string OutputFile); +} diff --git a/XSplinter/Services/ReferenceRewriter.cs b/XSplinter/Services/ReferenceRewriter.cs new file mode 100644 index 0000000..eaa15be --- /dev/null +++ b/XSplinter/Services/ReferenceRewriter.cs @@ -0,0 +1,79 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Services +{ + using System.Collections.Generic; + using System.Linq; + using System.Xml.Linq; + + /// + /// Rewrites cross-package xmi:idref references into href references + /// that point to the external XMI file containing the target element. + /// This enables UML4NET to resolve types across separate XMI files. + /// + public class ReferenceRewriter : IReferenceRewriter + { + /// + /// The XMI namespace URI. + /// + private readonly XNamespace xmi; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The XMI namespace used in the source document. + /// + public ReferenceRewriter(XNamespace xmiNamespace) + { + this.xmi = xmiNamespace; + } + + /// + public void Rewrite(XElement element, string currentPackageName, IReadOnlyDictionary elementIndex) + { + switch (element.Name.LocalName) + { + case "type": + case "constrainedElement": + this.RewriteIdref(element, currentPackageName, elementIndex); + break; + } + + foreach (var child in element.Elements().ToList()) + { + this.Rewrite(child, currentPackageName, elementIndex); + } + } + + /// + /// Replaces an xmi:idref attribute with an href attribute + /// when the referenced element belongs to a different package. + /// + /// + /// The element whose xmi:idref attribute may be rewritten. + /// + /// + /// The name of the current package. + /// + /// + /// The element index mapping each xmi:id to its owning . + /// + private void RewriteIdref(XElement element, string currentPackageName, IReadOnlyDictionary elementIndex) + { + var idref = (string?)element.Attribute(this.xmi + "idref"); + + if (idref != null + && elementIndex.TryGetValue(idref, out var entry) + && entry.PackageName != currentPackageName) + { + element.Attribute(this.xmi + "idref")!.Remove(); + element.SetAttributeValue("href", $"{entry.OutputFile}#{idref}"); + } + } + } +} diff --git a/XSplinter/Services/XmiFileService.cs b/XSplinter/Services/XmiFileService.cs new file mode 100644 index 0000000..9c57f77 --- /dev/null +++ b/XSplinter/Services/XmiFileService.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Services +{ + using System.IO; + using System.Text; + using System.Xml.Linq; + + /// + /// Default implementation backed by the local file system. + /// Registers the so that the windows-1252 + /// XML declaration used by the output documents can be honoured. + /// + public class XmiFileService : IXmiFileService + { + /// + /// Initializes a new instance of the class. + /// + public XmiFileService() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + /// + public XDocument Load(string path) + { + return XDocument.Load(path); + } + + /// + public void EnsureDirectory(string path) + { + Directory.CreateDirectory(path); + } + + /// + public void Save(XDocument document, string path) + { + document.Save(path); + } + } +} diff --git a/XSplinter/Services/XmiSplitterService.cs b/XSplinter/Services/XmiSplitterService.cs new file mode 100644 index 0000000..256ece3 --- /dev/null +++ b/XSplinter/Services/XmiSplitterService.cs @@ -0,0 +1,387 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// ------------------------------------------------------------------------------------------------ + +namespace XSplinter.Services +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Xml.Linq; + + using Microsoft.Extensions.Logging; + + using XSplinter.Configuration; + + /// + /// Orchestrates the splitting of a monolithic Enterprise Architect XMI export + /// into separate XMI files per package, rewriting cross-package references + /// into href attributes that can be resolved by UML4NET. + /// + public class XmiSplitterService : IXmiSplitterService + { + /// + /// The XMI namespace URI. + /// + private static readonly XNamespace Xmi = "http://www.omg.org/spec/XMI/20131001"; + + /// + /// The UML namespace URI. + /// + private static readonly XNamespace Uml = "http://www.omg.org/spec/UML/20161101"; + + /// + /// The UML DI namespace URI. + /// + private static readonly XNamespace Umldi = "http://www.omg.org/spec/UML/20161101/UMLDI"; + + /// + /// The UML DC namespace URI. + /// + private static readonly XNamespace Dc = "http://www.omg.org/spec/UML/20161101/UMLDC"; + + /// + /// The used to log diagnostic messages. + /// + private readonly ILogger logger; + + /// + /// The element indexer used to build the element-to-package mapping. + /// + private readonly IElementIndexer elementIndexer; + + /// + /// The reference rewriter used to convert cross-package references into cross-file hrefs. + /// + private readonly IReferenceRewriter referenceRewriter; + + /// + /// The extension builder used to create filtered EA Extension sections. + /// + private readonly IExtensionBuilder extensionBuilder; + + /// + /// The file service used to load the input document and persist the outputs. + /// + private readonly IXmiFileService fileService; + + /// + /// Initializes a new instance of the class, + /// wiring up the default concrete collaborators. + /// + /// + /// The used to log diagnostic messages. + /// + public XmiSplitterService(ILogger logger) + : this(logger, new ElementIndexer(Xmi), new ReferenceRewriter(Xmi), new ExtensionBuilder(Xmi), new XmiFileService()) + { + } + + /// + /// Initializes a new instance of the class + /// with the supplied collaborators. This overload enables unit testing with mocks. + /// + /// + /// The used to log diagnostic messages. + /// + /// + /// The used to build the element-to-package mapping. + /// + /// + /// The used to rewrite cross-package references. + /// + /// + /// The used to create filtered EA Extension sections. + /// + /// + /// The used to load and save documents. + /// + public XmiSplitterService( + ILogger logger, + IElementIndexer elementIndexer, + IReferenceRewriter referenceRewriter, + IExtensionBuilder extensionBuilder, + IXmiFileService fileService) + { + this.logger = logger; + this.elementIndexer = elementIndexer; + this.referenceRewriter = referenceRewriter; + this.extensionBuilder = extensionBuilder; + this.fileService = fileService; + } + + /// + /// Splits the monolithic XMI file at into + /// separate files according to the provided , + /// writing the results to the . + /// + /// + /// The path to the monolithic XMI file exported from Enterprise Architect. + /// + /// + /// The splitter configuration defining the root package and child packages. + /// + /// + /// The directory in which to write the split XMI files. + /// + /// + /// Thrown when the root package or a configured child package is not found in the XMI. + /// + public void Split(string inputPath, SplitterConfig config, string outputDirectory) + { + this.fileService.EnsureDirectory(outputDirectory); + + var document = this.fileService.Load(inputPath); + var root = document.Root!; + + var rootPackage = this.FindRootPackage(root, config.RootPackageName); + var packageNodes = this.FindPackageNodes(rootPackage, config); + + var elementIndex = this.BuildElementIndex(config, packageNodes); + + this.logger.LogInformation("Indexed {ElementCount} elements across {PackageCount} packages", elementIndex.Count, config.Packages.Count); + + var packageElementIds = this.BuildPackageElementIds(config, packageNodes); + + var extension = root.Element(Xmi + "Extension"); + var extensionElements = extension?.Element("elements")?.Elements("element").ToList() ?? []; + var extensionConnectors = extension?.Element("connectors")?.Elements("connector").ToList() ?? []; + + var connectorPackageMap = this.extensionBuilder.BuildConnectorPackageMap( + extensionConnectors, + packageElementIds, + config.Packages.Select(packageConfig => packageConfig.Name)); + + foreach (var packageConfig in config.Packages) + { + var clonedPackage = new XElement(packageNodes[packageConfig.Name]); + + this.referenceRewriter.Rewrite(clonedPackage, packageConfig.Name, elementIndex); + + var outputDocument = packageConfig.ConvertToLibrary + ? BuildLibraryOutputDocument(clonedPackage) + : this.BuildOutputDocument( + clonedPackage, + packageConfig, + packageElementIds[packageConfig.Name], + extensionElements, + extensionConnectors, + connectorPackageMap); + + var outputPath = Path.Combine(outputDirectory, packageConfig.OutputFile); + this.fileService.Save(outputDocument, outputPath); + + this.logger.LogInformation("Written: {OutputPath}", outputPath); + } + + this.logger.LogInformation("Splitting complete"); + } + + /// + /// Locates the root container package (e.g. "5. Data Structure") inside + /// the uml:Model element. + /// + /// + /// The root xmi:XMI element of the source document. + /// + /// + /// The expected name of the root container package. + /// + /// + /// The representing the root container package. + /// + /// + /// Thrown when no package with the given name is found. + /// + private XElement FindRootPackage(XElement root, string rootPackageName) + { + var model = root.Element(Uml + "Model") + ?? throw new InvalidOperationException("No uml:Model element found in the XMI document."); + + return model + .Elements("packagedElement") + .FirstOrDefault(element => (string?)element.Attribute("name") == rootPackageName) + ?? throw new InvalidOperationException($"Root package '{rootPackageName}' not found in the XMI document."); + } + + /// + /// Locates the for each configured child package + /// within the root container package. + /// + /// + /// The root container package element. + /// + /// + /// The splitter configuration. + /// + /// + /// A dictionary mapping each package name to its . + /// + /// + /// Thrown when a configured package is not found. + /// + private Dictionary FindPackageNodes(XElement rootPackage, SplitterConfig config) + { + var packageNodes = new Dictionary(); + + foreach (var packageConfig in config.Packages) + { + var packageElement = rootPackage + .Elements("packagedElement") + .FirstOrDefault(element => + (string?)element.Attribute(Xmi + "type") == "uml:Package" + && (string?)element.Attribute("name") == packageConfig.Name) + ?? throw new InvalidOperationException( + $"Package '{packageConfig.Name}' not found under '{config.RootPackageName}'."); + + packageNodes[packageConfig.Name] = packageElement; + } + + return packageNodes; + } + + /// + /// Builds the global element index mapping every xmi:id across + /// all configured packages to its owning . + /// + /// + /// The splitter configuration. + /// + /// + /// The dictionary of package name to package . + /// + /// + /// The populated element index. + /// + private Dictionary BuildElementIndex( + SplitterConfig config, + Dictionary packageNodes) + { + var elementIndex = new Dictionary(); + + foreach (var packageConfig in config.Packages) + { + var packageElement = packageNodes[packageConfig.Name]; + this.elementIndexer.IndexElementIds(packageElement, packageConfig.Name, packageConfig.OutputFile, elementIndex); + } + + return elementIndex; + } + + /// + /// Builds a dictionary mapping each package name to the set of all + /// element identifiers (both xmi:id and xmi:idref) found + /// within that package. Used for filtering EA Extension entries. + /// + /// + /// The splitter configuration. + /// + /// + /// The dictionary of package name to package . + /// + /// + /// A dictionary mapping each package name to its set of element identifiers. + /// + private Dictionary> BuildPackageElementIds( + SplitterConfig config, + Dictionary packageNodes) + { + var packageElementIds = new Dictionary>(); + + foreach (var packageConfig in config.Packages) + { + var ids = new HashSet(); + this.elementIndexer.CollectAllIds(packageNodes[packageConfig.Name], ids); + packageElementIds[packageConfig.Name] = ids; + } + + return packageElementIds; + } + + /// + /// Constructs a standard library output for a package + /// that does not retain the Enterprise Architect model structure. The output + /// uses a uml:Package root element without the uml:Model wrapper + /// or xmi:Extension section. + /// + /// + /// The cloned and rewritten package element. + /// + /// + /// The library output . + /// + private static XDocument BuildLibraryOutputDocument(XElement packageElement) + { + return new XDocument( + new XDeclaration("1.0", "windows-1252", null), + new XElement(Xmi + "XMI", + new XAttribute(XNamespace.Xmlns + "xmi", Xmi), + new XAttribute(XNamespace.Xmlns + "uml", Uml), + new XElement(Uml + "Package", + new XAttribute(Xmi + "type", "uml:Package"), + packageElement.Attribute(Xmi + "id") is { } idAttr ? new XAttribute(Xmi + "id", idAttr.Value) : null!, + new XAttribute("name", (string?)packageElement.Attribute("name") ?? ""), + packageElement.Elements()))); + } + + /// + /// Constructs the complete output for a single + /// package, including the XMI envelope, UML model wrapper, and filtered + /// EA Extension section. + /// + /// + /// The cloned and rewritten package element. + /// + /// + /// The configuration for this package. + /// + /// + /// The set of element identifiers belonging to this package. + /// + /// + /// All element entries from the source EA Extension section. + /// + /// + /// All connector entries from the source EA Extension section. + /// + /// + /// The mapping of connector IDs to owning package names. + /// + /// + /// The complete output . + /// + private XDocument BuildOutputDocument( + XElement packageElement, + PackageConfig packageConfig, + HashSet packageIds, + List extensionElements, + List extensionConnectors, + Dictionary connectorPackageMap) + { + return new XDocument( + new XDeclaration("1.0", "windows-1252", null), + new XElement(Xmi + "XMI", + new XAttribute(XNamespace.Xmlns + "xmi", Xmi), + new XAttribute(XNamespace.Xmlns + "uml", Uml), + new XAttribute(XNamespace.Xmlns + "umldi", Umldi), + new XAttribute(XNamespace.Xmlns + "dc", Dc), + new XElement(Xmi + "Documentation", + new XAttribute("exporter", "Enterprise Architect"), + new XAttribute("exporterVersion", "6.5"), + new XAttribute("exporterID", "1704")), + new XElement(Uml + "Model", + new XAttribute(Xmi + "type", "uml:Model"), + new XAttribute("name", "EA_Model"), + packageElement), + this.extensionBuilder.Build( + packageConfig.Name, + packageIds, + extensionElements, + extensionConnectors, + connectorPackageMap))); + } + } +} diff --git a/XSplinter/XSplinter.csproj b/XSplinter/XSplinter.csproj new file mode 100644 index 0000000..bca68c2 --- /dev/null +++ b/XSplinter/XSplinter.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + XSplinter + + + + + + + + diff --git a/example/packages.json b/example/packages.json new file mode 100644 index 0000000..6420910 --- /dev/null +++ b/example/packages.json @@ -0,0 +1,8 @@ +{ + "rootPackageName": "5. Data Structure", + "packages": [ + { "name": "Primitives", "outputFile": "CSharp_Primitives.xmi", "convertToLibrary": true }, + { "name": "Forge", "outputFile": "Forge.xmi" }, + { "name": "FunctionalData", "outputFile": "FunctionalData.xmi" } + ] +} From 863c67ff0ede9579636404dc4f31ebdb02f3da23 Mon Sep 17 00:00:00 2001 From: atheate Date: Thu, 30 Jul 2026 16:23:37 +0200 Subject: [PATCH 2/6] github templates and CONTRIBUTING --- .github/CONTRIBUTING.md | 66 ++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/bug_or_feature.md | 34 ++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 11 ++++ 3 files changed, 111 insertions(+) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/bug_or_feature.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..0da2afd --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# How to contribute + +We would like to start with saying thank you for wanting to contribute to the X-Splinter codebase. We want to keep it as easy as possible to contribute changes that get things working in your environment. There are a few guidelines that we need contributors to follow so that we have a chance of keeping on top of things. + +- [Making Changes](#making-changes) + - [Handling Updates from Upstream/Development](#handling-updates-from-upstreamdevelopment) + - [Sending a Pull Request](#sending-a-pull-request) +- [Style Guidelines](#style-guidelines) + +## Making Changes + +1. [Fork](http://help.github.com/forking/) on GitHub +1. Clone your fork locally +1. Configure the upstream repo (`git remote add upstream git://github.com/STARIONGROUP/X-Splinter`) +1. Checkout development +1. Create a local branch (`git checkout -b myBranch`) from development +1. Work on your feature +1. Rebase if required (see below) +1. Push the branch up to GitHub (`git push origin myBranch`) +1. Send a Pull Request on GitHub + +You should **never** work on a clone of master or development, and you should **never** send a pull request from master or development - always from a branch. The reasons for this are detailed below. + +### Handling Updates from Upstream/Development + +While you're working away in your branch it's quite possible that your upstream development (most likely the canonical X-Splinter version) may be updated. If this happens you should: + +1. [Stash](http://git-scm.com/book/en/Git-Tools-Stashing) any un-committed changes you need to +1. `git checkout development` +1. `git pull upstream development` +1. `git checkout myBranch` +1. `git rebase development myBranch` +1. `git push origin development` - (optional) this makes sure your remote development is up to date + +This ensures that your history is "clean" i.e. you have one branch off from development followed by your changes in a straight line. Failing to do this ends up with several "messy" merges in your history, which we don't want. This is the reason why you should always work in a branch and you should never be working in, or sending pull requests from, development. + +If you're working on a long running feature then you may want to do this quite often, rather than run the risk of potential merge issues further down the line. + +### Sending a Pull Request + +While working on your feature you may well create several branches, which is fine, but before you send a pull request you should ensure that you have rebased back to a single "Feature branch". We care about your commits, and we care about your feature branch; but we don't care about how many or which branches you created while you were working on it :smile:. + +When you're ready to go you should confirm that you are up to date and rebased with upstream/development (see "Handling Updates from Upstream/development" above), and then: + +1. `git push origin myBranch` +1. Send a descriptive [Pull Request](https://help.github.com/articles/creating-a-pull-request/) on GitHub - making sure you have selected the correct branch in the GitHub UI! +1. Wait for a maintainer to merge your changes in. + +And remember; **A pull-request with tests is a pull-request that's likely to be pulled in.** :grin: + +## Style Guidelines + +- Indent with 4 spaces, **not** tabs. +- No underscore (`_`) prefix for member names. +- Use `this` when accessing instance members, e.g. `this.Name = "X-Splinter";`. +- Use the `var` keyword unless the inferred type is not obvious. +- Use the C# type aliases for types that have them, e.g. `int` instead of `Int32`, `string` instead of `String` etc. +- Use meaningful names (no hungarian notation), we like long descriptive names of methods, variables and parameters. +- Wrap `if`, `else` and `using` blocks (or blocks in general, really) in curly braces, even if it's a single line. +- Put `using` statements inside namespace. +- One type per file. +- Add the Starion Group copyright header to every file. +- Pay attention to whitespace and extra blank lines +- Absolutely **no** regions + +> Please pay attention to the style of existing code and keep new contributions consistent with it. diff --git a/.github/ISSUE_TEMPLATE/bug_or_feature.md b/.github/ISSUE_TEMPLATE/bug_or_feature.md new file mode 100644 index 0000000..c3b39f5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_or_feature.md @@ -0,0 +1,34 @@ +--- +name: "Bug or Feature Report" +about: "Report a bug or request a feature for X-Splinter" +title: "[Bug|Feature]: " +labels: [] +assignees: [] +--- + +### What type of issue is this? + +- [ ] Bug report +- [ ] Feature request + +### Prerequisites + +- [ ] I have written a descriptive issue title +- [ ] I have verified that I am running the latest version of the X-Splinter +- [ ] I have searched [open](https://github.com/STARIONGROUP/X-Splinter/issues) and [closed](https://github.com/STARIONGROUP/X-Splinter/issues?q=is%3Aissue+is%3Aclosed) issues to ensure it has not already been reported + +### Description + + +### Steps to Reproduce + + +### System Configuration + + +- X-Splinter version: +- Environment (Operating system, version and so on): +- .NET version: +- Additional information: + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7d1108a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +### Prerequisites + +- [ ] I have written a descriptive pull-request title +- [ ] I have verified that there are no overlapping [pull-requests](https://github.com/STARIONGROUP/X-Splinter/pulls) open +- [ ] I have verified that I am following the X-Splinter [code style guidelines](https://raw.githubusercontent.com/STARIONGROUP/X-Splinter/development/.github/CONTRIBUTING.md) +- [ ] I have provided test coverage for my change (where applicable) + +### Description + + + From 096fe55b6a322bac94f807eaa0925abb6ffadfbd Mon Sep 17 00:00:00 2001 From: atheate Date: Fri, 31 Jul 2026 08:47:58 +0200 Subject: [PATCH 3/6] header fix + GH Actions --- README.md | 19 +++++++++++++++++++ .../Services/ElementIndexerTestFixture.cs | 2 ++ .../Services/ExtensionBuilderTestFixture.cs | 2 ++ .../Services/ReferenceRewriterTestFixture.cs | 2 ++ .../Services/XmiSplitterServiceTestFixture.cs | 8 ++++++++ XSplinter/Configuration/PackageConfig.cs | 2 ++ XSplinter/Configuration/SplitterConfig.cs | 2 ++ XSplinter/Program.cs | 2 ++ XSplinter/Services/ElementIndexer.cs | 2 ++ XSplinter/Services/ExtensionBuilder.cs | 2 ++ XSplinter/Services/IElementIndexer.cs | 2 ++ XSplinter/Services/IExtensionBuilder.cs | 2 ++ XSplinter/Services/IReferenceRewriter.cs | 2 ++ XSplinter/Services/IXmiFileService.cs | 2 ++ XSplinter/Services/IXmiSplitterService.cs | 2 ++ XSplinter/Services/PackageEntry.cs | 2 ++ XSplinter/Services/ReferenceRewriter.cs | 2 ++ XSplinter/Services/XmiFileService.cs | 2 ++ XSplinter/Services/XmiSplitterService.cs | 2 ++ XSplinter/XSplinter.csproj | 15 +++++++++++++++ 20 files changed, 76 insertions(+) diff --git a/README.md b/README.md index 5937310..6e16def 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ **X-Splinter** is a .NET 10 command-line tool that splits a single monolithic Enterprise Architect (EA) XMI export into separate, per-package XMI files. Cross-package references are rewritten from internal `xmi:idref`s into cross-file `href="targetFile.xmi#id"` references, so the resulting files can be loaded independently while types still resolve across files (e.g. with UML4NET). +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=STARIONGROUP_X-Splinter&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=STARIONGROUP_X-Splinter) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=STARIONGROUP_X-Splinter&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=STARIONGROUP_X-Splinter) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=STARIONGROUP_X-Splinter&metric=coverage)](https://sonarcloud.io/summary/new_code?id=STARIONGROUP_X-Splinter) +[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=STARIONGROUP_X-Splinter&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=STARIONGROUP_X-Splinter) +[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=STARIONGROUP_X-Splinter&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=STARIONGROUP_X-Splinter) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=STARIONGROUP_X-Splinter&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=STARIONGROUP_X-Splinter) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=STARIONGROUP_X-Splinter&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=STARIONGROUP_X-Splinter) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=STARIONGROUP_X-Splinter&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=STARIONGROUP_X-Splinter) +[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=STARIONGROUP_X-Splinter&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=STARIONGROUP_X-Splinter) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=STARIONGROUP_X-Splinter&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=STARIONGROUP_X-Splinter) + ## Features - Splits one EA XMI export into one output file per configured UML package. @@ -50,6 +61,14 @@ The configuration is a JSON file describing the root container package and the c A sample configuration is available in [`example/packages.json`](example/packages.json). +## Build Status + +GitHub actions are used to build and test the solution. + +Branch | Build Status +------- | :------------ +Development | ![Build Status](https://github.com/STARIONGROUP/X-Splinter/actions/workflows/CodeQuality.yml/badge.svg?branch=development) + # License X-Splinter is provided to the community under the Apache License 2.0. See the [LICENSE](LICENSE) file for the full text. diff --git a/XSplinter.Tests/Services/ElementIndexerTestFixture.cs b/XSplinter.Tests/Services/ElementIndexerTestFixture.cs index 021ea72..a7de80e 100644 --- a/XSplinter.Tests/Services/ElementIndexerTestFixture.cs +++ b/XSplinter.Tests/Services/ElementIndexerTestFixture.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs b/XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs index 4164504..f7d2f2a 100644 --- a/XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs +++ b/XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter.Tests/Services/ReferenceRewriterTestFixture.cs b/XSplinter.Tests/Services/ReferenceRewriterTestFixture.cs index 60f5850..78d63f5 100644 --- a/XSplinter.Tests/Services/ReferenceRewriterTestFixture.cs +++ b/XSplinter.Tests/Services/ReferenceRewriterTestFixture.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter.Tests/Services/XmiSplitterServiceTestFixture.cs b/XSplinter.Tests/Services/XmiSplitterServiceTestFixture.cs index d12525d..9ecb59b 100644 --- a/XSplinter.Tests/Services/XmiSplitterServiceTestFixture.cs +++ b/XSplinter.Tests/Services/XmiSplitterServiceTestFixture.cs @@ -1,3 +1,11 @@ +// ------------------------------------------------------------------------------------------------ +// +// Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 +// +// ------------------------------------------------------------------------------------------------ + namespace XSplinter.Tests.Services { using System.Xml.Linq; diff --git a/XSplinter/Configuration/PackageConfig.cs b/XSplinter/Configuration/PackageConfig.cs index 3a75705..51e091f 100644 --- a/XSplinter/Configuration/PackageConfig.cs +++ b/XSplinter/Configuration/PackageConfig.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Configuration/SplitterConfig.cs b/XSplinter/Configuration/SplitterConfig.cs index 9ea2afa..5938e60 100644 --- a/XSplinter/Configuration/SplitterConfig.cs +++ b/XSplinter/Configuration/SplitterConfig.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Program.cs b/XSplinter/Program.cs index d676acb..8e92505 100644 --- a/XSplinter/Program.cs +++ b/XSplinter/Program.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Services/ElementIndexer.cs b/XSplinter/Services/ElementIndexer.cs index 5a555a3..ac0e2f4 100644 --- a/XSplinter/Services/ElementIndexer.cs +++ b/XSplinter/Services/ElementIndexer.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Services/ExtensionBuilder.cs b/XSplinter/Services/ExtensionBuilder.cs index a1398f4..a791a90 100644 --- a/XSplinter/Services/ExtensionBuilder.cs +++ b/XSplinter/Services/ExtensionBuilder.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Services/IElementIndexer.cs b/XSplinter/Services/IElementIndexer.cs index 56bc024..28f5f51 100644 --- a/XSplinter/Services/IElementIndexer.cs +++ b/XSplinter/Services/IElementIndexer.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Services/IExtensionBuilder.cs b/XSplinter/Services/IExtensionBuilder.cs index e3adf2c..7da17ce 100644 --- a/XSplinter/Services/IExtensionBuilder.cs +++ b/XSplinter/Services/IExtensionBuilder.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Services/IReferenceRewriter.cs b/XSplinter/Services/IReferenceRewriter.cs index e22d155..9680c58 100644 --- a/XSplinter/Services/IReferenceRewriter.cs +++ b/XSplinter/Services/IReferenceRewriter.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Services/IXmiFileService.cs b/XSplinter/Services/IXmiFileService.cs index 58c3135..0183e78 100644 --- a/XSplinter/Services/IXmiFileService.cs +++ b/XSplinter/Services/IXmiFileService.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Services/IXmiSplitterService.cs b/XSplinter/Services/IXmiSplitterService.cs index cc05627..204b976 100644 --- a/XSplinter/Services/IXmiSplitterService.cs +++ b/XSplinter/Services/IXmiSplitterService.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Services/PackageEntry.cs b/XSplinter/Services/PackageEntry.cs index dbf36f3..92c2d67 100644 --- a/XSplinter/Services/PackageEntry.cs +++ b/XSplinter/Services/PackageEntry.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Services/ReferenceRewriter.cs b/XSplinter/Services/ReferenceRewriter.cs index eaa15be..f108d19 100644 --- a/XSplinter/Services/ReferenceRewriter.cs +++ b/XSplinter/Services/ReferenceRewriter.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Services/XmiFileService.cs b/XSplinter/Services/XmiFileService.cs index 9c57f77..c222f8b 100644 --- a/XSplinter/Services/XmiFileService.cs +++ b/XSplinter/Services/XmiFileService.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/Services/XmiSplitterService.cs b/XSplinter/Services/XmiSplitterService.cs index 256ece3..dc26e98 100644 --- a/XSplinter/Services/XmiSplitterService.cs +++ b/XSplinter/Services/XmiSplitterService.cs @@ -1,6 +1,8 @@ // ------------------------------------------------------------------------------------------------ // // Copyright (c) 2026 Starion Group S.A. +// +// SPDX-License-Identifier: Apache-2.0 // // ------------------------------------------------------------------------------------------------ diff --git a/XSplinter/XSplinter.csproj b/XSplinter/XSplinter.csproj index bca68c2..630b08b 100644 --- a/XSplinter/XSplinter.csproj +++ b/XSplinter/XSplinter.csproj @@ -8,6 +8,21 @@ XSplinter + + 1.0.0 + X-Splinter + X-Splinter + A .NET command line tool that splits a monolithic Enterprise Architect (EA) XMI export into separate per-package XMI files, rewriting cross-package references into cross-file href references so the pieces still resolve against each other. + Sam Gerené, Antoine Théate + Starion Group S.A. + Copyright © 2026 Starion Group S.A. + Apache-2.0 + EA XMI UML EnterpriseArchitect splitter + https://github.com/STARIONGROUP/X-Splinter.git + Git + en-US + + From 9089606cf3d50caca48b24e5abb340af3da2f661 Mon Sep 17 00:00:00 2001 From: atheate Date: Fri, 31 Jul 2026 08:48:07 +0200 Subject: [PATCH 4/6] GH actions --- .github/workflows/CodeQuality.yml | 54 ++++++++ .github/workflows/codeql-analysis.yml | 39 ++++++ .github/workflows/nuget-reference-check.yml | 138 ++++++++++++++++++++ X-Splinter.sln.DotSettings | 9 ++ 4 files changed, 240 insertions(+) create mode 100644 .github/workflows/CodeQuality.yml create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/nuget-reference-check.yml create mode 100644 X-Splinter.sln.DotSettings diff --git a/.github/workflows/CodeQuality.yml b/.github/workflows/CodeQuality.yml new file mode 100644 index 0000000..9689f2c --- /dev/null +++ b/.github/workflows/CodeQuality.yml @@ -0,0 +1,54 @@ +name: Build & Test & SonarQube + +on: + push: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Set up JDK 17 + uses: actions/setup-java@v5.2.0 + with: + distribution: 'temurin' + java-version: '17' + overwrite-settings: false + - name: Setup dotnet + uses: actions/setup-dotnet@v5.1.0 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore X-Splinter.sln + + - name: Sonarqube Begin + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_SCANNER_OPTS: "-Xmx4096m" + run: | + dotnet tool install --global dotnet-sonarscanner + dotnet tool install --global dotnet-coverage + dotnet sonarscanner begin /k:"STARIONGROUP_X-Splinter" /o:"stariongroup" /d:sonar.token="$SONAR_TOKEN" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths=coverage.xml + + - name: Build + run: dotnet build --no-restore --no-incremental /p:ContinuousIntegrationBuild=true + + - name: Run Tests and Compute Coverage + run: dotnet-coverage collect "dotnet test X-Splinter.sln --no-restore --no-build --verbosity normal" -f xml -o "coverage.xml" + + - name: Sonarqube end + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: dotnet sonarscanner end /d:sonar.token="$SONAR_TOKEN" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..7511a0d --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,39 @@ +name: "Code scanning - action" + +on: + push: + pull_request: + schedule: + - cron: '0 18 * * 1' + +jobs: + CodeQL-Build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + # Override language selection by uncommenting this and choosing your languages + with: + languages: csharp + + - name: Setup .NET Core + uses: actions/setup-dotnet@v5.1.0 + with: + dotnet-version: '10.0.x' + - name: Install dependencies + run: dotnet restore + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/nuget-reference-check.yml b/.github/workflows/nuget-reference-check.yml new file mode 100644 index 0000000..9163830 --- /dev/null +++ b/.github/workflows/nuget-reference-check.yml @@ -0,0 +1,138 @@ +name: "nuget package reference check" + +on: + push: + pull_request: + schedule: + - cron: '0 8 * * *' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + - name: Setup .NET Environment + uses: actions/setup-dotnet@v5.1.0 + with: + dotnet-version: 10.0.x + + - name: Check for outdated packages + id: outdated + run: | + set -e + + # Packages to ignore: SDK/runtime-managed, plus deliberately low-floored stable abstractions. + IGNORE_PACKAGES="Microsoft\.NETCore\.Platforms|Microsoft\.NETCore\.Targets|Microsoft\.Extensions\.Logging\.Abstractions" + + dotnet list X-Splinter.sln package --outdated --include-transitive > outdated-raw.log + + # Filter out ignored core packages + grep -v -E "$IGNORE_PACKAGES" outdated-raw.log > outdated.log || true + + # Print full outdated report (including test projects) to action log + echo "=== Full outdated packages report ===" + cat outdated.log + + # Build issue log: exclude test project sections + # dotnet list output groups packages under project headers like: + # Project `ProjectName` has the following updates available: + # We remove sections for *.Tests projects + awk ' + /^Project .*.Tests/ { skip=1; next } + /^Project / { skip=0 } + !skip { print } + ' outdated.log > outdated-issue.log + + # Check if non-test outdated packages exist (look for > lines indicating actual packages) + if grep -q ">" outdated-issue.log; then + echo "Outdated packages found (non-test)" + echo "outdated=true" >> $GITHUB_OUTPUT + else + echo "No outdated packages found in non-test projects" + echo "outdated=false" >> $GITHUB_OUTPUT + fi + + - name: Check for deprecated packages + id: deprecated + run: | + set -e + dotnet list X-Splinter.sln package --deprecated --include-transitive > deprecated.log + if [ -s deprecated.log ]; then + echo "Deprecated packages found" + echo "deprecated=true" >> $GITHUB_OUTPUT + else + echo "No deprecated packages found" + echo "deprecated=false" >> $GITHUB_OUTPUT + fi + + - name: Check for vulnerable packages + id: vulnerable + run: | + set -e + dotnet list X-Splinter.sln package --vulnerable --include-transitive > vulnerabilities.log + if grep -q -i "\bcritical\b\|\bhigh\b\|\bmoderate\b\|\blow\b" vulnerabilities.log; then + echo "Security Vulnerabilities found" + echo "vulnerable=true" >> $GITHUB_OUTPUT + else + echo "No Security Vulnerabilities found" + echo "vulnerable=false" >> $GITHUB_OUTPUT + fi + + - name: Create GitHub Issue if issues found + if: steps.outdated.outputs.outdated == 'true' || steps.deprecated.outputs.deprecated == 'true' || steps.vulnerable.outputs.vulnerable == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + + let issueBody = `### NuGet Package Issues Detected in [X-Splinter](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY})\n\n`; + + if ('${{ steps.outdated.outputs.outdated }}' === 'true') { + const outdatedLog = fs.readFileSync('outdated-issue.log', 'utf8'); + issueBody += `#### Outdated Packages\n\`\`\`\n${outdatedLog}\n\`\`\`\n\n`; + } + + if ('${{ steps.deprecated.outputs.deprecated }}' === 'true') { + const deprecatedLog = fs.readFileSync('deprecated.log', 'utf8'); + issueBody += `#### Deprecated Packages\n\`\`\`\n${deprecatedLog}\n\`\`\`\n\n`; + } + + if ('${{ steps.vulnerable.outputs.vulnerable }}' === 'true') { + const vulnerabilitiesLog = fs.readFileSync('vulnerabilities.log', 'utf8'); + issueBody += `#### Vulnerable Packages\n\`\`\`\n${vulnerabilitiesLog}\n\`\`\`\n\n`; + } + + issueBody += '**Action Required:** Please review and update the affected packages.'; + + const issueTitle = 'NuGet Package Issues Detected'; + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + }); + + const existingIssue = issues.find(issue => issue.title === issueTitle); + + if (existingIssue) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existingIssue.number, + body: `New check results:\n${issueBody}`, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: issueBody, + labels: ['dependencies', 'maintenance'], + }); + } diff --git a/X-Splinter.sln.DotSettings b/X-Splinter.sln.DotSettings new file mode 100644 index 0000000..c7fb4e7 --- /dev/null +++ b/X-Splinter.sln.DotSettings @@ -0,0 +1,9 @@ + + ------------------------------------------------------------------------------------------------ +<copyright file="${File.FileName}" company="Starion Group S.A."> + Copyright (c) ${CurrentDate.Year} Starion Group S.A. + + SPDX-License-Identifier: Apache-2.0 +</copyright> +------------------------------------------------------------------------------------------------ + \ No newline at end of file From 124af5f141a7c18fc915a440d3ff44df7ea3107b Mon Sep 17 00:00:00 2001 From: atheate Date: Fri, 31 Jul 2026 08:52:25 +0200 Subject: [PATCH 5/6] excludes Program.cs from coverage --- XSplinter/Program.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/XSplinter/Program.cs b/XSplinter/Program.cs index 8e92505..532cb91 100644 --- a/XSplinter/Program.cs +++ b/XSplinter/Program.cs @@ -9,6 +9,7 @@ namespace XSplinter { using System; + using System.Diagnostics.CodeAnalysis; using System.IO; using System.Text.Json; @@ -22,6 +23,7 @@ namespace XSplinter /// Splits a monolithic Enterprise Architect XMI export into separate /// XMI files per package with cross-file href references. /// + [ExcludeFromCodeCoverage] public class Program { /// From 41669a16fdda535f0c1f22ddaf106ace0ee32d4d Mon Sep 17 00:00:00 2001 From: atheate Date: Fri, 31 Jul 2026 09:08:45 +0200 Subject: [PATCH 6/6] SQ fix --- .../Services/ExtensionBuilderTestFixture.cs | 4 +- XSplinter/Program.cs | 22 ++++++-- XSplinter/Services/ExtensionBuilder.cs | 15 ++++-- XSplinter/Services/XmiSplitterService.cs | 51 +++++++++---------- 4 files changed, 54 insertions(+), 38 deletions(-) diff --git a/XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs b/XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs index f7d2f2a..c311b4f 100644 --- a/XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs +++ b/XSplinter.Tests/Services/ExtensionBuilderTestFixture.cs @@ -27,6 +27,8 @@ public class ExtensionBuilderTestFixture private XNamespace xmi; private ExtensionBuilder extensionBuilder; + private static readonly string[] PackageNames = ["Forge", "Primitives"]; + [SetUp] public void Setup() { @@ -48,7 +50,7 @@ public void Verify_that_a_connector_is_assigned_to_the_package_owning_its_endpoi ["Primitives"] = ["x"] }; - var map = this.extensionBuilder.BuildConnectorPackageMap(connectors, packageElementIds, new[] { "Forge", "Primitives" }); + var map = this.extensionBuilder.BuildConnectorPackageMap(connectors, packageElementIds, PackageNames); Assert.That(map["conn1"], Is.EqualTo("Forge")); } diff --git a/XSplinter/Program.cs b/XSplinter/Program.cs index 532cb91..be23935 100644 --- a/XSplinter/Program.cs +++ b/XSplinter/Program.cs @@ -24,8 +24,13 @@ namespace XSplinter /// XMI files per package with cross-file href references. /// [ExcludeFromCodeCoverage] - public class Program + public static class Program { + /// + /// The JSON serializer options used to deserialize the configuration file. + /// + private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNameCaseInsensitive = true }; + /// /// The application entry point. /// @@ -44,7 +49,7 @@ public static int Main(string[] args) .AddConsole(); }); - var logger = loggerFactory.CreateLogger(); + var logger = loggerFactory.CreateLogger(nameof(Program)); if (args.Length < 2) { @@ -56,11 +61,18 @@ public static int Main(string[] args) var configPath = args[1]; var outputDirectory = "."; - for (var argIndex = 2; argIndex < args.Length; argIndex++) + var argIndex = 2; + + while (argIndex < args.Length) { if (args[argIndex] == "--output" && argIndex + 1 < args.Length) { - outputDirectory = args[++argIndex]; + outputDirectory = args[argIndex + 1]; + argIndex += 2; + } + else + { + argIndex++; } } @@ -78,7 +90,7 @@ public static int Main(string[] args) var config = JsonSerializer.Deserialize( File.ReadAllText(configPath), - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + SerializerOptions); if (config == null) { diff --git a/XSplinter/Services/ExtensionBuilder.cs b/XSplinter/Services/ExtensionBuilder.cs index a791a90..f8f45b9 100644 --- a/XSplinter/Services/ExtensionBuilder.cs +++ b/XSplinter/Services/ExtensionBuilder.cs @@ -19,6 +19,11 @@ namespace XSplinter.Services /// public class ExtensionBuilder : IExtensionBuilder { + /// + /// The local name of the xmi:idref attribute. + /// + private const string IdRefAttribute = "idref"; + /// /// The XMI namespace URI. /// @@ -61,9 +66,9 @@ public Dictionary BuildConnectorPackageMap( foreach (var connector in connectors) { - var connectorId = (string?)connector.Attribute(this.xmi + "idref") ?? ""; - var sourceId = (string?)connector.Element("source")?.Attribute(this.xmi + "idref") ?? ""; - var targetId = (string?)connector.Element("target")?.Attribute(this.xmi + "idref") ?? ""; + var connectorId = (string?)connector.Attribute(this.xmi + IdRefAttribute) ?? ""; + var sourceId = (string?)connector.Element("source")?.Attribute(this.xmi + IdRefAttribute) ?? ""; + var targetId = (string?)connector.Element("target")?.Attribute(this.xmi + IdRefAttribute) ?? ""; foreach (var packageName in packageNames) { @@ -117,7 +122,7 @@ public XElement Build( var filteredElements = allElements .Where(element => { - var idref = (string?)element.Attribute(this.xmi + "idref") ?? ""; + var idref = (string?)element.Attribute(this.xmi + IdRefAttribute) ?? ""; return packageIds.Contains(idref); }) .Select(element => new XElement(element)) @@ -126,7 +131,7 @@ public XElement Build( var filteredConnectors = allConnectors .Where(connector => { - var idref = (string?)connector.Attribute(this.xmi + "idref") ?? ""; + var idref = (string?)connector.Attribute(this.xmi + IdRefAttribute) ?? ""; return connectorPackageMap.TryGetValue(idref, out var owner) && owner == packageName; }) .Select(connector => new XElement(connector)) diff --git a/XSplinter/Services/XmiSplitterService.cs b/XSplinter/Services/XmiSplitterService.cs index dc26e98..79a64ba 100644 --- a/XSplinter/Services/XmiSplitterService.cs +++ b/XSplinter/Services/XmiSplitterService.cs @@ -139,12 +139,15 @@ public void Split(string inputPath, SplitterConfig config, string outputDirector var document = this.fileService.Load(inputPath); var root = document.Root!; - var rootPackage = this.FindRootPackage(root, config.RootPackageName); - var packageNodes = this.FindPackageNodes(rootPackage, config); + var rootPackage = FindRootPackage(root, config.RootPackageName); + var packageNodes = FindPackageNodes(rootPackage, config); var elementIndex = this.BuildElementIndex(config, packageNodes); - this.logger.LogInformation("Indexed {ElementCount} elements across {PackageCount} packages", elementIndex.Count, config.Packages.Count); + if (this.logger.IsEnabled(LogLevel.Information)) + { + this.logger.LogInformation("Indexed {ElementCount} elements across {PackageCount} packages", elementIndex.Count, config.Packages.Count); + } var packageElementIds = this.BuildPackageElementIds(config, packageNodes); @@ -176,7 +179,10 @@ public void Split(string inputPath, SplitterConfig config, string outputDirector var outputPath = Path.Combine(outputDirectory, packageConfig.OutputFile); this.fileService.Save(outputDocument, outputPath); - this.logger.LogInformation("Written: {OutputPath}", outputPath); + if (this.logger.IsEnabled(LogLevel.Information)) + { + this.logger.LogInformation("Written: {OutputPath}", outputPath); + } } this.logger.LogInformation("Splitting complete"); @@ -198,7 +204,7 @@ public void Split(string inputPath, SplitterConfig config, string outputDirector /// /// Thrown when no package with the given name is found. /// - private XElement FindRootPackage(XElement root, string rootPackageName) + private static XElement FindRootPackage(XElement root, string rootPackageName) { var model = root.Element(Uml + "Model") ?? throw new InvalidOperationException("No uml:Model element found in the XMI document."); @@ -225,24 +231,17 @@ private XElement FindRootPackage(XElement root, string rootPackageName) /// /// Thrown when a configured package is not found. /// - private Dictionary FindPackageNodes(XElement rootPackage, SplitterConfig config) + private static Dictionary FindPackageNodes(XElement rootPackage, SplitterConfig config) { - var packageNodes = new Dictionary(); - - foreach (var packageConfig in config.Packages) - { - var packageElement = rootPackage + return config.Packages.ToDictionary( + packageConfig => packageConfig.Name, + packageConfig => rootPackage .Elements("packagedElement") .FirstOrDefault(element => (string?)element.Attribute(Xmi + "type") == "uml:Package" && (string?)element.Attribute("name") == packageConfig.Name) ?? throw new InvalidOperationException( - $"Package '{packageConfig.Name}' not found under '{config.RootPackageName}'."); - - packageNodes[packageConfig.Name] = packageElement; - } - - return packageNodes; + $"Package '{packageConfig.Name}' not found under '{config.RootPackageName}'.")); } /// @@ -291,16 +290,14 @@ private Dictionary> BuildPackageElementIds( SplitterConfig config, Dictionary packageNodes) { - var packageElementIds = new Dictionary>(); - - foreach (var packageConfig in config.Packages) - { - var ids = new HashSet(); - this.elementIndexer.CollectAllIds(packageNodes[packageConfig.Name], ids); - packageElementIds[packageConfig.Name] = ids; - } - - return packageElementIds; + return config.Packages.ToDictionary( + packageConfig => packageConfig.Name, + packageConfig => + { + var ids = new HashSet(); + this.elementIndexer.CollectAllIds(packageNodes[packageConfig.Name], ids); + return ids; + }); } ///