-- lib-core.api-discovery — Pure-Lua surface-discovery + README-parsing. -- See: meta/docs/superpowers/specs/2026-05-16-api-doc-convention-design.md local M = {} -- Extracts public/private surface from Lua source code. -- Convention: M. public; M._ private. -- Supports both explicit assignment (`M.foo = function`) and syntax sugar -- (`function M.foo`) — Lua treats them semantically identical. -- Returns: { public = ["foo","bar",...], private = ["_baz",...] } function M.parse_lua_surface(source_string) -- Strip Lua line-comments (-- to end-of-line) before pattern matching. -- This prevents false-positive matches inside commented-out forward-compat -- stubs (e.g. DEPRECATED-MVP placeholders). -- Block comments (--[[...]]) are not handled; not used in Sporel-lib code. -- Caveat: a literal "--" inside a string would also be stripped; acceptable -- for the lint-tool's purpose (false-negatives in pathological string cases -- are preferable to false-positives on commented stubs). local stripped = string.gsub(source_string, "%-%-[^\n]*", "") local public = {} local private = {} local seen = {} local function classify(name) if seen[name] then return end seen[name] = true if string.sub(name, 1, 1) == "_" then table.insert(private, name) else table.insert(public, name) end end -- Form 1: M.foo = function(...) for name in string.gmatch(stripped, "M%.([_%w]+)%s*=%s*function") do classify(name) end -- Form 2: function M.foo(...) for name in string.gmatch(stripped, "function%s+M%.([_%w]+)") do classify(name) end return { public = public, private = private } end -- Extracts documented function-names from README's "## API" section. -- Parses H3-Headers like "### `input.bind(action_name, keys)`" → "bind". -- Convention: H3 header opens with backtick, function-name follows after optional namespace-dot. -- Returns: { documented = ["bind","unbind",...] } function M.parse_readme_api(markdown_string) local documented = {} local seen = {} -- Find "## API" section start (allow trailing whitespace/content) local api_start = string.find(markdown_string, "\n## API[%s\n]") if not api_start then return { documented = documented } end -- Find next H2 (terminate API section) local api_end = string.find(markdown_string, "\n## ", api_start + 5) local section = string.sub(markdown_string, api_start, api_end or #markdown_string) -- Two-pass matching to handle both namespace-prefixed and namespace-less -- function-names in H3 headers. -- Pattern 1: namespaced — "### `ns.func(...)`" -> capture "func" for name in string.gmatch(section, "###%s+`[%w_]+%.([_%w]+)") do if not seen[name] then seen[name] = true table.insert(documented, name) end end -- Pattern 2: namespace-less — "### `func(...)`" -> capture "func" -- The seen-set prevents re-matching names already captured by pattern 1. for name in string.gmatch(section, "###%s+`([_%w]+)%s*[%(`]") do if not seen[name] then seen[name] = true table.insert(documented, name) end end return { documented = documented } end -- Diffs code-surface vs README-doc. -- Compares surface.public (set) vs readme.documented (set). -- Returns: { missing_docs = [...], stale_docs = [...] } function M.diff_surface(surface, readme) local public_set = {} for _, name in ipairs(surface.public) do public_set[name] = true end local doc_set = {} for _, name in ipairs(readme.documented) do doc_set[name] = true end local missing = {} for name in pairs(public_set) do if not doc_set[name] then table.insert(missing, name) end end local stale = {} for name in pairs(doc_set) do if not public_set[name] then table.insert(stale, name) end end table.sort(missing); table.sort(stale) return { missing_docs = missing, stale_docs = stale } end -- Extracts unique engine.. calls from Lua source. -- Returns: array of unique strings, sorted. function M.grep_engine_calls(source_string) local seen = {} for call in string.gmatch(source_string, "(engine%.[_%w]+%.[_%w]+)") do seen[call] = true end local out = {} for call in pairs(seen) do table.insert(out, call) end table.sort(out) return out end -- Generates mermaid topology-block from manifest + engine_calls. -- engine_calls: array from grep_engine_calls (presence determines engine-node). -- Returns: mermaid-source-string (no markers - caller wraps). function M.generate_topology_block(manifest, engine_calls) local lines = {"graph LR"} local self_node = string.format(' this["%s"]', manifest.id) table.insert(lines, self_node) if manifest.deps then for _, dep in ipairs(manifest.deps) do local dep_var = string.gsub(dep.id, "[%-%.]", "_") table.insert(lines, string.format(' %s["%s"]', dep_var, dep.id)) table.insert(lines, string.format(' this --> %s', dep_var)) end end if engine_calls and #engine_calls > 0 then table.insert(lines, ' engine["engine.*"]') table.insert(lines, ' this --> engine') end return table.concat(lines, "\n") end -- Validates README structure against MUST-sections for the given tier. -- Tier "core" enforces: H1, Abstract, Badges, Topology, Topology-Block, API, References. -- Tier "engine" enforces the same MUST-sections, but the Badges check accepts -- any bold key:value line (engine README has no Lib-ID; instead Version/License). -- Tier "community" enforces nothing (returns empty result). -- Returns: { missing_sections = [...], section_order_ok = bool } function M.validate_readme_structure(markdown_string, tier) if tier == "community" then return { missing_sections = {}, section_order_ok = true } end -- Both "core" and "engine" tiers use the same MUST-sections list. -- Difference is handled upstream (engine tier skips parse_lua_surface etc.). local missing = {} local checks = { { name = "H1", pattern = "^#%s+%S" }, { name = "Badges", pattern = "\n%*%*Lib%-ID:%*%*%s*lib%-" }, { name = "Topology", pattern = "\n##%s+Topology[%s\n]" }, { name = "Topology-Block", pattern = "