فهرست منبع

merge: repo-doctor guard-comment recognition, adversarially hardened

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 1 هفته پیش
والد
کامیت
a36d6240d4
3فایلهای تغییر یافته به همراه96 افزوده شده و 19 حذف شده
  1. 1 1
      skills/repo-doctor/references/scoring-rubric.md
  2. 54 13
      skills/repo-doctor/scripts/repo-doctor.py
  3. 41 5
      skills/repo-doctor/tests/run.sh

+ 1 - 1
skills/repo-doctor/references/scoring-rubric.md

@@ -51,7 +51,7 @@ the scorer only checks the two mechanically-checkable proxies.
 | Check | Threshold | Why | Fix |
 |---|---|---|---|
 | Monster files | warn ≥800, crit ≥1500 (generated files exempt via header detection) | #1 agent friction in 3 of 4 audited clusters; a 9,560-line file is grep-only territory | Split (refactor-ops), OR guard comment + section map + invariant gate (the justified-monster route) |
-| Justified monster | downgraded to info when the first 40 lines carry a justification marker (`ARCHITECTURE:`, `PERF:`, `Sections:`, "single-file", "single-writer", "do not split") | The scorer honours the doctrine's own escape hatch — a justified file shouldn't nag forever; the info reminds you to verify the map + gate still hold | Keep the marker honest: if the gate is gone, the justification is a lie — split or restore the gate |
+| Justified monster | guard comment plus at least three `=== NAME ===` comment markers → info; only one signal → warn naming the missing half | The scorer honours the doctrine's own escape hatch — a justified file shouldn't nag forever; the info reminds you to verify the mechanical gate still holds | Keep the marker honest: if the gate is gone, the justification is a lie — split or restore the gate |
 | Repo-root junk | warn; media >1 MB or scratch-pattern names | Root is the first thing every agent lists; junk destroys signal | docs/screenshots/, dev/, scratchpad, or delete |
 
 ## enforcement (weight 1.5)

+ 54 - 13
skills/repo-doctor/scripts/repo-doctor.py

@@ -60,12 +60,25 @@ MEDIA_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".mp4", ".mov", ".webm", ".zip",
               ".7z", ".tar", ".gz", ".glb", ".psd"}
 SCRATCH_PAT = re.compile(r"^(tmp|temp|scratch|junk|old|copy|test)[-_]", re.I)
 GENERATED_PAT = re.compile(r"generated|do not edit|don'?t hand-edit|@generated", re.I)
-# The doctrine's escape hatch for large files: guard comment + section map.
-# A monster whose head carries a justification marker downgrades to info —
-# the scorer must honour the same rule it audits, or it nags forever.
-JUSTIFY_PAT = re.compile(
-    r"ARCHITECTURE:|PERF:|Sections?\s*:|single[- ]file|do not split|"
-    r"single[- ]writer|deliberately (one|large)", re.I)
+# Deliberateness signals for justified large files. Extend this list when the
+# doctrine adopts another unambiguous way to say that a file must stay whole.
+# Signals require INTENT wording — bare nouns like "one file"/"single file"
+# match innocent prose ("reads one file per call") and wrongly bless real
+# monsters (adversarial-review finding, 2026-07).
+LARGE_FILE_GUARD_SIGNALS = (
+    "do not split", "don't split", "never split", "must not be split",
+    "deliberately single-file", "deliberately single file",
+    "deliberately one file",
+)
+LARGE_FILE_GUARD_PAT = re.compile(
+    "|".join(re.escape(signal) for signal in LARGE_FILE_GUARD_SIGNALS), re.I)
+# Keep marker syntax in lockstep with the comments dim's SECTION_PAT or the
+# two dims contradict each other on the same body: accept any >=3-char run of
+# = or ═ around the name, not only the exact ===.
+LARGE_FILE_SECTION_PAT = re.compile(
+    r"^\s*(?:#|//|;|<!--)\s*[=═]{3,}\s+\w[\w .-]*\s+[=═]{3,}", re.M)
+BOXED_SECTION_PAT = re.compile(
+    r"(?m)^\s*#\s*=+\s*$\n^\s*#\s+\w[^\n]*$\n^\s*#\s*=+\s*$")
 LANDMINE_PAT = re.compile(r"landmine|gotcha|pitfall|footgun|hazard|trap|don'?t", re.I)
 COMMENT_LINE = re.compile(r"^\s*(#|//|/\*|\*|<!--|--|\"\"\"|''')")
 SECTION_PAT = re.compile(r"^\s*(#|//|/\*|<!--|--)\s*.{0,8}([=─-]{4,}|SECTION|═{4,})")
