specfuse-loop 0.3.6__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.6/specfuse_loop.egg-info → specfuse_loop-0.3.7}/PKG-INFO +1 -1
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/pyproject.toml +1 -1
- specfuse_loop-0.3.7/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/skills.md +3 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/loop.py +25 -2
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.6 → 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.7/tests/test_upgrade_merge_gate.py +181 -0
- specfuse_loop-0.3.6/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/LICENSE +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/NOTICE +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/README.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/setup.cfg +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/lint_plan.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_orchestration.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.6 → 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
|
|
@@ -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.
|
|
@@ -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
|
# --------------------------------------------------------------------------- #
|
|
@@ -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()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse Contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Tests for .specfuse/scripts/upgrade_merge_gate.py (FEAT-2026-0029/T01).
|
|
6
|
+
|
|
7
|
+
`decide` turns (CI status, per-feature lint_plan.py results) into a merge/halt
|
|
8
|
+
verdict; `collect_reports` produces those per-feature results by shelling out
|
|
9
|
+
to lint_plan.py once per `.specfuse/features/*/` folder. Loaded by file path,
|
|
10
|
+
matching the pattern other `.specfuse/scripts` helpers use in this test suite
|
|
11
|
+
(see test_leak_scan_content.py) since this module is not part of the
|
|
12
|
+
`specfuse` package.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib.util
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import unittest
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
24
|
+
SCRIPTS_DIR = REPO_ROOT / ".specfuse" / "scripts"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load(name: str):
|
|
28
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
29
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
30
|
+
path = SCRIPTS_DIR / f"{name}.py"
|
|
31
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
32
|
+
mod = importlib.util.module_from_spec(spec)
|
|
33
|
+
sys.modules[name] = mod
|
|
34
|
+
spec.loader.exec_module(mod)
|
|
35
|
+
return mod
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
umg = _load("upgrade_merge_gate")
|
|
39
|
+
|
|
40
|
+
_VALID_FM = """\
|
|
41
|
+
---
|
|
42
|
+
feature_id: FEAT-2026-0099
|
|
43
|
+
title: Test Feature
|
|
44
|
+
branch: feat/test
|
|
45
|
+
roadmap_goal: Test goal
|
|
46
|
+
status: active
|
|
47
|
+
---
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
_CLOSING_WUS = [
|
|
51
|
+
("FEAT-2026-0099/G1-RETRO", "WU-90-retro.md", "retrospective"),
|
|
52
|
+
("FEAT-2026-0099/G1-LESSONS", "WU-91-lessons.md", "lessons"),
|
|
53
|
+
("FEAT-2026-0099/G1-DOCS", "WU-92-docs.md", "docs"),
|
|
54
|
+
("FEAT-2026-0099/G1-PLAN", "WU-93-plan-next.md", "plan-next"),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _wu_fm(wid: str, wu_type: str, status: str = "done") -> str:
|
|
59
|
+
return "\n".join(["---", f"id: {wid}", f"type: {wu_type}", f"status: {status}", "---"]) + "\n"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _make_graph(work_units: list[dict]) -> str:
|
|
63
|
+
parts = ["```yaml", "gates:", " - gate: 1", " work_units:"]
|
|
64
|
+
for wu in work_units:
|
|
65
|
+
parts.append(f" - id: {wu['id']}")
|
|
66
|
+
parts.append(f" file: {wu['file']}")
|
|
67
|
+
parts.append(" depends_on: []")
|
|
68
|
+
parts.append("```")
|
|
69
|
+
return "\n".join(parts)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _build_valid_feature(features_dir: Path, name: str) -> Path:
|
|
73
|
+
"""A feature folder that passes lint_plan.py cleanly."""
|
|
74
|
+
feat = features_dir / name
|
|
75
|
+
feat.mkdir()
|
|
76
|
+
impl_id = "FEAT-2026-0099/T01"
|
|
77
|
+
impl_file = "WU-01-impl.md"
|
|
78
|
+
all_wus = [{"id": impl_id, "file": impl_file}] + [
|
|
79
|
+
{"id": wid, "file": wfile} for wid, wfile, _ in _CLOSING_WUS
|
|
80
|
+
]
|
|
81
|
+
(feat / "PLAN.md").write_text(_VALID_FM + "\n" + _make_graph(all_wus) + "\n")
|
|
82
|
+
(feat / impl_file).write_text(_wu_fm(impl_id, "implementation"))
|
|
83
|
+
for wid, wfile, wtype in _CLOSING_WUS:
|
|
84
|
+
(feat / wfile).write_text(_wu_fm(wid, wtype))
|
|
85
|
+
return feat
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _build_invalid_feature(features_dir: Path, name: str) -> Path:
|
|
89
|
+
"""A feature folder with no PLAN.md — fails lint_plan.py."""
|
|
90
|
+
feat = features_dir / name
|
|
91
|
+
feat.mkdir()
|
|
92
|
+
return feat
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestDecide(unittest.TestCase):
|
|
96
|
+
|
|
97
|
+
def test_halts_when_a_feature_fails_conformance(self):
|
|
98
|
+
verdict, reason = umg.decide(
|
|
99
|
+
True,
|
|
100
|
+
[
|
|
101
|
+
{"feature": "FEAT-2026-0001-a", "ok": True, "detail": ""},
|
|
102
|
+
{"feature": "FEAT-2026-0002-b", "ok": False, "detail": "missing PLAN.md"},
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
self.assertEqual(verdict, "halt")
|
|
106
|
+
self.assertIn("FEAT-2026-0002-b", reason)
|
|
107
|
+
|
|
108
|
+
def test_merge_when_all_ok_and_ci_green(self):
|
|
109
|
+
verdict, reason = umg.decide(
|
|
110
|
+
True,
|
|
111
|
+
[
|
|
112
|
+
{"feature": "a", "ok": True, "detail": ""},
|
|
113
|
+
{"feature": "b", "ok": True, "detail": ""},
|
|
114
|
+
],
|
|
115
|
+
)
|
|
116
|
+
self.assertEqual(verdict, "merge")
|
|
117
|
+
self.assertEqual(reason, "")
|
|
118
|
+
|
|
119
|
+
def test_halt_when_ci_not_green_even_if_all_ok(self):
|
|
120
|
+
verdict, reason = umg.decide(
|
|
121
|
+
False,
|
|
122
|
+
[{"feature": "a", "ok": True, "detail": ""}],
|
|
123
|
+
)
|
|
124
|
+
self.assertEqual(verdict, "halt")
|
|
125
|
+
self.assertIn("CI not green", reason)
|
|
126
|
+
|
|
127
|
+
def test_halt_when_ci_green_but_a_report_not_ok(self):
|
|
128
|
+
verdict, reason = umg.decide(
|
|
129
|
+
True,
|
|
130
|
+
[{"feature": "a", "ok": True, "detail": ""}, {"feature": "b", "ok": False, "detail": "x"}],
|
|
131
|
+
)
|
|
132
|
+
self.assertEqual(verdict, "halt")
|
|
133
|
+
self.assertIn("b", reason)
|
|
134
|
+
|
|
135
|
+
def test_empty_reports_fails_safe_to_halt(self):
|
|
136
|
+
verdict, reason = umg.decide(True, [])
|
|
137
|
+
self.assertEqual(verdict, "halt")
|
|
138
|
+
self.assertIn("no feature folders", reason)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestCollectReports(unittest.TestCase):
|
|
142
|
+
|
|
143
|
+
def test_no_feature_folders_returns_empty_list(self):
|
|
144
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
145
|
+
repo_root = Path(tmp)
|
|
146
|
+
reports = umg.collect_reports(repo_root)
|
|
147
|
+
self.assertEqual(reports, [])
|
|
148
|
+
|
|
149
|
+
def test_marks_valid_and_invalid_feature_folders(self):
|
|
150
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
151
|
+
repo_root = Path(tmp)
|
|
152
|
+
features_dir = repo_root / ".specfuse" / "features"
|
|
153
|
+
features_dir.mkdir(parents=True)
|
|
154
|
+
_build_valid_feature(features_dir, "FEAT-2026-0001-good")
|
|
155
|
+
_build_invalid_feature(features_dir, "FEAT-2026-0002-bad")
|
|
156
|
+
|
|
157
|
+
scripts_dir = repo_root / ".specfuse" / "scripts"
|
|
158
|
+
scripts_dir.mkdir(parents=True)
|
|
159
|
+
# A minimal shim (not a copy of the repo's own lint_plan.py shim,
|
|
160
|
+
# whose path-insert logic assumes it lives 2 levels under the real
|
|
161
|
+
# repo root) that runs the real specfuse.loop.lint_plan CLI against
|
|
162
|
+
# this tmp repo's feature folders.
|
|
163
|
+
(scripts_dir / "lint_plan.py").write_text(
|
|
164
|
+
"import sys\n"
|
|
165
|
+
f"sys.path.insert(0, {str(REPO_ROOT)!r})\n"
|
|
166
|
+
"from specfuse.loop.lint_plan import main\n"
|
|
167
|
+
"if __name__ == '__main__':\n"
|
|
168
|
+
" raise SystemExit(main())\n"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
reports = umg.collect_reports(repo_root)
|
|
172
|
+
|
|
173
|
+
by_name = {r["feature"]: r for r in reports}
|
|
174
|
+
self.assertEqual(len(reports), 2)
|
|
175
|
+
self.assertTrue(by_name["FEAT-2026-0001-good"]["ok"])
|
|
176
|
+
self.assertFalse(by_name["FEAT-2026-0002-bad"]["ok"])
|
|
177
|
+
self.assertTrue(by_name["FEAT-2026-0002-bad"]["detail"])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.6
|
|
File without changes
|
|
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.6 → specfuse_loop-0.3.7}/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
|
|
File without changes
|
|
File without changes
|
{specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/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
|