specfuse-loop 0.9.1__tar.gz → 0.9.2__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.
- {specfuse_loop-0.9.1/specfuse_loop.egg-info → specfuse_loop-0.9.2}/PKG-INFO +1 -1
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/pyproject.toml +1 -1
- specfuse_loop-0.9.2/specfuse/loop/data/VERSION +1 -0
- specfuse_loop-0.9.2/specfuse/loop/gate_commands.py +94 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/lint_plan.py +79 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/loop.py +91 -9
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse_loop.egg-info/SOURCES.txt +4 -0
- specfuse_loop-0.9.2/tests/test_ci_runs_every_gate.py +74 -0
- specfuse_loop-0.9.2/tests/test_produces_shape.py +160 -0
- specfuse_loop-0.9.2/tests/test_unterminated_frontmatter.py +139 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_upgrade_merge_gate.py +103 -0
- specfuse_loop-0.9.1/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/LICENSE +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/NOTICE +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/README.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/setup.cfg +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/_filelock.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/_wu_sections.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/arm_eval.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/arm_sweep.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/arm_txn.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/changelog.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/closing_requirements.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/cost.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/CHANGELOG.seed.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/docs/concepts/adopting-auto-mode.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/docs/concepts/autonomy-stop-classes.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/monitoring-secrets-checklist.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/monitoring.overrides.yml.example +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/monitoring.yml.example +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/rules/close-discipline.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/rules/design-for-diagnosis.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/rules/operator-escalation.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/rules/planning-discipline.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/rules-local/README.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/schemas/driver-event.schema.json +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/templates/LEARNINGS-pending.template.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/data/workflows/specfuse-monitor.yml +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/escalation.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/events_stats.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/labels.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/lint_closing.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/lint_monitoring.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/lint_roadmap.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/plan_baseline.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/__init__.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/adapters.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/artifact.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/autofix.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/autofix_invoke.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/autofix_run.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/autofix_state.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/cli.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/diagnose_cli.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/diagnosis.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/fingerprint.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/issues.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/providers/__init__.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/providers/_azure_auth.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/providers/azure_app_insights.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/providers/azure_service_bus.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/redaction.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse/monitor/schedule.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_accept_hedged_close_headline.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_accept_hedged_close_skill.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_app_insights_adapters.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_arm_eval.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_arm_eval_lint_class.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_arm_eval_wiring.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_arm_sweep.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_arm_sweep_gate.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_arm_txn.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_arm_wiring.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_artifact_redaction.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_attempt_outcome_contract.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_attention_nonwriting_guard.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_attention_skill_structure.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_autoclose_deferral_visibility.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_autofix_invoke.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_autofix_live.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_autofix_predicate.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_autofix_run.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_autofix_state.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_autosync_no_cwd_leak.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_azure_credential_resolution.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_backend.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_baseline_escalation_message.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_baseline_persistence.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_baseline_probe.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_bash_routing.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_bats_suites_gated.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_bookkeeping_no_verify.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_changelog_collection.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_changelog_release_wiring.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_changelog_schema.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_claude_resolution.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_close_wu_self_reference.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_closing_guard_contracts.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_closing_guard_prediction.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_closing_requirements.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_console_encoding.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_correlation_id_override.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_correlation_id_two_surfaces.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_cost_lifetime.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_derive_monitoring_discovery.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_derive_monitoring_skill_registration.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_design_for_diagnosis_rule.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_diagnose_headless.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_diagnose_issue_skill.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_diagnosis.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_diagnosis_roundtrip_live.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_dispatch_skeleton.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_done_feature_gates.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_driver_event_registry_covers_emitters.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_ensure_feature_branch_base.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_escalation_contract.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_escalation_emit.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_event_gate_full_envelope.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_events_stats.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_failure_artifact_model.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_feature_id_resolution.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_feature_review_accumulation.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_filelock_portable.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_fingerprint.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_fix_bug_headless.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_gate_budget.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_gate_budget_post_dispatch.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_gate_report_verdict.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_gitleaks_pinning.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_heartbeat_adapter.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_hedged_kind_contract.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_interpreter_normalization.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_label_registry.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_label_registry_covers_consumers.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_learnings_staging.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lifetime_accounting.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_boundary_consistency.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_boundary_extraction.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_closing.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_monitoring.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_plan_contract_fields.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_plan_verdict_exempt.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_planning_sections.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_produces_satisfiability.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_roadmap.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_lint_verification_reference.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_attempt_evidence.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_orchestration.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_monitor_cli.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_monitor_issue_lifecycle.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_monitor_runner_surfaces.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_monitoring_bootstrap_artifacts.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_monitoring_cron_dialect.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_monitoring_example.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_monitoring_fenced_blocks.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_monitoring_seed.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_no_pytest_subprocess.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_operator_escalation_rule.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_plan_baseline.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_planning_cost_floor.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_produces_in_diff.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_produces_semantics_unified.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_provision_labels.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_queue_stalled_adapter.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_redaction_windows_home.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_resolve_base.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_roadmap_link_gate.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_routed_finding_tracking.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_scaffold_label_provisioning.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_schedule_dialect.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_service_bus_dlq_adapter.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_skill_discovery_links.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_spinning_rearm_gate.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_timeout_kill_portable.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_untracked_file_absorption.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_validate_event_driver_types.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_windows_gate_exec.py +0 -0
- {specfuse_loop-0.9.1 → specfuse_loop-0.9.2}/tests/test_wu_execution_metadata.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specfuse-loop"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.2"
|
|
8
8
|
description = "Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.9.2
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse Contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Read the gate sets out of `.specfuse/verification.yml` (#592).
|
|
6
|
+
|
|
7
|
+
`verification.yml` is the source of truth for what gets verified. Anything
|
|
8
|
+
that runs gates should derive its list from here rather than keeping a copy.
|
|
9
|
+
|
|
10
|
+
`scripts/smoke-test.sh` used to carry a hand-maintained mirror under the
|
|
11
|
+
comment "Keep in sync". It drifted by six gates, and two of them
|
|
12
|
+
(`roadmap-link-gate`, `arm-sweep-gate`) shipped with features that week and
|
|
13
|
+
were never once executed by CI. A declared-but-unrun gate is worse than an
|
|
14
|
+
absent one, because the declaration reads as coverage.
|
|
15
|
+
|
|
16
|
+
Deliberately not a YAML dependency: `_miniyaml` already parses this project's
|
|
17
|
+
files, and adding a third-party parser to a path CI depends on would trade one
|
|
18
|
+
drift risk for an install risk.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
_SET_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*$", re.M)
|
|
27
|
+
_NAME_RE = re.compile(r"^ - name:\s*(\S+)\s*$", re.M)
|
|
28
|
+
_COMMAND_RE = re.compile(r'^\s+command:\s*"(.*?)"\s*$', re.M | re.S)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _set_block(text: str, set_name: str) -> str:
|
|
32
|
+
"""Return the raw text of one top-level gate set, without its siblings."""
|
|
33
|
+
starts = [(m.group(1), m.start()) for m in _SET_RE.finditer(text)]
|
|
34
|
+
for i, (name, pos) in enumerate(starts):
|
|
35
|
+
if name != set_name:
|
|
36
|
+
continue
|
|
37
|
+
end = starts[i + 1][1] if i + 1 < len(starts) else len(text)
|
|
38
|
+
return text[pos:end]
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def iter_code_gates(
|
|
43
|
+
verification_yml: "Path | str", set_name: str = "code"
|
|
44
|
+
) -> list[tuple[str, str]]:
|
|
45
|
+
"""Yield `(name, command)` for every gate in *set_name*, in declared order.
|
|
46
|
+
|
|
47
|
+
Order is preserved because gate order is a real signal: the cheap,
|
|
48
|
+
fast-failing gates are declared first so a broken build reports in seconds
|
|
49
|
+
rather than after the full suite.
|
|
50
|
+
"""
|
|
51
|
+
text = Path(verification_yml).read_text(encoding="utf-8")
|
|
52
|
+
block = _set_block(text, set_name)
|
|
53
|
+
if not block:
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
gates: list[tuple[str, str]] = []
|
|
57
|
+
entries = re.split(r"^ - name:\s*", block, flags=re.M)[1:]
|
|
58
|
+
for entry in entries:
|
|
59
|
+
name = entry.split("\n", 1)[0].strip()
|
|
60
|
+
m = _COMMAND_RE.search(entry)
|
|
61
|
+
command = m.group(1).strip() if m else ""
|
|
62
|
+
gates.append((name, command))
|
|
63
|
+
return gates
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def code_gate_names(
|
|
67
|
+
verification_yml: "Path | str", set_name: str = "code"
|
|
68
|
+
) -> list[str]:
|
|
69
|
+
"""Names only, for parity checks."""
|
|
70
|
+
text = Path(verification_yml).read_text(encoding="utf-8")
|
|
71
|
+
return _NAME_RE.findall(_set_block(text, set_name))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> int:
|
|
75
|
+
"""Print `name<TAB>command` per gate, for shell consumption.
|
|
76
|
+
|
|
77
|
+
Used by `scripts/smoke-test.sh`, which substitutes its own interpreter for
|
|
78
|
+
a leading `python3`: the declared commands name `python3` because that is
|
|
79
|
+
what a target project's driver invokes, while the smoke test must use the
|
|
80
|
+
virtualenv interpreter that actually has the dependencies installed.
|
|
81
|
+
"""
|
|
82
|
+
import argparse
|
|
83
|
+
|
|
84
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
85
|
+
ap.add_argument("verification_yml")
|
|
86
|
+
ap.add_argument("--set", default="code", dest="set_name")
|
|
87
|
+
args = ap.parse_args()
|
|
88
|
+
for name, command in iter_code_gates(args.verification_yml, args.set_name):
|
|
89
|
+
print(f"{name}\t{command}")
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
raise SystemExit(main())
|
|
@@ -240,6 +240,15 @@ def read_frontmatter(path: Path) -> tuple[dict, str]:
|
|
|
240
240
|
j = 1
|
|
241
241
|
while j < len(lines) and not FM.match(lines[j]):
|
|
242
242
|
j += 1
|
|
243
|
+
if j >= len(lines):
|
|
244
|
+
# See loop.read_frontmatter: an opening `---` with no closing one made
|
|
245
|
+
# the scan run off the end and parse the document as frontmatter,
|
|
246
|
+
# reporting the failure against an arbitrary body line (#306).
|
|
247
|
+
raise _miniyaml.MiniYAMLError(
|
|
248
|
+
f"{path}: frontmatter block opened with `---` on line 1 but is "
|
|
249
|
+
f"never closed — add a `---` line to end it. Every line after it "
|
|
250
|
+
f"was read as frontmatter, so no check could evaluate this file."
|
|
251
|
+
)
|
|
243
252
|
return _miniyaml.parse("\n".join(lines[1:j])) or {}, "\n".join(lines[j + 1:])
|
|
244
253
|
|
|
245
254
|
|
|
@@ -659,6 +668,60 @@ def _match_dnt_boundary(
|
|
|
659
668
|
return None
|
|
660
669
|
|
|
661
670
|
|
|
671
|
+
def check_produces_shape(feature_dir: Path, gates: list) -> list[str]:
|
|
672
|
+
"""ERROR when a WU's `produces:` entry is a directory rather than a file
|
|
673
|
+
or glob.
|
|
674
|
+
|
|
675
|
+
Same family as `check_produces_boundary` below, and the same argument: the
|
|
676
|
+
driver refuses this outright, but only after a full `claude -p` session.
|
|
677
|
+
Since the agent cannot edit its own frontmatter, every retry is
|
|
678
|
+
byte-identical -- a real feature paid $6.42 and 20.6 minutes across three
|
|
679
|
+
of them before `spinning_detected` fired, while `specfuse-lint` reported
|
|
680
|
+
`OK - structurally valid` throughout (#593).
|
|
681
|
+
|
|
682
|
+
Every input is static, so this belongs in the pre-dispatch checklist. The
|
|
683
|
+
message is rendered by `loop.produces_shape_error`, the same function the
|
|
684
|
+
driver's guards call, so all three surfaces read identically rather than
|
|
685
|
+
drifting into three descriptions of one rule.
|
|
686
|
+
|
|
687
|
+
Applies to every WU with a `produces:` entry regardless of status: a `done`
|
|
688
|
+
WU with a directory entry is a folder authored against the pre-0.9.0
|
|
689
|
+
contract, and reporting it is how a migrating project finds them all at
|
|
690
|
+
once instead of one dispatch at a time.
|
|
691
|
+
"""
|
|
692
|
+
from .loop import produces_shape_error
|
|
693
|
+
|
|
694
|
+
found: list[str] = []
|
|
695
|
+
for gate in gates:
|
|
696
|
+
for entry in gate.get("work_units") or []:
|
|
697
|
+
wid, wfile = entry.get("id"), entry.get("file")
|
|
698
|
+
if not wid or not wfile:
|
|
699
|
+
continue
|
|
700
|
+
wpath = feature_dir / wfile
|
|
701
|
+
if not wpath.exists():
|
|
702
|
+
continue
|
|
703
|
+
wfm, _ = read_frontmatter(wpath)
|
|
704
|
+
produces_raw = wfm.get("produces")
|
|
705
|
+
if not produces_raw:
|
|
706
|
+
continue
|
|
707
|
+
raw_entries = (
|
|
708
|
+
produces_raw if isinstance(produces_raw, list) else [produces_raw]
|
|
709
|
+
)
|
|
710
|
+
for p in raw_entries:
|
|
711
|
+
p_s = str(p).strip()
|
|
712
|
+
if not p_s:
|
|
713
|
+
continue
|
|
714
|
+
err = produces_shape_error(p_s)
|
|
715
|
+
if err:
|
|
716
|
+
found.append(
|
|
717
|
+
f"ERROR: {wfile}: {wid} {err}. assert_declared_"
|
|
718
|
+
f"deliverables refuses this after a full dispatch "
|
|
719
|
+
f"attempt, and a retry cannot help — the agent cannot "
|
|
720
|
+
f"edit its own frontmatter. See #593."
|
|
721
|
+
)
|
|
722
|
+
return found
|
|
723
|
+
|
|
724
|
+
|
|
662
725
|
def check_produces_boundary(feature_dir: Path, gates: list) -> list[str]:
|
|
663
726
|
"""ERROR when a WU's own `produces:` path (or `produces_driver_helper`
|
|
664
727
|
surface) falls inside its own Do-not-touch section's paths.
|
|
@@ -893,6 +956,21 @@ def check_planning_sections(
|
|
|
893
956
|
|
|
894
957
|
|
|
895
958
|
def lint(feature_dir: Path) -> list[str]:
|
|
959
|
+
"""Lint a feature folder, converting an unparseable file into a finding.
|
|
960
|
+
|
|
961
|
+
A malformed WU used to abort the whole run with a traceback, which made
|
|
962
|
+
the folder unevaluable by *every* check rather than one (#306). The lint's
|
|
963
|
+
job is to report what is wrong with a folder; a file it cannot parse is
|
|
964
|
+
the most basic thing that can be wrong with one, so it is a finding like
|
|
965
|
+
any other. Callers still get a non-empty error list and a non-zero exit.
|
|
966
|
+
"""
|
|
967
|
+
try:
|
|
968
|
+
return _lint_impl(feature_dir)
|
|
969
|
+
except _miniyaml.MiniYAMLError as exc:
|
|
970
|
+
return [f"ERROR: unparseable frontmatter — {exc}"]
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _lint_impl(feature_dir: Path) -> list[str]:
|
|
896
974
|
errs: list[str] = []
|
|
897
975
|
plan = feature_dir / "PLAN.md"
|
|
898
976
|
if not plan.exists():
|
|
@@ -1212,6 +1290,7 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
1212
1290
|
check_closing_guard_literals(feature_dir, gates)
|
|
1213
1291
|
check_autoclose_debt_prediction(feature_dir, gates)
|
|
1214
1292
|
check_produces_satisfiability(feature_dir, gates)
|
|
1293
|
+
errs.extend(check_produces_shape(feature_dir, gates))
|
|
1215
1294
|
errs.extend(check_produces_boundary(feature_dir, gates))
|
|
1216
1295
|
errs.extend(check_done_feature_gates(feature_dir, fm))
|
|
1217
1296
|
|
|
@@ -105,7 +105,7 @@ SPECFUSE_DIR = Path(".specfuse")
|
|
|
105
105
|
REPO_ROOT = SPECFUSE_DIR.parent
|
|
106
106
|
FEATURES_DIR = SPECFUSE_DIR / "features"
|
|
107
107
|
VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
|
|
108
|
-
DRIVER_VERSION = "0.9.
|
|
108
|
+
DRIVER_VERSION = "0.9.2"
|
|
109
109
|
# Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
|
|
110
110
|
# version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
|
|
111
111
|
# the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
|
|
@@ -245,6 +245,17 @@ def read_frontmatter(path: Path) -> tuple[dict, str]:
|
|
|
245
245
|
j = 1
|
|
246
246
|
while j < len(lines) and not FM.match(lines[j]):
|
|
247
247
|
j += 1
|
|
248
|
+
if j >= len(lines):
|
|
249
|
+
# Unterminated block: an opening `---` with no closing one. Without
|
|
250
|
+
# this check the scan runs off the end, the whole document is parsed
|
|
251
|
+
# as frontmatter, and the failure is reported against whatever body
|
|
252
|
+
# line happens to be first — which sent #306 chasing an HTML comment
|
|
253
|
+
# that was never the problem. Name the actual defect instead.
|
|
254
|
+
raise _miniyaml.MiniYAMLError(
|
|
255
|
+
f"{path}: frontmatter block opened with `---` on line 1 but is "
|
|
256
|
+
f"never closed — add a `---` line to end it. Every line after it "
|
|
257
|
+
f"was read as frontmatter, so no check could evaluate this file."
|
|
258
|
+
)
|
|
248
259
|
try:
|
|
249
260
|
fm = _miniyaml.parse("\n".join(lines[1:j])) or {}
|
|
250
261
|
except _miniyaml.MiniYAMLError as exc:
|
|
@@ -4800,6 +4811,52 @@ def assert_implementation_touched_files(
|
|
|
4800
4811
|
)
|
|
4801
4812
|
|
|
4802
4813
|
|
|
4814
|
+
def produces_shape_error(path: str) -> "str | None":
|
|
4815
|
+
"""Return why *path* is an invalid ``produces:`` entry, or None if it is fine.
|
|
4816
|
+
|
|
4817
|
+
Shape only -- never presence. A path that does not exist yet is perfectly
|
|
4818
|
+
valid at draft time; whether it was actually produced is the dispatch
|
|
4819
|
+
gate's question, asked after the session.
|
|
4820
|
+
|
|
4821
|
+
Split out of ``assert_declared_deliverables`` so the one fact that needs no
|
|
4822
|
+
session output can be checked by every surface that has the frontmatter:
|
|
4823
|
+
``lint_plan`` at draft/arm/conformance time, and the driver before it
|
|
4824
|
+
spends a token (#593). A real feature paid $6.42 across three byte-identical
|
|
4825
|
+
refusals for a ``Path.is_dir()`` call, with ``specfuse-lint`` reporting
|
|
4826
|
+
``OK - structurally valid`` the whole time.
|
|
4827
|
+
|
|
4828
|
+
The three surfaces must read identically, so all render this one string.
|
|
4829
|
+
"""
|
|
4830
|
+
if path.endswith("/") or Path(path).is_dir():
|
|
4831
|
+
return (
|
|
4832
|
+
f"declared deliverable is a directory: {path} — directories "
|
|
4833
|
+
"are not valid produces: entries under the unified "
|
|
4834
|
+
"literal/glob contract (a directory always passed presence "
|
|
4835
|
+
"while failing assert_produces_in_diff's diff match); declare "
|
|
4836
|
+
"the specific file(s) or a glob instead"
|
|
4837
|
+
)
|
|
4838
|
+
return None
|
|
4839
|
+
|
|
4840
|
+
|
|
4841
|
+
def assert_produces_shape(wu: WorkUnit) -> tuple[bool, str]:
|
|
4842
|
+
"""Pre-dispatch half of ``assert_declared_deliverables`` (#593).
|
|
4843
|
+
|
|
4844
|
+
Every input is static: the WU's own frontmatter and the filesystem. None
|
|
4845
|
+
of it depends on what a session did, so refusing here converts a $2.46
|
|
4846
|
+
attempt into a $0.00 refusal -- the lever ``planning-discipline.md`` §5
|
|
4847
|
+
already names, where ``assert_verdict_well_formed`` fired 10 times for
|
|
4848
|
+
nothing because it is checked before the agent spends anything.
|
|
4849
|
+
|
|
4850
|
+
The agent cannot edit its own frontmatter, so a WU that fails this cannot
|
|
4851
|
+
be fixed by retrying it; escalating immediately is the only honest move.
|
|
4852
|
+
"""
|
|
4853
|
+
for raw in wu.produces or []:
|
|
4854
|
+
err = produces_shape_error(str(raw))
|
|
4855
|
+
if err:
|
|
4856
|
+
return False, err
|
|
4857
|
+
return True, ""
|
|
4858
|
+
|
|
4859
|
+
|
|
4803
4860
|
def assert_declared_deliverables(wu: WorkUnit) -> tuple[bool, str]:
|
|
4804
4861
|
"""Deliverable-presence gate (FEAT-2026-0022/T02), unified with
|
|
4805
4862
|
``assert_produces_in_diff``'s literal/glob contract (FEAT-2026-0055/T03).
|
|
@@ -4831,14 +4888,11 @@ def assert_declared_deliverables(wu: WorkUnit) -> tuple[bool, str]:
|
|
|
4831
4888
|
for raw in wu.produces:
|
|
4832
4889
|
path = str(raw)
|
|
4833
4890
|
p = Path(path)
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
"while failing assert_produces_in_diff's diff match); declare "
|
|
4840
|
-
"the specific file(s) or a glob instead"
|
|
4841
|
-
)
|
|
4891
|
+
# Shape refusal is shared with the pre-dispatch guard and lint_plan so
|
|
4892
|
+
# all three surfaces render one message (#593).
|
|
4893
|
+
shape_err = produces_shape_error(path)
|
|
4894
|
+
if shape_err:
|
|
4895
|
+
return False, shape_err
|
|
4842
4896
|
if any(ch in path for ch in "*?["):
|
|
4843
4897
|
matches = [
|
|
4844
4898
|
m for m in glob.glob(path)
|
|
@@ -5516,6 +5570,34 @@ def run(
|
|
|
5516
5570
|
blocked = True
|
|
5517
5571
|
continue
|
|
5518
5572
|
|
|
5573
|
+
# produces: shape gate (#593). Same posture as the re-arm gate
|
|
5574
|
+
# above: fires BEFORE any dispatch state change, so a WU the
|
|
5575
|
+
# driver is guaranteed to refuse costs $0.00 instead of a full
|
|
5576
|
+
# session. Every input is static frontmatter plus the
|
|
5577
|
+
# filesystem, and the agent cannot edit its own frontmatter --
|
|
5578
|
+
# so retrying is provably useless. A real feature paid $6.42
|
|
5579
|
+
# across three byte-identical refusals before spinning fired.
|
|
5580
|
+
shape_ok, shape_reason = assert_produces_shape(wu)
|
|
5581
|
+
if not shape_ok:
|
|
5582
|
+
backend.set_wu(wu, "status", "blocked_human")
|
|
5583
|
+
flush_events(events_path, [build_event(
|
|
5584
|
+
"human_escalation", wu.wu_id, {
|
|
5585
|
+
"reason": "produces_shape_invalid",
|
|
5586
|
+
"blocked_reason": shape_reason,
|
|
5587
|
+
"attempts": 0,
|
|
5588
|
+
"failure_class": "guard_refusal",
|
|
5589
|
+
"failure_signature": "assert_produces_shape",
|
|
5590
|
+
})])
|
|
5591
|
+
commit_bookkeeping(
|
|
5592
|
+
[wu.file, events_path],
|
|
5593
|
+
f"chore(loop): {wu.wu_id} blocked "
|
|
5594
|
+
f"(produces: shape invalid)"
|
|
5595
|
+
f"\n\nFeature: {wu.wu_id}",
|
|
5596
|
+
)
|
|
5597
|
+
print(f" BLOCKED before dispatch (no cost) — {shape_reason}")
|
|
5598
|
+
blocked = True
|
|
5599
|
+
continue
|
|
5600
|
+
|
|
5519
5601
|
head_before = git("rev-parse", "HEAD")
|
|
5520
5602
|
# Snapshot the operator's untracked files alongside head_before:
|
|
5521
5603
|
# squash_commit must not absorb WIP that pre-dates this dispatch
|
|
@@ -15,6 +15,7 @@ specfuse/loop/closing_requirements.py
|
|
|
15
15
|
specfuse/loop/cost.py
|
|
16
16
|
specfuse/loop/escalation.py
|
|
17
17
|
specfuse/loop/events_stats.py
|
|
18
|
+
specfuse/loop/gate_commands.py
|
|
18
19
|
specfuse/loop/gate_eval.py
|
|
19
20
|
specfuse/loop/gh_backend.py
|
|
20
21
|
specfuse/loop/gh_features.py
|
|
@@ -129,6 +130,7 @@ tests/test_bookkeeping_no_verify.py
|
|
|
129
130
|
tests/test_changelog_collection.py
|
|
130
131
|
tests/test_changelog_release_wiring.py
|
|
131
132
|
tests/test_changelog_schema.py
|
|
133
|
+
tests/test_ci_runs_every_gate.py
|
|
132
134
|
tests/test_claude_resolution.py
|
|
133
135
|
tests/test_close_wu_self_reference.py
|
|
134
136
|
tests/test_closing_deliverable_guard.py
|
|
@@ -258,6 +260,7 @@ tests/test_prepare_scaffold_sync.py
|
|
|
258
260
|
tests/test_produces_field.py
|
|
259
261
|
tests/test_produces_in_diff.py
|
|
260
262
|
tests/test_produces_semantics_unified.py
|
|
263
|
+
tests/test_produces_shape.py
|
|
261
264
|
tests/test_provision_labels.py
|
|
262
265
|
tests/test_queue_stalled_adapter.py
|
|
263
266
|
tests/test_redaction_windows_home.py
|
|
@@ -288,6 +291,7 @@ tests/test_template_closing_shapes.py
|
|
|
288
291
|
tests/test_terminal_flip_ownership.py
|
|
289
292
|
tests/test_terminal_flips.py
|
|
290
293
|
tests/test_timeout_kill_portable.py
|
|
294
|
+
tests/test_unterminated_frontmatter.py
|
|
291
295
|
tests/test_untracked_feature_folder.py
|
|
292
296
|
tests/test_untracked_file_absorption.py
|
|
293
297
|
tests/test_upgrade_integration.py
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""#592 — CI must run every gate `verification.yml` declares.
|
|
6
|
+
|
|
7
|
+
`scripts/smoke-test.sh` carried a hand-maintained copy of the `code` gate set
|
|
8
|
+
under the comment "Keep in sync". It drifted by six: `roadmap-link-gate`,
|
|
9
|
+
`arm-sweep-gate`, `monitoring-example-lint`, `sync-scaffold-symlinks-bats`,
|
|
10
|
+
`init-sh-shim-bats`, and `hookspath-conflict-bats` were declared and never
|
|
11
|
+
executed.
|
|
12
|
+
|
|
13
|
+
A declared-but-unrun gate is worse than no gate: `verification.yml` is the
|
|
14
|
+
source of truth for what gets verified, so an unrun entry reads as coverage.
|
|
15
|
+
`sync_scaffold_symlinks.bats` sat red through two merges because of this, and
|
|
16
|
+
its silence made a one-PR-old regression look environmental.
|
|
17
|
+
|
|
18
|
+
`init-skills-bats` already carried the comment "Was silently red + un-run
|
|
19
|
+
before #121" — the same problem, fixed once for one gate.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import unittest
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from specfuse.loop.gate_commands import code_gate_names, iter_code_gates
|
|
28
|
+
|
|
29
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
30
|
+
SMOKE = REPO_ROOT / "scripts" / "smoke-test.sh"
|
|
31
|
+
VERIFICATION = REPO_ROOT / ".specfuse" / "verification.yml"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestGateParity(unittest.TestCase):
|
|
35
|
+
def test_declared_gates_were_found(self):
|
|
36
|
+
"""Guard the guard: an empty parse would make every check vacuous."""
|
|
37
|
+
names = code_gate_names(VERIFICATION)
|
|
38
|
+
self.assertGreaterEqual(len(names), 10, names)
|
|
39
|
+
self.assertIn("tests", names)
|
|
40
|
+
self.assertIn("roadmap-link-gate", names)
|
|
41
|
+
|
|
42
|
+
def test_every_declared_gate_has_a_command(self):
|
|
43
|
+
for name, command in iter_code_gates(VERIFICATION):
|
|
44
|
+
with self.subTest(gate=name):
|
|
45
|
+
self.assertTrue(command.strip(), f"{name} declares no command")
|
|
46
|
+
|
|
47
|
+
def test_smoke_test_derives_gates_rather_than_listing_them(self):
|
|
48
|
+
"""The drift is structural: any hand-copied list can fall behind.
|
|
49
|
+
|
|
50
|
+
Asserts the runner iterates the declared set instead of naming gates
|
|
51
|
+
one by one, so a gate added to verification.yml is run without anyone
|
|
52
|
+
remembering to touch the shell script.
|
|
53
|
+
"""
|
|
54
|
+
sh = SMOKE.read_text()
|
|
55
|
+
self.assertIn("gate_commands", sh,
|
|
56
|
+
"smoke-test.sh does not derive its gates from "
|
|
57
|
+
"verification.yml; a seventh drift is a matter of time")
|
|
58
|
+
|
|
59
|
+
def test_no_declared_gate_is_hardcoded_out_of_the_run(self):
|
|
60
|
+
"""Every declared gate must be reachable by the runner's iteration.
|
|
61
|
+
|
|
62
|
+
Catches the specific failure this issue is about: a gate present in
|
|
63
|
+
verification.yml that the CI runner never executes.
|
|
64
|
+
"""
|
|
65
|
+
declared = set(code_gate_names(VERIFICATION))
|
|
66
|
+
emitted = {name for name, _ in iter_code_gates(VERIFICATION)}
|
|
67
|
+
self.assertEqual(
|
|
68
|
+
declared - emitted, set(),
|
|
69
|
+
"declared gates the runner would skip",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
unittest.main()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""#593 — a directory in `produces:` must be caught before a session runs.
|
|
6
|
+
|
|
7
|
+
`assert_declared_deliverables` refuses a `produces:` entry that resolves to a
|
|
8
|
+
directory. The verdict is right; the timing was not. It ran after a full
|
|
9
|
+
`claude -p` session, three times, for a fact that is a `Path.is_dir()` call on
|
|
10
|
+
static frontmatter: $6.42 and 20.6 minutes observed on a real feature, with
|
|
11
|
+
`specfuse-lint` reporting `OK — structurally valid` throughout.
|
|
12
|
+
|
|
13
|
+
Two surfaces, one contract:
|
|
14
|
+
- `lint_plan` must ERROR at draft/arm/conformance time, before the loop runs;
|
|
15
|
+
- the driver must refuse at dispatch, before a token is spent.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import io
|
|
22
|
+
import tempfile
|
|
23
|
+
import unittest
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from specfuse.loop.loop import produces_shape_error
|
|
27
|
+
from tests._loop_loader import load_lint
|
|
28
|
+
from tests.test_lint_boundary_consistency import _build_feature
|
|
29
|
+
|
|
30
|
+
lint_plan = load_lint()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestProducesShapeError(unittest.TestCase):
|
|
34
|
+
"""The shared predicate both surfaces call — pure, no session input."""
|
|
35
|
+
|
|
36
|
+
def test_trailing_slash_is_refused_without_touching_the_filesystem(self):
|
|
37
|
+
err = produces_shape_error("src/main/java/dev/example/regression/")
|
|
38
|
+
self.assertIsNotNone(err)
|
|
39
|
+
self.assertIn("directory", err)
|
|
40
|
+
|
|
41
|
+
def test_existing_directory_is_refused(self):
|
|
42
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
43
|
+
d = Path(tmp) / "pkg"
|
|
44
|
+
d.mkdir()
|
|
45
|
+
self.assertIsNotNone(produces_shape_error(str(d)))
|
|
46
|
+
|
|
47
|
+
def test_plain_file_path_is_accepted(self):
|
|
48
|
+
self.assertIsNone(produces_shape_error("specfuse/loop/loop.py"))
|
|
49
|
+
|
|
50
|
+
def test_glob_is_accepted(self):
|
|
51
|
+
self.assertIsNone(produces_shape_error("src/**/Regression*.java"))
|
|
52
|
+
|
|
53
|
+
def test_nonexistent_file_path_is_accepted(self):
|
|
54
|
+
"""Shape only. Presence is the dispatch gate's job, not this one."""
|
|
55
|
+
self.assertIsNone(produces_shape_error("does/not/exist/yet.py"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestLintCatchesProducesShape(unittest.TestCase):
|
|
59
|
+
"""Surface 1: the pre-dispatch checklist must not pass a doomed WU."""
|
|
60
|
+
|
|
61
|
+
def _lint(self, produces: str) -> list[str]:
|
|
62
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
63
|
+
feat = _build_feature(
|
|
64
|
+
Path(tmp),
|
|
65
|
+
[{
|
|
66
|
+
"id": "FEAT-2026-0099/T01", "file": "WU-01.md",
|
|
67
|
+
"type": "implementation", "status": "pending",
|
|
68
|
+
"do_not_touch": "- nothing relevant.",
|
|
69
|
+
"produces": [produces],
|
|
70
|
+
}],
|
|
71
|
+
)
|
|
72
|
+
buf = io.StringIO()
|
|
73
|
+
with contextlib.redirect_stdout(buf):
|
|
74
|
+
errs = lint_plan.lint(feat)
|
|
75
|
+
return [str(e) for e in errs] + buf.getvalue().splitlines()
|
|
76
|
+
|
|
77
|
+
def test_directory_produces_is_an_error_finding(self):
|
|
78
|
+
found = [f for f in self._lint("src/regression/") if "directory" in f]
|
|
79
|
+
self.assertTrue(
|
|
80
|
+
found,
|
|
81
|
+
"lint passed a produces: entry the driver is guaranteed to refuse "
|
|
82
|
+
"- that is the $6.42 in #593",
|
|
83
|
+
)
|
|
84
|
+
self.assertTrue(
|
|
85
|
+
any(f.startswith("ERROR") for f in found),
|
|
86
|
+
f"directory produces: must be ERROR, not WARN: {found}",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def test_the_error_names_the_guard_it_preempts(self):
|
|
90
|
+
"""Earlier-enforcer-names-the-later-one, per FEAT-2026-0070."""
|
|
91
|
+
found = [f for f in self._lint("src/regression/") if "directory" in f]
|
|
92
|
+
self.assertTrue(any("assert_declared_deliverables" in f for f in found))
|
|
93
|
+
|
|
94
|
+
def test_file_produces_is_not_flagged(self):
|
|
95
|
+
self.assertEqual(
|
|
96
|
+
[f for f in self._lint("src/Regression.java") if "directory" in f], [],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def test_glob_produces_is_not_flagged(self):
|
|
100
|
+
self.assertEqual(
|
|
101
|
+
[f for f in self._lint("src/**/*.java") if "directory" in f], [],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestDispatchRefusesBeforeSpending(unittest.TestCase):
|
|
107
|
+
"""Surface 2: the driver must refuse without opening a session."""
|
|
108
|
+
|
|
109
|
+
def test_shape_gate_is_wired_before_the_attempt_loop(self):
|
|
110
|
+
"""The guard is worthless if it is defined but never reached.
|
|
111
|
+
|
|
112
|
+
Asserts the call site sits BEFORE `head_before = git("rev-parse",
|
|
113
|
+
"HEAD")`, which is where dispatch state starts changing. A guard
|
|
114
|
+
placed after that point would still cost the session it exists to
|
|
115
|
+
prevent.
|
|
116
|
+
"""
|
|
117
|
+
src = (
|
|
118
|
+
Path(__file__).resolve().parents[1]
|
|
119
|
+
/ "specfuse" / "loop" / "loop.py"
|
|
120
|
+
).read_text()
|
|
121
|
+
call = src.index("shape_ok, shape_reason = assert_produces_shape(wu)")
|
|
122
|
+
head = src.index('head_before = git("rev-parse", "HEAD")')
|
|
123
|
+
attempt_loop = src.index("for attempt in range(1, MAX_ATTEMPTS + 1):")
|
|
124
|
+
self.assertLess(call, head, "shape gate runs after dispatch begins")
|
|
125
|
+
self.assertLess(call, attempt_loop, "shape gate runs inside the attempt loop")
|
|
126
|
+
|
|
127
|
+
def test_refusal_escalates_immediately_rather_than_retrying(self):
|
|
128
|
+
"""A retry cannot help: the agent cannot edit its own frontmatter."""
|
|
129
|
+
src = (
|
|
130
|
+
Path(__file__).resolve().parents[1]
|
|
131
|
+
/ "specfuse" / "loop" / "loop.py"
|
|
132
|
+
).read_text()
|
|
133
|
+
block = src[src.index("shape_ok, shape_reason = assert_produces_shape(wu)"):][:1200]
|
|
134
|
+
self.assertIn('backend.set_wu(wu, "status", "blocked_human")', block)
|
|
135
|
+
self.assertIn("produces_shape_invalid", block)
|
|
136
|
+
|
|
137
|
+
def test_refusal_carries_a_failure_class_so_it_clusters(self):
|
|
138
|
+
"""#593's smaller finding: guard refusals were invisible to
|
|
139
|
+
learnings-suggest because failure_class/failure_signature were null."""
|
|
140
|
+
src = (
|
|
141
|
+
Path(__file__).resolve().parents[1]
|
|
142
|
+
/ "specfuse" / "loop" / "loop.py"
|
|
143
|
+
).read_text()
|
|
144
|
+
block = src[src.index("shape_ok, shape_reason = assert_produces_shape(wu)"):][:1200]
|
|
145
|
+
self.assertIn('"failure_class": "guard_refusal"', block)
|
|
146
|
+
self.assertIn('"failure_signature": "assert_produces_shape"', block)
|
|
147
|
+
|
|
148
|
+
def test_shape_guard_and_dispatch_guard_render_one_message(self):
|
|
149
|
+
"""Three surfaces, one wording -- they must not drift apart."""
|
|
150
|
+
from specfuse.loop.loop import WorkUnit, assert_produces_shape
|
|
151
|
+
wu = WorkUnit.__new__(WorkUnit)
|
|
152
|
+
object.__setattr__(wu, "produces", ["src/regression/"]) if hasattr(
|
|
153
|
+
WorkUnit, "__slots__") else setattr(wu, "produces", ["src/regression/"])
|
|
154
|
+
ok, reason = assert_produces_shape(wu)
|
|
155
|
+
self.assertFalse(ok)
|
|
156
|
+
self.assertEqual(reason, produces_shape_error("src/regression/"))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
unittest.main()
|