Skip to content

refactor: extract shared predicates, swap CLI to oxc-walker#757

Merged
harlan-zw merged 15 commits into
mainfrom
refactor/predicate-extraction
Apr 27, 2026
Merged

refactor: extract shared predicates, swap CLI to oxc-walker#757
harlan-zw merged 15 commits into
mainfrom
refactor/predicate-extraction

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Apr 26, 2026

Copy link
Copy Markdown
Collaborator

Linked issue

N/A — follow-up to #755.

Type of change

  • Documentation
  • Bug fix
  • Enhancement
  • New feature
  • Chore
  • Breaking change

Description

Two problems addressed in one pass:

  1. unhead audit was unusable on TS / Vue / Svelte projects. packages/cli/src/lint.ts built an ESLint config without a parser, so every .ts / .vue file failed with Parsing error: Unexpected token <. On a real Nuxt project (~hundreds of files) this produced 600+ parse errors and zero useful diagnostics.

  2. Per-rule logic was triplicated across unhead/plugins/ValidatePlugin, @unhead/eslint-plugin rule modules, and @unhead/cli — drift was inevitable.

Fix:

  • Extracts each source-level rule as a parser-agnostic TagPredicate / HeadInputPredicate in unhead/validate/predicates/. Pure functions: TagInput → Diagnostic[] with optional source-agnostic PredicateFix shapes (rename-prop, replace-prop-value, insert-after-prop, remove-prop, wrap-tag).
  • @unhead/eslint-plugin rule modules become ~15-line adapters: ESTree → TagInput via new materialize.ts, predicate dispatch via new createPredicateRule.ts, fix translation via new applyDiagnostic.ts. Bundle drops 21 KB → 16 KB. All 42 existing tests pass (assertions migrated from messageId to message regex since predicates emit raw strings).
  • @unhead/cli swaps the ESLint backend for oxc-parser + oxc-walker + magic-string. Vue / Svelte SFCs handled via a small regex-based <script> extractor that preserves byte offsets for accurate file-level line/column reporting. Drops eslint and @unhead/eslint-plugin deps. New oxc/ subdir mirrors the eslint-plugin's helper layout. New format.ts produces stylish-ish output without bringing ESLint along.
  • ValidatePlugin refactored to dispatch through the same predicates for the per-tag rules it owned inline. New tagInputFromRuntime / titleInputFromRuntime adapters in unhead/validate/predicates/runtime.ts handle the runtime ↔ source impedance mismatch (name lowercasing, content null-coercion, innerHTML/textContent keys, top-level tagPriority lift). Cross-tag and content-size checks (canonical-og-url-mismatch, too-many-preloads, meta-beyond-1mb, render-blocking-script, …) stay inline since they need cross-tag context. Plugin shrinks 566 → 513 lines, all 121 existing tests pass without modification.
  • Fixes the broken import unhead from '@unhead/eslint-plugin' example in the eslint-plugin README (configs is a named export, not on the default).

Verification

Tested against a real Nuxt project (~hundreds of files, mixed .ts / .vue / .tsx):

  • Before: 602 parse errors in ~3s, zero useful output.
  • After: clean run in ~0.4s. Synthetic violations report accurate line/column for both .ts and .vue sources. migrate round-trip rewrites children / hid / body / redundant defer / unprefixed Twitter handles in a single MagicString pass.

42 new predicate unit tests + 13 new CLI audit/migrate tests (incl. a Vue SFC fixture). Full test suite green: 799 unhead, 42 eslint-plugin, 13 CLI.

What stayed inline in ValidatePlugin

canonical-og-url-mismatch, og-image-missing-dimensions, og-missing-{title,description}, missing-{title,description}, too-many-preloads, too-many-preconnects, too-many-fetchpriority-high, redundant-dns-prefetch, preload-async-defer-conflict, prefetch-preload-conflict, duplicate-resource-hint, charset-not-early, preload-not-modulepreload, preconnect-missing-crossorigin, meta-beyond-1mb, non-absolute-og-url, render-blocking-script, inline-script-size, inline-style-size, unresolved-template-param, empty-title, missing-template-params-plugin, missing-alias-sorting-plugin, deprecated-option-mode — all need cross-tag, render-order, or content-size context that doesn't fit a single-tag predicate signature.

Summary by CodeRabbit

  • New Features
    • CLI audit/migrate now uses a native parser with SFC <script> extraction, outputs ESLint-style grouped/colorized diagnostics, and can apply safe file-level migrations (dry-run supported).
  • Refactor
    • Validation moved to shared predicate-driven logic so CLI and linting produce consistent diagnostics and fixes.
  • Tests
    • Expanded unit and integration tests covering audit/migrate, SFC extraction, and validation predicates.
  • Documentation
    • READMEs updated to document CLI behavior and shared validation usage.

… CLI to oxc-walker

Per-rule logic was previously duplicated across three sites: the runtime
ValidatePlugin, @unhead/eslint-plugin rule modules, and @unhead/cli.
The CLI's audit was also unusable on real projects — it built an ESLint
config without parsers, so every .ts/.vue file failed with a parse error.

This change extracts each rule's predicate as a parser-agnostic pure
function in unhead/validate, then makes all three layers thin adapters
that materialise their AST into a TagInput and call the predicate.

unhead/validate
- Add predicates/ module: 14 pure TagPredicate / HeadInputPredicate
  functions, one per existing source-level rule. Each takes a
  parser-agnostic TagInput and returns Diagnostic[] with optional
  PredicateFix descriptions.
- Add runtime adapter (tagInputFromRuntime, titleInputFromRuntime) so
  the runtime ValidatePlugin can call the same predicates against
  resolved HeadTags. Handles name lowercasing, content null-coercion,
  innerHTML/textContent surfacing, top-level tagPriority lift.
- Add 'prefer-define-helpers' to VALIDATION_RULE_IDS for the
  migration-only predicate.

@unhead/eslint-plugin
- Each rule module becomes a ~15-line adapter: import predicate, wrap
  with createTagPredicateRule / createHeadInputPredicateRule.
- New utils/materialize.ts (ESTree ObjectExpression → TagInput),
  utils/applyDiagnostic.ts (Diagnostic → ctx.report), and
  utils/createPredicateRule.ts.
- Bundle drops from 21 KB to 16 KB. Existing tests migrated from
  messageId assertions to message regex (predicates emit raw strings,
  not message templates).
- README: fix broken default-import example (configs is a named export).

@unhead/cli
- Drop @unhead/eslint-plugin and eslint dependencies entirely. Replace
  with oxc-parser + oxc-walker + magic-string.
- New oxc/ subdir: walker, materialize, applyFix, audit, sfc (regex
  <script> extractor that preserves byte offsets for Vue/Svelte SFCs).
- New format.ts produces stylish-ish output without pulling in ESLint.
- Audit on a real Nuxt project (~hundreds of files) runs in ~0.4s with
  zero parse errors instead of 600+. Vue SFCs report file-relative
  line/column. Migrate produces identical output to the eslint-plugin's
  --fix path through shared PredicateFix shapes.
- README updated: notes oxc-based parsing, drops ESLint references.

unhead/plugins/validate
- Refactor ValidatePlugin to dispatch through shared predicates for the
  per-tag rules it owned inline (empty-meta-content, robots-conflict,
  viewport-user-scalable, twitter-handle-missing-at, possible-typo,
  html-in-title, preload-font-crossorigin, preload-missing-as,
  script-src-with-content, defer-on-module-script, deprecated-prop-*,
  numeric-tag-priority, non-absolute-canonical).
- Cross-tag, content-size, and runtime-only checks (canonical-og-url-
  mismatch, render-blocking-script, charset-not-early, too-many-*,
  meta-beyond-1mb, …) stay inline since they need cross-tag context.
- PREDICATE_SEVERITY map preserves the legacy 'info' severities for
  defer-on-module-script, viewport-user-scalable, numeric-tag-priority.
- Plugin shrinks from 566 to 513 lines. All 121 ValidatePlugin tests
  pass without modification (assertions check rule.id).

Tests
- 42 new predicate unit tests against plain TagInput fixtures.
- 13 new CLI audit/migrate tests including a Vue SFC fixture.
- All existing tests pass: 799 unhead, 42 eslint-plugin, 13 CLI.
@coderabbitai

coderabbitai Bot commented Apr 26, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces the ESLint-based CLI lint pipeline with an oxc-parser–based audit/migrate pipeline; extracts validator logic into predicate modules under unhead/validate; adapts ESLint plugin rules to call those predicates via predicate→ESLint adapters; adds materializers, SFC extraction, MagicString-based fix application, CLI formatters, and tests.

Changes

