Two modules with the same name, one is a demo shell
A generator wrote a feature into a template demo module sharing the real one's name. Resolve the target from disk and halt on ambiguity rather than picking.
A code generator was told that screens, view models and routes belong in a feature module. It resolved that to a path, wrote the files, and everything compiled.
The module it wrote into was a demo shell that arrived with the template. The real implementation was in a nested path beside it, with the same leaf name.
The case this post is about
One feature name, settings, and two directories that both answer to it:
feature/settings 22 Kotlin files
feature/progress/settings 15 Kotlin files
The first is a template demo shell. The second is the real implementation. A generator following its module map wrote into the first one, correctly by the map's own terms, and manufactured a duplicate feature alongside the working one.
Every tie-break I considered picks wrong here. More code selects the facade. The flat path is also the one the map implies, so the heuristic and the bug agree — and a resolver that guessed would have confirmed the wrong answer with more confidence than the generator that produced it.
This post follows those two paths, because the only safe answer is to refuse.
The symptom
Nothing fails. The generated code compiles, the module is in the build, the tests that were generated alongside it pass. By every check available, the feature was implemented.
The app does not change. You run it, navigate to the screen you just implemented, and get the old one — because the module the application actually depends on is the other one. The work exists, in a real module, in the build, and nothing consumes it.

The second thing is the shape that makes it possible. A fork organises its features into groups, so the real module sits one level down — a group directory, then the feature. The template's demo modules sit flat, directly under the feature root. Both are real directories, both are declared, and the leaf name is identical. There is nothing malformed here; two legitimate paths simply end in the same word.
The third is that the obvious way to tell them apart is wrong, and I can put a number on it. The instinct is that the real one is the bigger one — more files, more recently touched. Measured: the flat module holds twenty-two Kotlin files and the nested one holds fifteen. The heuristic picks the shell. It is not merely unreliable, it is actively inverted in the case I had.
The fourth is that the structural check which looks like it should catch this does not. There is a gate for module placement, and it asks whether types from one layer have leaked into a feature module — a genuinely useful question, and a different one. It says nothing about which feature module received the write. Run it against the failing case and it exits zero, correctly, having checked something else.
Why the obvious fix failed
Follow the artifact map. The map says a screen belongs in a feature module. That is true and it is not a path. Reading a statement about which kind of module a file belongs in as a statement about the shape of the path is the conflation that caused this, and it is easy to make because the map looks like it is answering the question.
Prefer the flat path. The default that produced the bug. It is also the shape the template uses and the shape most documentation shows, which is why it feels like the safe fallback rather than a guess.
Pick the larger or more recently modified one. Measured false, as above. Worse, it is the kind of heuristic that works often enough to be adopted and then fails silently, which is the profile of every rule that should never have been written.
Rename so the collision cannot happen. This would genuinely work and it is not available. The flat modules come from the template and are re-supplied on every sync; the nesting is the fork's deliberate organisation. Renaming either side to fix a lookup problem means fighting the template forever or abandoning the structure, which is a large change to avoid writing a resolver.
The actual mechanism
Resolve the target from disk, and treat ambiguity as a halt rather than as a choice to make.
Resolution is a search, and a search has three outcomes:
exactly one candidate → use it
more than one → HALT, naming the feature and the candidates
none → HALT, naming the feature
The second line is the whole point and it is the one that normally gets built wrong. Faced with two candidates, the natural engineering instinct is to rank them — add a tiebreak, prefer the deeper path, prefer the one with a manifest, prefer anything. Every one of those is a guess dressed as a rule, and the cost of guessing here is not an error. It is generated code in the wrong module, which compiles, passes, and does nothing.

A generator that picks under ambiguity is wrong some fraction of the time, silently. A generator that halts is wrong none of the time, loudly, at a moment when the person running it has full context. The second is strictly better even when the halt is occasionally unnecessary, because the failure modes are not symmetric: a spurious halt costs a minute, and a wrong write costs however long it takes somebody to notice the feature they implemented is not the one running.
The sub-case that completes it: when ambiguity is real, the only resolution is a human statement. Which of two identically-named modules is the live one is a fact about intent, and intent is not recoverable from the filesystem — the directories are equally real, equally valid, equally included. So the disambiguation is an operator pin recorded in the project: a person naming the path once, for that feature, permanently. Not a heuristic the tool applies, because the tool has no access to the fact in question.
This is also why there is no agent-usable waiver. A waiver would let the thing that cannot know the answer declare that it does not matter, which is the precise failure the halt exists to prevent.
The fix
resolve search the feature tree for candidates matching this feature
0 found halt — unresolved
2+ found halt — ambiguous, list the candidates
1 found use its path
pin an operator-recorded path overrides resolution for that feature

