// lint — library.md §9. "Every projection defect so far has been diagnosed by reading daemon logs.
// This makes it a one-liner against a file, offline."
//
// The logic lives here rather than in bin/ for two reasons: it is the part worth testing, and lib/
// must stay free of Node builtins so the peer can import it under Bare (§3). bin/ is a thin shell.
//
// SEVERITY IS PART OF THE CONTRACT, not decoration:
// error — the record cannot be published. §6.2 fails closed; publishing anyway is a privacy or
// correctness bug, not a stylistic one.
// warn — it publishes, and something is lost. §5.3's honesty requirement made mechanical.
// info — a fact the author should know that costs nothing.
// Conflating them is how "it lints clean" comes to mean nothing.
import { parse } from './record.js'
import { strip } from './strip.js'
import { maxReach, NETWORKS } from './reach.js'
// §5 of library.md pins the directive set. An unknown NAME is legal — §4.8 yields its contents — so
// this is info, not an error. The set is pinned so that "did you mean :::spoiler" is answerable.
const KNOWN_DIRECTIVES = ['spoiler', 'cw', 'details']
const DIRECTIVE_RE = /^:::([A-Za-z][\w-]*)/gm
const BARE_MENTION_RE = /(^|\s)@[A-Za-z0-9][\w.-]*/g
const NON_HTTP_LINK_RE = /\[[^\]]*\]\((?!https?:\/\/|favela:\/\/|did:)[^)]+\)/g
const issue = (code, severity, spec, message, extra) =>
Object.assign({ code, severity, spec, message }, extra || {})
/**
* lint(source, { roomType } = {}) -> { ok, publishable, issues, text, facets }
*
* TOTAL — never throws. `roomType` is optional: without it no claim is made about reach, because
* the room is what determines the ceiling (SOCIAL-MODEL §4) and a CLI reading a loose file cannot
* know which room it belongs to. Guessing would be worse than declining.
*/
export function lint (source, opts = {}) {
const issues = []
const src = String(source == null ? '' : source)
const rec = parse(src)
for (const e of rec.errors) {
issues.push(issue(e.code, 'error', 'library §6.2', e.message))
}
const fm = rec.frontmatter || {}
// reach: unknown networks are named rather than dropped. A silently ignored network is
// indistinguishable from one that failed to deliver (record-vocabulary §5.3).
const reach = Array.isArray(fm.reach) ? fm.reach : []
for (const n of reach) {
if (!NETWORKS.includes(n)) {
issues.push(issue('unknown-network', 'error', 'record-vocabulary §5.1',
`reach names "${n}", which is not a network this build knows`, { network: n }))
}
}
// The ceiling is the ROOM's, so this is only checkable when the caller supplies one.
if (typeof opts.roomType === 'string') {
const ceiling = maxReach({ roomType: opts.roomType, verb: opts.verb || 'post' })
for (const n of reach) {
if (NETWORKS.includes(n) && !ceiling.has(n)) {
issues.push(issue('reach-exceeds-room', 'error', 'library §6.1',
`reach names "${n}" but a ${opts.roomType} room cannot reach it`, { network: n }))
}
}
}
let text = ''
let facets = []
try {
const s = strip(rec.body)
text = s.text
facets = s.facets
} catch (err) {
issues.push(issue('strip-failed', 'error', 'strip §2', 'strip threw, which it must never do: ' + err.message))
}
// §5 — the spoiler asymmetry, which is the thesis in one worked example: the SAME directive is
// lossless to the fediverse (summary + sensitive) and lossy to bsky (revealed as inner text), and
// today nothing tells the author.
const names = []
let m
DIRECTIVE_RE.lastIndex = 0
while ((m = DIRECTIVE_RE.exec(rec.body)) !== null) names.push(m[1])
for (const name of names) {
if (name === 'spoiler' || name === 'cw') {
issues.push(issue('spoiler-revealed-on-bsky', 'warn', 'library §5',
`:::${name} projects as its inner text on bsky — the post arrives un-concealed there`))
} else if (!KNOWN_DIRECTIVES.includes(name)) {
issues.push(issue('unknown-directive', 'info', 'strip §4.8',
`:::${name} is not in the pinned set — its contents are kept and the fence is dropped`,
{ name }))
}
}
// §3.4 — a mention resolves or is omitted, never guessed. A bare @name is plain text, which is
// correct and also probably not what the author meant.
BARE_MENTION_RE.lastIndex = 0
if (BARE_MENTION_RE.test(rec.body)) {
issues.push(issue('unresolved-mention', 'info', 'strip §3.4',
'a bare @name is plain text with no facet — a mention needs a favela:// target'))
}
// §3.2 — a non-http target emits the label as plain text with NO facet. Silently.
NON_HTTP_LINK_RE.lastIndex = 0
if (NON_HTTP_LINK_RE.test(rec.body)) {
issues.push(issue('link-refused', 'warn', 'strip §3.2',
'a link target that is not http/https/favela/did emits its label as plain text, with no link'))
}
// §3.5 — an image-only post strips to empty text, and that is VALID. Worth saying, not flagging.
if (text.length === 0 && rec.body.trim().length > 0) {
issues.push(issue('empty-text', 'info', 'strip §3.5',
'this record strips to empty text — valid for an image-only post'))
}
const ok = !issues.some((i) => i.severity === 'error')
return { ok, publishable: rec.publishable, issues, text, facets }
}
export default { lint }