OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown / lib / lint.js

122 lines · javascript ref:
// 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 }