Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fef763e
feat(repo): measure cognitive complexity, and propose a budget protocol
helix-nine Aug 27, 2026
01aadf2
fix(repo): guard the census on parse-error rate, not just file coverage
helix-nine Aug 27, 2026
78ff05b
fix(repo): close the shredding and one-call-site holes in the census
helix-nine Aug 27, 2026
5e24c69
docs(repo): evaluate SonarQube, which this RFC should have opened with
helix-nine Aug 27, 2026
52cbbd6
fix(repo): score each space on its own, not on its children's total
helix-nine Aug 27, 2026
404434f
docs(repo): retract the eslint-plugin-sonarjs recommendation on licen…
helix-nine Aug 27, 2026
29311fa
docs(repo): correct the SSAL analysis — clause (c) is not a blocker here
helix-nine Aug 27, 2026
9083b11
feat(repo): drive the census with big-code-analysis
helix-nine Aug 27, 2026
ad2e6cc
fix(repo): fail the census loudly when the analyzer does not run
helix-nine Aug 27, 2026
b3fe2cd
fix(repo): stop the gate taxing shared utilities
helix-nine Aug 27, 2026
6add88b
fix(repo): credit a utility for being reused, not for existing
helix-nine Aug 27, 2026
8b2a41e
fix(repo): credit a utility when a subsystem adopts it
helix-nine Aug 27, 2026
57e95db
refactor(repo): make complexity context rather than a gate
helix-nine Aug 27, 2026
6f5f8dd
docs(repo): tell agents to read the complexity delta when opening a PR
helix-nine Aug 27, 2026
5ada7e9
docs(repo): say plainly what a complexity rise usually means
helix-nine Aug 27, 2026
346b220
feat(repo): record the census from the PR, and have CI check it ran
helix-nine Aug 28, 2026
a3d9d99
fix(ci): measure the branch head, and compare only the last row
helix-nine Aug 28, 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
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Append-only log. Two branches appending different rows conflict on every merge
# without this; union keeps both sides, which is always the right answer for a
# file whose lines are independent records.
build/complexity/history.tsv merge=union
24 changes: 24 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,27 @@ jobs:
run: make manpages-check
- name: Check TS bindings up to date
run: make start-core-ts-bindings-check

# The log's last row must describe the tree being shipped, which is only true if
# the author ran `make complexity-record`. This checks that the census was run,
# never what it said — no number here fails a build.
complexity:
name: Complexity
needs: [prettier, changes]
if: >-
!cancelled() && needs.prettier.result == 'success'
&& github.event.pull_request.draft != true
&& (github.event_name != 'pull_request' || needs.changes.outputs.code == 'true')
runs-on: ubuntu-latest
steps:
# The author recorded their branch, so measure that. A pull_request event
# checks out head merged into the current base by default, and its numbers
# move whenever master does — numbers no author can predict.
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Check the complexity log describes this tree
run: make complexity-verify
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ What `### Environment` carries, by project:

- **Label every PR with the project(s) it modifies.** Nothing labels it for you: [`issue-triage.yml`](.github/workflows/issue-triage.yml) is bound to `issues:` alone, and no workflow reads a PR's diff. Pass them when you open it — `gh pr create --label StartOS --label StartSDK` — or add them after with `gh pr edit <n> --add-label repo`. Take them from the same set as an issue, listed under [Filing issues](#filing-issues); `gh` fails on a label the repo doesn't have, and the casing is matched literally.
- **The diff decides, so a PR takes as many labels as it needs.** An issue carries the one product a defect surfaces in; a PR carries every project whose files it changes, because that is what tells a reviewer and a release what a merge can break. Build, CI, and release tooling — `.github/`, `build/`, `scripts/`, `Makefile`, `debian/`, `apt/`, and the repo-root docs — is `repo`. A `shared-libs/` change has no label of its own: label the products whose behavior it changes.
- **Run `make complexity-record` before opening a PR and commit what it writes.** It compares the branch against its merge-base and prints the totals, every function it pushed higher, every one it simplified, and which utilities a second subsystem now depends on — then appends this tree's totals to [`build/complexity/history.tsv`](build/complexity/history.tsv). Three seconds, no build. CI checks that the log's last row matches the tree you are shipping, which is how it knows the census was run; it never checks what the numbers said, and no value fails a build. Re-run it if you push more commits, or the row goes stale and the check says so. The log is `merge=union`, so two branches appending different rows never conflict.
- **An unexpected rise is a symptom, not a verdict.** Read the named functions, not the total. It is usually a condition that did not need adding, one clear function chopped into worse ones, or a special case passed down through layers that should have handled it at the top. Fix what the report caught, or say in the body why the number is wrong: a requirement that has nine cases takes nine branches wherever it is handled, and no restructuring removes them.
- **Adding a utility costs nothing here.** A general-purpose helper has one call site the day it is written, and the report only names a function once a second subsystem calls it. Writing a second helper where one already exists therefore gains nothing either, so search before you add.

