Skip to content

Commit 63a2dea

Browse files
alexmmillerclaude
andcommitted
Fix the eight findings from the second /code-review xhigh pass
Two of these are misses in the previous commit's own fixes. - The act check moved above the argument *validation* rather than below it, so five semantic errors (both --skip flags, --db-path missing or nonexistent, --images-zip-path missing or nonexistent) were still answered with "act is required" on a machine without act. It now sits immediately before the act invocation; all five report themselves, and act is still required once the arguments are good. - possible_output_names' undetermined case claimed both names, which was widened from GIFs to every PNG and TIFF by the last commit. It now claims nothing: optimize_raster repeats the same Image.open and is_animated access, so whatever makes the probe fail makes process_file fail too, and the source writes no file at all. Holding two names for it de-conflicted a real image against an output that never appears. And the deeper one behind both of the last two rounds' F26-shaped bugs: optimize_directory now checks that the name process_file actually wrote was one the planning pass claimed for that source, and fails loudly otherwise. The prediction lives in a second place from the code it predicts and has drifted twice, silently both times; this is the missing connection between the halves. Also: - _is_animated_raster reports a probe failure through the logger instead of swallowing it - it runs for every PNG now, and a systematic failure would otherwise shift the whole de-confliction pass unexplained. - outside_fences' `source` is required, not defaulted: the warning is the entire point of that change, and an optional argument is how a later call site quietly gets the old silence back. - The unterminated-fence check runs once on spans[-1]. Only the last span can be unterminated (fenced_spans appends the run-to-EOF one after its loop), so checking every span re-split every code block for nothing. - `animated: Optional[bool]`, and a non-bool argument is refused - the parameter took a Path one revision ago, and a Path is truthy, so a stale call reported "animated" for every file with no error. - The case-handling test asserts the property rather than the "{stem}-{ext}" naming literal. 6 new tests, and every changed behaviour fails against the previous commit. 192 + 33 + 189 pass; scripts/ unchanged from base. The real 299-image corpus is byte-for-byte identical again, and the local workflow under act produces the same database md5 as the last two rounds - with the new written-vs-claimed check silent across every path the corpus exercises, including SVG rasterization. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fa1848f commit 63a2dea

4 files changed

Lines changed: 169 additions & 36 deletions

File tree

‎ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/find_missing_assets.py‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def is_unterminated(span_text: str) -> bool:
6161
return sum(1 for line in span_text.splitlines() if FENCE_LINE_RE.match(line.lstrip())) < 2
6262

6363

