11import { createHash } from 'node:crypto' ;
22import { parse } from 'acorn' ;
3+ import type { AnyNode , Pattern , Program } from 'acorn' ;
4+ import { cssIdSelector } from '../escape.js' ;
35
46export type RuntimeEffectUnit = {
57 id : string ;
@@ -9,7 +11,7 @@ export type RuntimeEffectUnit = {
911 mutations : string [ ] ;
1012 dependencies : string [ ] ;
1113 status : 'independently_suppressible' | 'shared_or_unsplittable' ;
12- reason ?: 'dynamic_selector' | 'shared_state' | 'unrecognized_pattern' ;
14+ reason ?: 'dynamic_selector' | 'shared_state' | 'unrecognized_pattern' | 'parse_failed' ;
1315} ;
1416
1517export type RegionEffectManifest = {
@@ -18,39 +20,99 @@ export type RegionEffectManifest = {
1820 units : RuntimeEffectUnit [ ] ;
1921} ;
2022
23+ const SCHEMA = 'blocks-engine/runtime-region-effects/v1' as const ;
24+
25+ const CLASS_MUTATORS = new Set ( [ 'add' , 'remove' , 'toggle' , 'replace' ] ) ;
26+
2127const hash = ( value : string ) => createHash ( 'sha256' ) . update ( value ) . digest ( 'hex' ) ;
2228
2329/**
2430 * Produces an ownership manifest from top-level DOM-effect statements. This is
25- * deliberately bounded: unsupported AST shapes remain retained as a whole.
31+ * deliberately bounded: unsupported AST shapes remain retained as a whole, and
32+ * unparseable source yields a single whole-source unretirable unit rather than
33+ * an empty (effect-free-looking) manifest.
2634 */
2735export function analyzeRuntimeRegionEffects ( source : string ) : RegionEffectManifest {
2836 const sourceHash = hash ( source ) ;
29- let program : any ;
37+ let program : Program ;
3038 try {
3139 program = parse ( source , { ecmaVersion : 'latest' , sourceType : 'script' } ) ;
3240 } catch {
33- return { schema : 'blocks-engine/runtime-region-effects/v1' , sourceHash, units : [ ] } ;
41+ return {
42+ schema : SCHEMA ,
43+ sourceHash,
44+ units : [
45+ {
46+ id : `effect_1_${ sourceHash . slice ( 0 , 12 ) } ` ,
47+ source : { start : 0 , end : source . length , hash : sourceHash } ,
48+ targets : [ ] ,
49+ events : [ ] ,
50+ mutations : [ ] ,
51+ dependencies : [ ] ,
52+ status : 'shared_or_unsplittable' ,
53+ reason : 'parse_failed' ,
54+ } ,
55+ ] ,
56+ } ;
3457 }
3558
36- const declared = new Map < string , number > ( ) ;
37- for ( const statement of program . body ) {
38- if ( statement . type === 'VariableDeclaration' ) {
39- for ( const declaration of statement . declarations ) {
40- if ( declaration . id . type === 'Identifier' ) declared . set ( declaration . id . name , ( declared . get ( declaration . id . name ) ?? 0 ) + 1 ) ;
41- }
42- }
43- }
59+ const declared = new Set < string > ( ) ;
60+ for ( const statement of program . body ) collectSharedBindings ( statement , declared ) ;
4461
4562 return {
46- schema : 'blocks-engine/runtime-region-effects/v1' ,
63+ schema : SCHEMA ,
4764 sourceHash,
48- units : program . body . map ( ( statement : any , index : number ) => unitFor ( statement , index , source , declared ) ) ,
65+ units : program . body . map ( ( statement , index ) => unitFor ( statement , index , source , declared ) ) ,
4966 } ;
5067}
5168
52- function unitFor ( statement : any , index : number , source : string , declared : Map < string , number > ) : RuntimeEffectUnit {
69+ /**
70+ * Registers every binding a top-level statement can contribute to program
71+ * scope: declarations in nested blocks and loop heads included, function
72+ * bodies excluded (their bindings are local). Block-scoped bindings in nested
73+ * blocks over-collect, which only fails closed.
74+ */
75+ function collectSharedBindings ( value : unknown , names : Set < string > ) : void {
76+ if ( Array . isArray ( value ) ) {
77+ for ( const child of value ) collectSharedBindings ( child , names ) ;
78+ return ;
79+ }
80+ if ( ! isAstNode ( value ) ) return ;
81+ if ( ( value . type === 'FunctionDeclaration' || value . type === 'ClassDeclaration' ) && value . id ) names . add ( value . id . name ) ;
82+ if ( value . type === 'FunctionDeclaration' || value . type === 'FunctionExpression' || value . type === 'ArrowFunctionExpression' ) return ;
83+ if ( value . type === 'VariableDeclaration' ) {
84+ for ( const declaration of value . declarations ) collectPatternNames ( declaration . id , names ) ;
85+ }
86+ for ( const child of Object . values ( value ) ) collectSharedBindings ( child , names ) ;
87+ }
88+
89+ function collectPatternNames ( pattern : Pattern , names : Set < string > ) : void {
90+ switch ( pattern . type ) {
91+ case 'Identifier' :
92+ names . add ( pattern . name ) ;
93+ break ;
94+ case 'ObjectPattern' :
95+ for ( const property of pattern . properties ) {
96+ collectPatternNames ( property . type === 'RestElement' ? property . argument : property . value , names ) ;
97+ }
98+ break ;
99+ case 'ArrayPattern' :
100+ for ( const element of pattern . elements ) {
101+ if ( element ) collectPatternNames ( element , names ) ;
102+ }
103+ break ;
104+ case 'RestElement' :
105+ collectPatternNames ( pattern . argument , names ) ;
106+ break ;
107+ case 'AssignmentPattern' :
108+ collectPatternNames ( pattern . left , names ) ;
109+ break ;
110+ }
111+ }
112+
113+ function unitFor ( statement : AnyNode , index : number , source : string , declared : Set < string > ) : RuntimeEffectUnit {
53114 const slice = source . slice ( statement . start , statement . end ) ;
115+ const sliceHash = hash ( slice ) ;
54116 const targets = new Set < string > ( ) ;
55117 const events = new Set < string > ( ) ;
56118 const mutations = new Set < string > ( ) ;
@@ -65,32 +127,37 @@ function unitFor(statement: any, index: number, source: string, declared: Map<st
65127 recognized = true ;
66128 const argument = node . arguments [ 0 ] ;
67129 if ( argument . type !== 'Literal' || typeof argument . value !== 'string' ) dynamicSelector = true ;
68- else targets . add ( name === 'getElementById' ? `# ${ argument . value } ` : argument . value ) ;
130+ else targets . add ( name === 'getElementById' ? cssIdSelector ( argument . value ) : argument . value ) ;
69131 }
70132 if ( name === 'addEventListener' && node . arguments [ 0 ] ?. type === 'Literal' && typeof node . arguments [ 0 ] . value === 'string' ) {
71133 recognized = true ;
72134 events . add ( node . arguments [ 0 ] . value ) ;
73135 }
74- if ( [ 'add' , 'remove' , 'toggle' , 'replace' ] . includes ( name ) && node . callee . object ? .type === 'MemberExpression' && node . callee . object . property ? .name === 'classList' ) mutations . add ( 'class' ) ;
136+ if ( CLASS_MUTATORS . has ( name ) && node . callee . object . type === 'MemberExpression' && node . callee . object . property . type === 'Identifier' && node . callee . object . property . name === 'classList' ) mutations . add ( 'class' ) ;
75137 if ( name === 'setAttribute' && node . arguments [ 0 ] ?. type === 'Literal' && typeof node . arguments [ 0 ] . value === 'string' ) mutations . add ( `attribute:${ node . arguments [ 0 ] . value } ` ) ;
76138 } ) ;
77- const shared = [ ...declared . keys ( ) ] . some ( ( name ) => identifiers . has ( name ) ) ;
78- const reason = dynamicSelector ? 'dynamic_selector' : shared ? 'shared_state' : ! recognized || ! targets . size ? 'unrecognized_pattern' : undefined ;
139+ const dependencies = [ ...identifiers ] . filter ( ( name ) => declared . has ( name ) ) . sort ( ) ;
140+ const reason = dynamicSelector ? 'dynamic_selector' : dependencies . length ? 'shared_state' : ! recognized || ! targets . size ? 'unrecognized_pattern' : undefined ;
79141 const unit : RuntimeEffectUnit = {
80- id : `effect_${ index + 1 } _${ hash ( slice ) . slice ( 0 , 12 ) } ` ,
81- source : { start : statement . start , end : statement . end , hash : hash ( slice ) } ,
142+ id : `effect_${ index + 1 } _${ sliceHash . slice ( 0 , 12 ) } ` ,
143+ source : { start : statement . start , end : statement . end , hash : sliceHash } ,
82144 targets : [ ...targets ] . sort ( ) , events : [ ...events ] . sort ( ) , mutations : [ ...mutations ] . sort ( ) ,
83- dependencies : [ ... identifiers ] . filter ( ( name ) => declared . has ( name ) ) . sort ( ) ,
145+ dependencies,
84146 status : reason ? 'shared_or_unsplittable' : 'independently_suppressible' ,
85147 } ;
86148 return reason ? { ...unit , reason } : unit ;
87149}
88150
89- function walk ( node : any , visit : ( node : any ) => void ) {
90- if ( ! node || typeof node !== 'object' || typeof node . type !== 'string' ) return ;
91- visit ( node ) ;
92- for ( const value of Object . values ( node ) ) {
93- if ( Array . isArray ( value ) ) value . forEach ( ( child ) => walk ( child , visit ) ) ;
94- else walk ( value , visit ) ;
151+ function isAstNode ( value : unknown ) : value is AnyNode {
152+ return ! ! value && typeof value === 'object' && typeof ( value as { type ?: unknown } ) . type === 'string' ;
153+ }
154+
155+ function walk ( value : unknown , visit : ( node : AnyNode ) => void ) : void {
156+ if ( Array . isArray ( value ) ) {
157+ for ( const child of value ) walk ( child , visit ) ;
158+ return ;
95159 }
160+ if ( ! isAstNode ( value ) ) return ;
161+ visit ( value ) ;
162+ for ( const child of Object . values ( value ) ) walk ( child , visit ) ;
96163}
0 commit comments