27 — Codebase health
This doc describes why NoticeOS measures each site's code, which measures it keeps, how the alert rules read them and how a measurement reaches the store. The operator's guide is Measure your codebase; the envelope is in doc 02.
Why measure the code
Many sites in a portfolio are built quickly, often with AI coding tools, by people who then have to keep them running. The cost of that upkeep is in the code: every copy of a component is fixed separately, every unused file is still read, built and updated, and a function with many branches breaks in one place when it is changed in another. None of this shows in traffic or revenue until a change takes a week instead of an hour.
The site's Codebase tab records the code's size and four of its costs over time, beside the site's visitors, so a change in the code can be read next to a change in use. Under them it shows three checks the site's own workflow runs: whether the build passed, whether the tests passed and how many packages have a known vulnerability. It records counts and passes. It gives no score and no grade.
The measures, and why these
One report holds up to twenty-three measures (CODEBASE_MEASURES in packages/contract/src/codebase-measures.ts, which also holds the label and the one-sentence meaning the Tower shows for each). The tab leads with five counted from the files, then a row of three checks the sender passes in.
| Figure on the tab | Measures | Tool |
|---|---|---|
| Lines of code | code_lines, with comment_lines, files and languages | scc |
| Repeated code | duplication_percent, with duplicated_lines and duplicate_blocks | jscpd |
| Unused code | unused_files + unused_exports + unused_dependencies, whichever were measured | jscpd --dead-code |
| Hard-to-change functions | complex_functions (cyclomatic complexity above 15), with functions, max_function_complexity and mean_function_complexity | lizard |
| Test coverage by size | test_ratio = test_lines ÷ code_lines | scc |
The report also counts the last 30 days of change from git (commits_30d, files_touched_30d, large_commits_30d, the last being commits that touched 20 files or more) and the packages a project installs by name (dependencies_direct).
These were chosen because each one:
- is counted from the files alone, by a widely used tool, without installing, building or running the project. A measure that needs the code to run, such as test coverage by executed lines, would need the project's own toolchain and secrets, and would run code the installation did not write.
- matches a cost an operator recognises in plain words: more to read, copies to fix separately, files kept for nothing, functions that break when touched, tests that catch mistakes.
- reads the same on any site where its tool applies. Lint warnings depend on each repository's own configuration and cannot be compared across sites; a combined maintainability score hides which of its parts moved.
Tests are measured apart from the code: code_lines excludes test files, so a week of new tests does not read as growth, and test_ratio can exceed 1.
The three checks the sender passes in
| Figure on the tab | Measure | Unit | Where it comes from |
|---|---|---|---|
| Build | build_passed | pass: 1 passed, 0 failed | the outcome of the workflow's build step |
| Tests | tests_passed | pass: 1 passed, 0 failed | the outcome of the workflow's test step |
| Vulnerable packages | vulnerable_dependencies | count | the count from the workflow's package audit, such as npm audit |
These are the exception to the first reason above: a build, a test run and an audit need the project's own toolchain, so the image never runs them. The sender runs them in its own workflow and passes each result to the image, as the action's build-passed, tests-passed and vulnerable-dependencies inputs or as --measure key=value on measure.mjs and pnpm os:codebase-report. A step that was skipped or cancelled sends nothing, and its figure is unmeasured. A pass has no size to compare with a month before, so the tab shows the latest result as Passed or Failed, with how many of the last 30 days' reports passed, such as "4 of 5 reports passed in 30 days".
Beside the visitors
The sentence at the top of the tab sets the code's change over 30 days next to the site's visitors over the same days, from the visitor series the site's Overview reads: "The code grew 12% in 30 days while visitors grew 3%." The chart's Visitors button draws the visitors under the lines of code, with the same deploy marks on both.
The sentence states two changes and claims no cause. Code growth with no growth in use is a cost with no visible return, which is the reason to show them together; whether one caused the other is outside what this view measures (doc 03).
Absent means unmeasured
Every measure in the envelope is optional. A measure a tool did not produce is left out, never written as 0: jscpd's unused-code check covers JavaScript, TypeScript and Python only, unused packages are not measured by any tool in the image, a shallow checkout has no 30-day history, and the three checks are absent unless the sender passes them in. The report lists each absent measure under unmeasured with the sentence the Tower shows beside Not measured.
The rule holds at every step:
- The contract accepts a report with any measure missing (
packages/contract/src/codebase.ts). - A rule whose input is absent does not run, and so neither raises nor resolves an alert.
- The Tower reads a stored measure only when it is a number the contract accepts for that key (
apps/tower/worker/codebase-history.ts); anything else is unmeasured. - Unused code adds only the parts that were measured, and shows Not measured when none was.
- A findings list a tool did not produce is left out of the envelope, which differs from an empty list: the tab shows "Not measured" for the first and "Nothing unused found." for the second.
Tool versions and the pinned image
The measurement runs in one image, deploy/codebase-report, which pins scc, jscpd and lizard by version and SHA-256 digest; the release workflow publishes it as ghcr.io/notice-cx/noticeos-codebase-report. A new tool version can count the same code differently, so:
- a tool version changes only in a commit that changes the Dockerfile;
- every report records the versions that produced it under
tools, with the image digest when the run knew it; - the Tower's chart marks each report whose versions differ from the report before it with Measuring tools updated, and a step on that day is read as the tools, not the code.
The comparison and the rules
A change "in 30 days" compares the latest report with the site's report nearest 30 days earlier, between 28 and 45 days earlier, the older of two at the same distance. Outside that span there is no comparison, and the tab shows "No report from a month before yet". pickComparisonReport makes that choice for both the alert rules and the Tower's figures, so an alert and the figure beside it compare the same two reports.
Five rules run on every accepted report, in the same transaction that stores it (workers/ingest/src/codebase.ts), with the thresholds in config/constants.json under codebase_rules, store first:
| Rule | Severity, kind | Fires when (default) | Alert |
|---|---|---|---|
codebase-growth | warn, anomaly | lines of code grew at least 25% and at least 500 lines since the comparison report | "The code grew 31% in 30 days (38,100 → 49,900 lines)." |
codebase-duplication | warn, opportunity | at least 8% of the code is repeated | "8.4% of the code is repeated." |
codebase-unused | info, opportunity | unused files plus unused packages reach 10 | "14 files and 3 packages are not used." |
codebase-complexity | info, opportunity | at least 5 more hard-to-change functions than the comparison report | "7 more functions became hard to change in 30 days." |
codebase-vulnerabilities | info, opportunity | at least 1 package has a known vulnerability, as the sender's audit counted | "3 packages have a known vulnerability." |
- Growth needs both conditions: on a small repository a few added lines are a large percent, and on a large one a routine month is many lines.
- Repetition, unused code and vulnerable packages are levels, read from the latest report alone; growth and complexity are changes and need a comparison report, so a newly measured site raises neither for its first four weeks.
- Unused shared pieces (
unused_exports) are on the Unused code figure and in its findings, and are not counted by the rule. - A vulnerable-packages threshold below 1 reads as 1, so a report with no vulnerable package never raises the alert.
- Growth is of kind anomaly: it records a change larger than its thresholds. The other four are of kind opportunity: each names work that would lower the cost of later changes or remove a known flaw.
Each rule first resolves its own open alert for the site, then raises a new one if it fires, so a site holds at most one open alert per rule and the next report closes an alert that no longer holds. Each alert stores the numbers it used, both reports and the thresholds in rule_inputs, so a false alarm can be traced and a threshold changed on evidence. The thresholds are measurement rules: an operator changes them in the constants document, and nothing changes them automatically.
What is not stored
A report never carries source text. Findings are repository-relative paths, line numbers and function names; a path is at most 512 characters, relative, with no .. segment, backslash or control character. Every list is bounded (20 repeated blocks of at most 10 files, 20 functions, 50 unused items, 10 largest files, 12 languages) and the body is at most 256 KiB.
Findings name where to look and hold none of the code itself. Keeping code out of the store means a report from a private repository puts no source in the installation's database, its backups or the demo, and a report's size stays bounded however large the repository is.
Three ways to send a report
The same image produces the same envelope in each case; only the way it reaches the store differs. Each sends it to POST /api/codebase-report with the site's token from ASSET_TOKENS, the same token and route checks as the nightly report (doc 11).
| Way | Runs where | How it reaches the store |
|---|---|---|
GitHub Action (.github/actions/codebase-report) | the site's repository, on push to main, weekly and on demand | posts to the installation's ingest address when both the address and the token are set as repository secrets; otherwise keeps the report as a workflow artifact |
pnpm os:codebase-report (scripts/codebase-report.mjs) | the computer that runs the installation | runs the image through the installation's Docker in the app container's network and posts to the ingest on that container's loopback |
| Central pull | the installation, on a schedule | not built |
The Tower does not receive reports at its own address. A standalone installation receives POST /api/pulse and POST /api/codebase-report only on the app container's loopback, so a GitHub Action can reach it only when the installation publishes an ingest address of its own; otherwise the local command sends the report. The setup on the tab therefore reads the address from a repository secret instead of printing the Tower's address.
Central pull
The third way is named on the tab as Central pull · coming later and is not built. It would have the installation fetch each connected repository and run the same image on a schedule, so no workflow file and no token sit in the repository. It needs read access to the repository's contents through the installation's GitHub connection and a scheduled job that runs the image; the envelope, the route, the store and the tab stay as they are.
Where it lives
| Part | Path |
|---|---|
| Envelope and limits | packages/contract/src/codebase.ts |
| Measures, labels, comparison, rules | packages/contract/src/codebase-measures.ts |
| Measurement image | deploy/codebase-report/ |
| GitHub Action | .github/actions/codebase-report/ |
| Local command | scripts/codebase-report.mjs (--measure passes the three checks to the image) |
| Route, store write, rules | workers/ingest/src/routes/codebase-report.ts, workers/ingest/src/codebase.ts |
| Table and current view | db/postgres/migrations/0016_codebase_reports.sql (noticeos.codebase_reports, noticeos.current_codebase_reports) |
| Tower read and tab | apps/tower/worker/codebase-history.ts, apps/tower/src/routes/asset-detail/CodebaseTab.tsx, apps/tower/src/routes/asset-detail/codebase-measures.ts |
| Thresholds | config/constants.json codebase_rules (README) |