The Context Debt Report · August 2026
Context debt: the expired code your AI reads — and you pay for — every day. Workarounds whose cited reasons died: the issue closed, the browser was dropped, the date passed, the version floor moved. This report has three parts — how much of it a normal project carries, the individual cases we were willing to put our name on, and what happened when we asked every cited issue whether it was still open. It also lists what we refuse to claim, and the things we got wrong. Every claim on this page is published as data at /claims.json and re-checked before every publish.
A self-admitted workaround is a comment where the code says so itself: workaround, hack, kludge, remove this once…, until we upgrade…. Density here is markers per 10,000 lines, so a 75,000-line project and a 1.7-million-line one can be compared at all. Across 201 million lines it is stable, and lower than most people guess.
Half of these projects carry fewer than one confession per 10,000 lines. 456 of them (25%) contain not a single one — the most misread number here. A large, old codebase with zero markers is not clean; it is quiet. Nobody wrote the label. The debt is still there, unsigned. At the other end, 96 repositories exceed 5 per 10k. Density measures how honestly a team writes things down, not how dirty the code is.
| Language | Repos | Lines | Markers | Median /10k | p90 | Confess nothing |
|---|---|---|---|---|---|---|
| Python | 754 | 72.3M | 11,008 | 0.94 | 3.47 | 25% |
| JavaScript | 717 | 47.8M | 7,905 | 1.16 | 4.32 | 27% |
| TypeScript | 217 | 54.4M | 4,591 | 0.66 | 3.29 | 20% |
| PHP | 123 | 26.6M | 2,036 | 0.44 | 2.12 | 29% |
JavaScript confesses most often, PHP least. Comment culture differs by language and by project age — compare density within a language, not across.
redash tops the list at 81 markers per 10,000 lines — 11× the 99th percentile. They are all the same line: // @ts-expect-error ts-migrate(7053) FIXME:. A TypeScript migration codemod stamped 609 identical confessions across the codebase in what was presumably a single afternoon. Real debt, but one decision — not 609 of them. This is why the median matters and the mean does not.
Largest raw counts in the census — where an agent reading the code meets the most expired reasoning per task. Click a column to sort.
| Repository | Lang | Lines | Markers | Density /10k |
|---|---|---|---|---|
| sentry | Python | 1,745,391 | 704 | 4.03 |
| redash | Python | 74,981 | 609 | 81.22 |
| pytorch | Python | 1,455,710 | 506 | 3.48 |
| mlflow | Python | 684,071 | 401 | 5.86 |
| posthog | TypeScript | 5,146,921 | 339 | 0.66 |
| saleor | Python | 265,214 | 332 | 12.52 |
| vscode | TypeScript | 2,472,286 | 316 | 1.28 |
| woocommerce | PHP | 1,118,812 | 273 | 2.44 |
| drawio | JavaScript | 483,352 | 234 | 4.84 |
| expo | TypeScript | 524,094 | 220 | 4.20 |
| unilm | Python | 600,968 | 204 | 3.39 |
| grafana | TypeScript | 874,351 | 201 | 2.30 |
| numpy | Python | 206,476 | 191 | 9.25 |
| nuclide | JavaScript | 462,058 | 187 | 4.05 |
| wp-calypso | JavaScript | 1,362,409 | 183 | 1.34 |
| ray | Python | 713,915 | 183 | 2.56 |
| codesandbox-client | JavaScript | 947,362 | 173 | 1.83 |
| nx | TypeScript | 602,507 | 171 | 2.84 |
| truffle | TypeScript | 100,931 | 168 | 16.65 |
| leanote | JavaScript | 346,359 | 168 | 4.85 |
| react-native-macos | JavaScript | 113,701 | 167 | 14.69 |
| transformers | Python | 1,251,962 | 166 | 1.33 |
| PrestaShop | PHP | 787,505 | 166 | 2.11 |
| magento2 | PHP | 2,816,696 | 157 | 0.56 |
| vllm | Python | 920,372 | 155 | 1.68 |
isURLSameOrigin.js sniffs /(msie|trident)/i and skips the port check in same-origin decisions for a browser that died in 2022 — and that can't run modern axios at all. axios' own README support table lists no IE. Bonus: the sniff survived a full 2024 rewrite of the same file.
Receipt: PR axios#11174
IE_VERSION is marked @deprecated, has zero internal usage, and video.js dropped IE in v8 (2022) per its own changelog. The only environments where the sniff returns non-null are environments where video.js cannot run.
Receipt: PR video.js#9227
asynpool.py: "rewrite as a dict comprehension once we drop Python 3.7" — celery's own setup.py says python_requires=">=3.10". Same repo also declares a dependency that can never install (backports.zoneinfo; python_version<'3.9' under a 3.10 floor) and two version gates that are now constant-true, each hiding a dead else-branch.
Receipt: their own setup.py + setup.cfg, and PR celery#10525
rich-text.asset.php: "This file will be deleted after 2026-08-24." Found on main, days past its own date. Nobody's fault: there is no alarm for a date inside a comment.
twenty-sdk/.../config-service.ts: "TODO: Remove after 2026-04-30 — migrates legacy config format". Four months past its own date, still running on every CLI invocation. Dates inside comments are the most provable expiry there is — and the least watched. (Re-verified 2026-08-30: a second dated marker we reported in an earlier pass is gone from main.)
All four cite next.js#8592 — closed, fixed since Next 10. The comments outlived the bug by years. Same class confirmed in Ghost (a buffer PR merged in 2016) and directus (a rollup issue closed in 2022).
The code dodges a V8 deopt (V8 #4730) that was fixed years ago. On Node 22 the workaround measures at no benefit (−0.4%). The reason died; the cleverness stayed.
TODO webpack 6 appears 66 times across 37 files in lib/; 41 of them promise a removal or a drop. None are expired — they are waiting. The day webpack 6 ships, 41 obligations come due at once, written years apart by different people. Who is subscribed to that? (Counted on main, 2026-08-30.)
pandas' zip-read workaround and sklearn's tarfile_extractall wrapper both say "remove when the minimum is Python 3.12". Both projects are at 3.11 today. This is exactly what a watcher is for.
Precision beats recall. A false "expired" kills trust — so the report includes the findings we rejected, and why.
jQuery still runs IE in CI (test:ie). Identical code, opposite verdict: theirs is a supported-browser choice. The code can't tell you which; the support matrix can.
The upstream issue is closed, but prettier's lockfile still resolves remark-parse 8.0.3, which predates the fix. "Issue closed" is not "safe to remove". The lockfile is the only truth.
vars.php sets $is_NS4 (Netscape 4, dead ~2002) and $is_macIE (IE for Mac, dead 2003) on ~43% of the web, and ships a filter for Google Chrome Frame (retired 2014). Nothing in core reads $is_NS4. But WordPress' backwards-compatibility contract forbids removing globals — the reason is locked in amber. Expired? Yes. Removable? No. A tool that can't tell the difference is spam.
Our scanner flagged "derived from the Hack Standard Library (v4.40 - 2020-05-03)" — that's the Hack programming language, and the date is a version stamp, not an expiry. Rejected on review; the detector gets stricter next release. We publish our misses too.
Both packages declare requires-python = ">=3.10", yet sec_document.py and fetch.py still branch on sys.version_info < (3, 8) — carrying # noqa: UP036, the exact pyupgrade rule that exists to catch this. Both branches were identical anyway, so the gate decided nothing. A third gate in the Solr vector store picked timeout arguments for interpreters the package does not support.
Receipt: PR llama_index#22872 (+4/−27)
env_override.py carries four Inductor monkeypatches plus a get_raw_stream workaround behind is_torch_equal("2.9.0"). Its own pyproject.toml pins torch == 2.13.0; the ROCm build pins 2.11 and builds 2.12; the TPU image tracks nightly. No supported path installs 2.9, so none of that code can execute. We deliberately did not touch the neighbouring 2.10–2.12 gates in the same file: ROCm images ship those versions, so those patches are still live — and the comment claiming one "can be removed once torch >=2.12 is the minimum" is not yet true for every platform.
Receipt: PR vllm#54446 (+2/−406)
A date inside a comment is the only expiry that needs no interpretation. The scanner found 25 across the census; we read every one by hand, and they do not all mean the same thing.
alertDef.ts: "For backwards compatibility . Should be be able to remove this after ~2017-06-01" — nine years and two months past its own note, still on main. Same class: synapse ("we can safely remove this in a year (remove after 2023-11-16)"), AutoGPT ("⚠️ Remove after 2025-10-01", which even cites the PR that created it), WooCommerce, twenty, lobe-chat, orca, PostHog ×2, litellm ×2, jax ×2. Nobody was negligent — there is simply no alarm attached to a date written inside a comment.
DeepLabCut, vyper, edx-platform, vimium, spiderfoot and astro stamp comments with the author and the day they were written: "# CMC 2023-08-10 remove this as soon as we have…", "TODO(philc): 2024-03-27 Remove this check once Firefox has popover support". The date is a signature; the real condition is a version or a feature. Our scanner cannot tell those apart — both are a past date next to removal intent — so we count them separately and claim nothing about them. Several are almost certainly expired on their condition: Firefox shipped popover support in 2024. That is a version question, not a date question.
PostHog's hogbox_preview/stack.py mentions "from 2026-07-06 to 2026-07-10 until this was fixed" — an incident window, not a removal date. Flagged by the scanner, rejected on review. We would rather publish the miss than pretend the rule is sharper than it is.
A workaround that links an issue is a promise with an address on it — remove this when they fix it. That promise is checkable by machine, and almost nobody checks it. The corpus cites 978 distinct issues from inside 406 repositories. We asked every one of them.
Of the 956 references GitHub could resolve, 679 were closed as fixed — 582 of them at least a year ago. The median marker in this set is waiting on a fix that shipped 4.2 years ago; the oldest waits on one from 15.0 years ago. 204 cited issues are genuinely still open, and those workarounds are doing exactly what they say.
Closed is not the same as fixed, and we count it that way. A pull request closed without merging fixed nothing, and an issue closed as not planned is worse than unfixed — it means the workaround is now permanent rather than expired. 73 references fall into that category and are excluded from every number above. Building this pass without that distinction gave us 752 instead of 679, and we would have published it.
Nor is fixed the same as removable. Three things must be true before a line can go, and this pass establishes only the first: the fix has to exist, the project has to be on a version containing it, and removal has to be safe. That is why the scanner refuses to call anything expired until the lockfile agrees, and why the ten rows below were each read by hand.
The 949 markers pointing at a fixed issue split three ways, and they are not the same finding:
| Kind | Markers | Repos | What it means |
|---|---|---|---|
| external | 467 | 228 | waiting on somebody else's fix — the case this tool exists for |
| vendored | 129 | 53 | the marker sits in a copied-in third-party file; fixed by upgrading, not editing |
| self | 353 | 156 | the project cites its own tracker — its own promise, its own call |
The oldest of all of them is both at once. Knockout carries // Workaround for .../knockout/issues/155 in src/virtualElements.js, citing an issue its own maintainers closed on 2011-09-13 — titled "comment-based control flow broken in IE7 after <li> tag". Magento 2 ships a bundled copy of that same file, so a fifteen-year-old sentence about Internet Explorer 7 is sitting inside a store checkout somewhere right now, still explaining itself to everyone who reads it, including your agent.
Every row: the marker is in that file at upstream HEAD today, and the issue it cites was closed as fixed on the date shown. That is the whole claim. It is not a claim that the line should be deleted.
| Repository | Cited issue | Closed | What the comment says |
|---|---|---|---|
| saltstack/salt salt/utils/templates.py:547 | mitsuhiko/jinja2#75 | 2013-05-20 | Workaround a bug in Jinja that removes the final newline |
| meteor/meteor tools/cli/flush-buffers-on-exit-in-windows.js:3 | joyent/node#3584 | 2014-02-26 | when we upgrade let's remember to remove this clause |
| DistrictDataLabs/yellowbrick yellowbrick/style/rcmod.py:329 | mwaskom/seaborn#344 | 2014-11-22 | Implement hack workaround for matplotlib bug |
| meteor/meteor tools/isobuild/package-source.js:204 | jgm/CommonMark#276 | 2015-01-10 | Remove this workaround when the issue is fixed. |
| typescript-eslint/typescript-eslint packages/utils/src/json-schema.ts:37 | Microsoft/TypeScript#3496 | 2015-10-15 | Workaround for infinite type recursion |
| TryGhost/Ghost apps/ember-admin/app/serializers/application.js:6 | emberjs/data#4077 | 2016-01-15 | hacky method for getting access to meta data for single-resource responses |
| portainer/portainer app/app.js:7 | chieffancypants/angular-loading-bar#273 | 2016-02-17 | Workaround to prevent the loading bar from going backward |
| microsoft/vscode src/vs/platform/menubar/electron-main/menubar.ts:228 | electron/electron#846 | 2016-05-06 | workaround from Electron is to set the application menu again. |
| eclipse-theia/theia packages/plugin-ext/src/main/browser/webview/pre/main.js:576 | Microsoft/vscode#12865 | 2016-09-29 | Workaround for .../vscode/issues/12865 |
| DefinitelyTyped/DefinitelyTyped types/ace-diff/ace-diff-tests.ts:19 | Microsoft/TypeScript#11465 | 2016-10-08 | workaround: cast to avoid .../TypeScript/issues/11465 |
patroni/dcs/etcd.py:154 says "Workaround for the case when …/python-etcd/pull/196 is not applied". The pull request was merged nine years ago, which is exactly why the code stays: it guards users whose installed version predates it. A merged fix expires this line only when the dependency floor moves, not when the merge happens. Our own tool flagged it; the rule that a closed issue is never sufficient on its own is what saved it.
The single oldest external reference in the census points at expressjs/express#1101, "How do you close a running server?", closed in 2012. GitHub's API calls that completed. A question being answered is not a bug being fixed, and we will not count fourteen years against it.
Matomo cites piwik/piwik#9194. Piwik is Matomo; the project renamed in 2018. Our classifier compares repository names and read a self-citation as an external dependency. Renames are a known blind spot and are counted in the external column above — treat it as a small upper bound, not a clean number.
A report is a set of statements whose reasons can expire — which is the exact thing this tool is for. So the cases above are not written once and left: each one is a machine-checkable claim (repository, file, the string that must still be there), re-run against upstream HEAD before every publish. The claims are published as data — claims.json — and the verifier that checks them is verify.py. Take both and re-run them against us; it needs git and nothing else.
Last re-verified 2026-08-30: 31 of 31 published claims still hold. Two did not survive the previous pass and were rewritten rather than quietly left: twenty had a second dated marker that has since been removed from main, and our webpack count was wrong — TODO webpack 6 appears 66 times across 37 files today, not the eight we first reported. When one of these is fixed upstream, it will be marked fixed here with the date, not deleted. A report that only grows is a report nobody re-read.
None of this says the code should be deleted. A marker means the code admits it is a workaround; whether the reason expired is a second question and whether removal is safe is a third — WordPress core checks every request for Netscape 4 and cannot remove it, because its backwards-compatibility contract forbids it.
A closed issue is evidence, not a verdict. Part three establishes that a cited fix exists and when it landed. It does not establish that the project has upgraded to a version containing it, and it does not establish that removal is safe. Read those numbers as a list of questions worth asking.
The sample is biased on purpose. Most-starred repositories in four languages, deduplicated, restricted to at least 5,000 lines of scannable source so that awesome-lists and tutorial repos do not distort density. Popular open source is better maintained than average private code. Read these numbers as a floor.
Repository list built from a public daily ranking snapshot, filtered to JavaScript, TypeScript, Python and PHP. Each repository was shallow-cloned, scanned with contextdebt 0.1.7, and deleted immediately: 3,239 clones attempted, 2,978 scanned, 1,813 met the 5,000-line floor. A marker counts only inside a real comment in that language's own syntax — identifiers, UI strings, PHP 8 attributes and docstring prose that merely describes behaviour are excluded, every one of those rules added after a false positive we published. Dates are compared to the scan date and require removal-intent wording nearby. Tier 2: issue links checked against their tracker; a closed issue is never sufficient on its own — the fix must be in the version the lockfile actually resolves. The same repository list was scanned a second time, six hours later, to capture every issue reference rather than a sample per repository; that pass returned 1,812 repositories, 200.5M lines and 25,489 markers. The 0.2% drift is repositories moving between the two clones. Parts one and two quote the first pass and part three the second; both are dated rather than quietly reconciled, because a number without a timestamp is the thing this report is about.
The scanner is free, has zero dependencies, and never sends your code anywhere: the only network call it can make is a GitHub API lookup for an issue number found in your own comments.
npx contextdebt — free, local, zero dependencies. JS/TS, PHP/WordPress, Liquid, Python.
If it prints zero on a large old codebase, that is a finding too. Report #2 will be found by the watcher, not by hand — join at contextdebt.dev.