From b574958423207d143ddb2f5e750b01c5d6e1f1e5 Mon Sep 17 00:00:00 2001 From: Mark Ridley <210189+maudlin@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:44:26 +0100 Subject: [PATCH] feat(forensics): knowledge-concentration / key-person (bus-factor) ownership check (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the PEOPLE axis to the git forensics. hotspots/coupling/bug-fix measure the code (what/where is risky); `ownership` measures who holds it — contribution concentration, the literal bus factor (authors to reach 50%/80%), sole-authored files, single-owned areas, and orphaned knowledge (sole-owned code whose only author has gone inactive). Pure `git log`, no new tooling; a natural fit for a deterministic localiser (tech due diligence + team prioritisation, ADR-0009). Design (ADR-0010): - Identity via git's own mailmap resolution (%aN/%aE), coalesced by email — we never guess aliases. The summary always carries an identity caveat. This is the headline correctness risk: without a .mailmap an author's two emails split them in two and the concentration reads falsely low. - Commit-touch ownership is primary (robust to one-off codemods); lines-added informs the concentration headline. Generated/vendored files are dropped up front by reusing the source inventory, so a committed bundle can't crown its committer. - Recency drives the orphaned-knowledge signal (absence-is-signal). - warn, never fail; non-git / shallow clone / thin history → honest skip. - Optional CHECKUP_OWNERSHIP_ANON=1 anonymises names for shared reports. Thresholds (keyperson %, sole-author %, orphan months) are tunable via .checkup.yml thresholds + env, defaulting to the literals so an absent block is byte-identical. The renderer picks the record up automatically (contract). - lib/ownership.jq: pure, unit-testable transform (rows → record) - bin/checkup.sh: section 23 — git log + awk normalise, feeds the transform - lib/config.sh + .checkup.yml.example: threshold keys - test/ownership.test.sh: 28 assertions on the transform (maths, thresholds, identity coalescing, orphaned recency, anonymise, honest skip); wired into CI - ADR-0010 + README/architecture docs Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VxHXw3RKcV6SBKary5iwcC --- .checkup.yml.example | 3 + .github/workflows/ci.yml | 3 + README.md | 4 +- bin/checkup.sh | 156 +++++++++++++++- docs/architecture.md | 3 +- .../0010-knowledge-concentration-forensic.md | 85 +++++++++ docs/decisions/README.md | 1 + lib/config.sh | 3 + lib/ownership.jq | 153 ++++++++++++++++ test/config.test.sh | 9 + test/ownership.test.sh | 167 ++++++++++++++++++ 11 files changed, 582 insertions(+), 5 deletions(-) create mode 100644 docs/decisions/0010-knowledge-concentration-forensic.md create mode 100644 lib/ownership.jq create mode 100755 test/ownership.test.sh diff --git a/.checkup.yml.example b/.checkup.yml.example index 40a520c..f5e2c8e 100644 --- a/.checkup.yml.example +++ b/.checkup.yml.example @@ -72,3 +72,6 @@ thresholds: # complexity_ccn_fail: 30 # any function at/above this → the record fails # duplication_warn_pct: 3 # duplication % at/above this → warn # duplication_fail_pct: 5 # duplication % at/above this → fail + # ownership_keyperson_pct_warn: 50 # top author over this % of the code → warn (bus factor) + # ownership_sole_author_pct_warn: 50 # single-author files over this % → warn + # ownership_orphan_months: 6 # sole author idle ≥ this many months → orphaned knowledge diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 889ade2..7a15601 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,9 @@ jobs: - name: Calibration tests run: bash test/calibration.test.sh + - name: Ownership transform tests + run: bash test/ownership.test.sh + docker-core: name: build checkup-core image runs-on: ubuntu-latest diff --git a/README.md b/README.md index 0ac9444..b7539ed 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ The following all work unchanged on any stack: has community rulesets for most major languages. - **Config-lint** — `yamllint`, `hadolint` are language-neutral. - **Git-axis** — `git-hotspots`, `change-coupling`, `bug-fix-density`, - `branch-hygiene` are pure git; identical on every stack. + `branch-hygiene`, `ownership` are pure git; identical on every stack. - **Contract, helpers, renderer** — language-agnostic by design. The language-specific work is concentrated in the ten npm-script-driven @@ -300,6 +300,8 @@ finding shape and the rest of the substrate carries it through unchanged. | `CHECKUP_SHELL_DIRS` | `shellcheck` section | `scripts .husky .githooks .claude/hooks` | Space-separated dirs to search for shell scripts. Missing dirs are skipped silently. | | `HADOLINT_DOCKERFILE` | `hadolint` section | auto-detect `Dockerfile*` at root | Override the Dockerfile filename when it is named non-conventionally. | | `MUTATION_TEST` | `mutation` section | unset (skipped) | Set to `1` to enable Stryker; opt-in because mutation testing is slow (~2 min). | +| `CHECKUP_OWNERSHIP_SINCE` | `ownership` section | unset (all history) | `git log --since` window for the bus-factor analysis. Ownership is **cumulative**, so the default is all-history (who has ever held the code); set e.g. `2.years.ago` to score only recent tenure. Separate from `CHECKUP_FORENSIC_SINCE`. | +| `CHECKUP_OWNERSHIP_ANON` | `ownership` section | unset (names shown) | Set to `1` to replace author names with stable, share-ranked pseudonyms (`Contributor 1`, …) for reports that get shared. Thresholds are tunable via `.checkup.yml thresholds` (`ownership_keyperson_pct_warn`, `ownership_sole_author_pct_warn`, `ownership_orphan_months`). | | `RAW_DIR` | every section (via `run_tool`) | `reports/raw` | Where each section's stdout/stderr capture is written. | | `PARSED_DIR` | every section (via `run_tool`) | `reports/parsed` | Where each section's normalised JSON is written. | diff --git a/bin/checkup.sh b/bin/checkup.sh index 9a4ef53..3390383 100755 --- a/bin/checkup.sh +++ b/bin/checkup.sh @@ -3426,7 +3426,157 @@ else fi echo "" -# 23. Documentation Presence (absence-is-signal, #51) +# 23. Knowledge Concentration / Key-Person (bus-factor forensic, ADR-0010, #127) +# section: ownership +# purpose: The PEOPLE axis of the git forensics. hotspots/coupling/bug-fix +# measure the code; this measures WHO holds it — contribution +# concentration, the literal bus factor (authors to reach 50%/80%), +# sole-authored files, single-owned areas, and orphaned knowledge +# (sole-owned code whose only author has gone inactive). Pure git log, +# no new tooling. Identity is git's OWN mailmap-resolved %aN/%aE, +# coalesced by email — we never guess aliases from initials (ADR-0010). +# pass_means: No author over the key-person threshold and no majority of +# single-author files — knowledge is shared. +# fail_means: One author dominates, files are single-authored, or sole-owned code +# has an inactive owner — a continuity/bus-factor risk. Cross-train or +# document. Reported as warn — a focus signal, never a gate. +# notes: Reuses the source inventory (generated/vendored already excluded) so +# a committed bundle can't crown its committer; commit-touch ownership +# is primary (robust to one-off codemods). Shallow clone / thin history +# / non-git → honest skip. CHECKUP_OWNERSHIP_ANON=1 anonymises names. +print_section "Knowledge Concentration (Key-Person / Bus Factor)" +echo "Command: git log --numstat (mailmap-resolved authorship over the source inventory)" +echo "" + +OWNERSHIP_INTENT=$(jq -n '{ + purpose: "Localise key-person dependency from git authorship — where knowledge concentrates in one author (bus factor). Identity is mailmap-resolved and email-coalesced; informational, never gates.", + pass_means: "No single author over the concentration threshold and no majority of single-author files — knowledge is shared.", + fail_means: "One author dominates, files are single-authored, or sole-owned code has an inactive owner — a continuity/bus-factor risk. Cross-train or document. (Reported as warn, a focus signal.)" +}') + +# Tunable thresholds (#72 / ADR-0010) — env or .checkup.yml thresholds, with the +# literals as defaults so an absent config is byte-identical. Non-integer env is +# coerced back to the default (jq --argjson would otherwise abort on garbage). +OWN_KEYPERSON_PCT="${CHECKUP_OWNERSHIP_KEYPERSON_PCT:-50}" +OWN_SOLE_PCT="${CHECKUP_OWNERSHIP_SOLE_PCT:-50}" +OWN_ORPHAN_MONTHS="${CHECKUP_OWNERSHIP_ORPHAN_MONTHS:-6}" +OWN_AREA_DEPTH="${CHECKUP_OWNERSHIP_AREA_DEPTH:-1}" +case "$OWN_KEYPERSON_PCT" in ''|*[!0-9]*) OWN_KEYPERSON_PCT=50;; esac +case "$OWN_SOLE_PCT" in ''|*[!0-9]*) OWN_SOLE_PCT=50;; esac +case "$OWN_ORPHAN_MONTHS" in ''|*[!0-9]*) OWN_ORPHAN_MONTHS=6;; esac +case "$OWN_AREA_DEPTH" in ''|*[!0-9]*|0) OWN_AREA_DEPTH=1;; esac +OWN_ORPHAN_DAYS=$(( OWN_ORPHAN_MONTHS * 30 )) +OWN_ANON="0"; [ -n "${CHECKUP_OWNERSHIP_ANON:-}" ] && [ "${CHECKUP_OWNERSHIP_ANON}" != "0" ] && OWN_ANON="1" + +if [ "$GIT_OK" != true ]; then + write_skipped "ownership" "not a git repository with history (or git absent) — authorship needs commits" "$OWNERSHIP_INTENT" +elif [ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = "true" ] || [ -f "$(git rev-parse --git-dir 2>/dev/null)/shallow" ]; then + # A truncated history mis-attributes ownership (the real author is beyond the + # graft) — skip honestly rather than headline a wrong bus factor (ADR-0003/0010). + echo -e "${BLUE}ℹ️ Shallow clone — authorship history is truncated${NC}" + write_skipped "ownership" "shallow clone — authorship history is truncated; ownership/bus-factor would be misattributed. Fetch full history (git fetch --unshallow) to enable" "$OWNERSHIP_INTENT" +else + OWN_NOW=$(date +%s) + + # Optional analysis window. Ownership is CUMULATIVE — the default is + # all-history (who has ever held this code); a window narrows to recent tenure. + OWN_SINCE_ARGS=() + [ -n "${CHECKUP_OWNERSHIP_SINCE:-}" ] && OWN_SINCE_ARGS=(--since="${CHECKUP_OWNERSHIP_SINCE}") + + # The current source inventory (tracked; generated/vendored already excluded) + # is the allow-list: only files that still exist AND count as source get + # authorship, so a deleted file or a committed bundle can't skew the numbers. + OWN_KEEP=$(inventory_paths "" | tr '\0' '\n' | grep -v '^$' || true) + + # One git-log pass: mailmap-resolved author (%aN/%aE) + timestamp per commit, + # then --numstat lines per file. A printable "@@CU@@"-prefixed, tab-delimited + # header row distinguishes commit rows from numstat rows (control-byte regex is + # not portable across awk implementations); numstat paths never start with it. + # --no-merges so a merge commit doesn't double-count its files' authorship. + OWNERSHIP_ROWS=$(git log --no-merges "${OWN_SINCE_ARGS[@]}" \ + --pretty=format:'@@CU@@%x09%aE%x09%aN%x09%at' --numstat \ + -- "${SCAN_ROOTS[@]}" 2>/dev/null \ + | awk -F'\t' -v keeplist="$OWN_KEEP" -v gitprefix="$GIT_PREFIX" ' + function strip(p) { return (gitprefix != "" && index(p, gitprefix) == 1) ? substr(p, length(gitprefix) + 1) : p } + function newpath(p) { + # git numstat rename notations → the NEW path + if (p ~ /\{.* => .*\}/) { gsub(/\{[^}]* => /, "", p); gsub(/\}/, "", p); gsub(/\/\//, "/", p); return p } + if (p ~ / => /) { sub(/^.* => /, "", p); return p } + return p + } + BEGIN { n = split(keeplist, ka, "\n"); for (i = 1; i <= n; i++) if (ka[i] != "") keep[ka[i]] = 1 } + $1 == "@@CU@@" { email = $2; name = $3; ts = $4 + 0; next } + $1 == "-" { next } # binary file (added shown as -) + NF >= 3 { + path = newpath(strip($3)) + if (!(path in keep)) next + key = path SUBSEP email + cnt[key]++ + add[key] += ($1 + 0) + if (ts > tsm[key]) { tsm[key] = ts; nm[key] = name } + } + END { + for (k in cnt) { + split(k, a, SUBSEP) + printf "%s\t%s\t%s\t%d\t%d\t%d\n", a[1], a[2], nm[k], cnt[k], add[k], tsm[k] + } + } + ') + + OWN_FILE_COUNT=$(printf '%s\n' "$OWNERSHIP_ROWS" | grep -v '^$' | cut -f1 | sort -u | wc -l | tr -d ' ') + + if [ -z "$OWNERSHIP_ROWS" ]; then + echo -e "${BLUE}ℹ️ No attributable source commits — ownership can't be computed${NC}" + write_skipped "ownership" \ + "no authored source history in the inventory (window/roots produced no attributable commits) — widen CHECKUP_OWNERSHIP_SINCE or check CHECKUP_SRC_ROOTS" \ + "$OWNERSHIP_INTENT" + elif [ "$OWN_FILE_COUNT" -lt 5 ]; then + echo -e "${BLUE}ℹ️ Only $OWN_FILE_COUNT tracked file(s) with authorship — too little to localise${NC}" + write_skipped "ownership" \ + "only $OWN_FILE_COUNT tracked file(s) with authorship — history too thin to localise ownership" \ + "$OWNERSHIP_INTENT" + else + OWN_HAS_MAILMAP=0; [ -f "$TARGET/.mailmap" ] && OWN_HAS_MAILMAP=1 + + OWNERSHIP_JSON=$(printf '%s\n' "$OWNERSHIP_ROWS" | jq -R -s -c \ + --argjson now "$OWN_NOW" \ + --argjson keypersonPct "$OWN_KEYPERSON_PCT" \ + --argjson solePct "$OWN_SOLE_PCT" \ + --argjson orphanDays "$OWN_ORPHAN_DAYS" \ + --argjson areaDepth "$OWN_AREA_DEPTH" \ + --arg anon "$OWN_ANON" \ + --arg hasMailmap "$OWN_HAS_MAILMAP" \ + -f "$CHECKUP_HOME/lib/ownership.jq") + + OWN_STATUS=$(echo "$OWNERSHIP_JSON" | jq -r '.status') + OWN_COUNT=$(echo "$OWNERSHIP_JSON" | jq -r '.count') + OWN_SUMMARY=$(echo "$OWNERSHIP_JSON" | jq -r '.summary') + OWN_TOP=$(echo "$OWNERSHIP_JSON" | jq -c '.findings') + + if [ "$OWN_STATUS" = "pass" ]; then + echo -e "${GREEN}✅ Knowledge is shared${NC} — $OWN_SUMMARY" + else + echo -e "${YELLOW}⚠️ $OWN_SUMMARY${NC}" + printf "%-18s %-8s %s\n" "Signal" "Severity" "Detail" + echo "----------------------------------------------------------------------------------------" + echo "$OWN_TOP" | jq -r ' + .[0:10][] + | [ .code, .severity, + (if (.file // "") == "" then .message else (.file + " — " + .message) end) ] + | @tsv + ' | awk -F'\t' '{ printf "%-18s %-8s %s\n", $1, $2, $3 }' + echo "" + OWN_ANON_LABEL=""; [ "$OWN_ANON" = "1" ] && OWN_ANON_LABEL="anonymised · " + echo " ${OWN_ANON_LABEL}top 20 in reports/parsed/ownership.json" + fi + + write_parsed "ownership" "$OWN_STATUS" "$OWN_COUNT" \ + "$OWN_SUMMARY" "$OWN_TOP" "$OWNERSHIP_INTENT" + fi +fi +echo "" + +# 24. Documentation Presence (absence-is-signal, #51) # section: docs # purpose: Does the codebase have an entry point to understand it? A repo with # no README/docs forces a newcomer — or an agent — to start with a @@ -3460,7 +3610,7 @@ else fi echo "" -# 24. Test Presence (absence-is-signal, #51) +# 25. Test Presence (absence-is-signal, #51) # section: test-presence # purpose: Is there ANY automated test safety net at all? A broad, # cross-language sweep for test FILES/dirs — deliberately HUMBLE: it @@ -3561,7 +3711,7 @@ case "$TOPO_SHAPE" in esac echo "" -# 25. Technology Viability (macro alarm, #52) +# 26. Technology Viability (macro alarm, #52) # section: tech-viability # purpose: Is this built on a LIVING platform? The single cheapest, loudest # risk signal — a dead/declining stack (Classic ASP, Flash, …) means diff --git a/docs/architecture.md b/docs/architecture.md index 7cfe7ec..341942a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,7 +65,8 @@ Artifact readers freshness-gate what they consume so a stale report can't pass. - **Cross-stack** (any repo, in `checkup-core`): `gitleaks`, `semgrep`, `shellcheck`, `yamllint`, `hadolint`, `codebase-stats`, `complexity`, - `git-hotspots`, `change-coupling`, `bug-fix-density`, `branch-hygiene`. + `git-hotspots`, `change-coupling`, `bug-fix-density`, `branch-hygiene`, + `ownership`. - **Project-built** (Node, in core; skip without the toolchain): `typecheck`, `unit-tests`, `build`, `code-quality`, `type-aware-lint`, `coverage`, `circular-deps`, `duplication`, `unused-code`, `deps-freshness`, `npm-audit`, diff --git a/docs/decisions/0010-knowledge-concentration-forensic.md b/docs/decisions/0010-knowledge-concentration-forensic.md new file mode 100644 index 0000000..6cff7b7 --- /dev/null +++ b/docs/decisions/0010-knowledge-concentration-forensic.md @@ -0,0 +1,85 @@ +# 0010 — Knowledge-concentration (key-person / bus-factor) forensic check + +- **Status:** Accepted (2026-07-15) + +## Context + +checkup's git forensics — `git-hotspots`, `change-coupling`, `bug-fix-density`, +`branch-hygiene` — all measure the **code**: *what* is risky and *where*. None +measure the **people**: *who* holds the code. Yet "what's the bus factor?" is the +number-one non-code question in tech due diligence (ADR-0009 use case 3), and a +cross-training map is exactly the team-prioritisation signal (use case 2). All of +it is derivable from `git log` alone — deterministic, reproducible, zero new +tooling — so it is a natural fit for a front-loaded localiser rather than +something an agent should burn tokens inferring. + +The signal is genuinely useful only if a few hard problems are handled honestly, +which is why it warrants a record: + +1. **Author identity is the correctness risk.** Ownership is computed by author, + and one human commits under many identities (`work@`, `personal@`, a CI bot, a + renamed account) while shared accounts merge many humans into one. Get identity + wrong and the *headline number is wrong* — this dwarfs every other design + choice. (A manual precursor run mislabelled a contributor by guessing identity + from initials; the tool must never guess.) +2. **Lines-added is a noisy proxy.** Generated/vendored files and mass reformats + or moves inflate authorship. Trusting raw line counts rewards whoever ran the + codemod. +3. **All-history ≠ maintainable now.** Someone who wrote a subsystem and left is a + *bigger* continuity risk than a healthy shared area — but a pure all-time + ownership tally would rank them as a reassuring "owner". +4. **It emits people's names** — PII that is fine in a local, uncommitted report + but not always in one that gets shared. + +## Decision + +Add an `ownership` forensic check (slug `ownership`) computed from a single +`git log --numstat` pass over the same scan roots the other forensics use. It +surfaces contribution concentration, the literal bus factor (authors to reach +50% / 80% of the code), sole-authored files, single-owned areas, and +**orphaned knowledge** (sole-owned code whose only author has gone inactive). + +Design commitments, each answering a Context problem: + +1. **Identity via git's own mailmap resolution, coalesced by email.** We read + `%aN`/`%aE` (the mailmap-applied forms), so a repo `.mailmap` is honoured for + free and identities coalesce by canonical email. We do **not** invent our own + alias heuristics (no initials-matching, no name fuzzing). The summary **always + carries an identity caveat** — unmerged aliases split one person, shared + accounts merge many — so the number is never read as more precise than it is. +2. **Commit-touch ownership as primary; lines-added as corroboration.** Per-file + ownership is decided by how many commits each author landed on the file + (robust to one-off codemods); lines-added share informs the concentration + headline. Generated/vendored files are dropped up front by reusing the + existing source inventory (the same exclude the complexity/duplication engines + trust) so a committed bundle can't crown its committer. +3. **Recency for the orphaned-knowledge signal.** A sole-authored file whose + owner has no commit within the recency window is flagged as the sharpest tier — + this is the *absence-is-signal* case (ADR-0009): nobody active knows it. +4. **`warn`, never `fail`; `skip` when history is too thin.** It is a focus + signal, not a defect (a non-gate, per ADR-0009) — same posture as + `change-coupling` and `branch-hygiene`. A non-git tree, a **shallow clone**, or + too little history degrades to an honest `skip` (ADR-0003), never a false pass. +5. **Optional anonymise mode** (`CHECKUP_OWNERSHIP_ANON=1`) replaces names with + stable, share-ranked pseudonyms for reports that leave the machine. + +Thresholds (`keyperson_pct_warn`, `sole_author_pct_warn`, orphan recency) are +tunable via `.checkup.yml thresholds` and env, defaulting to the literals so an +absent block is byte-identical (the ADR-0002 / #72 convention). The record is +emitted through `write_parsed`/`write_skipped` with an `intent` block; the +tool-agnostic renderer picks it up automatically (ADR-0002). + +## Consequences + +- A new deterministic **people axis** alongside the code axes, at near-zero cost + (one extra `git log` walk), directly serving the due-diligence and + team-prioritisation contexts. +- The headline is only as good as author identity. We mitigate with mailmap + + email coalescing and a **loud, permanent caveat**, but a repo with unmerged + aliases will still under-count concentration — documented, not hidden. +- Names in output are PII. Acceptable for a local report (as `branch-hygiene` + already surfaces author-named branches), with an opt-in anonymise mode for + shared ones. +- Ownership is tracked as its own continuity facet, **not** averaged into the + code-quality bands: "one person wrote it well" is a different risk from "it's + written badly", and conflating them would mislead both readings. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a4d9a90..992ec8b 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -21,3 +21,4 @@ Format: **Context · Decision · Consequences · Status**. | 0007 | [Human-gated merges (agents never self-merge)](0007-human-gated-merges.md) | Accepted | | 0008 | [Network isolation as the primary exfiltration control](0008-network-isolation.md) | Accepted | | 0009 | [checkup is a deterministic health localiser, not a deploy gate](0009-deterministic-health-localiser.md) | Accepted | +| 0010 | [Knowledge-concentration (key-person / bus-factor) forensic check](0010-knowledge-concentration-forensic.md) | Accepted | diff --git a/lib/config.sh b/lib/config.sh index 429ef32..e7b2d26 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -141,6 +141,9 @@ _cfg_apply() { # $1 = section, $2 = key, $3 = raw value complexity_ccn_fail) _tvar=CHECKUP_CPLX_CCN_FAIL;; duplication_warn_pct) _tvar=CHECKUP_DUP_WARN_PCT;; duplication_fail_pct) _tvar=CHECKUP_DUP_FAIL_PCT;; + ownership_keyperson_pct_warn) _tvar=CHECKUP_OWNERSHIP_KEYPERSON_PCT;; + ownership_sole_author_pct_warn) _tvar=CHECKUP_OWNERSHIP_SOLE_PCT;; + ownership_orphan_months) _tvar=CHECKUP_OWNERSHIP_ORPHAN_MONTHS;; *) echo "⚠️ .checkup.yml: unknown key 'thresholds.$key' — ignoring" >&2;; esac if [ -n "$_tvar" ]; then diff --git a/lib/ownership.jq b/lib/ownership.jq new file mode 100644 index 0000000..87a35f1 --- /dev/null +++ b/lib/ownership.jq @@ -0,0 +1,153 @@ +# ownership.jq — knowledge-concentration / key-person (bus-factor) transform +# (ADR-0010, #127). The PEOPLE axis of the git forensics: given per-(file,author) +# authorship rows, localise where knowledge concentrates in one person. +# +# Kept as a pure jq transform (no git) so it is unit-testable from fixture rows — +# the "one shared transform, env-independent test" pattern (lib/source-inventory.sh, +# lib/detect-stacks.jq). bin/checkup.sh does the git log + awk normalise and feeds +# the rows here; identity correctness (mailmap, email-coalescing) is settled BEFORE +# this stage — the caller reads git's mailmap-applied %aN/%aE, we never guess. +# +# Input: newline-delimited TSV rows on stdin (via -R -s), one per (file, author): +# file \t email \t name \t commits \t added \t lastCommitTs +# `commits` = commits by that author touching that file; `added` = lines +# added; `lastCommitTs` = epoch of that author's most recent touch of it. +# Generated/vendored files are already excluded by the caller (inventory). +# Args: --argjson now current time, for the recency test +# --argjson keypersonPct top-author share (%) that warns (e.g. 50) +# --argjson solePct single-author-file rate (%) that warns +# --argjson orphanDays sole author idle ≥ this many days → orphaned +# --argjson areaDepth path components that define an "area" (e.g. 1) +# --arg anon <"0"|"1"> replace names with share-ranked pseudonyms +# --arg hasMailmap <"0"|"1"> whether the repo carries a .mailmap (caveat) +# Output: { status, count, summary, findings } — status capped at warn (never fail), +# count = number of warning-tier findings; findings mirror the git-hotspots +# {file,line,code,severity,message} shape so the renderer picks them up. +# +# Metric choice (ADR-0010): per-file ownership is decided by COMMIT touches (robust +# to one-off codemods); the concentration headline uses lines-added share, falling +# back to commits when a window added no lines. Deterministic throughout: every +# sort carries an explicit tie-break (email/file/area) so runs are byte-identical. + +# Display name: real name, or a stable share-ranked pseudonym under anonymise mode. +def disp($name; $rank): if $anon == "1" then "Contributor \($rank)" else $name end; + +[ split("\n")[] | select(length > 0) | split("\t") + | { file: .[0], email: (.[1] | ascii_downcase), name: .[2], + commits: (.[3] | tonumber), added: (.[4] | tonumber), ts: (.[5] | tonumber) } ] as $rows +| if ($rows | length) == 0 then + { status: "skip", count: 0, summary: "no authored source history in scope", findings: [] } + else + +# --- whole-repo author aggregates --- +( $rows | group_by(.email) + | map({ email: .[0].email, + name: (sort_by([-.ts, -.commits])[0].name), + added: (map(.added) | add), + commits: (map(.commits) | add), + last: (map(.ts) | max) }) ) as $authors +| ($authors | length) as $authorCount +| ($authors | map(.added) | add) as $totalAdded +| ($rows | map(.commits) | add) as $totalCommits +# Concentration basis: lines-added is the natural "share of the code", but a window +# of pure moves/deletes can zero it out — fall back to commit share so the headline +# is never divide-by-zero and never silently empty. +| (if $totalAdded > 0 then "added" else "commits" end) as $basis +| (if $basis == "added" then $totalAdded else $totalCommits end) as $totalMetric +| ($authors | map(. + { metric: (if $basis == "added" then .added else .commits end) }) + | sort_by([-.metric, .email]) ) as $auth +# email -> rank (1-based, by contribution) for stable anonymised pseudonyms +| (reduce range(0; ($auth | length)) as $i ({}; . + { ($auth[$i].email): ($i + 1) })) as $rank + +# Bus factor: fewest authors whose combined metric first reaches $pct% of the total. +| def busFactor($pct): + ($auth | map(.metric)) as $m + | reduce range(0; ($m | length)) as $i ({ acc: 0, k: 0, hit: false }; + if .hit then . else + { acc: (.acc + $m[$i]), k: ($i + 1), + hit: (((.acc + $m[$i]) * 100) >= ($totalMetric * $pct)) } + end) + | (if .hit then .k else ($m | length) end); + busFactor(50) as $bf50 +| busFactor(80) as $bf80 +| $auth[0] as $kp +| (($kp.metric * 100) / $totalMetric) as $kpShare +| (($kp.commits * 100) / $totalCommits) as $kpCommitShare +| ($kpShare >= $keypersonPct) as $kpWarn + +# --- per-file ownership (commit-touch based) --- +| ( $rows | group_by(.file) + | map({ file: .[0].file, + fileCommits: (map(.commits) | add), + authors: (group_by(.email) + | map({ email: .[0].email, + name: (sort_by([-.ts])[0].name), + commits: (map(.commits) | add), + last: (map(.ts) | max) })) }) + | map(. + { owner: (.authors | sort_by([-.commits, .email])[0]), + sole: ((.authors | length) == 1) }) ) as $files +| ($files | length) as $fileCount +| ($files | map(select(.sole)) | length) as $soleCount +| (($soleCount * 100) / $fileCount) as $soleRate + +# --- orphaned knowledge: sole-owned files whose only author has gone quiet --- +| ($now - ($orphanDays * 86400)) as $orphanCut +| ( $files | map(select(.sole and (.owner.last < $orphanCut))) + | sort_by([.owner.last, .file]) ) as $orphans +| ($orphans | length) as $orphanCount + +# --- per-area concentration: which directories one person dominates --- +| ( $rows + | map(. + { area: ((.file | split("/")) as $p + | if ($p | length) <= $areaDepth then .file + else ($p[0:$areaDepth] | join("/")) end) }) + | group_by(.area) + | map({ area: .[0].area, + areaCommits: (map(.commits) | add), + files: (map(.file) | unique | length), + owners: (group_by(.email) + | map({ email: .[0].email, name: (sort_by([-.ts])[0].name), + commits: (map(.commits) | add) }) + | sort_by([-.commits, .email])) }) + | map(. + { top: .owners[0], topShare: ((.owners[0].commits * 100) / .areaCommits) }) + | map(select(.files >= 3 and (.topShare >= $solePct))) + | sort_by([-(.files), -.topShare, .area]) ) as $areas + +# --- findings (tier order: key-person, sole-rate, orphaned, single-owned areas) --- +| ( [ { file: "", line: 1, code: "key-person", + severity: (if $kpWarn then "warning" else "info" end), + message: (disp($kp.name; 1) + " authored " + (($kpShare | floor) | tostring) + + "% of the code (" + (($kpCommitShare | floor) | tostring) + + "% of commits); bus factor " + ($bf50 | tostring) + " to 50% / " + + ($bf80 | tostring) + " to 80%, across " + ($authorCount | tostring) + + " authors") } ] + + (if $soleRate >= $solePct then + [ { file: "", line: 1, code: "sole-authorship", severity: "warning", + message: (($soleCount | tostring) + " of " + ($fileCount | tostring) + + " files (" + (($soleRate | floor) | tostring) + + "%) have a single author — bus-factor-1 files") } ] + else [] end) + + ($orphans[0:10] | map( + { file: .file, line: 1, code: "orphaned-knowledge", severity: "warning", + message: ("sole author " + disp(.owner.name; ($rank[.owner.email] // 0)) + + " inactive " + ((($now - .owner.last) / 86400) | floor | tostring) + + " days — no active maintainer") })) + + ($areas[0:8] | map( + { file: .area, line: 1, code: "single-owned-area", severity: "low", + message: (disp(.top.name; ($rank[.top.email] // 0)) + " owns " + + ((.topShare | floor) | tostring) + "% of " + (.files | tostring) + + " files in this area") })) ) as $findings + +| ($findings | map(select(.severity == "warning")) | length) as $warnCount +| { status: (if $warnCount > 0 then "warn" else "pass" end), + count: $warnCount, + summary: ("bus factor " + ($bf50 | tostring) + " — top author holds " + + (($kpShare | floor) | tostring) + "% of the code across " + + ($authorCount | tostring) + " authors; " + + (($soleRate | floor) | tostring) + "% single-author files" + + (if $orphanCount > 0 then ", " + ($orphanCount | tostring) + " orphaned" else "" end) + + ". Identity is " + + (if $hasMailmap == "1" then ".mailmap-resolved" else "email-coalesced (no .mailmap)" end) + + "; unmerged aliases split a person and shared accounts merge many — verify before quoting."), + findings: ($findings[0:20]) } + end diff --git a/test/config.test.sh b/test/config.test.sh index b9a6dd2..ab68395 100755 --- a/test/config.test.sh +++ b/test/config.test.sh @@ -128,6 +128,15 @@ assert_eq "duplication_warn_pct" "4" "$( load_checkup_config "$TH"; printf '% assert_eq "duplication_fail_pct" "8" "$( load_checkup_config "$TH"; printf '%s' "${CHECKUP_DUP_FAIL_PCT:-}" )" assert_eq "thresholds flip overridden" "true" "$( load_checkup_config "$TH"; printf '%s' "$CHECKUP_OVERRIDDEN" )" +# Ownership / bus-factor thresholds (ADR-0010, #127) +TO=$(yml 'thresholds: + ownership_keyperson_pct_warn: 60 + ownership_sole_author_pct_warn: 40 + ownership_orphan_months: 9') +assert_eq "ownership_keyperson_pct_warn" "60" "$( load_checkup_config "$TO"; printf '%s' "${CHECKUP_OWNERSHIP_KEYPERSON_PCT:-}" )" +assert_eq "ownership_sole_author_pct_warn" "40" "$( load_checkup_config "$TO"; printf '%s' "${CHECKUP_OWNERSHIP_SOLE_PCT:-}" )" +assert_eq "ownership_orphan_months" "9" "$( load_checkup_config "$TO"; printf '%s' "${CHECKUP_OWNERSHIP_ORPHAN_MONTHS:-}" )" + echo "" echo "thresholds: non-integer warns + is ignored (default preserved), siblings still parse" TG=$(yml 'thresholds: diff --git a/test/ownership.test.sh b/test/ownership.test.sh new file mode 100755 index 0000000..9051a3b --- /dev/null +++ b/test/ownership.test.sh @@ -0,0 +1,167 @@ +#!/bin/bash +# Tests for the knowledge-concentration / key-person transform (lib/ownership.jq, +# ADR-0010, #127). Feeds fixture authorship rows (file, email, name, commits, +# added, lastTs) straight into the pure jq filter — env-independent, no git, no +# clock — the same "one shared transform, tested in isolation" pattern the config +# and source-inventory suites use. The git log + awk normalisation that produces +# the rows lives in bin/checkup.sh and is exercised by the honesty harness; here +# we pin the maths, the thresholds, identity coalescing, and the honest skip. + +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CHECKUP_HOME="$(cd "$SCRIPT_DIR/.." && pwd)" +JQF="$CHECKUP_HOME/lib/ownership.jq" + +# A fixed "now" so recency is deterministic; helper to build timestamps N days back. +NOW=1752566400 # 2026-07-15T08:00:00Z +ago() { echo $(( NOW - $1 * 86400 )); } + +PASS=0 +FAIL=0 +ok() { PASS=$((PASS+1)); echo " ✓ $1"; } +notok() { FAIL=$((FAIL+1)); echo " ✗ $1"; } +assert_eq() { + local name="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then ok "$name" + else notok "$name (expected '$expected', got '$actual')"; fi +} + +# ROWS is an accumulator: reset(), then R +# appends one TSV row (command-substitution strips a row's trailing +# newline, so we re-add it explicitly — otherwise rows would run together). +ROWS="" +reset() { ROWS=""; } +R() { ROWS+="$(printf '%s\t%s\t%s\t%s\t%s\t%s' "$1" "$2" "$3" "$4" "$5" "$(ago "$6")")"$'\n'; } + +# Run the filter over the current ROWS: run [kp% sole% orphanDays areaDepth anon mailmap] +run() { + local kp="${1:-50}" sole="${2:-50}" orphan="${3:-180}" \ + depth="${4:-1}" anon="${5:-0}" mm="${6:-0}" + printf '%s' "$ROWS" | jq -R -s -c \ + --argjson now "$NOW" --argjson keypersonPct "$kp" --argjson solePct "$sole" \ + --argjson orphanDays "$orphan" --argjson areaDepth "$depth" \ + --arg anon "$anon" --arg hasMailmap "$mm" -f "$JQF" +} + +echo "empty input degrades to skip (never a false pass)" +reset +OUT=$(run) +assert_eq "empty → skip" "skip" "$(echo "$OUT" | jq -r .status)" +assert_eq "empty → 0 findings" "0" "$(echo "$OUT" | jq '.findings|length')" + +echo "" +echo "concentration + bus factor" +# Alice 3000 lines, Bob 1000, Carol 1000 (total 5000). Alice = 60%. +reset; R src/a.ts alice@x Alice 30 3000 5; R src/b.ts bob@x Bob 10 1000 5; R src/c.ts carol@x Carol 10 1000 5 +OUT=$(run) +assert_eq "top author over threshold → warn" "warn" "$(echo "$OUT" | jq -r .status)" +assert_eq "key-person finding is warning" "warning" "$(echo "$OUT" | jq -r '.findings[]|select(.code=="key-person").severity')" +assert_eq "headline shows 60%" "60" "$(echo "$OUT" | jq -r '.findings[]|select(.code=="key-person").message|capture("authored (?

[0-9]+)%").p')" +assert_eq "bus factor 1 (Alice alone ≥ 50%)" "1" "$(echo "$OUT" | jq -r '.summary|capture("bus factor (?[0-9]+)").b')" + +echo "" +echo "raising the threshold above the top share flips warn→pass" +# Co-authored files (sole rate 0) so ONLY the key-person threshold governs. Alice +# holds ~60% of lines; threshold 70 > 60 → not a key person → pass. +reset +R src/a.ts alice@x Alice 30 3000 5; R src/a.ts bob@x Bob 5 100 5 +R src/b.ts bob@x Bob 10 900 5; R src/b.ts carol@x Carol 5 100 5 +R src/c.ts carol@x Carol 10 900 5; R src/c.ts alice@x Alice 5 100 5 +assert_eq "at threshold 50 → warn" "warn" "$(echo "$(run 50)" | jq -r .status)" +OUT=$(run 70) +assert_eq "raised threshold 70 → pass" "pass" "$(echo "$OUT" | jq -r .status)" +assert_eq "key-person demoted to info" "info" "$(echo "$OUT" | jq -r '.findings[]|select(.code=="key-person").severity')" + +echo "" +echo "sole-authorship rate warns independently of the key person" +# Five authors, evenly split by lines (no key person), but every file sole-owned. +reset +R src/a.ts a@x A 5 100 5; R src/b.ts b@x B 5 100 5; R src/c.ts c@x C 5 100 5 +R src/d.ts d@x D 5 100 5; R src/e.ts e@x E 5 100 5 +OUT=$(run) +assert_eq "no key person (20% each)" "info" "$(echo "$OUT" | jq -r '.findings[]|select(.code=="key-person").severity')" +assert_eq "100% sole-authored → warn" "warn" "$(echo "$OUT" | jq -r .status)" +assert_eq "sole-authorship finding present" "warning" "$(echo "$OUT" | jq -r '.findings[]|select(.code=="sole-authorship").severity')" + +echo "" +echo "shared files are NOT counted as sole-authored" +# Every file touched by two authors → sole rate 0; balanced lines → pass. +reset +R src/a.ts a@x A 5 100 5; R src/a.ts b@x B 5 100 5 +R src/b.ts b@x B 5 100 5; R src/b.ts c@x C 5 100 5 +R src/c.ts c@x C 5 100 5; R src/c.ts a@x A 5 100 5 +OUT=$(run) +assert_eq "no sole-authored files → pass" "pass" "$(echo "$OUT" | jq -r .status)" +assert_eq "0% single-author in summary" "0" "$(echo "$OUT" | jq -r '.summary|capture("; (?

