npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 beta dist-tag, and any minor version may break.

Contents

Install

Requires Node 24 or later and an ESM project ("type": "module").

npm i -D @larrydarko/lint-config@beta

Install 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's message or stack may reach a logger, an error's message argument or AppError's logContext, 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 in components/icons/; one drawn from data (a binding, a v-for, an interpolation, a <slot>) stays inline. On every component in both families.
  • larry/icon-component — a file in components/icons/ is one root <svg>, aria-hidden="true", with no static id. duplication adds the cross-file half: no two icon files draw the same picture.
  • larry/no-native-dialog — no alert(), confirm() or prompt(), bare or through window, globalThis or self. They freeze the window and ignore the app's theme, and Electron does not implement prompt(). 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(…) and res.end(…) with a payload fail, each at its line. { error: CODE } bodies and an empty res.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 schemasDir is checked for z.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 call droppedKeys(, 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.
  • pending only 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 at header 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: or query: handed to the validator must be an identifier imported from sharedPackage; 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 apiClient and tests, a direct api.get(…) (or any method) on the axios instance fails at its line.
  • The validator refuses unknown keys (validator). It must import droppedKeys from sharedPackage and 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: the params, body and query it hands handlers.validate (an object literal, a spread, or a named object declared in the router or in one of objects) 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's params schema's .safeParse( or .parse(. Every schema it answers through handlers.send with (second argument, in the route or in the handler it names) must appear in the entry's response, a null response must never call send, and the multer fields (.single, .array, .fields) must be the entry's files, 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 call requestProblems, responseProblems and errorProblems from sharedPackage. 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 testChecks file must import and call recordCheckedRoute from module, with inject('<key>') as its log, and sit inside a listed suite.
  • Every suite reads it back and fails. Each setup must call openCoverageLog and uncheckedRoutes, provide the key its suite's recorders inject, and throw on what is left; its config must register it as a globalSetup.

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.file exports document.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.name and passes --config-file with config. Schemathesis otherwise looks for schemathesis.toml only 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_error and response_schema_conformance stay on everywhere, and no expected-statuses lists 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 re can read is left to Schemathesis.
  • Everything the run installs is pinned with ==. Every line of requirements (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, or process.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, a redis-server that 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 beside config: 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() or z.strictObject would fail every job a newer API queued during a rolling deploy.
  • The producer parses and refuses. It reads table, calls .safeParse/.parse, and calls droppedKeys. No other file in its workspace builds a new Queue(.
  • The processor parses and gives up once. processor.name must parse the job's data and throw an UnrecoverableError when it does not fit: a retry would read the same bytes.
  • Every worker uses both. In each workers file, a new Worker( or new Queue( names its queue from table, never a string, and a Worker runs its jobs through processor.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. fixtures must call createServerErrorRecorder( with a logFile, .watch( a context and .drain( the recorder.
  • The teardown reads the log. teardown must call logErrors(.
  • The config wires both. The Playwright config must name the teardown as its globalTeardown and pass LOG_FILE to the API it boots.
  • Every spec takes test from the fixtures. A spec importing test from @playwright/test runs none of the project's auto fixtures, so no tripwire watches it. It fails at its line; importing only expect from 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-json gate fails if the project lists one of them. It also fails if a CLI is missing from the top of node_modules or 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 export

The 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=true

npm 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-config

This 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@beta

License

MIT