Skip to content

Commit de2fb0f

Browse files
committed
fix(tooling): the tenant-audit census counts top-level object declarations only
`declaredObjects()` recursed into every object literal in a `*.object.ts(x)` file and recorded every `name:` matching `/^[a-z][a-z0-9_]*$/`. That is not the object-identity key alone: it also spells the grid column identity (`inlineColumns`), the validation-rule id, the action name, the list-view name and the index name. The registry read 300 "declared objects" out of 112 files that declare 117. The over-match was not confined to a printed figure. This name set is the census's discriminator for `any`-typed receivers: runCensus()'s RESCUE promotes an unresolved write call to `engine` -- to PLACED -- exactly when its first argument names something in the set, and the same set answers each placed site's tenancy posture. Over-matching widened the predicate that decides whether a write call site is placed at all. So the definition is now written down and enforced: a declared object is a TOP-LEVEL declaration -- `export const X = ObjectSchema.create({ name: … })` -- and never a literal nested inside one, at any depth. A `*.object.ts(x)` file that yields no top-level declaration REFUSES rather than contributing nothing, because a shrunk set un-places live write call sites. Measured on this tree: registry 300 -> 117, a strict subset (183 dropped, 0 added). The RESCUE places the same 18 sites before and after, and all 23 enforced population totals are unchanged; only the unenforced corpus-scale row moves. Twelve self-test cases pin the rule in both directions, including the two files the card measured: expense-report.object.ts still counts 2, and invoice.object.ts's seven `inlineColumns` entries count 0. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
1 parent d4554d4 commit de2fb0f

3 files changed

Lines changed: 189 additions & 34 deletions

File tree

content/docs/permissions/tenant-audit-census.mdx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,18 @@ receiver that none of the three place is an error, never a default.**
8484

8585
Tenancy itself is enabled *by default*`isTenancyDisabled()` reads
8686
`tenancy.enabled === false` and nothing else — so the object registry only has to
87-
find the opt-outs. Across 300 declared objects — the dated, ⛔ unenforced
87+
find the opt-outs. Across 117 declared objects — the dated, ⛔ unenforced
8888
corpus-scale figure below — exactly two opt out (`sys_api_key`,
8989
`sys_sso_provider`), and no write call site on this surface targets either.
9090

91+
A **declared object** here is a top-level object declaration in a
92+
`*.object.ts(x)` file — `export const X = ObjectSchema.create({ name: … })`
93+
and ⛔ never a literal nested inside one. The distinction is not cosmetic: the
94+
same `name:` key spells a grid column identity (`inlineColumns`), a validation
95+
rule id, an action, a list view and an index, and this name set is what places
96+
an `any`-typed receiver's write call, so counting those would widen the
97+
predicate that decides whether a site is placed at all.
98+
9199
## ⛔ What this instrument cannot decide
92100

93101
A site whose object name is an inline literal or a local `const` is statically
@@ -224,13 +232,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
224232
their values are not compared. The reasoning, and the measurement behind it,
225233
are in `scripts/check-tenant-audit-census.mjs`.
226234

227-
Measured on 2026-09-13 at `f62bc2d0a`.
235+
Measured on 2026-09-14 at `d4554d4f5`.
228236

229237
| corpus scale (not enforced) | count |
230238
| :--- | ---: |
231-
| tracked non-test sources scanned | 567 |
239+
| tracked non-test sources scanned | 568 |
232240
| engine-shaped types recognised | 61 |
233-
| declared objects in the registry | 300 |
234-
| same-named calls subtracted as non-engine | 138 |
241+
| declared objects in the registry | 117 |
242+
| same-named calls subtracted as non-engine | 140 |
235243

236244
{/* END GENERATED: tenant-audit-census */}

docs/audits/2026-08-tenant-audit-write-call-sites.counts.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
5252
their values are not compared. The reasoning, and the measurement behind it,
5353
are in `scripts/check-tenant-audit-census.mjs`.
5454

55-
Measured on 2026-09-13 at `f62bc2d0a`.
55+
Measured on 2026-09-14 at `d4554d4f5`.
5656

5757
| corpus scale (not enforced) | count |
5858
| :--- | ---: |
59-
| tracked non-test sources scanned | 567 |
59+
| tracked non-test sources scanned | 568 |
6060
| engine-shaped types recognised | 61 |
61-
| declared objects in the registry | 300 |
62-
| same-named calls subtracted as non-engine | 138 |
61+
| declared objects in the registry | 117 |
62+
| same-named calls subtracted as non-engine | 140 |
6363

6464
## Every site
6565

scripts/tenant-audit-census.mjs