[0-9]+)% single-author").p')" + +echo "" +echo "orphaned knowledge: sole owner gone quiet (absence-is-signal)" +# Six files. Five owned by an active author; one sole-owned by an inactive one. +reset +R src/a.ts act@x Active 5 100 3; R src/b.ts act@x Active 5 100 3; R src/c.ts act@x Active 5 100 3 +R src/d.ts act@x Active 5 100 3; R src/e.ts act@x Active 5 100 3 +R legacy/old.ts gone@x Gone 5 100 400 +OUT=$(run) +assert_eq "orphaned file flagged" "legacy/old.ts" "$(echo "$OUT" | jq -r '.findings[]|select(.code=="orphaned-knowledge").file')" +assert_eq "orphaned finding is warning" "warning" "$(echo "$OUT" | jq -r '.findings[]|select(.code=="orphaned-knowledge").severity')" +assert_eq "orphan count in summary" "1" "$(echo "$OUT" | jq -r '.summary|capture(", (?[0-9]+) orphaned").n')" +# A recently-active sole owner is NOT orphaned. +reset +R src/a.ts act@x Active 5 100 3; R src/b.ts act@x Active 5 100 3; R src/c.ts act@x Active 5 100 3 +R src/d.ts act@x Active 5 100 3; R src/e.ts act@x Active 5 100 3 +R legacy/old.ts gone@x Gone 5 100 30 +OUT=$(run) +assert_eq "recent sole owner not orphaned" "0" "$(echo "$OUT" | jq '[.findings[]|select(.code=="orphaned-knowledge")]|length')" + +echo "" +echo "identity: email coalescing merges an author's rows (case-insensitive)" +reset +R src/a.ts Alice@X Alice 20 2000 5; R src/b.ts alice@x Alice 20 2000 5; R src/c.ts bob@x Bob 5 100 5 +OUT=$(run) +assert_eq "two emails, one casing → one author" "2" "$(echo "$OUT" | jq -r '.summary|capture("across (?[0-9]+) authors").n')" + +echo "" +echo "identity caveat + mailmap note always present in the summary" +OUT=$(run 50 50 180 1 0 1) +echo "$OUT" | jq -e '.summary|test("mailmap-resolved")' >/dev/null && ok "mailmap=1 → '.mailmap-resolved'" || notok "mailmap note" +OUT=$(run 50 50 180 1 0 0) +echo "$OUT" | jq -e '.summary|test("no .mailmap")' >/dev/null && ok "mailmap=0 → 'no .mailmap'" || notok "no-mailmap note" +echo "$OUT" | jq -e '.summary|test("unmerged aliases")' >/dev/null && ok "alias caveat surfaced" || notok "alias caveat" + +echo "" +echo "anonymise mode strips names for shared reports" +reset; R src/a.ts alice@x Alice 30 3000 5; R src/b.ts bob@x Bob 5 100 5 +OUT=$(run 50 50 180 1 1 0) +echo "$OUT" | jq -e '[.findings[].message]|any(test("Alice"))|not' >/dev/null && ok "no real names when anon=1" || notok "anon leaked a name" +echo "$OUT" | jq -e '.findings[]|select(.code=="key-person").message|test("Contributor 1")' >/dev/null && ok "top author → 'Contributor 1'" || notok "anon pseudonym" + +echo "" +echo "single-owned areas surface only past the size floor (≥3 files)" +# src/ has 4 files all Alice; tiny/ has 1 → only src/ is an area finding. +reset +R src/a.ts alice@x Alice 5 100 5; R src/b.ts alice@x Alice 5 100 5 +R src/c.ts alice@x Alice 5 100 5; R src/d.ts alice@x Alice 5 100 5 +R tiny/z.ts bob@x Bob 5 100 5 +OUT=$(run) +assert_eq "one single-owned area (src)" "src" "$(echo "$OUT" | jq -r '[.findings[]|select(.code=="single-owned-area")]|first.file')" +assert_eq "tiny (1 file) not an area" "1" "$(echo "$OUT" | jq '[.findings[]|select(.code=="single-owned-area")]|length')" + +echo "" +echo "deterministic: identical input yields byte-identical output" +A=$(run); B=$(run) +assert_eq "repeated run identical" "$A" "$B" + +echo "" +echo "status is capped at warn — the transform never emits fail" +# Worst case: one author, everything, ancient. Still warn, never fail. +reset +R src/a.ts solo@x Solo 50 5000 500; R src/b.ts solo@x Solo 50 5000 500; R src/c.ts solo@x Solo 50 5000 500 +R src/d.ts solo@x Solo 50 5000 500; R src/e.ts solo@x Solo 50 5000 500 +OUT=$(run) +assert_eq "extreme concentration still warn" "warn" "$(echo "$OUT" | jq -r .status)" + +echo "" +echo "==================================================" +echo "ownership.jq: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]