Nineteen places decide "is this path a test?" from the path string, under six different names, over seventeen separate pattern definitions. They disagree with each other, three of them classify production code as tests, and sixteen of them re-derive an answer the ingestion already computed and stored.
This is not a tidiness issue: three call sites return wrong answers for real paths, and two of the disagreements change what the health engine and the search rank.
Version: 0.36.0, verified against a clean uv pip install repowise==0.36.0 in an isolated venv.
Where the question is answered
| File |
Function |
core/ingestion/traverser.py:674 |
_is_test_file(rel_path, filename) — this is the one that sets FileInfo.is_test (traverser.py:534) |
core/analysis/health/engine.py:88 |
_is_test_file(rel_path) |
core/analysis/communities.py:395 |
_is_test_file(path) |
core/analysis/health/coverage/detector.py:115 |
is_test_file(rel_path, source) |
core/generation/layers.py:104,119 |
_is_test_file_name + _is_test_dir_path, combined in infer_layer |
core/analysis/health/refactoring/split_file.py:112 |
_is_test_path(path) |
core/analysis/health/refactoring/move_method.py:55 |
_is_test_path(path) |
core/analysis/health/refactoring/extract_helper.py:70 |
_is_test_path(path) |
core/analysis/health/biomarkers/hidden_coupling.py:71 |
_is_test_path(path) |
server/mcp_server/_answer_pipeline.py:201 |
_is_test_path(target_path) |
server/mcp_server/tool_search_symbols.py:43 |
_is_test_path(path) |
server/mcp_server/tool_search.py:366 |
inline over its own _TEST_PATH_TOKENS |
_TEST_PATH_TOKENS is defined three times, byte-identical, in _answer_pipeline.py:194,
tool_search.py:331 and tool_search_symbols.py:35.
The full set
Grepping for _is_test_* finds eleven. Grepping for the behaviour — any function or expression
that classifies a path by test tokens — finds nineteen, spread over six names:
| Name |
Where |
_is_test_file |
ingestion/traverser.py:674 (sets FileInfo.is_test), analysis/health/engine.py:88, analysis/communities.py:395 |
is_test_file |
analysis/health/coverage/detector.py:115 |
_is_test_file_name / _is_test_dir_path |
generation/layers.py:104,119, combined in infer_layer |
_is_test_path |
health/refactoring/{split_file,move_method,extract_helper}.py, health/biomarkers/hidden_coupling.py, mcp_server/_answer_pipeline.py, mcp_server/tool_search_symbols.py |
_looks_like_test_path |
health/biomarkers/coverage_gap.py:74, health/biomarkers/coverage_gradient.py:46 |
is_test_path |
ingestion/git_indexer/fix_shape.py:97, workspace/extractors/base.py:42 |
Four more decide inline, without a named predicate: mcp_server/tool_search.py:352
(_classify_hit_kind), server/routers/stats.py:545, cli/ui/repo_scanner.py:91,
generation/onboarding/subkinds/development_guide.py:81,117.
Seventeen separate pattern definitions back them: _TEST_PATH_TOKENS (×3, byte-identical),
_TEST_PATH_RE (×2, different), _TEST_DIR_SEGMENTS (×2, different), plus _TEST_DIRS,
_TEST_FILE_RE, _TEST_FILE_PATTERNS, _TEST_DIR_TOKENS, _TEST_DIR_FRAGMENTS,
_TEST_SUFFIXES, _TEST_BASENAME_PATTERNS, _TEST_FILE_PREFIXES, _TEST_FILE_SUFFIXES,
_TEST_PATH_FRAGMENTS, _TEST_PATTERNS, _TEST_ROOTS.
Worth noting: two of them are already called is_test_path, public, in
ingestion/git_indexer/ and workspace/extractors/. workspace/extractors/base.py is also the
only one that matches with fnmatch on the basename and on path segments rather than on
substrings — it gets the cases below right.
What they answer
Nine are importable and were run over the same paths. layers is called through its real entry
point infer_layer(path) == "Test", not through its private halves.
| path |
tool_search |
answer_pipe |
split_file |
move_method |
extract_helper |
hidden_coupling |
engine |
communities |
infer_layer |
tests/app/tests.py |
no |
no |
yes |
yes |
no |
yes |
no |
yes |
yes |
spec/models/user_spec.rb |
no |
no |
no |
no |
no |
no |
no |
no |
yes |
conftest.py |
no |
no |
yes |
yes |
no |
no |
no |
yes |
yes |
src/latest/api.py |
no |
no |
no |
no |
no |
no |
no |
yes |
no |
protest/main.py |
no |
no |
no |
no |
no |
no |
no |
yes |
no |
src/test/java/Foo.java |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
src/__tests__/x.js |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
app/user_test.go |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
src/Button.spec.ts |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
src/testing/helpers.py |
no |
no |
no |
no |
no |
no |
no |
no |
no |
lib/contest.py |
no |
no |
no |
no |
no |
no |
no |
no |
no |
Disagreement on 5 of 11.
Findings
1. Three places classify production code as tests
_TEST_PATH_RE = re.compile(
r"(test[s_/]|_test\.|\.test\.|\.spec\.|__tests__|conftest|fixture[s]?[/.])",
re.IGNORECASE,
)
test[s_/] is meant to catch test/ and tests/, but it is not anchored to a path segment, so it
also fires on any segment ending in "test" followed by a separator. src/latest/api.py and
protest/main.py are classified as test files and are pulled out of community assignment
(_assign_tests_to_communities).
The same substring check appears in two more places, with the same effect:
cli/ui/repo_scanner.py:91 any(p in full_rel for p in _TEST_PATTERNS)
generation/.../development_guide.py:81 any(seg in path for seg in _TEST_ROOTS)
Verified against 0.36.0: protest/main.py, src/latest/api.py and contest/x.py are counted as
tests by all three. In repo_scanner that inflates the test-file count shown during init; in
development_guide it drops those files out of the generated guidance.
2. tests/app/tests.py — five say test, four say not
The layout Django's own tutorial produces. It is skipped by three refactoring detectors and by
community assignment, but returned as an ordinary hit by both MCP search paths and not excluded by
the health engine.
Two of the implementations state in their own docstrings that they are meant to match the others:
Conservative test-file check (mirrors the other detectors). — split_file.py:113
Conservative test-file check (mirrors the engine's ``_is_test_file``). Kept local so the detector stays self-contained — extract_helper.py:71
extract_helper.py answers no for this path; split_file.py answers yes. The copies have drifted
apart from the thing they claim to mirror.
3. RSpec layout is recognised by exactly one of nine
spec/models/user_spec.rb is only classified as a test by infer_layer. RSpec's convention is
spec/**/*_spec.rb, so in Ruby projects the health engine, both refactoring detectors, the
biomarker and both MCP search paths treat specs as production code.
4. conftest.py — defensible either way, argued nowhere
Four say test, five say not. Strictly, pytest does not collect conftest.py as a test module: it is a
per-directory plugin providing fixtures. For a refactoring detector, treating test infrastructure
like a test is equally defensible. Both readings are reasonable; what is missing is that either is
stated anywhere, so the same file is treated differently depending on which tool asks.
5. Sixteen of them re-derive a decision that is already stored
traverser.py:534 computes is_test per file during ingestion and stores it on FileInfo
(core/ingestion/models.py:132). It is carried into the graph (graph/builder.py:187) and
propagated onto production files reached by tests (graph/_edges.py:102).
That flag is the established mechanism, not an unused field. Roughly a dozen consumers read it:
analysis/dead_code/analyzer.py (three sites), analysis/knowledge_graph.py,
analysis/pr_blast.py, ingestion/framework_edges/pytest_edges.py,
generation/page_generator/helpers.py, generation/context/assembler.py, pipeline/persist.py,
persistence/crud/graph.py.
The split is between subsystems that hold a graph node or a FileInfo and use the flag, and
subsystems that hold only a path string and re-derive. Of the nineteen path-based sites, three do
use the stored value:
mcp_server/tool_search_symbols.py:117 (gnode is not None and gnode.is_test) or _is_test_path(row.file_path)
mcp_server/tool_search_symbols.py:241 is_test = (g is not None and g.is_test) or _is_test_path(row.file_path)
server/routers/stats.py:537 GraphNode.is_test.is_(False)
The remaining sixteen do not.
6. The stored flag has a real gap, and one call site documents it
stats.py reads the flag and applies its own pattern, and says why:
# catches conftest, fixtures, and spec files the is_test flag misses. — stats.py:79
Measured against 0.36.0, the comment is partly outdated and partly correct. conftest.py,
spec/spec_helper.rb, spec/models/user_spec.rb and tests/fixtures/sample.json are all caught by
the flag today. These are not:
| path |
traverser._is_test_file |
fixtures/data.yml |
no |
src/__fixtures__/x.ts |
no |
testdata/golden.json |
no |
Fixture and golden-data directories outside a test tree are missed. This matters for the shape of a
fix: "just read the stored flag" would make those call sites worse, not better. The order has to be
the other way round — close the gap in the one authoritative computation first, then migrate the
callers onto it.
stats.py is the counter-example to the pattern in this report, not an instance of it: it uses the
stored decision, knows where it falls short, and says so in the code.
Why it matters
A single file is a test and not a test at the same time, depending on which subsystem asks. Health
scores, dead-code findings, refactoring suggestions, community assignment and search ranking each
use their own answer. The three verbatim copies of _TEST_PATH_TOKENS mean a fix to one search path
silently leaves the other two behind.
The existing answer to this does not scale
tests/unit/health/test_mts_cts_test_classification.py shows how this class of problem was handled
before: ".mts/.cts test files must be classified as tests across health logic (#288)". It imports
four implementations by name and asserts each one separately:
from ...biomarkers.coverage_gap import _looks_like_test_path
from ...biomarkers.hidden_coupling import _is_test_path as _coupling_is_test_path
from ...coverage import is_test_file
from ...engine import _is_test_file as _engine_is_test_file
(That file also surfaces a twelfth implementation I had not listed: coverage_gap._looks_like_test_path.)
The cost of that approach grows with every incident and every copy:
- it pins one input class (
.mts/.cts) and says nothing about the five disagreements above;
- a thirteenth implementation is not covered by it, so it may be wrong from the day it is written;
- it makes the copies harder to remove, because the test now names four of them explicitly.
A single source inverts that. Adding an ecosystem becomes one row in the pattern table, and if the
test is parametrised over that table rather than over the call sites, the new row brings its own
coverage with it — the property that makes table-driven tests worth having: "once the test function
exists, adding a new scenario is a single row of data".
The "should there be a thirteenth copy" question is then answerable by the build rather than by
review. A rule that fails when a new private _is_test_* helper appears outside the shared module is
an architecture fitness function in the usual sense — "an objective, automated check that a given
architectural characteristic still holds … converts a rule you hope people follow into a rule the
build enforces" — and in Python it is what import-linter is normally used for.
Suggested shape
One decision, one helper.
- First close the gap in
traverser._is_test_file (fixture and golden-data directories outside a
test tree), so the stored flag can carry the weight. Then, where a FileInfo or a graph node is
in hand, read is_test — as a dozen consumers already do.
- For the path-only callers (the MCP tools), one shared function, in one place.
- Its patterns as data, grouped by ecosystem, with the convention named in a comment:
TEST_PATTERNS: dict[str, tuple[str, ...]] = {
"maven_gradle": ("**/src/test/**", "**/src/androidTest/**"),
"jest": ("**/__tests__/**", "**/*.test.*", "**/*.spec.*"),
"pytest": ("**/test_*.py", "**/*_test.py", "**/tests/**", "**/test/**"),
"rspec": ("**/spec/**/*_spec.rb",),
"go": ("**/*_test.go",),
}
- Match on path segments and globs, never on substrings —
** crosses directory boundaries, *
does not, which is exactly the property finding 1 needs.
- Give
conftest.py its own flag (test support), so refactoring detectors can skip it without
search filing it away as a test.
- Where a subsystem deliberately deviates, say so in the code — as
extract_helper.py tried to,
but with a deviation the comment actually describes.
The migration can be done one call site at a time: add the module, convert one site, diff its old
answer against the new one over a corpus of paths. Where the answer changes, it is one of the
findings above. The three copies of _TEST_PATH_TOKENS fall out on the way.
Note on method
layers.py is the one implementation where the answer depends on which entry point you call, and it
is worth flagging for anyone reproducing this. Calling the private _is_test_file_name in isolation
makes it look as though layers.py misses the Maven and Jest layouts. It does not: that helper is
documented as looking at the filename alone, and infer_layer combines it with _is_test_dir_path,
which handles the directory conventions. The table above calls infer_layer(path) == "Test" for that
reason.
What was measured and what was not
Measured against a clean uv pip install repowise==0.36.0 in an isolated venv: every file:line
reference in this report (31, checked automatically against the source); the comparison table (the
listed predicates were imported and executed); the substring behaviour of the three affected sites;
the gap in the stored flag; which sites read the flag.
Not measured: coverage/detector.is_test_file also takes the file contents, so a path-only helper
cannot replace it as-is. Whether any of the nineteen needs a different answer for its own purpose
was not assessed per call site — the report shows that they differ and that three are wrong, not
that all nineteen must return the same thing. Two earlier drafts of this report contained mistakes
of exactly that kind, both found by executing the code rather than reading it.
Nineteen places decide "is this path a test?" from the path string, under six different names, over seventeen separate pattern definitions. They disagree with each other, three of them classify production code as tests, and sixteen of them re-derive an answer the ingestion already computed and stored.
This is not a tidiness issue: three call sites return wrong answers for real paths, and two of the disagreements change what the health engine and the search rank.
Version: 0.36.0, verified against a clean
uv pip install repowise==0.36.0in an isolated venv.Where the question is answered
core/ingestion/traverser.py:674_is_test_file(rel_path, filename)— this is the one that setsFileInfo.is_test(traverser.py:534)core/analysis/health/engine.py:88_is_test_file(rel_path)core/analysis/communities.py:395_is_test_file(path)core/analysis/health/coverage/detector.py:115is_test_file(rel_path, source)core/generation/layers.py:104,119_is_test_file_name+_is_test_dir_path, combined ininfer_layercore/analysis/health/refactoring/split_file.py:112_is_test_path(path)core/analysis/health/refactoring/move_method.py:55_is_test_path(path)core/analysis/health/refactoring/extract_helper.py:70_is_test_path(path)core/analysis/health/biomarkers/hidden_coupling.py:71_is_test_path(path)server/mcp_server/_answer_pipeline.py:201_is_test_path(target_path)server/mcp_server/tool_search_symbols.py:43_is_test_path(path)server/mcp_server/tool_search.py:366_TEST_PATH_TOKENS_TEST_PATH_TOKENSis defined three times, byte-identical, in_answer_pipeline.py:194,tool_search.py:331andtool_search_symbols.py:35.The full set
Grepping for
_is_test_*finds eleven. Grepping for the behaviour — any function or expressionthat classifies a path by test tokens — finds nineteen, spread over six names:
_is_test_fileingestion/traverser.py:674(setsFileInfo.is_test),analysis/health/engine.py:88,analysis/communities.py:395is_test_fileanalysis/health/coverage/detector.py:115_is_test_file_name/_is_test_dir_pathgeneration/layers.py:104,119, combined ininfer_layer_is_test_pathhealth/refactoring/{split_file,move_method,extract_helper}.py,health/biomarkers/hidden_coupling.py,mcp_server/_answer_pipeline.py,mcp_server/tool_search_symbols.py_looks_like_test_pathhealth/biomarkers/coverage_gap.py:74,health/biomarkers/coverage_gradient.py:46is_test_pathingestion/git_indexer/fix_shape.py:97,workspace/extractors/base.py:42Four more decide inline, without a named predicate:
mcp_server/tool_search.py:352(
_classify_hit_kind),server/routers/stats.py:545,cli/ui/repo_scanner.py:91,generation/onboarding/subkinds/development_guide.py:81,117.Seventeen separate pattern definitions back them:
_TEST_PATH_TOKENS(×3, byte-identical),_TEST_PATH_RE(×2, different),_TEST_DIR_SEGMENTS(×2, different), plus_TEST_DIRS,_TEST_FILE_RE,_TEST_FILE_PATTERNS,_TEST_DIR_TOKENS,_TEST_DIR_FRAGMENTS,_TEST_SUFFIXES,_TEST_BASENAME_PATTERNS,_TEST_FILE_PREFIXES,_TEST_FILE_SUFFIXES,_TEST_PATH_FRAGMENTS,_TEST_PATTERNS,_TEST_ROOTS.Worth noting: two of them are already called
is_test_path, public, iningestion/git_indexer/andworkspace/extractors/.workspace/extractors/base.pyis also theonly one that matches with
fnmatchon the basename and on path segments rather than onsubstrings — it gets the cases below right.
What they answer
Nine are importable and were run over the same paths.
layersis called through its real entrypoint
infer_layer(path) == "Test", not through its private halves.tests/app/tests.pyspec/models/user_spec.rbconftest.pysrc/latest/api.pyprotest/main.pysrc/test/java/Foo.javasrc/__tests__/x.jsapp/user_test.gosrc/Button.spec.tssrc/testing/helpers.pylib/contest.pyDisagreement on 5 of 11.
Findings
1. Three places classify production code as tests
test[s_/]is meant to catchtest/andtests/, but it is not anchored to a path segment, so italso fires on any segment ending in "test" followed by a separator.
src/latest/api.pyandprotest/main.pyare classified as test files and are pulled out of community assignment(
_assign_tests_to_communities).The same substring check appears in two more places, with the same effect:
Verified against 0.36.0:
protest/main.py,src/latest/api.pyandcontest/x.pyare counted astests by all three. In
repo_scannerthat inflates the test-file count shown duringinit; indevelopment_guideit drops those files out of the generated guidance.2.
tests/app/tests.py— five say test, four say notThe layout Django's own tutorial produces. It is skipped by three refactoring detectors and by
community assignment, but returned as an ordinary hit by both MCP search paths and not excluded by
the health engine.
Two of the implementations state in their own docstrings that they are meant to match the others:
extract_helper.pyanswersnofor this path;split_file.pyanswersyes. The copies have driftedapart from the thing they claim to mirror.
3. RSpec layout is recognised by exactly one of nine
spec/models/user_spec.rbis only classified as a test byinfer_layer. RSpec's convention isspec/**/*_spec.rb, so in Ruby projects the health engine, both refactoring detectors, thebiomarker and both MCP search paths treat specs as production code.
4.
conftest.py— defensible either way, argued nowhereFour say test, five say not. Strictly, pytest does not collect
conftest.pyas a test module: it is aper-directory plugin providing fixtures. For a refactoring detector, treating test infrastructure
like a test is equally defensible. Both readings are reasonable; what is missing is that either is
stated anywhere, so the same file is treated differently depending on which tool asks.
5. Sixteen of them re-derive a decision that is already stored
traverser.py:534computesis_testper file during ingestion and stores it onFileInfo(
core/ingestion/models.py:132). It is carried into the graph (graph/builder.py:187) andpropagated onto production files reached by tests (
graph/_edges.py:102).That flag is the established mechanism, not an unused field. Roughly a dozen consumers read it:
analysis/dead_code/analyzer.py(three sites),analysis/knowledge_graph.py,analysis/pr_blast.py,ingestion/framework_edges/pytest_edges.py,generation/page_generator/helpers.py,generation/context/assembler.py,pipeline/persist.py,persistence/crud/graph.py.The split is between subsystems that hold a graph node or a
FileInfoand use the flag, andsubsystems that hold only a path string and re-derive. Of the nineteen path-based sites, three do
use the stored value:
The remaining sixteen do not.
6. The stored flag has a real gap, and one call site documents it
stats.pyreads the flag and applies its own pattern, and says why:Measured against 0.36.0, the comment is partly outdated and partly correct.
conftest.py,spec/spec_helper.rb,spec/models/user_spec.rbandtests/fixtures/sample.jsonare all caught bythe flag today. These are not:
traverser._is_test_filefixtures/data.ymlsrc/__fixtures__/x.tstestdata/golden.jsonFixture and golden-data directories outside a test tree are missed. This matters for the shape of a
fix: "just read the stored flag" would make those call sites worse, not better. The order has to be
the other way round — close the gap in the one authoritative computation first, then migrate the
callers onto it.
stats.pyis the counter-example to the pattern in this report, not an instance of it: it uses thestored decision, knows where it falls short, and says so in the code.
Why it matters
A single file is a test and not a test at the same time, depending on which subsystem asks. Health
scores, dead-code findings, refactoring suggestions, community assignment and search ranking each
use their own answer. The three verbatim copies of
_TEST_PATH_TOKENSmean a fix to one search pathsilently leaves the other two behind.
The existing answer to this does not scale
tests/unit/health/test_mts_cts_test_classification.pyshows how this class of problem was handledbefore: ".mts/.cts test files must be classified as tests across health logic (#288)". It imports
four implementations by name and asserts each one separately:
(That file also surfaces a twelfth implementation I had not listed:
coverage_gap._looks_like_test_path.)The cost of that approach grows with every incident and every copy:
.mts/.cts) and says nothing about the five disagreements above;A single source inverts that. Adding an ecosystem becomes one row in the pattern table, and if the
test is parametrised over that table rather than over the call sites, the new row brings its own
coverage with it — the property that makes table-driven tests worth having: "once the test function
exists, adding a new scenario is a single row of data".
The "should there be a thirteenth copy" question is then answerable by the build rather than by
review. A rule that fails when a new private
_is_test_*helper appears outside the shared module isan architecture fitness function in the usual sense — "an objective, automated check that a given
architectural characteristic still holds … converts a rule you hope people follow into a rule the
build enforces" — and in Python it is what
import-linteris normally used for.Suggested shape
One decision, one helper.
traverser._is_test_file(fixture and golden-data directories outside atest tree), so the stored flag can carry the weight. Then, where a
FileInfoor a graph node isin hand, read
is_test— as a dozen consumers already do.**crosses directory boundaries,*does not, which is exactly the property finding 1 needs.
conftest.pyits own flag (test support), so refactoring detectors can skip it withoutsearch filing it away as a test.
extract_helper.pytried to,but with a deviation the comment actually describes.
The migration can be done one call site at a time: add the module, convert one site, diff its old
answer against the new one over a corpus of paths. Where the answer changes, it is one of the
findings above. The three copies of
_TEST_PATH_TOKENSfall out on the way.Note on method
layers.pyis the one implementation where the answer depends on which entry point you call, and itis worth flagging for anyone reproducing this. Calling the private
_is_test_file_namein isolationmakes it look as though
layers.pymisses the Maven and Jest layouts. It does not: that helper isdocumented as looking at the filename alone, and
infer_layercombines it with_is_test_dir_path,which handles the directory conventions. The table above calls
infer_layer(path) == "Test"for thatreason.
What was measured and what was not
Measured against a clean
uv pip install repowise==0.36.0in an isolated venv: every file:linereference in this report (31, checked automatically against the source); the comparison table (the
listed predicates were imported and executed); the substring behaviour of the three affected sites;
the gap in the stored flag; which sites read the flag.
Not measured:
coverage/detector.is_test_filealso takes the file contents, so a path-only helpercannot replace it as-is. Whether any of the nineteen needs a different answer for its own purpose
was not assessed per call site — the report shows that they differ and that three are wrong, not
that all nineteen must return the same thing. Two earlier drafts of this report contained mistakes
of exactly that kind, both found by executing the code rather than reading it.