## Code style

Expand Down
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@ include projects/start-tunnel/build.mk
include projects/start-os/build.mk
include projects/start-wrt/build.mk
include projects/start-docs/build.mk
include build/complexity.mk

.PHONY: help start-os metadata start-os-install clean format format-check start-cli-install start-cli start-cli-deb start-os-uis start-os-ui start-os-emulate-reflash start-os-deb start-os-$(IMAGE_TYPE) start-os-squashfs start-os-wormhole start-os-wormhole-deb start-os-update start-os-update-from-gha test start-core-test start-sdk-test container-runtime-test start-wrt-test start-registry start-registry-install start-tunnel start-tunnel-install start-core-ts-bindings
.PHONY: help start-os metadata start-os-install clean format format-check complexity complexity-top complexity-diff complexity-record complexity-verify start-cli-install start-cli start-cli-deb start-os-uis start-os-ui start-os-emulate-reflash start-os-deb start-os-$(IMAGE_TYPE) start-os-squashfs start-os-wormhole start-os-wormhole-deb start-os-update start-os-update-from-gha test start-core-test start-sdk-test container-runtime-test start-wrt-test start-registry start-registry-install start-tunnel start-tunnel-install start-core-ts-bindings

help:
@echo "No default target — specify one. Common targets:"
@echo " start-os start-os-deb start-os-squashfs start-os-ui start-os-uis start-os-install (StartOS)"
@echo " start-cli start-cli-deb start-registry start-tunnel start-wrt start-wrt-image (other products)"
@echo " test start-core-test start-sdk-test container-runtime-test start-wrt-test (tests)"
@echo " format format-check start-core-ts-bindings clean (tooling)"
@echo " complexity complexity-top complexity-diff (complexity)"
@echo "See CONTRIBUTING.md for the full list."

touch:
Expand Down
25 changes: 25 additions & 0 deletions build/complexity.mk
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# --- cognitive-complexity census (no build; a tree-sitter pass over the sources) ---
COMPLEXITY := ./build/complexity/census.sh
BASE ?= origin/master

.PHONY: complexity complexity-top complexity-diff complexity-record complexity-verify

# Totals plus the worst 25 functions in the tree.
complexity:
@$(COMPLEXITY) census

# The standing pay-down list.
complexity-top:
@$(COMPLEXITY) top

# What this branch did to the numbers, against its merge-base. Paste into the PR body.
complexity-diff:
@$(COMPLEXITY) diff $(BASE)

# Prints the delta and appends this tree's totals to the log. Run before opening a PR.
complexity-record:
@./build/complexity/record.sh

