This document defines the coding conventions, architectural rules, and quality standards for the LexBuild monorepo. Follow these rules for all new code and modifications. When in doubt, match the patterns already established in the codebase.
All packages use strict TypeScript. These compiler flags are non-negotiable:
strict: truenoUncheckedIndexedAccess: true(array/record indexing returnsT | undefined)exactOptionalPropertyTypes: true(distinguishesundefinedfrom "missing")
Do not add @ts-ignore or @ts-expect-error without an accompanying comment explaining why. Prefer fixing the type error.
Every package.json declares "type": "module". All imports use ESM syntax. Relative imports must include the .js extension:
import { renderSection } from "./renderer.js";Use import type for type-only imports. This is enforced by ESLint (consistent-type-imports):
import type { ConvertOptions } from "./converter.js";Prefer interface for object shapes. Use type for unions, intersections, mapped types, and utility types:
// Object shapes
interface ConvertOptions {
input: string;
output: string;
}
// Unions, intersections, mapped types
type LevelType = "title" | "chapter" | "section";
type NodeWithChildren = LevelNode & { children: ASTNode[] };Use unknown instead of any. If any is genuinely required, add an ESLint disable comment with an explanation:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SAX parser callback types are untyped
function handleEvent(data: any): void { ... }noUncheckedIndexedAccess makes arr[i] return T | undefined, and no-non-null-assertion forbids arr[i]!. Use destructuring iteration:
for (const [i, item] of arr.entries()) {
// `item` is typed as T, not T | undefined
}Each package has an index.ts barrel file in src/ that re-exports the public API. Internal modules are not exported from the barrel.
?? does not catch empty strings. "" ?? "fallback" returns "". Use || when empty strings should be treated as falsy.
| Entity | Convention | Example |
|---|---|---|
| Project name (prose) | LexBuild |
"LexBuild converts..." |
| Package names, CLI, URLs, paths, code identifiers | lexbuild (lowercase) |
@lexbuild/core, lexbuild convert-usc |
| Files | kebab-case.ts |
section-builder.ts |
| Types / Interfaces | PascalCase |
SectionNode, ConvertOptions |
| Functions | camelCase |
parseIdentifier, renderSection |
| Constants | UPPER_SNAKE_CASE |
USLM_NAMESPACE |
| Enum-like objects | PascalCase keys, as const satisfies |
LevelType.Section |
| CLI commands | {action}-{source} |
download-usc, convert-ecfr |
These settings are enforced by Prettier and must not be overridden per-file:
| Setting | Value |
|---|---|
| Quotes | Double |
| Trailing commas | All |
| Print width | 100 |
| Semicolons | Yes |
| Tab width | 2 |
- Comments explain why, not what.
- TypeScript is the source of truth for types. Never duplicate type information in comments.
- Comments must add value or be removed.
- Code should be readable without relying on comments.
All exported functions, classes, and key modules require a docblock:
/**
* Converts a parsed XML section into normalized Markdown output.
*
* Assumes the input node has already passed structural validation.
*
* @param node - Parsed XML section node
* @param context - Shared pipeline context
* @returns Markdown document with associated metadata
* @throws {ConversionError} When required elements are missing
*/Rules for docblocks:
- One-line summary is required.
@paramonly when the meaning is not obvious from the name and type.@returnsdescribes semantic output, not the return type.@throwswhen the function can throw.- Do not repeat TypeScript types in JSDoc tags (no
@param {string} id). - Do not document trivial functions.
Do not require docblocks for private or internal helpers. Use inline comments only when they fall into one of these categories:
- Invariants:
// At this stage, IDs must already be normalized - Reasoning:
// Preserve ordering for downstream heading generation - Performance:
// Stream to avoid loading large XML files into memory - Edge cases:
// Handle malformed upstream nodes missing wrappers - Constraints:
// Do not collapse whitespace; citation formatting depends on it
Remove or avoid writing:
- Redundant:
// Loop through items - Type duplication:
/** @param id string */ - Obvious:
// Increment counter - Stale or incorrect: outdated explanations, comments contradicting implementation
Only include when the file has architectural significance (pipeline entrypoints, parsers, AST definitions, orchestration modules, public boundaries). Keep concise: responsibilities, scope, constraints.
Break long functions using numbered section comments only for complex multi-phase flows:
// 1. Normalize input
// 2. Transform structure
// 3. Emit outputAll TODOs must be actionable, specific, and use this format:
// TODO(lexbuild): Actionable descriptionRemove vague TODOs. Do not leave // TODO: fix this or similar.
- Use custom error classes extending
Errorwithcausechaining. - When re-throwing in catch blocks, always attach
{ cause: err }(enforced bypreserve-caught-errorlint rule). - XML parsing errors: warn and continue. Do not crash on anomalous structures.
- File I/O errors: throw with context (file path, operation attempted).
- Never swallow errors silently. At minimum, log at
warnlevel. - No silent
catch {}blocks.
export class DownloadError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "DownloadError";
}
}- Co-locate test files:
parser.ts->parser.test.tsin the same directory. - Use
describeblocks mirroring the module's exported API. - Snapshot tests for Markdown output stability. Update snapshots intentionally, not casually.
- Name test cases descriptively:
it("converts <subsection> with chapeau to indented bold-lettered paragraph"). - Non-null assertions (
!) are allowed in test files (ESLint rule disabled for**/*.test.ts). - Shared test data lives in
fixtures/at the repo root. Reference via relative paths.
Source packages (packages/usc/, packages/ecfr/, packages/fr/) are independent. They depend only on @lexbuild/core, never on each other. This is enforced by no-restricted-imports rules in eslint.config.js.
When adding a new source package:
- Add its
no-restricted-importsblock toeslint.config.js. - Add it to the restriction lists of all existing source packages.
- Add it to the
fixedarray in.changeset/config.json.
Internal dependencies use workspace:* protocol:
{
"dependencies": {
"@lexbuild/core": "workspace:*"
}
}builduses"dependsOn": ["^build"]to respect the dependency graph.- Apps (
apps/astro/,apps/api/) are excluded from defaultpnpm turbo build. They use dedicated tasks (build:astro,build:api). lintdepends ontransit(not^build) for cross-package import resolution.devispersistent: true,cache: false.
Knip runs at root level via pnpm lint:knip. It is not wrapped in the Turborepo pipeline. Config file must be knip.jsonc (not knip.config.jsonc).
All published packages use lockstep versioning via Changesets. After changesets bump packages/*, also update version in root package.json and apps/astro/package.json to match, and add a corresponding entry to root CHANGELOG.md.
Library packages (core, usc, ecfr, fr) share an identical tsup config:
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
});The CLI package adds a shebang banner. The API app bundles npm dependencies but keeps native modules external. Do not deviate from these patterns without justification.
Every package follows this structure:
packages/{name}/
├── src/
│ ├── index.ts # Barrel exports (public API)
│ ├── *.ts # Module files
│ └── *.test.ts # Co-located tests
├── dist/ # Build output (gitignored)
├── package.json
├── tsconfig.json # Extends root tsconfig
├── tsup.config.ts
└── CLAUDE.md # Package architecture notes
Every package defines the same script set: build, dev, typecheck, test, lint, lint:fix.
Follow Conventional Commits:
<type>(<scope>): <description>
Types: feat, fix, docs, chore, refactor, test, style.
Scopes: core, usc, ecfr, fr, cli, astro, api.
- Astro 6 SSR with Node.js adapter.
- Template expressions (
{}) are plain JS, not TypeScript. Move complex typed logic to the---frontmatter section. - Analytics scripts use
is:inlineto prevent Astro's build pipeline from bundling them as modules. - Content is gitignored. The app has no code dependency on
@lexbuild/core,@lexbuild/usc, or@lexbuild/cli. - Fonts: IBM Plex Sans (body), Serif (display), Mono (code) via
@fontsource. No other font families.
- Hono +
@hono/zod-openapifor contract-first API design. - SQLite via
better-sqlite3withreadonly: trueon the content database connection. - Two separate databases:
lexbuild.db(content, read-only) andlexbuild-keys.db(API keys, read-write). better-sqlite3native bindings are platform-specific. macOS binaries do not work on Linux.better-sqlite3requires explicit approval in rootpackage.jsonunderpnpm.onlyBuiltDependencies.
- Self-managed VPS (AWS Lightsail) behind Cloudflare CDN/WAF and Caddy reverse proxy.
- PM2 for process management.
ecosystem.config.cjsreads secrets fromprocess.env. .env.productionis generated byscripts/deploy.shon every deploy, never manually maintained.- Secrets live in
~/.lexbuild-secretson the VPS, sourced from~/.zshenv.
- Avoid overuse of hyphens in prose.
- Ora spinner text should NOT end with
...(the spinner animation provides the "in progress" cue). - Prefer clear, direct language. No filler or marketing fluff.
When writing or reviewing code, verify:
- Exported APIs have docblocks.
- No redundant or type-duplicating comments.
- Comments reflect current behavior.
-
import typeused for type-only imports. - No
anywithout an eslint-disable comment explaining why. - Errors are chained with
{ cause: err }when re-thrown. - Package boundaries are respected (no cross-source-package imports).
- Test cases are descriptively named.
- Files use
kebab-case.tsnaming. - Prettier formatting is applied (double quotes, trailing commas, 100 char width).