@calibrate-ds/dna
v0.2.17
Published
Design-system conformance: extract a codebase's design identity (.dna/identity.yaml, with per-rule provenance), then keep source AND rendered output conformant to it. Framework-agnostic: resolvers → normalized IR → generic rules.
Maintainers
Readme
@calibrate-ds/dna
Design-system conformance for codebases that have a design identity — or need one
extracted. dna reads what your codebase already believes (tokens, type scale, spacing
grid, observed colours), records it as .dna/identity.yaml with per-rule provenance
({ value, from, at } — which side held authority, and when), and then keeps both your
source and your rendered output conformant to it.
Requirements
Node.js >= 18.17.
Installation
npm install -g @calibrate-ds/dnaOr run it one-off with no install, via npx — note the full package name (dna
alone resolves an unrelated package on npm):
npx @calibrate-ds/dna checkOnce installed globally, the dna command is available directly:
dna check # → "No identity found — nothing to check. Run `dna start` …"
dna start # extract the identity + a starter config from this codebase
dna check # now it checks — and it is clean on the code it extracted fromStarting from a Figma design system
If your design tokens live in Figma (via the CalibrateDS plugin or otherwise), dna
doesn't import a token file directly — it only ever extracts from real, checkable
source in your own codebase, the same as everything else it does. The reliable path:
export the plugin's .tokens.css format (real CSS custom properties, including
var() aliases), commit it into your repo, and either add it to tokenSources in
ds-lint.config.json or just run dna start with it present under an includePaths
directory — dna reads it through the same postcss-based extractor it uses for any
other CSS, with the same tier inference, alias resolution, and fabrication guards.
Verified directly against a real, 200+-variable plugin export: colors, aliases, and
tiers all extract correctly with zero manual cleanup. Length-valued semantic tokens
(--font-size-*, --spacing-*, --border-radius-*/--radius-* aliasing a raw scale
via var(), e.g. --font-size-body-md: var(--scale400)) are classified by what they
actually RESOLVE to, not by reference syntax — they route into scales.type,
scales.spacingObserved, and scales.radius respectively, the sections that actually enforce
type/spacing/radius membership, rather than landing in palette or a generic
declaredScale bucket. A plain (non-semantically-named) length custom property still
falls back to scales.declaredScale, used by off-grid-spacing's scale mode. All four
axes — palette, type, spacing, radius — are enforceable from a .tokens.css import with
zero manual cleanup. The CSV/JSON/JS export formats aren't a supported input today;
.tokens.css is the one that goes through dna's real, hardened parser rather than a
bespoke importer.
Commands
| Command | What it does |
|---|---|
| dna start | Extract an identity → .dna/identity.yaml (palette with tiers inferred from the resolution graph, type scale, observed-in-usage values annotated for review — the extractor surfaces, never silently drops). Writes a starter config in bare projects. |
| dna check | The gate, diff-default: lint (tokens/literals/grid/scale) · cn-merge guard · rendered conformance (headless Chrome, needs puppeteer-core) · structural manifests. --audit for the whole tree, --only=lint,cn,render,manifest. |
| dna resolve <value> | Reverse-lookup a raw value → identity token. Given a colour (#2E7D32, #2e7d32, or the equivalent rgb(...) — all resolve identically) or a length (16px/1rem), prints the matching identity token name(s) labeled by axis; a value on multiple scales (type/spacing/radius) reports all of them. The proactive counterpart to dna check — author in the token vocabulary first pass instead of writing a literal and getting it flagged after the fact. Tri-state and honest: a well-formed value that isn't in your identity prints "not a token" (no green tick), no identity prints the dna start prompt, and malformed input is a clean error. Detector only — reads the identity, writes nothing. |
| dna allow <axis> <value> --why "<reason>" | Adopt a deliberate arbitrary value (type/radius/color/spacing) directly into identity.yaml's enforced section — immediate effect on the next dna check, no dna start re-run, no hand-editing YAML. --as <name> names it (required for radius/color). Stamps provenance and appends to .dna/decisions.jsonl. type/spacing adoptions survive dna start --force; radius/color do not unless the value is also present in source — dna allow tells you which applies when you run it. |
| dna inventory | The component vocabulary, parsed fresh from code: name · props · use-when · path. Read it at session start — the component failure mode is ignorance, not defiance. |
| dna similar | Two advisory duplication checks over the same scorer (role-normalized shape + class shape, asymmetric containment), diff-default (--audit for the whole tree). (1) Rebuild of an existing component — changed JSX scored against the component library ("you rebuilt <Metric> by hand"). (2) Clone of a net-new shape — near-identical subtrees clustered across the scanned files themselves, reporting a shape repeated ≥2× with no backing component ("extract one and reuse it") — the case rebuild-matching is structurally blind to, since neither copy has a library entry to match against. Both are advisory (exit 0), never gating — see MAINTAINERS.md non-negotiable #2. |
| dna shapes | Derive a component set from committed design-frame dumps (distribution-ranked, rule of three). |
| dna gen-manifest | Structural screen manifests from design frames (Sec-* children, y-order). |
| dna figma diff --fresh=<dir> [screen] | Advisory, optional, hermetic structural change detector. Diffs a freshly-produced dump against the committed baseline dump and reports the Sec-* section delta per screen — sections added, removed, and reordered (same set, new y-order — a distinct category). See below. |
| dna hook install | Pre-commit hook running dna check. Refuses to overwrite an existing .git/hooks/pre-commit it didn't write itself — pass --force to replace a foreign hook (its content is lost once you do, so back it up first if you need it). Re-running over its own prior install always proceeds without --force. |
dna figma diff — structural change detector
dna figma diff --fresh=<dir> answers "what changed structurally in the frames since I
last looked?" without touching the network. It's optional and advisory — most useful
to a team still treating Figma as a source of truth. dna is code-first, so a frame change
is information, not automatically a code violation; this ships as a reporting command,
never a gate.
- Baseline vs fresh. The baseline is the committed dump
gen-manifestalready reads,<cacheDir>/<screen>.figma.json— the last frame state you accepted into git. The fresh dump is one you just produced (the identical extractor stepgen-manifestuses, aimed at a scratch directory) and pass via--fresh=<dir>; per screen the fresh file is<dir>/<screen>.figma.json. One invocation diffs every configured screen; pass a screen name to diff just one. - Hermetic. Pure local dump-vs-dump. dna never fetches a dump, reads no credentials,
and has no network-capable import — same isolation guarantee as
dna check. - Structural only. Reports the
Sec-*section delta (added / removed / reordered), reusing the exact prefix-filter + y-order + schema-validation pathgen-manifestuses. A malformed baseline or fresh dump fails loud at validation (naming the file and the bad node), never a silent wrong or empty delta. Token-value and shape deltas are deliberately out of scope for now (future, separate axes). - Tri-state, middle never green.
pass(✓, both dumps present and structurally identical) ·no-op(•, no baseline — or no fresh — dump to diff against, naming the reason) ·delta(Δ, a real structural change). A no-op never renders as a green tick. - Exit convention. Advisory: a real structural delta reports and exits
0(likedna similar). The only non-zero exit is a hard error — a malformed dump (schema validation) or a missing--freshargument — i.e. "couldn't compute a trustworthy answer", not "the frames changed."
MCP server
dna mcp [--cwd=dir] runs a standalone MCP server that exposes dna's findings as
structured data to an MCP client (an AI IDE/chat), instead of a human reading CLI
output. --cwd sets the project root to operate on; it defaults to the MCP client's
workspace root, else the server process's own cwd. Independent of the @calibrate-ds/
cli MCP server — no shared code, no shared process.
| Tool | What it does |
|---|---|
| dna_check | Run dna's design-system conformance check and return the findings as structured data (rule, severity, file, line, value, and an optional "what would satisfy this" hint) — the same findings dna check prints to stdout and logs to .dna/log.jsonl. Detector only: this never edits source, config, or anything outside .dna/ — use the returned findings to make your own edits with your own tools. |
| dna_inventory | Return dna's current component inventory (name, props, one-line "use when" description, file path) as structured data — the same data dna inventory --json prints. Check this before building a new component: a component that looks missing may already exist under a different name. Read-only — this tool never writes anything. |
| dna_similar | Run dna's structural duplication check and return the findings as structured data, each tagged with kind: "rebuild" (a hand-rebuild of an existing component — file, line, matched component name/path, score) or "clone" (a net-new shape repeated across files with no backing component — size and the list of occurrences to extract). Same findings dna similar/dna similar --audit prints. Call this (alongside dna_inventory) before writing new component code: it catches reinvention that slips past a name-only inventory check, and net-new shapes being cloned across files. Detector only. |
| dna_resolve | Reverse-lookup a raw design value to the project's identity token(s): given a colour (hex like #2E7D32, or rgb()/rgba()) or a length (e.g. 16px, 1rem), returns the matching token name(s) labeled by axis — a value on multiple scales (e.g. both a spacing step and a radius step) reports all of them, never one. The same lookup dna resolve <value> prints. Call this before writing a raw literal pulled from a design/frame, so you author with the token instead of a hardcoded value — the proactive counterpart to dna_check. Tri-state and honest: resolved, not-a-token, no-identity, or invalid. Detector only — never writes. |
| dna_start_preview | Preview what dna start would extract and write — the palette (declared + observed-in-usage), type scale, spacing grid/scale, radius scale/idioms, Tailwind color families, and per-rule provenance annotations — as structured data, WITHOUT writing anything to disk. Use this to show a user what dna start is about to set up before they run it themselves. Preview only: dna start itself (CLI, user-initiated) is the only thing that writes. |
Every tool is detector-only — none of them ever write source, config, or anything
outside .dna/; a calling agent uses the returned findings to make its own edits with
its own tools. dna_check's payload also folds in the optional render/figma
sub-checks (rendered conformance, structural manifests) when those peers are installed
and configured, matching the CLI's own dna check behavior.
To make this self-serve for an agent working in a consumer project, dna start (and
dna start --force) auto-writes a short, delimiter-scoped instruction block into that
project's own CLAUDE.md/AGENTS.md, telling a coding agent to call dna_inventory/
dna_similar before creating a new component, dna_resolve before writing a raw value
pulled from a design/frame (to author with the token instead of a literal), and
dna_check after editing a design-relevant file — on its own initiative, no manual step
required from the human,
as long as dna mcp is configured as an MCP server in the agent's client. This is a
cooperation convention, not an enforcement guarantee — dna hook install's pre-commit
hook remains the real backstop regardless of whether an agent follows it.
CI / Team Usage
What to commit
.dna/ holds three files with genuinely different commit semantics — treat them
asymmetrically, not as one directory to gitignore wholesale:
| File | Commit? | Why |
| --- | --- | --- |
| .dna/identity.yaml | Commit | The shared design-system record — the whole team reads the same palette/scales/families. Not derived output; it's a record, never overwritten without --force. Hand-maintain it after the first dna start. Promoting a value (e.g. moving a size out of an observed/review-annotated entry into a real scale) is durable only if you don't re-run dna start --force — a full re-extraction recomputes palette/type/etc. purely from a fresh source scan and does not merge with your prior edits, so it silently wipes anything hand-promoted that isn't also present in source. Treat --force as an explicit, understood full re-derivation, not a routine refresh. |
| .dna/decisions.jsonl | Commit | The append-only trust-decision ledger: every severity change/rule override, with a reason. A new teammate reads this to understand why a rule was disabled — without it, that context only exists in one person's local .dna/ and is invisible to everyone else. |
| .dna/log.jsonl | Gitignore | Per-run violation noise, rewritten on every dna check. Local scratch, not a record. |
| .dna/state.json | Gitignore | A severity cache read/written every dna check — derived, not authoritative. |
dna start sets this up automatically: it writes or appends to your project's
.gitignore, ignoring exactly .dna/log.jsonl and .dna/state.json (never the whole
.dna/ directory), so identity.yaml and decisions.jsonl are committed by default.
Re-running dna start/dna start --force is idempotent — it won't duplicate entries
that are already there, and it never touches unrelated .gitignore content.
Recommended: add a CODEOWNERS entry for .dna/identity.yaml. Changing the
design system should go through a reviewed PR, not land as a silent commit buried in
an unrelated change:
# CODEOWNERS
.dna/identity.yaml @your-design-system-ownersCI
Gate PRs on dna check in GitHub Actions by diffing against the PR's base commit,
so the check only flags lines actually touched by the PR (not pre-existing issues
elsewhere in the repo). dna check defaults to --base=HEAD, which isn't useful in
CI — pass the PR's base SHA explicitly so the diff is computed against the branch
being merged into, not the last commit on the PR branch itself.
--base takes the ref as --base=<ref> (equals sign, no space) — the flag
parser only recognizes the --name=value form; a space-separated --base <ref> is
silently ignored and the diff falls back to the HEAD default.
name: dna check
on:
pull_request:
jobs:
dna-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so the base SHA below is resolvable
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npx @calibrate-ds/dna check --base=${{ github.event.pull_request.base.sha }}fetch-depth: 0 matters here: with the default shallow checkout, the base commit
often isn't present locally. dna check verifies base resolves before diffing and
exits non-zero with cannot resolve base ref '<ref>' — is the history shallow?
(actions/checkout needs fetch-depth: 0) when it doesn't — it will never silently
report "nothing to check" for an unresolvable ref.
Troubleshooting
dna mcp seems to hang / print nothing when I run it in a terminal.
This is expected — it's not stuck or broken. dna mcp starts a standalone MCP server
that holds the process open on stdio, waiting for an MCP client to send it JSON-RPC
messages. Run directly in a terminal with no client attached, there's nothing to
respond to, so it just sits there until you Ctrl+C. It isn't meant to be run
standalone to "see output" — the real usage path is registering it as an MCP server in
an IDE or chat client, e.g.:
claude mcp add dna -- dna mcpOnce a client is talking to it, dna_check / dna_inventory / dna_similar /
dna_resolve / dna_start_preview become available as tools it can call — see MCP server
above.
Cannot find package '@modelcontextprotocol/sdk' / ERR_MODULE_NOT_FOUND when
running dna mcp.
This means dependencies haven't been installed for packages/dna specifically. It
only happens on a local/dev clone of this monorepo, not on a real npm install -g
@calibrate-ds/dna or npx @calibrate-ds/dna — @modelcontextprotocol/sdk is a
regular dependencies entry in packages/dna/package.json, so a real npm install
pulls it in automatically. If you're seeing this, you don't need to worry about it as
a published-package user.
If you're working from a git clone, packages/dna has its own package-lock.json,
separate from any install step at the monorepo root — fix it with:
cd packages/dna
npm installRules
off-grid-spacingchecks grid divisibility, not scale membership, by default: a value that's a multiple of the configured grid (scale, default4) passes even if it was never one of the actual declared steps — e.g. atgrid: 4,p-11(44px) passes because44 % 4 === 0, even though 44 isn't in a scale like[4, 8, 12, 16, 24, 32]. This is deliberate: a literal-membership check false- positived on legitimate section spacing (py-8,py-12) that wasn't in an initially-declared scale, so grid divisibility is the permissive default for every existing user. Opt in to stricter checking withmode: "scale". In this mode, a value must be an exact member of a declared scale array —p-11(44px, on-grid but not a real step) is flagged;p-8(32px, in the array) is not.modeunset, or"grid", keeps the default divisibility-only behaviour unchanged.The declared array can come from two places, most specific wins:
- an explicit override in the rule config itself:
"off-grid-spacing": { "severity": "error", "mode": "scale", "scale": [4, 8, 12, 16, 24, 32] } - failing that,
.dna/identity.yaml's ownscales.spacingScale— written automatically bydna startwhen a project's owntailwind.config.*declares a realtheme.spacing/extend.spacing(the project's genuine declared scale, not a guess), or hand-authored the same way. Absent from the identity for every project that hasn't declared one — no behaviour change for them.
If
mode: "scale"is set but neither source resolves an array, the rule falls back to the grid check rather than silently going permissive or over-flagging.- an explicit override in the rule config itself:
Principles
A ✓ is a claim that something was verified. Three outcomes everywhere: pass (with counts) · nothing-to-check (named reason + fix, never green) · fail.
Coverage is accounted for, not assumed. Every file matched by
includePathsindna check(and every file walked bydna start) lands in exactly one bucket: examined, or skipped with a specific reason —no-resolver(unsupported file type, e.g..vue/.svelte),resolver-unavailable(e.g. incompatible/absenttypescript),parse-error(a file of a supported type that failed to parse), orunreadable(permission denied or similar). The headline reconciles the totals when any skips occurred —N examined · M skipped (reason breakdown)— so a clean or passing result never gets misread as full coverage when part of the codebase was silently unexamined. The same standard applies one layer up, at scope resolution itself: an unresolvable--basegit ref (e.g. a shallow clone missing the PR's base SHA) fails loudly with a specific message instead of silently reporting zero changed files, and a nonexistentincludePaths/excludePathsentry is reported as a named warning instead of reading as an empty project.Information is never invented, either. The inverse of the law above: a value entering
.dna/identity.yaml,dna inventory, or any other artifact is validated as well-formed for its type before it's written — a parser's error-recovery output on a broken file, or a regex's malformed match on unusual-but-valid input, is not data. A file that fails this validation is a parse failure, reported the same way any other skip is, never silently harvested and never silently dropped.Information never disappears silently — see CLAUDE.md for the design law and the tests every feature must pass.
Extract, don't impose. Tiers (primitive/semantic) are inferred from the alias graph, never required; flat one-tier projects see no mention of tiers anywhere. Naming conventions are read as signals, never demanded.
Isolation. Runs in a bare directory: no config, no git, no network, no credentials. Owns
.dna/and writes nowhere else.typescript(~20MB, JSX/TS resolution) andpuppeteer-core(rendered checks) are optional peers — the core installs with one dependency (postcss) and degrades cleanly, stating what it skipped.Each axis picks the guarantee that fits it — and says so explicitly, not left for users to discover by diffing behaviour. Four axes, four independently-justified choices:
| Axis | Default guarantee | Opt-in / escape hatch | |---------|-----------------------------------|------------------------| | Color | Membership —
families.allowed/palette | — (color either is or isn't yours; no useful middle ground) | | Type | Membership —scales.type| — (a font size either is a real step or it isn't) | | Radius | Membership —scales.radius+ idioms | — (see below: idioms are a categorical exemption, not a config toggle) | | Spacing | Divisibility — grid multiple ofscale(default4) |mode: "scale"→ membership, same as the others |Color and type are enforced as identity — exact membership — because a color either is or isn't yours, and a font size either is a declared step or it isn't; there's no useful "close enough" for either. Spacing defaults to hygiene (grid divisibility only —
off-grid-spacing) because "any multiple of the grid" is a real, permissive design position many codebases hold on purpose; opt into identity-level strictness withoff-grid-spacing'smode: "scale"(below). Radius (#135) is membership like color/type, NOT divisibility like spacing's default — a radius value either matches a reviewed scale step or it's flagged, full stop, no permissive "any multiple of 4px" fallback — except for a first-class idiom exemption: pill/circle shapes (999px,9999px,50%) are categorically different from "a scale step that happens to be large," so they're always legal regardless of scale membership, via.dna/identity.yaml'sscales.radiusIdioms(seeded with the three industry-standard values by default;dna startalso auto-detects likely idioms from your own source — any value ≥100px, or any%-based value, since a real radius scale never has steps that large). This is why radius isn't simply "spacing'smode: 'scale'twin": it needed its own categorical exemption that spacing's membership opt-in doesn't have a parallel for.Spacing: if you want spacing enforced as identity too — exact membership in a reviewed set of real steps, not just anything grid-divisible — opt in explicitly with
off-grid-spacing'smode: "scale".dna startgives that opt-in something real to point at: it extracts a frequency-ranked, review-annotated candidate scale from actual usage (CSSpadding/margin/gapdeclarations, merged with Tailwind class usage) into.dna/identity.yaml'sscales.spacingObserved, for you to promote intoscales.spacingScale. Oncemode: "scale"is active, an on-grid value that isn't in that promoted scale gets aprefer-scale-stepnudge (warn) naming the nearest real step, distinct fromoff-grid-spacing's hard error for values that are off-grid outright.Radius:
dna startextracts a frequency-ranked, review-annotatedscales.radiusfrom realborder-radius:/border-*-radius:CSS declarations, merged with Tailwindrounded-[Npx]class usage — the same extract-then-review shape spacing/type use, written directly as the enforced scale (no separate observed→promoted staging step, since radius has no curated Tailwind-config source the way spacing does). A CSS declaration on the scale (or inradiusIdioms) is legal outright; a Tailwind class on the scale downgrades toprefer-token-class(swap the arbitrary class for the real token); neither →arbitrary-values.
Layout
core/ model · resolvers · rules · lint · identity · start · inventory · similar ·
check-cn — render/ rendered conformance — figma/ manifests + shape extraction —
bin/dna.mjs — test/ the fixture suite (npm test) — scripts/check-generic.mjs
keeps this repo project-agnostic (no consumer names in tooling logic, ever).
