feat(cli,eslint-plugin): add @unhead/cli and @unhead/eslint-plugin#755
Conversation
Adds two new packages plus a shared `unhead/validate` subpath: - `@unhead/eslint-plugin` -- flat-config ESLint plugin with rules for v2-to-v3 migration, type narrowing, and SEO/perf foot-guns (no-deprecated-props, no-unknown-meta, numeric-tag-priority, preload-* rules, robots-conflict, twitter-handle-missing-at, prefer-define-helpers, etc.) - `@unhead/cli` -- `unhead` binary wrapping the ESLint plugin (`audit`, `migrate`) and the runtime ValidatePlugin (`validate-html`, `validate-url`) - `unhead/validate` -- shared rule IDs, known-meta sets, and Levenshtein helper consumed by the runtime ValidatePlugin, the ESLint plugin, and the CLI so all three layers stay aligned
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a new Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant CLI as "unhead validate-url"
participant Net as "HTTP Client"
participant Validator as "validateHtml()"
participant Plugin as "ValidatePlugin"
participant Reporter as "printReport / JSON"
User->>CLI: run validate-url <URL> [--json]
CLI->>Net: fetch(URL) with UA & timeout
Net-->>CLI: HTML response (or error)
CLI->>Validator: validateHtml(html, source=URL)
Validator->>Plugin: SSR render + onReport callbacks
Plugin-->>Validator: collected HeadValidationRule[]
Validator-->>CLI: ValidationOutput
CLI->>Reporter: format (JSON or human)
Reporter-->>User: output
alt any rule severity == "warn"
CLI-->>User: set exit code 1
end
sequenceDiagram
participant User
participant CLI as "unhead migrate"
participant ESLint as "ESLint Runner"
participant Fixer as "ESLint.outputFixes"
participant Summary as "CLI summary"
User->>CLI: run migrate [--dry-run] <patterns>
CLI->>ESLint: runLint(mode="migrate", patterns, write based on dry-run)
ESLint-->>CLI: results + counts
alt not --dry-run
CLI->>Fixer: persist fixes
Fixer-->>CLI: files modified
end
CLI->>Summary: print formatted results and counts
Summary-->>User: output
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 14
🧹 Nitpick comments (16)
packages/cli/bin/unhead.mjs (1)
4-7: Optional: preferprocess.exitCode = 1overprocess.exit(1)to avoid truncating buffered stderr.
process.exit(1)terminates the process synchronously and can drop pending stdio writes on some platforms. Settingprocess.exitCodelets Node finish flushing theconsole.erroroutput before exiting naturally.♻️ Proposed refactor
import ('../dist/index.mjs').then(m => m.run()).catch((err) => { console.error(err) - process.exit(1) + process.exitCode = 1 })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/bin/unhead.mjs` around lines 4 - 7, The catch block currently calls process.exit(1) which can truncate buffered stderr; change it to set process.exitCode = 1 instead and allow the process to exit naturally after logging the error (keep the existing console.error(err) call in the Promise rejection handler that follows the dynamic import and m.run invocation). Target the Promise chain created by import('../dist/index.mjs').then(m => m.run()).catch((err) => { ... }) and replace the synchronous exit call with setting process.exitCode so pending stdio can flush.packages/cli/test/lint.test.ts (1)
17-27: Brittle assertion — duplicate rule hits or extra fixture files will break it.
results[0].messages.map(m => m.ruleId)includes one entry per ESLint message. If any rule fires more than once on the fixture (or if a future fixture file is added under**/*.ts), the sorted-arraytoEqualwill fail even when behavior is correct. Also, parse errors surface asruleId: null, which would silently appear in the array.Consider asserting on the unique set instead, and asserting non-null:
♻️ Proposed refactor
- expect(results).toHaveLength(1) - const ruleIds = results[0].messages.map(m => m.ruleId).sort() - expect(ruleIds).toEqual([ - '@unhead/defer-on-module-script', - '@unhead/no-html-in-title', - '@unhead/non-absolute-canonical', - '@unhead/preload-font-crossorigin', - '@unhead/robots-conflict', - '@unhead/twitter-handle-missing-at', - ].sort()) + expect(results).toHaveLength(1) + const ruleIds = new Set(results[0].messages.map(m => m.ruleId)) + expect(ruleIds.has(null)).toBe(false) // no parse/config errors + expect(ruleIds).toEqual(new Set([ + '@unhead/defer-on-module-script', + '@unhead/no-html-in-title', + '@unhead/non-absolute-canonical', + '@unhead/preload-font-crossorigin', + '@unhead/robots-conflict', + '@unhead/twitter-handle-missing-at', + ])) expect(errorCount + warningCount).toBeGreaterThan(0)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/test/lint.test.ts` around lines 17 - 27, The assertion is brittle because results[0].messages may contain duplicate ruleIds and nulls; update the test in lint.test.ts to dedupe and filter out null ruleIds before asserting: derive the uniqueRuleIds from results[0].messages by filtering m.ruleId != null and creating a Set (or equivalent) to remove duplicates, then assert that the expected list (the six '@unhead/...' IDs) is a subset of uniqueRuleIds (e.g., use an arrayContaining-style assertion or compare Sets) and keep the existing non-zero error/warning check; reference the variables/results used in the diff: results, results[0].messages, ruleIds, and errorCount/warningCount.packages/eslint-plugin/src/rules/canonical-rules.ts (1)
4-6: Protocol comparison should be case-insensitive.URL schemes are case-insensitive per RFC 3986, so a literal like
HTTP://example.comwould be (incorrectly) reported as non-absolute. Trivially fixed:♻️ Proposed fix
function isAbsolute(url: string): boolean { - return url.startsWith('http://') || url.startsWith('https://') + return /^https?:\/\//i.test(url) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/rules/canonical-rules.ts` around lines 4 - 6, The isAbsolute function incorrectly treats schemes case-sensitively; update isAbsolute to compare the URL scheme case-insensitively by normalizing the input (e.g., call toLowerCase() on the url) or use a case-insensitive regex when checking startsWith('http://') || startsWith('https://') so URLs like "HTTP://..." are recognized as absolute; update the function name isAbsolute accordingly and ensure any callers still receive the same boolean behavior.packages/eslint-plugin/src/rules/twitter-handle-missing-at.ts (1)
40-40: Fixer rewrites the original quote style.The fix unconditionally emits a single-quoted literal (
'@${v}'), so a source likecontent: "foo"becomescontent: '@foo'. That conflicts with projects whosequotesrule prefers double quotes and produces churn / autofix loops with stylistic linters. It also doesn’t escapev, so a value containing a'would emit broken syntax (unlikely for a Twitter handle, but the literal itself is untrusted user code).A range-insert preserves the original quote and avoids escape concerns:
♻️ Proposed fix
- fix: fixer => fixer.replaceText(value, `'@${v}'`), + fix: fixer => fixer.insertTextAfterRange([value.range![0], value.range![0] + 1], '@'),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/rules/twitter-handle-missing-at.ts` at line 40, The fixer currently calls fixer.replaceText(value, `'@${v}'`) which force‑writes a single‑quoted string and doesn't escape v; change the fixer to insert the @ inside the existing string token instead of replacing it: locate the string token represented by value, detect its original quote char, and use a range/insert fixer (e.g. fixer.insertTextAfterRange or fixer.replaceTextRange on value.range) to insert '@' immediately after the opening quote (or insert before closing quote if needed), using the original quotes so you preserve quote style and avoid introducing escaping issues for v; keep using the existing v variable to build the inserted text.packages/eslint-plugin/src/rules/title-rules.ts (1)
5-46: Minor false-positive risk and a small coverage gap.
HTML_CHARS_RE = /[<>]/will trigger on legitimate titles such as"5 < 10"or breadcrumbs like"Docs > API". Since the rule is awarn, this is tolerable, but the message ("HTML characters") may be confusing in those cases. Tightening the regex to detect tag-like patterns (e.g.,/<\/?[a-z][^>]*>/i) or entity references would reduce noise.- The rule only inspects string titles.
useHead({ title: { textContent: '...' } })is a supported form and would silently bypass this check. Optional to extend, but worth a comment if intentionally out of scope.🤖 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 - 46, The current HTML_CHARS_RE in noHtmlInTitle is too broad and misses object-form titles; replace HTML_CHARS_RE with a stricter pattern that matches tag-like sequences and entity references (e.g. /<\/?[a-z][^>]*>/i or also detect /&[a-zA-Z0-9#]+;/) and use that in checkTitle to reduce false positives; additionally update the CallExpression visitor (inside create) to also handle title provided as an ObjectExpression with a textContent property by using findProperty on the title object and passing its value into checkTitle (or add a comment if you intentionally keep object-title out of scope), ensuring references to HTML_CHARS_RE, checkTitle, CallExpression, and findProperty are updated accordingly.packages/cli/src/commands/migrate.ts (1)
14-35: Positional argpatternsis declared but unused; consumeargs.patternsconsistently.The
patternspositional is declared inargs, but the code readsargs._directly and ignoresargs.patterns. With citty's positional handling this happens to work, but it's confusing for maintainers and breaks if citty's positional shape changes (e.g., the named-positional binding moves from_toargs.patterns). Either drop the named positional, or read it through the typedargsaccessor.🤖 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 14 - 35, The code declares a positional arg "patterns" but then reads args._ directly; update the run logic in the migrate command to consume args.patterns (or remove the named positional). Specifically, inside async run({ args }) replace the positional extraction that uses args._ and instead read const positional = (Array.isArray(args.patterns) ? args.patterns : (args.patterns ? [String(args.patterns)] : [])) .map(String).filter(Boolean) (or simply use args.patterns as an array) and then compute patterns = positional.length > 0 ? positional : DEFAULT_PATTERNS; ensure any fallbacks to args._ are removed so the code consistently uses args.patterns and keep references to DEFAULT_PATTERNS and cwd resolution unchanged.packages/eslint-plugin/test/no-deprecated-props.test.ts (1)
8-30: Coverage looks solid; consider one extra case.The current invalid cases each exercise one deprecated prop in isolation. Worth adding a single invalid case combining multiple deprecations on one tag (e.g.,
{ children: 'x', body: true, hid: 'k' }) to lock in the behavior when multiple fixes are applied in the same pass — historically a common source of fixer conflicts.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/test/no-deprecated-props.test.ts` around lines 8 - 30, Add an invalid test case to the tester.run('no-deprecated-props', noDeprecatedProps, ...) suite that exercises multiple deprecated props on the same tag (e.g., an object with children: 'x', body: true, hid: 'k') so the fixer is validated to handle simultaneous fixes; specify the expected output with children → innerHTML, body → tagPosition: 'bodyClose', hid → key, and include corresponding errors (messageId: 'deprecated' entries for each deprecated prop) to ensure all fixes are applied in one pass by the rule's fixer.packages/eslint-plugin/src/rules/preload-rules.ts (1)
43-60: LGTM with a tiny redundancy.Logic and autofix look correct, including trailing-comma cases. Small nit:
findProperty(tag, 'as')is called twice (once viagetStringPropon Line 49 and again on Line 53 with a!non-null assertion). Capture it once to drop the assertion:- if (getStringProp(tag, 'as') !== 'font') + const asProp = findProperty(tag, 'as') + if (!asProp || getStringValue(asProp.value) !== 'font') return if (findProperty(tag, 'crossorigin')) return - const asProp = findProperty(tag, 'as')! ctx.report({Optional.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/rules/preload-rules.ts` around lines 43 - 60, Capture the "as" property once to avoid the redundant call and drop the non-null assertion: in the createTagVisitor -> onTag handler, call findProperty(tag, 'as') once into a const (e.g., asProp), early-return if it's missing, then use getStringProp(tag, 'as') to check it's 'font' and finally use the captured asProp in the fixer instead of calling findProperty(tag, 'as')!; this removes the extra lookup and the `!` assertion while preserving the same logic.packages/eslint-plugin/src/rules/robots-conflict.ts (1)
24-31: Extend conflict detection to treatnoneas equivalent tonoindex, nofollow.Google's robots meta documentation confirms that
noneis equivalent tonoindex, nofollow. The current implementation misses real conflicts likecontent="none, index"orcontent="none, follow". Adding this equivalence would improve the rule's conflict coverage without additional complexity.♻️ Proposed extension
const directives = content.toLowerCase().split(',').map(d => d.trim()) - if (directives.includes('index') && directives.includes('noindex')) + const hasNone = directives.includes('none') + const hasNoindex = directives.includes('noindex') || hasNone + const hasNofollow = directives.includes('nofollow') || hasNone + if (directives.includes('index') && hasNoindex) ctx.report({ node: tag, messageId: 'indexConflict' }) - if (directives.includes('follow') && directives.includes('nofollow')) + if (directives.includes('follow') && hasNofollow) ctx.report({ node: tag, messageId: 'followConflict' })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/rules/robots-conflict.ts` around lines 24 - 31, The rule currently parses meta tag content into directives but doesn't treat "none" as equivalent to "noindex" and "nofollow", so conflicts like content="none, index" are missed; update the logic in robots-conflict.ts (around getStringProp, tag, directives, and the ctx.report checks) to normalize "none" by mapping it to both "noindex" and "nofollow" (e.g., if directives.includes('none') then treat it as if directives also include 'noindex' and 'nofollow') before running the existing index/follow conflict checks that call ctx.report with messageId 'indexConflict' and 'followConflict'.packages/eslint-plugin/src/index.ts (1)
33-39: Optional: keep pluginmeta.versionin sync withpackage.jsonautomatically.The
'3.0.5'literal duplicatespackages/eslint-plugin/package.json'sversionand will drift on release. Since you're building withunbuild, you can inline the version frompackage.json(e.g. via areplacestep or by importingversionfrom../package.jsonwith a JSON resolver) so future bumps don't require touching this file.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/index.ts` around lines 33 - 39, The hard-coded plugin version in the plugin object (plugin.meta.version) will drift; replace the literal '3.0.5' by sourcing the package version dynamically (e.g., import { version } from the package.json or inject it during the unbuild replace step) so plugin.meta.version uses that imported/injected version; update the plugin object (plugin.meta.version) to reference that symbol instead of the string literal.packages/eslint-plugin/test/rules.test.ts (1)
11-157: LGTM — broad messageId + fixer-output coverage.Good mix of
validcases andinvalidcases asserting bothmessageIdand (where applicable) theoutputafter autofix. Consider adding at least one negative-fixer case (e.g., atwitter:sitevalue that is purely numeric) to lock in the "don't fix numeric IDs" branch intwitter-handle-missing-at, but that's optional.🤖 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 11 - 157, Add a negative-fixer test to the twitter-handle-missing-at suite: in the tester.run('twitter-handle-missing-at', ...) invalid array add a case with code `useHead({ meta: [{ name: 'twitter:site', content: '12345' }] })` that asserts errors: [{ messageId: 'missingAt' }] but does NOT include an output property, so the rule is tested to report the error without applying a fixer for numeric IDs; locate the twitter-handle-missing-at test block and append this invalid case alongside the existing cases.packages/cli/src/commands/validate-html.ts (1)
17-38: Drop unusedpatternsdeclaration —args._is the recommended approach for citty.
patternsis declared as a single positional inargsbutrun()ignores it and readsargs._directly. This declaration is dead code and should be removed.Note: Citty does not support variadic/rest positional arguments in stable releases (a feature request exists as PR
#199). Usingargs._to access unmatched positionals is the officially recommended approach until that feature lands.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/src/commands/validate-html.ts` around lines 17 - 38, Remove the unused positional declaration "patterns" from the args object since run() reads unmatched positionals via args._; update the args block to only include cwd and json (or any true named positionals you need), leaving DEFAULT_PATTERNS and the logic in run() unchanged (the run function already resolves cwd, reads args._ into positional, and falls back to DEFAULT_PATTERNS), and ensure no other code references args.patterns so there are no remaining dead symbols.packages/eslint-plugin/src/rules/numeric-tag-priority.ts (1)
33-36: Optional: simplify alias→messageId mapping with a lookup table.The nested ternary works but is harder to extend. A small map keeps the relationship explicit:
♻️ Proposed refactor
- suggest: (['critical', 'high', 'low'] as const).map(alias => ({ - messageId: alias === 'critical' ? 'suggestCritical' : alias === 'high' ? 'suggestHigh' : 'suggestLow', - fix: fixer => fixer.replaceText(value, `'${alias}'`), - })), + suggest: ([ + ['critical', 'suggestCritical'], + ['high', 'suggestHigh'], + ['low', 'suggestLow'], + ] as const).map(([alias, messageId]) => ({ + messageId, + fix: fixer => fixer.replaceText(value, `'${alias}'`), + })),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/rules/numeric-tag-priority.ts` around lines 33 - 36, Replace the nested ternary used to pick messageId inside the suggest map with an explicit lookup table to make the relation clearer and easier to extend: define a const mapping (e.g., messageIdMap = { critical: 'suggestCritical', high: 'suggestHigh', low: 'suggestLow' }) and then in the suggest map (the array mapping over alias) use messageId: messageIdMap[alias] while keeping the existing fix: fixer => fixer.replaceText(value, `'${alias}'`).packages/eslint-plugin/src/rules/viewport-user-scalable.ts (1)
4-5: Optional: dedupe regexes shared with the runtime validator.
USER_SCALABLE_NO_RE/MAX_SCALE_REare duplicated verbatim frompackages/unhead/src/plugins/validate.ts. Since the goal of theunhead/validatesubpath is exactly this kind of shared rule data, consider exporting these fromunhead/validateso the lint rule and runtime check can never drift.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/rules/viewport-user-scalable.ts` around lines 4 - 5, Replace the duplicated regex constants in the lint rule with imports from the shared validator module: export USER_SCALABLE_NO_RE and MAX_SCALE_RE from the unhead/validate subpath (where the runtime validator lives) and then remove the local definitions in packages/eslint-plugin/src/rules/viewport-user-scalable.ts and import those two symbols instead; update any usages in the rule to reference the imported USER_SCALABLE_NO_RE and MAX_SCALE_RE so the runtime validator and linter share the same single source of truth.packages/unhead/src/plugins/validate.ts (1)
412-437: Optional: merge the two adjacentfor (const tag of tags)loops.The deprecated-props check and the numeric-
tagPrioritycheck both walk every tag back-to-back. Folding them into a single pass is a small but free win and keeps related v2→v3 migration warnings co-located.♻️ Proposed refactor
// Deprecated v2 property names (no longer auto-converted) for (const tag of tags) { for (const propName of Object.keys(DEPRECATED_PROPS)) { if (!(propName in tag.props)) continue if (propName === 'body' && tag.props.body !== true) continue const { replacement, ruleId } = DEPRECATED_PROPS[propName] const message = propName === 'body' ? `"body: true" was removed in v3. Use "${replacement}" instead.` : `"${propName}" was removed in v3. Use "${replacement}" instead.` report(ruleId, message, 'warn', tag) } - } - - // Numeric tagPriority (alias is preferred for readability and stability) - for (const tag of tags) { if (typeof tag.tagPriority === 'number') { report( 'numeric-tag-priority', `Numeric tagPriority (${tag.tagPriority}) is brittle. Prefer an alias ('critical' | 'high' | 'low'), or 'before:<key>' / 'after:<key>' to position relative to another tag.`, 'info', tag, ) } }🤖 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 412 - 437, Merge the two adjacent iterations over tags into a single loop to avoid double traversal: inside one "for (const tag of tags)" handle both the DEPRECATED_PROPS check (including the special-case "body" handling that compares tag.props.body === true and uses DEPRECATED_PROPS[propName].replacement and .ruleId to call report) and the numeric tagPriority check (if typeof tag.tagPriority === 'number' call report with the same message). Keep the same report ruleIds, messages and severity levels and ensure both checks run for every tag in that single loop.packages/cli/package.json (1)
53-55: Consider adding anengines.nodefield to prevent install-time failures on incompatible Node versions.
@unhead/clipeers oneslint >= 9, which requires Node^18.18.0 || ^20.9.0 || >=21.1.0. Without anenginesfield, users on EOL Node versions will encounter opaque ESLint runtime errors rather than clear install-time warnings.Proposed addition
"peerDependencies": { "eslint": ">=9.0.0" }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, "dependencies": {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/package.json` around lines 53 - 55, Add an engines.node entry to package.json to prevent installs on unsupported Node versions: edit package.json's "engines" field (key "engines.node") to at least the range required by the peer dependency (eslint), e.g. " ^18.18.0 || ^20.9.0 || >=21.1.0", so that npm/yarn will warn/refuse installs on incompatible Node runtimes; ensure you update the package.json that contains the existing "peerDependencies" block and run a quick package lint/format afterwards.
🤖 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/README.md`:
- Around line 35-54: The README for the CLI is missing documentation of
exit-code behavior for the commands `unhead validate-html` and `unhead
validate-url`; update the docs under those command sections to explicitly state
the exit codes and current behavior (that the runtime `ValidatePlugin` causes
the CLI to exit with code 1 on any rule with severity: 'warn' as opposed to
`audit` which exits on errors only), and either (a) change the implementation to
align with `audit`'s semantics or (b) clearly call out the difference so CI
consumers know that `unhead validate-html` and `unhead validate-url` will
fail/exit 1 on warnings; reference the `ValidatePlugin` and the command names in
the text so readers can locate the related behavior.
In `@packages/cli/src/commands/audit.ts`:
- Around line 14-30: The positional `patterns` declared in the command args is
never read because the run handler uses args._; update the run implementation to
read args.patterns directly (e.g., compute patterns with something like: const
patterns = args.patterns ? [args.patterns] : DEFAULT_PATTERNS) and remove or
stop relying on the args._/positional variable (positional) in the audit
command’s run function; ensure the same fix is applied to the run handlers in
migrate.ts and validate-html.ts so they use args.patterns (or remove the
declared positional to opt for variadic behavior with documentation) and still
fall back to DEFAULT_PATTERNS when no pattern is provided.
In `@packages/cli/src/commands/migrate.ts`:
- Around line 37-58: The dry-run currently uses mode='audit' so runLint computes
fixes against the smaller recommended rule set; change runLint to accept an
explicit write boolean (e.g., runLint({patterns, mode, cwd, ignore, write})) and
update migrate.ts to always call runLint with mode: 'migrate' but write: !dryRun
(keep dryRun boolean for messaging only); update runLint implementation (and any
callers) to use the new write flag to decide whether to actually write fixes to
disk (instead of deriving write from mode) and keep mode only for selecting the
rule set (e.g., use unheadConfigs.migration when mode==='migrate').
In `@packages/cli/src/commands/validate-url.ts`:
- Around line 50-51: The command currently sets process.exitCode = 1 when any
result.rules has severity 'warn'; change the failure condition to only trigger
on severity === 'error' to match the documented behavior of unhead audit (i.e.,
replace the predicate in the check that uses result.rules.some(r => r.severity
=== 'warn') with result.rules.some(r => r.severity === 'error') in
validate-url.ts), or alternately add and honor a CLI option like
--fail-on/--max-severity and use that value to decide the predicate; ensure the
change is mirrored in validate-html.ts to keep behavior consistent across
subcommands.
- Around line 5-16: fetchHtml currently uses fetch without a timeout, redirect
cap, or content-type check; update fetchHtml to use an AbortController with a
configurable timeout (e.g., 5–10s) and pass signal to fetch to abort slow
requests, set fetch option redirect to 'follow' with a manual redirect cap by
counting responses (or use RequestInit's maxRedirects if available) to avoid
infinite redirect chains, and after receiving the response validate Content-Type
(e.g., startsWith 'text/html') before returning res.text(), throwing a clear
error if the content-type is not HTML so downstream validateHtml receives only
HTML input. Ensure the function name fetchHtml and any callers (e.g.,
validateHtml) handle the thrown timeout/Content-Type errors appropriately.
In `@packages/cli/src/index.ts`:
- Around line 8-11: Update the CLI top-level meta.description (the meta object
alongside name: 'unhead' in packages/cli/src/index.ts) to reflect the new
validate commands; change the string from "Audit and migrate unhead head usage."
to a broader summary that mentions validate-html and validate-url (e.g., include
"validate-html" and "validate-url" or say "includes validation commands"), so
the output of unhead --help accurately describes all primary commands.
In `@packages/cli/src/validate.ts`:
- Around line 38-40: The summary line currently prints warnings with no
pluralization; update the console.log call that uses rules, warnings, and info
so the warnings word is pluralized like issues (e.g., use `${warnings}
warning${warnings === 1 ? '' : 's'}`) while keeping info unchanged; locate the
console.log that formats `\n ${rules.length} issue... (${warnings} warning,
${info} info)` and replace the warning segment to conditionally append 's' based
on the warnings variable.
In `@packages/cli/test/lint.test.ts`:
- Around line 9-29: The fixture-driven audit in runLint(test) is missing
coverage for the '@unhead/no-unknown-meta' rule; add a test that asserts this
rule fires (either include '@unhead/no-unknown-meta' in the expected ruleIds
array in packages/cli/test/lint.test.ts or create a dedicated unit test for the
rule in packages/eslint-plugin/test/—e.g., add a case in rules.test.ts that runs
the rule's linter against a small source fixture demonstrating the unknown meta
and asserts a message with ruleId 'no-unknown-meta'), and ensure the test uses
the same runLint or rule test harness functions so the CI recognizes the rule as
covered.
In `@packages/cli/test/validate-url.test.ts`:
- Around line 1-30: The test file is misnamed: it exercises validateHtml(html,
'inline') rather than the URL-fetching entry point; either move these test cases
into the validate-html.test.ts suite and delete this file, or rewrite this test
to exercise the validate-url command entry (the command that fetches HTML,
follows redirects, sets the social-crawler User-Agent, and handles HTTP errors)
by invoking the command handler and mocking the HTTP layer; when rewriting, mock
fetch/HTTP client to assert the request headers (e.g., User-Agent =
facebookexternalhit), simulate redirects and non-2xx responses, and verify the
handler passes the fetched HTML into validateHtml and returns expected rule IDs
(eg. check for non-absolute-canonical and canonical-og-url-mismatch).
In `@packages/eslint-plugin/src/rules/no-deprecated-props.ts`:
- Around line 33-55: The autofix in no-deprecated-props.ts can create duplicate
object keys by blindly replacing deprecated keys (propName) with target names
('key', 'innerHTML', 'tagPosition'); update the fix logic in the rule where
ctx.report is called (around propName, key and fixer.replaceText usage) to first
scan the surrounding object properties for an existing property with the
intended newKeyName (compute newKeyName for 'children'/'hid'/'vmid' and
'tagPosition' for body), and if such a property exists do not apply an automatic
fixer but still call ctx.report (or use ctx.report with suggest instead) so the
rule reports but avoids applying a fix that would produce duplicate keys; ensure
the special-body case (body true → tagPosition) performs the same existence
check before returning a fixer.
In `@packages/eslint-plugin/src/rules/prefer-define-helpers.ts`:
- Around line 41-49: The autofix currently wraps the object literal with
`${helper}(...)` in ctx.report (see fixer.replaceText usage) without ensuring
the helper (e.g. defineMeta, defineLink) is imported, which can introduce an
undeclared identifier; update the fixer to first inspect the Program AST
(Program.body ImportDeclaration nodes) for an import from 'unhead' /
'@unhead/vue' / other allowed modules that contains the helper identifier and
only apply the automatic fixer if that import exists, otherwise do not apply an
automatic fix and instead register a non-automatic suggestion (use
context.report with hasSuggestions or emit a suggestion) that shows the
replacement and/or adds an import; alternatively implement the fixer to add a
missing import by returning a compound fix that inserts an ImportDeclaration for
the helper at the top of the file plus the existing replaceText, ensuring no
duplicate import is added (detect existing imports by name/module before
inserting).
In `@packages/eslint-plugin/src/utils/visitor.ts`:
- Around line 80-91: findProperty currently returns the first matching property
but JS semantics use the last duplicate key; change findProperty to iterate
obj.properties in reverse (from end to start) and return the first match
encountered so that the returned ESTree.Property reflects the runtime-last key;
preserve the existing guards (prop.type === 'Property', !prop.computed) and the
key checks (k.type === 'Identifier' && k.name === key or k.type === 'Literal' &&
k.value === key); alternatively add a clear comment in findProperty indicating
it intentionally returns the first match if you opt not to change behavior, but
prefer the reverse iteration to make fixers like no-deprecated-props align with
runtime behavior.
- Around line 50-58: The visitor currently misses TypeScript-wrapped nodes
(TSAsExpression, TSTypeAssertion, TSNonNullExpression,
TSParenthesizedExpression), causing checks like getStringValue and the
call-argument/type-narrowing paths to silently return; add a small helper (e.g.,
unwrapTS(node)) that recursively returns the inner expression for those TS
wrappers and use it inside getStringValue and at every site that does arg?.type
or property value type checks (the call-argument branch that checks
ObjectExpression, the getStringValue call sites, and the property/meta value
checks) so TypeScript expressions are unwrapped before performing type checks.
Ensure the helper returns the original node if no unwrap is needed and is called
wherever the diff references getStringValue, the argument/type-narrowing logic,
and the property value assertions.
---
Nitpick comments:
In `@packages/cli/bin/unhead.mjs`:
- Around line 4-7: The catch block currently calls process.exit(1) which can
truncate buffered stderr; change it to set process.exitCode = 1 instead and
allow the process to exit naturally after logging the error (keep the existing
console.error(err) call in the Promise rejection handler that follows the
dynamic import and m.run invocation). Target the Promise chain created by
import('../dist/index.mjs').then(m => m.run()).catch((err) => { ... }) and
replace the synchronous exit call with setting process.exitCode so pending stdio
can flush.
In `@packages/cli/package.json`:
- Around line 53-55: Add an engines.node entry to package.json to prevent
installs on unsupported Node versions: edit package.json's "engines" field (key
"engines.node") to at least the range required by the peer dependency (eslint),
e.g. " ^18.18.0 || ^20.9.0 || >=21.1.0", so that npm/yarn will warn/refuse
installs on incompatible Node runtimes; ensure you update the package.json that
contains the existing "peerDependencies" block and run a quick package
lint/format afterwards.
In `@packages/cli/src/commands/migrate.ts`:
- Around line 14-35: The code declares a positional arg "patterns" but then
reads args._ directly; update the run logic in the migrate command to consume
args.patterns (or remove the named positional). Specifically, inside async run({
args }) replace the positional extraction that uses args._ and instead read
const positional = (Array.isArray(args.patterns) ? args.patterns :
(args.patterns ? [String(args.patterns)] : [])) .map(String).filter(Boolean) (or
simply use args.patterns as an array) and then compute patterns =
positional.length > 0 ? positional : DEFAULT_PATTERNS; ensure any fallbacks to
args._ are removed so the code consistently uses args.patterns and keep
references to DEFAULT_PATTERNS and cwd resolution unchanged.
In `@packages/cli/src/commands/validate-html.ts`:
- Around line 17-38: Remove the unused positional declaration "patterns" from
the args object since run() reads unmatched positionals via args._; update the
args block to only include cwd and json (or any true named positionals you
need), leaving DEFAULT_PATTERNS and the logic in run() unchanged (the run
function already resolves cwd, reads args._ into positional, and falls back to
DEFAULT_PATTERNS), and ensure no other code references args.patterns so there
are no remaining dead symbols.
In `@packages/cli/test/lint.test.ts`:
- Around line 17-27: The assertion is brittle because results[0].messages may
contain duplicate ruleIds and nulls; update the test in lint.test.ts to dedupe
and filter out null ruleIds before asserting: derive the uniqueRuleIds from
results[0].messages by filtering m.ruleId != null and creating a Set (or
equivalent) to remove duplicates, then assert that the expected list (the six
'@unhead/...' IDs) is a subset of uniqueRuleIds (e.g., use an
arrayContaining-style assertion or compare Sets) and keep the existing non-zero
error/warning check; reference the variables/results used in the diff: results,
results[0].messages, ruleIds, and errorCount/warningCount.
In `@packages/eslint-plugin/src/index.ts`:
- Around line 33-39: The hard-coded plugin version in the plugin object
(plugin.meta.version) will drift; replace the literal '3.0.5' by sourcing the
package version dynamically (e.g., import { version } from the package.json or
inject it during the unbuild replace step) so plugin.meta.version uses that
imported/injected version; update the plugin object (plugin.meta.version) to
reference that symbol instead of the string literal.
In `@packages/eslint-plugin/src/rules/canonical-rules.ts`:
- Around line 4-6: The isAbsolute function incorrectly treats schemes
case-sensitively; update isAbsolute to compare the URL scheme case-insensitively
by normalizing the input (e.g., call toLowerCase() on the url) or use a
case-insensitive regex when checking startsWith('http://') ||
startsWith('https://') so URLs like "HTTP://..." are recognized as absolute;
update the function name isAbsolute accordingly and ensure any callers still
receive the same boolean behavior.
In `@packages/eslint-plugin/src/rules/numeric-tag-priority.ts`:
- Around line 33-36: Replace the nested ternary used to pick messageId inside
the suggest map with an explicit lookup table to make the relation clearer and
easier to extend: define a const mapping (e.g., messageIdMap = { critical:
'suggestCritical', high: 'suggestHigh', low: 'suggestLow' }) and then in the
suggest map (the array mapping over alias) use messageId: messageIdMap[alias]
while keeping the existing fix: fixer => fixer.replaceText(value, `'${alias}'`).
In `@packages/eslint-plugin/src/rules/preload-rules.ts`:
- Around line 43-60: Capture the "as" property once to avoid the redundant call
and drop the non-null assertion: in the createTagVisitor -> onTag handler, call
findProperty(tag, 'as') once into a const (e.g., asProp), early-return if it's
missing, then use getStringProp(tag, 'as') to check it's 'font' and finally use
the captured asProp in the fixer instead of calling findProperty(tag, 'as')!;
this removes the extra lookup and the `!` assertion while preserving the same
logic.
In `@packages/eslint-plugin/src/rules/robots-conflict.ts`:
- Around line 24-31: The rule currently parses meta tag content into directives
but doesn't treat "none" as equivalent to "noindex" and "nofollow", so conflicts
like content="none, index" are missed; update the logic in robots-conflict.ts
(around getStringProp, tag, directives, and the ctx.report checks) to normalize
"none" by mapping it to both "noindex" and "nofollow" (e.g., if
directives.includes('none') then treat it as if directives also include
'noindex' and 'nofollow') before running the existing index/follow conflict
checks that call ctx.report with messageId 'indexConflict' and 'followConflict'.
In `@packages/eslint-plugin/src/rules/title-rules.ts`:
- Around line 5-46: The current HTML_CHARS_RE in noHtmlInTitle is too broad and
misses object-form titles; replace HTML_CHARS_RE with a stricter pattern that
matches tag-like sequences and entity references (e.g. /<\/?[a-z][^>]*>/i or
also detect /&[a-zA-Z0-9#]+;/) and use that in checkTitle to reduce false
positives; additionally update the CallExpression visitor (inside create) to
also handle title provided as an ObjectExpression with a textContent property by
using findProperty on the title object and passing its value into checkTitle (or
add a comment if you intentionally keep object-title out of scope), ensuring
references to HTML_CHARS_RE, checkTitle, CallExpression, and findProperty are
updated accordingly.
In `@packages/eslint-plugin/src/rules/twitter-handle-missing-at.ts`:
- Line 40: The fixer currently calls fixer.replaceText(value, `'@${v}'`) which
force‑writes a single‑quoted string and doesn't escape v; change the fixer to
insert the @ inside the existing string token instead of replacing it: locate
the string token represented by value, detect its original quote char, and use a
range/insert fixer (e.g. fixer.insertTextAfterRange or fixer.replaceTextRange on
value.range) to insert '@' immediately after the opening quote (or insert before
closing quote if needed), using the original quotes so you preserve quote style
and avoid introducing escaping issues for v; keep using the existing v variable
to build the inserted text.
In `@packages/eslint-plugin/src/rules/viewport-user-scalable.ts`:
- Around line 4-5: Replace the duplicated regex constants in the lint rule with
imports from the shared validator module: export USER_SCALABLE_NO_RE and
MAX_SCALE_RE from the unhead/validate subpath (where the runtime validator
lives) and then remove the local definitions in
packages/eslint-plugin/src/rules/viewport-user-scalable.ts and import those two
symbols instead; update any usages in the rule to reference the imported
USER_SCALABLE_NO_RE and MAX_SCALE_RE so the runtime validator and linter share
the same single source of truth.
In `@packages/eslint-plugin/test/no-deprecated-props.test.ts`:
- Around line 8-30: Add an invalid test case to the
tester.run('no-deprecated-props', noDeprecatedProps, ...) suite that exercises
multiple deprecated props on the same tag (e.g., an object with children: 'x',
body: true, hid: 'k') so the fixer is validated to handle simultaneous fixes;
specify the expected output with children → innerHTML, body → tagPosition:
'bodyClose', hid → key, and include corresponding errors (messageId:
'deprecated' entries for each deprecated prop) to ensure all fixes are applied
in one pass by the rule's fixer.
In `@packages/eslint-plugin/test/rules.test.ts`:
- Around line 11-157: Add a negative-fixer test to the twitter-handle-missing-at
suite: in the tester.run('twitter-handle-missing-at', ...) invalid array add a
case with code `useHead({ meta: [{ name: 'twitter:site', content: '12345' }] })`
that asserts errors: [{ messageId: 'missingAt' }] but does NOT include an output
property, so the rule is tested to report the error without applying a fixer for
numeric IDs; locate the twitter-handle-missing-at test block and append this
invalid case alongside the existing cases.
In `@packages/unhead/src/plugins/validate.ts`:
- Around line 412-437: Merge the two adjacent iterations over tags into a single
loop to avoid double traversal: inside one "for (const tag of tags)" handle both
the DEPRECATED_PROPS check (including the special-case "body" handling that
compares tag.props.body === true and uses DEPRECATED_PROPS[propName].replacement
and .ruleId to call report) and the numeric tagPriority check (if typeof
tag.tagPriority === 'number' call report with the same message). Keep the same
report ruleIds, messages and severity levels and ensure both checks run for
every tag in that single loop.
🪄 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: 65d2470b-69d3-4232-9942-93c72c1a5497
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (49)
packages/cli/README.mdpackages/cli/bin/unhead.mjspackages/cli/build.config.tspackages/cli/package.jsonpackages/cli/src/commands/audit.tspackages/cli/src/commands/migrate.tspackages/cli/src/commands/validate-html.tspackages/cli/src/commands/validate-url.tspackages/cli/src/index.tspackages/cli/src/lint.tspackages/cli/src/validate.tspackages/cli/test/fixtures/bad.tspackages/cli/test/fixtures/page.htmlpackages/cli/test/lint.test.tspackages/cli/test/validate-html.test.tspackages/cli/test/validate-url.test.tspackages/cli/tsconfig.jsonpackages/cli/vitest.config.tspackages/eslint-plugin/README.mdpackages/eslint-plugin/build.config.tspackages/eslint-plugin/package.jsonpackages/eslint-plugin/src/configs/recommended.tspackages/eslint-plugin/src/index.tspackages/eslint-plugin/src/rules/canonical-rules.tspackages/eslint-plugin/src/rules/empty-meta-content.tspackages/eslint-plugin/src/rules/no-deprecated-props.tspackages/eslint-plugin/src/rules/no-unknown-meta.tspackages/eslint-plugin/src/rules/numeric-tag-priority.tspackages/eslint-plugin/src/rules/prefer-define-helpers.tspackages/eslint-plugin/src/rules/preload-rules.tspackages/eslint-plugin/src/rules/robots-conflict.tspackages/eslint-plugin/src/rules/script-rules.tspackages/eslint-plugin/src/rules/title-rules.tspackages/eslint-plugin/src/rules/twitter-handle-missing-at.tspackages/eslint-plugin/src/rules/viewport-user-scalable.tspackages/eslint-plugin/src/utils/visitor.tspackages/eslint-plugin/test/no-deprecated-props.test.tspackages/eslint-plugin/test/numeric-tag-priority.test.tspackages/eslint-plugin/test/rules.test.tspackages/eslint-plugin/tsconfig.jsonpackages/eslint-plugin/vitest.config.tspackages/unhead/build.config.tspackages/unhead/package.jsonpackages/unhead/src/plugins/validate.tspackages/unhead/src/validate/index.tspackages/unhead/src/validate/known.tspackages/unhead/src/validate/levenshtein.tspackages/unhead/src/validate/rules.tspnpm-workspace.yaml
| args: { | ||
| patterns: { | ||
| type: 'positional', | ||
| description: 'File globs to scan (default: all source files).', | ||
| required: false, | ||
| valueHint: 'glob', | ||
| }, | ||
| cwd: { | ||
| type: 'string', | ||
| description: 'Project root.', | ||
| default: '.', | ||
| }, | ||
| }, | ||
| async run({ args }) { | ||
| const cwd = resolve(process.cwd(), args.cwd) | ||
| const positional = (args._ ?? []).map(String).filter(Boolean) | ||
| const patterns = positional.length > 0 ? positional : DEFAULT_PATTERNS |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect citty's positional handling and any existing tests that pass globs to audit.
rg -nP -C3 "type:\s*['\"]positional"
rg -nP -C5 'unhead audit|runMain|args\._' packages/cli
fd -t f -e ts . packages/cli/test | xargs rg -nP -C3 'audit|patterns'
# Look at citty's parser to confirm semantics.
fd -t d -p '*citty*' node_modules 2>/dev/null | head -n 5Repository: unjs/unhead
Length of output: 7245
🌐 Web query:
citty positional argument parsing args._ vs named positional behavior
💡 Result:
In Citty (unjs/citty), a TypeScript CLI builder for Node.js, positional arguments are explicitly defined using type: "positional" in the args object. These capture specific unnamed positional values based on their named position in the args definition. For example: args: { name: { type: "positional", required: true } } cli myname sets args.name = "myname". Named arguments use types like string, boolean, enum with --flag value syntax. There is no explicit args._ for unknown/remaining positional arguments in the official docs. Citty's parser (src/args.ts, based on util.parseArgs/mri) likely consumes defined positionals sequentially from rawArgs non-flagged tokens. Extra unnamed positionals after defined ones may be ignored or cause errors, as seen in subcommand parsing issues where non-flags are treated as subcommands or positional values. For multiple/variadic positionals, multiPositional type is proposed (PR #199, issue #80/#21) to capture remaining positionals as string[], but not merged as of latest sources. Users suggest args._ might work in future. Key difference: Named positionals bind to specific args.name properties. Undeclared/remaining positionals (args._ style) are not standardly supported; define explicit positional entries or use variadic proposals.
Citations:
- 1: https://github.com/unjs/citty
- 2: https://unjs.io/packages/citty
- 3: Support multiple optional positional args citty#80
- 4: feat: add support for
multiPositionalcitty#199 - 5: like <project-name> support? citty#69
- 6: https://www.npmjs.com/package/citty
- 7: https://github.com/unjs/citty?tab=readme-ov-file
- 8: https://github.com/unjs/citty/blob/main/AGENTS.md
🏁 Script executed:
#!/bin/bash
# Check citty version and look for args._ implementation
cat packages/cli/package.json | grep -A2 -B2 'citty'
# Check if there are any CLI tests that actually invoke audit/migrate with patterns
fd -t f -name '*.test.ts' packages/cli/test | xargs grep -l 'audit\|migrate' | head -3
# Search for any documentation or examples of args._ usage in citty or this repo
rg -n 'args\._|args\.patterns' packages/cli/src --type ts
# Look at actual citty package to see if args._ is mentioned anywhere
if [ -d "node_modules/citty" ]; then
echo "=== Citty source files ==="
ls -la node_modules/citty/dist/ 2>/dev/null || echo "dist not found"
grep -r "args._\|remaining.*positional" node_modules/citty --include="*.ts" --include="*.js" 2>/dev/null | head -10
fiRepository: unjs/unhead
Length of output: 770
🏁 Script executed:
#!/bin/bash
# Find the citty version from monorepo lock file
cat pnpm-lock.yaml 2>/dev/null | grep -A 5 '"citty"' | head -15 || cat package-lock.json 2>/dev/null | grep -A 3 'citty' | head -15
# Look for test files and check if any test calls the command with patterns
find packages/cli/test -name '*.test.ts' -o -name '*.spec.ts' 2>/dev/null | head -10
# Check if there are CLI e2e tests (look at test directory structure)
ls -la packages/cli/test/ 2>/dev/null
# Search test files for any invocation of audit or migrate command
rg -n 'runMain|defineCommand|audit.*globs|patterns.*\[' packages/cli/test --type ts -A 3 2>/dev/null | head -30Repository: unjs/unhead
Length of output: 755
🏁 Script executed:
#!/bin/bash
# Check root for lock files
ls -la pnpm-lock.yaml package-lock.json yarn.lock 2>/dev/null
# Read the lint.test.ts file to see if it tests CLI invocation
cat packages/cli/test/lint.test.ts
# Search citty issues/discussions about args._
# First check if citty package is available and look at its package.json
if [ -d "node_modules/citty" ]; then
cat node_modules/citty/package.json | grep -E '"name"|"version"'
echo "=== Checking citty dist files ==="
ls -la node_modules/citty/dist/ 2>/dev/null | head -10
fiRepository: unjs/unhead
Length of output: 1088
🏁 Script executed:
#!/bin/bash
# Extract citty version from pnpm-lock.yaml
grep -A 5 "citty@" pnpm-lock.yaml | head -20
# Also check if there's a catalog section
grep -B 2 -A 2 "citty" pnpm-lock.yaml | head -30
# Check Citty source code for args._ implementation
python3 << 'EOF'
import re
import json
# Read pnpm-lock.yaml and extract citty version
with open('pnpm-lock.yaml', 'r') as f:
content = f.read()
# Look for citty version pattern
matches = re.findall(r'citty@([^:]+):', content)
if matches:
print(f"Found citty versions: {set(matches)}")
# Also look for catalogVersions section
if 'catalogVersions' in content:
start = content.find('catalogVersions:')
snippet = content[start:start+500]
if 'citty' in snippet:
print("Citty in catalogVersions:", snippet[:300])
EOFRepository: unjs/unhead
Length of output: 1606
🏁 Script executed:
#!/bin/bash
# Check citty 0.1.6 source for args._ implementation
if [ -d "node_modules/citty" ]; then
echo "=== Citty files ==="
find node_modules/citty -name "*.d.ts" -o -name "*.ts" | head -20
echo "=== Checking dist files ==="
ls -la node_modules/citty/dist/
# Look at the main files
echo "=== Index file ==="
head -50 node_modules/citty/dist/index.d.ts 2>/dev/null || head -50 node_modules/citty/dist/index.js 2>/dev/null
echo "=== Args parsing ==="
grep -r "args\._\|_:" node_modules/citty/dist/ --include="*.d.ts" --include="*.js" 2>/dev/null | head -20
fi
# Also check if there's any documentation or examples
cat packages/cli/README.md | head -40Repository: unjs/unhead
Length of output: 1355
🏁 Script executed:
#!/bin/bash
# Get citty dist index definition
if [ -d "node_modules/citty" ]; then
echo "=== Citty index.d.ts ==="
cat node_modules/citty/dist/index.d.ts 2>/dev/null | head -100
echo -e "\n=== Citty parseArgs or main logic ==="
ls node_modules/citty/dist/
# Check if there's args.ts
cat node_modules/citty/dist/args.d.ts 2>/dev/null || cat node_modules/citty/dist/args.js 2>/dev/null | head -100
fi
# Quick Python check: look for _ in type definitions
python3 << 'EOF'
import os
citty_dir = "node_modules/citty/dist"
if os.path.exists(citty_dir):
for f in os.listdir(citty_dir):
if f.endswith('.d.ts'):
with open(os.path.join(citty_dir, f), 'r') as file:
content = file.read()
if '_' in content or 'positional' in content:
print(f"\n=== {f} ===")
lines = content.split('\n')
for i, line in enumerate(lines):
if 'positional' in line.lower() or 'args' in line:
print(f"{i+1}: {line}")
EOFRepository: unjs/unhead
Length of output: 37
🏁 Script executed:
#!/bin/bash
# List what's in citty
echo "=== Citty package contents ==="
ls -la node_modules/citty/ 2>/dev/null
# Check package.json
cat node_modules/citty/package.json 2>/dev/null | grep -E '"main"|"types"|"exports"' -A 1
# Try to find and read any type definitions
find node_modules/citty -type f \( -name "*.d.ts" -o -name "*.ts" \) 2>/dev/null
# Check if there's source code available
ls node_modules/citty/dist/ 2>/dev/null
# Direct search for args._ or remaining positionals in citty code
find node_modules/citty -type f 2>/dev/null | xargs grep -l "args\._\|remaining" 2>/dev/null | head -5
# Try a simpler approach: check what's exported
cat node_modules/citty/dist/index.js 2>/dev/null | head -200Repository: unjs/unhead
Length of output: 86
🏁 Script executed:
#!/bin/bash
# Final verification: Check if there's any recent commit or PR that might have addressed this
# or any comment about args._
# Search for any TODOs or FIXMEs related to positional/patterns
rg -n "TODO|FIXME" packages/cli/src/commands/ --type ts
# Check git history if available (just commits, not full diffs)
cd packages/cli && git log --oneline src/commands/audit.ts 2>/dev/null | head -10 || echo "No git history available"
# Look for any recent issues or discussions in comments
rg -n "args\._|patterns|positional" packages/cli/src/commands/audit.ts -B2 -A2Repository: unjs/unhead
Length of output: 224
Fix positional argument handling — declared patterns argument is ignored.
The patterns positional is declared in args but the run handler reads args._ instead, which is not supported by Citty. When a user runs unhead audit src/**/*.ts, citty populates args.patterns (a string), leaving args._ empty. This causes positional.length === 0 and silently falls back to DEFAULT_PATTERNS, scanning the whole repo instead of the user-provided glob.
Use args.patterns directly:
const patterns = args.patterns ? [args.patterns] : DEFAULT_PATTERNS
Or, if you need variadic support (multiple globs), remove the patterns declaration and document the expected behavior explicitly, since Citty's 0.1.6 doesn't provide a built-in way to capture multiple positionals.
This same issue exists in migrate.ts and validate-html.ts.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/cli/src/commands/audit.ts` around lines 14 - 30, The positional
`patterns` declared in the command args is never read because the run handler
uses args._; update the run implementation to read args.patterns directly (e.g.,
compute patterns with something like: const patterns = args.patterns ?
[args.patterns] : DEFAULT_PATTERNS) and remove or stop relying on the
args._/positional variable (positional) in the audit command’s run function;
ensure the same fix is applied to the run handlers in migrate.ts and
validate-html.ts so they use args.patterns (or remove the declared positional to
opt for variadic behavior with documentation) and still fall back to
DEFAULT_PATTERNS when no pattern is provided.
| if (result.rules.some(r => r.severity === 'warn')) | ||
| process.exitCode = 1 |
There was a problem hiding this comment.
Exit-code semantic: warn triggers exit 1, but audit only exits 1 on error.
The README documents unhead audit as exiting 1 only when a rule fires at error severity, while this command (and validate-html) exits 1 on any warn. That asymmetry will trip up CI integrators who expect uniform behavior across unhead * subcommands. Consider either gating on severity === 'error' here, or adding a --max-severity / --fail-on flag and aligning the README.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/cli/src/commands/validate-url.ts` around lines 50 - 51, The command
currently sets process.exitCode = 1 when any result.rules has severity 'warn';
change the failure condition to only trigger on severity === 'error' to match
the documented behavior of unhead audit (i.e., replace the predicate in the
check that uses result.rules.some(r => r.severity === 'warn') with
result.rules.some(r => r.severity === 'error') in validate-url.ts), or
alternately add and honor a CLI option like --fail-on/--max-severity and use
that value to decide the predicate; ensure the change is mirrored in
validate-html.ts to keep behavior consistent across subcommands.
…fety, TS unwrap) - cli: drop the `patterns` declared positional that citty was overwriting, rely on `args._` directly for variadic glob input - cli/migrate: `--dry-run` now uses the migration rule set (was falling back to the smaller `recommended` set), via a new `write` flag on `runLint` - cli/validate-url: add an `AbortController` timeout (default 30s, overridable via `--timeout`) and a content-type guard so non-HTML responses fail fast - cli: clarify exit-code semantics in the README; runtime ValidatePlugin rules don't have an `error` tier, so `validate-html`/`validate-url` exit 1 on `warn` (already documented for `audit`) - cli/index: top-level description now mentions validation - eslint/no-deprecated-props: skip the autofix when the target key already exists on the same tag (prevents duplicate-key object literals where the runtime silently picks one) - eslint/prefer-define-helpers: only autofix when the helper is already imported; otherwise emit a non-automatic suggestion so users opt in and the migrate run never produces broken code - eslint/visitor: add a recursive `unwrapTS` helper and apply it at every type-narrowing site so TypeScript wrappers (`as`, `satisfies`, `<T>x`, non-null `!`) don't silently bypass lint coverage - eslint/visitor: `findProperty` now iterates in reverse so it returns the last matching property, mirroring runtime JS semantics for duplicate keys - validate/known: lowercase the `msapplication-*` entries (HTML meta `name` is case-insensitive); add `og:image:alt`, `og:video:alt`, `og:video:duration` - runtime ValidatePlugin + no-unknown-meta: normalize `meta[name]` to lowercase before lookup so users writing `MSApplication-TileColor` no longer trip false-positive typo warnings
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
packages/cli/src/commands/validate-html.ts (1)
46-50: Optional: parallelize file reads.
readFileis awaited sequentially per file. For large output dirs (e.g., a static site with hundreds of HTML files),Promise.allwould noticeably reduce wall-clock time without changing behavior.♻️ Proposed refactor
- const reports: ValidationOutput[] = [] - for (const file of files) { - const html = await readFile(file, 'utf8') - reports.push(validateHtml(html, file)) - } + const reports: ValidationOutput[] = await Promise.all( + files.map(async (file) => { + const html = await readFile(file, 'utf8') + return validateHtml(html, file) + }), + )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/src/commands/validate-html.ts` around lines 46 - 50, The loop currently awaits readFile sequentially; change it to parallelize reads by mapping files to promises and awaiting Promise.all, then run validateHtml for each result (or include validateHtml inside the mapped promise) so you still populate reports (the reports array) with results corresponding to files; use the existing readFile, files and validateHtml symbols to implement Promise.all-based parallel reads while preserving order.
🤖 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/eslint-plugin/src/rules/prefer-define-helpers.ts`:
- Around line 5-11: The TAG_TO_HELPER mapping currently includes non-existent
helpers (defineMeta, defineNoscript, defineStyle); remove those keys from the
mapping so it only maps valid helpers (link -> defineLink, script ->
defineScript) and update any rule logic that references TAG_TO_HELPER (including
autofix suggestion and import generation code in this file) to stop producing
warnings, import suggestions, or autofix branches for meta, noscript, and style
tags; ensure functions or branches that check TAG_TO_HELPER (the mapping and any
helper lookup code) only consider 'link' and 'script' to avoid offering
unresolvable helpers.
- Around line 56-60: The onTag callback currently inspects tag.parent; update
the TagVisitor interface and createTagVisitor so onTag receives an explicit
parent context parameter (e.g., pass the ArrayExpression/ ObjectExpression flag
or parent node) instead of relying on tag.parent, then update all call sites
(including where visitTagArray invokes onTag) to pass that parent/context
through; finally remove references to tag.parent usage (the check in
prefer-define-helpers that tests parent.type !== 'ArrayExpression' should use
the new passed-in parent/context). Ensure function signatures (onTag) and
callers (createTagVisitor, visitTagArray) are adjusted consistently.
---
Nitpick comments:
In `@packages/cli/src/commands/validate-html.ts`:
- Around line 46-50: The loop currently awaits readFile sequentially; change it
to parallelize reads by mapping files to promises and awaiting Promise.all, then
run validateHtml for each result (or include validateHtml inside the mapped
promise) so you still populate reports (the reports array) with results
corresponding to files; use the existing readFile, files and validateHtml
symbols to implement Promise.all-based parallel reads while preserving order.
🪄 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: 211abb64-e304-4012-8f68-41d43a74f1c2
📒 Files selected for processing (13)
packages/cli/README.mdpackages/cli/src/commands/audit.tspackages/cli/src/commands/migrate.tspackages/cli/src/commands/validate-html.tspackages/cli/src/commands/validate-url.tspackages/cli/src/index.tspackages/cli/src/lint.tspackages/eslint-plugin/src/rules/no-deprecated-props.tspackages/eslint-plugin/src/rules/no-unknown-meta.tspackages/eslint-plugin/src/rules/prefer-define-helpers.tspackages/eslint-plugin/src/utils/visitor.tspackages/unhead/src/plugins/validate.tspackages/unhead/src/validate/known.ts
✅ Files skipped from review due to trivial changes (2)
- packages/cli/README.md
- packages/cli/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- packages/cli/src/commands/validate-url.ts
- packages/cli/src/lint.ts
- packages/cli/src/commands/audit.ts
|
|
||
| // Only flag tags that live inside an array (a head input tag list). | ||
| const parent = (tag as { parent?: { type: string } }).parent | ||
| if (!parent || parent.type !== 'ArrayExpression') | ||
| return |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm whether ESLint sets parent pointers before listener invocation.
# Look at how other rules in popular plugins access parent for nested literals.
rg -nP --type=ts -C2 '\.parent\b' packages/eslint-plugin/src/rulesRepository: unjs/unhead
Length of output: 574
🏁 Script executed:
cat -n packages/eslint-plugin/src/rules/prefer-define-helpers.ts | head -100Repository: unjs/unhead
Length of output: 3467
🏁 Script executed:
# Search for the createTagVisitor function definition
rg -nA 20 'createTagVisitor' packages/eslint-plugin/src/rules/prefer-define-helpers.tsRepository: unjs/unhead
Length of output: 1665
🏁 Script executed:
# Check ESLint's documentation and how parent pointers work in visitor callbacks
# Also look for onTag definition
rg -nB5 -A10 'onTag' packages/eslint-plugin/src/rules/prefer-define-helpers.ts | head -60Repository: unjs/unhead
Length of output: 698
🏁 Script executed:
cat -n packages/eslint-plugin/src/utils/visitor.tsRepository: unjs/unhead
Length of output: 7590
🏁 Script executed:
# Also check how ESLint's visitor pattern works in tests for this rule
fd 'prefer-define-helpers' --type f packages/eslint-pluginRepository: unjs/unhead
Length of output: 113
🏁 Script executed:
# Check how ESLint officially handles parent pointers
# Look at ESLint's types and whether parent is documented
fd -type f -name "*.d.ts" | head -20Repository: unjs/unhead
Length of output: 226
🏁 Script executed:
# Check if there are tests for prefer-define-helpers to see if parent access works
fd "prefer-define-helpers" packages/eslint-plugin --type fRepository: unjs/unhead
Length of output: 113
🏁 Script executed:
# Look for test files
find packages/eslint-plugin -name "*.test.*" -o -name "*.spec.*" | head -10Repository: unjs/unhead
Length of output: 210
🏁 Script executed:
cat packages/eslint-plugin/test/rules.test.tsRepository: unjs/unhead
Length of output: 5056
🏁 Script executed:
# Check if ESLint's SourceCode interface documents parent pointers
web_search
ESLint SourceCode parent pointer documentation official APIRepository: unjs/unhead
Length of output: 151
🌐 Web query:
ESLint SourceCode parent pointer documentation official API
💡 Result:
ESLint's SourceCode class does not directly provide or document a "parent pointer" API or method for bidirectional AST traversal. Instead, ESLint sets the parent property on each AST node during its traversal process before invoking rule listeners, making node.parent available in custom rules. Key details from official documentation: - For custom parsers, "The parent property of all nodes must be rewritable. ESLint sets each node's parent property to its parent node while traversing, before any rules have access to the AST." (Result 11) - In rules, nodes received by listeners have node.parent set, as traversal logic ensures parents are assigned first. This was improved in ESLint v5.0.0 to defer rule calls until after full traversal for consistent parent availability (Result 33). - SourceCode provides visitorKeys (Result 1, 4), an object mapping node types to arrays of child property names (from eslint-visitor-keys package, Results 28-30). This enables unidirectional traversal via sourceCode.visitorKeys[node.type] to iterate children without relying on Object.keys. - To manually traverse from a node, use context.sourceCode.getAncestors(node) for the parent chain, or implement traversal using visitorKeys (e.g., estraverse or custom walker). No explicit SourceCode method like getParent(node) exists in the API docs (Results 1, 2, 4, 9). For nodes from sourceCode.getNodeByRangeIndex(index), parent may need special handling during traversal (Results 21, 19), but in rule contexts, it's reliable. SourceCode API focuses on text/tokens/comments/AST access (e.g., getText, getFirstToken), scope, parserServices, not parent queries (comprehensive lists in Results 1, 4).
Citations:
- 1: https://mintlify.com/eslint/eslint/api/source-code
- 2: https://denar90.github.io/eslint.github.io/docs/developer-guide/nodejs-api
- 3: https://eslint.org/docs/latest/extend/custom-rules
- 4: https://eslint.org/docs/latest/developer-guide/nodejs-api
- 5: https://archive.eslint.org/docs/developer-guide/working-with-custom-parsers
- 6: Missing parent property for nodes retrieved by getNodeByRangeIndex eslint/eslint#2863
- 7: https://github.com/eslint/js/tree/main/packages/eslint-visitor-keys
- 8: Proposal: defer rule listener calls until after traversal is complete eslint/eslint#9122
🏁 Script executed:
# Check the actual behavior by looking at what other ESLint rules do with parent
# Search for parent access patterns in ESLint plugin code
rg -n 'parent\?' packages/eslint-plugin/src/rules --type ts -A2 | head -40Repository: unjs/unhead
Length of output: 376
Pass parent context through onTag callback instead of relying on .parent pointer.
While ESLint does guarantee node.parent is set during rule listener execution, the architectural design here is suboptimal. The createTagVisitor already knows whether each tag is inside an array (line 178 in visitor.ts calls visitTagArray with ArrayExpression context, while line 180 calls with direct ObjectExpression). Rather than having onTag introspect the .parent property, extend the TagVisitor interface to pass the parent context as an explicit parameter—this makes the contract clearer and eliminates the need for unrelated pointers.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/eslint-plugin/src/rules/prefer-define-helpers.ts` around lines 56 -
60, The onTag callback currently inspects tag.parent; update the TagVisitor
interface and createTagVisitor so onTag receives an explicit parent context
parameter (e.g., pass the ArrayExpression/ ObjectExpression flag or parent node)
instead of relying on tag.parent, then update all call sites (including where
visitTagArray invokes onTag) to pass that parent/context through; finally remove
references to tag.parent usage (the check in prefer-define-helpers that tests
parent.type !== 'ArrayExpression' should use the new passed-in parent/context).
Ensure function signatures (onTag) and callers (createTagVisitor, visitTagArray)
are adjusted consistently.
…panel Three view-side enhancements that build on the new packages: - Click-to-source: every rule and tag entry now exposes a launch icon that hits Vite's /__open-in-editor with the captured source location. - /audit page: new dock tab with Run audit / Apply migrate buttons that call a new bundler RPC (`unhead:run-lint`). The RPC lazy-imports @unhead/cli's runLint so users without the CLI installed get a clear unavailable message rather than a runtime error. @unhead/cli is added as an optional peer dep on @unhead/bundler. - Per-rule overrides: a settings panel on /audit lists every ValidationRuleId from unhead/validate with default/warn/info/off selectors, persisted to localStorage and applied as a view-side filter on validation rules (the runtime ValidatePlugin config is unchanged). ValidationRuleId is now derived from a runtime VALIDATION_RULE_IDS array so the union and the iterable list stay in sync.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (9)
packages/devtools-app/app/app.vue (1)
22-26: Minor: redundant spread.
applyOverridesalready constructs a new array internally; the[...]copy here is redundant. You can passstate.value.validationRules || []directly.♻️ Optional cleanup
const tagsWarnings = computed(() => - applyOverrides([...(state.value.validationRules || [])], overrides.value) + applyOverrides(state.value.validationRules || [], overrides.value) .filter(r => r.severity === 'warn') .length, )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/app.vue` around lines 22 - 26, The computed property tagsWarnings creates an unnecessary shallow copy using [...(state.value.validationRules || [])]; remove the redundant spread and pass state.value.validationRules || [] directly into applyOverrides (i.e., update tagsWarnings to call applyOverrides(state.value.validationRules || [], overrides.value) and keep the rest of the chain/filter on r.severity === 'warn' unchanged).packages/devtools-app/app/composables/rule-overrides.ts (2)
15-30: Validate stored severities to harden against stale/unknown values.
readStoragecasts the parsed JSON toOverrideMapwithout verifying that each value is one of'warn' | 'info' | 'off'. If the storage schema ever changes (e.g., a future severity is added then removed, or someone hand-edits localStorage), rules with unknown severities silently fall throughapplyOverridesand are kept with their original severity instead of being treated as no-override. A small filter here prevents stale entries from accumulating.♻️ Suggested hardening
- const parsed = JSON.parse(raw) - if (!parsed || typeof parsed !== 'object') - return {} - return parsed as OverrideMap + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') + return {} + const out: OverrideMap = {} + for (const [k, v] of Object.entries(parsed)) { + if (v === 'warn' || v === 'info' || v === 'off') + out[k as ValidationRuleId] = v + } + return out🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/composables/rule-overrides.ts` around lines 15 - 30, readStorage currently casts parsed localStorage directly to OverrideMap, allowing stale/unknown severity strings to persist; update readStorage to validate each parsed entry for STORAGE_KEY so only keys whose values are one of 'warn' | 'info' | 'off' are kept, and drop any other values (returning {} for invalid input), ensuring applyOverrides sees only known severities; reference the readStorage function, OverrideMap type and STORAGE_KEY constant when implementing the filter/validation logic.
41-43:deep: trueis unnecessary here.
set()andreset()both reassignoverrides.valueto a brand-new object, so a shallow watch already triggers.deep: trueadds reactivity overhead without benefit.♻️ Optional cleanup
-watch(overrides, v => writeStorage(v), { deep: true }) +watch(overrides, v => writeStorage(v))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/composables/rule-overrides.ts` around lines 41 - 43, The watch on the overrides ref currently uses { deep: true } which is unnecessary because set() and reset() reassign overrides.value to new objects; remove the deep option so the watch becomes a shallow watcher (keep watch(overrides, v => writeStorage(v)) ), and verify functions that mutate overrides (set, reset, any direct assignments) still reassign overrides.value rather than mutating in-place to ensure the shallow watch triggers.packages/devtools-app/app/composables/open-in-editor.ts (1)
6-17: Consider checking the response status.
fetchonly rejects on network errors; a 404/500 from the dev server (e.g., when the path can't be resolved bylaunch-editor) will resolve silently. Logging non-OK responses would make editor-launch failures easier to diagnose.♻️ Optional refinement
- fetch(url).catch((err) => { - console.warn('[unhead devtools] open-in-editor failed:', err) - }) + fetch(url) + .then((res) => { + if (!res.ok) + console.warn(`[unhead devtools] open-in-editor returned ${res.status} for ${source}`) + }) + .catch((err) => { + console.warn('[unhead devtools] open-in-editor failed:', err) + })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/composables/open-in-editor.ts` around lines 6 - 17, The openInEditor function currently ignores non-network HTTP errors from fetch; update openInEditor to await the fetch response and check response.ok, and when not ok log a clear warning including response.status and response.statusText (and optionally response.text() for more details) so 404/500 editor-launch failures are visible; keep the existing catch to handle network errors. Ensure you reference the openInEditor function and the fetch call when making this change.packages/devtools-app/app/components/DevtoolsTagTable.vue (2)
382-390:(rule as any).sourcecast hints at a missing field in the rule type.Runtime
ValidatePluginrules likely don't carry asourceproperty — that's an artifact added by the bundler's source-location injection / CLI lint output. Casting toanyworks but loses type safety and silently hides cases wheresourceisundefined(thev-ifonly checks truthiness, not type).If the template intentionally surfaces source for rules that originate from tags (carrying the tag's
_source), it's worth adding an optionalsource?: stringto the validation rule type emitted into devtools state, so this doesn't need a cast.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/components/DevtoolsTagTable.vue` around lines 382 - 390, The template is casting (rule as any).source because the rule type lacks an optional source field; add source?: string to the validation rule type used by the devtools state (the rule interface/type that populates DevtoolsTagTable.vue) so components can reference rule.source without any casts, update any code that populates rules (e.g., where ValidatePlugin rules are added) to assign the tag's _source into the new source property when present, and remove the (rule as any) casts and rely on the typed property in the template and the openInEditor((rule).source) usage.
1-6: Add explicitopenInEditorimport for clarity.
openInEditoris invoked from the template (lines 345, 386) but relies on Nuxt's auto-import, which is enabled in your config. While this works, adding an explicit import would improve code clarity and align with how other composables are imported in this file:import { applyOverrides, useRuleOverrides } from '~/composables/rule-overrides' +import { openInEditor } from '~/composables/open-in-editor' import { state } from '~/composables/state'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/components/DevtoolsTagTable.vue` around lines 1 - 6, The template calls openInEditor but the file relies on Nuxt auto-imports; explicitly import openInEditor (the openInEditor symbol) at the top of the <script setup> alongside other composables to improve clarity and consistency with applyOverrides/useRuleOverrides/state imports; update the import list so openInEditor is imported from Nuxt's runtime (e.g., the '#app' export) and used directly in the template.packages/unhead/src/validate/rules.ts (2)
1-69: Manual sync betweenVALIDATION_RULE_IDSand PR-claimed rules — consider adding a guard.The PR objectives mention CLI/eslint-plugin rules (e.g.,
prefer-define-helpers,no-deprecated-props,no-unknown-meta,preload-font-crossorigin) that aren't in this list. That's expected if those are eslint-only rule IDs, but with the union derived only from this array, drift is silent. A small integration test that asserts every runtime rule emitted byValidatePluginand every rule documented in@unhead/eslint-plugineither belongs toVALIDATION_RULE_IDSor is intentionally excluded would catch future drift.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/validate/rules.ts` around lines 1 - 69, VALIDATION_RULE_IDS is the single source used to derive ValidationRuleId but the PR notes show eslint-only rule IDs live elsewhere, so add a test to prevent silent drift: create an integration test that imports VALIDATION_RULE_IDS and (a) collects runtime rule IDs emitted by ValidatePlugin (e.g., from its exported rule list or factory) and (b) collects rule IDs exported by the `@unhead/eslint-plugin` package, then assert every ID from both sources is either present in VALIDATION_RULE_IDS or explicitly listed in an "eslintOnlyExclusions" array in the test; fail the test when an ID is neither present nor intentionally excluded so future PRs must update VALIDATION_RULE_IDS or the exclusion list.
8-8: Remove the ineffective/*@pure*/annotation from the array literal.Rollup and esbuild only recognize the
@__PURE__annotation onCallExpressionandNewExpression(function calls and constructor calls). The annotation has no effect on array literals and serves only as misleading comment.♻️ Suggested cleanup
-export const VALIDATION_RULE_IDS = /* `@__PURE__` */ [ +export const VALIDATION_RULE_IDS = [🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/validate/rules.ts` at line 8, The `/* `@__PURE__` */` annotation attached to the array literal is ineffective and misleading; remove the comment so the declaration "export const VALIDATION_RULE_IDS" is simply an array export without the annotation, i.e. delete the `/* `@__PURE__` */` token adjacent to VALIDATION_RULE_IDS and run tests/build to confirm no regressions.packages/bundler/src/devtools/rpc/functions/run-lint.ts (1)
37-42: Consider extending the default ignore set and allowing user overrides for lint scope.The hardcoded ignore list (
node_modules,dist,.output,.nuxt) misses common output directories likecoverage/,build/,.cache/, and.git/, which can cause ESLint to parse unnecessary files, adding noise and latency in larger projects.Two improvements, neither blocking:
- Expand the default ignore list to include
**/coverage/**,**/build/**,**/.cache/**, and**/.git/**.- Extend
RunLintArgsto accept optionalpatternsandignoreparameters so downstream tooling (audit page, CI) can customize the lint scope—e.g., "lint changed files only" or "skip specific directories."The underlying
lib.runLint()already supports both parameters; they're simply hardcoded in the RPC handler.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/bundler/src/devtools/rpc/functions/run-lint.ts` around lines 37 - 42, The RPC handler calling lib.runLint hardcodes patterns and an incomplete ignore list; update the handler to merge a wider default ignore set (add "**/coverage/**", "**/build/**", "**/.cache/**", "**/.git/**") and allow callers to override both patterns and ignore via RunLintArgs (make patterns? and ignore? optional), then call lib.runLint({ patterns: args.patterns ?? defaultPatterns, ignore: args.ignore ? args.ignore.concat(defaultIgnore) : defaultIgnore, mode, cwd }) so downstream callers can pass custom scopes; reference lib.runLint and the RunLintArgs type in your changes.
🤖 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/bundler/src/devtools/rpc/functions/run-lint.ts`:
- Around line 49-62: The current mapping of messages in the run-lint result sets
fixable: Boolean(m.fix) || Boolean(m.suggestions?.length), which conflates
auto-fixable edits with manual suggestions; change the mapping in the messages
map (the LintMessage returned by the .map over r.messages in run-lint.ts) to set
fixable to only Boolean(m.fix) and add a separate flag (e.g. hasSuggestions or
suggestions) as Boolean(m.suggestions?.length) so the UI and header counts
(fixableErrorCount/fixableWarningCount) remain consistent with ESLint semantics.
In `@packages/devtools-app/app/pages/audit.vue`:
- Around line 25-62: Remove the duplicated local type declarations (LintMessage,
LintFileResult, LintRunResult, LintUnavailableResult, LintResponse) and instead
import these canonical RPC types from the shared RPC types module used by the
bundler RPC handler; update the top of the file to import the named types so the
view uses the same definitions (including endLine/endColumn on LintMessage) as
the RPC handler (referencing the local symbols LintMessage, LintFileResult,
LintRunResult, LintUnavailableResult, LintResponse to locate what to remove and
replace with the import).
---
Nitpick comments:
In `@packages/bundler/src/devtools/rpc/functions/run-lint.ts`:
- Around line 37-42: The RPC handler calling lib.runLint hardcodes patterns and
an incomplete ignore list; update the handler to merge a wider default ignore
set (add "**/coverage/**", "**/build/**", "**/.cache/**", "**/.git/**") and
allow callers to override both patterns and ignore via RunLintArgs (make
patterns? and ignore? optional), then call lib.runLint({ patterns: args.patterns
?? defaultPatterns, ignore: args.ignore ? args.ignore.concat(defaultIgnore) :
defaultIgnore, mode, cwd }) so downstream callers can pass custom scopes;
reference lib.runLint and the RunLintArgs type in your changes.
In `@packages/devtools-app/app/app.vue`:
- Around line 22-26: The computed property tagsWarnings creates an unnecessary
shallow copy using [...(state.value.validationRules || [])]; remove the
redundant spread and pass state.value.validationRules || [] directly into
applyOverrides (i.e., update tagsWarnings to call
applyOverrides(state.value.validationRules || [], overrides.value) and keep the
rest of the chain/filter on r.severity === 'warn' unchanged).
In `@packages/devtools-app/app/components/DevtoolsTagTable.vue`:
- Around line 382-390: The template is casting (rule as any).source because the
rule type lacks an optional source field; add source?: string to the validation
rule type used by the devtools state (the rule interface/type that populates
DevtoolsTagTable.vue) so components can reference rule.source without any casts,
update any code that populates rules (e.g., where ValidatePlugin rules are
added) to assign the tag's _source into the new source property when present,
and remove the (rule as any) casts and rely on the typed property in the
template and the openInEditor((rule).source) usage.
- Around line 1-6: The template calls openInEditor but the file relies on Nuxt
auto-imports; explicitly import openInEditor (the openInEditor symbol) at the
top of the <script setup> alongside other composables to improve clarity and
consistency with applyOverrides/useRuleOverrides/state imports; update the
import list so openInEditor is imported from Nuxt's runtime (e.g., the '#app'
export) and used directly in the template.
In `@packages/devtools-app/app/composables/open-in-editor.ts`:
- Around line 6-17: The openInEditor function currently ignores non-network HTTP
errors from fetch; update openInEditor to await the fetch response and check
response.ok, and when not ok log a clear warning including response.status and
response.statusText (and optionally response.text() for more details) so 404/500
editor-launch failures are visible; keep the existing catch to handle network
errors. Ensure you reference the openInEditor function and the fetch call when
making this change.
In `@packages/devtools-app/app/composables/rule-overrides.ts`:
- Around line 15-30: readStorage currently casts parsed localStorage directly to
OverrideMap, allowing stale/unknown severity strings to persist; update
readStorage to validate each parsed entry for STORAGE_KEY so only keys whose
values are one of 'warn' | 'info' | 'off' are kept, and drop any other values
(returning {} for invalid input), ensuring applyOverrides sees only known
severities; reference the readStorage function, OverrideMap type and STORAGE_KEY
constant when implementing the filter/validation logic.
- Around line 41-43: The watch on the overrides ref currently uses { deep: true
} which is unnecessary because set() and reset() reassign overrides.value to new
objects; remove the deep option so the watch becomes a shallow watcher (keep
watch(overrides, v => writeStorage(v)) ), and verify functions that mutate
overrides (set, reset, any direct assignments) still reassign overrides.value
rather than mutating in-place to ensure the shallow watch triggers.
In `@packages/unhead/src/validate/rules.ts`:
- Around line 1-69: VALIDATION_RULE_IDS is the single source used to derive
ValidationRuleId but the PR notes show eslint-only rule IDs live elsewhere, so
add a test to prevent silent drift: create an integration test that imports
VALIDATION_RULE_IDS and (a) collects runtime rule IDs emitted by ValidatePlugin
(e.g., from its exported rule list or factory) and (b) collects rule IDs
exported by the `@unhead/eslint-plugin` package, then assert every ID from both
sources is either present in VALIDATION_RULE_IDS or explicitly listed in an
"eslintOnlyExclusions" array in the test; fail the test when an ID is neither
present nor intentionally excluded so future PRs must update VALIDATION_RULE_IDS
or the exclusion list.
- Line 8: The `/* `@__PURE__` */` annotation attached to the array literal is
ineffective and misleading; remove the comment so the declaration "export const
VALIDATION_RULE_IDS" is simply an array export without the annotation, i.e.
delete the `/* `@__PURE__` */` token adjacent to VALIDATION_RULE_IDS and run
tests/build to confirm no regressions.
🪄 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: dcb1d18d-9359-4d2a-b40a-b992a4f185e0
📒 Files selected for processing (13)
packages/bundler/build.config.tspackages/bundler/package.jsonpackages/bundler/src/devtools/rpc/functions/run-lint.tspackages/bundler/src/devtools/rpc/index.tspackages/bundler/src/devtools/rpc/types.tspackages/bundler/src/devtools/vite.tspackages/devtools-app/app/app.vuepackages/devtools-app/app/components/DevtoolsTagTable.vuepackages/devtools-app/app/composables/open-in-editor.tspackages/devtools-app/app/composables/rpc.tspackages/devtools-app/app/composables/rule-overrides.tspackages/devtools-app/app/pages/audit.vuepackages/unhead/src/validate/rules.ts
✅ Files skipped from review due to trivial changes (3)
- packages/bundler/build.config.ts
- packages/bundler/src/devtools/rpc/index.ts
- packages/bundler/src/devtools/rpc/types.ts
- prefer-define-helpers no longer suggests defineMeta/defineNoscript/ defineStyle (these helpers don't exist; only defineLink and defineScript are exported from `unhead`). README and visitor whitelist updated to match. - run-lint RPC now reports `fixable` only when ESLint's `m.fix` is set; suggestions aren't applied by `--fix` so flagging them as fixable was misleading the devtools UI badge. - validate.ts pluralises "warning(s)" in the summary line. - audit.vue imports its lint result types from `composables/state.ts` instead of redeclaring them, matching the existing pattern used for the other Serialized* RPC types in that file.
…-plugin Three new pages under docs/0.typescript/head/guides/2.tooling/: - eslint-plugin.md walks the install + flat-config setup, calls out the highest-impact rules for migration, and links to the README rules table for the full reference. - cli.md documents the four subcommands (audit, migrate, validate-html, validate-url) with exit-code semantics and the cross-layer "what runs where" matrix from the package README. - validate-plugin.md is the previously-missing reference for the runtime ValidatePlugin: when to use it, configurable rule options, and how consumers can iterate VALIDATION_RULE_IDS for tooling. Each page cross-links the other two so readers can move between source lint, rendered-HTML lint, and runtime validation as one story.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (5)
packages/eslint-plugin/src/utils/visitor.ts (1)
38-45: Optional: handleChainExpressioncallees.
getCalleeNamewon't resolve callees wrapped by optional chaining (e.g.unhead?.useHead({...})orobj?.useHead?.()), wherenode.calleeis aChainExpressionwhose inner expression is theMemberExpression. Probably rare in practice, but a one-line unwrap would close the gap:♻️ Optional refactor
export function getCalleeName(node: ESTree.CallExpression): string | undefined { - const callee = node.callee + let callee = node.callee + if (callee.type === 'ChainExpression') + callee = callee.expression if (callee.type === 'Identifier') return callee.name if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') return callee.property.name return undefined }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/utils/visitor.ts` around lines 38 - 45, The getCalleeName function doesn't handle optional chaining where node.callee can be a ChainExpression; update getCalleeName to unwrap a ChainExpression (access its .expression) before the existing checks so Identifier and MemberExpression cases still work (retain the property.type === 'Identifier' check for MemberExpression); modify the logic inside getCalleeName to normalize callee by replacing node.callee with callee.expression when callee.type === 'ChainExpression' so existing return paths (callee.name or callee.property.name) cover optional chaining scenarios.packages/eslint-plugin/src/rules/prefer-define-helpers.ts (1)
21-33: Consider validating the import source module inhelperIsImported.The function currently accepts any
ImportDeclarationwith a specifier nameddefineLinkordefineScript, regardless of source. If a user imports an identically-named symbol from an unrelated package, the autofix will incorrectly wrap the call. Restricting the check to known unhead packages (unhead,@unhead/vue,@unhead/react,@unhead/solid-js,@unhead/svelte,@unhead/angular, etc.) would be safer.Additionally, the
ImportDefaultSpecifiercheck at line 28 is dead code —defineLinkanddefineScriptare only ever named exports across the monorepo.ImportNamespaceSpecifier(e.g.,import * as h from 'unhead') also won't match this function, so users importing via namespace only receive suggestions, which is the conservative behavior.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/eslint-plugin/src/rules/prefer-define-helpers.ts` around lines 21 - 33, The helperIsImported function currently treats any ImportDeclaration with a matching specifier name as valid; change it to also verify the import source is one of the known unhead packages (e.g., 'unhead', '@unhead/vue', '@unhead/react', '@unhead/solid-js', '@unhead/svelte', '@unhead/angular', etc.) before accepting a specifier, and remove the dead ImportDefaultSpecifier branch since defineLink/defineScript are named exports; leave ImportNamespaceSpecifier unmatched (so namespace imports only trigger suggestions, not autofix). Ensure you update helperIsImported (the function name) to check node.source.value against the allowed-package list and only then inspect specifiers for ImportSpecifier matches.packages/devtools-app/app/composables/state.ts (1)
56-98: Lint result types LGTM; consider an automated drift guard.The added types correctly mirror
packages/bundler/src/devtools/rpc/types.ts(includingendLine/endColumn, which the prior audit.vue duplication was missing) and match the discriminated union shape returned byrunLintRpc. The inline comment justifying the duplication pattern is appreciated.Since two copies now have to be kept in sync by hand, you may want a lightweight type-equivalence assertion (e.g. a
satisfiescheck or a smallexpectTypeOftest importing both modules) so future divergence — for instance, ifLintMessagegains a field on the bundler side — fails CI rather than silently typing it asanydownstream.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/composables/state.ts` around lines 56 - 98, Add an automated drift guard to ensure the duplicated lint types stay in sync: import the canonical types from packages/bundler/src/devtools/rpc/types.ts and assert equivalence against the local LintMessage, LintFileResult, LintRunResult, LintUnavailableResult (and LintResponse) using either a TypeScript `satisfies`-style check or a small type-only test (e.g., expectTypeOf or a compile-time assertion) so CI will fail if the shapes diverge; place the assertion/test near the existing type declarations in app/composables/state.ts or in a new test file that imports both modules.packages/devtools-app/app/pages/audit.vue (2)
31-44: Optional: clearresultwhen starting a new run.
error.valueis reset at the top ofrun()butresult.valueisn't. That's fine for the success path (it produces a smooth replace), but if a previous run succeeded and a subsequent run throws, the user sees the newerrorbanner viav-if="error", and as soon as they dismiss/retry, the previous result (possibly from a differentmode) re-appears. Either clearingresulthere, or clearing it on error specifically, makes the displayed state unambiguously tied tolastMode.async function run(mode: 'audit' | 'migrate') { loading.value = true error.value = null + result.value = null lastMode.value = modeNon-blocking.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/pages/audit.vue` around lines 31 - 44, The current run() clears error.value but not result.value, which causes an old successful result to reappear after a failed run; update run() to also reset result.value at the start (e.g., set result.value = null) so the UI state is unambiguous and tied to lastMode.value; alternatively, clear result.value in the catch block when handling err, but prefer clearing it at the beginning of the run() function that calls callRpc<LintResponse>('unhead:run-lint', { mode }) to ensure no stale output is shown during loading or on error.
161-161: Don't render?:?for messages without a location.
openFilealready gates onmessage?.line != nulland falls back to opening the file without a line/column, but the template unconditionally renders{{ m.line ?? '?' }}:{{ m.column ?? '?' }}, so messages without source positions display as?:?— visually noisy and inconsistent with the click behavior.✏️ Proposed tweak
- <span class="font-mono text-muted text-[10px]">{{ m.line ?? '?' }}:{{ m.column ?? '?' }}</span> + <span v-if="m.line != null" class="font-mono text-muted text-[10px]">{{ m.line }}{{ m.column != null ? `:${m.column}` : '' }}</span>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/devtools-app/app/pages/audit.vue` at line 161, The template currently always renders the location span "{{ m.line ?? '?' }}:{{ m.column ?? '?' }}" causing "?:?" for messages without positions; change the template to only render the location span when m.line (or message?.line) is not null/undefined to match openFile's gating logic—locate the span using the m.line / m.column bindings in audit.vue and wrap it in a conditional (e.g., v-if="m.line != null") so messages without locations omit the "?:?" text and keep click behavior unchanged.
🤖 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/devtools-app/app/pages/audit.vue`:
- Around line 50-55: The openFile function currently swallows all failures from
fetch(`/__open-in-editor?...`) via .catch(() => {}); update openFile to await
the fetch (or handle the Promise) and check the response: if the fetch rejects
or res.ok is false, set the existing error ref (error) with a meaningful message
(include response.status and body/text when available) or trigger a transient
toast so users get feedback; keep the encoded loc string logic and ensure both
network errors and non-2xx responses (e.g. 404) are surfaced instead of being
ignored.
In `@packages/eslint-plugin/README.md`:
- Line 34: The table row for the rule `numeric-tag-priority` contains raw pipe
characters in the cell (the snippet `'critical' | 'high' | 'low'`) which breaks
GFM table parsing; update that cell to escape the pipes (e.g., `'critical' \|
'high' \| 'low'`) or wrap the whole value in an HTML <code> element so the pipes
are not treated as column separators, ensuring the `numeric-tag-priority` table
row renders correctly.
---
Nitpick comments:
In `@packages/devtools-app/app/composables/state.ts`:
- Around line 56-98: Add an automated drift guard to ensure the duplicated lint
types stay in sync: import the canonical types from
packages/bundler/src/devtools/rpc/types.ts and assert equivalence against the
local LintMessage, LintFileResult, LintRunResult, LintUnavailableResult (and
LintResponse) using either a TypeScript `satisfies`-style check or a small
type-only test (e.g., expectTypeOf or a compile-time assertion) so CI will fail
if the shapes diverge; place the assertion/test near the existing type
declarations in app/composables/state.ts or in a new test file that imports both
modules.
In `@packages/devtools-app/app/pages/audit.vue`:
- Around line 31-44: The current run() clears error.value but not result.value,
which causes an old successful result to reappear after a failed run; update
run() to also reset result.value at the start (e.g., set result.value = null) so
the UI state is unambiguous and tied to lastMode.value; alternatively, clear
result.value in the catch block when handling err, but prefer clearing it at the
beginning of the run() function that calls
callRpc<LintResponse>('unhead:run-lint', { mode }) to ensure no stale output is
shown during loading or on error.
- Line 161: The template currently always renders the location span "{{ m.line
?? '?' }}:{{ m.column ?? '?' }}" causing "?:?" for messages without positions;
change the template to only render the location span when m.line (or
message?.line) is not null/undefined to match openFile's gating logic—locate the
span using the m.line / m.column bindings in audit.vue and wrap it in a
conditional (e.g., v-if="m.line != null") so messages without locations omit the
"?:?" text and keep click behavior unchanged.
In `@packages/eslint-plugin/src/rules/prefer-define-helpers.ts`:
- Around line 21-33: The helperIsImported function currently treats any
ImportDeclaration with a matching specifier name as valid; change it to also
verify the import source is one of the known unhead packages (e.g., 'unhead',
'@unhead/vue', '@unhead/react', '@unhead/solid-js', '@unhead/svelte',
'@unhead/angular', etc.) before accepting a specifier, and remove the dead
ImportDefaultSpecifier branch since defineLink/defineScript are named exports;
leave ImportNamespaceSpecifier unmatched (so namespace imports only trigger
suggestions, not autofix). Ensure you update helperIsImported (the function
name) to check node.source.value against the allowed-package list and only then
inspect specifiers for ImportSpecifier matches.
In `@packages/eslint-plugin/src/utils/visitor.ts`:
- Around line 38-45: The getCalleeName function doesn't handle optional chaining
where node.callee can be a ChainExpression; update getCalleeName to unwrap a
ChainExpression (access its .expression) before the existing checks so
Identifier and MemberExpression cases still work (retain the property.type ===
'Identifier' check for MemberExpression); modify the logic inside getCalleeName
to normalize callee by replacing node.callee with callee.expression when
callee.type === 'ChainExpression' so existing return paths (callee.name or
callee.property.name) cover optional chaining scenarios.
🪄 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: 853c6f4d-3664-4543-8aa6-d4191a8c1f69
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (7)
packages/bundler/src/devtools/rpc/functions/run-lint.tspackages/cli/src/validate.tspackages/devtools-app/app/composables/state.tspackages/devtools-app/app/pages/audit.vuepackages/eslint-plugin/README.mdpackages/eslint-plugin/src/rules/prefer-define-helpers.tspackages/eslint-plugin/src/utils/visitor.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/cli/src/validate.ts
- packages/bundler/src/devtools/rpc/functions/run-lint.ts
| function openFile(file: LintFileResult, message?: LintMessage) { | ||
| const loc = message?.line != null | ||
| ? `${file.filePath}:${message.line}${message.column != null ? `:${message.column}` : ''}` | ||
| : file.filePath | ||
| fetch(`/__open-in-editor?file=${encodeURIComponent(loc)}`).catch(() => {}) | ||
| } |
There was a problem hiding this comment.
openFile silently swallows /__open-in-editor failures.
fetch(...).catch(() => {}) discards every failure mode — Vite middleware unreachable (e.g. when the devtools app is served outside the dev server), non-2xx responses (Vite returns 404 when the editor binary can't resolve the file), and network errors. Users clicking a row will see no feedback at all.
Consider surfacing failures via the existing error ref (or a transient toast) and at least checking res.ok, e.g.:
🛠️ Sketch
-function openFile(file: LintFileResult, message?: LintMessage) {
- const loc = message?.line != null
- ? `${file.filePath}:${message.line}${message.column != null ? `:${message.column}` : ''}`
- : file.filePath
- fetch(`/__open-in-editor?file=${encodeURIComponent(loc)}`).catch(() => {})
-}
+async function openFile(file: LintFileResult, message?: LintMessage) {
+ const loc = message?.line != null
+ ? `${file.filePath}:${message.line}${message.column != null ? `:${message.column}` : ''}`
+ : file.filePath
+ try {
+ const res = await fetch(`/__open-in-editor?file=${encodeURIComponent(loc)}`)
+ if (!res.ok)
+ error.value = `Could not open ${file.relativePath} in editor (HTTP ${res.status}).`
+ }
+ catch (e: any) {
+ error.value = `Could not open ${file.relativePath} in editor: ${e?.message || e}`
+ }
+}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/devtools-app/app/pages/audit.vue` around lines 50 - 55, The openFile
function currently swallows all failures from fetch(`/__open-in-editor?...`) via
.catch(() => {}); update openFile to await the fetch (or handle the Promise) and
check the response: if the fetch rejects or res.ok is false, set the existing
error ref (error) with a meaningful message (include response.status and
body/text when available) or trigger a transient toast so users get feedback;
keep the encoded loc string logic and ensure both network errors and non-2xx
responses (e.g. 404) are surfaced instead of being ignored.
- Escape pipe characters in the eslint-plugin README rules table: the `numeric-tag-priority` row was rendering broken in GFM because the `'critical' | 'high' | 'low'` union sat inside an inline code span without escaping the table-cell delimiters. - audit.vue's openFile no longer silently swallows /__open-in-editor failures; logs a warning to the console so a misconfigured Vite setup is debuggable. - Consolidate the two CLI helper tests (validate-html.test.ts and validate-url.test.ts) into a single validate.test.ts. Both files exercised the shared validateHtml helper but were named after the commands, which was misleading. Added a docblock pointing at where the URL-fetch plumbing is actually exercised.
User-written `<meta name="Description">` previously passed the typo detector (which lowercased before comparing to KNOWN_META_NAMES) but still produced a false-positive `missing-description` warning because the cross-tag `metaByKey` lookup keyed off the original-case `name`. Normalize `name` to lowercase when building cross-tag state so the typo path and the cross-tag path agree.
Code reviewFound 1 issue and pushed a fix in 6b61b72:
unhead/packages/unhead/src/plugins/validate.ts Lines 158 to 170 in d2ddbdb Fix: normalize Other items considered and dismissed as false positives:
🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Linked issue
N/A
Type of change
Description
Adds two new published packages and a shared
unhead/validatesubpath so source-level lint, rendered-HTML lint, and the runtimeValidatePluginall consume the same rule IDs and known-meta sets.@unhead/eslint-plugin-- flat-config ESLint plugin with autofix-where-possible rules covering v2-to-v3 migration (no-deprecated-props,prefer-define-helpers), type narrowing (numeric-tag-priority,script-src-with-content), and SEO/perf foot-guns (no-unknown-meta,preload-missing-as,preload-font-crossorigin,robots-conflict,twitter-handle-missing-at,viewport-user-scalable,non-absolute-canonical,empty-meta-content,no-html-in-title,defer-on-module-script).@unhead/cli-- ships theunheadbinary withaudit,migrate,validate-html, andvalidate-urlcommands.audit/migratewrap the ESLint plugin,validate-html/validate-urlrun the runtimeValidatePluginover rendered output (with a defaultfacebookexternalhituser agent so social-crawler-aware rules engage).unhead/validate-- new subpath export holding rule IDs, known meta name/property sets, and a small Levenshtein helper. The runtimeValidatePluginwas refactored to read from here too, so adding a rule in one place updates lint diagnostics, runtime warnings, and CLI reports together.Devtools panel
A new
/auditdock tab and a few cross-cutting tweaks make the same validation visible inside the Vite devtools UI:/__open-in-editorwith the file:line captured by the existing source-location transform.unhead:run-lintRPC. The RPC lazy-imports@unhead/cli'srunLint, so consumers without the CLI installed get a clear "install @unhead/cli" message rather than a runtime error.@unhead/cliis declared as an optional peer dep on@unhead/bundler.ValidationRuleId, persisted to localStorage and applied as a view-side filter on validation rules. The runtimeValidatePluginconfig is unchanged. To enumerate the canonical rule list at runtime,ValidationRuleIdis now derived from a newVALIDATION_RULE_IDSconstant exported fromunhead/validate.Summary by CodeRabbit
New Features
Improvements
Tests
Documentation