DEV Community

Cover image for No Code Calls Nineteen of My Scripts, Nobody Calls Nine
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

No Code Calls Nineteen of My Scripts, Nobody Calls Nine

The scripts/ directory of this blog holds 53 scripts at its top level (.ts, .mjs, .sh), 16,576 lines in total. Eight of them can be called by name from package.json. So someone new to the repository can see roughly one seventh of the toolbox by looking at the lid.

Today I sat down to count the rest. My intent was the classic dead-code cleanup: find the scripts nobody calls, delete them, enjoy a tidier directory. But the first number I produced was wrong, the second was incomplete, and the ways they were wrong taught me more than the cleanup would have. The question was never "is there dead code here". The question was: who runs these 53 tools, and when the thing that runs them isn't code, where is it written down?

First count: counting references

My first method was the easy one. I took each script's name and searched the repository for it — in the workflows, in package.json, in other scripts, under src/, inside deploy/, and also in my agent recipes that live outside the repository. Anything with no mention would be counted dead.

Eleven scripts came back with zero references. Looking at the list, one of them caught my eye: share-to-linkedin.ts was among the dead, but its sibling share-to-bluesky.ts was not on the list. So something referenced the Bluesky script. I went looking for what:

$ grep -rl 'share-to-bluesky' . | grep -v '^./src/content/'
./scripts/share-to-bluesky.ts
./scripts/share-to-linkedin.ts
Enter fullscreen mode Exit fullscreen mode

Its only citers were itself and the LinkedIn script I had just declared dead. Two corpses pointing at each other, and that was enough to make one of them look alive.

The measurement itself was broken here. I was counting incoming references; but a tool is alive not because something points at it, but because it is reachable from a root that runs. In a graph, edges from dead to dead contaminate any measure of in-degree. Dead scripts huddle together, and the measurement takes them for living ones.

So I changed the method. I defined the roots — everything that gets triggered or is part of the running application: 17 GitHub Actions workflows, package.json, src/, deploy/, and the agent recipes outside the repository. Then I walked outward from those roots breadth-first: a script called by a live script is also live.

The result differs sharply from the first count: of 53 scripts, 34 are reachable and 19 are not. Not eleven, nineteen. The first count missed half of the truth.

I had assumed the roots were automatic

After fixing the method, another assumption of mine blew up. I had defined the roots as "things that trigger themselves", but I had never checked whether the workflows actually trigger themselves. So I checked:

$ for f in .github/workflows/*.yml; do
    echo "$(basename $f) cron=$(grep -c '^\s*- cron:' $f) dispatch=$(grep -c workflow_dispatch $f)"
  done
...
content-generate.yml cron=0 dispatch=5
devto-fix.yml cron=0 dispatch=1
post-veo-short.yml cron=0 dispatch=2
...
Enter fullscreen mode Exit fullscreen mode

Seven of the seventeen workflows have no schedule; the three above have no push trigger either, so all they have is a manual start button. content-generate.yml explains its own situation inside the file: the hourly cron was removed, and generation moved to the agent on my Mac.

I found five scripts whose only caller is one of those three files: apply-calendar-rejections.mjs, reconcile-content-metadata.mjs, fix-devto-mermaid.mjs, sync-devto-article.mjs, post-veo-short.ts. They were sitting in the "reachable from automation" column, yet none of them runs by itself — nothing happens until somebody presses the button. The correct reading is this: of the 34 reachable scripts, 29 have an automatic trigger and five I start by hand. The class I was trying to separate out was sitting inside the place I thought I had already separated.

Second trap: name matching merged two different things

Among the unreachable 19 was linkedin-auth.ts. Yet in the first count that script had been stamped "mentioned", because it appeared in my notes outside the repository. I opened the note. What it actually said was this:

curl -sD - -o /dev/null -H "X-Admin-Token: $T" \
  https://mustafaerbay.com.tr/api/linkedin-auth
Enter fullscreen mode Exit fullscreen mode

The note is not talking about the script, it is talking about the endpoint. There is a file in the repository called src/pages/api/linkedin-auth.ts; what I run when I renew LinkedIn's token every sixty days is that endpoint, not the script under scripts/. Because the two share a name, a measurement based on names took them for one thing.

The lesson is simple and expensive: a matching name is not an identity. If a script and an HTTP endpoint that happen to share a name collapse into one row of the inventory, the inventory lies.

The caller isn't always code

I went through the unreachable 19 one by one. This time I also looked for callers outside code: repository documentation, the header comments in the tools themselves, my notes outside the repository, and git hooks. Seven of them turned out to have a caller — and not one of those callers is code. Three more sat on the fence; I'll come back to them.

Two are written in CLAUDE.md, KURULUM.md and README.md: personalize.sh and setup.sh. Someone installing this blog on a new server runs those two by reading the guide. A human reading a guide is a caller too; just one that grep cannot see.

Two are steps in a numbered procedure in the video-pipeline documentation: test-veo-render.ts is step four, test-dialogue-render.ts is step one. A numbered procedure says when to run the thing; that is a caller.

Two more declare their own status in their own headers. The first lines of submit-helper.ts:

 * Reddit + Hacker News submit helper.
 *
 * Does NOT submit automatically — Reddit/HN have aggressive bot detection,
 * risk of a ban. Instead: take the post slug, produce a submit link with an
 * optimized title + URL. Mustafa clicks the link, it opens in the browser,
 * and he approves manually.
Enter fullscreen mode Exit fullscreen mode

And install-git-hooks.sh says on its third line: "After a new clone: bash scripts/install-git-hooks.sh". Neither deserves the "no caller" stamp; they have callers, the caller is me, and they wrote that down inside themselves.

I tried widening the rule, and it didn't discriminate

At this point I thought: if a line in the header counts as a caller, let me count every script that has an invocation line. It would have been wrong to accept that without testing it, so I tested it:

$ # files whose first 30 lines contain 'node|npx|tsx|bash ... scripts/'
37 of 53 scripts have an invocation-syntax line
  22 of those are already reachable from automation
  15 of the 19 unreachable ones have the same line
Enter fullscreen mode Exit fullscreen mode

So the test flags 22 of the 34 live scripts and 15 of the 19 unreachable ones at once. It sweeps both sets at the same rate; that is not a test for callers, it's a measure of my habit of writing file headers. A criterion that doesn't discriminate isn't a criterion.

I stayed with the narrow one: a caller is a written instruction that says when or by whom the tool runs, not a line that merely shows the syntax. install-git-hooks.sh's "after a new clone" and submit-helper.ts's "Mustafa clicks the link" meet that bar; npx tsx scripts/foo.ts does not.

To be honest, that rule doesn't settle three of the scripts either. veo-daily.ts appears eight times across the documentation: five are bare npx tsx lines, one is an item in a status list, one describes a data source. The eighth is a numbered "run this" step in veo-prompt-template.md — the very kind of evidence I accepted a paragraph earlier. But that procedure is stale: step one of the same list refers to the script as "to be written", and step four calls build-veo-short.ts, which doesn't exist in the repository at all. Of the three references to generate-short.ts, two are unchecked to-do items (- [ ]) and the third sits in a directory tree that was never built; the generate-shorts.yml named beside it doesn't exist, and the script's own code says "Phase 0 stub". The note that mentions _compare-render.mjs is the record of a past run, not an instruction for a future one. None of the three earns "has a caller", and I can't bring myself to file them under "nobody" either. So I split the classification three ways rather than two: 7 scripts have a clear caller, 9 have none at all, and 3 are undecided.

The untracked caller: .git/hooks

The seventh one stopped me: validate-mdx-staged.ts. The MDX validator that runs before publishing. Its only citer in the repository is install-git-hooks.sh, which is itself unreachable. My first reaction was "a mandatory gate never runs at all". That was wrong.

The only thing install-git-hooks.sh does is write a .git/hooks/pre-commit file. I went looking for it — and my first attempt failed for exactly the reason this article is about:

$ stat -f '%Sm' .git/hooks/pre-commit
stat: .git/hooks/pre-commit: stat: Not a directory
Enter fullscreen mode Exit fullscreen mode

I'm writing this in a linked worktree, where .git is not a directory but a file pointing at the common directory. So I asked git itself for the right path:

$ H="$(git rev-parse --git-common-dir)/hooks/pre-commit"
$ stat -f '%Sm' -t '%Y-%m-%d %H:%M:%S' "$H"
2026-05-09 15:04:26
$ diff -q <(sed -n '/<<.EOF.$/,/^EOF$/p' scripts/install-git-hooks.sh | sed '1d;$d') "$H"; echo "exit=$?"
exit=0
$ git log --diff-filter=A --format='%ci' -- scripts/install-git-hooks.sh | tail -1
2026-05-09 15:06:27 +0300
Enter fullscreen mode Exit fullscreen mode

diff printed nothing and exited zero: the installed hook is byte-for-byte identical to the body the script writes. And it is dated two minutes before the script entered the repository. So on 9 May I first installed the hook, then committed the script that installs it. The validator has been running on every commit of mine for five months; the line that calls it is in none of the tracked files.

Git's own documentation says this plainly: hooks are looked for by default in $GIT_DIR/hooks, and that location can be changed with core.hooksPath. $GIT_DIR is not tracked content — meaning no measurement that scans the repository tree can ever see that caller. The same document adds two more details: hooks without the executable bit are ignored, and pre-commit can be bypassed with --no-verify. Put the three sentences together: this gate sits somewhere invisible, doesn't run if the executable bit is missing, and switches off with a single flag.

The worktree detail doesn't let the gate be skipped, it only changes the path: git rev-parse --git-common-dir returns the main repository's .git, and the hook is found from there — the consequence of the design the git-worktree documentation describes as sharing everything except per-worktree files such as HEAD and index.

Where it does get skipped is elsewhere: a fresh clone, and the CI runner. The hook isn't tracked content, so it doesn't arrive with a clone; and there is no line in KURULUM.md telling you to install it. In other words, this validator's operation depends on a manual step documented nowhere in the repository. It works on my machine. There is no evidence whatsoever that it works on anyone else's.

The thirty-four live ones hang by a thin thread too

Once I was done with the unreachable ones, my curiosity moved to the live side. Thirty-four scripts are reachable, fine — but from how many different places?

First I went looking for the class I was afraid of: scripts called only from my agent recipes outside the repository. Those files sit on my machine, not in the repo; a tool called from there would be orphaned the moment the repository changed hands. The number came back zero. All three scripts the agent recipes touch (generate-hero.ts, generate-hero-en.ts, validate-content-sources.mjs) are also called from a workflow. The worst class I expected is empty; I have to write that down too, because a measurement that reports only its bad news stops being a measurement and becomes propaganda.

The root distribution did show a different fragility, though. Of the 34 live scripts, 25 have exactly one kind of caller: 14 only from workflows, 5 only from package.json, 3 only from under deploy/, 3 only from application code. Eleven of those 14 appear in exactly one .yml file — meaning the visibility of eleven tools rests on a single file. An edit that retires that file or changes the relevant run: line leaves the tool in the codebase with nothing calling it; some of the 19 I counted today came about in precisely that way.

One more detail emerged. Thirty-three of the 34 live scripts are called directly from a root; the only tool kept alive through a chain is collect-sitemap-urls.mjs, and update-sitemap.mjs is what calls it. My automation graph is almost perfectly flat: the tools don't use each other, each one sits alone under a workflow. The upside is that when I break a tool, the blast radius is small. The downside is that the odds are high I have written the same shared work twice instead of having two tools share it — and today's count doesn't measure that. That's the subject of the next one.

The remaining nine: not garbage, a map of decisions

Diagram

Nine scripts with no caller at all remained: 1,283 lines, 7.7 percent of the top-level scripts/ total. The file I had opened as a deletion list turned out to be a chronology of my own decisions.

Five of them have a successor sitting in the repository under its own name. On 9 May the workflows dropped share-to-linkedin.ts and share-to-bluesky.ts and moved to social-post.ts, which runs a per-platform queue. The work of svg-to-png.ts is done by generate-hero.ts, which renders covers straight through sharp. linkedin-auth.ts moved into an endpoint inside the application. And sitemap-ping.ts states its own condition in its header: "This script is currently mostly placeholder + log." Its successor, indexnow-submit.ts, is called from three workflows.

Four are one-shot tools that finished their job: import-d1-export.mjs (a database migration), youtube-auth.ts and youtube-channel-setup.ts (channel setup), test-tts.ts (one text-to-speech trial). These aren't dead, they're finished. The difference doesn't show up in an inventory, but it changes everything about the decision: you delete dead code, you archive a finished tool.

What the dead script knew and the live one forgot

Before signing off on the deletion list I opened two of the orphans and read them. Both contained the same line:

scripts/share-to-linkedin.ts:37:      if (fm.draft === 'true') continue;
scripts/share-to-bluesky.ts:57:      if (fm.draft === 'true') continue;
Enter fullscreen mode Exit fullscreen mode

The check that skips a draft article in the sharing queue. I looked up when the same check appeared in social-post.ts, the script that replaced them:

$ git log -S'!p.data.draft' --format='%cs %h %s' -- scripts/social-post.ts
2026-08-13 a088d2bf fix(social): draft post kuyrugu kilitliyor — draft filtresi + gate skip (#84)
Enter fullscreen mode Exit fullscreen mode

(The commit subject is in Turkish: "draft post locks the queue — draft filter + gate skip".) There's a detail here worth being precise about: social-post.ts is older than the scripts it replaced — it was born on 31 March. So the check wasn't lost in a handover; it was never there in the new script. The workflows dropped the old scripts on 9 May and the filter arrived on 13 August — 96 days in between, and for those 96 days the only place that knew in writing that the check was necessary was two scripts no automation called any more. According to my own notes the price was paid as well: once a draft article reached the head of the queue, five consecutive sharing runs returned "success" without sharing anything.

What bothers me here isn't the bug. Moving to a new tool and failing to pick up one of the old one's checks happens. What bothers me is this: had I run this count three months earlier and deleted the two as "no caller", I would have erased the only written trace that the check was once considered necessary. Dead code is sometimes a record.

Google's SRE book defines toil by six characteristics: manual, repetitive, automatable, tactical, devoid of enduring value, and growing linearly as a service grows. The book adds one more thing: even though running a script is faster than doing every step by hand, the hands-on time a human spends running it is still toil. I agree; but after this count I'd add a footnote. The time spent on a hand-run tool is toil, the record it carries is not. Anyone who lumps the two together and starts cleaning will save time out of memory.

A decision framework

After this count the sequence I ended up with has four questions. Before deleting a tool or leaving it be:

  1. Is it reachable from a root that runs — and does that root really run by itself? The workflow existing in the directory isn't enough; does it have a schedule, or am I the one pressing the button? Those are two different classes.
  2. If it isn't reachable, who is the caller? A repository document, a "when to run this" line in the tool's own header, a note outside the repository, an untracked git hook — or genuinely nobody?
  3. Is there a successor, and does the successor carry every check the old tool had? If it doesn't, add the missing check to the successor first; deletion comes after that.
  4. If I handed this repository over tomorrow, which line in it says this tool exists? If the answer is "none", the thing to do isn't to delete the tool — it's to write that line.

Applying the framework to today's nine orphans, the decision list has fewer "delete" entries than I expected. The five with successors can go, but only after verifying line by line that the successor carries the old checks — as someone who has already missed a draft line once, I won't reverse that order. I won't delete the four one-shot tools; they are the only record of a migration, a channel setup and a text-to-speech trial. Dropping a one-line "when to run this" note under scripts/ is both faster and more honest than deleting 1,283 lines — though three of the nine already carry a "run once" line; all of them look backwards, which is why none of them calls me anywhere today. And adding the hook-installation step to KURULUM.md — that's the only genuine mistake in the whole list, because on a fresh clone the publishing gate closes itself.

In this run I deleted no lines and moved no files. I changed method three times along the way, and once tried to widen it and took it back; what came out of it isn't a tidy directory but a ledger I noticed I don't have.

The single rule I'm taking away: a tool whose caller is a human needs a line in the repository, not in my memory. A caller that lives in memory is a caller that doesn't survive a handover. I have paid for this before; when I renamed the repository, I found out months later that three scripts were still calling the old name. What drifted there was a reference; what drifts here is the caller itself.

An inventory with no ledger

I opened the day as a "dead code count" and I have no dead-code number in hand. The 7.7 percent share of 1,283 lines is the least interesting finding of this count. The interesting one is this: the repository describes the things that run themselves, and stays silent about the things that need me.

Producing an automation inventory is easy; it's one line of find. The hard one is the inventory of callers — because some of those callers aren't code: a line in a guide, an "after a new clone" in a file header, an untracked hook, a button pressed by hand, or just me remembering. When I wrote about service ownership handover, the matter always looked to me like documentation discipline. After today's count I see it as something narrower and more measurable: handover debt is the caller being undocumented, not the code. git clone carries the code. The only thing carrying the caller is the fact that I wrote that line.

Official Sources

Top comments (0)