Skip to content
Domphy

Doctor

@domphy/doctor is a static analyzer for Domphy element trees. It walks the plain-object tree — including the output of reactive (listener) => … functions — and flags non-idiomatic patterns. Its main job is to give AI agents (and humans) a feedback loop: generate code → diagnose() → fix what it reports.

Because Domphy UIs are plain objects, the doctor needs no parser and no build step.

Install

npm install -D @domphy/doctor @domphy/core

@domphy/core is a peer dependency (the doctor reads its tag tables).

Live Example

Run diagnose() on a deliberately broken tree, then apply fix() and watch the report shrink:

Usage

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

const App = {
  div: [
    { p: "Hello", style: { fontSize: "20px" } }, // inline typography
    { input: "oops" }, // void tag with content
    { dvi: "typo" }, // unknown tag
  ],
}

console.log(format(diagnose(App)))
⚠ [inline-typography] div > p
  Inline `fontSize` — avoid inline typography styles.
  → Use a typography patch (paragraph()/heading()/…) via $.
✗ [void-content] div > input
  Void tag "input" must have null content (got string).
⚠ [unknown-tag] div
  "dvi" is not a known HTML/SVG tag — likely a typo.

diagnose(element, options?) returns Diagnostic[]:

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

interface Diagnostic {
  rule: string     // "inline-typography" | "void-content" | "missing-key" | …
  severity: Severity
  category?: string // "structure" | "key" | "theme" | "typography" | "data-attr" | "visual"
  path: string     // "div > ul > li"
  message: string
  hint?: string
}

Severity is exported from @domphy/doctor as its own named type.

Rules