The resolution runs before any file is written, which matters more than it sounds. A generator that resolves lazily, as it goes, has already written three files into the wrong module by the time it discovers the fourth is ambiguous. Resolving up front means the halt happens with nothing on disk to clean up.
The exit codes earn their place too. Ambiguous and unresolved are different conditions with different remedies — one needs a pin, the other needs a module — and collapsing them into a single failure means the message has to be read carefully to know which. Distinct codes let the caller branch without parsing prose.
What the halt message contains is worth being deliberate about, because a halt that does not help is just a slower failure. Naming the feature is the minimum; listing the candidate paths is what lets somebody resolve it without going and looking; and saying which remedy applies — a pin for ambiguity, a module for the unresolved case — turns a stop into an instruction. Three lines, written once,
and the whole resolver is short enough that the interesting part is what it refuses to do:
resolve_feature_module() {
local tree="$1" feature="$2" pin_file="$3"
# An operator pin is the ONLY disambiguation. Not a default, not a heuristic.
local pinned
pinned=$(read_pin "$pin_file" "$feature")
if [ -n "$pinned" ]; then
[ -d "$tree/$pinned" ] || { echo "pinned path $pinned does not exist" >&2; return 4; }
echo "$pinned"; return 0
fi
# Flat (feature/<f>) and nested (feature/<group>/<f>) are BOTH legitimate shapes.
local candidates
candidates=$(find_dirs_named "$tree" "$feature")
local n; n=$(count_lines_of "$candidates")
case "$n" in
0) echo "unresolved: no module named '$feature' under $tree" >&2; return 4 ;;
1) echo "$candidates"; return 0 ;;
*)
# DELIBERATELY no tie-break. Every available signal is measurably wrong:
# file count, mtime, and "the obvious one" all pick the facade sometimes.
{
echo "ambiguous: $n candidates for feature '$feature'"
echo "$candidates" | prefix_each " "
echo " remedy: pin one as feature_module_paths.$feature in the project plan"
} >&2
return 3 ;;
esac
}
The three outcomes on a real fork's feature tree:
$ resolve_feature_module source/app/feature home ""
feature/home # exit 0 — template demo, only one
$ resolve_feature_module source/app/feature settings ""
ambiguous: 2 candidates for feature 'settings'
feature/settings
feature/progress/settings
remedy: pin one as feature_module_paths.settings in the project plan
# exit 3
$ resolve_feature_module source/app/feature ledger ""
unresolved: no module named 'ledger' under source/app/feature
# exit 4
The middle case is the one worth staring at, because every tie-break I considered picks wrong on it.
feature/settings holds 22 Kotlin files and feature/progress/settings holds 15 — so "the one with
more code" selects the facade. The flat path is also the one a generator following the module map
writes to by default, which means the heuristic and the bug agree, and a resolver that guesses would
have confirmed the wrong answer with more confidence than the generator that produced it.
reused by every future collision.
The thing to resist is the temptation to make the message apologetic. This is not the tool failing to cope; it is the tool correctly reporting that two valid answers exist and it has no basis for choosing. Phrasing it as a limitation invites somebody to "fix" it with a tiebreak.
How we proved it
The shape of the tree is the finding. Nine flat feature modules and twenty-seven nested ones in the same project. That mixture is what makes a single assumed path shape wrong: neither "flat" nor "nested" describes the tree, so any generator with a hardcoded shape is wrong for one of the two groups.
The file-count heuristic is refuted by measurement, which is the part I would keep if I kept one thing. Twenty-two Kotlin files in the flat module, fifteen in the nested one — so "pick the bigger" selects the demo shell. I would have believed that heuristic without checking, and it is wrong in the first case I looked at.
The existing gate's silence is demonstrable. Running the module-placement check against a deliberately broken fixture — a feature written into the wrong module of a same-named pair — exits zero. That is the check behaving correctly for its own question and establishes that this defect had no coverage, which is a stronger statement than "we did not notice".
What the halt costs is small enough to report honestly. In the project above, twenty-seven of the thirty-six features resolve to exactly one candidate and never see a halt at all. The halt is reachable only for the nine where a flat template module shares a name, which is the set where the question is genuinely open. A guard that fires on a quarter of cases would be a different conversation; this one is quiet almost everywhere and loud exactly where it should be.
What I have not solved: detecting the ones already written. Everything here guards new generation. A feature written into a demo shell three months ago is still there, compiling, included and unused, and nothing sweeps for it. The signature is findable in principle — a module nothing depends on, holding files that duplicate names in a sibling — and I have not built the sweep.

And a limitation of the halt: it is only as good as the search. Resolution looks for candidates matching a feature name, which means a feature whose module is named differently from the feature itself resolves to zero candidates and halts as unresolved — correct, and annoying, and it will be the first thing somebody wants a flag for. The pin covers that case, which makes the pin do double duty as both a disambiguator and a general escape, and I am not sure that is a clean design.
What this generalizes to
A lookup that returns more than one result is not a ranking problem. It is a report that the question was underspecified, and the useful response is usually to say so rather than to answer anyway.
The pressure runs the other way. Returning something feels helpful, halting feels like a failure to handle a case, and ranking is the kind of thing that is satisfying to implement. So tools acquire tiebreaks — prefer the first, the newest, the biggest, the shortest path — and each one converts an answerable question into a plausible answer that is right most of the time.
The test is what happens when the tiebreak is wrong. If the consequence is a visible error, ranking is fine. If the consequence is a confident wrong result that passes every downstream check — code in the wrong module, a write to the wrong record, a match against the wrong account — then the only correct behaviour is to stop and say which candidates were found, and to let somebody who has the missing fact supply it.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".