npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

fairux

v0.2.0

Published

Explainable, rule-based linter for dark patterns & unfair UX — scan HTML, JSX/TSX, and Figma exports for UX risk signals. Local, no AI.

Readme

fairux

Explainable, rule-based linter for dark patterns & unfair UX — scan HTML and JSX/TSX for UX risk signals. Local, no AI.

fairux flags interface patterns that may pressure or mislead users — dark patterns, misleading subscription flows, hidden costs, unfair consent UI, missing cancellation paths, and scarcity pressure. Every finding explains what was detected, why it matters, and how to fix it. It runs entirely on your machine; no network, no AI.

⚠️ Not a legal tool. Findings are UX risk signals for human review, not a judgment that a UI is "illegal" or "malicious".

Install / run

Published on npm, on the latest dist-tag:

npm install --global fairux

The published version of record is the release table in the release runbook; this README names the channel rather than repeating a version literal that nothing here would keep current — it carried the first beta's version, and the claim that no release had happened, for two releases after both stopped being true, in a file that ships inside the published tarball. The prerelease channel is unchanged: fairux@next resolves the newest beta.

# one-off, no install
npx fairux scan page.html

# or add it to a project (dev dependency)
npm install --save-dev fairux
pnpm add --save-dev fairux

# then
npm exec fairux -- scan page.html   # or: pnpm exec fairux scan page.html

Requires Node.js ^22.18.0 || >=24.11.0.

Usage

fairux scan <path>                                # .html → HTML; .tsx/.jsx/.ts/.js → JSX/TSX
fairux scan <dir>                                 # recursively scan a directory
fairux scan '**/*.html'                           # glob pattern (fast-glob; sorted, skips .git/node_modules)
fairux scan -                                     # read from stdin (parsed as HTML)
fairux scan - --stdin-filename Page.tsx           # name it so its extension picks the adapter
fairux scan <path> --format json|markdown|sarif|html  # default: markdown
fairux scan <path> --include-experimental         # also run heuristic rules
fairux scan <path> --config ./fairux.config.json  # explicit config
fairux scan <path> --ignore-config                # ignore any discovered config
fairux scan <path> --fail-on high|medium|low|info # exit 1 if findings meet threshold

fairux rules                                      # list the rules a scan would run
fairux rules --format json                        # same list, machine-readable
fairux rules --include-experimental               # include heuristic rules

fairux explain <rule-id>                          # what one rule checks, and what it cannot see
fairux explain <rule-id> --format json            # same, machine-readable

fairux scan <path> --rule-pack ./pack.mjs         # load an external RulePack (repeatable)
fairux scan <dir> --no-ignore                     # bypass a discovered .fairuxignore
fairux scan <dir> --write-baseline fairux.baseline.json  # record what is already there
fairux scan <dir> --baseline fairux.baseline.json        # fail on new findings only
fairux scan <dir> --suppress fairux.suppressions.json    # accept individual findings, with reasons

Output formats: Markdown (default), JSON (a stable, documented envelope), SARIF 2.1.0 (for GitHub code scanning), and HTML. The adapter is chosen by file extension; JSX/TSX scanning is static-only.

HTML report

--format html writes a single self-contained file: no script, no external stylesheet, no font, no image, no remote URL of any kind. It renders as a build artifact, an email attachment, or in an air-gapped review — and it cannot report back on what was scanned.

fairux scan ./dist --format html > fairux-report.html

Everything in a finding is untrusted text from the scanned page — evidence snippets are literally markup FairUX found — so every value is escaped on the only path it can take to the output. There is no JavaScript in the report at all, which is a property a test can check rather than a promise.

It carries a coverage panel when the report has coverage to show, and a FairUX Risk Index panel when one was asked for with --risk-index. It carries no grade, no good-or-bad verdict, and no Lighthouse-style chart: those would be the overstatement this project keeps refusing. An empty report says so in as many words — no findings is not a statement that the page is fair or compliant.

Listing the rule set

fairux rules answers "what will a scan here actually run", under the same --config, --ignore-config, and --include-experimental inputs scan uses. It reports the effective severity after any config override, and why each rule is or is not enabled — "you turned it off" and "it is experimental and you did not ask" produce the same silence in a scan and are different things to know.

