specfuse-loop 0.3.4__tar.gz → 0.3.6__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.4/specfuse_loop.egg-info → specfuse_loop-0.3.6}/PKG-INFO +1 -1
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/pyproject.toml +1 -1
- specfuse_loop-0.3.6/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/docs/methodology.md +5 -1
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/roadmap.template.md +3 -1
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/templates/PLAN.template.md +2 -1
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/loop.py +10 -9
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse_loop.egg-info/SOURCES.txt +1 -0
- specfuse_loop-0.3.6/tests/test_lint_plan_relative_import.py +57 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_orchestration.py +21 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_post_pass_invariant.py +56 -3
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_roadmap_archive_skill.py +107 -0
- specfuse_loop-0.3.4/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/LICENSE +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/NOTICE +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/README.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/setup.cfg +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/lint_plan.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/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.6"
|
|
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.6
|
|
@@ -18,7 +18,11 @@ on both surfaces.
|
|
|
18
18
|
## 1. The unit hierarchy
|
|
19
19
|
|
|
20
20
|
- **Roadmap** — the master index of features for a repository/project, with each
|
|
21
|
-
feature's status (`planned → active → done`/`abandoned`).
|
|
21
|
+
feature's status (`planned → active → done`/`abandoned`). A feature may also be
|
|
22
|
+
`deferred` — parked pending an external decision or dependency: nothing is
|
|
23
|
+
loop-dispatchable, so the driver skips it (like `abandoned`), but it stays
|
|
24
|
+
resumable — a human flips it back to `active` when the blocker clears. `deferred`
|
|
25
|
+
is distinct from `abandoned` (dead) and `done` (complete).
|
|
22
26
|
- **Feature** — a spec-driven *or directly-authored* unit of value, identified by
|
|
23
27
|
a correlation ID `FEAT-YYYY-NNNN`. A feature owns an ordered list of gates.
|
|
24
28
|
- **Gate** — a milestone partition of a feature: an ordered batch of substantive
|
|
@@ -22,7 +22,9 @@ the canonical column order below.
|
|
|
22
22
|
| Feature ID | Title | Status | Folder | Detail |
|
|
23
23
|
|----------------|-------|--------|--------|--------|
|
|
24
24
|
|
|
25
|
-
Status: `planned` → `active` → `done` (or `abandoned`).
|
|
25
|
+
Status: `planned` → `active` → `done` (or `abandoned`). `deferred` = parked
|
|
26
|
+
pending an external decision/dependency; resumable (a human flips it back to
|
|
27
|
+
`active` when the blocker clears), distinct from `abandoned` (dead).
|
|
26
28
|
|
|
27
29
|
## Notes
|
|
28
30
|
|
|
@@ -7,7 +7,8 @@ roadmap_goal: <one line copied from the roadmap — the north star this feature
|
|
|
7
7
|
plan-next anchors every drafted gate to this and flags if a retrospective implies
|
|
8
8
|
it should change>
|
|
9
9
|
autonomy_default: review # auto | review | supervised
|
|
10
|
-
status: active # active | done | abandoned
|
|
10
|
+
status: active # active | deferred | done | abandoned
|
|
11
|
+
# deferred = parked pending an external decision/dependency; resumable, non-dispatchable
|
|
11
12
|
# planned_cost_usd: 0.00 # OPTIONAL — sum of WU planned costs; lint warns when missing or when delta from WU sum > 10%
|
|
12
13
|
---
|
|
13
14
|
|
|
@@ -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.6"
|
|
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
|
|
@@ -2900,17 +2900,13 @@ def assert_terminal_flips_fired(
|
|
|
2900
2900
|
False,
|
|
2901
2901
|
f"roadmap_row_not_done: roadmap.md absent at {roadmap_path}",
|
|
2902
2902
|
)
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
re.MULTILINE,
|
|
2906
|
-
)
|
|
2907
|
-
rm = row_re.search(roadmap_path.read_text())
|
|
2908
|
-
if not rm:
|
|
2903
|
+
parsed = _parse_roadmap_row(roadmap_path.read_text(), feature_id)
|
|
2904
|
+
if not parsed:
|
|
2909
2905
|
return (
|
|
2910
2906
|
False,
|
|
2911
2907
|
f"roadmap_row_not_done: row for {feature_id} not found",
|
|
2912
2908
|
)
|
|
2913
|
-
row_status =
|
|
2909
|
+
row_status = parsed["columns"].get("Status", "").strip()
|
|
2914
2910
|
if row_status != "done":
|
|
2915
2911
|
return False, f"roadmap_row_not_done: status={row_status!r}"
|
|
2916
2912
|
|
|
@@ -3531,7 +3527,12 @@ def run(
|
|
|
3531
3527
|
# FEAT-2026-0018/T07 — plan-next-draft lint hook (warn-only v1)
|
|
3532
3528
|
if wu.type == "plan-next":
|
|
3533
3529
|
try:
|
|
3534
|
-
|
|
3530
|
+
# Package-relative + function-local: lint_plan
|
|
3531
|
+
# imports `from .loop import VERDICT_VALUES`, so a
|
|
3532
|
+
# module-top import would be circular; the old
|
|
3533
|
+
# flat (top-level) form broke once the driver
|
|
3534
|
+
# ships as a pip package (#100).
|
|
3535
|
+
from .lint_plan import lint_plan_next_draft
|
|
3535
3536
|
_warns = lint_plan_next_draft(feature_dir, gate.number)
|
|
3536
3537
|
except Exception as _exc:
|
|
3537
3538
|
_warns = [f"lint_plan_next_draft raised: {_exc}"]
|
|
@@ -79,6 +79,7 @@ tests/test_lint_correlation_id_close_intermediate.py
|
|
|
79
79
|
tests/test_lint_oracle_env.py
|
|
80
80
|
tests/test_lint_plan_errors.py
|
|
81
81
|
tests/test_lint_plan_next_draft.py
|
|
82
|
+
tests/test_lint_plan_relative_import.py
|
|
82
83
|
tests/test_lint_produces_driver_helper.py
|
|
83
84
|
tests/test_lint_sections.py
|
|
84
85
|
tests/test_lint_task_graph_yaml_selection.py
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Regression test for #100 — the plan-next-draft lint hook must use a
|
|
6
|
+
package-relative import.
|
|
7
|
+
|
|
8
|
+
`loop.py`'s plan-next hook imported `from lint_plan import lint_plan_next_draft`
|
|
9
|
+
(flat). That only resolved when the scripts were copied side-by-side (pre-pip);
|
|
10
|
+
in the installed package it raises `No module named 'lint_plan'`, so the
|
|
11
|
+
plan-next gate logs a warning and the draft lint never runs. The import is
|
|
12
|
+
function-local on purpose (lint_plan imports `from .loop import VERDICT_VALUES`,
|
|
13
|
+
so a module-top import would be circular) — the fix is to make the local import
|
|
14
|
+
package-relative, not to hoist it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import unittest
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
26
|
+
LOOP_SRC = REPO_ROOT / "specfuse" / "loop" / "loop.py"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestLintPlanRelativeImport(unittest.TestCase):
|
|
30
|
+
|
|
31
|
+
def test_loop_uses_package_relative_lint_plan_import(self):
|
|
32
|
+
src = LOOP_SRC.read_text(encoding="utf-8")
|
|
33
|
+
self.assertNotIn(
|
|
34
|
+
"from lint_plan import", src,
|
|
35
|
+
"flat `from lint_plan import` breaks in the installed package (#100)",
|
|
36
|
+
)
|
|
37
|
+
self.assertIn(
|
|
38
|
+
"from .lint_plan import lint_plan_next_draft", src,
|
|
39
|
+
"the plan-next hook must import lint_plan_next_draft package-relative",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def test_lint_plan_next_draft_importable_outside_repo_root(self):
|
|
43
|
+
"""Resolvable as a package member even when CWD is not the repo root
|
|
44
|
+
(so a flat `lint_plan.py` on the path can't mask the bug)."""
|
|
45
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
46
|
+
r = subprocess.run(
|
|
47
|
+
[sys.executable, "-c",
|
|
48
|
+
"from specfuse.loop.lint_plan import lint_plan_next_draft; "
|
|
49
|
+
"print('ok')"],
|
|
50
|
+
capture_output=True, text=True, cwd=tmp,
|
|
51
|
+
)
|
|
52
|
+
self.assertEqual(r.returncode, 0, r.stderr)
|
|
53
|
+
self.assertIn("ok", r.stdout)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
unittest.main()
|
|
@@ -319,6 +319,27 @@ class TestFindFeatureSelection(unittest.TestCase):
|
|
|
319
319
|
result = loop.find_feature("FEAT-2026-9502-explicit")
|
|
320
320
|
self.assertEqual(result, fdir)
|
|
321
321
|
|
|
322
|
+
def test_deferred_feature_is_not_auto_selected(self):
|
|
323
|
+
"""Issue #106: a `deferred` foldered feature is non-dispatchable — the
|
|
324
|
+
driver skips it exactly like a non-active feature (parked, resumable)."""
|
|
325
|
+
self._write_plan("FEAT-2026-9502-deferred", "deferred")
|
|
326
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
327
|
+
loop.find_feature(None)
|
|
328
|
+
self.assertIn("active", str(ctx.exception.code).lower())
|
|
329
|
+
|
|
330
|
+
def test_deferred_does_not_count_as_a_second_active(self):
|
|
331
|
+
"""A `deferred` feature alongside one `active` does not trigger the
|
|
332
|
+
multiple-active disambiguation — deferred is not dispatchable."""
|
|
333
|
+
active_dir = self._write_plan("FEAT-2026-9502-live", "active")
|
|
334
|
+
self._write_plan("FEAT-2026-9502-parked", "deferred")
|
|
335
|
+
self.assertEqual(loop.find_feature(None), active_dir)
|
|
336
|
+
|
|
337
|
+
def test_deferred_still_selectable_by_explicit_arg(self):
|
|
338
|
+
"""A human can still target a `deferred` feature explicitly (e.g. to
|
|
339
|
+
inspect it); only auto-selection skips it."""
|
|
340
|
+
fdir = self._write_plan("FEAT-2026-9502-parked", "deferred")
|
|
341
|
+
self.assertEqual(loop.find_feature("FEAT-2026-9502-parked"), fdir)
|
|
342
|
+
|
|
322
343
|
def test_explicit_arg_without_plan_exits(self):
|
|
323
344
|
"""Lines 188-190: explicit dir name with no PLAN.md → sys.exit."""
|
|
324
345
|
orphan = self._features_dir / "FEAT-2026-9502-orphan"
|
|
@@ -69,6 +69,7 @@ def _write_feature_scaffold(
|
|
|
69
69
|
roadmap_row_status: str = "done",
|
|
70
70
|
archive_anchor_present: bool = True,
|
|
71
71
|
wu_verdict: str = "met",
|
|
72
|
+
roadmap_priority_column: bool = False,
|
|
72
73
|
) -> Path:
|
|
73
74
|
"""Write a .specfuse scaffold matching the requested post-flip state.
|
|
74
75
|
|
|
@@ -98,11 +99,23 @@ def _write_feature_scaffold(
|
|
|
98
99
|
f"status: done\nattempts: 1\nverdict: {wu_verdict}\n---\n\n# Close{_WU_BODY}"
|
|
99
100
|
)
|
|
100
101
|
|
|
102
|
+
if roadmap_priority_column:
|
|
103
|
+
# Non-canonical shape: a Priority column sits BEFORE Status, shifting
|
|
104
|
+
# Status from column 3 to column 4. Exercises header-name parsing.
|
|
105
|
+
roadmap_table = (
|
|
106
|
+
f"| Feature ID | Title | Priority | Status | Folder |\n"
|
|
107
|
+
f"|------------|-------|----------|--------|--------|\n"
|
|
108
|
+
f"| {feature_id} | Test feature | P2 | {roadmap_row_status} | — |\n\n"
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
roadmap_table = (
|
|
112
|
+
f"| Feature ID | Title | Status | Folder | Detail |\n"
|
|
113
|
+
f"|------------|-------|--------|--------|--------|\n"
|
|
114
|
+
f"| {feature_id} | Test feature | {roadmap_row_status} | — | — |\n\n"
|
|
115
|
+
)
|
|
101
116
|
(specfuse / "roadmap.md").write_text(
|
|
102
117
|
f"---\nproject: test\n---\n\n# Roadmap\n\n"
|
|
103
|
-
f"
|
|
104
|
-
f"|------------|-------|--------|--------|--------|\n"
|
|
105
|
-
f"| {feature_id} | Test feature | {roadmap_row_status} | — | — |\n\n"
|
|
118
|
+
f"{roadmap_table}"
|
|
106
119
|
f"## {feature_id} — Test feature\n\nContent.\n"
|
|
107
120
|
)
|
|
108
121
|
|
|
@@ -175,6 +188,46 @@ class TestAssertTerminalFlipsFired(unittest.TestCase):
|
|
|
175
188
|
)
|
|
176
189
|
self.assertIn("active", reason)
|
|
177
190
|
|
|
191
|
+
def test_close_passes_with_priority_column_roadmap(self):
|
|
192
|
+
"""Issue #104: roadmap with a Priority column before Status.
|
|
193
|
+
|
|
194
|
+
Positional parsing read the Priority cell ('P2') as the status and
|
|
195
|
+
false-escalated roadmap_row_not_done. Header-name parsing must find
|
|
196
|
+
the real Status column and pass.
|
|
197
|
+
"""
|
|
198
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
199
|
+
root = Path(tmp)
|
|
200
|
+
feature_dir = _write_feature_scaffold(
|
|
201
|
+
root, roadmap_priority_column=True,
|
|
202
|
+
)
|
|
203
|
+
wu = _make_close_wu(feature_dir)
|
|
204
|
+
ok, reason = loop.assert_terminal_flips_fired(
|
|
205
|
+
wu, feature_dir, root, DUMMY_HEAD,
|
|
206
|
+
)
|
|
207
|
+
self.assertTrue(ok, msg=f"unexpected fail: {reason!r}")
|
|
208
|
+
self.assertEqual(reason, "")
|
|
209
|
+
|
|
210
|
+
def test_close_fails_with_priority_column_when_row_active(self):
|
|
211
|
+
"""Priority-column table, row genuinely active → still flagged, and the
|
|
212
|
+
reason reports the real Status cell, not the Priority token."""
|
|
213
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
214
|
+
root = Path(tmp)
|
|
215
|
+
feature_dir = _write_feature_scaffold(
|
|
216
|
+
root, roadmap_priority_column=True,
|
|
217
|
+
roadmap_row_status="active",
|
|
218
|
+
)
|
|
219
|
+
wu = _make_close_wu(feature_dir)
|
|
220
|
+
ok, reason = loop.assert_terminal_flips_fired(
|
|
221
|
+
wu, feature_dir, root, DUMMY_HEAD,
|
|
222
|
+
)
|
|
223
|
+
self.assertFalse(ok)
|
|
224
|
+
self.assertTrue(
|
|
225
|
+
reason.startswith("roadmap_row_not_done:"),
|
|
226
|
+
msg=f"unexpected reason: {reason!r}",
|
|
227
|
+
)
|
|
228
|
+
self.assertIn("active", reason)
|
|
229
|
+
self.assertNotIn("P2", reason)
|
|
230
|
+
|
|
178
231
|
def test_close_with_verdict_met_fails_when_archive_anchor_absent(self):
|
|
179
232
|
"""Gate + row done but archive missing the lower-cased anchor."""
|
|
180
233
|
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -104,6 +104,34 @@ def archive_feature(roadmap_path: Path, archive_path: Path, feat_id: str) -> str
|
|
|
104
104
|
return "archived"
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
def auto_eligible(roadmap_text: str) -> list[str]:
|
|
108
|
+
"""Reference impl of `--auto` eligibility, corrected per #102.
|
|
109
|
+
|
|
110
|
+
Eligible = `done`/`abandoned` rows that still have an inline `## FEAT-id — `
|
|
111
|
+
section and are not already back-linked. NOT gated on the `Detail` cell being
|
|
112
|
+
`—`: a folder-path Detail (older convention) is orthogonal to whether the
|
|
113
|
+
inline prose was archived. The buggy rule (`Detail == '—'`) silently skipped
|
|
114
|
+
folder-path rows, leaving their prose inline forever.
|
|
115
|
+
"""
|
|
116
|
+
eligible: list[str] = []
|
|
117
|
+
row_re = re.compile(
|
|
118
|
+
r"^\| *(FEAT-\d{4}-\d{4}) *\|([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|",
|
|
119
|
+
re.MULTILINE,
|
|
120
|
+
)
|
|
121
|
+
for m in row_re.finditer(roadmap_text):
|
|
122
|
+
feat_id = m.group(1).strip()
|
|
123
|
+
status = m.group(3).strip()
|
|
124
|
+
detail = m.group(5).strip()
|
|
125
|
+
if status not in ("done", "abandoned"):
|
|
126
|
+
continue
|
|
127
|
+
if "roadmap-archive.md#" in detail:
|
|
128
|
+
continue # already archived
|
|
129
|
+
if re.search(r"^## " + re.escape(feat_id) + r" — ", roadmap_text,
|
|
130
|
+
re.MULTILINE):
|
|
131
|
+
eligible.append(feat_id)
|
|
132
|
+
return eligible
|
|
133
|
+
|
|
134
|
+
|
|
107
135
|
# ---------------------------------------------------------------------------
|
|
108
136
|
# Test fixtures
|
|
109
137
|
# ---------------------------------------------------------------------------
|
|
@@ -240,5 +268,84 @@ class TestArchiveFeature(unittest.TestCase):
|
|
|
240
268
|
self.assertEqual(self.archive.read_text(), archive_snapshot)
|
|
241
269
|
|
|
242
270
|
|
|
271
|
+
_ROADMAP_MIXED = textwrap.dedent("""\
|
|
272
|
+
---
|
|
273
|
+
project: test
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
# Roadmap
|
|
277
|
+
|
|
278
|
+
| Feature ID | Title | Status | Folder | Detail |
|
|
279
|
+
|----------------|---------------|-----------|--------|--------|
|
|
280
|
+
| FEAT-2026-0001 | Folder detail | done | f1 | features/FEAT-2026-0001-x/ |
|
|
281
|
+
| FEAT-2026-0002 | Dash detail | done | — | — |
|
|
282
|
+
| FEAT-2026-0003 | Already arch | done | — | [→ archive](roadmap-archive.md#feat-2026-0003) |
|
|
283
|
+
| FEAT-2026-0004 | Planned thing | planned | — | — |
|
|
284
|
+
| FEAT-2026-0005 | Abandoned one | abandoned | f5 | features/FEAT-2026-0005-y/ |
|
|
285
|
+
|
|
286
|
+
## FEAT-2026-0001 — Folder detail
|
|
287
|
+
|
|
288
|
+
Prose 1.
|
|
289
|
+
|
|
290
|
+
## FEAT-2026-0002 — Dash detail
|
|
291
|
+
|
|
292
|
+
Prose 2.
|
|
293
|
+
|
|
294
|
+
## FEAT-2026-0004 — Planned thing
|
|
295
|
+
|
|
296
|
+
Prose 4.
|
|
297
|
+
|
|
298
|
+
## FEAT-2026-0005 — Abandoned one
|
|
299
|
+
|
|
300
|
+
Prose 5.
|
|
301
|
+
|
|
302
|
+
## Notes
|
|
303
|
+
|
|
304
|
+
Notes.
|
|
305
|
+
""")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class TestAutoEligibility(unittest.TestCase):
|
|
309
|
+
"""#102 — --auto must not gate eligibility on Detail == '—'."""
|
|
310
|
+
|
|
311
|
+
def test_folder_path_detail_done_feature_is_eligible(self):
|
|
312
|
+
elig = auto_eligible(_ROADMAP_MIXED)
|
|
313
|
+
# The #102 regression: folder-path Detail done feature WITH inline prose.
|
|
314
|
+
self.assertIn("FEAT-2026-0001", elig)
|
|
315
|
+
self.assertIn("FEAT-2026-0005", elig) # abandoned + folder-path
|
|
316
|
+
|
|
317
|
+
def test_dash_detail_still_eligible(self):
|
|
318
|
+
self.assertIn("FEAT-2026-0002", auto_eligible(_ROADMAP_MIXED))
|
|
319
|
+
|
|
320
|
+
def test_already_archived_excluded(self):
|
|
321
|
+
self.assertNotIn("FEAT-2026-0003", auto_eligible(_ROADMAP_MIXED))
|
|
322
|
+
|
|
323
|
+
def test_planned_excluded(self):
|
|
324
|
+
self.assertNotIn("FEAT-2026-0004", auto_eligible(_ROADMAP_MIXED))
|
|
325
|
+
|
|
326
|
+
def test_full_eligible_set(self):
|
|
327
|
+
self.assertEqual(
|
|
328
|
+
sorted(auto_eligible(_ROADMAP_MIXED)),
|
|
329
|
+
["FEAT-2026-0001", "FEAT-2026-0002", "FEAT-2026-0005"],
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def test_old_dash_only_rule_would_skip_folder_paths(self):
|
|
333
|
+
"""Document the bug: gating on Detail == '—' drops the folder-path rows
|
|
334
|
+
that the corrected rule (auto_eligible) includes."""
|
|
335
|
+
dash_only = [f for f in auto_eligible(_ROADMAP_MIXED)
|
|
336
|
+
if _detail_cell(_ROADMAP_MIXED, f) == "—"]
|
|
337
|
+
self.assertEqual(dash_only, ["FEAT-2026-0002"])
|
|
338
|
+
# corrected rule catches 2 more the buggy one missed
|
|
339
|
+
self.assertEqual(len(auto_eligible(_ROADMAP_MIXED)) - len(dash_only), 2)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _detail_cell(roadmap_text: str, feat_id: str) -> str:
|
|
343
|
+
m = re.search(
|
|
344
|
+
r"^\| *" + re.escape(feat_id) + r" *\|[^|]+\|[^|]+\|[^|]+\|([^|]+)\|",
|
|
345
|
+
roadmap_text, re.MULTILINE,
|
|
346
|
+
)
|
|
347
|
+
return m.group(1).strip() if m else ""
|
|
348
|
+
|
|
349
|
+
|
|
243
350
|
if __name__ == "__main__":
|
|
244
351
|
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.4
|
|
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.4 → specfuse_loop-0.3.6}/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
|
|
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.4 → specfuse_loop-0.3.6}/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
|