@larrydarko/lint-config
v0.1.0-beta.7
Published
Shared formatting, commit and style configuration for my projects.
Readme
@larrydarko/lint-config
Formatting, linting, commit rules and CI quality gates for my projects, kept in one package so a rule is changed once and every project picks it up on its next update.
It is opinionated and built for two kinds of project:
- fullstack: a Vue frontend and an Express API as npm workspaces, with a worker, a database and a websocket gateway.
- electron: a Vue desktop app split into main, preload and renderer.
You are welcome to use it, but expect the defaults to be mine. Every setting that varies between projects is an option, and everything else is fixed.
Status: pre-1.0. Releases go out under the
betadist-tag, and any minor version may break.
Contents
- Install
- Config files
- Git hooks
- ESLint
- Stylelint, commitlint, lint-staged
- CI gates
- Response schemas
- The API contract
- The job contract
- Response-leak tripwire
- Server-error tripwire
- The toolchain
- Working on this package
Install
Requires Node 24 or later and an ESM project ("type": "module").
npm i -D @larrydarko/lint-config@betaInstall only this package. ESLint, Prettier, Stylelint, commitlint, lint-staged, husky, knip, jscpd and every plugin come with it. See The toolchain for why you should not list them yourself.
Config files
Each tool gets one small file that contains only what differs in this project:
// prettier.config.js
export { default } from '@larrydarko/lint-config/prettier';
// commitlint.config.js
import { commitlint } from '@larrydarko/lint-config/commitlint';
export default commitlint();
// .stylelintrc.mjs
import { stylelint } from '@larrydarko/lint-config/stylelint';
export default stylelint({ spacingUnit: 'rem', mobileFirst: true });
// lint-staged.config.js
import { lintStaged } from '@larrydarko/lint-config/lint-staged';
export default lintStaged({ stylelint: true });
// knip.config.js
import { knip } from '@larrydarko/lint-config/knip';
export default knip({ entry: ['src/main.ts'], project: ['src/**/*.{ts,vue}'] });Prettier takes no options. To change one value, spread prettier from the package root and
override that key, so the change is visible in the project's own config.
Use knip() rather than a plain knip.json. knip turns on its ESLint, Prettier and lint-staged
support only for packages the project lists itself, and a project using this package lists none
of them. Without the helper, knip reports every config file above as unused. knip() turns that
support back on and tells knip which CLIs are installed. Options you pass win.
Git hooks
"scripts": { "prepare": "husky" }# .husky/pre-commit
npx --no -- lint-staged
# .husky/commit-msg
npx --no -- commitlint --edit "$1"--no makes a broken install fail. Without it, npx would download the latest version from the
registry and run that instead of the pinned one.
ESLint
// eslint.config.js
import { larry } from '@larrydarko/lint-config/eslint';
import { electronStandards } from '@larrydarko/lint-config/eslint/electron';
export default larry({
preset: 'electron', // or 'fullstack'
rootDir: import.meta.dirname,
standards: electronStandards(),
overrides: [/* this project's own blocks */],
});larry() sets up the parts every project shares:
- the JavaScript, TypeScript and Vue presets
- which globals apply where
- the strict type-aware rules
- naming, return types and test relaxations
- Prettier compatibility, applied last
preset chooses a family's folder layout, and paths overrides any part of it. The family
modules (/eslint/electron and /eslint/fullstack) export the standards blocks. They also export
the selectors, banned-import lists and rule plugins those blocks are built from, so the
project's own per-folder blocks can reuse them in overrides.
Most standards are the same in both families. Security, TypeScript, env, Vue, HTML and a11y,
i18n, refactoring, testing and the function contracts are one implementation each, applied to
that family's folders. Only the rules about a family's architecture differ: process or
workspace boundaries, its logger, and the full-stack api, database and websocket rules. Both
electronStandards() and fullstackStandards() accept the same options: security.vHtmlAllowed,
html.globalStylesheet, refactoring.markerFiles and i18n (or i18n: false).
fullstackStandards() also accepts logging.consoleAllowed.
No any, no unchecked casts, tests included. no-explicit-any, the no-unsafe-* family and
no-unsafe-type-assertion are errors in every typed file. A cast that narrows (as never, as unknown as T,
event.target as HTMLInputElement) labels a value with a type nothing checked; in a test, that is how a fake
with the wrong shape compiles. Narrow with a check (instanceof, a type guard, a Zod parse) or type the fake
in full. The typed rules read a Vue file's <script> only, so vue/no-restricted-syntax refuses as in
its <template> too.
One notification channel. larry/no-native-dialog keeps the browser's dialogs out; noToastLibraryPatterns
keeps a notification library out, so every message goes through the one component the app owns. Like the
other banned-import lists, it is composed into the project's own no-restricted-imports block for the frontend,
because that rule is last-match-wins:
'no-restricted-imports': ['error', { patterns: [...noStoreLibraryPatterns, ...noToastLibraryPatterns, ...aliasOnlyImportPatterns] }],Every disable comment names its rule and says why: // eslint-disable-next-line rule -- reason.
@eslint-community/eslint-comments enforces it (require-description, no-unlimited-disable,
disable-enable-pair), everywhere, typed or not. A block disable needs its enable, unless it is a
whole-file exception at the very top.
A rule of the package's own, under the larry/ prefix, needs no wiring:
larry/no-caught-message-leak— a caught error'smessageorstackmay reach a logger, an error's message argument or AppError'slogContext, and nothing else: not a return value, a call, or a property write. On in the full-stack api and services, and the Electron main and preload processes. AppError takes its message third; pass{ messageArgs: { MyError: 1 } }for another class that does not take it first.larry/no-inline-svg— an<svg>that draws a fixed picture belongs incomponents/icons/; one drawn from data (a binding, av-for, an interpolation, a<slot>) stays inline. On every component in both families.larry/icon-component— a file incomponents/icons/is one root<svg>,aria-hidden="true", with no staticid.duplicationadds the cross-file half: no two icon files draw the same picture.larry/no-native-dialog— noalert(),confirm()orprompt(), bare or throughwindow,globalThisorself. They freeze the window and ignore the app's theme, and Electron does not implementprompt(). The user is told through the app's notification function, and asked through its own dialog. A binding the module declares or imports under one of those names is not the browser's, and passes. On everything that runs in the Vue app, in both families.
Full-stack workspaces are roles. The full-stack rules are written against four roles, not
folder names: frontend (the Vue app), api (the HTTP service), services (Node processes
outside the request cycle: a queue consumer, a feed ingestor) and shared (packages the others
import). The defaults are frontend, api, ['worker'] and ['packages/shared']. A project
with another workspace names it once, and every rule for that role reaches it:
export default larry({
preset: 'fullstack',
rootDir: import.meta.dirname,
workspaces: { services: ['worker', 'ingestor'] },
standards: fullstackStandards(),
});larry() builds the preset's paths and the standards from the same workspaces, and holds them
to package.json. A workspace with a src/ and no role fails the config, and so does a role
naming a folder with no src/. So adding a workspace without giving it a role is an error, not a
workspace that silently gets only the base rules. fullstackLayout(workspaces) returns each
role's globs for the project's own blocks in overrides.
rootDir is required. It becomes tsconfigRootDir and must be the project's directory. A
default taken from inside this package would point at the package, and every type-aware rule would
quietly find no tsconfig.
| Option | Default | What it is for |
| ------------------ | ------------ | ----------------------------------------------------------------------- |
| preset | — | 'electron' or 'fullstack' |
| rootDir | — | the project root; required |
| workspaces | the four | full-stack only: which workspace plays which role |
| paths | the preset's | override source, tests, browser, node, ignores, … |
| vue | true | load the Vue plugin and parse .vue files |
| importExtensions | false | enforce import specifiers; { nodeRun } for folders Node runs directly |
| testProject | none | a dedicated tsconfig for tests, such as ./tsconfig.test.json |
| testRules | {} | extra relaxations for tests |
| rules | {} | extra rules on typed source |
| standards | [] | the family's standards blocks |
| overrides | [] | the project's own blocks, appended last before Prettier |
Order matters. In flat config the last matching block wins, and a block with no files matches
everything. That is why the catch-all block comes first and the test relaxations come after the
standards. The tests check this order against ESLint's own resolver.
Stylelint, commitlint, lint-staged
Stylelint. The base rules apply everywhere. Each option below turns on or off one rule that some projects follow and others do not.
| Option | Default | Turn it on when |
| ------------------------- | ----------- | -------------------------------------------------------------- |
| spacingUnit | 'em' | set to 'rem' for a UI that must not scale with its font size |
| tokenProperties | colour only | more scales (type, radius, …) are tokenised |
| ignoreZero | false | 0 is a reset, not a design value waiting to be named |
| lowercaseCurrentColor | false | value-keyword-case should rewrite currentColor |
| unknownCustomProperties | true | off where custom properties are generated by a SCSS loop |
| bem | false | class names are block__element--modifier |
| noScssColorFunctions | false | $color-* aliases resolve to var(--color-*) at runtime |
| noScssImport | false | the project is fully on @use/@forward |
| mobileFirst | false | breakpoints only ever layer up with min-width |
| duplicateFallbacks | false | 100vh then 100dvh is a fallback, not a mistake |
| variablesOverride | false | _variables.scss declares the aliases |
| referenceFiles | [] | globs for token/theme files read for context but not linted |
| vue | true | leave on to lint <style> blocks in SFCs |
| rules / overrides | {} / [] | anything else, merged last |
commitlint. It uses Conventional Commits with these types: feat, fix, perf, refactor,
test, security, docs, style, chore, bump and revert.
| Option | Default | What it does |
| ------------------- | -------- | -------------------------------------------------------------------- |
| subjectSingleWhy | true | warns, without blocking, when a subject describes two pieces of work |
| types | the list | replaces the allowed types |
| headerMaxLength | 200 | longest allowed header |
| bodyMaxLineLength | 400 | longest allowed body line |
| rules | {} | extra commitlint rules, merged last |
lint-staged. It runs ESLint and Prettier on staged .ts and .vue files, and Prettier on
.scss, .json and .md files. Pass stylelint: true to also run Stylelint on .scss and .vue
files, and tasks to add more glob → command entries.
CI gates
The gates check what a linter cannot see: a whole file, several files together, or files ESLint never opens, such as manifests, workflows, Dockerfiles and locale files. Each gate is a function that reads the repository and reports every failure with a reason and a fix. It sets a non-zero exit code when anything fails, and a passing run lists what the gate does not check.
A project runs each gate from a short script:
// scripts/check/check-error-handling.ts
import { checkElectronErrorHandling } from '@larrydarko/lint-config/gates/error-handling/electron';
checkElectronErrorHandling({
cleanupRequired: [['fsService', 'holds a file watcher that must close on quit']],
});"scripts": { "error:check": "node scripts/check/check-error-handling.ts" }Every option is typed and documented in the gate's declarations, so your editor lists them. Most
gates also export an inspect* function that returns the failures instead of printing them, for
use in tests. Most gates run with no options. Options exist for things only the project knows:
its source roots, its services, and exceptions it has approved, each with a stated reason.
| Gate | Family | Checks |
| -------------------------- | --------- | ------------------------------------------------------------------------ |
| audit | both | npm audit, with a reviewed allowlist that expires |
| code-style | both | file names, file length, SFC block order, stale size baselines |
| dead-code | both | unused code and dependencies, undeclared imports, import cycles (knip) |
| declaration-order | both | the order of imports, types, constants and functions in a module |
| duplication | both | copy-paste ceilings and a clone-size cap (jscpd); no icon drawn twice |
| html-standards | both | index.html: shell, CSP, entry module, remote resources, reduced motion |
| i18n | both | two-letter locale files; plurals, placeholders and arrays agree; options |
| package-json | both | manifest fields, version ranges, and the toolchain rule below |
| pipeline | both | every gate runs in CI; timeouts, permissions, SHA-pinned actions, … |
| refactoring | both | no deferral markers left in code |
| scss-standards | both | the entry, Vite injection, theme parity, self-hosted fonts |
| testing | both | suite layout, .only/.skip, coverage ratchet, E2E server-error wiring |
| tsconfigs | both | compiler flags stay at the standard |
| electron-security | electron | webPreferences, navigation, permissions, v-html, TLS overrides |
| error-handling/electron | electron | IPC handlers contain failures, process backstops, cleanup on quit |
| ipc-standards | electron | the channel list, documentation and preload surface agree |
| api-standards | fullstack | route map, middleware order, rate limits, response schemas, the contract |
| db-drift | fullstack | collection registry, indexes, migrations, drain order, ops scripts |
| docker | fullstack | image rules that need no Docker daemon, including no secrets in layers |
| env-drift | fullstack | the env schema and .env.example files agree; secrets have no default |
| error-handling/fullstack | fullstack | the status policy, error codes and their translations |
| metrics | fullstack | scrape config, probes, metric cardinality |
| security-drift | fullstack | security controls whose failure is silent |
| ws-standards | fullstack | one socket server, one client, and the handshake order |
dead-code and duplication accept --all, which prints the tool's own full report instead of
pass or fail. List vue in .jscpd.json and jscpd reports template markup as html on its own.
Don't list html there, because jscpd rejects it.
gates/lib/repo-root, gates/lib/strip-comments, gates/lib/source, gates/lib/files,
gates/lib/report and gates/lib/swallowed-errors are the helpers the gates are built from, and
they are exported for your own gates.
Response schemas
An API response should carry only the fields the frontend uses. The reliable way to get that is an allowlist that runs on every response: a Zod schema per response, parsed just before the data is sent. A Zod object drops every key it does not declare, so a whole database document passed by mistake, or a field added to the collection next month, never reaches the wire. The frontend infers its types from the same schemas, so the two sides cannot drift.
The project owns the helper, because it runs in production and this package is a dev dependency:
// api/src/lib/send.ts
export function send<TSchema extends z.ZodType>(res: Response, schema: TSchema, data: z.input<TSchema>): void {
const parsed = schema.safeParse(data);
// A server bug, not a bad request: never let it reach a ZodError → 422 handler.
if (!parsed.success) throw new AppError(500, 'INTERNAL', 'Response did not match its schema');
// A field the schema does not declare is a bug too. Off the wire either way; the question is
// who finds out: outside production the answer fails, in production it is logged by name.
const dropped = droppedKeys(data, parsed.data);
if (dropped.length > 0) {
if (config.isDev) throw new AppError(500, 'INTERNAL', 'Response carried undeclared fields');
res.req.log.error({ dropped }, 'Response carried undeclared fields');
}
res.json(parsed.data);
}
// a route
send(res, videoMetaSchema, video);
send(res.status(202), uploadAcceptedSchema, result);api-standards enforces it once the project states responses:
checkApiStandards({
// ...the other paths
responses: {
helper: 'api/src/lib/send.ts',
schemasDir: 'packages/shared/src/responses',
devFlag: 'config.isDev',
pending: {
'api/src/routes/admin.ts': 'moderation shapes not migrated yet',
},
},
});- No raw responses in route files.
res.json(…),res.send(…)andres.end(…)with a payload fail, each at its line.{ error: CODE }bodies and an emptyres.status(204).end()are fine, since they carry no data. - The helper parses. It must exist and call
.parse(or.safeParse(. - No loose response schemas. Every file under
schemasDiris checked forz.looseObject,.passthrough(),.loose(),.catchall(),z.any(),z.unknown(),z.json(),z.custom()and a record of strings. Each of these lets through a field nobody declared. - A dropped field is loud (
devFlag). The helper must calldroppedKeys(, throw when the stated expression holds (outside production), and log at error level otherwise. Without it a schema that is quietly wrong stays wrong: nothing leaks, but nobody learns the mapper hands over a field nobody listed. pendingonly shrinks. It lists the route files not migrated yet, each with a reason. A listed file with no raw response left fails, and so does an entry naming no route file. When the list is empty, delete the key.
A project adopting this lists every route file with a raw response in pending on day one, so the
gate passes. It then migrates one area at a time. With exactOptionalPropertyTypes, declare
optional fields with .exactOptional(): .optional() infers ?: T | undefined.
The gate reads text, so it does not see a response parameter named something other than res,
or a schema helper imported under another name.
The API contract
Response schemas stop the API sending a field it should not. They do not stop the frontend and the API disagreeing about what a route takes or answers: the frontend's mocks, its request types and its socket listeners are each a second, hand-written copy of the API, and any of them can drift while every test stays green. The contract is one table in the shared package, one entry per route, naming the schemas the router validates and answers with:
// packages/shared/src/contract.ts
export const API_ROUTES = {
'GET /api/videos/:id': { response: videoMetaSchema },
'PUT /api/videos/:id': { body: updateVideoBody, response: videoMetaSchema },
'GET /api/videos': { query: ownVideosQuery, response: ownMediaPageSchema },
'DELETE /api/videos/:id': { response: null }, // no body
'GET /api/videos/:id/download': { response: 'raw' }, // a file
} as const satisfies Record<string, Endpoint>;Everything else reads it: the frontend's one caller types the body, query and answer from the key
(call('PUT /api/videos/:id', { params: { id }, body })); the frontend's test server checks every
mocked exchange against it, and the API's route tests check every answer a router gives. Both
sides are strict: a key a schema does not declare is a problem, and the API's validator refuses it
with a 422 rather than dropping it.
api-standards enforces the parts a test cannot, once the project states contract:
checkApiStandards({
// ...the other paths
requestId: 'requestId',
contract: {
file: 'packages/shared/src/contract.ts',
sharedPackage: '@acme/shared',
caller: { instance: 'api', dir: 'frontend/src' },
validator: 'api/src/middleware/validate.ts',
testChecks: ['frontend/src/__tests__/server.ts', 'api/src/__tests__/test-app.ts'],
handlers: { validate: 'validated', send: 'send', objects: ['api/src/lib/schemas.ts'] },
},
});- The keys are the served routes, both ways. Each router's
mounted atheader plus its declared paths must match the contract's keys exactly: a route missing from the table fails, and so does a key no router serves. - Request schemas come from the shared package. A
params:,body:orquery:handed to the validator must be an identifier imported fromsharedPackage; a schema written in the router is a second definition the frontend and the contract never see. - The frontend calls through its typed caller. Outside
apiClientand tests, a directapi.get(…)(or any method) on the axios instance fails at its line. - The validator refuses unknown keys (
validator). It must importdroppedKeysfromsharedPackageand call it: a Zod object alone strips the key and the request answers 2xx, which tells a client sending a renamed or stale field that its change was saved. - Each route uses exactly what its entry names (
handlers). Read from each route declaration: theparams,bodyandqueryit handshandlers.validate(an object literal, a spread, or a named object declared in the router or in one ofobjects) must be the ones the entry names. A route that checks its path itself, to answer a bad one as a probe rather than a 422, passes when it calls the entry'sparamsschema's.safeParse(or.parse(. Every schema it answers throughhandlers.sendwith (second argument, in the route or in the handler it names) must appear in the entry'sresponse, anullresponse must never callsend, and the multer fields (.single,.array,.fields) must be the entry'sfiles, by name and by count. Without it a router can validate or answer with a schema the contract does not name, and only a test that happens to send the difference notices. - The test setups check against the contract (
testChecks). Each listed file, typically the frontend's mock server and the API's test app, must import and callrequestProblems,responseProblemsanderrorProblemsfromsharedPackage. Unchecked, a mock is a second API that drifts from the real one while every test stays green; a mock refusing with a code the API does not have tests a path no user can reach. - The request id comes before the body parser (
requestId). A body that will not parse otherwise reaches the error handler with no logger, crashes it, and becomes a bare 500.
The checks only see the exchanges a suite makes, so a route no test calls is never checked and
its schemas can be wrong while every test stays green. contract.coverage closes that: each test
setup records the routes it held to the contract, and a full run with coverage fails on the routes
nobody did, with the list.
contract: {
// ...as above
coverage: {
module: '@acme/shared/contract-coverage',
suites: [
{ config: 'api/vitest.config.ts', setup: 'api/src/__tests__/contract-coverage.ts' },
{ config: 'frontend/vitest.config.ts', setup: 'frontend/src/__tests__/contract-coverage.ts' },
],
},
},- Every test setup records. Each
testChecksfile must import and callrecordCheckedRoutefrommodule, withinject('<key>')as its log, and sit inside a listed suite. - Every suite reads it back and fails. Each
setupmust callopenCoverageLoganduncheckedRoutes,providethe key its suite's recorders inject, andthrowon what is left; itsconfigmust register it as aglobalSetup.
A route the frontend never calls (a webhook, a file URL the browser loads, an admin route called from a shell) goes in the frontend setup's exemption map with the reason, never quietly.
The mock server can only check what reaches it. testing takes
apiClient: { module: '@/api/client', exports: ['call', 'api'] }, the exports every request
goes through, and fails a suite that replaces any of them: a vi.mock/vi.doMock of the module
with no factory or with a factory returning that name, or a vi.spyOn on it. A stand-in answers
whatever the test hands it, so the code under test is never held to the API. Replacing the
module's other exports, around the real request path, is fine.
Both suites test against doubles. contract.fuzz holds the third check, a fuzz run against the
real API: Schemathesis reads an OpenAPI document built from
the contract, sends thousands of generated requests, and fails on a 5xx or an answer the contract
refuses. The run is only as good as the document it reads and the exceptions its config allows, so
the gate checks both:
contract: {
// ...as above
fuzz: {
document: { file: 'packages/shared/src/openapi.ts', name: 'openApiDocument' },
runner: 'e2e/fuzz/run.ts',
config: 'e2e/fuzz/schemathesis.toml',
requirements: 'e2e/fuzz/requirements.txt',
outputs: ['e2e/fuzz/.venv', 'e2e/fuzz/report'],
isolate: ['S3_ENDPOINT', 'S3_ACCESS_KEY', 'S3_SECRET_KEY', 'REDIS_HOST', 'REDIS_PORT', 'REDIS_URL'],
},
},- The document is built from the contract.
document.fileexportsdocument.name, reads the contract table, and writes no route string of its own. Each operation's id is its contract key ('GET /api/videos/:id'), which is how the config names one. - Every run builds it afresh and hands the fuzzer its config. The runner calls
document.nameand passes--config-filewithconfig. Schemathesis otherwise looks forschemathesis.tomlonly where it starts and above, and started anywhere else it runs with no exceptions at all, fuzzing the routes the config kept out. - Every exception says why, and none forgives a crash. Each
[[operations]]group and each setting turned off carries a comment directly above it, or at the end of its line.not_a_server_errorandresponse_schema_conformancestay on everywhere, and noexpected-statuseslists a 5xx. - Every route the config names exists. An operation id, a path or a path pattern that matches
nothing in the contract is a route that was renamed under its exception. A pattern only Python's
recan read is left to Schemathesis. - Everything the run installs is pinned with
==. Every line ofrequirements(the fuzzer, and a stand-in such as moto), each unpinned one reported at its line. A new release changes how requests are generated or answered, so an unpinned run changes colour for a reason in no diff, and a printed seed no longer replays it. - The API it starts gets the run's own services (
isolate). The runner must set each listed env key itself (KEY: …in an env object, orprocess.env['KEY'] = …). The API fills a key it is not given from its dev env file, so a run that leaves one out stores its uploads in the dev buckets and queues its jobs in the dev Redis, where they outlive it. The run's own services (moto, aredis-serverthat saves nothing) start and stop with it. - What a run writes is git-ignored. Each of
outputs, and the.schemathesis/crash cache at the root and besideconfig: its manifest records the absolute path of the document it read.
Socket pushes have the same problem and the same fix: ws-standards takes
events: { file, table }, the shared table of outbound events and their payload schemas, and fails
when the gateway emits an event the table does not list, the table lists one nothing emits, or the
gateway never reads the table.
The job contract
A job crosses a process boundary and waits in Redis, so a type shared between the API and the worker proves nothing at run time: a job queued by an older API can reach a newer worker. The registry is one table in the shared package, each job's queue name beside the schema of the data it carries, and both ends parse against it:
// packages/shared/src/jobs.ts
export const JOBS = {
transcode: { queue: 'transcode', data: z.object({ videoId, rawObjectKey }) },
accountCleanup: { queue: 'account-cleanup', data: z.object({ userId }) },
} as const satisfies Record<string, { queue: string; data: z.ZodObject }>;The producer is strict and the worker tolerant: the API refuses a field the schema would drop,
and the worker ignores one it does not know, so an API that adds a field can deploy before the
worker that reads it. api-standards checks it once the project states jobs:
jobs: {
file: 'packages/shared/src/jobs.ts',
table: 'JOBS',
producer: 'api/src/lib/queue.ts',
processor: { file: 'worker/src/lib/jobs.ts', name: 'jobProcessor' },
workers: ['worker/src/index.ts'],
},- The registry pairs every queue with a schema, and none is strict.
.strict()orz.strictObjectwould fail every job a newer API queued during a rolling deploy. - The producer parses and refuses. It reads
table, calls.safeParse/.parse, and callsdroppedKeys. No other file in its workspace builds anew Queue(. - The processor parses and gives up once.
processor.namemust parse the job's data and throw anUnrecoverableErrorwhen it does not fit: a retry would read the same bytes. - Every worker uses both. In each
workersfile, anew Worker(ornew Queue(names its queue fromtable, never a string, and aWorkerruns its jobs throughprocessor.name(.
Response-leak tripwire
@larrydarko/lint-config/response-leaks checks every JSON response an E2E suite receives from the
API for fields that must never leave the server. The project lists those fields per collection,
and every response is checked two ways:
- by key: a property with one of those names, at any depth.
- by value: a string equal to what one of those fields holds in the test database at that
moment, under any name.
{ ref: user.stripeCustomerId }passes the key check and fails this one.
It is a test fixture, not a gate, because it needs the real API and database running: route tests that mock the service layer only ever see the data the test made up. Plug it into the Playwright fixtures every test imports:
// e2e/support/fixtures.ts
import { createLeakRecorder, type LeakRecorder } from '@larrydarko/lint-config/response-leaks';
const SENSITIVE_FIELDS = {
Users: ['password', 'totpSecret', 'emailEncrypted', 'stripeCustomerId'],
Media: ['rawObjectKey'],
};
export const test = base.extend<{ noResponseLeaks: void }, { leaks: LeakRecorder }>({
leaks: [
async ({}, use) => {
const client = await new MongoClient(MONGO_URI).connect();
await use(createLeakRecorder({ sensitive: SENSITIVE_FIELDS, db: client.db(MONGO_DB) }));
await client.close();
},
{ scope: 'worker' },
],
noResponseLeaks: [
async ({ leaks }, use) => {
await use();
expect(await leaks.drain(), 'API responses carried server-only fields').toEqual([]);
},
{ auto: true },
],
context: async ({ context, leaks }, use) => {
leaks.watch(context);
await use(context);
},
});A failure names the route, the JSON path and how it matched:
GET /api/profile/alice → $.user.emailIndex (key of Users.emailIndex). A dotted field name
(billing.customerId) reaches into a subdocument. Values are read when drain() runs, so a user
the test registered is covered. Strings shorter than minValueLength (default 8) are not
value-matched, since they collide with ordinary text. Leave out db and only names are checked.
Leave out any field that is sent on purpose to one audience, such as recovery codes shown once,
or a ban reason shown to staff. Both checks are denylists, so response schemas
are still the real defence. The tripwire catches what a schema cannot: a secret under a field
name the schema does list, on the routes the suite visits.
The module has no dependencies. It accepts any object shaped like a MongoDB Db and a Playwright
BrowserContext.
Server-error tripwire
@larrydarko/lint-config/server-errors fails an E2E test that received a 5xx from the API, and
says why. A test that checks what it came to check walks straight past a 500 on the side: the page
still renders, a spinner just never stops. The recorder watches every API response; on a 5xx it
reads the API's JSON log (the API writes one when LOG_FILE is set) for the lines carrying the
response's x-request-id, so the failure arrives with its cause:
POST /api/auth/login → 500 (request 4892a4bd-…)
error: Unhandled error {"requestId":"4892a4bd-…","err":"TypeError: x is undefined (at load (/app/src/a.ts:3:1))"}// e2e/support/fixtures.ts
import { createServerErrorRecorder, type ServerErrorRecorder } from '@larrydarko/lint-config/server-errors';
serverErrors: [async ({}, use) => use(createServerErrorRecorder({ logFile: API_LOG })), { scope: 'worker' }],
noServerErrors: [
async ({ serverErrors }, use) => {
await use();
expect(await serverErrors.drain(), 'API requests failed with a server error').toEqual([]);
},
{ auto: true },
],
context: async ({ context, serverErrors }, use) => {
serverErrors.watch(context);
await use(context);
},Point the API the suite boots at the log (webServer.env: { LOG_FILE: API_LOG }), empty it in the
global setup, and read it once more in the global teardown with logErrors(API_LOG): every
error-level line of the run, including the ones no response showed, such as a socket push its
schema refused, a job that died in the background, or an error logged inside a request that still
answered 2xx. The logger may write a line after the response leaves, so drain() waits up to
logWaitMs (default 2000) for it. Without logFile, an entry still names the route, the status
and the request id.
testing checks the wiring once the project states serverErrors (next to e2e):
checkTesting({
// ...the other options
e2e: { dir: 'e2e', config: 'e2e/playwright.config.ts' },
serverErrors: { fixtures: 'e2e/support/fixtures.ts', teardown: 'e2e/support/global-teardown.ts' },
});- The fixtures record and drain.
fixturesmust callcreateServerErrorRecorder(with alogFile,.watch(a context and.drain(the recorder. - The teardown reads the log.
teardownmust calllogErrors(. - The config wires both. The Playwright config must name the teardown as its
globalTeardownand passLOG_FILEto the API it boots. - Every spec takes
testfrom the fixtures. A spec importingtestfrom@playwright/testruns none of the project'sautofixtures, so no tripwire watches it. It fails at its line; importing onlyexpectfrom Playwright is fine.
The toolchain
Every tool and plugin this package uses is one of its dependencies, pinned to an exact version: ESLint, Prettier, Stylelint, commitlint, lint-staged, husky, knip, jscpd and their plugins. This means:
- Don't install any of them yourself. In a project that depends on this package, the
package-jsongate fails if the project lists one of them. It also fails if a CLI is missing from the top ofnode_modulesor is a different version from the one pinned here. - Pins are exact on purpose. In a project, ranges are right, because the lockfile pins the tree. A published package's lockfile never reaches the projects that install it, so an exact pin is the only way to give every project the same version.
- A tool upgrade is a release of this package. Newer tools find new problems. When you update, run the linters and gates and read what changed. New findings are usually real, not a regression.
The plugins are resolved from inside this package, so they can sit anywhere in node_modules.
The CLIs can't: scripts, git hooks and editors look for eslint, prettier, stylelint and the
rest in the project's own node_modules. A registry install puts them there.
Working on this package
npm run build # src/*.ts → dist/, with declarations
npm run typecheck
npm test
npm run check:package # packs the tarball, installs it, then imports and type-checks every exportThe source is TypeScript and the package ships compiled dist/. Node will not strip types from
files under node_modules, so a .ts export would fail once installed. check:package runs with
skipLibCheck off, because with it on a declaration that fails to resolve silently becomes
any. prepublishOnly runs the typecheck, the tests and this check.
Testing a local checkout in a project. A file: dependency is a symlink by default, and
a symlinked package's dependencies never reach the project's node_modules, so the CLIs are
missing. Add this to the project's .npmrc:
install-links=truenpm then installs a packed copy, laid out like a registry install. Plain npm i does not refresh
that copy after you change this package, so reinstall it:
npm uninstall @larrydarko/lint-config && npm i -D file:../lint-configThis is for trying a change, not for committing. A file: dependency ties the project's lockfile
to your folder layout, so CI and fresh clones cannot install it. Switch back to the published
version, and remove install-links from .npmrc, before you commit:
npm i -D @larrydarko/lint-config@betaLicense
MIT
