dsh-crate-web 0.1.1 → 0.1.2-preview.2

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.
Files changed (34) hide show
  1. package/README.md +11 -290
  2. package/README.zh-CN.md +2 -303
  3. package/THIRD-PARTY-LICENSE.txt +21 -0
  4. package/cordis.patch.yml +1 -6
  5. package/core/dsh_pack/_version.py +1 -0
  6. package/core/dsh_pack/bundle.py +74 -0
  7. package/core/dsh_pack/cli.py +81 -18
  8. package/core/dsh_pack/desktop_verify.py +304 -0
  9. package/core/dsh_pack/diagnostics.py +1170 -0
  10. package/core/dsh_pack/errors.py +16 -1
  11. package/core/dsh_pack/export.py +86 -103
  12. package/core/dsh_pack/export_plan.py +96 -0
  13. package/core/dsh_pack/identity.py +25 -0
  14. package/core/dsh_pack/importer.py +468 -217
  15. package/core/dsh_pack/network.py +5 -4
  16. package/core/dsh_pack/pack.py +14 -2
  17. package/core/dsh_pack/portability.py +392 -0
  18. package/core/dsh_pack/preflight.py +63 -16
  19. package/core/dsh_pack/troubleshooting.py +94 -0
  20. package/core/dsh_pack/user_cli.py +16 -0
  21. package/core/dsh_pack/validation.py +64 -4
  22. package/core/dsh_pack/verify.py +343 -194
  23. package/core/dsh_pack/windows_verify.py +177 -0
  24. package/desktop-patch-helper.mjs +140 -0
  25. package/lib/client.js +292 -102
  26. package/lib/index.d.ts +7 -1
  27. package/lib/index.js +1121 -229
  28. package/package.json +14 -7
  29. package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
  30. package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
  31. package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
  32. package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
  33. package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
  34. package/verify-desktop.mjs +314 -0
@@ -12,6 +12,7 @@ from typing import Any, Mapping
12
12
 
13
13
  from .errors import DshPackError, IntegrityError, SchemaValidationError, _default_checks
14
14
  from .network import network_source_details
15
+ from .identity import expected_install_version, unknown_source
15
16
  from .pack import _read_entries, sha256_bytes
16
17
  from .validation import validate_manifest, validate_plugins_lock
17
18
 