# Fails when no row in the log describes this tree. What CI checks.
complexity-verify:
@./build/complexity/verify.sh
2 changes: 2 additions & 0 deletions build/complexity/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Fetched by fetch-tool.sh, pinned by sha256 — never committed.
bin/
153 changes: 153 additions & 0 deletions build/complexity/census.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Per-function cognitive complexity for the tree, as JSON or a table."""
import argparse, collections, json, os, re, subprocess, sys, tempfile

SCOPES = ['shared-libs', 'projects']
EXCLUDE = ('/node_modules/', '/dist/', '/target/', '/.angular/', '/out-tsc/',
'/osBindings/', '/locales/', '/__snapshots__/', '/__fixtures__/',
'/patch-db/client/', '/exver/exver.ts')
CALL_SITE = re.compile(r'\b([A-Za-z_]\w*)\s*[(:<]')
UTIL_MODULE = re.compile(r'(^|/)(util|utils|helpers)(/|\.(rs|ts|js)$)')


def subsystem(path):
"""The product or crate a file belongs to, plus its first module segment."""
parts = path.split('/')
if parts[0] == 'shared-libs' and len(parts) > 3:
base = parts[:3]
elif parts[0] == 'projects' and len(parts) > 2:
base = parts[:2]
else:
base = parts[:1]
rest = [p for p in parts[len(base):] if p not in ('src', 'lib')]
head = rest[0] if rest and '.' not in rest[0] else ''
return '/'.join(base) + ('/' + head if head else '')


def kept(path):
return (path.endswith(('.rs', '.ts', '.js'))
and not path.endswith(('.spec.ts', '.d.ts'))
and not any(x in '/' + path for x in EXCLUDE))


def spaces(node, root=True):
if node.get('kind') == 'function' and not root:
yield node
for child in node.get('spaces', []):
yield from spaces(child, False)


def census(root, scopes, bca):
out = tempfile.mkdtemp(prefix='cx-')
run = subprocess.run([bca, 'metrics', '-O', 'json', '--exclude-tests', '--output-dir', out,
*(os.path.join(root, s) for s in scopes)],
capture_output=True, text=True)
if run.returncode != 0:
sys.exit(f"complexity: {bca} failed ({run.returncode}): {run.stderr.strip().splitlines()[-1] if run.stderr.strip() else 'no output'}")
rows = []
for dirpath, _, names in os.walk(out):
for name in names:
if not name.endswith('.json'):
continue
try:
doc = json.load(open(os.path.join(dirpath, name)))
except (OSError, ValueError):
continue
rel = os.path.relpath(doc.get('name', ''), root)
if not kept(rel):
continue
for fn in spaces(doc):
m = fn.get('metrics', {})
label = fn.get('name') or ''
rows.append({
'file': rel,
'name': '<anonymous>' if not label or os.sep in label else label,
'line': fn.get('start_line'),
'cognitive': int(m.get('cognitive', {}).get('value') or 0),
'cyclomatic': int(m.get('cyclomatic', {}).get('value') or 0),
'sloc': int(m.get('loc', {}).get('sloc') or 0),
})
return rows


def count_callers(rows, root, scopes):
"""Marks each function with its call-site count and the subsystems that call it."""
per_file = collections.defaultdict(collections.Counter)
for scope in scopes:
for dirpath, dirs, names in os.walk(os.path.join(root, scope)):
dirs[:] = [d for d in dirs if d not in
('node_modules', 'target', 'dist', '.angular', 'out-tsc', 'osBindings', 'locales')]
for name in names:
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, root)
if not kept(rel):
continue
try:
per_file[rel].update(CALL_SITE.findall(open(path, encoding='utf-8', errors='replace').read()))
except OSError:
pass
defined = collections.Counter(r['name'] for r in rows)
callers_of = collections.defaultdict(set)
for path, counts in per_file.items():
for name in counts:
callers_of[name].add(subsystem(path))
for r in rows:
name = r['name']
if name == '<anonymous>':
continue
total = sum(c[name] for c in per_file.values())
r['callers'] = max(0, total - defined[name])
r['shared'] = sum(c[name] for f, c in per_file.items() if f != r['file']) > 0
r['util'] = bool(UTIL_MODULE.search(r['file']))
r['scopes'] = sorted(callers_of[name] - {subsystem(r['file'])})


def assert_parsed(bca, root, scopes, rows):
"""Grammar rot is silent: a file the parser cannot read yields no functions, not an error."""
if not rows:
sys.exit('complexity: the census found no functions at all — the analyzer did not run')
out = subprocess.run([bca, 'count', '--type', 'ERROR', *(os.path.join(root, s) for s in scopes)],
capture_output=True, text=True).stdout
total = found = 0
for line in out.splitlines():
digits = line.split(':')[-1].strip().replace(',', '')
if line.startswith('Total nodes'):
total = int(digits or 0)
elif line.startswith('Found nodes'):
found = int(digits or 0)
if total and found / total > 0.005:
sys.exit(f"complexity: {found / total:.3%} of AST nodes are parse errors — "
"the grammar has fallen behind the language")


def totals(rows):
return {'functions': len(rows),
'cognitive': sum(r['cognitive'] for r in rows),
'cyclomatic': sum(r['cyclomatic'] for r in rows),
'sloc': sum(r['sloc'] for r in rows),
'over25': sum(1 for r in rows if r['cognitive'] > 25)}


def main():
ap = argparse.ArgumentParser()
ap.add_argument('--root', default='.')
ap.add_argument('--bca', default=os.environ.get('BCA', 'bca'))
ap.add_argument('--scope', action='append')
ap.add_argument('--json', action='store_true')
ap.add_argument('--top', type=int, default=25)
a = ap.parse_args()
scopes = a.scope or SCOPES
rows = census(a.root, scopes, a.bca)
assert_parsed(a.bca, a.root, scopes, rows)
if a.json:
count_callers(rows, a.root, scopes)
json.dump({'totals': totals(rows), 'functions': rows}, sys.stdout, sort_keys=True)
return
t = totals(rows)
print(f"functions {t['functions']} cognitive {t['cognitive']} cyclomatic {t['cyclomatic']} "
f"sloc {t['sloc']} over25 {t['over25']}")
for r in sorted(rows, key=lambda r: -r['cognitive'])[:a.top]:
print(f" {r['cognitive']:>5} {r['sloc']:>5} {r['name'][:34]:<34} {r['file']}:{r['line']}")


main()
23 changes: 23 additions & 0 deletions build/complexity/census.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/bash
# Cognitive-complexity census. `census` prints the totals and the worst 25; `top` the worst N;
# `diff <base>` what this branch did to them. Nothing here exits non-zero on a number.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
export BCA="${BCA:-$HERE/bin/bca}"

[ -x "$BCA" ] || "$HERE/fetch-tool.sh"

case "${1:-census}" in
census) python3 "$HERE/census.py" --root . ;;
top) python3 "$HERE/census.py" --root . --top "${2:-25}" ;;
diff)
base="${2:-origin/master}"
mb="$(git merge-base "$base" HEAD)"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
git archive "$mb" | tar -x -C "$tmp"
python3 "$HERE/census.py" --root "$tmp" --json > "$tmp/.base.json"
python3 "$HERE/census.py" --root . --json > "$tmp/.head.json"
python3 "$HERE/delta.py" "$tmp/.base.json" "$tmp/.head.json" "$mb"
;;
*) echo "usage: census.sh {census|top [n]|diff <base>}" >&2; exit 2 ;;
esac
72 changes: 72 additions & 0 deletions build/complexity/delta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Prints the complexity delta between two census JSON dumps."""
import json, sys

