opencode-bioresearcher 1.9.0 → 1.10.0
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/agents/bioresearcher-dr-worker.md +31 -22
- package/connector-meta.json +1 -1
- package/package.json +1 -1
- package/skills/bioresearcher-deep-research/SKILL.md +115 -61
- package/skills/bioresearcher-deep-research/references/analysis-methods.md +40 -2
- package/skills/bioresearcher-deep-research/references/best-practices.md +5 -5
- package/skills/bioresearcher-deep-research/references/citations.md +38 -23
- package/skills/bioresearcher-deep-research/references/clinical-trials.md +1 -1
- package/skills/bioresearcher-deep-research/references/report-template.md +16 -14
- package/skills/bioresearcher-deep-research/references/worker-protocol.md +89 -39
- package/skills/bioresearcher-deep-research/scripts/evidence-ledger.py +565 -3
- package/skills/bioresearcher-deep-research/scripts/vet-references.py +182 -11
|
@@ -10,6 +10,13 @@ Contract:
|
|
|
10
10
|
- Fill-missing-only merging: never overwrite a non-null stored value.
|
|
11
11
|
- Loud gaps: missing fields render as [MISSING field: ...]; unknown bib keys as
|
|
12
12
|
[MISSING record <key>] with a non-zero exit. Never fabricate.
|
|
13
|
+
- render is the single numbering authority: drafts cite [@key] markers; render
|
|
14
|
+
assigns numbers by first appearance, rewrites the markers, and appends the
|
|
15
|
+
References section from the ledger. Any unresolved key or [MISSING ...] entry
|
|
16
|
+
fails the render (exit 1) WITHOUT writing the output file.
|
|
17
|
+
- check validates a ledger end-to-end (exit 1 on quarantined lines; with
|
|
18
|
+
--markers also on unresolved/mixed [@key] markers in the given markdown
|
|
19
|
+
files - exit 1 iff anything this invocation validated failed).
|
|
13
20
|
- Verb banners: every subcommand prints "[evidence-ledger] <verb>: ..." so
|
|
14
21
|
test graders can anchor on deterministic stdout.
|
|
15
22
|
|
|
@@ -161,6 +168,14 @@ def normalize_record(raw, require_provenance_aspect=None) -> dict:
|
|
|
161
168
|
title = raw.get("title")
|
|
162
169
|
if title is not None:
|
|
163
170
|
title = str(title).strip() or None
|
|
171
|
+
if title is None and rtype != "article":
|
|
172
|
+
# biomcp record shapes often carry `name` instead of `title` (drugs,
|
|
173
|
+
# genes, diseases): fold it fill-only so bibliographies never render
|
|
174
|
+
# [MISSING field: title] for schema-shaped records. Articles keep
|
|
175
|
+
# their hard id requirement (a name-only article must not verify-gate).
|
|
176
|
+
name = raw.get("name")
|
|
177
|
+
if isinstance(name, str) and name.strip():
|
|
178
|
+
title = name.strip()
|
|
164
179
|
if not title and not ids:
|
|
165
180
|
raise ValueError("record needs a title or at least one id")
|
|
166
181
|
if raw.get("meta") is not None and not isinstance(raw.get("meta"), dict):
|
|
@@ -462,16 +477,23 @@ def cmd_add(args) -> int:
|
|
|
462
477
|
qpath = append_quarantine(path, led.quarantined)
|
|
463
478
|
warn(f"{len(led.quarantined)} pre-existing malformed line(s) quarantined to {qpath} (excluded from rewrite)")
|
|
464
479
|
accepted = rejected = 0
|
|
480
|
+
derived_keys: list = []
|
|
465
481
|
for raw in _parse_incoming(args):
|
|
466
482
|
try:
|
|
467
|
-
|
|
483
|
+
rec = normalize_record(raw, require_provenance_aspect=args.aspect)
|
|
468
484
|
except (ValueError, TypeError) as e:
|
|
469
485
|
rejected += 1
|
|
470
486
|
warn(f"rejected record ({e}): {json.dumps(raw, ensure_ascii=False)[:200]}")
|
|
471
487
|
continue
|
|
488
|
+
led.upsert(rec)
|
|
489
|
+
derived_keys.append(rec["key"])
|
|
472
490
|
accepted += 1
|
|
473
491
|
led.write(path)
|
|
474
492
|
banner("add", f"{accepted} record(s) accepted, {rejected} rejected -> {path}")
|
|
493
|
+
if derived_keys:
|
|
494
|
+
# Echo the derived canonical keys so workers cite exactly what the
|
|
495
|
+
# ledger keyed (dataset/alias-derived keys are otherwise guesswork).
|
|
496
|
+
banner("add", f"derived keys: {', '.join(derived_keys)}")
|
|
475
497
|
return 0
|
|
476
498
|
|
|
477
499
|
|
|
@@ -544,6 +566,8 @@ def cmd_verify(args) -> int:
|
|
|
544
566
|
docs = fetch_ncbi_summaries(pmids, timeout=args.timeout) if pmids else {}
|
|
545
567
|
now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
546
568
|
filled = title_fixed = clean = unreachable = 0
|
|
569
|
+
already = sum(1 for r in led.records()
|
|
570
|
+
if r.get("type") in verifiable and (r.get("ids") or {}).get("pmid") and r.get("verified"))
|
|
547
571
|
skipped = sum(1 for r in led.records() if r.get("type") not in verifiable)
|
|
548
572
|
for rec in led.records():
|
|
549
573
|
if rec.get("type") not in verifiable:
|
|
@@ -599,6 +623,7 @@ def cmd_verify(args) -> int:
|
|
|
599
623
|
f"{len(docs)} PubMed record(s) checked; {filled} backfilled, {title_fixed} title(s) set, "
|
|
600
624
|
f"{clean} verified clean, {unreachable} unreachable (fail-safe, left unverified); "
|
|
601
625
|
f"{skipped} record(s) of unverified type(s) skipped (no verifier configured)"
|
|
626
|
+
+ (f"; {already} already verified (not rechecked)" if already else "")
|
|
602
627
|
+ ("; --apply written" if args.apply else " (dry-run, no changes written)"),
|
|
603
628
|
)
|
|
604
629
|
return 0
|
|
@@ -696,6 +721,14 @@ def _close(s: str) -> str:
|
|
|
696
721
|
return s if s.endswith(".") else s + "."
|
|
697
722
|
|
|
698
723
|
|
|
724
|
+
def _close_title(v) -> str:
|
|
725
|
+
"""Render a title and close with a single period (registry titles often
|
|
726
|
+
already end with one - never emit 'Title..')."""
|
|
727
|
+
if v in (None, ""):
|
|
728
|
+
return f"[MISSING field: title]"
|
|
729
|
+
return _close(str(v).strip().rstrip("."))
|
|
730
|
+
|
|
731
|
+
|
|
699
732
|
def render_article(rec: dict, expand: bool) -> str:
|
|
700
733
|
ids = rec.get("ids") or {}
|
|
701
734
|
title = _close(_need(rec, "title").strip())
|
|
@@ -716,7 +749,7 @@ def render_trial(rec: dict) -> str:
|
|
|
716
749
|
ids = rec.get("ids") or {}
|
|
717
750
|
meta = rec.get("meta") or {}
|
|
718
751
|
nct = ids.get("nct") or "[MISSING field: ids.nct]"
|
|
719
|
-
out = f"{nct}: {
|
|
752
|
+
out = f"{nct}: {_close_title(rec.get('title'))}"
|
|
720
753
|
phase = str(meta.get("phase") or "").strip().rstrip(".")
|
|
721
754
|
if phase:
|
|
722
755
|
# Workers copy phase verbatim from biomcp/CTgov ("Phase 2", "PHASE3",
|
|
@@ -771,7 +804,7 @@ def render_drug(rec: dict) -> str:
|
|
|
771
804
|
"[MISSING field: ids.chembl|chebi|unii]")
|
|
772
805
|
ind = f" Indication: {meta['indication']}." if meta.get("indication") else ""
|
|
773
806
|
url = rec.get("url") or ""
|
|
774
|
-
return f"{
|
|
807
|
+
return f"{_close_title(rec.get('title'))}{ind} {dbid}. {url}".strip()
|
|
775
808
|
|
|
776
809
|
|
|
777
810
|
def render_disease(rec: dict) -> str:
|
|
@@ -903,6 +936,298 @@ def cmd_stats(args) -> int:
|
|
|
903
936
|
return 0
|
|
904
937
|
|
|
905
938
|
|
|
939
|
+
# ---------------------------------------------------------------------------
|
|
940
|
+
# Cite-key rendering (draft -> numbered report; the single numbering authority)
|
|
941
|
+
# ---------------------------------------------------------------------------
|
|
942
|
+
|
|
943
|
+
# A cite-key marker: [@ns:value] or [@a; @b]. A bracket is a citation group
|
|
944
|
+
# only if EVERY non-empty token is a recognized namespace with a shape-valid
|
|
945
|
+
# value; anything else (prose [@home], pandoc [@Chapman2011], [@gene:BRAF]
|
|
946
|
+
# symbols) passes through verbatim.
|
|
947
|
+
MARKER_RE = re.compile(r"\[@([^\[\]]+)\]")
|
|
948
|
+
|
|
949
|
+
# Canonical value shapes per key namespace (see KEY_NAMESPACES). Namespaces
|
|
950
|
+
# without an entry accept any non-empty value.
|
|
951
|
+
NS_VALUE_SHAPES = {
|
|
952
|
+
"pmid": re.compile(r"^\d+$"),
|
|
953
|
+
"doi": re.compile(r"^10\.\S+$", re.IGNORECASE),
|
|
954
|
+
"pmcid": re.compile(r"^PMC\d+$", re.IGNORECASE),
|
|
955
|
+
"nct": re.compile(r"^NCT\d+$", re.IGNORECASE),
|
|
956
|
+
"patent": re.compile(r"^[A-Z]{2}\d+", re.IGNORECASE),
|
|
957
|
+
"geo": re.compile(r"^GS[ED]\d+$", re.IGNORECASE),
|
|
958
|
+
"sra": re.compile(r"^SR[RP]\d+$", re.IGNORECASE),
|
|
959
|
+
"gb": re.compile(r"^[A-Z]{2,}\d+(\.\d+)?$", re.IGNORECASE),
|
|
960
|
+
"gene": re.compile(r"^\d+$"),
|
|
961
|
+
"clinvar": re.compile(r"^\d+$"),
|
|
962
|
+
"chembl": re.compile(r"^CHEMBL\d+$", re.IGNORECASE),
|
|
963
|
+
"chebi": re.compile(r"^CHEBI[:_ ]?\d+$", re.IGNORECASE),
|
|
964
|
+
"unii": re.compile(r"^[A-Z0-9]{4,10}$", re.IGNORECASE),
|
|
965
|
+
"mondo": re.compile(r"^MONDO[:_ ]?\d+$", re.IGNORECASE),
|
|
966
|
+
"doid": re.compile(r"^DOID[:_ ]?\d+$", re.IGNORECASE),
|
|
967
|
+
"omim": re.compile(r"^\d{5,7}$"),
|
|
968
|
+
"efo": re.compile(r"^[A-Z]{2,}[_:]?\d+", re.IGNORECASE),
|
|
969
|
+
"url": re.compile(r"^[0-9a-f]{16}$", re.IGNORECASE),
|
|
970
|
+
"title": re.compile(r"^[0-9a-f]{16}$", re.IGNORECASE),
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
REFS_STRIP_RE = re.compile(
|
|
974
|
+
r"(?ims)^#{1,6}[ \t]*(?:references?|bibliography|literature cited|citations)\b[^\n]*\n"
|
|
975
|
+
r".*?(?=\n#{1,6}[ \t]*\S|\Z)"
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
# Hand-typed numeric citation brackets (render's input is authored with
|
|
979
|
+
# [@key] markers, so any [N]/[N, M]/[N-M] bracket in a draft is suspect).
|
|
980
|
+
# Negative lookarounds exclude markdown links [1](url), reference defs [1]:,
|
|
981
|
+
# and wikilinks [[1,2]].
|
|
982
|
+
HAND_TYPED_NUM_RE = re.compile(r"(?<!\[)\[\d{1,3}(?:\s*[,\u2013\-]\s*\d{1,3})*\](?![:(\[])")
|
|
983
|
+
|
|
984
|
+
CODE_BLOCK_RE = re.compile(r"(?ms)^(?:```|~~~)[^\n]*\n.*?^(?:```|~~~)[ \t]*$")
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def mask_code_blocks(text: str) -> str:
|
|
988
|
+
"""Fenced code blocks -> same-length newline filler (offsets preserved), so
|
|
989
|
+
section detection and marker scans never fire on example/documentation
|
|
990
|
+
content inside fences."""
|
|
991
|
+
return CODE_BLOCK_RE.sub(lambda m: "\n" * (m.end() - m.start()), text)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def classify_token(token: str):
|
|
995
|
+
"""Classify a marker token: ('cite', ns, value) | ('shape', ns, value) | ('plain', tok, None)."""
|
|
996
|
+
tok = token.strip()
|
|
997
|
+
if tok.startswith("@"):
|
|
998
|
+
tok = tok[1:].strip()
|
|
999
|
+
if ":" not in tok:
|
|
1000
|
+
return ("plain", tok, None)
|
|
1001
|
+
ns, _, value = tok.partition(":")
|
|
1002
|
+
ns, value = ns.strip().lower(), value.strip()
|
|
1003
|
+
if not value or ns not in KEY_NAMESPACES:
|
|
1004
|
+
return ("plain", tok, None)
|
|
1005
|
+
shape = NS_VALUE_SHAPES.get(ns)
|
|
1006
|
+
if shape is None or shape.match(value):
|
|
1007
|
+
return ("cite", ns, value)
|
|
1008
|
+
return ("shape", ns, value)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _canonical_marker_key(ns: str, value: str) -> str:
|
|
1012
|
+
try:
|
|
1013
|
+
return f"{ns}:{_canonicalize_id_value(ns, value)}"
|
|
1014
|
+
except ValueError:
|
|
1015
|
+
return f"{ns}:{value}"
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def resolve_key(led: Ledger, ns: str, value: str):
|
|
1019
|
+
"""Resolve a marker key to a ledger key: direct -> secondary-id twin -> None."""
|
|
1020
|
+
cand = _canonical_marker_key(ns, value)
|
|
1021
|
+
if cand in led.by_key:
|
|
1022
|
+
return cand
|
|
1023
|
+
if ns in ("doi", "pmcid"):
|
|
1024
|
+
# merge promotes doi:/pmcid: twins to their pmid key; the marker may
|
|
1025
|
+
# legitimately cite the pre-promotion namespace.
|
|
1026
|
+
canon_val = cand.split(":", 1)[1].lower()
|
|
1027
|
+
twin = led.sec_index.get(f"{ns}:{canon_val}")
|
|
1028
|
+
if twin and twin in led.by_key:
|
|
1029
|
+
return twin
|
|
1030
|
+
return None
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _compress_numbers(nums: list) -> str:
|
|
1034
|
+
"""[1,2,3,5] -> '1-3, 5'; [1,2] -> '1, 2'."""
|
|
1035
|
+
nums = sorted(set(nums))
|
|
1036
|
+
parts, i = [], 0
|
|
1037
|
+
while i < len(nums):
|
|
1038
|
+
j = i
|
|
1039
|
+
while j + 1 < len(nums) and nums[j + 1] == nums[j] + 1:
|
|
1040
|
+
j += 1
|
|
1041
|
+
if j - i >= 2:
|
|
1042
|
+
parts.append(f"{nums[i]}-{nums[j]}")
|
|
1043
|
+
elif j == i + 1:
|
|
1044
|
+
parts.append(f"{nums[i]}, {nums[j]}")
|
|
1045
|
+
else:
|
|
1046
|
+
parts.append(str(nums[i]))
|
|
1047
|
+
i = j + 1
|
|
1048
|
+
return ", ".join(parts)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def cmd_render(args) -> int:
|
|
1052
|
+
led = read_ledger(Path(args.ledger))
|
|
1053
|
+
text = Path(args.draft).read_text(encoding="utf-8-sig")
|
|
1054
|
+
out_path = Path(args.out)
|
|
1055
|
+
|
|
1056
|
+
# Strip pre-existing References-like sections, fence-aware: detect on a
|
|
1057
|
+
# code-block-masked copy (offsets preserved), cut from the real text in
|
|
1058
|
+
# reverse so earlier spans stay valid.
|
|
1059
|
+
masked = mask_code_blocks(text)
|
|
1060
|
+
had_entries = False
|
|
1061
|
+
for m in reversed(list(REFS_STRIP_RE.finditer(masked))):
|
|
1062
|
+
chunk = text[m.start():m.end()]
|
|
1063
|
+
if re.search(r"(?m)^\s*[-*]?\s*\[\d+\]", chunk):
|
|
1064
|
+
had_entries = True
|
|
1065
|
+
if "[@" in chunk:
|
|
1066
|
+
warn("a pre-existing References-like section contained [@key] marker(s); "
|
|
1067
|
+
"the section was stripped - cite those sources in the body instead")
|
|
1068
|
+
text = text[:m.start()] + text[m.end():]
|
|
1069
|
+
body = text.rstrip() + "\n"
|
|
1070
|
+
|
|
1071
|
+
failures: list = []
|
|
1072
|
+
numbers: dict = {}
|
|
1073
|
+
order: list = []
|
|
1074
|
+
|
|
1075
|
+
def render_marker(bracket: str) -> str:
|
|
1076
|
+
inner = bracket[2:-1] if bracket.endswith("]") else bracket[2:]
|
|
1077
|
+
tokens = [t for t in inner.split(";") if t.strip()]
|
|
1078
|
+
kinds = [classify_token(t) for t in tokens]
|
|
1079
|
+
cites = [k for k in kinds if k[0] == "cite"]
|
|
1080
|
+
if not tokens or not cites:
|
|
1081
|
+
for kind, ns, _ in kinds:
|
|
1082
|
+
if kind == "shape":
|
|
1083
|
+
warn(f"bracket {bracket!r} uses the {ns}: namespace but its value is not shape-valid; left verbatim")
|
|
1084
|
+
return bracket
|
|
1085
|
+
if any(k[0] != "cite" for k in kinds):
|
|
1086
|
+
# A group with at least one real cite-key must not silently drop
|
|
1087
|
+
# its non-citation tokens - that would lose citations quietly.
|
|
1088
|
+
failures.append(f"mixed citation group {bracket!r}: every token must be a cite-key")
|
|
1089
|
+
return bracket
|
|
1090
|
+
keys = []
|
|
1091
|
+
for _, ns, value in cites:
|
|
1092
|
+
key = resolve_key(led, ns, value)
|
|
1093
|
+
if key is None:
|
|
1094
|
+
suggestions = [k for k in sorted(led.by_key) if k.startswith(ns + ":")][:5]
|
|
1095
|
+
failures.append(
|
|
1096
|
+
f"unknown citation key {ns}:{value}"
|
|
1097
|
+
+ (f" (did you mean: {', '.join(suggestions)}?)" if suggestions else "")
|
|
1098
|
+
)
|
|
1099
|
+
continue
|
|
1100
|
+
keys.append(key)
|
|
1101
|
+
if failures:
|
|
1102
|
+
return bracket
|
|
1103
|
+
for key in keys:
|
|
1104
|
+
if key not in numbers:
|
|
1105
|
+
numbers[key] = len(numbers) + 1
|
|
1106
|
+
order.append(key)
|
|
1107
|
+
return "[" + _compress_numbers([numbers[k] for k in keys]) + "]"
|
|
1108
|
+
|
|
1109
|
+
# Marker substitution, fence-aware: scan the masked copy (offsets equal),
|
|
1110
|
+
# splice replacements into the real body.
|
|
1111
|
+
masked_body = mask_code_blocks(body)
|
|
1112
|
+
# Hand-typed numeric brackets in the DRAFT are the residual leak class:
|
|
1113
|
+
# render owns numbering, so warn loudly (non-fatal - prose ranges like
|
|
1114
|
+
# [140, 155] are legitimate; links/wikilinks/reference defs are excluded).
|
|
1115
|
+
hand_typed = [m.start() for m in HAND_TYPED_NUM_RE.finditer(masked_body)]
|
|
1116
|
+
if hand_typed:
|
|
1117
|
+
lines = sorted({masked_body.count("\n", 0, pos) + 1 for pos in hand_typed[:5]})
|
|
1118
|
+
warn(f"draft contains {len(hand_typed)} hand-typed numeric citation bracket(s) "
|
|
1119
|
+
f"(first at line(s) {lines}): render owns numbering - remove hand-typed [N] brackets from the draft")
|
|
1120
|
+
parts: list = []
|
|
1121
|
+
last = 0
|
|
1122
|
+
for m in MARKER_RE.finditer(masked_body):
|
|
1123
|
+
parts.append(body[last:m.start()])
|
|
1124
|
+
parts.append(render_marker(body[m.start():m.end()]))
|
|
1125
|
+
last = m.end()
|
|
1126
|
+
parts.append(body[last:])
|
|
1127
|
+
rendered_body = "".join(parts)
|
|
1128
|
+
|
|
1129
|
+
# Double-render guard: an already-rendered document has no markers left.
|
|
1130
|
+
if not order and had_entries:
|
|
1131
|
+
banner("render", "FAILED: no [@key] citation markers found, but a References section with entries was present "
|
|
1132
|
+
"(already-rendered document?); nothing written")
|
|
1133
|
+
return 1
|
|
1134
|
+
if failures:
|
|
1135
|
+
for f in failures:
|
|
1136
|
+
warn(f"unresolved citation: {f}")
|
|
1137
|
+
banner("render", f"FAILED: {len(failures)} unresolved citation key(s); nothing written")
|
|
1138
|
+
return 1
|
|
1139
|
+
|
|
1140
|
+
entries = []
|
|
1141
|
+
missing = []
|
|
1142
|
+
for key in order:
|
|
1143
|
+
entry = render_record(led.by_key[key], args.expand_pages)
|
|
1144
|
+
if "[MISSING" in entry:
|
|
1145
|
+
missing.append(key)
|
|
1146
|
+
entries.append(f"[{numbers[key]}] {entry}")
|
|
1147
|
+
if missing:
|
|
1148
|
+
for key in missing:
|
|
1149
|
+
warn(f"record {key} would render with [MISSING ...] markers (incomplete ledger record)")
|
|
1150
|
+
banner("render", f"FAILED: {len(missing)} record(s) render with [MISSING ...] gaps "
|
|
1151
|
+
f"({', '.join(missing[:5])}{' ...' if len(missing) > 5 else ''}); nothing written")
|
|
1152
|
+
return 1
|
|
1153
|
+
|
|
1154
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1155
|
+
refs = "\n".join(entries) if entries else "(no cited sources)"
|
|
1156
|
+
out_path.write_text(rendered_body + "\n## References\n\n" + refs + "\n", encoding="utf-8")
|
|
1157
|
+
banner("render", f"{len(order)} citation(s) numbered, {len(entries)} reference(s) rendered from {args.ledger} -> {args.out}")
|
|
1158
|
+
return 0
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _check_markers(led: Ledger, paths: list) -> int:
|
|
1162
|
+
"""Cross-validate [@key] markers in markdown files against the ledger.
|
|
1163
|
+
|
|
1164
|
+
Mirrors render's group semantics exactly: groups with zero cite tokens are
|
|
1165
|
+
prose (ignored); all-cite groups must fully resolve (direct or sec_index);
|
|
1166
|
+
groups mixing >=1 cite token with any non-cite token are failures;
|
|
1167
|
+
shape-kind tokens (recognized namespace, invalid shape) warn only.
|
|
1168
|
+
Returns the number of failures; a missing marker file counts as one
|
|
1169
|
+
(never a vacuous pass)."""
|
|
1170
|
+
failures = 0
|
|
1171
|
+
for raw in paths:
|
|
1172
|
+
p = Path(raw)
|
|
1173
|
+
if not p.is_file():
|
|
1174
|
+
banner("check", f"markers: marker file not found: {p}")
|
|
1175
|
+
failures += 1
|
|
1176
|
+
continue
|
|
1177
|
+
text = p.read_text(encoding="utf-8-sig")
|
|
1178
|
+
masked = mask_code_blocks(text)
|
|
1179
|
+
groups = resolved = 0
|
|
1180
|
+
for m in MARKER_RE.finditer(masked):
|
|
1181
|
+
tokens = [t for t in m.group(1).split(";") if t.strip()]
|
|
1182
|
+
kinds = [classify_token(t) for t in tokens]
|
|
1183
|
+
cites = [k for k in kinds if k[0] == "cite"]
|
|
1184
|
+
if not tokens or not cites:
|
|
1185
|
+
continue # prose bracket - render leaves it verbatim
|
|
1186
|
+
line = masked.count("\n", 0, m.start()) + 1
|
|
1187
|
+
groups += 1
|
|
1188
|
+
if any(k[0] != "cite" for k in kinds):
|
|
1189
|
+
warn(f"{p.name}:{line}: mixed citation group {m.group(0)!r}: every token must be a cite-key")
|
|
1190
|
+
failures += 1
|
|
1191
|
+
continue
|
|
1192
|
+
for kind, ns, value in cites:
|
|
1193
|
+
if kind == "shape":
|
|
1194
|
+
warn(f"{p.name}:{line}: bracket {m.group(0)!r} uses the {ns}: namespace but its value is not shape-valid (left verbatim)")
|
|
1195
|
+
continue
|
|
1196
|
+
key = resolve_key(led, ns, value)
|
|
1197
|
+
if key is None:
|
|
1198
|
+
warn(f"{p.name}:{line}: unresolved marker {ns}:{value} (no matching ledger record)")
|
|
1199
|
+
failures += 1
|
|
1200
|
+
else:
|
|
1201
|
+
resolved += 1
|
|
1202
|
+
print(f"markers[{p.name}]: {groups} citation group(s), {resolved} marker(s) resolved")
|
|
1203
|
+
return failures
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def cmd_check(args) -> int:
|
|
1207
|
+
markers = getattr(args, "markers", None) or []
|
|
1208
|
+
led = read_ledger(Path(args.file))
|
|
1209
|
+
types: dict = {}
|
|
1210
|
+
for rec in led.records():
|
|
1211
|
+
types[rec.get("type", "?")] = types.get(rec.get("type", "?"), 0) + 1
|
|
1212
|
+
for k in sorted(led.by_key):
|
|
1213
|
+
print(k)
|
|
1214
|
+
status = "OK" if not led.quarantined else "FAIL"
|
|
1215
|
+
banner("check", f"{len(led.by_key)} record(s) across {len(types)} type(s) "
|
|
1216
|
+
f"({', '.join(f'{t}:{c}' for t, c in sorted(types.items())) or 'none'}); "
|
|
1217
|
+
f"quarantined {len(led.quarantined)}; {status}")
|
|
1218
|
+
if led.quarantined:
|
|
1219
|
+
for q in led.quarantined:
|
|
1220
|
+
warn(f"quarantined {q.get('file')}:{q.get('line')}: {q.get('error')}")
|
|
1221
|
+
return 1
|
|
1222
|
+
if markers:
|
|
1223
|
+
marker_failures = _check_markers(led, markers)
|
|
1224
|
+
banner("check", f"markers: {marker_failures} problem(s) across {len(markers)} file(s); "
|
|
1225
|
+
f"{'FAIL' if marker_failures else 'OK'}")
|
|
1226
|
+
if marker_failures:
|
|
1227
|
+
return 1
|
|
1228
|
+
return 0
|
|
1229
|
+
|
|
1230
|
+
|
|
906
1231
|
# ---------------------------------------------------------------------------
|
|
907
1232
|
# Hermetic selftest (CI; no network)
|
|
908
1233
|
# ---------------------------------------------------------------------------
|
|
@@ -1345,6 +1670,230 @@ def selftest() -> int:
|
|
|
1345
1670
|
assert len(led3.by_key) == 1 and not led3.quarantined, f"BOM re-read failed: {led3.quarantined}"
|
|
1346
1671
|
check("batch/title-rules", st_batch_and_title_rules)
|
|
1347
1672
|
|
|
1673
|
+
# ---- name -> title fold (biomcp `name`-shaped records) -------------
|
|
1674
|
+
def st_name_fold():
|
|
1675
|
+
f = d / "nf.jsonl"
|
|
1676
|
+
# drug carrying `name` instead of `title` (biomcp drug_get shape)
|
|
1677
|
+
drug = {"type": "drug", "ids": {"chembl": "CHEMBL1229517"}, "name": "vemurafenib",
|
|
1678
|
+
"meta": {"indication": "BRAF V600E-mutant melanoma"}, "provenance": [{"aspect": "a"}]}
|
|
1679
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(drug), stdin=False, aspect=None))
|
|
1680
|
+
led = read_ledger(f)
|
|
1681
|
+
assert "chembl:CHEMBL1229517" in led.by_key, "name-bearing drug not accepted"
|
|
1682
|
+
assert led.by_key["chembl:CHEMBL1229517"]["title"] == "vemurafenib", "name not folded into title"
|
|
1683
|
+
# fold is idempotent across re-reads and preserves the original field
|
|
1684
|
+
before = json.dumps(led.by_key["chembl:CHEMBL1229517"], sort_keys=True)
|
|
1685
|
+
after = json.dumps(read_ledger(f).by_key["chembl:CHEMBL1229517"], sort_keys=True)
|
|
1686
|
+
assert before == after, "name fold not idempotent on re-read"
|
|
1687
|
+
assert led.by_key["chembl:CHEMBL1229517"].get("name") == "vemurafenib", "original name field lost"
|
|
1688
|
+
# gene name-only now accepted too (acceptance widening, CHANGELOG-noted)
|
|
1689
|
+
gene = {"type": "gene", "ids": {"entrez_id": "673"}, "name": "B-Raf proto-oncogene",
|
|
1690
|
+
"provenance": [{"aspect": "a"}]}
|
|
1691
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(gene), stdin=False, aspect=None))
|
|
1692
|
+
assert "gene:673" in read_ledger(f).by_key, "name-bearing gene not accepted"
|
|
1693
|
+
# article keeps its hard id requirement: name-only rejected
|
|
1694
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "article", "name": "x", "ids": {}}', stdin=False, aspect=None))
|
|
1695
|
+
# web/dataset keep their hard identity fields: name-only rejected
|
|
1696
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "web", "name": "Just a page", "ids": {}}', stdin=False, aspect=None))
|
|
1697
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "dataset", "name": "Just a series", "ids": {}}', stdin=False, aspect=None))
|
|
1698
|
+
keys = set(read_ledger(f).by_key)
|
|
1699
|
+
assert not any(k.startswith("title:") for k in keys), "name-only article/web/dataset must not derive title keys"
|
|
1700
|
+
assert len(keys) == 2, f"name-fold acceptance boundary wrong: {sorted(keys)}"
|
|
1701
|
+
# renders clean (no [MISSING field: title])
|
|
1702
|
+
_, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="chembl:CHEMBL1229517", expand_pages=False, offset=0))
|
|
1703
|
+
assert "[MISSING" not in out.splitlines()[0], f"drug still renders MISSING: {out.splitlines()[0]}"
|
|
1704
|
+
assert "Vemurafenib" in out.splitlines()[0] or "vemurafenib" in out.splitlines()[0], out.splitlines()[0]
|
|
1705
|
+
check("name-fold", st_name_fold)
|
|
1706
|
+
|
|
1707
|
+
# ---- render: cite-key markers -> numbered citations + References ---
|
|
1708
|
+
def _render_ledger(d):
|
|
1709
|
+
f = d / "render.jsonl"
|
|
1710
|
+
batch = [
|
|
1711
|
+
_fixture_article(),
|
|
1712
|
+
_fixture_article(pmid="30000001", doi="10.1000/r1", title="Render one", ids={"pmid": "30000001", "doi": "10.1000/r1", "pmcid": "PMC3000001"}),
|
|
1713
|
+
_fixture_article(pmid="30000002", doi="10.1000/r2", title="Render two", ids={"pmid": "30000002", "doi": "10.1000/r2", "pmcid": "PMC3000002"}),
|
|
1714
|
+
_fixture_article(pmid="30000003", doi="10.1000/r3", title="Render three", ids={"pmid": "30000003", "doi": "10.1000/r3", "pmcid": "PMC3000003"}),
|
|
1715
|
+
{"key": "nct:NCT04280705", "type": "trial", "ids": {"nct": "NCT04280705"},
|
|
1716
|
+
"title": "Encorafenib Plus Cetuximab", "meta": {"phase": "Phase 2", "sponsor": "Pfizer", "status": "Completed"},
|
|
1717
|
+
"provenance": [{"aspect": "a"}]},
|
|
1718
|
+
{"type": "drug", "ids": {"chembl": "CHEMBL1229517"}, "name": "vemurafenib",
|
|
1719
|
+
"meta": {"indication": "BRAF V600E-mutant melanoma"}, "provenance": [{"aspect": "a"}]},
|
|
1720
|
+
]
|
|
1721
|
+
old_stdin, sys.stdin = sys.stdin, io.StringIO(json.dumps(batch))
|
|
1722
|
+
try:
|
|
1723
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=None, stdin=True, aspect=None))
|
|
1724
|
+
finally:
|
|
1725
|
+
sys.stdin = old_stdin
|
|
1726
|
+
return f
|
|
1727
|
+
|
|
1728
|
+
def st_render():
|
|
1729
|
+
f = _render_ledger(d)
|
|
1730
|
+
draft = d / "report.draft.md"
|
|
1731
|
+
draft.write_text(
|
|
1732
|
+
"# Report\n\n"
|
|
1733
|
+
"Vemurafenib [@chembl:CHEMBL1229517] improves survival [@pmid:21639808].\n\n"
|
|
1734
|
+
"Prose [@home] and pandoc [@Chapman2011] and symbol-ish [@gene:BRAF] stay verbatim.\n\n"
|
|
1735
|
+
"Trial plus article [@nct:NCT04280705; @pmid:21639808] group. Again [@pmid:21639808].\n\n"
|
|
1736
|
+
"Three more [@pmid:30000001; @pmid:30000002; @pmid:30000003] in one group.\n",
|
|
1737
|
+
encoding="utf-8",
|
|
1738
|
+
)
|
|
1739
|
+
out = d / "final.md"
|
|
1740
|
+
rc, stdout = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(draft), out=str(out), expand_pages=False))
|
|
1741
|
+
assert rc == 0, f"render failed: {stdout}"
|
|
1742
|
+
text = out.read_text(encoding="utf-8")
|
|
1743
|
+
assert "Vemurafenib [1] improves survival [2]." in text, f"numbering wrong:\n{text}"
|
|
1744
|
+
assert "[@home]" in text and "[@Chapman2011]" in text and "[@gene:BRAF]" in text, "prose brackets rewritten"
|
|
1745
|
+
assert "[2, 3]" in text, f"group not sorted/compressed: {text}"
|
|
1746
|
+
assert "Again [2]." in text, "duplicate key not reusing its number"
|
|
1747
|
+
assert "[4-6]" in text, f"consecutive run not range-compressed: {text}"
|
|
1748
|
+
assert "## References" in text and "[1] vemurafenib." in text and "[2] Chapman PB" in text
|
|
1749
|
+
# registry titles ending in a period never render doubled ("Title.. Status")
|
|
1750
|
+
tdot = d / "tdot.jsonl"
|
|
1751
|
+
tdot.write_text(json.dumps({
|
|
1752
|
+
"key": "nct:NCT01844986", "type": "trial", "ids": {"nct": "NCT01844986"},
|
|
1753
|
+
"title": "Olaparib Maintenance Monotherapy in Patients With BRCA Mutated Ovarian Cancer Following First Line Platinum Based Chemotherapy.",
|
|
1754
|
+
"meta": {"status": "ACTIVE_NOT_RECRUITING"},
|
|
1755
|
+
"provenance": [{"aspect": "a"}]}) + "\n", encoding="utf-8")
|
|
1756
|
+
_, tout = _capture(cmd_bib, argparse.Namespace(file=str(tdot), keys="nct:NCT01844986", expand_pages=False, offset=0))
|
|
1757
|
+
tline = tout.splitlines()[0]
|
|
1758
|
+
assert "Chemotherapy. Status:" in tline and ".." not in tline, f"double period rendered: {tline}"
|
|
1759
|
+
assert "[MISSING" not in text
|
|
1760
|
+
refs = text.split("## References", 1)[1]
|
|
1761
|
+
nums = re.findall(r"(?m)^\[(\d+)\]", refs)
|
|
1762
|
+
assert nums == [str(i) for i in range(1, 7)], f"bibliography not 1..N contiguous: {nums}"
|
|
1763
|
+
# idempotent re-render of the SAME draft
|
|
1764
|
+
out2 = d / "final2.md"
|
|
1765
|
+
_capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(draft), out=str(out2), expand_pages=False))
|
|
1766
|
+
assert out2.read_text(encoding="utf-8") == text, "re-render of the same draft is not idempotent"
|
|
1767
|
+
# sec-index: doi: marker resolves to the promoted pmid twin
|
|
1768
|
+
doi_draft = d / "doi.draft.md"
|
|
1769
|
+
doi_draft.write_text("Only one [@doi:10.1056/nejmoa1103782].\n", encoding="utf-8")
|
|
1770
|
+
doi_out = d / "doi.md"
|
|
1771
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(doi_draft), out=str(doi_out), expand_pages=False))
|
|
1772
|
+
dtext = doi_out.read_text(encoding="utf-8")
|
|
1773
|
+
assert rc == 0 and "Only one [1]." in dtext and "PMID: 21639808." in dtext, f"doi twin resolution failed: {dtext}"
|
|
1774
|
+
assert len(re.findall(r"(?m)^\[\d+\]", dtext.split("## References", 1)[1])) == 1
|
|
1775
|
+
# unknown key: exit 1, output NOT written
|
|
1776
|
+
bad = d / "bad.draft.md"
|
|
1777
|
+
bad.write_text("Broken [@pmid:99999999].\n", encoding="utf-8")
|
|
1778
|
+
bad_out = d / "bad.md"
|
|
1779
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(bad), out=str(bad_out), expand_pages=False))
|
|
1780
|
+
assert rc != 0 and not bad_out.exists(), "unknown key must exit 1 without writing output"
|
|
1781
|
+
# [MISSING ...] entry: exit 1, output NOT written
|
|
1782
|
+
tonly = {"type": "trial", "title": "Title-only degraded trial", "provenance": [{"aspect": "a"}]}
|
|
1783
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(tonly), stdin=False, aspect=None))
|
|
1784
|
+
tkey = next(k for k in read_ledger(f).by_key if k.startswith("title:"))
|
|
1785
|
+
miss = d / "miss.draft.md"
|
|
1786
|
+
miss.write_text(f"Degraded [@{tkey}] cite.\n", encoding="utf-8")
|
|
1787
|
+
miss_out = d / "miss.md"
|
|
1788
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(miss), out=str(miss_out), expand_pages=False))
|
|
1789
|
+
assert rc != 0 and not miss_out.exists(), "MISSING-rendering record must exit 1 without writing output"
|
|
1790
|
+
# double-render guard: rendering an already-rendered report fails
|
|
1791
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(out), out=str(d / "double.md"), expand_pages=False))
|
|
1792
|
+
assert rc != 0, "double-render must fail (no markers, References present)"
|
|
1793
|
+
assert not (d / "double.md").exists()
|
|
1794
|
+
# fence-awareness: a fenced example References section / marker is
|
|
1795
|
+
# never stripped and never numbered
|
|
1796
|
+
fence_draft = d / "fence.draft.md"
|
|
1797
|
+
fence_draft.write_text(
|
|
1798
|
+
"# F\n\nReal cite [@pmid:21639808].\n\nExample (do not touch):\n\n```\n"
|
|
1799
|
+
"## References\n\n[1] example entry.\n\nCite like [@pmid:21639808].\n```\n\nTail kept.\n",
|
|
1800
|
+
encoding="utf-8",
|
|
1801
|
+
)
|
|
1802
|
+
fence_out = d / "fence.md"
|
|
1803
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(fence_draft), out=str(fence_out), expand_pages=False))
|
|
1804
|
+
ftext = fence_out.read_text(encoding="utf-8")
|
|
1805
|
+
assert rc == 0, "fenced content must not break render"
|
|
1806
|
+
assert "## References\n\n[1] example entry." in ftext, "fenced References example was stripped"
|
|
1807
|
+
assert "[@pmid:21639808]" in ftext, "marker inside a fence was rewritten"
|
|
1808
|
+
assert "Tail kept." in ftext, "content after a fenced References example was truncated"
|
|
1809
|
+
assert "Real cite [1]." in ftext and ftext.rstrip().endswith("[1] Chapman PB, Hauschild A, Robert C, et al Improved survival with vemurafenib in melanoma with BRAF V600E mutation. N Engl J Med. 2011;364(26):2507-16. DOI: 10.1056/nejmoa1103782. PMID: 21639808."), \
|
|
1810
|
+
f"real marker/References wrong:\n{ftext}"
|
|
1811
|
+
# mixed citation group: at least one cite-key + a non-citation token
|
|
1812
|
+
# must hard-fail (never silently drop the citation)
|
|
1813
|
+
mix = d / "mix.draft.md"
|
|
1814
|
+
mix.write_text("Mixed [@pmid:21639808; see note] group.\n", encoding="utf-8")
|
|
1815
|
+
mix_out = d / "mix.md"
|
|
1816
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(mix), out=str(mix_out), expand_pages=False))
|
|
1817
|
+
assert rc != 0 and not mix_out.exists(), "mixed citation group must exit 1 without writing output"
|
|
1818
|
+
check("render", st_render)
|
|
1819
|
+
|
|
1820
|
+
# ---- check: worker validity gate -----------------------------------
|
|
1821
|
+
def st_check():
|
|
1822
|
+
f = d / "ck.jsonl"
|
|
1823
|
+
f.write_text("", encoding="utf-8")
|
|
1824
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f)))
|
|
1825
|
+
assert rc == 0 and "0 record(s)" in out and "quarantined 0" in out, f"empty ledger must pass: {out}"
|
|
1826
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
|
|
1827
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f)))
|
|
1828
|
+
assert rc == 0 and "1 record(s)" in out and "pmid:21639808" in out and "; OK" in out, out
|
|
1829
|
+
bad = d / "ck2.jsonl"
|
|
1830
|
+
bad.write_text(json.dumps(_fixture_article()) + "\nnot json\n", encoding="utf-8")
|
|
1831
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(bad)))
|
|
1832
|
+
assert rc == 1 and "quarantined 1" in out and "FAIL" in out, f"quarantined ledger must fail: {out}"
|
|
1833
|
+
check("check", st_check)
|
|
1834
|
+
|
|
1835
|
+
# ---- check --markers: worker marker cross-validation gate ------------
|
|
1836
|
+
def st_check_markers():
|
|
1837
|
+
f = d / "ckm.jsonl"
|
|
1838
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
|
|
1839
|
+
good = d / "good.md"
|
|
1840
|
+
good.write_text(
|
|
1841
|
+
"Real [@pmid:21639808] plus its doi twin [@doi:10.1056/NEJMOA1103782].\n"
|
|
1842
|
+
"Prose [@home] and shape-ish [@gene:BRAF] stay verbatim.\n"
|
|
1843
|
+
"CI text [95% CI 78-89] and a link [1](http://x) are not citations.\n"
|
|
1844
|
+
"Fenced example:\n\n```\n[@pmid:99999999]\n```\n",
|
|
1845
|
+
encoding="utf-8",
|
|
1846
|
+
)
|
|
1847
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(good)]))
|
|
1848
|
+
assert rc == 0, f"resolvable markers must pass:\n{out}"
|
|
1849
|
+
assert "markers[good.md]: 2 citation group(s), 2 marker(s) resolved" in out
|
|
1850
|
+
assert "markers: 0 problem(s) across 1 file(s); OK" in out
|
|
1851
|
+
# unresolved marker -> exit 1
|
|
1852
|
+
bad = d / "bad.md"
|
|
1853
|
+
bad.write_text("Broken [@pmid:99999999] cite.\n", encoding="utf-8")
|
|
1854
|
+
buf = io.StringIO()
|
|
1855
|
+
with contextlib.redirect_stderr(buf):
|
|
1856
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(bad)]))
|
|
1857
|
+
assert rc == 1 and "unresolved marker pmid:99999999" in buf.getvalue(), out + buf.getvalue()
|
|
1858
|
+
# mixed group (cite + plain token) -> exit 1, mirroring render
|
|
1859
|
+
mix = d / "mix.md"
|
|
1860
|
+
mix.write_text("Mixed [@pmid:21639808; see note] group.\n", encoding="utf-8")
|
|
1861
|
+
buf = io.StringIO()
|
|
1862
|
+
with contextlib.redirect_stderr(buf):
|
|
1863
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(mix)]))
|
|
1864
|
+
assert rc == 1 and "mixed citation group" in buf.getvalue(), out + buf.getvalue()
|
|
1865
|
+
# missing marker file -> exit 1 (never a vacuous pass)
|
|
1866
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(d / "nope.md")]))
|
|
1867
|
+
assert rc == 1 and "marker file not found" in out, out
|
|
1868
|
+
check("check-markers", st_check_markers)
|
|
1869
|
+
|
|
1870
|
+
# ---- render: hand-typed numeric bracket warning (non-fatal) ---------
|
|
1871
|
+
def st_render_handtyped_warning():
|
|
1872
|
+
f = d / "ht.jsonl"
|
|
1873
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
|
|
1874
|
+
draft = d / "ht.draft.md"
|
|
1875
|
+
draft.write_text(
|
|
1876
|
+
"Clean [@pmid:21639808] marker.\n\nBut a hand-typed [1] leak and [2, 3] too.\n\n"
|
|
1877
|
+
"Not citations: [95% CI 78-89], link [4](http://x), wiki [[5, 6]], fence:\n\n```\n[7]\n```\n",
|
|
1878
|
+
encoding="utf-8",
|
|
1879
|
+
)
|
|
1880
|
+
out_path = d / "ht.md"
|
|
1881
|
+
buf_err = io.StringIO()
|
|
1882
|
+
with contextlib.redirect_stderr(buf_err):
|
|
1883
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(draft), out=str(out_path), expand_pages=False))
|
|
1884
|
+
err = buf_err.getvalue()
|
|
1885
|
+
assert rc == 0, "hand-typed brackets must NOT fail render"
|
|
1886
|
+
assert "hand-typed numeric citation bracket" in err and "line(s) [3]" in err, err
|
|
1887
|
+
assert "[7]" not in err.replace("line(s)", ""), "fenced [7] must not warn"
|
|
1888
|
+
# clean draft: no warning at all
|
|
1889
|
+
clean = d / "ht2.draft.md"
|
|
1890
|
+
clean.write_text("Only [@pmid:21639808] here.\n", encoding="utf-8")
|
|
1891
|
+
buf_err2 = io.StringIO()
|
|
1892
|
+
with contextlib.redirect_stderr(buf_err2):
|
|
1893
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(clean), out=str(d / "ht2.md"), expand_pages=False))
|
|
1894
|
+
assert rc == 0 and "hand-typed" not in buf_err2.getvalue(), buf_err2.getvalue()
|
|
1895
|
+
check("render-handtyped-warning", st_render_handtyped_warning)
|
|
1896
|
+
|
|
1348
1897
|
failed = [r for r in results if r[1] is not None]
|
|
1349
1898
|
for name, err in results:
|
|
1350
1899
|
print(f"{'PASS' if err is None else 'FAIL'} {name}" + (f": {err}" if err else ""))
|
|
@@ -1394,6 +1943,19 @@ def main() -> int:
|
|
|
1394
1943
|
p.add_argument("file")
|
|
1395
1944
|
p.set_defaults(fn=cmd_stats)
|
|
1396
1945
|
|
|
1946
|
+
p = sub.add_parser("render", help="Number cite-key markers in a draft and append the References section")
|
|
1947
|
+
p.add_argument("ledger", help="merged ledger file (evidence/sources.jsonl)")
|
|
1948
|
+
p.add_argument("draft", help="markdown draft authored with [@key] markers")
|
|
1949
|
+
p.add_argument("-o", "--out", required=True, help="output report path (never written on failure)")
|
|
1950
|
+
p.add_argument("--expand-pages", action="store_true", help="expand abbreviated page ranges (2507-16 -> 2507-2516)")
|
|
1951
|
+
p.set_defaults(fn=cmd_render)
|
|
1952
|
+
|
|
1953
|
+
p = sub.add_parser("check", help="Validate a ledger file (exit 1 on quarantined lines; with --markers also on unresolved/mixed markers)")
|
|
1954
|
+
p.add_argument("file")
|
|
1955
|
+
p.add_argument("--markers", nargs="+", metavar="MD",
|
|
1956
|
+
help="markdown file(s) whose [@key] markers must resolve in this ledger")
|
|
1957
|
+
p.set_defaults(fn=cmd_check)
|
|
1958
|
+
|
|
1397
1959
|
p = sub.add_parser("selftest", help="Hermetic feature-matrix selftest (no network)")
|
|
1398
1960
|
p.set_defaults(fn=lambda a: selftest())
|
|
1399
1961
|
|