|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `sys_record_share`'s `guest` recipient RETIRED (ADR-0138 D8) — the census. |
| 5 | + * |
| 6 | + * The record's row: "remove, on its own card"; the pin it names: "a census |
| 7 | + * showing no stored row carries it". The value was declared in two places — |
| 8 | + * the `recipient_type` select on `SysRecordShare` (`@objectstack/plugin-sharing`) |
| 9 | + * and the contract union `RecordShareRecipientType` here — and honoured by |
| 10 | + * none: `grant()` refused it and no gate reads such a row, because guest access |
| 11 | + * flows through the guest position and share links (ADR-0090 D11). |
| 12 | + * |
| 13 | + * Three legs, one walk: |
| 14 | + * 1. PRODUCERS — no source inside the declared radius writes |
| 15 | + * `recipient_type` / `recipientType` as `guest` (a seed, a fixture, an |
| 16 | + * example, a template, a test row, a REST body, a migration). The key is |
| 17 | + * shared with `sys_sharing_rule`, whose vocabulary retired the same value |
| 18 | + * earlier (`sharing-rule-recipient-reconcile`), so a hit on either row |
| 19 | + * kind is a resurrection of a dead value, never a live one. |
| 20 | + * 2. THE SELECT — the `sys_record_share` object's `recipient_type` options, |
| 21 | + * read from its declaration, do not offer `guest`. |
| 22 | + * 3. THE UNION — `RecordShareRecipientType`'s literal members do not name it |
| 23 | + * (the compile-time half is `sharing-service.test.ts`'s `@ts-expect-error`). |
| 24 | + * |
| 25 | + * Each leg carries a planted positive control (the matcher finds the value |
| 26 | + * where it is written) and a lit control on the real tree (the matcher finds |
| 27 | + * the LIVE values — `user` producers, the `user` option, the `user` member) so |
| 28 | + * a zero is a measurement, not a matcher that reads nothing. |
| 29 | + * |
| 30 | + * The bound, stated: a value assembled at runtime (`recipient_type: kind`, a |
| 31 | + * spread, a computed key) is invisible to a text walk; `docs/**`, `.claude/**`, |
| 32 | + * `.github/**` and the repo-root files are outside the radius, which is the |
| 33 | + * one declared for `@objectstack/spec` in `scripts/cross-package-test-inputs.mjs`. |
| 34 | + */ |
| 35 | + |
| 36 | +import fs from 'node:fs'; |
| 37 | +import path from 'node:path'; |
| 38 | +import { fileURLToPath } from 'node:url'; |
| 39 | + |
| 40 | +import { describe, expect, it } from 'vitest'; |
| 41 | + |
| 42 | +const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); |
| 43 | +const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); |
| 44 | +const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); |
| 45 | + |
| 46 | +/** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ |
| 47 | +const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; |
| 48 | +const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); |
| 49 | +/** Under `examples/` the non-code extensions everywhere, and `.ts` under each app's `src/` only — what is declared. */ |
| 50 | +const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); |
| 51 | +const EXAMPLES_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; |
| 52 | +const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); |
| 53 | +/** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ |
| 54 | +const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; |
| 55 | + |
| 56 | +/** |
| 57 | + * Structural exclusions, each with its reason. ⛔ NOT an allowlist file: every |
| 58 | + * entry's JOB is to spell the retired value. |
| 59 | + */ |
| 60 | +const EXCLUDED = new Set([ |
| 61 | + // This census plants the value in its positive controls. |
| 62 | + THIS_FILE, |
| 63 | + // The grant door's refusal pin sends `recipientType: 'guest'` on purpose. |
| 64 | + 'packages/plugins/plugin-sharing/src/sharing-service.test.ts', |
| 65 | +]); |
| 66 | +const EXCLUDED_PREFIXES = [ |
| 67 | + // Release-owned prose records the removal; never edited by a code PR. |
| 68 | + 'content/docs/releases/', |
| 69 | +]; |
| 70 | + |
| 71 | +/** Leg 1: a key spelling of the recipient type, then `:` (or a lone `=`), then `guest` QUOTED. */ |
| 72 | +const PRODUCER_CODE = /(?:^|[^\w$])['"]?recipient_?[tT]ype['"]?\s*(?::|=(?![=>]))\s*(['"`])guest\1/g; |
| 73 | +/** Leg 1, YAML: a mapping line whose key is the recipient type and whose value is `guest`, quoted or not. */ |
| 74 | +const PRODUCER_YAML = /^\s*(?:-\s+)?recipient_?[tT]ype\s*:\s*(['"]?)guest\1\s*(?:#.*)?$/gm; |
| 75 | + |
| 76 | +const lineOf = (text: string, index: number): number => text.slice(0, index).split('\n').length; |
| 77 | + |
| 78 | +const producersIn = (ext: string, text: string, live = 'guest'): number[] => { |
| 79 | + if (!text.includes(live)) return []; |
| 80 | + const out: number[] = []; |
| 81 | + const code = new RegExp(PRODUCER_CODE.source.replace('guest', live), 'g'); |
| 82 | + for (let m = code.exec(text); m; m = code.exec(text)) out.push(lineOf(text, m.index)); |
| 83 | + if (ext === '.yaml' || ext === '.yml' || ext === '.md' || ext === '.mdx') { |
| 84 | + const yaml = new RegExp(PRODUCER_YAML.source.replace('guest', live), 'gm'); |
| 85 | + for (let m = yaml.exec(text); m; m = yaml.exec(text)) out.push(lineOf(text, m.index)); |
| 86 | + } |
| 87 | + return [...new Set(out)].sort((a, b) => a - b); |
| 88 | +}; |
| 89 | + |
| 90 | +/** The string literals inside the bracketed span that starts at `open`, comments stripped. */ |
| 91 | +const literalsInBrackets = (text: string, open: number): string[] => { |
| 92 | + let depth = 0; |
| 93 | + let end = -1; |
| 94 | + for (let i = open; i < text.length; i += 1) { |
| 95 | + if (text[i] === '[') depth += 1; |
| 96 | + else if (text[i] === ']') { depth -= 1; if (depth === 0) { end = i; break; } } |
| 97 | + } |
| 98 | + if (end < 0) return []; |
| 99 | + const body = text.slice(open + 1, end).replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); |
| 100 | + return [...body.matchAll(/(['"`])([^'"`]*)\1/g)].map((m) => m[2]!); |
| 101 | +}; |
| 102 | + |
| 103 | +/** Leg 2: the options of the `recipient_type` select in a file that declares `sys_record_share`, or `null`. */ |
| 104 | +const recordShareSelectOptions = (text: string): string[] | null => { |
| 105 | + if (!/name:\s*['"]sys_record_share['"]/.test(text)) return null; |
| 106 | + const at = text.search(/recipient_type\s*:\s*Field\.select\(/); |
| 107 | + if (at < 0) return null; |
| 108 | + const open = text.indexOf('[', at); |
| 109 | + return open < 0 ? null : literalsInBrackets(text, open); |
| 110 | +}; |
| 111 | + |
| 112 | +/** Leg 3: the literal members of `RecordShareRecipientType`, or `null` when the file does not declare it. */ |
| 113 | +const unionMembers = (text: string): string[] | null => { |
| 114 | + const m = /export\s+type\s+RecordShareRecipientType\s*=([^;]*);/.exec(text); |
| 115 | + if (!m) return null; |
| 116 | + return [...m[1]!.replace(/\/\/[^\n]*/g, '').matchAll(/'([^']*)'/g)].map((x) => x[1]!); |
| 117 | +}; |
| 118 | + |
| 119 | +describe('record-share guest recipient census — the matchers can see the value (planted positive controls)', () => { |
| 120 | + it('leg 1 finds a producer in each spelling the walk reads, and ignores the neighbours', () => { |
| 121 | + expect(producersIn('.ts', "insert('sys_record_share', [{ id: 's', recipient_type: 'guest', recipient_id: 'x' }])")).toEqual([1]); |
| 122 | + expect(producersIn('.ts', "svc.grant({ object: 'a', recordId: 'r',\n recipientType: \"guest\" })")).toEqual([2]); |
| 123 | + expect(producersIn('.ts', "const input = {};\ninput.recipientType = 'guest';")).toEqual([2]); |
| 124 | + expect(producersIn('.json', '{ "records": [ { "recipient_type": "guest" } ] }')).toEqual([1]); |
| 125 | + expect(producersIn('.yaml', 'records:\n - recipient_type: guest\n recipient_id: x\n')).toEqual([2]); |
| 126 | + expect(producersIn('.md', "Seed:\n\n```ts\n({ recipient_type: 'guest' });\n```\n")).toEqual([4]); |
| 127 | + // Neighbours that must NOT match: a comparison (a reader), another key, |
| 128 | + // the anchor vocabulary, a different value, and prose naming the word. |
| 129 | + expect(producersIn('.ts', "if (row.recipient_type === 'guest') return;")).toEqual([]); |
| 130 | + expect(producersIn('.ts', "({ anchor: 'guest', principalKind: 'guest', type: 'guest' })")).toEqual([]); |
| 131 | + expect(producersIn('.ts', "({ recipient_type: 'guest_portal' })")).toEqual([]); |
| 132 | + expect(producersIn('.md', 'The guest recipient type was removed.')).toEqual([]); |
| 133 | + }); |
| 134 | + |
| 135 | + it('leg 2 reads the options of the record-share select, and finds a planted guest option', () => { |
| 136 | + const planted = "ObjectSchema.create({ name: 'sys_record_share', fields: {\n" |
| 137 | + + " recipient_type: Field.select(\n // a comment naming 'nothing'\n ['user', 'guest'],\n { label: 'x' },\n ),\n} })"; |
| 138 | + expect(recordShareSelectOptions(planted)).toEqual(['user', 'guest']); |
| 139 | + // Another object's `recipient_type` select is not this object's. |
| 140 | + expect(recordShareSelectOptions("ObjectSchema.create({ name: 'sys_sharing_rule', fields: { recipient_type: Field.select(['user', 'guest']) } })")).toBeNull(); |
| 141 | + }); |
| 142 | + |
| 143 | + it('leg 3 reads the union members, and finds a planted guest member', () => { |
| 144 | + expect(unionMembers("export type RecordShareRecipientType =\n | 'user'\n | 'guest';")).toEqual(['user', 'guest']); |
| 145 | + expect(unionMembers("export type SharingRuleRecipientType = 'user' | 'guest';")).toBeNull(); |
| 146 | + }); |
| 147 | +}); |
| 148 | + |
| 149 | +describe('record-share guest recipient census — nothing inside the declared radius carries it', () => { |
| 150 | + it('no producer writes it, the select does not offer it, the union does not name it', () => { |
| 151 | + const producers: string[] = []; |
| 152 | + const liveProducers: string[] = []; |
| 153 | + const selects: { rel: string; options: string[] }[] = []; |
| 154 | + const unions: { rel: string; members: string[] }[] = []; |
| 155 | + let visited = 0; |
| 156 | + let recordShareFiles = 0; |
| 157 | + |
| 158 | + const walk = (dir: string) => { |
| 159 | + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { |
| 160 | + const full = path.join(dir, entry.name); |
| 161 | + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); |
| 162 | + if (entry.isDirectory()) { |
| 163 | + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; |
| 164 | + walk(full); |
| 165 | + continue; |
| 166 | + } |
| 167 | + if (!entry.isFile()) continue; |
| 168 | + const ext = path.extname(entry.name); |
| 169 | + const declared = rel.startsWith('examples/') |
| 170 | + ? EXAMPLES_EXT.has(ext) || EXAMPLES_SRC_TS.test(rel) |
| 171 | + : SCANNED_EXT.has(ext); |
| 172 | + if (!declared) continue; |
| 173 | + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal |
| 174 | + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; |
| 175 | + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; |
| 176 | + let text: string; |
| 177 | + try { |
| 178 | + text = fs.readFileSync(full, 'utf-8'); |
| 179 | + } catch (err) { |
| 180 | + // Tolerate ONLY a path that vanished mid-walk (a concurrent build). |
| 181 | + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') continue; |
| 182 | + throw err; |
| 183 | + } |
| 184 | + visited += 1; |
| 185 | + if (text.includes('sys_record_share')) recordShareFiles += 1; |
| 186 | + for (const line of producersIn(ext, text)) producers.push(`${rel}:${line}`); |
| 187 | + for (const line of producersIn(ext, text, 'user')) liveProducers.push(`${rel}:${line}`); |
| 188 | + if (ext === '.ts') { |
| 189 | + const options = recordShareSelectOptions(text); |
| 190 | + if (options) selects.push({ rel, options }); |
| 191 | + const members = unionMembers(text); |
| 192 | + if (members) unions.push({ rel, members }); |
| 193 | + } |
| 194 | + } |
| 195 | + }; |
| 196 | + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); |
| 197 | + |
| 198 | + // Lit controls: the walk covered the tree, it read the files that hold |
| 199 | + // record shares, and the producer matcher finds the LIVE value at scale. |
| 200 | + expect(visited).toBeGreaterThan(1000); |
| 201 | + expect(recordShareFiles).toBeGreaterThan(20); |
| 202 | + expect(liveProducers.length).toBeGreaterThan(20); |
| 203 | + // Leg 1. |
| 204 | + expect(producers, 'a recipient_type / recipientType written as guest is a share no gate honours (ADR-0138 D8)').toEqual([]); |
| 205 | + // Leg 2: exactly one declaration, which the matcher really read (`user` is there). |
| 206 | + expect(selects.map((s) => s.rel)).toEqual(['packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts']); |
| 207 | + expect(selects[0]!.options).toContain('user'); |
| 208 | + expect(selects[0]!.options).not.toContain('guest'); |
| 209 | + // Leg 3: exactly one declaration, likewise lit. |
| 210 | + expect(unions.map((u) => u.rel)).toEqual(['packages/spec/src/contracts/sharing-service.ts']); |
| 211 | + expect(unions[0]!.members).toContain('user'); |
| 212 | + expect(unions[0]!.members).not.toContain('guest'); |
| 213 | + }); |
| 214 | +}); |
0 commit comments