RuleSeverityCatches
inline-typographywarningfontSize / lineHeight / fontWeight / letterSpacing / fontFamily / textDecoration literals in style — use a typography patch, or the @domphy/theme token the hint names per property (themeWeight(), themeLetterSpacing(), themeFont(); fontFamily usually just goes away, since the themed root's stack inherits). Static and reactive forms are judged identically (fontWeight: 500 and () => 500 are the same declaration; a var(--…)/calc() is theme-driven either way). Scoped to the element itself — &:hover, &::after and conditional at-rules (@media, @container, @supports, @layer) included, at any depth — while a nested block whose selector reaches descendants ("& h1", "& > p") type-sets markup with no call site to patch and is not checked
raw-theme-valueinfoa literal hex/rgb/hsl color or a CSS named color ("red", "white", "black", …) in a color style prop (color, background, border, fill, …). A color-mix() is judged by its arguments — a mix of nothing but var(--…) theme tokens is not a raw value; a literal inside one still is. For hex/rgb values the hint uses @domphy/theme chromametry (CIELAB→LCH) to suggest the nearest themeColor() call with perceptual coordinates
raw-spacing-valueinfoa literal rem/em/px value in a layout spacing prop (padding, paddingBlock, paddingInline, margin, marginBlock, marginInline, gap, …) — suggests themeSpacing(n) for consistent theme density
low-opacitywarning / infostyle.opacity < 0.6 on a control is too dim for interactive elements to be discoverable; downgraded to info if a hover-restore pattern (&:hover: { opacity: '1' }) is detected
tone-background-inheritwarningstyle.backgroundColor resolves to a fixed shifted tone instead of "inherit" — use dataTone to shift the surface context, not backgroundColor directly. Null-content decorative hosts ({ span: null } swatches, chips, glyphs) are exempt: they paint a fixed tone by definition and have no children for a tone context to reach
missing-colorwarningelement uses themeColor() for at least one styled prop but has no style.color — text color won't re-evaluate when the tone context shifts (CSS color inheritance carries the computed value, not a live theme var)
low-contrastwarning / infostyle.color and style.backgroundColor both resolve to theme vars but their ramp gap is < CONTRAST_SPAN (9) — insufficient contrast for legible text. Both are resolved against the element's own surface (its dataTone, or the nearest declared ancestor's), since every themeColor() tone is relative to the tone context. Nested blocks (&:hover, @media …) are checked with the cascade applied and reported at info
dataTone-surface-contractwarningelement sets dataTone but is missing backgroundColor and/or color — a tone context surface must declare both so children can guarantee readable contrast
color-shift-minimumwarningstyle.color on an element with dataTone lands fewer than CONTRAST_SPAN (9) ramp steps from the surface that dataTone creates. A gap, not an absolute step: on a shift-17 surface themeColor(l, "shift-9") resolves to step 7 and is legible. Defers to low-contrast when the element's own backgroundColor already gives it that pair
unknown-tonewarninga dataTone string that isn't in ElementTones (inherit / base / shift-N / increase-N / decrease-N with N ≤ 17, or aliases surface / hover / border / border-strong / muted / text) — catches invented words like foreground / light, bare-numeric strings like "3" (the runtime throws; use dataTone: 3 or "shift-3"), and out-of-range offsets like shift-25
middle-surface-anchorwarninga dataTone: "shift-N" where N is 4–13 — a mid-ramp surface anchor causes child tones to clamp and collapse contrast; prefer edge anchors (0–3 light, 14–17 dark)
unknown-densitywarning / errora dataDensity value that isn't "inherit" / "increase-N" / "decrease-N" (N ≤ 4), or uses shift- (invalid for density). Error when N > 4 (out of the 5-step scale).
unknown-sizewarning / errora dataSize value that isn't "inherit" / "increase-N" / "decrease-N" (N ≤ 7), or uses shift- (invalid for size). Error when N > 7 (out of the 8-step scale).
void-contenterrora void tag (input, img, br, …) with non-null content
missing-keywarninga dynamic list (returned by a reactive function) of element children missing _key
unknown-tagwarningan element whose first key isn't a valid HTML/SVG tag or custom element name (typo)
duplicate-keyerrortwo siblings sharing the same _key value — the reconciler can't tell them apart
unstable-keywarninga dynamic list whose _keys are the array index (0, 1, 2, …) — index keys shift on reorder/insert
invalid-nestingerrorHTML content-model violations the browser re-parents (breaking SSR/hydration): flow content in <p>, a/button inside a/button, li/dt/dd/tr/td/th/option/table-section tags with the wrong parent, non-li element child of ul/ol. Declared direct parent-child pairs only — reactive content, rawHtml, custom elements (no declared content model) and SVG subtrees are exempt
click-without-keyboardwarningan onClick on a non-interactive element (not a/button/input/select/textarea/summary/label, no interactive role, no tabIndex) without a keyboard handler — hidden elements exempt
missing-required-attributeerror (warning for a)<img> without alt (aria-label/aria-labelledby/role: "presentation"|"none" accepted), <iframe> without title; <a> with onClick but no href/role is a warning
descendant-color-overridewarninga scoped "& <tag>": { color } / { backgroundColor } block on an element that declares <tag> descendants carrying a patch which sets the same prop to a different value — a descendant selector is specificity (0,1,1) and a patch's own class only (0,1,0), so the patch's tone is silently overridden. A block that resolves to the same value the patch would paint changes nothing and is not reported
unused-doctor-disableinfoa _doctorDisable entry that suppresses nothing on its element — the named rule fired no diagnostic there, _doctorDisable: true consumed nothing, or the id matches no known rule (typo detection). Only entries the element declares itself are checked, never a $ patch's

The doctor implements 23 built-in rules. See Rules Reference for the full contract of each.

$ patches are part of the element

Every rule reads the element's effective props — the element with its $ patches applied, exactly the way ElementNode composes them at runtime (each patch expanded, composed left to right, the native element last so it wins). A style, dataTone, role, tabIndex or _doctorDisable a patch contributes is therefore visible to the rules, and a rule never asks for something a patch already supplies:

// missing-color fires — panelSection({ divider: true }) themes a
// borderBottom with themeColor(), and nothing on the element sets `color`.
{ div: "Section A", $: [panelSection({ divider: true })] }

// low-contrast does NOT fire — button()'s solid variant declares
// _doctorDisable: ["low-contrast", …] for its deliberate deep brand fill,
// and that suppression travels with the patch to every host.
{ button: "Save", $: [button({ color: "primary", variant: "solid" })] }

The element's children are read from the declared tree: content a patch contributes as a default is analyzed, content the element declares itself is analyzed as written. The host tag always comes from the element, never from a patch — core resolves the tag before patches are merged.

By default the doctor invokes reactive content functions with a no-op listener to inspect their output (this is how the dynamic-list rules are found). Pass { runReactive: false } if your reactive functions have side effects.

duplicate-key is decidable on any sibling array — static or dynamic — so it is checked everywhere. missing-key and unstable-key are specific to dynamic lists, since only those go through keyed reconciliation.

Rule filtering

Run only a subset of rules with only, or skip rules with exclude:

// Only check theming
diagnose(App, { only: ["raw-theme-value", "raw-spacing-value"] })

// Skip soft info-level recommendations
diagnose(App, { exclude: ["raw-theme-value", "raw-spacing-value"] })

Inline suppression

Add _doctorDisable to an element to suppress rules at that node (not its children):

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

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

Custom rules

Provide project-specific rules alongside the built-in 23:

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

const noBareDivRule: CustomRule = {
  id: "no-bare-div",
  severity: "warning",
  check: (_el, _path, tag) =>
    tag === "div" ? [{ message: "Use a semantic element or add a patch." }] : [],
}

diagnose(App, { rules: [noBareDivRule] })

See Configuration & API for full details.

validate

validate(element, options?) is the aggregate entry point. It runs every rule and returns a structured report instead of a raw array:

import { validate } from "@domphy/doctor"

const report = validate(App)

report.ok      // false — there is at least one error-severity issue
report.issues  // Diagnostic[] — same as diagnose(App)
report.summary // { error: 1, warning: 2, info: 0, total: 3 }
interface ValidationReport {
  ok: boolean // true when there are no error-severity diagnostics
  issues: Diagnostic[]
  summary: { error: number; warning: number; info: number; total: number }
}

ok is false when any error diagnostic is present; warnings and info do not flip it. Use this as the single programmatic gate — for example fail CI when !report.ok — while diagnose / format remain available for raw access.

fix

fix(element, options?) applies the lossless fixes automatically and reports the rest:

import { fix } from "@domphy/doctor"

const { tree, applied, report } = fix(App)
// tree    — a copy with lossless fixes applied (reactive functions preserved)
// applied — [{ rule, path, message }] describing what changed
// report  — validate(tree): the issues that still need a human/model decision

Only provably-lossless transforms run (currently void-content: a void tag cannot render children, so its content is cleared to null). Anything that needs intent — which key, tone, color token, or typography patch — is never guessed; it stays in report for the model or you to resolve. This keeps autofix safe to apply blindly in an agent loop.

In an AI loop

This is the point of the package. After the model generates a Domphy tree, run the doctor and return the report:

const report = format(diagnose(generatedApp))
if (report !== "✓ No issues found.") {
  // hand `report` back to the model and ask it to fix the listed issues
}

Most LLMs have little Domphy training data, so they learn it in-context from llms.txt / AGENTS.md. The doctor enforces those same rules mechanically — turning "the model might get it wrong" into "the model gets told exactly what's wrong and fixes it." Wire it into your agent's task loop or CI.

Large codebases

In a real app the model also needs to find and reuse the app's own building blocks, not just the framework surface. The repo ships an app-block registry generator, apps/web/scripts/app-manifest.mjs, which parses your app source with the TypeScript compiler API and emits one entry per exported Domphy block (function/const that returns an element tree):

node apps/web/scripts/app-manifest.mjs [srcDir] [outFile]
# defaults: srcDir = apps/web/docs/demos, outFile = apps/web/public/app-manifest.json

Each entry carries { name, kind, file, signature, jsdoc, exportKind } — a machine-readable index an agent can browse the way manifest.json exposes the framework packages and @domphy/ui patches.

@domphy/mcp wraps both halves as MCP tools so an agent gets validation and discovery over the wire:

ToolDoes
domphy_list_patchesLists all @domphy/ui patches with their host tag and signature.
domphy_get_patchGets one patch's full contract (host tag, signature, props, example, doc, source) by name.
domphy_list_packagesLists all @domphy/* packages with versions and descriptions.
domphy_rulesGets the Domphy code-generation rules (llms.txt) for AI agents to follow.
domphy_tonesGets valid tone and theme color names for themeColor()/dataTone — avoids invented tones.
domphy_diagnoseRuns diagnose() on a JSON element tree and returns a formatted text report (format(diagnose(...))), without the structured { ok, issues, summary } wrapper that validate() adds.
domphy_validateRuns the aggregate validate() on a JSON element tree, returning { ok, issues, summary }.
domphy_fixApplies the lossless autofix to a JSON element tree, returning { tree, applied, report }.
domphy_list_app_blocksLists the app's own blocks (name, kind, signature, file) from app-manifest.json.
domphy_get_app_blockReturns one block's full source plus signature and jsdoc, by name.

The loop becomes: list the app's blocks → reuse them → generate → domphy_validate → fix what it reports. The doctor is the validation half; the manifest is the discovery half.