@logictan/dsh-config-manager 0.1.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/cordis.patch.yml +21 -0
- package/lib/adapters/agent-instructions.d.ts +13 -0
- package/lib/adapters/agent-instructions.js +46 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/credentials.d.ts +37 -0
- package/lib/adapters/credentials.js +122 -0
- package/lib/adapters/file-collection.d.ts +15 -0
- package/lib/adapters/file-collection.js +127 -0
- package/lib/adapters/index.d.ts +57 -0
- package/lib/adapters/index.js +55 -0
- package/lib/adapters/link-report.d.ts +16 -0
- package/lib/adapters/link-report.js +47 -0
- package/lib/adapters/mcp.d.ts +28 -0
- package/lib/adapters/mcp.js +178 -0
- package/lib/adapters/plugin-files.d.ts +18 -0
- package/lib/adapters/plugin-files.js +142 -0
- package/lib/adapters/plugins.d.ts +41 -0
- package/lib/adapters/plugins.js +603 -0
- package/lib/adapters/pnpm-workspace.d.ts +26 -0
- package/lib/adapters/pnpm-workspace.js +147 -0
- package/lib/adapters/prompts.d.ts +30 -0
- package/lib/adapters/prompts.js +231 -0
- package/lib/adapters/providers.d.ts +34 -0
- package/lib/adapters/providers.js +155 -0
- package/lib/adapters/self.d.ts +17 -0
- package/lib/adapters/self.js +71 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/settings.d.ts +28 -0
- package/lib/adapters/settings.js +165 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/test-helpers.d.ts +125 -0
- package/lib/adapters/test-helpers.js +236 -0
- package/lib/adapters/ui.d.ts +27 -0
- package/lib/adapters/ui.js +89 -0
- package/lib/adapters/workspaces.d.ts +13 -0
- package/lib/adapters/workspaces.js +106 -0
- package/lib/client.d.ts +1026 -0
- package/lib/client.js +10047 -0
- package/lib/core/analyzer.d.ts +99 -0
- package/lib/core/analyzer.js +934 -0
- package/lib/core/backup.d.ts +138 -0
- package/lib/core/backup.js +513 -0
- package/lib/core/boot-rescue.d.ts +146 -0
- package/lib/core/boot-rescue.js +618 -0
- package/lib/core/cache-cleaner.d.ts +31 -0
- package/lib/core/cache-cleaner.js +95 -0
- package/lib/core/config-lifecycle.d.ts +180 -0
- package/lib/core/config-lifecycle.js +481 -0
- package/lib/core/config-snapshot.d.ts +135 -0
- package/lib/core/config-snapshot.js +424 -0
- package/lib/core/config-state.d.ts +70 -0
- package/lib/core/config-state.js +177 -0
- package/lib/core/consult-source.d.ts +58 -0
- package/lib/core/consult-source.js +260 -0
- package/lib/core/crash-report.d.ts +84 -0
- package/lib/core/crash-report.js +317 -0
- package/lib/core/exporter.d.ts +68 -0
- package/lib/core/exporter.js +375 -0
- package/lib/core/ghost-sweep.d.ts +19 -0
- package/lib/core/ghost-sweep.js +52 -0
- package/lib/core/importer.d.ts +60 -0
- package/lib/core/importer.js +29 -0
- package/lib/core/index.d.ts +14 -0
- package/lib/core/index.js +14 -0
- package/lib/core/journal.d.ts +197 -0
- package/lib/core/journal.js +418 -0
- package/lib/core/local-plugin-host.d.ts +35 -0
- package/lib/core/local-plugin-host.js +120 -0
- package/lib/core/local-plugin-pack.d.ts +135 -0
- package/lib/core/local-plugin-pack.js +278 -0
- package/lib/core/messages.d.ts +310 -0
- package/lib/core/messages.js +621 -0
- package/lib/core/migration-consult.d.ts +117 -0
- package/lib/core/migration-consult.js +363 -0
- package/lib/core/migration-history.d.ts +153 -0
- package/lib/core/migration-history.js +411 -0
- package/lib/core/model-tools.d.ts +50 -0
- package/lib/core/model-tools.js +148 -0
- package/lib/core/msg-types.d.ts +8 -0
- package/lib/core/msg-types.js +6 -0
- package/lib/core/phase3-child-crash.d.ts +1 -0
- package/lib/core/phase3-child-crash.js +87 -0
- package/lib/core/phase3-host.d.ts +138 -0
- package/lib/core/phase3-host.js +359 -0
- package/lib/core/phase3-prod-child.d.ts +1 -0
- package/lib/core/phase3-prod-child.js +35 -0
- package/lib/core/phase4-crash-child.d.ts +1 -0
- package/lib/core/phase4-crash-child.js +103 -0
- package/lib/core/plugin-cli.d.ts +150 -0
- package/lib/core/plugin-cli.js +497 -0
- package/lib/core/reconcile.d.ts +120 -0
- package/lib/core/reconcile.js +389 -0
- package/lib/core/recovery-orchestrator.d.ts +94 -0
- package/lib/core/recovery-orchestrator.js +327 -0
- package/lib/core/restore.d.ts +162 -0
- package/lib/core/restore.js +703 -0
- package/lib/core/rollback.d.ts +12 -0
- package/lib/core/rollback.js +145 -0
- package/lib/core/run-registry.d.ts +85 -0
- package/lib/core/run-registry.js +157 -0
- package/lib/core/startup-barrier.d.ts +53 -0
- package/lib/core/startup-barrier.js +77 -0
- package/lib/core/types.d.ts +515 -0
- package/lib/core/types.js +27 -0
- package/lib/core/undo.d.ts +73 -0
- package/lib/core/undo.js +87 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/verify-recovery.d.ts +21 -0
- package/lib/core/verify-recovery.js +256 -0
- package/lib/core/watcher.d.ts +101 -0
- package/lib/core/watcher.js +219 -0
- package/lib/index.d.ts +238 -0
- package/lib/index.js +2764 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/profile-manager.d.ts +106 -0
- package/lib/profiles/profile-manager.js +543 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +165 -0
- package/lib/schema/index.d.ts +24 -0
- package/lib/schema/index.js +26 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/tombstones.d.ts +46 -0
- package/lib/schema/tombstones.js +64 -0
- package/lib/schema/types.d.ts +232 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/security/encryption.d.ts +85 -0
- package/lib/security/encryption.js +279 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/secret-scanner.d.ts +86 -0
- package/lib/security/secret-scanner.js +404 -0
- package/lib/security/vault.d.ts +59 -0
- package/lib/security/vault.js +151 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/sync/autosync-config.d.ts +39 -0
- package/lib/sync/autosync-config.js +151 -0
- package/lib/sync/autosync-scheduler.d.ts +139 -0
- package/lib/sync/autosync-scheduler.js +564 -0
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +49 -0
- package/lib/sync/git/git-transport.d.ts +100 -0
- package/lib/sync/git/git-transport.js +437 -0
- package/lib/sync/github-auth.d.ts +131 -0
- package/lib/sync/github-auth.js +264 -0
- package/lib/sync/layout.d.ts +43 -0
- package/lib/sync/layout.js +203 -0
- package/lib/sync/snapshot-json.d.ts +9 -0
- package/lib/sync/snapshot-json.js +107 -0
- package/lib/sync/sync-config.d.ts +81 -0
- package/lib/sync/sync-config.js +281 -0
- package/lib/sync/sync-engine.d.ts +269 -0
- package/lib/sync/sync-engine.js +591 -0
- package/lib/sync/sync-history.d.ts +42 -0
- package/lib/sync/sync-history.js +78 -0
- package/lib/sync/sync-selection.d.ts +29 -0
- package/lib/sync/sync-selection.js +127 -0
- package/lib/sync/sync-session.d.ts +40 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-state.d.ts +37 -0
- package/lib/sync/sync-state.js +108 -0
- package/lib/sync/transport.d.ts +75 -0
- package/lib/sync/transport.js +55 -0
- package/lib/sync/ui-prefs.d.ts +34 -0
- package/lib/sync/ui-prefs.js +109 -0
- package/lib/sync/webdav/webdav-transport.d.ts +77 -0
- package/lib/sync/webdav/webdav-transport.js +451 -0
- package/lib/ui/errors.d.ts +19 -0
- package/lib/ui/errors.js +81 -0
- package/lib/ui/export-flow.d.ts +82 -0
- package/lib/ui/export-flow.js +98 -0
- package/lib/ui/i18n.d.ts +289 -0
- package/lib/ui/i18n.js +581 -0
- package/lib/ui/migration-consult-view.d.ts +91 -0
- package/lib/ui/migration-consult-view.js +115 -0
- package/lib/ui/progress.d.ts +38 -0
- package/lib/ui/progress.js +87 -0
- package/lib/ui/report.d.ts +23 -0
- package/lib/ui/report.js +153 -0
- package/lib/ui/test-helpers.d.ts +69 -0
- package/lib/ui/test-helpers.js +154 -0
- package/lib/ui/types.d.ts +257 -0
- package/lib/ui/types.js +21 -0
- package/lib/utils/atomic-write.d.ts +88 -0
- package/lib/utils/atomic-write.js +399 -0
- package/lib/utils/env-lock.d.ts +393 -0
- package/lib/utils/env-lock.js +930 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/paths.d.ts +42 -0
- package/lib/utils/paths.js +189 -0
- package/lib/utils/proxy.d.ts +69 -0
- package/lib/utils/proxy.js +455 -0
- package/lib/utils/recursive-walk.d.ts +24 -0
- package/lib/utils/recursive-walk.js +134 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/package.json +188 -0
- package/src/adapters/agent-instructions.ts +48 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +143 -0
- package/src/adapters/file-collection.ts +137 -0
- package/src/adapters/files.test.ts +353 -0
- package/src/adapters/index.ts +94 -0
- package/src/adapters/link-report.test.ts +65 -0
- package/src/adapters/link-report.ts +61 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +185 -0
- package/src/adapters/plugin-files.ts +146 -0
- package/src/adapters/plugins.test.ts +326 -0
- package/src/adapters/plugins.ts +615 -0
- package/src/adapters/pnpm-workspace.test.ts +87 -0
- package/src/adapters/pnpm-workspace.ts +150 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +230 -0
- package/src/adapters/providers.test.ts +126 -0
- package/src/adapters/providers.ts +173 -0
- package/src/adapters/roundtrip.test.ts +190 -0
- package/src/adapters/self.test.ts +92 -0
- package/src/adapters/self.ts +75 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +100 -0
- package/src/adapters/settings.ts +187 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +248 -0
- package/src/adapters/ui.test.ts +57 -0
- package/src/adapters/ui.ts +108 -0
- package/src/adapters/workspaces.test.ts +106 -0
- package/src/adapters/workspaces.ts +109 -0
- package/src/client/ConfigManagerSection.tsx +90 -0
- package/src/client/api.ts +66 -0
- package/src/client/client-types.ts +18 -0
- package/src/client/common/ErrorBanner.tsx +73 -0
- package/src/client/common/Icon.tsx +129 -0
- package/src/client/common/Modal.tsx +173 -0
- package/src/client/common/ToastViewport.tsx +73 -0
- package/src/client/common/toast-store.test.ts +192 -0
- package/src/client/common/toast-store.ts +181 -0
- package/src/client/common/toast-types.ts +7 -0
- package/src/client/common/ui.tsx +342 -0
- package/src/client/config-manager.module.css +2971 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +56 -0
- package/src/client/lucide-icons.d.ts +38 -0
- package/src/client/run-store.test.ts +223 -0
- package/src/client/run-store.ts +372 -0
- package/src/client/sync/SyncConfirmView.tsx +379 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +220 -0
- package/src/client/sync/SyncSettingsView.tsx +1535 -0
- package/src/client/sync/history-model.test.ts +194 -0
- package/src/client/sync/history-model.ts +195 -0
- package/src/client/sync/sync-api.test.ts +585 -0
- package/src/client/sync/sync-api.ts +543 -0
- package/src/client/sync/sync-locales.ts +416 -0
- package/src/client/sync/sync-push-preview.test.ts +52 -0
- package/src/client/sync/sync-view-v2.test.ts +213 -0
- package/src/client/sync/sync-view.test.ts +487 -0
- package/src/client/sync/sync-view.ts +759 -0
- package/src/core/analyzer.ts +1068 -0
- package/src/core/backup.ts +605 -0
- package/src/core/boot-rescue.test.ts +847 -0
- package/src/core/boot-rescue.ts +737 -0
- package/src/core/cache-cleaner.test.ts +169 -0
- package/src/core/cache-cleaner.ts +121 -0
- package/src/core/config-lifecycle.test.ts +727 -0
- package/src/core/config-lifecycle.ts +573 -0
- package/src/core/config-snapshot.test.ts +369 -0
- package/src/core/config-snapshot.ts +522 -0
- package/src/core/config-state.test.ts +381 -0
- package/src/core/config-state.ts +207 -0
- package/src/core/consult-source.test.ts +199 -0
- package/src/core/consult-source.ts +293 -0
- package/src/core/crash-report.test.ts +380 -0
- package/src/core/crash-report.ts +369 -0
- package/src/core/exporter.ts +417 -0
- package/src/core/ghost-sweep.test.ts +94 -0
- package/src/core/ghost-sweep.ts +67 -0
- package/src/core/importer.ts +83 -0
- package/src/core/index.ts +56 -0
- package/src/core/journal.test.ts +257 -0
- package/src/core/journal.ts +518 -0
- package/src/core/local-plugin-host.ts +152 -0
- package/src/core/local-plugin-pack.test.ts +359 -0
- package/src/core/local-plugin-pack.ts +363 -0
- package/src/core/messages.test.ts +54 -0
- package/src/core/messages.ts +664 -0
- package/src/core/migration-consult.test.ts +315 -0
- package/src/core/migration-consult.ts +494 -0
- package/src/core/migration-history.test.ts +319 -0
- package/src/core/migration-history.ts +484 -0
- package/src/core/model-tools.test.ts +190 -0
- package/src/core/model-tools.ts +208 -0
- package/src/core/msg-types.ts +10 -0
- package/src/core/phase3-child-crash.ts +95 -0
- package/src/core/phase3-host.ts +411 -0
- package/src/core/phase3-p1.test.ts +212 -0
- package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
- package/src/core/phase3-prod-child.ts +36 -0
- package/src/core/phase3-production-integration.test.ts +286 -0
- package/src/core/phase4-crash-child.ts +101 -0
- package/src/core/phase4-crash-injection.test.ts +166 -0
- package/src/core/plugin-cli.fs.test.ts +184 -0
- package/src/core/plugin-cli.test.ts +137 -0
- package/src/core/plugin-cli.ts +563 -0
- package/src/core/reconcile.test.ts +392 -0
- package/src/core/reconcile.ts +498 -0
- package/src/core/recovery-orchestrator.test.ts +145 -0
- package/src/core/recovery-orchestrator.ts +366 -0
- package/src/core/restore-manage.test.ts +94 -0
- package/src/core/restore.ts +849 -0
- package/src/core/rollback.ts +151 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +232 -0
- package/src/core/run-registry.ts +209 -0
- package/src/core/smoke.test.ts +842 -0
- package/src/core/startup-barrier.ts +99 -0
- package/src/core/types.ts +542 -0
- package/src/core/undo.ts +116 -0
- package/src/core/validator.ts +84 -0
- package/src/core/verify-recovery.ts +265 -0
- package/src/core/watcher.test.ts +326 -0
- package/src/core/watcher.ts +263 -0
- package/src/index.facade.test.ts +265 -0
- package/src/index.sync.test.ts +217 -0
- package/src/index.ts +2975 -0
- package/src/migrations/index.ts +71 -0
- package/src/migrations/v1-to-v2.ts +17 -0
- package/src/profiles/index.ts +8 -0
- package/src/profiles/profile-manager.test.ts +282 -0
- package/src/profiles/profile-manager.ts +630 -0
- package/src/schema/config.ts +165 -0
- package/src/schema/index.ts +71 -0
- package/src/schema/manifest-schema.test.ts +519 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/tombstones.test.ts +131 -0
- package/src/schema/tombstones.ts +89 -0
- package/src/schema/types.ts +245 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +335 -0
- package/src/security/index.ts +16 -0
- package/src/security/integrity.ts +102 -0
- package/src/security/redaction.ts +101 -0
- package/src/security/secret-scanner.ts +445 -0
- package/src/security/security.test.ts +1215 -0
- package/src/security/vault.test.ts +164 -0
- package/src/security/vault.ts +198 -0
- package/src/security/zip-security.ts +180 -0
- package/src/sync/autosync-config.test.ts +147 -0
- package/src/sync/autosync-config.ts +179 -0
- package/src/sync/autosync-scheduler.test.ts +490 -0
- package/src/sync/autosync-scheduler.ts +646 -0
- package/src/sync/fs.ts +48 -0
- package/src/sync/git/git-transport.test.ts +673 -0
- package/src/sync/git/git-transport.ts +491 -0
- package/src/sync/github-auth.test.ts +280 -0
- package/src/sync/github-auth.ts +356 -0
- package/src/sync/layout.test.ts +270 -0
- package/src/sync/layout.ts +222 -0
- package/src/sync/snapshot-json.test.ts +119 -0
- package/src/sync/snapshot-json.ts +128 -0
- package/src/sync/sync-config.test.ts +310 -0
- package/src/sync/sync-config.ts +299 -0
- package/src/sync/sync-engine.test.ts +1074 -0
- package/src/sync/sync-engine.ts +754 -0
- package/src/sync/sync-history.test.ts +116 -0
- package/src/sync/sync-history.ts +120 -0
- package/src/sync/sync-selection.test.ts +186 -0
- package/src/sync/sync-selection.ts +143 -0
- package/src/sync/sync-session.test.ts +143 -0
- package/src/sync/sync-session.ts +77 -0
- package/src/sync/sync-state.test.ts +164 -0
- package/src/sync/sync-state.ts +131 -0
- package/src/sync/transport.test.ts +111 -0
- package/src/sync/transport.ts +118 -0
- package/src/sync/ui-prefs.test.ts +152 -0
- package/src/sync/ui-prefs.ts +135 -0
- package/src/sync/webdav/webdav-transport.test.ts +871 -0
- package/src/sync/webdav/webdav-transport.ts +535 -0
- package/src/ui/errors.test.ts +47 -0
- package/src/ui/errors.ts +100 -0
- package/src/ui/export-flow.test.ts +87 -0
- package/src/ui/export-flow.ts +156 -0
- package/src/ui/i18n.ts +590 -0
- package/src/ui/migration-consult-view.test.ts +151 -0
- package/src/ui/migration-consult-view.ts +178 -0
- package/src/ui/progress.test.ts +47 -0
- package/src/ui/progress.ts +102 -0
- package/src/ui/report.test.ts +101 -0
- package/src/ui/report.ts +146 -0
- package/src/ui/test-helpers.ts +184 -0
- package/src/ui/types.ts +325 -0
- package/src/utils/atomic-write.test.ts +312 -0
- package/src/utils/atomic-write.ts +422 -0
- package/src/utils/env-lock.test.ts +999 -0
- package/src/utils/env-lock.ts +1086 -0
- package/src/utils/hashing.ts +53 -0
- package/src/utils/json.ts +77 -0
- package/src/utils/logger.ts +114 -0
- package/src/utils/paths.ts +176 -0
- package/src/utils/proxy.test.ts +465 -0
- package/src/utils/proxy.ts +493 -0
- package/src/utils/recursive-walk.test.ts +148 -0
- package/src/utils/recursive-walk.ts +145 -0
- package/src/utils/zip.ts +332 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 1 地基回归:配置状态采集(config-state.ts)+ 撤销/重做规划(undo.ts)。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖用户可见契约:
|
|
5
|
+
* - 状态指纹对同一逻辑内容稳定(对象键顺序不影响),内容变化必然改变指纹;
|
|
6
|
+
* - 文件类分区的字节变化必须被识别(字节在 data.files[].data 里,不在 files 数组);
|
|
7
|
+
* - 单分区采集失败只跳过该分区,不使整次采集失败;
|
|
8
|
+
* - 撤销 = 回退到与当前状态**内容不同**的最新快照(跳过等于当前状态的);
|
|
9
|
+
* - 全部相同 → 明确返回「没有可撤销的变化」,不做空操作;
|
|
10
|
+
* - 撤销后一旦产生更新快照,重做即失效(superseded-by-newer-change);
|
|
11
|
+
* - pre-restore 永不作为撤销目标。
|
|
12
|
+
*/
|
|
13
|
+
import test from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
captureConfigState, diffStates, hashExportSection, stableStringify, stateFromExports, statesEqual, stateSections,
|
|
18
|
+
type ConfigState,
|
|
19
|
+
} from './config-state.ts';
|
|
20
|
+
import {
|
|
21
|
+
canRedo, canUndo, planRedo, planUndo, sortNewestFirst, steppedIds,
|
|
22
|
+
type UndoCandidate,
|
|
23
|
+
} from './undo.ts';
|
|
24
|
+
import { makeContext } from '../adapters/test-helpers.ts';
|
|
25
|
+
import type { ConfigAdapter, ExportSection, HostContext } from './types.ts';
|
|
26
|
+
import type { SectionId } from '../schema/types.ts';
|
|
27
|
+
|
|
28
|
+
/* ------------------------------------------------------------ 测试替身 */
|
|
29
|
+
|
|
30
|
+
/** 可编程的最小 adapter:export 返回预置数据,可选抛错。 */
|
|
31
|
+
function fakeAdapter(
|
|
32
|
+
id: SectionId,
|
|
33
|
+
exported: ExportSection,
|
|
34
|
+
opts: { failExport?: boolean } = {},
|
|
35
|
+
): ConfigAdapter {
|
|
36
|
+
return {
|
|
37
|
+
id,
|
|
38
|
+
displayName: id,
|
|
39
|
+
defaultIncluded: true,
|
|
40
|
+
portability: 'portable',
|
|
41
|
+
async export(): Promise<ExportSection> {
|
|
42
|
+
if (opts.failExport === true) throw new Error(`boom:${id}`);
|
|
43
|
+
return exported;
|
|
44
|
+
},
|
|
45
|
+
async analyzeImport() { return []; },
|
|
46
|
+
async applyItem() { return { ok: true }; },
|
|
47
|
+
async validate() { return { valid: true, issues: [] }; },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function section(id: SectionId, data: unknown, files?: { relativePath: string; data: Uint8Array }[]): ExportSection {
|
|
52
|
+
return {
|
|
53
|
+
sectionId: id,
|
|
54
|
+
data,
|
|
55
|
+
...(files !== undefined ? { files } : {}),
|
|
56
|
+
counts: { n: 1 },
|
|
57
|
+
warnings: [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ctx(): HostContext {
|
|
62
|
+
return makeContext('win32', 'C:/home/.dsh');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stateOf(sections: { section: SectionId; hash: string }[], capturedAt = '2026-09-13T00:00:00.000Z'): ConfigState {
|
|
66
|
+
return { capturedAt, sections: sections.map((s) => ({ ...s, counts: {}, fileCount: 0 })) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ------------------------------------------------------------ stableStringify */
|
|
70
|
+
|
|
71
|
+
test('stableStringify:对象键顺序不影响结果', () => {
|
|
72
|
+
assert.equal(stableStringify({ a: 1, b: 2 }), stableStringify({ b: 2, a: 1 }));
|
|
73
|
+
assert.equal(stableStringify({ x: { p: 1, q: 2 } }), stableStringify({ x: { q: 2, p: 1 } }));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('stableStringify:数组顺序影响结果(顺序是语义)', () => {
|
|
77
|
+
assert.notEqual(stableStringify([1, 2]), stableStringify([2, 1]));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('stableStringify:undefined 与函数键被省略,循环引用不炸', () => {
|
|
81
|
+
assert.equal(stableStringify({ a: 1, b: undefined, c: () => 1 }), '{"a":1}');
|
|
82
|
+
const cyclic: Record<string, unknown> = { a: 1 };
|
|
83
|
+
cyclic.self = cyclic;
|
|
84
|
+
assert.match(stableStringify(cyclic), /"<cycle>"/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('stableStringify:非有限数字归一为 null,Uint8Array 按内容指纹计入', () => {
|
|
88
|
+
assert.equal(stableStringify({ a: Number.NaN, b: Infinity }), '{"a":null,"b":null}');
|
|
89
|
+
const same = stableStringify({ d: new Uint8Array([1, 2, 3]) });
|
|
90
|
+
const sameAgain = stableStringify({ d: new Uint8Array([1, 2, 3]) });
|
|
91
|
+
const other = stableStringify({ d: new Uint8Array([1, 2, 4]) });
|
|
92
|
+
assert.equal(same, sameAgain);
|
|
93
|
+
assert.notEqual(same, other);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/* ------------------------------------------------------------ hashExportSection */
|
|
97
|
+
|
|
98
|
+
test('hashExportSection:同一逻辑内容稳定,data 变化必然改变指纹', () => {
|
|
99
|
+
const a = hashExportSection(section('settings', { alpha: 1, beta: 2 }));
|
|
100
|
+
const b = hashExportSection(section('settings', { beta: 2, alpha: 1 }));
|
|
101
|
+
const c = hashExportSection(section('settings', { alpha: 1, beta: 3 }));
|
|
102
|
+
assert.equal(a, b, '键顺序不同但内容相同 → 指纹必须一致');
|
|
103
|
+
assert.notEqual(a, c);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('hashExportSection:文件数组顺序不影响,但文件内容变化必须被识别', () => {
|
|
107
|
+
const f1 = { relativePath: 'a.md', data: new Uint8Array([1]) };
|
|
108
|
+
const f2 = { relativePath: 'b.md', data: new Uint8Array([2]) };
|
|
109
|
+
const ordered = hashExportSection(section('skills', { version: 1 }, [f1, f2]));
|
|
110
|
+
const reversed = hashExportSection(section('skills', { version: 1 }, [f2, f1]));
|
|
111
|
+
assert.equal(ordered, reversed, '文件顺序不同 → 指纹一致');
|
|
112
|
+
const changed = hashExportSection(section('skills', { version: 1 }, [f1, { relativePath: 'b.md', data: new Uint8Array([9]) }]));
|
|
113
|
+
assert.notEqual(ordered, changed);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('hashExportSection:文件类分区字节在 data.files[].data 里时也必须被识别(真实 adapter 形态)', () => {
|
|
117
|
+
// 真实 FileCollectionAdapter 把字节放进 data.files[].data,files 字段为 undefined。
|
|
118
|
+
const before = hashExportSection(section('skills', {
|
|
119
|
+
version: 1,
|
|
120
|
+
files: [{ relativePath: 'x.md', contentHash: 'h', data: new Uint8Array([1, 2, 3]) }],
|
|
121
|
+
}));
|
|
122
|
+
const after = hashExportSection(section('skills', {
|
|
123
|
+
version: 1,
|
|
124
|
+
files: [{ relativePath: 'x.md', contentHash: 'h', data: new Uint8Array([1, 2, 4]) }],
|
|
125
|
+
}));
|
|
126
|
+
assert.notEqual(before, after, '内嵌字节变化必须改变分区指纹');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/* ------------------------------------------------------------ captureConfigState */
|
|
130
|
+
|
|
131
|
+
test('captureConfigState:分区按 id 升序输出,便于跨次比较', async () => {
|
|
132
|
+
const adapters = [
|
|
133
|
+
fakeAdapter('ui', section('ui', { v: 1 })),
|
|
134
|
+
fakeAdapter('settings', section('settings', { v: 1 })),
|
|
135
|
+
];
|
|
136
|
+
const state = await captureConfigState(adapters, ctx());
|
|
137
|
+
assert.deepEqual(state.sections.map((s) => s.section), ['settings', 'ui']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('captureConfigState:单分区导出失败只跳过该分区,不使整次采集失败', async () => {
|
|
141
|
+
const errors: { section: SectionId; error: unknown }[] = [];
|
|
142
|
+
const adapters = [
|
|
143
|
+
fakeAdapter('settings', section('settings', { v: 1 })),
|
|
144
|
+
fakeAdapter('ui', section('ui', { v: 1 }), { failExport: true }),
|
|
145
|
+
];
|
|
146
|
+
const state = await captureConfigState(adapters, ctx(), {
|
|
147
|
+
onSectionError: (s, e) => errors.push({ section: s, error: e }),
|
|
148
|
+
});
|
|
149
|
+
assert.deepEqual(state.sections.map((s) => s.section), ['settings']);
|
|
150
|
+
assert.equal(errors.length, 1);
|
|
151
|
+
assert.equal(errors[0]!.section, 'ui');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('captureConfigState:only 过滤生效', async () => {
|
|
155
|
+
const adapters = [
|
|
156
|
+
fakeAdapter('settings', section('settings', { v: 1 })),
|
|
157
|
+
fakeAdapter('ui', section('ui', { v: 1 })),
|
|
158
|
+
];
|
|
159
|
+
const state = await captureConfigState(adapters, ctx(), { only: ['ui'] });
|
|
160
|
+
assert.deepEqual(state.sections.map((s) => s.section), ['ui']);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/* ------------------------------------------------------------ 相等与差异 */
|
|
164
|
+
|
|
165
|
+
test('statesEqual:null 与任何非 null 不等;同 null 相等', () => {
|
|
166
|
+
assert.equal(statesEqual(null, null), true);
|
|
167
|
+
assert.equal(statesEqual(null, stateOf([{ section: 'settings', hash: 'a' }])), false);
|
|
168
|
+
assert.equal(statesEqual(stateOf([{ section: 'settings', hash: 'a' }]), null), false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('statesEqual:分区集合或任一 hash 不同即不等', () => {
|
|
172
|
+
const a = stateOf([{ section: 'settings', hash: 'a' }, { section: 'ui', hash: 'b' }]);
|
|
173
|
+
assert.equal(statesEqual(a, stateOf([{ section: 'ui', hash: 'b' }, { section: 'settings', hash: 'a' }])), true);
|
|
174
|
+
assert.equal(statesEqual(a, stateOf([{ section: 'settings', hash: 'a' }, { section: 'ui', hash: 'X' }])), false);
|
|
175
|
+
assert.equal(statesEqual(a, stateOf([{ section: 'settings', hash: 'a' }])), false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('stateSections:null 得到空 Map', () => {
|
|
179
|
+
assert.equal(stateSections(null).size, 0);
|
|
180
|
+
assert.equal(stateSections(stateOf([{ section: 'ui', hash: 'a' }])).get('ui')?.hash, 'a');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/*
|
|
184
|
+
* 回归:statesEqual 必须是**全函数**。
|
|
185
|
+
* meta.state 直接来自磁盘(可能被手改 / 截断 / 版本不符而缺字段),一旦它抛 TypeError,
|
|
186
|
+
* status() 与 undo()/redo() 会整体 500,用户连快照列表都看不见。
|
|
187
|
+
*/
|
|
188
|
+
test('statesEqual:state 为 undefined / 缺 sections 时不抛(磁盘损坏的 meta 不得炸掉调用方)', () => {
|
|
189
|
+
const good = stateOf([{ section: 'settings', hash: 'a' }]);
|
|
190
|
+
const broken = { capturedAt: '2026-01-01T00:00:00.000Z' } as unknown as ConfigState;
|
|
191
|
+
|
|
192
|
+
assert.equal(statesEqual(good, undefined as unknown as ConfigState), false);
|
|
193
|
+
assert.equal(statesEqual(undefined as unknown as ConfigState, good), false);
|
|
194
|
+
assert.equal(statesEqual(good, broken), false, '缺 sections → 与任何可用状态都不等(绝不当成「相同」)');
|
|
195
|
+
assert.equal(statesEqual(broken, broken), true, '同一损坏对象的引用相等仍然成立(保守)');
|
|
196
|
+
assert.equal(statesEqual(broken, { ...broken }), false, '两个不同的损坏对象不得判为相等');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('statesEqual:数组长度不同必不等(含单侧多分区)', () => {
|
|
200
|
+
const wide = stateOf([{ section: 'settings', hash: 'a' }, { section: 'ui', hash: 'b' }]);
|
|
201
|
+
const narrow = stateOf([{ section: 'settings', hash: 'a' }]);
|
|
202
|
+
assert.equal(statesEqual(wide, narrow), false);
|
|
203
|
+
assert.equal(statesEqual(narrow, wide), false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('stateSections:state 缺 sections 时返回空 Map(不抛)', () => {
|
|
207
|
+
assert.equal(stateSections({ capturedAt: 'x' } as unknown as ConfigState).size, 0);
|
|
208
|
+
assert.equal(stateSections(undefined as unknown as ConfigState).size, 0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/*
|
|
212
|
+
* stateFromExports:与 captureConfigState 必须口径一致(同一份导出 → 同一指纹)。
|
|
213
|
+
* 快照路径直接复用它,若两条路径算法漂移,「当前状态」与「快照状态」就没法比较了。
|
|
214
|
+
*/
|
|
215
|
+
test('stateFromExports:与 captureConfigState 对同一份导出得到同一状态', async () => {
|
|
216
|
+
const exported = section('settings', { v: 1 });
|
|
217
|
+
const map = new Map<SectionId, ExportSection>([['settings', exported]]);
|
|
218
|
+
const direct = stateFromExports(map);
|
|
219
|
+
|
|
220
|
+
const adapters: ConfigAdapter[] = [fakeAdapter('settings', exported)];
|
|
221
|
+
const captured = await captureConfigState(adapters, ctx());
|
|
222
|
+
|
|
223
|
+
assert.deepEqual(direct.sections, captured.sections, '两条路径的分区口径必须完全一致');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('stateFromExports:按 section 升序输出(不依赖 Map 插入顺序)', () => {
|
|
227
|
+
const map = new Map<SectionId, ExportSection>([
|
|
228
|
+
['ui', section('ui', { b: 1 })],
|
|
229
|
+
['settings', section('settings', { a: 1 })],
|
|
230
|
+
]);
|
|
231
|
+
assert.deepEqual(stateFromExports(map).sections.map((s) => s.section), ['settings', 'ui']);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('diffStates:区分 changed / added / removed,且顺序确定', () => {
|
|
235
|
+
const before = stateOf([{ section: 'settings', hash: 'a' }, { section: 'ui', hash: 'b' }]);
|
|
236
|
+
const after = stateOf([{ section: 'settings', hash: 'A' }, { section: 'providers', hash: 'c' }]);
|
|
237
|
+
const diff = diffStates(before, after);
|
|
238
|
+
assert.deepEqual(diff.changed, ['settings']);
|
|
239
|
+
assert.deepEqual(diff.added, ['providers']);
|
|
240
|
+
assert.deepEqual(diff.removed, ['ui']);
|
|
241
|
+
assert.equal(diff.identical, false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('diffStates:无差异时 identical=true 且三个列表皆空', () => {
|
|
245
|
+
const s = stateOf([{ section: 'settings', hash: 'a' }]);
|
|
246
|
+
const diff = diffStates(s, stateOf([{ section: 'settings', hash: 'a' }]));
|
|
247
|
+
assert.deepEqual(diff.changed, []);
|
|
248
|
+
assert.deepEqual(diff.added, []);
|
|
249
|
+
assert.deepEqual(diff.removed, []);
|
|
250
|
+
assert.equal(diff.identical, true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/* ------------------------------------------------------------ 撤销规划 */
|
|
254
|
+
|
|
255
|
+
test('planUndo:跳过「等于当前状态」的更新快照,命中第一个内容不同的快照', () => {
|
|
256
|
+
const current = stateOf([{ section: 'settings', hash: 'CURRENT' }]);
|
|
257
|
+
const snapshots: UndoCandidate[] = [
|
|
258
|
+
{ id: 's3', createdAt: '2026-09-13T03:00:00.000Z', kind: 'auto', state: stateOf([{ section: 'settings', hash: 'CURRENT' }]) },
|
|
259
|
+
{ id: 's2', createdAt: '2026-09-13T02:00:00.000Z', kind: 'auto', state: stateOf([{ section: 'settings', hash: 'CURRENT' }]) },
|
|
260
|
+
{ id: 's1', createdAt: '2026-09-13T01:00:00.000Z', kind: 'auto', state: stateOf([{ section: 'settings', hash: 'OLD' }]) },
|
|
261
|
+
];
|
|
262
|
+
const plan = planUndo(current, snapshots);
|
|
263
|
+
assert.equal(plan.kind, 'undo');
|
|
264
|
+
if (plan.kind !== 'undo') return;
|
|
265
|
+
assert.equal(plan.targetId, 's1');
|
|
266
|
+
assert.equal(plan.skippedNewer, 2);
|
|
267
|
+
assert.equal(plan.reason, 'content-differs');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('planUndo:全部与当前状态相同 → 明确报告没有可撤销的变化(不做空操作)', () => {
|
|
271
|
+
const current = stateOf([{ section: 'settings', hash: 'SAME' }]);
|
|
272
|
+
const snapshots: UndoCandidate[] = [
|
|
273
|
+
{ id: 's1', createdAt: '2026-09-13T01:00:00.000Z', kind: 'auto', state: stateOf([{ section: 'settings', hash: 'SAME' }]) },
|
|
274
|
+
];
|
|
275
|
+
const plan = planUndo(current, snapshots);
|
|
276
|
+
assert.equal(plan.kind, 'none');
|
|
277
|
+
if (plan.kind !== 'none') return;
|
|
278
|
+
assert.equal(plan.reason, 'already-at-state');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('planUndo:无可用候选 → no-snapshots(旧格式无 state 的快照不可比较)', () => {
|
|
282
|
+
const current = stateOf([{ section: 'settings', hash: 'X' }]);
|
|
283
|
+
const plan = planUndo(current, [
|
|
284
|
+
{ id: 'legacy', createdAt: '2026-09-13T01:00:00.000Z', kind: 'auto' },
|
|
285
|
+
{ id: 'pre', createdAt: '2026-09-13T02:00:00.000Z', kind: 'pre-restore', state: current },
|
|
286
|
+
]);
|
|
287
|
+
assert.equal(plan.kind, 'none');
|
|
288
|
+
if (plan.kind !== 'none') return;
|
|
289
|
+
assert.equal(plan.reason, 'no-snapshots');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('planUndo:pre-restore 永不作为撤销目标(否则撤销会撤销掉自己)', () => {
|
|
293
|
+
const current = stateOf([{ section: 'settings', hash: 'NEW' }]);
|
|
294
|
+
const old = stateOf([{ section: 'settings', hash: 'OLD' }]);
|
|
295
|
+
const plan = planUndo(current, [
|
|
296
|
+
{ id: 'pre1', createdAt: '2026-09-13T09:00:00.000Z', kind: 'pre-restore', state: old },
|
|
297
|
+
{ id: 'auto1', createdAt: '2026-09-13T08:00:00.000Z', kind: 'auto', state: old },
|
|
298
|
+
]);
|
|
299
|
+
assert.equal(plan.kind, 'undo');
|
|
300
|
+
if (plan.kind !== 'undo') return;
|
|
301
|
+
assert.equal(plan.targetId, 'auto1');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('canUndo:与 planUndo 语义一致', () => {
|
|
305
|
+
const current = stateOf([{ section: 'settings', hash: 'C' }]);
|
|
306
|
+
assert.equal(canUndo(current, [{ id: 'a', createdAt: 't', kind: 'auto', state: current }]), false);
|
|
307
|
+
assert.equal(canUndo(current, [{ id: 'a', createdAt: 't', kind: 'auto', state: stateOf([{ section: 'settings', hash: 'D' }]) }]), true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
/* ------------------------------------------------------------ 重做规划 */
|
|
311
|
+
|
|
312
|
+
test('planRedo:取最新的未消费 pre-restore', () => {
|
|
313
|
+
const snapshots: UndoCandidate[] = [
|
|
314
|
+
{ id: 'pre2', createdAt: '2026-09-13T03:00:00.000Z', kind: 'pre-restore' },
|
|
315
|
+
{ id: 'auto1', createdAt: '2026-09-13T01:00:00.000Z', kind: 'auto' },
|
|
316
|
+
];
|
|
317
|
+
const plan = planRedo(snapshots);
|
|
318
|
+
assert.equal(plan.kind, 'redo');
|
|
319
|
+
if (plan.kind !== 'redo') return;
|
|
320
|
+
assert.equal(plan.targetId, 'pre2');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('planRedo:无未消费 pre-restore → no-pre-restore', () => {
|
|
324
|
+
assert.equal(planRedo([]).kind, 'none');
|
|
325
|
+
const consumed = planRedo([{ id: 'pre1', createdAt: '2026-09-13T03:00:00.000Z', kind: 'pre-restore', consumed: true }]);
|
|
326
|
+
assert.equal(consumed.kind, 'none');
|
|
327
|
+
if (consumed.kind !== 'none') return;
|
|
328
|
+
assert.equal(consumed.reason, 'no-pre-restore');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('planRedo:撤销后又产生了新快照 → 重做失效(不被旧状态覆盖新改动)', () => {
|
|
332
|
+
const snapshots: UndoCandidate[] = [
|
|
333
|
+
{ id: 'newer', createdAt: '2026-09-13T05:00:00.000Z', kind: 'auto' },
|
|
334
|
+
{ id: 'pre1', createdAt: '2026-09-13T04:00:00.000Z', kind: 'pre-restore' },
|
|
335
|
+
{ id: 'auto1', createdAt: '2026-09-13T01:00:00.000Z', kind: 'auto' },
|
|
336
|
+
];
|
|
337
|
+
const plan = planRedo(snapshots);
|
|
338
|
+
assert.equal(plan.kind, 'none');
|
|
339
|
+
if (plan.kind !== 'none') return;
|
|
340
|
+
assert.equal(plan.reason, 'superseded-by-newer-change');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('planRedo:仅存在更旧的快照不构成 supersede(仍可重做)', () => {
|
|
344
|
+
const snapshots: UndoCandidate[] = [
|
|
345
|
+
{ id: 'pre1', createdAt: '2026-09-13T04:00:00.000Z', kind: 'pre-restore' },
|
|
346
|
+
{ id: 'auto1', createdAt: '2026-09-13T01:00:00.000Z', kind: 'auto' },
|
|
347
|
+
];
|
|
348
|
+
assert.equal(planRedo(snapshots).kind, 'redo');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('canRedo:与 planRedo 语义一致', () => {
|
|
352
|
+
assert.equal(canRedo([]), false);
|
|
353
|
+
assert.equal(canRedo([{ id: 'p', createdAt: '2026-09-13T01:00:00.000Z', kind: 'pre-restore' }]), true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
/* ------------------------------------------------------------ stepped / 排序 */
|
|
357
|
+
|
|
358
|
+
test('steppedIds:标记比目标更新、且内容等于撤销前状态的快照', () => {
|
|
359
|
+
const beforeState = stateOf([{ section: 'settings', hash: 'BEFORE' }]);
|
|
360
|
+
const otherState = stateOf([{ section: 'settings', hash: 'OTHER' }]);
|
|
361
|
+
const snapshots: UndoCandidate[] = [
|
|
362
|
+
{ id: 'target', createdAt: '2026-09-13T01:00:00.000Z', kind: 'auto', state: beforeState },
|
|
363
|
+
{ id: 'after1', createdAt: '2026-09-13T02:00:00.000Z', kind: 'auto', state: beforeState },
|
|
364
|
+
{ id: 'after2', createdAt: '2026-09-13T03:00:00.000Z', kind: 'auto', state: otherState },
|
|
365
|
+
{ id: 'pre', createdAt: '2026-09-13T04:00:00.000Z', kind: 'pre-restore', state: beforeState },
|
|
366
|
+
];
|
|
367
|
+
assert.deepEqual(steppedIds(snapshots, 'target', beforeState), ['after1']);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('steppedIds:目标不存在 → 空数组', () => {
|
|
371
|
+
assert.deepEqual(steppedIds([], 'nope', stateOf([])), []);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('sortNewestFirst:按 createdAt 倒序,同刻按 id 稳定', () => {
|
|
375
|
+
const sorted = sortNewestFirst([
|
|
376
|
+
{ id: 'b', createdAt: '2026-09-13T01:00:00.000Z' },
|
|
377
|
+
{ id: 'a', createdAt: '2026-09-13T02:00:00.000Z' },
|
|
378
|
+
{ id: 'c', createdAt: '2026-09-13T02:00:00.000Z' },
|
|
379
|
+
]);
|
|
380
|
+
assert.deepEqual(sorted.map((s) => s.id), ['c', 'a', 'b']);
|
|
381
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置状态向量(Phase 1 地基:P0-1 自动快照 / P0-2 撤销重做 共用)。
|
|
3
|
+
*
|
|
4
|
+
* 问题:既有 `Snapshot` 是**导入计划驱动**的(只登记本次导入将写入的目标),
|
|
5
|
+
* 因此它无法回答「相对于某个时刻,配置整体有没有变」「变的是哪个分区」。
|
|
6
|
+
* 撤销/重做与自动快照需要的正是后者。
|
|
7
|
+
*
|
|
8
|
+
* 本模块把「当前 DSH 配置」压成一个**可分区的稳定指纹集合**(ConfigState):
|
|
9
|
+
* - 每个分区经 `adapter.export(ctx, { includeSecrets: false })` 读取;
|
|
10
|
+
* - 对分区内容做**稳定序列化**后取 sha256(对象键排序,跨进程/跨机器一致);
|
|
11
|
+
* - 文件类分区的文件字节按 sha256 计入,不把二进制塞进哈希输入。
|
|
12
|
+
*
|
|
13
|
+
* 边界:纯计算 + 只读 adapter,零写入;不持久化(持久化见 config-snapshot.ts)。
|
|
14
|
+
* 采集失败的分区**跳过并回调**,绝不因单分区异常而使整次采集失败——否则一个坏
|
|
15
|
+
* adapter 会让自动快照静默停摆。
|
|
16
|
+
*/
|
|
17
|
+
import { sha256Hex } from '../utils/hashing.ts';
|
|
18
|
+
import type { SectionId } from '../schema/types.ts';
|
|
19
|
+
import type { ConfigAdapter, ExportSection, HostContext } from './types.ts';
|
|
20
|
+
|
|
21
|
+
/** 单分区状态:内容指纹 + 计数(counts 仅展示,不参与相等性判定) */
|
|
22
|
+
export interface SectionState {
|
|
23
|
+
section: SectionId;
|
|
24
|
+
/** 分区内容的稳定 sha256(hex) */
|
|
25
|
+
hash: string;
|
|
26
|
+
/** adapter 自报计数(展示用;不参与相等判定) */
|
|
27
|
+
counts: Record<string, number>;
|
|
28
|
+
/** 文件类分区的文件条数(非文件分区为 0) */
|
|
29
|
+
fileCount: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 一次配置状态采集结果 */
|
|
33
|
+
export interface ConfigState {
|
|
34
|
+
/** ISO 时间戳 */
|
|
35
|
+
capturedAt: string;
|
|
36
|
+
/** 按 section 名升序,保证 stateSections/diff 的确定性 */
|
|
37
|
+
sections: SectionState[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 稳定序列化:对象键**排序**后输出,保证同一逻辑内容得到同一字符串。
|
|
42
|
+
* 处理 undefined / 函数 → 省略(与 JSON 语义一致);Date → ISO;Uint8Array → base64 标记。
|
|
43
|
+
* 不追求通用性:只服务本模块的哈希输入(adapter 导出数据 = JSON 安全结构)。
|
|
44
|
+
*/
|
|
45
|
+
export function stableStringify(value: unknown): string {
|
|
46
|
+
const seen = new WeakSet<object>();
|
|
47
|
+
const walk = (v: unknown): string => {
|
|
48
|
+
if (v === null) return 'null';
|
|
49
|
+
if (v === undefined) return 'undefined';
|
|
50
|
+
const t = typeof v;
|
|
51
|
+
if (t === 'number') return Number.isFinite(v as number) ? String(v) : 'null';
|
|
52
|
+
if (t === 'boolean') return v === true ? 'true' : 'false';
|
|
53
|
+
if (t === 'bigint') return `${String(v)}n`;
|
|
54
|
+
if (t === 'string') return JSON.stringify(v);
|
|
55
|
+
if (t === 'function' || t === 'symbol') return 'undefined';
|
|
56
|
+
// 二进制按内容 sha256 计入,而非 base64 展开:文件类分区的字节就在 data.files[].data 里,
|
|
57
|
+
// 展开成 base64 会让哈希输入膨胀 33% 且对大目录明显变慢;变更检测只需要内容指纹。
|
|
58
|
+
if (v instanceof Uint8Array) return `"sha256:${sha256Hex(v)}"`;
|
|
59
|
+
if (v instanceof Date) return JSON.stringify(v.toISOString());
|
|
60
|
+
const obj = v as object;
|
|
61
|
+
if (seen.has(obj)) return '"<cycle>"';
|
|
62
|
+
seen.add(obj);
|
|
63
|
+
if (Array.isArray(v)) return `[${v.map(walk).join(',')}]`;
|
|
64
|
+
const entries = Object.entries(obj as Record<string, unknown>)
|
|
65
|
+
.filter(([, val]) => val !== undefined && typeof val !== 'function')
|
|
66
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
67
|
+
return `{${entries.map(([k, val]) => `${JSON.stringify(k)}:${walk(val)}`).join(',')}}`;
|
|
68
|
+
};
|
|
69
|
+
return walk(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 分区内容指纹。
|
|
74
|
+
* data 走 stableStringify;files(文件类分区)按「相对路径 + 内容 sha256」参与,
|
|
75
|
+
* 使文件重命名/内容变化都能被识别,而不必把字节写进哈希输入。
|
|
76
|
+
*/
|
|
77
|
+
export function hashExportSection(section: Pick<ExportSection, 'data' | 'files'>): string {
|
|
78
|
+
const files = (section.files ?? [])
|
|
79
|
+
.map((f) => ({ path: f.relativePath, hash: sha256Hex(f.data) }))
|
|
80
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
81
|
+
return sha256Hex(stableStringify({ data: section.data, files }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface CaptureConfigStateOptions {
|
|
85
|
+
/** 仅采集这些分区(缺省 = 全部传入的 adapter) */
|
|
86
|
+
only?: readonly SectionId[];
|
|
87
|
+
/** 单分区采集失败回调(该分区被跳过,不进入 state) */
|
|
88
|
+
onSectionError?: (section: SectionId, error: unknown) => void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 由**已导出的**分区结果构建状态(按 section 升序)。
|
|
93
|
+
*
|
|
94
|
+
* 单一事实源:`captureConfigState` 与「先导出再落快照」的快照路径都必须走这里,
|
|
95
|
+
* 否则两条路径的分区口径(hash / counts / fileCount)会悄悄漂移。
|
|
96
|
+
*/
|
|
97
|
+
export function stateFromExports(
|
|
98
|
+
sections: ReadonlyMap<SectionId, ExportSection>,
|
|
99
|
+
capturedAt: string = new Date().toISOString(),
|
|
100
|
+
): ConfigState {
|
|
101
|
+
const list: SectionState[] = [];
|
|
102
|
+
for (const [id, exported] of sections) {
|
|
103
|
+
list.push({
|
|
104
|
+
section: id,
|
|
105
|
+
hash: hashExportSection(exported),
|
|
106
|
+
counts: exported.counts ?? {},
|
|
107
|
+
fileCount: exported.files?.length ?? 0,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
list.sort((a, b) => (a.section < b.section ? -1 : a.section > b.section ? 1 : 0));
|
|
111
|
+
return { capturedAt, sections: list };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 采集当前配置状态。分区按 id 升序输出以保证可比性。
|
|
116
|
+
* 单分区失败 → 跳过(记入 onSectionError),不抛出。
|
|
117
|
+
*/
|
|
118
|
+
export async function captureConfigState(
|
|
119
|
+
adapters: readonly ConfigAdapter[],
|
|
120
|
+
ctx: HostContext,
|
|
121
|
+
opts: CaptureConfigStateOptions = {},
|
|
122
|
+
): Promise<ConfigState> {
|
|
123
|
+
const only = opts.only !== undefined ? new Set(opts.only) : null;
|
|
124
|
+
const collected = new Map<SectionId, ExportSection>();
|
|
125
|
+
for (const adapter of adapters) {
|
|
126
|
+
if (only !== null && !only.has(adapter.id)) continue;
|
|
127
|
+
try {
|
|
128
|
+
collected.set(adapter.id, await adapter.export(ctx, { includeSecrets: false, only: [adapter.id] }));
|
|
129
|
+
} catch (error) {
|
|
130
|
+
opts.onSectionError?.(adapter.id, error);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return stateFromExports(collected);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** section → SectionState(便于比较与展示);state 为 null/损坏时返回空表(不抛)。 */
|
|
137
|
+
export function stateSections(state: ConfigState | null): Map<SectionId, SectionState> {
|
|
138
|
+
const map = new Map<SectionId, SectionState>();
|
|
139
|
+
const sections = sectionListOf(state);
|
|
140
|
+
if (sections === null) return map;
|
|
141
|
+
for (const s of sections) map.set(s.section, s);
|
|
142
|
+
return map;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 取出 state.sections;非数组(含 state 为 null/undefined、或旧格式缺字段)→ null。
|
|
147
|
+
* 用于让 statesEqual 成为**全函数**:绝不因一条损坏的持久化快照而抛 TypeError
|
|
148
|
+
* (undo/redo/status 都直接消费磁盘上的 meta.state)。
|
|
149
|
+
*/
|
|
150
|
+
function sectionListOf(state: ConfigState | null | undefined): SectionState[] | null {
|
|
151
|
+
if (state === null || state === undefined) return null;
|
|
152
|
+
const sections = (state as { sections?: unknown }).sections;
|
|
153
|
+
return Array.isArray(sections) ? (sections as SectionState[]) : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 两个状态的内容是否完全一致(分区集合 + 每分区 hash)。
|
|
158
|
+
*
|
|
159
|
+
* 全函数:null / undefined / 缺 sections 的损坏状态一律**不抛**——
|
|
160
|
+
* 任一侧不可解析时退化为引用相等(null vs null 为 true,其余为 false)。
|
|
161
|
+
*/
|
|
162
|
+
export function statesEqual(a: ConfigState | null, b: ConfigState | null): boolean {
|
|
163
|
+
const left = sectionListOf(a);
|
|
164
|
+
const right = sectionListOf(b);
|
|
165
|
+
if (left === null || right === null) return a === b;
|
|
166
|
+
if (left.length !== right.length) return false;
|
|
167
|
+
const bySection = new Map<SectionId, SectionState>();
|
|
168
|
+
for (const s of left) bySection.set(s.section, s);
|
|
169
|
+
for (const s of right) {
|
|
170
|
+
const other = bySection.get(s.section);
|
|
171
|
+
if (other === undefined || other.hash !== s.hash) return false;
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface ConfigStateDiff {
|
|
177
|
+
/** 两侧都有但 hash 不同 */
|
|
178
|
+
changed: SectionId[];
|
|
179
|
+
/** 仅 after 有 */
|
|
180
|
+
added: SectionId[];
|
|
181
|
+
/** 仅 before 有 */
|
|
182
|
+
removed: SectionId[];
|
|
183
|
+
/** 无任何差异 */
|
|
184
|
+
identical: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 分区级差异(before → after)。顺序确定(按 section 升序)。 */
|
|
188
|
+
export function diffStates(before: ConfigState | null, after: ConfigState | null): ConfigStateDiff {
|
|
189
|
+
const b = stateSections(before);
|
|
190
|
+
const a = stateSections(after);
|
|
191
|
+
const changed: SectionId[] = [];
|
|
192
|
+
const added: SectionId[] = [];
|
|
193
|
+
const removed: SectionId[] = [];
|
|
194
|
+
for (const [id, as] of a) {
|
|
195
|
+
const bs = b.get(id);
|
|
196
|
+
if (bs === undefined) added.push(id);
|
|
197
|
+
else if (bs.hash !== as.hash) changed.push(id);
|
|
198
|
+
}
|
|
199
|
+
for (const id of b.keys()) {
|
|
200
|
+
if (!a.has(id)) removed.push(id);
|
|
201
|
+
}
|
|
202
|
+
const sortIds = (ids: SectionId[]): SectionId[] => ids.sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
|
|
203
|
+
sortIds(changed);
|
|
204
|
+
sortIds(added);
|
|
205
|
+
sortIds(removed);
|
|
206
|
+
return { changed, added, removed, identical: changed.length === 0 && added.length === 0 && removed.length === 0 };
|
|
207
|
+
}
|