|
| 1 | +#!/usr/bin/env node |
| 2 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 3 | +// |
| 4 | +// check-org-identifier -- keeps author-facing reference code on the blessed |
| 5 | +// org name in hook/action bodies. |
| 6 | +// |
| 7 | +// #3280 made `organizationId` the blessed developer-facing name for the |
| 8 | +// caller's active org across the JS authoring surface: a hook or action body |
| 9 | +// reads `ctx.user.organizationId` / `ctx.session.organizationId`, matching the |
| 10 | +// `organization_id` column and `current_user.organizationId` in RLS. The old |
| 11 | +// `ctx.session.tenantId` is a DEPRECATED alias that still works but teaches the |
| 12 | +// wrong name -- and TSDoc `@deprecated` only nudges an author whose editor |
| 13 | +// surfaces it. Nothing stopped our own reference apps (examples/, apps/), which |
| 14 | +// authors and AIs copy from, from re-introducing `session.tenantId`. |
| 15 | +// |
| 16 | +// This is a hard-fail guard, not a ratchet: the authoring surfaces carry ZERO |
| 17 | +// occurrences today, so any match is a NEW one and fails. It is deliberately |
| 18 | +// NARROW: |
| 19 | +// • Scope is author-facing reference code only (examples/, apps/). Internal |
| 20 | +// framework packages legitimately read `session.tenantId` (the engine's own |
| 21 | +// `buildSession`, the record-change trigger) -- that is the driver-layer |
| 22 | +// alias, a non-goal of #3280, and must not be flagged. |
| 23 | +// • skills/ and content/docs/ are EXCLUDED: they deliberately show the |
| 24 | +// deprecated form when TEACHING the deprecation ("deprecated alias"). |
| 25 | +// • The pattern matches only the `session.tenantId` token, never |
| 26 | +// `execCtx.tenantId` / `opts.tenantId` / `DriverOptions.tenantId`, which are |
| 27 | +// the generic driver-layer tenancy knob (explicitly out of scope). |
| 28 | +// |
| 29 | +// Escape hatch for a genuine driver-layer `session.tenantId` in an example: |
| 30 | +// add `os-allow-tenant-id` in a comment on the same line. |
| 31 | +// |
| 32 | +// node scripts/check-org-identifier.mjs |
| 33 | +// |
| 34 | +// Scope: tracked sources under examples/ and apps/ only (git ls-files). |
| 35 | + |
| 36 | +import { execFileSync } from 'node:child_process'; |
| 37 | +import { readFileSync } from 'node:fs'; |
| 38 | +import { join } from 'node:path'; |
| 39 | + |
| 40 | +const ROOTS = ['examples', 'apps']; |
| 41 | +const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.cts', '.mts']; |
| 42 | +const EXCLUDED = /(^|\/)(node_modules|dist|build|\.next|\.turbo)\//; |
| 43 | + |
| 44 | +// `ctx.session.tenantId`, `session?.tenantId`, `this.session . tenantId`, … — |
| 45 | +// the `session` receiver immediately before `.tenantId`. Anchored on the |
| 46 | +// `session` word so `execCtx.tenantId` / `opts.tenantId` never match. |
| 47 | +const PATTERN = /\bsession\s*\??\.\s*tenantId\b/; |
| 48 | +const ALLOW_MARKER = 'os-allow-tenant-id'; |
| 49 | + |
| 50 | +const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { |
| 51 | + encoding: 'utf8', |
| 52 | +}).trim(); |
| 53 | + |
| 54 | +// Newline-delimited on purpose (not `-z`): tracked paths under examples/ and |
| 55 | +// apps/ never contain a newline, and avoiding the NUL delimiter keeps this very |
| 56 | +// script free of any raw NUL byte (which would make it invisible to grep — the |
| 57 | +// exact #3127 failure mode this repo already guards with check:nul-bytes). |
| 58 | +const files = execFileSync('git', ['ls-files', '--', ...ROOTS], { |
| 59 | + cwd: root, |
| 60 | + encoding: 'utf8', |
| 61 | + maxBuffer: 64 * 1024 * 1024, |
| 62 | +}) |
| 63 | + .split('\n') |
| 64 | + .filter(Boolean) |
| 65 | + .filter((f) => EXTENSIONS.some((ext) => f.endsWith(ext))) |
| 66 | + .filter((f) => !EXCLUDED.test(f)); |
| 67 | + |
| 68 | +const offenders = []; |
| 69 | +for (const file of files) { |
| 70 | + const text = readFileSync(join(root, file), 'utf8'); |
| 71 | + const lines = text.split('\n'); |
| 72 | + for (let i = 0; i < lines.length; i++) { |
| 73 | + const line = lines[i]; |
| 74 | + if (!PATTERN.test(line)) continue; |
| 75 | + if (line.includes(ALLOW_MARKER)) continue; |
| 76 | + offenders.push({ file, line: i + 1, text: line.trim() }); |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +if (offenders.length === 0) { |
| 81 | + console.log( |
| 82 | + `check-org-identifier: OK (${files.length} author-facing source file(s), no deprecated session.tenantId).`, |
| 83 | + ); |
| 84 | + process.exit(0); |
| 85 | +} |
| 86 | + |
| 87 | +const plural = offenders.length === 1 ? 'occurrence' : 'occurrences'; |
| 88 | +console.error( |
| 89 | + `check-org-identifier: ${offenders.length} deprecated \`session.tenantId\` ${plural} in author-facing code\n`, |
| 90 | +); |
| 91 | +for (const o of offenders) { |
| 92 | + console.error(` • ${o.file}:${o.line} ${o.text}`); |
| 93 | +} |
| 94 | +console.error(` |
| 95 | +\`session.tenantId\` is a DEPRECATED alias (#3280). In a hook or action body read |
| 96 | +the caller's active org under the blessed name instead: |
| 97 | +
|
| 98 | + const org = ctx.user?.organizationId ?? ctx.session?.organizationId; |
| 99 | +
|
| 100 | +It carries the identical value and matches the \`organization_id\` column and |
| 101 | +\`current_user.organizationId\` in RLS. For a genuine driver-layer use, add an |
| 102 | +\`${ALLOW_MARKER}\` comment on the line.`); |
| 103 | +process.exit(1); |
0 commit comments