mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
502e0e2b97
#439: user-declared format_profile (schema + intake + formatter) A scholar-declared layout/format profile the Phase 7 formatter follows when rendering — fonts, caption placement, line spacing, margins, table borders. ARS ships the contract, never any school's or journal's profile content. Closes the gap surfaced by #436 (a downstream fork had to fork+add a whole layer because there was no upstream way to declare a layout profile). Decision B (standalone format_profile, not a venue_profile extension): verifier-input vs renderer-input is a schema smell, lifecycle + data semantics differ. Design-first (docs/design/2026-06-15-439-format-profile-design.md), through codex design review, two /simplify passes, and a dual-track ship gate (codex review + security-review, 0 P1/P2, 0 vulns). - Schema: minimal field set (body_font, caption incl. latin_font_family for the #436 CJK case, line_spacing fixed_pt biconditional, margins_cm, table_border_style), additionalProperties:false, no provenance machinery. - Intake: Step 5 follow-up (DOCX/PDF/LaTeX) -> YAML by path in a new PCR row; a declined follow-up writes nothing (Invariant 7 byte-equivalence). - Formatter: reads by path, applies declared fields, first-line byte-equivalence guard, fail-closed STOP on bad/empty/escaping paths, declared-only no-inference, best-effort-per-target, venue-compliance-wins precedence. - POSITIONING boundary: ships the mechanism, never a specific institution's profile content (out-of-tree, user-supplied). 8-invariant lint, 43 tests (mutation discipline), CI green. Fail-closed is prose-tier this release; the deterministic-reader parity gap vs venue_profile is recorded in design §10 as a future slice. Closes #439 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
308 lines
14 KiB
Python
308 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""#439 format_profile contract lint (spec docs/design/2026-06-15-439-format-profile-design.md).
|
|
|
|
Guards the scholar-declared layout profile schema (Slice A). The schema is a
|
|
RENDERER input, kept standalone from venue_profile (decision B). This lint pins
|
|
the load-bearing properties so they cannot be silently broken:
|
|
|
|
1. The schema is a valid Draft 2020-12 schema, standalone (Invariant 11), and
|
|
locks unknown keys (additionalProperties:false) at the root.
|
|
2. The line_spacing.fixed_pt conditional is intact (required iff mode==fixed_pt,
|
|
forbidden otherwise) — the rule that makes a mis-declared spacing a hard error
|
|
rather than a silently-ignored field.
|
|
3. NO provenance machinery leaked in from venue_profile (no declared_by, no
|
|
*_inferred / *_provenance / *_source fields) — the downgraded declared-only
|
|
rule (§3) is documentation, not an apparatus, and this asserts it stays that way.
|
|
4. The cut fields stay cut (profile_name / heading_font / caption.bold) — the
|
|
no-unrequested-abstraction discipline, enforced.
|
|
5. The committed synthetic example fixture exists and validates against the
|
|
schema (design §6/§8) — keeps the boundary-note artifact from drifting.
|
|
6. The formatter's Format Profile section carries its load-bearing prose rules
|
|
(byte-equivalence guard, fail-closed, no-inference, venue precedence,
|
|
best-effort) — Slice C wiring, guarded by literal-presence like #394.
|
|
7. The intake Step 5 follow-up carries the write-nothing-when-declined rule
|
|
(Invariant 7 byte-equivalence) and the PCR row omission note.
|
|
8. POSITIONING.md records the ship-contract-not-content boundary (#439 §6) —
|
|
the mechanism ships, no specific school's/journal's profile content does.
|
|
|
|
Mutation discipline: scripts/test_check_439_format_profile.py proves each check
|
|
fires when its guarded property is broken.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from jsonschema import Draft202012Validator
|
|
|
|
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from _skill_lint import check_section_literals # noqa: E402
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SCHEMA = ROOT / "shared" / "contracts" / "submission" / "format_profile.schema.json"
|
|
EXAMPLE = ROOT / "shared" / "contracts" / "submission" / "format_profile.example.yaml"
|
|
FORMATTER = ROOT / "academic-paper" / "agents" / "formatter_agent.md"
|
|
INTAKE = ROOT / "academic-paper" / "agents" / "intake_agent.md"
|
|
POSITIONING = ROOT / "POSITIONING.md"
|
|
FMT_HEADING = "## Format Profile (#439) — declared layout, NOT-DECLARED → current default"
|
|
|
|
# Fields that were deliberately cut (design §4) — must NOT reappear.
|
|
CUT_ROOT_FIELDS = ("profile_name", "heading_font")
|
|
CUT_CAPTION_FIELDS = ("bold",)
|
|
# venue_profile provenance machinery that must NOT carry over (design §3).
|
|
PROVENANCE_MARKERS = ("declared_by", "_inferred", "_provenance", "_source")
|
|
|
|
|
|
def check_schema_valid(schema: dict) -> list[str]:
|
|
"""Invariant 1: valid Draft 2020-12 schema, root locks unknown keys."""
|
|
errors: list[str] = []
|
|
try:
|
|
Draft202012Validator.check_schema(schema)
|
|
except Exception as exc: # noqa: BLE001 - surface any schema-meta failure
|
|
errors.append(f"format_profile schema is not a valid Draft 2020-12 schema: {exc}")
|
|
if schema.get("additionalProperties") is not False:
|
|
errors.append("format_profile root must set additionalProperties:false")
|
|
if schema.get("type") != "object":
|
|
errors.append("format_profile root type must be 'object'")
|
|
return errors
|
|
|
|
|
|
def check_fixed_pt_conditional(schema: dict) -> list[str]:
|
|
"""Invariant 2: line_spacing.fixed_pt required iff mode==fixed_pt, else forbidden.
|
|
|
|
Verified behaviorally (the conditional must actually reject the bad shapes),
|
|
not by string-matching the allOf — a structural rename would slip past a grep.
|
|
"""
|
|
errors: list[str] = []
|
|
validator = Draft202012Validator(schema)
|
|
|
|
def valid(inst: dict) -> bool:
|
|
return not list(validator.iter_errors(inst))
|
|
|
|
if not valid({"line_spacing": {"mode": "fixed_pt", "fixed_pt": 20}}):
|
|
errors.append("line_spacing fixed_pt+fixed_pt should be VALID but is rejected")
|
|
if not valid({"line_spacing": {"mode": "double"}}):
|
|
errors.append("line_spacing double (no fixed_pt) should be VALID but is rejected")
|
|
if valid({"line_spacing": {"mode": "double", "fixed_pt": 20}}):
|
|
errors.append("line_spacing double WITH fixed_pt must be INVALID (fixed_pt only with mode==fixed_pt)")
|
|
if valid({"line_spacing": {"mode": "fixed_pt"}}):
|
|
errors.append("line_spacing fixed_pt WITHOUT fixed_pt must be INVALID (fixed_pt required when mode==fixed_pt)")
|
|
if valid({"line_spacing": {"fixed_pt": 20}}):
|
|
errors.append("line_spacing WITHOUT mode must be INVALID (mode is required) — guards against dropping required:[mode]")
|
|
return errors
|
|
|
|
|
|
def _all_property_names(schema: dict) -> set[str]:
|
|
"""Recursively collect every declared property NAME in the schema.
|
|
|
|
Structural only — descriptions are NOT walked, so the schema may *mention*
|
|
'declared_by' in prose explaining that it carries no such field without the
|
|
check false-firing on its own documentation.
|
|
|
|
Walks only the applicators this schema actually uses (`properties`, `allOf`,
|
|
`if`/`then`/`else`). If a future edit introduces `anyOf`/`oneOf`/`items`, that
|
|
edit extends this walker in the same change — carrying dead branches now would
|
|
be unrequested generality.
|
|
"""
|
|
names: set[str] = set()
|
|
props = schema.get("properties")
|
|
if isinstance(props, dict):
|
|
for name, subschema in props.items():
|
|
names.add(name)
|
|
if isinstance(subschema, dict):
|
|
names |= _all_property_names(subschema)
|
|
for branch in schema.get("allOf", []):
|
|
if isinstance(branch, dict):
|
|
names |= _all_property_names(branch)
|
|
for key in ("if", "then", "else"):
|
|
branch = schema.get(key)
|
|
if isinstance(branch, dict):
|
|
names |= _all_property_names(branch)
|
|
return names
|
|
|
|
|
|
def check_no_provenance(schema: dict) -> list[str]:
|
|
"""Invariant 3: no venue_profile provenance machinery leaked in (§3 downgrade).
|
|
|
|
Checks property NAMES only (not descriptions): a field whose name is or ends
|
|
with a provenance marker. This catches `declared_by`, `body_font_provenance`,
|
|
`venue_source`, `family_inferred`, while ignoring prose mentions.
|
|
"""
|
|
errors: list[str] = []
|
|
for name in _all_property_names(schema):
|
|
# endswith subsumes exact-equality (declared_by) and catches the realistic
|
|
# suffix leak (body_font_provenance ends with _provenance). The tested cases
|
|
# are exactly these two shapes; an infix-marker rule would be untested scope.
|
|
for marker in PROVENANCE_MARKERS:
|
|
if name.endswith(marker):
|
|
errors.append(
|
|
f"format_profile must NOT carry provenance machinery (field '{name}' "
|
|
f"matches '{marker}') — declared-only is downgraded to documentation, "
|
|
f"not an apparatus (design §3)"
|
|
)
|
|
break
|
|
return errors
|
|
|
|
|
|
def check_cut_fields_stay_cut(schema: dict) -> list[str]:
|
|
"""Invariant 4: deliberately-cut fields must not reappear (design §4)."""
|
|
errors: list[str] = []
|
|
props = schema.get("properties", {})
|
|
for field in CUT_ROOT_FIELDS:
|
|
if field in props:
|
|
errors.append(f"cut field '{field}' reappeared at root (design §4 — no concrete use case)")
|
|
caption_props = props.get("caption", {}).get("properties", {})
|
|
for field in CUT_CAPTION_FIELDS:
|
|
if field in caption_props:
|
|
errors.append(f"cut field 'caption.{field}' reappeared (design §4 — no #436 requirement)")
|
|
return errors
|
|
|
|
|
|
def check_example_fixture(schema: dict) -> list[str]:
|
|
"""Invariant 5: the committed synthetic example validates against the schema (design §6/§8).
|
|
|
|
Pins that the documentation artifact exists and stays schema-valid, so the
|
|
boundary-note fixture (§6: must be synthetic, ships no real school profile)
|
|
cannot silently drift out of contract.
|
|
"""
|
|
if not EXAMPLE.exists():
|
|
return [f"format_profile example fixture not found at {EXAMPLE} (design §6/§8)"]
|
|
try:
|
|
example = yaml.safe_load(EXAMPLE.read_text(encoding="utf-8"))
|
|
except yaml.YAMLError as exc:
|
|
return [f"format_profile example fixture is not valid YAML: {exc}"]
|
|
schema_errors = list(Draft202012Validator(schema).iter_errors(example))
|
|
return [
|
|
f"format_profile example fixture violates the schema: {e.message}"
|
|
for e in schema_errors
|
|
]
|
|
|
|
|
|
def check_formatter_wiring(text: str) -> list[str]:
|
|
"""Invariant 6: the formatter's Format Profile section carries its load-bearing rules.
|
|
|
|
Slice C wiring is prose (LLM-agent behavior), so it is guarded by literal-presence
|
|
the way the #394 formatter advisories are — a refactor that drops the byte-equivalence
|
|
guard, the fail-closed STOP, or the venue-precedence rule fails loudly.
|
|
"""
|
|
return check_section_literals(6, text, FMT_HEADING, "formatter format-profile", {
|
|
"byte-equivalence guard (skip when no row)": "skip this entire section",
|
|
"fail-closed before formatting": "fail closed BEFORE formatting",
|
|
"no-inference rule": "Never infer a missing layout field",
|
|
"venue precedence": "venue compliance wins",
|
|
"best-effort per target": "best-effort per output target",
|
|
})
|
|
|
|
|
|
STEP5_HEADING = "### Step 5: Output Format"
|
|
# The production Paper Configuration Record H2 — matched exactly so the Plan Mode PCR
|
|
# ("## Paper Configuration Record (Plan Mode)", which is exempt and carries no row) and
|
|
# the "### Paper Configuration Record" intro sub-heading are both excluded.
|
|
PCR_HEADING_RE = re.compile(r"^## Paper Configuration Record\s*$", re.MULTILINE)
|
|
# Structural table-row match for the PCR Format Profile row (mirrors check_392's PCR_ROW_RE).
|
|
PCR_FORMAT_ROW_RE = re.compile(r"^\|\s*\*\*Format Profile\*\*\s*\|", re.MULTILINE)
|
|
|
|
|
|
def _h2_block(text: str, heading_re: "re.Pattern[str]") -> str | None:
|
|
"""The block from an H2 heading (located by regex) to the next `## ` heading.
|
|
|
|
Used to scope the production PCR table, matched exactly so the Plan Mode PCR variant
|
|
and the intro sub-heading are excluded.
|
|
"""
|
|
m = heading_re.search(text)
|
|
if m is None:
|
|
return None
|
|
nxt = re.compile(r"^## \S", re.MULTILINE).search(text, m.end())
|
|
return text[m.start() : nxt.start()] if nxt else text[m.start():]
|
|
|
|
|
|
def _step_block(text: str, heading: str) -> str | None:
|
|
"""The block from `heading` to the next heading of <= its level (None if absent).
|
|
|
|
Scopes literal checks to the intake step rather than the whole 330-line file, so a
|
|
rule moved out of Step 5 fails the lint instead of passing on a stray match elsewhere.
|
|
"""
|
|
start = text.find(heading)
|
|
if start < 0:
|
|
return None
|
|
level = heading.split(" ")[0] # e.g. '###'
|
|
pattern = re.compile(rf"^#{{2,{len(level)}}}\s", re.MULTILINE)
|
|
m = pattern.search(text, start + len(heading))
|
|
return text[start : m.start()] if m else text[start:]
|
|
|
|
|
|
def check_intake_wiring(text: str) -> list[str]:
|
|
"""Invariant 7: the intake Step 5 follow-up carries the write-nothing-when-declined rule.
|
|
|
|
Scoped to the Step 5 block (not whole-file substring), so the byte-equivalence rule
|
|
cannot pass the lint after being moved out of the follow-up; the PCR row is asserted
|
|
structurally (a real table row), not by a prose fragment.
|
|
"""
|
|
errors: list[str] = []
|
|
step5 = _step_block(text, STEP5_HEADING)
|
|
if step5 is None or "Format-profile follow-up (#439" not in step5:
|
|
errors.append("intake_agent.md missing the #439 Format-profile follow-up under Step 5")
|
|
return errors
|
|
# the byte-equivalence discipline (declined => no PCR row at all) is load-bearing, in-step
|
|
if "no PCR `Format Profile` row at all" not in step5:
|
|
errors.append(
|
|
"Step 5 follow-up must state a declined run writes NO PCR Format Profile row "
|
|
"(Invariant 7 byte-equivalence — an explicit `absent` would perturb)"
|
|
)
|
|
# the PCR row exists as a real table row, scoped to the production PCR block
|
|
# (not the Plan Mode PCR, and not a stray row elsewhere in the file)
|
|
pcr_block = _h2_block(text, PCR_HEADING_RE)
|
|
if pcr_block is None or not PCR_FORMAT_ROW_RE.search(pcr_block):
|
|
errors.append("production PCR block must carry a structural `| **Format Profile** |` row")
|
|
elif "ROW OMITTED ENTIRELY" not in pcr_block:
|
|
errors.append("PCR Format Profile row must document that it is omitted when declined")
|
|
return errors
|
|
|
|
|
|
def check_positioning_boundary(text: str) -> list[str]:
|
|
"""Invariant 8: POSITIONING.md records the #439 ship-contract-not-content boundary.
|
|
|
|
The boundary (ARS ships the format_profile mechanism, never a specific school's/
|
|
journal's profile content) is otherwise prose-only and unguarded — this pins that it
|
|
stays recorded with its review criterion (design §6 acceptance).
|
|
"""
|
|
errors: list[str] = []
|
|
if "format-profile content" not in text.lower():
|
|
errors.append("POSITIONING.md missing the #439 institutional/journal format-profile boundary note")
|
|
return errors
|
|
if "Review criterion:" not in text or "out-of-tree" not in text:
|
|
errors.append("POSITIONING.md #439 boundary must keep its Review criterion + out-of-tree rule")
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
if not SCHEMA.exists():
|
|
print(f"FAIL: schema not found at {SCHEMA}", file=sys.stderr)
|
|
return 1
|
|
schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
|
|
errors: list[str] = []
|
|
errors += check_schema_valid(schema)
|
|
errors += check_fixed_pt_conditional(schema)
|
|
errors += check_no_provenance(schema)
|
|
errors += check_cut_fields_stay_cut(schema)
|
|
errors += check_example_fixture(schema)
|
|
errors += check_formatter_wiring(FORMATTER.read_text(encoding="utf-8"))
|
|
errors += check_intake_wiring(INTAKE.read_text(encoding="utf-8"))
|
|
errors += check_positioning_boundary(POSITIONING.read_text(encoding="utf-8"))
|
|
|
|
if errors:
|
|
print("#439 format_profile lint FAILED:", file=sys.stderr)
|
|
for e in errors:
|
|
print(f" - {e}", file=sys.stderr)
|
|
return 1
|
|
print("#439 format_profile lint passed (8 invariants).")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|