The decision itself is the engine's, not a second reading of it, so the list cannot disagree with the scan beside it.

Enabled is not coverage. A rule scoped to a page context runs only where the page carries a matching signal, so an enabled rule is silent on a page it does not apply to. The output shows each rule's scope and says this in as many words; what a scan actually checked is not something FairUX reports yet.

Explaining one rule

fairux explain consent/checked-checkbox prints that rule's governance record: maturity, what it needs from the page, the jurisdictions and official sources the maintainers reviewed, and — first, above the citations — its known limitations.

The limitations are the point. consent/checked-checkbox records that a checked attribute may not match runtime state after scripts run, which is the difference between a finding worth acting on and one worth dismissing. A rule whose record states no limitations says so explicitly; a missing section would read as a guarantee, and there is none.

Jurisdictions and sources are review context, not a verdict. They record what was read while deciding the rule was worth shipping. FairUX returns risk signals, not legal judgments.

Why a specific finding matters, and what to change, comes with that finding — run a scan.

Inline suppressions

A comment beside the line accepts one finding, with the argument next to the code it is about:

<!-- fairux-disable-next-line scarcity/scarcity-phrase -- stock count is live from inventory -->
<p>Only 2 left in stock!</p>
{/* fairux-disable-next-line consent/missing-reject-option -- reject lives in the footer */}
<button>Accept</button>

The reason is required here too. A directive without one is refused and reported — it does not silently suppress nothing, because a user who wrote a directive and got silence would believe a finding was accepted when it was not. A directive that matches nothing is reported the same way.

It applies to the next line only, and only to the rule it names. Not "the next finding", which would skip blank lines and quietly cover something further down.

Available for HTML and JSX/TSX — the inputs that have both comments and line numbers. A live DOM has comments but no stable lines and a Figma file has neither, so neither supports this. There is deliberately no file-level fairux-disable: a whole file with no findings is indistinguishable from a whole file nobody looked at.

Suppressed findings and directive problems are recorded in the report as suppressed and suppressionDiagnostics, so nothing is hidden.

Suppressions

Some findings are deliberate: a scarcity phrase where the scarcity is real, a pattern a regulator has approved for one jurisdiction. Those need an argument attached, not a bulk acceptance.

{
  "schemaVersion": "1",
  "entries": [
    {
      "fingerprint": "a143d03c1e5a1566",
      "ruleId": "scarcity/scarcity-phrase",
      "reason": "Stock count is live from inventory; the scarcity is real.",
      "expiresOn": "2027-01-01"
    }
  ]
}

A reason is required and may not be blank. A suppression without one is a disabled rule with extra steps, and fairux.config.json already disables rules — so a file containing a reasonless entry is refused before anything is scanned, naming the entry.

expiresOn is optional and enforced. The suppression applies through the whole of that day; after it, the finding comes back and the lapse is reported. Dates are compared as YYYY-MM-DD strings, so nobody has to decide what timezone a suppression expires in — which is also why the date has to be a day the calendar actually has. 2026-02-30 is date-shaped and sorts after every real day in February, so an entry carrying it would outlive the month it was written for and nothing would say so; it is refused, as are 2025-02-29, 2026-13-01, and the rest.

One fingerprint may appear once. Two entries for one finding are two arguments for one decision, of which a run applies one without saying which, so a file containing both is refused with both entry indexes named. ruleId is optional, is never matched on, and must be a non-empty string when present — it is what the stderr summary shows a reader. Fields this version does not know are accepted and ignored, so a file written by a later one stays readable.

Every run prints what was suppressed and why, plus entries that have expired and entries that matched nothing. A suppression nobody can see is a rule that was silently turned off; the argument is the only thing distinguishing the two.

How this differs from a baseline, deliberately:

| | Baseline | Suppression | | --- | --- | --- | | Scope | every finding in one scan | one finding | | Justification | none — a date | a required reason | | Expiry | none | optional, and enforced | | Intent | "we will get to this" | "this is correct here" |

Both can be used together. Suppressions are applied first, so a finding covered by both is attributed to the one that carries a reason.

