Modules differ from libs structurally: they have no consumable public API (their hooks like M.update are engine-contract, documented in the engine spec, not in the module). The README convention for modules replaces the ## API section with ## Controls + ## Demonstrates. This adds tier=module validation in validate_readme_structure with MUST-sections H1, Abstract via Module-ID badge, Topology + Topology- Block markers, Controls, Demonstrates, References. Order-check ensures Demonstrates precedes References (no API exists to check). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
215 lines
8.8 KiB
Lua
215 lines
8.8 KiB
Lua
-- 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.<name> public; M._<name> 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.<namespace>.<func> 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 "module" enforces: H1, Abstract via Module-ID badge, Topology + Topology-Block,
|
|
-- Controls, Demonstrates, References. Modules have no consumable public API
|
|
-- (engine-hooks are documented in the engine spec), so ## API is replaced by
|
|
-- ## Controls + ## Demonstrates.
|
|
-- 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
|
|
|
|
local missing = {}
|
|
local checks
|
|
|
|
if tier == "module" then
|
|
-- Module-tier: no ## API (engine-hooks aren't a consumable surface);
|
|
-- Badges identifies the module via **Module-ID:**; Controls + Demonstrates
|
|
-- are the module-specific README sections.
|
|
checks = {
|
|
{ name = "H1", pattern = "^#%s+%S" },
|
|
{ name = "Badges", pattern = "\n%*%*Module%-ID:%*%*%s*" },
|
|
{ name = "Topology", pattern = "\n##%s+Topology[%s\n]" },
|
|
{ name = "Topology-Block", pattern = "<!%-%-%s*topology:start" },
|
|
{ name = "Controls", pattern = "\n##%s+Controls[%s\n]" },
|
|
{ name = "Demonstrates", pattern = "\n##%s+Demonstrates[%s\n]" },
|
|
{ name = "References", pattern = "\n##%s+References[%s\n]" },
|
|
}
|
|
else
|
|
-- Both "core" and "engine" tiers use the same MUST-sections list.
|
|
-- Difference is handled upstream (engine tier skips parse_lua_surface etc.).
|
|
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 = "<!%-%-%s*topology:start" },
|
|
{ name = "API", pattern = "\n##%s+API[%s\n]" },
|
|
{ name = "References", pattern = "\n##%s+References[%s\n]" },
|
|
}
|
|
-- Engine-tier: relax the Badges check (engine uses "Version", "License" etc.
|
|
-- instead of a "Lib-ID:" prefix — engine is not a lib).
|
|
if tier == "engine" then
|
|
checks[2] = { name = "Badges", pattern = "\n%*%*[%w%-]+:%*%*" }
|
|
end
|
|
end
|
|
|
|
for _, c in ipairs(checks) do
|
|
if not string.find(markdown_string, c.pattern) then
|
|
table.insert(missing, c.name)
|
|
end
|
|
end
|
|
|
|
-- Order check: for module tier Demonstrates must precede References
|
|
-- (no API exists to check); for non-module tiers API must precede References.
|
|
local order_ok
|
|
if tier == "module" then
|
|
local dem_pos = string.find(markdown_string, "\n##%s+Demonstrates[%s\n]")
|
|
local ref_pos = string.find(markdown_string, "\n##%s+References[%s\n]")
|
|
order_ok = (dem_pos and ref_pos and dem_pos < ref_pos) or (not dem_pos and not ref_pos)
|
|
else
|
|
local api_pos = string.find(markdown_string, "\n##%s+API[%s\n]")
|
|
local ref_pos = string.find(markdown_string, "\n##%s+References[%s\n]")
|
|
order_ok = (api_pos and ref_pos and api_pos < ref_pos) or (not api_pos and not ref_pos)
|
|
end
|
|
|
|
return { missing_sections = missing, section_order_ok = order_ok }
|
|
end
|
|
|
|
return M
|