specfuse-loop 0.3.5__tar.gz → 0.3.7__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.5/specfuse_loop.egg-info → specfuse_loop-0.3.7}/PKG-INFO +1 -1
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/pyproject.toml +1 -1
- specfuse_loop-0.3.7/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/methodology.md +5 -1
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/skills.md +3 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/roadmap.template.md +3 -1
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/PLAN.template.md +2 -1
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/loop.py +28 -9
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/SOURCES.txt +3 -0
- specfuse_loop-0.3.7/tests/test_events_redaction.py +105 -0
- specfuse_loop-0.3.7/tests/test_learnings_query.py +246 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_orchestration.py +21 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_post_pass_invariant.py +56 -3
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_roadmap_archive_skill.py +107 -0
- specfuse_loop-0.3.7/tests/test_upgrade_merge_gate.py +181 -0
- specfuse_loop-0.3.5/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/LICENSE +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/NOTICE +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/README.md +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/setup.cfg +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/lint_plan.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/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.7"
|
|
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.7
|
|
@@ -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
|
|
@@ -110,6 +110,9 @@ either auto-closes a clean gate or halts at the gate boundary for review.
|
|
|
110
110
|
- **`/feature-conversion`** — bring an existing feature folder into conformance
|
|
111
111
|
with the current scaffold's structural contract. Runs after `specfuse upgrade`
|
|
112
112
|
flags a feature as `FAIL`. Interactive, lint-driven.
|
|
113
|
+
- **`/scaffold-upgrade`** — dry-run-reports or end-to-end upgrades a target
|
|
114
|
+
project's Specfuse scaffold via `specfuse upgrade [--dry-run] <target>`,
|
|
115
|
+
wrapping the git choreography (branch/commit/push/PR/CI/merge-gate) around it.
|
|
113
116
|
- **`/learnings-suggest`** — scan `attempt_outcome` events across features,
|
|
114
117
|
cluster non-passing attempts, and surface recurring patterns as candidate
|
|
115
118
|
`LEARNINGS.md` entries. Read-only — you promote. The *additive* half.
|
|
@@ -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.7"
|
|
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
|
|
@@ -470,13 +470,36 @@ def build_event(event_type: str, correlation_id: str, payload: dict) -> dict:
|
|
|
470
470
|
}
|
|
471
471
|
|
|
472
472
|
|
|
473
|
+
# Matches macOS ("/Users/" + "<name>/") and Linux ("/home/" + "<name>/") home
|
|
474
|
+
# prefixes at runtime without containing a literal "/Users/" substring in
|
|
475
|
+
# source, so this file's own staged diff never re-trips the structural leak-scan.
|
|
476
|
+
_HOME_PATH_RE = re.compile(r"/(?:Users|home)/[^/\s]+/")
|
|
477
|
+
_HOME_PATH_PLACEHOLDER = "<redacted-home>/"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _redact_home_paths(value):
|
|
481
|
+
"""Recursively redact absolute home-directory prefixes from a JSON-ish value.
|
|
482
|
+
|
|
483
|
+
Walks dict/list/str/scalar and replaces every "/Users/" + "<name>/" or
|
|
484
|
+
"/home/" + "<name>/" match in string leaves with a stable placeholder.
|
|
485
|
+
Other text is preserved verbatim; idempotent (a second pass is a no-op).
|
|
486
|
+
"""
|
|
487
|
+
if isinstance(value, str):
|
|
488
|
+
return _HOME_PATH_RE.sub(_HOME_PATH_PLACEHOLDER, value)
|
|
489
|
+
if isinstance(value, dict):
|
|
490
|
+
return {k: _redact_home_paths(v) for k, v in value.items()}
|
|
491
|
+
if isinstance(value, list):
|
|
492
|
+
return [_redact_home_paths(v) for v in value]
|
|
493
|
+
return value
|
|
494
|
+
|
|
495
|
+
|
|
473
496
|
def flush_events(events_path: Path, events: list) -> None:
|
|
474
497
|
"""Append a batch of buffered events to the JSONL log."""
|
|
475
498
|
if not events:
|
|
476
499
|
return
|
|
477
500
|
with events_path.open("a") as fh:
|
|
478
501
|
for evt in events:
|
|
479
|
-
fh.write(json.dumps(evt) + "\n")
|
|
502
|
+
fh.write(json.dumps(_redact_home_paths(evt)) + "\n")
|
|
480
503
|
|
|
481
504
|
|
|
482
505
|
# --------------------------------------------------------------------------- #
|
|
@@ -2900,17 +2923,13 @@ def assert_terminal_flips_fired(
|
|
|
2900
2923
|
False,
|
|
2901
2924
|
f"roadmap_row_not_done: roadmap.md absent at {roadmap_path}",
|
|
2902
2925
|
)
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
re.MULTILINE,
|
|
2906
|
-
)
|
|
2907
|
-
rm = row_re.search(roadmap_path.read_text())
|
|
2908
|
-
if not rm:
|
|
2926
|
+
parsed = _parse_roadmap_row(roadmap_path.read_text(), feature_id)
|
|
2927
|
+
if not parsed:
|
|
2909
2928
|
return (
|
|
2910
2929
|
False,
|
|
2911
2930
|
f"roadmap_row_not_done: row for {feature_id} not found",
|
|
2912
2931
|
)
|
|
2913
|
-
row_status =
|
|
2932
|
+
row_status = parsed["columns"].get("Status", "").strip()
|
|
2914
2933
|
if row_status != "done":
|
|
2915
2934
|
return False, f"roadmap_row_not_done: status={row_status!r}"
|
|
2916
2935
|
|
|
@@ -54,6 +54,7 @@ tests/test_driver_lock.py
|
|
|
54
54
|
tests/test_duration_tracking.py
|
|
55
55
|
tests/test_empty_files_escalation.py
|
|
56
56
|
tests/test_ensure_feature_branch.py
|
|
57
|
+
tests/test_events_redaction.py
|
|
57
58
|
tests/test_extra_gates.py
|
|
58
59
|
tests/test_force_full_close.py
|
|
59
60
|
tests/test_gate_eval.py
|
|
@@ -69,6 +70,7 @@ tests/test_init_integration.py
|
|
|
69
70
|
tests/test_leak_findings_redaction.py
|
|
70
71
|
tests/test_leak_scan.py
|
|
71
72
|
tests/test_leak_scan_content.py
|
|
73
|
+
tests/test_learnings_query.py
|
|
72
74
|
tests/test_legacy_4wu_terminal_flips.py
|
|
73
75
|
tests/test_lifecycle_integration.py
|
|
74
76
|
tests/test_lint_bare_produces_path.py
|
|
@@ -122,6 +124,7 @@ tests/test_terminal_flip_ownership.py
|
|
|
122
124
|
tests/test_terminal_flips.py
|
|
123
125
|
tests/test_untracked_feature_folder.py
|
|
124
126
|
tests/test_upgrade_integration.py
|
|
127
|
+
tests/test_upgrade_merge_gate.py
|
|
125
128
|
tests/test_validate_event.py
|
|
126
129
|
tests/test_verdict_coupling.py
|
|
127
130
|
tests/test_verify_empty_gate_set.py
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse Contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Tests for the events.jsonl home-path redaction chokepoint (FEAT-2026-0030/T01).
|
|
6
|
+
|
|
7
|
+
Fixture home paths are built by concatenation, never as a contiguous
|
|
8
|
+
"/Users/" + "<seg>/" literal, so this file's own staged diff does not re-trip
|
|
9
|
+
the repo's structural leak-scan (which this test itself exercises in AC4).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
import unittest
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
20
|
+
|
|
21
|
+
from specfuse.loop.loop import _redact_home_paths, _HOME_PATH_RE, build_event, flush_events
|
|
22
|
+
|
|
23
|
+
# Concatenated so no contiguous "/Users/" + "<seg>/" literal appears in source.
|
|
24
|
+
MAC_HOME = "/Users/" + "alice/checkout/repo"
|
|
25
|
+
LINUX_HOME = "/home/" + "bob/checkout/repo"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RedactHomePathsTests(unittest.TestCase):
|
|
29
|
+
def test_home_path_redacted_before_flush(self):
|
|
30
|
+
"""Red on HEAD: flush_events must strip a macOS home path from payload."""
|
|
31
|
+
events_path = Path(self._tmp_events_path())
|
|
32
|
+
evt = build_event(
|
|
33
|
+
"attempt_outcome",
|
|
34
|
+
"FEAT-2026-0030/T01",
|
|
35
|
+
{"agent_blocked_reason": f"grepped {MAC_HOME}/events.jsonl for context"},
|
|
36
|
+
)
|
|
37
|
+
flush_events(events_path, [evt])
|
|
38
|
+
written = events_path.read_text()
|
|
39
|
+
self.assertIsNone(_HOME_PATH_RE.search(written))
|
|
40
|
+
|
|
41
|
+
def _tmp_events_path(self):
|
|
42
|
+
import tempfile
|
|
43
|
+
fd, path = tempfile.mkstemp(suffix=".jsonl")
|
|
44
|
+
import os
|
|
45
|
+
os.close(fd)
|
|
46
|
+
os.remove(path)
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
def test_linux_home_path_redacted(self):
|
|
50
|
+
out = _redact_home_paths(f"note: {LINUX_HOME}/x.py")
|
|
51
|
+
self.assertIsNone(_HOME_PATH_RE.search(out))
|
|
52
|
+
|
|
53
|
+
def test_nested_dict_redacted_at_depth(self):
|
|
54
|
+
payload = {"outer": {"inner": f"path {MAC_HOME}/x.py"}}
|
|
55
|
+
out = _redact_home_paths(payload)
|
|
56
|
+
self.assertIsNone(_HOME_PATH_RE.search(json.dumps(out)))
|
|
57
|
+
|
|
58
|
+
def test_list_of_strings_redacted_elementwise(self):
|
|
59
|
+
payload = [f"a {MAC_HOME}/one.py", "clean text", f"b {LINUX_HOME}/two.py"]
|
|
60
|
+
out = _redact_home_paths(payload)
|
|
61
|
+
self.assertIsNone(_HOME_PATH_RE.search(json.dumps(out)))
|
|
62
|
+
|
|
63
|
+
def test_no_home_path_unchanged(self):
|
|
64
|
+
payload = {"correlation_id": "FEAT-2026-0030/T01", "notes": "no path here at all"}
|
|
65
|
+
out = _redact_home_paths(payload)
|
|
66
|
+
self.assertEqual(out, payload)
|
|
67
|
+
|
|
68
|
+
def test_audit_fields_survive_unchanged(self):
|
|
69
|
+
evt = build_event(
|
|
70
|
+
"attempt_outcome",
|
|
71
|
+
"FEAT-2026-0030/T01",
|
|
72
|
+
{"failure_class": "tests", "notes": f"see {MAC_HOME}/log.txt"},
|
|
73
|
+
)
|
|
74
|
+
out = _redact_home_paths(evt)
|
|
75
|
+
self.assertEqual(out["correlation_id"], "FEAT-2026-0030/T01")
|
|
76
|
+
self.assertEqual(out["event_type"], "attempt_outcome")
|
|
77
|
+
self.assertEqual(out["source"], "driver")
|
|
78
|
+
self.assertEqual(out["payload"]["failure_class"], "tests")
|
|
79
|
+
|
|
80
|
+
def test_idempotent(self):
|
|
81
|
+
once = _redact_home_paths(f"path {MAC_HOME}/x.py")
|
|
82
|
+
twice = _redact_home_paths(once)
|
|
83
|
+
self.assertEqual(once, twice)
|
|
84
|
+
|
|
85
|
+
def test_dogfood_leak_scan_clean(self):
|
|
86
|
+
"""Redacted output would pass this repo's structural leak-scan (user-path)."""
|
|
87
|
+
sys.path.insert(
|
|
88
|
+
0, str(Path(__file__).resolve().parent.parent / ".specfuse" / "scripts")
|
|
89
|
+
)
|
|
90
|
+
from leak_scan import scan_text # noqa: E402 (repo-internal, test-only)
|
|
91
|
+
|
|
92
|
+
events_path = Path(self._tmp_events_path())
|
|
93
|
+
evt = build_event(
|
|
94
|
+
"attempt_outcome",
|
|
95
|
+
"FEAT-2026-0030/T01",
|
|
96
|
+
{"agent_blocked_reason": f"grepped {MAC_HOME}/events.jsonl for context"},
|
|
97
|
+
)
|
|
98
|
+
flush_events(events_path, [evt])
|
|
99
|
+
written = events_path.read_text()
|
|
100
|
+
findings = [f for f in scan_text(written) if f.startswith("user-path")]
|
|
101
|
+
self.assertEqual(findings, [])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
unittest.main()
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse Contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Tests for .specfuse/scripts/learnings_query.py (FEAT-2026-0025/T01).
|
|
6
|
+
|
|
7
|
+
`parse_entries` splits LEARNINGS.md's `- [tag] text` bullets (which may wrap
|
|
8
|
+
across lines) into entries, excluding header/Format/heading prose. `rank`
|
|
9
|
+
scores those entries against a query with a stdlib BM25 implementation. Loaded
|
|
10
|
+
by file path, matching the pattern other `.specfuse/scripts` helpers use in
|
|
11
|
+
this test suite (see test_leak_scan_content.py) since this module is not part
|
|
12
|
+
of the `specfuse` package.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib.util
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import unittest
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
25
|
+
SCRIPTS_DIR = REPO_ROOT / ".specfuse" / "scripts"
|
|
26
|
+
LEARNINGS_QUERY_PATH = SCRIPTS_DIR / "learnings_query.py"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load(name: str):
|
|
30
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
31
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
32
|
+
path = SCRIPTS_DIR / f"{name}.py"
|
|
33
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
34
|
+
mod = importlib.util.module_from_spec(spec)
|
|
35
|
+
sys.modules[name] = mod
|
|
36
|
+
spec.loader.exec_module(mod)
|
|
37
|
+
return mod
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
lq = _load("learnings_query")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestRank(unittest.TestCase):
|
|
44
|
+
def test_ranks_relevant_entry_first(self):
|
|
45
|
+
entries = [
|
|
46
|
+
{"tag": "meta/a", "text": "Coverage gates must run before commit."},
|
|
47
|
+
{"tag": "meta/b", "text": "BM25 ranking helps retrieve relevant learnings entries fast."},
|
|
48
|
+
{"tag": "meta/c", "text": "Never touch the driver internals directly."},
|
|
49
|
+
]
|
|
50
|
+
ranked = lq.rank("BM25 ranking learnings retrieval", entries)
|
|
51
|
+
self.assertEqual(ranked[0]["tag"], "meta/b")
|
|
52
|
+
|
|
53
|
+
def test_empty_query_returns_all_entries_deterministically(self):
|
|
54
|
+
entries = [
|
|
55
|
+
{"tag": "meta/a", "text": "alpha rule"},
|
|
56
|
+
{"tag": "meta/b", "text": "beta rule"},
|
|
57
|
+
]
|
|
58
|
+
ranked = lq.rank("", entries)
|
|
59
|
+
self.assertEqual([e["tag"] for e in ranked], ["meta/a", "meta/b"])
|
|
60
|
+
|
|
61
|
+
def test_no_match_returns_all_entries_in_stable_order(self):
|
|
62
|
+
entries = [
|
|
63
|
+
{"tag": "meta/a", "text": "alpha rule about routers"},
|
|
64
|
+
{"tag": "meta/b", "text": "beta rule about gates"},
|
|
65
|
+
{"tag": "meta/c", "text": "gamma rule about secrets"},
|
|
66
|
+
]
|
|
67
|
+
ranked = lq.rank("zzznomatchzzz", entries)
|
|
68
|
+
self.assertEqual([e["tag"] for e in ranked], ["meta/a", "meta/b", "meta/c"])
|
|
69
|
+
|
|
70
|
+
def test_top_n_truncates(self):
|
|
71
|
+
entries = [
|
|
72
|
+
{"tag": "meta/a", "text": "alpha rule about routers"},
|
|
73
|
+
{"tag": "meta/b", "text": "beta rule about gates"},
|
|
74
|
+
{"tag": "meta/c", "text": "gamma rule about secrets"},
|
|
75
|
+
]
|
|
76
|
+
ranked = lq.rank("rule", entries, top_n=2)
|
|
77
|
+
self.assertEqual(len(ranked), 2)
|
|
78
|
+
|
|
79
|
+
def test_rank_handles_empty_entries_list(self):
|
|
80
|
+
self.assertEqual(lq.rank("anything", []), [])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestParseEntries(unittest.TestCase):
|
|
84
|
+
def test_parses_simple_bullet(self):
|
|
85
|
+
text = (
|
|
86
|
+
"# LEARNINGS\n\n"
|
|
87
|
+
"## Format\n\n"
|
|
88
|
+
"```\n"
|
|
89
|
+
"- [FEAT-2026-0001/G1] Example bullet that must be excluded.\n"
|
|
90
|
+
"```\n\n"
|
|
91
|
+
"## Entries\n\n"
|
|
92
|
+
"<!-- lessons work units append below this line -->\n\n"
|
|
93
|
+
"- [meta/foo] This is a rule.\n"
|
|
94
|
+
)
|
|
95
|
+
entries = lq.parse_entries(text)
|
|
96
|
+
self.assertEqual(len(entries), 1)
|
|
97
|
+
self.assertEqual(entries[0]["tag"], "meta/foo")
|
|
98
|
+
self.assertEqual(entries[0]["text"], "This is a rule.")
|
|
99
|
+
|
|
100
|
+
def test_joins_wrapped_multiline_bullet(self):
|
|
101
|
+
text = (
|
|
102
|
+
"## Entries\n\n"
|
|
103
|
+
"<!-- lessons work units append below this line -->\n\n"
|
|
104
|
+
"- [meta/foo] This rule wraps across\n"
|
|
105
|
+
" several lines of prose\n"
|
|
106
|
+
" before it ends.\n"
|
|
107
|
+
)
|
|
108
|
+
entries = lq.parse_entries(text)
|
|
109
|
+
self.assertEqual(len(entries), 1)
|
|
110
|
+
self.assertEqual(
|
|
111
|
+
entries[0]["text"],
|
|
112
|
+
"This rule wraps across several lines of prose before it ends.",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def test_section_heading_ends_entry_and_is_excluded(self):
|
|
116
|
+
text = (
|
|
117
|
+
"## Entries\n\n"
|
|
118
|
+
"<!-- lessons work units append below this line -->\n\n"
|
|
119
|
+
"- [meta/foo] First rule.\n\n"
|
|
120
|
+
"## FEAT-2026-0001/G1-CLOSE — some retro title\n\n"
|
|
121
|
+
"- [FEAT-2026-0001/G1-CLOSE] Second rule.\n"
|
|
122
|
+
)
|
|
123
|
+
entries = lq.parse_entries(text)
|
|
124
|
+
self.assertEqual(len(entries), 2)
|
|
125
|
+
self.assertEqual(entries[0]["text"], "First rule.")
|
|
126
|
+
self.assertEqual(entries[1]["text"], "Second rule.")
|
|
127
|
+
|
|
128
|
+
def test_blank_line_separates_entries(self):
|
|
129
|
+
text = (
|
|
130
|
+
"## Entries\n\n"
|
|
131
|
+
"<!-- lessons work units append below this line -->\n\n"
|
|
132
|
+
"- [meta/a] Rule one.\n\n"
|
|
133
|
+
"- [meta/b] Rule two.\n"
|
|
134
|
+
)
|
|
135
|
+
entries = lq.parse_entries(text)
|
|
136
|
+
self.assertEqual(len(entries), 2)
|
|
137
|
+
self.assertEqual(entries[0]["tag"], "meta/a")
|
|
138
|
+
self.assertEqual(entries[1]["tag"], "meta/b")
|
|
139
|
+
|
|
140
|
+
def test_real_learnings_file_round_trip(self):
|
|
141
|
+
learnings_path = REPO_ROOT / ".specfuse" / "LEARNINGS.md"
|
|
142
|
+
text = learnings_path.read_text(encoding="utf-8")
|
|
143
|
+
entries = lq.parse_entries(text)
|
|
144
|
+
self.assertGreater(len(entries), 1)
|
|
145
|
+
for entry in entries:
|
|
146
|
+
self.assertTrue(entry["text"])
|
|
147
|
+
self.assertTrue(entry["tag"])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _small_fixture(n_entries: int) -> str:
|
|
151
|
+
lines = ["## Entries", "", "<!-- lessons work units append below this line -->", ""]
|
|
152
|
+
for i in range(n_entries):
|
|
153
|
+
lines.append(f"- [meta/e{i}] Entry number {i} about routers and gates.")
|
|
154
|
+
return "\n".join(lines) + "\n"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TestShouldLoadWhole(unittest.TestCase):
|
|
158
|
+
def test_below_threshold_is_true(self):
|
|
159
|
+
entries = [{"tag": "a", "text": "x"}] * 5
|
|
160
|
+
self.assertTrue(lq.should_load_whole(entries, threshold=40))
|
|
161
|
+
|
|
162
|
+
def test_at_or_above_threshold_is_false(self):
|
|
163
|
+
entries = [{"tag": "a", "text": "x"}] * 40
|
|
164
|
+
self.assertFalse(lq.should_load_whole(entries, threshold=40))
|
|
165
|
+
entries = [{"tag": "a", "text": "x"}] * 41
|
|
166
|
+
self.assertFalse(lq.should_load_whole(entries, threshold=40))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestCli(unittest.TestCase):
|
|
170
|
+
def _run_cli(self, *args: str) -> subprocess.CompletedProcess:
|
|
171
|
+
return subprocess.run(
|
|
172
|
+
[sys.executable, str(LEARNINGS_QUERY_PATH), *args],
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def test_cli_below_threshold_signals_load_whole(self):
|
|
178
|
+
with tempfile.NamedTemporaryFile(
|
|
179
|
+
mode="w", suffix=".md", delete=False
|
|
180
|
+
) as fixture:
|
|
181
|
+
fixture.write(_small_fixture(5))
|
|
182
|
+
fixture_path = fixture.name
|
|
183
|
+
try:
|
|
184
|
+
result = self._run_cli(
|
|
185
|
+
"routers", "--file", fixture_path, "--threshold", "40"
|
|
186
|
+
)
|
|
187
|
+
finally:
|
|
188
|
+
Path(fixture_path).unlink()
|
|
189
|
+
self.assertEqual(result.returncode, 0)
|
|
190
|
+
self.assertEqual(result.stdout.strip(), lq.LOAD_WHOLE_SENTINEL)
|
|
191
|
+
|
|
192
|
+
def test_cli_at_threshold_returns_ranked_slice(self):
|
|
193
|
+
with tempfile.NamedTemporaryFile(
|
|
194
|
+
mode="w", suffix=".md", delete=False
|
|
195
|
+
) as fixture:
|
|
196
|
+
fixture.write(_small_fixture(45))
|
|
197
|
+
fixture_path = fixture.name
|
|
198
|
+
try:
|
|
199
|
+
result = self._run_cli(
|
|
200
|
+
"routers", "--file", fixture_path, "--threshold", "40", "--top", "3"
|
|
201
|
+
)
|
|
202
|
+
finally:
|
|
203
|
+
Path(fixture_path).unlink()
|
|
204
|
+
self.assertEqual(result.returncode, 0)
|
|
205
|
+
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
|
206
|
+
self.assertNotIn(lq.LOAD_WHOLE_SENTINEL, lines)
|
|
207
|
+
self.assertEqual(len(lines), 3)
|
|
208
|
+
|
|
209
|
+
def test_cli_threshold_zero_forces_slice_on_tiny_file(self):
|
|
210
|
+
with tempfile.NamedTemporaryFile(
|
|
211
|
+
mode="w", suffix=".md", delete=False
|
|
212
|
+
) as fixture:
|
|
213
|
+
fixture.write(_small_fixture(2))
|
|
214
|
+
fixture_path = fixture.name
|
|
215
|
+
try:
|
|
216
|
+
result = self._run_cli(
|
|
217
|
+
"routers", "--file", fixture_path, "--threshold", "0"
|
|
218
|
+
)
|
|
219
|
+
finally:
|
|
220
|
+
Path(fixture_path).unlink()
|
|
221
|
+
self.assertEqual(result.returncode, 0)
|
|
222
|
+
self.assertNotEqual(result.stdout.strip(), lq.LOAD_WHOLE_SENTINEL)
|
|
223
|
+
self.assertEqual(len(result.stdout.strip().splitlines()), 2)
|
|
224
|
+
|
|
225
|
+
def test_cli_missing_file_exits_nonzero_with_message(self):
|
|
226
|
+
result = self._run_cli("routers", "--file", "/nonexistent/path/LEARNINGS.md")
|
|
227
|
+
self.assertNotEqual(result.returncode, 0)
|
|
228
|
+
self.assertIn("cannot read", result.stderr)
|
|
229
|
+
|
|
230
|
+
def test_cli_top_bounds_count(self):
|
|
231
|
+
with tempfile.NamedTemporaryFile(
|
|
232
|
+
mode="w", suffix=".md", delete=False
|
|
233
|
+
) as fixture:
|
|
234
|
+
fixture.write(_small_fixture(45))
|
|
235
|
+
fixture_path = fixture.name
|
|
236
|
+
try:
|
|
237
|
+
result = self._run_cli(
|
|
238
|
+
"routers", "--file", fixture_path, "--threshold", "40", "--top", "1"
|
|
239
|
+
)
|
|
240
|
+
finally:
|
|
241
|
+
Path(fixture_path).unlink()
|
|
242
|
+
self.assertEqual(len(result.stdout.strip().splitlines()), 1)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
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:
|