specfuse-loop 0.3.17__tar.gz → 0.3.18__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.3.17/specfuse_loop.egg-info → specfuse_loop-0.3.18}/PKG-INFO +1 -1
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/pyproject.toml +1 -1
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/_miniyaml.py +14 -7
- specfuse_loop-0.3.18/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/verification.yml.example +12 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/lint_plan.py +87 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/loop.py +84 -6
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/SOURCES.txt +1 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_attempt_outcome_emission.py +43 -0
- specfuse_loop-0.3.18/tests/test_lint_verification_reference.py +177 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_orchestration.py +35 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_miniyaml_negative.py +10 -7
- specfuse_loop-0.3.17/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/LICENSE +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/NOTICE +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/README.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/setup.cfg +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/_filelock.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_autoclose_deferral_visibility.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_bash_routing.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_bookkeeping_no_verify.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_claude_resolution.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_close_wu_self_reference.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_console_encoding.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_ensure_feature_branch_base.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_filelock_portable.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_interpreter_normalization.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_attempt_evidence.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_redaction_windows_home.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_resolve_base.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_timeout_kill_portable.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_untracked_file_absorption.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_windows_gate_exec.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/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.3.
|
|
7
|
+
version = "0.3.18"
|
|
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"
|
|
@@ -53,7 +53,9 @@ on a missing gate set — silent misparses are worse than crashes.
|
|
|
53
53
|
fields that the driver writes to WU frontmatter.
|
|
54
54
|
- **booleans** — exact lowercase `true` / `false` only. `True`, `yes`,
|
|
55
55
|
`on`, etc. are errors.
|
|
56
|
-
- **empty value** — a key followed by `:` with nothing
|
|
56
|
+
- **empty value / explicit null** — a key followed by `:` with nothing
|
|
57
|
+
after is `None`; so are the explicit YAML null spellings `null`,
|
|
58
|
+
`Null`, `NULL`, and `~` (matching yaml.safe_load).
|
|
57
59
|
* **Inline (flow) lists** — `[]`, `[a]`, `[a, b]`. Items are scalars only;
|
|
58
60
|
nested brackets are an error. Real usage is correlation-ID lists.
|
|
59
61
|
* **Comments** — `#` starting a line (after optional whitespace), or
|
|
@@ -73,8 +75,9 @@ on a missing gate set — silent misparses are worse than crashes.
|
|
|
73
75
|
* Mixed / inconsistent indentation.
|
|
74
76
|
* Nested brackets inside a flow list.
|
|
75
77
|
* Escape sequences other than `\\` and `\"` in double-quoted strings.
|
|
76
|
-
* Boolean spellings other than exact lowercase `true` / `false
|
|
77
|
-
|
|
78
|
+
* Boolean spellings other than exact lowercase `true` / `false` (the YAML
|
|
79
|
+
1.1 "Norway problem" — `yes`/`no`/`on`/`off`/`True` — stays rejected on
|
|
80
|
+
purpose; only `null` is accepted as a non-lowercase-keyword idiom).
|
|
78
81
|
* Integers with sign, leading zeros, floats, hex, or underscores.
|
|
79
82
|
|
|
80
83
|
A file using any of those raises `MiniYAMLError` with a line number and a
|
|
@@ -108,7 +111,7 @@ _FORBIDDEN_BOOLS = {
|
|
|
108
111
|
"yes", "Yes", "YES", "no", "No", "NO",
|
|
109
112
|
"on", "On", "ON", "off", "Off", "OFF",
|
|
110
113
|
}
|
|
111
|
-
|
|
114
|
+
_NULL_SCALARS = {"null", "Null", "NULL", "~"}
|
|
112
115
|
|
|
113
116
|
|
|
114
117
|
# --------------------------------------------------------------------------- #
|
|
@@ -363,9 +366,13 @@ def _parse_scalar(text: str, lineno: int) -> Any:
|
|
|
363
366
|
# forgiving result-block parse path on real agent output.
|
|
364
367
|
raise MiniYAMLError(
|
|
365
368
|
f"line {lineno}: flow mappings unsupported — use a block mapping")
|
|
366
|
-
if text in
|
|
367
|
-
|
|
368
|
-
|
|
369
|
+
if text in _NULL_SCALARS:
|
|
370
|
+
# Explicit YAML null spellings map to None, same as an empty value.
|
|
371
|
+
# Accepting them (rather than failing loud) removes a foot-gun for
|
|
372
|
+
# hand-authored frontmatter and for anything generated by a real YAML
|
|
373
|
+
# library, which emits `null` for None — and matches yaml.safe_load,
|
|
374
|
+
# tightening the equivalence guarantee (#177).
|
|
375
|
+
return None
|
|
369
376
|
if text in _FORBIDDEN_BOOLS:
|
|
370
377
|
raise MiniYAMLError(
|
|
371
378
|
f"line {lineno}: only lowercase `true`/`false` accepted as "
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.18
|
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
#
|
|
29
29
|
# cost_tracking: true
|
|
30
30
|
|
|
31
|
+
# PER-GATE `wu_must_reference: true` (optional, default false). Flag a gate
|
|
32
|
+
# whose fix is MECHANICAL over a work unit's own output — a formatter, an
|
|
33
|
+
# auto-fixable linter (`spotless:check`, `black --check`, `gofmt -l`,
|
|
34
|
+
# `prettier --check`). The plan linter (specfuse-lint) then WARNs when an
|
|
35
|
+
# implementation WU's Verification section names none of that gate's command —
|
|
36
|
+
# catching, at authoring time and zero model cost, the case where a WU forgets
|
|
37
|
+
# a mechanical gate and the driver spins retries on the identical failure until
|
|
38
|
+
# the attempt budget is exhausted. Leave it off for project-global gates
|
|
39
|
+
# (leak-scan, hygiene checks) that an individual WU has no reason to name.
|
|
31
40
|
code:
|
|
32
41
|
- name: tests
|
|
33
42
|
command: "pytest -q"
|
|
@@ -37,6 +46,9 @@ code:
|
|
|
37
46
|
command: "python3 -W error -c 'pass'" # replace with your build's -Werror equivalent
|
|
38
47
|
- name: lint
|
|
39
48
|
command: "ruff check ."
|
|
49
|
+
- name: format
|
|
50
|
+
command: "black --check ." # deterministic formatter: fix with `black .`
|
|
51
|
+
wu_must_reference: true # WUs must name it (see note above)
|
|
40
52
|
- name: security
|
|
41
53
|
command: "bandit -r src -ll" # or your OWASP-aligned scanner
|
|
42
54
|
|
|
@@ -138,6 +138,69 @@ def _slice_section(body: str, section_name: str) -> str:
|
|
|
138
138
|
return after[:em.start()] if em else after
|
|
139
139
|
|
|
140
140
|
|
|
141
|
+
# Interpreters / super-generic words that don't distinguish one gate command
|
|
142
|
+
# from another — excluded from a command's signature tokens (#176).
|
|
143
|
+
_GATE_TOKEN_STOP = {
|
|
144
|
+
"python", "python3", "bash", "sh", "run", "exec", "discover",
|
|
145
|
+
"report", "source", "true", "false",
|
|
146
|
+
}
|
|
147
|
+
_GATE_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_.:+-]{2,}")
|
|
148
|
+
# Verification prose that delegates to the whole gate set — treat as
|
|
149
|
+
# referencing every gate so a legitimate "all gates pass" phrasing is not
|
|
150
|
+
# flagged.
|
|
151
|
+
_UMBRELLA_GATES_RE = re.compile(r"(?i)\b(all|every|entire|full|the)\b[^.\n]{0,24}\bgate")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _must_reference_gate_signatures(feature_dir: Path) -> list[tuple[str, set[str]]]:
|
|
155
|
+
"""Return [(gate_name, signature_tokens)] for OPT-IN must-reference gates (#176).
|
|
156
|
+
|
|
157
|
+
Reads ``<project>/.specfuse/verification.yml`` (``feature_dir`` is
|
|
158
|
+
``.../.specfuse/features/<name>``) and includes only `code` gates that set
|
|
159
|
+
``wu_must_reference: true``. This is deliberately opt-in: warning on every
|
|
160
|
+
declared gate is noise on real multi-gate projects (leak-scan, hygiene
|
|
161
|
+
bats, etc. that an individual WU has no business naming). A project flags
|
|
162
|
+
the gates whose fix is mechanical over a WU's own output — formatters,
|
|
163
|
+
auto-fixable linters — the class #175 shows can burn the whole attempt
|
|
164
|
+
budget when a WU forgets them. Empty list (the check no-ops) when the file
|
|
165
|
+
is absent, unparseable, or no gate opts in. Each gate's token set is the
|
|
166
|
+
distinctive words of its command plus the gate name, lowercased.
|
|
167
|
+
"""
|
|
168
|
+
vpath = feature_dir.parent.parent / "verification.yml"
|
|
169
|
+
if not vpath.is_file():
|
|
170
|
+
return []
|
|
171
|
+
try:
|
|
172
|
+
cfg = _miniyaml.parse(vpath.read_text()) or {}
|
|
173
|
+
except _miniyaml.MiniYAMLError:
|
|
174
|
+
return []
|
|
175
|
+
out: list[tuple[str, set[str]]] = []
|
|
176
|
+
for gate in cfg.get("code") or []:
|
|
177
|
+
if not isinstance(gate, dict) or gate.get("wu_must_reference") is not True:
|
|
178
|
+
continue
|
|
179
|
+
name = str(gate.get("name") or "").strip()
|
|
180
|
+
command = str(gate.get("command") or "")
|
|
181
|
+
tokens = {t.lower() for t in _GATE_TOKEN_RE.findall(command)
|
|
182
|
+
if t.lower() not in _GATE_TOKEN_STOP}
|
|
183
|
+
if name:
|
|
184
|
+
tokens.add(name.lower())
|
|
185
|
+
if tokens:
|
|
186
|
+
out.append((name or command[:40], tokens))
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _unreferenced_code_gates(
|
|
191
|
+
verification_text: str, signatures: list[tuple[str, set[str]]],
|
|
192
|
+
) -> list[str]:
|
|
193
|
+
"""Names of code gates whose tokens *verification_text* references none of.
|
|
194
|
+
|
|
195
|
+
An umbrella "all gates" phrasing short-circuits to [] (author delegated).
|
|
196
|
+
"""
|
|
197
|
+
if _UMBRELLA_GATES_RE.search(verification_text):
|
|
198
|
+
return []
|
|
199
|
+
low = verification_text.lower()
|
|
200
|
+
return [name for name, tokens in signatures
|
|
201
|
+
if not any(tok in low for tok in tokens)]
|
|
202
|
+
|
|
203
|
+
|
|
141
204
|
def detect_oracle_verbs(ac_section_text: str) -> list[str]:
|
|
142
205
|
"""Return matched oracle-verb strings found in the AC section text."""
|
|
143
206
|
found = []
|
|
@@ -249,6 +312,11 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
249
312
|
if missing:
|
|
250
313
|
errs.append(f"PLAN.md frontmatter missing keys: {sorted(missing)}")
|
|
251
314
|
|
|
315
|
+
# Opt-in must-reference gate signatures, loaded once for the
|
|
316
|
+
# Verification-reference WARN below (#176). Empty unless the project flags
|
|
317
|
+
# gates with `wu_must_reference: true`.
|
|
318
|
+
must_ref_gate_sigs = _must_reference_gate_signatures(feature_dir)
|
|
319
|
+
|
|
252
320
|
if "base" in fm:
|
|
253
321
|
base_val = fm["base"]
|
|
254
322
|
feature_id_val = fm.get("feature_id", feature_dir.name)
|
|
@@ -400,6 +468,25 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
400
468
|
f"driver. See authoring-work-units §9 + FEAT-2026-0017."
|
|
401
469
|
)
|
|
402
470
|
|
|
471
|
+
# Verification ↔ must-reference gate WARN (#176). For gates a
|
|
472
|
+
# project has flagged `wu_must_reference: true` — those whose fix is
|
|
473
|
+
# mechanical over a WU's own output (formatters, auto-fixable
|
|
474
|
+
# linters) — the driver enforces them regardless of what a WU's
|
|
475
|
+
# Verification names. A session not told to run one writes code that
|
|
476
|
+
# fails it and, per #175, can burn the whole attempt budget on the
|
|
477
|
+
# mechanical failure. Flag at lint time, at zero model cost. Opt-in,
|
|
478
|
+
# so no noise on projects that don't flag any gate.
|
|
479
|
+
if wu_type_val == "implementation" and must_ref_gate_sigs:
|
|
480
|
+
verif_text = _slice_section(wbody, "Verification")
|
|
481
|
+
unref = _unreferenced_code_gates(verif_text, must_ref_gate_sigs)
|
|
482
|
+
if unref:
|
|
483
|
+
print(
|
|
484
|
+
f"WARN: {wfile}: Verification references none of the "
|
|
485
|
+
f"must-reference gate(s): {', '.join(unref)}. The driver "
|
|
486
|
+
f"runs them regardless; name each so the session knows to "
|
|
487
|
+
f"satisfy it before finishing (#176)."
|
|
488
|
+
)
|
|
489
|
+
|
|
403
490
|
# Deliverable-presence declaration WARN (FEAT-2026-0022/T01).
|
|
404
491
|
# Advisory: an implementation WU should declare the file path(s) it
|
|
405
492
|
# is contracted to yield via `produces:`, which T02's presence gate
|
|
@@ -63,7 +63,7 @@ SPECFUSE_DIR = Path(".specfuse")
|
|
|
63
63
|
REPO_ROOT = SPECFUSE_DIR.parent
|
|
64
64
|
FEATURES_DIR = SPECFUSE_DIR / "features"
|
|
65
65
|
VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
|
|
66
|
-
DRIVER_VERSION = "0.3.
|
|
66
|
+
DRIVER_VERSION = "0.3.18"
|
|
67
67
|
# Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
|
|
68
68
|
# version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
|
|
69
69
|
# the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
|
|
@@ -190,14 +190,27 @@ FM = re.compile(r"^---\s*$")
|
|
|
190
190
|
|
|
191
191
|
|
|
192
192
|
def read_frontmatter(path: Path) -> tuple[dict, str]:
|
|
193
|
-
"""Return (frontmatter_dict, body_text).
|
|
193
|
+
"""Return (frontmatter_dict, body_text).
|
|
194
|
+
|
|
195
|
+
A parse failure is re-raised with the file path attached and the line
|
|
196
|
+
clarified as frontmatter-relative (add 1 for the file line, since the
|
|
197
|
+
opening `---` is line 1). Without this a `_miniyaml.MiniYAMLError` carries
|
|
198
|
+
only a block-relative line number, so locating the offending file across
|
|
199
|
+
many feature folders meant grepping by hand (#177).
|
|
200
|
+
"""
|
|
194
201
|
lines = path.read_text().splitlines()
|
|
195
202
|
if not lines or not FM.match(lines[0]):
|
|
196
203
|
return {}, path.read_text()
|
|
197
204
|
j = 1
|
|
198
205
|
while j < len(lines) and not FM.match(lines[j]):
|
|
199
206
|
j += 1
|
|
200
|
-
|
|
207
|
+
try:
|
|
208
|
+
fm = _miniyaml.parse("\n".join(lines[1:j])) or {}
|
|
209
|
+
except _miniyaml.MiniYAMLError as exc:
|
|
210
|
+
raise _miniyaml.MiniYAMLError(
|
|
211
|
+
f"{path}: {exc} (line is relative to the frontmatter block; "
|
|
212
|
+
f"add 1 for the file line)"
|
|
213
|
+
) from exc
|
|
201
214
|
body = "\n".join(lines[j + 1 :])
|
|
202
215
|
return fm, body
|
|
203
216
|
|
|
@@ -261,7 +274,16 @@ def find_feature(arg: str | None) -> Path:
|
|
|
261
274
|
for d in sorted(FEATURES_DIR.glob("*/")):
|
|
262
275
|
plan = d / "PLAN.md"
|
|
263
276
|
if plan.exists():
|
|
264
|
-
|
|
277
|
+
try:
|
|
278
|
+
fm, _ = read_frontmatter(plan)
|
|
279
|
+
except _miniyaml.MiniYAMLError as exc:
|
|
280
|
+
# One feature's malformed frontmatter must not block dispatch of
|
|
281
|
+
# every OTHER feature. Feature lookup is the earliest and least
|
|
282
|
+
# relevant failure point; skip the bad candidate with a warning
|
|
283
|
+
# rather than aborting the whole scan (#177).
|
|
284
|
+
print(f"warning: skipping {d.name} — unparseable PLAN.md "
|
|
285
|
+
f"frontmatter: {exc}", file=sys.stderr)
|
|
286
|
+
continue
|
|
265
287
|
if fm.get("status") == "active":
|
|
266
288
|
actives.append(d)
|
|
267
289
|
elif fm.get("status") == "done":
|
|
@@ -655,7 +677,56 @@ def extract_failure_excerpt(stdout: str, max_chars: int = 500) -> str:
|
|
|
655
677
|
encoded = text.encode("utf-8")
|
|
656
678
|
if len(encoded) <= max_chars:
|
|
657
679
|
return text
|
|
658
|
-
|
|
680
|
+
# Head+tail, not tail-only (#175): build-tool output puts the actionable
|
|
681
|
+
# part (the list of offending files) up top and boilerplate (help URLs,
|
|
682
|
+
# stack-trace hints) at the bottom. A tail-only slice reliably keeps the
|
|
683
|
+
# noise; splitting the budget preserves both ends.
|
|
684
|
+
marker = "\n...\n"
|
|
685
|
+
budget = max_chars - len(marker.encode("utf-8"))
|
|
686
|
+
if budget <= 0:
|
|
687
|
+
return encoded[-max_chars:].decode("utf-8", errors="ignore")
|
|
688
|
+
head_bytes = budget // 2
|
|
689
|
+
tail_bytes = budget - head_bytes
|
|
690
|
+
head = encoded[:head_bytes].decode("utf-8", errors="ignore")
|
|
691
|
+
tail = encoded[-tail_bytes:].decode("utf-8", errors="ignore")
|
|
692
|
+
return head + marker + tail
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
# Forward-looking retry directives keyed by failure_class (#175 fix 1). The
|
|
696
|
+
# gate output replayed into a retry describes files the per-attempt reset
|
|
697
|
+
# already discarded, and reads as stale advice about a prior attempt. These
|
|
698
|
+
# reframe it as a standing requirement for the fresh attempt and name the
|
|
699
|
+
# mechanical remedy per class — no per-tool knowledge.
|
|
700
|
+
_RETRY_CLASS_HINT: dict[str, str] = {
|
|
701
|
+
"lint": "This was a lint/format gate — its fix is mechanical: run the "
|
|
702
|
+
"formatter / linter's fix command over your changes and re-run the "
|
|
703
|
+
"gate before declaring done, rather than only rewriting the code.",
|
|
704
|
+
"tests": "Run the failing tests and make them pass before declaring done.",
|
|
705
|
+
"coverage": "Add tests covering the new or changed code to clear the "
|
|
706
|
+
"coverage gate before declaring done.",
|
|
707
|
+
"security": "Resolve the flagged security findings (or add a narrowly "
|
|
708
|
+
"scoped, justified suppression) before declaring done.",
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def synthesize_retry_directive(failure_class: "str | None") -> str:
|
|
713
|
+
"""Return a forward-looking retry instruction for *failure_class* (#175).
|
|
714
|
+
|
|
715
|
+
Prepended to the failure note fed into the next attempt. The note itself
|
|
716
|
+
is raw gate output about files the reset discarded; without this lead a
|
|
717
|
+
fresh session reads it as stale advice and reproduces the same omission
|
|
718
|
+
(e.g. never running the formatter), failing identically until attempts
|
|
719
|
+
exhaust. The lead reframes the output as a standing requirement and the
|
|
720
|
+
per-class hint names the remedy generically.
|
|
721
|
+
"""
|
|
722
|
+
lead = ("The previous attempt's work was DISCARDED before this retry, so "
|
|
723
|
+
"the gate output below refers to files that no longer exist. Do "
|
|
724
|
+
"not read it as stale advice about a past attempt — treat it as a "
|
|
725
|
+
"standing requirement for THIS fresh attempt. ")
|
|
726
|
+
hint = _RETRY_CLASS_HINT.get(
|
|
727
|
+
failure_class or "",
|
|
728
|
+
"Satisfy every declared gate before declaring done.")
|
|
729
|
+
return lead + hint
|
|
659
730
|
|
|
660
731
|
|
|
661
732
|
def emit_attempt_outcome(
|
|
@@ -4241,9 +4312,16 @@ def run(
|
|
|
4241
4312
|
+ "```diff\n" + _diff + "\n```\n"
|
|
4242
4313
|
)
|
|
4243
4314
|
attempt_notes.append((attempt, _evidence))
|
|
4244
|
-
failure_note = payload
|
|
4245
4315
|
_fc, _fs = parse_gate_failure_signature(payload)
|
|
4246
4316
|
_ex = extract_failure_excerpt(payload)
|
|
4317
|
+
# Prepend a forward-looking directive so the retry reads the
|
|
4318
|
+
# gate output as a standing requirement, not stale advice
|
|
4319
|
+
# about the discarded attempt (#175 fix 1).
|
|
4320
|
+
failure_note = (
|
|
4321
|
+
synthesize_retry_directive(_fc)
|
|
4322
|
+
+ "\n\n## Gate output from the discarded attempt\n\n"
|
|
4323
|
+
+ payload
|
|
4324
|
+
)
|
|
4247
4325
|
wu_events.append(emit_attempt_outcome(
|
|
4248
4326
|
wu, attempt, "failed",
|
|
4249
4327
|
attempts_usage[-1],
|
|
@@ -102,6 +102,7 @@ tests/test_lint_plan_relative_import.py
|
|
|
102
102
|
tests/test_lint_produces_driver_helper.py
|
|
103
103
|
tests/test_lint_sections.py
|
|
104
104
|
tests/test_lint_task_graph_yaml_selection.py
|
|
105
|
+
tests/test_lint_verification_reference.py
|
|
105
106
|
tests/test_loop_attempt_evidence.py
|
|
106
107
|
tests/test_loop_auto_archive.py
|
|
107
108
|
tests/test_loop_caveman_preamble.py
|
|
@@ -304,6 +304,49 @@ class TestExtractFailureExcerpt(unittest.TestCase):
|
|
|
304
304
|
self.fail(f"UnicodeDecodeError raised: {e}")
|
|
305
305
|
self.assertIsInstance(excerpt, str)
|
|
306
306
|
|
|
307
|
+
def test_keeps_head_and_tail_on_truncation(self):
|
|
308
|
+
# #175: build-tool output puts the offending-file list up top and
|
|
309
|
+
# boilerplate (help URLs, stack hints) at the bottom. A tail-only
|
|
310
|
+
# slice keeps the noise; head+tail must preserve both ends.
|
|
311
|
+
lines = ["Error: HEAD_MARKER offending file list here"]
|
|
312
|
+
lines += [f"Error: filler line {i} " + "x" * 40 for i in range(400)]
|
|
313
|
+
lines += ["Error: TAIL_MARKER re-run with -X for debug"]
|
|
314
|
+
excerpt = loop.extract_failure_excerpt("\n".join(lines), max_chars=500)
|
|
315
|
+
self.assertLessEqual(len(excerpt.encode("utf-8")), 500)
|
|
316
|
+
self.assertIn("HEAD_MARKER", excerpt)
|
|
317
|
+
self.assertIn("TAIL_MARKER", excerpt)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class TestSynthesizeRetryDirective(unittest.TestCase):
|
|
321
|
+
"""#175 fix 1: the retry note must carry a forward-looking lesson, not
|
|
322
|
+
just replay output about files the reset already discarded."""
|
|
323
|
+
|
|
324
|
+
def test_lint_directive_is_forward_looking(self):
|
|
325
|
+
d = loop.synthesize_retry_directive("lint")
|
|
326
|
+
self.assertIn("discard", d.lower())
|
|
327
|
+
# Names the mechanical remedy without per-tool knowledge.
|
|
328
|
+
self.assertIn("format", d.lower())
|
|
329
|
+
self.assertIn("before", d.lower())
|
|
330
|
+
|
|
331
|
+
def test_tests_directive_mentions_tests(self):
|
|
332
|
+
d = loop.synthesize_retry_directive("tests")
|
|
333
|
+
self.assertIn("test", d.lower())
|
|
334
|
+
self.assertIn("before", d.lower())
|
|
335
|
+
|
|
336
|
+
def test_coverage_and_security_have_specific_hints(self):
|
|
337
|
+
self.assertIn("coverage", loop.synthesize_retry_directive("coverage").lower())
|
|
338
|
+
self.assertIn(
|
|
339
|
+
"finding", loop.synthesize_retry_directive("security").lower())
|
|
340
|
+
|
|
341
|
+
def test_unknown_class_falls_back_to_generic_non_empty(self):
|
|
342
|
+
d = loop.synthesize_retry_directive("other")
|
|
343
|
+
self.assertTrue(d.strip())
|
|
344
|
+
self.assertIn("discard", d.lower())
|
|
345
|
+
|
|
346
|
+
def test_none_class_does_not_crash(self):
|
|
347
|
+
d = loop.synthesize_retry_directive(None)
|
|
348
|
+
self.assertTrue(d.strip())
|
|
349
|
+
|
|
307
350
|
|
|
308
351
|
# --------------------------------------------------------------------------- #
|
|
309
352
|
# TestCumulativeFoldOnRearm #
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""#176: warn when an implementation WU's Verification omits a declared code gate.
|
|
6
|
+
|
|
7
|
+
The driver enforces the full `code` gate set regardless of what a WU's
|
|
8
|
+
Verification names, so the two can silently disagree — the authoring-side
|
|
9
|
+
trigger of #175. This lint catches it before dispatch at zero model cost.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
import tempfile
|
|
16
|
+
import unittest
|
|
17
|
+
from contextlib import redirect_stdout
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from tests._loop_loader import load_lint
|
|
21
|
+
|
|
22
|
+
lint_plan = load_lint()
|
|
23
|
+
|
|
24
|
+
# Only `format` opts in via wu_must_reference; tests/lint are NOT checked.
|
|
25
|
+
_VERIFICATION = """\
|
|
26
|
+
code:
|
|
27
|
+
- name: tests
|
|
28
|
+
command: "python3 -m unittest discover -s tests -v"
|
|
29
|
+
- name: lint
|
|
30
|
+
command: "ruff check specfuse tests"
|
|
31
|
+
- name: format
|
|
32
|
+
command: "./mvnw spotless:check"
|
|
33
|
+
wu_must_reference: true
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestMustReferenceGateSignatureHelpers(unittest.TestCase):
|
|
38
|
+
|
|
39
|
+
def _feature_with_verification(self, tmp: str, yml: str | None) -> Path:
|
|
40
|
+
root = Path(tmp)
|
|
41
|
+
fdir = root / ".specfuse" / "features" / "FEAT-2026-9401-x"
|
|
42
|
+
fdir.mkdir(parents=True)
|
|
43
|
+
if yml is not None:
|
|
44
|
+
(root / ".specfuse" / "verification.yml").write_text(yml)
|
|
45
|
+
return fdir
|
|
46
|
+
|
|
47
|
+
def test_only_flagged_gate_is_included(self):
|
|
48
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
49
|
+
fdir = self._feature_with_verification(tmp, _VERIFICATION)
|
|
50
|
+
sigs = dict(lint_plan._must_reference_gate_signatures(fdir))
|
|
51
|
+
# tests/lint are NOT flagged → excluded; only format opts in.
|
|
52
|
+
self.assertEqual(set(sigs), {"format"})
|
|
53
|
+
self.assertIn("spotless:check", sigs["format"])
|
|
54
|
+
self.assertIn("format", sigs["format"]) # gate name is a token
|
|
55
|
+
|
|
56
|
+
def test_no_flag_anywhere_returns_empty(self):
|
|
57
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
58
|
+
plain = ("code:\n - name: tests\n"
|
|
59
|
+
" command: \"pytest -q\"\n")
|
|
60
|
+
fdir = self._feature_with_verification(tmp, plain)
|
|
61
|
+
self.assertEqual(lint_plan._must_reference_gate_signatures(fdir), [])
|
|
62
|
+
|
|
63
|
+
def test_no_verification_file_returns_empty(self):
|
|
64
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
65
|
+
fdir = self._feature_with_verification(tmp, None)
|
|
66
|
+
self.assertEqual(lint_plan._must_reference_gate_signatures(fdir), [])
|
|
67
|
+
|
|
68
|
+
def test_unparseable_verification_returns_empty(self):
|
|
69
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
70
|
+
fdir = self._feature_with_verification(tmp, "code: {bad: flow}\n")
|
|
71
|
+
self.assertEqual(lint_plan._must_reference_gate_signatures(fdir), [])
|
|
72
|
+
|
|
73
|
+
def test_unreferenced_detects_omitted_gate(self):
|
|
74
|
+
sigs = [("tests", {"tests", "unittest"}),
|
|
75
|
+
("format", {"format", "spotless:check"})]
|
|
76
|
+
# Names tests but not the formatter → only 'format' is unreferenced.
|
|
77
|
+
text = "Run the unittest suite; all tests pass."
|
|
78
|
+
self.assertEqual(lint_plan._unreferenced_code_gates(text, sigs),
|
|
79
|
+
["format"])
|
|
80
|
+
|
|
81
|
+
def test_umbrella_phrase_short_circuits(self):
|
|
82
|
+
sigs = [("format", {"format", "spotless:check"})]
|
|
83
|
+
for phrase in ("all declared gates pass", "every gate is green",
|
|
84
|
+
"the full gate set runs clean"):
|
|
85
|
+
self.assertEqual(
|
|
86
|
+
lint_plan._unreferenced_code_gates(phrase, sigs), [],
|
|
87
|
+
f"{phrase!r} should short-circuit")
|
|
88
|
+
|
|
89
|
+
def test_all_referenced_is_empty(self):
|
|
90
|
+
sigs = [("tests", {"tests"}), ("format", {"spotless:check"})]
|
|
91
|
+
text = "tests pass; ran spotless:check clean."
|
|
92
|
+
self.assertEqual(lint_plan._unreferenced_code_gates(text, sigs), [])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestVerificationReferenceLintWarn(unittest.TestCase):
|
|
96
|
+
|
|
97
|
+
def _make_feature(self, tmp: str, verification_body: str,
|
|
98
|
+
yml: str | None = _VERIFICATION) -> Path:
|
|
99
|
+
root = Path(tmp)
|
|
100
|
+
fdir = root / ".specfuse" / "features" / "FEAT-2026-9401-x"
|
|
101
|
+
fdir.mkdir(parents=True)
|
|
102
|
+
if yml is not None:
|
|
103
|
+
(root / ".specfuse" / "verification.yml").write_text(yml)
|
|
104
|
+
(fdir / "PLAN.md").write_text(
|
|
105
|
+
"---\n"
|
|
106
|
+
"feature_id: FEAT-2026-9401\n"
|
|
107
|
+
"title: verification-reference lint\n"
|
|
108
|
+
"branch: feat/vref\n"
|
|
109
|
+
"roadmap_goal: Check the Verification-reference WARN.\n"
|
|
110
|
+
"status: active\n"
|
|
111
|
+
"---\n\n# Plan\n\n```yaml\n"
|
|
112
|
+
"gates:\n"
|
|
113
|
+
" - gate: 1\n"
|
|
114
|
+
" file: GATE-01.md\n"
|
|
115
|
+
" work_units:\n"
|
|
116
|
+
" - id: FEAT-2026-9401/T01\n"
|
|
117
|
+
" file: WU-01-impl.md\n"
|
|
118
|
+
" depends_on: []\n"
|
|
119
|
+
" - id: FEAT-2026-9401/G1-CLOSE\n"
|
|
120
|
+
" file: WU-90-close.md\n"
|
|
121
|
+
" depends_on: [FEAT-2026-9401/T01]\n"
|
|
122
|
+
"```\n"
|
|
123
|
+
)
|
|
124
|
+
(fdir / "WU-01-impl.md").write_text(
|
|
125
|
+
"---\nid: FEAT-2026-9401/T01\ntype: implementation\n"
|
|
126
|
+
"status: done\nattempts: 1\nproduces: [src/x.py]\n---\n\n"
|
|
127
|
+
"# Title\n\n**Context.**\nAdd a file.\n\n"
|
|
128
|
+
"**Acceptance criteria.**\nThe code works.\n\n"
|
|
129
|
+
"**Do not touch.**\nNothing.\n\n"
|
|
130
|
+
f"**Verification.**\n{verification_body}\n\n"
|
|
131
|
+
"**Escalation triggers.**\nEmit blocked if wrong.\n"
|
|
132
|
+
)
|
|
133
|
+
(fdir / "WU-90-close.md").write_text(
|
|
134
|
+
"---\nid: FEAT-2026-9401/G1-CLOSE\ntype: close\n"
|
|
135
|
+
"status: done\nattempts: 1\n---\n\n# Close\n"
|
|
136
|
+
)
|
|
137
|
+
(fdir / "GATE-01.md").write_text("---\nstatus: open\n---\n\n# Gate 1\n")
|
|
138
|
+
return fdir
|
|
139
|
+
|
|
140
|
+
def _lint(self, fdir: Path) -> str:
|
|
141
|
+
buf = io.StringIO()
|
|
142
|
+
with redirect_stdout(buf):
|
|
143
|
+
lint_plan.lint(fdir)
|
|
144
|
+
return buf.getvalue()
|
|
145
|
+
|
|
146
|
+
def test_warns_when_formatter_gate_omitted(self):
|
|
147
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
148
|
+
fdir = self._make_feature(
|
|
149
|
+
tmp, "Run the unittest suite and ruff; all pass.")
|
|
150
|
+
out = self._lint(fdir)
|
|
151
|
+
self.assertIn("WARN", out)
|
|
152
|
+
# Only the omitted formatter gate is named; the referenced
|
|
153
|
+
# tests/lint gates are not listed as unreferenced.
|
|
154
|
+
listed = out.split("gate(s):", 1)[1].split("\n", 1)[0]
|
|
155
|
+
self.assertIn("format", listed)
|
|
156
|
+
self.assertNotIn("tests", listed)
|
|
157
|
+
self.assertNotIn("lint", listed)
|
|
158
|
+
|
|
159
|
+
def test_silent_when_all_gates_named(self):
|
|
160
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
161
|
+
fdir = self._make_feature(
|
|
162
|
+
tmp, "unittest suite passes; ruff clean; spotless:check clean.")
|
|
163
|
+
self.assertNotIn("references none", self._lint(fdir))
|
|
164
|
+
|
|
165
|
+
def test_silent_on_umbrella_phrase(self):
|
|
166
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
167
|
+
fdir = self._make_feature(tmp, "All declared code gates pass.")
|
|
168
|
+
self.assertNotIn("references none", self._lint(fdir))
|
|
169
|
+
|
|
170
|
+
def test_silent_when_no_verification_yml(self):
|
|
171
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
172
|
+
fdir = self._make_feature(tmp, "anything at all", yml=None)
|
|
173
|
+
self.assertNotIn("references none", self._lint(fdir))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
unittest.main()
|
|
@@ -320,6 +320,41 @@ class TestFindFeatureSelection(unittest.TestCase):
|
|
|
320
320
|
result = loop.find_feature("FEAT-2026-9502-explicit")
|
|
321
321
|
self.assertEqual(result, fdir)
|
|
322
322
|
|
|
323
|
+
def test_null_in_frontmatter_does_not_crash_lookup(self):
|
|
324
|
+
"""#177 fix 3: an explicit null in frontmatter parses fine now."""
|
|
325
|
+
fdir = self._features_dir / "FEAT-2026-9502-nullkey"
|
|
326
|
+
fdir.mkdir(parents=True)
|
|
327
|
+
(fdir / "PLAN.md").write_text(
|
|
328
|
+
"---\nfeature_id: FEAT-2026-9502\nstatus: active\n"
|
|
329
|
+
"supersedes_gate_of: null\n---\n\n# Plan\n"
|
|
330
|
+
)
|
|
331
|
+
self.assertEqual(loop.find_feature(None), fdir)
|
|
332
|
+
|
|
333
|
+
def test_unparseable_candidate_is_skipped_not_fatal(self):
|
|
334
|
+
"""#177 fix 2: a malformed PLAN.md in one feature must not block
|
|
335
|
+
dispatch of a different, valid active feature."""
|
|
336
|
+
bad = self._features_dir / "FEAT-2026-9502-bad"
|
|
337
|
+
bad.mkdir(parents=True)
|
|
338
|
+
# Single-quoted scalar is unsupported → MiniYAMLError on this file only.
|
|
339
|
+
(bad / "PLAN.md").write_text(
|
|
340
|
+
"---\nfeature_id: FEAT-2026-9502\nstatus: 'active'\n---\n\n# Plan\n"
|
|
341
|
+
)
|
|
342
|
+
good = self._write_plan("FEAT-2026-9502-good", "active")
|
|
343
|
+
# The bad one is skipped; the good active feature still resolves.
|
|
344
|
+
self.assertEqual(loop.find_feature(None), good)
|
|
345
|
+
|
|
346
|
+
def test_read_frontmatter_error_names_the_file(self):
|
|
347
|
+
"""#177 fix 1: a parse error carries the file path, not just a
|
|
348
|
+
block-relative line number."""
|
|
349
|
+
bad = self._features_dir / "FEAT-2026-9502-badfm"
|
|
350
|
+
bad.mkdir(parents=True)
|
|
351
|
+
plan = bad / "PLAN.md"
|
|
352
|
+
plan.write_text("---\nstatus: 'active'\n---\n\n# Plan\n")
|
|
353
|
+
with self.assertRaises(loop._miniyaml.MiniYAMLError) as ctx:
|
|
354
|
+
loop.read_frontmatter(plan)
|
|
355
|
+
self.assertIn("PLAN.md", str(ctx.exception))
|
|
356
|
+
self.assertIn(str(plan), str(ctx.exception))
|
|
357
|
+
|
|
323
358
|
def test_deferred_feature_is_not_auto_selected(self):
|
|
324
359
|
"""Issue #106: a `deferred` foldered feature is non-dispatchable — the
|
|
325
360
|
driver skips it exactly like a non-active feature (parked, resumable)."""
|
|
@@ -98,13 +98,16 @@ class TestUnsupportedConstructsFailLoudly(unittest.TestCase):
|
|
|
98
98
|
def test_top_level_indent_nonzero_rejected(self):
|
|
99
99
|
self._expect_error(" foo: 1\n", "indent 0")
|
|
100
100
|
|
|
101
|
-
# --- explicit nulls
|
|
102
|
-
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
101
|
+
# --- explicit nulls now ACCEPTED (#177); forbidden boolean spellings ---
|
|
102
|
+
|
|
103
|
+
def test_explicit_null_spellings_accepted_as_none(self):
|
|
104
|
+
# #177: null/~/Null/NULL map to None, matching yaml.safe_load — the
|
|
105
|
+
# explicit-null rejection used to crash feature lookup with a
|
|
106
|
+
# traceback. Boolean spellings below stay rejected (Norway problem).
|
|
107
|
+
for spelling in ("null", "Null", "NULL", "~"):
|
|
108
|
+
self.assertEqual(
|
|
109
|
+
miniyaml.parse(f"foo: {spelling}\n"), {"foo": None},
|
|
110
|
+
f"{spelling!r} should parse to None")
|
|
108
111
|
|
|
109
112
|
def test_titlecase_boolean_rejected(self):
|
|
110
113
|
self._expect_error("foo: True\n", "lowercase", "true")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.17
|
|
File without changes
|
|
File without changes
|
|
File without changes
|