-- Pure-Lua structural linter over services/. Validates that every service -- directory has the files its `type` requires, that every -- dependencies.d/contents.d entry names a real sibling service, and that -- producer-for/consumer-for pairs are mutually consistent. -- -- No lfs/posix available, so directory listing shells out to `dir /b` on -- Windows or `ls -1a` on Unix. -- -- Usage: lua55 tools/lint.lua local function is_windows() return package.config:sub(1, 1) == "\\" end local function list_dir(path) local cmd if is_windows() then cmd = 'dir /b "' .. path:gsub("/", "\\") .. '" 2>nul' else cmd = 'ls -1a "' .. path .. '" 2>/dev/null' end local entries = {} local p = io.popen(cmd) if not p then return entries end for line in p:lines() do if line ~= "." and line ~= ".." and line ~= "" then table.insert(entries, line) end end p:close() return entries end local function read_file(path) local f = io.open(path, "r") if not f then return nil end local content = f:read("*a") f:close() return content end local function trim(s) return s:match("^%s*(.-)%s*$") end local script_dir = arg[0]:match("^(.*)[/\\][^/\\]*$") or "." local repo_root = script_dir .. "/.." local services_dir = repo_root .. "/services" local errors = {} local function fail(fmt, ...) table.insert(errors, string.format(fmt, ...)) end local names = list_dir(services_dir) local svc_type = {} for _, name in ipairs(names) do local dir = services_dir .. "/" .. name local t = read_file(dir .. "/type") if not t then fail("%s: missing 'type' file", name) else t = trim(t) svc_type[name] = t if t ~= "oneshot" and t ~= "longrun" and t ~= "bundle" then fail("%s: type must be oneshot|longrun|bundle, got %q", name, t) elseif t == "oneshot" and not read_file(dir .. "/up") then fail("%s: oneshot missing 'up'", name) elseif t == "longrun" and not read_file(dir .. "/run") then fail("%s: longrun missing 'run'", name) elseif t == "bundle" and #list_dir(dir .. "/contents.d") == 0 then fail("%s: bundle has empty/missing contents.d", name) end end end local function exists(name) return svc_type[name] ~= nil end for _, name in ipairs(names) do local dir = services_dir .. "/" .. name for _, dep in ipairs(list_dir(dir .. "/dependencies.d")) do if not exists(dep) then fail("%s: dependencies.d references unknown service %q", name, dep) end end for _, member in ipairs(list_dir(dir .. "/contents.d")) do if not exists(member) then fail("%s: contents.d references unknown service %q", name, member) end end local producer = read_file(dir .. "/producer-for") if producer then producer = trim(producer) if not exists(producer) then fail("%s: producer-for references unknown service %q", name, producer) else local consumer = read_file(services_dir .. "/" .. producer .. "/consumer-for") if not consumer or trim(consumer) ~= name then fail("%s: producer-for %s but %s's consumer-for does not point back", name, producer, producer) end if not read_file(services_dir .. "/" .. producer .. "/pipeline-name") then fail("%s: logger %s missing pipeline-name", name, producer) end end end local nfd = read_file(dir .. "/notification-fd") if nfd and not trim(nfd):match("^%d+$") then fail("%s: notification-fd must be a bare integer, got %q", name, trim(nfd)) end end if #errors > 0 then for _, e in ipairs(errors) do io.stderr:write("LINT: " .. e .. "\n") end io.stderr:write(string.format("%d lint error(s)\n", #errors)) os.exit(1) end print(string.format("lint OK: %d service directories checked", #names))