Skip to content

Configuration

One file holds the rules for your project. This page lists every key that file accepts.

The file is TypeScript. It is found by walking upward through the directories, looking for trueup.config.ts, or the same name ending in .js or .mjs. Whichever directory holds it is the project root.

You do not have to write the first one yourself. This reads the shape of your tree and writes a config to match:

Terminal window
npx trueup init

Because the file is TypeScript, it can read the environment. That is useful for anything that should behave differently in CI.

trueup.config.ts
import { defineConfig, oxlintRunner } from "@vismek-solutions/trueup";
export default defineConfig({
zones: [
{ name: "spec", patterns: ["**/*.test.ts"], role: "tests" },
{ name: "components", patterns: ["src/components/**"] },
{ name: "hooks", patterns: ["src/hooks/**"] },
{ name: "api", patterns: ["src/api/**"] },
{ name: "domain", patterns: ["src/domain/**"] },
{ name: "app", patterns: ["src/**"], role: "wiring" },
],
boundaries: [
{ from: "components", allow: ["hooks", "api"] },
{ from: "hooks", allow: ["api"] },
{ from: "api", allow: [] },
],
seams: [{ generic: "components", domain: ["domain"] }],
colocation: true,
maxFilesPerDirectory: 12,
runners: [oxlintRunner({ paths: ["src"] })],
});

Only the zones are required. Everything under them is optional, and a config with zones alone still runs nine claims.

key type default effect
zones ZoneDefinition[] required unless members is used The names every other rule is written in.
boundaries BoundaryRule[] none Which zones each zone may reach.
seams SeamRule[] none Domain leaks with no import.
isolate IsolationRule[] none Sibling directories kept apart.
rules Rule[] none Rules you write yourself.
runners Runner[] none Delegated tools.
members string[] none Globs naming monorepo members.
include string[] the whole project Directories to scan. Narrowing this removes files from every check.
extensions string[] .ts .tsx .mts .cts .js .jsx .mjs .cjs Replaces that list rather than adding to it.
assets string[] none Globs naming files a rule may read but nothing parses, such as stylesheets.
externals string[] none Specifiers your build tool supplies.
ignoreDirectories string[] .git node_modules dist build out coverage .next .turbo .trueup-cache Directory basenames never walked into. Replaces the list, so a list of your own should name .trueup-cache too.
protect string[] or { paths?, decision? } every rulebook and the baseline, always Extra paths an agent may not edit.
deferAtWrite string[] none Claims the guard lets through at write time. The full check still reports them.
maxFilesPerDirectory number off Directory size limit.
duplication number off Shortest declaration worth reporting, in characters.
colocation boolean false Turns on the placement claims.
readerships boolean false Turns on the check for a file serving two audiences.
testInternals boolean false Turns on the check for a test reaching an internal.
text TextSettings off Claims over the markdown in your project. Its files key also joins that markdown to assets.
reviewable ReviewBudget off How large a change may grow before nobody can review it.
command string trueup The command named in printed guidance. Set it to pnpm lint:arch and every message says that.

A zone is a name you give to a group of files, chosen by where the files sit.

{ name: string, patterns: string[], role?: "wiring" | "tests" | "api", shared?: boolean }

A file belongs to the first zone whose patterns match it, in the order you declared them. What each role changes.

A shared zone is one written to serve the layer above it, and saying so changes what counts as a second customer in the colocation check.

A boundary is a note saying which zones a zone is allowed to reach.

{ from: string, allow: string[], anchor?: "declaring-file" | "imported-module", ignoreTypeOnly?: boolean }

The allow list works as an allowlist, so anything you leave off it is refused. A zone always reaches itself, and a zone with no rule of its own is unrestricted. Write several rules for one zone and a reach has to satisfy all of them.

The anchor defaults to the declaring file, which is the file where a thing is actually written, after following every re-export. The rule attaches there rather than to the module you imported from. The blunter alternative.

A seam is the line between reusable code and code that knows your business.

{ generic: string, domain: string[], allow?: string[], minLiteralLength?: number }

The minimum literal length defaults to four.

Isolation keeps a group of sibling directories from reaching into each other, without you naming any of them.

{ siblings: string, except?: (string | { shared: string, allow: string[] })[], wiring?: string[] }

The siblings pattern is a glob, and each star in it names a group of directories to keep apart. The list of exceptions holds those names, meaning the directory names the star matched rather than whole paths. So this pair exempts src/routes/_shared:

