diff --git a/CHANGELOG.md b/CHANGELOG.md
index c6468974..1d160fe6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- #1178: Add `-password-env` and `-token-env` modifiers to the `repo` command to read the password/token from a named environment variable (secure alternatives to `-password` and `-token`).
+- #1117: Add `sync` command for incremental loading of changed files in dev-mode modules. Detects modified files since last sync using SHA-1 hash and recompiles only what is stale. Supports `-delete` for processing removed files and `-test` for running changed test-phase unit tests.
### Changed
- #1186: Change %IPM.Main:ShellScript() to return a status.
diff --git a/src/cls/IPM/DataType/PhaseName.cls b/src/cls/IPM/DataType/PhaseName.cls
index 2107dd27..53098385 100644
--- a/src/cls/IPM/DataType/PhaseName.cls
+++ b/src/cls/IPM/DataType/PhaseName.cls
@@ -10,6 +10,6 @@ Parameter MAXLEN As INTEGER = 50;
/// If a non-null value is present, then the attribute is restricted to values
/// in the list, and the validation code simply checks to see if the value is in the list.
/// Should be kept in sync with %IPM.Lifecycle.Base.Phases
-Parameter VALUELIST = ",Clean,Initialize,Reload,*,Validate,ExportData,Compile,Activate,Document,MakeDeployed,Test,Package,Verify,Publish,Configure,Unconfigure,ApplyUpdateSteps";
+Parameter VALUELIST = ",Clean,Initialize,Reload,*,Validate,ExportData,Compile,Activate,Document,MakeDeployed,Test,Package,Verify,Publish,Configure,Unconfigure,ApplyUpdateSteps,Sync";
}
diff --git a/src/cls/IPM/General/Sync/Output.cls b/src/cls/IPM/General/Sync/Output.cls
new file mode 100644
index 00000000..1ba8a465
--- /dev/null
+++ b/src/cls/IPM/General/Sync/Output.cls
@@ -0,0 +1,79 @@
+/// Sync output that always writes to the current device, regardless of whether a whole-namespace
+/// sync (`sync` with no module named) is in progress. Contrast %IPM.General.Sync.Summary, whose
+/// recorders are gated on such a run being active.
+Class %IPM.General.Sync.Output [ Abstract ]
+{
+
+/// Write a module-scoped status line: "[moduleName] text". Single place for the bracketed
+/// per-module prefix so the many status writes in the pipeline stay consistent.
+ClassMethod Log(moduleName As %String, text As %String) [ Internal ]
+{
+ write !, "[", moduleName, "] ", text
+}
+
+/// Count the top-level subscripts of an array. Used for the verbose "N file(s)"/"N path(s)"
+/// status lines, which report only top-level keys and ignore any child nodes.
+ClassMethod CountNodes(ByRef array) As %Integer [ Internal ]
+{
+ set count = 0
+ set key = ""
+ for {
+ set key = $order(array(key))
+ quit:key=""
+ set count = count + 1
+ }
+ quit count
+}
+
+/// Shared closing output for both pipeline exit paths (nothing-to-sync and full sync): the
+/// module.xml reload warning, the unsupported-resource note, and the total-elapsed line. Kept in
+/// one place so the two branches can't drift apart.
+ClassMethod PrintTail(
+ moduleName As %String,
+ verbose As %Boolean,
+ moduleXmlChanged As %Boolean,
+ syncStart As %Numeric,
+ ByRef unsupportedResources) [ Internal ]
+{
+ if moduleXmlChanged {
+ do ..PrintModuleXmlWarning(moduleName)
+ }
+ if $data(unsupportedResources) {
+ do ..PrintUnsupportedNote(moduleName, verbose, .unsupportedResources)
+ }
+ do ..Log(moduleName, "Sync done in "_$fnumber($zhorolog - syncStart, "", 2)_"s")
+}
+
+ClassMethod PrintModuleXmlWarning(moduleName As %String) [ Internal ]
+{
+ write !
+ write !, "Warning: module.xml changed and was reloaded."
+ write !, " Resources may have been added/removed. Run `reload ", moduleName, "` to fully apply"
+ write !, " manifest-level changes (mappings, dependencies, defaults)."
+ do ##class(%IPM.General.Sync.Summary).AddWarning(moduleName, "module.xml changed and was reloaded; run `reload " _ moduleName _ "` to fully apply.")
+}
+
+ClassMethod PrintUnsupportedNote(moduleName As %String, verbose As %Boolean, ByRef unsupportedResources) [ Internal ]
+{
+ set count = 0
+ set names = ""
+ set resName = ""
+ for {
+ set resName = $order(unsupportedResources(resName))
+ quit:resName=""
+ set count = count + 1
+ if count <= 3 {
+ set names = names _ $select(names="":"", 1:", ") _ resName
+ }
+ }
+ if count > 3 {
+ set names = names _ ", ... (" _ (count - 3) _ " more)"
+ }
+ if verbose {
+ do ..Log(moduleName, count_" resource(s) skipped (no sync support): "_names)
+ write !, " Use `reload ", moduleName, "` to apply changes to those resources."
+ }
+ do ##class(%IPM.General.Sync.Summary).AddWarning(moduleName, count _ " resource(s) skipped (no sync support): " _ names _ ".")
+}
+
+}
diff --git a/src/cls/IPM/General/Sync/Pipeline.cls b/src/cls/IPM/General/Sync/Pipeline.cls
new file mode 100644
index 00000000..6dc5bf69
--- /dev/null
+++ b/src/cls/IPM/General/Sync/Pipeline.cls
@@ -0,0 +1,557 @@
+Include (%occErrors, %IPM.Common)
+
+/// The incremental-sync pipeline for a dev-mode module. %IPM.Lifecycle.Base's %Sync phase is a
+/// thin delegator to Run below; all the sync orchestration and its helpers live here so Base.cls
+/// stays a lifecycle hook surface rather than the sync implementation.
+Class %IPM.General.Sync.Pipeline [ Abstract ]
+{
+
+/// Incrementally sync changed files from disk into the namespace for a dev-mode module.
+/// Detects files changed since last load/sync, routes them to resource processors, and recompiles.
+/// module is the lifecycle's already-opened, validated module instance; SyncCheckModuleXml may
+/// replace it with a freshly-reloaded instance if module.xml changed.
+ClassMethod Run(
+ module As %IPM.Storage.Module,
+ ByRef params) As %Status
+{
+ set sc = $$$OK
+ try {
+ set syncStart = $zhorolog
+ set verbose = $get(params("Verbose"), 0)
+ set processDeletes = $get(params("ProcessDeletes"), 0)
+ set runTests = $get(params("RunTests"), 0)
+
+ // Intentional ByRef mutation of caller's array: params("Sync") is the per-module scratch
+ // subtree for test-case tracking. Main.Sync reuses the same params array across modules,
+ // so without this kill a prior module's ChangedTestCases leak into this module's SyncRunTests.
+ kill params("Sync")
+
+ set moduleName = module.Name
+
+ if 'module.DeveloperMode {
+ $$$ThrowStatus($$$ERROR($$$GeneralError, $$$FormatText("Module '%1' is not in development mode. Sync requires dev mode.", moduleName)))
+ }
+
+ set root = ##class(%File).NormalizeDirectory(module.Root)
+ if root = "" {
+ $$$ThrowStatus($$$ERROR($$$GeneralError, $$$FormatText("Module '%1' has no root directory configured.", moduleName)))
+ }
+
+ // Step 1: Check if module.xml changed; reload manifest if so
+ set moduleXmlRelPath = ##class(%IPM.Storage.FileHash).NormalizePath("module.xml")
+ set moduleXmlPath = root _ "module.xml"
+ set moduleXmlChanged = ..SyncCheckModuleXml(.module, moduleXmlPath, moduleXmlRelPath)
+
+ if '##class(%IPM.Storage.FileHash).HasBaseline(moduleName) {
+ // Self-heal: establish baseline for modules loaded before this feature
+ $$$ThrowOnError(##class(%IPM.Storage.FileHash).StampModule(module))
+ do ##class(%IPM.General.Sync.Output).Log(moduleName, "Baseline established. Run sync again to detect changes.")
+ quit
+ }
+
+ // Deliberately no StampModule here after a manifest reload: it would overwrite every
+ // file's hash (including files the user just edited), so ComputeChanges would see
+ // current-vs-current and report no changes. Newly-declared resources still get picked up:
+ // SyncBuildReverseIndex adds their relPaths to reverseIndex, and ComputeChanges Pass 2
+ // reports any with no baseline row as modified.
+
+ // Step 2: Collect scan directories from resource processors, then walk only those.
+ // Each processor declares its owned directory via GetSyncDirectory(); sync never
+ // touches files outside declared locations.
+ set orderedResourceList = module.GetOrderedResourceList()
+ do ##class(%IPM.Storage.FileHash).CollectScanDirs(module, .scanDirs)
+
+ set walkStart = $zhorolog
+ kill allFiles, allHashes
+ $$$ThrowOnError(##class(%IPM.Storage.FileHash).WalkAndHashDirs(root, .scanDirs, .allFiles, .allHashes))
+
+ if verbose {
+ set fileCount = ##class(%IPM.General.Sync.Output).CountNodes(.allFiles)
+ set dirCount = ##class(%IPM.General.Sync.Output).CountNodes(.scanDirs)
+ do ##class(%IPM.General.Sync.Output).Log(moduleName, "Scanned "_fileCount_" file(s) across "_dirCount_" director(ies) in "_$fnumber($zhorolog - walkStart, "", 2)_"s")
+ }
+
+ // Step 3: Build reverse index (relPath -> owning resource + processor).
+ kill reverseIndex, unsupportedResources
+ do ..SyncBuildReverseIndex(module, orderedResourceList, .reverseIndex, .unsupportedResources, .allFiles)
+
+ if verbose {
+ // Top-level keys of reverseIndex are the tracked relPaths (subnodes are children).
+ set riCount = ##class(%IPM.General.Sync.Output).CountNodes(.reverseIndex)
+ do ##class(%IPM.General.Sync.Output).Log(moduleName, "Tracking "_riCount_" path(s) across "_orderedResourceList.Count()_" resource(s)")
+ }
+
+ // Step 4: Compute disk changes vs baseline. module.xml is owned by SyncCheckModuleXml
+ // above and is deliberately excluded by ComputeChanges, so it never appears in
+ // modified/deleted here.
+ $$$ThrowOnError(##class(%IPM.Storage.FileHash).ComputeChanges(module, .modified, .deleted, .reverseIndex, .allFiles, .allHashes))
+
+ // Falls through when only deletes exist and processDeletes=1
+ if '$data(modified) && ('$data(deleted) || 'processDeletes) {
+ do ##class(%IPM.General.Sync.Output).Log(moduleName, "Nothing to sync.")
+ if '$data(modified) && $data(deleted) && 'processDeletes {
+ set delCount = ##class(%IPM.General.Sync.Output).CountNodes(.deleted)
+ write !, " ", delCount, " deleted file(s) detected but not applied. Use -delete to remove from server."
+ do ##class(%IPM.General.Sync.Summary).AddWarning(moduleName, delCount _ " deleted file(s) detected but not applied; use -delete to remove from server.")
+ }
+ // module.xml can change without producing any routed file change; commit its new hash
+ // here so the next sync doesn't re-detect it. PrintTail emits the reload warning.
+ if moduleXmlChanged {
+ do ..SyncCommitModuleXml(module, moduleXmlPath, moduleXmlRelPath)
+ }
+ do ##class(%IPM.General.Sync.Output).PrintTail(moduleName, verbose, moduleXmlChanged, syncStart, .unsupportedResources)
+ quit
+ }
+
+ // Step 5: Partition changes by owning resource
+ kill syncByResource
+ do ..SyncRoutePathSet(.modified, .reverseIndex, "modified", .syncByResource)
+ if processDeletes {
+ do ..SyncRoutePathSet(.deleted, .reverseIndex, "deleted", .syncByResource)
+ }
+
+ // Step 6: Dispatch OnSync to each processor; load unhandled compilable files
+ if verbose {
+ do ##class(%IPM.General.Sync.Output).Log(moduleName, "Dispatching to "_##class(%IPM.General.Sync.Output).CountNodes(.syncByResource)_" resource(s)")
+ }
+ $$$ThrowOnError(..SyncDispatchProcessors(module, root, verbose, .syncByResource, .params, .loadItems))
+
+ // Step 7: Compile the full resource set to pick up dependent recompiles
+ if loadItems > 0 {
+ $$$ThrowOnError(..SyncCompile(module, orderedResourceList, verbose, .params))
+ }
+
+ // Step 8: Delete server-side documents for removed files, then recompile
+ if processDeletes && ($data(deleted) > 1) {
+ do ..SyncApplyDeletes(moduleName, .deleted, .reverseIndex, .syncByResource, verbose)
+ $$$ThrowOnError(..SyncCompile(module, orderedResourceList, verbose, .params))
+ }
+
+ // Step 9: Commit new hashes on success (skipped on error so next sync re-detects).
+ // Done before running tests below so a test failure doesn't prevent the file-sync
+ // outcome from being committed and reported — file sync and test results are independent.
+ $$$ThrowOnError(##class(%IPM.Storage.FileHash).CommitChanges(module, .modified, .deleted, processDeletes))
+ if moduleXmlChanged {
+ do ..SyncCommitModuleXml(module, moduleXmlPath, moduleXmlRelPath)
+ }
+
+ set modCount = 0
+ set key = ""
+ for {
+ set key = $order(modified(key))
+ quit:key=""
+ set modCount = modCount + 1
+ write !, " Updated: ", key
+ do ##class(%IPM.General.Sync.Summary).RecordFile(moduleName, key, "Updated")
+ }
+ set delCount = 0
+ if processDeletes {
+ set key = ""
+ for {
+ set key = $order(deleted(key))
+ quit:key=""
+ set delCount = delCount + 1
+ write !, " Deleted: ", key
+ do ##class(%IPM.General.Sync.Summary).RecordFile(moduleName, key, "Deleted")
+ }
+ }
+ do ##class(%IPM.General.Sync.Output).Log(moduleName, "Sync complete: "_modCount_" file(s) updated"_$select(delCount>0:", "_delCount_" deleted", 1:"")_".")
+
+ do ##class(%IPM.General.Sync.Output).PrintTail(moduleName, verbose, moduleXmlChanged, syncStart, .unsupportedResources)
+
+ // File sync is committed and reported. Mark success before running tests so a fatal
+ // test failure below is reported as a test failure, not a sync error, in the summary.
+ do ##class(%IPM.General.Sync.Summary).MarkSyncOK(moduleName)
+
+ // Step 10: Run changed test-phase tests if -test flag is set (after sync is reported)
+ if runTests {
+ $$$ThrowOnError(..SyncRunTests(orderedResourceList, verbose, .params))
+ }
+
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ quit sc
+}
+
+/// Check if module.xml changed and reload the manifest if so.
+/// Returns 1 if module.xml changed and was reloaded, 0 otherwise.
+/// On reload, replaces module with the freshly-opened instance.
+ClassMethod SyncCheckModuleXml(
+ ByRef module As %IPM.Storage.Module,
+ moduleXmlPath As %String,
+ moduleXmlRelPath As %String) As %Boolean [ Private ]
+{
+ if '##class(%File).Exists(moduleXmlPath) {
+ quit 0
+ }
+ set existing = ##class(%IPM.Storage.FileHash).ModulePathIndexOpen(module.Name, moduleXmlRelPath)
+ if '$isobject(existing) {
+ quit 0
+ }
+ set newHash = $$$lcase(##class(%File).SHA1Hash(moduleXmlPath, 1))
+ if newHash = existing.Hash {
+ quit 0
+ }
+ $$$ThrowOnError($system.OBJ.Load(moduleXmlPath, "-d"))
+ set module = ##class(%IPM.Storage.Module).NameOpen(module.Name, , .openSC)
+ $$$ThrowOnError(openSC)
+ quit 1
+}
+
+/// Build a reverse index: normalizedRelPath -> resource name, Processor, Resource object.
+/// Used by SyncRoutePathSet to map changed files back to their owning resource processors.
+/// Skips resources whose processor does not support sync — those are collected in
+/// unsupportedResources(resourceName)="" for informational display.
+ClassMethod SyncBuildReverseIndex(module As %IPM.Storage.Module, orderedResourceList As %ListOfObjects, ByRef reverseIndex, Output unsupportedResources, ByRef allFiles) [ Private ]
+{
+ kill unsupportedResources
+ // docToResource maps server document names (e.g. "SyncTest.SuperClass.CLS") to the
+ // resource that owns them. This lets us map relPath → owner in O(1) below.
+ kill docToResource
+ set key = ""
+ for {
+ set resource = orderedResourceList.GetNext(.key)
+ quit:key=""
+
+ if '$isobject(resource.Processor) {
+ continue
+ }
+
+ if 'resource.Processor.SupportsSync() {
+ set unsupportedResources(resource.Name) = ""
+ continue
+ }
+
+ // ResolveChildren returns childArr keyed by document name (server-side identifier).
+ kill childArr
+ set childSC = resource.ResolveChildren(.childArr)
+ if $$$ISERR(childSC) {
+ continue
+ }
+
+ set childName = ""
+ for {
+ set childName = $order(childArr(childName))
+ quit:childName=""
+ set docToResource(childName) = resource.Name
+ set docToResource(childName, "Processor") = resource.Processor
+ set docToResource(childName, "Resource") = resource
+
+ // A resource added since the last %Compile has no baseline row yet, so GetStoredPaths
+ // below won't find it. Map its relPath directly.
+ set relPath = $get(childArr(childName, "RelativePath"))
+ if relPath = "" {
+ set relPath = resource.Processor.OnItemRelativePath(childName)
+ }
+ if relPath '= "" {
+ set normalizedRelPath = ##class(%IPM.Storage.FileHash).NormalizePath(relPath)
+ set reverseIndex(normalizedRelPath) = resource.Name
+ set reverseIndex(normalizedRelPath, "Processor") = resource.Processor
+ set reverseIndex(normalizedRelPath, "Resource") = resource
+ }
+ }
+
+ // Directory-based resources (e.g. test dirs) own all files under their declared
+ // directory. Prefix-scan allFiles to map every file under that dir to this resource.
+ set syncDir = resource.Processor.GetSyncDirectory()
+ if syncDir '= "" {
+ set prefix = ##class(%IPM.Storage.FileHash).NormalizePath(syncDir _ "/")
+ set prefixLen = $length(prefix)
+ set dirRelPath = prefix
+ for {
+ set dirRelPath = $order(allFiles(dirRelPath))
+ quit:dirRelPath=""
+ quit:($extract(dirRelPath, 1, prefixLen) '= prefix)
+ if '$data(reverseIndex(dirRelPath)) {
+ set reverseIndex(dirRelPath) = resource.Name
+ set reverseIndex(dirRelPath, "Processor") = resource.Processor
+ set reverseIndex(dirRelPath, "Resource") = resource
+ }
+ }
+ }
+ }
+
+ // Catch compilable files in the baseline not reachable via ResolveChildren.
+ kill storedPaths
+ do ##class(%IPM.Storage.FileHash).GetStoredPaths(module.Name, .storedPaths)
+ set relPath = ""
+ for {
+ set relPath = $order(storedPaths(relPath))
+ quit:relPath=""
+
+ if $data(reverseIndex(relPath)) {
+ continue
+ }
+
+ set docName = ##class(%IPM.Storage.FileHash).RelPathToDocName(relPath)
+ if docName = "" {
+ continue
+ }
+ if '$data(docToResource(docName)) {
+ continue
+ }
+
+ set reverseIndex(relPath) = docToResource(docName)
+ set reverseIndex(relPath, "Processor") = docToResource(docName, "Processor")
+ set reverseIndex(relPath, "Resource") = docToResource(docName, "Resource")
+ }
+}
+
+/// Route a set of changed paths to their owning resources, categorized by type (modified/deleted).
+/// Only sync-supporting resources appear in reverseIndex, so no filtering needed here.
+ClassMethod SyncRoutePathSet(
+ ByRef paths,
+ ByRef reverseIndex,
+ category As %String,
+ ByRef syncByResource) [ Private ]
+{
+ set relPath = ""
+ for {
+ set relPath = $order(paths(relPath))
+ quit:relPath=""
+
+ if '$data(reverseIndex(relPath)) {
+ continue
+ }
+ set resName = reverseIndex(relPath)
+ set syncByResource(resName, category, relPath) = ""
+ set syncByResource(resName, "Processor") = reverseIndex(relPath, "Processor")
+ set syncByResource(resName, "Resource") = reverseIndex(relPath, "Resource")
+ }
+}
+
+/// Call OnSync on each processor, then load any unhandled compilable files.
+/// Returns the number of files loaded (used to decide whether SyncCompile is needed).
+/// If a processor sets handled=1 in OnSync, it fully owns the sync for that resource
+/// (e.g. Test reloads its own way). Otherwise the default path loads+compiles each file.
+ClassMethod SyncDispatchProcessors(
+ module As %IPM.Storage.Module,
+ root As %String,
+ verbose As %Boolean,
+ ByRef syncByResource,
+ ByRef params,
+ Output loadItems As %Integer = 0) As %Status [ Private ]
+{
+ set sc = $$$OK
+ try {
+ set resName = ""
+ for {
+ set resName = $order(syncByResource(resName))
+ quit:resName=""
+
+ if '$data(syncByResource(resName, "Processor")) {
+ continue
+ }
+ set processor = syncByResource(resName, "Processor")
+
+ kill resModified, resDeleted
+ merge resModified = syncByResource(resName, "modified")
+ merge resDeleted = syncByResource(resName, "deleted")
+
+ set handled = 0
+ $$$ThrowOnError(processor.OnSync(.resModified, .resDeleted, .params, .handled))
+
+ if handled || 'processor.%IsA("%IPM.ResourceProcessor.AbstractCompilable") {
+ continue
+ }
+
+ // Default: load each changed file; SyncCompile will do a full compile with u-flag
+ set loadRelPath = ""
+ for {
+ set loadRelPath = $order(resModified(loadRelPath))
+ quit:loadRelPath=""
+
+ set fullPath = ##class(%File).NormalizeFilename(loadRelPath, root)
+ if ##class(%File).Exists(fullPath) {
+ set loadFlags = $select(verbose:"d", 1:"-d")
+ $$$ThrowOnError($system.OBJ.Load(fullPath, loadFlags _ "c"))
+ set loadItems = loadItems + 1
+ }
+ }
+ }
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ quit sc
+}
+
+/// Delete server-side documents for compilable deleted files.
+/// Guards skip paths with no owner, no processor, or non-compilable processors — those are
+/// handled elsewhere (unsupported warnings) or aren't server-side documents at all.
+ClassMethod SyncApplyDeletes(
+ moduleName As %String,
+ ByRef deleted,
+ ByRef reverseIndex,
+ ByRef syncByResource,
+ verbose As %Boolean) As %Status [ Private ]
+{
+ set sc = $$$OK
+ set relPath = ""
+ for {
+ set relPath = $order(deleted(relPath))
+ quit:relPath=""
+
+ if '$data(reverseIndex(relPath)) {
+ continue
+ }
+ set resName = reverseIndex(relPath)
+ if '$data(syncByResource(resName, "Processor")) {
+ continue
+ }
+ set processor = syncByResource(resName, "Processor")
+ if 'processor.%IsA("%IPM.ResourceProcessor.AbstractCompilable") {
+ continue
+ }
+ set docName = ##class(%IPM.Storage.FileHash).RelPathToDocName(relPath)
+ if docName '= "" {
+ set delFlags = $select(verbose:"d", 1:"-d")
+ set delSC = $system.OBJ.Delete(docName, delFlags)
+ // Non-fatal: the SyncCompile pass that follows will surface any compilation errors
+ // caused by the still-present document. Aborting here would leave remaining deletions
+ // unapplied and make the overall error harder to diagnose.
+ if $$$ISERR(delSC) {
+ set errText = $system.Status.GetOneErrorText(delSC)
+ write !, "Warning: could not delete ", docName, ": ", errText
+ do ##class(%IPM.General.Sync.Summary).AddWarning(moduleName, "Could not delete " _ docName _ ": " _ errText)
+ set sc = $$$ADDSC(sc, delSC)
+ }
+ }
+ }
+ quit sc
+}
+
+/// Run test-phase tests for changed test case classes recorded in params("Sync","ChangedTestCases").
+/// Groups changed classes by owning resource first, then dispatches one batched RunTest call
+/// per resource (via OnSyncRunTests) instead of one call per class — avoids N separate
+/// reload/compile/run cycles when several test classes in the same resource changed together.
+ClassMethod SyncRunTests(
+ orderedResourceList As %ListOfObjects,
+ verbose As %Boolean,
+ ByRef params) As %Status [ Private ]
+{
+ set sc = $$$OK
+ try {
+ // Step 1: group changed classes by owning resource: byResource(resourceName, className) = ""
+ kill byResource
+ set className = ""
+ for {
+ set className = $order(params("Sync", "ChangedTestCases", className), 1, owningResource)
+ quit:className=""
+ set byResource(owningResource, className) = ""
+ }
+
+ // Step 2: one OnSyncRunTests call per resource
+ set testKey = ""
+ for {
+ set testResource = orderedResourceList.GetNext(.testKey)
+ quit:testKey=""
+
+ if '$isobject(testResource.Processor) {
+ continue
+ }
+ if 'testResource.Processor.%IsA("%IPM.ResourceProcessor.Test") {
+ continue
+ }
+ if '$data(byResource(testResource.Name)) {
+ continue
+ }
+ if '$listfind(testResource.Processor.Phase, "test") {
+ set skipClassName = ""
+ for {
+ set skipClassName = $order(byResource(testResource.Name, skipClassName))
+ quit:skipClassName=""
+ write:verbose !, "Skipping verify-scoped test: ", skipClassName, " (use 'verify' to run)"
+ do ##class(%IPM.General.Sync.Summary).AddSkipped(testResource.Module.Name, skipClassName)
+ }
+ continue
+ }
+ kill classInfo
+ merge classInfo = byResource(testResource.Name)
+ kill testParams
+ merge testParams = params
+ set testParams("DeveloperMode") = 1
+ $$$ThrowOnError(testResource.Processor.OnSyncRunTests(.classInfo, .testParams))
+ }
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ quit sc
+}
+
+/// Commit the current module.xml hash to the baseline.
+ClassMethod SyncCommitModuleXml(
+ module As %IPM.Storage.Module,
+ moduleXmlPath As %String,
+ moduleXmlRelPath As %String) [ Private ]
+{
+ kill moduleXmlMod, emptyDel
+ set moduleXmlMod(moduleXmlRelPath) = $$$lcase(##class(%File).SHA1Hash(moduleXmlPath, 1))
+ set commitSC = ##class(%IPM.Storage.FileHash).CommitChanges(module, .moduleXmlMod, .emptyDel, 0)
+ if $$$ISERR(commitSC) {
+ // Non-fatal: the next sync will re-detect module.xml as changed and reload the manifest again.
+ write !, "Warning: failed to record module.xml hash: ", $system.Status.GetOneErrorText(commitSC)
+ }
+}
+
+/// Recompile all compilable resources in the module to catch dependents invalidated by
+/// changes loaded in SyncDispatchProcessors. Skips CompileFromProject resources — those are
+/// deployed code loaded via Studio project files (.prj); compiling them individually would
+/// fail because deployed classes have no source in the routine database.
+ClassMethod SyncCompile(module As %IPM.Storage.Module, orderedResourceList As %ListOfObjects, verbose As %Boolean = 0, ByRef params) As %Status [ Private ]
+{
+ set sc = $$$OK
+ try {
+ kill compileArray
+
+ set key = ""
+ for {
+ set resource = orderedResourceList.GetNext(.key)
+ quit:key=""
+
+ if '$isobject(resource.Processor) {
+ continue
+ }
+ if 'resource.Processor.%IsA("%IPM.ResourceProcessor.AbstractCompilable") {
+ continue
+ }
+ if 'resource.IsInScope("Compile") {
+ continue
+ }
+ if resource.Processor.CompileFromProject {
+ continue
+ }
+
+ kill oneResourceList
+ set oneResourceList(resource.Name) = ""
+ set resSC = resource.ResolveChildren(.oneResourceList)
+ if $$$ISERR(resSC) {
+ continue
+ }
+
+ set childKey = ""
+ for {
+ set childKey = $order(oneResourceList(childKey))
+ quit:childKey=""
+ set ext = $zconvert($piece(childKey, ".", *), "U")
+ if ext = "CLS" {
+ set className = $piece(childKey, ".", 1, *-1)
+ if '$$$comClassDefined(className) {
+ continue
+ }
+ }
+ set compileArray(childKey) = ""
+ }
+ }
+
+ if $data(compileArray) > 1 {
+ set flags = $select(verbose:"d", 1:"-d") _ "cku"
+ set sc = ##class(%IPM.Utils.LegacyCompat).UpdateSuperclassAndCompile(.compileArray, .flags)
+ }
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ quit sc
+}
+
+}
diff --git a/src/cls/IPM/General/Sync/Summary.cls b/src/cls/IPM/General/Sync/Summary.cls
new file mode 100644
index 00000000..60617a4e
--- /dev/null
+++ b/src/cls/IPM/General/Sync/Summary.cls
@@ -0,0 +1,209 @@
+/// Whole-namespace-sync summary accumulator. Main.Sync ("sync" with no module named) opens the summary with
+/// Begin, runs its per-module loop, then prints with Report. While open, each module's %Sync
+/// records its own outcome into the process-private global ^||IPM.Sync.Summary so the reader can
+/// aggregate without threading data through ExecutePhases' by-ref params. Shape:
+/// ^||IPM.Sync.Summary("Active") = 1 while a whole-namespace sync is in progress
+/// ^||IPM.Sync.Summary("Module",name,"File",relPath) = "Updated" | "Deleted"
+/// ^||IPM.Sync.Summary("Module",name,"SyncOK") = 1 once file sync committed (pre-tests)
+/// ^||IPM.Sync.Summary("Module",name,"TestsPassed") = running count of passed test methods
+/// ^||IPM.Sync.Summary("Module",name,"TestsFailed") = running count of failed test methods
+/// ^||IPM.Sync.Summary("Module",name,"Skipped",seq) = verify-scoped test class not run
+/// ^||IPM.Sync.Summary("Module",name,"Warning",seq) = warning text
+/// Every recorder is a no-op unless "Active" is set, so a single-module sync (which never calls
+/// Begin) records nothing and prints no summary.
+///
+/// SyncOK separates a genuine sync failure from a test failure: %Sync runs tests with
+/// FailuresAreFatal, so a failing test makes %Sync return an error status just like a compile
+/// failure would. %Sync sets SyncOK once file sync is committed (before tests run), so the
+/// reader can tell the two apart — a module in the failures list with SyncOK set had its files
+/// synced fine and only a test failed; without SyncOK the sync itself failed.
+Class %IPM.General.Sync.Summary [ Abstract ]
+{
+
+ClassMethod Begin() [ Internal ]
+{
+ kill ^||IPM.Sync.Summary
+ set ^||IPM.Sync.Summary("Active") = 1
+}
+
+ClassMethod End() [ Internal ]
+{
+ kill ^||IPM.Sync.Summary
+}
+
+/// Record one synced file for the module. action is "Updated" or "Deleted".
+ClassMethod RecordFile(
+ moduleName As %String,
+ relPath As %String,
+ action As %String) [ Internal ]
+{
+ if '$get(^||IPM.Sync.Summary("Active")) {
+ quit
+ }
+ set ^||IPM.Sync.Summary("Module", moduleName, "File", relPath) = action
+}
+
+/// Mark that the module's file sync committed successfully. Called by %Sync just before it runs
+/// tests, so a later test failure (which returns an error status) is distinguishable from a
+/// sync/compile failure that never reached this point.
+ClassMethod MarkSyncOK(moduleName As %String) [ Internal ]
+{
+ if '$get(^||IPM.Sync.Summary("Active")) {
+ quit
+ }
+ set ^||IPM.Sync.Summary("Module", moduleName, "SyncOK") = 1
+}
+
+/// Record a verify-scoped test class that sync detected as changed but did not run (needs
+/// `verify`). Called once per skipped class by SyncRunTests.
+ClassMethod AddSkipped(
+ moduleName As %String,
+ className As %String) [ Internal ]
+{
+ if '$get(^||IPM.Sync.Summary("Active")) {
+ quit
+ }
+ set seq = $increment(^||IPM.Sync.Summary("Module", moduleName, "SkippedCount"))
+ set ^||IPM.Sync.Summary("Module", moduleName, "Skipped", seq) = className
+}
+
+/// Record one advisory warning for the module (e.g. module.xml reloaded, deletes not applied).
+ClassMethod AddWarning(
+ moduleName As %String,
+ text As %String) [ Internal ]
+{
+ if '$get(^||IPM.Sync.Summary("Active")) {
+ quit
+ }
+ set seq = $increment(^||IPM.Sync.Summary("Module", moduleName, "WarningCount"))
+ set ^||IPM.Sync.Summary("Module", moduleName, "Warning", seq) = text
+}
+
+/// Add to the module's running test-method pass/fail tally. Called once per test resource by
+/// OnSyncRunTests.
+ClassMethod AddTests(
+ moduleName As %String,
+ passed As %Integer,
+ failed As %Integer) [ Internal ]
+{
+ if '$get(^||IPM.Sync.Summary("Active")) {
+ quit
+ }
+ set ^||IPM.Sync.Summary("Module", moduleName, "TestsPassed") = $get(^||IPM.Sync.Summary("Module", moduleName, "TestsPassed"), 0) + passed
+ set ^||IPM.Sync.Summary("Module", moduleName, "TestsFailed") = $get(^||IPM.Sync.Summary("Module", moduleName, "TestsFailed"), 0) + failed
+}
+
+/// Print the bordered whole-namespace-sync summary from the accumulated ^||IPM.Sync.Summary data.
+/// orderedNames is the full dependency-ordered dev-mode module list (the "checked" set);
+/// failures is the $list of module names whose Sync phase returned an error status. A module in
+/// failures that still recorded SyncOK synced its files fine and only had a failing test — that
+/// is reflected in its test tally, not the Errors row (see SyncOK). A per-module line is shown
+/// only for modules that were updated, had a real sync error, or ran/skipped tests — unchanged
+/// modules are implied by the "checked" count.
+ClassMethod Report(
+ orderedNames As %List,
+ failures As %List = "") [ Internal ]
+{
+ if '$get(^||IPM.Sync.Summary("Active")) {
+ quit
+ }
+ set border = $translate($justify("", 64), " ", "=")
+ set checkedCount = $listlength(orderedNames)
+
+ // Header count: modules that actually had at least one file change.
+ set updatedCount = 0
+ for i = 1:1:checkedCount {
+ set name = $list(orderedNames, i)
+ if $data(^||IPM.Sync.Summary("Module", name, "File")) {
+ set updatedCount = updatedCount + 1
+ }
+ }
+
+ write !!, border
+ write !, "Sync Summary"
+ write !, border
+
+ // Write module name list, wrapping at border width with aligned continuation lines.
+ set linePrefix = "Modules checked: " _ checkedCount _ " ("
+ set contPrefix = $justify("", $length(linePrefix))
+ set nameLine = linePrefix
+ for i = 1:1:checkedCount {
+ set name = $list(orderedNames, i)
+ set sep = $select(i < checkedCount: ", ", 1: ")")
+ if ($length(nameLine) + $length(name) + $length(sep)) > $length(border) && (nameLine '= linePrefix) {
+ write !, nameLine
+ set nameLine = contPrefix
+ }
+ set nameLine = nameLine _ name _ sep
+ }
+ write !, nameLine
+ write !, "Modules updated: ", updatedCount
+
+ set warningCount = 0, errorNames = ""
+ kill warnings
+ for i = 1:1:checkedCount {
+ set name = $list(orderedNames, i)
+
+ set fileCount = 0
+ set relPath = ""
+ for {
+ set relPath = $order(^||IPM.Sync.Summary("Module", name, "File", relPath))
+ quit:relPath=""
+ set fileCount = fileCount + 1
+ }
+ // A real sync error: the Sync phase failed AND file sync never committed. If SyncOK is
+ // set, the phase error came from a fatal test failure, not from sync itself.
+ set isError = $listfind(failures, name) && '$get(^||IPM.Sync.Summary("Module", name, "SyncOK"))
+ if isError {
+ set errorNames = errorNames _ $listbuild(name)
+ }
+ set passed = $get(^||IPM.Sync.Summary("Module", name, "TestsPassed"))
+ set failed = $get(^||IPM.Sync.Summary("Module", name, "TestsFailed"))
+ set hasTests = (passed '= "") || (failed '= "")
+ set skippedCount = $get(^||IPM.Sync.Summary("Module", name, "SkippedCount"), 0)
+
+ if fileCount || isError || hasTests || skippedCount {
+ set line = " " _ name
+ if fileCount {
+ set line = line _ " " _ fileCount _ " file" _ $select(fileCount = 1:"", 1:"s")
+ }
+ if hasTests {
+ // Report how many tests passed/ran
+ set line = line _ " tests " _ (+passed) _ "/" _ ((+passed) + (+failed))
+ }
+ if isError {
+ set line = line _ " ERROR"
+ }
+ write !, line
+ // Enumerate verify-scoped test classes that were detected but not run.
+ set skippedSeq = ""
+ for {
+ set skippedSeq = $order(^||IPM.Sync.Summary("Module", name, "Skipped", skippedSeq), 1, skippedClass)
+ quit:skippedSeq=""
+ write !, " skipped (verify-scoped): ", skippedClass
+ }
+ }
+
+ // Collect this module's warnings for the aggregated section below.
+ set warningSeq = ""
+ for {
+ set warningSeq = $order(^||IPM.Sync.Summary("Module", name, "Warning", warningSeq), 1, warningText)
+ quit:warningSeq=""
+ set warningCount = warningCount + 1
+ set warnings(warningCount) = " [" _ name _ "] " _ warningText
+ }
+ }
+
+ if errorNames '= "" {
+ write !, "Errors: ", $listtostring(errorNames, ", ")
+ }
+ if warningCount {
+ write !, "Warnings: ", warningCount
+ for i = 1:1:warningCount {
+ write !, warnings(i)
+ }
+ }
+ write !, border
+}
+
+}
diff --git a/src/cls/IPM/Lifecycle/Base.cls b/src/cls/IPM/Lifecycle/Base.cls
index d6b9a03f..1c7c5c52 100644
--- a/src/cls/IPM/Lifecycle/Base.cls
+++ b/src/cls/IPM/Lifecycle/Base.cls
@@ -183,6 +183,7 @@ ClassMethod GetCompletePhasesForOne(pOnePhase As %String) As %List
"applyupdatesteps": $listbuild("Initialize","Reload","*","Validate","Compile","Activate","ApplyUpdateSteps"),
"configure": $listbuild("Configure"),
"unconfigure": $listbuild("Unconfigure"),
+ "sync": $listbuild("Sync"),
: ""
)
}
@@ -209,6 +210,7 @@ ClassMethod MatchSinglePhase(pOnePhase As %String) As %String
"configure": "Configure",
"unconfigure": "Unconfigure",
"applyupdatesteps": "ApplyUpdateSteps",
+ "sync": "Sync",
: pOnePhase // return the phase as-is if it's a custom phase name
)
}
@@ -570,6 +572,15 @@ Method %Unconfigure(ByRef pParams) As %Status
quit tSC
}
+/// Incrementally sync changed files from disk into the namespace for a dev-mode module.
+/// Thin lifecycle hook: ExecutePhases dispatches phases via $method on the lifecycle instance,
+/// so this must be an instance method here. The sync orchestration itself lives in
+/// %IPM.General.Sync.Pipeline.
+Method %Sync(ByRef params) As %Status
+{
+ return ##class(%IPM.General.Sync.Pipeline).Run(..Module, .params)
+}
+
Method %Initialize(ByRef pParams) As %Status
{
set status = $$$OK
@@ -1203,6 +1214,17 @@ Method %Compile(ByRef pParams) As %Status
$$$ThrowStatus(tSC)
}
}
+
+ // Stamp file baselines for sync change detection (dev mode only).
+ // Non-fatal: stamping failure must not break a normal compile cycle.
+ // Done after compile so test classes are in ^oddDEF and pass the namespace filter in StampModule.
+ if tDevMode {
+ try {
+ $$$ThrowOnError(##class(%IPM.Storage.FileHash).StampModule(..Module))
+ } catch stampEx {
+ write !, "Warning: sync baseline stamping failed: ", $system.Status.GetOneErrorText(stampEx.AsStatus())
+ }
+ }
} catch e {
set tSC = e.AsStatus()
}
diff --git a/src/cls/IPM/Main.cls b/src/cls/IPM/Main.cls
index 21eef44d..480a4182 100644
--- a/src/cls/IPM/Main.cls
+++ b/src/cls/IPM/Main.cls
@@ -104,6 +104,19 @@ This command is an alias for `module-action module-name reload`
+
+Incrementally syncs changed files from disk into the namespace.
+
+Detects files changed on disk since the last load/sync for development-mode modules,
+routes each to its resource processor, and recompiles only what is stale.
+If no module is specified, syncs all modules in development mode.
+
+
+
+
+
+
+
This command is an alias for `module-action module-name compile`
@@ -1098,6 +1111,8 @@ ClassMethod ShellInternal(
do ..Information(.tCommandInfo)
} elseif (tCommandInfo = "history") {
do ..History(.tCommandInfo)
+ } elseif (tCommandInfo = "sync") {
+ do ..Sync(.tCommandInfo)
}
} catch pException {
if (pException.Code = $$$ERCTRLC) {
@@ -2310,6 +2325,46 @@ ClassMethod LoadFromRepo(
quit tDirectoryName
}
+ClassMethod Sync(ByRef commandInfo) [ Private ]
+{
+ set moduleName = $get(commandInfo("parameters", "module"))
+ merge params = commandInfo("data")
+
+ if moduleName '= "" {
+ $$$ThrowOnError(##class(%IPM.Storage.Module).ExecutePhases(moduleName, $listbuild("Sync"), 1, .params))
+ } else {
+ // Sync all dev-mode modules in dependency order (least dependent first) so each module
+ // recompiles against dependencies that have already been synced this run.
+ set orderedNames = ##class(%IPM.Utils.Module).GetDevModeModulesInDependencyOrder()
+ set found = $listlength(orderedNames)
+ set failures = ""
+ if 'found {
+ write !, "No modules in development mode."
+ } else {
+ // Turn on per-module summary recording for the duration of the loop, then print the
+ // aggregated bordered summary. Kept in a try/finally so a mid-loop error still clears
+ // the process-private accumulator.
+ do ##class(%IPM.General.Sync.Summary).Begin()
+ try {
+ for i = 1:1:found {
+ set name = $list(orderedNames, i)
+ set syncSC = ##class(%IPM.Storage.Module).ExecutePhases(name, $listbuild("Sync"), 1, .params)
+ if $$$ISERR(syncSC) {
+ set failures = failures _ $listbuild(name)
+ do $system.OBJ.DisplayError(syncSC)
+ }
+ }
+ do ##class(%IPM.General.Sync.Summary).Report(orderedNames, failures)
+ } catch e {
+ do ##class(%IPM.General.Sync.Summary).End()
+ throw e
+ }
+ do ##class(%IPM.General.Sync.Summary).End()
+ }
+ }
+}
+
+
ClassMethod Load(
ByRef pCommandInfo,
pLog As %IPM.General.AbstractHistory = "") [ Internal ]
diff --git a/src/cls/IPM/ResourceProcessor/Abstract.cls b/src/cls/IPM/ResourceProcessor/Abstract.cls
index 2bf0eead..06586f7a 100644
--- a/src/cls/IPM/ResourceProcessor/Abstract.cls
+++ b/src/cls/IPM/ResourceProcessor/Abstract.cls
@@ -204,6 +204,31 @@ Method OnAfterPhase(
quit $$$OK
}
+/// Called during 'sync' for resources whose files changed on disk.
+/// modifiedPaths(relPath)="" for changed files; deletedPaths(relPath)="" for removed files.
+/// Set handled=1 to fully own sync for this resource (skip default load+compile handling).
+Method OnSync(ByRef modifiedPaths, ByRef deletedPaths, ByRef params, Output handled As %Boolean = 0) As %Status
+{
+ quit $$$OK
+}
+
+/// Returns 1 if this processor supports incremental sync. Base returns 0.
+/// Stage 2 TODO: FileCopy, WebApplication, PythonWheel — override to 1 + add OnSync.
+/// Stage 3 TODO: CPF, Copy, ArtifactoryTarball, LegacyLocalizedMessages, Default.Global.
+Method SupportsSync() As %Boolean
+{
+ quit 0
+}
+
+/// Returns the directory (relative to module root, normalized, no leading/trailing slash)
+/// that this processor owns on disk. Sync walks only declared directories rather than the
+/// full module root. Base returns "" (no owned directory; files are declared individually
+/// via OnItemRelativePath). Processors that own a directory override this.
+Method GetSyncDirectory() As %String
+{
+ quit ""
+}
+
/// Returns the path relative to the module root for item pItemName within this resource.
Method OnItemRelativePath(pItemName As %String) As %String
{
diff --git a/src/cls/IPM/ResourceProcessor/AbstractCompilable.cls b/src/cls/IPM/ResourceProcessor/AbstractCompilable.cls
index 25839ade..05ba2389 100644
--- a/src/cls/IPM/ResourceProcessor/AbstractCompilable.cls
+++ b/src/cls/IPM/ResourceProcessor/AbstractCompilable.cls
@@ -19,4 +19,12 @@ Property ExportFlags As %String(MAXLEN = "");
/// Certain Deployed Resources cannot be compiled directly, but must be compiled from a studio project.
Property CompileFromProject As %Boolean [ InitialExpression = 0 ];
+/// Compilable resources (classes, routines, includes) can be individually reloaded and
+/// recompiled by sync without a full lifecycle run, so they support sync by default.
+/// Non-compilable processors (FileCopy, CSP applications, etc.) keep the default of 0.
+Method SupportsSync() As %Boolean
+{
+ quit 1
+}
+
}
diff --git a/src/cls/IPM/ResourceProcessor/Default/Document.cls b/src/cls/IPM/ResourceProcessor/Default/Document.cls
index 4891c3a1..b2f5f445 100644
--- a/src/cls/IPM/ResourceProcessor/Default/Document.cls
+++ b/src/cls/IPM/ResourceProcessor/Default/Document.cls
@@ -512,6 +512,31 @@ Method OnItemRelativePath(pItemName As %String) As %String
quit $select(..ResourceReference.Preload:"preload/",1:"")_ tSourceRoot _ $select(directory=$char(0):"", 1:directory _ "/") _ $translate($piece(pItemName,".",1,*-1),..FilenameTranslateIdentifier,..FilenameTranslateAssociator)_tFileExtension
}
+/// Returns the directory this resource owns on disk, relative to module root (no trailing slash).
+/// For directory-style resources (Name starts with "/"), the Name IS the directory.
+/// For normal doc resources, it's {SourcesRoot}/{Directory}.
+Method GetSyncDirectory() As %String
+{
+ if ..LoadAsDirectory {
+ set dir = ##class(%IPM.Storage.FileHash).NormalizePath(..ResourceReference.Name)
+ if $extract(dir, *) = "/" {
+ set dir = $extract(dir, 1, *-1)
+ }
+ } else {
+ set sourcesRoot = ..ResourceReference.Module.SourcesRoot
+ set dir = ..Directory
+ if dir = "" {
+ quit ""
+ }
+ if sourcesRoot '= "" {
+ set dir = ##class(%IPM.Storage.FileHash).NormalizePath(sourcesRoot _ "/" _ dir)
+ } else {
+ set dir = ##class(%IPM.Storage.FileHash).NormalizePath(dir)
+ }
+ }
+ quit dir
+}
+
Method GetSourceControlInfo(Output pInfo As %IPM.ExtensionBase.SourceControl.ResourceInfo) As %Status
{
set pInfo = ##class(%IPM.ExtensionBase.SourceControl.ResourceInfo).%New()
diff --git a/src/cls/IPM/ResourceProcessor/Test.cls b/src/cls/IPM/ResourceProcessor/Test.cls
index 10bcaf99..ac4bc86b 100644
--- a/src/cls/IPM/ResourceProcessor/Test.cls
+++ b/src/cls/IPM/ResourceProcessor/Test.cls
@@ -214,64 +214,80 @@ Method OnPhase(
zkill ^UnitTestRoot
$$$ThrowOnError(tSC)
- set testIndex = $get(^||%UnitTest.Manager.AllResults($get(^||%UnitTest.Manager.AllResultsCount)))
- if testIndex = "" {
- set testIndex = $order(^UnitTest.Result(""),-1)
- }
- if $data(pParams("outputfile"), outputFile) {
- set fileExtension = $zconvert($piece(outputFile,".",*),"L")
- set outputClass = $case(fileExtension,
- "json":"%IPM.Test.JsonOutput",
- "yaml":"%IPM.Test.YamlOutput",
- "toon":"%IPM.Test.ToonOutput",
- "xml":"%IPM.Test.JUnitOutput",
- :"")
- if outputClass = "" {
- $$$ThrowOnError($$$ERROR($$$GeneralError,"Unsupported output-file extension '."_fileExtension_"'. Use .json, .yaml, .toon, or .xml."))
- }
- set outputDir = ##class(%File).GetDirectory(outputFile)
- if outputDir '= "" {
- $$$ThrowOnError(##class(%IPM.Utils.File).CreateDirectoryChain(outputDir))
- }
- set tSC = $classmethod(outputClass,"ToFile",outputFile)
- $$$ThrowOnError(tSC)
- }
set suppressor = ""
+ $$$ThrowOnError(..ReportTestResults(phaseStartIndex, tVerbose, .pParams))
+ }
+ } catch e {
+ set tSC = e.AsStatus()
+ }
+ if $data(tOldUnitTestRoot,^UnitTestRoot) // Restore ^UnitTestRoot
+ quit tSC
+}
- set outputFormat = $get(pParams("outputformat"))
- if outputFormat = "" {
- set outputFormat = ##class(%IPM.Repo.UniversalSettings).GetTestReportFormat()
+/// Shared result-reporting tail for OnPhase and OnSyncRunTests: resolves the test-run's log
+/// index, optionally exports full results to a file, then writes the "Test Results:" summary
+/// (format-specific output class, or the legacy default) and throws if FailuresAreFatal and
+/// any test in this phase failed. Callers must clear their own OutputSuppressor (if any)
+/// before calling — the file-export step is unaffected by device redirection, but the summary
+/// write below is not.
+ClassMethod ReportTestResults(
+ phaseStartIndex As %Integer,
+ verbose As %Boolean,
+ ByRef params) As %Status [ Private ]
+{
+ set sc = $$$OK
+ try {
+ set testIndex = $get(^||%UnitTest.Manager.AllResults($get(^||%UnitTest.Manager.AllResultsCount)))
+ if testIndex = "" {
+ set testIndex = $order(^UnitTest.Result(""),-1)
+ }
+ if $data(params("outputfile"), outputFile) {
+ set fileExtension = $zconvert($piece(outputFile,".",*),"L")
+ set outputClass = $case(fileExtension,
+ "json":"%IPM.Test.JsonOutput",
+ "yaml":"%IPM.Test.YamlOutput",
+ "toon":"%IPM.Test.ToonOutput",
+ "xml":"%IPM.Test.JUnitOutput",
+ :"")
+ if outputClass = "" {
+ $$$ThrowOnError($$$ERROR($$$GeneralError,"Unsupported output-file extension '."_fileExtension_"'. Use .json, .yaml, .toon, or .xml."))
+ }
+ set outputDir = ##class(%File).GetDirectory(outputFile)
+ if outputDir '= "" {
+ $$$ThrowOnError(##class(%IPM.Utils.File).CreateDirectoryChain(outputDir))
}
+ $$$ThrowOnError($classmethod(outputClass,"ToFile",outputFile))
+ }
- write !!,"Test Results:"
- if outputFormat '= "" {
- set outputClass = "%IPM.Test."_$zconvert(outputFormat,"w")_"Output"
- if '$$$defClassDefined(outputClass) {
- $$$ThrowOnError($$$ERROR($$$GeneralError,"Unknown output format: "_outputFormat))
- }
- set tSC = $classmethod(outputClass,"OutputToDevice",testIndex,tVerbose,1)
- $$$ThrowOnError(tSC)
- } else {
- set tSC = ##class(%IPM.Test.Abstract).OutputToDevice(testIndex,tVerbose,0)
- $$$ThrowOnError(tSC)
+ set outputFormat = $get(params("outputformat"))
+ if outputFormat = "" {
+ set outputFormat = ##class(%IPM.Repo.UniversalSettings).GetTestReportFormat()
+ }
+
+ write !!,"Test Results:"
+ if outputFormat '= "" {
+ set outputClass = "%IPM.Test."_$zconvert(outputFormat,"w")_"Output"
+ if '$$$defClassDefined(outputClass) {
+ $$$ThrowOnError($$$ERROR($$$GeneralError,"Unknown output format: "_outputFormat))
}
- write !
- // Detect and report unit test failures as an error from this phase.
- // OutputFailures shows legacy red FAILED lines only when no format is active.
- if $get(pParams("UnitTest","FailuresAreFatal"),1) {
- if outputFormat = "" {
- do ##class(%IPM.Test.Manager).OutputFailures(phaseStartIndex)
- }
- set tSC = ##class(%IPM.Test.Manager).GetAllTestsStatus(,phaseStartIndex)
- $$$ThrowOnError(tSC)
+ $$$ThrowOnError($classmethod(outputClass,"OutputToDevice",testIndex,verbose,1))
+ } else {
+ $$$ThrowOnError(##class(%IPM.Test.Abstract).OutputToDevice(testIndex,verbose,0))
+ }
+ write !
+ // Detect and report unit test failures as an error from this phase.
+ // OutputFailures shows legacy red FAILED lines only when no format is active.
+ if $get(params("UnitTest","FailuresAreFatal"),1) {
+ if outputFormat = "" {
+ do ##class(%IPM.Test.Manager).OutputFailures(phaseStartIndex)
}
- write !
+ $$$ThrowOnError(##class(%IPM.Test.Manager).GetAllTestsStatus(,phaseStartIndex))
}
+ write !
} catch e {
- set tSC = e.AsStatus()
+ set sc = e.AsStatus()
}
- if $data(tOldUnitTestRoot,^UnitTestRoot) // Restore ^UnitTestRoot
- quit tSC
+ quit sc
}
Method OnResolveChildren(ByRef pResourceArray) As %Status
@@ -359,6 +375,208 @@ Method OnItemRelativePath(pItemName As %String) As %String
quit ..EmbeddedProcessor.OnItemRelativePath(pItemName)
}
+Method SupportsSync() As %Boolean
+{
+ quit 1
+}
+
+/// The test directory (resource Name, e.g. "/tests/unit/") is the owned scan directory.
+/// Sync walks this directory directly so new test classes are detected before compilation.
+Method GetSyncDirectory() As %String
+{
+ quit ##class(%IPM.Storage.FileHash).NormalizePath(..ResourceReference.Name)
+}
+
+Method OnSync(ByRef modifiedPaths, ByRef deletedPaths, ByRef params, Output handled As %Boolean = 0) As %Status
+{
+ set sc = $$$OK
+ set handled = 1
+ try {
+ set verbose = $get(params("Verbose"))
+ set unitTestDir = ##class(%File).NormalizeDirectory(..ResourceReference.Module.Root _ ..ResourceReference.Name)
+
+ // Reload all test files in the directory, then compile so %Extends checks are valid below.
+ // New test classes may not yet be in ^oddDEF, so we can't limit to changed files here.
+ $$$ThrowOnError(##class(%IPM.Test.Manager).LoadTestDirectory(unitTestDir, verbose, .loadedList, ..Format))
+ if ..Package '= "" {
+ $$$ThrowOnError($system.OBJ.CompilePackage(..Package, "ck"_$select(verbose:"d",1:"-d")))
+ } elseif ..Class '= "" {
+ $$$ThrowOnError($system.OBJ.Compile(..Class, "ck"_$select(verbose:"d",1:"-d")))
+ }
+
+ // Record changed TestCase subclasses for SyncRunTests.
+ // relPath is relative to module root (e.g. "tests/unit/SyncTest/Tests/Trivial.cls").
+ set resourceDir = ##class(%IPM.Storage.FileHash).NormalizePath(..ResourceReference.Name)
+ set relPath = ""
+ for {
+ set relPath = $order(modifiedPaths(relPath))
+ quit:relPath=""
+ set className = ..RelPathToClassName(relPath, resourceDir)
+ if className '= "" && $$$comClassDefined(className)
+ && $classmethod(className, "%Extends", "%UnitTest.TestCase") {
+ set params("Sync", "ChangedTestCases", className) = ..ResourceReference.Name
+ }
+ }
+
+ // Delete server-side classes for removed test files. SyncApplyDeletes skips Test
+ // resources (not AbstractCompilable), so OnSync must own the delete for this type.
+ set relPath = ""
+ for {
+ set relPath = $order(deletedPaths(relPath))
+ quit:relPath=""
+ set className = ..RelPathToClassName(relPath, resourceDir)
+ if className '= "" && $$$comClassDefined(className) {
+ $$$ThrowOnError($system.OBJ.Delete(className, $select(verbose:"d",1:"-d")))
+ }
+ }
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ quit sc
+}
+
+/// Called once per resource by SyncRunTests: runs every changed test-phase case belonging
+/// to this resource in a single RunTest invocation instead of one call per class.
+/// classInfo(className) = "" for each changed TestCase class in this resource.
+/// Assumes OnSync already loaded and compiled these classes earlier in the same sync() call,
+/// so no reload/recompile happens here — only testspec construction, RunTest, and reporting.
+Method OnSyncRunTests(ByRef classInfo, ByRef params) As %Status
+{
+ set sc = $$$OK
+ try {
+ set verbose = $get(params("Verbose"), 0)
+ set unitTestDir = ##class(%File).NormalizeDirectory(..ResourceReference.Module.Root _ ..ResourceReference.Name)
+
+ // Initialize test result accumulator for this phase if not already initialized (i.e., we're at top level, not nested)
+ // Mirrors OnPhase's accumulator so multiple resources synced in one -test run share one result set.
+ if '$data(^||%UnitTest.Manager.AllResultsCount) {
+ kill ^||%UnitTest.Manager.AllResults
+ set ^||%UnitTest.Manager.AllResultsCount = 0
+ }
+ set phaseStartIndex = $get(^||%UnitTest.Manager.AllResultsCount, 0)
+
+ // Build one testspec covering every changed class in this resource. Classes sharing
+ // a subdirectory must join with ";" within ONE "dir:Class1;Class2" entry — repeating
+ // the same dirPart across separate comma-joined entries is invalid grammar (RunTest
+ // treats each comma-separated entry as its own testsuite scope; a second entry naming
+ // the same dir errors instead of adding to the first). Distinct subdirectories become
+ // separate comma-joined entries so unrelated sibling test classes elsewhere in the
+ // resource are not swept in. dirPart is derived from className (its inverse) rather
+ // than passed in, since a TestCase class name always mirrors its on-disk path.
+ kill byDir
+ set className = ""
+ for {
+ set className = $order(classInfo(className))
+ quit:className=""
+ set dirPart = $translate($piece(className, ".", 1, *-1), ".", "/")
+ set byDir(dirPart, className) = ""
+ }
+ set testSpec = ""
+ set dirPart = ""
+ for {
+ set dirPart = $order(byDir(dirPart))
+ quit:dirPart=""
+ set classList = ""
+ set className = ""
+ for {
+ set className = $order(byDir(dirPart, className))
+ quit:className=""
+ set classList = classList _ $select(classList="":"", 1: ";") _ className
+ }
+ set testSpec = testSpec _ $select(testSpec="":"", 1: ",") _ dirPart _ ":" _ classList
+ }
+
+ set explicitQuiet = ($data(params("Verbose")) && (params("Verbose") = 0))
+ if explicitQuiet {
+ set suppressor = ##class(%IPM.Utils.OutputSuppressor).%New()
+ }
+
+ set flags = $select(verbose:"/display=all",1:"/display=none")_"/nodelete/norecursive"
+
+ if $data(^UnitTestRoot,oldUnitTestRoot) // Stash ^UnitTestRoot
+ set ^UnitTestRoot = unitTestDir
+ set managerClass = $get(params("UnitTest","ManagerClass"),..ManagerClass)
+ if (managerClass = "") {
+ set managerClass = "%IPM.Test.Manager"
+ }
+ merge userParams = params("UnitTest","UserParam")
+ set sc = $classmethod(managerClass,"RunTest",testSpec,flags,.userParams)
+ zkill ^UnitTestRoot
+ $$$ThrowOnError(sc)
+
+ set suppressor = ""
+ // Tally this resource's per-method pass/fail into the whole-namespace-sync summary before
+ // ReportTestResults runs — at top level it consumes (kills) the result accumulator.
+ do ..SyncSummaryTallyTests(..ResourceReference.Module.Name, phaseStartIndex)
+ $$$ThrowOnError(..ReportTestResults(phaseStartIndex, verbose, .params))
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ if $data(oldUnitTestRoot,^UnitTestRoot) // Restore ^UnitTestRoot
+ quit sc
+}
+
+/// Count the test methods run since phaseStartIndex and forward their pass/fail tally to the
+/// whole-namespace-sync summary. A method counts as failed if it errored or has any failed
+/// assertion, mirroring how GetAllTestsStatus/OutputFailures classify a failure; otherwise passed.
+/// No-op unless a whole-namespace sync is in progress (%IPM.General.Sync.Summary gates on that).
+ClassMethod SyncSummaryTallyTests(
+ moduleName As %String,
+ phaseStartIndex As %Integer) [ Private ]
+{
+ set passed = 0, failed = 0
+ set testCount = $get(^||%UnitTest.Manager.AllResultsCount, 0)
+ for i = (phaseStartIndex + 1):1:testCount {
+ set logIndex = $get(^||%UnitTest.Manager.AllResults(i))
+ continue:logIndex=""
+ set tree = ##class(%IPM.Test.Abstract).BuildResultTree(logIndex)
+ set suiteIter = tree.suites.%GetIterator()
+ while suiteIter.%GetNext(, .suiteObj) {
+ set caseIter = suiteObj.cases.%GetIterator()
+ while caseIter.%GetNext(, .caseObj) {
+ set methodIter = caseObj.methods.%GetIterator()
+ while methodIter.%GetNext(, .methodObj) {
+ set methodFailed = (methodObj.error '= "")
+ set assertIter = methodObj.asserts.%GetIterator()
+ while assertIter.%GetNext(, .assertObj) {
+ if assertObj.status = "failed" {
+ set methodFailed = 1
+ }
+ }
+ if methodFailed {
+ set failed = failed + 1
+ } else {
+ set passed = passed + 1
+ }
+ }
+ }
+ }
+ }
+ do ##class(%IPM.General.Sync.Summary).AddTests(moduleName, passed, failed)
+}
+
+/// Convert a module-root-relative relPath to a class name within this test resource.
+/// e.g. "tests/unit/SyncTest/Tests/Trivial.cls" → "SyncTest.Tests.Trivial".
+/// Returns "" for non-.cls files or paths outside resourceDir.
+ClassMethod RelPathToClassName(relPath As %String, resourceDir As %String) As %String [ Private ]
+{
+ set packageRelPath = relPath
+ if $extract(packageRelPath, 1, $length(resourceDir)) = resourceDir {
+ set packageRelPath = $extract(packageRelPath, $length(resourceDir) + 1, *)
+ }
+ // Strip leading slash left after prefix removal (e.g. "/SyncTest/...")
+ if $extract(packageRelPath) = "/" {
+ set packageRelPath = $extract(packageRelPath, 2, *)
+ }
+ set fileName = $piece(packageRelPath, "/", *)
+ if $zconvert($piece(fileName, ".", *), "U") '= "CLS" {
+ quit ""
+ }
+ set baseName = $piece(fileName, ".", 1, *-1)
+ set dirPart = $piece(packageRelPath, "/", 1, *-1)
+ quit $select(dirPart '= "": $translate(dirPart, "/", ".") _ "." _ baseName, 1: baseName)
+}
+
Method %OnValidateObject() As %Status [ Private, ServerOnly = 1 ]
{
if ((..Package = "") && (..Class = "")) || ((..Package '= "") && (..Class '= "")) {
diff --git a/src/cls/IPM/Storage/FileHash.cls b/src/cls/IPM/Storage/FileHash.cls
new file mode 100644
index 00000000..2a6a814f
--- /dev/null
+++ b/src/cls/IPM/Storage/FileHash.cls
@@ -0,0 +1,542 @@
+Include (%IPM.Common, %occReference)
+
+Class %IPM.Storage.FileHash Extends %Persistent
+{
+
+Property ModuleName As %String(MAXLEN = 255) [ Required ];
+
+/// Path relative to the module root, normalized: forward slashes, no leading slash, no double slashes.
+/// Used as the lookup key in ModulePathIndex — callers must normalize via NormalizePath before querying.
+Property RelativePath As %String(MAXLEN = 512) [ Required ];
+
+/// SHA-1 content hash (hex).
+Property Hash As %String(MAXLEN = 64) [ Required ];
+
+Index ModulePathIndex On (ModuleName, RelativePath) [ Unique ];
+
+Index ModuleNameIndex On ModuleName;
+
+ForeignKey ModuleNameFK(ModuleName) References %IPM.Storage.Module(Name) [ OnDelete = cascade ];
+
+/// Collect the deduplicated set of directories sync should walk for a module.
+/// Each sync-supporting resource processor declares its owned directory via GetSyncDirectory();
+/// this gathers them into scanDirs(relDir)="" and removes parent/child overlaps. Shared by
+/// StampModule and the sync pipeline so both walk exactly the same directory set.
+ClassMethod CollectScanDirs(module As %IPM.Storage.Module, Output scanDirs)
+{
+ kill scanDirs
+ set orderedResourceList = module.GetOrderedResourceList()
+ set key = ""
+ for {
+ set resource = orderedResourceList.GetNext(.key)
+ quit:key=""
+ if '$isobject(resource.Processor) || 'resource.Processor.SupportsSync() {
+ continue
+ }
+ set syncDir = resource.Processor.GetSyncDirectory()
+ if syncDir '= "" {
+ set scanDirs(syncDir) = ""
+ }
+ }
+ do ..DeduplicateScanDirs(.scanDirs)
+}
+
+/// Stamp all tracked files for a module.
+/// Collects scan directories from each resource processor via GetSyncDirectory(), deduplicates,
+/// walks only those directories, then stamps every compilable file found there (no namespace
+/// check — see the note at the stamping loop below). Also stamps module.xml from the module root.
+ClassMethod StampModule(module As %IPM.Storage.Module) As %Status
+{
+ set sc = $$$OK
+ try {
+ set root = ##class(%File).NormalizeDirectory(module.Root)
+
+ // Collect and deduplicate scan directories from resource processors.
+ do ..CollectScanDirs(module, .scanDirs)
+
+ // Walk each declared directory.
+ kill allFiles, allHashes
+ $$$ThrowOnError(..WalkAndHashDirs(root, .scanDirs, .allFiles, .allHashes))
+
+ // module.xml is always tracked — hash it directly from root.
+ set moduleXmlPath = root _ "module.xml"
+ if ##class(%File).Exists(moduleXmlPath) {
+ set moduleXmlHash = $$$lcase(##class(%File).SHA1Hash(moduleXmlPath, 1))
+ if moduleXmlHash '= "" {
+ $$$ThrowOnError(..StampOneFileWithHash(module.Name, "module.xml", moduleXmlHash))
+ }
+ }
+
+ // Stamp all compilable files found in scan dirs.
+ // Targeted walks only visit declared resource directories, so every compilable file
+ // found here belongs to the module and should be tracked. No namespace presence check
+ // needed — directory-based resources (e.g. test dirs) use paths that RelPathToDocName
+ // can't resolve to the correct class name, so a namespace check would wrongly skip them.
+ set relPath = ""
+ for {
+ set relPath = $order(allFiles(relPath))
+ quit:relPath=""
+ set ext = $$$lcase($piece(relPath, ".", *))
+ if ",cls,inc,mac,int,xml,rtn," '[ (","_ext_",") {
+ continue
+ }
+ set hash = $get(allHashes(relPath))
+ if hash = "" {
+ continue
+ }
+ set stampSC = ..StampOneFileWithHash(module.Name, relPath, hash)
+ if $$$ISERR(stampSC) {
+ write !, "Warning: could not stamp ", relPath, ": ", $system.Status.GetOneErrorText(stampSC)
+ }
+ }
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ quit sc
+}
+
+/// Stamp (or update) the FileHash row for a single file using a pre-computed hash.
+ClassMethod StampOneFileWithHash(moduleName As %String, relPath As %String, hash As %String) As %Status
+{
+ if hash = "" {
+ quit $$$ERROR($$$GeneralError, "Empty hash for: " _ relPath)
+ }
+ set existing = ..ModulePathIndexOpen(moduleName, relPath, , .openSC)
+ if $isobject(existing) {
+ set instance = existing
+ } else {
+ set instance = ..%New()
+ set instance.ModuleName = moduleName
+ set instance.RelativePath = relPath
+ }
+ set instance.Hash = hash
+ quit instance.%Save()
+}
+
+/// Derive a server document name from a module-root-relative path.
+/// Returns "" for paths that don't map to a compilable document.
+/// Examples:
+/// "src/cls/Foo/Bar.cls" -> "Foo.Bar.CLS"
+/// "src/SyncFlat/Flat.cls" -> "SyncFlat.Flat.CLS"
+/// "src/inc/MyInc.inc" -> "MyInc.INC"
+ClassMethod RelPathToDocName(relPath As %String) As %String
+{
+ set parts = $length(relPath, "/")
+ set ext = $$$lcase($piece(relPath, ".", *))
+ if ",cls,inc,mac,int," '[ (","_ext_",") {
+ quit ""
+ }
+ // Skip leading path segments that are conventional prefix directories.
+ // Stop at the first segment that isn't one of these well-known prefixes.
+ set startSeg = 1
+ for i = 1:1:parts {
+ set seg = $piece(relPath, "/", i)
+ if ",src,cls,inc,mac,int," [ (","_$$$lcase(seg)_",") {
+ set startSeg = i + 1
+ } else {
+ quit
+ }
+ }
+ if startSeg > parts {
+ quit ""
+ }
+ // Segments startSeg through (parts-1) are package/directory components.
+ // Segment parts is "ClassName.ext" — strip the extension for the final component.
+ set name = ""
+ for i = startSeg:1:(parts - 1) {
+ set name = name _ $select(name="":"", 1:".") _ $piece(relPath, "/", i)
+ }
+ set lastName = $piece($piece(relPath, "/", parts), ".", 1, *-1)
+ set name = name _ $select(name="":"", 1:".") _ lastName
+ quit name _ "." _ $$$UPPER(ext)
+}
+
+/// Compute which files changed on disk vs stored baseline.
+/// Pass 1: compilable files present in the namespace, from the pre-walked file set.
+/// Pass 2: manifest-derived paths (from reverseIndex) for files not yet compiled
+/// or non-compilable tracked files.
+/// Pass 3: iterate stored rows to detect deletions.
+/// allFiles(relPath)=fullPath is the full pre-walked module root (all extensions).
+/// allHashes(relPath)=sha1hex are pre-computed hashes from the same walk.
+/// module.xml is deliberately excluded from all three passes — it is owned by the pipeline's
+/// SyncCheckModuleXml/SyncCommitModuleXml, never routed as a normal file.
+/// Returns modified(relPath)=newHash and deleted(relPath)="" arrays.
+ClassMethod ComputeChanges(module As %IPM.Storage.Module, Output modified, Output deleted, ByRef reverseIndex, ByRef allFiles, ByRef allHashes) As %Status
+{
+ set sc = $$$OK
+ kill modified, deleted
+ try {
+ set moduleName = module.Name
+ kill seen
+
+ // Pass 1: compilable files present in the namespace. RelPathToDocName returns "" for
+ // non-compilable extensions, so iterating allFiles and skipping empty docNames suffices.
+ set relPath = ""
+ for {
+ set relPath = $order(allFiles(relPath))
+ quit:relPath=""
+ set docName = ..RelPathToDocName(relPath)
+ if docName = "" {
+ continue
+ }
+ set docExt = $$$lcase($piece(docName, ".", *))
+ if docExt = "cls" {
+ set className = $piece(docName, ".", 1, *-1)
+ if '$$$comClassDefined(className) {
+ continue
+ }
+ } else {
+ if '##class(%RoutineMgr).Exists(docName) {
+ continue
+ }
+ }
+ do ..CompareOneFile(moduleName, relPath, $get(allHashes(relPath)), .modified, .seen)
+ }
+
+ // Pass 2: check manifest-derived paths for files Pass 1 missed (uncompiled new files,
+ // non-compilable tracked files). Top-level $order over reverseIndex yields relPaths only;
+ // the per-path "Processor"/"Resource" subnodes are children and are skipped.
+ set relPath = ""
+ for {
+ set relPath = $order(reverseIndex(relPath))
+ quit:relPath=""
+ if '$data(allFiles(relPath)) {
+ continue
+ }
+ do ..CompareOneFile(moduleName, relPath, $get(allHashes(relPath)), .modified, .seen)
+ }
+
+ // Pass 3: iterate all stored rows — file not in allFiles → deleted. module.xml is skipped:
+ // it is always a stored row but is not part of the walked file set, so it would otherwise
+ // be misreported as deleted every sync.
+ set result = ##class(%SQL.Statement).%ExecDirect(,
+ "SELECT RelativePath FROM %IPM_Storage.FileHash WHERE ModuleName = ?",
+ moduleName)
+ while result.%Next() {
+ set storedRelPath = result.%Get("RelativePath")
+ if storedRelPath = "module.xml" {
+ continue
+ }
+ if '$data(allFiles(storedRelPath)) {
+ set deleted(storedRelPath) = ""
+ }
+ }
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ quit sc
+}
+
+/// Compare a file's pre-computed hash against stored baseline. No filesystem I/O.
+/// Mutates the caller's by-ref arrays: sets modified(relPath)=newHash when the file is new or
+/// changed, and marks seen(relPath) so a later pass won't re-compare the same path.
+ClassMethod CompareOneFile(moduleName As %String, relPath As %String, newHash As %String, ByRef modified, ByRef seen) [ Private ]
+{
+ if $data(seen(relPath)) {
+ quit
+ }
+ set seen(relPath) = ""
+ if newHash = "" {
+ set modified(relPath) = ""
+ quit
+ }
+ set existing = ..ModulePathIndexOpen(moduleName, relPath, , .openSC)
+ if '$isobject(existing) {
+ set modified(relPath) = newHash
+ quit
+ }
+ if existing.Hash '= newHash {
+ set modified(relPath) = newHash
+ }
+}
+
+/// After a successful sync, commit new hashes for modified files and optionally remove deleted rows.
+ClassMethod CommitChanges(module As %IPM.Storage.Module, ByRef modified, ByRef deleted, processDeletes As %Boolean = 0) As %Status
+{
+ set sc = $$$OK
+ try {
+ set relPath = ""
+ for {
+ set relPath = $order(modified(relPath), 1, newHash)
+ quit:relPath=""
+
+ set existing = ..ModulePathIndexOpen(module.Name, relPath, , .openSC)
+ if $isobject(existing) {
+ set instance = existing
+ } else {
+ set instance = ..%New()
+ set instance.ModuleName = module.Name
+ set instance.RelativePath = relPath
+ }
+
+ set instance.Hash = newHash
+ $$$ThrowOnError(instance.%Save())
+ }
+
+ if processDeletes {
+ set relPath = ""
+ for {
+ set relPath = $order(deleted(relPath))
+ quit:relPath=""
+
+ set existing = ..ModulePathIndexOpen(module.Name, relPath, , .openSC)
+ if $isobject(existing) {
+ $$$ThrowOnError(existing.%DeleteId(existing.%Id()))
+ }
+ }
+ }
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ quit sc
+}
+
+/// Returns 1 if this module has any stored baseline rows.
+ClassMethod HasBaseline(moduleName As %String) As %Boolean
+{
+ set result = ##class(%SQL.Statement).%ExecDirect(,
+ "SELECT TOP 1 1 FROM %IPM_Storage.FileHash WHERE ModuleName = ?",
+ moduleName)
+ if result.%SQLCODE < 0 {
+ quit 0
+ }
+ quit result.%Next()
+}
+
+/// Populate paths(normalizedRelPath)="" for all stored baseline rows for this module.
+ClassMethod GetStoredPaths(moduleName As %String, Output paths)
+{
+ kill paths
+ set result = ##class(%SQL.Statement).%ExecDirect(,
+ "SELECT RelativePath FROM %IPM_Storage.FileHash WHERE ModuleName = ?",
+ moduleName)
+ if result.%SQLCODE < 0 {
+ quit
+ }
+ while result.%Next() {
+ set paths(result.%Get("RelativePath")) = ""
+ }
+}
+
+/// Normalize a relative path: forward slashes, collapse //, strip leading slash.
+ClassMethod NormalizePath(path As %String) As %String
+{
+ set path = $translate(path, "\", "/")
+ while path [ "//" {
+ set path = $replace(path, "//", "/")
+ }
+ while $extract(path) = "/" {
+ set path = $extract(path, 2, *)
+ }
+ quit path
+}
+
+/// Walk each directory in scanDirs(relDir)="" under moduleRoot and hash all files.
+/// Returns files(relPath)=fullPath and hashes(relPath)=sha1hex (lowercase).
+/// Uses a single Python os.walk call across all directories for speed; falls back to SQL BFS.
+ClassMethod WalkAndHashDirs(moduleRoot As %String, ByRef scanDirs, Output files, Output hashes) As %Status
+{
+ set sc = $$$OK
+ kill files, hashes
+ try {
+ set moduleRoot = ##class(%File).NormalizeDirectory(moduleRoot)
+
+ // Collect existing absolute directories to walk.
+ kill absDirList
+ set dirCount = 0
+ set relDir = ""
+ for {
+ set relDir = $order(scanDirs(relDir))
+ quit:relDir=""
+ set absDir = ##class(%File).NormalizeDirectory(moduleRoot _ relDir)
+ if ##class(%File).DirectoryExists(absDir) {
+ set dirCount = dirCount + 1
+ set absDirList(dirCount) = absDir
+ }
+ }
+ quit:dirCount=0
+
+ // Single Python call across all directories — avoids per-dir interpreter overhead.
+ set dirsJson = "["
+ for i = 1:1:dirCount {
+ if i > 1 { set dirsJson = dirsJson _ "," }
+ set dirsJson = dirsJson _ """" _ $replace(absDirList(i), "\", "\\") _ """"
+ }
+ set dirsJson = dirsJson _ "]"
+
+ set walkSC = ..WalkAndHashFilesPython(dirsJson, moduleRoot, .files, .hashes)
+ if $$$ISERR(walkSC) {
+ // SQL fallback: walk each directory individually.
+ kill files, hashes
+ for i = 1:1:dirCount {
+ kill dirFiles, dirHashes
+ $$$ThrowOnError(..WalkAndHashFilesSQL(absDirList(i), moduleRoot, .dirFiles, .dirHashes))
+ merge files = dirFiles
+ merge hashes = dirHashes
+ }
+ }
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ quit sc
+}
+
+/// Remove entries from scanDirs that are already covered by a shorter prefix.
+/// Sorts alphabetically; skips any entry that starts with a previously-kept entry + "/".
+ClassMethod DeduplicateScanDirs(ByRef scanDirs)
+{
+ kill kept
+ set relDir = ""
+ for {
+ set relDir = $order(scanDirs(relDir))
+ quit:relDir=""
+ set covered = 0
+ set keptDir = ""
+ for {
+ set keptDir = $order(kept(keptDir))
+ quit:keptDir=""
+ set keptPrefix = ..NormalizePath(keptDir _ "/")
+ if $extract(relDir, 1, $length(keptPrefix)) = keptPrefix {
+ set covered = 1
+ quit
+ }
+ }
+ if 'covered {
+ set kept(relDir) = ""
+ }
+ }
+ kill scanDirs
+ merge scanDirs = kept
+}
+
+ClassMethod WalkAndHashFilesPython(dirsJson As %String, relativeToRoot As %String, Output files, Output hashes) As %Status [ Private ]
+{
+ set sc = $$$OK
+ try {
+ set relativeToRoot = ##class(%File).NormalizeDirectory(relativeToRoot)
+ set jsonStr = ..WalkAndHashFilesPythonImpl(dirsJson, relativeToRoot)
+ set result = ##class(%DynamicArray).%FromJSON(jsonStr)
+ set count = result.%Size()
+ for i = 0:1:(count - 1) {
+ set entry = result.%Get(i)
+ set relPath = entry.%Get("rel")
+ set files(relPath) = entry.%Get("full")
+ set hashes(relPath) = entry.%Get("hash")
+ }
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ quit sc
+}
+
+ClassMethod WalkAndHashFilesPythonImpl(dirsJson As %String, relativeToRoot As %String) As %String [ Language = python ]
+{
+import os
+import json
+import hashlib
+import concurrent.futures
+
+SKIP_DIRS = {'.git', '__pycache__', 'node_modules'}
+
+dirs = json.loads(dirsJson)
+root_len = len(relativeToRoot.rstrip(os.sep)) + 1
+
+# Walk all directories, collecting all files.
+file_list = []
+for d in dirs:
+ for dirpath, dirnames, filenames in os.walk(d):
+ dirnames[:] = [x for x in dirnames if x not in SKIP_DIRS]
+ for fname in filenames:
+ full_path = os.path.join(dirpath, fname)
+ rel_path = full_path[root_len:].replace('\\', '/')
+ if rel_path:
+ file_list.append((rel_path, full_path))
+
+def hash_file(full_path):
+ try:
+ h = hashlib.sha1()
+ with open(full_path, 'rb') as f:
+ while chunk := f.read(65536):
+ h.update(chunk)
+ return h.hexdigest()
+ except (OSError, PermissionError):
+ return ""
+
+# Hash all files in parallel — SHA1 releases the GIL, so threads overlap I/O wait.
+with concurrent.futures.ThreadPoolExecutor(max_workers=24) as executor:
+ hash_results = list(executor.map(hash_file, [f for _, f in file_list]))
+
+return json.dumps([
+ {"rel": rel, "full": full, "hash": h}
+ for (rel, full), h in zip(file_list, hash_results)
+])
+}
+
+/// SQL-based BFS fallback that walks and hashes without Python.
+ClassMethod WalkAndHashFilesSQL(dir As %String, relativeToRoot As %String, Output files, Output hashes) As %Status [ Private ]
+{
+ set sc = $$$OK
+ try {
+ set dir = ##class(%File).NormalizeDirectory(dir)
+ set relativeToRoot = ##class(%File).NormalizeDirectory(relativeToRoot)
+ kill walkQueue
+ set walkHead = 1, walkTail = 1
+ set walkQueue(walkTail) = dir
+ for {
+ quit:(walkHead > walkTail)
+ set walkDir = walkQueue(walkHead)
+ set walkHead = walkHead + 1
+
+ set rs = ##class(%SQL.Statement).%ExecDirect(,
+ "SELECT Name, Type FROM %Library.File_FileSet(?, ?, ?, ?)",
+ walkDir, "*", "", 0)
+ while rs.%Next() {
+ set entryType = rs.%Get("Type")
+ set entryPath = rs.%Get("Name")
+ if entryType = "D" {
+ set childDirName = ##class(%File).GetFilename(entryPath)
+ if ",.git,__pycache__,node_modules," [ (","_childDirName_",") {
+ continue
+ }
+ set walkTail = walkTail + 1
+ set walkQueue(walkTail) = ##class(%File).NormalizeDirectory(entryPath)
+ continue
+ }
+ set relPath = ..NormalizePath($extract(entryPath, $length(relativeToRoot) + 1, *))
+ if relPath '= "" {
+ set files(relPath) = entryPath
+ set hashes(relPath) = $$$lcase(##class(%File).SHA1Hash(entryPath, 1))
+ }
+ }
+ }
+ } catch e {
+ set sc = e.AsStatus()
+ }
+ quit sc
+}
+
+Storage Default
+{
+
+
+%%CLASSNAME
+
+
+ModuleName
+
+
+RelativePath
+
+
+Hash
+
+
+^IPM.Storage.FileHashD
+FileHashDefaultData
+^IPM.Storage.FileHashD
+^IPM.Storage.FileHashI
+^IPM.Storage.FileHashS
+%Storage.Persistent
+}
+
+}
diff --git a/src/cls/IPM/Utils/Module.cls b/src/cls/IPM/Utils/Module.cls
index 19b83a08..87e3208a 100644
--- a/src/cls/IPM/Utils/Module.cls
+++ b/src/cls/IPM/Utils/Module.cls
@@ -1448,6 +1448,88 @@ ClassMethod GetFlatDependencyListFromInvertedDependencyGraph(ByRef pInvertedDepe
return orderedDependencyList
}
+/// Returns a $list of dev-mode module names ordered least-dependent-first: a module's
+/// dependencies appear before it. Only dependency edges among the dev-mode set are
+/// considered; unconnected modules keep their natural row order. Used by whole-namespace sync so each
+/// module recompiles against dependencies already synced in the same run.
+ClassMethod GetDevModeModulesInDependencyOrder() As %List
+{
+ // Dev-mode modules in row order (stable tiebreaker for modules with no ordering constraint).
+ set result = ##class(%SQL.Statement).%ExecDirect(,
+ "select Name from %IPM_Storage.ModuleItem where DeveloperMode = 1")
+ if (result.%SQLCODE < 0) {
+ throw ##class(%Exception.SQL).CreateFromSQLCODE(result.%SQLCODE, result.%Message)
+ }
+ kill devMode
+ set rowOrder = ""
+ while result.%Next(.sc) {
+ $$$ThrowOnError(sc)
+ set name = result.%Get("Name")
+ set devMode(name) = ""
+ set rowOrder = rowOrder _ $listbuild(name)
+ }
+ $$$ThrowOnError(sc)
+
+ // Dependency edges restricted to the dev-mode set: dependsOn(modName, depName) = "".
+ set depRes = ##class(%SQL.Statement).%ExecDirect(,
+ "select ModuleItem->Name ModName, Dependencies_Name DepName from %IPM_Storage.ModuleItem_Dependencies")
+ if (depRes.%SQLCODE < 0) {
+ throw ##class(%Exception.SQL).CreateFromSQLCODE(depRes.%SQLCODE, depRes.%Message)
+ }
+ kill dependsOn
+ while depRes.%Next(.sc) {
+ $$$ThrowOnError(sc)
+ set modName = depRes.%Get("ModName")
+ set depName = depRes.%Get("DepName")
+ if $data(devMode(modName)) && $data(devMode(depName)) {
+ set dependsOn(modName, depName) = ""
+ }
+ }
+ $$$ThrowOnError(sc)
+
+ // Emit a module only once all its (dev-mode) dependencies are already emitted; repeat until
+ // every module is placed. Each pass scans in row order so independent modules keep their
+ // natural sequence.
+ set ordered = ""
+ kill emitted
+ set remaining = $listlength(rowOrder)
+ while remaining > 0 {
+ set progress = 0
+ set ptr = 0
+ while $listnext(rowOrder, ptr, name) {
+ continue:$data(emitted(name))
+ set ready = 1
+ set depName = ""
+ for {
+ set depName = $order(dependsOn(name, depName))
+ quit:depName=""
+ if '$data(emitted(depName)) {
+ set ready = 0
+ quit
+ }
+ }
+ if ready {
+ set ordered = ordered _ $listbuild(name)
+ set emitted(name) = ""
+ set remaining = remaining - 1
+ set progress = 1
+ }
+ }
+ if 'progress {
+ // A dependency cycle within the dev-mode set would stall the loop. Install rejects
+ // real cycles, so this is a safety net: emit the rest in row order and stop.
+ set ptr = 0
+ while $listnext(rowOrder, ptr, name) {
+ continue:$data(emitted(name))
+ set ordered = ordered _ $listbuild(name)
+ set emitted(name) = ""
+ }
+ set remaining = 0
+ }
+ }
+ quit ordered
+}
+
ClassMethod CheckLicenseKey() As %Boolean
{
// Limit the number of attempts in case this somehow ends up running in a non-interactive process.
diff --git a/tests/integration_tests/Test/PM/Integration/Sync.cls b/tests/integration_tests/Test/PM/Integration/Sync.cls
new file mode 100644
index 00000000..8879dd2d
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/Sync.cls
@@ -0,0 +1,596 @@
+Class Test.PM.Integration.Sync Extends %UnitTest.TestCase
+{
+
+Property TempDir As %String;
+
+// Native-filesystem copy of the fixture, made once. _data/sync-test/ lives on the repo's
+// bind mount (slow for the many small-file copies OnAfterOneTest does between every test),
+// so each restore copies from here instead of re-reading the mount every time.
+Property PristineDir As %String;
+
+Method OnBeforeAllTests() As %Status
+{
+ set sourceDir = ..GetModuleDir("sync-test")
+
+ set ..PristineDir = ##class(%File).NormalizeDirectory(##class(%File).ManagerDirectory() _ "sync-test-pristine-" _ $job)
+ $$$ThrowOnError(##class(%IPM.Utils.File).CreateDirectoryChain(..PristineDir))
+ if '##class(%Library.File).CopyDir(sourceDir, ..PristineDir, 1) {
+ quit $$$ERROR($$$GeneralError, "Failed to copy sync-test fixture to pristine directory")
+ }
+
+ set ..TempDir = ##class(%File).NormalizeDirectory(##class(%File).ManagerDirectory() _ "sync-test-" _ $job)
+ quit $$$OK
+}
+
+// Load a clean copy of the fixture in dev mode before each test to stamp a baseline.
+// Uninstalling first handles version-change tests (e.g. TestModuleXmlChangedWarning bumps version).
+Method OnBeforeOneTest(testName As %String) As %Status
+{
+ $$$ThrowOnError(##class(%IPM.Utils.File).CreateDirectoryChain(..TempDir))
+ if '##class(%Library.File).CopyDir(..PristineDir, ..TempDir, 1) {
+ quit $$$ERROR($$$GeneralError, "Failed to restore sync-test fixture before test: " _ testName)
+ }
+ quit ##class(%IPM.Main).Shell("load " _ ..TempDir _ " -dev")
+}
+
+Method OnAfterOneTest(testName As %String) As %Status
+{
+ // Shell never throws; ignore the returned status since uninstall may fail if the test
+ // left the module in a broken state — that's expected.
+ do ##class(%IPM.Main).Shell("uninstall sync-test")
+ do ##class(%Library.File).RemoveDirectoryTree(..TempDir)
+ quit $$$OK
+}
+
+Method OnAfterAllTests() As %Status
+{
+ do ##class(%IPM.Main).Shell("uninstall sync-test")
+ if ..TempDir '= "" {
+ do ##class(%Library.File).RemoveDirectoryTree(..TempDir)
+ }
+ if ..PristineDir '= "" {
+ do ##class(%Library.File).RemoveDirectoryTree(..PristineDir)
+ }
+ quit $$$OK
+}
+
+/// GetStoredPaths returns paths that were stamped by StampModule.
+Method TestGetStoredPathsReturnsStampedPaths()
+{
+ do $$$AssertTrue(##class(%IPM.Storage.FileHash).HasBaseline("sync-test"), "Baseline exists after load")
+
+ kill paths
+ do ##class(%IPM.Storage.FileHash).GetStoredPaths("sync-test", .paths)
+
+ do $$$AssertTrue($data(paths("module.xml")), "module.xml in stored paths")
+ do $$$AssertTrue($data(paths("src/cls/SyncTest/SuperClass.cls")), "SuperClass.cls in stored paths")
+ do $$$AssertTrue($data(paths("src/inc/SyncTest.inc")), "SyncTest.inc in stored paths")
+}
+
+
+/// Sync with no files changed on disk since the last load/sync reports nothing to do.
+Method TestNoChangeIsNoOp()
+{
+ do $$$AssertTrue(##class(%IPM.Storage.FileHash).HasBaseline("sync-test"), "Baseline rows exist after load")
+
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+
+ do $$$AssertStatusOK(sc, "Sync with no changes succeeds")
+ do $$$AssertTrue(..FindInOutput(.output, "Nothing to sync"), "Reports nothing to sync")
+}
+
+/// Simulates a module installed before this feature existed (zero FileHash baseline rows).
+/// The first sync call must self-heal by stamping a baseline rather than failing or reporting
+/// every tracked file as changed; only a second sync call (after a real edit) detects changes.
+Method TestMigrationFromNoBaseline()
+{
+ set deleteRS = ##class(%SQL.Statement).%ExecDirect(, "DELETE FROM %IPM_Storage.FileHash WHERE ModuleName = ?", "sync-test")
+ do $$$AssertTrue(deleteRS.%SQLCODE >= 0, "Deleted existing baseline rows to simulate a pre-sync-feature module")
+ do $$$AssertNotTrue(##class(%IPM.Storage.FileHash).HasBaseline("sync-test"), "No baseline rows remain")
+
+ set filePath = ..TempDir _ "src/cls/SyncTest/SuperClass.cls"
+ do ..ReplaceInFile(filePath, """original""", """modified""")
+
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+
+ do $$$AssertStatusOK(sc, "First sync after baseline loss succeeds")
+ do $$$AssertTrue(..FindInOutput(.output, "Baseline established"), "First sync self-heals by establishing a baseline")
+ do $$$AssertTrue(##class(%IPM.Storage.FileHash).HasBaseline("sync-test"), "Baseline rows now exist")
+
+ // The edit made before the self-heal is captured in the new baseline (not retroactively
+ // detected), so a second sync with no further changes reports nothing to do.
+ kill output2
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie2)
+ set sc2 = ##class(%IPM.Main).Shell("sync sync-test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie2, .output2)
+ do $$$AssertStatusOK(sc2, "Second sync succeeds")
+ do $$$AssertTrue(..FindInOutput(.output2, "Nothing to sync"), "Second sync finds no changes (baseline already reflects the modified file)")
+
+ // A genuinely new edit after the baseline is established is detected normally.
+ do ..ReplaceInFile(filePath, """modified""", """modified again""")
+ kill output3
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie3)
+ set sc3 = ##class(%IPM.Main).Shell("sync sync-test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie3, .output3)
+ do $$$AssertStatusOK(sc3, "Third sync succeeds")
+ do $$$AssertTrue(..FindInOutput(.output3, "Sync complete"), "Third sync detects the post-baseline edit normally")
+}
+
+/// A modified class file is detected and recompiled by sync.
+Method TestModifiedClassRecompiles()
+{
+ set filePath = ..TempDir _ "src/cls/SyncTest/SuperClass.cls"
+ do ..ReplaceInFile(filePath, """original""", """modified""")
+
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+
+ do $$$AssertStatusOK(sc, "Sync after class modification succeeds")
+ do $$$AssertTrue(..FindInOutput(.output, "Sync complete"), "Reports sync complete")
+}
+
+/// A modified XML-format class file is detected and recompiled by sync.
+/// Verifies that Format="XML" resources participate in the sync pipeline identically
+/// to UDL resources, and that the reloaded class reflects the edited content.
+Method TestModifiedXmlClassRecompiles()
+{
+ set filePath = ..TempDir _ "src/cls/SyncTest/XmlClass.xml"
+ do ..ReplaceInFile(filePath, """xml-original""", """xml-modified""")
+
+ set sc = ##class(%IPM.Main).Shell("sync sync-test")
+ do $$$AssertStatusOK(sc, "Sync after XML class modification succeeds")
+ do $$$AssertEquals(##class(SyncTest.XmlClass).GetValue(), "xml-modified", "Class reflects updated value after XML sync")
+}
+
+/// Editing a superclass recompiles its subclasses via the u-flag, even though the
+/// subclass's own file on disk never changed.
+Method TestSuperclassEditRecompilesSubclass()
+{
+ // Add property to SuperClass — SubClass recompiles via u-flag
+ set filePath = ..TempDir _ "src/cls/SyncTest/SuperClass.cls"
+ do ..ReplaceInFile(filePath, "Property BaseValue", "Property NewProp As %String;" _ $char(10) _ $char(10) _ "Property BaseValue")
+
+ set sc = ##class(%IPM.Main).Shell("sync sync-test")
+ do $$$AssertStatusOK(sc, "Sync after superclass edit succeeds (subclass recompiles via u-flag)")
+}
+
+/// Editing an include file recompiles classes that include it, even though those class files
+/// on disk are unchanged. SyncTest.Consumer includes SyncTest.INC and calls $$$SyncTestValue.
+Method TestIncludeEditRecompilesConsumer()
+{
+ set incPath = ..TempDir _ "src/inc/SyncTest.inc"
+ do ..ReplaceInFile(incPath, """original-include""", """modified-include""")
+
+ set sc = ##class(%IPM.Main).Shell("sync sync-test")
+ do $$$AssertStatusOK(sc, "Sync after include edit succeeds")
+ do $$$AssertEquals(##class(SyncTest.Consumer).GetMacroValue(), "modified-include", "Consumer reflects updated macro value after include sync")
+}
+
+/// A file outside every tracked resource directory (e.g. under node_modules/) is ignored
+/// by sync and never given a baseline row — sync only walks declared resource directories,
+/// so files in undeclared locations are never seen.
+Method TestUntrackedFileIgnored()
+{
+ set junkDir = ..TempDir _ "node_modules/"
+ $$$ThrowOnError(##class(%IPM.Utils.File).CreateDirectoryChain(junkDir))
+ set stream = ##class(%Stream.FileCharacter).%New()
+ $$$ThrowOnError(stream.LinkToFile(junkDir _ "x.js"))
+ $$$ThrowOnError(stream.Write("junk"))
+ $$$ThrowOnError(stream.%Save())
+ set stream = ""
+
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+
+ do $$$AssertStatusOK(sc, "Sync ignores untracked files")
+ do $$$AssertNotTrue(..FindInOutput(.output, "x.js"), "Untracked file not mentioned in sync output")
+ do $$$AssertNotTrue(##class(%IPM.Storage.FileHash).ModulePathIndexExists("sync-test", "node_modules/x.js"), "No FileHash row for untracked file")
+}
+
+/// A deleted file is left alone unless -delete is passed. With -delete, the corresponding
+/// server-side class is removed.
+Method TestDeleteSkippedByDefault()
+{
+ set filePath = ..TempDir _ "src/cls/SyncTest/Deletable.cls"
+ do ##class(%Library.File).Delete(filePath)
+
+ // Without -delete: class still present
+ set sc = ##class(%IPM.Main).Shell("sync sync-test")
+ do $$$AssertStatusOK(sc, "Sync without -delete succeeds")
+ do $$$AssertTrue($$$comClassDefined("SyncTest.Deletable"), "Deletable class still exists without -delete flag")
+
+ // With -delete: class is removed from server
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -delete")
+ do $$$AssertStatusOK(sc, "Sync with -delete succeeds")
+ do $$$AssertNotTrue($$$comClassDefined("SyncTest.Deletable"), "Deletable class removed after -delete sync")
+}
+
+/// Deleting a test class file with -delete must remove the server-side class.
+/// Without this, the %UnitTest.TestCase subclass persists with no on-disk source and no
+/// hash record — permanently invisible to future syncs.
+Method TestDeleteTestClassRemovesFromServer()
+{
+ set filePath = ..TempDir _ "tests/unit/SyncTest/Tests/Trivial.cls"
+ do ##class(%Library.File).Delete(filePath)
+
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -delete")
+ do $$$AssertStatusOK(sc, "Sync with -delete on a deleted test class succeeds")
+ do $$$AssertNotTrue($$$comClassDefined("SyncTest.Tests.Trivial"), "Deleted test class removed from server after -delete sync")
+}
+
+/// Deleting a superclass (with -delete) leaves its subclass referencing a now-missing class.
+/// The post-delete recompile pass (SyncApplyDeletes + SyncCompile) must still run and surface
+/// this as a compile error, rather than silently leaving the subclass in a stale-but-compiled state.
+Method TestDeleteRecompilesDependents()
+{
+ set filePath = ..TempDir _ "src/cls/SyncTest/SuperClass.cls"
+ do ##class(%Library.File).Delete(filePath)
+
+ do $$$AssertTrue($$$comClassDefined("SyncTest.SubClass"), "SubClass still compiled before delete sync")
+
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -delete")
+
+ do $$$AssertStatusNotOK(sc, "Sync with -delete fails: SubClass now references a deleted superclass")
+ do $$$AssertNotTrue($$$comClassDefined("SyncTest.SuperClass"), "SuperClass was removed from the server")
+}
+
+/// A changed module.xml is detected and its manifest reloaded, with a warning that a full
+/// reload may still be needed to apply manifest-level changes (mappings, dependencies, etc.).
+Method TestModuleXmlChangedWarning()
+{
+ set filePath = ..TempDir _ "module.xml"
+ do ..ReplaceInFile(filePath, "1.0.0", "1.0.1")
+
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+
+ do $$$AssertStatusOK(sc, "Sync after module.xml change succeeds")
+ do $$$AssertTrue(..FindInOutput(.output, "module.xml changed"), "Warning about module.xml change is shown")
+}
+
+
+/// Without -test, a changed test-phase class is loaded but not executed. With -test, it is
+/// executed and its results are shown.
+Method TestSyncTestFlag()
+{
+ set filePath = ..TempDir _ "tests/unit/SyncTest/Tests/Trivial.cls"
+ do ..ReplaceInFile(filePath, "This test always passes.", "This test always passes (modified).")
+
+ // Without RunTests: loads the changed test but does not execute it
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+ do $$$AssertStatusOK(sc, "Sync without RunTests loads but does not run tests")
+ do $$$AssertNotTrue(..FindInOutput(.output, "Test Results"), "No test results without RunTests flag")
+
+ // Modify again so the file shows as changed on the next sync
+ do ..ReplaceInFile(filePath, "(modified).", "(modified again).")
+
+ // With RunTests: executes the changed test-phase test class
+ kill output
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+ do $$$AssertStatusOK(sc, "Sync with RunTests runs changed tests")
+ do $$$AssertTrue(..FindInOutput(.output, "Test Results"), "Test results shown with RunTests flag")
+ do $$$AssertTrue(..FindInOutput(.output, "SyncTest.Tests.Trivial begins"), "Changed test class actually ran, not just compiled")
+}
+
+/// Editing an existing test class and adding a brand-new test class in the same resource,
+/// in the same sync call, batches both changed classes into a single RunTest invocation
+/// (one "Test Results:" block) rather than one call per class — and both tests still run.
+Method TestSyncTestFlagBatchesMultipleChangedClasses()
+{
+ set existingFilePath = ..TempDir _ "tests/unit/SyncTest/Tests/Trivial.cls"
+ do ..ReplaceInFile(existingFilePath, "This test always passes.", "This test always passes (modified).")
+
+ set newFilePath = ..TempDir _ "tests/unit/SyncTest/Tests/BatchedNew.cls"
+ set newClassContent = "Class SyncTest.Tests.BatchedNew Extends %UnitTest.TestCase" _ $char(10) _ "{" _ $char(10)
+ set newClassContent = newClassContent _ "Method TestBatchedNewPasses()" _ $char(10) _ "{" _ $char(10)
+ set newClassContent = newClassContent _ " do $$$AssertTrue(1, ""BatchedNew always passes."")" _ $char(10) _ "}" _ $char(10) _ "}" _ $char(10)
+ set stream = ##class(%Stream.FileCharacter).%New()
+ $$$ThrowOnError(stream.LinkToFile(newFilePath))
+ $$$ThrowOnError(stream.Write(newClassContent))
+ $$$ThrowOnError(stream.%Save())
+ set stream = ""
+
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+
+ do $$$AssertStatusOK(sc, "Sync with two changed classes in one resource succeeds")
+ // "ClassName begins ..." / "ClassName passed" only appear when the test runner actually
+ // executes that class — unlike a bare class-name substring, which also matches the
+ // "Loading file .../Trivial.cls" and "Compiling class SyncTest.Tests.Trivial" lines
+ // that show up regardless of whether the test itself ran.
+ do $$$AssertTrue(..FindInOutput(.output, "SyncTest.Tests.Trivial begins"), "Modified existing class actually ran, not just compiled")
+ do $$$AssertTrue(..FindInOutput(.output, "SyncTest.Tests.BatchedNew begins"), "New class in the same resource actually ran, not just compiled")
+
+ set testResultsCount = 0
+ set lineKey = ""
+ for {
+ set lineKey = $order(output(lineKey), 1, line)
+ quit:lineKey=""
+ if line [ "Test Results:" {
+ set testResultsCount = testResultsCount + 1
+ }
+ }
+ do $$$AssertEquals(testResultsCount, 1, "Both changed classes ran in a single batched Test Results block, not one per class")
+}
+
+
+/// With two UnitTest resources present, a changed test class in one resource's directory
+/// must only be dispatched through that owning resource — not through every Test processor
+/// in the module (which would waste a reload+compile+RunTest cycle per extra resource).
+Method TestSyncTestFlagOnlyRunsOwningResource()
+{
+ set filePath = ..TempDir _ "tests/unit/SyncTest/Tests/Trivial.cls"
+ do ..ReplaceInFile(filePath, "This test always passes.", "This test always passes (modified).")
+
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+
+ do $$$AssertStatusOK(sc, "Sync with RunTests succeeds")
+ do $$$AssertTrue(..FindInOutput(.output, "SyncTest.Tests.Trivial begins"), "Changed test in tests/unit actually ran, not just compiled")
+ do $$$AssertNotTrue(..FindInOutput(.output, "SyncTest2.Tests.Trivial2"), "Unrelated test in tests/unit2 was not run")
+}
+
+/// Running sync with no module name syncs every module currently in development mode.
+Method TestSyncAllDevModeModules()
+{
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+
+ do $$$AssertStatusOK(sc, "Sync with no module name (sync-all) succeeds")
+ do $$$AssertTrue(..FindInOutput(.output, "[sync-test] Nothing to sync"), "sync-test specifically reports nothing to sync (no changes since baseline)")
+}
+
+/// Naked `sync` (no module name) must process modules most-dependent last: a dependency is
+/// synced before the module that depends on it. This matters because sync recompiles each
+/// module's changed files against the currently-installed version of its dependencies — if a
+/// dependent is synced before its dependency, it recompiles against stale code.
+///
+/// The bug this guards against: Main.Sync selects dev-mode modules with no ORDER BY, so it
+/// iterates in row-ID (first-install) order. A clean install is coincidentally in dependency
+/// order (the installer forces deps-first), so the divergence only appears once a dependency
+/// edge is added between two already-installed modules. This test reproduces that: install
+/// a, b, c standalone (row IDs a < b < c), then add edges a->b->c so dependency order (c, b, a)
+/// is the reverse of row order. Reloading to register the edges preserves row IDs.
+Method TestSyncAllProcessesDependenciesFirst()
+{
+ // Fixture source dirs (resolve before any work — GetModuleDir needs ^UnitTestRoot).
+ set srcA = ..GetModuleDir("sync-dep", "a")
+ set srcB = ..GetModuleDir("sync-dep", "b")
+ set srcC = ..GetModuleDir("sync-dep", "c")
+
+ set depRoot = ##class(%File).NormalizeDirectory(##class(%File).ManagerDirectory() _ "sync-dep-work-" _ $job)
+ set workA = ##class(%File).NormalizeDirectory(depRoot _ "a")
+ set workB = ##class(%File).NormalizeDirectory(depRoot _ "b")
+ set workC = ##class(%File).NormalizeDirectory(depRoot _ "c")
+
+ try {
+ $$$ThrowOnError(##class(%IPM.Utils.File).CreateDirectoryChain(depRoot))
+ do $$$AssertTrue(##class(%Library.File).CopyDir(srcA, workA, 1), "Copied sync-dep-a fixture")
+ do $$$AssertTrue(##class(%Library.File).CopyDir(srcB, workB, 1), "Copied sync-dep-b fixture")
+ do $$$AssertTrue(##class(%Library.File).CopyDir(srcC, workC, 1), "Copied sync-dep-c fixture")
+
+ // Install standalone (no deps yet) in a, b, c order so row IDs are a < b < c.
+ $$$ThrowOnError(##class(%IPM.Main).Shell("load " _ workA _ " -dev"))
+ $$$ThrowOnError(##class(%IPM.Main).Shell("load " _ workB _ " -dev"))
+ $$$ThrowOnError(##class(%IPM.Main).Shell("load " _ workC _ " -dev"))
+
+ // Add dependency edges: a depends on b, b depends on c. Dependency order is now c, b, a
+ // (the reverse of row order). Reloading registers the edges without changing row IDs.
+ do ..AddDependency(workA _ "module.xml", "SyncDepA.Top.CLS", "sync-dep-b")
+ do ..AddDependency(workB _ "module.xml", "SyncDepB.Middle.CLS", "sync-dep-c")
+ $$$ThrowOnError(##class(%IPM.Main).Shell("load " _ workA _ " -dev"))
+ $$$ThrowOnError(##class(%IPM.Main).Shell("load " _ workB _ " -dev"))
+
+ // Modify a file in every module so each one actually syncs (and prints its marker).
+ do ..ReplaceInFile(workA _ "src/cls/SyncDepA/Top.cls", """a-original""", """a-modified""")
+ do ..ReplaceInFile(workB _ "src/cls/SyncDepB/Middle.cls", """b-original""", """b-modified""")
+ do ..ReplaceInFile(workC _ "src/cls/SyncDepC/Base.cls", """c-original""", """c-modified""")
+
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+ do $$$AssertStatusOK(sc, "Naked sync succeeds")
+
+ set idxA = ..FirstLineContaining(.output, "sync-dep-a")
+ set idxB = ..FirstLineContaining(.output, "sync-dep-b")
+ set idxC = ..FirstLineContaining(.output, "sync-dep-c")
+ do $$$AssertTrue(idxA > 0, "sync-dep-a appears in output")
+ do $$$AssertTrue(idxB > 0, "sync-dep-b appears in output")
+ do $$$AssertTrue(idxC > 0, "sync-dep-c appears in output")
+
+ // c (least dependent) before b before a (most dependent).
+ do $$$AssertTrue(idxC < idxB, "Dependency sync-dep-c is processed before sync-dep-b")
+ do $$$AssertTrue(idxB < idxA, "Dependency sync-dep-b is processed before sync-dep-a")
+ } catch e {
+ do $$$AssertStatusOK(e.AsStatus(), "No unexpected exception during dependency-order sync")
+ }
+
+ // Cleanup: uninstall dependent-first (a needs b needs c) and remove the work tree.
+ do ##class(%IPM.Main).Shell("uninstall sync-dep-a")
+ do ##class(%IPM.Main).Shell("uninstall sync-dep-b")
+ do ##class(%IPM.Main).Shell("uninstall sync-dep-c")
+ do ##class(%Library.File).RemoveDirectoryTree(depRoot)
+}
+
+/// Sync fails with an error when given a module name that isn't installed.
+Method TestSyncModuleNotFound()
+{
+ set sc = ##class(%IPM.Main).Shell("sync this-module-does-not-exist")
+ do $$$AssertStatusNotOK(sc, "Sync fails for a module that doesn't exist")
+}
+
+/// Sync fails with an error when the target module is not in development mode.
+Method TestSyncNonDevModeModule()
+{
+ // Reload without -dev so the installed module is not in development mode.
+ // OnAfterOneTest uninstalls, and OnBeforeOneTest will reinstall -dev for the next test.
+ do ##class(%IPM.Main).Shell("uninstall sync-test")
+ $$$ThrowOnError(##class(%IPM.Main).Shell("load " _ ..TempDir))
+
+ set sc = ##class(%IPM.Main).Shell("sync sync-test")
+ do $$$AssertStatusNotOK(sc, "Sync fails for a module not in development mode")
+}
+
+/// module.xml adds a brand-new in the same edit that changes the version.
+/// After SyncCheckModuleXml reloads the manifest, the newly-declared resource's files
+/// must be tracked and picked up in that same sync call (not require a separate 'reload').
+Method TestModuleXmlAddsResourcePicksUpNewFile()
+{
+ set clsFilePath = ..TempDir _ "src/cls/SyncTest/AddedByManifest.cls"
+ set stream = ##class(%Stream.FileCharacter).%New()
+ $$$ThrowOnError(stream.LinkToFile(clsFilePath))
+ $$$ThrowOnError(stream.Write("Class SyncTest.AddedByManifest" _ $char(10) _ "{" _ $char(10) _ "}" _ $char(10)))
+ $$$ThrowOnError(stream.%Save())
+ set stream = ""
+
+ set moduleXmlPath = ..TempDir _ "module.xml"
+ do ..ReplaceInFile(moduleXmlPath, "1.0.0", "1.0.1")
+ do ..ReplaceInFile(moduleXmlPath, "", "" _ $char(10) _ " ")
+
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+
+ do $$$AssertStatusOK(sc, "Sync after module.xml adds a resource succeeds")
+ do $$$AssertTrue(..FindInOutput(.output, "module.xml changed"), "Warning about module.xml change is shown")
+ do $$$AssertTrue($$$comClassDefined("SyncTest.AddedByManifest"), "Newly-declared resource's class was loaded and compiled in the same sync call")
+}
+
+/// A sync that fails to compile leaves its baseline uncommitted, so the same file is
+/// re-detected as changed and retried on the next sync once the error is fixed.
+Method TestFailedCompileRetries()
+{
+ // Introduce a syntax error
+ set filePath = ..TempDir _ "src/cls/SyncTest/SuperClass.cls"
+ set stream = ##class(%Stream.FileCharacter).%New()
+ $$$ThrowOnError(stream.LinkToFile(filePath))
+ $$$ThrowOnError(stream.Write("Class SyncTest.SuperClass { INVALID SYNTAX HERE }"))
+ $$$ThrowOnError(stream.%Save())
+ set stream = ""
+
+ set sc = ##class(%IPM.Main).Shell("sync sync-test")
+ do $$$AssertStatusNotOK(sc, "Sync fails with syntax error")
+
+ // Fix and retry — CommitChanges was skipped on failure, so file is still detected as changed
+ set fixContent = "Class SyncTest.SuperClass" _ $char(10) _ "{" _ $char(10) _ $char(10)
+ _ "ClassMethod GetValue() As %String" _ $char(10) _ "{" _ $char(10)
+ _ " quit ""fixed""" _ $char(10) _ "}" _ $char(10) _ $char(10) _ "}" _ $char(10)
+ set stream = ##class(%Stream.FileCharacter).%New()
+ $$$ThrowOnError(stream.LinkToFile(filePath))
+ $$$ThrowOnError(stream.Write(fixContent))
+ $$$ThrowOnError(stream.%Save())
+ set stream = ""
+
+ set sc = ##class(%IPM.Main).Shell("sync sync-test")
+ do $$$AssertStatusOK(sc, "Sync succeeds after fixing syntax error (retry works)")
+}
+
+/// Editing module.xml and a source class in the same operation must sync BOTH changes.
+/// The StampModule call in the moduleXmlChanged branch must not clobber existing baseline
+/// rows for files edited at the same time — if it does, the class change is lost silently.
+Method TestModuleXmlAndClassEditedTogether()
+{
+ set moduleXmlPath = ..TempDir _ "module.xml"
+ set clsPath = ..TempDir _ "src/cls/SyncTest/SuperClass.cls"
+
+ // Edit both files before running sync
+ do ..ReplaceInFile(moduleXmlPath, "1.0.0", "1.0.1")
+ do ..ReplaceInFile(clsPath, """original""", """modified-with-xml""")
+
+ do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie)
+ set sc = ##class(%IPM.Main).Shell("sync sync-test -verbose")
+ do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output)
+
+ do $$$AssertStatusOK(sc, "Sync after co-editing module.xml and a class succeeds")
+ do $$$AssertTrue(..FindInOutput(.output, "Sync complete"), "Sync reports changes (not 'Nothing to sync')")
+ do $$$AssertTrue(..FindInOutput(.output, "SuperClass.cls"), "SuperClass.cls appears in sync output as updated")
+}
+
+/// Returns the path to a fixture directory under _data/.
+/// Matches the convention from Test.PM.Integration.Base.GetModuleDir.
+ClassMethod GetModuleDir(subfolders... As %String) As %String
+{
+ set testRoot = ##class(%File).NormalizeDirectory($get(^UnitTestRoot))
+ set testRoot = ##class(%File).GetDirectory(testRoot)
+ set moduleDir = ##class(%File).Construct(testRoot, "_data", subfolders...)
+ quit ##class(%File).NormalizeDirectory(moduleDir)
+}
+
+/// Read a file, replace oldStr with newStr, write back.
+ClassMethod ReplaceInFile(filePath As %String, oldStr As %String, newStr As %String)
+{
+ set stream = ##class(%Stream.FileCharacter).%New()
+ $$$ThrowOnError(stream.LinkToFile(filePath))
+ set content = ""
+ while 'stream.AtEnd {
+ set content = content _ stream.ReadLine() _ $char(10)
+ }
+ set stream = ""
+ set content = $replace(content, oldStr, newStr)
+ set writeStream = ##class(%Stream.FileCharacter).%New()
+ $$$ThrowOnError(writeStream.LinkToFile(filePath))
+ $$$ThrowOnError(writeStream.Write(content))
+ $$$ThrowOnError(writeStream.%Save())
+}
+
+ClassMethod FindInOutput(ByRef output, searchString As %String) As %Boolean
+{
+ set sub = ""
+ for {
+ set sub = $order(output(sub), 1, line)
+ quit:sub=""
+ if line [ searchString {
+ return 1
+ }
+ }
+ return 0
+}
+
+/// Returns the 1-based line index of the first captured output line containing searchString,
+/// or 0 if not found. Used to assert relative ordering of per-module output in sync-all.
+ClassMethod FirstLineContaining(ByRef output, searchString As %String) As %Integer
+{
+ set sub = ""
+ for {
+ set sub = $order(output(sub), 1, line)
+ quit:sub=""
+ if line [ searchString {
+ return sub
+ }
+ }
+ return 0
+}
+
+/// Rewrites a module.xml to insert a single-dependency block after the given
+/// line. Assumes the resource line is present and no block exists yet.
+ClassMethod AddDependency(
+ moduleXmlPath As %String,
+ afterResource As %String,
+ dependencyName As %String)
+{
+ set resourceLine = ""
+ set depBlock = resourceLine _ $char(10)
+ _ " " _ $char(10)
+ _ " " _ $char(10)
+ _ " " _ dependencyName _ "" _ $char(10)
+ _ " 1.0.0" _ $char(10)
+ _ " " _ $char(10)
+ _ " "
+ do ..ReplaceInFile(moduleXmlPath, resourceLine, depBlock)
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-dep/a/module.xml b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/a/module.xml
new file mode 100644
index 00000000..2601c8ef
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/a/module.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ sync-dep-a
+ 1.0.0
+ module
+ src
+
+
+
+
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-dep/a/src/cls/SyncDepA/Top.cls b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/a/src/cls/SyncDepA/Top.cls
new file mode 100644
index 00000000..954492e0
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/a/src/cls/SyncDepA/Top.cls
@@ -0,0 +1,9 @@
+Class SyncDepA.Top
+{
+
+ClassMethod Version() As %String
+{
+ quit "a-original"
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-dep/b/module.xml b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/b/module.xml
new file mode 100644
index 00000000..f65fbf0f
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/b/module.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ sync-dep-b
+ 1.0.0
+ module
+ src
+
+
+
+
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-dep/b/src/cls/SyncDepB/Middle.cls b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/b/src/cls/SyncDepB/Middle.cls
new file mode 100644
index 00000000..6cbb6556
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/b/src/cls/SyncDepB/Middle.cls
@@ -0,0 +1,9 @@
+Class SyncDepB.Middle
+{
+
+ClassMethod Version() As %String
+{
+ quit "b-original"
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-dep/c/module.xml b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/c/module.xml
new file mode 100644
index 00000000..36305d64
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/c/module.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ sync-dep-c
+ 1.0.0
+ module
+ src
+
+
+
+
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-dep/c/src/cls/SyncDepC/Base.cls b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/c/src/cls/SyncDepC/Base.cls
new file mode 100644
index 00000000..759e6ed5
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-dep/c/src/cls/SyncDepC/Base.cls
@@ -0,0 +1,9 @@
+Class SyncDepC.Base
+{
+
+ClassMethod Version() As %String
+{
+ quit "c-original"
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-test/module.xml b/tests/integration_tests/Test/PM/Integration/_data/sync-test/module.xml
new file mode 100644
index 00000000..f43503d7
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-test/module.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+ sync-test
+ 1.0.0
+ module
+ src
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/Consumer.cls b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/Consumer.cls
new file mode 100644
index 00000000..f4b32480
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/Consumer.cls
@@ -0,0 +1,11 @@
+Include SyncTest
+
+Class SyncTest.Consumer
+{
+
+ClassMethod GetMacroValue() As %String
+{
+ quit $$$SyncTestValue
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/Deletable.cls b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/Deletable.cls
new file mode 100644
index 00000000..f715e57a
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/Deletable.cls
@@ -0,0 +1,9 @@
+Class SyncTest.Deletable
+{
+
+ClassMethod Hello() As %String
+{
+ quit "I exist"
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/SubClass.cls b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/SubClass.cls
new file mode 100644
index 00000000..d0837619
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/SubClass.cls
@@ -0,0 +1,9 @@
+Class SyncTest.SubClass Extends SyncTest.SuperClass
+{
+
+ClassMethod GetInherited() As %String
+{
+ quit ..GetValue()
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/SuperClass.cls b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/SuperClass.cls
new file mode 100644
index 00000000..dbbd7553
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/SuperClass.cls
@@ -0,0 +1,11 @@
+Class SyncTest.SuperClass
+{
+
+Property BaseValue As %String [ InitialExpression = "original" ];
+
+ClassMethod GetValue() As %String
+{
+ quit "original"
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/XmlClass.xml b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/XmlClass.xml
new file mode 100644
index 00000000..bc4098ff
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/cls/SyncTest/XmlClass.xml
@@ -0,0 +1,14 @@
+
+
+
+
+XML-format fixture class for sync integration tests.
+
+
+1
+%String
+
+
+
+
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/inc/SyncTest.inc b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/inc/SyncTest.inc
new file mode 100644
index 00000000..9b4a4b69
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-test/src/inc/SyncTest.inc
@@ -0,0 +1,3 @@
+ROUTINE SyncTest [Type=INC]
+
+#define SyncTestValue "original-include"
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-test/static/config.txt b/tests/integration_tests/Test/PM/Integration/_data/sync-test/static/config.txt
new file mode 100644
index 00000000..89abc6b8
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-test/static/config.txt
@@ -0,0 +1 @@
+static file content
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-test/tests/unit/SyncTest/Tests/Trivial.cls b/tests/integration_tests/Test/PM/Integration/_data/sync-test/tests/unit/SyncTest/Tests/Trivial.cls
new file mode 100644
index 00000000..81dde040
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-test/tests/unit/SyncTest/Tests/Trivial.cls
@@ -0,0 +1,9 @@
+Class SyncTest.Tests.Trivial Extends %UnitTest.TestCase
+{
+
+Method TestAlwaysPasses()
+{
+ do $$$AssertTrue(1, "This test always passes.")
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/sync-test/tests/unit2/SyncTest2/Tests/Trivial2.cls b/tests/integration_tests/Test/PM/Integration/_data/sync-test/tests/unit2/SyncTest2/Tests/Trivial2.cls
new file mode 100644
index 00000000..c9df607d
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/sync-test/tests/unit2/SyncTest2/Tests/Trivial2.cls
@@ -0,0 +1,9 @@
+Class SyncTest2.Tests.Trivial2 Extends %UnitTest.TestCase
+{
+
+Method TestAlwaysPasses()
+{
+ do $$$AssertTrue(1, "This test always passes too.")
+}
+
+}
diff --git a/tests/unit_tests/Test/PM/Unit/FileHash.cls b/tests/unit_tests/Test/PM/Unit/FileHash.cls
new file mode 100644
index 00000000..1a2d31d8
--- /dev/null
+++ b/tests/unit_tests/Test/PM/Unit/FileHash.cls
@@ -0,0 +1,35 @@
+Class Test.PM.Unit.FileHash Extends %UnitTest.TestCase
+{
+
+Method TestNormalizePath()
+{
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).NormalizePath("src\cls\Foo\Bar.cls"), "src/cls/Foo/Bar.cls", "Backslashes -> forward slashes")
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).NormalizePath("src//cls///Foo.cls"), "src/cls/Foo.cls", "Consecutive slashes collapsed")
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).NormalizePath("/src/cls/Foo.cls"), "src/cls/Foo.cls", "Leading slash stripped")
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).NormalizePath("//src/Foo.cls"), "src/Foo.cls", "Multiple leading slashes stripped")
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).NormalizePath("src/cls/Foo.cls"), "src/cls/Foo.cls", "Already-normalized path unchanged")
+}
+
+Method TestRelPathToDocName()
+{
+ // Standard layouts
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).RelPathToDocName("src/cls/Foo/Bar.cls"), "Foo.Bar.CLS", "Standard src/cls/ layout")
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).RelPathToDocName("src/inc/SyncTest.inc"), "SyncTest.INC", "Include file")
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).RelPathToDocName("src/mac/MyRoutine.mac"), "MyRoutine.MAC", "MAC routine")
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).RelPathToDocName("src/int/MyRoutine.int"), "MyRoutine.INT", "INT routine")
+
+ // Non-compilable files return "" so they are never routed as server-side documents
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).RelPathToDocName("static/config.txt"), "", "Non-compilable extension")
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).RelPathToDocName("module.xml"), "", "module.xml")
+
+ // Flat layout: 'SyncFlat' is not a prefix dir, becomes the package component
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).RelPathToDocName("src/SyncFlat/Flat.cls"), "SyncFlat.Flat.CLS", "Flat layout (no cls/ subdir)")
+
+ // Edge cases
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).RelPathToDocName("Foo.cls"), "Foo.CLS", "Single-segment path")
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).RelPathToDocName("src/cls/A/B/C/Deep.cls"), "A.B.C.Deep.CLS", "Deep package hierarchy")
+ // IPM own classes: callers handle %-prefix namespace fallback; this method doesn't add %
+ do $$$AssertEquals(##class(%IPM.Storage.FileHash).RelPathToDocName("src/cls/IPM/Main.cls"), "IPM.Main.CLS", "IPM class — no % prefix added")
+}
+
+}