`ludic help` ended with a section titled "contributing to the toolchain itself", listing bootstrap, reseed, docs-gen and release tasks. None of that is available to someone who installed the language — those tasks need the repository — so the shipped tool was advertising work its user cannot do, in a namespace they have to read past to find `new` and `run`. The tasks move to a second program, dev.ludic -> bin/ludic-dev, built from a checkout and excluded from every release artifact. `ludic` keeps the project and package commands and nothing else; `ludic dev …` now explains where the tasks went instead of failing as an unknown command. What this shook out: the two programs share prelude/build/project/pkg, so the helpers each had accreted in whichever file first needed them — cc(), ensure_ludicc, the string functions, title_case, cmd_version — moved to where both can see them. The argument-shift indirection added for the `dev` namespace is gone with the namespace, so commands read argv directly again. `ludic-dev test` asserts the split rather than trusting it: the staged install must build a project, and `ludic dev build` there must fail while naming ludic-dev. install.sh keeps building older tags, whose bootstrap goes through main.ludic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
9.3 KiB
Text
291 lines
9.3 KiB
Text
# docgen_check.ludic — `ludic-dev docs-check [DIR]`: the coverage / integrity guard over
|
|
# a generated docs site, ported from tools/docgen/check.py. Fails (exit 1) if the
|
|
# pages contract is broken, an inventory symbol lacks a source file or page, a
|
|
# token is documented on two pages, a namespace/section is split across dirs, or
|
|
# a highlighter link target was not generated. Thin (un-expanded) symbols warn.
|
|
|
|
# a key -> set-of-values bucket (for token/dir conflict detection)
|
|
property Bucket { k1: pointer = "", k2: pointer = "", vals: []pointer }
|
|
|
|
function bucket_get(bs: []Bucket, k1: pointer, k2: pointer) -> Bucket {
|
|
var i = 0
|
|
while i < len(bs) {
|
|
if bs[i].k1 == k1 and bs[i].k2 == k2 { return bs[i] }
|
|
i += 1
|
|
}
|
|
let b = new Bucket
|
|
b.k1 = k1
|
|
b.k2 = k2
|
|
b.vals = new []pointer
|
|
push(bs, b)
|
|
return b
|
|
}
|
|
function bucket_add(bs: []Bucket, k1: pointer, k2: pointer, v: pointer) -> void {
|
|
let b = bucket_get(bs, k1, k2)
|
|
var i = 0
|
|
while i < len(b.vals) { if b.vals[i] == v { return }; i += 1 }
|
|
push(b.vals, v)
|
|
}
|
|
# ", ".join(sorted(vals))
|
|
function join_sorted(vals: []pointer) -> pointer {
|
|
strs_sort(vals)
|
|
let b = sb_new()
|
|
var i = 0
|
|
while i < len(vals) {
|
|
if i > 0 { sb_puts(b, ", ") }
|
|
sb_puts(b, vals[i])
|
|
i += 1
|
|
}
|
|
return sb_str(b)
|
|
}
|
|
|
|
# parse_front: like parse_doc but body is stripped on both ends (check.py)
|
|
function parse_front(path: pointer) -> Doc {
|
|
let d = new Doc
|
|
d.m = meta_new()
|
|
let text = read_file(path)
|
|
if text == null { d.body = ""; return d }
|
|
d.body = text
|
|
if not s_starts(text, "---") { return d }
|
|
let end = s_index(text, "\n---", 3)
|
|
if end < 0 { return d }
|
|
let fm = sslice(text, 3, end)
|
|
let fn2 = slen(fm)
|
|
var j = 0
|
|
while j < fn2 {
|
|
let ln = line_at(fm, j)
|
|
j = j + slen(ln) + 1
|
|
let t = s_trim(ln)
|
|
if slen(t) == 0 { continue }
|
|
let c = s_index(ln, ":", 0)
|
|
if c < 0 { continue }
|
|
meta_put(d.m, s_trim(sslice(ln, 0, c)), s_trim(sslice(ln, c + 1, slen(ln))))
|
|
}
|
|
d.body = s_trim(sslice(text, end + 4, slen(text)))
|
|
return d
|
|
}
|
|
|
|
# count Unicode codepoints (a byte is a lead unless (b & 0xC0) == 0x80)
|
|
function cp_len(s: pointer) -> int {
|
|
let n = slen(s)
|
|
var c = 0
|
|
var i = 0
|
|
while i < n { if (s[i] & 192) != 128 { c += 1 }; i += 1 }
|
|
return c
|
|
}
|
|
function replace_all(hay: pointer, needle: pointer, repl: pointer) -> pointer {
|
|
if slen(needle) == 0 { return hay }
|
|
let b = sb_new()
|
|
let nl = slen(needle)
|
|
let n = slen(hay)
|
|
var i = 0
|
|
while i < n {
|
|
if s_starts_at(hay, i, needle) { sb_puts(b, repl); i += nl }
|
|
else { sb_putc(b, hay[i]); i += 1 }
|
|
}
|
|
return sb_str(b)
|
|
}
|
|
# strip a filename's trailing ".md" -> the fallback id (fn[:-3])
|
|
function drop_md(fn: pointer) -> pointer {
|
|
let n = slen(fn)
|
|
if n >= 3 { return sslice(fn, 0, n - 3) }
|
|
return fn
|
|
}
|
|
|
|
function cmd_docs_check() -> int {
|
|
var site = "build/pages"
|
|
if arg_count() >= 3 { site = arg(2) }
|
|
let problems = new []pointer
|
|
let warnings = new []pointer
|
|
|
|
# 1) pages contract
|
|
if not file_exists(`{site}/index.html`) { push(problems, "missing required file: index.html") }
|
|
if not file_exists(`{site}/api.html`) { push(problems, "missing required file: api.html") }
|
|
if not file_exists(`{site}/.nojekyll`) { push(problems, "missing required file: .nojekyll") }
|
|
# the landing page tells people to curl this; a site without it publishes a
|
|
# broken install command
|
|
if not file_exists(`{site}/install.sh`) { push(problems, "missing required file: install.sh") }
|
|
|
|
# 2) coverage + thin detection over docs/language/**
|
|
let id2src = meta_new()
|
|
let thin = new []pointer
|
|
let cats = list_sorted("docs/language")
|
|
var ci = 0
|
|
while ci < len(cats) {
|
|
let cat = cats[ci]
|
|
ci += 1
|
|
let cdir = "docs/language/" + cat
|
|
if not is_dir(cdir) { continue }
|
|
let files = list_sorted(cdir)
|
|
var fi = 0
|
|
while fi < len(files) {
|
|
let fn = files[fi]
|
|
fi += 1
|
|
if not s_ends(fn, ".md") { continue }
|
|
if fn == "_section.md" { continue }
|
|
let d = parse_front(cdir + "/" + fn)
|
|
let sid = meta_get(d.m, "id", drop_md(fn))
|
|
meta_put(id2src, sid, cdir + "/" + fn)
|
|
# thin: drop a Parameters: block (to end, case-insensitive) + the tip line
|
|
let tipline = meta_get(d.m, "tip", "")
|
|
let low = lower_ascii(d.body)
|
|
var btext = d.body
|
|
let pp = s_index(low, "parameters:", 0)
|
|
if pp >= 0 { btext = sslice(d.body, 0, pp) }
|
|
btext = s_trim(btext)
|
|
if slen(tipline) > 0 { btext = s_trim(replace_all(btext, tipline, "")) }
|
|
if cp_len(btext) < 40 { push(thin, sid) }
|
|
}
|
|
}
|
|
# coverage against the authoritative inventory
|
|
let invtext = read_file("tools/docgen/inventory.json")
|
|
if invtext != null {
|
|
let inv = json_parse(invtext)
|
|
if inv.t == JV_OBJ {
|
|
var k = 0
|
|
while k < len(inv.keys) {
|
|
let cat = inv.keys[k]
|
|
let ids = inv.kids[k]
|
|
k += 1
|
|
if ids.t != JV_ARR { continue }
|
|
var m = 0
|
|
while m < len(ids.kids) {
|
|
let sid = ids.kids[m].s
|
|
m += 1
|
|
if meta_get(id2src, sid, "") == "" { push(problems, `no source file for inventory symbol: {sid} ({cat})`) }
|
|
else if not file_exists(`{site}/{sid}.html`) { push(problems, `no generated page for symbol: {sid}.html`) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# 2b/2c) duplicate tokens + one-dir-per-namespace/section
|
|
let tok2ids = new []Bucket # k1=kind, k2=tok -> ids
|
|
let ns2dirs = new []Bucket # k1="", k2=ns -> dirs
|
|
let id2dirs = new []Bucket # k1="", k2=section id -> dirs
|
|
let title2dirs = new []Bucket # k1="", k2=lower(title) -> dirs
|
|
ci = 0
|
|
while ci < len(cats) {
|
|
let cat = cats[ci]
|
|
ci += 1
|
|
let cdir = "docs/language/" + cat
|
|
if not is_dir(cdir) { continue }
|
|
let secp = cdir + "/_section.md"
|
|
if file_exists(secp) {
|
|
let sd = parse_front(secp)
|
|
bucket_add(id2dirs, "", meta_get(sd.m, "id", cat), cat)
|
|
bucket_add(title2dirs, "", lower_ascii(s_trim(meta_get(sd.m, "title", ""))), cat)
|
|
}
|
|
let files = list_sorted(cdir)
|
|
var fi = 0
|
|
while fi < len(files) {
|
|
let fn = files[fi]
|
|
fi += 1
|
|
if not s_ends(fn, ".md") { continue }
|
|
if fn == "_section.md" { continue }
|
|
let d = parse_front(cdir + "/" + fn)
|
|
let kind = meta_get(d.m, "kind", "")
|
|
let id = meta_get(d.m, "id", "")
|
|
let toks = split_ws(meta_get(d.m, "tokens", ""))
|
|
var ti = 0
|
|
while ti < len(toks) { bucket_add(tok2ids, kind, toks[ti], id); ti += 1 }
|
|
let ns = meta_get(d.m, "ns", "")
|
|
if slen(ns) > 0 { bucket_add(ns2dirs, "", ns, cat) }
|
|
}
|
|
}
|
|
var bi = 0
|
|
while bi < len(tok2ids) {
|
|
let b = tok2ids[bi]
|
|
bi += 1
|
|
if len(b.vals) > 1 { push(problems, `token '{b.k2}' ({b.k1}) documented on multiple pages: {join_sorted(b.vals)}`) }
|
|
}
|
|
bi = 0
|
|
while bi < len(ns2dirs) {
|
|
let b = ns2dirs[bi]
|
|
bi += 1
|
|
if len(b.vals) > 1 { push(problems, `namespace '{b.k2}' documented from multiple dirs: {join_sorted(b.vals)}`) }
|
|
}
|
|
bi = 0
|
|
while bi < len(id2dirs) {
|
|
let b = id2dirs[bi]
|
|
bi += 1
|
|
if len(b.vals) > 1 { push(problems, `section id '{b.k2}' declared by multiple dirs: {join_sorted(b.vals)}`) }
|
|
}
|
|
bi = 0
|
|
while bi < len(title2dirs) {
|
|
let b = title2dirs[bi]
|
|
bi += 1
|
|
if slen(b.k2) > 0 and len(b.vals) > 1 { push(problems, `section title '{b.k2}' shared by multiple dirs: {join_sorted(b.vals)}`) }
|
|
}
|
|
|
|
# 3) thin warning
|
|
if len(thin) > 0 {
|
|
strs_sort(thin)
|
|
let b = sb_new()
|
|
var i = 0
|
|
while i < len(thin) and i < 12 {
|
|
if i > 0 { sb_puts(b, ", ") }
|
|
sb_puts(b, thin[i])
|
|
i += 1
|
|
}
|
|
var tail = ""
|
|
if len(thin) > 12 { tail = " …" }
|
|
push(warnings, `{string(len(thin))} symbols still have only seed text: {sb_str(b)}{tail}`)
|
|
}
|
|
|
|
# 4) highlighter targets exist
|
|
let sjtext = read_file(`{site}/symbols.json`)
|
|
if sjtext != null {
|
|
let sj = json_parse(sjtext)
|
|
let hl = j_get(sj, "highlight")
|
|
let targets = new []pointer
|
|
collect_targets_str(j_get(hl, "keywords"), targets)
|
|
collect_targets_str(j_get(hl, "types"), targets)
|
|
collect_targets_str(j_get(hl, "phases"), targets)
|
|
collect_targets_str(j_get(hl, "annotations"), targets)
|
|
collect_targets_id(j_get(hl, "builtins"), targets)
|
|
collect_targets_id(j_get(hl, "nsmethods"), targets)
|
|
collect_targets_str(j_get(hl, "namespaces"), targets)
|
|
strs_sort(targets)
|
|
var i = 0
|
|
while i < len(targets) {
|
|
let tgt = targets[i]
|
|
i += 1
|
|
if not file_exists(`{site}/{tgt}.html`) { push(problems, `highlighter links to {tgt}.html but it was not generated`) }
|
|
}
|
|
}
|
|
|
|
# report
|
|
var wi = 0
|
|
while wi < len(warnings) { print(` warning: {warnings[wi]}`); wi += 1 }
|
|
if len(problems) > 0 {
|
|
print("docs check FAILED:")
|
|
var pi = 0
|
|
while pi < len(problems) { print(` - {problems[pi]}`); pi += 1 }
|
|
return 1
|
|
}
|
|
print(`docs check OK: {site} ({string(len(id2src.keys))} symbols documented)`)
|
|
return 0
|
|
}
|
|
|
|
# add every string value of an object's members (set semantics via the caller)
|
|
function collect_targets_str(o: JVal, out: []pointer) -> void {
|
|
if o.t != JV_OBJ { return }
|
|
var i = 0
|
|
while i < len(o.kids) {
|
|
let v = o.kids[i]
|
|
i += 1
|
|
if v.t == JV_STR { set_add(out, v.s) }
|
|
}
|
|
}
|
|
# add each member value's ["id"] (builtins / nsmethods)
|
|
function collect_targets_id(o: JVal, out: []pointer) -> void {
|
|
if o.t != JV_OBJ { return }
|
|
var i = 0
|
|
while i < len(o.kids) {
|
|
let v = o.kids[i]
|
|
i += 1
|
|
let id = j_get(v, "id")
|
|
if id.t == JV_STR { set_add(out, id.s) }
|
|
}
|
|
}
|