@hunter-harness/workflow-harness 0.2.27 → 0.2.28
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.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/claude-code/scripts/harness_events.py +43 -9
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/claude-code/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/claude-code/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +43 -9
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/codebuddy/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/codebuddy/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/codex/scripts/harness_events.py +43 -9
- package/harness/bundles/general/codex/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/codex/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/codex/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/codex/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/cursor/scripts/harness_events.py +43 -9
- package/harness/bundles/general/cursor/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/cursor/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/cursor/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/cursor/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/claude-code/scripts/harness_events.py +43 -9
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/claude-code/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/claude-code/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +43 -9
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/codebuddy/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/codebuddy/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/codex/scripts/harness_events.py +43 -9
- package/harness/bundles/java/codex/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/codex/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/codex/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/codex/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/cursor/scripts/harness_events.py +43 -9
- package/harness/bundles/java/cursor/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/cursor/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/cursor/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/cursor/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +702 -22
- package/harness/manifests/general/claude-code.json +26 -22
- package/harness/manifests/general/codebuddy.json +26 -22
- package/harness/manifests/general/codex.json +26 -22
- package/harness/manifests/general/cursor.json +26 -22
- package/harness/manifests/java/claude-code.json +28 -24
- package/harness/manifests/java/codebuddy.json +28 -24
- package/harness/manifests/java/codex.json +28 -24
- package/harness/manifests/java/cursor.json +28 -24
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -56,6 +56,11 @@ REASONS = ("tdd-created", "stale-test-repair", "test-updated")
|
|
|
56
56
|
class LockUnavailable(RuntimeError):
|
|
57
57
|
"""A compatible lock file is already held by another process."""
|
|
58
58
|
|
|
59
|
+
|
|
60
|
+
class ProfileConfigInvalid(ValueError):
|
|
61
|
+
"""The selected project-owned test-tracking profile is malformed."""
|
|
62
|
+
|
|
63
|
+
|
|
59
64
|
if hasattr(sys.stdout, "reconfigure"):
|
|
60
65
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
61
66
|
if hasattr(sys.stderr, "reconfigure"):
|
|
@@ -218,30 +223,69 @@ def _manifest_target_inside(change_root: Path, manifest: Path) -> bool:
|
|
|
218
223
|
)
|
|
219
224
|
|
|
220
225
|
|
|
221
|
-
def _profile_config(
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
226
|
+
def _profile_config(
|
|
227
|
+
project: Path,
|
|
228
|
+
*,
|
|
229
|
+
strict: bool = False,
|
|
230
|
+
) -> tuple[list[str], list[str]] | None:
|
|
231
|
+
def invalid(message: str) -> tuple[list[str], list[str]]:
|
|
232
|
+
if strict:
|
|
233
|
+
raise ProfileConfigInvalid(message)
|
|
234
|
+
return [], []
|
|
235
|
+
|
|
236
|
+
local_profile = project / PROFILE_REL
|
|
237
|
+
if os.path.lexists(local_profile):
|
|
238
|
+
profile_path = local_profile
|
|
239
|
+
if not profile_path.is_file():
|
|
240
|
+
return invalid(f"{PROFILE_REL.as_posix()} is not a regular file")
|
|
241
|
+
else:
|
|
242
|
+
state_project = _state_project_root(project)
|
|
243
|
+
common_root = harness_paths.common_root(project)
|
|
244
|
+
common_profile = state_project / PROFILE_REL
|
|
245
|
+
if (
|
|
246
|
+
state_project == project
|
|
247
|
+
or state_project != common_root
|
|
248
|
+
or not os.path.lexists(common_profile)
|
|
249
|
+
):
|
|
250
|
+
return None
|
|
251
|
+
if not common_profile.is_file():
|
|
252
|
+
return invalid(
|
|
253
|
+
f"common {PROFILE_REL.as_posix()} is not a regular file"
|
|
254
|
+
)
|
|
255
|
+
profile_path = common_profile
|
|
225
256
|
try:
|
|
226
257
|
profile = _read_json(profile_path)
|
|
227
|
-
except (OSError, json.JSONDecodeError):
|
|
228
|
-
return
|
|
229
|
-
|
|
258
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
259
|
+
return invalid(f"cannot read {profile_path}: {exc}")
|
|
260
|
+
if not isinstance(profile, dict):
|
|
261
|
+
return invalid(f"{profile_path} must contain a JSON object")
|
|
262
|
+
tracking = profile.get("testTracking")
|
|
263
|
+
if not isinstance(tracking, dict):
|
|
264
|
+
return invalid(f"{profile_path} testTracking must be an object")
|
|
230
265
|
paths = tracking.get("paths") if isinstance(tracking, dict) else None
|
|
231
|
-
if
|
|
232
|
-
|
|
233
|
-
|
|
266
|
+
if (
|
|
267
|
+
not isinstance(paths, list)
|
|
268
|
+
or not paths
|
|
269
|
+
or any(not isinstance(item, str) or not item.strip() for item in paths)
|
|
270
|
+
):
|
|
271
|
+
return invalid(
|
|
272
|
+
f"{profile_path} testTracking.paths must be a non-empty string list"
|
|
273
|
+
)
|
|
274
|
+
excluded = profile.get("excludedRoots")
|
|
275
|
+
if excluded is not None and (
|
|
276
|
+
not isinstance(excluded, list)
|
|
277
|
+
or any(not isinstance(item, str) or not item.strip() for item in excluded)
|
|
278
|
+
):
|
|
279
|
+
return invalid(f"{profile_path} excludedRoots must be a string list")
|
|
234
280
|
configured_roots = (
|
|
235
|
-
[item.replace("\\", "/").strip("/") for item in excluded
|
|
281
|
+
[item.replace("\\", "/").strip("/") for item in excluded]
|
|
236
282
|
if isinstance(excluded, list)
|
|
237
283
|
else []
|
|
238
284
|
)
|
|
239
285
|
excluded_roots = list(
|
|
240
286
|
dict.fromkeys([*configured_roots, *MANDATORY_EXCLUDED_ROOTS])
|
|
241
287
|
)
|
|
242
|
-
patterns = [
|
|
243
|
-
item.replace("\\", "/") for item in paths if isinstance(item, str) and item
|
|
244
|
-
]
|
|
288
|
+
patterns = [item.replace("\\", "/") for item in paths]
|
|
245
289
|
return patterns, excluded_roots
|
|
246
290
|
|
|
247
291
|
|
|
@@ -297,6 +341,29 @@ def _validate_file(project: Path, raw: str) -> tuple[Path | None, str | None, st
|
|
|
297
341
|
return resolved, rel, None
|
|
298
342
|
|
|
299
343
|
|
|
344
|
+
def _validate_snapshot_file(
|
|
345
|
+
project: Path,
|
|
346
|
+
raw: str,
|
|
347
|
+
) -> tuple[Path | None, str | None, str | None]:
|
|
348
|
+
"""Validate a path captured under the begin-time profile.
|
|
349
|
+
|
|
350
|
+
The active profile may legitimately change before close. Snapshot paths
|
|
351
|
+
remain constrained to the repository but are not reclassified against the
|
|
352
|
+
newer allowlist.
|
|
353
|
+
"""
|
|
354
|
+
candidate = Path(raw)
|
|
355
|
+
if not candidate.is_absolute():
|
|
356
|
+
candidate = project / candidate
|
|
357
|
+
resolved = candidate.resolve()
|
|
358
|
+
if not _inside(resolved, project):
|
|
359
|
+
return None, None, "PATH_OUTSIDE_PROJECT"
|
|
360
|
+
if not resolved.exists():
|
|
361
|
+
return None, None, "FILE_NOT_FOUND"
|
|
362
|
+
if not resolved.is_file():
|
|
363
|
+
return None, None, "NOT_REGULAR_FILE"
|
|
364
|
+
return resolved, resolved.relative_to(project).as_posix(), None
|
|
365
|
+
|
|
366
|
+
|
|
300
367
|
def _contract_is_v2(change_root: Path) -> bool:
|
|
301
368
|
try:
|
|
302
369
|
contract = harness_paths.load_change_contract(change_root)
|
|
@@ -744,6 +811,217 @@ def rehome(
|
|
|
744
811
|
)
|
|
745
812
|
|
|
746
813
|
|
|
814
|
+
def rebind_snapshot(
|
|
815
|
+
from_project: Path | str,
|
|
816
|
+
to_project: Path | str,
|
|
817
|
+
change_dir: Path | str,
|
|
818
|
+
expected_base: str,
|
|
819
|
+
) -> dict[str, Any]:
|
|
820
|
+
"""Rebind an unchanged pre-change snapshot to a descendant worktree.
|
|
821
|
+
|
|
822
|
+
This is the pre-merge counterpart to ``rehome``. It is intentionally
|
|
823
|
+
narrow: the source root must still be exactly at ``expected_base``, every
|
|
824
|
+
snapshotted file must still match its captured byte hash there, and the
|
|
825
|
+
target HEAD must descend from that base. A target-owned manifest, when
|
|
826
|
+
present, is validated but never rewritten.
|
|
827
|
+
"""
|
|
828
|
+
|
|
829
|
+
action = "rebind-snapshot"
|
|
830
|
+
from_root = Path(from_project).resolve()
|
|
831
|
+
to_root = Path(to_project).resolve()
|
|
832
|
+
if not from_root.is_dir() or not to_root.is_dir():
|
|
833
|
+
return _result(False, action, "PROJECT_ROOT_MISSING", [])
|
|
834
|
+
if harness_paths.repository_identity(from_root) != harness_paths.repository_identity(to_root):
|
|
835
|
+
return _result(False, action, "REPOSITORY_MISMATCH", [])
|
|
836
|
+
|
|
837
|
+
from_head_result = _git(from_root, "rev-parse", "--verify", "HEAD")
|
|
838
|
+
from_head = from_head_result.stdout.strip() if from_head_result.returncode == 0 else ""
|
|
839
|
+
if not expected_base or from_head != expected_base:
|
|
840
|
+
return _result(
|
|
841
|
+
False,
|
|
842
|
+
action,
|
|
843
|
+
"EXPECTED_BASE_MISMATCH",
|
|
844
|
+
[],
|
|
845
|
+
expectedBase=expected_base,
|
|
846
|
+
actualBase=from_head,
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
to_head_result = _git(to_root, "rev-parse", "--verify", "HEAD")
|
|
850
|
+
to_head = to_head_result.stdout.strip() if to_head_result.returncode == 0 else ""
|
|
851
|
+
descendant = _git(
|
|
852
|
+
to_root,
|
|
853
|
+
"merge-base",
|
|
854
|
+
"--is-ancestor",
|
|
855
|
+
expected_base,
|
|
856
|
+
to_head,
|
|
857
|
+
)
|
|
858
|
+
if not to_head or descendant.returncode != 0:
|
|
859
|
+
return _result(
|
|
860
|
+
False,
|
|
861
|
+
action,
|
|
862
|
+
"TARGET_NOT_DESCENDANT",
|
|
863
|
+
[],
|
|
864
|
+
expectedBase=expected_base,
|
|
865
|
+
targetHead=to_head,
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
change_root = _change_dir(from_root, change_dir)
|
|
869
|
+
if change_root is None:
|
|
870
|
+
return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
|
|
871
|
+
snapshot_path = _state_root(change_root) / SNAPSHOT_REL
|
|
872
|
+
if not _manifest_target_inside(change_root, snapshot_path):
|
|
873
|
+
return _result(False, action, "SNAPSHOT_PATH_OUTSIDE_PROJECT", [])
|
|
874
|
+
manifest_path = _manifest_path(change_root)
|
|
875
|
+
if manifest_path is None:
|
|
876
|
+
return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
|
|
877
|
+
|
|
878
|
+
lock_path = snapshot_path.with_name(snapshot_path.name + ".lock")
|
|
879
|
+
try:
|
|
880
|
+
with _exclusive_lock(lock_path, wait_seconds=5.0):
|
|
881
|
+
try:
|
|
882
|
+
snapshot = _read_json(snapshot_path)
|
|
883
|
+
except FileNotFoundError:
|
|
884
|
+
return _result(False, action, "SNAPSHOT_MISSING", [])
|
|
885
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
886
|
+
return _result(False, action, "SNAPSHOT_INVALID", [], error=str(exc))
|
|
887
|
+
|
|
888
|
+
entries = snapshot.get("files") if isinstance(snapshot, dict) else None
|
|
889
|
+
snapshot_root = snapshot.get("projectRoot") if isinstance(snapshot, dict) else None
|
|
890
|
+
if (
|
|
891
|
+
not isinstance(snapshot, dict)
|
|
892
|
+
or snapshot.get("schemaVersion") != SCHEMA_VERSION
|
|
893
|
+
or snapshot.get("mode") != MODE
|
|
894
|
+
or snapshot_root not in {str(from_root), str(to_root)}
|
|
895
|
+
or not isinstance(entries, list)
|
|
896
|
+
):
|
|
897
|
+
return _result(False, action, "SNAPSHOT_INVALID", [])
|
|
898
|
+
|
|
899
|
+
validated_paths: list[str] = []
|
|
900
|
+
for item in entries:
|
|
901
|
+
if (
|
|
902
|
+
not isinstance(item, dict)
|
|
903
|
+
or not isinstance(item.get("path"), str)
|
|
904
|
+
or not isinstance(item.get("sha256"), str)
|
|
905
|
+
):
|
|
906
|
+
return _result(False, action, "SNAPSHOT_INVALID", [])
|
|
907
|
+
rel = item["path"]
|
|
908
|
+
source, normalized, error = _validate_file(from_root, rel)
|
|
909
|
+
if error or normalized != rel or source is None:
|
|
910
|
+
return _result(False, action, error or "SNAPSHOT_INVALID", [rel])
|
|
911
|
+
if item["sha256"] != _sha256(source):
|
|
912
|
+
return _result(False, action, "SOURCE_SNAPSHOT_DRIFT", [rel])
|
|
913
|
+
item["logicalHash"] = logical_file_hash(from_root, rel)
|
|
914
|
+
validated_paths.append(rel)
|
|
915
|
+
|
|
916
|
+
manifest = None
|
|
917
|
+
if manifest_path.is_file():
|
|
918
|
+
try:
|
|
919
|
+
manifest = _read_json(manifest_path)
|
|
920
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
921
|
+
return _result(False, action, "MANIFEST_INVALID", [], error=str(exc))
|
|
922
|
+
validator = (
|
|
923
|
+
_validate_existing_manifest_v2
|
|
924
|
+
if isinstance(manifest, dict) and manifest.get("schemaVersion") == 2
|
|
925
|
+
else _validate_existing_manifest
|
|
926
|
+
)
|
|
927
|
+
error, _ = validator(to_root, manifest, require_files=False)
|
|
928
|
+
if error:
|
|
929
|
+
return _result(False, action, error, [])
|
|
930
|
+
|
|
931
|
+
before_hash = _sha256(snapshot_path)
|
|
932
|
+
rebound_at = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
933
|
+
rebind_id = "rebind-" + hashlib.sha256(
|
|
934
|
+
f"{from_root}\0{to_root}\0{expected_base}\0{to_head}".encode("utf-8")
|
|
935
|
+
).hexdigest()[:20]
|
|
936
|
+
updated = json.loads(json.dumps(snapshot))
|
|
937
|
+
updated["files"] = entries
|
|
938
|
+
rebinds = updated.get("rebinds")
|
|
939
|
+
if rebinds is None:
|
|
940
|
+
rebinds = []
|
|
941
|
+
if not isinstance(rebinds, list):
|
|
942
|
+
return _result(False, action, "SNAPSHOT_INVALID", [])
|
|
943
|
+
record = {
|
|
944
|
+
"id": rebind_id,
|
|
945
|
+
"fromRoot": str(from_root),
|
|
946
|
+
"toRoot": str(to_root),
|
|
947
|
+
"expectedBase": expected_base,
|
|
948
|
+
"targetHead": to_head,
|
|
949
|
+
"at": rebound_at,
|
|
950
|
+
"snapshotHashBefore": before_hash,
|
|
951
|
+
}
|
|
952
|
+
if not any(
|
|
953
|
+
isinstance(item, dict) and item.get("id") == rebind_id
|
|
954
|
+
for item in rebinds
|
|
955
|
+
):
|
|
956
|
+
rebinds.append(record)
|
|
957
|
+
updated["projectRoot"] = str(to_root)
|
|
958
|
+
updated["rebinds"] = rebinds
|
|
959
|
+
_write_json(snapshot_path, updated)
|
|
960
|
+
after_hash = _sha256(snapshot_path)
|
|
961
|
+
|
|
962
|
+
removed_manifest_paths: list[str] = []
|
|
963
|
+
if isinstance(manifest, dict):
|
|
964
|
+
manifest_entries = manifest.get("files")
|
|
965
|
+
if not isinstance(manifest_entries, list):
|
|
966
|
+
return _result(False, action, "MANIFEST_INVALID", [])
|
|
967
|
+
baseline = {
|
|
968
|
+
item["path"]: item["logicalHash"]
|
|
969
|
+
for item in entries
|
|
970
|
+
if isinstance(item, dict)
|
|
971
|
+
and isinstance(item.get("path"), str)
|
|
972
|
+
and isinstance(item.get("logicalHash"), str)
|
|
973
|
+
}
|
|
974
|
+
kept_entries: list[dict[str, Any]] = []
|
|
975
|
+
for item in manifest_entries:
|
|
976
|
+
rel = item.get("path") if isinstance(item, dict) else None
|
|
977
|
+
if (
|
|
978
|
+
isinstance(rel, str)
|
|
979
|
+
and rel in baseline
|
|
980
|
+
and item.get("reason") in REASONS
|
|
981
|
+
and logical_file_hash(to_root, rel) == baseline[rel]
|
|
982
|
+
):
|
|
983
|
+
removed_manifest_paths.append(rel)
|
|
984
|
+
continue
|
|
985
|
+
kept_entries.append(item)
|
|
986
|
+
if removed_manifest_paths:
|
|
987
|
+
cleaned = json.loads(json.dumps(manifest))
|
|
988
|
+
cleaned["files"] = kept_entries
|
|
989
|
+
cleanups = cleaned.get("reconciliations")
|
|
990
|
+
if cleanups is None:
|
|
991
|
+
cleanups = []
|
|
992
|
+
if not isinstance(cleanups, list):
|
|
993
|
+
return _result(False, action, "MANIFEST_INVALID", [])
|
|
994
|
+
cleanups.append(
|
|
995
|
+
{
|
|
996
|
+
"rebindId": rebind_id,
|
|
997
|
+
"at": rebound_at,
|
|
998
|
+
"reason": "logical-baseline-match",
|
|
999
|
+
"removedPaths": sorted(removed_manifest_paths),
|
|
1000
|
+
}
|
|
1001
|
+
)
|
|
1002
|
+
cleaned["reconciliations"] = cleanups
|
|
1003
|
+
_write_json(manifest_path, cleaned)
|
|
1004
|
+
except LockUnavailable:
|
|
1005
|
+
return _result(False, action, "SNAPSHOT_LOCKED", [])
|
|
1006
|
+
|
|
1007
|
+
return _result(
|
|
1008
|
+
True,
|
|
1009
|
+
action,
|
|
1010
|
+
"SNAPSHOT_REBOUND",
|
|
1011
|
+
sorted(validated_paths),
|
|
1012
|
+
fromRoot=str(from_root),
|
|
1013
|
+
toRoot=str(to_root),
|
|
1014
|
+
expectedBase=expected_base,
|
|
1015
|
+
targetHead=to_head,
|
|
1016
|
+
rebindId=rebind_id,
|
|
1017
|
+
snapshotHashBefore=before_hash,
|
|
1018
|
+
snapshotHashAfter=after_hash,
|
|
1019
|
+
manifestEntriesRemoved=len(removed_manifest_paths),
|
|
1020
|
+
removedManifestPaths=sorted(removed_manifest_paths),
|
|
1021
|
+
snapshotPath=str(snapshot_path),
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
|
|
747
1025
|
def _stage_locked(
|
|
748
1026
|
project_root: Path, change_root: Path, manifest_path: Path, index_path: Path
|
|
749
1027
|
) -> dict[str, Any]:
|
|
@@ -870,10 +1148,14 @@ def _walk_matching_files(
|
|
|
870
1148
|
return matches
|
|
871
1149
|
|
|
872
1150
|
|
|
873
|
-
def _enumerate_allowed_test_files(
|
|
1151
|
+
def _enumerate_allowed_test_files(
|
|
1152
|
+
project_root: Path,
|
|
1153
|
+
*,
|
|
1154
|
+
strict_profile: bool = False,
|
|
1155
|
+
) -> dict[str, str]:
|
|
874
1156
|
"""Map repo-relative test path -> sha256 for all allowed existing files."""
|
|
875
1157
|
found: dict[str, str] = {}
|
|
876
|
-
config = _profile_config(project_root)
|
|
1158
|
+
config = _profile_config(project_root, strict=strict_profile)
|
|
877
1159
|
if config is None:
|
|
878
1160
|
patterns = ["test/**", "tests/**", "src/test/**"]
|
|
879
1161
|
excluded_roots = [
|
|
@@ -888,13 +1170,285 @@ def _enumerate_allowed_test_files(project_root: Path) -> dict[str, str]:
|
|
|
888
1170
|
if not _inside(resolved, base):
|
|
889
1171
|
continue
|
|
890
1172
|
rel = resolved.relative_to(base).as_posix()
|
|
891
|
-
if
|
|
1173
|
+
if (
|
|
1174
|
+
rel in seen
|
|
1175
|
+
or _path_is_excluded(rel, excluded_roots)
|
|
1176
|
+
or not any(_matches_pattern(rel, pattern) for pattern in patterns)
|
|
1177
|
+
):
|
|
892
1178
|
continue
|
|
893
1179
|
seen.add(rel)
|
|
894
1180
|
found[rel] = _sha256(resolved)
|
|
895
1181
|
return found
|
|
896
1182
|
|
|
897
1183
|
|
|
1184
|
+
def _current_head(project_root: Path) -> str | None:
|
|
1185
|
+
result = _git(project_root, "rev-parse", "--verify", "HEAD")
|
|
1186
|
+
value = result.stdout.strip() if result.returncode == 0 else ""
|
|
1187
|
+
return value if re.fullmatch(r"[0-9a-fA-F]{40,64}", value) else None
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _snapshot_baseline_commit(
|
|
1191
|
+
project_root: Path,
|
|
1192
|
+
change_root: Path,
|
|
1193
|
+
snapshot: dict[str, Any],
|
|
1194
|
+
) -> str | None:
|
|
1195
|
+
candidates: list[Any] = [snapshot.get("headCommit")]
|
|
1196
|
+
for state_snapshot_path in dict.fromkeys(
|
|
1197
|
+
[
|
|
1198
|
+
change_root / "meta" / "state-snapshot.json",
|
|
1199
|
+
_state_root(change_root) / "meta" / "state-snapshot.json",
|
|
1200
|
+
]
|
|
1201
|
+
):
|
|
1202
|
+
if not state_snapshot_path.is_file():
|
|
1203
|
+
continue
|
|
1204
|
+
try:
|
|
1205
|
+
state_snapshot = _read_json(state_snapshot_path)
|
|
1206
|
+
except (OSError, json.JSONDecodeError):
|
|
1207
|
+
continue
|
|
1208
|
+
if isinstance(state_snapshot, dict):
|
|
1209
|
+
git_state = state_snapshot.get("git")
|
|
1210
|
+
if isinstance(git_state, dict):
|
|
1211
|
+
candidates.extend([git_state.get("base"), git_state.get("head")])
|
|
1212
|
+
for candidate in candidates:
|
|
1213
|
+
if not isinstance(candidate, str) or re.fullmatch(
|
|
1214
|
+
r"[0-9a-fA-F]{40,64}", candidate
|
|
1215
|
+
) is None:
|
|
1216
|
+
continue
|
|
1217
|
+
exists = _git(project_root, "cat-file", "-e", f"{candidate}^{{commit}}")
|
|
1218
|
+
if exists.returncode == 0:
|
|
1219
|
+
return candidate
|
|
1220
|
+
return None
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def _classify_against_baseline(
|
|
1224
|
+
project_root: Path,
|
|
1225
|
+
rel: str,
|
|
1226
|
+
baseline_commit: str | None,
|
|
1227
|
+
) -> tuple[str | None, str | None]:
|
|
1228
|
+
"""Return touched reason (or None when unchanged) and an optional error."""
|
|
1229
|
+
if baseline_commit is None:
|
|
1230
|
+
return None, "BASELINE_COMMIT_MISSING"
|
|
1231
|
+
listed = _git(
|
|
1232
|
+
project_root,
|
|
1233
|
+
"ls-tree",
|
|
1234
|
+
"-r",
|
|
1235
|
+
"--name-only",
|
|
1236
|
+
"-z",
|
|
1237
|
+
baseline_commit,
|
|
1238
|
+
"--",
|
|
1239
|
+
rel,
|
|
1240
|
+
)
|
|
1241
|
+
if listed.returncode != 0:
|
|
1242
|
+
return None, "BASELINE_LOOKUP_FAILED"
|
|
1243
|
+
tracked_at_baseline = rel in {
|
|
1244
|
+
item for item in listed.stdout.split("\0") if item
|
|
1245
|
+
}
|
|
1246
|
+
if not tracked_at_baseline:
|
|
1247
|
+
return "tdd-created", None
|
|
1248
|
+
changed = _git(
|
|
1249
|
+
project_root,
|
|
1250
|
+
"diff",
|
|
1251
|
+
"--quiet",
|
|
1252
|
+
baseline_commit,
|
|
1253
|
+
"--",
|
|
1254
|
+
rel,
|
|
1255
|
+
)
|
|
1256
|
+
if changed.returncode == 0:
|
|
1257
|
+
return None, None
|
|
1258
|
+
if changed.returncode == 1:
|
|
1259
|
+
return "test-updated", None
|
|
1260
|
+
return None, "BASELINE_DIFF_FAILED"
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _profile_fingerprint(
|
|
1264
|
+
project_root: Path,
|
|
1265
|
+
*,
|
|
1266
|
+
strict_profile: bool = False,
|
|
1267
|
+
) -> str:
|
|
1268
|
+
config = _profile_config(project_root, strict=strict_profile)
|
|
1269
|
+
payload = json.dumps(
|
|
1270
|
+
{"mode": "fallback"} if config is None else {"mode": "profile", "value": config},
|
|
1271
|
+
ensure_ascii=False,
|
|
1272
|
+
sort_keys=True,
|
|
1273
|
+
separators=(",", ":"),
|
|
1274
|
+
).encode("utf-8")
|
|
1275
|
+
return "sha256:" + hashlib.sha256(payload).hexdigest()
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _reconcile_close_manifest(
|
|
1279
|
+
project_root: Path,
|
|
1280
|
+
change_root: Path,
|
|
1281
|
+
recorded: set[str],
|
|
1282
|
+
baseline_commit: str | None,
|
|
1283
|
+
) -> dict[str, Any]:
|
|
1284
|
+
manifest_path = _manifest_path(change_root)
|
|
1285
|
+
if manifest_path is None:
|
|
1286
|
+
return {"ok": False, "code": "MANIFEST_PATH_OUTSIDE_PROJECT"}
|
|
1287
|
+
if not manifest_path.is_file():
|
|
1288
|
+
return {
|
|
1289
|
+
"ok": True,
|
|
1290
|
+
"code": "NO_MANIFEST",
|
|
1291
|
+
"removedCurrentTouches": 0,
|
|
1292
|
+
"deletedEntries": 0,
|
|
1293
|
+
"preservedForeignEntries": 0,
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
lock_path = manifest_path.with_name(manifest_path.name + ".lock")
|
|
1297
|
+
try:
|
|
1298
|
+
with _exclusive_lock(lock_path, wait_seconds=5.0):
|
|
1299
|
+
try:
|
|
1300
|
+
manifest = _read_json(manifest_path)
|
|
1301
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1302
|
+
return {"ok": False, "code": "MANIFEST_INVALID", "error": str(exc)}
|
|
1303
|
+
is_v2 = isinstance(manifest, dict) and manifest.get("schemaVersion") == 2
|
|
1304
|
+
validator = (
|
|
1305
|
+
_validate_existing_manifest_v2
|
|
1306
|
+
if is_v2
|
|
1307
|
+
else _validate_existing_manifest
|
|
1308
|
+
)
|
|
1309
|
+
head_before = _current_head(project_root)
|
|
1310
|
+
if head_before is None:
|
|
1311
|
+
return {"ok": False, "code": "HEAD_MISSING"}
|
|
1312
|
+
allow_hash_drift: set[str] = set()
|
|
1313
|
+
raw_entries = (
|
|
1314
|
+
manifest.get("files")
|
|
1315
|
+
if isinstance(manifest, dict)
|
|
1316
|
+
else None
|
|
1317
|
+
)
|
|
1318
|
+
if isinstance(raw_entries, list):
|
|
1319
|
+
for raw_item in raw_entries:
|
|
1320
|
+
if not isinstance(raw_item, dict):
|
|
1321
|
+
continue
|
|
1322
|
+
rel = raw_item.get("path")
|
|
1323
|
+
if (
|
|
1324
|
+
not isinstance(rel, str)
|
|
1325
|
+
or not rel
|
|
1326
|
+
or rel in recorded
|
|
1327
|
+
or raw_item.get("reason") == "stale-test-repair"
|
|
1328
|
+
):
|
|
1329
|
+
continue
|
|
1330
|
+
touched_reason, classify_error = _classify_against_baseline(
|
|
1331
|
+
project_root,
|
|
1332
|
+
rel,
|
|
1333
|
+
baseline_commit,
|
|
1334
|
+
)
|
|
1335
|
+
if classify_error:
|
|
1336
|
+
return {
|
|
1337
|
+
"ok": False,
|
|
1338
|
+
"code": classify_error,
|
|
1339
|
+
"files": [rel],
|
|
1340
|
+
}
|
|
1341
|
+
if touched_reason is None:
|
|
1342
|
+
allow_hash_drift.add(rel)
|
|
1343
|
+
error, validated = validator(
|
|
1344
|
+
project_root,
|
|
1345
|
+
manifest,
|
|
1346
|
+
allow_hash_drift=allow_hash_drift,
|
|
1347
|
+
require_files=False,
|
|
1348
|
+
)
|
|
1349
|
+
if error:
|
|
1350
|
+
return {"ok": False, "code": error}
|
|
1351
|
+
|
|
1352
|
+
before_hash = _sha256(manifest_path)
|
|
1353
|
+
change_id = change_root.name
|
|
1354
|
+
reconciled: list[dict[str, Any]] = []
|
|
1355
|
+
removed_paths: list[str] = []
|
|
1356
|
+
removed_current_touches = 0
|
|
1357
|
+
deleted_entries = 0
|
|
1358
|
+
preserved_foreign_entries = 0
|
|
1359
|
+
|
|
1360
|
+
for rel in sorted(validated):
|
|
1361
|
+
item = dict(validated[rel])
|
|
1362
|
+
if rel in recorded or item.get("reason") == "stale-test-repair":
|
|
1363
|
+
reconciled.append(item)
|
|
1364
|
+
continue
|
|
1365
|
+
touched_reason, classify_error = _classify_against_baseline(
|
|
1366
|
+
project_root,
|
|
1367
|
+
rel,
|
|
1368
|
+
baseline_commit,
|
|
1369
|
+
)
|
|
1370
|
+
if classify_error:
|
|
1371
|
+
return {"ok": False, "code": classify_error, "files": [rel]}
|
|
1372
|
+
if touched_reason is not None:
|
|
1373
|
+
reconciled.append(item)
|
|
1374
|
+
continue
|
|
1375
|
+
|
|
1376
|
+
if not is_v2:
|
|
1377
|
+
removed_paths.append(rel)
|
|
1378
|
+
deleted_entries += 1
|
|
1379
|
+
continue
|
|
1380
|
+
|
|
1381
|
+
touched_by = list(item.get("touchedBy") or [])
|
|
1382
|
+
if change_id not in touched_by:
|
|
1383
|
+
reconciled.append(item)
|
|
1384
|
+
preserved_foreign_entries += 1
|
|
1385
|
+
continue
|
|
1386
|
+
remaining = [owner for owner in touched_by if owner != change_id]
|
|
1387
|
+
removed_current_touches += 1
|
|
1388
|
+
if item.get("introducedBy") == change_id:
|
|
1389
|
+
if remaining:
|
|
1390
|
+
return {
|
|
1391
|
+
"ok": False,
|
|
1392
|
+
"code": "FOREIGN_PROVENANCE_CONFLICT",
|
|
1393
|
+
"files": [rel],
|
|
1394
|
+
}
|
|
1395
|
+
removed_paths.append(rel)
|
|
1396
|
+
deleted_entries += 1
|
|
1397
|
+
continue
|
|
1398
|
+
item["touchedBy"] = remaining
|
|
1399
|
+
item["commitScope"] = "foreign-change"
|
|
1400
|
+
current_hash = logical_file_hash(project_root, rel)
|
|
1401
|
+
item["logicalHash"] = current_hash
|
|
1402
|
+
item["binaryHash"] = (
|
|
1403
|
+
None if current_hash.startswith("gitblob:") else current_hash
|
|
1404
|
+
)
|
|
1405
|
+
reconciled.append(item)
|
|
1406
|
+
preserved_foreign_entries += 1
|
|
1407
|
+
|
|
1408
|
+
for rel in removed_paths:
|
|
1409
|
+
touched_reason, classify_error = _classify_against_baseline(
|
|
1410
|
+
project_root,
|
|
1411
|
+
rel,
|
|
1412
|
+
baseline_commit,
|
|
1413
|
+
)
|
|
1414
|
+
if classify_error or touched_reason is not None:
|
|
1415
|
+
return {
|
|
1416
|
+
"ok": False,
|
|
1417
|
+
"code": classify_error or "FILE_CHANGED_DURING_RECONCILE",
|
|
1418
|
+
"files": [rel],
|
|
1419
|
+
}
|
|
1420
|
+
if _current_head(project_root) != head_before:
|
|
1421
|
+
return {"ok": False, "code": "HEAD_MOVED"}
|
|
1422
|
+
|
|
1423
|
+
updated = json.loads(json.dumps(manifest))
|
|
1424
|
+
updated["files"] = reconciled
|
|
1425
|
+
if reconciled:
|
|
1426
|
+
recheck_error, _ = validator(
|
|
1427
|
+
project_root,
|
|
1428
|
+
updated,
|
|
1429
|
+
require_files=False,
|
|
1430
|
+
)
|
|
1431
|
+
if recheck_error:
|
|
1432
|
+
return {"ok": False, "code": recheck_error}
|
|
1433
|
+
_write_json(manifest_path, updated)
|
|
1434
|
+
after_hash: str | None = _sha256(manifest_path)
|
|
1435
|
+
else:
|
|
1436
|
+
manifest_path.unlink()
|
|
1437
|
+
after_hash = None
|
|
1438
|
+
except LockUnavailable:
|
|
1439
|
+
return {"ok": False, "code": "MANIFEST_LOCKED"}
|
|
1440
|
+
|
|
1441
|
+
return {
|
|
1442
|
+
"ok": True,
|
|
1443
|
+
"code": "RECONCILED",
|
|
1444
|
+
"manifestHashBefore": before_hash,
|
|
1445
|
+
"manifestHashAfter": after_hash,
|
|
1446
|
+
"removedCurrentTouches": removed_current_touches,
|
|
1447
|
+
"deletedEntries": deleted_entries,
|
|
1448
|
+
"preservedForeignEntries": preserved_foreign_entries,
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
|
|
898
1452
|
def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
899
1453
|
action = "begin"
|
|
900
1454
|
project_root = Path(project).resolve()
|
|
@@ -907,6 +1461,19 @@ def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
|
907
1461
|
snapshot_path = _state_root(change_root) / SNAPSHOT_REL
|
|
908
1462
|
if not _manifest_target_inside(change_root, snapshot_path):
|
|
909
1463
|
return _result(False, action, "SNAPSHOT_PATH_OUTSIDE_PROJECT", [])
|
|
1464
|
+
try:
|
|
1465
|
+
current_profile_fingerprint = _profile_fingerprint(
|
|
1466
|
+
project_root,
|
|
1467
|
+
strict_profile=True,
|
|
1468
|
+
)
|
|
1469
|
+
except ProfileConfigInvalid as exc:
|
|
1470
|
+
return _result(
|
|
1471
|
+
False,
|
|
1472
|
+
action,
|
|
1473
|
+
"PROFILE_INVALID",
|
|
1474
|
+
[],
|
|
1475
|
+
error=str(exc),
|
|
1476
|
+
)
|
|
910
1477
|
|
|
911
1478
|
if snapshot_path.is_file():
|
|
912
1479
|
try:
|
|
@@ -924,6 +1491,11 @@ def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
|
924
1491
|
for item in entries)
|
|
925
1492
|
):
|
|
926
1493
|
return _result(False, action, "SNAPSHOT_INVALID", [])
|
|
1494
|
+
if (
|
|
1495
|
+
isinstance(snapshot.get("profileFingerprint"), str)
|
|
1496
|
+
and snapshot["profileFingerprint"] != current_profile_fingerprint
|
|
1497
|
+
):
|
|
1498
|
+
return _result(False, action, "PROFILE_CHANGED", [])
|
|
927
1499
|
return _result(
|
|
928
1500
|
True,
|
|
929
1501
|
action,
|
|
@@ -933,11 +1505,26 @@ def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
|
933
1505
|
fileCount=len(entries),
|
|
934
1506
|
)
|
|
935
1507
|
|
|
936
|
-
|
|
1508
|
+
try:
|
|
1509
|
+
files = _enumerate_allowed_test_files(
|
|
1510
|
+
project_root,
|
|
1511
|
+
strict_profile=True,
|
|
1512
|
+
)
|
|
1513
|
+
except ProfileConfigInvalid as exc:
|
|
1514
|
+
return _result(
|
|
1515
|
+
False,
|
|
1516
|
+
action,
|
|
1517
|
+
"PROFILE_INVALID",
|
|
1518
|
+
[],
|
|
1519
|
+
error=str(exc),
|
|
1520
|
+
)
|
|
937
1521
|
snapshot = {
|
|
938
1522
|
"schemaVersion": SCHEMA_VERSION,
|
|
939
1523
|
"mode": MODE,
|
|
940
1524
|
"projectRoot": str(project_root),
|
|
1525
|
+
"repositoryId": harness_paths.repository_identity(project_root),
|
|
1526
|
+
"headCommit": _current_head(project_root),
|
|
1527
|
+
"profileFingerprint": current_profile_fingerprint,
|
|
941
1528
|
"capturedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
942
1529
|
"files": [
|
|
943
1530
|
{"path": rel, "sha256": digest, "ignored": _is_ignored(project_root, rel)}
|
|
@@ -987,6 +1574,36 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
|
987
1574
|
expectedRoot=snapshot_root,
|
|
988
1575
|
actualRoot=str(project_root),
|
|
989
1576
|
)
|
|
1577
|
+
snapshot_repository = snapshot.get("repositoryId")
|
|
1578
|
+
if (
|
|
1579
|
+
snapshot_repository is not None
|
|
1580
|
+
and snapshot_repository != harness_paths.repository_identity(project_root)
|
|
1581
|
+
):
|
|
1582
|
+
return _result(False, action, "REPOSITORY_MISMATCH", [])
|
|
1583
|
+
try:
|
|
1584
|
+
current_profile_fingerprint = _profile_fingerprint(
|
|
1585
|
+
project_root,
|
|
1586
|
+
strict_profile=True,
|
|
1587
|
+
)
|
|
1588
|
+
except ProfileConfigInvalid as exc:
|
|
1589
|
+
return _result(
|
|
1590
|
+
False,
|
|
1591
|
+
action,
|
|
1592
|
+
"PROFILE_INVALID",
|
|
1593
|
+
[],
|
|
1594
|
+
error=str(exc),
|
|
1595
|
+
)
|
|
1596
|
+
if (
|
|
1597
|
+
isinstance(snapshot.get("profileFingerprint"), str)
|
|
1598
|
+
and snapshot["profileFingerprint"] != current_profile_fingerprint
|
|
1599
|
+
):
|
|
1600
|
+
return _result(False, action, "PROFILE_CHANGED", [])
|
|
1601
|
+
baseline_commit = _snapshot_baseline_commit(
|
|
1602
|
+
project_root,
|
|
1603
|
+
change_root,
|
|
1604
|
+
snapshot,
|
|
1605
|
+
)
|
|
1606
|
+
profile_changed = False
|
|
990
1607
|
|
|
991
1608
|
before_entries = snapshot.get("files")
|
|
992
1609
|
if not isinstance(before_entries, list):
|
|
@@ -999,14 +1616,26 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
|
999
1616
|
digest = item.get("sha256")
|
|
1000
1617
|
if not isinstance(rel, str) or not rel or not isinstance(digest, str):
|
|
1001
1618
|
return _result(False, action, "SNAPSHOT_INVALID", [])
|
|
1002
|
-
path, normalized, error =
|
|
1619
|
+
path, normalized, error = _validate_snapshot_file(project_root, rel)
|
|
1003
1620
|
if error:
|
|
1004
1621
|
return _result(False, action, error, [rel])
|
|
1005
1622
|
if normalized != rel or path is None:
|
|
1006
1623
|
return _result(False, action, "PATH_ESCAPE", [rel])
|
|
1007
1624
|
before[rel] = item
|
|
1008
1625
|
|
|
1009
|
-
|
|
1626
|
+
try:
|
|
1627
|
+
current = _enumerate_allowed_test_files(
|
|
1628
|
+
project_root,
|
|
1629
|
+
strict_profile=True,
|
|
1630
|
+
)
|
|
1631
|
+
except ProfileConfigInvalid as exc:
|
|
1632
|
+
return _result(
|
|
1633
|
+
False,
|
|
1634
|
+
action,
|
|
1635
|
+
"PROFILE_INVALID",
|
|
1636
|
+
[],
|
|
1637
|
+
error=str(exc),
|
|
1638
|
+
)
|
|
1010
1639
|
for rel in current:
|
|
1011
1640
|
path, normalized, error = _validate_file(project_root, rel)
|
|
1012
1641
|
if error:
|
|
@@ -1017,9 +1646,23 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
|
1017
1646
|
touched: list[tuple[str, str]] = []
|
|
1018
1647
|
for rel, digest in current.items():
|
|
1019
1648
|
if rel not in before:
|
|
1020
|
-
|
|
1649
|
+
reason, classify_error = _classify_against_baseline(
|
|
1650
|
+
project_root,
|
|
1651
|
+
rel,
|
|
1652
|
+
baseline_commit,
|
|
1653
|
+
)
|
|
1654
|
+
if classify_error:
|
|
1655
|
+
return _result(False, action, classify_error, [rel])
|
|
1656
|
+
if reason is not None:
|
|
1657
|
+
touched.append((rel, reason))
|
|
1021
1658
|
continue
|
|
1022
|
-
|
|
1659
|
+
before_digest = before[rel].get("logicalHash") or before[rel]["sha256"]
|
|
1660
|
+
current_digest = (
|
|
1661
|
+
logical_file_hash(project_root, rel)
|
|
1662
|
+
if before[rel].get("logicalHash")
|
|
1663
|
+
else digest
|
|
1664
|
+
)
|
|
1665
|
+
if before_digest != current_digest:
|
|
1023
1666
|
touched.append((rel, "test-updated"))
|
|
1024
1667
|
|
|
1025
1668
|
for reason in ("tdd-created", "test-updated"):
|
|
@@ -1039,6 +1682,19 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
|
1039
1682
|
return result
|
|
1040
1683
|
|
|
1041
1684
|
recorded = [rel for rel, _ in touched]
|
|
1685
|
+
reconciliation = _reconcile_close_manifest(
|
|
1686
|
+
project_root,
|
|
1687
|
+
change_root,
|
|
1688
|
+
set(recorded),
|
|
1689
|
+
baseline_commit,
|
|
1690
|
+
)
|
|
1691
|
+
if not reconciliation.get("ok"):
|
|
1692
|
+
return _result(
|
|
1693
|
+
False,
|
|
1694
|
+
action,
|
|
1695
|
+
str(reconciliation.get("code") or "MANIFEST_RECONCILE_FAILED"),
|
|
1696
|
+
list(reconciliation.get("files") or []),
|
|
1697
|
+
)
|
|
1042
1698
|
|
|
1043
1699
|
# Cross-check (retro §5.10): if the manifest has active entries for this
|
|
1044
1700
|
# change but close computed recordedCount=0, the snapshot/manifest/diff
|
|
@@ -1052,10 +1708,17 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
|
1052
1708
|
if isinstance(manifest, dict):
|
|
1053
1709
|
manifest_files = manifest.get("files")
|
|
1054
1710
|
if isinstance(manifest_files, list):
|
|
1711
|
+
is_v2 = manifest.get("schemaVersion") == 2
|
|
1712
|
+
change_id = change_root.name
|
|
1055
1713
|
active_entries = [
|
|
1056
1714
|
f for f in manifest_files
|
|
1057
1715
|
if isinstance(f, dict)
|
|
1058
1716
|
and f.get("reason") in ("tdd-created", "test-updated", "stale-test-repair")
|
|
1717
|
+
and (
|
|
1718
|
+
not is_v2
|
|
1719
|
+
or f.get("introducedBy") == change_id
|
|
1720
|
+
or change_id in (f.get("touchedBy") or [])
|
|
1721
|
+
)
|
|
1059
1722
|
]
|
|
1060
1723
|
if active_entries and not recorded:
|
|
1061
1724
|
return _result(
|
|
@@ -1075,6 +1738,13 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
|
1075
1738
|
recorded,
|
|
1076
1739
|
recordedCount=len(recorded),
|
|
1077
1740
|
unchangedPreexisting=len(before) - sum(1 for rel, _ in touched if rel in before),
|
|
1741
|
+
baselineCommit=baseline_commit,
|
|
1742
|
+
profileChanged=profile_changed,
|
|
1743
|
+
manifestReconciliation={
|
|
1744
|
+
key: value
|
|
1745
|
+
for key, value in reconciliation.items()
|
|
1746
|
+
if key != "ok"
|
|
1747
|
+
},
|
|
1078
1748
|
)
|
|
1079
1749
|
|
|
1080
1750
|
|
|
@@ -1146,6 +1816,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1146
1816
|
rehome_parser.add_argument("--change-dir", required=True)
|
|
1147
1817
|
rehome_parser.add_argument("--expected-head", required=True)
|
|
1148
1818
|
rehome_parser.add_argument("--json", action="store_true")
|
|
1819
|
+
rebind_parser = sub.add_parser("rebind-snapshot")
|
|
1820
|
+
rebind_parser.add_argument("--from", dest="from_project", required=True)
|
|
1821
|
+
rebind_parser.add_argument("--to", dest="to_project", required=True)
|
|
1822
|
+
rebind_parser.add_argument("--change-dir", required=True)
|
|
1823
|
+
rebind_parser.add_argument("--expected-base", required=True)
|
|
1824
|
+
rebind_parser.add_argument("--json", action="store_true")
|
|
1149
1825
|
mark_parser = sub.add_parser("mark")
|
|
1150
1826
|
mark_parser.add_argument("--project", required=True)
|
|
1151
1827
|
mark_parser.add_argument("--change-dir", required=True)
|
|
@@ -1166,6 +1842,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1166
1842
|
result = rehome(
|
|
1167
1843
|
args.from_project, args.to_project, args.change_dir, args.expected_head
|
|
1168
1844
|
)
|
|
1845
|
+
elif args.action == "rebind-snapshot":
|
|
1846
|
+
result = rebind_snapshot(
|
|
1847
|
+
args.from_project, args.to_project, args.change_dir, args.expected_base
|
|
1848
|
+
)
|
|
1169
1849
|
else:
|
|
1170
1850
|
result = stage(args.project, args.change_dir)
|
|
1171
1851
|
print(json.dumps(result, ensure_ascii=False, indent=2 if args.json else None))
|