base, head, ref = json.load(open(sys.argv[1])), json.load(open(sys.argv[2])), sys.argv[3]
key = lambda r: (r['file'], r['name'], r['sloc'] if r['name'] == '<anonymous>' else 0)
B = {key(r): r for r in base['functions']}
H = {key(r): r for r in head['functions']}
tb, th = base['totals'], head['totals']

print(f"Complexity vs {ref[:10]}")
for label, k in (('functions', 'functions'), ('cognitive', 'cognitive'),
('cyclomatic', 'cyclomatic'), ('sloc', 'sloc'),
('fns over 25', 'over25')):
print(f" {label:<12}{tb[k]:>7} -> {th[k]:>7} {th[k]-tb[k]:+d}")

new = sorted((r for k, r in H.items() if k not in B), key=lambda r: -r['cognitive'])
big = [r for r in new if r['cognitive'] > 10]
if big:
print(f"\n new functions over cognitive 10 ({len(big)} of {len(new)} new):")
for r in big[:10]:
print(f" cog {r['cognitive']:>4} {r['name']} {r['file']}:{r['line']}")

worse = sorted(((H[k], B[k]['cognitive']) for k in H
if k in B and H[k]['cognitive'] > B[k]['cognitive']),
key=lambda x: -(x[0]['cognitive'] - x[1]))
if worse:
print(f"\n existing functions made more complex ({len(worse)}):")
for r, old in worse[:10]:
flag = ' <-- already over 25' if old > 25 else (' <-- now over 25' if r['cognitive'] > 25 else '')
print(f" cog {old} -> {r['cognitive']} {r['name']} {r['file']}:{r['line']}{flag}")