There is a third mechanism, and it is applied somewhere else. An inline directive is read by the scanner, so the finding never reaches the report's findings at all — it moves to suppressed, with its reason. A suppression file and a baseline are read by the CLI, after the scan, and subtract from the report it produced.

That difference has one consequence worth knowing before it surprises someone: a finding an inline directive removed leaves no fingerprint anywhere in the report, so a baseline entry covering that finding is reported as one the file can drop — while the page still has it.

Baselines

On an existing codebase the first scan reports everything at once. A baseline records what is already there so a run fails on new findings only.

fairux scan ./src --write-baseline fairux.baseline.json   # once, and commit the file
fairux scan ./src --baseline fairux.baseline.json --fail-on medium

A baseline is a record of accepted risk, not of resolved risk. Nothing about writing one makes a finding less true. The file says so in its own contents, and every baselined run reports on stderr how many findings it suppressed — including when that number is zero, so "the baseline is empty" and "the baseline was not applied" stay distinguishable.

Baselined findings that no longer appear are reported too, so the file can shrink. It is never rewritten by a normal scan: a file that updates itself when findings change is a file that never fails. Rerun --write-baseline deliberately.

Findings are matched on fingerprints.fairuxV1. That survives a line moving, but not the markup around a finding being restructured — the primary locator is part of the fingerprint, so such a finding reappears as new. Expect that when refactoring, and re-record rather than assuming the baseline broke.

--write-baseline writes the file and emits no report, for the same reason: a command that both recorded a baseline and passed would be a command that never fails.

What a version-1 baseline file must contain

{
  "schemaVersion": "1",
  "note": "Accepted risk, not resolved risk. …",
  "toolVersion": "0.1.0",
  "createdAt": "2026-01-01T00:00:00.000Z",
  "entries": [{ "fingerprint": "a143d03c1e5a1566", "ruleId": "consent/checked-checkbox", "file": "signup.html" }]
}

All five top-level fields are required, and --baseline refuses a file missing any of them rather than reading it as an empty baseline. note, toolVersion, and createdAt are how a file committed a year ago answers "what is this, what wrote it, and when" — the ones a reader needs and no code dereferences. Each entry needs a non-empty fingerprint and ruleId; file is optional and must be a non-empty string when present. One fingerprint may appear once, and a duplicate names both entry indexes.

Two things are deliberately not checked. note is never compared to the text this version writes, so a reworded or older note stays readable, and createdAt accepts any ISO 8601 date-time rather than only toISOString()'s exact output. Unknown fields are accepted and ignored, so a file written by a later version remains readable by this one.

Naming a piped document

fairux scan - has no path, so nothing tells it whether the bytes are markup or JSX. It reports the document as stdin.html and parses it as HTML. --stdin-filename <name> replaces that label, and because the adapter is chosen by extension, it is also what selects the parser:

cat Page.tsx | fairux scan - --stdin-filename Page.tsx --format json

It must be a bare file name with an extension this scans — no separator, no . or .., no control character, no leading-dot-only name. The label is what the report records and what a remediation would carry as its file, so a label that looks like a path is a label something downstream may treat as one. For the same reason --fix-dry-run and --fix-write stay refused for a piped scan whatever the document is called: there is no file on disk to fix.

Options that cannot be combined

Because --write-baseline emits no report, everything a report goes through is dead for that run — so it is refused beside --format, --suppress, --baseline, --risk-index, --risk-index-model, --fix-dry-run, --fix-write, and --fail-on rather than accepted and ignored. The same applies to --risk-index-model without --risk-index (no index is computed at all), to --ignore-config beside --config (there is no discovery pass left to skip), and to --fix-dry-run beside --fix-write, which ask for opposite things. --stdin-filename is refused beside a path target: it names the document piped to -, and a run that took the path and ignored the name would report a file it did not scan under a name nobody gave it.

--ignore-config beside --config is refused by scan, scan-journey, rules, and explain alike, in the same words and before any of them loads a config. rules describing a rule set the scan beside it would have refused to run is worse than either command failing.

A refused invocation exits 2 and does nothing — no discovery, no scan, no RulePack import, no output file. Exit 1 stays what a finding means. Nothing here picks a winner between two flags: a command line that says two things is a command line whose author meant one of them, and which one is not something this can know.

