In my first post, I argued that working code is only the beginning. Vibe coding is tactical programming at scale. It removes the friction of writing code, but not the friction of thinking about design. I laid out when tactical makes sense and when strategic matters.
Tactical programming optimizes for working code at the expense of long-term simplicity.
Strategic programming takes the time to design systems that are easier to understand, modify, and extend.
In this post, I walk through the design decisions of a tool I’ve been building called uda (universal dependency analyzer) to show what strategic programming with AI looked like in practice.
In my second post, I used uda to measure coupling and instability in codebases and running systems. Here, I’m turning inward: how the tool itself was designed, and why those design choices mattered.
uda is a CLI tool that supports Go, Python, Rust, TypeScript, and Swift. It uses tree-sitter for static analysis and has an interactive TUI. It’s not a perfect codebase, but the mistakes in it are fixable in isolation. That’s the point.
Strategy 1: Explore the problem space
The early commits tell the real story of exploratory programming:
```text feat(wip): list files recursively feat(wip): detect language feat(wip): load parsers feat(wip): query parsed files feat(wip): capturing query nodes feat(wip): go analyzer broken(wip): building a dep graph refactor(wip): cleaning up dep graph node population refactor(wip): rm old analyzer fn refactor: abstract away coupling stats aggregation feat: metrics now outputting accurately refactor: move query and capture processing to ts ```
Notice the `(wip)` tags. The multiple refactors in sequence. I’d never built a static analysis tool before. I’d never integrated directly with tree-sitter before. I didn’t know what the right interface should look like, how to separate tree-sitter parsing from the rest of the system, or how deeply nested the coupling data needed to be. I just had to start building and figure it out as I went.
This is the exploratory part of programming that I think is genuinely strategic. Not because I had a grand plan, but because I was learning. I wanted to fail early, before things got too complicated. Recognizing when an abstraction wasn’t working. Being willing to throw away code I’d just written.
Strategy 2: Find the right abstraction by building the wrong one first
My first instinct was to make tree-sitter the organizing principle of the entire tool. I built a central grammar registry that managed parsers for every supported language. The CLI depended on it directly: iterate over files, detect the language, load the right parser from the registry, parse, query.
```text CLI ──▶ Grammar Registry ──▶ tree-sitter │ ├── Go parser ├── Python parser ├── Rust parser ├── TypeScript parser └── Swift parser ```
Tree-sitter was the architecture. The CLI was coupled to it.
The grammar registry was the obvious approach in the moment, and it could have worked. But while I was being tactical about getting something running, the goal was strategic: figure out the right abstractions so I could leverage that investment later.
Then I started building the Go analyzer and ran into a problem. The Go analyzer needed two tree-sitter grammars: one for Go source files and one for `go.mod` files. The registry assumed one parser per language. The abstraction didn’t fit.
That’s when I realized the registry was wrong. The CLI shouldn’t know or care about tree-sitter. Coupling to tree-sitter only made sense inside the analyzer, where the implementation knows what grammars it needs and how to use them.
As soon as I identified the temporal coupling between the CLI and tree-sitter, and the information leakage of parsing details into the command layer, I replaced it before it became a time sink or a source of compounding complexity.
I introduced an `Analyzer` interface. Give it a directory, get back metrics. Each analyzer owns its parsing internally. The CLI just calls `Analyze`.
```text CLI ──▶ Analyzer interface │ ├── Go analyzer ──▶ tree-sitter (Go + go.mod grammars) ├── Python analyzer ──▶ tree-sitter (Python grammar) ├── Rust analyzer ──▶ tree-sitter (Rust grammar) └── … ```
The reusable parts of tree-sitter interaction got extracted into a small helper package. It went from being the central architecture to being an optional utility. Analyzers use it if they want to. They don’t have to. In future, an analyzer could use an entirely different parsing strategy without touching anything else.
This is information hiding arrived at through experience, not theory. I didn’t sit down and decide “tree-sitter should be hidden.” I built a system where it wasn’t hidden, felt the friction, and restructured.
Strategy 3: Design deep modules
The interface eventually evolved to:
```go type Analyzer interface { Name() string Analyze(ctx context.Context, dir fs.FS) ([]Metrics, error) } ```
Two methods. Behind this interface, each analyzer discovers manifest files, walks the filesystem, parses source files, resolves import paths, builds dependency graphs, and computes coupling statistics.
This is a deep module: a simple interface that hides significant complexity. The opposite is a shallow module, where the interface is as or more complex than the implementation. Deep modules pull their weight. They absorb complexity so the rest of the system doesn’t have to.
Strategy 4: Use golden tests as guardrails
Testing is strategic work. For this project, the testing strategy was about making sure we could test real functionality.
Each test points an analyzer at a real codebase—not mocks—and compares the full output against a golden JSON snapshot. These are true integration tests. The analyzer reads real files, parses them with tree-sitter, resolves real import paths, and produces real coupling metrics. Mocks are useful when you need to simulate something that’s hard to test otherwise. Here, the real thing is easy to test, so there’s no reason to simulate it.
There are no property-level assertions. The test compares the entire output structure. If I extend the metrics with new fields, the golden test fails and forces me to review the updated output. With explicit property assertions, a new field would go untested silently. You wouldn’t catch a regression on something you never asserted against.
This reduces cognitive load for humans and agents alike. Nobody has to remember to add a new assertion every time the output changes. The assertions are implicit in the snapshot. Updating them is just reviewing and accepting the new output.
From a code perspective, the test harness is almost zero maintenance. The concern is entirely on the outputs. Add a new test case by adding a directory. The harness doesn’t change.
Where AI actually helped
Once the interface, the package structure, and the testing pattern were established, the nature of the work changed. The remaining analyzers all needed to implement the same interface, follow the same patterns, and produce the same output format.
The Go analyzer took many commits of exploratory work. The TypeScript analyzer landed in a single commit. Rust, Python, and Swift followed shortly after. Each analyzer is an isolated package with no dependencies on the others. The architecture made this possible.
The division of labor:
- I defined the `Analyzer` interface, the `Metrics` types, the package structure, and the golden test harness.
- The agent generated mock codebases for testing, tree-sitter queries for each language, and the mechanical wiring of each analyzer implementation.
- I reviewed the mock codebases to make sure they exercised real edge cases, the golden outputs to verify the coupling data made sense, and the tree-sitter queries to confirm they captured the right import patterns.
```text CLI ──▶ Analyzer interface │ ├── Go analyzer (isolated, tree-sitter) ├── Python analyzer (isolated, tree-sitter) ├── Rust analyzer (isolated, tree-sitter) ├── TypeScript analyzer (isolated, tree-sitter) └── Swift analyzer (isolated, tree-sitter) ```
It’s not a perfect codebase
uda is not a showcase of flawless architecture. There are parts where I got tactical. Some of the TUI logic, some analyzer internals where I have a more superficial understanding of the implementation. I don’t fully understand Swift. The Swift analyzer might have bugs I haven’t caught.
But the issues are isolated. If the Swift analyzer is wrong, you fix it and nothing else changes. If the TUI has a bug, the analyzers don’t care. If I want to add a Java analyzer, I implement the interface in a new package. The existing code is untouched.
This is the return on strategic investment. Not perfection, but containment. The mistakes are cheap to fix because the boundaries are clear.
Strategic programming is not anti-AI
My first post argued that vibe coding is tactical programming at scale. I stand by that. But the lesson isn’t “don’t use AI.” The lesson is: do the design work first.
The strategic phase of uda was messy hand-coding. That investment is what made the tactical phase productive instead of reckless. The agent could build analyzers quickly because the architecture told it exactly where each one should go, what contract it needed to fulfill, and how its output would be verified.
The architecture was the guardrail. The AI operated within it.
If I had vibe coded uda from the start, I’d probably still have that grammar registry. The LLM would have built it out. It would have worked. But the CLI would still be coupled to tree-sitter. Adding a new language would mean touching the registry, the CLI, and the new analyzer. That’s the kind of mistake you only catch by building, feeling the friction, and being willing to throw away code that works. An agent will happily extend a bad abstraction forever. Vibe coding’s failure mode isn’t using shortcuts; it’s shipping them.
uda is a small project. A utility. I’m not suggesting that what I did here scales directly to every team or every codebase. Larger systems have more constraints, more people, more pressure. We don’t always have the luxury of being strategic.
But we can be thoughtful. We can trend toward the best we can do at a given time. Negotiate for more time when it matters. Use AI to accelerate toward a better solution, not just any solution. If you’re letting AI do everything and you’re losing track of what it’s doing, you’re not going to find the right abstractions. It’s steering and you’re just along for the ride. And it produces bad code just as fast as it produces good code.
Speed without structure eventually makes you slower. The commit history of uda is one small example of what it looks like to learn it deliberately.
Takeaways
- Deep modules with small interfaces create natural boundaries for AI-assisted development.
- Information hiding makes mistakes cheap to fix. If a component can be replaced without touching anything else, it’s safe to be tactical within it.
- Golden tests make AI contributions reviewable and regressions visible.
- The strategic phase is slow, messy, and non-delegable. It’s where you figure out the abstractions.
- The tactical phase is where AI shines, but only if the strategic work came first.
- Sustainable software isn’t about perfection. It’s about containment: making sure the imperfect parts can be fixed in isolation.