@bsuite/dry-lint

中文文档
1.0.2发布于 2026/08/22UNLICENSEDTypeScriptES Module近 30 天下载 2139

ESLint plugin enforcing BSuite one-shot architecture (single-owner Supabase entity tables) and the OAuth callback bridge contract.

原始使用文档
文档目录18 节
  1. bsuite/no-uuid-input-placeholder
  2. Detected pattern
  3. Canonical fix
  4. Allowed escapes
  5. Install
  6. Usage
  7. Recommended — one-line wire-up
  8. Warn-only (useful during phased rollout)
  9. Manual wiring with options
  10. How app detection works
  11. How write detection works
  12. Adding a new table to the ownership map
  13. Single-owner entry (most common)
  14. Multi-writer entry (PHASE-3c, v0.2.0+)
  15. Versioning
  16. Owner keys
  17. Known violations and enforcement posture
  18. Development

@bsuite/dry-lint

ESLint flat-config plugin that enforces the BSuite one-shot architecture — every Supabase entity table has a single canonical owner app, and other apps may READ but never WRITE to it.

The plugin ships four rules:

RuleDefaultPurpose
bsuite/no-cross-app-writeerrorErrors when an app calls .from('<table>').{insert,update,upsert,delete}() against a table owned by a different app per the ownership map.
bsuite/no-raw-entity-selectwarnFlags raw .select() reads against canonical entity tables — encourages going through the typed entity-service layer.
bsuite/no-uuid-input-placeholdererrorFlags <Input placeholder="UUID of …"> and similar UUID-prompting placeholders on <Input>/<input>/<Textarea>/<textarea>. Operators must pick from a canonical entity selector, not paste a UUID by hand.
bsuite/oauth-callback-must-bridgeerrorErrors when a file calls exchangeCodeForTokens() without a corresponding supabase.auth.setSession() bridge — prevents the BSU→app handoff from leaving the user anonymous.

The ownership map lives at src/ownership-map.json and is sourced from docs/20260227-dry-one-shot-architecture-v1.04A.md §1 plus the V5–V10 findings in docs/20260423-cross-app-write-audit-v1.00W.md.

bsuite/no-uuid-input-placeholder

