tenseleyflow/loader / aa9c340

Browse files

Scope link repair targets

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
aa9c34067dc6499804233f81fd423acf9c98e931
Parents
0544f0e
Tree
4a3c0bb

2 changed files

StatusFile+-
M src/loader/runtime/finalization.py 112 5
M tests/test_finalization.py 57 0
src/loader/runtime/finalization.pymodified
@@ -1214,12 +1214,15 @@ def _build_verification_repair_guidance(
12141214
     lines.extend(f"- {item}" for item in fixes)
12151215
     primary_target = repair_targets[0] if repair_targets else None
12161216
     if primary_target is not None:
1217
+        expected_action = _repair_expected_action_line(
1218
+            dod,
1219
+            primary_target,
1220
+            project_root=project_root,
1221
+        )
12171222
         lines.extend(
12181223
             [
12191224
                 f"- Immediate next step: edit `{primary_target.artifact_path}`.",
1220
-                "- If the broken reference should remain, create "
1221
-                f"`{primary_target.expected_path}`; otherwise remove or replace "
1222
-                f"`{primary_target.failing_reference}`.",
1225
+                expected_action,
12231226
                 *(
12241227
                     [
12251228
                         "- Use the existing artifact files as the source of truth while "
@@ -1327,6 +1330,11 @@ def _build_verification_failure_recovery_nudge(
13271330
         )
13281331
     if repair_targets:
13291332
         primary_target = repair_targets[0]
1333
+        expected_action = _repair_expected_action_line(
1334
+            dod,
1335
+            primary_target,
1336
+            project_root=project_root,
1337
+        )
13301338
         source_hint = ""
13311339
         if repair_source_paths:
13321340
             preview = ", ".join(f"`{path}`" for path in repair_source_paths[:4])
@@ -1342,8 +1350,7 @@ def _build_verification_failure_recovery_nudge(
13421350
             "Your next response should be one concrete `edit` or `write`-style tool "
13431351
             f"call that updates `{primary_target.artifact_path}` to repair "
13441352
             f"`{primary_target.failing_reference}`. "
1345
-            f"If that reference should stay, create `{primary_target.expected_path}`; "
1346
-            "otherwise remove or replace the broken local reference."
1353
+            f"{expected_action[2:] if expected_action.startswith('- ') else expected_action}"
13471354
             f"{source_hint}"
13481355
         )
13491356
 
@@ -1397,6 +1404,106 @@ def _existing_repair_source_paths(
13971404
     return paths
13981405
 
13991406
 
1407
+def _repair_expected_action_line(
1408
+    dod: DefinitionOfDone,
1409
+    target: VerificationRepairTarget,
1410
+    *,
1411
+    project_root: Path,
1412
+) -> str:
1413
+    if _repair_expected_path_is_within_artifact_scope(
1414
+        dod,
1415
+        target,
1416
+        project_root=project_root,
1417
+    ):
1418
+        return (
1419
+            "- If the broken reference should remain, create "
1420
+            f"`{target.expected_path}`; otherwise remove or replace "
1421
+            f"`{target.failing_reference}`."
1422
+        )
1423
+    return (
1424
+        "- The missing target resolves outside the requested artifact scope; "
1425
+        "do not create that outside file just to satisfy the link. "
1426
+        f"Remove or replace `{target.failing_reference}` in "
1427
+        f"`{target.artifact_path}` instead."
1428
+    )
1429
+
1430
+
1431
+def _repair_expected_path_is_within_artifact_scope(
1432
+    dod: DefinitionOfDone,
1433
+    target: VerificationRepairTarget,
1434
+    *,
1435
+    project_root: Path,
1436
+) -> bool:
1437
+    try:
1438
+        expected_path = Path(target.expected_path).expanduser().resolve(strict=False)
1439
+        artifact_path = Path(target.artifact_path).expanduser().resolve(strict=False)
1440
+    except (OSError, RuntimeError, ValueError):
1441
+        return False
1442
+
1443
+    scope_roots = _repair_artifact_scope_roots(dod, project_root=project_root)
1444
+    if not scope_roots:
1445
+        scope_roots = (artifact_path.parent,)
1446
+
1447
+    for root in scope_roots:
1448
+        if not _path_is_relative_to(artifact_path, root):
1449
+            continue
1450
+        if _path_is_relative_to(expected_path, root):
1451
+            return True
1452
+    if artifact_path.name.lower() != "index.html":
1453
+        nested_artifact_root = artifact_path.parent.parent
1454
+        if _path_is_relative_to(expected_path, nested_artifact_root):
1455
+            return True
1456
+    return False
1457
+
1458
+
1459
+def _repair_artifact_scope_roots(
1460
+    dod: DefinitionOfDone,
1461
+    *,
1462
+    project_root: Path,
1463
+) -> tuple[Path, ...]:
1464
+    roots: list[Path] = []
1465
+    seen: set[str] = set()
1466
+
1467
+    def add_root(path: Path) -> None:
1468
+        try:
1469
+            resolved = path.expanduser().resolve(strict=False)
1470
+        except (OSError, RuntimeError, ValueError):
1471
+            return
1472
+        key = str(resolved)
1473
+        if key in seen:
1474
+            return
1475
+        seen.add(key)
1476
+        roots.append(resolved)
1477
+
1478
+    for target, expect_directory in collect_planned_artifact_targets(
1479
+        dod,
1480
+        project_root=project_root,
1481
+        max_paths=24,
1482
+    ):
1483
+        if expect_directory:
1484
+            add_root(target)
1485
+        elif target.suffix:
1486
+            add_root(target.parent)
1487
+
1488
+    for path_str in dod.touched_files:
1489
+        if not str(path_str).strip():
1490
+            continue
1491
+        path = Path(path_str)
1492
+        effective_path = path if path.is_absolute() else project_root / path
1493
+        if effective_path.suffix:
1494
+            add_root(effective_path.parent)
1495
+
1496
+    return tuple(roots)
1497
+
1498
+
1499
+def _path_is_relative_to(path: Path, root: Path) -> bool:
1500
+    try:
1501
+        path.relative_to(root)
1502
+    except ValueError:
1503
+        return False
1504
+    return True
1505
+
1506
+
14001507
 def _extract_verification_repair_targets(
14011508
     evidence_items: list[VerificationEvidence],
14021509
 ) -> list[VerificationRepairTarget]:
tests/test_finalization.pymodified
@@ -26,6 +26,7 @@ from loader.runtime.permissions import (
2626
     build_permission_policy,
2727
     load_permission_rules,
2828
 )
29
+from loader.runtime.repair_focus import extract_active_repair_context
2930
 from loader.runtime.tracing import RuntimeTracer
3031
 from loader.runtime.verification_observations import VerificationObservationStatus
3132
 from loader.tools.base import ToolResult as RegistryToolResult
@@ -345,6 +346,62 @@ def test_verification_repair_guidance_uses_existing_artifacts_as_source_of_truth
345346
     assert str(chapter_four) in guidance
346347
 
347348
 
349
+def test_verification_repair_guidance_does_not_create_out_of_scope_link_target(
350
+    temp_dir: Path,
351
+) -> None:
352
+    guide_root = temp_dir / "guides" / "nginx"
353
+    chapters = guide_root / "chapters"
354
+    chapters.mkdir(parents=True)
355
+    index_path = guide_root / "index.html"
356
+    chapter_one = chapters / "01-introduction.html"
357
+    index_path.write_text('<a href="../index.html">All guides</a>\n')
358
+    chapter_one.write_text('<a href="../index.html">Back</a>\n')
359
+    parent_index = temp_dir / "guides" / "index.html"
360
+
361
+    implementation_plan = temp_dir / "implementation.md"
362
+    implementation_plan.write_text(
363
+        "\n".join(
364
+            [
365
+                "# Implementation Plan",
366
+                "",
367
+                "## File Changes",
368
+                f"- `{guide_root}/`",
369
+                f"- `{chapters}/`",
370
+                f"- `{index_path}`",
371
+                f"- `{chapter_one}`",
372
+                "",
373
+            ]
374
+        )
375
+    )
376
+
377
+    dod = create_definition_of_done("Create the nginx guide under guides/nginx.")
378
+    dod.implementation_plan = str(implementation_plan)
379
+    dod.touched_files.extend([str(index_path), str(chapter_one)])
380
+    dod.evidence = [
381
+        VerificationEvidence(
382
+            command="verify-links",
383
+            passed=False,
384
+            output=(
385
+                "Missing local HTML links:\n"
386
+                f"{index_path}:../index.html -> {parent_index}\n"
387
+            ),
388
+        )
389
+    ]
390
+
391
+    guidance = _build_verification_repair_guidance(
392
+        dod,
393
+        project_root=temp_dir,
394
+    )
395
+    repair = extract_active_repair_context([Message(role=Role.USER, content=guidance)])
396
+
397
+    assert "outside the requested artifact scope" in guidance
398
+    assert "do not create that outside file" in guidance
399
+    assert f"create `{parent_index}`" not in guidance
400
+    assert repair is not None
401
+    assert str(parent_index.resolve(strict=False)) not in repair.allowed_paths
402
+    assert str(index_path.resolve(strict=False)) in repair.allowed_paths
403
+
404
+
348405
 @pytest.mark.asyncio
349406
 async def test_turn_finalizer_records_skipped_verification_observation(
350407
     temp_dir: Path,