VelarOS Arch Guard is an extensible architecture policy engine for JavaScript and TypeScript projects. It turns repository-specific boundaries into executable checks without coupling the engine to a framework, build tool, or application.
It was extracted from the architecture enforcement path used by VelarOS Desktop. The public project owns the reusable engine and plugin contract; VelarOS-specific package names, paths, baselines, and product policy remain in a private downstream plugin.
The engine provides:
- a typed check and plugin API;
- deterministic file collection and shared source/AST caches;
- rule severity and option overrides;
- diff, staged-file, tag, and path-scoped execution;
- baselines for adopting checks in existing repositories;
- opt-in, staged autofix with atomic writes;
- stylish, JSON, SARIF, and GitHub Actions reporters.
Project policy does not belong in this package. Package boundaries, forbidden imports, naming rules, framework conventions, and product architecture should live in a project plugin.
npm install --save-dev @velaros-ai/arch-guardInstalling a tagged Git release also works — the repository versions dist/, so
package managers can consume Error-Zhang/VelarOS-Arch-Guard#v0.1.1 without a
build step. The npm registry package is the recommended source.
Node.js 20 or newer is required. The CLI and library run on Node; Bun is not required.
Create a starter configuration:
npx arch-guard initThat produces arch-guard.config.mjs with the generic cross-file duplication check enabled:
import { defineConfig } from '@velaros-ai/arch-guard'
import { crossFileDuplication } from '@velaros-ai/arch-guard/checks'
export default defineConfig({
files: {
roots: ['src'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
excludePatterns: ['**/node_modules/**', '**/dist/**', '**/coverage/**'],
},
checks: [crossFileDuplication],
})Validate the configuration, then run it:
npx arch-guard doctor
npx arch-guard runAdd stable scripts to the consuming project instead of coupling CI to a package-manager-specific command:
{
"scripts": {
"arch:check": "arch-guard run",
"arch:changed": "arch-guard run --changed",
"arch:verify": "arch-guard verify"
}
}arch-guard init
arch-guard doctor
arch-guard run [files...]
arch-guard verify
arch-guard list [--by-tag | --json | --ids-only]
arch-guard explain <check-id>
arch-guard baseline update|prune|migrate|check
Only baseline update, baseline prune and baseline migrate write the baseline. run and
verify are strictly read-only.
Common scoping and output options:
arch-guard run src/main.ts packages/core/src
arch-guard run --changed --base main
arch-guard run --staged
arch-guard run --only duplication/cross-file
arch-guard run --tag architecture
arch-guard run --format stylish --format sarif --out reports/arch-guard.sarifUse arch-guard help for the complete option list.
The plugin entrypoint is the supported authoring surface. Checks receive a repository context and a structured report builder:
import { defineCheck, toRelativePosix } from '@velaros-ai/arch-guard/plugin'
export const noDirectDatabaseImports = defineCheck({
id: 'example/no-direct-database-imports',
title: 'Database access boundary',
description: 'Application modules access the database through the data package.',
verifies: ['Source files outside packages/data do not import the database client.'],
tags: ['architecture', 'imports'],
appliesTo: { include: ['src/**/*.{js,ts,tsx}'] },
run({ context, report }) {
const section = report.section('Direct database imports')
for (const file of context.files.collect(['src'], new Set(['.js', '.ts', '.tsx']))) {
const source = context.cache.readSource(file)
if (!source.includes("from 'database-client'")) continue
section.add({
ruleId: 'direct-import',
file: toRelativePosix(context.rootDir, file),
message: 'Import the data package instead of database-client.',
})
}
},
})Register a check directly in a config, or publish a plugin:
import { definePlugin } from '@velaros-ai/arch-guard/plugin'
import { noDirectDatabaseImports } from './checks/no-direct-database-imports.mjs'
export default function exampleArchitecture() {
return definePlugin({
name: 'example-architecture',
version: '1.0.0',
checks: [noDirectDatabaseImports],
})
}The package exports five deliberate surfaces:
@velaros-ai/arch-guard— configuration, runner, and result models;@velaros-ai/arch-guard/plugin— check/plugin contracts and supported analysis helpers;@velaros-ai/arch-guard/checks— generic built-in checks;@velaros-ai/arch-guard/checks/code-style— the optional code-style ruleset (see below);@velaros-ai/arch-guard/reporters— reporter factories.
Internal source paths are not public API.
@velaros-ai/arch-guard/checks/code-style ships an opt-in ruleset of language-level
writing rules — absence-value expression, single-source type guards, early return over deep
nesting, table-driven branching, identity forwarders, redundant strict comparisons, swallowed
errors, React conditional rendering, team-language comments. Nothing here is enabled by
default: you register the checks explicitly.
These rules are opinionated. They judge TypeScript and JavaScript constructs only — no product
architecture — but several of them recommend a helper vocabulary (isPresent,
isPlainObject, optionalWhen, isEmpty, stringifyPretty, Nullable<T> …) shipped by
@velaros-ai/core. Adopt them if that vocabulary — or your own equivalent — exists in your
codebase; skip them otherwise. Repository coordinates are never hard-coded: every rule reads
its scanning scope from check options.
import { defineConfig, definePlugin } from '@velaros-ai/arch-guard'
import { codeStyleChecks, createCodeStyleDefaults } from '@velaros-ai/arch-guard/checks/code-style'
const codeStyle = definePlugin({
name: 'my-code-style',
checks: codeStyleChecks,
defaults: createCodeStyleDefaults({
scope: {
scanRoots: ['packages'],
runtimeRoots: ['packages/'], // most rules only scan runtime source
frontendRoots: ['packages/ui/src/'], // JSX rules only scan the frontend
skipPatterns: ['^packages/core/src/typeGuards\\.ts$'],
},
perCheck: {
'code-style/forbid-raw-timers': { allowFiles: ['packages/core/src/utils/TimerScope.ts'] },
},
}),
})
export default defineConfig({ plugins: [codeStyle], files: { roots: ['packages'] } })Every scope field is optional; an omitted field means "do not filter". Adopt the ruleset on an
existing repository the same way as any other check — freeze the current violations with
arch-guard baseline update, then let the ratchet fail new ones.
code-style/require-chinese-comments enforces a team language for explanatory comments. It
exempts JSDoc blocks attached to exported declarations (public API documentation is written for
external consumers); set exemptExportedJsDoc: false to enforce everywhere.
Existing repositories can record current violations without disabling a check:
arch-guard baseline update
arch-guard runThe default snapshot is .arch-guard/baseline.json. Future runs report violations that are not
covered by the snapshot. Commit the baseline when it represents an intentional migration boundary.
| command | effect |
|---|---|
baseline update |
re-freeze violations. Honours --file / --changed / --staged / --only / --skip / --tag; a scoped update merges — entries outside the scope are left byte-for-byte alone, and violations outside the scope are not frozen either |
baseline prune |
retire entries that no longer match anything, and shrink occurrence counts down to what this run actually found. Refuses to run under a scope, and refuses to empty a baseline that this run matched nothing of (--force overrides) |
baseline migrate |
backfill contentDigest and occurrence counts on entries written by older versions, and report exactly how many waivers the migration takes away |
Everything else is read-only. Concretely:
run,verify,list,explain,doctornever touch the file. Before 0.3.0runpruned stale entries by default, which made "read the violations" a mutating operation: onearch-guard runcould turn a red tree green, and agit checkoutof the baseline could turn it red again.--no-prune-stale-baselineis still accepted and now does nothing.- A bare
arch-guard baselineprints usage and exits 2. There is no default action — the word you type to discover the sub-commands must not rewrite the ratchet. --dry-runprints the full diff and writes nothing. Unknown long options are rejected with exit 2 rather than ignored, so a typo in--dry-runcannot quietly turn into a write.- A scope that resolves to zero scannable files (
--changedwhen the diff is all.md,--stagedwith nothing TypeScript staged) is a no-op, not a whole-repository re-freeze. Read commands still run — checks that ignore the file scan surface report repository-wide, and skipping them would weaken the gate — but they say that no file was scanned, so "scanned nothing" never looks like "found nothing".verify --jsoncarries it asemptyFileScope. - Any write that would produce byte-identical content is skipped, so the file's mtime only moves when the ratchet actually moved.
Prefer a scoped update. An unscoped baseline update freezes everything currently reported,
including debt written by somebody else since the last freeze; it prints every newly frozen entry,
and every entry whose occurrence count grew, so that is at least visible.
--skip is not a scope. It names what to leave out, which leaves every other check covering
the whole repository, so baseline update --skip X is a full re-freeze. It is still honoured as a
merge directive — entries belonging to X are preserved byte-for-byte — but the summary reports it
as whole repository, minus --skip X and keeps the "you are freezing debt you did not write"
warning. Narrow with --file / --changed / --staged / --only / --tag.
An entry is keyed by checkId + ruleId + fingerprint, where the fingerprint is a hash of whatever
the check passes as fingerprintInput. Checks commonly build that from file::line::kind, which
means two different violations on the same line of the same file under the same rule hash to the
same value — and the ratchet then waives a violation it has never seen. Identity therefore has two
more parts:
contentDigest— a hash of the violation message with itspath:line:prefix removed. A waiver requires the fingerprint and the digest to match.count— how many occurrences the entry waives (absent = 1). Matching by key alone is a map lookup with no arithmetic, so one frozen record would waive an unbounded number of identical violations: freeze onetypeof x === 'string', then paste three copies onto that line, and the gate stays green. Digests do not help there — three byte-identical expressions have the same digest. Only counting does.
How that plays with existing baselines:
- Entries without a digest (written by ≤ 0.2.x) keep the old fingerprint-only, uncounted behaviour, so an existing baseline needs no migration to keep working, byte-for-byte.
baseline updatewrites digests and counts for everything it freezes.baseline migratebackfills them. The content comes from the entry's own frozen message, not from today's code, so a frozen entry that no longer matches today's code is reported and starts failing the gate instead of silently covering the newcomer. Every entry gets a digest, including the ones this run cannot match: their message is right there in the file, the digest is computable offline, and leaving them bare would keep handing that(file, line, rule)slot a blank cheque for any future violation.migrate --dry-runreports the exact number of currently-waived violations that will stop being waived, by running the whole violation set through both the old and the new baseline.
Known gaps, in the order they hurt:
- The fingerprint still carries the line number, so moving code (or adding an import above it)
makes a frozen entry go stale and reports the unchanged violation as new. Re-anchor with
baseline update --file <moved file>. - Some rules build a message that does not name the offending code (
forbid-swallowed-errors,require-error-logging,forbid-redundant-else-after-return). For those the digest degenerates to a per-rule constant and adds no discrimination — the fingerprint and the count are all that constrain them. Item 2 of the roadmap below is the fix.
- Content-keyed count ratchet. Replace per-occurrence fingerprints with
(file, checkId, ruleId, contentKey) → count. Reordering, reformatting, line drift and added imports stop mattering; adding an N+1-th occurrence of the same shape still fails; merge conflicts degrade to comparing two integers instead of reconciling opaque hashes. ViolationInput.contentKey. Let checks declare a line-free content key explicitly instead of deriving it from the message, so a reworded message does not invalidate a digest — and so rules whose message carries no per-violation content stop degenerating to a constant.- Structural anchors (
file > enclosing named scope > expression text) for the reviewer-facingexamples[]list, which stays non-authoritative and is allowed to go stale.
- Content-keyed count ratchet. Replace per-occurrence fingerprints with
(file, checkId, ruleId, contentKey) → count. Reordering, reformatting, line drift and added imports stop mattering; adding an N+1-th occurrence of the same shape still fails; merge conflicts degrade to comparing two integers instead of reconciling opaque hashes. ViolationInput.contentKey. Let checks declare a line-free content key explicitly instead of deriving it from the message, so a reworded message does not invalidate a digest.- Structural anchors (
file > enclosing named scope > expression text) for the reviewer-facingexamples[]list, which stays non-authoritative and is allowed to go stale.
Autofix is disabled unless the config sets fix: true or the CLI receives --fix.
arch-guard run --changed --fixFix-capable checks attach a repair function to a violation. The engine:
- groups repairs by file and fix phase;
- applies only the earliest phase for each file in a pass;
- applies offsets from right to left and deduplicates identical offsets;
- writes atomically;
- adds any imports the repairs asked for, after every replacement in the pass has landed;
- clears caches and reruns checks before the next phase;
- stops at a bounded iteration limit.
Review the diff after autofix. Plugin authors should keep fixes deterministic and narrowly scoped.
A repair that rewrites typeof x === 'string' into isString(x) introduces an identifier the file
may not have. Two calls handle it:
fix.planNamedImport(file, module, name, offset)— ask, before writing anything, whether the name is usable at that position.satisfiedmeans a value import of that name from that module already resolves there;insertmeans the name is free;blockedmeans something else owns it.fix.requireNamedImport(file, module, name)— declare the import. The engine defers the insertion to the end of the pass (an import at the top of the file would invalidate every offset the other repairs are holding), re-reads the file, and is idempotent: an existing value import from the same module is extended in place, and a type-only import of the same module is not repurposed.
A repair that gets blocked must throw, not proceed. Writing the expression and skipping the
import is the worst possible outcome — the file no longer compiles and nothing said so. The engine
catches the throw, leaves the file untouched, and prints one aggregated line per reason at the end
of the pass; the violation itself stays red, so nothing is lost.
planNamedImport resolves the name the way TypeScript does, not by scanning the file for a
matching string. Three shapes that a whole-file "is this name bound anywhere?" test gets wrong:
- an unrelated
const isStringinside another function suppresses the import that top-level code needs (repair written, import missing); - a same-named symbol imported from a different module suppresses it, and the repair silently wires the guard to a foreign function (compiles, wrong semantics);
- a function parameter named
isStringis not a variable declaration at all, so the import is added and then shadowed inside the very function being repaired.
The bundled code-style ruleset emits ~28 such primitives, so it needs to be told where they live:
createCodeStyleDefaults({
scope: { /* … */ },
helpers: {
module: '@your-scope/core',
bySymbol: { stringifyPretty: '@your-scope/core/json' },
},
})Without a helpers block those repairs are declined, loudly, and nothing is written. Silence
was the old behaviour and it was wrong: --fix produced TS2304 plus a second wave of TS2322
(an unresolved identifier carries no type predicate, so narrowing that used to work through the raw
typeof collapses, and the error count exceeds the number of missing imports) and printed nothing
at all. If the host really does inject these primitives as globals, say so:
createCodeStyleDefaults({ scope: { /* … */ }, helpers: { assumeGlobals: true } })Suppressions are evaluated before baseline matching. They require a check id and a reason on the same line.
// @arch-guard:suspend-file example/generated-contract Reason: generated by schema compiler; tracked in issue #123.// @arch-guard:suspend example/legacy-boundary Reason: compatibility adapter removed after issue #456.
legacyCall()Use suppressions as traceable debt, not as a replacement for configuration.
npm install
npm run checknpm run check type-checks, builds, runs the Node test suite, and validates the package contents. See CONTRIBUTING.md for contribution boundaries and SECURITY.md for vulnerability reporting.
MIT © Error-Zhang