Coding Standards
The bewith-wide invariants for TypeScript code (NestJS backend, Next.js frontends, worker services, any shared TS package). Read by every agent that authors or reviews TS code. The single source of truth โ agents reference this file rather than restating the rules.
PHP and other languages have their own conventions; this file is TS-specific. Cross-language invariants (CU-id traceability, no secrets in commits, etc.) live in autonomy-model.md, CLAUDE.md, and the hooks.
The enforcement hierarchy, from most to least mechanical:
- ESLint via
@bewith-dev/eslint-pluginโ the real, published shared config (this supersedes the earlier idea of a futureconfigs/eslint/bewith-base.eslintrc.json). When a rule is expressible in eslint, the plugin owns the enforcement. Pre-push hookgit-hooks/lint-pass.shblocks pushes that fail lint; CI re-runs lint on every PR. - Pre-PR review โ
code-revieweragent applies its ten-point checklist before a PR is marked ready-for-review. Catches the few cases ESLint cannot. - Domain agents โ
backend-developer,frontend-developer, and other implementer agents are aware of these rules at authoring time, so violations rarely reach review.
Scope: TypeScript/JavaScript only. The plugin (and most of this file) does not cover PHP โ the Yii2 repos (superco-consumer, superco-management backend) need their own linter (PHPCS / PHPStan), a separate track owned by legacy-php-guide.
This is principle 1 from deterministic-ai.md applied to coding standards: determinism via tooling, not prompting. Where a rule can be configured into ESLint, that is the right home; the prose below exists for the cases ESLint cannot fully cover, and for context the agents need when authoring.
The rulesโ
1. Strict equality onlyโ
Always use === / !==. Never == / !=. No exceptions.
// good
if (value === null) { /* โฆ */ }
if (status !== 'active') { /* โฆ */ }
if (count === 0) { /* โฆ */ }
// bad
if (value == null) { /* โฆ */ }
if (status != 'active') { /* โฆ */ }
When you need to allow both null and undefined, check explicitly โ do not rely on == coercion:
// good
if (value === null || value === undefined) { /* โฆ */ }
// or, when more concise
if (value == null) { /* โฆ */ } // BAD โ relies on coercion
if (!value) { /* โฆ */ } // wrong if 0, '', false are valid
For the "either null or undefined" check, prefer rule 2 (lodash.isNil if you already import lodash; otherwise the explicit two-arm comparison).
Enforcement:
- Already enforced โ
@bewith-dev/eslint-pluginshipseqeqeq: 'error'in its shared config. Any repo extending the plugin gets this for free. code-revieweris the backstop for repos not yet on the plugin (see Enforcing across repos).
Why this rule exists: == triggers JavaScript's coercion algorithm, which produces surprising results (0 == '0' is true, null == undefined is true, '' == false is true). Strict equality is the only equality with predictable semantics.
2. Lodash for empty / presence checksโ
For "is this value null, undefined, an empty string, an empty array, or an empty object?", use lodash.isEmpty (or its sibling isNil for the narrower "null or undefined" case). Do not assemble it by hand.
import { isEmpty } from 'lodash';
// good
if (!isEmpty(match)) { /* โฆ */ }
if (isEmpty(options.filters)) { /* โฆ */ }
// bad โ re-implements isEmpty inline and gets one of the edge cases wrong
if (match !== undefined && match !== null && Object.keys(match).length > 0) { /* โฆ */ }
if (match && match.length > 0) { /* โฆ */ } // breaks on objects, falsy primitives
isEmpty correctly handles null, undefined, '', [], {}, and new Set() / new Map(). Custom inline checks miss one of these and produce subtle bugs.
For "is this exactly null or undefined" (not "empty"), use lodash.isNil:
import { isNil } from 'lodash';
// distinguishes "not provided" from "empty array"
if (isNil(value)) { /* the caller did not pass anything */ }
Enforcement:
- No standard ESLint rule for this preference. The community plugin
eslint-plugin-lodashhasprefer-lodash-methodbut it does not catch hand-rolled isEmpty. To be re-evaluated whenconfigs/eslint/is authored. - Until then,
code-reviewerflags the manual patterns in checklist item 11.
Why this rule exists: Empty-value checks are a high-frequency source of off-by-one and edge-case bugs. Centralising the behaviour in lodash.isEmpty removes the variation between authors and across files.
3. ESLint conformityโ
Code must comply with the project's ESLint configuration. Run ESLint and fix every reported issue before pushing. There is no PR with --fix-able warnings left open.
# at minimum, every TS repo defines a 'lint' script:
pnpm lint
# auto-fix what you can:
pnpm lint --fix
Enforcement:
git-hooks/lint-pass.shrunspnpm linton everygit push. Failed lint blocks the push.- CI re-runs lint on every PR. Failed lint blocks the PR.
process-guardianincludes lint-green as a REQUIRED Definition-of-Done item.
Why this rule exists: ESLint is the platform's first line of defence against syntax-level bugs, dead code, accessibility regressions, and security anti-patterns. The cost of running it locally before pushing is seconds; the cost of failing it at PR time is minutes-to-hours.
The canonical shared ruleset is @bewith-dev/eslint-plugin (published to GitHub Packages). Consumer repos extend it; they do not redefine it. See What the plugin enforces and Enforcing across repos.
4. Boolean namingโ
Name booleans as positive predicates, prefixed with a question word โ is, has, should, can (also did, will). Applies to variables, properties, parameters, and the return value of a boolean-returning function.
// good
const isActive = true;
const hasPermission = checkAccess();
const canRetry = attempts < MAX_RETRY_ATTEMPTS;
// bad
const active = true; // reads as a noun, not a yes/no question
const processing = true; // better: isProcessing
const notLoading = false; // negative predicate โ double-negates at the call site: `if (!notLoading)`
Prefer the positive form: name the flag isLoading and negate at the point of use (if (!isLoading)) rather than introducing notLoading. Negative predicates compound into hard-to-read double negatives.
Enforcement:
- Not yet a lint rule.
@typescript-eslint/naming-convention(already shipped by the plugin) can express this with avariableselector scoped totypes: ['boolean']and aprefixlist. Planned as a warning first โ it would flag existing non-conforming booleans โ and promoted toerroronly in a later plugin version that repos adopt deliberately (the plugin is version-pinned, so no repo breaks until it upgrades). Tracked against the plugin. - Until then,
code-reviewerflags non-predicate boolean names as a nit.
Why this rule exists: A predicate-named boolean reads as the yes/no question it answers (if (isActive)), so call sites are self-documenting. Noun-named or negative booleans force the reader to re-derive the polarity at every use.
5. Reuse before reimplementing (DRY)โ
Before writing a new function, helper, or utility, check whether one already exists โ in the module, the shared packages (lodash, @bewith-dev/*), and the repo. If a suitable one exists, reuse it instead of shipping a parallel implementation. Reuse only when it is sensible and safe: the existing function genuinely fits the case, and calling it does not change behavior for its current callers or drag in unwanted coupling.
// good โ reuse the existing helper
import { isEmpty } from 'lodash';
if (!isEmpty(items)) { /* โฆ */ }
// bad โ a second implementation of something that already exists
function listIsNotEmpty(x) { return x && x.length > 0; }
This rule has a counterweight: do not contort existing code to avoid every duplication. When sharing would force the existing function into a worse shape (extra flags, branches, or parameters for subtly different callers), write the focused new code โ that is the DRY-for-its-own-sake anti-pattern in code-reviewer ยง10, and a little duplication beats it. Reuse what fits; don't force what doesn't.
Enforcement:
- No ESLint rule can detect "you reimplemented an existing function."
code-reviewerflags duplicated / parallel implementations at review (checklist item 11), and domain agents read existing code before authoring โ the standing fix for the "code duplication" failure mode named inai-engineering-strategy.
Why this rule exists: The most common AI failure mode is writing new code without reading what already exists: helpers get reimplemented, patterns reinvented, two parallel solutions ship. Read first, reuse what fits, and one behavior stays in one place.
What @bewith-dev/eslint-plugin enforcesโ
The published plugin (extend it, do not redefine) ships these, grouped:
- Shared (all TS/JS):
eqeqeq(strict equality),no-unneeded-ternary,object-shorthand,no-empty,unicorn/prefer-node-protocol,@typescript-eslint/naming-convention(camelCase methods,PascalCase+Enum$enums,UPPER_CASEenum members),@typescript-eslint/return-await(in-try-catch), plus the custom rulesdont-use-any(stricter thanno-explicit-any),no-await-inside-promise-all,no-undefined-args,unified-filename-rules(consts/types/styles filename forms). - Backend:
require-readonly-in-dto(all DTO propsreadonly),no-throw-plain-error(throw a typed error, e.g.ExceptionErrorBuilder(message, code), notnew Error),validate-camelcase-schema-properties. - Frontend:
styled-component-naming,no-return-null-in-component.
When a convention can be expressed as a rule, add it to the plugin rather than writing prose here โ shift-left from "documented" to "enforced".
Enforcing across reposโ
The consumer-repo CLAUDE.md template sets the starting state for new repos. Existing repos are enforced through three mechanisms, strongest first:
- Org branch protection requiring status checks. The strongest gate โ a repo cannot merge unless the required checks (lint, test) pass. This is what actually forces an existing repo; everything below is how the checks come to exist in that repo.
- A shared reusable CI workflow that an existing repo consumes via a tiny caller workflow (
uses: bewith-dev/<host>/.github/workflows/<wf>.yml@<ref>) โ centralized lint/test/typecheck, version-pinned, no per-repo logic to drift. (Where this should be hosted is being decided โ see the uniform-CI task.) - A per-repo onboarding sweep (the planned
install-developer.sh/ consumer-onboarding): run once in an existing repo to install the git hooks (lint-pass,branch-name-check, โฆ), add/extend the@bewith-dev/eslint-pluginconfig (pinned version), and drop the caller CI workflow. This is the retrofit path.
Note:
governance.lock/governance-version-checkwere dropped after discussion (2026-06-01) โ version pinning is handled by the package version of@bewith-dev/eslint-plugin+ the caller workflow's@ref, not a lock file.
Current reality (org scan, 2026-06-01) โ the gaps a sweep must close:
- Adoption is uneven:
backend-services,organization-dashboardon^2.1.1;management-webapp,support-tool-frontend,design-systemon^2.0.0;facility-rentalson^1.9.1(version drift);bewith-consumer-frontenddoes not use the plugin at all. - CI lint enforcement is not uniform โ most repos rely on the pre-push hook + build steps, not a dedicated required lint check.
- PHP is uncovered (no eslint) โ needs a parallel PHPCS/PHPStan track.
So: a template alone is insufficient for existing repos. The enforcement story for them is reusable-workflow + required-status-check + a one-time onboarding sweep that pins the plugin version, tracked as a consumer-onboarding deliverable.
Conventions migrated from Confluenceโ
The team's coding-convention notes lived in Confluence (With R&D coding conventions, Conventions 13/2/25 in space WITH). Per the platform's SoT decision, bewith-docs โ not Confluence โ is now the source of truth; those pages are superseded by this file.
Most of that Confluence content was unresolved questions, not settled rules (the Q2/Q3 retros note "the team lacked a unified code convention reference"). Disposition:
- Settled โ enforced by tooling: filename forms โ the plugin's
unified-filename-rules; branch naming โgit-hooks/branch-name-check.sh(and the existingtype/branch-nameconvention); strict-equality, DTO-readonly, typed-errors, naming โ the plugin (above). - Settled in Confluence (
Conventions 13/2/25) โ recorded here as the decision, not yet a lint rule:- Component filename casing โ kebab-case (
user-card.tsx, notUserCard.tsx). - Aggregation files โ
.aggr.tssuffix (get-users.aggr.ts), with the matching variable suffixAggr(getUsersAggr). - Test-file location โ a
tests/folder at the module level (e.g.apps/users/tests/users.helper.spec.ts), not co-located beside the source. - Helpers vs utils layering โ prefer
helpers; only helpers may callutils(utils are the lower layer, not invoked directly elsewhere).
- Component filename casing โ kebab-case (
- Still to settle (low-stakes; decide when first touched, then encode here or as a plugin rule): helper/util implementation style (class vs functions), interface naming (
UserResponseInterfacewas floated as a direction, not locked). These are conventions, not blockers โ pick one when it next comes up and add the rule.
Authoring guidance for agentsโ
When backend-developer, frontend-developer, or any agent authoring TS code is loaded:
- Default to these rules. Do not produce code that violates them, even when the user does not mention them.
- When unsure about an equality check, write the strict form.
===and!==are never wrong;==and!=need justification this file does not provide. - When introducing a new dependency on
lodash, prefer importing only the function you need (import { isEmpty } from 'lodash') to keep bundle size sane. Tree-shaking handles it. - For new repos / packages, ensure
pnpm lintexists inpackage.jsonand that the shared ESLint config is extended. - Before adding a function / helper, look for an existing one โ grep the module, the shared packages, and
lodash. Reuse what cleanly and safely fits rather than writing a parallel implementation (rule 5).
Review guidance for code-reviewerโ
code-reviewer runs these checks as part of its ten-point checklist (specifically checklist item 11 โ "bewith-wide coding standards"):
- No
==or!=in the diff (excluding ESLinteqeqeqdisable comments โ flag those for justification). - No hand-rolled empty-value checks where
lodash.isEmptyorisNilwould do. - Booleans are positive predicates (
is/has/should/can); flag noun-named or negative ones (processing,notLoading) as a nit. -
pnpm lintpasses on the diff (verify via the PR's CI status check or by running locally). - No reimplementation of an existing function / helper where clean, safe reuse fits (new code duplicating something already in the module, a shared package, or
lodash) โ rule 5. The opposite mistake (contorting existing code to force-share) is overengineering โ see code-reviewer ยง10.
If an item is debatable (e.g. a legacy file that the diff merely adds to, with == already present), the reviewer flags it as a nit and does not block the PR.
MDX-safe markdown authoringโ
Docusaurus builds every .md / .mdx page through an MDX compiler that treats <word> tokens as JSX tags. Angle-bracket placeholders outside backticks fail the build.
Real failures this rule prevents (caught in PR review):
- PR #30: a literal
"Migrated to bewith-docs/<path>"in a proposal triggeredExpected a closing tag for <path>. - PR #32:
<list, โค3 items>in a skill body triggeredUnexpected character ',' in name.
The rule:
โ Wrong: "Migrated to bewith-docs/<path>"
โ Right: "Migrated to bewith-docs/`<path>`"
(or: `"Migrated to bewith-docs/<path>"`)
โ Wrong: Open loops: <list, โค3 items>
โ Right: Open loops: `<list, โค3 items>`
โ Wrong: Branch name: feature/<slug>_CU-<id>
โ Right: Branch name: `feature/<slug>_CU-<id>`
Safe forms โ no escaping needed:
- Inside a fenced code block (
```). - Inside an inline code span (
`โฆ`). - HTML elements Docusaurus understands:
<div>,<span>,<details>,<summary>,<br>,<a href="โฆ">,<img>, etc. - JSX components with a capitalised first letter:
<Tabs>,<TabItem>,<Admonition>. - Autolinks:
<https://example.com>,<mailto:eng@bewith.io>.
Enforcement:
- Local (pre-commit) โ
git-hooks/mdx-safety-check.shcallsscripts/checks/check-mdx-safety.mjson staged markdown files, blocks commits with risky placeholders, prints suggested backtick-wrapped fixes. - CI โ
validate-pr / Validate Docsruns the full Docusaurus build. Authoritative but slower (~2 minutes). The pre-commit check shifts most failures left. - Bypass โ
ALLOW_MDX_UNSAFE=1 git commit ...for the rare case where the heuristic fires on a true positive that you've verified Docusaurus accepts (e.g., a custom JSX component the allowlist doesn't know about).
The script's allowlist of safe tags is in SAFE_TAGS at the top of check-mdx-safety.mjs. Add to it when a new Docusaurus component lands.
Blockquote authoringโ
When a blockquote carries multiple distinct labeled statements (each starting with **Label:**), separate them with a blank > line so each renders as its own paragraph. Consecutive > lines without a blank > between them collapse into one paragraph in Docusaurus and read as a single run-on sentence.
โ Wrong (renders as one paragraph):
> **What lives here:** ideas before they earn an RFC.
> **Use vs. siblings:** an RFC is heavier.
> **Template:** the proposal template.
โ Right (renders as three paragraphs):
> **What lives here:** ideas before they earn an RFC.
>
> **Use vs. siblings:** an RFC is heavier.
>
> **Template:** the proposal template.
The rule applies to any blockquote with two or more labeled items. A single-statement blockquote is fine on one line; a continuation of the same statement across lines is fine without the blank > separator. Only distinct labeled statements need the separator.
Enforcement: convention only โ no hook for now. If the pattern recurs after this rule lands, consider extending scripts/checks/check-mdx-safety.mjs to flag consecutive ^> \*\* lines.
Future additionsโ
This document is deliberately short. Coding standards grow by accretion of rules that have caught real bugs; we add a rule when an incident or a recurring review comment demonstrates the need. New entries follow the same shape: the rule (with code examples), the enforcement path (ESLint where possible, agent fallback otherwise), and the why in one paragraph.
Cross-language rules (Yii2 PHP, Python, etc.) get their own files under this directory once enough rules accumulate to justify the split โ until then, language-specific rules live inside the domain agent's body (legacy-php-guide.md for PHP, etc.).
See alsoโ
- ai-engineering-strategy.md ยง17 (Testing Strategy) โ testing standards that interact with these coding standards.
- deterministic-ai.md principle 1 โ Determinism via tooling, not prompting. The reason ESLint owns enforcement wherever it can.
- autonomy-model.md โ the broader gate hierarchy these standards plug into.
.claude/agents/code-reviewer.mdโ the agent that enforces what ESLint cannot.git-hooks/lint-pass.shโ the local enforcement point.