local M = {}
local FAKE_LABELS = {
["Usage"] = true,
["Args"] = true,
["Arguments"] = true,
["Parameters"] = true,
["Params"] = true,
["Returns"] = true,
["Return"] = true,
["Example"] = true,
["Examples"] = true,
["Notes"] = true,
["Note"] = true,
["See"] = true,
["See also"] = true,
}
local function split_lines(s)
local lines = {}
local i = 1
local len = #s
while i <= len do
local j = s:find("\n", i, true)
if not j then
lines[#lines + 1] = s:sub(i)
break
end
lines[#lines + 1] = s:sub(i, j - 1)
i = j + 1
end
return lines
end
local function has_parameters_section(sections)
for i = 1, #sections do
if sections[i].heading == "Parameters" then
return true
end
end
return false
end
local function has_result_section(sections)
for i = 1, #sections do
if sections[i].heading == "Result" then
return true
end
end
return false
end
function M.check(pkg_info, docstring, pkg_dir)
local out = {}
local id = pkg_info.identity
local nar = pkg_info.narrative
local shp = pkg_info.shape
if not id.name or id.name == "" then
out[#out + 1] = {
severity = "error",
code = "E_META_MISSING_NAME",
msg = "meta.name is required",
}
end
if not id.version or id.version == "" then
out[#out + 1] = {
severity = "error",
code = "E_META_MISSING_VERSION",
msg = "meta.version is required",
}
end
if not id.description or id.description == "" then
out[#out + 1] = {
severity = "error",
code = "E_META_MISSING_DESCRIPTION",
msg = "meta.description is required",
}
end
if not id.category or id.category == "" then
out[#out + 1] = {
severity = "error",
code = "E_META_MISSING_CATEGORY",
msg = "meta.category is required",
}
end
if pkg_dir and id.name and id.name ~= "" and id.name ~= pkg_dir then
out[#out + 1] = {
severity = "error",
code = "E_NAME_MISMATCH",
msg = string.format(
"meta.name='%s' does not match pkg directory '%s'",
id.name,
pkg_dir
),
}
end
if id.description and id.description:find("\n", 1, true) then
out[#out + 1] = {
severity = "warning",
code = "W_DESCRIPTION_MULTILINE",
msg = "meta.description contains a newline (keep it single-line)",
}
end
if id.legacy_m_version then
out[#out + 1] = {
severity = "warning",
code = "W_META_LEGACY_M_VERSION",
msg = "M.VERSION top-level field detected; canonical form uses "
.. "M.meta.version only (pkg-author-conventions.md §2.1). "
.. "Safe to remove if no external reference.",
}
end
local in_fence = false
local raw_lines = split_lines(docstring or "")
for i = 1, #raw_lines do
local line = raw_lines[i]
if line:match("^```") then
in_fence = not in_fence
elseif not in_fence then
if line:sub(1, 2) == "# " then
out[#out + 1] = {
severity = "error",
code = "E_H1_IN_DOCSTRING",
msg = "H1 is reserved for the generator; drop `# ` prefix",
line = i,
}
end
local label = line:match("^([%w][%w ]*):%s*$")
if label and FAKE_LABELS[label] then
out[#out + 1] = {
severity = "warning",
code = "W_FAKE_LABEL",
msg = string.format(
"line looks like a section label; promote to '## %s'",
label
),
line = i,
}
end
end
end
if (nar.summary == nil or nar.summary == "") and #nar.sections == 0 then
out[#out + 1] = {
severity = "warning",
code = "W_EMPTY_NARRATIVE",
msg = "docstring has no summary and no sections",
}
end
if shp.input ~= nil and has_parameters_section(nar.sections) then
out[#out + 1] = {
severity = "error",
code = "E_PARAMETERS_CONFLICT",
msg = "spec.entries.run.input is declared AND docstring has a ## "
.. "Parameters section; remove the docstring section (shape is the SSoT)",
}
end
if shp.result ~= nil and has_result_section(nar.sections) then
out[#out + 1] = {
severity = "error",
code = "E_RESULT_CONFLICT",
msg = "spec.entries.run.result is declared AND docstring has a ## "
.. "Result section; remove the docstring section (shape is the SSoT)",
}
end
return { violations = out }
end
function M.format(pkg_name, violations)
if #violations == 0 then
return ""
end
local parts = { string.format("# %s", pkg_name) }
for i = 1, #violations do
local v = violations[i]
local tag = v.severity == "error" and "ERROR" or "warn "
local loc = v.line and string.format(" (line %d)", v.line) or ""
parts[#parts + 1] = string.format(" %s %s%s: %s", tag, v.code, loc, v.msg)
end
return table.concat(parts, "\n")
end
function M.errors(violations)
local out = {}
for i = 1, #violations do
if violations[i].severity == "error" then
out[#out + 1] = violations[i]
end
end
return out
end
M._internal = {
FAKE_LABELS = FAKE_LABELS,
split_lines = split_lines,
}
return M