specfuse-loop 0.3.11__tar.gz → 0.3.13__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.11/specfuse_loop.egg-info → specfuse_loop-0.3.13}/PKG-INFO +1 -1
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/pyproject.toml +1 -1
- specfuse_loop-0.3.13/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/loop.py +128 -11
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse_loop.egg-info/SOURCES.txt +2 -0
- specfuse_loop-0.3.13/tests/test_close_wu_self_reference.py +115 -0
- specfuse_loop-0.3.13/tests/test_prepare_scaffold_sync.py +172 -0
- specfuse_loop-0.3.11/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/LICENSE +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/NOTICE +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/README.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/setup.cfg +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/lint_plan.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_orchestration.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/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.13"
|
|
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.3.13
|
|
@@ -62,7 +62,7 @@ SPECFUSE_DIR = Path(".specfuse")
|
|
|
62
62
|
REPO_ROOT = SPECFUSE_DIR.parent
|
|
63
63
|
FEATURES_DIR = SPECFUSE_DIR / "features"
|
|
64
64
|
VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
|
|
65
|
-
DRIVER_VERSION = "0.3.
|
|
65
|
+
DRIVER_VERSION = "0.3.13"
|
|
66
66
|
# Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
|
|
67
67
|
# version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
|
|
68
68
|
# the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
|
|
@@ -766,6 +766,84 @@ def _current_branch() -> str:
|
|
|
766
766
|
return git("branch", "--show-current")
|
|
767
767
|
|
|
768
768
|
|
|
769
|
+
def _default_branch() -> "str | None":
|
|
770
|
+
"""The repo's default branch, or None if undeterminable.
|
|
771
|
+
|
|
772
|
+
Prefers origin's HEAD symref (`origin/main` -> `main`); falls back to a
|
|
773
|
+
local `main`/`master` when there is no remote. None when neither resolves —
|
|
774
|
+
callers then treat the current branch as non-default (auto_sync commits its
|
|
775
|
+
scaffold sync rather than leaving it dangling).
|
|
776
|
+
"""
|
|
777
|
+
ref = subprocess.run(
|
|
778
|
+
["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
|
779
|
+
capture_output=True, text=True,
|
|
780
|
+
)
|
|
781
|
+
if ref.returncode == 0 and ref.stdout.strip():
|
|
782
|
+
name = ref.stdout.strip()
|
|
783
|
+
return name[len("origin/"):] if name.startswith("origin/") else name
|
|
784
|
+
for cand in ("main", "master"):
|
|
785
|
+
if subprocess.run(
|
|
786
|
+
["git", "rev-parse", "--verify", "--quiet", cand],
|
|
787
|
+
capture_output=True, text=True,
|
|
788
|
+
).returncode == 0:
|
|
789
|
+
return cand
|
|
790
|
+
return None
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
# Paths the scaffold overlay owns — auto_sync writes these on upgrade. They are
|
|
794
|
+
# the driver's, not the user's edits: they may carry onto a feature branch and
|
|
795
|
+
# are folded into the --prepare scaffold commit. Mirrors scaffold.py's versioned
|
|
796
|
+
# overlay set (templates/, rules/, docs/, schemas/) plus the meta files.
|
|
797
|
+
_SCAFFOLD_MANAGED_PREFIXES: tuple[str, ...] = (
|
|
798
|
+
".specfuse/templates/", ".specfuse/rules/",
|
|
799
|
+
".specfuse/docs/", ".specfuse/schemas/",
|
|
800
|
+
)
|
|
801
|
+
_SCAFFOLD_MANAGED_EXACT: frozenset[str] = frozenset({
|
|
802
|
+
".specfuse/VERSION", ".specfuse/.scaffold-manifest",
|
|
803
|
+
".specfuse/verification.yml.example",
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _is_scaffold_managed(path: str) -> bool:
|
|
808
|
+
return (path in _SCAFFOLD_MANAGED_EXACT
|
|
809
|
+
or any(path.startswith(p) for p in _SCAFFOLD_MANAGED_PREFIXES))
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _scaffold_managed_dirty() -> set[str]:
|
|
813
|
+
"""Tracked-dirty paths the scaffold overlay owns (subset of the dirty set)."""
|
|
814
|
+
return {p for p in _tracked_dirty_paths() if _is_scaffold_managed(p)}
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _persist_scaffold_sync(installed: str) -> None:
|
|
818
|
+
"""After auto_sync overlays a newer scaffold, keep the tree clean for the
|
|
819
|
+
rest of the run. On a non-default branch, commit the scaffold-managed
|
|
820
|
+
changes (`chore(loop): sync scaffold to X.Y.Z`). On the DEFAULT branch, leave
|
|
821
|
+
them and print guidance — committing scaffold churn onto the default branch
|
|
822
|
+
is undesirable; --prepare (or the next ensure_feature_branch) carries them
|
|
823
|
+
onto the feature branch and folds them into its scaffold commit instead.
|
|
824
|
+
"""
|
|
825
|
+
dirty = _scaffold_managed_dirty()
|
|
826
|
+
if not dirty:
|
|
827
|
+
return
|
|
828
|
+
branch = _current_branch()
|
|
829
|
+
on_default = (not branch) or branch == _default_branch()
|
|
830
|
+
if on_default:
|
|
831
|
+
print(
|
|
832
|
+
f"auto_sync: scaffold upgraded to {installed}; leaving "
|
|
833
|
+
f"{len(dirty)} scaffold file(s) uncommitted on default branch "
|
|
834
|
+
f"'{branch or '(detached)'}'. --prepare will carry them onto the "
|
|
835
|
+
f"feature branch, or commit them yourself.",
|
|
836
|
+
file=sys.stderr,
|
|
837
|
+
)
|
|
838
|
+
return
|
|
839
|
+
commit_bookkeeping(sorted(dirty), f"chore(loop): sync scaffold to {installed}")
|
|
840
|
+
print(
|
|
841
|
+
f"auto_sync: scaffold upgraded to {installed}; committed "
|
|
842
|
+
f"{len(dirty)} scaffold file(s) on '{branch}'.",
|
|
843
|
+
file=sys.stderr,
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
|
|
769
847
|
def _branch_prep_hint(feature_dir: "Path", feat_fm: dict, feature_id: str) -> str:
|
|
770
848
|
"""Actionable next-step block for the two pre-flight guards.
|
|
771
849
|
|
|
@@ -920,6 +998,11 @@ def prepare_feature(feat_fm: dict, feature_dir: "Path", feature_id: str) -> None
|
|
|
920
998
|
for rel in (".specfuse/roadmap.md", ".specfuse/roadmap-archive.md"):
|
|
921
999
|
if (repo_root / rel).exists():
|
|
922
1000
|
add_paths.append(str(repo_root / rel))
|
|
1001
|
+
# Fold auto_sync's scaffold overlay into this commit too. On the default
|
|
1002
|
+
# branch auto_sync leaves those uncommitted (see _persist_scaffold_sync);
|
|
1003
|
+
# committing them here lands the upgrade on the feature branch, clean.
|
|
1004
|
+
for rel in sorted(_scaffold_managed_dirty()):
|
|
1005
|
+
add_paths.append(str(repo_root / rel))
|
|
923
1006
|
git("add", "--", *add_paths)
|
|
924
1007
|
staged = git("status", "--porcelain", "--", *add_paths)
|
|
925
1008
|
if staged:
|
|
@@ -1006,7 +1089,11 @@ def ensure_feature_branch(feat_fm: dict, feature_dir: "Path | None" = None) -> N
|
|
|
1006
1089
|
# Create-from-HEAD carries the working tree onto the new branch. Only
|
|
1007
1090
|
# the expected /pick-feature flips may ride along; anything else stops.
|
|
1008
1091
|
dirty = _tracked_dirty_paths()
|
|
1009
|
-
|
|
1092
|
+
# Scaffold-overlay files (auto_sync's own upgrade writes) ride along too:
|
|
1093
|
+
# they are driver-owned, and prepare_feature folds them into the scaffold
|
|
1094
|
+
# commit. Only genuinely-unrelated edits remain "unexpected" (#prepare).
|
|
1095
|
+
allowed = _expected_flip_paths(feature_dir) | _scaffold_managed_dirty()
|
|
1096
|
+
unexpected = dirty - allowed
|
|
1010
1097
|
if unexpected:
|
|
1011
1098
|
raise FeatureBranchError(
|
|
1012
1099
|
"working tree has uncommitted changes to unexpected paths: "
|
|
@@ -2564,13 +2651,17 @@ _NO_FAILURES_SENTINEL = "### Failure-class breakdown\n\n(no non-passing attempts
|
|
|
2564
2651
|
def summarize_attempt_failure_classes(
|
|
2565
2652
|
feature_dir: Path,
|
|
2566
2653
|
gate_n: int | None = None,
|
|
2654
|
+
exclude_correlation_id: str | None = None,
|
|
2567
2655
|
) -> str:
|
|
2568
2656
|
"""Render a '### Failure-class breakdown' markdown table from events.jsonl.
|
|
2569
2657
|
|
|
2570
2658
|
Reads attempt_outcome events whose outcome != 'passed'. When gate_n is
|
|
2571
2659
|
provided, restricts to events whose correlation_id belongs to that gate
|
|
2572
|
-
(resolved via _gate_number_from_wu_id).
|
|
2573
|
-
|
|
2660
|
+
(resolved via _gate_number_from_wu_id). When exclude_correlation_id is
|
|
2661
|
+
provided, drops events with that exact correlation_id — used to keep a
|
|
2662
|
+
close WU's OWN non-passing attempts out of the breakdown it authors, so the
|
|
2663
|
+
close's stumble does not arm the guard against itself (issue #145). Returns
|
|
2664
|
+
_NO_FAILURES_SENTINEL when no non-passing attempts match the filter.
|
|
2574
2665
|
|
|
2575
2666
|
Pure function — reads events.jsonl; no writes, no side effects.
|
|
2576
2667
|
Malformed JSONL lines are skipped (legacy-event tolerance, AC5).
|
|
@@ -2593,8 +2684,10 @@ def summarize_attempt_failure_classes(
|
|
|
2593
2684
|
payload = evt.get("payload") or {}
|
|
2594
2685
|
if payload.get("outcome") == "passed":
|
|
2595
2686
|
continue
|
|
2687
|
+
cid = evt.get("correlation_id", "")
|
|
2688
|
+
if exclude_correlation_id is not None and cid == exclude_correlation_id:
|
|
2689
|
+
continue
|
|
2596
2690
|
if gate_n is not None:
|
|
2597
|
-
cid = evt.get("correlation_id", "")
|
|
2598
2691
|
if _gate_number_from_wu_id(cid) != gate_n:
|
|
2599
2692
|
continue
|
|
2600
2693
|
non_passing.append(payload)
|
|
@@ -2658,7 +2751,14 @@ def assert_failure_class_breakdown_when_failures_present(
|
|
|
2658
2751
|
return True, ""
|
|
2659
2752
|
|
|
2660
2753
|
gate_n = _gate_number_from_wu_id(wu.wu_id)
|
|
2661
|
-
|
|
2754
|
+
# Exclude the close WU's OWN non-passing attempts: the breakdown documents
|
|
2755
|
+
# SUBSTANTIVE-WU failures, not the close's own stumble. Without this, a
|
|
2756
|
+
# failed first close attempt retroactively requires a new subsection in the
|
|
2757
|
+
# RETROSPECTIVE the close itself authors — and the between-attempt
|
|
2758
|
+
# `reset --hard` wipes the partial each retry → spin → blocked_human on
|
|
2759
|
+
# otherwise-done features (issue #145).
|
|
2760
|
+
summary = summarize_attempt_failure_classes(
|
|
2761
|
+
feature_dir, gate_n, exclude_correlation_id=wu.wu_id)
|
|
2662
2762
|
|
|
2663
2763
|
if summary == _NO_FAILURES_SENTINEL:
|
|
2664
2764
|
return True, ""
|
|
@@ -2666,7 +2766,7 @@ def assert_failure_class_breakdown_when_failures_present(
|
|
|
2666
2766
|
if re.search(r"^#{3} Failure-class breakdown\b", retro.read_text(), re.MULTILINE):
|
|
2667
2767
|
return True, ""
|
|
2668
2768
|
|
|
2669
|
-
# Count non-passing attempts for the
|
|
2769
|
+
# Count non-passing attempts (excluding the close's own) for the message.
|
|
2670
2770
|
events_path = feature_dir / "events.jsonl"
|
|
2671
2771
|
count = 0
|
|
2672
2772
|
if events_path.exists():
|
|
@@ -2683,18 +2783,25 @@ def assert_failure_class_breakdown_when_failures_present(
|
|
|
2683
2783
|
payload = evt.get("payload") or {}
|
|
2684
2784
|
if payload.get("outcome") == "passed":
|
|
2685
2785
|
continue
|
|
2786
|
+
cid = evt.get("correlation_id", "")
|
|
2787
|
+
if cid == wu.wu_id:
|
|
2788
|
+
continue
|
|
2686
2789
|
if gate_n is not None:
|
|
2687
|
-
cid = evt.get("correlation_id", "")
|
|
2688
2790
|
if _gate_number_from_wu_id(cid) != gate_n:
|
|
2689
2791
|
continue
|
|
2690
2792
|
count += 1
|
|
2691
2793
|
|
|
2692
2794
|
gate_label = f"gate {gate_n}" if gate_n is not None else "all gates"
|
|
2795
|
+
# Actionable, self-contained retry note (flows into the next attempt's prompt
|
|
2796
|
+
# via failure_note, issue #145 fix #2): name the moved bar AND embed the exact
|
|
2797
|
+
# table to paste, so the agent doesn't rediscover the requirement.
|
|
2693
2798
|
return (
|
|
2694
2799
|
False,
|
|
2695
2800
|
f"assert_failure_class_breakdown_when_failures_present: {count} "
|
|
2696
|
-
f"
|
|
2697
|
-
f"
|
|
2801
|
+
f"substantive-WU attempt(s) in {gate_label} did not pass, so "
|
|
2802
|
+
f"RETROSPECTIVE.md MUST include a '### Failure-class breakdown' subsection "
|
|
2803
|
+
f"— it is absent. Add exactly this subsection to RETROSPECTIVE.md:\n\n"
|
|
2804
|
+
f"{summary}",
|
|
2698
2805
|
)
|
|
2699
2806
|
|
|
2700
2807
|
|
|
@@ -3112,7 +3219,14 @@ def run(
|
|
|
3112
3219
|
# draft-feature can't take). --prepare-only stops after, so you can
|
|
3113
3220
|
# review the commit before the loop dispatches anything.
|
|
3114
3221
|
if prepare or prepare_only:
|
|
3115
|
-
|
|
3222
|
+
# Genuinely-unrelated dirty paths (not the /pick-feature flips, not
|
|
3223
|
+
# auto_sync's scaffold overlay) still stop --prepare — but with a
|
|
3224
|
+
# clean, actionable message and a non-zero exit, not a traceback.
|
|
3225
|
+
try:
|
|
3226
|
+
prepare_feature(feat_fm, feature_dir, feature_id)
|
|
3227
|
+
except FeatureBranchError as exc:
|
|
3228
|
+
print(f"--prepare cannot proceed:\n{exc}", file=sys.stderr)
|
|
3229
|
+
return 1
|
|
3116
3230
|
if prepare_only:
|
|
3117
3231
|
print("Prepared: feature is on its branch and committed. "
|
|
3118
3232
|
"Re-run `specfuse-loop` to start the gate.")
|
|
@@ -3998,6 +4112,7 @@ def auto_sync(
|
|
|
3998
4112
|
f"WARNING: auto_sync: plugin config drift corrected: {', '.join(changes)}",
|
|
3999
4113
|
file=sys.stderr,
|
|
4000
4114
|
)
|
|
4115
|
+
_persist_scaffold_sync(installed)
|
|
4001
4116
|
return
|
|
4002
4117
|
|
|
4003
4118
|
# Older with modified files.
|
|
@@ -4056,6 +4171,7 @@ def auto_sync(
|
|
|
4056
4171
|
f"WARNING: auto_sync: plugin config drift corrected: {', '.join(changes)}",
|
|
4057
4172
|
file=sys.stderr,
|
|
4058
4173
|
)
|
|
4174
|
+
_persist_scaffold_sync(installed)
|
|
4059
4175
|
else:
|
|
4060
4176
|
# Non-interactive (CI / claude -p): skip modified files + warn; never block.
|
|
4061
4177
|
print(
|
|
@@ -4092,6 +4208,7 @@ def auto_sync(
|
|
|
4092
4208
|
f"WARNING: auto_sync: plugin config drift corrected: {', '.join(changes)}",
|
|
4093
4209
|
file=sys.stderr,
|
|
4094
4210
|
)
|
|
4211
|
+
_persist_scaffold_sync(installed)
|
|
4095
4212
|
|
|
4096
4213
|
|
|
4097
4214
|
def main() -> int:
|
|
@@ -51,6 +51,7 @@ tests/test_autosync_plugin.py
|
|
|
51
51
|
tests/test_backend.py
|
|
52
52
|
tests/test_bookkeeping_commit_crash_run.py
|
|
53
53
|
tests/test_bookkeeping_commit_hook_crash.py
|
|
54
|
+
tests/test_close_wu_self_reference.py
|
|
54
55
|
tests/test_closing_deliverable_guard.py
|
|
55
56
|
tests/test_cost_tracking.py
|
|
56
57
|
tests/test_degraded_oracle.py
|
|
@@ -112,6 +113,7 @@ tests/test_miniyaml_equivalence.py
|
|
|
112
113
|
tests/test_miniyaml_negative.py
|
|
113
114
|
tests/test_planned_cost_lint.py
|
|
114
115
|
tests/test_prepare_feature.py
|
|
116
|
+
tests/test_prepare_scaffold_sync.py
|
|
115
117
|
tests/test_produces_field.py
|
|
116
118
|
tests/test_result_block.py
|
|
117
119
|
tests/test_roadmap_add_skill.py
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Issue #145 — a close WU's own failed attempt must not arm the
|
|
6
|
+
failure-class-breakdown guard against that same close WU.
|
|
7
|
+
|
|
8
|
+
`assert_failure_class_breakdown_when_failures_present` requires a
|
|
9
|
+
`### Failure-class breakdown` subsection in RETROSPECTIVE.md once non-passing
|
|
10
|
+
attempts exist in the gate. It counted ALL non-passing attempts — including the
|
|
11
|
+
close WU's OWN. Since the close WU is the one that authors the RETROSPECTIVE the
|
|
12
|
+
guard inspects, its malformed first attempt retroactively added a required
|
|
13
|
+
subsection to its own output, and the between-attempt `reset --hard` wiped the
|
|
14
|
+
partial each retry → spin → blocked_human on otherwise-done features.
|
|
15
|
+
|
|
16
|
+
Fix #3: exclude the close WU's own attempts (correlation_id == its wu_id) from
|
|
17
|
+
the failure set that arms the guard against it. Substantive-WU failures still
|
|
18
|
+
require the breakdown. Fix #2: when the guard does fire, its message names the
|
|
19
|
+
required subsection and embeds the exact expected table (it flows into the retry
|
|
20
|
+
prompt via failure_note), so the agent doesn't rediscover the moved bar.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import tempfile
|
|
27
|
+
import unittest
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from tests._loop_loader import load_loop
|
|
31
|
+
|
|
32
|
+
loop = load_loop()
|
|
33
|
+
|
|
34
|
+
CLOSE_ID = "FEAT-2026-0099/G1-CLOSE"
|
|
35
|
+
SUBSTANTIVE_ID = "FEAT-2026-0099/G1-T01"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _close_wu(wu_file: Path) -> "loop.WorkUnit":
|
|
39
|
+
return loop.WorkUnit(
|
|
40
|
+
wu_id=CLOSE_ID, file=wu_file, depends_on=[], type="close",
|
|
41
|
+
model="opus", effort="high", status="pending", attempts=1,
|
|
42
|
+
title="Close gate 1", body="body",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _attempt_event(correlation_id: str, outcome: str = "failed",
|
|
47
|
+
failure_class: str = "other", sig: str = "verdict_malformed") -> str:
|
|
48
|
+
return json.dumps({
|
|
49
|
+
"event_type": "attempt_outcome",
|
|
50
|
+
"correlation_id": correlation_id,
|
|
51
|
+
"timestamp": "2026-07-12T00:00:00Z",
|
|
52
|
+
"payload": {"outcome": outcome, "failure_class": failure_class,
|
|
53
|
+
"failure_signature": sig},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestCloseSelfReference(unittest.TestCase):
|
|
58
|
+
def _feature_dir(self, tmp: str, events: list[str], retro: str = "# Retro\n") -> Path:
|
|
59
|
+
d = Path(tmp)
|
|
60
|
+
(d / "events.jsonl").write_text("\n".join(events) + "\n")
|
|
61
|
+
(d / "RETROSPECTIVE.md").write_text(retro)
|
|
62
|
+
return d
|
|
63
|
+
|
|
64
|
+
def test_close_own_failure_does_not_arm_guard(self):
|
|
65
|
+
# Only the close WU's OWN attempts are non-passing (the observed run).
|
|
66
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
67
|
+
d = self._feature_dir(tmp, [_attempt_event(CLOSE_ID)])
|
|
68
|
+
wu = _close_wu(d / "WU.md")
|
|
69
|
+
ok, msg = loop.assert_failure_class_breakdown_when_failures_present(
|
|
70
|
+
wu, d, Path(tmp), "HEAD~1")
|
|
71
|
+
self.assertTrue(ok, f"close's own failure must not require the breakdown: {msg}")
|
|
72
|
+
|
|
73
|
+
def test_substantive_failure_still_requires_breakdown(self):
|
|
74
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
75
|
+
d = self._feature_dir(
|
|
76
|
+
tmp, [_attempt_event(SUBSTANTIVE_ID), _attempt_event(CLOSE_ID)])
|
|
77
|
+
wu = _close_wu(d / "WU.md")
|
|
78
|
+
ok, msg = loop.assert_failure_class_breakdown_when_failures_present(
|
|
79
|
+
wu, d, Path(tmp), "HEAD~1")
|
|
80
|
+
self.assertFalse(ok, "a substantive-WU failure still requires the breakdown")
|
|
81
|
+
# Fix #2 — message is actionable and embeds the exact table to include.
|
|
82
|
+
self.assertIn("### Failure-class breakdown", msg)
|
|
83
|
+
self.assertIn("| failure_class |", msg)
|
|
84
|
+
|
|
85
|
+
def test_breakdown_table_excludes_close_own_attempts(self):
|
|
86
|
+
# Two substantive failures + one close-own failure; the rendered table
|
|
87
|
+
# the guard offers must count only the two substantive ones.
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
89
|
+
d = self._feature_dir(
|
|
90
|
+
tmp,
|
|
91
|
+
[_attempt_event(SUBSTANTIVE_ID), _attempt_event(SUBSTANTIVE_ID),
|
|
92
|
+
_attempt_event(CLOSE_ID)])
|
|
93
|
+
table = loop.summarize_attempt_failure_classes(
|
|
94
|
+
d, gate_n=1, exclude_correlation_id=CLOSE_ID)
|
|
95
|
+
self.assertIn("**total** | **2**", table)
|
|
96
|
+
|
|
97
|
+
def test_summarize_all_self_returns_sentinel(self):
|
|
98
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
99
|
+
d = self._feature_dir(tmp, [_attempt_event(CLOSE_ID)])
|
|
100
|
+
table = loop.summarize_attempt_failure_classes(
|
|
101
|
+
d, gate_n=1, exclude_correlation_id=CLOSE_ID)
|
|
102
|
+
self.assertEqual(table, loop._NO_FAILURES_SENTINEL)
|
|
103
|
+
|
|
104
|
+
def test_breakdown_present_passes(self):
|
|
105
|
+
retro = "# Retro\n\n### Failure-class breakdown\n\n| x | 1 | y |\n"
|
|
106
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
107
|
+
d = self._feature_dir(tmp, [_attempt_event(SUBSTANTIVE_ID)], retro=retro)
|
|
108
|
+
wu = _close_wu(d / "WU.md")
|
|
109
|
+
ok, _ = loop.assert_failure_class_breakdown_when_failures_present(
|
|
110
|
+
wu, d, Path(tmp), "HEAD~1")
|
|
111
|
+
self.assertTrue(ok)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
unittest.main()
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""auto_sync's scaffold upgrade must not poison the same run's --prepare.
|
|
6
|
+
|
|
7
|
+
Before this fix, `specfuse-loop --prepare` crashed with a traceback: auto_sync
|
|
8
|
+
overlays the new scaffold (dirtying .specfuse/VERSION, .scaffold-manifest,
|
|
9
|
+
docs/, templates/) and leaves it uncommitted, then ensure_feature_branch
|
|
10
|
+
refuses to carry those "unexpected" paths onto the new feature branch.
|
|
11
|
+
|
|
12
|
+
The graceful behavior pinned here:
|
|
13
|
+
|
|
14
|
+
1. auto_sync COMMITS its own scaffold overlay on a non-default branch
|
|
15
|
+
(`chore(loop): sync scaffold to X.Y.Z`) so the tree is clean; on the
|
|
16
|
+
DEFAULT branch it leaves them + prints guidance (--prepare carries them).
|
|
17
|
+
2. ensure_feature_branch CARRIES scaffold-managed dirty paths onto a new
|
|
18
|
+
branch instead of refusing — but still blocks genuinely-unrelated edits.
|
|
19
|
+
3. prepare_feature FOLDS the scaffold-managed paths into its scaffold commit
|
|
20
|
+
(so the default-branch case lands them committed on the feature branch).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import unittest
|
|
28
|
+
from contextlib import contextmanager
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from tempfile import TemporaryDirectory
|
|
31
|
+
|
|
32
|
+
from tests._loop_loader import load_loop
|
|
33
|
+
|
|
34
|
+
loop = load_loop()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _git(root: Path, *args: str) -> str:
|
|
38
|
+
return subprocess.run(
|
|
39
|
+
["git", "-C", str(root), *args],
|
|
40
|
+
capture_output=True, text=True, check=True,
|
|
41
|
+
).stdout.strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@contextmanager
|
|
45
|
+
def _chdir(path: Path):
|
|
46
|
+
prev = Path.cwd()
|
|
47
|
+
os.chdir(path)
|
|
48
|
+
try:
|
|
49
|
+
yield
|
|
50
|
+
finally:
|
|
51
|
+
os.chdir(prev)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@contextmanager
|
|
55
|
+
def _repo():
|
|
56
|
+
"""Temp git repo on `main`, one commit, a tracked scaffold template."""
|
|
57
|
+
with TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
|
|
58
|
+
root = Path(tmp)
|
|
59
|
+
subprocess.run(["git", "init", "-q", "-b", "main", str(root)], check=True)
|
|
60
|
+
_git(root, "config", "user.email", "test@example.com")
|
|
61
|
+
_git(root, "config", "user.name", "Test")
|
|
62
|
+
_git(root, "config", "commit.gpgSign", "false")
|
|
63
|
+
tmpl = root / ".specfuse/templates/WU.template.md"
|
|
64
|
+
tmpl.parent.mkdir(parents=True)
|
|
65
|
+
tmpl.write_text("old template\n")
|
|
66
|
+
(root / ".specfuse/VERSION").write_text("0.3.0\n")
|
|
67
|
+
(root / "README.md").write_text("# fixture\n")
|
|
68
|
+
_git(root, "add", ".")
|
|
69
|
+
_git(root, "commit", "-q", "-m", "init")
|
|
70
|
+
yield root
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _dirty_scaffold(root: Path) -> None:
|
|
74
|
+
"""Simulate auto_sync's overlay: modify tracked scaffold files."""
|
|
75
|
+
(root / ".specfuse/templates/WU.template.md").write_text("new template\n")
|
|
76
|
+
(root / ".specfuse/VERSION").write_text("0.3.11\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestDefaultBranch(unittest.TestCase):
|
|
80
|
+
def test_local_main_detected(self):
|
|
81
|
+
with _repo() as root, _chdir(root):
|
|
82
|
+
self.assertEqual(loop._default_branch(), "main")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestScaffoldManagedDirty(unittest.TestCase):
|
|
86
|
+
def test_classifies_scaffold_vs_user_paths(self):
|
|
87
|
+
with _repo() as root:
|
|
88
|
+
_dirty_scaffold(root)
|
|
89
|
+
(root / "README.md").write_text("# edited\n") # user edit
|
|
90
|
+
with _chdir(root):
|
|
91
|
+
managed = loop._scaffold_managed_dirty()
|
|
92
|
+
self.assertIn(".specfuse/templates/WU.template.md", managed)
|
|
93
|
+
self.assertIn(".specfuse/VERSION", managed)
|
|
94
|
+
self.assertNotIn("README.md", managed)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestPersistScaffoldSync(unittest.TestCase):
|
|
98
|
+
def test_commits_on_feature_branch(self):
|
|
99
|
+
with _repo() as root:
|
|
100
|
+
_git(root, "checkout", "-q", "-b", "feat/x")
|
|
101
|
+
_dirty_scaffold(root)
|
|
102
|
+
with _chdir(root):
|
|
103
|
+
loop._persist_scaffold_sync("0.3.11")
|
|
104
|
+
dirty = loop._tracked_dirty_paths()
|
|
105
|
+
self.assertEqual(dirty, set(), "scaffold sync must be committed")
|
|
106
|
+
self.assertIn("sync scaffold to 0.3.11",
|
|
107
|
+
_git(root, "log", "-1", "--pretty=%s"))
|
|
108
|
+
|
|
109
|
+
def test_guides_and_leaves_on_default_branch(self):
|
|
110
|
+
with _repo() as root: # on main == default
|
|
111
|
+
_dirty_scaffold(root)
|
|
112
|
+
with _chdir(root):
|
|
113
|
+
loop._persist_scaffold_sync("0.3.11")
|
|
114
|
+
dirty = loop._tracked_dirty_paths()
|
|
115
|
+
# left uncommitted (no surprise commit on the default branch)
|
|
116
|
+
self.assertIn(".specfuse/VERSION", dirty)
|
|
117
|
+
self.assertEqual("init", _git(root, "log", "-1", "--pretty=%s"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestEnsureFeatureBranchCarriesScaffold(unittest.TestCase):
|
|
121
|
+
def test_scaffold_paths_carry_onto_new_branch(self):
|
|
122
|
+
with _repo() as root:
|
|
123
|
+
_dirty_scaffold(root)
|
|
124
|
+
with _chdir(root):
|
|
125
|
+
loop.ensure_feature_branch({"branch": "feat/y"})
|
|
126
|
+
current = _git(root, "branch", "--show-current")
|
|
127
|
+
self.assertEqual(current, "feat/y")
|
|
128
|
+
self.assertEqual(
|
|
129
|
+
(root / ".specfuse/templates/WU.template.md").read_text(),
|
|
130
|
+
"new template\n",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def test_unexpected_edit_still_blocks_even_with_scaffold_dirty(self):
|
|
134
|
+
with _repo() as root:
|
|
135
|
+
_commit = root / "src/app.py"
|
|
136
|
+
_commit.parent.mkdir()
|
|
137
|
+
_commit.write_text("x = 1\n")
|
|
138
|
+
_git(root, "add", ".")
|
|
139
|
+
_git(root, "commit", "-q", "-m", "app")
|
|
140
|
+
_dirty_scaffold(root)
|
|
141
|
+
(root / "src/app.py").write_text("x = 2\n") # unrelated edit
|
|
142
|
+
with _chdir(root):
|
|
143
|
+
with self.assertRaises(loop.FeatureBranchError) as ctx:
|
|
144
|
+
loop.ensure_feature_branch({"branch": "feat/z"})
|
|
145
|
+
self.assertIn("src/app.py", str(ctx.exception))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestPrepareFoldsScaffold(unittest.TestCase):
|
|
149
|
+
def test_default_branch_scaffold_folds_into_prepare_commit(self):
|
|
150
|
+
with _repo() as root: # on main == default
|
|
151
|
+
feat_dir = root / ".specfuse/features/FEAT-2026-0099-x"
|
|
152
|
+
feat_dir.mkdir(parents=True)
|
|
153
|
+
(feat_dir / "PLAN.md").write_text("branch: feat/FEAT-2026-0099-x\n")
|
|
154
|
+
_dirty_scaffold(root) # auto_sync left these on default branch
|
|
155
|
+
with _chdir(root):
|
|
156
|
+
loop.prepare_feature(
|
|
157
|
+
{"branch": "feat/FEAT-2026-0099-x"}, feat_dir,
|
|
158
|
+
"FEAT-2026-0099",
|
|
159
|
+
)
|
|
160
|
+
dirty = loop._tracked_dirty_paths()
|
|
161
|
+
current = _git(root, "branch", "--show-current")
|
|
162
|
+
self.assertEqual(current, "feat/FEAT-2026-0099-x")
|
|
163
|
+
self.assertEqual(dirty, set(), "scaffold + folder must be committed")
|
|
164
|
+
# scaffold file is committed on the feature branch
|
|
165
|
+
self.assertEqual(
|
|
166
|
+
_git(root, "show", "HEAD:.specfuse/templates/WU.template.md"),
|
|
167
|
+
"new template",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.11
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/docs/concepts/ralph-lineage.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/rules/security-boundaries.md
RENAMED
|
File without changes
|
{specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/specfuse/loop/data/rules/verification-discipline.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{specfuse_loop-0.3.11 → specfuse_loop-0.3.13}/tests/test_lint_correlation_id_close_intermediate.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|