The files --suppress and --baseline name are read in the same place, immediately after the invocation is accepted and before anything is discovered, scanned, or imported. A malformed one exits 1 without having run a scan and without having executed a RulePack, which is unsandboxed code.

Fixes

fairux scan ./src --fix-dry-run   # say what would change, and change nothing
fairux scan ./src --fix-write     # apply the safe ones

--fix-write applies remediations marked safe and nothing else. There is no flag that applies a review-required one, and an AI-suggested edit can never be marked safe — that is refused when the remediation is validated, not when it is applied.

One built-in rule proposes a fix. consent/checked-checkbox offers to delete the checked attribute from a pre-checked consent box, and only in static HTML, where the parser recorded exactly where the attribute is and exactly what that range holds. The edit removes the attribute and the whitespace before it; it changes no wording, no label, no order, and no other markup. Nothing else in the built-in set proposes anything, deliberately — a fix is offered where reading the diff is enough to check it, and rewording a sentence a user reads is not that.

There is no fix when the proof is short of complete, and the finding is still reported:

| Situation | Why there is no fix | | --- | --- | | a JSX/TSX or Figma finding | no adapter recorded the attribute's range | | checked="yes" and other free values | HTML calls it pre-checked; only the spellings whose meaning is beyond argument are removed | | the file changed since the scan | the checksum no longer matches, and the edit is refused rather than landing on different bytes |

A --fix-write run exits 1 when a safe remediation it was asked for did not land — a stale file, a permission, or an edit another remediation had already made stale — so a script cannot commit a tree it believes was fixed. Every refusal is named on stderr with its reason.

.fairuxignore

A .fairuxignore beside your config keeps generated output and vendored code out of a scan. It is found by walking up from the scan's base — the same way fairux.config.json is — and applies to directory walks and globs.

# generated output
dist/
build/

