Skip to main content

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:

  1. ESLint via @bewith-dev/eslint-plugin โ€” the real, published shared config (this supersedes the earlier idea of a future configs/eslint/bewith-base.eslintrc.json). When a rule is expressible in eslint, the plugin owns the enforcement. Pre-push hook git-hooks/lint-pass.sh blocks pushes that fail lint; CI re-runs lint on every PR.
  2. Pre-PR review โ€” code-reviewer agent applies its ten-point checklist before a PR is marked ready-for-review. Catches the few cases ESLint cannot.
  3. 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-plugin ships eqeqeq: 'error' in its shared config. Any repo extending the plugin gets this for free.
  • code-reviewer is 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-lodash has prefer-lodash-method but it does not catch hand-rolled isEmpty. To be re-evaluated when configs/eslint/ is authored.
  • Until then, code-reviewer flags 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.sh runs pnpm lint on every git push. Failed lint blocks the push.
  • CI re-runs lint on every PR. Failed lint blocks the PR.
  • process-guardian includes 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 a variable selector scoped to types: ['boolean'] and a prefix list. Planned as a warning first โ€” it would flag existing non-conforming booleans โ€” and promoted to error only 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-reviewer flags 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-reviewer flags 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 in ai-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_CASE enum members), @typescript-eslint/return-await (in-try-catch), plus the custom rules dont-use-any (stricter than no-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 props readonly), no-throw-plain-error (throw a typed error, e.g. ExceptionErrorBuilder(message, code), not new 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:

  1. 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.
  2. 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.)
  3. 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-plugin config (pinned version), and drop the caller CI workflow. This is the retrofit path.

Note: governance.lock / governance-version-check were 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-dashboard on ^2.1.1; management-webapp, support-tool-frontend, design-system on ^2.0.0; facility-rentals on ^1.9.1 (version drift); bewith-consumer-frontend does 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 existing type/branch-name convention); 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, not UserCard.tsx).
    • Aggregation files โ†’ .aggr.ts suffix (get-users.aggr.ts), with the matching variable suffix Aggr (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 call utils (utils are the lower layer, not invoked directly elsewhere).
  • 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 (UserResponseInterface was 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:

  1. Default to these rules. Do not produce code that violates them, even when the user does not mention them.
  2. When unsure about an equality check, write the strict form. === and !== are never wrong; == and != need justification this file does not provide.
  3. 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.
  4. For new repos / packages, ensure pnpm lint exists in package.json and that the shared ESLint config is extended.
  5. 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 ESLint eqeqeq disable comments โ€” flag those for justification).
  • No hand-rolled empty-value checks where lodash.isEmpty or isNil would do.
  • Booleans are positive predicates (is/has/should/can); flag noun-named or negative ones (processing, notLoading) as a nit.
  • pnpm lint passes 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 triggered Expected a closing tag for <path>.
  • PR #32: <list, โ‰ค3 items> in a skill body triggered Unexpected 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:

  1. Local (pre-commit) โ€” git-hooks/mdx-safety-check.sh calls scripts/checks/check-mdx-safety.mjs on staged markdown files, blocks commits with risky placeholders, prints suggested backtick-wrapped fixes.
  2. CI โ€” validate-pr / Validate Docs runs the full Docusaurus build. Authoritative but slower (~2 minutes). The pre-commit check shifts most failures left.
  3. 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โ€‹