Skip to content
Domphy

Configuration & API

This page covers the full programmatic surface of @domphy/doctor: when to use each entry point, how to configure analysis, and how to integrate the doctor into build pipelines and test suites.

Entry Points

The package exposes three entry points, each suited to a different use case.

diagnose(element, options?) — raw array

Returns a flat Diagnostic[]. Use this when you want to process results yourself — filter by severity, count by rule, or pipe into a custom formatter.

import { diagnose } from "@domphy/doctor"

const issues = diagnose(MyApp)

const errors = issues.filter(d => d.severity === "error")
const byRule = issues.reduce((acc, d) => {
  acc[d.rule] = (acc[d.rule] ?? 0) + 1
  return acc
}, {} as Record<string, number>)

validate(element, options?) — structured report

Runs the same rules as diagnose but wraps the result in a ValidationReport with a pass/fail flag and severity counts. This is the recommended entry point for CI and programmatic gates.

import { validate } from "@domphy/doctor"

const report = validate(MyApp)

report.ok      // false when any error-severity issue is present
report.issues  // Diagnostic[] — same as diagnose()
report.summary // { error: 1, warning: 2, info: 0, total: 3 }

ok is false only when summary.error > 0. Warnings and info do not flip ok.

fix(element, options?) — autofix + remainder

Applies every lossless fix to a deep copy of the tree and runs validate() on the result. Use this as the first step in an automated correction loop: apply what can be fixed automatically, then hand the remaining report to a human or model.

import { fix } from "@domphy/doctor"

const { tree, applied, report } = fix(MyApp)

// tree    — a copy with lossless fixes applied (reactive functions preserved)
// applied — [{ rule, path, message }] listing what changed
// report  — validate(tree): the issues that still need manual resolution

Currently only void-content has a lossless fix (clearing the tag value to null). All other rules require semantic intent the tree does not carry — the correct _key, which tone, which typography patch — so they remain in report.


Options

DiagnoseOptions

Both diagnose() and validate() (and fix(), which calls validate() internally) accept a DiagnoseOptions object as the second argument.

interface DiagnoseOptions {
  /**
   * Invoke reactive content functions `(listener) => …` with a no-op listener
   * to inspect their output. This is how `missing-key`, `unstable-key`, and
   * `duplicate-key` inside dynamic lists are found.
   *
   * Default: true
   * Set to false if your reactive functions have side effects.
   */
  runReactive?: boolean

  /**
   * If set, only emit diagnostics whose rule id is in this list.
   * Takes precedence over `exclude`. An empty array returns no diagnostics.
   * Applies to both built-in and custom rules.
   */
  only?: string[]

  /**
   * Rule ids to skip entirely. Ignored when `only` is also set.
   * Applies to both built-in and custom rules.
   */
  exclude?: string[]

  /**
   * Additional custom rules to run alongside the 22 built-in rules.
   * Custom rule ids are also subject to `only`/`exclude` filtering.
   * See "Custom Rules" section below.
   */
  rules?: CustomRule[]
}

runReactive: true (default)

The doctor calls each reactive function (l) => … with a no-op listener to inspect its output. This is safe for pure reactive functions — those that only read state and return an element tree.

import { toState } from "@domphy/core"
import { diagnose } from "@domphy/doctor"

const items = toState(["A", "B", "C"])

// The reactive function is invoked with a no-op listener.
// The doctor sees the returned list and can check for _key.
const issues = diagnose({
  ul: (l) => items.get(l).map(text => ({ li: text }))
})
// -> includes missing-key warning

runReactive: false

Pass { runReactive: false } when reactive functions read from the DOM, dispatch events, start timers, or have any other side effect you do not want triggered during analysis.

const issues = diagnose(MyApp, { runReactive: false })
// dynamic-list rules (missing-key, unstable-key) won't fire;
// duplicate-key and all structural rules still run on static content.

only and exclude