64-
def outside_fences(text: str, source: str = None) -> str:
64+
def outside_fences(text: str, source: str) -> str:
6565
"""`text` with every fenced code block removed, so the <include> scan below
6666
doesn't report a sample as a broken reference.
6767
@@ -74,21 +74,29 @@ def outside_fences(text: str, source: str = None) -> str:
7474
exposed the rest of the block. fenced_spans handles both, and is already
7575
what extract_title trusts to stay out of code samples.
7676
77-
`source` names the file in the warning an unterminated fence earns. That
78-
warning is the point: the old regex needed a *closing* fence to match
79-
anything, so an unpaired one left the rest of the file scannable, where
80-
this correctly treats it as one long code block and stops checking. That
81-
is a false negative in a report whose value is catching what's missing,
82-
so it has to be said out loud rather than inferred from a short report."""
77+
`source` names the file in the warning an unterminated fence earns, and is
78+
required rather than defaulting to None. That warning is the point: the
79+
old regex needed a *closing* fence to match anything, so an unpaired one
80+
left the rest of the file scannable, where this correctly treats it as one
81+
long code block and stops checking. That is a false negative in a report
82+
whose value is catching what's missing, so it has to be said out loud
83+
rather than inferred from a short report - and an optional argument is
84+
exactly how a later call site would drop it and quietly get the old
85+
silence back."""
8386
spans = fenced_spans(text)
8487
if not spans:
8588
return text
89+
# Only the last span can be unterminated: fenced_spans appends a closed
90+
# block as it meets its closing fence, and the run-to-end-of-document span
91+
# only after the loop ("if open_at is not None"). Checking every span
92+
# re-split every code block in the file to ask a question already answered
93+
# False for all but one of them - and obscured that invariant.
94+
if is_unterminated(text[spans[-1][0]:spans[-1][1]]):
95+
print(f"warning: {source} has an unterminated code fence; everything after it is being read as "
96+
"code, so any <include> below it is not being checked", file=sys.stderr)
8697
parts = []
8798
pos = 0
8899
for start, end in spans:
89-
if source and is_unterminated(text[start:end]):
90-
print(f"warning: {source} has an unterminated code fence; everything after it is being read as "
91-
"code, so any <include> below it is not being checked", file=sys.stderr)
92100
parts.append(text[pos:start])
93101
pos = end
94102
parts.append(text[pos:])

‎ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/optimize_media.py‎

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
import subprocess
5454
import sys
5555
from pathlib import Path
56-
from typing import NamedTuple
56+
from typing import NamedTuple, Optional
5757

5858
from PIL import Image
5959

@@ -462,7 +462,7 @@ class OptimizeResult(NamedTuple):
462462
written: list
463463

464464

465-
def possible_output_names(stem: str, suffix: str, cfg: dict, animated: bool = None) -> set:
465+
def possible_output_names(stem: str, suffix: str, cfg: dict, animated: Optional[bool] = None) -> set:
466466
"""Every basename process_file could write for a source named
467467
`stem + suffix`.
468468
@@ -486,37 +486,68 @@ def possible_output_names(stem: str, suffix: str, cfg: dict, animated: bool = No
486486
the collision this pre-pass exists to prevent, reached through the single
487487
output name the suffix alone cannot determine.
488488
489-
Pass True/False from _is_animated_raster. None means "not determined"
490-
(not probed, or the file could not be read), and a multi-frame-capable
491-
extension then claims both names, safe in the same way the SVG branch is.
489+
Pass True/False from _is_animated_raster. None means "could not be
490+
determined", and a multi-frame-capable extension then claims *nothing*
491+
rather than claiming both.
492+
493+
That is the opposite of the SVG branch's conservatism, and deliberately
494+
so: the two ambiguities are different. An SVG genuinely might write
495+
either name, so both have to be held. A raster whose animation could not
496+
be determined is one optimize_raster is about to fail on for the same
497+
reason - it repeats this exact `Image.open` plus `is_animated` access, so
498+
whatever raised here raises there, process_file counts an error and
499+
returns None, and the source writes no file at all. Holding two names for
500+
it would de-conflict an unrelated source against an output that is never
501+
produced, renaming a real image and rewriting every stored URL that
502+
pointed at it. Claiming nothing is the accurate prediction; if it is ever
503+
wrong, optimize_directory's written-vs-claimed check says so out loud.
504+
492505
Taking the answer rather than the path keeps this function free of I/O:
493506
it is called once per de-confliction attempt, and re-opening the source
494507
on each attempt bought nothing - the answer is a property of the file,
495508
not of the candidate name being tried."""
509+
if animated is not None and not isinstance(animated, bool):
510+
# This parameter took a Path one revision ago. A Path is truthy and is
511+
# not None, so a call site left on the old signature would silently
512+
# report "animated" for every file - no conversion, no error, and
513+
# nothing in the output to suggest why.
514+
raise TypeError(f"animated must be True, False or None, not {type(animated).__name__}")
496515
if suffix.lower() == SVG_EXTENSION:
497516
rasterized = ".webp" if cfg["webp"] else ".png"
498517
return {f"{stem}{SVG_EXTENSION}", f"{stem}{rasterized}"}
499518
if suffix.lower() in RASTER_EXTENSIONS:
500519
if not cfg["webp"]:
501520
return {f"{stem}{suffix}"}
502521
if suffix.lower() in ANIMATABLE_EXTENSIONS:
503-
if animated is None: # undetermined: claim both rather than guess
504-
return {f"{stem}{suffix}", f"{stem}.webp"}
522+
if animated is None: # undetermined: this source writes nothing
523+
return set()
505524
return {f"{stem}{suffix}"} if animated else {f"{stem}.webp"}
506525
return {f"{stem}.webp"}
507526
return {f"{stem}{suffix}"} # passthrough copy, extension never changes
508527

509528

510-
def _is_animated_raster(src: Path):
529+
def _is_animated_raster(src: Path, logger: Logger = None) -> Optional[bool]:
511530
"""True/False for a readable raster, or None when it can't be determined.
512-
The same `is_animated` test optimize_raster makes later, just made early
513-
enough for the name-planning pass to predict the output extension."""
531+
The same `Image.open` + `is_animated` test optimize_raster makes later,
532+
just made early enough for the name-planning pass to predict the output
533+
extension.
534+
535+
A failure here is reported rather than swallowed. It is not fatal -
536+
process_file repeats the same access and counts the error properly - but
537+
it does change what this source claims (see possible_output_names), and
538+
it now runs for every PNG and TIFF in the tree rather than only the
539+
handful of GIFs. A systematic probe failure - a broken Pillow plugin, an
540+
unreadable work tree - would otherwise shift the whole de-confliction
541+
pass with nothing in the log to explain the renames that follow."""
514542
if src is None:
515543
return None
516544
try:
517545
with Image.open(src) as img:
518546
return bool(getattr(img, "is_animated", False))
519-
except Exception: # noqa: BLE001 - unreadable here is not fatal; process_file reports it
547+
except Exception as exc: # noqa: BLE001 - one file's problem, not the run's
548+
if logger is not None:
549+
logger.error(f"warning: could not determine whether {src} is animated ({exc}); "
550+
"planning for it to produce no output")
520551
return None
521552

522553

@@ -635,6 +666,7 @@ def optimize_directory(input_dir: Path, output_dir: Path, *, cfg: dict, pngquant
635666
# miss a collision that still costs an image. insert_optimized_media's own
636667
# seen_names guard is case-sensitive too and would not catch it either.
637668
claimed = {}
669+
claimed_for = {}
638670
for src in sources:
639671
rel = src.relative_to(input_dir)
640672
stem, suffix = rel.stem, rel.suffix
@@ -643,7 +675,7 @@ def optimize_directory(input_dir: Path, output_dir: Path, *, cfg: dict, pngquant
643675
# attempt was pure waste. False without a probe - an extension that
644676
# cannot carry a second frame is never animated, and opening every
645677
# JPEG to learn that would cost an extra pass over the whole corpus.
646-
animated = _is_animated_raster(src) if suffix.lower() in ANIMATABLE_EXTENSIONS else False
678+
animated = _is_animated_raster(src, logger) if suffix.lower() in ANIMATABLE_EXTENSIONS else False
647679
candidate = stem
648680
attempt = 0
649681
while True:
@@ -661,6 +693,7 @@ def optimize_directory(input_dir: Path, output_dir: Path, *, cfg: dict, pngquant
661693
)
662694
for name in names:
663695
claimed[name.lower()] = src
696+
claimed_for[src] = names
664697
dst_rel_for[src] = rel.parent / f"{candidate}{suffix}"
665698

666699
renamed = {}
@@ -671,6 +704,27 @@ def optimize_directory(input_dir: Path, output_dir: Path, *, cfg: dict, pngquant
671704
dst_final = process_file(src, dst, cfg=cfg, pngquant_path=pngquant_path, stats=stats, logger=logger)
672705
if dst_final is None:
673706
continue
707+
# The name this actually wrote has to be one the planning pass held
708+
# for it. possible_output_names predicts what process_file ->
709+
# optimize_raster/optimize_svg -> encode_raster will do, in a second
710+
# place, and that prediction has already drifted twice: it said .webp
711+
# for animated GIFs while optimize_raster wrote .gif, and then again
712+
# for animated PNG/TIFF once the first fix special-cased .gif rather
713+
# than the branch optimize_raster really takes. Both times the symptom
714+
# was silent - a name claimed but never written, an unrelated image
715+
# de-conflicted against the phantom and its stored URLs rewritten, or
716+
# a name written but never claimed, free to overwrite another source's
717+
# output. Nothing connected the two halves, so nothing noticed.
718+
#
719+
# This is that connection. It costs one set lookup per file and turns
720+
# every future drift into an immediate, named failure instead of a
721+
# corrupted image corpus that still exits 0.
722+
if dst_final.name not in claimed_for[src]:
723+
raise RuntimeError(
724+
f"internal error: {src} wrote {dst_final.name}, which possible_output_names did not predict "
725+
f"(it claimed {sorted(claimed_for[src]) or 'nothing'}). The output-name planning pass and "
726+
"process_file have drifted apart - see possible_output_names."
727+
)
674728
written.append(dst_final)
675729
rel_final = dst_final.relative_to(output_dir)
676730
if rel_final != rel:

‎ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/tests/test_review_findings.py‎

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -773,7 +773,7 @@ def test_includes_inside_any_fence_are_not_scanned(source, expected):
773773
exposed the rest of the block. Both warned about files nobody meant to
774774
ship. md_to_json.fenced_spans - which extract_title already relies on -
775775
knows both fence characters and the "at least as long" close rule."""
776-
assert INCLUDE_RE.findall(outside_fences(source)) == expected
776+
assert INCLUDE_RE.findall(outside_fences(source, "topics/x.md")) == expected
777777

778778

779779
# =============================================================================
@@ -815,8 +815,13 @@ def test_names_differing_only_by_case_are_both_kept(tmp_path):
815815
written = sorted(p.name for p in result.written)
816816
assert len(written) == 2, "neither source may be dropped - both are referenceable"
817817
assert len(set(n.lower() for n in written)) == 2, "and they must not collide once flattened"
818-
# The de-conflicted one is reported, so stored URLs follow it.
819-
assert result.renamed == {"b/logo.png": "b/logo-png.png"}
818+
# The de-conflicted one is reported, so stored URLs follow it. Asserted as
819+
# a property rather than against the "{stem}-{ext}" literal: the naming
820+
# scheme is not what this test is about, and pinning it here would make a
821+
# rename of that convention look like a case-handling regression.
822+
assert list(result.renamed) == ["b/logo.png"], "only the second, de-conflicted source moved"
823+
new_name = Path(result.renamed["b/logo.png"]).name
824+
assert new_name in written and new_name.lower() != "logo.png"
820825

821826

822827
def test_identical_basenames_are_still_skipped(tmp_path):
@@ -864,7 +869,8 @@ def test_animated_png_is_not_predicted_as_webp(tmp_path):
864869
@pytest.mark.parametrize("suffix, animated, expected", [
865870
(".gif", True, {"x.gif"}),
866871
(".gif", False, {"x.webp"}),
867-
(".gif", None, {"x.gif", "x.webp"}),
872+
(".gif", None, set()), # undetermined: optimize_raster fails too, so nothing is written
873+
(".png", None, set()),
868874
(".png", True, {"x.png"}),
869875
(".png", False, {"x.webp"}),
870876
(".tiff", True, {"x.tiff"}),
@@ -921,3 +927,66 @@ def test_repair_refuses_half_a_scan(tmp_path):
921927
assert rmf.repair(conn, [], [])["chains_renumbered"] == 0 # both: uses them
922928
finally:
923929
conn.close()
930+
931+
932+
# --- what process_file writes has to be what the planner claimed ------------
933+
934+
def test_a_write_the_planner_did_not_predict_is_refused(tmp_path, monkeypatch):
935+
"""possible_output_names predicts, in a second place, what process_file ->
936+
optimize_raster -> encode_raster will do, and that prediction has drifted
937+
twice. Nothing connected the two halves, so both drifts were silent: a
938+
name claimed but never written de-conflicts an unrelated image and
939+
rewrites its stored URLs, and a name written but never claimed is free to
940+
overwrite another source's output."""
941+
src, out = tmp_path / "in", tmp_path / "out"
942+
src.mkdir(), out.mkdir()
943+
Image.new("RGB", (20, 20), (1, 1, 1)).save(src / "photo.png")
944+
945+
# Stand in for a future encoder change the planner doesn't know about.
946+
real_process_file = om.process_file
947+
def drifting_process_file(source, dst, **kwargs):
948+
written = real_process_file(source, dst, **kwargs)
949+
return written.with_suffix(".avif") if written else written
950+
monkeypatch.setattr(om, "process_file", drifting_process_file)
951+
952+
with pytest.raises(RuntimeError, match="possible_output_names did not predict"):
953+
om.optimize_directory(src, out, cfg=dict(om.BUILTIN_DEFAULTS), pngquant_path=om.find_pngquant(),
954+
logger=om.Logger(sys.stdout), stats=_new_stats())
955+
956+
957+
def test_an_unprobeable_source_claims_no_names(tmp_path, capsys):
958+
"""A raster whose animation can't be determined is one optimize_raster is
959+
about to fail on for the same reason, so it writes nothing and must hold
960+
nothing. Claiming both names instead de-conflicted a perfectly good
961+
unrelated image against an output that never appears - renaming it, and
962+
rewriting every stored URL that pointed at it."""
963+
src, out = tmp_path / "in", tmp_path / "out"
964+
src.mkdir(), out.mkdir()
965+
(src / "broken.png").write_bytes(b"not a png at all")
966+
Image.new("RGB", (20, 20), (2, 2, 2)).save(src / "broken.jpg")
967+
968+
cfg = dict(om.BUILTIN_DEFAULTS) | {"webp": True}
969+
stats = _new_stats()
970+
result = om.optimize_directory(src, out, cfg=cfg, pngquant_path=om.find_pngquant(),
971+
logger=om.Logger(sys.stdout), stats=stats)
972+
973+
# broken.jpg keeps its own stem: nothing real ever claimed broken.webp.
974+
assert [p.name for p in result.written] == ["broken.webp"]
975+
assert result.renamed == {"broken.jpg": "broken.webp"}
976+
assert stats["errors"] == 1, "the unreadable file is still counted as one file's error"
977+
# And the probe said why, rather than swallowing it.
978+
assert "could not determine whether" in capsys.readouterr().out
979+
980+
981+
def test_the_probe_reports_its_own_failure(tmp_path, capsys):
982+
"""_is_animated_raster now runs for every PNG and TIFF in the tree, not
983+
just the handful of GIFs, so a systematic failure would shift the whole
984+
de-confliction pass. Swallowing it left no way to find out why."""
985+
bad = tmp_path / "corrupt.png"
986+
bad.write_bytes(b"still not a png")
987+
988+
assert om._is_animated_raster(bad, om.Logger(sys.stdout)) is None
989+
assert "could not determine whether" in capsys.readouterr().out
990+
# Silent without a logger, so a standalone caller isn't forced to have one.
991+
assert om._is_animated_raster(bad) is None
992+
assert capsys.readouterr().out == ""

‎run-build-kotlin-docs-with-act.sh‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,16 +126,6 @@ while [ $# -gt 0 ]; do
126126
esac
127127
done
128128

129-
# Checked here rather than as this script's first act: a mistyped flag should
130-
# be reported as a mistyped flag on any machine, not shadowed by "act is
131-
# required" on the machines that don't have it. Nothing above this line needs
132-
# act, and the argument checks below are pure string/path validation - the
133-
# first thing that actually uses it is the `act` invocation at the end.
134-
if ! command -v act >/dev/null 2>&1; then
135-
echo "error: act is required - see https://github.com/nektos/act#installation" >&2
136-
exit 1
137-
fi
138-
139129
if [ "$SKIP_WEBSITE_DOCS" = "true" ] && [ "$SKIP_STDLIB_DOCS" = "true" ]; then
140130
echo "error: --skip-website-docs and --skip-stdlib-docs together skip every step that" >&2
141131
echo "error: changes the database, leaving nothing for the run to do." >&2
@@ -187,6 +177,18 @@ if [ "$SKIP_WEBSITE_DOCS" != "true" ]; then
187177
WORKFLOW_IMAGES_ZIP_PATH="$CONTAINER_IMAGES_ZIP_PATH"
188178
fi
189179

180+
# Last, after every argument check above, and immediately before the only
181+
# thing that needs it. Anything earlier shadows a real complaint about the
182+
# arguments with "act is required" on the machines that don't have act -
183+
# which is most of them, including CI. Moving it off line 1 fixed that for
184+
# the parse errors; it has to sit below the semantic checks too, or a missing
185+
# --db-path or a --skip-website-docs/--skip-stdlib-docs pair still gets
186+
# answered with the wrong message.
187+
if ! command -v act >/dev/null 2>&1; then
188+
echo "error: act is required - see https://github.com/nektos/act#installation" >&2
189+
exit 1
190+
fi
191+
190192
if [ "$DRY_RUN" = "true" ]; then
191193
echo "note: dry_run=true - '$DB_PATH' will NOT be modified; the built database is" >&2
192194
echo "note: written to '$OUTPUT_DIR' only. Both Slack notifications still fire if" >&2

0 commit comments

Comments
 (0)