Lines changed: 172 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -544,12 +544,91 @@ export function resolveReceiver(recvNode, sf, decls, index, depth = 0) {
544544
return { kind: 'unresolved', how: ts.SyntaxKind[r.kind], detail: receiverKey(r, sf) };
545545
}
546546

547+
/**
548+
* ⭐ WHAT COUNTS AS A DECLARED OBJECT -- the definition, written down here
549+
* because the walk that preceded it had none.
550+
*
551+
* A declared object is a **top-level object declaration** in a `*.object.ts(x)`
552+
* file: a `const` / `export const` whose initializer is an object literal, or a
553+
* call whose first object-literal argument is one -- `ObjectSchema.create({…})`,
554+
* the only spelling in this corpus today -- and that literal carries a `name:`
555+
* string literal. ⛔ A literal NESTED inside that declaration is never one, at
556+
* any depth.
557+
*
558+
* ## Why DEPTH is the whole rule
559+
*
560+
* `name:` is not this corpus's object-identity key alone. It is also the grid
561+
* column identity (`inlineColumns: [{ name: 'quantity' }, …]`), the validation
562+
* rule id (`validationRules: [{ name: 'discount_cap' }, …]`), the action name,
563+
* the list-view name and the index name. The walk this replaces recursed into
564+
* every object literal unconditionally and recorded every `name:` matching
565+
* `/^[a-z][a-z0-9_]*$/`, so it recorded all of those too: 300 "declared
566+
* objects" out of 112 object files that declare 117 (#17663).
567+
*
568+
* ⭐⭐ The damage was NOT confined to a printed figure. This name set is the
569+
* census's discriminator for `any`-typed receivers: {@link runCensus}'s RESCUE
570+
* promotes an `unresolved` write call to `engine` -- that is, to PLACED --
571+
* exactly when its first argument names something in this set. Over-matching
572+
* therefore WIDENS the predicate that decides whether a write call site is
573+
* placed at all, and `quantity`, `amount`, `receipt` and `discount_cap` were in
574+
* it. The same set answers each placed site's tenancy posture
575+
* (`enabled` / `disabled` / `undeclared-name`), so a name in the set by accident
576+
* answers that question by accident too.
577+
*
578+
* ⇒ The rule is the DECLARATION SITE, not a callee name. Keying on
579+
* `ObjectSchema.create` would make the registry a function of one helper's
580+
* identifier; keying on the top-level declaration keeps it a fact about the
581+
* file's shape, which is what "declares an object" means.
582+
*
583+
* ⛔ A `*.object.ts(x)` file this finds nothing in REFUSES rather than
584+
* contributing nothing -- see {@link declaredObjects}. Silently contributing
585+
* nothing is the direction that shrinks the RESCUE set, and a shrunk set
586+
* un-places live write call sites; absence has to be loud here.
587+
*/
588+
export function topLevelObjectDeclarations(sf) {
589+
const found = [];
590+
for (const statement of sf.statements) {
591+
if (!ts.isVariableStatement(statement)) continue;
592+
for (const decl of statement.declarationList.declarations) {
593+
let init = decl.initializer;
594+
while (init && (ts.isAsExpression(init) || ts.isParenthesizedExpression(init)
595+
|| (ts.isSatisfiesExpression && ts.isSatisfiesExpression(init)))) init = init.expression;
596+
if (init && ts.isCallExpression(init)) init = init.arguments.find((a) => ts.isObjectLiteralExpression(a));
597+
if (!init || !ts.isObjectLiteralExpression(init)) continue;
598+
const entry = readDeclarationLiteral(init);
599+
if (entry) found.push(entry);
600+
}
601+
}
602+
return found;
603+
}
604+
605+
/**
606+
* The two facts a declaration literal carries: its `name` and whether it opts
607+
* out of tenancy. ⛔ Reads the literal's OWN properties and does not descend --
608+
* descending is the defect {@link topLevelObjectDeclarations} exists to stop.
609+
*/
610+
function readDeclarationLiteral(lit) {
611+
let name = null;
612+
let tenancyDisabled = false;
613+
for (const prop of lit.properties) {
614+
if (!ts.isPropertyAssignment(prop) || !prop.name) continue;
615+
const key = ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name) ? prop.name.text : null;
616+
if (key === 'name' && ts.isStringLiteralLike(prop.initializer)) name = prop.initializer.text;
617+
if (key === 'tenancy' && ts.isObjectLiteralExpression(prop.initializer)) {
618+
for (const q of prop.initializer.properties) {
619+
if (ts.isPropertyAssignment(q) && ts.isIdentifier(q.name) && q.name.text === 'enabled'
620+
&& q.initializer.kind === ts.SyntaxKind.FalseKeyword) tenancyDisabled = true;
621+
}
622+
}
623+
}
624+
return name ? { name, tenancyDisabled } : null;
625+
}
626+
547627
/**
548628
* Every object the tree DECLARES, with its tenancy posture.
549629
*
550-
* Tenancy is enabled by DEFAULT: `isTenancyDisabled()` reads
551-
* `tenancy.enabled === false` and nothing else, so the registry only has to
552-
* find the objects that opt OUT. Two do, today.
630+
* {@link topLevelObjectDeclarations} is the definition of "declares"; this
631+
* applies it to every `*.object.ts(x)` in the tree and keeps the machine names.
553632
*
554633
* The name set doubles as the census's discriminator for `any`-typed receivers
555634
* -- see {@link runCensus}.
@@ -560,27 +639,22 @@ export function declaredObjects(root = ROOT) {
560639
if (!/\.object\.tsx?$/.test(rel)) continue;
561640
const text = readFileSync(join(root, rel), 'utf8');
562641
const sf = parseSourceFile(rel, text);
563-
const visit = (n) => {
564-
if (ts.isObjectLiteralExpression(n)) {
565-
let nm = null;
566-
let disabled = false;
567-
for (const prop of n.properties) {
568-
if (!ts.isPropertyAssignment(prop) || !prop.name) continue;
569-
const key = ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name) ? prop.name.text : null;
570-
if (key === 'name' && ts.isStringLiteralLike(prop.initializer)) nm = prop.initializer.text;
571-
if (key === 'tenancy' && ts.isObjectLiteralExpression(prop.initializer)) {
572-
for (const q of prop.initializer.properties) {
573-
if (ts.isPropertyAssignment(q) && ts.isIdentifier(q.name) && q.name.text === 'enabled'
574-
&& q.initializer.kind === ts.SyntaxKind.FalseKeyword) disabled = true;
575-
}
576-
}
577-
}
578-
if (nm && /^[a-z][a-z0-9_]*$/.test(nm) && !objects.has(nm)) objects.set(nm, { file: rel, tenancyDisabled: disabled });
579-
else if (nm && disabled) objects.set(nm, { file: rel, tenancyDisabled: true });
580-
}
581-
ts.forEachChild(n, visit);
582-
};
583-
visit(sf);
642+
const declarations = topLevelObjectDeclarations(sf);
643+
if (declarations.length === 0) {
644+
throw new Error(
645+
`tenant-audit-census: ${rel} is named *.object.ts(x) but declares no TOP-LEVEL object -- `
646+
+ 'refusing to walk past it. Every object file in this corpus declares its objects as '
647+
+ '`export const X = ObjectSchema.create({ name: … })` at file scope; a declaration this '
648+
+ 'cannot see is one the RESCUE in runCensus() can no longer place, which un-places live '
649+
+ 'write call sites rather than merely lowering a count. Either declare the object at file '
650+
+ 'scope, or teach topLevelObjectDeclarations() the new spelling in the same change.',
651+
);
652+
}
653+
for (const { name, tenancyDisabled } of declarations) {
654+
if (!/^[a-z][a-z0-9_]*$/.test(name)) continue;
655+
if (!objects.has(name)) objects.set(name, { file: rel, tenancyDisabled });
656+
else if (tenancyDisabled) objects.set(name, { file: rel, tenancyDisabled: true });
657+
}
584658
}
585659
if (objects.size === 0) {
586660
throw new Error(
@@ -1345,6 +1419,76 @@ export function selfTest() {
13451419
})(), 'false');
13461420
t('a context key still reads as carried', carries('{ context: ctx }'), 'true');
13471421

1422+
// ── ⭐ THE DECLARED-OBJECT REGISTRY: depth is the rule (#17663) ────────────
1423+
// A smaller number proves nothing on its own, so each case is a CONTROL PAIR:
1424+
// the declaration that must still be counted, beside the nested `name:` in the
1425+
// same file that must not be. The fixtures are cut down from the two files the
1426+
// card measured -- `expense-report.object.ts` (two declarations) and
1427+
// `invoice.object.ts` (`inlineColumns`, whose `name` is the grid's column
1428+
// identity, not an object's).
1429+
const declaredIn = (src) =>
1430+
topLevelObjectDeclarations(parseSourceFile('selftest.object.ts', src)).map((d) => d.name).join(',');
1431+
1432+
t('a single top-level declaration is counted',
1433+
declaredIn("export const A = ObjectSchema.create({ name: 'showcase_account' });\n"),
1434+
'showcase_account');
1435+
t('⭐ a file that genuinely declares TWO objects still counts two',
1436+
declaredIn(
1437+
"export const ExpenseReport = ObjectSchema.create({ name: 'showcase_expense_report' });\n"
1438+
+ "export const ExpenseLine = ObjectSchema.create({ name: 'showcase_expense_line' });\n"),
1439+
'showcase_expense_report,showcase_expense_line');
1440+
t('⭐ `inlineColumns` entries are grid COLUMN identities, not declared objects',
1441+
declaredIn(
1442+
"export const Invoice = ObjectSchema.create({\n"
1443+
+ " name: 'showcase_invoice_line',\n"
1444+
+ " fields: {\n"
1445+
+ " invoice: Field.lookup('showcase_invoice', {\n"
1446+
+ " inlineColumns: [{ name: 'product' }, { name: 'quantity' }, { name: 'amount' }],\n"
1447+
+ " }),\n"
1448+
+ ' },\n'
1449+
+ '});\n'),
1450+
'showcase_invoice_line');
1451+
t('validation-rule names are not declared objects',
1452+
declaredIn(
1453+
"export const Account = ObjectSchema.create({\n"
1454+
+ " name: 'showcase_account',\n"
1455+
+ " validationRules: [{ name: 'tax_id_format' }, { name: 'discount_cap' }],\n"
1456+
+ '});\n'),
1457+
'showcase_account');
1458+
t('action / list-view / index names are not declared objects',
1459+
declaredIn(
1460+
"export const User = ObjectSchema.create({\n"
1461+
+ " name: 'sys_user',\n"
1462+
+ " actions: [{ name: 'invite_user' }, { name: 'ban_user' }],\n"
1463+
+ " listViews: [{ name: 'all_users' }],\n"
1464+
+ " indexes: [{ name: 'idx_sys_user_org' }],\n"
1465+
+ '});\n'),
1466+
'sys_user');
1467+
t('a declaration nested inside a function is NOT top-level',
1468+
declaredIn("function make() { return ObjectSchema.create({ name: 'nested_object' }); }\n"), '');
1469+
t('a bare top-level object literal declaration is counted',
1470+
declaredIn("const A = { name: 'bare_object' };\n"), 'bare_object');
1471+
t('a declaration behind an `as` assertion is counted',
1472+
declaredIn("export const A = ObjectSchema.create({ name: 'asserted_object' }) as never;\n"),
1473+
'asserted_object');
1474+
t('⛔ a file with no top-level declaration yields NOTHING to declare -- the shape declaredObjects() refuses on',
1475+
declaredIn("export default ObjectSchema.create({ name: 'default_exported' });\n"), '');
1476+
1477+
// The tenancy posture rides on the same literal, and only on the TOP-LEVEL one.
1478+
const disabledIn = (src) =>
1479+
topLevelObjectDeclarations(parseSourceFile('selftest.object.ts', src)).map((d) => String(d.tenancyDisabled)).join(',');
1480+
t('a top-level `tenancy.enabled: false` is read as an opt-out',
1481+
disabledIn("export const K = ObjectSchema.create({ name: 'sys_api_key', tenancy: { enabled: false } });\n"), 'true');
1482+
t('an object with no tenancy block is tenancy-ENABLED by default',
1483+
disabledIn("export const K = ObjectSchema.create({ name: 'sys_user' });\n"), 'false');
1484+
t('⛔ a NESTED literal cannot opt anything out -- it is not a declaration at all',
1485+
declaredIn(
1486+
"export const K = ObjectSchema.create({\n"
1487+
+ " name: 'sys_user',\n"
1488+
+ " actions: [{ name: 'ban_user', tenancy: { enabled: false } }],\n"
1489+
+ '});\n'),
1490+
'sys_user');
1491+
13481492
const failed = cases.filter((c) => !c.ok);
13491493
for (const c of failed) console.error(` ✗ ${c.name} -- ${c.detail}`);
13501494
if (failed.length > 0) {
@@ -1354,7 +1498,10 @@ export function selfTest() {
13541498
console.log(
13551499
`✓ tenant-audit-census self-test: ${cases.length} cases pass (an \`as const\` context, an `
13561500
+ 'elevated SPREAD, an unresolvable spread refusing to answer `false`, an unreadable '
1357-
+ 'options argument refusing to answer "carries no context", and the ordinary verdicts).',
1501+
+ 'options argument refusing to answer "carries no context", the ordinary verdicts -- plus '
1502+
+ 'the declared-object registry in BOTH directions: a file declaring two objects still '
1503+
+ 'counts two, while `inlineColumns`, validation-rule, action, list-view and index names '
1504+
+ 'in the same file count none).',
13581505
);
13591506
return 0;
13601507
}

0 commit comments

Comments
 (0)