vendor/**
!vendor/keep-this.html

An explicitly named file is always scanned, even when a pattern excludes it. Naming a file is an instruction, and silently doing nothing in response to one is worse than scanning something you did not want. Use --no-ignore to bypass the file for a whole run.

The grammar is a small subset of gitignore's, and the boundary is stated rather than left to be discovered:

| Supported | Not supported | | --- | --- | | # comments, blank lines | character classes ([a-z]) | | *, ?, ** | backslash escaping | | leading / (anchor to the ignore file's directory) | nested per-directory ignore files | | trailing / (directories and everything under them) | reading .gitignore | | ! negation, last match wins | |

An unsupported pattern is refused with its line number rather than matched approximately: a pattern you believe excludes something and does not is the failure worth avoiding. For the same reason, patterns that matched nothing during a run are reported on stderr, and a scan that ends with no files names the ignore file as the reason.

Only one file is used — the first one found. Git merges nested ignore files per directory; doing that here would make "why was this skipped" a question with several answers.

.gitignore is deliberately not read. A file being untracked is not the same as it being uninteresting to a linter.

External RulePacks

--rule-pack <path> loads a RulePack and composes it with the built-in one. It is repeatable, and it works on scan, rules, and explain alike, so all three describe the same set.

fairux rules --rule-pack ./packs/house-rules.mjs
fairux scan ./dist --rule-pack ./packs/house-rules.mjs --format sarif

A RulePack is executable JavaScript and FairUX does not sandbox it. It runs with your privileges. That is why loading is explicit per invocation: there is no auto-discovery and no config key that loads one, because a config file is found by walking up from the working directory and would make cloning a repository enough to run its code. Loading prints a warning naming the path, on stderr, so --format json on stdout stays parseable.

The module may export the pack as default or as exactly one named export — the authoring example uses a named one. Two exported packs are refused rather than resolved by order.

A malformed pack, a duplicate pack id, or a rule id colliding with a built-in one is refused before anything is scanned. A pack whose own status is experimental is skipped entirely unless you pass --include-experimentalfairux rules shows the composed set, so it will tell you.

Every pack that ran is recorded in the report envelope's rulePacks and in SARIF's rule metadata.

Glob separators

Quote a glob so the shell hands it over unexpanded. / works on every platform. On Windows \ is accepted too, so fairux scan "src\*.html" names the same files as src/*.html — neither cmd.exe nor PowerShell expands a pattern, so the CLI is what has to understand it. On Linux and macOS a backslash keeps its escape meaning, so a\*.html names the single file a*.html.

UNC, device, and extended-length patterns (\\server\share\*.html, \\?\C:\…, \\.\…) are not expanded, and are refused with exit code 2 rather than reported as matching nothing. Scan the directory itself instead — a directory or a direct file on a share is unaffected.

Multi-file scanning

Scanning a directory or glob pattern that resolves to multiple files produces a batch report (FairUxBatchReport) that preserves per-file metadata (runtime, file path, individual findings) while providing an aggregate summary. If the target resolves to exactly one file, the CLI emits the standard single-file FairUxReport; that keeps consumers from handling a batch wrapper for a single result.

Figma adapter (experimental)

.figma.json and .figjson files are parsed using the Figma REST API node types. The adapter infers semantic HTML tags from COMPONENT/INSTANCE node names and componentPropertyDefinitions. This is experimental — inference is conservative and confidence is low. Throws on input size limits (does not silently truncate).

The inference is heuristic; whether the input is the shape the adapter consumes is not. A payload is refused, naming the node, when it is not JSON, is not an object, has no document, has a node that is not an object or that lacks an id, name, or type, has children that are not an array, has a component-property entry of the wrong shape, or uses one node id twice — an id is the whole of a Figma finding's locator, so two nodes sharing one produce two findings nothing can tell apart. Fields the adapter does not consume are ignored, so a payload from a later API version stays readable.

Scan limits

| Limit | Value | Scope | | -------------------- | ------ | -------------- | | Single file size | 10 MB | All scans | | Stdin size | 10 MB | stdin only | | Batch file count | 500 | Directory/glob | | Batch total bytes | 100 MB | Directory/glob | | Batch total findings | 10,000 | Directory/glob | | Directory depth | 50 | Directory walk |

A finding looks like:

## High

### Pre-checked consent box

- **Rule:** `consent/checked-checkbox`
- **Severity:** high **Confidence:** high
- **What:** A checkbox is checked by default: "Email me product offers and promotions".
- **Why it matters:** Pre-checked boxes opt users in without an active, informed choice.
- **Recommendation:** Leave consent and marketing checkboxes unchecked so users opt in deliberately.

Configuration

Place a fairux.config.json near your project — it is auto-discovered upward from the scan target, up to the repo root. (Executable fairux.config.{ts,mjs,js,cjs} is trusted code and is only loaded with an explicit --config.)

{
  "configVersion": 1,
  "rules": {
    "consent/missing-reject-option": false,
    "consent/checked-checkbox": { "severity": "low" }
  }
}
  • rules[id]: false (or { "enabled": false }) silences a rule.
  • rules[id].severity re-grades a rule. Severity overrides do not move finding fingerprints, so CI baselines stay stable.
  • "includeExperimental": true enables heuristic rules.

When scanning untrusted code (e.g. a fork PR in CI), pass --ignore-config so a config the repo ships can't disable rules or lower severities.

The CLI is not a sandbox for untrusted file trees. It reads local files, walks directories and globs, and may execute trusted config only when explicitly requested. Products that inspect remote HTML, such as future URL checkers, should pass bounded HTML strings to the SDK from an isolated process or worker instead of unpacking remote content into arbitrary paths and scanning them with the CLI. Do not dynamically download and execute third-party RulePacks.

CI (SARIF → GitHub code scanning)

fairux scan ./dist/index.html --format sarif --ignore-config > fairux.sarif

Upload fairux.sarif with github/codeql-action/upload-sarif. Severity maps high → error, medium → warning, low | info → note.

A finding with no source line — a Figma node — is reported at the file that was scanned, with no line number; code scanning displays it at line 1. A result with no file at all is rejected by code scanning, which rejects the whole upload rather than the one result, so a live-DOM report is not uploadable. See the GitHub Actions guide.

License

Apache-2.0 (see NOTICE). FairUX is open core; this CLI is open source.

Source, issues, and full docs: https://github.com/toshtag/fairux-linter.