Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
336e119
tooling: add a re-runnable package-rename script, with rename trackin…
astubbs Aug 10, 2026
f92df27
fix(copyright): resolve provenance through the package move, not by c…
astubbs Aug 10, 2026
eee5ce7
docs(agents): cite the plan by PR, since the tooling merges first
astubbs Aug 10, 2026
fb9c853
tooling(rename): demote --single-commit to a self-test arm, not a usa…
astubbs Aug 10, 2026
7450360
Merge master, re-placing this branch's rules into the split AGENTS.md
astubbs Aug 11, 2026
b8177cd
tooling(rename): assert each prose guard still matches its sentence
astubbs Aug 11, 2026
98768fc
fix(tooling): name the marker index by file, so the index does not li…
astubbs Aug 11, 2026
d19cf9b
tooling(rename): fold the project's general utilities into the intern…
astubbs Aug 12, 2026
be56321
tooling(rename): delete the catch-all mapping, and refuse a package n…
astubbs Aug 12, 2026
052e39e
docs(tooling): point the rename script at the project's in-flight entry
astubbs Aug 12, 2026
2ef6402
docs(tooling): drop the branch-divergence caveat from the in-flight p…
astubbs Aug 12, 2026
9a6ddad
Merge remote-tracking branch 'origin/master' into tooling/package-ren…
astubbs Aug 12, 2026
dc87cff
fix(tooling): build the dead-pattern control on \d, which no engine r…
astubbs Aug 12, 2026
d983471
tooling(rename): give the script the procedure a stale branch needs t…
astubbs Aug 12, 2026
2483862
tooling(rename): the per-branch procedure, freeze regions, and the fi…
astubbs Aug 12, 2026
0a0b9f7
tooling(rename): make each prose guard carry both spellings, so it ne…
astubbs Aug 12, 2026
a5638cb
docs(inflight): the grand-sweep plan, for a session that has to execu…
astubbs Aug 12, 2026
2a20286
fix(tooling): stop awk -v from eating the freeze-marker regex on anot…
astubbs Aug 12, 2026
2904be7
docs(inflight): close the CI open item, and name the repo its PR numb…
astubbs Aug 12, 2026
aaf53cf
fix(tooling): let --defer-prose past a guard whose sentence was never…
astubbs Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/repo-hygiene.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,26 @@ jobs:
- name: No SIGPIPE-prone pipes in bin/
run: bash bin/check-shell-sigpipe.sh

rename:
name: "tooling: package rename"
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Needs to build fixture git repositories and make commits in them, but only in a temp dir -
# it never touches this checkout, so shallow is fine.
- uses: actions/checkout@v6

# bin/rename-packages.sh is run by hand, once per branch, on master and on ~29 open PRs. That
# is exactly the shape of tool that rots unnoticed: nobody exercises it between the day it is
# written and the day the whole rename depends on it, and by then a regression costs a
# conflict storm across every open PR. Seconds to run, so it runs on every PR.
#
# The self-test includes three NEGATIVE CONTROLS (a stale reference, a misspelt one, and the
# escaped-regex form the habitual `grep -rn "io\.confluent"` cannot see) plus the paired
# commit-shape experiment whose result set the script's default. See its header.
- name: Self-test the package-rename tool
run: bash bin/test-rename-packages.sh

actions:
name: "workflows: action versions"
runs-on: ubuntu-latest
Expand Down
27 changes: 27 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,33 @@ costs seconds.
Worked example and method:
[`docs/solutions/workflow-issues/read-the-commits-you-inherit-2026-08-10.md`](docs/solutions/workflow-issues/read-the-commits-you-inherit-2026-08-10.md).

## IN FLIGHT: rename your branch BEFORE you merge master

One such inherited instruction, live now: the fork is moving `io.confluent.*` to `bz.stub.*` with
`bin/rename-packages.sh`, branch by branch. It binds **any** agent merging **any** branch until no
open branch predates the rename - then delete this section. Reasoning, measurements and the task
inventory are in the script's own header and in
`docs/plans/2026-08-11-001-refactor-package-rename-plan.md`, which arrives with
astubbs/parallel-consumer#277 - the tooling merges first, so on master the plan lands after this
section does.

- **Run `bin/rename-packages.sh` on your branch, then merge master.** Both sides then agree on where
every file lives and what it is called, so the merge is ordinary.
- **A clean merge is not evidence you were safe to skip it.** Merging renamed master into an
un-renamed branch reported **zero conflicts** and silently applied the streams module's ArchUnit
edit into the *mutiny* module's file - git paired the five near-identical
`TestConventionsArchTest.java` files across modules. Renamed on both sides, that case surfaces as
a rename/rename conflict on the right file, for a human to resolve.
- **Sweep with `grep -rnE 'io[\\./]*conflu'`, never `grep -rn "io\.confluent"`.** Three files encode
the package as an escaped regex and one misspells it, so the habitual sweep reports success
without `bin/lib/quarantine-common.sh` even appearing in its output.
- **Assert the renames git RECORDED *and* their pairing - a bare R-count reads a mis-paired rename
as healthy.** `bin/rename-packages.sh` asserts both; if you moved anything by hand, do both by
hand.
- **Confirm the mutation lane scored mutants rather than trusting its tick.**
`bin/ci-mutation-test.sh` exits **0** printing "nothing to mutate, skipping" when its package
regex is stale, which is indistinguishable from a pass in the job summary.

## Overview

Parallel Consumer is a Java library for concurrent message processing from Apache Kafka with a
Expand Down
208 changes: 189 additions & 19 deletions bin/check-copyright-headers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,24 @@
# fork-point commit to be present in history (CI: actions/checkout fetch-depth: 0).
# "Modified" is judged against the WORKING TREE, so local uncommitted edits count.
#
# PROVENANCE SURVIVES THE PACKAGE MOVE, AND THAT IS A DESIGN CONSTRAINT, NOT A DETAIL.
# A file's provenance is a fact about the fork-point tree, so it has to be resolved
# against the path the file had THERE - not the path it has today. Looking the CURRENT
# path up in the fork-point listing worked only while the two spellings coincided; the
# fork's io.confluent.* -> bz.stub.* rename (bin/rename-packages.sh) ends that, and the
# failure is an INVERSION rather than a degradation: every upstream-derived file misses
# the lookup, is judged fork-original, and its REQUIRED Confluent header becomes a
# violation. Measured on a renamed throwaway clone: 0 -> 197 violations. This script is
# bound to maven's `validate` phase, so that is not one red CI job, it is every `./mvnw`
# invocation on the tree dying before it starts.
#
# So each path is mapped back through PACKAGE_MOVES below BEFORE any lookup. Both
# spellings resolve to the same fork-point path, which is what lets the check pass on a
# renamed branch, an un-renamed branch, and the half-renamed tree that exists between
# the rename's two commits - all of which are live at once while the rename rolls out
# across the open branches. See docs/plans/2026-08-11-001-refactor-package-rename-plan.md
# for why a ~200-entry RENAMED_FROM_UPSTREAM manifest was rejected in favour of a rule.
#
# Years are deliberately NOT policed: never bump copyright years as an incidental
# change (see AGENTS.md). This scanner IS the header enforcement. It replaced the
# mycila license-maven-plugin, which knew only ONE header template - running its
Expand All @@ -45,11 +63,79 @@ FORK_POINT="${COPYRIGHT_CHECK_FORK_POINT:-7f2901226bccac68a2a71f0d9da343887b1abb
FORK_HOLDER="Antony Stubbs and contributors"
HEADER_WINDOW=8 # lines from the top of the file searched for copyright notices

