diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3fe711d480..c0ac83474d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -411,6 +411,32 @@ jobs: node scripts/check-undeclared-dep-imports.mjs --self-test node scripts/check-undeclared-dep-imports.mjs + # A text-family column a DECLARED INDEX keys on must declare a `maxLength` + # (#12147, route A of #11374). Without one `driver-sql` emits it TEXT, MySQL + # refuses `ALTER TABLE ... ADD INDEX` with ER_BLOB_KEY_WITHOUT_LENGTH, and the + # object lands REGISTERED-BUT-BROKEN with its declared index silently absent + # (measured live on MySQL 8.0.46, #12058: 12 of 44 platform objects, sys_session + # among them). Enforcement used to be per-package pins, and each one was widened + # by a column that had escaped the previous scope -- objects keep moving across + # package boundaries under ADR-0029 K2, so a boundary-scoped pin re-opens the + # hole every time one moves. A central importing pin is NOT available: measured + # on PR #12143, it would invert the dependency graph. So this is a class-level + # source scan over every `*.object.ts`. + # Node builtins plus the shared comment mask only -- no node_modules, so a + # reviewer can run it in place. Its `--self-test` runs FIRST, and that leg is + # the load-bearing one: the production run over a fixed tree is green by + # construction, so it cannot tell a working matcher from a dead one. The other + # half is the FLOORS -- a sweep that finds nothing because it swept nothing + # reports exactly what a clean tree reports, so an empty population is `exit 2` + # rather than a pass. Unclassifiable shapes refuse for the same reason. + # Invoked as `node` rather than through a `pnpm check:*` alias: see the + # GATE INVOCATION IDIOM note at the top of this file. + # Scans 113 *.object.ts files, no spawns; ~0.3s. + - name: Keyed text-family columns declare their bound (#12147) + run: | + node scripts/check-keyed-text-bounds.mjs --self-test + node scripts/check-keyed-text-bounds.mjs + # The bash-3.2 floor, over every shell file the repo ships (#12221). # `/usr/bin/env bash` is bash 3.2.57 on macOS -- Apple ships no bash 4+, # for licensing reasons -- and THIS RUNNER IS BASH 5, where every construct diff --git a/package.json b/package.json index 51385f6052..b98aa1c1ab 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "check:refd-timer-probe": "node scripts/check-refd-timer-probe.mjs --self-test && node scripts/check-refd-timer-probe.mjs", "check:type-source-resolution": "node scripts/check-type-source-resolution.mjs --self-test && node scripts/check-type-source-resolution.mjs", "check:undeclared-dep-imports": "node scripts/check-undeclared-dep-imports.mjs --self-test && node scripts/check-undeclared-dep-imports.mjs", + "check:keyed-text-bounds": "node scripts/check-keyed-text-bounds.mjs --self-test && node scripts/check-keyed-text-bounds.mjs", "check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs", "check:published-readme-exports": "node scripts/check-published-readme-exports.mjs --self-test && node scripts/check-published-readme-exports.mjs", "check:published-readme-links": "node scripts/check-published-readme-links.mjs --self-test && node scripts/check-published-readme-links.mjs", diff --git a/packages/platform-objects/src/platform-keyed-text-bounds.test.ts b/packages/platform-objects/src/platform-keyed-text-bounds.test.ts index 112e89d83e..f2e3e91460 100644 --- a/packages/platform-objects/src/platform-keyed-text-bounds.test.ts +++ b/packages/platform-objects/src/platform-keyed-text-bounds.test.ts @@ -4,120 +4,47 @@ import { describe, it, expect } from 'vitest'; import * as PlatformObjects from './index'; /** - * #11374 — every text-family column a declared index keys on must declare a - * `maxLength`, because a bound is what lets the column be a key at all. - * - * ## Why this pin exists - * - * `driver-sql` emits a KEYED text-family column as `varchar(maxLength)` when - * the field declares a bound the dialect can key on, and leaves it `TEXT` - * otherwise. MySQL refuses a TEXT/BLOB column in a key without a prefix length - * (`ER_BLOB_KEY_WITHOUT_LENGTH`), so an unbounded keyed text column means: - * `CREATE TABLE` succeeds, `ALTER TABLE … ADD [UNIQUE] INDEX` fails, and the - * object lands registered-but-broken with its declared uniqueness silently - * absent. Measured on live MySQL 8.0.46: 12 of 44 platform objects failed - * schema-sync this way — sys_session and sys_account among them, so a MySQL - * stack could not sign anyone in. - * - * The driver deliberately does NOT substitute a prefix index: measured on the - * same server, a prefix-UNIQUE index is stricter-and-different — it refused a - * second, genuinely distinct token that shared its first 191 characters - * (`ER_DUP_ENTRY`), i.e. a valid sign-in refused as a duplicate. So the bound - * has to live HERE, in the field declaration (maintainer ruling on #11374, - * 2026-08-24: route A). - * - * ## Why this file enumerates the WHOLE package, not just `identity/` - * - * It used to be `identity/identity-keyed-text-bounds.test.ts`, importing - * `./index` from `identity/`. That scoping is precisely how - * `sys_import_job.created_by` — a keyed, unbounded text column in `audit/` — - * survived route A's first pass: the pin could not see it, so nothing failed by - * name and the column was left for a follow-up card to find by hand. A pin that - * polices one directory does not police the defect class; it polices a - * directory. The enumeration now walks every object the package exports, and - * the vacuity control below asserts a column from OUTSIDE `identity/` is in - * the enumerated set, so the same narrowing cannot silently come back. - * - * ## What a red on this file means - * - * A new keyed text-family field arrived without a `maxLength`. Do not silence - * the assertion — derive a bound from the value's producer (upstream - * better-auth schema/constraints, IdP norms, or the in-repo producer) and - * declare it. If the value source genuinely cannot be bounded, extend - * `UNBOUNDABLE` WITH a comment naming why — but read the #11701 block below - * first: an unboundable column may only be keyed by a UNIQUE index, because a - * UNIQUE index is the only kind #11627's hash shadow can carry. - * - * A bound may legitimately exceed 768 chars (the utf8mb4 index-key ceiling — - * e.g. `sys_account.issuer` at 2048, the oauth TOKEN columns at 1024 — - * `sys_oauth_resource.identifier` is no longer among them, see #12313): the - * column then stays TEXT and its index still cannot exist on MySQL directly. - * That debt was #11627's, and #11627 discharged it for the UNIQUE half — such - * an index is now carried on a hash-shadow column. The first `describe` below - * still polices only "keyed text declares its bound". - * - * ## #11701 — the NON-UNIQUE half, which a hash shadow cannot serve - * - * The second `describe` polices the case #11627 deliberately left refused. A - * UNIQUE constraint is an equality-only predicate, so hashing the value - * preserves it exactly; a NON-UNIQUE index exists for an ACCESS PATH, and an - * index over a digest accelerates no `WHERE col = ?` the planner can reach - * without rewriting the read side. So for a non-unique index there is no - * shadow to fall back on: the column must be KEYABLE — bounded, and bounded at - * or under 768 — or the index cannot exist on MySQL at all and the object's - * whole schema-sync is refused. - * - * That left exactly two platform members, and the maintainer ruled them - * separately on 2026-08-25 because they are different problems: - * - * • `sys_verification.value` — unboundable AND unread. The declared index was - * REMOVED, on measured liveness (better-auth keys verification lookups on - * `identifier`; no in-repo query filters by `value`). Removing it is what - * emptied `UNBOUNDABLE` below. - * • `sys_oauth_client_resource.resource_id` — a LIVE access path (the FK side - * of `sys_oauth_resource.identifier`), so its bound was narrowed - * 1024 → 768 instead. See the field's own comment for the evidence that - * nothing legitimate lives in the discarded band. - * - * ⚠️ UPDATED by #12313: that bound is now **255**, not 768. #11701 picked - * 768 as the smallest narrowing that made the index expressible and left - * the number unsourced on purpose; #12313 sourced the REFERENT - * (`sys_oauth_resource.identifier`, 1024 → 255, from better-auth 1.7.1's - * own varchar(255) emission) and this column follows it, as a referencing - * column takes the referenced column's bound. 255 ≤ 768, so the #11701 - * rule below is still satisfied — it is the same disposition at a sourced - * number, not a different one. - * - * The pin below is the executable form of "the class is closed": it does not - * name those two, it enumerates the whole package, so a THIRD member arriving - * later fails here rather than being found on a live MySQL months on. + * #11701 — a NON-UNIQUE declared index over a text column MySQL cannot key. + * + * ## What used to be here, and where it went (#12147) + * + * This file also carried route A's own rule — "every text-family column a + * declared index keys on declares a `maxLength`" (#11374) — enumerated over + * this package's exports, with a vacuity control, an `UNBOUNDABLE` allowlist + * and a synthetic control driving that allowlist's two branches. All of it is + * now `scripts/check-keyed-text-bounds.mjs`, which walks EVERY `*.object.ts` in + * the repository rather than one package's export surface. + * + * That is not a like-for-like move, and the difference is the reason for it. + * This pin enumerated `Object.values(PlatformObjects)`, so its population was + * whatever the barrel re-exports — 95 keyed text columns, measured. The gate's + * population over the same objects is 97: `sys_metadata_commit.package_id` and + * `sys_metadata_commit.parent_commit_id` were invisible here, because + * `metadata/index.ts` is a HAND-WRITTEN back-compat re-export naming four + * objects and `sys_metadata_commit` was never added to it. Both columns are + * bounded today, so nothing was broken — but nothing in the tree was watching + * them either, which is the same escape-by-boundary this pin was itself widened + * to close once before (`identity/` → the package, after + * `sys_import_job.created_by` slipped through). + * + * ## Why THIS half stays + * + * It is a different rule with a different disposition, not a narrower copy of + * the one that moved. Route A asks "is there a bound?"; this asks "is the + * declared bound small enough to be a key?" — and answers it only for + * NON-UNIQUE indexes, because a UNIQUE index over an unkeyable column is + * EXPRESSIBLE after #11627 (it moves onto a SHA-256 hash-shadow column) while a + * non-unique one is not: hashing destroys the ordering and prefix structure an + * access path is for, so there is no fallback and the column itself must be + * keyable. `sys_account.issuer` (bounded at 2048) is the live illustration that + * the two rules are independent — it passes the gate and is out of this + * describe's scope because its index is unique. + * + * The gate deliberately does not fold this in; its header says so. */ const TEXT_FAMILY = new Set(['text', 'textarea', 'html', 'markdown']); -/** - * Keyed text-family columns with NO defensible bound. Every entry must name - * why. Entries that stop matching a real keyed unbounded column fail the - * fourth test, so the list cannot rot. - * - * ⚠️ EMPTY since #11701 — and empty here is a RESULT, not a default. The list - * held exactly one entry, `sys_verification.value`, allowlisted because - * better-auth's oauth-provider writes OIDC authorization-code payloads there as - * a JSON blob and no bound provably admits all of them. That entry was written - * to explain why the column could not be BOUNDED, and the maintainer's - * 2026-08-25 ruling did not bound it — it removed the column's declared INDEX, - * on measured liveness. An unindexed column is not a keyed column, so the entry - * stopped describing anything real and moved with the change rather than being - * left to rot. (The fourth test enforces exactly that: it is what would have - * gone red had the entry been left behind.) - * - * ⚠️ Before adding an entry: an unboundable column may only be keyed by a - * UNIQUE index, which #11627 carries on a hash shadow. A NON-UNIQUE index over - * an unboundable column is not "debt" — it is unfixable, and the #11701 - * `describe` below rejects it. - */ -const UNBOUNDABLE: ReadonlySet = new Set([]); - /** * MySQL's utf8mb4 key-part ceiling, in CHARACTERS: 768 × 4 = 3072 bytes, the * whole key-part budget. A declared bound at or under this makes `driver-sql` @@ -143,106 +70,6 @@ const platformObjects: AnyObject[] = Object.values(PlatformObjects) !!v.fields, ); -function keyedTextColumns(o: AnyObject): Array<{ column: string; maxLength: unknown }> { - const keyed = new Set(); - for (const ix of o.indexes ?? []) for (const f of ix.fields ?? []) keyed.add(f); - return Object.entries(o.fields) - .filter(([name, def]) => keyed.has(name) && TEXT_FAMILY.has(def?.type ?? '')) - .map(([column, def]) => ({ column: `${o.name}.${column}`, maxLength: def.maxLength })); -} - -/** - * The rule the third test enforces, as a pure function of (objects, allowlist). - * - * Extracted rather than inlined because #11701 emptied `UNBOUNDABLE`: with the - * allowlist empty, the `allowlist.has(column)` branch is never taken against the - * real objects, so it would sit unexecuted and free to rot until the next agent - * needed it. The synthetic control below drives both of its outcomes. - */ -function unboundedKeyedColumns(objects: AnyObject[], allowlist: ReadonlySet): string[] { - const offenders: string[] = []; - for (const o of objects) { - for (const { column, maxLength } of keyedTextColumns(o)) { - if (allowlist.has(column)) continue; - const bounded = typeof maxLength === 'number' && Number.isInteger(maxLength) && maxLength > 0; - if (!bounded) offenders.push(`${column} (maxLength: ${String(maxLength)})`); - } - } - return offenders; -} - -describe('platform keyed text-family columns declare their bound (#11374)', () => { - it('enumerates a real surface — the probe itself is not vacuous', () => { - // Positive control: if the export shape or field/index spelling changes so - // this file stops seeing columns, fail loudly instead of passing empty. - const all = platformObjects.flatMap(keyedTextColumns); - expect(platformObjects.length).toBeGreaterThanOrEqual(40); - expect(all.length).toBeGreaterThanOrEqual(70); - expect(all.map((c) => c.column)).toContain('sys_session.token'); - }); - - it('reaches beyond identity/ — the scoping that let a keyed column escape', () => { - // The specific regression control for this file's own history: while it - // lived in `identity/` it enumerated only that directory, and - // `sys_import_job.created_by` (audit/) went unbounded through route A's - // first pass. These two names are in DIFFERENT source directories, so a - // future re-narrowing of the import fails here by name rather than by - // quietly enumerating less. - const columns = platformObjects.flatMap(keyedTextColumns).map((c) => c.column); - expect(columns).toContain('sys_import_job.created_by'); // audit/ - expect(columns).toContain('sys_metadata.name'); // metadata/ - expect(columns).toContain('sys_setting.key'); // system/ - }); - - it('every keyed text-family column declares a positive integer maxLength, or is allowlisted by name', () => { - const offenders = unboundedKeyedColumns(platformObjects, UNBOUNDABLE); - expect( - offenders, - `keyed text-family column(s) without a declared maxLength — on MySQL their ` + - `declared index cannot be created and the object lands registered-but-broken. ` + - `Declare a sourced bound or extend UNBOUNDABLE with a named reason: ` + - offenders.join(', '), - ).toEqual([]); - }); - - it('the UNBOUNDABLE allowlist matches only real, still-unbounded keyed columns', () => { - const real = new Map( - platformObjects.flatMap(keyedTextColumns).map((c) => [c.column, c.maxLength]), - ); - for (const entry of UNBOUNDABLE) { - expect(real.has(entry), `allowlist entry ${entry} is not a keyed text column any more — remove it`).toBe(true); - expect( - real.get(entry), - `allowlist entry ${entry} now declares a bound — remove it from UNBOUNDABLE`, - ).toBeUndefined(); - } - }); - - /** - * ⚠️ The control that keeps the test above honest now that #11701 emptied the - * allowlist. An empty `for` loop passes, so with a real-objects-only check the - * excusing branch of the rule would be dead code that nobody notices rotting. - * This drives BOTH outcomes on a synthetic object, so the mechanism a future - * unboundable column will rely on is proven to work while the list is empty. - */ - it('the allowlist mechanism still excuses and still accuses — driven on a synthetic object', () => { - const synthetic: AnyObject[] = [ - { - name: 'sys_probe', - fields: { blob: { type: 'text' } }, - indexes: [{ fields: ['blob'], unique: true }], - }, - ]; - // Keyed + unbounded, excused by nothing → an offender, named with its value. - expect(unboundedKeyedColumns(synthetic, new Set())).toEqual([ - 'sys_probe.blob (maxLength: undefined)', - ]); - // …and named in the allowlist → excused. The branch the real objects no - // longer reach. - expect(unboundedKeyedColumns(synthetic, new Set(['sys_probe.blob']))).toEqual([]); - }); -}); - /** * #11701 — a NON-UNIQUE index over a text column MySQL cannot key. * diff --git a/packages/plugins/plugin-audit/src/plugin-keyed-text-bounds.test.ts b/packages/plugins/plugin-audit/src/plugin-keyed-text-bounds.test.ts index dc79d6f576..03e527efc8 100644 --- a/packages/plugins/plugin-audit/src/plugin-keyed-text-bounds.test.ts +++ b/packages/plugins/plugin-audit/src/plugin-keyed-text-bounds.test.ts @@ -4,61 +4,50 @@ import { describe, it, expect } from 'vitest'; import { AuditPlugin } from './audit-plugin.js'; /** - * #11374 route A, for the objects THIS PLUGIN registers — every text-family - * column a declared index keys on must declare a `maxLength`, because a bound - * is what lets the column be a key at all. + * The ActivityPointer id columns carry the REFERENCED column's bound. * - * ## Why a second copy of the pin lives here + * ## What used to be here, and where it went (#12147) * - * The original pin is `@objectstack/platform-objects`' - * `platform-keyed-text-bounds.test.ts`, and it enumerates the objects THAT - * package exports. Platform objects that moved out to plugins under ADR-0029 K2 - * are outside it by construction, which is exactly how `sys_activity.record_id` - * and `sys_audit_log.record_id` stayed unbounded through route A's sweep: the - * pin could not see them, so nothing failed by name. + * This file carried route A's rule — "every text-family column a declared index + * keys on declares a `maxLength`" (#11374) — enumerated over the objects this + * plugin registers, with a vacuity control and an `UNBOUNDABLE` allowlist. That + * is now `scripts/check-keyed-text-bounds.mjs`, a source scan over EVERY + * `*.object.ts` in the repository. * - * That is the same failure the platform pin already survived once at a smaller - * scale (it used to be scoped to `identity/`, and `sys_import_job.created_by` - * in `audit/` escaped it). A pin scoped to a package polices a package, not the - * defect class. The class-level repair — one walk over every package that ships - * platform objects — is engine-lane work tracked separately; until it lands, - * each shipping package carries its own copy so no keyed column is unpoliced. + * The duplication was never the design. This copy existed because + * `@objectstack/platform-objects`' pin enumerates that package's exports and + * cannot reach a plugin's objects — this package's `package.json` declares only + * the `.` export and the root barrel does not re-export `./objects`, and making + * it importable would invert the dependency graph (measured on PR #12143: + * `platform-objects` depends only on `metadata-core` + `spec`, while this + * plugin depends on `platform-objects`). So each shipping package carried its + * own copy until a class-level instrument existed. It exists now. * - * ## Why it drives `init()` instead of importing the objects + * Coverage was measured before this half was removed, not assumed: driving + * `init()` the way this file does enumerates 5 keyed text-family columns + * (`sys_audit_log.{object_name,record_id}`, `sys_activity.{object_name, + * record_id}`, `sys_comment.thread_id`); the gate's population over + * `packages/plugins/plugin-audit` is the same 5, with 0 columns missed in + * either direction. * - * This package's `package.json` declares only the `.` export and the root - * barrel does not re-export `./objects`, so nothing outside the package can - * import `SysActivity` at all — which is why the objects were never measured - * live. Enumerating a hand-written list here would reproduce that blind spot in - * miniature: the list, not the plugin, would define the surface. So the pin - * drives the REAL registration path (`AuditPlugin.init` → the `manifest` - * service's `register({ objects })`) and polices whatever the plugin actually - * contributes to a kernel. An object added to that call is policed the moment - * it is added, with no second edit here. + * ## Why THIS half stays * - * ## What a red on this file means + * The gate asks whether a bound EXISTS. It cannot ask whether the bound is the + * RIGHT ONE, because "right" here is a relation to another column rather than a + * property of this one — and that relation is exactly what a later edit breaks + * without noticing. 255 is the width of the physical `id` column `driver-sql` + * creates (`table.string('id').primary()`, knex's varchar(255) — the driver + * spells it `DEFAULT_STRING_VARCHAR_CHARS`), so a column holding a record id is + * bounded by transitivity from the id itself. Pinned by VALUE because a later + * edit that "tidies" one of these to a narrower sibling convention (100, as + * plugin-sharing and plugin-approvals chose) would silently make the column + * unable to hold ids that the id column itself accepts — and would sail through + * the gate, which sees a positive integer and stops there. * - * A new keyed text-family field arrived without a `maxLength`. Do not silence - * it — derive a bound from the value's producer and declare it (route A's - * shape: a NAMED producer, stated in the declaration so it is vetoable in - * review), or extend the allowlist with a comment naming why no bound exists - * and where the keyability debt is tracked. - * - * On MySQL the cost of a red is not theoretical: the unbounded column is - * emitted `TEXT`, `ALTER TABLE … ADD INDEX` is refused with - * `ER_BLOB_KEY_WITHOUT_LENGTH`, and the object lands registered-but-broken with - * its declared index silently absent. - */ - -const TEXT_FAMILY = new Set(['text', 'textarea', 'html', 'markdown']); - -/** - * Keyed text-family columns with NO defensible bound. Every entry must name - * why. Entries that stop matching a real keyed unbounded column fail the last - * test, so the list cannot rot. Empty today, deliberately: all four of this - * plugin's keyed text columns have a sourced bound. + * This test is its own vacuity control: driven through the plugin's REAL + * registration path, an `init()` that stops registering objects leaves + * `byName.get(...)` undefined, and `undefined` is not 255. */ -const UNBOUNDABLE: ReadonlySet = new Set([]); type AnyObject = { name: string; @@ -68,7 +57,8 @@ type AnyObject = { /** * The objects `AuditPlugin` really contributes to a kernel, read off the - * manifest registration it performs in `init()`. + * manifest registration it performs in `init()` — so an object added to that + * call is covered the moment it is added, with no second edit here. */ async function registeredObjects(): Promise { const captured: AnyObject[] = []; @@ -97,82 +87,10 @@ async function registeredObjects(): Promise { return captured; } -function keyedTextColumns(o: AnyObject): Array<{ column: string; maxLength: unknown }> { - const keyed = new Set(); - for (const ix of o.indexes ?? []) for (const f of ix.fields ?? []) keyed.add(f); - return Object.entries(o.fields ?? {}) - .filter(([name, def]) => keyed.has(name) && TEXT_FAMILY.has(def?.type ?? '')) - .map(([column, def]) => ({ column: `${o.name}.${column}`, maxLength: def.maxLength })); -} - -describe('plugin-audit keyed text-family columns declare their bound (#11374 route A)', () => { - it('enumerates a real surface through the plugin registration path — the probe is not vacuous', async () => { - // Positive control: if `init()` stops registering objects, or the field / - // index spelling changes so this file stops seeing columns, fail loudly - // instead of passing empty. An empty enumeration is the failure mode that - // let these columns escape route A in the first place. - const objects = await registeredObjects(); - expect(objects.map((o) => o.name)).toEqual( - expect.arrayContaining(['sys_audit_log', 'sys_activity', 'sys_comment']), - ); - - // 5 is MEASURED off this registration surface, not a round number: - // sys_audit_log.{object_name,record_id}, sys_activity.{object_name,record_id}, - // sys_comment.thread_id. Every other index on these three objects keys on a - // lookup, select or datetime column, which is not text-family. - const all = objects.flatMap(keyedTextColumns); - expect(all.length).toBeGreaterThanOrEqual(5); - // Three names from THREE DIFFERENT objects, so a future narrowing of the - // enumeration fails here by name rather than by quietly enumerating less. - expect(all.map((c) => c.column)).toContain('sys_activity.record_id'); - expect(all.map((c) => c.column)).toContain('sys_audit_log.record_id'); - expect(all.map((c) => c.column)).toContain('sys_comment.thread_id'); - }); - - it('every keyed text-family column declares a positive integer maxLength, or is allowlisted by name', async () => { - const objects = await registeredObjects(); - const offenders: string[] = []; - for (const o of objects) { - for (const { column, maxLength } of keyedTextColumns(o)) { - if (UNBOUNDABLE.has(column)) continue; - const bounded = - typeof maxLength === 'number' && Number.isInteger(maxLength) && maxLength > 0; - if (!bounded) offenders.push(`${column} (maxLength: ${String(maxLength)})`); - } - } - expect( - offenders, - `keyed text-family column(s) without a declared maxLength — on MySQL their ` + - `declared index cannot be created and the object lands registered-but-broken. ` + - `Declare a sourced bound or extend UNBOUNDABLE with a named reason: ` + - offenders.join(', '), - ).toEqual([]); - }); - +describe('plugin-audit ActivityPointer bounds are the referenced column\'s (#11374 route A)', () => { it('the two ActivityPointer id columns carry the referenced-column bound, not just any bound', async () => { - // The bound is not free-floating: 255 is the width of the physical `id` - // column `driver-sql` creates (`table.string('id').primary()`, knex's - // varchar(255) — the driver spells it `DEFAULT_STRING_VARCHAR_CHARS`), so a - // column holding a record id is bounded by transitivity from the id itself. - // Pinned by VALUE because a later edit that "tidies" one of these to a - // narrower sibling convention (100, as plugin-sharing and plugin-approvals - // chose) would silently make the column unable to hold ids that the id - // column itself accepts. const byName = new Map((await registeredObjects()).map((o) => [o.name, o])); expect(byName.get('sys_activity')?.fields.record_id?.maxLength).toBe(255); expect(byName.get('sys_audit_log')?.fields.record_id?.maxLength).toBe(255); }); - - it('the UNBOUNDABLE allowlist matches only real, still-unbounded keyed columns', async () => { - const real = new Map( - (await registeredObjects()).flatMap(keyedTextColumns).map((c) => [c.column, c.maxLength]), - ); - for (const entry of UNBOUNDABLE) { - expect(real.has(entry), `allowlist entry ${entry} is not a keyed text column any more — remove it`).toBe(true); - expect( - real.get(entry), - `allowlist entry ${entry} now declares a bound — remove it from UNBOUNDABLE`, - ).toBeUndefined(); - } - }); }); diff --git a/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts b/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts index 14f29485ab..1a94d3e7f9 100644 --- a/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts +++ b/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts @@ -4,33 +4,34 @@ import { describe, it, expect } from 'vitest'; import { SecurityPlugin } from './security-plugin.js'; /** - * #11374 route A, for the objects THIS PLUGIN registers — every text-family - * column a declared index keys on must declare a `maxLength`, because a bound - * is what lets the column be a key at all. + * The suggestion key columns carry their REFERENCED columns' bounds, and the + * composite key stays expressible on MySQL. * - * The rationale, the MySQL mechanism (`ER_BLOB_KEY_WITHOUT_LENGTH` on an - * unbounded keyed TEXT column, object registered-but-broken with its declared - * index silently absent) and the reason a package-scoped pin cannot police the - * defect class are written once, next to this file's sibling in - * `@objectstack/plugin-audit` (`plugin-keyed-text-bounds.test.ts`). This copy - * exists for the same reason that one does: `@objectstack/platform-objects`' - * pin enumerates only that package's exports, and `sys_audience_binding_ - * suggestion` moved out to this plugin under ADR-0029 K2 — so its keyed - * `(package_id, permission_set_name, anchor)` unique index was unpoliced. + * ## What used to be here, and where it went (#12147) * - * It drives `SecurityPlugin.init()` rather than importing the object list, so - * the plugin's OWN registration path defines the surface: an object added to - * the manifest is policed the moment it is added, with no second edit here. - */ - -const TEXT_FAMILY = new Set(['text', 'textarea', 'html', 'markdown']); - -/** - * Keyed text-family columns with NO defensible bound. Every entry must name - * why. Entries that stop matching a real keyed unbounded column fail the last - * test, so the list cannot rot. Empty today, deliberately. + * This file carried route A's rule — "every text-family column a declared index + * keys on declares a `maxLength`" (#11374) — enumerated over the objects this + * plugin registers, with a vacuity control and an `UNBOUNDABLE` allowlist. That + * is now `scripts/check-keyed-text-bounds.mjs`, a source scan over EVERY + * `*.object.ts` in the repository. The rationale for the move, and why a + * central importing pin was not available, is written once next to this file's + * sibling in `@objectstack/plugin-audit`. + * + * Coverage was measured before this half was removed, not assumed: driving + * `init()` the way this file does enumerates 8 keyed text-family columns; the + * gate's population over `packages/plugins/plugin-security` is the same 8, with + * 0 columns missed in either direction. + * + * ## Why THIS half stays + * + * Both remaining assertions are about a bound's VALUE, and the gate only asks + * whether a bound exists. That is not a gap in the gate — a bound's correctness + * here is a RELATION to another column, and the relation is the point: if + * either referenced column is ever widened, this is where the transitive bound + * is re-derived rather than rediscovered on a MySQL deployment. A gate that + * checked values would have to know which column references which, which is + * exactly the knowledge that belongs beside the objects. */ -const UNBOUNDABLE: ReadonlySet = new Set([]); type AnyObject = { name: string; @@ -40,7 +41,8 @@ type AnyObject = { /** * The objects `SecurityPlugin` really contributes to a kernel, read off the - * manifest registration it performs in `init()`. + * manifest registration it performs in `init()` — so an object added to that + * call is covered the moment it is added, with no second edit here. */ async function registeredObjects(): Promise { const captured: AnyObject[] = []; @@ -62,54 +64,7 @@ async function registeredObjects(): Promise { return captured; } -function keyedTextColumns(o: AnyObject): Array<{ column: string; maxLength: unknown }> { - const keyed = new Set(); - for (const ix of o.indexes ?? []) for (const f of ix.fields ?? []) keyed.add(f); - return Object.entries(o.fields ?? {}) - .filter(([name, def]) => keyed.has(name) && TEXT_FAMILY.has(def?.type ?? '')) - .map(([column, def]) => ({ column: `${o.name}.${column}`, maxLength: def.maxLength })); -} - -describe('plugin-security keyed text-family columns declare their bound (#11374 route A)', () => { - it('enumerates a real surface through the plugin registration path — the probe is not vacuous', async () => { - const objects = await registeredObjects(); - expect(objects.map((o) => o.name)).toEqual( - expect.arrayContaining([ - 'sys_permission_set', - 'sys_position', - 'sys_capability', - 'sys_audience_binding_suggestion', - ]), - ); - - const all = objects.flatMap(keyedTextColumns); - expect(all.length).toBeGreaterThanOrEqual(8); - // Two names from DIFFERENT objects, so a future narrowing of the - // enumeration fails here by name rather than by enumerating less. - expect(all.map((c) => c.column)).toContain('sys_audience_binding_suggestion.package_id'); - expect(all.map((c) => c.column)).toContain('sys_permission_set.name'); - }); - - it('every keyed text-family column declares a positive integer maxLength, or is allowlisted by name', async () => { - const objects = await registeredObjects(); - const offenders: string[] = []; - for (const o of objects) { - for (const { column, maxLength } of keyedTextColumns(o)) { - if (UNBOUNDABLE.has(column)) continue; - const bounded = - typeof maxLength === 'number' && Number.isInteger(maxLength) && maxLength > 0; - if (!bounded) offenders.push(`${column} (maxLength: ${String(maxLength)})`); - } - } - expect( - offenders, - `keyed text-family column(s) without a declared maxLength — on MySQL their ` + - `declared index cannot be created and the object lands registered-but-broken. ` + - `Declare a sourced bound or extend UNBOUNDABLE with a named reason: ` + - offenders.join(', '), - ).toEqual([]); - }); - +describe('plugin-security suggestion key bounds are their referenced columns\' (#11374 route A)', () => { it('the suggestion key columns carry their referenced-column bounds, not just any bound', async () => { // Pinned by VALUE, and by the RELATION that sources each value: // package_id 255 — the width every landed same-class column @@ -119,9 +74,6 @@ describe('plugin-security keyed text-family columns declare their bound (#11374 // permission_set_name 100 — the width of `sys_permission_set.name`, the // column this value must resolve against at // confirm time. - // The relation is the point: if either referenced column is ever widened, - // this pin is where the transitive bound is re-derived rather than - // rediscovered on a MySQL deployment. const byName = new Map((await registeredObjects()).map((o) => [o.name, o])); const suggestion = byName.get('sys_audience_binding_suggestion'); const permissionSet = byName.get('sys_permission_set'); @@ -143,24 +95,17 @@ describe('plugin-security keyed text-family columns declare their bound (#11374 // must sum to <= 768 characters. 255 + 100 leaves ample room for the // `anchor` select column, which the driver emits at its default width. const byName = new Map((await registeredObjects()).map((o) => [o.name, o])); + // The vacuity control for THIS test specifically: with no registration the + // sum below is 0 + 255, which passes a ceiling check while measuring + // nothing. The object has to be there for the sum to mean anything. + expect(byName.has('sys_audience_binding_suggestion')).toBe(true); + const fields = byName.get('sys_audience_binding_suggestion')?.fields ?? {}; const declared = Number(fields.package_id?.maxLength ?? 0) + Number(fields.permission_set_name?.maxLength ?? 0); + expect(declared).toBeGreaterThan(0); // 255 (anchor's default varchar width) is charged too — the select column // is part of the same key. expect(declared + 255).toBeLessThanOrEqual(768); }); - - it('the UNBOUNDABLE allowlist matches only real, still-unbounded keyed columns', async () => { - const real = new Map( - (await registeredObjects()).flatMap(keyedTextColumns).map((c) => [c.column, c.maxLength]), - ); - for (const entry of UNBOUNDABLE) { - expect(real.has(entry), `allowlist entry ${entry} is not a keyed text column any more — remove it`).toBe(true); - expect( - real.get(entry), - `allowlist entry ${entry} now declares a bound — remove it from UNBOUNDABLE`, - ).toBeUndefined(); - } - }); }); diff --git a/scripts/check-keyed-text-bounds.mjs b/scripts/check-keyed-text-bounds.mjs new file mode 100644 index 0000000000..a89f0ce7c2 --- /dev/null +++ b/scripts/check-keyed-text-bounds.mjs @@ -0,0 +1,1301 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-keyed-text-bounds (#12147) -- a text-family column that a DECLARED + * INDEX keys on must declare a `maxLength`. + * + * node scripts/check-keyed-text-bounds.mjs # judge the tree + * node scripts/check-keyed-text-bounds.mjs --list # the full sweep, per object + * node scripts/check-keyed-text-bounds.mjs --self-test # prove the detector can go red + * + * ## The defect class, and why it is not a per-package pin's to hold + * + * `driver-sql` emits a keyed text-family column as `varchar(maxLength)` when + * the field declares a bound the dialect can key on, and leaves it `TEXT` + * otherwise. MySQL refuses a TEXT/BLOB column in a key without a prefix length + * (`ER_BLOB_KEY_WITHOUT_LENGTH`), so an unbounded keyed text column means: + * `CREATE TABLE` succeeds, `ALTER TABLE ... ADD [UNIQUE] INDEX` fails, and the + * object lands REGISTERED-BUT-BROKEN with its declared index silently absent. + * Measured live on MySQL 8.0.46 (#12058): 12 of 44 platform objects failed + * schema-sync this way, `sys_session` and `sys_account` among them -- a MySQL + * stack could not sign anyone in. The driver deliberately does not substitute a + * prefix index (a prefix-UNIQUE index is stricter-and-different: measured + * `ER_DUP_ENTRY` on a second, genuinely distinct token sharing 191 characters), + * so the bound has to live in the field declaration. That is route A, the + * maintainer's 2026-08-24 ruling on #11374. + * + * Enforcement then widened three times and stopped at a boundary each time: + * `identity/` -> all of `platform-objects` (#12058) -> per-plugin pins in + * `plugin-audit` / `plugin-security` (#12143). Every widening was triggered by + * a column that ESCAPED the previous scope -- `sys_import_job.created_by` out + * of `identity/`, then `sys_activity.record_id` and `sys_audit_log.record_id` + * out of `platform-objects` when ADR-0029 K2 moved their objects into plugins. + * A pin scoped to a package polices a package, not the defect class, and + * objects keep moving across package boundaries. + * + * ## Why a SOURCE SCAN rather than one central importing pin + * + * Measured on PR #12143, not argued: a central pin cannot import the plugins' + * objects. Each plugin's `package.json` declares only the `.` export and the + * root barrel does not re-export `./objects`; making it importable would invert + * the dependency graph, because `platform-objects` depends only on + * `metadata-core` + `spec` while both plugins depend on `platform-objects`. A + * pin there importing the plugins is a cycle. Two alternatives were measured + * and disrecommended on that PR: exporting `./objects` from every plugin widens + * published surfaces to serve a test, and a leaf conformance package grows a + * dependency edge per plugin forever. + * + * So this is the idiom the repo already blesses for dependency-free detection + * -- "a detector with no dependencies cannot itself fail to resolve in CI", + * per `check:cross-package-test-inputs`. Node builtins plus the shared comment + * mask, nothing else: it runs against an empty `node_modules`, so a reviewer + * can run it in place and CI cannot fail it for the wrong reason. + * + * ## The price of a source scan, and the two things that pay it + * + * A source scan sees only the spellings it knows, and an unrecognised one + * produces no finding -- SILENTLY. That is the same failure the pins it + * replaces were written against, one layer down. Two mechanisms pay for it, + * and both fail LOUD rather than empty: + * + * 1. **Refusal, not omission.** Every shape this file cannot classify is an + * `exit 2` refusal naming the file, the line and the shape -- an unknown + * `Field.`, a field whose `type` is not a string literal, a + * `maxLength` that is not a literal, an index entry that is not an object + * literal, an index keying a column the object does not declare. The + * refusals are only raised for columns a declared index actually KEYS, so + * an unrelated authoring style elsewhere in the object costs nothing. + * `--list` prints every unclassified-but-unkeyed field, so the population + * that is being tolerated is visible rather than assumed. + * + * 2. **Vacuity floors.** A sweep that finds nothing because it swept nothing + * reports exactly what a clean tree reports. Four counts have floors -- + * object files walked, object declarations parsed, index entries read, + * text-family fields seen -- and the fifth, the KEYED text columns that are + * the judged population, has the strictest one. Below any floor the gate + * refuses (`exit 2`) instead of passing. + * + * ## "text-family" is READ OFF THE EMITTER, not retyped here + * + * The three pins this gate supersedes each hard-coded + * `TEXT_FAMILY = {text, textarea, html, markdown}`. That set has been WRONG + * since #11794 and #11875: `driver-sql`'s own `varcharColumnChars` routes + * `richtext`, `code`, `signature` and `qrcode` through `keyableTextLength` too, + * so those four types have the identical MySQL failure and no pin in the tree + * could see them. Retyping the set here would reproduce that drift a fourth + * time, so it is EXTRACTED from `packages/drivers/driver-sql/src/sql-driver.ts` + * -- the `case` labels that fall into the `keyed ? this.keyableTextLength(...)` + * arm, with comments masked. `EXPECTED_TEXT_FAMILY` below is a witness, not the + * source: when the two disagree the gate REFUSES and names both sets, so a type + * joining or leaving the family is a decision someone makes here rather than a + * silent widening of what goes unpoliced. + * + * ## Scope: `maxLength` presence, deliberately -- not the >768 ceiling + * + * Route A's rule is "a keyed text-family column declares a bound". Whether a + * declared bound is small enough for MySQL's utf8mb4 key-part ceiling (768 + * characters) is a DIFFERENT class with a different disposition -- #11627 for + * the UNIQUE half (carried on a hash-shadow column) and #11701 for the + * non-unique half (no shadow is possible; the column must be keyable or the + * index must go). Those two are not folded in here, and the `platform-objects` + * pin's `#11701` describe stays exactly where it is for that reason. + * + * ## What a red means + * + * A keyed text-family field arrived without a `maxLength`. Do not silence it: + * derive a bound from the value's PRODUCER and declare it, naming the producer + * in the declaration so it is vetoable in review. If the value genuinely cannot + * be bounded, add an `ALLOWLIST` row naming why -- and read #11701 first, since + * an unboundable column may only be keyed by a UNIQUE index. + */ + +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; +import { blank, scanSource } from './js-comment-mask.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..'); + +// --------------------------------------------------------------------------- +// Declared vocabulary -- published here because a source scan that meets an +// unpublished spelling produces no finding, silently. + +/** + * `Field.` -> the `type` string the builder emits, read off + * `packages/spec/src/data/field.zod.ts`. Every entry is `name === type` except + * `masterDetail`, which emits `master_detail`. + * + * A builder NOT in this map, used on a column a declared index keys, is a + * refusal -- never a pass. Extend it (and add a `--self-test` case) rather than + * routing around it. + */ +const FIELD_BUILDER_TYPES = { + address: 'address', + autonumber: 'autonumber', + avatar: 'avatar', + boolean: 'boolean', + code: 'code', + color: 'color', + currency: 'currency', + date: 'date', + datetime: 'datetime', + email: 'email', + file: 'file', + formula: 'formula', + html: 'html', + image: 'image', + json: 'json', + location: 'location', + lookup: 'lookup', + markdown: 'markdown', + masterDetail: 'master_detail', + number: 'number', + password: 'password', + percent: 'percent', + phone: 'phone', + qrcode: 'qrcode', + rating: 'rating', + richtext: 'richtext', + secret: 'secret', + select: 'select', + signature: 'signature', + slider: 'slider', + summary: 'summary', + text: 'text', + textarea: 'textarea', + time: 'time', + url: 'url', + user: 'user', + vector: 'vector', +}; + +/** + * The text family, as a WITNESS. The authority is `driver-sql`'s own switch + * (see `readTextFamilyFromEmitter`); this is what the extraction must agree + * with, so a change on either side is reported rather than absorbed. + */ +const EXPECTED_TEXT_FAMILY = [ + 'code', 'html', 'markdown', 'qrcode', 'richtext', 'signature', 'text', 'textarea', +]; + +/** Where the emitter's answer is read from. */ +const EMITTER_FILE = join('packages', 'drivers', 'driver-sql', 'src', 'sql-driver.ts'); + +/** + * The anchor the extraction walks backwards from -- the arm of + * `varcharColumnChars`'s switch that a KEYED text-family column reaches. + */ +const EMITTER_ANCHOR = 'keyed ? this.keyableTextLength(field) : null'; + +/** + * Columns `driver-sql` creates itself, so an index may key them although the + * object never declares them. None is text-family: `id` is a + * `varchar(255)` primary key and the two timestamps are DATETIME columns + * (`createTable`'s built-ins, the same three `initObjects` skips when iterating + * `obj.fields`). An index naming anything else the object does not declare is a + * refusal, not a silent skip. + */ +const BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']); + +/** + * The per-package allowlist: keyed text-family columns this gate does not fail + * on, each with its package, its disposition and its reason. + * + * Two dispositions, and they are NOT interchangeable: + * + * `unboundable` -- no bound can be defended. The row must say what the value + * source is and why nothing admits it. ⚠️ Read #11701 first: an + * unboundable column may only be keyed by a UNIQUE index, because a + * UNIQUE index is the only kind #11627's hash shadow can carry. + * `pending` -- the column IS boundable and is not bounded yet. The row must + * cite the issue that will bound it. This is debt with a name on it, not + * an exemption. + * + * It is a ledger, not a wildcard, and it cannot rot in any direction. A row + * whose column stopped being a keyed text column FAILS; a row whose column has + * since been bounded FAILS; a row whose `pkg` does not match where the column + * actually lives FAILS; a `pending` row with no issue FAILS. And a column that + * is not enumerated here fails the gate outright -- so a NEW offender is caught + * whatever the ledger holds, which is the property that makes it safe to land + * with rows in it. + * + * `unboundable` is empty today, and empty is a RESULT: every keyed text column + * in the tree either declares a bound or is one of the 15 `pending` rows below. + * + * @type {ReadonlyArray<{ pkg: string, column: string, kind: 'unboundable' | 'pending', why: string, issue?: string }>} + */ +const ALLOWLIST = [ + // ── #12978 ─────────────────────────────────────────────────────────────── + // `packages/services/service-messaging` has never had a keyed-text-bounds pin + // -- the three that exist are scoped to `platform-objects`, `plugin-audit` + // and `plugin-security` -- so these 15 columns are the class's live members + // that no boundary-scoped pin could see. They are ledgered rather than fixed + // here because each bound needs a NAMED producer (route A's shape) and + // because declaring one moves the column TEXT -> varchar(n), a drift op that + // belongs to the services lane with a changeset. The full evidence, including + // which index each column keys, is on #12978. + // + // ⚠️ The sharpest of them: `sys_notification_delivery`'s + // `(notification_id, recipient_id, channel)` UNIQUE index is the outbox's + // dedup constraint, and on MySQL it does not exist at all today. + { pkg: 'packages/services/service-messaging', column: 'sys_notification_delivery.notification_id', kind: 'pending', issue: '#12978', why: 'FK to sys_notification.id; keys the UNIQUE dedup index' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_delivery.recipient_id', kind: 'pending', issue: '#12978', why: 'FK to sys_user.id; keys the UNIQUE dedup index' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_delivery.channel', kind: 'pending', issue: '#12978', why: 'open channel-id vocabulary; keys the UNIQUE dedup index' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_delivery.digest_key', kind: 'pending', issue: '#12978', why: 'derived recipient|channel|window key; bound follows its three parts' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_preference.user_id', kind: 'pending', issue: '#12978', why: 'FK to sys_user.id, or the literal * global default' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_preference.topic', kind: 'pending', issue: '#12978', why: 'open topic vocabulary, or the literal *' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_preference.channel', kind: 'pending', issue: '#12978', why: 'open channel-id vocabulary, or the literal *' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_receipt.notification_id', kind: 'pending', issue: '#12978', why: 'FK to sys_notification.id' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_receipt.user_id', kind: 'pending', issue: '#12978', why: 'FK to sys_user.id' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_receipt.channel', kind: 'pending', issue: '#12978', why: 'open channel-id vocabulary' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_subscription.topic', kind: 'pending', issue: '#12978', why: 'open topic vocabulary' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_subscription.principal', kind: 'pending', issue: '#12978', why: 'RecipientResolver.resolveOne() accepts an email-shaped value as well as a bare user id (#9807)' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_template.topic', kind: 'pending', issue: '#12978', why: 'open topic vocabulary' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_template.channel', kind: 'pending', issue: '#12978', why: 'open channel-id vocabulary' }, + { pkg: 'packages/services/service-messaging', column: 'sys_notification_template.locale', kind: 'pending', issue: '#12978', why: 'BCP-47 tag; sys_email_template.locale is bounded at 16' }, +]; + +const ALLOWLIST_KINDS = new Set(['unboundable', 'pending']); + +// --------------------------------------------------------------------------- +// Vacuity floors. Each is set just under the value measured on `fa5d137ab0`, +// so a walk or a matcher that collapses REFUSES instead of reporting the empty +// finding set that success also looks like. + +const MEASURED = { + files: 113, objects: 118, indexEntries: 255, textFields: 594, keyedTextColumns: 151, +}; +const MIN_FILES = 105; +const MIN_OBJECTS = 110; +const MIN_INDEX_ENTRIES = 235; +const MIN_TEXT_FIELDS = 550; +const MIN_KEYED_TEXT_COLUMNS = 140; + +const SKIP_DIRS = new Set([ + '.git', 'node_modules', 'dist', 'build', 'coverage', '.turbo', '.next', '.cache', '.changeset', +]); + +/** Where shipped object declarations are discovered. */ +const OBJECT_FILE_SUFFIX = '.object.ts'; + +/** + * The subtrees this gate's population lives in, declared so a dispatch brief + * can NAME it. Without this the gate is reachable only by its own script path, + * so a card editing an object file -- the population it judges -- would never + * be told to run it: the invisible-population species + * `scripts/pm/bare-root-worklist.mjs` exists to count. `hintCovers` refuses a + * separator-less literal as too generic, so the glob form is the reachable one. + * + * ⚠️ These are a DECLARATION, not the walk. The walk is the whole repository + * minus `SKIP_DIRS`, deliberately: a boundary-scoped sweep is the defect this + * gate exists to close, so narrowing the WALK to these roots would rebuild that + * hole one level up. `hintProblem` holds the two together from the other side + * -- an object file discovered OUTSIDE every hint is a refusal naming this + * constant -- so the declaration can never silently under-name the population + * while the sweep quietly keeps covering it. + */ +const ROOT_DIR_WATCH_HINTS = ['packages/**', 'apps/**', 'examples/**']; + +/** Object files the sweep found outside every declared watch hint. */ +function unhintedFiles(relPaths) { + const roots = ROOT_DIR_WATCH_HINTS.map((h) => h.replace(/\/\*+$/, '')); + return relPaths.filter((p) => !roots.some((r) => p === r || p.startsWith(`${r}/`))); +} + +// --------------------------------------------------------------------------- +// Source structure -- comments masked, string CONTENT blanked for structure + +/** + * Two projections of one source, both offset-preserving. + * + * `masked` has comment spans blanked and literals intact: it is what values are + * READ from. `struct` additionally has literal CONTENT blanked, delimiters + * kept: it is what brackets are COUNTED on, so a `{` inside a string cannot + * move the parse. + */ +function project(source) { + const { comment, literal } = scanSource(source); + const masked = blank(source, comment); + return { masked, struct: blank(masked, literal) }; +} + +const OPEN_TO_CLOSE = { '(': ')', '{': '}', '[': ']' }; + +/** Index of the bracket matching the one at `openIdx`, or -1. */ +function matchBracket(struct, openIdx) { + const open = struct[openIdx]; + const close = OPEN_TO_CLOSE[open]; + if (close === undefined) return -1; + let depth = 0; + for (let i = openIdx; i < struct.length; i += 1) { + if (struct[i] === open) depth += 1; + else if (struct[i] === close) { + depth -= 1; + if (depth === 0) return i; + } + } + return -1; +} + +/** Index of the quote closing the one at `i`, or -1. Content is blanked in `struct`. */ +function closingQuote(struct, i) { + const q = struct[i]; + for (let k = i + 1; k < struct.length; k += 1) if (struct[k] === q) return k; + return -1; +} + +/** Index of the depth-0 comma at or after `from`, else `end`. */ +function scanToComma(struct, from, end) { + let depth = 0; + for (let i = from; i < end; i += 1) { + const c = struct[i]; + if (c === '{' || c === '[' || c === '(') depth += 1; + else if (c === '}' || c === ']' || c === ')') depth -= 1; + else if (c === ',' && depth === 0) return i; + } + return end; +} + +/** + * Top-level entries of the object literal whose braces are `open`/`close`. + * An entry with no readable `key: value` shape (a spread, a shorthand, a + * computed key, a method) comes back with `key: null` so the caller decides. + */ +function objectEntries(struct, masked, open, close) { + const entries = []; + let i = open + 1; + while (i < close) { + const ch = struct[i]; + if (ch === ',' || /\s/.test(ch)) { i += 1; continue; } + + let key = null; + let after = i; + if (ch === "'" || ch === '"') { + const end = closingQuote(struct, i); + if (end < 0 || end >= close) return entries; + key = masked.slice(i + 1, end); + after = end + 1; + } else if (/[A-Za-z_$]/.test(ch)) { + let k = i; + while (k < close && /[\w$]/.test(struct[k])) k += 1; + key = struct.slice(i, k); + after = k; + } + + let colon = after; + while (colon < close && /\s/.test(struct[colon])) colon += 1; + if (key === null || struct[colon] !== ':') { + const stop = scanToComma(struct, i, close); + entries.push({ key: null, start: i, end: stop }); + i = stop + 1; + continue; + } + + let v = colon + 1; + while (v < close && /\s/.test(struct[v])) v += 1; + const stop = scanToComma(struct, v, close); + entries.push({ key, start: v, end: stop, keyAt: i }); + i = stop + 1; + } + return entries; +} + +/** Top-level element spans of the array literal whose brackets are `open`/`close`. */ +function arrayElements(struct, open, close) { + const out = []; + let i = open + 1; + while (i < close) { + const ch = struct[i]; + if (ch === ',' || /\s/.test(ch)) { i += 1; continue; } + const stop = scanToComma(struct, i, close); + out.push([i, stop]); + i = stop + 1; + } + return out; +} + +/** Trim whitespace off a `[start, end)` span, returning the tightened span. */ +function trimSpan(struct, start, end) { + let a = start; + let b = end; + while (a < b && /\s/.test(struct[a])) a += 1; + while (b > a && /\s/.test(struct[b - 1])) b -= 1; + return [a, b]; +} + +/** The value of a single string literal filling the span, else `null`. */ +function stringValue(struct, masked, start, end) { + const [a, b] = trimSpan(struct, start, end); + if (b - a < 2) return null; + const q = struct[a]; + if (q !== "'" && q !== '"') return null; + if (closingQuote(struct, a) !== b - 1) return null; + return masked.slice(a + 1, b - 1); +} + +const INTEGER_LITERAL = /^[0-9][0-9_]*$/; +/** A literal whose value is decidable without evaluating anything. */ +const SIMPLE_LITERAL = /^(?:-?[0-9][0-9_]*(?:\.[0-9]+)?|'\s*'|"\s*"|true|false|null|undefined)$/; + +/** + * How a declared `maxLength` reads. + * { kind: 'integer', value } a positive integer literal -- a bound + * { kind: 'literal', text } a literal that is NOT a positive integer + * { kind: 'opaque', text } an expression this file will not evaluate + */ +function readBound(struct, start, end) { + const [a, b] = trimSpan(struct, start, end); + const text = struct.slice(a, b); + if (INTEGER_LITERAL.test(text)) { + const value = Number(text.replace(/_/g, '')); + return value > 0 ? { kind: 'integer', value } : { kind: 'literal', text }; + } + if (SIMPLE_LITERAL.test(text)) return { kind: 'literal', text }; + return { kind: 'opaque', text }; +} + +function lineStarts(source) { + const starts = [0]; + for (let i = 0; i < source.length; i += 1) if (source[i] === '\n') starts.push(i + 1); + return starts; +} + +function lineAt(starts, offset) { + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= offset) lo = mid; else hi = mid - 1; + } + return lo + 1; +} + +// --------------------------------------------------------------------------- +// The emitter's own text family + +/** + * The `case` labels that reach `varcharColumnChars`' keyed-text arm, read off + * the driver with comments masked. Returns `{ family }` or `{ error }` -- an + * extraction that cannot find its anchor is a refusal, never an empty set. + */ +export function readTextFamilyFromEmitter(root) { + let source; + try { + source = readFileSync(join(root, EMITTER_FILE), 'utf8'); + } catch { + return { error: `cannot read ${EMITTER_FILE} -- the emitter this gate reads its text family from.` }; + } + const { struct, masked } = project(source); + const anchor = struct.indexOf(EMITTER_ANCHOR); + if (anchor < 0) { + return { + error: `could not find the keyed-text arm (\`${EMITTER_ANCHOR}\`) in ${EMITTER_FILE}.\n` + + 'The emitter decides which types go TEXT unless keyed AND bounded; without it this\n' + + 'gate would be judging a hand-retyped set, which is the drift it exists to prevent.', + }; + } + // Walk backwards over the consecutive `case '':` labels that share the arm. + const head = masked.slice(0, anchor); + const labels = []; + const re = /case\s*'([a-z_]+)'\s*:/g; + let m; + const spans = []; + while ((m = re.exec(head)) !== null) spans.push({ type: m[1], start: m.index, end: re.lastIndex }); + for (let i = spans.length - 1; i >= 0; i -= 1) { + const between = head.slice(spans[i].end, i + 1 < spans.length ? spans[i + 1].start : head.length); + // The LAST label is separated from the anchor by the arm's own `return`; + // between two labels of the SAME arm only whitespace may stand (comments + // are masked to spaces, and the real switch carries several). Anything else + // -- another `return`, a `break` -- means the previous arm already ended. + const ok = i + 1 < spans.length ? between.trim() === '' : /^\s*return\s*$/.test(between); + if (!ok) break; + labels.push(spans[i].type); + } + if (labels.length === 0) return { error: `no \`case\` labels precede the keyed-text arm in ${EMITTER_FILE}.` }; + return { family: labels.sort() }; +} + +// --------------------------------------------------------------------------- +// The sweep + +function walkObjectFiles(root) { + const found = []; + const stack = [root]; + while (stack.length > 0) { + const dir = stack.pop(); + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + if (e.isDirectory()) { + if (!SKIP_DIRS.has(e.name)) stack.push(join(dir, e.name)); + } else if (e.isFile() && e.name.endsWith(OBJECT_FILE_SUFFIX)) { + found.push(join(dir, e.name)); + } + } + } + return found.sort(); +} + +const CREATE_CALL = /ObjectSchema\s*\.\s*create\s*\(/g; + +/** + * Parse every object declaration in one file. + * + * @returns {{ objects: Array, refusals: Array }} + */ +function parseObjectFile(absPath, relPath, source, textFamily) { + const { masked, struct } = project(source); + const starts = lineStarts(source); + const refusals = []; + const objects = []; + const refuse = (offset, message) => refusals.push({ file: relPath, line: lineAt(starts, offset), message }); + + CREATE_CALL.lastIndex = 0; + let call; + while ((call = CREATE_CALL.exec(struct)) !== null) { + const parenOpen = CREATE_CALL.lastIndex - 1; + const parenClose = matchBracket(struct, parenOpen); + if (parenClose < 0) { refuse(parenOpen, 'unbalanced `ObjectSchema.create(` argument list'); continue; } + + let braceOpen = parenOpen + 1; + while (braceOpen < parenClose && /\s/.test(struct[braceOpen])) braceOpen += 1; + if (struct[braceOpen] !== '{') { + refuse(parenOpen, 'the `ObjectSchema.create(...)` argument is not an object literal, so its fields and indexes cannot be read'); + continue; + } + const braceClose = matchBracket(struct, braceOpen); + if (braceClose < 0 || braceClose > parenClose) { refuse(braceOpen, 'unbalanced object literal in `ObjectSchema.create(...)`'); continue; } + + const decl = objectEntries(struct, masked, braceOpen, braceClose); + const nameEntry = decl.find((e) => e.key === 'name'); + const fieldsEntry = decl.find((e) => e.key === 'fields'); + const indexesEntry = decl.find((e) => e.key === 'indexes'); + + const name = nameEntry === undefined ? null : stringValue(struct, masked, nameEntry.start, nameEntry.end); + if (name === null) { + refuse(braceOpen, 'object declaration has no `name:` string literal, so nothing it declares can be attributed'); + continue; + } + + // ── indexes ──────────────────────────────────────────────────────────── + /** @type {Array<{ columns: string[], unique: string, at: number }>} */ + const indexes = []; + if (indexesEntry !== undefined) { + const [ia, ib] = trimSpan(struct, indexesEntry.start, indexesEntry.end); + if (struct[ia] !== '[') { + refuse(ia, `\`${name}.indexes\` is not an array literal, so its keyed columns cannot be read`); + continue; + } + const bracketClose = matchBracket(struct, ia); + if (bracketClose < 0 || bracketClose >= ib) { refuse(ia, `unbalanced \`${name}.indexes\` array`); continue; } + let broken = false; + for (const [ea, eb] of arrayElements(struct, ia, bracketClose)) { + const [ta, tb] = trimSpan(struct, ea, eb); + if (struct[ta] !== '{') { + refuse(ta, `an entry of \`${name}.indexes\` is not an object literal (${struct.slice(ta, Math.min(tb, ta + 40)).trim()})`); + broken = true; + break; + } + const entryClose = matchBracket(struct, ta); + const props = objectEntries(struct, masked, ta, entryClose); + const fieldsProp = props.find((p) => p.key === 'fields'); + if (fieldsProp === undefined) continue; // an index over nothing keys nothing + const [fa] = trimSpan(struct, fieldsProp.start, fieldsProp.end); + if (struct[fa] !== '[') { + refuse(fa, `\`${name}.indexes[].fields\` is not an array literal, so its keyed columns cannot be read`); + broken = true; + break; + } + const fClose = matchBracket(struct, fa); + const columns = []; + for (const [ca, cb] of arrayElements(struct, fa, fClose)) { + const col = stringValue(struct, masked, ca, cb); + if (col === null) { + refuse(ca, `\`${name}.indexes[].fields\` holds a non-literal column name (${struct.slice(ca, Math.min(cb, ca + 40)).trim()})`); + broken = true; + break; + } + columns.push(col); + } + if (broken) break; + const uniqueProp = props.find((p) => p.key === 'unique'); + const unique = uniqueProp === undefined + ? 'absent' + : struct.slice(...trimSpan(struct, uniqueProp.start, uniqueProp.end)).trim(); + indexes.push({ columns, unique, at: ta }); + } + if (broken) continue; + } + + const keyed = new Map(); + for (const ix of indexes) for (const c of ix.columns) if (!keyed.has(c)) keyed.set(c, ix.at); + + // ── fields ───────────────────────────────────────────────────────────── + if (fieldsEntry === undefined) { + if (keyed.size > 0) { + refuse(braceOpen, `\`${name}\` declares indexes but no readable \`fields:\`, so the keyed columns cannot be typed`); + } + objects.push({ name, file: relPath, fields: [], indexes, keyedTextColumns: [], unclassifiedUnkeyed: [] }); + continue; + } + const [fa2] = trimSpan(struct, fieldsEntry.start, fieldsEntry.end); + if (struct[fa2] !== '{') { + refuse(fa2, `\`${name}.fields\` is not an object literal, so its column types cannot be read`); + continue; + } + const fieldsClose = matchBracket(struct, fa2); + const fieldEntries = objectEntries(struct, masked, fa2, fieldsClose); + + const fields = []; + const unclassifiedUnkeyed = []; + let broken = false; + for (const fe of fieldEntries) { + if (fe.key === null) { + // A spread or shorthand inside `fields:` hides columns entirely -- it is + // not attributable to one column, so it is always a refusal. + refuse(fe.start, `\`${name}.fields\` holds an entry this scan cannot attribute to a column (${struct.slice(fe.start, Math.min(fe.end, fe.start + 40)).trim()})`); + broken = true; + break; + } + const classified = classifyField(struct, masked, fe.start, fe.end); + const isKeyed = keyed.has(fe.key); + if (classified.error !== undefined) { + if (isKeyed) { + refuse(fe.keyAt, `\`${name}.${fe.key}\` is keyed by a declared index and ${classified.error}`); + broken = true; + break; + } + unclassifiedUnkeyed.push({ column: `${name}.${fe.key}`, why: classified.error }); + continue; + } + fields.push({ column: fe.key, type: classified.type, bound: classified.bound, at: fe.keyAt }); + } + if (broken) continue; + + const byName = new Map(fields.map((f) => [f.column, f])); + for (const [col, at] of keyed) { + if (byName.has(col) || BUILTIN_COLUMNS.has(col)) continue; + const excused = unclassifiedUnkeyed.some((u) => u.column === `${name}.${col}`); + refuse(at, excused + ? `\`${name}\` keys an index on \`${col}\`, whose declaration this scan cannot classify` + : `\`${name}\` keys an index on \`${col}\`, which the object does not declare and which is not a driver built-in (${[...BUILTIN_COLUMNS].join(', ')})`); + broken = true; + break; + } + if (broken) continue; + + const keyedTextColumns = fields + .filter((f) => keyed.has(f.column) && textFamily.has(f.type)) + .map((f) => ({ + column: `${name}.${f.column}`, + type: f.type, + bound: f.bound, + line: lineAt(starts, f.at), + })); + + objects.push({ name, file: relPath, fields, indexes, keyedTextColumns, unclassifiedUnkeyed }); + } + + if (objects.length === 0 && refusals.length === 0) { + refusals.push({ + file: relPath, + line: 1, + message: `no object declaration recognised in a \`${OBJECT_FILE_SUFFIX}\` file.\n` + + ' Either the declaration uses a spelling this scan does not know (extend it, with a\n' + + ' --self-test case), or the file should not carry that suffix. A silently empty parse\n' + + ' is the failure this gate exists to prevent.', + }); + } + return { objects, refusals }; +} + +/** + * The `type` and declared `maxLength` of one field declaration. + * @returns {{ type: string, bound: object } | { error: string }} + */ +function classifyField(struct, masked, start, end) { + const [a, b] = trimSpan(struct, start, end); + const head = struct.slice(a, Math.min(b, a + 64)); + + const builder = /^Field\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/.exec(head); + if (builder !== null) { + const name = builder[1]; + const type = FIELD_BUILDER_TYPES[name]; + if (type === undefined) { + return { error: `uses \`Field.${name}(...)\`, a builder this gate does not know (extend FIELD_BUILDER_TYPES)` }; + } + const parenOpen = a + builder[0].length - 1; + const parenClose = matchBracket(struct, parenOpen); + if (parenClose < 0 || parenClose > b) return { error: `has an unbalanced \`Field.${name}(\` argument list` }; + // `maxLength` lives in whichever argument is the config object literal -- + // `Field.code(language, config)` and `Field.select(options, config)` put it + // second, everything else first. + const bounds = []; + for (const [ea, eb] of arrayElements(struct, parenOpen, parenClose)) { + const [ta] = trimSpan(struct, ea, eb); + if (struct[ta] !== '{') continue; + const close = matchBracket(struct, ta); + if (close < 0) continue; + for (const p of objectEntries(struct, masked, ta, close)) { + if (p.key === 'maxLength') bounds.push(readBound(struct, p.start, p.end)); + } + } + if (bounds.length > 1) return { error: 'declares `maxLength` more than once' }; + const bound = bounds[0] ?? { kind: 'absent' }; + if (bound.kind === 'opaque') return { error: `declares \`maxLength: ${bound.text}\`, which is not a literal this gate can read` }; + return { type, bound }; + } + + if (struct[a] === '{') { + const close = matchBracket(struct, a); + if (close < 0 || close > b) return { error: 'has an unbalanced object literal' }; + const props = objectEntries(struct, masked, a, close); + const typeProp = props.find((p) => p.key === 'type'); + if (typeProp === undefined) return { error: 'is an object literal with no `type:` key' }; + const type = stringValue(struct, masked, typeProp.start, typeProp.end); + if (type === null) return { error: 'declares a `type:` that is not a string literal' }; + const maxProp = props.find((p) => p.key === 'maxLength'); + const bound = maxProp === undefined ? { kind: 'absent' } : readBound(struct, maxProp.start, maxProp.end); + if (bound.kind === 'opaque') return { error: `declares \`maxLength: ${bound.text}\`, which is not a literal this gate can read` }; + return { type, bound }; + } + + return { error: `is declared as \`${struct.slice(a, Math.min(b, a + 40)).trim()}\`, a shape this gate cannot classify` }; +} + +/** + * Walk `root`, parse every object file, and return the whole reading. + * Pure: no printing, no exits -- so `--self-test` can drive it over fixtures. + */ +export function sweep(root) { + const emitter = readTextFamilyFromEmitter(root); + if (emitter.error !== undefined) return { fatal: emitter.error }; + const textFamily = new Set(emitter.family); + + const files = walkObjectFiles(root); + const objects = []; + const refusals = []; + const relFiles = []; + for (const abs of files) { + const rel = relative(root, abs).split(sep).join('/'); + relFiles.push(rel); + const parsed = parseObjectFile(abs, rel, readFileSync(abs, 'utf8'), textFamily); + objects.push(...parsed.objects); + refusals.push(...parsed.refusals); + } + const unhinted = unhintedFiles(relFiles); + + const counts = { + files: files.length, + objects: objects.length, + indexEntries: objects.reduce((n, o) => n + o.indexes.length, 0), + textFields: objects.reduce((n, o) => n + o.fields.filter((f) => textFamily.has(f.type)).length, 0), + keyedTextColumns: objects.reduce((n, o) => n + o.keyedTextColumns.length, 0), + }; + + return { family: emitter.family, files, relFiles, unhinted, objects, refusals, counts }; +} + +/** + * The rule, as a pure function of (objects, allowlist) -- extracted rather than + * inlined because `ALLOWLIST` is empty, so the excusing branch is never taken + * against the real tree and would sit unexecuted and free to rot. The synthetic + * control in `--self-test` drives both of its outcomes. + */ +export function unboundedKeyedColumns(objects, allowlist) { + const excused = new Set(allowlist.map((r) => r.column)); + const offenders = []; + for (const o of objects) { + for (const c of o.keyedTextColumns) { + if (excused.has(c.column)) continue; + if (c.bound.kind === 'integer') continue; + const declared = c.bound.kind === 'absent' ? 'no maxLength' : `maxLength: ${c.bound.text}`; + offenders.push({ column: c.column, type: c.type, file: o.file, line: c.line, declared }); + } + } + return offenders; +} + +/** + * Allowlist rows that no longer describe a real, still-unbounded keyed column, + * or whose own shape has rotted. Checked in every direction a row can go wrong: + * the column disappeared, the column got bounded, the column moved package, the + * disposition is unspelled, a `pending` row lost the issue that owns it, or the + * reason is blank. + */ +export function staleAllowlistRows(objects, allowlist) { + const real = new Map(); + for (const o of objects) for (const c of o.keyedTextColumns) real.set(c.column, { bound: c.bound, pkg: packageOf(o.file) }); + const stale = []; + for (const row of allowlist) { + const hit = real.get(row.column); + if (hit === undefined) { + stale.push({ row, why: 'is not a keyed text-family column any more -- remove the row' }); + } else if (hit.bound.kind === 'integer') { + stale.push({ row, why: `now declares maxLength ${hit.bound.value} -- remove the row` }); + } else if (row.pkg !== hit.pkg) { + stale.push({ row, why: `now lives in ${hit.pkg}, not ${row.pkg} -- a per-package row must name where the column is` }); + } else if (!ALLOWLIST_KINDS.has(row.kind)) { + stale.push({ row, why: `declares kind '${row.kind}' -- must be one of ${[...ALLOWLIST_KINDS].join(', ')}` }); + } else if (row.kind === 'pending' && !/^#\d+$/.test(row.issue ?? '')) { + stale.push({ row, why: "is 'pending' but cites no issue -- debt with no name on it is an exemption" }); + } else if (!row.why || row.why.trim() === '') { + stale.push({ row, why: 'carries no stated reason' }); + } + } + return stale; +} + +// --------------------------------------------------------------------------- +// Reporting + +function refuse(message) { + console.error(`check:keyed-text-bounds: ${message}`); + return 2; +} + +const FLOORS = [ + ['files', MIN_FILES, `*${OBJECT_FILE_SUFFIX} file(s)`, + 'The walk broke. A sweep over an empty population reports exactly what a clean tree reports.'], + ['objects', MIN_OBJECTS, 'object declaration(s)', + 'The `ObjectSchema.create` matcher broke. Refusing to report clean over declarations nobody read.'], + ['indexEntries', MIN_INDEX_ENTRIES, 'declared index entr(ies)', + 'The index reader broke. With no indexes read, NOTHING is keyed and every column passes.'], + ['textFields', MIN_TEXT_FIELDS, 'text-family field(s)', + 'The field classifier or the emitter-derived family broke. With no text fields seen, nothing is judged.'], + ['keyedTextColumns', MIN_KEYED_TEXT_COLUMNS, 'keyed text-family column(s)', + 'This is the JUDGED population -- the intersection of declared indexes with text-family fields.\n' + + 'A dead intersection produces an empty finding set, and the empty set is what success looks like.'], +]; + +function floorProblem(counts) { + for (const [key, min, what, why] of FLOORS) { + const got = counts?.[key] ?? 0; + if (got >= min) continue; + return `discovered only ${got} ${what}, below the floor of ${min} (measured ${MEASURED[key]} on fa5d137ab0).\n${why}`; + } + return null; +} + +function familyProblem(family) { + const got = [...family].sort().join(', '); + const want = [...EXPECTED_TEXT_FAMILY].sort().join(', '); + if (got === want) return null; + return `the text family read off ${EMITTER_FILE} no longer matches this gate's witness.\n` + + ` emitter: ${got}\n` + + ` witness: ${want}\n` + + 'A type joining or leaving the keyed-text arm changes which columns need a declared bound.\n' + + 'Update EXPECTED_TEXT_FAMILY here, in the same PR that moved it, so the widening is a decision\n' + + 'rather than a silent change in what goes unpoliced.'; +} + +function main() { + const result = sweep(REPO_ROOT); + if (result.fatal !== undefined) return refuse(result.fatal); + + const family = familyProblem(result.family); + if (family !== null) return refuse(family); + + if (result.unhinted.length > 0) { + return refuse( + `${result.unhinted.length} object file(s) sit outside every declared watch hint ` + + `(${ROOT_DIR_WATCH_HINTS.join(', ')}):\n ${result.unhinted.join('\n ')}\n` + + 'The SWEEP covers them -- it walks the whole repository -- but no dispatch brief can NAME\n' + + 'this gate for a card that edits them, so the card is told to run everything except the one\n' + + 'gate that judges its diff. Add the subtree to ROOT_DIR_WATCH_HINTS in the same PR.', + ); + } + + if (result.refusals.length > 0) { + console.error(`check:keyed-text-bounds: ${result.refusals.length} declaration(s) this scan cannot classify\n`); + for (const r of result.refusals) console.error(` ${r.file}:${r.line}\n ${r.message}`); + console.error( + '\nA source scan sees only the spellings it knows, and an unrecognised one produces no finding,\n' + + 'silently -- which is the failure this gate exists to prevent. So an unclassifiable shape on a\n' + + 'KEYED column is a refusal, not a pass. Teach the scan the spelling (FIELD_BUILDER_TYPES, or the\n' + + 'parser) and add a --self-test case, or write the declaration in a shape it reads.', + ); + return 2; + } + + const floor = floorProblem(result.counts); + if (floor !== null) return refuse(floor); + + const offenders = unboundedKeyedColumns(result.objects, ALLOWLIST); + const stale = staleAllowlistRows(result.objects, ALLOWLIST); + + if (stale.length > 0) { + console.error(`✗ check:keyed-text-bounds: ${stale.length} stale ALLOWLIST row(s)\n`); + for (const s of stale) console.error(` • ${s.row.column} (${s.row.pkg}) ${s.why}`); + console.error(''); + } + + if (offenders.length > 0) { + console.error(`✗ check:keyed-text-bounds: ${offenders.length} unbounded keyed text-family column(s)\n`); + for (const o of offenders) { + console.error(` • ${o.column} [${o.type}] ${o.declared}`); + console.error(` ${o.file}:${o.line}`); + } + console.error( + '\nA text-family column a declared index keys on must declare a `maxLength` (route A, #11374).\n' + + 'Without one `driver-sql` emits it TEXT; MySQL then refuses `ALTER TABLE ... ADD INDEX` with\n' + + 'ER_BLOB_KEY_WITHOUT_LENGTH, and the object lands REGISTERED-BUT-BROKEN with its declared index\n' + + 'silently absent (measured live on MySQL 8.0.46, #12058).\n\n' + + 'Fix it one of three ways, per column:\n' + + ' 1. declare a bound derived from the value PRODUCER, and name the producer in the declaration;\n' + + ' 2. if nothing reads the column as a predicate, remove the index and say so;\n' + + ' 3. if the column is boundable but sourcing the bound is its own piece of work, add a\n' + + " `pending` ALLOWLIST row citing the issue that owns it -- debt with a name on it;\n" + + ' 4. if the value genuinely cannot be bounded, add an `unboundable` ALLOWLIST row with its\n' + + ' reason -- and read #11701 first: an unboundable column may only be keyed by a UNIQUE index.', + ); + } + + if (offenders.length > 0 || stale.length > 0) return 1; + + const unclassified = result.objects.reduce((n, o) => n + o.unclassifiedUnkeyed.length, 0); + const pending = ALLOWLIST.filter((r) => r.kind === 'pending').length; + const unboundable = ALLOWLIST.length - pending; + console.log( + `✓ check:keyed-text-bounds: ${result.counts.files} *${OBJECT_FILE_SUFFIX} files under ` + + `${ROOT_DIR_WATCH_HINTS.join(' + ')} (walk is repo-wide; 0 outside), ` + + `${result.counts.objects} object declarations, ${result.counts.indexEntries} declared index entries, ` + + `${result.counts.textFields} text-family fields; ${result.counts.keyedTextColumns} keyed text-family ` + + `columns judged, ${result.counts.keyedTextColumns - ALLOWLIST.length} bounded. ` + + `Family read off the emitter: ${result.family.join(', ')}. ` + + `Allowlist: ${pending} pending, ${unboundable} unboundable, all rows still real. ` + + `${unclassified} unclassified field(s), none of them keyed.`, + ); + return 0; +} + +function list() { + const result = sweep(REPO_ROOT); + if (result.fatal !== undefined) return refuse(result.fatal); + console.log(`text family (read off ${EMITTER_FILE}): ${result.family.join(', ')}`); + console.log(`files: ${result.counts.files} objects: ${result.counts.objects} index entries: ${result.counts.indexEntries} ` + + `text-family fields: ${result.counts.textFields} keyed text columns: ${result.counts.keyedTextColumns}\n`); + + const byPackage = new Map(); + for (const o of result.objects) { + const pkg = packageOf(o.file); + if (!byPackage.has(pkg)) byPackage.set(pkg, []); + byPackage.get(pkg).push(o); + } + for (const pkg of [...byPackage.keys()].sort()) { + const objects = byPackage.get(pkg); + const columns = objects.flatMap((o) => o.keyedTextColumns); + console.log(`${pkg} (${objects.length} object(s), ${columns.length} keyed text column(s))`); + for (const o of objects) { + for (const c of o.keyedTextColumns) { + const bound = c.bound.kind === 'integer' ? `maxLength: ${c.bound.value}` + : c.bound.kind === 'absent' ? 'NO maxLength' : `maxLength: ${c.bound.text}`; + console.log(` ${c.column} [${c.type}] ${bound} ${o.file}:${c.line}`); + } + } + } + const unclassified = result.objects.flatMap((o) => o.unclassifiedUnkeyed.map((u) => ({ ...u, file: o.file }))); + console.log(`\nunclassified fields (none keyed, or the run would have refused): ${unclassified.length}`); + for (const u of unclassified) console.log(` ${u.column} ${u.why} ${u.file}`); + if (result.refusals.length > 0) { + console.log(`\nrefusals: ${result.refusals.length}`); + for (const r of result.refusals) console.log(` ${r.file}:${r.line} ${r.message}`); + } + return 0; +} + +/** The workspace package (or example) a repo-relative path belongs to. */ +export function packageOf(relPath) { + const parts = relPath.split('/'); + if (parts[0] === 'packages' && parts[1] === 'plugins') return parts.slice(0, 3).join('/'); + if (parts[0] === 'packages' && (parts[1] === 'services' || parts[1] === 'drivers' || parts[1] === 'qa')) { + return parts.slice(0, 3).join('/'); + } + return parts.slice(0, 2).join('/'); +} + +// --------------------------------------------------------------------------- +// Self-test +// +// The production run over a fixed tree is green by construction, so it cannot +// tell a working matcher from a dead one: weakening the rule can only SHRINK +// the finding set, and the empty set is what success looks like. Every case +// below supplies the adversarial input a clean tree does not contain, in BOTH +// directions -- the detector firing, and the detector staying silent on the +// shapes that are legitimately not findings. + +const EMITTER_STUB = ` + protected varcharColumnChars(field: any, keyed?: { unique: boolean }): number | null { + switch (type) { + case 'string': + case 'email': + return this.declaredVarcharLength(field); + case 'text': + case 'textarea': + case 'html': + case 'markdown': + case 'richtext': + case 'code': + case 'signature': + case 'qrcode': + return ${JSON.stringify(EMITTER_ANCHOR).slice(1, -1)}; + default: + return 255; + } + } +`; + +function fixture(root, rel, text) { + const abs = join(root, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, text); +} + +function makeTree(root, files, { emitter = EMITTER_STUB } = {}) { + fixture(root, EMITTER_FILE, emitter); + for (const [rel, text] of Object.entries(files)) fixture(root, rel, text); +} + +/** An object file with the given body, in a package path. */ +function objectFile(body) { + return `import { ObjectSchema, Field } from '@objectstack/spec/data';\n\nexport const X = ObjectSchema.create(${body});\n`; +} + +export function selfTest() { + let failures = 0; + const t = (name, ok, detail) => { + if (ok) { console.log(` ok ${name}`); return; } + failures += 1; + console.error(` FAIL ${name}${detail === undefined ? '' : ` -- ${detail}`}`); + }; + + const tmp = mkdtempSync(join(tmpdir(), 'keyed-text-bounds-')); + const run = (files, opts) => { + const root = mkdtempSync(join(tmp, 'case-')); + makeTree(root, files, opts); + return sweep(root); + }; + const oneObject = (body, opts) => run({ 'packages/p/src/a.object.ts': objectFile(body) }, opts); + // Judged against an EMPTY allowlist: these fixtures are about the detector, + // and the real ledger's rows are exercised against the real tree instead. + const offendersOf = (r) => unboundedKeyedColumns(r.objects ?? [], []); + + try { + // ── the emitter-derived family ──────────────────────────────────────── + const fam = oneObject(`{ name: 'o', fields: {}, indexes: [] }`); + t('the text family is read off the emitter, and matches this gate\'s witness', + familyProblem(fam.family) === null, JSON.stringify(fam.family)); + + const noAnchor = oneObject(`{ name: 'o', fields: {}, indexes: [] }`, { emitter: 'class X {}' }); + t('an emitter with no keyed-text arm REFUSES rather than judging a retyped family', + noAnchor.fatal !== undefined && /keyed-text arm/.test(noAnchor.fatal)); + + const movedFamily = oneObject(`{ name: 'o', fields: {}, indexes: [] }`, { + emitter: EMITTER_STUB.replace(` case 'qrcode':\n`, ''), + }); + t('a type LEAVING the emitter\'s keyed-text arm is reported, not absorbed', + movedFamily.fatal === undefined && familyProblem(movedFamily.family) !== null, + JSON.stringify(movedFamily.family)); + + // ── the detector FIRES ──────────────────────────────────────────────── + for (const type of EXPECTED_TEXT_FAMILY) { + const r = oneObject(`{ name: 'o', fields: { c: { type: '${type}' } }, indexes: [{ fields: ['c'] }] }`); + const found = offendersOf(r); + t(`an unbounded keyed \`${type}\` column is a finding`, + r.refusals.length === 0 && found.length === 1 && found[0].column === 'o.c', JSON.stringify({ found, refusals: r.refusals })); + } + + const viaBuilder = oneObject(`{ name: 'o', fields: { c: Field.text({ label: 'C' }) }, indexes: [{ fields: ['c'] }] }`); + t('the builder spelling is read too -- `Field.text(...)` with no maxLength is a finding', + offendersOf(viaBuilder).length === 1, JSON.stringify(viaBuilder.refusals)); + + const unique = oneObject(`{ name: 'o', fields: { c: Field.text({}) }, indexes: [{ fields: ['c'], unique: true }] }`); + t('a UNIQUE index keys just as an ordinary one does', offendersOf(unique).length === 1); + + const scoped = oneObject(`{ name: 'o', fields: { c: Field.text({}) }, indexes: [{ fields: ['c'], unique: 'organization' }] }`); + t("an ADR-0120 scoped unique index keys too (`unique: 'organization'`)", offendersOf(scoped).length === 1); + + const composite = oneObject(`{ name: 'o', fields: { a: Field.text({ maxLength: 10 }), b: Field.text({}) }, indexes: [{ fields: ['a', 'b'] }] }`); + const compositeFound = offendersOf(composite); + t('every column of a COMPOSITE index is keyed -- the unbounded one is named, the bounded one is not', + compositeFound.length === 1 && compositeFound[0].column === 'o.b', JSON.stringify(compositeFound)); + + const secondIndex = oneObject(`{ name: 'o', fields: { c: Field.text({}) }, indexes: [{ fields: ['other'] }, { fields: ['c'] }], }`); + t('a column keyed by the SECOND index is found (the reader does not stop at the first)', + secondIndex.refusals.length === 1 && /does not declare/.test(secondIndex.refusals[0].message), JSON.stringify(secondIndex.refusals)); + + const zero = oneObject(`{ name: 'o', fields: { c: Field.text({ maxLength: 0 }) }, indexes: [{ fields: ['c'] }] }`); + t('`maxLength: 0` is a DECLARATION but not a bound -- the driver returns null for it', + offendersOf(zero).length === 1); + + const codeSecondArg = oneObject(`{ name: 'o', fields: { c: Field.code('sql') }, indexes: [{ fields: ['c'] }] }`); + t('`Field.code(language)` -- config is the SECOND argument, and its absence is a finding', + offendersOf(codeSecondArg).length === 1, JSON.stringify(codeSecondArg.refusals)); + + // ── the detector STAYS SILENT ───────────────────────────────────────── + const bounded = oneObject(`{ name: 'o', fields: { c: Field.text({ maxLength: 255 }) }, indexes: [{ fields: ['c'] }] }`); + t('a bounded keyed text column is NOT a finding', offendersOf(bounded).length === 0); + + const boundedRaw = oneObject(`{ name: 'o', fields: { c: { type: 'text', maxLength: 2_000 } }, indexes: [{ fields: ['c'] }] }`); + t('a numeric separator in the bound is still an integer bound', offendersOf(boundedRaw).length === 0); + + const unkeyed = oneObject(`{ name: 'o', fields: { c: Field.text({}) }, indexes: [{ fields: ['other'] }] , }`); + t('an UNBOUNDED but UNKEYED text column is not a finding -- the control that must stay green', + offendersOf(unkeyed).length === 0); + + const noIndexes = oneObject(`{ name: 'o', fields: { c: Field.text({}) } }`); + t('an object with no `indexes:` at all keys nothing', offendersOf(noIndexes).length === 0 && noIndexes.refusals.length === 0); + + const nonText = oneObject(`{ name: 'o', fields: { c: Field.select(['a', 'b'], { label: 'C' }), d: Field.datetime({}), e: Field.lookup('sys_user', {}) }, indexes: [{ fields: ['c', 'd', 'e'] }] }`); + t('keyed NON-text columns (select, datetime, lookup) are not findings', + offendersOf(nonText).length === 0 && nonText.refusals.length === 0, JSON.stringify(nonText.refusals)); + + const builtin = oneObject(`{ name: 'o', fields: { c: Field.text({ maxLength: 5 }) }, indexes: [{ fields: ['id', 'created_at'] }] }`); + t('an index over the driver built-ins does not refuse', builtin.refusals.length === 0 && offendersOf(builtin).length === 0); + + const commented = oneObject(`{ name: 'o', fields: { /* c: Field.text({}) is commented out */ d: Field.text({ maxLength: 5 }) }, indexes: [{ fields: ['d'] }] }`); + t('a commented-out field is prose, not a column -- the mask is load-bearing', + commented.refusals.length === 0 && offendersOf(commented).length === 0, JSON.stringify(commented.refusals)); + + const braceInString = oneObject(`{ name: 'o', fields: { c: Field.text({ maxLength: 5, description: 'a } brace and a /* opener in a string' }) }, indexes: [{ fields: ['c'] }] }`); + t('a brace or comment opener inside a STRING does not move the parse', + braceInString.refusals.length === 0 && offendersOf(braceInString).length === 0, JSON.stringify(braceInString.refusals)); + + // ── refusals: the shapes it will not guess at ───────────────────────── + const unknownBuilder = oneObject(`{ name: 'o', fields: { c: Field.mystery({}) }, indexes: [{ fields: ['c'] }] }`); + t('an UNKNOWN `Field.` on a keyed column REFUSES rather than passing', + unknownBuilder.refusals.length === 1 && /does not know/.test(unknownBuilder.refusals[0].message), + JSON.stringify(unknownBuilder.refusals)); + + const unknownBuilderUnkeyed = oneObject(`{ name: 'o', fields: { c: Field.mystery({}), d: Field.text({ maxLength: 5 }) }, indexes: [{ fields: ['d'] }] }`); + t('...but the same unknown builder on an UNKEYED column costs nothing, and is counted', + unknownBuilderUnkeyed.refusals.length === 0 + && unknownBuilderUnkeyed.objects[0].unclassifiedUnkeyed.length === 1, + JSON.stringify(unknownBuilderUnkeyed.refusals)); + + const opaqueBound = oneObject(`{ name: 'o', fields: { c: Field.text({ maxLength: MAX_ID }) }, indexes: [{ fields: ['c'] }] }`); + t('a `maxLength` that is not a literal REFUSES -- it is not read as bounded, and not as unbounded', + opaqueBound.refusals.length === 1 && /not a literal/.test(opaqueBound.refusals[0].message), + JSON.stringify(opaqueBound.refusals)); + + const opaqueType = oneObject(`{ name: 'o', fields: { c: { type: TEXT_TYPE } }, indexes: [{ fields: ['c'] }] }`); + t('a `type` that is not a string literal REFUSES on a keyed column', + opaqueType.refusals.length === 1 && /not a string literal/.test(opaqueType.refusals[0].message)); + + const sharedField = oneObject(`{ name: 'o', fields: { c: SHARED_TEXT_FIELD }, indexes: [{ fields: ['c'] }] }`); + t('a field declared by reference REFUSES on a keyed column', sharedField.refusals.length === 1); + + const spreadFields = oneObject(`{ name: 'o', fields: { ...COMMON, c: Field.text({ maxLength: 5 }) }, indexes: [{ fields: ['c'] }] }`); + t('a SPREAD inside `fields:` always refuses -- it hides columns from the walk entirely', + spreadFields.refusals.length === 1 && /cannot attribute/.test(spreadFields.refusals[0].message), + JSON.stringify(spreadFields.refusals)); + + const spreadIndexes = oneObject(`{ name: 'o', fields: { c: Field.text({ maxLength: 5 }) }, indexes: [...COMMON_INDEXES] }`); + t('a SPREAD inside `indexes:` refuses -- an unread index keys nothing, and nothing is what green looks like', + spreadIndexes.refusals.length === 1 && /not an object literal/.test(spreadIndexes.refusals[0].message), + JSON.stringify(spreadIndexes.refusals)); + + const computedIndexColumn = oneObject(`{ name: 'o', fields: { c: Field.text({}) }, indexes: [{ fields: [COL] }] }`); + t('a non-literal column name inside `indexes[].fields` refuses', computedIndexColumn.refusals.length === 1); + + const noName = oneObject(`{ name: NAME, fields: { c: Field.text({}) }, indexes: [{ fields: ['c'] }] }`); + t('an object with no literal `name:` refuses -- nothing it declares can be attributed', + noName.refusals.length === 1 && /no \`name:\`/.test(noName.refusals[0].message)); + + const emptyObjectFile = run({ 'packages/p/src/a.object.ts': 'export const NOT_AN_OBJECT = 1;\n' }); + t('a `.object.ts` file with NO recognised declaration refuses -- a silently empty parse is the defect', + emptyObjectFile.refusals.length === 1 && /no object declaration recognised/.test(emptyObjectFile.refusals[0].message)); + + const undeclaredKeyedColumn = oneObject(`{ name: 'o', fields: { c: Field.text({ maxLength: 5 }) }, indexes: [{ fields: ['ghost'] }] }`); + t('an index keying a column the object does not declare refuses', + undeclaredKeyedColumn.refusals.length === 1 && /does not declare/.test(undeclaredKeyedColumn.refusals[0].message)); + + // ── the allowlist mechanism, driven on synthetic objects ────────────── + // ALLOWLIST is empty against the real tree, so the excusing branch is never + // taken there and would rot unexecuted. Both outcomes are driven here. + const syntheticObjects = [{ + name: 'o', + file: 'packages/p/src/a.object.ts', + fields: [], + indexes: [], + keyedTextColumns: [{ column: 'o.blob', type: 'text', bound: { kind: 'absent' }, line: 3 }], + unclassifiedUnkeyed: [], + }]; + const goodRow = { pkg: 'packages/p', column: 'o.blob', kind: 'unboundable', why: 'because' }; + const pendingRow = { pkg: 'packages/p', column: 'o.blob', kind: 'pending', issue: '#1', why: 'because' }; + t('the allowlist ACCUSES when the column is not named', + unboundedKeyedColumns(syntheticObjects, []).length === 1); + t('the allowlist EXCUSES when the column is named (`unboundable`)', + unboundedKeyedColumns(syntheticObjects, [goodRow]).length === 0); + t('the allowlist EXCUSES a `pending` row too -- named debt, not an exemption', + unboundedKeyedColumns(syntheticObjects, [pendingRow]).length === 0); + t('a well-formed row is not stale', staleAllowlistRows(syntheticObjects, [goodRow]).length === 0 + && staleAllowlistRows(syntheticObjects, [pendingRow]).length === 0); + t('a row for a column that is not keyed text any more is STALE', + staleAllowlistRows(syntheticObjects, [{ ...goodRow, column: 'o.gone' }]).length === 1); + t('a row for a column that has SINCE been bounded is STALE', + staleAllowlistRows( + [{ ...syntheticObjects[0], keyedTextColumns: [{ column: 'o.blob', type: 'text', bound: { kind: 'integer', value: 5 }, line: 3 }] }], + [goodRow], + ).length === 1); + t('a row naming the WRONG package is STALE -- the allowlist is per package', + staleAllowlistRows(syntheticObjects, [{ ...goodRow, pkg: 'packages/elsewhere' }]).length === 1); + t('a row with an unspelled disposition is STALE', + staleAllowlistRows(syntheticObjects, [{ ...goodRow, kind: 'whatever' }]).length === 1); + t('a `pending` row citing no issue is STALE -- debt with no name on it is an exemption', + staleAllowlistRows(syntheticObjects, [{ ...pendingRow, issue: undefined }]).length === 1); + t('a row with no stated reason is STALE', + staleAllowlistRows(syntheticObjects, [{ ...goodRow, why: ' ' }]).length === 1); + + // ── the vacuity floors ──────────────────────────────────────────────── + const empty = run({}); + t('an EMPTY tree trips a floor rather than reporting clean', + floorProblem(empty.counts) !== null, JSON.stringify(empty.counts)); + t('a tree with objects but NO indexes read trips the index floor', + floorProblem({ files: 999, objects: 999, indexEntries: 0, textFields: 999, keyedTextColumns: 999 }) !== null); + t('a tree whose keyed-text INTERSECTION collapses trips its own floor', + floorProblem({ files: 999, objects: 999, indexEntries: 999, textFields: 999, keyedTextColumns: 0 }) !== null); + t('the floors pass at the values measured on fa5d137ab0', floorProblem(MEASURED) === null); + + // ── the watch-hint declaration vs the repo-wide walk ───────────────── + const outsideHints = run({ 'tools/stray.object.ts': objectFile(`{ name: 'o', fields: { c: Field.text({ maxLength: 5 }) }, indexes: [{ fields: ['c'] }] }`) }); + t('an object file OUTSIDE every watch hint is still SWEPT -- the walk is repo-wide', + outsideHints.relFiles.includes('tools/stray.object.ts') && outsideHints.objects.length === 1, + JSON.stringify(outsideHints.relFiles)); + t('...and is REPORTED as unhinted, so the declaration cannot silently under-name the population', + outsideHints.unhinted.length === 1 && outsideHints.unhinted[0] === 'tools/stray.object.ts'); + const insideHints = run({ 'packages/p/src/a.object.ts': objectFile(`{ name: 'o', fields: {}, indexes: [] }`) }); + t('an object file inside a declared hint is not reported as unhinted', insideHints.unhinted.length === 0); + t('every declared watch hint is a reachable glob, never a separator-less bare word', + ROOT_DIR_WATCH_HINTS.every((h) => h.includes('/'))); + + // ── package attribution, which the per-package allowlist rests on ───── + t('packageOf attributes a plugin path to the plugin', + packageOf('packages/plugins/plugin-audit/src/objects/x.object.ts') === 'packages/plugins/plugin-audit'); + t('packageOf attributes a service path to the service', + packageOf('packages/services/service-messaging/src/objects/x.object.ts') === 'packages/services/service-messaging'); + t('packageOf attributes a plain package path to the package', + packageOf('packages/platform-objects/src/identity/x.object.ts') === 'packages/platform-objects'); + t('packageOf attributes an example path to the example', + packageOf('examples/app-crm/src/objects/x.object.ts') === 'examples/app-crm'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + console.log(`\n${failures === 0 ? 'PASS' : 'FAIL'} check-keyed-text-bounds --self-test (${failures} failure(s))`); + return failures === 0 ? 0 : 1; +} + +if (isEntrypoint(import.meta.url)) { + const argv = process.argv.slice(2); + process.exit(argv.includes('--self-test') ? selfTest() : argv.includes('--list') ? list() : main()); +}