projectfmt
v1.1.0
Published
Format generated code the way its destination project expects
Maintainers
Readme
projectfmt
Format generated code the way its destination project expects.
Copyright (c) 2015 by Gadi Cohen. MIT Licensed.
Quick Start
projectfmt is a project-aware formatter broker for code generators and
libraries. Give it source text, the path where that source is intended to live,
and either an explicit project boundary or an absolute path from which one can
be inferred. It discovers and invokes the formatter already chosen by that
project—without writing a temporary file.
Deno / JSR import:
import { formatSource } from "@gadicc/projectfmt";Node / npm import:
import { formatSource } from "projectfmt";The call is the same in either runtime:
const formatted = await formatSource(generatedSource, {
filePath: "src/generated/schema.ts",
projectRoot,
});The intended path is not cosmetic. It controls nested configuration discovery, language/parser selection, ignores, plugins, and monorepo project selection.
Why projectfmt?
projectfmt does not own formatting rules. Its job is discovery, resolution,
invocation, and consistent failure behavior for virtual files.
The closest prior art is
Formatly, which also detects a
project's formatter. Its current public workflow accepts file/glob patterns and
runs formatter commands; projectfmt is source-string and virtual-file first.
Formatly's open discussions about an
importable formatting API,
parent-directory discovery,
and
project package-manager execution
show how closely related the problem spaces are.
| Tool | Primary center of gravity | Difference from projectfmt |
| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Formatly | Formatter detection for file/glob and subprocess workflows | Closest analogue; projectfmt starts with an in-memory string and an intended virtual path |
| treefmt and Trunk | Project-wide formatter orchestration and CLI workflows | Operate at repository/workspace scale and use their own orchestration configuration |
| Unibeautify | Universal beautifier ecosystem | Configuration-centric formatter abstraction rather than destination-project resolution for virtual files |
| Spotless | Composable formatting steps integrated with build systems | Especially strong in JVM/Gradle/Maven workflows |
| Prettier API and dprint plugins | Programmatic APIs for one formatter ecosystem | They format directly; they do not select whichever formatter an arbitrary destination project chose |
This comparison was reviewed on 22 July 2026. It is deliberately about product shape, not popularity. Alternatives exist and may be better for repository-wide formatting.
The original formatter integration was used by
valibot-serialize from 17
September 2025. It became a standalone package because project-aware formatting
is useful to code generators well beyond that project. The extraction also
corrects several limitations of that first integration; see
History and intentional differences.
Install
Deno / JSR:
deno add jsr:@gadicc/projectfmtNode / npm:
npm install projectfmtThe destination project is expected to already provide its formatter:
- Prettier: a project-local
prettierpackage; - Biome: a project-local
@biomejs/biomepackage and its platform binary; - Deno fmt: a
denoexecutable onPATH.
All formatter integrations are optional. projectfmt never installs or
downloads one, invokes a package manager, or contacts the network.
Agent skill
Install the bundled projectfmt skill to help coding agents integrate the
library correctly:
npx skills add https://github.com/gadicc/projectfmt --skill projectfmtAPI
formatSource(source, optionsOrAbsolutePath)
Returns Promise<string>.
const output = await formatSource("export const answer=42", {
filePath: "packages/api/src/generated/answer.ts",
projectRoot: "/workspace",
formatter: "auto", // default; also "prettier", "biome", "deno", or "none"
strict: true,
});filePath may be relative to an explicit projectRoot or absolute. Relative
paths without projectRoot are rejected rather than interpreted against the
process's current working directory. An absolute path may omit projectRoot, in
which case projectfmt infers it. Supplying projectRoot for an absolute path is
still supported when the caller needs an exact, reproducible boundary. Paths
that escape an explicit or inferred project boundary are rejected, as is an
intended path equal to projectRoot: the intended path must name a descendant
file. strict defaults to false.
The equivalent absolute-path shorthand uses all default options:
await formatSource(
source,
"/workspace/packages/api/src/generated/answer.ts",
);The string shorthand must be absolute. Use the options object to supply a relative path, explicit boundary, formatter selection, strict mode, custom adapters, or processing constraints.
For automatic inference, projectfmt walks upward from the intended file's
directory. The nearest VCS root (.git or .hg) or workspace boundary wins.
Recognized workspaces include pnpm, Lerna, Rush, package.json#workspaces, and
Deno workspaces. If none exists, the nearest package, Deno, JSR, lockfile, or
supported formatter configuration is used. Inference errors instead of using the
filesystem root when no defensible project marker exists.
Inferred roots are cached for traversed directories at or below the detected
root, so later files in the same package or workspace reuse the result. Call
clearProjectRootCache() if a long-running process changes project topology,
such as creating or removing a workspace or VCS boundary.
Biome automatically runs the equivalent of
biome check --write --stdin-file-path=.... This applies the repository's
formatting, safe lint fixes, and enabled assist actions such as import
organization. It never passes --unsafe. Set formatOnly: true to opt out of
lint fixes and assist actions and call the equivalent of biome format instead:
await formatSource(source, {
filePath,
projectRoot,
formatOnly: true,
});formatOnly is an adapter-independent processing constraint. Prettier and Deno
already perform formatting only, so Biome is the only built-in adapter whose
behavior currently changes. Custom adapters receive the constraint as
context.formatOnly and should avoid lint fixes, assists, or other cleanup when
it is true.
formatSourceWithResult(source, optionsOrAbsolutePath)
Returns the source plus resolution diagnostics:
const result = await formatSourceWithResult(source, options);
console.log(result.changed, result.ignored);
console.log(result.resolution.formatter);
console.log(result.resolution.evidence);It accepts the same absolute-path string shorthand as formatSource.
The result contains:
source,changed, andignored;- the selected formatter and implementation/version availability;
- absolute normalized project and file paths;
- the configuration root, all discovery evidence, ranked candidates, ambiguity state, and a human-readable reason.
resolveFormatter(optionsOrAbsolutePath)
Performs the same discovery and availability probing without formatting:
const resolution = await resolveFormatter({
filePath: "src/generated/schema.ts",
projectRoot,
});
switch (resolution.status) {
case "selected":
console.log(resolution.formatter, resolution.availability?.version);
break;
case "not-configured":
case "unavailable":
case "disabled":
console.log(resolution.reason);
}For default resolution options, pass the absolute intended path directly:
const resolution = await resolveFormatter(absoluteOutputPath);clearProjectRootCache()
Clears roots cached by automatic inference. Explicit projectRoot calls do not
use this cache. Each separate public call performs fresh formatter discovery and
availability probing; formatter evidence, configuration, and availability are
not cached across calls.
Within one formatting call, projectfmt keeps the selected adapter map and
snapshots the successful probe result through formatting. Prettier and Biome
therefore use the package implementation path selected by that call instead of
resolving it a second time. Repointing or removing a package after probing does
not select a replacement for the current call; failure to load or invoke the
snapshotted path is reported as a FormatterExecutionError. A later public call
discovers and probes again.
Custom adapters
Additional adapters use the same discovery/probe/format lifecycle as built-ins:
Deno / JSR import:
import type { FormatterAdapter } from "@gadicc/projectfmt";Node / npm import:
import type { FormatterAdapter } from "projectfmt";The adapter definition is runtime-neutral:
const custom: FormatterAdapter = {
name: "companyfmt",
priority: 100,
async discover(directory) {
// Return evidence for this directory, or [].
return [];
},
async probe() {
return { available: true, implementation: "in-process" };
},
async format(source) {
return { source: companyFormat(source) };
},
};
await formatSource(source, {
filePath,
projectRoot,
formatter: "companyfmt",
adapters: [custom],
});Adapter names must be non-empty and unique, priorities must be finite, and all
three lifecycle methods are required at runtime. Discovery returns an array of
evidence whose formatter matches the adapter, kind is supported, path is
absolute and within projectRoot, description is non-empty, and strength is
finite. A nested evidence path is valid, but ranking and configRoot use the
directory passed to discover, so evidence cannot spoof its search distance.
probe must return an object with a boolean available and optional string
details. format must return an object with string source, optional boolean
ignored, and optional string stderr. projectfmt validates these results,
passes the same context (including formatOnly) to probe and format, and wraps
probe or format failures in structured errors while preserving their causes and
available stderr. Because automatic root inference cannot know custom project
markers, callers using custom adapters should normally provide projectRoot
explicitly.
Resolution rules
For formatter: "auto", discovery walks from the intended file's directory up
to and including projectRoot. It never searches above that boundary.
- The nearest directory containing formatter evidence wins. This establishes the nested-project/monorepo boundary.
- Within that directory, explicit configuration (
biome.json, a Prettier config or package metadata, ordeno.json(c)#fmt) beats a formatting script, which beats dependency-only evidence. Discovery follows the native filename sets, including dotted Biome JSON/JSONC, Prettier TOML, and truthypackage.yaml#prettierconfiguration. - Equal-strength candidates use the stable precedence
biome, thendeno, thenprettier. This is an explicit tie-breaker, not adapter import order.strict: truerejects the ambiguity instead. - Availability never changes selection. If the chosen formatter is missing,
formatSourceerrors instead of silently falling through to a different formatter.
Explicit selection bypasses ranking but still uses nearest applicable config and
project-local implementation resolution. formatter: "none" returns the input
unchanged and performs no formatter probe.
With no configured formatter, non-strict auto mode returns the input unchanged
and reports not-configured; strict mode errors. Ignored files are returned
unchanged in either mode.
Formatter behavior
| Formatter | Implementation | Project/path behavior | Deno | Node |
| --------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | --------------------------- |
| Prettier | Project-local JS API | Passes filepath, loads the exact discovered config and project plugins, and checks bounded .prettierignore/.gitignore paths. EditorConfig lookup is disabled. | Yes | Yes |
| Biome | Project-local platform CLI binary | Runs check --write --stdin-file-path by default with the exact discovered config, safe lint rules, assists, and file/tool includes. formatOnly selects format. Configless calls use a temporary empty config. | Yes | Yes |
| Deno fmt | deno fmt subprocess | Passes the exact discovered config or --no-config, infers --ext, honors fmt.include/fmt.exclude, and uses the nearest existing destination directory as cwd. EditorConfig lookup is disabled. | Yes | Yes, when Deno is installed |
No adapter creates a temporary source or destination file. Configless Biome calls create and unconditionally remove a temporary empty configuration. Paths containing spaces are passed as process arguments rather than shell strings.
The Deno adapter evaluates top-level and fmt exclusions in order, including
negated re-inclusions. Markdown suffix aliases such as .markdown map to the
canonical md media type. Other media types and any required unstable flags are
derived from the selected Deno 2 executable's fmt --help output on each call,
so version-gated formats are used only when that runtime advertises them.
Errors and diagnostics
FormatterResolutionError covers invalid options, strict ambiguity, missing
configuration in strict mode, and unavailable selected implementations.
FormatterExecutionError wraps parsing/configuration/process failures.
Both extend ProjectfmtError and preserve useful fields such as code,
formatter, filePath, projectRoot, evidence, resolution, stderr, and
the original cause. Formatter failures are never converted to unchanged
output.
Security and trust model
Use projectfmt only with projects you trust.
Prettier configuration and plugins are JavaScript modules and can execute code
when loaded. The Biome adapter executes the project's pinned native binary, and
the Deno adapter executes the deno binary on PATH. This is the same broad
trust boundary as running those project formatters directly. projectRoot is a
discovery and module-resolution boundary, not a sandbox. Provide it explicitly
when automatic inference would grant a broader boundary than the caller intends.
Native formatter configuration search is also bounded: projectfmt passes only
the exact configuration discovered within projectRoot, or explicitly disables
native auto-discovery. EditorConfig lookup is disabled for all built-in adapters
because bounded EditorConfig inheritance is not yet implemented.
The library itself performs no network access or installation. Deno callers must grant the filesystem, environment, and subprocess permissions required to inspect the destination project and run its formatter.
History and intentional differences
Compared with the formatter subsystem extracted from valibot-serialize:
- auto mode selects from project evidence instead of whichever adapter imports first;
- Prettier receives the intended filepath, resolved configuration, ignores, and plugins;
- Deno fmt receives the real extension, project cwd, and config rather than always parsing stdin as TypeScript;
- Biome consistently applies configured formatting, safe lint fixes, and assist actions in both runtimes, with a formatting-only opt-out;
- Node and Deno share one resolution/error contract;
- diagnostics, strict mode, custom adapters, ignored virtual files, and project boundary validation are public behavior.
Roadmap
Likely future adapters include dprint and oxfmt. A whole-repository formatting CLI is not the primary goal.
Development
See CONTRIBUTING.md and docs/RELEASING.md. The main gates are:
deno task pre-commit
deno task coverage
deno task test:packagesMIT © Gadi Cohen.
