11import { parseSync } from 'oxc-parser' ;
2- import { memberPath , visitAst } from './cutover-policy-ast.ts' ;
2+ import { propertyName , visitAst } from './cutover-policy-ast.ts' ;
33import { countNamedCalls , lineOf , namedFunction } from './runtime-command-cutover-ast.ts' ;
44import type { UnruledViolation } from './runtime-command-cutover-model.ts' ;
55
@@ -34,23 +34,35 @@ export function snapshotPlatformPolicyBranchViolations(
3434 ] ;
3535 }
3636
37+ const aliases = snapshotAdmissionAliases ( admission ) ;
3738 const violations : UnruledViolation [ ] = [ ] ;
3839 const seenLines = new Set < number > ( ) ;
3940 let readsRequiredOperations = false ;
4041 let readsOperationFacts = false ;
4142 let admitsAvailableFacts = false ;
4243 visitAst ( admission , ( node ) => {
43- const path = memberPath ( node ) ;
44+ const binding = aliasBinding ( node ) ;
45+ if ( binding !== undefined ) {
46+ for ( const path of boundPatternPaths (
47+ binding . pattern ,
48+ initializerPath ( binding . value , aliases ) ,
49+ ) ) {
50+ if ( readsDeviceOwnerIdentity ( path ) ) addOwnerIdentityViolation ( node ) ;
51+ }
52+ }
53+ if ( node . type !== 'MemberExpression' && node . type !== 'ChainExpression' ) return ;
54+ const path = canonicalMemberPath ( node , aliases ) ;
4455 if ( path === undefined ) return ;
4556 if ( samePath ( path , [ 'plan' , 'use' , 'required' ] ) ) readsRequiredOperations = true ;
4657 if ( samePath ( path , [ 'facts' , 'operations' ] ) ) readsOperationFacts = true ;
47- if ( samePath ( path , [ 'fact' , 'available' ] ) ) admitsAvailableFacts = true ;
58+ if ( path [ 0 ] === 'facts' && path [ 1 ] === 'operations' && path [ path . length - 1 ] === 'available' ) {
59+ admitsAvailableFacts = true ;
60+ }
4861
49- const readsDeviceLeaf =
50- ( path [ 0 ] === 'device' && path . length > 1 ) ||
51- ( path [ 0 ] === 'params' && path [ 1 ] === 'device' && path . length > 2 ) ;
52- const readsOwnerIdentity = path [ 0 ] === 'facts' && path [ 1 ] === 'device' && path . length > 2 ;
53- if ( ! readsDeviceLeaf && ! readsOwnerIdentity ) return ;
62+ if ( readsDeviceOwnerIdentity ( path ) ) addOwnerIdentityViolation ( node ) ;
63+ } ) ;
64+
65+ function addOwnerIdentityViolation ( node : AstNode ) : void {
5466 const line = lineOf ( source , node ) ;
5567 if ( seenLines . has ( line ) ) return ;
5668 seenLines . add ( line ) ;
@@ -59,7 +71,7 @@ export function snapshotPlatformPolicyBranchViolations(
5971 line,
6072 message : 'snapshot admission reads device-owner identity instead of selected operation facts' ,
6173 } ) ;
62- } ) ;
74+ }
6375
6476 if ( countNamedCalls ( admission , 'resolveSnapshotRuntimePlan' ) !== 1 ) {
6577 violations . push ( {
@@ -78,6 +90,154 @@ export function snapshotPlatformPolicyBranchViolations(
7890 return violations ;
7991}
8092
93+ function snapshotAdmissionAliases ( admission : AstNode ) : ReadonlyMap < string , readonly string [ ] > {
94+ const aliases = new Map < string , readonly string [ ] > ( ) ;
95+ const params = admission . params ;
96+ if ( Array . isArray ( params ) ) {
97+ const canonicalParams = [ [ 'params' ] , [ 'device' ] , [ 'session' ] ] as const ;
98+ for ( const [ index , parameter ] of params . entries ( ) ) {
99+ if ( index >= canonicalParams . length || ! isIdentifier ( parameter ) ) continue ;
100+ aliases . set ( parameter . name , canonicalParams [ index ] ) ;
101+ }
102+ }
103+
104+ const bindings : AstNode [ ] = [ ] ;
105+ visitAst ( admission , ( node ) => {
106+ if ( aliasBinding ( node ) !== undefined ) bindings . push ( node ) ;
107+ } ) ;
108+ // Every acyclic alias chain stabilizes within this bound; malformed cycles cannot hang the gate.
109+ for ( let pass = 0 ; pass <= bindings . length ; pass += 1 ) {
110+ let changed = false ;
111+ for ( const node of bindings ) {
112+ const binding = aliasBinding ( node ) ! ;
113+ const path = initializerPath ( binding . value , aliases ) ;
114+ for ( const [ name , aliasPath ] of boundPatternAliases ( binding . pattern , path ) ) {
115+ if ( samePath ( aliases . get ( name ) ?? [ ] , aliasPath ) ) continue ;
116+ aliases . set ( name , aliasPath ) ;
117+ changed = true ;
118+ }
119+ }
120+ if ( ! changed ) break ;
121+ }
122+ return aliases ;
123+ }
124+
125+ function aliasBinding ( node : AstNode ) : Readonly < { pattern : unknown ; value : unknown } > | undefined {
126+ if ( node . type === 'VariableDeclarator' ) return { pattern : node . id , value : node . init } ;
127+ if ( node . type === 'AssignmentExpression' && node . operator === '=' ) {
128+ return { pattern : node . left , value : node . right } ;
129+ }
130+ return undefined ;
131+ }
132+
133+ function initializerPath (
134+ node : unknown ,
135+ aliases : ReadonlyMap < string , readonly string [ ] > ,
136+ ) : readonly string [ ] | undefined {
137+ const value = unwrapExpression ( node ) ;
138+ if ( value === undefined ) return undefined ;
139+ if ( value . type === 'CallExpression' ) {
140+ if ( containsNamedCall ( value , 'requireRuntimeFacts' ) ) return [ 'facts' ] ;
141+ if ( containsNamedCall ( value , 'resolveSnapshotRuntimePlan' ) ) return [ 'plan' ] ;
142+ }
143+ return canonicalMemberPath ( value , aliases ) ;
144+ }
145+
146+ function canonicalMemberPath (
147+ node : unknown ,
148+ aliases : ReadonlyMap < string , readonly string [ ] > ,
149+ ) : readonly string [ ] | undefined {
150+ const value = unwrapExpression ( node ) ;
151+ if ( value === undefined ) return undefined ;
152+ if ( value . type === 'Identifier' ) {
153+ const name = typeof value . name === 'string' ? value . name : undefined ;
154+ return name === undefined ? undefined : ( aliases . get ( name ) ?? [ name ] ) ;
155+ }
156+ if ( value . type !== 'MemberExpression' ) return undefined ;
157+ const object = canonicalMemberPath ( value . object , aliases ) ;
158+ const name = propertyName ( value . property ) ;
159+ return object === undefined || name === undefined
160+ ? undefined
161+ : canonicalizePath ( [ ...object , name ] ) ;
162+ }
163+
164+ function canonicalizePath ( path : readonly string [ ] ) : readonly string [ ] {
165+ if ( path [ 0 ] === 'params' && path [ 1 ] === 'device' ) return [ 'device' , ...path . slice ( 2 ) ] ;
166+ if ( path [ 0 ] === 'params' && path [ 1 ] === 'session' ) return [ 'session' , ...path . slice ( 2 ) ] ;
167+ if ( path [ 0 ] === 'session' && path [ 1 ] === 'device' ) return [ 'device' , ...path . slice ( 2 ) ] ;
168+ return path ;
169+ }
170+
171+ function boundPatternAliases (
172+ pattern : unknown ,
173+ path : readonly string [ ] | undefined ,
174+ ) : ReadonlyArray < readonly [ string , readonly string [ ] ] > {
175+ if ( path === undefined || pattern === null || typeof pattern !== 'object' ) return [ ] ;
176+ const value = pattern as AstNode ;
177+ if ( value . type === 'Identifier' && typeof value . name === 'string' ) {
178+ return [ [ value . name , canonicalizePath ( path ) ] ] ;
179+ }
180+ if ( value . type === 'AssignmentPattern' ) return boundPatternAliases ( value . left , path ) ;
181+ if ( value . type !== 'ObjectPattern' || ! Array . isArray ( value . properties ) ) return [ ] ;
182+ const aliases : Array < readonly [ string , readonly string [ ] ] > = [ ] ;
183+ for ( const property of value . properties ) {
184+ if ( property === null || typeof property !== 'object' ) continue ;
185+ const entry = property as AstNode ;
186+ if ( entry . type !== 'Property' ) continue ;
187+ const name = propertyName ( entry . key ) ;
188+ if ( name === undefined ) continue ;
189+ aliases . push ( ...boundPatternAliases ( entry . value , canonicalizePath ( [ ...path , name ] ) ) ) ;
190+ }
191+ return aliases ;
192+ }
193+
194+ function boundPatternPaths (
195+ pattern : unknown ,
196+ path : readonly string [ ] | undefined ,
197+ ) : readonly ( readonly string [ ] ) [ ] {
198+ return boundPatternAliases ( pattern , path ) . map ( ( [ , aliasPath ] ) => aliasPath ) ;
199+ }
200+
201+ function readsDeviceOwnerIdentity ( path : readonly string [ ] ) : boolean {
202+ return (
203+ ( path [ 0 ] === 'device' && path . length > 1 ) ||
204+ ( path [ 0 ] === 'facts' && path [ 1 ] === 'device' && path . length > 2 )
205+ ) ;
206+ }
207+
208+ function containsNamedCall ( node : unknown , name : string ) : boolean {
209+ let found = false ;
210+ visitAst ( node , ( candidate ) => {
211+ if ( candidate . type !== 'CallExpression' ) return ;
212+ const callee = unwrapExpression ( candidate . callee ) ;
213+ if ( callee ?. type === 'Identifier' && callee . name === name ) found = true ;
214+ } ) ;
215+ return found ;
216+ }
217+
218+ function unwrapExpression ( node : unknown ) : AstNode | undefined {
219+ if ( node === null || typeof node !== 'object' ) return undefined ;
220+ const value = node as AstNode ;
221+ if (
222+ value . type === 'AwaitExpression' ||
223+ value . type === 'ChainExpression' ||
224+ value . type === 'TSAsExpression' ||
225+ value . type === 'TSNonNullExpression'
226+ ) {
227+ return unwrapExpression ( value . expression ) ;
228+ }
229+ return value ;
230+ }
231+
232+ function isIdentifier ( node : unknown ) : node is AstNode & { name : string } {
233+ return (
234+ node !== null &&
235+ typeof node === 'object' &&
236+ ( node as AstNode ) . type === 'Identifier' &&
237+ typeof ( node as AstNode ) . name === 'string'
238+ ) ;
239+ }
240+
81241function samePath ( actual : readonly string [ ] , expected : readonly string [ ] ) : boolean {
82242 return (
83243 actual . length === expected . length && actual . every ( ( part , index ) => part === expected [ index ] )
0 commit comments