better = sorted(((H[k], B[k]['cognitive']) for k in H
if k in B and H[k]['cognitive'] < B[k]['cognitive']),
key=lambda x: x[0]['cognitive'] - x[1])
if better:
print(f"\n simplified ({len(better)}):")
for r, old in better[:5]:
print(f" cog {old} -> {r['cognitive']} {r['name']} {r['file']}:{r['line']}")

gone = [r for k, r in B.items() if k not in H]
if gone:
print(f"\n removed: {len(gone)} functions, {sum(r['cognitive'] for r in gone)} cognitive")

# A helper whose only caller shares its file is the shape a shredded function takes.
# One that anything else calls is a shared utility, and is not the target here.
# Adoption is the counterweight to a rising number: complexity that moved into a
# shared helper a second subsystem now calls reads differently from complexity added.
adopted = []
for k, r in H.items():
before = set((B[k].get('scopes') or []) if k in B else ())
after = set(r.get('scopes') or [])
# Two distinct subsystems is where generality stops being a claim; the first
# caller is just the author, so creating a utility earns nothing.
if after - before and len(after) >= 2:
adopted.append((r, sorted(after - before), len(after)))
if adopted:
print(f"\n utilities a second subsystem now depends on ({len(adopted)}):")
for r, gained, total in sorted(adopted, key=lambda x: -len(x[1]))[:8]:
print(f" {r['name']} +{', '.join(gained)} (now {total}) {r['file']}")

util_new = [r for r in new if r.get('util')]
if util_new:
print(f" of the new functions, {len(util_new)} sit in util modules"
f" ({sum(r['cognitive'] for r in util_new)} cognitive)")

private = [r for r in new
if r.get('callers') == 1 and not r.get('shared')
and not r.get('util') and r['name'] != '<anonymous>']
if private:
print(f" single-use helpers alongside their only caller: {len(private)}")

18 changes: 18 additions & 0 deletions build/complexity/fetch-tool.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/bash
# Fetches the pinned big-code-analysis binary and verifies it against the release checksum.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
VERSION=2.1.0
case "$(uname -s)-$(uname -m)" in
Linux-x86_64) TRIPLE=x86_64-unknown-linux-gnu; SHA256=6904518ff57968408dd3fa46a3fb533b8ac42cd035d5dd503090e24e19d5232a ;;
Linux-aarch64) TRIPLE=aarch64-unknown-linux-gnu; SHA256=6400d71fb8b436ee71a984a605172680eacf3ad9d4fb2046e24d2d1972f669d0 ;;
Darwin-arm64) TRIPLE=aarch64-apple-darwin; SHA256=94faaa8f6f20952147e263222df4f65a11c8994af1da2e9d7882b3caae598212 ;;
*) echo "complexity: no pinned bca build for $(uname -s)-$(uname -m); build it with 'cargo install big-code-analysis --version $VERSION --root $HERE'" >&2; exit 1 ;;
esac
url="https://github.com/dekobon/big-code-analysis/releases/download/v$VERSION/big-code-analysis-$VERSION-$TRIPLE.tar.gz"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
curl -fsSL "$url" -o "$tmp/bca.tar.gz"
echo "$SHA256 $tmp/bca.tar.gz" | sha256sum -c - >/dev/null
tar -xzf "$tmp/bca.tar.gz" -C "$tmp"
mkdir -p "$HERE/bin"
install -m 0755 "$tmp/big-code-analysis-$VERSION-$TRIPLE/bca" "$HERE/bin/bca"
Loading