Cohort / File(s) Summary
CLI docs & deps
packages/cli/README.md, packages/cli/package.json
Docs updated to describe oxc-parser-based source linting; eslint removed; added oxc-parser, oxc-walker, magic-string.
CLI commands & format
packages/cli/src/commands/audit.ts, packages/cli/src/commands/migrate.ts, packages/cli/src/format.ts
Swap ESLint runLintrunAudit flow; add formatStylish; migrate writes per-file MagicString output, dry-run handling, and summarise-based exit codes.
CLI oxc pipeline
packages/cli/src/oxc/audit.ts, .../walker.ts, .../materialize.ts, .../sfc.ts, .../applyFix.ts
New audit/migrate pipeline: SFC <script> extraction, parsing, AST walking, materialization to predicate inputs, diagnostics→fix mapping, and MagicString fix applier.
Removed CLI lint orchestrator
packages/cli/src/lint.ts
Removed old ESLint-based runLint, types, and formatResults.
CLI tests & fixtures
packages/cli/test/audit.test.ts, packages/cli/test/sfc.test.ts, packages/cli/test/fixtures/*, packages/cli/test/lint.test.ts
Added audit/migrate and SFC tests and fixtures (bad.vue, clean.ts, migrate-input.ts); removed old runLint test content.
ESLint plugin rules refactor
packages/eslint-plugin/src/rules/*.ts
Many rules replaced local visitor/fix logic with predicate-driven factories using unhead/validate predicates; per-rule meta.messages removed where predicates supply messaging.
ESLint plugin utilities
packages/eslint-plugin/src/utils/applyDiagnostic.ts, .../createPredicateRule.ts, .../materialize.ts, .../visitor.ts
Add predicate→ESLint adapters: materializers, diagnostic→ctx.report fixer builders, predicate rule factories, and inArray visitor info.
unhead validate: predicates & types
packages/unhead/src/validate/predicates/*, .../types.ts, .../index.ts, packages/unhead/src/validate/index.ts, packages/unhead/src/validate/rules.ts
Add predicate types and ~13 predicates (e.g., emptyMetaContent, noDeprecatedProps, twitterHandleMissingAt, preloadMissingAs, preferDefineHelpers, numericTagPriority, viewportUserScalable), aggregate exports (tagPredicates, migrationTagPredicates, headInputPredicates), and re-export predicates from validate.
Runtime plugin wiring
packages/unhead/src/plugins/validate.ts
Runtime validation now dispatches many checks to the predicate set via a PREDICATE_SEVERITY mapping, removing numerous inline checks.
Predicate tests & export manifest
packages/unhead/test/unit/predicates.test.ts, test/exports/unhead.yaml
Add comprehensive predicate unit tests and extend ./validate export manifest for new predicate APIs and runtime adapters.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as CLI (unhead)
  participant FS as File System
  participant Parser as oxc-parser
  participant Walker as oxc-walker
  participant Validator as unhead/validate
  participant Formatter as formatStylish

  CLI->>FS: glob patterns -> file list
  loop per file
    CLI->>FS: read file
    FS-->>CLI: source
    CLI->>Parser: parse (with SFC extraction)
    Parser-->>CLI: AST + script pieces
    CLI->>Walker: walk AST -> head/tag nodes
    Walker-->>CLI: nodes
    CLI->>Validator: run predicates (audit or migrate)
    Validator-->>CLI: diagnostics (+ optional fixes/output)
    CLI->>Formatter: format diagnostics
    Formatter-->>CLI: formatted output
    alt migrate & output present
      CLI->>FS: write migrated output
      FS-->>CLI: write result
    end
  end
  CLI->>CLI: set exit code via summarise(results)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through ASTs at break of day,
Predicates whispered what tags shouldn't say,
MagicString stitched fixes neat and quick,
ESLint let oxc take the lead — so slick!
One rabbit giggled: diagnostics tidy, hooray! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'refactor: extract shared predicates, swap CLI to oxc-walker' accurately and concisely summarizes the main change: extracting validation predicates and switching the CLI to use oxc-walker instead of ESLint.
Description check ✅ Passed The PR description comprehensively addresses the template requirements with all key sections: linked issue (N/A — follow-up), type of change (bug fix, enhancement), and detailed description explaining the problems, solutions, verification, and what stayed inline.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/predicate-extraction

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The exports-snapshot test asserts the public surface of every package.
Since the predicates module adds 17 new named exports to unhead/validate,
regenerate the snapshot. No behaviour change.

The bad.vue test fixture is intentionally invalid (it triggers
preload-font-crossorigin et al.) but vue-tsc was checking it during the
typecheck script. Add @ts-nocheck so the fixture only exercises the CLI's
parser path, not the project's typecheck.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/cli/README.md (1)

97-100: ⚠️ Potential issue | 🟡 Minor

Soften "byte-for-byte identical" claim.

CLI output goes through formatStylish and ESLint output goes through whatever formatter is configured in the consumer's pipeline; the rule IDs and messages will match, but the rendered diagnostic stream won't be byte-for-byte equal. Suggest "produce the same diagnostics" or "report identical rule IDs and messages."

📝 Suggested wording
-`audit` and `migrate` invoke the same predicate functions exported from `unhead/validate` that `@unhead/eslint-plugin` registers as ESLint rules. Source-level diagnostics are byte-for-byte identical between `unhead audit` (CLI) and `pnpm lint` (your editor + CI ESLint pipeline). Use the CLI for one-shot project-wide audits and CI; use the ESLint plugin for inline editor feedback.
+`audit` and `migrate` invoke the same predicate functions exported from `unhead/validate` that `@unhead/eslint-plugin` registers as ESLint rules. The same rule IDs, messages, and fixes are produced by both `unhead audit` (CLI) and `pnpm lint` (your editor + CI ESLint pipeline). Use the CLI for one-shot project-wide audits and CI; use the ESLint plugin for inline editor feedback.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/README.md` around lines 97 - 100, The README's claim that
outputs are "byte-for-byte identical" is too strong; update the paragraph
referencing `audit`, `migrate`, `unhead/validate`, and `@unhead/eslint-plugin`
to say the CLI and ESLint plugin "produce the same diagnostics" or "report
identical rule IDs and messages" instead, and note that CLI output passes
through `formatStylish` while editor/CI uses the consumer's formatter so the
rendered streams may differ; replace the phrase "byte-for-byte identical" with
one of the suggested softer phrasings and add a brief note about formatter
differences.
🧹 Nitpick comments (20)
packages/unhead/src/validate/predicates/non-absolute-canonical.ts (1)

3-5: Optional: case-insensitive scheme check.

HTTP:///HTTPS:// (uppercase scheme) is technically a valid absolute URL but would currently be flagged as non-absolute. Edge case, but trivially addressed:

-function isAbsolute(url: string): boolean {
-  return url.startsWith('http://') || url.startsWith('https://')
-}
+function isAbsolute(url: string): boolean {
+  return /^https?:\/\//i.test(url)
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/validate/predicates/non-absolute-canonical.ts` around
lines 3 - 5, The isAbsolute function currently only checks for 'http://' and
'https://' in lowercase, so URLs with uppercase schemes like 'HTTP://' will be
misclassified; update the isAbsolute(url: string) implementation (function name:
isAbsolute) to perform a case-insensitive scheme check—either by lowercasing the
url before startsWith checks or by using a case-insensitive regex (e.g.,
matching /^https?:\/\//i) so both 'HTTP://' and 'http://' are treated as
absolute.
packages/unhead/src/validate/predicates/preload-rules.ts (1)

17-32: Minor: case-sensitive matching on rel and as.

tag.props.rel !== 'preload' and tag.props.as !== 'font' won't fire on rel="Preload" or as="FONT". HTML attribute values for these are conventionally lowercase, so this is unlikely to bite users in practice, but worth noting if other predicates in this directory normalize casing.

Also, the fix.insert value begins with , and relies on the consumer (applyFix) inserting it directly after the existing as property's value position. Worth a brief comment on the predicate type or fix shape so future predicate authors don't double up the separator.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/validate/predicates/preload-rules.ts` around lines 17 -
32, The predicate preloadFontCrossorigin does strict case-sensitive checks on
tag.props.rel and tag.props.as and uses a fix.insert that starts with ", " which
couples it to the consumer; update the predicate to normalize the attribute
values (e.g., coerce tag.props.rel and tag.props.as to lower-case strings before
comparison) so rel="Preload" or as="FONT" match, and change the fix.insert to
not include a leading separator (e.g., use "crossorigin: 'anonymous'") so
applyFix can decide how to insert separators; keep references to
preloadFontCrossorigin and the fix.insert field when making these edits.
packages/unhead/src/validate/predicates/empty-meta-content.ts (1)

10-12: Include http-equiv in the identifier fallback for better messages.

For tags like <meta http-equiv="refresh" content="">, the diagnostic currently reads Meta tag "meta" has empty content. — using http-equiv as a key candidate would produce a more useful message.

♻️ Proposed change
   const key = (typeof tag.props.name === 'string' && tag.props.name)
     || (typeof tag.props.property === 'string' && tag.props.property)
+    || (typeof tag.props['http-equiv'] === 'string' && tag.props['http-equiv'])
     || 'meta'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/validate/predicates/empty-meta-content.ts` around lines
10 - 12, The identifier fallback for empty-meta content uses only tag.props.name
and tag.props.property, causing poor messages for meta tags using http-equiv;
update the const key expression in empty-meta-content.ts (the const named key)
to also check for a string-valued tag.props['http-equiv'] (e.g., || (typeof
tag.props['http-equiv'] === 'string' && tag.props['http-equiv']) ) before
falling back to 'meta' so diagnostics show the http-equiv value when present.
packages/eslint-plugin/test/rules.test.ts (1)

22-154: Regex-based message assertions look reasonable.

The patterns are specific enough to avoid cross-rule collisions. Note that this couples tests to the predicate's exact wording — if the message strings in packages/unhead/src/validate/predicates/* are reworded later, these tests will need updating in lockstep. Consider exporting the predicate ruleId constants and asserting on those (or on a stable substring) for resilience.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/eslint-plugin/test/rules.test.ts` around lines 22 - 154, Tests
currently assert error messages with regexes which are brittle; instead import
the stable rule identifier constants from the corresponding predicate modules
(e.g. the predicate modules under packages/unhead/src/validate/predicates that
define twitterHandleMissingAt, robotsConflict, deferOnModuleScript, etc.) and
replace the regex-based errors assertions with errors: [{ ruleId: THE_RULE_ID }]
(or assert on a stable substring constant exported by the predicate) so tests
assert on the exported ruleId symbol rather than the full message text.
packages/unhead/src/validate/predicates/robots-conflict.ts (1)

11-25: Consider handling none / all shorthand directives.

Per the robots meta spec, none is shorthand for noindex, nofollow and all for index, follow. Combinations like none, index or all, noindex are real-world conflicts that this predicate currently misses. Low priority for this PR, but worth tracking.

♻️ Sketch
   const directives = content.toLowerCase().split(',').map(d => d.trim())
+  const hasIndex = directives.includes('index') || directives.includes('all')
+  const hasNoindex = directives.includes('noindex') || directives.includes('none')
+  const hasFollow = directives.includes('follow') || directives.includes('all')
+  const hasNofollow = directives.includes('nofollow') || directives.includes('none')
   const out: Diagnostic[] = []
-  if (directives.includes('index') && directives.includes('noindex')) {
+  if (hasIndex && hasNoindex) {
     out.push({ ... })
   }
-  if (directives.includes('follow') && directives.includes('nofollow')) {
+  if (hasFollow && hasNofollow) {
     out.push({ ... })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/validate/predicates/robots-conflict.ts` around lines 11 -
25, The predicate currently checks the raw directives array (variable
directives) for conflicting pairs but ignores shorthand tokens; update the logic
to expand 'none' → ['noindex','nofollow'] and 'all' → ['index','follow'] into
the directives set before conflict checks. Concretely, compute a normalized Set
from directives (expanding any 'none'/'all' entries), then test for the
conflicting pairs ('index' vs 'noindex', 'follow' vs 'nofollow') and push the
same Diagnostics (ruleId 'robots-conflict') into out when found. Ensure you keep
the existing lowercase/trim normalization and return out as before.
packages/unhead/src/validate/predicates/prefer-define-helpers.ts (1)

23-34: Optional: consider an autofix that also inserts the missing import.

Right now the suggestion path hands back the same wrap-tag fix as the hard-fix path and asks the user to import the helper themselves. Since the CLI/ESLint adapters know the source file, an enhancement would be a fix variant that also injects an import { defineX } from 'unhead' (or upgrades an existing import) so accepting the suggestion produces compilable code. Not required for this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/validate/predicates/prefer-define-helpers.ts` around
lines 23 - 34, The suggestion should offer an autofix that also inserts or
updates the import when the helper is not already imported: when building the
Diagnostic in prefer-define-helpers, detect ctx.importedHelpers and, for the
suggestions branch (where imported is false), provide a second-kind of fix (or
extend the existing fix object) that not only sets type: 'wrap-tag' and
wrapWith: helper but also includes metadata to add or update an import for the
helper (e.g. import { <helper> } from 'unhead') so the adapter can apply both
AST edits; modify the construction of diag.suggestions (and the exported fix
shape) to carry this import-insertion variant while leaving the imported=true
path unchanged.
packages/eslint-plugin/src/rules/title-rules.ts (1)

5-16: The noHtmlInTitle predicate is currently safe but consider adding meta flags as a defensive measure.

The predicate currently returns diagnostics with no fix or suggestions fields, so reportDiagnostic will not attempt to pass either to ctx.report(). However, if the predicate is updated in the future to emit fixes or suggestions, the rule's meta will be missing the required fixable and hasSuggestions declarations, causing ESLint to throw at runtime. Either add the flags now as a safeguard or document the constraint that this predicate must not emit fixes/suggestions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/eslint-plugin/src/rules/title-rules.ts` around lines 5 - 16, The
rule metadata for noHtmlInTitle is missing declarations for possible
fixes/suggestions, which will cause ESLint to throw if predicate later returns a
fix or suggestions; update the meta object on noHtmlInTitle to include fixable:
'code' if predicate/createHeadInputPredicateRule(predicate) might emit fixes and
hasSuggestions: true if it might emit suggestions (or explicitly document that
predicate will never emit fixes/suggestions), so add the appropriate
fixable/hasSuggestions flags in the meta for noHtmlInTitle to match any future
output from predicate.
packages/cli/src/commands/migrate.ts (1)

45-62: Dry-run skips the errorCount > 0 → exitCode = 1 signal.

In --dry-run the function returns at line 48 before summarise(results) is consulted, so a CI invocation like unhead migrate --dry-run will exit 0 even when audit errors are present. If that's intentional (dry-run treated as purely informational), fine — otherwise consider hoisting the errorCount check above the dry-run branch so both paths agree.

♻️ Suggested adjustment
+    const { errorCount } = summarise(results)
+
     if (dryRun) {
       const fixable = results.reduce((n, r) => n + (r.output ? 1 : 0), 0)
       console.log(`unhead migrate: ${fixable} file${fixable === 1 ? '' : 's'} would be modified (dry run)`)
+      if (errorCount > 0)
+        process.exitCode = 1
       return
     }

     let written = 0
     for (const r of results) {
       if (!r.output)
         continue
       await writeFile(r.filePath, r.output)
       written++
     }
     console.log(`unhead migrate: applied fixes to ${written} file${written === 1 ? '' : 's'}`)

-    const { errorCount } = summarise(results)
     if (errorCount > 0)
       process.exitCode = 1
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/migrate.ts` around lines 45 - 62, The dry-run path
in migrate (guarded by the dryRun variable) returns before checking
summarise(results) so process.exitCode is never set when there are errors; move
or duplicate the errorCount check so both branches consult summarise(results)
and set process.exitCode = 1 when errorCount > 0. Specifically, after computing
const { errorCount } = summarise(results) (or before the dryRun return), ensure
the dry-run branch still prints the fixable message but then sets
process.exitCode based on errorCount (or hoist the summarise call above the
dryRun check) so migrate's behavior is consistent for both dryRun and real runs.
packages/eslint-plugin/src/utils/applyDiagnostic.ts (1)

86-103: Optional: drop suggestions whose fixer can't be built instead of registering a no-op.

When a suggestion's PredicateFix references a missing property, buildFixer returns undefined and the current code falls back to () => null. ESLint will still display the suggestion to the user, but selecting it is a no-op. Filtering these out gives cleaner UX and matches the defensive intent of the comment in buildFixer.

♻️ Suggested change
   ctx.report({
     node,
     message: diag.message,
     fix: fixer,
-    suggest: diag.suggestions?.map(s => ({
-      desc: s.message,
-      fix: buildFixer(obj, s.fix, ctx.sourceCode) ?? (() => null),
-    })),
+    suggest: diag.suggestions
+      ?.map((s) => {
+        const f = buildFixer(obj, s.fix, ctx.sourceCode)
+        return f ? { desc: s.message, fix: f } : undefined
+      })
+      .filter((x): x is { desc: string, fix: (fixer: Rule.RuleFixer) => Rule.Fix | null } => Boolean(x)),
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/eslint-plugin/src/utils/applyDiagnostic.ts` around lines 86 - 103,
The reportDiagnostic function currently maps diag.suggestions to objects even
when buildFixer returns undefined (falling back to a no-op), so update
reportDiagnostic to call buildFixer(obj, s.fix, ctx.sourceCode) for each
suggestion and only include suggestions where that fixer is defined: compute a
suggestions array by mapping each s to { desc: s.message, fix: fixer } and
filter out entries with undefined fixer before passing suggest to ctx.report;
reference reportDiagnostic, diag.suggestions and buildFixer to locate the
change.
packages/cli/src/oxc/applyFix.ts (1)

28-84: Add an exhaustive default to the switch.

The function is declared to return boolean, but the switch (fix.type) has no default arm — if PredicateFix gains a new variant in unhead/validate, the CLI silently returns undefined (and applyFix's caller assumes boolean). An exhaustive default with assertNever/throw will make the type system fail compilation when new variants are added.

♻️ Suggested change
     case 'wrap-tag': {
       magic.appendLeft(obj.start + off, `${fix.wrapWith}(`)
       magic.appendRight(obj.end + off, `)`)
       return true
     }
+    default: {
+      const _exhaustive: never = fix
+      throw new Error(`Unknown PredicateFix type: ${(_exhaustive as PredicateFix).type}`)
+    }
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/oxc/applyFix.ts` around lines 28 - 84, The switch over
fix.type in applyFix currently lacks a default, so add an exhaustive default arm
that calls an assertion helper (e.g., assertNever) or throws a descriptive Error
to ensure the function always returns a boolean and to surface new PredicateFix
variants at compile time; update or add an assertNever(value: never) helper if
needed and reference the switch on fix.type inside applyFix to implement the
default branch that throws/asserts with the unexpected fix value.
packages/cli/test/audit.test.ts (2)

90-144: Five identical runAudit calls per migrate test — consider sharing the result.

Each migrate it() re-parses, re-walks, and re-rewrites input.ts despite asserting on the same output. Hoisting runAudit into beforeEach (or a single it with multiple expects) cuts test runtime ~5×.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/test/audit.test.ts` around lines 90 - 144, Hoist the repeated
runAudit invocation into a shared setup so the file is parsed only once: call
runAudit({ patterns: ['input.ts'], mode: 'migrate', cwd: tmp }) in a beforeEach
(or one encompassing it block) and store its return in a shared variable (e.g.
results/out) that each test uses; update each it() (the tests asserting
rewrites, defer removal, crossorigin, twitter prefixing, and hid→key) to read
from that shared results[0].output instead of calling runAudit again, leaving
existing expect assertions unchanged.

86-88: Empty afterEach leaks a temp directory per migrate test.

Each beforeEach creates a fresh mkdtemp and the no-op afterEach never removes it, so every runAudit (migrate) run leaves five orphaned unhead-cli-* directories in os.tmpdir(). The comment dismisses cleanup, but the leak occurs on success too — not just failure. A best-effort rm is one line and avoids unbounded growth on dev machines / CI runners.

🧹 Suggested cleanup
-import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@
-  afterEach(async () => {
-    // Best-effort cleanup; tmp dir leaking on failure isn't worth a try/catch.
-  })
+  afterEach(async () => {
+    await rm(tmp, { recursive: true, force: true })
+  })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/test/audit.test.ts` around lines 86 - 88, The empty afterEach in
the migrate tests is leaking mkdtemp-created temp dirs; update afterEach to
perform a best-effort removal of the temp directory created in beforeEach (the
same variable used by the runAudit (migrate) tests), e.g. await
fs.promises.rm(tempDir, { recursive: true, force: true }) or fs.rmSync with
guards if needed, and swallow any errors so cleanup never fails the test;
reference and use the same temp directory variable created in beforeEach and
keep afterEach async to await the removal.
packages/cli/src/oxc/materialize.ts (1)

6-7: Loose Node = any typing — consider importing oxc-parser's AST types behind a type-only import.

type-only imports (import type { ... } from 'oxc-parser') don't add runtime weight and would catch e.g. accessing p.value on a SpreadElement (which has no value) or relying on start/end if oxc renames offset fields. Today the bug surface is small because getKeyName rejects non-Property nodes early, but the loose typing is a foot-gun if the AST shape ever drifts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/oxc/materialize.ts` around lines 6 - 7, Replace the loose
"type Node = any" with precise type-only imports from oxc-parser: add an import
type line (e.g., import type { Node, Property, SpreadElement, Identifier,
Literal } from 'oxc-parser') and use those types in the code (or use Node as the
AST root type and narrow to Property in getKeyName) so TypeScript will catch
invalid accesses (e.g., accessing .value on SpreadElement) and rely on declared
offset names; update any function signatures that use the old Node alias (such
as getKeyName and any other helpers in this file) to use the imported oxc-parser
types or narrowed unions instead.
packages/unhead/src/plugins/validate.ts (1)

207-211: Object.values(tagPredicates) runs every per-tag predicate on every resolved tag, every resolve cycle.

This is hot path code — tags:afterResolve fires on every head update. Calling 10+ predicates per tag (each scanning its own conditions) is fine for typical tag counts but worth keeping an eye on for apps with large head trees. Most predicates short-circuit on tagType mismatch on their first line, so the overhead should be modest, but consider grouping predicates by tagType (e.g., predicatesByTagType.get(tag.tagType)) if profiling later flags this.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/plugins/validate.ts` around lines 207 - 211, The hot path
currently iterates Object.values(tagPredicates) for every resolved tag in the
tags:afterResolve loop (see tagInputFromRuntime, tagPredicates,
emitFromPredicates), which runs all predicates for every tag; change this to
group predicates by tagType (e.g., build a predicatesByTagType Map at predicate
registration time keyed by predicate.tagType or the tag types they handle) and
in the loop only fetch predicatesByTagType.get(tag.tagType) (falling back to any
global predicates) and run emitFromPredicates for that filtered list; update the
predicate registration/initialization code where tagPredicates is populated so
the grouped map is kept in sync.
packages/unhead/src/validate/predicates/no-deprecated-props.ts (1)

27-36: Hardcoded rename target & ruleId couple this predicate to today's DEPRECATED_PROPS shape.

newKey resolves to 'key' for anything that isn't children, and ruleId is fixed to 'deprecated-prop-hid-vmid' for the same bucket. Adding a new entry to DEPRECATED_PROPS (e.g., a future tagDuplicateStrategy deprecation) will silently rename to key and surface under the misleading deprecated-prop-hid-vmid rule.

Consider sourcing both values from DEPRECATED_PROPS[key] (e.g., a newKey and ruleId field on the table) so the predicate stays data-driven.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/validate/predicates/no-deprecated-props.ts` around lines
27 - 36, The predicate currently hardcodes newKey and ruleId (newKey = key ===
'children' ? 'innerHTML' : 'key' and ruleId = key === 'children' ?
'deprecated-prop-children' : 'deprecated-prop-hid-vmid'), coupling behavior to
the current DEPRECATED_PROPS shape; change the logic to read the rename target
and rule id from DEPRECATED_PROPS[key] (e.g., expect each entry to provide
newKey and ruleId), use those values when computing fix (keep the
tag.keys.has(newKey) check) and when setting out.push.ruleId and message so
future deprecations (like tagDuplicateStrategy) are data-driven rather than
hardcoded.
packages/eslint-plugin/src/utils/materialize.ts (1)

13-88: Optional: factor out the shared key-extraction loop.

The two loops in materializeTag (Lines 22–48) and materializeHeadInput (Lines 67–85) duplicate the same Property / computed / Identifier|Literal key-extraction logic and keys/propLocs bookkeeping. A small forEachStaticKey(node, cb) helper would keep both materializers in lockstep when this is extended (e.g., to support string-keyed literals beyond the current set).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/eslint-plugin/src/utils/materialize.ts` around lines 13 - 88, The
two functions materializeTag and materializeHeadInput duplicate the same
Property/computed/Identifier|Literal key extraction and keys/propLocs
bookkeeping; factor that out into a helper like forEachStaticKey(node, cb) that
iterates node.properties, skips non-Property or computed entries, derives the
string key from Identifier or string Literal, adds the key to a provided Set and
records propLocs, and invokes cb(name, property) for further per-key processing;
then replace the for-loops in materializeTag and materializeHeadInput to call
forEachStaticKey and perform only the value-specific logic (type checks,
getStringValue, props assignment) inside the callback so behavior and
bookkeeping remain identical.
packages/unhead/src/validate/predicates/types.ts (1)

50-62: Nit: PredicateFix.remove-prop doc describes adapter behavior, not the contract.

The comment "Remove a property and its surrounding comma" (Line 59) is really an instruction to whoever implements the fix. Worth rewording to make it clear that comma handling is the adapter's responsibility (both applyFix in packages/cli/src/oxc/applyFix.ts and the ESLint adapter need to honor this consistently), so the contract isn't ambiguous when a third adapter is added.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/validate/predicates/types.ts` around lines 50 - 62, The
JSDoc for the PredicateFix variant `{ type: 'remove-prop', key: string }`
currently prescribes adapter behavior ("Remove a property and its surrounding
comma"); change it to a contract-style description that clearly separates
responsibilities — e.g. state that `remove-prop` removes the property token(s)
but NOT comma/whitespace handling, and that comma removal/normalization is the
responsibility of adapters (ensure `applyFix` and the ESLint adapter honor this
contract); update the comment on the `PredicateFix` union so callers and all
adapters (including `applyFix` and the ESLint adapter) have an unambiguous
specification.
packages/cli/src/oxc/audit.ts (2)

188-218: runAudit reads and audits files serially.

for (const filePath of files) { await readFile(...); await auditFile(...) } is single-flighted. On Nuxt-sized projects this dominates wall time even though each file is independent. Consider Promise.all with a small concurrency limit (e.g., p-limit or a hand-rolled pool of os.cpus().length).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/oxc/audit.ts` around lines 188 - 218, runAudit currently
processes files serially inside the for..of loop (reading each file with
readFile and calling auditFile), causing slow wall time for large projects;
change it to run file processing in parallel with a bounded concurrency (e.g.,
use p-limit or a small worker pool sized to os.cpus().length) by mapping files
to async tasks that call readFile and auditFile and submitting them through the
limiter, then await Promise.all on the limited tasks and collect non-empty
results into the results array; keep existing behavior for predicateNames and
shouldFix, preserve filePath association in each result, and ensure error
handling/ordering remains equivalent.

67-77: Repeated lineCol scans become O(file·diagnostics) on large files.

Each diagnostic re-scans source from index 0. With many diagnostics in a large generated file (Nuxt apps can produce dozens per file), this is wasteful. Consider precomputing a sorted array of newline offsets per file and using binary search, or computing line/column only after all diagnostics for the file are collected and sorted by offset so the scan is monotone.

♻️ Sketch (per-file newline index, binary search)
function buildLineIndex(source: string): number[] {
  const nls: number[] = []
  for (let i = 0; i < source.length; i++)
    if (source.charCodeAt(i) === 10) nls.push(i)
  return nls
}

function lineColFromIndex(nls: number[], offset: number) {
  // binary search greatest nl < offset
  let lo = 0, hi = nls.length
  while (lo < hi) {
    const mid = (lo + hi) >>> 1
    if (nls[mid] < offset) lo = mid + 1
    else hi = mid
  }
  const lastNL = lo === 0 ? -1 : nls[lo - 1]
  return { line: lo + 1, column: offset - lastNL }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/oxc/audit.ts` around lines 67 - 77, The current lineCol
function scans from start for every diagnostic causing O(file·diagnostics);
replace it by precomputing a per-file newline index and using binary search or a
monotone scan when diagnostics are sorted. Add a buildLineIndex(source: string):
number[] helper that collects newline offsets, then replace lineCol(offset) with
a lineColFromIndex(nls, offset) that binary-searches nls to find the last
newline and returns {line, column}; alternatively, collect all diagnostics for a
file, sort by offset and compute line/column with a single linear pass over the
source using the same newline index to keep complexity near O(file +
diagnostics). Ensure you update callers that reference lineCol to use the new
API (lineColFromIndex or the post-sorted monotone routine).
packages/cli/src/oxc/walker.ts (1)

31-36: Optional: deduplicate unwrapTS / TS_WRAPPERS.

The same set + helper exists in packages/eslint-plugin/src/utils/visitor.ts (and the materializer relies on it). One copy in unhead/validate (or a tiny shared internal package) would keep the two adapters from drifting on which TS wrapper kinds are recognized.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/oxc/walker.ts` around lines 31 - 36, Duplicate logic: the
TS_WRAPPERS set and unwrapTS function are defined here and also in another
module; extract them into a single shared helper and import it where needed.
Create a small internal util that exports TS_WRAPPERS and unwrapTS (preserve the
function name and behavior), replace the local definitions in this file (and in
the other module) with imports from that util, and update any callers (e.g.,
materializer/visitor code) to use the shared symbols so both adapters recognize
the same TS wrapper kinds.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cli/src/oxc/audit.ts`:
- Around line 118-130: The current loop over pieces silently skips any piece
that throws in parseSync or has parsed.errors, which hides coverage gaps; update
the catch block around parseSync and the parsed.errors branch to record a
parse-error diagnostic (or populate a parseErrors field on the AuditFileResult
for that file) instead of just continuing — include the piece identifier (e.g.,
piece.lang or piece.code location) and any thrown error/message, and ensure the
rest of the audit still runs for other pieces; modify the code paths that
reference parseSync, the catch, and the parsed.errors check to attach this
informational diagnostic to the audit result returned for the file.

In `@packages/cli/src/oxc/sfc.ts`:
- Around line 1-47: Update the misleading "byte offset" documentation to state
"UTF-16 code-unit offset" wherever the ScriptBlock.offset is documented and
used: change the comment on the ScriptBlock interface (and its `offset`
description) and the inline docs in the modules that consume it to say "UTF-16
code-unit offset" instead of "byte offset" — specifically update the JSDoc for
the ScriptBlock type and any comments that describe "byte offset" in the
audit/applyFix consumers so they correctly reflect that extractScriptBlocks (and
RegExp.exec()/String.indexOf) produce UTF-16 code-unit offsets; keep all code
and behavior unchanged (symbols to locate: ScriptBlock, offset,
extractScriptBlocks, and the comments in the audit/applyFix consumers).

In `@packages/cli/src/oxc/walker.ts`:
- Around line 65-78: The collectImportedHelpers function currently records
helper names from any ImportDeclaration without checking the import source;
update it to only consider specifiers when the ImportDeclaration's source
(node.source.value) matches unhead packages (exactly 'unhead' or starts with
'unhead/' or '@unhead/' or '@unhead/'), so that only imports from unhead are
added to the Set; keep existing checks for specifier types (ImportSpecifier with
spec.imported.name in HELPER_NAMES and ImportDefaultSpecifier with
spec.local.name in HELPER_NAMES) but only execute them when the source filter
passes to avoid false positives in the helper detection used by
prefer-define-helpers.
- Around line 38-45: getCalleeName fails to detect identifiers when the callee
is wrapped in TS nodes (e.g. TSAsExpression); update getCalleeName to first
unwrap TypeScript wrappers by calling unwrapTS(node.callee) (or equivalent
helper) and then perform the existing checks (Identifier and MemberExpression
with Identifier property) against the unwrapped callee so patterns like (useHead
as typeof useHead)(...) are recognized.

In `@packages/unhead/src/plugins/validate.ts`:
- Around line 63-67: PREDICATE_SEVERITY currently covers only three IDs causing
other predicate-emitted ruleIds to default to 'warn'; update the
PREDICATE_SEVERITY map to explicitly include the missing ruleIds from the
predicate emitters—add 'possible-typo' => 'info' (from noUnknownMeta) and add
'deprecated-prop-children' => 'error', 'deprecated-prop-hid-vmid' => 'error',
and 'deprecated-prop-body' => 'error' (from noDeprecatedProps) so
emitFromPredicates produces severities consistent with the CLI audit config.

In `@packages/unhead/src/validate/predicates/script-rules.ts`:
- Around line 19-31: The predicate scriptSrcWithContent currently flags scripts
that have a src and the keys innerHTML/textContent present even if their values
are empty; change the check to verify that tag.props.innerHTML or
tag.props.textContent is a non-empty string (e.g., typeof === 'string' and
.trim() !== '') before emitting the Diagnostic. Update logic in
scriptSrcWithContent to inspect tag.props.innerHTML and tag.props.textContent
values (not just tag.keys.has) and only return the diag when one of those values
contains actual non-whitespace content while tag.props.src is a string.

In `@packages/unhead/src/validate/predicates/twitter-handle-missing-at.ts`:
- Around line 16-21: The autofix builds newSource naively as `'@${content}'`,
which produces invalid JS when content contains quotes, backslashes, or
newlines; update the Diagnostic.fix.newSource construction in the
twitter-handle-missing-at diagnostic (ruleId 'twitter-handle-missing-at',
variable diag) to serialize the fix string safely by using a JS source literal
generator such as JSON.stringify('@' + content) (or an equivalent safe-escaping
routine) instead of interpolating content directly so the emitted
replace-prop-value fix is always valid JS.

In `@packages/unhead/src/validate/predicates/viewport-user-scalable.ts`:
- Around line 3-4: The regexes USER_SCALABLE_NO_RE and MAX_SCALE_RE miss numeric
forms; update USER_SCALABLE_NO_RE to also match "0" (e.g.,
/user-scalable\s*=\s*(?:no|0)(?:\s|,|$)/i) and update MAX_SCALE_RE to accept one
with any number of trailing zeros after the decimal (e.g.,
/maximum-scale\s*=\s*1(?:\.0+)?(?:\s|,|$)/i) so values like user-scalable=0 and
maximum-scale=1.00 are detected; modify those constant definitions
(USER_SCALABLE_NO_RE and MAX_SCALE_RE) accordingly.

---

Outside diff comments:
In `@packages/cli/README.md`:
- Around line 97-100: The README's claim that outputs are "byte-for-byte
identical" is too strong; update the paragraph referencing `audit`, `migrate`,
`unhead/validate`, and `@unhead/eslint-plugin` to say the CLI and ESLint plugin
"produce the same diagnostics" or "report identical rule IDs and messages"
instead, and note that CLI output passes through `formatStylish` while editor/CI
uses the consumer's formatter so the rendered streams may differ; replace the
phrase "byte-for-byte identical" with one of the suggested softer phrasings and
add a brief note about formatter differences.

---

Nitpick comments:
In `@packages/cli/src/commands/migrate.ts`:
- Around line 45-62: The dry-run path in migrate (guarded by the dryRun
variable) returns before checking summarise(results) so process.exitCode is
never set when there are errors; move or duplicate the errorCount check so both
branches consult summarise(results) and set process.exitCode = 1 when errorCount
> 0. Specifically, after computing const { errorCount } = summarise(results) (or
before the dryRun return), ensure the dry-run branch still prints the fixable
message but then sets process.exitCode based on errorCount (or hoist the
summarise call above the dryRun check) so migrate's behavior is consistent for
both dryRun and real runs.

In `@packages/cli/src/oxc/applyFix.ts`:
- Around line 28-84: The switch over fix.type in applyFix currently lacks a
default, so add an exhaustive default arm that calls an assertion helper (e.g.,
assertNever) or throws a descriptive Error to ensure the function always returns
a boolean and to surface new PredicateFix variants at compile time; update or
add an assertNever(value: never) helper if needed and reference the switch on
fix.type inside applyFix to implement the default branch that throws/asserts
with the unexpected fix value.

In `@packages/cli/src/oxc/audit.ts`:
- Around line 188-218: runAudit currently processes files serially inside the
for..of loop (reading each file with readFile and calling auditFile), causing
slow wall time for large projects; change it to run file processing in parallel
with a bounded concurrency (e.g., use p-limit or a small worker pool sized to
os.cpus().length) by mapping files to async tasks that call readFile and
auditFile and submitting them through the limiter, then await Promise.all on the
limited tasks and collect non-empty results into the results array; keep
existing behavior for predicateNames and shouldFix, preserve filePath
association in each result, and ensure error handling/ordering remains
equivalent.
- Around line 67-77: The current lineCol function scans from start for every
diagnostic causing O(file·diagnostics); replace it by precomputing a per-file
newline index and using binary search or a monotone scan when diagnostics are
sorted. Add a buildLineIndex(source: string): number[] helper that collects
newline offsets, then replace lineCol(offset) with a lineColFromIndex(nls,
offset) that binary-searches nls to find the last newline and returns {line,
column}; alternatively, collect all diagnostics for a file, sort by offset and
compute line/column with a single linear pass over the source using the same
newline index to keep complexity near O(file + diagnostics). Ensure you update
callers that reference lineCol to use the new API (lineColFromIndex or the
post-sorted monotone routine).

In `@packages/cli/src/oxc/materialize.ts`:
- Around line 6-7: Replace the loose "type Node = any" with precise type-only
imports from oxc-parser: add an import type line (e.g., import type { Node,
Property, SpreadElement, Identifier, Literal } from 'oxc-parser') and use those
types in the code (or use Node as the AST root type and narrow to Property in
getKeyName) so TypeScript will catch invalid accesses (e.g., accessing .value on
SpreadElement) and rely on declared offset names; update any function signatures
that use the old Node alias (such as getKeyName and any other helpers in this
file) to use the imported oxc-parser types or narrowed unions instead.

In `@packages/cli/src/oxc/walker.ts`:
- Around line 31-36: Duplicate logic: the TS_WRAPPERS set and unwrapTS function
are defined here and also in another module; extract them into a single shared
helper and import it where needed. Create a small internal util that exports
TS_WRAPPERS and unwrapTS (preserve the function name and behavior), replace the
local definitions in this file (and in the other module) with imports from that
util, and update any callers (e.g., materializer/visitor code) to use the shared
symbols so both adapters recognize the same TS wrapper kinds.

In `@packages/cli/test/audit.test.ts`:
- Around line 90-144: Hoist the repeated runAudit invocation into a shared setup
so the file is parsed only once: call runAudit({ patterns: ['input.ts'], mode:
'migrate', cwd: tmp }) in a beforeEach (or one encompassing it block) and store
its return in a shared variable (e.g. results/out) that each test uses; update
each it() (the tests asserting rewrites, defer removal, crossorigin, twitter
prefixing, and hid→key) to read from that shared results[0].output instead of
calling runAudit again, leaving existing expect assertions unchanged.
- Around line 86-88: The empty afterEach in the migrate tests is leaking
mkdtemp-created temp dirs; update afterEach to perform a best-effort removal of
the temp directory created in beforeEach (the same variable used by the runAudit
(migrate) tests), e.g. await fs.promises.rm(tempDir, { recursive: true, force:
true }) or fs.rmSync with guards if needed, and swallow any errors so cleanup
never fails the test; reference and use the same temp directory variable created
in beforeEach and keep afterEach async to await the removal.

In `@packages/eslint-plugin/src/rules/title-rules.ts`:
- Around line 5-16: The rule metadata for noHtmlInTitle is missing declarations
for possible fixes/suggestions, which will cause ESLint to throw if predicate
later returns a fix or suggestions; update the meta object on noHtmlInTitle to
include fixable: 'code' if predicate/createHeadInputPredicateRule(predicate)
might emit fixes and hasSuggestions: true if it might emit suggestions (or
explicitly document that predicate will never emit fixes/suggestions), so add
the appropriate fixable/hasSuggestions flags in the meta for noHtmlInTitle to
match any future output from predicate.

In `@packages/eslint-plugin/src/utils/applyDiagnostic.ts`:
- Around line 86-103: The reportDiagnostic function currently maps
diag.suggestions to objects even when buildFixer returns undefined (falling back
to a no-op), so update reportDiagnostic to call buildFixer(obj, s.fix,
ctx.sourceCode) for each suggestion and only include suggestions where that
fixer is defined: compute a suggestions array by mapping each s to { desc:
s.message, fix: fixer } and filter out entries with undefined fixer before
passing suggest to ctx.report; reference reportDiagnostic, diag.suggestions and
buildFixer to locate the change.

In `@packages/eslint-plugin/src/utils/materialize.ts`:
- Around line 13-88: The two functions materializeTag and materializeHeadInput
duplicate the same Property/computed/Identifier|Literal key extraction and
keys/propLocs bookkeeping; factor that out into a helper like
forEachStaticKey(node, cb) that iterates node.properties, skips non-Property or
computed entries, derives the string key from Identifier or string Literal, adds
the key to a provided Set and records propLocs, and invokes cb(name, property)
for further per-key processing; then replace the for-loops in materializeTag and
materializeHeadInput to call forEachStaticKey and perform only the
value-specific logic (type checks, getStringValue, props assignment) inside the
callback so behavior and bookkeeping remain identical.

In `@packages/eslint-plugin/test/rules.test.ts`:
- Around line 22-154: Tests currently assert error messages with regexes which
are brittle; instead import the stable rule identifier constants from the
corresponding predicate modules (e.g. the predicate modules under
packages/unhead/src/validate/predicates that define twitterHandleMissingAt,
robotsConflict, deferOnModuleScript, etc.) and replace the regex-based errors
assertions with errors: [{ ruleId: THE_RULE_ID }] (or assert on a stable
substring constant exported by the predicate) so tests assert on the exported
ruleId symbol rather than the full message text.

In `@packages/unhead/src/plugins/validate.ts`:
- Around line 207-211: The hot path currently iterates
Object.values(tagPredicates) for every resolved tag in the tags:afterResolve
loop (see tagInputFromRuntime, tagPredicates, emitFromPredicates), which runs
all predicates for every tag; change this to group predicates by tagType (e.g.,
build a predicatesByTagType Map at predicate registration time keyed by
predicate.tagType or the tag types they handle) and in the loop only fetch
predicatesByTagType.get(tag.tagType) (falling back to any global predicates) and
run emitFromPredicates for that filtered list; update the predicate
registration/initialization code where tagPredicates is populated so the grouped
map is kept in sync.

In `@packages/unhead/src/validate/predicates/empty-meta-content.ts`:
- Around line 10-12: The identifier fallback for empty-meta content uses only
tag.props.name and tag.props.property, causing poor messages for meta tags using
http-equiv; update the const key expression in empty-meta-content.ts (the const
named key) to also check for a string-valued tag.props['http-equiv'] (e.g., ||
(typeof tag.props['http-equiv'] === 'string' && tag.props['http-equiv']) )
before falling back to 'meta' so diagnostics show the http-equiv value when
present.

In `@packages/unhead/src/validate/predicates/no-deprecated-props.ts`:
- Around line 27-36: The predicate currently hardcodes newKey and ruleId (newKey
= key === 'children' ? 'innerHTML' : 'key' and ruleId = key === 'children' ?
'deprecated-prop-children' : 'deprecated-prop-hid-vmid'), coupling behavior to
the current DEPRECATED_PROPS shape; change the logic to read the rename target
and rule id from DEPRECATED_PROPS[key] (e.g., expect each entry to provide
newKey and ruleId), use those values when computing fix (keep the
tag.keys.has(newKey) check) and when setting out.push.ruleId and message so
future deprecations (like tagDuplicateStrategy) are data-driven rather than
hardcoded.

In `@packages/unhead/src/validate/predicates/non-absolute-canonical.ts`:
- Around line 3-5: The isAbsolute function currently only checks for 'http://'
and 'https://' in lowercase, so URLs with uppercase schemes like 'HTTP://' will
be misclassified; update the isAbsolute(url: string) implementation (function
name: isAbsolute) to perform a case-insensitive scheme check—either by
lowercasing the url before startsWith checks or by using a case-insensitive
regex (e.g., matching /^https?:\/\//i) so both 'HTTP://' and 'http://' are
treated as absolute.

In `@packages/unhead/src/validate/predicates/prefer-define-helpers.ts`:
- Around line 23-34: The suggestion should offer an autofix that also inserts or
updates the import when the helper is not already imported: when building the
Diagnostic in prefer-define-helpers, detect ctx.importedHelpers and, for the
suggestions branch (where imported is false), provide a second-kind of fix (or
extend the existing fix object) that not only sets type: 'wrap-tag' and
wrapWith: helper but also includes metadata to add or update an import for the
helper (e.g. import { <helper> } from 'unhead') so the adapter can apply both
AST edits; modify the construction of diag.suggestions (and the exported fix
shape) to carry this import-insertion variant while leaving the imported=true
path unchanged.

In `@packages/unhead/src/validate/predicates/preload-rules.ts`:
- Around line 17-32: The predicate preloadFontCrossorigin does strict
case-sensitive checks on tag.props.rel and tag.props.as and uses a fix.insert
that starts with ", " which couples it to the consumer; update the predicate to
normalize the attribute values (e.g., coerce tag.props.rel and tag.props.as to
lower-case strings before comparison) so rel="Preload" or as="FONT" match, and
change the fix.insert to not include a leading separator (e.g., use
"crossorigin: 'anonymous'") so applyFix can decide how to insert separators;
keep references to preloadFontCrossorigin and the fix.insert field when making
these edits.

In `@packages/unhead/src/validate/predicates/robots-conflict.ts`:
- Around line 11-25: The predicate currently checks the raw directives array
(variable directives) for conflicting pairs but ignores shorthand tokens; update
the logic to expand 'none' → ['noindex','nofollow'] and 'all' →
['index','follow'] into the directives set before conflict checks. Concretely,
compute a normalized Set from directives (expanding any 'none'/'all' entries),
then test for the conflicting pairs ('index' vs 'noindex', 'follow' vs
'nofollow') and push the same Diagnostics (ruleId 'robots-conflict') into out
when found. Ensure you keep the existing lowercase/trim normalization and return
out as before.

In `@packages/unhead/src/validate/predicates/types.ts`:
- Around line 50-62: The JSDoc for the PredicateFix variant `{ type:
'remove-prop', key: string }` currently prescribes adapter behavior ("Remove a
property and its surrounding comma"); change it to a contract-style description
that clearly separates responsibilities — e.g. state that `remove-prop` removes
the property token(s) but NOT comma/whitespace handling, and that comma
removal/normalization is the responsibility of adapters (ensure `applyFix` and
the ESLint adapter honor this contract); update the comment on the
`PredicateFix` union so callers and all adapters (including `applyFix` and the
ESLint adapter) have an unambiguous specification.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 19cc33ca-942c-4b02-b66e-f9125d39f30b

📥 Commits

Reviewing files that changed from the base of the PR and between f5e516d and 646f283.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (55)
  • packages/cli/README.md
  • packages/cli/package.json
  • packages/cli/src/commands/audit.ts
  • packages/cli/src/commands/migrate.ts
  • packages/cli/src/format.ts
  • packages/cli/src/lint.ts
  • packages/cli/src/oxc/applyFix.ts
  • packages/cli/src/oxc/audit.ts
  • packages/cli/src/oxc/materialize.ts
  • packages/cli/src/oxc/sfc.ts
  • packages/cli/src/oxc/walker.ts
  • packages/cli/test/audit.test.ts
  • packages/cli/test/fixtures/bad.vue
  • packages/cli/test/fixtures/clean.ts
  • packages/cli/test/fixtures/migrate-input.ts
  • packages/cli/test/lint.test.ts
  • packages/eslint-plugin/README.md
  • packages/eslint-plugin/src/rules/canonical-rules.ts
  • packages/eslint-plugin/src/rules/empty-meta-content.ts
  • packages/eslint-plugin/src/rules/no-deprecated-props.ts
  • packages/eslint-plugin/src/rules/no-unknown-meta.ts
  • packages/eslint-plugin/src/rules/numeric-tag-priority.ts
  • packages/eslint-plugin/src/rules/prefer-define-helpers.ts
  • packages/eslint-plugin/src/rules/preload-rules.ts
  • packages/eslint-plugin/src/rules/robots-conflict.ts
  • packages/eslint-plugin/src/rules/script-rules.ts
  • packages/eslint-plugin/src/rules/title-rules.ts
  • packages/eslint-plugin/src/rules/twitter-handle-missing-at.ts
  • packages/eslint-plugin/src/rules/viewport-user-scalable.ts
  • packages/eslint-plugin/src/utils/applyDiagnostic.ts
  • packages/eslint-plugin/src/utils/createPredicateRule.ts
  • packages/eslint-plugin/src/utils/materialize.ts
  • packages/eslint-plugin/src/utils/visitor.ts
  • packages/eslint-plugin/test/no-deprecated-props.test.ts
  • packages/eslint-plugin/test/numeric-tag-priority.test.ts
  • packages/eslint-plugin/test/rules.test.ts
  • packages/unhead/src/plugins/validate.ts
  • packages/unhead/src/validate/index.ts
  • packages/unhead/src/validate/predicates/empty-meta-content.ts
  • packages/unhead/src/validate/predicates/index.ts
  • packages/unhead/src/validate/predicates/no-deprecated-props.ts
  • packages/unhead/src/validate/predicates/no-html-in-title.ts
  • packages/unhead/src/validate/predicates/no-unknown-meta.ts
  • packages/unhead/src/validate/predicates/non-absolute-canonical.ts
  • packages/unhead/src/validate/predicates/numeric-tag-priority.ts
  • packages/unhead/src/validate/predicates/prefer-define-helpers.ts
  • packages/unhead/src/validate/predicates/preload-rules.ts
  • packages/unhead/src/validate/predicates/robots-conflict.ts
  • packages/unhead/src/validate/predicates/runtime.ts
  • packages/unhead/src/validate/predicates/script-rules.ts
  • packages/unhead/src/validate/predicates/twitter-handle-missing-at.ts
  • packages/unhead/src/validate/predicates/types.ts
  • packages/unhead/src/validate/predicates/viewport-user-scalable.ts
  • packages/unhead/src/validate/rules.ts
  • packages/unhead/test/unit/predicates.test.ts
💤 Files with no reviewable changes (2)
  • packages/cli/test/lint.test.ts
  • packages/cli/src/lint.ts

Comment thread packages/cli/src/oxc/audit.ts
Comment thread packages/cli/src/oxc/sfc.ts
Comment thread packages/cli/src/oxc/walker.ts
Comment thread packages/cli/src/oxc/walker.ts Outdated
Comment thread packages/unhead/src/plugins/validate.ts
Comment thread packages/unhead/src/validate/predicates/script-rules.ts
Comment thread packages/unhead/src/validate/predicates/viewport-user-scalable.ts Outdated
- ValidatePlugin: PREDICATE_SEVERITY now lists every predicate-emitted
  ruleId explicitly so a new predicate's severity is a deliberate
  decision, not a 'warn' fall-through. Adds 'possible-typo' and the
  'deprecated-prop-*' triplet that previously fell through implicitly.

- twitter-handle-missing-at: autofix used a raw template-literal
  interpolation, producing invalid JS for handles containing quotes,
  backslashes, or newlines (e.g. "O'Reilly" → 'O'Reilly'). Switch to
  JSON.stringify so any input round-trips as a valid JS string literal.
  Tests updated to reflect the resulting double-quoted output.

- prefer-define-helpers (both adapters): collectImportedHelpers
  ignored the import source, so `import { defineLink } from
  'some-other-lib'` would convince the predicate that the unhead helper
  was already imported and emit an autofix wrapping with a foreign
  symbol. Filter on `node.source.value` against an allowlist of unhead
  / @unhead/* sources.

- walker: getCalleeName now peels TS wrappers on the callee so
  `(useHead as typeof useHead)({...})` is recognised as useHead.

- viewport-user-scalable regexes: also flag user-scalable=0 / =false
  (equivalent to =no per WHATWG) and maximum-scale=1.x (anything below
  2 still defeats WCAG 1.4.4).

- script-src-with-content: ignore empty-string innerHTML/textContent so
  `{ src: '/x.js', innerHTML: '' }` doesn't produce a misleading
  diagnostic about content the browser would silently drop.

- sfc / applyFix: doc comments said "byte offset" but oxc-parser spans
  are UTF-16 character offsets (matching JS string indexing and
  MagicString edits). Correct the comments; behaviour is unchanged.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
packages/unhead/src/validate/predicates/script-rules.ts (1)

19-36: Empty-string innerHTML/textContent is now correctly ignored.

The earlier false-positive concern (flagging innerHTML: '' / textContent: '') is addressed by gating on !== '' alongside keys.has(...).

One minor edge worth being aware of: when a key is present but its value is not statically resolvable, it lands in tag.keys but not in tag.props, so tag.props.innerHTML is undefined and undefined !== '' is true — the diagnostic still fires. That's probably the intended behavior (the user did write innerHTML), but if you'd rather only flag known non-empty content, tighten the check to a string-typed value:

♻️ Optional tightening
-  const hasInner = (tag.keys.has('innerHTML') && tag.props.innerHTML !== '')
-    || (tag.keys.has('textContent') && tag.props.textContent !== '')
+  const inner = tag.props.innerHTML
+  const text = tag.props.textContent
+  const hasInner
+    = (tag.keys.has('innerHTML') && (typeof inner !== 'string' || inner !== ''))
+    || (tag.keys.has('textContent') && (typeof text !== 'string' || text !== ''))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/validate/predicates/script-rules.ts` around lines 19 -
36, The predicate scriptSrcWithContent currently treats presence of
innerHTML/textContent keys with non-string or undefined values as "has content"
because it only checks !== ''; tighten the condition so it only counts known
non-empty string content: replace the hasInner checks to require typeof
tag.props.innerHTML === 'string' && tag.props.innerHTML !== '' (and likewise for
textContent) while still gating on tag.keys.has('innerHTML') /
tag.keys.has('textContent'); this ensures only statically-resolved non-empty
string values trigger the diagnostic in scriptSrcWithContent.
packages/cli/src/oxc/sfc.ts (1)

36-44: Optional: lang="typescript" / lang="javascript" fall through to 'js'.

Only the short forms (ts/tsx/jsx) are recognized; the long forms (which Vue/Svelte tooling do accept) silently downgrade to plain JS, which means oxc will parse TS files without TS support and emit confusing diagnostics. If you want to be defensive, normalize the long forms too.

♻️ Proposed change
-    const declared = langMatch?.[1]?.toLowerCase()
-    const lang: ScriptBlock['lang'] = declared === 'ts'
-      ? 'ts'
-      : declared === 'tsx'
-        ? 'tsx'
-        : declared === 'jsx'
-          ? 'jsx'
-          : 'js'
+    const declared = langMatch?.[1]?.toLowerCase()
+    const lang: ScriptBlock['lang']
+      = declared === 'ts' || declared === 'typescript'
+        ? 'ts'
+        : declared === 'tsx'
+          ? 'tsx'
+          : declared === 'jsx'
+            ? 'jsx'
+            : 'js'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/oxc/sfc.ts` around lines 36 - 44, The lang detection
currently only maps short forms and silently treats long forms as 'js'; update
the normalization around LANG_ATTR_RE / langMatch / declared (the variable
assigned with declared = langMatch?.[1]?.toLowerCase()) so it also recognizes
the long names "typescript" -> 'ts' and "javascript" -> 'js' (and
"typescriptreact"/"javascriptreact" or similar if needed), then assign
ScriptBlock['lang'] accordingly instead of defaulting to 'js' for those cases;
keep the toLowerCase normalization and ensure declared aliases map to the same
canonical short tokens used elsewhere.
packages/unhead/src/plugins/validate.ts (1)

247-252: Title's no-html-in-title path bypasses PREDICATE_SEVERITY.

The whole point of the new PREDICATE_SEVERITY table (and the docstring above it) is that severities for predicate-emitted ruleIds live in one place. This branch hardcodes 'warn' instead, so a future change to PREDICATE_SEVERITY['html-in-title'] would silently fail to apply on the runtime title path while still applying everywhere else. Reuse emitFromPredicates (or look up via the table) so the source of truth stays single.

♻️ Proposed refactor
             if (tag.tag === 'title') {
               const titleInput = titleInputFromRuntime(tag)
-              if (titleInput) {
-                for (const diag of headInputPredicates['no-html-in-title'](titleInput))
-                  report(diag.ruleId as ValidationRuleId, diag.message, 'warn', tag)
-              }
+              if (titleInput)
+                emitFromPredicates(headInputPredicates['no-html-in-title'](titleInput), tag)
               const text = tag.textContent || ''
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/plugins/validate.ts` around lines 247 - 252, The title
branch currently calls report(...) with a hardcoded 'warn' severity; change it
to use the centralized predicate-severity logic so it doesn't bypass
PREDICATE_SEVERITY — obtain the emitted entries via emitFromPredicates (or look
up PREDICATE_SEVERITY using the rule id) when handling the runtime title path in
the title handling block (where tag.tag === 'title' and
titleInputFromRuntime(tag) is used), and for each diagnostic from
headInputPredicates['no-html-in-title'] call report(...) with the severity
returned by emitFromPredicates (or the table lookup) rather than the literal
'warn', keeping the same ruleId/message/tag parameters.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cli/src/oxc/sfc.ts`:
- Around line 16-46: The opener regex (SCRIPT_OPEN_RE with /i) matches
case-insensitive tags but the closer constant (SCRIPT_CLOSE = '</script>') is
lowercase-only, so extractScriptBlocks can skip blocks like
<Script>...</Script>; fix by making the closing search case-insensitive instead
of changing the opener: when computing closeIdx in extractScriptBlocks, search
for the closing tag case-insensitively (e.g., find the index of '</script>' in a
lowercased copy of source starting at openEnd or use a case-insensitive regex to
locate the closing tag) and convert that index back to the original source
offset before slicing and pushing the ScriptBlock; keep references to
SCRIPT_OPEN_RE, SCRIPT_CLOSE, openEnd, closeIdx, and the out.push call intact.
- Around line 16-49: The SCRIPT_OPEN_RE currently uses /<script\b([^>]*)>/gi
which breaks when attribute values contain '>' (e.g., Vue generic types); update
the regex used by SCRIPT_OPEN_RE in extractScriptBlocks so it matches the whole
opening tag while ignoring '>' characters inside single- or double-quoted
attribute values (i.e., consume attributes but skip over quoted runs), keep
using SCRIPT_CLOSE as the end marker and preserve existing logic that reads
attrs = m[1] and matches LANG_ATTR_RE to determine lang; ensure
SCRIPT_OPEN_RE.lastIndex is still reset and advanced to closeIdx +
SCRIPT_CLOSE.length as before.

In `@packages/eslint-plugin/src/utils/createPredicateRule.ts`:
- Around line 86-99: The CallExpression handler in createHeadInputPredicateRule
reads node.arguments[0] directly and fails on TS wrappers; mirror
createTagVisitor by passing the raw argument through unwrapTS before the
ObjectExpression check and before calling materializeHeadInput. Update the
handler to obtain the raw arg from node.arguments[0], call unwrapTS(rawArg)
(importing/using the same unwrapTS as createTagVisitor), then verify
unwrappedArg.type === 'ObjectExpression' and pass the unwrapped node into
materializeHeadInput(name) so predicate(...) and reportDiagnostic(...) work with
TSAsExpression/TSSatisfiesExpression/TSTypeAssertion-wrapped calls (referencing
createHeadInputPredicateRule, unwrapTS, materializeHeadInput, createTagVisitor,
getCalleeName, HEAD_INPUT_CALLEES).

---

Nitpick comments:
In `@packages/cli/src/oxc/sfc.ts`:
- Around line 36-44: The lang detection currently only maps short forms and
silently treats long forms as 'js'; update the normalization around LANG_ATTR_RE
/ langMatch / declared (the variable assigned with declared =
langMatch?.[1]?.toLowerCase()) so it also recognizes the long names "typescript"
-> 'ts' and "javascript" -> 'js' (and "typescriptreact"/"javascriptreact" or
similar if needed), then assign ScriptBlock['lang'] accordingly instead of
defaulting to 'js' for those cases; keep the toLowerCase normalization and
ensure declared aliases map to the same canonical short tokens used elsewhere.

In `@packages/unhead/src/plugins/validate.ts`:
- Around line 247-252: The title branch currently calls report(...) with a
hardcoded 'warn' severity; change it to use the centralized predicate-severity
logic so it doesn't bypass PREDICATE_SEVERITY — obtain the emitted entries via
emitFromPredicates (or look up PREDICATE_SEVERITY using the rule id) when
handling the runtime title path in the title handling block (where tag.tag ===
'title' and titleInputFromRuntime(tag) is used), and for each diagnostic from
headInputPredicates['no-html-in-title'] call report(...) with the severity
returned by emitFromPredicates (or the table lookup) rather than the literal
'warn', keeping the same ruleId/message/tag parameters.

In `@packages/unhead/src/validate/predicates/script-rules.ts`:
- Around line 19-36: The predicate scriptSrcWithContent currently treats
presence of innerHTML/textContent keys with non-string or undefined values as
"has content" because it only checks !== ''; tighten the condition so it only
counts known non-empty string content: replace the hasInner checks to require
typeof tag.props.innerHTML === 'string' && tag.props.innerHTML !== '' (and
likewise for textContent) while still gating on tag.keys.has('innerHTML') /
tag.keys.has('textContent'); this ensures only statically-resolved non-empty
string values trigger the diagnostic in scriptSrcWithContent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 94d7a36f-1c76-4de9-81e7-d096e8fe2842

📥 Commits

Reviewing files that changed from the base of the PR and between 87f684b and 438f861.

📒 Files selected for processing (11)
  • packages/cli/src/oxc/applyFix.ts
  • packages/cli/src/oxc/sfc.ts
  • packages/cli/src/oxc/walker.ts
  • packages/cli/test/audit.test.ts
  • packages/eslint-plugin/src/utils/createPredicateRule.ts
  • packages/eslint-plugin/test/rules.test.ts
  • packages/unhead/src/plugins/validate.ts
  • packages/unhead/src/validate/predicates/script-rules.ts
  • packages/unhead/src/validate/predicates/twitter-handle-missing-at.ts
  • packages/unhead/src/validate/predicates/viewport-user-scalable.ts
  • packages/unhead/test/unit/predicates.test.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/unhead/test/unit/predicates.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/cli/test/audit.test.ts
  • packages/cli/src/oxc/walker.ts
  • packages/cli/src/oxc/applyFix.ts

Comment thread packages/cli/src/oxc/sfc.ts Outdated
Comment thread packages/cli/src/oxc/sfc.ts Outdated
Comment thread packages/eslint-plugin/src/utils/createPredicateRule.ts
- createHeadInputPredicateRule: peel TS wrappers on the first arg the
  same way createTagVisitor already does. `useHead({...} as Foo)` and
  `useHead({...} satisfies Foo)` head-input rules (e.g. no-html-in-title)
  were silently skipping these calls.

- sfc.ts: SCRIPT_OPEN_RE now allows `>` inside quoted attribute values,
  so Vue 3.3+ `<script setup generic="T extends Foo<Bar>">` parses
  instead of getting truncated at the first `>` inside the generic.
  Closing tag scan switched to a case-insensitive regex so `<Script>…
  </Script>` is matched consistently with the opener's `i` flag.

- New sfc.test.ts covers: basic ts script, byte-offset preservation,
  generic-attribute opener, mixed-case `</Script>`, multiple blocks,
  template-only files.
Comment thread packages/cli/src/oxc/sfc.ts Fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/eslint-plugin/src/utils/createPredicateRule.ts (1)

9-24: HELPER_SOURCES is largely subsumed by the @unhead/ prefix check.

isHelperSource returns true for any @unhead/* source via the prefix branch, so the only entry in HELPER_SOURCES that adds value is 'unhead'. The other five entries are redundant. Consider simplifying:

♻️ Optional simplification
-const HELPER_SOURCES = new Set([
-  'unhead',
-  '@unhead/vue',
-  '@unhead/react',
-  '@unhead/svelte',
-  '@unhead/solid-js',
-  '@unhead/angular',
-])
-
 function isHelperSource(source: string): boolean {
-  if (HELPER_SOURCES.has(source))
-    return true
-  // Be permissive within the `@unhead` namespace so framework subpaths (e.g.
-  // `@unhead/vue/server`) still count.
-  return source.startsWith('@unhead/')
+  return source === 'unhead' || source.startsWith('@unhead/')
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/eslint-plugin/src/utils/createPredicateRule.ts` around lines 9 - 24,
The HELPER_SOURCES set contains entries that are already matched by the
startsWith('@unhead/') check in isHelperSource, so simplify by reducing
HELPER_SOURCES to only the non-@unhead entry (keep 'unhead') and let the prefix
check cover all `@unhead/`* variants; update the HELPER_SOURCES declaration used
by isHelperSource accordingly (referencing HELPER_SOURCES and isHelperSource) so
the function logic remains correct and less redundant.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cli/src/oxc/sfc.ts`:
- Line 20: The regex constant SCRIPT_OPEN_RE currently uses an alternation
("[^"]*"|'[^']*'|[^>]) that makes the third branch overlap with the quoted
branches and triggers ReDoS; update SCRIPT_OPEN_RE so the fallback character
class excludes quote characters (e.g. use a class that excludes double-quote,
single-quote and >) so the alternatives become non-overlapping and backtracking
is eliminated, preserving the same matches and flags on the existing
SCRIPT_OPEN_RE.

In `@packages/eslint-plugin/src/utils/createPredicateRule.ts`:
- Around line 34-39: collectImportedHelpers currently records canonical helper
names (e.g., defineLink/defineScript) from spec.imported.name instead of the
actual local binding, so fixes applied later in applyDiagnostic.ts (uses
fix.wrapWith(...)) produce invalid code for aliased imports; update
collectImportedHelpers to store the local identifier (spec.local.name) for
ImportSpecifier cases (or store both canonical and local names and pass the
local binding into the fixer) so generated wraps use the actual local binding;
you can also remove or mark the ImportDefaultSpecifier branch (it’s dead for
these named exports) if desired.

---

Nitpick comments:
In `@packages/eslint-plugin/src/utils/createPredicateRule.ts`:
- Around line 9-24: The HELPER_SOURCES set contains entries that are already
matched by the startsWith('@unhead/') check in isHelperSource, so simplify by
reducing HELPER_SOURCES to only the non-@unhead entry (keep 'unhead') and let
the prefix check cover all `@unhead/`* variants; update the HELPER_SOURCES
declaration used by isHelperSource accordingly (referencing HELPER_SOURCES and
isHelperSource) so the function logic remains correct and less redundant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6443b38d-0757-4e69-8913-cc0599400ccc

📥 Commits

Reviewing files that changed from the base of the PR and between 438f861 and 999e1f6.

📒 Files selected for processing (3)
  • packages/cli/src/oxc/sfc.ts
  • packages/cli/test/sfc.test.ts
  • packages/eslint-plugin/src/utils/createPredicateRule.ts

Comment thread packages/cli/src/oxc/sfc.ts Outdated
Comment thread packages/eslint-plugin/src/utils/createPredicateRule.ts
CodeQL flagged the new `(?:"[^"]*"|'[^']*'|[^>])*` regex for
catastrophic backtracking — the three branches all accept `"` and `'`,
so the engine explores exponentially many paths on inputs like
`"""""...`. Restrict the unquoted branch to `[^>"']` so each character
deterministically routes to exactly one alternative.

Behaviourally identical for well-formed `<script>` openers; only
malformed HTML with stray unmatched quotes is now rejected (it was
ambiguous before).
`collectImportedHelpers` previously stored canonical names
(`defineLink`, `defineScript`), but the `prefer-define-helpers` autofix
references whatever the file's local binding is. For
`import { defineLink as dl } from 'unhead'`, the fix was emitting
`defineLink(...)` — an undefined symbol that breaks the file.

Change `PredicateContext.importedHelpers` from `Set<canonical>` to
`Map<canonical, local>`. Both the eslint-plugin and CLI walkers now
record the local binding via `spec.local.name`. The
`prefer-define-helpers` predicate looks up the local binding and emits
a `wrap-tag` fix using it.

Drop the dead `ImportDefaultSpecifier` branch — `defineLink` and
`defineScript` are named exports, never defaults.

New test covers the alias path: `Map([['defineLink', 'dl']])` produces
a fix with `wrapWith: 'dl'`.
Previously a piece that failed to parse (or had parser errors) was
silently `continue`d, so a real-world repo with one broken file would
report zero issues for that file — easy to mistake for "clean".

Emit a `parse-error` warning on the first line of the affected piece
when parseSync throws or returns errors. The diagnostic carries the
parser's own message so users can locate the real issue. Subsequent
pieces in the same file still run.

This is not type-checking; it only flags inputs the parser couldn't
read at all.
Two debugging-oriented improvements after running the audit on a real
Nuxt project and getting silent zero-issue output:

Coverage checks
- Track each recognised head/seo call site (`useHead`, `useSeoMeta`,
  `defineLink`, `defineNuxtConfig`, …) per file via a new `onCall`
  visitor hook. Surface them on `AuditFileResult.headCalls`.
- Format prints a `Scanned N files with head usage and no issues:`
  block listing every clean file with a green ✓ and the call
  composition (e.g. `useHead ×2, useSeoMeta`). Files with diagnostics
  are unchanged.
- Without this users had no way to tell whether a clean run meant
  "no problems" or "the CLI never saw your code".

nuxt.config app.head
- `defineNuxtConfig({ app: { head: {...} } })` carries a head input
  that's functionally identical to a `useHead` call but was previously
  invisible to the audit. Walker now recognises `defineNuxtConfig`,
  finds the `app.head` ObjectExpression, and fans it out through the
  existing head-input + tag-array predicates.
- New test covers an `app.head` config with `<b>` in the title and a
  twitter handle missing the `@` — both fire as expected.
Pages under any pages/ directory should set page-specific head metadata
for SEO; if they don't, you have a missed-opportunity bug. The check
runs after the per-file pass:

1. extractCallGraph(program): for each named function defined in a
   file, the set of identifier names called inside its body, plus all
   identifier names called anywhere in the file. Cheap (one extra walk
   per function body).
2. After all files are scanned, fixpoint over the merged graphs to
   compute a "head-providing" identifier set: seeded with the unhead
   composables (useHead, useSeoMeta, useServerHead, …) and grown by
   adding any function whose body calls a name already in the set.
   Catches `usePageMeta() → useDefaultMeta() → useHead()` chains.
3. For each file matching `**/pages/**/*.vue` with no direct head call
   AND no call to any head-providing composable: emit an info-level
   `page-missing-head` diagnostic.

Severity 'info' end-to-end:
- FileDiagnostic.severity union extended to include 'info'.
- summarise() returns infoCount alongside error/warning counts.
- format.ts renders info in cyan, summary line uses ℹ glyph when only
  infos are present, and includes "N info" in the count breakdown.
- audit command exit code unchanged: only errors → exit 1.

Tests: page without head usage in pages/ flagged; page calling a
two-hop project composable that ends in useHead is not flagged.

On nuxtseo.com (8.4k files, 0.35s) the rule surfaces 34 dashboard
pages missing head metadata while the 35 head-using files keep their
green ✓ coverage line.
After running the audit on a dozen of my own projects the output felt
boring — pages mostly clean, the rest was page-missing-head noise.
Title consistency is the first concrete "Project insights" pass, since
the data is already in materializeHeadInput and the recommendation
reaches for a real unhead feature most users don't know about.

audit pipeline
- AuditFileResult gains `titles: TitleObservation[]` and
  `titleTemplates: TitleObservation[]` (resolvable string literals
  with file location). Falls out of the existing materializeHeadInput
  call — only literal strings are captured, dynamic expressions are
  ignored.

analyser (oxc/title-consistency.ts)
- Mixed separators across pages → "3 different separators in use"
  pointing at templateParams.separator + a single titleTemplate.
- Common trailing suffix that ≥50% of titles share (and ≥3 pages) →
  "set titleTemplate + templateParams.siteName, drop the suffix from
  every page". Suffix candidates with %templateParams are skipped so
  pages already doing it right don't trip the check.
- Redundant suffix variant: when titleTemplate is already set in the
  project AND pages still hardcode the suffix → warn that titles will
  render the suffix twice.
- Mix of literal titles and titles using template params → suggest
  picking one approach.

format.ts
- New "Title consistency" section above the per-file diagnostics list.
  Each finding shows headline, ⇒ educational hint, then up to 5 sample
  occurrences with truncated values.

sfc.ts (drive-by)
- LANG_ATTR_RE now accepts unquoted (`lang=ts`) and single-quoted
  attribute values, not just double-quoted. Found while running the
  CLI on forgd: ~25 components used `<script setup lang=ts>` (no
  quotes — valid Vue) and were being detected as `lang: 'js'`, then
  the JS parser bailed on `import type` etc. The CLI dutifully
  surfaced these as parse-error warnings — accurate but pointing at
  the wrong root cause.
Previously the title-consistency analyser only saw `title:` literals
inside calls to known unhead composables (`useHead`, `useSeoMeta`,
`defineNuxtConfig`). Project-local wrappers like
`useToolSeo({ title: '…' })` were invisible even though the fixpoint
already classified them as head-providing — so on nuxtseo.com the
analyser reported "1 of 16 titles use templates" when the real number
is "9 of 24".

walker
- New `extractCandidateTitles(program)` walks every CallExpression and
  captures literal/template-string `title:` and `titleTemplate:` props
  along with the callee name. Template literals with `${…}` substitutions
  are coerced to placeholder text and tagged `dynamic: true`.

audit
- Per-file pass collects candidate titles into pendingCandidates.
- After the fixpoint runs (already needed for page-missing-head), fold
  any candidate whose callee landed in headProviding into the result's
  titles/titleTemplates, deduplicated against the direct unhead-callee
  captures.

This lights up consistency analysis on every project that wraps unhead
in a domain-specific helper.
When a useHead call is meta-only — title + description + an array of
{ name|property|http-equiv, content } entries — it is functionally
equivalent to a useSeoMeta call but with much weaker TypeScript
inference. The flat useSeoMeta shape gives autocomplete on every
known SEO/OG/Twitter key.

new check (oxc/prefer-use-seo-meta.ts)
- analyzeUseHeadForUseSeoMeta walks the input ObjectExpression and
  bails on anything not losslessly translatable: link/script/style/
  noscript/htmlAttrs/bodyAttrs blocks, computed keys, spread, shorthand
  props, meta entries with extra attrs (media, key, id, hid,
  tagPriority, …), duplicate flat-keys, non-literal name/property
  values. Otherwise returns the new prop list with original source
  text preserved for each value.
- metaTagToFlatKey converts `og:image:secure_url` → `ogImageSecureUrl`,
  `theme-color` → `themeColor`, `X-UA-Compatible` → `xUaCompatible` by
  splitting on `:`/`-`/`_` and camelCasing.
- renderUseSeoMetaArg materialises the new ObjectExpression source,
  detecting indent from the original literal so the rewrite preserves
  the file's style.

walker
- onHeadInput visitor now receives the surrounding CallExpression as a
  third argument so the rewrite can locate the `useHead` identifier
  and replace it.

audit pipeline
- For every onHeadInput where callee === 'useHead', run the analyser.
  If eligible: emit an info diagnostic with line/column at the call
  itself, and in migrate mode apply two MagicString edits — replace
  the callee identifier with `useSeoMeta` and replace the input arg
  with the rendered new shape.
- prefer-use-seo-meta added to RECOMMENDED_SEVERITY as 'info'. Also
  recorded explicit severities for parse-error and page-missing-head
  so the table is complete.

tests cover: positive case with mixed name/property/http-equiv,
bail on link/script blocks, bail on extra attrs, migrate output
shape, dynamic content preservation, no-op on title-only useHead.

Surfaces real candidates on nuxtseo.com and mdream.dev.
The original info classification framed this as a stylistic upgrade,
but the underlying motivation is correctness. useHead's meta array
types name/property/content as plain strings, so a typo like
\`name: 'descriptipon'\` compiles cleanly and ships a broken meta tag
that crawlers silently ignore. useSeoMeta's keys are typed against
MetaFlat, so the same mistake fails at write time.

Reframing the message and bumping severity so the diagnostic actually
gets fixed instead of sitting in the info tier indefinitely.
Update both packages/cli/README.md and the docs CLI page with
everything added in this branch:

- coverage check output (✓ scanned files with head usage)
- parse-error diagnostic
- nuxt.config app.head support (audited as a head input)
- Project insights section: page-missing-head, prefer-use-seo-meta,
  title consistency analysis
- updated rule table with the four new CLI-only rule classes
- migrate command description includes the useSeoMeta conversion

Also drops the lingering "uses @unhead/eslint-plugin's recommended
config" line from the docs page — the CLI no longer depends on
ESLint after the predicate-extraction refactor.
@harlan-zw harlan-zw merged commit 35100fe into main Apr 27, 2026
7 of 8 checks passed
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