# The fork's package move, as PATH prefixes: 'current-form|fork-point-form'. Every path this
# script handles - the files it scans and both halves of the manifests below - is mapped through
# this table before it is looked up, so a file keeps its provenance across the move.
#
# A SMALL TABLE, NOT ~200 RENAMED_FROM_UPSTREAM ENTRIES. The move is a rule, so it is written as
# one (or a handful): a per-file manifest of everything that moved would be unreviewable, and the
# next file to move under a moved package would silently not be in it. Derived, this needs no
# maintenance.
#
# THE NESTED ENTRIES MUST STAY ABOVE THE GENERAL ONE. Both packages under the second upstream-owned
# prefix fold INTO the library's internals - `io/confluent/csid/utils` to
# `bz/stub/parallelconsumer/internal/utils` and `io/confluent/csid/testcontainers` to
# `bz/stub/parallelconsumer/internal/testcontainers` (see bin/rename-packages.sh for the reasoning).
# Their bz/stub halves sit INSIDE the general `bz/stub/parallelconsumer` prefix on the last line, and
# fork_point_path() below returns on the FIRST prefix match it finds - so if the general rule were
# checked first, every file the nested rules actually moved would match it too and resolve to a
# fork-point path under parallelconsumer that upstream never owned. That is not a near miss: the
# resulting path holds no blob at the fork point, so the file is judged fork-original and its
# REQUIRED upstream header is reported as a violation. bin/test-check-copyright-headers.sh runs that
# reordering as a control arm and asserts the extra violations appear, so the ordering is measured
# rather than asserted in a comment.
#
# THERE IS NO GENERAL RULE FOR THE SECOND PREFIX ANY MORE. It used to map onto a same-named prefix
# under bz/stub, which carried upstream's mark into the new namespace; bin/rename-packages.sh
# deleted the equivalent rule and now REFUSES to run on a package no rule names. Both tables list
# every package explicitly, and that is the invariant the drift guard below protects.
#
# NOT SHARED WITH bin/rename-packages.sh, DELIBERATELY. That script is a migration tool and is
# deleted once the rename has landed on every branch; this table describes the fork point, which is
# immutable history, so it must outlive it. Wiring a permanent gate - one bound to maven's validate
# phase - into a disposable script buys DRY at the cost of the gate breaking when the tool goes.
# bin/test-check-copyright-headers.sh cross-checks EVERY rule here against bin/rename-packages.sh's
# PKG_MAP while both exist, so the only real drift risk (someone changing a target package before
# the rename lands) fails loudly. It reads this one with an anchored grep, which is why entries sit
# flush against the opening quote.
PACKAGE_MOVES="
bz/stub/parallelconsumer/internal/utils|io/confluent/csid/utils
bz/stub/parallelconsumer/internal/testcontainers|io/confluent/csid/testcontainers
bz/stub/parallelconsumer|io/confluent/parallelconsumer"

# Sets FP_PATH rather than echoing it: this runs once per java file, and a command substitution
# would fork a subshell each time on a script that maven runs at the start of every build.
FP_PATH=""
fork_point_path() { # <path in the working tree> -> FP_PATH = the path it had at the fork point
local p="$1" new old
while IFS='|' read -r new old; do
[ -n "$new" ] || continue
case "$p" in
"$new"/*) FP_PATH="${old}${p#"$new"}"; return 0 ;;
*/"$new"/*) FP_PATH="${p%%/"$new"/*}/${old}/${p#*/"$new"/}"; return 0 ;;
esac
done <<EOF
$PACKAGE_MOVES
EOF
FP_PATH="$p"
}

fp_path_of() { # <path> -> prints the fork-point path (for the manifest normalisation below)
fork_point_path "$1"
printf '%s' "$FP_PATH"
}

# Fork-side RENAMES of upstream files: 'newpath|oldpath-at-fork-point'. They keep the
# Confluent header; if their content has diverged from the fork-point blob they must
# also carry the modifications line. (The first three were relocated in fface195; the
# MockConsumer* trio was renamed so surefire would actually collect them - their old
# names matched none of its include patterns, so they had never run in CI.)
#
# These paths move with the package rename too, and BOTH halves are normalised through
# PACKAGE_MOVES before use, so an entry matches whichever spelling it is written in. The
# newpath half is a current path and bin/rename-packages.sh retargets it; the oldpath
# half names a path in the UPSTREAM tree and must keep saying io/confluent - normalising
# it as well is a no-op on a correct manifest and repairs one that a bulk rewrite got at.
RENAMED_FROM_UPSTREAM="
parallel-consumer-examples/parallel-consumer-example-metrics/src/test/java/io/confluent/parallelconsumer/examples/metrics/integrationTests/CoreAppMetricsIntegrationTest.java|parallel-consumer-examples/parallel-consumer-example-metrics/src/test/java/io/confluent/parallelconsumer/examples/metrics/CoreAppMetricsIntegrationTest.java
parallel-consumer-examples/parallel-consumer-example-metrics/src/test/java/io/confluent/parallelconsumer/examples/metrics/integrationTests/PrometheusContainer.java|parallel-consumer-examples/parallel-consumer-example-metrics/src/test/java/io/confluent/parallelconsumer/examples/metrics/PrometheusContainer.java
Expand Down Expand Up @@ -83,9 +169,98 @@ if ! git cat-file -e "${FORK_POINT}^{commit}" 2>/dev/null; then
exit 0
fi

upstream_files=$(git ls-tree -r --name-only "$FORK_POINT")
# vs the working tree, so uncommitted local edits are judged too
modified_since_fork=$(git diff --name-only "$FORK_POINT" -- '*.java')
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
TAB=$'\t'

# The fork-point tree as 'blob-sha<TAB>path', java only - it is the only extension asked about.
# Both membership ("was this file upstream?") and modification ("has it changed since?") are
# answered from this ONE listing, which is what makes the answer independent of the file's current
# path: a moved file is looked up under the path it had HERE. The old design answered the second
# question from `git diff --name-only`, a list of CURRENT names, which cannot describe a file that
# has moved - the two halves of the model disagreed the moment a package moved.
git ls-tree -r "$FORK_POINT" > "$TMP/fork-tree.txt"
awk -F'\t' '$2 ~ /\.java$/ { split($1, a, " "); print a[3] "\t" $2 }' "$TMP/fork-tree.txt" \
| LC_ALL=C sort -t"$TAB" -k2,2 > "$TMP/fork-blobs.tsv"

# Working-tree blob hashes for every java file that exists on disk, in ONE `git hash-object` call
# (0.06s for 233 files) rather than a `git cat-file | cmp` per file. That matters: this script is
# bound to maven's validate phase, so its cost is paid by every build. Hashing the working tree,
# not the index, keeps the documented behaviour that uncommitted local edits count as modified.
: > "$TMP/files.txt"
while IFS= read -r f; do
[ -f "$f" ] || continue
printf '%s\n' "$f" >> "$TMP/files.txt"
done <<EOF
$(git ls-files '*.java')
EOF
# `if`, not `[ -s f ] && cmd`: under `set -e` that list exits the script when the test is false.
: > "$TMP/shas.txt"
if [ -s "$TMP/files.txt" ]; then
git hash-object --stdin-paths < "$TMP/files.txt" > "$TMP/shas.txt"
fi
paste "$TMP/files.txt" "$TMP/shas.txt" > "$TMP/pairs.tsv"

fork_point_sha() { # <fork-point path> -> blob sha, empty if that path held no java blob there
# reads a FILE, not a pipe: `awk ... exit` on the reading end of a pipe would SIGPIPE the
# writer, and under pipefail a MATCH would then read as failure
awk -F'\t' -v p="$1" '$2 == p { print $1; exit }' "$TMP/fork-blobs.tsv"
}

# Resolve each file's fork-point path and pair it with its fork-point blob in ONE `join`, rather
# than an `awk` lookup per file: 233 extra process spawns doubled this script's runtime when it
# was written that way, and maven pays it on every build. LC_ALL=C so both sides collate alike.
while IFS="$TAB" read -r f cur_sha; do
fork_point_path "$f"
printf '%s\t%s\t%s\n' "$FP_PATH" "$f" "$cur_sha"
done < "$TMP/pairs.tsv" | LC_ALL=C sort -t"$TAB" -k1,1 > "$TMP/by-fork-path.tsv"
# -a 1: keep files with no fork-point counterpart (fork-original), with an empty sha field.
LC_ALL=C join -t"$TAB" -1 1 -2 2 -a 1 -e '' -o 1.1,1.2,1.3,2.1 \
"$TMP/by-fork-path.tsv" "$TMP/fork-blobs.tsv" > "$TMP/resolved.tsv"

