diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f72ed274..64e8ba38b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- #1001: The `unmap` and `enable` commands will now only activate CPF merge once after all namespaces have been configured instead after every namespace
- #1052: In a namespace with mapped IPM, the `info` command works again and the intro message displays the IPM version and where its mapped from
- #1102: %IPM.Storage.QualifiedModuleInfo:%New() will now copy over version properties when passed in a resolvedReference
+- #1112: Packaging a module with a globals resource now respects SourcesRoot, placing the exported file at the correct path in the tarball
## [0.10.6] - 2026-02-24
diff --git a/src/cls/IPM/ResourceProcessor/Default/Global.cls b/src/cls/IPM/ResourceProcessor/Default/Global.cls
index 16829f29a..fb57d01f3 100644
--- a/src/cls/IPM/ResourceProcessor/Default/Global.cls
+++ b/src/cls/IPM/ResourceProcessor/Default/Global.cls
@@ -5,7 +5,7 @@ Class %IPM.ResourceProcessor.Default.Global Extends %IPM.ResourceProcessor.Abstr
Parameter DESCRIPTION As STRING = "Standard resource processor for global exports.";
/// Comma-separated list of resource attribute names that this processor uses
-Parameter ATTRIBUTES As STRING = "Global,Preserve";
+Parameter ATTRIBUTES As STRING = "Global,Preserve,Directory,FilenameTranslateIdentifier,FilenameTranslateAssociator";
/// Optional name of global within the file
Property Global As %IPM.DataType.GlobalReference;
@@ -17,6 +17,12 @@ Property Preserve As %Boolean [ InitialExpression = 0 ];
/// Defaults to the resource's extension (lower-case) if unspecified.
Property Directory As %String(MAXLEN = "") [ InitialExpression = "gbl/" ];
+/// Characters in the resource name to translate when building the filename.
+Property FilenameTranslateIdentifier As %String [ InitialExpression = "%,("")" ];
+
+/// Replacement characters corresponding to FilenameTranslateIdentifier.
+Property FilenameTranslateAssociator As %String [ InitialExpression = "___" ];
+
Method OnPhase(
pPhase As %String,
ByRef pParams,
@@ -51,30 +57,16 @@ Method OnPhase(
}
if '..ResourceReference.Generated {
- set tSubDirectory = $select(..ResourceReference.Preload:"preload/",1:"")
- set tResourceDirectory = tRoot _ "/" _ tSubDirectory
-
- set tSourceRoot = ..ResourceReference.Module.SourcesRoot
- if tSourceRoot'="","\/"'[$extract(tSourceRoot, *) {
- set tSourceRoot = tSourceRoot _ "/"
- }
-
- set tDirectory = ..Directory
- if tDirectory'="","\/"'[$extract(tDirectory, *) {
- set tDirectory = tDirectory _ "/"
- } else {
- set tDirectory = "gbl/"
- }
-
- set tResourceDirectory = ##class(%File).NormalizeDirectory(tResourceDirectory_tSourceRoot_tDirectory)
+ set relativePath = ..OnItemRelativePath(..ResourceReference.Name)
+ set resourcePath = ##class(%File).NormalizeFilename(tRoot _ "/" _ relativePath)
+ set resourceDirectory = ##class(%File).GetDirectory(resourcePath)
if tDeveloperMode {
- set ^Sources("GBL",tName) = tSourcesPrefix_tResourceDirectory
+ set ^Sources("GBL", tName) = tSourcesPrefix _ resourceDirectory
}
if '..ResourceReference.Preload {
- set tResourcePath = tResourceDirectory_$translate(tName,"%,("")","___")_".xml"
- set tSC = $system.OBJ.Load(tResourcePath,$select(tVerbose:"/display",1:"/nodisplay")_"/nocompile")
+ set tSC = $system.OBJ.Load(resourcePath, $select(tVerbose:"/display", 1:"/nodisplay") _ "/nocompile")
if $$$ISERR(tSC) {
quit
}
@@ -93,4 +85,33 @@ Method OnPhase(
quit tSC
}
+/// Sets the relative path for the globals resource so the packaging phase
+/// exports the file to the correct location, respecting SourcesRoot.
+Method OnResolveChildren(ByRef resourceArray) As %Status
+{
+ set resourceArray(..ResourceReference.Name, "RelativePath") = ..OnItemRelativePath(..ResourceReference.Name)
+ quit $$$OK
+}
+
+/// Returns the path of itemName relative to the module root,
+/// including SourcesRoot and the configured Directory.
+Method OnItemRelativePath(itemName As %String) As %String
+{
+ set sourceRoot = ..ResourceReference.Module.SourcesRoot
+ // Append trailing slash only if sourceRoot is non-empty and doesn't already end with / or \
+ if (sourceRoot '= "") && ("\/" '[ $extract(sourceRoot, *)) {
+ set sourceRoot = sourceRoot _ "/"
+ }
+
+ set directory = ..Directory
+ if directory = "" {
+ set directory = "gbl/"
+ } elseif "\/" '[ $extract(directory, *) {
+ set directory = directory _ "/"
+ }
+
+ set name = $piece(itemName, ".", 1, * - 1)
+ quit $select(..ResourceReference.Preload:"preload/", 1:"") _ sourceRoot _ directory _ $translate(name, ..FilenameTranslateIdentifier, ..FilenameTranslateAssociator) _ ".xml"
+}
+
}
diff --git a/tests/integration_tests/Test/PM/Integration/GlobalsPackaging.cls b/tests/integration_tests/Test/PM/Integration/GlobalsPackaging.cls
new file mode 100644
index 000000000..dcc584be0
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/GlobalsPackaging.cls
@@ -0,0 +1,40 @@
+Class Test.PM.Integration.GlobalsPackaging Extends Test.PM.Integration.Base
+{
+
+/// Verifies that packaging a module with a globals resource and SourcesRoot
+/// exports the file to the correct path and produces a loadable tarball.
+Method TestGlobalsPackageWithSourcesRoot()
+{
+ set sc = $$$OK
+ try {
+ set moduleDir = ..GetModuleDir("globals")
+
+ // Ensure clean state before loading from source
+ do ##class(%IPM.Main).Shell("uninstall globals-test")
+
+ set sc = ##class(%IPM.Main).Shell("load " _ moduleDir)
+ do $$$AssertStatusOK(sc, "Loaded globals-test module successfully.")
+
+ set tempDir = ##class(%Library.File).TempFilename() _ "dir"
+ set sc = ##class(%IPM.Main).Shell("globals-test package -DPath=" _ tempDir)
+ do $$$AssertStatusOK(sc, "Packaged globals-test module successfully.")
+
+ set outDir = ##class(%Library.File).NormalizeDirectory(##class(%Library.File).TempFilename() _ "dir-out")
+ set sc = ##class(%IPM.General.Archive).Extract(tempDir _ ".tgz", outDir, .extractOutput)
+ do $$$AssertStatusOK(sc, "Extracted tarball.")
+
+ do $$$AssertTrue(##class(%File).Exists(outDir _ "src/gbl/My.Settings.xml"), "Global exported to src/gbl/My.Settings.xml (SourcesRoot respected).")
+
+ // Uninstall before loading from tarball to get a clean load assertion
+ do ##class(%IPM.Main).Shell("uninstall globals-test")
+
+ set sc = ##class(%IPM.Main).Shell("load " _ tempDir _ ".tgz")
+ do $$$AssertStatusOK(sc, "Loaded globals-test from tarball successfully.")
+
+ do $$$AssertEquals($get(^My.Settings("Parameter")), 42, "^My.Settings imported correctly from tarball.")
+ } catch e {
+ do $$$AssertStatusOK(e.AsStatus(), "An exception occurred.")
+ }
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/globals/module.xml b/tests/integration_tests/Test/PM/Integration/_data/globals/module.xml
new file mode 100644
index 000000000..c016dbfa0
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/globals/module.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ globals-test
+ 1.0.0
+ module
+ src
+
+
+
+
diff --git a/tests/integration_tests/Test/PM/Integration/_data/globals/src/gbl/My.Settings.xml b/tests/integration_tests/Test/PM/Integration/_data/globals/src/gbl/My.Settings.xml
new file mode 100644
index 000000000..0701ca5ba
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/globals/src/gbl/My.Settings.xml
@@ -0,0 +1,13 @@
+
+
+
+^My.Settings
+ Mode
+ Example
+
+ Parameter
+ 42
+
+
+
+