From af4b5d71d9dd652a23f4a4513559f68fbb5cde43 Mon Sep 17 00:00:00 2001 From: Anthony Giacalone Date: Sun, 28 Jun 2026 14:49:32 -0700 Subject: [PATCH] feat(recon): resolve writeup deliverables at case/path variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reg-lab-recon matched the manifest deliverable path strictly (repo/), so a writeup committed as WRITEUP.md, Writeup.md, submission/writeup.md, or "CECS 378 Lab Writeup.md" read as not-present: a false doc-✗ in FACTS.md and, worse, no body to snapshot into recon-/writeups/. In the su26 §01 Lab 2 population that dropped 5 of 24 submitters (including two top-of-class writeups) from the vault snapshot. Add resolve_doc_path(repo_root, rel_file): exact -> case-insensitive basename anywhere (shallowest wins) -> a single .md whose name contains the configured stem. Ambiguous or not-found returns the canonical path, so a genuine non-submission still records present=False. .git is never searched. Wired into recon.py's cloned-repo doc pass. Tests: 9 resolver cases (exact/case/subdir/fuzzy/root-pref/git-skip/ ambiguous/not-found/integration). Full suite green (397 passed). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015cY2osrcunEXb5a8xtTy2V --- lectern/recon.py | 4 ++-- lectern/recon_docs.py | 33 +++++++++++++++++++++++++++ tests/test_recon_docs.py | 49 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/lectern/recon.py b/lectern/recon.py index e75f425..97eabfa 100644 --- a/lectern/recon.py +++ b/lectern/recon.py @@ -9,7 +9,7 @@ from lectern.recon_autograde import (fetch_autograde, fetch_autograde_artifact, scrape_autograde, AutogradeResult) from lectern.recon_git import recon_git -from lectern.recon_docs import recon_doc +from lectern.recon_docs import recon_doc, resolve_doc_path from lectern.recon_links import repo_links from lectern.recon_record import RepoRecord from lectern.recon_bundle import write_bundle @@ -57,7 +57,7 @@ def _default_auto(ref: RepoRef) -> AutogradeResult | None: if cloned and repo.exists(): ag = do_auto(ref) git = recon_git(repo, profile=m.git_profile) - docs = {d.label: recon_doc(repo / d.file, label=d.label) for d in m.docs} + docs = {d.label: recon_doc(resolve_doc_path(repo, d.file), label=d.label) for d in m.docs} else: ag, git, docs = None, None, {d.label: recon_doc(repo / d.file, label=d.label) for d in m.docs} commit = ag.commit if ag else None diff --git a/lectern/recon_docs.py b/lectern/recon_docs.py index 3d79bbe..060bc22 100644 --- a/lectern/recon_docs.py +++ b/lectern/recon_docs.py @@ -17,6 +17,39 @@ class DocRecon: _FM = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL) + +def resolve_doc_path(repo_root: Path, rel_file: str) -> Path: + """Resolve a manifest deliverable path tolerantly within a repo. + + Students name the writeup inconsistently — ``WRITEUP.md``, ``Writeup.md``, + ``submission/writeup.md``, ``CECS 378 Lab Writeup.md``. The strict + ``repo/`` check marked all of those not-present (a false doc-✗) and + dropped them from the writeup snapshot. Resolve in priority order: + + 1. exact ``repo/`` + 2. case-insensitive basename match anywhere in the tree (shallowest wins) + 3. a *single* ``.md`` whose name contains the configured stem (e.g. ``…Writeup.md``) + + Ambiguous (2+ fuzzy candidates) or not-found returns the canonical path, so + the caller records ``present=False`` exactly as before. ``.git`` is never searched. + """ + repo_root = Path(repo_root) + canonical = repo_root / rel_file + if canonical.exists(): + return canonical + target = Path(rel_file).name.lower() + stem = Path(rel_file).stem.lower() + files = [p for p in repo_root.rglob("*") + if p.is_file() and ".git" not in p.relative_to(repo_root).parts] + ci = sorted((p for p in files if p.name.lower() == target), + key=lambda p: len(p.relative_to(repo_root).parts)) + if ci: + return ci[0] + fuzzy = [p for p in files if p.suffix.lower() == ".md" and stem in p.name.lower()] + if len(fuzzy) == 1: + return fuzzy[0] + return canonical + def recon_doc(path: Path, *, label: str) -> DocRecon: p = Path(path) if not p.exists(): diff --git a/tests/test_recon_docs.py b/tests/test_recon_docs.py index 40c9573..585ed0e 100644 --- a/tests/test_recon_docs.py +++ b/tests/test_recon_docs.py @@ -1,5 +1,5 @@ from pathlib import Path -from lectern.recon_docs import recon_doc, DocRecon +from lectern.recon_docs import recon_doc, DocRecon, resolve_doc_path WRITEUP = """--- honor: SOLDIER-abc123 @@ -28,3 +28,50 @@ def test_recon_doc_missing(tmp_path): d = recon_doc(tmp_path / "nope.md", label="grimoire") assert d.present is False assert d.word_count == 0 + + +# --- resolve_doc_path: tolerant deliverable lookup (case/path variants) --- +# Students name the writeup inconsistently; the strict repo/ check marked +# real submissions not-present (false doc-✗) and dropped them from the snapshot. + +def test_resolve_exact_path_wins(tmp_path): + (tmp_path / "writeup.md").write_text("x") + assert resolve_doc_path(tmp_path, "writeup.md") == tmp_path / "writeup.md" + +def test_resolve_case_variant_at_root(tmp_path): + (tmp_path / "WRITEUP.md").write_text("x") + assert resolve_doc_path(tmp_path, "writeup.md") == tmp_path / "WRITEUP.md" + +def test_resolve_basename_in_subdir(tmp_path): + (tmp_path / "submission").mkdir() + (tmp_path / "submission" / "writeup.md").write_text("x") + assert resolve_doc_path(tmp_path, "writeup.md") == tmp_path / "submission" / "writeup.md" + +def test_resolve_fuzzy_md_containing_stem(tmp_path): + f = tmp_path / "CECS 378 Lab Writeup.md"; f.write_text("x") + assert resolve_doc_path(tmp_path, "writeup.md") == f + +def test_resolve_prefers_root_over_subdir(tmp_path): + (tmp_path / "WRITEUP.md").write_text("root") + (tmp_path / "sub").mkdir(); (tmp_path / "sub" / "writeup.md").write_text("deep") + assert resolve_doc_path(tmp_path, "writeup.md") == tmp_path / "WRITEUP.md" + +def test_resolve_ignores_git_dir(tmp_path): + g = tmp_path / ".git"; g.mkdir(); (g / "writeup.md").write_text("x") + assert resolve_doc_path(tmp_path, "writeup.md") == tmp_path / "writeup.md" # canonical (not found) + +def test_resolve_ambiguous_fuzzy_returns_canonical(tmp_path): + (tmp_path / "my writeup draft.md").write_text("a") + (tmp_path / "final writeup notes.md").write_text("b") + assert resolve_doc_path(tmp_path, "writeup.md") == tmp_path / "writeup.md" # ambiguous -> canonical + +def test_resolve_not_found_returns_canonical(tmp_path): + (tmp_path / "README.md").write_text("x") + assert resolve_doc_path(tmp_path, "writeup.md") == tmp_path / "writeup.md" + +def test_resolve_then_recon_doc_reads_variant_body(tmp_path): + # the integration the bug was about: a variant-named writeup is discovered + # and its body is available to snapshot. + (tmp_path / "WRITEUP.md").write_text("# Title\nreal content here\n") + d = recon_doc(resolve_doc_path(tmp_path, "writeup.md"), label="writeup") + assert d.present is True and "real content here" in d.body