# Normalise both halves of the manifests to fork-point spelling, once, so the per-file lookups
# below compare like with like whichever spelling the entries happen to be written in. Held in
# arrays and searched in pure bash: these lists are a handful of entries, and a `grep`/`awk` per
# scanned file is a process spawn per file for a seven-line lookup.
rename_new=(); rename_old=(); n_renames=0
while IFS= read -r entry; do
case "$entry" in *'|'*) ;; *) continue ;; esac
rename_new[$n_renames]="$(fp_path_of "${entry%%|*}")"
rename_old[$n_renames]="$(fp_path_of "${entry#*|}")"
n_renames=$((n_renames + 1))
done <<EOF
$RENAMED_FROM_UPSTREAM
EOF

extracted=(); n_extracted=0
while IFS= read -r entry; do
[ -n "$entry" ] || continue
extracted[$n_extracted]="$(fp_path_of "$entry")"
n_extracted=$((n_extracted + 1))
done <<EOF
$EXTRACTED_FROM_UPSTREAM
EOF

registered_rename() { # <fork-point path> -> sets RENAME_ORIGIN; 1 if the path is not registered
# exact match on the newpath field - a substring match would misroute files whose path is a
# tail-substring of a registered newpath into the rename branch
local i=0
RENAME_ORIGIN=""
while [ "$i" -lt "$n_renames" ]; do
if [ "${rename_new[$i]}" = "$1" ]; then RENAME_ORIGIN="${rename_old[$i]}"; return 0; fi
i=$((i + 1))
done
return 1
}

registered_extraction() { # <fork-point path>
local i=0
while [ "$i" -lt "$n_extracted" ]; do
if [ "${extracted[$i]}" = "$1" ]; then return 0; fi
i=$((i + 1))
done
return 1
}

fails=0
checked=0
Expand Down Expand Up @@ -113,30 +288,27 @@ require_modifications_line() { # <file> <header> <reason>
fi
}

while IFS= read -r f; do
[ -f "$f" ] || continue
# fp_path is THE line the whole provenance model turns on: every lookup below asks about the path
# the file had at the FORK POINT, never the path it has now. It is identity for a file that has
# not moved, so an un-renamed tree behaves exactly as it did before this was introduced.
while IFS=$'\t' read -r fp_path f cur_sha fp_sha; do
[ -n "$f" ] || continue
checked=$((checked + 1))
header=$(head -"$HEADER_WINDOW" "$f")

# exact match on the newpath field - a substring match would misroute files
# whose path is a tail-substring of a registered newpath into the rename branch
# herestring, not `printf | awk`: awk's early `exit` would SIGPIPE printf, and under
# `set -e` a failed $() assignment kills the whole script
rename_entry=$(awk -F'|' -v f="$f" '$1 == f {print; exit}' <<< "$RENAMED_FROM_UPSTREAM")
if [ -n "$rename_entry" ]; then
old_path=${rename_entry#*|}
if registered_rename "$fp_path"; then
require_confluent "$f" "$header" || { fails=$((fails + 1)); continue; }
if ! git cat-file blob "${FORK_POINT}:${old_path}" 2>/dev/null | cmp -s - "$f"; then
if [ "$(fork_point_sha "$RENAME_ORIGIN")" != "$cur_sha" ]; then
require_modifications_line "$f" "$header" \
"renamed upstream file modified since the fork point" || fails=$((fails + 1))
fi
elif grep -qxF "$f" <<< "$EXTRACTED_FROM_UPSTREAM"; then
elif registered_extraction "$fp_path"; then
require_confluent "$f" "$header" || { fails=$((fails + 1)); continue; }
require_modifications_line "$f" "$header" \
"extraction of upstream-derived code" || fails=$((fails + 1))
elif grep -qxF "$f" <<< "$upstream_files"; then
elif [ -n "$fp_sha" ]; then
require_confluent "$f" "$header" || { fails=$((fails + 1)); continue; }
if grep -qxF "$f" <<< "$modified_since_fork"; then
if [ "$fp_sha" != "$cur_sha" ]; then
require_modifications_line "$f" "$header" \
"upstream-derived file modified since the fork point" || fails=$((fails + 1))
fi
Expand All @@ -150,9 +322,7 @@ while IFS= read -r f; do
fails=$((fails + 1))
fi
fi
done <<EOF
$(git ls-files '*.java')
EOF
done < "$TMP/resolved.tsv"

echo "Checked ${checked} java files against fork point ${FORK_POINT} - ${fails} violation(s)."
[ "$fails" -eq 0 ]
Loading
Loading