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

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 +12 -289
  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 +44 -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
@@ -9,9 +9,12 @@ or model interaction. Without an adapter, runtime checks are UNTESTED.
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
+ import errno
13
+ import ipaddress
12
14
  import os
13
15
  import posixpath
14
16
  import re
17
+ import socket
15
18
  import subprocess
16
19
  import threading
17
20
  import time
@@ -19,7 +22,12 @@ import urllib.error
19
22
  import urllib.parse
20
23
  import urllib.request
21
24
  from .errors import _default_checks
22
- from dataclasses import dataclass, field
25
+ from .errors import DshPackError
26
+ from .bundle import check_patch_declaration, read_bundle_patches
27
+ from .identity import expected_install_version
28
+ from .pack import sha256_bytes
29
+ from .preflight import _artifact_package_identity
30
+ from dataclasses import dataclass, field, replace
23
31
  from pathlib import Path
24
32
  from typing import Any, Mapping, Protocol, Sequence
25
33
 
@@ -142,7 +150,9 @@ def _runtime_diagnostic(
142
150
  can_continue: bool = False,
143
151
  ) -> dict[str, Any]:
144
152
  """Structured diagnostic attached to a failed runtime VerifyStep."""
153
+ from .diagnostics import envelope_fields
145
154
  return {
155
+ **envelope_fields(operation="verify", status="FAIL"),
146
156
  "code": code,
147
157
  "stage": stage,
148
158
  "severity": "BLOCKER",
@@ -159,7 +169,16 @@ def _runtime_diagnostic(
159
169
 
160
170
  def _normalise_observation(name: str, value: VerifyStep | Mapping[str, Any], *, required: bool) -> VerifyStep:
161
171
  if isinstance(value, VerifyStep):
162
- return value
172
+ # 步骤身份和必测要求由 Core 决定;适配器标签不能绕过
173
+ # 前置失败,也不能弱化最终状态。保留适配器的原始证据。
174
+ return VerifyStep(
175
+ name=name,
176
+ status=value.status,
177
+ message=value.message,
178
+ required=required,
179
+ evidence=value.evidence,
180
+ diagnostic=value.diagnostic,
181
+ )
163
182
  if not isinstance(value, Mapping):
164
183
  return _step(
165
184
  name,
@@ -181,7 +200,7 @@ def _normalise_observation(name: str, value: VerifyStep | Mapping[str, Any], *,
181
200
  name,
182
201
  status,
183
202
  str(value.get("message", name)),
184
- required=bool(value.get("required", required)),
203
+ required=required,
185
204
  evidence=value.get("evidence") if isinstance(value.get("evidence"), Mapping) else {},
186
205
  )
187
206
 
@@ -223,85 +242,6 @@ def _profile_bundles(profile_manifest: Mapping[str, Any]) -> tuple[list[str] | N
223
242
  return list(bundles), None
224
243
 
225
244
 
226
- def _resolve_installed_package_dir(anchor_dir: Path, name: str) -> Path | None:
227
- """Node-style node_modules lookup from an anchor directory.
228
-
229
- Mirrors DSH ``packageDirFromAnchor`` (installation anchor, then the profile
230
- directory): probes the profile-local ``node_modules``, the shared
231
- ``profiles/node_modules`` layer, home-level ``node_modules``, then each
232
- ancestor. This matches what the Cordis Loader would import from the same
233
- anchor.
234
- """
235
- parts = Path(*name.split("/"))
236
- current = anchor_dir
237
- while True:
238
- candidate = current / "node_modules" / parts
239
- if (candidate / "package.json").is_file():
240
- return candidate
241
- parent = current.parent
242
- if parent == current:
243
- return None
244
- current = parent
245
- return None
246
-
247
-
248
- def _internal_composition_issues(
249
- profile_path: Path,
250
- dependencies: Mapping[str, Any],
251
- bundles: Sequence[str],
252
- ) -> list[dict[str, Any]]:
253
- """Verify a Profile against its own manifest when no Pack metadata exists.
254
-
255
- Used when the Crate Import metadata directory for this Profile is absent
256
- (the Profile was not created by a Crate Import, or the metadata was
257
- removed). This can never claim a Pack round-trip: it only checks that
258
- declared Bundles are installed with their patch files, and that declared
259
- dependencies resolve. The caller marks the Pack-comparison dimension as
260
- UNTESTED.
261
- """
262
- issues: list[dict[str, Any]] = []
263
- declared = dict(dependencies) if isinstance(dependencies, Mapping) else {}
264
- for name in bundles:
265
- package_dir = _resolve_installed_package_dir(profile_path, name)
266
- if package_dir is None:
267
- issues.append({"code": "BUNDLE_NOT_INSTALLED", "bundle": name, "error": "not resolvable from the profile or shared node_modules layers"})
268
- continue
269
- installed, error = _json_object(package_dir / "package.json")
270
- if installed is None:
271
- issues.append({"code": "BUNDLE_NOT_INSTALLED", "bundle": name, "error": error})
272
- continue
273
- dsh = installed.get("dsh", {})
274
- if not isinstance(dsh, Mapping):
275
- dsh = {}
276
- bundle = dsh.get("bundle", {})
277
- patch = bundle.get("patch") if isinstance(bundle, Mapping) else None
278
- normalized_patch = posixpath.normpath(patch.replace("\\", "/")) if isinstance(patch, str) else ""
279
- safe_patch = (
280
- normalized_patch not in {"", ".", ".."}
281
- and not normalized_patch.startswith("../")
282
- and not normalized_patch.startswith("/")
283
- and ".." not in normalized_patch.split("/")
284
- )
285
- patch_path = package_dir / Path(*normalized_patch.split("/")) if safe_patch else None
286
- if not isinstance(patch, str) or not patch.strip() or patch_path is None or not patch_path.is_file():
287
- issues.append({
288
- "code": "BUNDLE_PATCH_MISSING",
289
- "bundle": name,
290
- "patch": patch,
291
- "path": str(patch_path) if patch_path else None,
292
- })
293
- for name, spec in declared.items():
294
- package_dir = _resolve_installed_package_dir(profile_path, name)
295
- if package_dir is None:
296
- issues.append({
297
- "code": "DEPENDENCY_NOT_INSTALLED",
298
- "plugin": name,
299
- "spec": spec,
300
- "error": "not resolvable from the profile or shared node_modules layers",
301
- })
302
- return issues
303
-
304
-
305
245
  def _composition_step(options: "VerifyOptions") -> VerifyStep:
306
246
  profile_path = options.dsh_home / "profiles" / options.profile_name
307
247
  metadata_path = options.dsh_home / ".dsh-pack" / "imports" / options.profile_name
@@ -318,50 +258,9 @@ def _composition_step(options: "VerifyOptions") -> VerifyStep:
318
258
  if bundle_error or bundles is None:
319
259
  return _step("composition", "FAIL", bundle_error or "invalid Bundle composition", evidence={"path": str(package_path)})
320
260
 
321
- manifest_file = metadata_path / "manifest.json"
322
- lock_file = metadata_path / "plugins.lock.json"
323
- prepared_file = metadata_path / "prepared.json"
324
- metadata_present = metadata_path.is_dir() and any(
325
- candidate.is_file() for candidate in (manifest_file, lock_file, prepared_file)
326
- )
327
- if not metadata_present:
328
- # This Profile was not created by a Crate Import, or the Import
329
- # metadata was removed. The Pack-comparison dimension is UNTESTED:
330
- # only an internal consistency check against the Profile's own
331
- # manifest is possible, never a claimed round-trip.
332
- dependencies = profile_manifest.get("dependencies", {})
333
- if not isinstance(dependencies, Mapping):
334
- return _step(
335
- "composition",
336
- "FAIL",
337
- "Profile dependencies is not an object",
338
- evidence={"path": str(package_path)},
339
- )
340
- internal_issues = _internal_composition_issues(profile_path, dependencies, bundles)
341
- if internal_issues:
342
- return _step(
343
- "composition",
344
- "FAIL",
345
- "Crate Import metadata is absent and the Profile fails its internal consistency check",
346
- evidence={
347
- "metadataPath": str(metadata_path),
348
- "packComparison": "UNTESTED",
349
- "issues": internal_issues,
350
- },
351
- )
352
- return _step(
353
- "composition",
354
- "DEGRADED",
355
- "Crate Import metadata is absent; only internal consistency was checked, Pack round-trip UNTESTED",
356
- evidence={
357
- "metadataPath": str(metadata_path),
358
- "profileBundles": bundles,
359
- "packComparison": "UNTESTED",
360
- },
361
- )
362
- metadata_manifest, manifest_error = _json_object(manifest_file)
363
- lock, lock_error = _json_object(lock_file)
364
- prepared, prepared_error = _json_object(prepared_file)
261
+ metadata_manifest, manifest_error = _json_object(metadata_path / "manifest.json")
262
+ lock, lock_error = _json_object(metadata_path / "plugins.lock.json")
263
+ prepared, prepared_error = _json_object(metadata_path / "prepared.json")
365
264
  if manifest_error or lock_error or prepared_error or metadata_manifest is None or lock is None or prepared is None:
366
265
  return _step(
367
266
  "composition",
@@ -392,6 +291,40 @@ def _composition_step(options: "VerifyOptions") -> VerifyStep:
392
291
  expected_bundles: list[str] = []
393
292
  installed_rows: list[dict[str, Any]] = []
394
293
  issues: list[dict[str, Any]] = []
294
+ installation = prepared.get('installation')
295
+ rebuilt = isinstance(installation, Mapping) and installation.get('resolver') == 'pnpm'
296
+ original_dependencies = None
297
+ if rebuilt:
298
+ try:
299
+ original_bytes = (profile_path / '.dsh-crate/original-package.json').read_bytes()
300
+ if sha256_bytes(original_bytes) != installation.get('originalManifestSha256'):
301
+ raise ValueError('original manifest hash mismatch')
302
+ if sha256_bytes((profile_path / 'pnpm-workspace.yaml').read_bytes()) != installation.get('workspaceSha256'):
303
+ raise ValueError('generated installation workspace hash mismatch')
304
+ adaptation = installation.get('profilePatchAdaptation')
305
+ if isinstance(adaptation, Mapping) and adaptation.get('changed') is True:
306
+ if adaptation.get('adapter') != 'official-desktop-composition-equivalence-v1':
307
+ raise ValueError('unsupported Profile patch adaptation identity')
308
+ if sha256_bytes((profile_path / '.dsh-crate/original-profile-config/cordis.patch.yml').read_bytes()) != adaptation.get('originalSha256'):
309
+ raise ValueError('original Profile patch hash mismatch')
310
+ if sha256_bytes((profile_path / 'cordis.patch.yml').read_bytes()) != adaptation.get('runtimeSha256'):
311
+ raise ValueError('adapted runtime Profile patch hash mismatch')
312
+ if not isinstance(adaptation.get('loaderTreeSha256'), str) or not re.fullmatch('[a-f0-9]{64}', adaptation['loaderTreeSha256']):
313
+ raise ValueError('official Loader equivalence evidence is invalid')
314
+ removed = adaptation.get('removedNames')
315
+ if not isinstance(removed, list) or not removed or any(not isinstance(row, Mapping) or set(row) != {'id', 'name'} or not all(isinstance(value, str) and value for value in row.values()) for row in removed):
316
+ raise ValueError('Profile patch adaptation record is invalid')
317
+ original_dependencies = json.loads(original_bytes).get('dependencies', {})
318
+ if not isinstance(original_dependencies, Mapping):
319
+ raise ValueError('original dependencies must be an object')
320
+ modules, error = _json_object(profile_path / 'node_modules/.modules.yaml')
321
+ if error or modules is None or modules.get('nodeLinker') != 'hoisted' or not (profile_path / 'pnpm-lock.yaml').is_file():
322
+ raise ValueError('pnpm installation records are incomplete')
323
+ store = modules.get('virtualStoreDir')
324
+ if not isinstance(store, str) or (profile_path / 'node_modules' / store).resolve() != (profile_path / 'node_modules/.pnpm').resolve():
325
+ raise ValueError('pnpm virtual store does not match the prepared Profile')
326
+ except (OSError, ValueError, TypeError) as error:
327
+ issues.append({'code': 'REBUILD_STATE_INVALID', 'message': str(error)})
395
328
  for index, plugin in enumerate(raw_plugins):
396
329
  name = plugin.get("name")
397
330
  if not isinstance(name, str) or not name:
@@ -399,10 +332,35 @@ def _composition_step(options: "VerifyOptions") -> VerifyStep:
399
332
  continue
400
333
  requested = plugin.get("requested")
401
334
  requested_specifier = requested.get("specifier") if isinstance(requested, Mapping) else None
402
- resolved = plugin.get("resolved")
403
- resolved_version = resolved.get("version") if isinstance(resolved, Mapping) else None
335
+ resolved_version = expected_install_version(plugin)
404
336
  declared_spec = dependencies.get(name)
405
- if declared_spec is not None and declared_spec != requested_specifier:
337
+ installed, installed_error = _package_manifest(profile_path, name)
338
+ prepared_names = {row.get('name') for row in prepared.get('plugins', []) if isinstance(row, Mapping)}
339
+ skipped_optional = plugin.get('required') is not True and name not in prepared_names and installed is None and declared_spec is None
340
+ if rebuilt and not skipped_optional:
341
+ try:
342
+ requests = installation.get('requests', {})
343
+ spec = requests.get(name) if isinstance(requests, Mapping) else None
344
+ match = re.fullmatch(r'file:\./\.dsh-crate/artifacts/([0-9a-f]{64})\.tgz', spec or '')
345
+ if not match or declared_spec != spec:
346
+ raise ValueError('local artifact dependency mapping mismatch')
347
+ if original_dependencies is None or original_dependencies.get(name, 'unknown') != requested_specifier:
348
+ raise ValueError('original Profile SPEC does not match plugins.lock')
349
+ artifact = plugin.get('artifact', {})
350
+ if artifact.get('mode') == 'embedded' and artifact.get('sha256') != match[1]:
351
+ raise ValueError('embedded artifact mapping differs from plugins.lock hash')
352
+ path = profile_path / '.dsh-crate/artifacts' / (match[1] + '.tgz')
353
+ if path.is_symlink() or path.resolve().parent != (profile_path / '.dsh-crate/artifacts').resolve():
354
+ raise ValueError('artifact path escapes the prepared Profile')
355
+ content = path.read_bytes()
356
+ if sha256_bytes(content) != match[1]:
357
+ raise ValueError('local rebuild artifact hash mismatch')
358
+ identity = _artifact_package_identity(content)
359
+ if identity != {'name': name, 'version': resolved_version}:
360
+ raise ValueError('local rebuild artifact identity mismatch')
361
+ except (OSError, ValueError, TypeError) as error:
362
+ issues.append({'code': 'REBUILD_ARTIFACT_INVALID', 'plugin': name, 'message': str(error)})
363
+ elif declared_spec is not None and declared_spec != requested_specifier:
406
364
  issues.append({
407
365
  "code": "PROFILE_SPEC_MISMATCH",
408
366
  "plugin": name,
@@ -413,24 +371,20 @@ def _composition_step(options: "VerifyOptions") -> VerifyStep:
413
371
  enabled = isinstance(bundle, Mapping) and bundle.get("enabled") is True
414
372
  if enabled:
415
373
  expected_bundles.append(name)
374
+ if enabled and not skipped_optional:
416
375
  patch = bundle.get("patch")
417
376
  package_path_for_plugin = profile_path / "node_modules" / Path(*name.split("/"))
418
- normalized_patch = posixpath.normpath(patch.replace("\\", "/")) if isinstance(patch, str) else ""
419
- safe_patch = (
420
- normalized_patch not in {"", ".", ".."}
421
- and not normalized_patch.startswith("../")
422
- and not normalized_patch.startswith("/")
423
- and ".." not in normalized_patch.split("/")
424
- )
425
- patch_path = package_path_for_plugin / Path(*normalized_patch.split("/")) if safe_patch else None
426
- if not isinstance(patch, str) or not patch.strip() or patch_path is None or not patch_path.is_file():
377
+ try:
378
+ read_bundle_patches(package_path_for_plugin, patch)
379
+ if installed is not None:
380
+ check_patch_declaration(installed, patch)
381
+ except DshPackError as error:
427
382
  issues.append({
428
- "code": "BUNDLE_PATCH_MISSING",
383
+ "code": "BUNDLE_PATCH_INVALID",
429
384
  "plugin": name,
430
385
  "patch": patch,
431
- "path": str(patch_path) if patch_path else None,
386
+ "error": str(error),
432
387
  })
433
- installed, installed_error = _package_manifest(profile_path, name)
434
388
  installed_row = {
435
389
  "name": name,
436
390
  "required": plugin.get("required") is True,
@@ -512,6 +466,7 @@ class VerifyResult:
512
466
  available_secret_names: frozenset[str]
513
467
  steps: tuple[VerifyStep, ...]
514
468
  plugin_contracts: tuple[SmokeTestContract, ...]
469
+ business_status: str | None = None
515
470
 
516
471
  def __post_init__(self) -> None:
517
472
  if self.status not in VERIFY_STATUSES:
@@ -526,6 +481,8 @@ class VerifyResult:
526
481
  ]
527
482
  return {
528
483
  "status": self.status,
484
+ "businessStatus": self.business_status or self.status,
485
+ "cleanup": step_map.get("cleanup"),
529
486
  "profile": {
530
487
  "name": self.profile_name,
531
488
  "dshHome": str(self.dsh_home),
@@ -567,6 +524,73 @@ def _overall_status(steps: Sequence[VerifyStep]) -> str:
567
524
  return "PASS"
568
525
 
569
526
 
527
+ def _cleanup_status(evidence: Mapping[str, Any]) -> str:
528
+ """缺失证据不能当作释放成功;此前失败的清理也不能被末次成功遮蔽。"""
529
+ attempts = evidence.get("attempts", [])
530
+ if isinstance(attempts, list) and any(
531
+ isinstance(item, Mapping) and _cleanup_status(item) == "FAIL" for item in attempts
532
+ ):
533
+ return "FAIL"
534
+ endpoints = evidence.get("endpoints", [])
535
+ endpoints = endpoints if isinstance(endpoints, list) else []
536
+ if (evidence.get("status") == "FAIL" or evidence.get("errors") or evidence.get("processExited") is False
537
+ or evidence.get("portReleased") is False
538
+ or any(isinstance(item, Mapping) and item.get("released") is False for item in endpoints)):
539
+ return "FAIL"
540
+ if evidence.get("processExited") is not True:
541
+ return "UNTESTED"
542
+ if evidence.get("portApplicable") is not False and evidence.get("portReleased") is not True:
543
+ return "UNTESTED"
544
+ if evidence.get("portApplicable") is not False and (
545
+ not isinstance(evidence.get("portEvidence"), Mapping)
546
+ or evidence["portEvidence"].get("checked") is not True
547
+ ):
548
+ return "UNTESTED"
549
+ if evidence.get("endpointsApplicable") is not False and (
550
+ not endpoints or any(not isinstance(item, Mapping) or item.get("checked") is not True
551
+ or item.get("released") is not True for item in endpoints)
552
+ ):
553
+ return "UNTESTED"
554
+ if isinstance(attempts, list) and any(
555
+ not isinstance(item, Mapping) or _cleanup_status(item) != "PASS" for item in attempts
556
+ ):
557
+ return "UNTESTED"
558
+ return "PASS"
559
+
560
+
561
+ def _cleanup_step(adapter: VerifyAdapter | None) -> VerifyStep:
562
+ close = getattr(adapter, "close", None)
563
+ if not callable(close):
564
+ return _step("cleanup", "UNTESTED", "未提供可核查的最终清理接口",
565
+ evidence={"processExited": None, "portReleased": None, "endpoints": [],
566
+ "reason": "VerifyAdapter has no close implementation"})
567
+ try:
568
+ value = close()
569
+ except Exception as error:
570
+ evidence = {"processExited": None, "portReleased": None, "endpoints": [],
571
+ "error": str(error), "exception": type(error).__name__}
572
+ return _step("cleanup", "FAIL", "最终清理抛出异常;原业务步骤和诊断已保留", evidence=evidence,
573
+ diagnostic=_runtime_diagnostic(
574
+ code="VERIFY_CLEANUP_EXCEPTION", stage="cleanup", message="最终清理抛出异常",
575
+ evidence=evidence, item="cleanup", expected="清理完成并返回资源释放证据",
576
+ impact="无法确认验证资源已释放;请检查 cleanup 证据中的进程和端点。"))
577
+ evidence = dict(value) if isinstance(value, Mapping) else {
578
+ "processExited": None, "portReleased": None, "endpoints": [],
579
+ "reason": "close returned no resource evidence"}
580
+ status = _cleanup_status(evidence)
581
+ diagnostic = None
582
+ if status != "PASS":
583
+ diagnostic = _runtime_diagnostic(
584
+ code="VERIFY_CLEANUP_FAILED" if status == "FAIL" else "VERIFY_CLEANUP_UNVERIFIED",
585
+ stage="cleanup", message="最终清理失败" if status == "FAIL" else "最终清理证据不足",
586
+ evidence=evidence, item="cleanup", expected="已观察进程、端口和端点均有释放证据",
587
+ impact="原业务步骤和诊断已保留;资源释放状态请单独检查。")
588
+ return _step("cleanup", status,
589
+ {"PASS": "已核查所记录资源的释放", "FAIL": "清理失败;原业务步骤和诊断已保留",
590
+ "UNTESTED": "清理证据不足;未将未知状态判为成功"}[status],
591
+ evidence=evidence, diagnostic=diagnostic)
592
+
593
+
570
594
  def _loader_composition_failure(composition: VerifyStep, boot: VerifyStep) -> dict[str, Any] | None:
571
595
  """Classify a real DSH Loader activation failure as composition failure.
572
596
 
@@ -593,22 +617,38 @@ def _loader_composition_failure(composition: VerifyStep, boot: VerifyStep) -> di
593
617
  if not any(marker in lowered for marker in markers):
594
618
  return None
595
619
 
596
- failed_bundle: str | None = None
620
+ # 只接受失败行明确命名的条目;全局启动日志和安装顺序不能证明归属。
621
+ # 运行条目可能是未直接列在 Bundle 清单中的包或 subpath,必须保留原名。
622
+ target_pattern = re.compile(
623
+ r"(?P<marker>plugin tree failed to load\s*:\s*|failed to apply loader entry\s+|did not activate\s+)"
624
+ r"[`\"']?(?P<target>(?:@[\w.-]+/)?[\w.-]+(?:/[\w.-]+)*)[`\"']?(?=\s*(?::|$))",
625
+ re.IGNORECASE,
626
+ )
597
627
  installed_plugins = composition.evidence.get("installedPlugins", [])
598
- if isinstance(installed_plugins, list):
599
- for row in installed_plugins:
600
- if not isinstance(row, Mapping):
601
- continue
602
- name = row.get("name")
603
- if not isinstance(name, str) or not name:
628
+ installed_names = {
629
+ row["name"] for row in installed_plugins
630
+ if isinstance(row, Mapping) and isinstance(row.get("name"), str) and row["name"]
631
+ } if isinstance(installed_plugins, list) else set()
632
+ failed_targets: set[str] = set()
633
+ for line in combined.splitlines():
634
+ if not any(marker in line.lower() for marker in markers):
635
+ continue
636
+ for match in target_pattern.finditer(line):
637
+ target = match.group("target")
638
+ if target.lower() in {"error", "typeerror", "rangeerror", "exception"}:
604
639
  continue
605
- if name in combined:
606
- failed_bundle = name
607
- break
608
- if failed_bundle is None:
609
- match = re.search(r"(?:failed to apply loader entry|did not activate)\s+([^\s:(]+)", combined, re.IGNORECASE)
610
- if match:
611
- failed_bundle = match.group(1)
640
+ # 泛化的等待失败文案不是 Loader 证据;只有明确包名才可归属。
641
+ if match.group("marker").lower().startswith("did not activate"):
642
+ if not (target.startswith("@") or any(target == name or target.startswith(name + "/") for name in installed_names)):
643
+ continue
644
+ failed_targets.add(target)
645
+ for name in installed_names:
646
+ if re.search(r"(?<![\w@./-])" + re.escape(name) + r"\s+did not activate\b", line, re.IGNORECASE):
647
+ failed_targets.add(name)
648
+ has_loader_failure = any(marker in lowered for marker in markers if marker != "did not activate")
649
+ if not has_loader_failure and not failed_targets:
650
+ return None
651
+ failed_bundle = next(iter(failed_targets)) if len(failed_targets) == 1 else None
612
652
 
613
653
  observed = "Loader activation failed" if "loader activation failed" in lowered else "DSH Loader activation failed"
614
654
  return {
@@ -694,6 +734,15 @@ def _plugin_step(
694
734
  )
695
735
 
696
736
 
737
+ def _runtime_blocker(steps: list[VerifyStep]) -> str | None:
738
+ """Preserve the first failure; never probe or restart past missing startup evidence."""
739
+ runtime_names = {name for name, _ in RUNTIME_STEPS}
740
+ prerequisites = {"boot", "surface_ready", "restart", "restart_surface_ready"}
741
+ return next((step.name for step in steps if step.name in runtime_names
742
+ and (step.status == "FAIL" or (step.name in prerequisites
743
+ and step.status not in {"PASS", "DEGRADED"}))), None)
744
+
745
+
697
746
  def verify_profile(options: VerifyOptions) -> VerifyResult:
698
747
  """Verify one prepared Profile without changing its files."""
699
748
 
@@ -707,8 +756,10 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
707
756
  available_secret_names=options.available_secret_names,
708
757
  steps=(_step("composition", "FAIL", "DSH_HOME does not exist", evidence={"dshHome": str(options.dsh_home)}),),
709
758
  plugin_contracts=options.plugin_contracts,
759
+ business_status="FAIL",
710
760
  )
711
- return result
761
+ cleanup = _cleanup_step(options.adapter)
762
+ return replace(result, steps=(*result.steps, cleanup))
712
763
 
713
764
  composition = _composition_step(options)
714
765
  steps: list[VerifyStep] = [composition]
@@ -724,10 +775,7 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
724
775
  }
725
776
 
726
777
  for name, label in RUNTIME_STEPS:
727
- blocking_runtime_step = next(
728
- (step.name for step in steps if step.name in {"boot", "restart"} and step.status == "FAIL"),
729
- None,
730
- )
778
+ blocking_runtime_step = _runtime_blocker(steps)
731
779
  if composition.status == "FAIL":
732
780
  current = _step(
733
781
  name,
@@ -739,7 +787,7 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
739
787
  current = _step(
740
788
  name,
741
789
  "UNTESTED",
742
- f"{label} was not run because an earlier runtime step failed",
790
+ f"{label} was not run because an earlier runtime prerequisite did not pass",
743
791
  evidence={"blockedBy": blocking_runtime_step},
744
792
  )
745
793
  else:
@@ -748,10 +796,49 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
748
796
  steps.append(current)
749
797
  previous[current.name] = current
750
798
 
751
- for contract in options.plugin_contracts:
752
- for test_name in contract.tests:
753
- context = VerifyContext(previous_steps=dict(previous), **context_base)
754
- current = _plugin_step(options.adapter, contract, test_name, context)
799
+ # The imported lock, rather than the caller's contract list, defines which
800
+ # plugin capabilities must be accounted for. A missing or empty contract
801
+ # is explicit missing evidence, never an implicit successful smoke test.
802
+ imported_plugins = composition.evidence.get("installedPlugins", [])
803
+ imported_required = {
804
+ row["name"]: row.get("required") is True
805
+ for row in imported_plugins
806
+ if isinstance(row, Mapping) and isinstance(row.get("name"), str)
807
+ } if isinstance(imported_plugins, list) else {}
808
+ contracts = [
809
+ SmokeTestContract(
810
+ plugin=contract.plugin,
811
+ tests=tuple(test for test in contract.tests if isinstance(test, str) and test.strip()),
812
+ required=contract.required or imported_required.get(contract.plugin, False),
813
+ )
814
+ for contract in options.plugin_contracts
815
+ ]
816
+ covered_plugins = {contract.plugin for contract in contracts}
817
+ contracts.extend(
818
+ SmokeTestContract(plugin=name, tests=(), required=required)
819
+ for name, required in imported_required.items()
820
+ if name not in covered_plugins
821
+ )
822
+ blocking_plugin_step = ("composition" if composition.status not in {"PASS", "DEGRADED"}
823
+ else _runtime_blocker(steps))
824
+ for contract in contracts:
825
+ for test_name in contract.tests or (None,):
826
+ step_name = f"plugin:{contract.plugin}:{test_name or 'untested'}"
827
+ if blocking_plugin_step is not None:
828
+ current = _step(
829
+ step_name, "UNTESTED", "Plugin smoke test was not run because runtime prerequisites did not pass",
830
+ required=contract.required,
831
+ evidence={"plugin": contract.plugin, "test": test_name, "blockedBy": blocking_plugin_step},
832
+ )
833
+ elif test_name is None:
834
+ current = _step(
835
+ step_name, "UNTESTED", "Imported plugin has no non-empty dedicated smoke-test contract",
836
+ required=contract.required,
837
+ evidence={"plugin": contract.plugin, "test": None, "reason": "missing smoke-test contract"},
838
+ )
839
+ else:
840
+ context = VerifyContext(previous_steps=dict(previous), **context_base)
841
+ current = _plugin_step(options.adapter, contract, test_name, context)
755
842
  steps.append(current)
756
843
  previous[current.name] = current
757
844
 
@@ -773,6 +860,8 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
773
860
  )
774
861
  steps[0] = composition
775
862
 
863
+ business_status = _overall_status(steps)
864
+ steps.append(_cleanup_step(options.adapter))
776
865
  result = VerifyResult(
777
866
  status=_overall_status(steps),
778
867
  dsh_home=options.dsh_home,
@@ -781,16 +870,9 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
781
870
  current_environment=options.current_environment,
782
871
  available_secret_names=options.available_secret_names,
783
872
  steps=tuple(steps),
784
- plugin_contracts=options.plugin_contracts,
873
+ plugin_contracts=tuple(contracts),
874
+ business_status=business_status,
785
875
  )
786
- close = getattr(options.adapter, "close", None)
787
- if callable(close):
788
- try:
789
- close()
790
- except Exception:
791
- # Runtime cleanup evidence belongs to the adapter; do not replace
792
- # the actual verification result with a cleanup exception.
793
- pass
794
876
  return result
795
877
 
796
878
 
@@ -826,6 +908,8 @@ class SubprocessVerifyAdapter:
826
908
  self._threads: list[threading.Thread] = []
827
909
  self._url: str | None = None
828
910
  self._last_headless: dict[str, Any] | None = None
911
+ self._cleanup_attempts: list[dict[str, Any]] = []
912
+ self._last_cleanup: dict[str, Any] | None = None
829
913
 
830
914
  def _timeout(self) -> float:
831
915
  value = self.config.get("timeoutSeconds", 60)
@@ -849,13 +933,16 @@ class SubprocessVerifyAdapter:
849
933
 
850
934
  def _environment(self, context: VerifyContext) -> dict[str, str]:
851
935
  env = dict(os.environ) if self.config.get("inheritEnvironment", True) is not False else {}
852
- env["DSH_HOME"] = str(context.dsh_home)
853
- env["DSH_PROFILE"] = context.profile_name
854
936
  if self._url:
855
937
  env["DSH_WEB_URL"] = self._url
856
938
  extra = self.config.get("environment", {})
857
939
  if isinstance(extra, Mapping):
858
940
  env.update({str(key): str(value) for key, value in extra.items()})
941
+ # Windows environment names are case-insensitive. Remove all inherited
942
+ # and configured aliases before assigning the actual isolated target.
943
+ env = {key: value for key, value in env.items() if key.upper() not in {"DSH_HOME", "DSH_PROFILE"}}
944
+ env["DSH_HOME"] = str(context.dsh_home)
945
+ env["DSH_PROFILE"] = context.profile_name
859
946
  return env
860
947
 
861
948
  @staticmethod
@@ -1176,6 +1263,9 @@ class SubprocessVerifyAdapter:
1176
1263
  )
1177
1264
  except (OSError, subprocess.TimeoutExpired) as error:
1178
1265
  return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": str(error)}
1266
+ if completed.returncode != 0:
1267
+ return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False,
1268
+ "ownershipError": "netstat failed", "exitCode": completed.returncode}
1179
1269
  port_owner: int | None = None
1180
1270
  for line in completed.stdout.splitlines():
1181
1271
  fields = line.split()
@@ -1205,7 +1295,10 @@ class SubprocessVerifyAdapter:
1205
1295
  restarted = self._run_headless(context, step) if self.mode == "headless" else self._rename(self._spawn_web(context), step)
1206
1296
  evidence = dict(restarted.evidence)
1207
1297
  evidence["stoppedProcess"] = stopped
1208
- if restarted.status == "PASS" and stopped.get("portReleased") is not True:
1298
+ # 已完成的headless命令没有Web端口;不适用不是“释放未知”。
1299
+ no_web_port = (self.mode == "headless" and stopped.get("processExited") is True
1300
+ and stopped.get("portApplicable") is False)
1301
+ if restarted.status == "PASS" and stopped.get("portReleased") is not True and not no_web_port:
1209
1302
  return _step(
1210
1303
  step,
1211
1304
  "FAIL",
@@ -1283,47 +1376,103 @@ class SubprocessVerifyAdapter:
1283
1376
  ),
1284
1377
  )
1285
1378
 
1379
+ @staticmethod
1380
+ def _endpoint_release(url: str) -> dict[str, Any]:
1381
+ """只核查已观察的本地 TCP 端点;拒绝连接才证明监听已释放。"""
1382
+ evidence: dict[str, Any] = {"checked": False, "released": None, "transport": "tcp"}
1383
+ try:
1384
+ parsed = urllib.parse.urlparse(url)
1385
+ address = ipaddress.ip_address(parsed.hostname or "")
1386
+ if not address.is_loopback or parsed.port is None:
1387
+ raise ValueError("only explicit loopback endpoints with a port are checked")
1388
+ evidence.update({"url": f"{parsed.scheme}://{'[' + str(address) + ']' if address.version == 6 else address}:{parsed.port}",
1389
+ "host": str(address), "port": parsed.port})
1390
+ with socket.socket(socket.AF_INET6 if address.version == 6 else socket.AF_INET, socket.SOCK_STREAM) as client:
1391
+ client.settimeout(0.3)
1392
+ code = client.connect_ex((str(address), parsed.port))
1393
+ evidence["socketError"] = code
1394
+ if code == 0:
1395
+ evidence.update(checked=True, released=False)
1396
+ elif code in {errno.ECONNREFUSED, 10061}:
1397
+ evidence.update(checked=True, released=True)
1398
+ else:
1399
+ evidence["reason"] = "连接结果不能证明端点已释放"
1400
+ except (OSError, ValueError) as error:
1401
+ evidence["reason"] = str(error)
1402
+ return evidence
1403
+
1286
1404
  def close(self) -> dict[str, Any]:
1287
1405
  process = self.process
1288
1406
  url = self._url
1289
1407
  if process is None:
1290
- return {"pid": None, "url": url, "processExited": True, "portReleased": True, "reason": "no persistent process"}
1408
+ if self._last_cleanup is not None:
1409
+ return self._last_cleanup
1410
+ completed = self._last_headless is not None
1411
+ return {"pid": None, "url": None, "processExited": True if completed else None,
1412
+ "exitCode": self._last_headless.get("exitCode") if completed else None,
1413
+ "portReleased": None, "portApplicable": False, "endpointsApplicable": False,
1414
+ "endpoints": [], "attempts": [],
1415
+ "reason": "headless subprocess.run completed" if completed else "no runtime process was observed"}
1291
1416
  pid = process.pid
1292
- if process.poll() is None:
1293
- process.terminate()
1417
+ actions: list[str] = []
1418
+ errors: list[dict[str, str]] = []
1419
+ exit_code = None
1420
+ try:
1421
+ if process.poll() is None:
1422
+ actions.append("terminate")
1423
+ process.terminate()
1424
+ try:
1425
+ process.wait(timeout=5)
1426
+ except subprocess.TimeoutExpired:
1427
+ actions.append("kill")
1428
+ process.kill()
1429
+ process.wait(timeout=5)
1430
+ exit_code = process.poll()
1431
+ except Exception as error:
1432
+ errors.append({"operation": "stop-process", "error": str(error), "exception": type(error).__name__})
1294
1433
  try:
1295
- process.wait(timeout=5)
1296
- except subprocess.TimeoutExpired:
1297
- process.kill()
1298
- process.wait(timeout=5)
1299
- exit_code = process.poll()
1300
- port_evidence: dict[str, Any] = {"checked": True, "portOwnerPid": None, "portBelongsToProcess": False}
1301
- port_released = True
1434
+ exit_code = process.poll()
1435
+ except Exception as poll_error:
1436
+ errors.append({"operation": "poll-process", "error": str(poll_error), "exception": type(poll_error).__name__})
1437
+ port_evidence: dict[str, Any] = {"checked": False, "reason": "no observed Web URL"}
1438
+ port_released: bool | None = None
1439
+ endpoints: list[dict[str, Any]] = []
1302
1440
  if self.mode == "web" and url:
1303
1441
  deadline = time.monotonic() + 2.0
1304
1442
  while True:
1305
- port_evidence = self._port_owner_for(url, pid)
1443
+ try:
1444
+ port_evidence = self._port_owner_for(url, pid)
1445
+ except Exception as error:
1446
+ port_evidence = {"checked": False, "ownershipError": str(error)}
1447
+ endpoints = [{"url": url, **self._endpoint_release(url)}]
1306
1448
  if port_evidence.get("checked") is not True:
1307
- port_released = False
1449
+ port_released = None
1308
1450
  break
1309
- if port_evidence.get("portOwnerPid") is None:
1310
- port_released = True
1451
+ port_released = port_evidence.get("portOwnerPid") is None
1452
+ if port_released and endpoints[0].get("released") is True:
1311
1453
  break
1312
1454
  if time.monotonic() >= deadline:
1313
- port_released = False
1314
1455
  break
1315
1456
  time.sleep(0.05)
1457
+ for thread in self._threads:
1458
+ thread.join(timeout=1)
1316
1459
  evidence = {
1317
1460
  "pid": pid,
1318
1461
  "url": url,
1319
1462
  "exitCode": exit_code,
1320
- "processExited": process.poll() is not None,
1463
+ "processExited": True if exit_code is not None else (False if not errors else None),
1321
1464
  "portReleased": port_released,
1322
1465
  "portEvidence": port_evidence,
1466
+ "portApplicable": self.mode == "web", "endpointsApplicable": self.mode == "web",
1467
+ "endpoints": endpoints,
1468
+ "endpointCoverage": "observed-web-url-only; Inspector/PTY/unknown descendants are not inferred",
1469
+ "actions": actions, "errors": errors,
1323
1470
  }
1471
+ self._cleanup_attempts.append(evidence)
1472
+ self._last_cleanup = {**evidence, "attempts": list(self._cleanup_attempts)}
1324
1473
  self.process = None
1325
1474
  self._url = None
1326
- return evidence
1475
+ return self._last_cleanup
1327
1476
 
1328
1477
 
1329
1478
  def load_smoke_contracts(path: str | os.PathLike[str]) -> tuple[SmokeTestContract, ...]: