@brighterly/lib-fe-codestyle
v0.5.2
Published
Shared invariant-validator engine, rule pack and ESLint config for Brighterly Nuxt frontend apps
Keywords
Readme
@brighterly/lib-fe-codestyle
Shared, deterministic code-review machinery for the Brighterly Nuxt apps — land-app, cs-app, app-tutor, app-admin.
It holds the mechanisms. Domain rules stay in each app.
New here? docs/how-it-works.md — how a check reads real code, what the gate can and cannot cover, what is already enforced, how to extend it, and how to run it.
Subpaths
| Import | What it is |
|---|---|
| @brighterly/lib-fe-codestyle/invariants | the invariants engine — run(options), the rule contract, AST helpers, util |
| @brighterly/lib-fe-codestyle/invariants/rules | the common rule pack, portable across all apps |
| @brighterly/lib-fe-codestyle/eslint | the shared flat config, its seven local rules, and the maps behind them |
| @brighterly/lib-fe-codestyle/validate | the aggregating runner behind pnpm validate |
exports is the public surface. Anything not listed is private — a linked package
resolves deep paths that break the moment it is published.
The one design rule: export data, never factories
Consumers compose. There is no defineBrighterlyEslint({...}), and there never will be —
the four apps' configs are ~95% identical and the variance is mostly drift, so a factory
would parameterise accidental difference.
// an app that needs nothing extra
import brighterly from '@brighterly/lib-fe-codestyle/eslint'
export default [...brighterly]// an app extending a map — plain object spread, no API to learn
import brighterly, { checkFileFolders } from '@brighterly/lib-fe-codestyle/eslint'
export default [
...brighterly,
{ rules: { 'check-file/folder-naming-convention': ['error', {
...checkFileFolders,
'app/components/pages/**/': 'KEBAB_CASE',
}] } },
]ESLint replaces rule options rather than merging them, so appending an override without spreading the shared map silently discards every shared entry. That is why the maps are exported as data.
What the rule pack ships
Invariants — @brighterly/lib-fe-codestyle/invariants/rules
| Rule | Level | Checks |
|---|---|---|
| core/R1 | warn* | bare localStorage / sessionStorage — the accessor itself throws on Safari ITP and in-app webviews |
| core/R2 | error | SCSS rules inline in a Vue <style> block instead of a referenced .scss file |
| core/R3 | warn* | JSON.parse with no enclosing try/catch — a corrupt entry breaks that path permanently |
| core/R4 | error | @font-face declaring a font stack — an invalid descriptor, so the browser drops the face silently |
| core/C1 | warn | hand-rolled browser API with a VueUse equivalent |
| core/C2 | warn | a component duplicating an existing UI-kit component name |
| core/C3 | warn | a literal colour in SCSS instead of a design token |
| core/C4 | warn | a hand-rolled date format string, or toISOString() |
| core/C5 | warn | a type file outside the types/<entity>/{enums,interfaces,types}.ts structure |
| core/C6 | warn | a raw font-family / font-size / font-weight declaration instead of the app's typography source |
| core/C7 | warn | <script> attribute order differing from the app's canonical (setup lang vs lang setup) |
Level discipline: error is for crash-class rules (core/R*); warn is for
conventions (core/C*). core/R1/core/R3 (marked *) ship at warn as an interim
state (2026-09-25): their fleet backlog is real code nobody has bandwidth to migrate
and re-verify right now. Their target level is error — promote once the backlog
clears (a breaking-style bump). core/R2/core/R4 stay error. A consumer's levels
ceiling is still never the tool for a backlog on an error rule — fix or allowlist.
Underscore-prefixed SCSS partials (_extends.scss, _colors-*.scss, …) are declaration
files by convention in every Brighterly app and are exempt from core/C3/core/C6 wholesale.
core/R* were ported from land-app, where each was a production scar (as R1, R7, R18,
R28 — renumbered sequentially when the prefix landed, pre-publish, while no released
version pinned the old tokens). core/C* are conventions with
no incident behind them yet — they ship at warn so a consuming app can measure what they
find before anyone decides to block on them. Promoting one to error is a major bump.
Fixtures ship with the rules (commonFixtures), so --self-test works in any consumer
without it having to write fixtures for rules it did not author.
ESLint — @brighterly/lib-fe-codestyle/eslint
Seven local rules: padding-after-macros, async-components-after-macros,
no-import-group-labels, ref-prefix-prop-mirrors, require-component-name,
on-prefixed-callbacks, and optional-callback-chaining (new — an optional callback
invoked without ?. throws for the first caller that omits it).
Plus the shared flat config: restricted syntax, restricted imports, the types-organization
blocks, and the check-file naming maps — all exported as data so a consumer can spread
and extend them.
App-specific paths: settings, not parameters
A shared rule must not hardcode a consuming app's paths. Each app declares them once:
// invariants/config.mts
export const settings = {
safeStorageModule: 'safeLocalStorage from @helpers/safe-local-storage',
safeStorageFile: 'app/utils/helpers/safe-local-storage.ts', // core/R1 auto-exempts the wrapper itself
safeJsonParse: 'parseStoredJson() from @helpers/safe-local-storage',
scssTokensFile: 'app/assets/scss/_colors.scss',
uiKitRoot: 'app/components/UI',
dateFormatModule: 'DateFormat from @ts-types/date/enums',
typesRoot: 'app/types', // core/C5 (default shown)
typographySource: 'the font() mixin from @scss/_mixins.scss', // core/C6 — cs-app points this at its AppText component instead
scriptAttrOrder: 'setup-first', // core/C7 — or 'lang-first' (default 'setup-first')
selfTestFile: 'invariants/self-test.mts', // its ignore-strings are fixture DATA — skipped by IG0/IG1
}A missing key degrades the rule's message, never its firing. A rule that went quiet
because a path was unset would be the exact silent failure this package exists to prevent.
Two keys shape behaviour rather than the message — typesRoot (where core/C5 looks) and
scriptAttrOrder (which direction core/C7 flags) — and both fall back to the documented
default above, so leaving them unset still never silences the rule.
What consumers see in the IDE
Three hover surfaces, so "what rules do we have and what do they look for" never requires opening this repo:
sharedRuleIds(from…/invariants/rules) — the catalog: one constant per rule, each JSDoc'd with level, what it checks, and the fix. Use them asallowlists/levelskeys ([sharedRuleIds.core/C6]: 'off') and hover for the contract. A test pins the catalog to the shipped rules, so it cannot drift.SharedRuleSettings(from…/invariants) — annotate the app'ssettingsconst with it to get completion plus the per-key contract, and a type error on an invalidscriptAttrOrdervalue.- ESLint squiggles — every local rule ships
meta.docs.description, which is what the editor shows on hover over a violation.
PhpStorm / WebStorm notes: the IDE runs the project's own ESLint binary against the
flat eslint.config.mjs, so rules defined inside this package just work — nothing to
install or configure beyond the default "Automatic ESLint configuration". Two real
behaviours to know: the ESLint daemon caches the resolved config, so after upgrading
this package run ESLint: Restart Service (or reopen the project) to pick up new
rules; and the invariants engine is not an IDE inspection — its findings appear in
the terminal (pnpm validate:invariants) and the pre-commit hook only.
Wiring a consumer
pnpm add -D @brighterly/lib-fe-codestyle— plus, if not already direct devDeps,@vue/compiler-sfcandtypescript. Both are peers, not dependencies, on purpose: the validator must parse your files with the same compiler instance your build uses, so the package dedupes onto the app's copy instead of carrying its own. Every Brighterly Nuxt app already has both in the store — the devDep line adds a symlink, not a download.- An
invariants/directory withcli.mts(a ~20-line composition root — copy land-app's),config.mts(allowlists, levels, retired, unmaintained,settings— see above), your app-local rules, andspecs/for checks needing real module resolution. - A missing peer is loud, never silent: pnpm warns at install (and auto-installs by
default), and the engine's lazy
import()throws on the first AST rule otherwise. - Arm the pre-commit hook from install, not by hand: keep the hook script in a tracked
directory and add
git config core.hooksPath <that-dir> || truetopostinstall(the|| truekeeps .git-less installs — Docker builds — green). Setup stayspnpm i && pnpm dev; a hand-made.git/hookssymlink dies with every fresh clone.
An app with a pre-extraction fork of the engine (app-funnels) deletes its copied
ast/util/run/self-test plumbing and keeps only domain rules + config; its existing
compiler-sfc devDep already satisfies the peer.
Why src/ is compiled but eslint/ is not
Node refuses to strip types inside node_modules
(ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so the TypeScript half ships as dist/.
The ESLint config and its seven rules are already plain .mjs and ship as-is.
Rule IDs
Shared rules carry the core/ prefix: core/R1, core/C3. App-local rules stay bare:
R1, B2. The prefix marks provenance — a core/-id is shipped by this package, never
defined, renumbered or recycled in a consumer — and it keeps the two number lines
independent: every app allocates its own R1, R2, … naturally, with no risk of
colliding with a rule the package ships later. run() still exits 2 on a duplicate,
so a collision is loud, not silent.
Within the package each family is numbered sequentially with no gaps (core/R1–core/R4,
core/C1–core/C7) — enforced by test/invariants/rule-ids.test.ts. The family letter keeps
meaning provenance — R regression scar, B business, C convention, L
linter-owned — not topic. core/ in a report means exactly one thing: this finding
comes from the library, and its numbering is the library's to own.
Versioning is about blocking power
One question decides the bump: can this change turn a green consumer red, or break something a consumer wrote against a rule id? Yes → major. Adds signal without blocking → minor. Everything else → patch.
| Change | Bump | Why |
|---|---|---|
| New invariant rule at error | major | can turn a green repo red on install |
| Promoting a rule warn → error | major | same — new blocking power |
| Widening a rule's scope, narrowing its exclude, or making its match stricter | major | existing code that passed now fails |
| Renaming, renumbering or removing a rule id | major | breaks every invariant-ignore: comment, allowlists/levels key and doc that names the token — IG1 starts firing in every consumer |
| New ESLint rule at error, or adding a selector to restrictedSyntax / restrictedImports / the check-file maps | major | the shared config is spread into every app's lint gate |
| Engine behavior change (run() exit codes, finding shape, config contract) | major | consumers' composition roots are written against it |
| New invariant rule at warn | minor | new signal, blocks nothing |
| New ESLint rule at warn | minor | same |
| New export (a map, a helper, a settings key a rule optionally reads) | minor | additive surface |
| Demoting a rule error → warn, or narrowing its scope | patch | strictly relaxing |
| Bug fix that removes false positives, message/why/fix wording, fixture improvements | patch | nothing new fires |
Two edges worth naming: a bug fix that removes false negatives (the rule now
catches what it always claimed to) is still major — correctness of intent does not
change what happens in a consumer's CI. And a new settings key a rule requires is
major; one it merely reads for a better message is minor (a missing key degrades the
message, never the firing — see above).
Publishing
The package is published publicly to npmjs.org under the @brighterly org — the
same channel as @brighterly/lib-lumee-ui, so consumers need no registry config or
token. publishConfig.access: "public" in package.json makes the scoped publish
work without a paid org plan.
npm login # must be a member of the @brighterly org
npm version <patch|minor|major> # per the blocking-power table above
npm publish # prepublishOnly rebuilds dist and runs the tests first
git push && git push --tagsdist/ is gitignored; prepublishOnly (clean → build → test) guarantees the
tarball is built from the current sources, never a stale artifact.
Design
land-app/docs/superpowers/specs/2026-09-22-fe-codestyle-design.md
