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.
Files changed (136) hide show
  1. {specfuse_loop-0.3.6/specfuse_loop.egg-info → specfuse_loop-0.3.7}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.7/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/skills.md +3 -0
  5. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/loop.py +25 -2
  6. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7/specfuse_loop.egg-info}/PKG-INFO +1 -1
  7. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/SOURCES.txt +3 -0
  8. specfuse_loop-0.3.7/tests/test_events_redaction.py +105 -0
  9. specfuse_loop-0.3.7/tests/test_learnings_query.py +246 -0
  10. specfuse_loop-0.3.7/tests/test_upgrade_merge_gate.py +181 -0
  11. specfuse_loop-0.3.6/specfuse/loop/data/VERSION +0 -1
  12. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/LICENSE +0 -0
  13. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/NOTICE +0 -0
  14. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/README.md +0 -0
  15. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/setup.cfg +0 -0
  16. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/__init__.py +0 -0
  17. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/_miniyaml.py +0 -0
  18. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/adopt_feature.py +0 -0
  19. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  20. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  21. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  22. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/getting-started.md +0 -0
  23. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/methodology.md +0 -0
  24. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/gitignore.snippet +0 -0
  25. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/roadmap.template.md +0 -0
  26. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  27. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/never-touch.md +0 -0
  28. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/result-contract.md +0 -0
  29. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  30. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/GATE.template.md +0 -0
  31. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  32. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/WU.template.md +0 -0
  33. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/data/verification.yml.example +0 -0
  34. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/gate_eval.py +0 -0
  35. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/gh_backend.py +0 -0
  36. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/gh_features.py +0 -0
  37. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/lint_plan.py +0 -0
  38. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/scaffold.py +0 -0
  39. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse/loop/validate_event.py +0 -0
  40. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  41. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/entry_points.txt +0 -0
  42. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/requires.txt +0 -0
  43. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/top_level.txt +0 -0
  44. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_adopt_feature.py +0 -0
  45. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  46. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_attempt_outcome_emission.py +0 -0
  47. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_autosync.py +0 -0
  48. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_autosync_consent.py +0 -0
  49. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_autosync_firstrun.py +0 -0
  50. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_autosync_plugin.py +0 -0
  51. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_backend.py +0 -0
  52. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  53. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  54. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_closing_deliverable_guard.py +0 -0
  55. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_cost_tracking.py +0 -0
  56. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_deliverable_presence_gate.py +0 -0
  57. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_doctor.py +0 -0
  58. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_driver_integration.py +0 -0
  59. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_driver_lock.py +0 -0
  60. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_duration_tracking.py +0 -0
  61. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_empty_files_escalation.py +0 -0
  62. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_ensure_feature_branch.py +0 -0
  63. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_extra_gates.py +0 -0
  64. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_force_full_close.py +0 -0
  65. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gate_eval.py +0 -0
  66. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gate_eval_calibration.py +0 -0
  67. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  68. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gate_eval_terminal_wiring.py +0 -0
  69. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gh_backend.py +0 -0
  70. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_gh_features.py +0 -0
  71. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_git_env_isolation.py +0 -0
  72. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_hashed_denylist.py +0 -0
  73. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_hashed_denylist_ci.py +0 -0
  74. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_init_integration.py +0 -0
  75. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_leak_findings_redaction.py +0 -0
  76. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_leak_scan.py +0 -0
  77. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_leak_scan_content.py +0 -0
  78. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  79. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lifecycle_integration.py +0 -0
  80. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_bare_produces_path.py +0 -0
  81. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_close_intermediate.py +0 -0
  82. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_close_wu.py +0 -0
  83. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_correlation_id.py +0 -0
  84. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  85. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_oracle_env.py +0 -0
  86. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_plan_errors.py +0 -0
  87. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_plan_next_draft.py +0 -0
  88. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_plan_relative_import.py +0 -0
  89. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_produces_driver_helper.py +0 -0
  90. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_sections.py +0 -0
  91. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  92. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_auto_archive.py +0 -0
  93. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_caveman_preamble.py +0 -0
  94. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_close_intermediate.py +0 -0
  95. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_defaults_by_type.py +0 -0
  96. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_effort.py +0 -0
  97. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_failure_note_cap.py +0 -0
  98. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_files_changed_guard.py +0 -0
  99. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_gate_budget.py +0 -0
  100. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_model_alias.py +0 -0
  101. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_orchestration.py +0 -0
  102. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_post_pass_invariant.py +0 -0
  103. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_reset_preserving_events.py +0 -0
  104. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_smoke_runner.py +0 -0
  105. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_unsandboxed.py +0 -0
  106. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_loop_zero_token_guard.py +0 -0
  107. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_migrate_legacy.py +0 -0
  108. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_miniyaml_equivalence.py +0 -0
  109. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_miniyaml_negative.py +0 -0
  110. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_planned_cost_lint.py +0 -0
  111. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_prepare_feature.py +0 -0
  112. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_produces_field.py +0 -0
  113. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_result_block.py +0 -0
  114. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_roadmap_add_skill.py +0 -0
  115. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_roadmap_archive_skill.py +0 -0
  116. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_roadmap_row_parser.py +0 -0
  117. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_data_in_sync.py +0 -0
  118. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_docs.py +0 -0
  119. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_init.py +0 -0
  120. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_manifest.py +0 -0
  121. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_resources.py +0 -0
  122. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_seed_sanity.py +0 -0
  123. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_upgrade.py +0 -0
  124. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_scaffold_wiring.py +0 -0
  125. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_squash_commit_hook_crash.py +0 -0
  126. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_template_closing_shapes.py +0 -0
  127. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_terminal_flip_ownership.py +0 -0
  128. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_terminal_flips.py +0 -0
  129. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_untracked_feature_folder.py +0 -0
  130. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_upgrade_integration.py +0 -0
  131. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_validate_event.py +0 -0
  132. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_verdict_coupling.py +0 -0
  133. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_verify_empty_gate_set.py +0 -0
  134. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_version_consistency.py +0 -0
  135. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_version_skew.py +0 -0
  136. {specfuse_loop-0.3.6 → specfuse_loop-0.3.7}/tests/test_wu_execution_metadata.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology.
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specfuse-loop"
7
- version = "0.3.6"
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.6"
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
  # --------------------------------------------------------------------------- #
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology.
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -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