@@ -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 ( ! / \. o b j e c t \. t s x ? $ / . 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 - z 0 - 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 - z 0 - 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