ssh git@grit.deadkey.org man

Commit 1108ea8 h

Pat Ambrosio · 26-08-26 11:15

Make the lock cover the layout, and let pages use it too

Changes: 4 files +196 -13

Changes (Side-by-Side)

bin/jenny +9 -3

if source_blogrc; then
219 # garbage silently, so refuse to build against one. 219 # garbage silently, so refuse to build against one.
220 assert_layouts_current "$LAYOUT_DIR" || exit 1 220 assert_layouts_current "$LAYOUT_DIR" || exit 1
221 221
222 # Everything that shapes the output but is not the document: the layouts and
223 # the settings they read. Recorded with each lock entry so a theme edit
224 # invalidates rendered files the way a content edit does.
225 BUILD_SUM=$(build_signature "$LAYOUT_DIR")
226 export BUILD_SUM
227
222 # Sets up directory 228 # Sets up directory
223 touch "$BLOG_LOCK" 229 touch "$BLOG_LOCK"
224 mkdir -p "$DIST/post" 230 mkdir -p "$DIST/post"
begin () {
310 slug=$(get_unique_slug "$f") 316 slug=$(get_unique_slug "$f")
311 forRendering=true 317 forRendering=true
312 echo "$T + /post/$slug.html" 318 echo "$T + /post/$slug.html"
313 echo "$slug $filename_sum $content_sum" >> "$BLOG_LOCK"
319 echo "$slug $filename_sum $content_sum $BUILD_SUM" >> "$BLOG_LOCK"
314 else 320 else
315 # not new 321 # not new
316 slug=$(get_existing_slug "$filename_sum") 322 slug=$(get_existing_slug "$filename_sum")
317 if is_changed "$filename_sum" "$content_sum"; then
323 if is_changed "$filename_sum" "$content_sum" "$BUILD_SUM"; then
318 echo "$T ↑ /post/$slug.html" 324 echo "$T ↑ /post/$slug.html"
319 $SED -i "/ $filename_sum /d" "$BLOG_LOCK" 325 $SED -i "/ $filename_sum /d" "$BLOG_LOCK"
320 echo "$slug $filename_sum $content_sum" >> "$BLOG_LOCK"
326 echo "$slug $filename_sum $content_sum $BUILD_SUM" >> "$BLOG_LOCK"
321 forRendering=true 327 forRendering=true
322 fi 328 fi
323 fi 329 fi

share/jenny/lib/components.sh +26 -4

render_pages () {
280 title=$(basename "${rel%.md}") 280 title=$(basename "${rel%.md}")
281 fi 281 fi
282 282
283 echo "$T ▤ /${rel%.md}.html"
284 if ! render_page "$src" "$out" "$title" "$dist"; then
285 BUILD_FAILED=1
286 continue
283 # Pages consult the lock the way posts do. Before this they were rewritten
284 # on every build, so a run with nothing changed still rendered every page
285 # -- and, worse, a layout edit updated the pages while the posts were
286 # skipped, leaving one build's output half old and half new.
287 #
288 # A page has no slug indirection, so the path is its identity: the first
289 # column is the page's own path rather than a generated name. That is also
290 # what keeps a page's entry from colliding with a post's.
291 local page_key page_sum
292 page_key="page:${rel%.md}"
293 page_sum=$(cat "$src" | $SUM | cut -d ' ' -f 1)
294 local render_this=true
295 if [ "${FORCE_RENDER:-0}" -ne 1 ] \
296 && [ -f "$out" ] \
297 && ! is_changed "$page_key" "$page_sum" "${BUILD_SUM:-}"; then
298 render_this=false
299 fi
300
301 if [ "$render_this" = true ]; then
302 echo "$T ▤ /${rel%.md}.html"
303 if ! render_page "$src" "$out" "$title" "$dist"; then
304 BUILD_FAILED=1
305 continue
306 fi
307 "$SED" -i "/ $page_key /d" "$BLOG_LOCK" 2>/dev/null || true
308 echo "$page_key $page_key $page_sum ${BUILD_SUM:-}" >> "$BLOG_LOCK"
287 fi 309 fi
288 record_page "$dist" "${rel%.md}.html" 310 record_page "$dist" "${rel%.md}.html"
289 count=$((count + 1)) 311 count=$((count + 1))

share/jenny/lib/helpers.sh +72 -6

function is_scheduled() {
184 # Lookups match on a named column so a filename hash can never be satisfied 184 # Lookups match on a named column so a filename hash can never be satisfied
185 # by another line's content hash. 185 # by another line's content hash.
186 186
187 # build_signature <layout_dir>
188 # A hash of everything that shapes the output but is not the document itself:
189 # the layout files, and the settings in .blogrc that layouts read. Stored
190 # alongside each entry in the lock so a themed change invalidates the rendered
191 # files the way a content change does.
192 #
193 # Without this a layout edit was invisible to the lock. Posts consulted it and
194 # were skipped; pages did not and were rewritten -- so one build produced a
195 # site where the pages had the new layout and the posts still had the old one.
196 # The inconsistency reads as a layout bug rather than a stale build, which is
197 # the worst way for it to fail.
198 #
199 # The whole directory is hashed rather than each layout separately: a partial
200 # is shared between layouts, and attributing which output depends on which file
201 # is a dependency graph this does not need. The cost is that editing one layout
202 # re-renders everything, which is the same work the -f flag has always done.
203 function build_signature() {
204 local layout_dir=$1
205 {
206 # Sorted so the hash does not depend on directory order.
207 find "$layout_dir" -type f -name '*.sh' -print0 2>/dev/null \
208 | LC_ALL=C sort -z \
209 | xargs -0 cat 2>/dev/null
210 # Nav registries. A page renders the nav it declares, so editing
211 # navs/study-toc.tsv changes that page's output without touching the
212 # page's own source -- exactly the kind of edit the lock would otherwise
213 # skip. They are hashed with the layouts rather than per page because
214 # which page reads which nav is not knowable here.
215 find "${NAVS_DIR:-navs}" -type f -name '*.tsv' -print0 2>/dev/null \
216 | LC_ALL=C sort -z \
217 | xargs -0 cat 2>/dev/null
218 # Settings a layout reads. A change to URL_STYLE or the site title changes
219 # every rendered file just as surely as editing the template does.
220 printf '%s\n' "${BLOG_TITLE:-}" "${BLOG_HOST:-}" "${URL_STYLE:-}" \
221 "${POSTS_PER_PAGE:-}" "${DIAGRAM_SCRIPT:-}"
222 } | $SUM | cut -d ' ' -f 1
223 }
224
187 function is_new() { 225 function is_new() {
188 local filename_sum=$1 226 local filename_sum=$1
189 if "$AWK" -v sum="$filename_sum" '$2 == sum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then 227 if "$AWK" -v sum="$filename_sum" '$2 == sum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then
function is_new() {
193 fi 231 fi
194 } 232 }
195 233
234 # is_changed <filename_sum> <content_sum> [build_sum]
235 # A file is unchanged only when its content and the build signature both match
236 # what the lock recorded. An entry written before signatures existed has no
237 # fourth column and so never matches, which re-renders it once and then records
238 # the signature -- an old lock upgrades itself rather than needing to be reset.
196 function is_changed() { 239 function is_changed() {
197 local filename_sum=$1 content_sum=$2
198 if "$AWK" -v fsum="$filename_sum" -v csum="$content_sum" \
199 '$2 == fsum && $3 == csum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then
240 local filename_sum=$1 content_sum=$2 build_sum=${3:-}
241 if "$AWK" -v fsum="$filename_sum" -v csum="$content_sum" -v bsum="$build_sum" \
242 '$2 == fsum && $3 == csum && $4 == bsum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then
200 return 1 # unchanged 243 return 1 # unchanged
201 else 244 else
202 return 0 # changed 245 return 0 # changed
function sweep_orphans() {
345 type jenny_register_temp >/dev/null 2>&1 && jenny_register_temp "$kept" 388 type jenny_register_temp >/dev/null 2>&1 && jenny_register_temp "$kept"
346 389
347 # Explicit IFS: the lock is space-separated, and a caller may have left 390 # Explicit IFS: the lock is space-separated, and a caller may have left
348 # IFS set to something else.
349 while IFS=' ' read -r slug fsum csum; do
391 # IFS set to something else. The fourth column is the build signature; it is
392 # read and written back so this pass does not strip it -- rewriting a kept
393 # line with three fields would have silently dropped every signature and made
394 # each post look changed on the following build.
395 local bsum
396 while IFS=' ' read -r slug fsum csum bsum; do
350 [ -z "$slug" ] && continue 397 [ -z "$slug" ] && continue
351 398
399 # A page entry names its own source path rather than a hashed filename, so
400 # it is checked against that path. Sweeping it by the post rule -- looking
401 # for a *.md in the blog root whose name hashes to fsum -- found nothing,
402 # because a page's source lives under pages/, so every page entry was
403 # discarded on the build that wrote it and pages never skipped at all.
404 case "$slug" in
405 page:*)
406 if [ -f "${PAGES_DIR:-pages}/${slug#page:}.md" ]; then
407 printf '%s %s %s %s\n' "$slug" "$fsum" "$csum" "$bsum" >> "$kept"
408 else
409 if [ -f "$dist/${slug#page:}.html" ]; then
410 echo "$T - /${slug#page:}.html (source removed)"
411 rm -f "$dist/${slug#page:}.html"
412 fi
413 fi
414 continue
415 ;;
416 esac
417
352 # Is there still a source file whose name hashes to fsum? 418 # Is there still a source file whose name hashes to fsum?
353 found="" 419 found=""
354 for f in *.md; do 420 for f in *.md; do
function sweep_orphans() {
360 done 426 done
361 427
362 if [ -n "$found" ]; then 428 if [ -n "$found" ]; then
363 printf '%s %s %s\n' "$slug" "$fsum" "$csum" >> "$kept"
429 printf '%s %s %s %s\n' "$slug" "$fsum" "$csum" "$bsum" >> "$kept"
364 else 430 else
365 if [ -f "$dist/post/$slug.html" ]; then 431 if [ -f "$dist/post/$slug.html" ]; then
366 echo "$T - /post/$slug.html (source removed)" 432 echo "$T - /post/$slug.html (source removed)"

test/regressions.sh +89 -0

assert_success "$status" "$output"
250 it "the fallback title is the slug" 250 it "the fallback title is the slug"
251 assert_file_contains "$nt/.dist/index.html" "headless" 251 assert_file_contains "$nt/.dist/index.html" "headless"
252 252
253 suite "incremental builds notice more than post content"
254
255 # The lock recorded a post's filename and content only. A layout edit was
256 # invisible to it, so posts were skipped -- while pages, which consulted no
257 # lock at all, were rewritten every build. One build therefore produced a site
258 # where the pages had the new layout and the posts still had the old one, which
259 # reads as a layout bug rather than a stale build.
260
261 inc="$TMPROOT/incremental"
262 make_blog_min "$inc" 1 10
263 mkdir -p "$inc/pages" "$inc/navs"
264 printf '# A Page\n\nBody.\n' > "$inc/pages/p.md"
265 cp -r "$JENNY_SHARE_DIR/layout" "$inc/mylayout"
266 run_jenny "$inc"
267
268 inc_post=$(find "$inc/.dist/post" -name '*.html' | head -1)
269 inc_stamp() { "$SED" -n "s/.*//p" /dev/null; stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
270
271 it "a rebuild with nothing changed rewrites nothing"
272 # Pages used to be rendered unconditionally, so every build rewrote them all.
273 before_post=$(inc_stamp "$inc_post")
274 before_page=$(inc_stamp "$inc/.dist/p.html")
275 sleep 1
276 run_jenny "$inc"
277 assert_eq "$(inc_stamp "$inc_post")" "$before_post"
278 assert_eq "$(inc_stamp "$inc/.dist/p.html")" "$before_page"
279
280 it "editing one page rebuilds only that page"
281 sleep 1
282 printf '# A Page\n\nEdited.\n' > "$inc/pages/p.md"
283 run_jenny "$inc"
284 assert_eq "$(inc_stamp "$inc_post")" "$before_post"
285 [ "$(inc_stamp "$inc/.dist/p.html")" != "$before_page" ] \
286 && pass "the edited page was rebuilt" || fail "the edited page was skipped"
287
288 it "a layout edit rebuilds posts and pages alike"
289 # The defect this fixes: one build leaving half the site on the old layout.
290 sleep 1
291 "$SED" -i 's|<article>|<article data-mark="ZZ">|' "$inc/mylayout/post.sh" "$inc/mylayout/page.sh"
292 ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
293 assert_file_contains "$inc_post" 'data-mark="ZZ"'
294 assert_file_contains "$inc/.dist/p.html" 'data-mark="ZZ"'
295
296 it "a nav registry edit rebuilds the pages that read it"
297 # A page renders the nav it declares, so editing the .tsv changes that page's
298 # output without touching the page's own source.
299 sleep 1
300 printf 'Nav: side\n\n# Navved\n\nBody.\n' > "$inc/pages/navved.md"
301 printf 'One\t/one\n' > "$inc/navs/side.tsv"
302 ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
303 assert_file_contains "$inc/.dist/navved.html" 'One'
304 sleep 1
305 printf 'Two\t/two\n' > "$inc/navs/side.tsv"
306 ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
307 assert_file_contains "$inc/.dist/navved.html" 'Two'
308
309 it "a settings change rebuilds everything"
310 # BLOG_TITLE and URL_STYLE reach every rendered file.
311 sleep 1
312 "$SED" -i 's|^BLOG_TITLE=.*|BLOG_TITLE="Renamed Blog"|' "$inc/.blogrc"
313 ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
314 assert_file_contains "$inc_post" 'Renamed Blog'
315 assert_file_contains "$inc/.dist/p.html" 'Renamed Blog'
316
317 it "the lock keeps its build signature across a sweep"
318 # sweep_orphans rewrote every kept line with three fields, which would have
319 # stripped the signature and made each post look changed on the next build.
320 assert_eq "$("$AWK" 'NF && $1 !~ /^page:/ { print NF; exit }' "$inc/.bloglock")" "4"
321
322 it "a page entry survives the sweep"
323 # Page entries name their source path; swept by the post rule -- a *.md in the
324 # blog root whose name hashes to column two -- they matched nothing and were
325 # discarded on the very build that wrote them, so pages never skipped.
326 assert_eq "$("$GREP" -c '^page:' "$inc/.bloglock")" "2"
327
328 it "a removed page is swept from the lock and the output"
329 sleep 1
330 rm "$inc/pages/navved.md"
331 ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
332 assert_file_absent "$inc/.dist/navved.html"
333 assert_eq "$("$GREP" -c 'page:navved' "$inc/.bloglock" || true)" "0"
334
335 it "-f still forces a rebuild"
336 sleep 1
337 before_forced=$(inc_stamp "$inc_post")
338 ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" -f >/dev/null 2>&1 )
339 [ "$(inc_stamp "$inc_post")" != "$before_forced" ] \
340 && pass "-f rebuilt the post" || fail "-f did not rebuild the post"
341
253 suite "P1-5 deleted sources are swept" 342 suite "P1-5 deleted sources are swept"
254 343
255 it "deleting a source removes its rendered HTML" 344 it "deleting a source removes its rendered HTML"

Raw

commit 1108ea848749a50ae2b91d36be5e6ef346044409
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Wed Aug 26 11:15:45 2026 +0800

    Make the lock cover the layout, and let pages use it too
    
    Posts consulted the lock; pages did not. So a layout edit produced a site
    where the pages had the new template and the posts still had the old one,
    from a single build with no warning. That is worse than a stale build: it
    reads as a layout bug, which is how it cost time in this session.
    
    The lock recorded a post's filename and content only. It now also records
    a build signature -- the hash of every layout file, the nav registries, and
    the .blogrc settings that layouts read. A theme edit, a nav edit or a title
    change invalidates rendered files the way a content edit always has.
    
    Pages get the same treatment, keyed on their own path rather than a slug.
    Before this they were rewritten on every build, so a run with nothing
    changed still re-rendered every page. Now a no-op build writes nothing at
    all except the index and the 404, which regenerate by design because the
    index lists every post.
    
    Nav registries are in the signature because a page renders the nav it
    declares: editing navs/study-toc.tsv changes that page's output without
    touching the page's source, which is exactly the edit the lock would
    otherwise skip. The existing nav-sections tests caught this -- they rewrite
    a .tsv and rebuild, and started failing the moment pages began skipping.
    
    Two bugs in sweep_orphans came out of this. It read three columns and wrote
    three back, so it would have stripped the signature from every kept line
    and made each post look changed on the following build. And it swept page
    entries by the post rule -- looking for a *.md in the blog root whose name
    hashes to column two -- which matches nothing for a page under pages/, so
    every page entry was discarded on the build that wrote it and pages would
    never have skipped at all.
    
    -f still forces a full rebuild and is unchanged; it is no longer the only
    way to get a consistent site after touching a template.
    
    Tests: a suite covering the no-op build, a single-page edit, a layout edit
    reaching posts and pages alike, a nav edit, a settings change, signature
    survival across a sweep, page-entry survival, page removal, and -f.
    Checked by mutation -- pages never skipping, the layout left out of the
    signature, the sweep stripping the fourth column -- each caught.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01WnSAstgnDYdKPwLGv1YZE5

diff --git a/bin/jenny b/bin/jenny
index 49a4bc8..8d42466 100755
--- a/bin/jenny
+++ b/bin/jenny
@@ -219,6 +219,12 @@ if source_blogrc; then
   # garbage silently, so refuse to build against one.
   assert_layouts_current "$LAYOUT_DIR" || exit 1
 
+  # Everything that shapes the output but is not the document: the layouts and
+  # the settings they read. Recorded with each lock entry so a theme edit
+  # invalidates rendered files the way a content edit does.
+  BUILD_SUM=$(build_signature "$LAYOUT_DIR")
+  export BUILD_SUM
+
   # Sets up directory
   touch "$BLOG_LOCK"
   mkdir -p "$DIST/post"
@@ -310,14 +316,14 @@ begin () {
         slug=$(get_unique_slug "$f")
         forRendering=true
         echo "$T + /post/$slug.html"
-        echo "$slug $filename_sum $content_sum" >> "$BLOG_LOCK"
+        echo "$slug $filename_sum $content_sum $BUILD_SUM" >> "$BLOG_LOCK"
       else
         # not new
         slug=$(get_existing_slug "$filename_sum")
-        if is_changed "$filename_sum" "$content_sum"; then
+        if is_changed "$filename_sum" "$content_sum" "$BUILD_SUM"; then
           echo "$T ↑ /post/$slug.html"
           $SED -i "/ $filename_sum /d" "$BLOG_LOCK"
-          echo "$slug $filename_sum $content_sum" >> "$BLOG_LOCK"
+          echo "$slug $filename_sum $content_sum $BUILD_SUM" >> "$BLOG_LOCK"
           forRendering=true
         fi
       fi
diff --git a/share/jenny/lib/components.sh b/share/jenny/lib/components.sh
index ee1b4c3..7f56967 100644
--- a/share/jenny/lib/components.sh
+++ b/share/jenny/lib/components.sh
@@ -280,10 +280,32 @@ render_pages () {
       title=$(basename "${rel%.md}")
     fi
 
-    echo "$T ▤ /${rel%.md}.html"
-    if ! render_page "$src" "$out" "$title" "$dist"; then
-      BUILD_FAILED=1
-      continue
+    # Pages consult the lock the way posts do. Before this they were rewritten
+    # on every build, so a run with nothing changed still rendered every page
+    # -- and, worse, a layout edit updated the pages while the posts were
+    # skipped, leaving one build's output half old and half new.
+    #
+    # A page has no slug indirection, so the path is its identity: the first
+    # column is the page's own path rather than a generated name. That is also
+    # what keeps a page's entry from colliding with a post's.
+    local page_key page_sum
+    page_key="page:${rel%.md}"
+    page_sum=$(cat "$src" | $SUM | cut -d ' ' -f 1)
+    local render_this=true
+    if [ "${FORCE_RENDER:-0}" -ne 1 ] \
+       && [ -f "$out" ] \
+       && ! is_changed "$page_key" "$page_sum" "${BUILD_SUM:-}"; then
+      render_this=false
+    fi
+
+    if [ "$render_this" = true ]; then
+      echo "$T ▤ /${rel%.md}.html"
+      if ! render_page "$src" "$out" "$title" "$dist"; then
+        BUILD_FAILED=1
+        continue
+      fi
+      "$SED" -i "/ $page_key /d" "$BLOG_LOCK" 2>/dev/null || true
+      echo "$page_key $page_key $page_sum ${BUILD_SUM:-}" >> "$BLOG_LOCK"
     fi
     record_page "$dist" "${rel%.md}.html"
     count=$((count + 1))
diff --git a/share/jenny/lib/helpers.sh b/share/jenny/lib/helpers.sh
index da6d09a..9a12a14 100644
--- a/share/jenny/lib/helpers.sh
+++ b/share/jenny/lib/helpers.sh
@@ -184,6 +184,44 @@ function is_scheduled() {
 # Lookups match on a named column so a filename hash can never be satisfied
 # by another line's content hash.
 
+# build_signature <layout_dir>
+# A hash of everything that shapes the output but is not the document itself:
+# the layout files, and the settings in .blogrc that layouts read. Stored
+# alongside each entry in the lock so a themed change invalidates the rendered
+# files the way a content change does.
+#
+# Without this a layout edit was invisible to the lock. Posts consulted it and
+# were skipped; pages did not and were rewritten -- so one build produced a
+# site where the pages had the new layout and the posts still had the old one.
+# The inconsistency reads as a layout bug rather than a stale build, which is
+# the worst way for it to fail.
+#
+# The whole directory is hashed rather than each layout separately: a partial
+# is shared between layouts, and attributing which output depends on which file
+# is a dependency graph this does not need. The cost is that editing one layout
+# re-renders everything, which is the same work the -f flag has always done.
+function build_signature() {
+  local layout_dir=$1
+  {
+    # Sorted so the hash does not depend on directory order.
+    find "$layout_dir" -type f -name '*.sh' -print0 2>/dev/null \
+      | LC_ALL=C sort -z \
+      | xargs -0 cat 2>/dev/null
+    # Nav registries. A page renders the nav it declares, so editing
+    # navs/study-toc.tsv changes that page's output without touching the
+    # page's own source -- exactly the kind of edit the lock would otherwise
+    # skip. They are hashed with the layouts rather than per page because
+    # which page reads which nav is not knowable here.
+    find "${NAVS_DIR:-navs}" -type f -name '*.tsv' -print0 2>/dev/null \
+      | LC_ALL=C sort -z \
+      | xargs -0 cat 2>/dev/null
+    # Settings a layout reads. A change to URL_STYLE or the site title changes
+    # every rendered file just as surely as editing the template does.
+    printf '%s\n' "${BLOG_TITLE:-}" "${BLOG_HOST:-}" "${URL_STYLE:-}" \
+                   "${POSTS_PER_PAGE:-}" "${DIAGRAM_SCRIPT:-}"
+  } | $SUM | cut -d ' ' -f 1
+}
+
 function is_new() {
 	local filename_sum=$1
 	if "$AWK" -v sum="$filename_sum" '$2 == sum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then
@@ -193,10 +231,15 @@ function is_new() {
 	fi
 }
 
+# is_changed <filename_sum> <content_sum> [build_sum]
+# A file is unchanged only when its content and the build signature both match
+# what the lock recorded. An entry written before signatures existed has no
+# fourth column and so never matches, which re-renders it once and then records
+# the signature -- an old lock upgrades itself rather than needing to be reset.
 function is_changed() {
-	local filename_sum=$1 content_sum=$2
-	if "$AWK" -v fsum="$filename_sum" -v csum="$content_sum" \
-		'$2 == fsum && $3 == csum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then
+	local filename_sum=$1 content_sum=$2 build_sum=${3:-}
+	if "$AWK" -v fsum="$filename_sum" -v csum="$content_sum" -v bsum="$build_sum" \
+		'$2 == fsum && $3 == csum && $4 == bsum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then
 		return 1 # unchanged
 	else
 		return 0 # changed
@@ -345,10 +388,33 @@ function sweep_orphans() {
   type jenny_register_temp >/dev/null 2>&1 && jenny_register_temp "$kept"
 
   # Explicit IFS: the lock is space-separated, and a caller may have left
-  # IFS set to something else.
-  while IFS=' ' read -r slug fsum csum; do
+  # IFS set to something else. The fourth column is the build signature; it is
+  # read and written back so this pass does not strip it -- rewriting a kept
+  # line with three fields would have silently dropped every signature and made
+  # each post look changed on the following build.
+  local bsum
+  while IFS=' ' read -r slug fsum csum bsum; do
     [ -z "$slug" ] && continue
 
+    # A page entry names its own source path rather than a hashed filename, so
+    # it is checked against that path. Sweeping it by the post rule -- looking
+    # for a *.md in the blog root whose name hashes to fsum -- found nothing,
+    # because a page's source lives under pages/, so every page entry was
+    # discarded on the build that wrote it and pages never skipped at all.
+    case "$slug" in
+      page:*)
+        if [ -f "${PAGES_DIR:-pages}/${slug#page:}.md" ]; then
+          printf '%s %s %s %s\n' "$slug" "$fsum" "$csum" "$bsum" >> "$kept"
+        else
+          if [ -f "$dist/${slug#page:}.html" ]; then
+            echo "$T - /${slug#page:}.html (source removed)"
+            rm -f "$dist/${slug#page:}.html"
+          fi
+        fi
+        continue
+        ;;
+    esac
+
     # Is there still a source file whose name hashes to fsum?
     found=""
     for f in *.md; do
@@ -360,7 +426,7 @@ function sweep_orphans() {
     done
 
     if [ -n "$found" ]; then
-      printf '%s %s %s\n' "$slug" "$fsum" "$csum" >> "$kept"
+      printf '%s %s %s %s\n' "$slug" "$fsum" "$csum" "$bsum" >> "$kept"
     else
       if [ -f "$dist/post/$slug.html" ]; then
         echo "$T - /post/$slug.html (source removed)"
diff --git a/test/regressions.sh b/test/regressions.sh
index 3cc6221..d9eb9da 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -250,6 +250,95 @@ assert_success "$status" "$output"
 it "the fallback title is the slug"
 assert_file_contains "$nt/.dist/index.html" "headless"
 
+suite "incremental builds notice more than post content"
+
+# The lock recorded a post's filename and content only. A layout edit was
+# invisible to it, so posts were skipped -- while pages, which consulted no
+# lock at all, were rewritten every build. One build therefore produced a site
+# where the pages had the new layout and the posts still had the old one, which
+# reads as a layout bug rather than a stale build.
+
+inc="$TMPROOT/incremental"
+make_blog_min "$inc" 1 10
+mkdir -p "$inc/pages" "$inc/navs"
+printf '# A Page\n\nBody.\n' > "$inc/pages/p.md"
+cp -r "$JENNY_SHARE_DIR/layout" "$inc/mylayout"
+run_jenny "$inc"
+
+inc_post=$(find "$inc/.dist/post" -name '*.html' | head -1)
+inc_stamp() { "$SED" -n "s/.*//p" /dev/null; stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
+
+it "a rebuild with nothing changed rewrites nothing"
+# Pages used to be rendered unconditionally, so every build rewrote them all.
+before_post=$(inc_stamp "$inc_post")
+before_page=$(inc_stamp "$inc/.dist/p.html")
+sleep 1
+run_jenny "$inc"
+assert_eq "$(inc_stamp "$inc_post")" "$before_post"
+assert_eq "$(inc_stamp "$inc/.dist/p.html")" "$before_page"
+
+it "editing one page rebuilds only that page"
+sleep 1
+printf '# A Page\n\nEdited.\n' > "$inc/pages/p.md"
+run_jenny "$inc"
+assert_eq "$(inc_stamp "$inc_post")" "$before_post"
+[ "$(inc_stamp "$inc/.dist/p.html")" != "$before_page" ] \
+  && pass "the edited page was rebuilt" || fail "the edited page was skipped"
+
+it "a layout edit rebuilds posts and pages alike"
+# The defect this fixes: one build leaving half the site on the old layout.
+sleep 1
+"$SED" -i 's|<article>|<article data-mark="ZZ">|' "$inc/mylayout/post.sh" "$inc/mylayout/page.sh"
+( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
+assert_file_contains "$inc_post" 'data-mark="ZZ"'
+assert_file_contains "$inc/.dist/p.html" 'data-mark="ZZ"'
+
+it "a nav registry edit rebuilds the pages that read it"
+# A page renders the nav it declares, so editing the .tsv changes that page's
+# output without touching the page's own source.
+sleep 1
+printf 'Nav: side\n\n# Navved\n\nBody.\n' > "$inc/pages/navved.md"
+printf 'One\t/one\n' > "$inc/navs/side.tsv"
+( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
+assert_file_contains "$inc/.dist/navved.html" 'One'
+sleep 1
+printf 'Two\t/two\n' > "$inc/navs/side.tsv"
+( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
+assert_file_contains "$inc/.dist/navved.html" 'Two'
+
+it "a settings change rebuilds everything"
+# BLOG_TITLE and URL_STYLE reach every rendered file.
+sleep 1
+"$SED" -i 's|^BLOG_TITLE=.*|BLOG_TITLE="Renamed Blog"|' "$inc/.blogrc"
+( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
+assert_file_contains "$inc_post" 'Renamed Blog'
+assert_file_contains "$inc/.dist/p.html" 'Renamed Blog'
+
+it "the lock keeps its build signature across a sweep"
+# sweep_orphans rewrote every kept line with three fields, which would have
+# stripped the signature and made each post look changed on the next build.
+assert_eq "$("$AWK" 'NF && $1 !~ /^page:/ { print NF; exit }' "$inc/.bloglock")" "4"
+
+it "a page entry survives the sweep"
+# Page entries name their source path; swept by the post rule -- a *.md in the
+# blog root whose name hashes to column two -- they matched nothing and were
+# discarded on the very build that wrote them, so pages never skipped.
+assert_eq "$("$GREP" -c '^page:' "$inc/.bloglock")" "2"
+
+it "a removed page is swept from the lock and the output"
+sleep 1
+rm "$inc/pages/navved.md"
+( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
+assert_file_absent "$inc/.dist/navved.html"
+assert_eq "$("$GREP" -c 'page:navved' "$inc/.bloglock" || true)" "0"
+
+it "-f still forces a rebuild"
+sleep 1
+before_forced=$(inc_stamp "$inc_post")
+( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" -f >/dev/null 2>&1 )
+[ "$(inc_stamp "$inc_post")" != "$before_forced" ] \
+  && pass "-f rebuilt the post" || fail "-f did not rebuild the post"
+
 suite "P1-5  deleted sources are swept"
 
 it "deleting a source removes its rendered HTML"