engineering-process 2.4.0__tar.gz → 2.5.0__tar.gz

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 (102) hide show
  1. {engineering_process-2.4.0/engineering_process.egg-info → engineering_process-2.5.0}/PKG-INFO +19 -1
  2. {engineering_process-2.4.0 → engineering_process-2.5.0}/README.md +18 -0
  3. engineering_process-2.5.0/RELEASE_NOTES.md +17 -0
  4. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/__init__.py +1 -1
  5. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/cli.py +144 -6
  6. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/commands.py +93 -16
  7. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/contracts.py +1 -0
  8. engineering_process-2.5.0/engineering_process/evidence.py +173 -0
  9. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/lifecycle.py +425 -22
  10. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/pr_description.py +226 -0
  11. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/repository.py +31 -8
  12. {engineering_process-2.4.0 → engineering_process-2.5.0/engineering_process.egg-info}/PKG-INFO +19 -1
  13. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process.egg-info/SOURCES.txt +2 -0
  14. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-plan/SKILL.md +10 -0
  15. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-review/SKILL.md +17 -3
  16. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-verify/SKILL.md +29 -5
  17. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/process-improve/SKILL.md +24 -9
  18. {engineering_process-2.4.0 → engineering_process-2.5.0}/pyproject.toml +2 -1
  19. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/run.schema.json +2 -0
  20. engineering_process-2.5.0/schemas/verification-selection.schema.json +72 -0
  21. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_architecture.py +1 -0
  22. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_artifact_standards.py +384 -2
  23. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_automation.py +6 -1
  24. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_cli.py +107 -1
  25. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_commands.py +112 -3
  26. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_contracts.py +1 -0
  27. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_lifecycle.py +453 -11
  28. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_release.py +2 -2
  29. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_repository.py +36 -0
  30. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_review_contexts.py +4 -2
  31. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_skills.py +2 -0
  32. engineering_process-2.4.0/RELEASE_NOTES.md +0 -22
  33. {engineering_process-2.4.0 → engineering_process-2.5.0}/LICENSE +0 -0
  34. {engineering_process-2.4.0 → engineering_process-2.5.0}/MANIFEST.in +0 -0
  35. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/__main__.py +0 -0
  36. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/_supervisor_contract.py +0 -0
  37. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/_supervisor_posix.py +0 -0
  38. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/_supervisor_windows.py +0 -0
  39. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/_windows_job.py +0 -0
  40. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/adoption.py +0 -0
  41. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/artifact_standards.py +0 -0
  42. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/automation_name.py +0 -0
  43. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/distribution.py +0 -0
  44. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/helper_launch.py +0 -0
  45. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/issue.py +0 -0
  46. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/production_engineering.py +0 -0
  47. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/project.py +0 -0
  48. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/publication_compat.py +0 -0
  49. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/release.py +0 -0
  50. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/release_notes.py +0 -0
  51. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/requirements-dev.txt +0 -0
  52. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/requirements-runtime.txt +0 -0
  53. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/review_contexts.py +0 -0
  54. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/skills.py +0 -0
  55. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/source_publication.py +0 -0
  56. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/supervision.py +0 -0
  57. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process.egg-info/dependency_links.txt +0 -0
  58. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process.egg-info/entry_points.txt +0 -0
  59. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process.egg-info/requires.txt +0 -0
  60. {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process.egg-info/top_level.txt +0 -0
  61. {engineering_process-2.4.0 → engineering_process-2.5.0}/process-graph.json +0 -0
  62. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-complete/SKILL.md +0 -0
  63. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-implement/SKILL.md +0 -0
  64. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-start/SKILL.md +0 -0
  65. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/deliver-change/SKILL.md +0 -0
  66. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/production-engineering/SKILL.md +0 -0
  67. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/production-engineering/invariants.json +0 -0
  68. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/standards/automation-name.v1.json +0 -0
  69. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/standards/issue.v1.json +0 -0
  70. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/standards/pull-request.v1.json +0 -0
  71. {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/standards/release-notes.v1.json +0 -0
  72. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/artifact-selection.schema.json +0 -0
  73. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/artifact-standard.schema.json +0 -0
  74. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/automation-name-data.schema.json +0 -0
  75. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/change.schema.json +0 -0
  76. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/issue-data.schema.json +0 -0
  77. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/plan.schema.json +0 -0
  78. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/pr-description-data.schema.json +0 -0
  79. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/process-graph.schema.json +0 -0
  80. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/process-lock.schema.json +0 -0
  81. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/production-engineering.schema.json +0 -0
  82. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/project-legacy.schema.json +0 -0
  83. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/project.schema.json +0 -0
  84. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/receipt.schema.json +0 -0
  85. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/release-change.schema.json +0 -0
  86. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/release-notes-data.schema.json +0 -0
  87. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/release.schema.json +0 -0
  88. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/renovate-preset.schema.json +0 -0
  89. {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/review.schema.json +0 -0
  90. {engineering_process-2.4.0 → engineering_process-2.5.0}/setup.cfg +0 -0
  91. {engineering_process-2.4.0 → engineering_process-2.5.0}/templates/AGENTS.process.md +0 -0
  92. {engineering_process-2.4.0 → engineering_process-2.5.0}/templates/PULL_REQUEST_TEMPLATE.md +0 -0
  93. {engineering_process-2.4.0 → engineering_process-2.5.0}/templates/adopt-process-windows-job.py +0 -0
  94. {engineering_process-2.4.0 → engineering_process-2.5.0}/templates/adopt-process.py +0 -0
  95. {engineering_process-2.4.0 → engineering_process-2.5.0}/templates/renovate.json +0 -0
  96. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_adoption.py +0 -0
  97. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_distribution.py +0 -0
  98. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_production_engineering.py +0 -0
  99. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_publication_compat.py +0 -0
  100. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_publication_workflow.py +0 -0
  101. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_pypi_cache.py +0 -0
  102. {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_supervisor_posix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engineering-process
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: A small agent-neutral engineering lifecycle with managed adoption
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/phuongnse/engineering-process
@@ -86,6 +86,16 @@ but cannot establish native conversation freshness or inspect removed history,
86
86
  other clones or other repositories. Keep native creation, non-inherited dispatch
87
87
  and effective settings evidence in the existing handoff.
88
88
 
89
+ Verification keeps two caller intents explicit. `change verify --profile PROFILE`
90
+ always refreshes the named profile. `change explain` is read-only; it shows the
91
+ accepted required profiles, valid prior evidence, remaining work, inapplicable
92
+ optional profiles, and blocked or unknown decisions. `change verify --remaining`
93
+ executes only remaining work and reuses a complete profile report only when the
94
+ candidate, contract/plan, project policy, process authority, runtime, and bounded
95
+ child environment match. Legacy evidence without that identity reruns. This is
96
+ whole-profile reuse: equal check IDs and ordered side effects are never merged, and
97
+ the lifecycle receipt still records only actual executions.
98
+
89
99
  `processctl change review replace-reused` repairs only an initial pending assignment
90
100
  proven to reuse another change's agent context, before a submitted review or normal
91
101
  report file exists. It validates a fresh replacement and current verification,
@@ -502,6 +512,14 @@ Register implementation and run every required profile:
502
512
  processctl change verify --change-id change-123 --profile development
503
513
  processctl change verify --change-id change-123 --profile review
504
514
 
515
+ When continuing an incomplete verification, inspect and execute the necessary set:
516
+
517
+ processctl change explain --change-id change-123
518
+ processctl change verify --change-id change-123 --remaining
519
+
520
+ The explicit `--profile` form remains a refresh and is never silently converted to
521
+ reuse.
522
+
505
523
  Assign an independent reviewer and submit its report:
506
524
 
507
525
  processctl change review start \
@@ -62,6 +62,16 @@ but cannot establish native conversation freshness or inspect removed history,
62
62
  other clones or other repositories. Keep native creation, non-inherited dispatch
63
63
  and effective settings evidence in the existing handoff.
64
64
 
65
+ Verification keeps two caller intents explicit. `change verify --profile PROFILE`
66
+ always refreshes the named profile. `change explain` is read-only; it shows the
67
+ accepted required profiles, valid prior evidence, remaining work, inapplicable
68
+ optional profiles, and blocked or unknown decisions. `change verify --remaining`
69
+ executes only remaining work and reuses a complete profile report only when the
70
+ candidate, contract/plan, project policy, process authority, runtime, and bounded
71
+ child environment match. Legacy evidence without that identity reruns. This is
72
+ whole-profile reuse: equal check IDs and ordered side effects are never merged, and
73
+ the lifecycle receipt still records only actual executions.
74
+
65
75
  `processctl change review replace-reused` repairs only an initial pending assignment
66
76
  proven to reuse another change's agent context, before a submitted review or normal
67
77
  report file exists. It validates a fresh replacement and current verification,
@@ -478,6 +488,14 @@ Register implementation and run every required profile:
478
488
  processctl change verify --change-id change-123 --profile development
479
489
  processctl change verify --change-id change-123 --profile review
480
490
 
491
+ When continuing an incomplete verification, inspect and execute the necessary set:
492
+
493
+ processctl change explain --change-id change-123
494
+ processctl change verify --change-id change-123 --remaining
495
+
496
+ The explicit `--profile` form remains a refresh and is never silently converted to
497
+ reuse.
498
+
481
499
  Assign an independent reviewer and submit its report:
482
500
 
483
501
  processctl change review start \
@@ -0,0 +1,17 @@
1
+ # Engineering Process v2.5.0
2
+
3
+ Changes since v2.4.0.
4
+
5
+ ## Features
6
+
7
+ - Resolve process issues \#197\-\#205\: durable feedback and review recovery\, snapshot and test cost\, guidance authority\, PR evidence\, adoption scope\, CI repetition\, and necessary verification\. ([#205](https://github.com/phuongnse/engineering-process/issues/205))
8
+
9
+ ## Upgrade and compatibility
10
+
11
+ Merge the complete hash-locked package/adoption PR, update the local and CI environments to the selected version, and start a fresh agent session.
12
+
13
+ Consumer CI, naming conventions and branch-protection settings remain consumer-owned; adoption does not configure them automatically.
14
+
15
+ See [versioning and compatibility](https://github.com/phuongnse/engineering-process/blob/v2.5.0/VERSIONING.md) and [adoption guidance](https://github.com/phuongnse/engineering-process/blob/v2.5.0/SELF_HOSTING.md).
16
+
17
+ [Full change comparison](https://github.com/phuongnse/engineering-process/compare/v2.4.0...v2.5.0)
@@ -1,3 +1,3 @@
1
1
  """Agent-neutral engineering process."""
2
2
 
3
- VERSION = "2.4.0"
3
+ VERSION = "2.5.0"
@@ -38,6 +38,8 @@ from .lifecycle import (
38
38
  start_change,
39
39
  start_review,
40
40
  submit_review,
41
+ resolve_verification_work,
42
+ verify_remaining,
41
43
  verify_change,
42
44
  )
43
45
  from .issue import render_issue
@@ -53,7 +55,13 @@ from .publication_compat import (
53
55
  validate_range,
54
56
  )
55
57
  from .release import validate_release
56
- from .pr_description import body_issues, render_description, render_renovate_preset, render_template
58
+ from .pr_description import (
59
+ body_issues,
60
+ build_pr_description_data,
61
+ render_description,
62
+ render_renovate_preset,
63
+ render_template,
64
+ )
57
65
  from .release_notes import render_notes
58
66
  from .repository import repository_snapshot, same_checkpoint
59
67
  from .skills import validate_skills
@@ -87,9 +95,16 @@ def _result(command: str, status: str = "passed", **details: Any) -> dict[str, A
87
95
  return {"command": command, "status": status, **details}
88
96
 
89
97
 
90
- def _state_result(command: str, state: dict[str, Any], **details: Any) -> dict[str, Any]:
98
+ def _state_result(
99
+ command: str,
100
+ state: dict[str, Any],
101
+ *,
102
+ status: str = "passed",
103
+ **details: Any,
104
+ ) -> dict[str, Any]:
91
105
  return _result(
92
106
  command,
107
+ status=status,
93
108
  changeId=state["changeId"],
94
109
  phase=state["phase"],
95
110
  cycle=state["cycle"],
@@ -312,6 +327,54 @@ def command_change_implement(args: argparse.Namespace) -> Result:
312
327
  def command_change_verify(args: argparse.Namespace) -> Result:
313
328
  process_root = _process_root(args)
314
329
  project = load_project(args.project_root, process_root)
330
+ if args.remaining:
331
+ state, selection = verify_remaining(
332
+ args.project_root, process_root, project, args.change_id
333
+ )
334
+ executions = []
335
+ failures = []
336
+ for profile in selection["executeProfiles"]:
337
+ report = state["verification"].get(profile)
338
+ if report is None:
339
+ failures.append(profile)
340
+ continue
341
+ if report["status"] != "passed":
342
+ failures.append(profile)
343
+ check_duration = sum(check["durationMs"] for check in report["checks"])
344
+ executions.append(
345
+ {
346
+ "profile": profile,
347
+ "status": report["status"],
348
+ "launchCount": len(report["checks"]),
349
+ "durationMs": report.get("durationMs", check_duration),
350
+ "checkDurationMs": check_duration,
351
+ "processOverheadMs": max(
352
+ 0, report.get("durationMs", check_duration) - check_duration
353
+ ),
354
+ }
355
+ )
356
+ contract = state.get("contract", {})
357
+ required_profiles = (
358
+ contract.get("document", {}).get("requiredProfiles", [])
359
+ if isinstance(contract, dict)
360
+ else []
361
+ )
362
+ verification = state.get("verification", {})
363
+ required_profiles_passed = bool(required_profiles) and all(
364
+ isinstance(verification.get(profile), dict)
365
+ and verification[profile].get("status") == "passed"
366
+ for profile in required_profiles
367
+ )
368
+ complete = state["phase"] == "verified" and required_profiles_passed
369
+ return _state_result(
370
+ "change verify",
371
+ state,
372
+ status="passed" if complete and not failures else "failed",
373
+ selection=selection,
374
+ execution="remaining",
375
+ executions=executions,
376
+ failures=failures,
377
+ ), (0 if complete and not failures else 1)
315
378
  state, report = verify_change(
316
379
  args.project_root, process_root, project, args.change_id, args.profile
317
380
  )
@@ -321,6 +384,15 @@ def command_change_verify(args: argparse.Namespace) -> Result:
321
384
  ), code
322
385
 
323
386
 
387
+ def command_change_explain(args: argparse.Namespace) -> Result:
388
+ process_root = _process_root(args)
389
+ project = load_project(args.project_root, process_root)
390
+ selection = resolve_verification_work(
391
+ args.project_root, process_root, project, args.change_id
392
+ )
393
+ return _result("change explain", selection=selection), 0
394
+
395
+
324
396
  def command_change_review_start(args: argparse.Namespace) -> Result:
325
397
  process_root = _process_root(args)
326
398
  state = start_review(
@@ -434,13 +506,37 @@ def command_artifact(args: argparse.Namespace) -> Result:
434
506
  f"{standard.document['adapter']} state must be "
435
507
  + " or ".join(sorted(allowed_states))
436
508
  )
437
- data = load_and_validate(args.data_file, standard.document["adapter"] + "-data", schema_root=schemas_root(process_root)) if args.data_file is not None else None
509
+ if getattr(args, "change_id", None) is not None and standard.document["adapter"] == "pr-description":
510
+ overrides = (
511
+ load_and_validate(
512
+ args.data_file,
513
+ "pr-description-data",
514
+ schema_root=schemas_root(process_root),
515
+ )
516
+ if args.data_file is not None
517
+ else None
518
+ )
519
+ data = build_pr_description_data(
520
+ args.project_root,
521
+ process_root,
522
+ args.change_id,
523
+ overrides=overrides,
524
+ standard=standard,
525
+ )
526
+ elif args.data_file is not None:
527
+ data = load_and_validate(
528
+ args.data_file,
529
+ standard.document["adapter"] + "-data",
530
+ schema_root=schemas_root(process_root),
531
+ )
532
+ else:
533
+ data = None
438
534
  if data is not None:
439
535
  renderer = {"pr-description": render_description, "release-notes": render_notes, "automation-name": render_name, "issue": render_issue}[standard.document["adapter"]]
440
536
  payload = renderer(standard, data, state=state, process_root=process_root).encode("utf-8")
441
537
  details["dataDigest"] = digest_json(data)
442
538
  elif operation == "render" or standard.document["adapter"] != "pr-description":
443
- raise ProcessError("this artifact operation requires --data-file")
539
+ raise ProcessError("this artifact operation requires --data-file or --change-id")
444
540
  if operation == "validate":
445
541
  actual = read_document(args.body_file)
446
542
  if data is None:
@@ -460,6 +556,38 @@ def command_artifact(args: argparse.Namespace) -> Result:
460
556
  return _result(f"artifact {operation}", status, **details, issues=issues), (1 if issues else 0)
461
557
 
462
558
 
559
+ def command_artifact_prepare_pr_data(args: argparse.Namespace) -> Result:
560
+ process_root = _process_root(args)
561
+ standard = resolve_standard(args.project_root, process_root, "pull-request")
562
+ overrides = (
563
+ load_and_validate(
564
+ args.data_file,
565
+ "pr-description-data",
566
+ schema_root=schemas_root(process_root),
567
+ )
568
+ if args.data_file is not None
569
+ else None
570
+ )
571
+ data = build_pr_description_data(
572
+ args.project_root,
573
+ process_root,
574
+ args.change_id,
575
+ overrides=overrides,
576
+ standard=standard,
577
+ )
578
+ payload = formatted_json_bytes(data)
579
+ details: dict[str, Any] = {
580
+ "changeId": args.change_id,
581
+ "dataDigest": digest_json(data),
582
+ }
583
+ if args.output is not None:
584
+ args.output.write_bytes(payload)
585
+ details["output"] = str(args.output)
586
+ else:
587
+ details["data"] = data
588
+ return _result("artifact prepare-pr-data", "passed", **details), 0
589
+
590
+
463
591
  def _add_common(parser: argparse.ArgumentParser, *, project: bool = True) -> None:
464
592
  if project:
465
593
  parser.add_argument("--project-root", type=_root, default=Path.cwd())
@@ -536,7 +664,12 @@ def build_parser() -> argparse.ArgumentParser:
536
664
  change_implement.add_argument("--change-id", required=True)
537
665
  change_verify = _leaf(change_commands, "verify", command_change_verify)
538
666
  change_verify.add_argument("--change-id", required=True)
539
- change_verify.add_argument("--profile", required=True)
667
+ verify_scope = change_verify.add_mutually_exclusive_group(required=True)
668
+ verify_scope.add_argument("--profile")
669
+ verify_scope.add_argument("--remaining", action="store_true")
670
+
671
+ change_explain = _leaf(change_commands, "explain", command_change_explain)
672
+ change_explain.add_argument("--change-id", required=True)
540
673
 
541
674
  review = change_commands.add_parser("review")
542
675
  review_commands = review.add_subparsers(dest="review_command", required=True)
@@ -564,12 +697,17 @@ def build_parser() -> argparse.ArgumentParser:
564
697
 
565
698
  artifact = commands.add_parser("artifact", help="Generate and verify consumer-selected artifact standards")
566
699
  artifact_commands = artifact.add_subparsers(dest="artifact_command", required=True)
700
+ prepare_pr_data = _leaf(artifact_commands, "prepare-pr-data", command_artifact_prepare_pr_data)
701
+ prepare_pr_data.add_argument("--change-id", required=True)
702
+ prepare_pr_data.add_argument("--data-file", type=Path)
703
+ prepare_pr_data.add_argument("--output", type=Path)
567
704
  for name in ("show", "template", "renovate-preset", "render", "validate"):
568
705
  leaf = _leaf(artifact_commands, name, command_artifact)
569
706
  leaf.add_argument("--artifact", required=True)
570
707
  if name in {"render", "validate"}:
571
708
  leaf.add_argument("--state", choices=("draft", "ready", "open", "closed"))
572
- leaf.add_argument("--data-file", type=Path, required=name == "render")
709
+ leaf.add_argument("--change-id")
710
+ leaf.add_argument("--data-file", type=Path, required=False)
573
711
  if name == "validate":
574
712
  leaf.add_argument("--body-file", type=Path, required=True)
575
713
  else:
@@ -3,39 +3,113 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import hashlib
6
+ from contextlib import contextmanager
6
7
  import os
7
8
  from pathlib import Path
8
9
  import sys
9
10
  import threading
10
11
  import time
11
- from typing import Any, BinaryIO
12
+ from typing import Any, BinaryIO, Iterator
12
13
 
13
14
  from .contracts import ProcessError
15
+ from .evidence import child_environment, execution_identity as _execution_identity
14
16
  from .supervision import process_supervisor
15
17
 
16
18
 
17
19
  DEFAULT_OUTPUT_BYTES = 1_000_000
18
20
  TERMINATION_SECONDS = 2
19
- SECRET_MARKERS = ("TOKEN", "SECRET", "PASSWORD", "PASSWD", "API_KEY", "PRIVATE_KEY")
20
21
  _EXECUTION_LOCK = threading.Lock()
21
22
 
22
23
 
23
24
  def _child_environment() -> dict[str, str]:
24
- environment: dict[str, str] = {}
25
- for name, value in os.environ.items():
26
- upper = name.upper()
27
- if name in {"PYTHONHOME", "PYTHONPATH"}:
28
- continue
29
- if any(marker in upper for marker in SECRET_MARKERS):
30
- continue
31
- environment[name] = value
32
- runtime_directory = str(Path(sys.executable).absolute().parent)
33
- inherited_path = environment.get("PATH", "")
34
- environment["PATH"] = os.pathsep.join(
35
- entry for entry in (runtime_directory, inherited_path) if entry
25
+ return child_environment(executable=sys.executable)
26
+
27
+
28
+ def execution_identity() -> dict[str, Any]:
29
+ """Return the bounded runtime inputs used to launch consumer checks."""
30
+ return _execution_identity(executable=sys.executable)
31
+
32
+
33
+ _LOCK_OWNERS = threading.local()
34
+
35
+
36
+ @contextmanager
37
+ def verification_lock(path: Path) -> Iterator[None]:
38
+ """Prevent concurrent lifecycle requests from dispatching verification twice."""
39
+ lock_path = (
40
+ path.parent / ".verification.lock"
41
+ if os.name == "nt"
42
+ else path.parent
36
43
  )
37
- environment["PYTHONUNBUFFERED"] = "1"
38
- return environment
44
+ resolved = lock_path.resolve()
45
+ holders = getattr(_LOCK_OWNERS, "holders", None)
46
+ if holders is None:
47
+ holders = _LOCK_OWNERS.holders = {}
48
+
49
+ if resolved in holders:
50
+ holders[resolved] += 1
51
+ try:
52
+ yield
53
+ finally:
54
+ holders[resolved] -= 1
55
+ if holders[resolved] == 0:
56
+ del holders[resolved]
57
+ return
58
+
59
+ if os.name == "nt":
60
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
61
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0)
62
+ flags |= getattr(os, "O_NOFOLLOW", 0)
63
+ else:
64
+ # Lock the run directory itself so replacing a profile lock entry cannot
65
+ # create a second lock inode while this operation is active.
66
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
67
+ try:
68
+ descriptor = os.open(lock_path, flags, 0o600)
69
+ except OSError as error:
70
+ raise ProcessError(f"cannot open verification lock: {error}") from error
71
+ locked = False
72
+ try:
73
+ if os.name == "nt":
74
+ opened = os.fstat(descriptor)
75
+ current = os.stat(lock_path)
76
+ if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
77
+ raise ProcessError("verification lock was replaced while opening")
78
+ import msvcrt
79
+ if os.fstat(descriptor).st_size == 0:
80
+ os.write(descriptor, b"0")
81
+ os.lseek(descriptor, 0, os.SEEK_SET)
82
+ msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
83
+ else:
84
+ import fcntl
85
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
86
+ locked = True
87
+ if os.name == "nt":
88
+ current = os.stat(lock_path)
89
+ if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
90
+ raise ProcessError("verification lock was replaced while acquiring")
91
+ except OSError as error:
92
+ os.close(descriptor)
93
+ raise ProcessError("verification for this profile is already running") from error
94
+ except ProcessError:
95
+ os.close(descriptor)
96
+ raise
97
+ holders[resolved] = 1
98
+ try:
99
+ yield
100
+ finally:
101
+ del holders[resolved]
102
+ if locked:
103
+ try:
104
+ if os.name == "nt":
105
+ import msvcrt
106
+ os.lseek(descriptor, 0, os.SEEK_SET)
107
+ msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
108
+ else:
109
+ import fcntl
110
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
111
+ finally:
112
+ os.close(descriptor)
39
113
 
40
114
 
41
115
  class _OutputBudget:
@@ -221,6 +295,7 @@ def run_profile(
221
295
  )
222
296
  checks = [configured_checks[check_position - 1]]
223
297
  positions = [check_position]
298
+ started = time.monotonic()
224
299
  reports: list[dict[str, Any]] = []
225
300
  failed_position: int | None = None
226
301
  for position, check in zip(positions, checks, strict=True):
@@ -234,6 +309,7 @@ def run_profile(
234
309
  "status": "passed" if len(reports) == len(checks) and all(
235
310
  report["status"] == "passed" for report in reports
236
311
  ) else "failed",
312
+ "durationMs": 0,
237
313
  "scope": (
238
314
  {"kind": "profile"}
239
315
  if check_position is None
@@ -245,6 +321,7 @@ def run_profile(
245
321
  ),
246
322
  "checks": reports,
247
323
  }
324
+ result["durationMs"] = int((time.monotonic() - started) * 1000)
248
325
  failed = next((report for report in reports if report["status"] == "failed"), None)
249
326
  if failed is not None:
250
327
  assert failed_position is not None
@@ -36,6 +36,7 @@ CONTRACT_KINDS = (
36
36
  "renovate-preset",
37
37
  "review",
38
38
  "run",
39
+ "verification-selection",
39
40
  )
40
41
 
41
42
 
@@ -0,0 +1,173 @@
1
+ """Shared runtime and lifecycle evidence identity helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import metadata
6
+ import hashlib
7
+ import os
8
+ import platform
9
+ from pathlib import Path
10
+ import sys
11
+ from typing import Any, Mapping
12
+
13
+ from . import VERSION
14
+ from .contracts import digest_json
15
+ from .distribution import distribution_digest
16
+ from .repository import same_checkpoint
17
+
18
+
19
+ SECRET_MARKERS = ("TOKEN", "SECRET", "PASSWORD", "PASSWD", "API_KEY", "PRIVATE_KEY")
20
+
21
+
22
+ def child_environment(
23
+ *,
24
+ executable: str | Path | None = None,
25
+ source: Mapping[str, str] | None = None,
26
+ ) -> dict[str, str]:
27
+ """Project the exact sanitized environment used by consumer checks."""
28
+ environment: dict[str, str] = {}
29
+ for name, value in (os.environ if source is None else source).items():
30
+ upper = name.upper()
31
+ if name in {"PYTHONHOME", "PYTHONPATH"}:
32
+ continue
33
+ if any(marker in upper for marker in SECRET_MARKERS):
34
+ continue
35
+ # Empty bindings remain meaningful inputs to a consumer command.
36
+ environment[name] = value
37
+ runtime_executable = Path(
38
+ sys.executable if executable is None else executable
39
+ ).absolute()
40
+ runtime_directory = str(runtime_executable.parent)
41
+ inherited_path = environment.get("PATH", "")
42
+ path_entries = [entry for entry in inherited_path.split(os.pathsep) if entry]
43
+ runtime_is_first = bool(path_entries) and (
44
+ os.path.normcase(os.path.normpath(path_entries[0]))
45
+ == os.path.normcase(os.path.normpath(runtime_directory))
46
+ )
47
+ if not runtime_is_first:
48
+ path_entries.insert(0, runtime_directory)
49
+ environment["PATH"] = os.pathsep.join(path_entries)
50
+ environment["PYTHONUNBUFFERED"] = "1"
51
+ return environment
52
+
53
+
54
+ def execution_identity(
55
+ *,
56
+ executable: str | Path | None = None,
57
+ source: Mapping[str, str] | None = None,
58
+ ) -> dict[str, Any]:
59
+ """Return the runtime inputs used to launch consumer checks."""
60
+ try:
61
+ # Keep duplicate distribution records: multiplicity is part of the
62
+ # environment identity, even when records have the same display name.
63
+ dependencies = sorted(
64
+ f"{distribution.name}=={distribution.version}@"
65
+ f"{Path(distribution.locate_file('')).resolve()}"
66
+ for distribution in metadata.distributions(path=_metadata_search_path())
67
+ )
68
+ except Exception:
69
+ dependency_identity: dict[str, Any] = {"known": False}
70
+ else:
71
+ dependency_identity = {
72
+ "known": True,
73
+ "count": len(dependencies),
74
+ "digest": "sha256:" + hashlib.sha256(
75
+ "\n".join(dependencies).encode("utf-8")
76
+ ).hexdigest(),
77
+ }
78
+ runtime_executable = Path(
79
+ sys.executable if executable is None else executable
80
+ ).absolute()
81
+ return {
82
+ "executable": str(runtime_executable),
83
+ "python": sys.version,
84
+ "platform": platform.platform(),
85
+ "environment": child_environment(
86
+ executable=runtime_executable, source=source
87
+ ),
88
+ "dependencies": dependency_identity,
89
+ }
90
+
91
+
92
+ def _metadata_search_path() -> list[str]:
93
+ paths: list[str] = []
94
+ seen: set[str] = set()
95
+ for entry in sys.path:
96
+ value = os.fspath(entry) if entry else os.curdir
97
+ key = os.path.normcase(str(Path(value).resolve()))
98
+ if key in seen:
99
+ continue
100
+ seen.add(key)
101
+ paths.append(value)
102
+ return paths
103
+
104
+
105
+ def verification_input_digest(
106
+ project_root: Path,
107
+ process_root: Path,
108
+ project: dict[str, Any],
109
+ state: dict[str, Any],
110
+ profile: str,
111
+ *,
112
+ runtime: dict[str, Any] | None = None,
113
+ ) -> str | None:
114
+ """Bind reusable evidence to every input controlled by this process."""
115
+ runtime = runtime if runtime is not None else execution_identity()
116
+ dependencies = runtime.get("dependencies")
117
+ if not isinstance(dependencies, Mapping) or dependencies.get("known") is not True:
118
+ return None
119
+ return digest_json({
120
+ "authority": {
121
+ "version": VERSION,
122
+ "distribution": distribution_digest(process_root),
123
+ },
124
+ "project": project,
125
+ "contractDigest": state["contract"]["digest"],
126
+ "planDigest": state["plan"]["digest"] if state.get("plan") else None,
127
+ "comparisonBaseCommit": state.get("comparisonBaseCommit"),
128
+ "profile": profile,
129
+ "projectRoot": str(project_root.resolve()),
130
+ "runtime": runtime,
131
+ "host": platform.node(),
132
+ "pythonImplementation": sys.implementation.name,
133
+ })
134
+
135
+
136
+ def verification_report_matches_inputs(
137
+ project_root: Path,
138
+ process_root: Path,
139
+ project: dict[str, Any],
140
+ state: dict[str, Any],
141
+ profile: str,
142
+ report: dict[str, Any],
143
+ checkpoint: dict[str, Any],
144
+ *,
145
+ require_input: bool,
146
+ require_explicit_scope: bool = False,
147
+ runtime: dict[str, Any] | None = None,
148
+ ) -> bool:
149
+ """Return whether one report is reusable for the supplied inputs."""
150
+ scope = report.get("scope")
151
+ if scope is None:
152
+ if require_explicit_scope:
153
+ return False
154
+ scope = {"kind": "profile"}
155
+ if (
156
+ report.get("profile") != profile
157
+ or report.get("status") != "passed"
158
+ or scope != {"kind": "profile"}
159
+ or not same_checkpoint(report.get("checkpoint", {}), checkpoint)
160
+ ):
161
+ return False
162
+ recorded = report.get("inputDigest")
163
+ if recorded is None:
164
+ return not require_input
165
+ current = verification_input_digest(
166
+ project_root,
167
+ process_root,
168
+ project,
169
+ state,
170
+ profile,
171
+ runtime=runtime,
172
+ )
173
+ return current is not None and recorded == current