Run only a subset of rules (mirrors ESLint's --rule flag) or skip rules you don't care about:

// Only check for literal theme values:
const colorIssues = diagnose(MyApp, { only: ["raw-theme-value", "raw-spacing-value"] })

// Skip soft recommendations, fail only on structural errors:
const strictIssues = diagnose(MyApp, {
  exclude: ["raw-theme-value", "raw-spacing-value", "inline-typography"]
})

only takes precedence over exclude. An empty only: [] returns zero diagnostics (whitelist mode, nothing allowed through).


Inline suppression: _doctorDisable

Add _doctorDisable to any element to suppress diagnostics for that element (not its children). This is the equivalent of // eslint-disable-next-line for Domphy trees.

// Suppress all rules on this element
{ div: "x", dataTone: "shift-6", _doctorDisable: true }

// Suppress specific rules only
{ div: "x", dataTone: "shift-6", _doctorDisable: ["middle-surface-anchor"] }

// Suppress a single rule as a string
{ div: "x", dataTone: "shift-6", _doctorDisable: "middle-surface-anchor" }

The annotation suppresses diagnostics at this element's path — both element-level rules (inline-typography, unknown-tone, etc.) and array-level rules that fire at the same path (missing-key when the element's content is a reactive function):

// Suppress missing-key warning on the reactive list container
{
  ul: (l) => items.get(l).map(item => ({ li: item.label })),
  _doctorDisable: ["missing-key"],
}

Diagnostics from child elements are never suppressed — only diagnostics at the annotated element's own path.


Custom Rules

Extend the doctor with project-specific rules using options.rules. A CustomRule has an id, a default severity, an optional category, and a check function called for every element node in the tree.

import { type CustomRule, diagnose } from "@domphy/doctor"

// Disallow bare <div> with no patches — enforce using a layout wrapper
const noBareDiv: CustomRule = {
  id: "no-bare-div",
  severity: "warning",
  category: "structure",
  check: (element, _path, tag) => {
    if (tag === "div" && (!element.$ || (element.$ as unknown[]).length === 0)) {
      return [{ message: "Bare <div> without patches — add a layout patch or use a semantic tag." }]
    }
    return []
  },
}

const issues = diagnose(MyApp, { rules: [noBareDiv] })

Custom rule violations appear in format() output and ValidationReport.issues alongside built-in violations. They are subject to only / exclude filtering.

CustomRule type

interface CustomRule {
  /** Unique id shown in diagnostics. Must not clash with any built-in rule id. */
  id: string
  /** Default severity for violations from this rule. */
  severity: Severity
  /** Category for display and filtering. Optional. */
  category?: RuleCategory
  /**
   * Called for each element node (nodes with a valid HTML/SVG tag).
   * Return violation descriptors; the engine fills in rule, severity, category, path.
   * Pass severity in the descriptor to override the rule default per violation.
   */
  check: (
    element: Record<string, unknown>,
    path: string,
    tag: string,
  ) => Array<{ message: string; hint?: string; severity?: Severity }>
}

Per-violation severity override

A custom rule can emit different severities per violation:

const strictTypography: CustomRule = {
  id: "strict-typography",
  severity: "warning",
  check: (element) => {
    const style = element.style as Record<string, unknown> | undefined
    if (style?.fontSize) {
      // Escalate to error for heading elements
      const tag = Object.keys(element).find(k => /^h[1-6]$/.test(k))
      return [{ message: "Inline font-size on heading.", severity: tag ? "error" : "warning" }]
    }
    return []
  },
}

Throwing rules

If a custom rule's check function throws, the error is caught silently and that rule is skipped for that element. Built-in rules are unaffected. Design check to be as defensive as possible.


Output Structures

Diagnostic

type Severity = "error" | "warning" | "info"

type RuleCategory = "structure" | "key" | "theme" | "typography" | "data-attr" | "visual"

interface Diagnostic {
  rule: string          // e.g. "inline-typography"
  severity: Severity
  category?: RuleCategory // always set by built-in rules; optional for custom rules
  path: string          // human path to the node, e.g. "div > ul > li"
  message: string       // one-line description of the problem
  hint?: string         // how to fix it
}

