Now that the partial index resolves a render to a file (#2058, #2061, #2062), the linter can answer a question it previously could not: does the partial this call names actually exist?
<%= render "posts/crad" %>
Nothing reports that today. PartialIndex#lookup returns null, every rule that depends on resolution quietly does nothing, and the typo surfaces at runtime as ActionView::MissingTemplate. actionview-no-strict-locals-error and the strict locals checks all resolve first and skip when resolution fails, so an unresolvable partial is currently a hole that swallows the rules built on top of it.
This is the natural completion of that set, and it is the one that catches typos, partials deleted without their callers, and partials moved between directories.
Why this is not just "invert the null check"
The index has exactly one view root:
const VIEW_ROOT_CANDIDATE = "app/views"
const PROJECT_ROOT = "."
export async function findViewRoot(projectPath: string): Promise<string> {
const matches = await partialsIn(projectPath, VIEW_ROOT_CANDIDATE)
return matches.length > 0 ? VIEW_ROOT_CANDIDATE : PROJECT_ROOT
}
Rails has many. lookup_context.view_paths accumulates entries from mounted engines, from gems that ship views, and from any controller calling prepend_view_path or append_view_path. A rule that reports "this partial does not exist" against a single-root index reports a false positive on every one of them:
<%= render "devise/shared/links" %>
<%= render "kaminari/paginator" %>
<%= render "layouts/engine_header" %>
None of those live under the host app's app/views, and all three resolve fine at runtime. This is not a tail case. A false "this file does not exist" on a call that works is the most expensive kind of wrong a linter can be, because the obvious fix is to delete a working call.
So the prerequisite is real work, not a guard clause:
PartialIndex needs to hold an ordered list of roots rather than one viewRoot string, and resolvePartial needs to try them in precedence order. The existing resolution order (exact name, then relative to the rendering template's directory, then application/) stays, applied per root.
partialNameForFile and outranksTemplate need to keep working when the same partial name exists in two roots, where the winner is decided by root precedence first and variant rank second.
- Something has to supply the non-app roots. The JavaScript linter cannot ask Bundler where the Devise gem's views live. The plausible options are a
.herb.yml setting, having the Ruby CLI supply the paths since it can ask Rails directly, or both, and that choice is worth settling before any rule work starts.
expectedPartialPaths
The rule needs to say what it looked for, not just that it failed. That wants a function in core that is currently missing:
expectedPartialPaths(partialName: string, sourceFile: string, viewRoots: string[]): string[]
Given "card" rendered from app/views/posts/index.html.erb, it returns the paths Rails would try, in order:
app/views/posts/_card.html.erb
app/views/application/_card.html.erb
It is the inverse of resolvePartial and shares its precedence rules, so the two should live next to each other and be tested against each other. Three things want it:
- this rule's message, so it can list where it looked
- a "create this partial" code action in the language server, which needs a path to create and currently has none
- hover on an unresolvable partial, which today explains why it cannot resolve the name but cannot say what file would satisfy it
Now that the partial index resolves a
renderto a file (#2058, #2061, #2062), the linter can answer a question it previously could not: does the partial this call names actually exist?Nothing reports that today.
PartialIndex#lookupreturnsnull, every rule that depends on resolution quietly does nothing, and the typo surfaces at runtime asActionView::MissingTemplate.actionview-no-strict-locals-errorand the strict locals checks all resolve first and skip when resolution fails, so an unresolvable partial is currently a hole that swallows the rules built on top of it.This is the natural completion of that set, and it is the one that catches typos, partials deleted without their callers, and partials moved between directories.
Why this is not just "invert the
nullcheck"The index has exactly one view root:
Rails has many.
lookup_context.view_pathsaccumulates entries from mounted engines, from gems that ship views, and from any controller callingprepend_view_pathorappend_view_path. A rule that reports "this partial does not exist" against a single-root index reports a false positive on every one of them:None of those live under the host app's
app/views, and all three resolve fine at runtime. This is not a tail case. A false "this file does not exist" on a call that works is the most expensive kind of wrong a linter can be, because the obvious fix is to delete a working call.So the prerequisite is real work, not a guard clause:
PartialIndexneeds to hold an ordered list of roots rather than oneviewRootstring, andresolvePartialneeds to try them in precedence order. The existing resolution order (exact name, then relative to the rendering template's directory, thenapplication/) stays, applied per root.partialNameForFileandoutranksTemplateneed to keep working when the same partial name exists in two roots, where the winner is decided by root precedence first and variant rank second..herb.ymlsetting, having the Ruby CLI supply the paths since it can ask Rails directly, or both, and that choice is worth settling before any rule work starts.expectedPartialPathsThe rule needs to say what it looked for, not just that it failed. That wants a function in core that is currently missing:
Given
"card"rendered fromapp/views/posts/index.html.erb, it returns the paths Rails would try, in order:It is the inverse of
resolvePartialand shares its precedence rules, so the two should live next to each other and be tested against each other. Three things want it: