Преглед изворни кода

fix(repo-doctor): recognize guard-comment + section-map on large files (CRIT->INFO)

A >800-line file carrying both a deliberateness guard comment (do not
split / deliberately single-file) and >=3 section markers is the
doctrine-compliant shape, but the scorer CRIT-flagged it by size alone
- punishing the exact pattern agentic-quality.md mandates. Both signals
-> INFO (verify the mechanical gate); one signal -> WARN naming the
missing half; bare -> CRIT unchanged. Motivated by the false-positive
pair summon.py (2,359 lines) and svg-brand-tint index.html (1,566).

Built by fleetflow lane gates/g4-repodoctor (codex); committed by the
orchestrator - codex sandbox cannot write worktree git metadata.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter пре 1 недеља
родитељ
комит
b234152088

+ 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 |
 | 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) |
 | 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 |
 | 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)
 ## enforcement (weight 1.5)

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

@@ -60,12 +60,18 @@ MEDIA_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".mp4", ".mov", ".webm", ".zip",
               ".7z", ".tar", ".gz", ".glb", ".psd"}
               ".7z", ".tar", ".gz", ".glb", ".psd"}
 SCRATCH_PAT = re.compile(r"^(tmp|temp|scratch|junk|old|copy|test)[-_]", re.I)
 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)
 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.
+LARGE_FILE_GUARD_SIGNALS = (
+    "do not split", "deliberately single-file", "deliberately single file",
+    "single-file", "single file", "one file",
+)
+LARGE_FILE_GUARD_PAT = re.compile(
+    "|".join(re.escape(signal) for signal in LARGE_FILE_GUARD_SIGNALS), re.I)
+LARGE_FILE_SECTION_PAT = re.compile(
+    r"^\s*(?:#|//|;|<!--)\s*===\s+\w[\w .-]*\s+===", 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)
 LANDMINE_PAT = re.compile(r"landmine|gotcha|pitfall|footgun|hazard|trap|don'?t", re.I)
 COMMENT_LINE = re.compile(r"^\s*(#|//|/\*|\*|<!--|--|\"\"\"|''')")
 COMMENT_LINE = re.compile(r"^\s*(#|//|/\*|\*|<!--|--|\"\"\"|''')")
 SECTION_PAT = re.compile(r"^\s*(#|//|/\*|<!--|--)\s*.{0,8}([=─-]{4,}|SECTION|═{4,})")
 SECTION_PAT = re.compile(r"^\s*(#|//|/\*|<!--|--)\s*.{0,8}([=─-]{4,}|SECTION|═{4,})")
@@ -102,6 +108,27 @@ def commits_since_touch(repo: Path, rel: str) -> int | None:
     return int(n) if n.isdigit() else 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):
 def iter_source_files(repo: Path):
     for root, dirs, files in os.walk(repo):
     for root, dirs, files in os.walk(repo):
         dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS and not d.startswith(".")]
         dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS and not d.startswith(".")]
@@ -309,15 +336,22 @@ class Audit:
                 monsters.append((n, str(p.relative_to(self.repo)).replace("\\", "/")))
                 monsters.append((n, str(p.relative_to(self.repo)).replace("\\", "/")))
         monsters.sort(reverse=True)
         monsters.sort(reverse=True)
         self.facts["monster_files"] = monsters[:10]
         self.facts["monster_files"] = monsters[:10]
-        unjustified = []
+        penalties = []
         for n, rel in monsters[:10]:
         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",
                 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
                 continue
-            unjustified.append(n)
+            penalties.append(1.0 if n >= MONSTER_CRIT else 0.5)
             if n >= MONSTER_CRIT:
             if n >= MONSTER_CRIT:
                 self.add("structure", "crit",
                 self.add("structure", "crit",
                          f"{n}-line file — split by responsibility (refactor-ops) "
                          f"{n}-line file — split by responsibility (refactor-ops) "
@@ -327,7 +361,7 @@ class Audit:
                 self.add("structure", "warn",
                 self.add("structure", "warn",
                          f"{n}-line file — needs section markers now, a split "
                          f"{n}-line file — needs section markers now, a split "
                          "or a written justification before it grows", rel)
                          "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 = []
         junk = []
         for p in self.repo.iterdir():
         for p in self.repo.iterdir():
             if p.is_file():
             if p.is_file():

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

@@ -123,15 +123,23 @@ GR="$(get 'd["data"]["grade"]')"
 "$PY" "$DOCTOR" --repo "$TMP" --strict >/dev/null 2>&1 \
 "$PY" "$DOCTOR" --repo "$TMP" --strict >/dev/null 2>&1 \
     && ok "remediated repo passes --strict (grade $GR)" || no "strict pass" "grade $GR exit $?"
     && 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
   "$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"
 git add -A; git commit -qm "feat: justified monster"
 OUT="$(run_json)"
 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"
     || 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"
 
 
 echo
 echo
 echo "repo-doctor tests: $pass passed, $fail failed"
 echo "repo-doctor tests: $pass passed, $fail failed"