ValidationReport

interface ValidationReport {
  ok: boolean      // false when summary.error > 0
  issues: Diagnostic[]
  summary: {
    error: number
    warning: number
    info: number
    total: number
  }
}

FixResult

interface FixResult {
  tree: unknown          // deep copy with lossless fixes applied
  applied: AppliedFix[]  // what changed
  report: ValidationReport // validate() on the fixed tree
}

interface AppliedFix {
  rule: string
  path: string
  message: string
}

format() — human-readable output

format(diagnostics) converts a Diagnostic[] into a readable multi-line string. Pass it the output of diagnose() or report.issues.

import { diagnose, format } from "@domphy/doctor"

const output = format(diagnose(MyApp))
console.log(output)

Output format:

⚠ [inline-typography] div > p
  Inline `fontSize` — avoid inline typography styles.
  → Use a typography patch (paragraph()/heading()/…) via $ so the theme owns the type scale.
i [raw-theme-value] div > span
  Inline `color` uses a literal color (#ff0000).
  → Prefer a theme token — (l) => themeColor(l, "decrease-4", "error") …
✗ [void-content] div > input
  Void tag "input" must have null content (got string).
  → Write { input: null, … } and put attributes as sibling keys.

Severity icons:

  • — error
  • — warning
  • i — info

When there are no issues: "✓ No issues found."

if (format(diagnose(MyApp)) !== "✓ No issues found.") {
  // there are issues
}

CI Integration

Failing the build on errors

Use validate() as the programmatic gate. Only error-severity issues fail the build; warnings and info are surfaced but do not block.

// scripts/lint-ui.ts
import { validate, format } from "@domphy/doctor"
import { MyApp } from "../src/app"

const report = validate(MyApp)

if (report.summary.total > 0) {
  console.log(format(report.issues))
}

if (!report.ok) {
  console.error(`\n${report.summary.error} error(s) — build blocked.`)
  process.exit(1)
}
// package.json
{
  "scripts": {
    "lint:ui": "tsx scripts/lint-ui.ts",
    "ci": "tsc --noEmit && vitest run && npm run lint:ui"
  }
}

Warnings as a quality gate

To also block on warnings (stricter mode):

if (report.summary.error > 0 || report.summary.warning > 0) {
  console.log(format(report.issues))
  process.exit(1)
}

Filtering by rule

const themeIssues = report.issues.filter(d =>
  d.rule === "raw-theme-value" || d.rule === "raw-spacing-value"
)

if (themeIssues.length > 0) {
  console.warn("Theme token gaps found:", themeIssues.length)
}

Filtering by severity

const errors = report.issues.filter(d => d.severity === "error")
const structural = errors.filter(d =>
  d.rule === "void-content" || d.rule === "duplicate-key"
)

CLI: domphy-doctor

@domphy/doctor ships a domphy-doctor binary that scans files on disk instead of trees you import yourself — useful for a one-line CI step or a pre-commit hook.

npx domphy-doctor src/
npx domphy-doctor src/app.ts src/pages/

It walks the given files/directories (skipping node_modules, dist, .git, .next, .nuxt, and dotfiles), imports each .ts/.tsx/.js/.mjs file, collects every exported Domphy element (including zero-arg exported factory functions, called once to get their return value), and runs diagnose() on each one. .ts/.tsx files require tsx in your devDependencies — without it they're skipped with a warning.

Usage: domphy-doctor [options] <path...>

Arguments:
  path    TS/JS file or directory to analyze (skips node_modules, dist)

Options:
  --only <rules>       Only run these rule IDs (comma-separated)
  --exclude <rules>    Skip these rule IDs (comma-separated)
  --no-reactive        Skip reactive function evaluation
  --no-output          Skip Layer 4 HTML+CSS linting (htmlhint + stylelint)
  --no-factory-exec    Never invoke exported functions as zero-arg factories
                       (suppresses factory-threw warnings on component-library
                       files whose factories require props)
  --format text|json   Output format (default: text)
  -h, --help           Show this help

Exit codes:
  0  No errors (warnings/info are fine)
  1  One or more error-severity diagnostics, a file failed to import,
     or an input path was not found
  2  CLI usage error or nothing to analyze

--no-factory-exec is the flag to reach for when scanning component-library files: factories that genuinely require props cannot be invoked zero-arg, so without the flag each one produces a factory-threw warning. With the flag those exports are left untouched — no invocation, no warning (they are simply not analyzed).

// package.json
{
  "scripts": {
    "lint:ui": "domphy-doctor src/"
  }
}

Layer 4: HTML/CSS output linting

diagnose()/validate() (Layers 1–3) analyze the plain-object tree itself. auditOutput() is a separate, optional Layer 4: it builds an ElementNode, generates the actual HTML/CSS it would render, and runs it through htmlhint and stylelint. The domphy-doctor CLI calls it automatically for every element it collects (disable with --no-output); call it yourself if you're not using the CLI.

import { ElementNode } from "@domphy/core"
import { auditOutput, format, type Layer4Options } from "@domphy/doctor"

const node = new ElementNode(MyApp)
const outputDiags = await auditOutput(node, { path: "MyApp" })
console.log(format(outputDiags))
interface Layer4Options {
  path?: string // label prefixed to each diagnostic's path; defaults to node.tagName
}

function auditOutput(node: ElementNode, options?: Layer4Options): Promise<Diagnostic[]>
  • HTMLhtmlhint checks node.generateHTML() for structural/a11y issues: alt-require, attr-no-duplication, button-type-require, id-unique, input-requires-label, src-not-empty, spec-char-escape, tag-no-obsolete, tag-pair, tagname-lowercase. Diagnostics use rule: "html/<rule-id>".
  • CSSstylelint checks node.generateCSS() for color-no-invalid-hex, declaration-no-important, no-duplicate-selectors, no-empty-source, length-zero-no-unit (named colors are intentionally excluded — raw-theme-value already catches those at the source with better context). Diagnostics use rule: "css/<rule-name>".
  • Both diagnostic kinds carry category: "output" and a path suffixed with [html:line:col]/[css:line:col].
  • htmlhint and stylelint are optional peer dependencies, not bundled. If either isn't installed, auditOutput() silently returns [] for that linter — install what you need: npm install --save-dev htmlhint stylelint.

Running on multiple trees

For a codebase with several top-level views, collect diagnostics from each and merge:

import { diagnose, format, validate } from "@domphy/doctor"
import { HomePage } from "../src/pages/home"
import { SettingsPage } from "../src/pages/settings"
import { DashboardPage } from "../src/pages/dashboard"

const pages = [
  { name: "home", tree: HomePage },
  { name: "settings", tree: SettingsPage },
  { name: "dashboard", tree: DashboardPage },
]

let hasErrors = false

for (const { name, tree } of pages) {
  const report = validate(tree)
  if (report.summary.total > 0) {
    console.log(`\n--- ${name} ---`)
    console.log(format(report.issues))
  }
  if (!report.ok) hasErrors = true
}

if (hasErrors) process.exit(1)

Side-effect-free reactive functions

For best results, keep reactive functions pure — they should only read from state, not write to it, start timers, or dispatch events. The doctor calls them with a no-op listener; any subscription the function registers during that call is immediately discarded.

import { toState } from "@domphy/core"

const tasks = toState<Task[]>([])

// Fine — pure read; doctor can inspect the list
{
  ul: (l) => tasks.get(l).map(task => ({
    li: task.title,
    _key: task.id,
  }))
}

// Problematic — side effect inside reactive function
// Pass { runReactive: false } if you have this pattern
{
  div: (l) => {
    trackPageView()  // side effect — do not put this inside the reactive function
    return content.get(l)
  }
}