{ siblings: "src/routes/*", except: ["_shared"] }

An exception written as an object is shared the same way, and its allow list says which of the other shared directories it may reach. A bare name reaches all of them. How the order is read.

The wiring list names the files allowed to sit in the parent directory rather than inside one of the groups. Leave it out and that question is never asked. What it reports.

The text settings name the markdown to read and the rules to hold it to.

{ files: string[], comments?, strings?, marks?, words?, maxSentenceWords?, maxParagraphSentences?, maxInlineCodeWords?, links?, maxSectionWordsWithoutExample? }

Only files is required, and on its own it makes no claim. Every key but comments turns on one claim, so a key you leave out is a check that never runs. What each one reports.

Setting comments to true widens what those claims read, rather than adding one of its own:

{ files: ["docs/**/*.md"], comments: true, marks: ["—"] }

Every claim you switched on then also reads the comments in the code this tool already parses. How a comment is read.

The strings key names the modules whose text a person reads, an error message or a piece of guidance:

{ files: ["docs/**/*.md"], strings: ["src/messages/**"], marks: ["—"] }

Those files get one claim of their own as well, which asks that a passage is written as one string. Why that one exists.

A word in the list carries the plainer word that replaces it:

words: [{ word: "leverage", instead: "use" }]

The files globs also join that markdown to assets, so a rule you write yourself reads the same pages.

A review budget is the largest change you believe a person can actually read.

{ additions: number, deletions: number, severity?, nearing?, base?, except? }

The two required numbers are the most added lines and the most removed lines a single change may carry. Going over prints a warning unless severity is set to error. What the other options do.

A member is one package inside a workspace that keeps its own rules.

packages/lib/trueup.config.ts
import { defineMember } from "@vismek-solutions/trueup";
export default defineMember({
zones, allow?, boundaries?, seams?, isolate?, rules?, maxFilesPerDirectory?, doorsFromExports?
})

The doorsFromExports option builds the member’s api zone from the exports field of its package.json, so you do not declare one yourself. When that works and when it does not.

A rule written inside a member sees a project narrowed to that member: its own files, and its own zone names with no package prefix. Its findings are reported under the member name, then the rule name. What that means for a rule spanning packages.

Runners hand a job to another tool you already use, and they stay at the root. So do externals, protect, command and duplication. All of those are properties of the repository rather than of a package.

A member may declare its own isolation rules, and the patterns in them are read against the member’s own directory.

Protection names paths an agent may not edit, an agent being a coding assistant that writes code in your project.

protect: ["CLAUDE.md"]
protect: { paths: ["CLAUDE.md"], decision: "deny" }

The root config, every member config and the baseline are covered with no configuration at all. The baseline is the recorded list of problems you have agreed to live with for now.

The decision is ask, deny or allow. It defaults to a permission prompt, and is turned into a refusal automatically wherever no person would see that prompt.

The rulebook is the config file holding the rules, whether at the root or inside one package. Allow hands that rulebook to the agent in every mode, and every run then prints a notice line saying so. What that gives up.

npx trueup init write a starting config, and one per workspace package
npx trueup the full report
npx trueup --dots one character per claim
npx trueup --next one problem, then stop
npx trueup --next=<claim> one problem, from a claim that text names, or that a config key of that name turns on
npx trueup --json machine-readable
npx trueup --gitlab GitLab Code Quality
npx trueup --github SARIF for GitHub code scanning
npx trueup --update-baseline record current violations
npx trueup --config=<path> use a specific config
npx trueup --help this list, printed by the tool itself
npx trueup explain <path>... what each file may reach, once for files that share an answer
npx trueup explain <path>#<name> who reads one name, and what moving it would cost
npx trueup explain --needs=<paths> where a new file may live
npx trueup explain --read-by=<paths> narrows that answer to what may be reached from its readers
npx trueup explain --ungoverned zone pairs no boundary rule refuses
npx trueup activate every zone, boundary and setting in force
npx trueup agent-instructions a block to append to your agent’s memory file
npx trueup docs every guide that ships with this version
npx trueup docs <topic> one of those guides, printed in full
npx trueup text the prose commands, with what each one is for
npx trueup text calibrate what your own prose measures, so a text limit comes from it
npx trueup guard read a hook payload on stdin

An argument that is not on this list is an error rather than something to ignore quietly.