luau-obfuscator 1.0.0
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.
- package/.github/workflows/release.yml +57 -0
- package/dist/index.cjs +1097 -0
- package/dist/index.d.cts +79 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +1068 -0
- package/generated/final.luau +1029 -0
- package/package.json +38 -0
- package/scripts/example.luau +1174 -0
- package/scripts/test.js +8 -0
- package/src/config.ts +80 -0
- package/src/index.ts +22 -0
- package/src/passes/ConstantArray.ts +90 -0
- package/src/passes/EncryptNumbers.ts +66 -0
- package/src/passes/EncryptStrings.ts +83 -0
- package/src/passes/GlobalMapping.ts +194 -0
- package/src/passes/InsertJunk.ts +185 -0
- package/src/passes/Minify.ts +6 -0
- package/src/passes/NumbersToExpressions.ts +86 -0
- package/src/passes/RenameVariables.ts +32 -0
- package/src/passes/StringsToExpressions.ts +56 -0
- package/src/passes/StripTypes.ts +192 -0
- package/src/passes/WrapInFunction.ts +32 -0
- package/src/passes/nodeFactory.ts +143 -0
- package/src/passes/walk.ts +173 -0
- package/src/pipeline.ts +58 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +10 -0
package/scripts/test.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import { obfuscate } from '../dist/index.js'
|
|
3
|
+
|
|
4
|
+
const exampleScript = fs.readFileSync('./scripts/example.luau', 'utf-8')
|
|
5
|
+
const obfuscated = obfuscate(exampleScript, {
|
|
6
|
+
Minify: {active: false}
|
|
7
|
+
})
|
|
8
|
+
fs.writeFileSync('./generated/final.luau', obfuscated)
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { StringsToExpressionsOptions } from "./passes/StringsToExpressions"
|
|
2
|
+
import type { NumbersToExpressionsOptions } from "./passes/NumbersToExpressions"
|
|
3
|
+
import type { RenameVariablesOptions } from "./passes/RenameVariables"
|
|
4
|
+
import type { GlobalMappingOptions } from "./passes/GlobalMapping"
|
|
5
|
+
import type { EncryptStringsOptions } from "./passes/EncryptStrings"
|
|
6
|
+
import type { EncryptNumbersOptions } from "./passes/EncryptNumbers"
|
|
7
|
+
|
|
8
|
+
export type ObfuscateConfigEach<C> = ({
|
|
9
|
+
active: true
|
|
10
|
+
} & C) | {
|
|
11
|
+
active: false
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 각 feature의 옵션 타입은 여기서 새로 선언하지 않고 해당 패스 파일에서 export한
|
|
15
|
+
// *Options 타입을 그대로 참조한다. 옵션 모양이 바뀌면 패스 파일 하나만 고치면 됨.
|
|
16
|
+
export type ObfuscateConfig = {
|
|
17
|
+
Vmify: ObfuscateConfigEach<{}>
|
|
18
|
+
Minify: ObfuscateConfigEach<{}>
|
|
19
|
+
StringsToExpressions: ObfuscateConfigEach<StringsToExpressionsOptions>
|
|
20
|
+
NumbersToExpressions: ObfuscateConfigEach<NumbersToExpressionsOptions>
|
|
21
|
+
EncryptStrings: ObfuscateConfigEach<EncryptStringsOptions>
|
|
22
|
+
EncryptNumbers: ObfuscateConfigEach<EncryptNumbersOptions>
|
|
23
|
+
RenameVariables: ObfuscateConfigEach<RenameVariablesOptions>
|
|
24
|
+
GlobalMapping: ObfuscateConfigEach<GlobalMappingOptions>
|
|
25
|
+
ConstantArray: ObfuscateConfigEach<{}>
|
|
26
|
+
InsertJunk: ObfuscateConfigEach<{ probability: number, maxPerBlock: number }>
|
|
27
|
+
WrapInFunction: ObfuscateConfigEach<{}>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// active:true 분기의 옵션 타입만 추출 (naked type param에 대한 distributive conditional type)
|
|
31
|
+
type ExtractOptions<T> = T extends { active: true } ? Omit<T, "active"> : never
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 사용자가 넘기는 partial config.
|
|
35
|
+
* 각 feature는 `{active:false}`를 통째로 넘기거나,
|
|
36
|
+
* `{active:true}` + 옵션 일부만 override 하는 형태만 허용.
|
|
37
|
+
* (재귀적 DeepMerge는 이 판별 유니온 구조를 깨서 쓰지 않음)
|
|
38
|
+
*/
|
|
39
|
+
export type ObfuscatePartialConfig = {
|
|
40
|
+
[K in keyof ObfuscateConfig]?:
|
|
41
|
+
| { active: false }
|
|
42
|
+
| ({ active: true } & Partial<ExtractOptions<ObfuscateConfig[K]>>)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const ObfuscateDefault: ObfuscateConfig = {
|
|
46
|
+
Vmify: { active: true },
|
|
47
|
+
Minify: { active: true },
|
|
48
|
+
StringsToExpressions: { active: true, min: 5, max: 10 },
|
|
49
|
+
NumbersToExpressions: { active: true, min: 5, max: 10 },
|
|
50
|
+
EncryptStrings: { active: true },
|
|
51
|
+
EncryptNumbers: { active: true },
|
|
52
|
+
RenameVariables: { active: true, random: () => "_" + globalThis.crypto.randomUUID().replace(/-/g, "") },
|
|
53
|
+
GlobalMapping: { active: true, tableName: "GLOBAL" },
|
|
54
|
+
ConstantArray: { active: true },
|
|
55
|
+
InsertJunk: { active: true, probability: 0.7, maxPerBlock: 5 },
|
|
56
|
+
WrapInFunction: { active: true },
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function mergeConfig(
|
|
60
|
+
defaults: ObfuscateConfig,
|
|
61
|
+
partial: ObfuscatePartialConfig,
|
|
62
|
+
): ObfuscateConfig {
|
|
63
|
+
const result = {} as ObfuscateConfig
|
|
64
|
+
|
|
65
|
+
for (const key of Object.keys(defaults) as (keyof ObfuscateConfig)[]) {
|
|
66
|
+
const def = defaults[key]
|
|
67
|
+
const part = partial[key]
|
|
68
|
+
|
|
69
|
+
if (!part) {
|
|
70
|
+
(result as any)[key] = def
|
|
71
|
+
} else if (part.active === false) {
|
|
72
|
+
(result as any)[key] = { active: false }
|
|
73
|
+
} else {
|
|
74
|
+
// active:true -> 기본값 위에 override만 얕게 덮어씀 (재귀 병합 없음)
|
|
75
|
+
(result as any)[key] = { ...(def as object), ...(part as object), active: true }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Program } from "luau-parser"
|
|
2
|
+
import { luauparser } from "luau-parser"
|
|
3
|
+
import type { ObfuscateConfig, ObfuscateConfigEach, ObfuscatePartialConfig } from "./config"
|
|
4
|
+
import { ObfuscateDefault, mergeConfig } from "./config"
|
|
5
|
+
import { runPipeline } from "./pipeline"
|
|
6
|
+
import { minifyPrinted } from "./passes/Minify"
|
|
7
|
+
|
|
8
|
+
export type { ObfuscateConfig, ObfuscateConfigEach, ObfuscatePartialConfig } from "./config"
|
|
9
|
+
export { ObfuscateDefault } from "./config"
|
|
10
|
+
export { PASS_ORDER, PASS_MAP } from "./pipeline"
|
|
11
|
+
|
|
12
|
+
export function obfuscateByAst(program: Program, PConfig?: ObfuscatePartialConfig): string {
|
|
13
|
+
const config = mergeConfig(ObfuscateDefault, PConfig ?? {})
|
|
14
|
+
runPipeline(program, config)
|
|
15
|
+
const printed = luauparser.print(program)
|
|
16
|
+
return config.Minify.active ? minifyPrinted(printed) : printed
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function obfuscate(source: string, PConfig?: ObfuscatePartialConfig): string {
|
|
20
|
+
const program = luauparser.parse(source)
|
|
21
|
+
return obfuscateByAst(program, PConfig)
|
|
22
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Program, TableField } from "luau-parser"
|
|
2
|
+
import { transformExpressions } from "./walk"
|
|
3
|
+
import {
|
|
4
|
+
stringLiteral, numberLiteral, index as indexExpr, identifier, table, localStatement,
|
|
5
|
+
} from "./nodeFactory"
|
|
6
|
+
|
|
7
|
+
export interface ConstantArrayOptions {
|
|
8
|
+
/** 상수 배열을 담을 local 변수 이름. 생략하면 랜덤 생성됨
|
|
9
|
+
* (어차피 RenameVariables가 이후에 한 번 더 이름을 바꿔줌). */
|
|
10
|
+
arrayName?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Entry = { kind: "string"; value: string } | { kind: "number"; value: number }
|
|
14
|
+
type ConstKey = string
|
|
15
|
+
|
|
16
|
+
function keyOf(kind: Entry["kind"], value: string | number): ConstKey {
|
|
17
|
+
return kind === "string" ? `s:${value}` : `n:${value}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function shuffleIndices(length: number): number[] {
|
|
21
|
+
const order = Array.from({ length }, (_, i) => i)
|
|
22
|
+
for (let i = order.length - 1; i > 0; i--) {
|
|
23
|
+
const j = Math.floor(Math.random() * (i + 1))
|
|
24
|
+
;[order[i], order[j]] = [order[j], order[i]]
|
|
25
|
+
}
|
|
26
|
+
return order
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultArrayName(): string {
|
|
30
|
+
return "_CA" + Math.random().toString(36).slice(2, 8)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function runConstantArray(program: Program, options: ConstantArrayOptions): void {
|
|
34
|
+
const order: Entry[] = []
|
|
35
|
+
const seen = new Set<ConstKey>()
|
|
36
|
+
|
|
37
|
+
transformExpressions(program, (expr) => {
|
|
38
|
+
if (expr.type === "StringLiteral") {
|
|
39
|
+
const k = keyOf("string", expr.value)
|
|
40
|
+
if (!seen.has(k)) {
|
|
41
|
+
seen.add(k)
|
|
42
|
+
order.push({ kind: "string", value: expr.value })
|
|
43
|
+
}
|
|
44
|
+
} else if (expr.type === "NumberLiteral") {
|
|
45
|
+
const k = keyOf("number", expr.value)
|
|
46
|
+
if (!seen.has(k)) {
|
|
47
|
+
seen.add(k)
|
|
48
|
+
order.push({ kind: "number", value: expr.value })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (order.length === 0) return
|
|
55
|
+
|
|
56
|
+
// 2) 배열 안에서 실제로 놓일 위치를 등장 순서와 다르게 섞음.
|
|
57
|
+
// shuffledOrder[슬롯] = order 배열에서의 원래 인덱스
|
|
58
|
+
const shuffledOrder = shuffleIndices(order.length)
|
|
59
|
+
const indexOf = new Map<ConstKey, number>() // 상수 키 -> 1-based 배열 인덱스
|
|
60
|
+
shuffledOrder.forEach((originalIndex, slot) => {
|
|
61
|
+
const entry = order[originalIndex]
|
|
62
|
+
indexOf.set(keyOf(entry.kind, entry.value), slot + 1)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const arrayName = options.arrayName ?? defaultArrayName()
|
|
66
|
+
|
|
67
|
+
// 3) 기존 트리를 돌면서 리터럴을 ARR[idx] 접근으로 치환.
|
|
68
|
+
// 배열 선언문은 아직 트리에 넣지 않은 상태에서 해야 함 — 넣고 나서 돌리면
|
|
69
|
+
// 배열 자신의 값들까지 자기 자신을 가리키게 치환돼서 깨짐.
|
|
70
|
+
transformExpressions(program, (expr) => {
|
|
71
|
+
if (expr.type === "StringLiteral") {
|
|
72
|
+
const idx = indexOf.get(keyOf("string", expr.value))!
|
|
73
|
+
return indexExpr(identifier(arrayName), numberLiteral(idx))
|
|
74
|
+
}
|
|
75
|
+
if (expr.type === "NumberLiteral") {
|
|
76
|
+
const idx = indexOf.get(keyOf("number", expr.value))!
|
|
77
|
+
return indexExpr(identifier(arrayName), numberLiteral(idx))
|
|
78
|
+
}
|
|
79
|
+
return undefined
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// 4) 치환이 끝난 뒤에야 배열 선언문을 맨 위에 삽입.
|
|
83
|
+
const fields: TableField[] = shuffledOrder.map((originalIndex) => {
|
|
84
|
+
const entry = order[originalIndex]
|
|
85
|
+
const value = entry.kind === "string" ? stringLiteral(entry.value) : numberLiteral(entry.value)
|
|
86
|
+
return { type: "TableFieldPositional", value }
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
program.body.statements.unshift(localStatement(arrayName, table(fields)))
|
|
90
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Program, Expression } from "luau-parser"
|
|
2
|
+
import { transformExpressions } from "./walk"
|
|
3
|
+
import {
|
|
4
|
+
identifier, numberLiteral, call, member,
|
|
5
|
+
localFunctionStatement, functionParam, functionBody, block, returnStatement,
|
|
6
|
+
} from "./nodeFactory"
|
|
7
|
+
|
|
8
|
+
export interface EncryptNumbersOptions {}
|
|
9
|
+
|
|
10
|
+
// bit32.bxor는 32비트 부호 없는 정수만 다룸. 실수/음수/범위 밖 값은 그대로 둔다
|
|
11
|
+
// (그런 값은 NumbersToExpressions 쪽 산술식 위장으로 커버).
|
|
12
|
+
const UINT32_MAX = 0xFFFFFFFF
|
|
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
|
+
function isEncryptable(value: number): boolean {
|
|
23
|
+
return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** JS의 ^ 연산자는 32비트 부호 있는 정수로 계산하므로 >>> 0으로 다시
|
|
27
|
+
* 부호 없는 32비트 표현으로 맞춰줌 — bit32.bxor와 동일한 비트 결과가 나옴. */
|
|
28
|
+
function xor32(a: number, b: number): number {
|
|
29
|
+
return (a ^ b) >>> 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* local function <name>(n, key)
|
|
34
|
+
* return bit32.bxor(n, key)
|
|
35
|
+
* end
|
|
36
|
+
*/
|
|
37
|
+
function buildDecoderStatement(name: string) {
|
|
38
|
+
const nParam = randomName()
|
|
39
|
+
const keyParam = randomName()
|
|
40
|
+
|
|
41
|
+
const body = block([
|
|
42
|
+
returnStatement([call(member(identifier("bit32"), "bxor"), [identifier(nParam), identifier(keyParam)])]),
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
return localFunctionStatement(name, functionBody([functionParam(nParam), functionParam(keyParam)], body))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function runEncryptNumbers(program: Program, _options: EncryptNumbersOptions): void {
|
|
49
|
+
const decoderName = randomName()
|
|
50
|
+
let used = false
|
|
51
|
+
|
|
52
|
+
transformExpressions(program, (expr: Expression) => {
|
|
53
|
+
if (expr.type !== "NumberLiteral") return
|
|
54
|
+
if (!isEncryptable(expr.value)) return
|
|
55
|
+
|
|
56
|
+
used = true
|
|
57
|
+
const key = randomInt(1, 0xFFFFFF)
|
|
58
|
+
const encoded = xor32(expr.value, key)
|
|
59
|
+
|
|
60
|
+
return call(identifier(decoderName), [numberLiteral(encoded), numberLiteral(key)])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
if (!used) return
|
|
64
|
+
|
|
65
|
+
program.body.statements.unshift(buildDecoderStatement(decoderName))
|
|
66
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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))
|
|
83
|
+
}
|
|
@@ -0,0 +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
|
+
)
|
|
194
|
+
}
|