@@ -47,7 +48,7 @@ _EXPECTED_BY_CODE = {
47
48
  "HASH_MISMATCH": "declared SHA-256 matches artifact bytes",
48
49
  "INTEGRITY_FILE_MISSING": "all declared Pack files are present",
49
50
  "INTEGRITY_FILE_SET_MISMATCH": "Pack files exactly match the integrity set",
50
- "VERSION_MISSING": "each plugin has resolved.version",
51
+ "VERSION_MISSING": "each plugin has a known resolution or an explicit unknown resolution with known runtime identity",
51
52
  "PACKAGE_IDENTITY_DRIFT": "artifact package identity matches lock identity",
52
53
  "REQUIRED_ARTIFACT_MISSING": "required embedded artifact is present",
53
54
  "OPTIONAL_ARTIFACT_MISSING": "optional embedded artifact is present",
@@ -70,7 +71,7 @@ _NEXT_STEP_BY_CODE = {
70
71
  "HASH_MISMATCH": "Restore the original artifact or regenerate the Pack.",
71
72
  "INTEGRITY_FILE_MISSING": "Restore the missing Pack member and regenerate its integrity table.",
72
73
  "INTEGRITY_FILE_SET_MISMATCH": "Regenerate the Pack so its integrity file set matches its contents.",
73
- "VERSION_MISSING": "Regenerate plugins.lock.json with a resolved plugin version.",
74
+ "VERSION_MISSING": "Re-export runtime identity, or provide recorded resolution evidence without guessing it.",
74
75
  "PACKAGE_IDENTITY_DRIFT": "Restore the artifact matching plugins.lock.json or re-export the Profile.",
75
76
  "REQUIRED_ARTIFACT_MISSING": "Restore the required embedded artifact before continuing.",
76
77
  "OPTIONAL_ARTIFACT_MISSING": "Restore the optional artifact or intentionally export the plugin reference-only.",
@@ -108,7 +109,9 @@ class Finding:
108
109
  raise ValueError(f"unsupported preflight severity: {self.severity}")
109
110
 
110
111
  def as_dict(self) -> dict[str, Any]:
112
+ from .diagnostics import envelope_fields
111
113
  return {
114
+ **envelope_fields(operation="inspect", status="FAIL" if self.severity == "BLOCKER" else "UNTESTED"),
112
115
  "severity": self.severity,
113
116
  "code": self.code,
114
117
  "message": self.message,
@@ -138,6 +141,7 @@ class PreflightContext:
138
141
  available_secrets: set[str] | None = None
139
142
  available_plugins: Mapping[str, str | None] | None = None
140
143
  allow_network_reference_install: bool = False
144
+ verified_local_plugins: Mapping[str, str] = field(default_factory=dict)
141
145
 
142
146
 
143
147
  @dataclass
@@ -384,7 +388,9 @@ def inspect_pack(
384
388
 
385
389
  try:
386
390
  validate_manifest(manifest, require_integrity=True)
387
- except SchemaValidationError as error:
391
+ from .portability import validate_inventory
392
+ validate_inventory(manifest, {name[len("user-data/"):]: content for name, content in entries.items() if name.startswith("user-data/")}, lock)
393
+ except DshPackError as error:
388
394
  findings.append(_finding("BLOCKER", "SCHEMA_ERROR", str(error)))
389
395
 
390
396
  raw_plugins = lock.get("plugins", [])
@@ -400,15 +406,14 @@ def inspect_pack(
400
406
  if name in plugin_names:
401
407
  findings.append(_finding("BLOCKER", "SCHEMA_ERROR", f"duplicate plugin name: {name}"))
402
408
  plugin_names.add(name)
403
- resolved = raw_plugin.get("resolved")
404
- version = resolved.get("version") if isinstance(resolved, Mapping) else None
409
+ version = expected_install_version(raw_plugin)
405
410
  if not isinstance(version, str) or not version.strip():
406
411
  missing_version_indexes.add(index)
407
412
  findings.append(
408
413
  _finding(
409
414
  "BLOCKER",
410
415
  "VERSION_MISSING",
411
- f"plugin at plugins.lock.plugins[{index}] is missing resolved.version",
416
+ f"plugin at plugins.lock.plugins[{index}] has no usable installation version identity",
412
417
  index=index,
413
418
  plugin=name,
414
419
  )
@@ -466,7 +471,9 @@ def inspect_pack(
466
471
 
467
472
  try:
468
473
  validate_plugins_lock(lock, artifact_paths=artifact_entries)
469
- except SchemaValidationError as error:
474
+ from .validation import validate_format_pair
475
+ validate_format_pair(manifest, lock)
476
+ except DshPackError as error:
470
477
  message = str(error)
471
478
  if "resolved.version" not in message and "resolved is missing fields" not in message:
472
479
  if "artifact.path is missing from plugins/" not in message:
@@ -594,9 +601,9 @@ def inspect_pack(
594
601
  if not _version_matches(required, current):
595
602
  findings.append(
596
603
  _finding(
597
- "WARNING",
604
+ "BLOCKER",
598
605
  "ENVIRONMENT_VERSION_MISMATCH",
599
- f"Pack was exported with {label} {required}; current {label} is {current} (runtime compatibility may differ)",
606
+ f"Pack requires {label} {required}; current is {current}",
600
607
  stage="environment",
601
608
  item=section,
602
609
  expected=required,
@@ -636,17 +643,35 @@ def inspect_pack(
636
643
  for name in missing_secrets:
637
644
  findings.append(_finding("WARNING", "SECRET_MISSING", f"required Secret is missing: {name}", secret=name))
638
645
 
646
+ for plugin in plugin_summaries:
647
+ artifact = plugin.get("artifact")
648
+ if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only" or not unknown_source(plugin):
649
+ continue
650
+ expected_version = expected_install_version(plugin)
651
+ local_version = context.verified_local_plugins.get(plugin["name"])
652
+ if expected_version is None or local_version != expected_version:
653
+ findings.append(_finding(
654
+ "BLOCKER" if plugin.get("required") is True else "WARNING", "SOURCE_UNKNOWN",
655
+ f"reference-only plugin has no recorded installation source: {plugin['name']}",
656
+ stage="identity", plugin=plugin["name"], expected=expected_version,
657
+ observed=local_version, evidence={"requested": plugin.get("requested"),
658
+ "resolved": plugin.get("resolved"), "runtime": plugin.get("runtime")},
659
+ suggested_next_step="Provide an explicit local package source with matching name/version, or embed its verified artifact.",
660
+ ))
661
+
639
662
  if context.available_plugins is not None:
640
663
  available_plugins = dict(context.available_plugins)
641
664
  for plugin in plugin_summaries:
642
665
  artifact = plugin.get("artifact")
643
666
  if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only":
644
667
  continue
668
+ if unknown_source(plugin):
669
+ continue # Checked separately using verified local-source evidence.
645
670
  name = plugin["name"]
646
671
  if name not in available_plugins:
647
672
  network_source = network_source_details(plugin)
648
673
  if context.allow_network_reference_install and network_source is not None:
649
- expected_version = plugin.get("resolved", {}).get("version") if isinstance(plugin.get("resolved"), Mapping) else None
674
+ expected_version = expected_install_version(plugin)
650
675
  findings.append(
651
676
  _finding(
652
677
  "WARNING",
@@ -670,7 +695,7 @@ def inspect_pack(
670
695
  findings.append(_finding(severity, code, f"reference-only plugin is missing: {name}", plugin=name))
671
696
  continue
672
697
  installed_version = available_plugins[name]
673
- expected_version = plugin.get("resolved", {}).get("version") if isinstance(plugin.get("resolved"), Mapping) else None
698
+ expected_version = expected_install_version(plugin)
674
699
  if installed_version and expected_version and installed_version != expected_version:
675
700
  findings.append(
676
701
  _finding(
@@ -690,12 +715,32 @@ def inspect_pack(
690
715
  path = artifact.get("path")
691
716
  if not isinstance(path, str) or path not in entries:
692
717
  continue
718
+ bundle = plugin.get("bundle")
719
+ # Schema 3 adds exhaustive ordered-patch artifact validation. Older
720
+ # schemas retain their previous Preflight contract; Import still checks
721
+ # the actual installed patch declarations and contents.
722
+ if (manifest.get("schemaVersion") == 3
723
+ and isinstance(bundle, Mapping) and bundle.get("enabled") is True):
724
+ from .bundle import check_bundle_artifact
725
+ try:
726
+ check_bundle_artifact(entries[path], bundle.get("patch"))
727
+ except DshPackError as error:
728
+ findings.append(_finding(
729
+ "BLOCKER" if plugin.get("required") is True else "WARNING",
730
+ "BUNDLE_PATCH_INVALID", f"embedded Bundle patch check failed: {plugin['name']}",
731
+ plugin=plugin["name"], error=str(error), patch=bundle.get("patch"),
732
+ ))
693
733
  identity = _artifact_package_identity(entries[path])
734
+ resolved = plugin.get("resolved")
735
+ resolution_unknown = isinstance(resolved, Mapping) and resolved.get("version") == "unknown"
694
736
  if identity is None:
737
+ if resolution_unknown:
738
+ findings.append(_finding("BLOCKER" if plugin.get("required") is True else "WARNING",
739
+ "PACKAGE_IDENTITY_DRIFT", "embedded package identity cannot be verified",
740
+ plugin=plugin["name"]))
695
741
  continue
696
742
  plugin["artifactIdentity"] = identity
697
- resolved = plugin.get("resolved")
698
- expected_version = resolved.get("version") if isinstance(resolved, Mapping) else None
743
+ expected_version = expected_install_version(plugin)
699
744
  drift: dict[str, dict[str, str]] = {}
700
745
  if identity["name"] != plugin["name"]:
701
746
  drift["name"] = {"expected": str(plugin["name"]), "actual": identity["name"]}
@@ -714,7 +759,8 @@ def inspect_pack(
714
759
  if drift:
715
760
  findings.append(
716
761
  _finding(
717
- "WARNING",
762
+ ("BLOCKER" if plugin.get("required") is True else "WARNING")
763
+ if resolution_unknown else "WARNING",
718
764
  "PACKAGE_IDENTITY_DRIFT",
719
765
  f"embedded package identity differs from lock identity: {plugin['name']}",
720
766
  plugin=plugin["name"],
@@ -738,6 +784,8 @@ def inspect_pack(
738
784
  "path": str(source_path),
739
785
  "format": manifest.get("format"),
740
786
  "packVersion": manifest.get("packVersion"),
787
+ "dataInventory": manifest.get("dataInventory", []),
788
+ "completeness": manifest.get("completeness", {}),
741
789
  "profile": manifest.get("profile"),
742
790
  },
743
791
  schema={"status": schema_status, "version": schema_version},
@@ -778,8 +826,7 @@ def render_text(result: PreflightResult) -> str:
778
826
  ]
779
827
  if result.plugins:
780
828
  for plugin in result.plugins:
781
- resolved = plugin.get("resolved")
782
- version = resolved.get("version", "missing") if isinstance(resolved, Mapping) else "missing"
829
+ version = expected_install_version(plugin) or "unknown"
783
830
  required = "required" if plugin.get("required") else "optional"
784
831
  artifact = plugin.get("artifact")
785
832
  mode = artifact.get("mode", "unknown") if isinstance(artifact, Mapping) else "unknown"
@@ -0,0 +1,94 @@
1
+ """Read-only diagnosis using the release's existing diagnostic registry."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import copy
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Sequence
9
+
10
+ from .diagnostics import CRATE_VERSION, classify, compat_status
11
+
12
+
13
+ def _issues(value: Any):
14
+ if isinstance(value, list):
15
+ for item in value:
16
+ yield from _issues(item)
17
+ elif isinstance(value, dict):
18
+ if isinstance(value.get("code"), str):
19
+ yield value
20
+ # Only traverse report containers, never execute or interpret arbitrary
21
+ # command strings, package configuration or model instructions.
22
+ for key in ("result", "error", "errors", "diagnostic", "diagnostics", "findings", "steps"):
23
+ if key in value:
24
+ yield from _issues(value[key])
25
+
26
+
27
+ def diagnose(report: dict[str, Any]) -> dict[str, Any]:
28
+ if not isinstance(report, dict):
29
+ raise ValueError("diagnostic report must be a JSON object")
30
+ version = report.get("crateVersion")
31
+ schema = report.get("diagnosticSchemaVersion")
32
+ if (report.get("producer") != "dsh-crate" or not isinstance(version, str)
33
+ or type(schema) is not int):
34
+ compatibility = {"status": "UNSUPPORTED", "canProceed": False,
35
+ "reason": "diagnostic envelope is incomplete"}
36
+ else:
37
+ compatibility = compat_status(
38
+ skill_version=CRATE_VERSION, dsh_crate_range=">=0.1.0 <0.1.1",
39
+ supported_schemas=[1], crate_version=version,
40
+ diagnostic_schema_version=schema,
41
+ )
42
+ issues = []
43
+ for issue in _issues(report):
44
+ matched = classify(issue)
45
+ # A registry entry suggests checks, not a proven root cause. Actual
46
+ # severity, stage and evidence always remain available unchanged.
47
+ issues.append({
48
+ "code": issue["code"], "stage": issue.get("stage"),
49
+ "severity": issue.get("severity"), "known": matched["known"],
50
+ "suggestedChecks": matched.get("suggestedChecks", []),
51
+ "registry": matched, "evidence": copy.deepcopy(issue),
52
+ })
53
+ supported = compatibility["canProceed"] and bool(issues) and all(i["known"] for i in issues)
54
+ return {
55
+ "operation": "diagnose", "readOnly": True, "writeScope": "none",
56
+ "compatibility": compatibility, "sourceStatus": report.get("status"),
57
+ "decision": "REVIEW_EVIDENCE" if supported else "STOP",
58
+ "repairStatus": "UNTESTED", "issues": issues,
59
+ "nextStep": (
60
+ "Check the listed evidence, propose the smallest scoped correction, then rerun Verify."
61
+ if supported else
62
+ "Obtain a complete report from the matching release; unknown evidence must not trigger repair."
63
+ ),
64
+ "sourceReport": copy.deepcopy(report),
65
+ }
66
+
67
+
68
+ def main(argv: Sequence[str] | None = None) -> int:
69
+ parser = argparse.ArgumentParser(prog="dsh-crate diagnose")
70
+ parser.add_argument("report", help="complete diagnostic JSON file")
71
+ parser.add_argument("--json", action="store_true")
72
+ args = parser.parse_args(argv)
73
+ try:
74
+ result = diagnose(json.loads(Path(args.report).read_text(encoding="utf-8")))
75
+ except (OSError, ValueError) as error:
76
+ result = {"operation": "diagnose", "readOnly": True, "decision": "STOP",
77
+ "repairStatus": "UNTESTED", "error": str(error)}
78
+ if args.json:
79
+ print(json.dumps(result, ensure_ascii=False, indent=2))
80
+ else:
81
+ print(f"Diagnosis: {result['decision']} (read-only)")
82
+ print(json.dumps(result, ensure_ascii=False, indent=2))
83
+ return 0 if result["decision"] == "REVIEW_EVIDENCE" else 1
84
+
85
+
86
+ if __name__ == "__main__":
87
+ raise SystemExit(main())
88
+
89
+
90
+ def configure_parser(subparsers):
91
+ parser = subparsers.add_parser("diagnose", help="read-only diagnosis from a complete report")
92
+ parser.add_argument("report")
93
+ parser.add_argument("--json", action="store_true")
94
+ parser.set_defaults(handler=lambda args: main([args.report] + (["--json"] if args.json else [])))
@@ -114,7 +114,9 @@ def _envelope(
114
114
  preflight = result.get("plan", {}).get("preflight", {}) if isinstance(result.get("plan"), dict) else {}
115
115
  if isinstance(preflight, dict) and preflight.get("status") in {"READY", "NOT_READY"}:
116
116
  status = preflight["status"]
117
+ from .diagnostics import envelope_fields
117
118
  return {
119
+ **envelope_fields(operation=command, status=status, operation_id=operation_id),
118
120
  "operationId": operation_id,
119
121
  "command": command,
120
122
  "status": status,
@@ -207,6 +209,11 @@ def _export_core_args(args: argparse.Namespace, home: Path) -> list[str]:
207
209
  if args.include_installed_bundles:
208
210
  argv.append("--include-installed-bundles")
209
211
  _append_repeated(argv, "--required-secret", args.required_secret)
212
+ if args.preset != "custom":
213
+ _append_option(argv, "--preset", args.preset)
214
+ _append_option(argv, "--data-selection-json", args.data_selection_json)
215
+ if args.plan_only:
216
+ argv.append("--plan-only")
210
217
  return argv
211
218
 
212
219
 
@@ -294,6 +301,10 @@ def _finish_operation(
294
301
 
295
302
 
296
303
  def _run_export(args: argparse.Namespace) -> int:
304
+ if args.plan_only:
305
+ code, payload = _invoke_core(_export_core_args(args, _dsh_home(args.dsh_home)))
306
+ _print(payload, as_json=args.json)
307
+ return code
297
308
  home = _operation_home(args, profile=args.profile)
298
309
  exit_code, result = _invoke_core(_export_core_args(args, home))
299
310
  return _finish_operation("export", args, home, exit_code, result)
@@ -399,6 +410,9 @@ def _build_parser() -> argparse.ArgumentParser:
399
410
  export.add_argument("--embed", action="append", default=[], metavar="PLUGIN")
400
411
  export.add_argument("--reference-only", action="append", default=[], metavar="PLUGIN")
401
412
  export.add_argument("--include-installed-bundles", action="store_true", help="include direct installed Bundle inventory in the exported Profile composition")
413
+ export.add_argument("--preset", choices=("share", "freeze", "custom"), default="custom")
414
+ export.add_argument("--data-selection-json", help="JSON array of explicitly selected data paths, kinds and owners")
415
+ export.add_argument("--plan-only", action="store_true", help="preview export contents without writing a Crate")
402
416
  export.add_argument("--required-secret", action="append", default=[], metavar="NAME")
403
417
  export.add_argument("--json", action="store_true", help="emit machine-readable JSON")
404
418
  export.add_argument("--dsh-home", help="DSH_HOME used for Profile-name resolution and reports")
@@ -445,6 +459,8 @@ def _build_parser() -> argparse.ArgumentParser:
445
459
  delete.add_argument("--json", action="store_true", help="emit machine-readable JSON")
446
460
  delete.add_argument("--dsh-home", help="DSH_HOME containing the Profile and operation reports")
447
461
  delete.set_defaults(handler=_run_delete_profile)
462
+ from .troubleshooting import configure_parser
463
+ configure_parser(subparsers)
448
464
  return parser
449
465
 
450
466
 
@@ -126,16 +126,23 @@ def validate_manifest(value: Any, *, require_integrity: bool = False) -> dict[st
126
126
  manifest = _object(value, "manifest")
127
127
  # Report an unsupported schema before checking version-specific required
128
128
  # fields, so a future schema cannot be misdiagnosed as a malformed v1 Pack.
129
- if "schemaVersion" in manifest and manifest["schemaVersion"] != SCHEMA_VERSION:
129
+ if "schemaVersion" in manifest and (type(manifest["schemaVersion"]) is not int or manifest["schemaVersion"] not in {1, 2, 3}):
130
130
  raise SchemaValidationError(
131
131
  f"unsupported manifest.schemaVersion: {manifest['schemaVersion']!r}; expected {SCHEMA_VERSION}"
132
132
  )
133
133
  allowed = {"schemaVersion", "format", "packVersion", "profile", "environment", "requiredSecrets", "integrity"}
134
+ if manifest.get("schemaVersion") in {2, 3}:
135
+ _validate_completeness(manifest.get("completeness"))
136
+ if not isinstance(manifest.get("dataInventory"), list):
137
+ raise SchemaValidationError("schema 2 requires dataInventory")
138
+ allowed.update({"exportMode", "dataInventory", "completeness"})
139
+ if not isinstance(manifest.get("exportMode"), str) or manifest["exportMode"] not in {"share", "freeze", "custom"}:
140
+ raise SchemaValidationError("schema 2 requires exportMode")
134
141
  required = {"schemaVersion", "format", "packVersion", "profile", "environment"}
135
142
  if require_integrity:
136
143
  required.add("integrity")
137
144
  _strict_keys(manifest, allowed, required, "manifest")
138
- if manifest["schemaVersion"] != SCHEMA_VERSION:
145
+ if manifest["schemaVersion"] not in {1, 2, 3}:
139
146
  raise SchemaValidationError(
140
147
  f"unsupported manifest.schemaVersion: {manifest['schemaVersion']!r}; expected {SCHEMA_VERSION}"
141
148
  )
@@ -162,6 +169,30 @@ def validate_manifest(value: Any, *, require_integrity: bool = False) -> dict[st
162
169
  return manifest
163
170
 
164
171
 
172
+
173
+ def _validate_completeness(value: Any) -> None:
174
+ data = _object(value, "manifest.completeness")
175
+ arrays = {"embedded", "referenceOnly", "secretNameOnly", "excluded", "unsupported"}
176
+ keys = arrays | {"hostDependencies", "offlineRestorable", "verification", "reason"}
177
+ _strict_keys(data, keys, keys, "manifest.completeness")
178
+ for key in arrays:
179
+ if not isinstance(data[key], list) or any(not isinstance(x, str) or not x for x in data[key]):
180
+ raise SchemaValidationError(f"completeness.{key} must be an array of names")
181
+ if not isinstance(data["hostDependencies"], list):
182
+ raise SchemaValidationError("completeness.hostDependencies must be an array")
183
+ for item in data["hostDependencies"]:
184
+ host = _object(item, "host dependency")
185
+ _strict_keys(host, {"name", "version", "requiredBy"}, {"name", "version"}, "host dependency")
186
+ for key in host:
187
+ _string(host[key], "host dependency." + key)
188
+ # Export declarations are not runtime evidence. YES belongs in a separately
189
+ # executed verification report, never in this format's export claim.
190
+ expected = "NO" if data["referenceOnly"] else "UNPROVEN"
191
+ if data["offlineRestorable"] != expected or data["verification"] != "UNTESTED":
192
+ raise SchemaValidationError("export completeness cannot claim verified restoration")
193
+ _string(data["reason"], "completeness.reason")
194
+
195
+
165
196
  def validate_environment(value: Any) -> dict[str, Any]:
166
197
  environment = _object(value, "manifest.environment")
167
198
  _strict_keys(environment, {"schemaVersion", "os", "node", "dsh"}, {"schemaVersion", "os", "node", "dsh"}, "manifest.environment")
@@ -201,10 +232,37 @@ def _validate_package_name(value: Any, path: str) -> str:
201
232
  return name
202
233
 
203
234
 
235
+ def bundle_patch_paths(value: Any) -> tuple[str, ...]:
236
+ """Return safe patch paths in declared composition order, accepting legacy strings."""
237
+ values = [value] if isinstance(value, str) else value
238
+ if not isinstance(values, list) or not values:
239
+ raise SchemaValidationError("bundle.patch must be a string or a non-empty ordered array of strings")
240
+ paths: list[str] = []
241
+ for item in values:
242
+ path = _string(item, "bundle.patch item").replace("\\", "/")
243
+ if path.startswith("./"):
244
+ path = path[2:]
245
+ if ":" in path:
246
+ raise SchemaValidationError("bundle.patch path cannot contain a drive or alternate stream")
247
+ validate_archive_path(f"package/{path}", expected_root="package")
248
+ reserved = {"con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)), *(f"lpt{i}" for i in range(1, 10))}
249
+ if any(part.rstrip(" .") != part or part.split(".", 1)[0].casefold() in reserved for part in path.split("/")):
250
+ raise SchemaValidationError("bundle.patch path contains a non-portable Windows filename")
251
+ if path.casefold() in {prior.casefold() for prior in paths}:
252
+ raise SchemaValidationError("bundle.patch contains duplicate paths")
253
+ paths.append(path)
254
+ return tuple(paths)
255
+
256
+
257
+ def validate_format_pair(manifest: Mapping[str, Any], lock: Mapping[str, Any]) -> None:
258
+ if lock.get("schemaVersion") == 2 and manifest.get("schemaVersion") != 3:
259
+ raise SchemaValidationError("ordered Bundle patch arrays require manifest schema 3 and plugins.lock schema 2")
260
+
261
+
204
262
  def validate_plugins_lock(value: Any, *, artifact_paths: set[str] | None = None) -> dict[str, Any]:
205
263
  lock = _object(value, "plugins.lock")
206
264
  _strict_keys(lock, {"schemaVersion", "plugins"}, {"schemaVersion", "plugins"}, "plugins.lock")
207
- if lock["schemaVersion"] != SCHEMA_VERSION:
265
+ if type(lock["schemaVersion"]) is not int or lock["schemaVersion"] not in {1, 2}:
208
266
  raise SchemaValidationError(
209
267
  f"unsupported plugins.lock.schemaVersion: {lock['schemaVersion']!r}; expected {SCHEMA_VERSION}"
210
268
  )
@@ -250,7 +308,9 @@ def validate_plugins_lock(value: Any, *, artifact_paths: set[str] | None = None)
250
308
  bundle_enabled = _boolean(bundle["enabled"], f"{path}.bundle.enabled")
251
309
  if bundle_enabled:
252
310
  _integer(bundle["order"], f"{path}.bundle.order", minimum=0)
253
- _string(bundle["patch"], f"{path}.bundle.patch")
311
+ if isinstance(bundle["patch"], list) and lock["schemaVersion"] != 2:
312
+ raise SchemaValidationError("Bundle patch arrays require plugins.lock schema 2")
313
+ bundle_patch_paths(bundle["patch"])
254
314
  else:
255
315
  if bundle["order"] is not None or bundle["patch"] is not None:
256
316
  raise SchemaValidationError(f"{path}.bundle.order and patch must be null when bundle.enabled is false")