Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .checkup.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. |

Expand Down
156 changes: 153 additions & 3 deletions bin/checkup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
85 changes: 85 additions & 0 deletions docs/decisions/0010-knowledge-concentration-forensic.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
3 changes: 3 additions & 0 deletions lib/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading