Add source linter, negative test fixtures, and fallback guidance
- scripts/lint.py: reject raw rectangles, absolute coordinates, hue-budget and callout/group/formula-order violations at the source level - tests/invalid/ + tests/lint-invalid/: negative fixtures proving the package and linter reject bad input; test.sh now runs both directions - references/fallback.md: degraded path when no LaTeX is available - tests/group-callout.tex: exercise \stgroup and \stcallout - agents/openai.yaml: agent config - Docs and .sty updated to match
This commit is contained in:
+6
-1
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compile a supertensor figure and export every delivery artifact.
|
||||
# Lint, compile, and export every delivery artifact for a supertensor figure.
|
||||
#
|
||||
# ./scripts/build.sh figure.tex [outdir]
|
||||
#
|
||||
@@ -23,6 +23,11 @@ OUT="${2:-$SRCDIR/build}"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
if [[ "${ST_SKIP_LINT:-0}" != "1" ]]; then
|
||||
echo "==> lint $BASE"
|
||||
python3 "$ROOT/scripts/lint.py" "$SRC"
|
||||
fi
|
||||
|
||||
echo "==> xelatex $BASE"
|
||||
# supertensor.sty lives in assets/; keep it off the user's texmf tree.
|
||||
TEXINPUTS="$ROOT/assets:$SRCDIR:" \
|
||||
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static source checks for supertensor figures.
|
||||
|
||||
The TeX package owns geometry at render time; this linter catches source-level
|
||||
escapes that TeX cannot see: mutable ledgers, hand placement, formula order,
|
||||
raw rectangles, repeated anonymous dimensions, and per-row hue budget.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DIM_RE = re.compile(r"\\stdim\{([^{}]+)\}\{([^{}]+)\}")
|
||||
ROLE_RE = re.compile(r"\\stsetrole\{([^{}]+)\}\{([^{}]+)\}")
|
||||
FACE_RE = re.compile(
|
||||
r"\\(stface|ststack|stindexface)(?:\[[^\]]*\])?"
|
||||
r"\{([^{}]+)\}\{([^{}]*)\}\{([^{}]+)\}\{([^{}]+)\}",
|
||||
re.DOTALL,
|
||||
)
|
||||
ROW_RE = re.compile(r"\\strow\{[^{}]+\}\{[^{}]+\}(.*?)\\strowend", re.DOTALL)
|
||||
ROW_NAME_RE = re.compile(r"\\strow\{([^{}]+)\}\{[^{}]+\}")
|
||||
ROLE_KEY_RE = re.compile(r"role\s*=\s*([A-Za-z0-9_-]+)")
|
||||
CALLOUT_RE = re.compile(r"\\stcallout\{([^{}]+)\}\{[^{}]+\}\{([^{}]+)\}")
|
||||
|
||||
|
||||
def strip_comments(source: str) -> str:
|
||||
return re.sub(r"(?<!\\)%.*$", "", source, flags=re.MULTILINE)
|
||||
|
||||
|
||||
def line_of(source: str, offset: int) -> int:
|
||||
return source.count("\n", 0, offset) + 1
|
||||
|
||||
|
||||
def directives(source: str) -> set[str]:
|
||||
found: set[str] = set()
|
||||
for match in re.finditer(r"^\s*%\s*supertensor-lint:\s*(.+)$", source, re.MULTILINE):
|
||||
found.update(item.strip() for item in match.group(1).split(","))
|
||||
return found
|
||||
|
||||
|
||||
def check_unique(
|
||||
source: str, pattern: re.Pattern[str], kind: str, errors: list[str]
|
||||
) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for match in pattern.finditer(source):
|
||||
name, value = (part.strip() for part in match.groups())
|
||||
previous = values.get(name)
|
||||
if previous is not None and previous != value:
|
||||
errors.append(
|
||||
f"line {line_of(source, match.start())}: {kind} {name!r} changes "
|
||||
f"from {previous!r} to {value!r}"
|
||||
)
|
||||
else:
|
||||
values[name] = value
|
||||
return values
|
||||
|
||||
|
||||
def lint(path: Path) -> list[str]:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
allowed = directives(raw)
|
||||
source = strip_comments(raw)
|
||||
errors: list[str] = []
|
||||
|
||||
dimensions = check_unique(source, DIM_RE, "axis", errors)
|
||||
roles = check_unique(source, ROLE_RE, "role", errors)
|
||||
|
||||
formula_positions = [m.start() for m in re.finditer(r"\\sttopformula\b", source)]
|
||||
row_ends = [m.start() for m in re.finditer(r"\\strowend\b", source)]
|
||||
if not formula_positions and "allow-missing-formula" not in allowed:
|
||||
errors.append("missing \\sttopformula (add an explicit lint exemption for galleries/tests)")
|
||||
elif formula_positions and row_ends and formula_positions[-1] < row_ends[-1]:
|
||||
errors.append(
|
||||
f"line {line_of(source, formula_positions[-1])}: \\sttopformula must follow the last \\strowend"
|
||||
)
|
||||
|
||||
tracked = set(re.findall(r"\\sttrack\{([^{}]+)\}", source))
|
||||
raw_dimensions: dict[str, list[int]] = {}
|
||||
for match in FACE_RE.finditer(source):
|
||||
_macro, name, coordinate, rows, cols = match.groups()
|
||||
if coordinate.strip() and name not in tracked and "allow-absolute" not in allowed:
|
||||
errors.append(
|
||||
f"line {line_of(source, match.start())}: absolute object {name!r} has no \\sttrack{{{name}}}"
|
||||
)
|
||||
for value in (rows.strip(), cols.strip()):
|
||||
if value.isdigit() and value != "1":
|
||||
raw_dimensions.setdefault(value, []).append(line_of(source, match.start()))
|
||||
|
||||
if "allow-repeated-raw-dim" not in allowed:
|
||||
for value, lines in sorted(raw_dimensions.items()):
|
||||
if len(lines) > 1:
|
||||
errors.append(
|
||||
f"lines {', '.join(map(str, lines))}: raw dimension {value!r} is reused; declare a symbolic axis with \\stdim"
|
||||
)
|
||||
|
||||
if "allow-raw-tikz" not in allowed:
|
||||
for match in re.finditer(
|
||||
r"\\(?:draw|fill|path)\b[^;]*\brectangle\b", source, re.DOTALL
|
||||
):
|
||||
errors.append(
|
||||
f"line {line_of(source, match.start())}: hand-drawn rectangle bypasses \\stface"
|
||||
)
|
||||
|
||||
# \stgroup is a block. An unclosed one silently swallows the rest of the
|
||||
# band into the group's fit list, which TeX only reports as an empty row.
|
||||
opened = len(re.findall(r"\\stgroup(?![A-Za-z])", source))
|
||||
closed = len(re.findall(r"\\stgroupend(?![A-Za-z])", source))
|
||||
if opened != closed:
|
||||
errors.append(
|
||||
f"{opened} \\stgroup vs {closed} \\stgroupend: every group must be closed"
|
||||
)
|
||||
|
||||
# A callout hangs off a BAND. Anchored to a face it becomes the floating
|
||||
# commentary card between two operands that layout.md forbids, and two of
|
||||
# them on one band is a dashboard, not a figure.
|
||||
band_names = set(ROW_NAME_RE.findall(source))
|
||||
callout_anchors: dict[str, str] = {}
|
||||
for match in CALLOUT_RE.finditer(source):
|
||||
name, anchor = (part.strip() for part in match.groups())
|
||||
line = line_of(source, match.start())
|
||||
if anchor not in band_names:
|
||||
errors.append(
|
||||
f"line {line}: callout {name!r} is anchored to {anchor!r}, which is not a "
|
||||
f"\\strow band; a card hanging off a single object reads as a step"
|
||||
)
|
||||
elif anchor in callout_anchors:
|
||||
errors.append(
|
||||
f"line {line}: callout {name!r} is the second card on band {anchor!r} "
|
||||
f"(after {callout_anchors[anchor]!r}); one aside per band"
|
||||
)
|
||||
callout_anchors.setdefault(anchor, name)
|
||||
|
||||
for row_number, row in enumerate(ROW_RE.finditer(source), start=1):
|
||||
row_roles = set(ROLE_KEY_RE.findall(row.group(1)))
|
||||
colors = {roles.get(role, "<undeclared>") for role in row_roles}
|
||||
active = colors - {"stGray"}
|
||||
if len(active) > 4:
|
||||
errors.append(
|
||||
f"line {line_of(source, row.start())}: row {row_number} uses {len(active)} active hue families "
|
||||
f"({', '.join(sorted(active))}); maximum is 4 plus gray"
|
||||
)
|
||||
|
||||
# Declared dimensions are intentionally allowed to be illustrative. This
|
||||
# lookup merely keeps the declaration parse exercised and future-proof.
|
||||
del dimensions
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="lint a supertensor .tex source")
|
||||
parser.add_argument("sources", nargs="+", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
failed = False
|
||||
for path in args.sources:
|
||||
if not path.is_file():
|
||||
print(f"supertensor-lint: no such file: {path}", file=sys.stderr)
|
||||
failed = True
|
||||
continue
|
||||
errors = lint(path)
|
||||
if errors:
|
||||
failed = True
|
||||
print(f"!! {path}", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f" {error}", file=sys.stderr)
|
||||
else:
|
||||
print(f" lint {path}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+11
-7
@@ -12,7 +12,7 @@ QUIET=0
|
||||
[[ "${1:-}" == "--quiet" ]] && QUIET=1
|
||||
say() { [[ $QUIET -eq 1 ]] || echo -e "$*"; }
|
||||
|
||||
ok=0; warn=0; fail=0
|
||||
ok=0; warn=0; fail=0; cjk_warn=0; export_warn=0
|
||||
check() { # name, command
|
||||
local name="$1"; shift
|
||||
if "$@" >/dev/null 2>&1; then say " ok $name"; ok=$((ok+1)); return 0
|
||||
@@ -27,11 +27,11 @@ check "tikz.sty" kpsewhich tikz.sty || fail=$((fail+1))
|
||||
check "xstring.sty" kpsewhich xstring.sty || fail=$((fail+1))
|
||||
|
||||
say "--- chinese figures ---"
|
||||
check "ctex.sty" kpsewhich ctex.sty || warn=$((warn+1))
|
||||
check "fandol font" kpsewhich FandolSong-Regular.otf || warn=$((warn+1))
|
||||
check "ctex.sty" kpsewhich ctex.sty || { warn=$((warn+1)); cjk_warn=1; }
|
||||
check "fandol font" kpsewhich FandolSong-Regular.otf || { warn=$((warn+1)); cjk_warn=1; }
|
||||
|
||||
say "--- raster / vector export ---"
|
||||
check "pdftocairo" command -v pdftocairo || warn=$((warn+1))
|
||||
check "pdftocairo" command -v pdftocairo || { warn=$((warn+1)); export_warn=1; }
|
||||
check "latexmk (optional)" command -v latexmk || true
|
||||
|
||||
if [[ $fail -gt 0 ]]; then
|
||||
@@ -44,9 +44,13 @@ fi
|
||||
if [[ $warn -gt 0 ]]; then
|
||||
say ""
|
||||
say "RESULT: degraded."
|
||||
say " - missing ctex/fandol -> English-label figures only; do not substitute"
|
||||
say " an OS-specific CJK font without telling the user it costs portability."
|
||||
say " - missing pdftocairo -> deliver PDF only, and say PNG/SVG were skipped."
|
||||
if [[ $cjk_warn -eq 1 ]]; then
|
||||
say " - missing ctex/fandol -> English-label figures only; do not substitute"
|
||||
say " an OS-specific CJK font without telling the user it costs portability."
|
||||
fi
|
||||
if [[ $export_warn -eq 1 ]]; then
|
||||
say " - missing pdftocairo -> deliver PDF only, and say PNG/SVG were skipped."
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
say ""
|
||||
|
||||
+25
-3
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build every example and the smoke test. Any dirty build fails the run.
|
||||
# Build every valid example, then prove invalid TeX and lint fixtures fail.
|
||||
#
|
||||
# ./scripts/test.sh
|
||||
#
|
||||
@@ -21,8 +21,30 @@ for f in "$ROOT"/tests/*.tex "$ROOT"/examples/*.tex; do
|
||||
fi
|
||||
done
|
||||
|
||||
for f in "$ROOT"/tests/invalid/*.tex; do
|
||||
[[ -e "$f" ]] || continue
|
||||
name="$(basename "$f")"
|
||||
if ST_SKIP_LINT=1 "$ROOT/scripts/build.sh" "$f" >/dev/null 2>&1; then
|
||||
echo " FAIL $name (invalid fixture built cleanly)"
|
||||
fail=$((fail+1))
|
||||
else
|
||||
echo " ok $name (rejected by package/build)"
|
||||
fi
|
||||
done
|
||||
|
||||
for f in "$ROOT"/tests/lint-invalid/*.tex; do
|
||||
[[ -e "$f" ]] || continue
|
||||
name="$(basename "$f")"
|
||||
if python3 "$ROOT/scripts/lint.py" "$f" >/dev/null 2>&1; then
|
||||
echo " FAIL $name (invalid fixture passed lint)"
|
||||
fail=$((fail+1))
|
||||
else
|
||||
echo " ok $name (rejected by lint)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $fail -gt 0 ]]; then
|
||||
echo "$fail failing figure(s); rerun scripts/build.sh on one to see why" >&2
|
||||
echo "$fail failing check(s); rerun the reported build or lint command to inspect" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "all figures build clean"
|
||||
echo "all positive and negative checks passed"
|
||||
|
||||
Reference in New Issue
Block a user