From a8f48b00df939f0588d9e818264565624651a711 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 30 Jul 2026 20:04:20 -0500 Subject: [PATCH 1/6] fix: stabilize v4 component contracts --- +labkit/+app/+internal/+launcher/dispatch.m | 2 +- .../MatlabPlatformAdapter.m | 37 +++- +labkit/+app/+internal/NativeAdapterValues.m | 96 ++++++++++- +labkit/+app/+internal/ProjectDocumentStore.m | 70 ++++++++ +labkit/+app/+internal/RuntimeKernel.m | 6 +- +labkit/+app/+internal/RuntimePresentation.m | 8 +- +labkit/+app/+project/Schema.m | 41 ++++- +labkit/+app/version.m | 2 +- +labkit/+biosignal/buildTemplate.m | 3 + +labkit/+biosignal/detectEcgPeaks.m | 11 +- +labkit/+biosignal/filterSignal.m | 4 + +labkit/+biosignal/measureSegments.m | 3 + .../+biosignal/private/validateOptionStruct.m | 12 ++ +labkit/+biosignal/readRecording.m | 5 + +labkit/+biosignal/version.m | 2 +- +labkit/+dta/detectType.m | 14 +- +labkit/+dta/loadFile.m | 28 ++- +labkit/+dta/loadFiles.m | 7 +- +labkit/+dta/version.m | 2 +- +labkit/+image/private/validateOptionStruct.m | 12 ++ +labkit/+image/readFiles.m | 8 +- +labkit/+image/version.m | 2 +- +labkit/+rhs/private/validateOptionStruct.m | 12 ++ +labkit/+rhs/readWindow.m | 6 +- +labkit/+rhs/version.m | 2 +- +labkit/+thermal/inspectFile.m | 5 +- .../+thermal/private/validateOptionStruct.m | 12 ++ +labkit/+thermal/rawToTemperatureC.m | 48 ++++-- +labkit/+thermal/readFile.m | 7 +- +labkit/+thermal/readFiles.m | 8 +- +labkit/+thermal/renderImage.m | 3 +- +labkit/+thermal/version.m | 2 +- +labkit/AGENTS.md | 7 + .agents/dos-and-donts.md | 156 +---------------- .agents/skills/README.md | 12 ++ .agents/skills/labkit-app-builder/SKILL.md | 2 +- .../skills/labkit-app-builder/manifest.yaml | 1 + .agents/skills/labkit-boundary-guard/SKILL.md | 2 +- .../labkit-boundary-guard/manifest.yaml | 1 + .../labkit-documentation-maintainer/SKILL.md | 2 +- .../manifest.yaml | 1 + .../labkit-performance-profiler/SKILL.md | 2 +- .../labkit-performance-profiler/manifest.yaml | 1 + .../labkit-scientific-change-guard/SKILL.md | 41 +++++ .../labkit-scientific-change-guard/evals.json | 25 +++ .../manifest.yaml | 1 + .agents/skills/labkit-test-planner/SKILL.md | 2 +- .../skills/labkit-test-planner/manifest.yaml | 1 + .github/scripts/classify_ci_scope.py | 4 +- .github/scripts/test_classify_ci_scope.py | 8 + .github/scripts/test_validate_agent_skills.py | 87 ++++++++++ .github/scripts/validate_agent_skills.py | 162 ++++++++++++++++++ .github/workflows/ci.yml | 1 + AGENTS.md | 34 ++-- apps/AGENTS.md | 3 + .../+dic_postprocess/definition.m | 4 +- .../+dic_postprocess/projectSpec.m | 3 +- .../+dic_preprocess/definition.m | 4 +- .../+dic_preprocess/projectSpec.m | 3 +- .../+chrono_overlay/definition.m | 4 +- .../+chrono_overlay/projectSpec.m | 2 +- apps/electrochem/cic/+cic/definition.m | 2 +- apps/electrochem/cic/+cic/projectSpec.m | 3 +- apps/electrochem/csc/+csc/definition.m | 2 +- apps/electrochem/csc/+csc/projectSpec.m | 3 +- apps/electrochem/eis/+eis/definition.m | 2 +- apps/electrochem/eis/+eis/projectSpec.m | 2 +- .../vt_resistance/+vt_resistance/definition.m | 2 +- .../+vt_resistance/projectSpec.m | 3 +- .../gait_analysis/+gait_analysis/definition.m | 2 +- .../+gait_analysis/projectSpec.m | 50 ++++-- .../batch_crop/+batch_crop/definition.m | 2 +- .../batch_crop/+batch_crop/projectSpec.m | 2 +- .../curvature/+curvature/definition.m | 2 +- .../curvature/+curvature/projectSpec.m | 3 +- .../flir_thermal/+flir_thermal/definition.m | 2 +- .../flir_thermal/+flir_thermal/projectSpec.m | 3 +- .../focus_stack/+focus_stack/definition.m | 2 +- .../focus_stack/+focus_stack/projectSpec.m | 42 ++++- .../image_enhance/+image_enhance/definition.m | 2 +- .../+image_enhance/projectSpec.m | 3 +- .../image_match/+image_match/definition.m | 2 +- .../image_match/+image_match/projectSpec.m | 3 +- .../video_marker/+video_marker/definition.m | 4 +- .../video_marker/+video_marker/projectSpec.m | 2 +- .../figure_studio/+figure_studio/definition.m | 2 +- .../+figure_studio/projectSpec.m | 2 +- .../+nerve_response_analysis/definition.m | 2 +- .../+nerve_response_analysis/projectSpec.m | 3 +- .../+response_review_stats/definition.m | 4 +- .../+response_review_stats/projectSpec.m | 3 +- .../rhs_preview/+rhs_preview/definition.m | 2 +- .../rhs_preview/+rhs_preview/projectSpec.m | 4 +- .../ttest_wizard/+ttest_wizard/definition.m | 4 +- .../ttest_wizard/+ttest_wizard/projectSpec.m | 2 +- .../+ecg_print/+analysisRun/peakMethodValue.m | 5 +- .../ecg_print/+ecg_print/definition.m | 2 +- .../ecg_print/+ecg_print/projectSpec.m | 6 +- docs/apps/gait/gait-analysis/README.md | 3 + .../image-measurement/focus-stack/README.md | 4 + docs/apps/wearable/ecg-print/README.md | 4 + docs/framework/guides/runtime.md | 18 +- ...-20260730-v4-stability-contract-closure.md | 111 ++++++++++++ docs/libraries/biosignal/README.md | 2 + docs/libraries/dta/README.md | 4 + docs/libraries/image/README.md | 2 + docs/libraries/rhs/README.md | 3 + docs/libraries/thermal/README.md | 8 + labkit_launcher.m | 11 +- tests/+labkittest/classifyPath.m | 5 + tests/AGENTS.md | 7 + .../gait_analysis/project/GaitProjectSpec.m | 23 +++ .../project/FocusStackProjectSpec.m | 21 +++ .../analysisRun/EcgPrintScientificSpec.m | 7 +- .../ecg_print/project/EcgPrintProjectSpec.m | 10 ++ tests/specs/framework/app/AppSdkSpec.m | 105 ++++++++++++ .../framework/app/SemanticDiagnosticsSpec.m | 3 +- .../framework/biosignal/BiosignalFacadeSpec.m | 9 + tests/specs/framework/dta/DtaFacadeSpec.m | 14 ++ tests/specs/framework/image/ImageFacadeSpec.m | 6 + tests/specs/framework/rhs/RhsFacadeSpec.m | 6 + .../framework/thermal/ThermalFacadeSpec.m | 43 +++++ .../system/repository/TestArchitectureSpec.m | 2 + 123 files changed, 1357 insertions(+), 316 deletions(-) create mode 100644 +labkit/+biosignal/private/validateOptionStruct.m create mode 100644 +labkit/+image/private/validateOptionStruct.m create mode 100644 +labkit/+rhs/private/validateOptionStruct.m create mode 100644 +labkit/+thermal/private/validateOptionStruct.m create mode 100644 .agents/skills/README.md create mode 100644 .agents/skills/labkit-app-builder/manifest.yaml create mode 100644 .agents/skills/labkit-boundary-guard/manifest.yaml create mode 100644 .agents/skills/labkit-documentation-maintainer/manifest.yaml create mode 100644 .agents/skills/labkit-performance-profiler/manifest.yaml create mode 100644 .agents/skills/labkit-scientific-change-guard/SKILL.md create mode 100644 .agents/skills/labkit-scientific-change-guard/evals.json create mode 100644 .agents/skills/labkit-scientific-change-guard/manifest.yaml create mode 100644 .agents/skills/labkit-test-planner/manifest.yaml create mode 100644 .github/scripts/test_validate_agent_skills.py create mode 100644 .github/scripts/validate_agent_skills.py create mode 100644 docs/history/records/2026/07/LK-20260730-v4-stability-contract-closure.md diff --git a/+labkit/+app/+internal/+launcher/dispatch.m b/+labkit/+app/+internal/+launcher/dispatch.m index 7a474944c..89d22dbe4 100644 --- a/+labkit/+app/+internal/+launcher/dispatch.m +++ b/+labkit/+app/+internal/+launcher/dispatch.m @@ -516,7 +516,7 @@ function updateInfo() info = struct( ... "name", "labkit_launcher", ... "displayName", "LabKit App Launcher", ... - "version", "1.8.1", ... + "version", "1.8.2", ... "updated", "2026-07-30"); end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m index aad51e6d6..3a392d181 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m @@ -263,24 +263,46 @@ function alert(obj, message, title) function result = chooseInputFile(~, filters, startPath) filters = labkit.app.internal.NativeAdapterValues.dialogFilters(filters); [name, folder] = uigetfile(filters, "Choose input file", ... - labkit.app.internal.NativeAdapterValues.safeStartPath(startPath)); + labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + "input", startPath)); + if ~isequal(name, 0) + labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + "input", folder); + end result = labkit.app.internal.NativeAdapterValues.dialogPath(name, folder); end function result = chooseInputFolder(~, startPath) - folder = uigetdir(labkit.app.internal.NativeAdapterValues.safeStartPath(startPath), "Choose input folder"); + folder = uigetdir( ... + labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + "input", startPath), "Choose input folder"); + if ~isequal(folder, 0) + labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + "input", folder); + end result = labkit.app.internal.NativeAdapterValues.folderDialogPath(folder); end function result = chooseOutputFile(~, filters, startPath) filters = labkit.app.internal.NativeAdapterValues.dialogFilters(filters); [name, folder] = uiputfile(filters, "Choose output file", ... - labkit.app.internal.NativeAdapterValues.safeStartPath(startPath)); + labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + "output", startPath)); + if ~isequal(name, 0) + labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + "output", folder); + end result = labkit.app.internal.NativeAdapterValues.dialogPath(name, folder); end function result = chooseOutputFolder(~, startPath) - folder = uigetdir(labkit.app.internal.NativeAdapterValues.safeStartPath(startPath), "Choose output folder"); + folder = uigetdir( ... + labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + "output", startPath), "Choose output folder"); + if ~isequal(folder, 0) + labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + "output", folder); + end result = labkit.app.internal.NativeAdapterValues.folderDialogPath(folder); end end @@ -634,6 +656,8 @@ function chooseFiles(obj, target) return; end obj.DialogFolders(char(target)) = char(folder); + labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + "input", folder); paths = string(folder) + filesep + string(names); if config.SelectionMode == "single" paths = paths(1); @@ -653,6 +677,8 @@ function chooseFolderFiles(obj, target, recursive) return end obj.DialogFolders(char(target)) = char(folder); + labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + "input", folder); paths = labkit.app.internal.NativeAdapterValues.filesInFolder(folder, config.Filters, recursive); if recursive && ... numel(paths) > config.FolderWarningThreshold @@ -691,7 +717,8 @@ function removeSelectedFiles(obj, target, list) end folder = char(string(configured)); if isempty(folder) || ~isfolder(folder) - folder = labkit.app.internal.NativeAdapterValues.userDialogFolder(); + folder = labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + "input", ""); end end diff --git a/+labkit/+app/+internal/NativeAdapterValues.m b/+labkit/+app/+internal/NativeAdapterValues.m index 8c783707a..db77e1fac 100644 --- a/+labkit/+app/+internal/NativeAdapterValues.m +++ b/+labkit/+app/+internal/NativeAdapterValues.m @@ -434,9 +434,68 @@ function restoreViewport(axes, viewport) end function path = safeStartPath(value) - path = char(string(value)); - if isempty(path) || ~isfolder(path) - path = pwd; + path = labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + "input", value); + end + + function path = dialogStartFolder(kind, proposed) + % Private adapter policy for persistent native-dialog locations. + kind = lower(strip(string(kind))); + if ~isscalar(kind) || ~any(kind == ["input", "output"]) + error("labkit:app:contract:InvalidValue", ... + "Dialog kind must be input or output."); + end + path = existingSafeDialogFolder(proposed); + if strlength(path) == 0 + if kind == "input" + preference = "LastInputFolder"; + else + preference = "LastOutputFolder"; + end + if ispref("LabKit", char(preference)) + path = existingSafeDialogFolder( ... + getpref("LabKit", char(preference))); + end + end + if strlength(path) == 0 + path = existingSafeDialogFolder(getenv("USERPROFILE")); + end + if strlength(path) == 0 + path = existingSafeDialogFolder(getenv("HOME")); + end + if strlength(path) == 0 + candidates = split(string(userpath), pathsep); + candidates = candidates(strlength(candidates) > 0); + for candidate = reshape(candidates, 1, []) + path = existingSafeDialogFolder(candidate); + if strlength(path) > 0 + break + end + end + end + if strlength(path) == 0 + path = string(tempdir); + end + path = char(path); + end + + function rememberDialogFolder(kind, folder) + % Cancellation and invalid paths intentionally preserve prior memory. + kind = lower(strip(string(kind))); + folder = existingSafeDialogFolder(folder); + if ~isscalar(kind) || ~any(kind == ["input", "output"]) || ... + strlength(folder) == 0 + return + end + if kind == "input" + preference = "LastInputFolder"; + else + preference = "LastOutputFolder"; + end + try + setpref("LabKit", char(preference), char(folder)); + catch + % A read-only preference store must not break file selection. end end @@ -552,6 +611,37 @@ function restoreViewport(axes, viewport) end end +function folder = existingSafeDialogFolder(value) +folder = ""; +if isempty(value) + return +end +value = strip(string(value)); +if ~isscalar(value) || strlength(value) == 0 || ~isfolder(value) + return +end +try + value = string(char(java.io.File(char(value)).getCanonicalPath())); +catch +end +root = string(fileparts(fileparts(fileparts(fileparts(mfilename("fullpath")))))); +try + root = string(char(java.io.File(char(root)).getCanonicalPath())); +catch +end +comparison = value; +rootComparison = root; +if ispc + comparison = lower(comparison); + rootComparison = lower(rootComparison); +end +if comparison == rootComparison || ... + startsWith(comparison, rootComparison + filesep) + return +end +folder = value; +end + function values = textColumn(values) if isempty(values) values = strings(0, 1); diff --git a/+labkit/+app/+internal/ProjectDocumentStore.m b/+labkit/+app/+internal/ProjectDocumentStore.m index b9f73fb1c..0a9f52d92 100644 --- a/+labkit/+app/+internal/ProjectDocumentStore.m +++ b/+labkit/+app/+internal/ProjectDocumentStore.m @@ -9,6 +9,10 @@ Contract Context Sources + AcceptedPath (1, 1) string = "" + AcceptedFingerprint (1, 1) string = "" + PendingPath (1, 1) string = "" + PendingFingerprint (1, 1) string = "" end methods (Access = ?labkit.app.internal.RuntimeKernel) @@ -36,10 +40,12 @@ function result = save(obj, state, filepath) filepath = projectPath(filepath); + obj.assertNoExternalOverwrite(filepath); candidate = obj.nextSavedMetadata(filepath); envelope = obj.envelope(state, candidate); writeProjectFile(filepath, envelope); obj.Metadata = candidate; + obj.acceptFile(filepath); result = labkit.app.dialog.Choice(filepath, Cancelled=false); end @@ -74,6 +80,13 @@ function saveRecovery(obj, state, filepath) candidate.dirty = true; end metadata = candidate; + if asRecovery + obj.PendingPath = ""; + obj.PendingFingerprint = ""; + else + obj.PendingPath = filepath; + obj.PendingFingerprint = fileFingerprint(filepath); + end end function [state, metadata] = createNew(obj) @@ -86,6 +99,10 @@ function saveRecovery(obj, state, filepath) function acceptRestore(obj, metadata) obj.Metadata = metadata; + obj.AcceptedPath = obj.PendingPath; + obj.AcceptedFingerprint = obj.PendingFingerprint; + obj.PendingPath = ""; + obj.PendingFingerprint = ""; end function markDirty(obj) @@ -240,6 +257,11 @@ function markDirty(obj) end function bindings = projectSourceBindings(obj) + schema = obj.Application.ProjectSchema; + if ~schema.UsesInferredSourceBindings + bindings = schema.SourceBindings; + return; + end plan = obj.Contract.PlatformPlan; bindings = strings(1, 0); for k = 1:numel(plan.Nodes) @@ -310,6 +332,24 @@ function validateState(obj, state) "modifiedAtUtc", nowUtc, "revision", uint64(0), ... "path", "", "dirty", true); end + + function assertNoExternalOverwrite(obj, filepath) + if strlength(obj.AcceptedPath) == 0 || ... + ~samePath(filepath, obj.AcceptedPath) + return; + end + if ~isfile(filepath) || ... + fileFingerprint(filepath) ~= obj.AcceptedFingerprint + error("labkit:app:runtime:ProjectWriteConflict", ... + "Project file changed after it was opened or saved: %s.", ... + filepath); + end + end + + function acceptFile(obj, filepath) + obj.AcceptedPath = filepath; + obj.AcceptedFingerprint = fileFingerprint(filepath); + end end end @@ -443,6 +483,36 @@ function deleteIfPresent(filepath) end end +function value = fileFingerprint(filepath) +stream = fopen(char(filepath), "r"); +if stream < 0 + error("labkit:app:runtime:ProjectReadFailed", ... + "Could not read project file for conflict detection: %s.", filepath); +end +cleanup = onCleanup(@() fclose(stream)); +digest = java.security.MessageDigest.getInstance("SHA-256"); +while true + bytes = fread(stream, 1024 * 1024, "*uint8"); + if isempty(bytes) + break; + end + digest.update(typecast(bytes, "int8")); +end +value = lower(string(reshape(dec2hex(typecast(digest.digest(), "uint8"), 2).', 1, []))); +clear cleanup +end + +function tf = samePath(left, right) +left = java.nio.file.Paths.get(char(left), javaArray("java.lang.String", 0)); +right = java.nio.file.Paths.get(char(right), javaArray("java.lang.String", 0)); +tf = string(left.toAbsolutePath().normalize().toString()) == ... + string(right.toAbsolutePath().normalize().toString()); +if ispc + tf = lower(string(left.toAbsolutePath().normalize().toString())) == ... + lower(string(right.toAbsolutePath().normalize().toString())); +end +end + function invalidProject(message, varargin) error("labkit:app:runtime:InvalidProject", message, varargin{:}); end diff --git a/+labkit/+app/+internal/RuntimeKernel.m b/+labkit/+app/+internal/RuntimeKernel.m index 0d1bf155e..ecf4cc2d0 100644 --- a/+labkit/+app/+internal/RuntimeKernel.m +++ b/+labkit/+app/+internal/RuntimeKernel.m @@ -3,7 +3,7 @@ properties (SetAccess = private) State (1, 1) struct Presentation - StatusLog (1, :) string = "Ready." + CurrentStatus (1, 1) string = "Ready." Closed (1, 1) logical = false StartupFailed (1, 1) logical = false end @@ -888,7 +888,7 @@ function execute(obj, binding, payload) obj.Contract.PlatformPlan, state, ... @(records, role) ... obj.presentationSourcePaths(records, role), ... - obj.StatusLog); + obj.CurrentStatus); if isempty(obj.Application.PresentWorkbench) custom = labkit.app.view.Snapshot(); else @@ -916,7 +916,7 @@ function log(obj, severity, eventName, message, category, audience, attributes, Exception=exception); if audience == "user" && ... any(severity == ["info", "warning", "error", "critical"]) - obj.StatusLog(end + 1) = message; + obj.CurrentStatus = message; end end diff --git a/+labkit/+app/+internal/RuntimePresentation.m b/+labkit/+app/+internal/RuntimePresentation.m index 4e8ad2295..575474e88 100644 --- a/+labkit/+app/+internal/RuntimePresentation.m +++ b/+labkit/+app/+internal/RuntimePresentation.m @@ -1,12 +1,12 @@ % Derive the complete default Snapshot from compiled layout and App state. % Expected caller: RuntimeKernel before overlaying the App-owned Snapshot. % Inputs are a compiled platform plan, scalar application state, portable -% source store, and status log. The method returns a new immutable Snapshot +% source store, and current status. The method returns a new immutable Snapshot % and does not create or mutate native MATLAB graphics. classdef (Sealed, Hidden) RuntimePresentation methods (Static) function view = fromState( ... - plan, state, sourcePathsForRole, statusLog) + plan, state, sourcePathsForRole, currentStatus) view = labkit.app.view.Snapshot(); for k = 1:numel(plan.Nodes) node = plan.Nodes(k); @@ -85,8 +85,8 @@ ColumnEditable=config.ColumnEditable); case "statusPanel" status = config.Text; - if isempty(status) && ~isempty(statusLog) - status = statusLog(end); + if isempty(status) && strlength(currentStatus) > 0 + status = currentStatus; end view = view.text(node.Id, join(status, newline)); case "workspacePage" diff --git a/+labkit/+app/+project/Schema.m b/+labkit/+app/+project/Schema.m index 350b87763..b23d630e5 100644 --- a/+labkit/+app/+project/Schema.m +++ b/+labkit/+app/+project/Schema.m @@ -34,6 +34,12 @@ % RelinkSources - Fixed callback % project = relink(project,unresolved,projectFile). Returning empty % cancels the load. Default: empty. + % SourceBindings - String or cell array of project-relative field paths + % containing portable source records, for example + % "inputs.sources". When omitted, Runtime infers bindings from + % project-bound file-list nodes for compatibility with existing App + % definitions. An explicitly empty value declares no source + % bindings. Default: omitted compatibility inference. % % Outputs: % contract - Immutable labkit.app.project.Schema value. @@ -60,13 +66,15 @@ CreateResume ApplyResume RelinkSources + SourceBindings (1, :) string + UsesInferredSourceBindings (1, 1) logical end methods function obj = Schema(varargin) names = ["Version", "Create", "Validate", "Migrate", ... "LegacyImports", "CreateResume", "ApplyResume", ... - "RelinkSources"]; + "RelinkSources", "SourceBindings"]; if isempty(varargin) varargin = {"Version", 1, "Create", @createProject, ... "Validate", @validateProject}; @@ -104,10 +112,41 @@ options, "ApplyResume", 3, 1); obj.RelinkSources = optionalCallback( ... options, "RelinkSources", 3, 1); + obj.UsesInferredSourceBindings = ~isfield(options, "SourceBindings"); + obj.SourceBindings = sourceBindings( ... + optionValue(options, "SourceBindings", strings(1, 0))); end end end +function bindings = sourceBindings(value) + if ischar(value) + value = string(value); + elseif iscell(value) + if ~all(cellfun(@(item) ischar(item) || ... + (isstring(item) && isscalar(item)), value), "all") + error("labkit:app:contract:InvalidValue", ... + "Project Schema SourceBindings must contain scalar text."); + end + value = string(value); + end + if ~isstring(value) + error("labkit:app:contract:InvalidValue", ... + "Project Schema SourceBindings must be text."); + end + bindings = reshape(value, 1, []); + for binding = bindings + parts = split(binding, "."); + if strlength(binding) == 0 || any(strlength(parts) == 0) || ... + startsWith(binding, "project.") || ... + ~all(cellfun(@isvarname, cellstr(parts))) + error("labkit:app:contract:InvalidValue", ... + "Project Schema SourceBindings must be project-relative field paths."); + end + end + bindings = unique(bindings, "stable"); +end + function callback = optionalCallback(options, name, inputs, outputs) callback = []; if isfield(options, name) && ~isempty(options.(name)) diff --git a/+labkit/+app/version.m b/+labkit/+app/version.m index 9ee97c75c..f79f41ac3 100644 --- a/+labkit/+app/version.m +++ b/+labkit/+app/version.m @@ -30,6 +30,6 @@ % labkit.app.Definition info = labkit.contract.versionInfo( ... - "app", "2.0.3", ">=2 <3", "stable", ... + "app", "2.1.0", ">=2 <3", "stable", ... "Explicit LabKit App SDK contract for tracked production Apps."); end diff --git a/+labkit/+biosignal/buildTemplate.m b/+labkit/+biosignal/buildTemplate.m index a64e9ca5a..37eaa32eb 100644 --- a/+labkit/+biosignal/buildTemplate.m +++ b/+labkit/+biosignal/buildTemplate.m @@ -47,6 +47,8 @@ % segment waveforms are available. % % Errors: +% labkit:biosignal:InvalidOptions - opts is not a scalar struct or contains +% an unknown field. % labkit:biosignal:InvalidSegments - segments is not a structure with % values and timeOffset fields. Invalid numeric values or an unusable % topN value may also raise the originating MATLAB conversion error. @@ -62,6 +64,7 @@ if nargin < 2 opts = struct(); end + validateOptionStruct(opts, "topN"); validateSegments(segments); X = double(segments.values); diff --git a/+labkit/+biosignal/detectEcgPeaks.m b/+labkit/+biosignal/detectEcgPeaks.m index 420c5c981..e32eeffca 100644 --- a/+labkit/+biosignal/detectEcgPeaks.m +++ b/+labkit/+biosignal/detectEcgPeaks.m @@ -77,6 +77,8 @@ % method-specific settings used during detection. % % Errors: +% labkit:biosignal:InvalidOptions - opts is not a scalar struct or contains +% an unknown field. % labkit:biosignal:InvalidSignal - signal lacks time, values, or fs. % labkit:biosignal:UnsupportedPeakMethod - opts.method is unsupported. % labkit:biosignal:UnsupportedPolarity - opts.polarity is unsupported. @@ -97,15 +99,18 @@ if nargin < 2 opts = struct(); end + validateOptionStruct(opts, ["method", "polarity", "minDistanceSec", ... + "threshold", "thresholdStd", "smoothSec", ... + "integrationWindowSec", "refineSearchSec", ... + "rawRefineSearchSec", "baselineWindowSec", ... + "envelopeWindowSec", "lookaheadSec", "minTemplateScore", ... + "medianPolarityCorrection", "medianReviewPeakCount"]); opts = mergeOptions(labkit.biosignal.defaultEcgPeakOptions(optionValue(opts, 'method', [])), opts); events = detectEcgPeaksImpl(signal, opts); end function merged = mergeOptions(defaults, overrides) merged = defaults; - if ~isstruct(overrides) - return; - end fields = fieldnames(overrides); for k = 1:numel(fields) merged.(fields{k}) = overrides.(fields{k}); diff --git a/+labkit/+biosignal/filterSignal.m b/+labkit/+biosignal/filterSignal.m index 131404367..fbf6b91e7 100644 --- a/+labkit/+biosignal/filterSignal.m +++ b/+labkit/+biosignal/filterSignal.m @@ -48,6 +48,8 @@ % supplied by the caller. % % Errors: +% labkit:biosignal:InvalidOptions - opts is not a scalar struct or contains +% an unknown field. % labkit:biosignal:InvalidSignal - signal lacks values or fs. % labkit:biosignal:UnsupportedFilter - type is not supported. % labkit:biosignal:UnsupportedFilterEdgeMode - edgeMode is not supported. @@ -67,6 +69,8 @@ if nargin < 2 spec = struct(); end + validateOptionStruct(spec, ... + ["type", "cutoffHz", "edgeMode", "edgePadSec", "edgeTaperSec"]); validateSignal(signal); filtered = signal; diff --git a/+labkit/+biosignal/measureSegments.m b/+labkit/+biosignal/measureSegments.m index 49d1fe727..e8bd0d934 100644 --- a/+labkit/+biosignal/measureSegments.m +++ b/+labkit/+biosignal/measureSegments.m @@ -47,6 +47,8 @@ % metadata.noiseWindowsSec - Noise intervals used for the calculation. % % Errors: +% labkit:biosignal:InvalidOptions - opts is not a scalar struct or contains +% an unknown field. % labkit:biosignal:InvalidSegments - segments lacks values or timeOffset. % labkit:biosignal:InvalidTemplate - template lacks values. % @@ -65,6 +67,7 @@ if nargin < 3 opts = struct(); end + validateOptionStruct(opts, ["signalWindowSec", "noiseWindowsSec"]); validateInputs(segments, template); X = double(segments.values); diff --git a/+labkit/+biosignal/private/validateOptionStruct.m b/+labkit/+biosignal/private/validateOptionStruct.m new file mode 100644 index 000000000..6b0d7077a --- /dev/null +++ b/+labkit/+biosignal/private/validateOptionStruct.m @@ -0,0 +1,12 @@ +function validateOptionStruct(value, allowed) +% Package-private validation for public biosignal option structures. +if ~isstruct(value) || ~isscalar(value) + error('labkit:biosignal:InvalidOptions', ... + 'Biosignal options must be a scalar struct.'); +end +unknown = setdiff(string(fieldnames(value)), string(allowed)); +if ~isempty(unknown) + error('labkit:biosignal:InvalidOptions', ... + 'Unknown biosignal option: %s.', unknown(1)); +end +end diff --git a/+labkit/+biosignal/readRecording.m b/+labkit/+biosignal/readRecording.m index 1432250cb..3eea6613f 100644 --- a/+labkit/+biosignal/readRecording.m +++ b/+labkit/+biosignal/readRecording.m @@ -88,6 +88,8 @@ % status.ok=false, preserves the normalized source path, and returns an % empty recording. Only an invalid filepath MATLAB value is thrown as % labkit:biosignal:InvalidFilepath before import begins. +% Unknown option fields or a non-struct opts value throw +% labkit:biosignal:InvalidOptions. % % Example: % filepath = [tempname '.csv']; @@ -105,6 +107,9 @@ if nargin < 2 opts = struct(); end + validateOptionStruct(opts, ["headerLine", "hasHeader", "timeColumn", ... + "timeUnit", "signalColumns", "fallbackFs", "timeRepair", ... + "gapFactor", "useFirstNumericColumnAsTime"]); filepath = normalizeFilepath(filepath); recording = emptyRecording(filepath); diff --git a/+labkit/+biosignal/version.m b/+labkit/+biosignal/version.m index 6d9da4656..f29b32d2f 100644 --- a/+labkit/+biosignal/version.m +++ b/+labkit/+biosignal/version.m @@ -30,6 +30,6 @@ % See also labkit.contract.versionInfo, % labkit.contract.checkRequirements - info = labkit.contract.versionInfo("biosignal", "1.0.3", ">=1.0 <2", ... + info = labkit.contract.versionInfo("biosignal", "1.0.4", ">=1.0 <2", ... "stable", "Biosignal recording, filtering, event, segmentation, and ECG facade contract."); end diff --git a/+labkit/+dta/detectType.m b/+labkit/+dta/detectType.m index 2571a31bb..c1c0210c7 100644 --- a/+labkit/+dta/detectType.m +++ b/+labkit/+dta/detectType.m @@ -16,9 +16,10 @@ % Outputs: % kind - String scalar: "chrono", "eis", "cvct", or "unknown". % status - Scalar structure describing detection. ok is true only for a -% recognized file; message is empty on success and explains failure -% otherwise; kind repeats the detected value; and filepath contains the -% normalized character-vector path. +% recognized file; code is "ok", "missing_file", or +% "unsupported_content"; message is empty on success and explains +% failure otherwise; kind repeats the detected value; and filepath +% contains the normalized character-vector path. % % Errors: % Throws labkit:dta:InvalidFilepath when filepath is not a character vector @@ -37,9 +38,10 @@ filepath = normalizeFilepath(filepath); kind = "unknown"; - status = makeStatus(filepath, kind, ""); + status = makeStatus(filepath, kind, "unsupported_content", ""); if exist(filepath, 'file') ~= 2 + status.code = "missing_file"; status.message = sprintf('File not found: %s', filepath); return; end @@ -53,6 +55,7 @@ if ok kind = kinds(k); status.ok = true; + status.code = "ok"; status.kind = kind; status.message = ""; return; @@ -70,9 +73,10 @@ filepath = char(filepath); end -function status = makeStatus(filepath, kind, message) +function status = makeStatus(filepath, kind, code, message) status = struct( ... 'ok', false, ... + 'code', string(code), ... 'message', string(message), ... 'kind', string(kind), ... 'filepath', filepath); diff --git a/+labkit/+dta/loadFile.m b/+labkit/+dta/loadFile.m index 87099f382..10356d719 100644 --- a/+labkit/+dta/loadFile.m +++ b/+labkit/+dta/loadFile.m @@ -29,9 +29,11 @@ % Outputs: % item - Scalar parsed item structure when status.ok is true, or an empty % structure array when loading fails. See Output Fields. -% status - Scalar structure with ok, message, kind, expectedKind, and -% filepath fields. A kind mismatch reports both the expected and detected -% family in message. +% status - Scalar structure with ok, code, message, kind, expectedKind, and +% filepath fields. code is "ok", "missing_file", +% "unsupported_content", "kind_mismatch", or "parse_failure". +% A kind mismatch reports both the expected and detected family in +% message. % % Output Fields: % type - "chrono", "eis", or "cvct". @@ -80,7 +82,8 @@ filepath = normalizeFilepath(filepath); expectedKind = normalizeExpectedKind(expectedKind); item = struct([]); - status = makeStatus(filepath, "unknown", expectedKind, ""); + status = makeStatus(filepath, "unknown", expectedKind, ... + "parse_failure", ""); if expectedKind == "auto" [detectedKind, detectStatus] = labkit.dta.detectType(filepath); @@ -93,9 +96,10 @@ [detectedKind, detectStatus] = labkit.dta.detectType(filepath); if detectStatus.ok && detectedKind ~= expectedKind status.kind = detectedKind; + status.code = "kind_mismatch"; status.message = sprintf('Expected %s DTA, detected %s.', expectedKind, detectedKind); return; - elseif ~detectStatus.ok && contains(detectStatus.message, 'File not found') + elseif ~detectStatus.ok && detectStatus.code == "missing_file" status = withExpectedKind(detectStatus, expectedKind); return; end @@ -106,6 +110,7 @@ item = loadByKind(filepath, kind, opts); status.ok = true; + status.code = "ok"; status.kind = kind; status.message = ""; catch ME @@ -120,9 +125,10 @@ filepath = char(filepath); end -function status = makeStatus(filepath, kind, expectedKind, message) +function status = makeStatus(filepath, kind, expectedKind, code, message) status = struct( ... 'ok', false, ... + 'code', string(code), ... 'message', string(message), ... 'kind', string(kind), ... 'expectedKind', string(expectedKind), ... @@ -130,11 +136,13 @@ end function status = withExpectedKind(status, expectedKind) - status = makeStatus(status.filepath, status.kind, expectedKind, status.message); + status = makeStatus(status.filepath, status.kind, expectedKind, ... + status.code, status.message); end function status = statusForLoadFailure(filepath, kind, expectedKind, loadError) - status = makeStatus(filepath, kind, expectedKind, loadError.message); + status = makeStatus(filepath, kind, expectedKind, ... + "parse_failure", loadError.message); if expectedKind == "auto" return; end @@ -142,9 +150,11 @@ [detectedKind, detectStatus] = labkit.dta.detectType(filepath); if detectStatus.ok && detectedKind ~= expectedKind status.kind = detectedKind; + status.code = "kind_mismatch"; status.message = sprintf('Expected %s DTA, detected %s.', expectedKind, detectedKind); - elseif ~detectStatus.ok && contains(detectStatus.message, 'File not found') + elseif ~detectStatus.ok && detectStatus.code == "missing_file" status.kind = detectStatus.kind; + status.code = detectStatus.code; status.message = detectStatus.message; end end diff --git a/+labkit/+dta/loadFiles.m b/+labkit/+dta/loadFiles.m index ab10fb2f1..f55e73d0f 100644 --- a/+labkit/+dta/loadFiles.m +++ b/+labkit/+dta/loadFiles.m @@ -33,7 +33,8 @@ % % Output Fields: % loaded - Cell array of paths that loaded successfully. -% failed - Structure array with filepath, kind, and message for failures. +% failed - Structure array with filepath, kind, code, and message for +% failures. % statuses - One loadFile status structure per requested input, in input % order. % nRequested - Number of input paths. @@ -78,6 +79,7 @@ report.failed(end+1) = struct( ... 'filepath', status.filepath, ... 'kind', status.kind, ... + 'code', status.code, ... 'message', status.message); end end @@ -120,9 +122,10 @@ function report = emptyReport() report = struct(); report.loaded = {}; - report.failed = struct('filepath', {}, 'kind', {}, 'message', {}); + report.failed = struct('filepath', {}, 'kind', {}, 'code', {}, 'message', {}); report.statuses = struct( ... 'ok', {}, ... + 'code', {}, ... 'message', {}, ... 'kind', {}, ... 'expectedKind', {}, ... diff --git a/+labkit/+dta/version.m b/+labkit/+dta/version.m index 38d2c30f3..3bfecdf6b 100644 --- a/+labkit/+dta/version.m +++ b/+labkit/+dta/version.m @@ -27,6 +27,6 @@ % See also labkit.contract.versionInfo, % labkit.contract.checkRequirements - info = labkit.contract.versionInfo("dta", "3.0.0", ">=3 <4", ... + info = labkit.contract.versionInfo("dta", "3.1.0", ">=3 <4", ... "stable", "DTA parser, file item, pulse, and curve facade contract."); end diff --git a/+labkit/+image/private/validateOptionStruct.m b/+labkit/+image/private/validateOptionStruct.m new file mode 100644 index 000000000..c0ac8a1ed --- /dev/null +++ b/+labkit/+image/private/validateOptionStruct.m @@ -0,0 +1,12 @@ +function validateOptionStruct(value, allowed) +% Package-private validation for public image option structures. +if ~isstruct(value) || ~isscalar(value) + error('labkit:image:InvalidOptions', ... + 'Image options must be a scalar struct.'); +end +unknown = setdiff(string(fieldnames(value)), string(allowed)); +if ~isempty(unknown) + error('labkit:image:InvalidOptions', ... + 'Unknown image option: %s.', unknown(1)); +end +end diff --git a/+labkit/+image/readFiles.m b/+labkit/+image/readFiles.m index 78038fed1..06169dc51 100644 --- a/+labkit/+image/readFiles.m +++ b/+labkit/+image/readFiles.m @@ -63,7 +63,7 @@ % labkit.image.assertSupportedPaths, % labkit.image.writeFile - if nargin < 2 || isempty(opts) + if nargin < 2 opts = struct(); end opts = normalizeOptions(opts); @@ -95,10 +95,8 @@ end function opts = normalizeOptions(opts) - if ~isstruct(opts) || ~isscalar(opts) - error('labkit:image:InvalidOptions', ... - 'readFiles options must be a scalar struct.'); - end + validateOptionStruct(opts, ["Normalize", "AllowEmpty", ... + "ValidateExtensions", "RequireExisting", "progressFcn"]); opts = struct( ... 'Normalize', optionValue(opts, 'Normalize', true), ... 'AllowEmpty', optionValue(opts, 'AllowEmpty', true), ... diff --git a/+labkit/+image/version.m b/+labkit/+image/version.m index 8fd2c2702..0ee308b23 100644 --- a/+labkit/+image/version.m +++ b/+labkit/+image/version.m @@ -28,6 +28,6 @@ % See also labkit.contract.versionInfo, % labkit.contract.checkRequirements - info = labkit.contract.versionInfo("image", "2.0.3", ">=2.0 <3", ... + info = labkit.contract.versionInfo("image", "2.0.4", ">=2.0 <3", ... "stable", "GUI-free image file input, basic processing, and preview-budget helpers for responsive image apps."); end diff --git a/+labkit/+rhs/private/validateOptionStruct.m b/+labkit/+rhs/private/validateOptionStruct.m new file mode 100644 index 000000000..343478596 --- /dev/null +++ b/+labkit/+rhs/private/validateOptionStruct.m @@ -0,0 +1,12 @@ +function validateOptionStruct(value, allowed) +% Package-private validation for public RHS option structures. +if ~isstruct(value) || ~isscalar(value) + error('labkit:rhs:InvalidOptions', ... + 'RHS options must be a scalar struct.'); +end +unknown = setdiff(string(fieldnames(value)), string(allowed)); +if ~isempty(unknown) + error('labkit:rhs:InvalidOptions', ... + 'Unknown RHS option: %s.', unknown(1)); +end +end diff --git a/+labkit/+rhs/readWindow.m b/+labkit/+rhs/readWindow.m index 41872430c..16769bd02 100644 --- a/+labkit/+rhs/readWindow.m +++ b/+labkit/+rhs/readWindow.m @@ -53,7 +53,8 @@ % values - Samples-by-channels numeric matrix in the unit named by unit. % % Errors: -% Throws labkit:rhs:InvalidFilepath for an invalid path, +% Throws labkit:rhs:InvalidOptions when opts is not a scalar struct or +% contains an unknown field, labkit:rhs:InvalidFilepath for an invalid path, % labkit:rhs:InvalidFamily for an unsupported family, % labkit:rhs:InvalidChannel for an unknown or out-of-range channel, and % labkit:rhs:InvalidTimeRange for a malformed or reversed time range. @@ -75,9 +76,10 @@ % See also labkit.rhs.indexFile, % labkit.rhs.inspectFile - if nargin < 2 || isempty(opts) + if nargin < 2 opts = struct(); end + validateOptionStruct(opts, ["family", "channels", "timeRangeSec"]); filepath = normalizeFilepath(filepath); [index, status] = labkit.rhs.indexFile(filepath); diff --git a/+labkit/+rhs/version.m b/+labkit/+rhs/version.m index 112e1d3c8..a63c0101a 100644 --- a/+labkit/+rhs/version.m +++ b/+labkit/+rhs/version.m @@ -27,6 +27,6 @@ % See also labkit.contract.versionInfo, % labkit.contract.checkRequirements - info = labkit.contract.versionInfo("rhs", "1.0.3", ">=1.0 <2", ... + info = labkit.contract.versionInfo("rhs", "1.0.4", ">=1.0 <2", ... "stable", "RHS discovery, metadata, indexing, and waveform-window facade contract."); end diff --git a/+labkit/+thermal/inspectFile.m b/+labkit/+thermal/inspectFile.m index a782406e3..e0e174fe6 100644 --- a/+labkit/+thermal/inspectFile.m +++ b/+labkit/+thermal/inspectFile.m @@ -32,6 +32,8 @@ % with identifier and message populated; those reader exceptions are not % rethrown. path must still be convertible to scalar text, and malformed % MATLAB values may raise the originating conversion error. +% Unknown option fields or a non-struct opts value throw +% labkit:thermal:InvalidOptions. % % Example: % info = labkit.thermal.inspectFile("candidate.jpg"); @@ -44,9 +46,10 @@ % See also labkit.thermal.readFile, % labkit.thermal.isSupportedPath - if nargin < 2 || isempty(opts) + if nargin < 2 opts = struct(); end + validateOptionStruct(opts, ["RequireExisting", "TemperatureCorrection"]); [~, base, ext] = fileparts(char(string(path))); info = struct( ... 'path', string(path), ... diff --git a/+labkit/+thermal/private/validateOptionStruct.m b/+labkit/+thermal/private/validateOptionStruct.m new file mode 100644 index 000000000..8d67447cc --- /dev/null +++ b/+labkit/+thermal/private/validateOptionStruct.m @@ -0,0 +1,12 @@ +function validateOptionStruct(value, allowed) +% Package-private validation for public thermal option structures. +if ~isstruct(value) || ~isscalar(value) + error('labkit:thermal:InvalidOptions', ... + 'Thermal options must be a scalar struct.'); +end +unknown = setdiff(string(fieldnames(value)), string(allowed)); +if ~isempty(unknown) + error('labkit:thermal:InvalidOptions', ... + 'Unknown thermal option: %s.', unknown(1)); +end +end diff --git a/+labkit/+thermal/rawToTemperatureC.m b/+labkit/+thermal/rawToTemperatureC.m index 4abbdfa2a..4c009551f 100644 --- a/+labkit/+thermal/rawToTemperatureC.m +++ b/+labkit/+thermal/rawToTemperatureC.m @@ -25,17 +25,16 @@ % PlanckF - Required finite scalar from the camera metadata. % PlanckO - Required finite scalar from the camera metadata. % PlanckR2 - Required finite scalar from the camera metadata. -% Emissivity - Object emissivity. Must be positive. Default: 1. -% ObjectDistanceM - Camera-to-object distance in metres. Negative values are -% treated as zero. Default: 1. +% Emissivity - Object emissivity in (0,1]. Default: 1. +% ObjectDistanceM - Nonnegative camera-to-object distance in metres. +% Default: 1. % ReflectedApparentTemperatureC - Reflected apparent temperature in degrees % Celsius. Default: 20. % AtmosphericTemperatureC - Atmospheric temperature in degrees Celsius. % Default: 20. % IRWindowTemperatureC - Infrared-window temperature in degrees Celsius. % Defaults to ReflectedApparentTemperatureC. -% IRWindowTransmission - Infrared-window transmission. Must be positive. -% Default: 1. +% IRWindowTransmission - Infrared-window transmission in (0,1]. Default: 1. % RelativeHumidity - Relative humidity as a fraction from 0 to 1 or as a % percentage greater than 2. Default: 0.5. % AtmosphericTransAlpha1 - First atmospheric alpha coefficient. Default: @@ -66,8 +65,9 @@ % % Errors: % Throws labkit:thermal:MissingCalibration when a required Planck field is -% absent or not finite, and labkit:thermal:InvalidOptions when Correction is -% not one of the two supported values. +% absent or not finite, labkit:thermal:InvalidCalibration when a supplied +% environmental field is outside its physical range, and +% labkit:thermal:InvalidOptions when Correction is unsupported. % % Example: % calibration = struct("PlanckR1", 21106.77, "PlanckB", 1501, ... @@ -80,9 +80,10 @@ % See also labkit.thermal.readFile, % labkit.thermal.renderImage - if nargin < 3 || isempty(opts) + if nargin < 3 opts = struct(); end + validateOptionStruct(opts, "Correction"); mode = string(optionValue(opts, 'Correction', "environment")); if ~any(mode == ["environment", "planck-basic"]) error('labkit:thermal:InvalidOptions', ... @@ -121,7 +122,7 @@ function assertPlanckCalibration(calibration) IRWTemp = parameters.IRWindowTemperatureC; IRT = parameters.IRWindowTransmission; RH = parameters.RelativeHumidity; - if RH > 2 + if RH > 1 RH = RH / 100; end ATA1 = parameters.AtmosphericTransAlpha1; @@ -182,13 +183,8 @@ function assertPlanckCalibration(calibration) if field == "IRWindowTemperatureC" && ~isfinite(fallback) fallback = fieldValue(parameters, 'ReflectedApparentTemperatureC', 20); end + validateEnvironmentalField(calibration, field); [value, usedDefault] = scalarField(calibration, field, fallback); - if any(field == ["Emissivity", "IRWindowTransmission"]) && value <= 0 - value = fallback; - usedDefault = true; - elseif field == "ObjectDistanceM" - value = max(0, value); - end parameters.(field) = value; if usedDefault sources.(field) = "default"; @@ -200,6 +196,28 @@ function assertPlanckCalibration(calibration) diagnostics = conversionDiagnostics("environment", defaultedFields, sources); end +function validateEnvironmentalField(calibration, field) + if ~isfield(calibration, field) + return; + end + value = calibration.(field); + validScalar = isnumeric(value) && isscalar(value) && isfinite(double(value)); + switch field + case {"Emissivity", "IRWindowTransmission"} + valid = validScalar && double(value) > 0 && double(value) <= 1; + case "ObjectDistanceM" + valid = validScalar && double(value) >= 0; + case "RelativeHumidity" + valid = validScalar && double(value) >= 0 && double(value) <= 100; + otherwise + return; + end + if ~valid + error('labkit:thermal:InvalidCalibration', ... + 'Invalid FLIR environmental calibration field: %s.', field); + end +end + function value = defaultAtmosphericCoefficient(name) % Constant: FLIR radiometric atmospheric transmission coefficients used % when a file or caller does not provide camera-specific values. diff --git a/+labkit/+thermal/readFile.m b/+labkit/+thermal/readFile.m index 7936f9170..9ebfd68de 100644 --- a/+labkit/+thermal/readFile.m +++ b/+labkit/+thermal/readFile.m @@ -52,7 +52,7 @@ % labkit.thermal.readFiles, % labkit.thermal.rawToTemperatureC - if nargin < 2 || isempty(opts) + if nargin < 2 opts = struct(); end opts = normalizeOptions(opts); @@ -81,10 +81,7 @@ end function opts = normalizeOptions(opts) - if ~isstruct(opts) || ~isscalar(opts) - error('labkit:thermal:InvalidOptions', ... - 'readFile options must be a scalar struct.'); - end + validateOptionStruct(opts, ["RequireExisting", "TemperatureCorrection"]); opts = struct( ... 'RequireExisting', optionValue(opts, 'RequireExisting', true), ... 'TemperatureCorrection', string(optionValue(opts, ... diff --git a/+labkit/+thermal/readFiles.m b/+labkit/+thermal/readFiles.m index 409e85ec4..01fd26e99 100644 --- a/+labkit/+thermal/readFiles.m +++ b/+labkit/+thermal/readFiles.m @@ -54,7 +54,7 @@ % See also labkit.thermal.readFile, % labkit.thermal.inspectFile - if nargin < 2 || isempty(opts) + if nargin < 2 opts = struct(); end opts = normalizeOptions(opts); @@ -87,10 +87,8 @@ end function opts = normalizeOptions(opts) - if ~isstruct(opts) || ~isscalar(opts) - error('labkit:thermal:InvalidOptions', ... - 'readFiles options must be a scalar struct.'); - end + validateOptionStruct(opts, ["AllowEmpty", "RequireExisting", ... + "SkipInvalid", "TemperatureCorrection", "progressFcn"]); opts = struct( ... 'AllowEmpty', optionValue(opts, 'AllowEmpty', true), ... 'RequireExisting', optionValue(opts, 'RequireExisting', true), ... diff --git a/+labkit/+thermal/renderImage.m b/+labkit/+thermal/renderImage.m index 1404c4a42..e24f7af28 100644 --- a/+labkit/+thermal/renderImage.m +++ b/+labkit/+thermal/renderImage.m @@ -42,7 +42,7 @@ % See also labkit.thermal.rawToTemperatureC, % labkit.thermal.readFile - if nargin < 2 || isempty(opts) + if nargin < 2 opts = struct(); end opts = normalizeOptions(values, opts); @@ -56,6 +56,7 @@ end function opts = normalizeOptions(values, opts) + validateOptionStruct(opts, ["Limits", "Palette", "Levels"]); opts = struct( ... 'Limits', optionValue(opts, 'Limits', finiteLimits(values)), ... 'Palette', lower(string(optionValue(opts, 'Palette', "turbo"))), ... diff --git a/+labkit/+thermal/version.m b/+labkit/+thermal/version.m index b69717db7..44ff67761 100644 --- a/+labkit/+thermal/version.m +++ b/+labkit/+thermal/version.m @@ -27,6 +27,6 @@ % See also labkit.contract.versionInfo, % labkit.contract.checkRequirements - info = labkit.contract.versionInfo("thermal", "1.1.2", ">=1.0 <2", ... + info = labkit.contract.versionInfo("thermal", "1.1.3", ">=1.0 <2", ... "experimental", "GUI-free thermal image facade for FLIR radiometric JPEG reads, raw sensor matrices, provenance-aware temperature conversion, and display rendering."); end diff --git a/+labkit/AGENTS.md b/+labkit/AGENTS.md index b218e4d17..0b81f6cd4 100644 --- a/+labkit/AGENTS.md +++ b/+labkit/AGENTS.md @@ -58,6 +58,13 @@ Use `labkittest.explain` to find the exact owner and contract. remain legal and unique; references must resolve before UI mutation. - View snapshots must preserve unchanged graphics and viewports. Renderers own incremental overlay changes; interaction specs own user gestures. +- Establish the legal domain of interdependent native properties before + assigning dependent values; constructor name-value order is not a contract + across MATLAB releases. Give responsive resize to one explicit container + owner and verify release-sensitive layout through native construction. +- Show determinate progress only when work has a measurable denominator. + Otherwise report real named stages, and paint the stage before synchronous + expensive work. - One interaction target has one active gesture owner. A managed movable rectangle accepts movement from its visible box or interior, not only a thin edge; display-only affordances remain non-pickable. diff --git a/.agents/dos-and-donts.md b/.agents/dos-and-donts.md index 78027080c..31a6462b0 100644 --- a/.agents/dos-and-donts.md +++ b/.agents/dos-and-donts.md @@ -22,156 +22,6 @@ second architecture guide. ## Incubating lessons -### Debug and native presentation - -- Generate synthetic inputs deliberately without preloading them. Validate - both the pack contract and a native launch; finite schema placeholders can - still violate small-image control limits or native presentation. -- Validate interdependent native UI properties through real construction: - constructor name-values may be applied in a different order than written, - and layout-manager resize hooks vary by MATLAB release. Set dependent values - only after their legal domain exists, give responsive resize to an explicit - container owner, and retain a rendered GUI check. Responsive assertions must - respect layout minimums and headless virtual-screen bounds; a smaller window - cannot reduce a table that is already at its readable-width floor. -- Logging severity, capture detail, display filtering, persistence, and - synthetic-input generation are independent policies. Do not bundle them into - a Debug launch; an always-on bounded flight recorder is what makes an - ordinary post-incident session diagnosable. -- Treat capture and the default viewer filter as separate contracts. Prove - retained DEBUG callback boundaries even when the useful default view hides - them, and explain exactly what enabling TRACE can and cannot recover. -- Instrument teardown before cleanup begins, continue independent cleanup - owners after one fails, persist the terminal failure before closing the - recorder, and only then return the cleanup exception. -- A self-contained repair entrypoint owns only minimum health detection, - repair, and delegation. Do not give it a second log/session schema; canonical - diagnostics begin after the installed framework is available. -- Prove a single-file entrypoint from a directory containing only that file. - MATLAB resolves the current folder before later path entries, so changing - `path` alone can silently exercise the installed repository copy instead. -- Do not draw determinate progress for work without a measurable denominator. - Show named stages backed by real operation boundaries, and paint the status - before synchronous expensive work with `drawnow`. -- Make retained semantic events privacy-safe before they enter memory or disk. - Free-form messages can leak more readily than attributes; export-time - redaction is defense in depth, not the primary boundary. -- Normalize string-authored filter tables immediately before native file - dialogs. Some MATLAB paths accept string cells while older native dialog - helpers require every filter-table element to be a character array. -- A diagnostic last resort must not reuse the subsystem that just failed. - Build it from the sanitized in-memory stream, write one plain-text file - without ZIP staging, and report the concrete fallback path. - -### Interaction and previews - -- Overlapping gestures need one active owner. A movable ROI must accept its - visible interior or center as a normal drag target; state geometry alone - cannot prove pointer ownership. -- Shared image code keeps native pixels by default. Any finite preview budget - is an explicit App decision, and pixel-unit parameters follow the preview - scale. -- Treat file-list bindings as shape-agnostic collections. Normalize parallel - App task, source, path, and cache arrays at the callback boundary before - vertical insertion so native row state cannot break one-entry alignment. - -### Validation and compatibility - -- Pair an old MATLAB release with runner images from that release's supported - operating-system list. A MATLAB-version matrix over `*-latest` can turn an - unsupported host/runtime combination into misleading product failures. -- Choose the oldest CI release from the product compatibility contract, not - from the setup action's installation floor or the build tool's introduction. - Give each platform-release entry one named risk instead of defaulting to a - full Cartesian product. -- Derive portable identifier and shape limits from the oldest supported MATLAB, - not the development runtime. Newer releases can raise `namelengthmax` and - silently accept struct keys or test names that R2022b truncates; encode the - compatibility-floor limit in the owning contract and a repository guard. -- Give Linux hidden-GUI validation a virtual display. A green setup and - headless run do not prevent older Qt-based MATLAB graphics from crashing - when the runner has no active display server. -- Guard release-added graphics options at the App export boundary and keep the - oldest supported release on a real export test. Parsing and layout tests - cannot detect an unsupported name-value or file format. -- Windows constrains displayed figure windows to the desktop. For an offscreen - export canvas, create it invisible, anchor its pixel Position at the desktop - origin, and only then assign dimensions that may exceed the screen. Treat - native text extents as iterative renderer output: converge until geometry is - stable, then verify figure-coordinate ruler-label bounds because an older - renderer may update them only after accepting the final offscreen geometry. - R2022b on Windows can omit ruler decorators from descendant text discovery - and still refuse that requested growth for an invisible figure. Include axis - labels explicitly; when the accepted size remains clamped, recompute the - rendered data frame from that canvas, the measured insets, and a - typography-derived minimum outer margin. The old Windows `print` path can - under-report an axis-label hardcopy line by one em, so reserve that line box - in addition to the intended half-em whitespace. An export regression must - inspect the written artifact boundary; hidden figure Position is useful - diagnostic context, not the product assertion. -- When a test can produce deterministic rendered output that a person or - visual model can review, retain that exact image as run-centered visual - evidence instead of deleting it with a temporary fixture. Keep an automated - assertion over the same production artifact for the CI result, and publish - `visual-evidence/` inside the platform artifact so failures and cross-platform - differences can be inspected without rerunning the job. -- A CI summary is evidence navigation, not a second status badge. On success, - state the compatibility claim and untested manual boundaries; on failure, - preserve passing profiles and distinguish a missing report from a failed - test. Cancellation means incomplete evidence, not product failure. Record - real framework diagnostics in JUnit before improving its layout. -- A component version change proves that its contract moved; it does not prove - that every component manual needs new prose. For cross-App behavior, choose - one canonical owner first and keep App pages limited to workflow-critical or - App-specific differences. -- Moving behavior to a new owner does not authorize retiring its visible or - interactive contract. Recreate the old appearance, selection paths, status, - and failure semantics at the new boundary before deleting the old owner. -- Automatic instrumentation wraps the existing failure boundary, not a wider - convenience block: preserve specific validation/error identifiers, and test - nested diagnostic ownership by parent/root ancestry rather than assuming - every health record attaches directly to the outer callback. -- A destructive updater has one shared write gateway for GUI and programmatic - callers. Reject invalid roots and both Git-marker forms before network I/O; - use a sibling backup, preserve explicit local data, validate the replacement, - and prove success and rollback with path entries that actually existed. -- A changed `projectSpec` needs nonempty owner-level persistence evidence. - Treat a client timeout during a durable MATLAB run as unknown until its - progress artifact or terminal log confirms the executor result. -- Treat MATLAB local functions as complete structural blocks. After inserting - or moving one, inspect the preceding and following `end` boundaries before - running tests; a misplaced boundary can silently nest later helpers and turn - a simple edit defect into misleading runtime failures. -- For destructive path checks, compare the resolved existing target with an - absolute normalized expected path that does not follow links. Canonicalizing - both sides can hide a redirect, while legacy canonical APIs can miss Windows - junctions; prove both external and same-root redirects through the public - operation before allowing recursive deletion. -- After a bulk text rewrite, scan changed MATLAB files for unexpected UTF-8 - BOMs before reviewing semantics. A shell encoding default can otherwise - create hundreds of noisy first-line changes and conceal the real migration. - If terminal output looks corrupted, reread with explicit UTF-8 and inspect - code points before changing source; display decoding is not source damage. -- Before starting a durable background MATLAB test run, derive the repository - root from the runner file, convert suite paths to absolute character cells, - and assert both the container type and file existence. Reusing this - preflight prevents repeated no-test runs caused by changed working - directories or string-cell inputs. For a quick focused run, keep those - assertions inside MATLAB rather than building a second cross-shell preflight. -- Treat parameter providers and reset-path probes as executable test - infrastructure: run suite construction, not only selected test bodies. After - `restoredefaultpath`, a probe must use only dependencies it explicitly - restored; calling a test helper that just disappeared from the path defeats - the isolation contract. -- A fixture constrains only the contract under test. An unrelated facade - version range can turn a path-isolation test into a stale compatibility test - and produce a convincing but false isolation failure after a major upgrade. -- A GUI environment tag is metadata, not a visibility fixture. Tests that - construct a native runtime must establish and restore hidden mode themselves - so a focused `runtests` invocation cannot open product windows. -- Callback unit tests must use a contract-complete context or an explicit - narrow fake of every operation the callback invokes. Old partial test - backends can make a supported production API look broken during migration. -- Exact one-way old-data readers are bounded persistence support; simultaneous - old/new fields on live values are competing models. Defaults cover omitted - options, while explicit unknown scientific modes fail visibly. +None. Add a lesson only after a future checkpoint produces recurring evidence +that is not already owned by an `AGENTS.md`, Skill, test, source contract, +manual, or component history record. diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 000000000..f85d11460 --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,12 @@ +# Repository Skills + +Each directory owns one LabKit-specific agent workflow: + +- `SKILL.md` defines activation boundaries and procedure. +- `manifest.yaml` declares stable identity and dependencies. +- `evals.json`, when present, records lightweight positive and negative + activation examples. + +Run `python .github/scripts/validate_agent_skills.py` after changing a Skill. +Model-scored evaluation remains a review aid rather than a required local or +CI dependency. diff --git a/.agents/skills/labkit-app-builder/SKILL.md b/.agents/skills/labkit-app-builder/SKILL.md index 15fe717d9..a80d749de 100644 --- a/.agents/skills/labkit-app-builder/SKILL.md +++ b/.agents/skills/labkit-app-builder/SKILL.md @@ -1,6 +1,6 @@ --- name: labkit-app-builder -description: "Use to create or substantially refactor a LabKit MATLAB app from scripts, functions, protocols, existing GUIs, workflow notes, or prose requirements." +description: "Use to create or substantially refactor a LabKit MATLAB app from scripts, functions, protocols, existing GUIs, workflow notes, or prose requirements. Do not use for a narrow bug fix that preserves the existing App shape." --- # LabKit App Builder diff --git a/.agents/skills/labkit-app-builder/manifest.yaml b/.agents/skills/labkit-app-builder/manifest.yaml new file mode 100644 index 000000000..ba0057f42 --- /dev/null +++ b/.agents/skills/labkit-app-builder/manifest.yaml @@ -0,0 +1 @@ +{"schema_version":1,"name":"labkit-app-builder","scope":"labkit-repository","dependencies":["labkit-boundary-guard","labkit-test-planner"]} diff --git a/.agents/skills/labkit-boundary-guard/SKILL.md b/.agents/skills/labkit-boundary-guard/SKILL.md index 10c368e3b..fb1d3b4ce 100644 --- a/.agents/skills/labkit-boundary-guard/SKILL.md +++ b/.agents/skills/labkit-boundary-guard/SKILL.md @@ -1,6 +1,6 @@ --- name: labkit-boundary-guard -description: "Use for changes to +labkit, public APIs, package ownership, helper promotion, or app-versus-library boundary decisions." +description: "Use for changes to +labkit, public APIs, package ownership, helper promotion, or app-versus-library boundary decisions. Do not use for an App-local implementation that cannot affect a shared boundary." --- # LabKit Boundary Guard diff --git a/.agents/skills/labkit-boundary-guard/manifest.yaml b/.agents/skills/labkit-boundary-guard/manifest.yaml new file mode 100644 index 000000000..35143c810 --- /dev/null +++ b/.agents/skills/labkit-boundary-guard/manifest.yaml @@ -0,0 +1 @@ +{"schema_version":1,"name":"labkit-boundary-guard","scope":"labkit-repository","dependencies":[]} diff --git a/.agents/skills/labkit-documentation-maintainer/SKILL.md b/.agents/skills/labkit-documentation-maintainer/SKILL.md index 8416ab0f9..09ff32d26 100644 --- a/.agents/skills/labkit-documentation-maintainer/SKILL.md +++ b/.agents/skills/labkit-documentation-maintainer/SKILL.md @@ -1,6 +1,6 @@ --- name: labkit-documentation-maintainer -description: "Use for LabKit documentation architecture, public API reference, component history, path-derived navigation, the MATLAB documentation renderer, generated site consistency, or documentation deployment changes." +description: "Use for LabKit documentation architecture, public API reference, component history, path-derived navigation, the MATLAB documentation renderer, generated site consistency, or documentation deployment changes. Do not use for source-only edits with no authored documentation contract." --- # LabKit Documentation Maintainer diff --git a/.agents/skills/labkit-documentation-maintainer/manifest.yaml b/.agents/skills/labkit-documentation-maintainer/manifest.yaml new file mode 100644 index 000000000..a7b4a634d --- /dev/null +++ b/.agents/skills/labkit-documentation-maintainer/manifest.yaml @@ -0,0 +1 @@ +{"schema_version":1,"name":"labkit-documentation-maintainer","scope":"labkit-repository","dependencies":[]} diff --git a/.agents/skills/labkit-performance-profiler/SKILL.md b/.agents/skills/labkit-performance-profiler/SKILL.md index a185e97ae..4a84d133e 100644 --- a/.agents/skills/labkit-performance-profiler/SKILL.md +++ b/.agents/skills/labkit-performance-profiler/SKILL.md @@ -1,6 +1,6 @@ --- name: labkit-performance-profiler -description: "Use for measured LabKit startup, callback, file-loading, or launcher performance work and profileLabKitTarget reports." +description: "Use for measured LabKit startup, callback, file-loading, or launcher performance work and profileLabKitTarget reports. Do not use to claim performance from code inspection or a single unmeasured run." --- # LabKit Performance Profiler diff --git a/.agents/skills/labkit-performance-profiler/manifest.yaml b/.agents/skills/labkit-performance-profiler/manifest.yaml new file mode 100644 index 000000000..c2d6baea1 --- /dev/null +++ b/.agents/skills/labkit-performance-profiler/manifest.yaml @@ -0,0 +1 @@ +{"schema_version":1,"name":"labkit-performance-profiler","scope":"labkit-repository","dependencies":["labkit-boundary-guard","labkit-test-planner"]} diff --git a/.agents/skills/labkit-scientific-change-guard/SKILL.md b/.agents/skills/labkit-scientific-change-guard/SKILL.md new file mode 100644 index 000000000..66b92ff7a --- /dev/null +++ b/.agents/skills/labkit-scientific-change-guard/SKILL.md @@ -0,0 +1,41 @@ +--- +name: labkit-scientific-change-guard +description: "Use for changes to scientific formulas, units, ranges, defaults, selectors, tolerances, parity, or provenance in LabKit Apps and facades. Do not use for presentation-only work, documentation-only work, or a private rename that cannot change scientific outputs." +--- + +# LabKit Scientific Change Guard + +## Establish the contract + +Read `AGENTS.md`, the nearest scoped rules, the affected calculation and its +public help, the App or library manual that states the scientific meaning, and +the complete source-owned tests. Use `labkit-boundary-guard` if ownership or a +public facade may change, and `labkit-test-planner` before selecting evidence. + +Write down the accepted inputs, units, defaults, valid ranges, selector labels, +formula or reference implementation, output fields and units, downstream +branches, and failure identifiers. Treat missing rationale as a question to +resolve from repository evidence or the user, not as permission to invent +science. + +## Guard the change + +- Reject unknown selectors and invalid numeric domains before calculation. +- Keep constants semantically named with nearby scientific rationale. +- Preserve output shapes, units, saved data, and export meaning unless the + user explicitly changes the contract. +- When Toolbox behavior is replaced or temporarily referenced, follow the + repository debt ledger and prove idempotency plus parity on App-consumed + outputs within a justified tolerance. +- Prefer synthetic edge cases around valid boundaries, malformed values, + selector alternatives, and repeated identical inputs. +- Test the calculation directly and every downstream result or presentation + branch whose scientific meaning depends on it. + +## Finish + +Run the smallest scientific evidence while iterating. Update public help, +the owning manual, component version, and structured history when the +scientific or public failure contract changes. Report preserved formulas and +units, intentional numerical differences, tolerance evidence, unverified +real-data behavior, and any remaining expert-review boundary. diff --git a/.agents/skills/labkit-scientific-change-guard/evals.json b/.agents/skills/labkit-scientific-change-guard/evals.json new file mode 100644 index 000000000..aeca8872b --- /dev/null +++ b/.agents/skills/labkit-scientific-change-guard/evals.json @@ -0,0 +1,25 @@ +{ + "schema_version": 1, + "cases": [ + { + "prompt": "Reject an unsupported ECG peak-method label without changing the three supported methods.", + "should_activate": true, + "rationale": "A scientific selector and its failure contract change." + }, + { + "prompt": "Correct thermal emissivity and humidity range validation while preserving temperature units.", + "should_activate": true, + "rationale": "Scientific inputs, ranges, defaults, and outputs are in scope." + }, + { + "prompt": "Adjust the zoom toolbar icon spacing.", + "should_activate": false, + "rationale": "Presentation-only work has no scientific meaning." + }, + { + "prompt": "Rename a private variable without changing a formula or result.", + "should_activate": false, + "rationale": "A private semantic-preserving rename is outside this Skill." + } + ] +} diff --git a/.agents/skills/labkit-scientific-change-guard/manifest.yaml b/.agents/skills/labkit-scientific-change-guard/manifest.yaml new file mode 100644 index 000000000..6fb652953 --- /dev/null +++ b/.agents/skills/labkit-scientific-change-guard/manifest.yaml @@ -0,0 +1 @@ +{"schema_version":1,"name":"labkit-scientific-change-guard","scope":"labkit-repository","dependencies":["labkit-boundary-guard","labkit-test-planner"]} diff --git a/.agents/skills/labkit-test-planner/SKILL.md b/.agents/skills/labkit-test-planner/SKILL.md index 70b07cf49..92f97b5ef 100644 --- a/.agents/skills/labkit-test-planner/SKILL.md +++ b/.agents/skills/labkit-test-planner/SKILL.md @@ -1,6 +1,6 @@ --- name: labkit-test-planner -description: "Use for validation planning, MATLAB test execution, pre-commit checks, CI scope, GUI checks, fixtures, or test-catalog changes." +description: "Use for validation planning, MATLAB test execution, pre-commit checks, CI scope, GUI checks, fixtures, or test-catalog changes. Do not use to widen validation without a source-owned contract." --- # LabKit Test Planner diff --git a/.agents/skills/labkit-test-planner/manifest.yaml b/.agents/skills/labkit-test-planner/manifest.yaml new file mode 100644 index 000000000..53ed59055 --- /dev/null +++ b/.agents/skills/labkit-test-planner/manifest.yaml @@ -0,0 +1 @@ +{"schema_version":1,"name":"labkit-test-planner","scope":"labkit-repository","dependencies":[]} diff --git a/.github/scripts/classify_ci_scope.py b/.github/scripts/classify_ci_scope.py index dd030fa85..d919ea263 100644 --- a/.github/scripts/classify_ci_scope.py +++ b/.github/scripts/classify_ci_scope.py @@ -20,7 +20,9 @@ def is_governance_document(path: str) -> bool: item = PurePosixPath(path) return ( item.name == "AGENTS.md" - or path.startswith(".agents/") + or (path.startswith(".agents/") and not ( + path.startswith(".agents/skills/") and path.endswith(".m") + )) or path == ".github/PULL_REQUEST_TEMPLATE.md" or path.startswith(".github/ISSUE_TEMPLATE/") ) diff --git a/.github/scripts/test_classify_ci_scope.py b/.github/scripts/test_classify_ci_scope.py index f77a7b457..8696e75b7 100644 --- a/.github/scripts/test_classify_ci_scope.py +++ b/.github/scripts/test_classify_ci_scope.py @@ -30,6 +30,14 @@ def test_agent_guidance_uses_only_governance_check(self): {"full": False, "docs": False, "governance": True}, ) + def test_skill_owned_matlab_automation_requires_full_matrix(self): + self.assertEqual( + MODULE.classify([ + ".agents/skills/labkit-test-planner/scripts/probe.m" + ]), + {"full": True, "docs": False, "governance": False}, + ) + def test_human_docs_request_docs_check_without_full_matrix(self): self.assertEqual( MODULE.classify(["docs/framework/README.md", "site/index.html"]), diff --git a/.github/scripts/test_validate_agent_skills.py b/.github/scripts/test_validate_agent_skills.py new file mode 100644 index 000000000..fc6331ad4 --- /dev/null +++ b/.github/scripts/test_validate_agent_skills.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Regression tests for repository Skill validation.""" + +import importlib.util +import json +import pathlib +import tempfile +import unittest + + +SCRIPT = pathlib.Path(__file__).with_name("validate_agent_skills.py") +SPEC = importlib.util.spec_from_file_location("validate_agent_skills", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +class ValidateAgentSkillsTest(unittest.TestCase): + def make_skill(self, root, name="probe", dependencies=None, evals=False): + folder = root / ".agents" / "skills" / name + folder.mkdir(parents=True) + (folder / "SKILL.md").write_text( + "---\n" + f"name: {name}\n" + 'description: "Use for a probe. Do not use outside the probe."\n' + "---\n\n# Probe\n", + encoding="utf-8", + ) + (folder / "manifest.yaml").write_text(json.dumps({ + "schema_version": 1, + "name": name, + "scope": "labkit-repository", + "dependencies": dependencies or [], + }), encoding="utf-8") + if evals: + (folder / "evals.json").write_text(json.dumps({ + "schema_version": 1, + "cases": [ + {"prompt": "use it", "should_activate": True, + "rationale": "in scope"}, + {"prompt": "skip it", "should_activate": False, + "rationale": "out of scope"}, + ], + }), encoding="utf-8") + return folder + + def test_accepts_complete_contract_with_balanced_evals(self): + with tempfile.TemporaryDirectory() as raw: + root = pathlib.Path(raw) + self.make_skill(root, evals=True) + self.assertEqual(MODULE.validate(root), 1) + + def test_rejects_name_drift_and_unknown_dependency(self): + with tempfile.TemporaryDirectory() as raw: + root = pathlib.Path(raw) + folder = self.make_skill(root) + text = (folder / "SKILL.md").read_text(encoding="utf-8") + (folder / "SKILL.md").write_text( + text.replace("name: probe", "name: other"), encoding="utf-8") + with self.assertRaises(MODULE.SkillContractError): + MODULE.validate(root) + with tempfile.TemporaryDirectory() as raw: + root = pathlib.Path(raw) + self.make_skill(root, dependencies=["missing"]) + with self.assertRaises(MODULE.SkillContractError): + MODULE.validate(root) + + def test_rejects_dependency_cycles_and_unportable_content(self): + with tempfile.TemporaryDirectory() as raw: + root = pathlib.Path(raw) + self.make_skill(root, "first", ["second"]) + self.make_skill(root, "second", ["first"]) + with self.assertRaises(MODULE.SkillContractError): + MODULE.validate(root) + with tempfile.TemporaryDirectory() as raw: + root = pathlib.Path(raw) + folder = self.make_skill(root) + slash = chr(92) + (folder / "notes.md").write_text( + "C:" + slash + "Users" + slash + "Example" + slash + "private", + encoding="utf-8") + with self.assertRaises(MODULE.SkillContractError): + MODULE.validate(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/validate_agent_skills.py b/.github/scripts/validate_agent_skills.py new file mode 100644 index 000000000..57f2ec821 --- /dev/null +++ b/.github/scripts/validate_agent_skills.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Validate repository-owned LabKit Skill contracts without dependencies.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + + +FRONTMATTER = re.compile( + r"\A---\s*\nname:\s*([^\n]+)\n" + r"description:\s*(?:\"([^\"]+)\"|([^\n]+))\n---\s*\n", +) +LINK = re.compile(r"\[[^\]]+\]\(([^)]+)\)") +MANIFEST_KEYS = {"schema_version", "name", "scope", "dependencies"} + + +class SkillContractError(ValueError): + pass + + +def load_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as cause: + raise SkillContractError( + f"{path}: invalid JSON-compatible YAML/JSON") from cause + + +def validate(root: Path) -> int: + skills_root = root / ".agents" / "skills" + skill_dirs = sorted( + path for path in skills_root.iterdir() + if path.is_dir() and (path / "SKILL.md").is_file() + ) + if not skill_dirs: + raise SkillContractError("No repository Skills were found.") + manifests: dict[str, dict[str, object]] = {} + for folder in skill_dirs: + skill_path = folder / "SKILL.md" + text = skill_path.read_text(encoding="utf-8") + match = FRONTMATTER.match(text) + if not match: + raise SkillContractError(f"{skill_path}: invalid frontmatter") + name = match.group(1).strip().strip("\"'") + description = (match.group(2) or match.group(3)).strip() + if name != folder.name: + raise SkillContractError(f"{folder}: folder and Skill name differ") + if "Do not use" not in description: + raise SkillContractError( + f"{folder}: description needs a negative boundary") + manifest_path = folder / "manifest.yaml" + manifest = load_json(manifest_path) + if not isinstance(manifest, dict) or set(manifest) != MANIFEST_KEYS: + raise SkillContractError( + f"{manifest_path}: manifest keys differ from contract") + dependencies = manifest["dependencies"] + if (manifest["schema_version"] != 1 or manifest["name"] != name or + manifest["scope"] != "labkit-repository" or + not isinstance(dependencies, list) or + not all(isinstance(item, str) for item in dependencies)): + raise SkillContractError(f"{manifest_path}: invalid manifest value") + manifests[name] = manifest + validate_links(folder, text) + validate_portability(folder) + eval_path = folder / "evals.json" + if eval_path.exists(): + validate_evals(eval_path) + validate_dependencies(manifests) + return len(skill_dirs) + + +def validate_links(folder: Path, text: str) -> None: + for target in LINK.findall(text): + target = target.split("#", 1)[0] + if not target or "://" in target: + continue + if not (folder / target).resolve().exists(): + raise SkillContractError( + f"{folder / 'SKILL.md'}: missing link {target}") + + +def validate_portability(folder: Path) -> None: + for path in folder.rglob("*"): + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8", errors="ignore") + if re.search(r"(?:[A-Za-z]:\\Users\\|/Users/|/home/[^/\s]+/)", text): + raise SkillContractError( + f"{path}: contains a user-specific absolute path") + if any(token in text for token in ( + "runLabKitTests", "tests/runner/", "tests/cases/")): + raise SkillContractError( + f"{path}: contains a retired repository token") + + +def validate_evals(path: Path) -> None: + data = load_json(path) + if not isinstance(data, dict) or set(data) != {"schema_version", "cases"}: + raise SkillContractError(f"{path}: invalid eval contract") + cases = data["cases"] + if data["schema_version"] != 1 or not isinstance(cases, list) or not cases: + raise SkillContractError(f"{path}: eval cases are required") + decisions = set() + for case in cases: + if (not isinstance(case, dict) or + set(case) != {"prompt", "should_activate", "rationale"} or + not isinstance(case["prompt"], str) or + not case["prompt"].strip() or + not isinstance(case["rationale"], str) or + not case["rationale"].strip() or + not isinstance(case["should_activate"], bool)): + raise SkillContractError(f"{path}: malformed eval case") + decisions.add(case["should_activate"]) + if decisions != {False, True}: + raise SkillContractError( + f"{path}: evals need positive and negative cases") + + +def validate_dependencies(manifests: dict[str, dict[str, object]]) -> None: + for name, manifest in manifests.items(): + for dependency in manifest["dependencies"]: + if dependency not in manifests: + raise SkillContractError( + f"{name}: unknown dependency {dependency}") + visiting: set[str] = set() + visited: set[str] = set() + + def visit(name: str) -> None: + if name in visiting: + raise SkillContractError(f"{name}: dependency cycle") + if name in visited: + return + visiting.add(name) + for dependency in manifests[name]["dependencies"]: + visit(dependency) + visiting.remove(name) + visited.add(name) + + for name in manifests: + visit(name) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--root", type=Path, + default=Path(__file__).resolve().parents[2], + ) + args = parser.parse_args() + try: + count = validate(args.root.resolve()) + except SkillContractError as cause: + parser.error(str(cause)) + print(f"Validated {count} repository Skill contract(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6d89b2ce..fda74fd66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,7 @@ jobs: fi git diff --check "${BASE_SHA}" "${HEAD_SHA}" python -m unittest discover -s .github/scripts -p 'test_*.py' + python .github/scripts/validate_agent_skills.py python .github/scripts/check_integration_policy.py \ --event-name "${{ github.event_name }}" \ --base-ref "${{ github.base_ref }}" \ diff --git a/AGENTS.md b/AGENTS.md index 04f92c34f..d8acfaf38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,9 @@ under `docs/`. ## Architecture and implementation - Preserve behavior unless the user asks to change it. +- Moving behavior to a new owner does not retire its observable contract. + Preserve appearance, interaction paths, status, results, and failure + semantics at the new boundary before deleting the old owner. - Apps own formulas, thresholds, units, workflow decisions, plots, results, exports, failures, and wording. Promote code into `+labkit` only when it is a stable domain-neutral contract useful beyond one app. @@ -167,20 +170,27 @@ tests, history, and details out of the public repository. confirming that it was created from the current `origin/main`. Never edit or commit directly on `main`, including for documentation, CI, release preparation, emergency repairs, and bug fixes. -2. When a multi-commit migration needs a roadmap, write it in - `.agents/migration_guide.md` as a standalone `develop` checkpoint before - implementation. Do not add a future-state migration page under `docs/`. - Remove the active entry in the final zero-debt squash merge after durable - final behavior and rationale are recorded in the owning manual and - component history. -3. Use `develop` as the sole ordinary integration branch and keep one active - delivery stream. Push completed checkpoints promptly. Once a +2. When a multi-commit migration needs a roadmap, maintain it locally in + `.agents/migration_guide.md` while the work is active. The roadmap is + disposable working state: do not require a standalone roadmap commit, push, + or preservation in `develop` or `main` history. Do not add a future-state + migration page under `docs/`. Remove local active entries before preparing + the final PR diff. The PR `Why`, net behavior and ownership decisions, + compatibility boundaries, exact evidence, and remaining risks are the + durable review record; delivered behavior and rationale still belong in + the owning manual and component history. +3. Use `develop` as the sole ordinary integration branch, the only branch that + may receive direct pushes, and the one active delivery stream. Commit and + push logical checkpoints when the work benefits from them; do not delay a + coherent checkpoint merely to accumulate a larger batch. Once a `develop -> main` PR opens, freeze `develop` until the PR is merged or closed; do not mix later work into its moving head. -4. Keep branch work stable with small logical purpose-based commits and focused - validation. Prepare user docs, component versions, and structured history as - the single net change that the PR will squash into; do not accumulate - release semantics from intermediate branch commits. +4. Keep branch work stable with purpose-based commits and focused validation. + Intermediate commit count is not a merge criterion. Before opening or + merging the final PR, inspect the complete base-to-head diff, user docs, + component versions, structured history, validation evidence, and remaining + risks as one net change that `main` will squash into; do not derive release + semantics from intermediate branch commits. 5. Main accepts PRs only from the repository-owned `develop` branch. Run `changedFast` once before final review, inspect required PR CI, and read only failing logs. Squash-merge with an explicit compliant subject. diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 826767ee9..547bdd986 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -99,6 +99,9 @@ find the exact owner and contract; App authors never invent test paths. `labkit.app.view.Snapshot` values, and `labkit.app.CallbackContext`. File lists own portable source and selection bindings; `createSession` rebuilds transient data after source changes. + Treat file-list values as shape-agnostic collections and normalize parallel + source, task, path, and cache arrays at the callback boundary before aligned + insertion. Do not mutate registries, restore figure callbacks, create interaction runtimes, or add startup timers/readiness flags. - Interactive rectangles use managed `rectangle` or `regionSelection` specs. diff --git a/apps/dic/dic_postprocess/+dic_postprocess/definition.m b/apps/dic/dic_postprocess/+dic_postprocess/definition.m index a4bb0af55..0004cc047 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/definition.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/definition.m @@ -6,8 +6,8 @@ Title="DIC Strain Postprocess", ... DisplayName="DIC Postprocess", ... Family="DIC", ... - AppVersion="1.6.0", ... - Updated="2026-07-26", ... + AppVersion="1.6.1", ... + Updated="2026-07-30", ... Requirements=labkit.contract.requirements( ... "app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=dic_postprocess.projectSpec(), ... diff --git a/apps/dic/dic_postprocess/+dic_postprocess/projectSpec.m b/apps/dic/dic_postprocess/+dic_postprocess/projectSpec.m index a616b2689..ed0d8431b 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/projectSpec.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/projectSpec.m @@ -3,7 +3,8 @@ % payload version plus local creation and validation. Side effects are none. function spec = projectSpec() spec = labkit.app.project.Schema( ... - Version=1, Create=@createProject, Validate=@validateProject); + Version=1, Create=@createProject, Validate=@validateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/dic/dic_preprocess/+dic_preprocess/definition.m b/apps/dic/dic_preprocess/+dic_preprocess/definition.m index 9ff72ac8a..a3c009104 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/definition.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/definition.m @@ -4,8 +4,8 @@ function app = definition() app = labkit.app.Definition(Entrypoint="labkit_DICPreprocess_app", ... AppId="dic_preprocess", Title="DIC Image Preprocess", ... - DisplayName="DIC Preprocess", Family="DIC", AppVersion="1.7.0", ... - Updated="2026-07-26", Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... + DisplayName="DIC Preprocess", Family="DIC", AppVersion="1.7.1", ... + Updated="2026-07-30", Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=dic_preprocess.projectSpec(), CreateSession=@dic_preprocess.createSession, ... Workbench=dic_preprocess.workbench.buildLayout(), PresentWorkbench=@dic_preprocess.workbench.present, ... BuildSyntheticSample=@dic_preprocess.syntheticInputs.writeSamplePack); diff --git a/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m b/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m index c35679f3c..7820d665b 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m @@ -2,7 +2,8 @@ % Expected caller: dic_preprocess.definition. Output owns the current payload % version, creation defaults, and validation. Side effects are none. function spec = projectSpec() - spec = labkit.app.project.Schema(Version=1, Create=@createProject, Validate=@validateProject); + spec = labkit.app.project.Schema(Version=1, Create=@createProject, ... + Validate=@validateProject, SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m b/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m index cf6e924ff..ae96a6e85 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m @@ -7,8 +7,8 @@ Title="Gamry Multi-DTA Plot Export GUI", ... DisplayName="Chrono Overlay", ... Family="Electrochem", ... - AppVersion="1.6.0", ... - Updated="2026-07-26", ... + AppVersion="1.6.1", ... + Updated="2026-07-30", ... Requirements=labkit.contract.requirements( ... "app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=chrono_overlay.projectSpec(), ... diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/projectSpec.m b/apps/electrochem/chrono_overlay/+chrono_overlay/projectSpec.m index 0dad8a9d2..3e80bd68a 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/projectSpec.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/projectSpec.m @@ -4,7 +4,7 @@ function spec = projectSpec() spec = labkit.app.project.Schema( ... Version=2, Create=@createProject, Validate=@validateProject, ... - Migrate=@migrateProject); + Migrate=@migrateProject, SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/electrochem/cic/+cic/definition.m b/apps/electrochem/cic/+cic/definition.m index 9ae130abe..ae2948e81 100644 --- a/apps/electrochem/cic/+cic/definition.m +++ b/apps/electrochem/cic/+cic/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_CIC_app", AppId="cic", ... Title="Gamry CIC GUI (Voltage Transient)", DisplayName="CIC", ... - Family="Electrochem", AppVersion="1.6.0", Updated="2026-07-26", ... + Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=cic.projectSpec(), CreateSession=@cic.createSession, ... Workbench=cic.workbench.buildLayout(), ... diff --git a/apps/electrochem/cic/+cic/projectSpec.m b/apps/electrochem/cic/+cic/projectSpec.m index 38458803a..471d54c13 100644 --- a/apps/electrochem/cic/+cic/projectSpec.m +++ b/apps/electrochem/cic/+cic/projectSpec.m @@ -3,7 +3,8 @@ % creation defaults, and validation. Side effects are none. function spec = projectSpec() spec = labkit.app.project.Schema(Version=1, ... - Create=@createProject, Validate=@validateProject); + Create=@createProject, Validate=@validateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/electrochem/csc/+csc/definition.m b/apps/electrochem/csc/+csc/definition.m index 0e3d865d2..a6fca855f 100644 --- a/apps/electrochem/csc/+csc/definition.m +++ b/apps/electrochem/csc/+csc/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_CSC_app", AppId="csc", ... Title="Gamry DTA GUI (literature CSC)", DisplayName="CSC", ... - Family="Electrochem", AppVersion="1.6.0", Updated="2026-07-26", ... + Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=csc.projectSpec(), CreateSession=@csc.createSession, ... Workbench=csc.workbench.buildLayout(), ... diff --git a/apps/electrochem/csc/+csc/projectSpec.m b/apps/electrochem/csc/+csc/projectSpec.m index b41c47e9c..b6c8c217a 100644 --- a/apps/electrochem/csc/+csc/projectSpec.m +++ b/apps/electrochem/csc/+csc/projectSpec.m @@ -3,7 +3,8 @@ % creation defaults, and validation. Side effects are none. function spec = projectSpec() spec = labkit.app.project.Schema(Version=1, ... - Create=@createProject, Validate=@validateProject); + Create=@createProject, Validate=@validateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/electrochem/eis/+eis/definition.m b/apps/electrochem/eis/+eis/definition.m index 77d5c17b8..6f8f8e0e7 100644 --- a/apps/electrochem/eis/+eis/definition.m +++ b/apps/electrochem/eis/+eis/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_EIS_app", AppId="eis", ... Title="Gamry EIS Multi-DTA Plot GUI", DisplayName="EIS Overlay", ... - Family="Electrochem", AppVersion="1.6.0", Updated="2026-07-26", ... + Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=eis.projectSpec(), CreateSession=@eis.createSession, ... Workbench=eis.workbench.buildLayout(), ... diff --git a/apps/electrochem/eis/+eis/projectSpec.m b/apps/electrochem/eis/+eis/projectSpec.m index 8069f0c6a..02896aada 100644 --- a/apps/electrochem/eis/+eis/projectSpec.m +++ b/apps/electrochem/eis/+eis/projectSpec.m @@ -4,7 +4,7 @@ function spec = projectSpec() spec = labkit.app.project.Schema(Version=2, ... Create=@createProject, Validate=@validateProject, ... - Migrate=@migrateProject); + Migrate=@migrateProject, SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/electrochem/vt_resistance/+vt_resistance/definition.m b/apps/electrochem/vt_resistance/+vt_resistance/definition.m index 5fe5d068a..14a80f463 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/definition.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_VTResistance_app", AppId="vt_resistance", ... Title="VT Steady Resistance", DisplayName="VT Resistance", ... - Family="Electrochem", AppVersion="1.6.0", Updated="2026-07-26", ... + Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=vt_resistance.projectSpec(), CreateSession=@vt_resistance.createSession, ... Workbench=vt_resistance.workbench.buildLayout(), ... diff --git a/apps/electrochem/vt_resistance/+vt_resistance/projectSpec.m b/apps/electrochem/vt_resistance/+vt_resistance/projectSpec.m index 404321cd8..e1ed7ecd0 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/projectSpec.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/projectSpec.m @@ -3,7 +3,8 @@ % version, creation defaults, and validation. Side effects are none. function spec = projectSpec() spec = labkit.app.project.Schema(Version=1, ... - Create=@createProject, Validate=@validateProject); + Create=@createProject, Validate=@validateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/gait/gait_analysis/+gait_analysis/definition.m b/apps/gait/gait_analysis/+gait_analysis/definition.m index fb70dc7fc..0474bcf5e 100644 --- a/apps/gait/gait_analysis/+gait_analysis/definition.m +++ b/apps/gait/gait_analysis/+gait_analysis/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_GaitAnalysis_app", AppId="gait_analysis", ... Title="Gait Analysis", DisplayName="Gait Analysis", Family="Gait", ... - AppVersion="2.2.0", Updated="2026-07-26", ... + AppVersion="2.2.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=gait_analysis.projectSpec(), CreateSession=@gait_analysis.createSession, ... Workbench=gait_analysis.workbench.buildLayout(), ... diff --git a/apps/gait/gait_analysis/+gait_analysis/projectSpec.m b/apps/gait/gait_analysis/+gait_analysis/projectSpec.m index 8bda3a40b..9e8c53693 100644 --- a/apps/gait/gait_analysis/+gait_analysis/projectSpec.m +++ b/apps/gait/gait_analysis/+gait_analysis/projectSpec.m @@ -4,7 +4,7 @@ function spec = projectSpec() spec = labkit.app.project.Schema(Version=3, ... Create=@createProject, Validate=@validateProject, ... - Migrate=@migrateProject); + Migrate=@migrateProject, SourceBindings="inputs.sources"); end function project = createProject() @@ -67,25 +67,51 @@ end function accepted = validateProject(project) - assert(isfield(project.inputs, 'sources'), ... + assert(isstruct(project) && isscalar(project) && ... + isfield(project, 'inputs') && isstruct(project.inputs) && ... + isscalar(project.inputs) && isfield(project.inputs, 'sources') && ... + isstruct(project.inputs.sources), ... 'gait_analysis:InvalidProject', 'Gait project source is missing.'); defaults = gait_analysis.analysisRun.defaultOptions(); fields = fieldnames(defaults); assert(all(isfield(project.parameters, fields)), ... 'gait_analysis:InvalidProject', 'Gait parameters are incomplete.'); - numericFields = ["frameRate", "pixelsPerUnit", "smoothWindow", ... - "detectionProminence", "detectionMinHeightSigma", ... - "minLiftOffIntervalSeconds", "minSwingFrames", ... - "maxSwingFrames", "minStepLength", "maxHipTranslation"]; - for name = numericFields - value = double(project.parameters.(name)); - assert(isscalar(value) && isfinite(value), ... - 'gait_analysis:InvalidProject', ... - 'Gait numeric parameter is invalid.'); - end + assert(finiteRange(project.parameters.frameRate, 0, Inf, false) && ... + finiteRange(project.parameters.pixelsPerUnit, 0, Inf, false) && ... + double(project.parameters.pixelsPerUnit) > 0 && ... + finiteRange(project.parameters.smoothWindow, 1, Inf, true) && ... + finiteRange(project.parameters.detectionProminence, 0, Inf, false) && ... + finiteRange(project.parameters.detectionMinHeightSigma, 0, Inf, false) && ... + finiteRange(project.parameters.minLiftOffIntervalSeconds, 0, Inf, false) && ... + finiteRange(project.parameters.minSwingFrames, 1, Inf, true) && ... + finiteRange(project.parameters.maxSwingFrames, 1, Inf, true) && ... + finiteRange(project.parameters.minStepLength, 0, Inf, false) && ... + finiteRange(project.parameters.maxHipTranslation, 0, Inf, false) && ... + project.parameters.minSwingFrames <= project.parameters.maxSwingFrames, ... + 'gait_analysis:InvalidProject', 'Gait numeric parameter is invalid.'); + textFields = ["iliacPoint", "hipPoint", "kneePoint", "anklePoint", ... + "footPoint", "unitName"]; + assert(all(arrayfun(@(name) isScalarText(project.parameters.(name)), ... + textFields)) && islogical(project.parameters.originAtFirstFrameFirstPoint) && ... + isscalar(project.parameters.originAtFirstFrameFirstPoint), ... + 'gait_analysis:InvalidProject', 'Gait text or logical parameter is invalid.'); assert(all(isfield(project.results, {'analysis', 'lastExport'})) && ... isstruct(project.results.analysis) && ... isscalar(project.results.analysis), ... 'gait_analysis:InvalidProject', 'Gait result state is invalid.'); accepted = true; end + +function tf = finiteRange(value, minimum, maximum, integerRequired) +tf = isnumeric(value) && isscalar(value) && isfinite(double(value)) && ... + double(value) >= minimum && double(value) <= maximum; +if integerRequired + tf = tf && double(value) == fix(double(value)); +end +end + +function tf = isScalarText(value) +tf = ((ischar(value) && isrow(value)) || ... + (isstring(value) && isscalar(value))) && ... + strlength(string(value)) > 0; +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/definition.m b/apps/image_measurement/batch_crop/+batch_crop/definition.m index 8b289cf9b..6ea2d4343 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/definition.m +++ b/apps/image_measurement/batch_crop/+batch_crop/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition(Entrypoint="labkit_BatchImageCrop_app", ... AppId="batch_crop", Title="Microscope Batch Image Crop", ... DisplayName="Batch Image Crop", Family="Image Measurement", ... - AppVersion="1.9.1", Updated="2026-07-29", ... + AppVersion="1.9.2", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=batch_crop.projectSpec(), CreateSession=@batch_crop.createSession, ... Workbench=batch_crop.workbench.buildLayout(), PresentWorkbench=@batch_crop.workbench.present, ... diff --git a/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m b/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m index 3d8e43c72..1dc697985 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m +++ b/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m @@ -4,7 +4,7 @@ function spec = projectSpec() spec = labkit.app.project.Schema(Version=3, ... Create=@createProject, Validate=@validateProject, ... - Migrate=@migrateProject); + Migrate=@migrateProject, SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/image_measurement/curvature/+curvature/definition.m b/apps/image_measurement/curvature/+curvature/definition.m index 7d253a4d7..31993278e 100644 --- a/apps/image_measurement/curvature/+curvature/definition.m +++ b/apps/image_measurement/curvature/+curvature/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_CurvatureMeasurement_app", AppId="curvature", ... Title="Image Curvature Measurement", DisplayName="Curvature Measurement", ... - Family="Image Measurement", AppVersion="1.6.0", Updated="2026-07-26", ... + Family="Image Measurement", AppVersion="1.6.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=curvature.projectSpec(), CreateSession=@curvature.createSession, ... Workbench=curvature.workbench.buildLayout(), ... diff --git a/apps/image_measurement/curvature/+curvature/projectSpec.m b/apps/image_measurement/curvature/+curvature/projectSpec.m index a6b6cc7df..4a59978c7 100644 --- a/apps/image_measurement/curvature/+curvature/projectSpec.m +++ b/apps/image_measurement/curvature/+curvature/projectSpec.m @@ -2,7 +2,8 @@ % project creation, validation, and each required version migration step. function spec = projectSpec() spec = labkit.app.project.Schema(Version=2, Create=@createProject, ... - Validate=@validateProject, Migrate=@migrateProject); + Validate=@validateProject, Migrate=@migrateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/definition.m b/apps/image_measurement/flir_thermal/+flir_thermal/definition.m index a5656fd37..6d8732bfe 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/definition.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/definition.m @@ -6,7 +6,7 @@ Entrypoint="labkit_FLIRThermal_app", ... AppId="flir_thermal", ... Title="FLIR Thermal Postprocess", DisplayName="FLIR Thermal", ... - Family="Image Measurement", AppVersion="1.6.0", Updated="2026-07-26", ... + Family="Image Measurement", AppVersion="1.6.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements( ... "app", ">=2 <3", "image", ">=2.0 <3", ... "thermal", ">=1.1 <2"), ... diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/projectSpec.m b/apps/image_measurement/flir_thermal/+flir_thermal/projectSpec.m index a4cbe8527..2d660b919 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/projectSpec.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/projectSpec.m @@ -1,7 +1,8 @@ % App-owned durable FLIR Thermal contract. App SDK runtime calls this single entry % for current project creation and validation; version 1 needs no migration. function spec = projectSpec() - spec = labkit.app.project.Schema(Version=1,Create=@createProject,Validate=@validateProject); + spec = labkit.app.project.Schema(Version=1, Create=@createProject, ... + Validate=@validateProject, SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/image_measurement/focus_stack/+focus_stack/definition.m b/apps/image_measurement/focus_stack/+focus_stack/definition.m index 2711526b4..19ff52332 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/definition.m +++ b/apps/image_measurement/focus_stack/+focus_stack/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_FocusStack_app", AppId="focus_stack", ... Title="Microscope Focus Stack Fusion", DisplayName="Focus Stack", ... - Family="Image Measurement", AppVersion="1.7.0", Updated="2026-07-26", ... + Family="Image Measurement", AppVersion="1.7.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=focus_stack.projectSpec(), CreateSession=@focus_stack.createSession, ... Workbench=focus_stack.workbench.buildLayout(), ... diff --git a/apps/image_measurement/focus_stack/+focus_stack/projectSpec.m b/apps/image_measurement/focus_stack/+focus_stack/projectSpec.m index f8d7f79cb..682c04647 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/projectSpec.m +++ b/apps/image_measurement/focus_stack/+focus_stack/projectSpec.m @@ -2,7 +2,8 @@ % for current project creation and validation; version 1 needs no migration. function spec = projectSpec() spec = labkit.app.project.Schema(Version=1, ... - Create=@createProject, Validate=@validateProject); + Create=@createProject, Validate=@validateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() project = struct(); @@ -26,11 +27,48 @@ end function accepted = validateProject(project) - assert(isfield(project.inputs, 'sources'), ... + assert(isstruct(project) && isscalar(project) && ... + isfield(project, 'inputs') && isstruct(project.inputs) && ... + isscalar(project.inputs) && isfield(project.inputs, 'sources') && ... + isstruct(project.inputs.sources), ... 'focus_stack:InvalidProject', 'Project sources are invalid.'); fields = {'fusionPreset', 'autoRegister', 'focusWindow', ... 'smoothRadius', 'uncertainBlend', 'outputFolder'}; assert(all(isfield(project.parameters, fields)), ... 'focus_stack:InvalidProject', 'Project parameters are incomplete.'); + parameters = project.parameters; + assert(isScalarText(parameters.fusionPreset) && ... + ismember(string(parameters.fusionPreset), ... + focus_stack.focusPreview.fusionPresetItems()) && ... + islogical(parameters.autoRegister) && isscalar(parameters.autoRegister) && ... + integerRange(parameters.focusWindow, 3, 99) && ... + mod(double(parameters.focusWindow), 2) == 1 && ... + integerRange(parameters.smoothRadius, 0, 50) && ... + finiteRange(parameters.uncertainBlend, 0, 100) && ... + isScalarText(parameters.outputFolder), ... + 'focus_stack:InvalidProject', 'Project parameters are invalid.'); + resultFields = {'lastRun', 'lastRunFingerprint', 'registrationLines', ... + 'lastExport', 'resultManifestPath'}; + assert(isfield(project, 'results') && isstruct(project.results) && ... + isscalar(project.results) && all(isfield(project.results, resultFields)) && ... + isstruct(project.results.lastRun) && isscalar(project.results.lastRun) && ... + isScalarText(project.results.lastRunFingerprint) && ... + isstring(project.results.registrationLines) && ... + isScalarText(project.results.resultManifestPath), ... + 'focus_stack:InvalidProject', 'Project result state is invalid.'); accepted = true; end + +function tf = integerRange(value, minimum, maximum) +tf = finiteRange(value, minimum, maximum) && double(value) == fix(double(value)); +end + +function tf = finiteRange(value, minimum, maximum) +tf = isnumeric(value) && isscalar(value) && isfinite(double(value)) && ... + double(value) >= minimum && double(value) <= maximum; +end + +function tf = isScalarText(value) +tf = (ischar(value) && isrow(value)) || ... + (isstring(value) && isscalar(value)); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/definition.m b/apps/image_measurement/image_enhance/+image_enhance/definition.m index 7050d8806..c431f811f 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/definition.m +++ b/apps/image_measurement/image_enhance/+image_enhance/definition.m @@ -4,7 +4,7 @@ function app = definition() app = labkit.app.Definition(Entrypoint="labkit_ImageEnhance_app", ... AppId="image_enhance", Title="Paper Image Enhance", DisplayName="Image Enhance", ... - Family="Image Measurement", AppVersion="1.8.0", Updated="2026-07-26", ... + Family="Image Measurement", AppVersion="1.8.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=image_enhance.projectSpec(), CreateSession=@image_enhance.createSession, ... Workbench=image_enhance.workbench.buildLayout(), PresentWorkbench=@image_enhance.workbench.present, ... diff --git a/apps/image_measurement/image_enhance/+image_enhance/projectSpec.m b/apps/image_measurement/image_enhance/+image_enhance/projectSpec.m index ade758967..6a0a8b613 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/projectSpec.m +++ b/apps/image_measurement/image_enhance/+image_enhance/projectSpec.m @@ -1,7 +1,8 @@ % App-owned durable Image Enhance contract. App SDK runtime uses this one entry % for version-1 project creation and validation; no migration is required. function spec = projectSpec() - spec = labkit.app.project.Schema(Version=1,Create=@createProject,Validate=@validateProject); + spec = labkit.app.project.Schema(Version=1, Create=@createProject, ... + Validate=@validateProject, SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/image_measurement/image_match/+image_match/definition.m b/apps/image_measurement/image_match/+image_match/definition.m index fa62cd7dc..9c001b27f 100644 --- a/apps/image_measurement/image_match/+image_match/definition.m +++ b/apps/image_measurement/image_match/+image_match/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_ImageMatch_app", AppId="image_match", ... Title="Paper Image Match", DisplayName="Image Match", ... - Family="Image Measurement", AppVersion="1.8.0", Updated="2026-07-26", ... + Family="Image Measurement", AppVersion="1.8.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=image_match.projectSpec(), CreateSession=@image_match.createSession, ... Workbench=image_match.workbench.buildLayout(), ... diff --git a/apps/image_measurement/image_match/+image_match/projectSpec.m b/apps/image_measurement/image_match/+image_match/projectSpec.m index 728b9d8b8..c2a2938e8 100644 --- a/apps/image_measurement/image_match/+image_match/projectSpec.m +++ b/apps/image_measurement/image_match/+image_match/projectSpec.m @@ -2,7 +2,8 @@ % for current project creation and validation; version 1 needs no migration. function spec = projectSpec() spec = labkit.app.project.Schema(Version=1, ... - Create=@createProject, Validate=@validateProject); + Create=@createProject, Validate=@validateProject, ... + SourceBindings=["inputs.reference", "inputs.sources"]); end function project = createProject() diff --git a/apps/image_measurement/video_marker/+video_marker/definition.m b/apps/image_measurement/video_marker/+video_marker/definition.m index b6c704381..12caa3a89 100644 --- a/apps/image_measurement/video_marker/+video_marker/definition.m +++ b/apps/image_measurement/video_marker/+video_marker/definition.m @@ -5,8 +5,8 @@ app = labkit.app.Definition( ... Entrypoint="labkit_VideoMarker_app", AppId="video_marker", ... Title="Video Marker", DisplayName="Video Marker", ... - Family="Image Measurement", AppVersion="1.7.0", ... - Updated="2026-07-26", ... + Family="Image Measurement", AppVersion="1.7.1", ... + Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=video_marker.projectSpec(), ... CreateSession=@video_marker.createSession, ... diff --git a/apps/image_measurement/video_marker/+video_marker/projectSpec.m b/apps/image_measurement/video_marker/+video_marker/projectSpec.m index 68f455819..899bea558 100644 --- a/apps/image_measurement/video_marker/+video_marker/projectSpec.m +++ b/apps/image_measurement/video_marker/+video_marker/projectSpec.m @@ -7,7 +7,7 @@ LegacyImports=struct( ... "videoMarkerProject", @importLegacyProject), ... CreateResume=@createResume, ... - ApplyResume=@applyResume); + ApplyResume=@applyResume, SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/labkit_core/figure_studio/+figure_studio/definition.m b/apps/labkit_core/figure_studio/+figure_studio/definition.m index b81e27917..202071520 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/definition.m +++ b/apps/labkit_core/figure_studio/+figure_studio/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_FigureStudio_app", AppId="figure_studio", ... Title="Figure Studio", Family="LabKit Core", ... - AppVersion="0.7.1", Updated="2026-07-29", ... + AppVersion="0.7.2", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=figure_studio.projectSpec(), ... CreateSession=@figure_studio.createSession, ... diff --git a/apps/labkit_core/figure_studio/+figure_studio/projectSpec.m b/apps/labkit_core/figure_studio/+figure_studio/projectSpec.m index cb4348c58..4c62bdfed 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/projectSpec.m +++ b/apps/labkit_core/figure_studio/+figure_studio/projectSpec.m @@ -4,7 +4,7 @@ function spec = projectSpec() spec = labkit.app.project.Schema(Version=4, ... Create=@createProject, Validate=@validateProject, ... - Migrate=@migrateProject); + Migrate=@migrateProject, SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m index 042a9fcb5..42e9b67ff 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m @@ -7,7 +7,7 @@ AppId="nerve_response_analysis", ... Title="Nerve Response Analysis", ... DisplayName="Nerve Response Analysis", Family="Neurophysiology", ... - AppVersion="1.6.0", Updated="2026-07-26", ... + AppVersion="1.6.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "rhs", ">=1.0 <2"), ... ProjectSchema=nerve_response_analysis.projectSpec(), ... CreateSession=@nerve_response_analysis.createSession, ... diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/projectSpec.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/projectSpec.m index ae281ca86..90e1e46aa 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/projectSpec.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/projectSpec.m @@ -3,7 +3,8 @@ % identities, run limits, and compact export state. function spec = projectSpec() spec = labkit.app.project.Schema(Version=2, ... - Create=@createProject, Validate=@validateProject, Migrate=@migrateProject); + Create=@createProject, Validate=@validateProject, Migrate=@migrateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m b/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m index 7104d3607..d8d0f2505 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m @@ -8,8 +8,8 @@ Title="Response Review Stats", ... DisplayName="Response Review Stats", ... Family="Neurophysiology", ... - AppVersion="1.6.0", ... - Updated="2026-07-26", ... + AppVersion="1.6.1", ... + Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=response_review_stats.projectSpec(), ... CreateSession=@response_review_stats.createSession, ... diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/projectSpec.m b/apps/neurophysiology/response_review_stats/+response_review_stats/projectSpec.m index 0ae19e315..c1d431b15 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/projectSpec.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/projectSpec.m @@ -3,7 +3,8 @@ % windows and durable export state. function spec = projectSpec() spec = labkit.app.project.Schema(Version=2, ... - Create=@createProject, Validate=@validateProject, Migrate=@migrateProject); + Create=@createProject, Validate=@validateProject, Migrate=@migrateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m b/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m index 016a4b935..104fb60ee 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m @@ -4,7 +4,7 @@ function app = definition() app = labkit.app.Definition(Entrypoint="labkit_RHSPreview_app", ... AppId="rhs_preview", Title="RHS Preview", DisplayName="RHS Preview", ... - Family="Neurophysiology", AppVersion="1.6.0", Updated="2026-07-26", ... + Family="Neurophysiology", AppVersion="1.6.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "rhs", ">=1.0 <2"), ... ProjectSchema=rhs_preview.projectSpec(), CreateSession=@rhs_preview.createSession, ... Workbench=rhs_preview.workbench.buildLayout(), PresentWorkbench=@rhs_preview.workbench.present, ... diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/projectSpec.m b/apps/neurophysiology/rhs_preview/+rhs_preview/projectSpec.m index ff89f9ad3..82402b66e 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/projectSpec.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/projectSpec.m @@ -2,7 +2,9 @@ % source-schema migration, then validates preview settings, role-based source % collections, durable annotations, and compact export state. function spec = projectSpec() - spec = labkit.app.project.Schema(Version=2, Create=@createProject, Validate=@validateProject, Migrate=@migrateProject); + spec = labkit.app.project.Schema(Version=2, Create=@createProject, ... + Validate=@validateProject, Migrate=@migrateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/definition.m b/apps/statistics/ttest_wizard/+ttest_wizard/definition.m index 4c0e7b898..1470fadbb 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/definition.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/definition.m @@ -12,8 +12,8 @@ Title="T-Test Wizard", ... DisplayName="T-Test Wizard", ... Family="Statistics", ... - AppVersion="1.3.0", ... - Updated="2026-07-26", ... + AppVersion="1.3.1", ... + Updated="2026-07-30", ... Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=ttest_wizard.projectSpec(), ... CreateSession=@ttest_wizard.createSession, ... diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/projectSpec.m b/apps/statistics/ttest_wizard/+ttest_wizard/projectSpec.m index 68bb140c2..e6da61c82 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/projectSpec.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/projectSpec.m @@ -8,7 +8,7 @@ spec = labkit.app.project.Schema( ... Version=2, Create=@createProject, Validate=@validateProject, ... - Migrate=@migrateProject); + Migrate=@migrateProject, SourceBindings="inputs.sources"); end function project = createProject() diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/peakMethodValue.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/peakMethodValue.m index bbfe33883..f8cd894a1 100644 --- a/apps/wearable/ecg_print/+ecg_print/+analysisRun/peakMethodValue.m +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/peakMethodValue.m @@ -10,7 +10,10 @@ method = "pan-tompkins"; case "Local peaks" method = "local"; - otherwise + case "QRS streaming" method = "qrs-streaming"; + otherwise + error('ecg_print:UnsupportedPeakMethodLabel', ... + 'Unsupported ECG peak-method label: %s.', string(label)); end end diff --git a/apps/wearable/ecg_print/+ecg_print/definition.m b/apps/wearable/ecg_print/+ecg_print/definition.m index 0dbd58c5e..4811f5fbe 100644 --- a/apps/wearable/ecg_print/+ecg_print/definition.m +++ b/apps/wearable/ecg_print/+ecg_print/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_ECGPrint_app", AppId="ecg_print", ... Title="ECG Signal Print + SNR Explorer", DisplayName="ECG Print", ... - Family="Wearable", AppVersion="1.6.0", Updated="2026-07-26", ... + Family="Wearable", AppVersion="1.6.1", Updated="2026-07-30", ... Requirements=labkit.contract.requirements( ... "app", ">=2 <3", "biosignal", ">=1.0 <2"), ... ProjectSchema=ecg_print.projectSpec(), ... diff --git a/apps/wearable/ecg_print/+ecg_print/projectSpec.m b/apps/wearable/ecg_print/+ecg_print/projectSpec.m index ccf514dec..1c7138e65 100644 --- a/apps/wearable/ecg_print/+ecg_print/projectSpec.m +++ b/apps/wearable/ecg_print/+ecg_print/projectSpec.m @@ -3,7 +3,8 @@ % compact result records before rebuilding decoded signal state. function spec = projectSpec() spec = labkit.app.project.Schema(Version=2, ... - Create=@createProject, Validate=@validateProject, Migrate=@migrateProject); + Create=@createProject, Validate=@validateProject, Migrate=@migrateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() @@ -61,6 +62,9 @@ assert(isscalar(string(parameters.(fieldName))), ... 'ecg_print:InvalidProject', 'ECG text parameter is invalid.'); end + assert(ismember(string(parameters.peakMethod), ... + ["Pan-Tompkins", "Local peaks", "QRS streaming"]), ... + 'ecg_print:InvalidProject', 'ECG peak method is invalid.'); assert(all(isfield(project.results, ... {'lastAnalysis', 'lastSegmentExport', 'lastWaveformExport'})) && ... isstruct(project.results.lastAnalysis) && ... diff --git a/docs/apps/gait/gait-analysis/README.md b/docs/apps/gait/gait-analysis/README.md index 13b5c7340..1dce2570e 100644 --- a/docs/apps/gait/gait-analysis/README.md +++ b/docs/apps/gait/gait-analysis/README.md @@ -51,6 +51,9 @@ The pose source, analysis options, computed tables/events, and export record are saved. Decoded pose data and the currently selected step are reconstructed after load. Projects from the older stride-naming contract invalidate results whose scientific meaning changed, so rerun the analysis before export. +Current projects validate finite numeric ranges, integer frame/window fields, +the swing-frame ordering, nonempty role names, and logical origin settings +before reconstructing a session. ## Two-Stage Workflow diff --git a/docs/apps/image-measurement/focus-stack/README.md b/docs/apps/image-measurement/focus-stack/README.md index 91517eea5..bf08e8622 100644 --- a/docs/apps/image-measurement/focus-stack/README.md +++ b/docs/apps/image-measurement/focus-stack/README.md @@ -18,6 +18,10 @@ otherwise invalid frames before running. The first image defines working geometry; differently sized inputs are resized and the result records how many images required resizing. +Project restore validates the selected fusion preset, odd focus-window range, +smoothing and blend ranges, logical registration flag, and durable result +shape before rebuilding transient image data. + ## Basic Workflow 1. Load at least two focal planes. diff --git a/docs/apps/wearable/ecg-print/README.md b/docs/apps/wearable/ecg-print/README.md index 7c5aa2d11..2e1c38815 100644 --- a/docs/apps/wearable/ecg-print/README.md +++ b/docs/apps/wearable/ecg-print/README.md @@ -85,6 +85,10 @@ on every tab. | Smooth beats | 15 | Smoothing span used in exported per-segment trends | | Template plot | Template + residual band | Alternative view is template plus individual segments | +Saved projects accept exactly the three displayed peak-method labels. A +corrupt or hand-edited project containing another label is rejected instead +of silently falling back to a detector. + Peak polarity is selected automatically. The default detector threshold is 2.8 standard deviations inside the app calculation. diff --git a/docs/framework/guides/runtime.md b/docs/framework/guides/runtime.md index 5f976d21f..4aae3b0ee 100644 --- a/docs/framework/guides/runtime.md +++ b/docs/framework/guides/runtime.md @@ -136,7 +136,12 @@ end ``` Portable source records are opaque. Resolve their paths only at IO boundaries. -Saved projects store portable references and use runtime relinking. +Saved projects store portable references and use runtime relinking. A project +Schema declares each durable source location with project-relative +`SourceBindings`, such as `"inputs.sources"`; an explicit empty list means the +project has no sources. Omitted bindings retain layout-derived inference for +older external App definitions, while built-in Apps use explicit declarations +so persistence does not depend on UI layout. ## Typed Events @@ -252,7 +257,10 @@ confirmation choices. `Title` controls the dialog title, `DefaultChoice` selects the Enter-key action, and `CancelChoice` is returned when the user dismisses the dialog. All three named choices must be members of the declared nonempty unique choice row. File and folder methods remain separate because -they return paths and use platform file choosers. +they return paths and use platform file choosers. Successful input and output +choices are remembered separately across App windows. A valid App-supplied +start path takes precedence; cancellation or an invalid path does not replace +the last successful folder. An App-specific project button may choose a MAT file and return `callbackContext.restoreProjectDocument(filepath)`. The context prepares the @@ -335,6 +343,12 @@ through the App's ordinary controls. ordered version migration. Runtime owns the project envelope, atomic save, restore, recovery, and relinking loop. +After a document is saved or accepted from restore, Runtime fingerprints the +on-disk file. Saving again to the same path is rejected if another program has +changed that file; **Save As** remains available. This prevents a stale App +window from silently overwriting external edits without changing the project +payload format. + ### Saved-project compatibility boundary Every save writes exactly one `labkitProject` variable using the current App diff --git a/docs/history/records/2026/07/LK-20260730-v4-stability-contract-closure.md b/docs/history/records/2026/07/LK-20260730-v4-stability-contract-closure.md new file mode 100644 index 000000000..7165cb11f --- /dev/null +++ b/docs/history/records/2026/07/LK-20260730-v4-stability-contract-closure.md @@ -0,0 +1,111 @@ +# V4 stability contracts become explicit and machine-verifiable + +```labkit-change +id: LK-20260730-v4-stability-contract-closure +date: 2026-07-30 +sequence: 166 +type: fix +compatibility: compatible +component: `labkit.app` | `2.0.3 -> 2.1.0` +component: `labkit.biosignal` | `1.0.3 -> 1.0.4` +component: `labkit.dta` | `3.0.0 -> 3.1.0` +component: `labkit.image` | `2.0.3 -> 2.0.4` +component: `labkit.rhs` | `1.0.3 -> 1.0.4` +component: `labkit.thermal` | `1.1.2 -> 1.1.3` +component: `labkit_launcher` | `1.8.1 -> 1.8.2` +component: `labkit_DICPostprocess_app` | `1.6.0 -> 1.6.1` +component: `labkit_DICPreprocess_app` | `1.7.0 -> 1.7.1` +component: `labkit_ChronoOverlay_app` | `1.6.0 -> 1.6.1` +component: `labkit_CIC_app` | `1.6.0 -> 1.6.1` +component: `labkit_CSC_app` | `1.6.0 -> 1.6.1` +component: `labkit_EIS_app` | `1.6.0 -> 1.6.1` +component: `labkit_VTResistance_app` | `1.6.0 -> 1.6.1` +component: `labkit_GaitAnalysis_app` | `2.2.0 -> 2.2.1` +component: `labkit_BatchImageCrop_app` | `1.9.1 -> 1.9.2` +component: `labkit_CurvatureMeasurement_app` | `1.6.0 -> 1.6.1` +component: `labkit_FLIRThermal_app` | `1.6.0 -> 1.6.1` +component: `labkit_FocusStack_app` | `1.7.0 -> 1.7.1` +component: `labkit_ImageEnhance_app` | `1.8.0 -> 1.8.1` +component: `labkit_ImageMatch_app` | `1.8.0 -> 1.8.1` +component: `labkit_VideoMarker_app` | `1.7.0 -> 1.7.1` +component: `labkit_FigureStudio_app` | `0.7.1 -> 0.7.2` +component: `labkit_NerveResponseAnalysis_app` | `1.6.0 -> 1.6.1` +component: `labkit_ResponseReviewStats_app` | `1.6.0 -> 1.6.1` +component: `labkit_RHSPreview_app` | `1.6.0 -> 1.6.1` +component: `labkit_TTestWizard_app` | `1.3.0 -> 1.3.1` +component: `labkit_ECGPrint_app` | `1.6.0 -> 1.6.1` +scope: V4 stability contract closure +scope: Project source ownership and external overwrite protection +scope: Scientific input validation and machine-readable failures +scope: Native dialog path memory and Launcher delegation +scope: Repository Skill contracts +``` + +## Context + +The V4 architecture had stable ownership boundaries, but several behaviors +still relied on inference or permissive fallback: project source fields were +derived from UI layout, option structures ignored unknown fields, DTA failures +required message parsing, and invalid scientific settings could cross a saved +project boundary. Runtime V2 had also lost the prior cross-window input and +output folder memory. + +## Decision and rationale + +Close those gaps with additive metadata, stable private mechanics, focused +validation, and compatibility fallbacks. Built-in Apps explicitly declare +portable-source fields, while external definitions that omit the declaration +retain layout inference. Existing project payloads and App entrypoints remain +unchanged. + +## Changes + +- Added project `SourceBindings`, accepted-document fingerprints, and external + overwrite rejection with an unaffected Save As path. +- Restored separate last-successful input/output folder preferences across App + windows and preserved explicit valid start-path precedence. +- Closed documented option structures in biosignal, image, RHS, and thermal + facades; added DTA status codes and strict ECG, gait, focus, and thermal + invalid-input handling. +- Replaced append-only runtime status history with one current status value and + avoided redundant Launcher path refresh on a correctly resolved install. +- Added repository Skill manifests, activation examples, deterministic + validation, CI routing, and a scientific-change workflow; compressed + promoted experience out of the reservoir. + +## User and data impact + +Ordinary valid workflows and numerical outputs are preserved. App file +choosers again begin in the last successful folder when no valid start path is +provided. Stale windows no longer silently overwrite a project file changed +outside LabKit. Invalid or hand-edited settings fail earlier with stable +identifiers or codes. + +## Compatibility and migration + +The change is backward compatible. Existing project payload versions, saved +source records, result schemas, and exports require no migration. Omitted +`SourceBindings` keep the former inference behavior, and the restored dialog +preferences reuse the published `LabKit/LastInputFolder` and +`LabKit/LastOutputFolder` keys. + +## Validation + +Focused App, facade, project, Launcher, repository-policy, and documentation +specifications cover the changed contracts. The final branch gate selects the +complete base-to-head evidence closure; required pull-request CI owns the +supported MATLAB platform matrix. On Windows R2025b Update 3, the same warm +`labkit_launcher("list")` profiler scenario decreased from about 0.614 seconds +before the fast path to 0.243 seconds after it; nine ordinary warm calls had a +0.133-second median. + +## Evidence + +The delivery PR records exact local test artifacts, documentation checks, +required CI results, compatibility decisions, and the final squash commit. + +## Known limitations and follow-up + +Automated tests cannot assess native dialog appearance or real laboratory data +quality. Those remain manual platform and domain-review boundaries; no known +code migration debt remains from this stability batch. diff --git a/docs/libraries/biosignal/README.md b/docs/libraries/biosignal/README.md index 628746d16..1c93d2e31 100644 --- a/docs/libraries/biosignal/README.md +++ b/docs/libraries/biosignal/README.md @@ -47,6 +47,8 @@ opts = struct('timeColumn', 'timestamp', 'timeUnit', 'milliseconds'); ``` Useful delimited-table options include `headerLine`, `hasHeader`, `timeColumn`, `timeUnit`, `signalColumns`, `fallbackFs`, and `timeRepair`. +Option structures are closed contracts. Unknown fields or non-struct values +raise `labkit:biosignal:InvalidOptions` instead of being silently ignored. ### `readRecording` Options diff --git a/docs/libraries/dta/README.md b/docs/libraries/dta/README.md index b2fdcfc7f..adf3a90b1 100644 --- a/docs/libraries/dta/README.md +++ b/docs/libraries/dta/README.md @@ -72,6 +72,10 @@ end ``` `report.statuses` contains one status in the same order as the input paths. +Each status and failed record includes a stable `code`: `ok`, `missing_file`, +`unsupported_content`, `kind_mismatch`, or `parse_failure`. Use the code for +program logic and retain `message` for people; message wording is not a machine +contract. `items` contains successful items only, so use the status array when you need to match a result to every original position. diff --git a/docs/libraries/image/README.md b/docs/libraries/image/README.md index 40a25de1c..7f512087b 100644 --- a/docs/libraries/image/README.md +++ b/docs/libraries/image/README.md @@ -73,6 +73,8 @@ Applications add: log text Use `labkit.image.readFiles` when an app needs generic source-image records. +Its option structure is a closed contract: unknown fields or a non-struct +value raise `labkit:image:InvalidOptions`. Apps may copy the returned `path`, `name`, and `image` fields into their own item structures. Specialized formats and result structures remain documented by the app that uses them. diff --git a/docs/libraries/rhs/README.md b/docs/libraries/rhs/README.md index 7c5e26995..f13b42cdf 100644 --- a/docs/libraries/rhs/README.md +++ b/docs/libraries/rhs/README.md @@ -40,6 +40,9 @@ if status.ok end ``` +RHS option structures are closed contracts. Unknown fields or non-struct +values raise `labkit:rhs:InvalidOptions`. + `window.values` is always samples-by-channels. `window.timeSec` contains the timestamps stored in the recording, converted to seconds. diff --git a/docs/libraries/thermal/README.md b/docs/libraries/thermal/README.md index 1b94448cc..781f19400 100644 --- a/docs/libraries/thermal/README.md +++ b/docs/libraries/thermal/README.md @@ -90,6 +90,11 @@ previewing data, but the result is not evidence that the assumed emissivity, distance, humidity, or temperatures match the experiment. Check those values before using the temperatures in quantitative analysis. +When supplied, emissivity and infrared-window transmission must be finite +scalars in `(0, 1]`, object distance must be nonnegative, and relative +humidity accepts either a fraction in `[0, 1]` or a percentage in `[0, 100]`. +Invalid supplied calibration is rejected rather than replaced by a default. + Choose `"planck-basic"` only when you intentionally want the conversion based on the five Planck constants alone: @@ -115,6 +120,9 @@ Values outside `Limits` are clipped to the end colours. Supported palettes are also offers display-only log and gamma controls; those controls are app features and do not alter exported temperature values. +Option structures across this facade are closed contracts: unknown fields and +non-struct option values raise `labkit:thermal:InvalidOptions`. + ## Supported Files and Limitations The current reader supports FLIR radiometric JPEG and RJPEG files with an diff --git a/labkit_launcher.m b/labkit_launcher.m index 0bcd93cb4..bff225a19 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -31,12 +31,15 @@ return; end try - addpath(root, "-begin"); - rehash; dispatcher = str2func("labkit.app.internal.launcher.dispatch"); if ~resolvesInstalledDispatch(dispatcher, entry) - error("labkit_launcher:InstalledEntryMismatch", ... - "The installed launcher entry does not resolve from this LabKit installation."); + addpath(root, "-begin"); + rehash; + dispatcher = str2func("labkit.app.internal.launcher.dispatch"); + if ~resolvesInstalledDispatch(dispatcher, entry) + error("labkit_launcher:InstalledEntryMismatch", ... + "The installed launcher entry does not resolve from this LabKit installation."); + end end if nargout == 0 dispatcher(root, varargin{:}); diff --git a/tests/+labkittest/classifyPath.m b/tests/+labkittest/classifyPath.m index a377a0b68..3b325cfb8 100644 --- a/tests/+labkittest/classifyPath.m +++ b/tests/+labkittest/classifyPath.m @@ -25,6 +25,11 @@ "validation framework or CI policy behavior"); return; end + if startsWith(file, ".agents/skills/") && endsWith(file, ".m") + classification = mapped(file, "validation-framework", "system/build", ... + "Skill-owned MATLAB automation executes repository behavior"); + return; + end if startsWith(file, ".agents/") || file == "AGENTS.md" || ... endsWith(file, "/AGENTS.md") || file == ".gitignore" classification = mapped(file, "repository-policy", "system/repository", ... diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 42d3f1b4b..1b4e19cc1 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -19,6 +19,13 @@ for defaults, validation, and migration semantics. App conformance separately validates each synthetic sample pack, keeps Debug startup on the clean default project, and launches the synthetic project through the native adapter. +A fixture constrains only the contract under test; do not add unrelated facade +version ranges or compatibility assertions that can turn a focused fixture +into a stale cross-component test. A direct test that constructs native Runtime +owns and restores its hidden-visibility fixture even when its catalog tag is +GUI. Callback tests use a contract-complete context or a narrow fake covering +every operation the callback invokes. + `headless`, `gui`, `isolated`, and `coverage` are full catalog profiles. `changedFast` is focused local evidence: an App or facade path maps to its bounded closure, while framework, build, and repository-policy paths map to diff --git a/tests/specs/apps/gait/gait_analysis/project/GaitProjectSpec.m b/tests/specs/apps/gait/gait_analysis/project/GaitProjectSpec.m index b8296cf24..dbdd8344d 100644 --- a/tests/specs/apps/gait/gait_analysis/project/GaitProjectSpec.m +++ b/tests/specs/apps/gait/gait_analysis/project/GaitProjectSpec.m @@ -26,5 +26,28 @@ function requiresSourcesAndMigratesVersionOneOptionNames(testCase) [0.25, 4, 40, 3, 9]); testCase.verifyFalse(isfield(migrated.parameters, 'minStride')); end + + function rejectsImpossibleOrContradictoryParameters(testCase) + spec = gait_analysis.projectSpec(); + project = spec.Create(); + testCase.verifyTrue(spec.Validate(project)); + + cases = { ... + "pixelsPerUnit", 0; ... + "smoothWindow", 2.5; ... + "detectionProminence", -1; ... + "minSwingFrames", 0}; + for k = 1:size(cases, 1) + invalid = project; + invalid.parameters.(cases{k, 1}) = cases{k, 2}; + testCase.verifyError(@() spec.Validate(invalid), ... + "gait_analysis:InvalidProject"); + end + invalid = project; + invalid.parameters.minSwingFrames = 20; + invalid.parameters.maxSwingFrames = 10; + testCase.verifyError(@() spec.Validate(invalid), ... + "gait_analysis:InvalidProject"); + end end end diff --git a/tests/specs/apps/image_measurement/focus_stack/project/FocusStackProjectSpec.m b/tests/specs/apps/image_measurement/focus_stack/project/FocusStackProjectSpec.m index ec04aad0d..2cf68bb98 100644 --- a/tests/specs/apps/image_measurement/focus_stack/project/FocusStackProjectSpec.m +++ b/tests/specs/apps/image_measurement/focus_stack/project/FocusStackProjectSpec.m @@ -11,5 +11,26 @@ function createsAValidSourceFreeProjectWithNoMigrationDebt(testCase) testCase.verifyEqual(project.parameters.outputFolder, ""); testCase.verifyEmpty(spec.Migrate); end + + function rejectsMalformedParametersAndResultState(testCase) + spec = focus_stack.projectSpec(); + project = spec.Create(); + cases = { ... + "fusionPreset", "unknown"; ... + "autoRegister", 1; ... + "focusWindow", 4; ... + "smoothRadius", 51; ... + "uncertainBlend", 101}; + for k = 1:size(cases, 1) + invalid = project; + invalid.parameters.(cases{k, 1}) = cases{k, 2}; + testCase.verifyError(@() spec.Validate(invalid), ... + "focus_stack:InvalidProject"); + end + invalid = project; + invalid.results.lastRun = struct([]); + testCase.verifyError(@() spec.Validate(invalid), ... + "focus_stack:InvalidProject"); + end end end diff --git a/tests/specs/apps/wearable/ecg_print/analysisRun/EcgPrintScientificSpec.m b/tests/specs/apps/wearable/ecg_print/analysisRun/EcgPrintScientificSpec.m index 3c741b22d..c20e89047 100644 --- a/tests/specs/apps/wearable/ecg_print/analysisRun/EcgPrintScientificSpec.m +++ b/tests/specs/apps/wearable/ecg_print/analysisRun/EcgPrintScientificSpec.m @@ -17,8 +17,13 @@ function sanitizesParametersAndMapsPeakMethodLabels(testCase) testCase.verifyEqual(actual.smoothBeats, 15); testCase.verifyEqual(ecg_print.analysisRun.peakMethodValue('Local peaks'), ... "local"); - testCase.verifyEqual(ecg_print.analysisRun.peakMethodValue('unexpected'), ... + testCase.verifyEqual(ecg_print.analysisRun.peakMethodValue('Pan-Tompkins'), ... + "pan-tompkins"); + testCase.verifyEqual(ecg_print.analysisRun.peakMethodValue('QRS streaming'), ... "qrs-streaming"); + testCase.verifyError( ... + @() ecg_print.analysisRun.peakMethodValue('unexpected'), ... + 'ecg_print:UnsupportedPeakMethodLabel'); end function derivesGuiIndependentSignalProducts(testCase) diff --git a/tests/specs/apps/wearable/ecg_print/project/EcgPrintProjectSpec.m b/tests/specs/apps/wearable/ecg_print/project/EcgPrintProjectSpec.m index 6a8bc0eb1..ef043b354 100644 --- a/tests/specs/apps/wearable/ecg_print/project/EcgPrintProjectSpec.m +++ b/tests/specs/apps/wearable/ecg_print/project/EcgPrintProjectSpec.m @@ -15,5 +15,15 @@ function migratesVersionOneSourceIntoTheCanonicalCollection(testCase) testCase.verifyFalse(isfield(migrated.inputs, "source")); testCase.verifyEqual(spec.Version, 2); end + + function rejectsUnknownPeakMethodWithoutChangingSupportedProjects(testCase) + spec = ecg_print.projectSpec(); + project = spec.Create(); + + testCase.verifyTrue(spec.Validate(project)); + project.parameters.peakMethod = "unexpected"; + testCase.verifyError(@() spec.Validate(project), ... + 'ecg_print:InvalidProject'); + end end end diff --git a/tests/specs/framework/app/AppSdkSpec.m b/tests/specs/framework/app/AppSdkSpec.m index c044efe23..758d13505 100644 --- a/tests/specs/framework/app/AppSdkSpec.m +++ b/tests/specs/framework/app/AppSdkSpec.m @@ -108,6 +108,42 @@ function nativeFileValuesPreserveScalarPathsAndCancellation(testCase) testCase.verifyTrue(folderCancellation.Cancelled); end + function nativeDialogsRememberLastSuccessfulInputAndOutputFolders(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + inputFolder = fullfile(root, "input"); + outputFolder = fullfile(root, "output"); + mkdir(inputFolder); + mkdir(outputFolder); + inputMemory = capturePreference("LastInputFolder"); + outputMemory = capturePreference("LastOutputFolder"); + cleanup = onCleanup(@() restoreDialogPreferences( ... + inputMemory, outputMemory)); + + labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + "input", inputFolder); + labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + "output", outputFolder); + + testCase.verifyEqual(string( ... + labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + "input", "")), string(inputFolder)); + testCase.verifyEqual(string( ... + labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + "output", "")), string(outputFolder)); + testCase.verifyEqual(string( ... + labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + "input", outputFolder)), string(outputFolder), ... + "An explicit valid start folder must override remembered state."); + labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + "input", fullfile(root, "missing")); + testCase.verifyEqual(string( ... + labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + "input", "")), string(inputFolder), ... + "An invalid or cancelled choice must preserve remembered state."); + clear cleanup + end + function sourceResolutionTreatsCharacterIdAsOneIdentifier(testCase) backend = struct("sourcePaths", @sourcePathsProbe); context = labkit.app.internal.CallbackContextFactory.create(backend); @@ -240,6 +276,55 @@ function restoresDeclaredMigrationsAndReadOnlyImports(testCase) "labkit:app:runtime:NewerProjectPayload"); clear cleanup end + + function preventsExternalProjectOverwriteAndAllowsSaveAs(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + layout = labkit.app.layout.workbench({}); + schema = labkit.app.project.Schema(Version=1, ... + Create=@createProject, Validate=@validateProject); + app = AppSdkSpec.definition(layout, "ProjectSchema", schema); + journal = labkittest.temporarySessionJournal(app, folder); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + app, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + original = fullfile(folder, "project.mat"); + alternate = fullfile(folder, "alternate.mat"); + + runtime.saveProject(runtime.State, original); + external = load(original, "labkitProject"); + labkitProject = external.labkitProject; + labkitProject.payload.parameters.gain = 7; + save(original, "labkitProject"); + + testCase.verifyError( ... + @() runtime.saveProject(runtime.State, original), ... + "labkit:app:runtime:ProjectWriteConflict"); + runtime.saveProject(runtime.State, alternate); + testCase.verifyTrue(isfile(alternate)); + clear cleanup + end + + function explicitSourceBindingsPreserveLegacyInference(testCase) + inferred = labkit.app.project.Schema(Version=1, ... + Create=@createSourceProject, Validate=@validateSourceProject); + explicit = labkit.app.project.Schema(Version=1, ... + Create=@createSourceProject, Validate=@validateSourceProject, ... + SourceBindings="inputs.sources"); + none = labkit.app.project.Schema(Version=1, ... + Create=@createProject, Validate=@validateProject, ... + SourceBindings=strings(1, 0)); + + testCase.verifyTrue(inferred.UsesInferredSourceBindings); + testCase.verifyFalse(explicit.UsesInferredSourceBindings); + testCase.verifyEqual(explicit.SourceBindings, "inputs.sources"); + testCase.verifyFalse(none.UsesInferredSourceBindings); + testCase.verifyEmpty(none.SourceBindings); + testCase.verifyError(@() labkit.app.project.Schema( ... + Version=1, Create=@createProject, Validate=@validateProject, ... + SourceBindings="project.inputs.sources"), ... + "labkit:app:contract:InvalidValue"); + end end methods (Test, TestTags = {'Contract:source', 'Env:hidden-gui'}) @@ -402,3 +487,23 @@ function exposesSyntheticInputGenerationAsAnOrdinaryTool(testCase) project = createCurrentProject(); project.parameters.gain = legacy.gain; end + +function memory = capturePreference(name) +memory = struct("name", name, "existed", ispref("LabKit", name), "value", []); +if memory.existed + memory.value = getpref("LabKit", name); +end +end + +function restoreDialogPreferences(inputMemory, outputMemory) +restorePreference(inputMemory); +restorePreference(outputMemory); +end + +function restorePreference(memory) +if memory.existed + setpref("LabKit", memory.name, memory.value); +elseif ispref("LabKit", memory.name) + rmpref("LabKit", memory.name); +end +end diff --git a/tests/specs/framework/app/SemanticDiagnosticsSpec.m b/tests/specs/framework/app/SemanticDiagnosticsSpec.m index 37bed439e..d04c5690c 100644 --- a/tests/specs/framework/app/SemanticDiagnosticsSpec.m +++ b/tests/specs/framework/app/SemanticDiagnosticsSpec.m @@ -16,7 +16,8 @@ function semanticLogsDriveStatusAndDeveloperEvents(testCase) runtime.invokeAction("run"); - testCase.verifyEqual(runtime.StatusLog(end), "Semantic status."); + testCase.verifyEqual(runtime.CurrentStatus, "Semantic status."); + testCase.verifySize(runtime.CurrentStatus, [1 1]); events = runtime.diagnosticEvents(); status = events(string({events.eventName}) == "probe.status"); checkpoint = events(string({events.eventName}) == "probe.checkpoint"); diff --git a/tests/specs/framework/biosignal/BiosignalFacadeSpec.m b/tests/specs/framework/biosignal/BiosignalFacadeSpec.m index 731a88d1e..41ad318a1 100644 --- a/tests/specs/framework/biosignal/BiosignalFacadeSpec.m +++ b/tests/specs/framework/biosignal/BiosignalFacadeSpec.m @@ -2,6 +2,15 @@ %BIOSIGNALFACADESPEC Specify public recording and ECG-analysis behavior. methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function rejectsUnknownOrNonstructPublicOptions(testCase) + signal = BiosignalFacadeSpec.syntheticEcgSignal(); + testCase.verifyError(@() labkit.biosignal.detectEcgPeaks( ... + signal, struct("methd", "local")), ... + "labkit:biosignal:InvalidOptions"); + testCase.verifyError(@() labkit.biosignal.filterSignal( ... + signal, "bandpass"), "labkit:biosignal:InvalidOptions"); + end + function importsDelimitedRecordingsWithExplicitOrSyntheticTime(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; diff --git a/tests/specs/framework/dta/DtaFacadeSpec.m b/tests/specs/framework/dta/DtaFacadeSpec.m index 7386219e4..f821c1dbc 100644 --- a/tests/specs/framework/dta/DtaFacadeSpec.m +++ b/tests/specs/framework/dta/DtaFacadeSpec.m @@ -30,6 +30,7 @@ function loadsCanonicalItemsAndReportsRecoverableFailures(testCase) [items, report] = labkit.dta.loadFiles({chrono, eis, cvct}, "auto"); testCase.verifyTrue(chronoStatus.ok, chronoStatus.message); + testCase.verifyEqual(chronoStatus.code, "ok"); testCase.verifyEqual(chronoItem.type, "chrono"); testCase.verifyTrue(all(isfield(chronoItem, {'t_s', 'Vf_V', 'Im_A'}))); testCase.verifyFalse(any(isfield(chronoItem, {'t', 'Vf', 'Im'}))); @@ -43,10 +44,23 @@ function loadsCanonicalItemsAndReportsRecoverableFailures(testCase) testCase.verifyNotEmpty(cvctItem.curves); testCase.verifyEmpty(mismatch); testCase.verifyFalse(mismatchStatus.ok); + testCase.verifyEqual(mismatchStatus.code, "kind_mismatch"); testCase.verifyEqual(mismatchStatus.kind, "eis"); testCase.verifySubstring(mismatchStatus.message, "Expected chrono DTA"); testCase.verifyEqual(numel(items), 3); testCase.verifyEqual([report.nRequested, report.nLoaded, report.nFailed], [3, 3, 0]); end + + function reportsStableMachineReadableFailureCodes(testCase) + missing = fullfile(tempdir, "labkit-missing-dta-file.DTA"); + [kind, detectStatus] = labkit.dta.detectType(missing); + [item, loadStatus] = labkit.dta.loadFile(missing, "chrono"); + + testCase.verifyEqual(kind, "unknown"); + testCase.verifyEqual(detectStatus.code, "missing_file"); + testCase.verifyEmpty(item); + testCase.verifyEqual(loadStatus.code, "missing_file"); + testCase.verifySubstring(loadStatus.message, "File not found"); + end end end diff --git a/tests/specs/framework/image/ImageFacadeSpec.m b/tests/specs/framework/image/ImageFacadeSpec.m index 01c318398..364ca7f9e 100644 --- a/tests/specs/framework/image/ImageFacadeSpec.m +++ b/tests/specs/framework/image/ImageFacadeSpec.m @@ -2,6 +2,12 @@ %IMAGEFACADEPEC Specify public image file and conversion behavior. methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function rejectsUnknownReadOptions(testCase) + testCase.verifyError(@() labkit.image.readFiles( ... + strings(0, 1), struct("Normalise", true)), ... + "labkit:image:InvalidOptions"); + end + function readsNormalizedImagesAndReportsSemanticProgress(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; diff --git a/tests/specs/framework/rhs/RhsFacadeSpec.m b/tests/specs/framework/rhs/RhsFacadeSpec.m index 5c0aeff86..19628ca6f 100644 --- a/tests/specs/framework/rhs/RhsFacadeSpec.m +++ b/tests/specs/framework/rhs/RhsFacadeSpec.m @@ -2,6 +2,12 @@ %RHSFACADESPEC Specify public Intan RHS discovery and read behavior. methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function rejectsUnknownReadWindowOptions(testCase) + testCase.verifyError(@() labkit.rhs.readWindow( ... + "missing.rhs", struct("familly", "amplifier")), ... + "labkit:rhs:InvalidOptions"); + end + function discoversAndIndexesSyntheticRhsInputs(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; diff --git a/tests/specs/framework/thermal/ThermalFacadeSpec.m b/tests/specs/framework/thermal/ThermalFacadeSpec.m index f599cef0e..38b8f4e26 100644 --- a/tests/specs/framework/thermal/ThermalFacadeSpec.m +++ b/tests/specs/framework/thermal/ThermalFacadeSpec.m @@ -2,6 +2,16 @@ %THERMALFACADESPEC Specify public radiometric-image ingest behavior. methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function rejectsUnknownOrNonstructPublicOptions(testCase) + calibration = struct("PlanckR1", 21106.77, "PlanckB", 1501, ... + "PlanckF", 1, "PlanckO", -7340, "PlanckR2", 0.012545258); + testCase.verifyError(@() labkit.thermal.rawToTemperatureC( ... + 16000, calibration, struct("Corection", "planck-basic")), ... + "labkit:thermal:InvalidOptions"); + testCase.verifyError(@() labkit.thermal.renderImage( ... + [1 2], "turbo"), "labkit:thermal:InvalidOptions"); + end + function readsRadiometricJpegWithTemperatureAndProgress(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; @@ -47,5 +57,38 @@ function skipsUnsupportedThermalDataWhenRequested(testCase) testCase.verifyEqual([report.requested, report.loaded, report.skipped], [2, 1, 1]); testCase.verifyEqual(report.failures.name, "ordinary.jpg"); end + + function validatesSuppliedEnvironmentalCalibration(testCase) + calibration = struct("PlanckR1", 21106.77, "PlanckB", 1501, ... + "PlanckF", 1, "PlanckO", -7340, "PlanckR2", 0.012545258); + raw = 16000; + [baseline, diagnostics] = labkit.thermal.rawToTemperatureC( ... + raw, calibration); + testCase.verifyTrue(isfinite(baseline)); + testCase.verifyTrue(diagnostics.usedDefaults); + + percentage = calibration; + percentage.RelativeHumidity = 50; + fraction = calibration; + fraction.RelativeHumidity = 0.5; + testCase.verifyEqual( ... + labkit.thermal.rawToTemperatureC(raw, percentage), ... + labkit.thermal.rawToTemperatureC(raw, fraction), ... + "AbsTol", 1e-12); + + invalid = { ... + "Emissivity", 0; ... + "Emissivity", 1.01; ... + "IRWindowTransmission", 0; ... + "ObjectDistanceM", -1; ... + "RelativeHumidity", 101}; + for k = 1:size(invalid, 1) + candidate = calibration; + candidate.(invalid{k, 1}) = invalid{k, 2}; + testCase.verifyError( ... + @() labkit.thermal.rawToTemperatureC(raw, candidate), ... + 'labkit:thermal:InvalidCalibration'); + end + end end end diff --git a/tests/specs/system/repository/TestArchitectureSpec.m b/tests/specs/system/repository/TestArchitectureSpec.m index 912630f50..f3d61e149 100644 --- a/tests/specs/system/repository/TestArchitectureSpec.m +++ b/tests/specs/system/repository/TestArchitectureSpec.m @@ -63,6 +63,8 @@ function ciRoutesDocumentationWithoutWeakeningAggregateGate(testCase) "group: ci-${{ github.event.pull_request.head.sha || github.sha }}")); testCase.verifySubstring(workflow, ... "python -m unittest discover -s .github/scripts"); + testCase.verifySubstring(workflow, ... + "python .github/scripts/validate_agent_skills.py"); testCase.verifySubstring(workflow, ... "python .github/scripts/check_integration_policy.py"); testCase.verifySubstring(workflow, ... From bf52421951383e35b04229b930d35c6c2bd52870 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 30 Jul 2026 20:20:26 -0500 Subject: [PATCH 2/6] fix: refresh corrected launcher dispatch --- labkit_launcher.m | 1 + 1 file changed, 1 insertion(+) diff --git a/labkit_launcher.m b/labkit_launcher.m index bff225a19..5059a2353 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -35,6 +35,7 @@ if ~resolvesInstalledDispatch(dispatcher, entry) addpath(root, "-begin"); rehash; + clear('labkit.app.internal.launcher.dispatch'); dispatcher = str2func("labkit.app.internal.launcher.dispatch"); if ~resolvesInstalledDispatch(dispatcher, entry) error("labkit_launcher:InstalledEntryMismatch", ... From 6a04531a0b2fa92b0b64a41dafd3dece33bd2cd1 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 30 Jul 2026 20:34:57 -0500 Subject: [PATCH 3/6] fix: release stale launcher dispatch handle --- labkit_launcher.m | 1 + 1 file changed, 1 insertion(+) diff --git a/labkit_launcher.m b/labkit_launcher.m index 5059a2353..af4754c1d 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -35,6 +35,7 @@ if ~resolvesInstalledDispatch(dispatcher, entry) addpath(root, "-begin"); rehash; + dispatcher = []; clear('labkit.app.internal.launcher.dispatch'); dispatcher = str2func("labkit.app.internal.launcher.dispatch"); if ~resolvesInstalledDispatch(dispatcher, entry) From 6e4a6b90f1a35be0e206096e71cb685280170b1f Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 30 Jul 2026 20:49:08 -0500 Subject: [PATCH 4/6] fix: prioritize installed launcher root --- labkit_launcher.m | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/labkit_launcher.m b/labkit_launcher.m index af4754c1d..f92317d5c 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -33,6 +33,10 @@ try dispatcher = str2func("labkit.app.internal.launcher.dispatch"); if ~resolvesInstalledDispatch(dispatcher, entry) + searchPath = pathsep + string(path) + pathsep; + if contains(searchPath, pathsep + string(root) + pathsep) + rmpath(root); + end addpath(root, "-begin"); rehash; dispatcher = []; From aa49412bfa29e4d61b4df6f7e8a792e1e42d3c74 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Fri, 31 Jul 2026 00:03:25 -0500 Subject: [PATCH 5/6] fix: complete v4 stability contracts --- +labkit/+app/+internal/+launcher/dispatch.m | 39 ++++- .../MatlabPlatformAdapter.m | 2 +- .../@MatlabPlatformAdapter/applyFilePaths.m | 2 +- .../collectInteractionDeclarations.m | 10 +- .../@MatlabPlatformAdapter/createFilePanel.m | 2 +- .../installContentGrid.m | 2 +- .../interactionTargetAxes.m | 10 +- .../isGrowableTabChild.m | 2 +- .../+internal/@MatlabPlatformAdapter/nodes.m | 8 +- .../preferredRowHeight.m | 2 +- +labkit/+app/+internal/CompiledDefinition.m | 31 ++-- +labkit/+app/+internal/InteractionSpec.m | 2 +- +labkit/+app/+internal/LayoutNode.m | 8 +- +labkit/+app/+internal/NativeAdapterValues.m | 8 +- +labkit/+app/+internal/ProjectDocumentStore.m | 26 ++-- +labkit/+app/+internal/ResourceStore.m | 20 ++- +labkit/+app/+internal/ResultWriter.m | 9 +- +labkit/+app/+internal/RuntimeStatePath.m | 2 +- .../+app/+internal/SessionJournalArchive.m | 25 +++- .../+internal/private/FigureInteractionHub.m | 19 ++- .../+internal/private/InteractionController.m | 10 +- .../+internal/private/createAnchorEditor.m | 2 +- .../private/createPointSlotsEditor.m | 5 +- .../+internal/private/createRectangleEditor.m | 4 +- .../+app/+internal/private/zoomAxesAtPoint.m | 4 +- +labkit/+app/+plot/private/finitePlotLimits.m | 10 +- +labkit/+app/+plot/private/popoutAxes.m | 7 +- +labkit/+app/+view/Snapshot.m | 10 +- +labkit/+app/Definition.m | 8 +- +labkit/+biosignal/private/readCsvRecording.m | 10 +- .../+biosignal/private/readDelimitedTable.m | 24 +++- +labkit/+biosignal/private/readMatRecording.m | 19 ++- +labkit/+contract/checkRequirements.m | 14 +- +labkit/+contract/requirements.m | 5 +- +labkit/+dta/detectType.m | 3 - +labkit/+dta/loadFiles.m | 45 ++++-- +labkit/+dta/private/detectPulseCore.m | 3 - +labkit/+dta/private/findDTAFilesRecursive.m | 11 +- +labkit/+dta/private/parseChronoDTA.m | 49 ++++--- +labkit/+dta/private/parseEISDTA.m | 33 +++-- +labkit/+dta/private/pulsesFromMetadata.m | 2 - +labkit/+rhs/private/readHeader.m | 102 +++++++++----- .../private/readFlirRadiometricJpeg.m | 16 ++- +labkit/+thermal/rawToTemperatureC.m | 7 +- +labkit/+thermal/readFiles.m | 15 +- +labkit/AGENTS.md | 12 +- .agents/dos-and-donts.md | 7 +- AGENTS.md | 22 ++- .../+analysisRun/enhanceReferenceImage.m | 2 +- .../+analysisRun/extendStrainMapToRoi.m | 2 +- .../+analysisRun/drawPreview.m | 2 +- .../+resultFiles/buildOverlayExportTable.m | 5 +- .../+sourceFiles/loadProjectItems.m | 12 +- .../+cic/+analysisRun/buildCurrentSummary.m | 12 +- .../cic/+cic/+sourceFiles/loadProjectItems.m | 12 +- .../csc/+csc/+analysisRun/computeCSC.m | 18 ++- .../csc/+csc/+resultFiles/buildResultsTable.m | 11 +- .../+resultFiles/writeVoltageCurrentCSV.m | 16 ++- .../csc/+csc/+sourceFiles/loadProjectItems.m | 12 +- .../electrochem/csc/+csc/+workbench/present.m | 5 +- .../eis/+eis/+sourceFiles/buildSummary.m | 6 +- .../eis/+eis/+sourceFiles/loadProjectItems.m | 12 +- .../+sourceFiles/loadProjectItems.m | 12 +- .../+gait_analysis/+stepPreview/select.m | 4 +- .../+cropGeometry/clampCropCenterToCanvas.m | 2 +- .../+cropGeometry/cropCanvasFixedSize.m | 2 +- .../+batch_crop/+cropGeometry/padImageEdges.m | 4 +- .../+batch_crop/+cropGeometry/rotateCanvas.m | 2 +- .../+batch_crop/+cropPreview/draw.m | 2 +- .../+batch_crop/+resultFiles/exportPlan.m | 2 +- .../+resultFiles/missingWorkflowItemsText.m | 7 +- .../+batch_crop/+resultFiles/writeOutputs.m | 12 +- .../+sourceFiles/selectionChanged.m | 4 +- .../batch_crop/+batch_crop/projectSpec.m | 43 +++--- .../+curvature/+analysisRun/fitTask.m | 9 +- .../+curvature/+analysisRun/lengthTask.m | 9 +- .../curvature/+curvature/+curvePreview/draw.m | 2 +- .../+analysisRun/extremeTemperatureReadings.m | 2 +- .../+analysisRun/pointTemperatureReading.m | 2 +- .../+analysisRun/roiTemperatureMeanReading.m | 2 +- .../+displayMapping/groupRange.m | 11 +- .../+displayMapping/perImageRange.m | 11 +- .../+presentationData/detailLines.m | 8 +- .../+focus_stack/+analysisRun/alignImages.m | 17 ++- .../+analysisRun/computeFocusStack.m | 2 +- .../+analysisRun/resizeImageToSize.m | 2 +- .../+focus_stack/+analysisRun/runTask.m | 7 +- .../+focus_stack/+focusPreview/details.m | 11 +- .../+focus_stack/+sourceFiles/chooseFolder.m | 5 +- .../+analysisRun/previewResult.m | 6 +- .../+enhancementPipeline/changeAmount.m | 2 +- .../+enhancementPipeline/changeSecondary.m | 2 +- .../+enhancementPipeline/undo.m | 1 - .../+presentationData/beforeAfterImage.m | 2 +- .../+image_enhance/+resultFiles/exportTask.m | 25 +++- .../+image_match/+analysisRun/applyMatch.m | 5 - .../+presentationData/beforeAfterImage.m | 2 +- .../+matchPipeline/settingsChanged.m | 2 +- .../+image_match/+matchPipeline/undo.m | 1 - .../+image_match/+resultFiles/exportTask.m | 10 +- .../+motionEstimate/trackPoints.m | 18 +-- .../+syntheticInputs/writeSamplePack.m | 2 +- .../+video_marker/+videoPreview/draw.m | 2 +- .../+resultFiles/exportCurrent.m | 2 +- .../+resultFiles/exportGraphic.m | 2 +- .../+resultFiles/extractAxesData.m | 11 +- .../+resultFiles/writeAxesDataExport.m | 7 +- .../+figure_studio/+sourceAxes/panelChanged.m | 4 +- .../+figure_studio/+sourceAxes/sourceStyle.m | 7 +- .../+analysisRun/analyzeRecording.m | 28 ++-- .../+sourceFiles/parseSegmentTable.m | 20 +-- .../+rhs_preview/+analysisRun/drawPreview.m | 2 +- .../+analysisRun/editFileFilter.m | 4 +- .../+resultFiles/protocolJsonStruct.m | 7 - .../+groupData/replaceFromTableEdit.m | 11 +- .../+ttest_wizard/+groupData/selectRows.m | 4 +- .../+ttest_wizard/+sourceTable/selectCells.m | 4 +- .../+ttest_wizard/+testRun/validateSettings.m | 4 +- .../+sourceFiles/previewFileHeader.m | 7 +- .../maintain-and-release/testing.md | 12 +- docs/development/tools/profiling.md | 10 +- ...-20260730-v4-stability-contract-closure.md | 33 ++++- labkit_launcher.m | 40 +++--- tests/+labkittest/catalog.m | 7 +- tests/+labkittest/classifyPath.m | 33 +++-- tests/+labkittest/locate.m | 16 ++- tests/+labkittest/plan.m | 73 +++++++--- tests/+labkittest/publicApps.m | 2 +- tests/+labkittest/run.m | 10 +- tests/+testfixtures/StateStore.m | 58 ++++++++ tests/AGENTS.md | 13 +- .../cic/sourceFiles/CicSourceSpec.m | 15 ++ .../csc/sourceFiles/CscSourceSpec.m | 12 ++ .../sourceFiles/VtResistanceSourceSpec.m | 15 ++ .../stepPreview/GaitStepPreviewSpec.m | 20 +++ .../curvePreview/CurvaturePresentationSpec.m | 31 ++++ .../workbench/CurvaturePresentationSpec.m | 13 -- .../workbench/CurvatureWorkbenchSpec.m | 16 +++ .../displayMapping/FlirDisplayMappingSpec.m | 28 ++++ .../thermalPreview/FlirThermalPreviewSpec.m | 26 ++++ .../focusPreview/FocusStackPresentationSpec.m | 33 +++++ .../workbench/FocusStackPresentationSpec.m | 14 -- .../workbench/FocusStackWorkbenchSpec.m | 16 +++ .../ImageEnhancePipelineSpec.m | 27 ++++ .../imagePreview/ImageEnhancePreviewSpec.m | 18 +++ .../imagePreview/ImageMatchPreviewSpec.m | 18 +++ .../matchPipeline/ImageMatchPipelineSpec.m | 25 ++++ .../VideoMarkerMotionEstimateSpec.m | 20 +++ .../VideoMarkerSyntheticInputsSpec.m | 20 +++ .../videoPreview/VideoMarkerPreviewSpec.m | 26 ++++ .../groupData/TTestGroupDataSpec.m | 30 ++++ .../{framework => labkit}/app/AppSdkSpec.m | 0 .../app/AutomaticInstrumentationSpec.m | 0 .../app/SemanticDiagnosticsSpec.m | 0 .../app/SessionDiagnosticBundleSpec.m | 4 +- .../app/SessionEventStreamOperationSpec.m | 1 + .../app/SessionEventStreamSpec.m | 106 +++++++------- .../app/SessionJournalSpec.m | 62 ++++---- .../app/SessionLogProjectionSpec.m | 0 .../app/SessionLogViewerSpec.m | 6 +- .../app/SessionLoggingContractSpec.m | 0 .../app/SessionLoggingPrivacyContractSpec.m | 0 .../app/SessionLoggingRuntimeSpec.m | 56 ++++---- .../internal}/launcher/LauncherDispatchSpec.m | 3 +- .../biosignal/BiosignalFacadeSpec.m | 0 .../contract/ContractFacadeSpec.m | 0 .../{framework => labkit}/dta/DtaFacadeSpec.m | 0 .../{framework => labkit}/dta/DtaPulseSpec.m | 0 .../image/ImageFacadeSpec.m | 0 .../{framework => labkit}/rhs/RhsFacadeSpec.m | 0 .../thermal/ThermalFacadeSpec.m | 0 .../LauncherBootstrapSpec.m | 10 +- .../repository/TestArchitectureSpec.m | 57 +++++++- .../labkittest}/TestCatalogSpec.m | 133 +++++++++++++++--- .../tools/codecheck/CodecheckReportSpec.m | 56 ++++++++ .../deployment/VersionManagementSpec.m | 0 .../maintenance/CleanLabKitArtifactsSpec.m | 10 +- tests/specs/tools/profiling/ProfileToolSpec.m | 67 +++++++++ .../codecheck/private/writeCodecheckReport.m | 11 +- tools/codecheck/runCodecheckReport.m | 38 +++-- tools/deployment/packageLabKitApp.m | 44 +++--- .../deployment/private/packageManifestText.m | 2 +- tools/docs/checkLabKitDocs.m | 6 +- tools/docs/maintainLabKitDocLinks.m | 27 ++-- tools/docs/private/listLabKitDocTreeFiles.m | 6 +- tools/docs/private/loadLabKitDocumentation.m | 47 ++++--- tools/docs/private/renderLabKitApiBody.m | 100 ++++++++----- tools/docs/private/renderLabKitApiIndex.m | 32 ++--- tools/docs/private/renderLabKitMarkdown.m | 125 ++++++++++------ tools/docs/private/renderLabKitPage.m | 39 ++--- tools/docs/private/renderLabKitPageApiLinks.m | 12 +- tools/docs/private/syncLabKitDocTree.m | 7 +- tools/docs/renderLabKitDocs.m | 33 ++++- .../profiling/private/profileLabKitPayload.m | 10 +- tools/profiling/profileLabKitTarget.m | 60 +++++--- 195 files changed, 2199 insertions(+), 916 deletions(-) create mode 100644 tests/+testfixtures/StateStore.m create mode 100644 tests/specs/apps/electrochem/cic/sourceFiles/CicSourceSpec.m create mode 100644 tests/specs/apps/electrochem/csc/sourceFiles/CscSourceSpec.m create mode 100644 tests/specs/apps/electrochem/vt_resistance/sourceFiles/VtResistanceSourceSpec.m create mode 100644 tests/specs/apps/gait/gait_analysis/stepPreview/GaitStepPreviewSpec.m create mode 100644 tests/specs/apps/image_measurement/curvature/curvePreview/CurvaturePresentationSpec.m delete mode 100644 tests/specs/apps/image_measurement/curvature/workbench/CurvaturePresentationSpec.m create mode 100644 tests/specs/apps/image_measurement/curvature/workbench/CurvatureWorkbenchSpec.m create mode 100644 tests/specs/apps/image_measurement/flir_thermal/displayMapping/FlirDisplayMappingSpec.m create mode 100644 tests/specs/apps/image_measurement/flir_thermal/thermalPreview/FlirThermalPreviewSpec.m create mode 100644 tests/specs/apps/image_measurement/focus_stack/focusPreview/FocusStackPresentationSpec.m delete mode 100644 tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackPresentationSpec.m create mode 100644 tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackWorkbenchSpec.m create mode 100644 tests/specs/apps/image_measurement/image_enhance/enhancementPipeline/ImageEnhancePipelineSpec.m create mode 100644 tests/specs/apps/image_measurement/image_enhance/imagePreview/ImageEnhancePreviewSpec.m create mode 100644 tests/specs/apps/image_measurement/image_match/imagePreview/ImageMatchPreviewSpec.m create mode 100644 tests/specs/apps/image_measurement/image_match/matchPipeline/ImageMatchPipelineSpec.m create mode 100644 tests/specs/apps/image_measurement/video_marker/motionEstimate/VideoMarkerMotionEstimateSpec.m create mode 100644 tests/specs/apps/image_measurement/video_marker/syntheticInputs/VideoMarkerSyntheticInputsSpec.m create mode 100644 tests/specs/apps/image_measurement/video_marker/videoPreview/VideoMarkerPreviewSpec.m create mode 100644 tests/specs/apps/statistics/ttest_wizard/groupData/TTestGroupDataSpec.m rename tests/specs/{framework => labkit}/app/AppSdkSpec.m (100%) rename tests/specs/{framework => labkit}/app/AutomaticInstrumentationSpec.m (100%) rename tests/specs/{framework => labkit}/app/SemanticDiagnosticsSpec.m (100%) rename tests/specs/{framework => labkit}/app/SessionDiagnosticBundleSpec.m (99%) rename tests/specs/{framework => labkit}/app/SessionEventStreamOperationSpec.m (99%) rename tests/specs/{framework => labkit}/app/SessionEventStreamSpec.m (88%) rename tests/specs/{framework => labkit}/app/SessionJournalSpec.m (96%) rename tests/specs/{framework => labkit}/app/SessionLogProjectionSpec.m (100%) rename tests/specs/{framework => labkit}/app/SessionLogViewerSpec.m (99%) rename tests/specs/{framework => labkit}/app/SessionLoggingContractSpec.m (100%) rename tests/specs/{framework => labkit}/app/SessionLoggingPrivacyContractSpec.m (100%) rename tests/specs/{framework => labkit}/app/SessionLoggingRuntimeSpec.m (92%) rename tests/specs/{system => labkit/app/internal}/launcher/LauncherDispatchSpec.m (99%) rename tests/specs/{framework => labkit}/biosignal/BiosignalFacadeSpec.m (100%) rename tests/specs/{framework => labkit}/contract/ContractFacadeSpec.m (100%) rename tests/specs/{framework => labkit}/dta/DtaFacadeSpec.m (100%) rename tests/specs/{framework => labkit}/dta/DtaPulseSpec.m (100%) rename tests/specs/{framework => labkit}/image/ImageFacadeSpec.m (100%) rename tests/specs/{framework => labkit}/rhs/RhsFacadeSpec.m (100%) rename tests/specs/{framework => labkit}/thermal/ThermalFacadeSpec.m (100%) rename tests/specs/{system/launcher => labkit_launcher}/LauncherBootstrapSpec.m (99%) rename tests/specs/{system => }/repository/TestArchitectureSpec.m (86%) rename tests/specs/{system/build => tests/labkittest}/TestCatalogSpec.m (86%) create mode 100644 tests/specs/tools/codecheck/CodecheckReportSpec.m rename tests/specs/{system => tools}/deployment/VersionManagementSpec.m (100%) rename tests/specs/{system => tools}/maintenance/CleanLabKitArtifactsSpec.m (95%) create mode 100644 tests/specs/tools/profiling/ProfileToolSpec.m diff --git a/+labkit/+app/+internal/+launcher/dispatch.m b/+labkit/+app/+internal/+launcher/dispatch.m index 89d22dbe4..341d9b6b0 100644 --- a/+labkit/+app/+internal/+launcher/dispatch.m +++ b/+labkit/+app/+internal/+launcher/dispatch.m @@ -297,7 +297,7 @@ function launchSelected() addPathIfMissing(app.folder, "-end"); reportLaunchStage(app, 2, ... "initializing app window via " + app.command); - feval(app.command); + invokeDiscoveredApp(app); setStatus("Finishing startup for " + app.name + "..."); drawnow; setStatus("Opened " + app.command + "."); @@ -696,14 +696,47 @@ function addPathIfMissing(folder, varargin) addpath(folder, "-begin"); cleanup = onCleanup(@() rmpath(folder)); end +switch string(name) + case "manageLabKitVersions" + callable = @manageLabKitVersions; + case "cleanLabKitArtifacts" + callable = @cleanLabKitArtifacts; + case "renderLabKitDocs" + callable = @renderLabKitDocs; + case "runCodecheckReport" + callable = @runCodecheckReport; + case "profileLabKitTarget" + callable = @profileLabKitTarget; + case "packageLabKitApp" + callable = @packageLabKitApp; + otherwise + error("labkit:app:internal:launcher:UnknownTool", ... + "Launcher tool is not allowlisted: %s", name); +end if nargout > 0 - [varargout{1:nargout}] = feval(name, varargin{:}); + [varargout{1:nargout}] = callable(varargin{:}); else - feval(name, varargin{:}); + callable(varargin{:}); end clear cleanup end +function invokeDiscoveredApp(app) +command = string(app.command); +resolved = string(which(char(command))); +expected = fullfile(string(app.folder), command + [".m", ".p"]); +available = arrayfun(@(candidate) exist(candidate, "file") == 2, expected); +expected = expected(available); +if strlength(resolved) == 0 || isempty(expected) || ... + ~any(normalizePathEntry(resolved) == normalizePathEntry(expected)) + error("labkit:app:internal:launcher:AppEntryMismatch", ... + "Discovered App entry does not resolve from its owning folder: %s", command); +end +% Dynamic extension boundary: the command is derived from and revalidated +% against one discovered labkit_*_app.m or .p file before invocation. +feval(char(command)); +end + function apps = discoverApps(root) apps = emptyApps(); roots = [string(fullfile(root, "apps")); privateAppRoots(root)]; diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m index 3a392d181..68ac3a613 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m @@ -480,7 +480,7 @@ function popoutAllPlots(obj) function copyAllPlots(obj) handles = obj.allAxes(); - if numel(handles) == 1 + if isscalar(handles) copygraphics(handles(1), ContentType="image"); elseif ~isempty(handles) copygraphics(obj.Figure, ContentType="image"); diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFilePaths.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFilePaths.m index 355409ad3..5a48bb1d4 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFilePaths.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFilePaths.m @@ -21,7 +21,7 @@ function applyFilePaths(~, component, paths) if ~isempty(paths) if component.UserData.Compact text = string(paths(1)); - elseif numel(paths) == 1 + elseif isscalar(paths) text = "1 file"; else text = string(numel(paths)) + " files"; diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/collectInteractionDeclarations.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/collectInteractionDeclarations.m index be3232808..f786c0216 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/collectInteractionDeclarations.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/collectInteractionDeclarations.m @@ -1,10 +1,16 @@ function declarations = collectInteractionDeclarations(obj) % Class-folder implementation of MatlabPlatformAdapter.collectInteractionDeclarations. - declarations = {}; + chunks = cell(1, numel(obj.Plan.Nodes)); for k = 1:numel(obj.Plan.Nodes) config = obj.Plan.Nodes(k).Configuration; if isfield(config, "Interactions") - declarations = [declarations config.Interactions]; + chunks{k} = config.Interactions; end end + populated = ~cellfun("isempty", chunks); + if any(populated) + declarations = [chunks{populated}]; + else + declarations = cell(1, 0); + end end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createFilePanel.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/createFilePanel.m index 6a0103d49..198a28f8f 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/createFilePanel.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/createFilePanel.m @@ -1,4 +1,4 @@ -function list = createFilePanel(obj, node, parent) +function list = createFilePanel(~, node, parent) % Class-folder implementation of MatlabPlatformAdapter.createFilePanel. config = node.Configuration; panel = uipanel(parent, BorderType="line", ... diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installContentGrid.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installContentGrid.m index c95d34cca..ff97177a7 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/installContentGrid.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/installContentGrid.m @@ -36,7 +36,7 @@ function installContentGrid(obj, node, component) RowSpacing=policy.ContentSpacing, ... ColumnSpacing=policy.ContentSpacing); heights = obj.childRowHeights(node.ChildIds); - singleGrowable = numel(node.ChildIds) == 1 && ... + singleGrowable = isscalar(node.ChildIds) && ... obj.isGrowableTabChild(obj.node(node.ChildIds(1))); if node.Kind == "workspacePage" for k = 1:numel(node.ChildIds) diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/interactionTargetAxes.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/interactionTargetAxes.m index 8f5a73331..dca50776b 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/interactionTargetAxes.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/interactionTargetAxes.m @@ -1,6 +1,8 @@ function targets = interactionTargetAxes(obj) % Class-folder implementation of MatlabPlatformAdapter.interactionTargetAxes. - targets = struct("id", {}, "axes", {}); + capacity = sum(arrayfun(@(node) numel(node.AxisIds), obj.Plan.Nodes)); + targets = repmat(struct("id", "", "axes", []), 1, capacity); + targetCount = 0; for k = 1:numel(obj.Plan.Nodes) node = obj.Plan.Nodes(k); if node.Kind ~= "plotArea" @@ -9,11 +11,13 @@ for axisId = node.AxisIds key = labkit.app.internal.NativeAdapterValues.axisKey(node.Id, axisId); targetId = key; - if numel(node.AxisIds) == 1 + if isscalar(node.AxisIds) targetId = node.Id; end - targets(end + 1) = struct( ... + targetCount = targetCount + 1; + targets(targetCount) = struct( ... "id", targetId, "axes", obj.Axes(char(key))); end end + targets = targets(1:targetCount); end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m index 1ee571c5b..c8fdd48a2 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m @@ -1,6 +1,6 @@ function tf = isGrowableTabChild(obj, node) % Class-folder implementation of MatlabPlatformAdapter.isGrowableTabChild. - if node.Kind == "section" && numel(node.ChildIds) == 1 + if node.Kind == "section" && isscalar(node.ChildIds) tf = obj.isGrowableTabChild(obj.node(node.ChildIds(1))); return end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/nodes.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/nodes.m index 804f98a9c..3bb812d99 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/nodes.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/nodes.m @@ -1,12 +1,14 @@ function selected = nodes(obj, ids) % Class-folder implementation of MatlabPlatformAdapter.nodes. - selected = repmat(obj.Plan.Nodes(1), 0, 1); - for id = string(ids) + ids = string(ids); + selected = repmat(obj.Plan.Nodes(1), numel(ids), 1); + for selectedIndex = 1:numel(ids) + id = ids(selectedIndex); index = find(string({obj.Plan.Nodes.Id}) == id, 1); if isempty(index) error("labkit:app:runtime:InvariantFailure", ... "Compiled Layout child is missing: %s.", id); end - selected(end + 1, 1) = obj.Plan.Nodes(index); + selected(selectedIndex, 1) = obj.Plan.Nodes(index); end end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m index 1fe9901a0..1ea60e635 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m @@ -60,7 +60,7 @@ node.Configuration.Layout == "horizontal"; if node.Kind == "section" && ... ~obj.sectionDrawsOwnTitle(node) && ... - numel(children) == 1 + isscalar(children) height = childHeights(1) + ... policy.UntitledSectionChromeHeight; elseif obj.usesAdaptiveActionGrid(node) diff --git a/+labkit/+app/+internal/CompiledDefinition.m b/+labkit/+app/+internal/CompiledDefinition.m index a47adfb3d..4f2fb80ba 100644 --- a/+labkit/+app/+internal/CompiledDefinition.m +++ b/+labkit/+app/+internal/CompiledDefinition.m @@ -120,30 +120,41 @@ end function bindings = collectSignalBindings(nodes, start) -bindings = {}; +capacity = sum(cellfun(@(node) numel(node.Signals), nodes)) + ~isempty(start); +bindings = cell(1, capacity); +bindingCount = 0; for k = 1:numel(nodes) signals = nodes{k}.Signals; for s = 1:numel(signals) - bindings{end + 1} = signals{s}; + bindingCount = bindingCount + 1; + bindings{bindingCount} = signals{s}; end end if ~isempty(start) - bindings{end + 1} = start; + bindingCount = bindingCount + 1; + bindings{bindingCount} = start; end +bindings = bindings(1:bindingCount); bindings = uniqueBindings(bindings); end function interactions = collectInteractions(nodes) -interactions = {}; +chunks = cell(1, numel(nodes)); for k = 1:numel(nodes) if nodes{k}.Kind ~= "plotArea" continue; end configuration = nodes{k}.configurationForCompiler(); if isfield(configuration, "Interactions") - interactions = [interactions configuration.Interactions]; + chunks{k} = configuration.Interactions; end end +populated = ~cellfun("isempty", chunks); +if any(populated) + interactions = [chunks{populated}]; +else + interactions = cell(1, 0); +end end function assertUnique(values, label) @@ -154,17 +165,19 @@ function assertUnique(values, label) end function values = uniqueBindings(values) -uniqueValues = {}; +uniqueValues = cell(size(values)); +uniqueCount = 0; for k = 1:numel(values) value = values{k}; sameId = find(cellfun(@(candidate) candidate.Id == value.Id, ... - uniqueValues), 1); + uniqueValues(1:uniqueCount)), 1); if isempty(sameId) - uniqueValues{end + 1} = value; + uniqueCount = uniqueCount + 1; + uniqueValues{uniqueCount} = value; elseif ~isequaln(uniqueValues{sameId}, value) error("labkit:app:contract:DuplicateId", ... "Layout signal ID %s has conflicting callbacks.", value.Id); end end -values = uniqueValues; +values = uniqueValues(1:uniqueCount); end diff --git a/+labkit/+app/+internal/InteractionSpec.m b/+labkit/+app/+internal/InteractionSpec.m index 3e5eeb76c..e084fe999 100644 --- a/+labkit/+app/+internal/InteractionSpec.m +++ b/+labkit/+app/+internal/InteractionSpec.m @@ -71,7 +71,7 @@ obj.Id, missing(1)); end result = obj; - if numel(axisIds) == 1 + if isscalar(axisIds) result.Targets = repmat(plotId, size(obj.AxisIds)); else result.Targets = plotId + "." + obj.AxisIds; diff --git a/+labkit/+app/+internal/LayoutNode.m b/+labkit/+app/+internal/LayoutNode.m index ee8bf7311..7aa4d7dd7 100644 --- a/+labkit/+app/+internal/LayoutNode.m +++ b/+labkit/+app/+internal/LayoutNode.m @@ -452,7 +452,7 @@ function obj = workspace(varargin) content = {}; if ~isempty(varargin) && isa(varargin{1}, "labkit.app.internal.LayoutNode") - content = {varargin{1}}; + content = varargin(1); varargin = varargin(2:end); end options = labkit.app.internal.OptionParser.parse( ... @@ -541,7 +541,7 @@ labkit.app.internal.LayoutNodeValues.validateChildKinds(content, labkit.app.internal.LayoutNodeValues.workspaceContentKinds(), ... "workspace page"); pageNode = labkit.app.internal.LayoutNode("workspacePage", id, content, ... - ["workspacePage"], {}, [], strings(1, 0), ... + "workspacePage", {}, [], strings(1, 0), ... struct("Title", labkit.app.internal.LayoutNodeValues.nonemptyText(title, "workspace page title"))); obj.Children{end + 1} = pageNode; obj.PageIds(end + 1) = id; @@ -595,7 +595,3 @@ labkit.app.internal.LayoutNodeValues.optionValue( ... options, name, defaultValue), name); end - -function state = runAnalysis(state, ~) - state.finished = true; -end diff --git a/+labkit/+app/+internal/NativeAdapterValues.m b/+labkit/+app/+internal/NativeAdapterValues.m index db77e1fac..05eb58d1b 100644 --- a/+labkit/+app/+internal/NativeAdapterValues.m +++ b/+labkit/+app/+internal/NativeAdapterValues.m @@ -405,9 +405,13 @@ function restoreViewport(axes, viewport) if mod(numel(filters), 2) == 0 filters = filters(1:2:end); end + patternChunks = cell(numel(filters), 1); + for k = 1:numel(filters) + patternChunks{k} = split(filters(k), ";"); + end patterns = strings(0, 1); - for filter = filters.' - patterns = [patterns; split(filter, ";")]; + if ~isempty(patternChunks) + patterns = vertcat(patternChunks{:}); end patterns = unique(strtrim(patterns(strlength(strtrim(patterns)) > 0)), ... "stable"); diff --git a/+labkit/+app/+internal/ProjectDocumentStore.m b/+labkit/+app/+internal/ProjectDocumentStore.m index 0a9f52d92..41a63013c 100644 --- a/+labkit/+app/+internal/ProjectDocumentStore.m +++ b/+labkit/+app/+internal/ProjectDocumentStore.m @@ -213,16 +213,19 @@ function markDirty(obj) function [project, collected] = rebaseBoundSources( ... obj, project, filepath) bindings = obj.projectSourceBindings(); - collected = struct([]); - for path = bindings + chunks = cell(numel(bindings), 1); + for k = 1:numel(bindings) + path = bindings(k); sources = getProjectBinding(project, path); sources = obj.Sources.rebase(sources, filepath); project = setProjectBinding(project, path, sources); - if isempty(collected) - collected = sources; - elseif ~isempty(sources) - collected = [collected; sources]; - end + chunks{k} = sources; + end + chunks = chunks(~cellfun(@isempty, chunks)); + if isempty(chunks) + collected = struct([]); + else + collected = vertcat(chunks{:}); end end @@ -263,7 +266,8 @@ function markDirty(obj) return; end plan = obj.Contract.PlatformPlan; - bindings = strings(1, 0); + bindings = strings(1, numel(plan.Nodes)); + bindingCount = 0; for k = 1:numel(plan.Nodes) node = plan.Nodes(k); if node.Kind ~= "fileList" || ... @@ -272,9 +276,11 @@ function markDirty(obj) end path = node.Configuration.Bind; if startsWith(path, "project.") - bindings(end + 1) = extractAfter(path, "project."); + bindingCount = bindingCount + 1; + bindings(bindingCount) = extractAfter(path, "project."); end end + bindings = bindings(1:bindingCount); bindings = unique(bindings, "stable"); end @@ -374,7 +380,7 @@ function acceptFile(obj, filepath) if ~isstruct(owner) || ~isscalar(owner) || ~isfield(owner, name) invalidProject("Bound source path is absent: project.%s.", path); end -if numel(parts) == 1 +if isscalar(parts) owner.(name) = value; else owner.(name) = assignProjectField( ... diff --git a/+labkit/+app/+internal/ResourceStore.m b/+labkit/+app/+internal/ResourceStore.m index da8e3a28e..8cb0daae1 100644 --- a/+labkit/+app/+internal/ResourceStore.m +++ b/+labkit/+app/+internal/ResourceStore.m @@ -43,16 +43,20 @@ function remove(obj, scope, id) function clearScope(obj, scope) keys = string(obj.Entries.keys); selected = startsWith(keys, string(scope) + "|"); - failures = {}; - for key = keys(selected) + selectedKeys = keys(selected); + failures = cell(1, numel(selectedKeys)); + failureCount = 0; + for key = selectedKeys entry = obj.Entries(char(key)); remove(obj.Entries, char(key)); try obj.dispose(entry); catch cause - failures{end + 1} = cause; + failureCount = failureCount + 1; + failures{failureCount} = cause; end end + failures = failures(1:failureCount); if ~isempty(failures) failure = MException( ... "labkit:app:runtime:ResourceCleanupFailed", ... @@ -65,14 +69,18 @@ function clearScope(obj, scope) end function clearAll(obj) - failures = {}; - for scope = ["event", "interaction", "document", "application"] + scopes = ["event", "interaction", "document", "application"]; + failures = cell(1, numel(scopes)); + failureCount = 0; + for scope = scopes try obj.clearScope(scope); catch cause - failures{end + 1} = cause; + failureCount = failureCount + 1; + failures{failureCount} = cause; end end + failures = failures(1:failureCount); if ~isempty(failures) failure = MException( ... "labkit:app:runtime:ResourceCleanupFailed", ... diff --git a/+labkit/+app/+internal/ResultWriter.m b/+labkit/+app/+internal/ResultWriter.m index 51dab9c5a..f0b76137f 100644 --- a/+labkit/+app/+internal/ResultWriter.m +++ b/+labkit/+app/+internal/ResultWriter.m @@ -61,9 +61,10 @@ end function outputs = verifiedOutputs(folder, declarations) - outputs = struct("id", {}, "role", {}, "relativePath", {}, ... - "mediaType", {}, "bytes", {}, "sha256", {}, "status", {}, ... - "message", {}, "warnings", {}); + template = struct("id", "", "role", "", "relativePath", "", ... + "mediaType", "", "bytes", uint64(0), "sha256", "", ... + "status", "", "message", "", "warnings", strings(0, 1)); + outputs = repmat(template, 1, numel(declarations)); for k = 1:numel(declarations) declaration = declarations{k}; status = declaration.Status; @@ -81,7 +82,7 @@ digest = sha256File(target); end end - outputs(end + 1) = struct( ... + outputs(k) = struct( ... "id", declaration.Id, "role", declaration.Role, ... "relativePath", declaration.RelativePath, ... "mediaType", declaration.MediaType, "bytes", bytes, ... diff --git a/+labkit/+app/+internal/RuntimeStatePath.m b/+labkit/+app/+internal/RuntimeStatePath.m index b02b756a6..a53287d6c 100644 --- a/+labkit/+app/+internal/RuntimeStatePath.m +++ b/+labkit/+app/+internal/RuntimeStatePath.m @@ -33,7 +33,7 @@ error("labkit:app:contract:UnknownReference", ... "Bound state path is unavailable: %s.", path); end - if numel(parts) == 1 + if isscalar(parts) owner.(name) = value; return; end diff --git a/+labkit/+app/+internal/SessionJournalArchive.m b/+labkit/+app/+internal/SessionJournalArchive.m index f680b4aa6..8afc9b0f9 100644 --- a/+labkit/+app/+internal/SessionJournalArchive.m +++ b/+labkit/+app/+internal/SessionJournalArchive.m @@ -146,7 +146,8 @@ folders = folders(~ismember(string({folders.name}), [".", ".."])); template = struct("Folder", "", "SessionId", "", "State", "", ... "AppId", "", "Timestamp", "", "Bytes", 0); -sessions = repmat(template, 0, 1); +sessions = repmat(template, numel(folders), 1); +sessionCount = 0; for index = 1:numel(folders) folder = string(fullfile(folders(index).folder, folders(index).name)); manifest = readJson(fullfile(folder, "manifest.json")); @@ -161,10 +162,12 @@ catch continue; end - sessions(end + 1, 1) = struct("Folder", folder, "SessionId", sessionId, ... + sessionCount = sessionCount + 1; + sessions(sessionCount, 1) = struct("Folder", folder, "SessionId", sessionId, ... "State", string(manifest.state), "AppId", string(manifest.appId), ... "Timestamp", sessionTimestamp(manifest), "Bytes", folderBytes(folder)); end +sessions = sessions(1:sessionCount); end function [didRecover, corruptTailCount] = recover(folder, abandonActive) @@ -219,7 +222,6 @@ if ~isempty(options.LeaseClock) nowUtc = string(options.LeaseClock()); end -probe = []; targetPid = -1; targetHost = "unknown"; if isstruct(marker) && isscalar(marker) @@ -260,7 +262,7 @@ if isempty(content) return; end -breaks = find(content == char(10)); +breaks = find(content == newline); starts = [1, breaks + 1]; ends = [breaks - 1, numel(content)]; for index = numel(starts):-1:1 @@ -300,7 +302,7 @@ options.ClosedSessionLimitPerApp, options.AppByteLimit, ... options.ProtectedSessionIds); retention.perAppPrunedSessionCount = retention.perAppPrunedSessionCount + removed; - retainedAppSessions = find(string({sessions.AppId}) == appId); + retainedAppSessions = string({sessions.AppId}) == appId; retainedAppBytes = sum([sessions(retainedAppSessions).Bytes]); if retainedAppBytes > options.AppByteLimit retention.unsatisfiedAppIds(end + 1, 1) = appId; @@ -394,12 +396,14 @@ function removeSession(folder) end function [events, corruptRecordCount] = readCanonicalEvents(folder) -events = repmat(canonicalTemplate(), 0, 1); corruptRecordCount = 0; segments = journalSegments(folder); +segmentEvents = cell(numel(segments), 1); for segmentIndex = 1:numel(segments) lines = splitlines(string(fileread(fullfile(segments(segmentIndex).folder, ... segments(segmentIndex).name)))); + events = repmat(canonicalTemplate(), numel(lines), 1); + eventCount = 0; for lineIndex = 1:numel(lines) line = strtrim(lines(lineIndex)); if strlength(line) == 0 @@ -411,11 +415,18 @@ function removeSession(folder) error("labkit:app:runtime:JournalCorruptRecord", ... "A retained journal record is not canonical."); end - events(end + 1, 1) = record; + eventCount = eventCount + 1; + events(eventCount, 1) = record; catch corruptRecordCount = corruptRecordCount + 1; end end + segmentEvents{segmentIndex} = events(1:eventCount); +end +events = repmat(canonicalTemplate(), 0, 1); +segmentEvents = segmentEvents(~cellfun(@isempty, segmentEvents)); +if ~isempty(segmentEvents) + events = vertcat(segmentEvents{:}); end end diff --git a/+labkit/+app/+internal/private/FigureInteractionHub.m b/+labkit/+app/+internal/private/FigureInteractionHub.m index 1ad0008e0..5c97bbe90 100644 --- a/+labkit/+app/+internal/private/FigureInteractionHub.m +++ b/+labkit/+app/+internal/private/FigureInteractionHub.m @@ -411,9 +411,10 @@ function assertTargetValid(id) error("labkit:app:runtime:InvariantFailure", ... "Interaction targets must provide semantic ids and axes."); end - targets = struct("id", {}, "axes", {}, "listener", {}); + targets = repmat(struct("id", "", "axes", [], "listener", []), ... + 1, numel(values)); for k = 1:numel(values) - targets(end + 1) = struct( ... + targets(k) = struct( ... "id", string(values(k).id), ... "axes", values(k).axes, "listener", []); end @@ -466,9 +467,11 @@ function assertTargetValid(id) return; end if iscell(value) + chunks = cell(1, numel(value)); for k = 1:numel(value) - handles = [handles normalizeHandles(value{k})]; + chunks{k} = normalizeHandles(value{k}); end + handles = [chunks{:}]; return; end value = value(:).'; @@ -525,8 +528,16 @@ function invokeCallback(callback, src, event) if isa(callback, 'function_handle') callback(src, event); elseif iscell(callback) - feval(callback{1}, src, event, callback{2:end}); + callbackFunction = callback{1}; + callbackFunction(src, event, callback{2:end}); elseif ischar(callback) || isstring(callback) + if isempty(regexp(char(callback), ... + '^[A-Za-z]\w*(\.[A-Za-z]\w*)*$', 'once')) + error('labkit:app:FigureInteractionHub:InvalidCallbackName', ... + 'Legacy string callbacks must contain one callable function name.'); + end + % Compatibility boundary for MATLAB figures that still expose a + % legacy named callback instead of a function handle. feval(char(callback), src, event); end end diff --git a/+labkit/+app/+internal/private/InteractionController.m b/+labkit/+app/+internal/private/InteractionController.m index df4fbc0a7..3103aa24c 100644 --- a/+labkit/+app/+internal/private/InteractionController.m +++ b/+labkit/+app/+internal/private/InteractionController.m @@ -67,13 +67,15 @@ function deleteController() end function ids = actionIdsFor(specs) -ids = strings(1, 0); names = string(fieldnames(specs)); -for name = names.' +ids = strings(3, numel(names)); +for k = 1:numel(names) + name = names(k); spec = specs.(char(name)); - ids = [ids, string(spec.Event), ... - string(spec.BackgroundEvent), string(spec.ScrollEvent)]; + ids(:, k) = [string(spec.Event); ... + string(spec.BackgroundEvent); string(spec.ScrollEvent)]; end +ids = ids(:).'; ids = unique(ids(strlength(ids) > 0), "stable"); end diff --git a/+labkit/+app/+internal/private/createAnchorEditor.m b/+labkit/+app/+internal/private/createAnchorEditor.m index 7a554b5e6..15cac0dc0 100644 --- a/+labkit/+app/+internal/private/createAnchorEditor.m +++ b/+labkit/+app/+internal/private/createAnchorEditor.m @@ -518,7 +518,7 @@ function zoomImageAxesAtPoint(ax, x, y, scrollCount, imageSize) function n = pointCount(points) if isempty(points) n = 0; - elseif isnumeric(points) && ndims(points) == 2 + elseif isnumeric(points) && ismatrix(points) n = size(points, 1); else n = numel(points); diff --git a/+labkit/+app/+internal/private/createPointSlotsEditor.m b/+labkit/+app/+internal/private/createPointSlotsEditor.m index 874ed02f3..7579b46ee 100644 --- a/+labkit/+app/+internal/private/createPointSlotsEditor.m +++ b/+labkit/+app/+internal/private/createPointSlotsEditor.m @@ -130,12 +130,11 @@ function updateDraggedPoint() end function index = nextPlacementIndex() - index = []; if any(~isfinite(value.points(value.selectedIndex, :))) index = value.selectedIndex; - return; + else + index = find(~all(isfinite(value.points), 2), 1); end - index = find(~all(isfinite(value.points), 2), 1); end function index = nextUnplacedIndex(placedIndex) diff --git a/+labkit/+app/+internal/private/createRectangleEditor.m b/+labkit/+app/+internal/private/createRectangleEditor.m index b5c67f6ad..7fa068499 100644 --- a/+labkit/+app/+internal/private/createRectangleEditor.m +++ b/+labkit/+app/+internal/private/createRectangleEditor.m @@ -130,10 +130,10 @@ function activateIfAvailable() end function tf = isValid() - expectedCorners = 4 .* double(state.resizable); + expectedCorners = 4 * double(state.resizable); tf = isValidGraphic(state.box) && ... numel(state.cornerHandles) == expectedCorners && ... - all(isgraphics(state.cornerHandles)); + all(isgraphics(state.cornerHandles), "all"); end function handles = editorGraphics() diff --git a/+labkit/+app/+internal/private/zoomAxesAtPoint.m b/+labkit/+app/+internal/private/zoomAxesAtPoint.m index 2794f18a5..2b49fe882 100644 --- a/+labkit/+app/+internal/private/zoomAxesAtPoint.m +++ b/+labkit/+app/+internal/private/zoomAxesAtPoint.m @@ -100,7 +100,7 @@ if isempty(args) return; end - if numel(args) == 1 && isstruct(args{1}) + if isscalar(args) && isstruct(args{1}) opts = args{1}; return; end @@ -200,7 +200,7 @@ limits = [0.5, double(count) + 0.5]; return; end - if numel(values) == 1 + if isscalar(values) limits = [values(1) - 0.5, values(1) + double(count) - 0.5]; return; end diff --git a/+labkit/+app/+plot/private/finitePlotLimits.m b/+labkit/+app/+plot/private/finitePlotLimits.m index 74a7a8f56..f46dbaf16 100644 --- a/+labkit/+app/+plot/private/finitePlotLimits.m +++ b/+labkit/+app/+plot/private/finitePlotLimits.m @@ -57,8 +57,8 @@ end function [x, y] = collectFiniteXY(handles) - x = []; - y = []; + xChunks = cell(numel(handles), 1); + yChunks = cell(numel(handles), 1); handles = handles(:).'; for k = 1:numel(handles) h = handles(k); @@ -68,10 +68,12 @@ childX = h.XData; childY = h.YData; if isnumeric(childX) && isnumeric(childY) - x = [x; childX(:)]; - y = [y; childY(:)]; + xChunks{k} = childX(:); + yChunks{k} = childY(:); end end + x = vertcat(xChunks{:}); + y = vertcat(yChunks{:}); x = x(isfinite(x)); y = y(isfinite(y)); end diff --git a/+labkit/+app/+plot/private/popoutAxes.m b/+labkit/+app/+plot/private/popoutAxes.m index 335c64b60..7f0df67dd 100644 --- a/+labkit/+app/+plot/private/popoutAxes.m +++ b/+labkit/+app/+plot/private/popoutAxes.m @@ -205,15 +205,18 @@ function lockImageAspectRatio(srcAx, dstAx) function addLegendIfNeeded(ax) children = ax.Children; - names = strings(0, 1); + names = strings(numel(children), 1); + nameCount = 0; for k = 1:numel(children) if isprop(children(k), 'DisplayName') name = string(children(k).DisplayName); if strlength(name) > 0 && ~startsWith(name, "_") - names(end+1, 1) = name; + nameCount = nameCount + 1; + names(nameCount) = name; end end end + names = names(1:nameCount); if ~isempty(names) legend(ax, 'show', 'Interpreter', 'none'); end diff --git a/+labkit/+app/+view/Snapshot.m b/+labkit/+app/+view/Snapshot.m index 1d054855c..543da2fea 100644 --- a/+labkit/+app/+view/Snapshot.m +++ b/+labkit/+app/+view/Snapshot.m @@ -300,11 +300,13 @@ error("labkit:app:contract:InvalidValue", ... "View snapshot overlay requires a View snapshot value."); end - operations = base.Operations; + operationCount = numel(base.Operations); + operations = cell(1, operationCount + numel(custom.Operations)); + operations(1:operationCount) = base.Operations; for k = 1:numel(custom.Operations) incoming = custom.Operations{k}; replaced = false; - for n = 1:numel(operations) + for n = 1:operationCount current = operations{n}; if current.Kind == incoming.Kind && ... current.Target == incoming.Target @@ -314,9 +316,11 @@ end end if ~replaced - operations{end + 1} = incoming; + operationCount = operationCount + 1; + operations{operationCount} = incoming; end end + operations = operations(1:operationCount); result = labkit.app.view.Snapshot(); result.Operations = operations; end diff --git a/+labkit/+app/Definition.m b/+labkit/+app/Definition.m index a5297d83e..552911863 100644 --- a/+labkit/+app/Definition.m +++ b/+labkit/+app/Definition.m @@ -158,7 +158,7 @@ %LAUNCH Answer metadata requests or show the native MATLAB App. initialProject = []; if ~isempty(varargin) && ... - ~(numel(varargin) == 1 && ... + ~(isscalar(varargin) && ... (ischar(varargin{1}) || ... (isstring(varargin{1}) && isscalar(varargin{1})))) options = labkit.app.internal.OptionParser.parse( ... @@ -222,7 +222,6 @@ end end - function value = optionValue(options, name, defaultValue) value = defaultValue; if isfield(options, name) @@ -318,8 +317,3 @@ name, inputs, outputs); end end - - -function state = runAnalysis(state, ~) - state.finished = true; -end diff --git a/+labkit/+biosignal/private/readCsvRecording.m b/+labkit/+biosignal/private/readCsvRecording.m index 62ae84ead..bf77745f7 100644 --- a/+labkit/+biosignal/private/readCsvRecording.m +++ b/+labkit/+biosignal/private/readCsvRecording.m @@ -35,8 +35,9 @@ names = T.Properties.VariableNames; [timeSec, timeColumn, timeInfo] = inferTableTime(T, opts, importInfo); - signals = struct([]); signalColumns = resolveSignalColumns(names, optionValue(opts, 'signalColumns', [])); + signalCells = cell(1, numel(names)); + signalCount = 0; for k = 1:numel(names) if k == timeColumn continue; @@ -59,7 +60,12 @@ 'timeSource', timeInfo.source, ... 'timeRepair', timeInfo.repair), ... opts); - signals = [signals sig]; + signalCount = signalCount + 1; + signalCells{signalCount} = sig; + end + signals = struct([]); + if signalCount > 0 + signals = [signalCells{1:signalCount}]; end recording = makeRecording(filepath, "table", signals); diff --git a/+labkit/+biosignal/private/readDelimitedTable.m b/+labkit/+biosignal/private/readDelimitedTable.m index d22aa607b..32412d5b9 100644 --- a/+labkit/+biosignal/private/readDelimitedTable.m +++ b/+labkit/+biosignal/private/readDelimitedTable.m @@ -43,7 +43,8 @@ skipNonDataRows = isLikelySignalHeader(headerLine) || ... isTimeLikeName(firstToken(header)); - rows = {}; + rows = cell(numel(lines) - info.headerLine, numel(names)); + rowCount = 0; for k = info.headerLine + 1:numel(lines) line = strtrim(lines(k)); if strlength(line) == 0 @@ -58,8 +59,10 @@ for j = 1:n row{j} = char(strip(tokens(j))); end - rows(end+1, :) = row; + rowCount = rowCount + 1; + rows(rowCount, :) = row; end + rows = rows(1:rowCount, :); if isempty(rows) T = cell2table(cell(0, numel(names)), 'VariableNames', names); @@ -71,7 +74,8 @@ function T = readHeaderlessTextDelimitedTable(filepath, info) lines = readlines(filepath); lines = erase(lines, char(13)); - rows = {}; + rows = cell(numel(lines) - info.headerLine + 1, 1); + rowCount = 0; maxWidth = 0; for k = info.headerLine:numel(lines) line = strtrim(lines(k)); @@ -80,8 +84,10 @@ end tokens = splitDelimitedLine(line); maxWidth = max(maxWidth, numel(tokens)); - rows{end+1, 1} = tokens; + rowCount = rowCount + 1; + rows{rowCount, 1} = tokens; end + rows = rows(1:rowCount); if isempty(rows) || maxWidth == 0 T = table(); return; @@ -105,7 +111,8 @@ return; end - tokens = strings(0, 1); + tokens = strings(numel(line) + 1, 1); + tokenCount = 0; current = ""; inQuotes = false; for k = 1:numel(line) @@ -115,13 +122,16 @@ continue; end if ch == ',' && ~inQuotes - tokens(end+1, 1) = current; + tokenCount = tokenCount + 1; + tokens(tokenCount, 1) = current; current = ""; else current = current + string(ch); end end - tokens(end+1, 1) = current; + tokenCount = tokenCount + 1; + tokens(tokenCount, 1) = current; + tokens = tokens(1:tokenCount); end function tokens = trimTrailingEmptyTokens(tokens) diff --git a/+labkit/+biosignal/private/readMatRecording.m b/+labkit/+biosignal/private/readMatRecording.m index 9f55114fa..2b41f8b74 100644 --- a/+labkit/+biosignal/private/readMatRecording.m +++ b/+labkit/+biosignal/private/readMatRecording.m @@ -23,13 +23,18 @@ data = load(filepath); names = fieldnames(data); - signals = struct([]); + signalChunks = cell(1, numel(names)); for i = 1:numel(names) value = data.(names{i}); if istimetable(value) - signals = [signals readTimetableSignals(value, names{i}, opts)]; + signalChunks{i} = readTimetableSignals(value, names{i}, opts); end end + signalChunks = signalChunks(~cellfun(@isempty, signalChunks)); + signals = struct([]); + if ~isempty(signalChunks) + signals = [signalChunks{:}]; + end recording = makeRecording(filepath, "mat", signals); if isempty(recording.signals) @@ -41,7 +46,8 @@ function signals = readTimetableSignals(TT, sourceName, opts) varNames = TT.Properties.VariableNames; timeSec = timeToSeconds(TT.Properties.RowTimes); - signals = struct([]); + signalCells = cell(1, numel(varNames)); + signalCount = 0; for k = 1:numel(varNames) values = TT.(varNames{k}); if ~(isnumeric(values) || islogical(values)) || ~isvector(values) @@ -54,6 +60,11 @@ double(values(:)), ... struct('sourceKind', "timetable", 'sourceVariable', string(sourceName)), ... opts); - signals = [signals sig]; + signalCount = signalCount + 1; + signalCells{signalCount} = sig; + end + signals = struct([]); + if signalCount > 0 + signals = [signalCells{1:signalCount}]; end end diff --git a/+labkit/+contract/checkRequirements.m b/+labkit/+contract/checkRequirements.m index a5aff40a6..61ea29e93 100644 --- a/+labkit/+contract/checkRequirements.m +++ b/+labkit/+contract/checkRequirements.m @@ -57,12 +57,14 @@ entries = normalizeRequirements(req); versions = normalizeVersions(versions); failures = repmat(struct('facade', "", 'required', "", ... - 'available', strings(0, 1), 'message', ""), 0, 1); + 'available', strings(0, 1), 'message', ""), numel(entries), 1); + failureCount = 0; for k = 1:numel(entries) match = find([versions.facade] == entries(k).facade, 1); if isempty(match) - failures(end + 1, 1) = makeFailure(entries(k), strings(0, 1), ... + failureCount = failureCount + 1; + failures(failureCount, 1) = makeFailure(entries(k), strings(0, 1), ... sprintf('Unknown LabKit facade "%s".', entries(k).facade)); continue; end @@ -73,16 +75,19 @@ message = sprintf('labkit.%s current %s does not satisfy the app requirement %s. It advertises support for %s.', ... entries(k).facade, current, entries(k).range, ... strjoin(cellstr(advertised(:).'), ', ')); - failures(end + 1, 1) = makeFailure(entries(k), advertised, message); + failureCount = failureCount + 1; + failures(failureCount, 1) = makeFailure(entries(k), advertised, message); continue; end if ~rangeIntersectsAny(entries(k).range, advertised) message = sprintf('labkit.%s current %s supports %s, but the app requires %s.', ... entries(k).facade, current, ... strjoin(cellstr(advertised(:).'), ', '), entries(k).range); - failures(end + 1, 1) = makeFailure(entries(k), advertised, message); + failureCount = failureCount + 1; + failures(failureCount, 1) = makeFailure(entries(k), advertised, message); end end + failures = failures(1:failureCount); report = struct(); report.ok = isempty(failures); @@ -284,7 +289,6 @@ function [op, version] = parseConstraint(token) token = strtrim(string(token)); operators = [">=", "<=", "==", ">", "<", "="]; - op = ""; for k = 1:numel(operators) candidate = operators(k); if startsWith(token, candidate) diff --git a/+labkit/+contract/requirements.m b/+labkit/+contract/requirements.m index 56d7eb218..29aadbe04 100644 --- a/+labkit/+contract/requirements.m +++ b/+labkit/+contract/requirements.m @@ -42,7 +42,7 @@ 'Requirements must be facade/range pairs.'); end - entries = repmat(struct('facade', "", 'range', ""), 0, 1); + entries = repmat(struct('facade', "", 'range', ""), numel(varargin) / 2, 1); for k = 1:2:numel(varargin) facade = normalizeFacade(varargin{k}); rangeText = normalizeRange(varargin{k + 1}); @@ -50,7 +50,8 @@ error('labkit:contract:DuplicateRequirement', ... 'Facade "%s" is listed more than once.', facade); end - entries(end + 1, 1) = struct('facade', facade, 'range', rangeText); + entries((k + 1) / 2, 1) = struct( ... + 'facade', facade, 'range', rangeText); end req = struct(); diff --git a/+labkit/+dta/detectType.m b/+labkit/+dta/detectType.m index c1c0210c7..de3a20975 100644 --- a/+labkit/+dta/detectType.m +++ b/+labkit/+dta/detectType.m @@ -84,7 +84,6 @@ function [ok, msg] = isEIS(filepath) ok = false; - msg = ""; try [~, tables] = parseEISDTA(filepath); [~, ok, msg] = labkit.dta.getZCurve(tables); @@ -95,7 +94,6 @@ function [ok, msg] = isChrono(filepath) ok = false; - msg = ""; try [~, tables] = parseChronoDTA(filepath); [curve, tableOk, msg] = labkit.dta.getMainCurve(tables); @@ -118,7 +116,6 @@ function [ok, msg] = isCVCT(filepath) ok = false; - msg = ""; try [scanRate, curves, logmsg] = parseCVCTDTA(filepath); ok = ~isempty(curves) && isfinite(scanRate); diff --git a/+labkit/+dta/loadFiles.m b/+labkit/+dta/loadFiles.m index f55e73d0f..32f4e3a0a 100644 --- a/+labkit/+dta/loadFiles.m +++ b/+labkit/+dta/loadFiles.m @@ -65,24 +65,34 @@ expectedKind = normalizeExpectedKind(expectedKind); filepaths = normalizeFilepaths(filepaths); - items = {}; report = emptyReport(); + items = cell(1, numel(filepaths)); + report.statuses = repmat(statusTemplate(), 1, numel(filepaths)); + report.loaded = cell(1, numel(filepaths)); + report.failed = repmat(failureTemplate(), 1, numel(filepaths)); + loadedCount = 0; + failedCount = 0; for k = 1:numel(filepaths) [item, status] = labkit.dta.loadFile(filepaths{k}, expectedKind, opts); - report.statuses(end+1) = status; + report.statuses(k) = status; if status.ok - items{end+1} = item; - report.loaded{end+1} = status.filepath; + loadedCount = loadedCount + 1; + items{loadedCount} = item; + report.loaded{loadedCount} = status.filepath; else - report.failed(end+1) = struct( ... + failedCount = failedCount + 1; + report.failed(failedCount) = struct( ... 'filepath', status.filepath, ... 'kind', status.kind, ... 'code', status.code, ... 'message', status.message); end end + items = items(1:loadedCount); + report.loaded = report.loaded(1:loadedCount); + report.failed = report.failed(1:failedCount); report.nRequested = numel(filepaths); report.nLoaded = numel(report.loaded); @@ -122,15 +132,24 @@ function report = emptyReport() report = struct(); report.loaded = {}; - report.failed = struct('filepath', {}, 'kind', {}, 'code', {}, 'message', {}); - report.statuses = struct( ... - 'ok', {}, ... - 'code', {}, ... - 'message', {}, ... - 'kind', {}, ... - 'expectedKind', {}, ... - 'filepath', {}); + report.failed = repmat(failureTemplate(), 1, 0); + report.statuses = repmat(statusTemplate(), 1, 0); report.nRequested = 0; report.nLoaded = 0; report.nFailed = 0; end + +function value = failureTemplate() + value = struct( ... + 'filepath', '', 'kind', '', 'code', '', 'message', ''); +end + +function value = statusTemplate() + value = struct( ... + 'ok', false, ... + 'code', '', ... + 'message', '', ... + 'kind', '', ... + 'expectedKind', '', ... + 'filepath', ''); +end diff --git a/+labkit/+dta/private/detectPulseCore.m b/+labkit/+dta/private/detectPulseCore.m index b3627d6d6..9a2370264 100644 --- a/+labkit/+dta/private/detectPulseCore.m +++ b/+labkit/+dta/private/detectPulseCore.m @@ -45,9 +45,6 @@ end end - pulse = emptyPulse(); - msg = 'Pulse detection failed.'; - switch string(opts.mode) case "metadata_only" [pulse, ~, msg] = pulsesFromMetadata(meta, t); diff --git a/+labkit/+dta/private/findDTAFilesRecursive.m b/+labkit/+dta/private/findDTAFilesRecursive.m index f361e4026..5c865b61b 100644 --- a/+labkit/+dta/private/findDTAFilesRecursive.m +++ b/+labkit/+dta/private/findDTAFilesRecursive.m @@ -20,7 +20,7 @@ % changes; this helper only walks the tree. entries = dir(rootDir); - filepaths = {}; + pathChunks = cell(1, numel(entries)); for i = 1:numel(entries) name = entries(i).name; @@ -32,13 +32,18 @@ if entries(i).isdir subpaths = findDTAFilesRecursive(fullpath); if ~isempty(subpaths) - filepaths = [filepaths, subpaths]; + pathChunks{i} = subpaths; end else [~, ~, ext] = fileparts(name); if strcmpi(ext, '.dta') - filepaths{end+1} = fullpath; + pathChunks{i} = {fullpath}; end end end + populated = ~cellfun("isempty", pathChunks); + filepaths = cell(1, 0); + if any(populated) + filepaths = [pathChunks{populated}]; + end end diff --git a/+labkit/+dta/private/parseChronoDTA.m b/+labkit/+dta/private/parseChronoDTA.m index 9e4385e2c..8e30f73ab 100644 --- a/+labkit/+dta/private/parseChronoDTA.m +++ b/+labkit/+dta/private/parseChronoDTA.m @@ -32,11 +32,12 @@ meta.sampleTime_s = NaN; meta.controlMode = "unknown"; meta.steps = struct('idx', {}, 'I', {}, 'V', {}, 'T', {}); - tables = struct('name', {}, 'headers', {}, 'units', {}, 'data', {}, 'numericMask', {}); - logmsg = {}; - nLines = numel(lines); - logmsg{end+1} = sprintf('Parsing DTA: %s', filepath); + tableCells = cell(1, nLines); + tableCount = 0; + logmsg = cell(1, nLines + 5); + logCount = 1; + logmsg{logCount} = sprintf('Parsing DTA: %s', filepath); stepI = containers.Map('KeyType', 'int32', 'ValueType', 'double'); stepV = containers.Map('KeyType', 'int32', 'ValueType', 'double'); @@ -89,6 +90,8 @@ allIdx = unique([cell2mat(keys(stepI)), cell2mat(keys(stepV)), cell2mat(keys(stepT))]); allIdx = sort(allIdx); + meta.steps = repmat(struct('idx', NaN, 'I', NaN, 'V', NaN, 'T', NaN), ... + 1, numel(allIdx)); for k = 1:numel(allIdx) idx = allIdx(k); I = NaN; @@ -103,20 +106,24 @@ if isKey(stepT, idx) T = stepT(idx); end - meta.steps(end+1) = struct('idx', double(idx), 'I', I, 'V', V, 'T', T); + meta.steps(k) = struct('idx', double(idx), 'I', I, 'V', V, 'T', T); end meta.controlMode = inferControlMode(meta.steps); if ~isempty(meta.steps) if any(isfinite([meta.steps.I])) - logmsg{end+1} = sprintf('Found %d ISTEP/TSTEP step(s).', numel(meta.steps)); + logCount = logCount + 1; + logmsg{logCount} = sprintf('Found %d ISTEP/TSTEP step(s).', numel(meta.steps)); elseif any(isfinite([meta.steps.V])) - logmsg{end+1} = sprintf('Found %d VSTEP/TSTEP step(s).', numel(meta.steps)); + logCount = logCount + 1; + logmsg{logCount} = sprintf('Found %d VSTEP/TSTEP step(s).', numel(meta.steps)); else - logmsg{end+1} = sprintf('Found %d step(s) with timing only.', numel(meta.steps)); + logCount = logCount + 1; + logmsg{logCount} = sprintf('Found %d step(s) with timing only.', numel(meta.steps)); end else - logmsg{end+1} = 'No ISTEP/TSTEP or VSTEP/TSTEP sequence found.'; + logCount = logCount + 1; + logmsg{logCount} = 'No ISTEP/TSTEP or VSTEP/TSTEP sequence found.'; end i = 1; @@ -140,7 +147,8 @@ dataStart = nextNonEmpty(lines, iUnits + 1); end - raw = []; + raw = nan(max(nLines - dataStart + 1, 0), numel(headers)); + rawCount = 0; j = dataStart; while j <= nLines tokj = splitTabs(lines{j}); @@ -162,21 +170,24 @@ end end if anyNumeric - raw(end+1, :) = row; + rawCount = rawCount + 1; + raw(rawCount, :) = row; end j = j + 1; end + raw = raw(1:rawCount, :); if ~isempty(raw) numericMask = any(~isnan(raw), 1); - tables(end+1).name = name; - tables(end).headers = headers; - tables(end).units = units; - tables(end).data = raw; - tables(end).numericMask = numericMask; - logmsg{end+1} = sprintf('Table %s parsed: %d rows x %d cols.', name, size(raw, 1), size(raw, 2)); + tableCount = tableCount + 1; + tableCells{tableCount} = struct('name', name, ... + 'headers', {headers}, 'units', {units}, ... + 'data', raw, 'numericMask', numericMask); + logCount = logCount + 1; + logmsg{logCount} = sprintf('Table %s parsed: %d rows x %d cols.', name, size(raw, 1), size(raw, 2)); else - logmsg{end+1} = sprintf('Table %s found but no numeric rows.', name); + logCount = logCount + 1; + logmsg{logCount} = sprintf('Table %s found but no numeric rows.', name); end i = j; @@ -184,6 +195,8 @@ i = i + 1; end end + tables = [tableCells{1:tableCount}]; + logmsg = logmsg(1:logCount); if isempty(tables) error('No numeric TABLE section was parsed from this DTA file.'); diff --git a/+labkit/+dta/private/parseEISDTA.m b/+labkit/+dta/private/parseEISDTA.m index b68b3be5e..be5205214 100644 --- a/+labkit/+dta/private/parseEISDTA.m +++ b/+labkit/+dta/private/parseEISDTA.m @@ -30,11 +30,12 @@ meta.tag = ''; meta.title = ''; meta.area_cm2 = NaN; - tables = struct('name', {}, 'headers', {}, 'units', {}, 'data', {}, 'numericMask', {}); - logmsg = {}; - nLines = numel(lines); - logmsg{end+1} = sprintf('Parsing DTA: %s', filepath); + tableCells = cell(1, nLines); + tableCount = 0; + logmsg = cell(1, nLines + 1); + logCount = 1; + logmsg{logCount} = sprintf('Parsing DTA: %s', filepath); for i = 1:nLines tok = splitTabs(lines{i}); @@ -79,7 +80,8 @@ dataStart = nextNonEmpty(lines, iUnits + 1); end - raw = []; + raw = nan(max(nLines - dataStart + 1, 0), numel(headers)); + rawCount = 0; j = dataStart; while j <= nLines tokj = splitTabs(lines{j}); @@ -103,21 +105,24 @@ end if anyNumeric - raw(end+1, :) = row; + rawCount = rawCount + 1; + raw(rawCount, :) = row; end j = j + 1; end + raw = raw(1:rawCount, :); if ~isempty(raw) numericMask = any(~isnan(raw), 1); - tables(end+1).name = name; - tables(end).headers = headers; - tables(end).units = units; - tables(end).data = raw; - tables(end).numericMask = numericMask; - logmsg{end+1} = sprintf('Table %s parsed: %d rows x %d cols.', name, size(raw, 1), size(raw, 2)); + tableCount = tableCount + 1; + tableCells{tableCount} = struct('name', name, ... + 'headers', {headers}, 'units', {units}, ... + 'data', raw, 'numericMask', numericMask); + logCount = logCount + 1; + logmsg{logCount} = sprintf('Table %s parsed: %d rows x %d cols.', name, size(raw, 1), size(raw, 2)); else - logmsg{end+1} = sprintf('Table %s found but no numeric rows.', name); + logCount = logCount + 1; + logmsg{logCount} = sprintf('Table %s found but no numeric rows.', name); end i = j; @@ -125,6 +130,8 @@ i = i + 1; end end + tables = [tableCells{1:tableCount}]; + logmsg = logmsg(1:logCount); if isempty(tables) error('No numeric TABLE section was parsed from this DTA file.'); diff --git a/+labkit/+dta/private/pulsesFromMetadata.m b/+labkit/+dta/private/pulsesFromMetadata.m index cff09b736..d894b4abe 100644 --- a/+labkit/+dta/private/pulsesFromMetadata.m +++ b/+labkit/+dta/private/pulsesFromMetadata.m @@ -40,8 +40,6 @@ return; end - stepMode = ''; - stepVals = []; if any(isfinite(Ivals)) stepVals = Ivals; stepMode = 'current'; diff --git a/+labkit/+rhs/private/readHeader.m b/+labkit/+rhs/private/readHeader.m index 0c1f33765..d0f92772a 100644 --- a/+labkit/+rhs/private/readHeader.m +++ b/+labkit/+rhs/private/readHeader.m @@ -139,52 +139,82 @@ function [families, spikeTriggers, signalGroups] = readSignalGroups(fid) families = emptyFamilies(); spikeTriggers = emptySpikeTrigger(); - signalGroups = struct( ... - "name", {}, ... - "prefix", {}, ... - "enabled", {}, ... - "numChannels", {}, ... - "numAmplifierChannels", {}); - numberOfSignalGroups = readScalar(fid, "int16"); + groupTemplate = struct("name", "", "prefix", "", "enabled", false, ... + "numChannels", 0, "numAmplifierChannels", 0); + signalGroups = repmat(groupTemplate, 1, numberOfSignalGroups); + familyNames = ["amplifier", "boardAdc", "boardDac", ... + "boardDigIn", "boardDigOut"]; + familyChunks = cell(numel(familyNames), numberOfSignalGroups); + triggerChunks = cell(1, numberOfSignalGroups); for groupIndex = 1:numberOfSignalGroups groupName = readQString(fid); groupPrefix = readQString(fid); groupEnabled = readScalar(fid, "int16") ~= 0; groupNumChannels = readScalar(fid, "int16"); groupNumAmplifierChannels = readScalar(fid, "int16"); - signalGroups(end+1) = struct( ... + signalGroups(groupIndex) = struct( ... "name", string(groupName), ... "prefix", string(groupPrefix), ... "enabled", groupEnabled, ... "numChannels", double(groupNumChannels), ... "numAmplifierChannels", double(groupNumAmplifierChannels)); + groupChannels = cell(numel(familyNames), groupNumChannels); + groupCounts = zeros(1, numel(familyNames)); + groupTriggers = cell(1, groupNumChannels); + triggerCount = 0; for channelIndex = 1:groupNumChannels [channel, trigger, signalType, channelEnabled] = readChannel(fid, ... groupName, groupPrefix, groupIndex); if ~(groupEnabled && channelEnabled) continue; end + familyIndex = 0; switch signalType case 0 - families.amplifier(end+1) = channel; - spikeTriggers(end+1) = trigger; + familyIndex = 1; + triggerCount = triggerCount + 1; + groupTriggers{triggerCount} = trigger; case 3 - families.boardAdc(end+1) = channel; + familyIndex = 2; case 4 - families.boardDac(end+1) = channel; + familyIndex = 3; case 5 - families.boardDigIn(end+1) = channel; + familyIndex = 4; case 6 - families.boardDigOut(end+1) = channel; + familyIndex = 5; otherwise % RHS files may include unused channel types inherited % from the Intan header schema; only stored families are % exposed by the RHS facade. end + if familyIndex > 0 + groupCounts(familyIndex) = groupCounts(familyIndex) + 1; + groupChannels{familyIndex, groupCounts(familyIndex)} = channel; + end + end + for familyIndex = 1:numel(familyNames) + if groupCounts(familyIndex) > 0 + familyChunks{familyIndex, groupIndex} = ... + [groupChannels{familyIndex, 1:groupCounts(familyIndex)}]; + end + end + if triggerCount > 0 + triggerChunks{groupIndex} = [groupTriggers{1:triggerCount}]; end end + for familyIndex = 1:numel(familyNames) + chunks = familyChunks(familyIndex, :); + chunks = chunks(~cellfun(@isempty, chunks)); + if ~isempty(chunks) + families.(familyNames(familyIndex)) = [chunks{:}]; + end + end + triggerChunks = triggerChunks(~cellfun(@isempty, triggerChunks)); + if ~isempty(triggerChunks) + spikeTriggers = [triggerChunks{:}]; + end end function [channel, trigger, signalType, enabled] = readChannel(fid, ... @@ -296,31 +326,33 @@ end function T = channelTable(families) - family = strings(0, 1); - nativeName = strings(0, 1); - customName = strings(0, 1); - nativeOrder = zeros(0, 1); - customOrder = zeros(0, 1); - chipChannel = zeros(0, 1); - boardStream = zeros(0, 1); - portName = strings(0, 1); - impedanceMagnitude = zeros(0, 1); - impedancePhase = zeros(0, 1); - names = ["amplifier", "boardAdc", "boardDac", "boardDigIn", "boardDigOut"]; + channelCount = sum(arrayfun(@(name) numel(families.(name)), names)); + family = strings(channelCount, 1); + nativeName = strings(channelCount, 1); + customName = strings(channelCount, 1); + nativeOrder = zeros(channelCount, 1); + customOrder = zeros(channelCount, 1); + chipChannel = zeros(channelCount, 1); + boardStream = zeros(channelCount, 1); + portName = strings(channelCount, 1); + impedanceMagnitude = zeros(channelCount, 1); + impedancePhase = zeros(channelCount, 1); + row = 0; for iFamily = 1:numel(names) channels = families.(names(iFamily)); for k = 1:numel(channels) - family(end+1, 1) = names(iFamily); - nativeName(end+1, 1) = channels(k).nativeName; - customName(end+1, 1) = channels(k).customName; - nativeOrder(end+1, 1) = channels(k).nativeOrder; - customOrder(end+1, 1) = channels(k).customOrder; - chipChannel(end+1, 1) = channels(k).chipChannel; - boardStream(end+1, 1) = channels(k).boardStream; - portName(end+1, 1) = channels(k).portName; - impedanceMagnitude(end+1, 1) = channels(k).electrodeImpedanceMagnitude; - impedancePhase(end+1, 1) = channels(k).electrodeImpedancePhase; + row = row + 1; + family(row, 1) = names(iFamily); + nativeName(row, 1) = channels(k).nativeName; + customName(row, 1) = channels(k).customName; + nativeOrder(row, 1) = channels(k).nativeOrder; + customOrder(row, 1) = channels(k).customOrder; + chipChannel(row, 1) = channels(k).chipChannel; + boardStream(row, 1) = channels(k).boardStream; + portName(row, 1) = channels(k).portName; + impedanceMagnitude(row, 1) = channels(k).electrodeImpedanceMagnitude; + impedancePhase(row, 1) = channels(k).electrodeImpedancePhase; end end diff --git a/+labkit/+thermal/private/readFlirRadiometricJpeg.m b/+labkit/+thermal/private/readFlirRadiometricJpeg.m index fc7b3ede3..cf7360caa 100644 --- a/+labkit/+thermal/private/readFlirRadiometricJpeg.m +++ b/+labkit/+thermal/private/readFlirRadiometricJpeg.m @@ -224,16 +224,28 @@ function cleanupTempFile(filepath) probeLength = min(numel(bytes), 64); probe = bytes(1:probeLength); if numel(bytes) >= 8 && (isequal(bytes(1:8), pngSignature) || ... - ~isempty(strfind(probe, pngSignature))) + containsByteSequence(probe, pngSignature)) type = "PNG"; elseif numel(bytes) >= 3 && (isequal(bytes(1:3), uint8([255 216 255])) || ... - ~isempty(strfind(probe, uint8([255 216 255])))) + containsByteSequence(probe, uint8([255 216 255]))) type = "JPG"; else type = "DAT"; end end +function tf = containsByteSequence(bytes, signature) + tf = false; + lastStart = numel(bytes) - numel(signature) + 1; + for startIndex = 1:lastStart + stopIndex = startIndex + numel(signature) - 1; + if isequal(bytes(startIndex:stopIndex), signature) + tf = true; + return; + end + end +end + function info = parseCameraInfo(bytes) info = struct(); base = 1; diff --git a/+labkit/+thermal/rawToTemperatureC.m b/+labkit/+thermal/rawToTemperatureC.m index 4c009551f..00e9439fa 100644 --- a/+labkit/+thermal/rawToTemperatureC.m +++ b/+labkit/+thermal/rawToTemperatureC.m @@ -176,7 +176,8 @@ function assertPlanckCalibration(calibration) fields = string(fieldnames(defaults)); parameters = struct(); sources = struct(); - defaultedFields = strings(0, 1); + defaultedFields = strings(numel(fields), 1); + defaultedCount = 0; for k = 1:numel(fields) field = fields(k); fallback = defaults.(field); @@ -188,11 +189,13 @@ function assertPlanckCalibration(calibration) parameters.(field) = value; if usedDefault sources.(field) = "default"; - defaultedFields(end + 1, 1) = field; + defaultedCount = defaultedCount + 1; + defaultedFields(defaultedCount) = field; else sources.(field) = "calibration"; end end + defaultedFields = defaultedFields(1:defaultedCount); diagnostics = conversionDiagnostics("environment", defaultedFields, sources); end diff --git a/+labkit/+thermal/readFiles.m b/+labkit/+thermal/readFiles.m index 01fd26e99..636fd94d6 100644 --- a/+labkit/+thermal/readFiles.m +++ b/+labkit/+thermal/readFiles.m @@ -61,23 +61,30 @@ paths = normalizePaths(paths, opts.AllowEmpty); template = emptyRecord(); - records = repmat(template, 0, 1); - failures = repmat(emptyFailure(), 0, 1); + records = repmat(template, numel(paths), 1); + failures = repmat(emptyFailure(), numel(paths), 1); + recordCount = 0; + failureCount = 0; for k = 1:numel(paths) path = paths(k); reportProgress(opts.progressFcn, "beforeRead", k, numel(paths), path); readOpts = rmfield(opts, {'AllowEmpty', 'progressFcn', 'SkipInvalid'}); try - records(end + 1, 1) = labkit.thermal.readFile(path, readOpts); + record = labkit.thermal.readFile(path, readOpts); + recordCount = recordCount + 1; + records(recordCount, 1) = record; reportProgress(opts.progressFcn, "afterRead", k, numel(paths), path); catch ME if ~opts.SkipInvalid rethrow(ME); end - failures(end + 1, 1) = failureFromException(path, ME); + failureCount = failureCount + 1; + failures(failureCount, 1) = failureFromException(path, ME); reportProgress(opts.progressFcn, "skipped", k, numel(paths), path); end end + records = records(1:recordCount); + failures = failures(1:failureCount); report = struct( ... 'requested', numel(paths), ... diff --git a/+labkit/AGENTS.md b/+labkit/AGENTS.md index 0b81f6cd4..2052e2719 100644 --- a/+labkit/AGENTS.md +++ b/+labkit/AGENTS.md @@ -15,7 +15,8 @@ tests, and the one owning manual: - RHS: `docs/libraries/rhs/README.md` - biosignal: `docs/libraries/biosignal/README.md` -Framework behavior specifications live under `tests/specs/framework//`. +Library behavior specifications mirror the package below +`tests/specs/labkit//`. Use `labkittest.explain` to find the exact owner and contract. ## Ownership @@ -113,7 +114,8 @@ Use `labkittest.explain` to find the exact owner and contract. ## Validation -Use the affected `labkit_framework/` suite and add downstream app-family -or hidden-GUI coverage when the app-facing contract can change. Package -boundary and public-surface changes also run project guardrails. Exact commands -belong in `docs/development/maintain-and-release/testing.md`. +Use `labkittest.run(File=...)` to select the affected `labkit/` owner and +add downstream app-family or hidden-GUI coverage when the app-facing contract +can change. Package boundary and public-surface changes also run project +guardrails. Exact commands belong in +`docs/development/maintain-and-release/testing.md`. diff --git a/.agents/dos-and-donts.md b/.agents/dos-and-donts.md index 31a6462b0..445f6de37 100644 --- a/.agents/dos-and-donts.md +++ b/.agents/dos-and-donts.md @@ -22,6 +22,7 @@ second architecture guide. ## Incubating lessons -None. Add a lesson only after a future checkpoint produces recurring evidence -that is not already owned by an `AGENTS.md`, Skill, test, source contract, -manual, or component history record. +- When the same ownership boundary needs a second local workaround, stop + extending the patch chain. Reconstruct the complete entry-to-owner call + graph, state the invariant that should make resolution deterministic, and + replace the boundary coherently before spending more time on symptoms. diff --git a/AGENTS.md b/AGENTS.md index d8acfaf38..6e4bc93e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,10 +44,14 @@ under `docs/`. durable policy here or in the nearest scoped `AGENTS.md`; keep step-by-step agent procedure in skills rather than duplicating it in human manuals. - Treat `.agents/dos-and-donts.md` as an experience reservoir. After each - meaningful checkpoint, add only high-value experience whose rediscovery - would be costly. Let lessons accumulate and survive repeated use before - promoting them to the nearest `AGENTS.md`, skill, test, source contract, or - manual; then actively compress or remove the reservoir copy plus stale, + meaningful checkpoint, explicitly review repeated inspection, discarded + approaches, rollback, time lost on the same boundary, and user correction. + Record only the unresolved agent decision trap whose rediscovery would be + costly, including the signal that should trigger a different approach. + Never use the reservoir as a work log or duplicate behavior already enforced + by an `AGENTS.md`, skill, test, source contract, or manual. Let useful + lessons survive repeated use before promotion; once another owner prevents + the mistake, actively compress or remove the reservoir copy plus stale, duplicated, disproven, and low-value detail. ## Architecture and implementation @@ -72,6 +76,12 @@ under `docs/`. governed by `+labkit/AGENTS.md`. - Do not convert struct state into classes, merge all apps into one entrypoint, or change implementation language without explicit approval. +- Call fixed production symbols directly so static analysis, dependency + discovery, and refactoring can see them. Use `eval`, string-based `feval`, + or `str2func` only at a genuinely dynamic extension or compatibility + boundary with closed input validation, explicit ownership, and contract + tests; never construct a callable symbol from untrusted project or user + data. - File budgets count nonblank, non-comment MATLAB code. They are review backstops, not extraction targets. Keep callback-local glue local when that makes workflow order clearer. @@ -158,6 +168,10 @@ tests, history, and details out of the public repository. - Automated hidden GUI tests do not prove native dialogs, visual quality, pointer feel, scientific validity, or full manual workflows. Do not run interactive workflows in MATLAB `-batch` mode. +- A validation entry point expected to run longer than 30 seconds reports its + current stage and completed/total work, and emits a heartbeat at least every + 30 seconds while one unit remains active. Reuse the owning progress plugin + or callback instead of making callers infer progress from process liveness. - For an accepted private workspace, run its own tests first. If `.labkit-accept-main-guardrails` is present and private changes are unpushed, also run the relevant public guardrail because the public changed-file diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/enhanceReferenceImage.m b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/enhanceReferenceImage.m index 3f8bf0c3f..9fcad5677 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/enhanceReferenceImage.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/enhanceReferenceImage.m @@ -3,7 +3,7 @@ % enhanced RGB image data. Side effects: none. function img = enhanceReferenceImage(referenceImage, opts) img = localIm2double(referenceImage); - if ndims(img) == 2 + if ismatrix(img) img = repmat(img, [1 1 3]); end gains = reshape(opts.rgbGain, 1, 1, 3); diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/extendStrainMapToRoi.m b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/extendStrainMapToRoi.m index eb4c6448c..8e6163424 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/extendStrainMapToRoi.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/extendStrainMapToRoi.m @@ -21,7 +21,7 @@ validValues = S(validMap); [invalidRows, invalidCols] = find(invalid); - if numel(validValues) == 1 + if isscalar(validValues) values = repmat(validValues, size(invalidRows)); return; end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m index 2f972ee9b..f7455efa8 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m @@ -19,7 +19,7 @@ function drawOne(ax, model) isequaln(background.CData, model.imageData); if ~sameImage labkit.app.plot.clearAxes(ax); - if ndims(model.imageData) == 2 + if ismatrix(model.imageData) background = imagesc(ax, model.imageData); colormap(ax, gray(256)); else diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m index 58eba890b..060ac9741 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m @@ -3,10 +3,11 @@ % file side effects. function T = buildOverlayExportTable(items) - timeUnion = []; + timeChunks = cell(numel(items), 1); for i = 1:numel(items) - timeUnion = [timeUnion; chronoAlignedTime(items(i))]; + timeChunks{i} = chronoAlignedTime(items(i)); end + timeUnion = vertcat(timeChunks{:}); timeUnion = unique(timeUnion); timeUnion = sort(timeUnion); diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/loadProjectItems.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/loadProjectItems.m index 19f0405d9..da4aa7854 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/loadProjectItems.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/loadProjectItems.m @@ -1,8 +1,8 @@ % Expected caller: Chrono Overlay session creation. Input is runtime-resolved % paths. Output is the rebuildable decoded and pulse-aligned DTA item vector. function items = loadProjectItems(paths) - items = struct([]); paths = string(paths(:)); + itemCells = cell(1, numel(paths)); for k = 1:numel(paths) filepath = paths(k); [item, status] = labkit.dta.loadFile(filepath, "chrono"); @@ -11,10 +11,10 @@ 'Could not load %s: %s', filepath, status.message); end item = chrono_overlay.sourceFiles.alignByPulseGap(item); - if isempty(items) - items = item; - else - items(end + 1) = item; - end + itemCells{k} = item; + end + items = struct([]); + if ~isempty(itemCells) + items = [itemCells{:}]; end end diff --git a/apps/electrochem/cic/+cic/+analysisRun/buildCurrentSummary.m b/apps/electrochem/cic/+cic/+analysisRun/buildCurrentSummary.m index 86da098f7..038107284 100644 --- a/apps/electrochem/cic/+cic/+analysisRun/buildCurrentSummary.m +++ b/apps/electrochem/cic/+cic/+analysisRun/buildCurrentSummary.m @@ -93,15 +93,19 @@ out = '-'; return; end - safeIdx = []; - vals = []; + safeIdx = zeros(1, numel(items)); + vals = zeros(1, numel(items)); + safeCount = 0; for i = 1:numel(items) A = itemAnalysis(items(i)); if ~isempty(A) && isfield(A, 'ok') && A.ok && isfield(A, 'safe') && A.safe - safeIdx(end+1) = i; - vals(end+1) = selectedCICValue(A, modeLabel); + safeCount = safeCount + 1; + safeIdx(safeCount) = i; + vals(safeCount) = selectedCICValue(A, modeLabel); end end + safeIdx = safeIdx(1:safeCount); + vals = vals(1:safeCount); if isempty(safeIdx) out = 'No safe file in current batch'; return; diff --git a/apps/electrochem/cic/+cic/+sourceFiles/loadProjectItems.m b/apps/electrochem/cic/+cic/+sourceFiles/loadProjectItems.m index b6f822a0b..03c393a2e 100644 --- a/apps/electrochem/cic/+cic/+sourceFiles/loadProjectItems.m +++ b/apps/electrochem/cic/+cic/+sourceFiles/loadProjectItems.m @@ -2,9 +2,9 @@ % and durable analysis parameters. Output is the rebuildable decoded and % analyzed DTA item vector; invalid required sources raise an app error. function items = loadProjectItems(sources, parameters) - items = struct([]); opts = cic.analysisRun.optionsFromParameters(parameters); paths = string(sources); + itemCells = cell(1, numel(paths)); for k = 1:numel(sources) filepath = paths(k); [item, status] = labkit.dta.loadFile(filepath, "chrono"); @@ -13,10 +13,10 @@ filepath, status.message); end item.analysis = cic.analysisRun.computeCIC(item, opts); - if isempty(items) - items = item; - else - items(end + 1) = item; - end + itemCells{k} = item; + end + items = struct([]); + if ~isempty(itemCells) + items = [itemCells{:}]; end end diff --git a/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m b/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m index e0f2b491d..3ba626a90 100644 --- a/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m +++ b/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m @@ -260,7 +260,8 @@ case char(choices.modes(3)) R.IcathDisp(I >= 0) = NaN; R.IanodDisp(I <= 0) = NaN; - dtErrList = []; + dtErrList = zeros(1, 2 * max(numel(t) - 1, 0)); + dtErrCount = 0; useCV = isscalar(scanRate) && isfinite(scanRate) && scanRate > 0; for k = 1:numel(t)-1 @@ -272,12 +273,14 @@ case char(choices.modes(3)) continue; end - bp = [0, 1]; + bp = [0, 1, NaN]; + bpCount = 2; s0 = crossingFraction(I1, I2, 0); if ~isempty(s0) - bp(end+1) = s0; + bpCount = bpCount + 1; + bp(bpCount) = s0; end - bp = unique(sort(bp)); + bp = unique(sort(bp(1:bpCount))); for j = 1:numel(bp)-1 sa = bp(j); @@ -300,7 +303,8 @@ case char(choices.modes(3)) if useCV dt_act = tb - ta; dt_cv = abs(Vb - Va) / scanRate; - dtErrList(end+1) = abs(dt_act - dt_cv); + dtErrCount = dtErrCount + 1; + dtErrList(dtErrCount) = abs(dt_act - dt_cv); if Imid < 0 R.QcvCath = R.QcvCath + abs(trapz([0 dt_cv], [Ia Ib])); @@ -311,8 +315,8 @@ case char(choices.modes(3)) end end - if ~isempty(dtErrList) - R.dtErr = max(dtErrList); + if dtErrCount > 0 + R.dtErr = max(dtErrList(1:dtErrCount)); end end diff --git a/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m b/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m index 58499ea53..3643edbe4 100644 --- a/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m +++ b/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m @@ -19,12 +19,15 @@ function rows = collectRows(items, opts) choices = csc.analysisRun.analysisChoices(); - rowCells = {}; + capacity = sum(arrayfun(@(item) max(1, numel(itemCurves(item))), items)); + rowCells = cell(1, capacity); + rowCount = 0; for iItem = 1:numel(items) item = items(iItem); curves = itemCurves(item); if isempty(curves) - rowCells{end + 1} = failedRow(item, 0, "", opts, "No curve found"); + rowCount = rowCount + 1; + rowCells{rowCount} = failedRow(item, 0, "", opts, "No curve found"); continue; end curveIndices = selectedCurveIndices(numel(curves), opts); @@ -34,9 +37,11 @@ 'mode', char(choices.modes(1)), ... 'scanRate', itemScanRate(item), ... 'area_cm2', opts.area_cm2)); - rowCells{end + 1} = resultRow(item, iCurve, curve, opts, result); + rowCount = rowCount + 1; + rowCells{rowCount} = resultRow(item, iCurve, curve, opts, result); end end + rowCells = rowCells(1:rowCount); if isempty(rowCells) rows = emptyRows(); else diff --git a/apps/electrochem/csc/+csc/+resultFiles/writeVoltageCurrentCSV.m b/apps/electrochem/csc/+csc/+resultFiles/writeVoltageCurrentCSV.m index b94b16660..0ac437214 100644 --- a/apps/electrochem/csc/+csc/+resultFiles/writeVoltageCurrentCSV.m +++ b/apps/electrochem/csc/+csc/+resultFiles/writeVoltageCurrentCSV.m @@ -52,7 +52,10 @@ function records = collectRecords(items, opts) opts = fillOptions(opts); - recordCells = {}; + capacity = sum(arrayfun(@(item) ... + double(isfield(item, 'curves')) * numel(itemCurvesForCapacity(item)), items)); + recordCells = cell(1, capacity); + recordCount = 0; for iItem = 1:numel(items) item = items(iItem); if ~isfield(item, 'curves') || isempty(item.curves) @@ -62,10 +65,12 @@ for iCurve = indices record = curveRecord(item, iItem, iCurve); if record.ok - recordCells{end + 1} = rmfield(record, 'ok'); + recordCount = recordCount + 1; + recordCells{recordCount} = rmfield(record, 'ok'); end end end + recordCells = recordCells(1:recordCount); if isempty(recordCells) records = struct([]); else @@ -73,6 +78,13 @@ end end +function curves = itemCurvesForCapacity(item) + curves = struct([]); + if isfield(item, 'curves') + curves = item.curves; + end +end + function record = curveRecord(item, itemIndex, curveIndex) curve = item.curves(curveIndex); voltage = exactColumn(curve, 'Vf'); diff --git a/apps/electrochem/csc/+csc/+sourceFiles/loadProjectItems.m b/apps/electrochem/csc/+csc/+sourceFiles/loadProjectItems.m index 3f7f4c205..03316223d 100644 --- a/apps/electrochem/csc/+csc/+sourceFiles/loadProjectItems.m +++ b/apps/electrochem/csc/+csc/+sourceFiles/loadProjectItems.m @@ -1,8 +1,8 @@ % Expected caller: CSC session creation. Input is resolved DTA source % records. Output is decoded CV/CT items; invalid required sources throw. function items = loadProjectItems(sources) - items = struct([]); paths = string(sources); + itemCells = cell(1, numel(paths)); for k = 1:numel(sources) filepath = paths(k); [item, status] = labkit.dta.loadFile(filepath, "cvct"); @@ -10,10 +10,10 @@ error('csc:SourceLoadFailed', 'Could not load %s: %s', ... filepath, status.message); end - if isempty(items) - items = item; - else - items(end + 1) = item; - end + itemCells{k} = item; + end + items = struct([]); + if ~isempty(itemCells) + items = [itemCells{:}]; end end diff --git a/apps/electrochem/csc/+csc/+workbench/present.m b/apps/electrochem/csc/+csc/+workbench/present.m index 6f6233af2..e24b1d8d7 100644 --- a/apps/electrochem/csc/+csc/+workbench/present.m +++ b/apps/electrochem/csc/+csc/+workbench/present.m @@ -46,9 +46,10 @@ return end item = items(itemIndex); -labels = choices.allCycles; +labels = strings(1, numel(item.curves) + 1); +labels(1) = choices.allCycles; for k = 1:numel(item.curves) - labels(end + 1) = string(sprintf("%s (%d rows)", ... + labels(k + 1) = string(sprintf("%s (%d rows)", ... item.curves(k).name, size(item.curves(k).data, 1))); end if isempty(item.curves) diff --git a/apps/electrochem/eis/+eis/+sourceFiles/buildSummary.m b/apps/electrochem/eis/+eis/+sourceFiles/buildSummary.m index 6f6a40f0f..bfb990e2d 100644 --- a/apps/electrochem/eis/+eis/+sourceFiles/buildSummary.m +++ b/apps/electrochem/eis/+eis/+sourceFiles/buildSummary.m @@ -2,13 +2,13 @@ % stable summary text cell array. No side effects. function summary = buildSummary(items) - summary = cell(0, 1); - summary{end+1} = sprintf('Loaded files: %d', numel(items)); + summary = cell(numel(items) + 1, 1); + summary{1} = sprintf('Loaded files: %d', numel(items)); for i = 1:numel(items) freq = items(i).freq_Hz; fmin = min(freq, [], 'omitnan'); fmax = max(freq, [], 'omitnan'); - summary{end+1} = sprintf('%s | N=%d | Freq %.4g to %.4g Hz | order: %s', ... + summary{i + 1} = sprintf('%s | N=%d | Freq %.4g to %.4g Hz | order: %s', ... items(i).name, items(i).n, fmin, fmax, ternary(items(i).freqDesc, 'high->low', 'low->high/mixed')); end end diff --git a/apps/electrochem/eis/+eis/+sourceFiles/loadProjectItems.m b/apps/electrochem/eis/+eis/+sourceFiles/loadProjectItems.m index 77710680e..f99653f19 100644 --- a/apps/electrochem/eis/+eis/+sourceFiles/loadProjectItems.m +++ b/apps/electrochem/eis/+eis/+sourceFiles/loadProjectItems.m @@ -1,8 +1,8 @@ % Expected caller: EIS session creation. Input is resolved EIS source records. % Output is decoded EIS items; invalid required sources raise an app error. function items = loadProjectItems(paths) - items = struct([]); paths = string(paths); + itemCells = cell(1, numel(paths)); for k = 1:numel(paths) filepath = paths(k); [item, status] = labkit.dta.loadFile(filepath, "eis"); @@ -10,10 +10,10 @@ error('eis:SourceLoadFailed', 'Could not load %s: %s', ... filepath, status.message); end - if isempty(items) - items = item; - else - items(end + 1) = item; - end + itemCells{k} = item; + end + items = struct([]); + if ~isempty(itemCells) + items = [itemCells{:}]; end end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadProjectItems.m b/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadProjectItems.m index 8d98f8dc7..a5f7ed10b 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadProjectItems.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadProjectItems.m @@ -2,9 +2,9 @@ % and durable parameters. Output is the rebuildable decoded and % analyzed DTA item vector; invalid required sources raise an app error. function items = loadProjectItems(sources, parameters) - items = struct([]); opts = vt_resistance.analysisRun.optionsFromParameters(parameters); paths = string(sources); + itemCells = cell(1, numel(paths)); for k = 1:numel(sources) filepath = paths(k); [item, status] = labkit.dta.loadFile(filepath, "chrono"); @@ -13,10 +13,10 @@ 'Could not load %s: %s', filepath, status.message); end item.analysis = vt_resistance.analysisRun.computeResistance(item, opts); - if isempty(items) - items = item; - else - items(end + 1) = item; - end + itemCells{k} = item; + end + items = struct([]); + if ~isempty(itemCells) + items = [itemCells{:}]; end end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m index 8f3688d45..b199d1ab7 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m @@ -1,11 +1,11 @@ % App-owned implementation for gait_analysis.stepPreview.select within the gait_analysis product workflow. function applicationState = select( ... - applicationState, selection, callbackContext) + applicationState, selection, ~) %SELECT Use the selected result-table row as the active gait step. arguments applicationState (1, 1) struct selection (1, 1) labkit.app.event.TableCellSelection - callbackContext (1, 1) labkit.app.CallbackContext + ~ end if isempty(selection.CellIndices) return diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/clampCropCenterToCanvas.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/clampCropCenterToCanvas.m index e5d7f0cb7..ab99aba85 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/clampCropCenterToCanvas.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/clampCropCenterToCanvas.m @@ -106,7 +106,7 @@ else mask = true(size(geometry.canvas, 1), size(geometry.canvas, 2)); end - if ndims(mask) > 2 + if ~ismatrix(mask) mask = mask(:, :, 1); end if ~any(mask, "all") diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropCanvasFixedSize.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropCanvasFixedSize.m index 02cee694a..c6b113522 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropCanvasFixedSize.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropCanvasFixedSize.m @@ -24,7 +24,7 @@ srcColStart = max(1, colStart); srcColEnd = min(canvasWidth, colEnd); - if ndims(canvas) == 2 + if ismatrix(canvas) cropped = repmat(fillValue, height, width); else cropped = repmat(fillValue, height, width, size(canvas, 3)); diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/padImageEdges.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/padImageEdges.m index ac251ab8a..99fb63387 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/padImageEdges.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/padImageEdges.m @@ -32,7 +32,7 @@ rowIdx = reflectedSubscripts(rowCoords, height); colIdx = reflectedSubscripts(colCoords, width); - if ndims(imageData) == 2 + if ismatrix(imageData) padded = repaired(rowIdx, colIdx); else padded = repaired(rowIdx, colIdx, :); @@ -88,7 +88,7 @@ function validateImageData(imageData) return; end - if ndims(imageData) == 2 + if ismatrix(imageData) repaired = repairPlane(imageData, rowWidth, colWidth); else repaired = imageData; diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotateCanvas.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotateCanvas.m index 909c877f0..785802d22 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotateCanvas.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotateCanvas.m @@ -65,7 +65,7 @@ function canvas = interpolateImage(imageData, xInput, yInput, mask, fillValue) outHeight = size(xInput, 1); outWidth = size(xInput, 2); - if ndims(imageData) == 2 + if ismatrix(imageData) canvas = interpolatePlane(imageData, xInput, yInput, mask, fillValue); else canvas = repmat(castFillValue(fillValue, imageData), ... diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m index ad1512df2..271c302e7 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m @@ -11,7 +11,7 @@ function draw(axesById, model) box(ax, 'on'); return; end - if ndims(model.imageData) == 2 + if ismatrix(model.imageData) imagesc(ax, model.xData, model.yData, model.imageData); colormap(ax, gray(256)); else diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/exportPlan.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/exportPlan.m index a9688dcfa..dd5c3a467 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/exportPlan.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/exportPlan.m @@ -22,7 +22,7 @@ "outputFolder=" + plan.outputFolder optionLines(plan.options) "itemCount=" + string(numel(items)) - plan.itemFingerprints], sprintf('\n')); + plan.itemFingerprints], newline); end function optsOut = normalizeOptions(opts) diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/missingWorkflowItemsText.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/missingWorkflowItemsText.m index 586ca1470..5097125a2 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/missingWorkflowItemsText.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/missingWorkflowItemsText.m @@ -24,12 +24,15 @@ end function names = missingItemNames(items, missingKind) - names = strings(0, 1); + names = strings(numel(items), 1); + nameCount = 0; for k = 1:numel(items) if isMissing(items(k), missingKind) - names(end + 1, 1) = labkit.image.displayName(items(k).path); + nameCount = nameCount + 1; + names(nameCount, 1) = labkit.image.displayName(items(k).path); end end + names = names(1:nameCount); end function tf = isMissing(item, missingKind) diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/writeOutputs.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/writeOutputs.m index d0f02aa68..54a3cbe25 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/writeOutputs.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/writeOutputs.m @@ -33,7 +33,8 @@ end results = repmat(batch_crop.resultFiles.emptyResult(), numel(items), 1); - reservedPaths = strings(0, 1); + reservedPaths = strings(numel(items), 1); + reservedCount = 0; for k = 1:numel(items) result = batch_crop.resultFiles.emptyResult(); result.sourcePath = string(items(k).path); @@ -50,8 +51,10 @@ crop = batch_crop.cropGeometry.cropImage(items(k).image, cropOpts); end outputPath = uniqueBatchCropOutputPath(outputFolder, ... - string(items(k).path), outputFormat.extension, reservedPaths, "_crop"); - reservedPaths(end+1, 1) = outputPath; + string(items(k).path), outputFormat.extension, ... + reservedPaths(1:reservedCount), "_crop"); + reservedCount = reservedCount + 1; + reservedPaths(reservedCount, 1) = outputPath; labkit.image.writeFile(crop.image, outputPath); result = crop; @@ -69,7 +72,8 @@ manifest = batch_crop.resultFiles.buildManifest(results); manifestPath = uniqueBatchCropOutputPath(outputFolder, ... - "batch_crop_manifest.csv", ".csv", reservedPaths, ""); + "batch_crop_manifest.csv", ".csv", ... + reservedPaths(1:reservedCount), ""); writetable(manifest, char(manifestPath)); payload = struct(); diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectionChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectionChanged.m index e34bbb467..6dec4ef80 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectionChanged.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectionChanged.m @@ -6,7 +6,7 @@ items = applicationState.project.inputs.items; wasEmpty = isempty(items); sourceIds = string({sources.id}); -retained = repmat(batch_crop.cropTasks.emptyTask(), 0, 1); +retained = repmat(batch_crop.cropTasks.emptyTask(), numel(sourceIds), 1); for k = 1:numel(sourceIds) match = find(string({items.sourceId}) == sourceIds(k), 1); if isempty(match) @@ -15,7 +15,7 @@ else task = items(match); end - retained(end + 1, 1) = task; + retained(k, 1) = task; end applicationState.project.inputs.items = retained; applicationState.project.results = ... diff --git a/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m b/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m index 1dc697985..993ed7fd9 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m +++ b/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m @@ -63,8 +63,9 @@ end items = project.inputs.items; sources = project.inputs.sources; -taskSources = emptySources(); -usedSourceIds = strings(0, 1); +taskSourceCells = cell(numel(items), 1); +usedSourceIds = strings(numel(items), 1); +sourceCount = 0; for k = 1:numel(items) match = find(string({sources.id}) == string(items(k).sourceId), 1); if isempty(match) @@ -73,16 +74,18 @@ source = sources(match); sourceId = string(source.id); if any(usedSourceIds == sourceId) - sourceId = nextSourceId(sources, taskSources); + sourceId = nextSourceId( ... + sources, vertcat(taskSourceCells{1:sourceCount})); source.id = sourceId; items(k).sourceId = sourceId; end - if isempty(taskSources) - taskSources = source; - else - taskSources(end + 1, 1) = source; - end - usedSourceIds(end + 1, 1) = sourceId; + sourceCount = sourceCount + 1; + taskSourceCells{sourceCount, 1} = source; + usedSourceIds(sourceCount, 1) = sourceId; +end +taskSources = emptySources(); +if sourceCount > 0 + taskSources = vertcat(taskSourceCells{1:sourceCount}); end project.inputs.items = items; project.inputs.sources = taskSources; @@ -110,26 +113,28 @@ return; end items = project.inputs.items; - sources = struct([]); - sourcePaths = strings(0, 1); + sourceCells = cell(numel(items), 1); + sourcePaths = strings(numel(items), 1); + sourceCount = 0; for k = 1:numel(items) path = string(items(k).path); match = find(sourcePaths == path, 1, 'first'); if isempty(match) - sourceId = "image" + string(numel(sources) + 1); + sourceId = "image" + string(sourceCount + 1); source = labkit.app.project.sourceRecord( ... sourceId, "cropSource", path, true); - if isempty(sources) - sources = source; - else - sources(end + 1, 1) = source; - end - sourcePaths(end + 1, 1) = path; + sourceCount = sourceCount + 1; + sourceCells{sourceCount} = source; + sourcePaths(sourceCount, 1) = path; else - sourceId = string(sources(match).id); + sourceId = string(sourceCells{match}.id); end items(k).sourceId = sourceId; end + sources = emptySources(); + if sourceCount > 0 + sources = vertcat(sourceCells{1:sourceCount}); + end project.inputs.items = rmfield(items, 'path'); project.inputs.sources = sources; end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/fitTask.m b/apps/image_measurement/curvature/+curvature/+analysisRun/fitTask.m index a22434180..b1153f477 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/fitTask.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/fitTask.m @@ -36,7 +36,7 @@ calibrationToken(task.calibration) pointBlock("point", task.points) pointBlock("fitPath", task.fitPath)]; - fingerprint = strjoin(lines, sprintf('\n')); + fingerprint = strjoin(lines, newline); end function points = normalizePoints(points) @@ -52,9 +52,10 @@ end function lines = pointBlock(label, points) - lines = label + "Count=" + string(size(points, 1)); + lines = strings(size(points, 1) + 1, 1); + lines(1) = label + "Count=" + string(size(points, 1)); for k = 1:size(points, 1) - lines(end + 1, 1) = label + "[" + string(k) + "]=" + ... + lines(k + 1, 1) = label + "[" + string(k) + "]=" + ... numberToken(points(k, 1)) + "," + numberToken(points(k, 2)); end end @@ -67,7 +68,7 @@ "unit=" + string(calibration.unit) "pixelsPerUnit=" + numberToken(calibration.pixelsPerUnit) "isCalibrated=" + string(logical(calibration.isCalibrated)) - pointBlock("referenceLine", referenceLine)], sprintf('\n')); + pointBlock("referenceLine", referenceLine)], newline); end function token = numberToken(value) diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/lengthTask.m b/apps/image_measurement/curvature/+curvature/+analysisRun/lengthTask.m index 3716271a6..e6690c2c9 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/lengthTask.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/lengthTask.m @@ -28,7 +28,7 @@ calibrationToken(task.calibration) pointBlock("point", task.points) pointBlock("lengthPath", task.lengthPath)]; - fingerprint = strjoin(lines, sprintf('\n')); + fingerprint = strjoin(lines, newline); end function points = normalizePoints(points) @@ -44,9 +44,10 @@ end function lines = pointBlock(label, points) - lines = label + "Count=" + string(size(points, 1)); + lines = strings(size(points, 1) + 1, 1); + lines(1) = label + "Count=" + string(size(points, 1)); for k = 1:size(points, 1) - lines(end + 1, 1) = label + "[" + string(k) + "]=" + ... + lines(k + 1, 1) = label + "[" + string(k) + "]=" + ... numberToken(points(k, 1)) + "," + numberToken(points(k, 2)); end end @@ -59,7 +60,7 @@ "unit=" + string(calibration.unit) "pixelsPerUnit=" + numberToken(calibration.pixelsPerUnit) "isCalibrated=" + string(logical(calibration.isCalibrated)) - pointBlock("referenceLine", referenceLine)], sprintf('\n')); + pointBlock("referenceLine", referenceLine)], newline); end function token = numberToken(value) diff --git a/apps/image_measurement/curvature/+curvature/+curvePreview/draw.m b/apps/image_measurement/curvature/+curvature/+curvePreview/draw.m index 1447bebc1..6ecab7990 100644 --- a/apps/image_measurement/curvature/+curvature/+curvePreview/draw.m +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/draw.m @@ -9,7 +9,7 @@ function draw(axesById, model) box(ax, "on"); return end -if ndims(model.imageData) == 2 +if ismatrix(model.imageData) imagesc(ax, model.imageData, ... HitTest="off", PickableParts="none"); colormap(ax, gray(256)); diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/extremeTemperatureReadings.m b/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/extremeTemperatureReadings.m index db673f680..c7efd1c3b 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/extremeTemperatureReadings.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/extremeTemperatureReadings.m @@ -40,7 +40,7 @@ values = double(temperatureC); hotSpot = emptyReading(); coldSpot = emptyReading(); - if isempty(values) || ndims(values) ~= 2 + if isempty(values) || ~ismatrix(values) return; end finiteMask = isfinite(values); diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/pointTemperatureReading.m b/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/pointTemperatureReading.m index 43deccf14..71ab8c256 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/pointTemperatureReading.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/pointTemperatureReading.m @@ -39,7 +39,7 @@ values = double(temperatureC); reading = emptyReading(); - if isempty(values) || ndims(values) ~= 2 + if isempty(values) || ~ismatrix(values) return; end pointXY = double(pointXY(:)).'; diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/roiTemperatureMeanReading.m b/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/roiTemperatureMeanReading.m index 22b287302..caa65f166 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/roiTemperatureMeanReading.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+analysisRun/roiTemperatureMeanReading.m @@ -49,7 +49,7 @@ hotSpot = emptyPointReading(); coldSpot = emptyPointReading(); meanReading = emptyRoiReading(); - if isempty(values) || ndims(values) ~= 2 + if isempty(values) || ~ismatrix(values) return; end startXY = double(startXY(:)).'; diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/groupRange.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/groupRange.m index ddad8fee0..6ca14c035 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/groupRange.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/groupRange.m @@ -57,17 +57,24 @@ function applicationState = storeAll(applicationState, items) sources = applicationState.project.inputs.sources; annotations = applicationState.project.annotations.items; +annotationCells = cell(1, numel(annotations) + numel(items)); +annotationCount = numel(annotations); +annotationCells(1:annotationCount) = num2cell(annotations); for k = 1:numel(items) annotation = flir_thermal.thermalAnnotations.fromItem( ... items(k), sources(k).id); match = find(string({annotations.sourceId}) == ... string(sources(k).id), 1); if isempty(match) - annotations(end + 1, 1) = annotation; + annotationCount = annotationCount + 1; + annotationCells{annotationCount} = annotation; else - annotations(match) = annotation; + annotationCells{match} = annotation; end end +if annotationCount > 0 + annotations = [annotationCells{1:annotationCount}].'; +end applicationState.project.annotations.items = annotations; index = applicationState.session.selection.currentIndex; if index >= 1 && index <= numel(items) diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/perImageRange.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/perImageRange.m index 7dee9616a..f243e1545 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/perImageRange.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/perImageRange.m @@ -17,6 +17,9 @@ return end annotations = applicationState.project.annotations.items; +annotationCells = cell(1, numel(annotations) + numel(items)); +annotationCount = numel(annotations); +annotationCells(1:annotationCount) = num2cell(annotations); for k = 1:numel(items) old = find(string({annotations.sourceId}) == ... string(sources(k).id), 1); @@ -38,11 +41,15 @@ annotation = flir_thermal.thermalAnnotations.fromItem( ... items(k), sources(k).id); if isempty(old) - annotations(end + 1, 1) = annotation; + annotationCount = annotationCount + 1; + annotationCells{annotationCount} = annotation; else - annotations(old) = annotation; + annotationCells{old} = annotation; end end +if annotationCount > 0 + annotations = [annotationCells{1:annotationCount}].'; +end applicationState.project.annotations.items = annotations; index = applicationState.session.selection.currentIndex; if index >= 1 && index <= numel(items) diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/detailLines.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/detailLines.m index 65ba6cba8..ba430b422 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/detailLines.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/detailLines.m @@ -87,7 +87,9 @@ readingTemperature(item, 'roiHotSpot') readingTemperature(item, 'roiColdSpot') readingTemperature(item, 'roiMean')]; - parts = strings(1, 0); + pairCapacity = numel(values) * (numel(values) - 1) / 2; + parts = strings(1, pairCapacity); + partCount = 0; for a = 1:numel(values) if ~isfinite(values(a)) continue; @@ -96,10 +98,12 @@ if ~isfinite(values(b)) continue; end - parts(end+1) = sprintf('%s - %s = %.2f C', ... + partCount = partCount + 1; + parts(partCount) = sprintf('%s - %s = %.2f C', ... names(a), names(b), values(a) - values(b)); end end + parts = parts(1:partCount); if isempty(parts) text = "-"; else diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/alignImages.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/alignImages.m index 5235bc37a..9731de35f 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/alignImages.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/alignImages.m @@ -20,22 +20,27 @@ if numel(images) < 2 return; end + lines = cell(1, numel(images)); referenceIndex = round((numel(images) + 1) / 2); reference = images{referenceIndex}; - lines{end+1} = sprintf('Registration reference image: %d.', referenceIndex); + lineCount = 1; + lines{lineCount} = sprintf('Registration reference image: %d.', referenceIndex); for k = 1:numel(images) if k == referenceIndex continue; end try [alignedImages{k}, method] = alignImageToReference(reference, images{k}); - lines{end+1} = sprintf('Registered image %d using %s.', k, method); + lineCount = lineCount + 1; + lines{lineCount} = sprintf('Registered image %d using %s.', k, method); catch ME alignedImages{k} = focus_stack.analysisRun.resizeImageToReference(images{k}, size(reference)); - lines{end+1} = sprintf('Image %d registration skipped: %s', k, ME.message); + lineCount = lineCount + 1; + lines{lineCount} = sprintf('Image %d registration skipped: %s', k, ME.message); end end + lines = lines(1:lineCount); end function [alignedImage, method] = alignImageToReference(referenceImage, movingImage) @@ -70,7 +75,7 @@ end function fillValues = backgroundFillValues(imageData) - if ndims(imageData) == 2 + if ismatrix(imageData) border = [imageData(1, :), imageData(end, :), imageData(:, 1).', imageData(:, end).']; fillValues = median(double(border(:))); return; @@ -125,7 +130,7 @@ return; end - if ndims(imageIn) == 2 + if ismatrix(imageIn) imageOut(dstRows, dstCols) = imageIn(srcRows, srcCols); else imageOut(dstRows, dstCols, :) = imageIn(srcRows, srcCols, :); @@ -134,7 +139,7 @@ function imageOut = filledImageLike(imageIn, fillValues) imageOut = zeros(size(imageIn), class(imageIn)); - if ndims(imageIn) == 2 + if ismatrix(imageIn) imageOut(:) = cast(fillValues(1), class(imageIn)); return; end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/computeFocusStack.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/computeFocusStack.m index a2d61bcb7..6d8cb7da3 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/computeFocusStack.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/computeFocusStack.m @@ -297,7 +297,7 @@ end return; end - if ndims(img) == 2 || size(img, 3) == 1 + if ismatrix(img) || size(img, 3) == 1 img = repmat(img(:, :, 1), [1 1 3]); elseif size(img, 3) > 3 img = img(:, :, 1:3); diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/resizeImageToSize.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/resizeImageToSize.m index 14e0711e7..19285e9df 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/resizeImageToSize.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/resizeImageToSize.m @@ -21,7 +21,7 @@ return; end imageOut = resizeLinear(imageIn, [targetRows targetCols]); - if ndims(imageIn) == 3 && size(imageIn, 3) == 1 && ndims(imageOut) == 2 + if ndims(imageIn) == 3 && size(imageIn, 3) == 1 && ismatrix(imageOut) imageOut = reshape(imageOut, targetRows, targetCols, 1); end end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/runTask.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/runTask.m index 0511f87ed..5c185691c 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/runTask.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/runTask.m @@ -27,7 +27,8 @@ end function fingerprint = taskFingerprint(task, images) - lines = [ + lines = strings(numel(images) + 6, 1); + lines(1:6) = [ "app=focus_stack" "registerStack=" + string(task.registerStack) "focusWindow=" + numberToken(task.options.focusWindow) @@ -40,11 +41,11 @@ if k <= numel(task.sourcePaths) pathValue = task.sourcePaths(k); end - lines(end + 1, 1) = "image[" + string(k) + "]=" + ... + lines(k + 6, 1) = "image[" + string(k) + "]=" + ... pathValue + "|image=" + imageToken(images{k}); end - fingerprint = strjoin(lines, sprintf('\n')); + fingerprint = strjoin(lines, newline); end function token = imageToken(imageData) diff --git a/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/details.m b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/details.m index ce52cfa74..3798fd12e 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/details.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/details.m @@ -4,7 +4,8 @@ function lines = details(result, paths, registrationLines) %DETAILS Return user-facing focus-stack detail lines. - lines = { ... + lines = cell(1, 5 + result.inputCount + 1 + numel(registrationLines)); + lines(1:5) = { ... sprintf('Method: %s', result.method), ... sprintf('Fused size: %d x %d px, channels: %d', ... result.imageWidth, result.imageHeight, result.channelCount), ... @@ -14,11 +15,13 @@ 'Selected pixel coverage by source:'}; names = focus_stack.focusPreview.displayImageNamesForDetails(paths, result.inputCount); for k = 1:result.inputCount - lines{end+1} = sprintf(' %d. %s: %.2f%%', ... + lines{5 + k} = sprintf(' %d. %s: %.2f%%', ... k, names{k}, 100 * result.focusCoverage(k)); end if ~isempty(registrationLines) - lines{end+1} = 'Registration:'; - lines = [lines, registrationLines(:).']; + lines{6 + result.inputCount} = 'Registration:'; + lines(7 + result.inputCount:end) = registrationLines(:).'; + else + lines = lines(1:5 + result.inputCount); end end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/chooseFolder.m b/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/chooseFolder.m index 809b9a018..1dab03b3b 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/chooseFolder.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/chooseFolder.m @@ -27,12 +27,13 @@ "Could not load the focus image folder."); return; end -incoming = labkit.app.project.emptySourceRecords(); +incoming = repmat(labkit.app.project.sourceRecord( ... + "placeholder", "focus-image", "", true), numel(paths), 1); for index = 1:numel(paths) record = labkit.app.project.sourceRecord( ... "image_" + compose("%03d", index), ... "focus-image", paths(index), true); - incoming(end + 1, 1) = record; + incoming(index, 1) = record; end applicationState.project.inputs.sources = incoming; applicationState = focus_stack.analysisRun.invalidate( ... diff --git a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/previewResult.m b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/previewResult.m index ec893ecb5..1edbb6799 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/previewResult.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/previewResult.m @@ -3,9 +3,9 @@ function imageOut = previewResult(sourceImage, steps, whiteRoi, scale) scale = max(eps, double(scale)); for k = 1:numel(steps) - kind = lower(regexprep(char(string(steps(k).kind)), ... - '[^a-zA-Z0-9]', '')); - if any(strcmp(kind, {'localcontrast', 'sharpen'})) + kind = regexprep(char(string(steps(k).kind)), ... + '[^a-zA-Z0-9]', ''); + if any(strcmpi(kind, {'localcontrast', 'sharpen'})) steps(k).secondary = steps(k).secondary .* scale; end end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeAmount.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeAmount.m index 146e9ed87..6457f2706 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeAmount.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeAmount.m @@ -1,6 +1,6 @@ % App-owned implementation for image_enhance.enhancementPipeline.changeAmount within the image_enhance product workflow. function applicationState = changeAmount( ... - applicationState, value, callbackContext) + applicationState, value, ~) %CHANGEAMOUNT Update and preview the primary tool parameter. defaults = image_enhance.analysisRun.defaultStepValues( ... applicationState.session.view.toolKind); diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeSecondary.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeSecondary.m index e38456d35..8e863edcf 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeSecondary.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeSecondary.m @@ -1,6 +1,6 @@ % App-owned implementation for image_enhance.enhancementPipeline.changeSecondary within the image_enhance product workflow. function applicationState = changeSecondary( ... - applicationState, value, callbackContext) + applicationState, value, ~) %CHANGESECONDARY Update and preview the secondary tool parameter. defaults = image_enhance.analysisRun.defaultStepValues( ... applicationState.session.view.toolKind); diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/undo.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/undo.m index 8489ca8bc..6c3477026 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/undo.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/undo.m @@ -5,7 +5,6 @@ if isempty(steps) return; end -removed = steps(end); steps(end) = []; steps = steps(:); applicationState = image_enhance.analysisRun.setActiveSteps( ... diff --git a/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/beforeAfterImage.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/beforeAfterImage.m index 53a249392..ff3cc2714 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/beforeAfterImage.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/beforeAfterImage.m @@ -15,7 +15,7 @@ function imageOut = normalizePreviewImage(imageIn) imageOut = min(max(labkit.image.im2double(imageIn), 0), 1); - if ndims(imageOut) == 2 + if ismatrix(imageOut) imageOut = repmat(imageOut, 1, 1, 3); end end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/exportTask.m b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/exportTask.m index 51998ed5a..ed20e9718 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/exportTask.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/exportTask.m @@ -20,7 +20,13 @@ end function fingerprint = taskFingerprint(items, steps, task) - lines = [ + itemStepLineCount = 0; + if ~isempty(task.itemSteps) + itemStepLineCount = numel(task.itemSteps) + ... + sum(cellfun(@numel, task.itemSteps)); + end + lines = strings(5 + numel(items) + numel(steps) + itemStepLineCount, 1); + lines(1:5) = [ "app=image_enhance" "outputFolder=" + task.outputFolder "format=" + task.options.format @@ -28,27 +34,32 @@ "stepCount=" + string(numel(steps))]; for k = 1:numel(items) - lines(end + 1, 1) = "source[" + string(k) + "]=" + ... + lines(5 + k, 1) = "source[" + string(k) + "]=" + ... string(items(k).path) + "|image=" + imageToken(items(k).image); end for k = 1:numel(steps) - lines(end + 1, 1) = "step[" + string(k) + "]=" + stepToken(steps(k)); + lines(5 + numel(items) + k, 1) = ... + "step[" + string(k) + "]=" + stepToken(steps(k)); end if ~isempty(task.itemSteps) + nextLine = 5 + numel(items) + numel(steps) + 1; for k = 1:numel(task.itemSteps) itemStepLines = itemStepTokens(k, task.itemSteps{k}); - lines = [lines; itemStepLines]; + destination = nextLine:nextLine + numel(itemStepLines) - 1; + lines(destination) = itemStepLines; + nextLine = destination(end) + 1; end end - fingerprint = strjoin(lines, sprintf('\n')); + fingerprint = strjoin(lines, newline); end function lines = itemStepTokens(itemIndex, steps) - lines = "itemStepCount[" + string(itemIndex) + "]=" + string(numel(steps)); + lines = strings(numel(steps) + 1, 1); + lines(1) = "itemStepCount[" + string(itemIndex) + "]=" + string(numel(steps)); for k = 1:numel(steps) - lines(end + 1, 1) = "itemStep[" + string(itemIndex) + "," + ... + lines(k + 1, 1) = "itemStep[" + string(itemIndex) + "," + ... string(k) + "]=" + stepToken(steps(k)); end end diff --git a/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m b/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m index 2923f0bfb..4d21feed4 100644 --- a/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m +++ b/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m @@ -326,11 +326,6 @@ value = percentileValues(data, pct); end -function imageData = normalizeImage(imageData) - imageData = labkit.image.ensureRgb(labkit.image.im2double(imageData)); - imageData = min(max(imageData, 0), 1); -end - function gray = luma(imageData) gray = labkit.image.rgb2gray(imageData); end diff --git a/apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/beforeAfterImage.m b/apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/beforeAfterImage.m index dc8075f2a..75e883c1a 100644 --- a/apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/beforeAfterImage.m +++ b/apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/beforeAfterImage.m @@ -15,7 +15,7 @@ function imageOut = normalizePreviewImage(imageIn) imageOut = min(max(labkit.image.im2double(imageIn), 0), 1); - if ndims(imageOut) == 2 + if ismatrix(imageOut) imageOut = repmat(imageOut, 1, 1, 3); end end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/settingsChanged.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/settingsChanged.m index 693ae6893..a0b3959ad 100644 --- a/apps/image_measurement/image_match/+image_match/+matchPipeline/settingsChanged.m +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/settingsChanged.m @@ -1,6 +1,6 @@ % App-owned implementation for image_match.matchPipeline.settingsChanged within the image_match product workflow. function applicationState = settingsChanged( ... - applicationState, changedValue, callbackContext) + applicationState, ~, callbackContext) %SETTINGSCHANGED Preview one bounded draft without committing history. parameters = applicationState.project.parameters; method = string(parameters.matchMethod); diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/undo.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/undo.m index a2152c77c..cf28bb29d 100644 --- a/apps/image_measurement/image_match/+image_match/+matchPipeline/undo.m +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/undo.m @@ -6,7 +6,6 @@ return end steps = steps(:); -removed = steps(end); steps(end) = []; steps = steps(:); applicationState.project.annotations.steps = steps; diff --git a/apps/image_measurement/image_match/+image_match/+resultFiles/exportTask.m b/apps/image_measurement/image_match/+image_match/+resultFiles/exportTask.m index dbc6e75f1..5ac9b4fc7 100644 --- a/apps/image_measurement/image_match/+image_match/+resultFiles/exportTask.m +++ b/apps/image_measurement/image_match/+image_match/+resultFiles/exportTask.m @@ -20,7 +20,8 @@ end function fingerprint = taskFingerprint(items, referenceItem, steps, task) - lines = [ + lines = strings(6 + numel(items) + numel(steps), 1); + lines(1:6) = [ "app=image_match" "outputFolder=" + task.outputFolder "format=" + task.options.format @@ -29,15 +30,16 @@ "stepCount=" + string(numel(steps))]; for k = 1:numel(items) - lines(end + 1, 1) = "source[" + string(k) + "]=" + ... + lines(6 + k, 1) = "source[" + string(k) + "]=" + ... string(items(k).path) + "|image=" + imageToken(items(k).image); end for k = 1:numel(steps) - lines(end + 1, 1) = "step[" + string(k) + "]=" + stepToken(steps(k)); + lines(6 + numel(items) + k, 1) = ... + "step[" + string(k) + "]=" + stepToken(steps(k)); end - fingerprint = strjoin(lines, sprintf('\n')); + fingerprint = strjoin(lines, newline); end function pathValue = referencePath(referenceItem) diff --git a/apps/image_measurement/video_marker/+video_marker/+motionEstimate/trackPoints.m b/apps/image_measurement/video_marker/+video_marker/+motionEstimate/trackPoints.m index c3317172a..f35266098 100644 --- a/apps/image_measurement/video_marker/+video_marker/+motionEstimate/trackPoints.m +++ b/apps/image_measurement/video_marker/+video_marker/+motionEstimate/trackPoints.m @@ -157,12 +157,14 @@ end function reduced = downsampleByTwo(image) - lastRow = 2 .* floor(size(image, 1) ./ 2); - lastColumn = 2 .* floor(size(image, 2) ./ 2); - oddRows = 1:2:lastRow; - evenRows = 2:2:lastRow; - oddColumns = 1:2:lastColumn; - evenColumns = 2:2:lastColumn; + lastRow = 2 * floor(size(image, 1) / 2); + lastColumn = 2 * floor(size(image, 2) / 2); + rowPairs = reshape(1:lastRow, 2, []); + columnPairs = reshape(1:lastColumn, 2, []); + oddRows = rowPairs(1, :); + evenRows = rowPairs(2, :); + oddColumns = columnPairs(1, :); + evenColumns = columnPairs(2, :); reduced = (image(oddRows, oddColumns) + image(evenRows, oddColumns) + ... image(oddRows, evenColumns) + image(evenRows, evenColumns)) ./ 4; end @@ -231,8 +233,8 @@ if patchEnergy <= minimumPatchEnergy continue; end - correlation = sum(template(:) .* patch(:)) ./ ... - (templateEnergy .* patchEnergy); + correlation = sum(template(:) .* patch(:)) / ... + (templateEnergy * patchEnergy); scoreGrid(yOffset + searchRadius + 1, ... xOffset + searchRadius + 1) = correlation; if correlation > bestScore diff --git a/apps/image_measurement/video_marker/+video_marker/+syntheticInputs/writeSamplePack.m b/apps/image_measurement/video_marker/+video_marker/+syntheticInputs/writeSamplePack.m index 7d657a246..ac42fb1f6 100644 --- a/apps/image_measurement/video_marker/+video_marker/+syntheticInputs/writeSamplePack.m +++ b/apps/image_measurement/video_marker/+video_marker/+syntheticInputs/writeSamplePack.m @@ -10,7 +10,7 @@ videoPath = sampleContext.samplePath( ... "video_marker/synthetic_video_marker.avi"); writeSyntheticVideo(videoPath); -[reader, info] = video_marker.videoSource.openVideo(videoPath); +[~, info] = video_marker.videoSource.openVideo(videoPath); clear reader preset = video_marker.skeletonSetup.presets(); diff --git a/apps/image_measurement/video_marker/+video_marker/+videoPreview/draw.m b/apps/image_measurement/video_marker/+video_marker/+videoPreview/draw.m index 0f86c784d..a20d72a2d 100644 --- a/apps/image_measurement/video_marker/+video_marker/+videoPreview/draw.m +++ b/apps/image_measurement/video_marker/+video_marker/+videoPreview/draw.m @@ -9,7 +9,7 @@ function draw(axesById, model) box(ax, "on"); return end -if ndims(model.image) == 2 +if ismatrix(model.image) imagesc(ax, model.image); colormap(ax, gray(256)); else diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportCurrent.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportCurrent.m index f0b8e3d4f..998a20954 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportCurrent.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportCurrent.m @@ -71,5 +71,5 @@ stem = "figure"; end name = string(matlab.lang.makeValidName(char(stem))) + "_" + ... - string(datestr(now, "yyyymmdd_HHMMSS")); + string(datetime("now", "Format", "yyyyMMdd_HHmmss")); end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportGraphic.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportGraphic.m index 7e9be6657..de8f74262 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportGraphic.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportGraphic.m @@ -18,7 +18,7 @@ if chosen.Cancelled return end -[fig, ax] = figure_studio.resultFiles.createStyledFigure( ... +[fig, ~] = figure_studio.resultFiles.createStyledFigure( ... state.session.cache.plotData, state.project.parameters.style, ... state.session.cache.sourceAxes); cleanup = onCleanup(@() delete(fig)); diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/extractAxesData.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/extractAxesData.m index 22fd82ed4..ec81ec7f6 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/extractAxesData.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/extractAxesData.m @@ -41,18 +41,23 @@ end function children = expandVisibleGroups(children) - expanded = gobjects(0, 1); + if isempty(children) + children = gobjects(0, 1); + return; + end + chunks = cell(numel(children), 1); for k = 1:numel(children) child = children(k); if isgraphics(child, 'hggroup') if isprop(child, 'Visible') && string(child.Visible) == "off" continue; end - expanded = [expanded; expandVisibleGroups(flipud(allchild(child)))]; + chunks{k} = expandVisibleGroups(flipud(allchild(child))); else - expanded(end + 1, 1) = child; + chunks{k} = child; end end + expanded = vertcat(chunks{:}); children = expanded; end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/writeAxesDataExport.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/writeAxesDataExport.m index 671b665ed..75fc18a81 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/writeAxesDataExport.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/writeAxesDataExport.m @@ -38,7 +38,8 @@ return; end - rows = cell(0, 1); + rows = cell(numel(plotData.objects), 1); + rowCount = 0; for k = 1:numel(plotData.objects) object = plotData.objects(k); n = min(numel(object.x), numel(object.y)); @@ -56,8 +57,10 @@ else one.z = double(object.z(1:n)); end - rows{end + 1, 1} = one; + rowCount = rowCount + 1; + rows{rowCount, 1} = one; end + rows = rows(1:rowCount); if isempty(rows) return; end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/panelChanged.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/panelChanged.m index 32fd12f55..c5f5ac025 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/panelChanged.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/panelChanged.m @@ -1,8 +1,8 @@ % App-owned implementation for Figure Studio's active single-panel selector. -function state = panelChanged(state, event, callbackContext) +function state = panelChanged(state, ~, callbackContext) arguments state (1, 1) struct - event + ~ callbackContext (1, 1) labkit.app.CallbackContext end resource = callbackContext.getResource("document", "sourceFigure"); diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/sourceStyle.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/sourceStyle.m index c1d9192d4..328dd2885 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/sourceStyle.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/sourceStyle.m @@ -128,7 +128,8 @@ value = fallback; labels = {ax.Title, ax.XLabel, ax.YLabel, ax.ZLabel}; texts = findall(ax, 'Type', 'text'); - sizes = zeros(0, 1); + sizes = zeros(numel(texts), 1); + sizeCount = 0; for k = 1:numel(texts) if any(cellfun(@(label) texts(k) == label, labels)) continue; @@ -136,9 +137,11 @@ sizeValue = finiteValue(optionalAxesValue( ... texts(k), 'FontSize'), NaN); if isfinite(sizeValue) - sizes(end + 1, 1) = sizeValue; + sizeCount = sizeCount + 1; + sizes(sizeCount) = sizeValue; end end + sizes = sizes(1:sizeCount); if ~isempty(sizes) value = median(sizes); end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/analyzeRecording.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/analyzeRecording.m index ab1a025d3..68f152ec9 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/analyzeRecording.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/analyzeRecording.m @@ -338,7 +338,8 @@ return; end names = string(fieldnames(roles)); - pairCells = {}; + pairCells = cell(1, numel(names)); + pairCount = 0; for k = 1:numel(names) role = roles.(char(names(k))); roleId = string(fieldOrDefault(role, "id", names(k))); @@ -359,19 +360,20 @@ if strlength(baseId) == 0 baseId = "pair"; end - pairCells{end + 1} = struct( ... + pairCount = pairCount + 1; + pairCells{pairCount} = struct( ... "id", baseId + "_diff", ... "label", upper(baseId), ... "positive", roleId, ... "negative", negativeId, ... "mode", "positive-minus-negative"); end - if ~isempty(pairCells) - pairs = [pairCells{:}]; + if pairCount > 0 + pairs = [pairCells{1:pairCount}]; end end -function result = analyzePair(filepath, pair, roles, protocol, ... +function result = analyzePair(filepath, pair, roles, ~, ... maxDurationSec, eventTimesSec, recordingId) result = struct("metrics", emptyMetrics(), "issues", emptyIssues()); pairId = string(fieldOrDefault(pair, "id", "pair")); @@ -452,16 +454,22 @@ end function output = appendTables(output, tables) + chunks = cell(numel(tables) + 1, 1); + chunkCount = 0; + if ~isempty(output) && width(output) > 0 + chunkCount = 1; + chunks{chunkCount} = output; + end for k = 1:numel(tables) input = tables{k}; if isempty(input) || height(input) == 0 continue; end - if isempty(output) || width(output) == 0 - output = input; - else - output = [output; input]; - end + chunkCount = chunkCount + 1; + chunks{chunkCount} = input; + end + if chunkCount > 0 + output = vertcat(chunks{1:chunkCount}); end end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+sourceFiles/parseSegmentTable.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+sourceFiles/parseSegmentTable.m index b80de4825..d647d4976 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+sourceFiles/parseSegmentTable.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+sourceFiles/parseSegmentTable.m @@ -18,22 +18,25 @@ if ~isempty(timeIdx) timeSec = double(T{:, timeIdx}); signalIdx = setdiff(1:width(T), timeIdx); - segmentCells = {}; + segmentCells = cell(1, numel(signalIdx)); + segmentCount = 0; for k = signalIdx if ~isnumeric(T{:, k}) continue; end - segmentCells{end+1} = makeSegment(names(k), timeSec, ... + segmentCount = segmentCount + 1; + segmentCells{segmentCount} = makeSegment(names(k), timeSec, ... double(T{:, k})); end - if ~isempty(segmentCells) - segments = [segmentCells{:}]; + if segmentCount > 0 + segments = [segmentCells{1:segmentCount}]; end return; end timeCols = find(endsWith(normalizeName(names), "times")); - segmentCells = {}; + segmentCells = cell(1, numel(timeCols)); + segmentCount = 0; for k = 1:numel(timeCols) timeCol = timeCols(k); prefix = erase(normalizeName(names(timeCol)), "times"); @@ -43,11 +46,12 @@ end label = erase(string(names(timeCol)), "_Time_s"); label = erase(label, "_Time"); - segmentCells{end+1} = makeSegment(label, double(T{:, timeCol}), ... + segmentCount = segmentCount + 1; + segmentCells{segmentCount} = makeSegment(label, double(T{:, timeCol}), ... double(T{:, signalCol})); end - if ~isempty(segmentCells) - segments = [segmentCells{:}]; + if segmentCount > 0 + segments = [segmentCells{1:segmentCount}]; end if isempty(segments) diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/drawPreview.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/drawPreview.m index e07583169..2b5626093 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/drawPreview.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/drawPreview.m @@ -1,6 +1,6 @@ % Expected caller: RHS Preview direct callbacks. Inputs are one axes handle and app state % with an optional preview window. Side effect redraws stacked RHS waveforms. -function drawStackedPreview(axesById, S) +function drawPreview(axesById, S) %DRAWSTACKEDPREVIEW Draw time-aligned stacked RHS preview traces. ax = axesById.main; diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editFileFilter.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editFileFilter.m index c96b4f010..12eacc9c8 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editFileFilter.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editFileFilter.m @@ -1,11 +1,11 @@ % App-owned implementation for rhs_preview.analysisRun.editFileFilter within the rhs_preview product workflow. function applicationState = editFileFilter( ... - applicationState, edit, callbackContext) + applicationState, edit, ~) %EDITFILEFILTER Apply typed label/comment edits and persist their source order. arguments applicationState (1, 1) struct edit (1, 1) labkit.app.event.TableCellEdit - callbackContext (1, 1) labkit.app.CallbackContext + ~ end rows = rhs_preview.analysisRun.applyFileFilterTableData( ... applicationState.session.cache.filterRows, edit.Data); diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/protocolJsonStruct.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/protocolJsonStruct.m index 22005d247..0297a5242 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/protocolJsonStruct.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/protocolJsonStruct.m @@ -54,10 +54,3 @@ roles = [roleCells{:}]; end end - -function value = fieldOrDefault(S, fieldName, defaultValue) - value = defaultValue; - if isstruct(S) && isfield(S, fieldName) - value = S.(fieldName); - end -end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/replaceFromTableEdit.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/replaceFromTableEdit.m index 44d7b5261..33acb7d81 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/replaceFromTableEdit.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/replaceFromTableEdit.m @@ -38,7 +38,8 @@ function [groups, ok, message] = groupsFromRows( ... labels, values, priorGroups) -groups = repmat(ttest_wizard.groupData.emptyGroup("Group 1"), 0, 1); +groups = repmat(ttest_wizard.groupData.emptyGroup("Group 1"), numel(values), 1); +groupCount = 0; ok = true; message = ""; priorLabel = ""; @@ -64,7 +65,7 @@ 'Row %d Value must be one finite number.', row); return; end - groupIndex = find(strcmpi(label, [groups.label]), 1); + groupIndex = find(strcmpi(label, [groups(1:groupCount).label]), 1); if isempty(groupIndex) group = ttest_wizard.groupData.emptyGroup(label); priorIndex = find(strcmpi(label, [priorGroups.label]), 1); @@ -75,12 +76,14 @@ else group.sourceDisplayName = "Manual data table"; end - groups(end + 1, 1) = group; - groupIndex = numel(groups); + groupCount = groupCount + 1; + groups(groupCount, 1) = group; + groupIndex = groupCount; end groups(groupIndex).values(end + 1, 1) = number; priorLabel = groups(groupIndex).label; end +groups = groups(1:groupCount); end function tf = isBlankValue(value) diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/selectRows.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/selectRows.m index c0b78d232..f7c57a26a 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/selectRows.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/selectRows.m @@ -1,5 +1,5 @@ % App-owned implementation for ttest_wizard.groupData.selectRows within the ttest_wizard product workflow. -function state = selectRows(state, selection, context) +function state = selectRows(state, selection, ~) %SELECTROWS Remember editable analysis-table cells selected by the user. % % Expected caller: dataTable OnCellSelectionChanged. This transition stores @@ -8,7 +8,7 @@ arguments state (1, 1) struct selection (1, 1) labkit.app.event.TableCellSelection - context (1, 1) labkit.app.CallbackContext + ~ end state.session.selection.analysisCells = double(selection.CellIndices); diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/selectCells.m b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/selectCells.m index 01ffeea30..cad929178 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/selectCells.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/selectCells.m @@ -1,5 +1,5 @@ % App-owned implementation for ttest_wizard.sourceTable.selectCells within the ttest_wizard product workflow. -function state = selectCells(state, selection, context) +function state = selectCells(state, selection, ~) %SELECTCELLS Remember source-table cells and summarize numeric usability. % % Expected caller: sourceGrid OnCellSelectionChanged. Selection is the typed @@ -9,7 +9,7 @@ arguments state (1, 1) struct selection (1, 1) labkit.app.event.TableCellSelection - context (1, 1) labkit.app.CallbackContext + ~ end indices = selection.CellIndices; diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/validateSettings.m b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/validateSettings.m index 92cc166ec..1b74f841f 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/validateSettings.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/validateSettings.m @@ -1,5 +1,5 @@ % App-owned implementation for ttest_wizard.testRun.validateSettings within the ttest_wizard product workflow. -function state = validateSettings(state, newValue, context) +function state = validateSettings(state, ~, context) %VALIDATESETTINGS Keep the user-entered significance level in its legal range. % % Expected caller: test-setting OnValueChanged callbacks. The changed value @@ -8,7 +8,7 @@ arguments state (1, 1) struct - newValue + ~ context (1, 1) labkit.app.CallbackContext end diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewFileHeader.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewFileHeader.m index ed9d778f2..d0a236940 100644 --- a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewFileHeader.m +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewFileHeader.m @@ -5,7 +5,8 @@ function lines = previewFileHeader(filepath, maxLines) %PREVIEWFILEHEADER Return numbered header preview lines for the ECG Print app. - lines = {}; + lines = cell(maxLines, 1); + lineCount = 0; fid = fopen(filepath, 'r'); if fid < 0 lines = {'Could not open file preview.'}; @@ -17,8 +18,10 @@ if ~ischar(line) break; end - lines{end+1, 1} = sprintf('%02d: %s', k, line); + lineCount = lineCount + 1; + lines{lineCount, 1} = sprintf('%02d: %s', k, line); end + lines = lines(1:lineCount); if isempty(lines) lines = {'File is empty or could not be previewed.'}; end diff --git a/docs/development/maintain-and-release/testing.md b/docs/development/maintain-and-release/testing.md index 047ae9f1c..34c896f3c 100644 --- a/docs/development/maintain-and-release/testing.md +++ b/docs/development/maintain-and-release/testing.md @@ -57,12 +57,20 @@ execution environment. ```text tests/specs/ -├── framework// +├── labkit//... +├── tools//... +├── tests/labkittest/ ├── apps//// ├── apps/conformance/ -└── system// +├── labkit_launcher/ +└── repository/ ``` +The owner path is a mechanical mirror of the production capability. Do not +replace source paths with logical aliases such as `framework/` or +`system/`; contract and environment tags describe behavior without +changing physical ownership. + `tests/+testfixtures/` contains only synthetic inputs reused by multiple specification owners. Keep an owner-specific fixture beside its specification; do not create a generic `shared`, `support`, or `helpers` test directory. diff --git a/docs/development/tools/profiling.md b/docs/development/tools/profiling.md index 75024d948..5fe359321 100644 --- a/docs/development/tools/profiling.md +++ b/docs/development/tools/profiling.md @@ -2,8 +2,8 @@ `profileLabKitTarget` runs a MATLAB target under the profiler and exports an interactive HTML report plus a complete JSON sidecar. It supports launcher and -app startup, callbacks, scripts, functions, command strings, and already -captured `profile("info")` data. +app startup, callbacks, scripts, functions, and already captured +`profile("info")` data. ## Syntax @@ -27,8 +27,10 @@ addpath(fullfile(repoRoot, "tools", "profiling")) ``` Calling the function with an empty target opens a MATLAB file picker. A target -may also be a function handle, function name, command string, `.m` path, or a -profile-info struct containing `FunctionTable`. +may also be a function handle, resolvable function name, `.m` path, or a +profile-info struct containing `FunctionTable`. Use a function handle when the +target needs arguments or setup state; arbitrary command strings are not +executed. ## Options diff --git a/docs/history/records/2026/07/LK-20260730-v4-stability-contract-closure.md b/docs/history/records/2026/07/LK-20260730-v4-stability-contract-closure.md index 7165cb11f..fe42caf97 100644 --- a/docs/history/records/2026/07/LK-20260730-v4-stability-contract-closure.md +++ b/docs/history/records/2026/07/LK-20260730-v4-stability-contract-closure.md @@ -39,6 +39,9 @@ scope: Project source ownership and external overwrite protection scope: Scientific input validation and machine-readable failures scope: Native dialog path memory and Launcher delegation scope: Repository Skill contracts +scope: Static production call visibility +scope: Code Analyzer zero-finding closure +scope: Path-mirrored public and private test ownership ``` ## Context @@ -69,9 +72,22 @@ unchanged. invalid-input handling. - Replaced append-only runtime status history with one current status value and avoided redundant Launcher path refresh on a correctly resolved install. +- Made the fixed repair-root dispatcher and fixed Launcher tool calls + statically visible. The three remaining production `feval` boundaries now + validate a discovered App file, a legacy named MATLAB callback, or an exact + profiler target file, and a repository test rejects any additional hidden + production call. - Added repository Skill manifests, activation examples, deterministic validation, CI routing, and a scientific-change workflow; compressed promoted experience out of the reservoir. +- Replaced the `framework/*`, `system/*`, and private `tests/cases` test + taxonomies with production-path mirrors. Public and accepted private + specifications now use the same path-derived owner, contract/environment + metadata, compiled plan, progress, and heartbeat contracts. +- Cleared Code Analyzer information, warning, and error findings across the + public MATLAB source and test tree without suppression pragmas. Collection + preallocation preserves each empty cell, struct, string, and numeric shape + rather than relying on append growth or untyped concatenation. ## User and data impact @@ -87,17 +103,24 @@ The change is backward compatible. Existing project payload versions, saved source records, result schemas, and exports require no migration. Omitted `SourceBindings` keep the former inference behavior, and the restored dialog preferences reuse the published `LabKit/LastInputFolder` and -`LabKit/LastOutputFolder` keys. +`LabKit/LastOutputFolder` keys. The maintainer profiler no longer accepts an +arbitrary command string; pass a function handle for calls that need arguments +or setup state. ## Validation Focused App, facade, project, Launcher, repository-policy, and documentation specifications cover the changed contracts. The final branch gate selects the complete base-to-head evidence closure; required pull-request CI owns the -supported MATLAB platform matrix. On Windows R2025b Update 3, the same warm -`labkit_launcher("list")` profiler scenario decreased from about 0.614 seconds -before the fast path to 0.243 seconds after it; nine ordinary warm calls had a -0.133-second median. +supported MATLAB platform matrix. On Windows R2025b Update 3, the final static +resolver's profiled `labkit_launcher("list")` call spent about 0.666 seconds in +the Launcher, while nine subsequent ordinary warm calls had a 0.138-second +median. Repository architecture tests also scan all production MATLAB sources +for hidden dynamic invocation, and dedicated profiler tests prove arbitrary +command strings are rejected before execution. +The final Code Analyzer report covers 1,559 MATLAB files and contains zero +ordinary and zero suppressed findings; focused regression runs cover the +typed-empty and preallocation paths changed while reaching that state. ## Evidence diff --git a/labkit_launcher.m b/labkit_launcher.m index f92317d5c..f123c191b 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -31,26 +31,19 @@ return; end try - dispatcher = str2func("labkit.app.internal.launcher.dispatch"); - if ~resolvesInstalledDispatch(dispatcher, entry) - searchPath = pathsep + string(path) + pathsep; - if contains(searchPath, pathsep + string(root) + pathsep) - rmpath(root); - end - addpath(root, "-begin"); - rehash; - dispatcher = []; + if ~installedDispatchResolves(entry) + prioritizeInstalledRoot(root); clear('labkit.app.internal.launcher.dispatch'); - dispatcher = str2func("labkit.app.internal.launcher.dispatch"); - if ~resolvesInstalledDispatch(dispatcher, entry) + rehash; + if ~installedDispatchResolves(entry) error("labkit_launcher:InstalledEntryMismatch", ... "The installed launcher entry does not resolve from this LabKit installation."); end end if nargout == 0 - dispatcher(root, varargin{:}); + labkit.app.internal.launcher.dispatch(root, varargin{:}); else - [varargout{1:nargout}] = dispatcher(root, varargin{:}); + [varargout{1:nargout}] = labkit.app.internal.launcher.dispatch(root, varargin{:}); end catch cause if isempty(varargin) && isStructuralDelegateFailure(cause) @@ -96,22 +89,25 @@ startsWith(identifier, "MATLAB:parse") || startsWith(identifier, "MATLAB:dispatcher"); end -function tf = resolvesInstalledDispatch(dispatcher, entry) +function tf = installedDispatchResolves(entry) tf = false; try - details = functions(dispatcher); - resolvedFile = ""; - if isfield(details, "file") - resolvedFile = string(details.file); - end - if strlength(resolvedFile) == 0 && isfield(details, "function") - resolvedFile = string(which(char(details.function))); - end + resolvedFile = string(which('labkit.app.internal.launcher.dispatch')); tf = strlength(resolvedFile) > 0 && sameNormalizedPath(resolvedFile, entry); catch end end +function prioritizeInstalledRoot(root) +entries = string(strsplit(path, pathsep)); +for k = numel(entries):-1:1 + if strlength(entries(k)) > 0 && sameNormalizedPath(entries(k), root) + rmpath(char(entries(k))); + end +end +addpath(root, "-begin"); +end + function tf = sameNormalizedPath(leftPath, rightPath) leftPath = normalizedPath(leftPath); rightPath = normalizedPath(rightPath); diff --git a/tests/+labkittest/catalog.m b/tests/+labkittest/catalog.m index 9ff6efc07..2e24edc39 100644 --- a/tests/+labkittest/catalog.m +++ b/tests/+labkittest/catalog.m @@ -5,7 +5,8 @@ % matlab.unittest.Test identity. % % DESCRIPTORS = labkittest.catalog(Owner=OWNER) limits discovery to a -% path-derived test owner such as "apps/electrochem/cic/analysisRun". +% path-derived test owner such as "apps/electrochem/cic/analysisRun" or +% "tools/codecheck". % OWNER must name a directory below tests/specs; it is never a substring % selector. % @@ -163,8 +164,8 @@ if numel(contractTags) ~= 1 || numel(environmentTags) ~= 1 || ... ~isempty(unexpected) error("LabKit:TestCatalog:InvalidMetadata", ... - ["Each test must declare exactly one Contract: and one " + ... - "Env: tag, with no other tags: %s."], test.Name); + "Each test must declare exactly one Contract: and one " + ... + "Env: tag, with no other tags: %s.", test.Name); end contracts = lower(extractAfter(contractTags, contractPrefix)); environment = lower(extractAfter(environmentTags, environmentPrefix)); diff --git a/tests/+labkittest/classifyPath.m b/tests/+labkittest/classifyPath.m index 3b325cfb8..f855d1f52 100644 --- a/tests/+labkittest/classifyPath.m +++ b/tests/+labkittest/classifyPath.m @@ -21,18 +21,18 @@ end if startsWith(file, "tests/+labkittest/") || file == "buildfile.m" || ... startsWith(file, ".github/") - classification = mapped(file, "validation-framework", "system/build", ... + classification = mapped(file, "validation-framework", "tests/labkittest", ... "validation framework or CI policy behavior"); return; end if startsWith(file, ".agents/skills/") && endsWith(file, ".m") - classification = mapped(file, "validation-framework", "system/build", ... + classification = mapped(file, "validation-framework", "tests/labkittest", ... "Skill-owned MATLAB automation executes repository behavior"); return; end if startsWith(file, ".agents/") || file == "AGENTS.md" || ... endsWith(file, "/AGENTS.md") || file == ".gitignore" - classification = mapped(file, "repository-policy", "system/repository", ... + classification = mapped(file, "repository-policy", "repository", ... "repository policy behavior"); return; end @@ -43,13 +43,23 @@ return; end if startsWith(file, "tools/maintenance/") - classification = mapped(file, "maintenance-tool", "system/maintenance", ... + classification = mapped(file, "maintenance-tool", "tools/maintenance", ... "independently callable repository maintenance tool"); return; end - if file == "tools/deployment/manageLabKitVersions.m" - classification = mapped(file, "deployment-tool", "system/deployment", ... - "independently callable deployment and version-management tool"); + if startsWith(file, "tools/profiling/") + classification = mapped(file, "profiling-tool", "tools/profiling", ... + "independently callable profiling target and report behavior"); + return; + end + if startsWith(file, "tools/codecheck/") + classification = mapped(file, "codecheck-tool", "tools/codecheck", ... + "independently callable static-analysis and report behavior"); + return; + end + if startsWith(file, "tools/deployment/") + classification = mapped(file, "deployment-tool", "tools/deployment", ... + "independently callable packaging and version-management behavior"); return; end if startsWith(file, "artifacts/") || startsWith(file, ".Trash/") || ... @@ -59,24 +69,25 @@ return; end if startsWith(file, "tests/+testfixtures/") - classification = mapped(file, "test-fixture", "system/build", ... + classification = mapped(file, "test-fixture", "tests/labkittest", ... "cross-owner synthetic fixture behavior"); return; end if file == "labkit_launcher.m" - classification = mapped(file, "repair-launcher", "system/launcher", ... + classification = mapped(file, "repair-launcher", "labkit_launcher", ... "self-contained repair-launcher bootstrap behavior"); return; end if startsWith(file, "+labkit/+app/+internal/+launcher/") - classification = mapped(file, "installed-launcher", "system/launcher", ... + classification = mapped(file, "installed-launcher", ... + "labkit/app/internal/launcher", ... "installed Launcher composition and routing behavior"); return; end if startsWith(file, "+labkit/") && numel(parts) >= 2 && startsWith(parts(2), "+") area = erase(parts(2), "+"); if ismember(area, ["app", "biosignal", "contract", "dta", "image", "rhs", "thermal"]) - classification = mapped(file, "framework-facade", "framework/" + area, ... + classification = mapped(file, "framework-facade", "labkit/" + area, ... "direct public facade behavior"); return; end diff --git a/tests/+labkittest/locate.m b/tests/+labkittest/locate.m index 03d2ab7e0..3c9dad3ef 100644 --- a/tests/+labkittest/locate.m +++ b/tests/+labkittest/locate.m @@ -44,7 +44,7 @@ "headless", "", true, classification.Reason); return; end - if ismember(classification.Role, ["maintenance-tool", "deployment-tool"]) + if startsWith(opts.File, "tools/") targets = target(opts.SpecsRoot, classification.Owner, "system", ... "headless", "", true, classification.Reason); return; @@ -55,7 +55,7 @@ area = erase(parts(2), "+"); if ismember(area, ... ["app", "biosignal", "contract", "dta", "image", "rhs", "thermal"]) - targets = target(opts.SpecsRoot, "framework/" + area, "source", ... + targets = target(opts.SpecsRoot, classification.Owner, "source", ... "headless", "", true, "direct public facade behavior"); return; end @@ -113,6 +113,16 @@ "parameterized App smoke after layout assembly", ... layoutManualCheck(packageName))]; end + case {"steppreview", "curvepreview", "thermalpreview", ... + "focuspreview", "imagepreview", "videopreview", ... + "displaymapping", "enhancementpipeline", "matchpipeline"} + targets = target(opts.SpecsRoot, owner, ... + "presentation", "headless", "", true, ... + "direct App presentation-helper behavior"); + case "motionestimate" + targets = target(opts.SpecsRoot, owner, ... + "scientific", "headless", "", true, ... + "direct App scientific-helper behavior"); otherwise targets = target(opts.SpecsRoot, owner, "source", "headless", "", true, ... "direct capability source behavior"); @@ -120,7 +130,7 @@ return; end if startsWith(opts.File, ".agents/") || opts.File == "AGENTS.md" - targets = target(opts.SpecsRoot, "system/repository", "system", ... + targets = target(opts.SpecsRoot, classification.Owner, "system", ... "headless", "", true, "repository policy behavior"); return; end diff --git a/tests/+labkittest/plan.m b/tests/+labkittest/plan.m index cf22dfc3a..11babe7d5 100644 --- a/tests/+labkittest/plan.m +++ b/tests/+labkittest/plan.m @@ -118,16 +118,29 @@ end function [queries, reasons, manualChecks, classifications] = changedQueries(paths) + fprintf("LabKit test planning: 0/%d changed paths\n", numel(paths)); + queryChunks = cell(1, numel(paths)); + reasonChunks = cell(1, numel(paths)); + manualCheckChunks = cell(1, numel(paths)); + classifications = repmat(emptyClassification(), 1, numel(paths)); + for k = 1:numel(paths) + [pathQueries, pathReasons, pathManualChecks, pathClassification] = queriesForChangedPath(paths(k)); + queryChunks{k} = pathQueries; + reasonChunks{k} = pathReasons; + manualCheckChunks{k} = pathManualChecks; + classifications(k) = pathClassification; + if mod(k, 25) == 0 || k == numel(paths) + fprintf("LabKit test planning: %d/%d changed paths\n", ... + k, numel(paths)); + end + end queries = repmat(emptyQuery(), 1, 0); reasons = strings(1, 0); manualChecks = strings(1, 0); - classifications = repmat(emptyClassification(), 1, 0); - for k = 1:numel(paths) - [pathQueries, pathReasons, pathManualChecks, pathClassification] = queriesForChangedPath(paths(k)); - queries = [queries, pathQueries]; - reasons = [reasons, pathReasons]; - manualChecks = [manualChecks, pathManualChecks]; - classifications = [classifications, pathClassification]; + if ~isempty(paths) + queries = [queryChunks{:}]; + reasons = [reasonChunks{:}]; + manualChecks = [manualCheckChunks{:}]; end if isempty(queries) if isempty(paths) @@ -147,10 +160,10 @@ return; case "unknown" error("LabKit:TestPlan:UnknownOwnership", ... - ["No validation ownership exists for: %s\n\n" + ... + "No validation ownership exists for: %s\n\n" + ... "The change either introduces a new production structure, is outside " + ... "an existing owner, requires a routing rule, or requires an explicit " + ... - "no-test classification. Full CI cannot resolve missing ownership."], path); + "no-test classification. Full CI cannot resolve missing ownership.", path); end if classification.Role == "specification" queries = query(classification.Owner, "", ""); @@ -166,7 +179,10 @@ end function descriptors = descriptorsForQueries(opts, queries) - descriptors = repmat(emptyDescriptor(), 1, 0); + fprintf("LabKit evidence selection: 0/%d contract queries\n", ... + numel(queries)); + descriptorChunks = cell(1, numel(queries)); + missingRequirements = strings(1, numel(queries)); for k = 1:numel(queries) current = queries(k); try @@ -187,11 +203,26 @@ "(App=" + current.App + ")")); end if isempty(selected) - error("LabKit:TestPlan:MissingContract", ... - "Required evidence is missing for owner=%s contract=%s environment=%s.", ... + missingRequirements(k) = sprintf( ... + "owner=%s contract=%s environment=%s", ... current.Owner, current.Contract, current.Environment); end - descriptors = [descriptors, selected]; + descriptorChunks{k} = selected; + if mod(k, 25) == 0 || k == numel(queries) + fprintf("LabKit evidence selection: %d/%d contract queries\n", ... + k, numel(queries)); + end + end + missingRequirements = unique( ... + missingRequirements(strlength(missingRequirements) > 0), "stable"); + if ~isempty(missingRequirements) + error("LabKit:TestPlan:MissingContract", ... + "Required evidence is missing for:\n%s", ... + strjoin(missingRequirements, newline)); + end + descriptors = repmat(emptyDescriptor(), 1, 0); + if ~isempty(queries) + descriptors = [descriptorChunks{:}]; end end @@ -225,8 +256,8 @@ end function value = emptyClassification() - value = struct("Path", string(""), "Kind", string(""), ... - "Role", string(""), "Owner", string(""), "Reason", string("")); + value = struct("Path", "", "Kind", "", ... + "Role", "", "Owner", "", "Reason", ""); end function scope = scopeForOptions(opts) @@ -285,7 +316,8 @@ function validateFocusedQueries(scope, queries) end function paths = gitChangedPaths(root) - tracked = gitOutput(root, "diff --name-only HEAD"); + tracked = gitOutput(root, ... + "diff --name-only --diff-filter=ACMRTUXB HEAD"); untracked = gitOutput(root, "ls-files --others --exclude-standard"); paths = unique(normalizeRepositoryPath([splitlines(tracked); splitlines(untracked)]), ... "stable"); @@ -293,13 +325,15 @@ function validateFocusedQueries(scope, queries) if ~isempty(paths) || ~gitRefExists(root, "HEAD~1") return; end - output = gitOutput(root, "diff --name-only HEAD~1 HEAD"); + output = gitOutput(root, ... + "diff --name-only --diff-filter=ACMRTUXB HEAD~1 HEAD"); paths = normalizeRepositoryPath(splitlines(output)); paths = paths(strlength(paths) > 0).'; end function output = gitOutput(root, gitArguments) - command = "git --no-pager -C " + shellQuote(root) + " " + gitArguments; + command = "git -c core.safecrlf=false --no-pager -C " + ... + shellQuote(root) + " " + gitArguments; [status, output] = system(char(command)); if status ~= 0 error("LabKit:TestPlan:GitInspection", ... @@ -309,7 +343,8 @@ function validateFocusedQueries(scope, queries) end function tf = gitRefExists(root, reference) - command = "git --no-pager -C " + shellQuote(root) + " rev-parse --verify --quiet " + reference; + command = "git -c core.safecrlf=false --no-pager -C " + ... + shellQuote(root) + " rev-parse --verify --quiet " + reference; [status, ~] = system(char(command)); tf = status == 0; end diff --git a/tests/+labkittest/publicApps.m b/tests/+labkittest/publicApps.m index 6644e5367..5fc616fbf 100644 --- a/tests/+labkittest/publicApps.m +++ b/tests/+labkittest/publicApps.m @@ -29,7 +29,7 @@ "Public App %s must expose exactly one +package/definition.m.", ... listing.Command(k)); end - [packageFolder, packageName] = fileparts(definitions(1).folder); + [~, packageName] = fileparts(definitions(1).folder); packageName = erase(string(packageName), "+"); relativeFolder = extractAfter(string(appFolder), string(root) + filesep); relativeFolder = replace(relativeFolder, filesep, "/"); diff --git a/tests/+labkittest/run.m b/tests/+labkittest/run.m index 83084b0b3..27cf2b75a 100644 --- a/tests/+labkittest/run.m +++ b/tests/+labkittest/run.m @@ -33,7 +33,8 @@ end environmentCleanup = applyEnvironment(group.Environment); results{k} = runner.run(suite); - clear environmentCleanup cleanup + delete(environmentCleanup); + delete(cleanup); end failed = cellfun(@hasFailures, results); writeJUnit(fullfile(artifacts.Folder, "junit.xml"), results); @@ -45,7 +46,7 @@ result = struct("Plan", compiledPlan, "Results", {results}, ... "RunName", opts.RunName, "ArtifactsRoot", opts.ArtifactsRoot, ... "Artifacts", artifacts); - clear artifactEnvironment + delete(artifactEnvironment); end function reportPlanScope(compiledPlan) @@ -106,9 +107,10 @@ function reportManualChecks(checks) end function tf = isPlanOrEmpty(value) + requiredFields = {'Descriptors', 'Groups', 'Reasons', 'Fallback', ... + 'Scope', 'Classifications', 'ManualChecks'}; tf = isstruct(value) && (isempty(fieldnames(value)) || ... - all(isfield(value, {"Descriptors", "Groups", "Reasons", "Fallback", ... - "Scope", "Classifications", "ManualChecks"}))); + all(isfield(value, requiredFields))); end function tf = isTextScalar(value) diff --git a/tests/+testfixtures/StateStore.m b/tests/+testfixtures/StateStore.m new file mode 100644 index 000000000..b4a50b08a --- /dev/null +++ b/tests/+testfixtures/StateStore.m @@ -0,0 +1,58 @@ +classdef StateStore + %STATESTORE Process-local fixture state shared with callback functions. + + methods (Static) + function set(key, value) + testfixtures.StateStore.manage("set", string(key), value); + end + + function value = get(key, defaultValue) + if nargin < 2 + defaultValue = []; + end + value = testfixtures.StateStore.manage( ... + "get", string(key), defaultValue); + end + + function reset(varargin) + keys = string(varargin); + if isempty(keys) + testfixtures.StateStore.manage("clear", "", []); + return; + end + for key = keys + testfixtures.StateStore.manage("remove", key, []); + end + end + end + + methods (Static, Access = private) + function output = manage(action, key, value) + persistent values + if isempty(values) + values = containers.Map("KeyType", "char", "ValueType", "any"); + end + output = []; + name = char(key); + switch action + case "set" + values(name) = value; + case "get" + if isKey(values, name) + output = values(name); + else + output = value; + end + case "remove" + if isKey(values, name) + remove(values, name); + end + case "clear" + values = containers.Map("KeyType", "char", "ValueType", "any"); + otherwise + error("labkit:test:UnknownStateStoreAction", ... + "Unknown fixture-state action: %s", action); + end + end + end +end diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 1b4e19cc1..b0e9d149c 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -2,7 +2,12 @@ Runnable specifications live under `tests/specs/`. Every test has exactly one `Contract:` and one `Env:` tag. Physical owner paths mirror the -production capability; `tests/+labkittest/` owns discovery, authoring, +production capability without logical aliases: `+labkit/+app/...` maps below +`tests/specs/labkit/app/...`, `tools//...` maps below +`tests/specs/tools//...`, and App capability packages map below the same +`apps////` path. Contract and environment metadata +describe behavior but never change physical ownership. +`tests/+labkittest/` owns discovery, authoring, execution, artifacts, and conformance support. `tests/+testfixtures/` owns only synthetic inputs reused by more than one specification owner; a fixture used by one owner stays beside that specification. Do not create a generic @@ -39,3 +44,9 @@ native interaction or scientific review, never passing test evidence. A state-only geometry assertion does not prove pointer ownership or native control creation; add hidden-GUI structure evidence when either is the regression boundary. + +All durable suite runners use `labkittest.ProgressPlugin` so the console and +event artifact identify suite size, the active test, completed/total work, and +a heartbeat no less often than every 30 seconds during a long test. Detailed +result output alone is insufficient because it can remain silent inside one +slow test. diff --git a/tests/specs/apps/electrochem/cic/sourceFiles/CicSourceSpec.m b/tests/specs/apps/electrochem/cic/sourceFiles/CicSourceSpec.m new file mode 100644 index 000000000..49445c807 --- /dev/null +++ b/tests/specs/apps/electrochem/cic/sourceFiles/CicSourceSpec.m @@ -0,0 +1,15 @@ +classdef CicSourceSpec < matlab.unittest.TestCase + %CICSOURCESPEC Guard CIC project-source reconstruction. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function emptySourcesReturnTheDeclaredStructVector(testCase) + project = cic.projectSpec().Create(); + + items = cic.sourceFiles.loadProjectItems( ... + strings(0, 1), project.parameters); + + testCase.verifyClass(items, "struct"); + testCase.verifySize(items, [0 0]); + end + end +end diff --git a/tests/specs/apps/electrochem/csc/sourceFiles/CscSourceSpec.m b/tests/specs/apps/electrochem/csc/sourceFiles/CscSourceSpec.m new file mode 100644 index 000000000..f763d41e5 --- /dev/null +++ b/tests/specs/apps/electrochem/csc/sourceFiles/CscSourceSpec.m @@ -0,0 +1,12 @@ +classdef CscSourceSpec < matlab.unittest.TestCase + %CSCSOURCESPEC Guard CSC project-source reconstruction. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function emptySourcesReturnTheDeclaredStructVector(testCase) + items = csc.sourceFiles.loadProjectItems(strings(0, 1)); + + testCase.verifyClass(items, "struct"); + testCase.verifySize(items, [0 0]); + end + end +end diff --git a/tests/specs/apps/electrochem/vt_resistance/sourceFiles/VtResistanceSourceSpec.m b/tests/specs/apps/electrochem/vt_resistance/sourceFiles/VtResistanceSourceSpec.m new file mode 100644 index 000000000..77d57c3a9 --- /dev/null +++ b/tests/specs/apps/electrochem/vt_resistance/sourceFiles/VtResistanceSourceSpec.m @@ -0,0 +1,15 @@ +classdef VtResistanceSourceSpec < matlab.unittest.TestCase + %VTRESISTANCESOURCESPEC Guard resistance project-source reconstruction. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function emptySourcesReturnTheDeclaredStructVector(testCase) + project = vt_resistance.projectSpec().Create(); + + items = vt_resistance.sourceFiles.loadProjectItems( ... + strings(0, 1), project.parameters); + + testCase.verifyClass(items, "struct"); + testCase.verifySize(items, [0 0]); + end + end +end diff --git a/tests/specs/apps/gait/gait_analysis/stepPreview/GaitStepPreviewSpec.m b/tests/specs/apps/gait/gait_analysis/stepPreview/GaitStepPreviewSpec.m new file mode 100644 index 000000000..e0d64b0a1 --- /dev/null +++ b/tests/specs/apps/gait/gait_analysis/stepPreview/GaitStepPreviewSpec.m @@ -0,0 +1,20 @@ +classdef GaitStepPreviewSpec < matlab.unittest.TestCase + %GAITSTEPPREVIEWSPEC Guard result-table step selection. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function selectedRowIsClampedToTheAvailableSteps(testCase) + state = struct( ... + "project", struct("results", struct("analysis", struct( ... + "stepTable", table([1; 2], VariableNames="value")))), ... + "session", struct("selection", struct( ... + "currentStepIndex", 1))); + selection = labkit.app.event.TableCellSelection([7 1]); + + state = gait_analysis.stepPreview.select( ... + state, selection, []); + + testCase.verifyEqual( ... + state.session.selection.currentStepIndex, 2); + end + end +end diff --git a/tests/specs/apps/image_measurement/curvature/curvePreview/CurvaturePresentationSpec.m b/tests/specs/apps/image_measurement/curvature/curvePreview/CurvaturePresentationSpec.m new file mode 100644 index 000000000..187ebcdb6 --- /dev/null +++ b/tests/specs/apps/image_measurement/curvature/curvePreview/CurvaturePresentationSpec.m @@ -0,0 +1,31 @@ +classdef CurvaturePresentationSpec < matlab.unittest.TestCase + %CURVATUREPRESENTATIONSPEC Specify initial curve measurement readouts. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function presentsTheStableInitialMetricRows(testCase) + rows = curvature.curvePreview.presentationData.initialResultTable(); + + testCase.verifyTrue(iscell(rows)); + testCase.verifyEqual(rows{1, 1}, 'Curve length'); + testCase.verifyEqual(rows{end, 1}, 'Pixels/unit'); + end + + function grayscaleImagesUseTheScalarImageRenderingPath(testCase) + figureValue = figure(Visible="off"); + cleanup = onCleanup(@() delete(figureValue)); + ax = axes(figureValue); + model = struct( ... + "imageData", reshape(1:20, 4, 5), ... + "points", zeros(0, 2), "curve", zeros(0, 2), ... + "fit", struct("ok", false), ... + "showDensePoints", false, "scaleBar", []); + + curvature.curvePreview.draw(struct("image", ax), model); + + images = findobj(ax, Type="image"); + testCase.verifyNumElements(images, 1); + testCase.verifySize(images.CData, [4 5]); + clear cleanup + end + end +end diff --git a/tests/specs/apps/image_measurement/curvature/workbench/CurvaturePresentationSpec.m b/tests/specs/apps/image_measurement/curvature/workbench/CurvaturePresentationSpec.m deleted file mode 100644 index dee751466..000000000 --- a/tests/specs/apps/image_measurement/curvature/workbench/CurvaturePresentationSpec.m +++ /dev/null @@ -1,13 +0,0 @@ -classdef CurvaturePresentationSpec < matlab.unittest.TestCase - %CURVATUREPRESENTATIONSPEC Specify initial curve measurement readouts. - - methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) - function presentsTheStableInitialMetricRows(testCase) - rows = curvature.curvePreview.presentationData.initialResultTable(); - - testCase.verifyTrue(iscell(rows)); - testCase.verifyEqual(rows{1, 1}, 'Curve length'); - testCase.verifyEqual(rows{end, 1}, 'Pixels/unit'); - end - end -end diff --git a/tests/specs/apps/image_measurement/curvature/workbench/CurvatureWorkbenchSpec.m b/tests/specs/apps/image_measurement/curvature/workbench/CurvatureWorkbenchSpec.m new file mode 100644 index 000000000..4c0d3b692 --- /dev/null +++ b/tests/specs/apps/image_measurement/curvature/workbench/CurvatureWorkbenchSpec.m @@ -0,0 +1,16 @@ +classdef CurvatureWorkbenchSpec < matlab.unittest.TestCase + %CURVATUREWORKBENCHSPEC Specify complete Curvature snapshot composition. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function composesTheEmptyProjectSnapshot(testCase) + project = curvature.projectSpec().Create(); + session = curvature.createSession(project, ... + labkit.app.internal.CallbackContextFactory.disconnected()); + + snapshot = curvature.workbench.present(struct( ... + "project", project, "session", session)); + + testCase.verifyClass(snapshot, "labkit.app.view.Snapshot"); + end + end +end diff --git a/tests/specs/apps/image_measurement/flir_thermal/displayMapping/FlirDisplayMappingSpec.m b/tests/specs/apps/image_measurement/flir_thermal/displayMapping/FlirDisplayMappingSpec.m new file mode 100644 index 000000000..7e66adef0 --- /dev/null +++ b/tests/specs/apps/image_measurement/flir_thermal/displayMapping/FlirDisplayMappingSpec.m @@ -0,0 +1,28 @@ +classdef FlirDisplayMappingSpec < matlab.unittest.TestCase + %FLIRDISPLAYMAPPINGSPEC Guard shared thermal display ranges. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function sharedRangeStoresOneAnnotationPerSource(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + pack = flir_thermal.syntheticInputs.writeSamplePack( ... + labkit.app.synthetic.Context(folder)); + definition = flir_thermal.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, pack.InitialProject, struct(), journal); + cleanup = onCleanup(@() runtime.close()); + + runtime.invokeAction("groupRange"); + state = runtime.State; + annotations = state.project.annotations.items; + + testCase.verifyNumElements(annotations, ... + numel(state.project.inputs.sources)); + ranges = vertcat(annotations.displayRange); + testCase.verifyEqual(ranges, ... + repmat(ranges(1, :), size(ranges, 1), 1)); + clear cleanup + end + end +end diff --git a/tests/specs/apps/image_measurement/flir_thermal/thermalPreview/FlirThermalPreviewSpec.m b/tests/specs/apps/image_measurement/flir_thermal/thermalPreview/FlirThermalPreviewSpec.m new file mode 100644 index 000000000..a805dfd4a --- /dev/null +++ b/tests/specs/apps/image_measurement/flir_thermal/thermalPreview/FlirThermalPreviewSpec.m @@ -0,0 +1,26 @@ +classdef FlirThermalPreviewSpec < matlab.unittest.TestCase + %FLIRTHERMALPREVIEWSPEC Guard thermal detail presentation. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function finiteReadingsProduceEveryPairwiseDifference(testCase) + item = flir_thermal.sourceFiles.emptyItem(); + item.name = "synthetic.jpg"; + item.units = "C"; + item.hotSpot = struct("x", 1, "y", 1, "temperatureC", 30); + item.coldSpot = struct("x", 2, "y", 2, "temperatureC", 20); + item.manualPoint = struct( ... + "x", 3, "y", 3, "temperatureC", 25); + + lines = string( ... + flir_thermal.thermalPreview.presentationData.detailLines( ... + item, 1, "")); + differences = lines(startsWith(lines, ... + "Temperature differences:")); + + testCase.verifySubstring(differences, ... + "image hot - image cold = 10.00 C"); + testCase.verifySubstring(differences, ... + "image cold - manual = -5.00 C"); + end + end +end diff --git a/tests/specs/apps/image_measurement/focus_stack/focusPreview/FocusStackPresentationSpec.m b/tests/specs/apps/image_measurement/focus_stack/focusPreview/FocusStackPresentationSpec.m new file mode 100644 index 000000000..92bfd0807 --- /dev/null +++ b/tests/specs/apps/image_measurement/focus_stack/focusPreview/FocusStackPresentationSpec.m @@ -0,0 +1,33 @@ +classdef FocusStackPresentationSpec < matlab.unittest.TestCase + %FOCUSSTACKPRESENTATIONSPEC Specify focus result reader-facing rows. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function presentsAStableInitialResultTable(testCase) + rows = focus_stack.focusPreview.initialResultTable(); + presets = focus_stack.focusPreview.fusionPresetItems(); + + testCase.verifyTrue(iscell(rows)); + testCase.verifyEqual(rows{1, 1}, 'Input images'); + testCase.verifyTrue(any(presets == "Balanced")); + end + + function detailsPreserveCoverageAndRegistrationOrder(testCase) + result = struct( ... + "method", "Synthetic", ... + "imageWidth", 20, "imageHeight", 10, ... + "channelCount", 3, "resizedCount", 0, ... + "focusWindow", 5, "smoothRadius", 2, ... + "minConfidence", .1, "inputCount", 2, ... + "focusCoverage", [.25 .75]); + + lines = focus_stack.focusPreview.details( ... + result, ["first.png" "second.png"], ... + {"registered 2 images"}); + + testCase.verifyEqual(lines{6}, ... + ' 1. first.png: 25.00%'); + testCase.verifyEqual(lines{8}, 'Registration:'); + testCase.verifyEqual(lines{9}, "registered 2 images"); + end + end +end diff --git a/tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackPresentationSpec.m b/tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackPresentationSpec.m deleted file mode 100644 index d9aaa9fa5..000000000 --- a/tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackPresentationSpec.m +++ /dev/null @@ -1,14 +0,0 @@ -classdef FocusStackPresentationSpec < matlab.unittest.TestCase - %FOCUSSTACKPRESENTATIONSPEC Specify focus result reader-facing rows. - - methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) - function presentsAStableInitialResultTable(testCase) - rows = focus_stack.focusPreview.initialResultTable(); - presets = focus_stack.focusPreview.fusionPresetItems(); - - testCase.verifyTrue(iscell(rows)); - testCase.verifyEqual(rows{1, 1}, 'Input images'); - testCase.verifyTrue(any(presets == "Balanced")); - end - end -end diff --git a/tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackWorkbenchSpec.m b/tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackWorkbenchSpec.m new file mode 100644 index 000000000..ff4f009cb --- /dev/null +++ b/tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackWorkbenchSpec.m @@ -0,0 +1,16 @@ +classdef FocusStackWorkbenchSpec < matlab.unittest.TestCase + %FOCUSSTACKWORKBENCHSPEC Specify complete Focus Stack snapshot composition. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function composesTheEmptyProjectSnapshot(testCase) + project = focus_stack.projectSpec().Create(); + session = focus_stack.createSession(project, ... + labkit.app.internal.CallbackContextFactory.disconnected()); + + snapshot = focus_stack.workbench.present(struct( ... + "project", project, "session", session)); + + testCase.verifyClass(snapshot, "labkit.app.view.Snapshot"); + end + end +end diff --git a/tests/specs/apps/image_measurement/image_enhance/enhancementPipeline/ImageEnhancePipelineSpec.m b/tests/specs/apps/image_measurement/image_enhance/enhancementPipeline/ImageEnhancePipelineSpec.m new file mode 100644 index 000000000..a5cc6daa1 --- /dev/null +++ b/tests/specs/apps/image_measurement/image_enhance/enhancementPipeline/ImageEnhancePipelineSpec.m @@ -0,0 +1,27 @@ +classdef ImageEnhancePipelineSpec < matlab.unittest.TestCase + %IMAGEENHANCEPIPELINESPEC Guard enhancement-history transitions. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function undoRemovesOnlyTheNewestSharedStep(testCase) + project = image_enhance.projectSpec().Create(); + project.annotations.sharedSteps = [ ... + image_enhance.analysisRun.makeStep( ... + "Brightness/contrast", 10, 20, 0); ... + image_enhance.analysisRun.makeStep( ... + "Sharpen", 30, 1, 0)]; + context = labkit.app.internal.CallbackContextFactory.create( ... + struct("log", @(varargin) [])); + state = struct("project", project, ... + "session", image_enhance.createSession(project, context)); + + state = image_enhance.enhancementPipeline.undo( ... + state, context); + + testCase.verifyNumElements( ... + state.project.annotations.sharedSteps, 1); + testCase.verifyEqual( ... + state.project.annotations.sharedSteps.kind, ... + "Brightness/contrast"); + end + end +end diff --git a/tests/specs/apps/image_measurement/image_enhance/imagePreview/ImageEnhancePreviewSpec.m b/tests/specs/apps/image_measurement/image_enhance/imagePreview/ImageEnhancePreviewSpec.m new file mode 100644 index 000000000..463bcc18a --- /dev/null +++ b/tests/specs/apps/image_measurement/image_enhance/imagePreview/ImageEnhancePreviewSpec.m @@ -0,0 +1,18 @@ +classdef ImageEnhancePreviewSpec < matlab.unittest.TestCase + %IMAGEENHANCEPREVIEWSPEC Guard before/after preview composition. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function grayscaleInputsBecomeOneRgbComparisonImage(testCase) + original = zeros(4, 5); + enhanced = ones(4, 5); + + preview = ... + image_enhance.imagePreview.presentationData.beforeAfterImage( ... + original, enhanced); + + testCase.verifySize(preview, [4 16 3]); + testCase.verifyEqual(preview(:, 1:5, 1), original); + testCase.verifyEqual(preview(:, 12:16, 3), enhanced); + end + end +end diff --git a/tests/specs/apps/image_measurement/image_match/imagePreview/ImageMatchPreviewSpec.m b/tests/specs/apps/image_measurement/image_match/imagePreview/ImageMatchPreviewSpec.m new file mode 100644 index 000000000..ce54da9ce --- /dev/null +++ b/tests/specs/apps/image_measurement/image_match/imagePreview/ImageMatchPreviewSpec.m @@ -0,0 +1,18 @@ +classdef ImageMatchPreviewSpec < matlab.unittest.TestCase + %IMAGEMATCHPREVIEWSPEC Guard before/after preview composition. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function grayscaleInputsBecomeOneRgbComparisonImage(testCase) + original = zeros(3, 4); + matched = ones(3, 4); + + preview = ... + image_match.imagePreview.presentationData.beforeAfterImage( ... + original, matched); + + testCase.verifySize(preview, [3 14 3]); + testCase.verifyEqual(preview(:, 1:4, 2), original); + testCase.verifyEqual(preview(:, 11:14, 1), matched); + end + end +end diff --git a/tests/specs/apps/image_measurement/image_match/matchPipeline/ImageMatchPipelineSpec.m b/tests/specs/apps/image_measurement/image_match/matchPipeline/ImageMatchPipelineSpec.m new file mode 100644 index 000000000..275efa860 --- /dev/null +++ b/tests/specs/apps/image_measurement/image_match/matchPipeline/ImageMatchPipelineSpec.m @@ -0,0 +1,25 @@ +classdef ImageMatchPipelineSpec < matlab.unittest.TestCase + %IMAGEMATCHPIPELINESPEC Guard match-history transitions. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function undoRemovesOnlyTheNewestMatchStep(testCase) + project = image_match.projectSpec().Create(); + project.annotations.steps = [ ... + image_match.analysisRun.makeStep( ... + "White balance", 100, 100, 100); ... + image_match.analysisRun.makeStep( ... + "Tone only", 80, 90, 0)]; + context = labkit.app.internal.CallbackContextFactory.create( ... + struct("log", @(varargin) [])); + state = struct("project", project, ... + "session", image_match.createSession(project, context)); + + state = image_match.matchPipeline.undo(state, context); + + testCase.verifyNumElements( ... + state.project.annotations.steps, 1); + testCase.verifyEqual( ... + state.project.annotations.steps.matchMethod, "White balance"); + end + end +end diff --git a/tests/specs/apps/image_measurement/video_marker/motionEstimate/VideoMarkerMotionEstimateSpec.m b/tests/specs/apps/image_measurement/video_marker/motionEstimate/VideoMarkerMotionEstimateSpec.m new file mode 100644 index 000000000..a505ed860 --- /dev/null +++ b/tests/specs/apps/image_measurement/video_marker/motionEstimate/VideoMarkerMotionEstimateSpec.m @@ -0,0 +1,20 @@ +classdef VideoMarkerMotionEstimateSpec < matlab.unittest.TestCase + %VIDEOMARKERMOTIONESTIMATESPEC Guard multiscale point tracking. + + methods (Test, TestTags = {'Contract:scientific', 'Env:headless'}) + function controlledTranslationIsRecoveredDeterministically(testCase) + rng(7); + previous = rand(80, 100); + current = zeros(size(previous)); + current(1:78, 4:100) = previous(3:80, 1:97); + + [point, confidence, diagnostics] = ... + video_marker.motionEstimate.trackPoints( ... + previous, current, [50 40]); + + testCase.verifyEqual(point, [53 38], AbsTol=.35); + testCase.verifyGreaterThan(confidence, .5); + testCase.verifyTrue(diagnostics.valid); + end + end +end diff --git a/tests/specs/apps/image_measurement/video_marker/syntheticInputs/VideoMarkerSyntheticInputsSpec.m b/tests/specs/apps/image_measurement/video_marker/syntheticInputs/VideoMarkerSyntheticInputsSpec.m new file mode 100644 index 000000000..fcacca3e2 --- /dev/null +++ b/tests/specs/apps/image_measurement/video_marker/syntheticInputs/VideoMarkerSyntheticInputsSpec.m @@ -0,0 +1,20 @@ +classdef VideoMarkerSyntheticInputsSpec < matlab.unittest.TestCase + %VIDEOMARKERSYNTHETICINPUTSSPEC Guard the reproducible sample pack. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function samplePackOwnsAReadableSyntheticVideo(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + + pack = video_marker.syntheticInputs.writeSamplePack( ... + labkit.app.synthetic.Context(folder)); + source = pack.InitialProject.inputs.sources; + + testCase.verifyNumElements(source, 1); + testCase.verifyGreaterThan( ... + pack.InitialProject.inputs.videoMetadata.frameCount, 0); + testCase.verifyGreaterThan( ... + pack.InitialProject.parameters.coordinateEndFrame, 0); + end + end +end diff --git a/tests/specs/apps/image_measurement/video_marker/videoPreview/VideoMarkerPreviewSpec.m b/tests/specs/apps/image_measurement/video_marker/videoPreview/VideoMarkerPreviewSpec.m new file mode 100644 index 000000000..ec13d6e1c --- /dev/null +++ b/tests/specs/apps/image_measurement/video_marker/videoPreview/VideoMarkerPreviewSpec.m @@ -0,0 +1,26 @@ +classdef VideoMarkerPreviewSpec < matlab.unittest.TestCase + %VIDEOMARKERPREVIEWSPEC Guard grayscale frame rendering. + + methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) + function grayscaleFramesRenderAsOneImage(testCase) + figureValue = figure(Visible="off"); + cleanup = onCleanup(@() delete(figureValue)); + ax = axes(figureValue); + model = struct( ... + "image", reshape(1:20, 4, 5), ... + "title", "Synthetic frame", ... + "skeleton", struct( ... + "edges", zeros(0, 2), ... + "pointNames", strings(0, 1)), ... + "points", zeros(0, 2), ... + "scaleBar", []); + + video_marker.videoPreview.draw(struct("video", ax), model); + + images = findobj(ax, Type="image"); + testCase.verifyNumElements(images, 1); + testCase.verifySize(images.CData, [4 5]); + clear cleanup + end + end +end diff --git a/tests/specs/apps/statistics/ttest_wizard/groupData/TTestGroupDataSpec.m b/tests/specs/apps/statistics/ttest_wizard/groupData/TTestGroupDataSpec.m new file mode 100644 index 000000000..ecc6cd9aa --- /dev/null +++ b/tests/specs/apps/statistics/ttest_wizard/groupData/TTestGroupDataSpec.m @@ -0,0 +1,30 @@ +classdef TTestGroupDataSpec < matlab.unittest.TestCase + %TTESTGROUPDATASPEC Guard editable group-table transitions. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function tableRowsBuildOrderedGroupsAndSelection(testCase) + project = ttest_wizard.projectSpec().Create(); + state = struct("project", project, ... + "session", struct("selection", struct( ... + "analysisCells", zeros(0, 2)))); + edit = labkit.app.event.TableCellEdit( ... + RowIndex=1, ColumnIndex=2, ... + PreviousValue=[], NewValue=1, ... + Data={"A", 1; "", 2; "B", 3}); + context = labkit.app.internal.CallbackContextFactory.create( ... + struct("log", @(varargin) [])); + + state = ttest_wizard.groupData.replaceFromTableEdit( ... + state, edit, context); + state = ttest_wizard.groupData.selectRows( ... + state, labkit.app.event.TableCellSelection([2 2]), []); + + testCase.verifyEqual( ... + string({state.project.inputs.groups.label}), ["A" "B"]); + testCase.verifyEqual( ... + state.project.inputs.groups(1).values, [1; 2]); + testCase.verifyEqual( ... + state.session.selection.analysisCells, [2 2]); + end + end +end diff --git a/tests/specs/framework/app/AppSdkSpec.m b/tests/specs/labkit/app/AppSdkSpec.m similarity index 100% rename from tests/specs/framework/app/AppSdkSpec.m rename to tests/specs/labkit/app/AppSdkSpec.m diff --git a/tests/specs/framework/app/AutomaticInstrumentationSpec.m b/tests/specs/labkit/app/AutomaticInstrumentationSpec.m similarity index 100% rename from tests/specs/framework/app/AutomaticInstrumentationSpec.m rename to tests/specs/labkit/app/AutomaticInstrumentationSpec.m diff --git a/tests/specs/framework/app/SemanticDiagnosticsSpec.m b/tests/specs/labkit/app/SemanticDiagnosticsSpec.m similarity index 100% rename from tests/specs/framework/app/SemanticDiagnosticsSpec.m rename to tests/specs/labkit/app/SemanticDiagnosticsSpec.m diff --git a/tests/specs/framework/app/SessionDiagnosticBundleSpec.m b/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m similarity index 99% rename from tests/specs/framework/app/SessionDiagnosticBundleSpec.m rename to tests/specs/labkit/app/SessionDiagnosticBundleSpec.m index e055ff204..9e66d6d71 100644 --- a/tests/specs/framework/app/SessionDiagnosticBundleSpec.m +++ b/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m @@ -207,7 +207,7 @@ function deleteIfFile(filepath) end function choice = failOutputDialog(varargin) -choice = []; -error("labkit:test:OutputDialogFailure", ... +choice = MException("labkit:test:OutputDialogFailure", ... "Intentional output dialog failure."); +throw(choice); end diff --git a/tests/specs/framework/app/SessionEventStreamOperationSpec.m b/tests/specs/labkit/app/SessionEventStreamOperationSpec.m similarity index 99% rename from tests/specs/framework/app/SessionEventStreamOperationSpec.m rename to tests/specs/labkit/app/SessionEventStreamOperationSpec.m index b46a90e1d..09bc81d31 100644 --- a/tests/specs/framework/app/SessionEventStreamOperationSpec.m +++ b/tests/specs/labkit/app/SessionEventStreamOperationSpec.m @@ -68,6 +68,7 @@ function closesActiveOperationsAsAbandonedFromInnerToOuter(testCase) "Dispatching callback."); inner = stream.begin("app.probe", "analysis.run", ... "Running analysis."); + testCase.verifyNotEmpty(inner); stream.close(); records = stream.records(); diff --git a/tests/specs/framework/app/SessionEventStreamSpec.m b/tests/specs/labkit/app/SessionEventStreamSpec.m similarity index 88% rename from tests/specs/framework/app/SessionEventStreamSpec.m rename to tests/specs/labkit/app/SessionEventStreamSpec.m index d6ddb9b28..8a03fa8cb 100644 --- a/tests/specs/framework/app/SessionEventStreamSpec.m +++ b/tests/specs/labkit/app/SessionEventStreamSpec.m @@ -42,8 +42,7 @@ function retainsMinimalPrivacySafeEventBeforeAnyProjection(testCase) end function rejectsRawPathBeforeItCanEnterTheRetainedRing(testCase) - global labkitSessionEventStreamConsumerRecords - labkitSessionEventStreamConsumerRecords = strings(0, 1); + testfixtures.StateStore.set("eventConsumerRecords", strings(0, 1)); resetConsumer = onCleanup(@resetTestConsumer); folder = string(testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder); @@ -58,7 +57,7 @@ function rejectsRawPathBeforeItCanEnterTheRetainedRing(testCase) stream = labkit.app.internal.SessionEventStream( ... loggingProbeDefinition(), ProjectionHook=@recordTestConsumer); cleanup = onCleanup(@() stream.close()); - labkitSessionEventStreamConsumerRecords = strings(0, 1); + testfixtures.StateStore.set("eventConsumerRecords", strings(0, 1)); before = numel(stream.records()); for unsafeValue = unsafeValues @@ -74,7 +73,7 @@ function rejectsRawPathBeforeItCanEnterTheRetainedRing(testCase) end testCase.verifyEqual(numel(stream.records()), before); - testCase.verifyEmpty(labkitSessionEventStreamConsumerRecords); + testCase.verifyEmpty(testfixtures.StateStore.get("eventConsumerRecords")); clear cleanup resetConsumer end @@ -97,13 +96,12 @@ function boundsTheProvisionalInMemoryRing(testCase) end function retainsUnitRatiosAndOrdinaryPunctuation(testCase) - global labkitSessionEventStreamConsumerRecords - labkitSessionEventStreamConsumerRecords = strings(0, 1); + testfixtures.StateStore.set("eventConsumerRecords", strings(0, 1)); resetConsumer = onCleanup(@resetTestConsumer); stream = labkit.app.internal.SessionEventStream( ... loggingProbeDefinition(), ProjectionHook=@recordTestConsumer); cleanup = onCleanup(@() stream.close()); - labkitSessionEventStreamConsumerRecords = strings(0, 1); + testfixtures.StateStore.set("eventConsumerRecords", strings(0, 1)); stream.log("info", "analysis.unit_ratio", ... "Rate settled at the expected mV/s ratio (A/B).", ... @@ -115,7 +113,7 @@ function retainsUnitRatiosAndOrdinaryPunctuation(testCase) testCase.verifyNumElements(retained, 1); testCase.verifyEqual(retained.attributes.unit, "mV/s"); testCase.verifyEqual(retained.attributes.enum, "nominal-ratio"); - testCase.verifyEqual(labkitSessionEventStreamConsumerRecords, ... + testCase.verifyEqual(testfixtures.StateStore.get("eventConsumerRecords"), ... "analysis.unit_ratio"); clear cleanup resetConsumer end @@ -192,21 +190,20 @@ function rejectsUnsafeAttributeShapesAndSemanticsBeforeRetention(testCase) end function rejectsUnsafeAttributesWithoutChangingRingHookOrJournal(testCase) - global labkitAttributePrivacyJournal labkitAttributePrivacyHookCount root = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; journal = labkit.app.internal.SessionJournal(loggingProbeDefinition(), ... RootFolder=root, SessionId="session-attribute-privacy", ... BufferRecordLimit=1); - labkitAttributePrivacyJournal = journal; - labkitAttributePrivacyHookCount = 0; + testfixtures.StateStore.set("attributePrivacyJournal", journal); + testfixtures.StateStore.set("attributePrivacyHookCount", 0); globalCleanup = onCleanup(@resetAttributePrivacyProjection); journalCleanup = onCleanup(@() journal.close()); stream = labkit.app.internal.SessionEventStream(loggingProbeDefinition(), ... SessionId="session-attribute-privacy", ... ProjectionHook=@persistAttributePrivacyRecord); streamCleanup = onCleanup(@() stream.close()); - labkitAttributePrivacyHookCount = 0; + testfixtures.StateStore.set("attributePrivacyHookCount", 0); beforeRecords = numel(stream.records()); beforeJournal = journalText(journal.folder()); @@ -216,22 +213,21 @@ function rejectsUnsafeAttributesWithoutChangingRingHookOrJournal(testCase) "labkit:app:contract:UnsafeLogData"); testCase.verifyEqual(numel(stream.records()), beforeRecords); - testCase.verifyEqual(labkitAttributePrivacyHookCount, 0); + testCase.verifyEqual(testfixtures.StateStore.get("attributePrivacyHookCount"), 0); testCase.verifyEqual(journalText(journal.folder()), beforeJournal); clear streamCleanup journalCleanup globalCleanup end function retainsClosedNonrecursiveProjectionHealthNotifications(testCase) - global labkitProjectionHookCount labkitProjectionHealthNotifications resetProjectionHealthFixture(); cleanup = onCleanup(@resetProjectionHealthFixture); stream = labkit.app.internal.SessionEventStream(loggingProbeDefinition(), ... ProjectionHook=@countProjectionHook, ... ProjectionHealthHook=@nextProjectionHealthNotification); streamCleanup = onCleanup(@() stream.close()); - labkitProjectionHealthNotifications = struct( ... + testfixtures.StateStore.set("projectionHealthNotifications", struct( ... "eventName", "journal.records_dropped", ... - "reason", "write-failure", "count", 2); + "reason", "write-failure", "count", 2)); stream.log("info", "analysis.projection", "Projection retained.", ... Category="runtime.lifecycle", Audience="developer"); @@ -242,7 +238,7 @@ function retainsClosedNonrecursiveProjectionHealthNotifications(testCase) testCase.verifyNumElements(dropped, 1); testCase.verifyEqual(dropped.attributes.reason, "write-failure"); testCase.verifyEqual(dropped.attributes.count, 2); - testCase.verifyEqual(labkitProjectionHookCount, 2); + testCase.verifyEqual(testfixtures.StateStore.get("projectionHookCount"), 2); clear streamCleanup cleanup end @@ -253,13 +249,13 @@ function isolatesMalformedProjectionHealthNotifications(testCase) struct("eventName", "journal.records_dropped", "reason", "write-failure", "count", 0), ... struct("eventName", "journal.records_dropped", "reason", "free text", "count", 1)}; for index = 1:numel(invalid) - global labkitProjectionHealthNotifications resetProjectionHealthFixture(); cleanup = onCleanup(@resetProjectionHealthFixture); stream = labkit.app.internal.SessionEventStream(loggingProbeDefinition(), ... ProjectionHealthHook=@nextProjectionHealthNotification); streamCleanup = onCleanup(@() stream.close()); - labkitProjectionHealthNotifications = invalid{index}; + testfixtures.StateStore.set( ... + "projectionHealthNotifications", invalid{index}); stream.refreshProjectionHealth(); stream.log("info", "analysis.after_health_fault", ... @@ -276,7 +272,6 @@ function isolatesMalformedProjectionHealthNotifications(testCase) end function surfacesJournalProjectionFailureWithoutRecursing(testCase) - global labkitProjectionDeliveryFailures labkitProjectionDeliveryIndex resetProjectionHealthFixture(); fixtureCleanup = onCleanup(@resetProjectionHealthFixture); root = testCase.applyFixture( ... @@ -291,8 +286,8 @@ function surfacesJournalProjectionFailureWithoutRecursing(testCase) ProjectionHook=@projection.project, ... ProjectionHealthHook=@projection.drainHealth); streamCleanup = onCleanup(@() stream.close()); - labkitProjectionDeliveryFailures = [true, true]; - labkitProjectionDeliveryIndex = 0; + testfixtures.StateStore.set("projectionDeliveryFailures", [true, true]); + testfixtures.StateStore.set("projectionDeliveryIndex", 0); operation = stream.begin("runtime.callback", "callback.delivery", ... "Delivering callback."); @@ -329,7 +324,6 @@ function surfacesJournalProjectionFailureWithoutRecursing(testCase) end function reportsProjectionRecoveryAndLaterFailureAsNewTransition(testCase) - global labkitProjectionDeliveryFailures labkitProjectionDeliveryIndex resetProjectionHealthFixture(); fixtureCleanup = onCleanup(@resetProjectionHealthFixture); root = testCase.applyFixture( ... @@ -344,8 +338,9 @@ function reportsProjectionRecoveryAndLaterFailureAsNewTransition(testCase) ProjectionHook=@projection.project, ... ProjectionHealthHook=@projection.drainHealth); streamCleanup = onCleanup(@() stream.close()); - labkitProjectionDeliveryFailures = [true, false, true]; - labkitProjectionDeliveryIndex = 0; + testfixtures.StateStore.set( ... + "projectionDeliveryFailures", [true, false, true]); + testfixtures.StateStore.set("projectionDeliveryIndex", 0); stream.log("info", "analysis.projection_first", "First delivery.", ... Category="runtime.lifecycle", Audience="developer"); @@ -411,8 +406,7 @@ function reportsWrapperCloseFailureOnceWithoutARecordDrop(testCase) end function closeSequenceRefreshesHealthAfterProjectionFailure(testCase) - global labkitProjectionHealthNotifications - resetProjectionHealthFixture(); + resetProjectionHealthFixture(); cleanup = onCleanup(@resetProjectionHealthFixture); stream = labkit.app.internal.SessionEventStream(loggingProbeDefinition(), ... ProjectionHealthHook=@nextProjectionHealthNotification); @@ -446,43 +440,40 @@ function closeSequenceRefreshesHealthAfterProjectionFailure(testCase) end function recordTestConsumer(record) -global labkitSessionEventStreamConsumerRecords -labkitSessionEventStreamConsumerRecords(end + 1) = record.eventName; +records = testfixtures.StateStore.get("eventConsumerRecords", strings(0, 1)); +testfixtures.StateStore.set("eventConsumerRecords", [records; string(record.eventName)]); end function resetTestConsumer() -global labkitSessionEventStreamConsumerRecords -labkitSessionEventStreamConsumerRecords = strings(0, 1); +testfixtures.StateStore.reset("eventConsumerRecords"); end function countProjectionHook(~) -global labkitProjectionHookCount -labkitProjectionHookCount = labkitProjectionHookCount + 1; +count = testfixtures.StateStore.get("projectionHookCount", 0); +testfixtures.StateStore.set("projectionHookCount", count + 1); end function notifications = nextProjectionHealthNotification() -global labkitProjectionHealthNotifications -notifications = labkitProjectionHealthNotifications; -labkitProjectionHealthNotifications = []; +notifications = testfixtures.StateStore.get("projectionHealthNotifications", []); +testfixtures.StateStore.set("projectionHealthNotifications", []); end function resetProjectionHealthFixture() -global labkitProjectionHookCount labkitProjectionHealthNotifications ... - labkitProjectionDeliveryFailures labkitProjectionDeliveryIndex -labkitProjectionHookCount = 0; -labkitProjectionHealthNotifications = []; -labkitProjectionDeliveryFailures = false(1, 0); -labkitProjectionDeliveryIndex = 0; +testfixtures.StateStore.set("projectionHookCount", 0); +testfixtures.StateStore.set("projectionHealthNotifications", []); +testfixtures.StateStore.set("projectionDeliveryFailures", false(1, 0)); +testfixtures.StateStore.set("projectionDeliveryIndex", 0); end function failProjectionDelivery(stage) -global labkitProjectionDeliveryFailures labkitProjectionDeliveryIndex if string(stage) ~= "project" return; end -labkitProjectionDeliveryIndex = labkitProjectionDeliveryIndex + 1; -if labkitProjectionDeliveryIndex > numel(labkitProjectionDeliveryFailures) || ... - ~labkitProjectionDeliveryFailures(labkitProjectionDeliveryIndex) +index = testfixtures.StateStore.get("projectionDeliveryIndex", 0) + 1; +failures = testfixtures.StateStore.get( ... + "projectionDeliveryFailures", false(1, 0)); +testfixtures.StateStore.set("projectionDeliveryIndex", index); +if index > numel(failures) || ~failures(index) return; end error("labkit:test:ProjectionDeliveryFailure", "Intentional projection delivery failure."); @@ -513,9 +504,9 @@ function failProjectionCloseDelivery(stage) end function failClosingProjection() -global labkitProjectionHealthNotifications -labkitProjectionHealthNotifications = struct( ... - "eventName", "journal.degraded", "reason", "projection-failure", "count", 0); +testfixtures.StateStore.set("projectionHealthNotifications", struct( ... + "eventName", "journal.degraded", ... + "reason", "projection-failure", "count", 0)); error("labkit:test:ProjectionCloseFailure", "Intentional projection close failure."); end @@ -580,22 +571,23 @@ function failClosingProjection() end function persistAttributePrivacyRecord(record) -global labkitAttributePrivacyJournal labkitAttributePrivacyHookCount -labkitAttributePrivacyHookCount = labkitAttributePrivacyHookCount + 1; -labkitAttributePrivacyJournal.append(record); +count = testfixtures.StateStore.get("attributePrivacyHookCount", 0); +testfixtures.StateStore.set("attributePrivacyHookCount", count + 1); +journal = testfixtures.StateStore.get("attributePrivacyJournal"); +journal.append(record); end function resetAttributePrivacyProjection() -global labkitAttributePrivacyJournal labkitAttributePrivacyHookCount -labkitAttributePrivacyJournal = []; -labkitAttributePrivacyHookCount = 0; +testfixtures.StateStore.reset( ... + "attributePrivacyJournal", "attributePrivacyHookCount"); end function text = journalText(folder) segments = dir(fullfile(folder, "events-*.jsonl")); -text = strings(0, 1); +text = strings(numel(segments), 1); for index = 1:numel(segments) - text(end + 1, 1) = string(fileread(fullfile(segments(index).folder, segments(index).name))); + text(index, 1) = string(fileread( ... + fullfile(segments(index).folder, segments(index).name))); end end diff --git a/tests/specs/framework/app/SessionJournalSpec.m b/tests/specs/labkit/app/SessionJournalSpec.m similarity index 96% rename from tests/specs/framework/app/SessionJournalSpec.m rename to tests/specs/labkit/app/SessionJournalSpec.m index beac6cc4d..81b8a8a64 100644 --- a/tests/specs/framework/app/SessionJournalSpec.m +++ b/tests/specs/labkit/app/SessionJournalSpec.m @@ -103,8 +103,7 @@ function defaultSessionIdentityDoesNotChangeRng(testCase) end function buffersContextAndFlushesAroundWarnings(testCase) - global labkitSessionJournalStages - labkitSessionJournalStages = strings(0, 1); + testfixtures.StateStore.set("journalStages", strings(0, 1)); resetObserver = onCleanup(@resetJournalObserver); root = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; @@ -128,17 +127,15 @@ function buffersContextAndFlushesAroundWarnings(testCase) lines = lines(strlength(lines) > 0); testCase.verifyEqual(numel(lines), 3); - writeStages = labkitSessionJournalStages( ... - labkitSessionJournalStages == "open" | ... - labkitSessionJournalStages == "flush"); + stages = testfixtures.StateStore.get("journalStages"); + writeStages = stages(stages == "open" | stages == "flush"); testCase.verifyEqual(writeStages, ... ["open", "flush", "flush"]); clear streamCleanup cleanup resetObserver end function countsWarningDroppedAfterPreflushManifestFailure(testCase) - global labkitPreflushManifestFaultCount - labkitPreflushManifestFaultCount = 0; + testfixtures.StateStore.set("preflushManifestFaultCount", 0); resetFault = onCleanup(@resetPreflushManifestFault); root = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; @@ -325,8 +322,7 @@ function coalescesOnlyEquivalentLowSeverityRecords(testCase) end function highFrequencyCoalescingDoesNotWriteManifestPerRecord(testCase) - global labkitSessionJournalStages - labkitSessionJournalStages = strings(0, 1); + testfixtures.StateStore.set("journalStages", strings(0, 1)); resetObserver = onCleanup(@resetJournalObserver); root = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; @@ -344,8 +340,8 @@ function highFrequencyCoalescingDoesNotWriteManifestPerRecord(testCase) Category="app.probe.journal.analysisRun", Audience="developer"); end - manifestStages = labkitSessionJournalStages( ... - labkitSessionJournalStages == "manifest"); + stages = testfixtures.StateStore.get("journalStages"); + manifestStages = stages(stages == "manifest"); testCase.verifyNumElements(manifestStages, 1); testCase.verifyEqual(journal.manifest().degradation.coalescedRecordCount, 31); clear streamCleanup cleanup resetObserver @@ -739,13 +735,13 @@ function retentionDoesNotMisattributeBoundsAfterAnotherAppPrunes(testCase) end function recordJournalStage(stage, ~) -global labkitSessionJournalStages -labkitSessionJournalStages(end + 1) = string(stage); +stages = testfixtures.StateStore.get("journalStages", strings(0, 1)); +testfixtures.StateStore.set("journalStages", ... + [reshape(stages, 1, []), string(stage)]); end function resetJournalObserver() -global labkitSessionJournalStages -labkitSessionJournalStages = strings(0, 1); +testfixtures.StateStore.reset("journalStages"); end function failWrite(stage) @@ -755,19 +751,18 @@ function failWrite(stage) end function failPreflushManifest(stage) -global labkitPreflushManifestFaultCount if string(stage) ~= "manifest" return; end -labkitPreflushManifestFaultCount = labkitPreflushManifestFaultCount + 1; -if labkitPreflushManifestFaultCount >= 2 +count = testfixtures.StateStore.get("preflushManifestFaultCount", 0) + 1; +testfixtures.StateStore.set("preflushManifestFaultCount", count); +if count >= 2 error("labkit:test:JournalManifestFailure", "Intentional preflush manifest failure."); end end function resetPreflushManifestFault() -global labkitPreflushManifestFaultCount -labkitPreflushManifestFaultCount = 0; +testfixtures.StateStore.reset("preflushManifestFaultCount"); end function [folder, events] = writeJournalSession(root, sessionId, appId) @@ -851,35 +846,34 @@ function failActiveMarker(stage) end function resetManifestFault() -global labkitSessionJournalManifestCount -labkitSessionJournalManifestCount = 0; +testfixtures.StateStore.set("sessionJournalManifestCount", 0); end function resetLeaseClock(values) -global labkitSessionJournalLeaseClockValues labkitSessionJournalLeaseClockIndex if nargin < 1 - labkitSessionJournalLeaseClockValues = strings(0, 1); + values = strings(0, 1); else - labkitSessionJournalLeaseClockValues = string(values(:)); + values = string(values(:)); end -labkitSessionJournalLeaseClockIndex = 0; +testfixtures.StateStore.set("sessionJournalLeaseClockValues", values); +testfixtures.StateStore.set("sessionJournalLeaseClockIndex", 0); end function value = nextLeaseClock() -global labkitSessionJournalLeaseClockValues labkitSessionJournalLeaseClockIndex -labkitSessionJournalLeaseClockIndex = labkitSessionJournalLeaseClockIndex + 1; -index = min(labkitSessionJournalLeaseClockIndex, ... - numel(labkitSessionJournalLeaseClockValues)); -value = labkitSessionJournalLeaseClockValues(index); +values = testfixtures.StateStore.get( ... + "sessionJournalLeaseClockValues", strings(0, 1)); +index = testfixtures.StateStore.get("sessionJournalLeaseClockIndex", 0) + 1; +testfixtures.StateStore.set("sessionJournalLeaseClockIndex", index); +value = values(min(index, numel(values))); end function failSecondManifest(stage) -global labkitSessionJournalManifestCount if string(stage) ~= "manifest" return; end -labkitSessionJournalManifestCount = labkitSessionJournalManifestCount + 1; -if labkitSessionJournalManifestCount >= 2 +count = testfixtures.StateStore.get("sessionJournalManifestCount", 0) + 1; +testfixtures.StateStore.set("sessionJournalManifestCount", count); +if count >= 2 error("labkit:test:LeaseManifestFailure", "Intentional closing manifest failure."); end end diff --git a/tests/specs/framework/app/SessionLogProjectionSpec.m b/tests/specs/labkit/app/SessionLogProjectionSpec.m similarity index 100% rename from tests/specs/framework/app/SessionLogProjectionSpec.m rename to tests/specs/labkit/app/SessionLogProjectionSpec.m diff --git a/tests/specs/framework/app/SessionLogViewerSpec.m b/tests/specs/labkit/app/SessionLogViewerSpec.m similarity index 99% rename from tests/specs/framework/app/SessionLogViewerSpec.m rename to tests/specs/labkit/app/SessionLogViewerSpec.m index d38d89669..5caf2ed42 100644 --- a/tests/specs/framework/app/SessionLogViewerSpec.m +++ b/tests/specs/labkit/app/SessionLogViewerSpec.m @@ -234,9 +234,5 @@ function exportsTheLiveBundleFromToolsAndTheViewer(testCase) end function invoke(callback, varargin) -if isa(callback, "function_handle") - callback(varargin{:}); -else - feval(callback, varargin{:}); -end +callback(varargin{:}); end diff --git a/tests/specs/framework/app/SessionLoggingContractSpec.m b/tests/specs/labkit/app/SessionLoggingContractSpec.m similarity index 100% rename from tests/specs/framework/app/SessionLoggingContractSpec.m rename to tests/specs/labkit/app/SessionLoggingContractSpec.m diff --git a/tests/specs/framework/app/SessionLoggingPrivacyContractSpec.m b/tests/specs/labkit/app/SessionLoggingPrivacyContractSpec.m similarity index 100% rename from tests/specs/framework/app/SessionLoggingPrivacyContractSpec.m rename to tests/specs/labkit/app/SessionLoggingPrivacyContractSpec.m diff --git a/tests/specs/framework/app/SessionLoggingRuntimeSpec.m b/tests/specs/labkit/app/SessionLoggingRuntimeSpec.m similarity index 92% rename from tests/specs/framework/app/SessionLoggingRuntimeSpec.m rename to tests/specs/labkit/app/SessionLoggingRuntimeSpec.m index 233f1f5a7..6f857e46f 100644 --- a/tests/specs/framework/app/SessionLoggingRuntimeSpec.m +++ b/tests/specs/labkit/app/SessionLoggingRuntimeSpec.m @@ -229,9 +229,8 @@ function reportsMidCallbackJournalFailureWithoutChangingOutcome(testCase) end function recordsCloseTimeJournalFailureAfterSessionClosed(testCase) - global labkitRuntimeManifestFaultCount labkitRuntimeJournalStages - labkitRuntimeManifestFaultCount = 0; - labkitRuntimeJournalStages = strings(0, 1); + testfixtures.StateStore.set("runtimeManifestFaultCount", 0); + testfixtures.StateStore.set("runtimeJournalStages", strings(0, 1)); resetFault = onCleanup(@resetRuntimeManifestFault); root = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; @@ -243,9 +242,10 @@ function recordsCloseTimeJournalFailureAfterSessionClosed(testCase) runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... definition, [], struct(), journal); - testCase.verifyEqual(labkitRuntimeManifestFaultCount, 1); - testCase.verifyEmpty(labkitRuntimeJournalStages( ... - labkitRuntimeJournalStages == "flush")); + testCase.verifyEqual( ... + testfixtures.StateStore.get("runtimeManifestFaultCount"), 1); + stages = testfixtures.StateStore.get("runtimeJournalStages"); + testCase.verifyEmpty(stages(stages == "flush")); runtime.close(); records = runtime.diagnosticEvents(); names = string({records.eventName}); @@ -261,9 +261,10 @@ function recordsCloseTimeJournalFailureAfterSessionClosed(testCase) testCase.verifyEqual(health.state, "closed"); testCase.verifyFalse(health.available); testCase.verifyFalse(any(startsWith(journalNames, "journal."))); - testCase.verifyEqual(labkitRuntimeManifestFaultCount, 3); - testCase.verifyNumElements(labkitRuntimeJournalStages( ... - labkitRuntimeJournalStages == "flush"), 1); + testCase.verifyEqual( ... + testfixtures.StateStore.get("runtimeManifestFaultCount"), 3); + stages = testfixtures.StateStore.get("runtimeJournalStages"); + testCase.verifyNumElements(stages(stages == "flush"), 1); clear resetFault end @@ -345,37 +346,37 @@ function failJournalInitialize(stage) end function failClosingRuntimeManifest(stage) -global labkitRuntimeManifestFaultCount if string(stage) ~= "manifest" return; end -labkitRuntimeManifestFaultCount = labkitRuntimeManifestFaultCount + 1; -if labkitRuntimeManifestFaultCount >= 2 +count = testfixtures.StateStore.get("runtimeManifestFaultCount", 0) + 1; +testfixtures.StateStore.set("runtimeManifestFaultCount", count); +if count >= 2 error("labkit:test:JournalManifestFailure", "Intentional closing manifest failure."); end end function resetRuntimeManifestFault() -global labkitRuntimeManifestFaultCount labkitRuntimeJournalStages -labkitRuntimeManifestFaultCount = 0; -labkitRuntimeJournalStages = strings(0, 1); +testfixtures.StateStore.reset("runtimeManifestFaultCount", "runtimeJournalStages"); end function recordRuntimeJournalStage(stage, ~) -global labkitRuntimeJournalStages -labkitRuntimeJournalStages(end + 1, 1) = string(stage); +stages = testfixtures.StateStore.get("runtimeJournalStages", strings(0, 1)); +testfixtures.StateStore.set("runtimeJournalStages", [stages; string(stage)]); end function names = journalEventNames(folder) segments = dir(fullfile(folder, "events-*.jsonl")); -names = strings(0, 1); +lineSets = cell(numel(segments), 1); for index = 1:numel(segments) lines = readlines(fullfile(segments(index).folder, segments(index).name)); - lines = lines(strlength(lines) > 0); - for line = lines.' - record = jsondecode(line); - names(end + 1, 1) = string(record.eventName); - end + lineSets{index} = lines(strlength(lines) > 0); +end +lines = vertcat(lineSets{:}); +names = strings(numel(lines), 1); +for index = 1:numel(lines) + record = jsondecode(lines(index)); + names(index) = string(record.eventName); end end @@ -384,8 +385,9 @@ function recordRuntimeJournalStage(stage, ~) Category="analysisRun", Attributes=struct("validItemCount", 2)); end -function state = failLoggingProbe(state, ~) -error("probe:ExpectedFailure", "Expected rollback failure."); +function state = failLoggingProbe(~, ~) +state = MException("probe:ExpectedFailure", "Expected rollback failure."); +throw(state); end function state = installFailingResource(state, callbackContext) @@ -416,5 +418,7 @@ function failResourceCleanup(~) end function session = failRuntimeSession(~, ~) -error("probe:SessionConstructionFailure", "Intentional session construction failure."); +session = MException("probe:SessionConstructionFailure", ... + "Intentional session construction failure."); +throw(session); end diff --git a/tests/specs/system/launcher/LauncherDispatchSpec.m b/tests/specs/labkit/app/internal/launcher/LauncherDispatchSpec.m similarity index 99% rename from tests/specs/system/launcher/LauncherDispatchSpec.m rename to tests/specs/labkit/app/internal/launcher/LauncherDispatchSpec.m index 18a546df3..f22ca195b 100644 --- a/tests/specs/system/launcher/LauncherDispatchSpec.m +++ b/tests/specs/labkit/app/internal/launcher/LauncherDispatchSpec.m @@ -621,7 +621,8 @@ function invokeCallback(callback, source, event) if isa(callback, "function_handle") callback(source, event); else - feval(callback{1}, source, event, callback{2:end}); + callbackFunction = callback{1}; + callbackFunction(source, event, callback{2:end}); end end diff --git a/tests/specs/framework/biosignal/BiosignalFacadeSpec.m b/tests/specs/labkit/biosignal/BiosignalFacadeSpec.m similarity index 100% rename from tests/specs/framework/biosignal/BiosignalFacadeSpec.m rename to tests/specs/labkit/biosignal/BiosignalFacadeSpec.m diff --git a/tests/specs/framework/contract/ContractFacadeSpec.m b/tests/specs/labkit/contract/ContractFacadeSpec.m similarity index 100% rename from tests/specs/framework/contract/ContractFacadeSpec.m rename to tests/specs/labkit/contract/ContractFacadeSpec.m diff --git a/tests/specs/framework/dta/DtaFacadeSpec.m b/tests/specs/labkit/dta/DtaFacadeSpec.m similarity index 100% rename from tests/specs/framework/dta/DtaFacadeSpec.m rename to tests/specs/labkit/dta/DtaFacadeSpec.m diff --git a/tests/specs/framework/dta/DtaPulseSpec.m b/tests/specs/labkit/dta/DtaPulseSpec.m similarity index 100% rename from tests/specs/framework/dta/DtaPulseSpec.m rename to tests/specs/labkit/dta/DtaPulseSpec.m diff --git a/tests/specs/framework/image/ImageFacadeSpec.m b/tests/specs/labkit/image/ImageFacadeSpec.m similarity index 100% rename from tests/specs/framework/image/ImageFacadeSpec.m rename to tests/specs/labkit/image/ImageFacadeSpec.m diff --git a/tests/specs/framework/rhs/RhsFacadeSpec.m b/tests/specs/labkit/rhs/RhsFacadeSpec.m similarity index 100% rename from tests/specs/framework/rhs/RhsFacadeSpec.m rename to tests/specs/labkit/rhs/RhsFacadeSpec.m diff --git a/tests/specs/framework/thermal/ThermalFacadeSpec.m b/tests/specs/labkit/thermal/ThermalFacadeSpec.m similarity index 100% rename from tests/specs/framework/thermal/ThermalFacadeSpec.m rename to tests/specs/labkit/thermal/ThermalFacadeSpec.m diff --git a/tests/specs/system/launcher/LauncherBootstrapSpec.m b/tests/specs/labkit_launcher/LauncherBootstrapSpec.m similarity index 99% rename from tests/specs/system/launcher/LauncherBootstrapSpec.m rename to tests/specs/labkit_launcher/LauncherBootstrapSpec.m index b298fa82f..e5adf9632 100644 --- a/tests/specs/system/launcher/LauncherBootstrapSpec.m +++ b/tests/specs/labkit_launcher/LauncherBootstrapSpec.m @@ -178,7 +178,7 @@ function fixtureLauncherIgnoresAnEarlierShadowDispatchPackage(testCase) delete(cleanup) end - function repairRootDoesNotContainRuntimeLoggingOrStaticDispatchCalls(testCase) + function repairRootKeepsDispatchStaticAndAvoidsRuntimeLogging(testCase) source = string(fileread(fullfile(labkittest.setup(), "labkit_launcher.m"))); testCase.verifyFalse(contains(source, "SessionEvent", "IgnoreCase", true)); @@ -187,9 +187,8 @@ function repairRootDoesNotContainRuntimeLoggingOrStaticDispatchCalls(testCase) testCase.verifyFalse(contains(source, "bootstrap logging", "IgnoreCase", true)); testCase.verifyFalse(contains(source, "Version Manager", "IgnoreCase", true)); testCase.verifyFalse(contains(source, ".labkit-managed-files.txt", "IgnoreCase", true)); - testCase.verifyTrue(contains(source, ... - 'str2func("labkit.app.internal.launcher.dispatch")')); - testCase.verifyFalse(contains(source, "labkit.app.internal.launcher.dispatch(")); + testCase.verifyFalse(contains(source, "str2func(")); + testCase.verifyTrue(contains(source, "labkit.app.internal.launcher.dispatch(")); testCase.verifyTrue(contains(source, ... 'strlength(string(folder)) > 0 && exist(folder, "dir") == 7')); testCase.verifyTrue(contains(source, ... @@ -689,7 +688,8 @@ function invokeRepairCallback(callback, source, event) if isa(callback, "function_handle") callback(source, event); else - feval(callback{1}, source, event, callback{2:end}); + callbackFunction = callback{1}; + callbackFunction(source, event, callback{2:end}); end end diff --git a/tests/specs/system/repository/TestArchitectureSpec.m b/tests/specs/repository/TestArchitectureSpec.m similarity index 86% rename from tests/specs/system/repository/TestArchitectureSpec.m rename to tests/specs/repository/TestArchitectureSpec.m index f3d61e149..22d5027da 100644 --- a/tests/specs/system/repository/TestArchitectureSpec.m +++ b/tests/specs/repository/TestArchitectureSpec.m @@ -40,7 +40,9 @@ function catalogOwnsTheOnlyRunnableSpecificationRoot(testCase) testCase.verifyNotEmpty(descriptors); testCase.verifyTrue(all(startsWith(string({descriptors.Owner}), ... - ["apps/" "framework/" "system/"]) | ... + ["apps/" "labkit/" "tools/" "tests/"]) | ... + ismember(string({descriptors.Owner}), ... + ["labkit_launcher" "repository"]) | ... string({descriptors.Owner}) == "")); testCase.verifyFalse(isfile(fullfile(root, "tests", "runLabKitTests.m")) && ... isfile(fullfile(root, "buildfile.m")) && ... @@ -51,6 +53,38 @@ function catalogOwnsTheOnlyRunnableSpecificationRoot(testCase) testCase.verifyFalse(isfolder(fullfile(root, "tests", "runner"))); end + function productionDynamicInvocationIsClosedAndOwned(testCase) + root = labkittest.setup(); + files = replace(repositoryTextFiles(root), "\", "/"); + files = files(endsWith(lower(files), ".m")); + files = files(files == "labkit_launcher.m" | ... + startsWith(files, ["+labkit/" "apps/" "tools/"])); + allowedFiles = [ ... + "+labkit/+app/+internal/+launcher/dispatch.m" + "+labkit/+app/+internal/private/FigureInteractionHub.m" + "tools/profiling/profileLabKitTarget.m"]; + markers = [ ... + "Dynamic extension boundary" + "Compatibility boundary" + "Dynamic maintainer-tool boundary"]; + + for file = files.' + source = string(fileread(fullfile(root, file))); + calls = regexp(source, ... + '(?= 0, "Could not create profiling fixture."); +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s", content); +clear cleanup +end diff --git a/tools/codecheck/private/writeCodecheckReport.m b/tools/codecheck/private/writeCodecheckReport.m index ebf057e28..0fcdbcfb9 100644 --- a/tools/codecheck/private/writeCodecheckReport.m +++ b/tools/codecheck/private/writeCodecheckReport.m @@ -59,7 +59,7 @@ paths = [paths; sourcePaths(fieldArray(payload, "Issues"))]; paths = [paths; sourcePaths(fieldArray(payload, "SuppressedIssues"))]; paths = unique(paths, "stable"); - sources = repmat(struct("path", "", "lines", {{}}), 0, 1); + sources = repmat(struct("path", "", "lines", {{}}), numel(paths), 1); for k = 1:numel(paths) filepath = char(paths(k)); if exist(filepath, "file") ~= 2 @@ -67,7 +67,7 @@ else lines = cellstr(splitlines(sanitizeSourceText(string(fileread(filepath))))); end - sources(end+1, 1) = struct("path", filepath, "lines", {lines}); + sources(k, 1) = struct("path", filepath, "lines", {lines}); end end @@ -80,12 +80,15 @@ end function paths = sourcePaths(issues) - paths = strings(0, 1); + paths = strings(numel(issues), 1); + pathCount = 0; for k = 1:numel(issues) if isfield(issues(k), "FullFilename") && ~isempty(issues(k).FullFilename) - paths(end+1, 1) = string(issues(k).FullFilename); + pathCount = pathCount + 1; + paths(pathCount, 1) = string(issues(k).FullFilename); end end + paths = paths(1:pathCount); end function html = buildHtml(jsonText, summary, sources) diff --git a/tools/codecheck/runCodecheckReport.m b/tools/codecheck/runCodecheckReport.m index dc82f91f3..8627b015d 100644 --- a/tools/codecheck/runCodecheckReport.m +++ b/tools/codecheck/runCodecheckReport.m @@ -15,6 +15,9 @@ root = char(string(p.Results.root)); progressFcn = p.Results.ProgressFcn; + if isempty(progressFcn) + progressFcn = @writeConsoleProgress; + end excludedFolders = [".git", ".github", ".vscode", ".codes", ... "artifacts", "node_modules", "photos"]; scanRoots = codecheckScanRoots(root); @@ -57,7 +60,7 @@ function reportBase = uniqueReportBase(outputRoot) ensureFolder(outputRoot); - stamp = datestr(now, "yyyymmdd_HHMMSS"); + stamp = string(datetime("now", "Format", "yyyyMMdd_HHmmss")); reportBase = fullfile(outputRoot, "matlab_code_issues_" + string(stamp)); suffix = 1; while exist(reportBase + ".json", "file") == 2 || exist(reportBase + ".html", "file") == 2 @@ -143,12 +146,15 @@ function openHtmlReport(htmlOutput) return; end - roots = strings(0, 1); + roots = strings(numel(candidates), 1); + rootCount = 0; for k = 1:numel(candidates) if privateRootAcceptsMainGuardrails(candidates(k)) - roots(end+1, 1) = candidates(k); + rootCount = rootCount + 1; + roots(rootCount, 1) = candidates(k); end end + roots = roots(1:rootCount); end function tf = forcePrivateAppGuardsEnabled() @@ -157,10 +163,13 @@ function openHtmlReport(htmlOutput) end function roots = configuredPrivateAppRoots(root) - roots = strings(0, 1); + candidateRoots = strings(numel(strsplit(char(string(getenv( ... + "LABKIT_PRIVATE_APP_ROOTS"))), pathsep)) + 1, 1); + candidateCount = 0; localPrivateRoot = string(fullfile(root, "private_apps", "apps")); if exist(localPrivateRoot, "dir") == 7 - roots(end+1) = localPrivateRoot; + candidateCount = candidateCount + 1; + candidateRoots(candidateCount) = localPrivateRoot; end envValue = string(getenv("LABKIT_PRIVATE_APP_ROOTS")); @@ -171,11 +180,12 @@ function openHtmlReport(htmlOutput) for k = 1:numel(parts) candidate = privateAppRootAppsFolder(parts(k)); if exist(candidate, "dir") == 7 - roots(end+1) = candidate; + candidateCount = candidateCount + 1; + candidateRoots(candidateCount) = candidate; end end end - roots = unique(roots, "stable"); + roots = unique(candidateRoots(1:candidateCount), "stable"); end function appRoot = privateAppRootAppsFolder(root) @@ -203,7 +213,8 @@ function openHtmlReport(htmlOutput) function files = collectFiles(root, pattern, excludedFolders) entries = dir(fullfile(root, "**", pattern)); - files = strings(1, 0); + files = strings(1, numel(entries)); + fileCount = 0; for k = 1:numel(entries) if entries(k).isdir continue; @@ -214,8 +225,10 @@ function openHtmlReport(htmlOutput) if any(ismember(parts, excludedFolders)) continue; end - files(end+1) = filepath; + fileCount = fileCount + 1; + files(fileCount) = filepath; end + files = files(1:fileCount); end function rel = relativePath(root, filepath) @@ -228,12 +241,13 @@ function openHtmlReport(htmlOutput) end function notifyProgress(progressFcn, message, value) - if isempty(progressFcn) - return; - end progressFcn(message, value); end +function writeConsoleProgress(message, value) + fprintf("CODECHECK [%3.0f%%] %s\n", 100 * value, message); +end + function ensureFolder(folder) if strlength(string(folder)) > 0 && exist(folder, "dir") ~= 7 mkdir(folder); diff --git a/tools/deployment/packageLabKitApp.m b/tools/deployment/packageLabKitApp.m index 01b1ed1c3..00edbb38a 100644 --- a/tools/deployment/packageLabKitApp.m +++ b/tools/deployment/packageLabKitApp.m @@ -141,9 +141,11 @@ function apps = resolvePackageApps(root, selector) if isstruct(selector) - apps = emptyPackageApp(); + template = struct("command", "", "folder", "", "entryFile", "", ... + "packageRelativeFolder", "", "visibility", ""); + apps = repmat(template, 1, numel(selector)); for k = 1:numel(selector) - apps(end+1) = appFromLauncherStruct(root, selector(k)); + apps(k) = appFromLauncherStruct(root, selector(k)); end apps = uniquePackageApps(apps); return; @@ -151,9 +153,11 @@ if iscell(selector) || (isstring(selector) && ~isscalar(selector)) selectors = string(selector); - apps = emptyPackageApp(); + template = struct("command", "", "folder", "", "entryFile", "", ... + "packageRelativeFolder", "", "visibility", ""); + apps = repmat(template, 1, numel(selectors)); for k = 1:numel(selectors) - apps(end+1) = resolvePackageApp(root, selectors(k)); + apps(k) = resolvePackageApp(root, selectors(k)); end apps = uniquePackageApps(apps); return; @@ -293,18 +297,25 @@ end function roots = appDiscoveryRoots(root) - roots = struct("appRoot", string(fullfile(root, "apps")), "visibility", "public"); privateRoots = privateAppRoots(root); + roots = repmat(struct("appRoot", "", "visibility", ""), ... + 1, numel(privateRoots) + 1); + roots(1) = struct("appRoot", string(fullfile(root, "apps")), ... + "visibility", "public"); for k = 1:numel(privateRoots) - roots(end+1) = struct("appRoot", privateRoots(k), "visibility", "private"); + roots(k + 1) = struct( ... + "appRoot", privateRoots(k), "visibility", "private"); end end function roots = privateAppRoots(root) - roots = strings(1, 0); + candidates = strings(numel(strsplit(char(string(getenv( ... + "LABKIT_PRIVATE_APP_ROOTS"))), pathsep)) + 1, 1); + candidateCount = 0; localRoot = string(fullfile(root, "private_apps", "apps")); if exist(localRoot, "dir") == 7 - roots(end+1) = localRoot; + candidateCount = candidateCount + 1; + candidates(candidateCount) = localRoot; end envValue = string(getenv("LABKIT_PRIVATE_APP_ROOTS")); @@ -315,11 +326,12 @@ for k = 1:numel(parts) candidate = privateAppRootAppsFolder(parts(k)); if exist(candidate, "dir") == 7 - roots(end+1) = candidate; + candidateCount = candidateCount + 1; + candidates(candidateCount) = candidate; end end end - roots = unique(roots, "stable"); + roots = unique(candidates(1:candidateCount), "stable"); end function appRoot = privateAppRootAppsFolder(root) @@ -408,12 +420,8 @@ function validateSourceFilesAvailable(root, app) string(app.entryFile) string(fullfile(root, "tools", "deployment", "packageLabKitApp.m")) string(fullfile(root, "tools", "profiling", "profileLabKitTarget.m"))]; - missing = strings(1, 0); - for k = 1:numel(requiredSources) - if exist(requiredSources(k), "file") ~= 2 - missing(end+1) = requiredSources(k); - end - end + missing = requiredSources(arrayfun( ... + @(path) exist(path, "file") ~= 2, requiredSources)); if ~isempty(missing) error("packageLabKitApp:SourceUnavailable", ... "Source package format requires .m files. Use CodeFormat='pcode' for P-code roots. Missing: %s", ... @@ -512,12 +520,12 @@ function pcodePackageCode(packageRoot) end function zipFile = defaultZipFile(outputRoot, apps) - timestamp = string(datestr(now, "yyyymmdd_HHMMSS")); + timestamp = string(datetime("now", "Format", "yyyyMMdd_HHmmss")); zipFile = fullfile(outputRoot, packageFolderName(apps) + "_" + timestamp + ".zip"); end function name = packageFolderName(apps) - if numel(apps) == 1 + if isscalar(apps) name = "LabKitApp_" + sanitizeFilename(apps.command); else name = "LabKitApps_" + string(numel(apps)); diff --git a/tools/deployment/private/packageManifestText.m b/tools/deployment/private/packageManifestText.m index 1e4b2e71e..410e46d38 100644 --- a/tools/deployment/private/packageManifestText.m +++ b/tools/deployment/private/packageManifestText.m @@ -1,7 +1,7 @@ function text = packageManifestText(apps, entryRelativeFiles, codeFormat) % Build the deployment manifest for resolved app records and package entries. - if numel(apps) == 1 + if isscalar(apps) text = singleAppManifestText(apps, entryRelativeFiles(1), codeFormat); return; end diff --git a/tools/docs/checkLabKitDocs.m b/tools/docs/checkLabKitDocs.m index aeb216c5e..35ae98365 100644 --- a/tools/docs/checkLabKitDocs.m +++ b/tools/docs/checkLabKitDocs.m @@ -7,7 +7,8 @@ % omitted, the function renders an independent reference. % Output: % result - renderer result plus comparedFileCount. -% Side effects: creates and removes one or two temporary generated sites. +% Side effects: creates and removes one or two temporary generated sites and +% reports render/compare stages to the console. repoRoot = fileparts(fileparts(fileparts(mfilename("fullpath")))); if nargin < 1 || strlength(string(sourceRoot)) == 0 @@ -19,12 +20,15 @@ existingSiteRoot = tempname; referenceCleanup = onCleanup( ... @() removeDocFolder(existingSiteRoot)); + fprintf("DOCS CHECK [1/3] render reference\n"); renderLabKitDocs(sourceRoot, existingSiteRoot); end generatedRoot = tempname; cleanup = onCleanup(@() removeDocFolder(generatedRoot)); + fprintf("DOCS CHECK [2/3] render candidate\n"); result = renderLabKitDocs(sourceRoot, generatedRoot); + fprintf("DOCS CHECK [3/3] compare generated trees\n"); [matches, diagnostic, count] = compareLabKitDocTrees( ... generatedRoot, existingSiteRoot); if ~matches diff --git a/tools/docs/maintainLabKitDocLinks.m b/tools/docs/maintainLabKitDocLinks.m index 6aa35ef1c..20cdaaf5a 100644 --- a/tools/docs/maintainLabKitDocLinks.m +++ b/tools/docs/maintainLabKitDocLinks.m @@ -44,7 +44,7 @@ repoRoot = absoluteFolder(repoRoot); files = markdownFiles(repoRoot); pageInfo = markdownPageInfo(repoRoot, files); - unresolved = strings(0, 1); + unresolvedChunks = cell(numel(files), 1); repairedLinkCount = 0; updatedFileCount = 0; @@ -54,12 +54,16 @@ [updated, repairs, failures] = repairFileLinks( ... repoRoot, filepath, original, pageInfo); repairedLinkCount = repairedLinkCount + repairs; - unresolved = [unresolved; failures]; + unresolvedChunks{k} = failures; if options.Update && updated ~= original writeText(filepath, updated); updatedFileCount = updatedFileCount + 1; end end + unresolved = strings(0, 1); + if ~isempty(unresolvedChunks) + unresolved = vertcat(unresolvedChunks{:}); + end result = struct( ... "checkedFileCount", numel(files), ... "repairedLinkCount", repairedLinkCount, ... @@ -86,7 +90,8 @@ function files = markdownFiles(repoRoot) entries = dir(fullfile(repoRoot, "**", "*.md")); - files = strings(0, 1); + files = strings(numel(entries), 1); + fileCount = 0; for k = 1:numel(entries) filepath = string(fullfile(entries(k).folder, entries(k).name)); relative = replace(extractAfter(filepath, repoRoot + filesep), ... @@ -94,9 +99,10 @@ if startsWith(relative, ["site/", "artifacts/", ".git/"]) continue; end - files(end + 1, 1) = filepath; + fileCount = fileCount + 1; + files(fileCount, 1) = filepath; end - files = sort(files); + files = sort(files(1:fileCount)); end function info = markdownPageInfo(repoRoot, files) @@ -128,7 +134,8 @@ "start", "end", "tokens"); updated = text; repaired = 0; - failures = strings(0, 1); + failures = strings(numel(starts), 1); + failureCount = 0; for k = numel(tokens):-1:1 label = string(tokens{k}{1}); rawTarget = string(tokens{k}{2}); @@ -145,7 +152,8 @@ if strlength(candidate) == 0 relativeSource = replace(extractAfter( ... filepath, repoRoot + filesep), filesep, "/"); - failures(end + 1, 1) = relativeSource + " -> " + rawTarget; + failureCount = failureCount + 1; + failures(failureCount, 1) = relativeSource + " -> " + rawTarget; continue; end newTarget = relativeMarkdownPath(currentFolder, candidate) + suffix; @@ -154,6 +162,7 @@ extractAfter(updated, finishes(k)); repaired = repaired + 1; end + failures = failures(1:failureCount); end function [pathPart, suffix] = splitTarget(target) @@ -183,11 +192,11 @@ normalizedLabel = normalizedWords(label); titles = arrayfun(@(item) normalizedWords(item.title), pageInfo(matches)); exact = matches(titles == normalizedLabel); - if numel(exact) == 1 + if isscalar(exact) matches = exact; end end - if numel(matches) == 1 + if isscalar(matches) candidate = string(pageInfo(matches).path); else candidate = ""; diff --git a/tools/docs/private/listLabKitDocTreeFiles.m b/tools/docs/private/listLabKitDocTreeFiles.m index 7aa519c33..b6d83b2bb 100644 --- a/tools/docs/private/listLabKitDocTreeFiles.m +++ b/tools/docs/private/listLabKitDocTreeFiles.m @@ -27,11 +27,15 @@ filesep, "/"); end rootHiddenFiles = ".nojekyll"; + rootHiddenPresent = strings(numel(rootHiddenFiles), 1); + presentCount = 0; for k = 1:numel(rootHiddenFiles) if isfile(fullfile(root, rootHiddenFiles(k))) && ... ~any(files == rootHiddenFiles(k)) - files(end + 1, 1) = rootHiddenFiles(k); + presentCount = presentCount + 1; + rootHiddenPresent(presentCount) = rootHiddenFiles(k); end end + files = [files; rootHiddenPresent(1:presentCount)]; files = sort(unique(files)); end diff --git a/tools/docs/private/loadLabKitDocumentation.m b/tools/docs/private/loadLabKitDocumentation.m index a952afffe..27c121b81 100644 --- a/tools/docs/private/loadLabKitDocumentation.m +++ b/tools/docs/private/loadLabKitDocumentation.m @@ -54,7 +54,8 @@ function pages = discoverNarrativePages(sourceRoot, apps) entries = dir(fullfile(sourceRoot, "**", "*.md")); - sources = strings(0, 1); + sources = strings(numel(entries), 1); + sourceCount = 0; for k = 1:numel(entries) filepath = string(fullfile(entries(k).folder, entries(k).name)); source = replace(extractAfter(filepath, string(sourceRoot) + filesep), ... @@ -62,9 +63,10 @@ if startsWith(source, "history/records/") continue; end - sources(end + 1, 1) = source; + sourceCount = sourceCount + 1; + sources(sourceCount, 1) = source; end - sources = sort(sources); + sources = sort(sources(1:sourceCount)); pages = repmat(emptyPage(), numel(sources), 1); for k = 1:numel(sources) source = sources(k); @@ -72,7 +74,7 @@ text = string(fileread(filepath)); title = markdownTitle(text, source); [id, output, kind, nav, components] = ... - narrativeIdentity(source, title, apps); + narrativeIdentity(source, apps); raw = struct( ... "id", id, ... "source", source, ... @@ -102,9 +104,8 @@ end function [id, output, kind, nav, components] = ... - narrativeIdentity(source, title, apps) + narrativeIdentity(source, apps) source = string(source); - title = string(title); isReadme = endsWith(source, "/README.md") || source == "README.md"; stem = erase(source, ".md"); if isReadme @@ -290,7 +291,7 @@ changeType = record.type; compatibility = record.compatibility; componentLines = record.components; - components = strings(0, 1); + components = strings(numel(componentLines), 1); for iLine = 1:numel(componentLines) token = regexp(componentLines(iLine), ... '^`([^`]+)`', "tokens", "once"); @@ -298,7 +299,7 @@ error("LabKit:Docs:InvalidHistory", ... "History page has malformed component metadata: %s", source); end - components(end + 1, 1) = string(token{1}); + components(iLine, 1) = string(token{1}); end raw = struct( ... "id", "history-" + historyId, ... @@ -409,7 +410,8 @@ function api = discoverLabKitPublicApi(repoRoot) root = fullfile(repoRoot, "+labkit"); entries = dir(fullfile(root, "**", "*.m")); - api = repmat(emptyApi(), 0, 1); + api = repmat(emptyApi(), numel(entries), 1); + apiCount = 0; for k = 1:numel(entries) filepath = string(fullfile(entries(k).folder, entries(k).name)); if contains(filepath, filesep + "private" + filesep) @@ -425,8 +427,10 @@ continue; end item = readApiItem(repoRoot, filepath, "library", "labkit"); - api(end + 1, 1) = item; + apiCount = apiCount + 1; + api(apiCount, 1) = item; end + api = api(1:apiCount); [~, order] = sort(string({api.symbol})); api = api(order); end @@ -440,7 +444,8 @@ function api = discoverAppPublicApi(repoRoot, apps) entries = dir(fullfile(repoRoot, "apps", "**", "*.m")); - api = repmat(emptyApi(), 0, 1); + api = repmat(emptyApi(), numel(entries), 1); + apiCount = 0; for k = 1:numel(entries) filepath = string(fullfile(entries(k).folder, entries(k).name)); text = string(fileread(filepath)); @@ -467,8 +472,10 @@ owner = replace(appId, "-", "_"); item = readApiItem(repoRoot, filepath, "app", owner); item.family = char(string(apps(appIndex).familyTitle)); - api(end + 1, 1) = item; + apiCount = apiCount + 1; + api(apiCount, 1) = item; end + api = api(1:apiCount); end function tf = hasPublicAppHelpContract(text) @@ -517,7 +524,8 @@ end signature = strjoin(strip(lines(start:finish)), newline); leadingHelp = leadingCommentBlock(lines, start); - helpLines = strings(0, 1); + helpLines = strings(max(numel(lines) - finish, 0), 1); + helpLineCount = 0; index = finish + 1; while index <= numel(lines) line = lines(index); @@ -529,9 +537,11 @@ if startsWith(text, " ") text = extractAfter(text, 1); end - helpLines(end + 1, 1) = text; + helpLineCount = helpLineCount + 1; + helpLines(helpLineCount, 1) = text; index = index + 1; end + helpLines = helpLines(1:helpLineCount); if isempty(helpLines) && ~isempty(leadingHelp) helpLines = leadingHelp; end @@ -552,7 +562,8 @@ end function comments = leadingCommentBlock(lines, functionStart) - comments = strings(0, 1); + comments = strings(max(functionStart - 1, 0), 1); + commentCount = 0; for k = 1:(functionStart - 1) trimmed = strtrim(lines(k)); if startsWith(trimmed, "%") @@ -560,11 +571,13 @@ if startsWith(text, " ") text = extractAfter(text, 1); end - comments(end + 1, 1) = text; + commentCount = commentCount + 1; + comments(commentCount, 1) = text; elseif strlength(trimmed) > 0 - comments = strings(0, 1); + commentCount = 0; end end + comments = comments(1:commentCount); end function item = emptyApi() diff --git a/tools/docs/private/renderLabKitApiBody.m b/tools/docs/private/renderLabKitApiBody.m index 06fea28a9..498bddd59 100644 --- a/tools/docs/private/renderLabKitApiBody.m +++ b/tools/docs/private/renderLabKitApiBody.m @@ -44,21 +44,27 @@ function html = renderSyntax(syntax) lines = splitlines(string(syntax)); - groups = strings(0, 1); - current = strings(0, 1); + groups = strings(numel(lines), 1); + groupCount = 0; + current = strings(numel(lines), 1); + currentCount = 0; for k = 1:numel(lines) if strlength(strip(lines(k))) == 0 - if ~isempty(current) - groups(end + 1, 1) = syntaxGroup(current); - current = strings(0, 1); + if currentCount > 0 + groupCount = groupCount + 1; + groups(groupCount, 1) = syntaxGroup(current(1:currentCount)); + currentCount = 0; end else - current(end + 1, 1) = lines(k); + currentCount = currentCount + 1; + current(currentCount, 1) = lines(k); end end - if ~isempty(current) - groups(end + 1, 1) = syntaxGroup(current); + if currentCount > 0 + groupCount = groupCount + 1; + groups(groupCount, 1) = syntaxGroup(current(1:currentCount)); end + groups = groups(1:groupCount); html = "
" + strjoin(groups, "") + ... "
"; end @@ -74,7 +80,8 @@ lines(1) = []; end sections = parseSections(lines); - blocks = strings(0, 1); + blocks = strings(numel(sections), 1); + blockCount = 0; for k = 1:numel(sections) if any(lower(sections(k).name) == ... ["usage", "app-facing contract", "see also"]) @@ -86,17 +93,19 @@ end title = displaySectionTitle(sections(k).name); id = slug(title); - blocks(end + 1, 1) = "

" + htmlEscape(title) + "

" + ... renderSectionContent( ... title, content, api, item, outputPath) + "
"; end - html = strjoin(blocks, newline); + html = strjoin(blocks(1:blockCount), newline); end function syntax = publicCallSyntax(helpText, fallback) lines = splitlines(string(helpText)); - syntaxLines = strings(0, 1); + syntaxLines = strings(numel(lines), 1); + syntaxLineCount = 0; collecting = false; for k = 1:numel(lines) line = lines(k); @@ -112,9 +121,11 @@ break; end elseif collecting - syntaxLines(end + 1, 1) = line; + syntaxLineCount = syntaxLineCount + 1; + syntaxLines(syntaxLineCount, 1) = line; end end + syntaxLines = syntaxLines(1:syntaxLineCount); syntaxLines = stripEmptyEdges(syntaxLines); if isempty(syntaxLines) syntax = string(fallback); @@ -125,7 +136,7 @@ function sections = parseSections(lines) template = struct("name", "Description", "lines", strings(0, 1)); - sections = template; + sections = repmat(template, numel(lines) + 1, 1); current = 1; for k = 1:numel(lines) line = lines(k); @@ -135,8 +146,7 @@ isSeeAlso = line == trimmed && ... startsWith(lower(trimmed), "see also "); if isSeeAlso - sections(end + 1, 1) = template; - current = numel(sections); + current = current + 1; sections(current).name = "See also"; sections(current).lines = extractAfter( ... trimmed, strlength("See also ")); @@ -146,14 +156,14 @@ sections(current).name == "Description" sections(current).name = name; else - sections(end + 1, 1) = template; - current = numel(sections); + current = current + 1; sections(current).name = name; end else sections(current).lines(end + 1, 1) = line; end end + sections = sections(1:current); end function html = renderSectionContent(title, lines, api, item, outputPath) @@ -180,27 +190,35 @@ end function html = renderDefinitions(lines, api, item, outputPath) - entries = repmat(struct("term", "", "description", ""), 0, 1); - preface = strings(0, 1); + entries = repmat(struct("term", "", "description", ""), numel(lines), 1); + entryCount = 0; + preface = strings(numel(lines), 1); + prefaceCount = 0; current = 0; for k = 1:numel(lines) token = regexp(char(lines(k)), ... '^\s{2,}([A-Za-z][A-Za-z0-9_.:]*(?:\([^)]*\))?)\s+-\s+(.*)$', ... 'tokens', 'once'); if ~isempty(token) - entries(end + 1, 1) = struct("term", string(token{1}), ... + entryCount = entryCount + 1; + entries(entryCount, 1) = struct("term", string(token{1}), ... "description", string(token{2})); - current = numel(entries); + current = entryCount; elseif current > 0 && strlength(strip(lines(k))) > 0 entries(current).description = entries(current).description + " " + ... strip(lines(k)); elseif current == 0 - preface(end + 1, 1) = lines(k); + prefaceCount = prefaceCount + 1; + preface(prefaceCount, 1) = lines(k); end end - parts = strings(0, 1); + entries = entries(1:entryCount); + preface = preface(1:prefaceCount); + parts = strings(2, 1); + partCount = 0; if any(strlength(strip(preface)) > 0) - parts(end + 1, 1) = renderParagraphs( ... + partCount = partCount + 1; + parts(partCount, 1) = renderParagraphs( ... preface, api, item, outputPath); end if ~isempty(entries) @@ -211,29 +229,33 @@ renderApiText(entries(k).description, ... api, item, outputPath) + ""; end - parts(end + 1, 1) = "
" + ... + partCount = partCount + 1; + parts(partCount, 1) = "
" + ... strjoin(rows, "") + "
"; end - html = strjoin(parts, newline); + html = strjoin(parts(1:partCount), newline); end function html = renderParagraphs(lines, api, item, outputPath) text = strip(strjoin(lines, newline)); paragraphs = regexp(char(text), '\n\s*\n', 'split'); - blocks = strings(0, 1); + blocks = strings(numel(paragraphs), 1); + blockCount = 0; for k = 1:numel(paragraphs) value = strip(regexprep(string(paragraphs{k}), '\s+', ' ')); if strlength(value) > 0 - blocks(end + 1, 1) = "

" + ... + blockCount = blockCount + 1; + blocks(blockCount, 1) = "

" + ... renderApiText(value, api, item, outputPath) + "

"; end end - html = strjoin(blocks, newline); + html = strjoin(blocks(1:blockCount), newline); end function html = renderApiText(text, api, item, outputPath) protected = string(text); - replacements = strings(0, 1); + replacements = strings(strlength(protected), 1); + replacementCount = 0; symbols = string({api.symbol}); symbols(symbols == string(item.symbol)) = []; [~, order] = sort(strlength(symbols), "descend"); @@ -251,15 +273,17 @@ end target = "reference/api/" + replace(symbol, ".", "/") + ... ".html"; - replacements(end + 1, 1) = ... + replacementCount = replacementCount + 1; + replacements(replacementCount, 1) = ... "" + ... htmlEscape(symbol) + ""; - marker = "@@LABKITAPILINK" + string(numel(replacements)) + "@@"; + marker = "@@LABKITAPILINK" + string(replacementCount) + "@@"; protected = extractBefore(protected, first) + marker + ... extractAfter(protected, last); end end + replacements = replacements(1:replacementCount); html = htmlEscape(protected); for k = 1:numel(replacements) marker = "@@LABKITAPILINK" + string(k) + "@@"; @@ -363,7 +387,8 @@ '[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*', ... 'match')); symbols = string({api.symbol}); - indices = zeros(1, 0); + indices = zeros(1, numel(tokens)); + indexCount = 0; for k = 1:numel(tokens) token = tokens(k); index = find(symbols == token, 1); @@ -372,15 +397,16 @@ end if isempty(index) && ~contains(token, ".") suffixMatches = find(endsWith(symbols, "." + token)); - if numel(suffixMatches) == 1 + if isscalar(suffixMatches) index = suffixMatches; end end if ~isempty(index) - indices(end + 1) = index; + indexCount = indexCount + 1; + indices(indexCount) = index; end end - indices = unique(indices, "stable"); + indices = unique(indices(1:indexCount), "stable"); end function value = displaySectionTitle(value) diff --git a/tools/docs/private/renderLabKitApiIndex.m b/tools/docs/private/renderLabKitApiIndex.m index b3d1c6199..9f20e7f0c 100644 --- a/tools/docs/private/renderLabKitApiIndex.m +++ b/tools/docs/private/renderLabKitApiIndex.m @@ -6,16 +6,16 @@ api = model.api; groups = apiGroups(api); - blocks = strings(0, 1); - words = strings(0, 1); + blocks = strings(numel(groups), 1); + words = strings(numel(groups), 1); for k = 1:numel(groups) items = api(groups(k).indices); [target, label] = categoryTarget(model, items, groups(k)); rows = "" + htmlEscape(label) + "" + ... htmlEscape(groups(k).description) + ""; - words(end + 1, 1) = label + " " + groups(k).description; - blocks(end + 1, 1) = "

" + htmlEscape(groups(k).title) + ... "

" + htmlEscape(groups(k).description) + ... "

" + ... @@ -49,8 +49,6 @@ end function groups = apiGroups(api) - groups = repmat(struct("id", "", "title", "", "description", "", ... - "indices", []), 0, 1); symbols = string({api.symbol}); origin = string({api.origin}); libraryIndices = find(origin == "library" & ... @@ -62,25 +60,31 @@ libraryKeys(k) = strjoin(parts(1:depth), "."); end keys = unique(libraryKeys, "stable"); + appIndices = find(origin == "app"); + owners = string({api(appIndices).owner}); + ownerKeys = unique(owners, "stable"); + template = struct("id", "", "title", "", "description", "", ... + "indices", []); + groups = repmat(template, numel(keys) + numel(ownerKeys), 1); + groupCount = 0; for k = 1:numel(keys) key = keys(k); indices = libraryIndices(libraryKeys == key); - groups(end + 1, 1) = struct( ... + groupCount = groupCount + 1; + groups(groupCount, 1) = struct( ... "id", slug(key), ... "title", libraryTitle(key), ... "description", libraryDescription(key), ... "indices", indices(:).'); end - appIndices = find(origin == "app"); if ~isempty(appIndices) - owners = string({api(appIndices).owner}); - ownerKeys = unique(owners, "stable"); for k = 1:numel(ownerKeys) owner = ownerKeys(k); indices = appIndices(owners == owner); family = string(api(indices(1)).family); - groups(end + 1, 1) = struct( ... + groupCount = groupCount + 1; + groups(groupCount, 1) = struct( ... "id", "app-" + slug(owner), ... "title", humanize(owner) + " App API", ... "description", "Supported GUI-free operations owned by the " + ... @@ -88,6 +92,7 @@ "indices", indices(:).'); end end + groups = groups(1:groupCount); end function title = libraryTitle(key) @@ -158,11 +163,6 @@ end end -function value = cleanSummary(value) - value = string(value); - value = regexprep(value, '^[A-Z][A-Z0-9_]*\s+', ''); -end - function value = humanize(value) value = replace(string(value), "_", " "); words = split(value); diff --git a/tools/docs/private/renderLabKitMarkdown.m b/tools/docs/private/renderLabKitMarkdown.m index 84f2e5e0d..0b615ee2f 100644 --- a/tools/docs/private/renderLabKitMarkdown.m +++ b/tools/docs/private/renderLabKitMarkdown.m @@ -2,63 +2,79 @@ %RENDERLABKITMARKDOWN Render the repository's documented Markdown subset. lines = readlines(page.sourcePath, "EmptyLineRule", "read"); - output = strings(0, 1); - plain = strings(0, 1); + output = strings(2 * numel(lines) + 2, 1); + plain = strings(numel(lines), 1); + outputCount = 0; + plainCount = 0; index = 1; listType = ""; inCode = false; codeLanguage = ""; - codeLines = strings(0, 1); + codeLines = strings(numel(lines), 1); + codeLineCount = 0; while index <= numel(lines) line = lines(index); trimmed = strtrim(line); if inCode if startsWith(trimmed, "```") - output(end + 1, 1) = "
" + ...
-                    htmlEscape(strjoin(codeLines, newline)) + "
"; - plain = [plain; codeLines]; + htmlEscape(strjoin(codeLines(1:codeLineCount), newline)) + ""; + destination = plainCount + (1:codeLineCount); + plain(destination, 1) = codeLines(1:codeLineCount); + plainCount = plainCount + codeLineCount; inCode = false; - codeLines = strings(0, 1); + codeLineCount = 0; else - codeLines(end + 1, 1) = line; + codeLineCount = codeLineCount + 1; + codeLines(codeLineCount, 1) = line; end index = index + 1; continue; end if startsWith(trimmed, "```") - [output, listType] = closeList(output, listType); + [output, outputCount, listType] = ... + closeList(output, outputCount, listType); inCode = true; codeLanguage = strip(extractAfter(trimmed, 3)); index = index + 1; continue; end if strlength(trimmed) == 0 - [output, listType] = closeList(output, listType); + [output, outputCount, listType] = ... + closeList(output, outputCount, listType); index = index + 1; continue; end if startsWith(trimmed, "") - [output, listType] = closeList(output, listType); + [output, outputCount, listType] = ... + closeList(output, outputCount, listType); index = index + 1; continue; end if isTableStart(lines, index) - [output, listType] = closeList(output, listType); + [output, outputCount, listType] = ... + closeList(output, outputCount, listType); [tableHtml, tablePlain, index] = renderTable(model, page, lines, index); - output(end + 1, 1) = tableHtml; - plain(end + 1, 1) = tablePlain; + outputCount = outputCount + 1; + output(outputCount, 1) = tableHtml; + plainCount = plainCount + 1; + plain(plainCount, 1) = tablePlain; continue; end heading = regexp(char(trimmed), '^(#{1,6})\s+(.+)$', 'tokens', 'once'); if ~isempty(heading) - [output, listType] = closeList(output, listType); + [output, outputCount, listType] = ... + closeList(output, outputCount, listType); level = strlength(string(heading{1})); label = string(heading{2}); anchor = headingAnchor(label); - output(end + 1, 1) = "" + ... + outputCount = outputCount + 1; + output(outputCount, 1) = "" + ... renderInline(model, page, label) + ""; - plain(end + 1, 1) = label; + plainCount = plainCount + 1; + plain(plainCount, 1) = label; index = index + 1; continue; end @@ -75,23 +91,30 @@ index = index + 1; [itemText, index] = appendListItemContinuations( ... lines, index, itemText); - [output, listType] = ensureList(output, listType, wanted); - output(end + 1, 1) = "
  • " + ... + [output, outputCount, listType] = ... + ensureList(output, outputCount, listType, wanted); + outputCount = outputCount + 1; + output(outputCount, 1) = "
  • " + ... renderInline(model, page, itemText) + "
  • "; - plain(end + 1, 1) = itemText; + plainCount = plainCount + 1; + plain(plainCount, 1) = itemText; continue; end - [output, listType] = closeList(output, listType); + [output, outputCount, listType] = ... + closeList(output, outputCount, listType); if startsWith(trimmed, ">") content = strip(extractAfter(trimmed, 1)); - output(end + 1, 1) = "
    Function
    "; for k = 1:numel(headers) chunks = chunks + ""; @@ -193,31 +221,36 @@ cells = strip(split(line, "|")).'; end -function [output, listType] = ensureList(output, listType, wanted) +function [output, outputCount, listType] = ... + ensureList(output, outputCount, listType, wanted) if listType == wanted return; end - [output, listType] = closeList(output, listType); - output(end + 1, 1) = "<" + wanted + ">"; + [output, outputCount, ~] = closeList(output, outputCount, listType); + outputCount = outputCount + 1; + output(outputCount, 1) = "<" + wanted + ">"; listType = wanted; end -function [output, listType] = closeList(output, listType) +function [output, outputCount, listType] = ... + closeList(output, outputCount, listType) if strlength(listType) > 0 - output(end + 1, 1) = ""; + outputCount = outputCount + 1; + output(outputCount, 1) = ""; end listType = ""; end function html = renderInline(model, page, text) text = string(text); - replacements = strings(0, 1); + replacements = strings(strlength(text), 1); [text, replacements] = protectTokens(text, replacements, ... '`([^`]+)`', "code", model, page); [text, replacements] = protectTokens(text, replacements, ... '!\[([^\]]*)\]\(([^)]+)\)', "image", model, page); [text, replacements] = protectTokens(text, replacements, ... '\[([^\]]+)\]\(([^)]+)\)', "link", model, page); + replacements = replacements(strlength(replacements) > 0); html = htmlEscape(text); html = regexprep(html, '\*\*([^*]+)\*\*', '$1'); html = regexprep(html, '(?$1'); @@ -231,6 +264,7 @@ function [text, replacements] = protectTokens( ... text, replacements, pattern, tokenType, model, page) + replacementCount = nnz(strlength(replacements) > 0); while true [start, finish, tokens] = regexp(char(text), pattern, ... 'start', 'end', 'tokens', 'once'); @@ -248,8 +282,9 @@ error("LabKit:Docs:UnknownInlineToken", ... "Unknown Markdown inline token type: %s", tokenType); end - replacements(end + 1, 1) = replacement; - marker = tokenMarker(numel(replacements)); + replacementCount = replacementCount + 1; + replacements(replacementCount, 1) = replacement; + marker = tokenMarker(replacementCount); text = extractBefore(text, start) + marker + extractAfter(text, finish); end end @@ -303,17 +338,19 @@ function path = normalizeDocPath(path) parts = split(replace(string(path), "\", "/"), "/"); - kept = strings(0, 1); + kept = strings(numel(parts), 1); + keptCount = 0; for k = 1:numel(parts) if parts(k) == "." || strlength(parts(k)) == 0 continue; - elseif parts(k) == ".." && ~isempty(kept) && kept(end) ~= ".." - kept(end, :) = []; + elseif parts(k) == ".." && keptCount > 0 && kept(keptCount) ~= ".." + keptCount = keptCount - 1; else - kept(end + 1, 1) = parts(k); + keptCount = keptCount + 1; + kept(keptCount, 1) = parts(k); end end - path = strjoin(kept, "/"); + path = strjoin(kept(1:keptCount), "/"); end function anchor = headingAnchor(label) diff --git a/tools/docs/private/renderLabKitPage.m b/tools/docs/private/renderLabKitPage.m index c6957274c..d5572a907 100644 --- a/tools/docs/private/renderLabKitPage.m +++ b/tools/docs/private/renderLabKitPage.m @@ -121,7 +121,8 @@ function html = renderContextAncestors(pages, outputPath, landingTarget) folder = replace(string(fileparts(char(outputPath))), "\\", "/"); parts = split(folder, "/"); - links = strings(0, 1); + links = strings(numel(parts), 1); + linkCount = 0; for k = 1:numel(parts) target = strjoin(parts(1:k), "/") + "/index.html"; if target == outputPath || target == landingTarget @@ -131,11 +132,12 @@ if isempty(index) continue; end - links(end + 1, 1) = "" + ... htmlEscape(pages(index).title) + ""; end - html = strjoin(links, ""); + html = strjoin(links(1:linkCount), ""); end function html = renderSectionLinks(model, outputPath, section, apiItem) @@ -178,11 +180,11 @@ parts = split(symbol, "."); prefix = strjoin(parts(1:end - 1), ".") + "."; members = api(startsWith(string({api.symbol}), prefix)); - links = strings(0, 1); + links = strings(numel(members), 1); for k = 1:numel(members) target = apiOutputPath(members(k)); nameParts = split(string(members(k).symbol), "."); - links(end + 1, 1) = localLink(outputPath, target, nameParts(end)); + links(k, 1) = localLink(outputPath, target, nameParts(end)); end groupTitle = "Package Functions"; if ~isempty(parts) && numel(parts) > 1 @@ -212,7 +214,8 @@ if ~isempty(currentIndex) && numel(pages(currentIndex).nav) > 1 families = pages(currentIndex).nav(2); end - chunks = strings(0, 1); + chunks = strings(numel(families), 1); + chunkCount = 0; for k = 1:numel(families) family = families(k); members = appMembers(arrayfun( ... @@ -225,11 +228,12 @@ if ~isempty(familyPage) && familyPage(1).output ~= outputPath target = string(familyPage(1).output); end - chunks(end + 1, 1) = ... + chunkCount = chunkCount + 1; + chunks(chunkCount, 1) = ... localSubgroup(family, content, outputPath, target); end end - html = strjoin(chunks, ""); + html = strjoin(chunks(1:chunkCount), ""); end function html = renderPageGroups(members, outputPath, defaultGroup, useNavGroup) @@ -252,15 +256,17 @@ groupNames = repmat(string(defaultGroup), numel(members), 1); groups = string(defaultGroup); end - chunks = strings(0, 1); + chunks = strings(numel(groups), 1); + chunkCount = 0; for k = 1:numel(groups) content = renderPageGroupLinks( ... members(groupNames == groups(k)), outputPath, true); if strlength(content) > 0 - chunks(end + 1, 1) = localSubgroup(groups(k), content); + chunkCount = chunkCount + 1; + chunks(chunkCount, 1) = localSubgroup(groups(k), content); end end - html = strjoin(chunks, ""); + html = strjoin(chunks(1:chunkCount), ""); end function html = renderPageGroupLinks(members, outputPath, indentChildren) @@ -268,7 +274,7 @@ members = members(memberOrder); parentKinds = ["overview", "app family"]; hasParent = any(ismember(string({members.kind}), parentKinds)); - links = strings(0, 1); + links = strings(numel(members), 1); for k = 1:numel(members) className = "local-link"; isParent = any(string(members(k).kind) == parentKinds); @@ -277,7 +283,7 @@ elseif isParent className = className + " local-parent-link"; end - links(end + 1, 1) = localLinkWithClass( ... + links(k, 1) = localLinkWithClass( ... outputPath, members(k).output, members(k).title, className); end html = strjoin(links, ""); @@ -309,12 +315,13 @@ function html = renderOnThisPage(body) tokens = regexp(char(body), '

    (.+?)

    ', ... 'tokens'); - links = strings(0, 1); - for k = 1:min(numel(tokens), 24) + linkCount = min(numel(tokens), 24); + links = strings(linkCount, 1); + for k = 1:linkCount anchor = string(tokens{k}{1}); label = regexprep(string(tokens{k}{2}), '<[^>]+>', ''); label = decodeBasicEntities(label); - links(end + 1, 1) = "" + htmlEscape(label) + ""; end html = strjoin(links, ""); diff --git a/tools/docs/private/renderLabKitPageApiLinks.m b/tools/docs/private/renderLabKitPageApiLinks.m index f1744a146..a0b960c5a 100644 --- a/tools/docs/private/renderLabKitPageApiLinks.m +++ b/tools/docs/private/renderLabKitPageApiLinks.m @@ -67,14 +67,18 @@ order = ["Core", "layout", "view", "event", "interaction", ... "plot", "project", "result", "dialog", "diagnostic"]; order = order(ismember(order, groups)); - blocks = strings(0, 1); - words = strings(0, 1); + blocks = strings(numel(order), 1); + wordChunks = cell(numel(order), 1); for k = 1:numel(order) members = items(groups == order(k)); [tableHtml, tableWords] = apiTable(members, outputPath); - blocks(end + 1, 1) = "

    " + ... + blocks(k, 1) = "

    " + ... htmlEscape(displayGroup(order(k))) + "

    " + tableHtml + "
    "; - words = [words; tableWords]; + wordChunks{k} = tableWords; + end + words = strings(0, 1); + if ~isempty(wordChunks) + words = vertcat(wordChunks{:}); end html = "

    " + ... "Complete App SDK API

    Open a function for exact MATLAB syntax, " + ... diff --git a/tools/docs/private/syncLabKitDocTree.m b/tools/docs/private/syncLabKitDocTree.m index 77baad645..37ab85795 100644 --- a/tools/docs/private/syncLabKitDocTree.m +++ b/tools/docs/private/syncLabKitDocTree.m @@ -74,14 +74,17 @@ function removeEmptyNumberedSiblings(root) function removeEmptyFolders(root) entries = dir(fullfile(root, "**", "*")); entries = entries([entries.isdir]); - paths = strings(0, 1); + paths = strings(numel(entries), 1); + pathCount = 0; for k = 1:numel(entries) if any(string(entries(k).name) == [".", ".."]) continue; end - paths(end + 1, 1) = string(fullfile( ... + pathCount = pathCount + 1; + paths(pathCount, 1) = string(fullfile( ... entries(k).folder, entries(k).name)); end + paths = paths(1:pathCount); [~, order] = sort(strlength(paths), "descend"); paths = paths(order); for k = 1:numel(paths) diff --git a/tools/docs/renderLabKitDocs.m b/tools/docs/renderLabKitDocs.m index 2a2d9befc..5cc459ccc 100644 --- a/tools/docs/renderLabKitDocs.m +++ b/tools/docs/renderLabKitDocs.m @@ -6,7 +6,8 @@ % outputRoot - destination for generated HTML and static assets. % Output: % result - struct with pageCount, apiCount, fileCount, and paths. -% Side effects: synchronizes outputRoot with deterministic generated output. +% Side effects: synchronizes outputRoot with deterministic generated output +% and reports stage plus completed/total progress to the console. repoRoot = fileparts(fileparts(fileparts(mfilename("fullpath")))); if nargin < 1 || strlength(string(sourceRoot)) == 0 @@ -18,6 +19,7 @@ sourceRoot = absoluteDocFolder(sourceRoot, false); outputRoot = absoluteDocFolder(outputRoot, true); + reportDocProgress("load sources", 0, 0); model = loadLabKitDocumentation(repoRoot, sourceRoot); stagingRoot = string(tempname); cleanup = onCleanup(@() removeDocFolder(stagingRoot)); @@ -25,6 +27,7 @@ renderedPages = renderNarrativePages(model, stagingRoot); apiPages = renderPublicApiPages(model, stagingRoot); + reportDocProgress("write assets", 0, 0); writeDocText(fullfile(stagingRoot, "assets", "style.css"), ... labKitDocumentationStyle()); writeDocText(fullfile(stagingRoot, "assets", "app.js"), ... @@ -36,6 +39,7 @@ "window.LABKIT_SEARCH_INDEX = " + searchJson + ";"); writeDocText(fullfile(stagingRoot, ".nojekyll"), ""); + reportDocProgress("synchronize output", 0, 0); syncLabKitDocTree(stagingRoot, outputRoot); clear cleanup @@ -46,6 +50,7 @@ "fileCount", sum(~[files.isdir]), ... "sourceRoot", string(sourceRoot), ... "outputRoot", string(outputRoot)); + reportDocProgress("complete", result.fileCount, result.fileCount); end function folder = absoluteDocFolder(folder, createIfMissing) @@ -62,7 +67,9 @@ end function output = renderNarrativePages(model, stagingRoot) - entries = repmat(emptySearchEntry(), 0, 1); + entries = repmat(emptySearchEntry(), numel(model.pages), 1); + total = numel(model.pages); + reportDocProgress("narrative pages", 0, total); for k = 1:numel(model.pages) page = model.pages(k); [body, plainText] = renderLabKitMarkdown(model, page); @@ -81,14 +88,19 @@ html = renderLabKitPage(model, page.title, page.output, ... page.kind, body); writeDocText(fullfile(stagingRoot, page.output), html); - entries(end + 1, 1) = searchEntry(page.title, page.output, ... + entries(k, 1) = searchEntry(page.title, page.output, ... page.kind, page.keywords, plainText); + if mod(k, 25) == 0 || k == total + reportDocProgress("narrative pages", k, total); + end end output = struct("searchEntries", entries); end function output = renderPublicApiPages(model, stagingRoot) - entries = repmat(emptySearchEntry(), 0, 1); + entries = repmat(emptySearchEntry(), numel(model.api), 1); + total = numel(model.api); + reportDocProgress("public API pages", 0, total); for k = 1:numel(model.api) item = model.api(k); outputPath = "reference/api/" + replace(item.symbol, ".", "/") + ".html"; @@ -96,8 +108,11 @@ html = renderLabKitPage(model, item.symbol, outputPath, ... "reference", body); writeDocText(fullfile(stagingRoot, outputPath), html); - entries(end + 1, 1) = searchEntry(item.symbol, outputPath, ... + entries(k, 1) = searchEntry(item.symbol, outputPath, ... "reference", item.symbol, item.summary + " " + item.helpText); + if mod(k, 25) == 0 || k == total + reportDocProgress("public API pages", k, total); + end end output = struct("searchEntries", entries); end @@ -152,3 +167,11 @@ function removeDocFolder(folder) rmdir(folder, "s"); end end + +function reportDocProgress(stage, completed, total) + if total > 0 + fprintf("DOCS [%d/%d] %s\n", completed, total, stage); + else + fprintf("DOCS [stage] %s\n", stage); + end +end diff --git a/tools/profiling/private/profileLabKitPayload.m b/tools/profiling/private/profileLabKitPayload.m index e0e165dfc..8752d0d53 100644 --- a/tools/profiling/private/profileLabKitPayload.m +++ b/tools/profiling/private/profileLabKitPayload.m @@ -83,7 +83,8 @@ metadata = struct(); metadata.Target = char(string(targetLabel)); - metadata.GeneratedAt = datestr(now, 'yyyy-mm-dd HH:MM:SS'); + metadata.GeneratedAt = char(datetime( ... + "now", "Format", "yyyy-MM-dd HH:mm:ss")); metadata.MatlabVersion = version; metadata.MatlabRoot = matlabRootValue; metadata.NumFunctions = numel(functions); @@ -243,13 +244,6 @@ end end -function edges = keepEdges(edges, keepIndices) - if isempty(edges) - return; - end - edges = edges(ismember([edges.Index], keepIndices)); -end - function total = executedLineTime(f) total = 0; if ~isfield(f, 'ExecutedLines') || isempty(f.ExecutedLines) diff --git a/tools/profiling/profileLabKitTarget.m b/tools/profiling/profileLabKitTarget.m index 7632d50f0..bf67fa11f 100644 --- a/tools/profiling/profileLabKitTarget.m +++ b/tools/profiling/profileLabKitTarget.m @@ -4,8 +4,8 @@ % Expected caller: LabKit maintainers diagnosing startup, callbacks, scripts, or % helper-function cost. % Inputs: -% target function name, command string, .m file path, function handle, or -% a profile('info') struct. Empty target opens a file picker. +% target function name, .m file path, function handle, or a +% profile('info') struct. Empty target opens a file picker. % htmlFile optional output HTML file. Defaults under artifacts/profile/. % Outputs: % htmlFile generated HTML report path @@ -69,12 +69,7 @@ targetLabel = targetLabelText(target); [runner, runFolder, cleanupPath, targetFile] = prepareRunner(target); - oldFolder = pwd; - folderCleanup = []; - if opt.ChangeFolder && strlength(string(runFolder)) > 0 && exist(runFolder, 'dir') == 7 - cd(runFolder); - folderCleanup = onCleanup(@() cd(oldFolder)); - end + folderCleanup = enterRunFolder(opt.ChangeFolder, runFolder); fprintf('\n=== profileLabKitTarget ===\n'); fprintf('Target: %s\n', targetLabel); @@ -106,7 +101,7 @@ if opt.CloseFiguresAfterRun closeFigures(newFigures(beforeFigs)); end - clear folderCleanup; + delete(folderCleanup); if isa(cleanupPath, 'function_handle') cleanupPath(); end @@ -187,7 +182,7 @@ end if ~ischar(target) error('profileLabKitTarget:InvalidTarget', ... - 'Target must be a .m path, function name, command string, function handle, or profile info struct.'); + 'Target must be a .m path, function name, function handle, or profile info struct.'); end target = strtrim(target); @@ -204,13 +199,27 @@ cleanupPath = @() rmpath(runFolder); end if isFunctionOrClassFile(filePath) - runner = @() evalin('base', funcName); + runner = @() invokeResolvedFunction(funcName, filePath); else - runner = @() evalin('base', sprintf('run(''%s'')', quoteText(filePath))); + runner = @() run(filePath); end return; end - runner = @() evalin('base', target); + error('profileLabKitTarget:UnresolvedTarget', ... + ['String targets must resolve to one function or .m file. ' ... + 'Use a function handle for calls with arguments or setup state.']); +end + +function invokeResolvedFunction(funcName, filePath) +resolved = string(which(funcName)); +if strlength(resolved) == 0 || ... + string(canonicalPath(resolved)) ~= string(canonicalPath(filePath)) + error('profileLabKitTarget:TargetResolutionChanged', ... + 'Profile target no longer resolves to the validated file: %s', funcName); +end +% Dynamic maintainer-tool boundary: the name is accepted only after it +% resolves to the exact .m file selected by prepareRunner. +feval(funcName); end function filePath = resolveMFile(text) @@ -287,12 +296,15 @@ function figs = newFigures(beforeFigs) afterFigs = currentFigures(); - figs = gobjects(0); + figs = gobjects(size(afterFigs)); + figureCount = 0; for k = 1:numel(afterFigs) if ~any(beforeFigs == afterFigs(k)) - figs(end + 1) = afterFigs(k); + figureCount = figureCount + 1; + figs(figureCount) = afterFigs(k); end end + figs = figs(1:figureCount); try figs = figs(isvalid(figs)); catch @@ -338,7 +350,19 @@ function closeFigures(figs) if isempty(baseName) baseName = 'target'; end - name = fullfile(char(outputRoot), ['profile_' baseName '_' datestr(now, 'yyyymmdd_HHMMSS') '.html']); + stamp = char(datetime("now", "Format", "yyyyMMdd_HHmmss")); + name = fullfile(char(outputRoot), ['profile_' baseName '_' stamp '.html']); +end + +function cleanup = enterRunFolder(changeFolder, runFolder) + if changeFolder && strlength(string(runFolder)) > 0 && ... + exist(runFolder, 'dir') == 7 + oldFolder = pwd; + cd(runFolder); + cleanup = onCleanup(@() cd(oldFolder)); + else + cleanup = onCleanup(@() []); + end end function out = absoluteOutputPath(out) @@ -385,10 +409,6 @@ function closeFigures(figs) [~, ~, ext] = fileparts(filePath); end -function quoted = quoteText(text) - quoted = strrep(char(text), '''', ''''''); -end - function tf = isOnPath(folderName) folderName = canonicalPath(folderName); pathParts = regexp(path, pathsep, 'split'); From 7bb443e0ae07d2d5a6668458d364fd28a3c06278 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Fri, 31 Jul 2026 00:16:45 -0500 Subject: [PATCH 6/6] fix: support codecheck reports on r2022b --- .../tools/codecheck/CodecheckReportSpec.m | 4 ++++ tools/codecheck/runCodecheckReport.m | 23 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/specs/tools/codecheck/CodecheckReportSpec.m b/tests/specs/tools/codecheck/CodecheckReportSpec.m index 49df96bc9..5e20ab0b0 100644 --- a/tests/specs/tools/codecheck/CodecheckReportSpec.m +++ b/tests/specs/tools/codecheck/CodecheckReportSpec.m @@ -22,6 +22,10 @@ function reportIsNativeReadableAndProgressObservable(testCase) testCase.verifyEqual(exist(report.jsonFile, "file"), 2); testCase.verifyEqual(exist(report.htmlFile, "file"), 2); testCase.verifyEqual(report.fileCount, 1); + payload = jsondecode(fileread(report.jsonFile)); + testCase.verifyTrue(all(isfield(payload, [ ... + "Date", "Release", "Files", ... + "Issues", "SuppressedIssues"]))); progress = testfixtures.StateStore.get("codecheckProgress"); testCase.verifySubstring(progress(1), "Finding MATLAB files"); testCase.verifyTrue(any(contains(progress, ... diff --git a/tools/codecheck/runCodecheckReport.m b/tools/codecheck/runCodecheckReport.m index 8627b015d..8b1a8e148 100644 --- a/tools/codecheck/runCodecheckReport.m +++ b/tools/codecheck/runCodecheckReport.m @@ -40,7 +40,7 @@ notifyProgress(progressFcn, "Writing native codeIssues report...", 0.96); ensureFolder(fileparts(output)); sourceRoot = commonSourceRoot([string(root), files]); - export(issues, output, "FileFormat", "json", "SourceRoot", sourceRoot); + exportCodeIssuesJson(issues, output, sourceRoot); notifyProgress(progressFcn, "Writing Code Analyzer HTML report...", 0.98); htmlOutput = reportBase + ".html"; @@ -58,6 +58,27 @@ notifyProgress(progressFcn, "codeIssues report complete.", 1.00); end +function exportCodeIssuesJson(issues, output, sourceRoot) + if ismethod(issues, "export") + export(issues, output, "FileFormat", "json", ... + "SourceRoot", sourceRoot); + return; + end + + % R2022b exposes the same public codeIssues properties but predates the + % codeIssues.export method. jsonencode preserves that native property + % schema for the report reader without resolving an unrelated export + % function from the MATLAB path. + fid = fopen(output, "w", "n", "UTF-8"); + if fid < 0 + error("LabKit:Codecheck:WriteFailed", ... + "Could not write Code Analyzer JSON report: %s", output); + end + cleanup = onCleanup(@() fclose(fid)); + fprintf(fid, "%s\n", jsonencode(issues)); + clear cleanup +end + function reportBase = uniqueReportBase(outputRoot) ensureFolder(outputRoot); stamp = string(datetime("now", "Format", "yyyyMMdd_HHmmss"));

    " + renderInline(model, page, headers(k)) + "