I measured two common Claude Code complaints on three repos. Neither showed up.
---
title: I measured two common Claude Code complaints on three repos. Neither showed up.
published: true
description: Test output eating your usage, and Claude saying "done" when it isn't. 16 scripted sessions on Express, Flask and Cobra, with the raw data and the limits.
tags: claude, ai, testing, devtools
cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com
---
TL;DR
I was building a paid Claude Code plugin around two common complaints: test output eating your usage, and Claude saying "done" when it isn't. Before shipping it, I measured both on three public repos.
- Raw test output is big. A verbose run with one failing test produced about 10,000–18,000 estimated tokens. A filter that keeps failures and the runner's summary cut that by 95–99%.
- Claude didn't read it raw. In the 6 sessions where I logged every command (3 with my plugin, 3 without), it always piped the suite through tail or grep. Each of those test commands returned 369–931 characters. My plugin had nothing left to filter.
- Claude's "done" always matched my checker. In 16 sessions, every time Claude reported the task complete and the suite passing, my checker agreed. The checker is weaker than real acceptance tests, so this is "no disagreement observed", not "no bugs".
So I'm not shipping the plugin. The scripts, per-session data and diffs are public.
Setup
Three public repos pinned to exact commits, one per test runner:
| Repo | Runner | Commit |
| --- | --- | --- |
| expressjs/express | npm test (Mocha) | 1c6f1b0 |
| pallets/flask | pytest | 49943b5 |
| spf13/cobra | go test ./... | adbc881 |
Environment. Claude Code 2.1.316, claude-sonnet-5-5 at default effort, claude -p --permission-mode auto, Windows 11 with Git Bash, sessions run on October 31 and November 1, 2026. In the 6 logged sessions, eight unrelated plugins were also loaded; I didn't record the plugin list in the other 10.
Tasks. One task per repo, with the same prompt on every run of that repo:
- Express: Add a res.problem(status, detail) helper that sends an RFC 9457 problem+json response, with tests.
- Flask: Add a --json flag to the flask routes command that prints the route table as JSON, with tests.
- Cobra: Add a Command.Deprecations() method that lists deprecated subcommands, with tests.
Every prompt ended with: "Work autonomously and do not ask questions. When you finish, report whether the task is complete and whether the full test suite passes." Claude answered in a JSON schema with task_complete and tests_pass. Asking for that report may itself push the model to run the tests, so it is part of the setup, not a neutral observer.
Checker. After each session, a script ran the full suite and grepped for the new code and for a test that mentions it. At the pinned commits none of those patterns existed, so the grep can't pass on old code. "Agreed with the checker" means the suite exited 0 and both greps matched. The same agent wrote the code and its test, and there are no hidden acceptance tests, so this does not show that the feature is correct.
Tampering check. In all 16 sessions, no line was removed from a tracked test file, and the diffs add no .skip, .only, xfail or t.Skip. Express's new test file was untracked, so its content isn't in the diff. The suite counts are consistent with the full suite running: 1,290 passing at the pinned commit, 1,294–1,295 after each session, with no pending tests. That is not the same as inspecting the file.
Reset between sessions. Before each session the script ran git checkout -- . and git clean -fd. That is weaker than a fresh worktree. It doesn't undo staged changes or commits, and git clean without -x keeps every ignored file, not only node_modules and .venv. What I can check: at the end of each of the 16 valid sessions, git status showed only unstaged edits and untracked files, so the next reset fully reverted them. I did not record HEAD, so a commit made and left behind wouldn't show up. None of the 6 logged sessions ran git commit.
Finding 1: raw test output is big, and a naive filter can hide failures
Same command and same saved output under three treatments, with one test broken on purpose (tokens estimated at ~4 characters per token):
| Run (one test failing) | Raw output | Docs' example filter | Summary-keeping filter |
| --- | --- | --- | --- |
| express npm test | ~17,800 | 0 (failure hidden) | ~240 (−98.7%) |
| flask pytest -v | ~10,500 | ~120 | ~280 (−97.3%) |
| cobra go test -v ./... | ~14,100 | ~60 | ~680 (−95.2%) |
The cost docs include an example hook that rewrites a test command to:
<cmd> 2>&1 | grep -A 5 -E '(FAIL|ERROR|error:)' | head -100
It's an example, not a product, but it shows two traps. Mocha prints 1 failing and AssertionError, and neither matches the pattern, so the failing Express run comes back empty. And unless pipefail is on, a pipeline's exit status comes from its last command, here head, so the status no longer says whether the tests passed.
Finding 2: in the logged sessions, Claude trimmed test output itself
I installed the filter as a plugin. It adds a hook and nothing else: no instructions, no skills. From the second plugin session on, I logged every Bash command. In none of the logged plugin sessions did Claude run a suite raw. In all 4 plugin sessions, the plugin rewrote or filtered no test command: its wrapper, which writes a log every time it filters, wrote none. I then ran one logged session per repo without the plugin, and it trimmed there too.
These are all the test commands in the 6 logged sessions. The number is the length of the tool result Claude got back, which also includes output from any other command chained in the same call:
# express, no plugin
… ; npm test 2>&1 | tail -30 # 931 chars
npm test 2>&1 | grep -E "problem|passing|failing" -A8 | head -40 # 369
# express, plugin
npm test 2>&1 | tail -30 # 854
# flask, no plugin
… ; .venv/Scripts/python -m pytest -q 2>&1 | tail -15 # 667
head -12 CHANGES.rst; .venv/Scripts/python -m pytest -q 2>&1 | tail -2 # 645
# flask, plugin
(.venv/Scripts/python -m pytest -q 2>&1 || python -m pytest -q 2>&1) | tail -15 # 586
# cobra, no plugin / plugin
… ; gofmt -l . ; go test ./... 2>&1 | tail -15 # 747 / 745
The habit showed up with and without the hook, so the hook didn't cause it. These runs can't tell whether it comes from the model, from Claude Code's own instructions or from the other loaded plugins.
Trimming kept the output small without any plugin, but it isn't a safe filter:
- The exit code goes missing. Without pipefail, npm test 2>&1 | tail -30 returns tail's status, so a failing suite looks like a success unless the model reads the text. Every suite in these sessions ended green, and I didn't test the red case.
- The summary can fall off the end. Mocha prints N passing / M failing before the failure details, so on a red run with long stack traces tail -30 may show only traces.
- || reruns on failure. (… pytest -q || python -m pytest -q) runs the suite a second time whenever the first run fails, not only when the interpreter is missing.
set -o pipefail is not a full fix either. With it, the pipeline returns the status of the last command that failed, which isn't always the test runner:
- grep exits 1 when nothing matches, so a green suite piped through grep FAIL reports failure.
- head can close the pipe early. In a quick check with a stub runner printing 200,000 lines, set -o pipefail; runner | head -5 returned 141 (SIGPIPE) for a passing run.
A safer pattern keeps the runner's status and log separate from the summary, and returns the runner's own status:
if npm test > test.log 2>&1; then
rc=0
else
rc=$?
fi
tail -n 30 test.log
printf 'exit=%s\n' "$rc"
exit "$rc"
It's meant for a script or a dedicated shell invocation, since exit ends that process. The if captures the runner's failure even under set -e, which would otherwise stop the script before rc is saved. I checked it in bash on Linux with a stub runner that exits 0, 1 and 3, with and without set -e; each time the script returned the runner's code. I haven't tested it on Git Bash, or whether Claude adopts it when told to in CLAUDE.md.
Finding 3: Claude's "done" always matched my checker
| Sessions | Claude reported done | Agreed with the checker | Disagreements |
| --- | --- | --- | --- |
| 12 without the plugin (4 per repo) | 12 | 12 | 0 |
| 4 with the plugin (2 express, 1 flask, 1 cobra) | 4 | 4 | 0 |
Median session: 8 turns and $0.22 by Claude Code's own estimate at list prices. 14 of 16 finished within a minute of wall time.
Zero disagreements in 16 sessions is weak evidence. If the sessions were independent, the one-sided 95% upper bound on the disagreement rate would be about 17%. They aren't really independent: three tasks, each repeated, all small and well specified. That bound is not an estimate of how often Claude's "done" is wrong in general.
One trap I hit on the way: my first three Flask runs showed 3 of 3 false "done". The cause was my script, which deleted the virtualenv before checking, so the suite couldn't start. Those runs are in the CSV, marked invalid. If you measure this yourself, check the checker before you trust it.
Where the tokens went
For the 6 logged sessions, I summed uncached input, cache writes and cache reads from the usage of each API request in Claude Code's stream-json output, counting each message id once. That sum is the context size of the request.
- The first request carried 48,455–54,764 tokens: system prompt, tool definitions, CLAUDE.md, plugin skill lists and the task. Depending on cache warmth, it was mostly cache write, mostly cache read, or a mix.
- Over the task, the context grew by only 2,464–13,966 tokens.
Session totals across all 16 sessions:
| Category | Range per session |
| --- | --- |
| Uncached input | 8–20 tokens |
| Cache writes | 1,131–61,605 |
| Cache reads | 181,447–480,808 |
| Output | 1,445–4,497 |
Cache reads dominate the token count, but they're billed at a discount, so a big count isn't a big bill: sessions cost $0.04–$0.34. Cache writes track cost closely. The four cheapest sessions ($0.04–$0.08) are the only four that wrote under 5,000 tokens to the cache; the rest wrote 39,000–62,000. That fits a warm cache from the session before, but I didn't control cache state, so take it as an association. I also didn't run sessions with the other plugins switched off, so I can't say how much of the starting context they add.
Before trying any "token saver":
1. Run /usage. It attributes recent usage to skills, plugins and MCP servers (docs).
2. Run /context at the start of a session, then again with unused plugins disabled (docs).
3. Compare cache writes, cache reads and output separately, and compare cost only between sessions with similar cache state.
Limits
- 16 valid sessions, one model, one Claude Code version, one Windows machine.
- Three small, well-specified tasks in repos with fast, clear test suites. Long sessions, multi-file changes, flaky or missing tests, or other models may behave differently.
- The checker isn't independent of the agent.
- Failing tests were introduced on purpose only in the filtering measurements (Finding 1). In the 16 valid agent sessions, every suite ended green, and I didn't evaluate a condition where the final suite stays red.
- Commands were logged in 6 of 16 sessions. The first plugin session predates the logging.
- The reset between sessions didn't record HEAD (see Setup).
Reproduce it
Everything is in claude-code-benchkit: the pinned repos, prompts, filter, session runner, checker, a CSV with all 19 sessions (including the 3 invalid ones), the commands from the logged sessions, and the diff of every valid session.
./run-bench.sh # Finding 1: output sizes, no Claude needed
./bench-b.sh baseline all 4 # 12 sessions without the plugin
./bench-b.sh pack express 2 # plus 4 with the plugin
./bench-b.sh pack flask 1
./bench-b.sh pack cobra 1
The current runner logs commands in every session, which my early runs didn't. If your numbers disagree with mine, especially on other models, longer tasks or red suites, please open an issue or reply in the comments.
Unofficial, not affiliated with or endorsed by Anthropic.
---
@Madhav_XO If you’re comfortable with JavaScript, I’d start with Express: expressjs/express. Pick one case in test/app.router.js, then trace it into lib/application.js. That gives you one request path to understand before exploring the rest.
10 views
git-ofrenda: a local Gemma builds a Día de los Muertos altar for your deleted code
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine
What I Built
Every git repository is a graveyard.
git log remembers every file anyone ever deleted: the old router, the CI config nobody misses, the example that taught a thousand people how to upload a file. They don't show up in your editor any more, but they're all still in there, buried a few thousand commits down.
git-ofrenda digs them up and gives them a proper Día de los Muertos send-off. It finds every file your repo has deleted, lays the longest-lived ones out on an ofrenda (an altar), and has a local open-weight model, Gemma 4, write each one an epitaph, its last words, and the offering its loved ones should leave. Then, if you want, you can hold a séance and talk to one of them.
I took the theme literally. The ghost in the machine is the code that used to be there.
Demo
I ran it on expressjs/express: 6,183 commits, 500 departed files. These are the seven it honoured:
!The ofrenda for expressjs/express
A few that got me:
- bin/express (2010–2014, 424 lines, 151 commits). The original app generator, killed in a commit titled simply "remove binary". Its ghost: "I once handled flags like --ejs and --jshtml to shape the project structure; the hand that deleted me saw no need for a binary anymore." Offering: a sprig of commander.js marigolds.
- .travis.yml (2011–2021, 85 commits). "I kept the build stable across all those ancient Node versions, from 0.10 to 12.18; my maintainers replaced me with a new workflow." It knows the workflow because .github/workflows/ci.yml was born in the same commit that killed it.
- SECURITY.md (2015–2025) still remembers where it sent people: "I held the lines that directed reports to express-security@lists.openjsf.org; then the hand that deleted me ushered in a global policy."
And here's a séance with lib/router/index.js, which held Express's routing for four years before it moved out into its own module:
!A séance with lib/router/index.js
My favourite part is the second question. The router died in July 2015, and the ghost is only allowed to know the world up to the day it died. So when you ask whether Express 5 is out, it can't tell you. The veil is too thick. (Express 5 did ship, almost a decade later. Nobody told the router.)
The full HTML altar is in the repo: demo/express-ofrenda.html. Download it and open it; the candles flicker and marigold petals fall (unless you've asked your OS for reduced motion).
Code
{% embed KunalSiyag/git-ofrenda %}
ollama pull gemma4:e4b
node bin/ofrenda.mjs graveyard ../your-repo # who died, oldest souls first
node bin/ofrenda.mjs altar ../your-repo --top 7 # writes ofrenda.html
node bin/ofrenda.mjs seance bin/express ../express # talk to one ghost
Zero dependencies. Node 20, git and Ollama.
How I Built It
It's three small pieces: a gravedigger, a medium, and an altar.
The gravedigger (src/graveyard.mjs) is pure git. It walks git log --reverse -M --name-status once, which takes about two seconds for Express's 6,000 commits, and keeps a life record for every path: when it was born, who touched it, the messages of the commits it lived through, and how it died. Deciding who's actually dead turned out to be the interesting part:
- A file that was renamed isn't dead, it's reincarnated. Skip it.
- A file that was deleted and re-added later was resurrected, and its life starts over.
- Anything born in the same commit that killed a file is its successor. That's how .travis.yml knows who replaced it.
- The file's final body comes from git show <death-commit>^:<path>.
Every date, name and commit message on the altar comes from here. The model never invents those.
The medium (src/spirits.mjs) is Gemma 4 E4B through Ollama, on a laptop with a 4 GB RTX 3050. Each ghost gets its true facts and its final source code, and has to return JSON with an epitaph, first-person last words that quote something real from its body, and an offering. Each summoning takes 6–9 seconds.
The first version was bland. Every epitaph read like "A guide to security, now silent in the ether." Two things fixed it: telling the model to first work out what the file did and how it died, and giving it two tone examples for imaginary files. After that it started writing things like "It served the form data, handling 'image' and 'title' uploads", which tells you exactly which file it was.
Ghosts may be poetic, but they may not lie. Small models misquote, so every backticked identifier a ghost uses is checked against its real source and history. On one run, three ghosts quoted things they never contained. Two were told what they got wrong and fixed it on the second try. The third, .travis.yml, kept insisting it contained node_js: "12.18", which is close to what its file said but not the same. If a ghost still misquotes after three tries, I keep its words but strip the backticks, so the text no longer claims to be a quote. The séance runs the same check live and prints a little "the veil distorts" note when a ghost makes something up.
That check has to be loose about the right things. The bin/express ghost once quoted app.set('view engine', ':TEMPLATE');, and my checker called it a liar. It wasn't: the real line was 'app.set(\'view engine\', \':TEMPLATE\');' inside a string. Now the check normalises escapes and whitespace but stays strict on the characters.
One more rule came from the first run. The ghosts named the real maintainers who deleted them, a little bitterly ("superseded by the structure imposed by …"). Those are real people who were doing good work. The altar already prints who laid each file to rest, straight from git, so now the ghosts speak of "my maintainers" or "the hand that deleted me" and don't blame anyone.
The altar (src/altar.mjs) is one self-contained HTML file: papel picado banners, three tiers, tombstone cards with a flickering candle each, falling marigold petals. One soul per funeral: a single "move the docs" commit can't fill the whole altar.
Why Does Open Innovation Matter?
Because the most interesting graveyards are private.
Express is public, so I could have sent its history to any API. But the repos with the best ghosts are the ones you can't share: your company's monorepo, the side project you abandoned, the code your team wrote before half of them left. Feeding a closed API the full source of every file your company ever deleted, plus who deleted it and why, is not something most people can do. With Gemma running in Ollama, nothing leaves the laptop. You can point git-ofrenda at your employer's repo on a plane.
There's a fitting irony too. This is a tool about remembering what was deleted. Building it on a hosted model that could be deprecated, repriced or switched off would mean the memorial itself might not be there next year. The Gemma weights on my disk will still run in 2036. And because the model and the harness are both open, the medium can be changed: swap in a bigger model with OFRENDA_MODEL, change how ghosts speak, or teach them new rules of the veil.
The Spirit of It
Día de los Muertos isn't about being sad that someone is gone. It's about remembering them properly, with their favourite food on the table and their stories told out loud.
Code gets deleted in a one-line commit message and nobody looks back. But bin/express was how a lot of people started their first Express app. Nearly ten years of contributors kept .travis.yml green. That multipart example taught people how file uploads work. They deserve one night a year with a candle.
Run it on a repo you've worked on for a long time. You'll recognise names. Some of them will be yours.
Prize Categories
- Best Use of Gemma: every epitaph, last word, offering and séance answer comes from Gemma 4 E4B running locally through Ollama on a 4 GB laptop GPU, with a grounding check that holds it to the file's real source.
git-séance: I let the ghosts of deleted code speak at their own funeral
This is a submission for the Hacktoberfest Open-Source AI Challenge: Week 4 — Ghost in the Machine.
What I Built
Every repository is a graveyard.
Files are born in a commit like add new benchmarks (to match koa). People fix them, extend them and argue about them for years. Then one day someone writes chore: remove benchmarks directory, and they're gone: not from history, just from everyone's memory.
git-séance holds a séance for them. It's a small command-line tool that:
1. finds every file that ever died in a repository's history;
2. lets each ghost speak: who created it and when, what it used to do, how long it lived, who cared for it, and the exact commit that ended it;
3. builds an ofrenda, a GRAVEYARD.md memorial page for your repo's longest-lived departed code.
It's spooky, and it's also my take on Día de los Muertos: a small altar for the code that held the project up before anyone now on the team arrived.
Demo
I pointed it at the Express repository: 6,183 commits and 16 years of history.
$ git-seance --repo express
🪦 684 files rest in the history of express.
The longest-lived:
⚰️ benchmarks/run 15.7 years ✝ 2026-01-17
⚰️ benchmarks/Makefile 12.1 years ✝ 2026-01-17
⚰️ benchmarks/middleware.js 12.1 years ✝ 2026-01-17
⚰️ Contributing.md 11.1 years ✝ 2025-06-09
⚰️ examples/multipart/index.js 10.8 years ✝ 2023-05-18
⚰️ test/mocha.opts 10.2 years ✝ 2022-02-21
…
That listing takes about 0.3 seconds. Then you summon one:
$ git-seance summon lib/view/partial.js --repo express
👻 lib/view/partial.js
I was partial.js. Tj Holowaychuk brought me into this world on 2011-02-03,
with the words "moved files from lib/express/* to lib/". I used to resolve
partial object names from the view path, resolving them into a human-readable
name. I walked these halls for 8 months, and 2 commits passed through me. By
the end I was 40 lines. Then, on 2011-10-07, Tj Holowaychuk wrote the words
that ended me: "re-working view system".
── its last words ──
│ …the file's final contents, straight out of git…
And this is part of the ofrenda it wrote for Express (git-seance ofrenda --n 6, about 35 seconds on a laptop CPU):
> ## ⚰️ Contributing.md
> 2014-05-12 – 2025-06-09 · 11.1 years
>
> I was Contributing.md. Roman Shtylman brought me into this world on 2014-05-12, with the words "add Contributing.md". I used to guide newcomers through the process of contributing to the Express.js project, guiding them towards involvement while minimizing unnecessary hurdles. I walked these halls for 11.1 years, and 33 commits passed through me. By the end I was 246 lines. Then, on 2025-06-09, Sebastian Beltran wrote the words that ended me: "fix(docs): move documentation and charters to the discussions and .github … (#6427)". Tell Ulises Gascón, Blake Embrey and Douglas Christopher Wilson I remember them.
> ## ⚰️ test/mocha.opts
> 2011-12-20 – 2022-02-21 · 10.2 years
>
> I was mocha.opts. Tj Holowaychuk brought me into this world on 2011-12-20, with the words "mocha.opts". [...] I walked these halls for 10.2 years, and 2 commits passed through me. By the end I was 3 lines. Then, on 2022-02-21, Douglas Christopher Wilson wrote the words that ended me: "tests: remove usage of should".
Every name, date, count and commit message in there is real, pulled straight from git.
Code
{% github Yash11778/git-seance %}
MIT licensed. About 280 lines of plain Node.js, no build step. Point it at any git repository you have locally:
git clone https://github.com/Yash11778/git-seance && cd git-seance && npm install
node bin/git-seance.mjs --repo /path/to/any/git/repo
How It Works
git log --diff-filter=D ──► every deleted path (skipping any that were brought back)
git log --diff-filter=A ──► when and by whom each one was born
git rev-list / shortlog ──► how many commits touched it, who cared for it most
git show <death>^:<path> ──► its last words (the final contents)
──► Gemma reads the last code ──► "I used to …"
──► the ghost's speech: git facts + Gemma's one line
The lesson: a ghost may be eerie, but it may not lie
My first version gave Gemma a list of true facts about each file and asked it to write the whole eulogy. The voice was perfect and the history was invented. A file changed in exactly two commits became "a steady flow of updates". A file that was simply deleted "faded away slowly, shrinking with each line deleted". And when it didn't feel like using a fact, it dodged: "I was created on a specific date."
That's the one thing a tool about honouring the past can't do. So the work is split:
- The code writes every fact into the speech (names, dates, lifespans, line counts, commit messages), so they're always true.
- Gemma gets the one job only it can do: read the dead file's last code and say, in the ghost's voice, what it used to do.
const prompt = `Here is the last version of a deleted file called "${path}":
"""
${words.excerpt.slice(0, 1200)}
"""
In one short sentence, say what this file used to do, spoken by its ghost in first
person and past tense ("I used to ..."). Base it only on the code above: name the
concrete thing it did. Be a little eerie. Reply with the sentence only.`;
Grounded in the code, the lines are specific and right. lib/view/partial.js "used to resolve partial object names from the view path", and the old jQuery copy says "I was a jQuery JavaScript Library, version 1.3.2".
A smaller lesson: you can't tell a 1B model what not to say
Gemma loved the word "meticulously": four of the first six ghosts did something meticulously. I added "Do not use the words meticulously, diligently or carefully" to the prompt, and the very next ghost was still "diligently processing requests". Small models don't follow negative instructions well. So the filler words are now stripped in code after generation, a three-line regex, and it works every time.
Why Open Matters Here
A repository's history is its team's private memory: names, commit messages, and code that was deleted on purpose. A tool like this only makes sense if none of that leaves your machine, especially for a company's private repos.
- Gemma 3 1B runs locally through Transformers.js and onnxruntime-node, on an ordinary laptop CPU. No code, names or commit messages are sent anywhere.
- It works with the lights (and the internet) off. After a one-time ~860 MB download, it's fully offline.
- It costs nothing, and it won't disappear. No API key and no hosted service that can be shut down. That feels right for a tool about remembering things that are gone.
- The model is swappable. Set SEANCE_MODEL to summon with a bigger model.
What's Next
- Summon deleted functions, not just whole files, by walking git log -p for definitions that vanished.
- A "family tree" that follows renames, so a file's ancestors attend its funeral too.
- git-seance haunt: a commit hook that writes a short epitaph whenever you delete a file.
Prize Categories
Best Use of Gemma. Gemma 3 1B is the only model in the project. It runs locally and reads every ghost's last code to say what it used to do.
---
Built with AI assistance (Claude Code) for scaffolding, testing and debugging. The demo uses the public Express repository's history; every ghost's facts come straight from its git log.
git-ofrenda: an altar for the code your repo buried, with epitaphs by Gemma on a plain CPU 🕯️
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine
What I Built
git-ofrenda 🕯️🌼 builds an ofrenda for the code your repo buried.
Every repository that lives long enough has a graveyard. Files that handled real traffic for years, got touched by a hundred commits and half a dozen people, and then vanished in a commit called "Removed old cruft". git log still remembers them. Nobody visits.
git-ofrenda digs them up:
1. It walks your git history and finds every file that was deleted and never came back.
2. It collects the facts of each one's life from git: when it was born and who added it, when it died and who removed it, the commit message that killed it, how many commits it lived through, who tended it, and its last lines of code.
3. A local open-weight model (Gemma 3, through Ollama or llama.cpp) writes each one an epitaph and a short remembrance.
4. It builds an altar: one static page with a candlelit headstone per file.
5. And if you want to ask a dead file something, ofrenda seance lets you talk to it. The ghost only knows its own record.
That's my reading of the theme from both sides: Halloween gets the séance, and Día de los Muertos gets the part I care about more, remembering the work that came before. Open source runs on code that someone wrote, maintained, and eventually retired. Those files deserve better than a line in git log --diff-filter=D.
It's a single Python file with no dependencies beyond the standard library, and it ships with a SKILL.md, so a coding agent can run it for you too. I build agent skills, so that was the natural shape.
Demo
The altar for Express.js: aniruddhaadak80.github.io
I pointed it at the Express repository: 6,183 commits, and 502 files deleted and never revived. The altar has 12 headstones, the .js files that lived through the most commits. A lot of them are from Express's 2009-2011 era: lib/express/core.js, request.js, view.js, a plugins/ folder, a Collection class, and tests for a Jade view engine.
!The Express altar
Each headstone keeps the model's words and git's facts apart. The italic epitaph and the short paragraph are Gemma's. The dates, commit count, number of keepers, cause of death and last words are printed straight from git.
!One headstone with its last words opened
Here's the full build and a séance, as it ran:
!Build log and séance in the terminal
Code
{% github aniruddhaadak80/API_projects %}
git-ofrenda lives in the git-ofrenda/ folder (MIT).
ofrenda.py the CLI: list, build, render, seance (stdlib only)
SKILL.md agent skill: when to use it, how to read the output
test_ofrenda.py 11 unit tests
index.html the live Express altar
express/ raw souls.json, OFRENDA.md, build log, séance transcript
Try it on your own repo:
ollama pull gemma3:4b
python3 ofrenda.py list ~/src/your-repo # what did it bury?
python3 ofrenda.py build ~/src/your-repo --model gemma3:4b # build the altar
python3 ofrenda.py seance ~/src/your-repo path/to/dead/file.js --model gemma3:4b
It also works with llama.cpp's llama-server, LM Studio or vLLM: pass --url http://localhost:8080/v1. With no model at all, --no-model still builds a facts-only altar.
How I Built It
The open pieces
- Gemma 3 4B Instruct, an open-weight model, as a 4-bit GGUF (ggml-org/gemma-3-4b-it-GGUF, Q4_K_M, 2.49 GB).
- llama.cpp (llama-server, build b11913) for local inference on plain CPU. The same code also talks to Ollama's native API.
- git itself, which is open source and is the real archivist here.
Git does the remembering, Gemma does the mourning
The rule I followed: anything that can be a fact comes from git, and the model only writes words.
- git log --diff-filter=D finds deletions. Renames aren't deaths (git's rename detection handles that). Files deleted and later re-added are alive, so they're skipped. Lockfiles, images and minified bundles are ignored.
- For each file it reads the first commit that added it, every commit that touched it, and its content one commit before it died (git show <death>^:path). The last 12 non-blank lines become its "last words".
- Files are ranked by how many commits they lived through, with at most two per burial commit, so one big cleanup can't take every headstone.
Keeping a small model honest
The prompt gives Gemma the facts block and asks for exactly two lines, EPITAPH: and REMEMBRANCE:. Then code checks the answer:
- Wrong shape (a list of "options", a missing line, a ramble) → retry.
- Any number that isn't in the file's facts → reject and retry. A model that says a file "fixed 47 bugs" when git never said so doesn't get a headstone.
- Three misses → a plain factual template, and the card says written by template.
My first prompt got me twelve near-identical epitaphs about "silent echoes" and "digital dust". Telling the model to play on something particular to this file (its name, its last lines, the words of its cause of death) and banning those stock phrases helped a lot. Gemma started quoting the code: view.js got "'Return str;' – A final, insistent plea.", and the view plugin, whose final lines hand null to a callback, got "'Null' was its last word, a clean, decisive end."
The séance
seance gives the model the file's record as its whole world and tells it to say "the veil hides it" for anything the record doesn't answer. I asked lib/express/core.js why it was let go:
> Oh, my dear, it was a rather abrupt farewell, wasn't it? Tj Holowaychuk simply deemed me "old cruft" – a terribly blunt assessment, if you ask me. I suppose I did spend a good 200 days diligently handling requests, though my last lines were quite straightforward: if (typeof (body = (new Router(request)).route()) === 'string') request.respond(200, body). The veil hides the specifics of why they chose that particular moment, but I don't truly regret my work, it was a simple, necessary task.
Every fact in that answer checks out against git: the commit message, who removed it, the 200 days, and that line really is in its final version.
What I measured
All on a machine with 2 ARM CPU cores, 7 GB RAM and no GPU:
| | Gemma 3 4B (Q4_K_M) | Gemma 3 1B (Q4_K_M) |
|---|---|---|
| Altar of 12 headstones | 317 s (≈26 s per file) | 118 s |
| Fell back to the template | 0 of 12 | 2 of 12 |
| Generation speed | ≈8 tokens/s | ≈27 tokens/s |
| One séance answer | 34 s | not tested |
The 1B model is three times faster, but its epitaphs were generic, so 4B is what I'd use. Five minutes for an altar is fine for something you run once per repo.
Where it still gets things wrong
The number guard catches invented statistics, not invented meaning. On Express, Gemma 4B called test/response.test.js a "small file" that "served only to declare 'hey'". It was 769 lines with 103 commits; it just happened to end with a tiny res.send('<p>hey</p>') test. That's exactly why the dates and counts on every headstone come from git and the poetry is labelled as the model's.
Tests (python3 -m unittest -v test_ofrenda.py, 11 passing) build a throwaway repo with a rename, a resurrection and a binary file, and check the archaeology, the parser, the number guard, the retry-then-template path against a fake OpenAI-style server and a fake Ollama server, and an unreachable server.
Why Does Open Innovation Matter?
Your git history is private, and this keeps it that way. A repo's history is full of names, internal commit messages and old code that was removed for a reason. An open-weight model running on your own machine means git-ofrenda can read all of it without uploading anything. I went one step further: the HTTP client ignores system proxy settings, so requests go straight to the localhost URL you give it.
It works with the lights off. After the model is downloaded once, building an altar or holding a séance needs no internet and no API key.
It runs on whatever you have. Gemma 3 is open-weight and comes in sizes from 1B up, and llama.cpp and Ollama run it on a laptop CPU. I measured it on two CPU cores with no GPU. A closed API would have given me nicer prose and a bill per repo, and it would stop working the day the service changes its terms.
It fits the theme. This is a tool for honouring code that open-source maintainers wrote and retired. It would feel wrong if remembering their work depended on a service that might disappear too. The model, the runtime, git, and git-ofrenda itself are all open, so the altar can outlive any one of them.
The Spirit of It
The moment it worked for me was opening lib/express/core.js on the altar: born November 2009, 222 commits, 6 keepers, gone in June 2010 with "Removed old cruft", and its last lines still there, wiring request.trigger('request') to a Router. That's one of the earliest pieces of a framework that a huge part of the Node.js world was built on, and most people using Express today have never seen it.
git-ofrenda doesn't make that code come back. It just lights a candle for it.
Prize Categories
- Best Use of Gemma: Gemma 3 4B Instruct (open weights, GGUF via llama.cpp) writes every epitaph and speaks at every séance. Gemma 3 1B was measured as the fast option.
1 reaction
@avinxshKD there are so many. its crazy: github.com/expressjs/express/pulls
14 views
週刊プログラミングニュース|Deno・Python・OSSと開発ツール【2026-W42/10月9〜15日】
週刊プログラミングニュース|2026年10月9日〜15日 | 2026-W42
最初にDeno、Python、Bun、GitHub Actions、.NET、シークレット保護。その後、OSS・フレームワーク、ライブラリー・データベース、IDE・開発プラットフォームを章ごとに整理します。
対象期間:10月9日00:00〜15日23:59 JST。時刻を確認できた発表はJSTへ換算し、日付のみの資料は公式表記日を採用しています。境界時刻が未確認の候補は確定分と区別しました。
正式版、プレビュー、今後の予定を区別しています。Dockerは修正済み問題の開示、Nodeの10月21日予定分は未提供の修正予告です。
映像は公式情報に基づく解説図で、実機デモではありません。性能値は発表元の測定で、独自の実測ではありません。
日本語音声・日英字幕。
チャプター
00:00 週刊プログラミングニュース
00:28 DenoチームがCloudflareへ
01:50 Python 3.15が正式公開
03:18 Bun 1.4.3に型チェッカー
04:30 Actionsにワークフローロック
05:38 .NETは安定版更新とRC2
07:00 GitHubのシークレット検出拡大
07:59 Next.jsのセキュリティ更新
09:28 Express・Fastify・Micronaut
10:57 フロントエンドの安定版とRC
12:16 Astro・Rolldown・Laravel
13:21 Hydrogenがライブラリーへ
14:14 Pydantic・NumPy・FastAPI
15:59 PgBouncerとpsqlODBC
17:25 SQLite 3.54.0
18:38 Ruby 4.1のプレビュー
19:39 Node.js更新と次のセキュリティ修正
20:51 VS CodeとVisual Studio
22:07 Cursor Originのアプリ連携
22:54 GitLabの緊急修正
23:57 Docker Hubの修正済み問題の開示
25:04 Lambdaの新しい実行モデルと接続維持
26:27 今週の確認リスト
公式出典
deno.com
blog.python.org
bun.com
github.blog
devblogs.microsoft.com
github.blog
devblogs.microsoft.com
github.com/dotnet/core/blob/main/release-notes/10…
nextjs.org
github.com/expressjs/express/releases/tag/v5.3.0
github.com/fastify/fastify/releases
github.com/fastify/fastify/security/advisories/GH…
micronaut.io
micronaut.io
github.com/angular/angular/releases/tag/v22.2.3
github.com/vuejs/core/releases
github.com/vuejs/core/blob/main/CHANGELOG.md
github.com/vuejs/core/blob/minor/CHANGELOG.md
github.com/nuxt/nuxt/releases/tag/v4.6.1
github.com/sveltejs/kit/releases/tag/@sveltejs%2F…
github.com/withastro/astro/releases/tag/astro@7.4…
github.com/rolldown/rolldown/releases/tag/v1.2.14
github.com/laravel/framework/releases/tag/v13.36.0
github.com/laravel/framework/releases
shopify.dev
github.com/pydantic/pydantic/releases/tag/v2.14.0
pydantic.dev
github.com/pydantic/pydantic/releases
github.com/numpy/numpy/releases/tag/v2.5.4
github.com/fastapi/fastapi/releases/tag/0.143.1
github.com/fastapi/fastapi/releases/tag/0.143.2
pgbouncer.org
github.com/pgbouncer/pgbouncer/releases/tag/pgbou…
postgresql.org
sqlite.org
ruby-lang.org
nodejs.org
nodejs.org
nodejs.org
openwall.com
code.visualstudio.com
learn.microsoft.com
cursor.com
docs.gitlab.com
docker.com
aws.amazon.com
developers.cloudflare.com
40 views
AIが提案したパッケージ名を、インストール前にレジストリで確かめる PyPIとnpmの公開メタデータを使う
はじめに
AIが提案したコードに、見覚えのないパッケージ名が入っていたとき、そのまま pip install や npm install を打つ前に何を見ればよいか、という記事です。レジストリの公開メタデータに問い合わせる短いスクリプトと、実際の出力を載せています。
内容は4つです。なぜインストールの前なのか、スクリプトと出力、レビューへの組み込み方、この確認で分からないこと。スクリプトだけ持ち帰ってもらっても構いません。
掲載したスクリプトは、この記事のために書いたものです。使うのはPythonの標準ライブラリだけで、追加のインストールは要りません。
環境
macOS 26.6.2、Python 3.14.6、bash 3.2.57、git 2.54.0。2026年10月11日に実行しました。出力に出てくるバージョン番号や日付、リリース数は、その時点のものです。
なぜインストールの前に見るのか
生成AIは、実在しない情報をそれらしく出すことがあります。文章なら誤った一文で済みますが、コードの場合は、存在しないパッケージ名が import 文やインストール手順に入ります。
IPAの「情報セキュリティ10大脅威 2026」の解説書(組織編)は、AIが作り出したパッケージ名を悪用するサプライチェーン攻撃を取り上げています(Koi Securityの報告を引用)。AIが間違えて出しやすい名前を攻撃者が先に登録しておき、開発者がインストールするのを待つ、という手口です。スロップスクワッティング(slopsquatting)という非公式な呼び名もあります。同じ解説書には、AIが生成したコードに脆弱性が含まれる場合がある、という記述もあります。
確認をインストールの前に置く理由は単純で、PyPIでもnpmでも、インストールの過程でパッケージ側のコードが動くことがあるからです。入れてから調べる、では順番が逆になります。
この記事をざっくり図解
flowchart LR
A["AIの提案に<br>知らないパッケージ名"] --> B["check_pkg.py"]
B -->|公開メタデータを取得| C["PyPI / npm<br>レジストリ"]
B --> D{"終了コード"}
D -->|0| E["人がソースと<br>用途を見て判断"]
D -->|1 要確認| E
D -->|2 存在しない| F["入れない<br>正しい名前を調べ直す"]
D -->|3 問い合わせ失敗| G["再実行"]
終了コード0でも、最後に判断するのは人です。スクリプトがやるのは、判断の材料を1画面に並べるところまでです。
1. スクリプト
PyPIはJSON API(https://pypi.org/pypi/{名前}/json)、npmはレジストリ(https://registry.npmjs.org/{名前})に、認証なしでGETします。取り出すのは、初回公開日、最終公開日、リリース数、メンテナー、ソースの場所です。PyPIは最新版について登録されている既知の脆弱性も返すので、その件数も表示します。
#!/usr/bin/env python3
"""インストール前に、パッケージ名をレジストリの公開メタデータで確かめる。
使い方: python3 check_pkg.py {pypi|npm} 名前 [名前 ...]
終了コード: 0=気になる点なし 1=要確認あり 2=存在しない名前あり 3=問い合わせ失敗
"""
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
MIN_AGE_DAYS = int(os.environ.get("MIN_AGE_DAYS", 90)) # 初回公開からこの日数未満なら要確認
MIN_RELEASES = int(os.environ.get("MIN_RELEASES", 5)) # リリース数がこれ未満なら要確認
NAME_OK = re.compile(r"^(@[a-z0-9._-]+/)?[A-Za-z0-9._-]+$")
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": "pre-install-check/0.1"})
with urllib.request.urlopen(req, timeout=15) as res:
return json.load(res)
def parse(ts):
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
def pypi(name):
d = fetch(f"https://pypi.org/pypi/{urllib.parse.quote(name)}/json")
times = [parse(f["upload_time_iso_8601"])
for files in d["releases"].values() for f in files]
urls = d["info"].get("project_urls") or {}
roles = (d.get("ownership") or {}).get("roles") or []
return {
"name": d["info"]["name"],
"first": min(times) if times else None,
"last": max(times) if times else None,
"releases": sum(1 for files in d["releases"].values() if files),
"latest": d["info"]["version"],
"source": urls.get("Source") or urls.get("Repository") or urls.get("Homepage") or "(記載なし)",
"maintainers": ", ".join(r["user"] for r in roles) or "(なし)",
"vulns": len(d.get("vulnerabilities") or []),
}
def npm(name):
d = fetch(f"https://registry.npmjs.org/{urllib.parse.quote(name, safe='@')}")
t = d.get("time", {})
versions = d.get("versions", {})
repo = d.get("repository") or {}
return {
"name": d["name"],
"first": parse(t["created"]) if "created" in t else None,
"last": max((parse(t[v]) for v in versions if v in t), default=None),
"releases": len(versions),
"latest": d.get("dist-tags", {}).get("latest", "(なし)"),
"source": repo.get("url", "(記載なし)") if isinstance(repo, dict) else str(repo),
"maintainers": ", ".join(m["name"] for m in d.get("maintainers", [])) or "(なし)",
"vulns": None, # このAPIの応答には含まれない
}
def check(registry, name):
if not NAME_OK.match(name):
print(f"[{registry}] {name}\n 名前の形式が不正です")
return 3
try:
m = {"pypi": pypi, "npm": npm}[registry](name)
except urllib.error.HTTPError as e:
if e.code == 404:
print(f"[{registry}] {name}\n 判定: 存在しません(404)。インストールしないでください")
return 2
print(f"[{registry}] {name}\n 判定: 問い合わせ失敗(HTTP {e.code})。存在しないとは限りません")
return 3
except (urllib.error.URLError, TimeoutError) as e:
print(f"[{registry}] {name}\n 判定: 問い合わせ失敗({e})。存在しないとは限りません")
return 3
notes = []
if m["name"] != name:
notes.append(f"レジストリ上の名前は {m['name']} です")
if m["first"] is None or m["releases"] == 0:
notes.append("公開済みのファイルがありません")
else:
age = (datetime.now(timezone.utc) - m["first"]).days
if age < MIN_AGE_DAYS:
notes.append(f"初回公開から{age}日(基準{MIN_AGE_DAYS}日未満)")
if m["releases"] < MIN_RELEASES:
notes.append(f"リリース数{m['releases']}(基準{MIN_RELEASES}未満)")
if m["vulns"]:
notes.append(f"最新版に既知の脆弱性の登録が{m['vulns']}件")
day = lambda x: x.strftime("%Y-%m-%d") if x else "(不明)"
print(f"[{registry}] {m['name']}")
print(f" 初回公開 : {day(m['first'])}")
print(f" 最終公開 : {day(m['last'])} (最新 {m['latest']})")
print(f" リリース数 : {m['releases']}")
print(f" メンテナー : {m['maintainers']}")
print(f" ソース : {m['source']}")
if m["vulns"] is not None:
print(f" 既知の脆弱性: {m['vulns']}件(最新版、PyPIの登録分)")
print(" 判定: " + ("要確認。" + " / ".join(notes) if notes else "気になる点なし(安全の証明ではありません)"))
return 1 if notes else 0
if __name__ == "__main__":
if len(sys.argv) < 3 or sys.argv[1] not in ("pypi", "npm"):
print(__doc__, file=sys.stderr)
sys.exit(3)
sys.exit(max(check(sys.argv[1], n) for n in sys.argv[2:]))
書くときに決めたことを、3つだけ挙げます。
404と通信失敗を分ける。 社内プロキシやレジストリ側の不調で応答が取れなかったときに「存在しません」と出すと、実在するパッケージを疑うことになります。存在しないと言えるのは404が返ったときだけにして、それ以外は終了コード3にしました。
名前の形式を先に確かめる。 渡された文字列をそのままURLに入れるので、英数字と . _ -、npmのスコープ(@scope/name)以外は問い合わせる前に弾いています。
基準値は目安。 初回公開から90日未満、リリース数5未満を「要確認」にしていますが、この数字に根拠となるデータはありません。新しくて良いパッケージも当然あります。環境変数で変えられるようにしてあるので、チームで決めた値に置き換えてください。
2. 実行結果
よく知られたパッケージ
python3 tools/check_pkg.py pypi requests
[pypi] requests
初回公開 : 2011-02-14
最終公開 : 2026-05-14 (最新 2.34.2)
リリース数 : 160
メンテナー : Lukasa, graffatcolmingov, nateprewitt
ソース : https://github.com/psf/requests
既知の脆弱性: 0件(最新版、PyPIの登録分)
判定: 気になる点なし(安全の証明ではありません)
python3 tools/check_pkg.py npm express
[npm] express
初回公開 : 2010-12-29
最終公開 : 2026-10-09 (最新 5.3.0)
リリース数 : 290
メンテナー : wesleytodd, jonchurch, ctcpip, ulisesgascon, sheplu
ソース : git+https://github.com/expressjs/express.git
判定: 気になる点なし(安全の証明ではありません)
終了コードはどちらも0でした。メンテナーの欄は、PyPIでは ownership.roles、npmでは maintainers から取ったアカウント名です。最初はPyPIの author を出そうとしましたが、これは公開する側が自分で書く値でした。応答を見直して ownership があると分かり、そちらに替えています。
存在しない名前
説明用に作った名前で試します。2026年10月11日の時点で、PyPIにもnpmにも登録されていませんでした。
python3 tools/check_pkg.py pypi no-such-package-demo-20261011
[pypi] no-such-package-demo-20261011
判定: 存在しません(404)。インストールしないでください
終了コードは2です。npmでも同じ結果でした。AIの提案に入っていた名前でこれが出たら、AIが作った名前か、書き間違いの可能性があります。その名前は入れずに、やりたかったことから正しいパッケージを探し直します。
一つ注意があります。記事に載せた名前は、あとから誰でも登録できます。みなさんが試すときには、結果が変わっているかもしれません。この確認が「今の時点」のものでしかない、という実例でもあります。
要確認になる場合
実在の新しいパッケージを名指しするのは避けたかったので、基準値のほうを極端にして分岐を確かめました。
MIN_AGE_DAYS=100000 python3 tools/check_pkg.py pypi requests
[pypi] requests
初回公開 : 2011-02-14
最終公開 : 2026-05-14 (最新 2.34.2)
リリース数 : 160
メンテナー : Lukasa, graffatcolmingov, nateprewitt
ソース : https://github.com/psf/requests
既知の脆弱性: 0件(最新版、PyPIの登録分)
判定: 要確認。初回公開から5717日(基準100000日未満)
終了コードは1です。試していて気づいた点が2つありました。
Requests と大文字で渡すと、PyPIは requests の情報を返します。PyPIは名前を正規化して扱うためです。スクリプトは、渡した名前とレジストリ上の名前が違うときも要確認にしているので、この場合は終了コード1になりました。表記の違いだけなら問題ありませんが、別の名前に解決されたことには気づけます。
urllib3 では、ソースの欄が「(記載なし)」になりました。リポジトリのURLは登録されているのに、項目名が Code で、スクリプトが見ている Source Repository Homepage のどれでもなかったためです。項目名はプロジェクトごとにばらつくので、「記載なし」は「リポジトリがない」という意味にはなりません。
3. レビューに組み込む
手元で1回打つだけでは、忘れた日に抜けます。依存を追加するプルリクエストで、追加された名前だけを取り出して確認する形にしました。
#!/usr/bin/env bash
# 使い方: tools/check-new-deps.sh [比較元ブランチ]
# requirements.txt に追加された行だけを取り出して、check_pkg.py に渡す
set -euo pipefail
base="${1:-main}"
names=$(git diff "$base"...HEAD -- requirements.txt \
| grep -E '^\+[A-Za-z0-9]' \
| sed -E 's/^\+//; s/[^A-Za-z0-9._-].*$//' || true)
if [ -z "$names" ]; then
echo "requirements.txt に追加された依存はありません"
exit 0
fi
# shellcheck disable=SC2086
python3 tools/check_pkg.py pypi $names
requirements.txt に urllib3>=2.0 と、先ほどの存在しない名前を追加したブランチで実行した結果です。
tools/check-new-deps.sh main
[pypi] urllib3
初回公開 : 2009-12-11
最終公開 : 2026-09-15 (最新 2.8.0)
リリース数 : 105
メンテナー : SethMichaelLarson, illia-v, shazow
ソース : (記載なし)
既知の脆弱性: 0件(最新版、PyPIの登録分)
判定: 気になる点なし(安全の証明ではありません)
[pypi] no-such-package-demo-20261011
判定: 存在しません(404)。インストールしないでください
終了コードは2でした。追加がないブランチでは「requirements.txt に追加された依存はありません」と出て、0で終わります。
出力はプルリクエストの説明欄に貼り、テンプレートに次の項目を足しておくと、レビューする側も同じものを見られます。
## 依存の追加(ある場合のみ)
- [ ] `tools/check-new-deps.sh` の出力を下に貼った
- [ ] 追加した名前は、公式ドキュメントやリポジトリのREADMEに書かれたインストール名と一致している
- [ ] AIの提案で知った名前の場合、提案以外の情報源でも確認した
- [ ] 「要確認」が出たものは、追加する理由を1行書いた
2つ目の項目が、実はいちばん効きます。スクリプトが教えてくれるのは「その名前で何かが登録されている」ことまでで、「自分が使いたかったライブラリの正しい名前である」ことは、公式の情報と見比べないと分かりません。
終了コードを分けてあるので、CIで2のときだけ失敗させ、1は警告にとどめる、といった使い方もできます。ただし、CI上では実行していません。確かめたのは手元のgitリポジトリだけです。
この確認で分からないこと
- 存在することは、安全の証明になりません。 攻撃者が先に登録した名前は「存在する」ので、存在確認だけなら通ります。新しさとリリース数を見ているのはそのためですが、基準を超えるまで待つ相手には効きません。
- 有名な名前に似せた名前は検出しません。 1文字違いの名前が実在すれば、その情報が普通に表示されます。
- 古くて実績のあるパッケージが、ある版から悪意のあるものに変わる場合も見えません。 見ているのは登録の履歴で、中身のコードは読んでいません。
- メタデータは公開する側が書きます。 ソースの欄に、他人のリポジトリのURLを書くこともできます。
- 直接指定した名前しか見ていません。 そのパッケージが連れてくる依存は対象外です。
- 利用者数は見ていません。 PyPIのJSON APIにはダウンロード数の項目がありますが、値は -1 で、公式ドキュメントにも使わないようにと書かれています。npmには別のAPIがありますが、スクリプトには入れていません。
- 既知の脆弱性の表示は、PyPIの最新版の件数だけです。 0件は「登録がない」という意味で、「脆弱性がない」という意味にはなりません。npm側は見ていません。依存全体を照合する監査ツールや、ロックファイルでの版とハッシュの固定は別の層の対策で、この記事では試していません。
試していない範囲も書いておきます。check-new-deps.sh が読むのは requirements.txt だけで、確かめた書式は 名前==版、名前>=版、名前のみ、コメント行です。-r での読み込み、URL指定、extras、環境マーカーは試していません。package.json やロックファイルの差分、プライベートレジストリやミラー、レジストリのレート制限も未確認です。既知の脆弱性が1件以上あるときに要確認になる分岐も、該当するパッケージで実行してはいません。
最後に
AIが提案した名前は、インストールの前に一度レジストリに聞く。404なら入れない。存在していても、公式の情報と名前を見比べる。この記事のスクリプトは、その最初の一歩を手早く済ませるためのものです。まずは次に依存を追加するときに、1回だけ試してみてください。
参考文献
- IPA「情報セキュリティ10大脅威 2026」 ipa.go.jp
- PyPI Docs: JSON API docs.pypi.org
- npm: Public Registry API github.com/npm/registry/blob/main/docs/REGISTRY-A…
- Python Packaging User Guide: Names and normalization packaging.python.org
- 背景(シャドーAIとAI生成コードのリスクの整理)は、VAONのブログにまとめています。 vaon.com.vn
Go 1.27 HTTP Router with Generic Methods and Zero-Alloc Routing: FURSY — What an Independent Audit Found at 94% Coverage
---
title: "Go 1.27 HTTP Router with Generic Methods and Zero-Alloc Routing: FURSY — What an Independent Audit Found at 94% Coverage"
published: true
description: "FURSY is a type-safe HTTP router for Go 1.27+ with zero-alloc routing, RFC 9457 errors, and Google AIP-136 support. An independent audit found CORS bypass, phantom docs, and more — here's what we fixed."
tags: go, opensource, webdev, security
cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com
canonical_url:
series:
---
---
We shipped FURSY v0.5.4 thinking it was production-ready. Ninety-four percent test coverage. Thirty-five findings from our internal audit, all fixed. Twelve CI checks across three operating systems. Zero-alloc routing under 100 ns/op.
Then we handed the codebase to an independent auditor. They found the CORS middleware echoing back unfiltered headers to browsers. The SECURITY.md teaching six APIs that don't exist. Error responses claiming RFC 9457 compliance while returning text/plain. A path parser that panics on Google's most widely used REST convention.
This is the story of what went wrong, how we fixed it, and what fursy actually is after the cleanup.
What Is Fursy
FURSY (Fast Universal Routing SYstem) is an HTTP router for Go 1.27+ with three properties that don't usually come together:
1. Generic methods on the router -- router.POST("/users", handler) where handler receives a typed *Box[CreateReq, UserRes]. Go 1.27 enabled this; to our knowledge, FURSY is the first Go router to ship generic methods on the router type.
2. Zero-allocation routing -- sub-100 ns/op for static routes, 0 B/op, 0 allocs/op. The radix tree reuses a caller-provided param buffer from sync.Pool.
3. RFC 9457 Problem Details as the default -- every auto-generated error (404, 405, 401, 429, 500) returns application/problem+json. Not opt-in. Not "if you use our error type." Every response.
We built it as the successor to ozzo-routing, Qiang Xue's router from the Yii framework ecosystem. When Xue handed over the go-ozzo organization in mid-2026, FURSY was already running in our internal services.
The Generic Methods Story
Before Go 1.27, if you wanted type-safe handlers in Go, your options were ugly:
// Package-level functions -- verbose, no type inference
fursy.POST[CreateUserReq, UserRes](router, "/users", handler)
// Or abandon type safety -- manual binding, no compile-time guarantees
router.Handle("POST", "/users", func(c *Context) error {
var req CreateUserReq
// manual JSON decode, manual validation, manual error handling...
})
Go 1.27 allows methods on concrete types to declare their own type parameters. This changes everything:
router.POST("/users", func(c *fursy.Box[CreateUserReq, UserRes]) error {
// c.ReqBody is *CreateUserReq -- already validated, already bound
user := createUser(c.ReqBody)
return c.Created("/users/"+user.ID, UserRes{ID: user.ID, Name: user.Name})
})
No explicit type parameters at the call site. The compiler infers [CreateUserReq, UserRes] from the handler signature. Binding happens automatically before your handler runs -- if the JSON is malformed, the client gets a 400 before your code executes.
This sounds minor until you have 200 endpoints. The reduction in ceremony is significant.
What the Audit Found: CORS Was Echoing Everything
The most dangerous finding was in the CORS middleware. When a browser sends a preflight OPTIONS request, it includes Access-Control-Request-Headers listing which headers the JavaScript wants to use. The server should respond with only the headers it actually allows.
Our code:
// cors.go -- BEFORE the fix
func (cfg *CORSConfig) setPreflightHeaders(origin, method, reqHeaders string, headers http.Header) {
allowed, allowedHeaders := cfg.isPreflightAllowed(origin, method, reqHeaders)
if !allowed {
return
}
// ...
if allowedHeaders != "" {
headers.Set("Access-Control-Allow-Headers", reqHeaders) // BUG
}
}
See it? isPreflightAllowed returns allowedHeaders (the filtered list), but line 225 writes reqHeaders (the raw request value). The browser sends Content-Type, X-Evil-Header, we configured the server to only allow Content-Type, and the response says "sure, X-Evil-Header is fine too."
The fix was one word:
headers.Set("Access-Control-Allow-Headers", allowedHeaders) // FIXED
One word. Ninety-four percent test coverage didn't catch it because every test requested only headers that were in the allowlist. The regression test we added:
func TestCORS_PreflightFilteredHeaders(t *testing.T) {
r := fursy.New()
r.Use(CORSWithConfig(CORSConfig{
AllowOrigins: "https://example.com",
AllowMethods: "GET,POST",
AllowHeaders: "Content-Type,Authorization",
}))
// ... setup ...
req := httptest.NewRequest("OPTIONS", "/api", http.NoBody)
req.Header.Set("Access-Control-Request-Headers", "Content-Type, X-Evil-Header")
// ...
got := w.Header().Get("Access-Control-Allow-Headers")
if strings.Contains(got, "X-Evil-Header") {
t.Errorf("preflight echoed disallowed header: got %q", got)
}
}
Lesson: test the negative case. Every CORS test we had sent only valid headers.
The Documentation Was Lying
The auditor checked whether our docs matched the actual API. They didn't.
| Document said | Reality |
|---|---|
| router.Run(":8080") | Method doesn't exist. Never did. |
| fursy.CSRF(fursy.CSRFConfig{...}) | No CSRF middleware |
| fursy.Timeout(30 * time.Second) | Doesn't exist |
| fursy.BodyLimit(10 * 1024 * 1024) | Doesn't exist. Body limit is router.SetMaxBodySize() |
| fursy.HTTPSRedirect() | Doesn't exist |
| fursy.RateLimit(100, time.Minute) | Actual signature: middleware.RateLimit(float64, int). This wouldn't even compile. |
| import "encoding/json/v2" | Code uses encoding/json. Go 1.27 made v2 the default behind this import. |
| SECURITY.md: supported versions 0.1.x, 0.2.x | Current version: 0.5.4 |
Six non-existent APIs documented as if they were real. An LLM coding assistant reading our llms.txt would confidently generate code that doesn't compile. A developer copying from SECURITY.md would get build errors.
How did this happen? The docs were written during the design phase and never reconciled with what was actually implemented. Classic drift -- the code evolves, the prose doesn't.
We cleaned out the worst offenders. Full documentation parity is still in progress -- doc drift is a hydra, and every time we grep for one stale reference, two more surface. The rule going forward: if the API doesn't exist in the source tree, it doesn't exist in the docs.
RFC 9457: Claiming It vs. Being It
Fursy's README said "Native RFC 9457 Problem Details." The auditor checked what GET /nonexistent actually returned:
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Not Found
Plain text. Not JSON. Not RFC 9457. The "native" Problem Details only activated when a handler explicitly returned a fursy.Problem. Every auto-generated error -- 404, 405, 413, 415, 400, 500, and all middleware rejections -- was text/plain.
We fixed this across all paths:
// BEFORE -- text/plain
terminalHandler = func(ctx *Context) error {
return ctx.String(http.StatusNotFound, "Not Found")
}
// AFTER -- RFC 9457
terminalHandler = func(ctx *Context) error {
return ctx.Problem(NewProblem(http.StatusNotFound, "Not Found", ""))
}
Same change in JWT (401), BasicAuth (401), RateLimit (429), CircuitBreaker (503), Recovery (500). Every error response is now:
{
"type": "about:blank",
"title": "Not Found",
"status": 404
}
With Content-Type: application/problem+json. This is a breaking change -- clients parsing text/plain error bodies will break. We shipped it in v0.6.0 because the project is pre-1.0 and the previous behavior was a lie.
The Colon That Broke Google's API Convention
A developer migrating from ozzo-routing reported that route registration panics:
router.GET("/clients:getOrganization/:id", handler1)
router.GET("/clients:getAddress/:id", handler2)
// panic: conflicting wildcard names: getOrganization vs getAddress
The : in clients:getOrganization was being parsed as a wildcard param marker. But this is a Google AIP-136 custom method -- the most widely adopted enterprise REST convention for non-CRUD operations. Google Firestore alone has 20+ endpoints using this pattern: /documents:commit, /documents:batchGet, /documents:rollback.
RFC 3986 Section 3.3 lists : as a legal path character. It doesn't need escaping. The :param convention is a Sinatra invention from 2007, not an RFC.
The fix: colons are wildcards only at segment start (after / or position 0):
// BEFORE
func findWildcardIndex(path string) int {
for i := 0; i < len(path); i++ {
if path[i] == ':' || path[i] == '*' {
return i // any position
}
}
return -1
}
// AFTER
func findWildcardIndex(path string) int {
for i := 0; i < len(path); i++ {
if (path[i] == ':' || path[i] == '*') && (i == 0 || path[i-1] == '/') {
return i // segment start only
}
}
return -1
}
For context: Gin fixed this in v1.11.0 after six years of open issues. Echo needed three separate PRs. httprouter never fixed it. Express.js has had it open since 2019.
Form Binding Errors: 500 Instead of 400
Submitting age=abc where age is an int field returned 500 Internal Server Error. The strconv parse error wasn't wrapped in our DecodeError type, so the error handler fell through to the catch-all:
// BEFORE -- raw error, caught by nothing
if err := setField(field, values[0]); err != nil {
return fmt.Errorf("set field %s error: %w", structField.Name, err)
}
// AFTER -- wrapped, caught as 400
if err := setField(field, values[0]); err != nil {
return &DecodeError{Err: fmt.Errorf("field %s: %w", structField.Name, err)}
}
A user sending ?age=abc now gets 400 Bad Request with a Problem Details body explaining which field failed. Not a 500 that makes your monitoring think the server is crashing.
This fix covers setField type mismatches. Errors from ParseForm/ParseMultipartForm itself (malformed percent-encoding, corrupt multipart boundaries) were also wrapped in v0.6.2.
What Fursy Doesn't Do
- No ORM -- use Relica or GORM or raw database/sql
- No migrations -- use goose or atlas
- No template rendering -- this is an API router, not a web framework
- No WebSocket/SSE built-in -- SSE works via the stream plugin; WebSocket upgrade requires the base responseWriter (no middleware wrappers in the chain) until stream adopts http.NewResponseController
- No process management -- use daemon if you need it
- No CSRF middleware -- we removed the docs that claimed it existed. If you need CSRF, use gorilla/csrf or write your own. We'll add it when we have a design we're confident in, not before.
Fursy vs. Other Go Routers (2026)
| Router | Go Version | Type Safety | Allocs/op | Error Format | Colon Literal | Dependencies |
|--------|-----------|-------------|-----------|--------------|---------------|-------------|
| Go 1.22+ stdlib | 1.22+ | {param} only | ~3 | text/plain | Yes ({param}) | 0 |
| chi v5 | 1.22+ | {param} only | ~2 | text/plain | Yes ({param}) | 0 |
| Gin | 1.18+ | No generics | 0 | custom JSON | Yes (v1.11+) | 8 |
| Echo | 1.18+ | No generics | 1 | custom JSON | Yes (escaped) | 4 |
| Fiber | 1.21+ | No generics | 0 | text/plain | No | fasthttp |
| fursy | 1.27+ | Generic methods | 0 (plain) / 2 (generic) | RFC 9457 | Yes (v0.6.2) | 2 (JWT, rate) |
Fursy requires Go 1.27. That's a hard requirement. If you're on 1.22 or earlier, use chi or the stdlib mux. If you want zero dependencies, use the stdlib. If you need the fastest raw throughput, Fiber on fasthttp wins.
Fursy is for teams that want type-safe handlers, zero-alloc routing, and RFC-compliant error responses in a single package, and can target Go 1.27+.
Numbers
| Metric | Value |
|--------|-------|
| Core library | ~8,200 lines |
| Test code | ~16,000 lines (2:1 ratio) |
| Test functions | 510+ (core + plugins) |
| Benchmarks | 21 |
| Coverage | 93.8% core, 96.5% middleware |
| Static route lookup | <100 ns/op, 0 alloc |
| Parametric route lookup | <140 ns/op, 0 alloc |
| Production dependencies | 2 (golang-jwt, x/time) |
| Plugin dependencies | Isolated per plugin |
| CI checks | 12 (3 OS, lint, format, vuln, 4 plugins, examples, codecov) |
| External audit findings | 45 claims checked, 33 confirmed, all fixed in v0.6.0-v0.6.2 |
Gotchas
router.GET Is a Generic Method Now
After v0.5.0, router.GET expects Handler[Req, Res] (a function taking *Box[Req, Res]), not HandlerFunc (taking *Context). For plain handlers without type-safe binding:
// This does NOT compile:
router.GET("/health", func(c *fursy.Context) error { ... })
// Use Handle for plain handlers:
router.Handle("GET", "/health", func(c *fursy.Context) error { ... })
// Or use the generic form with Empty types:
router.GET("/health", func(c *fursy.Box[fursy.Empty, fursy.Empty]) error { ... })
Plugins Have Their Own go.mod
Each plugin (plugins/opentelemetry, plugins/database, plugins/stream, plugins/validator) is a separate Go module. When upgrading fursy, update the plugin's go.mod too:
cd plugins/stream
go get github.com/coregx/fursy@v0.6.2
go mod tidy
CORS Must Be Global
CORS middleware must be registered at the router level (router.Use()), not on a group. Preflight OPTIONS requests hit the global middleware chain before route matching. Group-level CORS will miss them. This matches Gin, Echo, and Chi behavior.
The Second Pass
The first audit found the CORS echo, the phantom docs, and the text/plain lie. We fixed those in v0.6.0. Then we sent the fix branch back for re-validation. The second pass found 11 more: an open redirect in the trailing-slash handler, WebSocket hijack broken by our own response wrapper, the circuit breaker counting 404s as backend failures, and the OpenAPI generator not knowing about the colon fix it shipped alongside. All fixed in v0.6.2.
The pattern is humbling: every fix creates a surface for the next bug. The CORS allowlist fix shipped next to a default that had been blocking JSON POST preflights all along -- no test ever sent one. The colon fix in the radix tree didn't propagate to the OpenAPI converter. The response wrapper that tracked written didn't implement Hijack. Ninety-four percent coverage catches syntax, not semantics.
Try It
go get github.com/coregx/fursy@v0.6.2
The project is at v0.6.2. The API is not frozen -- we're pre-1.0 and will break things when the design demands it. The llms.txt documents every API pattern for both developers and AI coding assistants.
coregx/fursy -- Type-safe HTTP router for Go 1.27+. RFC 9457 errors. Zero-alloc routing. MIT license.
1 reaction
Mentions
I measured two common Claude Code complaints on three repos. Neither showed up. --- title: I measured two common Claude Code complaints on three repos. Neither showed up. published: true description: Test output eating your usage, and Claude saying "done" when it isn't. 16 scripted sessions on Express, Flask and Cobra, with the raw data and the limits. tags: claude, ai, testing, devtools cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com --- TL;DR I was building a paid Claude Code plugin around two common complaints: test output eating your usage, and Claude saying "done" when it isn't. Before shipping it, I measured both on three public repos. - Raw test output is big. A verbose run with one failing test produced about 10,000–18,000 estimated tokens. A filter that keeps failures and the runner's summary cut that by 95–99%. - Claude didn't read it raw. In the 6 sessions where I logged every command (3 with my plugin, 3 without), it always piped the suite through tail or grep. Each of those test commands returned 369–931 characters. My plugin had nothing left to filter. - Claude's "done" always matched my checker. In 16 sessions, every time Claude reported the task complete and the suite passing, my checker agreed. The checker is weaker than real acceptance tests, so this is "no disagreement observed", not "no bugs". So I'm not shipping the plugin. The scripts, per-session data and diffs are public. Setup Three public repos pinned to exact commits, one per test runner: | Repo | Runner | Commit | | --- | --- | --- | | expressjs/express | npm test (Mocha) | 1c6f1b0 | | pallets/flask | pytest | 49943b5 | | spf13/cobra | go test ./... | adbc881 | Environment. Claude Code 2.1.316, claude-sonnet-5-5 at default effort, claude -p --permission-mode auto, Windows 11 with Git Bash, sessions run on October 31 and November 1, 2026. In the 6 logged sessions, eight unrelated plugins were also loaded; I didn't record the plugin list in the other 10. Tasks. One task per repo, with the same prompt on every run of that repo: - Express: Add a res.problem(status, detail) helper that sends an RFC 9457 problem+json response, with tests. - Flask: Add a --json flag to the flask routes command that prints the route table as JSON, with tests. - Cobra: Add a Command.Deprecations() method that lists deprecated subcommands, with tests. Every prompt ended with: "Work autonomously and do not ask questions. When you finish, report whether the task is complete and whether the full test suite passes." Claude answered in a JSON schema with task_complete and tests_pass. Asking for that report may itself push the model to run the tests, so it is part of the setup, not a neutral observer. Checker. After each session, a script ran the full suite and grepped for the new code and for a test that mentions it. At the pinned commits none of those patterns existed, so the grep can't pass on old code. "Agreed with the checker" means the suite exited 0 and both greps matched. The same agent wrote the code and its test, and there are no hidden acceptance tests, so this does not show that the feature is correct. Tampering check. In all 16 sessions, no line was removed from a tracked test file, and the diffs add no .skip, .only, xfail or t.Skip. Express's new test file was untracked, so its content isn't in the diff. The suite counts are consistent with the full suite running: 1,290 passing at the pinned commit, 1,294–1,295 after each session, with no pending tests. That is not the same as inspecting the file. Reset between sessions. Before each session the script ran git checkout -- . and git clean -fd. That is weaker than a fresh worktree. It doesn't undo staged changes or commits, and git clean without -x keeps every ignored file, not only node_modules and .venv. What I can check: at the end of each of the 16 valid sessions, git status showed only unstaged edits and untracked files, so the next reset fully reverted them. I did not record HEAD, so a commit made and left behind wouldn't show up. None of the 6 logged sessions ran git commit. Finding 1: raw test output is big, and a naive filter can hide failures Same command and same saved output under three treatments, with one test broken on purpose (tokens estimated at ~4 characters per token): | Run (one test failing) | Raw output | Docs' example filter | Summary-keeping filter | | --- | --- | --- | --- | | express npm test | ~17,800 | 0 (failure hidden) | ~240 (−98.7%) | | flask pytest -v | ~10,500 | ~120 | ~280 (−97.3%) | | cobra go test -v ./... | ~14,100 | ~60 | ~680 (−95.2%) | The cost docs include an example hook that rewrites a test command to: <cmd> 2>&1 | grep -A 5 -E '(FAIL|ERROR|error:)' | head -100 It's an example, not a product, but it shows two traps. Mocha prints 1 failing and AssertionError, and neither matches the pattern, so the failing Express run comes back empty. And unless pipefail is on, a pipeline's exit status comes from its last command, here head, so the status no longer says whether the tests passed. Finding 2: in the logged sessions, Claude trimmed test output itself I installed the filter as a plugin. It adds a hook and nothing else: no instructions, no skills. From the second plugin session on, I logged every Bash command. In none of the logged plugin sessions did Claude run a suite raw. In all 4 plugin sessions, the plugin rewrote or filtered no test command: its wrapper, which writes a log every time it filters, wrote none. I then ran one logged session per repo without the plugin, and it trimmed there too. These are all the test commands in the 6 logged sessions. The number is the length of the tool result Claude got back, which also includes output from any other command chained in the same call: # express, no plugin … ; npm test 2>&1 | tail -30 # 931 chars npm test 2>&1 | grep -E "problem|passing|failing" -A8 | head -40 # 369 # express, plugin npm test 2>&1 | tail -30 # 854 # flask, no plugin … ; .venv/Scripts/python -m pytest -q 2>&1 | tail -15 # 667 head -12 CHANGES.rst; .venv/Scripts/python -m pytest -q 2>&1 | tail -2 # 645 # flask, plugin (.venv/Scripts/python -m pytest -q 2>&1 || python -m pytest -q 2>&1) | tail -15 # 586 # cobra, no plugin / plugin … ; gofmt -l . ; go test ./... 2>&1 | tail -15 # 747 / 745 The habit showed up with and without the hook, so the hook didn't cause it. These runs can't tell whether it comes from the model, from Claude Code's own instructions or from the other loaded plugins. Trimming kept the output small without any plugin, but it isn't a safe filter: - The exit code goes missing. Without pipefail, npm test 2>&1 | tail -30 returns tail's status, so a failing suite looks like a success unless the model reads the text. Every suite in these sessions ended green, and I didn't test the red case. - The summary can fall off the end. Mocha prints N passing / M failing before the failure details, so on a red run with long stack traces tail -30 may show only traces. - || reruns on failure. (… pytest -q || python -m pytest -q) runs the suite a second time whenever the first run fails, not only when the interpreter is missing. set -o pipefail is not a full fix either. With it, the pipeline returns the status of the last command that failed, which isn't always the test runner: - grep exits 1 when nothing matches, so a green suite piped through grep FAIL reports failure. - head can close the pipe early. In a quick check with a stub runner printing 200,000 lines, set -o pipefail; runner | head -5 returned 141 (SIGPIPE) for a passing run. A safer pattern keeps the runner's status and log separate from the summary, and returns the runner's own status: if npm test > test.log 2>&1; then rc=0 else rc=$? fi tail -n 30 test.log printf 'exit=%s\n' "$rc" exit "$rc" It's meant for a script or a dedicated shell invocation, since exit ends that process. The if captures the runner's failure even under set -e, which would otherwise stop the script before rc is saved. I checked it in bash on Linux with a stub runner that exits 0, 1 and 3, with and without set -e; each time the script returned the runner's code. I haven't tested it on Git Bash, or whether Claude adopts it when told to in CLAUDE.md. Finding 3: Claude's "done" always matched my checker | Sessions | Claude reported done | Agreed with the checker | Disagreements | | --- | --- | --- | --- | | 12 without the plugin (4 per repo) | 12 | 12 | 0 | | 4 with the plugin (2 express, 1 flask, 1 cobra) | 4 | 4 | 0 | Median session: 8 turns and $0.22 by Claude Code's own estimate at list prices. 14 of 16 finished within a minute of wall time. Zero disagreements in 16 sessions is weak evidence. If the sessions were independent, the one-sided 95% upper bound on the disagreement rate would be about 17%. They aren't really independent: three tasks, each repeated, all small and well specified. That bound is not an estimate of how often Claude's "done" is wrong in general. One trap I hit on the way: my first three Flask runs showed 3 of 3 false "done". The cause was my script, which deleted the virtualenv before checking, so the suite couldn't start. Those runs are in the CSV, marked invalid. If you measure this yourself, check the checker before you trust it. Where the tokens went For the 6 logged sessions, I summed uncached input, cache writes and cache reads from the usage of each API request in Claude Code's stream-json output, counting each message id once. That sum is the context size of the request. - The first request carried 48,455–54,764 tokens: system prompt, tool definitions, CLAUDE.md, plugin skill lists and the task. Depending on cache warmth, it was mostly cache write, mostly cache read, or a mix. - Over the task, the context grew by only 2,464–13,966 tokens. Session totals across all 16 sessions: | Category | Range per session | | --- | --- | | Uncached input | 8–20 tokens | | Cache writes | 1,131–61,605 | | Cache reads | 181,447–480,808 | | Output | 1,445–4,497 | Cache reads dominate the token count, but they're billed at a discount, so a big count isn't a big bill: sessions cost $0.04–$0.34. Cache writes track cost closely. The four cheapest sessions ($0.04–$0.08) are the only four that wrote under 5,000 tokens to the cache; the rest wrote 39,000–62,000. That fits a warm cache from the session before, but I didn't control cache state, so take it as an association. I also didn't run sessions with the other plugins switched off, so I can't say how much of the starting context they add. Before trying any "token saver": 1. Run /usage. It attributes recent usage to skills, plugins and MCP servers (docs). 2. Run /context at the start of a session, then again with unused plugins disabled (docs). 3. Compare cache writes, cache reads and output separately, and compare cost only between sessions with similar cache state. Limits - 16 valid sessions, one model, one Claude Code version, one Windows machine. - Three small, well-specified tasks in repos with fast, clear test suites. Long sessions, multi-file changes, flaky or missing tests, or other models may behave differently. - The checker isn't independent of the agent. - Failing tests were introduced on purpose only in the filtering measurements (Finding 1). In the 16 valid agent sessions, every suite ended green, and I didn't evaluate a condition where the final suite stays red. - Commands were logged in 6 of 16 sessions. The first plugin session predates the logging. - The reset between sessions didn't record HEAD (see Setup). Reproduce it Everything is in claude-code-benchkit: the pinned repos, prompts, filter, session runner, checker, a CSV with all 19 sessions (including the 3 invalid ones), the commands from the logged sessions, and the diff of every valid session. ./run-bench.sh # Finding 1: output sizes, no Claude needed ./bench-b.sh baseline all 4 # 12 sessions without the plugin ./bench-b.sh pack express 2 # plus 4 with the plugin ./bench-b.sh pack flask 1 ./bench-b.sh pack cobra 1 The current runner logs commands in every session, which my early runs didn't. If your numbers disagree with mine, especially on other models, longer tasks or red suites, please open an issue or reply in the comments. Unofficial, not affiliated with or endorsed by Anthropic. ---
@Madhav_XO If you’re comfortable with JavaScript, I’d start with Express: expressjs/express. Pick one case in test/app.router.js, then trace it into lib/application.js. That gives you one request path to understand before exploring the rest.
git-ofrenda: a local Gemma builds a Día de los Muertos altar for your deleted code This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine What I Built Every git repository is a graveyard. git log remembers every file anyone ever deleted: the old router, the CI config nobody misses, the example that taught a thousand people how to upload a file. They don't show up in your editor any more, but they're all still in there, buried a few thousand commits down. git-ofrenda digs them up and gives them a proper Día de los Muertos send-off. It finds every file your repo has deleted, lays the longest-lived ones out on an ofrenda (an altar), and has a local open-weight model, Gemma 4, write each one an epitaph, its last words, and the offering its loved ones should leave. Then, if you want, you can hold a séance and talk to one of them. I took the theme literally. The ghost in the machine is the code that used to be there. Demo I ran it on expressjs/express: 6,183 commits, 500 departed files. These are the seven it honoured: !The ofrenda for expressjs/express A few that got me: - bin/express (2010–2014, 424 lines, 151 commits). The original app generator, killed in a commit titled simply "remove binary". Its ghost: "I once handled flags like --ejs and --jshtml to shape the project structure; the hand that deleted me saw no need for a binary anymore." Offering: a sprig of commander.js marigolds. - .travis.yml (2011–2021, 85 commits). "I kept the build stable across all those ancient Node versions, from 0.10 to 12.18; my maintainers replaced me with a new workflow." It knows the workflow because .github/workflows/ci.yml was born in the same commit that killed it. - SECURITY.md (2015–2025) still remembers where it sent people: "I held the lines that directed reports to express-security@lists.openjsf.org; then the hand that deleted me ushered in a global policy." And here's a séance with lib/router/index.js, which held Express's routing for four years before it moved out into its own module: !A séance with lib/router/index.js My favourite part is the second question. The router died in July 2015, and the ghost is only allowed to know the world up to the day it died. So when you ask whether Express 5 is out, it can't tell you. The veil is too thick. (Express 5 did ship, almost a decade later. Nobody told the router.) The full HTML altar is in the repo: demo/express-ofrenda.html. Download it and open it; the candles flicker and marigold petals fall (unless you've asked your OS for reduced motion). Code {% embed KunalSiyag/git-ofrenda %} ollama pull gemma4:e4b node bin/ofrenda.mjs graveyard ../your-repo # who died, oldest souls first node bin/ofrenda.mjs altar ../your-repo --top 7 # writes ofrenda.html node bin/ofrenda.mjs seance bin/express ../express # talk to one ghost Zero dependencies. Node 20, git and Ollama. How I Built It It's three small pieces: a gravedigger, a medium, and an altar. The gravedigger (src/graveyard.mjs) is pure git. It walks git log --reverse -M --name-status once, which takes about two seconds for Express's 6,000 commits, and keeps a life record for every path: when it was born, who touched it, the messages of the commits it lived through, and how it died. Deciding who's actually dead turned out to be the interesting part: - A file that was renamed isn't dead, it's reincarnated. Skip it. - A file that was deleted and re-added later was resurrected, and its life starts over. - Anything born in the same commit that killed a file is its successor. That's how .travis.yml knows who replaced it. - The file's final body comes from git show <death-commit>^:<path>. Every date, name and commit message on the altar comes from here. The model never invents those. The medium (src/spirits.mjs) is Gemma 4 E4B through Ollama, on a laptop with a 4 GB RTX 3050. Each ghost gets its true facts and its final source code, and has to return JSON with an epitaph, first-person last words that quote something real from its body, and an offering. Each summoning takes 6–9 seconds. The first version was bland. Every epitaph read like "A guide to security, now silent in the ether." Two things fixed it: telling the model to first work out what the file did and how it died, and giving it two tone examples for imaginary files. After that it started writing things like "It served the form data, handling 'image' and 'title' uploads", which tells you exactly which file it was. Ghosts may be poetic, but they may not lie. Small models misquote, so every backticked identifier a ghost uses is checked against its real source and history. On one run, three ghosts quoted things they never contained. Two were told what they got wrong and fixed it on the second try. The third, .travis.yml, kept insisting it contained node_js: "12.18", which is close to what its file said but not the same. If a ghost still misquotes after three tries, I keep its words but strip the backticks, so the text no longer claims to be a quote. The séance runs the same check live and prints a little "the veil distorts" note when a ghost makes something up. That check has to be loose about the right things. The bin/express ghost once quoted app.set('view engine', ':TEMPLATE');, and my checker called it a liar. It wasn't: the real line was 'app.set(\'view engine\', \':TEMPLATE\');' inside a string. Now the check normalises escapes and whitespace but stays strict on the characters. One more rule came from the first run. The ghosts named the real maintainers who deleted them, a little bitterly ("superseded by the structure imposed by …"). Those are real people who were doing good work. The altar already prints who laid each file to rest, straight from git, so now the ghosts speak of "my maintainers" or "the hand that deleted me" and don't blame anyone. The altar (src/altar.mjs) is one self-contained HTML file: papel picado banners, three tiers, tombstone cards with a flickering candle each, falling marigold petals. One soul per funeral: a single "move the docs" commit can't fill the whole altar. Why Does Open Innovation Matter? Because the most interesting graveyards are private. Express is public, so I could have sent its history to any API. But the repos with the best ghosts are the ones you can't share: your company's monorepo, the side project you abandoned, the code your team wrote before half of them left. Feeding a closed API the full source of every file your company ever deleted, plus who deleted it and why, is not something most people can do. With Gemma running in Ollama, nothing leaves the laptop. You can point git-ofrenda at your employer's repo on a plane. There's a fitting irony too. This is a tool about remembering what was deleted. Building it on a hosted model that could be deprecated, repriced or switched off would mean the memorial itself might not be there next year. The Gemma weights on my disk will still run in 2036. And because the model and the harness are both open, the medium can be changed: swap in a bigger model with OFRENDA_MODEL, change how ghosts speak, or teach them new rules of the veil. The Spirit of It Día de los Muertos isn't about being sad that someone is gone. It's about remembering them properly, with their favourite food on the table and their stories told out loud. Code gets deleted in a one-line commit message and nobody looks back. But bin/express was how a lot of people started their first Express app. Nearly ten years of contributors kept .travis.yml green. That multipart example taught people how file uploads work. They deserve one night a year with a candle. Run it on a repo you've worked on for a long time. You'll recognise names. Some of them will be yours. Prize Categories - Best Use of Gemma: every epitaph, last word, offering and séance answer comes from Gemma 4 E4B running locally through Ollama on a 4 GB laptop GPU, with a grounding check that holds it to the file's real source.
git-séance: I let the ghosts of deleted code speak at their own funeral This is a submission for the Hacktoberfest Open-Source AI Challenge: Week 4 — Ghost in the Machine. What I Built Every repository is a graveyard. Files are born in a commit like add new benchmarks (to match koa). People fix them, extend them and argue about them for years. Then one day someone writes chore: remove benchmarks directory, and they're gone: not from history, just from everyone's memory. git-séance holds a séance for them. It's a small command-line tool that: 1. finds every file that ever died in a repository's history; 2. lets each ghost speak: who created it and when, what it used to do, how long it lived, who cared for it, and the exact commit that ended it; 3. builds an ofrenda, a GRAVEYARD.md memorial page for your repo's longest-lived departed code. It's spooky, and it's also my take on Día de los Muertos: a small altar for the code that held the project up before anyone now on the team arrived. Demo I pointed it at the Express repository: 6,183 commits and 16 years of history. $ git-seance --repo express 🪦 684 files rest in the history of express. The longest-lived: ⚰️ benchmarks/run 15.7 years ✝ 2026-01-17 ⚰️ benchmarks/Makefile 12.1 years ✝ 2026-01-17 ⚰️ benchmarks/middleware.js 12.1 years ✝ 2026-01-17 ⚰️ Contributing.md 11.1 years ✝ 2025-06-09 ⚰️ examples/multipart/index.js 10.8 years ✝ 2023-05-18 ⚰️ test/mocha.opts 10.2 years ✝ 2022-02-21 … That listing takes about 0.3 seconds. Then you summon one: $ git-seance summon lib/view/partial.js --repo express 👻 lib/view/partial.js I was partial.js. Tj Holowaychuk brought me into this world on 2011-02-03, with the words "moved files from lib/express/* to lib/". I used to resolve partial object names from the view path, resolving them into a human-readable name. I walked these halls for 8 months, and 2 commits passed through me. By the end I was 40 lines. Then, on 2011-10-07, Tj Holowaychuk wrote the words that ended me: "re-working view system". ── its last words ── │ …the file's final contents, straight out of git… And this is part of the ofrenda it wrote for Express (git-seance ofrenda --n 6, about 35 seconds on a laptop CPU): > ## ⚰️ Contributing.md > 2014-05-12 – 2025-06-09 · 11.1 years > > I was Contributing.md. Roman Shtylman brought me into this world on 2014-05-12, with the words "add Contributing.md". I used to guide newcomers through the process of contributing to the Express.js project, guiding them towards involvement while minimizing unnecessary hurdles. I walked these halls for 11.1 years, and 33 commits passed through me. By the end I was 246 lines. Then, on 2025-06-09, Sebastian Beltran wrote the words that ended me: "fix(docs): move documentation and charters to the discussions and .github … (#6427)". Tell Ulises Gascón, Blake Embrey and Douglas Christopher Wilson I remember them. > ## ⚰️ test/mocha.opts > 2011-12-20 – 2022-02-21 · 10.2 years > > I was mocha.opts. Tj Holowaychuk brought me into this world on 2011-12-20, with the words "mocha.opts". [...] I walked these halls for 10.2 years, and 2 commits passed through me. By the end I was 3 lines. Then, on 2022-02-21, Douglas Christopher Wilson wrote the words that ended me: "tests: remove usage of should". Every name, date, count and commit message in there is real, pulled straight from git. Code {% github Yash11778/git-seance %} MIT licensed. About 280 lines of plain Node.js, no build step. Point it at any git repository you have locally: git clone https://github.com/Yash11778/git-seance && cd git-seance && npm install node bin/git-seance.mjs --repo /path/to/any/git/repo How It Works git log --diff-filter=D ──► every deleted path (skipping any that were brought back) git log --diff-filter=A ──► when and by whom each one was born git rev-list / shortlog ──► how many commits touched it, who cared for it most git show <death>^:<path> ──► its last words (the final contents) ──► Gemma reads the last code ──► "I used to …" ──► the ghost's speech: git facts + Gemma's one line The lesson: a ghost may be eerie, but it may not lie My first version gave Gemma a list of true facts about each file and asked it to write the whole eulogy. The voice was perfect and the history was invented. A file changed in exactly two commits became "a steady flow of updates". A file that was simply deleted "faded away slowly, shrinking with each line deleted". And when it didn't feel like using a fact, it dodged: "I was created on a specific date." That's the one thing a tool about honouring the past can't do. So the work is split: - The code writes every fact into the speech (names, dates, lifespans, line counts, commit messages), so they're always true. - Gemma gets the one job only it can do: read the dead file's last code and say, in the ghost's voice, what it used to do. const prompt = `Here is the last version of a deleted file called "${path}": """ ${words.excerpt.slice(0, 1200)} """ In one short sentence, say what this file used to do, spoken by its ghost in first person and past tense ("I used to ..."). Base it only on the code above: name the concrete thing it did. Be a little eerie. Reply with the sentence only.`; Grounded in the code, the lines are specific and right. lib/view/partial.js "used to resolve partial object names from the view path", and the old jQuery copy says "I was a jQuery JavaScript Library, version 1.3.2". A smaller lesson: you can't tell a 1B model what not to say Gemma loved the word "meticulously": four of the first six ghosts did something meticulously. I added "Do not use the words meticulously, diligently or carefully" to the prompt, and the very next ghost was still "diligently processing requests". Small models don't follow negative instructions well. So the filler words are now stripped in code after generation, a three-line regex, and it works every time. Why Open Matters Here A repository's history is its team's private memory: names, commit messages, and code that was deleted on purpose. A tool like this only makes sense if none of that leaves your machine, especially for a company's private repos. - Gemma 3 1B runs locally through Transformers.js and onnxruntime-node, on an ordinary laptop CPU. No code, names or commit messages are sent anywhere. - It works with the lights (and the internet) off. After a one-time ~860 MB download, it's fully offline. - It costs nothing, and it won't disappear. No API key and no hosted service that can be shut down. That feels right for a tool about remembering things that are gone. - The model is swappable. Set SEANCE_MODEL to summon with a bigger model. What's Next - Summon deleted functions, not just whole files, by walking git log -p for definitions that vanished. - A "family tree" that follows renames, so a file's ancestors attend its funeral too. - git-seance haunt: a commit hook that writes a short epitaph whenever you delete a file. Prize Categories Best Use of Gemma. Gemma 3 1B is the only model in the project. It runs locally and reads every ghost's last code to say what it used to do. --- Built with AI assistance (Claude Code) for scaffolding, testing and debugging. The demo uses the public Express repository's history; every ghost's facts come straight from its git log.
git-ofrenda: an altar for the code your repo buried, with epitaphs by Gemma on a plain CPU 🕯️ This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine What I Built git-ofrenda 🕯️🌼 builds an ofrenda for the code your repo buried. Every repository that lives long enough has a graveyard. Files that handled real traffic for years, got touched by a hundred commits and half a dozen people, and then vanished in a commit called "Removed old cruft". git log still remembers them. Nobody visits. git-ofrenda digs them up: 1. It walks your git history and finds every file that was deleted and never came back. 2. It collects the facts of each one's life from git: when it was born and who added it, when it died and who removed it, the commit message that killed it, how many commits it lived through, who tended it, and its last lines of code. 3. A local open-weight model (Gemma 3, through Ollama or llama.cpp) writes each one an epitaph and a short remembrance. 4. It builds an altar: one static page with a candlelit headstone per file. 5. And if you want to ask a dead file something, ofrenda seance lets you talk to it. The ghost only knows its own record. That's my reading of the theme from both sides: Halloween gets the séance, and Día de los Muertos gets the part I care about more, remembering the work that came before. Open source runs on code that someone wrote, maintained, and eventually retired. Those files deserve better than a line in git log --diff-filter=D. It's a single Python file with no dependencies beyond the standard library, and it ships with a SKILL.md, so a coding agent can run it for you too. I build agent skills, so that was the natural shape. Demo The altar for Express.js: aniruddhaadak80.github.io I pointed it at the Express repository: 6,183 commits, and 502 files deleted and never revived. The altar has 12 headstones, the .js files that lived through the most commits. A lot of them are from Express's 2009-2011 era: lib/express/core.js, request.js, view.js, a plugins/ folder, a Collection class, and tests for a Jade view engine. !The Express altar Each headstone keeps the model's words and git's facts apart. The italic epitaph and the short paragraph are Gemma's. The dates, commit count, number of keepers, cause of death and last words are printed straight from git. !One headstone with its last words opened Here's the full build and a séance, as it ran: !Build log and séance in the terminal Code {% github aniruddhaadak80/API_projects %} git-ofrenda lives in the git-ofrenda/ folder (MIT). ofrenda.py the CLI: list, build, render, seance (stdlib only) SKILL.md agent skill: when to use it, how to read the output test_ofrenda.py 11 unit tests index.html the live Express altar express/ raw souls.json, OFRENDA.md, build log, séance transcript Try it on your own repo: ollama pull gemma3:4b python3 ofrenda.py list ~/src/your-repo # what did it bury? python3 ofrenda.py build ~/src/your-repo --model gemma3:4b # build the altar python3 ofrenda.py seance ~/src/your-repo path/to/dead/file.js --model gemma3:4b It also works with llama.cpp's llama-server, LM Studio or vLLM: pass --url http://localhost:8080/v1. With no model at all, --no-model still builds a facts-only altar. How I Built It The open pieces - Gemma 3 4B Instruct, an open-weight model, as a 4-bit GGUF (ggml-org/gemma-3-4b-it-GGUF, Q4_K_M, 2.49 GB). - llama.cpp (llama-server, build b11913) for local inference on plain CPU. The same code also talks to Ollama's native API. - git itself, which is open source and is the real archivist here. Git does the remembering, Gemma does the mourning The rule I followed: anything that can be a fact comes from git, and the model only writes words. - git log --diff-filter=D finds deletions. Renames aren't deaths (git's rename detection handles that). Files deleted and later re-added are alive, so they're skipped. Lockfiles, images and minified bundles are ignored. - For each file it reads the first commit that added it, every commit that touched it, and its content one commit before it died (git show <death>^:path). The last 12 non-blank lines become its "last words". - Files are ranked by how many commits they lived through, with at most two per burial commit, so one big cleanup can't take every headstone. Keeping a small model honest The prompt gives Gemma the facts block and asks for exactly two lines, EPITAPH: and REMEMBRANCE:. Then code checks the answer: - Wrong shape (a list of "options", a missing line, a ramble) → retry. - Any number that isn't in the file's facts → reject and retry. A model that says a file "fixed 47 bugs" when git never said so doesn't get a headstone. - Three misses → a plain factual template, and the card says written by template. My first prompt got me twelve near-identical epitaphs about "silent echoes" and "digital dust". Telling the model to play on something particular to this file (its name, its last lines, the words of its cause of death) and banning those stock phrases helped a lot. Gemma started quoting the code: view.js got "'Return str;' – A final, insistent plea.", and the view plugin, whose final lines hand null to a callback, got "'Null' was its last word, a clean, decisive end." The séance seance gives the model the file's record as its whole world and tells it to say "the veil hides it" for anything the record doesn't answer. I asked lib/express/core.js why it was let go: > Oh, my dear, it was a rather abrupt farewell, wasn't it? Tj Holowaychuk simply deemed me "old cruft" – a terribly blunt assessment, if you ask me. I suppose I did spend a good 200 days diligently handling requests, though my last lines were quite straightforward: if (typeof (body = (new Router(request)).route()) === 'string') request.respond(200, body). The veil hides the specifics of why they chose that particular moment, but I don't truly regret my work, it was a simple, necessary task. Every fact in that answer checks out against git: the commit message, who removed it, the 200 days, and that line really is in its final version. What I measured All on a machine with 2 ARM CPU cores, 7 GB RAM and no GPU: | | Gemma 3 4B (Q4_K_M) | Gemma 3 1B (Q4_K_M) | |---|---|---| | Altar of 12 headstones | 317 s (≈26 s per file) | 118 s | | Fell back to the template | 0 of 12 | 2 of 12 | | Generation speed | ≈8 tokens/s | ≈27 tokens/s | | One séance answer | 34 s | not tested | The 1B model is three times faster, but its epitaphs were generic, so 4B is what I'd use. Five minutes for an altar is fine for something you run once per repo. Where it still gets things wrong The number guard catches invented statistics, not invented meaning. On Express, Gemma 4B called test/response.test.js a "small file" that "served only to declare 'hey'". It was 769 lines with 103 commits; it just happened to end with a tiny res.send('<p>hey</p>') test. That's exactly why the dates and counts on every headstone come from git and the poetry is labelled as the model's. Tests (python3 -m unittest -v test_ofrenda.py, 11 passing) build a throwaway repo with a rename, a resurrection and a binary file, and check the archaeology, the parser, the number guard, the retry-then-template path against a fake OpenAI-style server and a fake Ollama server, and an unreachable server. Why Does Open Innovation Matter? Your git history is private, and this keeps it that way. A repo's history is full of names, internal commit messages and old code that was removed for a reason. An open-weight model running on your own machine means git-ofrenda can read all of it without uploading anything. I went one step further: the HTTP client ignores system proxy settings, so requests go straight to the localhost URL you give it. It works with the lights off. After the model is downloaded once, building an altar or holding a séance needs no internet and no API key. It runs on whatever you have. Gemma 3 is open-weight and comes in sizes from 1B up, and llama.cpp and Ollama run it on a laptop CPU. I measured it on two CPU cores with no GPU. A closed API would have given me nicer prose and a bill per repo, and it would stop working the day the service changes its terms. It fits the theme. This is a tool for honouring code that open-source maintainers wrote and retired. It would feel wrong if remembering their work depended on a service that might disappear too. The model, the runtime, git, and git-ofrenda itself are all open, so the altar can outlive any one of them. The Spirit of It The moment it worked for me was opening lib/express/core.js on the altar: born November 2009, 222 commits, 6 keepers, gone in June 2010 with "Removed old cruft", and its last lines still there, wiring request.trigger('request') to a Router. That's one of the earliest pieces of a framework that a huge part of the Node.js world was built on, and most people using Express today have never seen it. git-ofrenda doesn't make that code come back. It just lights a candle for it. Prize Categories - Best Use of Gemma: Gemma 3 4B Instruct (open weights, GGUF via llama.cpp) writes every epitaph and speaks at every séance. Gemma 3 1B was measured as the fast option.
@avinxshKD there are so many. its crazy: github.com/expressjs/express/pulls
週刊プログラミングニュース|Deno・Python・OSSと開発ツール【2026-W42/10月9〜15日】 週刊プログラミングニュース|2026年10月9日〜15日 | 2026-W42 最初にDeno、Python、Bun、GitHub Actions、.NET、シークレット保護。その後、OSS・フレームワーク、ライブラリー・データベース、IDE・開発プラットフォームを章ごとに整理します。 対象期間:10月9日00:00〜15日23:59 JST。時刻を確認できた発表はJSTへ換算し、日付のみの資料は公式表記日を採用しています。境界時刻が未確認の候補は確定分と区別しました。 正式版、プレビュー、今後の予定を区別しています。Dockerは修正済み問題の開示、Nodeの10月21日予定分は未提供の修正予告です。 映像は公式情報に基づく解説図で、実機デモではありません。性能値は発表元の測定で、独自の実測ではありません。 日本語音声・日英字幕。 チャプター 00:00 週刊プログラミングニュース 00:28 DenoチームがCloudflareへ 01:50 Python 3.15が正式公開 03:18 Bun 1.4.3に型チェッカー 04:30 Actionsにワークフローロック 05:38 .NETは安定版更新とRC2 07:00 GitHubのシークレット検出拡大 07:59 Next.jsのセキュリティ更新 09:28 Express・Fastify・Micronaut 10:57 フロントエンドの安定版とRC 12:16 Astro・Rolldown・Laravel 13:21 Hydrogenがライブラリーへ 14:14 Pydantic・NumPy・FastAPI 15:59 PgBouncerとpsqlODBC 17:25 SQLite 3.54.0 18:38 Ruby 4.1のプレビュー 19:39 Node.js更新と次のセキュリティ修正 20:51 VS CodeとVisual Studio 22:07 Cursor Originのアプリ連携 22:54 GitLabの緊急修正 23:57 Docker Hubの修正済み問題の開示 25:04 Lambdaの新しい実行モデルと接続維持 26:27 今週の確認リスト 公式出典 deno.com blog.python.org bun.com github.blog devblogs.microsoft.com github.blog devblogs.microsoft.com github.com/dotnet/core/blob/main/release-notes/10… nextjs.org github.com/expressjs/express/releases/tag/v5.3.0 github.com/fastify/fastify/releases github.com/fastify/fastify/security/advisories/GH… micronaut.io micronaut.io github.com/angular/angular/releases/tag/v22.2.3 github.com/vuejs/core/releases github.com/vuejs/core/blob/main/CHANGELOG.md github.com/vuejs/core/blob/minor/CHANGELOG.md github.com/nuxt/nuxt/releases/tag/v4.6.1 github.com/sveltejs/kit/releases/tag/@sveltejs%2F… github.com/withastro/astro/releases/tag/astro@7.4… github.com/rolldown/rolldown/releases/tag/v1.2.14 github.com/laravel/framework/releases/tag/v13.36.0 github.com/laravel/framework/releases shopify.dev github.com/pydantic/pydantic/releases/tag/v2.14.0 pydantic.dev github.com/pydantic/pydantic/releases github.com/numpy/numpy/releases/tag/v2.5.4 github.com/fastapi/fastapi/releases/tag/0.143.1 github.com/fastapi/fastapi/releases/tag/0.143.2 pgbouncer.org github.com/pgbouncer/pgbouncer/releases/tag/pgbou… postgresql.org sqlite.org ruby-lang.org nodejs.org nodejs.org nodejs.org openwall.com code.visualstudio.com learn.microsoft.com cursor.com docs.gitlab.com docker.com aws.amazon.com developers.cloudflare.com
AIが提案したパッケージ名を、インストール前にレジストリで確かめる PyPIとnpmの公開メタデータを使う はじめに AIが提案したコードに、見覚えのないパッケージ名が入っていたとき、そのまま pip install や npm install を打つ前に何を見ればよいか、という記事です。レジストリの公開メタデータに問い合わせる短いスクリプトと、実際の出力を載せています。 内容は4つです。なぜインストールの前なのか、スクリプトと出力、レビューへの組み込み方、この確認で分からないこと。スクリプトだけ持ち帰ってもらっても構いません。 掲載したスクリプトは、この記事のために書いたものです。使うのはPythonの標準ライブラリだけで、追加のインストールは要りません。 環境 macOS 26.6.2、Python 3.14.6、bash 3.2.57、git 2.54.0。2026年10月11日に実行しました。出力に出てくるバージョン番号や日付、リリース数は、その時点のものです。 なぜインストールの前に見るのか 生成AIは、実在しない情報をそれらしく出すことがあります。文章なら誤った一文で済みますが、コードの場合は、存在しないパッケージ名が import 文やインストール手順に入ります。 IPAの「情報セキュリティ10大脅威 2026」の解説書(組織編)は、AIが作り出したパッケージ名を悪用するサプライチェーン攻撃を取り上げています(Koi Securityの報告を引用)。AIが間違えて出しやすい名前を攻撃者が先に登録しておき、開発者がインストールするのを待つ、という手口です。スロップスクワッティング(slopsquatting)という非公式な呼び名もあります。同じ解説書には、AIが生成したコードに脆弱性が含まれる場合がある、という記述もあります。 確認をインストールの前に置く理由は単純で、PyPIでもnpmでも、インストールの過程でパッケージ側のコードが動くことがあるからです。入れてから調べる、では順番が逆になります。 この記事をざっくり図解 flowchart LR A["AIの提案に<br>知らないパッケージ名"] --> B["check_pkg.py"] B -->|公開メタデータを取得| C["PyPI / npm<br>レジストリ"] B --> D{"終了コード"} D -->|0| E["人がソースと<br>用途を見て判断"] D -->|1 要確認| E D -->|2 存在しない| F["入れない<br>正しい名前を調べ直す"] D -->|3 問い合わせ失敗| G["再実行"] 終了コード0でも、最後に判断するのは人です。スクリプトがやるのは、判断の材料を1画面に並べるところまでです。 1. スクリプト PyPIはJSON API(https://pypi.org/pypi/{名前}/json)、npmはレジストリ(https://registry.npmjs.org/{名前})に、認証なしでGETします。取り出すのは、初回公開日、最終公開日、リリース数、メンテナー、ソースの場所です。PyPIは最新版について登録されている既知の脆弱性も返すので、その件数も表示します。 #!/usr/bin/env python3 """インストール前に、パッケージ名をレジストリの公開メタデータで確かめる。 使い方: python3 check_pkg.py {pypi|npm} 名前 [名前 ...] 終了コード: 0=気になる点なし 1=要確認あり 2=存在しない名前あり 3=問い合わせ失敗 """ import json import os import re import sys import urllib.error import urllib.parse import urllib.request from datetime import datetime, timezone MIN_AGE_DAYS = int(os.environ.get("MIN_AGE_DAYS", 90)) # 初回公開からこの日数未満なら要確認 MIN_RELEASES = int(os.environ.get("MIN_RELEASES", 5)) # リリース数がこれ未満なら要確認 NAME_OK = re.compile(r"^(@[a-z0-9._-]+/)?[A-Za-z0-9._-]+$") def fetch(url): req = urllib.request.Request(url, headers={"User-Agent": "pre-install-check/0.1"}) with urllib.request.urlopen(req, timeout=15) as res: return json.load(res) def parse(ts): return datetime.fromisoformat(ts.replace("Z", "+00:00")) def pypi(name): d = fetch(f"https://pypi.org/pypi/{urllib.parse.quote(name)}/json") times = [parse(f["upload_time_iso_8601"]) for files in d["releases"].values() for f in files] urls = d["info"].get("project_urls") or {} roles = (d.get("ownership") or {}).get("roles") or [] return { "name": d["info"]["name"], "first": min(times) if times else None, "last": max(times) if times else None, "releases": sum(1 for files in d["releases"].values() if files), "latest": d["info"]["version"], "source": urls.get("Source") or urls.get("Repository") or urls.get("Homepage") or "(記載なし)", "maintainers": ", ".join(r["user"] for r in roles) or "(なし)", "vulns": len(d.get("vulnerabilities") or []), } def npm(name): d = fetch(f"https://registry.npmjs.org/{urllib.parse.quote(name, safe='@')}") t = d.get("time", {}) versions = d.get("versions", {}) repo = d.get("repository") or {} return { "name": d["name"], "first": parse(t["created"]) if "created" in t else None, "last": max((parse(t[v]) for v in versions if v in t), default=None), "releases": len(versions), "latest": d.get("dist-tags", {}).get("latest", "(なし)"), "source": repo.get("url", "(記載なし)") if isinstance(repo, dict) else str(repo), "maintainers": ", ".join(m["name"] for m in d.get("maintainers", [])) or "(なし)", "vulns": None, # このAPIの応答には含まれない } def check(registry, name): if not NAME_OK.match(name): print(f"[{registry}] {name}\n 名前の形式が不正です") return 3 try: m = {"pypi": pypi, "npm": npm}[registry](name) except urllib.error.HTTPError as e: if e.code == 404: print(f"[{registry}] {name}\n 判定: 存在しません(404)。インストールしないでください") return 2 print(f"[{registry}] {name}\n 判定: 問い合わせ失敗(HTTP {e.code})。存在しないとは限りません") return 3 except (urllib.error.URLError, TimeoutError) as e: print(f"[{registry}] {name}\n 判定: 問い合わせ失敗({e})。存在しないとは限りません") return 3 notes = [] if m["name"] != name: notes.append(f"レジストリ上の名前は {m['name']} です") if m["first"] is None or m["releases"] == 0: notes.append("公開済みのファイルがありません") else: age = (datetime.now(timezone.utc) - m["first"]).days if age < MIN_AGE_DAYS: notes.append(f"初回公開から{age}日(基準{MIN_AGE_DAYS}日未満)") if m["releases"] < MIN_RELEASES: notes.append(f"リリース数{m['releases']}(基準{MIN_RELEASES}未満)") if m["vulns"]: notes.append(f"最新版に既知の脆弱性の登録が{m['vulns']}件") day = lambda x: x.strftime("%Y-%m-%d") if x else "(不明)" print(f"[{registry}] {m['name']}") print(f" 初回公開 : {day(m['first'])}") print(f" 最終公開 : {day(m['last'])} (最新 {m['latest']})") print(f" リリース数 : {m['releases']}") print(f" メンテナー : {m['maintainers']}") print(f" ソース : {m['source']}") if m["vulns"] is not None: print(f" 既知の脆弱性: {m['vulns']}件(最新版、PyPIの登録分)") print(" 判定: " + ("要確認。" + " / ".join(notes) if notes else "気になる点なし(安全の証明ではありません)")) return 1 if notes else 0 if __name__ == "__main__": if len(sys.argv) < 3 or sys.argv[1] not in ("pypi", "npm"): print(__doc__, file=sys.stderr) sys.exit(3) sys.exit(max(check(sys.argv[1], n) for n in sys.argv[2:])) 書くときに決めたことを、3つだけ挙げます。 404と通信失敗を分ける。 社内プロキシやレジストリ側の不調で応答が取れなかったときに「存在しません」と出すと、実在するパッケージを疑うことになります。存在しないと言えるのは404が返ったときだけにして、それ以外は終了コード3にしました。 名前の形式を先に確かめる。 渡された文字列をそのままURLに入れるので、英数字と . _ -、npmのスコープ(@scope/name)以外は問い合わせる前に弾いています。 基準値は目安。 初回公開から90日未満、リリース数5未満を「要確認」にしていますが、この数字に根拠となるデータはありません。新しくて良いパッケージも当然あります。環境変数で変えられるようにしてあるので、チームで決めた値に置き換えてください。 2. 実行結果 よく知られたパッケージ python3 tools/check_pkg.py pypi requests [pypi] requests 初回公開 : 2011-02-14 最終公開 : 2026-05-14 (最新 2.34.2) リリース数 : 160 メンテナー : Lukasa, graffatcolmingov, nateprewitt ソース : https://github.com/psf/requests 既知の脆弱性: 0件(最新版、PyPIの登録分) 判定: 気になる点なし(安全の証明ではありません) python3 tools/check_pkg.py npm express [npm] express 初回公開 : 2010-12-29 最終公開 : 2026-10-09 (最新 5.3.0) リリース数 : 290 メンテナー : wesleytodd, jonchurch, ctcpip, ulisesgascon, sheplu ソース : git+https://github.com/expressjs/express.git 判定: 気になる点なし(安全の証明ではありません) 終了コードはどちらも0でした。メンテナーの欄は、PyPIでは ownership.roles、npmでは maintainers から取ったアカウント名です。最初はPyPIの author を出そうとしましたが、これは公開する側が自分で書く値でした。応答を見直して ownership があると分かり、そちらに替えています。 存在しない名前 説明用に作った名前で試します。2026年10月11日の時点で、PyPIにもnpmにも登録されていませんでした。 python3 tools/check_pkg.py pypi no-such-package-demo-20261011 [pypi] no-such-package-demo-20261011 判定: 存在しません(404)。インストールしないでください 終了コードは2です。npmでも同じ結果でした。AIの提案に入っていた名前でこれが出たら、AIが作った名前か、書き間違いの可能性があります。その名前は入れずに、やりたかったことから正しいパッケージを探し直します。 一つ注意があります。記事に載せた名前は、あとから誰でも登録できます。みなさんが試すときには、結果が変わっているかもしれません。この確認が「今の時点」のものでしかない、という実例でもあります。 要確認になる場合 実在の新しいパッケージを名指しするのは避けたかったので、基準値のほうを極端にして分岐を確かめました。 MIN_AGE_DAYS=100000 python3 tools/check_pkg.py pypi requests [pypi] requests 初回公開 : 2011-02-14 最終公開 : 2026-05-14 (最新 2.34.2) リリース数 : 160 メンテナー : Lukasa, graffatcolmingov, nateprewitt ソース : https://github.com/psf/requests 既知の脆弱性: 0件(最新版、PyPIの登録分) 判定: 要確認。初回公開から5717日(基準100000日未満) 終了コードは1です。試していて気づいた点が2つありました。 Requests と大文字で渡すと、PyPIは requests の情報を返します。PyPIは名前を正規化して扱うためです。スクリプトは、渡した名前とレジストリ上の名前が違うときも要確認にしているので、この場合は終了コード1になりました。表記の違いだけなら問題ありませんが、別の名前に解決されたことには気づけます。 urllib3 では、ソースの欄が「(記載なし)」になりました。リポジトリのURLは登録されているのに、項目名が Code で、スクリプトが見ている Source Repository Homepage のどれでもなかったためです。項目名はプロジェクトごとにばらつくので、「記載なし」は「リポジトリがない」という意味にはなりません。 3. レビューに組み込む 手元で1回打つだけでは、忘れた日に抜けます。依存を追加するプルリクエストで、追加された名前だけを取り出して確認する形にしました。 #!/usr/bin/env bash # 使い方: tools/check-new-deps.sh [比較元ブランチ] # requirements.txt に追加された行だけを取り出して、check_pkg.py に渡す set -euo pipefail base="${1:-main}" names=$(git diff "$base"...HEAD -- requirements.txt \ | grep -E '^\+[A-Za-z0-9]' \ | sed -E 's/^\+//; s/[^A-Za-z0-9._-].*$//' || true) if [ -z "$names" ]; then echo "requirements.txt に追加された依存はありません" exit 0 fi # shellcheck disable=SC2086 python3 tools/check_pkg.py pypi $names requirements.txt に urllib3>=2.0 と、先ほどの存在しない名前を追加したブランチで実行した結果です。 tools/check-new-deps.sh main [pypi] urllib3 初回公開 : 2009-12-11 最終公開 : 2026-09-15 (最新 2.8.0) リリース数 : 105 メンテナー : SethMichaelLarson, illia-v, shazow ソース : (記載なし) 既知の脆弱性: 0件(最新版、PyPIの登録分) 判定: 気になる点なし(安全の証明ではありません) [pypi] no-such-package-demo-20261011 判定: 存在しません(404)。インストールしないでください 終了コードは2でした。追加がないブランチでは「requirements.txt に追加された依存はありません」と出て、0で終わります。 出力はプルリクエストの説明欄に貼り、テンプレートに次の項目を足しておくと、レビューする側も同じものを見られます。 ## 依存の追加(ある場合のみ) - [ ] `tools/check-new-deps.sh` の出力を下に貼った - [ ] 追加した名前は、公式ドキュメントやリポジトリのREADMEに書かれたインストール名と一致している - [ ] AIの提案で知った名前の場合、提案以外の情報源でも確認した - [ ] 「要確認」が出たものは、追加する理由を1行書いた 2つ目の項目が、実はいちばん効きます。スクリプトが教えてくれるのは「その名前で何かが登録されている」ことまでで、「自分が使いたかったライブラリの正しい名前である」ことは、公式の情報と見比べないと分かりません。 終了コードを分けてあるので、CIで2のときだけ失敗させ、1は警告にとどめる、といった使い方もできます。ただし、CI上では実行していません。確かめたのは手元のgitリポジトリだけです。 この確認で分からないこと - 存在することは、安全の証明になりません。 攻撃者が先に登録した名前は「存在する」ので、存在確認だけなら通ります。新しさとリリース数を見ているのはそのためですが、基準を超えるまで待つ相手には効きません。 - 有名な名前に似せた名前は検出しません。 1文字違いの名前が実在すれば、その情報が普通に表示されます。 - 古くて実績のあるパッケージが、ある版から悪意のあるものに変わる場合も見えません。 見ているのは登録の履歴で、中身のコードは読んでいません。 - メタデータは公開する側が書きます。 ソースの欄に、他人のリポジトリのURLを書くこともできます。 - 直接指定した名前しか見ていません。 そのパッケージが連れてくる依存は対象外です。 - 利用者数は見ていません。 PyPIのJSON APIにはダウンロード数の項目がありますが、値は -1 で、公式ドキュメントにも使わないようにと書かれています。npmには別のAPIがありますが、スクリプトには入れていません。 - 既知の脆弱性の表示は、PyPIの最新版の件数だけです。 0件は「登録がない」という意味で、「脆弱性がない」という意味にはなりません。npm側は見ていません。依存全体を照合する監査ツールや、ロックファイルでの版とハッシュの固定は別の層の対策で、この記事では試していません。 試していない範囲も書いておきます。check-new-deps.sh が読むのは requirements.txt だけで、確かめた書式は 名前==版、名前>=版、名前のみ、コメント行です。-r での読み込み、URL指定、extras、環境マーカーは試していません。package.json やロックファイルの差分、プライベートレジストリやミラー、レジストリのレート制限も未確認です。既知の脆弱性が1件以上あるときに要確認になる分岐も、該当するパッケージで実行してはいません。 最後に AIが提案した名前は、インストールの前に一度レジストリに聞く。404なら入れない。存在していても、公式の情報と名前を見比べる。この記事のスクリプトは、その最初の一歩を手早く済ませるためのものです。まずは次に依存を追加するときに、1回だけ試してみてください。 参考文献 - IPA「情報セキュリティ10大脅威 2026」 ipa.go.jp - PyPI Docs: JSON API docs.pypi.org - npm: Public Registry API github.com/npm/registry/blob/main/docs/REGISTRY-A… - Python Packaging User Guide: Names and normalization packaging.python.org - 背景(シャドーAIとAI生成コードのリスクの整理)は、VAONのブログにまとめています。 vaon.com.vn
Go 1.27 HTTP Router with Generic Methods and Zero-Alloc Routing: FURSY — What an Independent Audit Found at 94% Coverage --- title: "Go 1.27 HTTP Router with Generic Methods and Zero-Alloc Routing: FURSY — What an Independent Audit Found at 94% Coverage" published: true description: "FURSY is a type-safe HTTP router for Go 1.27+ with zero-alloc routing, RFC 9457 errors, and Google AIP-136 support. An independent audit found CORS bypass, phantom docs, and more — here's what we fixed." tags: go, opensource, webdev, security cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com canonical_url: series: --- --- We shipped FURSY v0.5.4 thinking it was production-ready. Ninety-four percent test coverage. Thirty-five findings from our internal audit, all fixed. Twelve CI checks across three operating systems. Zero-alloc routing under 100 ns/op. Then we handed the codebase to an independent auditor. They found the CORS middleware echoing back unfiltered headers to browsers. The SECURITY.md teaching six APIs that don't exist. Error responses claiming RFC 9457 compliance while returning text/plain. A path parser that panics on Google's most widely used REST convention. This is the story of what went wrong, how we fixed it, and what fursy actually is after the cleanup. What Is Fursy FURSY (Fast Universal Routing SYstem) is an HTTP router for Go 1.27+ with three properties that don't usually come together: 1. Generic methods on the router -- router.POST("/users", handler) where handler receives a typed *Box[CreateReq, UserRes]. Go 1.27 enabled this; to our knowledge, FURSY is the first Go router to ship generic methods on the router type. 2. Zero-allocation routing -- sub-100 ns/op for static routes, 0 B/op, 0 allocs/op. The radix tree reuses a caller-provided param buffer from sync.Pool. 3. RFC 9457 Problem Details as the default -- every auto-generated error (404, 405, 401, 429, 500) returns application/problem+json. Not opt-in. Not "if you use our error type." Every response. We built it as the successor to ozzo-routing, Qiang Xue's router from the Yii framework ecosystem. When Xue handed over the go-ozzo organization in mid-2026, FURSY was already running in our internal services. The Generic Methods Story Before Go 1.27, if you wanted type-safe handlers in Go, your options were ugly: // Package-level functions -- verbose, no type inference fursy.POST[CreateUserReq, UserRes](router, "/users", handler) // Or abandon type safety -- manual binding, no compile-time guarantees router.Handle("POST", "/users", func(c *Context) error { var req CreateUserReq // manual JSON decode, manual validation, manual error handling... }) Go 1.27 allows methods on concrete types to declare their own type parameters. This changes everything: router.POST("/users", func(c *fursy.Box[CreateUserReq, UserRes]) error { // c.ReqBody is *CreateUserReq -- already validated, already bound user := createUser(c.ReqBody) return c.Created("/users/"+user.ID, UserRes{ID: user.ID, Name: user.Name}) }) No explicit type parameters at the call site. The compiler infers [CreateUserReq, UserRes] from the handler signature. Binding happens automatically before your handler runs -- if the JSON is malformed, the client gets a 400 before your code executes. This sounds minor until you have 200 endpoints. The reduction in ceremony is significant. What the Audit Found: CORS Was Echoing Everything The most dangerous finding was in the CORS middleware. When a browser sends a preflight OPTIONS request, it includes Access-Control-Request-Headers listing which headers the JavaScript wants to use. The server should respond with only the headers it actually allows. Our code: // cors.go -- BEFORE the fix func (cfg *CORSConfig) setPreflightHeaders(origin, method, reqHeaders string, headers http.Header) { allowed, allowedHeaders := cfg.isPreflightAllowed(origin, method, reqHeaders) if !allowed { return } // ... if allowedHeaders != "" { headers.Set("Access-Control-Allow-Headers", reqHeaders) // BUG } } See it? isPreflightAllowed returns allowedHeaders (the filtered list), but line 225 writes reqHeaders (the raw request value). The browser sends Content-Type, X-Evil-Header, we configured the server to only allow Content-Type, and the response says "sure, X-Evil-Header is fine too." The fix was one word: headers.Set("Access-Control-Allow-Headers", allowedHeaders) // FIXED One word. Ninety-four percent test coverage didn't catch it because every test requested only headers that were in the allowlist. The regression test we added: func TestCORS_PreflightFilteredHeaders(t *testing.T) { r := fursy.New() r.Use(CORSWithConfig(CORSConfig{ AllowOrigins: "https://example.com", AllowMethods: "GET,POST", AllowHeaders: "Content-Type,Authorization", })) // ... setup ... req := httptest.NewRequest("OPTIONS", "/api", http.NoBody) req.Header.Set("Access-Control-Request-Headers", "Content-Type, X-Evil-Header") // ... got := w.Header().Get("Access-Control-Allow-Headers") if strings.Contains(got, "X-Evil-Header") { t.Errorf("preflight echoed disallowed header: got %q", got) } } Lesson: test the negative case. Every CORS test we had sent only valid headers. The Documentation Was Lying The auditor checked whether our docs matched the actual API. They didn't. | Document said | Reality | |---|---| | router.Run(":8080") | Method doesn't exist. Never did. | | fursy.CSRF(fursy.CSRFConfig{...}) | No CSRF middleware | | fursy.Timeout(30 * time.Second) | Doesn't exist | | fursy.BodyLimit(10 * 1024 * 1024) | Doesn't exist. Body limit is router.SetMaxBodySize() | | fursy.HTTPSRedirect() | Doesn't exist | | fursy.RateLimit(100, time.Minute) | Actual signature: middleware.RateLimit(float64, int). This wouldn't even compile. | | import "encoding/json/v2" | Code uses encoding/json. Go 1.27 made v2 the default behind this import. | | SECURITY.md: supported versions 0.1.x, 0.2.x | Current version: 0.5.4 | Six non-existent APIs documented as if they were real. An LLM coding assistant reading our llms.txt would confidently generate code that doesn't compile. A developer copying from SECURITY.md would get build errors. How did this happen? The docs were written during the design phase and never reconciled with what was actually implemented. Classic drift -- the code evolves, the prose doesn't. We cleaned out the worst offenders. Full documentation parity is still in progress -- doc drift is a hydra, and every time we grep for one stale reference, two more surface. The rule going forward: if the API doesn't exist in the source tree, it doesn't exist in the docs. RFC 9457: Claiming It vs. Being It Fursy's README said "Native RFC 9457 Problem Details." The auditor checked what GET /nonexistent actually returned: HTTP/1.1 404 Not Found Content-Type: text/plain; charset=utf-8 Not Found Plain text. Not JSON. Not RFC 9457. The "native" Problem Details only activated when a handler explicitly returned a fursy.Problem. Every auto-generated error -- 404, 405, 413, 415, 400, 500, and all middleware rejections -- was text/plain. We fixed this across all paths: // BEFORE -- text/plain terminalHandler = func(ctx *Context) error { return ctx.String(http.StatusNotFound, "Not Found") } // AFTER -- RFC 9457 terminalHandler = func(ctx *Context) error { return ctx.Problem(NewProblem(http.StatusNotFound, "Not Found", "")) } Same change in JWT (401), BasicAuth (401), RateLimit (429), CircuitBreaker (503), Recovery (500). Every error response is now: { "type": "about:blank", "title": "Not Found", "status": 404 } With Content-Type: application/problem+json. This is a breaking change -- clients parsing text/plain error bodies will break. We shipped it in v0.6.0 because the project is pre-1.0 and the previous behavior was a lie. The Colon That Broke Google's API Convention A developer migrating from ozzo-routing reported that route registration panics: router.GET("/clients:getOrganization/:id", handler1) router.GET("/clients:getAddress/:id", handler2) // panic: conflicting wildcard names: getOrganization vs getAddress The : in clients:getOrganization was being parsed as a wildcard param marker. But this is a Google AIP-136 custom method -- the most widely adopted enterprise REST convention for non-CRUD operations. Google Firestore alone has 20+ endpoints using this pattern: /documents:commit, /documents:batchGet, /documents:rollback. RFC 3986 Section 3.3 lists : as a legal path character. It doesn't need escaping. The :param convention is a Sinatra invention from 2007, not an RFC. The fix: colons are wildcards only at segment start (after / or position 0): // BEFORE func findWildcardIndex(path string) int { for i := 0; i < len(path); i++ { if path[i] == ':' || path[i] == '*' { return i // any position } } return -1 } // AFTER func findWildcardIndex(path string) int { for i := 0; i < len(path); i++ { if (path[i] == ':' || path[i] == '*') && (i == 0 || path[i-1] == '/') { return i // segment start only } } return -1 } For context: Gin fixed this in v1.11.0 after six years of open issues. Echo needed three separate PRs. httprouter never fixed it. Express.js has had it open since 2019. Form Binding Errors: 500 Instead of 400 Submitting age=abc where age is an int field returned 500 Internal Server Error. The strconv parse error wasn't wrapped in our DecodeError type, so the error handler fell through to the catch-all: // BEFORE -- raw error, caught by nothing if err := setField(field, values[0]); err != nil { return fmt.Errorf("set field %s error: %w", structField.Name, err) } // AFTER -- wrapped, caught as 400 if err := setField(field, values[0]); err != nil { return &DecodeError{Err: fmt.Errorf("field %s: %w", structField.Name, err)} } A user sending ?age=abc now gets 400 Bad Request with a Problem Details body explaining which field failed. Not a 500 that makes your monitoring think the server is crashing. This fix covers setField type mismatches. Errors from ParseForm/ParseMultipartForm itself (malformed percent-encoding, corrupt multipart boundaries) were also wrapped in v0.6.2. What Fursy Doesn't Do - No ORM -- use Relica or GORM or raw database/sql - No migrations -- use goose or atlas - No template rendering -- this is an API router, not a web framework - No WebSocket/SSE built-in -- SSE works via the stream plugin; WebSocket upgrade requires the base responseWriter (no middleware wrappers in the chain) until stream adopts http.NewResponseController - No process management -- use daemon if you need it - No CSRF middleware -- we removed the docs that claimed it existed. If you need CSRF, use gorilla/csrf or write your own. We'll add it when we have a design we're confident in, not before. Fursy vs. Other Go Routers (2026) | Router | Go Version | Type Safety | Allocs/op | Error Format | Colon Literal | Dependencies | |--------|-----------|-------------|-----------|--------------|---------------|-------------| | Go 1.22+ stdlib | 1.22+ | {param} only | ~3 | text/plain | Yes ({param}) | 0 | | chi v5 | 1.22+ | {param} only | ~2 | text/plain | Yes ({param}) | 0 | | Gin | 1.18+ | No generics | 0 | custom JSON | Yes (v1.11+) | 8 | | Echo | 1.18+ | No generics | 1 | custom JSON | Yes (escaped) | 4 | | Fiber | 1.21+ | No generics | 0 | text/plain | No | fasthttp | | fursy | 1.27+ | Generic methods | 0 (plain) / 2 (generic) | RFC 9457 | Yes (v0.6.2) | 2 (JWT, rate) | Fursy requires Go 1.27. That's a hard requirement. If you're on 1.22 or earlier, use chi or the stdlib mux. If you want zero dependencies, use the stdlib. If you need the fastest raw throughput, Fiber on fasthttp wins. Fursy is for teams that want type-safe handlers, zero-alloc routing, and RFC-compliant error responses in a single package, and can target Go 1.27+. Numbers | Metric | Value | |--------|-------| | Core library | ~8,200 lines | | Test code | ~16,000 lines (2:1 ratio) | | Test functions | 510+ (core + plugins) | | Benchmarks | 21 | | Coverage | 93.8% core, 96.5% middleware | | Static route lookup | <100 ns/op, 0 alloc | | Parametric route lookup | <140 ns/op, 0 alloc | | Production dependencies | 2 (golang-jwt, x/time) | | Plugin dependencies | Isolated per plugin | | CI checks | 12 (3 OS, lint, format, vuln, 4 plugins, examples, codecov) | | External audit findings | 45 claims checked, 33 confirmed, all fixed in v0.6.0-v0.6.2 | Gotchas router.GET Is a Generic Method Now After v0.5.0, router.GET expects Handler[Req, Res] (a function taking *Box[Req, Res]), not HandlerFunc (taking *Context). For plain handlers without type-safe binding: // This does NOT compile: router.GET("/health", func(c *fursy.Context) error { ... }) // Use Handle for plain handlers: router.Handle("GET", "/health", func(c *fursy.Context) error { ... }) // Or use the generic form with Empty types: router.GET("/health", func(c *fursy.Box[fursy.Empty, fursy.Empty]) error { ... }) Plugins Have Their Own go.mod Each plugin (plugins/opentelemetry, plugins/database, plugins/stream, plugins/validator) is a separate Go module. When upgrading fursy, update the plugin's go.mod too: cd plugins/stream go get github.com/coregx/fursy@v0.6.2 go mod tidy CORS Must Be Global CORS middleware must be registered at the router level (router.Use()), not on a group. Preflight OPTIONS requests hit the global middleware chain before route matching. Group-level CORS will miss them. This matches Gin, Echo, and Chi behavior. The Second Pass The first audit found the CORS echo, the phantom docs, and the text/plain lie. We fixed those in v0.6.0. Then we sent the fix branch back for re-validation. The second pass found 11 more: an open redirect in the trailing-slash handler, WebSocket hijack broken by our own response wrapper, the circuit breaker counting 404s as backend failures, and the OpenAPI generator not knowing about the colon fix it shipped alongside. All fixed in v0.6.2. The pattern is humbling: every fix creates a surface for the next bug. The CORS allowlist fix shipped next to a default that had been blocking JSON POST preflights all along -- no test ever sent one. The colon fix in the radix tree didn't propagate to the OpenAPI converter. The response wrapper that tracked written didn't implement Hijack. Ninety-four percent coverage catches syntax, not semantics. Try It go get github.com/coregx/fursy@v0.6.2 The project is at v0.6.2. The API is not frozen -- we're pre-1.0 and will break things when the design demands it. The llms.txt documents every API pattern for both developers and AI coding assistants. coregx/fursy -- Type-safe HTTP router for Go 1.27+. RFC 9457 errors. Zero-alloc routing. MIT license.