@@ -102,6 +115,27 @@ def commits_since_touch(repo: Path, rel: str) -> int | None:
     return int(n) if n.isdigit() else None
 
 
+def large_file_signals(path: Path) -> tuple[bool, bool]:
+    """Return (guard comment, section map) for a deliberately large file."""
+    try:
+        body = path.read_text(encoding="utf-8", errors="replace")
+    except OSError:
+        return False, False
+
+    # Phrases only count inside comments/docstrings, never executable strings.
+    comment_regions = re.findall(
+        r"<!--.*?-->|/\*.*?\*/|'''[\s\S]*?'''|\"\"\"[\s\S]*?\"\"\"|"
+        r"(?m:^\s*(?:#|//|;).*?$)",
+        body,
+        flags=re.S,
+    )
+    has_guard = any(LARGE_FILE_GUARD_PAT.search(region) for region in comment_regions)
+    marker_count = (len(LARGE_FILE_SECTION_PAT.findall(body))
+                    + len(BOXED_SECTION_PAT.findall(body)))
+    has_map = marker_count >= 3
+    return has_guard, has_map
+
+
 def iter_source_files(repo: Path):
     for root, dirs, files in os.walk(repo):
         dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS and not d.startswith(".")]
@@ -309,15 +343,22 @@ class Audit:
                 monsters.append((n, str(p.relative_to(self.repo)).replace("\\", "/")))
         monsters.sort(reverse=True)
         self.facts["monster_files"] = monsters[:10]
-        unjustified = []
+        penalties = []
         for n, rel in monsters[:10]:
-            head = "".join(head_lines(self.repo / rel, 40))
-            if JUSTIFY_PAT.search(head):
+            has_guard, has_map = large_file_signals(self.repo / rel)
+            if has_guard and has_map:
                 self.add("structure", "info",
-                         f"{n}-line file carries a justification marker — verify "
-                         "the section map and the enforcing gate still hold", rel)
+                         f"{n}-line large file with guard comment + section map — "
+                         "verify its mechanical gate exists", rel)
+                continue
+            if has_guard or has_map:
+                missing = "section map" if has_guard else "guard comment"
+                self.add("structure", "warn",
+                         f"{n}-line large file has only half its justification — "
+                         f"missing {missing}", rel)
+                penalties.append(0.5)
                 continue
