Skip to content

Linter Rule: Report render calls whose partial does not exist #2070

Description

@marcoroth

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    linter@herb-tools/linter for HTML+ERB templateslinter-ruleIndividual linter rules and their documentation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions