specfuse-loop 0.3.16__tar.gz → 0.3.17__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.16/specfuse_loop.egg-info → specfuse_loop-0.3.17}/PKG-INFO +1 -1
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/pyproject.toml +1 -1
- specfuse_loop-0.3.17/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/loop.py +111 -13
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/scaffold.py +37 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/SOURCES.txt +1 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_attempt_outcome_emission.py +82 -0
- specfuse_loop-0.3.17/tests/test_loop_attempt_evidence.py +116 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_upgrade.py +66 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_untracked_file_absorption.py +5 -0
- specfuse_loop-0.3.16/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/LICENSE +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/NOTICE +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/README.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/setup.cfg +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/_filelock.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/lint_plan.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_autoclose_deferral_visibility.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_bash_routing.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_bookkeeping_no_verify.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_claude_resolution.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_close_wu_self_reference.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_console_encoding.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_ensure_feature_branch_base.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_filelock_portable.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_interpreter_normalization.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_orchestration.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_redaction_windows_home.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_resolve_base.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_timeout_kill_portable.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_windows_gate_exec.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/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.17"
|
|
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.17
|
|
@@ -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.17"
|
|
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
|
|
@@ -542,6 +542,36 @@ def detect_degraded_oracle(stdout: str) -> str | None:
|
|
|
542
542
|
return None
|
|
543
543
|
|
|
544
544
|
|
|
545
|
+
# A failure signature carries no distinguishing content when it is a bare
|
|
546
|
+
# code/markdown fence, whitespace-only, or pure ANSI. Non-Python gate output
|
|
547
|
+
# (e.g. a TypeScript `tests` gate) matches none of the Python-specific
|
|
548
|
+
# _SIG_PATTERNS below and used to fall through to the first line after the
|
|
549
|
+
# marker — often a ``` fence — collapsing genuinely different failures to one
|
|
550
|
+
# value and false-firing spinning_signature_repeat (#167).
|
|
551
|
+
_FENCE_RE = re.compile(r"^[`~]{3,}[\w.+-]*$")
|
|
552
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
553
|
+
# Sentinel returned when every line after the FAIL marker is non-informative.
|
|
554
|
+
# Distinct from `no_gate_marker` (a marker WAS found) and treated as
|
|
555
|
+
# non-informative by `_is_noninformative_signature`.
|
|
556
|
+
NO_SIGNATURE = "no_signature"
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _is_noninformative_signature(sig: "str | None") -> bool:
|
|
560
|
+
"""True when *sig* cannot distinguish one failure from another.
|
|
561
|
+
|
|
562
|
+
Covers None, whitespace-only, bare fences (``` / ~~~, optionally with a
|
|
563
|
+
language tag), pure-ANSI strings, and the NO_SIGNATURE sentinel. The spin
|
|
564
|
+
detector and the extraction fallback both consult this so a content-free
|
|
565
|
+
signature neither becomes a WU's identity nor trips escalation (#167).
|
|
566
|
+
"""
|
|
567
|
+
if sig is None or sig == NO_SIGNATURE:
|
|
568
|
+
return True
|
|
569
|
+
stripped = _ANSI_RE.sub("", sig).strip()
|
|
570
|
+
if not stripped:
|
|
571
|
+
return True
|
|
572
|
+
return bool(_FENCE_RE.match(stripped))
|
|
573
|
+
|
|
574
|
+
|
|
545
575
|
def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
|
|
546
576
|
"""Extract (failure_class, failure_signature) from gate runner stdout.
|
|
547
577
|
|
|
@@ -577,9 +607,12 @@ def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
|
|
|
577
607
|
return failure_class, sig if sig else "unknown"
|
|
578
608
|
for line in after_lines:
|
|
579
609
|
stripped = line.strip()
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
610
|
+
# Skip non-informative lines (bare fences, whitespace, pure ANSI):
|
|
611
|
+
# keying the signature off one would collapse distinct failures (#167).
|
|
612
|
+
if _is_noninformative_signature(stripped):
|
|
613
|
+
continue
|
|
614
|
+
return failure_class, stripped[:100]
|
|
615
|
+
return failure_class, NO_SIGNATURE
|
|
583
616
|
|
|
584
617
|
|
|
585
618
|
def detect_spinning_signature_repeat(
|
|
@@ -592,6 +625,9 @@ def detect_spinning_signature_repeat(
|
|
|
592
625
|
Returns False when either element of current is None.
|
|
593
626
|
Returns False when current or prior is the no_gate_marker sentinel to
|
|
594
627
|
avoid false-positive halts on parser-opaque failures (AC4).
|
|
628
|
+
Returns False when either signature is non-informative — a bare fence,
|
|
629
|
+
whitespace, pure ANSI, or the NO_SIGNATURE sentinel — since such values
|
|
630
|
+
collapse distinct failures and would false-fire the spin halt (#167).
|
|
595
631
|
"""
|
|
596
632
|
_SENTINEL = ("other", "no_gate_marker")
|
|
597
633
|
if prior is None:
|
|
@@ -600,6 +636,9 @@ def detect_spinning_signature_repeat(
|
|
|
600
636
|
return False
|
|
601
637
|
if current == _SENTINEL or prior == _SENTINEL:
|
|
602
638
|
return False
|
|
639
|
+
if (_is_noninformative_signature(current[1])
|
|
640
|
+
or _is_noninformative_signature(prior[1])):
|
|
641
|
+
return False
|
|
603
642
|
return current == prior
|
|
604
643
|
|
|
605
644
|
|
|
@@ -1498,6 +1537,51 @@ def reset_preserving_events(
|
|
|
1498
1537
|
_clean_attempt_untracked(untracked_before, events_path)
|
|
1499
1538
|
|
|
1500
1539
|
|
|
1540
|
+
def capture_working_tree_diff(head_before: str, max_chars: int = 20000) -> str:
|
|
1541
|
+
"""Return `git diff <head_before>` (working tree vs pre-attempt base), capped.
|
|
1542
|
+
|
|
1543
|
+
Folded into a failing attempt's evidence note BEFORE the per-attempt
|
|
1544
|
+
`git reset --hard` discards the rejected work, so a blocked WU stays
|
|
1545
|
+
diagnosable — a human (or /gate-status) can see the diff that was rolled
|
|
1546
|
+
back, not just a truncated failure excerpt (#168).
|
|
1547
|
+
|
|
1548
|
+
Best-effort: returns "" on any git error or a clean tree. Truncated to
|
|
1549
|
+
max_chars so a large refactor's diff cannot bloat the committed note.
|
|
1550
|
+
"""
|
|
1551
|
+
try:
|
|
1552
|
+
out = subprocess.run(
|
|
1553
|
+
["git", "diff", head_before],
|
|
1554
|
+
capture_output=True, text=True, check=True,
|
|
1555
|
+
).stdout
|
|
1556
|
+
except (subprocess.CalledProcessError, OSError):
|
|
1557
|
+
return ""
|
|
1558
|
+
if len(out) > max_chars:
|
|
1559
|
+
out = out[:max_chars] + "\n… (diff truncated)\n"
|
|
1560
|
+
return out
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def persist_attempt_notes(
|
|
1564
|
+
work_dir: Path, wu_id: str, attempt_notes: "list[tuple[int, str]]",
|
|
1565
|
+
) -> "list[Path]":
|
|
1566
|
+
"""Write buffered per-attempt evidence to work/<wu>/attempt-N.md.
|
|
1567
|
+
|
|
1568
|
+
Extracted so both escalation paths persist evidence the same way: the
|
|
1569
|
+
exhausted-attempts `for-else` and the early `spinning_signature_repeat`
|
|
1570
|
+
halt. Previously only the former wrote notes, so a signature-repeat block
|
|
1571
|
+
left nothing on disk to diagnose (#168). Returns the written paths for
|
|
1572
|
+
inclusion in the escalation commit. `work/` is gitignored, so the explicit
|
|
1573
|
+
`git add` in commit_bookkeeping is what tracks these.
|
|
1574
|
+
"""
|
|
1575
|
+
wu_key = wu_id.replace("/", "_")
|
|
1576
|
+
paths: list[Path] = []
|
|
1577
|
+
for atmpt, evidence in attempt_notes:
|
|
1578
|
+
p = work_dir / wu_key / f"attempt-{atmpt}.md"
|
|
1579
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1580
|
+
p.write_text(evidence)
|
|
1581
|
+
paths.append(p)
|
|
1582
|
+
return paths
|
|
1583
|
+
|
|
1584
|
+
|
|
1501
1585
|
class SquashCommitError(RuntimeError):
|
|
1502
1586
|
"""Raised when squash_commit's `git commit` is rejected (non-zero exit).
|
|
1503
1587
|
|
|
@@ -4144,7 +4228,19 @@ def run(
|
|
|
4144
4228
|
# Per-attempt notes are buffered (not written to disk) so they
|
|
4145
4229
|
# ride with the spinning-escalation commit if we exhaust
|
|
4146
4230
|
# attempts; on eventual PASS they're discarded as scratch.
|
|
4147
|
-
|
|
4231
|
+
# Fold the rejected working-tree diff into the buffered
|
|
4232
|
+
# evidence NOW, before the reset below discards it (#168).
|
|
4233
|
+
# Signature/excerpt parsing stays on the raw payload.
|
|
4234
|
+
_diff = capture_working_tree_diff(head_before)
|
|
4235
|
+
_evidence = payload
|
|
4236
|
+
if _diff:
|
|
4237
|
+
_evidence = (
|
|
4238
|
+
payload
|
|
4239
|
+
+ "\n\n## Rejected working-tree diff "
|
|
4240
|
+
"(discarded by git reset on block)\n\n"
|
|
4241
|
+
+ "```diff\n" + _diff + "\n```\n"
|
|
4242
|
+
)
|
|
4243
|
+
attempt_notes.append((attempt, _evidence))
|
|
4148
4244
|
failure_note = payload
|
|
4149
4245
|
_fc, _fs = parse_gate_failure_signature(payload)
|
|
4150
4246
|
_ex = extract_failure_excerpt(payload)
|
|
@@ -4169,11 +4265,18 @@ def run(
|
|
|
4169
4265
|
}))
|
|
4170
4266
|
reset_preserving_events(head_before, events_path,
|
|
4171
4267
|
untracked_before=untracked_before)
|
|
4268
|
+
# Persist buffered evidence (verify output + rejected
|
|
4269
|
+
# diff) AFTER the reset — work/ is gitignored so the
|
|
4270
|
+
# reset leaves it untouched, and this makes a
|
|
4271
|
+
# signature-repeat block diagnosable like the
|
|
4272
|
+
# exhausted-attempts path (#168).
|
|
4273
|
+
note_paths = persist_attempt_notes(
|
|
4274
|
+
work_dir, wu.wu_id, attempt_notes)
|
|
4172
4275
|
backend.set_wu(wu, "status", "blocked_human")
|
|
4173
4276
|
write_cost_to_wu(backend, wu, cum_usage)
|
|
4174
4277
|
flush_events(events_path, wu_events)
|
|
4175
4278
|
commit_bookkeeping(
|
|
4176
|
-
[wu.file, events_path],
|
|
4279
|
+
[wu.file, events_path, *note_paths],
|
|
4177
4280
|
f"chore(loop): {wu.wu_id} blocked_human "
|
|
4178
4281
|
f"(spinning_signature_repeat, attempt {attempt})"
|
|
4179
4282
|
f"\n\nFeature: {wu.wu_id}",
|
|
@@ -4206,13 +4309,8 @@ def run(
|
|
|
4206
4309
|
o == "zero_token" for o in attempt_outcomes)
|
|
4207
4310
|
reason = ("all_attempts_zero_token" if all_zero
|
|
4208
4311
|
else "spinning_detected")
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
for atmpt, evidence in attempt_notes:
|
|
4212
|
-
p = work_dir / wu_key / f"attempt-{atmpt}.md"
|
|
4213
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
4214
|
-
p.write_text(evidence)
|
|
4215
|
-
note_paths.append(p)
|
|
4312
|
+
note_paths = persist_attempt_notes(
|
|
4313
|
+
work_dir, wu.wu_id, attempt_notes)
|
|
4216
4314
|
backend.set_wu(wu, "status", "blocked_human")
|
|
4217
4315
|
write_cost_to_wu(backend, wu, cum_usage)
|
|
4218
4316
|
wu_events.append(build_event("human_escalation", wu.wu_id, {
|
|
@@ -8,6 +8,7 @@ import importlib.resources
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
10
|
import re
|
|
11
|
+
import sys
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
13
14
|
from . import _miniyaml
|
|
@@ -389,9 +390,45 @@ def upgrade_specfuse(
|
|
|
389
390
|
wire_claude(target_path)
|
|
390
391
|
_write_manifest(specfuse_dir, manifest_entries)
|
|
391
392
|
|
|
393
|
+
_warn_unmanaged_legacy_dirs(specfuse_dir)
|
|
394
|
+
|
|
392
395
|
return sorted(written)
|
|
393
396
|
|
|
394
397
|
|
|
398
|
+
def _warn_unmanaged_legacy_dirs(specfuse_dir: Path) -> None:
|
|
399
|
+
"""Warn (stderr) when legacy scripts/ or skills/ copies exist post-upgrade.
|
|
400
|
+
|
|
401
|
+
Current scaffold data ships neither directory: scripts come from the
|
|
402
|
+
specfuse-loop package, skills from the specfuse@specfuse plugin. Only
|
|
403
|
+
pre-PyPI (init.sh-era) repos carry full copies, and `upgrade` overlays
|
|
404
|
+
none of them while still advancing VERSION — so a VERSION that outpaces
|
|
405
|
+
the vendored scripts/skills is silent drift (#165). New projects have
|
|
406
|
+
neither dir, so this is a no-op for them.
|
|
407
|
+
|
|
408
|
+
A warning, not a prune: reconciling those copies (see `migrate_legacy`)
|
|
409
|
+
is destructive and left to an explicit operator step.
|
|
410
|
+
"""
|
|
411
|
+
drift: list[str] = []
|
|
412
|
+
for name in ("scripts", "skills"):
|
|
413
|
+
d = specfuse_dir / name
|
|
414
|
+
if d.is_dir():
|
|
415
|
+
n = sum(1 for p in d.rglob("*") if p.is_file())
|
|
416
|
+
if n:
|
|
417
|
+
drift.append(f" .specfuse/{name}/ ({n} file(s))")
|
|
418
|
+
if not drift:
|
|
419
|
+
return
|
|
420
|
+
print(
|
|
421
|
+
f"specfuse upgrade: WARNING — VERSION advanced to {scaffold_version()}, "
|
|
422
|
+
"but these directories are NOT managed by upgrade and may be stale:\n"
|
|
423
|
+
+ "\n".join(drift)
|
|
424
|
+
+ "\n Scripts are provided by the specfuse-loop package and skills by "
|
|
425
|
+
"the specfuse@specfuse plugin; these are leftover full copies from an "
|
|
426
|
+
"older (pre-package) install. Remove them once nothing local depends "
|
|
427
|
+
"on them.",
|
|
428
|
+
file=sys.stderr,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
395
432
|
def init(target: str | Path, *, ci_check: str | None = None) -> list[str]:
|
|
396
433
|
"""Bootstrap a Specfuse-enabled repo: write .specfuse/ then wire .claude/.
|
|
397
434
|
|
|
@@ -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_loop_attempt_evidence.py
|
|
105
106
|
tests/test_loop_auto_archive.py
|
|
106
107
|
tests/test_loop_caveman_preamble.py
|
|
107
108
|
tests/test_loop_close_intermediate.py
|
|
@@ -231,6 +231,45 @@ class TestParseGateFailureSignature(unittest.TestCase):
|
|
|
231
231
|
self.assertEqual(fc, "other")
|
|
232
232
|
self.assertEqual(sig, "no_gate_marker")
|
|
233
233
|
|
|
234
|
+
# -- #167: non-Python gate output must not collapse to a bare fence -- #
|
|
235
|
+
|
|
236
|
+
def test_fallback_skips_bare_code_fence(self):
|
|
237
|
+
# A non-Python tests gate (e.g. TypeScript) matches none of the
|
|
238
|
+
# Python _SIG_PATTERNS and falls through. The first line after the
|
|
239
|
+
# marker is a Markdown fence; the signature must key off the real
|
|
240
|
+
# failure line beneath it, not the fence.
|
|
241
|
+
stdout = (
|
|
242
|
+
"### tests: FAIL\n"
|
|
243
|
+
"```\n"
|
|
244
|
+
" ✕ company builds a valid config (expected 3, got 4)\n"
|
|
245
|
+
"```\n"
|
|
246
|
+
)
|
|
247
|
+
fc, sig = loop.parse_gate_failure_signature(stdout)
|
|
248
|
+
self.assertEqual(fc, "tests")
|
|
249
|
+
self.assertNotEqual(sig, "```")
|
|
250
|
+
self.assertIn("company builds a valid config", sig)
|
|
251
|
+
|
|
252
|
+
def test_fallback_skips_whitespace_and_tilde_fence(self):
|
|
253
|
+
stdout = "### tests: FAIL\n \n~~~\n\nReal failure: assertion X\n"
|
|
254
|
+
fc, sig = loop.parse_gate_failure_signature(stdout)
|
|
255
|
+
self.assertEqual(fc, "tests")
|
|
256
|
+
self.assertIn("Real failure", sig)
|
|
257
|
+
|
|
258
|
+
def test_all_noninformative_returns_no_signature(self):
|
|
259
|
+
stdout = "### tests: FAIL\n```\n \n~~~\n"
|
|
260
|
+
fc, sig = loop.parse_gate_failure_signature(stdout)
|
|
261
|
+
self.assertEqual(fc, "tests")
|
|
262
|
+
self.assertEqual(sig, "no_signature")
|
|
263
|
+
|
|
264
|
+
def test_two_distinct_ts_failures_yield_distinct_signatures(self):
|
|
265
|
+
# The #167 false-spin: two genuinely different failures must NOT
|
|
266
|
+
# reduce to the same signature (both previously became "```").
|
|
267
|
+
a = "### tests: FAIL\n```\n ✕ builds config (expected 3, got 4)\n```\n"
|
|
268
|
+
b = "### tests: FAIL\n```\n ✕ parses tokens (unexpected EOF)\n```\n"
|
|
269
|
+
_, sig_a = loop.parse_gate_failure_signature(a)
|
|
270
|
+
_, sig_b = loop.parse_gate_failure_signature(b)
|
|
271
|
+
self.assertNotEqual(sig_a, sig_b)
|
|
272
|
+
|
|
234
273
|
|
|
235
274
|
# --------------------------------------------------------------------------- #
|
|
236
275
|
# TestExtractFailureExcerpt #
|
|
@@ -443,6 +482,49 @@ class TestDetectSpinningSignatureRepeat(unittest.TestCase):
|
|
|
443
482
|
)
|
|
444
483
|
self.assertFalse(result)
|
|
445
484
|
|
|
485
|
+
# -- #167: non-informative signatures must not trip the spin detector -- #
|
|
486
|
+
|
|
487
|
+
def test_false_on_bare_fence_signature(self):
|
|
488
|
+
# The exact #167 shape: both attempts reduced to "```". Even though
|
|
489
|
+
# current == prior, a bare fence carries no failure-distinguishing
|
|
490
|
+
# content and must not escalate.
|
|
491
|
+
self.assertFalse(
|
|
492
|
+
loop.detect_spinning_signature_repeat(
|
|
493
|
+
("tests", "```"), ("tests", "```"),
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def test_false_on_whitespace_signature(self):
|
|
498
|
+
self.assertFalse(
|
|
499
|
+
loop.detect_spinning_signature_repeat(
|
|
500
|
+
("tests", " "), ("tests", " "),
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
def test_false_on_pure_ansi_signature(self):
|
|
505
|
+
ansi = "\x1b[31m\x1b[0m"
|
|
506
|
+
self.assertFalse(
|
|
507
|
+
loop.detect_spinning_signature_repeat(
|
|
508
|
+
("tests", ansi), ("tests", ansi),
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def test_false_on_no_signature_sentinel(self):
|
|
513
|
+
self.assertFalse(
|
|
514
|
+
loop.detect_spinning_signature_repeat(
|
|
515
|
+
("tests", "no_signature"), ("tests", "no_signature"),
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
def test_still_true_on_real_repeated_signature(self):
|
|
520
|
+
# Guard against over-correction: a genuine repeat still escalates.
|
|
521
|
+
self.assertTrue(
|
|
522
|
+
loop.detect_spinning_signature_repeat(
|
|
523
|
+
("tests", "builds config (expected 3, got 4)"),
|
|
524
|
+
("tests", "builds config (expected 3, got 4)"),
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
446
528
|
|
|
447
529
|
# --------------------------------------------------------------------------- #
|
|
448
530
|
# TestSummarizeAttemptFailureClasses #
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Unit tests for #168 — persist rejected-attempt evidence before git reset.
|
|
6
|
+
|
|
7
|
+
Two helpers keep a blocked WU diagnosable:
|
|
8
|
+
|
|
9
|
+
- `capture_working_tree_diff(head_before)` folds the rejected diff into a
|
|
10
|
+
failing attempt's note BEFORE the per-attempt `git reset` discards it.
|
|
11
|
+
- `persist_attempt_notes(work_dir, wu_id, notes)` writes the buffered
|
|
12
|
+
evidence to `work/<wu>/attempt-N.md`. It is now called on BOTH escalation
|
|
13
|
+
paths (early spinning_signature_repeat and exhausted-attempts); previously
|
|
14
|
+
only the latter wrote notes, so a signature-repeat block lost all evidence.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
import unittest
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from tests._loop_loader import load_loop
|
|
27
|
+
|
|
28
|
+
loop = load_loop()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def _git_repo_no_sign():
|
|
33
|
+
"""Minimal git repo with signing disabled (operator config-independent)."""
|
|
34
|
+
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
|
|
35
|
+
root = Path(tmp)
|
|
36
|
+
run = lambda *a: subprocess.run( # noqa: E731
|
|
37
|
+
["git", "-C", str(root), *a], check=True, capture_output=True,
|
|
38
|
+
)
|
|
39
|
+
run("init", "-q", "-b", "main")
|
|
40
|
+
run("config", "commit.gpgSign", "false")
|
|
41
|
+
run("config", "gc.auto", "0")
|
|
42
|
+
run("config", "user.email", "test@example.com")
|
|
43
|
+
run("config", "user.name", "Test")
|
|
44
|
+
(root / "README.md").write_text("# fixture\n")
|
|
45
|
+
run("add", ".")
|
|
46
|
+
run("commit", "-q", "-m", "init")
|
|
47
|
+
run("rev-parse", "HEAD")
|
|
48
|
+
prev_cwd = os.getcwd()
|
|
49
|
+
try:
|
|
50
|
+
os.chdir(root)
|
|
51
|
+
yield root
|
|
52
|
+
finally:
|
|
53
|
+
os.chdir(prev_cwd)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestCaptureWorkingTreeDiff(unittest.TestCase):
|
|
57
|
+
|
|
58
|
+
def test_captures_tracked_modification(self):
|
|
59
|
+
with _git_repo_no_sign() as root:
|
|
60
|
+
head = loop.git("rev-parse", "HEAD")
|
|
61
|
+
(root / "README.md").write_text("# fixture\nnew line\n")
|
|
62
|
+
diff = loop.capture_working_tree_diff(head)
|
|
63
|
+
self.assertIn("new line", diff)
|
|
64
|
+
self.assertIn("README.md", diff)
|
|
65
|
+
|
|
66
|
+
def test_empty_on_clean_tree(self):
|
|
67
|
+
with _git_repo_no_sign():
|
|
68
|
+
head = loop.git("rev-parse", "HEAD")
|
|
69
|
+
self.assertEqual(loop.capture_working_tree_diff(head), "")
|
|
70
|
+
|
|
71
|
+
def test_truncates_large_diff(self):
|
|
72
|
+
with _git_repo_no_sign() as root:
|
|
73
|
+
head = loop.git("rev-parse", "HEAD")
|
|
74
|
+
(root / "big.txt").write_text("x\n" * 100000)
|
|
75
|
+
subprocess.run(["git", "-C", str(root), "add", "big.txt"],
|
|
76
|
+
check=True, capture_output=True)
|
|
77
|
+
diff = loop.capture_working_tree_diff(head, max_chars=2000)
|
|
78
|
+
self.assertLessEqual(len(diff), 2000 + 64)
|
|
79
|
+
self.assertIn("truncated", diff)
|
|
80
|
+
|
|
81
|
+
def test_empty_on_bad_ref(self):
|
|
82
|
+
with _git_repo_no_sign():
|
|
83
|
+
# A non-existent ref makes git diff exit non-zero → "" (best-effort).
|
|
84
|
+
self.assertEqual(
|
|
85
|
+
loop.capture_working_tree_diff("deadbeefdeadbeef"), "")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestPersistAttemptNotes(unittest.TestCase):
|
|
89
|
+
|
|
90
|
+
def test_writes_one_file_per_attempt(self):
|
|
91
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
92
|
+
work = Path(tmp) / "work"
|
|
93
|
+
notes = [(1, "first failure"), (2, "second failure")]
|
|
94
|
+
paths = loop.persist_attempt_notes(work, "FEAT-2026-0016/T03", notes)
|
|
95
|
+
self.assertEqual(len(paths), 2)
|
|
96
|
+
self.assertTrue((work / "FEAT-2026-0016_T03" / "attempt-1.md").exists())
|
|
97
|
+
self.assertEqual(
|
|
98
|
+
(work / "FEAT-2026-0016_T03" / "attempt-2.md").read_text(),
|
|
99
|
+
"second failure")
|
|
100
|
+
|
|
101
|
+
def test_wu_id_slashes_flattened(self):
|
|
102
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
103
|
+
work = Path(tmp) / "work"
|
|
104
|
+
paths = loop.persist_attempt_notes(work, "A/B/C", [(1, "x")])
|
|
105
|
+
self.assertIn("A_B_C", str(paths[0]))
|
|
106
|
+
|
|
107
|
+
def test_empty_notes_writes_nothing(self):
|
|
108
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
109
|
+
work = Path(tmp) / "work"
|
|
110
|
+
paths = loop.persist_attempt_notes(work, "X/T01", [])
|
|
111
|
+
self.assertEqual(paths, [])
|
|
112
|
+
self.assertFalse(work.exists())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
unittest.main()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Copyright 2026 Specfuse Contributors
|
|
2
2
|
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
3
3
|
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
4
6
|
import pathlib
|
|
5
7
|
import shutil
|
|
6
8
|
import tempfile
|
|
@@ -87,6 +89,70 @@ class TestUpgradeDowngradeRefusal(unittest.TestCase):
|
|
|
87
89
|
shutil.rmtree(tmpdir)
|
|
88
90
|
|
|
89
91
|
|
|
92
|
+
class TestUnmanagedLegacyWarning(unittest.TestCase):
|
|
93
|
+
"""#165: upgrade advances VERSION but never manages scripts/ or skills/.
|
|
94
|
+
|
|
95
|
+
Only pre-PyPI (init.sh-era) repos carry full copies; upgrade leaves them
|
|
96
|
+
stale while VERSION moves on. A warning must surface that drift; a clean
|
|
97
|
+
(post-migration) project must stay silent.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def _upgrade_capturing_stderr(self, target) -> str:
|
|
101
|
+
err = io.StringIO()
|
|
102
|
+
with contextlib.redirect_stderr(err):
|
|
103
|
+
upgrade_specfuse(target)
|
|
104
|
+
return err.getvalue()
|
|
105
|
+
|
|
106
|
+
def test_warns_when_legacy_scripts_and_skills_present(self):
|
|
107
|
+
target, specfuse, tmpdir = _make_target_with_version("0.0.1")
|
|
108
|
+
try:
|
|
109
|
+
(specfuse / "scripts").mkdir()
|
|
110
|
+
(specfuse / "scripts" / "loop.py").write_text("# legacy 0.2.0\n")
|
|
111
|
+
(specfuse / "skills" / "draft-feature").mkdir(parents=True)
|
|
112
|
+
(specfuse / "skills" / "draft-feature" / "SKILL.md").write_text("x")
|
|
113
|
+
out = self._upgrade_capturing_stderr(target)
|
|
114
|
+
self.assertIn("scripts/", out)
|
|
115
|
+
self.assertIn("skills/", out)
|
|
116
|
+
# Names the version it advanced to, so the drift is legible.
|
|
117
|
+
self.assertIn(scaffold_version(), out)
|
|
118
|
+
finally:
|
|
119
|
+
shutil.rmtree(tmpdir)
|
|
120
|
+
|
|
121
|
+
def test_warns_when_only_scripts_present(self):
|
|
122
|
+
target, specfuse, tmpdir = _make_target_with_version("0.0.1")
|
|
123
|
+
try:
|
|
124
|
+
(specfuse / "scripts").mkdir()
|
|
125
|
+
(specfuse / "scripts" / "loop.py").write_text("# legacy\n")
|
|
126
|
+
out = self._upgrade_capturing_stderr(target)
|
|
127
|
+
self.assertIn("scripts/", out)
|
|
128
|
+
self.assertNotIn("skills/", out)
|
|
129
|
+
finally:
|
|
130
|
+
shutil.rmtree(tmpdir)
|
|
131
|
+
|
|
132
|
+
def test_silent_on_clean_project(self):
|
|
133
|
+
# A post-migration project (init_specfuse ships neither dir).
|
|
134
|
+
tmpdir = tempfile.mkdtemp()
|
|
135
|
+
target = pathlib.Path(tmpdir)
|
|
136
|
+
try:
|
|
137
|
+
init_specfuse(target)
|
|
138
|
+
self.assertFalse((target / ".specfuse" / "scripts").exists())
|
|
139
|
+
self.assertFalse((target / ".specfuse" / "skills").exists())
|
|
140
|
+
out = self._upgrade_capturing_stderr(target)
|
|
141
|
+
self.assertNotIn("not managed", out.lower())
|
|
142
|
+
finally:
|
|
143
|
+
shutil.rmtree(tmpdir)
|
|
144
|
+
|
|
145
|
+
def test_empty_legacy_dir_does_not_warn(self):
|
|
146
|
+
# An empty scripts/ dir (no files) is not drift.
|
|
147
|
+
target, specfuse, tmpdir = _make_target_with_version("0.0.1")
|
|
148
|
+
try:
|
|
149
|
+
(specfuse / "scripts").mkdir()
|
|
150
|
+
out = self._upgrade_capturing_stderr(target)
|
|
151
|
+
self.assertNotIn("not managed", out.lower())
|
|
152
|
+
finally:
|
|
153
|
+
shutil.rmtree(tmpdir)
|
|
154
|
+
|
|
155
|
+
|
|
90
156
|
class TestUpgradeOverlayAndPreserve(unittest.TestCase):
|
|
91
157
|
def setUp(self):
|
|
92
158
|
self._tmpdir = tempfile.TemporaryDirectory()
|
|
@@ -186,6 +186,11 @@ class TestUntrackedPathsHelper(unittest.TestCase):
|
|
|
186
186
|
root = Path(self._tmp.name)
|
|
187
187
|
subprocess.run(["git", "init", "-q", "-b", "main", str(root)],
|
|
188
188
|
check=True)
|
|
189
|
+
# Disable commit signing so the fixture is independent of operator
|
|
190
|
+
# global config: with SSH signing on and no agent socket (e.g. a
|
|
191
|
+
# sandboxed session) `git commit` fails "Couldn't get agent socket"
|
|
192
|
+
# (#166). Mirrors the _git_repo_no_sign convention used elsewhere.
|
|
193
|
+
_git(root, "config", "commit.gpgSign", "false")
|
|
189
194
|
_git(root, "config", "user.email", "test@example.com")
|
|
190
195
|
_git(root, "config", "user.name", "Test")
|
|
191
196
|
(root / "a.py").write_text("a\n")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.16
|
|
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.16 → specfuse_loop-0.3.17}/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.16 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/security-boundaries.md
RENAMED
|
File without changes
|
{specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/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
|
|
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.16 → specfuse_loop-0.3.17}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|