diff --git a/ROADMAP.md b/ROADMAP.md index d8e6486..4c878a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -535,6 +535,11 @@ The `xyflow` no-op refresh decision is now captured as a checked example fixture under `tests/fixtures/refresh_decisions/`, giving future tooling a stable artifact for `updateNeeded: false` without making no-op records registry authority. +SpecPM can now prepare refresh decision records from a fresh generated candidate +tree with `specpm producer-bundle prepare-refresh-decision`, compare +contract-bearing generated files, and emit read-only +`SpecPMGeneratedCandidateRefreshDecisionPrepareReport` evidence before any +maintainer registry decision. SpecPM now has a consumer-side `preflight-ai-draft` gate for `SpecHarvesterPackageSetAIDraftProposal`. It verifies AI-proposed member selection, exclusions, and `contains` relations against deterministic workspace diff --git a/Sources/SpecPM/Documentation.docc/CLIReference.md b/Sources/SpecPM/Documentation.docc/CLIReference.md index 20544ae..cfe4723 100644 --- a/Sources/SpecPM/Documentation.docc/CLIReference.md +++ b/Sources/SpecPM/Documentation.docc/CLIReference.md @@ -188,6 +188,15 @@ is provided. The report kind is review evidence only and does not mutate accepted packages, generated candidates, relations, or registry metadata. +`prepare-refresh-decision` builds a draft +`SpecPMGeneratedCandidateRefreshDecision` from a fresh generated artifact tree +and current registry evidence. It compares `specpm.yaml` and +`specs/*.spec.yaml`, writes the decision JSON with `--output` when requested, +and emits `SpecPMGeneratedCandidateRefreshDecisionPrepareReport`. It can produce +`no_update_required` for byte-identical generated contract files or +`manual_review_required` when contract drift needs maintainer review. It is +read-only and does not mutate registry artifacts. + `materialize-package-set` consumes the same package-set handoff only after maintainers pass explicit `--package` and optional `--relation` selections. In dry-run mode it writes a materialization report, accepted-manifest candidate, diff --git a/Sources/SpecPM/Documentation.docc/GeneratedCandidateRefreshDecisionPolicy.md b/Sources/SpecPM/Documentation.docc/GeneratedCandidateRefreshDecisionPolicy.md index 558f268..4d54d0a 100644 --- a/Sources/SpecPM/Documentation.docc/GeneratedCandidateRefreshDecisionPolicy.md +++ b/Sources/SpecPM/Documentation.docc/GeneratedCandidateRefreshDecisionPolicy.md @@ -91,6 +91,38 @@ A complete example fixture is available at and snapshots generated contract-file digests that support `updateNeeded: false`. +## Prepare Helper + +SpecPM can prepare a draft refresh decision by comparing fresh generated +candidate artifacts with the current registry evidence: + +```bash +specpm producer-bundle prepare-refresh-decision \ + --root . \ + --fresh-generated-root \ + --package xyflow.workspace \ + --package xyflow.react \ + --package xyflow.svelte \ + --package xyflow.system \ + --package-id xyflow.workspace \ + --version 0.1.0 \ + --source-repository https://github.com/xyflow/xyflow \ + --source-revision <40-char-source-sha> \ + --output refresh-decision.json \ + --json +``` + +The command emits `SpecPMGeneratedCandidateRefreshDecisionPrepareReport` and can +write the prepared `SpecPMGeneratedCandidateRefreshDecision` with `--output`. +It compares only contract-bearing generated files (`specpm.yaml` and +`specs/*.spec.yaml`), records current generated contract-file SHA-256 digests, +and runs the same consumer-side preflight rules against the prepared decision. + +Matching contract files produce `status: no_update_required`, +`updateNeeded: false`, and `reason: no_contract_delta`. Contract, source +revision, or accepted-artifact drift produces `manual_review_required` with +`updateNeeded: true`. Both outcomes remain review evidence only. + ## Consumer-Side Preflight SpecPM can verify refresh decision records with: diff --git a/Sources/SpecPM/Documentation.docc/ProducerBundleProposalPolicy.md b/Sources/SpecPM/Documentation.docc/ProducerBundleProposalPolicy.md index 84ca5bc..96306d5 100644 --- a/Sources/SpecPM/Documentation.docc/ProducerBundleProposalPolicy.md +++ b/Sources/SpecPM/Documentation.docc/ProducerBundleProposalPolicy.md @@ -197,6 +197,28 @@ The command verifies the `specpm.decisions/v0` decision envelope, authority boundary, safe artifact paths, and generated contract-file digests. It is review evidence only and does not perform registry acceptance or mutation. +When maintainers have a fresh generated candidate tree and need to decide +whether it changes current registry evidence, SpecPM can prepare the decision +record first: + +```bash +specpm producer-bundle prepare-refresh-decision \ + --root \ + --fresh-generated-root \ + --package \ + --version \ + --source-revision <40-char-source-sha> \ + --output \ + --json +``` + +The report kind is `SpecPMGeneratedCandidateRefreshDecisionPrepareReport`. The +helper compares contract-bearing generated files, emits a draft +`SpecPMGeneratedCandidateRefreshDecision`, and immediately runs the same +consumer-side preflight checks. It is read-only review evidence and does not +write accepted packages, generated candidates, relations, or public registry +metadata. + ## Fixture Alignment Cross-repository fixture ownership and drift handling are defined in diff --git a/Sources/SpecPM/Documentation.docc/Roadmap.md b/Sources/SpecPM/Documentation.docc/Roadmap.md index bf38e1a..acc4f1c 100644 --- a/Sources/SpecPM/Documentation.docc/Roadmap.md +++ b/Sources/SpecPM/Documentation.docc/Roadmap.md @@ -310,6 +310,11 @@ The `xyflow` no-op refresh decision is now captured as a checked example fixture under `tests/fixtures/refresh_decisions/`, giving future tooling a stable artifact for `updateNeeded: false` without making no-op records registry authority. +SpecPM can now prepare refresh decision records from a fresh generated candidate +tree with `specpm producer-bundle prepare-refresh-decision`, compare +contract-bearing generated files, and emit read-only +`SpecPMGeneratedCandidateRefreshDecisionPrepareReport` evidence before any +maintainer registry decision. SpecPM now has a consumer-side `preflight-ai-draft` gate for `SpecHarvesterPackageSetAIDraftProposal`. It verifies AI-proposed member selection, exclusions, and `contains` relations against deterministic workspace diff --git a/specpm.yaml b/specpm.yaml index 8a96399..ea64027 100644 --- a/specpm.yaml +++ b/specpm.yaml @@ -33,6 +33,7 @@ index: - specpm.registry.curated_accepted_artifacts - specpm.registry.generated_candidate_refresh_decision_policy - specpm.registry.generated_candidate_refresh_decision_preflight + - specpm.registry.generated_candidate_refresh_decision_prepare - specpm.registry.index_removal_request_flow - specpm.registry.namespace_claim_request_flow - specpm.registry.namespace_claim_policy diff --git a/specs/GENERATED_CANDIDATE_REFRESH_DECISION_POLICY.md b/specs/GENERATED_CANDIDATE_REFRESH_DECISION_POLICY.md index 3dde8b3..c0c49aa 100644 --- a/specs/GENERATED_CANDIDATE_REFRESH_DECISION_POLICY.md +++ b/specs/GENERATED_CANDIDATE_REFRESH_DECISION_POLICY.md @@ -221,6 +221,41 @@ records all four `xyflow` package-set members, cites the current curated and generated artifact paths, and snapshots the generated contract-file digests used to justify `updateNeeded: false`. +## Prepare Helper + +SpecPM can prepare a draft refresh decision by comparing a fresh generated +candidate tree with the current registry evidence: + +```bash +specpm producer-bundle prepare-refresh-decision \ + --root . \ + --fresh-generated-root \ + --package xyflow.workspace \ + --package xyflow.react \ + --package xyflow.svelte \ + --package xyflow.system \ + --package-id xyflow.workspace \ + --version 0.1.0 \ + --source-repository https://github.com/xyflow/xyflow \ + --source-revision <40-char-source-sha> \ + --output refresh-decision.json \ + --json +``` + +The command emits `SpecPMGeneratedCandidateRefreshDecisionPrepareReport` and can +write the prepared `SpecPMGeneratedCandidateRefreshDecision` with `--output`. +It compares only contract-bearing generated files (`specpm.yaml` and +`specs/*.spec.yaml`), records current generated contract-file SHA-256 digests, +checks the prepared decision with the same consumer-side preflight rules, and +keeps the output read-only review evidence. + +If fresh and current generated contract files match, the draft decision uses +`status: no_update_required`, `updateNeeded: false`, and +`reason: no_contract_delta`. If contract files, source revisions, or accepted +artifact assumptions differ, the draft uses `manual_review_required` and +`updateNeeded: true`; it still does not mutate curated artifacts, generated +artifacts, relations, or registry metadata. + ## Consumer-Side Preflight SpecPM can verify a generated candidate refresh decision before maintainers use diff --git a/specs/MULTI_PACKAGE_PRODUCER_INTAKE.md b/specs/MULTI_PACKAGE_PRODUCER_INTAKE.md index 239ef01..1773579 100644 --- a/specs/MULTI_PACKAGE_PRODUCER_INTAKE.md +++ b/specs/MULTI_PACKAGE_PRODUCER_INTAKE.md @@ -256,6 +256,12 @@ The full curated artifact lifecycle is documented in - package relation acceptance is recorded separately in `public-index/accepted-packages.yml` `relations[]`. +When a fresh package-set run is available, maintainers can use +`specpm producer-bundle prepare-refresh-decision` to draft the refresh decision +from generated contract-file comparisons before running +`preflight-refresh-decision`. This remains evidence preparation, not package or +relation acceptance. + ## Bundle-Set Checklist Before accepting any part of a multi-package proposal, maintainers should verify: diff --git a/specs/PRODUCER_BUNDLE_PROPOSAL_POLICY.md b/specs/PRODUCER_BUNDLE_PROPOSAL_POLICY.md index 737542d..4e2a8b6 100644 --- a/specs/PRODUCER_BUNDLE_PROPOSAL_POLICY.md +++ b/specs/PRODUCER_BUNDLE_PROPOSAL_POLICY.md @@ -292,6 +292,28 @@ The command verifies the `specpm.decisions/v0` decision envelope, authority boundary, safe artifact paths, and generated contract-file digests. It is review evidence only and does not perform registry acceptance or mutation. +When maintainers have a fresh generated candidate tree and need to decide +whether it changes current registry evidence, SpecPM can prepare the decision +record first: + +```bash +specpm producer-bundle prepare-refresh-decision \ + --root \ + --fresh-generated-root \ + --package \ + --version \ + --source-revision <40-char-source-sha> \ + --output \ + --json +``` + +The report kind is `SpecPMGeneratedCandidateRefreshDecisionPrepareReport`. The +helper compares contract-bearing generated files, emits a draft +`SpecPMGeneratedCandidateRefreshDecision`, and immediately runs the same +consumer-side preflight checks. It is read-only review evidence and does not +write accepted packages, generated candidates, relations, or public registry +metadata. + ## Fixture Alignment Cross-repository fixture ownership and drift handling are defined in diff --git a/specs/PUBLIC_INDEX_OPERATOR_GUIDE.md b/specs/PUBLIC_INDEX_OPERATOR_GUIDE.md index 1362268..6b76880 100644 --- a/specs/PUBLIC_INDEX_OPERATOR_GUIDE.md +++ b/specs/PUBLIC_INDEX_OPERATOR_GUIDE.md @@ -190,6 +190,26 @@ record `status: no_update_required` instead of opening a registry churn PR. Producer receipt churn, local output paths, or a newly emitted advisory quality report are review evidence, not accepted contract deltas by themselves. +When a fresh generated candidate tree is available but no refresh decision has +been written yet, maintainers can prepare a draft record from the current +registry evidence: + +```bash +specpm producer-bundle prepare-refresh-decision \ + --root . \ + --fresh-generated-root \ + --package \ + --version \ + --source-revision <40-char-source-sha> \ + --output \ + --json +``` + +The prepare helper compares generated `specpm.yaml` and `specs/*.spec.yaml` +files, emits `SpecPMGeneratedCandidateRefreshDecisionPrepareReport`, writes the +draft decision with `--output`, and performs the same preflight checks. It is +read-only review evidence. + When a `SpecPMGeneratedCandidateRefreshDecision` record is available, maintainers should run: diff --git a/specs/WORKPLAN.md b/specs/WORKPLAN.md index 334bf1b..856ee6f 100644 --- a/specs/WORKPLAN.md +++ b/specs/WORKPLAN.md @@ -2362,6 +2362,54 @@ Result: operator guidance, DocC, and self-spec document the command and non-authority boundary. +### P66-T20. Generated Candidate Refresh Decision Prepare Helper + +Status: Completed. + +Motivation: + +- P66-T19 can verify an already-written refresh decision, but maintainers still + need a repeatable way to draft that decision from a fresh generated candidate + run. +- Manual fixture editing is too error-prone for package-set refresh review: + paths, package IDs, source revision metadata, and generated contract-file + digests must stay aligned. + +Goal: + +- Add a read-only `specpm producer-bundle prepare-refresh-decision` command that + compares a fresh generated artifact tree with the current + `public-index/generated` evidence. +- Compare contract-bearing generated files (`specpm.yaml` and + `specs/*.spec.yaml`), record current generated SHA-256 digests, and produce a + draft `SpecPMGeneratedCandidateRefreshDecision`. +- Return `no_update_required` when fresh and current contract files match, and + `manual_review_required` when generated contract drift or source revision + drift requires maintainer review. +- Immediately run the existing preflight rules against the prepared decision, + while keeping the helper read-only and non-authoritative. + +Expected result: + +- Maintainers can run prepare against a fresh SpecHarvester output and get both + a machine-readable prepare report and an optional decision JSON file. +- The helper does not mutate accepted packages, generated candidates, accepted + relations, or public registry metadata. +- The prepared decision can be passed directly to + `specpm producer-bundle preflight-refresh-decision`. + +Result: + +- `specpm producer-bundle prepare-refresh-decision` emits + `SpecPMGeneratedCandidateRefreshDecisionPrepareReport`. +- The command supports package-set comparisons through repeated `--package`, + explicit `--package-id`, `--version`, `--source-revision`, and `--output`. +- Regression coverage checks the real `xyflow` no-op prepare path, CLI decision + output, preflight compatibility, and generated contract-delta detection. +- Refresh decision policy, producer bundle policy, public index operator guide, + CLI reference, DocC, roadmap, and self-spec document the helper and + non-authority boundary. + ## Post-MVP Tracks - Remote registry service implementation. diff --git a/specs/specpm.spec.yaml b/specs/specpm.spec.yaml index 29fe5fc..b2fa992 100644 --- a/specs/specpm.spec.yaml +++ b/specs/specpm.spec.yaml @@ -175,6 +175,9 @@ provides: - id: specpm.registry.generated_candidate_refresh_decision_preflight role: secondary summary: Preflight SpecPMGeneratedCandidateRefreshDecision records for decision shape, no-op consistency, authority boundaries, safe paths, and generated contract-file digests. + - id: specpm.registry.generated_candidate_refresh_decision_prepare + role: secondary + summary: Prepare draft SpecPMGeneratedCandidateRefreshDecision records from fresh/current generated artifact comparisons without mutating registry state. - id: specpm.registry.package_set_relation_metadata role: secondary summary: Publish maintainer-reviewed package-set relation metadata through additive `/v0` payloads and the static viewer without inferring relations from producer output. @@ -459,12 +462,16 @@ interfaces: - text/plain - id: cli_producer-bundle kind: cli - summary: specpm producer-bundle preflight, preflight-refresh-decision, and materialize-package-set for consumer-side review of producer evidence, refresh decisions, and maintainer-selected package-set accepted-source artifacts. + summary: specpm producer-bundle preflight, prepare/preflight-refresh-decision, and materialize-package-set for consumer-side review of producer evidence, refresh decisions, and maintainer-selected package-set accepted-source artifacts. outputs: - name: producer_bundle_preflight_report mediaTypes: - application/json - text/plain + - name: refresh_decision_prepare_report + mediaTypes: + - application/json + - text/plain - name: refresh_decision_preflight_report mediaTypes: - application/json @@ -734,6 +741,7 @@ evidence: - specpm.registry.accepted_manifest_pr_helper - specpm.registry.public_index_accepted_manifest - specpm.registry.generated_candidate_refresh_decision_preflight + - specpm.registry.generated_candidate_refresh_decision_prepare - id: add_specpackages_issue_template kind: documentation path: .github/ISSUE_TEMPLATE/add-specpackages.yml @@ -838,6 +846,7 @@ evidence: - specpm.specs.ai_enrichment_preflight - specpm.specs.ai_draft_preflight - specpm.registry.generated_candidate_refresh_decision_preflight + - specpm.registry.generated_candidate_refresh_decision_prepare - id: index_submission_validator_source kind: source path: src/specpm/index_submission.py @@ -1010,6 +1019,7 @@ evidence: - specpm.specs.ai_enrichment_preflight - specpm.specs.ai_draft_preflight - specpm.registry.generated_candidate_refresh_decision_preflight + - specpm.registry.generated_candidate_refresh_decision_prepare - specpm.registry.package_set_materialization_helper - specpm.specs.producer_receipt_contract - producer_receipts_not_generation_authority @@ -1163,6 +1173,7 @@ evidence: supports: - specpm.registry.generated_candidate_refresh_decision_policy - specpm.registry.generated_candidate_refresh_decision_preflight + - specpm.registry.generated_candidate_refresh_decision_prepare - specpm.registry.curated_artifact_lifecycle - specpm.registry.curated_accepted_artifacts - specpm.specs.multi_package_producer_intake @@ -1299,6 +1310,7 @@ evidence: - specpm.registry.observed_intent_catalog - specpm.specs.ai_draft_preflight - specpm.registry.generated_candidate_refresh_decision_preflight + - specpm.registry.generated_candidate_refresh_decision_prepare - id: core_source kind: source path: src/specpm/core.py @@ -1465,6 +1477,7 @@ evidence: - specpm.specs.ai_enrichment_preflight - specpm.specs.ai_draft_preflight - specpm.registry.generated_candidate_refresh_decision_preflight + - specpm.registry.generated_candidate_refresh_decision_prepare - specpm.specs.producer_receipt_contract - id: docc_producer_bundle_fixture_policy kind: documentation @@ -1585,6 +1598,7 @@ evidence: - specpm.documentation.docc_site - specpm.registry.generated_candidate_refresh_decision_policy - specpm.registry.generated_candidate_refresh_decision_preflight + - specpm.registry.generated_candidate_refresh_decision_prepare - specpm.registry.curated_artifact_lifecycle - specpm.registry.curated_accepted_artifacts - specpm.specs.multi_package_producer_intake diff --git a/src/specpm/cli.py b/src/specpm/cli.py index 8de65dd..6ed7020 100644 --- a/src/specpm/cli.py +++ b/src/specpm/cli.py @@ -36,6 +36,7 @@ preflight_package_set_ai_enrichment, preflight_producer_bundle, preflight_refresh_decision, + prepare_refresh_decision, render_package_set_materialization_manifest_candidate, render_package_set_materialization_pr_body, ) @@ -360,6 +361,94 @@ def build_parser() -> argparse.ArgumentParser: ) refresh_decision_preflight.set_defaults(handler=handle_refresh_decision_preflight) + refresh_decision_prepare = producer_bundle_subparsers.add_parser( + "prepare-refresh-decision", + help=( + "Prepare a generated candidate refresh decision from fresh/current generated artifacts." + ), + ) + refresh_decision_prepare.add_argument( + "--root", + default=".", + help="Repository root containing current generated and curated registry artifacts.", + ) + refresh_decision_prepare.add_argument( + "--fresh-generated-root", + required=True, + help=( + "Fresh generated artifact root containing / directories. " + "May be outside --root." + ), + ) + refresh_decision_prepare.add_argument( + "--current-generated-root", + default="public-index/generated", + help="Current generated artifact root under --root.", + ) + refresh_decision_prepare.add_argument( + "--curated-root", + default="public-index/curated", + help="Curated accepted artifact root under --root.", + ) + refresh_decision_prepare.add_argument( + "--package", + dest="packages", + action="append", + required=True, + help="Package ID to compare. May be passed more than once.", + ) + refresh_decision_prepare.add_argument( + "--package-id", + help="Subject package ID for the decision. Defaults to the first --package.", + ) + refresh_decision_prepare.add_argument("--version", required=True, help="Package version.") + refresh_decision_prepare.add_argument( + "--scope", + default="package_set", + help="Decision subject scope. Defaults to package_set.", + ) + refresh_decision_prepare.add_argument( + "--source-revision", + required=True, + help="Fresh upstream source revision as a 40-character commit SHA.", + ) + refresh_decision_prepare.add_argument( + "--source-repository", + default="", + help="Fresh upstream source repository URL.", + ) + refresh_decision_prepare.add_argument( + "--run-label", + default="local-refresh-evaluation", + help="Human-readable label for the fresh generated run.", + ) + refresh_decision_prepare.add_argument( + "--review-location", + default="local-draft", + help="Maintainer review location to embed in the draft decision.", + ) + refresh_decision_prepare.add_argument( + "--decision-by", + default="SpecPM maintainer review", + help="Maintainer review actor label to embed in the draft decision.", + ) + refresh_decision_prepare.add_argument( + "--no-receipt-only-delta", + action="store_true", + help="Do not mark receiptOnlyChanged/supporting reason in no-op output.", + ) + refresh_decision_prepare.add_argument( + "--no-advisory-report-only-delta", + action="store_true", + help="Do not mark advisoryReportOnlyChanged in the comparison.", + ) + refresh_decision_prepare.add_argument( + "--output", + help="Optional path to write the prepared SpecPMGeneratedCandidateRefreshDecision JSON.", + ) + refresh_decision_prepare.add_argument("--json", action="store_true", help="Emit stable JSON.") + refresh_decision_prepare.set_defaults(handler=handle_refresh_decision_prepare) + package_set_materialize = producer_bundle_subparsers.add_parser( "materialize-package-set", help="Prepare maintainer-selected accepted-source artifacts from a package-set handoff.", @@ -753,6 +842,36 @@ def handle_refresh_decision_preflight(args: argparse.Namespace) -> int: return 1 if report["status"] == "failed" else 0 +def handle_refresh_decision_prepare(args: argparse.Namespace) -> int: + report = prepare_refresh_decision( + root=Path(args.root), + fresh_generated_root=Path(args.fresh_generated_root), + current_generated_root=Path(args.current_generated_root), + curated_root=Path(args.curated_root), + package_ids=args.packages, + package_id=args.package_id, + version=args.version, + scope=args.scope, + source_revision=args.source_revision, + source_repository=args.source_repository, + run_label=args.run_label, + review_location=args.review_location, + decision_by=args.decision_by, + receipt_only_changed=not args.no_receipt_only_delta, + advisory_report_only_changed=not args.no_advisory_report_only_delta, + ) + if args.output and report["status"] != "failed": + write_cli_output( + Path(args.output), + json.dumps(report["decision"], indent=2, sort_keys=True) + "\n", + ) + if args.json: + print_json(report) + else: + print_refresh_decision_prepare(report) + return 1 if report["status"] == "failed" else 0 + + def handle_package_set_materialize(args: argparse.Namespace) -> int: report = materialize_package_set_handoff( Path(args.handoff), @@ -848,6 +967,21 @@ def print_refresh_decision_preflight(report: dict[str, Any]) -> None: print(f"warning {issue_payload['code']}: {issue_payload['message']}", file=sys.stderr) +def print_refresh_decision_prepare(report: dict[str, Any]) -> None: + summary = report["summary"] + print( + f"{report['status']}: generated candidate refresh decision prepare " + f"({summary['packageCount']} packages, " + f"{summary['generatedContractFileCount']} contract files, " + f"updateNeeded={summary['updateNeeded']}, " + f"{summary['errorCount']} errors, {summary['warningCount']} warnings)" + ) + for issue_payload in report["errors"]: + print(f"error {issue_payload['code']}: {issue_payload['message']}", file=sys.stderr) + for issue_payload in report["warnings"]: + print(f"warning {issue_payload['code']}: {issue_payload['message']}", file=sys.stderr) + + def print_package_set_materialization(report: dict[str, Any]) -> None: summary = report["summary"] print( diff --git a/src/specpm/producer_bundle.py b/src/specpm/producer_bundle.py index b3ac8f4..a0fb624 100644 --- a/src/specpm/producer_bundle.py +++ b/src/specpm/producer_bundle.py @@ -32,6 +32,8 @@ REFRESH_DECISION_KIND = "SpecPMGeneratedCandidateRefreshDecision" REFRESH_DECISION_PREFLIGHT_KIND = "SpecPMGeneratedCandidateRefreshDecisionPreflightReport" REFRESH_DECISION_PREFLIGHT_SCHEMA_VERSION = 1 +REFRESH_DECISION_PREPARE_KIND = "SpecPMGeneratedCandidateRefreshDecisionPrepareReport" +REFRESH_DECISION_PREPARE_SCHEMA_VERSION = 1 REQUIRED_PRODUCER_EVIDENCE_ROLES = { "accepted_source_bundle", @@ -725,6 +727,344 @@ def preflight_refresh_decision( ) +def prepare_refresh_decision( + *, + root: Path, + fresh_generated_root: Path, + package_ids: list[str], + version: str, + source_revision: str, + source_repository: str | None = None, + package_id: str | None = None, + scope: str = "package_set", + current_generated_root: Path = Path("public-index/generated"), + curated_root: Path = Path("public-index/curated"), + run_label: str = "local-refresh-evaluation", + review_location: str = "local-draft", + decision_by: str = "SpecPM maintainer review", + receipt_only_changed: bool = True, + advisory_report_only_changed: bool = True, +) -> dict[str, Any]: + errors: list[dict[str, Any]] = [] + warnings: list[dict[str, Any]] = [] + root_resolved = root.resolve(strict=False) + package_ids = [value for value in package_ids if isinstance(value, str) and value] + subject_package_id = package_id or (package_ids[0] if package_ids else "") + + if not package_ids: + errors.append( + issue( + "refresh_decision_prepare_packages_missing", + "Prepare refresh decision requires at least one --package.", + field="packageIds", + ) + ) + if not non_empty_string(version): + errors.append( + issue( + "refresh_decision_prepare_version_missing", + "Prepare refresh decision requires --version.", + field="version", + ) + ) + if not non_empty_string(source_revision): + errors.append( + issue( + "refresh_decision_prepare_source_revision_missing", + "Prepare refresh decision requires --source-revision.", + field="sourceRevision", + ) + ) + + accepted_artifacts: list[str] = [] + current_generated_artifacts: list[str] = [] + generated_contract_files: list[dict[str, Any]] = [] + generated_contract_changed = False + accepted_contract_changed = False + source_revision_changed = False + source_revision_evidence_found = False + + for item_index, item_package_id in enumerate(package_ids): + package_field = f"packageIds[{item_index}]" + if not is_safe_relative_path(item_package_id) or "/" in item_package_id: + errors.append( + issue( + "refresh_decision_prepare_package_id_unsafe", + "Package IDs must be safe single path segments for generated refresh compare.", + field=package_field, + ) + ) + continue + + current_package_dir = root_join( + root_resolved, + current_generated_root, + item_package_id, + version, + ) + fresh_package_dir = root_join(root_resolved, fresh_generated_root, item_package_id, version) + curated_package_dir = root_join(root_resolved, curated_root, item_package_id, version) + current_artifact = repo_relative_path(root_resolved, current_package_dir) + accepted_artifact = repo_relative_path(root_resolved, curated_package_dir) + if current_artifact is None: + errors.append( + issue( + "refresh_decision_prepare_current_path_unresolved", + "Current generated package path must resolve within --root.", + field=package_field, + ) + ) + else: + current_generated_artifacts.append(current_artifact) + if accepted_artifact is None: + errors.append( + issue( + "refresh_decision_prepare_curated_path_unresolved", + "Curated package path must resolve within --root.", + field=package_field, + ) + ) + else: + accepted_artifacts.append(accepted_artifact) + + if not curated_package_dir.is_dir(): + accepted_contract_changed = True + warnings.append( + issue( + "refresh_decision_prepare_curated_artifact_missing", + ( + "Curated accepted artifact is missing: " + f"{accepted_artifact or curated_package_dir}." + ), + field=package_field, + ) + ) + + current_contract_candidates = ( + refresh_contract_files(current_package_dir) if current_artifact is not None else [] + ) + current_contracts: list[Path] = [] + for current_contract in current_contract_candidates: + current_contract_repo_path = repo_relative_path(root_resolved, current_contract) + if current_contract_repo_path is None: + errors.append( + issue( + "refresh_decision_prepare_contract_file_path_unresolved", + "Current generated contract file must resolve within --root.", + field=package_field, + ) + ) + continue + current_contracts.append(current_contract) + generated_contract_files.append( + { + "path": current_contract_repo_path, + "sha256": sha256_file(current_contract), + } + ) + fresh_contracts = refresh_contract_files(fresh_package_dir) + if not current_contracts: + errors.append( + issue( + "refresh_decision_prepare_current_contract_files_missing", + ( + "Current generated artifact has no contract files: " + f"{current_artifact or current_package_dir}." + ), + field=package_field, + ) + ) + if not fresh_contracts: + generated_contract_changed = True + errors.append( + issue( + "refresh_decision_prepare_fresh_contract_files_missing", + f"Fresh generated artifact has no contract files: {fresh_package_dir}.", + field=package_field, + ) + ) + + current_relative_paths = { + path.relative_to(current_package_dir) for path in current_contracts + } + fresh_relative_paths = {path.relative_to(fresh_package_dir) for path in fresh_contracts} + if current_relative_paths != fresh_relative_paths: + generated_contract_changed = True + warnings.append( + issue( + "refresh_decision_prepare_contract_file_set_changed", + ( + "Fresh generated contract-file set differs from the current " + "generated artifact." + ), + field=package_field, + ) + ) + + for current_contract in current_contracts: + relative_contract = current_contract.relative_to(current_package_dir) + fresh_contract = fresh_package_dir / relative_contract + if ( + fresh_contract.is_file() + and current_contract.read_bytes() != fresh_contract.read_bytes() + ): + generated_contract_changed = True + + current_source_revisions = source_revisions_from_contracts(current_contracts) + if current_source_revisions: + source_revision_evidence_found = True + if current_source_revisions and any( + value != source_revision for value in current_source_revisions + ): + source_revision_changed = True + + update_needed = bool( + errors or generated_contract_changed or accepted_contract_changed or source_revision_changed + ) + status = "manual_review_required" if update_needed else "no_update_required" + reason = "refresh_prepare_requires_review" if update_needed else "no_contract_delta" + supporting_reasons: list[str] = [] + if not update_needed: + supporting_reasons = [ + "generated_contract_bytes_unchanged", + "curated_artifact_remains_stronger", + "immutable_generated_candidate", + ] + if source_revision_evidence_found: + supporting_reasons.insert(0, "same_source_revision") + if receipt_only_changed: + insert_index = 3 if source_revision_evidence_found else 2 + supporting_reasons.insert(insert_index, "producer_receipt_only_delta") + + decision = { + "apiVersion": REFRESH_DECISION_API_VERSION, + "kind": REFRESH_DECISION_KIND, + "schemaVersion": 1, + "decisionId": refresh_decision_id(subject_package_id, version, source_revision, status), + "requiredFor": ["public_index_refresh_evaluation"], + "subject": { + "packageId": subject_package_id, + "version": version, + "scope": scope, + "packageIds": package_ids, + "acceptedArtifacts": accepted_artifacts, + "currentGeneratedArtifacts": current_generated_artifacts, + "freshGeneratedRun": { + "kind": "local_review_evidence", + "label": run_label, + "sourceRepository": source_repository or "", + "sourceRevision": source_revision, + "summary": refresh_decision_fresh_run_summary(update_needed), + }, + }, + "decision": { + "status": status, + "updateNeeded": update_needed, + "reason": reason, + "supportingReasons": supporting_reasons, + }, + "comparison": { + "sourceRevisionChanged": source_revision_changed, + "acceptedContractChanged": accepted_contract_changed, + "generatedContractChanged": generated_contract_changed, + "capabilitiesChanged": generated_contract_changed, + "relationsChanged": generated_contract_changed, + "evidenceChanged": generated_contract_changed, + "receiptOnlyChanged": receipt_only_changed, + "advisoryReportOnlyChanged": advisory_report_only_changed, + "freshCandidateCount": len(package_ids), + }, + "generatedContractFiles": generated_contract_files, + "authority": { + "producerEvidenceAuthority": "evidence_only", + "registryAuthority": "maintainer_review_required", + "noRegistryMutation": True, + }, + "maintainerReview": { + "decisionBy": decision_by, + "reviewLocation": review_location, + "summary": refresh_decision_review_summary(update_needed), + }, + } + preflight_errors: list[dict[str, Any]] = [] + preflight_warnings: list[dict[str, Any]] = [] + digest_verified_count = validate_refresh_decision( + decision, + preflight_errors, + preflight_warnings, + root_resolved, + ) + return refresh_decision_prepare_report( + root_resolved, + fresh_generated_root, + decision, + errors, + warnings, + preflight_errors, + preflight_warnings, + digest_verified_count, + ) + + +def refresh_decision_prepare_report( + root: Path, + fresh_generated_root: Path, + decision: dict[str, Any], + errors: list[dict[str, Any]], + warnings: list[dict[str, Any]], + preflight_errors: list[dict[str, Any]], + preflight_warnings: list[dict[str, Any]], + digest_verified_count: int, +) -> dict[str, Any]: + subject = _mapping_value(decision.get("subject")) + decision_payload = _mapping_value(decision.get("decision")) + generated_contract_files = _list_of_mappings(decision.get("generatedContractFiles")) + all_errors = errors + preflight_errors + all_warnings = warnings + preflight_warnings + status = "failed" if all_errors else ("warning" if all_warnings else "passed") + return { + "kind": REFRESH_DECISION_PREPARE_KIND, + "schemaVersion": REFRESH_DECISION_PREPARE_SCHEMA_VERSION, + "status": status, + "root": str(root), + "freshGeneratedRoot": str(fresh_generated_root), + "refreshDecision": { + "decisionId": decision.get("decisionId"), + "packageId": subject.get("packageId"), + "version": subject.get("version"), + "status": decision_payload.get("status"), + "updateNeeded": decision_payload.get("updateNeeded"), + "reason": decision_payload.get("reason"), + "packageCount": len(string_list(subject.get("packageIds"))), + "generatedContractFileCount": len(generated_contract_files), + "digestVerifiedCount": digest_verified_count, + }, + "summary": { + "packageId": subject.get("packageId"), + "packageCount": len(string_list(subject.get("packageIds"))), + "generatedContractFileCount": len(generated_contract_files), + "digestVerifiedCount": digest_verified_count, + "updateNeeded": decision_payload.get("updateNeeded"), + "errorCount": len(all_errors), + "warningCount": len(all_warnings), + }, + "decision": decision, + "preflight": { + "kind": REFRESH_DECISION_PREFLIGHT_KIND, + "status": ( + "failed" if preflight_errors else ("warning" if preflight_warnings else "passed") + ), + "digestVerifiedCount": digest_verified_count, + "errorCount": len(preflight_errors), + "warningCount": len(preflight_warnings), + "errors": preflight_errors, + "warnings": preflight_warnings, + }, + "errors": all_errors, + "warnings": all_warnings, + } + + def refresh_decision_report( body_path: Path, root: Path | None, @@ -3833,6 +4173,100 @@ def resolve_package_set_path(root: Path, path: str) -> Path | None: return candidate +def root_join(root: Path, base: Path, *parts: str) -> Path: + if base.is_absolute(): + return base.joinpath(*parts) + return root.joinpath(base, *parts) + + +def repo_relative_path(root: Path, path: Path) -> str | None: + root_resolved = root.resolve(strict=False) + resolved = path.resolve(strict=False) + if not resolved.is_relative_to(root_resolved): + return None + relative = resolved.relative_to(root_resolved).as_posix() + if not is_safe_relative_path(relative): + return None + return relative + + +def refresh_contract_files(package_dir: Path) -> list[Path]: + paths: list[Path] = [] + manifest = package_dir / "specpm.yaml" + if manifest.is_file(): + paths.append(manifest) + specs_dir = package_dir / "specs" + if specs_dir.is_dir(): + paths.extend(sorted(specs_dir.glob("*.spec.yaml"))) + return paths + + +def source_revisions_from_contracts(paths: list[Path]) -> set[str]: + revisions: set[str] = set() + for path in paths: + if path.suffix not in {".yaml", ".yml"}: + continue + try: + loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + continue + if not isinstance(loaded, dict): + continue + collect_source_revisions(loaded, revisions) + return revisions + + +def collect_source_revisions(value: Any, revisions: set[str]) -> None: + if isinstance(value, dict): + for key, nested in value.items(): + if key in {"sourceRevision", "revision"} and isinstance(nested, str): + if re.fullmatch(r"[a-f0-9]{40}", nested): + revisions.add(nested) + else: + collect_source_revisions(nested, revisions) + elif isinstance(value, list): + for item in value: + collect_source_revisions(item, revisions) + + +def refresh_decision_id( + package_id: str, + version: str, + source_revision: str, + status: str, +) -> str: + safe_package_id = re.sub(r"[^a-zA-Z0-9_.-]+", "-", package_id).strip("-") or "unknown" + safe_version = re.sub(r"[^a-zA-Z0-9_.-]+", "-", version).strip("-") or "unknown" + revision_prefix = source_revision[:12] if source_revision else "unknown" + return ( + f"specpm-refresh-decision-draft-{safe_package_id}-{safe_version}-{revision_prefix}-{status}" + ) + + +def refresh_decision_fresh_run_summary(update_needed: bool) -> str: + if update_needed: + return ( + "Fresh generated candidate comparison requires maintainer review before any " + "registry update." + ) + return ( + "Fresh generated candidate comparison reproduced the current generated contract " + "files; no registry update is proposed." + ) + + +def refresh_decision_review_summary(update_needed: bool) -> str: + if update_needed: + return ( + "Prepared as review evidence only; maintainer review must decide whether a " + "curated update, generated candidate update, or package version change is required." + ) + return ( + "Prepared as no-update review evidence because current and fresh generated " + "contract-bearing files match." + ) + + def candidate_tree_contains_symlink(path: Path) -> bool: if path.is_symlink(): return True diff --git a/tests/test_core.py b/tests/test_core.py index 0da55dd..f080159 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -59,6 +59,7 @@ preflight_package_set_ai_enrichment, preflight_producer_bundle, preflight_refresh_decision, + prepare_refresh_decision, ) from specpm.public_index import ( generate_public_index, @@ -3346,6 +3347,8 @@ def test_generated_candidate_refresh_decision_policy_is_documented() -> None: "Receipt churn, local run paths, new quality reports", "`public-index/generated//` is producer evidence", "fresh real `xyflow` package-set run", + "specpm producer-bundle prepare-refresh-decision", + "SpecPMGeneratedCandidateRefreshDecisionPrepareReport", "specpm producer-bundle preflight-refresh-decision", "SpecPMGeneratedCandidateRefreshDecisionPreflightReport", "status/update consistency", @@ -3362,6 +3365,8 @@ def test_generated_candidate_refresh_decision_policy_is_documented() -> None: "`updateNeeded: false`", "`reason: no_contract_delta`", "producer receipt churn plus an additional advisory quality report", + "specpm producer-bundle prepare-refresh-decision", + "SpecPMGeneratedCandidateRefreshDecisionPrepareReport", "specpm producer-bundle preflight-refresh-decision", "SpecPMGeneratedCandidateRefreshDecisionPreflightReport", "status/update consistency", @@ -3385,6 +3390,7 @@ def test_generated_candidate_refresh_decision_policy_is_documented() -> None: for required_text in ( "`SpecPMGeneratedCandidateRefreshDecision` with `updateNeeded: false`", "`reason: no_contract_delta`", + "specpm producer-bundle prepare-refresh-decision", ): assert required_text in intake_flat assert required_text in operator_guide_flat @@ -3403,6 +3409,7 @@ def test_generated_candidate_refresh_decision_policy_is_documented() -> None: "`updateNeeded: false`", "`reason: no_contract_delta`", "curated accepted artifact remains the stronger registry source", + "SpecPMGeneratedCandidateRefreshDecisionPrepareReport", ): assert required_text in roadmap_flat assert required_text in docc_roadmap_flat @@ -3424,13 +3431,18 @@ def test_generated_candidate_refresh_decision_policy_is_documented() -> None: "`specpm producer-bundle preflight-refresh-decision`", "`SpecPMGeneratedCandidateRefreshDecisionPreflightReport`", "Missing `--root` warns rather than fails", + "P66-T20. Generated Candidate Refresh Decision Prepare Helper", + "`specpm producer-bundle prepare-refresh-decision`", + "`SpecPMGeneratedCandidateRefreshDecisionPrepareReport`", ): assert required_text in workplan_flat assert "specpm.registry.generated_candidate_refresh_decision_policy" in manifest assert "specpm.registry.generated_candidate_refresh_decision_preflight" in manifest + assert "specpm.registry.generated_candidate_refresh_decision_prepare" in manifest assert "specpm.registry.generated_candidate_refresh_decision_policy" in self_spec assert "specpm.registry.generated_candidate_refresh_decision_preflight" in self_spec + assert "specpm.registry.generated_candidate_refresh_decision_prepare" in self_spec assert "specs/GENERATED_CANDIDATE_REFRESH_DECISION_POLICY.md" in self_spec assert ( "Sources/SpecPM/Documentation.docc/GeneratedCandidateRefreshDecisionPolicy.md" in self_spec @@ -3621,6 +3633,229 @@ def test_cli_refresh_decision_preflight_emits_json(capsys: pytest.CaptureFixture assert report["summary"]["digestVerifiedCount"] == 8 +def test_refresh_decision_prepare_accepts_xyflow_noop() -> None: + report = prepare_refresh_decision( + root=ROOT, + fresh_generated_root=ROOT / "public-index/generated", + package_ids=[ + "xyflow.workspace", + "xyflow.react", + "xyflow.svelte", + "xyflow.system", + ], + package_id="xyflow.workspace", + version="0.1.0", + source_repository="https://github.com/xyflow/xyflow", + source_revision="a58568f11bc0e1a1bdca1b3549e959e2e1ca0cdd", + run_label="xyflow-registry-update-eval-20260612", + review_location="local-refresh-draft", + ) + + assert report["kind"] == "SpecPMGeneratedCandidateRefreshDecisionPrepareReport" + assert report["status"] == "passed" + assert report["summary"] == { + "packageId": "xyflow.workspace", + "packageCount": 4, + "generatedContractFileCount": 8, + "digestVerifiedCount": 8, + "updateNeeded": False, + "errorCount": 0, + "warningCount": 0, + } + decision = report["decision"] + assert decision["kind"] == "SpecPMGeneratedCandidateRefreshDecision" + assert decision["decision"]["status"] == "no_update_required" + assert decision["decision"]["reason"] == "no_contract_delta" + assert decision["decision"]["supportingReasons"] == [ + "same_source_revision", + "generated_contract_bytes_unchanged", + "curated_artifact_remains_stronger", + "producer_receipt_only_delta", + "immutable_generated_candidate", + ] + assert decision["comparison"]["generatedContractChanged"] is False + assert decision["authority"]["noRegistryMutation"] is True + assert report["preflight"]["status"] == "passed" + + +def test_cli_refresh_decision_prepare_writes_decision_json( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + output = tmp_path / "refresh-decision.json" + exit_code = main( + [ + "producer-bundle", + "prepare-refresh-decision", + "--root", + str(ROOT), + "--fresh-generated-root", + str(ROOT / "public-index/generated"), + "--package", + "xyflow.workspace", + "--package", + "xyflow.react", + "--package", + "xyflow.svelte", + "--package", + "xyflow.system", + "--package-id", + "xyflow.workspace", + "--version", + "0.1.0", + "--source-repository", + "https://github.com/xyflow/xyflow", + "--source-revision", + "a58568f11bc0e1a1bdca1b3549e959e2e1ca0cdd", + "--output", + str(output), + "--json", + ] + ) + + report = json.loads(capsys.readouterr().out) + written = json.loads(output.read_text(encoding="utf-8")) + assert exit_code == 0 + assert report["status"] == "passed" + assert written["kind"] == "SpecPMGeneratedCandidateRefreshDecision" + assert written["decision"]["updateNeeded"] is False + assert preflight_refresh_decision(output, root=ROOT)["status"] == "passed" + + +def test_cli_refresh_decision_prepare_does_not_write_failed_decision( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + output = tmp_path / "refresh-decision.json" + + exit_code = main( + [ + "producer-bundle", + "prepare-refresh-decision", + "--root", + str(tmp_path / "repo"), + "--fresh-generated-root", + str(tmp_path / "missing-fresh-root"), + "--package", + "example.package", + "--version", + "0.1.0", + "--source-revision", + "a" * 40, + "--output", + str(output), + "--json", + ] + ) + + report = json.loads(capsys.readouterr().out) + assert exit_code == 1 + assert report["status"] == "failed" + assert not output.exists() + + +def test_refresh_decision_prepare_omits_same_source_without_contract_revision( + tmp_path: Path, +) -> None: + root = tmp_path / "repo" + current_package = root / "public-index/generated/example.package/0.1.0" + curated_package = root / "public-index/curated/example.package/0.1.0" + fresh_package = tmp_path / "fresh/example.package/0.1.0" + for package in (current_package, curated_package, fresh_package): + package.mkdir(parents=True) + manifest = "apiVersion: specpm.dev/v0.1\nmetadata:\n id: example.package\n" + (current_package / "specpm.yaml").write_text(manifest, encoding="utf-8") + (fresh_package / "specpm.yaml").write_text(manifest, encoding="utf-8") + + report = prepare_refresh_decision( + root=root, + fresh_generated_root=tmp_path / "fresh", + package_ids=["example.package"], + package_id="example.package", + version="0.1.0", + source_revision="a" * 40, + ) + + assert report["status"] == "passed" + assert report["decision"]["decision"]["status"] == "no_update_required" + assert "same_source_revision" not in report["decision"]["decision"]["supportingReasons"] + assert report["decision"]["comparison"]["sourceRevisionChanged"] is False + + +def test_refresh_decision_prepare_rejects_current_contract_symlink_escape( + tmp_path: Path, +) -> None: + root = tmp_path / "repo" + current_package = root / "public-index/generated/example.package/0.1.0" + curated_package = root / "public-index/curated/example.package/0.1.0" + fresh_package = tmp_path / "fresh/example.package/0.1.0" + outside = tmp_path / "outside" + for package in (current_package, curated_package, fresh_package, outside): + package.mkdir(parents=True) + outside_manifest = outside / "specpm.yaml" + outside_manifest.write_text( + "apiVersion: specpm.dev/v0.1\n" + "metadata:\n" + " id: example.package\n" + "provenance:\n" + f" sourceRevision: {'b' * 40}\n", + encoding="utf-8", + ) + (current_package / "specpm.yaml").symlink_to(outside_manifest) + (fresh_package / "specpm.yaml").write_text( + "apiVersion: specpm.dev/v0.1\nmetadata:\n id: example.package\n", + encoding="utf-8", + ) + + report = prepare_refresh_decision( + root=root, + fresh_generated_root=tmp_path / "fresh", + package_ids=["example.package"], + package_id="example.package", + version="0.1.0", + source_revision="a" * 40, + ) + + assert report["status"] == "failed" + assert "refresh_decision_prepare_contract_file_path_unresolved" in issue_codes(report["errors"]) + assert report["decision"]["comparison"]["sourceRevisionChanged"] is False + assert report["summary"]["digestVerifiedCount"] == 0 + + +def test_refresh_decision_prepare_flags_generated_contract_delta(tmp_path: Path) -> None: + fresh_root = tmp_path / "generated" + shutil.copytree( + ROOT / "public-index/generated/xyflow.workspace", + fresh_root / "xyflow.workspace", + ) + manifest = fresh_root / "xyflow.workspace/0.1.0/specpm.yaml" + manifest.write_text( + manifest.read_text(encoding="utf-8") + "\nkeywords:\n - changed-refresh-candidate\n", + encoding="utf-8", + ) + + report = prepare_refresh_decision( + root=ROOT, + fresh_generated_root=fresh_root, + package_ids=["xyflow.workspace"], + package_id="xyflow.workspace", + version="0.1.0", + source_repository="https://github.com/xyflow/xyflow", + source_revision="a58568f11bc0e1a1bdca1b3549e959e2e1ca0cdd", + ) + + assert report["status"] == "passed" + assert report["summary"]["updateNeeded"] is True + assert report["decision"]["decision"] == { + "status": "manual_review_required", + "updateNeeded": True, + "reason": "refresh_prepare_requires_review", + "supportingReasons": [], + } + assert report["decision"]["comparison"]["generatedContractChanged"] is True + assert report["preflight"]["status"] == "passed" + + def test_refresh_decision_preflight_warns_without_root() -> None: report = preflight_refresh_decision(GENERATED_CANDIDATE_REFRESH_DECISION_FIXTURE)