luau-obfuscator 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.github/workflows/release.yml +56 -56
  2. package/dist/index.cjs +2186 -95
  3. package/dist/index.d.cts +34 -2
  4. package/dist/index.d.ts +34 -2
  5. package/dist/index.js +2186 -95
  6. package/generated/grammers.luau +13643 -0
  7. package/generated/tests.luau +5567 -0
  8. package/package.json +2 -2
  9. package/scripts/test.js +24 -8
  10. package/{scripts/example.luau → smoketest/grammers.luau} +1174 -1174
  11. package/smoketest/tests.luau +115 -0
  12. package/src/config.ts +2 -1
  13. package/src/passes/ConstantArray.ts +90 -89
  14. package/src/passes/EncryptNumbers.ts +122 -65
  15. package/src/passes/EncryptStrings.ts +162 -82
  16. package/src/passes/GlobalMapping.ts +193 -193
  17. package/src/passes/InsertJunk.ts +184 -184
  18. package/src/passes/Minify.ts +5 -5
  19. package/src/passes/NumbersToExpressions.ts +183 -2
  20. package/src/passes/RenameVariables.ts +2 -9
  21. package/src/passes/StringsToExpressions.ts +195 -6
  22. package/src/passes/StripTypes.ts +191 -191
  23. package/src/passes/Vmify.ts +87 -0
  24. package/src/passes/WrapInFunction.ts +31 -31
  25. package/src/passes/nodeFactory.ts +36 -1
  26. package/src/passes/vmify/chunk.ts +54 -0
  27. package/src/passes/vmify/compiler.ts +1026 -0
  28. package/src/passes/vmify/names.ts +72 -0
  29. package/src/passes/vmify/opcodes.ts +80 -0
  30. package/src/passes/vmify/registers.ts +63 -0
  31. package/src/passes/vmify/runtime.ts +334 -0
  32. package/src/passes/vmify/scope-walk.ts +175 -0
  33. package/src/passes/vmify/serialize.ts +68 -0
  34. package/src/passes/walk.ts +141 -43
  35. package/src/pipeline.ts +5 -6
  36. package/tsconfig.json +20 -20
  37. package/tsup.config.ts +9 -9
  38. package/generated/final.luau +0 -1029
