From 38cd8ebf3ffbc85f8e7554a10e87659187a1c20f Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Sun, 31 May 2026 04:04:14 +0900 Subject: [PATCH 1/3] build: move cargo caches out of the /workspace bind mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the sibling aozora fix. The cargo-target / cargo-registry / cargo-git / sccache named volumes were mounted at /workspace/{target,.cargo,.sccache} — nested inside the `.:/workspace` source bind mount. Because the dev container runs as root, the Docker daemon created those host-side mountpoints (./target, ./.cargo, ./.sccache) as root, littering the working tree with root-owned dirs and breaking any host-side cargo invocation. Move all four caches to /cargo/* (CARGO_HOME=/cargo/home, CARGO_TARGET_DIR=/cargo/target, SCCACHE_DIR=/cargo/sccache) — outside the bind mount. The daemon no longer touches the host tree and the checkout stays clean. The named volumes still persist the caches between runs. The build-time rustup override (CARGO_HOME=/usr/local/cargo for the fuzz stage's nightly install) is unaffected. Verified: `docker compose run dev` leaves the host ./target absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 23 ++++++++--------- docker-compose.yml | 62 +++++++++++++++++++++++++--------------------- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/Dockerfile b/Dockerfile index af8634f..2730bd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -159,19 +159,18 @@ COPY --from=cargo-tools /usr/local/bin/ /usr/local/bin/ # means the sync never fires. RUN rustup component add rustfmt clippy rust-src -ENV CARGO_HOME=/workspace/.cargo \ - CARGO_TARGET_DIR=/workspace/target \ +ENV CARGO_HOME=/cargo/home \ + CARGO_TARGET_DIR=/cargo/target \ RUSTC_WRAPPER=sccache \ - SCCACHE_DIR=/workspace/.sccache \ + SCCACHE_DIR=/cargo/sccache \ RUST_BACKTRACE=1 -# Pre-create cache mount targets so the runtime volume mounts at -# /workspace/{target,.cargo,.sccache} can attach without docker -# needing to mkdirat() into a read-only `/workspace`. Without these -# the `:ro` bind mount of the source tree blocks volume attachment -# and `docker compose run --rm ci ...` fails at container start with -# "read-only file system" during mountpoint creation. -RUN mkdir -p /workspace/target /workspace/.cargo /workspace/.sccache +# Pre-create the cache mount targets at /cargo/* so the named volume +# mounts attach cleanly. These live OUTSIDE the /workspace bind mount +# on purpose (see docker-compose.yml): nesting them under /workspace +# made the daemon create root-owned ./target / ./.cargo / ./.sccache +# on the host, littering the working tree and breaking host-side cargo. +RUN mkdir -p /cargo/target /cargo/home/registry /cargo/home/git /cargo/sccache WORKDIR /workspace @@ -192,9 +191,9 @@ FROM dev AS fuzz # `rustup toolchain install` tries to self-update by looking for the # rustup binary at $CARGO_HOME/bin/rustup. The inherited -# `CARGO_HOME=/workspace/.cargo` (set in the dev stage for runtime +# `CARGO_HOME=/cargo/home` (set in the dev stage for runtime # volume mounts) is empty at image-build time, so the self-update step -# bails with "rustup is not installed at '/workspace/.cargo'". Override +# bails with "rustup is not installed at '/cargo/home'". Override # the env for this one RUN so rustup finds itself at the parent rust # image's `/usr/local/cargo` location; the runtime CARGO_HOME setting # is unaffected. diff --git a/docker-compose.yml b/docker-compose.yml index 2f33f71..776e33d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,10 +13,17 @@ name: afm x-common-env: &common-env - CARGO_HOME: /workspace/.cargo - CARGO_TARGET_DIR: /workspace/target + # Cargo caches live at /cargo/* — OUTSIDE the /workspace bind mount — on + # purpose. Nesting a named volume inside the bind mount (the old + # /workspace/target etc.) makes the Docker daemon create the host-side + # mountpoint (./target, ./.cargo, ./.sccache) as **root**, which litters + # the working tree with root-owned junk and breaks any host-side cargo. + # Keeping the caches at /cargo/* leaves the host's own ./target free and + # keeps the checkout clean. The named volumes still persist them. + CARGO_HOME: /cargo/home + CARGO_TARGET_DIR: /cargo/target RUSTC_WRAPPER: sccache - SCCACHE_DIR: /workspace/.sccache + SCCACHE_DIR: /cargo/sccache # sccache cannot cache crates compiled with cargo's incremental mode. # Cargo defaults incremental=true for workspace members in dev/test profiles, # which silently bypasses sccache for the very crates we build most often. @@ -55,10 +62,10 @@ services: environment: *common-env volumes: - .:/workspace:cached - - cargo-registry:/workspace/.cargo/registry - - cargo-git:/workspace/.cargo/git - - cargo-target:/workspace/target - - sccache:/workspace/.sccache + - cargo-registry:/cargo/home/registry + - cargo-git:/cargo/home/git + - cargo-target:/cargo/target + - sccache:/cargo/sccache init: true tty: true stdin_open: true @@ -76,10 +83,10 @@ services: environment: *common-env volumes: - .:/workspace:cached - - cargo-registry:/workspace/.cargo/registry - - cargo-git:/workspace/.cargo/git - - cargo-target:/workspace/target - - sccache:/workspace/.sccache + - cargo-registry:/cargo/home/registry + - cargo-git:/cargo/home/git + - cargo-target:/cargo/target + - sccache:/cargo/sccache init: true tty: true stdin_open: true @@ -95,17 +102,16 @@ services: <<: *common-env CI: "true" volumes: - # `:cached` (not `:ro`): a `:ro` bind mount HIDES `/workspace/{target, - # .cargo,.sccache}` that were `mkdir -p`-ed in the image (overlayfs - # bind-mount semantics — the source dir replaces image contents), - # which then blocks the named-volume mountpoints below from - # attaching at runtime ("mkdirat ... read-only file system"). The - # runner is ephemeral; `:cached` is safe and the canonical CI mode. + # `:cached` (writable): CI steps emit artifacts back into the tree + # (wasm `pkg/`, generated docs, coverage reports), so the source + # mount stays writable. The cargo caches now live at /cargo/* + # (outside this bind mount), so the old ":ro hides the nested + # /workspace/{target,.cargo,.sccache} mountpoints" hazard is gone. - .:/workspace:cached - - cargo-registry:/workspace/.cargo/registry - - cargo-git:/workspace/.cargo/git - - cargo-target:/workspace/target - - sccache:/workspace/.sccache + - cargo-registry:/cargo/home/registry + - cargo-git:/cargo/home/git + - cargo-target:/cargo/target + - sccache:/cargo/sccache book: build: @@ -131,9 +137,9 @@ services: CI: "true" volumes: - .:/workspace:cached - - cargo-registry:/workspace/.cargo/registry - - cargo-git:/workspace/.cargo/git - - cargo-target:/workspace/target + - cargo-registry:/cargo/home/registry + - cargo-git:/cargo/home/git + - cargo-target:/cargo/target # Vite dev / preview server for the browser playground. # Reuses the `dev` image (which already has Node 22 + wasm-pack) so no @@ -150,10 +156,10 @@ services: environment: *common-env volumes: - .:/workspace:cached - - cargo-registry:/workspace/.cargo/registry - - cargo-git:/workspace/.cargo/git - - cargo-target:/workspace/target - - sccache:/workspace/.sccache + - cargo-registry:/cargo/home/registry + - cargo-git:/cargo/home/git + - cargo-target:/cargo/target + - sccache:/cargo/sccache - playground-node-modules:/workspace/playground/node_modules ports: - "5173:5173" From 6342e8d68a57ac5f47e341131a1fd11286cfd2bd Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Sun, 31 May 2026 03:36:24 +0900 Subject: [PATCH 2/3] ci(afm): run `just ci` in pre-push and add lefthook output/fail_text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-push hook ran only `test` / `deny` / `prop-deep` (in parallel), even though the repo already owns an 18-step `just ci` recipe — so a push could still introduce a lint / check / doc / spec / coverage / upstream-diff / verify-version-pins / book regression that only surfaced on the PR. Mirror the sibling aozora repo: run `just ci` sequentially (cheap-to-expensive) with `prop-deep` as a trailing deep sweep, and drop the now-redundant standalone `test` / `deny`. Also bring over aozora's lefthook DX: an explicit `output:` block so failures stay loud across lefthook version bumps, and a `fail_text:` on every command with a concrete repro hint (matches the project's dev-visibility / "no silent death" posture). `prop-deep` is tagged `deep` for `SKIP_TAGS=deep` opt-out. Co-Authored-By: Claude Opus 4.8 (1M context) --- lefthook.yml | 117 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 9 deletions(-) diff --git a/lefthook.yml b/lefthook.yml index 77a9ea1..7b0a54c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -5,6 +5,37 @@ # Disable: `just hooks-uninstall` # # Reference: https://lefthook.dev/configuration/ +# +# Status icons in the summary +# --------------------------- +# Lefthook prints one of three markers next to each command in its +# summary line: +# ✔️ success (exit 0) +# 🗙 failed +# 🥊 also a failure path — typically when the command exited +# non-zero but lefthook fell back to its branding icon because +# the underlying tooling (docker compose run, `just`) buried +# the status. Treat 🥊 the same as 🗙 — see the actual command +# output above the summary to find the real error. +# +# `fail_text` on each command below is the explicit human hint that +# lefthook displays on failure so it's obvious what to fix without +# scrolling back through hundreds of lines of cargo output. + +# Make the summary noisier on failures — show what command was +# executing, its stdout, the stderr, and the skip reasons (default +# already lists most of these, this is the explicit form so it +# survives lefthook version bumps that change the default set). +output: + - meta + - summary + - success + - failure + - execution + - execution_info + - execution_out + - skips + - empty_summary pre-commit: parallel: true @@ -20,14 +51,33 @@ pre-commit: glob: "*.rs" run: just fmt stage_fixed: true + fail_text: | + `cargo fmt` failed. Run `just fmt` manually to see the error. + Common cause: rustfmt edition mismatch with rust-toolchain.toml. clippy: glob: "*.{rs,toml}" run: just clippy + fail_text: | + clippy reported an error. Re-run `just clippy` for the full + diagnostic. `[workspace.lints]` in Cargo.toml controls which + lints fire — fix the code rather than silencing the lint. typos: run: just typos + fail_text: | + `typos` found a probable typo. Re-run `just typos` to see the + candidate fixes, or add an exception to `_typos.toml` if the + flagged token is intentional (proper noun, identifier, etc.). + # Guard the vendored comrak tree against any drift — ADR-0001 sets a + # 0-line diff budget. Runs only when files under `upstream/comrak/` + # actually changed. upstream-diff: glob: "upstream/comrak/**" run: just upstream-diff + fail_text: | + The vendored `upstream/comrak/` tree drifted from its pinned + upstream (ADR-0001, 0-line diff budget). Re-run + `just upstream-diff` to see the diff. A genuine comrak change + is a fork-divergence decision and needs its own ADR. commit-msg: commands: @@ -41,18 +91,67 @@ commit-msg: || { echo 'Commit message must follow Conventional Commits (https://www.conventionalcommits.org/)'; exit 1; }" pre-push: - parallel: true + # `just ci` runs gates sequentially in cheap-to-expensive order so + # the early ones fail fast. Parallelising at the lefthook level + # would let coverage start before lint catches a typo — and would + # also let the heavy steps race the cheap ones for the dev image's + # cargo cache. `just ci` already mirrors every CI job reachable from + # inside the dev image, so the hook just delegates to it. + # + # Push budget: a few minutes cold cache, less when warm. The + # alternative this hook exists to eliminate is "ship a PR, watch + # GitHub fail, re-push". + parallel: false skip: - rebase commands: - test: - run: just test - deny: - run: just deny + # Mirror CI. `just ci` is the 18-step fail-fast pipeline (typos / + # fmt-check / upstream-diff / strict-code / verify-version-pins / + # check / doc / deny / audit / lint / build / test / prop / + # spec-commonmark / spec-gfm / coverage / book-build / udeps). The + # CI-only jobs that need extra runtime (the PR-time wasm-build / + # playground build) are deliberately left out — `just ci` keeps the + # push under a typical attention budget. + ci: + run: just ci + fail_text: | + `just ci` failed. The error is the last `error: recipe + \`\` failed ...` line above this summary — that tells you + which sub-recipe (check / lint / build / test / spec-commonmark / + spec-gfm / coverage / book-build / udeps …) bombed. + + Common causes: + - upstream-diff → the vendored comrak tree drifted (ADR-0001); + run `just upstream-diff`. + - typos / clippy regressed → run the individual gate + (`just typos`, `just clippy`) for a cleaner repro. + - spec-commonmark / spec-gfm → a CommonMark/GFM conformance + example regressed; `just spec-commonmark` prints the failing + example index. + + Re-run `just ci` directly (not via lefthook) for the unwrapped + output if the lefthook summary truncates anything important. # Deep proptest sweep (4096 cases vs the CI baseline 128) — runs - # locally before push so a regression that needs many cases to - # surface is caught here instead of on a downstream PR. Fuzz is - # deliberately *not* wired in: libfuzzer's nightly + corpus + cold - # start budget overshoots a pre-push hook. + # after `just ci`'s 128-case `prop` step so a regression that needs + # many cases to surface is caught here instead of on a downstream + # PR. Fuzz is deliberately *not* wired in: libfuzzer's nightly + + # corpus + cold start budget overshoots a pre-push hook. prop-deep: run: just prop-deep + # Tag for opt-out via `SKIP_TAGS=deep git push`. A deep sweep can + # occasionally surface a pre-existing bug in the git-pinned aozora + # core (parse / serialize / round-trip) that is unrelated to the + # current change. `SKIP_TAGS=deep` lets that unrelated PR through + # without disabling the rest of the hook stack; file an issue + # against the failing aozora crate and bump the pin once fixed. + tags: + - deep + fail_text: | + Deep proptest regression. Re-run `just prop-deep` to reproduce. + The seed for the failing case is printed in nextest's PASS/FAIL + line; pass it via `AOZORA_PROPTEST_SEED=` to shrink locally. + + If the failure is in the git-pinned aozora CORE (parse / render / + pipeline) and is unrelated to your change, you may opt out with + `SKIP_TAGS=deep git push` + and file an issue against the failing crate. From a5ffddd4f88292cd613d6868c5a1c6c76f63d957 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Sun, 31 May 2026 04:26:37 +0900 Subject: [PATCH 3/3] build(afm): deny all rustdoc lints; drop unused criterion/insta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring afm's rustdoc posture in line with the sibling aozora repo, which denies all eight rustdoc lints. afm previously denied only `broken_intra_doc_links` and left the other seven at `warn` — which, per the shared lint-profile policy, is a soft suppression. - Promote all rustdoc lints to `deny`. This surfaced real defects: the `ir` module doc linked private items (`build_ir`, `OpenContainer`, `ast_splice`, `projection`, `types`, `SentinelCursor`, `ParaScan`). - Fix them at the root by demoting those intra-doc links to plain code spans. (afm's `just strict-code` forbids `#[allow]` outright — even with a `reason` — so the fix is to refactor the docs, never silence.) - Drop `criterion` and `insta` from `[workspace.dependencies]`: both were declared but consumed by zero crates — vestigial template inheritance that contradicts the udeps discipline `just ci` already runs. Verified `just strict-code` + `just doc` green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 25 ++++++++++++------------- crates/afm-markdown/src/ir/mod.rs | 20 ++++++++++---------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 47ec688..309421c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,18 +69,19 @@ dead_code = "deny" rust_2024_compatibility = { level = "warn", priority = -1 } [workspace.lints.rustdoc] -# Docs are a shipped surface: broken intra-doc links are a defect, not a -# smell. Keep this one `deny`. +# Docs are a shipped surface. All rustdoc lints are `deny` (matching the +# sibling aozora repo): broken / private links, bad codeblocks, invalid +# HTML, bare URLs, redundant links, and unescaped backticks are defects, +# not smells. A genuinely work-in-progress doc surface takes a scoped +# `#[allow(rustdoc::…, reason = "…")]`, never a blanket warn. broken_intra_doc_links = "deny" -# Everything else warns so drift shows up in CI without breaking the build -# while docs get fleshed out. -private_intra_doc_links = "warn" -invalid_codeblock_attributes = "warn" -invalid_html_tags = "warn" -invalid_rust_codeblocks = "warn" -bare_urls = "warn" -redundant_explicit_links = "warn" -unescaped_backticks = "warn" +private_intra_doc_links = "deny" +invalid_codeblock_attributes = "deny" +invalid_html_tags = "deny" +invalid_rust_codeblocks = "deny" +bare_urls = "deny" +redundant_explicit_links = "deny" +unescaped_backticks = "deny" [workspace.lints.clippy] # Opt-in to all three lint groups that mainstream Rust libraries ship with. @@ -219,9 +220,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } clap = { version = "4.6.1", features = ["derive", "wrap_help", "env", "color", "cargo"] } # Testing -insta = { version = "1.47", features = ["yaml", "json", "filters"] } proptest = "1.11" -criterion = { version = "0.8", features = ["html_reports"] } pretty_assertions = "1.4.1" [profile.release] diff --git a/crates/afm-markdown/src/ir/mod.rs b/crates/afm-markdown/src/ir/mod.rs index 76f8346..8133ea5 100644 --- a/crates/afm-markdown/src/ir/mod.rs +++ b/crates/afm-markdown/src/ir/mod.rs @@ -17,13 +17,13 @@ //! `Gaiji` / `Annotation` (inline) and `Container` / `PageBreak` / //! `SectionBreak` (block). Heading hints //! (`[#「X」は大見出し]`) promote their host paragraph to -//! `IrBlock::Heading` directly, mirroring [`crate::ast_splice`]. +//! `IrBlock::Heading` directly, mirroring `crate::ast_splice`. //! //! # Module map //! -//! - [`types`] — public IR enum/struct definitions (`IrDocument`, +//! - `types` — public IR enum/struct definitions (`IrDocument`, //! `IrBlock`, `IrInline`, `Range`, ...). -//! - [`projection`] — pure helpers that convert `AozoraNode` +//! - `projection` — pure helpers that convert `AozoraNode` //! variants into IR values plus the enum→string mappers and the //! sourcepos→range bridge. No walker state. //! - This file (`mod.rs`) — the stateful walker (`IrWalker`, @@ -35,17 +35,17 @@ //! //! The walker is built from three small primitives: //! -//! 1. [`crate::sentinel_stream::SentinelCursor`] — the shared registry-stream -//! cursor. The HTML splicer ([`crate::ast_splice`]) and this +//! 1. `crate::sentinel_stream::SentinelCursor` — the shared registry-stream +//! cursor. The HTML splicer (`crate::ast_splice`) and this //! builder both consume the same source-order sequence of //! `NodeRef` entries; the cursor abstraction keeps them in //! lockstep. -//! 2. [`ParaScan`] — single-descent paragraph profile. One walk per +//! 2. `ParaScan` — single-descent paragraph profile. One walk per //! paragraph computes both the sole-block-sentinel test and the //! heading-hint lookahead at once, eliminating the two-scan //! redundancy that a naive translation of the HTML splicer would //! have. -//! 3. [`OpenContainer`] — the per-walker container stack. Where the +//! 3. `OpenContainer` — the per-walker container stack. Where the //! HTML splicer can stream open/close tags into a string buffer, //! the IR demands a tree, so each open container collects //! `IrBlock`s into its own `Vec` until the matching close arrives. @@ -131,7 +131,7 @@ impl<'src> StreamingIrBuilder<'src> { /// Walk a single comrak block, advancing the shared cursor. /// Streaming-mode containers fragment per-block; for whole-doc - /// nesting use [`build_ir`]. + /// nesting use `build_ir`. pub fn walk_block<'a>(&mut self, node: &'a AstNode<'a>) -> Vec { // Move the cursor into a freshly-constructed walker for the // duration of this call, then take it back. The walker's @@ -154,7 +154,7 @@ impl<'src> StreamingIrBuilder<'src> { /// Tree builder that consumes comrak nodes plus a sentinel cursor and /// emits `IrBlock`s into a stack-balanced container hierarchy. /// -/// The state mirrors [`crate::ast_splice`]'s splicer for the HTML +/// The state mirrors `crate::ast_splice`'s splicer for the HTML /// side: same cursor, same balanced-container model, same /// orphan-close drain at end-of-document. They differ only in the /// emit target (rewritten comrak AST vs. tree of `Vec`). @@ -195,7 +195,7 @@ impl<'src> IrWalker<'src> { /// Drain any unclosed containers (mirror of the HTML splicer's /// end-of-document orphan-close pass) and return the document - /// blocks. Used by [`build_ir`]. + /// blocks. Used by `build_ir`. fn finish(self) -> Vec { self.finish_keeping_cursor().0 }