-            unjustified.append(n)
+            penalties.append(1.0 if n >= MONSTER_CRIT else 0.5)
             if n >= MONSTER_CRIT:
                 self.add("structure", "crit",
                          f"{n}-line file — split by responsibility (refactor-ops) "
@@ -327,7 +368,7 @@ class Audit:
                 self.add("structure", "warn",
                          f"{n}-line file — needs section markers now, a split "
                          "or a written justification before it grows", rel)
-        score -= min(3.0, sum(1.0 if n >= MONSTER_CRIT else 0.5 for n in unjustified))
+        score -= min(3.0, sum(penalties))
         junk = []
         for p in self.repo.iterdir():
             if p.is_file():

+ 41 - 5
skills/repo-doctor/tests/run.sh

@@ -123,15 +123,51 @@ GR="$(get 'd["data"]["grade"]')"
 "$PY" "$DOCTOR" --repo "$TMP" --strict >/dev/null 2>&1 \
     && ok "remediated repo passes --strict (grade $GR)" || no "strict pass" "grade $GR exit $?"
 
-# 11. justified monster (guard marker in head) downgrades crit -> info
-{ echo "# ARCHITECTURE: single-file by design — Sections: A · B · C"
+# 11-13. large-file escape hatch requires both guard and section map
+{ echo '"""Deliberately single-file; do not split this portable script."""'
+  printf '# === INPUT ===\n# === PROCESSING ===\n# === OUTPUT ===\n'
   "$PY" -c "print('\n'.join('y = %d' % i for i in range(1700)))"; } > src/justified.py
+{ printf '# === INPUT ===\n# === PROCESSING ===\n# === OUTPUT ===\n'
+  "$PY" -c "print('\n'.join('z = %d' % i for i in range(1700)))"; } > src/map-only.py
+"$PY" -c "print('\n'.join('b = %d' % i for i in range(1700)))" > src/bare.py
 git add -A; git commit -qm "feat: justified monster"
 OUT="$(run_json)"
-[ "$(get 'sum(1 for f in d["data"]["findings"] if "justified.py" in f["path"] and f["severity"]=="crit")')" = "0" ] \
-    && [ "$(get 'sum(1 for f in d["data"]["findings"] if "justified.py" in f["path"] and "justification marker" in f["msg"])')" = "1" ] \
-    && ok "justification marker downgrades monster crit -> info" \
+[ "$(get 'sum(1 for f in d["data"]["findings"] if "justified.py" in f["path"] and f["dim"]=="structure" and f["severity"]=="info")')" = "1" ] \
+    && ok "guard + map downgrades monster crit -> info" \
     || no "justified monster" "severity wrong"
+[ "$(get 'sum(1 for f in d["data"]["findings"] if "map-only.py" in f["path"] and f["severity"]=="warn" and "guard comment" in f["msg"])')" = "1" ] \
+    && ok "map-only monster warns for missing guard" \
+    || no "map-only monster" "severity or message wrong"
+[ "$(get 'sum(1 for f in d["data"]["findings"] if "bare.py" in f["path"] and f["severity"]=="crit")')" = "1" ] \
+    && ok "bare monster remains crit" || no "bare monster" "severity wrong"
+
+# 14-16. adversarial-review regressions (GLM refuter, 2026-07)
+# guard-only (no map) -> WARN: discriminates the two-signal branch from the
+# old guard-alone justification logic, which would have fully excused it.
+{ echo '"""Deliberately single-file; do not split this portable script."""'
+  "$PY" -c "print('\n'.join('g = %d' % i for i in range(1700)))"; } > src/guard-only.py
+# innocent prose + markers -> must stay CRIT (bare "one file" is not intent)
+{ echo '# reads one file per call and caches the result'
+  printf '# === INPUT ===\n# === PROCESSING ===\n# === OUTPUT ===\n'
+  "$PY" -c "print('\n'.join('p = %d' % i for i in range(1700)))"; } > src/prose-trap.py
+# 4-equals markers + guard -> INFO (marker-pattern parity with comments dim)
+{ echo '"""Deliberately single-file; do not split."""'
+  printf '# ==== INPUT ====\n# ==== PROCESSING ====\n# ==== OUTPUT ====\n'
+  "$PY" -c "print('\n'.join('w = %d' % i for i in range(1700)))"; } > src/wide-markers.py
+git add -A; git commit -qm "feat: refuter regression fixtures"
+OUT="$(run_json)"
+[ "$(get 'sum(1 for f in d["data"]["findings"] if "guard-only.py" in f["path"] and f["severity"]=="warn" and "map" in f["msg"].lower())')" = "1" ] \
+    && ok "guard-only monster warns for missing map (pins new branch)" \
+    || no "guard-only monster" "severity or message wrong"
+# prose is not a guard -> map-only WARN branch; the defended regression is
+# "never INFO" (no false blessing from bare 'one file' prose).
+[ "$(get 'sum(1 for f in d["data"]["findings"] if "prose-trap.py" in f["path"] and f["dim"]=="structure" and f["severity"]=="warn" and "guard" in f["msg"])')" = "1" ] \
+    && [ "$(get 'sum(1 for f in d["data"]["findings"] if "prose-trap.py" in f["path"] and f["dim"]=="structure" and f["severity"]=="info")')" = "0" ] \
+    && ok "innocent 'one file' prose does not excuse a monster" \
+    || no "prose-trap monster" "false downgrade"
+[ "$(get 'sum(1 for f in d["data"]["findings"] if "wide-markers.py" in f["path"] and f["severity"]=="info")')" = "1" ] \
+    && ok "4-equals markers count as a section map" \
+    || no "wide-markers monster" "marker pattern too narrow"
 
 echo
 echo "repo-doctor tests: $pass passed, $fail failed"