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") +} + +}