Skip to content

feat(twig): add Craft-tolerant Twig AST parser + phpunit harness - #1

Open
Jensderond wants to merge 18 commits into
masterfrom
claude/perf-branch-review-b7offx
Open

feat(twig): add Craft-tolerant Twig AST parser + phpunit harness#1
Jensderond wants to merge 18 commits into
masterfrom
claude/perf-branch-review-b7offx

Conversation

@Jensderond

Copy link
Copy Markdown
Owner

Jensderond and others added 18 commits June 30, 2026 21:35
The ForNode branch in TwigPerformanceAnalyzer::walk() pushed the loop's
frame onto loopStack before walking the loop's own children, including
seq (the source expression). This caused checkQueryInLoop() to falsely
flag the loop's source query as running inside the loop, even though it
executes once before iteration begins — spuriously flagging the most
common Craft Twig idiom, e.g.:
  {% for entry in craft.entries.section('news').all() %}...{% endfor %}

Fix: walk seq in the outer loop context before pushing this loop's
frame, then push the frame and walk the remaining children. This is
still correct for nesting — a query used as a nested loop's source is
walked with the outer frame still on the stack, so it is still flagged.

Adds two regression tests asserting absence (top-level loop source not
flagged) and presence (nested loop source still flagged).

Claude-Session: https://claude.ai/code/session_01CNFGL89Pp8zKRn6ajL5rSg
- Add TwigPerformanceRule, a Rule<CollectedDataNode> that scans configured
  template paths and reports Finding objects from TwigPerformanceAnalyzer as
  PHPStan RuleErrors. Exposes a public scanTemplates() seam for unit testing
  without a full PHPStan kernel.
- Wire craftTwigPerformance parameters/parametersSchema and the
  TwigTemplateParser / RelationFieldRegistry / TwigPerformanceAnalyzer /
  TwigPerformanceRule services into extension.neon, sharing single instances
  via service references.
- Add nplusone.twig / clean.twig fixtures and TwigPerformanceRuleTest.

Claude-Session: https://claude.ai/code/session_01CNFGL89Pp8zKRn6ajL5rSg
Declare twig/twig ^3.27 as a runtime dependency: src/Twig/* binds to
Twig's node API but it was only present transitively via the craftcms/cms
dev dependency. Bump craftcms/cms to ^5.10.8 (which ships twig ~3.27.0) so
the dev environment matches the declared floor, and update the node-helper
comment to reflect the verified Twig version.

Document the result-cache gap: the Twig checks scan templates outside
PHPStan's per-file analysis, so a warm result cache can serve stale Twig
findings when only templates change. README now recommends a cold cache
(CI) or clear-result-cache after editing templates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PgpX7sHMRT3keuAe8XhkBA
…, ensure checks always run

Three related fixes to how the Twig checks are delivered:

- Shared traversal: extract TwigTemplateScanner + ScannedTemplate so both
  TwigActionInputRule and TwigPerformanceRule walk the configured template
  tree once and read/parse each .twig file at most once (lazily), instead of
  each running its own RecursiveDirectoryIterator.

- Result-cache correctness: add TwigTemplateCacheMetaExtension (a
  ResultCacheMetaExtension) that hashes every discovered template's
  path + contents into PHPStan's result-cache key. Template edits, additions
  and removals now invalidate the cache and force re-analysis, so the
  out-of-band Twig scanning can no longer serve stale findings.

- Reliable execution: add AnalysisMarkerCollector. The Twig rules piggyback
  on the CollectedDataNode hook, which PHPStan's AnalyserResultFinalizer skips
  entirely when no collector produced any data. Since the only other collector
  emits nothing unless an analysed file defines a yii\web\Controller subclass
  with action* methods, a project without such a controller in scope would
  silently skip every Twig check. This marker emits one datum per analysed
  file, keeping the CollectedDataNode rules reliably reachable.

Rules, identifiers, tips and config toggles are unchanged. Tests cover the
scanner (discovery, dedup, stable order, laziness), the cache meta hash
(changes on edit/add/remove, stable key) and the marker collector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PgpX7sHMRT3keuAe8XhkBA
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PgpX7sHMRT3keuAe8XhkBA
The N+1 check analyses each template in isolation, so it assumed a loop
source with no visible .with() was un-eager-loaded. That produced false
positives on navigation partials (node.twig, footer.twig, careers nav)
where the elements arrive pre-eager-loaded from a parent layout.

- resolveEagerLoaded(): loop sources rooted at a variable not traceable
  in this template (supplied across {% include %}/{% embed %}) now yield
  null ("unknown"), which suppresses the N+1 check instead of assuming
  none. Queries we can read (craft.*, .find()/.relatedTo(), {% set %}
  vars, enclosing loop elements) still trust an empty set and flag.
- unwrapFallback(): see through the `craft.…all() ?? []` idiom
  (NullCoalesceBinary on Twig >=3.16, ConditionalExpression on older).
- checkNestedRelationAll() made eager-aware for consistency.
.with(['children.children.children']) was stored as a single key, so the
suppression check isset($eager['children']) never matched and the nesting
never propagated into child loops — flagging eager-loaded relations at every
level (navigation menu pattern).

- isEagerLoaded(): a relation is covered when it is the head segment of any
  eager path, not only an exact key.
- resolveEagerLoaded(): descend the enclosing loop's eager paths through the
  relation(s) accessed, so a deep path keeps covering each nesting level while
  relations beyond its depth still flag. An explicit .with() on the source
  overrides the inherited state.
- descendEagerPaths()/relationSegments() helpers.
The 1.4.0 version bump edited composer.json without refreshing the lock
file's content-hash, so a clean 'composer install' aborted with 'Your
lock file does not contain a compatible set of packages'. The resolved
dependency set was still valid; only the hash needed regenerating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01551h5FzhM7dza9reEzHMoK
Two loop-context defects in TwigPerformanceAnalyzer:

- checkChain() compared relation chains only against the innermost loop
  frame, so an N+1 on an outer loop variable inside a nested loop was
  never flagged (e.g. entry.author inside a loop over sizes). It now
  resolves the chain root against every active frame via
  elementLoopFrame(), which is additionally shadow-aware: an inner loop
  rebinding the same variable name to plain literal values masks the
  outer element binding instead of being skipped over.

- The {% else %} branch of a for loop runs at most once (only when the
  sequence is empty), but it was walked with the loop frame still on the
  stack, flagging queries there as per-iteration queries. It is now
  walked after the frame is popped.

Also removes the unused isActiveElementLoopVar() helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01551h5FzhM7dza9reEzHMoK
…er sharing

The README's result-cache section predated TwigTemplateCacheMetaExtension
and still told users to run clear-result-cache manually after template
edits; the meta extension makes that invalidation automatic. Also note
the twig/twig ^3.27 floor against Craft's pinned Twig minor, and correct
the scanner docblock: templates are read lazily at most once per
consumer, not once globally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01551h5FzhM7dza9reEzHMoK
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants