@@ -106,8 +106,6 @@ function extractMetaLiteral(source: string): { metaLiteral: string; metaEndIndex
106106 const end = scanBalancedObject ( source , objectStart ) ;
107107 const metaLiteral = source . slice ( objectStart , end + 1 ) ;
108108
109- assertPureMetaLiteral ( metaLiteral ) ;
110-
111109 return { metaLiteral, metaEndIndex : end + 1 } ;
112110}
113111
@@ -166,115 +164,214 @@ function scanBalancedObject(source: string, start: number): number {
166164 throw new WorkflowScriptError ( "meta" , "Unclosed meta object literal" ) ;
167165}
168166
169- function assertPureMetaLiteral ( literal : string ) : void {
170- for ( let i = 0 ; i < literal . length ; i += 1 ) {
171- const ch = literal [ i ] ! ;
172- const next = literal [ i + 1 ] ;
173- if ( ch === '"' || ch === "'" ) {
174- i = skipQuoted ( literal , i , ch ) ;
175- continue ;
176- }
177- if ( ch === "/" && next === "/" ) {
178- i = skipLineComment ( literal , i + 2 ) ;
179- continue ;
180- }
181- if ( ch === "/" && next === "*" ) {
182- i = skipBlockComment ( literal , i + 2 ) ;
183- continue ;
184- }
185- if ( ch === "`" ) {
186- throw new WorkflowScriptError ( "meta" , "meta must be a pure literal (no templates)" ) ;
187- }
188- if ( literal . startsWith ( "..." , i ) ) {
189- throw new WorkflowScriptError ( "meta" , "meta must be a pure literal (no spreads)" ) ;
190- }
191- if ( literal . startsWith ( "=>" , i ) ) {
192- throw new WorkflowScriptError ( "meta" , "meta must be a pure literal (no functions)" ) ;
193- }
194- if ( ! / [ A - Z a - z _ $ ] / . test ( ch ) ) continue ;
195-
196- const identifierStart = i ;
197- i += 1 ;
198- while ( i < literal . length && / [ \w $ ] / . test ( literal [ i ] ! ) ) i += 1 ;
199- const identifier = literal . slice ( identifierStart , i ) ;
200- if ( identifier === "function" || identifier === "class" || identifier === "new" ) {
201- throw new WorkflowScriptError ( "meta" , "meta must be a pure literal (no executable values)" ) ;
167+ function isOnlyPreamble ( text : string ) : boolean {
168+ // strip block comments, line comments, whitespace
169+ const stripped = text
170+ . replace ( / \/ \* [ \s \S ] * ?\* \/ / g, "" )
171+ . replace ( / \/ \/ .* $ / gm, "" )
172+ . trim ( ) ;
173+ return stripped . length === 0 ;
174+ }
175+
176+ function evaluateMetaLiteral ( literal : string , _filename : string ) : unknown {
177+ try {
178+ return new PureMetaLiteralParser ( literal ) . parse ( ) ;
179+ } catch ( error ) {
180+ const message = error instanceof Error ? error . message : String ( error ) ;
181+ throw new WorkflowScriptError ( "meta" , `Invalid meta literal: ${ message } ` ) ;
182+ }
183+ }
184+
185+ class PureMetaLiteralParser {
186+ private index = 0 ;
187+
188+ constructor ( private readonly source : string ) { }
189+
190+ parse ( ) : unknown {
191+ this . skipTrivia ( ) ;
192+ const value = this . parseValue ( ) ;
193+ this . skipTrivia ( ) ;
194+ if ( this . index !== this . source . length ) {
195+ this . fail ( `unexpected token ${ JSON . stringify ( this . source [ this . index ] ) } ` ) ;
202196 }
203- i = skipTrivia ( literal , i ) - 1 ;
204- if ( literal [ i + 1 ] === "(" ) {
205- throw new WorkflowScriptError ( "meta" , "meta must be a pure literal (no function calls)" ) ;
197+ return value ;
198+ }
199+
200+ private parseValue ( ) : unknown {
201+ this . skipTrivia ( ) ;
202+ const ch = this . source [ this . index ] ;
203+ if ( ch === "{" ) return this . parseObject ( ) ;
204+ if ( ch === "[" ) return this . parseArray ( ) ;
205+ if ( ch === '"' || ch === "'" ) return this . parseString ( ) ;
206+ if ( ch === "-" || ( ch !== undefined && / \d / . test ( ch ) ) ) return this . parseNumber ( ) ;
207+ if ( ch !== undefined && / [ A - Z a - z _ $ ] / . test ( ch ) ) {
208+ const identifier = this . parseIdentifier ( ) ;
209+ if ( identifier === "true" ) return true ;
210+ if ( identifier === "false" ) return false ;
211+ if ( identifier === "null" ) return null ;
212+ this . fail ( `identifier ${ identifier } is not a literal value` ) ;
206213 }
214+ this . fail ( `expected a literal value, got ${ JSON . stringify ( ch ) } ` ) ;
207215 }
208- }
209216
210- function skipQuoted ( source : string , start : number , quote : '"' | "'" ) : number {
211- let escape = false ;
212- for ( let i = start + 1 ; i < source . length ; i += 1 ) {
213- const ch = source [ i ] ! ;
214- if ( escape ) {
215- escape = false ;
216- continue ;
217+ private parseObject ( ) : Record < string , unknown > {
218+ this . expect ( "{" ) ;
219+ const value : Record < string , unknown > = Object . create ( null ) as Record < string , unknown > ;
220+ this . skipTrivia ( ) ;
221+ if ( this . consume ( "}" ) ) return value ;
222+
223+ for ( ; ; ) {
224+ this . skipTrivia ( ) ;
225+ const ch = this . source [ this . index ] ;
226+ const key = ch === '"' || ch === "'" ? this . parseString ( ) : this . parseIdentifier ( ) ;
227+ this . skipTrivia ( ) ;
228+ this . expect ( ":" ) ;
229+ value [ key ] = this . parseValue ( ) ;
230+ this . skipTrivia ( ) ;
231+ if ( this . consume ( "}" ) ) return value ;
232+ this . expect ( "," ) ;
233+ this . skipTrivia ( ) ;
234+ if ( this . consume ( "}" ) ) return value ;
217235 }
218- if ( ch === "\\" ) {
219- escape = true ;
220- continue ;
236+ }
237+
238+ private parseArray ( ) : unknown [ ] {
239+ this . expect ( "[" ) ;
240+ const value : unknown [ ] = [ ] ;
241+ this . skipTrivia ( ) ;
242+ if ( this . consume ( "]" ) ) return value ;
243+
244+ for ( ; ; ) {
245+ value . push ( this . parseValue ( ) ) ;
246+ this . skipTrivia ( ) ;
247+ if ( this . consume ( "]" ) ) return value ;
248+ this . expect ( "," ) ;
249+ this . skipTrivia ( ) ;
250+ if ( this . consume ( "]" ) ) return value ;
221251 }
222- if ( ch === quote ) return i ;
223252 }
224- return source . length - 1 ;
225- }
226253
227- function skipLineComment ( source : string , start : number ) : number {
228- const newline = source . indexOf ( "\n" , start ) ;
229- return newline < 0 ? source . length - 1 : newline ;
230- }
254+ private parseString ( ) : string {
255+ const quote = this . source [ this . index ] ;
256+ if ( quote !== '"' && quote !== "'" ) this . fail ( "expected a quoted string" ) ;
257+ this . index += 1 ;
258+ let value = "" ;
231259
232- function skipBlockComment ( source : string , start : number ) : number {
233- const end = source . indexOf ( "*/" , start ) ;
234- return end < 0 ? source . length - 1 : end + 1 ;
235- }
260+ while ( this . index < this . source . length ) {
261+ const ch = this . source [ this . index ++ ] ! ;
262+ if ( ch === quote ) return value ;
263+ if ( ch === "\n" || ch === "\r" ) this . fail ( "unterminated string literal" ) ;
264+ if ( ch !== "\\" ) {
265+ value += ch ;
266+ continue ;
267+ }
236268
237- function skipTrivia ( source : string , start : number ) : number {
238- let i = start ;
239- while ( i < source . length ) {
240- if ( / \s / . test ( source [ i ] ! ) ) {
241- i += 1 ;
242- continue ;
269+ if ( this . index >= this . source . length ) this . fail ( "unterminated string escape" ) ;
270+ const escaped = this . source [ this . index ++ ] ! ;
271+ const simpleEscapes : Record < string , string > = {
272+ "\\" : "\\" ,
273+ "\"" : "\"" ,
274+ "'" : "'" ,
275+ n : "\n" ,
276+ r : "\r" ,
277+ t : "\t" ,
278+ b : "\b" ,
279+ f : "\f" ,
280+ v : "\v" ,
281+ "0" : "\0" ,
282+ } ;
283+ if ( escaped in simpleEscapes ) {
284+ value += simpleEscapes [ escaped ] ;
285+ continue ;
286+ }
287+ if ( escaped === "x" ) {
288+ value += String . fromCodePoint ( this . parseHexDigits ( 2 ) ) ;
289+ continue ;
290+ }
291+ if ( escaped === "u" ) {
292+ if ( this . consume ( "{" ) ) {
293+ const end = this . source . indexOf ( "}" , this . index ) ;
294+ if ( end < 0 ) this . fail ( "unterminated Unicode escape" ) ;
295+ const digits = this . source . slice ( this . index , end ) ;
296+ if ( ! / ^ [ 0 - 9 a - f A - F ] { 1 , 6 } $ / . test ( digits ) ) this . fail ( "invalid Unicode escape" ) ;
297+ this . index = end + 1 ;
298+ const codePoint = Number . parseInt ( digits , 16 ) ;
299+ if ( codePoint > 0x10ffff ) this . fail ( "Unicode escape is out of range" ) ;
300+ value += String . fromCodePoint ( codePoint ) ;
301+ } else {
302+ value += String . fromCodePoint ( this . parseHexDigits ( 4 ) ) ;
303+ }
304+ continue ;
305+ }
306+ if ( escaped === "\n" ) continue ;
307+ if ( escaped === "\r" ) {
308+ this . consume ( "\n" ) ;
309+ continue ;
310+ }
311+ value += escaped ;
243312 }
244- if ( source . startsWith ( "//" , i ) ) {
245- i = skipLineComment ( source , i + 2 ) + 1 ;
246- continue ;
313+ this . fail ( "unterminated string literal" ) ;
314+ }
315+
316+ private parseNumber ( ) : number {
317+ const match = this . source
318+ . slice ( this . index )
319+ . match ( / ^ - ? (?: 0 | [ 1 - 9 ] \d * ) (?: \. \d + ) ? (?: [ e E ] [ + - ] ? \d + ) ? / ) ;
320+ if ( ! match ) this . fail ( "invalid number literal" ) ;
321+ this . index += match [ 0 ] . length ;
322+ const value = Number ( match [ 0 ] ) ;
323+ if ( ! Number . isFinite ( value ) ) this . fail ( "number literal must be finite" ) ;
324+ return value ;
325+ }
326+
327+ private parseIdentifier ( ) : string {
328+ const match = this . source . slice ( this . index ) . match ( / ^ [ A - Z a - z _ $ ] [ \w $ ] * / ) ;
329+ if ( ! match ) this . fail ( "expected a static property name" ) ;
330+ this . index += match [ 0 ] . length ;
331+ return match [ 0 ] ;
332+ }
333+
334+ private parseHexDigits ( length : number ) : number {
335+ const digits = this . source . slice ( this . index , this . index + length ) ;
336+ if ( digits . length !== length || ! / ^ [ 0 - 9 a - f A - F ] + $ / . test ( digits ) ) {
337+ this . fail ( "invalid hexadecimal escape" ) ;
247338 }
248- if ( source . startsWith ( "/*" , i ) ) {
249- i = skipBlockComment ( source , i + 2 ) + 1 ;
250- continue ;
339+ this . index += length ;
340+ return Number . parseInt ( digits , 16 ) ;
341+ }
342+
343+ private skipTrivia ( ) : void {
344+ for ( ; ; ) {
345+ while ( this . index < this . source . length && / \s / . test ( this . source [ this . index ] ! ) ) {
346+ this . index += 1 ;
347+ }
348+ if ( this . source . startsWith ( "//" , this . index ) ) {
349+ const end = this . source . indexOf ( "\n" , this . index + 2 ) ;
350+ this . index = end < 0 ? this . source . length : end + 1 ;
351+ continue ;
352+ }
353+ if ( this . source . startsWith ( "/*" , this . index ) ) {
354+ const end = this . source . indexOf ( "*/" , this . index + 2 ) ;
355+ if ( end < 0 ) this . fail ( "unterminated block comment" ) ;
356+ this . index = end + 2 ;
357+ continue ;
358+ }
359+ return ;
251360 }
252- break ;
253361 }
254- return i ;
255- }
256362
257- function isOnlyPreamble ( text : string ) : boolean {
258- // strip block comments, line comments, whitespace
259- const stripped = text
260- . replace ( / \/ \* [ \s \S ] * ?\* \/ / g, "" )
261- . replace ( / \/ \/ .* $ / gm, "" )
262- . trim ( ) ;
263- return stripped . length === 0 ;
264- }
363+ private expect ( token : string ) : void {
364+ if ( ! this . consume ( token ) ) this . fail ( `expected ${ JSON . stringify ( token ) } ` ) ;
365+ }
265366
266- function evaluateMetaLiteral ( literal : string , filename : string ) : unknown {
267- try {
268- const value = vm . runInNewContext ( `(${ literal } )` , Object . create ( null ) , {
269- filename : `${ filename } :meta` ,
270- timeout : 1000 ,
271- } ) ;
272- // Rehydrate into the host realm — vm values keep context prototypes which
273- // break assert.deepEqual and other host identity checks.
274- return JSON . parse ( JSON . stringify ( value ) ) ;
275- } catch ( error ) {
276- const message = error instanceof Error ? error . message : String ( error ) ;
277- throw new WorkflowScriptError ( "meta" , `Invalid meta literal: ${ message } ` ) ;
367+ private consume ( token : string ) : boolean {
368+ if ( ! this . source . startsWith ( token , this . index ) ) return false ;
369+ this . index += token . length ;
370+ return true ;
371+ }
372+
373+ private fail ( message : string ) : never {
374+ throw new Error ( `${ message } at offset ${ this . index } ` ) ;
278375 }
279376}
280377
0 commit comments