diff --git a/.luaurc b/.luaurc index d60d4c0..9a92598 100644 --- a/.luaurc +++ b/.luaurc @@ -1,13 +1,16 @@ { - "languageMode": "nonstrict", - "lint": { - "*": true - }, - "lintErrors": false, - "typeErrors": true, - "aliases": { - "client": "./modules/client", - "server": "./modules/server", - "shared": "./modules/shared" - } + "lint": { + "*": true + }, + "languageMode": "nonstrict", + "typeErrors": true, + "lintErrors": false, + "aliases": { + "std": "~/.lute/typedefs/0.1.0/std", + "shared": "./modules/shared", + "lint": "~/.lute/typedefs/0.1.0/lint", + "lute": "~/.lute/typedefs/0.1.0/lute", + "client": "./modules/client", + "server": "./modules/server" + } } diff --git a/aftman.toml b/aftman.toml index bac4749..231136b 100755 --- a/aftman.toml +++ b/aftman.toml @@ -8,3 +8,4 @@ darklua = "seaofvoices/darklua@0.17.3" selene = "Kampfkarren/selene@0.29.0" StyLua = "JohnnyMorganz/StyLua@2.3.1" lune = "lune-org/lune@0.10.4" +lute = "luau-lang/lute@0.1.0-nightly.20260408" diff --git a/checks/checkAll.luau b/checks/checkAll.luau new file mode 100644 index 0000000..702e294 --- /dev/null +++ b/checks/checkAll.luau @@ -0,0 +1,284 @@ +--!strict +--!native + +local json = require("@std/json") +local fs = require("@lute/fs") + +local luaurc +do + local handle = fs.open("./.luaurc", "r") + luaurc = assert(json.asObject(json.deserialize(fs.read(handle))), "expected object in luaurc") + fs.close(handle) + handle = nil +end + +local aliases: { [string]: string } = assert(luaurc["aliases"], "expected aliases key in luaurc") :: any + +local Path = require("@std/path") +local AstParser = require("@std/syntax/parser") +local Query = require("@std/syntax/query") +local SyntaxUtils = require("@std/syntax/utils") +local AstTypes = require("@std/syntax/types") +local tableext = require("@std/tableext") +local checkWorkerManagerContract = require("./workerManagerContract") + +local ENTRYPOINTS = table.freeze({ + "src/server/workerManager.server.luau", + "src/client/localWorkerManager.luau", +}) + +local DEFAULT_OUTPUT_PATH = "checks/checkAll.arrowcrab.json" + +local COLORS = { + entrypoint = "#F2C166", + server = "#F28B82", + client = "#8AB4F8", + shared = "#81C995", + other = "#C58AF9", +} + +type ModuleInfo = { + path: string, + dependencies: { string }, + isEntrypoint: boolean, +} + +type GraphData = { + subgraphs: { number }, + nodes: { number }, + edges: { number }, + strings: { string }, +} + +local function resolveRequirePath(path: string, callerPath: string): string? + local pieces = string.split(path, "/") + + if string.sub(pieces[1], 1, 1) == "@" then + pieces[1] = string.sub(pieces[1], 2) + + if pieces[1] == "self" then + pieces[1] = Path.dirname(callerPath) + else + pieces[1] = assert(aliases[pieces[1]], `failed finding alias {pieces[1]}`) + end + end + + local final = Path.format(Path.join(unpack(pieces))) + + local stat + pcall(function(...) + stat = fs.stat(final) + end) + + if not stat then + return final .. ".luau" + elseif stat.type == "dir" then + return final .. "/init.luau" + end + + return nil -- yaml, toml, etc. +end + +local function writeFile(file: string, contents: string) + local handle = fs.open(file, "w") + fs.write(handle, contents) + fs.close(handle) +end + +local function readFile(file: string): string + local handle = fs.open(file) + local source = fs.read(handle) + fs.close(handle) + return source +end + +local function collectDependencies(file: string, ast: AstTypes.ParseResult): { string } + local dependencies: { string } = {} + local seenDependencies: { [string]: boolean } = {} + + Query.findAllFromRoot(ast, SyntaxUtils.isExprCall :: any):forEach(function(expr: AstTypes.AstExprCall) + if + SyntaxUtils.isExprGlobal(expr.func :: any) + and (expr.func :: AstTypes.AstExprGlobal).name.text == "require" + and #expr.arguments == 1 + and SyntaxUtils.isExprConstantString(expr.arguments[1].node :: any) + then + local path = (expr.arguments[1].node :: AstTypes.AstExprConstantString).token.text + local resolved = resolveRequirePath(path, file) + if resolved and not seenDependencies[resolved] then + seenDependencies[resolved] = true + table.insert(dependencies, resolved) + end + end + end) + + return dependencies +end + +local function getNodeColor(file: string, isEntrypoint: boolean): string + if isEntrypoint then + return COLORS.entrypoint + elseif string.find(file, "src/server/", 1, true) or string.find(file, "modules/server/", 1, true) then + return COLORS.server + elseif string.find(file, "src/client/", 1, true) or string.find(file, "modules/client/", 1, true) then + return COLORS.client + elseif string.find(file, "src/shared/", 1, true) or string.find(file, "modules/shared/", 1, true) then + return COLORS.shared + else + return COLORS.other + end +end + +local function buildArrowCrabGraph(entrypoints: { string }): GraphData + local visitState: { [string]: "visiting" | "done" } = {} + local modulesByPath: { [string]: ModuleInfo } = {} + local orderedPaths: { string } = {} + + local function visit(file: string, isEntrypoint: boolean) + local state = visitState[file] + if state == "done" then + if isEntrypoint then + modulesByPath[file].isEntrypoint = true + end + + return + elseif state == "visiting" then + error(`cyclic require graph detected involving {file}`) + end + + visitState[file] = "visiting" + + local dependencies = collectDependencies(file, AstParser.parse(readFile(file))) + for _, dependency in dependencies do + visit(dependency, false) + end + + modulesByPath[file] = { + path = file, + dependencies = dependencies, + isEntrypoint = isEntrypoint, + } + + visitState[file] = "done" + table.insert(orderedPaths, file) + end + + for _, entrypoint in entrypoints do + visit(entrypoint, true) + end + + local strings: { string } = {} + local stringToIndex: { [string]: number } = {} + local nodeIds: { [string]: number } = {} + local nodes: { number } = {} + local edges: { number } = {} + + local function internString(value: string): number + local existing = stringToIndex[value] + if existing ~= nil then + return existing + end + + local index = #strings + table.insert(strings, value) + stringToIndex[value] = index + return index + end + + for id, path in orderedPaths do + local nodeId = id - 1 + local moduleInfo = modulesByPath[path] + nodeIds[path] = nodeId + + table.insert(nodes, nodeId) + table.insert(nodes, internString(moduleInfo.path)) + table.insert(nodes, internString(getNodeColor(path, moduleInfo.isEntrypoint))) + end + + for _, path in orderedPaths do + local moduleInfo = modulesByPath[path] + local targetId = nodeIds[path] + for _, dependency in moduleInfo.dependencies do + table.insert(edges, nodeIds[dependency]) + table.insert(edges, 0) + table.insert(edges, targetId) + table.insert(edges, 0) + end + end + + return { + subgraphs = {}, + nodes = nodes, + edges = edges, + strings = strings, + } +end + +local function writeArrowCrabGraph(outputPath: string, graphData: GraphData) + local handle = fs.open(outputPath, "w") + fs.write(handle, json.serialize(graphData :: any)) + fs.close(handle) +end + +local argv = { ... } + +if argv[2] == "--graph" then + local providedOutputPath = argv[3] + local outputPath = (providedOutputPath :: string?) or DEFAULT_OUTPUT_PATH + + writeArrowCrabGraph(outputPath, buildArrowCrabGraph(ENTRYPOINTS)) + print(`wrote ArrowCrab (https://hemileia.club/arrowcrab/) graph to {outputPath}`) +else + local TriviaUtils = require("@std/syntax/utils/trivia") + + local patch = argv[2] == "--patch" + + local function insertAfterLeadingTrivia(source: string, ast: AstTypes.ParseResult, text: string): string + local firstStatement = ast.root.statements[1] + if firstStatement == nil then + return text .. source + end + + local leadingTrivia = TriviaUtils.leftmostTrivia(firstStatement) + if #leadingTrivia == 0 then + return text .. source + end + + local prefixLength = 0 + for _, trivia in leadingTrivia do + prefixLength += #trivia.text + end + + return string.sub(source, 1, prefixLength) .. text .. string.sub(source, prefixLength + 1) + end + + for _, entrypoint in ENTRYPOINTS do + local function visit(path: string) + local source = readFile(path) + local ast = AstParser.parse(source) + local globalsMap = checkWorkerManagerContract(ast.root, path) + + if patch then + local globalsArray = tableext.keys(globalsMap) + + if #globalsArray > 0 then + local pretext = table.concat( + tableext.map(globalsArray, function(global: string) + return `local {global} = {global}\n` + end), + "" + ) + + source = insertAfterLeadingTrivia(source, ast, pretext) + writeFile(path, source) + end + end + + for _, dependency in collectDependencies(path, ast) do + visit(dependency) + end + end + + visit(entrypoint) + end +end diff --git a/checks/exampleUnsafeUsage.luau b/checks/exampleUnsafeUsage.luau new file mode 100644 index 0000000..e90e3df --- /dev/null +++ b/checks/exampleUnsafeUsage.luau @@ -0,0 +1,22 @@ +-- pretend this is running in a worker manager + +local os = os + +do + local a = bit32 + local b = buffer +end + +local function a() + buffer.create(0) -- unsafe +end + +function b() + function c() + buffer.create(0) -- unsafe + end +end + +local c = function() + buffer.create(0) -- unsafe +end diff --git a/checks/workerManagerContract.luau b/checks/workerManagerContract.luau new file mode 100644 index 0000000..a4b118f --- /dev/null +++ b/checks/workerManagerContract.luau @@ -0,0 +1,98 @@ +--!native +--!strict + +local AstVisitor = require("@std/syntax/visitor") +local SyntaxUtils = require("@std/syntax/utils") +local types = require("@std/syntax/types") +local tableext = require("@std/tableext") + +local function check(root: types.AstStatBlock, filePath: string): { [string]: true } + local visitor = AstVisitor.create(nil) + local functionScopes: { types.AstExprFunction } = {} + local functionNameMap: { [types.AstExprFunction]: { + name: string?, + location: types.Span, + } } = {} + + local globals: { [string]: true } = {} + + local function getScopePath() + return table.concat( + tableext.map(functionScopes, function(expr: types.AstExprFunction) + return if functionNameMap[expr] then functionNameMap[expr].name else "" + end), + "->" + ) + end + + local function formatSpan(span: types.Span, filePath: string) + return `{filePath}:{span.beginLine}:{span.beginColumn}-{span.endColumn}` + end + + visitor.visitExprGlobal = function(expr: types.AstExprGlobal) + if #functionScopes > 0 then + -- not in root scope, in a function + print( + `usage of global '{expr.name.text}' @ {formatSpan(expr.location, filePath)} in scope {getScopePath()} is unsafe` + ) + + globals[expr.name.text] = true + end + + return true + end + + local disable = function() + return false + end + + visitor.visitStatTypeFunction = disable + visitor.visitTypeFunction = disable + + visitor.visitStatFunction = function(stat: types.AstStatFunction) + functionNameMap[stat.func] = { + name = SyntaxUtils.isExprGlobal(stat.name :: any) and (stat.name :: types.AstExprGlobal).name.text or nil, + location = stat.location, + } + + AstVisitor.visit(stat.func, visitor) + + -- skip the AstExprGlobal in this stat function + return false + end + + visitor.visitStatLocalFunction = function(stat: types.AstStatLocalFunction) + functionNameMap[stat.func] = { + name = stat.name.name.text, + location = stat.location, + } + + return true + end + + visitor.visitStatLocalDeclaration = function(stat: types.AstStatLocal) + for index, value in stat.values do + functionNameMap[value.node :: types.AstExprFunction] = { + name = SyntaxUtils.isExprFunction(value.node :: any) and stat.variables[index].node.name.text or nil, + location = value.node.location, + } + end + return true + end + + visitor.visitExprFunction = function(expr: types.AstExprFunction) + table.insert(functionScopes, expr) + + return true + end + + visitor.visitExprFunctionEnd = function(expr: types.AstExprFunction) + table.remove(functionScopes) + end + + AstVisitor.visit(root, visitor) + + return globals +end + +return check diff --git a/modules/client/wm/sandbox/init.luau b/modules/client/wm/sandbox/init.luau index 4e9a8ff..b75829e 100644 --- a/modules/client/wm/sandbox/init.luau +++ b/modules/client/wm/sandbox/init.luau @@ -1,3 +1,9 @@ +local task = task +local table = table +local pcall = pcall +local rawset = rawset +local error = error +local getfenv = getfenv local game = game local setmetatable = setmetatable local os = os diff --git a/modules/client/wm/sandbox/rules.luau b/modules/client/wm/sandbox/rules.luau index f41c23e..0a85681 100644 --- a/modules/client/wm/sandbox/rules.luau +++ b/modules/client/wm/sandbox/rules.luau @@ -1,3 +1,7 @@ +local Instance = Instance +local game = game +local table = table +local ipairs = ipairs local tonumber = tonumber local error = error local typeof = typeof diff --git a/modules/client/wm/scriptManager.luau b/modules/client/wm/scriptManager.luau index 2c0fb10..b7508d6 100644 --- a/modules/client/wm/scriptManager.luau +++ b/modules/client/wm/scriptManager.luau @@ -1,3 +1,10 @@ +local coroutine = coroutine +local pcall = pcall +local unpack = unpack +local typeof = typeof +local _G = _G +local require = require +local ipairs = ipairs local setmetatable = setmetatable local shared = shared local rawequal = rawequal diff --git a/modules/client/wm/stackTrace.luau b/modules/client/wm/stackTrace.luau index 32ac147..0a78066 100644 --- a/modules/client/wm/stackTrace.luau +++ b/modules/client/wm/stackTrace.luau @@ -1,3 +1,4 @@ +local tonumber = tonumber local table = table local ipairs = ipairs diff --git a/modules/server/compile.luau b/modules/server/compile.luau index d62611e..c0bb363 100644 --- a/modules/server/compile.luau +++ b/modules/server/compile.luau @@ -1,3 +1,7 @@ +local table = table +local _G = _G +local type = type +local ipairs = ipairs local buffer = buffer local string = string local pcall = pcall diff --git a/modules/server/wm/sandbox/environment.luau b/modules/server/wm/sandbox/environment.luau index 547c260..e001175 100644 --- a/modules/server/wm/sandbox/environment.luau +++ b/modules/server/wm/sandbox/environment.luau @@ -1,3 +1,5 @@ +local unpack = unpack +local tostring = tostring local setmetatable = setmetatable local error = error local table = table diff --git a/modules/server/wm/sandbox/init.luau b/modules/server/wm/sandbox/init.luau index 1036345..c2548a6 100644 --- a/modules/server/wm/sandbox/init.luau +++ b/modules/server/wm/sandbox/init.luau @@ -1,3 +1,5 @@ +local require = require +local setfenv = setfenv local table = table local task = task local pcall = pcall diff --git a/modules/server/wm/sandbox/rules.luau b/modules/server/wm/sandbox/rules.luau index 760b429..baccfa9 100644 --- a/modules/server/wm/sandbox/rules.luau +++ b/modules/server/wm/sandbox/rules.luau @@ -1,3 +1,6 @@ +local game = game +local warn = warn +local ipairs = ipairs local table = table local typeof = typeof local error = error diff --git a/modules/server/wm/sandbox/wrapper/init.luau b/modules/server/wm/sandbox/wrapper/init.luau index c97ca4e..fcd8a20 100644 --- a/modules/server/wm/sandbox/wrapper/init.luau +++ b/modules/server/wm/sandbox/wrapper/init.luau @@ -1,3 +1,6 @@ +local getfenv = getfenv +local newproxy = newproxy +local setfenv = setfenv local table = table local setmetatable = setmetatable local typeof = typeof diff --git a/modules/server/wm/sandbox/wrapper/reflection.luau b/modules/server/wm/sandbox/wrapper/reflection.luau index 04df82b..c94f087 100644 --- a/modules/server/wm/sandbox/wrapper/reflection.luau +++ b/modules/server/wm/sandbox/wrapper/reflection.luau @@ -1,3 +1,4 @@ +local tostring = tostring local table = table local setmetatable = setmetatable local error = error diff --git a/modules/server/wm/scriptManager.luau b/modules/server/wm/scriptManager.luau index 8b18e7c..112bb47 100644 --- a/modules/server/wm/scriptManager.luau +++ b/modules/server/wm/scriptManager.luau @@ -1,3 +1,7 @@ +local Instance = Instance +local _G = _G +local require = require +local unpack = unpack local setmetatable = setmetatable local shared = shared local typeof = typeof diff --git a/modules/shared/crypto/CSPRNG/Blake3.luau b/modules/shared/crypto/CSPRNG/Blake3.luau index 0b64a79..b3b010f 100644 --- a/modules/shared/crypto/CSPRNG/Blake3.luau +++ b/modules/shared/crypto/CSPRNG/Blake3.luau @@ -26,6 +26,9 @@ --!optimize 2 --!native +local math = math +local buffer = buffer +local bit32 = bit32 local BLOCK_SIZE = 64 local CV_SIZE = 32 local EXTENDED_CV_SIZE = 64 diff --git a/modules/shared/crypto/CSPRNG/ChaCha20.luau b/modules/shared/crypto/CSPRNG/ChaCha20.luau index f0ac3f2..f8e3cd7 100644 --- a/modules/shared/crypto/CSPRNG/ChaCha20.luau +++ b/modules/shared/crypto/CSPRNG/ChaCha20.luau @@ -22,6 +22,11 @@ --!native --!optimize 2 +local buffer = buffer +local typeof = typeof +local math = math +local error = error +local bit32 = bit32 local DWORD = 4 local BLOCK_SIZE = 64 local STATE_SIZE = 16 diff --git a/modules/shared/crypto/CSPRNG/Conversions.luau b/modules/shared/crypto/CSPRNG/Conversions.luau index 7eeabf8..28fe39a 100644 --- a/modules/shared/crypto/CSPRNG/Conversions.luau +++ b/modules/shared/crypto/CSPRNG/Conversions.luau @@ -11,6 +11,10 @@ --!optimize 2 --!native +local type = type +local buffer = buffer +local error = error +local bit32 = bit32 local ENCODE_LOOKUP = buffer.create(256 * 2) do local HexChars = "0123456789abcdef" diff --git a/modules/shared/crypto/CSPRNG/init.luau b/modules/shared/crypto/CSPRNG/init.luau index 5b66de5..8ace3b4 100644 --- a/modules/shared/crypto/CSPRNG/init.luau +++ b/modules/shared/crypto/CSPRNG/init.luau @@ -24,6 +24,24 @@ --!optimize 2 --!strict +local string = string +local warn = warn +local tostring = tostring +local DateTime = DateTime +local tick = tick +local math = math +local coroutine = coroutine +local bit32 = bit32 +local buffer = buffer +local typeof = typeof +local error = error +local type = type +local table = table +local pcall = pcall +local os = os +local game = game +local newproxy = newproxy +local workspace = workspace local Conversions = require("@self/Conversions") local ChaCha20 = require("@self/ChaCha20") local Blake3 = require("@self/Blake3") diff --git a/modules/shared/enum.luau b/modules/shared/enum.luau index c3d617e..e82382c 100644 --- a/modules/shared/enum.luau +++ b/modules/shared/enum.luau @@ -1,3 +1,6 @@ +local _G = _G +local setmetatable = setmetatable +local error = error local ipairs = ipairs local table = table diff --git a/modules/shared/errors.luau b/modules/shared/errors.luau index 5926bd0..335383b 100644 --- a/modules/shared/errors.luau +++ b/modules/shared/errors.luau @@ -1,3 +1,4 @@ +local type = type local Functions = require("@shared/functions") local getInstanceName = Functions.getInstanceName diff --git a/modules/shared/functions.luau b/modules/shared/functions.luau index e0b6a73..24b4935 100644 --- a/modules/shared/functions.luau +++ b/modules/shared/functions.luau @@ -1,4 +1,7 @@ -- Maybe this should be rewritten to have each function in it's own file? +local tostring = tostring +local type = type +local tonumber = tonumber local string = string local game = game local coroutine = coroutine diff --git a/modules/shared/log.luau b/modules/shared/log.luau index 281f4d4..49f484c 100644 --- a/modules/shared/log.luau +++ b/modules/shared/log.luau @@ -1,3 +1,4 @@ +local _G = _G local table = table local print = print local warn = warn diff --git a/modules/shared/wm/communication.luau b/modules/shared/wm/communication.luau index 0ecd125..e177030 100644 --- a/modules/shared/wm/communication.luau +++ b/modules/shared/wm/communication.luau @@ -1,3 +1,9 @@ +local debug = debug +local unpack = unpack +local coroutine = coroutine +local _G = _G +local pcall = pcall +local ipairs = ipairs local setmetatable = setmetatable local table = table local error = error diff --git a/modules/shared/wm/protection/init.luau b/modules/shared/wm/protection/init.luau index 02eaba8..12c7209 100644 --- a/modules/shared/wm/protection/init.luau +++ b/modules/shared/wm/protection/init.luau @@ -10,6 +10,9 @@ each further level implies the last -- local ManagerCommunication = require("@shared/wm/communication") +local require = require +local error = error +local ipairs = ipairs local protectedInstances: { [Instance]: number } = {} local protectedClasses: { [string]: number } = {}