Forces operators to use canonical entity selectors instead of free-text UUID inputs. Triggered after the 2026-06-01 sweep (PRs #933–#939) which replaced raw UUID inputs across 16 CRM7 pages.

Detected pattern

// ❌ flagged — operator should pick, not paste
<Input placeholder="UUID of host employer" />
<Input placeholder="Enter apprentice UUID" />
<Input placeholder="Apprentice UUID" />

Canonical fix

// ✅ pick from a canonical entity selector
import { EmployerSelector } from '@/components/entity/selectors';

<EmployerSelector
  value={hostEmployerId}
  onSelect={(entity) => setHostEmployerId(entity?.id ?? '')}
/>

If no selector exists, build one mirroring the IncidentSelector I4 pattern (tenant-scoped via explicit .eq('tenant_id', …) on top of RLS).

Allowed escapes

  • Test, spec, stories, and __mocks__ paths are skipped automatically.
  • Inline // eslint-disable-next-line bsuite/no-uuid-input-placeholder for genuinely-no-canonical-entity surfaces (developer-only debug tools). Document why with a comment.

Install

pnpm add -D @bsuite/dry-lint

The plugin is ESLint v9 flat-config compatible. It declares eslint >=9 as a peer dependency.


Usage

// eslint.config.ts
import bsuiteDryLint from '@bsuite/dry-lint';

export default [
  bsuiteDryLint.configs.recommended, // bsuite/no-cross-app-write: error
  // ...rest of your config
];

Warn-only (useful during phased rollout)

import bsuiteDryLint from '@bsuite/dry-lint';

export default [
  bsuiteDryLint.configs.warn, // bsuite/no-cross-app-write: warn
];

Manual wiring with options

import bsuiteDryLint from '@bsuite/dry-lint';

export default [
  {
    plugins: { bsuite: bsuiteDryLint },
    rules: {
      'bsuite/no-cross-app-write': [
        'error',
        {
          // Override the auto-detected app (default: inferred from file path)
          appOverride: 'crm7',
          // Also report writes to tables not in the ownership map (default: false)
          warnOnUnknownTable: true,
        },
      ],
    },
  },
];

How app detection works

The rule needs to know which app a file belongs to so it can compare against the ownership map. Every real app config passes an explicit appOverride (see the "Configuration" examples above — each app's eslint.config.js sets { appOverride: '<app>' }), so in practice appOverride is what actually determines the app in production; the automatic detector below only runs when appOverride is omitted.

Detection (detectAppFromPath(), src/app-detection.ts) walks up from the linted file's own directory looking for the nearest package.json, bounded at the first .git marker (file or directory — covers git-worktree checkouts, where .git is a file pointing at the real gitdir). The package.json name field is matched against:

package.json nameApp key
business-suite-unifiedbsu
crm7-complete, crm7crm7
conduitconduit
braden-appbraden
r80-calculator, r80.3r80
throughputthroughput
any @bsuite/* scoped packageshared

If no package.json is found before the .git boundary, or its name doesn't match, the rule no-ops on that file (fail closed — a missed detection is always safe; a false attribution is not).

bsuite#1623: an earlier version of this detector matched path SEGMENTS instead (e.g. treating any crm7 directory-name segment as the CRM7 app). That broke for the platform's standard git worktree add /home/<user>/Desktop/Dev/<app>-<feature>-worktree convention — a worktree checked out as a SIBLING of bsuite/ with a directory name that doesn't literally equal the app name — and misattributed such files to whichever known app name happened to appear anywhere in the path (including a contributor's own home directory). The package.json-based walk above is immune to checkout directory naming entirely.

You can override detection per-config-block via the appOverride option — and every shipped app config does.


How write detection works

The rule walks the AST for any call expression where the callee is a member access ending in one of insert, update, upsert, or delete. It then walks back along the query-builder chain until it finds the closest .from('<literal>') call and resolves the table name.

The chain walk handles:

  • Direct calls — supabase.from('x').insert(...)
  • Chained query-builder calls — supabase.from('x').select('*').eq('id', 1).update(...)
  • await-wrapped calls
  • Calls preceded by any number of intermediate .from()-irrelevant query methods

It does NOT report:

  • Reads (.select(...))
  • Calls where the table name is a non-literal expression (e.g. a variable). This is intentional — we'd rather miss a few cases than spam false positives.
  • Calls in files outside any known app.

Adding a new table to the ownership map

Each entry uses EXACTLY ONE of owner (single canonical write surface) or writers (multi-writer admin co-ownership, @bsuite/[email protected]+). Setting both is a schema error.

Single-owner entry (most common)

  1. Open src/ownership-map.json.

  2. Add an entry under tables:

    {
      "tables": {
        "your_new_table": {
          "owner": "crm7",        // one of: bsu, crm7, conduit, braden, r80, throughput, shared, all
          "readers": ["bsu"],     // apps that may .select() but never write
          "$comment": "Optional context, e.g. audit reference."
        }
      }
    }
    
  3. Use "shared" if the table is co-owned by 2+ apps with a documented audit-trail / append-only pattern (e.g. wage_calculation_snapshots).

  4. Use "all" for explicit event-sink tables (e.g. bi_metrics).

Multi-writer entry (PHASE-3c, v0.2.0+)

For admin-CRUD tables co-owned by an explicit allow-list of apps (e.g. tenants + user_tenants, where BSU is the platform owner and CRM7's tenant-management edge function performs legitimate org-admin operations under RLS):

{
  "tables": {
    "your_co_owned_table": {
      "writers": ["bsu", "crm7"],   // explicit allow-list — at least one app
      "readers": ["r80", "conduit"], // apps that may .select() only
      "$comment": "Why this is multi-writer rather than single-owner."
    }
  }
}

The rule will pass writes from any listed app and flag writes from non-listed apps with a crossAppWriteMultiWriter message.

Versioning

Bump the @bsuite/dry-lint version (patch for new tables, minor for owner moves or schema additions) and republish.


Owner keys

KeyAppRepo
bsubusiness-suite-unifiedbusiness-suite-unified/
crm7CRM7crm7/
conduitConduit (recruitment ATS)conduit/
bradenbraden.com.au corporate sitebraden/
r80R80.3 wage calculatorR80.3/
throughputThroughput (idea management)throughput/
sharedCo-owned by multiple apps (audit-trail / append-only)n/a
allEvent sink — any app may writen/a

Known violations and enforcement posture

Consumer apps should wire bsuite/no-cross-app-write as error. Pre-existing legacy write paths must be isolated with narrow per-file overrides in the consumer ESLint config; do not keep the base rule in warn-mode.

The 2026-04-27 Phase 2D promotion moved BSU, CRM7, Conduit, R80.3, Braden, and Throughput to error-level enforcement while preserving documented exception sets for the Phase 5 ownership-relocation work.

Run the dry-run helper after ownership-map edits to identify any exception list that can be removed:

pnpm --filter @bsuite/dry-lint build
node packages/dry-lint/scripts/dry-run.mjs

Development

# Install (use --ignore-workspace until package is added to pnpm-workspace.yaml)
cd packages/dry-lint && pnpm install --ignore-workspace

# Build
pnpm build

# Run tests
pnpm test

版本与发布记录

@bsuite/dry-lint 共发布 10 个版本,首次发布于 2026/04/25;过去 12 个月发布了 10 个版本。

版本发布日期距今
1.0.2latest2026/08/222 个月前
1.0.12026/07/243 个月前
1.0.02026/07/233 个月前
0.6.02026/07/203 个月前
0.5.02026/06/035 个月前
0.4.02026/05/066 个月前

关于 @bsuite/dry-lint 的常见问题

@bsuite/dry-lint 是什么?

ESLint plugin enforcing BSuite one-shot architecture (single-owner Supabase entity tables) and the OAuth callback bridge contract.

如何安装 @bsuite/dry-lint?

在项目目录运行 npm install @bsuite/dry-lint,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:eslint(>=9.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@bsuite/dry-lint 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@bsuite/dry-lint 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@bsuite/dry-lint 可以商用吗?

@bsuite/dry-lint 使用 UNLICENSED 许可。作者声明为 UNLICENSED,即不授予公开使用许可(与公共领域的 Unlicense 不同)。使用前需获得作者授权。以上为概括,不构成法律意见,请以许可原文为准。

@bsuite/dry-lint 的下载量有多少?

根据 npm 官方下载统计,@bsuite/dry-lint 在 2026/10/09 – 2026/11/07 这 30 天内被下载 2,139 次,日均约 71 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@bsuite/dry-lint 还在维护吗?

最新版本 1.0.2 发布于 2026/08/22(2 个月前);至今共发布 10 个版本,其中过去 12 个月发布了 10 个;首次发布于 2026/04/25。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。