@@ -1,83 +1,163 @@
1
- import type { Program, Expression } from "luau-parser"
2
- import { transformExpressions } from "./walk"
3
- import {
4
- identifier, numberLiteral, call, member, index, table, positionalField,
5
- localFunctionStatement, functionParam, functionBody, block, localStatement,
6
- assignmentStatement, numericForStatement, returnStatement, unary,
7
- } from "./nodeFactory"
8
-
9
- export interface EncryptStringsOptions {}
10
-
11
- function randomInt(min: number, max: number): number {
12
- return Math.floor(Math.random() * (max - min + 1)) + min
13
- }
14
-
15
- function randomName(): string {
16
- return "_" + globalThis.crypto.randomUUID().replace(/-/g, "")
17
- }
18
-
19
- /** Luau 소스는 UTF-8 바이트 스트림이므로 charCodeAt이 아니라 실제 UTF-8 바이트로 변환. */
20
- function toUtf8Bytes(value: string): number[] {
21
- return Array.from(new TextEncoder().encode(value))
22
- }
23
-
24
- /**
25
- * 런타임 디코더를 조립:
26
- * local function <name>(data, key)
27
- * local out = {}
28
- * for i = 1, #data do
29
- * out[i] = string.char(bit32.bxor(data[i], key))
30
- * end
31
- * return table.concat(out)
32
- * end
33
- */
34
- function buildDecoderStatement(name: string) {
35
- const dataParam = randomName()
36
- const keyParam = randomName()
37
- const outVar = randomName()
38
- const iVar = randomName()
39
-
40
- const body = block([
41
- localStatement(outVar, table([])),
42
- numericForStatement(
43
- iVar,
44
- numberLiteral(1),
45
- unary("#", identifier(dataParam)),
46
- block([
47
- assignmentStatement(
48
- [index(identifier(outVar), identifier(iVar))],
49
- [call(member(identifier("string"), "char"), [
50
- call(member(identifier("bit32"), "bxor"), [
51
- index(identifier(dataParam), identifier(iVar)),
52
- identifier(keyParam),
53
- ]),
54
- ])],
55
- ),
56
- ]),
57
- ),
58
- returnStatement([call(member(identifier("table"), "concat"), [identifier(outVar)])]),
59
- ])
60
-
61
- return localFunctionStatement(name, functionBody([functionParam(dataParam), functionParam(keyParam)], body))
62
- }
63
-
64
- export function runEncryptStrings(program: Program, _options: EncryptStringsOptions): void {
65
- const decoderName = randomName()
66
- let used = false
67
-
68
- transformExpressions(program, (expr: Expression) => {
69
- if (expr.type !== "StringLiteral") return
70
- if (expr.value.length === 0) return
71
-
72
- used = true
73
- const key = randomInt(1, 255)
74
- const bytes = toUtf8Bytes(expr.value).map((b) => b ^ key)
75
- const dataTable = table(bytes.map((b) => positionalField(numberLiteral(b))))
76
-
77
- return call(identifier(decoderName), [dataTable, numberLiteral(key)])
78
- })
79
-
80
- if (!used) return
81
-
82
- program.body.statements.unshift(buildDecoderStatement(decoderName))
1
+ import type { Program, Expression } from "luau-parser"
2
+ import { transformExpressions } from "./walk"
3
+ import {
4
+ identifier, vmNumberLiteral, call, member, index, table, positionalField,
5
+ localFunctionStatement, functionParam, functionBody, block, localStatement,
6
+ assignmentStatement, numericForStatement, returnStatement, unary, binary,
7
+ } from "./nodeFactory"
8
+
9
+ export interface EncryptStringsOptions {}
10
+
11
+ const MIN_KEY_LEN = 4
12
+ const MAX_KEY_LEN = 12
13
+
14
+ function randomInt(min: number, max: number): number {
15
+ return Math.floor(Math.random() * (max - min + 1)) + min
16
+ }
17
+
18
+ function randomName(): string {
19
+ return "_" + globalThis.crypto.randomUUID().replace(/-/g, "")
20
+ }
21
+
22
+ /** Luau 소스는 UTF-8 바이트 스트림이므로 charCodeAt이 아니라 실제 UTF-8 바이트로 변환. */
23
+ function toUtf8Bytes(value: string): number[] {
24
+ return Array.from(new TextEncoder().encode(value))
25
+ }
26
+
27
+ /** bit32.lrotate(x, disp)와 동일한 32비트 좌회전. disp는 0~31 범위로 정규화. */
28
+ function lrotate32(x: number, disp: number): number {
29
+ const d = ((disp % 32) + 32) % 32
30
+ const xu = x >>> 0
31
+ if (d === 0) return xu
32
+ return ((xu << d) | (xu >>> (32 - d))) >>> 0
33
+ }
34
+
35
+ /**
36
+ * i번째(1-indexed) 바이트에 적용할 키스트림 바이트.
37
+ * - 롤링 키 배열에서 위치별로 다른 키 바이트를 뽑고
38
+ * - 좌회전으로 비트를 섞은 뒤
39
+ * - 위치(i)에 종속적인 바이트와 다시 섞는다.
40
+ * 같은 평문 바이트가 반복돼도 위치가 다르면 암호문 바이트가 달라져
41
+ * (반복 키 XOR과 달리) 단순 빈도분석/Kasiski류 공격에 내성을 갖는다.
42
+ */
43
+ function keystreamByte(keys: number[], mult: number, i: number): number {
44
+ const nKeys = keys.length
45
+ const keyByte = keys[(i - 1) % nKeys]
46
+ const rotated = lrotate32(keyByte, i % 8) & 0xFF
47
+ const posByte = (i * mult) & 0xFF
48
+ return (rotated ^ posByte) & 0xFF
49
+ }
50
+
51
+ /**
52
+ * 숫자 리터럴을 곧이곧대로 노출하지 않고, 산술식/bit32.bxor 호출로 위장한다.
53
+ * "decoder(literal, literal)" 패턴을 그대로 상수 접기(constant fold)하는
54
+ * 단순 정적 분석 스크립트를 무력화하기 위함.
55
+ */
56
+ function obfuscatedNumber(value: number): Expression {
57
+ const variant = randomInt(0, 2)
58
+ if (variant === 0) {
59
+ const a = randomInt(0, value)
60
+ return binary("+", vmNumberLiteral(a), vmNumberLiteral(value - a))
61
+ }
62
+ if (variant === 1) {
63
+ const a = randomInt(0, 0xFFFFFF)
64
+ return binary("-", vmNumberLiteral(value + a), vmNumberLiteral(a))
65
+ }
66
+ const a = randomInt(0, 0xFFFFFF)
67
+ const b = (value ^ a) >>> 0
68
+ return call(member(identifier("bit32"), "bxor"), [vmNumberLiteral(a), vmNumberLiteral(b)])
69
+ }
70
+
71
+ /**
72
+ * 런타임 디코더를 조립:
73
+ * local function <name>(data, keys, mult)
74
+ * local nKeys = #keys
75
+ * local out = {}
76
+ * for i = 1, #data do
77
+ * local keyByte = keys[((i - 1) % nKeys) + 1]
78
+ * local mixed = bit32.band(bit32.lrotate(keyByte, i % 8), 0xFF)
79
+ * local posByte = bit32.band(i * mult, 0xFF)
80
+ * out[i] = string.char(bit32.bxor(data[i], bit32.bxor(mixed, posByte)))
81
+ * end
82
+ * return table.concat(out)
83
+ * end
84
+ */
85
+ function buildDecoderStatement(name: string) {
86
+ const dataParam = randomName()
87
+ const keysParam = randomName()
88
+ const multParam = randomName()
89
+ const outVar = randomName()
90
+ const iVar = randomName()
91
+ const nKeysVar = randomName()
92
+
93
+ const idxExpr = binary(
94
+ "+",
95
+ binary("%", binary("-", identifier(iVar), vmNumberLiteral(1)), identifier(nKeysVar)),
96
+ vmNumberLiteral(1),
97
+ )
98
+ const keyByte = index(identifier(keysParam), idxExpr)
99
+ const mixed = call(member(identifier("bit32"), "band"), [
100
+ call(member(identifier("bit32"), "lrotate"), [keyByte, binary("%", identifier(iVar), vmNumberLiteral(8))]),
101
+ vmNumberLiteral(0xFF),
102
+ ])
103
+ const posByte = call(member(identifier("bit32"), "band"), [
104
+ binary("*", identifier(iVar), identifier(multParam)),
105
+ vmNumberLiteral(0xFF),
106
+ ])
107
+ const keystream = call(member(identifier("bit32"), "bxor"), [mixed, posByte])
108
+ const finalByte = call(member(identifier("bit32"), "bxor"), [
109
+ index(identifier(dataParam), identifier(iVar)),
110
+ keystream,
111
+ ])
112
+
113
+ const body = block([
114
+ localStatement(nKeysVar, unary("#", identifier(keysParam))),
115
+ localStatement(outVar, table([])),
116
+ numericForStatement(
117
+ iVar,
118
+ vmNumberLiteral(1),
119
+ unary("#", identifier(dataParam)),
120
+ block([
121
+ assignmentStatement(
122
+ [index(identifier(outVar), identifier(iVar))],
123
+ [call(member(identifier("string"), "char"), [finalByte])],
124
+ ),
125
+ ]),
126
+ ),
127
+ returnStatement([call(member(identifier("table"), "concat"), [identifier(outVar)])]),
128
+ ])
129
+
130
+ return localFunctionStatement(
131
+ name,
132
+ functionBody([functionParam(dataParam), functionParam(keysParam), functionParam(multParam)], body),
133
+ )
134
+ }
135
+
136
+ export function runEncryptStrings(program: Program, _options: EncryptStringsOptions): void {
137
+ const decoderName = randomName()
138
+ let used = false
139
+
140
+ transformExpressions(program, (expr: Expression) => {
141
+ if (expr.type !== "StringLiteral") return
142
+ if (expr.value.length === 0) return
143
+
144
+ used = true
145
+
146
+ const keyLen = randomInt(MIN_KEY_LEN, MAX_KEY_LEN)
147
+ const keys = Array.from({ length: keyLen }, () => randomInt(1, 255))
148
+ const mult = randomInt(1, 255)
149
+
150
+ const plainBytes = toUtf8Bytes(expr.value)
151
+ const cipherBytes = plainBytes.map((b, idx) => b ^ keystreamByte(keys, mult, idx + 1))
152
+
153
+ const dataTable = table(cipherBytes.map((b) => positionalField(vmNumberLiteral(b))))
154
+ const keysTable = table(keys.map((k) => positionalField(obfuscatedNumber(k))))
155
+ const multExpr = obfuscatedNumber(mult)
156
+
157
+ return call(identifier(decoderName), [dataTable, keysTable, multExpr])
158
+ })
159
+
160
+ if (!used) return
161
+
162
+ program.body.statements.unshift(buildDecoderStatement(decoderName))
83
163
  }
@@ -1,194 +1,194 @@
1
- import type {
2
- Program, Block, Statement, Expression, Identifier, ScopeAnalysis,
3
- } from "luau-parser"
4
- import { analyzeScopes, isGlobal, getBinding } from "luau-parser"
5
- import {
6
- identifier, numberLiteral, stringLiteral, index as indexExpr,
7
- table, computedField, localStatement,
8
- } from "./nodeFactory"
9
-
10
- export interface GlobalMappingOptions {
11
- /** 전역들을 담아둘 최상단 local 테이블 변수 이름. RenameVariables가 이후에
12
- * 다시 실행되면 이 이름도 다른 이름으로 한 번 더 바뀜. */
13
- tableName: string
14
- }
15
-
16
- type Key = number | string
17
-
18
- function randomInt(min: number, max: number): number {
19
- return Math.floor(Math.random() * (max - min + 1)) + min
20
- }
21
-
22
- function randomKey(): Key {
23
- // 숫자 키/문자열 키를 섞어서 접근 패턴을 예측하기 어렵게 함
24
- if (Math.random() < 0.5) return randomInt(1, 50)
25
- const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
26
- const len = randomInt(2, 4)
27
- let s = ""
28
- for (let i = 0; i < len; i++) s += chars[randomInt(0, chars.length - 1)]
29
- return s
30
- }
31
-
32
- function randomPath(): Key[] {
33
- const depth = randomInt(2, 4)
34
- const path: Key[] = []
35
- for (let i = 0; i < depth; i++) path.push(randomKey())
36
- return path
37
- }
38
-
39
- type TreeLeaf = { leaf: string }
40
- type TreeNode = Map<Key, TreeNode | TreeLeaf>
41
-
42
- function isLeaf(v: TreeNode | TreeLeaf): v is TreeLeaf {
43
- return !(v instanceof Map)
44
- }
45
-
46
- /** path를 따라 내려가며 중간 노드를 만들고 마지막 키에 리프를 심음.
47
- * 경로 충돌(이미 다른 값이 있음)이 나면 false를 반환 — 호출 쪽에서 다른 경로로 재시도. */
48
- function insertPath(root: TreeNode, path: Key[], globalName: string): boolean {
49
- let node = root
50
- for (let i = 0; i < path.length - 1; i++) {
51
- const key = path[i]
52
- let next = node.get(key)
53
- if (next === undefined) {
54
- next = new Map()
55
- node.set(key, next)
56
- } else if (isLeaf(next)) {
57
- return false
58
- }
59
- node = next as TreeNode
60
- }
61
- const lastKey = path[path.length - 1]
62
- if (node.has(lastKey)) return false
63
- node.set(lastKey, { leaf: globalName })
64
- return true
65
- }
66
-
67
- function assignPaths(globalNames: string[]): { root: TreeNode; paths: Map<string, Key[]> } {
68
- const root: TreeNode = new Map()
69
- const paths = new Map<string, Key[]>()
70
-
71
- for (const name of globalNames) {
72
- let path: Key[] = []
73
- let attempts = 0
74
- let ok = false
75
- while (!ok && attempts < 200) {
76
- path = randomPath()
77
- ok = insertPath(root, path, name)
78
- attempts++
79
- }
80
- paths.set(name, path)
81
- }
82
-
83
- return { root, paths }
84
- }
85
-
86
- function keyExpression(key: Key): Expression {
87
- return typeof key === "number" ? numberLiteral(key) : stringLiteral(key)
88
- }
89
-
90
- function buildTreeTableExpr(node: TreeNode): Expression {
91
- const fields = [...node.entries()].map(([key, value]) =>
92
- computedField(
93
- keyExpression(key),
94
- isLeaf(value) ? identifier(value.leaf) : buildTreeTableExpr(value),
95
- ),
96
- )
97
- return table(fields)
98
- }
99
-
100
- function buildIndexChain(tableName: string, path: Key[]): Expression {
101
- let expr: Expression = identifier(tableName)
102
- for (const key of path) {
103
- expr = indexExpr(expr, keyExpression(key))
104
- }
105
- return expr
106
- }
107
-
108
- /** node(원래 Identifier)를 같은 객체 참조를 유지한 채로 IndexExpression으로
109
- * 제자리 변형함 — 이 객체를 들고 있는 부모 필드(BinaryExpression.left 등)는
110
- * 어디 있는지 몰라도 되고, 그냥 이 객체가 바뀌면 자동으로 반영됨. */
111
- function morphIntoIndexChain(node: Identifier, tableName: string, path: Key[]): void {
112
- const built = buildIndexChain(tableName, path) as unknown as Record<string, unknown>
113
- const target = node as unknown as Record<string, unknown>
114
- for (const k of Object.keys(target)) delete target[k]
115
- Object.assign(target, built)
116
- }
117
-
118
- /**
119
- * `function Foo() end` / `function T.m() end` 형태의 target.base는 리터럴
120
- * 이름만 허용되는 문법 자리라 인덱스 체인으로 바꿀 수 없음. 이런 자리에 쓰인
121
- * 전역 이름은 매핑 대상에서 제외한다.
122
- * (statement 트리를 따라 내려가며 찾음 — 익명함수 표현식 내부에 중첩된
123
- * 전역 함수 선언 같은 극단적 케이스는 대상에서 빠질 수 있음)
124
- */
125
- function collectUnsafeFunctionDeclGlobals(program: Program, analysis: ScopeAnalysis): Set<string> {
126
- const unsafe = new Set<string>()
127
-
128
- function visitBlock(block: Block): void {
129
- for (const stmt of block.statements) visitStatement(stmt)
130
- }
131
-
132
- function visitStatement(stmt: Statement): void {
133
- switch (stmt.type) {
134
- case "FunctionDeclarationStatement": {
135
- const binding = getBinding(analysis, stmt.target.base)
136
- if (binding && isGlobal(binding)) unsafe.add(binding.name)
137
- visitBlock(stmt.func.body)
138
- return
139
- }
140
- case "LocalFunctionStatement":
141
- visitBlock(stmt.func.body)
142
- return
143
- case "DoStatement":
144
- visitBlock(stmt.body)
145
- return
146
- case "WhileStatement":
147
- visitBlock(stmt.body)
148
- return
149
- case "RepeatStatement":
150
- visitBlock(stmt.body)
151
- return
152
- case "IfStatement":
153
- for (const clause of stmt.clauses) visitBlock(clause.body)
154
- if (stmt.alternate) visitBlock(stmt.alternate)
155
- return
156
- case "NumericForStatement":
157
- visitBlock(stmt.body)
158
- return
159
- case "GenericForStatement":
160
- visitBlock(stmt.body)
161
- return
162
- default:
163
- return
164
- }
165
- }
166
-
167
- visitBlock(program.body)
168
- return unsafe
169
- }
170
-
171
- export function runGlobalMapping(program: Program, options: GlobalMappingOptions): void {
172
- const analysis = analyzeScopes(program)
173
- const unsafe = collectUnsafeFunctionDeclGlobals(program, analysis)
174
-
175
- const globalBindings = [...analysis.bindings.values()].filter(
176
- (b) => isGlobal(b) && b.references.length > 0 && !unsafe.has(b.name),
177
- )
178
- if (globalBindings.length === 0) return
179
-
180
- const { root, paths } = assignPaths(globalBindings.map((b) => b.name))
181
-
182
- for (const binding of globalBindings) {
183
- const path = paths.get(binding.name)!
184
- // declarationNode(대입으로 정의된 경우)는 이미 references에 포함된
185
- // 같은 객체라 references만 돌면 전부 커버됨
186
- for (const ref of binding.references) {
187
- morphIntoIndexChain(ref, options.tableName, path)
188
- }
189
- }
190
-
191
- program.body.statements.unshift(
192
- localStatement(options.tableName, buildTreeTableExpr(root)),
193
- )
1
+ import type {
2
+ Program, Block, Statement, Expression, Identifier, ScopeAnalysis,
3
+ } from "luau-parser"
4
+ import { analyzeScopes, isGlobal, getBinding } from "luau-parser"
5
+ import {
6
+ identifier, numberLiteral, stringLiteral, index as indexExpr,
7
+ table, computedField, localStatement,
8
+ } from "./nodeFactory"
9
+
10
+ export interface GlobalMappingOptions {
11
+ /** 전역들을 담아둘 최상단 local 테이블 변수 이름. RenameVariables가 이후에
12
+ * 다시 실행되면 이 이름도 다른 이름으로 한 번 더 바뀜. */
13
+ tableName: string
14
+ }
15
+
16
+ type Key = number | string
17
+
18
+ function randomInt(min: number, max: number): number {
19
+ return Math.floor(Math.random() * (max - min + 1)) + min
20
+ }
21
+
22
+ function randomKey(): Key {
23
+ // 숫자 키/문자열 키를 섞어서 접근 패턴을 예측하기 어렵게 함
24
+ if (Math.random() < 0.5) return randomInt(1, 50)
25
+ const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
26
+ const len = randomInt(2, 4)
27
+ let s = ""
28
+ for (let i = 0; i < len; i++) s += chars[randomInt(0, chars.length - 1)]
29
+ return s
30
+ }
31
+
32
+ function randomPath(): Key[] {
33
+ const depth = randomInt(2, 4)
34
+ const path: Key[] = []
35
+ for (let i = 0; i < depth; i++) path.push(randomKey())
36
+ return path
37
+ }
38
+
39
+ type TreeLeaf = { leaf: string }
40
+ type TreeNode = Map<Key, TreeNode | TreeLeaf>
41
+
42
+ function isLeaf(v: TreeNode | TreeLeaf): v is TreeLeaf {
43
+ return !(v instanceof Map)
44
+ }
45
+
46
+ /** path를 따라 내려가며 중간 노드를 만들고 마지막 키에 리프를 심음.
47
+ * 경로 충돌(이미 다른 값이 있음)이 나면 false를 반환 — 호출 쪽에서 다른 경로로 재시도. */
48
+ function insertPath(root: TreeNode, path: Key[], globalName: string): boolean {
49
+ let node = root
50
+ for (let i = 0; i < path.length - 1; i++) {
51
+ const key = path[i]
52
+ let next = node.get(key)
53
+ if (next === undefined) {
54
+ next = new Map()
55
+ node.set(key, next)
56
+ } else if (isLeaf(next)) {
57
+ return false
58
+ }
59
+ node = next as TreeNode
60
+ }
61
+ const lastKey = path[path.length - 1]
62
+ if (node.has(lastKey)) return false
63
+ node.set(lastKey, { leaf: globalName })
64
+ return true
65
+ }
66
+
67
+ function assignPaths(globalNames: string[]): { root: TreeNode; paths: Map<string, Key[]> } {
68
+ const root: TreeNode = new Map()
69
+ const paths = new Map<string, Key[]>()
70
+
71
+ for (const name of globalNames) {
72
+ let path: Key[] = []
73
+ let attempts = 0
74
+ let ok = false
75
+ while (!ok && attempts < 200) {
76
+ path = randomPath()
77
+ ok = insertPath(root, path, name)
78
+ attempts++
79
+ }
80
+ paths.set(name, path)
81
+ }
82
+
83
+ return { root, paths }
84
+ }
85
+
86
+ function keyExpression(key: Key): Expression {
87
+ return typeof key === "number" ? numberLiteral(key) : stringLiteral(key)
88
+ }
89
+
90
+ function buildTreeTableExpr(node: TreeNode): Expression {
91
+ const fields = [...node.entries()].map(([key, value]) =>
92
+ computedField(
93
+ keyExpression(key),
94
+ isLeaf(value) ? identifier(value.leaf) : buildTreeTableExpr(value),
95
+ ),
96
+ )
97
+ return table(fields)
98
+ }
99
+
100
+ function buildIndexChain(tableName: string, path: Key[]): Expression {
101
+ let expr: Expression = identifier(tableName)
102
+ for (const key of path) {
103
+ expr = indexExpr(expr, keyExpression(key))
104
+ }
105
+ return expr
106
+ }
107
+
108
+ /** node(원래 Identifier)를 같은 객체 참조를 유지한 채로 IndexExpression으로
109
+ * 제자리 변형함 — 이 객체를 들고 있는 부모 필드(BinaryExpression.left 등)는
110
+ * 어디 있는지 몰라도 되고, 그냥 이 객체가 바뀌면 자동으로 반영됨. */
111
+ function morphIntoIndexChain(node: Identifier, tableName: string, path: Key[]): void {
112
+ const built = buildIndexChain(tableName, path) as unknown as Record<string, unknown>
113
+ const target = node as unknown as Record<string, unknown>
114
+ for (const k of Object.keys(target)) delete target[k]
115
+ Object.assign(target, built)
116
+ }
117
+
118
+ /**
119
+ * `function Foo() end` / `function T.m() end` 형태의 target.base는 리터럴
120
+ * 이름만 허용되는 문법 자리라 인덱스 체인으로 바꿀 수 없음. 이런 자리에 쓰인
121
+ * 전역 이름은 매핑 대상에서 제외한다.
122
+ * (statement 트리를 따라 내려가며 찾음 — 익명함수 표현식 내부에 중첩된
123
+ * 전역 함수 선언 같은 극단적 케이스는 대상에서 빠질 수 있음)
124
+ */
125
+ function collectUnsafeFunctionDeclGlobals(program: Program, analysis: ScopeAnalysis): Set<string> {
126
+ const unsafe = new Set<string>()
127
+
128
+ function visitBlock(block: Block): void {
129
+ for (const stmt of block.statements) visitStatement(stmt)
130
+ }
131
+
132
+ function visitStatement(stmt: Statement): void {
133
+ switch (stmt.type) {
134
+ case "FunctionDeclarationStatement": {
135
+ const binding = getBinding(analysis, stmt.target.base)
136
+ if (binding && isGlobal(binding)) unsafe.add(binding.name)
137
+ visitBlock(stmt.func.body)
138
+ return
139
+ }
140
+ case "LocalFunctionStatement":
141
+ visitBlock(stmt.func.body)
142
+ return
143
+ case "DoStatement":
144
+ visitBlock(stmt.body)
145
+ return
146
+ case "WhileStatement":
147
+ visitBlock(stmt.body)
148
+ return
149
+ case "RepeatStatement":
150
+ visitBlock(stmt.body)
151
+ return
152
+ case "IfStatement":
153
+ for (const clause of stmt.clauses) visitBlock(clause.body)
154
+ if (stmt.alternate) visitBlock(stmt.alternate)
155
+ return
156
+ case "NumericForStatement":
157
+ visitBlock(stmt.body)
158
+ return
159
+ case "GenericForStatement":
160
+ visitBlock(stmt.body)
161
+ return
162
+ default:
163
+ return
164
+ }
165
+ }
166
+
167
+ visitBlock(program.body)
168
+ return unsafe
169
+ }
170
+
171
+ export function runGlobalMapping(program: Program, options: GlobalMappingOptions): void {
172
+ const analysis = analyzeScopes(program)
173
+ const unsafe = collectUnsafeFunctionDeclGlobals(program, analysis)
174
+
175
+ const globalBindings = [...analysis.bindings.values()].filter(
176
+ (b) => isGlobal(b) && b.references.length > 0 && !unsafe.has(b.name),
177
+ )
178
+ if (globalBindings.length === 0) return
179
+
180
+ const { root, paths } = assignPaths(globalBindings.map((b) => b.name))
181
+
182
+ for (const binding of globalBindings) {
183
+ const path = paths.get(binding.name)!
184
+ // declarationNode(대입으로 정의된 경우)는 이미 references에 포함된
185
+ // 같은 객체라 references만 돌면 전부 커버됨
186
+ for (const ref of binding.references) {
187
+ morphIntoIndexChain(ref, options.tableName, path)
188
+ }
189
+ }
190
+
191
+ program.body.statements.unshift(
192
+ localStatement(options.tableName, buildTreeTableExpr(root)),
193
+ )
194
194
  }