The tools
Formatting and linting
Both read the same tree the compiler does. The formatter is a Rust printer over Meta's Flow parser; the linter runs uf's own rules alongside Flow's built-in ones, in one pass, with one report.
uf fmt
uf fmtFormats every Flow, JavaScript, JSON, CSS and TypeScript file in the project —
Markdown is not among them. --check exits non-zero instead of writing, which
is what CI should run.
Flow files go through the official Flow parser and a printer written for it, so
component, hook, renders, match and enums are formatted as first-class
syntax rather than survived. The output follows Prettier's conventions — double quotes, trailing commas,
the same line-breaking decisions — because the point of a formatter is that
nobody argues about it, and Prettier already won that argument. The default
width is 100 rather than Prettier's 80; fmt.lineWidth changes it.
That claim is checked rather than asserted: every fixture in uf_fmt's suite
is compared byte for byte against Prettier's own output for the same input.
GraphQL in a tagged template
uf fmt formats the GraphQL inside a template tagged graphql, gql or
graphql.experimental, a template passed to graphql(…), and one written
behind a /* GraphQL */ comment — and re-indents it to the code around it,
which is the part you notice:
const fragment = graphql`
fragment UserFragment on User {
username
}
`;
becomes
const fragment = graphql`
fragment UserFragment on User {
username
}
`;
A template with ${…} in it works when each run of text between the holes is
a complete document on its own — a trailing ${Fragment} is the usual case —
and is left alone when it is not. That is Prettier's rule, not uf's.
A template uf will not reprint, it does not touch. It comes back exactly
as you wrote it rather than approximately right, and there are four reasons
for that to happen: the document holds a # comment, it holds a string with
a control character in it, it is not GraphQL, or it nests more than 128 deep.
The comment case is the one you are likely to meet — uf does not yet
reproduce where Prettier puts a GraphQL comment, and moving a comment to the
wrong line is worse than leaving the query alone.
GraphQL is the only embedded language uf reads. css, html, sql and
markdown templates go out exactly as written, where Prettier would format
them.
Files uf has no opinion about — JSON, CSS, TypeScript — are handed to Biome,
which is also Rust and also fast. uf runs the binary your project has rather
than shipping one, so Biome is a formatter you install; fmt.nonFlow.formatter
names a different one, or "none".
When it is not installed, uf fmt says so and names the files it left alone,
and that is a warning rather than a failure: uf picked Biome, you did not, and
the exit code answers for the files uf can format itself. Write
fmt.nonFlow.formatter in uf.config.js and the answer changes — a formatter
you asked for by name and do not have fails the run, which is what a CI job
that checks the JSON as well wants.
Measured on a 133 kB Flow and JSX file:
| Time | Throughput | |
|---|---|---|
| Parse alone | 10.4 ms | 12.9 MiB/s |
| Parse and format | 17.9 ms | 7.4 MiB/s |
--check, already formatted | 18.0 ms | 7.5 MiB/s |
The parser is 58% of that. The target is 10 MB/s end to end, and the printer is not what is standing between here and there.
uf lint
uf lintRuns uf's rules and Flow's own lints together and prints one list. --json
emits the report machine-readably.
Flow's built-in lints — sketchy-null, unclear-type, deprecated-type,
unnecessary-optional-chain and the rest — are configured in uf.config.js
alongside uf's rules rather than in a separate .flowconfig section, because a
project should have one place where "this is an error here" is written down.
Fixing what it finds
uf lint --fixApplies every fix uf is willing to make without asking, writes the files, and reports how many it applied and what it left. It is the same catalogue the language server offers in an editor, reached from a terminal, a git hook or CI rather than only from a lightbulb menu.
Two tiers, and the difference is real. --fix applies only the fixes where the
program afterwards means what it meant before — bool becomes boolean, and
nothing has been decided on your behalf. --fix-unsafe also applies the ones
whose correctness rests on something the rule could not check: export let count = 0 becomes export const count = 0, which still parses and still
formats, and throws at run time if anything reassigns the binding. Read that
diff before you commit it.
Most of uf's 53 rules have no fix at all, which is deliberate. flow/unclear-type
could turn any into mixed, an opaque type, or a generated router type
depending on what you meant; three answers is none, and guessing at one is
worse than leaving the finding where you can see it.
What is written parses, and running it twice is running it once. Every file
that passed uf fmt --check before the fix passes it after: a file the
formatter was content with is reprinted around the edit, because four bytes is
enough to push a line past the print width. A file that was already failing
that check keeps failing it — the edit is written as the edit left it, and
laying out a file nobody asked to have laid out is uf fmt's job rather than
this one's. The CLI reference spells out each of those and
what the exit status means.
uf fmt, uf lint and uf check all take paths, the way uf test does:
uf lint packages/uiAnything whose path contains one of the arguments is read and nothing else, so
uf fmt dialog.js is a way to format the file in front of you. A pattern that
matches nothing is an error rather than a clean run.
uf check
uf checkRuns Flow's type checker over the project and reports what it says. uf does not type-check anything itself: Flow is the type system, and reimplementing it would only create a second opinion about your code, which is the thing this whole toolchain exists to avoid.
uf types across modules. An import of a relative path is the type that file
exports, and an import of a package is resolved through the package.json that
publishes it — exports map, conditions and all, using Flow's own resolver
rather than a second opinion about what a specifier means:
// money.js
export function dollars(cents: number): Money { ... }
// index.js
import { dollars } from "./money.js";
const wrong: Money = dollars("twelve");
// ^^^^^^^^ "twelve" is incompatible with number
What it still cannot type is a module that is not in the batch — a package that
is not part of this project, a file the scan did not walk. Those are any, and
the command names them at the end of its report rather than leaving you to work
it out.
In CI
uf fmt --check && uf lint && uf check && uf testFour commands, no configuration, and each one exits non-zero when it should.
Better still, name them once. uf.config.js takes tasks, uf run ci runs
them, and the pipeline calls the same thing:
export default defineConfig({
tasks: {
"fmt:check": "uf fmt --check",
lint: "uf lint",
check: "uf check",
test: "uf test",
ci: { command: "echo ok", dependsOn: ["fmt:check", "lint", "check", "test"] },
},
});
A check that is in the pipeline and not in uf.config.js is a check a
contributor cannot run before pushing. uf's own repository is set up this way —
its CI builds uf once and every job runs uf run <task>.